Skip to content

✨ Let <Json> capture its text with as - #667

Merged
taras merged 2 commits into
mainfrom
agent/issue-666-json-capture
Aug 30, 2026
Merged

✨ Let <Json> capture its text with as#667
taras merged 2 commits into
mainfrom
agent/issue-666-json-capture

Conversation

@taras

@taras taras commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Why

<Json> refused a valid literal as in its own body. A document that wanted the
JSON text rather than its placement — to hand it to <CodeBlock>, a <File>, or
a later <Prompt> — had to write a wrapper whose only job was to catch the text:

<Let as="findings"><Json value={assessment.diagnostics} /></Let>

Every other text component already takes an optional as, and the engine already
owns it. <Json> was the one opting out.

Closes #666.

What changes

Before:

<Json value={assessment.diagnostics} as="findings" />

failed with <Json> renders JSON text and binds nothing, so as is not accepted.

After: it binds the exact string JSON.stringify(value, null, 2) produced and
emits nothing where the element stands. For one run in the same environment it is
exactly the single-child <Let> wrapper above — and only that wrapper.
Whitespace, additional content or select inside <Let> describes a different
operation.

Uncaptured <Json> is byte-for-byte what it was.

How it works

engine validates and strips `as` → Json() checks content, then syntactic `value`
→ capture("value") → one JSON.stringify → engine binds the string, or renders it

The production delta is the removal of one guard. A function component that
exports no returns is already a text component, so expandComponent's
existing path binds its returned value when as is present and renders it only
when as is absent. <Json> gained no prop, no returns and no registration
option — it stopped blocking a path that was already there.

Review guide

Start with: packages/core/src/components/Json.ts

Then review:

  1. specs/executable-mdx-spec.md §6.12 — the capture, its equivalence, and the
    revised validation order
  2. packages/core/src/components/registry.ts — the one new author-facing
    string, the as field, and everything around it left alone
  3. packages/core/tests/json-component.test.ts — the new as describe block and
    the reworked durability document
  4. architecture.md, the catalog tests, and the CLI syntax document

Look carefully at:

  • The failure paths under as. A serialization that fails must leave the
    destination absent, not empty, and must leak no partial JSON.
  • Validation order. An expression-valued as and a literal that names no binding
    are refused on the authored text, before either expression runs.

What must stay true

  • The successful result is JSON.stringify(value, null, 2), called once
    enforced by the single native call in Json.ts, checked by J1, J6 and
    J6b (a counting getter proves building the binding takes no second read).
  • value remains the one raw captured operand — enforced by
    captures: ["value"] on core's registration alone, checked by J4/J6
    (identity of the very object) and by CR22c, where a repository Json.md
    still receives value across the ordinary prop boundary.
  • A failure binds nothing — enforced by the component raising before the
    engine reaches the binding, checked by the two J7/J5b cases.
  • as is the engine's — no as in the props schema, no returns export;
    checked by SY24b2's complete row and CR25c.
  • Capture adds no journal record — checked by J9, which asserts no event
    description matches /json|serial|stringify/i.

How to verify it

deno task test packages/core/tests/json-component.test.ts
deno task test packages/core/tests/component-registration.test.ts
deno task test packages/core/tests/syntax-catalog.test.ts
deno task test packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts
  • J3 asserts the complete untrimmed rendering 'before{\n "ok": true\n}after\n',
    and fails if removing the guard moved the text or added a newline.
  • J5b: a valid \as` binds the exact text …compares the binding to the exact native string and asserts the output is exactly"beforeafter\n"`. It fails if
    a value or object is bound instead of the string, or if the invocation emits
    alongside the binding.
  • J5b: capturing is the exact <Let> wrapper … runs an object, array, scalar and
    null through both spellings separately, and fails on wrapper-only
    whitespace or a scalar special case.
  • The two J7/J5b cases fail if a failure publishes a binding, if the two
    diagnostics collapse, if the original cause becomes unreachable, or if the half
    JSON.stringify had built escapes.
  • The two invalid-as cases carry tripwires on both expressions and fail if
    either runs.
  • J9 binds <Json>'s text and renders it from a later authored position, so
    live, partial-replay and completed-root reuse are all proved through the
    captured path rather than the legacy emitted one.
  • CR22b invokes a repository Json.md with as and proves the binding holds
    the override's visibly different text, not core JSON.
  • SM12 asserts the description and the new as sentence in the JSON catalog
    and the rendered Markdown field. Inverting one word in it fails the suite
    with 1 of 12 tests failed, which is how it was checked for vacuity.

Scope

Included

  • Removal of <Json>'s component-specific as refusal
  • The as field in core's registry entry for <Json>. Its description
    already said what the component does and is unchanged
  • §6.12, Tier JSON and the architecture.md inventory row
  • Focused evidence for capture, equivalence, once-only execution, captured
    failures, invalid as, override capture, inspection and captured replay

Intentionally unchanged

  • JSON.stringify(value, null, 2), formatting, trailing newlines, interpolation,
    output middleware and failure settlement
  • value as the single captured prop; no new prop and no returns
  • Generic as, <Let>, component resolution and override precedence — no file
    in the expansion or binding machinery is touched
  • components/BootstrapNpmPackage.md keeps its <Let> wrapper; the shorter
    spelling is available, not required

Generated or mechanical changes

None.

Risks and limitations

  • The riskiest possible mistakes here are adding as to the schema, adding a
    returns declaration, proving only that a binding exists rather than that it
    is the exact string, and letting a failure publish one. SY24b2, J5b,
    J6b and the two J7/J5b cases discriminate each of those directly.
  • One deviation from the accepted plan, flagged rather than decided. The plan
    excluded site work, but two site sentences became false statements:
    site/routes/docs/components.tsx ("as is refused") and
    site/routes/docs/reference.tsx ("binds nothing"). Tier JSON row J12 asserts
    that the site documentation and §6.12 state the same contract, so leaving them
    would have made the spec's own row untrue at this commit. Each was corrected in
    one sentence. Revert those two hunks if the exclusion is read as covering prose
    the change falsifies.
  • Recovery or rollback: revert the commit. There is no persistence, identity or
    compatibility surface involved.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

`<Json>` refused a valid literal `as` in its own body, so a document that
wanted the JSON text rather than its placement had to write the single-child
`<Let>` wrapper around it. Removing that refusal opts the component back into
the ordinary text capture every text component already has: the engine
validates and strips `as`, binds the exact string the component returned, and
emits nothing at the invocation site.

`value` remains the one public prop and the one raw captured operand,
`JSON.stringify(value, null, 2)` still runs once, and the two serialization
failures stay distinct — under `as` each leaves the destination absent and
leaks no partial JSON. Validation order is unchanged apart from what the engine
now owns: an expression-valued `as` and a literal that names no binding are
refused on the authored text, before either expression runs.

Closes #666
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown

PR #667: ✨ Let <Json> capture its text with as

10 files, +263 / -56

Scope

✅ PR scope looks good.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

✅ Oxlint found no issues.

Correctness

No extraneous code patterns detected.

The registry's description already said what <Json> does; only the  field
was untrue. Restoring the original sentence keeps this PR to the one prose
change the behavior requires.
@taras
taras marked this pull request as ready for review August 30, 2026 18:42
@taras
taras enabled auto-merge (squash) August 30, 2026 18:43
@taras
taras merged commit e75859d into main Aug 30, 2026
30 checks passed
@taras
taras deleted the agent/issue-666-json-capture branch August 30, 2026 18:50
taras added a commit that referenced this pull request Aug 30, 2026
… making it (#260)

Rebased onto `<Json as>` (#667), which supplies what the single-child `<Let>`
wrapper was standing in for. Both wrappers in the packaged prompt command
document are gone; `<Json value={check.diagnostics} as="problems" />` binds the
serialized text directly, and the bytes reaching `<CodeBlock>` are the same
bytes — the repair turn and the review presentation still carry the complete
structured problems, now proven by parsing the fence back rather than by
matching a substring of it.

The default session's directory is now a scope-owned resource rather than a
directory with an ensure attached afterwards. The release is registered before
the `mkdir` that could create it, so there is no window in which a leaf exists
that nothing is responsible for: a failure between making it and using it hands
it back like every other ending. What the release knows is whether the
directory was ever handed over, and that decides which question it is answering.

Once established, the exit has exactly two outcomes and no third. Still the
empty directory it was given, and the leaf is removed, non-recursively. Anything
else — content that appeared, or a directory that vanished — is preserved as
found and fails the command terminally. A leaf that disappears under a live
conversation was silently accepted before, which was wrong twice over: nothing
here is allowed to remove it, so its absence is interference, and treating
interference as a clean exit would let admission, the save and the execution
proceed on a conversation something else had already reached into.

A directory establishment never handed over is a different question, already
answered by what establishment reported. The release leaves that report and the
directory's contents alone, while still handing back an empty leaf it did
create.

Explicitly named directories are untouched by any of this: they stay, because
the next `--session` derives the same ACPX session identity from where they are.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6
taras added a commit that referenced this pull request Aug 30, 2026
…260) (#657)

* ✨ Add xmd prompt, the authorship command (#260)

`xmd prompt "<request>"` asks the configured ACP agent for a complete
executable Markdown root, validates it without executing any of it, repairs its
definite defects by asking again, shows a person the exact bytes, and runs the
approved ones through the ordinary supplied-source path under the `<prompt>`
identity. It adds authorship around one normal document; it adds no second
execution model, props model or journal.

Authorship sits outside durability. The catalog, the fresh generator session,
every repair and the review create no journal and replay nothing, and the
generator's scope closes before the optional exclusive `--save` and before
execution. A defect the draft authored is repairable and earns one of three
turns; a defect the command line authored terminates without spending a turn on
something the agent cannot fix.

`xmd run` and `xmd prompt` now share one execution field set, one resolved Agent
configuration, and one props source resolution.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Settle one agent stack, refuse -e, and report a run's failure (#260)

Three review findings on the xmd prompt command.

An inline document reached the generator. `preparePropsPhase` skips the parse
that carries the other commands' `-e` refusal, so a supplied document was
dropped in silence and the command generated a different one. Refused in
prompt's own branch, before the catalog, the provider, the review, the save,
the journal or any execution.

The agent configuration was resolved twice, once for generation and again for
the document, so one command line had two chances to read DEFAULT_AGENT_NAME
differently. A dispatch now settles it once and hands the resolved value to
both consumers; DocumentMode carries the settled stack rather than the flags.
The executed program still gets a fresh ordinary provider and inherits neither
the generator session nor its system prompt.

A document failing its own <Testing> boundary printed a bare message where a
run prints a `tests failed:` heading. reportFailure moves to a module both
commands share.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* ✨ Ship the plan program as a packaged Markdown document (#260)

The first document-backed command needs its Markdown where the command is: a
source checkout, a published npm package, and a compiled binary with no
checkout. The document is the deployed program, so it ships as a file rather
than as a generated string.

src/documents/prompt-plan.md is that program. A package declares what it ships
by putting it in that directory; test documents and scenario fixtures live under
src/ too, and a build that swept those up would publish fixtures and grow every
binary carrying them.

The command locates it from its module's own URL, never from the working
directory and never through the component search path, so it finds the same
policy whatever directory a person stands in and no repository file can answer
for it.

Each build keeps the directory beside its module: both deno compile sites embed
it, and the npm build copies it into the emitted tree, because dnt emits the
module graph only and an asset nothing imports would otherwise reach Node and
Bun missing while Deno stayed green. A regression holds the two compile sites to
the documents that exist, since that list is the one thing no build discovers.

The host still runs the TypeScript authorship loop; wiring the plan program into
the plan profile follows.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Make plan approval and exhaustion mutually exclusive (#260)

A <Return> selects the value a value body publishes; it does not end that body,
and a later failure wins over a value already selected. The approve branch
returned the candidate and broke out of the review loop, then execution left the
Session and reached the unconditional exhaustion <Fail>. Every approval settled
as the ten-draft failure and no approved candidate could reach the host.

Approval now binds the exact candidate to a null-sentinel result, and one
exhaustive branch after the Session either returns it or fails. Only the arm
taken runs, so an approved candidate has no later sibling failure to overtake
it. Bounds, prompt wording, diagnostics, presentation, Session structure and the
authored abort remain as they were.

The review schema also needed an explicit string type: Ajv strict mode refuses
minLength on an untyped property, so every review was refused before a person
saw anything.

Diagnostics reach their code block through <Json> rather than a JSON.stringify
call in an expression, so serializing them is a document construct like
everything else this policy does.

The regression runs the packaged document itself, resolved against first-party
declarations with an empty include list, against a scripted agent turn, a
scripted approval and a test validator. It fails with the exhaustion message
before this change.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* ✨ Turn a request into a Plan with a packaged command document (#260)

`xmd prompt` now executes two roots with a scope boundary between them. First
`src/documents/prompt-command.md`, the checked-in first-party Markdown that
implements the conversion and its review workflow; then the Plan it returned,
through the ordinary supplied-source path. The TypeScript authorship loop —
`author()`, `evaluate()` and `review()` — is gone: what the assistant is asked,
how many drafts may be fixed, how many you are shown and what happens when you
approve nothing are readable Markdown now, and arguable.

A Plan is not a script with comments. The shipped generation, repair and
revision instructions ask for every requested outcome as reader-facing prose
with each component placed immediately after the sentences describing what it
does, so the source can be audited and the execution followed by the same
narrative. That authorship rule is the document's and human review's; it is
never a hidden validation rule.

The prompt profile is what that document runs inside. The assistant gets an
empty directory of this host's own, no additional directories, no MCP servers,
no native tools and a strict denial answered inside the provider — so nothing
composed around it can widen a ceiling with nothing in it, and `--approve-all`
configures the approved Plan rather than the conversation that wrote it. The
document itself reaches no Files, command, service or network capability, runs
against no repository component search, and cannot opt out of a failing turn
ending it: a turn that streamed half a program and failed presents nothing.

`<ValidateCandidate>` is declared to that execution rather than resolved from a
repository, and it keeps the two kinds of failure apart. A defect a draft
authored comes back as structured facts the document may repair; a defect the
command line authored raises out of it, so no policy can catch a caller's
mistake and call it feedback.

The host takes the returned Plan back as untrusted text. Only after every Prompt
task, provider and Elicitation resource has torn down does it validate those
exact bytes again and resolve their props — a revision that changed a property's
declared type changes what the run receives — and only then does `--save` create
the file and the Plan run.

`--session <name>` names the assistant session; without it each invocation
places its own. The profile's working directory is one fixed host-owned path
because a session's key includes the directory it lives in, and a location that
changed every time would leave that option unable to name anything.

Evidence runs the packaged document itself against a scripted agent and a
scripted review: the shipped words reach the turn, ten presentations bound the
review with no revision on the tenth, an interleaved Plan returns byte for byte,
and what a person reads says each thing once however many rounds it took.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Give each prompt session its own directory and a reachable exhaustion (#260)

Four corrections to the prompt profile and the command document it runs.

One directory per logical session, at
`~/.xmd/prompt/sessions/<sha256(logical-session-name)>`, replaces the single
`~/.xmd/prompt` every invocation shared. A shared directory was one ambient
location two conversations could both stand in; a fresh one each time would have
left `--session` unable to name anything, since a session's key includes the
directory it lives in. The digest gives both: a generated name reaches a
location nothing else does, and the same explicit name reaches the same one, so
the provider continues the session it established rather than placing a second.
The leaf is the digest and never the name — a caller's string that becomes a
path is a caller's string that can escape one.

Emptiness is now proven rather than assumed. The directory is created empty and
required to be empty before the provider is constructed or a session is
materialized, and anything already in there is a terminal refusal naming the
path. Nothing is deleted: what is in a directory this host did not authorize
anything to write to is not this command's to clean up.

Stopping on the tenth draft says two different things, and now the document says
both. When that draft still has problems, `abort` is the only choice offered and
there was never a Plan to approve, so it routes to the authored exhaustion
message. Every other abort — including one on a tenth draft that could have been
approved — stays the ordinary ending. The branch after the Session remains an
exhaustive fallback.

Final admission is now shown to be independently effective. A scripted draft
uses a repository component that exists while the command document runs and is
removed as its scope tears down; the unchanged approved bytes then fail the
host's second validation, in that order, with no save, journal or execution.
Both validations run the production path — leaving the component in place makes
the case pass and execute, which is what makes it evidence.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Own the profile root in tests, and give a default session's directory back (#260)

Two ownership corrections: one about which directories a test may touch, and one
about which directories an invocation may keep.

The suite no longer reads, creates or removes anything under a real
`~/.xmd/prompt`. Snapshotting that root and deleting whatever appeared after the
snapshot was unsafe on its face — absence from a snapshot says nothing about who
created a directory, so an overlapping case or a real invocation could have its
state removed by a test that never owned it. Where profile session directories
live is now a host dependency: production keeps its default, and a harness names
a tree it made itself, passed as a value with no flag, environment variable,
document prop or replaceable context anywhere near it. `createPromptHarness`
requires that root, so no case can fall back to the host's, and each root is a
per-scope temporary directory removed whole — which is safe precisely because
the scope created everything in it. The one case that needs two invocations to
share a root says so explicitly.

Only an explicitly named session needs its directory afterwards. Its identity is
what a later `--session` derives the same ACPX session from, so it stays, is
required empty on the way in every time, and is never cleaned. An
invocation-unique default names nothing anybody can ask for again, so its
directory belongs to the command: after the command document and every provider,
Prompt task and Elicitation resource inside it has torn down, exactly one
cleanup is attempted, and it settles before final admission, the save or the
execution begins. Still empty, and the leaf is removed non-recursively. No
longer empty, and the directory and its contents are preserved and the command
fails terminally — this host authorized nothing to write there, and deleting a
stranger's files to get on with the work is the opposite of what a ceiling is
for. Being an `ensure` is what makes an abort, a failed turn and a cancellation
settle the same way a success does.

Which of the two applies is a trusted host value — whether the caller wrote
`--session` — never inferred from the shape of the generated name, and never
visible to the command document.

Both outcomes are held by evidence that fails without them: disabling the
cleanup leaves the leaf behind on success and before admission, and making the
removal recursive turns the preserved-and-refused case into a silent success.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* ✨ Capture the problems with <Json as>, and claim the directory before making it (#260)

Rebased onto `<Json as>` (#667), which supplies what the single-child `<Let>`
wrapper was standing in for. Both wrappers in the packaged prompt command
document are gone; `<Json value={check.diagnostics} as="problems" />` binds the
serialized text directly, and the bytes reaching `<CodeBlock>` are the same
bytes — the repair turn and the review presentation still carry the complete
structured problems, now proven by parsing the fence back rather than by
matching a substring of it.

The default session's directory is now a scope-owned resource rather than a
directory with an ensure attached afterwards. The release is registered before
the `mkdir` that could create it, so there is no window in which a leaf exists
that nothing is responsible for: a failure between making it and using it hands
it back like every other ending. What the release knows is whether the
directory was ever handed over, and that decides which question it is answering.

Once established, the exit has exactly two outcomes and no third. Still the
empty directory it was given, and the leaf is removed, non-recursively. Anything
else — content that appeared, or a directory that vanished — is preserved as
found and fails the command terminally. A leaf that disappears under a live
conversation was silently accepted before, which was wrong twice over: nothing
here is allowed to remove it, so its absence is interference, and treating
interference as a clean exit would let admission, the save and the execution
proceed on a conversation something else had already reached into.

A directory establishment never handed over is a different question, already
answered by what establishment reported. The release leaves that report and the
directory's contents alone, while still handing back an empty leaf it did
create.

Explicitly named directories are untouched by any of this: they stay, because
the next `--session` derives the same ACPX session identity from where they are.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* ✨ Make the approved Plan the result, and the workflow readable end to end (#260)

The command document is now a workflow somebody can read straight through. It
opens by saying what `xmd prompt` is for — a Prompt describes steps, the
components carry them out, and the coding agent turns both into one document
that explains and executes the sequence — and every stage after that is a
heading: creating the first draft, checking and repairing it, reviewing it,
continuing from the decision, returning the approved Plan.

Every Plan now begins with one descriptive level-one title, and the generation,
repair and revision turns each ask for it alongside the steps-beside-components
structure, so a replacement cannot quietly drop either. The worked example is a
titled Plan itself. This is an authorship and human-review requirement, not a
rule hidden in the checker: `<CheckDraft>` reports structural facts and would
accept a titleless Plan that a person would send back.

`<ValidateCandidate>` is `<CheckDraft>` throughout. It checks a draft rather than
validating a candidate, and nothing about what it does changed.

The review choices are the words a person reads — Approve, Request changes,
Stop — with no internal spelling behind them. A tenth draft that still has
problems now offers something better than only stopping: Explain what went
wrong makes one more ordinary turn in the same Session, carrying only the final
diagnostics, because the conversation already holds the Prompt, the catalog and
every draft. It asks for an explanation and explicitly not another Plan; its
answer is inert text, reopens no draft limit, is reported as the coding agent's
own words, and ends the command. So the host's instruction layer no longer
demands that every answer be document source — it says only that an answer
belongs to the message that asked for it, and each authored turn owns its own
shape.

The approved Plan is the command's result. By default it goes to stdout, byte
for byte, with nothing this command added, so it can be piped, diffed or read
before anybody commits to it. `--output` puts those bytes in a file instead,
exclusively created. `--run` runs it. With both, the file is written first and
only a successful write is followed by the run. `--save` is gone rather than
aliased; nothing has been released to keep compatible with.

That makes most execution flags conditional, so they are refused rather than
ignored: a caller who asks for a journal, a permission mode or an exec deadline
from a command that runs nothing has not been answered. `--include`,
`--agent-provider`, `--default-agent`, `--session` and `--timeout` keep working
always — they build the catalog, settle the agent, name the conversation, admit
properties and bound the command. A journal exists only when `--run` begins.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 State the whole Plan requirement in every turn that asks for one (#260)

A repair or a revision was told to "keep" the draft's title. That is the wrong
instruction for a replacement: a draft whose title is missing, in the wrong
place, or does not describe the Plan has nothing worth keeping, and an
instruction to keep it asks the coding agent to carry the defect forward. Each
Plan-producing turn — the first draft, each repair, each revision — now states
the complete requirement on its own: optional frontmatter and then one
descriptive level-one heading as the first body content, the Prompt's complete
sequence as readable steps, every requested outcome, a meaningful order, and each
component beside the prose describing what it does. Replacements are told to
write the title the Plan needs rather than the one the last draft had.

None of that reaches `<CheckDraft>`, `validateDocument()` or TypeScript. It is
what the workflow asks for and what a person reviews, which is where a judgement
about whether a title describes a Plan belongs.

The prose under "Continue from your decision" claimed a final invalid draft left
only one choice. It leaves two: ask the coding agent what went wrong, or stop.

The governing documents still described the earlier command. They were swept
deliberately rather than by replacing words: the opening example is a titled
Plan, the flow shows the four destinations, and the failure table, timeout
paragraph, acceptance rows, terminology rows, construct inventory, executable-MDX
identity section, ACP lifecycle paragraph and root-props admission paragraph no
longer say that an approved Plan always runs, that `--save` is the output option,
that stopping is called abort, that admission is followed unconditionally by
execution, or that every success writes a journal.

The evidence follows the same rule. The approved introduction is pinned whole,
punctuation included, rather than sampled; the requirement block is asserted
against the actual initial, repair and revision turns and counted three times in
the shipped source; and the run-only preflight table covers every spelling,
including `-V` and `--secret-detection`. The test-local `validateCandidate` is
`checkDraft`, so a terminology sweep finds no survivor of the old name.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Use the approved apostrophe, and refuse --save by name (#260)

The introduction claimed to be the approved wording and was one byte away from
it: `coding agent's plan` with an ASCII apostrophe where the contract has U+2019.
The regression that was supposed to catch that had copied the same byte, so it
pinned the file to itself rather than the file to the contract. Both now carry
E2 80 99, and the fixture's comment says why it is the byte and not the sentence
that is being held.

Two contract passages still described the earlier command. The ACP lifecycle
said the profile's scope closes before "the save and the execution"; it closes
before final admission and before whichever result was asked for. The command
spec said `xmd prompt` executes two root documents; it always executes the
command document and executes a second only under `--run`.

The terminology sweep reached the comments, messages and test names that the
earlier passes left behind: "the save" in the deadline and teardown comments, the
harness's `--save`, the profile's two directory failures now saying "nothing was
output or run", and the review-decision comments and test names that still called
stopping an abort. The unrelated abort in `runDocument` is left alone — it is
about a document's own failure, not this command's.

`--save` had no real proof it was gone. The case that passed it also passed two
mutually exclusive permission flags, so the earlier failure decided the outcome
and the assertion would have held whether or not the option still worked. That
case no longer carries it, and a focused one does — on a command line that is
otherwise entirely valid.

Making that case honest exposed something worth fixing. `--save out.md` was
refused, but as "unrecognized argument: out.md": the scanner passed the unknown
option to a parser that stops at the first option it does not define and drops
the rest, so what a caller heard about was the value rather than the flag. Fixed
preflight now refuses an option this command does not define, by name, and names
`--output` when the option was `--save`.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🤖 Say that the second root execution is optional (#260)

The module contract opened by claiming `xmd prompt` owns two root document
executions, and its flow diagram ended by running the second one unconditionally
— one line after the line listing stdout, an `--output` file, a run, or both as
the four ways the bytes are delivered. A reader following the diagram would
conclude that a default invocation runs the Plan it just printed.

Every invocation executes the packaged prompt command document. A second root is
executed only under `--run`. The diagram now shows the four deliveries as the
alternatives they are, and the paragraph after it puts the complete scope
boundary where it belongs: before that optional second execution, so whatever
result follows has already let go of the conversation that wrote it.

The same claim opened the prompt profile's header and the Tier PR introduction,
and three nearby comments still had execution as the thing that follows approval
rather than one of the things that might. Corrected to the approved bytes, or to
the run that may follow.

Prose only: no runtime behaviour, no tests and no evidence changed.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Refuse a valued --run instead of reading it as the switch (#260)

An option's name is read up to its first `=`, so `--run=false` arrived under the
name of the switch and was taken for it. That set the flag saying execution had
been requested, which is also what satisfies the gate refusing options that only
configure a run — so `--run=false --journal trace.jsonl` was accepted.

What followed is worth stating precisely, because it is not what the flag being
set suggests. The ordinary parser reads `--run=false` as false and `--run=true`
as the field's default, which is also false. So the command took the write path
either way: `--run=false --journal <path>` completed successfully, created no
journal, and never told the caller that the journal they asked for was not a
thing this invocation would produce; and `--run=true` was silently answered by a
command that wrote the Plan instead of running it. One half of the command
believed a run had been requested and the other half did not, and neither told
anybody.

Every valued spelling — `--run=false`, `--run=true`, `--run=` — now fails fixed
preflight, before it establishes that execution was requested and before it can
answer for the gate. Nothing is loaded, built, contacted, opened, created or
executed.

The regressions fail without it: the scanner cases find no error, and the
command-level case exits 0 where it now exits 1 — the old behaviour was a
success, not an incidental failure, which is what makes the tripwires worth
asserting.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Assert the session and output-file tripwires on the --run= refusal (#260)

The refusal has to prove authorship never began, and the two tripwires that say
so most directly were implied rather than asserted. A provider count of zero
already rules out an established session, but the claim worth making is the
narrow one: no session was established, and no directory was made for one to run
in. The absence of an output file was likewise folded into an empty working
directory rather than stated.

Evidence only: no behaviour, no message and no other case changed.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

* 🐛 Pin the valued --run refusal at the parser boundary it crossed (#260)

The defect was a disagreement between two layers: the scanner read `--run=false`
as the switch while the parser read it as false. The command-level regression for
it hand-built the scanned result and called `runPrompt` directly, so it never
reached `preparePropsPhase`, the parser or dispatch — the boundary the bug lived
on. It would have passed with the two layers still disagreeing.

A real invocation now covers it, for `--run=false`, `--run=true` and `--run=`:
the exact refusal, empty stdout so no approved Plan escaped, no unavailable-agent
error because reaching a provider is what produces one, and an empty working
directory holding neither the journal it named nor anything else.

Both existing cases stay, because the three prove different things. The scanner
table is the grammar. This one is the boundary. The direct one names the phases
that stayed at zero, which a subprocess cannot see.

Without the refusal this case fails at `Expected: 1 / Received: 0`, and its
stderr carries the unavailable-agent error — the command line reached a provider,
which is the authorship this refusal exists to prevent.

Evidence only: no production change.

Claude-Session: https://claude.ai/code/session_015HcqB9kJM9KFnMNToAuZF6

---------

Co-authored-by: Taras Mankovski <74687+taras@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let <Json> bind its rendered text with as

1 participant