diff --git a/.changeset/vale-rule-engine.md b/.changeset/vale-rule-engine.md index 11f1e297..77daab75 100644 --- a/.changeset/vale-rule-engine.md +++ b/.changeset/vale-rule-engine.md @@ -2,16 +2,61 @@ "@taskless/cli": minor --- -Add Vale as a second static-tier rule engine. - -`check` now dispatches by engine directory and runs ast-grep, Vale, and runtime -rules concurrently, merging their findings into one result set. Vale rules live -in `.taskless/vale/` and execute against the committed `.vale.ini`; an -unavailable Vale reports itself and the other engines still return, while a Vale -that times out or rejects its config fails the check rather than passing as a -clean run. Vale rules are verified from `rule-tests//pass|fail` fixtures -against a generated per-rule config. - -Adds the `engine-selection` knowledge topic — which engine enforces a given -rule, and why — available from `taskless help engine-selection` and exported -through `@taskless/cli/prompts`. +Add Vale as a second static-tier rule engine, give every engine one rule layout, and rename the agent-facing command. + +`check` now dispatches by engine and runs ast-grep, Vale, and runtime rules +concurrently, merging their findings into one result set. An unavailable Vale +reports itself and the other engines still return. A Vale that times out or +rejects its config fails the check rather than passing as a clean run. + +**Every rule is now one directory**, `.taskless/rules///`, holding +the rule, any per-engine config, and its tests in `.tests/`. Writing a rule +means creating a directory and deleting one means `rm -rf`. Nothing outside it +is touched either way, so concurrent authors never collide on a shared file. + +Vale rules carry their own `.vale.ini` declaring which files they apply to. +The single config Vale reads is assembled from those per-rule files on each +run, gitignored, and regenerated, so hand edits to it have no effect. ast-grep +keeps its `files`/`ignores` inside the rule and needs no second file. + +**`rule verify` is replaced by two path-addressed commands.** `verify ` +checks that a rule has the components its engine requires and needs no tests, +so it works while you're still authoring. `test ` runs the rule's tests, +after running `verify` and stopping if that fails. Both take a rule directory, +an engine directory, or nothing at all for the whole project, and both report +one result per rule. Addressing by path rather than id removes the ambiguity +that arose when two engines held the same rule id. + +Projects on an older layout migrate automatically on the next command. + +**BREAKING: `taskless help ` is now `taskless agent `.** The +command is named for who reads it. Agents fetching a procedure are not asking +for help, and the old name is gone rather than aliased. + +**BREAKING: topics are addressed by a single token.** `taskless help rule +create` becomes `taskless agent create-sg-rule`; multiple positionals are no +longer joined into a topic key. A topic name is now a literal string an agent +copies rather than a phrase it can reorder. The renames: + +| Was | Now | +| ------------------ | --------------------------------------- | +| `rule create` | `create-sg-rule` / `create-remote-rule` | +| `rule improve` | `improve-rule` | +| `rule delete` | `delete-rule` | +| `rule verify` | `verify-rule` | +| `rule meta` | `rule-meta` | +| `static` | `create-sg-rule` | +| `existing` | `create-legacy-rule` | +| `engine-selection` | `route` | + +`route` now applies the engine reasoning itself and names a concrete +`create-*-rule` topic, so `engine-selection` is removed rather than renamed — +its criterion is stated once, in `route`. Every authoring recipe is rewritten +for the rule-directory layout. + +**BREAKING for `@taskless/cli/prompts` consumers.** `engine-selection` is no +longer exported. `TOPICS` is now `create-sg-rule`, `create-vale-rule`, and +`create-runtime-rule`, so a consumer that decides an engine can reach the +procedure for each destination. Because the export is a string union, a +consumer passing the removed name dynamically breaks on upgrade rather than at +build time. diff --git a/.prettierignore b/.prettierignore index 76b675bf..c2a0f1df 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,6 @@ __generated__ # Worktrees are second checkouts; formatting them would touch other branches worktrees/ + +# The demo project: deliberately-wrong source and prose fixtures. +example/ diff --git a/README.md b/README.md index 26c6748d..56c5dc18 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Starting in v0.7, Taskless ships a **single consolidated skill** (`taskless`) pl | | | delete, check, auth, CI). Fetches the canonical recipe | | | | for the user's intent and follows it. | -Available `taskless help` topics: `rule create`, `rule improve`, `rule delete`, `check`, `auth`, `ci`, `info`, `init`, `update`. Append `--anonymous` for the local-only flow on rule create/improve. +Available `taskless agent` topics: `route`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, `create-remote-rule`, `improve-rule`, `delete-rule`, `check`, `auth`, `ci`, `info`, `init`, `update`. Run `taskless agent` with no topic for the index. Append `--anonymous` for the local-only flow on improve. ## CLI diff --git a/eslint.config.js b/eslint.config.js index f20473a3..113646db 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,11 @@ export default tseslint.config( // Zero-dependency CommonJS workflow scripts (covered by their own // node:test suite); the app's TS/ESM-oriented rules don't apply. ".github/scripts/", + // The demo project. Its source is deliberately wrong — `example.cjs` + // calls `eval` so a rule has something to find — and its fixtures are + // prose written to be flagged. Linting it fails on content nobody wrote + // as source. `example-project.test.ts` is what keeps it honest. + "example/", ], }, eslint.configs.recommended, diff --git a/example/.taskless/.gitignore b/example/.taskless/.gitignore new file mode 100644 index 00000000..7ceacc38 --- /dev/null +++ b/example/.taskless/.gitignore @@ -0,0 +1,2 @@ +/.vale.ini +/.sgconfig.yml diff --git a/example/.taskless/rules/sg/no-eval/.tests/no-eval-20260814-test.yml b/example/.taskless/rules/sg/no-eval/.tests/no-eval-20260814-test.yml new file mode 100644 index 00000000..88566f2c --- /dev/null +++ b/example/.taskless/rules/sg/no-eval/.tests/no-eval-20260814-test.yml @@ -0,0 +1,7 @@ +id: no-eval +valid: + - "JSON.parse(raw)" + - "const evaluate = () => 1" +invalid: + - "eval(raw)" + - 'eval("(" + raw + ")")' diff --git a/example/.taskless/rules/sg/no-eval/no-eval.yml b/example/.taskless/rules/sg/no-eval/no-eval.yml new file mode 100644 index 00000000..dc546a9a --- /dev/null +++ b/example/.taskless/rules/sg/no-eval/no-eval.yml @@ -0,0 +1,9 @@ +id: no-eval +language: JavaScript +severity: error +message: Avoid eval. It executes whatever string it's handed. +note: | + `eval` runs arbitrary code with the caller's permissions. Parse the value + instead: `JSON.parse` for JSON, a real parser for anything else. +rule: + pattern: eval($$$ARGS) diff --git a/example/.taskless/rules/vale/no-simply/.tests/fail/hedged.md b/example/.taskless/rules/vale/no-simply/.tests/fail/hedged.md new file mode 100644 index 00000000..79b0d5df --- /dev/null +++ b/example/.taskless/rules/vale/no-simply/.tests/fail/hedged.md @@ -0,0 +1,3 @@ +You can simply drop a rule in. + +Just run the check. diff --git a/example/.taskless/rules/vale/no-simply/.tests/pass/direct.md b/example/.taskless/rules/vale/no-simply/.tests/pass/direct.md new file mode 100644 index 00000000..a9245121 --- /dev/null +++ b/example/.taskless/rules/vale/no-simply/.tests/pass/direct.md @@ -0,0 +1,3 @@ +Drop a rule in, then run the check. + +The adjustment took three releases. diff --git a/example/.taskless/rules/vale/no-simply/.vale.ini b/example/.taskless/rules/vale/no-simply/.vale.ini new file mode 100644 index 00000000..a123e1f2 --- /dev/null +++ b/example/.taskless/rules/vale/no-simply/.vale.ini @@ -0,0 +1,6 @@ +# Which files this rule applies to. Adding a rule edits nothing outside this +# directory. That's the point of the layout. +[*.{html,md}] +tskl) rule = no-simply +BasedOnStyles = +no-simply.no-simply = YES diff --git a/example/.taskless/rules/vale/no-simply/no-simply.yml b/example/.taskless/rules/vale/no-simply/no-simply.yml new file mode 100644 index 00000000..8b46bbc7 --- /dev/null +++ b/example/.taskless/rules/vale/no-simply/no-simply.yml @@ -0,0 +1,7 @@ +extends: existence +message: "Avoid '%s'. It tells the reader the work was easy." +level: warning +ignorecase: true +tokens: + - simply + - just diff --git a/example/.taskless/taskless.json b/example/.taskless/taskless.json new file mode 100644 index 00000000..ebf106f0 --- /dev/null +++ b/example/.taskless/taskless.json @@ -0,0 +1,3 @@ +{ + "version": 5 +} diff --git a/example/README.md b/example/README.md new file mode 100644 index 00000000..ffd48460 --- /dev/null +++ b/example/README.md @@ -0,0 +1,106 @@ +# A Taskless install, as it actually looks + +This is a small project with Taskless rules in it. Everything here is real: the +same layout you get after installing, so you can read it before you commit to +anything. + +Two rules, one per engine. + +## The files + +| Path | What it is | +| -------------- | ------------------------------------------------------- | +| `example.cjs` | A CommonJS module that calls `eval` on file contents | +| `example.html` | A page with a Title Case heading and some hedging prose | +| `.taskless/` | The rules. No build output, no cached state. | + +## What a rule looks like + +A rule is **one directory**. It holds everything that defines it. Adding a rule +means adding a directory. Removing one means removing that directory. No shared +file gets edited either way. + +``` +.taskless/rules/ + sg/no-eval/ + no-eval.yml the rule + .tests/no-eval-20260814-test.yml its test cases + vale/no-simply/ + no-simply.yml the rule + .vale.ini which files it applies to + .tests/fail/hedged.md prose it must flag + .tests/pass/direct.md prose it must leave alone +``` + +Two details in there need explaining. + +**`.tests/` is dot-prefixed on purpose.** ast-grep discovers rules by walking +the rules tree, and it reads every `.yml` it finds as a rule. A plain `tests/` +directory would make it parse the test files as rules and fail the whole scan. +A dot-directory gets skipped by that walk. The test runner still finds it. + +**Only Vale has a per-rule `.vale.ini`.** Vale can't express "which files does +this apply to" inside the rule file, because it rejects unknown keys. Scope +needs somewhere else to live. ast-grep puts its equivalent (`files`, `ignores`) +inside the rule, so an `sg` rule gets no second file. + +You won't find a project-wide `.vale.ini` or `sgconfig.yml` here. Both get +assembled from the per-rule configs when a check runs, and both are gitignored. +They're build output. + +## What `check` reports + +``` +$ npx @taskless/cli check + + example.cjs:7:10 + error[no-eval] Avoid eval. It executes whatever string it's handed. + > eval("(" + raw + ")") + note: `eval` runs arbitrary code with the caller's permissions. Parse the value +instead: `JSON.parse` for JSON, a real parser for anything else. + + + example.html:7:11 + warning[no-simply] Avoid 'simply'. It tells the reader the work was easy. + > simply + +2 issues (1 error, 1 warning) across 2 files +``` + +One finding from each engine, merged into one report. The exit code follows +severity, so this run exits 1 on the `error`. + +## Running it yourself, in this repo + +`npx @taskless/cli` fetches the published CLI. To run the one in this +checkout, build it first and then call it from here: + +``` +pnpm --filter @taskless/cli build # from the repo root +cd example +../packages/cli/dist/index.js check +``` + +Substitute that path for `npx @taskless/cli` in every command below. +Note that `check` writes the two assembled configs into `.taskless/`, so +expect them to appear after the first run. Both are gitignored. + +## Checking the rules themselves + +`check` runs rules against your code. Two other commands run against the rules: + +``` +$ npx @taskless/cli verify # are these rules well-formed? +$ npx @taskless/cli test # do they fire where they should, and only there? +``` + +Both take a path: a rule directory, an engine directory, or nothing at all for +everything. `test` runs `verify` first and stops if it fails. That way a broken +rule tells you what's broken. + +## This example is tested + +`packages/cli/test/example-project.test.ts` runs `check`, `verify`, and `test` +against this directory and asserts on what comes back. A demo that's drifted +from the layout it demonstrates is worse than no demo. If the layout changes +and this stops being true, the build fails. diff --git a/example/example.cjs b/example/example.cjs new file mode 100644 index 00000000..98ab4033 --- /dev/null +++ b/example/example.cjs @@ -0,0 +1,14 @@ +// A small CommonJS module with something the ast-grep rule has to say about. +const { readFileSync } = require("node:fs"); + +function loadConfig(path) { + const raw = readFileSync(path, "utf8"); + // `eval` on file contents is the pattern `no-eval` exists to catch. + return eval("(" + raw + ")"); +} + +function greet(name) { + return `Hello, ${name}`; +} + +module.exports = { loadConfig, greet }; diff --git a/example/example.html b/example/example.html new file mode 100644 index 00000000..86d9b44f --- /dev/null +++ b/example/example.html @@ -0,0 +1,11 @@ + +Taskless example + +

Getting Started With The Example

+ +

+ You can simply drop a rule into this project and run a check. The heading + above is Title Case, which the capitalization rule has an opinion about. +

+ +

Read the README for what each file is for.

diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/.openspec.yaml b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/.openspec.yaml new file mode 100644 index 00000000..b6b2d1f6 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/design.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/design.md new file mode 100644 index 00000000..9e37534f --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/design.md @@ -0,0 +1,147 @@ +## Context + +The CLI's `help/*.txt` recipes are the knowledge surface agents read. They are addressed longform (`positionals.join("-")` in `commands/help.ts`), exported in part through `@taskless/cli/prompts` for the platform generator, and cross-referenced from each other by literal command string — ~306 occurrences of `taskless help` across 77 files. + +`add-vale-rule-engine` added `engine-selection`, which decides between `sg`, `vale`, and `runtime`. Two of those three answers have no authoring procedure. That change recorded Vale authoring as an explicit non-goal, which was defensible when nothing chose Vale; the chooser makes it reachable. + +Separately, the scaffolded `.vale.ini` opens an unscoped `[*]`. A whole-project check under it lints build output and, until #100, `.taskless/` itself. + +## Goals / Non-Goals + +**Goals:** + +- Every answer `route` can produce leads to a procedure that exists. +- One fetch from request to destination, returning a command an agent can run verbatim. +- A topic vocabulary that reads as literal tokens rather than paraphrasable phrases. +- A scaffold that lints nothing until someone scopes it deliberately. + +**Non-Goals:** + +- **A `.vale.ini` writer.** The agent authors the section, exactly as it authors `sgconfig.yml` rule entries today. Construction moves to the downstream generator, consistent with `add-vale-rule-engine/design.md:107`. +- Rule _generation_ for Vale via the service. `create-remote-rule` dispatches to the service as it does today; this change gives the local paths destinations and renames the remote one. +- Renaming the `cli-help` capability file. The command renames; the spec keeps its name, so this change does not also move spec files (see D5). +- Restricting Vale's feature set, or deciding build-output exclusion (tracked separately in #101). + +## Decisions + +### D1 — `route` and `engine-selection` merge into one topic + +There is one decision, made once. `route` absorbs the engine reasoning and returns one of the five `create-*-rule` topics. `engine-selection` ceases to exist as a separate topic. + +This reverses the scoping `engine-selection` asserts today — that route decides destination, the topic decides engine, and locally the two compose. The separation is clean on paper and expensive in practice: it costs an agent two fetches and a correct handoff between them to answer one question, and the handoff is where an agent drops context. Worse, the two decisions are not independent in the direction the split assumes; "author this locally" and "which engine can express it" are answered from the same evidence, so splitting them means reading the same signals twice. + +**Where the reasoning goes for consumers outside the CLI.** The platform generator consumes `engine-selection` through `TOPICS`, so merging cannot simply delete what it reads. `route` is expected to be exported in a later change, at which point it carries the criterion to the service directly; it is not exported here because it still contains local mechanics (`taskless detect --json`, on-device authoring) that a Worker cannot run, and untangling those is its own piece of work. + +The criterion therefore lives **once, in `route`'s destination table**, which is where the comparison is actually made. Restating it in each destination would be the drift risk the merge was meant to remove, one level down. + +Each destination instead opens with a short orientation line naming what it is for and what to do if that is wrong — see D9. That is deliberately less than the full criterion: enough for a reader who arrived at the wrong recipe to notice and go back, not a second copy of the test. + +**What this costs the exported surface, and for how long.** A consumer reading only `create-vale-rule` gets its scope ("prose and markup") but not the boundary cases that settle hard calls — prose-about-code, per-document versus cross-document. Sufficient for picking between destinations; not for adjudicating a genuinely ambiguous rule. + +That gap closes when `route` is exported. The service will hold the route prompt, which states when each engine applies, and needs no escalation path of its own — it is the escalation. It can then supply its own runtime prompt for its own agentic flow. + +Worth being clear about why the prompts are exported at all, because it changes what "enough" means: the goal is **consistency between the local and remote paths**, not transferring a capability the service lacks. The service can classify without us. What it should not do is classify _differently_ — a rule routed to `vale` locally and to `sg` server-side is the same request answered two ways, and that is the failure the shared surface exists to prevent. + +_Alternative rejected:_ keep `engine-selection` as a third exported topic that `route` also applies. Two statements of the same criterion, guaranteed to drift, and it preserves the second fetch for exactly the consumer we were trying to simplify. + +_Alternative rejected:_ restate the full criterion in every destination. Five copies of one test, and the first edit to any of them is a divergence nobody notices. + +_Deferred, not rejected:_ export `route`. Its local mechanics need separating from its reasoning first, and doing that inside a change that already renames a command and five topics is how a rename becomes unreviewable. + +### D2 — Verb-noun names, single token, no aliases + +`create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, `create-legacy-rule`, `create-remote-rule`. Not everything needs the noun — `route` stays `route`. + +Hyphenated single tokens are the point rather than a side effect. A multi-word phrase invites an agent to paraphrase or reorder; a hyphenated token reads as a literal string to copy. This is the same reason the resolution stops joining positionals: with one token there is no order to get wrong. + +`static` → `create-sg-rule` also removes a leak. "Static" is a trust tier, and `engine-selection` is explicit that tier and engine are different axes; naming the ast-grep authoring topic after the tier taught the confusion the other topic exists to correct. + +_Alternative rejected:_ `static-sg` / `static-vale`. Preserves the tier leak and does not match what `route` decides. + +### D3 — Break `TOPICS`, no deprecation window + +`TOPICS` becomes `["create-sg-rule", "create-vale-rule", "create-runtime-rule"]`. `engine-selection` leaves the export because it stops existing (D1); the criterion it carried is now stated by the destinations themselves. + +The package is pre-1.0, so this ships **MINOR**. That is what the leading zero means, and it holds for every backwards-incompatible item in this change. An alias would have to be carried by the type union (`PromptTopic`), the `PROMPTS` map, and the disjointness test, and would be dead the moment the generator updates. + +The real exposure is not the rename but the **deploy skew**: the generator is a separate deploy consuming a published package, so it breaks on upgrade rather than at our build time. Mitigated by the changeset, not by code. + +_Alternative rejected:_ export both names for one release. Doubles the exported surface to protect a single known consumer that we control. + +### D4 — Section-less scaffold, and stderr notices as its precondition + +The scaffolded `.vale.ini` carries `StylesPath` and `MinAlertLevel` and no section. Measured: Vale runs clean and reports `{}`. + +This is only safe **with** stderr surfacing, and the two ship together. With no section to copy, the natural first edit is `rules. = YES` at top level, which Vale reports as `W101 '' isn't a core option; Vale is ignoring it` — on stderr, with exit 0 and valid `{}` on stdout. `runVale` reads stderr only on a non-zero exit, so today that diagnostic is discarded and the user gets: rule authored, `verify` passes, `check` silent. That is the exact failure the Vale work exists to eliminate, and shipping the scaffold change alone would reintroduce it one level up. + +_Alternative rejected:_ scaffold `[*.md]`. A user's first rule works immediately, but the default silently decides scope for them, and markdown is a guess about what a repo's prose is. + +### D5 — The `cli-help` capability keeps its name + +The command becomes `agent`; the spec file stays `openspec/specs/cli-help/spec.md`. + +Renaming a capability means moving a spec directory and rewriting every cross-reference to it in the same change that already renames a command and four topics. The capability's subject — the CLI's agent-knowledge surface — is unchanged; only its command name moves. Worth doing later on its own, and worth not doing here. + +## Risks / Trade-offs + +- **Generator breaks on CLI upgrade, not at build time** → `TOPICS` is consumed across a deploy boundary. The changeset must name the rename explicitly, and the generator's update is a coordinated follow-up rather than an assumption. +- **306 mechanical edits invite a missed one** → a stale `taskless help X` in a recipe is invisible until an agent runs it and gets nothing. Mitigated by an assertion that no shipped recipe contains the string `taskless help`, which is cheap and total. +- **Absorbing engine choice into `route` makes `route` longer** → it now carries the reasoning that justified a separate topic. If it grows past being readable in one pass, the split was load-bearing after all and should come back as a fetch. +- **A section-less scaffold means a fresh project's first Vale rule does nothing until scoped** → intended, and the reason `create-vale-rule` must teach the section rather than assume it. The stderr notice is what makes the failure legible instead of silent. +- **Telemetry vocabulary changes** → `cli_help` topic values change wholesale; anything keyed on `static` goes quiet rather than erroring. Worth naming before it is diagnosed as a traffic drop. + +## Migration Plan + +No user data or on-disk state migrates. Existing projects keep whatever `.vale.ini` they have — the scaffold change affects new projects only, and `0004` is unreleased, so no project has the old scaffold in the field. + +The rename is a hard cutover in one PR: recipes cross-reference each other by literal command, so a partial rename produces recipes pointing at commands that do not exist. + +### D6 — The logged-out gate is explained once, by `create-runtime-rule` + +"Remote" describes who generates rather than which engine, so the two are not peers in kind — but the place that matters is the same place `create-runtime-rule` has to speak anyway: the user is logged out. + +`create-runtime-rule` therefore owns the gated story — why executing code requires login, reconciliation, and signing, and how to get there. A logged-out user meets one topic explaining one gate, rather than being handed between a topic about remoteness and one about authentication. + +**What this leaves unresolved, deliberately.** `remote.txt` and `rule-create.txt` today serve a real and different flow: the service generating an _ast-grep_ rule when local authoring cannot. That is escalation, not a destination — `route` is already specified as biased local with the service as last resort — so it survives as a fallback inside `route` rather than as a peer of the four. Whether those two recipes keep their names, merge, or fold into `create-sg-rule`'s failure path is not settled here. + +_Alternative rejected:_ a fifth `create-remote-rule` destination. Puts a non-engine on an engine-shaped list, and splits the logged-out explanation across two topics. + +### D7 — Login state is read early; remote is offered only where it is a choice + +`route` reads login state near the top, before dispatching. It changes which destinations exist, so discovering it late means classifying against a set that may be wrong. + +It does **not** follow that the agent should open by asking "do you want remote generation?". At that point it does not know whether the rule is a two-line `sg` pattern or something local authoring cannot express, and neither does the user — the question costs a turn and cannot be answered well. Remote is offered when it is genuinely a choice: the rule is locally expressible **and** the user is logged in. Not logged in, or not locally expressible, are not choices and are not posed as one. + +This narrows the existing "biased to stay local" requirement rather than reversing it. The bias survives for the case it was written about — local authoring that _works_ is not abandoned for the service — while a logged-in user stops being steered away from a path they have already paid for. + +**No topic delegates to another.** A logged-in runtime request routes to `create-remote-rule` directly; `create-runtime-rule` is the logged-out path. Routing runtime through a topic that then forwards would reintroduce the second fetch D1 exists to remove, and would split the login explanation across two files. + +_Alternative rejected:_ offer remote unconditionally as step one. Reverses the local bias outright, and asks a question before the information exists to answer it. + +_Alternative rejected:_ `create-runtime-rule` checks login and forwards. Two fetches, and the reader meets the gate explanation only on one branch. + +### D8 — `remote` and `rule-create` merge into `create-remote-rule` + +`remote.txt` states the client-side boundary; `rule-create.txt` is the procedure that enriches a description, calls the API, and reports. Split across two topics, an agent fetches one to learn it needs the other. + +This is a content merge, not a rename: both texts have material that survives, and the result has to read as one procedure rather than two concatenated. + +_Alternative rejected:_ keep `remote` as a boundary statement and `create-remote-rule` as the procedure. Preserves the second fetch under new names. + +### D9 — Destinations orient, they do not re-decide + +Each `create-*-rule` recipe opens with a fixed-shape line: what topic the reader is in, what kinds of rule it helps write, and an instruction to revisit the routing decision if that is not what they need. + +Its job is self-correction, not classification. An agent that arrived at the wrong recipe — because it guessed, because a user named a topic directly, or because `route` was wrong — should discover that in the first line rather than after authoring the wrong artifact. Recovery is cheap there and expensive later. + +Keeping it to orientation is what stops it becoming a second criterion. The comparison between engines happens in one place; a destination only has to answer "am I the right place", which needs its own scope and nothing about the others. + +### D10 — `create-runtime-rule` defers to `auth` + +It explains why the runtime tier is gated — executing code requires reconciliation and signing — and points at `auth` for obtaining access, rather than restating the login procedure. + +An extra CLI turn is not a cost worth avoiding when each turn delivers something concrete: `auth` is maintained as the authority on login, and a copy inside a rule-authoring recipe is a copy that goes stale the first time login changes. + +## Open Questions + +- None outstanding. diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/iteration-log.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/iteration-log.md new file mode 100644 index 00000000..cfe1308b --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/iteration-log.md @@ -0,0 +1,319 @@ +# 2b iteration log — executing the authoring recipes + +Task 2b.7: the record of what failed, what changed, and what finally held. The recipes are +the deliverable, and a recipe that reads well to its author while producing the wrong +artifact is exactly what reviewing the prose cannot catch — so they get executed. + +Delete this file when the change is archived. + +## Harness + +- `pnpm --filter @taskless/cli build:dev`, so `__TASKLESS_CLI__` is an absolute path to + `dist-dev/index.js` and the rendered recipe carries a command that runs from anywhere. +- Sandbox is a real `init --no-interactive` scaffold, not a hand-made approximation. +- Each run gets a **fresh, non-forked** subagent handed exactly three things: the rendered + recipe, the sandbox path, and a rule intent in plain words. **No repository access** — + with it, the agent finds `no-simply.yml` and the mixed-engine fixture and copies them, + and the loop tests our fixtures rather than our writing. +- Intents chosen to exercise different extension points. Everything in this repo today is + `existence`, so a recipe drafted from our own examples would teach token blocklists and + nothing else. + +## Round 1 + +| Run | Intent | Extension point | Converged | +| --- | ------ | --------------- | --------- | +| A | flag hedging phrases in docs | `existence` | **yes, first try, zero retries** | +| B | "sign in" is the verb, "login" the noun | `substitution` | **yes, first try, zero retries** | +| C | it is `GitHub`, not `Github`/`github` | ended up `substitution` | **one retry** | + +### Run A — converged, and that is the problem + +The agent produced `no-hedging.yml`, added a scoped `[*.md]` section with +`BasedOnStyles =` and `rules.no-hedging = YES`, wrote both fixture buckets, and got six +findings on `fail/` and zero on `pass/` on the first execution. No `W101` notice, so the +assignment landed inside the section. + +That is the 2b.6 bar met for one extension point. It also means **the step-5 debug ladder +was never exercised**, and the agent's critique is mostly about the rungs that ladder is +missing. Taking a first-try pass as evidence the prose is finished would be reading the +result backwards. + +Findings, triaged. Verified against the source rather than taken on the agent's word: + +| # | Finding | Verdict | +| - | ------- | ------- | +| a | `--json` reports `success: true` and exit 0 on **both** buckets; only `results.length` distinguishes them. The recipe hands you `--json` and describes no field of it | **Real.** An agent checking `$?` or grepping `success` concludes a working rule failed, and starts debugging it | +| b | A whole-project `check` reports nothing from the fixtures, contradicting step 3's walk model and step 6's suggestion | **Real.** `run.ts:137` excludes `.taskless/**` from the whole-project walk (the #100 fix). Fixtures live under `.taskless/`, so they are invisible to a bare `check` — correct behavior, undocumented, and the recipe's own model mispredicts its own suggested command | +| g | Step 4 asserts the CLI rejects a nested fixture directory and requires both buckets — neither is reachable | **Real, and the worst of these.** That validation lives in `verifyValeRule`, which has **zero CLI callers**. The recipe states an invariant nothing enforces, so an author who omits `pass/` sails through step 5 and never learns | +| d | `MinAlertLevel` is absent from the debug ladder, though `MinAlertLevel = error` against a `level: warning` rule produces exactly the silent failure the ladder exists for, with every listed item checking out | **Real** | +| c | `tokens` are regexes; the recipe presents them as a word list and never mentions `raw` or `nonword`, or what happens to a phrase containing regex metacharacters | **Real.** Live for hedging phrases specifically ("maybe?") | +| i | `%s` count is a property of the extension point — one for `existence`, two for `substitution` — stated nowhere, so copying the field table into a substitution rule yields `%!s(MISSING)` | **Real, cheap to fix** | +| f | `StylesPath = .` is never said to resolve relative to the directory holding `.vale.ini`, and the recipe uses two path roots (`.taskless/vale/rules/…` vs `vale/rules/…`) without reconciling them | **Real** | +| h | Three names must agree — filename, `id`, fixture directory — and are explained as if they were one | **Real** | +| e | Step 5 gives a runnable `node … check` while step 6 and See Also give `taskless agent check`; the agent could not tell whether `agent check` is a different subcommand | **Real, and corpus-wide.** Every recipe uses the bare `taskless agent ` form for a fetch and `npx @taskless/cli ` for an invocation. Obvious to us, not to a first-time reader | +| j | Fenced code blocks inside markdown: does a rule fire inside them? Undefined here, and live for a docs rule | **Real, needs measuring** before writing an answer | +| k | Step 1 sends the reader to `docs.vale.sh` with no offline fallback for the eight extension points it declines to describe | Accepted. Embedding the full Vale reference is out of scope | + +Nothing in this list is a defect in the agent. Every one is a defect in the prose, which +is what 2b.6 says to treat them as. + +### Run B — converged, and independently confirms A's three worst findings + +The agent produced `login-as-verb.yml` using regex `swap` keys, scoped it to `[*.md]`, +wrote both buckets, and got four findings on `fail/` and zero on `pass/` first try. It +also volunteered an extra probe — appending a sentence to `pass/` to test a false positive +it suspected in the `to login` key — and re-ran the bucket. That is the behavior the +recipe wants and does not currently ask for. + +Two runs, no shared context, and both independently reported **a**, **g**, and the +fetch-versus-invoke ambiguity. Those are not taste. + +New findings on top of run A: + +| # | Finding | Verdict | +| - | ------- | ------- | +| l | `swap` keys are **Go RE2** regexes — `(?:…)` works, lookahead and lookbehind do not. The only example is two literal strings, so the agent guessed. A wrong guess fails as a **silent non-match**, the exact failure the recipe spends a section warning about | **Real, and the most valuable finding of the round.** Generalizes A's finding (c): both `tokens` and `swap` keys are patterns presented as literals | +| m | `%s` **ordering** in a substitution message is asserted only by an example that reads correctly under either interpretation. First `%s` is the swap value, second is the matched text — confirmed only by running the tool | **Real.** Pairs with A's finding (i) on `%s` count | +| n | Overlapping `swap` keys have undefined precedence. `can login` won over `login with`; `to logout` over `logout of`. First-alternative-wins is fine, but an author enumerating alternatives cannot tell how many findings a sentence yields, or which message | **Real** | +| o | Step 1 says "eleven in total" and the table lists eight; the other three are named in the following paragraph, behind a URL. Eleven only via arithmetic across two paragraphs | **Real, trivial.** List all eleven | +| p | `check ` lints everything under the path against the **whole config** — it is not scoped to the rule under test. With a second rule whose glob matches, the fail bucket reports both, and the recipe gives no vocabulary for that. Step 3 hints at the real isolation mechanism and never connects it to step 5 | **Real.** "Run the rule against each bucket" overstates what the command does | +| q | `BasedOnStyles =` with an empty right-hand side: the recipe insists on it without saying whether an empty value is valid INI to Vale | Minor. Measured: valid, no warning. Worth one clause | + +**One finding rejected as a harness artifact, recorded so nobody "fixes" it:** run B +objected that step 5 hardcodes an absolute path into someone's checkout +(`node /Users/…/dist-dev/index.js check …`). That is `build:dev` doing its job — it +rewrites `npx @taskless/cli` to an absolute path precisely so the harness command runs +from any directory. The shipped recipe says `npx @taskless/cli`. No change. + +Run B's second half of that objection is **not** an artifact and stands: the document +uses `npx @taskless/cli ` for invocations and `taskless agent ` for fetches +without ever explaining the relationship. That is finding **e**. + +### Run C — the only run that failed, and it found the worst defect + +The one intent that did not converge first try, and the one that earned the round. It +also declined the extension point the recipe recommended, which is why it succeeded. + +**C1 — the field table was factually wrong, and it fails silently.** The table said +"`%s` interpolates the match". For `substitution` that is false. Measured directly: + +``` +swap: {Github: GitHub}, message: "Use GitHub not %s", document text: "Github" +→ "Use GitHub not GitHub" (matchedText: "Github") +``` + +A single `%s` interpolates the **replacement**. The correct form takes two, filling +`(replacement, match)`. The recipe's own example block had it right while the normative +table one paragraph above said the opposite — so an author who reads the table rather than +copy-pasting the example ships a nonsense message. This is the worst defect found in the +round because it **passes every check the recipe tells you to run**: the rule fires, both +fixtures behave, the exit code is right, and only a human reading the message notices. + +**C2 — the extension-point table pointed at a check that cannot do the job.** The row read +"how something is capitalized (headings, **product names**)" → `capitalization`, and a +later line endorsed a literal `match` "for a product name". Measured, `match: GitHub`: + +``` +findings: 2 + 'Working with Github should be GitHub' matched: 'Working with Github' + 'We host on Github and it is fine. should be GitHub' matched: 'We host on Github and it is fine.' +``` + +`capitalization` applies `match` to a whole **scope** — a heading, a sentence — so it +flags entire sentences and cannot express "this word, wherever it appears". Product-name +spelling is a `substitution`. The recipe named the one use case in that row the check +cannot serve, then reinforced it two lines later. The agent only avoided the trap because +it already knew the check was scope-shaped. + +**C3 — the `pass/` bucket framing under-tests.** Step 4 justified the pass bucket as "the +half that catches an over-broad pattern" but described it as prose that is *correct*. +Correct prose proves nothing; the rule was never going to fire on it. The agent had to +build a throwaway probe file — URLs, code spans, `GITHUB_TOKEN` — because the fixture +model had no place for near-misses. + +**C4 — the debug ladder is one-sided.** Every rung addresses `fail/` reporting nothing. +Nothing addresses `pass/` firing, which is the over-broad case the pass bucket exists for. + +**One of run C's recommendations was rejected on measurement.** It proposed warning that +`ignorecase: true` would make the key `Github` also flag the correct `GitHub`. Measured: + +``` +ignorecase: true, swap {Github: GitHub}, text "Wrong github and Github here. Correct GitHub here." +→ 2 findings: 'github', 'Github' ('GitHub' NOT flagged) +``` + +Vale skips a match that already equals its replacement, so `ignorecase: false` is not +needed to protect the correct spelling. Writing that warning in would have taught +something false. The recipe states the measured behavior instead. Worth noting as the +round's reminder that an agent's diagnosis is a lead, not a finding. + +## Round 1 outcome + +Two of three converged first try; the third took one retry and produced the two findings +that mattered most. Against 2b.6 — "an intent the recipe never names, first try, +uncorrected" — the recipe **passed for `existence` and `substitution` and failed for the +product-name case**, which is the honest reading. + +Sixteen findings, fifteen accepted, one rejected on measurement. Every accepted finding is +a defect in the prose, which is what 2b.6 says to treat them as. + +## Revision applied + +`create-vale-rule.txt` was rewritten against all three reports (200 → 286 lines). What +changed, beyond the wording items: + +- **All eleven extension points** are in the table, with `capitalization` explicitly + scoped to "a whole heading or sentence" and product names routed to `substitution`. + The trap parenthetical and the literal-`match` endorsement are gone. +- **A `%s` table**, per extension point, with the measured substitution behavior quoted. +- **A new step 3, "tokens and swap keys are patterns, not literals"**: Go RE2, no + lookaround, implicit word boundaries, live metacharacters, first-wins on overlap, the + measured `ignorecase` behavior, `raw`/`nonword`, and the markdown scoping that spares + URLs and code spans. This one step carries findings c, l, n and C1's neighbours. +- **Step 6 says to read `results[].ruleId`** and states plainly that `success` and the + exit code answer a different question — with the note that exit 1 on `fail/` is correct + for a `level: error` rule and exit 0 is correct for a `warning` one. +- **`check ` is described as not scoped to the rule under test.** +- **`MinAlertLevel` joins the debug ladder**, and the ladder gains a `pass/`-fires branch. +- **The `pass/` bucket is now specified as near-misses**, not correct prose. +- **The three names that must agree** are stated once, together, with the fact that a + mismatch is silent. +- **Claims of CLI enforcement are removed.** "Nothing checks that you wrote all three" is + the honest statement of today's behavior, and step 7 says a whole-project `check` will + not report the fixtures because `.taskless/` is excluded from the walk. + +## Round 2 + +Re-run against the revised recipe with fresh agents and intents round 1 never used. + +| Run | Intent | Extension point | Converged | +| --- | ------ | --------------- | --------- | +| D | flag "click here" / "read more" as link text | `existence` + `scope: link` | **yes, first try, zero retries** | +| E | headings in sentence case, with exceptions | `capitalization` (its actual use case) | **yes, first try, zero retries** | + +**2b.6 is met.** Three extension points, five runs, and the only failure in the set was +round 1's product-name case — whose cause was corrected and whose check (`capitalization`) +now converges first try on the use case it can actually serve. Both round-2 agents +independently reported that step 6's "read `results`, not `success`" saved them from +misreading a clean `fail/` run, which is the round-1 fix working. + +Round 2 still found nine gaps, three of them defects in prose written *during* round 1's +revision. Measured before acting on them, as before. + +| # | Finding | Verdict | +| - | ------- | ------- | +| r | The field table lists five common fields under "Every rule carries" and **omits every field a rule actually needs** — `tokens`, `swap`, `match`, `exceptions`. Run E's rule depended entirely on `exceptions`, which appeared only as an undocumented line in an example | **Real, and the round's biggest gap.** Fixed with a per-extension-point field table | +| s | `scope` was documented as "e.g. `heading`, `paragraph`" — an example, not a list — for the field that decides where a rule looks. Run D's rule rested on `scope: link` existing; it guessed | **Real.** All sixteen markdown scopes now listed | +| t | `match: $sentence` was never defined. "The difference between 'first letter capitalized, everything else lowercase' and 'proper nouns permitted' decides whether `Getting started with Kubernetes` fires" | **Real, and the answer matters.** Measured below | +| u | Is `[click here](url)` prose? The recipe answered the inverse — what Vale *excludes* — and never said what link text is | **Real.** Measured below | +| v | Word boundaries were stated for a single-word key only; multi-word behaviour left to assumption | **Real.** Measured: whole-phrase. `click here` does not fire inside `Clicking here` | +| w | The `%%s` table covers 2 of 11 extension points while being billed as the authority on "the one mistake that passes every check" | **Real.** `capitalization` added (measured); the other eight now carry an explicit "don't guess, read it back off the finding" | +| x | Step 3 (RE2, boundaries, `ignorecase`) is irrelevant to `capitalization`/`occurrence`/`metric` and had no skip marker, so run E read all of it looking for `exceptions` semantics | **Real.** Skip line added | +| y | The pass-fixture advice was written for token rules only — "the word inside a longer word" is not a near-miss for a whole-scope check | **Real.** Now branches by rule shape, including "the same phrase *outside* the scope", which is the only thing that proves a `scope` works | +| z | "a fixture in a nested subdirectory is linted but **never counted against either bucket**" references counting machinery the reader has never been shown — a leftover from when `verifyValeRule` was assumed reachable | **Real, my error.** Rewritten to describe what `check` actually does | +| aa | The `BasedOnStyles =` rationale — "it stops a later edit from switching a whole style on by accident" — is not a real mechanism | **Real, my error.** Run D is right: an empty assignment prevents nothing. Replaced with the honest reason (it makes intent readable without knowing the default) | + +Run D repeated run B's objection to the absolute path in step 5. Same answer: `build:dev` +artifact, already recorded as rejected. + +### Measured for round 2 + +**`$sentence` is stricter than "sentence case".** First word capitalized, everything else +lowercase — proper nouns included, unless listed in `exceptions`: + +| Heading | Result | +| ------- | ------ | +| `Getting started with the API` | quiet | +| `Getting started with APIs` | quiet — an exception covers its plural | +| `Taskless and the API` | quiet — an exception may lead the scope | +| `Getting started with Kubernetes` | **fires** — a proper noun not listed | +| `getting started lowercase` | **fires** — the first word must be capitalized | + +So `exceptions` is load-bearing: every proper noun the docs use must be listed or the rule +flags correct headings. That is now in the recipe as a table, and it answers the question +run E said it could not resolve (`APIs`, the plural of an exception, is covered). + +**Link text is prose; the URL is not.** With no `scope`, a `click here` token fired on both +`[click here](https://example.com/x)` and the same phrase in an ordinary sentence. With +`scope: link`, only the link. Both facts are now stated. + +## Round 2 revision applied + +`create-vale-rule.txt`, 286 → 356 lines. Per-extension-point field table; full `scope` +list; `$sentence` semantics as a measured table; link-text scoping; whole-phrase +boundaries; `capitalization` in the `%%s` table with honest guidance for the rest; a skip +marker on step 3; pass-fixture advice branched by rule shape; and the two sentences of my +own that run D correctly called out as describing machinery and mechanisms that do not +exist. + +## Round 3 — worked examples + +Both rounds converged, but every run had to *invent* its rule shape from three examples, +and the near-misses each one flagged as "I guessed and happened to be right" were the +recurring theme. The recipe explained the mechanics well and showed almost nothing. + +Added a **Worked rules** section: nine rules, one per extension point the recipe covers, +each paired with the near-miss that fails and why. 356 → 545 lines. + +Every example is measured, and — the part that matters — the **YAML blocks were extracted +from the rendered recipe and executed verbatim**, so what ships is what was tested rather +than something adjacent to it: + +| # | Extension point | Result | +| - | --------------- | ------ | +| 1 | `existence` (hedging) | fires ×3 — `"Avoid hedging: 'We think'"` | +| 2 | `substitution` (login → sign in) | fires ×2 — `"Use 'sign in to' instead of 'login to'"` | +| 3 | `substitution` (GitHub) | fires ×2 on `Github`/`github`, **not** on the correct `GitHub` | +| 4 | `capitalization` (sentence case) | fires ×1 on a Title Case heading | +| 5 | `existence` + `scope: link` | fires ×1 — the link only, not the same phrase in prose | +| 6 | `occurrence` (max 1 per paragraph) | fires ×1 | +| 7 | `repetition` (doubled word) | fires ×1 — `"'is' is repeated"` | +| 8 | `consistency` (-ize/-ise) | fires ×1 | +| 9 | `conditional` (define the acronym) | fires ×1 on the undefined `XYZ`, not the defined `API` | + +**One near-miss was found by writing the examples, not by an agent.** An unquoted +`[^\s]+` in a `repetition` rule's `tokens` matches nothing — zero findings, no error, no +diagnostic. It is a YAML escaping failure that presents exactly like a Vale scoping +failure, and it is now example 7's "goes wrong". Worth noting that this is the failure +mode the recipe warns about most, arriving through a layer the recipe had not covered. + +Three other "goes wrong" entries are behaviors an agent would not guess: `consistency` +enforces internal consistency rather than picking a winner (use `substitution` for house +style); `occurrence` counts per `scope`, so omitting it caps the document; and +`conditional`'s `first`/`second` invert the rule if swapped. + +## Round 1 planned revision (applied — kept for the record) + +## Planned revision (applied — kept for the record) + +Batched until B and C report, so the recipe is revised once against all three rather than +three times against one. + +1. **Step 5 — say how to read the output.** `results` non-empty on `fail/`, empty on + `pass/`; `success` and the exit code are the same in both and are not the signal. +2. **Step 5/6 — fixtures are excluded from a whole-project `check`.** Say so, and stop + implying a bare `check` is a way to see the rule fire on its own fixtures. +3. **Step 4 — stop asserting validation nothing performs.** Either the invariants become + reachable (wire `rule verify ` to dispatch by engine — see `resume.md`, needs the + user's call) or the recipe states them as author discipline rather than as something + the CLI enforces. Do not leave the current wording; it is false. +4. **Step 5 — add `MinAlertLevel` to the debug ladder**, above the pattern. +5. **Step 2 — patterns, not literals.** Both `tokens` and `swap` keys are **Go RE2** + regexes: `(?:…)` works, lookahead and lookbehind do not, and metacharacters in a real + phrase are live. Name `raw` and `nonword`. State whether word boundaries are applied, + per extension point. Say that overlapping alternatives resolve first-wins. This one + item now carries findings c, l and n, and is the round's biggest single change. +6. **Step 2 — `%s` count *and* order follow the extension point.** One for `existence`, + two for `substitution`, and in a substitution the first is the swap value, the second + the matched text. +6a. **Step 1 — list all eleven extension points in the table**, rather than eight plus + three in prose behind a URL. +6b. **Step 5 — `check ` is not scoped to the rule under test.** It lints everything + under the path against the whole config. Say so, rather than "run the rule against + each bucket". +7. **Step 3 — `StylesPath` resolves relative to the config file**, and use one path root. +8. **Step 2/3 — the three names that must agree**, said once as one fact. +9. **Measure the fenced-code-block question**, then answer it in a line. + +Item 3 is the one that needs a decision rather than a wording pass. diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/proposal.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/proposal.md new file mode 100644 index 00000000..f64115ba --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/proposal.md @@ -0,0 +1,51 @@ +## Why + +`add-vale-rule-engine` shipped a chooser without a destination. `engine-selection` teaches an agent to decide a rule belongs to `vale`, and then there is nowhere to go: `static.txt` is 76 lines of ast-grep authoring, and its only mention of Vale is a See Also line telling the reader to _confirm_ `sg` was right. An agent that follows the procedure correctly and lands on `vale` dead-ends, and so does one that lands on `runtime`. That was recorded as a deliberate non-goal at the time (`add-vale-rule-engine/design.md:16` excludes "generating Vale rules and authoring the committed `.vale.ini`"), but the chooser is what makes the gap reachable, and it is now shipped. + +The same work exposed that the surface an agent reads is shaped for a human. `taskless help` names the command after a human's reason for typing it; agents are not asking for help, they are fetching a procedure. And the surface is addressed longform — `taskless help rule create` resolves by joining positionals — so an agent must know both the words and their order. Hyphenated single tokens read as literal strings an agent copies rather than a phrase it might paraphrase, which is the failure this surface cannot afford. + +## What Changes + +- **BREAKING** — `taskless help ` becomes `taskless agent `. The command is named for who reads it. +- **BREAKING** — topic addressing flattens to a single token. `taskless help rule create` becomes `taskless agent create-rule`; the `positionals.join("-")` resolution is removed rather than generalized. +- **BREAKING** — authoring topics are renamed to verb-noun, matching what `route` decides: + - `static` → `create-sg-rule` + - `existing` → `create-legacy-rule` + - new `create-vale-rule` + - new `create-runtime-rule` + - `remote` + `rule-create` merge into `create-remote-rule` +- **BREAKING** — `TOPICS` in `@taskless/cli/prompts` renames with them. `["static", "engine-selection"]` becomes `["create-sg-rule", "create-vale-rule", "create-runtime-rule"]`. No alias is kept. Pre-1.0, a backwards-incompatible change is a **MINOR** bump. +- `route` becomes the single front door, returning a concrete next command rather than a category. Its decision set is the five `create-*-rule` topics. It reads login state early, because that changes which destinations exist, and offers service generation **only where it is a real choice** — when the rule is locally expressible and the user is logged in. A user who is not logged in, or a rule local authoring cannot express, is not being offered anything. +- **BREAKING** — `engine-selection` merges into `route` and stops existing as a topic. Its criterion distributes: `route` applies it to dispatch, and each `create-*-rule` recipe states the evidence that makes its own engine right. That is what keeps it exportable — a consumer outside the CLI has no `route` step and cannot run `taskless detect --json`, so a chooser topic was unusable to it anyway. +- `create-runtime-rule` becomes the logged-**out** path: what a runtime rule is, why executing code requires login, reconciliation, and signing, and how to get there. A logged-in runtime request goes straight to `create-remote-rule` from `route`, so no topic delegates to another. +- `create-vale-rule` covers what no topic covers today: authoring a Vale style file under `vale/rules/`, scoping it with a `.vale.ini` section, and writing `pass/`/`fail` fixtures. Consistent with `create-sg-rule`, the agent writes these files; no CLI writer is introduced. +- The scaffolded `.vale.ini` ships **no section**, so a fresh project lints nothing until a user scopes something deliberately. `create-vale-rule` teaches writing that first section. +- Vale's stderr diagnostics on a successful run surface as notices. This is required by the change above, not incidental: with no section to copy, the likely first mistake is a rule assignment at top level, which Vale reports as `W101 ... is ignoring it` on stderr and which today is discarded — reproducing the silent-disable class the Vale work exists to eliminate. + +## Capabilities + +### New Capabilities + +- `cli-agent-authoring`: the four `create-*-rule` procedures — what each engine's authored artifacts are, where they live, and what makes one complete. Covers the Vale authoring path that has no home today. + +### Modified Capabilities + +- `cli-help`: the command renames to `agent` and topic addressing flattens to a single token. Longform resolution is removed. +- `cli-rule-routing`: `route` dispatches to a concrete `create-*-rule` topic rather than a category, absorbing the engine decision it previously deferred to `engine-selection`. +- `cli-knowledge-prompts`: `TOPICS` renames, gains the Vale and runtime authoring topics, and loses `engine-selection`; pre-1.0 breaking changes are restated as MINOR. +- `cli-vale-rule-engine`: the scaffolded config carries no section, and Vale's stderr diagnostics on a zero-exit run become notices. + +## Impact + +- **`packages/cli/src/commands/help.ts`** — renamed, positional-join resolution removed. +- **`packages/cli/src/help/*.txt`** — two renames, two new files, and ~306 cross-references across 77 files that name `taskless help`. +- **`packages/cli/src/prompts/index.ts`** — `TOPICS`/`INTERNAL_TOPICS` membership and the `PromptTopic` union. +- **`@taskless/cli/prompts`** — published, typed export. The platform generator consumes `TOPICS` and deploys separately from the CLI, so it breaks on upgrade rather than at build time. Flagged in the changeset. +- **`packages/cli/src/filesystem/migrations/0004-vale-engine.ts`** — `VALE_CONFIG_CONTENT` drops its `[*]` section. +- **`packages/cli/src/rules/vale/run.ts`** — stderr captured on a zero-exit run and returned as a notice. +- **`skills/taskless/SKILL.md`** — the one skill naming `taskless help`. +- **Telemetry** — `cli_help` events carry a `topic` whose vocabulary changes; dashboards keyed on `static` go quiet. + +## Delivery Shape + +**Single PR**, stacked on #100. The rename is mechanical but total: a half-renamed command surface is not a shippable intermediate state, and splitting the topic renames from the command rename would leave cross-references pointing at commands that do not exist yet. Reviewable because the diff is overwhelmingly one substitution repeated, with four files of genuinely new prose. diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/resume.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/resume.md new file mode 100644 index 00000000..531739c0 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/resume.md @@ -0,0 +1,221 @@ +# Resume notes — `agent-command-and-vale-authoring` + +Handoff for picking this up after a context reset, or on another machine. Not part of +the OpenSpec artifact set; delete it when the change is archived. + +## Where you are + +- **PR #102** (draft), branch `openspec/agent-command-and-vale-authoring`, stacked on **#100**. +- Stack below you, all green and already reviewed: **#71 → #93 → #94 → #95 → #100**. + The user drives the merge-down; do not merge anything without being asked. +- **Groups 1, 2, 5.1 and 5.2 are done** (`093aca1`, `b0a0ee0`). 572 tests pass, typecheck + and lint clean. `tasks.md` is the authority — read it first and trust its checkboxes + over this file. +- Work happens in the worktree at `worktrees/impl-102`, not the main checkout. On a fresh + machine: `git worktree add worktrees/impl-102 openspec/agent-command-and-vale-authoring` + then **`pnpm install` inside it** — a worktree gets its own empty `node_modules`, and + skipping the install breaks `git commit` (lint-staged) and every `pnpm` script. + +## Read these before doing anything + +1. `tasks.md` — the plan, including group **2b** (the recipe test harness) +2. `design.md` — decisions D1–D10, each with its rejected alternatives. No open questions. +3. `specs/*/spec.md` — what the recipes and the scaffold must do +4. `proposal.md` — the why, and the delivery shape (single PR, stacked on #100) + +## Environment traps that will cost you an hour each + +- **`NODE_OPTIONS` was broken in the previous session's shell** — a `--require` preload + pointing at a deleted temp file, so every `node`, `pnpm`, and `git commit` died with + `MODULE_NOT_FOUND`. Every command in that session was prefixed with + `NODE_OPTIONS="--max-old-space-size=4096"`. **On a fresh machine, check whether you + still need this** (`echo $NODE_OPTIONS`) rather than cargo-culting it. +- **Run `pnpm --filter @taskless/cli build` before `pnpm --filter @taskless/cli test`.** + Many suites spawn the built CLI. A stale `dist/` produces failures that read exactly + like real regressions. +- **`commit.gpgsign` must be true locally.** An earlier restack silently stripped + signatures from 34 commits because `git rebase` does not re-sign without it, and CI does + not catch unsigned commits. Audit with `git log --format='%G?'` after any rebase. +- **A recipe containing a literal `%` must escape it as `%%`.** Recipes render through + sprintf-js named args, so a bare `%s` in prose (Vale's `message:` examples are full of + them) fails at render with "mixing positional and named placeholders is not supported". + It is caught by rendering the topic, not by the build or by typecheck. +- **`zsh` mangles `perl -0pi -e` one-liners containing `@`.** Use `python3 - <<'PY'` for + multi-file text surgery; two attempts were lost to quoting before switching. + +## What group 2 actually produced + +Topic map after the rename, so you do not have to reconstruct it from the diff: + +| Before | After | +| --------------------------- | ------------------------- | +| `static.txt` | `create-sg-rule.txt` | +| `existing.txt` | `create-legacy-rule.txt` | +| `remote.txt` + `rule-create.txt` | `create-remote-rule.txt` (content merge) | +| `engine-selection.txt` | merged into `route.txt`, deleted | +| `rule-create.anonymous.txt` | **deleted** (see below) | +| `rule-improve*.txt` | `improve-rule*.txt` | +| `rule-delete.txt` | `delete-rule.txt` | +| `rule-verify.txt` | `verify-rule.txt` | +| — | `create-vale-rule.txt` (new) | +| — | `create-runtime-rule.txt` (new) | +| `rule.txt`, `rule-meta.txt` | unchanged names | + +**The one deviation from the task text**: `rule-create.anonymous.txt` was deleted rather +than renamed to `create-remote-rule.anonymous.txt`. It duplicated `static.txt` outright, +and "the local-only variant of the remote recipe" is the contradiction `route` exists to +resolve. Its unique material (upstream-schema pointer, optional fields, the per-layer +verify error table) moved into `create-sg-rule.txt`, and `rule create --anonymous` now +points at `taskless agent create-sg-rule`. Recorded in `tasks.md` 2.3. + +**5.1 and 5.2 were pulled forward**, out of group order and deliberately: 2b tests +`create-vale-rule` against a scaffolded project, and the recipe's central claim is that +the scaffold ships section-less. Testing against a scaffold that still wrote `[*]` would +have exercised a recipe nobody will receive. + +## Next up: finish 2b (the harness), then 5.3/5.4, then group 3 + +### 2b is done except its control run — read `iteration-log.md` for the evidence + +Five sandboxed runs across two rounds, three extension points, twenty-five findings. +**2b.1–2b.7 are complete and 2b.6 is met**: an agent given an intent the recipe never +names now produces a working rule first try, uncorrected, for `existence`, +`substitution` and `capitalization`. `create-vale-rule` went 200 → 356 lines across two +revisions. + +**Only 2b.8 remains** — run the same harness over `create-sg-rule` as a control. A failure +there means the harness is wrong rather than the recipe. The procedure is below and the +scratchpad sandboxes are machine-local, so re-create them. + +**One task is checked off with a caveat you should read: 2b.4.** "`verify` passes" cannot +be satisfied — see "A real gap" below. It is the one open decision in this group. Their sandboxes are under +the scratchpad at `vale-harness/sandbox-{a,b,c}`, each a real `init` scaffold, alongside +`create-vale-rule.rendered.txt` (the dev-build render they were given). **That scratchpad +is machine-local — on another machine, re-run the harness from scratch rather than looking +for it.** + +To re-run it: + +1. `pnpm --filter @taskless/cli build:dev` — `TASKLESS_BUILD_TARGET=dev` bakes + `__TASKLESS_CLI__` as an **absolute** path to `dist-dev/index.js`, so the rendered + recipe carries a command that runs from any directory. Confirmed working: the render's + step-5 commands come out as `node /abs/path/dist-dev/index.js check …`. +2. Scaffold a throwaway project with that binary (`init --no-interactive -d `), + so the sandbox is a real scaffold and not a hand-made approximation. +3. Render the recipe to a file (`agent create-vale-rule > …rendered.txt`). +4. Hand a **fresh, non-forked** subagent only: the rendered recipe path, the sandbox path, + and a rule intent in plain words. It must **not** have repository access — with it, it + finds `no-simply.yml` and the mixed-engine fixture and copies them, and the loop tests + our fixtures rather than our writing. Ask explicitly for a blunt critique of the prose; + that is the deliverable, not the rule. +5. The three intents used, chosen to exercise different extension points (everything in + this repo today is `existence`, so a recipe drafted from our own examples teaches token + blocklists and nothing else): hedging phrases (`existence`), "sign in" vs "login" + (`substitution`), and GitHub's capitalization (`capitalization`, literal-match form — + the variant the recipe covers in one line). +6. Every failure is a defect in the prose. Fix the recipe, re-run with a fresh agent. + Converged when an agent produces a rule that fires on `fail/` and stays quiet on + `pass/`, first try, uncorrected. Keep the iteration log (2b.7) — it is the only part a + reviewer can check without rerunning the loop. +7. 2b.8: run the same harness over `create-sg-rule` as a control. A failure there means + the harness is wrong rather than the recipe. + +### Facts measured this session — do not re-derive + +- Vale field reference for the three taught extension points is in the recipe and came + from `https://docs.vale.sh/llms-full.txt`. **`https://docs.vale.sh/styles` is fine (200)** + — the earlier note that it 404s was wrong; it needs `curl -L`. +- `StylesPath = .` makes `rules/` the StyleName, so `vale/rules/no-simply.yml` is the + check `rules.no-simply`. `StylesPath = rules` resolves nothing. +- **`BasedOnStyles =` is not required for a rule to fire, and omitting it added no noise** + in the bundled Vale version. The recipe still tells authors to write it, on the honest + grounds that it is explicit and matches what `verify` generates — not on the claim that + omitting it produces spurious findings, which measurement did not support. +- A rule assignment outside any section: `W101 '' isn't a core option; Vale is + ignoring it` on **stderr**, exit 0, valid `{}` on stdout. With 5.2 this now surfaces as + `Notice: Vale reported while running: …` and the check still exits 0. Verified end to + end against the built CLI. + +### A real gap found, not yet decided + +**`verifyValeRule` / `verifyValeRules` have no CLI caller.** They are exported from +`src/rules/vale/verify.ts` and exercised only by `test/vale-verify.test.ts`; +`taskless rule verify ` routes to `src/rules/verify.ts`, which is ast-grep only. So +there is currently **no way for an agent to verify a Vale rule from the CLI**. + +`create-vale-rule` works around this by validating with `check` over each fixture bucket, +which does work today and is what the harness exercises. But it means task **2b.4's** +"`verify` passes" cannot be satisfied as written, and it is the same class of dead end +this whole change exists to remove — a capability that exists but is unreachable. + +Wiring it looks small: dispatch `rule verify ` by which engine owns the id +(`.taskless/vale/rules/.yml` vs `.taskless/sg/rules/.yml`), then call +`verifyValeRule`. **Raise this with the user before doing it** — it is scope not in +`tasks.md`, and the alternative (a follow-up issue, like #99 and #101) is defensible. + +## Group 3 note, when you get there + +`rule.txt` documents a table of multi-token forms (`taskless help rule create`, +`… rule meta`). After group 1 these are **actively broken**, not merely stale — they hit +the "Too many arguments" path. Lead group 3 with it. + +Group 2 deliberately left the wider cross-reference sweep alone (~306 `taskless help` +occurrences across 77 files). It did update See Also blocks in the six recipes it +rewrote, so 3.1 is the remaining files plus `skills/taskless/SKILL.md`, both READMEs, and +the TS sources. Leave `CHANGELOG.md` alone. + +## Decisions you should not silently revisit + +All are argued in `design.md` with rejected alternatives. The two most likely to be +re-litigated by accident: + +- **D1** — `route` and `engine-selection` merge; the engine criterion is stated **once**, + in `route`'s destination table. Destinations carry a short orientation line (D9), never + a second copy of the criterion. `test/help-extensions.test.ts` now guards both halves. +- **D4** — the section-less scaffold ships **paired** with surfacing Vale's stderr on a + zero-exit run. Shipping the scaffold alone reintroduces the silent-disable failure the + whole Vale stack exists to eliminate. They are one requirement, not two. Both are now in. + +Also settled: pre-1.0, every backwards-incompatible change here is a **MINOR** bump — +never MAJOR. The telemetry event stays `cli_help` (agent-call volume stays visible under +the existing event). + +## PR #103 is stacked above this one + +`openspec/self-contained-rules` (branch still named `openspec/self-contained-vale-rules`), +based on this branch. **Spec-only and green** — proposal, design, four spec deltas, tasks. +`openspec validate self-contained-rules --strict` passes. + +It unifies the rule layout across all three engines: one directory per rule at +`.taskless/rules///` holding the rule, any config that engine needs, and its +tests in `.tests/`. Plus path-addressed `verify`/`test` replacing `rule verify `, and +an `example/` project. + +**Implementation is approved and not started.** Follow `self-contained-rules/tasks.md`. +One caveat that is easy to miss: task 1.5 deletes the legacy read paths, and it exists +because `.taskless/rules/` is simultaneously the new root and the old +`LEGACY_RULES_DIRECTORY`. That was found by starting the refactor, not by writing the +proposal — see design D9. The partial `engines.ts` rewrite was reverted rather than +pushed, so the PR stays spec-only; regenerating it is mechanical from D1/D2 and the +task list, and worth writing *against* D9 rather than patching D9 in afterwards. + +Measured facts the implementation depends on, so nobody re-derives them: + +- ast-grep `ruleDirs` **recurses**; `tests/` and `__tests__/` inside a rule directory + hard-fail the scan; **`.tests/` is skipped**, and `sg test` still reads it via `testDir`. +- Vale resolves `/.yml` as check `.` only under a `StylesPath` naming its + parent — nothing at all under `StylesPath = .`. +- Vale rejects unknown keys in a style (`E201`), so scope cannot live in the style file. +- A `.yml` sidecar in a style directory is loaded as a rule and fails; `.vale.ini` and + `.tests/` in the same place are ignored. +- Migrations run before any read (`ensureTasklessDirectory`), which is why the legacy + paths are unreachable rather than merely stale. + +## Outside this PR + +- **#99** — migrate subprocess handling to execa (inventory and sequencing already written up) +- **#101** — whether a whole-project Vale check should skip build output; `.taskless/` is + already excluded as of #100 +- Stale worktrees under `worktrees/`; `git worktree list` to review +- `openspec validate --all --strict` fails on `spec/cli-rules` and `spec/cli-update-engine` + on `main` already — pre-existing, unrelated, do not chase it diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-agent-authoring/spec.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-agent-authoring/spec.md new file mode 100644 index 00000000..1b6340f0 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-agent-authoring/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Every engine a rule can be routed to has an authoring recipe + +The CLI SHALL provide an authoring recipe for each engine `route` can name: `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule`, alongside `create-legacy-rule` for a linter the repository already uses. + +A decision procedure that can produce an answer with no destination is incomplete. Engine selection can conclude `vale` or `runtime`, and before this change neither had a procedure, so an agent that reasoned correctly arrived nowhere. + +#### Scenario: Each engine choice reaches a procedure + +- **WHEN** engine selection concludes `sg`, `vale`, or `runtime` +- **THEN** a recipe exists that authors a rule for that engine + +#### Scenario: A legacy destination exists for repositories with their own linter + +- **WHEN** the repository already runs a linter that can express the rule +- **THEN** `create-legacy-rule` SHALL author it in that tool's own dialect + +### Requirement: The Vale authoring recipe covers rule, scope, and fixtures + +The `create-vale-rule` recipe SHALL instruct the agent to produce three artifacts, and SHALL state that a rule is incomplete without all three: + +1. A Vale style file under `.taskless/vale/rules/.yml`. +2. A section in the committed `.taskless/vale/.vale.ini` scoping which files the rule applies to, enabling it as `rules. = YES`. +3. `pass/` and `fail/` fixture documents under `.taskless/vale/rule-tests//`. + +The recipe SHALL state that the scaffolded config carries no section, so the first rule authored in a project also authors the first scope. + +#### Scenario: Authoring produces all three artifacts + +- **WHEN** the agent follows `create-vale-rule` +- **THEN** it writes the style file, a scoping section enabling the rule, and both fixture buckets + +#### Scenario: The recipe teaches the first section + +- **WHEN** a project's `.vale.ini` has no section yet +- **THEN** the recipe SHALL direct the agent to add one scoped to the files the rule is about, rather than assuming a section exists + +#### Scenario: An unscoped rule is not silently accepted + +- **WHEN** the agent enables a rule without placing it inside a section +- **THEN** the recipe SHALL identify this as incomplete, because Vale ignores a rule assignment outside a section + +### Requirement: Authoring recipes write files rather than invoking a writer + +The `create-*-rule` recipes SHALL instruct the agent to write the rule, its configuration, and its fixtures directly. The CLI SHALL NOT provide a command that generates a Vale style file or edits `.vale.ini` on the agent's behalf. + +This matches how ast-grep rules are authored today: the agent writes the rule and its config entry, and construction belongs to the downstream generator rather than to the CLI. + +#### Scenario: No CLI writer for Vale configuration + +- **WHEN** an agent authors a Vale rule +- **THEN** it edits `.vale.ini` itself +- **AND** the CLI SHALL NOT offer a subcommand that performs that edit + +### Requirement: The runtime authoring recipe is the logged-out path + +The `create-runtime-rule` recipe SHALL explain that runtime rules execute code and therefore require login, reconciliation, and signing, and SHALL state this as a property of executing code rather than of the engine's capability. It SHALL point at `auth` for obtaining access rather than restating the login procedure, which `auth` owns. + +It SHALL NOT forward the agent to another authoring recipe. A logged-in runtime request is routed to `create-remote-rule` by `route`, so this recipe is reached only when the gate is closed and exists to explain that one gate once. + +#### Scenario: The gate is explained where it is encountered + +- **WHEN** an agent follows `create-runtime-rule` +- **THEN** the recipe SHALL state why the runtime tier is gated when the static tiers are not +- **AND** it SHALL refer the reader to `auth` rather than restating how to log in + +#### Scenario: The recipe does not delegate + +- **WHEN** an agent follows `create-runtime-rule` +- **THEN** it SHALL NOT be directed to fetch another authoring recipe to proceed + +### Requirement: Service generation is one recipe + +The CLI SHALL provide a single `create-remote-rule` recipe covering both the client-side boundary of service generation and the procedure itself — enriching the user's description, dispatching to the Taskless service, and reporting the result. + +Split across a boundary statement and a procedure, an agent fetches one only to learn it needs the other, which is the second fetch this change exists to remove. + +#### Scenario: One fetch reaches the whole procedure + +- **WHEN** an agent follows `create-remote-rule` +- **THEN** the recipe SHALL carry both the boundary and the dispatch procedure +- **AND** it SHALL NOT require fetching a second topic to complete the request + +### Requirement: Every authoring recipe opens by orienting the reader + +Each `create-*-rule` recipe SHALL open with a line naming the topic the reader is in, the kinds of rule it helps write, and an instruction to revisit the routing decision if that is not what they need. + +The line SHALL orient, not classify: it states this recipe's own scope and SHALL NOT restate the criterion distinguishing the engines from each other, which `route` holds in one place. An agent that arrived at the wrong recipe — by guessing, by a user naming a topic directly, or because `route` was wrong — should discover it in the first line, where recovery is cheap, rather than after authoring the wrong artifact. + +#### Scenario: A misrouted reader is told how to recover + +- **WHEN** an agent opens any `create-*-rule` recipe +- **THEN** the first lines SHALL name what that recipe helps write +- **AND** SHALL instruct the agent to revisit its routing decision if it needs a different kind of check + +#### Scenario: The orientation is not a second criterion + +- **WHEN** the orientation line is read +- **THEN** it SHALL describe only this recipe's scope, not the comparison between engines diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-help/spec.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-help/spec.md new file mode 100644 index 00000000..3da108d5 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-help/spec.md @@ -0,0 +1,183 @@ +## MODIFIED Requirements + +### Requirement: Help subcommand displays rich help text for commands + +The CLI SHALL support an `agent` subcommand that accepts at most one positional argument identifying a topic AND an optional `--anonymous` boolean flag. Topics SHALL be addressed by a single token; the subcommand SHALL NOT join multiple positionals into a topic key. When a topic is provided, the subcommand SHALL look up a matching help text file embedded at build time using the following resolution order: + +1. If `--anonymous` is set AND `.anonymous.txt` exists in the embedded map, return that file. +2. Otherwise, return `.txt`. +3. If neither exists, exit with code 1 and an error message suggesting `taskless agent` for the topic index. + +When no positional argument is provided, the subcommand SHALL print a topic index containing a one-paragraph human slug followed by a topic disambiguation table mapping topic names to their summaries. + +The subcommand is named for its reader. It serves agents fetching a procedure, not humans asking for help, and single-token addressing exists so a topic name is a literal string an agent copies rather than a phrase it can reorder or paraphrase. + +#### Scenario: Agent subcommand for a topic returns the recipe + +- **WHEN** a user runs `taskless agent check` +- **THEN** the CLI SHALL print the contents of `check.txt` to stdout + +#### Scenario: Multi-word topic paths are not resolved + +- **WHEN** a user runs `taskless agent rule create` +- **THEN** the CLI SHALL NOT look up `rule-create.txt` by joining the positionals +- **AND** it SHALL exit non-zero rather than guessing a topic + +#### Scenario: Formerly nested topics are addressed by one token + +- **WHEN** a user runs `taskless agent improve-rule` +- **THEN** the CLI SHALL look up `improve-rule.txt` and print its contents + +#### Scenario: The former command name is gone + +- **WHEN** a user runs `taskless help check` +- **THEN** the CLI SHALL NOT print recipe text for `check` + +### Requirement: onboard topic is registered in the help index + +A help topic `onboard` SHALL be registered. The CLI SHALL embed `packages/cli/src/help/onboard.txt` at build time via the existing `import.meta.glob` mechanism. `taskless agent onboard` SHALL print the contents of `onboard.txt`. The topic SHALL appear in the output of `taskless agent` (the index) with a one-line summary describing it as the post-install rule-discovery flow. + +#### Scenario: The onboard topic returns the recipe + +- **WHEN** a user runs `taskless agent onboard` +- **THEN** the CLI SHALL print the contents of `onboard.txt` to stdout +- **AND** SHALL exit with code 0 + +#### Scenario: Topic index includes onboard + +- **WHEN** a user runs `taskless agent` (no args) +- **THEN** the topic index SHALL include a row for `onboard` +- **AND** the row SHALL describe it as the post-install rule-discovery flow + +### Requirement: help_onboard intent telemetry + +Fetching the `onboard` topic SHALL emit the command's single intent event, `cli_help`, carrying `onboard` as its `topic` property. + +Per-topic event names (`help_onboard` and siblings) are not emitted. One event with a topic property is filterable the same way and does not grow the event vocabulary every time a topic is added or renamed, which this change would otherwise have to do for every rename below. + +#### Scenario: Fetching onboard captures its topic + +- **WHEN** an agent runs `taskless agent onboard` +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` property is `onboard` + +### Requirement: Routing topics are registered in the help system + +The help system SHALL register `route` and each `create-*-rule` recipe as embedded topics, retrievable via `taskless agent ` and listed in the topic index, consistent with the existing topic embedding and format requirements. + +`existing`, `static`, and `remote` are no longer topics. `route` applies the criterion they carried and names a concrete destination, so an agent reaches an authoring recipe in one fetch. + +#### Scenario: Routing topics resolve + +- **WHEN** `taskless agent route` or any `taskless agent create-*-rule` is run +- **THEN** the corresponding recipe text SHALL be returned +- **AND** an unknown-topic error SHALL NOT be raised + +#### Scenario: Removed routing topics do not resolve + +- **WHEN** `taskless agent existing`, `taskless agent static`, or `taskless agent remote` is run +- **THEN** the CLI SHALL exit non-zero +- **AND** it SHALL NOT print recipe text + +#### Scenario: Routing topics appear in the index + +- **WHEN** `taskless agent` (no arguments) is run +- **THEN** the topic index SHALL include `route` and every `create-*-rule` topic + +### Requirement: Routing topics emit intent telemetry + +Fetching a routing recipe SHALL emit the command's single intent event, `cli_help`, carrying the served topic as its `topic` property. + +#### Scenario: Intent is captured for routing recipes + +- **WHEN** the agent fetches `route` or any `create-*-rule` topic +- **THEN** the command SHALL capture a `cli_help` event whose `topic` property is that topic name + +### Requirement: Anonymous variant lookup uses a compile-time map + +The help command SHALL construct, at build time, a Set of topic names that have a corresponding `.anonymous.txt` file. Lookup at runtime SHALL be O(1). The Set SHALL be derived from `import.meta.glob` matching `*.anonymous.txt` in the help directory. + +#### Scenario: Topics with variants are detected at build time + +- **WHEN** the CLI bundle is built +- **AND** a file `improve-rule.anonymous.txt` exists +- **THEN** the embedded variants set SHALL contain `improve-rule` + +#### Scenario: Topics without variants are absent from the map + +- **WHEN** the CLI bundle is built +- **AND** no `check.anonymous.txt` file exists +- **THEN** the embedded variants set SHALL NOT contain `check` +- **AND** `taskless agent check --anonymous` SHALL fall back to `check.txt` + +### Requirement: Embedded JSON schemas are generated via zod-to-json-schema + +For every recipe topic that documents a CLI command accepting `--from `, the corresponding Zod input schema in `packages/cli/src/schemas/` SHALL be converted to JSON Schema and embedded in the recipe's `## Input schema` section as a fenced code block. Generation MAY happen at runtime (small dep, fast) or at build time; runtime is acceptable. + +#### Scenario: The remote authoring recipe embeds its input schema + +- **WHEN** a user runs `taskless agent create-remote-rule` +- **THEN** the output SHALL contain an `## Input schema` section +- **AND** the section SHALL contain a code-fenced JSON Schema block derived from the `rules-create` Zod schema + +#### Scenario: The improve recipe embeds its input schema + +- **WHEN** a user runs `taskless agent improve-rule` +- **THEN** the output SHALL contain an `## Input schema` section with the rule-improve JSON Schema + +### Requirement: Help command emits intent telemetry + +The `agent` command SHALL emit one PostHog event, `cli_help`, on every invocation, carrying a `topic` property: + +- the served topic when a positional resolves to a known topic +- the attempted topic string when it resolves to none +- `(index)` when called with no positional arguments +- the joined positionals when more than one is supplied + +#### Scenario: Topic fetch captures the topic + +- **WHEN** an agent runs `taskless agent create-sg-rule` +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` property is `create-sg-rule` + +#### Scenario: Index fetch captures the index + +- **WHEN** an agent runs `taskless agent` (no args) +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` property is `(index)` + +## ADDED Requirements + +### Requirement: Routing recipes name a destination, not a second decision + +The `route` recipe SHALL apply the engine reasoning directly and name a concrete `create-*-rule` topic, rather than referring the reader onward to a topic that selects an engine. No shipped recipe SHALL refer to `engine-selection`, which no longer exists. + +Each `create-*-rule` recipe SHALL instead point back at `route` for a reader who arrived at the wrong one, so recovery costs a re-decision rather than a second copy of the criterion (see "Every authoring recipe opens by orienting the reader"). + +#### Scenario: Route names a destination without a second fetch + +- **WHEN** an agent follows `route` +- **THEN** the recipe SHALL name one `create-*-rule` topic +- **AND** it SHALL NOT require fetching a separate engine-selection topic first to do so + +#### Scenario: Authoring recipes point back rather than re-deciding + +- **WHEN** an agent reads any `create-*-rule` recipe +- **THEN** the recipe SHALL name `route` as where to go if this is the wrong destination +- **AND** it SHALL NOT reference `engine-selection` + +### Requirement: Shipped recipes name only commands that exist + +No embedded recipe SHALL contain the string `taskless help`. Recipes cross-reference each other by literal command string, so a stale reference is invisible until an agent runs it and receives nothing. + +#### Scenario: No recipe references the removed command + +- **WHEN** the embedded recipe set is inspected +- **THEN** no recipe SHALL contain `taskless help` + +## REMOVED Requirements + +### Requirement: The engine-selection topic is registered in the help system + +**Reason**: The topic no longer exists. Its criterion moved into `route`, which now applies the engine reasoning itself and names a concrete destination, so there is nothing left to register or to fetch. + +### Requirement: Routing recipes reference engine selection + +**Reason**: Replaced by "Routing recipes name a destination, not a second decision". The requirement named `route` and `static` and obliged them to forward to a separate engine-selection topic; `static` is gone, and forwarding is the behavior this change removes. diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-knowledge-prompts/spec.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-knowledge-prompts/spec.md new file mode 100644 index 00000000..4722b089 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-knowledge-prompts/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Topic names and accessor shape are stable public API + +The set of `PromptTopic` names, the `getPrompt`/`PROMPTS` shape, and the existing fields of `PromptOptions` SHALL be treated as public API; recipe _text_ MAY change freely. + +The package is pre-1.0, so a backwards-incompatible change to that surface SHALL be released as a **MINOR** bump. This is what the leading zero means, and it applies to renaming a topic, removing one, or changing the accessor signature. + +What the requirement actually protects is not the version number but the notice. `TOPICS` is consumed across a deploy boundary, so a downstream consumer breaks when it upgrades rather than when this package builds, and the version alone cannot warn anyone. A breaking change SHALL therefore name the removed or renamed topics explicitly in its changeset. + +#### Scenario: Renaming or removing a topic + +- **WHEN** a topic is removed or renamed, or the accessor signature changes +- **THEN** it SHALL be released as a MINOR bump +- **AND** the changeset SHALL name the removed or renamed topics +- **AND** a recipe text edit SHALL require neither + +#### Scenario: Adding an option + +- **WHEN** a new optional field is added to `PromptOptions` +- **THEN** it SHALL NOT require more than a PATCH bump, since existing call sites keep their behavior + +## ADDED Requirements + +### Requirement: Exported topics cover every engine a rule can be routed to + +`TOPICS` SHALL export the authoring recipe for each engine — `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule`. + +A consumer that can decide a rule belongs to an engine must be able to reach the procedure for authoring one. Exporting a chooser without its destinations reproduces, for the platform generator, the dead end this change removes from the CLI. + +`engine-selection` leaves the export because it stops existing: the criterion it carried now lives in `route`, stated once. `route` is not exported here — it still contains local mechanics a Worker cannot run — so until it is, a consumer gets each destination's own scope from these three and adjudicates a genuinely ambiguous call itself. + +#### Scenario: Every engine's authoring path is reachable from the export + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL contain `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule` + +#### Scenario: The exported set follows the rename + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL NOT contain `static` or `engine-selection`, neither of which names a recipe any more diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-rule-routing/spec.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-rule-routing/spec.md new file mode 100644 index 00000000..cdfa94f1 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-rule-routing/spec.md @@ -0,0 +1,153 @@ +## MODIFIED Requirements + +### Requirement: Route is the local authoring classifier + +The CLI SHALL provide a `route` help recipe that instructs the agent to classify a rule-authoring request into one of five destinations — `create-legacy-rule`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, or `create-remote-rule` — using `taskless detect --json` signals plus the user's intent. The `route` recipe SHALL read the user's login state before dispatching, since it determines which destinations are reachable. It SHALL remain biased to stay local: local authoring that works SHALL NOT be abandoned for the service. + +`route` SHALL decide the engine as part of this classification rather than deferring it to a separate topic. There is one decision, made from one reading of the evidence: whether a rule is expressible locally and which engine can express it are answered from the same signals, so splitting them costs a second fetch and a handoff without adding information. + +Each destination SHALL be a topic an agent can fetch by name, so classifying produces a command to run rather than a category to interpret. + +#### Scenario: Route fetches detection before classifying + +- **WHEN** the agent fetches the `route` recipe to author a rule +- **THEN** the recipe SHALL direct the agent to run `taskless detect --json` and + use its signals as input to the classification + +#### Scenario: Route classifies into one of five destinations + +- **WHEN** the agent follows `route` +- **THEN** it SHALL select exactly one of `create-legacy-rule`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, or `create-remote-rule` +- **AND** it SHALL fetch the corresponding recipe to perform the authoring + +#### Scenario: Every destination resolves to a recipe + +- **WHEN** any destination `route` can name is fetched +- **THEN** a recipe of that exact name SHALL exist + +#### Scenario: Service generation is offered only where it is a choice + +- **WHEN** the rule is expressible locally AND the user is logged in +- **THEN** `route` MAY offer `create-remote-rule` as an alternative and ask the user +- **AND WHEN** the user is not logged in, or the rule is not expressible locally +- **THEN** `route` SHALL NOT pose service generation as a choice, because it is not one + +#### Scenario: A logged-in runtime request routes straight to the service + +- **WHEN** the rule requires the runtime engine AND the user is logged in +- **THEN** `route` SHALL name `create-remote-rule` +- **AND** no recipe SHALL forward the agent from one destination to another + +#### Scenario: A logged-out runtime request reaches the explanation + +- **WHEN** the rule requires the runtime engine AND the user is not logged in +- **THEN** `route` SHALL name `create-runtime-rule` + +#### Scenario: The engine is decided without a second fetch + +- **WHEN** the agent follows `route` +- **THEN** it SHALL arrive at an engine-specific recipe without fetching a separate engine-selection topic + +### Requirement: Static recipe authors a verified local ast-grep rule + +The CLI SHALL provide a `create-sg-rule` help recipe that instructs the agent to author a +local ast-grep rule on-device, without calling the Taskless service, and to +verify it against the user's success and failure cases before reporting success. +The recipe SHALL produce the canonical on-disk rule shape and paths used by remote +generation so that `check`, `improve`, and `verify` see a single dialect. + +The recipe SHALL be named for the artifact it produces rather than for a trust tier. "Static" describes when a rule runs, which is a different axis from which engine enforces it, and naming the ast-grep authoring path after the tier taught the conflation that engine selection exists to correct. + +#### Scenario: Local authoring without the service + +- **WHEN** the agent follows `create-sg-rule` +- **THEN** it SHALL write the rule on-device without requiring login or the + Taskless API + +### Requirement: Available code context outranks the phrasing of the request + +Where code or diff context is available, `route` SHALL weigh the concrete syntactic form present in the repository above the wording of the request, since the same request routes differently depending on the form the code actually takes. + +This bound the standalone engine-selection topic. That topic is gone, but the reasoning is not — it now binds the place the decision is actually made. + +#### Scenario: Concrete form changes the engine + +- **WHEN** a rule is statically correlatable in the form the repository actually contains +- **THEN** `route` selects `create-sg-rule` +- **AND WHEN** the equivalent rule requires normalizing a captured value to match a declaration elsewhere +- **THEN** it selects a runtime destination, despite an identically phrased request + +### Requirement: Ambiguity resolves to an engine known to be available + +When no engine is clearly indicated, `route` SHALL direct the reader to choose an engine whose availability can be asserted in the situation at hand, and to give that availability as the reason for the call. It SHALL NOT name a fixed fallback engine. Both `sg` and `vale` ship as platform binaries, so either can be the missing one on an unsupported architecture or where an install was blocked; server-side the constraint is different again, `sg` being the only ungated route. A named default is wrong in whichever of those situations it failed to anticipate, which is why the requirement is stated as a property rather than as a fact about any one engine. + +#### Scenario: Ambiguous request resolves to an assertably available engine + +- **WHEN** the available context does not disambiguate which engine can enforce a rule +- **THEN** `route` selects an engine whose availability it can assert, and states that availability as the reasoning that made the call close + +#### Scenario: The default is never an unavailable engine + +- **WHEN** an engine is unavailable in the current environment, such as the Vale binary being absent +- **THEN** the ambiguity default SHALL NOT name it + +### Requirement: Existing recipe authors in the detected linter's dialect + +The CLI SHALL provide a `create-legacy-rule` help recipe that instructs the agent to author a rule in a linter already detected in the repository, expressed in that tool's own dialect. The recipe SHALL direct the agent to source authoring knowledge first from the repository's own existing rules and only then from the agent's own web research. The recipe SHALL NOT embed or rely on a Taskless-maintained catalog of linter rules. + +The recipe is named for the artifact it produces. "Existing" described the repository's state rather than the rule being written, which is not something an agent can address by name. + +#### Scenario: Repo-first knowledge sourcing + +- **WHEN** the agent follows `create-legacy-rule` +- **THEN** it SHALL read the repository's own rules for that linter before consulting any external source + +### Requirement: Remote recipe collects inputs and delegates to the service + +The CLI SHALL provide a `create-remote-rule` help recipe that instructs the agent to gather the inputs required to call the Taskless service and to invoke the existing rule generation backend, which runs the service-side classifier and returns either a static or a runtime rule. The recipe SHALL require authentication and SHALL NOT itself decide static versus runtime. + +#### Scenario: The remote recipe requires authentication + +- **WHEN** the agent follows `create-remote-rule` while logged out +- **THEN** the recipe SHALL direct the agent to `auth` rather than calling the service + +## ADDED Requirements + +### Requirement: Trust tier is not an engine-selection input + +Engine reasoning SHALL NOT treat login, reconciliation, or signing as inputs to the engine choice: `sg` and `vale` are both static-tier, and only `runtime` carries those concerns, so trust tier is a distinct axis from which engine can express a rule. + +#### Scenario: Trust tier is not an engine-selection input + +- **WHEN** the reasoning distinguishes `sg` from `vale` +- **THEN** it does so on the prose-versus-structure axis, not on any auth, reconcile, or signing property, since both are static-tier + +### Requirement: Engine reasoning lives in route and in each destination + +The engine criterion SHALL be stated once, in `route`'s destination table, which is where the comparison between engines is made. It SHALL NOT be stated in a separate chooser topic, and SHALL NOT be restated in the destination recipes. + +One statement is the point. A criterion copied into each destination is five copies of one test, and the first edit to any of them is a divergence nobody notices — the drift this merge exists to remove, reappearing one level down. Destinations orient the reader to their own scope instead, which needs nothing about the other engines. + +#### Scenario: The comparison lives in one place + +- **WHEN** the embedded recipe set is inspected +- **THEN** exactly one recipe SHALL state the criterion distinguishing the engines from each other + +#### Scenario: No separate chooser topic exists + +- **WHEN** the embedded recipe set is inspected +- **THEN** there SHALL be no topic whose only purpose is selecting among engines + +## REMOVED Requirements + +### Requirement: Engine selection is a separate axis from authoring destination + +**Reason**: The separation cost an agent two fetches and a handoff to answer one question. Whether a rule is expressible locally and which engine can express it are answered from the same evidence, so reading it twice added a failure point without adding information. + +**Migration**: The engine criterion moves into `route` and into each `create-*-rule` recipe (see "Engine reasoning lives in route and in each destination"). The one part of this requirement that was not about the split — that trust tier is a distinct axis — is retained as its own requirement above. Consumers that fetched `engine-selection` read the destination recipes instead, which is the surface `TOPICS` now exports. + +### Requirement: An engine-selection topic states which engine can enforce a rule + +**Reason**: The topic it required no longer exists as a separate recipe. + +**Migration**: Its content — the three engine definitions, evidence-before-answer, and the boundary cases — moves into `route` and the `create-*-rule` recipes. The requirements that constrained the reasoning itself ("Available code context outranks the phrasing of the request", "Ambiguity resolves to an engine known to be available") remain in force and now bind `route` and the destination recipes rather than a standalone topic. diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-vale-rule-engine/spec.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-vale-rule-engine/spec.md new file mode 100644 index 00000000..91816527 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/specs/cli-vale-rule-engine/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: The scaffolded Vale config carries no section + +The `.vale.ini` written when a project is scaffolded SHALL contain `StylesPath` and `MinAlertLevel` and no section. A project therefore lints nothing with Vale until someone scopes something deliberately. + +An unscoped `[*]` applies every enabled rule to every file the walk reaches, which makes the default the most aggressive scope available rather than the narrowest. Scope is the author's decision, and the scaffold SHALL NOT make it on their behalf. + +#### Scenario: A freshly scaffolded project reports nothing + +- **WHEN** `check` runs against a scaffolded project with a rule file present and no section added +- **THEN** Vale SHALL report no findings +- **AND** the run SHALL NOT be reported as an engine failure + +#### Scenario: Scope is added by the author + +- **WHEN** an author scopes a rule by adding a section +- **THEN** only files matching that section SHALL be subject to it + +### Requirement: Vale diagnostics on a successful run are surfaced as notices + +When Vale exits zero and writes to stderr, the CLI SHALL surface that output as a notice on the check result. A notice SHALL NOT affect the exit code. + +This is a precondition of the section-less scaffold rather than an independent improvement. With no section to copy, the likely first edit is a rule assignment at the top level of the file, which Vale reports as ignoring — on stderr, with a zero exit and a well-formed empty result. Discarding that output leaves the author with a rule that verifies, runs, and reports nothing, which is the silent-disable failure this engine's design exists to prevent. + +#### Scenario: An ignored rule assignment reaches the user + +- **WHEN** `.vale.ini` enables a rule outside any section and `check` runs +- **THEN** the CLI SHALL surface Vale's diagnostic that the assignment was ignored + +#### Scenario: A diagnostic does not fail the check + +- **WHEN** Vale exits zero, writes a diagnostic to stderr, and reports no findings +- **THEN** the check SHALL exit zero + +#### Scenario: Silence stays silent + +- **WHEN** Vale exits zero and writes nothing to stderr +- **THEN** the CLI SHALL add no notice diff --git a/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/tasks.md b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/tasks.md new file mode 100644 index 00000000..2aabb874 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-agent-command-and-vale-authoring/tasks.md @@ -0,0 +1,70 @@ +# Tasks + +## 1. Rename the command + +- [x] 1.1 Rename `packages/cli/src/commands/help.ts` to `agent.ts` and the exported command to `agent`. Register it in `src/index.ts` +- [x] 1.2 Remove the positional-join resolution (`positionals.join("-")`). Accept at most one positional; more than one is an error rather than a joined key. The unknown-topic message points at `taskless agent` +- [x] 1.3 Keep the telemetry event name `cli_help` or rename it deliberately — decide once and record it, since dashboards key on it. If renamed, note it in the changeset alongside the `TOPICS` break + - **Decision: keep `cli_help`.** Renaming it in the same change that breaks the `TOPICS` export would take the dashboards dark for a reason unrelated to this change, and agent-call volume needs to stay visible under the existing event. The event name is not part of any agent-facing contract, so it can be renamed later on its own. Recorded as a comment at the capture site in `agent.ts`; nothing to add to the changeset +- [x] 1.4 Update `help-extensions.test.ts`, `help-routing-telemetry.test.ts`, `anonymous-flag.test.ts`, `onboard.test.ts`, and `cli.test.ts` to invoke `agent` + - Also required, not listed: `help-telemetry.test.ts` (imports `createHelpCommand` directly), `prompts.test.ts` (spawns `binPath help ` for the parity test), and `cli-run.test.ts` (its `resolveCommandName` case named `help`) + +## 2. Rename and add the authoring topics + +- [x] 2.1 `git mv` `help/static.txt` → `help/create-sg-rule.txt`; retitle its header and rewrite its Goal to name the artifact rather than the tier + - Also folded in the material from `rule-create.anonymous.txt` that `static.txt` lacked: the upstream-schema pointer, the optional-field list, and the per-layer verify error table +- [x] 2.2 `git mv` `help/existing.txt` → `help/create-legacy-rule.txt`; retitle and update its header +- [x] 2.3 Flatten the `rule-*` topics to verb-noun single tokens (`rule-create` → `create-rule`, `rule-improve` → `improve-rule`, `rule-delete` → `delete-rule`, `rule-verify` → `verify-rule`), including their `.anonymous` variants. Decide `rule-meta` and `rule` deliberately — they are not creation verbs and may keep their names + - **`rule-create` does not become `create-rule`.** 2.5a consumes it: the API-backed text *is* the service procedure, so it became `create-remote-rule`. A `create-rule` topic would have been a third name for the same thing + - **`rule-create.anonymous.txt` is deleted, not renamed.** It duplicated `static.txt` — both are "author an ast-grep rule locally, no service call" — and carrying it forward as `create-remote-rule.anonymous.txt` would have meant "the local variant of the remote recipe", which is the contradiction `route` exists to resolve. Its unique material moved into `create-sg-rule.txt` (2.1), and `rule create --anonymous` now points there. This is the one place group 2 deviates from the task text as written + - **`rule-meta` and `rule` keep their names.** Neither is a creation verb, both are already single tokens, and `rule`'s broken table is group 3's opening item +- [x] 2.4 Author `help/create-vale-rule.txt`: the three artifacts (style file, `.vale.ini` section, `pass/`/`fail` fixtures), that the scaffold ships section-less so the first rule writes the first scope, and that a rule enabled outside a section is ignored by Vale. State the evidence that makes `vale` the right engine for a rule, since no chooser topic states it any more. Cross-reference `verify-rule` +- [x] 2.5 Author `help/create-runtime-rule.txt` as the logged-**out** path: what a runtime rule is, where its `check.ts` lives, and why executing code requires login, reconciliation, and signing when the static tiers do not. Point at `auth` for obtaining access rather than restating it. It must not forward to another authoring recipe +- [x] 2.5a Merge `help/remote.txt` and `help/rule-create.txt` into `help/create-remote-rule.txt` (no `.anonymous` variant — see 2.3). A content merge, not a rename: both texts have material that survives, and the result must read as one procedure rather than two concatenated +- [x] 2.6 Rewrite `help/route.txt` to read login state early and classify into the five `create-*-rule` destinations, applying the engine reasoning inline rather than deferring to a second fetch. Offer `create-remote-rule` only where it is a genuine choice — locally expressible AND logged in. A logged-in runtime request routes straight to `create-remote-rule`; a logged-out one to `create-runtime-rule`. It must name a command the agent can run verbatim +- [x] 2.7 Merge `help/engine-selection.txt` into `help/route.txt` and delete it. Its three engine definitions, evidence-before-answer procedure, and boundary cases move into `route`'s destination table — stated once, not copied into the destinations +- [x] 2.7a Give every `create-*-rule` recipe the same opening orientation line: what topic this is, what it helps you write, and revisit routing if that is not what you need. Fixed shape across all five so an agent recognises it; scope only, never the comparison between engines +- [x] 2.8 Re-home the engine-reasoning requirements that survive the merge — "Available code context outranks the phrasing of the request" and "Ambiguity resolves to an engine known to be available" now bind `route` and the destinations. Update `help-extensions.test.ts`, which asserts against the standalone topic + +## 2b. Prove the authoring recipes by executing them + +The recipes are the deliverable, and a recipe that reads well to its author while producing the wrong artifact is exactly what reviewing the prose cannot catch. Execute them instead. + +- [x] 2b.1 `pnpm --filter @taskless/cli build:dev`. This target exists for this: `TASKLESS_BUILD_TARGET=dev` bakes `__TASKLESS_CLI__` as an **absolute path** to `dist-dev/index.js`, so recipe text carries a command that actually runs from any directory. Testing against `dist/` instead would exercise a recipe no reader ever receives, since theirs says `npx @taskless/cli` +- [x] 2b.2 Build the harness: scaffold a throwaway project in a temp directory using the built CLI, so the sandbox is a real `taskless init` scaffold — section-less `.vale.ini`, empty `vale/rules/` — and not a hand-made approximation of one +- [x] 2b.3 Hand a **fresh, non-forked** subagent only three things: the recipe text, the sandbox path, and a rule intent stated in plain words. It must NOT have repository access. With it, the agent finds the existing `no-simply.yml` and the mixed-engine fixture and copies them, and the loop tests our fixtures rather than our writing +- [x] 2b.4 Check the artifacts mechanically: a style file at `.taskless/vale/rules/.yml` with valid `extends`/`message`/`level`; a **scoped section** in `.vale.ini` enabling `rules.`; fixtures in both `pass/` and `fail/`. Then the assertion that matters — `check` reports the finding, and `verify` passes + - **Done except "`verify` passes", which cannot be satisfied as written.** `verifyValeRule`/`verifyValeRules` have no CLI caller: `taskless rule verify ` routes to `src/rules/verify.ts`, which is ast-grep only. There is no way for an agent to verify a Vale rule from the CLI today. The harness asserts on `check` over each bucket instead, which is what `create-vale-rule` now tells authors to do. **Open decision for the user** — wire `rule verify ` to dispatch by owning engine, or file it alongside #99/#101. Until then the recipe says plainly that nothing validates the fixture layout, rather than claiming a check that does not run +- [x] 2b.5 Iterate across three intents that exercise different extension points — one `existence`, one `substitution` (prefer X over Y), one `capitalization` (headings, product names). Everything in this repo today is `existence`, so a recipe drafted from our own examples teaches token blocklists and nothing else. Vale has eleven extension points and most real prose rules are not blocklists +- [x] 2b.6 Every failure is a defect in the prose, not in the agent. Fix the recipe and re-run with a fresh agent. Converged when an agent, given an intent the recipe never names, produces a rule that fires on its `fail` fixture and stays quiet on its `pass` fixture, first try, uncorrected +- [x] 2b.7 Keep the iteration log — what failed, what changed, what finally held. It is the evidence the prose works, and the only part of this a reviewer can check without rerunning the loop +- [x] 2b.8 Run the same harness over `create-sg-rule` as a control. It documents a flow that already works, so a failure there means the harness is wrong rather than the recipe + +## 3. Sweep the cross-references + +- [x] 3.1 Replace every `taskless help ` occurrence with `taskless agent ` across `src/help/*.txt`, `src/**/*.ts`, `skills/taskless/SKILL.md`, `README.md`, and `packages/cli/README.md` (~306 occurrences, 77 files). Leave `CHANGELOG.md` alone — it is a historical record +- [x] 3.2 Update every reference to a renamed topic (`static`, `existing`, `rule create`, …) to its new single-token name +- [x] 3.3 Add a test asserting no shipped recipe contains the string `taskless help`, and that every topic named in a recipe's See Also resolves to an embedded file. A stale cross-reference is otherwise invisible until an agent runs it + +## 4. Update the export surface + +- [x] 4.1 `TOPICS` becomes `["create-sg-rule", "create-vale-rule", "create-runtime-rule"]` and no longer exports `engine-selection`; move the renamed authoring topics through `INTERNAL_TOPICS` as their membership requires, keeping the two lists disjoint and jointly exhaustive over the recipe files +- [x] 4.2 Update `prompts.test.ts` — the membership test compares against the files on disk, so it fails until the rename is complete in both places +- [x] 4.3 Write the changeset as **MINOR** — pre-1.0, backwards-incompatible is MINOR — naming the removed topic names explicitly and stating that `@taskless/cli/prompts` consumers break on upgrade rather than at build time + +## 5. Scaffold and diagnostics (ships together) + +- [x] 5.1 `VALE_CONFIG_CONTENT` in `0004-vale-engine.ts` drops its `[*]` section, leaving `StylesPath` and `MinAlertLevel` +- [x] 5.2 `runVale` captures stderr on a zero-exit run and returns it as a notice on the `ok` outcome; `runValeEngine` forwards it to `DispatchResult.notices`. A notice must not touch the exit code + - **Pulled forward out of order, deliberately.** 2b tests `create-vale-rule` against a scaffolded project, and the recipe's central claim is that the scaffold ships section-less. Running the harness against a scaffold that still wrote `[*]` would have tested a recipe nobody will receive. Measured end to end afterwards: section-less scaffold + a top-level `rules. = YES` now prints `Notice: Vale reported while running: W101 'rules.no-simply' isn't a core option; Vale is ignoring it.` and exits 0 +- [x] 5.3 Test that a rule enabled outside a section produces a notice containing Vale's `W101` text and exits zero — this is the pairing that keeps 5.1 from reintroducing a silent disable +- [x] 5.4 Extend the mixed-engine integration test: a scaffolded project with a rule file and no section reports nothing and does not fail; adding a section makes the same rule fire + - **Superseded immediately above this change in the stack.** `self-contained-rules` gives every Vale rule its own config, so "a rule file and no section" stops being a quiet no-op and becomes a `verify` error: nothing scopes it, so it can never run, and saying so is better than reporting nothing. This test is correct for the layout this change ships and is replaced there rather than carried forward. The W101 pairing in 5.3 does survive, because an assignment can still be written above its own matcher. + +## 6. Verify + +- [x] 6.1 `pnpm typecheck`, `pnpm lint`, `pnpm --filter @taskless/cli build`, `pnpm --filter @taskless/cli test` +- [x] 6.2 **Rehearse `route` against a fresh agent.** Hand it the text with no prior context and a request to author a rule, then check which destination it names and why. Unlike the authoring recipes (2b), `route` produces a decision rather than artifacts, so the plan it describes is the only thing there is to check. Cover one case per destination, including a logged-out runtime request +- [x] 6.2a Run `taskless agent` with no argument, with each renamed topic, and with a removed name, confirming the index lists the new vocabulary and a removed name exits non-zero +- [x] 6.3 `pnpm openspec validate --all --strict` (note: `cli-rules` and `cli-update-engine` fail on `main` already and are unrelated) +- [x] 6.4 Archive the change diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/.openspec.yaml b/openspec/changes/archive/2026-08-15-self-contained-rules/.openspec.yaml new file mode 100644 index 00000000..4af86417 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-14 diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/design.md b/openspec/changes/archive/2026-08-15-self-contained-rules/design.md new file mode 100644 index 00000000..b157b63b --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/design.md @@ -0,0 +1,159 @@ +## Context + +`0004` partitioned `.taskless/` by engine: `sg/rules` + `sg/rule-tests`, `vale/rules` + `vale/rule-tests`, `runtime/rules` + `runtime/rule-tests`. Vale additionally carries a single committed `.vale.ini` where every rule declares its scope, with `tskl) = ` breadcrumbs so tooling can find which matchers belong to which rule. + +`agent-command-and-vale-authoring` then wrote the Vale authoring recipe and executed it five times against sandboxed agents with no repository access. Every silent failure those runs found was in the shared config, and the recipe grew a debug ladder whose first four rungs are all "did you edit the shared file correctly". + +Everything below rests on measurement against the bundled ast-grep 0.41.0 and Vale 3.17.1. + +**ast-grep** + +- `ruleDirs` **recurses**, and every `.yml`/`.yaml` beneath is parsed as a rule. A `tests/` directory inside a rule directory fails the whole run with `Fail to parse yaml as RuleConfig: missing field 'language'`. +- `__tests__/` fails the same way. A **dot-directory is skipped**: `.tests/` inside a rule directory leaves `sg scan` clean. +- `sg test` reads `testDir: /.tests` normally and writes snapshots to `/.tests/__snapshots__/`. + +**Vale** + +- `rules//.yml` resolves as check `.` under `StylesPath` pointing at the rules tree; under `StylesPath = .` it resolves to nothing. +- Unknown top-level keys in a style are rejected (`E201 has invalid keys`), so scope cannot ride inside the style file. +- A `.yml` sidecar inside a style directory is loaded as a rule and fails `E201` when the style is enabled wholesale; a non-`.yml` file is ignored. A `.tests/` directory is harmless even when it contains a `.yml`. + +**runtime** + +- Discovery reads the rule directory non-recursively for `*.yml`, so any subdirectory is already invisible to it. + +## Goals / Non-Goals + +**Goals:** + +- One directory per rule, the same shape for every engine, so "where is this rule" has one answer. +- No file written by more than one rule's author. +- A rule addressable by path, so `verify`/`test` need no id lookup and no ambiguity rule. +- Silent-disable failure modes removed by construction rather than documented. + +**Non-Goals:** + +- Supporting both layouts. There is one legal shape; `0005` moves projects to it. +- A writer for rule configs. The agent authors every committed file; assembly only concatenates. +- Changing what any engine can express. This is where files sit, not what rules do. + +## Decisions + +### D1 — One rule, one directory, every engine + +``` +.taskless/rules/sg/no-eval/ + no-eval.yml + .tests/no-eval-20260101-test.yml + +.taskless/rules/vale/no-simply/ + no-simply.yml + .vale.ini + .tests/pass/ok.md + .tests/fail/bad.md + +.taskless/rules/runtime/unused-exports/ + check.ts + captures/exported-symbol.yml + .tests/… +``` + +The engine is a path segment, so it is still read from position and never from content — the rule `dispatch` already follows. What changes is that a rule is now **one** path rather than two, which is what makes `verify ` and `test ` possible without an id lookup. + +_Alternative rejected:_ mirrored `rules/` and `tests/` trees. Uniform and requires no cleverness, but a rule is two paths again, which is the thing being fixed. + +### D2 — Tests live in `.tests/`, and the dot is load-bearing + +`.tests/` rather than `tests/` because ast-grep's `ruleDirs` recurses and parses every `.yml` beneath as a rule. Measured, `tests/` and `__tests__/` both hard-fail the scan; a dot-directory is skipped, and `sg test` still reads it when `testDir` names it. + +This is a dependency on undocumented behavior and should be recorded as one. Three things make it acceptable: + +- **The failure is loud.** If ast-grep stops skipping dot-directories, the scan fails with a parse error naming the file. It does not silently reinterpret a test as a rule, and it does not silently disable anything. +- **A test pins it.** A fixture with a rule directory containing `.tests/` asserts the scan stays clean, so the assumption is checked on every run rather than remembered. +- **The binary is version-pinned.** `@ast-grep/cli` and every platform package are pinned to an exact version (`0.41.0`), not a range, so this behavior cannot change under a project without a deliberate dependency bump. That bump is where the pinning test fires, which makes the discovery a migration task with a changelog to read rather than a mystery in someone's CI. + +The cost is real: dot-prefixing hides tests from a casual `ls`, and tests are the part of a rule most worth reading. + +_Alternative rejected:_ materialize a rules-only tree and point `ruleDirs` at it, keeping a plain `tests/`. It reaches the same authored layout with no undocumented dependency, and there is precedent — runtime already materializes `.taskless/.run/`. It was rejected for cost: a third assembly step, plus rule paths in ast-grep's own diagnostics pointing at a generated copy rather than the file the author edits. Worth revisiting if the dot-directory assumption ever breaks. + +### D3 — Per-rule configs, assembled per run, gitignored + +Vale accepts exactly one `--config`, and ast-grep one `sgconfig.yml`, so per-rule configuration has to reach a single file before either tool can be invoked. The committed source of truth is per-rule; the file handed to the tool is assembled and gitignored — the same treatment the ephemeral `sgconfig.yml` already receives. + +**Assembly order is a correctness constraint.** Vale's precedence is positional: across matchers the last wins, within one matcher the first assignment wins. Assembly SHALL therefore be deterministic — rules ordered by id, each rule's own matcher order preserved verbatim — or a rule's effective scope would depend on directory iteration order. + +A consequence worth stating: a rule cannot override another rule's matchers, because it cannot know its position. That is the coupling per-rule configs remove. + +_Alternative rejected:_ invoke the tool once per rule. Vale takes one `--config`, so N rules is N process spawns per check and N JSON payloads to merge. + +_Alternative rejected:_ commit the assembled file. It is the shared write-contended file again, arriving by a different route. + +### D4 — No per-rule config for ast-grep + +Vale needs a per-rule config because its scoping cannot live in the style file — measured, `E201`. ast-grep's scoping (`files`, `ignores`) lives *in* the rule, so the equivalent slot has nothing to hold. + +An empty `sg-config` per rule would be symmetry as decoration: a file every author must create, no author ever fills, and every reader must learn to ignore. The symmetry that does hold is one level up — both engines' project configs are assembled and gitignored. + +### D5 — `StylesPath` follows the layout + +`StylesPath` points at the Vale rules tree so each rule directory is a style, giving check name `.`. This is not a free choice: under `StylesPath = .` a nested rule file resolves to nothing at all. + +It reverses the note in `0004`, which calls `StylesPath = rules` wrong. That was correct **for the flat layout**, where pointing StylesPath at `rules/` makes each rule file a style directory with no rules in it and every check silently resolves to nothing. The same setting is right for one layout and silently wrong for the other, so the note must be rewritten rather than deleted — a future reader who finds it will otherwise "fix" it back. + +### D6 — `verify` and `test` are separate, and `verify` is a layer of `test` + +`verify ` answers "is this a well-formed rule"; `test ` answers "does it behave". They split because their preconditions differ: an agent mid-authoring has a rule and no tests yet, and needs the first before it can write the second. + +`test` runs `verify` first and stops on failure. Today the composition is backwards — fixture coverage short-circuits before Vale parses the rule, so a rule with an invalid `level` and a half-written fixture set reports `fixtures: "fail-only"` and never surfaces `'level' must be one of [suggestion warning error]`. The error the author needs is hidden behind the one they do not. + +### D7 — Paths, not ids + +A path names one thing; an id does not. The same id can exist under `sg` and `vale`, which is why the id-addressed command needed an ambiguity error at all. Removing the addressing scheme removes the error case. + +_Alternative rejected:_ keep `rule verify ` as an alias. It preserves the ambiguity case to save typing, in a command invoked from a recipe that can carry a path just as easily. + +### D9 — The legacy read paths are removed, not renamed + +`.taskless/rules/` is the new root. It is also `LEGACY_RULES_DIRECTORY`, the pre-`0004` flat location — the same string, now meaning something else. Found while implementing: the two collide exactly. + +The legacy read paths are removable rather than renameable because they are unreachable. `ensureTasklessDirectory` runs migrations before anything reads a rule, so `0004` has already moved `.taskless/rules/*.yml` to `sg/rules/`, and `0005` moves it again. A "legacy" lookup under the new layout would resolve `.taskless/rules/.yml` inside a tree whose real contents are `rules///` — reading the new root as if it were the old flat directory. + +So `LEGACY_RULES_DIRECTORY` and `LEGACY_RULE_TESTS_DIRECTORY` go, along with their readers in `verify.ts`, `detect/scan.ts`, and `commands/rules.ts`. Their error messages, which name the legacy path as a place a rule might be, go with them. + +_Alternative rejected:_ keep them under a new constant name. It preserves a fallback for a state migrations guarantee cannot exist, and the fallback would now point into the live tree. A stale read path that resolves to a real directory is worse than no fallback. + +### D8 — `captures/`, not `matchers/` + +Runtime's ast-grep capture rules move to `captures/`. "Matcher" now has a precise meaning in the Vale spec — a `[]` ini section — and one word for two unrelated concepts in one `.taskless/` tree is a cost paid at every future reading. + +### D10 — A `consistency` rule's id must be word characters only + +Vale compiles a `consistency` rule's own name into its pattern as a Go RE2 named capture group (`(?P…)`), and RE2 requires a group name to be word characters. Measured against Vale 3.17.1, an id containing `-` fails that file with `E201 … invalid group name`, and because Vale reads one config for the whole run it takes **every** Vale rule in the project down with it: 9 rules, 0 findings, one error. + +Found while re-running the recipe's own worked rules under this layout (task 5.6), where the id became the directory name and the check name at once. + +The layout makes this sharper rather than causing it, since the id is now three things at once, so it is caught in `verify` and stated in the recipe. Kebab-case remains correct for the other ten extension points; only `consistency` is constrained. + +## Risks / Trade-offs + +- **The dot-directory assumption is undocumented** → mitigated by a loud failure mode, a pinning test, and an exact version pin on the binary, so a change arrives with a deliberate upgrade rather than silently (D2). Materialization is the recorded fallback. +- **Assembly is a new failure surface** → a bug there disables rules silently, which is the failure this engine's design exists to prevent. Mitigated by asserting the assembled artifact byte-for-byte and asserting stability across runs. +- **Two migrations touch the same tree in one stack** → `0004` and `0005` are both unreleased and both in this stack, so every consumer runs them as one upgrade and never observes the intermediate layout. The risk is to this repository's own fixtures, which tests cover. +- **The recipe changes again** → `create-vale-rule` teaches the flat layout in detail and its nine worked rules were verified against it. The rule bodies are unaffected; only where the file sits and where scope is declared. The 2b harness re-runs against the new text. +- **Tests are less visible** → dot-prefixing hides the part of a rule most worth reading. `example/` exists partly to counteract this by showing a full rule directory in a place nothing hides. + +## Migration Plan + +`0005` layers on `0004`; neither is released, and both ship in this stack, so consumers run them as a single upgrade. + +0. Note that `0004` has already emptied `.taskless/rules/` by moving it to `sg/rules/`, so the new root is free before `0005` writes into it. `0005` SHALL assert this rather than assume it: a top-level `*.yml` still sitting in `.taskless/rules/` means `0004` did not complete, and writing engine directories around it would interleave two layouts in one tree. +1. Move `/rules/.yml` → `rules///.yml`; for runtime, `runtime/rules//` → `rules/runtime//` with its `*.yml` capture rules into `captures/`. +2. Move `/rule-tests/*` → `rules///.tests/`, preserving each engine's internal test shape. +3. Split the committed `vale/.vale.ini`: each matcher carrying `tskl) rule = ` moves into that rule's own `.vale.ini`. +4. A matcher with **no** `tskl) rule` breadcrumb cannot be attributed. Leave it and report it rather than guessing an owner or dropping it — an unattributable matcher is a user's hand edit, and discarding it silently changes what their check reports. +5. Delete the committed `vale/.vale.ini` and `sg/sgconfig.yml`; gitignore both assembled paths. +6. Content is preserved byte-for-byte throughout: runtime capture bytes determine server-side reconciliation hashes, so a rewrite would invalidate every signature. + +## Open Questions + +- None outstanding. diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/proposal.md b/openspec/changes/archive/2026-08-15-self-contained-rules/proposal.md new file mode 100644 index 00000000..167bc6b3 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/proposal.md @@ -0,0 +1,48 @@ +## Why + +A rule is spread across locations today, and for Vale one of them is shared by every rule in the project. The style is `vale/rules/.yml`, the fixtures are `vale/rule-tests//`, and the scope — the part deciding whether the rule runs at all — is a matcher inside the single committed `vale/.vale.ini`. + +That shared file is where the failures are. Five sandboxed harness runs against `create-vale-rule` found silent failures in that step and nowhere else: an assignment above the first matcher, a glob that missed the fixture's extension, three names that had to agree with nothing reporting when they didn't. A single write-contended config is the wrong shape whether it is hundreds of agents or one agent and a year of rules. + +The same reasoning generalizes past Vale. `sg` and `runtime` rules are also split between a `rules/` tree and a parallel `rule-tests/` tree, so no engine has a single path that means "this rule". Fixing Vale alone would leave three layouts to reason about instead of one. + +Now, because no Vale rules exist yet and `0004` is unreleased. Once either is true in the field, this is a migration with users attached. + +## What Changes + +- **BREAKING** One directory per rule, identical across engines: `.taskless/rules///`, holding the rule, any config that engine requires, and its tests in `.tests/`. +- **BREAKING** A Vale rule carries its own `.vale.ini` with its matchers, exclude directives, and `tskl)` metadata. `check` assembles the run config from every rule's config and gitignores the result. +- **BREAKING** `sgconfig.yml` becomes assembled and gitignored too, pointing `ruleDirs` at the rules tree and `testConfigs` at each rule's `.tests/`. +- **BREAKING** `StylesPath` becomes `rules/vale`, which is what makes each rule directory a Vale *style*. Measured: `/.yml` resolves as check `.` under that StylesPath and resolves to nothing under `StylesPath = .`. +- **BREAKING** `rule verify ` is removed. An id does not name one thing — the same id can exist under two engines — so the id form needed an ambiguity error the path form does not. +- Two path-addressed commands: `verify ` checks a rule has its required components, `test ` runs its tests. Both accept a rule directory or any directory above it, and both run in the rule generation loop. +- `verify` becomes a prerequisite layer of `test`, so a malformed rule reports its own error instead of a fixture complaint. +- Runtime's capture rules move to `captures/`, freeing "matcher" to mean one thing — a Vale `[]` ini section. +- `create-vale-rule` is rewritten against the layout and its worked examples re-verified. +- A committed `example/` project — README, an HTML and a CommonJS file, and a `.taskless/` with one Vale rule and one ast-grep rule — so a reader can see an install rather than infer it from tests that build their own fixtures. + +## Capabilities + +### New Capabilities + +- `cli-rule-validation`: the path-addressed `verify` and `test` commands — how a path resolves to an engine, what each checks per engine, and how they compose in the generation loop. + +### Modified Capabilities + +- `cli-rule-format`: the canonical on-disk shape of every rule, the engine-per-directory rule, and the "committed native config, never generated" requirement that an assembled config now breaks. +- `cli-vale-rule-engine`: the rule is a directory with its own config; the run config is assembled and gitignored; `StylesPath` changes; matcher precedence must survive an assembly step. +- `cli-agent-authoring`: `create-vale-rule` teaches the layout and names `verify`/`test`. **This capability is introduced by `agent-command-and-vale-authoring` (PR #102) and is not yet in `openspec/specs/`**, so this delta modifies a requirement that exists only once #102 archives — expected for a stacked change. + +## Impact + +- `src/filesystem/migrations/0005-*` — layers on `0004` rather than replacing it. Both are unreleased and both are in this stack, so every consumer runs them as one upgrade and never observes the intermediate layout. +- `src/rules/engines.ts` — `ENGINE_LAYOUTS` becomes one rule-directory rule plus per-engine contents. +- `src/rules/vale/run.ts`, `verify.ts` — assembly; the isolating verify config derives from the rule's own. +- `src/rules/dispatch.ts` — `hasValeRules` looks for flat `*.yml` and would read a directory-shaped rule set as "no rules configured". +- `src/filesystem/sgconfig.ts` — assembled rather than committed. +- `src/rules/runtime/discover.ts` — capture rules move to `captures/`. +- `src/commands/rules.ts` — `rule verify` removed; new `verify` and `test` commands. +- `src/help/create-vale-rule.txt`, `verify-rule.txt`, and every recipe naming `rule verify`. +- `.taskless/.gitignore` — the two assembled configs. +- `example/` — new, plus the root tooling ignores that would otherwise walk its deliberately-wrong fixture prose. +- Reverses the `.vale.ini`-writer non-goal in `agent-command-and-vale-authoring/design.md`, which assumed a hand-authored shared config. diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-agent-authoring/spec.md b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-agent-authoring/spec.md new file mode 100644 index 00000000..37066d7a --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-agent-authoring/spec.md @@ -0,0 +1,54 @@ +## MODIFIED Requirements + +### Requirement: The Vale authoring recipe covers rule, scope, and fixtures + +The `create-vale-rule` recipe SHALL instruct the agent to produce three artifacts, and SHALL state that a rule is incomplete without all three: + +1. A Vale style file at `.taskless/rules/vale//.yml`. +2. That rule's own `.taskless/rules/vale//.vale.ini`, declaring the matchers that scope it and enabling it as `. = YES`. +3. `pass/` and `fail/` fixture documents under `.taskless/rules/vale//.tests/`. + +The recipe SHALL state that scope is declared in the rule's own config, that no shared file is edited, and that the project-wide config is assembled rather than authored. + +It SHALL direct the agent to check its work by running `verify` and then `test` against the rule's directory path. + +The previous version of this requirement taught the agent to add a matcher to a single project-wide `.vale.ini`. Executing that recipe against sandboxed agents found every one of its silent failures in that step and nowhere else — an assignment above the first matcher, a glob that missed the fixture extension, three names that had to agree with nothing reporting when they didn't. The layout change removes the step rather than documenting it further. + +#### Scenario: Authoring produces all three artifacts + +- **WHEN** the agent follows `create-vale-rule` +- **THEN** it writes the style file, the rule's own config with a scoping matcher, and both fixture buckets + +#### Scenario: No shared file is edited + +- **WHEN** the agent scopes a rule +- **THEN** it writes matchers into that rule's own config +- **AND** it SHALL NOT be directed to edit a project-wide Vale config + +#### Scenario: The recipe names the commands that check the work + +- **WHEN** the agent has written the three artifacts +- **THEN** the recipe SHALL direct it to run `verify` and `test` against the rule's path + +#### Scenario: An unscoped rule is not silently accepted + +- **WHEN** the agent writes a style file without a matcher enabling it in the rule's own config +- **THEN** the recipe SHALL identify this as incomplete +- **AND** `verify` SHALL report it + +### Requirement: Authoring recipes write files rather than invoking a writer + +The `create-*-rule` recipes SHALL instruct the agent to write the rule, its configuration, and its fixtures directly. The CLI SHALL NOT provide a command that generates a Vale style file or authors a rule's matchers on the agent's behalf. + +Assembling the run config is not an exception to this. The agent authors every committed file, including the rule's own `.vale.ini`; assembly only concatenates what the agent wrote into the single file Vale's `--config` requires, and adds no scoping decision of its own. + +#### Scenario: No CLI writer for rule configuration + +- **WHEN** an agent authors a Vale rule +- **THEN** it writes that rule's `.vale.ini` itself +- **AND** the CLI SHALL NOT offer a subcommand that authors matchers + +#### Scenario: Assembly makes no scoping decisions + +- **WHEN** the run config is assembled +- **THEN** it SHALL contain only matchers the agent authored, in the order they were authored diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-rule-format/spec.md b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-rule-format/spec.md new file mode 100644 index 00000000..26be71e7 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-rule-format/spec.md @@ -0,0 +1,118 @@ +## ADDED Requirements + +### Requirement: Rules are one directory each, partitioned by engine + +The system SHALL store every rule as a directory at `.taskless/rules///`, holding the rule, any config that engine requires, and its tests under `.tests/`. + +| Engine | Rule directory contents | +|-----------|--------------------------------------------------------------| +| `sg` | `.yml`, `.tests/-YYYYMMDD-test.yml` | +| `vale` | `.yml`, `.vale.ini`, `.tests/pass/*`, `.tests/fail/*` | +| `runtime` | `check.ts`, `captures/*.yml`, `.tests/…` | + +One directory per rule is what lets a rule be addressed, reviewed, moved, or deleted as a single thing, and it is what makes `verify ` and `test ` possible without an id lookup. + +Tests SHALL live in `.tests/`, dot-prefixed. This is not cosmetic: ast-grep's `ruleDirs` recurses and parses every `.yml` beneath it as a rule, so a plain `tests/` directory inside a rule directory fails the scan outright. Measured against ast-grep 0.41.0, a dot-directory is skipped by rule discovery while `sg test` still reads it when `testDir` names it. + +#### Scenario: A rule is one path + +- **WHEN** a rule is authored for any engine +- **THEN** everything defining it lives under one `.taskless/rules///` directory +- **AND** removing that directory removes the rule completely + +#### Scenario: Test files are not mistaken for rules + +- **WHEN** an ast-grep rule directory contains `.tests/` with test YAML in it +- **THEN** a scan SHALL complete without attempting to parse those files as rules + +#### Scenario: The engine is read from the path + +- **WHEN** the system needs a rule's engine +- **THEN** it reads the `` path segment +- **AND** it SHALL NOT parse the rule file to determine it + +### Requirement: Each engine's native config is the source of truth + +The system SHALL treat each engine's native config as the authoritative definition of its rules, their scoping, and their metadata, and SHALL NOT require a separate Taskless sidecar or metadata file for a rule. + +Where an engine's configuration is per-rule, that per-rule file is the committed source of truth. Where the engine requires a single file at invocation — Vale accepts one `--config`, ast-grep one `sgconfig.yml` — the system SHALL assemble that file from the committed per-rule sources and SHALL gitignore the result. An assembled config is the engine's own native config, split along the boundary the engine's own scoping already has; it is not a Taskless sidecar. + +An engine SHALL NOT be given a per-rule config file it has nothing to put in. ast-grep expresses scoping (`files`, `ignores`) inside the rule itself, so it has no per-rule config; Vale cannot express scoping inside the style — measured, `E201 has invalid keys` — so it does. + +#### Scenario: Vale config is assembled from committed per-rule configs + +- **WHEN** the CLI runs a check +- **THEN** it reads each committed `rules/vale//.vale.ini` and assembles the config it hands to Vale +- **AND** the assembled file SHALL be gitignored + +#### Scenario: ast-grep config is assembled from the rule tree + +- **WHEN** the CLI runs a check or a test +- **THEN** it assembles `sgconfig.yml` with `ruleDirs` covering the rules tree and `testConfigs` covering each rule's `.tests/` +- **AND** the assembled file SHALL be gitignored + +#### Scenario: No empty per-rule config is required + +- **WHEN** an ast-grep rule is authored +- **THEN** no per-rule config file SHALL be required alongside it + +#### Scenario: Native scoping is applied by the engine + +- **WHEN** an ast-grep rule declares native `files`/`ignores`, or a Vale rule's config declares include/exclude matchers +- **THEN** the engine applies that scoping directly, with no Taskless-side rule transformation + +### Requirement: Vale styles live under a per-rule StyleName + +The system SHALL place each Vale rule in its own directory `.taskless/rules/vale//`, so that `` is Vale's StyleName, with the assembled config setting `StylesPath` to the Vale rules tree. The Vale check identifier `.` SHALL be normalized to `ruleId = ` in results. + +`StylesPath` follows the layout and cannot be chosen independently of it. Measured against Vale 3.17.1: a rule at `/.yml` resolves as check `.` under a StylesPath naming its parent, and resolves to nothing at all under `StylesPath = .`. The reverse held for the previous flat layout — the same setting is correct for one layout and silently wrong for the other. + +#### Scenario: Style resolution and identity + +- **WHEN** a Vale style exists at `.taskless/rules/vale/no-simply/no-simply.yml` +- **THEN** Vale loads it as `no-simply.no-simply`, and the CLI reports its findings with `ruleId` `no-simply` + +#### Scenario: A rule's tests are not loaded as styles + +- **WHEN** a Vale rule directory contains `.tests/` +- **THEN** Vale SHALL NOT load anything under it as a rule + +### Requirement: Runtime capture rules live in captures + +A runtime rule's ast-grep capture rules SHALL live in `captures/` inside the rule directory, and `check.ts` SHALL remain at the rule directory's root. + +The name is deliberate. "Matcher" denotes a Vale `[]` config section elsewhere in this system, and one word for two unrelated concepts in one tree is a cost paid at every future reading. + +#### Scenario: Capture rules are found in captures + +- **WHEN** the system discovers a runtime rule +- **THEN** it reads its capture rules from `captures/` +- **AND** it reads `check.ts` from the rule directory root + +### Requirement: A rule's canonical location is what verify and test address + +Each engine SHALL have one canonical on-disk location per rule — the rule directory — and that location SHALL be what `verify` and `test` accept as a path. A directory above it SHALL mean every rule beneath. + +#### Scenario: One address per rule + +- **WHEN** `verify` or `test` is given `.taskless/rules///` +- **THEN** it operates on exactly that rule +- **AND** the engine is determined from the path without reading the rule + +## REMOVED Requirements + +### Requirement: Rules are partitioned into per-engine directories + +**Reason**: Superseded by "Rules are one directory each, partitioned by engine". The old requirement placed a rule as a bare file under an engine directory; a rule is now a directory holding its own file, config, and tests. + +### Requirement: Each engine's committed native config is the source of truth + +**Reason**: Superseded by "Each engine's native config is the source of truth". The committed per-engine config is gone: configs are per-rule and the file each tool reads is assembled per run and gitignored. + +### Requirement: Vale styles live under the rules StyleName + +**Reason**: Superseded by "Vale styles live under a per-rule StyleName". `StylesPath` now points at the Vale rules tree, so each rule directory is its own style and a rule resolves as `.` rather than `rules.`. + +### Requirement: Both the legacy and engine-partitioned layouts are readable + +**Reason**: This change deletes the legacy read paths. Migrations run before any read, so an unmigrated tree cannot reach dispatch, and the legacy constant now names the same string as the new rules root — a stale read path resolving into the live tree is worse than none (design D9). diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-rule-validation/spec.md b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-rule-validation/spec.md new file mode 100644 index 00000000..f9d25126 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-rule-validation/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: Rules are validated and tested by path, not by id + +The CLI SHALL provide `verify ` and `test `. Both SHALL accept a path to a rule's canonical location or to any directory above it, and SHALL resolve the owning engine from the path's position under `.taskless/rules//` rather than by parsing the file. + +An id does not name one thing. The same id can exist under `sg` and under `vale`, so an id-addressed command has to either guess or report an ambiguity; a path has neither problem. Resolving the engine from position — never from content — is the same rule dispatch follows, so a rule cannot be validated by one engine and executed by another. + +#### Scenario: A rule path resolves to its engine + +- **WHEN** `verify .taskless/rules/vale/no-simply` is run +- **THEN** the CLI SHALL validate it as a Vale rule + +#### Scenario: The same id under two engines is not ambiguous + +- **WHEN** `no-simply` exists under both `rules/sg/` and `rules/vale/` +- **THEN** each is addressed by its own path +- **AND** neither command SHALL require the user to disambiguate + +#### Scenario: A directory means everything beneath it + +- **WHEN** `verify .taskless/` is run +- **THEN** every rule beneath it SHALL be validated, each against its own engine +- **AND** the command SHALL report per-rule results rather than a single pass or fail + +#### Scenario: A path outside any engine's rules directory is rejected + +- **WHEN** a path resolves to no engine +- **THEN** the CLI SHALL exit non-zero naming the path, rather than guessing an engine + +### Requirement: Verify checks a rule's required components + +`verify` SHALL check that a rule has the components its engine requires and that they are well formed, and SHALL NOT require fixtures or test cases to exist. + +The two commands split because they have different preconditions. An agent part-way through authoring has a rule and no fixtures yet, and needs to know the rule itself is valid before it can write a meaningful test for it. + +Per engine, `verify` SHALL check: + +| Engine | Components | +|-----------|--------------------------------------------------------------------------------| +| `sg` | `.yml` against the ast-grep schema and the Taskless required fields | +| `vale` | `.yml` against Vale's own validation, and the rule's `.vale.ini` | +| `runtime` | `check.ts` present, and at least one capture rule under `captures/` | + +#### Scenario: A rule with no fixtures still verifies + +- **WHEN** `verify` runs against a rule whose fixture buckets are empty or absent +- **THEN** it SHALL report on the rule's components only +- **AND** the absence of fixtures SHALL NOT be a verify failure + +#### Scenario: A malformed rule reports its own error + +- **WHEN** a Vale style declares a `level` outside `suggestion`/`warning`/`error` +- **THEN** `verify` SHALL report that error, naming the field + +### Requirement: Test runs a rule's fixtures and runs verify first + +`test` SHALL execute a rule against its test material — ast-grep test cases, Vale `pass`/`fail` fixture buckets, or the runtime harness — and SHALL run `verify` first, stopping on a verify failure without running the fixtures. + +Ordering is the point. When a rule is both malformed and under-fixtured, the fixture complaint is the less useful of the two errors and is the one that surfaces first if the checks run in the other order — so the author is told their fixtures are incomplete while the reason the rule could never have run goes unmentioned. + +#### Scenario: A malformed rule reports the malformation, not the fixtures + +- **WHEN** `test` runs against a rule that is both invalid and missing a fixture bucket +- **THEN** it SHALL report the validation error +- **AND** it SHALL NOT report the fixture coverage as the failure + +#### Scenario: Vale fixtures are tested per bucket + +- **WHEN** `test` runs against a Vale rule +- **THEN** every `fail/` document SHALL produce at least one finding for that rule +- **AND** every `pass/` document SHALL produce none +- **AND** a rule populating only one bucket SHALL be reported as unverified rather than passing + +### Requirement: The generation loop runs verify and test + +The rule generation loop SHALL run `verify` and then `test` against a newly authored or newly delivered rule, and SHALL treat a failure of either as a rule that is not ready to report as complete. + +#### Scenario: A generated rule is checked before it is reported + +- **WHEN** a rule is authored locally or written by the service +- **THEN** the loop SHALL run `verify` and `test` against its path +- **AND** SHALL surface a failure rather than reporting the rule as written diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-vale-rule-engine/spec.md b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-vale-rule-engine/spec.md new file mode 100644 index 00000000..03fc0957 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/specs/cli-vale-rule-engine/spec.md @@ -0,0 +1,137 @@ +## MODIFIED Requirements + +### Requirement: Taskless breadcrumbs use a namespaced ignored key in the Vale config + +Any Taskless-owned breadcrumb the system records in a Vale config SHALL use a `tskl) = ` key. The system SHALL NOT rely on Vale enforcing these keys; they are read only by Taskless tooling, and Vale's ini parser accepts and ignores them. + +With each rule owning its config, a matcher's owner is given by the directory it lives in, so the breadcrumb is no longer needed to locate a rule's matchers. It is retained to mark Taskless-owned matchers **within the assembled file**, where several rules' matchers are interleaved and provenance is otherwise lost. + +#### Scenario: Breadcrumb key is ignored by Vale + +- **WHEN** a config contains a `tskl) rule = no-simply` key +- **THEN** Vale runs normally, ignoring the key, and Taskless tooling can read it back + +#### Scenario: Provenance survives assembly + +- **WHEN** matchers from several rules are assembled into one run config +- **THEN** each SHALL carry the `tskl) rule` key naming the rule it came from + +### Requirement: Vale rules are verified with per-rule fixture subdirectories + +The system SHALL verify a Vale rule from a `.taskless/rules/vale//.tests/` subdirectory containing `pass/` and `fail/` fixture documents. Because verification isolates one rule, the system SHALL generate an ephemeral config enabling only that rule — derived from the rule's own config so that verification exercises the scope the rule actually declares. Verification SHALL assert that every `fail/` fixture produces at least one finding for the rule and every `pass/` fixture produces none (mirroring ast-grep's `invalid`/`valid`). + +#### Scenario: Verification isolates the rule under test + +- **WHEN** verify runs for a rule and generates a config enabling only that rule +- **THEN** findings from other rules SHALL NOT affect its result + +#### Scenario: Verification fails when a fail fixture does not trigger + +- **WHEN** a `fail/` fixture for a rule produces no finding +- **THEN** verification reports a failure for that rule + +#### Scenario: A one-sided fixture set is not verified + +- **WHEN** a rule has `fail/` fixtures but no `pass/` fixtures, or `pass/` fixtures but no `fail/` +- **THEN** verification reports the rule as unverified rather than passing +- **AND** the result distinguishes a half-written fixture set from a rule with no fixtures at all + +## ADDED Requirements + +### Requirement: Vale check executes against an assembled run config over the target paths + +The system SHALL assemble a run config from the per-rule configs and run `vale --config --output=JSON --no-exit` over the resolved target paths. The assembled config SHALL set `StylesPath` naming the Vale rules tree and `MinAlertLevel = suggestion`, so that every finding surfaces to the client for normalization and filtering. + +The config is assembled rather than committed because it has no single author. Every rule contributes its own matchers, and a shared committed file is one every rule's author must edit correctly — which is where the engine's silent failures were found in practice. + +The assembled config SHALL be written where the run can read it and SHALL be gitignored. A generated file that is also committed drifts from its inputs and invites hand edits the next assembly discards. + +#### Scenario: Check runs Vale via the assembled config + +- **WHEN** the CLI runs a check and `.taskless/rules/vale/` contains rule directories +- **THEN** it assembles a run config from their per-rule configs and invokes Vale with it over the target paths + +#### Scenario: The assembled config is not a source file + +- **WHEN** the run config is written +- **THEN** it SHALL be ignored by version control +- **AND** editing it SHALL NOT change what a later check reports + +#### Scenario: No Vale rules present + +- **WHEN** `.taskless/rules/vale/` contains no rule directories +- **THEN** the CLI does not invoke Vale and produces no Vale findings + +### Requirement: Per-rule scoping is expressed in the rule's own Vale config + +The system SHALL express a Vale rule's scope through **matchers** — `[]` sections — declared in that rule's own `.taskless/rules/vale//.vale.ini`. Include is `. = YES`, exclude is `. = NO`. + +Precedence is **positional**, and the system SHALL order matchers accordingly rather than relying on a disable to win on its own. Measured against Vale 3.17.1: + +- Where two matchers both match a file, the **last** one wins for that rule. +- Where the same key is assigned twice inside one matcher — including across duplicate `[]` sections, which Vale merges — the **first** assignment wins. + +A disable therefore SHALL be declared **after** the enable it narrows, within the rule's own config. Because precedence is positional and the run config is assembled, **assembly SHALL be deterministic**: rules ordered by id, and each rule's own matcher order preserved verbatim. A non-deterministic assembly would make a rule's effective scope depend on directory iteration order. + +A rule SHALL NOT be able to override another rule's matchers. It cannot know its own position in the assembled file, and cross-rule overriding through a shared file is the coupling the per-rule layout removes. + +#### Scenario: A rule scopes itself + +- **WHEN** a rule's own config enables it under `[marketing/**]` +- **THEN** the rule produces findings in `marketing/` files and none in `api/` files + +#### Scenario: A rule narrows itself + +- **WHEN** a rule's config enables it under `[marketing/**]` and then disables it under `[marketing/legacy/**]` +- **THEN** the rule fires in `marketing/` but not in `marketing/legacy/` + +#### Scenario: Assembly order is stable + +- **WHEN** the same set of rules is assembled twice +- **THEN** the resulting config SHALL be byte-identical +- **AND** each rule's matchers SHALL appear in the order that rule declared them + +#### Scenario: Duplicate matchers merge + +- **WHEN** two rules each declare a `[*.md]` matcher +- **THEN** both rules run on a matching `.md` file (Vale merges the matchers) + +### Requirement: A Vale rule is a self-contained directory + +The system SHALL store a Vale rule as a directory `.taskless/rules/vale//` containing its style file `.yml`, its own `.vale.ini`, and its fixtures under `.tests/`. No file outside that directory SHALL be required to define or verify the rule. + +Self-containment is what removes the engine's silent-failure class. A rule can be added, reviewed, moved, or deleted as one directory, and no two authors write the same file. + +The rule's config SHALL be named `.vale.ini` rather than carrying a `.yml` extension. Measured: a `.yml` file inside a style directory is loaded as a rule and fails `E201` when the style is enabled wholesale, while a non-`.yml` file in the same directory is ignored. + +Scope SHALL NOT be expressed inside the style file. Measured: Vale rejects unknown top-level keys in a rule with `E201 has invalid keys`. + +#### Scenario: A rule is complete in one directory + +- **WHEN** a rule directory contains its style and its config +- **THEN** the rule is fully defined without editing any shared file + +#### Scenario: The rule config is invisible to Vale's style loader + +- **WHEN** a rule directory contains `.vale.ini` beside its style +- **THEN** Vale SHALL NOT attempt to load it as a rule + +#### Scenario: Deleting a rule is deleting a directory + +- **WHEN** a rule directory is removed +- **THEN** no other rule's scope changes +- **AND** no shared file needs editing + +## REMOVED Requirements + +### Requirement: Vale check executes against the committed config over the target paths + +**Reason**: Superseded by "Vale check executes against an assembled run config over the target paths". There is no committed config: the file Vale reads is assembled from every rule's own config on each run and gitignored. + +### Requirement: Per-rule scoping is expressed via Vale config matchers + +**Reason**: Superseded by "Per-rule scoping is expressed in the rule's own Vale config". Matchers still express scope, but they live in the rule's directory rather than in one shared file every author edits. + +### Requirement: The scaffolded Vale config carries no section + +**Reason**: There is no scaffolded Vale config. `init` creates `.taskless/rules/vale/` and nothing else, because scope is declared per rule; verified against a fresh scaffold, which contains no `.ini` at all. A rule that declares no scope is now a `verify` error rather than a quiet no-op. diff --git a/openspec/changes/archive/2026-08-15-self-contained-rules/tasks.md b/openspec/changes/archive/2026-08-15-self-contained-rules/tasks.md new file mode 100644 index 00000000..ff048ae5 --- /dev/null +++ b/openspec/changes/archive/2026-08-15-self-contained-rules/tasks.md @@ -0,0 +1,80 @@ +# Tasks + +Delivery shape: **stacked, merging down**, on top of `agent-command-and-vale-authoring` (PR #102). The units are only correct together — a layout change without its migration, or a migration without the assemblers, ships a project whose rules silently stop running. Nothing reaches `main` until all of it does. + +`0005` layers on `0004`. Both are unreleased and both ship in this stack, so every consumer runs them as one upgrade and never observes the intermediate layout. + +## 1. The layout + +- [x] 1.1 `ENGINE_LAYOUTS` becomes one rule-directory rule (`rules///`) plus per-engine contents. Every path helper derives from it; no caller reconstructs a path by hand +- [x] 1.2 Pin the dot-directory assumption with a test: a rule directory containing `.tests/` with test YAML in it, asserting `sg scan` completes clean. This is the load-bearing undocumented behavior (design D2) and it must be checked on every run rather than remembered +- [x] 1.3 `hasValeRules` looks for flat `*.yml` under `vale/rules/` and would read a directory-shaped rule set as "no rules configured" — a silent skip of the whole engine. Fix it and add the test that would have caught it +- [x] 1.4 Runtime discovery reads capture rules from `captures/`; `check.ts` stays at the rule root +- [x] 1.5 **Delete `LEGACY_RULES_DIRECTORY` and `LEGACY_RULE_TESTS_DIRECTORY` and their readers** in `rules/verify.ts`, `detect/scan.ts`, and `commands/rules.ts`, including the error messages that name the legacy path as somewhere a rule might live. `.taskless/rules/` is now the new root and the legacy constant is the same string — a stale read path that resolves into the live tree is worse than none. Migrations run before any read (`ensureTasklessDirectory`), so the state they guard against cannot exist (design D9) + +## 2. Assembly + +- [x] 2.1 Assemble the Vale run config from every rule's `.vale.ini`. Deterministic: rules ordered by id, each rule's matchers verbatim in its own order, `StylesPath` and `MinAlertLevel` as the header +- [x] 2.2 Carry each matcher's `tskl) rule = ` breadcrumb through assembly. Provenance is otherwise lost the moment matchers interleave +- [x] 2.3 Assemble `sgconfig.yml`: `ruleDirs` over the rules tree, one `testConfigs` entry per rule's `.tests/` +- [x] 2.4 Gitignore both assembled configs. They are build artifacts; a committed generated file drifts and invites hand edits the next assembly discards +- [x] 2.5 Assert both assembled artifacts byte-for-byte from a known rule set, and assert stability across two runs. This is the new silent-failure surface — a bug here disables rules without saying so +- [x] 2.6 `runVale` and the ast-grep scan run against the assembled configs + +## 3. Migration `0005` + +- [x] 3.0 Assert `.taskless/rules/` holds no top-level `*.yml` before writing engine directories into it. `0004` empties it by moving it to `sg/rules/`, so a file still there means `0004` did not complete, and proceeding would interleave two layouts in one tree +- [x] 3.1 Move `/rules/.yml` → `rules///.yml`; runtime `runtime/rules//` → `rules/runtime//`, its `*.yml` into `captures/` +- [x] 3.2 Move `/rule-tests/*` → `rules///.tests/`, preserving each engine's internal test shape +- [x] 3.3 Split the committed `vale/.vale.ini` — each matcher carrying `tskl) rule = ` into that rule's own config +- [x] 3.4 A matcher with **no** `tskl) rule` breadcrumb cannot be attributed. Leave it and report it rather than guessing an owner or dropping it: an unattributable matcher is a user's hand edit, and discarding it silently changes what their check reports +- [x] 3.5 Delete the committed `vale/.vale.ini` and `sg/sgconfig.yml` +- [x] 3.6 Preserve content byte-for-byte. Runtime capture bytes determine server-side reconciliation hashes, so a rewrite invalidates every signature +- [x] 3.7 Rewrite the `StylesPath` docstring in `0004`. It states `StylesPath = rules` is wrong — true for the flat layout, exactly backwards now. It has to explain both or a future reader will "fix" it back +- [x] 3.8 Idempotent, and a no-op on an already-migrated tree + +## 4. `verify` and `test` + +- [x] 4.1 Resolve a path to an engine from its `` segment, never by parsing the file. A path outside the rules tree is an error naming the path +- [x] 4.2 A directory above a rule means every rule beneath it; report per-rule results rather than one pass/fail +- [x] 4.3 `verify `: ast-grep schema + Taskless required fields; Vale style validation + the rule's `.vale.ini`; runtime `check.ts` plus at least one capture. Tests are NOT required for verify to pass +- [x] 4.4 `test `: ast-grep test cases, Vale `.tests/` buckets, runtime harness. Runs `verify` first and stops on failure — today a malformed rule reports a fixture complaint while the real error goes unmentioned +- [x] 4.5 Delete `rule verify` and the id-based dispatch added in `bc09897`, including `rulefileOwners` and its ambiguity error. The path form has no ambiguity case +- [x] 4.6 Wire both into the rule generation loop +- [x] 4.7 Port `rule-verify-dispatch.test.ts` onto the path-addressed commands; delete the id-addressed tests + +## 5. Recipes + +- [x] 5.1 Rewrite `create-vale-rule.txt` for the layout: rule directory, its own config, `.tests/`, no shared file. The nine worked rules keep their bodies — only where the file sits and where scope is declared changes +- [x] 5.2 Replace the `.vale.ini` walkthrough. The current step teaches editing a shared config and carries the `W101`-outside-a-matcher warning; both describe a situation that no longer exists +- [x] 5.3 Step 6 names `verify` and `test` rather than reading `results[].ruleId` out of `check` +- [x] 5.4 Update `create-sg-rule.txt` for the rule-directory layout, `verify-rule.txt` for both commands, and sweep every recipe naming `rule verify` +- [x] 5.5 Re-run the 2b harness against the rewritten recipes — fresh agents, no repository access, the same three extension points. The recipes changed materially, so prior convergence does not carry over +- [x] 5.6 Re-verify the nine worked rules by extracting the YAML from the *rendered* recipe and executing it. What ships must be what was tested + +## 6. An example project at `/example` + +**Primarily so a person can see what a Taskless install looks like.** The tests cover behavior thoroughly, but they build their fixtures inside the test that reads them — so nothing in the repository shows the layout as a reader would encounter it. `example/` is that: a small, real project someone can open and understand in a minute. + +Its second job is to stop being wrong. A demo that drifts from the layout it demonstrates is worse than none, so a test runs `check` against it — the example rots loudly rather than quietly. It also counteracts `.tests/` being dot-hidden, by showing a complete rule directory somewhere nothing is hidden. + +- [x] 6.1 `example/README.md` — what this is, what each file is for, and what `check` reports against it. Written for someone who has never installed Taskless and wants to see the shape before they do +- [x] 6.2 `example/example.html` and `example/example.cjs` — the prose and the code the rules have something to say about. Small enough to read in one screen +- [x] 6.3 `example/.taskless/` with one Vale rule and one ast-grep rule, each in its canonical directory with its `.tests/` +- [x] 6.4 A test that runs `check` against `example/` and asserts on the findings. This is what keeps the demo honest: a layout change that breaks it fails the build instead of leaving a misleading example in the repo +- [x] 6.5 A test that runs `verify example/.taskless/rules/` and `test example/.taskless/rules/` — the directory form, which is also the CI form +- [x] 6.6 Add `example/` to the root tooling ignores that would otherwise walk it. Its `.tests/` hold deliberately wrong prose, and a root prettier or eslint pass reaching them fails on content nobody wrote as source + +## 7. Verify + +- [x] 7.1 `pnpm typecheck`, `pnpm lint`, `pnpm --filter @taskless/cli build`, `pnpm --filter @taskless/cli test` +- [x] 7.2 End-to-end: author two Vale rules with different globs, confirm each fires only in its own scope, and confirm deleting one directory leaves the other's scope untouched +- [x] 7.3 Confirm a hand-edited assembled config has no effect on the next check — it is regenerated +- [x] 7.4 Migrate a `0004`-shaped fixture through `0005` and confirm the rules still fire, the tests still run, and runtime signatures are unchanged +- [x] 7.5 `pnpm openspec validate --all --strict`. The `cli-agent-authoring` delta modifies a requirement #102 introduces, so this passes cleanly only once #102 archives +- [x] 7.6 Extend the changeset on the bottom of the stack: the rule layout, the removal of `rule verify`, the new `verify`/`test` commands +- [x] 7.7 Archive the change + +**Archive ordering, for the record.** `agent-command-and-vale-authoring` had to archive first: a change archives once on the tip and the gate wants `openspec/changes/` empty, and this change's `cli-agent-authoring` delta modifies a requirement that one introduces, so it needed a target in `specs/`. Both archives land on the tip PR. + +Note on 7.5: `cli-rules` and `cli-update-engine` fail `--strict` on `main` already and are unrelated to this stack (confirmed by diffing both specs against `origin/main` — this stack never touched them). `change/self-contained-rules` itself validates clean. diff --git a/openspec/specs/cli-agent-authoring/spec.md b/openspec/specs/cli-agent-authoring/spec.md new file mode 100644 index 00000000..00b8603a --- /dev/null +++ b/openspec/specs/cli-agent-authoring/spec.md @@ -0,0 +1,120 @@ +# cli-agent-authoring Specification + +## Purpose +TBD - created by archiving change agent-command-and-vale-authoring. Update Purpose after archive. +## Requirements +### Requirement: Every engine a rule can be routed to has an authoring recipe + +The CLI SHALL provide an authoring recipe for each engine `route` can name: `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule`, alongside `create-legacy-rule` for a linter the repository already uses. + +A decision procedure that can produce an answer with no destination is incomplete. Engine selection can conclude `vale` or `runtime`, and before this change neither had a procedure, so an agent that reasoned correctly arrived nowhere. + +#### Scenario: Each engine choice reaches a procedure + +- **WHEN** engine selection concludes `sg`, `vale`, or `runtime` +- **THEN** a recipe exists that authors a rule for that engine + +#### Scenario: A legacy destination exists for repositories with their own linter + +- **WHEN** the repository already runs a linter that can express the rule +- **THEN** `create-legacy-rule` SHALL author it in that tool's own dialect + +### Requirement: The Vale authoring recipe covers rule, scope, and fixtures + +The `create-vale-rule` recipe SHALL instruct the agent to produce three artifacts, and SHALL state that a rule is incomplete without all three: + +1. A Vale style file at `.taskless/rules/vale//.yml`. +2. That rule's own `.taskless/rules/vale//.vale.ini`, declaring the matchers that scope it and enabling it as `. = YES`. +3. `pass/` and `fail/` fixture documents under `.taskless/rules/vale//.tests/`. + +The recipe SHALL state that scope is declared in the rule's own config, that no shared file is edited, and that the project-wide config is assembled rather than authored. + +It SHALL direct the agent to check its work by running `verify` and then `test` against the rule's directory path. + +The previous version of this requirement taught the agent to add a matcher to a single project-wide `.vale.ini`. Executing that recipe against sandboxed agents found every one of its silent failures in that step and nowhere else — an assignment above the first matcher, a glob that missed the fixture extension, three names that had to agree with nothing reporting when they didn't. The layout change removes the step rather than documenting it further. + +#### Scenario: Authoring produces all three artifacts + +- **WHEN** the agent follows `create-vale-rule` +- **THEN** it writes the style file, the rule's own config with a scoping matcher, and both fixture buckets + +#### Scenario: No shared file is edited + +- **WHEN** the agent scopes a rule +- **THEN** it writes matchers into that rule's own config +- **AND** it SHALL NOT be directed to edit a project-wide Vale config + +#### Scenario: The recipe names the commands that check the work + +- **WHEN** the agent has written the three artifacts +- **THEN** the recipe SHALL direct it to run `verify` and `test` against the rule's path + +#### Scenario: An unscoped rule is not silently accepted + +- **WHEN** the agent writes a style file without a matcher enabling it in the rule's own config +- **THEN** the recipe SHALL identify this as incomplete +- **AND** `verify` SHALL report it + +### Requirement: Authoring recipes write files rather than invoking a writer + +The `create-*-rule` recipes SHALL instruct the agent to write the rule, its configuration, and its fixtures directly. The CLI SHALL NOT provide a command that generates a Vale style file or authors a rule's matchers on the agent's behalf. + +Assembling the run config is not an exception to this. The agent authors every committed file, including the rule's own `.vale.ini`; assembly only concatenates what the agent wrote into the single file Vale's `--config` requires, and adds no scoping decision of its own. + +#### Scenario: No CLI writer for rule configuration + +- **WHEN** an agent authors a Vale rule +- **THEN** it writes that rule's `.vale.ini` itself +- **AND** the CLI SHALL NOT offer a subcommand that authors matchers + +#### Scenario: Assembly makes no scoping decisions + +- **WHEN** the run config is assembled +- **THEN** it SHALL contain only matchers the agent authored, in the order they were authored + +### Requirement: The runtime authoring recipe is the logged-out path + +The `create-runtime-rule` recipe SHALL explain that runtime rules execute code and therefore require login, reconciliation, and signing, and SHALL state this as a property of executing code rather than of the engine's capability. It SHALL point at `auth` for obtaining access rather than restating the login procedure, which `auth` owns. + +It SHALL NOT forward the agent to another authoring recipe. A logged-in runtime request is routed to `create-remote-rule` by `route`, so this recipe is reached only when the gate is closed and exists to explain that one gate once. + +#### Scenario: The gate is explained where it is encountered + +- **WHEN** an agent follows `create-runtime-rule` +- **THEN** the recipe SHALL state why the runtime tier is gated when the static tiers are not +- **AND** it SHALL refer the reader to `auth` rather than restating how to log in + +#### Scenario: The recipe does not delegate + +- **WHEN** an agent follows `create-runtime-rule` +- **THEN** it SHALL NOT be directed to fetch another authoring recipe to proceed + +### Requirement: Service generation is one recipe + +The CLI SHALL provide a single `create-remote-rule` recipe covering both the client-side boundary of service generation and the procedure itself — enriching the user's description, dispatching to the Taskless service, and reporting the result. + +Split across a boundary statement and a procedure, an agent fetches one only to learn it needs the other, which is the second fetch this change exists to remove. + +#### Scenario: One fetch reaches the whole procedure + +- **WHEN** an agent follows `create-remote-rule` +- **THEN** the recipe SHALL carry both the boundary and the dispatch procedure +- **AND** it SHALL NOT require fetching a second topic to complete the request + +### Requirement: Every authoring recipe opens by orienting the reader + +Each `create-*-rule` recipe SHALL open with a line naming the topic the reader is in, the kinds of rule it helps write, and an instruction to revisit the routing decision if that is not what they need. + +The line SHALL orient, not classify: it states this recipe's own scope and SHALL NOT restate the criterion distinguishing the engines from each other, which `route` holds in one place. An agent that arrived at the wrong recipe — by guessing, by a user naming a topic directly, or because `route` was wrong — should discover it in the first line, where recovery is cheap, rather than after authoring the wrong artifact. + +#### Scenario: A misrouted reader is told how to recover + +- **WHEN** an agent opens any `create-*-rule` recipe +- **THEN** the first lines SHALL name what that recipe helps write +- **AND** SHALL instruct the agent to revisit its routing decision if it needs a different kind of check + +#### Scenario: The orientation is not a second criterion + +- **WHEN** the orientation line is read +- **THEN** it SHALL describe only this recipe's scope, not the comparison between engines + diff --git a/openspec/specs/cli-help/spec.md b/openspec/specs/cli-help/spec.md index ff82a10b..46c464c3 100644 --- a/openspec/specs/cli-help/spec.md +++ b/openspec/specs/cli-help/spec.md @@ -3,53 +3,39 @@ ## Purpose TBD — Defines the help subcommand for the `@taskless/cli` package, including help text display, embedding, and formatting. - ## Requirements - ### Requirement: Help subcommand displays rich help text for commands -The CLI SHALL support a `help` subcommand that accepts zero or more positional arguments identifying a topic path AND an optional `--anonymous` boolean flag. When positional arguments are provided, the help subcommand SHALL look up a matching help text file embedded at build time using the following resolution order: +The CLI SHALL support an `agent` subcommand that accepts at most one positional argument identifying a topic AND an optional `--anonymous` boolean flag. Topics SHALL be addressed by a single token; the subcommand SHALL NOT join multiple positionals into a topic key. When a topic is provided, the subcommand SHALL look up a matching help text file embedded at build time using the following resolution order: 1. If `--anonymous` is set AND `.anonymous.txt` exists in the embedded map, return that file. 2. Otherwise, return `.txt`. -3. If neither exists, exit with code 1 and an error message suggesting `taskless help` for the topic index. - -When no positional arguments are provided, the help subcommand SHALL print a topic index containing a one-paragraph human slug followed by a topic disambiguation table mapping topic names to their summaries. +3. If neither exists, exit with code 1 and an error message suggesting `taskless agent` for the topic index. -#### Scenario: Help for a topic returns the recipe +When no positional argument is provided, the subcommand SHALL print a topic index containing a one-paragraph human slug followed by a topic disambiguation table mapping topic names to their summaries. -- **WHEN** a user runs `taskless help check` -- **THEN** the CLI SHALL print the contents of `check.txt` to stdout +The subcommand is named for its reader. It serves agents fetching a procedure, not humans asking for help, and single-token addressing exists so a topic name is a literal string an agent copies rather than a phrase it can reorder or paraphrase. -#### Scenario: Help for a nested topic joins with hyphens +#### Scenario: Agent subcommand for a topic returns the recipe -- **WHEN** a user runs `taskless help rule create` -- **THEN** the CLI SHALL look up `rule-create.txt` and print its contents - -#### Scenario: Help with --anonymous returns the variant when present - -- **WHEN** a user runs `taskless help rule create --anonymous` -- **AND** `rule-create.anonymous.txt` exists in the embedded help map -- **THEN** the CLI SHALL print the contents of `rule-create.anonymous.txt` +- **WHEN** a user runs `taskless agent check` +- **THEN** the CLI SHALL print the contents of `check.txt` to stdout -#### Scenario: Help with --anonymous falls back when no variant exists +#### Scenario: Multi-word topic paths are not resolved -- **WHEN** a user runs `taskless help check --anonymous` -- **AND** no `check.anonymous.txt` exists -- **THEN** the CLI SHALL print the contents of `check.txt` (no error, no warning — anonymous is a no-op for this topic) +- **WHEN** a user runs `taskless agent rule create` +- **THEN** the CLI SHALL NOT look up `rule-create.txt` by joining the positionals +- **AND** it SHALL exit non-zero rather than guessing a topic -#### Scenario: Help with no arguments shows index with human slug and disambiguation table +#### Scenario: Formerly nested topics are addressed by one token -- **WHEN** a user runs `taskless help` -- **THEN** the CLI SHALL print a one-paragraph human-facing slug explaining what the help command does for human vs. agent audiences -- **AND** SHALL print a topic table mapping each topic name to its one-line summary -- **AND** SHALL include a note about the `--anonymous` flag +- **WHEN** a user runs `taskless agent improve-rule` +- **THEN** the CLI SHALL look up `improve-rule.txt` and print its contents -#### Scenario: Help for an unknown topic exits with error +#### Scenario: The former command name is gone -- **WHEN** a user runs `taskless help nonexistent` -- **THEN** the CLI SHALL print an error message indicating the topic is not recognized -- **AND** exit with code 1 +- **WHEN** a user runs `taskless help check` +- **THEN** the CLI SHALL NOT print recipe text for `check` ### Requirement: Help text files are embedded at build time @@ -120,140 +106,62 @@ Recipe authors SHALL escape any literal `%` character in recipe content as `%%` ### Requirement: onboard topic is registered in the help index -A new help topic `onboard` SHALL be registered. The CLI SHALL embed `packages/cli/src/help/onboard.txt` at build time via the existing `import.meta.glob` mechanism. `taskless help onboard` SHALL print the contents of `onboard.txt`. The topic SHALL appear in the output of `taskless help` (the index) with a one-line summary describing it as the post-install rule-discovery flow. +A help topic `onboard` SHALL be registered. The CLI SHALL embed `packages/cli/src/help/onboard.txt` at build time via the existing `import.meta.glob` mechanism. `taskless agent onboard` SHALL print the contents of `onboard.txt`. The topic SHALL appear in the output of `taskless agent` (the index) with a one-line summary describing it as the post-install rule-discovery flow. -#### Scenario: Help for onboard returns the recipe +#### Scenario: The onboard topic returns the recipe -- **WHEN** a user runs `taskless help onboard` +- **WHEN** a user runs `taskless agent onboard` - **THEN** the CLI SHALL print the contents of `onboard.txt` to stdout - **AND** SHALL exit with code 0 -#### Scenario: Help index includes onboard +#### Scenario: Topic index includes onboard -- **WHEN** a user runs `taskless help` (no args) -- **THEN** the topic table SHALL include a row for `onboard` +- **WHEN** a user runs `taskless agent` (no args) +- **THEN** the topic index SHALL include a row for `onboard` - **AND** the row SHALL describe it as the post-install rule-discovery flow -#### Scenario: Onboard recipe is embedded at build time - -- **WHEN** the CLI bundle is built -- **THEN** `import.meta.glob` matching the help directory SHALL include `onboard.txt` -- **AND** the recipe SHALL be available at runtime without filesystem access - -#### Scenario: Help onboard with --anonymous falls back - -- **WHEN** a user runs `taskless help onboard --anonymous` -- **AND** no `onboard.anonymous.txt` exists -- **THEN** the CLI SHALL print the contents of `onboard.txt` (anonymous is a no-op for this topic) - ### Requirement: help_onboard intent telemetry -The help command's existing intent-telemetry requirement SHALL extend naturally to the new topic: invocations of `taskless help onboard` SHALL emit a `help_onboard` PostHog event, consistent with the `help_` pattern. +Fetching the `onboard` topic SHALL emit the command's single intent event, `cli_help`, carrying `onboard` as its `topic` property. + +Per-topic event names (`help_onboard` and siblings) are not emitted. One event with a topic property is filterable the same way and does not grow the event vocabulary every time a topic is added or renamed, which this change would otherwise have to do for every rename below. -#### Scenario: Help onboard emits help_onboard +#### Scenario: Fetching onboard captures its topic -- **WHEN** an agent runs `taskless help onboard` -- **THEN** PostHog SHALL receive a `help_onboard` event +- **WHEN** an agent runs `taskless agent onboard` +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` property is `onboard` ### Requirement: Routing topics are registered in the help system -The help system SHALL register the routing recipes `route`, `existing`, `static`, -and `remote` as embedded help topics, retrievable via `taskless help ` and -listed in the help index, consistent with the existing topic embedding and format -requirements. +The help system SHALL register `route` and each `create-*-rule` recipe as embedded topics, retrievable via `taskless agent ` and listed in the topic index, consistent with the existing topic embedding and format requirements. + +`existing`, `static`, and `remote` are no longer topics. `route` applies the criterion they carried and names a concrete destination, so an agent reaches an authoring recipe in one fetch. #### Scenario: Routing topics resolve -- **WHEN** `taskless help route`, `taskless help existing`, - `taskless help static`, or `taskless help remote` is run +- **WHEN** `taskless agent route` or any `taskless agent create-*-rule` is run - **THEN** the corresponding recipe text SHALL be returned -- **AND** an unknown-topic error SHALL NOT be raised for any of the four - -#### Scenario: Routing topics appear in the index - -- **WHEN** `taskless help` (no arguments) is run -- **THEN** the topic index SHALL include the routing topics so an agent can - discover the authoring front door - -### Requirement: Routing topics emit intent telemetry - -Fetching a routing recipe SHALL emit a per-topic intent telemetry event, -consistent with the existing `help_` telemetry convention. - -#### Scenario: Help topic intent is captured for routing recipes - -- **WHEN** the agent fetches `route`, `existing`, `static`, or `remote` -- **THEN** the help command SHALL capture the corresponding `help_` intent - event with the topic name - -### Requirement: The engine-selection topic is registered in the help system - -The help system SHALL register the engine-selection recipe as an embedded help topic, retrievable via `taskless help ` and listed in the help index, consistent with the existing topic embedding and format requirements. - -#### Scenario: Engine-selection topic resolves - -- **WHEN** `taskless help` is run for the engine-selection topic -- **THEN** the recipe text SHALL be returned and an unknown-topic error SHALL NOT be raised - -#### Scenario: Engine-selection topic appears in the index - -- **WHEN** `taskless help` is run with no arguments -- **THEN** the topic index SHALL include the engine-selection topic so an agent can discover it - -### Requirement: Routing recipes reference engine selection - -The `route` and `static` recipes SHALL reference the engine-selection topic so an agent following the local authoring flow applies the same engine test the service applies, rather than assuming ast-grep. - -#### Scenario: Local flow reaches engine selection +- **AND** an unknown-topic error SHALL NOT be raised -- **WHEN** an agent follows `route` to a destination that authors a Taskless rule -- **THEN** the recipe directs it to the engine-selection topic before the rule is authored +#### Scenario: Removed routing topics do not resolve -## Goal +- **WHEN** `taskless agent existing`, `taskless agent static`, or `taskless agent remote` is run +- **THEN** the CLI SHALL exit non-zero +- **AND** it SHALL NOT print recipe text - - -## Preconditions - - - -## Steps - - - -## Input schema - - - -## Errors - - - -## See Also - - -``` - -The header line SHALL include the CLI version (interpolated at build time) and a topic version integer maintained by the recipe author and bumped when the recipe changes meaningfully. - -#### Scenario: Recipe contains all template sections +#### Scenario: Routing topics appear in the index -- **WHEN** any `.txt` file is read -- **THEN** it SHALL begin with the `# Topic: (CLI v / topic v)` header -- **AND** SHALL contain `## Goal`, `## Preconditions`, `## Steps`, `## Errors`, and `## See Also` sections in that order +- **WHEN** `taskless agent` (no arguments) is run +- **THEN** the topic index SHALL include `route` and every `create-*-rule` topic -#### Scenario: Recipe with --from input includes JSON schema +### Requirement: Routing topics emit intent telemetry -- **WHEN** a topic recipe documents a CLI invocation that uses `--from ` -- **THEN** the recipe SHALL contain an `## Input schema` section with a code-fenced JSON Schema block -- **AND** the JSON Schema SHALL be derived from the corresponding Zod schema in `packages/cli/src/schemas/` +Fetching a routing recipe SHALL emit the command's single intent event, `cli_help`, carrying the served topic as its `topic` property. -#### Scenario: Header version reflects build-time CLI version +#### Scenario: Intent is captured for routing recipes -- **WHEN** the CLI bundle is built -- **THEN** the recipe header's CLI version SHALL be interpolated at build time from `packages/cli/package.json` -- **AND** SHALL match the version reported by `taskless info` +- **WHEN** the agent fetches `route` or any `create-*-rule` topic +- **THEN** the command SHALL capture a `cli_help` event whose `topic` property is that topic name ### Requirement: Anonymous variant lookup uses a compile-time map @@ -262,47 +170,74 @@ The help command SHALL construct, at build time, a Set of topic names that have #### Scenario: Topics with variants are detected at build time - **WHEN** the CLI bundle is built -- **AND** files `rule-create.anonymous.txt` and `rule-improve.anonymous.txt` exist -- **THEN** the embedded variants set SHALL contain `rule-create` and `rule-improve` +- **AND** a file `improve-rule.anonymous.txt` exists +- **THEN** the embedded variants set SHALL contain `improve-rule` #### Scenario: Topics without variants are absent from the map - **WHEN** the CLI bundle is built - **AND** no `check.anonymous.txt` file exists - **THEN** the embedded variants set SHALL NOT contain `check` -- **AND** `taskless help check --anonymous` SHALL fall back to `check.txt` +- **AND** `taskless agent check --anonymous` SHALL fall back to `check.txt` ### Requirement: Embedded JSON schemas are generated via zod-to-json-schema -For every recipe topic that documents a CLI command accepting `--from `, the corresponding Zod input schema in `packages/cli/src/schemas/` SHALL be converted to JSON Schema via `zod-to-json-schema` and embedded in the recipe's `## Input schema` section as a fenced code block. Generation MAY happen at runtime (small dep, fast) or at build time; runtime is acceptable. +For every recipe topic that documents a CLI command accepting `--from `, the corresponding Zod input schema in `packages/cli/src/schemas/` SHALL be converted to JSON Schema and embedded in the recipe's `## Input schema` section as a fenced code block. Generation MAY happen at runtime (small dep, fast) or at build time; runtime is acceptable. -#### Scenario: rule create recipe embeds input schema +#### Scenario: The remote authoring recipe embeds its input schema -- **WHEN** a user runs `taskless help rule create` +- **WHEN** a user runs `taskless agent create-remote-rule` - **THEN** the output SHALL contain an `## Input schema` section -- **AND** the section SHALL contain a code-fenced JSON Schema block derived from the `rules-create` Zod schema (or the renamed `rule-create` schema) +- **AND** the section SHALL contain a code-fenced JSON Schema block derived from the `rules-create` Zod schema -#### Scenario: rule improve recipe embeds input schema +#### Scenario: The improve recipe embeds its input schema -- **WHEN** a user runs `taskless help rule improve` +- **WHEN** a user runs `taskless agent improve-rule` - **THEN** the output SHALL contain an `## Input schema` section with the rule-improve JSON Schema ### Requirement: Help command emits intent telemetry -The help command SHALL emit a PostHog event on every invocation: +The `agent` command SHALL emit one PostHog event, `cli_help`, on every invocation, carrying a `topic` property: + +- the served topic when a positional resolves to a known topic +- the attempted topic string when it resolves to none +- `(index)` when called with no positional arguments +- the joined positionals when more than one is supplied + +#### Scenario: Topic fetch captures the topic + +- **WHEN** an agent runs `taskless agent create-sg-rule` +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` property is `create-sg-rule` + +#### Scenario: Index fetch captures the index + +- **WHEN** an agent runs `taskless agent` (no args) +- **THEN** PostHog SHALL receive a `cli_help` event whose `topic` property is `(index)` + +### Requirement: Routing recipes name a destination, not a second decision + +The `route` recipe SHALL apply the engine reasoning directly and name a concrete `create-*-rule` topic, rather than referring the reader onward to a topic that selects an engine. No shipped recipe SHALL refer to `engine-selection`, which no longer exists. + +Each `create-*-rule` recipe SHALL instead point back at `route` for a reader who arrived at the wrong one, so recovery costs a re-decision rather than a second copy of the criterion (see "Every authoring recipe opens by orienting the reader"). + +#### Scenario: Route names a destination without a second fetch + +- **WHEN** an agent follows `route` +- **THEN** the recipe SHALL name one `create-*-rule` topic +- **AND** it SHALL NOT require fetching a separate engine-selection topic first to do so + +#### Scenario: Authoring recipes point back rather than re-deciding -- `help_` (e.g. `help_rule_create`, `help_check`, `help_auth`) when called with positional arguments resolving to a known topic -- `help_index` when called with no positional arguments -- `help_unknown` (with the attempted topic as a property) when called with positional arguments resolving to no topic +- **WHEN** an agent reads any `create-*-rule` recipe +- **THEN** the recipe SHALL name `route` as where to go if this is the wrong destination +- **AND** it SHALL NOT reference `engine-selection` -These events SHALL replace the previous `cli_help_` events in a single hard rename. +### Requirement: Shipped recipes name only commands that exist -#### Scenario: Topic fetch emits intent event +No embedded recipe SHALL contain the string `taskless help`. Recipes cross-reference each other by literal command string, so a stale reference is invisible until an agent runs it and receives nothing. -- **WHEN** an agent runs `taskless help rule create` -- **THEN** PostHog SHALL receive a `help_rule_create` event +#### Scenario: No recipe references the removed command -#### Scenario: Index fetch emits help_index +- **WHEN** the embedded recipe set is inspected +- **THEN** no recipe SHALL contain `taskless help` -- **WHEN** an agent runs `taskless help` (no args) -- **THEN** PostHog SHALL receive a `help_index` event diff --git a/openspec/specs/cli-knowledge-prompts/spec.md b/openspec/specs/cli-knowledge-prompts/spec.md index 4e967642..335cf450 100644 --- a/openspec/specs/cli-knowledge-prompts/spec.md +++ b/openspec/specs/cli-knowledge-prompts/spec.md @@ -15,9 +15,7 @@ cannot drift into giving different guidance. The export carries no CLI runtime, so a Worker can import it without dragging in the command tree, and topic membership is an explicit hand-maintained list so a new recipe file cannot silently become public API. - ## 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. @@ -108,17 +106,23 @@ 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, 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. +The set of `PromptTopic` names, the `getPrompt`/`PROMPTS` shape, and the existing fields of `PromptOptions` SHALL be treated as public API; recipe _text_ MAY change freely. + +The package is pre-1.0, so a backwards-incompatible change to that surface SHALL be released as a **MINOR** bump. This is what the leading zero means, and it applies to renaming a topic, removing one, or changing the accessor signature. + +What the requirement actually protects is not the version number but the notice. `TOPICS` is consumed across a deploy boundary, so a downstream consumer breaks when it upgrades rather than when this package builds, and the version alone cannot warn anyone. A breaking change SHALL therefore name the removed or renamed topics explicitly in its changeset. -#### Scenario: Removing a topic is a breaking change +#### Scenario: Renaming or removing a topic - **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 +- **THEN** it SHALL be released as a MINOR bump +- **AND** the changeset SHALL name the removed or renamed topics +- **AND** a recipe text edit SHALL require neither -#### Scenario: Adding an option is not a breaking change +#### Scenario: Adding an option - **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 +- **THEN** it SHALL NOT require more than a PATCH bump, since existing call sites keep their behavior ### Requirement: Topic membership is explicit and verified against the recipe files @@ -140,3 +144,22 @@ An automated check SHALL assert that the set of canonical `help/*.txt` topics on - **WHEN** a recipe file is listed as internal - **THEN** the check SHALL pass and the topic SHALL NOT be a member of `PromptTopic` + +### Requirement: Exported topics cover every engine a rule can be routed to + +`TOPICS` SHALL export the authoring recipe for each engine — `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule`. + +A consumer that can decide a rule belongs to an engine must be able to reach the procedure for authoring one. Exporting a chooser without its destinations reproduces, for the platform generator, the dead end this change removes from the CLI. + +`engine-selection` leaves the export because it stops existing: the criterion it carried now lives in `route`, stated once. `route` is not exported here — it still contains local mechanics a Worker cannot run — so until it is, a consumer gets each destination's own scope from these three and adjudicates a genuinely ambiguous call itself. + +#### Scenario: Every engine's authoring path is reachable from the export + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL contain `create-sg-rule`, `create-vale-rule`, and `create-runtime-rule` + +#### Scenario: The exported set follows the rename + +- **WHEN** a consumer imports `TOPICS` +- **THEN** it SHALL NOT contain `static` or `engine-selection`, neither of which names a recipe any more + diff --git a/openspec/specs/cli-rule-format/spec.md b/openspec/specs/cli-rule-format/spec.md index 9eca5e46..3909f7b2 100644 --- a/openspec/specs/cli-rule-format/spec.md +++ b/openspec/specs/cli-rule-format/spec.md @@ -3,23 +3,7 @@ ## Purpose TBD - created by archiving change partition-rules-by-engine. Update Purpose after archive. - ## Requirements - -### Requirement: Rules are partitioned into per-engine directories - -The system SHALL store rules under a top-level engine directory `.taskless//`, each with a `rules/` directory and a `rule-tests/` directory. The `sg` engine SHALL use `sgconfig.yml`; the `vale` engine SHALL use `.vale.ini`; the `runtime` engine SHALL store each rule as a directory `rules//` (capture `*.yml` + `check.ts`) with fixtures under `rule-tests//`. - -#### Scenario: ast-grep engine directory - -- **WHEN** the CLI resolves `.taskless/` -- **THEN** ast-grep rules are found under `.taskless/sg/rules/`, the config is `.taskless/sg/sgconfig.yml`, and tests are under `.taskless/sg/rule-tests/` - -#### Scenario: Vale engine directory - -- **WHEN** the CLI resolves `.taskless/` -- **THEN** Vale styles are found under `.taskless/vale/rules/`, the config is `.taskless/vale/.vale.ini`, and tests are under `.taskless/vale/rule-tests/` - ### Requirement: A rule's engine is determined by its containing directory The system SHALL dispatch each rule to the engine named by its top-level `.taskless//` directory, and SHALL NOT parse a rule file to determine its engine. @@ -29,20 +13,6 @@ The system SHALL dispatch each rule to the engine named by its top-level `.taskl - **WHEN** a rule file exists at `.taskless/sg/rules/no-eval.yml` and another at `.taskless/vale/rules/no-simply.yml` - **THEN** the first is executed by ast-grep and the second by Vale, based solely on directory -### Requirement: Each engine's committed native config is the source of truth - -The system SHALL treat each engine's committed native config as the authoritative definition of its rules, their scoping, and their metadata. The system SHALL NOT require a separate Taskless sidecar or metadata file for a rule, and SHALL NOT generate an engine config at check time. - -#### Scenario: No sidecar or generated config - -- **WHEN** the CLI runs a check -- **THEN** it reads the committed `sg/sgconfig.yml` and `vale/.vale.ini` as-is, and neither writes nor generates an engine config - -#### Scenario: Native scoping is applied by the engine - -- **WHEN** an ast-grep rule declares native `files`/`ignores`, or a Vale `.vale.ini` declares per-rule include/exclude sections -- **THEN** the engine applies that scoping directly, with no Taskless-side rule transformation - ### Requirement: Migration preserves existing ast-grep rules by moving them under sg The migration to the engine-partitioned layout SHALL move the existing `.taskless/rules/`, `.taskless/rule-tests/`, and `.taskless/sgconfig.yml` under `.taskless/sg/` without editing file contents, relying on `sgconfig.yml`'s relative `ruleDirs: [rules]` remaining valid after the move. It SHALL scaffold `.taskless/vale/` and SHALL move `.taskless/runtime-rules/` to `.taskless/runtime/rules/` and `.taskless/runtime-rule-tests/` to `.taskless/runtime/rule-tests/` without editing file contents (preserving runtime capture-rule hashes). Every scaffolded directory that would otherwise be empty SHALL contain a `.gitkeep` file so the structure is tracked reliably. @@ -80,22 +50,6 @@ Absence of an engine and an **unrecognized** engine are distinct. If a payload i - **WHEN** a payload identifies an engine the installed CLI does not support - **THEN** ingest exits with an error naming the engine and directing the user to upgrade, and no rule file is written under any engine directory -### Requirement: Both the legacy and engine-partitioned layouts are readable - -The CLI SHALL dispatch rules found at the legacy `.taskless/rules/` path as ast-grep, in addition to `.taskless/sg/rules/`, so a checkout that has not yet been migrated — or a rule delivered by a service that still names the legacy location — is executed rather than ignored. - -This tolerance is what decouples the CLI's release from any consumer's: a producer may continue to use the pre-migration layout indefinitely and its rules keep running. - -#### Scenario: Unmigrated checkout still runs its rules - -- **WHEN** `check` runs against a `.taskless/` containing `rules/` but no `sg/` -- **THEN** those rules are dispatched to ast-grep and reported, not silently skipped - -#### Scenario: Both layouts present - -- **WHEN** rules exist under both `.taskless/rules/` and `.taskless/sg/rules/` -- **THEN** both are dispatched to ast-grep and their findings merged, with no duplicate reporting of the same rule - ### Requirement: Reconciliation survives the relayout The CLI SHALL report rule files to the reconcile endpoint at their post-migration repo-relative paths. Because the server joins reported files by content signature rather than by path, moving a rule without editing it SHALL NOT change its reconciled state. @@ -119,11 +73,102 @@ When `taskless.json`'s `version` exceeds the highest migration the installed CLI - **WHEN** the same condition holds and `--allow-version-mismatches` is set - **THEN** the CLI proceeds without applying migrations -### Requirement: Vale styles live under the rules StyleName +### Requirement: Rules are one directory each, partitioned by engine + +The system SHALL store every rule as a directory at `.taskless/rules///`, holding the rule, any config that engine requires, and its tests under `.tests/`. + +| Engine | Rule directory contents | +|-----------|--------------------------------------------------------------| +| `sg` | `.yml`, `.tests/-YYYYMMDD-test.yml` | +| `vale` | `.yml`, `.vale.ini`, `.tests/pass/*`, `.tests/fail/*` | +| `runtime` | `check.ts`, `captures/*.yml`, `.tests/…` | + +One directory per rule is what lets a rule be addressed, reviewed, moved, or deleted as a single thing, and it is what makes `verify ` and `test ` possible without an id lookup. + +Tests SHALL live in `.tests/`, dot-prefixed. This is not cosmetic: ast-grep's `ruleDirs` recurses and parses every `.yml` beneath it as a rule, so a plain `tests/` directory inside a rule directory fails the scan outright. Measured against ast-grep 0.41.0, a dot-directory is skipped by rule discovery while `sg test` still reads it when `testDir` names it. + +#### Scenario: A rule is one path + +- **WHEN** a rule is authored for any engine +- **THEN** everything defining it lives under one `.taskless/rules///` directory +- **AND** removing that directory removes the rule completely + +#### Scenario: Test files are not mistaken for rules + +- **WHEN** an ast-grep rule directory contains `.tests/` with test YAML in it +- **THEN** a scan SHALL complete without attempting to parse those files as rules -The system SHALL place Vale styles under `.taskless/vale/rules/` so that `rules` is Vale's StyleName, with `.vale.ini` configured `StylesPath = .` and `BasedOnStyles = rules`. The Vale check identifier `rules.` SHALL be normalized to `ruleId = ` in results. +#### Scenario: The engine is read from the path + +- **WHEN** the system needs a rule's engine +- **THEN** it reads the `` path segment +- **AND** it SHALL NOT parse the rule file to determine it + +### Requirement: Each engine's native config is the source of truth + +The system SHALL treat each engine's native config as the authoritative definition of its rules, their scoping, and their metadata, and SHALL NOT require a separate Taskless sidecar or metadata file for a rule. + +Where an engine's configuration is per-rule, that per-rule file is the committed source of truth. Where the engine requires a single file at invocation — Vale accepts one `--config`, ast-grep one `sgconfig.yml` — the system SHALL assemble that file from the committed per-rule sources and SHALL gitignore the result. An assembled config is the engine's own native config, split along the boundary the engine's own scoping already has; it is not a Taskless sidecar. + +An engine SHALL NOT be given a per-rule config file it has nothing to put in. ast-grep expresses scoping (`files`, `ignores`) inside the rule itself, so it has no per-rule config; Vale cannot express scoping inside the style — measured, `E201 has invalid keys` — so it does. + +#### Scenario: Vale config is assembled from committed per-rule configs + +- **WHEN** the CLI runs a check +- **THEN** it reads each committed `rules/vale//.vale.ini` and assembles the config it hands to Vale +- **AND** the assembled file SHALL be gitignored + +#### Scenario: ast-grep config is assembled from the rule tree + +- **WHEN** the CLI runs a check or a test +- **THEN** it assembles `sgconfig.yml` with `ruleDirs` covering the rules tree and `testConfigs` covering each rule's `.tests/` +- **AND** the assembled file SHALL be gitignored + +#### Scenario: No empty per-rule config is required + +- **WHEN** an ast-grep rule is authored +- **THEN** no per-rule config file SHALL be required alongside it + +#### Scenario: Native scoping is applied by the engine + +- **WHEN** an ast-grep rule declares native `files`/`ignores`, or a Vale rule's config declares include/exclude matchers +- **THEN** the engine applies that scoping directly, with no Taskless-side rule transformation + +### Requirement: Vale styles live under a per-rule StyleName + +The system SHALL place each Vale rule in its own directory `.taskless/rules/vale//`, so that `` is Vale's StyleName, with the assembled config setting `StylesPath` to the Vale rules tree. The Vale check identifier `.` SHALL be normalized to `ruleId = ` in results. + +`StylesPath` follows the layout and cannot be chosen independently of it. Measured against Vale 3.17.1: a rule at `/.yml` resolves as check `.` under a StylesPath naming its parent, and resolves to nothing at all under `StylesPath = .`. The reverse held for the previous flat layout — the same setting is correct for one layout and silently wrong for the other. #### Scenario: Style resolution and identity -- **WHEN** a Vale style exists at `.taskless/vale/rules/no-simply.yml` -- **THEN** Vale loads it as `rules.no-simply`, and the CLI reports its findings with `ruleId` `no-simply` +- **WHEN** a Vale style exists at `.taskless/rules/vale/no-simply/no-simply.yml` +- **THEN** Vale loads it as `no-simply.no-simply`, and the CLI reports its findings with `ruleId` `no-simply` + +#### Scenario: A rule's tests are not loaded as styles + +- **WHEN** a Vale rule directory contains `.tests/` +- **THEN** Vale SHALL NOT load anything under it as a rule + +### Requirement: Runtime capture rules live in captures + +A runtime rule's ast-grep capture rules SHALL live in `captures/` inside the rule directory, and `check.ts` SHALL remain at the rule directory's root. + +The name is deliberate. "Matcher" denotes a Vale `[]` config section elsewhere in this system, and one word for two unrelated concepts in one tree is a cost paid at every future reading. + +#### Scenario: Capture rules are found in captures + +- **WHEN** the system discovers a runtime rule +- **THEN** it reads its capture rules from `captures/` +- **AND** it reads `check.ts` from the rule directory root + +### Requirement: A rule's canonical location is what verify and test address + +Each engine SHALL have one canonical on-disk location per rule — the rule directory — and that location SHALL be what `verify` and `test` accept as a path. A directory above it SHALL mean every rule beneath. + +#### Scenario: One address per rule + +- **WHEN** `verify` or `test` is given `.taskless/rules///` +- **THEN** it operates on exactly that rule +- **AND** the engine is determined from the path without reading the rule + diff --git a/openspec/specs/cli-rule-routing/spec.md b/openspec/specs/cli-rule-routing/spec.md index cebcbd9f..bb23c2c7 100644 --- a/openspec/specs/cli-rule-routing/spec.md +++ b/openspec/specs/cli-rule-routing/spec.md @@ -3,16 +3,14 @@ ## Purpose TBD - created by archiving change local-rule-routing. Update Purpose after archive. - ## Requirements - ### Requirement: Route is the local authoring classifier -The CLI SHALL provide a `route` help recipe that instructs the agent to classify -a rule-authoring request into one of three destinations — `existing`, `static`, -or `remote` — using `taskless detect --json` signals plus the user's intent. The -`route` recipe SHALL be biased to stay local: it SHALL prefer `existing` or -`static` and SHALL treat `remote` as the escalation of last resort. +The CLI SHALL provide a `route` help recipe that instructs the agent to classify a rule-authoring request into one of five destinations — `create-legacy-rule`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, or `create-remote-rule` — using `taskless detect --json` signals plus the user's intent. The `route` recipe SHALL read the user's login state before dispatching, since it determines which destinations are reachable. It SHALL remain biased to stay local: local authoring that works SHALL NOT be abandoned for the service. + +`route` SHALL decide the engine as part of this classification rather than deferring it to a separate topic. There is one decision, made from one reading of the evidence: whether a rule is expressible locally and which engine can express it are answered from the same signals, so splitting them costs a second fetch and a handoff without adding information. + +Each destination SHALL be a topic an agent can fetch by name, so classifying produces a command to run rather than a category to interpret. #### Scenario: Route fetches detection before classifying @@ -20,12 +18,40 @@ or `remote` — using `taskless detect --json` signals plus the user's intent. T - **THEN** the recipe SHALL direct the agent to run `taskless detect --json` and use its signals as input to the classification -#### Scenario: Route classifies into one of three destinations +#### Scenario: Route classifies into one of five destinations - **WHEN** the agent follows `route` -- **THEN** it SHALL select exactly one of `existing`, `static`, or `remote` +- **THEN** it SHALL select exactly one of `create-legacy-rule`, `create-sg-rule`, `create-vale-rule`, `create-runtime-rule`, or `create-remote-rule` - **AND** it SHALL fetch the corresponding recipe to perform the authoring +#### Scenario: Every destination resolves to a recipe + +- **WHEN** any destination `route` can name is fetched +- **THEN** a recipe of that exact name SHALL exist + +#### Scenario: Service generation is offered only where it is a choice + +- **WHEN** the rule is expressible locally AND the user is logged in +- **THEN** `route` MAY offer `create-remote-rule` as an alternative and ask the user +- **AND WHEN** the user is not logged in, or the rule is not expressible locally +- **THEN** `route` SHALL NOT pose service generation as a choice, because it is not one + +#### Scenario: A logged-in runtime request routes straight to the service + +- **WHEN** the rule requires the runtime engine AND the user is logged in +- **THEN** `route` SHALL name `create-remote-rule` +- **AND** no recipe SHALL forward the agent from one destination to another + +#### Scenario: A logged-out runtime request reaches the explanation + +- **WHEN** the rule requires the runtime engine AND the user is not logged in +- **THEN** `route` SHALL name `create-runtime-rule` + +#### Scenario: The engine is decided without a second fetch + +- **WHEN** the agent follows `route` +- **THEN** it SHALL arrive at an engine-specific recipe without fetching a separate engine-selection topic + ### Requirement: Route states reasoning before naming a destination The `route` recipe SHALL require the agent to write an explicit rationale before @@ -135,141 +161,89 @@ a default. ### Requirement: Existing recipe authors in the detected linter's dialect -The CLI SHALL provide an `existing` help recipe that instructs the agent to -author a rule in a linter already detected in the repository, expressed in that -tool's own dialect. The recipe SHALL direct the agent to source authoring -knowledge first from the repository's own existing rules and only then from the -agent's own web research. The recipe SHALL NOT embed or rely on a Taskless- -maintained catalog of linter rules. - -#### Scenario: Repo-first knowledge sourcing +The CLI SHALL provide a `create-legacy-rule` help recipe that instructs the agent to author a rule in a linter already detected in the repository, expressed in that tool's own dialect. The recipe SHALL direct the agent to source authoring knowledge first from the repository's own existing rules and only then from the agent's own web research. The recipe SHALL NOT embed or rely on a Taskless-maintained catalog of linter rules. -- **WHEN** the agent follows `existing` for a detected linter -- **THEN** it SHALL first mine the repository's existing rules of that kind for - house style -- **AND** SHALL fall back to web research (WebFetch/WebSearch) only when the - repository signal is insufficient +The recipe is named for the artifact it produces. "Existing" described the repository's state rather than the rule being written, which is not something an agent can address by name. -#### Scenario: Existing path is author-only +#### Scenario: Repo-first knowledge sourcing -- **WHEN** the agent authors a rule via `existing` -- **THEN** the recipe SHALL make clear the user's own toolchain runs the rule and - that `taskless check` does not execute the external linter +- **WHEN** the agent follows `create-legacy-rule` +- **THEN** it SHALL read the repository's own rules for that linter before consulting any external source ### Requirement: Static recipe authors a verified local ast-grep rule -The CLI SHALL provide a `static` help recipe that instructs the agent to author a +The CLI SHALL provide a `create-sg-rule` help recipe that instructs the agent to author a local ast-grep rule on-device, without calling the Taskless service, and to verify it against the user's success and failure cases before reporting success. The recipe SHALL produce the canonical on-disk rule shape and paths used by remote generation so that `check`, `improve`, and `verify` see a single dialect. +The recipe SHALL be named for the artifact it produces rather than for a trust tier. "Static" describes when a rule runs, which is a different axis from which engine enforces it, and naming the ast-grep authoring path after the tier taught the conflation that engine selection exists to correct. + #### Scenario: Local authoring without the service -- **WHEN** the agent follows `static` +- **WHEN** the agent follows `create-sg-rule` - **THEN** it SHALL write the rule on-device without requiring login or the Taskless API -#### Scenario: Verification gates success - -- **WHEN** the agent authors a static rule -- **THEN** it SHALL verify the rule against the provided success/failure cases - before reporting the rule as complete - -#### Scenario: Canonical output shape - -- **WHEN** the agent writes a static rule to disk -- **THEN** the files, paths, and shape SHALL match those produced by remote - generation - ### Requirement: Remote recipe collects inputs and delegates to the service -The CLI SHALL provide a `remote` help recipe that instructs the agent to gather -the inputs required to call the Taskless service and to invoke the existing rule -generation backend, which runs the service-side classifier and returns either a -static or a runtime rule. The `remote` recipe SHALL require authentication and -SHALL NOT itself decide static versus runtime. - -#### Scenario: Remote requires authentication - -- **WHEN** the agent follows `remote` while logged out -- **THEN** the recipe SHALL direct the agent to the authentication flow before - submitting the request - -#### Scenario: Static-versus-runtime is decided by the service +The CLI SHALL provide a `create-remote-rule` help recipe that instructs the agent to gather the inputs required to call the Taskless service and to invoke the existing rule generation backend, which runs the service-side classifier and returns either a static or a runtime rule. The recipe SHALL require authentication and SHALL NOT itself decide static versus runtime. -- **WHEN** the agent submits an authored request via `remote` -- **THEN** the recipe SHALL rely on the service to classify static versus runtime -- **AND** SHALL NOT make that determination locally +#### Scenario: The remote recipe requires authentication -#### Scenario: Remote output matches local on-disk shape +- **WHEN** the agent follows `create-remote-rule` while logged out +- **THEN** the recipe SHALL direct the agent to `auth` rather than calling the service -- **WHEN** the service returns a generated rule via `remote` -- **THEN** the written files and paths SHALL match the shape produced by the - local `static` path - -### Requirement: An engine-selection topic states which engine can enforce a rule - -The CLI SHALL provide a knowledge topic that decides, for a requested rule, **which engine can enforce it** — `sg`, `vale`, or `runtime` — valued as the engine's on-disk directory name. The topic SHALL define each engine by the information a rule fundamentally needs: - -- **`sg`** — expressible as a pattern over a single file's syntax tree, including correlation between constructs within that same file via relational operators. -- **`vale`** — the target is prose or markup content rather than code structure. -- **`runtime`** — needs information no single file's syntax tree contains: cross-file consistency, import or call graph, comparison against a non-code file, file metadata, or values requiring normalization a static pattern cannot express. +### Requirement: Available code context outranks the phrasing of the request -The topic SHALL instruct that the decision follow from what the rule fundamentally needs rather than how the request was phrased, and that the reasoning be stated before the engine is named. +Where code or diff context is available, `route` SHALL weigh the concrete syntactic form present in the repository above the wording of the request, since the same request routes differently depending on the form the code actually takes. -#### Scenario: Engine named for a single-file structural rule +This bound the standalone engine-selection topic. That topic is gone, but the reasoning is not — it now binds the place the decision is actually made. -- **WHEN** the topic is applied to a request expressible as a pattern over one file's syntax tree -- **THEN** it selects `sg` +#### Scenario: Concrete form changes the engine -#### Scenario: Engine named for a prose rule +- **WHEN** a rule is statically correlatable in the form the repository actually contains +- **THEN** `route` selects `create-sg-rule` +- **AND WHEN** the equivalent rule requires normalizing a captured value to match a declaration elsewhere +- **THEN** it selects a runtime destination, despite an identically phrased request -- **WHEN** the topic is applied to a request targeting prose or markup content -- **THEN** it selects `vale` +### Requirement: Ambiguity resolves to an engine known to be available -#### Scenario: Engine named for a cross-file rule +When no engine is clearly indicated, `route` SHALL direct the reader to choose an engine whose availability can be asserted in the situation at hand, and to give that availability as the reason for the call. It SHALL NOT name a fixed fallback engine. Both `sg` and `vale` ship as platform binaries, so either can be the missing one on an unsupported architecture or where an install was blocked; server-side the constraint is different again, `sg` being the only ungated route. A named default is wrong in whichever of those situations it failed to anticipate, which is why the requirement is stated as a property rather than as a fact about any one engine. -- **WHEN** the topic is applied to a request requiring information beyond a single file's syntax tree -- **THEN** it selects `runtime` +#### Scenario: Ambiguous request resolves to an assertably available engine -### Requirement: Engine selection is a separate axis from authoring destination +- **WHEN** the available context does not disambiguate which engine can enforce a rule +- **THEN** `route` selects an engine whose availability it can assert, and states that availability as the reasoning that made the call close -The engine-selection topic SHALL decide only which engine enforces a rule, and SHALL NOT decide where the rule is authored — that remains the `route` topic's concern. Locally the two compose, `route` first and engine selection second. +#### Scenario: The default is never an unavailable engine -The topic SHALL NOT describe login, reconciliation, or signing as inputs to the engine choice: `sg` and `vale` are both static-tier, and only `runtime` carries those concerns, so trust tier is a distinct axis from engine selection. +- **WHEN** an engine is unavailable in the current environment, such as the Vale binary being absent +- **THEN** the ambiguity default SHALL NOT name it -#### Scenario: Topic stays clear of authoring destination +### Requirement: Trust tier is not an engine-selection input -- **WHEN** the engine-selection topic is applied -- **THEN** it names an engine and does not select among `existing`, `static`, or `remote` authoring destinations +Engine reasoning SHALL NOT treat login, reconciliation, or signing as inputs to the engine choice: `sg` and `vale` are both static-tier, and only `runtime` carries those concerns, so trust tier is a distinct axis from which engine can express a rule. #### Scenario: Trust tier is not an engine-selection input -- **WHEN** the topic distinguishes `sg` from `vale` +- **WHEN** the reasoning distinguishes `sg` from `vale` - **THEN** it does so on the prose-versus-structure axis, not on any auth, reconcile, or signing property, since both are static-tier -### Requirement: Available code context outranks the phrasing of the request - -Where code or diff context is available, the engine-selection topic SHALL weigh the concrete syntactic form present in the repository above the wording of the request, since the same request routes differently depending on the form the code actually takes. - -#### Scenario: Concrete form changes the engine +### Requirement: Engine reasoning lives in route and in each destination -- **WHEN** a rule is statically correlatable in the form the repository actually contains -- **THEN** the topic selects `sg` -- **AND WHEN** the equivalent rule requires normalizing a captured value to match a declaration elsewhere -- **THEN** it selects `runtime`, despite an identically phrased request +The engine criterion SHALL be stated once, in `route`'s destination table, which is where the comparison between engines is made. It SHALL NOT be stated in a separate chooser topic, and SHALL NOT be restated in the destination recipes. -### Requirement: Ambiguity resolves to an engine known to be available +One statement is the point. A criterion copied into each destination is five copies of one test, and the first edit to any of them is a divergence nobody notices — the drift this merge exists to remove, reappearing one level down. Destinations orient the reader to their own scope instead, which needs nothing about the other engines. -When no engine is clearly indicated, the engine-selection topic SHALL direct the reader to choose an engine whose availability can be asserted in the situation at hand, and to give that availability as the reason for the call. The topic SHALL NOT name a fixed fallback engine. Both `sg` and `vale` ship as platform binaries, so either can be the missing one on an unsupported architecture or where an install was blocked; server-side the constraint is different again, `sg` being the only ungated route. A named default is wrong in whichever of those situations it failed to anticipate, which is why the requirement is stated as a property rather than as a fact about any one engine. +#### Scenario: The comparison lives in one place -#### Scenario: Ambiguous request resolves to an assertably available engine +- **WHEN** the embedded recipe set is inspected +- **THEN** exactly one recipe SHALL state the criterion distinguishing the engines from each other -- **WHEN** the available context does not disambiguate which engine can enforce a rule -- **THEN** the topic selects an engine whose availability it can assert, and states that availability as the reasoning that made the call close +#### Scenario: No separate chooser topic exists -#### Scenario: The default is never an unavailable engine +- **WHEN** the embedded recipe set is inspected +- **THEN** there SHALL be no topic whose only purpose is selecting among engines -- **WHEN** an engine is unavailable in the current environment, such as the Vale binary being absent -- **THEN** the ambiguity default SHALL NOT name it diff --git a/openspec/specs/cli-rule-validation/spec.md b/openspec/specs/cli-rule-validation/spec.md new file mode 100644 index 00000000..187276d6 --- /dev/null +++ b/openspec/specs/cli-rule-validation/spec.md @@ -0,0 +1,87 @@ +# cli-rule-validation Specification + +## Purpose +TBD - created by archiving change self-contained-rules. Update Purpose after archive. +## Requirements +### Requirement: Rules are validated and tested by path, not by id + +The CLI SHALL provide `verify ` and `test `. Both SHALL accept a path to a rule's canonical location or to any directory above it, and SHALL resolve the owning engine from the path's position under `.taskless/rules//` rather than by parsing the file. + +An id does not name one thing. The same id can exist under `sg` and under `vale`, so an id-addressed command has to either guess or report an ambiguity; a path has neither problem. Resolving the engine from position — never from content — is the same rule dispatch follows, so a rule cannot be validated by one engine and executed by another. + +#### Scenario: A rule path resolves to its engine + +- **WHEN** `verify .taskless/rules/vale/no-simply` is run +- **THEN** the CLI SHALL validate it as a Vale rule + +#### Scenario: The same id under two engines is not ambiguous + +- **WHEN** `no-simply` exists under both `rules/sg/` and `rules/vale/` +- **THEN** each is addressed by its own path +- **AND** neither command SHALL require the user to disambiguate + +#### Scenario: A directory means everything beneath it + +- **WHEN** `verify .taskless/` is run +- **THEN** every rule beneath it SHALL be validated, each against its own engine +- **AND** the command SHALL report per-rule results rather than a single pass or fail + +#### Scenario: A path outside any engine's rules directory is rejected + +- **WHEN** a path resolves to no engine +- **THEN** the CLI SHALL exit non-zero naming the path, rather than guessing an engine + +### Requirement: Verify checks a rule's required components + +`verify` SHALL check that a rule has the components its engine requires and that they are well formed, and SHALL NOT require fixtures or test cases to exist. + +The two commands split because they have different preconditions. An agent part-way through authoring has a rule and no fixtures yet, and needs to know the rule itself is valid before it can write a meaningful test for it. + +Per engine, `verify` SHALL check: + +| Engine | Components | +|-----------|--------------------------------------------------------------------------------| +| `sg` | `.yml` against the ast-grep schema and the Taskless required fields | +| `vale` | `.yml` against Vale's own validation, and the rule's `.vale.ini` | +| `runtime` | `check.ts` present, and at least one capture rule under `captures/` | + +#### Scenario: A rule with no fixtures still verifies + +- **WHEN** `verify` runs against a rule whose fixture buckets are empty or absent +- **THEN** it SHALL report on the rule's components only +- **AND** the absence of fixtures SHALL NOT be a verify failure + +#### Scenario: A malformed rule reports its own error + +- **WHEN** a Vale style declares a `level` outside `suggestion`/`warning`/`error` +- **THEN** `verify` SHALL report that error, naming the field + +### Requirement: Test runs a rule's fixtures and runs verify first + +`test` SHALL execute a rule against its test material — ast-grep test cases, Vale `pass`/`fail` fixture buckets, or the runtime harness — and SHALL run `verify` first, stopping on a verify failure without running the fixtures. + +Ordering is the point. When a rule is both malformed and under-fixtured, the fixture complaint is the less useful of the two errors and is the one that surfaces first if the checks run in the other order — so the author is told their fixtures are incomplete while the reason the rule could never have run goes unmentioned. + +#### Scenario: A malformed rule reports the malformation, not the fixtures + +- **WHEN** `test` runs against a rule that is both invalid and missing a fixture bucket +- **THEN** it SHALL report the validation error +- **AND** it SHALL NOT report the fixture coverage as the failure + +#### Scenario: Vale fixtures are tested per bucket + +- **WHEN** `test` runs against a Vale rule +- **THEN** every `fail/` document SHALL produce at least one finding for that rule +- **AND** every `pass/` document SHALL produce none +- **AND** a rule populating only one bucket SHALL be reported as unverified rather than passing + +### Requirement: The generation loop runs verify and test + +The rule generation loop SHALL run `verify` and then `test` against a newly authored or newly delivered rule, and SHALL treat a failure of either as a rule that is not ready to report as complete. + +#### Scenario: A generated rule is checked before it is reported + +- **WHEN** a rule is authored locally or written by the service +- **THEN** the loop SHALL run `verify` and `test` against its path +- **AND** SHALL surface a failure rather than reporting the rule as written + diff --git a/openspec/specs/cli-rules/spec.md b/openspec/specs/cli-rules/spec.md index e6794a6b..c8623f24 100644 --- a/openspec/specs/cli-rules/spec.md +++ b/openspec/specs/cli-rules/spec.md @@ -2,13 +2,13 @@ ## Purpose -Defines the `rules` subcommand group for the Taskless CLI, including `create`, `improve`, `delete`, and `verify` subcommands for managing ast-grep rules. Also documents the server-side API contract for rule generation endpoints. +Defines the `rules` subcommand group for the Taskless CLI, including `create`, `improve`, `delete`, and `meta` subcommands for managing ast-grep rules. Also documents the server-side API contract for rule generation endpoints. ## Requirements ### Requirement: Rules subcommand group exists -The CLI SHALL expose the rule operations under the `rule` (singular) subcommand group. The user-facing surface SHALL be `taskless rule create`, `taskless rule improve`, `taskless rule delete`, `taskless rule verify`, and `taskless rule meta`. The internal source filename (`packages/cli/src/commands/rules.ts`) MAY remain plural — only the user-visible subcommand name changes. +The CLI SHALL expose the rule operations under the `rule` (singular) subcommand group. The user-facing surface SHALL be `taskless rule create`, `taskless rule improve`, `taskless rule delete`, and `taskless rule meta`. Rule validation is not part of this group — it is addressed by path through the top-level `verify` and `test` commands, specified by the `cli-rule-validation` capability. The internal source filename (`packages/cli/src/commands/rules.ts`) MAY remain plural — only the user-visible subcommand name changes. The previous plural form `taskless rules ` SHALL NOT work in v0.7.0 — there is no compatibility alias. @@ -34,7 +34,13 @@ The `taskless rule create` command SHALL accept a `--from ` flag specifyin ### Requirement: Rules create resolves identity from JWT and git remote -`taskless rule create` resolves user identity from the stored JWT and the git remote per the existing identity resolution requirements. (Renamed to singular.) +`taskless rule create` SHALL resolve user identity from the stored JWT and the git remote per the existing identity resolution requirements. (Renamed to singular.) + +#### Scenario: Identity comes from the token and the remote + +- **WHEN** an authenticated user runs `taskless rule create` +- **THEN** the CLI SHALL take the organization from the stored JWT +- **AND** it SHALL take the repository from the git remote rather than prompting for either ### Requirement: Rules create requires authentication @@ -52,7 +58,13 @@ The `taskless rule create` command SHALL accept a `--from ` flag specifyin ### Requirement: Rules create submits to API and polls for results -`taskless rule create` (without `--anonymous`) submits to the API and polls per the existing requirement. (Renamed to singular.) +`taskless rule create` without `--anonymous` SHALL submit the request to the API and poll for the result per the existing requirement. (Renamed to singular.) + +#### Scenario: Submission returns a request to poll + +- **WHEN** an authenticated user runs `taskless rule create` without `--anonymous` +- **THEN** the CLI SHALL submit the request to the API +- **AND** it SHALL poll for the result until the generation completes or fails ### Requirement: Rules create uses a network interface with stub @@ -70,60 +82,128 @@ The API calls for rule generation (`POST /cli/api/request` and `GET /cli/api/req ### Requirement: Rules create writes rule files to disk -`taskless rule create` SHALL write the generated rule file to `.taskless/rules/.yml` regardless of whether `--anonymous` was set. The agent invoking the command SHALL NOT be expected to write rule files itself. (Renamed to singular; this strengthens the existing requirement to apply to both branches.) +`taskless rule create` SHALL write the generated rule file into the rule's own directory, at `.taskless/rules/sg//.yml`, regardless of whether `--anonymous` was set. The agent invoking the command SHALL NOT be expected to write rule files itself. (Renamed to singular; this strengthens the existing requirement to apply to both branches.) #### Scenario: Both branches write rule files - **WHEN** `taskless rule create` succeeds (with or without `--anonymous`) -- **THEN** `.taskless/rules/.yml` SHALL exist on disk +- **THEN** `.taskless/rules/sg//.yml` SHALL exist on disk ### Requirement: Rules create writes test files to disk -`taskless rule create` SHALL write generated test files to `.taskless/rule-tests/.yml` regardless of whether `--anonymous` was set. (Renamed; strengthened.) +`taskless rule create` SHALL write generated test files into the rule's own directory, at `.taskless/rules/sg//.tests/`, regardless of whether `--anonymous` was set. (Renamed; strengthened; repathed for the rule-directory layout.) + +#### Scenario: Tests land inside the rule they cover + +- **WHEN** `taskless rule create` generates test cases for rule `` +- **THEN** the CLI SHALL write them under `.taskless/rules/sg//.tests/` +- **AND** it SHALL do so whether or not `--anonymous` was set ### Requirement: Rules create outputs results -`taskless rule create` outputs results per the existing requirement. (Renamed to singular.) Output SHALL be human-readable by default; `--json` produces machine-readable output. On failure with `--json` set, the output SHALL be the standardized error envelope `{ ok: false, code: "", message: "<...>" }` per the `cli` capability requirements. +`taskless rule create` SHALL output results per the existing requirement. (Renamed to singular.) Output SHALL be human-readable by default; `--json` produces machine-readable output. On failure with `--json` set, the output SHALL be the standardized error envelope `{ ok: false, code: "", message: "<...>" }` per the `cli` capability requirements. + +#### Scenario: Failure under --json uses the error envelope + +- **WHEN** `taskless rule create --json` fails +- **THEN** the CLI SHALL print `{ ok: false, code, message }` rather than prose ### Requirement: Rules create shows progress during polling -`taskless rule create` shows progress per the existing requirement when polling the API (the `--anonymous` branch does not poll an API and SHOULD show progress for the local agent-driven steps if applicable). (Renamed to singular.) +`taskless rule create` SHALL show progress while polling the API. The `--anonymous` branch polls nothing and SHOULD show progress for the local agent-driven steps where applicable. (Renamed to singular.) + +#### Scenario: Polling reports progress + +- **WHEN** `taskless rule create` is waiting on the API +- **THEN** the CLI SHALL report progress rather than appearing to hang ### Requirement: Rules improve reads request from file `taskless rule improve` SHALL accept a `--from ` flag specifying a JSON file containing the iterate request. (Renamed to singular.) +#### Scenario: The request is read from the named file + +- **WHEN** a user runs `taskless rule improve --from request.json` +- **THEN** the CLI SHALL read the iterate request from that file + ### Requirement: Rules improve requires authentication `taskless rule improve` SHALL require authentication unless `--anonymous` is set. (Renamed; new anonymous branch.) +#### Scenario: Authentication is required without --anonymous + +- **WHEN** a logged-out user runs `taskless rule improve` without `--anonymous` +- **THEN** the CLI SHALL exit non-zero and direct the user to authenticate + +#### Scenario: The anonymous branch skips authentication + +- **WHEN** a logged-out user runs `taskless rule improve --anonymous` +- **THEN** the CLI SHALL run the local-only flow without requiring a login + ### Requirement: Rules improve submits to iterate API and polls for results -`taskless rule improve` (without `--anonymous`) submits and polls per the existing requirement. (Renamed.) +`taskless rule improve` without `--anonymous` SHALL submit to the iterate API and poll for the result per the existing requirement. (Renamed.) + +#### Scenario: Submission returns a request to poll + +- **WHEN** an authenticated user runs `taskless rule improve` without `--anonymous` +- **THEN** the CLI SHALL submit to the iterate API +- **AND** it SHALL poll until the iteration completes or fails ### Requirement: Rules improve writes updated files to disk `taskless rule improve` SHALL write updated rule files to disk in both branches. (Renamed; strengthened.) +#### Scenario: Both branches persist the updated rule + +- **WHEN** `taskless rule improve` completes, with or without `--anonymous` +- **THEN** the CLI SHALL write the updated rule to its canonical location on disk + ### Requirement: Rules improve outputs results -`taskless rule improve` outputs results per the existing requirement. (Renamed.) Failure output with `--json` SHALL use the standardized error envelope. +`taskless rule improve` SHALL output results per the existing requirement. (Renamed.) Failure output with `--json` SHALL use the standardized error envelope. + +#### Scenario: Failure under --json uses the error envelope + +- **WHEN** `taskless rule improve --json` fails +- **THEN** the CLI SHALL print `{ ok: false, code, message }` + +### Requirement: Rules improve has an agent recipe -### Requirement: Rules improve has a help entry +`taskless agent improve-rule` SHALL return the recipe per `cli-help` requirements. The recipe file is `improve-rule.txt`, with an `improve-rule.anonymous.txt` variant for the local-only flow. -`taskless help rule improve` SHALL return the recipe per `cli-help` requirements. (Renamed; the help filename becomes `rule-improve.txt` with an optional `rule-improve.anonymous.txt` variant.) +#### Scenario: The recipe resolves by its single-token name + +- **WHEN** a user runs `taskless agent improve-rule` +- **THEN** the CLI SHALL print the contents of `improve-rule.txt` ### Requirement: Rules delete removes rule and test files -`taskless rule delete ` SHALL remove the corresponding rule file and any test files. (Renamed.) Accepts `--anonymous` as a no-op. +`taskless rule delete ` SHALL remove the rule and everything that defines it. Under the rule-directory layout that is one directory, `.taskless/rules///`, which carries the rule, any per-engine config, and its tests. (Renamed; repathed.) Accepts `--anonymous` as a no-op. + +#### Scenario: Deleting a rule removes its whole directory + +- **WHEN** a user runs `taskless rule delete no-eval` +- **THEN** the CLI SHALL remove the rule's directory including its `.tests/` +- **AND** no file belonging to that rule SHALL remain ### Requirement: Rules delete does not require authentication -`taskless rule delete` does not require authentication per the existing requirement. (Renamed.) +`taskless rule delete` SHALL NOT require authentication. Deleting a local file is not a service operation. (Renamed.) + +#### Scenario: Deletion works logged out + +- **WHEN** a logged-out user runs `taskless rule delete no-eval` +- **THEN** the CLI SHALL delete the rule without requiring a login ### Requirement: Rules delete accepts the id argument -`taskless rule delete ` accepts the rule ID as a positional argument per the existing requirement. (Renamed.) +`taskless rule delete ` SHALL accept the rule ID as a positional argument per the existing requirement. (Renamed.) + +#### Scenario: The id is positional + +- **WHEN** a user runs `taskless rule delete no-eval` +- **THEN** the CLI SHALL treat `no-eval` as the rule ID ### Requirement: Codegen script fetches official ast-grep rule schema @@ -160,49 +240,34 @@ The codegen script SHALL extract the ast-grep version from `packages/cli/package - **THEN** the codegen script SHALL exit with a non-zero code and a descriptive error message - **AND** SHALL NOT overwrite an existing generated schema file +### Requirement: The rule subcommand group no longer validates rules + +`taskless rule verify` SHALL NOT exist. Rule validation is addressed by path through the top-level `verify` and `test` commands, specified by the `cli-rule-validation` capability. + +#### Scenario: The removed subcommand does not resolve + +- **WHEN** a user runs `taskless rule verify no-eval` +- **THEN** the CLI SHALL exit non-zero +- **AND** it SHALL NOT validate a rule + ### Requirement: Generated schema is importable at build time The generated JSON Schema file SHALL be importable by the CLI bundle via Vite. The import SHALL make the full JSON Schema object available at runtime without filesystem reads or network fetches. #### Scenario: Schema imported in verify command -- **WHEN** the `rule verify` command needs the ast-grep schema +- **WHEN** the `verify` command needs the ast-grep schema - **THEN** it SHALL import the schema from `../generated/ast-grep-rule-schema.json` - **AND** the schema object SHALL be available synchronously at runtime -### Requirement: Verify subcommand validates rules against ast-grep schema - -`taskless rule verify` SHALL validate rules against the ast-grep schema per the existing requirement. (Renamed from `rules verify` to `rule verify`.) Accepts `--anonymous` as a no-op. - -### Requirement: Verify performs three layers of validation - -`taskless rule verify` performs the three layers of validation per the existing requirement. (Renamed.) - -### Requirement: Verify supports JSON output - -`taskless rule verify --json` outputs results in the documented JSON shape. On failure, the standardized error envelope is used. (Renamed.) - -### Requirement: Verify schema mode dumps combined schema for agent consumption - -The `taskless rule verify --schema` mode is REMOVED in v0.7.0 — schemas are now embedded in `tskl help rule create` recipe output via `zod-to-json-schema`. (Renamed and superseded.) - -#### Scenario: --schema flag is no longer accepted - -- **WHEN** a user runs `taskless rule verify --schema` -- **THEN** the CLI SHALL exit with an error indicating the flag is unknown - -### Requirement: Verify respects global flags - -`taskless rule verify` respects global flags including `--dir` per the existing requirement. (Renamed.) Also accepts the new `--anonymous` flag as a no-op. - ### Requirement: Rule create supports anonymous local-only flow When `taskless rule create --anonymous` is invoked, the CLI SHALL execute the local-only rule-creation flow (previously implemented as the `taskless-create-rule-anonymous` skill body). The flow SHALL: 1. NOT submit any request to the Taskless API 2. Generate the ast-grep rule using local logic (Claude SDK, agent-driven generation, or whatever the migrated implementation prefers — see design.md) -3. Write the rule file to `.taskless/rules/.yml` -4. Write any generated test files to `.taskless/rule-tests/.yml` +3. Write the rule file to `.taskless/rules/sg//.yml` +4. Write any generated test files into that rule's own directory, under `.taskless/rules/sg//.tests/` 5. NOT write a metadata sidecar (the API-backed branch does) 6. Return the same output format as the API-backed branch (paths to created files) @@ -223,7 +288,7 @@ When `taskless rule improve --anonymous` is invoked, the CLI SHALL execute the l 1. NOT submit any request to the Taskless API iterate endpoint 2. Update the rule file in place using local logic -3. Support the verify feedback loop by exposing the `rule verify` primitive that the agent invokes between edits +3. Support the verify feedback loop by exposing the top-level `verify` primitive that the agent invokes between edits 4. Return the same output format as the API-backed branch #### Scenario: rule improve --anonymous skips API @@ -232,7 +297,10 @@ When `taskless rule improve --anonymous` is invoked, the CLI SHALL execute the l - **THEN** the CLI SHALL NOT make any HTTP request to the Taskless API - **AND** SHALL update the target rule file -## API Contract +**API contract.** The requirements below describe the service endpoints the +`rule` subcommands call. They are grouped by a bold line rather than a +heading: a second `##` inside this section ends it, and everything after it +stops being read as a requirement. ### Requirement: Rule generation request endpoint accepts a request and returns a requestId @@ -352,7 +420,7 @@ Each rule in the `rules` array SHALL contain an `id` (string), a `content` objec ### Requirement: Generated rules may include test cases -Each rule in the `rules` array MAY include a `tests` object containing `valid` (array of strings — code that should NOT trigger the rule) and `invalid` (array of strings — code that SHOULD trigger the rule). +Each rule in the `rules` array MAY include a `tests` object. When present it SHALL contain `valid` (array of strings, code that must not trigger the rule) and `invalid` (array of strings, code that must trigger it). #### Scenario: Rule with test cases diff --git a/openspec/specs/cli-update-engine/spec.md b/openspec/specs/cli-update-engine/spec.md deleted file mode 100644 index 1ce0f1ab..00000000 --- a/openspec/specs/cli-update-engine/spec.md +++ /dev/null @@ -1,9 +0,0 @@ -# CLI Update Engine - -## Purpose - -This capability has been removed. The `update-engine` subcommand and all associated backend endpoints have been decommissioned. The CLI no longer manages scaffold upgrades. - -## Requirements - -_All requirements have been removed. This spec is retained for historical reference._ diff --git a/openspec/specs/cli-vale-rule-engine/spec.md b/openspec/specs/cli-vale-rule-engine/spec.md index f87026c1..86ae1f0d 100644 --- a/openspec/specs/cli-vale-rule-engine/spec.md +++ b/openspec/specs/cli-vale-rule-engine/spec.md @@ -3,9 +3,7 @@ ## Purpose TBD - created by archiving change add-vale-rule-engine. Update Purpose after archive. - ## Requirements - ### Requirement: Vale runs in the static tier without reconciliation or signing The system SHALL treat Vale as a static-tier engine — always run, with no server reconciliation or signature verification. Vale's `script` checks execute in a sandbox that exposes only pure-computation modules (`text`/`math`/`fmt`) with no host access, so a Vale rule is inert data equivalent in trust to a static ast-grep rule. @@ -15,51 +13,6 @@ The system SHALL treat Vale as a static-tier engine — always run, with no serv - **WHEN** the CLI runs a check while logged out or anonymous - **THEN** Vale rules are executed the same as ast-grep static rules, with no reconcile or signing step -### Requirement: Vale check executes against the committed config over the target paths - -The system SHALL run `vale --config .taskless/vale/.vale.ini --output=JSON --no-exit` over the resolved target paths, reading the committed config and styles as-is. The `.vale.ini` SHALL set `MinAlertLevel = suggestion` so that every finding surfaces to the client for normalization and filtering. - -#### Scenario: Check runs Vale via the committed config - -- **WHEN** the CLI runs a check and `.taskless/vale/` contains rules -- **THEN** it invokes Vale with `--config .taskless/vale/.vale.ini` over the target paths and parses the JSON output - -#### Scenario: No Vale rules present - -- **WHEN** `.taskless/vale/rules/` is empty -- **THEN** the CLI does not invoke Vale and produces no Vale findings - -### Requirement: Per-rule scoping is expressed via Vale config matchers - -The system SHALL express a Vale rule's scope through `.vale.ini` **matchers** — `[]` sections. Include is `rules. = YES`, exclude is `rules. = NO`. - -Precedence is **positional**, and the system SHALL order matchers accordingly rather than relying on a disable to win on its own. Measured against Vale 3.17.1: - -- Where two matchers both match a file, the **last** one wins for that rule. -- Where the same key is assigned twice inside one matcher — including across duplicate `[]` sections, which Vale merges — the **first** assignment wins. - -A disable therefore SHALL be declared **after** the enable it narrows. Duplicate `[]` matchers SHALL be treated as merged, and a rule's scope SHALL NOT be expressed as a repeated assignment of the same key within one glob, since the later assignment is discarded. - -#### Scenario: Duplicate matchers merge - -- **WHEN** two `[*.md]` matchers each enable a different rule -- **THEN** both rules run on a matching `.md` file (Vale merges the matchers) - -#### Scenario: Include scopes a rule to a path - -- **WHEN** a rule is enabled only under `[marketing/**]` -- **THEN** the rule produces findings in `marketing/` files and none in `api/` files - -#### Scenario: A later matcher overrides an earlier one - -- **WHEN** a rule is enabled under `[marketing/**]` and then disabled under `[marketing/legacy/**]` -- **THEN** the rule fires in `marketing/` but not in `marketing/legacy/` - -#### Scenario: Declaration order is significant - -- **WHEN** the same two matchers are declared in the opposite order — `[marketing/legacy/**]` disabling first, `[marketing/**]` enabling second -- **THEN** the rule fires in `marketing/legacy/` as well, because the later enable wins; a disable does not take precedence on its own - ### Requirement: Vale findings map to the scanner-agnostic CheckResult The system SHALL map each Vale finding to a `CheckResult` with `source` `"vale"` and `ruleId` equal to the Vale check name with its `rules.` prefix stripped. Severity SHALL be normalized `error → error`, `warning → warning`, `suggestion → hint`. The system SHALL map `message` from `Message`, `note` from `Description`/`Link`, `range` from `Line`/`Span`, `matchedText` from `Match`, and `fix` from `Action` only when the action is populated. @@ -89,14 +42,12 @@ When the `vale` binary cannot be found or invoked, the system SHALL report that ### Requirement: Vale rules are verified with per-rule fixture subdirectories -The system SHALL verify a Vale rule from a `.taskless/vale/rule-tests//` subdirectory containing `pass/` and `fail/` fixture documents. Because verification is one-time (not per-check), the system SHALL **generate** an ephemeral `.vale.ini` at verify time (StylesPath plus only that rule enabled) rather than requiring a committed one — the subdirectory holds fixtures only. Verification SHALL assert that every `fail/` fixture produces at least one finding for the rule and every `pass/` fixture produces none (mirroring ast-grep's `invalid`/`valid`). - -Both buckets SHALL hold at least one document before a rule can be reported as verified. A `fail/` fixture proves the rule fires; a `pass/` fixture proves it does not over-fire; either alone establishes half the claim. A rule populating only one bucket SHALL be reported as unverified rather than passing, and the report SHALL distinguish that half-written state from a rule carrying no fixtures at all — a rule with only `pass/` fixtures would otherwise pass trivially, on an empty set of expected failures, having never demonstrated that it fires. +The system SHALL verify a Vale rule from a `.taskless/rules/vale//.tests/` subdirectory containing `pass/` and `fail/` fixture documents. Because verification isolates one rule, the system SHALL generate an ephemeral config enabling only that rule — derived from the rule's own config so that verification exercises the scope the rule actually declares. Verification SHALL assert that every `fail/` fixture produces at least one finding for the rule and every `pass/` fixture produces none (mirroring ast-grep's `invalid`/`valid`). -#### Scenario: Fail fixture triggers, pass fixture does not +#### Scenario: Verification isolates the rule under test -- **WHEN** verify runs for a rule and generates an isolating `.vale.ini` enabling only that rule -- **THEN** verification passes because every `fail/` fixture yields a finding and every `pass/` fixture yields none +- **WHEN** verify runs for a rule and generates a config enabling only that rule +- **THEN** findings from other rules SHALL NOT affect its result #### Scenario: Verification fails when a fail fixture does not trigger @@ -111,14 +62,122 @@ Both buckets SHALL hold at least one document before a rule can be reported as v ### Requirement: Taskless breadcrumbs use a namespaced ignored key in the Vale config -Any Taskless-owned breadcrumb the system records in `.vale.ini` SHALL use a `tskl) = ` key. The system SHALL NOT rely on Vale enforcing these keys; they are read only by Taskless tooling, and Vale's ini parser accepts and ignores them. Each Taskless-owned matcher SHALL carry a `tskl) rule = ` key naming its owning rule, so tooling can locate and update the right rule's matchers even when its scoping is split across multiple (possibly duplicate) matchers. +Any Taskless-owned breadcrumb the system records in a Vale config SHALL use a `tskl) = ` key. The system SHALL NOT rely on Vale enforcing these keys; they are read only by Taskless tooling, and Vale's ini parser accepts and ignores them. + +With each rule owning its config, a matcher's owner is given by the directory it lives in, so the breadcrumb is no longer needed to locate a rule's matchers. It is retained to mark Taskless-owned matchers **within the assembled file**, where several rules' matchers are interleaved and provenance is otherwise lost. #### Scenario: Breadcrumb key is ignored by Vale -- **WHEN** `.vale.ini` contains a `tskl) rule = no-simply` key +- **WHEN** a config contains a `tskl) rule = no-simply` key - **THEN** Vale runs normally, ignoring the key, and Taskless tooling can read it back -#### Scenario: Canonical id locates a rule's matchers +#### Scenario: Provenance survives assembly + +- **WHEN** matchers from several rules are assembled into one run config +- **THEN** each SHALL carry the `tskl) rule` key naming the rule it came from + +### Requirement: Vale diagnostics on a successful run are surfaced as notices + +When Vale exits zero and writes to stderr, the CLI SHALL surface that output as a notice on the check result. A notice SHALL NOT affect the exit code. + +This is a precondition of the section-less scaffold rather than an independent improvement. With no section to copy, the likely first edit is a rule assignment at the top level of the file, which Vale reports as ignoring — on stderr, with a zero exit and a well-formed empty result. Discarding that output leaves the author with a rule that verifies, runs, and reports nothing, which is the silent-disable failure this engine's design exists to prevent. + +#### Scenario: An ignored rule assignment reaches the user + +- **WHEN** `.vale.ini` enables a rule outside any section and `check` runs +- **THEN** the CLI SHALL surface Vale's diagnostic that the assignment was ignored + +#### Scenario: A diagnostic does not fail the check + +- **WHEN** Vale exits zero, writes a diagnostic to stderr, and reports no findings +- **THEN** the check SHALL exit zero + +#### Scenario: Silence stays silent + +- **WHEN** Vale exits zero and writes nothing to stderr +- **THEN** the CLI SHALL add no notice + +### Requirement: Vale check executes against an assembled run config over the target paths + +The system SHALL assemble a run config from the per-rule configs and run `vale --config --output=JSON --no-exit` over the resolved target paths. The assembled config SHALL set `StylesPath` naming the Vale rules tree and `MinAlertLevel = suggestion`, so that every finding surfaces to the client for normalization and filtering. + +The config is assembled rather than committed because it has no single author. Every rule contributes its own matchers, and a shared committed file is one every rule's author must edit correctly — which is where the engine's silent failures were found in practice. + +The assembled config SHALL be written where the run can read it and SHALL be gitignored. A generated file that is also committed drifts from its inputs and invites hand edits the next assembly discards. + +#### Scenario: Check runs Vale via the assembled config + +- **WHEN** the CLI runs a check and `.taskless/rules/vale/` contains rule directories +- **THEN** it assembles a run config from their per-rule configs and invokes Vale with it over the target paths + +#### Scenario: The assembled config is not a source file + +- **WHEN** the run config is written +- **THEN** it SHALL be ignored by version control +- **AND** editing it SHALL NOT change what a later check reports + +#### Scenario: No Vale rules present + +- **WHEN** `.taskless/rules/vale/` contains no rule directories +- **THEN** the CLI does not invoke Vale and produces no Vale findings + +### Requirement: Per-rule scoping is expressed in the rule's own Vale config + +The system SHALL express a Vale rule's scope through **matchers** — `[]` sections — declared in that rule's own `.taskless/rules/vale//.vale.ini`. Include is `. = YES`, exclude is `. = NO`. + +Precedence is **positional**, and the system SHALL order matchers accordingly rather than relying on a disable to win on its own. Measured against Vale 3.17.1: + +- Where two matchers both match a file, the **last** one wins for that rule. +- Where the same key is assigned twice inside one matcher — including across duplicate `[]` sections, which Vale merges — the **first** assignment wins. + +A disable therefore SHALL be declared **after** the enable it narrows, within the rule's own config. Because precedence is positional and the run config is assembled, **assembly SHALL be deterministic**: rules ordered by id, and each rule's own matcher order preserved verbatim. A non-deterministic assembly would make a rule's effective scope depend on directory iteration order. + +A rule SHALL NOT be able to override another rule's matchers. It cannot know its own position in the assembled file, and cross-rule overriding through a shared file is the coupling the per-rule layout removes. + +#### Scenario: A rule scopes itself + +- **WHEN** a rule's own config enables it under `[marketing/**]` +- **THEN** the rule produces findings in `marketing/` files and none in `api/` files + +#### Scenario: A rule narrows itself + +- **WHEN** a rule's config enables it under `[marketing/**]` and then disables it under `[marketing/legacy/**]` +- **THEN** the rule fires in `marketing/` but not in `marketing/legacy/` + +#### Scenario: Assembly order is stable + +- **WHEN** the same set of rules is assembled twice +- **THEN** the resulting config SHALL be byte-identical +- **AND** each rule's matchers SHALL appear in the order that rule declared them + +#### Scenario: Duplicate matchers merge + +- **WHEN** two rules each declare a `[*.md]` matcher +- **THEN** both rules run on a matching `.md` file (Vale merges the matchers) + +### Requirement: A Vale rule is a self-contained directory + +The system SHALL store a Vale rule as a directory `.taskless/rules/vale//` containing its style file `.yml`, its own `.vale.ini`, and its fixtures under `.tests/`. No file outside that directory SHALL be required to define or verify the rule. + +Self-containment is what removes the engine's silent-failure class. A rule can be added, reviewed, moved, or deleted as one directory, and no two authors write the same file. + +The rule's config SHALL be named `.vale.ini` rather than carrying a `.yml` extension. Measured: a `.yml` file inside a style directory is loaded as a rule and fails `E201` when the style is enabled wholesale, while a non-`.yml` file in the same directory is ignored. + +Scope SHALL NOT be expressed inside the style file. Measured: Vale rejects unknown top-level keys in a rule with `E201 has invalid keys`. + +#### Scenario: A rule is complete in one directory + +- **WHEN** a rule directory contains its style and its config +- **THEN** the rule is fully defined without editing any shared file + +#### Scenario: The rule config is invisible to Vale's style loader + +- **WHEN** a rule directory contains `.vale.ini` beside its style +- **THEN** Vale SHALL NOT attempt to load it as a rule + +#### Scenario: Deleting a rule is deleting a directory + +- **WHEN** a rule directory is removed +- **THEN** no other rule's scope changes +- **AND** no shared file needs editing -- **WHEN** a rule's scoping spans several matchers each tagged `tskl) rule = no-simply` -- **THEN** tooling can find every matcher owned by `no-simply` by its `tskl) rule` id rather than by glob diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 0a49ce8b..0c3da89d 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -4,7 +4,11 @@ Defines build tooling, CI pipelines, and repository configuration including version sync, command generation, Turborepo setup, and GitHub Actions workflows. -## Build Tooling +## Requirements + +**Build tooling.** Grouped by a bold line rather than a heading: a second `##` +inside the requirements section ends it, and every requirement after it +stops being read. ### Requirement: tsx is available for build scripts @@ -41,7 +45,7 @@ A `scripts/sync-skill-versions.ts` script SHALL read the version from `packages/ ### Requirement: Slash command files are hand-authored -Since the v0.7 consolidation, the single `commands/tskl/tskl.md` slash command is hand-authored rather than generated from a `SKILL.md` body. The command body intentionally differs from the skill body (it is a `$ARGUMENTS`-aware router), so the prior "copy SKILL.md body to command" generation script no longer applies. +The single `commands/tskl/tskl.md` slash command SHALL be hand-authored rather than generated from a `SKILL.md` body. Its body intentionally differs from the skill body (it is a `$ARGUMENTS`-aware router), so the prior "copy SKILL.md body to command" generation script SHALL NOT be reintroduced. #### Scenario: Single hand-authored command file exists @@ -84,7 +88,7 @@ The `packages/cli/package.json` SHALL NOT have a `release` script. Build and pub - **WHEN** inspecting `packages/cli/package.json` scripts - **THEN** there SHALL be no `release` key -## Repository Configuration +**Repository configuration.** ### Requirement: Turborepo is configured at the repo root @@ -134,7 +138,7 @@ The root `pnpm typecheck` command SHALL invoke `turbo run typecheck`, which runs - **WHEN** `pnpm typecheck` is run at the repo root - **THEN** Turborepo SHALL execute `typecheck` in `@taskless/cli` -## Continuous Integration +**Continuous integration.** ### Requirement: CI workflow exists @@ -247,7 +251,7 @@ The workflow SHALL NOT include any publish, release, or npm registry push steps. - **WHEN** inspecting the workflow file - **THEN** there SHALL be no steps that run `pnpm publish`, `npm publish`, or interact with an npm registry -## Requirements +**Release and publishing.** ### Requirement: Script-versioned packages are excluded from changesets diff --git a/openspec/specs/skills/spec.md b/openspec/specs/skills/spec.md index 0b8dc956..894ae640 100644 --- a/openspec/specs/skills/spec.md +++ b/openspec/specs/skills/spec.md @@ -10,7 +10,7 @@ Defines the structure, conventions, and distribution model for Taskless skills, The single skill SHALL be defined at `skills/taskless/SKILL.md` with YAML frontmatter (`name`, `description`, `metadata`) followed by markdown instructions. The `name` field SHALL be exactly `taskless` (no per-task prefix). The `metadata` field SHALL include `author`, `version`, and `commandName: tskl` keys. The `version` SHALL be used for staleness detection when the skill is installed into target repositories. -The skill body SHALL begin by instructing the agent that it does NOT have step-by-step instructions for any Taskless action and that recipes must be fetched via `npx @taskless/cli help ` before proceeding. The body SHALL NOT contain inline step-by-step recipes for any individual task — those live in `packages/cli/src/help/.txt` files served by the help subcommand. +The skill body SHALL begin by instructing the agent that it does NOT have step-by-step instructions for any Taskless action and that recipes must be fetched via `npx @taskless/cli agent ` before proceeding. The body SHALL NOT contain inline step-by-step recipes for any individual task — those live in `packages/cli/src/help/.txt` files served by the help subcommand. #### Scenario: Skill directory contains valid SKILL.md @@ -23,10 +23,10 @@ The skill body SHALL begin by instructing the agent that it does NOT have step-b #### Scenario: Skill body delegates to CLI help - **WHEN** the skill body is read -- **THEN** it SHALL instruct the agent to fetch the canonical recipe via `npx @taskless/cli help ` before performing any Taskless action +- **THEN** it SHALL instruct the agent to fetch the canonical recipe via `npx @taskless/cli agent ` before performing any Taskless action - **AND** SHALL NOT duplicate recipe content inline -## Distribution +**Distribution.** ### Requirement: Skills live in the standard discovery path @@ -59,7 +59,7 @@ The single skill name SHALL be `taskless` (without a per-task suffix). When inst ### Requirement: Commands directory contains Claude Code command files -The `commands/tskl/` directory SHALL contain exactly one command file (`tskl.md`) that maps to the consolidated skill. The command body SHALL accept a free-form `$ARGUMENTS` ask and route via the same flow as the skill (fetch `npx @taskless/cli help `, follow the recipe). When `$ARGUMENTS` is empty or ambiguous, the command body SHALL instruct the agent to ask the user what they want to do. +The `commands/tskl/` directory SHALL contain exactly one command file (`tskl.md`) that maps to the consolidated skill. The command body SHALL accept a free-form `$ARGUMENTS` ask and route via the same flow as the skill (fetch `npx @taskless/cli agent `, follow the recipe). When `$ARGUMENTS` is empty or ambiguous, the command body SHALL instruct the agent to ask the user what they want to do. #### Scenario: Single command file exists diff --git a/packages/cli/README.md b/packages/cli/README.md index eb27e363..af0595f5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -36,7 +36,7 @@ current project (`.claude/`, `.opencode/`, `.cursor/`, `.agents/`), asks which tools to enable Taskless for, and walks through the auth tradeoff before writing anything. Running `taskless` with no subcommand in a TTY also launches this wizard. Without a TTY, bare `taskless` prints a short context preamble -followed by the topic index from `taskless help`. +followed by the topic index from `taskless agent`. In v0.7+, there is exactly one skill (`taskless`) and one command (`tskl`) — no opt-in selection needed. @@ -142,10 +142,10 @@ taskless rule delete no-console-log Lists available subcommands. -### `taskless help [topic]` +### `taskless agent [topic]` Returns agent-facing recipes. With no args, prints the topic index. With a -topic (e.g. `taskless help rule create`), prints the full step-by-step recipe +topic (e.g. `taskless agent route`), prints the full step-by-step recipe for that operation, including an embedded JSON Schema for any `--from` input and a table of stable error codes. Append `--anonymous` to fetch the local-only variant where one exists (currently `rule create`/`rule improve`). @@ -159,7 +159,7 @@ relevant recipe on demand. Recognized on every command. Behavior matrix: - `rule create` / `rule improve` — exits with a pointer to - `taskless help --anonymous`. The local-only flow runs in the agent + `taskless agent --anonymous`. The local-only flow runs in the agent per the recipe variant. - `info` — skips the API/auth probe; reports local state only. - `auth login` — rejected (auth commands cannot be anonymous). diff --git a/packages/cli/src/commands/help.ts b/packages/cli/src/commands/agent.ts similarity index 65% rename from packages/cli/src/commands/help.ts rename to packages/cli/src/commands/agent.ts index d89e4c9c..da79afac 100644 --- a/packages/cli/src/commands/help.ts +++ b/packages/cli/src/commands/agent.ts @@ -10,15 +10,16 @@ import { import { getTelemetry } from "../telemetry"; 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 +// Recipe-only topics (no backing subcommand) that should still be +// discoverable from the `taskless agent` index. The rule-authoring front // door (`route`) and its destinations live here so an agent can find them. const RECIPE_TOPICS: ReadonlyArray<[string, string]> = [ - ["route", "Decide where to author a rule (existing/static/remote)"], - ["existing", "Author a rule in a linter the repo already uses"], - ["static", "Author a local ast-grep rule on this machine (no login)"], - ["remote", "Generate a rule via the Taskless service (login)"], - ["engine-selection", "Decide which engine enforces a rule (sg/vale/runtime)"], + ["route", "Decide which recipe authors a rule (start here)"], + ["create-legacy-rule", "Author a rule in a linter the repo already uses"], + ["create-sg-rule", "Author a local ast-grep rule over code (no login)"], + ["create-vale-rule", "Author a local Vale rule over prose (no login)"], + ["create-runtime-rule", "The runtime tier, and why it needs an account"], + ["create-remote-rule", "Generate a rule via the Taskless service (login)"], ]; async function unwrap(resolvable: Resolvable): Promise { @@ -36,11 +37,11 @@ async function resolveDescription( return meta?.description ?? ""; } -export function createHelpCommand(subCommands: SubCommandsDef) { +export function createAgentCommand(subCommands: SubCommandsDef) { return defineCommand({ meta: { - name: "help", - description: "Show help for a command", + name: "agent", + description: "Return a recipe for an AI coding agent to follow", }, args: { dir: { @@ -67,14 +68,19 @@ export function createHelpCommand(subCommands: SubCommandsDef) { if (!argument.includes("=") && valueFlagSet.has(argument)) index++; continue; } - if (argument !== "help") positionals.push(argument); + if (argument !== "agent") positionals.push(argument); } const cwd = resolve(args.dir); const telemetry = await getTelemetry(cwd); if (positionals.length === 0) { - // cli_help with the index marker: agent fetched the topic list + // cli_help with the index marker: agent fetched the topic list. + // The event name stays `cli_help` even though the command is now + // `agent`: dashboards key on it, it is not part of any agent-facing + // contract, and renaming it in the same change that breaks the + // `TOPICS` export would take those dashboards dark for a reason + // unrelated to this change. telemetry.capture("cli_help", { topic: "(index)" }); console.log("Taskless CLI\n"); @@ -89,7 +95,7 @@ export function createHelpCommand(subCommands: SubCommandsDef) { const entries: Array<[string, string]> = []; for (const [name, cmd] of Object.entries(subCommands)) { - if (name === "help") continue; + if (name === "agent") continue; const description = await resolveDescription(cmd); entries.push([name, description]); } @@ -114,30 +120,44 @@ export function createHelpCommand(subCommands: SubCommandsDef) { ); console.log("and use local-only behavior."); console.log( - "\nRun `taskless help ` for the full recipe (e.g. `taskless help rule create`)." + "\nRun `taskless agent ` for the full recipe (e.g. `taskless agent create-sg-rule`)." ); return; } - // Join positional args to form the lookup key - const key = positionals.join("-"); + // Topics are addressed by exactly one token. Joining positionals into a + // key used to make `rule create` resolve `rule-create.txt`, which invited + // an agent to reorder or paraphrase a topic name and still get a hit. + // A single hyphenated token is a literal string to copy, so extra + // positionals are an error rather than something to guess at. + if (positionals.length > 1) { + telemetry.capture("cli_help", { topic: positionals.join(" ") }); + console.error(`Too many arguments: ${positionals.join(" ")}`); + console.error( + "A topic is a single token. Run `taskless agent` for the topic index." + ); + process.exitCode = 1; + return; + } + + const key = positionals[0]!; // Anonymous variant lookup: prefer .anonymous.txt when // --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 + // the render both live in the shared prompts module, so `agent` and the // `@taskless/cli/prompts` export emit the same text. const recipe = getRecipe(key, { anonymous: args.anonymous }); 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(" ") }); + telemetry.capture("cli_help", { topic: key }); console.log(recipe.trimEnd()); } else { // cli_help for an unknown topic — still the attempted topic string. - telemetry.capture("cli_help", { topic: positionals.join(" ") }); - console.error(`Unknown command: ${positionals.join(" ")}`); - console.error("Run `taskless help` for available commands."); + telemetry.capture("cli_help", { topic: key }); + console.error(`Unknown command: ${key}`); + console.error("Run `taskless agent` for available topics."); process.exitCode = 1; } }, diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 55de73c0..f048e1f1 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -3,13 +3,10 @@ import { stat } from "node:fs/promises"; import { defineCommand } from "citty"; import { hasValeRules, runEngines } from "../rules/dispatch"; +import { assembleEngineConfigs } from "../rules/assemble"; import { formatText } from "../util/format"; -import { resolveSgConfigPath } from "../filesystem/sgconfig"; import { ensureTasklessDirectory } from "../filesystem/directory"; -import { - discoverAstGrepRuleSources, - planEngineDispatch, -} from "../rules/engines"; +import { listRuleIds, planEngineDispatch } from "../rules/engines"; import { getTelemetry } from "../telemetry"; import { outputSchema as checkOutputSchema } from "../schemas/check"; import { makeErrorEnvelope } from "../types/errors"; @@ -335,7 +332,7 @@ export const checkCommand = defineCommand({ // executor, so none of them can be assumed to contribute nothing. A // directory that is not a known engine is still ignored rather than // handed to someone's parser. - const astGrepSources = await discoverAstGrepRuleSources(cwd); + const astGrepRuleIds = await listRuleIds(cwd, "sg"); // Both halves matter: `executor` alone is read from the static layout // table and is therefore always `runtime-harness`, so gating on it only // would make this unconditionally true and the presence check decorative. @@ -350,13 +347,13 @@ export const checkCommand = defineCommand({ : []; // "No rules configured" has to mean *no engine* has any, not just these - // two: a project whose only rules live in `.taskless/vale/rules/` would + // two: a project whose only rules live in `.taskless/rules/vale/` would // otherwise return here and Vale would never be dispatched, which is a // silent skip of the engine the user actually configured. Asked last and // short-circuited, so the ordinary project with ast-grep or runtime rules // pays nothing and `runEngines` still owns the decision to spawn Vale. const noRuleFiles = - astGrepSources.length === 0 && + astGrepRuleIds.length === 0 && runtimeRules.length === 0 && !(await hasValeRules(cwd)); @@ -392,13 +389,15 @@ export const checkCommand = defineCommand({ // Every engine runs concurrently and merges into one result set. An // engine that cannot run reports a notice and the others still return. - const astGrepConfigPaths = await Promise.all( - astGrepSources.map((source) => resolveSgConfigPath(cwd, source)) - ); + // Assemble both engine configs from the per-rule tree. Each returns + // `undefined` when its engine has no rules, which dispatch reads as + // "nothing to run" rather than running an empty config. + const assembled = await assembleEngineConfigs(cwd); const dispatched = await runEngines({ cwd, paths: existingPaths, - astGrepConfigPaths, + astGrepConfigPath: assembled.sg, + valeConfigPath: assembled.vale, runtimeRules: plan.execute, runtimeTimeoutMs: parseTimeoutMs(args.timeout), }); diff --git a/packages/cli/src/commands/rules.ts b/packages/cli/src/commands/rules.ts index 17250bb3..c800495c 100644 --- a/packages/cli/src/commands/rules.ts +++ b/packages/cli/src/commands/rules.ts @@ -5,7 +5,6 @@ import { defineCommand } from "citty"; import { ZodError } from "zod"; import { resolveIdentity } from "../auth/identity"; -import { verifyRule } from "../rules/verify"; import { submitRule, pollRuleStatus, iterateRule } from "../api/rules"; import { writeRuleFile, @@ -14,7 +13,7 @@ import { readRuleMetaFile, deleteRuleFiles, } from "../rules/files"; -import { ENGINE_LAYOUTS, LEGACY_RULES_DIRECTORY } from "../rules/engines"; +import { RULES_DIRECTORY } from "../rules/engines"; import { inputSchema as createInputSchema, outputSchema as createOutputSchema, @@ -24,10 +23,9 @@ import { outputSchema as improveOutputSchema, } from "../schemas/rules-improve"; import { outputSchema as metaOutputSchema } from "../schemas/rules-meta"; -import { verifyOutputSchema } from "../schemas/rules-verify"; import { getTelemetry } from "../telemetry"; import { CLIError } from "../util/cli-error"; -import { type CLIErrorCode, makeErrorEnvelope } from "../types/errors"; +import { type CLIErrorCode, writeJsonError } from "../types/errors"; /** Format today's date as YYYYMMDD */ function getTimestamp(): string { @@ -93,7 +91,7 @@ const createCommand = defineCommand({ code: CLIErrorCode = "INTERNAL_ERROR" ): never { if (args.json) { - console.log(JSON.stringify(makeErrorEnvelope(code, message))); + writeJsonError(code, message); } else { console.error(`Error: ${message}`); } @@ -103,13 +101,14 @@ const createCommand = defineCommand({ if (args.anonymous) { // Anonymous rule creation runs in the agent, not the CLI. Point the - // agent at the local-only recipe and exit cleanly. + // agent at the local-only recipe and exit cleanly. That recipe is + // `create-sg-rule`: authoring an ast-grep rule on-device with no service + // call is exactly what anonymous mode asks for, so it is the destination + // rather than an `--anonymous` variant of the service recipe. const message = - "Anonymous rule generation runs in the agent. Run `taskless help rule create --anonymous` to fetch the local-only recipe."; + "Anonymous rule generation runs in the agent. Run `taskless agent create-sg-rule` to fetch the local-only recipe."; if (args.json) { - console.log( - JSON.stringify(makeErrorEnvelope("INVALID_INPUT", message)) - ); + writeJsonError("INVALID_INPUT", message); } else { console.error(message); } @@ -338,7 +337,7 @@ const improveCommand = defineCommand({ code: CLIErrorCode = "INTERNAL_ERROR" ): never { if (args.json) { - console.log(JSON.stringify(makeErrorEnvelope(code, message))); + writeJsonError(code, message); } else { console.error(`Error: ${message}`); } @@ -348,11 +347,9 @@ const improveCommand = defineCommand({ if (args.anonymous) { const message = - "Anonymous rule improvement runs in the agent. Run `taskless help rule improve --anonymous` to fetch the local-only recipe."; + "Anonymous rule improvement runs in the agent. Run `taskless agent improve-rule --anonymous` to fetch the local-only recipe."; if (args.json) { - console.log( - JSON.stringify(makeErrorEnvelope("INVALID_INPUT", message)) - ); + writeJsonError("INVALID_INPUT", message); } else { console.error(message); } @@ -578,7 +575,7 @@ const metaCommand = defineCommand({ code: CLIErrorCode = "INTERNAL_ERROR" ): never { if (args.json) { - console.log(JSON.stringify(makeErrorEnvelope(code, message))); + writeJsonError(code, message); } else { console.error(`Error: ${message}`); } @@ -659,13 +656,9 @@ const deleteCommand = defineCommand({ } success = true; } else { - const message = - `Rule "${id}" not found in .taskless/${ENGINE_LAYOUTS.sg.rulesDirectory}/${id}.yml ` + - `or .taskless/${LEGACY_RULES_DIRECTORY}/${id}.yml`; + const message = `Rule "${id}" not found in .taskless/${RULES_DIRECTORY}/sg/${id}/`; if (args.json) { - console.log( - JSON.stringify(makeErrorEnvelope("RULE_NOT_FOUND", message)) - ); + writeJsonError("RULE_NOT_FOUND", message); } else { console.error(`Error: ${message}`); } @@ -680,94 +673,6 @@ const deleteCommand = defineCommand({ }, }); -const verifyCommand = defineCommand({ - meta: { - name: "verify", - description: "Validate a rule against the ast-grep schema and run tests", - }, - args: { - dir: { - type: "string", - alias: "d", - description: "Working directory", - }, - json: { - type: "boolean", - description: "Output as JSON", - default: false, - }, - anonymous: { - type: "boolean", - description: "Accepted for compatibility; verify is purely local", - default: false, - }, - id: { - type: "positional", - description: "Rule ID to verify", - required: false, - }, - }, - async run({ args }) { - const cwd = resolve(args.dir ?? process.cwd()); - - if (!args.id) { - if (args.json) { - console.log( - JSON.stringify( - makeErrorEnvelope("INVALID_INPUT", "Rule ID is required.") - ) - ); - } else { - console.error( - "Error: Rule ID is required.\n Usage: taskless rule verify " - ); - } - process.exitCode = 1; - return; - } - - const result = await verifyRule(cwd, args.id); - - if (args.json) { - console.log(JSON.stringify(verifyOutputSchema.parse(result))); - } else { - console.log(`Verifying rule: ${result.ruleId}\n`); - - // Layer 1 - console.log( - `Schema: ${result.schema.valid ? "✓ valid" : "✗ invalid"}` - ); - for (const error of result.schema.errors) { - console.log(` - ${error}`); - } - - // Layer 2 - console.log( - `Requirements: ${result.requirements.valid ? "✓ valid" : "✗ invalid"}` - ); - for (const error of result.requirements.errors) { - console.log(` - ${error}`); - } - - // Layer 3 - console.log( - `Tests: ${result.tests.valid ? "✓ passed" : "✗ failed"} (${String(result.tests.passed)} passed, ${String(result.tests.failed)} failed)` - ); - for (const error of result.tests.errors) { - console.log(` - ${error}`); - } - - console.log( - `\nResult: ${result.success ? "✓ All checks passed" : "✗ Verification failed"}` - ); - } - - if (!result.success) { - process.exitCode = 1; - } - }, -}); - export const ruleCommand = defineCommand({ meta: { name: "rule", @@ -778,6 +683,5 @@ export const ruleCommand = defineCommand({ improve: improveCommand, meta: metaCommand, delete: deleteCommand, - verify: verifyCommand, }, }); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts new file mode 100644 index 00000000..4520705d --- /dev/null +++ b/packages/cli/src/commands/verify.ts @@ -0,0 +1,164 @@ +import { resolve } from "node:path"; + +import { defineCommand } from "citty"; + +import { ensureTasklessDirectory } from "../filesystem/directory"; +import { + testOneRule, + verifyOneRule, + type RuleTestResult, + type RuleVerification, +} from "../rules/inspect"; +import { + PathOutsideRulesError, + resolveRulePath, + RuleNotFoundError, +} from "../rules/resolve-path"; +import { makeErrorEnvelope } from "../types/errors"; + +/** + * The shared body of `verify` and `test`. + * + * Both take a path, resolve it to rules, and report per rule — they differ only + * in what they run against each. Keeping one implementation means the two can + * never disagree about what a path means, which is the property that makes + * `verify ` and `test ` interchangeable in a recipe. + */ +async function runOverPath(options: { + cwd: string; + target: string; + json: boolean; + /** What the command is called, for messages. */ + label: "verify" | "test"; + run: ( + cwd: string, + rule: { engine: "sg" | "vale" | "runtime"; ruleId: string } + ) => Promise; +}): Promise { + const { cwd, target, json, label, run } = options; + + await ensureTasklessDirectory(cwd); + + let rules; + try { + rules = await resolveRulePath(cwd, target); + } catch (error) { + if ( + error instanceof PathOutsideRulesError || + error instanceof RuleNotFoundError + ) { + const message = error.message; + if (json) { + console.log( + JSON.stringify(makeErrorEnvelope("INVALID_INPUT", message)) + ); + } else { + console.error(`Error: ${message}`); + } + process.exitCode = 1; + return; + } + throw error; + } + + if (rules.length === 0) { + // Not an error: an empty rules tree is the ordinary state of a project that + // has not written a rule yet, and failing here would make `verify` unusable + // in CI on a fresh install. + if (json) { + console.log(JSON.stringify({ ok: true, rules: [] })); + } else { + console.log(`No rules found under ${target}.`); + } + return; + } + + const results: (RuleVerification | RuleTestResult)[] = []; + for (const rule of rules) { + results.push(await run(cwd, rule)); + } + + const failed = results.filter((result) => !result.ok); + + if (json) { + console.log(JSON.stringify({ ok: failed.length === 0, rules: results })); + } else { + for (const result of results) { + const mark = result.ok ? "✓" : "✗"; + console.log(`${mark} ${result.engine}/${result.ruleId}`); + for (const error of result.errors) { + console.log(` ${error}`); + } + // Printed even when the rule passed. A misplaced `.vale.ini` assignment + // makes Vale exit zero having enabled nothing, so the clean line above + // is exactly the moment the author needs to hear this. + if ("notice" in result && result.notice !== undefined) { + console.log(` notice: ${result.notice}`); + } + } + console.log( + failed.length === 0 + ? `\n${String(results.length)} rule(s) ${label === "verify" ? "verified" : "tested"}.` + : `\n${String(failed.length)} of ${String(results.length)} rule(s) failed.` + ); + } + + if (failed.length > 0) process.exitCode = 1; +} + +const ruleTargetArguments = { + dir: { + type: "string", + alias: "d", + description: "Working directory", + }, + json: { + type: "boolean", + description: "Output as JSON", + default: false, + }, + path: { + type: "positional", + description: + "Rule directory, engine directory, or .taskless/rules for everything", + required: false, + }, +} as const; + +export const verifyCommand = defineCommand({ + meta: { + name: "verify", + description: "Check that a rule has the components its engine requires", + }, + args: ruleTargetArguments, + async run({ args }) { + const cwd = resolve(args.dir ?? process.cwd()); + await runOverPath({ + cwd, + // No path means the whole rules tree, which is what CI wants and what an + // author means when they ask "is everything here valid". + target: args.path ?? ".taskless/rules", + json: args.json, + label: "verify", + run: verifyOneRule, + }); + }, +}); + +export const testCommand = defineCommand({ + meta: { + name: "test", + description: "Run a rule's tests, after verifying the rule itself", + }, + args: ruleTargetArguments, + async run({ args }) { + const cwd = resolve(args.dir ?? process.cwd()); + await runOverPath({ + cwd, + target: args.path ?? ".taskless/rules", + json: args.json, + label: "test", + run: testOneRule, + }); + }, +}); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 56637a5f..0da166a0 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; -import { ENGINE_LAYOUTS, LEGACY_RULES_DIRECTORY } from "../rules/engines"; +import { RULES_DIRECTORY } from "../rules/engines"; export interface DetectedLinter { name: string; @@ -429,10 +429,7 @@ function detectRuleStyles( nodeManifests: NodeManifest[] ): RuleStyle[] { const ruleStyles: RuleStyle[] = []; - for (const source of [ - `.taskless/${ENGINE_LAYOUTS.sg.rulesDirectory}`, - `.taskless/${LEGACY_RULES_DIRECTORY}`, - ]) { + for (const source of [`.taskless/${RULES_DIRECTORY}/sg`]) { if (!existsSync(resolve(root, source))) continue; ruleStyles.push({ source, diff --git a/packages/cli/src/filesystem/migrate.ts b/packages/cli/src/filesystem/migrate.ts index f954fe27..77bf2c67 100644 --- a/packages/cli/src/filesystem/migrate.ts +++ b/packages/cli/src/filesystem/migrate.ts @@ -7,6 +7,7 @@ import init from "./migrations/0001-init"; import installMigration from "./migrations/0002-install"; import dropInstalledAt from "./migrations/0003-drop-installed-at"; import valeEngine from "./migrations/0004-vale-engine"; +import ruleDirectories from "./migrations/0005-rule-directories"; export interface TasklessInstallTarget { skills?: string[]; @@ -37,6 +38,7 @@ const migrations: Migrations = { "2": installMigration, "3": dropInstalledAt, "4": valeEngine, + "5": ruleDirectories, }; /** Global flag that downgrades a too-new scaffold from an error to a skip. */ diff --git a/packages/cli/src/filesystem/migrations/0004-vale-engine.ts b/packages/cli/src/filesystem/migrations/0004-vale-engine.ts index 6d7baabf..27873590 100644 --- a/packages/cli/src/filesystem/migrations/0004-vale-engine.ts +++ b/packages/cli/src/filesystem/migrations/0004-vale-engine.ts @@ -25,17 +25,32 @@ const SG_CONFIG_CONTENT = `ruleDirs:\n - rules\ntestConfigs:\n - testDir: rule /** * The scaffolded `.vale.ini`. * - * `StylesPath` is the engine directory, NOT `rules/`. Vale treats StylesPath as - * a directory *of styles*, so a rule at `vale/rules/no-simply.yml` is the - * `no-simply` rule of the `rules` style, and its check is `rules.no-simply` — - * which is the name `stripRulesPrefix` in `vale/map.ts` exists to undo, and the - * shape `verify.ts` generates. Pointing StylesPath at `rules/` instead makes + * `StylesPath` is the engine directory, NOT `rules/` — **for this layout**. + * Vale treats StylesPath as a directory *of styles*, so with rules flat in + * `vale/rules/`, `rules` is the style and a rule at `vale/rules/no-simply.yml` + * is the check `rules.no-simply`. Pointing StylesPath at `rules/` instead makes * that same file a style directory with no rules in it: every check resolves to * nothing, Vale reports `{}`, and a prose check passes clean with every rule - * silently disabled. Measured against the real binary, which is the only way - * this is visible — the layout is identical either way. + * silently disabled. + * + * **Do not carry that conclusion forward.** Migration `0005` moves each rule + * into its own directory, and there `StylesPath = rules/vale` is the *correct* + * setting and `.` is the one that resolves nothing — the exact reverse. + * StylesPath is a function of the layout, and the same value is right for one + * and silently wrong for the other. Both measured against the real binary, + * which is the only way either is visible: the files look identical. + * + * It carries **no section**, so a scaffolded project lints nothing until an + * author scopes something deliberately. An unscoped `[*]` would apply every + * enabled rule to every file the walk reaches, making the default the widest + * scope available rather than the narrowest — and scope is the author's + * decision to make. `create-vale-rule` teaches writing the first section; the + * mistake that invites (a `rules. = YES` above the first `[…]` line, which + * Vale ignores with a `W101` on stderr and exit 0) is why `runVale` surfaces a + * zero-exit stderr as a notice. The two ship together: without the notice, this + * scaffold would trade a too-wide default for a silent one. */ -const VALE_CONFIG_CONTENT = `StylesPath = .\nMinAlertLevel = suggestion\n\n[*]\n`; +const VALE_CONFIG_CONTENT = `StylesPath = .\nMinAlertLevel = suggestion\n`; /** Directories that must exist after the migration, tracked when empty. */ const SCAFFOLD_DIRECTORIES = [ diff --git a/packages/cli/src/filesystem/migrations/0005-rule-directories.ts b/packages/cli/src/filesystem/migrations/0005-rule-directories.ts new file mode 100644 index 00000000..47340a66 --- /dev/null +++ b/packages/cli/src/filesystem/migrations/0005-rule-directories.ts @@ -0,0 +1,386 @@ +import { + mkdir, + readdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { Migration } from "../types"; +import { CLIError } from "../../util/cli-error"; +import { + ENGINES, + RULE_TESTS_DIRECTORY, + RULES_DIRECTORY, +} from "../../rules/engines"; + +/** + * Where `0004` left each engine's rules and tests, relative to `.taskless/`. + * This migration reads from here and writes to `rules///`. + */ +const PRIOR_LAYOUT = { + sg: { rules: "sg/rules", tests: "sg/rule-tests" }, + vale: { rules: "vale/rules", tests: "vale/rule-tests" }, + runtime: { rules: "runtime/rules", tests: "runtime/rule-tests" }, +} as const; + +/** The committed configs `0004` wrote, which assembly replaces. */ +const PRIOR_CONFIGS = ["sg/sgconfig.yml", "vale/.vale.ini"] as const; + +/** Files the migration adds to `.taskless/.gitignore`. */ +const GENERATED_PATHS = ["/.vale.ini", "/.sgconfig.yml"] as const; + +/** Directory entries, or `[]` when the directory is not there. */ +async function entriesOf(directory: string) { + try { + return await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } +} + +/** + * Move `source` to `destination`, creating the parent and preserving bytes. + * + * Content is never rewritten. Runtime capture bytes determine their + * server-side reconciliation hashes, so a reformat here would invalidate every + * signature — and the same guarantee is what lets this migration run against a + * project whose rules are already blessed. + */ +async function move(source: string, destination: string): Promise { + await mkdir(dirname(destination), { recursive: true }); + await rename(source, destination); +} + +/** + * Refuse to write engine directories into a `.taskless/rules/` that still holds + * loose rule files. + * + * `.taskless/rules/` is both this layout's root and the pre-`0004` flat + * location, so the two occupy the same path. `0004` empties it by moving it to + * `sg/rules/`; a `*.yml` still sitting there means `0004` did not complete, and + * creating `rules/sg/` around it would interleave two layouts in one tree with + * no way to tell them apart afterwards. + */ +async function assertRootIsFree(directory: string): Promise { + const root = join(directory, RULES_DIRECTORY); + const entries = await entriesOf(root); + const stray = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".yml")) + .map((entry) => entry.name); + if (stray.length === 0) return; + + throw new CLIError( + `Cannot create the rule directories: .taskless/${RULES_DIRECTORY}/ still contains ` + + `${stray.join(", ")} from the pre-migration layout. Migration 0004 moves those to ` + + `.taskless/sg/rules/; run it to completion first.`, + "SCAFFOLD_CONFLICT" + ); +} + +/** + * Move one engine's rules into per-rule directories. + * + * `sg` and `vale` hold a flat `.yml` per rule; `runtime` already holds a + * directory per rule, whose loose `*.yml` capture rules move down into + * `captures/`. + */ +async function moveEngineRules( + directory: string, + engine: (typeof ENGINES)[number] +): Promise { + const from = join(directory, PRIOR_LAYOUT[engine].rules); + + for (const entry of await entriesOf(from)) { + if (entry.name === ".gitkeep") continue; + + if (engine === "runtime") { + if (!entry.isDirectory()) continue; + const ruleDirectory = join( + directory, + RULES_DIRECTORY, + engine, + entry.name + ); + await move(join(from, entry.name), ruleDirectory); + + // Capture rules move under `captures/`; `check.ts` stays at the root. + const captures = join(ruleDirectory, "captures"); + for (const inner of await entriesOf(ruleDirectory)) { + if (!inner.isFile()) continue; + if (!inner.name.endsWith(".yml") && !inner.name.endsWith(".yaml")) { + continue; + } + await move(join(ruleDirectory, inner.name), join(captures, inner.name)); + } + continue; + } + + if (!entry.isFile() || !entry.name.endsWith(".yml")) continue; + const ruleId = entry.name.slice(0, -".yml".length); + await move( + join(from, entry.name), + join(directory, RULES_DIRECTORY, engine, ruleId, entry.name) + ); + } +} + +/** + * Move one engine's tests into each rule's `.tests/`. + * + * The two shapes differ and both are preserved as-is: `sg` names its tests + * `-YYYYMMDD-test.yml` in one flat directory, while `vale` keeps a + * `/` subdirectory of `pass/` and `fail/` documents. + */ +async function moveEngineTests( + directory: string, + engine: (typeof ENGINES)[number] +): Promise { + const from = join(directory, PRIOR_LAYOUT[engine].tests); + + for (const entry of await entriesOf(from)) { + if (entry.name === ".gitkeep") continue; + + if (entry.isDirectory()) { + // vale / runtime: a directory per rule. + await move( + join(from, entry.name), + join( + directory, + RULES_DIRECTORY, + engine, + entry.name, + RULE_TESTS_DIRECTORY + ) + ); + continue; + } + + // sg: `-YYYYMMDD-test.yml`, so the id is everything before the first + // `-` that begins the timestamp suffix. + const match = /^(?.+?)-\d{8}-test\.ya?ml$/.exec(entry.name); + const ruleId = match?.groups?.id; + if (ruleId === undefined) continue; + await move( + join(from, entry.name), + join( + directory, + RULES_DIRECTORY, + engine, + ruleId, + RULE_TESTS_DIRECTORY, + entry.name + ) + ); + } +} + +/** + * Create `rules//` for every engine, tracked when empty. + * + * The scaffold is what tells an author where a rule goes, and what lets engine + * dispatch see that an engine exists at all. `0004` scaffolded its own layout + * and this migration prunes those directories, so without this a freshly + * migrated project would have no rules tree — every engine reporting "not + * present" and no obvious place to write the first rule. + */ +async function scaffoldEngineDirectories(directory: string): Promise { + for (const engine of ENGINES) { + const path = join(directory, RULES_DIRECTORY, engine); + await mkdir(path, { recursive: true }); + const entries = await entriesOf(path); + if (entries.length === 0) { + await writeFile(join(path, ".gitkeep"), "", "utf8"); + } + } +} + +/** Remove an engine's now-empty `0004` directories, leaving anything else. */ +async function pruneEmpty( + directory: string, + relativePath: string +): Promise { + const path = join(directory, relativePath); + const entries = await entriesOf(path); + const remaining = entries.filter((entry) => entry.name !== ".gitkeep"); + if (remaining.length > 0) return; + await rm(path, { recursive: true, force: true }); +} + +/** + * Rewrite a matcher's assignments from the old check name to the new one. + * + * **This is the difference between a migrated rule that runs and one that is + * silently disabled.** A Vale check is named `