From 01bae3f1d9bd5a98ee6e84bbabc30c374170eea2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:10:25 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=9A=9A=20Rename=20`xmd=20prompt`=20to?= =?UTF-8?q?=20`xmd=20plan`=20(#670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorship command is named for its result. `xmd plan ""` takes one Prompt and delivers the Plan it produced, and `xmd --help` describes it as "Create an executable Plan from a Prompt and review it before writing or running it." The rename carries the identities that name the command: the packaged root is `plan-command.md` and runs under ``, the constrained trusted-host assembly is the authorship profile, generated logical sessions are `xmd-plan:` under `~/.xmd/plan/sessions`, and the approved Plan runs under ``. Nothing about authorship policy, authority, property behavior, output mode, failure ordering, cancellation or teardown changes. The packaged Markdown document's only edits are its title, its one explanatory sentence and its four authored `` messages. `` remains the ordinary one-turn Agent component: its API, failure policy, `agent_prompt` records and the capitalized Prompt in authored instructions are untouched. The old spelling is not registered, aliased or kept as a tombstone. A token naming no command continues through the existing default-run grammar, and nothing reads, migrates or removes `~/.xmd/prompt`. --- .github/workflows/release.yml | 2 +- architecture.md | 10 +- deno.json | 2 +- packages/cli/src/agent-stack.ts | 4 +- ...rompt-profile.ts => authorship-profile.ts} | 68 ++-- packages/cli/src/cli.ts | 110 +++--- .../{prompt-command.md => plan-command.md} | 12 +- packages/cli/src/packaged-document.ts | 6 +- .../cli/src/{prompt-args.ts => plan-args.ts} | 37 +- packages/cli/src/{prompt.ts => plan.ts} | 75 ++-- packages/cli/src/props.ts | 2 +- packages/cli/src/report.ts | 2 +- packages/cli/src/syntax.ts | 2 +- packages/cli/tests/packaged-document.test.ts | 31 +- ...{prompt-args.test.ts => plan-args.test.ts} | 101 ++--- .../{prompt-cli.test.ts => plan-cli.test.ts} | 253 +++++++------ ....test.ts => plan-command-document.test.ts} | 22 +- .../tests/{prompt.test.ts => plan.test.ts} | 355 ++++++++++-------- .../{prompt-harness.ts => plan-harness.ts} | 42 +-- packages/cli/tests/syntax-cli.test.ts | 6 +- scripts/tests/cli-npm-bin.test.ts | 4 +- specs/acp-client-spec.md | 14 +- specs/executable-mdx-spec.md | 24 +- ...t-command-spec.md => plan-command-spec.md} | 94 ++--- specs/release-process-spec.md | 10 +- specs/root-document-props-spec.md | 12 +- 26 files changed, 696 insertions(+), 604 deletions(-) rename packages/cli/src/{prompt-profile.ts => authorship-profile.ts} (88%) rename packages/cli/src/documents/{prompt-command.md => plan-command.md} (93%) rename packages/cli/src/{prompt-args.ts => plan-args.ts} (91%) rename packages/cli/src/{prompt.ts => plan.ts} (88%) rename packages/cli/tests/{prompt-args.test.ts => plan-args.test.ts} (79%) rename packages/cli/tests/{prompt-cli.test.ts => plan-cli.test.ts} (77%) rename packages/cli/tests/{prompt-command-document.test.ts => plan-command-document.test.ts} (87%) rename packages/cli/tests/{prompt.test.ts => plan.test.ts} (80%) rename packages/cli/tests/support/{prompt-harness.ts => plan-harness.ts} (87%) rename specs/{prompt-command-spec.md => plan-command-spec.md} (89%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9cb72f0..1e61607e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,7 @@ jobs: --exclude-unused-npm \ --allow-all \ --include packages/code-review-agent \ - --include packages/cli/src/documents/prompt-command.md \ + --include packages/cli/src/documents/plan-command.md \ --target ${{ matrix.target }} \ --output dist/${{ matrix.artifact }} \ packages/cli/src/compiled.ts diff --git a/architecture.md b/architecture.md index e060a9dd..bcedef6e 100644 --- a/architecture.md +++ b/architecture.md @@ -33,10 +33,10 @@ Existing documents and code get aligned to this section retroactively. | Repository base | the optional Git revision from which one named Workspace Repository initializes its primary checkout | | pinned commit | the commit obtained by resolving a base once; it remains the workflow run's starting repository state even as the run creates descendant commits | | document target | an addressable static heading in a root document's own Markdown flow, named by the canonical path of heading labels that reaches it; selecting one executes the preamble, each ancestor's own content, and that heading's complete subtree | -| Prompt | a person's original request, in ordinary natural language. `xmd prompt` takes exactly one | -| Plan | the executable program produced from a Prompt: an Executable Markdown document combining readable prose that expresses the Prompt's intent with the components that carry it out, each placed beside the prose describing what it does. It begins with one descriptive level-one heading. A Plan is what `xmd prompt` approves and then delivers: printed to stdout by default, written to an `--output` file, and run only under `--run`. It is not a synonym for a workflow, a policy document or any executable Markdown file | -| prompt command document | the one exact checked-in first-party Markdown value root `xmd prompt` executes to convert a Prompt into a Plan. It implements that conversion and its review workflow — the Prompt wording, the draft and repair loops, the `` branches, human review, revision, approval, stopping, exhaustion and the final explanation turn — and returns the exact approved Plan source. Every Plan-producing turn in it states the complete Plan requirements for itself, so a replacement may add or correct a title rather than only carry one forward. It is not itself a Plan. Internal: no command-line option selects another one, and no repository component search can answer for it | -| prompt profile | the trusted-host assembly used only for the prompt command document: its fixed inputs, a constrained Agent provider, Elicitation, the fixed first-party components and the host-declared ``. It uses no repository component search and exposes no custom root, and the ceiling it establishes is not readable from the command line. Its working directory is one host-owned directory dedicated to the logical prompt session, keyed by the digest of that name, created empty and required to be empty on the way in. An explicitly named session's directory is durable, because continuation derives the same session identity from it; an invocation-unique default session's is scope-owned, claimed before it is created, and exactly one cleanup is attempted after profile teardown and before admission on every ending — the leaf removed non-recursively when it is still the empty directory that was handed over, and left as found with the command failing terminally when it has gained content or vanished. Where those directories live is a host dependency no caller or document selects | +| Prompt | a person's original request, in ordinary natural language. `xmd plan` takes exactly one | +| Plan | the executable program produced from a Prompt: an Executable Markdown document combining readable prose that expresses the Prompt's intent with the components that carry it out, each placed beside the prose describing what it does. It begins with one descriptive level-one heading. A Plan is what `xmd plan` approves and then delivers: printed to stdout by default, written to an `--output` file, and run only under `--run`. It is not a synonym for a workflow, a policy document or any executable Markdown file | +| plan command document | the one exact checked-in first-party Markdown value root `xmd plan` executes to convert a Prompt into a Plan. It implements that conversion and its review workflow — the Prompt wording, the draft and repair loops, the `` branches, human review, revision, approval, stopping, exhaustion and the final explanation turn — and returns the exact approved Plan source. Every Plan-producing turn in it states the complete Plan requirements for itself, so a replacement may add or correct a title rather than only carry one forward. It is not itself a Plan. Internal: no command-line option selects another one, and no repository component search can answer for it | +| authorship profile | the trusted-host assembly used only for the plan command document: its fixed inputs, a constrained Agent provider, Elicitation, the fixed first-party components and the host-declared ``. It uses no repository component search and exposes no custom root, and the ceiling it establishes is not readable from the command line. Its working directory is one host-owned directory dedicated to the logical session, keyed by the digest of that name, created empty and required to be empty on the way in. An explicitly named session's directory is durable, because continuation derives the same session identity from it; an invocation-unique default session's is scope-owned, claimed before it is created, and exactly one cleanup is attempted after profile teardown and before admission on every ending — the leaf removed non-recursively when it is still the empty directory that was handed over, and left as found with the command failing terminally when it has gained content or vanished. Where those directories live is a host dependency no caller or document selects | | expansion | one logical evaluation of an authored executable element within a document execution | | expansion ID | a deterministic identifier for one logical expansion; restoring or retrying that expansion preserves the ID, while a distinct evaluation requested by the document receives another | | Git capability | the contextual interface through which workflow infrastructure queries the Git repository associated with the current working directory | @@ -3545,7 +3545,7 @@ Status is measured against main. | --- | --- | --- | | `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset | built on the #632 stack | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | -| `xmd prompt` | turns one Prompt into a Plan and delivers it, by executing one root document — and, only under `--run`, a second — with a complete scope boundary between them. First the packaged prompt command document, under the internal `` identity, inside the prompt profile: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and one host-declared `` whose closed assessment answers `valid: false` for a defect the draft authored and raises for a defect the command line authored. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship sits outside durability: it runs on an invocation-owned in-memory stream that is never journaled, persisted, reused or replayed. Then, only after that execution and every provider, Prompt task and Elicitation resource inside it has torn down, the host validates the returned Plan again, resolves props for exactly those bytes, and then delivers the approved Plan where the caller asked: to stdout byte for byte by default, to an exclusively created `--output` path, and — only under `--run` — through the ordinary supplied-source path under the `` identity, one ordinary document with its own Agent provider, its own journal and ordinary run output, result and failure behavior. A journal exists only when `--run` begins, and the flags that configure only a run are refused in preflight without it | built on the #659 stack | +| `xmd plan` | turns one Prompt into a Plan and delivers it, by executing one root document — and, only under `--run`, a second — with a complete scope boundary between them. First the packaged plan command document, under the internal `` identity, inside the authorship profile: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and one host-declared `` whose closed assessment answers `valid: false` for a defect the draft authored and raises for a defect the command line authored. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship sits outside durability: it runs on an invocation-owned in-memory stream that is never journaled, persisted, reused or replayed. Then, only after that execution and every provider, Prompt task and Elicitation resource inside it has torn down, the host validates the returned Plan again, resolves props for exactly those bytes, and then delivers the approved Plan where the caller asked: to stdout byte for byte by default, to an exclusively created `--output` path, and — only under `--run` — through the ordinary supplied-source path under the `` identity, one ordinary document with its own Agent provider, its own journal and ordinary run output, result and failure behavior. A journal exists only when `--run` begins, and the flags that configure only a run are refused in preflight without it | built on the #659 stack | | `` / `printErrors(fn)` | prints failures | built on main | | `` | stops authored work with the sentence its author wrote, raised where it is written. An ordinary overridable core default — never structural, never reserved, so a repository `Fail.md` is chosen ahead of it — with a closed schema of one required non-empty `message` and **self-closing only**: a paired spelling never enters its body, and `as` is refused by the body itself because there is nothing to bind. Every refusal reports the invocation and happens before the authored message, so a document that never reached its decision is never reported as having made one. A valid invocation is the ordinary failure of a function component: an `Error` carrying the exact authored message, positioned at the opening tag, rendering nothing and binding nothing. It carries no `printErrors()` declaration, which is what leaves recovery to an authored `` region under ordinary text-root modes; a value body's `throw` is not replaced there, so the authored failure settles the body ahead of missing-`` settlement. No authority, context, provider, resource, module state or durable operation of its own: replay of a completed root restores the recorded outcome without re-expanding the body | built on the #659 stack | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | diff --git a/deno.json b/deno.json index 322b3d94..bd1b855a 100644 --- a/deno.json +++ b/deno.json @@ -57,7 +57,7 @@ "verify:clean": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/verify-clean.ts", "deps": "deno run --allow-all scripts/deps.ts", "deps:target": "deno run --allow-all scripts/deps-target.ts", - "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents/prompt-command.md --output dist/xmd packages/cli/src/compiled.ts", + "build": "deno task build:web && deno compile --node-modules-dir=none --cached-only --frozen --exclude-unused-npm --allow-all --include packages/code-review-agent --include packages/cli/src/documents/plan-command.md --output dist/xmd packages/cli/src/compiled.ts", "build:web": "deno run --allow-all --node-modules-dir=none --cached-only --frozen scripts/preflight.ts scripts/build-web-client.ts", "gen:publish-workflow": "deno run --allow-all packages/cli/src/deno.ts run scripts/gen-publish-workflow.md", "bump": "deno run -A scripts/bump-version.ts", diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 3aca46d7..353ec094 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -2,11 +2,11 @@ * The one Agent configuration an invocation resolves, and the two things a * host does with it (specs/acp-client-spec.md §Command-line configuration). * - * `xmd run` and `xmd prompt` take the same Agent, permission and provider + * `xmd run` and `xmd plan` take the same Agent, permission and provider * options, so they resolve them once, here, rather than each reading the flags * again. What they do with the result differs, and deliberately: a run installs * the registered provider into the Agent Api so a document may reach it, while - * `xmd prompt` hands the same answer to two consumers — the prompt profile, which + * `xmd plan` hands the same answer to two consumers — the authorship profile, which * takes the provider name and the default agent and nothing else, and the * approved document, which runs the ordinary run stack. * diff --git a/packages/cli/src/prompt-profile.ts b/packages/cli/src/authorship-profile.ts similarity index 88% rename from packages/cli/src/prompt-profile.ts rename to packages/cli/src/authorship-profile.ts index 822a9eed..7ff44649 100644 --- a/packages/cli/src/prompt-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -1,9 +1,9 @@ /** - * The prompt profile — the trusted-host assembly the prompt command document + * The authorship profile — the trusted-host assembly the plan command document * runs under, and the only thing that ever runs under it - * (specs/prompt-command-spec.md). + * (specs/plan-command-spec.md). * - * `xmd prompt` executes this root on every invocation, and a second one — the + * `xmd plan` executes this root on every invocation, and a second one — the * Plan it returns — only under `--run`, behind a complete scope boundary. This * module is the one that always happens. It supplies that document's inputs, a * constrained Agent provider, Elicitation, the fixed first-party components and @@ -52,25 +52,25 @@ import { API } from "@executablemd/runtime"; import { hostAcpDependencies } from "./agent-stack.ts"; import type { AgentStack } from "./agent-stack.ts"; -import { PROMPT_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; +import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; /** - * The identity the prompt command document runs under. + * The identity the plan command document runs under. * * Stable and internal: no path selects it, no include resolves it, and a - * position reading `(:12:1)` says the source is the CLI's own. + * position reading `(:12:1)` says the source is the CLI's own. */ -export const PROMPT_COMMAND_IDENTITY = ""; +export const PLAN_COMMAND_IDENTITY = ""; /** - * The permission mode the prompt command document runs under. + * The permission mode the plan command document runs under. * * Fixed rather than configured. The provider denies native requests privately, * so this is what an authored approval scope inside that document would * compose around if one existed — and the honest answer for a profile that * grants no native authority is the one that grants none. */ -const PROFILE_PERMISSION_MODE = "deny-all"; +const AUTHORSHIP_PERMISSION_MODE = "deny-all"; /** The closed answer the host gives about one candidate. */ export interface CandidateAssessment { @@ -79,8 +79,8 @@ export interface CandidateAssessment { diagnostics: Json; } -/** What the host supplies to one prompt command document execution. */ -export interface PromptProfile { +/** What the host supplies to one plan command document execution. */ +export interface AuthorshipProfile { /** The request as the person typed it. */ request: string; /** The rendered syntax catalog for this run profile and these includes. */ @@ -123,21 +123,21 @@ export interface PromptProfile { } /** - * Run the packaged prompt command document and answer with the Plan it approved. + * Run the packaged plan command document and answer with the Plan it approved. * * Every resource this builds lives inside one scope, so leaving it is what tears * the Prompt tasks, the provider and the Elicitation provider down. A teardown * failure raises out of here rather than being folded into the result, because * a failure to release is not an outcome the source that was selected survives. */ -export function* runPromptCommandDocument(profile: PromptProfile): Operation> { +export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { // Before a directory exists, before a provider exists, and therefore before // any session could be placed or any turn started. A host that cannot // establish this ceiling refuses rather than writing a Plan under a weaker one. if (profile.stack.provider !== "acpx") { return Err( new Error( - `the ${profile.stack.provider} provider cannot establish the prompt profile's ` + + `the ${profile.stack.provider} provider cannot establish the authorship profile's ` + "ceiling — nothing was written or run", ), ); @@ -158,14 +158,14 @@ export function* runPromptCommandDocument(profile: PromptProfile): Operation` ordinarily renders whatever a failed turn managed to // emit and carries on, which for a policy that reviews source would mean @@ -176,12 +176,12 @@ export function* runPromptCommandDocument(profile: PromptProfile): Operation> { - const directory = profileDirectoryFor(profile.root, profile.session); +function* useSessionDirectory(profile: AuthorshipProfile): Operation> { + const directory = authorshipDirectoryFor(profile.root, profile.session); if (profile.explicitSession) { return yield* establishDirectory(directory); } @@ -365,7 +365,7 @@ function* establishDirectory(directory: string): Operation> { if (entries.length > 0) { return Err( new Error( - `${directory} is not empty, and xmd prompt writes a Plan in a directory of its own ` + + `${directory} is not empty, and xmd plan writes a Plan in a directory of its own ` + "with nothing in it. Move or remove what is in there, or name a different " + "--session; nothing was written or run", ), @@ -425,7 +425,7 @@ function* releaseSessionDirectory(directory: string, claim: DirectoryClaim): Ope } /** - * The capabilities the prompt command document does not get. + * The capabilities the plan command document does not get. * * Installed above whatever the entrypoint provided, so the document is * refused rather than served. It decides what to write; writing a file, @@ -435,7 +435,7 @@ function* releaseSessionDirectory(directory: string, claim: DirectoryClaim): Ope function* refuseDocumentCapabilities(): Operation { const refuse = (capability: string) => () => { throw new Error( - `xmd prompt asked for ${capability}, which the prompt profile grants to nothing`, + `xmd plan asked for ${capability}, which the authorship profile grants to nothing`, ); }; yield* API.Files.around({ diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ddb2e613..cd08ceb6 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,7 +4,7 @@ * Usage: * xmd run [options] * xmd [options] (run is the default command) - * xmd prompt "" [options] + * xmd plan "" [options] * xmd workflow start [options] * xmd workflow resume * xmd workflow status|history [--json] @@ -17,7 +17,7 @@ * * Examples: * xmd run packages/core/examples/hello-world.md - * xmd prompt "ask me for my age and write the result to a file" + * xmd plan "ask me for my age and write the result to a file" * xmd packages/core/examples/hello-world.md --verbose * xmd run packages/core/examples/hello-world.md --journal events.jsonl * xmd run README.md#Release/Publish @@ -109,16 +109,10 @@ import { resolvePropsFromSources, } from "./props.ts"; import type { Binding, Extraction } from "./props.ts"; -import { - namesPrompt, - OUTPUT_OPTION, - RUN_OPTION, - scanPromptArgs, - SESSION_OPTION, -} from "./prompt-args.ts"; -import type { PromptScan } from "./prompt-args.ts"; -import { runPrompt } from "./prompt.ts"; -import type { PromptExecution } from "./prompt.ts"; +import { namesPlan, OUTPUT_OPTION, RUN_OPTION, scanPlanArgs, SESSION_OPTION } from "./plan-args.ts"; +import type { PlanScan } from "./plan-args.ts"; +import { runPlan } from "./plan.ts"; +import type { PlanExecution } from "./plan.ts"; import { componentSearchPath, resolveTestTarget } from "./test-target.ts"; import { renderSyntaxJson, renderSyntaxMarkdown, syntaxCatalog } from "./syntax.ts"; import { testingExecutionHost } from "./testing-host.ts"; @@ -167,10 +161,10 @@ const SECRET_DETECTION_FIELD = { /** * Everything a command that ends in a document execution configures. * - * Declared once because `xmd run` and `xmd prompt` configure the same + * Declared once because `xmd run` and `xmd plan` configure the same * execution: the includes it resolves components through, what it writes and * where, the agent stack it runs under, and the three deadlines. `run` adds the - * document, `prompt` adds the request and where to keep the source; nothing + * document, `plan` adds the request and where to keep the source; nothing * else differs, and a second copy of this list would be the two commands * drifting apart one option at a time. */ @@ -244,15 +238,19 @@ const runConfig = object({ ...executionFields, }); +/** What `xmd --help` says the plan command is for. */ +const PLAN_DESCRIPTION = + "Create an executable Plan from a Prompt and review it before writing or running it."; + /** - * `xmd prompt` — the Prompt, where the approved Plan goes, and whether it runs. + * `xmd plan` — the Prompt, where the approved Plan goes, and whether it runs. * * The individual `--props-*` options a candidate declares are deliberately * absent: they exist only once a document does, and the props phase binds them * per candidate. The aggregate `--props` is absent for the reason `run`'s is — * the parser coerces a separated value before any schema could judge it. */ -const promptConfig = object({ +const planConfig = object({ request: { description: "the steps the coding agent should turn into a Plan", ...field(z.string().optional(), cli.argument()), @@ -334,7 +332,7 @@ const xmd = program({ config: commands( { run: runConfig, - prompt: promptConfig, + plan: { ...planConfig, description: PLAN_DESCRIPTION }, test: testConfig, syntax: syntaxConfig, "test-agent": testAgentConfig, @@ -907,8 +905,8 @@ function* runScopedDocument( } } -/** What an approved prompt document runs with, beyond the source and its props. */ -export interface PromptExecutionConfig { +/** What an approved Plan runs with, beyond the source and its props. */ +export interface PlanExecutionConfig { include: string[]; verbose: boolean; journal: string | undefined; @@ -917,21 +915,21 @@ export interface PromptExecutionConfig { } /** - * How `xmd prompt` runs the document a person approved: exactly as `xmd run` + * How `xmd plan` runs the document a person approved: exactly as `xmd run` * runs a supplied one. * - * The prompt profile's scope is already gone by the time this is called, so the + * The authorship profile's scope is already gone by the time this is called, so the * executed program gets a fresh ordinary Agent provider and inherits neither * the assistant session nor its instruction layer. The browser form is composed - * around the document for the same reason a run composes one: `xmd prompt` is a + * around the document for the same reason a run composes one: `xmd plan` is a * command a person is sitting in front of. */ -export function promptExecutor( - config: PromptExecutionConfig, +export function planExecutor( + config: PlanExecutionConfig, stack: AgentStack, sessions: MachineSessionAssembly | undefined, installService: HostServiceInstaller, -): (approved: PromptExecution) => Operation> { +): (approved: PlanExecution) => Operation> { return (approved) => scoped(function* (): Operation> { announceSecretDetection(config.secretDetection); @@ -1224,14 +1222,14 @@ interface PropsPhase { */ workflow?: { action?: string; target?: string; argument?: string; value?: string }; /** - * What fixed grammar established about an `xmd prompt` command line. + * What fixed grammar established about an `xmd plan` command line. * * Carried rather than re-derived downstream, for the same reason the workflow * positionals are: the request may have been written after `--`, where the * parser never sees it, and the generated property occurrences are the ones * this scan classified. */ - prompt?: PromptScan; + plan?: PlanScan; root?: RootDocumentSource; bindings: Binding[]; extraction?: Extraction; @@ -1266,26 +1264,26 @@ interface PropsPhase { * of argv, so it needs no parse at all. */ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation { - // `xmd prompt` declares its own grammar and has no document to inspect: the + // `xmd plan` declares its own grammar and has no document to inspect: the // schema its generated options come from is written by an agent that has not // been asked anything yet. Everything below that reads a document, and every // refusal that assumes one, is therefore skipped. - if (namesPrompt(args)) { + if (namesPlan(args)) { // Refused here rather than with the other commands' inline refusal below, // because that one is reached through the parse this branch exists to skip. - // An inline document is what `xmd prompt` sets out to write, so a caller who + // An inline document is what `xmd plan` sets out to write, so a caller who // supplied one would otherwise watch it generate a different one instead. if (evalFlags.values[0] !== undefined) { return { args, bindings: [], error: - `unrecognized option for xmd prompt: ${EVAL_OPTION} — inline documents are ` + + `unrecognized option for xmd plan: ${EVAL_OPTION} — inline documents are ` + "exclusive to xmd run", }; } - const scan = scanPromptArgs(args); - return { args: scan.fixed, bindings: [], prompt: scan }; + const scan = scanPlanArgs(args); + return { args: scan.fixed, bindings: [], plan: scan }; } // `xmd workflow` reads its options from the head and its remaining positionals @@ -1353,7 +1351,7 @@ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation" --props-name Ada', + ' xmd plan "" --props-name Ada', "", ` ${AGGREGATE_OPTION} `, " Set document properties as a JSON object", @@ -1728,7 +1726,7 @@ function renderHelp(phase: PropsPhase): string { const help = xmd.parse({ args: [command, "--help"] }); const base = help.ok && help.value.config.help ? help.value.config.text : xmd.help({ args: [] }); const epilogue = - command === "run" ? RUN_SOURCE_HELP : command === "prompt" ? PROMPT_REQUEST_HELP : ""; + command === "run" ? RUN_SOURCE_HELP : command === "plan" ? PLAN_REQUEST_HELP : ""; const withSource = epilogue === "" ? base : `${base}\n\n${epilogue}`; if (!phase.root) { @@ -1926,20 +1924,18 @@ function* dispatch( } break; } - case "prompt": { + case "plan": { const config = command.config; - const scan = propsPhase.prompt; + const scan = propsPhase.plan; if (scan === undefined) { - console.error( - 'xmd prompt names the command first — write `xmd prompt "" [options]`', - ); + console.error('xmd plan names the command first — write `xmd plan "" [options]`'); yield* exit(1); break; } // Once, here, and handed to both consumers below. Authorship and the run // that may follow it are one invocation, so they answer to one // `--default-agent`, one `DEFAULT_AGENT_NAME` and one permission mode. - const promptStack = yield* settleAgentStack( + const planStack = yield* settleAgentStack( { agentProvider: config.agentProvider, defaultAgent: config.defaultAgent, @@ -1949,10 +1945,10 @@ function* dispatch( }, sessions, ); - if (promptStack === undefined) { + if (planStack === undefined) { break; } - const exitCode = yield* runPrompt( + const exitCode = yield* runPlan( { argv: helpRequest.args, scan, @@ -1960,7 +1956,7 @@ function* dispatch( ...(config.output === undefined ? {} : { output: config.output }), run: config.run, ...(config.session === undefined ? {} : { session: config.session }), - stack: promptStack, + stack: planStack, }, { ...(sessions === undefined ? {} : { sessions }), @@ -1970,7 +1966,7 @@ function* dispatch( // A host that answers installs a provider; one that does not installs // none, and nothing downstream reads a profile to find out which. installElicitation: installWebElicitation, - execute: promptExecutor(config, promptStack, sessions, installService), + execute: planExecutor(config, planStack, sessions, installService), }, ); if (exitCode !== 0) { @@ -1983,7 +1979,7 @@ function* dispatch( if (strayTimeout) { console.error( `unrecognized option for xmd test: ${strayTimeout} — timeout options are exclusive to ` + - "xmd run and xmd prompt", + "xmd run and xmd plan", ); yield* exit(1); break; @@ -1992,7 +1988,7 @@ function* dispatch( if (agentFlag) { console.error( `unrecognized option for xmd test: ${agentFlag} — agent options are exclusive to ` + - "xmd run and xmd prompt", + "xmd run and xmd plan", ); yield* exit(1); break; @@ -2001,7 +1997,7 @@ function* dispatch( if (propsFlag) { console.error( `unrecognized option for xmd test: ${propsFlag} — document properties are exclusive to ` + - "xmd run and xmd prompt", + "xmd run and xmd plan", ); yield* exit(1); break; @@ -2041,7 +2037,7 @@ function* dispatch( if (agentFlag) { console.error( `unrecognized option for xmd workflow: ${agentFlag} — agent options are exclusive to ` + - "xmd run and xmd prompt", + "xmd run and xmd plan", ); yield* exit(1); break; @@ -2174,7 +2170,7 @@ export function* runXmd( // end in one. const provisional = xmd.parse({ args: helpRequest.args }); const selected = provisional.ok ? provisional.value.config : undefined; - // The two commands that end in a document execution. `xmd prompt`'s deadline + // The two commands that end in a document execution. `xmd plan`'s deadline // encloses more than a run's — the catalog, the assistant session, every // repair, the human review, provider teardown, the output file and the run — // because all of it is what the caller asked to be bounded. @@ -2182,7 +2178,7 @@ export function* runXmd( !helpRequest.requested && selected !== undefined && !selected.help && - (selected.name === "run" || selected.name === "prompt"); + (selected.name === "run" || selected.name === "plan"); if (!executes) { return yield* dispatch(evalFlags, helpRequest, installService, workflowHost, sessions); diff --git a/packages/cli/src/documents/prompt-command.md b/packages/cli/src/documents/plan-command.md similarity index 93% rename from packages/cli/src/documents/prompt-command.md rename to packages/cli/src/documents/plan-command.md index a107a6d0..2900f58e 100644 --- a/packages/cli/src/documents/prompt-command.md +++ b/packages/cli/src/documents/plan-command.md @@ -11,7 +11,7 @@ returns: type: string --- -# `xmd prompt` turns steps into a program +# `xmd plan` turns steps into a program This document is a workflow that generates an executable Plan from a sequence of steps. It combines the original Prompt, which describes those steps, with the XMD @@ -23,7 +23,7 @@ plan must be interpreted again before its steps can happen. An XMD Plan already contains those executable steps, so running it simply executes them. A draft remains text while this workflow reviews it. Nothing in it runs before -you approve it. After approval, `xmd prompt` validates the exact source again. By +you approve it. After approval, `xmd plan` validates the exact source again. By default it prints the approved XMD source. `--output` writes that source to a file instead, and `--run` executes the Plan. With both options, the command writes the source before running it. @@ -194,9 +194,9 @@ remaining choices are to ask the coding agent what went wrong, or to stop. - + - + @@ -213,7 +213,7 @@ clarify in their next Prompt. Do not create another Plan. @@ -252,6 +252,6 @@ the coding agent wrote it. - + diff --git a/packages/cli/src/packaged-document.ts b/packages/cli/src/packaged-document.ts index 4a99111c..f522fa16 100644 --- a/packages/cli/src/packaged-document.ts +++ b/packages/cli/src/packaged-document.ts @@ -1,7 +1,7 @@ /** * The Markdown documents this CLI ships and executes itself. * - * `xmd prompt` runs a first-party document rather than a TypeScript policy, so + * `xmd plan` runs a first-party document rather than a TypeScript policy, so * that document has to be present wherever the command is: a source checkout, a * published npm package, and a compiled binary with no checkout at all. It is * located from this module's own URL, never from the working directory and never @@ -20,8 +20,8 @@ import { readFile } from "node:fs/promises"; import { until } from "effection"; import type { Operation } from "effection"; -/** The document `xmd prompt` executes to turn a request into a Plan. */ -export const PROMPT_COMMAND_DOCUMENT = "prompt-command.md"; +/** The document `xmd plan` executes to turn a request into a Plan. */ +export const PLAN_COMMAND_DOCUMENT = "plan-command.md"; /** * Where a packaged document lives, as a URL beside this module. diff --git a/packages/cli/src/prompt-args.ts b/packages/cli/src/plan-args.ts similarity index 91% rename from packages/cli/src/prompt-args.ts rename to packages/cli/src/plan-args.ts index d370a201..cba430c6 100644 --- a/packages/cli/src/prompt-args.ts +++ b/packages/cli/src/plan-args.ts @@ -1,6 +1,6 @@ /** - * `xmd prompt` argument grammar — everything decidable before a document exists - * (specs/prompt-command-spec.md). + * `xmd plan` argument grammar — everything decidable before a document exists + * (specs/plan-command-spec.md). * * A pure function over argv. It reads no Context Apis, contacts no agent, and * inspects nothing on disk, which is what lets the command refuse a malformed @@ -19,7 +19,7 @@ import { AGGREGATE_OPTION } from "./props.ts"; import type { Binding } from "./props.ts"; -export const PROMPT_COMMAND = "prompt"; +export const PLAN_COMMAND = "plan"; export const OUTPUT_OPTION = "--output"; export const SESSION_OPTION = "--session"; export const RUN_OPTION = "--run"; @@ -57,7 +57,7 @@ const SWITCH_OPTIONS: readonly string[] = [ /** * The options that configure the Plan's execution and nothing else. * - * `xmd prompt` prints an approved Plan unless `--run` asks for it to be run, so + * `xmd plan` prints an approved Plan unless `--run` asks for it to be run, so * each of these describes work that would not happen. Accepting one silently * would mean answering a caller who asked for a journal, a permission mode or an * exec deadline with a command that creates none of them. @@ -127,8 +127,8 @@ export interface PropertyOccurrence { provisional?: string; } -/** What fixed grammar establishes about one `xmd prompt` command line. */ -export interface PromptScan { +/** What fixed grammar establishes about one `xmd plan` command line. */ +export interface PlanScan { /** The request, byte for byte, when exactly one was written. */ request?: string; /** @@ -147,17 +147,16 @@ export interface PromptScan { error?: string; } -/** Whether these arguments select the `prompt` command. */ -export function namesPrompt(args: readonly string[]): boolean { - return args[0] === PROMPT_COMMAND; +/** Whether these arguments select the `plan` command. */ +export function namesPlan(args: readonly string[]): boolean { + return args[0] === PLAN_COMMAND; } const ORDER_HELP = - "document properties follow the request, as in " + - '`xmd prompt "" --props-name `'; + "document properties follow the request, as in " + '`xmd plan "" --props-name `'; -export function scanPromptArgs(args: readonly string[]): PromptScan { - const fixed: string[] = [PROMPT_COMMAND]; +export function scanPlanArgs(args: readonly string[]): PlanScan { + const fixed: string[] = [PLAN_COMMAND]; const occurrences: PropertyOccurrence[] = []; let request: string | undefined; let extra: string | undefined; @@ -221,9 +220,9 @@ export function scanPromptArgs(args: readonly string[]): PromptScan { occurrences, error: name === "--save" - ? `unrecognized option for xmd prompt: --save — the approved Plan goes to stdout, ` + + ? `unrecognized option for xmd plan: --save — the approved Plan goes to stdout, ` + `and ${OUTPUT_OPTION} writes it to a file` - : `unrecognized option for xmd prompt: ${name}`, + : `unrecognized option for xmd plan: ${name}`, }; } @@ -300,7 +299,7 @@ export function scanPromptArgs(args: readonly string[]): PromptScan { fixed, occurrences, error: - `unrecognized argument for xmd prompt: ${extra} — the command takes exactly one ` + + `unrecognized argument for xmd plan: ${extra} — the command takes exactly one ` + "request, and " + ORDER_HELP, }; @@ -310,7 +309,7 @@ export function scanPromptArgs(args: readonly string[]): PromptScan { return { fixed, occurrences, - error: 'xmd prompt requires one request — `xmd prompt ""`', + error: 'xmd plan requires one request — `xmd plan ""`', }; } @@ -319,7 +318,7 @@ export function scanPromptArgs(args: readonly string[]): PromptScan { request, fixed, occurrences, - error: "xmd prompt requires a request with at least one non-whitespace character", + error: "xmd plan requires a request with at least one non-whitespace character", }; } @@ -412,7 +411,7 @@ export function strayPropertyValue( const binding = byOption.get(occurrence.option); if (binding?.boolean === true) { return ( - `unrecognized argument for xmd prompt: ${provisional} — ${occurrence.option} is a ` + + `unrecognized argument for xmd plan: ${provisional} — ${occurrence.option} is a ` + `switch, so this is a second request; write \`${occurrence.option}=${provisional}\` ` + "to give it a value" ); diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/plan.ts similarity index 88% rename from packages/cli/src/prompt.ts rename to packages/cli/src/plan.ts index 5ec57732..347083c2 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/plan.ts @@ -1,20 +1,20 @@ /** - * `xmd prompt` — the trusted host around the prompt command document - * (specs/prompt-command-spec.md). + * `xmd plan` — the trusted host around the plan command document + * (specs/plan-command-spec.md). * - * Every invocation executes one root document — the packaged prompt command + * Every invocation executes one root document — the packaged plan command * document — and a second one only when `--run` asks for it: * * ```text * fixed command preflight * -> build the run-profile syntax catalog - * -> execute the exact packaged prompt command document + * -> execute the exact packaged plan command document * -> await that execution and provider teardown * -> validate the returned source again * -> deliver those exact bytes, in exactly one of four ways: * (default) write the source to stdout * --output exclusively create the file - * --run execute retainedSource("", source) + * --run execute retainedSource("", source) * through the ordinary run path * --output --run create the file, then execute it * ``` @@ -26,14 +26,14 @@ * * What a person is asked, how many drafts may be repaired, how many may be * reviewed and what happens when nobody approves anything are not here. They are - * in `src/documents/prompt-command.md`, written in the open, where they can be + * in `src/documents/plan-command.md`, written in the open, where they can be * read and argued with. This module is what a policy cannot be trusted to do for * itself: settle the command line, build the ceiling the assistant runs under, * answer honestly about a draft, and hold the boundary between text an agent * wrote and a Plan this host will hand over or run. * * Two kinds of failure are told apart throughout, because they have different - * remedies. A *draft* failure is something the agent wrote, so the prompt + * remedies. A *draft* failure is something the agent wrote, so the plan * command document is told the facts and may ask for another draft. A *caller* * failure is something the command line or the environment said, so it raises * out of the validator and ends that execution: no draft the agent could write @@ -66,8 +66,8 @@ import type { AcpxProviderDependencies } from "@executablemd/acp"; import { cwd } from "@executablemd/runtime"; import type { AgentStack } from "./agent-stack.ts"; -import { DEFAULT_PROFILE_ROOT, runPromptCommandDocument } from "./prompt-profile.ts"; -import type { CandidateAssessment } from "./prompt-profile.ts"; +import { DEFAULT_AUTHORSHIP_ROOT, runPlanCommandDocument } from "./authorship-profile.ts"; +import type { CandidateAssessment } from "./authorship-profile.ts"; import type { MachineSessionAssembly } from "./session-coordinator.ts"; import { buildBindings, @@ -83,32 +83,32 @@ import { signatureFailure, signatureOf, strayPropertyValue, -} from "./prompt-args.ts"; -import type { OptionSignature, PromptScan } from "./prompt-args.ts"; +} from "./plan-args.ts"; +import type { OptionSignature, PlanScan } from "./plan-args.ts"; /** * The identity approved text runs under. * * Deliberate and fixed, the way `` is for an inline document: the bytes * came from an agent, not from a file, and a source position reading - * `(:5:1)` says so. It affects positions and diagnostics only — + * `(:5:1)` says so. It affects positions and diagnostics only — * components, includes and every relative filesystem operation still resolve * from the contextual working directory. */ -export const PROMPT_IDENTITY = ""; +export const PLAN_IDENTITY = ""; /** The approved bytes, and the props resolved under exactly those bytes. */ -export interface PromptExecution { +export interface PlanExecution { root: RootDocumentSource; props: Record; } -/** What one `xmd prompt` invocation was asked to do. */ -export interface PromptCommand { +/** What one `xmd plan` invocation was asked to do. */ +export interface PlanCommand { /** The argv this invocation holds, and the props source for every candidate. */ argv: string[]; /** What fixed grammar established about that argv. */ - scan: PromptScan; + scan: PlanScan; include: string[]; /** Where the approved Plan is written, when the caller asked for a file. */ output?: string; @@ -127,7 +127,7 @@ export interface PromptCommand { } /** What the host supplies. Every entry is a decision only a host can make. */ -export interface PromptDependencies { +export interface PlanDependencies { /** What this host states about machine-wide agent sessions, if anything. */ sessions?: MachineSessionAssembly; /** What the profile's provider is built on, beyond the host's own assembly. */ @@ -144,13 +144,13 @@ export interface PromptDependencies { * one — there is no flag, no environment variable and no contextual Api to * reach, so a document cannot move where the ceiling lives. */ - profileRoot?: string; + authorshipRoot?: string; /** Run the approved document the way this host runs any supplied one. */ - execute(approved: PromptExecution): Operation>; + execute(approved: PlanExecution): Operation>; } -/** The prompt-owned findings that are not core's to report. */ -interface PromptDiagnostic { +/** The command-owned findings that are not core's to report. */ +interface DraftDiagnostic { code: "generated-binding-collision" | "root-props-unreadable"; message: string; } @@ -160,7 +160,7 @@ interface CandidateDefects { /** Core's complete versioned answer, whenever core produced one. */ validation?: DocumentValidation; /** What this command found about the options the candidate generates. */ - prompt?: PromptDiagnostic; + draft?: DraftDiagnostic; } type CandidateOutcome = @@ -184,10 +184,10 @@ const DECLARATION_CODES: ReadonlySet = new Set { +export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operation { const { scan } = command; if (scan.error !== undefined || scan.request === undefined) { - console.error(scan.error ?? 'xmd prompt requires one request — `xmd prompt ""`'); + console.error(scan.error ?? 'xmd plan requires one request — `xmd plan ""`'); return 1; } const request = scan.request; @@ -213,7 +213,7 @@ export function* runPrompt(command: PromptCommand, deps: PromptDependencies): Op // and the run that would otherwise already have happened. let authored: Result; try { - authored = yield* runPromptCommandDocument({ + authored = yield* runPlanCommandDocument({ request, syntax, session: command.session ?? invocationSessionName(), @@ -221,7 +221,7 @@ export function* runPrompt(command: PromptCommand, deps: PromptDependencies): Op // session somebody can ask for again needs its directory to outlive the // invocation, and only the host knows whether somebody named one. explicitSession: command.session !== undefined, - root: deps.profileRoot ?? DEFAULT_PROFILE_ROOT, + root: deps.authorshipRoot ?? DEFAULT_AUTHORSHIP_ROOT, stack: command.stack, ...(deps.acp === undefined ? {} : { acp: deps.acp }), installElicitation: deps.installElicitation, @@ -275,7 +275,7 @@ export function* runPrompt(command: PromptCommand, deps: PromptDependencies): Op } const executed = yield* deps.execute({ - root: retainedSource(PROMPT_IDENTITY, source), + root: retainedSource(PLAN_IDENTITY, source), props: admitted.props, }); if (!executed.ok) { @@ -292,13 +292,16 @@ export function* runPrompt(command: PromptCommand, deps: PromptDependencies): Op /** * A logical session name nothing else can name. * - * The conversation belongs to this invocation: a second `xmd prompt` places a + * The conversation belongs to this invocation: a second `xmd plan` places a * different session rather than continuing this one, and a request nobody meant * to repeat never arrives in a history it did not create. `--session` replaces * it when a caller wants the provider's ordinary continuation instead. + * + * Exported for the suite that pins the generated shape. Nothing outside this + * package names it, and a caller reaches it through no command line. */ -function invocationSessionName(): string { - return `xmd-prompt:${randomUUID()}`; +export function invocationSessionName(): string { + return `xmd-plan:${randomUUID()}`; } /** @@ -309,7 +312,7 @@ function invocationSessionName(): string { * and it never sees an argument the command line got wrong. */ function* assess( - command: PromptCommand, + command: PlanCommand, frozen: Map, source: string, ): Operation { @@ -333,13 +336,13 @@ function* assess( * vocabulary it only describes. */ function* assessCandidate( - command: PromptCommand, + command: PlanCommand, frozen: Map, candidate: string, ): Operation { return yield* scoped(function* (): Operation { yield* useRunProfileRegistry(); - const root = retainedSource(PROMPT_IDENTITY, candidate); + const root = retainedSource(PLAN_IDENTITY, candidate); const includes = command.include; const components = agentIdentityComponents(); @@ -357,7 +360,7 @@ function* assessCandidate( } catch (error) { return { kind: "repairable", - defects: { prompt: { code: "root-props-unreadable", message: describeError(error) } }, + defects: { draft: { code: "root-props-unreadable", message: describeError(error) } }, }; } @@ -368,7 +371,7 @@ function* assessCandidate( return { kind: "repairable", defects: { - prompt: { code: "generated-binding-collision", message: describeError(error) }, + draft: { code: "generated-binding-collision", message: describeError(error) }, }, }; } diff --git a/packages/cli/src/props.ts b/packages/cli/src/props.ts index 8afb5e32..aa128074 100644 --- a/packages/cli/src/props.ts +++ b/packages/cli/src/props.ts @@ -532,7 +532,7 @@ export interface ExtractOptions { * * `xmd run` supplies none: the document is already named, so the schema that * decides a property's arity cannot change under the same command line. - * `xmd prompt` re-binds the same argv against every candidate, so a value + * `xmd plan` re-binds the same argv against every candidate, so a value * option that swallowed `--raw` in one draft would take the flag away from * the invocation itself. A value that really begins with `-` is written * `--props-name=-value`. diff --git a/packages/cli/src/report.ts b/packages/cli/src/report.ts index 73be4b0d..96987e79 100644 --- a/packages/cli/src/report.ts +++ b/packages/cli/src/report.ts @@ -1,7 +1,7 @@ /** * How a completed document's failure reaches the terminal. * - * Shared because `xmd run` and `xmd prompt` end in the same thing — one + * Shared because `xmd run` and `xmd plan` end in the same thing — one * ordinary document execution — and a person reading the output of either has * no reason to see the same failure worded two ways. A test failure is the case * that makes the difference visible: it earns a heading and a blank line above diff --git a/packages/cli/src/syntax.ts b/packages/cli/src/syntax.ts index 873c3a4b..4309609d 100644 --- a/packages/cli/src/syntax.ts +++ b/packages/cli/src/syntax.ts @@ -61,7 +61,7 @@ export function* syntaxCatalog(includes: readonly string[]): Operation` while validation reported it unresolved — a document told * to use a component nobody would accept. diff --git a/packages/cli/tests/packaged-document.test.ts b/packages/cli/tests/packaged-document.test.ts index 8c8bb46b..c5298c0d 100644 --- a/packages/cli/tests/packaged-document.test.ts +++ b/packages/cli/tests/packaged-document.test.ts @@ -1,7 +1,7 @@ /** * The documents the CLI ships and executes itself. * - * `xmd prompt` runs a first-party Markdown program, so that program has to be + * `xmd plan` runs a first-party Markdown program, so that program has to be * present and identical wherever the command is. This suite runs under Deno, * Node and Bun, which is what makes it evidence: the lookup is package-relative, * and a resolution that only works under the runtime the author happened to use @@ -13,15 +13,16 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { readTextFile } from "@effectionx/fs"; +import { readTextFile, writeTextFile } from "@effectionx/fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { packagedDocumentUrl, - PROMPT_COMMAND_DOCUMENT, + PLAN_COMMAND_DOCUMENT, readPackagedDocument, } from "../src/packaged-document.ts"; -import { useWorkingDirectory } from "./support/prompt-harness.ts"; +import { useWorkingDirectory } from "./support/plan-harness.ts"; /** * The approved introduction, exactly. @@ -33,7 +34,7 @@ import { useWorkingDirectory } from "./support/prompt-harness.ts"; * approved wording around whatever the file happens to hold. */ const INTRODUCTION = [ - "# `xmd prompt` turns steps into a program", + "# `xmd plan` turns steps into a program", "", "This document is a workflow that generates an executable Plan from a sequence of", "steps. It combines the original Prompt, which describes those steps, with the XMD", @@ -45,7 +46,7 @@ const INTRODUCTION = [ "contains those executable steps, so running it simply executes them.", "", "A draft remains text while this workflow reviews it. Nothing in it runs before", - "you approve it. After approval, `xmd prompt` validates the exact source again. By", + "you approve it. After approval, `xmd plan` validates the exact source again. By", "default it prints the approved XMD source. `--output` writes that source to a", "file instead, and `--run` executes the Plan. With both options, the command", "writes the source before running it.", @@ -64,20 +65,24 @@ const PLAN_REQUIREMENTS = [ ].join("\n"); describe("packaged documents", () => { - it("reads the prompt command document from beside its module, whatever the cwd is", function* () { + it("reads the plan command document from beside its module, whatever the cwd is", function* () { // A temporary contextual cwd with no document in it. A lookup that reached // for the working directory would find nothing here, and one that reached // through the component search path would be answerable by a repository // file — neither may decide which program this command runs. - const source = yield* useWorkingDirectory(function* () { - return yield* readPackagedDocument(PROMPT_COMMAND_DOCUMENT); + const source = yield* useWorkingDirectory(function* (dir) { + // A file of the same name, in the directory a person is standing in. A + // lookup that resolved the working directory would read this one, and a + // program a repository file can replace is not the program that shipped. + yield* writeTextFile(join(dir, PLAN_COMMAND_DOCUMENT), "# not the shipped program\n"); + return yield* readPackagedDocument(PLAN_COMMAND_DOCUMENT); }); const committed = yield* readTextFile( - fileURLToPath(packagedDocumentUrl(PROMPT_COMMAND_DOCUMENT)), + fileURLToPath(packagedDocumentUrl(PLAN_COMMAND_DOCUMENT)), ); expect(source).toBe(committed); - // It is the prompt command document, not merely some file that exists. + // It is the plan command document, not merely some file that exists. expect(source).toContain("returns:"); expect(source).toContain(" { ); // Every visible stage of the workflow is a heading somebody can audit. for (const heading of [ - "# `xmd prompt` turns steps into a program", + "# `xmd plan` turns steps into a program", "## Create the first draft", "## Check and repair the draft", "## Review the draft", @@ -119,7 +124,7 @@ describe("packaged documents", () => { // copy of exhaustion: exhaustion is decided inside review, and saying it // twice would make the two endings indistinguishable to a reader. expect(source).toContain( - '', + '', ); expect(source.split("reviewed ten drafts without an approved Plan").length - 1).toBe(2); // The choices are the words a person reads, with no internal spelling diff --git a/packages/cli/tests/prompt-args.test.ts b/packages/cli/tests/plan-args.test.ts similarity index 79% rename from packages/cli/tests/prompt-args.test.ts rename to packages/cli/tests/plan-args.test.ts index 97fa101c..4c6b9d40 100644 --- a/packages/cli/tests/prompt-args.test.ts +++ b/packages/cli/tests/plan-args.test.ts @@ -1,12 +1,12 @@ /** - * Tier PR — `xmd prompt` fixed grammar (specs/prompt-command-spec.md). + * Tier PR — `xmd plan` fixed grammar (specs/plan-command-spec.md). * - * Rows P1–P6, in the half that is decidable without a document. `scanPromptArgs` + * Rows P1–P6, in the half that is decidable without a document. `scanPlanArgs` * is a pure function over argv, so what the command line means — and every * refusal it earns — is asserted directly rather than inferred from a process * that printed nothing. * - * The rest of P5 and P6 live in `prompt.test.ts`, where a candidate schema + * The rest of P5 and P6 live in `plan.test.ts`, where a candidate schema * exists to bind against. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -14,13 +14,13 @@ import { expect } from "@executablemd/test-support/expect"; import { isReservedOption, - namesPrompt, - scanPromptArgs, + namesPlan, + scanPlanArgs, signatureFailure, signatureOf, strayPropertyValue, -} from "../src/prompt-args.ts"; -import type { OptionSignature } from "../src/prompt-args.ts"; +} from "../src/plan-args.ts"; +import type { OptionSignature } from "../src/plan-args.ts"; import { buildBindings, extractPropsArgs, PropsError } from "../src/props.ts"; import type { Binding } from "../src/props.ts"; @@ -34,38 +34,41 @@ function frozen(entries: Record): Map { +describe("Tier PR — xmd plan fixed grammar", () => { it("C1: exactly one request, kept byte for byte", function* () { - expect(namesPrompt(["prompt", REQUEST])).toBe(true); - expect(namesPrompt(["run", "doc.md"])).toBe(false); + expect(namesPlan(["plan", REQUEST])).toBe(true); + expect(namesPlan(["run", "doc.md"])).toBe(false); + // The command is named `plan` and nothing else names it: the retired + // spelling selects no command, so it never reaches this grammar at all. + expect(namesPlan(["prompt", REQUEST])).toBe(false); - const one = scanPromptArgs(["prompt", REQUEST]); + const one = scanPlanArgs(["plan", REQUEST]); expect(one.error).toBe(undefined); expect(one.request).toBe(REQUEST); // Preserved, not trimmed: trimming is only how emptiness is tested. - const padded = scanPromptArgs(["prompt", ` ${REQUEST}\n`]); + const padded = scanPlanArgs(["plan", ` ${REQUEST}\n`]); expect(padded.error).toBe(undefined); expect(padded.request).toBe(` ${REQUEST}\n`); - expect(scanPromptArgs(["prompt"]).error).toContain("requires one request"); - expect(scanPromptArgs(["prompt", "", "--raw"]).error).toContain("non-whitespace"); - expect(scanPromptArgs(["prompt", " \t\n "]).error).toContain("non-whitespace"); - expect(scanPromptArgs(["prompt", REQUEST, "second"]).error).toContain( - "unrecognized argument for xmd prompt: second", + expect(scanPlanArgs(["plan"]).error).toContain("requires one request"); + expect(scanPlanArgs(["plan", "", "--raw"]).error).toContain("non-whitespace"); + expect(scanPlanArgs(["plan", " \t\n "]).error).toContain("non-whitespace"); + expect(scanPlanArgs(["plan", REQUEST, "second"]).error).toContain( + "unrecognized argument for xmd plan: second", ); // A request that begins with a dash is written after the separator, and is // still exactly one request. - const separated = scanPromptArgs(["prompt", "--", "--not-an-option"]); + const separated = scanPlanArgs(["plan", "--", "--not-an-option"]); expect(separated.error).toBe(undefined); expect(separated.request).toBe("--not-an-option"); // And it is kept out of the parser's argv, so nothing reads it as an option. - expect(separated.fixed).toEqual(["prompt"]); + expect(separated.fixed).toEqual(["plan"]); }); it("C1: individual options follow the request, aggregate props may precede it", function* () { - const early = scanPromptArgs(["prompt", "--props-name", "Ada", REQUEST]); + const early = scanPlanArgs(["plan", "--props-name", "Ada", REQUEST]); expect(early.error).toContain("unrecognized option: --props-name"); expect(early.error).toContain("follow the request"); // Refused before anything is classified: no request was adopted from the @@ -73,8 +76,8 @@ describe("Tier PR — xmd prompt fixed grammar", () => { expect(early.request).toBe(undefined); expect(early.occurrences).toEqual([]); - const aggregate = scanPromptArgs([ - "prompt", + const aggregate = scanPlanArgs([ + "plan", "--props", '{"name":"Ada"}', REQUEST, @@ -85,16 +88,16 @@ describe("Tier PR — xmd prompt fixed grammar", () => { expect(aggregate.request).toBe(REQUEST); // The aggregate never reaches the parser: it coerces a separated value // through Number() before any schema could judge it. - expect(aggregate.fixed).toEqual(["prompt", REQUEST, "--raw", "--run"]); + expect(aggregate.fixed).toEqual(["plan", REQUEST, "--raw", "--run"]); - const inline = scanPromptArgs(["prompt", '--props={"name":"Ada"}', REQUEST]); + const inline = scanPlanArgs(["plan", '--props={"name":"Ada"}', REQUEST]); expect(inline.error).toBe(undefined); - expect(inline.fixed).toEqual(["prompt", REQUEST]); + expect(inline.fixed).toEqual(["plan", REQUEST]); }); it("C1: built-in options after generated props stay with the invocation", function* () { - const scan = scanPromptArgs([ - "prompt", + const scan = scanPlanArgs([ + "plan", REQUEST, "--props-name", "Ada", @@ -113,7 +116,7 @@ describe("Tier PR — xmd prompt fixed grammar", () => { expect(scan.error).toBe(undefined); expect(scan.request).toBe(REQUEST); expect(scan.fixed).toEqual([ - "prompt", + "plan", REQUEST, "--raw", "--include", @@ -137,7 +140,7 @@ describe("Tier PR — xmd prompt fixed grammar", () => { const bindings = bindingsFor({ loud: { type: "string" } }); let failure: unknown; try { - extractPropsArgs(["prompt", REQUEST, "--props-loud", "--raw"], bindings, { + extractPropsArgs(["plan", REQUEST, "--props-loud", "--raw"], bindings, { reserved: isReservedOption, }); } catch (error) { @@ -159,8 +162,8 @@ describe("Tier PR — xmd prompt fixed grammar", () => { }); it("C1: scalar, boolean and aggregate sources are all recorded", function* () { - const scan = scanPromptArgs([ - "prompt", + const scan = scanPlanArgs([ + "plan", REQUEST, "--props-name", "Ada", @@ -186,7 +189,7 @@ describe("Tier PR — xmd prompt fixed grammar", () => { }); const extraction = extractPropsArgs( [ - "prompt", + "plan", REQUEST, "--props-name", "Ada", @@ -208,7 +211,7 @@ describe("Tier PR — xmd prompt fixed grammar", () => { }); it("C1: a boolean binding turns its provisional value into a second request", function* () { - const scan = scanPromptArgs(["prompt", REQUEST, "--props-loud", "true"]); + const scan = scanPlanArgs(["plan", REQUEST, "--props-loud", "true"]); expect(scan.error).toBe(undefined); expect(scan.occurrences).toEqual([{ option: "--props-loud", provisional: "true" }]); @@ -219,7 +222,7 @@ describe("Tier PR — xmd prompt fixed grammar", () => { // One that declares it a switch does not: `true` is then a positional. const stray = strayPropertyValue(scan.occurrences, bindingsFor({ loud: { type: "boolean" } })); - expect(stray).toContain("unrecognized argument for xmd prompt: true"); + expect(stray).toContain("unrecognized argument for xmd plan: true"); expect(stray).toContain("--props-loud=true"); }); @@ -244,14 +247,14 @@ describe("Tier PR — xmd prompt fixed grammar", () => { ["--secret-detection"], ["--no-secret-detection"], ]) { - const refused = scanPromptArgs(["prompt", REQUEST, ...flag]); + const refused = scanPlanArgs(["plan", REQUEST, ...flag]); expect(refused.error).toContain(`${flag[0]} configures running the Plan`); expect(refused.error).toContain("add --run"); // With `--run` the same command line is ordinary, wherever the two are // written relative to each other. - expect(scanPromptArgs(["prompt", REQUEST, ...flag, "--run"]).error).toBe(undefined); - expect(scanPromptArgs(["prompt", REQUEST, "--run", ...flag]).error).toBe(undefined); + expect(scanPlanArgs(["plan", REQUEST, ...flag, "--run"]).error).toBe(undefined); + expect(scanPlanArgs(["plan", REQUEST, "--run", ...flag]).error).toBe(undefined); } // The options the command always uses are never refused: they build the @@ -265,7 +268,7 @@ describe("Tier PR — xmd prompt fixed grammar", () => { ["--timeout", "5s"], ["--output", "plan.md"], ]) { - expect(scanPromptArgs(["prompt", REQUEST, ...flag]).error).toBe(undefined); + expect(scanPlanArgs(["plan", REQUEST, ...flag]).error).toBe(undefined); } }); @@ -278,38 +281,36 @@ describe("Tier PR — xmd prompt fixed grammar", () => { "or leave it out to write the Plan"; for (const spelling of ["--run=false", "--run=true", "--run="]) { - const scan = scanPromptArgs(["prompt", REQUEST, spelling]); + const scan = scanPlanArgs(["plan", REQUEST, spelling]); expect(scan.error).toBe(REFUSAL); // It established nothing: the token reached neither the parser's argv nor // the record of what this invocation asked for. - expect(scan.fixed).toEqual(["prompt", REQUEST]); + expect(scan.fixed).toEqual(["plan", REQUEST]); // And it does not answer for `--run` where a run is what makes an option // meaningful. Without the fix this command line is accepted, and then // nothing runs — so the journal the caller asked for is never created. - const gated = scanPromptArgs(["prompt", REQUEST, spelling, "--journal", "trace.jsonl"]); + const gated = scanPlanArgs(["plan", REQUEST, spelling, "--journal", "trace.jsonl"]); expect(gated.error).toBe(REFUSAL); - expect(gated.fixed).toEqual(["prompt", REQUEST]); + expect(gated.fixed).toEqual(["plan", REQUEST]); } // The switch itself is unaffected, wherever it is written. - expect(scanPromptArgs(["prompt", REQUEST, "--run"]).error).toBe(undefined); - expect(scanPromptArgs(["prompt", REQUEST, "--run", "--journal", "t.jsonl"]).error).toBe( - undefined, - ); + expect(scanPlanArgs(["plan", REQUEST, "--run"]).error).toBe(undefined); + expect(scanPlanArgs(["plan", REQUEST, "--run", "--journal", "t.jsonl"]).error).toBe(undefined); }); it("C1: an option this command does not define is refused, not dropped", function* () { // The parser stops at the first option it does not define and drops the // rest, so silence here would mean accepting a command line nobody honoured. - const unknown = scanPromptArgs(["prompt", REQUEST, "--not-a-thing", "value"]); - expect(unknown.error).toBe("unrecognized option for xmd prompt: --not-a-thing"); + const unknown = scanPlanArgs(["plan", REQUEST, "--not-a-thing", "value"]); + expect(unknown.error).toBe("unrecognized option for xmd plan: --not-a-thing"); // `--save` was replaced before release, so there is no alias — and the // refusal says where the Plan goes now rather than only that the option is // unknown. - const retired = scanPromptArgs(["prompt", REQUEST, "--save", "out.md"]); - expect(retired.error).toContain("unrecognized option for xmd prompt: --save"); + const retired = scanPlanArgs(["plan", REQUEST, "--save", "out.md"]); + expect(retired.error).toContain("unrecognized option for xmd plan: --save"); expect(retired.error).toContain("goes to stdout"); expect(retired.error).toContain("--output writes it to a file"); // It is not quietly read as the option that replaced it. diff --git a/packages/cli/tests/prompt-cli.test.ts b/packages/cli/tests/plan-cli.test.ts similarity index 77% rename from packages/cli/tests/prompt-cli.test.ts rename to packages/cli/tests/plan-cli.test.ts index 2386b82b..8930f502 100644 --- a/packages/cli/tests/prompt-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -1,9 +1,9 @@ /** - * Tier PR — the `xmd prompt` command lifecycle - * (specs/prompt-command-spec.md). + * Tier PR — the `xmd plan` command lifecycle + * (specs/plan-command-spec.md). * * The command's grammar, filesystem, journal, lifetime and execution rows: what - * `xmd prompt` does to the disk, to the process status and to the document it + * `xmd plan` does to the disk, to the process status and to the document it * ends in. The approved document runs through the production executor, so what * these prove about execution is what `xmd run` does with a supplied root. * @@ -14,7 +14,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { runCli } from "@executablemd/test-support/launch"; -import { readTextFile, writeTextFile } from "@effectionx/fs"; +import { ensureDir, readTextFile, writeTextFile } from "@effectionx/fs"; import { stat } from "@executablemd/runtime"; import { ensure, Ok, scoped, spawn, until } from "effection"; import type { Operation, Result } from "effection"; @@ -22,21 +22,21 @@ import { join } from "node:path"; import { readdir } from "node:fs/promises"; import process from "node:process"; -import { promptExecutor } from "../src/cli.ts"; +import { planExecutor } from "../src/cli.ts"; import { resolveAgentStack } from "../src/agent-stack.ts"; import type { AgentStack } from "../src/agent-stack.ts"; -import { runPrompt } from "../src/prompt.ts"; -import type { PromptCommand, PromptExecution } from "../src/prompt.ts"; -import { scanPromptArgs } from "../src/prompt-args.ts"; +import { runPlan } from "../src/plan.ts"; +import type { PlanCommand, PlanExecution } from "../src/plan.ts"; +import { namesPlan, scanPlanArgs } from "../src/plan-args.ts"; import { AGENT, - createPromptHarness, + createPlanHarness, timesRead, useEnvironment, useRecordedEnvironment, useWorkingDirectory, -} from "./support/prompt-harness.ts"; -import type { PromptHarness } from "./support/prompt-harness.ts"; +} from "./support/plan-harness.ts"; +import type { PlanHarness } from "./support/plan-harness.ts"; const REQUEST = "write a greeting"; @@ -99,11 +99,11 @@ const STACK: AgentStack = { * modes that write the Plan instead are exercised by their own cases below, * which pass `run: false` and say which destination they mean. */ -function command(dir: string, args: string[], output?: string): PromptCommand { - const argv = ["prompt", ...args, "--run"]; +function command(dir: string, args: string[], output?: string): PlanCommand { + const argv = ["plan", ...args, "--run"]; return { argv, - scan: scanPromptArgs(argv), + scan: scanPlanArgs(argv), include: [dir], ...(output === undefined ? {} : { output }), run: true, @@ -112,11 +112,11 @@ function command(dir: string, args: string[], output?: string): PromptCommand { } /** The same invocation, writing the Plan rather than running it. */ -function writing(dir: string, args: string[], output?: string): PromptCommand { - const argv = ["prompt", ...args]; +function writing(dir: string, args: string[], output?: string): PlanCommand { + const argv = ["plan", ...args]; return { argv, - scan: scanPromptArgs(argv), + scan: scanPlanArgs(argv), include: [dir], ...(output === undefined ? {} : { output }), run: false, @@ -129,8 +129,8 @@ function executor( dir: string, journal?: string, stack?: AgentStack, -): (approved: PromptExecution) => Operation> { - return promptExecutor( +): (approved: PlanExecution) => Operation> { + return planExecutor( { include: [dir], verbose: false, @@ -149,7 +149,7 @@ function* exists(path: string): Operation { } /** Every phase after the refusal, at zero. */ -function untouched(harness: PromptHarness): Record { +function untouched(harness: PlanHarness): Record { return { catalogs: harness.catalogCalls.length, runtimes: harness.fake.created.length, @@ -170,13 +170,13 @@ const NOTHING = { }; describe( - "Tier PR — the xmd prompt command", + "Tier PR — the xmd plan command lifecycle", { sanitizeOps: false, sanitizeResources: false }, () => { it("C1: a misplaced individual option refuses before any phase begins", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); - const code = yield* runPrompt( + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); + const code = yield* runPlan( command(dir, ["--props-name", "Ada", REQUEST], "out.md"), harness.deps, ); @@ -192,7 +192,7 @@ describe( // the whole invocation shares now happens. yield* useWorkingDirectory(function* (dir) { const { code, stderr } = yield* runCli( - ["prompt", REQUEST, "--approve-all", "--deny-all", "--run"], + ["plan", REQUEST, "--approve-all", "--deny-all", "--run"], { cwd: dir }, ).join(); @@ -218,14 +218,14 @@ describe( // cannot see. The one after it drives the real parser and dispatch, which // this one does not reach — the defect lived exactly between those two // layers, so both are needed to pin it. - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { const journal = join(dir, "trace.jsonl"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, journal); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const argv = ["prompt", REQUEST, "--run=false", "--journal", journal]; + const argv = ["plan", REQUEST, "--run=false", "--journal", journal]; const written = console.error; const lines: string[] = []; const value = yield* scoped(function* (): Operation { @@ -235,8 +235,8 @@ describe( console.error = (...parts: unknown[]) => { lines.push(parts.map((part) => String(part)).join(" ")); }; - return yield* runPrompt( - { argv, scan: scanPromptArgs(argv), include: [dir], run: false, stack: STACK }, + return yield* runPlan( + { argv, scan: scanPlanArgs(argv), include: [dir], run: false, stack: STACK }, harness.deps, ); }); @@ -251,10 +251,10 @@ describe( // Every phase after preflight stayed at zero: no catalog, no provider, // no turn, no review, no execution. expect(untouched(harness)).toEqual(NOTHING); - // No prompt-profile session was opened either — neither established + // No authorship-profile session was opened either — neither established // with the provider nor given a directory to run in. expect(harness.fake.ensured).toEqual([]); - expect(yield* until(readdir(profileRoot))).toEqual([]); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); // And nothing durable exists — neither the journal it named, nor an // output file, nor anything else. expect(yield* exists(journal)).toBe(false); @@ -265,7 +265,7 @@ describe( it("C1: a valued --run is refused by the real parser, not just the scanner", function* () { // The defect was a disagreement between the scanner and the parser: the // scanner read `--run=false` as the switch while the parser read it as - // false. A case that hands `runPrompt` an already-scanned command skips + // false. A case that hands `runPlan` an already-scanned command skips // the boundary the bug lived on, so this one goes through the command line // an operator actually types. const REFUSAL = @@ -275,7 +275,7 @@ describe( for (const spelling of ["--run=false", "--run=true", "--run="]) { yield* useWorkingDirectory(function* (dir) { const { code, stdout, stderr } = yield* runCli( - ["prompt", REQUEST, spelling, "--journal", "trace.jsonl"], + ["plan", REQUEST, spelling, "--journal", "trace.jsonl"], { cwd: dir }, ).join(); @@ -299,7 +299,7 @@ describe( // otherwise entirely valid: a case that also carried a second, earlier // failure would pass whether or not `--save` still worked. yield* useWorkingDirectory(function* (dir) { - const { code, stdout, stderr } = yield* runCli(["prompt", REQUEST, "--save", "out.md"], { + const { code, stdout, stderr } = yield* runCli(["plan", REQUEST, "--save", "out.md"], { cwd: dir, }).join(); @@ -317,8 +317,41 @@ describe( }); }); + it("C1: the retired spelling names no command and reaches no authorship", function* () { + // The command is `plan`. The spelling it replaced is not registered, + // aliased or kept as a tombstone, so the fixed grammar never sees it and + // the existing default-run grammar answers instead. + expect(namesPlan(["prompt", REQUEST])).toBe(false); + + yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + const { code, stdout, stderr } = yield* runCli(["prompt", REQUEST], { + cwd: dir, + env: { HOME: home }, + }).join(); + + expect(code).not.toBe(0); + // Nothing this command owns was reached: no approved source on stdout, + // no catalog, no provider to report an agent unavailable, no authored + // refusal from the plan command document, and nobody asked to review. + expect(stdout).toBe(""); + expect(stderr).not.toContain("## Built-in components"); + expect(stderr).not.toContain("unavailable"); + expect(stderr).not.toContain("xmd plan"); + expect(stderr).not.toContain("Request changes"); + // And neither namespace exists under the isolated home: the new one was + // never opened, and the old one is not read, migrated or created. + expect(yield* exists(join(home, ".xmd", "plan"))).toBe(false); + expect(yield* exists(join(home, ".xmd", "prompt"))).toBe(false); + // No output file and no journal: the only entry is the home this case + // made for the subprocess. + expect(yield* until(readdir(dir))).toEqual(["home"]); + }); + }); + it("C1: inline source is refused before any phase begins", function* () { - // `-e` belongs to `xmd run`. `xmd prompt` is the command that *writes* a + // `-e` belongs to `xmd run`. `xmd plan` is the command that *writes* a // document, so a second one supplied on the command line is a // contradiction — and one the parser used to drop in silence, leaving the // caller watching a different document get generated. @@ -326,7 +359,7 @@ describe( yield* useWorkingDirectory(function* (dir) { const { code, stdout, stderr } = yield* runCli( [ - "prompt", + "plan", REQUEST, flag, "# supplied", @@ -341,7 +374,7 @@ describe( expect(code).toBe(1); expect(stderr).toContain( - "unrecognized option for xmd prompt: --eval — inline documents are exclusive to xmd run", + "unrecognized option for xmd plan: --eval — inline documents are exclusive to xmd run", ); // Every later phase, unreached: no catalog was rendered, no provider // was built, no review was asked, and neither named file was made. @@ -358,7 +391,7 @@ describe( yield* useWorkingDirectory(function* (dir) { const { code, stdout, stderr } = yield* runCli( [ - "prompt", + "plan", "--help", "--output", "out.md", @@ -371,7 +404,7 @@ describe( ).join(); expect(code).toBe(0); - expect(stdout).toContain("Usage: xmd prompt [OPTIONS] [request]"); + expect(stdout).toContain("Usage: xmd plan [OPTIONS] [request]"); expect(stdout).toContain("Exactly one Prompt is required"); expect(stdout).toContain("--props "); expect(stdout).toContain("XMD_PROPS"); @@ -396,13 +429,19 @@ describe( }); const program = yield* runCli(["--help"]).expect(); - expect(program.stdout).toMatch(/^\s+prompt\s/m); + expect(program.stdout).toMatch(/^\s+plan\s/m); + // Named for its result where a person choosing a command reads, and the + // spelling it replaced is listed nowhere. + expect(program.stdout).toContain( + "Create an executable Plan from a Prompt and review it before writing or running it.", + ); + expect(program.stdout).not.toMatch(/^\s+prompt\s/m); // A session is named or it is not selected, and the refusal happens in // preflight, beside the request's own — a parser that read the empty value // as absent would have used the generated name instead. yield* useWorkingDirectory(function* (dir) { - const empty = yield* runCli(["prompt", REQUEST, "--session", ""], { cwd: dir }).join(); + const empty = yield* runCli(["plan", REQUEST, "--session", ""], { cwd: dir }).join(); expect(empty.code).toBe(1); expect(empty.stderr).toContain("--session needs a name"); expect(empty.stdout).not.toContain("## Built-in components"); @@ -410,7 +449,7 @@ describe( // A flag that only configures running a Plan, without --run: refused // before authorship and before anything reaches the filesystem. for (const flag of [["--journal", "trace.jsonl"], ["--raw"], ["--deny-all"]]) { - const stray = yield* runCli(["prompt", REQUEST, ...flag], { cwd: dir }).join(); + const stray = yield* runCli(["plan", REQUEST, ...flag], { cwd: dir }).join(); expect(stray.code).toBe(1); expect(stray.stderr).toContain(`${flag[0]} configures running the Plan`); expect(stray.stderr).toContain("add --run"); @@ -424,14 +463,14 @@ describe( }); it("C15: individual, aggregate and environment sources resolve and reach the run", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: GREETER }); harness.script({ decision: "Approve" }); yield* useEnvironment({ XMD_PROPS: '{"name":"FromEnv","count":7}' }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST, "--props-name", "Ada", "--props-loud"]), harness.deps, ); @@ -447,13 +486,13 @@ describe( const journalName = "trace.jsonl"; // Stop at review, through the command document's authored failure. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, join(dir, journalName)); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Stop" }); - const code = yield* runPrompt(command(dir, [REQUEST], "out.md"), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); expect(code).toBe(1); expect(harness.executions).toHaveLength(0); @@ -462,12 +501,12 @@ describe( }); // A turn that produced text and then failed. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, join(dir, journalName)); harness.fake.script({ reply: PLAIN, stopReason: "refusal" }); - const code = yield* runPrompt(command(dir, [REQUEST], "out.md"), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); expect(code).toBe(1); expect(harness.reviews).toHaveLength(0); @@ -476,12 +515,12 @@ describe( }); // A terminal property-source failure. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, join(dir, journalName)); harness.fake.script({ reply: PLAIN }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST, "--props-absent", "x"], "out.md"), harness.deps, ); @@ -494,14 +533,14 @@ describe( }); // An approved run's journal holds the document's events and no authorship. - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { const journal = join(dir, journalName); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, journal); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); const trace = yield* readTextFile(journal); @@ -519,8 +558,8 @@ describe( // Default: the exact source on stdout, and nothing runs. Written with // `process.stdout.write`, so what a pipe receives is the bytes and not a // line the command added. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); @@ -535,7 +574,7 @@ describe( written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); return true; }) as typeof process.stdout.write; - return yield* runPrompt(writing(dir, [REQUEST]), harness.deps); + return yield* runPlan(writing(dir, [REQUEST]), harness.deps); }); expect(code).toBe(0); @@ -547,8 +586,8 @@ describe( }); // `--output`: the same bytes in the file, a quiet stdout, and still no run. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: GREETER }); harness.script({ decision: "Approve" }); @@ -563,10 +602,10 @@ describe( written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); return true; }) as typeof process.stdout.write; - return yield* runPrompt( + return yield* runPlan( { ...writing(dir, [REQUEST, "--props-name", "Ada"], "plan.md"), - argv: ["prompt", REQUEST, "--props-name", "Ada", "--output", "plan.md"], + argv: ["plan", REQUEST, "--props-name", "Ada", "--output", "plan.md"], }, harness.deps, ); @@ -581,8 +620,8 @@ describe( }); // `--run`: the Plan runs, and stdout is the Plan's own to use. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: GREETER }); harness.script({ decision: "Approve" }); @@ -597,7 +636,7 @@ describe( written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); return true; }) as typeof process.stdout.write; - return yield* runPrompt(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); + return yield* runPlan(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); }); expect(code).toBe(0); @@ -610,8 +649,8 @@ describe( it("C11: the approved bytes are created exclusively, before the run", function* () { // Created before execution, and byte for byte. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); const run = executor(dir); const seen: boolean[] = []; harness.deps.execute = function* (approved) { @@ -621,7 +660,7 @@ describe( harness.fake.script({ reply: GREETER }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST, "--props-name", "Ada"], "out.md"), harness.deps, ); @@ -634,14 +673,14 @@ describe( }); // An existing path is left exactly as it is, and stops the run. - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { yield* writeTextFile(join(dir, "out.md"), "keep me\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST], "out.md"), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); expect(code).toBe(1); expect(yield* readTextFile(join(dir, "out.md"))).toBe("keep me\n"); @@ -649,13 +688,13 @@ describe( }); // Without the option, no generated source file is created at all. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: GREETER }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); expect(code).toBe(0); // Only what the document itself wrote. @@ -663,22 +702,26 @@ describe( }); }); - it("C15: the approved source runs as an ordinary document under ", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { + it("C15: the approved source runs as an ordinary document under ", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { const journal = join(dir, "trace.jsonl"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, journal); harness.fake.script({ reply: GREETER }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST, "--props-name", "Ada"], "out.md"), harness.deps, ); expect(code).toBe(0); - // The identity the run reports is the deliberate one. - expect(yield* readTextFile(journal)).toContain(""); + // The identity the run reports is the deliberate one, and the command + // owns no other. + const trace = yield* readTextFile(journal); + expect(trace).toContain(""); + expect(trace).not.toContain(""); + expect(trace).not.toContain(""); // Relative filesystem operations resolved the contextual cwd, not the // identity, so the document's own write landed beside the output file. expect(yield* exists(join(dir, "greeting.txt"))).toBe(true); @@ -687,13 +730,13 @@ describe( // A runtime failure is an ordinary run failure: nonzero, with the file the // caller asked for still on disk to hand-edit. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: FAILS_AT_RUN }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST], "out.md"), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); expect(code).toBe(1); expect(yield* readTextFile(join(dir, "out.md"))).toBe(FAILS_AT_RUN); @@ -704,8 +747,8 @@ describe( }); it("C15: a failing test in the approved document reports as a run reports it", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: FAILING_TEST }); harness.script({ decision: "Approve" }); @@ -719,7 +762,7 @@ describe( console.error = (...parts: unknown[]) => { lines.push(parts.map((part) => String(part)).join(" ")); }; - return yield* runPrompt(command(dir, [REQUEST], "out.md"), harness.deps); + return yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); }); expect(code).toBe(1); @@ -738,7 +781,7 @@ describe( }); it("C15: one Agent resolution serves generation and the execution after it", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { const reads: string[] = []; yield* useRecordedEnvironment(reads, { DEFAULT_AGENT_NAME: "settled-agent" }); @@ -760,12 +803,12 @@ describe( expect(stack.defaultAgent).toBe("settled-agent"); expect(timesRead(reads, "DEFAULT_AGENT_NAME")).toBe(1); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir, undefined, stack); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt({ ...command(dir, [REQUEST]), stack }, harness.deps); + const code = yield* runPlan({ ...command(dir, [REQUEST]), stack }, harness.deps); expect(code).toBe(0); // The command document resolved the settled agent rather than a name of @@ -783,14 +826,14 @@ describe( // the turn in flight is cancelled, the provider is dismantled, and no later // phase begins. The barrier is what makes this a gate rather than a race — // the turn it interrupts is known to be running. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.script({ reply: PLAIN, manual: true }); yield* scoped(function* () { const running = yield* spawn(() => - runPrompt(command(dir, [REQUEST], "out.md"), harness.deps), + runPlan(command(dir, [REQUEST], "out.md"), harness.deps), ); yield* harness.fake.startedTurns(1); yield* running.halt(); @@ -804,14 +847,14 @@ describe( // A teardown failure prevents the final admission, the output file and the run, // whatever the command document selected. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.deps.execute = executor(dir); harness.fake.closeFailure = new Error("the profile provider would not close"); harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST], "out.md"), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); expect(code).toBe(1); expect(harness.executions).toHaveLength(0); @@ -820,8 +863,8 @@ describe( // Nothing bounds an authoring turn: the exec and fetch defaults belong to // the document, and the run deadline is the enclosing timebox above. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); // deno-lint-ignore require-yield harness.deps.execute = function* () { return Ok(undefined); @@ -829,17 +872,17 @@ describe( harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); expect(harness.fake.turns[0]?.timeoutMs).toBe(undefined); }); }); - it("C13: the deadline is the whole prompt command's, as it is a run's", function* () { - // Read for prompt exactly as for run: a value that is not a duration fails + it("C13: the deadline is the whole plan command's, as it is a run's", function* () { + // Read for plan exactly as for run: a value that is not a duration fails // the invocation before it prepares anything. for (const flag of ["--timeout", "--timeout-exec", "--timeout-fetch"]) { - const { code, stderr } = yield* runCli(["prompt", REQUEST, `${flag}=abc`]).join(); + const { code, stderr } = yield* runCli(["plan", REQUEST, `${flag}=abc`]).join(); expect(code).toBe(1); expect(stderr).toContain(flag); } @@ -847,7 +890,7 @@ describe( // And it bounds the command rather than only its final document: the // deadline expires while the catalog is still being built, long before any // document exists to bound. - const expired = yield* runCli(["prompt", REQUEST, "--timeout=1ms"]).join(); + const expired = yield* runCli(["plan", REQUEST, "--timeout=1ms"]).join(); expect(expired.code).toBe(1); expect(expired.stderr).toContain("exceeded its --timeout of 1ms and was cancelled"); }); diff --git a/packages/cli/tests/prompt-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts similarity index 87% rename from packages/cli/tests/prompt-command-document.test.ts rename to packages/cli/tests/plan-command-document.test.ts index 452991e3..0eeed583 100644 --- a/packages/cli/tests/prompt-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -1,11 +1,11 @@ /** - * The packaged prompt command document, executed as itself. + * The packaged plan command document, executed as itself. * * This runs the exact Markdown the CLI ships — read through the packaged * loader, not copied into a fixture — so what it proves is what a release does. * The seams around it are deterministic: a scriptable ACP runtime for the one * Agent turn, a scripted Elicitation answer for the review, and a test-only - * validator in the place the prompt profile declares the production one. + * validator in the place the authorship profile declares the production one. * * The include list is empty on purpose. Repository component search must not be * able to supply `Loop`, `If`, `Return`, `Fail`, `CodeBlock` or the validator: @@ -29,8 +29,9 @@ import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { createAcpxProvider } from "@executablemd/acp"; -import { PROMPT_COMMAND_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; -import { AGENT, useWorkingDirectory } from "./support/prompt-harness.ts"; +import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; +import { PLAN_COMMAND_IDENTITY } from "../src/authorship-profile.ts"; +import { AGENT, useWorkingDirectory } from "./support/plan-harness.ts"; import { createFakeAcp, makeRegistry, makeStore } from "./support/fake-acp.ts"; /** @@ -68,7 +69,7 @@ interface CommandRun { } function* runDocument(): Operation { - const source = yield* readPackagedDocument(PROMPT_COMMAND_DOCUMENT); + const source = yield* readPackagedDocument(PLAN_COMMAND_DOCUMENT); const fake = createFakeAcp(); fake.script({ reply: CANDIDATE }); @@ -105,13 +106,13 @@ function* runDocument(): Operation { value = yield* collect( yield* executeInstalled( { - ...retainedSource("", source), + ...retainedSource(PLAN_COMMAND_IDENTITY, source), stream: new InMemoryStream(), includes: [], props: { request: "ask me for my age and write the result to a file", syntax: "## Built-in components\n\n### ``\n", - session: "prompt-command-regression", + session: "plan-command-regression", }, }, [ @@ -161,12 +162,17 @@ function* runDocument(): Operation { return { validated, reviews, prompts: fake.prompts, value, failure }; } -describe("the packaged prompt command document", () => { +describe("the packaged plan command document", () => { it("C2: returns the approved candidate's exact bytes and never reaches exhaustion", function* () { const run = yield* useWorkingDirectory(function* () { return yield* runDocument(); }); + // The root this document ran under is the internal one the host declares: + // no path selects it, and a position naming it says the source is the + // CLI's own. + expect(PLAN_COMMAND_IDENTITY).toBe(""); + // Approving the first valid candidate is one turn and one question. A // repair or revision turn here would mean the loops ran when they had // nothing to fix. diff --git a/packages/cli/tests/prompt.test.ts b/packages/cli/tests/plan.test.ts similarity index 80% rename from packages/cli/tests/prompt.test.ts rename to packages/cli/tests/plan.test.ts index fb6c7d2d..b97b5bf8 100644 --- a/packages/cli/tests/prompt.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -1,9 +1,9 @@ /** - * Tier PR — `xmd prompt` writing a Plan, as the host and the packaged prompt - * command document perform it together (specs/prompt-command-spec.md). + * Tier PR — `xmd plan` writing a Plan, as the host and the packaged plan + * command document perform it together (specs/plan-command-spec.md). * - * The policy under test is `src/documents/prompt-command.md`, reached the way - * the command reaches it: `runPrompt` builds the prompt profile, the packaged + * The policy under test is `src/documents/plan-command.md`, reached the way + * the command reaches it: `runPlan` builds the authorship profile, the packaged * document runs inside it, and every observation here is of that document's * behaviour rather than of a TypeScript loop standing in for it. * @@ -27,23 +27,23 @@ import { API, stat } from "@executablemd/runtime"; import { Elicitation } from "@executablemd/core"; import type { ElicitationRequest } from "@executablemd/core"; -import { runPrompt } from "../src/prompt.ts"; -import type { PromptCommand } from "../src/prompt.ts"; +import { invocationSessionName, runPlan } from "../src/plan.ts"; +import type { PlanCommand } from "../src/plan.ts"; import { - DEFAULT_PROFILE_ROOT, - profileDirectoryFor, - PROMPT_INSTRUCTIONS, -} from "../src/prompt-profile.ts"; -import { scanPromptArgs } from "../src/prompt-args.ts"; + DEFAULT_AUTHORSHIP_ROOT, + authorshipDirectoryFor, + AUTHORSHIP_INSTRUCTIONS, +} from "../src/authorship-profile.ts"; +import { scanPlanArgs } from "../src/plan-args.ts"; import type { AgentStack } from "../src/agent-stack.ts"; import { AGENT, - createPromptHarness, - useProfileRoot, + createPlanHarness, + useAuthorshipRoot, useWorkingDirectory, -} from "./support/prompt-harness.ts"; +} from "./support/plan-harness.ts"; import { makeStore } from "./support/fake-acp.ts"; -import type { PromptHarness } from "./support/prompt-harness.ts"; +import type { PlanHarness } from "./support/plan-harness.ts"; const REQUEST = "write a greeting"; @@ -132,9 +132,9 @@ const STACK: AgentStack = { * execution: the modes that write the Plan instead have their own cases, and * name the mode they mean. */ -function command(dir: string, args: string[], stack: AgentStack = STACK): PromptCommand { - const argv = ["prompt", ...args, "--run"]; - return { argv, scan: scanPromptArgs(argv), include: [dir], run: true, stack }; +function command(dir: string, args: string[], stack: AgentStack = STACK): PlanCommand { + const argv = ["plan", ...args, "--run"]; + return { argv, scan: scanPlanArgs(argv), include: [dir], run: true, stack }; } /** @@ -157,7 +157,7 @@ const PLAN_REQUIREMENTS = [ ].join("\n"); /** Every session key the fake was asked to establish, deduplicated in order. */ -function sessions(harness: PromptHarness): string[] { +function sessions(harness: PlanHarness): string[] { return [...new Set(harness.fake.ensured.map((input) => input.sessionKey))]; } @@ -181,7 +181,7 @@ function decisions(request: ElicitationRequest): unknown { * profile's scope — the only moment the directory exists. */ function watching( - harness: PromptHarness, + harness: PlanHarness, observe: (workdir: string) => Operation, ): () => Operation { return function* () { @@ -249,20 +249,20 @@ function* reported(body: () => Operation): Operation<{ value: T; lines: st } describe( - "Tier PR — xmd prompt writing a Plan", + "Tier PR — xmd plan writing a Plan", { sanitizeOps: false, sanitizeResources: false }, () => { it("C2, C3, C14: the packaged program's own words ask for the document", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { // A repository TypeScript component, so the catalog has to state the one // thing it honestly cannot know without importing the module. yield* writeTextFile(join(dir, "Widget.ts"), "export default function Widget() {}\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); // Exactly one catalog, built with the invocation's own includes. @@ -292,8 +292,8 @@ describe( // The host owns the instruction layer, and owns only the shape of an // answer: the catalog and the request are the program's to send. - expect(harness.fake.ensured[0]?.sessionOptions?.systemPrompt).toBe(PROMPT_INSTRUCTIONS); - expect(PROMPT_INSTRUCTIONS).not.toContain("Built-in components"); + expect(harness.fake.ensured[0]?.sessionOptions?.systemPrompt).toBe(AUTHORSHIP_INSTRUCTIONS); + expect(AUTHORSHIP_INSTRUCTIONS).not.toContain("Built-in components"); // C3: one is one turn. Nothing repaired, nothing retried. expect(harness.fake.prompts).toHaveLength(1); @@ -303,8 +303,8 @@ describe( }); it("C3, C14: every turn that asks for a Plan states the whole requirement", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); // A draft that fails its check, then one that passes, then a revision. // Three turns, one of each kind that produces a Plan. harness.fake.script({ reply: UNRESOLVED }); @@ -313,7 +313,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); const [initial, repair, revision] = harness.fake.prompts; @@ -347,8 +347,8 @@ describe( const keys: string[] = []; const directories: string[] = []; for (const _invocation of [0, 1]) { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: UNRESOLVED }); harness.fake.script({ reply: UNRESOLVED }); harness.fake.script({ reply: UNRESOLVED }); @@ -357,7 +357,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); // A draft, three repairs, a revision: five turns, one session. expect(harness.fake.prompts).toHaveLength(5); @@ -366,8 +366,8 @@ describe( const workdir = String(harness.fake.created[0]?.cwd); directories.push(workdir); // This suite reaches no directory this host would use for real. - expect(workdir.startsWith(`${profileRoot}${sep}`)).toBe(true); - expect(workdir.startsWith(DEFAULT_PROFILE_ROOT)).toBe(false); + expect(workdir.startsWith(`${authorshipRoot}${sep}`)).toBe(true); + expect(workdir.startsWith(DEFAULT_AUTHORSHIP_ROOT)).toBe(false); }); } expect(keys[0]).not.toBe(keys[1]); @@ -379,7 +379,7 @@ describe( // conversation in one directory — and one ACPX store is what turns that // from equal keys into an actually continued session. The root is shared // deliberately, and by this case alone. - yield* useProfileRoot(function* (profileRoot) { + yield* useAuthorshipRoot(function* (authorshipRoot) { const store = makeStore(); const named: string[] = []; const namedDirectories: string[] = []; @@ -387,11 +387,11 @@ describe( const survived: boolean[] = []; for (const _invocation of [0, 1]) { yield* useWorkingDirectory(function* (dir) { - const harness = createPromptHarness({ profileRoot, store }); + const harness = createPlanHarness({ authorshipRoot, store }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt( + const code = yield* runPlan( { ...command(dir, [REQUEST]), session: "ada" }, harness.deps, ); @@ -401,7 +401,7 @@ describe( materializations.push(harness.fake.ensured[0]?.materialization); // A named conversation's directory outlives the invocation, because // its identity is what the next `--session ada` derives. - survived.push(yield* exists(profileDirectoryFor(profileRoot, "ada"))); + survived.push(yield* exists(authorshipDirectoryFor(authorshipRoot, "ada"))); }); } expect(named[0]).toBe(named[1]); @@ -409,7 +409,7 @@ describe( expect(survived).toEqual([true, true]); // The name never reaches the path, and the digest is what the host // derived from it. - expect(namedDirectories[0]).toBe(profileDirectoryFor(profileRoot, "ada")); + expect(namedDirectories[0]).toBe(authorshipDirectoryFor(authorshipRoot, "ada")); expect(namedDirectories[0]).not.toContain("ada"); // The second invocation continued the record the first established @@ -424,8 +424,8 @@ describe( // It exists and is empty while the turn runs, and it is gone once the // profile has torn down — before anything the host does with what was // approved. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); const seen: { workdir?: string; entries?: string[] } = {}; harness.deps.installElicitation = watching(harness, function* (workdir) { @@ -433,15 +433,15 @@ describe( seen.entries = yield* until(readdir(workdir)); }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); expect(seen.entries).toEqual([]); - expect(seen.workdir?.startsWith(`${profileRoot}${sep}`)).toBe(true); + expect(seen.workdir?.startsWith(`${authorshipRoot}${sep}`)).toBe(true); // Handed back non-recursively when the conversation ended, and the root // it lived under is still there for the next one. expect(yield* exists(String(seen.workdir))).toBe(false); - expect(yield* exists(profileRoot)).toBe(true); + expect(yield* exists(authorshipRoot)).toBe(true); // The approved Plan still ran: cleanup is not a failure. expect(harness.executions).toHaveLength(1); }); @@ -449,22 +449,22 @@ describe( // Stopping, a turn that failed, and a cancelled command each hand the // directory back the same way a success does. for (const ending of [ - { name: "stop", drive: (harness: PromptHarness) => harness.script({ decision: "Stop" }) }, + { name: "stop", drive: (harness: PlanHarness) => harness.script({ decision: "Stop" }) }, { name: "a failed turn", - drive: (harness: PromptHarness) => { + drive: (harness: PlanHarness) => { harness.fake.script({ reply: VALID, stopReason: "refusal" }); }, }, ]) { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); if (ending.name === "stop") { harness.fake.script({ reply: VALID }); } ending.drive(harness); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(1); expect(harness.executions).toHaveLength(0); @@ -472,31 +472,31 @@ describe( // question: the directory this ending made is gone, and nothing else // was made in its place. expect(yield* exists(String(harness.fake.created[0]?.cwd))).toBe(false); - expect(yield* until(readdir(profileRoot))).toEqual([]); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); }); } // Cancellation: the turn in flight is interrupted, and the ensure that // hands the directory back runs on the way out like every other one. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID, manual: true }); yield* scoped(function* () { - const running = yield* spawn(() => runPrompt(command(dir, [REQUEST]), harness.deps)); + const running = yield* spawn(() => runPlan(command(dir, [REQUEST]), harness.deps)); yield* harness.fake.startedTurns(1); yield* running.halt(); }); expect(yield* exists(String(harness.fake.created[0]?.cwd))).toBe(false); - expect(yield* until(readdir(profileRoot))).toEqual([]); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); }); // A failure between making the leaf and using it still hands it back. The // claim is taken before the `mkdir`, so there is no window in which a // directory exists that nothing is responsible for. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); // deno-lint-ignore require-yield harness.deps.installElicitation = function* () { @@ -504,7 +504,7 @@ describe( }; const { value, lines } = yield* reported(() => - runPrompt(command(dir, [REQUEST]), harness.deps), + runPlan(command(dir, [REQUEST]), harness.deps), ); expect(value).toBe(1); @@ -512,22 +512,22 @@ describe( // Nothing was built after it, and no empty leaf was left behind. expect(harness.fake.created).toHaveLength(0); expect(harness.executions).toHaveLength(0); - expect(yield* until(readdir(profileRoot))).toEqual([]); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); }); // Establishment failing for a reason of its own leaves nothing behind and // says what it found. The claim is already taken here, so the release runs // and finds no directory it was ever given — which is the one case where // an absent directory is the ordinary answer rather than interference. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const blocked = join(profileRoot, "not-a-directory"); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const blocked = join(authorshipRoot, "not-a-directory"); yield* writeTextFile(blocked, "in the way\n"); - const harness = createPromptHarness({ profileRoot: blocked }); + const harness = createPlanHarness({ authorshipRoot: blocked }); harness.fake.script({ reply: VALID }); const { value, lines } = yield* reported(() => - runPrompt(command(dir, [REQUEST]), harness.deps), + runPlan(command(dir, [REQUEST]), harness.deps), ); expect(value).toBe(1); @@ -542,15 +542,15 @@ describe( // A leaf that disappears under a live conversation is interference, not a // tidy exit. The command fails terminally rather than shrugging at an // absent directory, and nothing it would have done next happens. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); harness.deps.installElicitation = watching(harness, function* (workdir) { yield* rm(workdir, { recursive: true, force: true }); }); const { value, lines } = yield* reported(() => - runPrompt({ ...command(dir, [REQUEST]), output: "out.md" }, harness.deps), + runPlan({ ...command(dir, [REQUEST]), output: "out.md" }, harness.deps), ); expect(value).toBe(1); @@ -570,8 +570,8 @@ describe( // something wrote there while the conversation ran, and this host // authorized nothing to. The draft was approved first, so what is being // observed is a Plan that would otherwise have been written and run. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); let workdir: string | undefined; let planted: string | undefined; @@ -582,7 +582,7 @@ describe( }); const { value, lines } = yield* reported(() => - runPrompt({ ...command(dir, [REQUEST]), output: "out.md" }, harness.deps), + runPlan({ ...command(dir, [REQUEST]), output: "out.md" }, harness.deps), ); // Terminal, and said once: the attempt happens once and either settles @@ -614,12 +614,12 @@ describe( // The same veto C10 proves, watched from the other side: by the time the // host reports what it decided about the approved bytes, the conversation // and its directory are both already gone. - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { const widget = join(dir, "Widget.md"); yield* writeTextFile(widget, "A widget.\n"); const draft = ["# Uses a widget", "", "", ""].join("\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: draft }); let workdir: string | undefined; harness.deps.installElicitation = watching(harness, function* (directory) { @@ -636,7 +636,7 @@ describe( console.error = () => { events.push("reported"); }; - return yield* runPrompt(command(dir, [REQUEST]), harness.deps); + return yield* runPlan(command(dir, [REQUEST]), harness.deps); }); expect(code).toBe(1); @@ -649,14 +649,53 @@ describe( }); }); - it("C5: the prompt profile's ceiling is the host's, and no flag widens it", function* () { + it("C4, C13: the namespace is the command's own, and the retired one is untouched", function* () { + // Where a real host keeps these conversations, and what the generated + // logical name looks like. Both are identities this command owns, so both + // are pinned rather than inferred from a directory a harness supplied. + expect(DEFAULT_AUTHORSHIP_ROOT.endsWith(join(".xmd", "plan", "sessions"))).toBe(true); + expect(DEFAULT_AUTHORSHIP_ROOT).not.toContain(join(".xmd", "prompt")); + + const first = invocationSessionName(); + const second = invocationSessionName(); + expect(first.startsWith("xmd-plan:")).toBe(true); + expect(second.startsWith("xmd-plan:")).toBe(true); + // Unique to the invocation: a second command places a conversation of its + // own rather than continuing the one before it. + expect(first).not.toBe(second); + + // Nothing reads, migrates, empties or removes the namespace the command + // used to keep. A sibling tree beside the one this suite owns is left + // byte for byte as it was found — after a success, and after a failure. + for (const ending of ["approved", "stopped"] as const) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const retired = join(`${authorshipRoot}-retired`, "sessions"); + const sentinel = join(retired, "kept.txt"); + yield* ensureDir(retired); + yield* writeTextFile(sentinel, "not this command's namespace\n"); + + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: VALID }); + harness.script({ decision: ending === "approved" ? "Approve" : "Stop" }); + + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + expect(code).toBe(ending === "approved" ? 0 : 1); + + expect(yield* until(readdir(retired))).toEqual(["kept.txt"]); + expect(yield* readTextFile(sentinel)).toBe("not this command's namespace\n"); + yield* rm(`${authorshipRoot}-retired`, { recursive: true, force: true }); + }); + } + }); + + it("C5: the authorship profile's ceiling is the host's, and no flag widens it", function* () { // This session's own directory, empty, no MCP servers and no native tools // — observed while the command document is still running, because that is // the only moment the claim is about. - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { yield* writeTextFile(join(dir, "secret.txt"), "the caller's tree\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); const seen: { cwd?: string; entries?: string[]; refusals: string[] } = { refusals: [] }; harness.deps.installElicitation = function* () { @@ -688,7 +727,7 @@ describe( ); }; - const code = yield* runPrompt( + const code = yield* runPlan( { ...command(dir, [REQUEST]), session: "ceiling" }, harness.deps, ); @@ -697,7 +736,7 @@ describe( // Not the caller's working directory: this session's, and empty while // the conversation ran. expect(seen.cwd).not.toBe(dir); - expect(seen.cwd).toBe(profileDirectoryFor(profileRoot, "ceiling")); + expect(seen.cwd).toBe(authorshipDirectoryFor(authorshipRoot, "ceiling")); expect(seen.entries).toEqual([]); // Stated rather than omitted: this host configures no MCP server and // allows no native tool on a fresh session. @@ -705,25 +744,25 @@ describe( expect(harness.fake.ensured[0]?.sessionOptions?.allowedTools).toEqual([]); // And the command document is given nothing to act with. expect(seen.refusals).toEqual([ - "xmd prompt asked for a directory, which the prompt profile grants to nothing", - "xmd prompt asked for a command, which the prompt profile grants to nothing", - "xmd prompt asked for the network, which the prompt profile grants to nothing", + "xmd plan asked for a directory, which the authorship profile grants to nothing", + "xmd plan asked for a command, which the authorship profile grants to nothing", + "xmd plan asked for the network, which the authorship profile grants to nothing", ]); }); // Something already in a named session's directory is a refusal, not a // cleanup. It happens before the provider exists, so no session is placed // and no turn is started — and what was there is still there afterwards. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const occupied = profileDirectoryFor(profileRoot, "occupied"); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const occupied = authorshipDirectoryFor(authorshipRoot, "occupied"); yield* ensureDir(occupied); yield* writeTextFile(join(occupied, "someone-elses.txt"), "not mine to delete\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); const { value, lines } = yield* reported(() => - runPrompt({ ...command(dir, [REQUEST]), session: "occupied" }, harness.deps), + runPlan({ ...command(dir, [REQUEST]), session: "occupied" }, harness.deps), ); expect(value).toBe(1); @@ -746,11 +785,11 @@ describe( // `--approve-all` configures the approved document. A native permission // request while the Plan is being written is still denied, privately, and // the turn it belongs to fails. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID, requestsTool: "Bash" }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST], { ...STACK, permissionMode: "approve-all" }), harness.deps, ); @@ -766,24 +805,24 @@ describe( it("C5: two cases' profile roots cannot see or remove one another", function* () { // Roots are made per scope and named by a UUID, so one case's cleanup // cannot reach another's directory even while both are live. - yield* useProfileRoot(function* (mine) { + yield* useAuthorshipRoot(function* (mine) { const marker = join(mine, "mine.txt"); yield* writeTextFile(marker, "still here\n"); - yield* useWorkingDirectory(function* (dir, profileRoot) { - expect(profileRoot).not.toBe(mine); - expect(profileRoot.startsWith(`${mine}${sep}`)).toBe(false); - expect(mine.startsWith(`${profileRoot}${sep}`)).toBe(false); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + expect(authorshipRoot).not.toBe(mine); + expect(authorshipRoot.startsWith(`${mine}${sep}`)).toBe(false); + expect(mine.startsWith(`${authorshipRoot}${sep}`)).toBe(false); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); // The other root is untouched, and this one holds nothing afterwards. expect(yield* readTextFile(marker)).toBe("still here\n"); - expect(yield* until(readdir(profileRoot))).toEqual([]); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); }); expect(yield* readTextFile(marker)).toBe("still here\n"); @@ -791,12 +830,12 @@ describe( }); it("C6: a candidate is inert until the approved document runs", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: WRITES_A_FILE }); harness.script({ decision: "Stop" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(1); // Validated, presented, and never run: the document's own write is the @@ -809,13 +848,13 @@ describe( it("C7: candidate defects earn a repair turn; caller defects escape", function* () { // A defect the agent authored: the root's own frontmatter. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: BROKEN_SOURCE }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); expect(harness.fake.prompts).toHaveLength(2); @@ -837,13 +876,13 @@ describe( }); // A defect the agent authored: two properties generating one option. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: COLLIDING }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); expect(harness.fake.prompts).toHaveLength(2); @@ -855,11 +894,11 @@ describe( // A defect the caller wrote: an option the candidate never declares. It // raises out of the validator, so the program never sees it as feedback. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST, "--props-nothing", "here"]), harness.deps, ); @@ -872,11 +911,11 @@ describe( }); // A defect the caller wrote: aggregate JSON that is not JSON. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); - const code = yield* runPrompt(command(dir, [REQUEST, "--props", "{oops"]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST, "--props", "{oops"]), harness.deps); expect(code).toBe(1); expect(harness.fake.prompts).toHaveLength(1); @@ -885,11 +924,11 @@ describe( }); // A defect the caller wrote: a value this candidate's schema rejects. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: NAME_IS_BOOLEAN }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, [REQUEST, "--props-name=not-a-boolean"]), harness.deps, ); @@ -902,13 +941,13 @@ describe( // A revision that changes what the command line means is the caller's // failure too, and it is caught before the candidate is presented. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: REQUIRES_NAME }); harness.script({ decision: "Request changes", feedback: "make it shout" }); harness.fake.script({ reply: NAME_IS_BOOLEAN }); - const code = yield* runPrompt(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); expect(code).toBe(1); expect(harness.reviews).toHaveLength(1); @@ -919,15 +958,15 @@ describe( it("C8: one base draft, three repairs, and ten presentations", function* () { // Three repairs are available, and the fourth draft is what a person sees. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: UNRESOLVED }); harness.fake.script({ reply: UNRESOLVED }); harness.fake.script({ reply: UNRESOLVED }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); expect(harness.fake.prompts).toHaveLength(4); @@ -937,14 +976,14 @@ describe( // A fourth invalid candidate is repair-exhausted: it reaches review with // its diagnostics, and there is no value that would approve it. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); for (const _draft of [0, 1, 2, 3]) { harness.fake.script({ reply: UNRESOLVED }); } harness.script({ decision: "Stop" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(1); expect(harness.fake.prompts).toHaveLength(4); @@ -965,8 +1004,8 @@ describe( // Ten presentations: nine revisions, and a tenth round with nothing left // to revise into. Each revision starts its own repair budget. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); for (const round of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { harness.fake.script({ reply: VALID }); if (round < 10) { @@ -975,7 +1014,7 @@ describe( } harness.script({ decision: "Stop" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(1); expect(harness.reviews).toHaveLength(10); @@ -994,7 +1033,7 @@ describe( }); it("C9: arbitrary source cannot close the presentation, and stopping is authored", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { // A document that holds a fence of its own, and a run of five backticks. const fenced = [ "Here is a block:", @@ -1007,11 +1046,11 @@ describe( "", ].join("\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: fenced }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); const message = harness.reviews[0].message; @@ -1023,18 +1062,18 @@ describe( // Stop reaches the command document's own ``, with the message the // shipped Markdown wrote. Nothing about it is host policy. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Stop" }); const { value, lines } = yield* reported(() => - runPrompt(command(dir, [REQUEST]), harness.deps), + runPlan(command(dir, [REQUEST]), harness.deps), ); expect(value).toBe(1); expect(lines.join("\n")).toContain( - "xmd prompt stopped at your request. Nothing was output or run.", + "xmd plan stopped at your request. Nothing was output or run.", ); expect(harness.executions).toHaveLength(0); }); @@ -1043,8 +1082,8 @@ describe( // that never validated, the last offering nothing but a way out, and the // sentence says there was never a Plan to approve rather than that // somebody decided to stop. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); for (const round of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { // A base draft and its three repairs, none of which validates. for (const _draft of [0, 1, 2, 3]) { @@ -1058,7 +1097,7 @@ describe( } const { value, lines } = yield* reported(() => - runPrompt(command(dir, [REQUEST], STACK), harness.deps), + runPlan(command(dir, [REQUEST], STACK), harness.deps), ); expect(value).toBe(1); @@ -1069,7 +1108,7 @@ describe( expect(decisions(harness.reviews[9])).toEqual(["Explain what went wrong", "Stop"]); expect(lines).toHaveLength(1); expect(lines[0]).toBe( - "xmd prompt reviewed ten drafts without an approved Plan. Nothing was output or run.", + "xmd plan reviewed ten drafts without an approved Plan. Nothing was output or run.", ); // Stopping asks the coding agent nothing: forty drafting turns and no // forty-first. @@ -1079,8 +1118,8 @@ describe( }); it("C3, C9: the last invalid draft can be explained rather than only stopped", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); for (const round of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) { for (const _draft of [0, 1, 2, 3]) { harness.fake.script({ reply: UNRESOLVED }); @@ -1101,7 +1140,7 @@ describe( harness.fake.script({ reply: explanation }); const { value, lines } = yield* reported(() => - runPrompt(command(dir, [REQUEST]), harness.deps), + runPlan(command(dir, [REQUEST]), harness.deps), ); expect(value).toBe(1); @@ -1140,40 +1179,40 @@ describe( it("C10, C11: the approved bytes are what runs, and props are theirs", function* () { // The command line is unchanged across a revision, and the schema that // resolves it is the approved document's rather than the first draft's. - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: counting("number") }); harness.script({ decision: "Request changes", feedback: "count in words" }); harness.fake.script({ reply: counting("string") }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST, "--props-count", "7"]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST, "--props-count", "7"]), harness.deps); expect(code).toBe(0); // Resolved under the approved bytes: a props object kept from the first // candidate would carry the number 7. expect(harness.executions[0]?.props).toEqual({ count: "7" }); expect(harness.executions[0]?.root.source).toBe(counting("string")); - expect(harness.executions[0]?.root.path).toBe(""); + expect(harness.executions[0]?.root.path).toBe(""); }); // Nothing is stripped. A reply wrapped in a fence is not a document, so it // earns repairs and a review — and what is shown is exactly what arrived. - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { const wrapped = ["```md", "Hello.", "```", ""].join("\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); for (const _draft of [0, 1, 2, 3]) { harness.fake.script({ reply: wrapped }); } harness.script({ decision: "Stop" }); - yield* runPrompt(command(dir, [REQUEST]), harness.deps); + yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(harness.reviews[0].message).toContain(wrapped); }); }); it("C10: final admission vetoes after the profile has torn down", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { // A repository component the draft uses. It exists while the command // document runs, so the same production validator that answers // finds the draft sound. @@ -1181,7 +1220,7 @@ describe( yield* writeTextFile(widget, "A widget.\n"); const draft = ["# Uses a widget", "", "", ""].join("\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: draft }); const events: string[] = []; harness.deps.installElicitation = function* () { @@ -1214,7 +1253,7 @@ describe( events.push("reported"); lines.push(parts.map((part) => String(part)).join(" ")); }; - return yield* runPrompt(command(dir, [REQUEST]), harness.deps); + return yield* runPlan(command(dir, [REQUEST]), harness.deps); }); // The draft was sound enough to approve, and the approved bytes are @@ -1241,7 +1280,7 @@ describe( }); it("C14: an interleaved Plan survives approval and execution byte for byte", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { // What the shipped instruction asks for: the request restated in prose a // reader was written for, with each component beside the sentences that // describe what it does. @@ -1261,11 +1300,11 @@ describe( "", ].join("\n"); - const harness = createPromptHarness({ profileRoot }); + const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: plan }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt( + const code = yield* runPlan( command(dir, ["ask me for my age and write it to a file"]), harness.deps, ); @@ -1281,8 +1320,8 @@ describe( }); it("C3, C9: what you read says each thing once, however many rounds it took", function* () { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); // Round one: a draft that cannot be repaired, presented with its problems. for (const _draft of [0, 1, 2, 3]) { harness.fake.script({ reply: UNRESOLVED }); @@ -1292,7 +1331,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPrompt(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); expect(code).toBe(0); const [exhausted, approvable] = harness.reviews.map((review) => review.message); @@ -1325,8 +1364,8 @@ describe( // presentation or the tenth: an approvable Plan existed and you chose to // stop, and ten rounds of it do not accumulate into a different sentence. for (const rounds of [1, 10]) { - yield* useWorkingDirectory(function* (dir, profileRoot) { - const harness = createPromptHarness({ profileRoot }); + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); for (const round of Array.from({ length: rounds }, (_, i) => i + 1)) { harness.fake.script({ reply: VALID }); if (round < rounds) { @@ -1336,14 +1375,14 @@ describe( harness.script({ decision: "Stop" }); const { value, lines } = yield* reported(() => - runPrompt(command(dir, [REQUEST]), harness.deps), + runPlan(command(dir, [REQUEST]), harness.deps), ); expect(value).toBe(1); // One line, from the document's own , and no accumulated // repetition of it however many rounds preceded it. expect(lines).toHaveLength(1); - expect(occurrences(lines[0], "xmd prompt ")).toBe(1); + expect(occurrences(lines[0], "xmd plan ")).toBe(1); expect(lines[0]).toContain("stopped at your request"); }); } diff --git a/packages/cli/tests/support/prompt-harness.ts b/packages/cli/tests/support/plan-harness.ts similarity index 87% rename from packages/cli/tests/support/prompt-harness.ts rename to packages/cli/tests/support/plan-harness.ts index 59b7d630..54864715 100644 --- a/packages/cli/tests/support/prompt-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -1,5 +1,5 @@ /** - * The deterministic seams `xmd prompt` is proven against. + * The deterministic seams `xmd plan` is proven against. * * Every phase the command owns is driven in process: the ACPX runtime is the * scriptable fake, the review provider is a scripted `Elicitation` handler, the @@ -23,11 +23,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { API, useHostFiles } from "@executablemd/runtime"; import { syntaxCatalog } from "../../src/syntax.ts"; -import type { PromptDependencies, PromptExecution } from "../../src/prompt.ts"; +import type { PlanDependencies, PlanExecution } from "../../src/plan.ts"; import { createFakeAcp, makeRegistry, makeStore } from "./fake-acp.ts"; import type { FakeAcp, FakeStore } from "./fake-acp.ts"; -/** The agent every prompt case drives, and the command it resolves to. */ +/** The agent every plan case drives, and the command it resolves to. */ export const AGENT = "scripted-agent"; /** @@ -43,23 +43,23 @@ export interface ScriptedReview { raw?: unknown; } -export interface PromptHarness { +export interface PlanHarness { fake: FakeAcp; /** Every catalog request, by the includes it was made with. */ catalogCalls: string[][]; /** Every review request a provider was asked, in order. */ reviews: ElicitationRequest[]; /** Every approved execution that reached the executor. */ - executions: PromptExecution[]; + executions: PlanExecution[]; /** What the executor answers with, once each, then `Ok`. */ executionResults: Result[]; /** Review answers, taken in order. Running out is a test defect, not a case. */ script(review: ScriptedReview): void; - /** The dependencies `runPrompt` is driven with. */ - deps: PromptDependencies; + /** The dependencies `runPlan` is driven with. */ + deps: PlanDependencies; } -export function createPromptHarness(options: { +export function createPlanHarness(options: { /** * Where this harness keeps its profile session directories. * @@ -68,7 +68,7 @@ export function createPromptHarness(options: { * developer's own home, and two cases running close together could not tell * whose was whose. */ - profileRoot: string; + authorshipRoot: string; /** Replace the catalog entirely, for a case about catalog failure. */ catalog?: (includes: readonly string[]) => Operation; /** @@ -79,15 +79,15 @@ export function createPromptHarness(options: { * session is continued or created a second time. */ store?: FakeStore; -}): PromptHarness { +}): PlanHarness { const fake = createFakeAcp(); const catalogCalls: string[][] = []; const reviews: ElicitationRequest[] = []; - const executions: PromptExecution[] = []; + const executions: PlanExecution[] = []; const executionResults: Result[] = []; const answers: ScriptedReview[] = []; - const harness: PromptHarness = { + const harness: PlanHarness = { fake, catalogCalls, reviews, @@ -106,7 +106,7 @@ export function createPromptHarness(options: { catalogCalls.push([...includes]); return yield* (options.catalog ?? syntaxCatalog)(includes); }, - profileRoot: options.profileRoot, + authorshipRoot: options.authorshipRoot, *installElicitation() { yield* Elicitation.around( { @@ -147,21 +147,21 @@ export function createPromptHarness(options: { * somewhere. */ export function* useWorkingDirectory( - body: (dir: string, profileRoot: string) => Operation, + body: (dir: string, authorshipRoot: string) => Operation, ): Operation { - const dir = join(tmpdir(), `xmd-prompt-${randomUUID()}`); + const dir = join(tmpdir(), `xmd-plan-${randomUUID()}`); // A sibling rather than a child: the working directory is what the approved // document writes into and what several cases read back, and a profile root // inside it would show up in those listings. - const profileRoot = `${dir}-profile`; + const authorshipRoot = `${dir}-profile`; yield* ensureDir(dir); - yield* ensureDir(profileRoot); + yield* ensureDir(authorshipRoot); return yield* scoped(function* () { yield* ensure(() => rm(dir, { recursive: true, force: true })); // Recursive, and safe because it is: everything under this root was created // by this scope, so nothing here can reach a directory another case or a // real invocation owns. - yield* ensure(() => rm(profileRoot, { recursive: true, force: true })); + yield* ensure(() => rm(authorshipRoot, { recursive: true, force: true })); yield* API.Env.around({ // deno-lint-ignore require-yield *cwd() { @@ -172,7 +172,7 @@ export function* useWorkingDirectory( // the runtime entrypoint installs it: a document that reaches the // filesystem must reach the caller's, or fail. yield* useHostFiles(); - return yield* body(dir, profileRoot); + return yield* body(dir, authorshipRoot); }); } @@ -221,8 +221,8 @@ export function timesRead(reads: readonly string[], name: string): number { * by this scope, so nothing here can reach a directory another case — or a real * invocation — is using. */ -export function* useProfileRoot(body: (root: string) => Operation): Operation { - const root = join(tmpdir(), `xmd-prompt-profile-${randomUUID()}`); +export function* useAuthorshipRoot(body: (root: string) => Operation): Operation { + const root = join(tmpdir(), `xmd-plan-profile-${randomUUID()}`); yield* ensureDir(root); return yield* scoped(function* () { yield* ensure(() => rm(root, { recursive: true, force: true })); diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 10da2db1..cd327325 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -384,13 +384,13 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources ); }); - it("SX11: the catalog is inspection, and prompt is the command that writes with it", function* () { + it("SX11: the catalog is inspection, and plan is the command that writes with it", function* () { const { stdout } = yield* runCli(["--help"]).expect(); expect(stdout).toContain("syntax"); - // `xmd prompt` is the other reader of this catalog: it feeds the same + // `xmd plan` is the other reader of this catalog: it feeds the same // structured value to a generator rather than spawning this command and // parsing what it printed. - expect(stdout).toMatch(/^\s+prompt\s/m); + expect(stdout).toMatch(/^\s+plan\s/m); }); }); diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 74955cff..53a7bda0 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -107,8 +107,8 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // absent from the package unless the build copies it — and the command // would then find no program to run, on Node and Bun while Deno stayed // green. - expect(yield* readTextFile(path.join(OUT_DIR, "esm/src/documents/prompt-command.md"))).toBe( - yield* readTextFile(path.join(ROOT, PKG_DIR, "src/documents/prompt-command.md")), + expect(yield* readTextFile(path.join(OUT_DIR, "esm/src/documents/plan-command.md"))).toBe( + yield* readTextFile(path.join(ROOT, PKG_DIR, "src/documents/plan-command.md")), ); }); }); diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index dcec3b24..3d31cea5 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -477,7 +477,7 @@ own session store and one empty working directory per session, both disposable. ## Command-line configuration -`xmd run` and `xmd prompt` configure the agent stack; the options belong to +`xmd run` and `xmd plan` configure the agent stack; the options belong to those two commands, and `xmd test` rejects them, driving agents through the deterministic test-agent stack instead. @@ -531,9 +531,9 @@ The default agent resolves in order, each entry overriding the ones above it: The installed provider belongs to the run's `DocumentExecution` scope and closes during its teardown. -### The `xmd prompt` prompt profile +### The `xmd plan` authorship profile -`xmd prompt` resolves that configuration once and uses it for the prompt command +`xmd plan` resolves that configuration once and uses it for the plan command document that writes the Plan, and — when `--run` asks for one — for the run that follows. Without `--run` the approved Plan is written rather than executed, so the second consumer never appears and the resolved configuration is used once. @@ -546,13 +546,13 @@ consumers, not the flags that produced it: `DEFAULT_AGENT_NAME` is read once per invocation, and authorship and a run of the Plan cannot reach different conclusions from one command line. -The prompt profile takes the provider name and the default agent from that +The authorship profile takes the provider name and the default agent from that answer, and nothing else. Its ceiling is the host's, assembled for that one document and not readable from the command line: | The profile's provider gets | Stated as | | --- | --- | -| one host-owned directory per logical session | `agentCwd`, `~/.xmd/prompt/sessions/`, required empty | +| one host-owned directory per logical session | `agentCwd`, `~/.xmd/plan/sessions/`, required empty | | no MCP servers | `mcpServers: []`, an empty set rather than an omission | | no native tools on a fresh session | `newSessionOptions.allowedTools: []` | | a private refusal of every native permission request | `permissions: "strict"` | @@ -570,7 +570,7 @@ network capability, and the host decides for that whole execution that a failing nothing. The profile's working directory is derived from the logical session name rather -than shared or freshly made: `~/.xmd/prompt/sessions/`, with the +than shared or freshly made: `~/.xmd/plan/sessions/`, with the digest in the path and never the name. A session's key includes the directory it lives in, so a shared location would put two conversations in one ambient directory while a fresh one would leave `--session` unable to name a conversation @@ -595,7 +595,7 @@ one fixed statement — that an answer belongs to the message that asked for it and nothing else. Which shape any particular message wants, a Plan or an explanation of why there is not one, is that message's own business, and every message is the command document's to send in its own turns -([`xmd prompt`](./prompt-command-spec.md)). ACPX applies those options when it +([`xmd plan`](./plan-command-spec.md)). ACPX applies those options when it creates a session and ignores them when it reuses a record. Without `--session` the command places a name unique to the invocation, so each invocation is a fresh conversation created under those instructions; `--session ` selects an diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 990a9c66..9aa6f7ca 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -8237,15 +8237,15 @@ identity, and no temporary file is created: the text is captured inside the durable root import, so the journal holds it and a replay restores it without reading anything. -Text a host generated reports `` on the same terms. `xmd prompt` runs a +Text a host generated reports `` on the same terms. `xmd plan` runs a packaged first-party command document that turns a Prompt into a Plan — asking a coding agent for a complete root, having the host check it, and having a person approve it; the host then validates the returned bytes again. What happens to them is the caller's choice: by default the exact source goes to stdout, and nothing runs; `--output` writes those bytes to a file instead; and only `--run` -executes `retainedSource("", source)`, with `--output --run` writing the +executes `retainedSource("", source)`, with `--output --run` writing the file first. So the identity says where the bytes came from, and a source position -reads `(:5:1)`. It is a deliberate identity rather than a path nobody +reads `(:5:1)`. It is a deliberate identity rather than a path nobody could read back, and it is the only thing that differs: an approved Plan that is run goes through the ordinary supplied-source path, renders and returns exactly as any other root, resolves the contextual working directory for every relative @@ -8253,11 +8253,11 @@ operation, and creates its journal only when that execution starts — so the mo that write a Plan create none. That command document is the other root the command executes, under its own -stable internal identity ``, on an invocation-owned in-memory +stable internal identity ``, on an invocation-owned in-memory durable stream that is discarded when authorship is over. So nothing about generation, repair or approval is journaled or replayed, and the two executions share no scope. The command's complete contract is -[`xmd prompt`](./prompt-command-spec.md). +[`xmd plan`](./plan-command-spec.md). `inspectDocument(root)` loads and validates the root definition and returns what it declares — without executing the document or creating a journal: @@ -10027,7 +10027,7 @@ itself, so an execution starting anywhere fails the row. Defined in §5.3. | SX4–SX6 | Renderers take a value | Both formats render from a supplied catalog with the filesystem refusing every call, twice with identical bytes, under the fixed category headings; every table cell is escaped, a prop name holding a pipe included | | SX7/SX8 | Includes | Repeated values select in caller order and replace the defaults; absent, the defaults apply | | SX9 | Failure | An unusable include exits 1, reports on stderr and prints no catalog | -| SX10/SX11 | Formats | Markdown by default, version-1 JSON with `--json`; the catalog is inspection, and `xmd prompt` is the command that writes with the same structured value | +| SX10/SX11 | Formats | Markdown by default, version-1 JSON with `--json`; the catalog is inspection, and `xmd plan` is the command that writes with the same structured value | | SX12 | A package tree | Bare `xmd syntax` succeeds with the default includes in a repository whose `node_modules` holds directory links | ### Tier SM — `xmd syntax` end to end @@ -10041,15 +10041,15 @@ One checked-in Markdown suite runs the real command against a fixture directory. | SM6/SM7 | Nothing runs | A `.ts` fixture that throws at top level is origin-only and the command still succeeds; a fixture whose body writes a file leaves no file | | SM8–SM11 | Discovery | Direct, dotted and index paths; the two formats agree; repeated includes replace the defaults in caller order; the built-in category holds the run profile including `` | -### Tier PR — The `xmd prompt` command +### Tier PR — The `xmd plan` command -One root execution on every invocation — the packaged prompt command document, +One root execution on every invocation — the packaged plan command document, which owns the visible generation, repair, review and failure policy — and a second only under `--run`: the Plan it returned, which runs as any supplied root does, behind a complete scope boundary. Without `--run` the approved Plan is the result rather than a second execution: stdout by default, or an exclusively created `--output` file. Defined in -[`xmd prompt`](./prompt-command-spec.md), whose acceptance table this points at +[`xmd plan`](./plan-command-spec.md), whose acceptance table this points at rather than restating. The ACPX runtime is a scriptable fake, the review provider is a scripted `Elicitation` handler, and the contextual working directory is a temporary one — no live agent, browser or network appears in this evidence, and @@ -10058,11 +10058,11 @@ every refusal is proven by the phase tripwires that stayed at zero. | # | Test | Verify | |---|------|--------| | C1 | Fixed grammar and help | One Prompt preserved byte for byte; missing, repeated, empty and whitespace-only Prompts refused; individual options after the Prompt and aggregate props before it; a built-in option never read as a generated property's value; `--session` named or refused; every run-only flag refused without `--run` before any effect; generic help with no effects, explaining `--output` and `--run` | -| C2–C3 | The packaged document is the policy | The command executes the checked-in Markdown value root under ``, and the turn text is that document's own words; generation, repair, review, revision, approval, stopping, exhaustion and the final explanation are Markdown under visible headings, every Plan-producing turn states the complete Plan requirements for itself, `` stays one turn, and what a person reads says each thing once however many rounds it took | -| C4–C6 | Session and ceiling | One enclosing Session carries every turn, defaults differ per invocation and `--session` supplies the exact override; the prompt profile gives the assistant an empty host-owned directory, no MCP servers, no native tools and a private strict denial no permission flag widens; a draft is data until the approved Plan is delivered, and no draft effect happens before that | +| C2–C3 | The packaged document is the policy | The command executes the checked-in Markdown value root under ``, and the turn text is that document's own words; generation, repair, review, revision, approval, stopping, exhaustion and the final explanation are Markdown under visible headings, every Plan-producing turn states the complete Plan requirements for itself, `` stays one turn, and what a person reads says each thing once however many rounds it took | +| C4–C6 | Session and ceiling | One enclosing Session carries every turn, defaults differ per invocation and `--session` supplies the exact override; the authorship profile gives the assistant an empty host-owned directory, no MCP servers, no native tools and a private strict denial no permission flag widens; a draft is data until the approved Plan is delivered, and no draft effect happens before that | | C7–C9 | Classification, bounds and presentation | Draft defects return structured facts and caller defects escape the checker; one base draft plus three repairs, and ten presentations with no revision on the last; arbitrary source cannot close ``, the review schemas expose exactly the friendly choices for each round and state, and stopping, exhaustion and the explanation ending each reach their own authored `` | | C10–C13 | Admission and lifetime | The host revalidates after the command document has completely torn down and resolves props for the exact returned bytes; those bytes reach exactly one of stdout, an exclusively created `--output` file, a run, or a file and then a run; a journal exists only when `--run` begins and holds only the Plan's events; cancellation and teardown failure settle before anything later begins | -| C14–C15 | Result | The shipped generation, repair and revision instructions each carry the complete titled-Plan rule, and a titled Plan of prose interleaved with components survives approval byte for byte into stdout, a file and a run alike; a run reports `` and behaves as `xmd run` does | +| C14–C15 | Result | The shipped generation, repair and revision instructions each carry the complete titled-Plan rule, and a titled Plan of prose interleaved with components survives approval byte for byte into stdout, a file and a run alike; a run reports `` and behaves as `xmd run` does | ### Tier WB — The workflow component bundle in core diff --git a/specs/prompt-command-spec.md b/specs/plan-command-spec.md similarity index 89% rename from specs/prompt-command-spec.md rename to specs/plan-command-spec.md index e35aa57c..c7ed07ed 100644 --- a/specs/prompt-command-spec.md +++ b/specs/plan-command-spec.md @@ -1,12 +1,12 @@ -# The `xmd prompt` command +# The `xmd plan` command -`xmd prompt` turns your **Prompt** — the request you typed, in ordinary language +`xmd plan` turns your **Prompt** — the request you typed, in ordinary language — into a **Plan**: an Executable Markdown document that states what you asked for in ordinary language and places the components that perform the work alongside those words. ```console -$ xmd prompt "ask me for my age and write the result to a file" +$ xmd plan "ask me for my age and write the result to a file" ``` A Plan is not merely executable code. It preserves the Prompt's intent as @@ -40,12 +40,12 @@ file instead, `--run` executes it, and the two together write it and then run it. The command writes no policy of its own. It always executes one root document: -the **prompt command document**, a checked-in first-party Markdown value root +the **plan command document**, a checked-in first-party Markdown value root that implements this conversion and its review workflow — what the coding agent is asked, how many drafts may be repaired, what you are shown, and what happens when you approve nothing. It executes a second root only under `--run`: the Plan that document returned, through the same path `xmd run` uses for a supplied one, -with a complete scope boundary between the two. The prompt command document is +with a complete scope boundary between the two. The plan command document is not itself a Plan. There is no second execution model, no second props model and no second journal. @@ -54,14 +54,14 @@ no second journal. ```text fixed command preflight -> build the run-profile syntax catalog - -> execute the exact packaged prompt command document + -> execute the exact packaged plan command document -> one Session -> generate, check, repair, review, revise, approve, explain or fail -> Return the exact approved Plan source -> await that execution and provider teardown -> validate the returned source again -> --output: exclusively create the file with the exact bytes - -> --run: execute retainedSource("", source) through the ordinary run + -> --run: execute retainedSource("", source) through the ordinary run path; otherwise, with no --output, write the exact bytes to stdout ``` @@ -69,7 +69,7 @@ Each phase hands the next one a value. No phase after the first failure begins, so a refused command line reaches no catalog, a failed turn reaches no review, and a review that stopped reaches no stdout, no file and no run. -Writing a Plan is a conversation, and a conversation is not a run: the prompt +Writing a Plan is a conversation, and a conversation is not a run: the plan command document runs on an invocation-owned in-memory durable stream that is never written to `--journal`, persisted, reused or replayed. Only the approved Plan's execution owns `--journal`. @@ -77,7 +77,7 @@ Plan's execution owns `--journal`. ## Command grammar ```console -xmd prompt [options] [--props- ]… +xmd plan [options] [--props- ]… ``` The request is one positional argument and is text for the agent, never a path. @@ -92,10 +92,10 @@ nobody wrote is never guessed, and no token is silently dropped. `--` ends option parsing, so a request that begins with `-` is written after it: ```console -xmd prompt -- "--this is the request" +xmd plan -- "--this is the request" ``` -`xmd prompt` takes the complete `xmd run -e` execution flag set — `--include`, +`xmd plan` takes the complete `xmd run -e` execution flag set — `--include`, `--verbose`, `--journal`/`-j`, `--raw`, `--agent-provider`, `--default-agent`, `--approve-all`, `--approve-reads`, `--deny-all`, `--no-secret-detection`, and the three timeout options — plus `--output`, `--run` and `--session`. @@ -111,14 +111,14 @@ Without `--run` nothing runs for them to configure, so writing one is refused in fixed preflight rather than accepted and ignored: ```console -$ xmd prompt "…" --journal trace.jsonl +$ xmd plan "…" --journal trace.jsonl --journal configures running the Plan, and without --run this command writes the Plan instead of running it — add --run, or drop --journal ``` The permission flags configure only the approved Plan; authorship does not inherit the final run's permission mode, and no permission flag widens the -prompt profile's ceiling below. +authorship profile's ceiling below. An option this command does not define is refused by name in the same preflight, because the ordinary parser stops at the first option it does not recognize and @@ -130,7 +130,7 @@ and the refusal says where an approved Plan goes now. `--run=true`, `--run=` — is refused there too: ```console -$ xmd prompt "…" --run=false +$ xmd plan "…" --run=false --run does not take a value — write --run to execute the Plan or leave it out to write the Plan ``` @@ -141,15 +141,15 @@ was written and satisfy the run-only gate on the way, which is how `--run=false --journal ` came to be accepted by a command that then created no journal. It establishes nothing and reaches no later phase. -`-e`/`--eval` stays exclusive to `xmd run`. A prompt supplies a request, not a +`-e`/`--eval` stays exclusive to `xmd run`. A plan supplies a request, not a document. Supplying one anyway is refused in the command's own preflight, with -`unrecognized option for xmd prompt: --eval — inline documents are exclusive to +`unrecognized option for xmd plan: --eval — inline documents are exclusive to xmd run`, before the catalog, the command document, the review, the file, the journal or any execution exists. Those options are resolved into one Agent configuration once per invocation — one `--agent-provider`, one `--default-agent` or `DEFAULT_AGENT_NAME`, one -permission mode — and that settled answer is what both the prompt profile and +permission mode — and that settled answer is what both the authorship profile and the approved Plan's installation are configured from. Incompatible permission flags and an unknown provider fail there, before the catalog is built. @@ -159,10 +159,10 @@ The Plan is what the command produces, and where it goes is the caller's choice: | Invocation | Result | | --- | --- | -| `xmd prompt ""` | the exact approved source on **stdout**; nothing runs | -| `xmd prompt "" --output ` | the exact bytes in ``; no source on stdout; nothing runs | -| `xmd prompt "" --run` | the Plan runs; no source is printed, and stdout belongs to the Plan's own output | -| `xmd prompt "" --output --run` | the file is created first, and only a successful write is followed by the run | +| `xmd plan ""` | the exact approved source on **stdout**; nothing runs | +| `xmd plan "" --output ` | the exact bytes in ``; no source on stdout; nothing runs | +| `xmd plan "" --run` | the Plan runs; no source is printed, and stdout belongs to the Plan's own output | +| `xmd plan "" --output --run` | the file is created first, and only a successful write is followed by the run | Stdout carries the approved source and nothing else: no fence, no label, no delimiter and no newline this command added. What a caller reads is what the @@ -182,13 +182,13 @@ grammar rather than read as absent, so a caller who asked for a named session never falls back to the generated one by accident. Ordinary provider session continuation applies when the configured provider -already holds that name. The prompt command document still supplies the current +already holds that name. The plan command document still supplies the current request and the current catalog in this invocation's initial turn. ### Help ```console -xmd prompt --help +xmd plan --help ``` Help needs no request. It describes the Prompt, `--output`, `--run`, @@ -202,11 +202,11 @@ otherwise would mean generating a document in order to describe one. Help reads no catalog, contacts no provider, places no session, asks nobody anything, creates no file and runs nothing. -## The packaged prompt command document +## The packaged plan command document The host executes one immutable packaged Markdown value root, -`packages/cli/src/documents/prompt-command.md`, under the stable internal source -identity ``. Its declared return schema is exactly +`packages/cli/src/documents/plan-command.md`, under the stable internal source +identity ``. Its declared return schema is exactly `{ type: "string" }`. It is located from the CLI module's own URL — never from the working directory and never through the component search path — so the same policy is found whatever directory a person stands in, and no repository file can @@ -239,20 +239,20 @@ narrative survives a draft being replaced. `` remains one Agent turn. It gains no hidden repair, retry, review or approval behaviour. -## The prompt profile +## The authorship profile -The prompt profile is the trusted-host assembly used only for that exact root. It +The authorship profile is the trusted-host assembly used only for that exact root. It supplies the document's inputs, a constrained Agent provider, Elicitation, the fixed first-party components and the host-declared draft validator. It uses no repository component search — that execution's include list is empty — and exposes no custom root. -### Agent authority under the prompt profile +### Agent authority under the authorship profile The assistant that writes a Plan is assembled separately from the final run provider: -- one host-owned directory dedicated to the logical prompt session, created +- one host-owned directory dedicated to the logical session, created empty, required to be empty on the way in, and — for an invocation-unique default session — handed back non-recursively on the way out; - no additional directories; @@ -272,7 +272,7 @@ ceiling refuses before session materialization or a turn; there is no silent downgrade. The profile's working directory is -`~/.xmd/prompt/sessions/`. It is dedicated to that +`~/.xmd/plan/sessions/`. It is dedicated to that one logical session rather than shared by every invocation, so two conversations never see one ambient directory. The leaf is the digest and never the name itself: a logical session name is a caller's string, and a caller's string that @@ -359,7 +359,7 @@ may those exact bytes enter ordinary execution. ## Host-declared draft validation -The prompt profile declares one internal value component to the execution: +The authorship profile declares one internal value component to the execution: ```md @@ -385,13 +385,13 @@ The component performs no candidate execution. For each source it: ## Generated document properties -An `xmd prompt` invocation resolves root props exactly as `xmd run` does — same +An `xmd plan` invocation resolves root props exactly as `xmd run` does — same sources, same precedence, same decoding ([Root Document Props](./root-document-props-spec.md)) — with one difference: the schema comes from the *candidate*, and there may be several candidates. ```console -xmd prompt "greet someone" --props-name Ada --props-loud +xmd plan "greet someone" --props-name Ada --props-loud ``` The aggregate `--props` may be written before the request, because its meaning @@ -421,7 +421,7 @@ A built-in option is never read as a generated property's value. A value that really begins with `-` is written in the unambiguous inline form: ```console -xmd prompt "" --props-name=-Ada +xmd plan "" --props-name=-Ada ``` ### Candidate failures and caller failures @@ -454,11 +454,11 @@ terminal failure and no way to recategorize it as draft feedback. An opaque `not-statically-checkable` invocation is not a diagnostic and does not by itself make a candidate invalid. -## The policy the prompt command document owns +## The policy the plan command document owns The following is the shipped program's behaviour, not the host's. It is stated here because it is what a caller sees; it is changed by editing -`prompt-command.md`, and nothing in TypeScript decides it. Prose quality is that +`plan-command.md`, and nothing in TypeScript decides it. Prose quality is that document's instructions and your review, never a hidden TypeScript validation rule: `` reports structural facts and executes nothing. @@ -549,13 +549,13 @@ belong to those exact source bytes; no props object from an earlier draft is reused. `--output` then exclusively creates the target with those exact bytes. With -`--run`, and only then, the host executes `retainedSource("", source)` +`--run`, and only then, the host executes `retainedSource("", source)` through the ordinary supplied-source run path, with the same includes, output, value result, secret detection, Agent configuration, permission mode and timeouts `xmd run -e` uses. No temporary Markdown file is created. Without `--run` and without `--output`, the exact bytes go to stdout. -The `` identity affects positions and diagnostics only. The contextual +The `` identity affects positions and diagnostics only. The contextual working directory still resolves relative filesystem operations, repository components and includes. The executed program receives a fresh ordinary document Agent provider and inherits neither the assistant Session nor its instruction @@ -592,7 +592,7 @@ Every failure below exits non-zero, and each one stops the phases after it: | a malformed command line, an unknown option, `--save`, or a valued `--run=…` | nothing | | incompatible permission flags or an unknown `--agent-provider` | nothing | | a catalog an include makes unreadable | no command document | -| a provider that cannot establish the prompt profile's ceiling | no session, no turn | +| a provider that cannot establish the authorship profile's ceiling | no session, no turn | | a turn that did not complete | no review, stdout, file or run | | a terminal caller-source failure | no repair, review, stdout, file or run | | the command document's authored `` — stopping, exhaustion, or the ending after an explanation | no stdout, file or run | @@ -603,11 +603,11 @@ Every failure below exits non-zero, and each one stops the phases after it: ## Acceptance -Tier PR. The evidence lives in `packages/cli/tests/prompt-args.test.ts` (fixed -grammar and signatures), `packages/cli/tests/prompt-command-document.test.ts` -(the packaged document executed as itself), `packages/cli/tests/prompt.test.ts` +Tier PR. The evidence lives in `packages/cli/tests/plan-args.test.ts` (fixed +grammar and signatures), `packages/cli/tests/plan-command-document.test.ts` +(the packaged document executed as itself), `packages/cli/tests/plan.test.ts` (the host and the packaged document writing a Plan together) and -`packages/cli/tests/prompt-cli.test.ts` (the command lifecycle, filesystem, +`packages/cli/tests/plan-cli.test.ts` (the command lifecycle, filesystem, journal and execution). The ACPX runtime is a scriptable fake, the review provider is a scripted @@ -619,16 +619,16 @@ produced. | # | Criterion | Required observation | | --- | --- | --- | | C1 | Fixed grammar and help | Prompt cardinality, individual-property ordering, aggregate props before the Prompt, `--session` including its empty-value refusal, run-only flags refused without `--run` before any authorship or filesystem effect, and effect-free generic help that explains `--output` and `--run` | -| C2 | Exact packaged root | The command executes the checked-in Markdown value root under ``; the turn text is that document's own words, and no TypeScript authorship loop or custom root chooses policy | +| C2 | Exact packaged root | The command executes the checked-in Markdown value root under ``; the turn text is that document's own words, and no TypeScript authorship loop or custom root chooses policy | | C3 | Visible policy | Generation, three-turn repair, ten-round review, revision, approval, stopping, exhaustion and the explanation turn are present in Markdown under visible headings; the generation, repair and revision instructions each require the descriptive title and the steps-beside-components structure; `` remains one turn | | C4 | One Session | One enclosing Session expansion carries every turn; two default invocations get different profile directories and session keys, two `--session` invocations get the same directory and key with the raw name absent from the path, and two named invocations sharing one ACPX store continue the established record rather than placing a second | -| C5 | Prompt profile ceiling | This session's own host-owned directory, empty while the command document runs, no MCP servers, no native tools, strict private denial, no Files/command/network capability for the command document, and final-run permission flags that cannot widen any of it; pre-existing content in that directory refuses before any provider, session, turn, review, result or execution and is left untouched | +| C5 | Authorship profile ceiling | This session's own host-owned directory, empty while the command document runs, no MCP servers, no native tools, strict private denial, no Files/command/network capability for the command document, and final-run permission flags that cannot widen any of it; pre-existing content in that directory refuses before any provider, session, turn, review, result or execution and is left untouched | | C6 | Draft inertness | A draft is only data while the Plan is written, and no draft effect occurs before the final execution | | C7 | Validation classification | Candidate failures return structured facts; caller-source failures escape immediately; frozen signatures are checked before token extraction | | C8 | Bounds | One base plus three automatic repairs per draft, and no more than ten human presentations, with no revision offered on the last | | C9 | Safe presentation and authored failure | Arbitrary source cannot close ``; the review schemas expose exactly the friendly choices for each round-and-state; an ordinary **Stop**, a tenth-round exhaustion and the explanation ending reach their distinct authored `` messages, and the closing fallback says no approval was reached rather than repeating exhaustion | | C10 | Final gate | The host revalidates after the command document has completely torn down — a component removed during that teardown makes the unchanged approved bytes fail admission, with no stdout, file, journal or execution — and resolves props for the exact returned bytes | -| C11 | Exact bytes | Approval, stdout, the exclusive `--output` create and `` execution each receive the Agent close value without rewriting or fence removal; `--output --run` writes before it runs, and an existing destination prevents the run | +| C11 | Exact bytes | Approval, stdout, the exclusive `--output` create and `` execution each receive the Agent close value without rewriting or fence removal; `--output --run` writes before it runs, and an existing destination prevents the run | | C12 | Journal separation | Authorship uses only disposable in-memory history; a journal exists only when `--run` begins the final execution, and it begins with the approved Plan | | C13 | Lifetime | Cancellation and every teardown failure settle before final validation, stdout, the file or execution; a default session's directory is empty while its turn runs and gone after teardown on success, stopping, a failed turn and cancellation alike; and one that gained content or vanished under the conversation is preserved as found while the command fails terminally, with no admission, result, journal or execution after it | | C14 | Narrative preservation | The shipped generation, repair and revision instructions carry the narrative-plus-components rule, the assistant receives it with the request and the catalog, and a scripted Plan of prose interleaved with components returns byte for byte after approval | diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 005ede27..7075515a 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -92,7 +92,7 @@ documents at the revision it checks. prerelease marker — so a forgotten bump is visible where the release was made, then refuses to build. On a valid tag it compiles `packages/cli/src/compiled.ts` per target with - `--include packages/code-review-agent --include packages/cli/src/documents/prompt-command.md` + `--include packages/code-review-agent --include packages/cli/src/documents/plan-command.md` and attaches the binaries and sha256 checksums to the tag's GitHub Release. That module is the compiled-binary entrypoint: it installs the `API.Env.command` adapter that @@ -550,8 +550,8 @@ without its browser asset and never published while private. A document-backed command executes first-party Markdown through the ordinary XMD engine rather than a TypeScript policy. A package declares which Markdown it ships by putting it in `src/documents/`; every other Markdown under `src/` — -test documents, scenario fixtures — stays out of the product. `xmd prompt` is -the first such command, and `packages/cli/src/documents/prompt-command.md` is +test documents, scenario fixtures — stays out of the product. `xmd plan` is +the first such command, and `packages/cli/src/documents/plan-command.md` is the document that implements it: the checked-in Markdown is the deployed artifact and the single source of truth, not a generated string mirror of one. @@ -564,7 +564,7 @@ Every build therefore keeps the asset beside its module, at the same relative path: - **source checkout** — the file as committed; -- **`deno compile`** — embedded by `--include packages/cli/src/documents/prompt-command.md`, +- **`deno compile`** — embedded by `--include packages/cli/src/documents/plan-command.md`, in `deno task build` and in `release.yml`'s matrix compile; - **npm (dnt)** — copied by `scripts/build-npm.ts`, which copies each package's `src/documents/` into `esm/src/documents/`, preserving relative location. dnt @@ -582,7 +582,7 @@ The checks that hold this together, each proving a different build: under Deno, Node and Bun, which is what makes it evidence rather than one runtime's opinion. - `scripts/tests/cli-npm-bin.test.ts` builds the real package and asserts - `esm/src/documents/prompt-command.md` is byte-identical to the source. + `esm/src/documents/plan-command.md` is byte-identical to the source. - `scripts/tests/packaged-document.test.ts` holds the two `deno compile` sites to the documents that exist, because that list is the one thing no build discovers for itself. diff --git a/specs/root-document-props-spec.md b/specs/root-document-props-spec.md index dab78b1b..eb133738 100644 --- a/specs/root-document-props-spec.md +++ b/specs/root-document-props-spec.md @@ -354,18 +354,18 @@ than another spelling of the same one. ## Command Scope -Root document props belong to `xmd run` and `xmd prompt`. The inline root +Root document props belong to `xmd run` and `xmd plan`. The inline root document belongs to `xmd run` alone. `xmd test` accepts neither `--props`, `--props-*`, `XMD_PROPS`, `XMD_PROPS_*`, nor `--eval`/`-e`. ## Generated documents -`xmd prompt` resolves root props from the same sources, in the same order, with -the same decoding ([`xmd prompt`](./prompt-command-spec.md)). What differs is +`xmd plan` resolves root props from the same sources, in the same order, with +the same decoding ([`xmd plan`](./plan-command-spec.md)). What differs is where the schema comes from: the document is written by an agent while the command runs, and one invocation may see several drafts of it. -That command executes two roots. The first is the packaged prompt command +That command executes two roots. The first is the packaged plan command document, whose own props — the request, the rendered syntax catalog and the assistant-session name — are host-supplied internal inputs. They are not draft props, they are declared by no command-line option, and they consume none of the @@ -376,7 +376,7 @@ same reason they follow the path under `xmd run` — the schema that gives them meaning arrives after them: ```console -xmd prompt "greet someone" --props-name Ada --props-loud +xmd plan "greet someone" --props-name Ada --props-loud ``` An individual `--props-*` written before the request fails in preflight, before @@ -384,7 +384,7 @@ any catalog, agent, elicitation, save, journal or document operation. The aggregate `--props` may be written before it, because its meaning never depends on a document. -`xmd prompt --help` describes the Prompt, `--output`, `--run`, `--session`, the +`xmd plan --help` describes the Prompt, `--output`, `--run`, `--session`, the aggregate sources and where individual options go. It names no individual option and no default: the document that would declare them does not exist yet, and generating one in order to describe it is not what help does. From 3fa18488edf1d484030988f8f62773532f864ac2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:25:14 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9A=A8=20Name=20the=20Prompt=20in=20t?= =?UTF-8?q?he=20result=20table=20and=20pin=20the=20retired=20namespace=20(?= =?UTF-8?q?#670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec's four result-mode rows wrote `xmd plan ""`, borrowing the final supplied-source identity for the argument the command receives. The command takes the person's Prompt and produces a Plan, so the rows now read `xmd plan ""`. Every other `` names the identity approved bytes run under and is unchanged. The retired-namespace sentinel sat at a `-retired` sibling of the harness's own root — a path no production code consults, so it proved nothing about `.xmd/prompt`. It now sits at a literal `.xmd/prompt/sessions` beside the `.xmd/plan/sessions` the command is given, both under one temporary tree the case owns and removes whole. The case observes that the conversation really ran under the plan namespace before reading the sibling back, so an empty listing cannot stand in for a command that reached neither tree. No production source, and no part of the packaged Markdown document, changes. --- packages/cli/tests/plan.test.ts | 48 ++++++++++++++++------ packages/cli/tests/support/plan-harness.ts | 5 ++- specs/plan-command-spec.md | 8 ++-- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/cli/tests/plan.test.ts b/packages/cli/tests/plan.test.ts index b97b5bf8..12b07f16 100644 --- a/packages/cli/tests/plan.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -115,6 +115,14 @@ function counting(type: "number" | "string"): string { ].join("\n"); } +/** + * What sits in the namespace this command no longer uses. + * + * Compared byte for byte rather than merely counted: a tree that was emptied and + * rebuilt would still hold one entry with that name. + */ +const RETIRED_SENTINEL = "not this command's namespace\n"; + /** A Plan whose effect is visible on the filesystem if anything runs it. */ const WRITES_A_FILE = ['the draft ran', ""].join("\n"); @@ -665,25 +673,41 @@ describe( expect(first).not.toBe(second); // Nothing reads, migrates, empties or removes the namespace the command - // used to keep. A sibling tree beside the one this suite owns is left - // byte for byte as it was found — after a success, and after a failure. + // used to keep. Both namespaces are placed as they sit in a real home — + // `.xmd/plan/sessions` beside `.xmd/prompt/sessions` — under a temporary + // tree this scope creates and removes whole, so the sibling the command + // must not touch is the one it would actually find. Proven after a + // success and after an authored failure alike. for (const ending of ["approved", "stopped"] as const) { - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const retired = join(`${authorshipRoot}-retired`, "sessions"); + yield* useAuthorshipRoot(function* (home) { + const authorshipRoot = join(home, ".xmd", "plan", "sessions"); + const retired = join(home, ".xmd", "prompt", "sessions"); const sentinel = join(retired, "kept.txt"); + yield* ensureDir(authorshipRoot); yield* ensureDir(retired); - yield* writeTextFile(sentinel, "not this command's namespace\n"); + yield* writeTextFile(sentinel, RETIRED_SENTINEL); - const harness = createPlanHarness({ authorshipRoot }); - harness.fake.script({ reply: VALID }); - harness.script({ decision: ending === "approved" ? "Approve" : "Stop" }); + const workdirs: string[] = []; + yield* useWorkingDirectory(function* (dir) { + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: VALID }); + harness.script({ decision: ending === "approved" ? "Approve" : "Stop" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); - expect(code).toBe(ending === "approved" ? 0 : 1); + const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + expect(code).toBe(ending === "approved" ? 0 : 1); + workdirs.push(String(harness.fake.created[0]?.cwd)); + }); + + // The conversation really did run under this namespace — an empty + // listing below would otherwise pass for a command that reached + // neither tree — and its invocation-unique leaf was handed back. + expect(workdirs[0].startsWith(`${authorshipRoot}${sep}`)).toBe(true); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); + // The sibling is exactly as it was found, down to its bytes. + expect((yield* until(readdir(join(home, ".xmd")))).sort()).toEqual(["plan", "prompt"]); expect(yield* until(readdir(retired))).toEqual(["kept.txt"]); - expect(yield* readTextFile(sentinel)).toBe("not this command's namespace\n"); - yield* rm(`${authorshipRoot}-retired`, { recursive: true, force: true }); + expect(yield* readTextFile(sentinel)).toBe(RETIRED_SENTINEL); }); } }); diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index 54864715..e6a9416e 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -215,11 +215,12 @@ export function timesRead(reads: readonly string[], name: string): number { } /** - * A profile-session root this scope creates, owns and removes whole. + * A temporary tree this scope creates, owns and removes whole. * * Owning it is what makes recursive removal safe: everything under it was made * by this scope, so nothing here can reach a directory another case — or a real - * invocation — is using. + * invocation — is using. A case uses it as an authorship root directly, or as + * the home a real host places `.xmd` beneath. */ export function* useAuthorshipRoot(body: (root: string) => Operation): Operation { const root = join(tmpdir(), `xmd-plan-profile-${randomUUID()}`); diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index c7ed07ed..969cc4d5 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -159,10 +159,10 @@ The Plan is what the command produces, and where it goes is the caller's choice: | Invocation | Result | | --- | --- | -| `xmd plan ""` | the exact approved source on **stdout**; nothing runs | -| `xmd plan "" --output ` | the exact bytes in ``; no source on stdout; nothing runs | -| `xmd plan "" --run` | the Plan runs; no source is printed, and stdout belongs to the Plan's own output | -| `xmd plan "" --output --run` | the file is created first, and only a successful write is followed by the run | +| `xmd plan ""` | the exact approved source on **stdout**; nothing runs | +| `xmd plan "" --output ` | the exact bytes in ``; no source on stdout; nothing runs | +| `xmd plan "" --run` | the Plan runs; no source is printed, and stdout belongs to the Plan's own output | +| `xmd plan "" --output --run` | the file is created first, and only a successful write is followed by the run | Stdout carries the approved source and nothing else: no fence, no label, no delimiter and no newline this command added. What a caller reads is what the From 613a40759b0dc4acf6af05b3f72ceeace51db2e2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:48:53 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=94=92=20Refuse=20the=20retired=20`pr?= =?UTF-8?q?ompt`=20token=20before=20it=20reads=20as=20a=20document=20path?= =?UTF-8?q?=20(#670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prompt` names no command, and a first token that names none is a document reference to the default `run` command. So an executable document called `prompt` in the working directory was rendered and executed by `xmd prompt` — exit 0, and a file written — by a caller who wrote what they believed was a command. That is authorship-adjacent execution and a durable effect from the spelling this rename retired. An invocation whose exact first token is `prompt` is now refused at the top of `runXmd()`: before the inline-document scan, before command selection, and before anything reads a path. It fails closed, establishing no catalog, Agent, Session, authorship directory, output, journal or execution: xmd prompt is not a command — use `xmd plan ""` to create a Plan, or `xmd run ./prompt` to run a document named `prompt` The message answers both readings, because the token is ambiguous by construction. `prompt` is still not registered, aliased or listed in help — this is a preflight refusal, not a command. Only the exact first token is recognized, so `xmd run ./prompt`, `xmd run prompt` and `xmd ./prompt` still execute a document legitimately called that; refusing the bare token must not cost the ability to run a file with that name. The earlier claim that falling through to default run satisfied the retired spelling was wrong, and it was written down in the suite commentary, the command spec, the mdx acceptance row and the architecture inventory. All four now describe the fail-closed preflight instead. No production behaviour outside that preflight changes, and the packaged Markdown document is untouched. --- architecture.md | 2 +- packages/cli/src/cli.ts | 22 ++++++- packages/cli/src/plan-args.ts | 29 ++++++++++ packages/cli/tests/plan-args.test.ts | 22 +++++++ packages/cli/tests/plan-cli.test.ts | 85 ++++++++++++++++++++++++++-- specs/executable-mdx-spec.md | 2 +- specs/plan-command-spec.md | 26 ++++++++- 7 files changed, 180 insertions(+), 8 deletions(-) diff --git a/architecture.md b/architecture.md index bcedef6e..7918c110 100644 --- a/architecture.md +++ b/architecture.md @@ -3545,7 +3545,7 @@ Status is measured against main. | --- | --- | --- | | `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset | built on the #632 stack | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | -| `xmd plan` | turns one Prompt into a Plan and delivers it, by executing one root document — and, only under `--run`, a second — with a complete scope boundary between them. First the packaged plan command document, under the internal `` identity, inside the authorship profile: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and one host-declared `` whose closed assessment answers `valid: false` for a defect the draft authored and raises for a defect the command line authored. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship sits outside durability: it runs on an invocation-owned in-memory stream that is never journaled, persisted, reused or replayed. Then, only after that execution and every provider, Prompt task and Elicitation resource inside it has torn down, the host validates the returned Plan again, resolves props for exactly those bytes, and then delivers the approved Plan where the caller asked: to stdout byte for byte by default, to an exclusively created `--output` path, and — only under `--run` — through the ordinary supplied-source path under the `` identity, one ordinary document with its own Agent provider, its own journal and ordinary run output, result and failure behavior. A journal exists only when `--run` begins, and the flags that configure only a run are refused in preflight without it | built on the #659 stack | +| `xmd plan` | turns one Prompt into a Plan and delivers it, by executing one root document — and, only under `--run`, a second — with a complete scope boundary between them. First the packaged plan command document, under the internal `` identity, inside the authorship profile: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and one host-declared `` whose closed assessment answers `valid: false` for a defect the draft authored and raises for a defect the command line authored. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship sits outside durability: it runs on an invocation-owned in-memory stream that is never journaled, persisted, reused or replayed. Then, only after that execution and every provider, Prompt task and Elicitation resource inside it has torn down, the host validates the returned Plan again, resolves props for exactly those bytes, and then delivers the approved Plan where the caller asked: to stdout byte for byte by default, to an exclusively created `--output` path, and — only under `--run` — through the ordinary supplied-source path under the `` identity, one ordinary document with its own Agent provider, its own journal and ordinary run output, result and failure behavior. A journal exists only when `--run` begins, and the flags that configure only a run are refused in preflight without it. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #659 stack | | `` / `printErrors(fn)` | prints failures | built on main | | `` | stops authored work with the sentence its author wrote, raised where it is written. An ordinary overridable core default — never structural, never reserved, so a repository `Fail.md` is chosen ahead of it — with a closed schema of one required non-empty `message` and **self-closing only**: a paired spelling never enters its body, and `as` is refused by the body itself because there is nothing to bind. Every refusal reports the invocation and happens before the authored message, so a document that never reached its decision is never reported as having made one. A valid invocation is the ordinary failure of a function component: an `Error` carrying the exact authored message, positioned at the opening tag, rendering nothing and binding nothing. It carries no `printErrors()` declaration, which is what leaves recovery to an authored `` region under ordinary text-root modes; a value body's `throw` is not replaced there, so the authored failure settles the body ahead of missing-`` settlement. No authority, context, provider, resource, module state or durable operation of its own: replay of a completed root restores the recorded outcome without re-expanding the body | built on the #659 stack | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cd08ceb6..fe9a7f08 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -109,7 +109,15 @@ import { resolvePropsFromSources, } from "./props.ts"; import type { Binding, Extraction } from "./props.ts"; -import { namesPlan, OUTPUT_OPTION, RUN_OPTION, scanPlanArgs, SESSION_OPTION } from "./plan-args.ts"; +import { + namesPlan, + namesRetiredCommand, + OUTPUT_OPTION, + RETIRED_COMMAND_REFUSAL, + RUN_OPTION, + scanPlanArgs, + SESSION_OPTION, +} from "./plan-args.ts"; import type { PlanScan } from "./plan-args.ts"; import { runPlan } from "./plan.ts"; import type { PlanExecution } from "./plan.ts"; @@ -2135,6 +2143,18 @@ export function* runXmd( // gets no machine sessions at all, which is the ordinary ACP behaviour. sessions?: MachineSessionAssembly, ): Operation { + // Before every scanner, before command selection, and before anything reads a + // path. `prompt` names no command, and a first token that names none is a + // document reference to the default `run` command — so a file of that name in + // the working directory would be rendered and executed by a caller who wrote + // a command, not a path. Refused closed here, where there is nothing yet to + // undo: no eval scan, no parse, no catalog, no profile, no document. + if (namesRetiredCommand(args)) { + console.error(RETIRED_COMMAND_REFUSAL); + yield* exit(1); + return; + } + // First, so that no later scanner — help, properties, agent flags — can // mistake the inline document's own text for an option. const evalFlags = readEvalFlags(args); diff --git a/packages/cli/src/plan-args.ts b/packages/cli/src/plan-args.ts index cba430c6..5867d40b 100644 --- a/packages/cli/src/plan-args.ts +++ b/packages/cli/src/plan-args.ts @@ -20,6 +20,35 @@ import { AGGREGATE_OPTION } from "./props.ts"; import type { Binding } from "./props.ts"; export const PLAN_COMMAND = "plan"; + +/** + * The spelling this command had before it was named for its result. + * + * Refused rather than left to the default `run` grammar, which reads a first + * token naming no command as a document reference. A file called `prompt` in + * the working directory would otherwise be rendered and executed by a token + * nobody wrote as a path — an effect from a spelling that is supposed to do + * nothing at all. It is not a command: nothing registers, aliases or lists it, + * and the refusal builds no profile and reads no document. + */ +export const RETIRED_COMMAND = "prompt"; + +/** Whether these arguments lead with the retired spelling, exactly. */ +export function namesRetiredCommand(args: readonly string[]): boolean { + return args[0] === RETIRED_COMMAND; +} + +/** + * What a caller who wrote the retired spelling is told. + * + * Both readings are answered, because the token is ambiguous by construction: + * whoever meant the command is sent to `xmd plan`, and whoever really does have + * a document of that name is shown the spelling that still runs it. + */ +export const RETIRED_COMMAND_REFUSAL: string = + `xmd ${RETIRED_COMMAND} is not a command — use \`xmd ${PLAN_COMMAND} ""\` to create ` + + `a Plan, or \`xmd run ./${RETIRED_COMMAND}\` to run a document named \`${RETIRED_COMMAND}\``; + export const OUTPUT_OPTION = "--output"; export const SESSION_OPTION = "--session"; export const RUN_OPTION = "--run"; diff --git a/packages/cli/tests/plan-args.test.ts b/packages/cli/tests/plan-args.test.ts index 4c6b9d40..ed0550bc 100644 --- a/packages/cli/tests/plan-args.test.ts +++ b/packages/cli/tests/plan-args.test.ts @@ -15,6 +15,8 @@ import { expect } from "@executablemd/test-support/expect"; import { isReservedOption, namesPlan, + namesRetiredCommand, + RETIRED_COMMAND_REFUSAL, scanPlanArgs, signatureFailure, signatureOf, @@ -42,6 +44,26 @@ describe("Tier PR — xmd plan fixed grammar", () => { // spelling selects no command, so it never reaches this grammar at all. expect(namesPlan(["prompt", REQUEST])).toBe(false); + // It is refused instead of being left to the default `run` grammar, which + // would read a token naming no command as a document reference. Recognized + // by the exact first token and nothing else, so `prompt` written as an + // argument — including a document that really is called that — is untouched. + expect(namesRetiredCommand(["prompt"])).toBe(true); + expect(namesRetiredCommand(["prompt", REQUEST])).toBe(true); + expect(namesRetiredCommand(["plan", REQUEST])).toBe(false); + expect(namesRetiredCommand(["run", "prompt"])).toBe(false); + expect(namesRetiredCommand(["run", "./prompt"])).toBe(false); + expect(namesRetiredCommand(["./prompt"])).toBe(false); + expect(namesRetiredCommand(["prompt.md"])).toBe(false); + expect(namesRetiredCommand([])).toBe(false); + + // Both readings are answered: the command somebody meant, and the document + // they may actually have. + expect(RETIRED_COMMAND_REFUSAL).toBe( + 'xmd prompt is not a command — use `xmd plan ""` to create a Plan, or ' + + "`xmd run ./prompt` to run a document named `prompt`", + ); + const one = scanPlanArgs(["plan", REQUEST]); expect(one.error).toBe(undefined); expect(one.request).toBe(REQUEST); diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index 8930f502..40ffd25c 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -27,7 +27,12 @@ import { resolveAgentStack } from "../src/agent-stack.ts"; import type { AgentStack } from "../src/agent-stack.ts"; import { runPlan } from "../src/plan.ts"; import type { PlanCommand, PlanExecution } from "../src/plan.ts"; -import { namesPlan, scanPlanArgs } from "../src/plan-args.ts"; +import { + namesPlan, + namesRetiredCommand, + RETIRED_COMMAND_REFUSAL as RETIRED_REFUSAL, + scanPlanArgs, +} from "../src/plan-args.ts"; import { AGENT, createPlanHarness, @@ -68,6 +73,23 @@ const FAILS_AT_RUN = ["```bash exec", "exit 3", "```", ""].join("\n"); const PLAIN = "Nothing but prose.\n"; +const PROBE_HEADING = "Retired token probe"; +const PROBE_SENTINEL = "the document ran"; + +/** + * A document whose filename is the retired command spelling. + * + * It writes a file, because whether the default `run` grammar reached it is a + * fact on disk. A case that only watched stdout would pass against a command + * that executed the document and printed nothing. + */ +const NAMED_LIKE_THE_RETIRED_TOKEN = [ + `# ${PROBE_HEADING}`, + "", + `${PROBE_SENTINEL}`, + "", +].join("\n"); + /** * A document that validates, runs, and fails its own tests. * @@ -319,9 +341,16 @@ describe( it("C1: the retired spelling names no command and reaches no authorship", function* () { // The command is `plan`. The spelling it replaced is not registered, - // aliased or kept as a tombstone, so the fixed grammar never sees it and - // the existing default-run grammar answers instead. + // aliased or kept as a tombstone — and it is not left to the default + // `run` grammar either, which would read it as a document reference. It + // is refused in preflight, before any scanner, parse or path lookup. expect(namesPlan(["prompt", REQUEST])).toBe(false); + expect(namesRetiredCommand(["prompt", REQUEST])).toBe(true); + expect(namesRetiredCommand(["plan", REQUEST])).toBe(false); + // Only as the first token. `prompt` written anywhere else is an ordinary + // argument, and this refusal never reaches it. + expect(namesRetiredCommand(["run", "prompt"])).toBe(false); + expect(namesRetiredCommand(["plan", "prompt"])).toBe(false); yield* useWorkingDirectory(function* (dir) { const home = join(dir, "home"); @@ -332,13 +361,16 @@ describe( }).join(); expect(code).not.toBe(0); + expect(stderr).toContain(RETIRED_REFUSAL); // Nothing this command owns was reached: no approved source on stdout, // no catalog, no provider to report an agent unavailable, no authored // refusal from the plan command document, and nobody asked to review. + // The refusal names `xmd plan` as the spelling to use, so an authored + // failure is told apart by its own words rather than by that name. expect(stdout).toBe(""); expect(stderr).not.toContain("## Built-in components"); expect(stderr).not.toContain("unavailable"); - expect(stderr).not.toContain("xmd plan"); + expect(stderr).not.toContain("Nothing was output or run"); expect(stderr).not.toContain("Request changes"); // And neither namespace exists under the isolated home: the new one was // never opened, and the old one is not read, migrated or created. @@ -350,6 +382,51 @@ describe( }); }); + it("C1: the retired token is refused before it can be read as a document path", function* () { + // The whole reason this is a preflight refusal rather than a fall-through. + // A first token naming no command is a document reference to the default + // `run` command, so a file called `prompt` in the working directory was + // rendered and executed — exit 0, and a file written — by a caller who + // wrote what they believed was a command. Proven on disk: the document + // writes a sentinel, so whether it ran is a fact rather than an inference + // from output nobody produced. + yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + yield* writeTextFile(join(dir, "prompt"), NAMED_LIKE_THE_RETIRED_TOKEN); + const options = { cwd: dir, env: { HOME: home } }; + const sentinel = join(dir, "sentinel.txt"); + + const refused = yield* runCli(["prompt"], options).join(); + + expect(refused.code).not.toBe(0); + expect(refused.stderr).toContain(RETIRED_REFUSAL); + // Neither rendered nor executed: its heading reached no stream, and the + // file it writes was never created. + expect(refused.stdout).toBe(""); + expect(refused.stderr).not.toContain(PROBE_HEADING); + expect(yield* exists(sentinel)).toBe(false); + // No catalog was built, no provider was reached to report an agent + // unavailable, and no authorship directory was placed — so no Agent and + // no Session either. The isolated home has no `.xmd` at all. + expect(refused.stderr).not.toContain("## Built-in components"); + expect(refused.stderr).not.toContain("unavailable"); + expect(yield* exists(join(home, ".xmd"))).toBe(false); + // And nothing else was written: no output file and no journal. The + // document and the home this case made are the only entries. + expect((yield* until(readdir(dir))).sort()).toEqual(["home", "prompt"]); + + // The refusal costs nothing. A document that is legitimately called + // `prompt` still runs, by the spelling the message itself names — a fix + // that made this file unrunnable would trade one defect for another. + const ran = yield* runCli(["run", "./prompt"], options).join(); + + expect(ran.code).toBe(0); + expect(ran.stdout).toContain(PROBE_HEADING); + expect(yield* readTextFile(sentinel)).toContain(PROBE_SENTINEL); + }); + }); + it("C1: inline source is refused before any phase begins", function* () { // `-e` belongs to `xmd run`. `xmd plan` is the command that *writes* a // document, so a second one supplied on the command line is a diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9aa6f7ca..399ebacd 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10057,7 +10057,7 @@ every refusal is proven by the phase tripwires that stayed at zero. | # | Test | Verify | |---|------|--------| -| C1 | Fixed grammar and help | One Prompt preserved byte for byte; missing, repeated, empty and whitespace-only Prompts refused; individual options after the Prompt and aggregate props before it; a built-in option never read as a generated property's value; `--session` named or refused; every run-only flag refused without `--run` before any effect; generic help with no effects, explaining `--output` and `--run` | +| C1 | Fixed grammar and help | One Prompt preserved byte for byte; missing, repeated, empty and whitespace-only Prompts refused; individual options after the Prompt and aggregate props before it; a built-in option never read as a generated property's value; `--session` named or refused; every run-only flag refused without `--run` before any effect; a first token of `prompt` refused in preflight rather than read as a document path, with `xmd run ./prompt` still executing a document of that name; generic help with no effects, explaining `--output` and `--run` | | C2–C3 | The packaged document is the policy | The command executes the checked-in Markdown value root under ``, and the turn text is that document's own words; generation, repair, review, revision, approval, stopping, exhaustion and the final explanation are Markdown under visible headings, every Plan-producing turn states the complete Plan requirements for itself, `` stays one turn, and what a person reads says each thing once however many rounds it took | | C4–C6 | Session and ceiling | One enclosing Session carries every turn, defaults differ per invocation and `--session` supplies the exact override; the authorship profile gives the assistant an empty host-owned directory, no MCP servers, no native tools and a private strict denial no permission flag widens; a draft is data until the approved Plan is delivered, and no draft effect happens before that | | C7–C9 | Classification, bounds and presentation | Draft defects return structured facts and caller defects escape the checker; one base draft plus three repairs, and ten presentations with no revision on the last; arbitrary source cannot close ``, the review schemas expose exactly the friendly choices for each round and state, and stopping, exhaustion and the explanation ending each reach their own authored `` | diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 969cc4d5..e1e51617 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -89,6 +89,30 @@ No request, more than one request, an empty string and a whitespace-only string each fail the invocation. There is no stdin form and no editor form: a request nobody wrote is never guessed, and no token is silently dropped. +### The retired spelling is refused, not absorbed + +`prompt` is not a command. It is not registered, aliased, listed in help or kept +as a tombstone that runs anything, and it does not fall through to the default +`run` command: a first token naming no command is a *document reference*, so a +file called `prompt` in the working directory would be rendered and executed by +a caller who wrote what they believed was a command. + +So an invocation whose exact first token is `prompt` is refused in preflight — +before the inline-document scan, before command selection, and before anything +reads a path: + +```console +$ xmd prompt +xmd prompt is not a command — use `xmd plan ""` to create a Plan, or `xmd run ./prompt` to run a document named `prompt` +``` + +The message answers both readings, because the token is ambiguous by +construction. Nothing else changes: only the exact first token is recognized, so +`xmd run ./prompt`, `xmd run prompt` and `xmd ./prompt` still execute a document +that is legitimately called that. The refusal exits nonzero and establishes +nothing — no catalog, no Agent, no Session, no authorship directory, no output, +no journal and no execution. + `--` ends option parsing, so a request that begins with `-` is written after it: ```console @@ -618,7 +642,7 @@ produced. | # | Criterion | Required observation | | --- | --- | --- | -| C1 | Fixed grammar and help | Prompt cardinality, individual-property ordering, aggregate props before the Prompt, `--session` including its empty-value refusal, run-only flags refused without `--run` before any authorship or filesystem effect, and effect-free generic help that explains `--output` and `--run` | +| C1 | Fixed grammar and help | Prompt cardinality, individual-property ordering, aggregate props before the Prompt, `--session` including its empty-value refusal, run-only flags refused without `--run` before any authorship or filesystem effect, a first token of `prompt` refused in preflight rather than read as a document path — leaving `xmd run ./prompt` still able to execute a document of that name — and effect-free generic help that explains `--output` and `--run` | | C2 | Exact packaged root | The command executes the checked-in Markdown value root under ``; the turn text is that document's own words, and no TypeScript authorship loop or custom root chooses policy | | C3 | Visible policy | Generation, three-turn repair, ten-round review, revision, approval, stopping, exhaustion and the explanation turn are present in Markdown under visible headings; the generation, repair and revision instructions each require the descriptive title and the steps-beside-components structure; `` remains one turn | | C4 | One Session | One enclosing Session expansion carries every turn; two default invocations get different profile directories and session keys, two `--session` invocations get the same directory and key with the raw name absent from the path, and two named invocations sharing one ACPX store continue the established record rather than placing a second |