diff --git a/.changeset/recipe-command-per-call.md b/.changeset/recipe-command-per-call.md new file mode 100644 index 00000000..5f6ac3cc --- /dev/null +++ b/.changeset/recipe-command-per-call.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Every `taskless agent ` recipe now opens with the rule the installed skill already states: run each Taskless command as its own shell call, with nothing chained to it, and write files with your file tools. Agents copy a recipe's own examples, so two that chained a command were rewritten: `check` now runs `git diff --name-only` separately instead of substituting it, and `create-vale-rule` measures one rule with `check --json --rule ` instead of piping into `jq`. Every topic version is bumped, so a cached recipe is refetched. diff --git a/openspec/changes/archive/2026-10-17-recipe-command-per-call/.openspec.yaml b/openspec/changes/archive/2026-10-17-recipe-command-per-call/.openspec.yaml new file mode 100644 index 00000000..b699b88b --- /dev/null +++ b/openspec/changes/archive/2026-10-17-recipe-command-per-call/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/archive/2026-10-17-recipe-command-per-call/proposal.md b/openspec/changes/archive/2026-10-17-recipe-command-per-call/proposal.md new file mode 100644 index 00000000..1e49ac10 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-recipe-command-per-call/proposal.md @@ -0,0 +1,28 @@ +## Why + +0.13.1 taught the installed skill to run each Taskless command as its own shell call, with nothing chained around it. That rule lives only in `skills/taskless/SKILL.md`. An agent deep in a recipe such as `onboard` or `report` is reading that recipe, and copies the recipe's own command examples, so chaining (`S=... && mkdir -p $S && npx @taskless/cli@...`, `echo ======` separators) creeps back in, and with it the auto-mode refusals and the zsh drop that 0.13.1 fixed. Two recipes still show a chained example themselves: `check` substitutes `$(git diff ...)` into the command line, and `create-vale-rule` pipes `check --json` into `jq`. + +## What Changes + +- Every standalone recipe carries the same short paragraph directly beneath its `# Topic:` header: run each Taskless command as its own shell call, start the line with the invocation, chain nothing to it, read output from the call's result, and write files with the agent's file tools. +- The paragraph is rendered from one `%(ONE_COMMAND_PER_CALL)s` placeholder, built from the same invocation `%(TASKLESS_CLI)s` resolves to, so the wording cannot drift between recipes. +- `check` tells the agent to run `git diff --name-only` as its own call and pass the paths it printed, instead of substituting it into the command. +- `create-vale-rule` measures one rule with `check --json --rule ` (which shipped for taskless/cli#379) instead of piping the whole-project result into `jq`. +- Every changed topic's version bumps, so an agent holding a cached recipe refetches. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-agent`: every recipe states the one-command-per-call rule beneath its header. + +## Impact + +- `packages/cli/src/prompts/recipes.ts`: the `ONE_COMMAND_PER_CALL` variable. +- `packages/cli/src/agent/*.md`: the placeholder in all 26 standalone recipes (not the `feedback-invite` fragment, which is appended under a recipe that already carries it), topic version bumps, and the two rewritten examples. +- Tests: a `prompts.test.ts` case asserting every topic's rendering carries the paragraph; topic-version assertions in `onboard.test.ts` and `report.test.ts`. +- Delivery shape: **single PR**. One placeholder and its use across the recipes, small enough to review together. diff --git a/openspec/changes/archive/2026-10-17-recipe-command-per-call/specs/cli-agent/spec.md b/openspec/changes/archive/2026-10-17-recipe-command-per-call/specs/cli-agent/spec.md new file mode 100644 index 00000000..7f5f6f43 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-recipe-command-per-call/specs/cli-agent/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Every recipe states the one-command-per-call rule + +Every recipe file at `packages/cli/src/agent/.md`, except `feedback-invite.md`, SHALL carry the `%(ONE_COMMAND_PER_CALL)s` placeholder as its own paragraph directly beneath the `# Topic:` header line and its closing blank line, before `## Goal`. The renderer SHALL substitute one shared paragraph, identical for every topic except for the rendered invocation, which SHALL be the same value `%(TASKLESS_CLI)s` resolves to. The paragraph SHALL tell the agent to run each Taskless command as its own shell call, starting the line with the invocation and chaining nothing to it (no variable assignment, `cd`, `mkdir`, `&&`, `;`, pipe, redirect, or command substitution), to read output from the call's result, and to write any file a recipe asks for with its file tools. + +When rendered with `mechanics: false`, the paragraph SHALL name no invocation and SHALL say "the Taskless invocation" in its place, because a caller with no CLI receives no invocations. The paragraph SHALL sit outside the header block, so `header: false` SHALL NOT remove it. `feedback-invite.md` is exempt because it is never served alone: the survey gate appends it, without its header, beneath a recipe that already carries the paragraph. A recipe's own command examples SHALL follow the rule: no example the agent is told to run SHALL chain anything to a Taskless command. Text the agent writes into a file, such as a CI job or a git hook, is not a command the agent runs and is exempt. + +#### Scenario: Every topic renders the paragraph + +- **WHEN** any topic other than `feedback-invite` is rendered +- **THEN** the text SHALL contain the one-command-per-call paragraph with the resolved invocation + +#### Scenario: The paragraph survives header suppression + +- **WHEN** a recipe is rendered with `header: false` +- **THEN** the text SHALL still contain the one-command-per-call paragraph + +#### Scenario: A caller with no CLI gets the rule without an invocation + +- **WHEN** a recipe is rendered with `mechanics: false` +- **THEN** the paragraph SHALL say "the Taskless invocation" and SHALL NOT contain an invocation or the `` marker + +#### Scenario: A diff-scoped check is not a command substitution + +- **WHEN** the `check` recipe describes scanning the files a branch changed +- **THEN** it SHALL tell the agent to run `git diff --name-only` as its own call and pass the paths it printed +- **AND** SHALL NOT show `$(git diff ...)` inside a Taskless command line diff --git a/openspec/changes/archive/2026-10-17-recipe-command-per-call/tasks.md b/openspec/changes/archive/2026-10-17-recipe-command-per-call/tasks.md new file mode 100644 index 00000000..77db4b18 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-recipe-command-per-call/tasks.md @@ -0,0 +1,11 @@ +## 1. Shared paragraph + +- [x] 1.1 Add the `ONE_COMMAND_PER_CALL` variable to `buildVariables()` in `src/prompts/recipes.ts`, rendered from the resolved invocation +- [x] 1.2 Place `%(ONE_COMMAND_PER_CALL)s` beneath the header of all 26 standalone recipes (not the `feedback-invite` fragment, which is appended under a recipe that already carries it) and bump those topic versions +- [x] 1.3 Rewrite the chained examples in `check.md` and `create-vale-rule.md` +- [x] 1.4 Add a `prompts.test.ts` case for every topic, with and without the header; update the topic-version assertions + +## 2. Release + +- [x] 2.1 Add a `patch` changeset; run `pnpm typecheck`, `pnpm lint`, and the full test suite +- [x] 2.2 Run the pre-archive scenario check from CLAUDE.md, then archive the change diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 674b7a03..f6d2510b 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -432,3 +432,30 @@ The topic is built into the `agent` command, not a recipe file. It SHALL NOT car - **WHEN** `taskless agent ok` is run - **THEN** PostHog SHALL receive a `cli_agent` event whose `topic` property is `ok` + +### Requirement: Every recipe states the one-command-per-call rule + +Every recipe file at `packages/cli/src/agent/.md`, except `feedback-invite.md`, SHALL carry the `%(ONE_COMMAND_PER_CALL)s` placeholder as its own paragraph directly beneath the `# Topic:` header line and its closing blank line, before `## Goal`. The renderer SHALL substitute one shared paragraph, identical for every topic except for the rendered invocation, which SHALL be the same value `%(TASKLESS_CLI)s` resolves to. The paragraph SHALL tell the agent to run each Taskless command as its own shell call, starting the line with the invocation and chaining nothing to it (no variable assignment, `cd`, `mkdir`, `&&`, `;`, pipe, redirect, or command substitution), to read output from the call's result, and to write any file a recipe asks for with its file tools. + +When rendered with `mechanics: false`, the paragraph SHALL name no invocation and SHALL say "the Taskless invocation" in its place, because a caller with no CLI receives no invocations. The paragraph SHALL sit outside the header block, so `header: false` SHALL NOT remove it. `feedback-invite.md` is exempt because it is never served alone: the survey gate appends it, without its header, beneath a recipe that already carries the paragraph. A recipe's own command examples SHALL follow the rule: no example the agent is told to run SHALL chain anything to a Taskless command. Text the agent writes into a file, such as a CI job or a git hook, is not a command the agent runs and is exempt. + +#### Scenario: Every topic renders the paragraph + +- **WHEN** any topic other than `feedback-invite` is rendered +- **THEN** the text SHALL contain the one-command-per-call paragraph with the resolved invocation + +#### Scenario: The paragraph survives header suppression + +- **WHEN** a recipe is rendered with `header: false` +- **THEN** the text SHALL still contain the one-command-per-call paragraph + +#### Scenario: A caller with no CLI gets the rule without an invocation + +- **WHEN** a recipe is rendered with `mechanics: false` +- **THEN** the paragraph SHALL say "the Taskless invocation" and SHALL NOT contain an invocation or the `` marker + +#### Scenario: A diff-scoped check is not a command substitution + +- **WHEN** the `check` recipe describes scanning the files a branch changed +- **THEN** it SHALL tell the agent to run `git diff --name-only` as its own call and pass the paths it printed +- **AND** SHALL NOT show `$(git diff ...)` inside a Taskless command line diff --git a/packages/cli/src/agent/auth.md b/packages/cli/src/agent/auth.md index 7833d429..9ddf45e6 100644 --- a/packages/cli/src/agent/auth.md +++ b/packages/cli/src/agent/auth.md @@ -1,4 +1,6 @@ -# Topic: auth (CLI v%(CLI_VERSION)s / topic v2) +# Topic: auth (CLI v%(CLI_VERSION)s / topic v3) + +%(ONE_COMMAND_PER_CALL)s ## Goal Manage Taskless authentication. Three branches: diff --git a/packages/cli/src/agent/bug-report.md b/packages/cli/src/agent/bug-report.md index 596534ca..d00f5259 100644 --- a/packages/cli/src/agent/bug-report.md +++ b/packages/cli/src/agent/bug-report.md @@ -1,4 +1,6 @@ -# Topic: bug-report (CLI v%(CLI_VERSION)s / topic v1) +# Topic: bug-report (CLI v%(CLI_VERSION)s / topic v2) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `bug-report`. It helps you report a bug in Taskless to the diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index c2353b08..83772342 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -1,4 +1,6 @@ -# Topic: check (CLI v%(CLI_VERSION)s / topic v8) +# Topic: check (CLI v%(CLI_VERSION)s / topic v9) + +%(ONE_COMMAND_PER_CALL)s ## Goal Run the applicable rules against the codebase and report matches. Two @@ -205,12 +207,13 @@ delete the rules to make `check` pass, and do not suggest ``` %(TASKLESS_CLI)s check --json src/foo.ts src/bar.ts ``` - or, against a git diff: + or, against a git diff: run `git diff --name-only main...HEAD` as + its own call, then pass the paths it printed: ``` - %(TASKLESS_CLI)s check --json $(git diff --name-only main...HEAD) + %(TASKLESS_CLI)s check --json src/foo.ts src/deleted.ts ``` - Paths that don't exist on disk are skipped, so you can pipe raw - `git diff` output directly without pre-filtering. The skipped paths + Paths that don't exist on disk are skipped, so you can pass every + path `git diff` printed without pre-filtering. The skipped paths are named in a notice (`notices` under `--json`), so a mistyped path is visible rather than reading as clean. diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 4d675ab0..9961dbe9 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,6 @@ -# Topic: ci (CLI v%(CLI_VERSION)s / topic v5) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v6) + +%(ONE_COMMAND_PER_CALL)s ## Goal Wire `%(TASKLESS_CLI)s check` into the user's existing CI so rules run diff --git a/packages/cli/src/agent/create-legacy-rule.md b/packages/cli/src/agent/create-legacy-rule.md index d12a7b0e..54bf30d2 100644 --- a/packages/cli/src/agent/create-legacy-rule.md +++ b/packages/cli/src/agent/create-legacy-rule.md @@ -1,4 +1,6 @@ -# Topic: create-legacy-rule (CLI v%(CLI_VERSION)s / topic v1) +# Topic: create-legacy-rule (CLI v%(CLI_VERSION)s / topic v2) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `create-legacy-rule`. It helps you write a rule for a linter the diff --git a/packages/cli/src/agent/create-remote-rule.md b/packages/cli/src/agent/create-remote-rule.md index 1573d77f..088ee2fb 100644 --- a/packages/cli/src/agent/create-remote-rule.md +++ b/packages/cli/src/agent/create-remote-rule.md @@ -1,4 +1,6 @@ -# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v7) +# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v8) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `create-remote-rule`. It helps you have the Taskless service diff --git a/packages/cli/src/agent/create-runtime-rule.md b/packages/cli/src/agent/create-runtime-rule.md index 1f5bfd3f..73834a15 100644 --- a/packages/cli/src/agent/create-runtime-rule.md +++ b/packages/cli/src/agent/create-runtime-rule.md @@ -1,4 +1,6 @@ -# Topic: create-runtime-rule (CLI v%(CLI_VERSION)s / topic v5) +# Topic: create-runtime-rule (CLI v%(CLI_VERSION)s / topic v6) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `create-runtime-rule`. It helps you write a runtime rule: a diff --git a/packages/cli/src/agent/create-sg-rule.md b/packages/cli/src/agent/create-sg-rule.md index 31267f54..be01790d 100644 --- a/packages/cli/src/agent/create-sg-rule.md +++ b/packages/cli/src/agent/create-sg-rule.md @@ -1,4 +1,6 @@ -# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v10) +# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v11) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `create-sg-rule`. It helps you write an ast-grep rule: a check diff --git a/packages/cli/src/agent/create-vale-rule.md b/packages/cli/src/agent/create-vale-rule.md index 4440695d..7de5a844 100644 --- a/packages/cli/src/agent/create-vale-rule.md +++ b/packages/cli/src/agent/create-vale-rule.md @@ -1,4 +1,6 @@ -# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v17) +# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v18) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `create-vale-rule`. It helps you write a Vale rule: a check over @@ -945,20 +947,19 @@ it. # of the four were written by the assistant, never by the human author. ``` - There is no single command that runs one Vale rule over the project - and counts (taskless/cli#379 tracks one). `verify` isolates the rule - but sees only its fixtures. The workable way is a whole-project - `check` filtered afterwards by rule id: + `verify` isolates the rule but sees only its fixtures. To measure it + over the whole project, run `check` with only that rule: ``` - %(TASKLESS_CLI)s check --json | jq '[.results[] | select(.ruleId == "")] | length' + %(TASKLESS_CLI)s check --json --rule ``` - A finding carries no record of which alternative matched, so to - count one branch, run the rule with that branch as its only `raw` - entry, note the number, then fold it back into the alternation. Drop - `| length` to read the hits themselves, which is what tells you - whether a hit is a false positive or a real one nobody had noticed. + Count the entries in `results`. A finding carries no record of which + alternative matched, so to count one branch, run the rule with that + branch as its only `raw` entry, note the number, then fold it back + into the alternation. Read the hits themselves too: that is what + tells you whether a hit is a false positive or a real one nobody had + noticed. 6. **Validate, then verify.** Two commands, both taking the rule's directory as their argument, both run from the project root: diff --git a/packages/cli/src/agent/delete-rule.md b/packages/cli/src/agent/delete-rule.md index 5cda434e..a1d127da 100644 --- a/packages/cli/src/agent/delete-rule.md +++ b/packages/cli/src/agent/delete-rule.md @@ -1,4 +1,6 @@ -# Topic: delete-rule (CLI v%(CLI_VERSION)s / topic v4) +# Topic: delete-rule (CLI v%(CLI_VERSION)s / topic v5) + +%(ONE_COMMAND_PER_CALL)s ## Goal Remove a rule and its associated test files from `.taskless/`. Does not diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index 62ffaee5..b2ccaa47 100644 --- a/packages/cli/src/agent/detect.md +++ b/packages/cli/src/agent/detect.md @@ -1,4 +1,6 @@ -# Topic: detect (CLI v%(CLI_VERSION)s / topic v2) +# Topic: detect (CLI v%(CLI_VERSION)s / topic v3) + +%(ONE_COMMAND_PER_CALL)s ## Goal Scan the working directory for the linters it configures, the diff --git a/packages/cli/src/agent/feedback.md b/packages/cli/src/agent/feedback.md index 5474fcaa..731b4a83 100644 --- a/packages/cli/src/agent/feedback.md +++ b/packages/cli/src/agent/feedback.md @@ -1,4 +1,6 @@ -# Topic: feedback (CLI v%(CLI_VERSION)s / topic v1) +# Topic: feedback (CLI v%(CLI_VERSION)s / topic v2) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `feedback`. It helps you send the Taskless team feedback the user diff --git a/packages/cli/src/agent/hooks.md b/packages/cli/src/agent/hooks.md index 0bad3e5d..c73a890f 100644 --- a/packages/cli/src/agent/hooks.md +++ b/packages/cli/src/agent/hooks.md @@ -1,4 +1,6 @@ -# Topic: hooks (CLI v%(CLI_VERSION)s / topic v2) +# Topic: hooks (CLI v%(CLI_VERSION)s / topic v3) + +%(ONE_COMMAND_PER_CALL)s ## Goal Run `check` on the staged files before every commit, in the hook tool diff --git a/packages/cli/src/agent/improve-rule.anonymous.md b/packages/cli/src/agent/improve-rule.anonymous.md index c9741747..55cec21c 100644 --- a/packages/cli/src/agent/improve-rule.anonymous.md +++ b/packages/cli/src/agent/improve-rule.anonymous.md @@ -1,4 +1,6 @@ -# Topic: improve-rule (anonymous) (CLI v%(CLI_VERSION)s / topic v4) +# Topic: improve-rule (anonymous) (CLI v%(CLI_VERSION)s / topic v5) + +%(ONE_COMMAND_PER_CALL)s ## Goal Iterate on an existing ast-grep rule **locally** without contacting diff --git a/packages/cli/src/agent/improve-rule.md b/packages/cli/src/agent/improve-rule.md index b6994260..5808353a 100644 --- a/packages/cli/src/agent/improve-rule.md +++ b/packages/cli/src/agent/improve-rule.md @@ -1,4 +1,6 @@ -# Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v9) +# Topic: improve-rule (CLI v%(CLI_VERSION)s / topic v10) + +%(ONE_COMMAND_PER_CALL)s ## Goal Iterate on an existing Taskless rule. The CLI submits the user's diff --git a/packages/cli/src/agent/info.md b/packages/cli/src/agent/info.md index 243244b4..d5487e9f 100644 --- a/packages/cli/src/agent/info.md +++ b/packages/cli/src/agent/info.md @@ -1,4 +1,6 @@ -# Topic: info (CLI v%(CLI_VERSION)s / topic v2) +# Topic: info (CLI v%(CLI_VERSION)s / topic v3) + +%(ONE_COMMAND_PER_CALL)s ## Goal Report local Taskless state: CLI version, installed skill versions diff --git a/packages/cli/src/agent/init.md b/packages/cli/src/agent/init.md index c52047ef..52c68893 100644 --- a/packages/cli/src/agent/init.md +++ b/packages/cli/src/agent/init.md @@ -1,4 +1,6 @@ -# Topic: init (CLI v%(CLI_VERSION)s / topic v9) +# Topic: init (CLI v%(CLI_VERSION)s / topic v10) + +%(ONE_COMMAND_PER_CALL)s ## Goal Install or update the Taskless skill in this project, and migrate the diff --git a/packages/cli/src/agent/onboard.md b/packages/cli/src/agent/onboard.md index 73105702..47493c75 100644 --- a/packages/cli/src/agent/onboard.md +++ b/packages/cli/src/agent/onboard.md @@ -1,4 +1,6 @@ -# Topic: onboard (CLI v%(CLI_VERSION)s / topic v8) +# Topic: onboard (CLI v%(CLI_VERSION)s / topic v9) + +%(ONE_COMMAND_PER_CALL)s ## Goal Take a user who has just installed Taskless from zero rules to a diff --git a/packages/cli/src/agent/recover-rule.md b/packages/cli/src/agent/recover-rule.md index bc381dac..f8ca491b 100644 --- a/packages/cli/src/agent/recover-rule.md +++ b/packages/cli/src/agent/recover-rule.md @@ -1,4 +1,6 @@ -# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v3) +# Topic: recover-rule (CLI v%(CLI_VERSION)s / topic v4) + +%(ONE_COMMAND_PER_CALL)s ## Goal Put an issued rule back the way Taskless issued it, after `check` diff --git a/packages/cli/src/agent/report.md b/packages/cli/src/agent/report.md index 50e299cc..2b5ee911 100644 --- a/packages/cli/src/agent/report.md +++ b/packages/cli/src/agent/report.md @@ -1,4 +1,6 @@ -# Topic: report (CLI v%(CLI_VERSION)s / topic v6) +# Topic: report (CLI v%(CLI_VERSION)s / topic v7) + +%(ONE_COMMAND_PER_CALL)s ## Goal Publish a Taskless report: an unlisted page on tskl.es that shows what diff --git a/packages/cli/src/agent/route.md b/packages/cli/src/agent/route.md index 86ed1d3b..e2b1c252 100644 --- a/packages/cli/src/agent/route.md +++ b/packages/cli/src/agent/route.md @@ -1,4 +1,6 @@ -# Topic: route (CLI v%(CLI_VERSION)s / topic v6) +# Topic: route (CLI v%(CLI_VERSION)s / topic v7) + +%(ONE_COMMAND_PER_CALL)s ## Goal Turn "write me a rule that…" into one command to run. This is the front diff --git a/packages/cli/src/agent/rule-feedback.md b/packages/cli/src/agent/rule-feedback.md index b383915b..2cff2975 100644 --- a/packages/cli/src/agent/rule-feedback.md +++ b/packages/cli/src/agent/rule-feedback.md @@ -1,4 +1,6 @@ -# Topic: rule-feedback (CLI v%(CLI_VERSION)s / topic v5) +# Topic: rule-feedback (CLI v%(CLI_VERSION)s / topic v6) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `rule-feedback`. It helps you turn what a user just said about diff --git a/packages/cli/src/agent/rule-meta.md b/packages/cli/src/agent/rule-meta.md index 50e3dfa9..9c1966d9 100644 --- a/packages/cli/src/agent/rule-meta.md +++ b/packages/cli/src/agent/rule-meta.md @@ -1,4 +1,6 @@ -# Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v4) +# Topic: rule-meta (CLI v%(CLI_VERSION)s / topic v5) + +%(ONE_COMMAND_PER_CALL)s ## Goal Report what `%(TASKLESS_CLI)s rule meta` does today, so no recipe and no diff --git a/packages/cli/src/agent/rule.md b/packages/cli/src/agent/rule.md index a0f87cd4..bcada353 100644 --- a/packages/cli/src/agent/rule.md +++ b/packages/cli/src/agent/rule.md @@ -1,4 +1,6 @@ -# Topic: rule (CLI v%(CLI_VERSION)s / topic v4) +# Topic: rule (CLI v%(CLI_VERSION)s / topic v5) + +%(ONE_COMMAND_PER_CALL)s ## Goal Umbrella for rule operations. Fetch the topic for the action you want. diff --git a/packages/cli/src/agent/update.md b/packages/cli/src/agent/update.md index 73ca5017..09c2c681 100644 --- a/packages/cli/src/agent/update.md +++ b/packages/cli/src/agent/update.md @@ -1,4 +1,6 @@ -# Topic: update (CLI v%(CLI_VERSION)s / topic v16) +# Topic: update (CLI v%(CLI_VERSION)s / topic v17) + +%(ONE_COMMAND_PER_CALL)s ## You are here This is `update`. It tells you what an upgrade changed for the rules diff --git a/packages/cli/src/agent/validate-rule.md b/packages/cli/src/agent/validate-rule.md index b9d7ef4a..2a1f3753 100644 --- a/packages/cli/src/agent/validate-rule.md +++ b/packages/cli/src/agent/validate-rule.md @@ -1,4 +1,6 @@ -# Topic: validate-rule (CLI v%(CLI_VERSION)s / topic v6) +# Topic: validate-rule (CLI v%(CLI_VERSION)s / topic v7) + +%(ONE_COMMAND_PER_CALL)s ## Goal Check that a rule is well-formed (`validate`), then run its tests diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index 86fcde68..3879c2f7 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -564,6 +564,31 @@ cannot see your MCP roster, so whether a bug tracker is reachable stays your judgement.`; } +/** + * The paragraph every recipe carries beneath its header, identical for every + * topic except the invocation. + * + * The skill states this rule too, but an agent deep in a recipe copies the + * recipe's own examples, and that is where chaining crept back in: zsh reads + * an `echo ======` separator as equals expansion and drops the rest of the + * line, and auto mode refuses a line that wraps the download-and-run command + * in glue it cannot scope (taskless/cli#532). One template, so the wording + * cannot drift between 27 files. + * + * With no invocation (`mechanics: false`) the rule still holds for whatever + * runs the CLI, but names no command, for the reason `nameDestinationStep` + * gives: a caller that asked for no invocations gets none. + */ +export function oneCommandPerCall(invocation?: string): string { + const start = + invocation === undefined ? "the Taskless invocation" : `\`${invocation}\``; + return ( + `Run each Taskless command as its own shell call. Start the line with ${start} ` + + `and chain nothing to it: no variable assignment, \`cd\`, \`mkdir\`, \`&&\`, \`;\`, pipe, redirect, or \`$(…)\`. ` + + `Read its output from the call's result, and write any file this recipe asks for with your file tools.` + ); +} + /** The invocation a render should use, resolved the same way `TASKLESS_CLI` is. */ function resolveInvocation(options: RecipeOptions): string { return ( @@ -629,6 +654,9 @@ export function buildVariables( // 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: resolveInvocation(options), + ONE_COMMAND_PER_CALL: oneCommandPerCall( + options.mechanics === false ? undefined : resolveInvocation(options) + ), // Conditional blocks: one of a fixed set of whole passages, chosen by // state the CALLER measured. Substituted whole — bullet marker, step // title, connective and all — for the reason `runBlock` documents above, diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts index ee0e6906..c74d6947 100644 --- a/packages/cli/test/onboard.test.ts +++ b/packages/cli/test/onboard.test.ts @@ -347,8 +347,8 @@ describe("onboard recipe asks one question and finishes on its own", () => { : stdout.slice(start, start + title.length + next); } - it("is topic v8", () => { - expect(stdout).toMatch(/^# Topic: onboard {5}\(CLI v.+ \/ topic v8\)/m); + it("is topic v9", () => { + expect(stdout).toMatch(/^# Topic: onboard {5}\(CLI v.+ \/ topic v9\)/m); }); it("gives the method for counting surfaced while mining, not after", () => { diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 255b6f4c..f776ac22 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -20,6 +20,7 @@ import { buildVariables, canonicalRecipeTopics, getRawRecipe, + oneCommandPerCall, stripValeDirectives, getRecipe, getRenderedRecipe, @@ -187,6 +188,7 @@ describe("the CLI invocation variable", () => { "HOST_TOOLS", "LOGIN_EVIDENCE", "NAME_DESTINATION", + "ONE_COMMAND_PER_CALL", "PACKAGE_MANAGER_DLX", "RECIPE_FETCH", "SOURCE_PR_REVIEW", @@ -467,6 +469,58 @@ describe("header suppression", () => { }); }); +describe("one command per call", () => { + // The skill states this rule, but an agent deep in a recipe copies the + // recipe's own examples, and chaining crept back in there (taskless/cli#532). + // Asserted over every topic and both variants, with and without the header, + // since `header: false` must not take the rule with it. + // + // Except the feedback invite, which is never served alone: the survey gate + // appends it, header-less, under a recipe that has already stated the rule. + const FRAGMENT_TOPIC = "feedback-invite"; + + it("states the rule beneath every recipe's header", () => { + const invocation = "npx @taskless/cli@1.2.3"; + const rule = oneCommandPerCall(invocation); + const variants = canonicalRecipeTopics() + .filter((topic) => topic !== FRAGMENT_TOPIC) + .flatMap((topic) => [ + { topic, anonymous: false }, + { topic, anonymous: true }, + ]); + for (const { topic, anonymous } of variants) { + const served = getRecipe(topic, { anonymous, invocation }); + expect(served, `${topic} has no recipe`).toBeDefined(); + expect(served!.split("\n").slice(0, 3), topic).toEqual([ + expect.stringMatching(/^# Topic: /), + "", + rule, + ]); + const withoutHeader = getRecipe(topic, { + anonymous, + invocation, + header: false, + }); + expect( + withoutHeader?.startsWith(`${rule}\n`), + `${topic} lost the rule with its header` + ).toBe(true); + } + }); + + it("names no invocation for a caller with no CLI", () => { + // `mechanics: false` must leave no invocation behind (taskless/cli#469), + // so the rule is stated against "the Taskless invocation" instead. + const rule = oneCommandPerCall(); + expect(rule).not.toContain(""); + for (const topic of canonicalRecipeTopics()) { + if (topic === FRAGMENT_TOPIC) continue; + const rendered = getRecipe(topic, { mechanics: false })!; + expect(rendered.split("\n")[2], topic).toBe(rule); + } + }); +}); + describe("anonymous variants", () => { it("returns the variant text for a topic that has one", () => { const canonical = getRecipe("improve-rule"); diff --git a/packages/cli/test/report.test.ts b/packages/cli/test/report.test.ts index 4837a750..81eb21d4 100644 --- a/packages/cli/test/report.test.ts +++ b/packages/cli/test/report.test.ts @@ -967,7 +967,7 @@ describe("taskless report", () => { it("counts only review comments, and asks for a candidate estimate, not pricing", async () => { const { stdout, exitCode } = await run(["agent", "report"]); expect(exitCode).toBe(0); - expect(stdout).toMatch(/^# Topic: report {5}\(CLI v.+ \/ topic v6\)/m); + expect(stdout).toMatch(/^# Topic: report {5}\(CLI v.+ \/ topic v7\)/m); const flat = stdout.replaceAll(/\s+/g, " "); const counting = flat.slice(flat.indexOf("## Counting surfaced")); expect(counting).toContain("**Only review comments count.**");