diff --git a/README.md b/README.md index 4f58944cf..2bc65722f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,17 @@ with a run without a file in between: deno task xmd plan "prepare the release" | deno task xmd run - ``` +That composition is the whole relationship between the two commands: + +```text +Plan produces a program. +Run executes a program from the host/CLI. +Composition decides whether and when a planned program runs. +``` + +`xmd plan` writes approved source and stops — to standard output, or to an +`--output` file it creates exclusively. Running it is a command you write. + Preparation is common to all of them, so the block above is the document's own preamble: whichever target you select runs it first, and the checkout is prepared again before the work you asked for. diff --git a/architecture.md b/architecture.md index 11ad5e67e..88f1bdaf4 100644 --- a/architecture.md +++ b/architecture.md @@ -41,7 +41,7 @@ Existing documents and code get aligned to this section retroactively. | 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 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 | 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 as source: printed to stdout by default, or written to an `--output` file. Plan produces a program; Run executes a program from the host or CLI; composition decides whether and when a planned program runs. 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. It is the command's adapter and nothing else: it projects the request into ``, supplies the session, and returns the approved source. It is not itself a Plan. Internal: no command-line option selects another one, and no repository component search can answer for it | | packaged `` Component | the one exact checked-in first-party Markdown text component that converts a prompt into a Plan, `packages/cli/src/documents/Plan.md`, declared to every ordinary run as the public ``. It owns and implements the Plan authorship workflow — the Prompt wording, the draft and repair loops, the `` branches, human review, revision, approval, stopping, exhaustion and the automatic final explanation turn — and produces the exact approved Plan source as what it renders: written bare it emits those bytes, and `as` is ordinary text capture that binds them and emits nothing. Neither form evaluates the 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. Both surfaces expand these exact bytes under one origin and one digest; there is no generated TypeScript copy and no second Markdown implementation. Its four phase components are private to it, and it is not itself a Plan | | authorship profile | the trusted-host assembly the packaged `` Component runs its authored turns under, installed by its own `` inside the invocation that owns it rather than around an execution — which is what makes it the same frame whether `xmd plan`, an ordinary document, or a configured `` run child asked. Which Agent context goes under it is a trusted-host capability the declaration carries — the production ACPX one built from the run's Agent stack, or the deterministic one a canonical `` child declaration produced — and a host that supplies none states the sentence a `` written there is refused with. The fixed policy is installed in one place for both, so a second provider cannot bring a weaker one: 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 policy it installs 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 | @@ -3828,8 +3828,8 @@ 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 — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: 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 the Component's own private `` 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 every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host validates the returned Plan again against the command line, 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 #660 stack | -| `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and binds the exact approved Plan source. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, one optional non-empty `session` prop, and a required `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is returned rather than refused. It prints no source, creates no file, and executes nothing it returns | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | +| `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: 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 the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. 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 every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, and ordinary help and version. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, `--verbose`/`-V`, `--journal`/`-j`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No invocation writes a journal, and no permission mode is settled at all. 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 #660 stack | +| `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | | `` / `` | chooses one branch by comparing a value with `===`. `` decides its whole case structure from source before evaluating anything, then evaluates the selector once and each non-default matcher at most once in source order, expands the first `===` match — or the final default, or nothing — inline and transparently, and appends no journal event | built on the #692 stack | | `xmd upgrade` | replaces the standalone binary that ran it with a published release, by executing one root document: the packaged upgrade command document, under the internal `` identity, with an empty component search path and no Files, Process, Service, command, Fetch, Agent, Elicitation, workflow or repository capability. That document is an **ordinary streaming text root** — it declares no `returns` and uses neither `` nor `` — so its rendered body is the command's output: each root segment reaches the reader as it completes, and a branch the command did not take contributes no prose, no phase call and no result. Its durable events go to one invocation-local in-memory stream, or to the file `--journal` named and the CLI exclusively created; neither is ever read back, and neither grants any resume or retry authority. Markdown owns the whole of the policy — the exact-tag grammar, which release is selected, semantic-version comparison through the npm `semver` package, which consent an install needs, the status, already-current and installation branches, and the wording of every refusal and every report. A compiled macOS or Linux binary whose platform the release publishes for is the only host that declares the four phases that policy may reach, ``, ``, `` and ``, and it declares them to canonical execution rather than through any contextual Api, middleware, repository lookup, ordinary `xmd run` profile or public syntax catalog; every other entrypoint states its provenance and no authority at all, so an npm, Bun, Deno-source or compiled Windows invocation has no phase to reach and stops at its own refusal before release lookup or any filesystem change. That host alone owns the private half: the exact `process.execPath` spelling it will replace and never a link it resolved, one non-blocking exclusive advisory lock on a stable sidecar beside that file, the bounded anonymous GitHub reads under a scope-bound abort signal, the downloaded bytes, the digest, the staged candidate it runs for its version, and one same-directory rename. Opaque identity is the boundary between the two halves — a release identity per admitted release, then one candidate advancing `downloaded → verified → committed` exactly once, with one installation attempt per invocation — so the document chooses among the releases it was shown and can name no other release, target, asset or destination, skip verification or replay a phase. Before the rename every failure and cancellation leaves the installed file byte-identical; after it the candidate is authoritative and no cleanup restores the old bytes | built on the #659 stack | | `` / `printErrors(fn)` | prints failures | built on main | @@ -3886,7 +3886,7 @@ Status is measured against main. | `Git.Push` under an ordinary run | keeps the same observe/adopt/fast-forward/refuse rules and the same isolated transport aimed at the checkout's admitted `origin`: a destination proven absent is published once, one already naming this exact commit is adopted, one holding a proven ancestor is published over by the same exact non-force refspec, and a divergent or unreadable one is a conflict, with an unreachable host never read as absence. It reconciles no Git-host effect and retains nothing. After a verified performed or adopted publication it stores one private evidence entry — the authenticated Repository identity, canonical checkout root, origin, named branch, destination ref and exact commit — in the provider instance's own closure. A checkout with no admitted `origin` refuses before a credential, a session or a transport exists | built on the #643 stack, Deno and compiled only | | `` and its evidence reads under an ordinary run | share the URL matching, host ceiling, response normalization and low-level GitHub reconciliation, and differ in lifecycle and authority. A read is performed afresh every execution and retained nowhere. An upsert authenticates the Repository selection and the contextual checkout, reads the current named branch and commit, and requires the exact matching entry this provider instance already holds — a Push for another checkout, Repository, origin, destination, branch or commit is irrelevant, a later Push of the same destination supersedes the earlier entry, and missing or conflicting evidence is a local refusal before a credential is opened. Nothing crosses executions: a new run and a new `--journal` run each start with a new invocation identity and empty evidence, and copying a Context value, a component result or a previous trace file grants nothing. Within one invocation the attempt happens at most once; across a process interruption there is no exactly-once claim | built on the #643 stack, Deno and compiled only | | `` under an ordinary run | reaches the same configured transport under the same host ceiling, with no durable envelope: identity is this execution's own opaque invocation identity together with the engine's expansion identity, so an upsert presents an idempotency key a provider can carry and a second run is a new request rather than a resumption. Absent or out-of-ceiling configuration installs no matching provider and sends no credential and no request | built on the #643 stack, Deno and compiled only | -| ordinary repository provider assembly | the Deno source entrypoint and the compiled binary install the live provider for `xmd run` and for an approved `xmd plan --run`, parameterized by the same credential-helper assembly the workflow host uses and by the two existing host configurations, `XMD_WORKFLOW_GITHUB_ISSUES` and `XMD_WORKFLOW_GITHUB_PULL_REQUESTS`, both read and validated before a document runs. A nested `` child receives a fresh instance — its own invocation identity, leases and Push evidence — so nothing it publishes authorizes its parent or a sibling. The outer `xmd test` command and a workflow execution install none. Node and Bun register the vocabulary and install no operational provider, so every repository operation reports an absent provider before a lock, a credential, a subprocess or a request exists | built on the #643 stack | +| ordinary repository provider assembly | the Deno source entrypoint and the compiled binary install the live provider for `xmd run`, parameterized by the same credential-helper assembly the workflow host uses and by the two existing host configurations, `XMD_WORKFLOW_GITHUB_ISSUES` and `XMD_WORKFLOW_GITHUB_PULL_REQUESTS`, both read and validated before a document runs. A nested `` child receives a fresh instance — its own invocation identity, leases and Push evidence — so nothing it publishes authorizes its parent or a sibling. The outer `xmd test` command and a workflow execution install none. Node and Bun register the vocabulary and install no operational provider, so every repository operation reports an absent provider before a lock, a credential, a subprocess or a request exists | built on the #643 stack | | `` under a workflow run | asks one of two questions, decided by its own shape, through a boundary of its own rather than the Git host's. Self-closing with `url` reads that issue and binds `{ url, title, description, tags, assignee }`; paired with `title` upserts and binds exactly `{ url }`, its rendered content being the description. There is no `description` prop. Props are exactly `url`, `title`, optional `tags`, optional `assignee` and — on a read only — optional `provider`; no repository/token/label/milestone/project/comment/close or approval prop. Both forms render nothing. The form is decided before the tracker is read, before any provider is asked and before an `issue_effect` record exists, and that is where a mixed `url`+`title`, a read carrying content or `tags`/`assignee`, an upsert with no content, an upsert naming a `provider`, and an element that is neither are all refused. A read needs no tracker — its URL is the identity; an upsert requires the nearest lexical `` and takes its discriminator only from there. The tracker carries a credential-free `url` and an optional `provider`; the URL is canonicalized — a credential, a query and a fragment are refused rather than stripped — and a nested tracker replaces the whole value for its descendants, never merging members, with the enclosing one restored on leaving. It is composition data, not authority: the provider holds an adapter-private ceiling beside its credentials, admitted before it connects, so a target outside it sends nothing. One stable contextual operation, `executablemd.workflow.issue`, with `read(url, options)` and `upsert(issue, options)`; a provider is ordinary middleware around it, matching its own URLs without a discriminator and only its own name with one, independently per member, with no host-side resolution. Once middleware matches it owns the answer — it never delegates afterwards, and nothing catches its refusal to try somebody else — and a request everyone delegated reaches `NoIssueProvider` unchanged. `issue_effect` records an operation discriminator with the normalized request and result; both forms replay without reaching `IssueApi` and therefore without network access; only an upsert derives an idempotency key, from the operation, the canonical target and the run's own effect identity. Retention excludes credentials, endpoints, payloads, provider identities, origin markers and host paths. Observing, adopting, creating once and recovering an interrupted creation are the provider's, because they are knowledge about what a service can prove; title is never identity, and tags are a code-point-sorted set. The Deno workflow host installs configured GitHub middleware and installs none otherwise, so absence of configuration is fail-closed | built on the #296 stack; GitHub middleware, Deno host | | workflow lifecycle inspection and control | reads status/list/history without advancing a run, recovering a private copy when a crashed source needs rollback; enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | direct read-only inspection and control built on the #367 stack; coordinated recovered inspection built on the #513 stack, Deno provider only | | XMD artifact export, inspection and fork source | seals one run's committed retained state, Workspace roots and workflow definition source closure into one immutable `.xmd` evidence file; opens that file read-only for status/history and admits continuation only by creating a new history fork whose lineage names the artifact identity | specified by `specs/xmd-artifact-spec.md`; the version-1 sealed container, its total read-only verifier, `xmd workflow export` and artifact `status`/`history` are built, Deno provider only — the artifact-source fork remains unbuilt. Inspection is two sibling lifecycle operations, `inspectArtifact()` and `historyArtifact()`, taking a path rather than a run id: a run id names live lifecycle authority and a path names immutable evidence, so neither is a mode of the other. They reach no run store, lock, Workspace, definition reader or external provider, and the artifact path never enters the structural answer | diff --git a/packages/cli/src/agent-config.ts b/packages/cli/src/agent-config.ts index cd3a70ae6..8552d0b80 100644 --- a/packages/cli/src/agent-config.ts +++ b/packages/cli/src/agent-config.ts @@ -10,9 +10,14 @@ import type { PermissionMode } from "@executablemd/core"; -export interface AgentFlags { +/** What settling who writes needs, and the whole of it. */ +export interface AuthorshipFlags { agentProvider: string; defaultAgent: string | undefined; +} + +/** The same, plus the flags that select a document execution's permission mode. */ +export interface AgentFlags extends AuthorshipFlags { approveAll: boolean; approveReads: boolean; denyAll: boolean; diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index aabc2153d..8788b2aea 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -1,14 +1,14 @@ /** - * The one Agent configuration an invocation resolves, and the two things a - * host does with it (specs/acp-client-spec.md §Command-line configuration). + * The Agent configuration an invocation resolves, and what a host does with it + * (specs/acp-client-spec.md §Command-line configuration). * - * `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 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. + * Two commands ask, and they ask for different amounts. `xmd run` settles the + * whole of it and installs the registered provider into the Agent Api so a + * document may reach it. `xmd plan` writes a program and runs none, so it + * settles only who writes — the provider name, the default agent and the + * adapters this build carries — and hands that to the authorship profile. + * Resolving it once, here, is what keeps `DEFAULT_AGENT_NAME` from being read + * twice and answered differently. * * `agent-config.ts` stays the pure flag-to-permission mapping. This module is * where the environment, the provider registry and the host's own machine @@ -37,7 +37,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { resolveAgentConfig } from "./agent-config.ts"; -import type { AgentFlags } from "./agent-config.ts"; +import type { AgentFlags, AuthorshipFlags } from "./agent-config.ts"; import type { MachineSessionAssembly } from "./session-coordinator.ts"; /** @@ -50,55 +50,77 @@ import type { MachineSessionAssembly } from "./session-coordinator.ts"; */ export const DEFAULT_ADAPTER_ROOT: string = join(homedir(), ".xmd", "adapters"); -/** Everything one invocation settled about agents, resolved exactly once. */ -export interface AgentStack { +/** + * Who writes, and what this host launches them with. + * + * The whole of what Plan authorship settles. There is no permission mode here + * because the authorship frame installs its own fixed one, and no command line + * selects it: the flags that choose a permission mode configure a document + * execution, and `xmd plan` starts none. + */ +export interface AuthorshipStack { /** The provider name the caller selected, already known to be registered. */ provider: string; /** The agent every consumer defaults to, environment fallback applied. */ defaultAgent: string; - permissionMode: PermissionMode; - /** - * The ACP adapters this build carries, and where this host puts them. - * - * Part of the one settled answer because both consumers resolve agents - * through it: the assistant that writes a Plan and the run of the approved - * Plan are the same Codex or Claude, launched from the same snapshot. - */ + /** The ACP adapters this build carries, and where this host puts them. */ adapters: EmbeddedAdapters; /** What this host states about machine-wide agent sessions, if anything. */ sessions?: MachineSessionAssembly; } +/** Everything one `xmd run` invocation settled about agents, resolved once. */ +export interface AgentStack extends AuthorshipStack { + permissionMode: PermissionMode; +} + /** - * Read the command line, the environment and the host's assembly into one - * configuration. + * Read the provider selection, the environment and the host's assembly into the + * answer authorship needs. * * A failure comes back as a `Result` rather than as a printed line and an exit, * so the same resolution serves a command that runs a document and one that - * generates one first. + * only writes one. */ -export function* resolveAgentStack( - flags: AgentFlags, +export function* resolveAuthorshipStack( + flags: AuthorshipFlags, sessions: MachineSessionAssembly | undefined, -): Operation> { - const config = resolveAgentConfig(flags); - if ("error" in config) { - return Err(new Error(config.error)); - } +): Operation> { if (flags.agentProvider !== "acpx") { return Err(new Error(`Unknown agent provider "${flags.agentProvider}"`)); } const defaultAgent = - config.defaultAgent ?? (yield* readEnv("DEFAULT_AGENT_NAME")) ?? DEFAULT_AGENT_NAME; + flags.defaultAgent ?? (yield* readEnv("DEFAULT_AGENT_NAME")) ?? DEFAULT_AGENT_NAME; return Ok({ provider: flags.agentProvider, defaultAgent, - permissionMode: config.permissionMode, adapters: createEmbeddedAdapters(DEFAULT_ADAPTER_ROOT), ...(sessions === undefined ? {} : { sessions }), }); } +/** + * The same answer, plus the permission mode the document a run executes is + * installed under. + */ +export function* resolveAgentStack( + flags: AgentFlags, + sessions: MachineSessionAssembly | undefined, +): Operation> { + const config = resolveAgentConfig(flags); + if ("error" in config) { + return Err(new Error(config.error)); + } + const authorship = yield* resolveAuthorshipStack( + { agentProvider: flags.agentProvider, defaultAgent: config.defaultAgent }, + sessions, + ); + if (!authorship.ok) { + return authorship; + } + return Ok({ ...authorship.value, permissionMode: config.permissionMode }); +} + /** * What this host carries and what it built, stated to the provider. * @@ -112,7 +134,7 @@ export function* resolveAgentStack( * ones a document could replace are not ones. The two advertised sets are stated * by the host, not inherited. */ -export function hostAcpDependencies(stack: AgentStack): AcpxProviderDependencies { +export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDependencies { const { sessions } = stack; const adapters = embeddedAdapterDependencies(stack.adapters); if (sessions === undefined) { diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index c050f5853..cf0f291ae 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -3,9 +3,9 @@ * runs under, and the only thing that ever runs under it * (specs/plan-command-spec.md). * - * `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 + * `xmd plan` executes this root on every invocation, and no other: the Plan it + * returns is source, and running it is a later command's business. This + * module supplies that document's inputs, a * constrained Agent provider, Elicitation, the fixed first-party components and * the host-declared draft checker, and it exposes no custom root and no * repository component search: the document it runs is the one the CLI ships. @@ -52,7 +52,7 @@ import { API } from "@executablemd/runtime"; import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; -import type { AgentStack } from "./agent-stack.ts"; +import type { AuthorshipStack } from "./agent-stack.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; /** @@ -108,8 +108,6 @@ export interface AuthorshipProfile { root: string; /** The Agent context this host can give a Plan, or why it can give none. */ context: Result; - /** Who answers the review question. */ - installElicitation(): Operation; /** * The `` declaration this command runs under. * @@ -119,20 +117,11 @@ export interface AuthorshipProfile { * could supply or a document could reach. */ declaration: DeclaredMarkdownComponent; - /** - * The host's assessment of one candidate. - * - * A candidate-authored failure comes back as `valid: false` and is repairable. - * A caller-source failure raises, which ends that execution: the document - * has no way to catch it and no way to recategorize it as feedback - * for an agent that could not have caused it. - */ - assess(source: string): Operation; } /** What building the constrained provider needs, and nothing more. */ export interface AuthorshipProviderInputs { - readonly stack: AgentStack; + readonly stack: AuthorshipStack; readonly acp?: AcpxProviderDependencies; } @@ -145,9 +134,9 @@ export interface AuthorshipProviderInputs { * middleware can reach or replace — which is what keeps "who may write a Plan" * a question about the host rather than about what a document arranged. * - * Availability is all it decides. What the Plan then runs under — the permission - * mode, the prompt-failure policy, the capability refusals and the session - * directory — is {@link installAuthorshipFrame}'s fixed policy, identical for + * Availability is all it decides. What writing a Plan then happens under — the + * permission mode, the prompt-failure policy, the capability refusals and the + * session directory — is {@link installAuthorshipFrame}'s fixed policy, identical for * every provider, so a second implementation cannot quietly bring a weaker one. */ export interface PlanAuthorship { @@ -222,7 +211,7 @@ export function noAgentContextFrom(provider: string): string { * were when this was the only way to supply one. */ export function planAgentContext( - stack: AgentStack | undefined, + stack: AuthorshipStack | undefined, acp?: AcpxProviderDependencies, ): Result { if (stack === undefined) { @@ -359,7 +348,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { @@ -644,7 +633,7 @@ function* establishDirectory(directory: string): Operation> { * nothing is deleted, because a leaf that changed underneath a conversation * nobody authorized to write there is interference, not a tidying job. The * failure raises out of the profile's scope, so no final admission follows it, - * and the approved Plan reaches no stdout, no file and no run. + * and the approved Plan reaches no stdout and no file. * * A directory the conversation never got — establishment refused it, or never * made it — is a different question, and one already answered: whatever diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1abc830a..63975fd63 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -19,8 +19,8 @@ * * Examples: * xmd run packages/core/examples/hello-world.md - * xmd plan "prepare the release" | xmd run - - * xmd plan "ask me for my age and write the result to a file" + * xmd plan "prepare the release program." + * xmd plan "prepare the release program." | xmd run - * 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 @@ -96,18 +96,17 @@ import { import { installWebComponents, installWebElicitation } from "@executablemd/web"; import { timebox } from "@effectionx/timebox"; import { timeout as runTimeout } from "@executablemd/runtime"; -import { installRunAgentStack, resolveAgentStack } from "./agent-stack.ts"; +import { installRunAgentStack, resolveAgentStack, resolveAuthorshipStack } from "./agent-stack.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./authorship-profile.ts"; import { VERBOSE_REGISTRATION } from "./verbose-component.ts"; import type { AgentStack } from "./agent-stack.ts"; import { reportFailure } from "./report.ts"; -import { TIMEOUT_FLAGS, resolveRunTimeouts } from "./timeouts.ts"; +import { TIMEOUT_FLAGS, resolvePlanTimeout, resolveRunTimeouts } from "./timeouts.ts"; import type { RunTimeouts } from "./timeouts.ts"; import type { AgentFlags } from "./agent-config.ts"; import { FileStream } from "./file-stream.ts"; import { - AGGREGATE_ENV, AGGREGATE_OPTION, buildBindings, declaredProperties, @@ -120,15 +119,12 @@ import type { Binding, Extraction } from "./props.ts"; import { namesPlan, namesRetiredCommand, - OUTPUT_OPTION, + removedPlanOption, 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"; import { runUpgrade } from "./upgrade.ts"; import type { UpgradeAssembly } from "./upgrade.ts"; import { componentSearchPath, resolveTestTarget } from "./test-target.ts"; @@ -183,14 +179,13 @@ const SECRET_DETECTION_FIELD = { }; /** - * Everything a command that ends in a document execution configures. + * Everything a document execution configures. * - * 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, `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. + * `xmd run` alone. `xmd plan` writes a program rather than running one, so it + * declares the few of these that describe *authorship* — the includes the + * catalog is built from, who writes, and one deadline — and none of the rest: + * a journal, a permission mode, an exec deadline or a presentation option would + * each configure work this command never performs. */ const executionFields = { include: { @@ -266,34 +261,47 @@ const runConfig = object({ /** 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."; + "Turn a request into an XMD Plan, review it, and write the approved source."; /** - * `xmd plan` — the Prompt, where the approved Plan goes, and whether it runs. + * `xmd plan` — the request, where the approved source goes, and who writes it. * - * 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. + * Every option that configured *running* a Plan is absent, because this command + * runs nothing: the program it writes is run by a later `xmd run`, and that is + * where a journal, a permission mode, an exec deadline and a root property are + * configured. The generated `--props-*` options are absent for the same reason, + * and for one more — they exist only once a document does, and this command's + * result is the document. */ const planConfig = object({ request: { - description: "the steps the coding agent should turn into a Plan", + description: "the request the coding agent should turn into an XMD Plan", ...field(z.string().optional(), cli.argument()), }, output: { - description: "write the approved Plan here instead of to stdout (path must not exist)", + description: "write the approved source here instead of to stdout (path must not exist)", ...field(z.string().optional()), }, - run: { - description: "run the approved Plan instead of writing it", - ...field(z.boolean(), field.default(false)), - }, session: { description: "logical name for the assistant session (default: unique to this invocation)", ...field(z.string().optional()), }, - ...executionFields, + include: { + description: "component search directory", + ...field(z.array(z.string()), field.default(["components", "."]), field.array()), + }, + agentProvider: { + description: "agent provider for Plan authorship", + ...field(z.string(), field.default("acpx")), + }, + defaultAgent: { + description: "default agent name (overrides DEFAULT_AGENT_NAME)", + ...field(z.string().optional()), + }, + timeout: { + description: "deadline for the whole planning invocation, as a duration (500ms, 30s, 5min)", + ...field(z.string().optional()), + }, }); const testConfig = object({ @@ -660,13 +668,29 @@ function findAgentOnlyFlag(args: string[]): string | undefined { ); } -/** The timeout options, like the agent options, belong to `xmd run` alone. */ +/** The timeout options, like the agent options, belong to a command that runs. */ function findTimeoutFlag(args: string[]): string | undefined { return args.find((arg) => TIMEOUT_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`)), ); } +/** + * The agent and timeout options `xmd plan` still defines. + * + * Planning settles who writes and bounds the whole invocation; it configures no + * execution, so a permission mode, an exec deadline and a fetch deadline reach + * `xmd run` alone. A command refusing one of these says which commands do take + * it, so the answer has to tell the two groups apart. + */ +const PLANNING_FLAGS = new Set(["--agent-provider", "--default-agent", "--timeout"]); + +/** Which commands the option a caller wrote actually belongs to. */ +function belongsTo(flag: string): string { + const [name] = flag.split("="); + return PLANNING_FLAGS.has(name) ? "xmd run and xmd plan" : "xmd run"; +} + /** * Install what the command line asked for, and nothing else: a field nobody * wrote stays as the enclosing scope has it, which for a run is no timeout. @@ -954,7 +978,7 @@ function* runDocument( // imported. The run profile is where `` belongs — a document that writes // one is asking for the same workflow `xmd plan` runs — and the surface is // fixed here, so the thin command adapter cannot supply or derive it and a - // Plan later executed by `--run` is a new ordinary run that receives + // Plan a later `xmd run` executes is an ordinary run that receives // `component` from its own declaration. // // Built whether or not this command settled an Agent stack. A host with none — @@ -1068,7 +1092,8 @@ function* runDocument( // Repository authority belongs to document execution too, and it is this // execution's own: the provider it installs holds an invocation identity, the // leases on the checkouts this document selects, and the evidence of what it - // published. `xmd run` and an approved `xmd plan --run` supply the live one; + // published. `xmd run` supplies the live one; `xmd plan` executes no document + // of a caller's and reaches this line for none. // `xmd test` and every runtime without an operational provider supply the one // that installs nothing, and every repository operation then reports an // absent provider before touching anything. @@ -1206,65 +1231,6 @@ function* runScopedDocument( } } -/** What an approved Plan runs with, beyond the source and its props. */ -export interface PlanExecutionConfig { - include: string[]; - verbose: boolean; - journal: string | undefined; - raw: boolean; - secretDetection: boolean; -} - -/** - * How `xmd plan` runs the document a person approved: exactly as `xmd run` - * runs a supplied one. - * - * 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 plan` is a - * command a person is sitting in front of. - */ -export function planExecutor( - config: PlanExecutionConfig, - stack: AgentStack, - sessions: MachineSessionAssembly | undefined, - installService: HostServiceInstaller, - installRepositories: RepositoryInstaller, -): (approved: PlanExecution) => Operation> { - return (approved) => - scoped(function* (): Operation> { - announceSecretDetection(config.secretDetection); - yield* installWebElicitation(); - return yield* runScopedDocument( - { - root: approved.root, - include: config.include, - verbose: config.verbose, - journal: config.journal, - raw: config.raw, - secretDetection: config.secretDetection, - retainProcessOutput: keepsProcessOutput(config.journal), - }, - { - testing: false, - props: approved.props, - ...(sessions === undefined ? {} : { machineSessions: sessions }), - // The same object generation was configured from. The provider it - // installs is a fresh ordinary one — the profile's scope is already - // gone — but which agent it defaults to and what it may do were - // decided once, for the whole invocation. - agent: stack, - }, - installService, - // An approved plan's second execution is an ordinary run, so it gets - // the ordinary provider — a fresh one, since the authorship profile's - // scope is already gone. - installRepositories, - ); - }); -} - /** * A document-target failure as the command line reports it, or `undefined` * when this failure is not one. @@ -1846,7 +1812,7 @@ function* preparePropsPhase( bindings: [], error: `unrecognized option for xmd ${command}: ${stray} — document properties are ` + - "exclusive to xmd run and xmd plan", + "exclusive to xmd run", }; } if (stray) { @@ -2217,53 +2183,34 @@ const RUN_SOURCE_HELP = [ /** * What `xmd plan --help` says beyond its option list. * - * Generic on purpose. The individual options a run accepts come from the - * document it names; the ones a plan accepts come from a document the agent - * has not written yet, so help describes where they go rather than what they - * are. Answering otherwise would mean generating a document to describe one. + * It answers the two questions the option list cannot: what a request is, and + * what happens to the program once it exists. Both explicit compositions are + * written out, because "planning never runs the approved program" is only half + * an answer without the command line that does. */ const PLAN_REQUEST_HELP = [ - "Exactly one Prompt is required, and it is the steps you want carried out", - "rather than a path. Quote it so the shell passes it as a single argument:", - ' xmd plan "ask me for my age and write the result to a file"', - "", - "A first-party command document turns your Prompt into a Plan: an executable", - "Markdown document that states each step in ordinary language and places the", - "component that carries it out beside those words. xmd checks each draft and", - "repairs definite defects; you approve, request changes or stop before anything", - "leaves the command.", - "", - "The approved Plan is the result. By default it is written to stdout and nothing", - "runs, so it can be piped, diffed or read before you commit to it.", - "", - "Document properties follow the Prompt. The individual options a Plan declares", - "are the generated document's, so they are not listed here:", - ' xmd plan "" --props-name Ada', - "", - ` ${AGGREGATE_OPTION} `, - " Set document properties as a JSON object", - ` Environment: ${AGGREGATE_ENV}`, + "Exactly one request is required. It describes the program you want the coding", + "agent to create, rather than a path. Quote it so the shell passes it as one", + "argument:", + ' xmd plan "Prepare the release program."', "", - ` ${OUTPUT_OPTION} `, - " Write the approved Plan there instead of to stdout. The path must not", - " exist; an existing one is left alone and the command stops.", + "A first-party command document turns the request into an XMD Plan. xmd checks", + "each draft, and you approve, request changes, or stop before source leaves the", + "command.", "", - ` ${RUN_OPTION}`, - " Run the approved Plan instead of writing it. With --output the file is", - " written first, and only a successful write is followed by the run.", + "The approved Plan is the only result. Without --output, stdout contains its", + "exact source bytes and nothing else. With --output, the path is created", + "exclusively after approval; an existing path is left unchanged.", "", - ` ${SESSION_OPTION} `, - " Use this logical assistant session instead of one unique to this run.", + "Planning never runs the approved program. Compose planning and execution", + "explicitly through standard input:", + ' xmd plan "Prepare the release program." | xmd run -', "", - `Options that only configure running a Plan — --journal, --raw, --verbose, the`, - "exec and fetch timeouts, the permission flags and secret detection — are", - `refused without ${RUN_OPTION}, because without it nothing runs for them to`, - "configure.", + "Or preserve the artifact and run it later:", + ' xmd plan "Prepare the release program." --output release.md && xmd run release.md', "", - "Permission flags configure the approved Plan. Writing the Plan is a", - "conversation about text and gives the coding agent nothing: an empty directory", - "of its own, no tools, no MCP servers, and every native permission request", - "denied.", + "A named --session continues the planning conversation. Without it, this", + "invocation uses a unique session.", ].join("\n"); /** @@ -2377,6 +2324,20 @@ function* dispatch( workflowHost: WorkflowHost | undefined, sessions: MachineSessionAssembly | undefined, ): Operation { + // Before the props phase, and before the help short-circuit below. `--help` + // is lifted out of argv early enough that a command's own grammar never sees + // the invocation it was written on, so a Plan command line naming a removed + // option would be answered with a page describing a command that would refuse + // it. It is refused here instead, in either order, having read nothing. + if (namesPlan(helpRequest.args)) { + const removed = removedPlanOption(helpRequest.args); + if (removed !== undefined) { + console.error(removed); + yield* exit(1); + return; + } + } + const propsPhase = yield* preparePropsPhase(helpRequest.args, evalFlags, readStandardInput); if (propsPhase.error) { @@ -2413,6 +2374,16 @@ function* dispatch( return; } + // The rest of what `xmd plan` decides on its own — cardinality, an unknown + // option, an empty session — answered before the shared checks below could + // report one of them as something else. The removed options were answered + // above, ahead of help. + if (propsPhase.plan?.error !== undefined) { + console.error(propsPhase.plan.error); + yield* exit(1); + return; + } + const secretDetectionError = secretDetectionGrammarError(evalFlags.rest); if (secretDetectionError) { console.error(secretDetectionError); @@ -2494,36 +2465,30 @@ function* dispatch( case "plan": { const config = command.config; const scan = propsPhase.plan; - if (scan === undefined) { + if (scan?.request === undefined) { 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 planStack = yield* settleAgentStack( - { - agentProvider: config.agentProvider, - defaultAgent: config.defaultAgent, - approveAll: config.approveAll, - approveReads: config.approveReads, - denyAll: config.denyAll, - }, + // Who writes, and nothing else. There is no permission mode to settle: + // this command starts no program, and the ceiling authorship runs under + // is the host's rather than the command line's. + const authorship = yield* resolveAuthorshipStack( + { agentProvider: config.agentProvider, defaultAgent: config.defaultAgent }, sessions, ); - if (planStack === undefined) { + if (!authorship.ok) { + console.error(authorship.error.message); + yield* exit(1); break; } const exitCode = yield* runPlan( { - argv: helpRequest.args, - scan, + request: scan.request, include: config.include, ...(config.output === undefined ? {} : { output: config.output }), - run: config.run, ...(config.session === undefined ? {} : { session: config.session }), - stack: planStack, + stack: authorship.value, }, { ...(sessions === undefined ? {} : { sessions }), @@ -2533,7 +2498,6 @@ 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: planExecutor(config, planStack, sessions, installService, installRepositories), }, ); if (exitCode !== 0) { @@ -2607,7 +2571,7 @@ function* dispatch( if (strayTimeout) { console.error( `unrecognized option for xmd test: ${strayTimeout} — timeout options are exclusive to ` + - "xmd run and xmd plan", + belongsTo(strayTimeout), ); yield* exit(1); break; @@ -2616,7 +2580,7 @@ function* dispatch( if (agentFlag) { console.error( `unrecognized option for xmd test: ${agentFlag} — agent options are exclusive to ` + - "xmd run and xmd plan", + belongsTo(agentFlag), ); yield* exit(1); break; @@ -2625,7 +2589,7 @@ function* dispatch( if (propsFlag) { console.error( `unrecognized option for xmd test: ${propsFlag} — document properties are exclusive to ` + - "xmd run and xmd plan", + "xmd run", ); yield* exit(1); break; @@ -2675,7 +2639,7 @@ function* dispatch( if (agentFlag) { console.error( `unrecognized option for xmd workflow: ${agentFlag} — agent options are exclusive to ` + - "xmd run and xmd plan", + belongsTo(agentFlag), ); yield* exit(1); break; @@ -2839,17 +2803,18 @@ 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 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. - const executes = + // The two commands a `--timeout` bounds. `xmd plan`'s deadline encloses + // something different from a run's — the catalog, the assistant session, + // every repair, the human review, provider teardown, final validation and the + // artifact — and covers no later program, because it starts none. + const planning = selected !== undefined && !selected.help && selected.name === "plan"; + const bounded = !helpRequest.requested && selected !== undefined && !selected.help && - (selected.name === "run" || selected.name === "plan"); + (selected.name === "run" || planning); - if (!executes) { + if (!bounded) { return yield* dispatch( evalFlags, helpRequest, @@ -2862,7 +2827,9 @@ export function* runXmd( ); } - const timeouts = resolveRunTimeouts(evalFlags.rest); + const timeouts = planning + ? resolvePlanTimeout(evalFlags.rest) + : resolveRunTimeouts(evalFlags.rest); if ("error" in timeouts) { console.error(timeouts.error); yield* exit(1); diff --git a/packages/cli/src/documents/Plan.md b/packages/cli/src/documents/Plan.md index 40b07851d..e881a02e6 100644 --- a/packages/cli/src/documents/Plan.md +++ b/packages/cli/src/documents/Plan.md @@ -46,10 +46,13 @@ here, so the branch that chooses between them is not repeated at every place one of them is raised. - - - - + + + + diff --git a/packages/cli/src/documents/plan-command.md b/packages/cli/src/documents/plan-command.md index 79bda46cd..6e0503123 100644 --- a/packages/cli/src/documents/plan-command.md +++ b/packages/cli/src/documents/plan-command.md @@ -22,11 +22,11 @@ The result is the XMD version of a coding agent’s plan. A conventional Markdow 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 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. +A draft remains text while this workflow reviews it, and nothing in it ever runs +here. After approval, `xmd plan` validates the exact source again and hands it +over: by default it prints the approved XMD source, and `--output` writes that +source to a file instead. Running the program is a separate command, so you +decide whether and when it happens. {props.request} diff --git a/packages/cli/src/plan-args.ts b/packages/cli/src/plan-args.ts index 5867d40b7..569e54d44 100644 --- a/packages/cli/src/plan-args.ts +++ b/packages/cli/src/plan-args.ts @@ -7,18 +7,13 @@ * command line before it builds a catalog, opens a session, asks a person, or * writes a file. * - * The command line is read in two stages because only the first one is fixed. - * The request, the built-in options and the end-of-options separator are known - * from the grammar alone; whether `--props-name` takes a following token depends - * on what the *generated* document declares, and no document exists yet. So this - * stage classifies what it can, records each generated occurrence with the token - * it provisionally read, and leaves the rest to the candidate that supplies the - * schema. + * The grammar is fixed and complete, because the command produces source rather + * than running it: there is no generated document whose declarations could add + * an option. Every option this command does not define is refused here, by + * name, before the general parser can drop it, coerce its value, or read it as + * a second positional. */ -import { AGGREGATE_OPTION } from "./props.ts"; -import type { Binding } from "./props.ts"; - export const PLAN_COMMAND = "plan"; /** @@ -49,60 +44,71 @@ 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}\``; +/** The aggregate root-property option, and the stem the generated ones share. */ +const AGGREGATE_OPTION = "--props"; + export const OUTPUT_OPTION = "--output"; export const SESSION_OPTION = "--session"; -export const RUN_OPTION = "--run"; -/** The built-in options that take a separated value. */ +/** The switch that used to run the approved Plan, and now names its migration. */ +const RUN_OPTION = "--run"; + +/** + * What every `--run` spelling is answered with. + * + * The option is gone rather than inert, so the message says what replaced it: + * planning writes source, and a caller who wants that source to run composes + * the two commands themselves. Both compositions are shown, because the choice + * between them is whether the artifact is kept. + */ +export const RUN_REMOVAL_REFUSAL: string = [ + "xmd plan --run was removed because xmd plan only produces approved source.", + "Run the program explicitly:", + ' xmd plan "..." | xmd run -', + ' xmd plan "..." --output release.md && xmd run release.md', +].join("\n"); + +/** What every other removed option is answered with. */ +export function removedOptionRefusal(option: string): string { + return ( + `unrecognized option for xmd plan: ${option} — configure the program when you run ` + + "the approved source with xmd run" + ); +} + +/** The options that take a separated value. */ const VALUE_OPTIONS: readonly string[] = [ "--include", - "--journal", - "-j", "--agent-provider", "--default-agent", "--timeout", - "--timeout-exec", - "--timeout-fetch", OUTPUT_OPTION, SESSION_OPTION, ]; -/** The built-in options that take none. */ -const SWITCH_OPTIONS: readonly string[] = [ - "--verbose", - "-V", - "--raw", - "--approve-all", - "--approve-reads", - "--deny-all", - "--secret-detection", - "--no-secret-detection", - RUN_OPTION, - "--help", - "-h", - "--version", -]; +/** The options that take none. */ +const SWITCH_OPTIONS: readonly string[] = ["--help", "-h", "--version"]; /** - * The options that configure the Plan's execution and nothing else. + * The options that configured running the approved Plan, and now configure + * nothing. * - * `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. + * `xmd plan` produces source and stops, so each of these describes work this + * command never performs. 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; `xmd run` still defines every one of them, and the + * refusal says so. * - * Everything absent from this list is here for a reason the command always has: - * `--include` builds the catalog and admits properties, `--agent-provider` and - * `--default-agent` settle who writes the Plan, `--session` names the - * conversation, `--timeout` bounds the whole command, and `--output` is where an - * approved Plan goes. + * The generated `--props-*` and `--no-props-*` names, and the aggregate + * `--props`, are refused by prefix rather than by list: they bind the root + * properties of a program, and the program a Plan describes is run later. */ -const RUN_ONLY_OPTIONS: readonly string[] = [ - "--journal", - "-j", +const REMOVED_OPTIONS: readonly string[] = [ "--raw", "--verbose", "-V", + "--journal", + "-j", "--timeout-exec", "--timeout-fetch", "--approve-all", @@ -112,48 +118,84 @@ const RUN_ONLY_OPTIONS: readonly string[] = [ "--no-secret-detection", ]; -const RUN_ONLY = new Set(RUN_ONLY_OPTIONS); - +const REMOVED = new Set(REMOVED_OPTIONS); const VALUE = new Set(VALUE_OPTIONS); -const KNOWN = new Set([...VALUE_OPTIONS, ...SWITCH_OPTIONS, AGGREGATE_OPTION]); +const KNOWN = new Set([...VALUE_OPTIONS, ...SWITCH_OPTIONS]); function optionName(token: string): string { const equals = token.indexOf("="); return equals === -1 ? token : token.slice(0, equals); } +/** + * Whether this name binds a root property of the program a Plan describes. + * + * Exactly the aggregate and the two generated prefixes. `--propspective`, + * `--no-propspective` and a bare `--no-props` are options this command does not + * define, and an option nobody defines is answered as one: telling a caller to + * configure their program with `xmd run` would be answering a question they did + * not ask. + */ function generatesProperty(name: string): boolean { - return name.startsWith("--props-") || name.startsWith("--no-props"); + return ( + name === AGGREGATE_OPTION || + name.startsWith(`${AGGREGATE_OPTION}-`) || + name.startsWith("--no-props-") + ); } /** - * Whether this token is an option `xmd` itself defines. + * What a removed option is answered with, or `undefined` when this name is not + * one. * - * Used to decide what a generated property option may provisionally read as its - * value. A caller writing `--props-name --raw` means the switch, not a value of - * `--raw`; a value that really begins with `-` is written `--props-name=-value`. + * The name is read up to its first `=`, so every valued spelling arrives under + * the name of the option it was written as: `--run=false` is a `--run`, and a + * removed option that appears to take a value never consumes the token after + * it. */ -function isKnownOption(token: string): boolean { +function removalRefusal(name: string): string | undefined { + if (name === RUN_OPTION) { + return RUN_REMOVAL_REFUSAL; + } + if (REMOVED.has(name) || generatesProperty(name)) { + return removedOptionRefusal(name); + } + return undefined; +} + +/** The removal this token names, wherever it stands on the command line. */ +function tokenRemoval(token: string): string | undefined { if (!token.startsWith("-") || token === "-") { - return false; + return undefined; } - const name = optionName(token); - return KNOWN.has(name) || generatesProperty(name); + return removalRefusal(optionName(token)); } -/** One `--props-*` token, as written. */ -export interface PropertyOccurrence { - /** The option name, without any `=value`. */ - option: string; - /** The value written with `=`, when it was written that way. */ - inline?: string; - /** - * The following token this scan read as the option's value. - * - * Provisional: whether the option takes one is the candidate's answer, and a - * candidate that declares it a switch turns this token into a second request. - */ - provisional?: string; +/** + * The refusal a Plan command line earns for naming a removed option, or + * `undefined` when it names none. + * + * Separate from {@link scanPlanArgs} because it has to be askable earlier than + * a scan is useful. `--help` short-circuits the general dispatch before any + * command's own grammar runs, so `xmd plan --help --run` would otherwise be + * answered with a page describing a command that would refuse it. The two share + * this classification rather than each keeping their own, so a spelling cannot + * become removed in one and unknown in the other. + * + * Tokens after `--` are positional and are not inspected, exactly as the scan + * leaves them. + */ +export function removedPlanOption(args: readonly string[]): string | undefined { + for (const token of args.slice(1)) { + if (token === "--") { + return undefined; + } + const removal = tokenRemoval(token); + if (removal !== undefined) { + return removal; + } + } + return undefined; } /** What fixed grammar establishes about one `xmd plan` command line. */ @@ -163,15 +205,11 @@ export interface PlanScan { /** * The argv the built-in option parser sees. * - * Generated property tokens and the aggregate `--props` are removed: the - * parser defines neither, and it coerces a separated value through `Number()` - * before any schema could judge it. Tokens after `--` are left out for the - * same reason the workflow command leaves them out — a dash-leading positional - * handed back to a parser is read as an option again. + * Tokens after `--` are left out for the same reason the workflow command + * leaves them out — a dash-leading positional handed back to a parser is read + * as an option again. */ fixed: string[]; - /** Every generated property occurrence, in the order it was written. */ - occurrences: PropertyOccurrence[]; /** Why fixed grammar refuses this command line. */ error?: string; } @@ -181,19 +219,19 @@ export function namesPlan(args: readonly string[]): boolean { return args[0] === PLAN_COMMAND; } -const ORDER_HELP = - "document properties follow the request, as in " + '`xmd plan "" --props-name `'; - export function scanPlanArgs(args: readonly string[]): PlanScan { const fixed: string[] = [PLAN_COMMAND]; - const occurrences: PropertyOccurrence[] = []; let request: string | undefined; let extra: string | undefined; - let runs = false; - let runOnly: string | undefined; let parsingOptions = true; let index = 1; + const refuse = (error: string): PlanScan => ({ + ...(request === undefined ? {} : { request }), + fixed, + error, + }); + while (index < args.length) { const token = args[index]; @@ -207,33 +245,9 @@ export function scanPlanArgs(args: readonly string[]): PlanScan { const equals = token.indexOf("="); const name = optionName(token); - if (name === AGGREGATE_OPTION) { - index += equals === -1 && args[index + 1] !== undefined ? 2 : 1; - continue; - } - - if (generatesProperty(name)) { - if (request === undefined) { - return { - fixed, - occurrences, - error: `unrecognized option: ${name} — ${ORDER_HELP}`, - }; - } - if (equals !== -1) { - occurrences.push({ option: name, inline: token.slice(equals + 1) }); - index += 1; - continue; - } - const next = args[index + 1]; - if (next === undefined || next === "--" || isKnownOption(next)) { - occurrences.push({ option: name }); - index += 1; - continue; - } - occurrences.push({ option: name, provisional: next }); - index += 2; - continue; + const removed = removalRefusal(name); + if (removed !== undefined) { + return refuse(removed); } if (!KNOWN.has(name)) { @@ -243,44 +257,26 @@ export function scanPlanArgs(args: readonly string[]): PlanScan { // has not been answered. `--save` is named because it is the one // spelling somebody may remember; it was replaced before release, so // there is no alias to keep. - return { - ...(request === undefined ? {} : { request }), - fixed, - occurrences, - error: - name === "--save" - ? `unrecognized option for xmd plan: --save — the approved Plan goes to stdout, ` + + return refuse( + name === "--save" + ? `unrecognized option for xmd plan: --save — the approved Plan goes to stdout, ` + `and ${OUTPUT_OPTION} writes it to a file` - : `unrecognized option for xmd plan: ${name}`, - }; + : `unrecognized option for xmd plan: ${name}`, + ); } - if (name === RUN_OPTION) { - // `optionName` stops at the first `=`, so every `--run=…` spelling - // arrives here under the name of the switch. Reading one as the switch - // would let a token satisfy the gate below while meaning the opposite, - // and the ordinary parser reads `--run=true` as the default rather than - // as true — so a caller who spelled it that way is answered by neither - // half of this command. Refused instead, before it establishes anything. - if (equals !== -1) { - return { - ...(request === undefined ? {} : { request }), - fixed, - occurrences, - error: - `${RUN_OPTION} does not take a value — write ${RUN_OPTION} to execute the Plan ` + - "or leave it out to write the Plan", - }; + const next = args[index + 1]; + const separated = equals === -1 && VALUE.has(name) && next !== undefined ? next : undefined; + if (separated !== undefined) { + // A removed option is refused where it stands, including where a + // retained option would otherwise swallow it: `--include --run` names + // a directory nobody has, and reading it as one is how a removed + // spelling survives. + const swallowed = tokenRemoval(separated); + if (swallowed !== undefined) { + return refuse(swallowed); } - runs = true; - } - if (runOnly === undefined && RUN_ONLY.has(name)) { - runOnly = name; } - const separated = - equals === -1 && VALUE.has(name) && args[index + 1] !== undefined - ? args[index + 1] - : undefined; // Read here rather than after parsing, because an empty value is exactly // what the parser cannot report: an option it reads as absent falls back // to the default, so a caller who asked for a session and named none would @@ -288,14 +284,10 @@ export function scanPlanArgs(args: readonly string[]): PlanScan { if (name === SESSION_OPTION) { const value = equals === -1 ? separated : token.slice(equals + 1); if (value === undefined || value.length === 0) { - return { - ...(request === undefined ? {} : { request }), - fixed, - occurrences, - error: - `${SESSION_OPTION} needs a name — write \`${SESSION_OPTION} \` or leave ` + + return refuse( + `${SESSION_OPTION} needs a name — write \`${SESSION_OPTION} \` or leave ` + "it out for a session unique to this invocation", - }; + ); } } fixed.push(token); @@ -323,136 +315,18 @@ export function scanPlanArgs(args: readonly string[]): PlanScan { } if (extra !== undefined) { - return { - ...(request === undefined ? {} : { request }), - fixed, - occurrences, - error: - `unrecognized argument for xmd plan: ${extra} — the command takes exactly one ` + - "request, and " + - ORDER_HELP, - }; + return refuse( + `unrecognized argument for xmd plan: ${extra} — the command takes exactly one request`, + ); } if (request === undefined) { - return { - fixed, - occurrences, - error: 'xmd plan requires one request — `xmd plan ""`', - }; + return refuse('xmd plan requires one request — `xmd plan ""`'); } if (request.trim().length === 0) { - return { - request, - fixed, - occurrences, - error: "xmd plan requires a request with at least one non-whitespace character", - }; - } - - if (runOnly !== undefined && !runs) { - return { - request, - fixed, - occurrences, - error: - `${runOnly} configures running the Plan, and without ${RUN_OPTION} this command ` + - `writes the Plan instead of running it — add ${RUN_OPTION}, or drop ${runOnly}`, - }; - } - - return { request, fixed, occurrences }; -} - -/** - * How a supplied individual option is written: whether it takes a token, and - * whether repeating it accumulates. - * - * This is what a later candidate may not change. The comparison happens before - * any token is extracted, so a switch that became a value option cannot reach - * forward and consume the `--raw` written after it. - */ -export interface OptionSignature { - boolean: boolean; - array: boolean; -} - -export function signatureOf(binding: Binding): OptionSignature { - return { boolean: binding.boolean, array: binding.array }; -} - -function describeSignature(signature: OptionSignature): string { - if (signature.boolean) { - return "a bare switch"; - } - return signature.array ? "a repeated value option" : "a single-value option"; -} - -/** - * Whether the candidate still declares every supplied option the way the - * candidate that first bound it did. - * - * A removed option or a changed shape is the caller's command line meaning - * something else than it did, which no revision may do silently. - */ -export function signatureFailure( - frozen: ReadonlyMap, - bindings: readonly Binding[], -): string | undefined { - const current = new Map(bindings.map((binding) => [binding.option, signatureOf(binding)])); - for (const [option, signature] of frozen) { - const now = current.get(option); - if (now === undefined) { - return ( - `${option} was accepted by an earlier draft and this one declares no such property — ` + - "the command line no longer describes the document under review" - ); - } - if (now.boolean !== signature.boolean || now.array !== signature.array) { - return ( - `${option} was ${describeSignature(signature)} in an earlier draft and is ` + - `${describeSignature(now)} in this one — the command line no longer describes the ` + - "document under review" - ); - } + return refuse("xmd plan requires a request with at least one non-whitespace character"); } - return undefined; -} -/** - * The token a candidate's own arity turns into a second request. - * - * `--props-loud true` reads as an option and a value until a candidate declares - * `loud` a boolean; from then on `true` is a positional, and the command takes - * exactly one. - */ -export function strayPropertyValue( - occurrences: readonly PropertyOccurrence[], - bindings: readonly Binding[], -): string | undefined { - const byOption = new Map(bindings.map((binding) => [binding.option, binding])); - for (const occurrence of occurrences) { - const { provisional } = occurrence; - if (provisional === undefined) { - continue; - } - const binding = byOption.get(occurrence.option); - if (binding?.boolean === true) { - return ( - `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" - ); - } - } - return undefined; -} - -/** - * Whether this token is an option the invocation owns, and therefore one a - * generated property may not read as its value. - */ -export function isReservedOption(token: string): boolean { - return isKnownOption(token); + return { request, fixed }; } diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 042904592..1fba07f59 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -90,6 +90,53 @@ export const PLAN_COMPONENT = "Plan"; */ export const PLAN_IDENTITY = ""; +/** + * How this host decides whether one candidate is structurally a program. + * + * One dependency rather than three call sites that happen to agree. `` + * answers a draft with it, `` admits the approved bytes with it, and + * on the command surface the host asks it once more after everything the + * conversation built has gone. Three questions, one environment — so two of them + * cannot come to differ for a reason nobody chose, and the last one is still a + * separate question about a tree that may have moved since. + */ +export type StructuralValidation = (candidate: string) => Operation; + +/** + * The canonical answer: `validateDocumentStructure` under the ordinary run + * profile, the `` identity, these includes and these declarations. + * + * Structure alone — the declarations, the source, the component resolution, the + * forms and everything else decidable without the property values a later run + * will supply. A Plan whose root declares required properties is a Plan, and + * refusing it for not having been given arguments nobody has offered it yet + * would refuse a program for being one. + * + * The declarations are read when the question is asked rather than when this is + * built, because the `` declaration has to be able to describe itself: the + * profile a Plan will run in contains ``, and the catalog the agent was + * shown says so. + */ +export function structuralValidation( + includes: readonly string[], + declarations: readonly DeclaredMarkdownComponent[], +): StructuralValidation { + return (candidate: string) => + // The registry is installed around the question rather than around the + // Component: what a Plan may write is the run profile's whole vocabulary — + // ``, `` and the assertions included — and the Component + // itself has no business reaching a vocabulary it only describes. + scoped(function* (): Operation { + yield* useRunProfileRegistry(); + return yield* validateDocumentStructure({ + ...retainedSource(PLAN_IDENTITY, candidate), + includes: [...includes], + components: agentIdentityComponents(), + declarations: [...declarations], + }); + }); +} + /** Which surface reached the Component. Sealed: it is never a public prop. */ export type PlanSurface = "command" | "component"; @@ -100,8 +147,8 @@ export interface PlanComponentAssembly { * * Fixed in the execution's declaration before the root is imported, so the * thin command adapter cannot accept it, derive it, or pass it on. A Plan - * later executed by `xmd plan --run` is a new ordinary run and receives - * `component`, because that run builds its own declaration. + * a later `xmd run` executes is an ordinary run and receives `component`, + * because that run builds its own declaration. */ readonly surface: PlanSurface; /** The component search path a Plan's own components resolve against. */ @@ -151,19 +198,14 @@ export interface PlanComponentAssembly { /** The run profile's rendered vocabulary, as the first Agent turn receives it. */ catalog(): Operation; /** - * The host's answer about one draft. - * - * A candidate-authored failure comes back as `valid: false` and is repairable. - * A caller-source failure raises, which ends the invocation: the Component has - * no way to catch it and no way to recategorize it as feedback for an agent that - * could not have caused it. + * How this host decides whether one candidate is structurally a program. * - * Omitted is the structural answer the final admission gives, which is what a - * document invoking `` wants: it has no command line, so there are no - * property values for a draft to be checked against and nothing a caller could - * have got wrong. `xmd plan` supplies its own, because it does have one. + * Omitted is the canonical answer built from `includes` and this declaration, + * which is what a document invoking `` wants. `xmd plan` supplies its + * own so that the draft check, the admission and the gate the command keeps + * after teardown are one dependency rather than three. */ - assess?(source: string): Operation; + validate?: StructuralValidation; } /** The frozen inputs `` answers with. */ @@ -263,6 +305,10 @@ export function* planComponentDeclaration( // assigned back rather than rebuilt: a second copy of these bytes would be a // second Component identity. const declared: DeclaredMarkdownComponent[] = []; + // One structural question for this invocation, asked by the draft check and by + // the admission alike — and, when the command supplied it, by the command's own + // gate after this declaration is gone. + const validate = assembly.validate ?? structuralValidation(assembly.includes, declared); const declaration: DeclaredMarkdownComponent = { name: PLAN_COMPONENT, origin: PLAN_ORIGIN, @@ -279,8 +325,8 @@ export function* planComponentDeclaration( privates: [ planInputs(assembly), planAuthorship(assembly), - checkDraft(assembly, declared), - admitPlan(assembly, declared), + checkDraft(validate), + admitPlan(validate), ], }; declared.push(declaration); @@ -539,11 +585,7 @@ function planAuthorship(assembly: PlanComponentAssembly): IdentityComponent { * re-deciding it against a working tree that has moved. The candidate itself * never reaches the description — only the digest that tells two drafts apart. */ -function checkDraft( - assembly: PlanComponentAssembly, - declared: readonly DeclaredMarkdownComponent[], -): IdentityComponent { - const assess = assembly.assess ?? structuralAssessment(assembly, declared); +function checkDraft(validate: StructuralValidation): IdentityComponent { return { name: "CheckDraft", origin: `${PLAN_ORIGIN}#CheckDraft`, @@ -560,7 +602,11 @@ function checkDraft( return yield* durablePlanOperation( `plan:check:${id}:${sourceDigest(candidate)}`, function* () { - const answer = yield* assess(candidate); + const validation = yield* validate(candidate); + const answer: CandidateAssessment = + validation.outcome === "invalid" + ? { valid: false, diagnostics: { validation } as unknown as Json } + : { valid: true, diagnostics: {} }; return { valid: answer.valid, diagnostics: answer.diagnostics }; }, ); @@ -572,59 +618,9 @@ function checkDraft( * Structurally admit the exact approved bytes, after the whole authorship frame * has gone. * - * Structure alone: the declarations, the source, the component resolution, the - * forms and everything else decidable without the property values a later run - * will supply. A Plan whose root declares required properties is a Plan, and - * refusing it here would refuse a program for not having been given the - * arguments nobody has offered it yet. What runs it resolves and validates them - * then. - * * Nothing is executed, and the string that comes back is the string that went * in — no trimming, no fence removal, no normalization, no added newline. */ -/** - * The structural answer about one candidate, against the profile it would run - * in. - * - * Declarations, source, component resolution, forms and everything else - * decidable without the property values a later run will supply. A Plan whose - * root declares required properties is a Plan, and refusing it for not having - * been given arguments nobody has offered it yet would refuse a program for - * being one. - */ -function* structurally( - assembly: PlanComponentAssembly, - declared: readonly DeclaredMarkdownComponent[], - candidate: string, -): Operation { - // The registry is installed around the question rather than around the - // Component: what a Plan may write is the run profile's whole vocabulary — - // ``, `` and the assertions included — and the Component itself - // has no business reaching a vocabulary it only describes. - return yield* scoped(function* (): Operation { - yield* useRunProfileRegistry(); - return yield* validateDocumentStructure({ - ...retainedSource(PLAN_IDENTITY, candidate), - includes: [...assembly.includes], - components: agentIdentityComponents(), - declarations: [...declared], - }); - }); -} - -/** What a surface that states no assessment of its own answers with. */ -function structuralAssessment( - assembly: PlanComponentAssembly, - declared: readonly DeclaredMarkdownComponent[], -): (source: string) => Operation { - return function* (source: string): Operation { - const validation = yield* structurally(assembly, declared, source); - if (validation.outcome === "invalid") { - return { valid: false, diagnostics: { validation } as unknown as Json }; - } - return { valid: true, diagnostics: {} }; - }; -} /** What the frozen inputs retained, or nothing when the record is not one. */ interface RetainedInputs { @@ -727,10 +723,7 @@ function readArtifact(value: Json): PlanArtifact | undefined { const UNREADABLE_ARTIFACT = "the retained Plan artifact cannot be read as one, so no Plan source was produced."; -function admitPlan( - assembly: PlanComponentAssembly, - declared: readonly DeclaredMarkdownComponent[], -): IdentityComponent { +function admitPlan(validate: StructuralValidation): IdentityComponent { return { name: "AdmitPlan", origin: `${PLAN_ORIGIN}#AdmitPlan`, @@ -750,7 +743,7 @@ function admitPlan( // instructions moved would author a second Plan rather than meet the one // this site already has. const retained = yield* durablePlanOperation(`plan:artifact:${id}`, function* () { - const validation = yield* structurally(assembly, declared, candidate); + const validation = yield* validate(candidate); if (validation.outcome === "invalid") { throw new Error( "the approved Plan does not validate:\n" + diff --git a/packages/cli/src/plan.ts b/packages/cli/src/plan.ts index 1d1b15c91..6c9eddb89 100644 --- a/packages/cli/src/plan.ts +++ b/packages/cli/src/plan.ts @@ -2,27 +2,24 @@ * `xmd plan` — the trusted host around the plan command document * (specs/plan-command-spec.md). * - * Every invocation executes one root document — the packaged plan command - * document — and a second one only when `--run` asks for it: + * Every invocation executes exactly one root document — the packaged plan + * command document — and produces one thing: the approved program's source. * * ```text * fixed command preflight * -> build the run-profile syntax catalog * -> 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: + * -> structurally validate the returned source again + * -> deliver those exact bytes, in exactly one of two ways: * (default) write the source to stdout * --output exclusively create the file - * --run execute retainedSource("", source) - * through the ordinary run path - * --output --run create the file, then execute it * ``` * - * The complete scope boundary sits before that optional second execution: the - * command document and everything it built are gone before a Plan is admitted, - * so whichever result follows, it follows an invocation that has already let go - * of the conversation that wrote it. + * Nothing after that delivery starts. Whether the approved program ever runs is + * the caller's own composition — `xmd plan … | xmd run -`, or an `--output` + * artifact given to a later `xmd run` — so the command that wrote a program + * never also decides when it happens. * * 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 @@ -31,14 +28,7 @@ * workflow 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 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 - * would fix it, and a workflow that could catch it could call it feedback. + * wrote and source this host will hand over. */ import { Err, Ok, scoped, until, useScope } from "effection"; @@ -49,48 +39,25 @@ import { randomUUID } from "node:crypto"; import { resolve } from "node:path"; import process from "node:process"; -import { - agentIdentityComponents, - inspectDocument, - retainedSource, - validateDocument, -} from "@executablemd/core"; -import type { - DocumentValidation, - DocumentValidationCode, - Json, - PropsSchema, - RootDocumentSource, - SyntaxCatalog, -} from "@executablemd/core"; +import type { SyntaxCatalog } from "@executablemd/core"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { cwd } from "@executablemd/runtime"; -import type { AgentStack } from "./agent-stack.ts"; +import type { AuthorshipStack } from "./agent-stack.ts"; import { DEFAULT_AUTHORSHIP_ROOT, planAgentContext, runPlanCommandDocument, } from "./authorship-profile.ts"; -import type { CandidateAssessment } from "./authorship-profile.ts"; -import { PLAN_IDENTITY, planComponentDeclaration } from "./plan-component.ts"; -import type { MachineSessionAssembly } from "./session-coordinator.ts"; -import { - buildBindings, - describeError, - extractPropsArgs, - resolvePropsFromSources, -} from "./props.ts"; -import type { Binding, Extraction } from "./props.ts"; -import { reportFailure } from "./report.ts"; -import { renderSyntaxMarkdown, useRunProfileRegistry } from "./syntax.ts"; import { - isReservedOption, - signatureFailure, - signatureOf, - strayPropertyValue, -} from "./plan-args.ts"; -import type { OptionSignature, PlanScan } from "./plan-args.ts"; + planComponentDeclaration, + planComponentDescription, + structuralValidation, +} from "./plan-component.ts"; +import type { StructuralValidation } from "./plan-component.ts"; +import type { MachineSessionAssembly } from "./session-coordinator.ts"; +import { describeError } from "./props.ts"; +import { renderSyntaxMarkdown } from "./syntax.ts"; /** * The identity approved text runs under. @@ -101,33 +68,17 @@ import type { OptionSignature, PlanScan } from "./plan-args.ts"; */ export { PLAN_IDENTITY } from "./plan-component.ts"; -/** The approved bytes, and the props resolved under exactly those bytes. */ -export interface PlanExecution { - root: RootDocumentSource; - props: Record; -} - /** 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: PlanScan; + /** The request, byte for byte, as fixed grammar preserved it. */ + request: string; include: string[]; /** Where the approved Plan is written, when the caller asked for a file. */ output?: string; - /** Whether the caller asked for the approved Plan to be run. */ - run: boolean; /** The logical assistant-session name, when the caller chose one. */ session?: string; - /** - * The Agent configuration this invocation settled, before the command ran. - * - * Settled by the caller rather than here, because the run that may follow - * approval is configured from the same answer: two resolutions of one command - * line is two chances to read `DEFAULT_AGENT_NAME` differently. - */ - stack: AgentStack; + /** Who writes the Plan, settled before the command began. */ + stack: AuthorshipStack; } /** What the host supplies. Every entry is a decision only a host can make. */ @@ -149,39 +100,18 @@ export interface PlanDependencies { * reach, so a document cannot move where the ceiling lives. */ authorshipRoot?: string; - /** Run the approved document the way this host runs any supplied one. */ - execute(approved: PlanExecution): Operation>; -} - -/** The command-owned findings that are not core's to report. */ -interface DraftDiagnostic { - code: "generated-binding-collision" | "root-props-unreadable"; - message: string; -} - -/** Everything definite that is wrong with one candidate. */ -interface CandidateDefects { - /** Core's complete versioned answer, whenever core produced one. */ - validation?: DocumentValidation; - /** What this command found about the options the candidate generates. */ - draft?: DraftDiagnostic; + /** + * How this invocation decides a candidate is structurally a program. + * + * Absent is the canonical answer below. One dependency, and the same one the + * declaration is built with, so the draft check, the admission inside `` + * and the gate this command keeps after that document has torn down are the + * same question asked three times rather than three questions that happen to + * agree today. + */ + validate?: StructuralValidation; } -type CandidateOutcome = - | { kind: "valid"; props: Record } - | { kind: "repairable"; defects: CandidateDefects } - | { kind: "terminal"; error: Error }; - -/** The codes that say the root's own declaration could not be read. */ -const DECLARATION_CODES: ReadonlySet = new Set([ - "source-unreadable", - "source-invalid", - "target-invalid", - "frontmatter-invalid", - "props-declaration-invalid", - "returns-declaration-invalid", -]); - /** * Run the command, and report the process status it earned. * @@ -189,13 +119,6 @@ const DECLARATION_CODES: ReadonlySet = new Set { - const { scan } = command; - if (scan.error !== undefined || scan.request === undefined) { - console.error(scan.error ?? 'xmd plan requires one request — `xmd plan ""`'); - return 1; - } - const request = scan.request; - let syntax: string; try { syntax = renderSyntaxMarkdown(yield* deps.catalog(command.include)); @@ -204,24 +127,22 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio return 1; } - // Every supplied individual option's shape, as the first draft that bound it - // declared it. Frozen while the Plan is being written and carried into the - // final gate, so the bytes that are delivered are checked against the command - // line that was written rather than against whichever draft happened to be - // last. - const frozen = new Map(); - // The command document lives and dies inside that call's scope. Leaving it // closes the Prompt tasks, the provider and the Elicitation provider, so a - // teardown failure raises out here — before the admission, the output file - // and the run that would otherwise already have happened. + // teardown failure raises out here — before the admission and before the + // output file. const session = command.session ?? invocationSessionName(); // Read from what the caller wrote, not from the shape of the name. Only a // session somebody can ask for again needs its directory to outlive the // invocation, and only the host knows whether somebody named one. const explicitSession = command.session !== undefined; const root = deps.authorshipRoot ?? DEFAULT_AUTHORSHIP_ROOT; - const assessOne = (source: string) => assess(command, frozen, source); + // Built before the declaration exists, and handed to it: the packaged `` + // description is the declaration an ordinary run resolves, so what the draft + // check, the admission and the gate below all ask about is the profile the + // approved program would actually run in. + const validate = + deps.validate ?? structuralValidation(command.include, [yield* planComponentDescription()]); let authored: Result; try { @@ -252,19 +173,17 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio *catalog() { return syntax; }, - assess: assessOne, + validate, }); authored = yield* runPlanCommandDocument({ - request, + request: command.request, syntax, session, explicitSession, root, context, - installElicitation: deps.installElicitation, declaration, - assess: assessOne, }); } catch (error) { console.error(describeError(error)); @@ -277,54 +196,37 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio } // The returned Plan is untrusted again. Whatever the command document concluded - // about a candidate, these are the bytes that would run, and they are checked - // as though nothing had ever validated them. - const admitted = yield* assessCandidate(command, frozen, authored.value); - if (admitted.kind === "terminal") { - console.error(admitted.error.message); - return 1; - } - if (admitted.kind === "repairable") { + // about a candidate — and however recently `` concluded it — these + // are the bytes a later `xmd run` would execute, and the tree they resolve + // against has had a whole teardown to move since. So they are checked once + // more, as though nothing had ever validated them. + const admitted = yield* validate(authored.value); + if (admitted.outcome === "invalid") { console.error( - `the approved document does not validate:\n${JSON.stringify(admitted.defects, null, 2)}`, + `the approved document does not validate:\n` + + JSON.stringify({ validation: admitted }, null, 2), ); return 1; } const source = authored.value; if (command.output !== undefined) { - // Before the run, so a Plan that fails at run time is still on disk to read - // and hand-edit. An existing path is refused and nothing after it happens. + // An existing path is refused and nothing after it happens: what somebody + // kept is left exactly as it was. const written = yield* writeOutput(command.output, source); if (!written.ok) { console.error(written.error.message); return 1; } - } - - if (!command.run) { - // The approved Plan is the result. It goes to stdout exactly as the agent - // wrote it — no fence, no heading, no trailing newline of this command's — - // so a caller can pipe it into a file, a diff or another program. A caller - // who named `--output` already has it, and gets a quiet command instead. - if (command.output === undefined) { - process.stdout.write(source); - } return 0; } - const executed = yield* deps.execute({ - root: retainedSource(PLAN_IDENTITY, source), - props: admitted.props, - }); - if (!executed.ok) { - // Reported exactly as `xmd run` reports the same failure: what ran is one - // ordinary document, and how it failed is not this command's news to - // rephrase. A runtime failure ends the command here — there is nothing for - // the command document to reconsider about a Plan you already approved. - reportFailure(executed.error); - return 1; - } + // The approved Plan is the result. It goes to stdout exactly as the agent + // wrote it — no fence, no heading, no trailing newline of this command's — + // so a caller can pipe it into `xmd run -`, a file, a diff or another + // program. A caller who named `--output` already has it, and gets a quiet + // command instead. + process.stdout.write(source); return 0; } @@ -343,110 +245,6 @@ export function invocationSessionName(): string { return `xmd-plan:${randomUUID()}`; } -/** - * The host's answer about one draft, in the shape the command document reads. - * - * A caller-source failure raises rather than answering. That is the whole of - * the classification the command document can observe: it sees facts about drafts, - * and it never sees an argument the command line got wrong. - */ -function* assess( - command: PlanCommand, - frozen: Map, - source: string, -): Operation { - const outcome = yield* assessCandidate(command, frozen, source); - if (outcome.kind === "terminal") { - throw outcome.error; - } - if (outcome.kind === "valid") { - return { valid: true, diagnostics: {} }; - } - return { valid: false, diagnostics: outcome.defects as unknown as Json }; -} - -/** - * Everything decidable about one candidate, in the order that keeps a caller's - * mistake from being taught to the agent. - * - * The registry is installed here, around the two questions that need it, rather - * than around the command document: validation and the catalog have to agree - * about what `` is, and that document has no business reaching a - * vocabulary it only describes. - */ -function* assessCandidate( - command: PlanCommand, - frozen: Map, - candidate: string, -): Operation { - return yield* scoped(function* (): Operation { - yield* useRunProfileRegistry(); - const root = retainedSource(PLAN_IDENTITY, candidate); - const includes = command.include; - const components = agentIdentityComponents(); - - // Whether the root declares itself readably. Inspection would raise on a - // malformed declaration, and recovering a code from an exception's prose is - // exactly what the structured answer exists to replace. - const declaration = yield* validateDocument({ ...root, includes, components }); - if (declaration.diagnostics.some((entry) => DECLARATION_CODES.has(entry.code))) { - return { kind: "repairable", defects: { validation: declaration } }; - } - - let propsSchema: PropsSchema; - try { - propsSchema = (yield* inspectDocument(root)).props; - } catch (error) { - return { - kind: "repairable", - defects: { draft: { code: "root-props-unreadable", message: describeError(error) } }, - }; - } - - let bindings: Binding[]; - try { - bindings = buildBindings(propsSchema); - } catch (error) { - return { - kind: "repairable", - defects: { - draft: { code: "generated-binding-collision", message: describeError(error) }, - }, - }; - } - - // Before a single token is extracted: an option that changed shape would - // otherwise reach forward and read the `--raw` written after it as its value. - const drift = signatureFailure(frozen, bindings); - if (drift !== undefined) { - return { kind: "terminal", error: new Error(drift) }; - } - const stray = strayPropertyValue(command.scan.occurrences, bindings); - if (stray !== undefined) { - return { kind: "terminal", error: new Error(stray) }; - } - - let extraction: Extraction; - let props: Record; - try { - extraction = extractPropsArgs(command.argv, bindings, { reserved: isReservedOption }); - props = yield* resolvePropsFromSources({ propsSchema, bindings, extraction }); - } catch (error) { - return { kind: "terminal", error: toError(error) }; - } - - for (const supplied of extraction.individual) { - frozen.set(supplied.binding.option, signatureOf(supplied.binding)); - } - - const validation = yield* validateDocument({ ...root, props, includes, components }); - if (validation.outcome === "invalid") { - return { kind: "repairable", defects: { validation } }; - } - return { kind: "valid", props }; - }); -} - /** * Create the destination and write the approved Plan, or refuse. * @@ -467,7 +265,7 @@ function* writeOutput(path: string, source: string): Operation> { return Err( new Error( `${target} already exists — choose another --output path; the approved Plan was ` + - "not written and nothing ran", + "not written", ), ); } @@ -499,7 +297,3 @@ function* closeHandle(handle: FileHandle, target: string): Operation boolean; -} - /** * Remove `--props` and `--props-*` tokens from argv, keeping their * original text. Configliere's own option matching coerces every value @@ -547,12 +532,7 @@ export interface ExtractOptions { * Only the generated bindings are recognized, so this stays a source * adapter rather than a second argument parser. */ -export function extractPropsArgs( - args: string[], - bindings: Binding[], - options?: ExtractOptions, -): Extraction { - const reserved = options?.reserved ?? (() => false); +export function extractPropsArgs(args: string[], bindings: Binding[]): Extraction { const byOption = new Map(bindings.map((binding) => [binding.option, binding])); const collected = new Map(); const individual: { binding: Binding; value: string | string[] }[] = []; @@ -630,12 +610,6 @@ export function extractPropsArgs( if (next === undefined) { throw new PropsError(`${binding.option} requires a value`); } - if (reserved(next)) { - throw new PropsError( - `${binding.option} requires a value — \`${next}\` is an xmd option, so write ` + - `\`${binding.option}=${next}\` for a value that begins with "-"`, - ); - } record(binding, next); index += 2; } diff --git a/packages/cli/src/timeouts.ts b/packages/cli/src/timeouts.ts index 9c6c90030..e7b2e4dcd 100644 --- a/packages/cli/src/timeouts.ts +++ b/packages/cli/src/timeouts.ts @@ -52,12 +52,34 @@ export function findFlagText(args: string[], flag: string): string | undefined { * invocation before it prepares a document. */ export function resolveRunTimeouts(args: string[]): RunTimeouts | { error: string } { + return resolveTimeouts(args, OPTIONS); +} + +/** + * The whole-invocation deadline alone. + * + * `xmd plan` writes a program and runs none, so the two options that configure + * an execution's effects are not its to read: they are refused by name in its + * own grammar, and a duration check here would answer one of them with a + * different complaint first. + */ +export function resolvePlanTimeout(args: string[]): RunTimeouts | { error: string } { + return resolveTimeouts( + args, + OPTIONS.filter(([, field]) => field === "timeout"), + ); +} + +function resolveTimeouts( + args: string[], + options: readonly (readonly [string, keyof RunTimeouts])[], +): RunTimeouts | { error: string } { const resolved: RunTimeouts = { timeout: undefined, timeoutExec: undefined, timeoutFetch: undefined, }; - for (const [flag, field] of OPTIONS) { + for (const [flag, field] of options) { const text = findFlagText(args, flag); if (text === undefined) { continue; diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index 46ce7703b..226db41f6 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -35,7 +35,6 @@ import type { AgentStack } from "../src/agent-stack.ts"; import { authorshipDependencies } from "../src/authorship-profile.ts"; import type { AuthorshipProviderInputs } from "../src/authorship-profile.ts"; import { runPlan } from "../src/plan.ts"; -import { scanPlanArgs } from "../src/plan-args.ts"; import { AGENT, createPlanHarness, useWorkingDirectory } from "./support/plan-harness.ts"; /** The two agents this build carries a patched snapshot for. */ @@ -191,14 +190,11 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { harness.fake.script({ reply: PLAN }); harness.script({ decision: "Approve" }); - const argv = ["plan", REQUEST]; const code = yield* runPlan( { - argv, - scan: scanPlanArgs(argv), + request: REQUEST, include: [dir], output: join(dir, "plan.md"), - run: false, stack: { ...stackWith(installingAdapters(prepared)), defaultAgent: AGENT }, }, harness.deps, diff --git a/packages/cli/tests/packaged-document.test.ts b/packages/cli/tests/packaged-document.test.ts index 36fc40f6e..9bbb883c9 100644 --- a/packages/cli/tests/packaged-document.test.ts +++ b/packages/cli/tests/packaged-document.test.ts @@ -47,11 +47,11 @@ const INTRODUCTION = [ "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 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.", + "A draft remains text while this workflow reviews it, and nothing in it ever runs", + "here. After approval, `xmd plan` validates the exact source again and hands it", + "over: by default it prints the approved XMD source, and `--output` writes that", + "source to a file instead. Running the program is a separate command, so you", + "decide whether and when it happens.", ].join("\n"); /** What every turn that asks for a Plan has to say, on its own. */ @@ -165,15 +165,18 @@ describe("packaged documents", () => { // A branch decided in TypeScript would put half of what a person reads // somewhere a person reading the workflow cannot see it. expect(source).toContain( - '', + '', ); expect(source).toContain( '', ); - // Twice, once per surface: the automatic explanation is the only ending - // that says it. The authored exhaustion sentence went with the review a - // tenth invalid draft no longer reaches. - expect(source.split("reviewed ten drafts without an approved Plan").length - 1).toBe(2); + // The automatic explanation is the only ending that says it, and each + // surface says it in its own words: the command names the ten attempts and + // what was not output, and the Component names the Plan it did not return. + expect(source.split("reviewed ten drafts without an approved Plan").length - 1).toBe(1); + expect( + source.split("xmd plan could not generate an approved Plan after 10 attempts.").length - 1, + ).toBe(1); // The closing branch is an unexpected-no-decision fallback, not a second // copy of exhaustion: exhaustion is decided inside review, and saying it // twice would make the two endings indistinguishable to a reader. diff --git a/packages/cli/tests/plan-args.test.ts b/packages/cli/tests/plan-args.test.ts index ed0550bca..8be72cb2b 100644 --- a/packages/cli/tests/plan-args.test.ts +++ b/packages/cli/tests/plan-args.test.ts @@ -1,43 +1,68 @@ /** * Tier PR — `xmd plan` fixed grammar (specs/plan-command-spec.md). * - * 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. + * Rows PS1–PS3. `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 `plan.test.ts`, where a candidate schema - * exists to bind against. + * The grammar is complete here, because the command produces source rather than + * running it: no generated document adds an option later, so nothing about this + * command line waits on a candidate schema. What a refusal *reaches* is + * `plan-cli.test.ts`, where the phases that stayed at zero are observable. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { - isReservedOption, namesPlan, namesRetiredCommand, + removedOptionRefusal, + removedPlanOption, RETIRED_COMMAND_REFUSAL, + RUN_REMOVAL_REFUSAL, scanPlanArgs, - signatureFailure, - signatureOf, - strayPropertyValue, } 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"; const REQUEST = "ask me for my age and write the result to a file"; -function bindingsFor(properties: Record): Binding[] { - return buildBindings({ type: "object", properties, additionalProperties: false }); -} +/** Every option the command still defines, with a value where it takes one. */ +const RETAINED: readonly string[][] = [ + ["--include", "lib"], + ["--agent-provider", "acpx"], + ["--default-agent", "codex"], + ["--session", "ada"], + ["--timeout", "5s"], + ["--output", "plan.md"], +]; -function frozen(entries: Record): Map { - return new Map(Object.entries(entries)); -} +/** + * One representative of every removed option class. + * + * Both short aliases are named, because a table of long names alone would leave + * `-V` and `-j` accepted and dropped by the parser; both secret-detection + * spellings are named for the same reason. + */ +const REMOVED: readonly string[][] = [ + ["--journal", "trace.jsonl"], + ["-j", "trace.jsonl"], + ["--raw"], + ["--verbose"], + ["-V"], + ["--timeout-exec", "5s"], + ["--timeout-fetch", "5s"], + ["--approve-all"], + ["--approve-reads"], + ["--deny-all"], + ["--secret-detection"], + ["--no-secret-detection"], + ["--props", '{"name":"Ada"}'], + ["--props-name", "Ada"], + ["--props-loud"], + ["--no-props-loud"], +]; describe("Tier PR — xmd plan fixed grammar", () => { - it("C1: exactly one request, kept byte for byte", function* () { + it("PS1: exactly one request, kept byte for byte", function* () { 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 @@ -74,10 +99,13 @@ describe("Tier PR — xmd plan fixed grammar", () => { expect(padded.request).toBe(` ${REQUEST}\n`); expect(scanPlanArgs(["plan"]).error).toContain("requires one request"); - expect(scanPlanArgs(["plan", "", "--raw"]).error).toContain("non-whitespace"); + expect(scanPlanArgs(["plan", ""]).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", + // The approved sentence, exactly: it names the token and says what the + // command takes, and nothing about property ordering — there are no + // generated properties left to order. + expect(scanPlanArgs(["plan", REQUEST, "second"]).error).toBe( + "unrecognized argument for xmd plan: second — the command takes exactly one request", ); // A request that begins with a dash is written after the separator, and is @@ -89,240 +117,143 @@ describe("Tier PR — xmd plan fixed grammar", () => { expect(separated.fixed).toEqual(["plan"]); }); - it("C1: individual options follow the request, aggregate props may precede it", function* () { - 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 - // tokens that followed, and no occurrence was recorded. - expect(early.request).toBe(undefined); - expect(early.occurrences).toEqual([]); - - const aggregate = scanPlanArgs([ - "plan", - "--props", - '{"name":"Ada"}', - REQUEST, - "--raw", - "--run", - ]); - expect(aggregate.error).toBe(undefined); - 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(["plan", REQUEST, "--raw", "--run"]); - - const inline = scanPlanArgs(["plan", '--props={"name":"Ada"}', REQUEST]); - expect(inline.error).toBe(undefined); - expect(inline.fixed).toEqual(["plan", REQUEST]); - }); + it("PS1: every retained option is accepted, before and after the request", function* () { + for (const option of RETAINED) { + expect(scanPlanArgs(["plan", REQUEST, ...option]).error).toBe(undefined); + expect(scanPlanArgs(["plan", ...option, REQUEST]).error).toBe(undefined); + } - it("C1: built-in options after generated props stay with the invocation", function* () { - const scan = scanPlanArgs([ - "plan", - REQUEST, - "--props-name", - "Ada", - "--raw", - "--include", - "lib", - "--props-loud", - "--journal", - "trace.jsonl", - "--output", - "out.md", - "--session", - "ada", - "--run", - ]); + // All of them at once, and every token reaches the parser in the order it + // was written: the invocation keeps its own options rather than losing one + // to a neighbour. + const scan = scanPlanArgs(["plan", REQUEST, ...RETAINED.flat()]); expect(scan.error).toBe(undefined); expect(scan.request).toBe(REQUEST); - expect(scan.fixed).toEqual([ - "plan", - REQUEST, - "--raw", - "--include", - "lib", - "--journal", - "trace.jsonl", - "--output", - "out.md", - "--session", - "ada", - "--run", - ]); - // `--props-loud` did not swallow `--journal`: a known option is never read - // as a generated property's value. - expect(scan.occurrences).toEqual([ - { option: "--props-name", provisional: "Ada" }, - { option: "--props-loud" }, - ]); - - // Nor at extraction, once a candidate declares `loud` a value option. - const bindings = bindingsFor({ loud: { type: "string" } }); - let failure: unknown; - try { - extractPropsArgs(["plan", REQUEST, "--props-loud", "--raw"], bindings, { - reserved: isReservedOption, - }); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(PropsError); - expect(String(failure)).toContain("--props-loud requires a value"); - expect(String(failure)).toContain("--props-loud=--raw"); - - // `xmd run` supplies no reserved list, so its behavior is unchanged. - const asRun = extractPropsArgs(["--props-loud", "--raw"], bindings); - expect(asRun.individual).toEqual([{ binding: bindings[0], value: "--raw" }]); + expect(scan.fixed).toEqual(["plan", REQUEST, ...RETAINED.flat()]); - expect(isReservedOption("--raw")).toBe(true); - expect(isReservedOption("--include=lib")).toBe(true); - expect(isReservedOption("--props-other")).toBe(true); - expect(isReservedOption("Ada")).toBe(false); - expect(isReservedOption("-5")).toBe(false); + // `--session` still needs a name. An option the parser reads as absent + // falls back to the generated session, so a caller who asked for a named + // one and named none would silently get a different conversation. + expect(scanPlanArgs(["plan", REQUEST, "--session"]).error).toContain("--session needs a name"); + expect(scanPlanArgs(["plan", REQUEST, "--session="]).error).toContain("--session needs a name"); }); - it("C1: scalar, boolean and aggregate sources are all recorded", function* () { - const scan = scanPlanArgs([ - "plan", - REQUEST, - "--props-name", - "Ada", - "--props-loud", - "--props-tag=alpha", - "--props-tag=beta", - "--props", - '{"count":2}', - ]); - expect(scan.error).toBe(undefined); - expect(scan.occurrences).toEqual([ - { option: "--props-name", provisional: "Ada" }, - { option: "--props-loud" }, - { option: "--props-tag", inline: "alpha" }, - { option: "--props-tag", inline: "beta" }, - ]); - - const bindings = bindingsFor({ - name: { type: "string" }, - loud: { type: "boolean" }, - tag: { type: "array", items: { type: "string" } }, - count: { type: "number" }, - }); - const extraction = extractPropsArgs( + it("PS2: every --run spelling reports the migration, and establishes nothing", function* () { + expect(RUN_REMOVAL_REFUSAL).toBe( [ - "plan", - REQUEST, - "--props-name", - "Ada", - "--props-loud", - "--props-tag=alpha", - "--props-tag=beta", - "--props", - '{"count":2}', - ], - bindings, - { reserved: isReservedOption }, + "xmd plan --run was removed because xmd plan only produces approved source.", + "Run the program explicitly:", + ' xmd plan "..." | xmd run -', + ' xmd plan "..." --output release.md && xmd run release.md', + ].join("\n"), ); - expect(extraction.aggregate).toBe('{"count":2}'); - expect(extraction.individual.map((entry) => [entry.binding.option, entry.value])).toEqual([ - ["--props-name", "Ada"], - ["--props-loud", "true"], - ["--props-tag", ["alpha", "beta"]], - ]); - }); - it("C1: a boolean binding turns its provisional value into a second request", function* () { - const scan = scanPlanArgs(["plan", REQUEST, "--props-loud", "true"]); - expect(scan.error).toBe(undefined); - expect(scan.occurrences).toEqual([{ option: "--props-loud", provisional: "true" }]); + const spellings = [ + ["plan", REQUEST, "--run"], + ["plan", REQUEST, "--run=true"], + ["plan", REQUEST, "--run=false"], + ["plan", REQUEST, "--run="], + ["plan", REQUEST, "--run", "--run"], + ["plan", "--run", REQUEST], + ["plan", "--run=false", REQUEST], + // Written where a retained option would otherwise swallow it: a value + // nobody names a directory is how a removed spelling survives. + ["plan", REQUEST, "--include", "--run"], + ["plan", REQUEST, "--output", "--run"], + // And after the options a caller does keep, so placement decides nothing. + ["plan", REQUEST, "--session", "ada", "--run"], + ]; + for (const argv of spellings) { + const scan = scanPlanArgs(argv); + expect(scan.error).toBe(RUN_REMOVAL_REFUSAL); + // No hidden field survives it: the token reached neither the parser's + // argv nor anything this scan established beyond the request itself. + expect(scan.fixed).not.toContain("--run"); + const established = + scan.request === undefined ? ["error", "fixed"] : ["error", "fixed", "request"]; + expect(Object.keys(scan).filter((key) => !established.includes(key))).toEqual([]); + expect(established.filter((key) => !(key in scan))).toEqual([]); + } - // A candidate that declares `loud` a value option accepts it. - expect(strayPropertyValue(scan.occurrences, bindingsFor({ loud: { type: "string" } }))).toBe( - undefined, + // There is no alias, and no compatibility spelling that means the same + // thing: `--execute` is simply an option this command does not define. + expect(scanPlanArgs(["plan", REQUEST, "--execute"]).error).toBe( + "unrecognized option for xmd plan: --execute", ); - - // 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 plan: true"); - expect(stray).toContain("--props-loud=true"); }); - it("C1: options that only configure a run need --run to mean anything", function* () { - // Each of them describes work that a command writing a Plan never does. A - // caller who asked for a journal, a permission mode or an exec deadline and - // got a command that creates none of them was not answered. - // Every spelling, including the short forms and both secret-detection - // switches: a table that covered only the long names would leave `-V` and - // `--secret-detection` accepted and ignored. - for (const flag of [ - ["--journal", "trace.jsonl"], - ["-j", "trace.jsonl"], - ["--raw"], - ["--verbose"], - ["-V"], - ["--timeout-exec", "5s"], - ["--timeout-fetch", "5s"], - ["--approve-all"], - ["--approve-reads"], - ["--deny-all"], - ["--secret-detection"], - ["--no-secret-detection"], - ]) { - 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(scanPlanArgs(["plan", REQUEST, ...flag, "--run"]).error).toBe(undefined); - expect(scanPlanArgs(["plan", REQUEST, "--run", ...flag]).error).toBe(undefined); - } + it("PS3: every other removed option reports the one generic refusal", function* () { + expect(removedOptionRefusal("--journal")).toBe( + "unrecognized option for xmd plan: --journal — configure the program when you run " + + "the approved source with xmd run", + ); - // The options the command always uses are never refused: they build the - // catalog, settle the agent, name the session, bound the command and say - // where the Plan goes. - for (const flag of [ - ["--include", "lib"], - ["--agent-provider", "acpx"], - ["--default-agent", "codex"], - ["--session", "ada"], - ["--timeout", "5s"], - ["--output", "plan.md"], - ]) { - expect(scanPlanArgs(["plan", REQUEST, ...flag]).error).toBe(undefined); + for (const option of REMOVED) { + const [name] = option; + const after = scanPlanArgs(["plan", REQUEST, ...option]); + expect(after.error).toBe(removedOptionRefusal(name)); + // It did not consume the token written after it, and it did not become a + // second positional first: the refusal is the option's own. + expect(after.fixed).toEqual(["plan", REQUEST]); + + // The same answer before the request, where a generated property option + // used to be told about ordering instead. + expect(scanPlanArgs(["plan", ...option, REQUEST]).error).toBe(removedOptionRefusal(name)); + + // And in the inline form, whose name is read up to the first `=`. + expect(scanPlanArgs(["plan", REQUEST, `${name}=value`]).error).toBe( + removedOptionRefusal(name), + ); } }); - it("C1: --run is a switch, and every valued spelling of it is refused", function* () { - // An option name is read up to its first `=`, so `--run=false` arrives under - // the name of the switch. Taken as the switch it would establish the - // opposite of what was written, and satisfy the run-only gate on the way. - const REFUSAL = - "--run does not take a value — write --run to execute the Plan " + - "or leave it out to write the Plan"; + it("PS2/PS3: the removal is decidable beside --help, in either order", function* () { + // `--help` is lifted out of argv before any command's own grammar runs, so + // the classification has to be askable on its own — otherwise the page a + // caller gets describes a command that would have refused them. + expect(removedPlanOption(["plan", "--help", "--run"])).toBe(RUN_REMOVAL_REFUSAL); + expect(removedPlanOption(["plan", "--run", "--help"])).toBe(RUN_REMOVAL_REFUSAL); + expect(removedPlanOption(["plan", "--help", "--journal", "trace.jsonl"])).toBe( + removedOptionRefusal("--journal"), + ); + expect(removedPlanOption(["plan", "--journal", "trace.jsonl", "--help"])).toBe( + removedOptionRefusal("--journal"), + ); + + // And it is the same classification the scan makes, so a spelling cannot be + // removed to one and unknown to the other. + for (const option of REMOVED) { + expect(removedPlanOption(["plan", REQUEST, ...option])).toBe( + scanPlanArgs(["plan", REQUEST, ...option]).error, + ); + } - for (const spelling of ["--run=false", "--run=true", "--run="]) { - 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(["plan", REQUEST]); + // A command line naming none of them has nothing to say here, whatever else + // it holds — including the retained options and `--help` itself. + expect(removedPlanOption(["plan", "--help"])).toBe(undefined); + expect(removedPlanOption(["plan", REQUEST, ...RETAINED.flat()])).toBe(undefined); + // A token after the separator is the request, not an option. + expect(removedPlanOption(["plan", "--", "--run"])).toBe(undefined); + }); - // 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 = scanPlanArgs(["plan", REQUEST, spelling, "--journal", "trace.jsonl"]); - expect(gated.error).toBe(REFUSAL); - expect(gated.fixed).toEqual(["plan", REQUEST]); + it("PS3: a name that merely begins like a property option is an unknown one", function* () { + // Exactly the aggregate and the two generated prefixes are property + // options. Telling a caller who wrote something else to configure their + // program with `xmd run` would answer a question they did not ask. + for (const name of ["--propspective", "--no-propspective", "--no-props", "--propsy"]) { + expect(scanPlanArgs(["plan", REQUEST, name]).error).toBe( + `unrecognized option for xmd plan: ${name}`, + ); + expect(removedPlanOption(["plan", REQUEST, name])).toBe(undefined); } - // The switch itself is unaffected, wherever it is written. - expect(scanPlanArgs(["plan", REQUEST, "--run"]).error).toBe(undefined); - expect(scanPlanArgs(["plan", REQUEST, "--run", "--journal", "t.jsonl"]).error).toBe(undefined); + // While the real spellings are still the removed options they are. + for (const name of ["--props", "--props-name", "--no-props-loud"]) { + expect(scanPlanArgs(["plan", REQUEST, name]).error).toBe(removedOptionRefusal(name)); + } }); - it("C1: an option this command does not define is refused, not dropped", function* () { + it("PS3: 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 = scanPlanArgs(["plan", REQUEST, "--not-a-thing", "value"]); @@ -336,28 +267,6 @@ describe("Tier PR — xmd plan fixed grammar", () => { 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. - expect(retired.occurrences).toEqual([]); - }); - - it("C7: a frozen option's shape is what a later candidate may not change", function* () { - const scalar = bindingsFor({ name: { type: "string" } }); - const boolean = bindingsFor({ name: { type: "boolean" } }); - const array = bindingsFor({ name: { type: "array", items: { type: "string" } } }); - const absent = bindingsFor({ other: { type: "string" } }); - - expect(signatureOf(scalar[0])).toEqual({ boolean: false, array: false }); - const stable = frozen({ "--props-name": signatureOf(scalar[0]) }); - - // Unchanged: nothing is refused, so the same sources resolve again. - expect(signatureFailure(stable, scalar)).toBe(undefined); - - expect(signatureFailure(stable, absent)).toContain("declares no such property"); - expect(signatureFailure(stable, boolean)).toContain("single-value option"); - expect(signatureFailure(stable, boolean)).toContain("bare switch"); - expect(signatureFailure(stable, array)).toContain("repeated value option"); - - // An option nobody supplied is never frozen, so a candidate may add, - // remove or reshape it freely. - expect(signatureFailure(new Map(), absent)).toBe(undefined); + expect(retired.fixed).toEqual(["plan", REQUEST]); }); }); diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index 0815595a5..bbbdeec01 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -2,90 +2,106 @@ * Tier PR — the `xmd plan` command lifecycle * (specs/plan-command-spec.md). * - * The command's grammar, filesystem, journal, lifetime and execution rows: what - * `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. + * Rows PS2–PS10. `xmd plan` maps one request to one reviewed XMD source + * artifact and starts no program, so every row here is about what the command + * delivers and what it refuses to do on the way: the grammar an operator meets, + * the two sinks the approved bytes may reach, and the endings that reach + * neither. * * The grammar rows shell out, because exit status and help text are what an - * operator sees. Everything with a phase to observe runs in process, where a - * refusal is proven by the tripwires that stayed at zero. + * operator sees, and because a subprocess is where a removed option meets the + * real parser rather than only the scanner. Every one of them carries an + * impossible dependency — an agent name nothing resolves, an isolated `HOME` + * with no session directory in it — so a refusal is proven by the phases that + * left no trace rather than by output nobody produced. + * + * Everything with a phase to observe runs in process, where the approved + * program's own effects are the negative control: a document that writes a file + * and then fails proves it was never interpreted. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { runCli } from "@executablemd/test-support/launch"; -import { ensureDir, readTextFile, writeTextFile } from "@effectionx/fs"; +import { ensureDir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; import { stat } from "@executablemd/runtime"; -import { ensure, Ok, scoped, spawn, until } from "effection"; -import type { Operation, Result } from "effection"; +import { Elicitation } from "@executablemd/core"; +import { ensure, scoped, spawn, until } from "effection"; +import type { Operation } from "effection"; import { join } from "node:path"; import { readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import process from "node:process"; -import { planExecutor } from "../src/cli.ts"; -import { resolveAgentStack } from "../src/agent-stack.ts"; -import type { AgentStack } from "../src/agent-stack.ts"; +import * as cliModule from "../src/cli.ts"; import { runPlan } from "../src/plan.ts"; -import type { PlanCommand, PlanExecution } from "../src/plan.ts"; +import type { PlanCommand } from "../src/plan.ts"; +import type { AuthorshipStack } from "../src/agent-stack.ts"; +import { planComponentDescription, structuralValidation } from "../src/plan-component.ts"; import { namesPlan, namesRetiredCommand, + removedOptionRefusal, RETIRED_COMMAND_REFUSAL as RETIRED_REFUSAL, - scanPlanArgs, + RUN_REMOVAL_REFUSAL, } from "../src/plan-args.ts"; import { ADAPTERS, AGENT, createPlanHarness, - timesRead, - useEnvironment, - useRecordedEnvironment, + useAuthorshipRoot, useWorkingDirectory, } from "./support/plan-harness.ts"; import type { PlanHarness } from "./support/plan-harness.ts"; +import { makeStore } from "./support/fake-acp.ts"; -import { unsupportedRepositories } from "../src/run-repositories.ts"; const REQUEST = "write a greeting"; -/** A document that declares props and writes what it resolved. */ -const GREETER = [ +const PLAIN = "Nothing but prose.\n"; + +/** + * The negative control for non-execution. + * + * It writes a file and then fails, so interpreting it is observable twice over: + * the file would be on disk, and the command would exit nonzero. A successful + * command that produced the exact bytes and neither observation is a command + * that never ran what it wrote. + */ +const EFFECT_AND_FAILURE = [ + "# A program nobody asked to run", + "", + 'the approved program ran', + "", + "```bash exec", + "exit 3", + "```", + "", +].join("\n"); + +/** + * A structurally valid Plan whose root declares a required property. + * + * `xmd plan` has no property source to resolve it with — the values belong to + * the later `xmd run` — so producing this successfully is what tells the + * command's structural gate apart from a full root-props validation. + */ +const REQUIRES_NAME = [ "---", "props:", " type: object", " properties:", " name: { type: string }", - " loud: { type: boolean, default: false }", - " count: { type: number }", " required: [name]", " additionalProperties: false", "---", "", - 'name={props.name} loud={props.loud} count={props.count}', - "", -].join("\n"); - -/** An approved Plan proving that `--run --verbose` receives the run-profile default. */ -const VERBOSE_PLAN = [ - "ordinary before", + "# Greet somebody", "", - "", - "plan verbose body executed", - "", - "", - "ordinary after", + 'Hello, {props.name}!', "", ].join("\n"); -/** - * A document that validates and then fails at run time. - * - * Validation runs no command, so nothing before execution can know this one - * exits nonzero — which is the point: a runtime failure is not a candidate - * defect and never returns the command to generation or review. - */ -const FAILS_AT_RUN = ["```bash exec", "exit 3", "```", ""].join("\n"); - -const PLAIN = "Nothing but prose.\n"; +/** A draft that resolves no such component, for the endings that never approve. */ +const UNRESOLVED = "\n"; const PROBE_HEADING = "Retired token probe"; const PROBE_SENTINEL = "the document ran"; @@ -104,91 +120,44 @@ const NAMED_LIKE_THE_RETIRED_TOKEN = [ "", ].join("\n"); -/** - * A document that validates, runs, and fails its own tests. - * - * A `` boundary is what makes an assertion decide the outcome of an - * ordinary run, so this is the approved document that ends in the one failure - * `xmd run` reports differently from every other: with a heading of its own. - */ -const FAILING_TEST = [ - "", - '', - "", - "", - "", - "", -].join("\n"); - -/** The Agent configuration a dispatch settles once and hands to both consumers. */ -const STACK: AgentStack = { +/** Who writes the Plan, as a dispatch settles it: no permission mode to settle. */ +const STACK: AuthorshipStack = { provider: "acpx", defaultAgent: AGENT, - permissionMode: "deny-all", adapters: ADAPTERS, }; -/** - * One invocation, asking for the approved Plan to be run. - * - * `--run` is the default here because these cases are about the run: what the - * journal holds, what the document writes, how a runtime failure reports. The - * 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): PlanCommand { - const argv = ["plan", ...args, "--run"]; - return { - argv, - scan: scanPlanArgs(argv), - include: [dir], - ...(output === undefined ? {} : { output }), - run: true, - stack: STACK, - }; -} - -/** The same invocation, writing the Plan rather than running it. */ -function writing(dir: string, args: string[], output?: string): PlanCommand { - const argv = ["plan", ...args]; +/** One invocation, writing its approved source to stdout or to a file. */ +function planning(dir: string, output?: string, session?: string): PlanCommand { return { - argv, - scan: scanPlanArgs(argv), + request: REQUEST, include: [dir], ...(output === undefined ? {} : { output }), - run: false, + ...(session === undefined ? {} : { session }), stack: STACK, }; } -/** The production executor, configured the way the dispatch configures it. */ -function executor( - dir: string, - journal?: string, - stack?: AgentStack, - verbose = false, -): (approved: PlanExecution) => Operation> { - return planExecutor( - { - include: [dir], - verbose, - journal, - raw: true, - secretDetection: true, - }, - stack ?? STACK, - undefined, - function* () {}, - // No repository provider: this suite drives the executor, not a checkout. - unsupportedRepositories, - ); -} - function* exists(path: string): Operation { return (yield* stat(path)).exists; } -/** Every phase after the refusal, at zero. */ +/** + * What a subprocess complained about, without the runtime's own chatter. + * + * A cold module cache prints its downloads to the same stream, and a case that + * compared the whole of stderr would be asserting on whether this machine had + * run the CLI before. + */ +function complaints(stderr: string): string { + return stderr + .split("\n") + .filter((line) => !/Download\b.*https?:\/\//.test(line)) + .join("\n") + .trim(); +} + +/** Every phase after a refusal, at zero. */ function untouched(harness: PlanHarness): Record { return { catalogs: harness.catalogCalls.length, @@ -196,168 +165,340 @@ function untouched(harness: PlanHarness): Record { started: harness.fake.started, turns: harness.fake.prompts.length, reviews: harness.reviews.length, - executions: harness.executions.length, }; } -const NOTHING = { - catalogs: 0, - runtimes: 0, - started: false, - turns: 0, - reviews: 0, - executions: 0, -}; +/** What the command wrote to stderr while it ran. */ +function* reported(body: () => Operation): Operation<{ value: T; lines: string[] }> { + const written = console.error; + const lines: string[] = []; + const value = yield* scoped(function* (): Operation { + yield* ensure(() => { + console.error = written; + }); + console.error = (...parts: unknown[]) => { + lines.push(parts.map((part) => String(part)).join(" ")); + }; + return yield* body(); + }); + return { value, lines }; +} + +/** What the command wrote to stdout, exactly, chunk by chunk. */ +function* delivered(body: () => Operation): Operation<{ value: T; chunks: string[] }> { + const original = process.stdout.write.bind(process.stdout); + const chunks: string[] = []; + const value = yield* scoped(function* (): Operation { + yield* ensure(() => { + process.stdout.write = original; + }); + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return true; + }) as typeof process.stdout.write; + return yield* body(); + }); + return { value, chunks }; +} + +/** + * The complete approved `xmd plan --help` output. + * + * Pinned whole rather than by phrase, because what this row is about is + * everything help no longer says: an option that came back, or a sentence that + * still promised to run the program, would pass every `toContain` written about + * the parts that stayed. + */ +const PLAN_HELP = [ + "Usage: xmd plan [OPTIONS] [request]", + "", + "Arguments:", + " [request] the request the coding agent should turn into an XMD Plan", + "", + "Options:", + " --output [OUTPUT] write the approved source here instead of to stdout (path must not exist)", + " --session [SESSION] logical name for the assistant session (default: unique to this invocation)", + " --include ... component search directory [default: components,.]", + " --agent-provider agent provider for Plan authorship [default: acpx]", + " --default-agent [DEFAULTAGENT] default agent name (overrides DEFAULT_AGENT_NAME)", + " --timeout [TIMEOUT] deadline for the whole planning invocation, as a duration (500ms, 30s, 5min)", + " -h, --help show help", + "", + "Exactly one request is required. It describes the program you want the coding", + "agent to create, rather than a path. Quote it so the shell passes it as one", + "argument:", + ' xmd plan "Prepare the release program."', + "", + "A first-party command document turns the request into an XMD Plan. xmd checks", + "each draft, and you approve, request changes, or stop before source leaves the", + "command.", + "", + "The approved Plan is the only result. Without --output, stdout contains its", + "exact source bytes and nothing else. With --output, the path is created", + "exclusively after approval; an existing path is left unchanged.", + "", + "Planning never runs the approved program. Compose planning and execution", + "explicitly through standard input:", + ' xmd plan "Prepare the release program." | xmd run -', + "", + "Or preserve the artifact and run it later:", + ' xmd plan "Prepare the release program." --output release.md && xmd run release.md', + "", + "A named --session continues the planning conversation. Without it, this", + "invocation uses a unique session.", +].join("\n"); + +/** Every option `xmd plan` removed, as help and a refusal spell them. */ +const REMOVED_SPELLINGS = [ + "--run", + "--props", + "--no-props", + "--raw", + "--verbose", + "-V", + "--journal", + "-j", + "--timeout-exec", + "--timeout-fetch", + "--approve-all", + "--approve-reads", + "--deny-all", + "--secret-detection", +]; + +/** + * An invocation that cannot get past authorship. + * + * The agent name resolves to nothing and `HOME` is a directory this case made, + * so an invocation that reached the catalog, the provider or a session leaves + * one of three traces: the rendered catalog, an `unavailable` agent, or a + * `.xmd` tree under that home. A grammar refusal leaves none of them. + */ +function* refusedEarly(args: string[], expected: string): Operation<{ stderr: string }> { + return yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + const result = yield* runCli( + ["plan", ...args, "--default-agent", "xmd-nonexistent-agent", "--session", "probe"], + { cwd: dir, env: { HOME: home } }, + ).join(); + + expect(result.code).toBe(1); + expect(result.stderr).toContain(expected); + // Nothing downstream of the refusal happened: no catalog reached a stream, + // no provider was built to report an agent unavailable, no session + // directory was placed, and no approved source escaped. + expect(result.stdout).toBe(""); + expect(result.stderr).not.toContain("## Built-in components"); + expect(result.stderr).not.toContain("unavailable"); + expect(yield* exists(join(home, ".xmd"))).toBe(false); + // And nothing was written beside it: the home this case made is the only + // entry in the working directory. + expect(yield* until(readdir(dir))).toEqual(["home"]); + return { stderr: result.stderr }; + }); +} describe( "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, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - const code = yield* runPlan( - command(dir, ["--props-name", "Ada", REQUEST], "out.md"), - harness.deps, - ); - - expect(code).toBe(1); - expect(untouched(harness)).toEqual(NOTHING); - expect(yield* exists(join(dir, "out.md"))).toBe(false); - }); - - // The agent configuration is settled before the command begins, so an - // incompatible pair of permission flags costs no inspection at all. Read - // at the boundary an operator uses, because that is where the resolution - // the whole invocation shares now happens. + it("PS4: plan help states the retained grammar and both compositions", function* () { yield* useWorkingDirectory(function* (dir) { - const { code, stderr } = yield* runCli( - ["plan", REQUEST, "--approve-all", "--deny-all", "--run"], + // Every retained option beside `--help`: help is still help, and none + // of them turns it into a refusal. + const { code, stdout, stderr } = yield* runCli( + [ + "plan", + "--help", + "--output", + "out.md", + "--session", + "ada", + "--include", + "lib", + "--timeout", + "5s", + "--agent-provider", + "acpx", + "--default-agent", + "xmd-nonexistent-agent", + ], { cwd: dir }, ).join(); - expect(code).toBe(1); - expect(stderr).toContain( - "--approve-all, --approve-reads, and --deny-all are mutually exclusive", - ); - // No provider was built: reaching one is what reports an agent as - // unavailable, and this command line never got that far. + expect(code).toBe(0); + // Every word, option, order, example and paragraph, exactly. + expect(stdout.trimEnd()).toBe(PLAN_HELP); + // Which is also the whole of it: no removed option is described, + // mentioned as refused, or listed among the ones this command takes. + for (const spelling of REMOVED_SPELLINGS) { + expect(stdout).not.toContain(spelling); + } + expect(stdout).not.toContain("XMD_PROPS"); + // Help reads no catalog and creates nothing. + expect(stdout).not.toContain("## Built-in components"); expect(stderr).not.toContain("unavailable"); + expect(yield* exists(join(dir, "out.md"))).toBe(false); }); - }); - it("C1: --run=false reaches no authorship and no durable effect", function* () { - // `--run=false` used to be read as the switch, which satisfied the gate - // that only exists because a run is what makes `--journal` mean anything. - // The command line was then accepted, nothing ran, and the journal the - // caller asked for was never created — so the gate answered a request it - // had not honoured. Nonzero alone would not catch that: the invocation - // below would have failed anyway, on the agent it cannot reach. - // - // This case names the phases that stayed at zero, which a subprocess - // 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, authorshipRoot) { - const journal = join(dir, "trace.jsonl"); - const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir, journal); - harness.fake.script({ reply: PLAIN }); - harness.script({ decision: "Approve" }); + // The program summary names the result rather than a choice between two. + const program = yield* runCli(["--help"]).expect(); + expect(program.stdout).toMatch(/^\s+plan\s/m); + expect(program.stdout).toContain( + "Turn a request into an XMD Plan, review it, and write the approved source.", + ); + // And the spelling this command replaced is listed nowhere. + expect(program.stdout).not.toMatch(/^\s+prompt\s/m); + }); - const argv = ["plan", REQUEST, "--run=false", "--journal", journal]; - const written = console.error; - const lines: string[] = []; - const value = yield* scoped(function* (): Operation { - yield* ensure(() => { - console.error = written; - }); - console.error = (...parts: unknown[]) => { - lines.push(parts.map((part) => String(part)).join(" ")); - }; - return yield* runPlan( - { argv, scan: scanPlanArgs(argv), include: [dir], run: false, stack: STACK }, - harness.deps, - ); - }); + it("PS5: xmd run --help still exposes every option it configures", function* () { + const run = yield* runCli(["run", "--help"]).expect(); + + // Splitting the Plan grammar out took nothing away from the command that + // executes a document: every class `xmd plan` dropped is still here. + for (const option of [ + "--journal", + "-j", + "--raw", + "--verbose", + "-V", + "--timeout", + "--timeout-exec", + "--timeout-fetch", + "--approve-all", + "--approve-reads", + "--deny-all", + "--secret-detection", + "--include", + "--agent-provider", + "--default-agent", + "--eval", + ]) { + expect(run.stdout).toContain(option); + } - expect(value).toBe(1); - // The refusal is the one the fixed grammar owes this command line, not - // an incidental failure further along. - expect(lines).toEqual([ - "--run does not take a value — write --run to execute the Plan " + - "or leave it out to write the Plan", - ]); - // Every phase after preflight stayed at zero: no catalog, no provider, - // no turn, no review, no execution. - expect(untouched(harness)).toEqual(NOTHING); - // 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(authorshipRoot))).toEqual([]); - // And nothing durable exists — neither the journal it named, nor an - // output file, nor anything else. - expect(yield* exists(journal)).toBe(false); - expect(yield* until(readdir(dir))).toEqual([]); + // Including the root properties a run binds for the document it names, + // which is where the generated options `xmd plan` dropped still live. + yield* useWorkingDirectory(function* (dir) { + yield* writeTextFile(join(dir, "greeter.md"), REQUIRES_NAME); + const props = yield* runCli(["run", "greeter.md", "--help"], { cwd: dir }).expect(); + expect(props.stdout).toContain("--props-name "); + expect(props.stdout).toContain("Environment: XMD_PROPS_NAME"); + expect(props.stdout).toContain("--props "); }); }); - 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 `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 = - "--run does not take a value — write --run to execute the Plan " + - "or leave it out to write the Plan"; - - for (const spelling of ["--run=false", "--run=true", "--run="]) { - yield* useWorkingDirectory(function* (dir) { - const { code, stdout, stderr } = yield* runCli( - ["plan", REQUEST, spelling, "--journal", "trace.jsonl"], - { cwd: dir }, - ).join(); - - expect(code).toBe(1); - expect(stderr).toContain(REFUSAL); - // No approved Plan escaped: stdout is where one would have gone. - expect(stdout).toBe(""); - // No provider was reached — reaching one is what reports an agent as - // unavailable, and this command line never got that far. - expect(stderr).not.toContain("unavailable"); - // And nothing was written: not the journal it named, not an output - // file, not anything else. - expect(yield* until(readdir(dir))).toEqual([]); - }); + it("PS2: every --run spelling reports the migration and reaches nothing", function* () { + for (const spelling of [ + ["--run"], + ["--run=true"], + ["--run=false"], + ["--run="], + ["--run", "--run"], + ]) { + const { stderr } = yield* refusedEarly([REQUEST, ...spelling], RUN_REMOVAL_REFUSAL); + // The whole migration, and only it: no second complaint about the + // options it used to gate, and no hint that a compatibility spelling + // still exists. + expect(complaints(stderr)).toBe(RUN_REMOVAL_REFUSAL); + } + + // Placement decides nothing: before the request, and swallowed by an + // option that takes a value, are the two places a hidden switch survives. + yield* refusedEarly(["--run", REQUEST], RUN_REMOVAL_REFUSAL); + yield* refusedEarly([REQUEST, "--include", "--run"], RUN_REMOVAL_REFUSAL); + + // Including beside `--help`, in either order. `--help` is lifted out of + // argv before any command's own grammar runs, so this is the one place a + // removed option could be answered with a page describing a command that + // would refuse it. + for (const argv of [ + ["--help", "--run"], + ["--run", "--help"], + ]) { + const { stderr } = yield* refusedEarly(argv, RUN_REMOVAL_REFUSAL); + expect(complaints(stderr)).toBe(RUN_REMOVAL_REFUSAL); } }); - it("C1: --save is gone, and is refused as the unknown option it is", function* () { - // Nothing was released under the old spelling, so there is no alias and - // nothing to keep compatible with. Proven on a command line that is - // 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(["plan", REQUEST, "--save", "out.md"], { - cwd: dir, - }).join(); + it("PS3: every other removed option refuses before authorship", function* () { + for (const option of [ + ["--journal", "trace.jsonl"], + ["-j", "trace.jsonl"], + ["--raw"], + ["--verbose"], + ["-V"], + ["--timeout-exec", "5s"], + ["--timeout-fetch", "5s"], + ["--approve-all"], + ["--deny-all"], + ["--no-secret-detection"], + ["--props", '{"name":"Ada"}'], + ["--props-name", "Ada"], + ["--no-props-loud"], + ]) { + const { stderr } = yield* refusedEarly( + [REQUEST, ...option], + removedOptionRefusal(option[0]), + ); + expect(complaints(stderr)).toBe(removedOptionRefusal(option[0])); + } - expect(code).toBe(1); - expect(stderr).toContain("--save"); - // Not accepted, not silently dropped, and not mistaken for `--output`. - expect(yield* exists(join(dir, "out.md"))).toBe(false); - // And nothing downstream of the refusal ran: no catalog was rendered, no - // provider was reached, nobody was asked anything, no approved source - // was printed, and no journal exists. - expect(stdout).toBe(""); - expect(stderr).not.toContain("## Built-in components"); - expect(stderr).not.toContain("unavailable"); - expect(yield* until(readdir(dir))).toEqual([]); - }); + // A duration nobody can parse is still answered as a removed option + // rather than as a malformed value: the grammar this command owns runs + // before the shared timeout reader. + yield* refusedEarly([REQUEST, "--timeout-exec=abc"], removedOptionRefusal("--timeout-exec")); + // And so does a secret-detection spelling the shared grammar check would + // otherwise complain about first. + yield* refusedEarly( + [REQUEST, "--secret-detection=yes"], + removedOptionRefusal("--secret-detection"), + ); + + // Beside `--help`, in either order, exactly as `--run` is. + for (const argv of [ + ["--help", "--journal", "trace.jsonl"], + ["--journal", "trace.jsonl", "--help"], + ]) { + const { stderr } = yield* refusedEarly(argv, removedOptionRefusal("--journal")); + expect(complaints(stderr)).toBe(removedOptionRefusal("--journal")); + } + + // A name that merely begins like a property option is an option this + // command does not define, and is answered as one. + for (const name of ["--propspective", "--no-propspective", "--no-props"]) { + yield* refusedEarly([REQUEST, name], `unrecognized option for xmd plan: ${name}`); + } }); - it("C1: the retired spelling names no command and reaches no authorship", function* () { + it("PS1: exactly one request, and an unknown option is refused not dropped", function* () { + yield* refusedEarly( + [REQUEST, "second"], + "unrecognized argument for xmd plan: second — the command takes exactly one request", + ); + yield* refusedEarly([REQUEST, "--not-a-thing", "value"], "unrecognized option for xmd plan"); + yield* refusedEarly( + [REQUEST, "--save", "out.md"], + "unrecognized option for xmd plan: --save", + ); + yield* refusedEarly([REQUEST, "--session", ""], "--session needs a name"); + }); + + it("PS1: 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 + // contradiction — and one the parser used to drop in silence, leaving the + // caller watching a different document get generated. + for (const flag of ["-e", "--eval"]) { + yield* refusedEarly( + [REQUEST, flag, "# supplied"], + "unrecognized option for xmd plan: --eval — inline documents are exclusive to xmd run", + ); + } + }); + + it("PS1: 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 — and it is not left to the default // `run` grammar either, which would read it as a document reference. It @@ -388,19 +529,19 @@ describe( expect(stdout).toBe(""); expect(stderr).not.toContain("## Built-in components"); expect(stderr).not.toContain("unavailable"); - expect(stderr).not.toContain("Nothing was output or run"); + expect(stderr).not.toContain("Nothing was output"); 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. + // No output file: the only entry is the home this case made for the + // subprocess. expect(yield* until(readdir(dir))).toEqual(["home"]); }); }); - it("C1: the retired token is refused before it can be read as a document path", function* () { + it("PS1: 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 @@ -424,14 +565,9 @@ describe( 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 @@ -445,518 +581,222 @@ describe( }); }); - 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 - // contradiction — and one the parser used to drop in silence, leaving the - // caller watching a different document get generated. - for (const flag of ["-e", "--eval"]) { - yield* useWorkingDirectory(function* (dir) { - const { code, stdout, stderr } = yield* runCli( - [ - "plan", - REQUEST, - flag, - "# supplied", - "--output", - "out.md", - "--run", - "--journal", - "trace.jsonl", - ], - { cwd: dir }, - ).join(); - - expect(code).toBe(1); - expect(stderr).toContain( - "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. - expect(stdout).not.toContain("## Built-in components"); - expect(stderr).not.toContain("unavailable"); - expect(stdout).toBe(""); - expect(yield* exists(join(dir, "out.md"))).toBe(false); - expect(yield* exists(join(dir, "trace.jsonl"))).toBe(false); - }); - } - }); - - it("C1: help needs no request and touches nothing", function* () { - yield* useWorkingDirectory(function* (dir) { - const { code, stdout, stderr } = yield* runCli( - [ - "plan", - "--help", - "--output", - "out.md", - "--journal", - "trace.jsonl", - "--default-agent", - "xmd-nonexistent-agent", - ], - { cwd: dir }, - ).join(); - - expect(code).toBe(0); - 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"); - // Where an approved Plan goes, and what changes that. - expect(stdout).toContain("The approved Plan is the result. By default it is written to"); - expect(stdout).toContain("--output "); - expect(stdout).toContain("Write the approved Plan there instead of to stdout"); - expect(stdout).toContain("--run"); - expect(stdout).toContain("Run the approved Plan instead of writing it"); - expect(stdout).toContain("--session "); - // The run-only flags are named as such rather than left to be - // discovered by a refusal. - expect(stdout).toContain("are\nrefused without --run"); - // The permission flags are the approved Plan's, and help says so rather - // than letting a caller believe they configure how the Plan is written. - expect(stdout).toContain("Permission flags configure the approved Plan"); - expect(stderr).not.toContain("unavailable"); - // No catalog was rendered, and neither file the options named was made. - expect(stdout).not.toContain("## Built-in components"); - expect(yield* exists(join(dir, "out.md"))).toBe(false); - expect(yield* exists(join(dir, "trace.jsonl"))).toBe(false); - }); - - const program = yield* runCli(["--help"]).expect(); - 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(["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"); - - // 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(["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"); - expect(stray.stdout).toBe(""); - expect(yield* exists(join(dir, "trace.jsonl"))).toBe(false); - } - // That the same flag is ordinary again once `--run` is present is fixed - // grammar, and is proven there rather than by an invocation that would - // have to reach a real agent to say so. - }); - }); - - it("C15: individual, aggregate and environment sources resolve and reach the run", function* () { - 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* runPlan( - command(dir, [REQUEST, "--props-name", "Ada", "--props-loud"]), - harness.deps, - ); - - expect(code).toBe(0); - // Individual CLI beats the aggregate environment; the switch is true; the - // property only the aggregate supplied is still there. - expect(yield* readTextFile(join(dir, "greeting.txt"))).toBe("name=Ada loud=true count=7"); - }); - }); - - it("C12: every refusal is nonzero and leaves no output file, journal or run", function* () { - const journalName = "trace.jsonl"; - - // Stop at review, through the command document's authored failure. - 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* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); - - expect(code).toBe(1); - expect(harness.executions).toHaveLength(0); - expect(yield* exists(join(dir, "out.md"))).toBe(false); - expect(yield* exists(join(dir, journalName))).toBe(false); - }); - - // A turn that produced text and then failed. - 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* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); - - expect(code).toBe(1); - expect(harness.reviews).toHaveLength(0); - expect(yield* exists(join(dir, "out.md"))).toBe(false); - expect(yield* exists(join(dir, journalName))).toBe(false); - }); - - // A terminal property-source failure. - 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* runPlan( - command(dir, [REQUEST, "--props-absent", "x"], "out.md"), - harness.deps, - ); - - expect(code).toBe(1); - expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); - expect(yield* exists(join(dir, "out.md"))).toBe(false); - expect(yield* exists(join(dir, journalName))).toBe(false); - }); - - // An approved run's journal holds the document's events and no authorship. + it("PS6: approval writes the exact source once to stdout, and runs none of it", function* () { yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const journal = join(dir, journalName); const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir, journal); - harness.fake.script({ reply: PLAIN }); + harness.fake.script({ reply: EFFECT_AND_FAILURE }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); - expect(code).toBe(0); + const { value, chunks } = yield* delivered(() => runPlan(planning(dir), harness.deps)); - const trace = yield* readTextFile(journal); - expect(trace).toContain("__root__"); - expect(trace).toContain(PLAIN.trim()); - // Nothing about generation is in it: not the request, not a turn, not a - // review, not a repair. - expect(trace).not.toContain(REQUEST); - expect(trace).not.toContain("agent_prompt"); - expect(trace).not.toContain("elicit"); + // The approved program writes a file and then exits 3. Success is + // therefore the first half of the proof: nothing interpreted it. + expect(value).toBe(0); + // Byte for byte, in one write, with no fence, heading, label or newline + // this command added. + expect(chunks).toEqual([EFFECT_AND_FAILURE]); + // And the second half: neither observation the program would have left. + expect(yield* until(readdir(dir))).toEqual([]); }); }); - it("C11: the approved Plan is the result, and where it goes is the caller's", function* () { - // 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. + it("PS7: --output creates the artifact after teardown, and never replaces one", function* () { yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const out = join(dir, "release.md"); const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir); - harness.fake.script({ reply: PLAIN }); - harness.script({ decision: "Approve" }); - - const written: string[] = []; - const original = process.stdout.write.bind(process.stdout); - const code = yield* scoped(function* (): Operation { - yield* ensure(() => { - process.stdout.write = original; + harness.fake.script({ reply: EFFECT_AND_FAILURE }); + + // Observed from inside the authorship frame's own teardown, which is + // the last thing that happens before the host validates and delivers. + // A command that opened the file early — to stream into it, or to + // truncate it — would already have created it here. + const events: string[] = []; + let duringTeardown = true; + harness.deps.installElicitation = function* () { + yield* ensure(function* () { + events.push("teardown"); + duringTeardown = yield* exists(out); }); - process.stdout.write = ((chunk: string | Uint8Array) => { - written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }) as typeof process.stdout.write; - return yield* runPlan(writing(dir, [REQUEST]), harness.deps); - }); - - expect(code).toBe(0); - // Byte for byte, once, with nothing around it. - expect(written.join("")).toBe(PLAIN); - // And nothing ran: no execution, so no journal and no document effects. - expect(harness.executions).toHaveLength(0); - expect(yield* until(readdir(dir))).toEqual([]); - }); - - // `--output`: the same bytes in the file, a quiet stdout, and still no run. - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir); - harness.fake.script({ reply: GREETER }); - harness.script({ decision: "Approve" }); - - const written: string[] = []; - const original = process.stdout.write.bind(process.stdout); - const code = yield* scoped(function* (): Operation { - yield* ensure(() => { - process.stdout.write = original; - }); - process.stdout.write = ((chunk: string | Uint8Array) => { - written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }) as typeof process.stdout.write; - return yield* runPlan( + yield* Elicitation.around( { - ...writing(dir, [REQUEST, "--props-name", "Ada"], "plan.md"), - argv: ["plan", REQUEST, "--props-name", "Ada", "--output", "plan.md"], + // deno-lint-ignore require-yield + *elicit([request], _next) { + harness.reviews.push(request); + events.push("review"); + return { decision: "Approve" }; + }, }, - harness.deps, + { at: "min" }, ); - }); + }; - expect(code).toBe(0); - expect(written.join("")).toBe(""); - expect(yield* readTextFile(join(dir, "plan.md"))).toBe(GREETER); - // The Plan was not run, so what it would have written is not there. - expect(harness.executions).toHaveLength(0); - expect((yield* until(readdir(dir))).sort()).toEqual(["plan.md"]); + const { value, chunks } = yield* delivered(() => runPlan(planning(dir, out), harness.deps)); + + expect(value).toBe(0); + expect(events).toEqual(["review", "teardown"]); + expect(duringTeardown).toBe(false); + // The same exact bytes the default sink would have written, and stdout + // stays empty: a caller who named a file does not also get a copy. + expect(yield* readTextFile(out)).toBe(EFFECT_AND_FAILURE); + expect(chunks).toEqual([]); + // Still nothing ran, so the file the program writes is not beside it. + expect((yield* until(readdir(dir))).sort()).toEqual(["release.md"]); }); - // `--run`: the Plan runs, and stdout is the Plan's own to use. + // An existing path is left exactly as it is, and the command stops. yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const out = join(dir, "release.md"); + yield* writeTextFile(out, "keep me\n"); const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir); - harness.fake.script({ reply: GREETER }); + harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - const written: string[] = []; - const original = process.stdout.write.bind(process.stdout); - const code = yield* scoped(function* (): Operation { - yield* ensure(() => { - process.stdout.write = original; - }); - process.stdout.write = ((chunk: string | Uint8Array) => { - written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }) as typeof process.stdout.write; - return yield* runPlan(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); - }); + const { value, lines } = yield* reported(() => runPlan(planning(dir, out), harness.deps)); - expect(code).toBe(0); - // The source itself was never printed. - expect(written.join("")).not.toContain("props:"); - // The document ran, and did what it says. - expect(yield* readTextFile(join(dir, "greeting.txt"))).toBe("name=Ada loud=false count="); + expect(value).toBe(1); + expect(lines.join("\n")).toBe( + `${out} already exists — choose another --output path; the approved Plan was not written`, + ); + expect(yield* readTextFile(out)).toBe("keep me\n"); }); + }); - // VB6 — `--run --verbose`: the approved Plan receives the production - // run-profile `` component, and the run's own verbosity is - // what it reads. + it("PS8: a Plan declaring a required root property is produced with no value", function* () { yield* useWorkingDirectory(function* (dir, authorshipRoot) { const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir, undefined, undefined, true); - harness.fake.script({ reply: VERBOSE_PLAN }); + harness.fake.script({ reply: REQUIRES_NAME }); harness.script({ decision: "Approve" }); - const written: string[] = []; - const original = process.stdout.write.bind(process.stdout); - const code = yield* scoped(function* (): Operation { - yield* ensure(() => { - process.stdout.write = original; - }); - process.stdout.write = ((chunk: string | Uint8Array) => { - written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return true; - }) as typeof process.stdout.write; - return yield* runPlan(command(dir, [REQUEST, "--verbose"]), harness.deps); - }); + const { value, chunks } = yield* delivered(() => runPlan(planning(dir), harness.deps)); - expect(code).toBe(0); - expect(written.join("")).toContain("ordinary before"); - expect(written.join("")).toContain("plan verbose body executed"); - expect(written.join("")).toContain("ordinary after"); + // The command has no property source to resolve `name` from — the value + // belongs to whoever runs the program later — so a gate that validated + // root props here would refuse a Plan for not having been given an + // argument nobody has offered it yet. + expect(value).toBe(0); + expect(chunks.join("")).toBe(REQUIRES_NAME); + // The draft check inside the workflow answered the same way: it was + // never sent back for repair. + expect(harness.fake.prompts).toHaveLength(1); + expect(harness.reviews).toHaveLength(1); }); }); - it("C11: the approved bytes are created exclusively, before the run", function* () { - // Created before execution, and byte for byte. - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - const run = executor(dir); - const seen: boolean[] = []; - harness.deps.execute = function* (approved) { - seen.push(yield* exists(join(dir, "out.md"))); - return yield* run(approved); - }; - harness.fake.script({ reply: GREETER }); - harness.script({ decision: "Approve" }); - - const code = yield* runPlan( - command(dir, [REQUEST, "--props-name", "Ada"], "out.md"), - harness.deps, - ); + it("PS9: every ending but approval delivers nothing at all", function* () { + /** One ending, and what it leaves behind. */ + function* ends( + name: string, + arrange: (harness: PlanHarness, dir: string) => Operation, + ): Operation { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const out = join(dir, "release.md"); + const harness = createPlanHarness({ authorshipRoot }); + yield* arrange(harness, dir); + + const { value, chunks } = yield* delivered(() => + reported(() => runPlan(planning(dir, out), harness.deps)), + ); - expect(code).toBe(0); - // The output file already existed when the document started. - expect(seen).toEqual([true]); - // Source only: no diagnostics, no decision, no wrapper. - expect(yield* readTextFile(join(dir, "out.md"))).toBe(GREETER); - }); + expect(`${name}: ${value.value}`).toBe(`${name}: 1`); + // No approved source on stdout, no artifact on disk, and no effect + // from the program that was never admitted. + expect(`${name}: ${chunks.join("")}`).toBe(`${name}: `); + expect(`${name}: ${(yield* until(readdir(dir))).join()}`).toBe(`${name}: `); + }); + } - // An existing path is left exactly as it is, and stops the run. - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - yield* writeTextFile(join(dir, "out.md"), "keep me\n"); - const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir); + // The person stopped. + yield* ends("stop", function* (harness) { harness.fake.script({ reply: PLAIN }); - harness.script({ decision: "Approve" }); - - 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"); - expect(harness.executions).toHaveLength(0); + harness.script({ decision: "Stop" }); }); - // Without the option, no generated source file is created at all. - 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* runPlan(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); - - expect(code).toBe(0); - // Only what the document itself wrote. - expect((yield* until(readdir(dir))).sort()).toEqual(["greeting.txt"]); + // A turn produced text and then failed. + yield* ends("failed turn", function* (harness) { + harness.fake.script({ reply: PLAIN, stopReason: "refusal" }); }); - }); - 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 = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir, journal); - harness.fake.script({ reply: GREETER }); - harness.script({ decision: "Approve" }); - - 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, 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); - expect(yield* exists(join(dir, "out.md"))).toBe(true); + // Ten drafts, none approvable, ending in the automatic explanation. + yield* ends("ten attempts", function* (harness) { + 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 }); + } + if (round < 10) { + harness.script({ decision: "Request changes", feedback: `round ${round}` }); + } + } + harness.fake.script({ reply: "Every draft named a component nothing offers." }); }); - // 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, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir); - harness.fake.script({ reply: FAILS_AT_RUN }); + // The profile provider would not close. + yield* ends("teardown failure", function* (harness) { + harness.fake.closeFailure = new Error("the profile provider would not close"); + harness.fake.script({ reply: PLAIN }); harness.script({ decision: "Approve" }); - - 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); - // The failure did not send the run back to generation or review. - expect(harness.fake.prompts).toHaveLength(1); - expect(harness.reviews).toHaveLength(1); }); - }); - it("C15: a failing test in the approved document reports as a run reports it", function* () { - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir); - harness.fake.script({ reply: FAILING_TEST }); + // The host's own gate, after the command document has settled. The draft + // check and `` both really validate and both succeed; the + // component the draft names is removed immediately after that successful + // admission, so the only thing left to catch it is the check this command + // keeps for itself. + yield* ends("host structural refusal", function* (harness, dir) { + const widget = join(dir, "Widget.md"); + yield* writeTextFile(widget, "A widget.\n"); + harness.fake.script({ reply: ["# Uses a widget", "", "", ""].join("\n") }); harness.script({ decision: "Approve" }); - const written = console.error; - const lines: string[] = []; - const code = yield* scoped(function* (): Operation { - yield* ensure(() => { - console.error = written; - }); - console.error = (...parts: unknown[]) => { - lines.push(parts.map((part) => String(part)).join(" ")); - }; - return yield* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); + const canonical = structuralValidation([dir], [yield* planComponentDescription()]); + const outcomes: string[] = []; + harness.deps.validate = function* (candidate) { + const validation = yield* canonical(candidate); + outcomes.push(validation.outcome); + if (outcomes.length === 2) { + yield* rm(widget, { force: true }); + } + return validation; + }; + yield* ensure(() => { + // Two sound answers inside the Component, and a third that refuses. + expect(outcomes).toEqual(["valid", "valid", "invalid"]); }); - - expect(code).toBe(1); - // Byte for byte what `xmd run` prints for this failure: the heading and - // the blank line above it are how a failed suite is told apart from an - // ordinary error, and printing only the message would lose both. - expect(lines.at(-1)).toBe("\ntests failed: 1 test(s) failed in "); - - // A runtime failure is not a candidate defect: the agent was asked once - // and the person was asked once, and neither was asked again. - expect(harness.fake.prompts).toHaveLength(1); - expect(harness.reviews).toHaveLength(1); - // The file was already written, and a failing run leaves it to hand-edit. - expect(yield* readTextFile(join(dir, "out.md"))).toBe(FAILING_TEST); }); - }); - it("C15: one Agent resolution serves generation and the execution after it", function* () { + // A host whose settled provider supplies no Agent context for ``. yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const reads: string[] = []; - yield* useRecordedEnvironment(reads, { DEFAULT_AGENT_NAME: "settled-agent" }); - - // What a dispatch settles, once, for the whole invocation. - const settled = yield* resolveAgentStack( - { - agentProvider: "acpx", - defaultAgent: undefined, - approveAll: false, - approveReads: false, - denyAll: true, - }, - undefined, - ); - if (!settled.ok) { - throw settled.error; - } - const stack = settled.value; - expect(stack.defaultAgent).toBe("settled-agent"); - expect(timesRead(reads, "DEFAULT_AGENT_NAME")).toBe(1); - const harness = createPlanHarness({ authorshipRoot }); - harness.deps.execute = executor(dir, undefined, stack); - harness.fake.script({ reply: PLAIN }); - harness.script({ decision: "Approve" }); - - const code = yield* runPlan({ ...command(dir, [REQUEST]), stack }, harness.deps); + const { value, lines } = yield* reported(() => + runPlan( + { ...planning(dir, join(dir, "release.md")), stack: { ...STACK, provider: "other" } }, + harness.deps, + ), + ); - expect(code).toBe(0); - // The command document resolved the settled agent rather than a name of - // its own. - expect(harness.fake.ensured.map((input) => input.agent)).toEqual(["settled-agent"]); - // And nothing after it read the name again: authorship and the document - // installation that followed were both configured from the one answer, - // so they cannot disagree about which agent this invocation meant. - expect(timesRead(reads, "DEFAULT_AGENT_NAME")).toBe(1); + expect(value).toBe(1); + expect(lines.join("\n")).toBe( + "The other provider did not provide an Agent context for . " + + "No Plan was returned. Nothing was output.", + ); + // Refused before a directory, a provider, a turn or a review existed. + expect(untouched(harness)).toEqual({ + catalogs: 1, + runtimes: 0, + started: false, + turns: 0, + reviews: 0, + }); + expect(yield* until(readdir(dir))).toEqual([]); }); - }); - it("C13: the deadline encloses every phase, and teardown gates what follows", function* () { - // Expiry is cancellation, so the proof is what cancelling the command does: - // 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. + // Cancellation while a turn is in flight. 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(() => - runPlan(command(dir, [REQUEST], "out.md"), harness.deps), + runPlan(planning(dir, join(dir, "release.md")), harness.deps), ); yield* harness.fake.startedTurns(1); yield* running.halt(); @@ -964,55 +804,107 @@ describe( expect(harness.fake.cancels).toBeGreaterThanOrEqual(1); expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); - expect(yield* exists(join(dir, "out.md"))).toBe(false); + expect(yield* until(readdir(dir))).toEqual([]); }); + }); - // A teardown failure prevents the final admission, the output file and the run, - // whatever the command document selected. - 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* runPlan(command(dir, [REQUEST], "out.md"), harness.deps); + it("PS10: a named session continues the conversation and still starts no program", function* () { + // One ACPX store and one authorship root shared by two invocations is the + // only way to observe whether a named session is continued or placed a + // second time — and whether continuing one ever runs what it produced. + yield* useAuthorshipRoot(function* (authorshipRoot) { + const store = makeStore(); + const materializations: (string | undefined)[] = []; + + for (const invocation of [1, 2]) { + yield* useWorkingDirectory(function* (dir) { + const harness = createPlanHarness({ authorshipRoot, store }); + harness.fake.script({ reply: EFFECT_AND_FAILURE }); + harness.script({ decision: "Approve" }); + + const { value, chunks } = yield* delivered(() => + runPlan(planning(dir, undefined, "release-notes"), harness.deps), + ); + + expect(`${invocation}: ${value}`).toBe(`${invocation}: 0`); + expect(chunks.join("")).toBe(EFFECT_AND_FAILURE); + materializations.push(harness.fake.ensured[0]?.materialization); + // Neither invocation ran the program it produced: the file that + // program writes is nowhere, and the failure it ends in never + // happened. The retained Plan artifact is not an execution record. + expect(`${invocation}: ${(yield* until(readdir(dir))).join()}`).toBe(`${invocation}: `); + }); + } - expect(code).toBe(1); - expect(harness.executions).toHaveLength(0); - expect(yield* exists(join(dir, "out.md"))).toBe(false); + // The second invocation continued the record the first established + // rather than placing a second one. + expect([...store.records.keys()]).toHaveLength(1); + expect(materializations).toEqual(["first-turn-acceptance", undefined]); }); - // 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, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - // deno-lint-ignore require-yield - harness.deps.execute = function* () { - return Ok(undefined); - }; - harness.fake.script({ reply: PLAIN }); - harness.script({ decision: "Approve" }); + // Structurally, too: this command has no execution capability to reach. + // A branch left unselected would still be a branch, and these are the + // names it would have had. + expect("execute" in createPlanHarness({ authorshipRoot: "/nowhere" }).deps).toBe(false); + expect("planExecutor" in cliModule).toBe(false); + expect("PlanExecutionConfig" in cliModule).toBe(false); + }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); - expect(code).toBe(0); - expect(harness.fake.turns[0]?.timeoutMs).toBe(undefined); - }); + it("PS12: the shipped documentation states what each command does", function* () { + // The defect this catches is prose, and prose is what a person reads + // before they type anything: a page still promising `xmd plan --run` + // describes a command that would refuse them. + const root = fileURLToPath(new URL("../../../", import.meta.url)); + const pages = [ + "README.md", + "architecture.md", + "specs/plan-command-spec.md", + "specs/executable-mdx-spec.md", + "specs/root-document-props-spec.md", + "specs/acp-client-spec.md", + "site/routes/index.tsx", + ]; + + for (const page of pages) { + let text = yield* readTextFile(join(root, page)); + if (page === "specs/plan-command-spec.md") { + // Spelling out what the command removed is that one section's whole + // job, so it is the one place the switch is written plainly. + const start = text.indexOf("### What the command removed"); + expect(start).toBeGreaterThan(0); + text = text.slice(0, start) + text.slice(text.indexOf("\n### ", start + 1)); + } + // Everywhere else a page may name the switch only to say it is gone: a + // line that names it and says neither is a page still offering it. + const offered = text + .split("\n") + .filter((line) => /--run\b/.test(line) && !/remov|refus/.test(line)) + .map((line) => line.slice(0, 120)); + expect(`${page}: ${offered.join(" | ")}`).toBe(`${page}: `); + } + + // And the relationship is stated where somebody arrives: the README, the + // homepage, the architecture glossary and the command's own contract. + for (const [page, phrase] of [ + ["README.md", "Composition decides whether and when a planned program runs."], + ["architecture.md", "composition decides whether and when a planned program runs"], + ["specs/plan-command-spec.md", "Run executes a program from the host/CLI."], + ["site/routes/index.tsx", "Composition decides whether and when a planned program runs."], + ] as const) { + const text = yield* readTextFile(join(root, page)); + expect(`${page}: ${text.includes(phrase)}`).toBe(`${page}: true`); + } }); - 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 + it("PS9: --timeout bounds the whole invocation, and it is the only deadline", function* () { + // Read exactly as a run reads it: 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(["plan", REQUEST, `${flag}=abc`]).join(); - expect(code).toBe(1); - expect(stderr).toContain(flag); - } + const malformed = yield* runCli(["plan", REQUEST, "--timeout=abc"]).join(); + expect(malformed.code).toBe(1); + expect(malformed.stderr).toContain("--timeout"); - // 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. + // And it bounds the command rather than only a document: the deadline + // expires while the catalog is still being built. 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/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index e1004f14b..13bac3d80 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -61,7 +61,7 @@ const CANDIDATE = [ "", ].join("\n"); -/** What the command document asked the validator about, in order. */ +/** What the command document asked the structural check about, in order. */ interface CommandRun { validated: string[]; reviews: ElicitationRequest[]; @@ -157,8 +157,11 @@ describe("the packaged plan command document", () => { expect(run.prompts).toHaveLength(1); expect(run.reviews).toHaveLength(1); - // The checker saw the Agent's complete close value, once, unaltered. - expect(run.validated).toEqual([CANDIDATE]); + // Both gates inside the Component saw the Agent's complete close value, + // unaltered: the draft check while the conversation was still standing, and + // the admission after the whole authorship frame had gone. They are the same + // question asked twice, of the same exact bytes. + expect(run.validated).toEqual([CANDIDATE, CANDIDATE]); // The document settled with a value rather than an authored failure. Before // the control-flow correction this was the ten-draft exhaustion message: a diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 596ac3c27..1cac40047 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -43,7 +43,12 @@ import { useWorkingDirectory, } from "./support/plan-harness.ts"; import type { PlanDeclarationHarness } from "./support/plan-harness.ts"; -import { PLAN_ORIGIN, planComponentDescription } from "../src/plan-component.ts"; +import { + PLAN_ORIGIN, + planComponentDescription, + structuralValidation, +} from "../src/plan-component.ts"; +import type { StructuralValidation } from "../src/plan-component.ts"; import { syntaxCatalog } from "../src/syntax.ts"; const ROOT = "document.md"; @@ -94,7 +99,7 @@ function* runDocument(options: { permissionMode: "deny-all"; adapters: typeof ADAPTERS; } | null; - assess?: (source: string) => Operation<{ valid: boolean; diagnostics: Json }>; + validate?: StructuralValidation; session?: string; props?: Record; /** @@ -118,7 +123,7 @@ function* runDocument(options: { authorshipRoot: root, ...(options.includes === undefined ? {} : { includes: options.includes }), ...(options.stack === undefined ? {} : { stack: options.stack }), - ...(options.assess === undefined ? {} : { assess: options.assess }), + ...(options.validate === undefined ? {} : { validate: options.validate }), })); if (options.reply !== undefined) { harness.fake.script({ reply: options.reply }); @@ -480,16 +485,28 @@ describe("Tier PC — in an ordinary document", () => { it("PC12: a Plan that is not structurally a program binds nothing", function* () { yield* useWorkingDirectory(function* () { + // The draft check and the admission are one question asked twice, of a + // tree that may have moved between them: the draft resolved everything it + // names while the conversation was standing, and by the time the + // authorship frame had gone it did not. Only the second answer decides + // what may be returned. + const canonical = structuralValidation([], [yield* planComponentDescription()]); + let answered = 0; const run = yield* runDocument({ - // Approved by the check this case scripts, and refused by the admission - // that follows teardown: the two are different questions, and only the - // second decides what may be returned. source: ['Write a program.', "", "got: {approved}", ""].join( "\n", ), reply: "\n", + *validate(candidate) { + answered += 1; + return answered === 1 + ? { version: 1, outcome: "valid", diagnostics: [], invocations: [] } + : yield* canonical(candidate); + }, }); + // Two answers, and the second is the one that refused. + expect(answered).toBe(2); expect(run.failure).toContain("the approved Plan does not validate"); expect(run.failure).toContain("component-unresolved"); expect(run.output).not.toContain("got:"); diff --git a/packages/cli/tests/plan-host-acts.test.ts b/packages/cli/tests/plan-host-acts.test.ts index 669ae2a37..314be03f2 100644 --- a/packages/cli/tests/plan-host-acts.test.ts +++ b/packages/cli/tests/plan-host-acts.test.ts @@ -26,8 +26,7 @@ import type { Operation } from "effection"; import { runPlan } from "../src/plan.ts"; import type { PlanCommand } from "../src/plan.ts"; -import { scanPlanArgs } from "../src/plan-args.ts"; -import type { AgentStack } from "../src/agent-stack.ts"; +import type { AuthorshipStack } from "../src/agent-stack.ts"; import { ADAPTERS, AGENT, createPlanHarness, useWorkingDirectory } from "./support/plan-harness.ts"; import type { PlanHarness } from "./support/plan-harness.ts"; @@ -36,16 +35,14 @@ const REQUEST = "write a greeting"; /** A Plan the host's validator accepts. */ const PLAN = ['the draft ran', ""].join("\n"); -const STACK: AgentStack = { +const STACK: AuthorshipStack = { provider: "acpx", defaultAgent: AGENT, - permissionMode: "deny-all", adapters: ADAPTERS, }; function writing(dir: string, output: string): PlanCommand { - const argv = ["plan", REQUEST]; - return { argv, scan: scanPlanArgs(argv), include: [dir], output, run: false, stack: STACK }; + return { request: REQUEST, include: [dir], output, stack: STACK }; } /** Every command this invocation reached, answered rather than spawned. */ diff --git a/packages/cli/tests/plan.test.ts b/packages/cli/tests/plan.test.ts index c1b2ceade..c9a7cc969 100644 --- a/packages/cli/tests/plan.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -9,13 +9,14 @@ * behaviour rather than of a TypeScript loop standing in for it. * * The seams are deterministic. The ACPX runtime is the scriptable fake, the - * review provider is a scripted `Elicitation` handler, the executor records what - * it was handed, and the contextual working directory is a temporary one. - * Nothing here starts an agent, opens a browser or reaches a network. + * review provider is a scripted `Elicitation` handler, and the contextual + * working directory is a temporary one. Nothing here starts an agent, opens a + * browser or reaches a network — and nothing runs the Plan, because the command + * has no way to. * * Every refusal is proven by the phases that stayed at zero — turns not sent, - * reviews not asked, executions not handed anything — rather than by output - * nobody produced. + * reviews not asked, directories not placed — rather than by output nobody + * produced. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; @@ -35,8 +36,7 @@ import { authorshipDirectoryFor, AUTHORSHIP_INSTRUCTIONS, } from "../src/authorship-profile.ts"; -import { scanPlanArgs } from "../src/plan-args.ts"; -import type { AgentStack } from "../src/agent-stack.ts"; +import type { AuthorshipStack } from "../src/agent-stack.ts"; import { ADAPTERS, AGENT, @@ -45,6 +45,7 @@ import { useWorkingDirectory, } from "./support/plan-harness.ts"; import { makeStore } from "./support/fake-acp.ts"; +import { planComponentDescription, structuralValidation } from "../src/plan-component.ts"; import type { PlanHarness } from "./support/plan-harness.ts"; const REQUEST = "write a greeting"; @@ -128,24 +129,26 @@ 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"); -/** The Agent configuration a dispatch settles once and hands to both consumers. */ -const STACK: AgentStack = { +/** + * Who writes the Plan, as a dispatch settles it. + * + * There is no permission mode in it. This command starts no program, so there + * is nothing for one to configure, and the ceiling authorship runs under is the + * host's rather than the command line's. + */ +const STACK: AuthorshipStack = { provider: "acpx", defaultAgent: AGENT, - permissionMode: "deny-all", adapters: ADAPTERS, }; -/** - * One invocation, asking for the approved Plan to be run. - * - * `--run` is the default here because these cases are about what reaches the - * 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): PlanCommand { - const argv = ["plan", ...args, "--run"]; - return { argv, scan: scanPlanArgs(argv), include: [dir], run: true, stack }; +/** One invocation, writing its approved source to stdout. */ +function command( + dir: string, + request: string = REQUEST, + stack: AuthorshipStack = STACK, +): PlanCommand { + return { request, include: [dir], stack }; } /** @@ -273,7 +276,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); // Exactly one catalog, built with the invocation's own includes. @@ -324,7 +327,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); const [initial, repair, revision] = harness.fake.prompts; @@ -368,7 +371,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); // A draft, three repairs, a revision: five turns, one session. expect(harness.fake.prompts).toHaveLength(5); @@ -402,10 +405,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan( - { ...command(dir, [REQUEST]), session: "ada" }, - harness.deps, - ); + const code = yield* runPlan({ ...command(dir), session: "ada" }, harness.deps); expect(code).toBe(0); named.push(sessions(harness)[0]); namedDirectories.push(String(harness.fake.created[0]?.cwd)); @@ -446,7 +446,7 @@ describe( seen.entries = yield* until(readdir(workdir)); }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); expect(seen.entries).toEqual([]); @@ -455,8 +455,8 @@ describe( // it lived under is still there for the next one. expect(yield* exists(String(seen.workdir))).toBe(false); expect(yield* exists(authorshipRoot)).toBe(true); - // The approved Plan still ran: cleanup is not a failure. - expect(harness.executions).toHaveLength(1); + // The approved Plan was still produced: cleanup is not a failure. + expect(code).toBe(0); }); // Stopping, a turn that failed, and a cancelled command each hand the @@ -477,10 +477,9 @@ describe( } ending.drive(harness); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(1); - expect(harness.executions).toHaveLength(0); // That exact leaf, and the root it lived under, both answer the // question: the directory this ending made is gone, and nothing else // was made in its place. @@ -496,7 +495,7 @@ describe( harness.fake.script({ reply: VALID, manual: true }); yield* scoped(function* () { - const running = yield* spawn(() => runPlan(command(dir, [REQUEST]), harness.deps)); + const running = yield* spawn(() => runPlan(command(dir), harness.deps)); yield* harness.fake.startedTurns(1); yield* running.halt(); }); @@ -516,15 +515,12 @@ describe( throw new Error("this host could not install a review provider"); }; - const { value, lines } = yield* reported(() => - runPlan(command(dir, [REQUEST]), harness.deps), - ); + const { value, lines } = yield* reported(() => runPlan(command(dir), harness.deps)); expect(value).toBe(1); expect(lines.join("\n")).toContain("could not install a review provider"); // 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(authorshipRoot))).toEqual([]); }); @@ -539,9 +535,7 @@ describe( const harness = createPlanHarness({ authorshipRoot: blocked }); harness.fake.script({ reply: VALID }); - const { value, lines } = yield* reported(() => - runPlan(command(dir, [REQUEST]), harness.deps), - ); + const { value, lines } = yield* reported(() => runPlan(command(dir), harness.deps)); expect(value).toBe(1); expect(lines).toHaveLength(1); @@ -549,7 +543,6 @@ describe( // What was in the way is untouched, and no phase after it began. expect(yield* readTextFile(blocked)).toBe("in the way\n"); expect(harness.fake.created).toHaveLength(0); - expect(harness.executions).toHaveLength(0); }); // A leaf that disappears under a live conversation is interference, not a @@ -563,26 +556,25 @@ describe( }); const { value, lines } = yield* reported(() => - runPlan({ ...command(dir, [REQUEST]), output: "out.md" }, harness.deps), + runPlan({ ...command(dir), output: "out.md" }, harness.deps), ); expect(value).toBe(1); expect(lines).toHaveLength(1); expect(lines[0]).toContain("was made for this conversation and is already gone"); expect(lines[0]).not.toContain("does not validate"); - // The Plan was approved and still reached nothing: no admission that - // could have delivered it, no output file, no execution and so no journal. + // The Plan was approved and still reached nothing: no host gate that + // could have delivered it, and no output file. expect(harness.reviews).toHaveLength(1); expect(yield* exists(join(dir, "out.md"))).toBe(false); expect(yield* until(readdir(dir))).toEqual([]); - expect(harness.executions).toHaveLength(0); }); // The other outcome of the one attempt. A directory this invocation was // given empty and did not leave empty is preserved and the command fails: // 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. + // observed is a Plan that would otherwise have been delivered. yield* useWorkingDirectory(function* (dir, authorshipRoot) { const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID }); @@ -595,7 +587,7 @@ describe( }); const { value, lines } = yield* reported(() => - runPlan({ ...command(dir, [REQUEST]), output: "out.md" }, harness.deps), + runPlan({ ...command(dir), output: "out.md" }, harness.deps), ); // Terminal, and said once: the attempt happens once and either settles @@ -615,11 +607,10 @@ describe( expect(yield* exists(String(workdir))).toBe(true); expect(yield* readTextFile(String(planted))).toBe("not this command's doing\n"); - // And nothing after the failure began: no output file, and no execution — so no - // journal, which only an execution creates. + // And nothing after the failure began: no output file, and nothing else + // written beside it. expect(yield* exists(join(dir, "out.md"))).toBe(false); expect(yield* until(readdir(dir))).toEqual([]); - expect(harness.executions).toHaveLength(0); }); }); @@ -649,7 +640,7 @@ describe( console.error = () => { events.push("reported"); }; - return yield* runPlan(command(dir, [REQUEST]), harness.deps); + return yield* runPlan(command(dir), harness.deps); }); expect(code).toBe(1); @@ -658,7 +649,6 @@ describe( // decision. expect(events).toEqual(["reported"]); expect(yield* exists(String(workdir))).toBe(false); - expect(harness.executions).toHaveLength(0); }); }); @@ -698,7 +688,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: ending === "approved" ? "Approve" : "Stop" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(ending === "approved" ? 0 : 1); workdirs.push(String(harness.fake.created[0]?.cwd)); }); @@ -756,10 +746,7 @@ describe( ); }; - const code = yield* runPlan( - { ...command(dir, [REQUEST]), session: "ceiling" }, - harness.deps, - ); + const code = yield* runPlan({ ...command(dir), session: "ceiling" }, harness.deps); expect(code).toBe(0); // Not the caller's working directory: this session's, and empty while @@ -791,7 +778,7 @@ describe( harness.fake.script({ reply: VALID }); const { value, lines } = yield* reported(() => - runPlan({ ...command(dir, [REQUEST]), session: "occupied" }, harness.deps), + runPlan({ ...command(dir), session: "occupied" }, harness.deps), ); expect(value).toBe(1); @@ -804,30 +791,27 @@ describe( expect(harness.fake.started).toBe(false); expect(harness.fake.prompts).toHaveLength(0); expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); // The contents were left exactly as they were found. expect(yield* readTextFile(join(occupied, "someone-elses.txt"))).toBe( "not mine to delete\n", ); }); - // `--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. + // A native permission request while the Plan is being written is denied + // privately, and the turn it belongs to fails. There is no command line + // that could have widened it: what authorship is settled from carries a + // provider and a default agent and no permission mode at all. + expect("permissionMode" in STACK).toBe(false); yield* useWorkingDirectory(function* (dir, authorshipRoot) { const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: VALID, requestsTool: "Bash" }); - const code = yield* runPlan( - command(dir, [REQUEST], { ...STACK, permissionMode: "approve-all" }), - harness.deps, - ); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(1); expect(harness.fake.decisions).toEqual(["reject_once"]); - // The denial ended the command: nobody was asked and nothing ran. + // The denial ended the command: nobody was asked. expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); }); }); @@ -847,7 +831,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), 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"); @@ -864,18 +848,17 @@ describe( harness.fake.script({ reply: WRITES_A_FILE }); harness.script({ decision: "Stop" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(1); // Validated, presented, and never run: the document's own write is the // effect that would have happened if anything had executed it. expect(harness.reviews).toHaveLength(1); expect((yield* until(readdir(dir))).includes("drafted.txt")).toBe(false); - expect(harness.executions).toHaveLength(0); }); }); - it("C7: candidate defects earn a repair turn; caller defects escape", function* () { + it("C7: a candidate defect earns a repair turn", function* () { // A defect the agent authored: the root's own frontmatter. yield* useWorkingDirectory(function* (dir, authorshipRoot) { const harness = createPlanHarness({ authorshipRoot }); @@ -883,7 +866,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); expect(harness.fake.prompts).toHaveLength(2); @@ -901,87 +884,37 @@ describe( expect(harness.fake.prompts[1]).toContain("source-invalid"); // The whole versioned value, as data the program serialized. expect(harness.fake.prompts[1]).toContain('"version": 1'); - expect(harness.executions).toHaveLength(1); }); - // A defect the agent authored: two properties generating one option. + // A defect the agent authored: a component this profile does not offer. yield* useWorkingDirectory(function* (dir, authorshipRoot) { const harness = createPlanHarness({ authorshipRoot }); - harness.fake.script({ reply: COLLIDING }); + harness.fake.script({ reply: UNRESOLVED }); harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); expect(harness.fake.prompts).toHaveLength(2); - expect(harness.fake.prompts[1]).toContain("generated-binding-collision"); - expect(harness.fake.prompts[1]).toContain("--props-first-name"); - // Carried as this command's own finding, not as a core diagnostic code. - expect(harness.fake.prompts[1]).not.toContain("DocumentValidationCode"); - }); - - // 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, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - harness.fake.script({ reply: VALID }); - - const code = yield* runPlan( - command(dir, [REQUEST, "--props-nothing", "here"]), - harness.deps, - ); - - expect(code).toBe(1); - // One turn, no repair: the agent cannot fix a command line. - expect(harness.fake.prompts).toHaveLength(1); - expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); - }); - - // A defect the caller wrote: aggregate JSON that is not JSON. - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - harness.fake.script({ reply: VALID }); - - const code = yield* runPlan(command(dir, [REQUEST, "--props", "{oops"]), harness.deps); - - expect(code).toBe(1); - expect(harness.fake.prompts).toHaveLength(1); - expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); + expect(harness.fake.prompts[1]).toContain("component-unresolved"); + expect(harness.fake.prompts[1]).toContain("NoSuchComponent"); }); - // A defect the caller wrote: a value this candidate's schema rejects. - yield* useWorkingDirectory(function* (dir, authorshipRoot) { - const harness = createPlanHarness({ authorshipRoot }); - harness.fake.script({ reply: NAME_IS_BOOLEAN }); - - const code = yield* runPlan( - command(dir, [REQUEST, "--props-name=not-a-boolean"]), - harness.deps, - ); - - expect(code).toBe(1); - expect(harness.fake.prompts).toHaveLength(1); - expect(harness.reviews).toHaveLength(0); - expect(harness.executions).toHaveLength(0); - }); - - // A revision that changes what the command line means is the caller's - // failure too, and it is caught before the candidate is presented. + // What is *not* a defect: a root that declares required properties. The + // check is structural, because the values belong to whoever runs the + // program later, and this command has no source to resolve them from. A + // full root-props validation would send this back for repair. 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 }); + harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST, "--props-name", "Ada"]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); - expect(code).toBe(1); + expect(code).toBe(0); + expect(harness.fake.prompts).toHaveLength(1); expect(harness.reviews).toHaveLength(1); - expect(harness.executions).toHaveLength(0); - expect(harness.fake.prompts).toHaveLength(2); }); }); @@ -995,7 +928,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); expect(harness.fake.prompts).toHaveLength(4); @@ -1012,7 +945,7 @@ describe( } harness.script({ decision: "Stop" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(1); expect(harness.fake.prompts).toHaveLength(4); @@ -1028,7 +961,6 @@ describe( expect( shown.validation?.diagnostics?.some((entry) => entry.code === "component-unresolved"), ).toBe(true); - expect(harness.executions).toHaveLength(0); }); // Ten presentations: nine revisions, and a tenth round with nothing left @@ -1043,7 +975,7 @@ describe( } harness.script({ decision: "Stop" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(1); expect(harness.reviews).toHaveLength(10); @@ -1079,7 +1011,7 @@ describe( harness.fake.script({ reply: fenced }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); const message = harness.reviews[0].message; @@ -1096,15 +1028,10 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Stop" }); - const { value, lines } = yield* reported(() => - runPlan(command(dir, [REQUEST]), harness.deps), - ); + const { value, lines } = yield* reported(() => runPlan(command(dir), harness.deps)); expect(value).toBe(1); - expect(lines.join("\n")).toContain( - "xmd plan stopped at your request. Nothing was output or run.", - ); - expect(harness.executions).toHaveLength(0); + expect(lines.join("\n")).toContain("xmd plan stopped at your request. Nothing was output."); }); // Exhaustion is reachable, and it is a different ending: ten drafts that @@ -1126,9 +1053,7 @@ describe( reply: "The drafts all named a component this profile does not have.", }); - const { value, lines } = yield* reported(() => - runPlan(command(dir, [REQUEST], STACK), harness.deps), - ); + const { value, lines } = yield* reported(() => runPlan(command(dir), harness.deps)); expect(value).toBe(1); // Nine reviews, not ten: the tenth invalid draft is never presented, @@ -1136,11 +1061,15 @@ describe( expect(harness.reviews).toHaveLength(9); expect(decisions(harness.reviews[8])).toEqual(["Request changes", "Stop"]); expect(lines).toHaveLength(1); - expect(lines[0]).toContain("reviewed ten drafts without an approved Plan"); - expect(lines[0]).toContain("Nothing was output or run."); + expect(lines[0]).toContain( + "xmd plan could not generate an approved Plan after 10 attempts.", + ); + expect(lines[0]).toContain( + "The coding agent explained why planning was unsuccessful and how to improve the outcome:", + ); + expect(lines[0]).toContain("Nothing was output."); // Forty drafting turns and exactly one explanation turn after them. expect(harness.fake.prompts).toHaveLength(41); - expect(harness.executions).toHaveLength(0); }); }); @@ -1167,9 +1096,7 @@ describe( ].join("\n"); harness.fake.script({ reply: explanation }); - const { value, lines } = yield* reported(() => - runPlan(command(dir, [REQUEST]), harness.deps), - ); + const { value, lines } = yield* reported(() => runPlan(command(dir), harness.deps)); expect(value).toBe(1); // Exactly one turn more than the forty drafting turns, in the same @@ -1197,31 +1124,30 @@ describe( // What it said is reported as the coding agent's words, and the command // ends. Nothing about the explanation is treated as a Plan. expect(lines).toHaveLength(1); - expect(lines[0]).toContain("reviewed ten drafts without an approved Plan"); + expect(lines[0]).toContain( + "xmd plan could not generate an approved Plan after 10 attempts.", + ); expect(lines[0]).toContain(explanation); - expect(lines[0]).toContain("Nothing was output or run."); - expect(harness.executions).toHaveLength(0); + expect(lines[0]).toContain("Nothing was output."); }); }); - 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. + it("C10, C11: the approved bytes are what the command delivers", function* () { + // The bytes that leave the command are the approved candidate's, not an + // earlier draft's: a revision replaces the whole document, and what a + // caller reads back is the replacement. yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const out = join(dir, "release.md"); 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* runPlan(command(dir, [REQUEST, "--props-count", "7"]), harness.deps); + const code = yield* runPlan({ ...command(dir), output: out }, 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(yield* readTextFile(out)).toBe(counting("string")); }); // Nothing is stripped. A reply wrapped in a fence is not a document, so it @@ -1234,36 +1160,54 @@ describe( } harness.script({ decision: "Stop" }); - yield* runPlan(command(dir, [REQUEST]), harness.deps); + yield* runPlan(command(dir), harness.deps); expect(harness.reviews[0].message).toContain(wrapped); }); }); - it("C10: final admission vetoes after the profile has torn down", function* () { + it("C10: the host's own gate refuses after the command document has settled", function* () { 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. + // A repository component the draft uses. It is there while the Component + // decides — twice, at the draft check and at the admission — and gone by + // the time the command asks the same question for itself. const widget = join(dir, "Widget.md"); yield* writeTextFile(widget, "A widget.\n"); const draft = ["# Uses a widget", "", "", ""].join("\n"); + const out = join(dir, "release.md"); const harness = createPlanHarness({ authorshipRoot }); harness.fake.script({ reply: draft }); + harness.script({ decision: "Approve" }); + + // The one structural check this invocation has — the same production + // answer the command builds, wrapped so the case can see each time it is + // asked and act between the last one inside the Component and the one + // after. Three answers: the draft check, ``, and the host. + const canonical = structuralValidation([dir], [yield* planComponentDescription()]); const events: string[] = []; - harness.deps.installElicitation = function* () { - // Registered inside the profile's scope, so it runs while that scope - // is being torn down — after the provider is gone and before the host - // looks at what was approved. - yield* ensure(function* () { - events.push("teardown"); + const outcomes: string[] = []; + harness.deps.validate = function* (candidate) { + const validation = yield* canonical(candidate); + outcomes.push(validation.outcome); + events.push(["draft check", "admission", "host gate"][outcomes.length - 1]); + if (outcomes.length === 2) { + // Immediately after a real admission that succeeded. The approved + // bytes never change; the tree they resolve against does. + expect(validation.outcome).toBe("valid"); yield* rm(widget, { force: true }); + } + return validation; + }; + harness.deps.installElicitation = function* () { + yield* ensure(() => { + events.push("authorship teardown"); }); yield* Elicitation.around( { // deno-lint-ignore require-yield *elicit([request], _next) { harness.reviews.push(request); + events.push("review"); return { decision: "Approve" }; }, }, @@ -1271,46 +1215,45 @@ describe( ); }; - const written = console.error; - const lines: string[] = []; - const code = yield* scoped(function* (): Operation { - yield* ensure(() => { - console.error = written; - }); - console.error = (...parts: unknown[]) => { - events.push("reported"); - lines.push(parts.map((part) => String(part)).join(" ")); - }; - return yield* runPlan(command(dir, [REQUEST]), harness.deps); + const { value, lines } = yield* reported(function* () { + const code = yield* runPlan({ ...command(dir), output: out }, harness.deps); + events.push("reported"); + return code; }); - // The draft was sound enough to approve, and the approved bytes are - // unchanged — only the tree they resolve against moved. + // The draft was sound, the person approved it, and the admission that + // followed the whole authorship frame coming down was sound too. expect(harness.reviews).toHaveLength(1); expect(harness.reviews[0].message).toContain(""); + expect(outcomes).toEqual(["valid", "valid", "invalid"]); + + // The order is the claim: the Component decided while the component file + // was there, the frame came down, and only then did the host ask again — + // about a tree that had moved. + expect(events).toEqual([ + "draft check", + "review", + "authorship teardown", + "admission", + "host gate", + "reported", + ]); - // The gate is effective on its own: the same bytes now fail, because they - // are structurally admitted again rather than being trusted for what the - // Component concluded while its own scope was still standing. The veto is - // the Component's own ``, which is the first of the two gates to - // see these bytes after teardown; the command's property-aware gate - // still stands behind it and never gets to run. - expect(code).toBe(1); - expect(lines.join("\n")).toContain("the approved Plan does not validate"); + // So the refusal is the command's own, after everything the conversation + // built has gone. Deleting that gate leaves the third answer unasked and + // this invocation succeeding with a file on disk. + expect(value).toBe(1); + expect(lines.join("\n")).toContain("the approved document does not validate"); expect(lines.join("\n")).toContain("component-unresolved"); expect(lines.join("\n")).toContain("Widget"); - // And it is ordered: teardown finished first, which is the only reason - // the component was missing when the second validation ran. - expect(events).toEqual(["teardown", "reported"]); - - // Nothing after the veto happened. - expect(harness.executions).toHaveLength(0); + // And nothing after it happened: no artifact, and no effect from a + // program this command never starts. expect((yield* until(readdir(dir))).sort()).toEqual([]); }); }); - it("C14: an interleaved Plan survives approval and execution byte for byte", function* () { + it("C14: an interleaved Plan survives approval and delivery byte for byte", function* () { 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 @@ -1335,8 +1278,9 @@ describe( harness.fake.script({ reply: plan }); harness.script({ decision: "Approve" }); + const out = join(dir, "release.md"); const code = yield* runPlan( - command(dir, ["ask me for my age and write it to a file"]), + { ...command(dir, "ask me for my age and write it to a file"), output: out }, harness.deps, ); @@ -1344,9 +1288,9 @@ describe( // The prose reached the person who approved it, inside the presentation // fence and unaltered. expect(harness.reviews[0].message).toContain(plan); - // And the exact bytes are what runs: nothing trimmed, re-fenced or - // reflowed between approval and execution. - expect(harness.executions[0]?.root.source).toBe(plan); + // And the exact bytes are what the command delivered: nothing trimmed, + // re-fenced or reflowed between approval and the artifact. + expect(yield* readTextFile(out)).toBe(plan); }); }); @@ -1362,7 +1306,7 @@ describe( harness.fake.script({ reply: VALID }); harness.script({ decision: "Approve" }); - const code = yield* runPlan(command(dir, [REQUEST]), harness.deps); + const code = yield* runPlan(command(dir), harness.deps); expect(code).toBe(0); const [exhausted, approvable] = harness.reviews.map((review) => review.message); @@ -1405,9 +1349,7 @@ describe( } harness.script({ decision: "Stop" }); - const { value, lines } = yield* reported(() => - runPlan(command(dir, [REQUEST]), harness.deps), - ); + const { value, lines } = yield* reported(() => runPlan(command(dir), harness.deps)); expect(value).toBe(1); // One line, from the document's own , and no accumulated diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index 4bba55517..778db442c 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -13,7 +13,7 @@ */ import { Elicitation } from "@executablemd/core"; -import type { ElicitationRequest, SyntaxCatalog } from "@executablemd/core"; +import type { DocumentValidation, ElicitationRequest, SyntaxCatalog } from "@executablemd/core"; import { Ok } from "effection"; import type { Operation, Result } from "effection"; import { ensure, scoped, useScope } from "effection"; @@ -26,18 +26,23 @@ import { createEmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import { syntaxCatalog } from "../../src/syntax.ts"; import { planComponentDeclaration } from "../../src/plan-component.ts"; -import type { PlanSurface } from "../../src/plan-component.ts"; -import type { CandidateAssessment } from "../../src/authorship-profile.ts"; +import type { PlanSurface, StructuralValidation } from "../../src/plan-component.ts"; import { planAgentContext } from "../../src/authorship-profile.ts"; -import type { AgentStack } from "../../src/agent-stack.ts"; +import type { AuthorshipStack } from "../../src/agent-stack.ts"; import type { DeclaredMarkdownComponent } from "@executablemd/core/host"; -import type { PlanDependencies, PlanExecution } from "../../src/plan.ts"; +import type { PlanDependencies } from "../../src/plan.ts"; import { createFakeAcp, makeRegistry, makeStore } from "./fake-acp.ts"; import type { FakeAcp, FakeStore } from "./fake-acp.ts"; /** The agent every plan case drives, and the command it resolves to. */ export const AGENT = "scripted-agent"; +/** The answer a case that is not about validation wants: this is a program. */ +// deno-lint-ignore require-yield +const SOUND: StructuralValidation = function* (): Operation { + return { version: 1, outcome: "valid", diagnostics: [], invocations: [] }; +}; + /** * The embedded adapters a case's Agent stack carries. * @@ -69,10 +74,6 @@ export interface PlanHarness { catalogCalls: string[][]; /** Every review request a provider was asked, in order. */ reviews: ElicitationRequest[]; - /** Every approved execution that reached the executor. */ - 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 `runPlan` is driven with. */ @@ -103,16 +104,12 @@ export function createPlanHarness(options: { const fake = createFakeAcp(); const catalogCalls: string[][] = []; const reviews: ElicitationRequest[] = []; - const executions: PlanExecution[] = []; - const executionResults: Result[] = []; const answers: ScriptedReview[] = []; const harness: PlanHarness = { fake, catalogCalls, reviews, - executions, - executionResults, script(review) { answers.push(review); }, @@ -149,11 +146,6 @@ export function createPlanHarness(options: { { at: "min" }, ); }, - // deno-lint-ignore require-yield - *execute(approved) { - executions.push(approved); - return executionResults.shift() ?? Ok(undefined); - }, }, }; return harness; @@ -279,13 +271,18 @@ export function* planDeclarationHarness(options: { includes?: readonly string[]; /** The catalog the first turn is built from. */ syntax?: string; - /** What the host says about each draft. The default finds every draft sound. */ - assess?: (source: string) => Operation; + /** + * How this case answers the one structural question the Component asks. + * + * The default finds every candidate sound, so what a case reading `checked` + * observes is the Component's control flow rather than validation's answers. + */ + validate?: StructuralValidation; /** The logical name the command surface fixes. */ session?: string; explicitSession?: boolean; /** Absent leaves the harness with no stack at all, as `xmd test` has none. */ - stack?: AgentStack | null; + stack?: AuthorshipStack | null; store?: FakeStore; }): Operation { const fake = createFakeAcp(); @@ -305,7 +302,6 @@ export function* planDeclarationHarness(options: { : (options.stack ?? { provider: "acpx", defaultAgent: AGENT, - permissionMode: "deny-all", adapters: ADAPTERS, }), { @@ -344,23 +340,14 @@ export function* planDeclarationHarness(options: { *catalog() { return options.syntax ?? "## Built-in components\n\n### ``\n"; }, - ...(options.assess === undefined - ? { - // The deterministic seam standing where production's answer goes. It - // records what it was asked about and says yes, so what a case reading - // it observes is the Component's control flow rather than validation's - // answers. - assess: function* (source: string): Operation { - checked.push(source); - return { valid: true, diagnostics: {} }; - }, - } - : { - assess: function* (source: string): Operation { - checked.push(source); - return yield* options.assess!(source); - }, - }), + // The deterministic seam standing where production's answer goes, recording + // every candidate it was asked about — the draft check's and the + // admission's alike, which is every time these bytes are decided on. + // deno-lint-ignore require-yield + *validate(candidate: string): Operation { + checked.push(candidate); + return yield* (options.validate ?? SOUND)(candidate); + }, }); return { diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 1809b48d4..54e83178b 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -223,6 +223,25 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () for (const name of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { expect(entries.map((entry: { name?: string }) => entry?.name)).not.toContain(name); } + + // The command's public grammar travels with those bytes. `--run` is gone, + // and this directory has no agent to reach and no `DEFAULT_AGENT_NAME` that + // resolves here — so a build that still accepted the switch would fail on + // the agent instead of answering with the migration, which is what makes + // this a check on preflight rather than on the exit status. + const removed = yield* runEmittedBinIn(elsewhere, [ + "plan", + "prepare the release program", + "--run", + ]); + expect(removed.code).toBe(1); + expect(removed.stdout).toBe(""); + expect(removed.stderr).toContain( + "xmd plan --run was removed because xmd plan only produces approved source.", + ); + expect(removed.stderr).toContain('xmd plan "..." | xmd run -'); + expect(removed.stderr).toContain('xmd plan "..." --output release.md && xmd run release.md'); + expect(removed.stderr).not.toContain("unavailable"); // The same for the upgrade command's program. The npm build discovers the // directory rather than listing files, so this is the check that the // discovery really covered the second document too. diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index 7ba812717..ad2de081f 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -93,5 +93,24 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => for (const name of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { expect(names).not.toContain(name); } + + // The command surface those bytes belong to is source-only in this build + // too: help describes both explicit compositions and names no option that + // would run the approved program. + const helped = yield* timebox(TIMEOUT, function* () { + return yield* exec(BINARY, { arguments: ["plan", "--help"], cwd: elsewhere }).join(); + }); + if (helped.timeout) { + throw new Error("the compiled binary timed out describing xmd plan"); + } + expect(helped.value.code).toBe(0); + expect(helped.value.stdout).toContain("Planning never runs the approved program."); + expect(helped.value.stdout).toContain('xmd plan "Prepare the release program." | xmd run -'); + expect(helped.value.stdout).toContain( + 'xmd plan "Prepare the release program." --output release.md && xmd run release.md', + ); + for (const option of ["--run", "--props", "--journal", "--raw", "--verbose", "--deny-all"]) { + expect(helped.value.stdout).not.toContain(option); + } }); }); diff --git a/site/routes/index.tsx b/site/routes/index.tsx index d7887e72d..d1eefdce2 100644 --- a/site/routes/index.tsx +++ b/site/routes/index.tsx @@ -537,12 +537,18 @@ export default define.page(function Home({ url }) {

A Plan is text, so it composes.{" "} xmd plan{" "} - writes the approved program to standard output, and{" "} + writes the approved program to standard output and runs none of it. + {" "} xmd run -{" "} takes a whole program from standard input — so the two are one command when you want no file in between.

+

+ Plan produces a program. Run executes a program from the host or + CLI. Composition decides whether and when a planned program runs. +

+ diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index ee337d35b..f87857ba4 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -490,9 +490,9 @@ own session store and one empty working directory per session, both disposable. ## Command-line configuration -`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. +`xmd run` configures the agent stack, and `xmd plan` settles the part of it +authorship needs — the provider and the default agent. `xmd test` rejects those +options, driving agents through the deterministic test-agent stack instead. That division also applies to a nested run profile. The outer `xmd test` invocation supplies no live Agent configuration to a child and no contextual @@ -555,24 +555,18 @@ refuses that agent. ### The `xmd plan` authorship profile -`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. -The provider -name, the default agent with its `DEFAULT_AGENT_NAME` precedence, the permission -mode and the host's own machine-session assembly are settled before any catalog -is built or any document executes, so an unknown provider or an incompatible -pair of permission flags fails first. It is the settled value that reaches both -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 authorship profile takes the provider name, the default agent and the -adapters this build carries from that answer, and nothing else — so the -assistant that writes a Plan is launched from the same snapshot as the run of -the approved Plan. Its ceiling is the host's, assembled for that one document -and not readable from the command line: +`xmd plan` resolves that configuration once, for the one document it executes: +the plan command document that writes the Plan. It starts no program, so there +is no second consumer and no permission mode to settle — `--approve-all`, +`--approve-reads` and `--deny-all` are `xmd run`'s and are refused by name here. +What is settled is the provider name, the default agent with its +`DEFAULT_AGENT_NAME` precedence, the adapters this build carries and the host's +own machine-session assembly, all before any catalog is built or any document +executes, so an unknown provider fails first. `DEFAULT_AGENT_NAME` is read once +per invocation. + +The authorship profile takes exactly that answer. 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 | | --- | --- | @@ -639,12 +633,12 @@ the command places a name unique to the invocation, so each invocation is a fres conversation created under those instructions; `--session ` selects an existing one under ordinary continuation semantics. -That document's scope closes before the final admission and before whichever -result the caller asked for: the source on stdout, the `--output` file, the run, -or the file followed by the run. A teardown failure fails the command and no later phase happens. The -executed program is an ordinary `xmd run` document with its own root provider and -its own lifetime: it inherits neither the assistant session nor its instruction -layer. It exists only under `--run`. +That document's scope closes before the structural admission and before whichever +sink the caller asked for: the source on stdout, or the `--output` file. A +teardown failure fails the command and no later phase happens. A later `xmd run` +of the approved Plan is an ordinary invocation with its own root provider and its +own lifetime: it inherits neither the assistant session nor its instruction +layer, because nothing passes between the two commands but the source. ### Availability diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 55209b5c5..0f488f2ba 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -8622,9 +8622,8 @@ it, so inspection consults no configuration at all. A repository `Verbose.md` or `Verbose.ts` is chosen ahead of the default. The profiles are unchanged by where the answer comes from: `xmd run` has -``, an approved Plan executed by `xmd plan --run` has it, an -`` child has it, and a direct `xmd test` root does not — -even under `--verbose`. +``, an `` child has it, and a direct `xmd test` +root does not — even under `--verbose`. It opens no scope, acquires no resource, holds no authority and has no durable effect of its own, and contextual verbosity takes no part in durable identity, @@ -8665,7 +8664,7 @@ imported. There are two, and they differ in lifetime and authority rather than in what an author writes. The **ordinary provider** is what the Deno source entrypoint and the compiled -binary install for `xmd run` and for an approved `xmd plan --run`. Constructing +binary install for `xmd run`. Constructing it mints a fresh opaque invocation identity and empty state, both private to that one execution: @@ -8829,23 +8828,22 @@ a file: nothing of that name is read, created, or used as a base directory. 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 -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 -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 -operation, and creates its journal only when that execution starts — so the modes -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 -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 +approve it; the host then structurally validates the returned bytes again under +that identity. So the identity says where the bytes came from, and a source +position reads `(:5:1)`. + +The command starts no program from them. The approved source is the result: +by default the exact bytes go to stdout, and `--output` writes them to a file +instead. Whether a Plan runs is the caller's own composition — +`xmd plan … | xmd run -`, or an artifact a later `xmd run` names — and that +later run is an ordinary supplied-source or file execution with an identity, an +Agent provider and a journal of its own. + +The command document is the one root `xmd plan` executes, under its own 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 no invocation of the command +writes a journal at all. The command's complete contract is [`xmd plan`](./plan-command-spec.md). `xmd upgrade` executes the second packaged root under the stable internal @@ -9418,8 +9416,7 @@ credential, a subprocess or a request exists (§5.3, §8.1). Being *supplied* by the entrypoint and being *used* are different things, and the commands differ: -- an ordinary `xmd run`, and an approved `xmd plan --run`, install a fresh - provider instance for that execution; +- an ordinary `xmd run` installs a fresh provider instance for that execution; - `xmd test` installs none for its own root document, and hands the entrypoint's installer to a `host="run"` child — which is an ordinary run whatever command hosts it, and receives an instance of its own; @@ -10766,13 +10763,12 @@ One checked-in Markdown suite runs the real command against a fixture directory. ### Tier PR — The `xmd plan` command -One root execution on every invocation — the packaged plan command document, -an adapter that invokes the packaged `` Component, which owns the visible -generation, repair, review and failure workflow — 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 +One root execution on every invocation, and no second one: the packaged plan +command document, an adapter that invokes the packaged `` Component, +which owns the visible generation, repair, review and failure workflow. The +approved Plan is the result rather than something to run — stdout by default, or +an exclusively created `--output` file — and whether that program runs is the +caller's own composition with `xmd run`. Defined in [`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 @@ -10781,12 +10777,14 @@ 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; 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` | +| PS1–PS3 | Fixed grammar | One request preserved byte for byte and a second positional refused; every retained option accepted before and after it; every `--run` spelling answered with the migration, and every other removed option — both short aliases and the aggregate and generated property names included — answered with the one refusal that names `xmd run`, before any catalog, Agent, session, review or filesystem activity, and before `--help` can short-circuit the dispatch in either order; a name that merely begins like a property option keeps the generic unknown-option refusal; 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 | +| PS4/PS5 | Help | The complete `xmd plan --help` output and the program summary carry only the retained grammar and both explicit compositions, and no removed option appears in either; `xmd run --help` still exposes every option it configures | | C2–C3 | The packaged adapter and Component | The command executes the checked-in Markdown value root under ``, which invokes the packaged `` Component, and the turn text is that Component'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 | +| 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 command line reaches; a draft is data throughout, and no draft effect ever happens | +| C7–C9 | Classification, bounds and presentation | Draft defects return structured facts, and a root declaring required properties is not one of them; 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 `` | +| PS6–PS9 | Validation, delivery and endings | The invocation settles one structural check, and the host asks it again after the command document has completely torn down — a component removed immediately after a successful `` is refused there and nowhere else; the exact bytes then reach exactly one of stdout or an exclusively created `--output` file, and an existing path is refused unchanged; stopping, exhaustion, a failed turn, missing Agent context, cancellation, teardown failure and that host refusal each deliver nothing at all | +| PS10, C14 | No execution, and the result | A named session continues the planning conversation and still starts no program, and no execution callback, program journal or second-root identity exists; 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 and a file alike | +| PS12 | Product copy | Architecture, specifications, README and the homepage state that Plan produces source, Run executes source, and composition decides when it runs | ### Tier UG — The `xmd upgrade` command @@ -12094,9 +12092,8 @@ timed. ### Tier VB — `` (§6.20) The behavioral rows run the real `xmd run` command against a document on disk, -so what they observe is what a reader of that command sees. VB5 and VB6 are -TypeScript rows: one inspects the catalog without running anything, and one -drives the `xmd plan` command. +so what they observe is what a reader of that command sees. VB5 is a TypeScript +row: it inspects the catalog without running anything. | Criterion | Evidence | | --- | --- | @@ -12105,7 +12102,6 @@ drives the `xmd plan` command. | VB3 | A skipped body does not expand | | VB4 | A repository component overrides the registered default | | VB5 | `xmd syntax` describes the component | -| VB6 | `xmd plan --run --verbose` gives the approved Plan `` | | VB7 | A run-profile child has `` | | VB8 | A direct `xmd test --verbose` root does not have `` | | VB9 | Host false is overridden to true for one lexical subtree | diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 829134ea9..0d80ac0bf 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -35,21 +35,50 @@ Plan-producing turn — the first draft, each repair and each revision — state that whole requirement for itself, so a replacement can add or correct a title rather than only carry one forward. -That command prints the approved Plan and runs nothing. `--output` writes it to a -file instead, `--run` executes it, and the two together write it and then run -it. +`xmd plan` maps one request to one reviewed program and stops there. What runs +that program, and when, is a separate command you write yourself. -The command implements no authorship workflow of its own. It always executes +## Common paths + +Write the exact approved source to standard output: + +```console +$ xmd plan "Prepare the release program." +``` + +Compose planning and execution explicitly through standard input: + +```console +$ xmd plan "Prepare the release program." | xmd run - +``` + +Or preserve the artifact first and run the saved file later: + +```console +$ xmd plan "Prepare the release program." --output release.md && xmd run release.md +``` + +Those three are the whole of it, and the relationship they express is the +lasting one: + +```text +Plan produces a program. +Run executes a program from the host/CLI. +Composition decides whether and when a planned program runs. +``` + +Planning never runs the approved program: the command that wrote a program does +not also decide when it happens, so a Plan you have not read is a file or a pipe +rather than an effect. + +The command implements no authorship workflow of its own. It executes exactly one root document: the **plan command document**, a checked-in first-party Markdown value root that is an adapter — it projects the request into the packaged `` Component and returns the source that Component approved. The workflow itself — what the coding agent is asked, how many drafts may be repaired, what you are shown, and what happens when you approve nothing — -belongs to ``. 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 plan command document is -not itself a Plan. There is no second execution model, no second props model and -no second journal. +belongs to ``. The plan command document is not itself a Plan. There is +no second root, no second execution model, no second props model and no journal. ## The flow @@ -63,25 +92,24 @@ fixed command preflight -> teardown, then structural admission of the exact approved bytes -> Return what approved -> await that execution and provider teardown - -> validate the returned source again + -> structurally validate the returned source again -> --output: exclusively create the file with the exact bytes - -> --run: execute retainedSource("", source) through the ordinary run - path; otherwise, with no --output, write the exact bytes to stdout + otherwise: write the exact bytes to stdout ``` 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. +and a review that stopped reaches no stdout and no file. 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`. +never persisted, reused or replayed. No invocation of this command writes a +journal. ## Command grammar ```console -xmd plan [options] [--props- ]… +xmd plan [options] ``` The request is one positional argument and is text for the agent, never a path. @@ -93,60 +121,95 @@ 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` +$ xmd plan "…" extra +unrecognized argument for xmd plan: extra — the command takes exactly one request ``` -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 xmd plan -- "--this is the request" ``` -`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`. +### What the command accepts -`--include`, `--agent-provider`, `--default-agent`, `--session` and `--timeout` -are always in use: they build the catalog, settle who writes the Plan, name the -conversation, admit properties and bound the whole command. +The grammar is fixed and complete. There is no generated document whose +declarations could add an option later, because the generated document *is* the +result. -Every other execution flag configures **running** a Plan and nothing else — -`--journal`/`-j`, `--raw`, `--verbose`/`-V`, `--timeout-exec`, `--timeout-fetch`, -the three permission flags, and `--secret-detection`/`--no-secret-detection`. -Without `--run` nothing runs for them to configure, so writing one is refused in -fixed preflight rather than accepted and ignored: +| Option | What it configures | +| --- | --- | +| `--include …` | the ordered component search path the syntax catalog and the structural checks resolve through | +| `--agent-provider ` | which provider writes the Plan | +| `--default-agent ` | which agent that provider defaults to, overriding `DEFAULT_AGENT_NAME` | +| `--session ` | the logical assistant session the planning conversation belongs to | +| `--timeout ` | the deadline for the whole planning invocation | +| `--output ` | the exclusive artifact sink, in place of stdout | +| `--help`, `--version` | ordinary behaviour | + +Every one of them is either about *who writes the Plan*, *what vocabulary they +write it in*, or *where the approved source goes*. + +### What the command removed + +Everything that configured **running** the approved program is gone, with no +alias and no inert placeholder: + +- `--run`; +- the root-property options — the aggregate `--props` and every generated + `--props-*` and `--no-props-*` name; +- `--raw`; +- `--verbose` and `-V`; +- `--journal` and `-j`; +- `--timeout-exec` and `--timeout-fetch`; +- `--approve-all`, `--approve-reads` and `--deny-all`; and +- `--secret-detection` and `--no-secret-detection`. + +Each of them describes work this command never performs, and every one of them +is still `xmd run`'s. Accepting one silently would mean answering a caller who +asked for a journal, a permission mode or a root property with a command that +creates none of them. + +The fixed grammar owns these refusals, before the general parser can drop a +token, coerce its value, or read it as a second positional. An option's name is +read up to its first `=`, so every valued spelling arrives under the name it was +written as; a removed option consumes no following token and inspects no +candidate schema. It is refused wherever it stands — before the request, after +it, beside a retained option, and in the place a retained option's value would +otherwise have swallowed it. + +**Including beside `--help`.** `--help` is lifted out of the command line before +any command's own grammar runs, so a removed option written beside it would +otherwise be answered with a page describing a command that would refuse the +caller. The removal is decided first, in either order, from the same +classification the rest of the grammar uses — so a spelling cannot be removed to +one and unknown to the other. Help that names no removed option is still help. + +The root-property options are exactly the aggregate `--props`, the generated +`--props-` and the generated `--no-props-`. A name that merely +begins like one — `--propspective`, `--no-propspective`, a bare `--no-props` — +is an option this command does not define and is answered as one: sending a +caller to `xmd run` would be answering a question they did not ask. + +Every `--run` spelling — the bare switch, `--run=true`, `--run=false`, `--run=`, +any other valued form, and repeated occurrences — reports the migration: ```console -$ 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 +$ xmd plan "…" --run +xmd plan --run was removed because xmd plan only produces approved source. +Run the program explicitly: + xmd plan "..." | xmd run - + xmd plan "..." --output release.md && xmd run release.md ``` -The permission flags configure only the approved Plan; authorship does not -inherit the final run's permission mode, and no permission flag widens the -authorship profile's ceiling below. +Every other removed option reports one line naming the spelling that was +written: + +```console +$ xmd plan "…" --journal trace.jsonl +unrecognized option for xmd plan: --journal — configure the program when you run the approved source with xmd run +``` 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 @@ -154,53 +217,41 @@ drops the rest — accepting a command line nobody honoured. `--save` is named i particular: it was replaced by `--output` before release, so there is no alias, and the refusal says where an approved Plan goes now. -`--run` is a switch, and every valued spelling of it — `--run=false`, -`--run=true`, `--run=` — is refused there too: - -```console -$ xmd plan "…" --run=false ---run does not take a value — write --run to execute the Plan or leave it out to -write the Plan -``` - -An option's name is read up to its first `=`, so such a token arrives under the -name of the switch. Taken as the switch it would establish the opposite of what -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 plan supplies a request, not a document. Supplying one anyway is refused in the command's own preflight, with `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. +xmd run`, before the catalog, the command document, the review or the file +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 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. +`--agent-provider` and `--default-agent` are resolved into one authorship +configuration once per invocation, and an unknown provider fails there, before +the catalog is built. No permission mode is settled: this command starts no +program, and the ceiling its authorship runs under is the host's rather than the +command line's. -### The approved Plan is the result +### The retired spelling is refused, not absorbed -The Plan is what the command produces, and where it goes is the caller's choice: +`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. -| 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 | +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: -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 -coding agent wrote, so it can be piped into a file, a diff or another program. +```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` +``` -`--output ` is resolved against the contextual working directory and -**created exclusively**: an existing path is left exactly as it is, the command -fails, and nothing runs. There is no check-then-write — the exclusive create *is* -the check. The file holds the approved Plan and nothing else: no problems, no -decision, no wrapper. Without `--output`, no file is created anywhere. +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 and no +output. ### `--session ` @@ -211,7 +262,8 @@ never falls back to the generated one by accident. Ordinary provider session continuation applies when the configured provider already holds that name. The plan command document still supplies the current -request and the current catalog in this invocation's initial turn. +request and the current catalog in this invocation's initial turn. A continued +conversation still produces source and starts no program. ### Help @@ -219,13 +271,11 @@ request and the current catalog in this invocation's initial turn. xmd plan --help ``` -Help needs no request. It describes the Prompt, `--output`, `--run`, -`--session`, the aggregate `--props`/`XMD_PROPS` sources, that Plan-declared -individual options follow the Prompt, that stdout is where an approved Plan goes -by default, that the run-only flags are refused without `--run`, and that the -permission flags configure the approved Plan. It describes no individual property, -because the document that would declare one does not exist yet — answering -otherwise would mean generating a document in order to describe one. +Help needs no request. It describes the request, `--output`, `--session`, the +authorship and catalog options and the deadline; it states that the approved +Plan is the only result, that stdout carries its exact bytes when `--output` is +absent, and that planning never runs the approved program — and it writes out +both explicit compositions. No removed option appears anywhere in it. Help reads no catalog, contacts no provider, places no session, asks nobody anything, creates no file and runs nothing. @@ -247,8 +297,8 @@ The host supplies three fixed internal inputs as that root's props: `--include` values; and - `session` — the resolved logical assistant-session name. -They are not Plan root props and consume none of the approved Plan's property -sources. +They are the adapter's own, and nothing a Plan declares is bound here: the +properties a Plan's root declares are resolved by whoever runs it. **The root is an adapter, not the workflow.** It projects `props.request` into `` without adding whitespace, supplies `props.session`, captures the exact @@ -362,10 +412,11 @@ review form in a browser — each of which runs a command in the scope that invo adapter it is about to launch and opens the form it is already serving ([`xmd run` and `xmd plan`](./acp-client-spec.md)). -`--approve-all`, `--approve-reads` and `--deny-all` do not change this ceiling. -They apply to the approved Plan later. A provider that cannot establish this -ceiling refuses before session materialization or a turn; there is no silent -downgrade. +No permission flag reaches this ceiling, because `xmd plan` defines none: the +flags that select a permission mode belong to `xmd run`, and what settles +authorship carries a provider and a default agent and nothing else. A provider +that cannot establish this ceiling refuses before session materialization or a +turn; there is no silent downgrade. The profile's working directory is `~/.xmd/plan/sessions/`. It is dedicated to that @@ -402,7 +453,7 @@ one of them has an identity worth keeping: command fails terminally. A leaf that has gained content, and one that has disappeared, are both interference: something acted on a directory this host authorized nothing to touch. Neither is a warning and neither is a silent - skip, and no final admission, stdout, file or execution follows. + skip, and no final admission, stdout or file follows. A directory establishment never handed over — refused as non-empty, or never created at all — is a different question, already answered by what @@ -423,9 +474,10 @@ variable, document prop or replaceable context selects it; production uses its own default, and a harness that owns a temporary tree is given that tree directly. -The approved Plan later receives the caller-selected ordinary run Agent and -permission configuration. It inherits neither the assistant Session nor its -instruction layer. +A later `xmd run` of the approved Plan is an ordinary invocation with an Agent +configuration of its own. It inherits neither the assistant Session nor its +instruction layer, because there is nothing between the two commands but the +source. ### The assistant Session @@ -437,21 +489,22 @@ within this invocation uses that one Session. A turn produces a draft only from its complete successful close value. The host decides, for the whole command document execution, that a failing `` ends it: a failed, cancelled, unavailable or protocol-invalid turn discards its -partial text and reaches no human review, result or final execution, and the -document cannot opt out of that. +partial text and reaches no human review and no result, and the document cannot +opt out of that. That execution closes after approval or failure. The host observes its result only after every Prompt task, Agent provider resource, Elicitation resource and other child has completed teardown. A teardown failure wins over a selected Plan -and prevents final validation and every way a Plan could leave the command. +and prevents structural validation and every way a Plan could leave the command. ### A draft is data An Agent reply is an inert string while the Plan is being written. The command document may bind it, pass it to the validator, serialize its problems, present it with `` and produce it as source. It never evaluates the draft and -never dynamically imports it. Only after approval, teardown and final host validation -may those exact bytes enter ordinary execution. +never dynamically imports it, and neither does the command that delivers it: +approved source is bytes on stdout or bytes in a file, and only a later `xmd run` +turns either into a program. ## Host-declared draft validation @@ -464,89 +517,35 @@ bytes may write: Canonical execution supplies its invocation identity, so repository resolution cannot replace it. Its result is a closed candidate assessment: either -`{ valid: true, diagnostics: {} }`, or `{ valid: false, diagnostics }` where -`diagnostics` carries the complete versioned `DocumentValidation` when core -produced one and the structured generated-binding diagnostic when property -binding failed. - -The component performs no candidate execution. For each source it: - -1. validates the supplied root declaration without executing it; -2. once a usable root props schema exists, derives that schema's bindings; -3. checks every frozen supplied individual-option signature before consuming - tokens; -4. resolves the original CLI and invocation-environment property sources under - that schema; and -5. calls `validateDocument()` with those exact props, the caller's includes and - the run profile's identity-component declarations. - -## Generated document properties - -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 plan "greet someone" --props-name Ada --props-loud -``` - -The aggregate `--props` may be written before the request, because its meaning -never depends on a document. An individual `--props-*` option before the request -fails in preflight, before any catalog, Agent, elicitation, file, journal or -document operation. - -The original argv is the command line source for every candidate. `XMD_PROPS` -and the candidate's `XMD_PROPS_*` variables are read through the contextual -runtime environment. No resolved props object is carried from one candidate to -the next, or from any candidate to the approved bytes. - -### Frozen signatures - -An individual option's **signature** is its generated option name, its token -arity — a bare switch or one value — and its accumulation behaviour — scalar -last-wins or repeated array. The first candidate that successfully binds a -supplied option freezes that option's signature. - -Before a later candidate extracts anything, every frozen supplied option is -compared with that candidate's binding. A removed option, a changed arity or a -changed accumulation is a terminal caller-source failure. The comparison happens -*before* extraction, so a switch that became a value option cannot reach forward -and read the `--raw`, `--include` or other built-in option written after it. - -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 plan "" --props-name=-Ada -``` - -### Candidate failures and caller failures - -These are **repairable candidate failures**. The agent authored them, so the -assessment answers `valid: false` and the command document may ask again: +`{ valid: true, diagnostics: {} }`, or `{ valid: false, diagnostics }` carrying +the complete versioned `DocumentValidation` core produced. + +The component performs no candidate execution. It asks the invocation's one +structural check — `validateDocumentStructure()` under the ordinary run-profile +registry, the `` identity, the caller's ordered includes and the run +profile's declarations, which include `` itself because the catalog the +agent was shown says the profile has it. The admission that follows teardown and +the command's own gate ask that same check, so the three cannot come to differ +about what a program is for a reason nobody chose. + +**Structure, not values.** Every check `validateDocument()` makes is made here +except one: the root's own props are not validated against values, because there +are none to validate against. `xmd plan` has no property source — the aggregate +and generated options belong to `xmd run` — and the values a Plan's root +declares belong to whoever runs the program later. A Plan that declares required +properties is therefore a Plan, and refusing it here would refuse a program for +not having been given arguments nobody has offered it yet. + +Every defect this check reports is one the agent authored, so the assessment +answers `valid: false` and the command document may ask again: - source, frontmatter, target, root-props and return-declaration diagnostics; -- a generated binding-name collision — two declared properties producing one - option or one environment variable; -- missing required root props and every other `props-invalid` result; and -- every other definite document diagnostic. - -These are **terminal caller-source failures**. The caller wrote them, so they -raise out of the validator and end the command document immediately, with no -repair turn, no presentation, no approval, no stdout, no file, no final journal -and no final execution: - -- a supplied individual option the usable candidate schema does not declare; -- malformed aggregate CLI or environment JSON; -- an individual CLI or environment value the candidate schema cannot decode; -- an extra positional the candidate's binding arity exposes; and -- a later candidate removing a frozen option or changing its signature. - -The split is about authorship, not severity. A collision is repairable because -the candidate authored both colliding names; an undeclared option is terminal -because only the caller supplied it. The command document has no way to catch a -terminal failure and no way to recategorize it as draft feedback. +- an unresolved or misused component; and +- every other definite structural diagnostic. + +There is no caller-source failure to tell them apart from. The command line is +settled by fixed grammar before a draft exists, and nothing about a candidate +can make an already-accepted command line mean something else. An opaque `not-statically-checkable` invocation is not a diagnostic and does not by itself make a candidate invalid. @@ -603,7 +602,7 @@ brief explanation and explicitly not another Plan. That turn is not a draft, a repair, a revision or a review round; it cannot reopen the ten-draft limit; its answer is inert text that is never interpreted as XMD; and it is reported to you and then ends the command with no approved -source, file, journal or execution. It is subject to the host's failed-turn +source and no file. It is subject to the host's failed-turn policy like every other turn, so a turn that fails ends the command immediately. **Presentation.** The review message presents the exact draft with ``, @@ -631,102 +630,115 @@ The command document's rendered output is not command output. Everything you see while a Plan is written reaches you through Elicitation, and that document's successful public result is the approved Plan source and nothing else. -## Final admission and the result +## Structural host validation, and the artifact After the command document has completely torn down, the host treats the -returned string as untrusted again. It repeats the draft check and property resolution using the exact returned -source, the original raw CLI and invocation-environment property sources, the -individual-option signatures frozen while it was written, the caller's ordered -includes and the ordinary run profile declarations. - -A final caller-source failure or document validation failure exits non-zero -before any result at all: no stdout, no file and no execution. It does not -re-enter the command document or ask for a repair. The final resolved props -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)` -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 -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 -layer. - -`--journal` is created when the final execution starts, and only then — so -source-only and `--output` invocations create no journal at all, and neither does -any ending before admission. That journal contains no command document, assistant -Session, Agent turn, draft check, repair or human-review event. Rendered output, a value root's JSON -result, runtime failure reporting and exit codes are byte-for-byte ordinary -`xmd run` behaviour — including a failing `` boundary, which is reported -under its own `tests failed:` heading rather than as a bare message. A runtime -failure does not send the command back to authorship; `--output` has already -completed, and the Plan is there to hand-edit. +returned string as untrusted again. It structurally validates the exact returned +bytes under the `` identity, the caller's ordered `--include` values and +the ordinary run profile declarations. + +It is not a second contract. The invocation settles **one** structural check and +hands the same one to the draft check inside ``, to the admission +inside `` and to itself — so what those three can disagree about is +not the question but *when* it was asked. `` answers while the +Component still exists; this gate answers after the whole conversation, its +provider, its Elicitation and its session directory have gone, about a tree that +had all of that time to move. A component the approved Plan names, removed after +a successful admission, is refused here and nowhere else. + +What it asks is structure alone, for the same reason `` does: what +runs a Plan later resolves the property values. + +A validation failure exits non-zero before any result at all: no stdout and no +file. It does not re-enter the command document or ask for a repair. + +The approved bytes then reach exactly one destination. + +**Without `--output`**, 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 coding agent wrote, so it can be piped into `xmd run -`, a +file, a diff or another program. No file is created anywhere. + +**With `--output `**, the path is resolved against the contextual working +directory and **created exclusively**: an existing path is left exactly as it +is, the command fails, and nothing else happens. There is no check-then-write — +the exclusive create *is* the check. + +```console +$ xmd plan "…" --output release.md +/tmp/work/release.md already exists — choose another --output path; the approved Plan was not written +``` + +The file holds the approved Plan and nothing else: no problems, no decision, no +wrapper. It is created after approval, after complete authorship teardown and +after this validation — never opened early, and never truncated. Stdout stays +empty, so a caller who named a file does not also get a copy. + +The `` identity affects positions and diagnostics only. Nothing is +executed on any path: no ordinary repository provider, journal, Agent stack or +runtime identity is assembled, because there is no second root to assemble one +for. Whether the approved program ever runs is the composition the caller +writes. ## Timeouts `--timeout` bounds the whole command: preflight, catalog construction, the -command document's execution, Elicitation, its teardown, final validation, and -whichever result the caller asked for — stdout, the file, the run, or the file -and then the run. Expiry is Effection cancellation, so structured teardown -completes before the failure is reported. A teardown failure prevents every later phase. +command document's execution, Elicitation, its teardown, the structural +validation and the artifact. It covers no later program, because this command +starts none. Expiry is Effection cancellation, so structured teardown completes +before the failure is reported, and a teardown failure prevents every later +phase. -`--timeout-exec` and `--timeout-fetch` configure the final document's effects -only, exactly as under `xmd run`. Nothing bounds an authoring turn but the -command deadline. +Nothing bounds an authoring turn but that deadline. `--timeout-exec` and +`--timeout-fetch` configure a document's effects and belong to `xmd run`. ## Failures -Every failure below exits non-zero, and each one stops the phases after it: +Every failure below exits non-zero, and each one stops the phases after it. None +of them writes an artifact, and none of them starts a program — because no +ending of this command does. | Failure | Reaches | | --- | --- | -| a malformed command line, an unknown option, `--save`, or a valued `--run=…` | nothing | -| incompatible permission flags or an unknown `--agent-provider` | nothing | +| a malformed command line, a removed option, an unknown option, or `--save` | nothing | +| an unknown `--agent-provider` | nothing | | a catalog an include makes unreadable | no command document | -| 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 | -| command document teardown | no final validation, stdout, file or run | -| final validation of the approved bytes | no stdout, file or run | -| an `--output` path that exists, or a write that fails | no stdout, no run | -| the Plan's own runtime failure under `--run` | nothing after it; the `--output` file stands | +| a host that supplies no Agent context, or a provider that cannot establish the authorship profile's ceiling | no session, no turn | +| a turn that did not complete | no review, stdout or file | +| the command document's authored `` — stopping, the automatic explanation, or the unexpected ending after neither | no stdout or file | +| command document teardown | no structural validation, stdout or file | +| structural validation of the approved bytes | no stdout or file | +| an `--output` path that exists, or a write that fails | no stdout | ## Acceptance 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/plan-cli.test.ts` (the command lifecycle, filesystem, -journal and execution). +grammar), `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/plan-cli.test.ts` (the command lifecycle, its grammar as an +operator meets it, and the artifact). 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. Every refusal is proven by the phase tripwires that stayed at zero rather than by output nobody -produced. +produced, and non-execution is proven by an approved program that writes a file +and then fails — a command that succeeded, produced the exact bytes, and left +neither observation never interpreted what it wrote. | # | 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, 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 adapter and Component | The command executes the checked-in Markdown value root under ``, and that root only projects the request into the packaged `` Component and returns what it approved; the turn text is that Component's own words, and no TypeScript authorship loop or custom root supplies them | -| C3 | Visible authorship workflow | Generation, three-turn repair, ten-round review, revision, approval, stopping, exhaustion and the automatic explanation turn are present in the Component's 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 | 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 | -| 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 | -| C15 | Ordinary run | Under `--run` the approved source keeps normal cwd, includes, props, output/value, permission, timeout and failure behaviour, and an `--output` file written before it stands | +| PS1 | Fixed grammar | Every retained option is accepted before and after the request, one request is preserved byte for byte, and a second positional is refused with the approved sentence | +| PS2 | The removed switch | Bare, valued, repeated, before-request, after-request and value-position `--run` forms all return the exact migration text, before any catalog, Agent, session, review, filesystem or document activity | +| PS3 | The removed options | One representative of every other removed class, both short aliases, and the aggregate and generated property names return the exact generic refusal before authorship, ahead of the shared timeout and secret-detection grammar checks; a name that merely begins like a property option keeps the generic unknown-option refusal | +| PS4 | Help | The complete `xmd plan --help` output and the program summary contain only the retained grammar and both explicit compositions; no removed option appears anywhere in either. Help beside every retained option is still help; help beside a removed one, in either order, is that option's refusal | +| PS5 | Run is unchanged | `xmd run --help` still exposes its execution, prop, permission, timeout, presentation, journal and secret-detection options | +| PS6 | Stdout | Approval without `--output` writes the exact source once, with no wrapper and no added newline, and a source-named effect and runtime failure both stay inert | +| PS7 | The artifact | Approval with `--output` writes the same exact bytes, keeps stdout empty, creates the path only after authorship teardown and structural validation, and refuses an existing path without changing it | +| PS8 | Structure, not values | A structurally valid Plan declaring a required root property is produced successfully with no value supplied | +| PS9 | Endings | Stop, the ten-attempt explanation, a failed Agent turn, missing Agent context, cancellation, teardown failure and the host's own structural refusal each return non-zero with no stdout artifact, output file or program effect. The refusal row drives the one shared structural check: the draft check and `` both really validate and both succeed, the tree moves immediately after that admission, and the gate this command keeps for itself is the only thing left to catch it | +| PS10 | No execution anywhere | An explicitly named session continues the planning conversation and still starts no program; no execution callback, program journal or second-root identity exists to reach | +| PS11 | Adapter and Component | The command document remains the exact thin adapter, and `` remains a bare-or-captured exact text component | +| PS12 | Product copy | Architecture, specifications, README and the homepage state that Plan produces source, Run executes source, and composition decides when it runs | +| C2–C5, C8, C9, C13, C14 | Authorship | The packaged adapter and Component, one Session, the profile ceiling, the repair and review bounds, safe presentation, the authored endings, directory lifetime and narrative preservation are unchanged by this command producing source only, and keep their evidence | diff --git a/specs/root-document-props-spec.md b/specs/root-document-props-spec.md index eb1337386..7090bbbbd 100644 --- a/specs/root-document-props-spec.md +++ b/specs/root-document-props-spec.md @@ -354,73 +354,31 @@ than another spelling of the same one. ## Command Scope -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`. +Root document props belong to `xmd run` alone, and so does the inline root +document. Neither `xmd test` nor `xmd plan` accepts `--props`, `--props-*`, +`XMD_PROPS`, `XMD_PROPS_*`, or `--eval`/`-e`. ## Generated documents -`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. +`xmd plan` produces a program rather than running one +([`xmd plan`](./plan-command-spec.md)), so it resolves no root props at all. A +Plan whose root declares properties — required ones included — is a Plan: its +values belong to the later `xmd run` that names the source, and both gates the +plan command applies to a candidate are structural for exactly that reason. -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 -sources below. - -Individual options follow the **request** rather than a document path, for the -same reason they follow the path under `xmd run` — the schema that gives them -meaning arrives after them: +Writing one of these options on that command is therefore refused by name in +fixed preflight, before a catalog, an Agent, a session, a review or a file +exists: ```console -xmd plan "greet someone" --props-name Ada --props-loud +$ xmd plan "greet someone" --props-name Ada +unrecognized option for xmd plan: --props-name — configure the program when you run the approved source with xmd run ``` -An individual `--props-*` written before the request fails in preflight, before -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 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. - -Every draft is bound afresh. The original argv is the command-line source for -all of them, the environment is read for each candidate's own bindings, and no -resolved props object is carried from one draft to the next. - -An individual option's **signature** is its generated option name, its token -arity — a bare switch or one value — and its accumulation behavior — scalar -last-wins or repeated array. The first draft that successfully binds a supplied -option freezes that option's signature, and every later draft is compared with -it before a single token is extracted. A removed option, a changed arity or a -changed accumulation fails the command. Checking before extraction is what stops -a switch that became a value option from reading the `--raw` or `--include` -written after it; a built-in option is never a generated property's value, and a -value that really begins with `-` is written `--props-name=-value`. - -Failures divide by who authored them. A draft's own defect — an unreadable -declaration, a collision between two properties generating one option or one -variable, a missing required property, any other document diagnostic — is -repairable: the host's assessment answers `valid: false` with the structured -findings, and the command document may ask for a replacement. A defect the caller -wrote — an option no draft declares, malformed aggregate JSON, a value the schema -cannot decode, an extra positional a draft's arity exposes, or a signature change -— raises out of that assessment instead, ending the command document with no -repair, no review, and no approved Plan on stdout, in a file or in a run. That -document cannot catch it and cannot recategorize it as feedback for an agent that -could not have caused it. - -The props the approved Plan is admitted with are resolved once more, after the -command document has completely torn down, from those exact returned bytes and -the original unchanged sources. Admission happens in every mode — before stdout, -before an `--output` file and before a run alike — and a failure there produces -none of them. No props object any draft produced is reused, so a revision that -changed a property's declared type changes what a run receives. +The packaged plan command document does declare props of its own — the request, +the rendered syntax catalog and the assistant-session name — but they are +host-supplied internal inputs. They are declared by no command-line option, and +they consume none of the sources above. ## Targeted roots