Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/export-route-prompt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@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.

`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.
16 changes: 13 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <change-name> -y
grep -n "^#### Scenario" openspec/specs/<capability>/spec.md
git reset --hard HEAD && git clean -fd openspec/
grep -n "^#### Scenario" openspec/specs/<capability>/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 <wip-sha> -- openspec/changes/<name>`
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.
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/export-route-topic/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-02
45 changes: 45 additions & 0 deletions openspec/changes/export-route-topic/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
## 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

### 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
27 changes: 27 additions & 0 deletions openspec/changes/export-route-topic/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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. Make the export usable by the consumer asking for it

- [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: <phrase> 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
- [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

- [ ] 4.1 Archive the change
12 changes: 2 additions & 10 deletions packages/cli/src/agent/route.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ answered together.

## Steps

1. **Read the repository.** Run:
```
%(TASKLESS_CLI)s detect --json
```
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
Comment thread
thecodedrift marked this conversation as resolved.
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:
Expand All @@ -36,11 +32,7 @@ answered together.
}
```

2. **Read the login state and the repository context.** Run:
```
%(TASKLESS_CLI)s info --json
```
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.

Expand Down
30 changes: 22 additions & 8 deletions packages/cli/src/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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.
Expand All @@ -75,7 +90,6 @@ export const INTERNAL_TOPICS = [
"info",
"init",
"onboard",
"route",
"rule",
"rule-meta",
"update",
Expand Down
Loading
Loading