From 3377152d07477cfe3db4f05defbce83adbf9dd56 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 2 Sep 2026 21:33:36 -0700 Subject: [PATCH 1/6] docs(openspec): the export covers the chooser as well as its destinations --- .../changes/export-route-topic/.openspec.yaml | 2 + .../changes/export-route-topic/proposal.md | 45 +++++++++++++++++++ .../specs/cli-knowledge-prompts/spec.md | 27 +++++++++++ openspec/changes/export-route-topic/tasks.md | 24 ++++++++++ 4 files changed, 98 insertions(+) create mode 100644 openspec/changes/export-route-topic/.openspec.yaml create mode 100644 openspec/changes/export-route-topic/proposal.md create mode 100644 openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md create mode 100644 openspec/changes/export-route-topic/tasks.md diff --git a/openspec/changes/export-route-topic/.openspec.yaml b/openspec/changes/export-route-topic/.openspec.yaml new file mode 100644 index 00000000..032461ff --- /dev/null +++ b/openspec/changes/export-route-topic/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/export-route-topic/proposal.md b/openspec/changes/export-route-topic/proposal.md new file mode 100644 index 00000000..142638a3 --- /dev/null +++ b/openspec/changes/export-route-topic/proposal.md @@ -0,0 +1,45 @@ +## Why + +`cli-knowledge-prompts` says `route` is not exported, and gives the reason: it +contains local mechanics a Worker cannot run, so until that changes a consumer +"gets each destination's own scope from these three and adjudicates a genuinely +ambiguous call itself." + +That second half failed in production, and the platform generator is the +evidence. It adjudicated the ambiguous calls itself: it hand-wrote the same +judgement `route` describes, reached a two-value classification with no way to +name `vale`, and generated every prose rule as an ast-grep rule while its own +delivery layer could already serve a Vale one. It shipped a tier it could not +produce, and neither side noticed until the demonstration returned the wrong +one. + +The local mechanics remain real and do not follow. A consumer ignores them, +which is a smaller and more honest adaptation than restating the criteria. + +## What Changes + +**`route` joins `TOPICS`.** The requirement covers both halves of the decision +rather than the destinations alone: a consumer that can author for every engine +must also be able to reach the choice between them. + +The spec's own argument already contains this. It says exporting a chooser +without its destinations strands a consumer that can route but not author. The +mirror is what we shipped, and it is worse in one specific way: a consumer +missing a destination stops, while a consumer missing the chooser writes its +own. + +## Capabilities + +### Modified Capabilities + +- `cli-knowledge-prompts`: the exported-topics requirement covers the chooser as + well as the destinations, and records why the withholding argument did not + survive contact with a consumer. + +## Impact + +- `openspec/specs/cli-knowledge-prompts/spec.md` — one requirement, amended. +- `packages/cli/src/prompts/index.ts` — `route` moves from `INTERNAL_TOPICS` to + `TOPICS`, shipping in the same PR. +- The completeness check (`TOPICS + INTERNAL_TOPICS` accounts for every canonical + recipe on disk) is unaffected: the topic moves between lists. diff --git a/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md b/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..ba14d7f0 --- /dev/null +++ b/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: Exported topics cover every engine a rule can be routed to + +`TOPICS` SHALL export `route`, the recipe that chooses an authoring destination, and the authoring recipe for each engine it can choose — `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule`. + +A consumer that can decide a rule belongs to an engine must be able to reach the procedure for authoring one, and a consumer that can author for each engine must be able to reach the choice between them. Exporting either half alone strands the other, and the two are not symmetric in their consequences: a consumer missing a destination stops, while **a consumer missing the chooser writes its own**, which is the divergence this export exists to prevent. + +`route` was withheld on the grounds that it contains local mechanics a service consumer cannot run, and that such a consumer could adjudicate a genuinely ambiguous call from the three destinations. The first is true and does not follow: a consumer ignores the mechanics, which is a smaller adaptation than restating the criteria. The second was tried and failed in production — the platform generator hand-wrote the same judgement, reached a two-value classification with no way to name `vale`, and generated every prose rule as an ast-grep rule while its own delivery layer could already serve a Vale one. + +`engine-selection` leaves the export because it stops existing: the criterion it carried now lives in `route`, stated once. + +#### Scenario: The chooser is reachable from the export + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL contain `route` +- **AND** `route` SHALL be a member of `PromptTopic` + +#### Scenario: Every engine's authoring path is reachable from the export + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL contain `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule` + +#### Scenario: The exported set follows the rename + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL NOT contain `static` or `engine-selection`, neither of which names a recipe any more diff --git a/openspec/changes/export-route-topic/tasks.md b/openspec/changes/export-route-topic/tasks.md new file mode 100644 index 00000000..ee2ed5c5 --- /dev/null +++ b/openspec/changes/export-route-topic/tasks.md @@ -0,0 +1,24 @@ +# Tasks + +One PR, alongside the export it describes. + +## 1. Export it + +- [x] 1.1 Move `route` from `INTERNAL_TOPICS` to `TOPICS`, and rewrite both doc comments so the old justification does not stand next to the new behaviour +- [x] 1.2 Confirm the completeness check still holds: the topic moved between lists rather than appearing or vanishing + +## 2. Say so in the spec + +- [x] 2.1 Amend the exported-topics requirement to cover the chooser as well as the destinations +- [x] 2.2 Record why the withholding argument failed, in the requirement rather than only in a commit message. It was tried in production and produced a generator that could not name `vale` +- [x] 2.3 Carry every existing scenario into the MODIFIED delta, and verify by archiving on a scratch commit. A delta replaces a requirement rather than patching it +- [x] 2.4 Keep the requirement's TITLE unchanged. **Measured**: `openspec archive` matches a MODIFIED block to the standing requirement by title, so renaming it applies nothing — the standing text survives untouched and the delta is discarded, while `openspec validate --strict` still passes. The amended framing lives in the body instead + +## 3. What the consumer still does not get + +- [ ] 3.1 The rendered recipe instructs a reader to run two CLI commands. `invocation` substitutes the binary name, so a consumer setting it to a phrase gets a malformed instruction rather than a clean absence. Decide whether a render option that drops those steps ships with the export or follows it +- [ ] 3.2 If it follows, say so where a consumer reads it, so the limitation is documented rather than discovered + +## 4. Close out + +- [ ] 4.1 Archive the change From 337fbeaa1f0c5eb1eb86e700e201ab719fe938c3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 2 Sep 2026 21:34:11 -0700 Subject: [PATCH 2/6] docs: a renamed requirement makes its delta a silent no-op The MODIFIED block is matched to the standing requirement by title. Rename it and nothing is applied: the standing text survives untouched, the delta is discarded, and `openspec validate --strict` passes on both sides. Found by doing it. The delta for `cli-knowledge-prompts` renamed a requirement to say it covers the chooser as well as the destinations, and the archive left the old requirement exactly as it was, with zero occurrences of the new text. This is the quieter half of the trap already recorded here. Dropping a scenario at least changes something; a rename looks like a substantial edit and does nothing. A title that should change is a REMOVED plus an ADDED. Also corrects the verification procedure, which bit me twice while measuring this. Reset to the scratch commit's own SHA, not to `HEAD~1`: resetting past it deletes the change under test, and `git clean` then removes what the reset left untracked. --- CLAUDE.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f91ebe95..a75c35a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,17 +286,27 @@ Signatures compare as whole strings -> (gone) Two normative scenarios, about parameter-parsing order and whole-string comparison, would have left the spec as a side effect of documenting something unrelated. +**A MODIFIED block is matched to the standing requirement BY ITS TITLE. Rename the requirement and the delta applies nothing at all.** Measured the same way: the standing text survived untouched, the delta was discarded, and `openspec validate --strict` passed on both sides. This is the quieter half of the same trap. Dropping a scenario at least changes something; a rename looks like a substantial edit and is a no-op. If a requirement's title should change, that is a REMOVED plus an ADDED, not a MODIFIED. + **Do not rely on a preserve-on-sync guardrail.** `openspec-sync-specs` advises retaining content a delta does not mention, and that advice does not reach `openspec archive`, which is what actually runs. A delta that omits a scenario, and a comment saying the omission is deliberate, produce the same result: the scenario is gone. **Check it the way it was found**, since nothing else will. `openspec validate --strict` passes on a delta that drops scenarios: ```bash -# commit everything first: the revert below is a hard reset +# Commit first and KEEP THE SHA. The reset below is hard, and `git clean` +# removes the change directory along with the archived copy. +git add -A && git commit -S -m "wip: pre-archive check" && SAFE=$(git rev-parse HEAD) pnpm openspec archive -y -grep -n "^#### Scenario" openspec/specs//spec.md -git reset --hard HEAD && git clean -fd openspec/ +grep -n "^#### Scenario" openspec/specs//spec.md # every prior one still there? +git reset --hard "$SAFE" && git clean -fd openspec/ ``` +**Reset to that SHA, never to `HEAD~1`.** Resetting past the commit deletes the +change you were testing, and `git clean` then removes what the reset left +untracked. Recovering means `git checkout -- openspec/changes/` +from the reflog, and copying a backup directory _onto_ the change directory +nests a stray copy inside it rather than replacing its files. + Every scenario present before must still be present, plus whatever you added. Two cautions from doing it: commit first, and restore uncommitted edits by copying files **over** the change directory rather than copying the directory onto itself, which nests a stray copy inside it. **The paranoia is warranted because the failure is invisible.** Nothing fails, no check reports it, and the requirement still reads coherently afterwards. It just no longer says the thing it used to say. From 97c0a36767443e6db9542f7bf6a66b50a2d1324c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 2 Sep 2026 13:25:36 -0700 Subject: [PATCH 3/6] feat(prompts): export route, so a consumer stops writing its own chooser `route` sat in INTERNAL_TOPICS on two grounds. It contains local mechanics a Worker cannot run, and a consumer could adjudicate ambiguous calls from the three authoring recipes it already had. The first is true and does not defeat it: a service consumer ignores the mechanics, which is a smaller and more honest adaptation than restating the criteria. The second was tested in the field and failed. The platform generator hand-wrote the same judgement, reached `static | runtime` with nowhere to put `vale`, and generated every prose rule as an ast-grep rule while its own delivery layer could already serve a Vale one. That is the dead end this surface exists to remove, mirrored. The file already warned that exporting a chooser without its destinations strands a consumer that can route but not author; destinations without the chooser strand one that can author but not route, and it writes its own chooser rather than stopping. Reworded one sentence to avoid the phrase `from "which engine"`, which the source-level import scanner in test/prompts.test.ts reads as an import. That is the false positive the code styleguide documents by name, still live in the source-level half of a check whose built-output half was replaced by a vite plugin. Filed separately rather than fixed here. --- .changeset/export-route-prompt.md | 18 ++++++++++++++++++ packages/cli/src/prompts/index.ts | 30 ++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 .changeset/export-route-prompt.md diff --git a/.changeset/export-route-prompt.md b/.changeset/export-route-prompt.md new file mode 100644 index 00000000..dca5ee22 --- /dev/null +++ b/.changeset/export-route-prompt.md @@ -0,0 +1,18 @@ +--- +"@taskless/cli": patch +--- + +`route` is now exported from `@taskless/cli/prompts`. + +It was withheld because it contains local mechanics a service consumer cannot +run, and because a consumer could adjudicate ambiguous calls from the three +authoring recipes. Both halves were wrong in the same way. The mechanics are +real and a consumer ignores them, which is a smaller adaptation than restating +the criteria; and adjudicating from the destinations is what the platform +generator actually tried. It hand-wrote the same judgement, arrived at +`static | runtime` with nowhere to put `vale`, and generated every prose rule as +an ast-grep rule while its own delivery layer could already serve a Vale one. + +Exporting a chooser without its destinations strands a consumer that can route +but not author. Exporting destinations without the chooser strands one that can +author but not route, and it writes its own chooser rather than stopping. diff --git a/packages/cli/src/prompts/index.ts b/packages/cli/src/prompts/index.ts index c1736c17..494968e6 100644 --- a/packages/cli/src/prompts/index.ts +++ b/packages/cli/src/prompts/index.ts @@ -38,16 +38,32 @@ import { * remove. * * `engine-selection` used to be exported alongside them. It no longer exists: - * the criterion it carried now lives in `route`, stated once. `route` is not - * exported yet because it still contains local mechanics (`taskless detect`, - * on-device authoring) a Worker cannot run; until it is, a consumer gets each - * destination's own scope from these three and adjudicates genuinely ambiguous - * calls itself. + * the criterion it carried now lives in `route`, stated once. + * + * `route` was withheld on the grounds that it still contains local mechanics + * (`taskless detect`, on-device authoring) a Worker cannot run, and that a + * consumer could adjudicate ambiguous calls from the three destinations. Both + * halves were wrong in the same way. + * + * The mechanics are true and do not defeat it: a service consumer ignores + * them, which is a smaller adaptation than restating the criteria. And + * adjudicating from the destinations is what the platform generator actually + * tried. It hand-wrote the same judgement, arrived at `static | runtime` with + * nowhere to put `vale`, and generated every prose rule as an ast-grep rule + * while its own delivery layer could already serve a Vale one. `route` warns + * about exactly that conflation: whether a rule is static or runtime is one + * axis, and which engine authors it is another. + * + * That is the dead end named two paragraphs up, mirrored. Exporting a chooser + * without its destinations strands a consumer that can route but not author; + * exporting destinations without the chooser strands one that can author but + * not route, and it will write its own chooser rather than stop. */ export const TOPICS = [ "create-sg-rule", "create-vale-rule", "create-runtime-rule", + "route", ] as const; /** @@ -57,8 +73,7 @@ export const TOPICS = [ * - Command recipes (`auth` … `update`) walk an agent through running a CLI * subcommand on a developer's machine. There is no caller for them outside * the CLI that hosts those commands. - * - Authoring recipes are unreachable server-side: `route` picks an authoring - * destination before the service is involved, `create-remote-rule` states + * - Authoring recipes are unreachable server-side: `create-remote-rule` states * the boundary from the client's side, `detect` documents a CLI subprocess a * Worker cannot spawn, `create-legacy-rule` targets a local toolchain, and * `rule-meta` describes a local sidecar file the CLI never writes. @@ -75,7 +90,6 @@ export const INTERNAL_TOPICS = [ "info", "init", "onboard", - "route", "rule", "rule-meta", "update", From 0ee3819ca4151cd36f2e279efe66524b056609af Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 2 Sep 2026 21:51:21 -0700 Subject: [PATCH 4/6] feat(prompts): mechanics: false, so a consumer without a CLI gets a clean absence Exporting `route` shipped a recipe whose first two steps tell a reader to run `taskless detect --json` and `taskless info --json`. A service consumer cannot, and `invocation` does not answer it: that option substitutes a binary NAME inside a command, so setting it to a phrase renders Run: ``` no CLI is available detect --json ``` which is an instruction to execute something that does not parse. Worse than either honest answer, and it worked only because a forgiving model skipped past it to the criteria. `mechanics: false` replaces both steps with what the caller must supply. It does not drop them: the routing criteria are stated in terms of linters, languages, rule styles, `loggedIn` and `ghOwner`, so a consumer that reads nothing there loses the inputs rather than the commands. Rendered through the variable table as whole blocks rather than stripped after the fact, so the default can be proved rather than asserted. Measured byte-identical at 18161 bytes, `diff` clean. `TASKLESS_CLI` and the new blocks now share one invocation resolver so the command inside a step cannot disagree with the command beside it. --- .changeset/export-route-prompt.md | 6 ++ openspec/changes/export-route-topic/tasks.md | 8 ++- packages/cli/src/agent/route.txt | 10 +--- packages/cli/src/prompts/recipes.ts | 58 +++++++++++++++++++- packages/cli/test/prompts.test.ts | 46 ++++++++++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/.changeset/export-route-prompt.md b/.changeset/export-route-prompt.md index dca5ee22..97ba271b 100644 --- a/.changeset/export-route-prompt.md +++ b/.changeset/export-route-prompt.md @@ -16,3 +16,9 @@ an ast-grep rule while its own delivery layer could already serve a Vale one. Exporting a chooser without its destinations strands a consumer that can route but not author. Exporting destinations without the chooser strands one that can author but not route, and it writes its own chooser rather than stopping. + +`getPrompt` gains `mechanics: false`, which replaces the two steps that gather +evidence by running the CLI with a statement of what the caller supplies +instead. `invocation` could not serve this: it substitutes a binary name inside +a command, so a consumer with no CLI rendered an instruction to run something +that does not parse. The default rendering is unchanged, byte for byte. diff --git a/openspec/changes/export-route-topic/tasks.md b/openspec/changes/export-route-topic/tasks.md index ee2ed5c5..6878c919 100644 --- a/openspec/changes/export-route-topic/tasks.md +++ b/openspec/changes/export-route-topic/tasks.md @@ -14,10 +14,12 @@ One PR, alongside the export it describes. - [x] 2.3 Carry every existing scenario into the MODIFIED delta, and verify by archiving on a scratch commit. A delta replaces a requirement rather than patching it - [x] 2.4 Keep the requirement's TITLE unchanged. **Measured**: `openspec archive` matches a MODIFIED block to the standing requirement by title, so renaming it applies nothing — the standing text survives untouched and the delta is discarded, while `openspec validate --strict` still passes. The amended framing lives in the body instead -## 3. What the consumer still does not get +## 3. Make the export usable by the consumer asking for it -- [ ] 3.1 The rendered recipe instructs a reader to run two CLI commands. `invocation` substitutes the binary name, so a consumer setting it to a phrase gets a malformed instruction rather than a clean absence. Decide whether a render option that drops those steps ships with the export or follows it -- [ ] 3.2 If it follows, say so where a consumer reads it, so the limitation is documented rather than discovered +- [x] 3.1 Add `mechanics: false`, which replaces the two CLI-running steps with a statement of what the caller supplies. `invocation` could not do this job: it substitutes a binary NAME inside a command, so a consumer with no CLI setting it to a phrase renders `Run: detect --json` — an instruction to execute something that does not parse +- [x] 3.2 Keep the evidence rather than dropping the steps. The routing criteria are stated in terms of linters, languages, rule styles, `loggedIn` and `ghOwner`, so a consumer that reads "nothing here" loses the inputs and not just the commands +- [x] 3.3 Render the steps as whole blocks through the variable table rather than stripping them afterwards, so the default can be proved unchanged. **Measured byte-identical**: 18161 bytes before and after, `diff` clean +- [x] 3.4 Point `TASKLESS_CLI` and the new blocks at one invocation resolver, so the command inside a step and the command elsewhere in the recipe cannot disagree ## 4. Close out diff --git a/packages/cli/src/agent/route.txt b/packages/cli/src/agent/route.txt index 925ccb86..0e93de8b 100644 --- a/packages/cli/src/agent/route.txt +++ b/packages/cli/src/agent/route.txt @@ -17,10 +17,7 @@ answered together. ## Steps -1. **Read the repository.** Run: - ``` - %(TASKLESS_CLI)s detect --json - ``` +1. **Read the repository.** %(DETECT_EVIDENCE)s This returns the configured linters, languages, and the repo's own rule styles. It is deterministic and offline. Use it as ground truth instead of guessing the repo's tooling. The scan is monorepo-aware, so @@ -36,10 +33,7 @@ answered together. } ``` -2. **Read the login state and the repository context.** Run: - ``` - %(TASKLESS_CLI)s info --json - ``` +2. **Read the login state and the repository context.** %(LOGIN_EVIDENCE)s and note `loggedIn` and `ghOwner`. Do this now, not later: they change which destinations exist, so classifying first means classifying against a set that may be wrong. diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 73dc8e60..b11ad252 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -125,6 +125,24 @@ export interface RecipeOptions { * @default "" */ invocation?: string; + /** + * Render the steps that gather evidence by running this CLI. + * + * `false` replaces them with a statement of what the caller must supply + * instead. It exists because `invocation` cannot do this job: that option + * substitutes the BINARY NAME inside a command, so a consumer with no CLI + * setting it to a phrase renders `Run: detect --json` — a + * malformed instruction rather than a clean absence, and worse than either + * honest answer. + * + * The evidence itself is not optional. A consumer that cannot run the + * commands still needs the linters, languages, rule styles, login state and + * owner, because the routing criteria are stated in terms of them. So this + * names what to supply rather than dropping the steps. + * + * @default true + */ + mechanics?: boolean; /** * Include the `# Topic: (CLI v / topic vN)` first line. * Suppressing it drops the CLI version from the text, which matters to @@ -147,6 +165,30 @@ export interface RecipeOptions { * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as * `` so the consuming agent knows to substitute. */ +/** + * What a step says when the caller supplies the evidence itself. + * + * Deliberately not an empty string: the step's following prose describes what + * the evidence contains and the criteria below are stated in terms of it, so a + * consumer that reads "nothing here" and moves on has lost the inputs rather + * than the commands. + */ +const SUPPLIED_EVIDENCE = + "This evidence is supplied by the caller rather than gathered here."; + +/** The `Run:` line and its fenced command, as the recipe has always shown it. */ +function runBlock(invocation: string, command: string): string { + return `Run:\n \`\`\`\n ${invocation} ${command}\n \`\`\``; +} + +/** The invocation a render should use, resolved the same way `TASKLESS_CLI` is. */ +function resolveInvocation(options: RecipeOptions): string { + return ( + options.invocation ?? + (isProductionInvocation() ? TASKLESS_CLI_MARKER : buildInvocation()) + ); +} + export function buildVariables( content: string, topic: string, @@ -169,12 +211,22 @@ export function buildVariables( VALE_CONVERTER_FORMATS: valeConverterList(), PACKAGE_MANAGER_DLX: options.packageManagerDlx ?? PACKAGE_MANAGER_DLX_MARKER, + // The two steps that gather evidence by running this CLI. Rendered as + // whole blocks rather than stripped afterwards, because the default must + // stay byte-identical to what `taskless agent route` has always printed + // and a post-strip cannot promise that. + DETECT_EVIDENCE: + options.mechanics === false + ? SUPPLIED_EVIDENCE + : runBlock(resolveInvocation(options), "detect --json"), + LOGIN_EVIDENCE: + options.mechanics === false + ? SUPPLIED_EVIDENCE + : runBlock(resolveInvocation(options), "info --json"), // Three steps, in descending order of how much the resolver actually // knows: the caller was told how the CLI was launched; the build is a // nightly/dev/self that knows what it is; nobody knows, so ask the agent. - TASKLESS_CLI: - options.invocation ?? - (isProductionInvocation() ? TASKLESS_CLI_MARKER : buildInvocation()), + TASKLESS_CLI: resolveInvocation(options), }; if (content.includes("%(INPUT_SCHEMA)s")) { const schema = TOPIC_INPUT_SCHEMAS[topic]; diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 256f03b0..7696ae27 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -139,6 +139,8 @@ describe("the CLI invocation variable", () => { "AST_GREP_LANGUAGES", "AST_GREP_VERSION", "CLI_VERSION", + "DETECT_EVIDENCE", + "LOGIN_EVIDENCE", "PACKAGE_MANAGER_DLX", "TASKLESS_CLI", "VALE_COMMENT_FORMATS", @@ -268,6 +270,50 @@ describe("raw and rendered instructions", () => { }); }); +describe("host mechanics suppression", () => { + it("renders the CLI steps by default, exactly as the recipe always has", () => { + const rendered = getPrompt("route"); + expect(rendered).toContain("detect --json"); + expect(rendered).toContain("info --json"); + // The fenced command block, not merely the words somewhere in the text. + expect(rendered).toMatch(/Run:\n {3}```\n {3}.*detect --json\n {3}```/); + }); + + it("replaces them with what the caller must supply", () => { + const rendered = getPrompt("route", { mechanics: false }); + expect(rendered).not.toContain("detect --json"); + expect(rendered).not.toContain("info --json"); + expect(rendered).toContain("supplied by the caller"); + }); + + it("leaves no malformed command behind when the caller has no CLI", () => { + // The defect this option exists for. `invocation` substitutes a binary + // NAME inside a command, so a consumer with no CLI that sets it to a + // phrase renders `Run: detect --json` — an instruction to execute + // something that does not parse, which is worse than either honest answer. + const withInvocation = getPrompt("route", { + invocation: "no CLI is available", + }); + expect(withInvocation).toContain("no CLI is available detect --json"); + + const withMechanicsOff = getPrompt("route", { + mechanics: false, + invocation: "no CLI is available", + }); + expect(withMechanicsOff).not.toContain("no CLI is available detect"); + expect(withMechanicsOff).not.toMatch(/Run:\n {3}```/); + }); + + it("keeps the evidence itself, since the criteria are stated in terms of it", () => { + // Dropping the steps entirely would lose the inputs, not just the + // commands: the prose below each step describes what the evidence holds. + const rendered = getPrompt("route", { mechanics: false }); + expect(rendered).toContain("linters"); + expect(rendered).toContain("loggedIn"); + expect(rendered).toContain("ghOwner"); + }); +}); + describe("header suppression", () => { it("drops the header line and the blank line after it, leaving the body intact", () => { const withHeader = getPrompt("create-sg-rule"); From a24de96125f4f76933a09433d345dbc6fdb7447b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 2 Sep 2026 22:27:25 -0700 Subject: [PATCH 5/6] fix(prompts): the connective belongs to the substitution, not the template Replacing only the command left prose that depended on it grammatically. Step 1 continued "This returns the configured linters", with nothing left for "This" to refer to; step 2 continued "and note `loggedIn`", a fragment whose "and" joined the `Run:` clause that had just been removed. That is a malformed rendering, which is the defect `mechanics: false` exists to remove rather than move somewhere quieter. The substitution now ends in the connective the step's prose continues from, so each step reads as a sentence in both modes. The template lines grew a tail as a result, because the placeholder supplies the newlines and the wrapping has to survive in the default. It does: measured byte-identical again against the pre-change render. A test asserts the grammar rather than only the absence of the command, since "the command is gone" was true of the broken version too. --- openspec/changes/export-route-topic/tasks.md | 1 + packages/cli/src/agent/route.txt | 6 ++-- packages/cli/src/prompts/recipes.ts | 33 ++++++++++++++------ packages/cli/test/prompts.test.ts | 15 ++++++++- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/openspec/changes/export-route-topic/tasks.md b/openspec/changes/export-route-topic/tasks.md index 6878c919..b0914a56 100644 --- a/openspec/changes/export-route-topic/tasks.md +++ b/openspec/changes/export-route-topic/tasks.md @@ -20,6 +20,7 @@ One PR, alongside the export it describes. - [x] 3.2 Keep the evidence rather than dropping the steps. The routing criteria are stated in terms of linters, languages, rule styles, `loggedIn` and `ghOwner`, so a consumer that reads "nothing here" loses the inputs and not just the commands - [x] 3.3 Render the steps as whole blocks through the variable table rather than stripping them afterwards, so the default can be proved unchanged. **Measured byte-identical**: 18161 bytes before and after, `diff` clean - [x] 3.4 Point `TASKLESS_CLI` and the new blocks at one invocation resolver, so the command inside a step and the command elsewhere in the recipe cannot disagree +- [x] 3.5 Absorb each step's connective into the substitution. Replacing only the command left `This returns` with nothing to refer to and `and note` as a sentence fragment — a malformed rendering, which is the defect this option removes rather than relocates. Asserted by a test rather than read once ## 4. Close out diff --git a/packages/cli/src/agent/route.txt b/packages/cli/src/agent/route.txt index 0e93de8b..80ade338 100644 --- a/packages/cli/src/agent/route.txt +++ b/packages/cli/src/agent/route.txt @@ -17,8 +17,7 @@ answered together. ## Steps -1. **Read the repository.** %(DETECT_EVIDENCE)s - This returns the configured linters, languages, and the repo's own +1. **Read the repository.** %(DETECT_EVIDENCE)s the configured linters, languages, and the repo's own rule styles. It is deterministic and offline. Use it as ground truth instead of guessing the repo's tooling. The scan is monorepo-aware, so evidence may carry a sub-package path. The output shape: @@ -33,8 +32,7 @@ answered together. } ``` -2. **Read the login state and the repository context.** %(LOGIN_EVIDENCE)s - and note `loggedIn` and `ghOwner`. Do this now, not later: they change +2. **Read the login state and the repository context.** %(LOGIN_EVIDENCE)s `loggedIn` and `ghOwner`. Do this now, not later: they change which destinations exist, so classifying first means classifying against a set that may be wrong. diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index b11ad252..010fa8e3 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -173,12 +173,27 @@ export interface RecipeOptions { * consumer that reads "nothing here" and moves on has lost the inputs rather * than the commands. */ -const SUPPLIED_EVIDENCE = - "This evidence is supplied by the caller rather than gathered here."; +const SUPPLIED_DETECT = "The caller supplies this evidence, which names"; +const SUPPLIED_LOGIN = "The caller supplies"; -/** The `Run:` line and its fenced command, as the recipe has always shown it. */ -function runBlock(invocation: string, command: string): string { - return `Run:\n \`\`\`\n ${invocation} ${command}\n \`\`\``; +/** + * The `Run:` block, ending in the connective the step's own prose continues + * from. + * + * The connective is part of the substitution rather than of the template + * because the prose depends on it grammatically: step 1 continues "…the + * configured linters", which needs "This returns" before it, and step 2 + * continues "`loggedIn` and `ghOwner`", which needs a verb. Replacing only the + * command left "This returns" dangling and "and note" as a fragment — a + * malformed rendering, which is the defect this option exists to remove rather + * than relocate. + */ +function runBlock( + invocation: string, + command: string, + connective: string +): string { + return `Run:\n \`\`\`\n ${invocation} ${command}\n \`\`\`\n ${connective}`; } /** The invocation a render should use, resolved the same way `TASKLESS_CLI` is. */ @@ -217,12 +232,12 @@ export function buildVariables( // and a post-strip cannot promise that. DETECT_EVIDENCE: options.mechanics === false - ? SUPPLIED_EVIDENCE - : runBlock(resolveInvocation(options), "detect --json"), + ? SUPPLIED_DETECT + : runBlock(resolveInvocation(options), "detect --json", "This returns"), LOGIN_EVIDENCE: options.mechanics === false - ? SUPPLIED_EVIDENCE - : runBlock(resolveInvocation(options), "info --json"), + ? SUPPLIED_LOGIN + : runBlock(resolveInvocation(options), "info --json", "and note"), // Three steps, in descending order of how much the resolver actually // knows: the caller was told how the CLI was launched; the build is a // nightly/dev/self that knows what it is; nobody knows, so ask the agent. diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 7696ae27..fd75d03d 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -283,7 +283,7 @@ describe("host mechanics suppression", () => { const rendered = getPrompt("route", { mechanics: false }); expect(rendered).not.toContain("detect --json"); expect(rendered).not.toContain("info --json"); - expect(rendered).toContain("supplied by the caller"); + expect(rendered).toContain("The caller supplies"); }); it("leaves no malformed command behind when the caller has no CLI", () => { @@ -304,6 +304,19 @@ describe("host mechanics suppression", () => { expect(withMechanicsOff).not.toMatch(/Run:\n {3}```/); }); + it("leaves no dangling connective where the command was", () => { + // Replacing only the command left "This returns" with nothing to refer to + // and "and note" as a fragment. The connective is part of the substitution + // for that reason, so each step reads as a sentence in both modes. + const rendered = getPrompt("route", { mechanics: false }); + expect(rendered).not.toMatch(/^\s*This returns/m); + expect(rendered).not.toMatch(/^\s*and note/m); + expect(rendered).toContain( + "The caller supplies this evidence, which names" + ); + expect(rendered).toContain("The caller supplies `loggedIn` and `ghOwner`."); + }); + it("keeps the evidence itself, since the criteria are stated in terms of it", () => { // Dropping the steps entirely would lose the inputs, not just the // commands: the prose below each step describes what the evidence holds. From 081c675db6051beb46ebdbaf06aab579d7b2bf1d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 2 Sep 2026 22:38:28 -0700 Subject: [PATCH 6/6] docs(openspec): mechanics gets a requirement, and raw names two kinds of variable Two review findings, both about the same gap: `mechanics` is public API on `PromptOptions` and nothing specified it, while the two variables it selects between quietly changed what a raw consumer is handed. `getRawInstructions` exists "so a host that knows a value the package cannot know can render it itself", and `DETECT_EVIDENCE`/`LOGIN_EVIDENCE` are not values a host knows. They are pre-composed blocks carrying the connective the step's prose continues from, and only this module composes them. The reframing that settles it: they join a group that already exists. The build version, the pinned engine capabilities and an input schema are all resolved by the package and unfillable by a host; only `TASKLESS_CLI` and `PACKAGE_MANAGER_DLX` are host-supplied, each with an agent-fill marker. The raw-accessor requirement now says which kind is which, so a raw consumer takes the composed ones from a rendered accessor rather than guessing at them. `mechanics` gets its own requirement, mirroring the header one: default true, the substitution carries the connective, and the evidence is not dropped along with the command. Also pins the seam the option introduced. A step's rendering now comes from two sources that agree only by construction, the placeholder's newlines and the template's tail on the same line, and reflowing that line would change the rendered bytes. The parity test cannot catch it, because it renders both sides from the same source and they move together. Proved by reflowing the line and watching the new test fail. --- .../specs/cli-knowledge-prompts/spec.md | 58 +++++++++++++++++++ packages/cli/test/prompts.test.ts | 20 +++++++ 2 files changed, 78 insertions(+) diff --git a/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md b/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md index ba14d7f0..2b336840 100644 --- a/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md +++ b/openspec/changes/export-route-topic/specs/cli-knowledge-prompts/spec.md @@ -25,3 +25,61 @@ A consumer that can decide a rule belongs to an engine must be able to reach the - **WHEN** a consumer imports `TOPICS` - **THEN** it SHALL NOT contain `static` or `engine-selection`, neither of which names a recipe any more + +### Requirement: The export provides rendered and raw instruction accessors + +The export SHALL provide `getInstructions(topic, options?)` and `getRawInstructions(topic, options?)`, each returning `{ text: string; variables: string[] }`. + +`getInstructions` SHALL return finished text — the same string `getPrompt` returns for the same topic and options — alongside the names of the sprintf variables the topic's template contains. `getRawInstructions` SHALL return the **unrendered** template text alongside the same variable names, so a host that knows a value the package cannot know can render it itself. + +**Not every variable is one a host can know.** The names a raw consumer receives cover two kinds: values a host may supply — `TASKLESS_CLI` and `PACKAGE_MANAGER_DLX`, each with an agent-fill marker as its default — and values only the package can resolve, which include the build version, the pinned engine capabilities, an input schema, and any pre-composed block a rendering option selects between. A host rendering the raw template itself SHALL substitute the first kind and SHALL take the second from a rendered accessor rather than composing it, because the package's own composition is what keeps the surrounding prose coherent. + +`mechanics` and `header` affect the rendered form only. The raw template is what a topic contains before any option is applied, so it carries the placeholders unconditionally. + +Both SHALL throw on a topic with no embedded recipe, matching `getPrompt`. The existing internal `getRecipe` accessor SHALL continue to return `undefined` for an unknown topic; the `agent` command distinguishes an unknown topic from a failure and cannot use a throwing accessor. + +`variables` SHALL be identical between the two functions for the same topic, since both describe the same template. + +#### Scenario: Rendered instructions match the existing accessor + +- **WHEN** a consumer calls `getInstructions(t)` and `getPrompt(t)` for the same topic and options +- **THEN** `getInstructions(t).text` SHALL equal `getPrompt(t)` + +#### Scenario: Raw instructions are re-renderable + +- **WHEN** a consumer renders `getRawInstructions(t).text` with sprintf-js against the same variable values the package used +- **THEN** the result SHALL equal `getInstructions(t).text` + +#### Scenario: An unknown topic throws + +- **WHEN** either accessor is called with a topic that has no embedded recipe +- **THEN** it SHALL throw, rather than returning an empty or undefined result + +#### Scenario: A package-composed variable is not a host-fillable one + +- **WHEN** a consumer reads `variables` from a raw accessor +- **THEN** the list SHALL include names the package resolves itself, which a host cannot supply a correct value for + +## ADDED Requirements + +### Requirement: The host-bound steps are suppressible + +`PromptOptions` SHALL accept `mechanics`, defaulting to `true`. When `false`, a recipe's steps that gather evidence by running this CLI SHALL render as a statement of what the caller supplies instead of as a command to run. + +The substitution SHALL include the connective the step's own prose continues from, so the step reads as a sentence in both modes. Replacing only the command leaves the prose depending on a clause that is no longer there. + +The evidence SHALL NOT be dropped along with the command. A recipe's criteria are stated in terms of what those steps gather, so a consumer told only that the step does not apply loses the inputs rather than the commands. + +**Rationale.** `invocation` cannot serve this. It substitutes the launcher inside a command, so a consumer with no CLI that sets it to a phrase renders an instruction to execute something that does not parse — a malformed rendering, which is worse than either honest answer. + +#### Scenario: The default renders the command + +- **WHEN** a consumer calls `getPrompt(t)` for a topic with host-bound steps +- **THEN** the step SHALL render the command to run, unchanged from what the `agent` command prints + +#### Scenario: Suppressing them names what the caller supplies + +- **WHEN** a consumer calls `getPrompt(t, { mechanics: false })` +- **THEN** no command to run SHALL appear in those steps +- **AND** the step SHALL name the evidence the caller is expected to provide +- **AND** the surrounding prose SHALL remain grammatical diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index fd75d03d..56c6592b 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -317,6 +317,26 @@ describe("host mechanics suppression", () => { expect(rendered).toContain("The caller supplies `loggedIn` and `ghOwner`."); }); + it("keeps each step's prose attached to the block its placeholder supplies", () => { + // The seam this option introduced. A step's rendering now comes from two + // independent sources that agree only by construction: the placeholder + // supplies the trailing newlines, and `route.txt` supplies the tail prose + // on the SAME template line. Reflowing that line in an editor would + // separate them and change the rendered bytes. + // + // Nothing else would catch it. The agent-command parity test renders both + // sides from the same source at test time, so a reflow moves them + // together, and the fenced-block regex above pins the block without + // pinning where the prose resumes. + const rendered = getPrompt("route"); + expect(rendered).toContain( + "```\n This returns the configured linters, languages, and the repo's own\n rule styles." + ); + expect(rendered).toContain( + "```\n and note `loggedIn` and `ghOwner`. Do this now, not later: they change\n which destinations exist," + ); + }); + it("keeps the evidence itself, since the criteria are stated in terms of it", () => { // Dropping the steps entirely would lose the inputs, not just the // commands: the prose below each step describes what the evidence holds.