diff --git a/architecture.md b/architecture.md index 88f1bdaf4..8d50192f0 100644 --- a/architecture.md +++ b/architecture.md @@ -42,7 +42,7 @@ Existing documents and code get aligned to this section retroactively. | 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 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 | +| 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. Its body is those two elements and no prose, because its rendered transcript is the progress the command writes to stderr. 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 | | upgrade command document | the one exact checked-in first-party Markdown streaming text root `xmd upgrade` executes to select and install a published release. It owns the exact-tag grammar, release selection, semantic-version comparison, consent, the status, already-current and installation branches, and the wording of every refusal and report; its rendered body is the command's output rather than a value it returns. Internal: no command-line option selects another one, and no repository component search can answer for it | @@ -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 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) | +| `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 reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft and every failed check's structured findings — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. 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`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, 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 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, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, so an ordinary `` expands no progress body at all. 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 | diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index cf0f291ae..cd45e03a6 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -28,6 +28,7 @@ import { ensure, Err, Ok, scoped, until, useScope } from "effection"; import type { Operation, Result, Scope } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; import { createHash } from "node:crypto"; import { mkdir, readdir, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; @@ -35,19 +36,20 @@ import { join } from "node:path"; import { agentIdentityComponents, - collect, installAgentComponents, installPermissionMode, installPromptFailurePolicy, registerAgentProvider, retainedSource, + useNormalizedOutput, + useTerminalOutput, } from "@executablemd/core"; import type { AgentProviderOptions, Json } from "@executablemd/core"; import type { DeclaredMarkdownComponent } from "@executablemd/core/host"; import { executeInstalled, installInvocationAgentProvider } from "@executablemd/core/host"; import { createAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; -import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableStream } from "@executablemd/durable-streams"; import { API } from "@executablemd/runtime"; import { FormOpener } from "@executablemd/web"; @@ -80,14 +82,63 @@ export interface CandidateAssessment { diagnostics: Json; } +/** + * Where this command's planning progress goes, and what that destination is. + * + * A host dependency, both halves of it. Only the entrypoint that owns + * `process.stderr` knows whether it is a terminal, and only it can wait for the + * stream to take a chunk — a shared module that detected a runtime, or a + * document that inspected a terminal, would be answering a question that is not + * its own. + */ +export interface ProgressOutput { + /** Whether the host's progress destination is a terminal. */ + readonly terminal: boolean; + /** + * Write one chunk, completing when the stream has taken all of it. + * + * A `Result` rather than a throw: a destination that stops accepting bytes is + * an outcome this command reports and ends on, not an exception it discovers + * somewhere in the middle of authorship. + */ + write(chunk: string): Operation>; +} + +/** + * What a progress destination that stopped taking bytes ends the command with. + * + * Raised out of the consumer, which is what cancels the producer: leaving the + * scope that owns the execution takes the live turn, the provider, the + * Elicitation, the session directory and the execution down before this is + * reported, and no artifact sink is attempted afterwards. + */ +export class ProgressDeliveryError extends Error { + constructor(reason: Error) { + super( + `Could not write planning progress to stderr: ${reason.message}\n\n` + + "Planning was cancelled, and no Plan was output.", + ); + } +} + /** What the host supplies to one plan command document execution. */ export interface AuthorshipProfile { /** The request as the person typed it. */ request: string; - /** The rendered syntax catalog for this run profile and these includes. */ - syntax: string; /** The logical name every turn in this invocation belongs to. */ session: string; + /** + * Where this invocation's live durable events go. + * + * The host's choice, because only the host knows whether somebody asked for a + * diagnostic trace: one fresh invocation-owned in-memory stream by default, + * or the file `--journal` named and the CLI exclusively created. Neither is + * opened as input, replayed or read as resume authority — a Plan is written + * once, and nothing here runs the program it produces. + */ + stream: DurableStream; + /** Who receives the progress this invocation renders, as it is rendered. */ + progress: ProgressOutput; /** * Whether the caller named that session. * @@ -112,9 +163,9 @@ export interface AuthorshipProfile { * The `` declaration this command runs under. * * Built by the command, from the packaged Component's bytes, before the adapter - * root is imported. It carries the sealed surface, the precomputed catalog and - * the Agent context this invocation settled — none of which is a prop the adapter - * could supply or a document could reach. + * root is imported. It carries the sealed surface, the sealed verbosity, the + * catalog this invocation will build and the Agent context it settled — none of + * which is a prop the adapter could supply or a document could reach. */ declaration: DeclaredMarkdownComponent; } @@ -335,12 +386,18 @@ function* installPlanPromptFailurePolicy(): Operation { } /** - * Run the packaged plan command document and answer with the Plan it approved. + * Run the packaged plan command document, reporting its progress as it happens, + * and answer with the Plan it approved. * * Every resource this builds lives inside one scope, so leaving it is what tears * the Prompt tasks, the provider and the Elicitation provider down. A teardown * failure raises out of here rather than being folded into the result, because * a failure to release is not an outcome the source that was selected survives. + * + * The progress is drained inside that scope and while the producer is alive, so + * a person watches a Plan being written rather than reading an account of work + * that already finished — and a destination that fails takes the whole + * conversation down with it, in that order, before anything is delivered. */ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { // Before a directory exists, before a provider exists, and therefore before @@ -362,44 +419,65 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation` and returns what comes back. Everything that used to be // installed around it — the directory, the provider, the Elicitation, the // refusals — is installed by the Component itself, inside the invocation that // owns it, so the command and an ordinary document run one workflow rather // than two arrangements of one. - const approved = yield* collect( - yield* executeInstalled( + const execution = yield* executeInstalled( + { + ...retainedSource(PLAN_COMMAND_IDENTITY, source), + stream: profile.stream, + // No repository component search. What the adapter may name is + // what this command declares, so a file in the caller's tree + // cannot answer for `` or anything else. + includes: [], + // The one secret boundary this command has. Every live durable event + // crosses the serialized pre-append gate, so a draft or a diagnostic + // becomes readable — to the journal and to the phase that presents it + // — only after the event supplying it has cleared and committed. + secretDetection: true, + props: { + request: profile.request, + session: profile.session, + }, + }, + [ { - ...retainedSource(PLAN_COMMAND_IDENTITY, source), - // Invocation-owned and thrown away with the scope. Ordinary - // document and Prompt semantics need a durable stream; nothing - // about writing a Plan needs a durable one, and `--journal` - // belongs to the Plan you approved rather than to the - // conversation that wrote it. - stream: new InMemoryStream(), - // No repository component search. What the adapter may name is - // what this command declares, so a file in the caller's tree - // cannot answer for `` or anything else. - includes: [], - props: { - request: profile.request, - syntax: profile.syntax, - session: profile.session, - }, + components: agentIdentityComponents(), + declarations: [profile.declaration], }, - [ - { - components: agentIdentityComponents(), - declarations: [profile.declaration], - }, - ], - ), + ], ); - if (typeof approved !== "string") { + + // Drains while the document is still producing. A destination that fails + // raises here, and leaving this scope cancels the execution and waits for + // its teardown before anything is reported. + yield* forEach(function* (chunk: string) { + const written = yield* profile.progress.write(chunk); + if (!written.ok) { + throw new ProgressDeliveryError(written.error); + } + }, execution.output); + + const completed = yield* execution; + if (!completed.ok) { + return completed; + } + if (typeof completed.value !== "string") { return Err(new Error("the plan command document returned something that is not a Plan")); } - return Ok(approved); + return Ok(completed.value); } catch (error) { return Err(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 63975fd63..e5edbdfc1 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -268,10 +268,15 @@ const PLAN_DESCRIPTION = * * 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. + * where 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. + * + * `--verbose` and `--journal` describe writing the Plan rather than running it. + * They observe this invocation's own authorship and nothing after it, which is + * why they are spelled in full: `-V` and `-j` are `xmd run`'s aliases for + * options about a program's run. */ const planConfig = object({ request: { @@ -286,6 +291,14 @@ const planConfig = object({ description: "logical name for the assistant session (default: unique to this invocation)", ...field(z.string().optional()), }, + verbose: { + description: "show generated drafts and XMD check diagnostics on stderr", + ...field(z.boolean(), field.default(false)), + }, + journal: { + description: "record the planning process as diagnostic JSONL (path must not exist)", + ...field(z.string().optional()), + }, include: { description: "component search directory", ...field(z.array(z.string()), field.default(["components", "."]), field.array()), @@ -2211,6 +2224,10 @@ const PLAN_REQUEST_HELP = [ "", "A named --session continues the planning conversation. Without it, this", "invocation uses a unique session.", + "", + "Secret detection checks journal entries before they are recorded, but it may not", + "catch every sensitive detail. The journal can contain prompts, drafts, and review", + "answers.", ].join("\n"); /** @@ -2488,11 +2505,21 @@ function* dispatch( include: config.include, ...(config.output === undefined ? {} : { output: config.output }), ...(config.session === undefined ? {} : { session: config.session }), + verbose: config.verbose, + ...(config.journal === undefined ? {} : { journal: config.journal }), stack: authorship.value, }, { ...(sessions === undefined ? {} : { sessions }), catalog: syntaxCatalog, + // The two facts about this process's own stderr that nothing further + // in may go and read: whether it is a terminal, and whether it took + // what it was handed. The approved Plan's sinks are stdout and + // `--output`, and progress reaches neither. + progress: { + terminal: process.stderr.isTTY === true, + write: (chunk) => deliverWhole(chunk, process.stderr), + }, // `` reaches a person through the browser form, and the // review question is asked by the command rather than by a document. // A host that answers installs a provider; one that does not installs diff --git a/packages/cli/src/documents/Plan.md b/packages/cli/src/documents/Plan.md index e881a02e6..d30e0a84d 100644 --- a/packages/cli/src/documents/Plan.md +++ b/packages/cli/src/documents/Plan.md @@ -36,6 +36,41 @@ nothing reaches no catalog, no session, no agent and no review. +## Bound the drafts and the repairs + + + + { + const tens = count % 100; + const ones = count % 10; + const suffix = tens >= 11 && tens <= 13 + ? "th" + : ones === 1 + ? "st" + : ones === 2 + ? "nd" + : ones === 3 + ? "rd" + : "th"; + return `${count}${suffix}`; + }} +/> + + +This workflow writes at most {attempts} Plans and repairs each one at most +{repairs} times. Both bounds are stated once, here, and every loop, condition and +sentence below is derived from them — so what you are told and what the workflow +does cannot come to disagree. The ordinal beside them turns a counter into the +word a person reads, for the same reason. + + +## Preparing the Plan + +Getting the available XMD components and constructs and setting up the planning session. + + ## Say what this surface calls things @@ -47,16 +82,19 @@ of them is raised. - + - + @@ -73,6 +111,16 @@ of them is raised. ## Create the first draft + + + +## Drafting the Plan + +The coding agent is turning your request into an XMD program. + +This is the {attemptOrdinal} of up to {attempts} attempts. + + Create one complete XMD Plan from this Prompt: @@ -117,23 +165,61 @@ Reply with the Plan source and nothing else. No enclosing code fence, no explanation before or after it. - + +## Generated draft + +The coding agent produced this draft: + + + + + ## Check and repair the draft Each new draft is checked before it is shown to you. If the check finds problems, -the coding agent gets up to three repair attempts to replace it with a corrected -Plan. A revision you request later is a new draft and receives three repair -attempts of its own. +the coding agent gets up to {repairs} repair attempts to replace it with a +corrected Plan. A revision you request later is a new draft and receives +{repairs} repair attempts of its own. + + +## Checking the draft + +Checking that it is valid XMD and uses the available components and constructs correctly. + - + + +## Problems found in the draft + +The XMD check found these problems: + + + + + + + + + + + + + +## Repairing the draft + +The coding agent is correcting problems found by the XMD check. + +This is the {repairOrdinal} of up to {repairs} repairs for the current Plan attempt. + + That Plan has problems. These are the exact ones: @@ -159,22 +245,53 @@ Reply with the Plan source and nothing else. No enclosing code fence, no explanation before or after it. + +## Generated draft + +The coding agent produced this draft: + + + + + +## Checking the draft + +Checking that it is valid XMD and uses the available components and constructs correctly. + + + + + +## Problems found in the draft + +The XMD check found these problems: + + + + + -## Explain a tenth draft that could not be repaired +## Explain a last draft that could not be repaired -Ten drafts is the limit, and the tenth cannot be revised into an eleventh. So a -tenth draft that still has problems after its repair attempts leaves nothing to -approve and nothing to ask for — there is no decision left for you to make, and -this workflow does not ask you to make one. +The limit is {attempts} drafts, and the last of them cannot be revised into +another. So a {lastAttempt} draft that still has problems after its repair +attempts leaves nothing to approve and nothing to ask for — there is no decision +left for you to make, and this workflow does not ask you to make one. Instead the coding agent is asked once, automatically, why the attempts did not work and what would make a future prompt more likely to succeed. That answer is explanation and nothing else: it creates no new draft, extends no limit, and ends this Plan without a program. - + + +## Could not generate a Plan + +The draft still has problems after {attempts} attempts. The coding agent is reviewing why planning was unsuccessful and how to improve the outcome of a future attempt. + + The final Plan still has these problems: @@ -198,8 +315,15 @@ complete draft after its repair attempts. new draft. - Choose **Stop** to end without returning anything. -A draft with remaining problems cannot be approved, and the tenth draft cannot -be revised, so on either of those the choices you are offered are narrower. +A draft with remaining problems cannot be approved, and the {lastAttempt} draft +cannot be revised, so on either of those the choices you are offered are +narrower. + + +## Waiting for your review + +Review the draft and choose what should happen next. + ### Problems that remain -The coding agent used all three repair attempts, but the draft still has these -problems: +The coding agent used all {repairs} repair attempts, but the draft still has +these problems: @@ -254,13 +378,33 @@ feedback to the coding agent and starts a new draft. Stop ends here. + +## Finalizing the Plan + +Closing the planning session and producing the final Plan from the approved draft. + + +## Stopping planning + +Closing the planning session without producing a Plan. + + + + +## Revising the Plan + +The coding agent is applying the changes you requested. + +This is the {attemptOrdinal} of up to {attempts} attempts. + + You read that Plan and asked for this to change: @@ -284,6 +428,14 @@ it did not describe the Plan. Reply with the Plan source and nothing else. No enclosing code fence, no explanation before or after it. + + +## Generated draft + +The coding agent produced this draft: + + + diff --git a/packages/cli/src/documents/plan-command.md b/packages/cli/src/documents/plan-command.md index 6e0503123..406b4c3e1 100644 --- a/packages/cli/src/documents/plan-command.md +++ b/packages/cli/src/documents/plan-command.md @@ -3,31 +3,11 @@ props: type: object properties: request: { type: string } - syntax: { type: string } session: { type: string } - required: [request, syntax, session] + required: [request, session] additionalProperties: false returns: type: string --- - -# `xmd plan` turns steps into a program - -This document is a workflow that generates an executable Plan from a sequence of -steps. It combines the original Prompt, which describes those steps, with the XMD -components available to carry them out. A coding agent turns both into one -document that explains and executes the sequence. - -The result is the XMD version of a coding agent’s plan. A conventional Markdown -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, 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 569e54d44..3d1f03ffe 100644 --- a/packages/cli/src/plan-args.ts +++ b/packages/cli/src/plan-args.ts @@ -49,6 +49,8 @@ const AGGREGATE_OPTION = "--props"; export const OUTPUT_OPTION = "--output"; export const SESSION_OPTION = "--session"; +export const VERBOSE_OPTION = "--verbose"; +export const JOURNAL_OPTION = "--journal"; /** The switch that used to run the approved Plan, and now names its migration. */ const RUN_OPTION = "--run"; @@ -76,6 +78,25 @@ export function removedOptionRefusal(option: string): string { ); } +/** + * The short spellings the two authorship options deliberately do not have. + * + * They are `xmd run`'s aliases for options that configure a program's run. The + * long spellings here describe writing a Plan and observe no later run, so a + * caller reaching for the short one is answered with the long one rather than + * with the generic removal — which would send them to `xmd run` for an option + * this command does define. + */ +const SHORT_ALIASES: ReadonlyMap = new Map([ + ["-V", VERBOSE_OPTION], + ["-j", `${JOURNAL_OPTION} `], +]); + +/** What a short alias of a retained authorship option is answered with. */ +export function shortAliasRefusal(option: string): string { + return `unrecognized option for xmd plan: ${option} — write \`${SHORT_ALIASES.get(option)}\``; +} + /** The options that take a separated value. */ const VALUE_OPTIONS: readonly string[] = [ "--include", @@ -84,10 +105,11 @@ const VALUE_OPTIONS: readonly string[] = [ "--timeout", OUTPUT_OPTION, SESSION_OPTION, + JOURNAL_OPTION, ]; /** The options that take none. */ -const SWITCH_OPTIONS: readonly string[] = ["--help", "-h", "--version"]; +const SWITCH_OPTIONS: readonly string[] = ["--help", "-h", "--version", VERBOSE_OPTION]; /** * The options that configured running the approved Plan, and now configure @@ -95,8 +117,8 @@ const SWITCH_OPTIONS: readonly string[] = ["--help", "-h", "--version"]; * * `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 + * who asked for 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. * * The generated `--props-*` and `--no-props-*` names, and the aggregate @@ -105,10 +127,6 @@ const SWITCH_OPTIONS: readonly string[] = ["--help", "-h", "--version"]; */ const REMOVED_OPTIONS: readonly string[] = [ "--raw", - "--verbose", - "-V", - "--journal", - "-j", "--timeout-exec", "--timeout-fetch", "--approve-all", @@ -127,6 +145,27 @@ function optionName(token: string): string { return equals === -1 ? token : token.slice(0, equals); } +/** + * Whether this token is one of the options this command defines. + * + * Asked only where a value option would otherwise swallow the token after it. + * A removed spelling is already refused where it stands, and a bare `-` is a + * file called `-` rather than an option, so what is left is the retained + * grammar: the tokens that mean something here and therefore cannot also be a + * caller's filename. + */ +function namesRetainedOption(token: string): boolean { + if (!token.startsWith("-") || token === "-") { + return false; + } + return KNOWN.has(optionName(token)); +} + +/** What a `--journal` that named no path is refused with. */ +export const JOURNAL_PATH_REFUSAL: string = + `${JOURNAL_OPTION} needs a path — write \`${JOURNAL_OPTION} \` or leave ` + + "it out to record no journal"; + /** * Whether this name binds a root property of the program a Plan describes. * @@ -157,6 +196,9 @@ function removalRefusal(name: string): string | undefined { if (name === RUN_OPTION) { return RUN_REMOVAL_REFUSAL; } + if (SHORT_ALIASES.has(name)) { + return shortAliasRefusal(name); + } if (REMOVED.has(name) || generatesProperty(name)) { return removedOptionRefusal(name); } @@ -290,6 +332,22 @@ export function scanPlanArgs(args: readonly string[]): PlanScan { ); } } + if (name === JOURNAL_OPTION) { + // A following token that names an option this command defines is that + // option, not a filename. `--journal --verbose` asked for a journal and + // named none, and reading the switch as a path would exclusively create + // a file called `--verbose` and drop the verbosity the caller asked for + // — silently, because a path is a path. + const value = + equals === -1 + ? separated !== undefined && namesRetainedOption(separated) + ? undefined + : separated + : token.slice(equals + 1); + if (value === undefined || value.length === 0) { + return refuse(JOURNAL_PATH_REFUSAL); + } + } fixed.push(token); if (separated !== undefined) { fixed.push(separated); diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 1fba07f59..31f4b12ff 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -1,5 +1,5 @@ /** - * `` — how this host declares the component, and the four private + * `` — how this host declares the component, and the five private * capabilities only its own bytes may write. * * The Component itself is `src/documents/Plan.md` rather than anything here: @@ -27,11 +27,12 @@ * * ## Why the capabilities are private * - * ``, ``, `` and `` are the - * phases of one invocation, not components anyone composes with. Freezing the - * inputs, installing a constrained Agent frame, answering about a draft and - * admitting the approved bytes are each meaningless outside the workflow that - * orders them — and each carries authority the enclosing document does not have. + * ``, ``, ``, `` and + * `` are the phases of one invocation, not components anyone composes + * with. Freezing the inputs, installing a constrained Agent frame, telling an + * operator which phase is running, answering about a draft and admitting the + * approved bytes are each meaningless outside the workflow that orders them — + * and each carries authority the enclosing document does not have. * So they resolve only while canonical core is expanding these exact bytes: * not from the caller's root, not from the Prompt the caller projected, not from * a sibling ``, and not from anything middleware can answer. @@ -45,6 +46,7 @@ import type { Json } from "@executablemd/durable-streams"; import { agentIdentityComponents, content, + DocumentOutput, retainedSource, validateDocumentStructure, } from "@executablemd/core"; @@ -189,6 +191,15 @@ export interface PlanComponentAssembly { readonly session?: string; /** Whether that fixed name was one a caller asked for and can ask for again. */ readonly explicitSession?: boolean; + /** + * Whether this command selected long-form progress. + * + * Sealed beside {@link surface}, and read by `` alone. + * `xmd plan --verbose` is the only thing that sets it: no prop, binding, + * middleware answer or authored opt-in reaches it, so a document cannot ask + * for the drafts and diagnostics an operator did not. + */ + readonly verbose?: boolean; /** The scope the two host acts run in, captured before the frame exists. */ readonly host: Scope; /** A trusted host-only observation after the whole frame is installed. */ @@ -256,6 +267,19 @@ const AUTHORSHIP_PROPS = { additionalProperties: false, }; +/** + * What one progress phase is given: whether it is long-form, and nothing else. + * + * Closed on purpose. A phase says which part of authorship is running, and the + * only question about one is whether an operator asked to see the drafts and + * diagnostics — which is the host's answer, not a prop's. + */ +const PROGRESS_PROPS = { + type: "object", + properties: { verbose: { type: "boolean" } }, + additionalProperties: false, +}; + const SOURCE_PROP = { type: "object", properties: { source: { type: "string" } }, @@ -325,6 +349,7 @@ export function* planComponentDeclaration( privates: [ planInputs(assembly), planAuthorship(assembly), + planProgress(assembly), checkDraft(validate), admitPlan(validate), ], @@ -385,6 +410,7 @@ function describedPrivates(): readonly IdentityComponent[] { forms: ["self-closing"], }, { name: "PlanAuthorship", props: AUTHORSHIP_PROPS, forms: ["paired"] }, + { name: "PlanProgress", props: PROGRESS_PROPS, forms: ["paired"] }, { name: "CheckDraft", props: SOURCE_PROP, returns: CHECK_RETURNS, forms: ["self-closing"] }, { name: "AdmitPlan", props: ADMIT_PROPS, returns: { type: "string" }, forms: ["self-closing"] }, ]; @@ -577,6 +603,48 @@ function planAuthorship(assembly: PlanComponentAssembly): IdentityComponent { }; } +/** + * Say which part of authorship is running, to the operator and to nobody else. + * + * Progress is a side effect of the command's own output, not something `` + * produces. The rendered prose is sent through the current `DocumentOutput` + * operation and the invocation returns the empty string, which is what keeps it + * out of two places at once: `` captures what the Component + * *returns*, so returning the prose would put a phase heading inside approved + * source, and the declaration is `exact`, so it would also bypass the + * whitespace and terminal presentation every other line of progress gets. + * + * Only the command surface has an operator to tell. An ordinary document that + * writes `` renders its approved source where the element stands, and a + * phase announcement in the middle of that is the same contamination by another + * route — so the component surface returns before the content is expanded at + * all, and the bindings a phase interpolates are never even read there. + * + * `verbose` is the same decision one step further in: a phase that carries a + * draft or a check's diagnostics renders only when this command was asked for + * them. The answer is the host's sealed fact rather than the prop's value; the + * prop says which phases the question is about. + */ +function planProgress(assembly: PlanComponentAssembly): IdentityComponent { + return { + name: "PlanProgress", + origin: `${PLAN_ORIGIN}#PlanProgress`, + forms: ["paired"], + props: PROGRESS_PROPS, + factory: () => + function* PlanProgress(props: Record): Operation { + if (assembly.surface !== "command") { + return ""; + } + if (props.verbose === true && assembly.verbose !== true) { + return ""; + } + yield* DocumentOutput.operations.output(yield* content()); + return ""; + }, + }; +} + /** * Answer about one draft, without executing a byte of it. * diff --git a/packages/cli/src/plan-journal.ts b/packages/cli/src/plan-journal.ts new file mode 100644 index 000000000..62b485771 --- /dev/null +++ b/packages/cli/src/plan-journal.ts @@ -0,0 +1,146 @@ +/** + * `xmd plan --journal` — the diagnostic record of one authorship invocation + * (specs/plan-command-spec.md). + * + * The file holds the command root's ordinary live durable events, serialized as + * the same JSONL sequence every other trace uses. It is a record of how a Plan + * came to be written, not a history anything reads back: this command opens no + * journal as input, resumes nothing from one, and runs no program that could be + * resumed. + * + * What is here rather than beside `xmd run`'s journal is the wording. A caller + * who asked `xmd plan` for a trace is told which `--journal` path to choose and + * what survived a failure, and neither sentence is `xmd run`'s to change. The + * bytes are identical; only the diagnostics are this command's own. + */ + +import { Err, Ok, until } from "effection"; +import type { Operation, Result } from "effection"; +import { open } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import type { DurableEvent, DurableStream } from "@executablemd/durable-streams"; + +import { FileStream } from "./file-stream.ts"; +import { describeError } from "./props.ts"; + +/** What a caller who named a path something already occupies is told. */ +export function journalExistsRefusal(path: string): string { + return `Journal file already exists: ${path}. Choose a different --journal path.`; +} + +/** What a path this command could not create at all is refused with. */ +export function journalCreationRefusal(path: string, reason: string): string { + return ( + `Could not create journal file ${path}: ${reason}\n\n` + + "Choose a different --journal path and try again." + ); +} + +/** + * What an entry the file would not take ends authorship with. + * + * It names what is still readable, because that is the whole value left: the + * entries recorded before the failure are the trace of everything that did + * happen, and a caller who reads "the journal is incomplete" would throw away + * the evidence they asked for. + */ +export function journalAppendFailure(path: string, reason: string): string { + return ( + `Could not write the next entry to journal file ${path}: ${reason}\n\n` + + "The journal still contains the entries recorded before this failure." + ); +} + +/** + * Exclusively create the named journal and hand back the stream that appends to + * it, or refuse. + * + * Exclusive creation, so a path somebody kept is left byte-identical and this + * command stops before the catalog, the session directory, the provider, any + * turn, the review or the artifact exists. There is no check-then-create: the + * open is the check. The creation handle is closed straight away — appending is + * the stream's business, and a handle held open across the whole invocation + * would be one more thing a teardown has to get right. + */ +export function* createPlanJournal(path: string): Operation> { + let handle: FileHandle; + try { + handle = yield* until(open(path, "wx")); + } catch (error) { + const existing = + error instanceof Error && + (("code" in error && error.code === "EEXIST") || error.message.startsWith("EEXIST:")); + if (existing) { + return Err(new Error(journalExistsRefusal(path))); + } + return Err(new Error(journalCreationRefusal(path, describeError(error)))); + } + + try { + yield* until(handle.close()); + } catch (error) { + return Err(new Error(journalCreationRefusal(path, describeError(error)))); + } + + return Ok(planJournalStream(path, new FileStream(path))); +} + +/** + * What an entry the journal would not take ends authorship with. + * + * A class rather than a message, because it has to survive being wrapped. The + * durable runtime reports a refused append as its own failure to persist an + * event, and a person who asked for a journal is owed the sentence about the + * file rather than the one about the protocol — so this travels as the `cause` + * and the command reads it back out (see {@link journalRefusal}). + */ +export class PlanJournalError extends Error { + constructor(path: string, cause: unknown) { + super(journalAppendFailure(path, describeError(cause)), { cause }); + this.name = "PlanJournalError"; + } +} + +/** + * The journal refusal underneath a failure, when that is what the failure is. + * + * Read from the cause chain rather than from a flag something set beside it: a + * refused entry is reported here exactly when it is what ended this authorship, + * so an unrelated failure keeps its own account. + */ +export function journalRefusal(error: unknown): PlanJournalError | undefined { + let current: unknown = error; + while (current instanceof Error) { + if (current instanceof PlanJournalError) { + return current; + } + current = current.cause; + } + return undefined; +} + +/** + * Wrap the stream that writes the file, so a refused entry says whose file it + * is and what survived. + * + * A translation and nothing else: the same `serializeDurableEvent()` JSONL, in + * commit order, with no curated projection and no format of this command's own. + * + * The backing stream is a parameter because a filesystem will not fail an + * append on request. Production hands it a {@link FileStream} over `path`; a + * case that has to prove what a failed entry leaves behind hands it one that + * writes the same file and then refuses, so what it reads back is this + * translation rather than a message the case wrote itself. + */ +export function planJournalStream(path: string, backing: DurableStream): DurableStream { + return { + readAll: () => backing.readAll(), + *append(event: DurableEvent): Operation { + try { + yield* backing.append(event); + } catch (error) { + throw new PlanJournalError(path, error); + } + }, + }; +} diff --git a/packages/cli/src/plan.ts b/packages/cli/src/plan.ts index 6c9eddb89..8b0259f24 100644 --- a/packages/cli/src/plan.ts +++ b/packages/cli/src/plan.ts @@ -7,8 +7,9 @@ * * ```text * fixed command preflight - * -> build the run-profile syntax catalog - * -> execute the exact packaged plan command document + * -> exclusively create the --journal file, when one was named + * -> execute the exact packaged plan command document, reporting each of its + * phases on stderr as it happens — the catalog is built inside it * -> await that execution and provider teardown * -> structurally validate the returned source again * -> deliver those exact bytes, in exactly one of two ways: @@ -41,14 +42,19 @@ import process from "node:process"; import type { SyntaxCatalog } from "@executablemd/core"; import type { AcpxProviderDependencies } from "@executablemd/acp"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableStream } from "@executablemd/durable-streams"; import { cwd } from "@executablemd/runtime"; import type { AuthorshipStack } from "./agent-stack.ts"; import { DEFAULT_AUTHORSHIP_ROOT, planAgentContext, + ProgressDeliveryError, runPlanCommandDocument, } from "./authorship-profile.ts"; +import type { ProgressOutput } from "./authorship-profile.ts"; +import { createPlanJournal, journalRefusal } from "./plan-journal.ts"; import { planComponentDeclaration, planComponentDescription, @@ -77,6 +83,10 @@ export interface PlanCommand { output?: string; /** The logical assistant-session name, when the caller chose one. */ session?: string; + /** Show each generated draft and each failed check's diagnostics. */ + verbose: boolean; + /** Where the diagnostic record of this authorship goes, when one was asked for. */ + journal?: string; /** Who writes the Plan, settled before the command began. */ stack: AuthorshipStack; } @@ -91,6 +101,23 @@ export interface PlanDependencies { catalog(includes: readonly string[]): Operation; /** Who answers the review question. */ installElicitation(): Operation; + /** + * Where planning progress goes, and whether that destination is a terminal. + * + * Both are the entrypoint's facts about its own `process.stderr`. Nothing + * here detects a runtime or inspects a terminal, and no document reaches + * either answer. + */ + progress: ProgressOutput; + /** + * Create the diagnostic journal `--journal` named, or refuse. + * + * Absent is {@link createPlanJournal}, which exclusively creates the path and + * appends the ordinary JSONL — and is what production uses. A harness that + * has to prove what a refused entry leaves behind supplies its own, because a + * filesystem will not fail a write on request. + */ + journal?(path: string): Operation>; /** * Where this host keeps its profile session directories. * @@ -119,12 +146,21 @@ export interface PlanDependencies { * phase reads, so a refusal cannot be followed by the work it refused. */ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operation { - let syntax: string; - try { - syntax = renderSyntaxMarkdown(yield* deps.catalog(command.include)); - } catch (error) { - console.error(describeError(error)); - return 1; + // The journal is the first thing this command establishes, because a path + // somebody kept is the one refusal that has to cost nothing: it happens + // before the catalog is built, before a session directory is placed, before + // the provider starts an agent, before anybody is asked to review and before + // any artifact exists, and it leaves that file byte-identical. + let stream: DurableStream; + if (command.journal === undefined) { + stream = new InMemoryStream(); + } else { + const created = yield* (deps.journal ?? createPlanJournal)(command.journal); + if (!created.ok) { + console.error(created.error.message); + return 1; + } + stream = created.value; } // The command document lives and dies inside that call's scope. Leaving it @@ -164,26 +200,28 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio authorshipRoot: root, session, explicitSession, + verbose: command.verbose, host, installElicitation: deps.installElicitation, - // Rendered once, before the document existed, and sealed: the catalog the - // agent is shown is the one this command produced, and no prop on the thin - // adapter could supply another. - // deno-lint-ignore require-yield + // Built when `` asks, which is what lets an authored phase + // announce the preparation before it happens. It is still sealed: the + // catalog the agent is shown is the one this command renders, and no prop + // on the thin adapter could supply another. *catalog() { - return syntax; + return renderSyntaxMarkdown(yield* deps.catalog(command.include)); }, validate, }); authored = yield* runPlanCommandDocument({ request: command.request, - syntax, session, explicitSession, root, context, declaration, + stream, + progress: deps.progress, }); } catch (error) { console.error(describeError(error)); @@ -191,7 +229,21 @@ export function* runPlan(command: PlanCommand, deps: PlanDependencies): Operatio } if (!authored.ok) { - console.error(authored.error.message); + // Progress delivery is the one failure this command cannot report through + // the stream that failed to report it. A later write may still land — a + // pipe that closed once is not a stream that refuses forever — so the + // diagnostic is offered there and nowhere else: an approved Plan's own sink + // is not a fallback channel for a message about progress. + if (authored.error instanceof ProgressDeliveryError) { + yield* deps.progress.write(`${authored.error.message}\n`); + return 1; + } + // A refused journal entry is what ended authorship, and the durable + // runtime's own account of it names an event rather than the file a person + // asked for. Reported from the cause chain, so this replaces nothing but + // the failure it actually is. + const refused = journalRefusal(authored.error); + console.error((refused ?? authored.error).message); return 1; } diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index 226db41f6..802f622e2 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -195,6 +195,7 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { request: REQUEST, include: [dir], output: join(dir, "plan.md"), + verbose: false, stack: { ...stackWith(installingAdapters(prepared)), defaultAgent: AGENT }, }, harness.deps, diff --git a/packages/cli/tests/document-suites/plan/Plan.test.md b/packages/cli/tests/document-suites/plan/Plan.test.md index 03b3664ae..34813b2b4 100644 --- a/packages/cli/tests/document-suites/plan/Plan.test.md +++ b/packages/cli/tests/document-suites/plan/Plan.test.md @@ -211,7 +211,7 @@ explanation of why none of them worked. {props.request}', + "", ].join("\n"); /** What every turn that asks for a Plan has to say, on its own. */ @@ -86,17 +71,17 @@ describe("packaged documents", () => { expect(source).toBe(committed); // It is the plan command document, not merely some file that exists. expect(source).toContain("returns:"); - // The approved introduction, pinned whole. It is what a reader meets first - // and the only place the command explains itself, so its wording and its - // punctuation are part of the contract rather than a paraphrase. - expect(source).toContain(INTRODUCTION); - // And it is an adapter rather than a second implementation: it hands the request to + // The body is the whole adapter, pinned whole: it hands the request to // `` and returns what comes back. Every phase of authorship — the // turns, the checking, the review, the endings — is in the one Component the // next case reads, so a second copy of any of it here would be exactly the // drift having one Component source exists to prevent. - expect(source).toContain('{props.request}'); - expect(source).toContain(""); + // + // And there is nothing else. This root's rendered transcript is what + // `xmd plan` writes to stderr, so a sentence of the adapter's own would be + // an operator reading the command explaining itself in the middle of the + // Plan being written. What the command is for belongs in its help. + expect(source.slice(source.lastIndexOf("---\n") + 4).trim()).toBe(ADAPTER_BODY); for (const authored of [ " { ).toBe(2); // The worked example is itself a titled Plan. expect(source).toContain("# Ask for and save your age"); - // A tenth draft that could not be repaired is not a review at all, and the + // A last draft that could not be repaired is not a review at all, and the // prose says why: there is no decision left to offer. expect(source).toContain( - "there is no decision left for you to make, and\nthis workflow does not ask you to make one.", + "there is no decision\nleft for you to make, and this workflow does not ask you to make one.", ); + // Both bounds are bound once and every loop, condition and sentence is + // derived from them, so the words a person reads cannot drift from the + // counters the workflow actually keeps. + expect(source).toContain(''); + expect(source).toContain(''); + expect(source).toContain(""); + expect(source).toContain(""); + expect(source).not.toContain(""); + expect(source).not.toContain(""); // Every visible stage of the workflow is a heading somebody can audit. for (const heading of [ + "## Bound the drafts and the repairs", "## Create the first draft", "## Check and repair the draft", "## Review the draft", "## Continue from your decision", - "## Explain a tenth draft that could not be repaired", + "## Explain a last draft that could not be repaired", "## Produce the approved Plan source", ]) { expect(source).toContain(`${heading}\n`); @@ -165,7 +160,9 @@ 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( - '', + "xmd plan ended unexpectedly without an approved Plan. Nothing was output." + + "\\n\\nRun the same command again with --journal plan-authorship.jsonl. If the problem " + + "repeats, include the journal in the bug report.", ); expect(source).toContain( '', @@ -173,9 +170,10 @@ describe("packaged documents", () => { // 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("reviewed ${attempts} 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, + source.split("xmd plan could not generate an approved Plan after ${attempts} 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 diff --git a/packages/cli/tests/plan-args.test.ts b/packages/cli/tests/plan-args.test.ts index 8be72cb2b..152696c68 100644 --- a/packages/cli/tests/plan-args.test.ts +++ b/packages/cli/tests/plan-args.test.ts @@ -14,6 +14,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { + JOURNAL_PATH_REFUSAL, namesPlan, namesRetiredCommand, removedOptionRefusal, @@ -33,21 +34,20 @@ const RETAINED: readonly string[][] = [ ["--session", "ada"], ["--timeout", "5s"], ["--output", "plan.md"], + ["--verbose"], + ["--journal", "authorship.jsonl"], ]; /** * 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. + * Both secret-detection spellings are named, because a table holding one of them + * would leave the other accepted and dropped by the parser. The two short + * aliases are not here: they are refused with the long spelling this command + * does define, in {@link ALIASED} below. */ const REMOVED: readonly string[][] = [ - ["--journal", "trace.jsonl"], - ["-j", "trace.jsonl"], ["--raw"], - ["--verbose"], - ["-V"], ["--timeout-exec", "5s"], ["--timeout-fetch", "5s"], ["--approve-all"], @@ -61,6 +61,19 @@ const REMOVED: readonly string[][] = [ ["--no-props-loud"], ]; +/** + * The short spellings the two authorship options deliberately do not have. + * + * `xmd run` gives `--verbose` and `--journal` these aliases for options about a + * program's run. Here they are refused with the long spelling rather than with + * the generic removal, because sending a caller to `xmd run` for an option this + * command does define would answer a question they did not ask. + */ +const ALIASED: readonly (readonly [string, string])[] = [ + ["-V", "--verbose"], + ["-j", "--journal "], +]; + describe("Tier PR — xmd plan fixed grammar", () => { it("PS1: exactly one request, kept byte for byte", function* () { expect(namesPlan(["plan", REQUEST])).toBe(true); @@ -183,8 +196,8 @@ describe("Tier PR — xmd plan fixed grammar", () => { }); 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 " + + expect(removedOptionRefusal("--raw")).toBe( + "unrecognized option for xmd plan: --raw — configure the program when you run " + "the approved source with xmd run", ); @@ -207,18 +220,126 @@ describe("Tier PR — xmd plan fixed grammar", () => { } }); + it("PO7: the two authorship options are accepted, and their aliases are not", function* () { + // Either side of the request, and beside each other. + for (const argv of [ + ["plan", REQUEST, "--verbose", "--journal", "authorship.jsonl"], + ["plan", "--verbose", "--journal", "authorship.jsonl", REQUEST], + ["plan", "--journal", "authorship.jsonl", REQUEST, "--verbose"], + ]) { + const scan = scanPlanArgs(argv); + expect(`${argv.join(" ")}: ${scan.error}`).toBe(`${argv.join(" ")}: undefined`); + expect(scan.request).toBe(REQUEST); + } + + // `--journal` takes exactly one non-empty path, read here rather than after + // parsing: an option the parser reads as absent falls back to the default, + // so a caller who asked for a journal and named none would silently get + // none at all. + expect(JOURNAL_PATH_REFUSAL).toBe( + "--journal needs a path — write `--journal ` or leave it out to record no journal", + ); + for (const empty of [ + ["plan", REQUEST, "--journal", ""], + ["plan", REQUEST, "--journal="], + // Nothing follows it at all. + ["plan", REQUEST, "--journal"], + ]) { + expect(`${empty.join(" ")}: ${scanPlanArgs(empty).error}`).toBe( + `${empty.join(" ")}: ${JOURNAL_PATH_REFUSAL}`, + ); + } + + // The short aliases are answered with the long spelling, wherever they + // stand and whichever form they take — including beside `--help`, which is + // lifted out of argv before any command's own grammar runs. + for (const [alias, spelling] of ALIASED) { + const refusal = `unrecognized option for xmd plan: ${alias} — write \`${spelling}\``; + expect(scanPlanArgs(["plan", REQUEST, alias]).error).toBe(refusal); + expect(scanPlanArgs(["plan", alias, REQUEST]).error).toBe(refusal); + expect(scanPlanArgs(["plan", REQUEST, `${alias}=x`]).error).toBe(refusal); + expect(removedPlanOption(["plan", "--help", alias])).toBe(refusal); + expect(removedPlanOption(["plan", alias, "--help"])).toBe(refusal); + // And it is the same classification both readers make. + expect(removedPlanOption(["plan", REQUEST, alias])).toBe( + scanPlanArgs(["plan", REQUEST, alias]).error, + ); + } + + // `--trace` is nobody's option here, and is answered as one. + expect(scanPlanArgs(["plan", REQUEST, "--trace"]).error).toBe( + "unrecognized option for xmd plan: --trace", + ); + }); + + it("PO7: an option that reaches this grammar after --journal is that option, not a path", function* () { + // The reported hole. Every retained spelling that reaches this grammar, + // written where the path goes: reading one as a filename would exclusively + // create a file called `--verbose` and drop what the caller actually asked + // for, and the scan used to accept all of it in silence. + // + // `--help` and `-h` are deliberately absent. They are lifted out of the + // command line before any command's own grammar runs, so this scan never + // sees them and help keeps its ordinary precedence — which is a claim about + // the command rather than about this function, and is proven as one in + // `plan-cli.test.ts`. + for (const swallowed of [ + ["--verbose"], + ["--output", "out.md"], + ["--session", "ada"], + ["--include", "lib"], + ["--agent-provider", "acpx"], + ["--default-agent", "codex"], + ["--timeout", "5s"], + ["--version"], + // Including a valued spelling, whose name is read up to its first `=`. + ["--session=ada"], + ]) { + const scan = scanPlanArgs(["plan", REQUEST, "--journal", ...swallowed]); + const written = swallowed.join(" "); + expect(`${written}: ${scan.error}`).toBe(`${written}: ${JOURNAL_PATH_REFUSAL}`); + // And the option was not consumed on the way out: nothing reached the + // parser's argv but the command and the request. + expect(`${written}: ${scan.fixed.join(" ")}`).toBe(`${written}: plan ${REQUEST}`); + expect(scan.request).toBe(REQUEST); + } + + // A removed spelling in that position keeps its own refusal, which is the + // more specific answer and was already the behavior. + expect(scanPlanArgs(["plan", REQUEST, "--journal", "--run"]).error).toBe(RUN_REMOVAL_REFUSAL); + expect(scanPlanArgs(["plan", REQUEST, "--journal", "--raw"]).error).toBe( + removedOptionRefusal("--raw"), + ); + expect(scanPlanArgs(["plan", REQUEST, "--journal", "-j"]).error).toBe( + "unrecognized option for xmd plan: -j — write `--journal `", + ); + + // And every valid ordering still parses. A path is a path even when it + // looks unusual: `-` names a file called `-`, and the inline form takes + // whatever follows the `=`. + for (const argv of [ + ["plan", REQUEST, "--journal", "authorship.jsonl", "--verbose"], + ["plan", REQUEST, "--verbose", "--journal", "authorship.jsonl"], + ["plan", "--journal", "authorship.jsonl", REQUEST, "--verbose"], + ["plan", "--verbose", REQUEST, "--journal", "authorship.jsonl"], + ["plan", REQUEST, "--journal=authorship.jsonl", "--verbose"], + ["plan", REQUEST, "--journal", "-"], + ["plan", REQUEST, "--journal=--verbose"], + ]) { + const scan = scanPlanArgs(argv); + expect(`${argv.join(" ")}: ${scan.error}`).toBe(`${argv.join(" ")}: undefined`); + expect(scan.request).toBe(REQUEST); + } + }); + 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"), - ); + expect(removedPlanOption(["plan", "--help", "--raw"])).toBe(removedOptionRefusal("--raw")); + expect(removedPlanOption(["plan", "--raw", "--help"])).toBe(removedOptionRefusal("--raw")); // And it is the same classification the scan makes, so a spelling cannot be // removed to one and unknown to the other. diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index bbbdeec01..fb732f64b 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -25,7 +25,10 @@ import { runCli } from "@executablemd/test-support/launch"; import { ensureDir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; import { stat } from "@executablemd/runtime"; import { Elicitation } from "@executablemd/core"; -import { ensure, scoped, spawn, until } from "effection"; +import type { DocumentValidation } from "@executablemd/core"; +import { serializeDurableEvent } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { ensure, Ok, scoped, spawn, until } from "effection"; import type { Operation } from "effection"; import { join } from "node:path"; import { readdir } from "node:fs/promises"; @@ -37,7 +40,11 @@ import { runPlan } 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 type { StructuralValidation } from "../src/plan-component.ts"; +import { FileStream } from "../src/file-stream.ts"; +import { createPlanJournal, planJournalStream } from "../src/plan-journal.ts"; import { + JOURNAL_PATH_REFUSAL, namesPlan, namesRetiredCommand, removedOptionRefusal, @@ -128,12 +135,19 @@ const STACK: AuthorshipStack = { }; /** One invocation, writing its approved source to stdout or to a file. */ -function planning(dir: string, output?: string, session?: string): PlanCommand { +function planning( + dir: string, + output?: string, + session?: string, + observability: { verbose?: boolean; journal?: string } = {}, +): PlanCommand { return { request: REQUEST, include: [dir], ...(output === undefined ? {} : { output }), ...(session === undefined ? {} : { session }), + verbose: observability.verbose === true, + ...(observability.journal === undefined ? {} : { journal: observability.journal }), stack: STACK, }; } @@ -218,6 +232,8 @@ const PLAN_HELP = [ "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)", + " --verbose show generated drafts and XMD check diagnostics on stderr [default: false]", + " --journal [JOURNAL] record the planning process as diagnostic JSONL (path must not exist)", " --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)", @@ -246,17 +262,25 @@ const PLAN_HELP = [ "", "A named --session continues the planning conversation. Without it, this", "invocation uses a unique session.", + "", + "Secret detection checks journal entries before they are recorded, but it may not", + "catch every sensitive detail. The journal can contain prompts, drafts, and review", + "answers.", ].join("\n"); -/** Every option `xmd plan` removed, as help and a refusal spell them. */ +/** + * Every option `xmd plan` removed, as help and a refusal spell them. + * + * The two short aliases are here and their long spellings are not: `--verbose` + * and `--journal` describe writing a Plan and are part of this grammar, while + * `-V` and `-j` are `xmd run`'s aliases for options about a program's run. + */ const REMOVED_SPELLINGS = [ "--run", "--props", "--no-props", "--raw", - "--verbose", "-V", - "--journal", "-j", "--timeout-exec", "--timeout-fetch", @@ -332,8 +356,12 @@ describe( 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. + // Matched as whole tokens, because `-j` is a substring of the + // `--journal` this command does define. for (const spelling of REMOVED_SPELLINGS) { - expect(stdout).not.toContain(spelling); + expect(`${spelling}: ${new RegExp(`(^|\\s)${spelling}\\b`, "m").test(stdout)}`).toBe( + `${spelling}: false`, + ); } expect(stdout).not.toContain("XMD_PROPS"); // Help reads no catalog and creates nothing. @@ -424,11 +452,7 @@ describe( 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"], @@ -458,11 +482,11 @@ describe( // Beside `--help`, in either order, exactly as `--run` is. for (const argv of [ - ["--help", "--journal", "trace.jsonl"], - ["--journal", "trace.jsonl", "--help"], + ["--help", "--raw"], + ["--raw", "--help"], ]) { - const { stderr } = yield* refusedEarly(argv, removedOptionRefusal("--journal")); - expect(complaints(stderr)).toBe(removedOptionRefusal("--journal")); + const { stderr } = yield* refusedEarly(argv, removedOptionRefusal("--raw")); + expect(complaints(stderr)).toBe(removedOptionRefusal("--raw")); } // A name that merely begins like a property option is an option this @@ -778,9 +802,11 @@ describe( "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. + // Refused before the catalog, a directory, a provider, a turn or a + // review existed. The catalog is built by `` now, and this + // refusal happens before the command document starts at all. expect(untouched(harness)).toEqual({ - catalogs: 1, + catalogs: 0, runtimes: 0, started: false, turns: 0, @@ -911,3 +937,779 @@ describe( }); }, ); + +/** The escape a terminal renderer introduces and a pipe never sees. */ +const ANSI = "\u001b["; + +/** + * A synthetic GitHub token, format-realistic and assembled at run time. + * + * Built rather than written, so no usable-looking literal enters the repository + * and this file does not trip the scanning it is about. + */ +function canary(): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + return ["ghp", "_", alphabet.slice(0, 36)].join(""); +} + +/** The same shape, with nothing in it a scanner objects to. */ +const SAFE_VALUE = "not-a-credential-at-all"; + +/** A draft holding the canary, so the turn that produced it is refused. */ +function canaryDraft(): string { + return ["# Uses a token", "", `The value is ${canary()}.`, ""].join("\n"); +} + +/** The same draft, clean: the control that shows the verbose branch does run. */ +const CLEAN_DRAFT = ["# Uses a token", "", `The value is ${SAFE_VALUE}.`, ""].join("\n"); + +/** The phase headings an operator read, in order. */ +function phasesOf(transcript: string): string[] { + return transcript + .split("\n") + .filter((line) => line.startsWith("## ")) + .map((line) => line.slice(3)); +} + +/** One journal file, read back as the NDJSON sequence it is. */ +function* journalEvents(path: string): Operation { + const text = yield* readTextFile(path); + return text + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); +} + +/** + * The records a journal file has certainly committed. + * + * Only the newline-terminated ones. An append this command made and the + * filesystem then failed is not a transaction — `appendFile` can write some of + * a record and stop — so what a case about a *failed* append may claim is the + * sequence that committed before it, and anything after the last terminator is + * not part of that sequence. A case about an ending where no append failed + * reads the whole file instead, and compares its bytes. + */ +function* committedJournal(path: string): Operation { + const text = yield* readTextFile(path); + const terminated = text.slice(0, text.lastIndexOf("\n") + 1); + return terminated + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); +} + +/** + * Tier PO — observable `xmd plan` authorship + * (specs/plan-command-spec.md). + * + * Rows PO6-PO15, the half of the tier that is about channels rather than + * phases: which stream each thing lands on, what a `--journal` file holds, what + * the secret boundary keeps out of both, and what happens when a destination + * stops accepting. The authored phases and their counters are proven against + * the packaged document itself, in `plan-command-document.test.ts`. + * + * Progress is observed through the host dependency the CLI supplies, so a case + * reads the same chunks `process.stderr` would have been handed, in the same + * order, without a real terminal or a real pipe in the evidence. + */ +describe( + "Tier PO — observable xmd plan authorship", + { sanitizeOps: false, sanitizeResources: false }, + () => { + it("PO6: progress is stderr's and the approved bytes are stdout's", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const piped = createPlanHarness({ authorshipRoot }); + piped.fake.script({ reply: EFFECT_AND_FAILURE }); + piped.script({ decision: "Approve" }); + + const { value, chunks } = yield* delivered(() => runPlan(planning(dir), piped.deps)); + + expect(value).toBe(0); + // Stdout carries the approved source and nothing else — no phase, no + // heading, no newline this command added. + expect(chunks).toEqual([EFFECT_AND_FAILURE]); + // A stated pipe receives normalized Markdown with no terminal + // formatting in it at all. + const transcript = piped.progress.join(""); + expect(transcript).toContain("## Preparing the Plan"); + expect(transcript).not.toContain(ANSI); + // And the progress channel never carried the source. + expect(transcript).not.toContain(EFFECT_AND_FAILURE.trim()); + }); + + // A host that states its stderr is a terminal gets terminal formatting on + // that stream, and the artifact stays byte-identical. The observable is + // the verbose draft block: rendered for a terminal, a fenced block + // becomes indented text, while a pipe receives the fence itself. Colour + // is not asserted — chalk decides that from the stream it is writing to, + // and in a test process it decides against it. + const rendered: Record = {}; + for (const terminal of [false, true]) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const out = join(dir, "release.md"); + const harness = createPlanHarness({ authorshipRoot, terminal }); + harness.fake.script({ reply: EFFECT_AND_FAILURE }); + harness.script({ decision: "Approve" }); + + const { value, chunks } = yield* delivered(() => + runPlan(planning(dir, out, undefined, { verbose: true }), harness.deps), + ); + + expect(`${terminal}: ${value}`).toBe(`${terminal}: 0`); + rendered[String(terminal)] = harness.progress.join(""); + // Whichever stderr this is, the artifact holds the exact approved + // bytes and stdout holds nothing. + expect(yield* readTextFile(out)).toBe(EFFECT_AND_FAILURE); + expect(chunks).toEqual([]); + }); + } + + // The pipe received the Markdown as written; the terminal received it + // rendered, with the fence turned into indentation. + expect(rendered.false).toContain("```markdown"); + expect(rendered.false).toContain("\n## Generated draft"); + expect(rendered.true).not.toContain("```markdown"); + expect(rendered.true).toContain(" # A program nobody asked to run"); + expect(rendered.true).toContain("Generated draft"); + }); + + it("PO7: both authorship options work either side of the request; the aliases do not", function* () { + // Accepted before and after the request, together, through the real + // parser — and with an impossible agent, so what is proven is that the + // grammar let the invocation reach authorship rather than that the + // invocation succeeded. + yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + for (const args of [ + [REQUEST, "--verbose", "--journal", "before.jsonl"], + ["--verbose", "--journal", "after.jsonl", REQUEST], + ]) { + const result = yield* runCli( + ["plan", ...args, "--default-agent", "xmd-nonexistent-agent"], + { cwd: dir, env: { HOME: home } }, + ).join(); + + // Neither spelling was refused by fixed grammar: the invocation got + // as far as the agent it cannot start. + expect(`${args.join(" ")}: ${result.stderr.includes("unrecognized option")}`).toBe( + `${args.join(" ")}: false`, + ); + expect(`${args.join(" ")}: ${result.stdout}`).toBe(`${args.join(" ")}: `); + } + }); + + // The short aliases are answered with the long spelling, and reach no + // catalog, session, provider or file. + for (const [alias, spelling] of [ + ["-V", "--verbose"], + ["-j", "--journal "], + ] as const) { + const refusal = `unrecognized option for xmd plan: ${alias} — write \`${spelling}\``; + const { stderr } = yield* refusedEarly([REQUEST, alias], refusal); + expect(complaints(stderr)).toBe(refusal); + } + // And `--trace` is nobody's option here. + yield* refusedEarly([REQUEST, "--trace"], "unrecognized option for xmd plan: --trace"); + }); + + it("PO7: a retained option written where the journal path goes refuses before any phase", function* () { + // `refusedEarly` carries an agent name nothing resolves and an isolated + // `HOME`, so reaching the catalog, the provider or a session placement + // would each leave a trace. None of them does — and neither does the + // filesystem: the switch that was about to become a filename is not one. + // + // Every retained spelling that reaches this command's own grammar. Help + // is not among them and is the case after next: it is lifted out of the + // command line before any command's grammar runs. + for (const swallowed of [ + ["--verbose"], + ["--output", "out.md"], + ["--session", "ada"], + ["--include", "lib"], + ["--timeout", "5s"], + ["--version"], + ]) { + const { stderr } = yield* refusedEarly( + [REQUEST, "--journal", ...swallowed], + JOURNAL_PATH_REFUSAL, + ); + expect(`${swallowed.join(" ")}: ${complaints(stderr)}`).toBe( + `${swallowed.join(" ")}: ${JOURNAL_PATH_REFUSAL}`, + ); + } + + yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + const result = yield* runCli( + ["plan", REQUEST, "--journal", "--verbose", "--output", "release.md"], + { cwd: dir, env: { HOME: home } }, + ).join(); + + expect(result.code).toBe(1); + expect(complaints(result.stderr)).toBe(JOURNAL_PATH_REFUSAL); + // Named exactly: the option was never exclusively created as a journal, + // no journal exists under any name, and the artifact sink was never + // reached either. + expect(yield* exists(join(dir, "--verbose"))).toBe(false); + expect(yield* exists(join(dir, "release.md"))).toBe(false); + expect((yield* until(readdir(dir))).sort()).toEqual(["home"]); + // And no catalog, provider or session placement happened on the way. + 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); + }); + + // Help is not one of them. `--help` and `-h` are removed from the command + // line before any command's own grammar runs, and this missing-value + // check is deliberately not moved ahead of that: ordinary help stays + // ordinary help, and pre-help refusal is reserved for the options this + // command removed. So help wins, exits successfully, and still creates no + // journal and begins no authorship. + for (const argv of [ + [REQUEST, "--journal", "--help"], + [REQUEST, "--journal", "-h"], + [REQUEST, "--help", "--journal"], + ]) { + yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + const result = yield* runCli( + ["plan", ...argv, "--default-agent", "xmd-nonexistent-agent"], + { cwd: dir, env: { HOME: home } }, + ).join(); + + const written = argv.join(" "); + expect(`${written}: ${result.code}`).toBe(`${written}: 0`); + expect(result.stdout).toContain("Usage: xmd plan [OPTIONS] [request]"); + expect(result.stdout).toContain("--journal [JOURNAL]"); + // The scan's refusal is never reported: help answered instead. + expect(`${written}: ${result.stderr.includes(JOURNAL_PATH_REFUSAL)}`).toBe( + `${written}: false`, + ); + // And help created nothing and reached nothing, exactly as it does + // beside every other option: no journal under any name, no catalog, + // no provider and no session directory. + expect((yield* until(readdir(dir))).sort()).toEqual(["home"]); + expect(result.stdout).not.toContain("## Built-in components"); + expect(result.stderr).not.toContain("unavailable"); + expect(yield* exists(join(home, ".xmd"))).toBe(false); + }); + } + + // The valid orderings still reach authorship rather than the grammar. + yield* useWorkingDirectory(function* (dir) { + const home = join(dir, "home"); + yield* ensureDir(home); + for (const args of [ + [REQUEST, "--journal", "kept.jsonl", "--verbose"], + ["--verbose", "--journal", "kept.jsonl", REQUEST], + ]) { + const result = yield* runCli( + ["plan", ...args, "--default-agent", "xmd-nonexistent-agent"], + { cwd: dir, env: { HOME: home } }, + ).join(); + expect(`${args.join(" ")}: ${result.stderr.includes(JOURNAL_PATH_REFUSAL)}`).toBe( + `${args.join(" ")}: false`, + ); + } + }); + }); + + it("PO8: no journal writes no file, and one records authorship as ordinary JSONL", function* () { + // Without `--journal`, nothing is created anywhere. + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAIN }); + harness.script({ decision: "Approve" }); + + const { value } = yield* delivered(() => runPlan(planning(dir), harness.deps)); + + expect(value).toBe(0); + expect(yield* until(readdir(dir))).toEqual([]); + }); + + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "authorship.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + // A Plan that writes a file and then fails, so "no later program run" + // is a fact about this journal rather than an absence nothing could + // have produced. + harness.fake.script({ reply: EFFECT_AND_FAILURE }); + // The file has to exist before the work it records, so this is read + // from inside the review — with the turn already committed and the + // approval not yet given. + const answered = { existed: false, entries: 0 }; + harness.deps.installElicitation = function* () { + yield* Elicitation.around( + { + *elicit([request], _next) { + harness.reviews.push(request); + answered.existed = yield* exists(journal); + answered.entries = (yield* readTextFile(journal)) + .split("\n") + .filter(Boolean).length; + return { decision: "Approve" }; + }, + }, + { at: "min" }, + ); + }; + + const { value, chunks } = yield* delivered(() => + runPlan(planning(dir, undefined, undefined, { journal }), harness.deps), + ); + + expect(value).toBe(0); + expect(chunks.join("")).toBe(EFFECT_AND_FAILURE); + // Created before the catalog, the session and the turn: by the review + // it already holds the entries those phases committed. + expect(answered.existed).toBe(true); + expect(answered.entries).toBeGreaterThan(0); + + // The whole trace parses as the existing NDJSON sequence, in commit + // order, and ends terminally. + const events = yield* journalEvents(journal); + expect(events.length).toBeGreaterThan(answered.entries); + expect(events.at(-1)?.type).toBe("close"); + // It records authorship and no later program run. The approved source + // is *in* the file, because a draft is retained content — what is not + // there is any effect of running it: no execution event, and none of + // the file the program writes. + expect(yield* until(readdir(dir))).toEqual(["authorship.jsonl"]); + const kinds = new Set( + events + .filter((event) => event.type === "yield") + .map((event) => String(Reflect.get(Object(event.description), "type"))), + ); + expect([...kinds].filter((kind) => /exec|process|command|write/.test(kind))).toEqual([]); + }); + }); + + it("PO9: an existing journal is refused untouched, before anything else happens", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "kept.jsonl"); + yield* writeTextFile(journal, "keep me\n"); + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAIN }); + harness.script({ decision: "Approve" }); + + const { value, lines } = yield* reported(() => + runPlan(planning(dir, join(dir, "release.md"), undefined, { journal }), harness.deps), + ); + + expect(value).toBe(1); + expect(lines.join("\n")).toBe( + `Journal file already exists: ${journal}. Choose a different --journal path.`, + ); + // Byte-identical, and nothing downstream of the refusal happened: no + // catalog, no provider, no session, no turn, no review and no artifact. + expect(yield* readTextFile(journal)).toBe("keep me\n"); + expect(untouched(harness)).toEqual({ + catalogs: 0, + runtimes: 0, + started: false, + turns: 0, + reviews: 0, + }); + expect(yield* until(readdir(dir))).toEqual(["kept.jsonl"]); + expect(harness.progress).toEqual([]); + }); + + // A path this command cannot create at all gets the other refusal, whole. + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "missing", "trace.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAIN }); + + const { value, lines } = yield* reported(() => + runPlan(planning(dir, undefined, undefined, { journal }), harness.deps), + ); + + expect(value).toBe(1); + const paragraphs = lines.join("\n").split("\n\n"); + expect(paragraphs[0].startsWith(`Could not create journal file ${journal}: `)).toBe(true); + expect(paragraphs.slice(1)).toEqual(["Choose a different --journal path and try again."]); + expect(untouched(harness)).toEqual({ + catalogs: 0, + runtimes: 0, + started: false, + turns: 0, + reviews: 0, + }); + }); + }); + + it("PO10: a secret in a draft reaches neither the progress nor the journal", function* () { + // The control first: the same shape without the canary is visible under + // `--verbose`, so an absent draft below is the gate's doing rather than a + // verbose branch that never ran. + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "clean.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: CLEAN_DRAFT }); + harness.script({ decision: "Approve" }); + + const { value } = yield* delivered(() => + runPlan(planning(dir, undefined, undefined, { journal, verbose: true }), harness.deps), + ); + + expect(value).toBe(0); + expect(harness.progress.join("")).toContain(SAFE_VALUE); + expect(yield* readTextFile(journal)).toContain(SAFE_VALUE); + }); + + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "tainted.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: canaryDraft() }); + + const { value, chunks } = yield* delivered(() => + reported(() => + runPlan( + planning(dir, join(dir, "release.md"), undefined, { journal, verbose: true }), + harness.deps, + ), + ), + ); + + expect(value.value).toBe(1); + // The gate is what ended it, rather than a scenario that stopped for + // some other reason: the run reports the rejection, and reports it + // without repeating what it found. + expect(value.lines.join("\n")).toContain( + "secret detection rejected content before it was persisted", + ); + expect(value.lines.join("\n")).not.toContain(canary()); + // The supplying event never cleared the pre-append gate, so the draft + // binding never existed and the verbose phase after it was unreachable. + const transcript = harness.progress.join(""); + expect(transcript).not.toContain(canary()); + expect(phasesOf(transcript)).not.toContain("Generated draft"); + // Nor is it in the file — while the prefix committed before it is + // still there and still parses. + expect(yield* readTextFile(journal)).not.toContain(canary()); + expect((yield* journalEvents(journal)).length).toBeGreaterThan(0); + // Teardown completed and nothing was delivered. + expect(harness.fake.closes.length).toBeGreaterThan(0); + expect(chunks).toEqual([]); + expect((yield* until(readdir(dir))).sort()).toEqual(["tainted.jsonl"]); + }); + }); + + it("PO11: a secret in a failed check's diagnostics is kept out of both, too", function* () { + /** A structural refusal whose message carries `secret`. */ + const refusing = (secret: string): StructuralValidation => + // deno-lint-ignore require-yield + function* (): Operation { + return { + version: 1, + outcome: "invalid", + diagnostics: [ + { code: "component-unresolved", message: `no component answers ${secret}` }, + ], + invocations: [], + }; + }; + + // The control: a clean diagnostic is displayed and recorded. + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "clean.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + harness.deps.validate = refusing(SAFE_VALUE); + for (const _draft of [0, 1, 2, 3]) { + harness.fake.script({ reply: PLAIN }); + } + harness.script({ decision: "Stop" }); + + const { value } = yield* reported(() => + runPlan(planning(dir, undefined, undefined, { journal, verbose: true }), harness.deps), + ); + + expect(value).toBe(1); + expect(harness.progress.join("")).toContain(SAFE_VALUE); + expect(yield* readTextFile(journal)).toContain(SAFE_VALUE); + }); + + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "tainted.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + harness.deps.validate = refusing(canary()); + harness.fake.script({ reply: PLAIN }); + + const { value, chunks } = yield* delivered(() => + reported(() => + runPlan( + planning(dir, join(dir, "release.md"), undefined, { journal, verbose: true }), + harness.deps, + ), + ), + ); + + expect(value.value).toBe(1); + expect(value.lines.join("\n")).toContain( + "secret detection rejected content before it was persisted", + ); + expect(value.lines.join("\n")).not.toContain(canary()); + const transcript = harness.progress.join(""); + expect(transcript).not.toContain(canary()); + expect(phasesOf(transcript)).not.toContain("Problems found in the draft"); + expect(yield* readTextFile(journal)).not.toContain(canary()); + // The prefix committed before the refused check is readable. + expect((yield* journalEvents(journal)).length).toBeGreaterThan(0); + expect(chunks).toEqual([]); + expect((yield* until(readdir(dir))).sort()).toEqual(["tainted.jsonl"]); + }); + }); + + it("PO12: an entry the journal will not take ends authorship and keeps the prefix", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "partial.jsonl"); + const harness: PlanHarness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAIN }); + harness.script({ decision: "Approve" }); + + // The real exclusive creation, then a backing stream that writes the + // same file until the first turn is under way and refuses everything + // after it. Refusing by that point rather than by a count is what makes + // the row about a journal that failed *during* authorship: a provider + // exists to tear down, and a readable prefix is already on disk. The + // diagnostic a case reads back is the command's own translation rather + // than a message the case wrote. + harness.deps.journal = function* (path) { + const created = yield* createPlanJournal(path); + if (!created.ok) { + return created; + } + const file = new FileStream(path); + return Ok( + planJournalStream(path, { + readAll: () => file.readAll(), + *append(event) { + if (harness.fake.prompts.length > 0) { + throw new Error("EACCES: permission denied, open 'partial.jsonl'"); + } + yield* file.append(event); + }, + }), + ); + }; + + const { value, chunks } = yield* delivered(() => + reported(() => + runPlan(planning(dir, join(dir, "release.md"), undefined, { journal }), harness.deps), + ), + ); + + expect(value.value).toBe(1); + expect(value.lines.join("\n")).toContain( + `Could not write the next entry to journal file ${journal}: ` + + "EACCES: permission denied, open 'partial.jsonl'", + ); + expect(value.lines.join("\n")).toContain( + "The journal still contains the entries recorded before this failure.", + ); + // The entries that committed before the refusal are still there and + // still parse — a preserved prefix. The last of them is the turn whose + // result the file would not take. + // + // Read as terminated records rather than as the whole file: a failed + // filesystem append is not atomic, so what this row is entitled to + // claim is what committed before it, not that the refused append left + // no bytes at all. + const prefix = yield* committedJournal(journal); + expect(prefix.length).toBeGreaterThan(0); + expect(prefix.at(-1)?.type).toBe("yield"); + // Teardown completed and nothing was delivered. + expect(harness.fake.closes.length).toBeGreaterThan(0); + expect(chunks).toEqual([]); + expect((yield* until(readdir(dir))).sort()).toEqual(["partial.jsonl"]); + }); + }); + + it("PO16: an ordinary failure leaves a whole, readable journal behind", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const journal = join(dir, "ordinary.jsonl"); + const harness = createPlanHarness({ authorshipRoot }); + // A turn that produced text and then failed. Nothing about this ending + // is a secret rejection or a write failure: the file took every entry + // it was offered, and authorship ended for a reason of its own. + harness.fake.script({ reply: PLAIN, stopReason: "refusal" }); + + const { value, chunks } = yield* delivered(() => + reported(() => + runPlan(planning(dir, join(dir, "release.md"), undefined, { journal }), harness.deps), + ), + ); + + expect(value.value).toBe(1); + // The ending is the turn's, and it is neither of the two failures that + // have a journal diagnostic of their own. + const said = value.lines.join("\n"); + expect(said).toContain("refusal"); + expect(said).not.toContain("secret detection rejected content"); + expect(said).not.toContain("Could not write the next entry to journal file"); + // No approved source on stdout, and no artifact. + expect(chunks).toEqual([]); + expect((yield* until(readdir(dir))).sort()).toEqual(["ordinary.jsonl"]); + // Teardown completed before `runPlan` returned: the provider closed, + // and the invocation's own session directory went back. + expect(harness.fake.closes.length).toBeGreaterThan(0); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); + + // At least one event committed before the failure, and the whole file + // parses: every line is a complete durable event, in commit order. + const recorded = yield* readTextFile(journal); + const events = yield* journalEvents(journal); + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(typeof event.type).toBe("string"); + } + // And nothing partial is left at the end. Re-serializing what parsed + // reproduces the file byte for byte, so there is no truncated record, + // no half-written line and no missing terminator — which a `JSON.parse` + // sweep alone would not catch, because it never sees dropped bytes. + expect(recorded).toBe(events.map(serializeDurableEvent).join("")); + expect(recorded.endsWith("\n")).toBe(true); + }); + }); + + it("PO13: a progress destination that fails cancels authorship and delivers nothing", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness: PlanHarness = createPlanHarness({ + authorshipRoot, + // The first chunk lands; the second is held until the turn it + // announced is actually in flight, and then refused. A destination + // that refused everything would prove only that nothing was ever + // written, and a synchronous refusal would race the producer. + *refuseProgress(_chunk, index) { + if (index !== 1) { + return undefined; + } + yield* harness.fake.startedTurns(1); + return new Error("EPIPE: broken pipe, write"); + }, + }); + // Never settles on its own: the only way out of this turn is the + // cancellation the failed write causes. + harness.fake.script({ reply: PLAIN, manual: true }); + + const { value, chunks } = yield* delivered(() => + runPlan(planning(dir, join(dir, "release.md")), harness.deps), + ); + + expect(value).toBe(1); + // The live turn was cancelled and every owned teardown finished before + // `runPlan` returned: the turn was cancelled, the provider was closed, + // and the invocation's own session directory was handed back. + expect(harness.fake.cancels).toBeGreaterThanOrEqual(1); + expect(harness.fake.closes.length).toBeGreaterThan(0); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); + // The bytes the destination had already accepted are not rolled back, + // and the exact diagnostic reached it once a later write succeeded. + expect(harness.progress[0]).toContain("Preparing the Plan"); + expect(harness.progress.at(-1)).toBe( + "Could not write planning progress to stderr: EPIPE: broken pipe, write\n\n" + + "Planning was cancelled, and no Plan was output.\n", + ); + // No stdout fallback, no artifact, and no review. + expect(chunks).toEqual([]); + expect(harness.reviews).toHaveLength(0); + expect(yield* until(readdir(dir))).toEqual([]); + }); + }); + + it("PO14: every existing ending keeps its order, and progress claims no delivery", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const out = join(dir, "release.md"); + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: EFFECT_AND_FAILURE }); + + // The artifact is still created after the whole authorship frame has + // torn down, and the last thing an operator was told is that the + // session was closing — never that a file exists. + const events: string[] = []; + let duringTeardown = true; + harness.deps.installElicitation = function* () { + yield* ensure(function* () { + events.push("teardown"); + duringTeardown = yield* exists(out); + }); + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit([request], _next) { + harness.reviews.push(request); + events.push("review"); + return { decision: "Approve" }; + }, + }, + { at: "min" }, + ); + }; + + const { value, chunks } = yield* delivered(() => runPlan(planning(dir, out), harness.deps)); + + expect(value).toBe(0); + expect(events).toEqual(["review", "teardown"]); + expect(duringTeardown).toBe(false); + expect(yield* readTextFile(out)).toBe(EFFECT_AND_FAILURE); + expect(chunks).toEqual([]); + + // Finalizing is the last phase, and it says the session is closing + // rather than that a Plan was produced, written or output. + const transcript = harness.progress.join(""); + expect(phasesOf(transcript).at(-1)).toBe("Finalizing the Plan"); + for (const claim of ["Plan produced", "Wrote", "written to", "was output"]) { + expect(`${claim}: ${transcript.includes(claim)}`).toBe(`${claim}: false`); + } + }); + + // Cancellation mid-turn: the progress already delivered stands, and no + // phase after it claims anything. + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAIN, manual: true }); + + yield* scoped(function* () { + const running = yield* spawn(() => + runPlan(planning(dir, join(dir, "release.md")), harness.deps), + ); + yield* harness.fake.startedTurns(1); + yield* running.halt(); + }); + + expect(harness.fake.cancels).toBeGreaterThanOrEqual(1); + expect(phasesOf(harness.progress.join(""))).not.toContain("Finalizing the Plan"); + expect(yield* until(readdir(dir))).toEqual([]); + }); + }); + + it("PO15: the catalog is built once, from inside the command document", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); + harness.fake.script({ reply: PLAIN }); + harness.script({ decision: "Approve" }); + + // Recorded against the progress the drain had at the time, so what this + // observes is that Preparing reached the operator before the catalog + // was read rather than merely that both happened. + const before: string[][] = []; + const catalog = harness.deps.catalog; + harness.deps.catalog = function* (includes) { + before.push(phasesOf(harness.progress.join(""))); + return yield* catalog(includes); + }; + + const { value } = yield* delivered(() => runPlan(planning(dir), harness.deps)); + + expect(value).toBe(0); + expect(harness.catalogCalls).toEqual([[dir]]); + expect(before).toEqual([["Preparing the Plan"]]); + }); + }); + }, +); diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 13bac3d80..7e1746fa3 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -11,27 +11,37 @@ * able to supply `Loop`, `If`, `Return`, `Fail`, `CodeBlock` or the validator: * the workflow under test is the one the packaged Component owns, resolved against * first-party declarations only. + * + * Tier PO's authored half lives here — the phases an operator reads, in the + * order the work happens, with the counters the document's own bounds produce. + * Progress is drained from `execution.output` exactly as the command drains it, + * so what a row observes is the channel a person actually watches rather than a + * transcript assembled afterwards. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, scoped } from "effection"; +import { ensure, scoped, sleep, spawn } from "effection"; import type { Operation } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; import { ensureDir, rm } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { agentIdentityComponents, - collect, installAgentComponents, retainedSource, + useNormalizedOutput, } from "@executablemd/core"; -import type { ElicitationRequest, Json } from "@executablemd/core"; +import type { DocumentValidation, ElicitationRequest, Json } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import { InMemoryStream } from "@executablemd/durable-streams"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; import { PLAN_COMMAND_IDENTITY } from "../src/authorship-profile.ts"; +import type { PlanSurface } from "../src/plan-component.ts"; import { AGENT, planDeclarationHarness, useWorkingDirectory } from "./support/plan-harness.ts"; +import type { ScriptedReview } from "./support/plan-harness.ts"; +import type { ScriptedTurn } from "./support/fake-acp.ts"; /** * A candidate whose exact bytes are worth preserving. @@ -61,59 +71,126 @@ const CANDIDATE = [ "", ].join("\n"); +/** The answer a case that is not about validation wants: this is a program. */ +// deno-lint-ignore require-yield +function* sound(): Operation { + return { version: 1, outcome: "valid", diagnostics: [], invocations: [] }; +} + +/** One structural refusal, with a diagnostic a verbose row can look for. */ +// deno-lint-ignore require-yield +function* unsound(): Operation { + return { + version: 1, + outcome: "invalid", + diagnostics: [{ code: "component-unresolved", message: "no component answers " }], + invocations: [], + }; +} + /** What the command document asked the structural check about, in order. */ interface CommandRun { validated: string[]; reviews: ElicitationRequest[]; prompts: string[]; + /** Every progress chunk the drain received, in arrival order. */ + progress: string[]; + /** When the catalog was built, as a marker in {@link phases} order. */ + events: string[]; value: Json | undefined; failure: string | undefined; } -function* runDocument(): Operation { +interface RunOptions { + /** The turns the agent answers with, in order. One approved draft by default. */ + turns?: readonly ScriptedTurn[]; + /** The review answers, in order. One Approve by default. */ + reviews?: readonly ScriptedReview[]; + /** Whether this command asked for drafts and check diagnostics. */ + verbose?: boolean; + /** Which surface declares ``. The command surface by default. */ + surface?: PlanSurface; + /** How each candidate is answered, in order; the last answer repeats. */ + validations?: readonly (() => Operation)[]; + /** Run beside the execution, with the progress this drain has so far. */ + observe?(run: { progress: string[]; events: string[] }): Operation; +} + +function* runDocument(options: RunOptions = {}): Operation { const source = yield* readPackagedDocument(PLAN_COMMAND_DOCUMENT); let value: Json | undefined; let failure: string | undefined; + const progress: string[] = []; + const events: string[] = []; + const validations = [...(options.validations ?? [sound])]; const harness = yield* scoped(function* () { return yield* planDeclarationHarness({ - surface: "command", + surface: options.surface ?? "command", authorshipRoot: yield* authorshipRoot(), session: SESSION, explicitSession: true, - syntax: "## Built-in components\n\n### ``\n", + ...(options.verbose === undefined ? {} : { verbose: options.verbose }), + // deno-lint-ignore require-yield + *catalog() { + events.push("catalog"); + return "## Built-in components\n\n### ``\n"; + }, + *validate(): Operation { + const answer = validations.length > 1 ? validations.shift() : validations[0]; + return yield* (answer ?? sound)(); + }, }); }); - harness.fake.script({ reply: CANDIDATE }); - harness.script({ decision: "Approve" }); + for (const turn of options.turns ?? [{ reply: CANDIDATE }]) { + harness.fake.script(turn); + } + for (const review of options.reviews ?? [{ decision: "Approve" }]) { + harness.script(review); + } yield* scoped(function* () { // The agent words and this execution's prompt bookkeeping, as the command // installs them. No root provider: the ceiling the Plan is written under is // the one the Component installs around its own content. yield* installAgentComponents({ defaultAgent: AGENT, permissionMode: "deny-all" }); + // Exactly what the command installs around this execution. A raw capture + // would show an operator whitespace nobody wrote. + yield* useNormalizedOutput(); try { - value = yield* collect( - yield* executeInstalled( + const execution = yield* executeInstalled( + { + ...retainedSource(PLAN_COMMAND_IDENTITY, source), + stream: new InMemoryStream(), + includes: [], + secretDetection: true, + props: { + request: REQUEST, + session: SESSION, + }, + }, + [ { - ...retainedSource(PLAN_COMMAND_IDENTITY, source), - stream: new InMemoryStream(), - includes: [], - props: { - request: REQUEST, - syntax: "## Built-in components\n\n### ``\n", - session: SESSION, - }, + components: agentIdentityComponents(), + declarations: [harness.declaration], }, - [ - { - components: agentIdentityComponents(), - declarations: [harness.declaration], - }, - ], - ), + ], ); + if (options.observe !== undefined) { + yield* spawn(() => options.observe!({ progress, events })); + } + // deno-lint-ignore require-yield + yield* forEach(function* (chunk: string) { + progress.push(chunk); + events.push(chunk); + }, execution.output); + const completed = yield* execution; + if (completed.ok) { + value = completed.value; + } else { + failure = completed.error.message; + } } catch (error) { failure = error instanceof Error ? error.message : String(error); } @@ -123,6 +200,8 @@ function* runDocument(): Operation { validated: harness.checked, reviews: harness.reviews, prompts: harness.fake.prompts, + progress, + events, value, failure, }; @@ -140,6 +219,39 @@ function* authorshipRoot(): Operation { return root; } +/** The phase headings an operator read, in the order they arrived. */ +function phases(chunks: readonly string[]): string[] { + return chunks + .join("") + .split("\n") + .filter((line) => line.startsWith("## ")) + .map((line) => line.slice(3)); +} + +/** The same, over a mixed marker/chunk sequence, with the markers kept. */ +function timeline(events: readonly string[]): string[] { + return events.flatMap((event) => + event === "catalog" ? ["catalog"] : phases([event]).map((phase) => `phase: ${phase}`), + ); +} + +/** Everything the transcript said that no progress phase put there. */ +function unattributed(chunks: readonly string[]): string[] { + const lines = chunks.join("").split("\n"); + const kept: string[] = []; + let inside = false; + for (const line of lines) { + if (line.startsWith("## ")) { + inside = true; + continue; + } + if (!inside && line.trim().length > 0) { + kept.push(line); + } + } + return kept; +} + describe("the packaged plan command document", () => { it("C2: returns the approved candidate's exact bytes and never reaches exhaustion", function* () { const run = yield* useWorkingDirectory(function* () { @@ -193,4 +305,342 @@ describe("the packaged plan command document", () => { expect(Reflect.get(Object(Reflect.get(schema, "if")), "type")).toBe("object"); expect(Reflect.get(Object(Reflect.get(schema, "then")), "type")).toBe("object"); }); + + it("PO1: every phase precedes the work it announces, and arrives while it runs", function* () { + const run = yield* useWorkingDirectory(function* () { + return yield* runDocument(); + }); + + // Each phase stands before the operation it describes. `catalog` is the + // marker the harness records where `` builds the vocabulary, so + // Preparing being ahead of it is the whole claim: before this was moved + // behind ``, no authored phase could precede that work at all. + expect(timeline(run.events)).toEqual([ + "phase: Preparing the Plan", + "catalog", + "phase: Drafting the Plan", + "phase: Checking the draft", + "phase: Waiting for your review", + "phase: Finalizing the Plan", + ]); + + // And the adapter contributed nothing of its own: every non-blank line in + // the transcript belongs to a phase this document authored. + expect(unattributed(run.progress)).toEqual([]); + }); + + it("PO1: an early phase reaches the operator while the turn is still blocked", function* () { + // The negative control for buffering. A turn that never settles holds the + // execution open forever, so anything already delivered was delivered + // *during* the work rather than summarized after it. A command that + // buffered its transcript would have delivered nothing here. + const seen: string[][] = []; + yield* useWorkingDirectory(function* () { + yield* scoped(function* () { + const running = yield* spawn(() => + runDocument({ + turns: [{ reply: CANDIDATE, manual: true }], + reviews: [], + observe: function* (live) { + // Nothing here waits on the execution: it watches the same array + // the drain appends to, and settles as soon as the blocked turn's + // own phase has arrived. + while (!phases(live.progress).includes("Drafting the Plan")) { + yield* sleep(1); + } + seen.push(phases(live.progress)); + }, + }), + ); + // The turn is in flight and will never finish on its own. + yield* untilObserved(seen); + yield* running.halt(); + }); + }); + + // Preparing and Drafting had both reached the operator, and no phase that + // depends on the turn finishing had. + expect(seen[0]).toEqual(["Preparing the Plan", "Drafting the Plan"]); + }); + + it("PO2: repair and attempt counters come from the document's own bounds", function* () { + const run = yield* useWorkingDirectory(function* () { + return yield* runDocument({ + // One invalid attempt, its three repairs, then a requested change whose + // replacement passes. + turns: [ + { reply: "\n" }, + { reply: "\n" }, + { reply: "\n" }, + { reply: "\n" }, + { reply: CANDIDATE }, + ], + reviews: [{ decision: "Request changes", feedback: "try again" }, { decision: "Approve" }], + // Four refusals — the base draft and its three repairs — then sound. + validations: [unsound, unsound, unsound, unsound, sound], + }); + }); + + expect(run.failure).toBe(undefined); + expect(run.value).toBe(CANDIDATE); + + // A check before every result, a repair between each pair, and the review + // only once the repair budget is spent. + expect(phases(run.progress)).toEqual([ + "Preparing the Plan", + "Drafting the Plan", + "Checking the draft", + "Repairing the draft", + "Checking the draft", + "Repairing the draft", + "Checking the draft", + "Repairing the draft", + "Checking the draft", + "Waiting for your review", + "Revising the Plan", + "Checking the draft", + "Waiting for your review", + "Finalizing the Plan", + ]); + + const transcript = run.progress.join(""); + // The repair ordinals are the loop's own counter rendered as words, and + // they stop at the bound rather than at a number written beside it. + expect(transcript).toContain( + "This is the 1st of up to 3 repairs for the current Plan attempt.", + ); + expect(transcript).toContain( + "This is the 2nd of up to 3 repairs for the current Plan attempt.", + ); + expect(transcript).toContain( + "This is the 3rd of up to 3 repairs for the current Plan attempt.", + ); + expect(transcript).not.toContain("4th of up to 3"); + // Requesting changes announces the next attempt, not another first one. + expect(transcript).toContain("This is the 1st of up to 10 attempts."); + expect(transcript).toContain("This is the 2nd of up to 10 attempts."); + expect(transcript).not.toContain("3rd of up to 10 attempts"); + }); + + it("PO2: the attempt counter reaches the tenth and stops there", function* () { + const run = yield* useWorkingDirectory(function* () { + return yield* runDocument({ + turns: Array.from({ length: 10 }, () => ({ reply: CANDIDATE })), + reviews: [ + ...Array.from( + { length: 9 }, + (_unused, round): ScriptedReview => ({ + decision: "Request changes", + feedback: `round ${round + 1}`, + }), + ), + { decision: "Stop" }, + ], + }); + }); + + const transcript = run.progress.join(""); + for (const ordinal of ["1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th"]) { + expect( + `${ordinal}: ${transcript.includes(`This is the ${ordinal} of up to 10 attempts.`)}`, + ).toBe(`${ordinal}: true`); + } + // Ten presentations is the bound: an eleventh attempt would mean the loop + // and the sentence disagreed about what the bound is. + expect(transcript).not.toContain("11th"); + expect(run.reviews).toHaveLength(10); + }); + + it("PO3: Stop announces itself before teardown and keeps its exact diagnostic", function* () { + /** The transcript as it stood when the authorship frame began to close. */ + const atTeardown: string[] = []; + + const run = yield* useWorkingDirectory(function* () { + const source = yield* readPackagedDocument(PLAN_COMMAND_DOCUMENT); + const progress: string[] = []; + let failure: string | undefined; + + const harness = yield* scoped(function* () { + return yield* planDeclarationHarness({ + surface: "command", + authorshipRoot: yield* authorshipRoot(), + session: SESSION, + explicitSession: true, + }); + }); + harness.fake.script({ reply: CANDIDATE }); + harness.script({ decision: "Stop" }); + + // Registered inside the frame's own scope, so it runs as that frame is + // taken down — which is what tells a phase written before teardown from + // one written after it. + const installed = harness.declaration; + yield* scoped(function* () { + yield* installAgentComponents({ defaultAgent: AGENT, permissionMode: "deny-all" }); + yield* useNormalizedOutput(); + const execution = yield* executeInstalled( + { + ...retainedSource(PLAN_COMMAND_IDENTITY, source), + stream: new InMemoryStream(), + includes: [], + secretDetection: true, + props: { request: REQUEST, session: SESSION }, + }, + [{ components: agentIdentityComponents(), declarations: [installed] }], + ); + // deno-lint-ignore require-yield + yield* forEach(function* (chunk: string) { + progress.push(chunk); + }, execution.output); + const completed = yield* execution; + if (!completed.ok) { + failure = completed.error.message; + } + }); + atTeardown.push(...progress); + return { progress, failure }; + }); + + expect(phases(run.progress)).toEqual([ + "Preparing the Plan", + "Drafting the Plan", + "Checking the draft", + "Waiting for your review", + "Stopping planning", + ]); + expect(run.progress.join("")).toContain( + "Closing the planning session without producing a Plan.", + ); + // The ending itself is unchanged, and it is a diagnostic rather than a + // phase: nothing on the progress channel claims it. + expect(run.failure).toBe("xmd plan stopped at your request. Nothing was output."); + expect(run.progress.join("")).not.toContain("Nothing was output"); + }); + + it("PO3: exhaustion announces itself, explains once, and asks nobody anything", function* () { + const run = yield* useWorkingDirectory(function* () { + return yield* runDocument({ + turns: [ + // Ten attempts of four drafts each, then the automatic explanation. + ...Array.from({ length: 40 }, () => ({ reply: "\n" })), + { reply: "Every draft named a component nothing offers." }, + ], + reviews: Array.from( + { length: 9 }, + (_unused, round): ScriptedReview => ({ + decision: "Request changes", + feedback: `round ${round + 1}`, + }), + ), + validations: [unsound], + }); + }); + + const rendered = phases(run.progress); + // The tenth attempt opens no review: nine were asked, and the phase that + // follows the last check says why there is no tenth question. + expect(run.reviews).toHaveLength(9); + expect(rendered.filter((phase) => phase === "Waiting for your review")).toHaveLength(9); + expect(rendered.at(-1)).toBe("Could not generate a Plan"); + expect(run.progress.join("")).toContain( + "The draft still has problems after 10 attempts. The coding agent is reviewing why " + + "planning was unsuccessful and how to improve the outcome of a future attempt.", + ); + + // #722's ending is unchanged, and no source came back. + expect(run.value).toBe(undefined); + expect(run.failure).toBe( + "xmd plan could not generate an approved Plan after 10 attempts.\n\n" + + "The coding agent explained why planning was unsuccessful and how to improve the " + + "outcome:\n\nEvery draft named a component nothing offers.\n\nNothing was output.", + ); + }); + + it("PO5: default progress discloses nothing, and verbose adds exactly two blocks", function* () { + const invalid = "\n"; + const scenario: RunOptions = { + turns: [{ reply: invalid }, { reply: CANDIDATE }], + reviews: [{ decision: "Approve" }], + validations: [unsound, sound], + }; + + const quiet = yield* useWorkingDirectory(function* () { + return yield* runDocument(scenario); + }); + const loud = yield* useWorkingDirectory(function* () { + return yield* runDocument({ ...scenario, verbose: true }); + }); + + // Neither run is about failure: both approved the repaired draft. + expect(quiet.value).toBe(CANDIDATE); + expect(loud.value).toBe(CANDIDATE); + + // Default progress holds none of the request, the drafts, the structured + // diagnostics or the approved source. + const quietText = quiet.progress.join(""); + for (const secret of [REQUEST, invalid, CANDIDATE.trim(), "component-unresolved"]) { + expect(`quiet: ${quietText.includes(secret)}`).toBe("quiet: false"); + } + expect(phases(quiet.progress)).toEqual([ + "Preparing the Plan", + "Drafting the Plan", + "Checking the draft", + "Repairing the draft", + "Checking the draft", + "Waiting for your review", + "Finalizing the Plan", + ]); + + // Verbose adds every cleared draft and each invalid check's structured + // JSON, in phase order, and nothing else. + expect(phases(loud.progress)).toEqual([ + "Preparing the Plan", + "Drafting the Plan", + "Generated draft", + "Checking the draft", + "Problems found in the draft", + "Repairing the draft", + "Generated draft", + "Checking the draft", + "Waiting for your review", + "Finalizing the Plan", + ]); + const loudText = loud.progress.join(""); + expect(loudText).toContain(invalid); + expect(loudText).toContain(CANDIDATE.trim()); + expect(loudText).toContain('"code": "component-unresolved"'); + // The second check passed, so exactly one problems block exists. + expect( + phases(loud.progress).filter((phase) => phase === "Problems found in the draft"), + ).toHaveLength(1); + // The request is still nobody's business: verbose adds drafts and + // diagnostics, not the Prompt or the review answer. + expect(loudText).not.toContain(REQUEST); + }); + + it("PO15: the packaged adapter builds the catalog once, and says nothing itself", function* () { + const run = yield* useWorkingDirectory(function* () { + return yield* runDocument(); + }); + + expect(run.events.filter((event) => event === "catalog")).toHaveLength(1); + // The adapter's own body is projection and return. Its former explanatory + // prose would arrive here the moment the transcript is drained. + const source = yield* readPackagedDocument(PLAN_COMMAND_DOCUMENT); + const body = source.slice(source.lastIndexOf("---\n") + 4); + expect( + body + .trim() + .split("\n") + .filter((line) => !line.startsWith("<")), + ).toEqual([]); + expect(unattributed(run.progress)).toEqual([]); + }); }); + +/** Settles once the observer recorded what it was watching for. */ +function* untilObserved(seen: readonly string[][]): Operation { + while (seen.length === 0) { + yield* sleep(1); + } +} diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 1cac40047..63e7cd44c 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -719,6 +719,86 @@ describe("Tier PC — in an ordinary document", () => { }); }); + it("PO4: neither ordinary form emits a planning phase, whatever verbosity says", function* () { + /** Every phase heading a run put on the Output Api. */ + const announced = (emitted: string): string[] => + emitted.split("\n").filter((line) => line.startsWith("## ")); + + yield* useWorkingDirectory(function* (dir) { + // Bare: the approved source is emitted where the element stands, and it + // is the whole of what the document said. + const bare = yield* runDocument({ + source: ["", "Write a program.", "", ""].join("\n"), + reply: PLAN, + normalized: true, + }); + + expect(bare.failure).toBe(undefined); + expect(bare.emitted).toContain(PLAN); + expect(announced(bare.emitted)).toEqual([]); + for (const phase of ["Preparing the Plan", "Drafting the Plan", "Checking the draft"]) { + expect(`bare: ${bare.emitted.includes(phase)}`).toBe("bare: false"); + } + + // Captured: the same bytes arrive under `as` and the element emits + // nothing — so a phase written into the capture would be the approved + // program's first line. + const captured = yield* runDocument({ + source: ['Write a program.', "", "got: {approved}", ""].join( + "\n", + ), + reply: PLAN, + normalized: true, + }); + + expect(captured.failure).toBe(undefined); + expect(captured.output).toContain(`got: ${PLAN}`); + expect(announced(captured.emitted)).toEqual([]); + expect(captured.emitted).not.toContain("Preparing the Plan"); + + // Verbosity is the command's sealed fact and reaches neither form: a + // declaration that carries it still announces nothing here. + const loud = yield* runDocument({ + source: ["", "Write a program.", "", ""].join("\n"), + reply: PLAN, + normalized: true, + harness: yield* planDeclarationHarness({ + surface: "component", + authorshipRoot: yield* authorshipRoot(), + verbose: true, + }), + }); + + expect(loud.failure).toBe(undefined); + expect(announced(loud.emitted)).toEqual([]); + + // The negative control: this exact scenario does announce its phases when + // the surface is the command's. Absence above is the surface's doing, not + // a scenario that never reached a phase. + const command = yield* runDocument({ + source: ["", "Write a program.", "", ""].join("\n"), + reply: PLAN, + normalized: true, + harness: yield* planDeclarationHarness({ + surface: "command", + authorshipRoot: yield* authorshipRoot(), + }), + }); + + expect(command.failure).toBe(undefined); + expect(announced(command.emitted)).toEqual([ + "## Preparing the Plan", + "## Drafting the Plan", + "## Checking the draft", + "## Waiting for your review", + "## Finalizing the Plan", + ]); + + // And nothing any of them produced was ever run. + expect((yield* until(readdir(dir))).sort()).toEqual([]); + }); + }); + it("PC20: the retained artifact carries the approved bytes and their digest", function* () { yield* useWorkingDirectory(function* () { const stream = new InMemoryStream(); diff --git a/packages/cli/tests/plan-host-acts.test.ts b/packages/cli/tests/plan-host-acts.test.ts index 314be03f2..91eb35537 100644 --- a/packages/cli/tests/plan-host-acts.test.ts +++ b/packages/cli/tests/plan-host-acts.test.ts @@ -42,7 +42,7 @@ const STACK: AuthorshipStack = { }; function writing(dir: string, output: string): PlanCommand { - return { request: REQUEST, include: [dir], output, stack: STACK }; + return { request: REQUEST, include: [dir], output, verbose: false, 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 c9a7cc969..8e7689dcb 100644 --- a/packages/cli/tests/plan.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -148,7 +148,7 @@ function command( request: string = REQUEST, stack: AuthorshipStack = STACK, ): PlanCommand { - return { request, include: [dir], stack }; + return { request, include: [dir], verbose: false, stack }; } /** @@ -951,9 +951,7 @@ describe( expect(harness.fake.prompts).toHaveLength(4); expect(harness.reviews).toHaveLength(1); expect(decisions(harness.reviews[0])).toEqual(["Request changes", "Stop"]); - expect(harness.reviews[0].message).toContain( - "The coding agent used all three repair attempts", - ); + expect(harness.reviews[0].message).toContain("The coding agent used all 3 repair attempts"); // The presentation carries the same complete captured JSON the repair // turns did, through the same `` binding. const shown = fencedJson(harness.reviews[0].message); diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index 778db442c..f3922f248 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -14,7 +14,7 @@ import { Elicitation } from "@executablemd/core"; import type { DocumentValidation, ElicitationRequest, SyntaxCatalog } from "@executablemd/core"; -import { Ok } from "effection"; +import { Err, Ok } from "effection"; import type { Operation, Result } from "effection"; import { ensure, scoped, useScope } from "effection"; import { ensureDir, rm } from "@effectionx/fs"; @@ -74,6 +74,14 @@ export interface PlanHarness { catalogCalls: string[][]; /** Every review request a provider was asked, in order. */ reviews: ElicitationRequest[]; + /** + * Every progress chunk the stated stderr accepted, in arrival order. + * + * Chunks rather than a joined string, because when a phase arrives is the + * whole point of most of these rows: a case that only read the transcript + * could not tell progressive delivery from one buffered summary. + */ + progress: string[]; /** Review answers, taken in order. Running out is a test defect, not a case. */ script(review: ScriptedReview): void; /** The dependencies `runPlan` is driven with. */ @@ -100,20 +108,51 @@ export function createPlanHarness(options: { * session is continued or created a second time. */ store?: FakeStore; + /** What this case's host states about its own stderr. Absent is a pipe. */ + terminal?: boolean; + /** + * Refuse a progress chunk, standing where a broken pipe would. + * + * An operation rather than a predicate, because a case about failing *while a + * turn is live* has to wait for that turn: a synchronous answer would race the + * producer, and the row would sometimes prove nothing. Called with every chunk + * in arrival order; an error is that write failing, and the chunk is not + * recorded as accepted. + */ + refuseProgress?: (chunk: string, index: number) => Operation; }): PlanHarness { const fake = createFakeAcp(); const catalogCalls: string[][] = []; const reviews: ElicitationRequest[] = []; const answers: ScriptedReview[] = []; + const progress: string[] = []; + let offered = 0; const harness: PlanHarness = { fake, catalogCalls, reviews, + progress, script(review) { answers.push(review); }, deps: { + progress: { + terminal: options.terminal === true, + *write(chunk) { + const index = offered; + offered += 1; + const refusal = + options.refuseProgress === undefined + ? undefined + : yield* options.refuseProgress(chunk, index); + if (refusal !== undefined) { + return Err(refusal); + } + progress.push(chunk); + return Ok(undefined); + }, + }, acp: { createRuntime: fake.create, sessionStore: options.store ?? makeStore(), @@ -271,6 +310,14 @@ export function* planDeclarationHarness(options: { includes?: readonly string[]; /** The catalog the first turn is built from. */ syntax?: string; + /** + * Build the catalog, in place of answering with {@link syntax}. + * + * A case that needs to know *when* the catalog was built supplies this, which + * is the only way to tell an authored phase that precedes the preparation from + * one that follows it. + */ + catalog?: () => Operation; /** * How this case answers the one structural question the Component asks. * @@ -281,6 +328,8 @@ export function* planDeclarationHarness(options: { /** The logical name the command surface fixes. */ session?: string; explicitSession?: boolean; + /** Whether the command surface asked for drafts and check diagnostics. */ + verbose?: boolean; /** Absent leaves the harness with no stack at all, as `xmd test` has none. */ stack?: AuthorshipStack | null; store?: FakeStore; @@ -313,6 +362,7 @@ export function* planDeclarationHarness(options: { authorshipRoot: options.authorshipRoot, ...(options.session === undefined ? {} : { session: options.session }), ...(options.explicitSession === undefined ? {} : { explicitSession: options.explicitSession }), + ...(options.verbose === undefined ? {} : { verbose: options.verbose }), host: yield* useScope(), *installElicitation() { yield* Elicitation.around( @@ -336,8 +386,10 @@ export function* planDeclarationHarness(options: { { at: "min" }, ); }, - // deno-lint-ignore require-yield *catalog() { + if (options.catalog !== undefined) { + return yield* options.catalog(); + } return options.syntax ?? "## Built-in components\n\n### ``\n"; }, // The deterministic seam standing where production's answer goes, recording diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 54e83178b..af7caefc3 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -220,7 +220,13 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () }); expect(plan.forms).toEqual(["paired"]); // And no private capability is syntax a document may write, in any build. - for (const name of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { + for (const name of [ + "PlanInputs", + "PlanAuthorship", + "PlanProgress", + "CheckDraft", + "AdmitPlan", + ]) { expect(entries.map((entry: { name?: string }) => entry?.name)).not.toContain(name); } diff --git a/scripts/tests/plan-component-compiled.test.ts b/scripts/tests/plan-component-compiled.test.ts index ad2de081f..67a222919 100644 --- a/scripts/tests/plan-component-compiled.test.ts +++ b/scripts/tests/plan-component-compiled.test.ts @@ -90,7 +90,13 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => // And the private capabilities are not syntax any build lets a document // write. const names = entries.map((entry: { name?: string }) => entry?.name); - for (const name of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { + for (const name of [ + "PlanInputs", + "PlanAuthorship", + "PlanProgress", + "CheckDraft", + "AdmitPlan", + ]) { expect(names).not.toContain(name); } @@ -109,8 +115,24 @@ describe("compiled xmd", { sanitizeOps: false, sanitizeResources: false }, () => 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); + // The two options that observe this command's own authorship travel with + // those bytes too, in full, and so does what a journal costs. + expect(helped.value.stdout).toContain( + "--verbose show generated drafts and XMD check diagnostics on stderr", + ); + expect(helped.value.stdout).toContain( + "--journal [JOURNAL] record the planning process as diagnostic JSONL " + + "(path must not exist)", + ); + expect(helped.value.stdout).toContain( + "Secret detection checks journal entries before they are recorded", + ); + // And nothing that would run the approved program. Matched as whole tokens, + // because `-j` is a substring of the `--journal` this command does define. + for (const option of ["--run", "--props", "--raw", "--deny-all", "-V", "-j"]) { + expect(`${option}: ${new RegExp(`(^|\\s)${option}\\b`, "m").test(helped.value.stdout)}`).toBe( + `${option}: false`, + ); } }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 0f488f2ba..64eca1729 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2840,8 +2840,11 @@ what it renders is the exact approved Plan source, after the authorship frame has been dismantled and the bytes have been structurally admitted. Written bare it emits that source where the component is written, and `as` is ordinary text capture: the same bytes are bound and nothing is emitted. Neither form -evaluates the source. Its four private -capabilities — ``, ``, `` and +evaluates the source, and neither announces a phase: the progress `xmd plan` +writes is a private side effect of the command surface, and an ordinary `` +expands no progress body at all. Its five private +capabilities — ``, ``, ``, +`` and `` — are the closure those exact bytes carry, and are syntax no document may write. [The plan command](./plan-command-spec.md) is the contract. @@ -8840,11 +8843,18 @@ 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). +internal identity ``. The durable stream it records itself on is +the host's choice: a fresh invocation-owned in-memory one that is discarded when +authorship is over, or the file `--journal` named and the command exclusively +created. Neither is ever read back — nothing about generation, repair or approval +is replayed or resumed, and a journal is a diagnostic record of how a Plan came +to be written rather than a history anything continues from. + +That root also reports itself. Its rendered transcript is the progress `xmd plan` +writes to stderr while it works — one Markdown phase before each piece of work, +with `--verbose` adding the generated drafts and the failed checks' diagnostics — +and it reaches neither of the approved source's two sinks. The command's complete +contract is [`xmd plan`](./plan-command-spec.md). `xmd upgrade` executes the second packaged root under the stable internal identity ``. It is an ordinary streaming text root: it declares @@ -10778,7 +10788,7 @@ every refusal is proven by the phase tripwires that stayed at zero. | # | Test | Verify | |---|------|--------| | 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 | +| PS4/PS5 | Help | The complete `xmd plan --help` output and the program summary carry only the retained grammar, both explicit compositions and the journal warning, 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 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 `` | @@ -10786,6 +10796,25 @@ every refusal is proven by the phase tripwires that stayed at zero. | 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 PO — Observable `xmd plan` authorship + +The same one root execution, watched while it happens. Every phase is announced +on stderr before the work it names, the counters are derived from the two bounds +`Plan.md` binds once, `--verbose` adds the cleared drafts and the failed checks' +findings, and `--journal` records the whole authorship as the existing JSONL. The +approved source's two sinks are untouched by all of it. Defined in +[`xmd plan`](./plan-command-spec.md), whose acceptance table this points at +rather than restating. + +| # | Test | Verify | +|---|------|--------| +| PO1–PO3 | Phases and counters | Each phase precedes the work it names, and one reaches the operator while a turn is still blocked; the repair and attempt ordinals come from the loop bounds and stop at them; Stop and exhaustion announce themselves and keep their exact endings | +| PO4/PO5 | Surface and disclosure | Neither ordinary `` form announces anything or expands a progress body; default progress holds no request, draft, diagnostic, feedback or approved source, and verbose adds exactly the two blocks, in phase order | +| PO6/PO7 | Channels and grammar | A non-terminal stderr receives normalized Markdown and a stated terminal receives it rendered, while stdout and `--output` stay byte-identical; `--verbose` and `--journal` work on either side of the request, help carries them and the journal warning, and the short aliases, every removed spelling and a retained option that reaches this grammar written where the journal path goes all refuse before any work, while `--help` keeps its ordinary precedence | +| PO8/PO9/PO16 | The journal file | No `--journal` writes no file; one creates the path before the catalog and the first turn, parses as the existing JSONL in commit order, ends terminally and holds no program execution; an existing path and an uncreatable one each report their exact refusal and reach nothing; and an ordinary failure — where no append failed — leaves a wholly parseable file with no partial trailing record | +| PO10–PO12 | The secret and persistence boundaries | A secret in a draft or in a failed check's findings reaches neither the progress nor the file while the earlier prefix stays readable, and the same values without it are shown and recorded; a refused entry reports the exact journal-write diagnostic and preserves what committed | +| PO13–PO15 | Failure and ordering | A progress destination that fails cancels the live turn, waits for every owned teardown and delivers nothing; every existing ending keeps its order and no phase claims delivery; the packaged adapter says nothing of its own and the catalog is built once, from `` | + ### Tier UG — The `xmd upgrade` command One root execution on every invocation — the packaged upgrade command document, diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 0d80ac0bf..2f380cc54 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -84,11 +84,13 @@ no second root, no second execution model, no second props model and no journal. ```text fixed command preflight - -> build the run-profile syntax catalog + -> --journal: exclusively create the named path -> execute the exact packaged plan command document, which is an adapter -> , the packaged Component, with the request as its Prompt + -> announce Preparing, then build the run-profile syntax catalog -> the authorship frame, and one Session inside it - -> generate, check, repair, review, revise, approve, explain or fail + -> generate, check, repair, review, revise, approve, explain or fail, + announcing each phase on stderr before it happens -> teardown, then structural admission of the exact approved bytes -> Return what approved -> await that execution and provider teardown @@ -101,10 +103,11 @@ 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 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 persisted, reused or replayed. No invocation of this command writes a -journal. +Writing a Plan is a conversation, and a conversation is not a run. The durable +stream that conversation records itself on belongs to the host: one fresh +invocation-owned `InMemoryStream` by default, or the file `--journal` named. It +is written and never read — no invocation opens a journal as input, resumes from +one, or runs a program that could be resumed. ## Command grammar @@ -146,10 +149,47 @@ result. | `--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 | +| `--verbose` | adds the generated drafts and the failed checks' diagnostics to the progress on stderr | +| `--journal ` | records this authorship as diagnostic JSONL at a path that must not exist | | `--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*. +write it in*, *where the approved source goes*, or *how much of the writing you +watch*. + +`--verbose` and `--journal` are spelled in full and have no short aliases. +`xmd run` gives those spellings `-V` and `-j` for options about a *program's* +run; these two observe this command's own authorship and nothing after it, so +each short form is refused by naming the long one: + +```console +$ xmd plan "…" -j trace.jsonl +unrecognized option for xmd plan: -j — write `--journal ` +``` + +`--journal` takes exactly one non-empty path, and a token that names an option +this command defines is that option rather than a filename. Reading one as a +path would exclusively create a file called `--verbose` and quietly drop the +verbosity the caller asked for, so the value is read by fixed grammar rather +than left to the parser: + +```console +$ xmd plan "…" --journal --verbose +--journal needs a path — write `--journal ` or leave it out to record no journal +``` + +Only that position is affected. `--journal --verbose` and +`--verbose --journal ` are both ordinary command lines, `--journal=-x` +takes whatever follows the `=`, and a removed spelling written there keeps its +own more specific refusal. + +`--help` and `-h` are the exception, and deliberately so. They are lifted out of +the command line before any command's own grammar runs, so this check never sees +them: `xmd plan "…" --journal --help` prints help and exits successfully, +creating no journal and beginning no authorship. Pre-help refusal is reserved +for the options this command *removed*, where a page describing a command that +would refuse the caller is the thing to avoid; a caller who asked for help +beside an incomplete option is asking for help. ### What the command removed @@ -160,16 +200,14 @@ alias and no inert placeholder: - 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. +asked for 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 @@ -271,11 +309,18 @@ conversation still produces source and starts no program. xmd plan --help ``` -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 needs no request. It describes the request, `--output`, `--session`, +`--verbose`, `--journal`, 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. + +It ends with what a journal costs, separated from everything above it: + +```text +Secret detection checks journal entries before they are recorded, but it may not catch every sensitive detail. The journal can contain prompts, drafts, and review answers. +``` Help reads no catalog, contacts no provider, places no session, asks nobody anything, creates no file and runs nothing. @@ -290,22 +335,25 @@ the working directory and never through the component search path — so the sam document is found whatever directory a person stands in, and no repository file can answer for it ([release process](./release-process-spec.md)). -The host supplies three fixed internal inputs as that root's props: +The host supplies two fixed internal inputs as that root's props: -- `request` — the original request text; -- `syntax` — the rendered syntax catalog for the current run profile and ordered - `--include` values; and +- `request` — the original request text; and - `session` — the resolved logical assistant-session name. 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 -source the Component renders, and returns it. It contains no Prompt, no check, no review, no revision -and no ending of its own. `props.syntax` is sealed by the host rather than -forwarded: the catalog the agent is shown is the one this command rendered, and -no prop on the adapter could supply another. +properties a Plan's root declares are resolved by whoever runs it. The catalog +is not among them: it is built inside ``, from a closure the host +captured, so an authored phase can say that the preparation is starting before +it happens. + +**The root is an adapter, not the workflow.** Its whole body is two elements: it +projects `props.request` into `` without adding whitespace, supplies +`props.session`, captures the exact source the Component renders, and returns it. +It contains no Prompt, no check, no review, no revision, no ending — and no prose +of its own. This root's rendered transcript *is* what the command writes to +stderr as progress, so a sentence the adapter explained itself with would be +printed to an operator in the middle of a Plan being written. What the command is +for belongs in its help. ## The packaged `` Component @@ -340,14 +388,16 @@ surfaces' endings, each written once. The command's wording is unchanged; the component's says that no Plan was returned rather than that nothing was output or run. TypeScript supplies neither the words nor the choice between them. -**The four private capabilities.** The Component's phases are components only these +**The five private capabilities.** The Component's phases are components only these exact bytes may write, declared by the host with the definition and revoked with -the execution: `` freezes the catalog, the instruction identity, the +the execution: `` builds and freezes the catalog, the instruction +identity, the session placement, the surface and whether that placement outlives the invocation, and refuses a continuation whose instructions render differently — as stale input, before a directory, a provider, a turn or a review exists; paired `` installs the constrained frame and does not return until every -part of it has torn down; `` answers about one draft without +part of it has torn down; paired `` says which phase is running; +`` answers about one draft without executing it; and `` structurally admits the approved bytes after that teardown and retains them as one Plan artifact — the invocation identity, the instruction identity, the approved source, its digest and that successful @@ -626,9 +676,186 @@ The branch after the Session is only an unexpected-no-decision fallback and says so; exhaustion is decided where the tenth draft's check is, not duplicated there. Failure is authored in Markdown rather than hidden in the host. -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. +The command document's rendered output is not the command's result. What it +renders is the progress an operator reads on stderr, described below; the draft +itself, the choices and the diagnostics reach you through Elicitation; and that +document's successful public result is the approved Plan source and nothing +else. + +## Watching a Plan being written + +Writing a Plan takes minutes and asks you a question in the middle. So the +command reports what it is doing while it does it, on stderr, as readable +Markdown — and stdout stays exactly what it was: the approved source, or +nothing at all when `--output` was named. + +Each phase is announced **before** the work it names, so what you read is what +is happening rather than an account of what already finished. The phases are: + +| Phase | Announced before | +| --- | --- | +| Preparing the Plan | the syntax catalog is built and the session is set up | +| Drafting the Plan | the first Agent turn, naming which of the ten attempts this is | +| Checking the draft | every structural check, including the ones after a repair | +| Repairing the draft | each repair turn, naming which of the three repairs this is | +| Waiting for your review | each actionable review | +| Revising the Plan | a requested-change turn, naming the attempt it begins | +| Finalizing the Plan | leaving authorship after an approval | +| Stopping planning | the authored ending a **Stop** raises | +| Could not generate a Plan | the automatic explanation turn a tenth unrepaired draft gets | + +The counters are the workflow's own. `Plan.md` binds ten attempts and three +repairs once, and every loop bound, every condition and every sentence above is +derived from those two bindings — so what you are told and what the workflow +does cannot come to disagree. + +**Finalizing says the session is closing.** It is not a claim that anything was +delivered: the final host validation and the artifact sink both happen after +authorship has torn down, and neither has a phase. Nothing on this channel ever +says a Plan was produced, written or output. + +**Default progress discloses nothing.** It holds none of the request, the draft +source, the structural diagnostics, your review feedback, the Agent's output, +provider or tool chatter, or the approved source. + +`--verbose` adds exactly two blocks, in phase order: + +- **Generated draft**, after every Agent result that committed, holding that + draft; and +- **Problems found in the draft**, after every check that committed and found + the draft invalid, holding its exact structured JSON. + +Both are reachable only *after* the durable event that supplies them has +cleared the secret gate and committed, which is why a rejected draft or a +rejected diagnostic is never displayed: the binding the block reads does not +exist. + +### The channel is the command's, not the document's + +Progress is a private output side effect of the packaged `` Component, not +something `` produces. The paired private `` renders its +content and sends it through the current document-output operation as ordinary +prose, then returns the empty string. + +Returning it instead would put a phase heading inside ``'s +capture and inside the declaration's exact-source disposition — contaminating +the approved program and bypassing the presentation every other line of progress +gets. So: + +- on the **component** surface it returns before its content is expanded at all, + and an ordinary document that writes `` announces nothing; +- on the **command** surface with `verbose`, while this invocation did not ask + for long-form progress, it returns before expanding too; and +- otherwise it renders, writes, and returns nothing. + +Which surface is asking and whether `--verbose` was written are sealed host +facts the declaration carries. There is no public progress syntax, no segment +marker, no authored opt-in and no context a document could set. + +### Who owns the stream and the terminal + +The command execution installs whitespace normalization for every invocation, +and terminal formatting only when the host says its stderr is a terminal. Both +the writer and that fact are host dependencies: the CLI owns `process.stderr` +and the `isTTY` answer, no shared module detects a runtime, and no document +inspects a terminal. A non-terminal stderr therefore receives normalized +Markdown, and a terminal one receives it rendered. + +The transcript is drained while the document is still producing it, inside the +scope that owns the execution. A destination that stops accepting bytes fails +that consumer, which cancels the producer and waits for the provider, the Prompt +tasks, Elicitation, the session directory and the execution to tear down before +anything is reported: + +```text +Could not write planning progress to stderr: + +Planning was cancelled, and no Plan was output. +``` + +That message is offered to stderr, because a stream that refused once is not a +stream that refuses forever. A stderr that never recovers gets no stdout +fallback: an approved Plan's sink is not a channel for a message about progress. +Bytes stderr already accepted are not rolled back, and no `--output` file is +created. + +## The `--journal` file + +`--journal ` exclusively creates the named path and records this +authorship on it. The format is the existing `serializeDurableEvent()` JSONL +sequence — the command root's ordinary live durable events, in commit order: +cleared Agent turns, draft checks, review decisions, admission and the terminal +outcome. There is no curated projection and no format of this command's own. + +It records no later program execution, because there is no later program +execution. It is written and never read: nothing opens it as input, replays it, +or treats it as resume authority. + +Without `--journal`, no file is created anywhere. + +### What the file holds when the invocation ends + +An invocation that ends badly still leaves usable evidence, which is the whole +reason to ask for a journal. What that evidence is worth depends on one thing: +whether an append to the file itself failed. + +| Ending | The file holds | +| --- | --- | +| the Plan was approved and delivered | the whole authorship, ending in its terminal event | +| an ordinary failure — a failed turn, a Stop, exhaustion, cancellation, a teardown failure, the final structural refusal | every entry that committed before it | +| an event the secret gate rejected | every entry that committed before that event, and nothing of the rejected one | +| an entry the file would not take | every entry that committed **before the failed append** | + +**In the first three, no append failed, so the file is wholly parseable.** Every +record the command offered was taken, each is newline-terminated, and reading +the file back gives exactly the events it recorded, in commit order. An ordinary +failure is not a journal failure: the file took everything it was offered, and +no journal diagnostic is reported for it. + +**After a failed append, the guaranteed evidence is the sequence committed +before it.** The storage primitive is an ordinary appending write, not a +transaction — a filesystem that fails partway through one can leave part of a +record behind — so this command promises the committed prefix and nothing about +the bytes of the append that failed. Reading a journal whose write failed means +reading up to the last complete record. + +Making that stronger would take a genuinely atomic append protocol, which +producing a Plan does not need and this command does not have. The failure +diagnostic says what is true: the entries recorded before the failure are still +there. + +**Secret detection covers it.** Every live durable event crosses the serialized +pre-append gate, so a rejected event reaches neither the file nor the in-memory +committed sequence — nothing of it is offered to the file, so the records +recorded before it are the whole of what is there, and it is wholly parseable. +Help +says plainly that the gate may not catch every sensitive detail and that the +journal can contain prompts, drafts and review answers. + +An existing path is refused before catalog preparation, session placement, Agent +startup, review or artifact creation, and is left byte-identical: + +```text +Journal file already exists: . Choose a different --journal path. +``` + +A path this command cannot create at all reports why, and where to go next: + +```text +Could not create journal file : + +Choose a different --journal path and try again. +``` + +An entry the file will not take ends authorship. Teardown completes, the prefix +committed before that entry is preserved, and no Plan is delivered: + +```text +Could not write the next entry to journal file : + +The journal still contains the entries recorded before this failure. +``` + ## Structural host validation, and the artifact @@ -701,8 +928,11 @@ ending of this command does. | Failure | Reaches | | --- | --- | | a malformed command line, a removed option, an unknown option, or `--save` | nothing | +| a `--journal` path that exists, or one this command cannot create | no catalog, session, turn, review, stdout or file | | an unknown `--agent-provider` | nothing | -| a catalog an include makes unreadable | no command document | +| a catalog an include makes unreadable | no turn, review, stdout or file | +| a `--journal` entry the file will not take | no stdout or file; the committed prefix stays | +| a progress destination that stops accepting bytes | no stdout or file; accepted bytes stay | | 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 | @@ -712,12 +942,15 @@ ending of this command does. ## Acceptance -Tier PR. The evidence lives in `packages/cli/tests/plan-args.test.ts` (fixed -grammar), `packages/cli/tests/plan-command-document.test.ts` (the packaged -document executed as itself), `packages/cli/tests/plan.test.ts` (the host and +Tiers PR and PO. The evidence lives in `packages/cli/tests/plan-args.test.ts` +(fixed grammar), `packages/cli/tests/plan-command-document.test.ts` (the packaged +document executed as itself, and the authored phases and counters), +`packages/cli/tests/plan-component.test.ts` (the ordinary `` surface), +`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). +operator meets it, the artifact, the channels, the journal and the failures each +of them can have). The ACPX runtime is a scriptable fake, the review provider is a scripted `Elicitation` handler, and the contextual working directory is a temporary one: @@ -742,3 +975,19 @@ neither observation never interpreted what it wrote. | 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 | +| PO1 | Progress precedes the work | Preparing arrives before the catalog is built, Drafting before the first turn, Checking before validation, Waiting before review and Finalizing before authorship teardown; an early phase reaches the operator while a turn is still blocked | +| PO2 | Counters come from the bounds | One invalid attempt uses repair ordinals 1st–3rd with a check before each result; a requested change announces the 2nd attempt; the counters reach the 10th and there is no 11th | +| PO3 | Terminal phases | Stop announces itself before teardown and keeps its exact final diagnostic; a tenth-attempt exhaustion announces itself before the automatic explanation, opens no review and produces no Plan | +| PO4 | The ordinary surface is silent | A bare `` emits only exact approved source, a captured `` binds the same bytes and emits nothing, and neither expands a progress body whatever verbosity the declaration carries | +| PO5 | Disclosure | Default progress excludes the request, the drafts, the diagnostics, the feedback and every Agent, provider and tool output; verbose adds every cleared draft and each invalid check's exact structured JSON, in phase order, and nothing else | +| PO6 | Channels | A non-terminal stderr receives normalized Markdown, a stated terminal receives it rendered, and stdout and `--output` stay byte-identical exact source in both | +| PO7 | The two options | `--verbose` and `--journal` are accepted on either side of the request, help contains them and the journal warning, and `-V`, `-j`, `--trace` and every removed spelling refuse before the catalog or a session exists; a retained option that reaches this command's grammar, written where the journal path goes, is that option rather than a filename and refuses before any catalog, session, provider, filesystem or artifact work — while `--help` and `-h` keep their ordinary precedence and answer with help, creating no journal and beginning no authorship | +| PO8 | The journal file | With no `--journal` no file appears; with one, the path exists before the catalog and the first turn, a successful trace parses as the existing JSONL events in commit order and ends terminally, and it holds no program-execution event | +| PO9 | Journal refusals | A pre-existing journal is byte-identical and refuses with the exact copy before any catalog, session, turn, review or artifact work; a path that cannot be created reports the other exact copy | +| PO10 | A secret in a draft | It reaches neither the progress nor the journal, the earlier prefix stays readable, teardown completes, and no source or artifact is delivered — while the same draft without it is displayed and recorded | +| PO11 | A secret in a diagnostic | The same, for a failed check's structured findings | +| PO12 | A refused entry | An append failure after a committed entry reports the exact journal-write diagnostic, preserves the records committed before it, completes teardown and delivers no Plan | +| PO16 | An ordinary failure | A journal-backed invocation that fails for its own reason — a failed turn, with neither a secret rejection nor a write failure — exits non-zero, delivers no source and no artifact, completes teardown, and leaves a file whose every entry parses and whose bytes are exactly those entries re-serialized: no append failed, so there is no partial or unterminated trailing record | +| PO13 | A failed destination | A consumer that fails while a turn is live cancels that turn, waits for every owned teardown, attempts no artifact sink, keeps the bytes stderr accepted, and uses the exact progress-failure diagnostic | +| PO14 | Ordering is unchanged | Cancellation, teardown failure, final validation refusal, the `--output` refusal and a successful delivery all keep their order, and no phase claims an artifact was delivered | +| PO15 | The adapter and the catalog | The packaged adapter emits no prose of its own, and the catalog is built exactly once, from ``, after Preparing |