Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,28 @@ Delivery shape: **stacked, merging DOWN**, five PRs. Group 1 is the bottom branc

## 2. PR 2 — launcher detection and the `getCliPrefix` fix

- [ ] 2.1 Rewrite `src/util/package-manager.ts` around `detectLauncher({ env, argv })`, pure over an injected context. Recognize npx (`_npx` path segment in `argv[1]`, or `npm_command === "exec"` with `npm_lifecycle_event === "npx"`) and pnpm dlx (`pnpm/` user agent **and** a `dlx` path segment). Everything else is `undefined`
- [ ] 2.2 Derive the package specifier from `__TASKLESS_CLI__`: a `npx `-prefixed invocation yields its specifier (pinned to `@latest` when it carries no version), and a path-form invocation is returned verbatim with no launcher applied
- [ ] 2.3 Reimplement `getCliPrefix()` over the two, keeping `npx <spec>` as the display default when detection is unknown. The five call sites are unchanged
- [ ] 2.4 Pass `invocation: detectCliInvocation(...)` from `src/commands/agent.ts` into `getRecipe`
- [ ] 2.5 Rewrite `test/package-manager.test.ts` as a table over injected contexts — its current premise, that the user agent answers the question, is what this change refutes. Cover npx by path, npx by env, pnpm dlx, pnpm run (must not be dlx), pnpm exec, a `node_modules/.bin` shim, and an empty environment
- [ ] 2.6 Confirm `test/prompts.test.ts`'s spawned-CLI byte-parity still holds: the test spawns `node dist/index.js` under a pnpm-run environment, which detection must report as unknown, so the CLI and the export both render the marker
- [x] 2.1 Rewrite `src/util/package-manager.ts` around `detectLauncher({ env, argv })`, pure over an injected context. Recognize npx (`_npx` path segment in `argv[1]`, or `npm_command === "exec"` with `npm_lifecycle_event === "npx"`) and pnpm dlx (`pnpm/` user agent **and** a `dlx` path segment). Everything else is `undefined`
- [x] 2.2 Derive the package specifier from `__TASKLESS_CLI__`: a `npx `-prefixed invocation yields its specifier (pinned to `@latest` when it carries no version), and a path-form invocation is returned verbatim with no launcher applied
- [x] 2.3 Reimplement `getCliPrefix()` over the two, keeping `npx <spec>` as the display default when detection is unknown. The five call sites are unchanged
- [x] 2.4 Pass `invocation: detectCliInvocation(...)` from `src/commands/agent.ts` into `getRecipe`
- [x] 2.5 Rewrite `test/package-manager.test.ts` as a table over injected contexts — its current premise, that the user agent answers the question, is what this change refutes. Cover npx by path, npx by env, pnpm dlx, pnpm run (must not be dlx), pnpm exec, a `node_modules/.bin` shim, and an empty environment
- [x] 2.6 Confirm `test/prompts.test.ts`'s spawned-CLI byte-parity still holds: the test spawns `node dist/index.js` under a pnpm-run environment, which detection must report as unknown, so the CLI and the export both render the marker

## 3. PR 3 — recipe normalization, part A

- [ ] 3.1 Replace every bare `` `taskless <subcommand>` `` and every `npx @taskless/cli <subcommand>` with `%(TASKLESS_CLI)s <subcommand>` across the heaviest recipes. Leave prose mentions of the product, `taskless.config`, and `.taskless/` alone
- [ ] 3.2 Leave `ci.txt`'s `%(PACKAGE_MANAGER_DLX)s` occurrences in place — they answer what the _consuming repo's_ CI should type, which is a different question from how this process was launched. Its one prose reference to `npx @taskless/cli` becomes `%(TASKLESS_CLI)s`
- [ ] 3.3 Note in the PR body that `onboard.txt` conflicts with PR #142, which lands first
- [x] 3.1 Replace every bare `` `taskless <subcommand>` `` and every `npx @taskless/cli <subcommand>` with `%(TASKLESS_CLI)s <subcommand>` across the heaviest recipes. Leave prose mentions of the product, `taskless.config`, and `.taskless/` alone
- [x] 3.2 Leave `ci.txt`'s `%(PACKAGE_MANAGER_DLX)s` occurrences in place — they answer what the _consuming repo's_ CI should type, which is a different question from how this process was launched. Its one prose reference to `npx @taskless/cli` becomes `%(TASKLESS_CLI)s`
- [x] 3.3 Note in the PR body that `onboard.txt` conflicts with PR #142, which lands first

## 4. PR 4 — recipe normalization, part B

- [ ] 4.1 Normalize the remaining recipes the same way
- [ ] 4.2 Normalize `skills/taskless/SKILL.md` and `commands/tskl/tskl.md`. These are not rendered through the recipe renderer, so they keep the literal `npx @taskless/cli` that `applyCliInvocation` already rewrites — verify which of the two treatments each file needs rather than assuming
- [ ] 4.3 Verify no recipe still names a bare `taskless` binary
- [x] 4.1 Normalize the remaining recipes the same way
- [x] 4.2 Normalize `skills/taskless/SKILL.md` and `commands/tskl/tskl.md`. These are not rendered through the recipe renderer, so they keep the literal `npx @taskless/cli` that `applyCliInvocation` already rewrites — verify which of the two treatments each file needs rather than assuming
- [x] 4.3 Verify no recipe still names a bare `taskless` binary

## 5. PR 5 (tip) — cross-reference checking and the archive

- [ ] 5.1 Move `test/recipe-cross-references.test.ts` from scanning recipe source to scanning rendered recipes, where the invocation is a stable literal. Match the marker alongside the existing spellings
- [ ] 5.2 Add the regression guard: no bare `` `taskless <subcommand>` `` in any `src/agent/*.txt`, naming the offending file and line
- [ ] 5.3 Archive the change to `openspec/changes/archive/<date>-full-cli-invocation/`
- [ ] 5.4 Run every gate on the tip: build, test, typecheck, lint, `openspec validate --strict`
- [x] 5.1 Move `test/recipe-cross-references.test.ts` from scanning recipe source to scanning rendered recipes, where the invocation is a stable literal. Match the marker alongside the existing spellings
- [x] 5.2 Add the regression guard: no bare `` `taskless <subcommand>` `` in any `src/agent/*.txt`, naming the offending file and line
- [x] 5.3 Archive the change to `openspec/changes/archive/<date>-full-cli-invocation/`
- [x] 5.4 Run every gate on the tip: build, test, typecheck, lint, `openspec validate --strict`
57 changes: 57 additions & 0 deletions openspec/specs/cli-agent/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ Recipe rendering SHALL substitute placeholders via `sprintf-js` using its named-
1. **System-resolved values** — keys whose values come from runtime state. The renderer SHALL provide `CLI_VERSION` (resolved from the build-time version constant) for every render. The renderer SHALL provide `INPUT_SCHEMA` only when the recipe content contains the `%(INPUT_SCHEMA)s` placeholder; the value is the JSON Schema rendered from the topic's Zod schema in `packages/cli/src/schemas/`, or the literal string `"(no input schema for this topic)"` when no Zod schema is registered for the topic.
2. **Agent-fill markers** — keys whose values render as a lowercase angle-bracket token of the same name (e.g. `PACKAGE_MANAGER_DLX` renders as `<package-manager-dlx>`). The renderer SHALL provide `PACKAGE_MANAGER_DLX` for every render. Agent-fill markers exist so the consuming agent can substitute the value at execution time without the recipe having to invent a per-recipe placeholder convention.

The renderer SHALL additionally provide `TASKLESS_CLI` for every render. It is a hybrid of the two flavors: system-resolved when the caller or the build knows the answer, and an agent-fill marker when neither does. It SHALL resolve in this order:

1. The caller-supplied invocation, when one is given.
2. The build-target invocation, when the build target is not prod — a `nightly`, `dev`, or `self` build knows exactly what it is and SHALL name itself.
3. Otherwise the agent-fill marker `<taskless-cli>`.

Step 3 SHALL NOT fall back to `npx @taskless/cli`. A prod build that does not know how it was launched has no basis for naming one launcher over another, and a marker asks the reading agent for the answer instead of asserting a wrong one.

Recipe authors SHALL escape any literal `%` character in recipe content as `%%` per sprintf-js conventions. The renderer SHALL NOT introduce any other placeholder syntax (`{{KEY}}`, `${KEY}`, etc.); all substitution SHALL flow through the sprintf-js named-argument table.

#### Scenario: CLI_VERSION substitutes the build-time version
Expand All @@ -100,6 +108,22 @@ Recipe authors SHALL escape any literal `%` character in recipe content as `%%`
- **WHEN** any recipe contains `%(PACKAGE_MANAGER_DLX)s`
- **THEN** the rendered output SHALL contain the literal token `<package-manager-dlx>` at every occurrence

#### Scenario: TASKLESS_CLI renders a caller-supplied invocation

- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with an explicit invocation
- **THEN** every occurrence SHALL render as that invocation

#### Scenario: TASKLESS_CLI names a non-prod build target

- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered with no explicit invocation from a `nightly`, `dev`, or `self` build
- **THEN** every occurrence SHALL render as that build's own invocation, so a nightly names `@taskless/cli-nightly` at its published version rather than the released package

#### Scenario: TASKLESS_CLI falls back to an agent-fill marker

- **WHEN** a recipe containing `%(TASKLESS_CLI)s` is rendered from a prod build with no explicit invocation
- **THEN** every occurrence SHALL render as the literal token `<taskless-cli>`
- **AND** SHALL NOT render as `npx @taskless/cli` or any other guessed launcher

#### Scenario: No legacy placeholder syntax remains in recipes

- **WHEN** any `<topic>.txt` file under `packages/cli/src/agent/` is read
Expand Down Expand Up @@ -242,3 +266,36 @@ No embedded recipe SHALL contain the string `taskless help`. Recipes cross-refer

- **WHEN** the embedded recipe set is inspected
- **THEN** no recipe SHALL contain `taskless help`

### Requirement: Recipes name the CLI by its full invocation

Every instruction in shipped agent-facing content that tells a reader to run the Taskless CLI SHALL name the CLI by the invocation the build resolves for the reader, followed by the subcommand and its arguments — never by a hand-written one. Two mechanisms deliver that, because the content travels by two different paths:

- Recipe sources under `packages/cli/src/agent/` SHALL express the CLI as `%(TASKLESS_CLI)s`, which the recipe renderer substitutes at render time.
- `skills/taskless/SKILL.md` and `commands/tskl/tskl.md` are not rendered through the recipe renderer, so they SHALL carry the canonical `npx @taskless/cli` string, which `applyCliInvocation` rewrites to the build target's invocation when the content is emitted.

Content SHALL NOT name the CLI as a bare `taskless` binary, because it is not installed on `PATH` for the overwhelming majority of readers. A recipe source SHALL NOT hardcode a launcher-and-package string such as `npx @taskless/cli` either, because that is the fact `%(TASKLESS_CLI)s` exists to hold in one place — the string is canonical only in the two files above, where it is the rewrite anchor rather than a hardcoding. Prose that mentions the product, a config filename, or a directory (`taskless.config`, `.taskless/`) is unaffected — the requirement is about executable instructions.

An automated check SHALL fail when a bare `` `taskless <subcommand>` `` invocation, or a hardcoded `npx @taskless/cli`, appears in a recipe source, so the normalization cannot silently regress as recipes are edited. The check SHALL read the subcommand names from the CLI's own registry rather than restating them, so a newly added subcommand is covered without a second edit. It does not extend to `skills/taskless/SKILL.md` or `commands/tskl/tskl.md`, whose rewrite anchor is the very string it flags and which quote invocations as example user utterances.

#### Scenario: A recipe instructs the reader to run a subcommand

- **WHEN** a recipe tells the reader to fetch another topic
- **THEN** the source SHALL read `%(TASKLESS_CLI)s agent <topic>` rather than `taskless agent <topic>` or `npx @taskless/cli agent <topic>`

#### Scenario: A regression is reintroduced

- **WHEN** a recipe source is edited to contain a bare `` `taskless check` ``-style invocation
- **THEN** the automated check SHALL fail and name the offending file and line

#### Scenario: Non-prod builds rewrite every invocation, not some of them

- **WHEN** a `nightly` or `self` build renders any recipe
- **THEN** every CLI invocation in the rendered text SHALL name that build's own invocation, with no occurrence left naming the released `@taskless/cli`

#### Scenario: Cross-reference checking survives the normalization

- **WHEN** the check that recipe cross-references cite only topics that resolve is run
- **THEN** it SHALL operate on rendered recipe text, where the invocation is a stable literal, rather than on source text where it is a placeholder
- **AND** it SHALL anchor on the invocation the running build actually renders, so the check does not pass vacuously under a `nightly`, `dev`, or `self` build whose rendered invocation names neither `taskless` nor `@taskless/cli`
- **AND** it SHALL fail when it finds no cross-reference at all, since an empty result is otherwise indistinguishable from every reference resolving
57 changes: 55 additions & 2 deletions openspec/specs/cli-knowledge-prompts/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ The prompt export SHALL be sourced from the same embedded `agent/*.txt` content

### Requirement: The export returns fully-rendered prompt text

Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder substituted — `CLI_VERSION` from the build-time version, `INPUT_SCHEMA` from the corresponding Zod input schema, `PACKAGE_MANAGER_DLX` from `PromptOptions.packageManagerDlx` or its default agent-fill marker — and with the build-target CLI invocation applied. The returned text SHALL NOT require further templating by the consumer.
Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder substituted — `CLI_VERSION` from the build-time version, `INPUT_SCHEMA` from the corresponding Zod input schema, `PACKAGE_MANAGER_DLX` from `PromptOptions.packageManagerDlx` or its default agent-fill marker, and `TASKLESS_CLI` from `PromptOptions.invocation`, the non-prod build-target invocation, or its default agent-fill marker `<taskless-cli>` — and with the build-target CLI invocation applied. The returned text SHALL NOT require further templating by the consumer.

`PromptOptions.invocation` SHALL be the only way a consumer influences `TASKLESS_CLI`. The render path SHALL NOT read `process`, the environment, or `argv` to discover the answer for itself: the module is imported by Workers without `nodejs_compat`, where a module-scope `process` read throws at import time, and a build-graph check cannot catch that because `process` is a global rather than an import. Detection therefore lives in the CLI, which passes the result in.

#### Scenario: Placeholders are resolved

Expand All @@ -61,14 +63,24 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub

#### Scenario: Schema-bearing topics render their input schema

- **WHEN** a recipe carrying `%(INPUT_SCHEMA)s` is rendered (today `rule-create` and `rule-improve`, both internal topics)
- **WHEN** a recipe carrying `%(INPUT_SCHEMA)s` is rendered (today `create-remote-rule` and `improve-rule`, both internal topics)
- **THEN** the placeholder is replaced by the JSON Schema rendered from that topic's Zod input schema

#### Scenario: Agent-fill marker defaults and overrides

- **WHEN** a recipe carrying `%(PACKAGE_MANAGER_DLX)s` is rendered without options (today `ci`, an internal topic)
- **THEN** the placeholder renders as the default `<package-manager-dlx>` marker; supplying `packageManagerDlx` substitutes that value instead

#### Scenario: A host importing the export gets the marker

- **WHEN** a consumer imports `@taskless/cli/prompts` from a prod build and renders a topic without supplying `invocation`
- **THEN** every `%(TASKLESS_CLI)s` occurrence renders as `<taskless-cli>`, leaving the host free to substitute the launcher it actually offers

#### Scenario: A supplied invocation is used verbatim

- **WHEN** a consumer supplies `invocation: "pnpm dlx @taskless/cli@latest"`
- **THEN** every `%(TASKLESS_CLI)s` occurrence renders as that string

### Requirement: The version header is suppressible

Rendered prompts SHALL begin with a header line naming the topic and the CLI version. Because that version participates in an LLM consumer's prompt-cache key, `PromptOptions.header` SHALL allow suppressing it. It SHALL default to `true`, leaving the `agent` command's output and all existing behavior unchanged.
Expand Down Expand Up @@ -164,3 +176,44 @@ 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.

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

### Requirement: The variable list is derived from the template parser

The `variables` list SHALL be obtained from `sprintf-js`'s own parse of the template, by rendering it against a value source that records which names are requested. It SHALL NOT be reconstructed by pattern-matching the template text.

The recorded pass's rendered output SHALL be discarded. `sprintf-js` collapses an escaped `%%` to a literal `%` while parsing, so text that has been through it is no longer a valid template — `getRawInstructions().text` SHALL therefore be the source template verbatim, never the recording pass's output.

#### Scenario: Escaped percent signs survive in raw text

- **WHEN** a topic whose template contains `%%` is requested via `getRawInstructions`
- **THEN** the returned text SHALL still contain `%%`, and rendering it SHALL yield a single `%`

#### Scenario: Variables are reported without a regex

- **WHEN** the variable list for a topic is computed
- **THEN** it SHALL come from the templating library's parse, so a name the library resolves is reported and text that merely resembles a placeholder is not
Loading
Loading