From 380e738483a27577595d0b1e974b73a99efc45ef Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 26 Jul 2026 17:56:36 -0700 Subject: [PATCH 1/9] =?UTF-8?q?docs(openspec):=20export-knowledge-prompts?= =?UTF-8?q?=20=E2=80=94=20importable=20@taskless/cli/prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LdEhGzeQfSGJM3nKKNj7Bp --- .../export-knowledge-prompts/.openspec.yaml | 2 + .../export-knowledge-prompts/design.md | 50 +++++++++++++++ .../export-knowledge-prompts/proposal.md | 22 +++++++ .../specs/cli-knowledge-prompts/spec.md | 64 +++++++++++++++++++ .../changes/export-knowledge-prompts/tasks.md | 18 ++++++ 5 files changed, 156 insertions(+) create mode 100644 openspec/changes/export-knowledge-prompts/.openspec.yaml create mode 100644 openspec/changes/export-knowledge-prompts/design.md create mode 100644 openspec/changes/export-knowledge-prompts/proposal.md create mode 100644 openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md create mode 100644 openspec/changes/export-knowledge-prompts/tasks.md diff --git a/openspec/changes/export-knowledge-prompts/.openspec.yaml b/openspec/changes/export-knowledge-prompts/.openspec.yaml new file mode 100644 index 00000000..8e7013b8 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-27 diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/export-knowledge-prompts/design.md new file mode 100644 index 00000000..f22a6634 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/design.md @@ -0,0 +1,50 @@ +## Context + +The CLI's knowledge prompts live at `packages/cli/src/help/*.txt` — canonical `.txt` recipes (`route`, `static`, `remote`, `existing`, `detect`, `rule-meta`, `check`, `rule-create`, …) plus `.anonymous.txt` local-only variants. `commands/help.ts` embeds them at build time with `import.meta.glob("../help/*.txt", { query: "?raw", import: "default", eager: true })`, builds `helpMap`/`anonymousMap`, and — for some topics — interpolates `%(KEY)s` placeholders (e.g. `CLI_VERSION`, `INPUT_SCHEMA`, agent-fill markers) via `sprintf-js` at render time. + +The package ships only `dist` and exports only `"." → ./dist/index.js` (which pulls the whole command tree: `citty`, `zod`, telemetry, …). So the prompts are reachable only by running `taskless help ` — not importable. `workers/generator` (a Cloudflare Worker) needs the same guidance programmatically, and must be able to import it **without** dragging in the CLI runtime. + +## Goals / Non-Goals + +**Goals:** expose the knowledge prompts as a stable, typed, importable API (`@taskless/cli/prompts`); keep one source of truth shared with the `help` command; keep the import dependency-light so a Worker can consume it. + +**Non-Goals:** rendering/interpolating `%(KEY)s` placeholders in the export (that needs CLI runtime context); changing the `help` command's behavior or the recipe text; changing rule execution or on-disk formats. + +## Decisions + +### D1 — A shared prompts module is the single source; `help.ts` consumes it + +Move the glob + `buildHelpMaps` logic into `src/prompts/index.ts`; `help.ts` imports from it. One embed, no duplication. + +- **Alternative — a second glob in `help.ts` and the export:** rejected; two embeds drift. + +### D2 — The export is dependency-light (pure data + types), imported via a subpath + +`src/prompts/index.ts` imports nothing from the CLI runtime (no `citty`/command tree/telemetry) — only the embedded strings and types. The subpath export `@taskless/cli/prompts` maps to a dedicated `dist/prompts.js` so importing it never loads `dist/index.js`. + +- **Alternative — re-export from the main entry (`@taskless/cli`):** rejected; the main entry pulls the whole CLI, unusable/heavy in a Worker. + +### D3 — The export returns RAW recipe text (placeholders intact) + +Prompts are returned verbatim, including any `%(KEY)s` placeholders. Interpolation stays CLI-side because it needs runtime context (version, package manager, input schemas). Consumers that feed guidance to an LLM router use the raw text as-is; a shared renderer can be added later if a concrete need appears. + +- **Alternative — export rendered text:** rejected; rendering requires CLI runtime state the export can't (and shouldn't) hold. + +### D4 — Typed accessor: `PROMPTS`, `PromptTopic`, `getPrompt` + +Expose a `PromptTopic` string-union of canonical topics, a `PROMPTS: Record` map, and `getPrompt(topic, opts?)`. Anonymous variants are accessible via `getPrompt(topic, { anonymous: true })` (falling back to canonical when no variant exists) and/or an `ANONYMOUS_PROMPTS` map. Topic names + accessor shape are the **public API** (semver-tracked); recipe _text_ may change within a major. + +## Risks / Trade-offs + +- **Build must emit the second entry** → configure Vite for a `prompts` entry with types; a CI/test asserts `dist/prompts.js` + `.d.ts` exist, or the export resolves to nothing at publish. +- **Raw placeholders surprise consumers** → document that text may contain `%(KEY)s`; optionally export the placeholder inventory later. +- **Prompt text drift within a major** → acceptable and stated; only topic names + shape are stability-guaranteed. + +## Migration Plan + +Purely additive: add the module, the subpath export, and the build entry; refactor `help.ts` internally (no behavior change). No consumer migration needed until `generator-decision-router` imports it. + +## Open Questions + +- Do consumers need `%(KEY)s` interpolation (a shared `renderPrompt`), or is raw sufficient for the generator's LLM-driven router? (Assumed raw is enough.) +- Final subpath name — `@taskless/cli/prompts` proposed; confirm before publishing since it's public API. diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md new file mode 100644 index 00000000..39197167 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -0,0 +1,22 @@ +## Why + +The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, `existing`, `detect`, `rule-meta`, `check`, …) — encode Taskless's rule-authoring and routing guidance. Today they are embedded at build time (Vite `import.meta.glob`) and reachable only through the `help` command (`npx @taskless/cli help `); the package `exports` is just `"." → dist/index.js`, so nothing can import them. `workers/generator` (and future consumers) need the _same_ guidance programmatically to give a consistent authoring/routing experience across the CLI and the service. This change exposes the prompts as a stable package import. + +## What Changes + +- Add a package **subpath export** `@taskless/cli/prompts` (built into `dist`, listed in `files`) that exposes the embedded `help/*.txt` recipes as importable, topic-keyed strings. +- Provide a typed accessor (e.g. `getPrompt(topic)` and a `PROMPTS` record) plus a stable `PromptTopic` union so consumers get compile-time safety over available topics. +- Keep the `help` command unchanged — it consumes the same embedded source, so there is one source of truth for both surfaces. +- Treat the prompt export as **public API**: topic names and the accessor shape are semver-tracked; prompt _text_ may evolve within a major. + +## Capabilities + +### New Capabilities + +- `cli-knowledge-prompts`: A stable, importable API exposing the CLI's knowledge prompts (the `help/*.txt` recipes) as topic-keyed strings with a typed accessor, sourced from the same embedded content the `help` command serves. + +## Impact + +- **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/index.ts` that re-exports the embedded `help/*.txt` map with a typed accessor; `commands/help.ts` refactored to consume that shared module (no behavior change). +- **Consumers**: `@taskless/cli/prompts` becomes importable — the enabler for the `taskless/taskless` `generator-decision-router` change. +- **No change** to CLI commands, rule execution, or on-disk formats. diff --git a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..f389d78a --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: The package exposes knowledge prompts via a dedicated import + +The package SHALL expose its knowledge prompts (the `help/*.txt` recipes) through a subpath export `@taskless/cli/prompts`, built into `dist` and listed in `files`, so consumers can import them without invoking the CLI. + +#### Scenario: Importing a prompt by topic + +- **WHEN** a consumer imports `getPrompt` (or `PROMPTS`) from `@taskless/cli/prompts` +- **THEN** it receives the recipe text for a known topic (e.g. `route`, `static`, `runtime`-adjacent authoring recipes) as a string + +### Requirement: The prompt export is typed + +The export SHALL provide a `PromptTopic` union of the available canonical topics, a `PROMPTS: Record` map, and a `getPrompt(topic, opts?)` accessor. Referencing an unknown topic SHALL be a compile-time error against `PromptTopic`. + +#### Scenario: Typed access to topics + +- **WHEN** a consumer calls `getPrompt("route")` +- **THEN** it type-checks and returns the `route` recipe; `getPrompt("nope")` fails type-checking against `PromptTopic` + +### Requirement: The export and the help command share one source + +The prompt export SHALL be sourced from the same embedded `help/*.txt` content that `commands/help.ts` serves, with no duplicated embedding. Both surfaces SHALL return identical text for the same topic. + +#### Scenario: Parity between import and help command + +- **WHEN** the `help` command renders topic `T` with no interpolation and a consumer reads `getPrompt("T")` +- **THEN** the two texts are identical (single source of truth) + +### Requirement: The export returns raw recipe text + +The export SHALL return recipe text verbatim, including any `%(KEY)s` placeholders; it SHALL NOT interpolate CLI runtime values. Interpolation remains a CLI-side concern. + +#### Scenario: Placeholders are preserved + +- **WHEN** a consumer reads a recipe that contains `%(CLI_VERSION)s` or similar +- **THEN** the returned string still contains the literal `%(...)s` placeholder, un-substituted + +### Requirement: The prompts import is free of CLI runtime dependencies + +The `@taskless/cli/prompts` module SHALL contain only embedded prompt data and types — no `citty` command tree, telemetry, or other CLI runtime imports — so importing it does not load `@taskless/cli`'s main entry. + +#### Scenario: Worker-safe import + +- **WHEN** a consumer imports `@taskless/cli/prompts` +- **THEN** the module resolves without pulling in `dist/index.js` or its CLI runtime dependencies + +### Requirement: Anonymous variants are accessible distinctly from canonical + +Where a `.anonymous.txt` variant exists, the export SHALL make it retrievable distinctly (e.g. `getPrompt(topic, { anonymous: true })`), falling back to the canonical recipe when no variant exists. + +#### Scenario: Anonymous variant retrieval and fallback + +- **WHEN** a consumer requests the anonymous variant of a topic that has one +- **THEN** it receives the `.anonymous` text; for a topic without a variant, it receives the canonical text + +### Requirement: Topic names and accessor shape are stable public API + +The set of `PromptTopic` names and the `getPrompt`/`PROMPTS` shape SHALL be treated as public API under semver; recipe _text_ MAY change within a major version. + +#### Scenario: Removing a topic is a breaking change + +- **WHEN** a topic is removed or renamed, or the accessor signature changes +- **THEN** it SHALL be released as a major version bump; a text edit SHALL NOT diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/export-knowledge-prompts/tasks.md new file mode 100644 index 00000000..c40a4c67 --- /dev/null +++ b/openspec/changes/export-knowledge-prompts/tasks.md @@ -0,0 +1,18 @@ +## 1. Shared prompts module + +- [ ] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps +- [ ] 1.2 Export the typed API: `PromptTopic` union, `PROMPTS: Record`, `getPrompt(topic, opts?)` (with `{ anonymous?: boolean }` and canonical fallback), and `ANONYMOUS_PROMPTS` +- [ ] 1.3 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree) — data + types only +- [ ] 1.4 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`), leaving `help` behavior unchanged + +## 2. Package export + build + +- [ ] 2.1 Add the `./prompts` subpath to `package.json` `exports` (→ `./dist/prompts.js`, with `types`) and keep `files: ["dist"]` +- [ ] 2.2 Configure the Vite build to emit `dist/prompts.js` (+ `dist/prompts.d.ts`) as a second entry alongside `dist/index.js` +- [ ] 2.3 Add a build/CI assertion that `dist/prompts.js` and its types exist after `vite build` + +## 3. Verify + +- [ ] 3.1 Test: `getPrompt(topic)` parity with the `help` command's un-interpolated text; raw `%(KEY)s` placeholders preserved; anonymous variant retrieval + fallback; `PromptTopic` rejects unknown topics +- [ ] 3.2 Test/assert the prompts entry does not pull in the CLI runtime (import graph excludes `dist/index.js` deps) +- [ ] 3.3 `pnpm --filter @taskless/cli typecheck && lint && test` clean; `vite build` emits both entries From 04e233813cb0e741320851a3256e765060b43d75 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 26 Jul 2026 22:38:44 -0700 Subject: [PATCH 2/9] docs(openspec): resolve export-knowledge-prompts open questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompts become render functions returning fully-rendered text rather than raw placeholder-bearing strings — every %(KEY)s resolves inside the package, and rendering carries applyCliInvocation so the export cannot diverge from `taskless help` under non-prod builds. Confirms @taskless/cli/prompts as the published subpath, and adds a completeness check requirement so topic membership stays an explicit list that a new or deleted recipe file cannot silently change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .../export-knowledge-prompts/design.md | 63 +++++++++++----- .../export-knowledge-prompts/proposal.md | 13 ++-- .../specs/cli-knowledge-prompts/spec.md | 72 +++++++++++++++---- .../changes/export-knowledge-prompts/tasks.md | 18 +++-- 4 files changed, 124 insertions(+), 42 deletions(-) diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/export-knowledge-prompts/design.md index f22a6634..c6f02124 100644 --- a/openspec/changes/export-knowledge-prompts/design.md +++ b/openspec/changes/export-knowledge-prompts/design.md @@ -6,45 +6,76 @@ The package ships only `dist` and exports only `"." → ./dist/index.js` (which ## Goals / Non-Goals -**Goals:** expose the knowledge prompts as a stable, typed, importable API (`@taskless/cli/prompts`); keep one source of truth shared with the `help` command; keep the import dependency-light so a Worker can consume it. +**Goals:** expose the knowledge prompts as a stable, typed, importable API (`@taskless/cli/prompts`); keep one source of truth shared with the `help` command — the same embedded text _and_ the same render pipeline, so both surfaces emit identical output; keep the import free of the CLI runtime so a Worker can consume it. -**Non-Goals:** rendering/interpolating `%(KEY)s` placeholders in the export (that needs CLI runtime context); changing the `help` command's behavior or the recipe text; changing rule execution or on-disk formats. +**Non-Goals:** exposing a caller-facing template/render API (`renderPrompt(topic, vars)`); changing the `help` command's observable behavior or the recipe text; changing rule execution or on-disk formats. ## Decisions ### D1 — A shared prompts module is the single source; `help.ts` consumes it -Move the glob + `buildHelpMaps` logic into `src/prompts/index.ts`; `help.ts` imports from it. One embed, no duplication. +Move the glob + `buildHelpMaps` logic **and `renderRecipe`** (`commands/help.ts:83-95`) into `src/prompts/index.ts`; `help.ts` imports from it. One embed and one render path, no duplication. - **Alternative — a second glob in `help.ts` and the export:** rejected; two embeds drift. +- **Alternative — share the embed but not the renderer:** rejected; the two surfaces would emit different text from identical source, which is the drift the change exists to prevent. -### D2 — The export is dependency-light (pure data + types), imported via a subpath +### D2 — The export carries no CLI runtime, imported via a subpath -`src/prompts/index.ts` imports nothing from the CLI runtime (no `citty`/command tree/telemetry) — only the embedded strings and types. The subpath export `@taskless/cli/prompts` maps to a dedicated `dist/prompts.js` so importing it never loads `dist/index.js`. +`src/prompts/index.ts` imports nothing from the CLI runtime — no `citty`/command tree, no telemetry, no filesystem or network. It may import `sprintf-js` and the two Zod input schemas (`schemas/rules-create`, `schemas/rules-improve`), which are leaf modules whose only dependency is `zod`; both are already dependencies of the intended consumer. The subpath export `@taskless/cli/prompts` maps to a dedicated `dist/prompts.js` so importing it never loads `dist/index.js`. - **Alternative — re-export from the main entry (`@taskless/cli`):** rejected; the main entry pulls the whole CLI, unusable/heavy in a Worker. +- **Alternative — pure data with zero deps, pre-rendering `INPUT_SCHEMA` at build time:** rejected as premature; it buys nothing for the intended consumer (which already ships `zod`) and adds a codegen step to keep in sync. -### D3 — The export returns RAW recipe text (placeholders intact) +### D3 — Prompts are functions returning fully-rendered text -Prompts are returned verbatim, including any `%(KEY)s` placeholders. Interpolation stays CLI-side because it needs runtime context (version, package manager, input schemas). Consumers that feed guidance to an LLM router use the raw text as-is; a shared renderer can be added later if a concrete need appears. +Each prompt is a function; calling it runs the same pipeline `taskless help` runs — `applyCliInvocation`, then `sprintf` over the variable table — and returns finished text. A consumer never observes a `%(KEY)s` placeholder. -- **Alternative — export rendered text:** rejected; rendering requires CLI runtime state the export can't (and shouldn't) hold. +Every placeholder in use today is resolvable **inside** the package, so there is nothing for a caller to supply: -### D4 — Typed accessor: `PROMPTS`, `PromptTopic`, `getPrompt` +| Placeholder | Topics | Resolved from | +| --------------------- | ----------------------------- | --------------------------------------------------------------- | +| `CLI_VERSION` | all (header line) | build define `__VERSION__` | +| `INPUT_SCHEMA` | `rule-create`, `rule-improve` | `z.toJSONSchema()` over the Zod input schema | +| `PACKAGE_MANAGER_DLX` | `ci` | agent-fill marker `` (overridable, see D4) | -Expose a `PromptTopic` string-union of canonical topics, a `PROMPTS: Record` map, and `getPrompt(topic, opts?)`. Anonymous variants are accessible via `getPrompt(topic, { anonymous: true })` (falling back to canonical when no variant exists) and/or an `ANONYMOUS_PROMPTS` map. Topic names + accessor shape are the **public API** (semver-tracked); recipe _text_ may change within a major. +None of the routing topics the first consumer needs (`route`, `static`, `remote`, `existing`, `detect`, `rule-meta`) contain anything but `CLI_VERSION`. + +Rendering also carries `applyCliInvocation` (`util/invocation.ts:18`), which rewrites `npx @taskless/cli` to the build-target invocation. This is a no-op for prod builds but **not** for `build:self`/`build:dev` — the mode in which the generator consumes this as a workspace/path dependency before publish. Returning raw text would make the export disagree with `taskless help` in exactly the setup the first consumer uses. + +- **Alternative — return raw text, placeholders intact:** rejected; pushes an undocumented template dialect (sprintf-js, including its `%%` escaping rule) onto every consumer to solve values the package already knows, and silently diverges from `help` under non-prod builds. +- **Alternative — a caller-facing `renderPrompt(topic, vars)`:** rejected as speculative; there is no variable a caller knows and the package does not. The optional-options escape hatch in D4 covers the case non-breakingly if one appears. + +### D4 — Typed accessor: `PromptTopic`, `PROMPTS`, `getPrompt` + +Expose a `PromptTopic` string-union of canonical topics, a `PROMPTS: Record string>` map of render functions, and a `getPrompt(topic, options?)` accessor over the same. `PromptOptions` carries `anonymous?: boolean` (select the `.anonymous` variant, falling back to canonical when none exists) and `packageManagerDlx?: string` — the one value a caller may genuinely know better than the package, defaulting to today's agent-fill marker. Adding a future option is additive, not breaking. + +Topic names, the accessor shape, and `PromptOptions`' existing fields are the **public API** (semver-tracked); recipe _text_ may change within a major. + +- **Alternative — `Record` of pre-rendered strings:** rejected; it has no room for the `anonymous` dimension without doubling the key space, and forecloses per-call options. + +### D5 — `PromptTopic` is an explicit list, kept honest by a completeness check + +`PromptTopic` derives from a hand-maintained `const TOPICS = [...] as const` tuple, not from the recipe files on disk. A companion `INTERNAL_TOPICS` set records recipe files deliberately withheld from the export. A test asserts the canonical `help/*.txt` topics on disk equal `TOPICS ∪ INTERNAL_TOPICS`, failing in either direction. + +Deriving the union from the glob is not possible at the type level regardless: Vite types `import.meta.glob` as `Record` (`vite/types/importGlob.d.ts:69-70,88`) — the keys are `string`, with no literal inference from the pattern. + +It is also not desirable. Topic names are semver-tracked public API (D4), so an auto-derived union would let a new `src/help/*.txt` file silently publish public API, and a deleted one silently ship a major break. The explicit list is the gate; the check is what prevents the gate from drifting out of sync unnoticed. A new recipe file fails CI on the PR that adds it, forcing a deliberate export-or-withhold decision. + +- **Alternative — codegen `topics.generated.ts` from the glob with a CI `--check` mode:** rejected; converts the failure from a red test to a red type error, but adds a generated file plus a script, still needs the same CI gate, and reinstates silent publishing of any newly added recipe. +- **Trade-off accepted:** completeness is enforced at test time, not compile time. The check runs on the PR that introduces the divergence, which is when it matters. ## Risks / Trade-offs - **Build must emit the second entry** → configure Vite for a `prompts` entry with types; a CI/test asserts `dist/prompts.js` + `.d.ts` exist, or the export resolves to nothing at publish. -- **Raw placeholders surprise consumers** → document that text may contain `%(KEY)s`; optionally export the placeholder inventory later. -- **Prompt text drift within a major** → acceptable and stated; only topic names + shape are stability-guaranteed. +- **Build defines must reach the second entry** → `dist/prompts.js` depends on `__VERSION__` and `__TASKLESS_CLI__` being inlined. If the `prompts` entry is configured without the same `define` block as the main entry, rendering emits a literal `__VERSION__` or throws. A test asserting rendered output contains no `__`-prefixed define names covers this. +- **No per-topic tree-shaking** → a render function isn't statically analyzable, so all 20 recipes (~66 KB of text) ship even when a consumer reads six. Negligible against Worker bundle limits; accepted deliberately in exchange for `help`/export parity. +- **Prompt text drift within a major** → acceptable and stated; only topic names + accessor shape are stability-guaranteed. ## Migration Plan -Purely additive: add the module, the subpath export, and the build entry; refactor `help.ts` internally (no behavior change). No consumer migration needed until `generator-decision-router` imports it. +Purely additive: add the module, the subpath export, and the build entry; move the embed + `renderRecipe` out of `help.ts` and import them back (no observable behavior change). No consumer migration needed until `generator-decision-router` imports it. -## Open Questions +## Resolved Questions -- Do consumers need `%(KEY)s` interpolation (a shared `renderPrompt`), or is raw sufficient for the generator's LLM-driven router? (Assumed raw is enough.) -- Final subpath name — `@taskless/cli/prompts` proposed; confirm before publishing since it's public API. +- **Interpolation (resolved: no caller-facing renderer).** Prompts are functions that return fully-rendered text; every placeholder in use resolves inside the package (D3). `PromptOptions.packageManagerDlx` is the sole caller-supplied value, and none of the first consumer's topics use it. +- **Subpath name (resolved: `@taskless/cli/prompts`).** Confirmed as the published name. It reads accurately for a surface broader than routing — it covers every `help` recipe, including `ci`, `auth`, and `init` — and matches the name the `generator-decision-router` change already references. diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md index 39197167..46ca3f17 100644 --- a/openspec/changes/export-knowledge-prompts/proposal.md +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -4,19 +4,20 @@ The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, ` ## What Changes -- Add a package **subpath export** `@taskless/cli/prompts` (built into `dist`, listed in `files`) that exposes the embedded `help/*.txt` recipes as importable, topic-keyed strings. -- Provide a typed accessor (e.g. `getPrompt(topic)` and a `PROMPTS` record) plus a stable `PromptTopic` union so consumers get compile-time safety over available topics. -- Keep the `help` command unchanged — it consumes the same embedded source, so there is one source of truth for both surfaces. -- Treat the prompt export as **public API**: topic names and the accessor shape are semver-tracked; prompt _text_ may evolve within a major. +- Add a package **subpath export** `@taskless/cli/prompts` (built into `dist`, listed in `files`) that exposes the embedded `help/*.txt` recipes as importable, topic-keyed render functions. +- Provide a typed accessor (`getPrompt(topic, options?)` and a `PROMPTS` record of functions) plus a stable `PromptTopic` union so consumers get compile-time safety over available topics. +- Return **fully-rendered** text: calling a prompt resolves every `%(KEY)s` placeholder from values the package already holds (version, input schemas, agent-fill markers), so consumers never handle a template dialect. +- Keep the `help` command's output unchanged — it consumes the same embedded source _and_ the same renderer, so there is one source of truth for both surfaces. +- Treat the prompt export as **public API**: topic names and the accessor shape are semver-tracked; prompt _text_ may evolve within a major. Membership is an explicit list, not whatever files happen to be on disk, guarded by a completeness check so a new or deleted recipe can't silently change the published surface. ## Capabilities ### New Capabilities -- `cli-knowledge-prompts`: A stable, importable API exposing the CLI's knowledge prompts (the `help/*.txt` recipes) as topic-keyed strings with a typed accessor, sourced from the same embedded content the `help` command serves. +- `cli-knowledge-prompts`: A stable, importable API exposing the CLI's knowledge prompts (the `help/*.txt` recipes) as topic-keyed render functions with a typed accessor, sourced from the same embedded content and render path the `help` command serves. ## Impact -- **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/index.ts` that re-exports the embedded `help/*.txt` map with a typed accessor; `commands/help.ts` refactored to consume that shared module (no behavior change). +- **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/index.ts` holding the embedded `help/*.txt` map, the render path, and a typed accessor; `commands/help.ts` refactored to consume that shared module (no observable behavior change); the Vite build gains a second entry. - **Consumers**: `@taskless/cli/prompts` becomes importable — the enabler for the `taskless/taskless` `generator-decision-router` change. - **No change** to CLI commands, rule execution, or on-disk formats. diff --git a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md index f389d78a..e3e029f8 100644 --- a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md +++ b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md @@ -11,34 +11,54 @@ The package SHALL expose its knowledge prompts (the `help/*.txt` recipes) throug ### Requirement: The prompt export is typed -The export SHALL provide a `PromptTopic` union of the available canonical topics, a `PROMPTS: Record` map, and a `getPrompt(topic, opts?)` accessor. Referencing an unknown topic SHALL be a compile-time error against `PromptTopic`. +The export SHALL provide a `PromptTopic` union of the available canonical topics, a `PROMPTS: Record string>` map of render functions, and a `getPrompt(topic, options?)` accessor over the same. Referencing an unknown topic SHALL be a compile-time error against `PromptTopic`. #### Scenario: Typed access to topics - **WHEN** a consumer calls `getPrompt("route")` - **THEN** it type-checks and returns the `route` recipe; `getPrompt("nope")` fails type-checking against `PromptTopic` -### Requirement: The export and the help command share one source +#### Scenario: A topic is callable from the map -The prompt export SHALL be sourced from the same embedded `help/*.txt` content that `commands/help.ts` serves, with no duplicated embedding. Both surfaces SHALL return identical text for the same topic. +- **WHEN** a consumer calls `PROMPTS.route()` with no arguments +- **THEN** it returns the same string as `getPrompt("route")` + +### Requirement: The export and the help command share one source and one renderer + +The prompt export SHALL be sourced from the same embedded `help/*.txt` content that `commands/help.ts` serves, and SHALL render it through the same render path, with no duplicated embedding and no duplicated interpolation logic. Both surfaces SHALL return identical text for the same topic and equivalent options. #### Scenario: Parity between import and help command -- **WHEN** the `help` command renders topic `T` with no interpolation and a consumer reads `getPrompt("T")` -- **THEN** the two texts are identical (single source of truth) +- **WHEN** the `help` command renders topic `T` and a consumer calls `getPrompt("T")` +- **THEN** the two texts are identical, including under a non-prod build target where the CLI invocation is rewritten + +### 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. + +#### Scenario: Placeholders are resolved -### Requirement: The export returns raw recipe text +- **WHEN** a consumer calls a prompt for a recipe whose source contains `%(CLI_VERSION)s` +- **THEN** the returned string contains the rendered version and no literal `%(...)s` placeholder -The export SHALL return recipe text verbatim, including any `%(KEY)s` placeholders; it SHALL NOT interpolate CLI runtime values. Interpolation remains a CLI-side concern. +#### Scenario: Schema-bearing topics render their input schema -#### Scenario: Placeholders are preserved +- **WHEN** a consumer calls the `rule-create` or `rule-improve` prompt +- **THEN** `%(INPUT_SCHEMA)s` is replaced by the JSON Schema rendered from that topic's Zod input schema -- **WHEN** a consumer reads a recipe that contains `%(CLI_VERSION)s` or similar -- **THEN** the returned string still contains the literal `%(...)s` placeholder, un-substituted +#### Scenario: Agent-fill marker defaults and overrides + +- **WHEN** a consumer calls the `ci` prompt without options +- **THEN** `%(PACKAGE_MANAGER_DLX)s` renders as the default `` marker; supplying `packageManagerDlx` substitutes that value instead + +#### Scenario: Build defines are inlined into the prompts entry + +- **WHEN** a rendered prompt is inspected from the built `dist/prompts.js` +- **THEN** it contains no un-inlined build-define identifier (e.g. a literal `__VERSION__`) ### Requirement: The prompts import is free of CLI runtime dependencies -The `@taskless/cli/prompts` module SHALL contain only embedded prompt data and types — no `citty` command tree, telemetry, or other CLI runtime imports — so importing it does not load `@taskless/cli`'s main entry. +The `@taskless/cli/prompts` module SHALL contain only embedded prompt data, types, and the render path — no `citty` command tree, telemetry, filesystem, or network imports — so importing it does not load `@taskless/cli`'s main entry. Its permitted runtime imports are the templating library and the leaf Zod input schemas required for rendering. #### Scenario: Worker-safe import @@ -47,7 +67,7 @@ The `@taskless/cli/prompts` module SHALL contain only embedded prompt data and t ### Requirement: Anonymous variants are accessible distinctly from canonical -Where a `.anonymous.txt` variant exists, the export SHALL make it retrievable distinctly (e.g. `getPrompt(topic, { anonymous: true })`), falling back to the canonical recipe when no variant exists. +Where a `.anonymous.txt` variant exists, the export SHALL make it retrievable distinctly via `PromptOptions.anonymous`, falling back to the canonical recipe when no variant exists. #### Scenario: Anonymous variant retrieval and fallback @@ -56,9 +76,35 @@ Where a `.anonymous.txt` variant exists, the export SHALL make it retriev ### Requirement: Topic names and accessor shape are stable public API -The set of `PromptTopic` names and the `getPrompt`/`PROMPTS` shape SHALL be treated as public API under semver; recipe _text_ MAY change within a major version. +The set of `PromptTopic` names, the `getPrompt`/`PROMPTS` shape, and the existing fields of `PromptOptions` SHALL be treated as public API under semver; recipe _text_ MAY change within a major version. #### Scenario: Removing a topic is a breaking change - **WHEN** a topic is removed or renamed, or the accessor signature changes - **THEN** it SHALL be released as a major version bump; a text edit SHALL NOT + +#### Scenario: Adding an option is not a breaking change + +- **WHEN** a new optional field is added to `PromptOptions` +- **THEN** it SHALL NOT require a major version bump, since existing call sites keep their behavior + +### Requirement: Topic membership is explicit and verified against the recipe files + +`PromptTopic` SHALL be derived from an explicit, hand-maintained list of exported topics rather than inferred from whatever recipe files are present, so that adding or removing a `help/*.txt` file cannot silently change the public API. Recipe files deliberately withheld from the export SHALL be recorded in an explicit internal-topics list. + +An automated check SHALL assert that the set of canonical `help/*.txt` topics on disk is exactly the union of the exported topics and the internal-topics list, failing when the two diverge in either direction. + +#### Scenario: A new recipe file is added without being classified + +- **WHEN** a new canonical `help/.txt` is added and appears in neither the exported topics nor the internal-topics list +- **THEN** the completeness check SHALL fail, requiring the author to either export the topic or record it as internal + +#### Scenario: An exported topic loses its recipe file + +- **WHEN** a topic remains in `PromptTopic` but its canonical `help/.txt` no longer exists +- **THEN** the completeness check SHALL fail, rather than the topic rendering empty or undefined at runtime + +#### Scenario: A deliberately internal recipe stays unexported + +- **WHEN** a recipe file is listed as internal +- **THEN** the check SHALL pass and the topic SHALL NOT be a member of `PromptTopic` diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/export-knowledge-prompts/tasks.md index c40a4c67..432fad51 100644 --- a/openspec/changes/export-knowledge-prompts/tasks.md +++ b/openspec/changes/export-knowledge-prompts/tasks.md @@ -1,18 +1,22 @@ ## 1. Shared prompts module - [ ] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps -- [ ] 1.2 Export the typed API: `PromptTopic` union, `PROMPTS: Record`, `getPrompt(topic, opts?)` (with `{ anonymous?: boolean }` and canonical fallback), and `ANONYMOUS_PROMPTS` -- [ ] 1.3 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree) — data + types only -- [ ] 1.4 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`), leaving `help` behavior unchanged +- [ ] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path +- [ ] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous +- [ ] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal +- [ ] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only +- [ ] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical ## 2. Package export + build - [ ] 2.1 Add the `./prompts` subpath to `package.json` `exports` (→ `./dist/prompts.js`, with `types`) and keep `files: ["dist"]` -- [ ] 2.2 Configure the Vite build to emit `dist/prompts.js` (+ `dist/prompts.d.ts`) as a second entry alongside `dist/index.js` +- [ ] 2.2 Configure the Vite build to emit `dist/prompts.js` (+ `dist/prompts.d.ts`) as a second entry alongside `dist/index.js`, **with the same `define` block** (`__VERSION__`, `__TASKLESS_CLI__`) as the main entry - [ ] 2.3 Add a build/CI assertion that `dist/prompts.js` and its types exist after `vite build` ## 3. Verify -- [ ] 3.1 Test: `getPrompt(topic)` parity with the `help` command's un-interpolated text; raw `%(KEY)s` placeholders preserved; anonymous variant retrieval + fallback; `PromptTopic` rejects unknown topics -- [ ] 3.2 Test/assert the prompts entry does not pull in the CLI runtime (import graph excludes `dist/index.js` deps) -- [ ] 3.3 `pnpm --filter @taskless/cli typecheck && lint && test` clean; `vite build` emits both entries +- [ ] 3.1 Test: `getPrompt(topic)` parity with the `help` command's rendered text; every `%(KEY)s` resolved (no literal placeholder survives); `rule-create`/`rule-improve` render their JSON Schema; `ci` renders the `` default and honors an override; anonymous variant retrieval + fallback; `PromptTopic` rejects unknown topics +- [ ] 3.2 Test: rendered output from the built `dist/prompts.js` contains no un-inlined build define (e.g. a literal `__VERSION__`) +- [ ] 3.3 Test/assert the prompts entry does not pull in the CLI runtime (import graph excludes `dist/index.js` deps) +- [ ] 3.4 Completeness check: assert the canonical `help/*.txt` topics on disk equal `TOPICS ∪ INTERNAL_TOPICS` — fails both when a new recipe is unclassified and when an exported topic's file is gone +- [ ] 3.5 `pnpm --filter @taskless/cli typecheck && lint && test` clean; `vite build` emits both entries From 062f491bd9f6cdaa4df7d1e02ba7661ced97883e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 26 Jul 2026 23:03:12 -0700 Subject: [PATCH 3/9] docs(openspec): add header option, narrow exported topics to real demand The cloud consumer established that only `static` is reachable server-side: `route` decides a pre-service authoring destination, `remote` states the service owns rule-type selection, and `detect` / `existing` / `rule-meta` are local-only. TOPICS ships minimal and the rest stay internal, since an exported name is a promise held for a major. Recorded as D6, correcting an earlier six-topic assumption this design had read off the consumer's draft rather than the recipes. Adds PromptOptions.header (default true) so a consumer can drop the version-bearing header line, which would otherwise sit in an LLM prompt-cache key and be invalidated by every CLI publish. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- .../export-knowledge-prompts/design.md | 37 +++++++++++++++++-- .../specs/cli-knowledge-prompts/spec.md | 14 +++++++ .../changes/export-knowledge-prompts/tasks.md | 6 +-- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/export-knowledge-prompts/design.md index c6f02124..49481dee 100644 --- a/openspec/changes/export-knowledge-prompts/design.md +++ b/openspec/changes/export-knowledge-prompts/design.md @@ -38,16 +38,28 @@ Every placeholder in use today is resolvable **inside** the package, so there is | `INPUT_SCHEMA` | `rule-create`, `rule-improve` | `z.toJSONSchema()` over the Zod input schema | | `PACKAGE_MANAGER_DLX` | `ci` | agent-fill marker `` (overridable, see D4) | -None of the routing topics the first consumer needs (`route`, `static`, `remote`, `existing`, `detect`, `rule-meta`) contain anything but `CLI_VERSION`. +No topic the first consumer needs contains anything but `CLI_VERSION`. (An earlier draft of this design named a six-topic consumer set read off the `generator-decision-router` design; the `cloud` side has since established that only `static` is reachable server-side — see D6.) -Rendering also carries `applyCliInvocation` (`util/invocation.ts:18`), which rewrites `npx @taskless/cli` to the build-target invocation. This is a no-op for prod builds but **not** for `build:self`/`build:dev` — the mode in which the generator consumes this as a workspace/path dependency before publish. Returning raw text would make the export disagree with `taskless help` in exactly the setup the first consumer uses. +Rendering also carries `applyCliInvocation` (`util/invocation.ts:18`), which rewrites `npx @taskless/cli` to the build-target invocation. That is a no-op for prod builds, and the first consumer now plans to consume a **published prerelease** rather than a workspace/path dependency (D6), so this is not load-bearing for that consumer. It still matters for parity in general: any consumer resolving the package from a non-prod build gets the same invocation string `taskless help` prints, because both go through one render path. -- **Alternative — return raw text, placeholders intact:** rejected; pushes an undocumented template dialect (sprintf-js, including its `%%` escaping rule) onto every consumer to solve values the package already knows, and silently diverges from `help` under non-prod builds. +- **Alternative — return raw text, placeholders intact:** rejected; pushes an undocumented template dialect (sprintf-js, including its `%%` escaping rule) onto every consumer to solve values the package already knows. - **Alternative — a caller-facing `renderPrompt(topic, vars)`:** rejected as speculative; there is no variable a caller knows and the package does not. The optional-options escape hatch in D4 covers the case non-breakingly if one appears. ### D4 — Typed accessor: `PromptTopic`, `PROMPTS`, `getPrompt` -Expose a `PromptTopic` string-union of canonical topics, a `PROMPTS: Record string>` map of render functions, and a `getPrompt(topic, options?)` accessor over the same. `PromptOptions` carries `anonymous?: boolean` (select the `.anonymous` variant, falling back to canonical when none exists) and `packageManagerDlx?: string` — the one value a caller may genuinely know better than the package, defaulting to today's agent-fill marker. Adding a future option is additive, not breaking. +Expose a `PromptTopic` string-union of canonical topics, a `PROMPTS: Record string>` map of render functions, and a `getPrompt(topic, options?)` accessor over the same. Adding a future option is additive, not breaking. + +`PromptOptions`: + +| Field | Default | Purpose | +| -------------------- | ----------------------- | --------------------------------------------------------------------------- | +| `anonymous?` | `false` | select the `.anonymous` variant, falling back to canonical when none exists | +| `packageManagerDlx?` | `` | the one value a caller may know better than the package | +| `header?` | `true` | include the `# Topic: (CLI v / topic v1)` first line | + +`header: false` exists for a concrete reason: the header embeds the CLI version, and a consumer placing rendered text in an LLM **system prompt** puts that version into the prompt-cache key — so every CLI publish invalidates the consumer's cache for every request, over a line the model does not use. Defaulting to `true` leaves `taskless help` output and every existing behavior byte-identical. + +- **Alternative — let consumers strip the header themselves:** rejected; every consumer reimplements the same fragile first-line regex against text whose format we control, and it silently breaks if the header ever gains a line. Topic names, the accessor shape, and `PromptOptions`' existing fields are the **public API** (semver-tracked); recipe _text_ may change within a major. @@ -64,6 +76,23 @@ It is also not desirable. Topic names are semver-tracked public API (D4), so an - **Alternative — codegen `topics.generated.ts` from the glob with a CI `--check` mode:** rejected; converts the failure from a red test to a red type error, but adds a generated file plus a script, still needs the same CI gate, and reinstates silent publishing of any newly added recipe. - **Trade-off accepted:** completeness is enforced at test time, not compile time. The check runs on the PR that introduces the divergence, which is when it matters. +### D6 — The initial exported topic list is minimal, driven by a real consumer + +The first consumer (`generator-decision-router`, in the `cloud` repo) has confirmed which topics it can actually use server-side, and it is not the set this design originally assumed. Verified against the recipe files: + +| Topic | Server-side usable | Why not | +| ----------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `static` | **yes** | canonical on-disk rule shape; the one topic the router wants | +| `route` | no | picks an _authoring destination_ (`existing`/`static`/`remote`) pre-service; runs `detect`, asks the user. A request reaching the generator has already resolved to `remote` | +| `remote` | no | states the boundary itself — "The service owns rule-type selection" (`remote.txt:51`) | +| `detect` | no | documents a CLI subprocess a Worker cannot run | +| `existing` | no | local-toolchain authoring; structurally unreachable server-side | +| `rule-meta` | no | unrelated — reads a `rule improve` metadata sidecar | + +So `TOPICS` starts at the minimum a consumer genuinely needs and grows on demand. Under D4 an exported name is a promise held for a major version; exporting a topic speculatively spends that promise for nothing. Everything else is recorded in `INTERNAL_TOPICS` (D5), which keeps them visible and deliberate rather than forgotten. + +Consumption is via a **published prerelease** of `@taskless/cli`, not a workspace/path dependency: `../skills` sits outside the `cloud` pnpm workspace and does not resolve in its CI or the generator's Docker build. + ## Risks / Trade-offs - **Build must emit the second entry** → configure Vite for a `prompts` entry with types; a CI/test asserts `dist/prompts.js` + `.d.ts` exist, or the export resolves to nothing at publish. diff --git a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md index e3e029f8..8ea23cb6 100644 --- a/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md +++ b/openspec/changes/export-knowledge-prompts/specs/cli-knowledge-prompts/spec.md @@ -51,6 +51,20 @@ Calling a prompt SHALL return finished text with every `%(KEY)s` placeholder sub - **WHEN** a consumer calls the `ci` prompt without options - **THEN** `%(PACKAGE_MANAGER_DLX)s` renders as the default `` marker; supplying `packageManagerDlx` substitutes that value instead +### 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 `help` command's output and all existing behavior unchanged. + +#### Scenario: Header suppressed for a cache-stable system prompt + +- **WHEN** a consumer calls a prompt with `header: false` +- **THEN** the returned text omits the `# Topic: …` line and contains no CLI version string, while the body is otherwise identical to the default rendering + +#### Scenario: Header present by default + +- **WHEN** a prompt is called with no options, or the `help` command renders a topic +- **THEN** the header line is present, exactly as it renders today + #### Scenario: Build defines are inlined into the prompts entry - **WHEN** a rendered prompt is inspected from the built `dist/prompts.js` diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/export-knowledge-prompts/tasks.md index 432fad51..cd566ff1 100644 --- a/openspec/changes/export-knowledge-prompts/tasks.md +++ b/openspec/changes/export-knowledge-prompts/tasks.md @@ -2,8 +2,8 @@ - [ ] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps - [ ] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path -- [ ] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous -- [ ] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal +- [ ] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous +- [ ] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does - [ ] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only - [ ] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical @@ -15,7 +15,7 @@ ## 3. Verify -- [ ] 3.1 Test: `getPrompt(topic)` parity with the `help` command's rendered text; every `%(KEY)s` resolved (no literal placeholder survives); `rule-create`/`rule-improve` render their JSON Schema; `ci` renders the `` default and honors an override; anonymous variant retrieval + fallback; `PromptTopic` rejects unknown topics +- [ ] 3.1 Test: `getPrompt(topic)` parity with the `help` command's rendered text; every `%(KEY)s` resolved (no literal placeholder survives); `rule-create`/`rule-improve` render their JSON Schema; `ci` renders the `` default and honors an override; `header: false` drops the `# Topic:` line and leaves no version string while the body is unchanged; anonymous variant retrieval + fallback; `PromptTopic` rejects unknown topics - [ ] 3.2 Test: rendered output from the built `dist/prompts.js` contains no un-inlined build define (e.g. a literal `__VERSION__`) - [ ] 3.3 Test/assert the prompts entry does not pull in the CLI runtime (import graph excludes `dist/index.js` deps) - [ ] 3.4 Completeness check: assert the canonical `help/*.txt` topics on disk equal `TOPICS ∪ INTERNAL_TOPICS` — fails both when a new recipe is unclassified and when an exported topic's file is gone From 76d59b00de2c9cbc52b17523955ab1d950a134aa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 26 Jul 2026 23:10:16 -0700 Subject: [PATCH 4/9] docs(openspec): consume a published release, not a prerelease The consumer retracted its prerelease request, preferring to wait for a stable version over pinning a moving one against an API that isn't semver-stable yet. Nothing downstream now waits on this change, so it releases at whatever cadence suits this repo. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- openspec/changes/export-knowledge-prompts/design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/export-knowledge-prompts/design.md index 49481dee..060c1957 100644 --- a/openspec/changes/export-knowledge-prompts/design.md +++ b/openspec/changes/export-knowledge-prompts/design.md @@ -40,7 +40,7 @@ Every placeholder in use today is resolvable **inside** the package, so there is No topic the first consumer needs contains anything but `CLI_VERSION`. (An earlier draft of this design named a six-topic consumer set read off the `generator-decision-router` design; the `cloud` side has since established that only `static` is reachable server-side — see D6.) -Rendering also carries `applyCliInvocation` (`util/invocation.ts:18`), which rewrites `npx @taskless/cli` to the build-target invocation. That is a no-op for prod builds, and the first consumer now plans to consume a **published prerelease** rather than a workspace/path dependency (D6), so this is not load-bearing for that consumer. It still matters for parity in general: any consumer resolving the package from a non-prod build gets the same invocation string `taskless help` prints, because both go through one render path. +Rendering also carries `applyCliInvocation` (`util/invocation.ts:18`), which rewrites `npx @taskless/cli` to the build-target invocation. That is a no-op for prod builds, and the first consumer will consume a normal published release (D6), so this is not load-bearing for that consumer. It still matters for parity in general: any consumer resolving the package from a non-prod build gets the same invocation string `taskless help` prints, because both go through one render path. - **Alternative — return raw text, placeholders intact:** rejected; pushes an undocumented template dialect (sprintf-js, including its `%%` escaping rule) onto every consumer to solve values the package already knows. - **Alternative — a caller-facing `renderPrompt(topic, vars)`:** rejected as speculative; there is no variable a caller knows and the package does not. The optional-options escape hatch in D4 covers the case non-breakingly if one appears. @@ -91,7 +91,9 @@ The first consumer (`generator-decision-router`, in the `cloud` repo) has confir So `TOPICS` starts at the minimum a consumer genuinely needs and grows on demand. Under D4 an exported name is a promise held for a major version; exporting a topic speculatively spends that promise for nothing. Everything else is recorded in `INTERNAL_TOPICS` (D5), which keeps them visible and deliberate rather than forgotten. -Consumption is via a **published prerelease** of `@taskless/cli`, not a workspace/path dependency: `../skills` sits outside the `cloud` pnpm workspace and does not resolve in its CI or the generator's Docker build. +Consumption is via a **normal published release** of `@taskless/cli`. A workspace/path dependency is not an option — `../skills` sits outside the `cloud` pnpm workspace and does not resolve in its CI or the generator's Docker build — and the consumer has explicitly declined a prerelease, preferring to wait for a stable version rather than pin a moving one against an API that is by definition not yet semver-stable. + +**This change is therefore not on anyone's critical path.** Nothing downstream is waiting on it, so it can be released at whatever cadence suits this repo, and there is no reason to cut a prerelease to unblock a consumer. ## Risks / Trade-offs From 8e9e5d1f8e469cd7a27ba5851528d0c7b90a5564 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 27 Jul 2026 17:09:27 -0700 Subject: [PATCH 5/9] docs(openspec): link export-knowledge-prompts to OSS-20 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- openspec/changes/export-knowledge-prompts/proposal.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md index 46ca3f17..7b099488 100644 --- a/openspec/changes/export-knowledge-prompts/proposal.md +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -21,3 +21,5 @@ The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, ` - **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/index.ts` holding the embedded `help/*.txt` map, the render path, and a typed accessor; `commands/help.ts` refactored to consume that shared module (no observable behavior change); the Vite build gains a second entry. - **Consumers**: `@taskless/cli/prompts` becomes importable — the enabler for the `taskless/taskless` `generator-decision-router` change. - **No change** to CLI commands, rule execution, or on-disk formats. + +**Tracking:** OSS-20 From cc39f5a60cda69077263bdddffd68090f4ae8d9d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 29 Jul 2026 12:02:58 -0700 Subject: [PATCH 6/9] docs(openspec): declare export-knowledge-prompts as a forward stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two units, each independently safe: a pure refactor that must leave help output byte-identical, then the new export surface nothing depends on yet. Splitting them keeps the risky part — touching a shipped command — reviewed apart from the new public API. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- openspec/changes/export-knowledge-prompts/proposal.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md index 7b099488..69478ed3 100644 --- a/openspec/changes/export-knowledge-prompts/proposal.md +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -22,4 +22,14 @@ The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, ` - **Consumers**: `@taskless/cli/prompts` becomes importable — the enabler for the `taskless/taskless` `generator-decision-router` change. - **No change** to CLI commands, rule execution, or on-disk formats. +## Delivery shape + +**Stacked, merging forward.** Each unit is independently safe: the first changes no observable behavior, and the second only adds a new export. + +| Unit | Scope | Safe alone because | +| ---- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| 1 | Move the glob and `renderRecipe` into `src/prompts/`, have `commands/help.ts` consume them | Pure refactor — `help` output must be byte-identical, which is the unit's own test | +| 2 | The `./prompts` subpath export, Vite entry, `TOPICS`/`PromptOptions` API, completeness check | Adds a new public surface; nothing existing depends on it yet | + +Unit 1 must not change `help` output at all, so a difference there is a regression rather than a judgement call. Splitting this way also means the risky part (touching a shipped command) is reviewed apart from the new API surface. **Tracking:** OSS-20 From 2dc4c1b2026ee752948728c9bce8634c6045a6b9 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 1 Aug 2026 08:17:59 -0700 Subject: [PATCH 7/9] docs(openspec): record export-knowledge-prompts as a minor release Every one of these PRs carried skip-changeset while it was spec-only, which becomes wrong the moment implementation lands. Stating the impact in the proposal means the tip PR needs a changeset written, not a label kept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q --- openspec/changes/export-knowledge-prompts/proposal.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md index 69478ed3..9290a20f 100644 --- a/openspec/changes/export-knowledge-prompts/proposal.md +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -24,6 +24,8 @@ The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, ` ## Delivery shape +**Release impact: minor.** Adds a new public subpath export (`@taskless/cli/prompts`). Purely additive — no existing surface changes — but a new entry point that consumers can depend on is a feature, not a fix. + **Stacked, merging forward.** Each unit is independently safe: the first changes no observable behavior, and the second only adds a new export. | Unit | Scope | Safe alone because | From 33e3540b2b26bc7e4a4bcd7ad90ba2faeeaa3cab Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 16:40:00 -0700 Subject: [PATCH 8/9] ref(cli): move the recipe glob and renderer into src/prompts `commands/help.ts` owned the `import.meta.glob` over `help/*.txt`, the canonical/anonymous map build, the `TOPIC_INPUT_SCHEMAS` table, and `renderRecipe`. That put the recipe source and its interpolation behind a citty command, so nothing else could reach the text without duplicating it. Move all of it to `src/prompts/recipes.ts` and have `help` and `onboard` call `getRecipe`. The module carries no CLI runtime: embedded text, `sprintf-js`, `applyCliInvocation`, and the two leaf Zod input schemas. Pure refactor. `help` output is byte-identical, verified across 38 captures covering all 18 canonical topics, their `--anonymous` variants, the topic index, and the unknown-topic error path, with a zero-byte diff before and after. Unit 1 of 2 for export-knowledge-prompts. Co-Authored-By: Claude Opus 5 (1M context) --- .../changes/export-knowledge-prompts/tasks.md | 12 +- packages/cli/src/commands/help.ts | 105 +---------- packages/cli/src/commands/onboard.ts | 3 +- packages/cli/src/prompts/recipes.ts | 166 ++++++++++++++++++ 4 files changed, 180 insertions(+), 106 deletions(-) create mode 100644 packages/cli/src/prompts/recipes.ts diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/export-knowledge-prompts/tasks.md index cd566ff1..27ea8fd3 100644 --- a/openspec/changes/export-knowledge-prompts/tasks.md +++ b/openspec/changes/export-knowledge-prompts/tasks.md @@ -1,11 +1,11 @@ ## 1. Shared prompts module -- [ ] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps -- [ ] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path -- [ ] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous -- [ ] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does -- [ ] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only -- [ ] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical +- [x] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps +- [x] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path +- [x] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous +- [x] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does +- [x] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only +- [x] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical ## 2. Package export + build diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/help.ts index b76523b6..1156f244 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/help.ts @@ -6,50 +6,9 @@ import { type Resolvable, type SubCommandsDef, } from "citty"; -import { sprintf } from "sprintf-js"; -import { z } from "zod"; import { getTelemetry } from "../telemetry"; -import { applyCliInvocation } from "../util/invocation"; -import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create"; -import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve"; - -// Help text files embedded at build time via Vite import.meta.glob. -// Filename convention: .txt for the canonical recipe and -// .anonymous.txt for the local-only variant (when the flow -// genuinely differs). -const helpFiles: Record = import.meta.glob("../help/*.txt", { - query: "?raw", - import: "default", - eager: true, -}); - -// Build two lookup maps: -// - helpMap: "rule-create" → canonical recipe text -// - anonymousMap: "rule-create" → anonymous variant text (if exists) -function buildHelpMaps(): { - helpMap: Map; - anonymousMap: Map; -} { - const helpMap = new Map(); - const anonymousMap = new Map(); - for (const [path, content] of Object.entries(helpFiles)) { - const filename = path - .split("/") - .pop() - ?.replace(/\.txt$/, ""); - if (!filename) continue; - if (filename.endsWith(".anonymous")) { - const topic = filename.slice(0, -".anonymous".length); - anonymousMap.set(topic, content); - } else { - helpMap.set(filename, content); - } - } - return { helpMap, anonymousMap }; -} - -const { helpMap, anonymousMap } = buildHelpMaps(); +import { getRecipe } from "../prompts/recipes"; // Help-only recipe topics (no backing subcommand) that should still be // discoverable from the `taskless help` index. The rule-authoring front @@ -61,56 +20,6 @@ const RECIPE_TOPICS: ReadonlyArray<[string, string]> = [ ["remote", "Generate a rule via the Taskless service (login)"], ]; -// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s -// placeholder, the help command substitutes the JSON Schema rendered -// from this Zod source. -const TOPIC_INPUT_SCHEMAS: Record = { - "rule-create": ruleCreateInputSchema, - "rule-improve": ruleImproveInputSchema, -}; - -/** - * Render a recipe by interpolating sprintf-js named arguments. The recipe - * source uses `%(KEY)s` placeholders; the variable table built here resolves - * each known placeholder to its rendered string. Recipes that contain a - * literal `%` character must escape it as `%%` per sprintf-js conventions. - * - * Two flavors of substitution coexist in the variables table: - * - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value. - * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as - * `` so the consuming agent knows to substitute. - */ -function renderRecipe(content: string, topic: string): string { - const variables: Record = { - CLI_VERSION: __VERSION__, - PACKAGE_MANAGER_DLX: "", - }; - if (content.includes("%(INPUT_SCHEMA)s")) { - const schema = TOPIC_INPUT_SCHEMAS[topic]; - variables.INPUT_SCHEMA = schema - ? JSON.stringify(z.toJSONSchema(schema), null, 2) - : "(no input schema for this topic)"; - } - return sprintf(applyCliInvocation(content), variables); -} - -/** - * Look up a help topic from the embedded recipe map and return the rendered - * text. Anonymous variants are preferred when `anonymous` is set and a - * variant exists; otherwise the canonical recipe is returned. Returns - * `undefined` when the topic is unknown. - */ -export function getRecipe( - topic: string, - options: { anonymous?: boolean } = {} -): string | undefined { - const content = options.anonymous - ? (anonymousMap.get(topic) ?? helpMap.get(topic)) - : helpMap.get(topic); - if (content === undefined) return undefined; - return renderRecipe(content, topic); -} - async function unwrap(resolvable: Resolvable): Promise { if (typeof resolvable === "function") { return (resolvable as () => T | Promise)(); @@ -213,16 +122,16 @@ export function createHelpCommand(subCommands: SubCommandsDef) { const key = positionals.join("-"); // Anonymous variant lookup: prefer .anonymous.txt when - // --anonymous is set, fall back to the canonical recipe. - const content = args.anonymous - ? (anonymousMap.get(key) ?? helpMap.get(key)) - : helpMap.get(key); + // --anonymous is set, fall back to the canonical recipe. The lookup and + // the render both live in the shared prompts module, so `help` and the + // `@taskless/cli/prompts` export emit the same text. + const recipe = getRecipe(key, { anonymous: args.anonymous }); - if (content) { + if (recipe) { // cli_help: agent fetched a specific recipe (intent signal). The topic // is the served topic; filtering on it replaces the old per-topic events. telemetry.capture("cli_help", { topic: positionals.join(" ") }); - console.log(renderRecipe(content, key).trimEnd()); + console.log(recipe.trimEnd()); } else { // cli_help for an unknown topic — still the attempted topic string. telemetry.capture("cli_help", { topic: positionals.join(" ") }); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index c46b3f37..14945c7b 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -4,11 +4,10 @@ import { defineCommand } from "citty"; import { ensureTasklessDirectory } from "../filesystem/directory"; import { readManifest, writeManifest } from "../filesystem/migrate"; +import { getRecipe } from "../prompts/recipes"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; -import { getRecipe } from "./help"; - /** * One-line trailer printed by `taskless init` (and the wizard) after a * successful install. Lives here so the install paths share the same diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts new file mode 100644 index 00000000..423e8417 --- /dev/null +++ b/packages/cli/src/prompts/recipes.ts @@ -0,0 +1,166 @@ +import { sprintf } from "sprintf-js"; +import { z } from "zod"; + +import { applyCliInvocation } from "../util/invocation"; +import { inputSchema as ruleCreateInputSchema } from "../schemas/rules-create"; +import { inputSchema as ruleImproveInputSchema } from "../schemas/rules-improve"; + +// Help text files embedded at build time via Vite import.meta.glob. +// Filename convention: .txt for the canonical recipe and +// .anonymous.txt for the local-only variant (when the flow +// genuinely differs). +// +// This module is the single embed and the single render path for the +// recipes. Both the `help` command and the `@taskless/cli/prompts` +// export consume it, so the two surfaces cannot drift. It must stay +// free of the CLI runtime — no citty, telemetry, filesystem, or +// network — so a Worker can import the prompts entry without pulling +// the command tree in behind it. +const helpFiles: Record = import.meta.glob("../help/*.txt", { + query: "?raw", + import: "default", + eager: true, +}); + +// Build two lookup maps: +// - helpMap: "rule-create" → canonical recipe text +// - anonymousMap: "rule-create" → anonymous variant text (if exists) +function buildHelpMaps(): { + helpMap: Map; + anonymousMap: Map; +} { + const helpMap = new Map(); + const anonymousMap = new Map(); + for (const [path, content] of Object.entries(helpFiles)) { + const filename = path + .split("/") + .pop() + ?.replace(/\.txt$/, ""); + if (!filename) continue; + if (filename.endsWith(".anonymous")) { + const topic = filename.slice(0, -".anonymous".length); + anonymousMap.set(topic, content); + } else { + helpMap.set(filename, content); + } + } + return { helpMap, anonymousMap }; +} + +const { helpMap, anonymousMap } = buildHelpMaps(); + +/** The canonical `.txt` recipe names present in the build. */ +export function canonicalRecipeTopics(): string[] { + return [...helpMap.keys()]; +} + +// Topic → Zod input schema. When a recipe contains the %(INPUT_SCHEMA)s +// placeholder, the renderer substitutes the JSON Schema rendered from +// this Zod source. +const TOPIC_INPUT_SCHEMAS: Record = { + "rule-create": ruleCreateInputSchema, + "rule-improve": ruleImproveInputSchema, +}; + +/** Agent-fill marker used when the caller does not supply a real value. */ +const PACKAGE_MANAGER_DLX_MARKER = ""; + +/** Options accepted by the shared render path. */ +export interface RecipeOptions { + /** + * Select the `.anonymous` variant of the topic, falling back to the + * canonical recipe when the topic has no variant. + * + * @default false + */ + anonymous?: boolean; + /** + * Value substituted for the `%(PACKAGE_MANAGER_DLX)s` placeholder. The + * default is an agent-fill marker, which is the right answer whenever + * the caller does not know the consuming repo's package manager. + * + * @default "" + */ + packageManagerDlx?: string; + /** + * Include the `# Topic: (CLI v / topic vN)` first line. + * Suppressing it drops the CLI version from the text, which matters to + * an LLM consumer whose prompt-cache key would otherwise churn on every + * CLI publish. + * + * @default true + */ + header?: boolean; +} + +/** + * Render a recipe by interpolating sprintf-js named arguments. The recipe + * source uses `%(KEY)s` placeholders; the variable table built here resolves + * each known placeholder to its rendered string. Recipes that contain a + * literal `%` character must escape it as `%%` per sprintf-js conventions. + * + * Two flavors of substitution coexist in the variables table: + * - System-resolved values (e.g. `CLI_VERSION`) — rendered to a real value. + * - Agent-fill markers (e.g. `PACKAGE_MANAGER_DLX`) — rendered as + * `` so the consuming agent knows to substitute. + */ +function renderRecipe( + content: string, + topic: string, + options: RecipeOptions = {} +): string { + const variables: Record = { + CLI_VERSION: __VERSION__, + PACKAGE_MANAGER_DLX: + options.packageManagerDlx ?? PACKAGE_MANAGER_DLX_MARKER, + }; + if (content.includes("%(INPUT_SCHEMA)s")) { + const schema = TOPIC_INPUT_SCHEMAS[topic]; + variables.INPUT_SCHEMA = schema + ? JSON.stringify(z.toJSONSchema(schema), null, 2) + : "(no input schema for this topic)"; + } + const rendered = sprintf(applyCliInvocation(content), variables); + return options.header === false ? stripHeader(rendered) : rendered; +} + +/** Every recipe opens with this marker on its first line. */ +const HEADER_PREFIX = "# Topic:"; + +/** + * Drop the leading header block from rendered recipe text: the `# Topic: …` + * line itself plus the single blank line that separates it from the body. + * Everything after that is returned untouched, so the body of a header-less + * rendering is byte-identical to the default rendering's body. + * + * Deliberately anchored to the first line only. A `# Topic:` string later in + * a recipe (inside a fenced example, say) is left alone, and a recipe that + * somehow lacks the header is returned unchanged rather than losing its + * first real line. + */ +function stripHeader(content: string): string { + const firstBreak = content.indexOf("\n"); + if (firstBreak === -1) { + return content.startsWith(HEADER_PREFIX) ? "" : content; + } + if (!content.startsWith(HEADER_PREFIX)) return content; + const body = content.slice(firstBreak + 1); + return body.startsWith("\n") ? body.slice(1) : body; +} + +/** + * Look up a help topic from the embedded recipe map and return the rendered + * text. Anonymous variants are preferred when `anonymous` is set and a + * variant exists; otherwise the canonical recipe is returned. Returns + * `undefined` when the topic is unknown. + */ +export function getRecipe( + topic: string, + options: RecipeOptions = {} +): string | undefined { + const content = options.anonymous + ? (anonymousMap.get(topic) ?? helpMap.get(topic)) + : helpMap.get(topic); + if (content === undefined) return undefined; + return renderRecipe(content, topic, options); +} From 1d756815b3394373bf1650b32c02449d6bbc5b4c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 6 Aug 2026 17:40:32 -0700 Subject: [PATCH 9/9] docs(openspec): point the change docs at the module layout that shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proposal, design D1/D2, and task 1.1 all named `src/prompts/index.ts` as the module holding the embed and the renderer. What landed splits in two: `recipes.ts` owns the embed and the render path, `index.ts` is the public entry stacked on top of it. Tasks 1.3 and 1.4 were also checked off here, but the typed API and the INTERNAL_TOPICS classification are unit 2's work in `index.ts` — nothing in this PR implements them. Uncheck them so the tracking matches the diff. Co-Authored-By: Claude Opus 5 (1M context) --- openspec/changes/export-knowledge-prompts/design.md | 4 ++-- openspec/changes/export-knowledge-prompts/proposal.md | 2 +- openspec/changes/export-knowledge-prompts/tasks.md | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/openspec/changes/export-knowledge-prompts/design.md b/openspec/changes/export-knowledge-prompts/design.md index 060c1957..59098fa3 100644 --- a/openspec/changes/export-knowledge-prompts/design.md +++ b/openspec/changes/export-knowledge-prompts/design.md @@ -14,14 +14,14 @@ The package ships only `dist` and exports only `"." → ./dist/index.js` (which ### D1 — A shared prompts module is the single source; `help.ts` consumes it -Move the glob + `buildHelpMaps` logic **and `renderRecipe`** (`commands/help.ts:83-95`) into `src/prompts/index.ts`; `help.ts` imports from it. One embed and one render path, no duplication. +Move the glob + `buildHelpMaps` logic **and `renderRecipe`** (`commands/help.ts:83-95`) into `src/prompts/recipes.ts`; `help.ts` imports from it. One embed and one render path, no duplication. `src/prompts/index.ts` sits on top of that module as the public entry, so the shared implementation and the published surface are separately reviewable. - **Alternative — a second glob in `help.ts` and the export:** rejected; two embeds drift. - **Alternative — share the embed but not the renderer:** rejected; the two surfaces would emit different text from identical source, which is the drift the change exists to prevent. ### D2 — The export carries no CLI runtime, imported via a subpath -`src/prompts/index.ts` imports nothing from the CLI runtime — no `citty`/command tree, no telemetry, no filesystem or network. It may import `sprintf-js` and the two Zod input schemas (`schemas/rules-create`, `schemas/rules-improve`), which are leaf modules whose only dependency is `zod`; both are already dependencies of the intended consumer. The subpath export `@taskless/cli/prompts` maps to a dedicated `dist/prompts.js` so importing it never loads `dist/index.js`. +The `src/prompts/` graph (`index.ts` and the `recipes.ts` it consumes) imports nothing from the CLI runtime — no `citty`/command tree, no telemetry, no filesystem or network. It may import `sprintf-js` and the two Zod input schemas (`schemas/rules-create`, `schemas/rules-improve`), which are leaf modules whose only dependency is `zod`; both are already dependencies of the intended consumer. The subpath export `@taskless/cli/prompts` maps to a dedicated `dist/prompts.js` so importing it never loads `dist/index.js`. - **Alternative — re-export from the main entry (`@taskless/cli`):** rejected; the main entry pulls the whole CLI, unusable/heavy in a Worker. - **Alternative — pure data with zero deps, pre-rendering `INPUT_SCHEMA` at build time:** rejected as premature; it buys nothing for the intended consumer (which already ships `zod`) and adds a codegen step to keep in sync. diff --git a/openspec/changes/export-knowledge-prompts/proposal.md b/openspec/changes/export-knowledge-prompts/proposal.md index 9290a20f..374b7631 100644 --- a/openspec/changes/export-knowledge-prompts/proposal.md +++ b/openspec/changes/export-knowledge-prompts/proposal.md @@ -18,7 +18,7 @@ The CLI's **knowledge prompts** — `help/*.txt` (`route`, `static`, `remote`, ` ## Impact -- **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/index.ts` holding the embedded `help/*.txt` map, the render path, and a typed accessor; `commands/help.ts` refactored to consume that shared module (no observable behavior change); the Vite build gains a second entry. +- **`packages/cli`**: `package.json` `exports` (add `./prompts`) and `files`; a new `src/prompts/` module — `recipes.ts` holding the embedded `help/*.txt` map and the render path, `index.ts` holding the typed accessor published as the subpath entry; `commands/help.ts` refactored to consume that shared module (no observable behavior change); the Vite build gains a second entry. - **Consumers**: `@taskless/cli/prompts` becomes importable — the enabler for the `taskless/taskless` `generator-decision-router` change. - **No change** to CLI commands, rule execution, or on-disk formats. diff --git a/openspec/changes/export-knowledge-prompts/tasks.md b/openspec/changes/export-knowledge-prompts/tasks.md index 27ea8fd3..f8a9e67d 100644 --- a/openspec/changes/export-knowledge-prompts/tasks.md +++ b/openspec/changes/export-knowledge-prompts/tasks.md @@ -1,9 +1,9 @@ ## 1. Shared prompts module -- [x] 1.1 Create `packages/cli/src/prompts/index.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps +- [x] 1.1 Create `packages/cli/src/prompts/recipes.ts` that embeds `../help/*.txt` via the same `import.meta.glob(..., { query: "?raw", eager: true })` and builds canonical + anonymous maps - [x] 1.2 Move `renderRecipe` + the `TOPIC_INPUT_SCHEMAS` table out of `commands/help.ts` into the shared module, so interpolation lives on the shared path -- [x] 1.3 Export the typed API: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous -- [x] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does +- [ ] 1.3 Export the typed API from `packages/cli/src/prompts/index.ts`: `PromptTopic` union (from an explicit `const TOPICS = [...] as const`), `PromptOptions` (`anonymous?`, `packageManagerDlx?`, `header?`), `PROMPTS: Record string>` of render functions, and `getPrompt(topic, options?)` with canonical fallback for anonymous +- [ ] 1.4 Add an `INTERNAL_TOPICS` list recording recipe files deliberately withheld from the export; classify every existing `help/*.txt` as exported or internal. Per D6, `TOPICS` starts minimal — `static` is the only topic a consumer has asked for; `route`/`remote`/`detect`/`existing`/`rule-meta` are internal until one does - [x] 1.5 Ensure the module imports nothing from the CLI runtime (no `citty`/telemetry/command tree/fs/network) — embedded text, types, `sprintf-js`, `applyCliInvocation`, and the leaf Zod input schemas only - [x] 1.6 Refactor `commands/help.ts` to consume the shared module (remove its own glob/`buildHelpMaps`/`renderRecipe`), leaving `help` output byte-identical