merge: main (wave 4 order A) into order B before B goes into main

Conflicts on the union lines only: CHANGELOG Unreleased (both orders'
entries kept), CLAUDE.md (/trekexecute row from A, /trekreview row from
B), tests/fixtures/jsonl-schemas.md (trekplan line_ref from B,
trekexecute line_ref from A). Suite on the merged tree 2682 (2680/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-19 18:49:02 +02:00
commit 45eaedc6b4
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
24 changed files with 274 additions and 45 deletions

View file

@ -22,6 +22,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Changed
- **The "run the thing" step names Claude Code's built-in `run` skill.** The plan's run step (`/trekplan`), the executor's end-to-end part (`/trekexecute`) and the plan template each say in one sentence that the app is seen and driven with `run`, and that the verdict is still the `End-to-end:` command through the criteria runner, because `run` gives no exit code (decisions 54 and 64).
- **A quiet eval case may name Claude Code's own `run` as its owner** (R2). The routing test keeps a short fixed list of built-in owners (`BUILTIN_OWNERS`, only `run`); for such an owner, which is outside the corpus, only "the skill does not rank first" is checked, and the report says so. `evals/skill-writing-criteria-quiet-2` now has `owner=run` instead of `/trekexecute` standing in.
- **P10's model-free part is done; the eval gate (P10) is exempted until the fixed test set runs again.** Every case in `evals/` now names the event it came from (`from=`), the eight test-set cases included (`from=proevesett-eval-suite`, `from=proevesett-intent-gate`). The routing test (`lib/skills/routing.mjs`) fails any case without it, routed or not, and its report gives both counts: cases checked for `from=` and cases routed. The release gate that would read the last test-set run is written into `docs/release-runbook.md` as exempted with its reason, not built: a gate that can never fire is not built.
- `/trekbrief`, `/trekplan` and `/trekreview` name `voyage:annotating` where they build the page;
the description of the page and the "revises freehand" loop left the three commands. The call
to `scripts/annotate.mjs`, the not-a-gate line and the printed links stay. The printed report now
@ -29,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Doors
- two-way: every case in `evals/` names its event, and the routing test checks every case (P10, R1) — a revert lets a case without `from=` through again; the eight tags added to the fixed test set stay harmless.
- two-way: the coupling to Claude Code's `run` and the built-in owner list (R2) — a revert removes the three sentences and makes `owner=run` an unknown owner again; the criteria runner and `End-to-end:` are unchanged.
- two-way: skill annotating and its pointers in three commands (order B) — revert the commit; the
script, the page, the stored notes and the approval checks are unchanged.

View file

@ -33,7 +33,7 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
| `/trekbrief` | Brief — one question round sized by class (`ceremony: small\|bounded\|architectural`, heaviest in doubt, said aloud, only moved up), run in the form of `voyage:interviewing` (asked for by name; criteria by `voyage:writing-criteria`, which `brief-reviewer` is also told to judge by, with `voyage:reviewing-adversarially`), produces a task brief with explicit research plan; ends with the operator explicitly approving `## Intent` + `## Goal` ("sounds good" is not approval), logged in the target repo's hash-chained approval log and committed (the brief too when `artifact_store: git`); the operator's notes from the annotation page go through `voyage:annotating` (`--approve <dir>` does only the approval, then writes the trekbrief record); optionally orchestrates the pipeline | session |
| `/trekresearch` | Research — deep local + external research, produces structured research brief. Opt-in `--engine {swarm\|deep-research}` delegates the external phase to Claude Code's built-in `/deep-research` workflow (swarm default). The external agents preload the `researching-sources` skill and are told it by name at spawn | session |
| `/trekplan` | Plan — brief-reviewer, explore, plan, review. Requires `--brief` or `--project`; halts unless the brief's intent is approved (hash-bound, stale on edit). Auto-discovers `architecture/overview.md` if present. Plan 1.9: the file first on every line — every `Files:` entry and `Test first:` `File:` line opens with its backticked path(s). From 1.8: `Test first:` per step + one `End-to-end:` step; every check written by `voyage:writing-criteria` (named where the plan is written and where its three reviewers start); a design part for `ceremony: architectural` (absent = architectural); critic loop of max 3 rounds, recorded in the plan, rounds 2+ in diff mode (rule A, counted by the gate; rule B in the reviewers), and `plan-gate.mjs` (0 go / 1 stop / 2 could not run) decides; open minors after it are listed for the operator to pick | session |
| `/trekexecute` | Execute — disciplined plan/session-spec executor: refuses a plan whose logged approval does not cover its current sha256 (K2); one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / step check (K9) / `Regression:` (K10) / done line as commands, fix rounds that go up (max 5: helper → plan → operator), architecture rules (K5) + plan-file sweep (K6) after the last step, then its own review (`/simplify` → `/trekreview` → verdict gate, K7; BLOCK keeps the merge into main closed), closed stop list + decision log in the final report | session |
| `/trekexecute` | Execute — disciplined plan/session-spec executor: refuses a plan whose logged approval does not cover its current sha256 (K2); one fresh `step-implementer` helper per plan step, one controller that reads the diff (spec + quality verdicts), proof of red / floor guard / step check (K9) / `Regression:` (K10) / done line as commands, fix rounds that go up (max 5: helper → plan → operator), architecture rules (K5) + plan-file sweep (K6) after the last step, the app seen with Claude Code's built-in `run` while the `End-to-end:` command stays the verdict (`run` gives no exit code), then its own review (`/simplify` → `/trekreview` → verdict gate, K7; BLOCK keeps the merge into main closed), closed stop list + decision log in the final report | session |
| `/trekreview` | Review — a gate: Claude Code's `/code-review` + `/security-review` as engine, then Voyage's brief control (which stops); writes `review.md` with a required verdict that `lib/review/verdict-gate.mjs` decides on (0 ALLOW/WARN · 1 BLOCK · 2 never green); proposes recurring finding classes as project memory, line by line (Handover 6). The reviewers and coordinator preload `voyage:reviewing-adversarially` and are told it by name at spawn; each reviewer's Declined to Judge lines reach `review.md`; the operator's notes from the annotation page go through `voyage:annotating` | session |
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session |
| `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | session |
@ -140,7 +140,9 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| `voyage:annotating` | The operator-annotation loop: the person writes every note, Claude none; Fix/Change edit only the quoted text, a Question is answered and changes nothing; the approval an edit costs is said first (stale brief intent → `/trekplan` halts; changed plan → K2, `/trekexecute` refuses); page rebuilt after the revision; research documents on request | `/trekbrief`, `/trekplan`, `/trekreview` name it where they build the page |
| `voyage:writing-criteria` | Each criterion / `Verify:` / `Regression:` / `End-to-end:` is one command the criteria runner accepts, as the line's first backticked span; anything more is `bash tests/<name>.sh`; it must be able to go red (runner deterministic, go-red prose only) | `brief-reviewer` + `plan-critic` `skills:` (preload); `/trekplan` names it |
> **Commands are the trunk** (operator, 06.10). A skill is preloaded into a helper or named by a command (`voyage:<name>`); none sets `disable-model-invocation`. Form = S1, checked by `lib/skills/skill-lint.mjs`; `lib/skills/routing.mjs` is the model-free routing test over skills + 7 commands + 21 spawnable agents (collision ≥ 0.75 fails; thresholds pinned, never lowered). Eval cases `evals/skill-*` (3 fire / 2 quiet / 1 effect) are written, not run. Method (red baseline first) → `docs/skills-authoring.md`.
> **Commands are the trunk** (operator, 06.10). A skill is preloaded into a helper or named by a command (`voyage:<name>`); none sets `disable-model-invocation`. Form = S1, checked by `lib/skills/skill-lint.mjs`; `lib/skills/routing.mjs` is the model-free routing test over skills + 7 commands + 21 spawnable agents (collision ≥ 0.75 fails; thresholds pinned, never lowered). Eval cases `evals/skill-*` (3 fire / 2 quiet / 1 effect) are written, not run.
> Every case in `evals/` (the test set too) carries `from=<event>`; the routing test fails one without it and reports both counts, checked for `from=` and routed; a quiet case's owner may be Claude Code's own `run` (`BUILTIN_OWNERS`, R2), outside the corpus, so only "not taken" is checked
> (P10's model-free part, R1; the release eval gate is exempted until the test set runs again). Method (red baseline first) → `docs/skills-authoring.md`.
## Reference docs (read on demand)

View file

@ -328,7 +328,7 @@ Every implementation step includes:
- **Checkpoint:** — git commit after success
- **Manifest:** — the objective completion predicate (Hard Rule 17)
One step per plan is the "run the thing" step (`End-to-end:`): the product run the way a user runs it. Without it a result is "not verified end to end", never green.
One step per plan is the "run the thing" step (`End-to-end:`): the product run the way a user runs it. Without it a result is "not verified end to end", never green. The app is seen and driven with Claude Code's built-in `run` skill; the verdict is still the `End-to-end:` command, because `run` gives no exit code.
**Checks that run, and can fail.** Every `Verify:`, `Regression:`, `End-to-end:` and `## Verification` command, like every brief success criterion, is written by the `voyage:writing-criteria` skill: one command the criteria runner accepts (a known test runner, plain words, no pipe or `&&`), a script under `tests/` (`bash tests/<name>.sh`) for anything more, and a command that can go red. `/trekplan` asks for it where the plan is written and where it starts its reviewers; `brief-reviewer` and `plan-critic` have it preloaded and report a criterion the runner refuses, or one that exits 0 whatever the code does (`git status`, `git log`, a test file that already passes).
@ -346,7 +346,7 @@ Borrowed, with credit: per-step interfaces, Global Constraints and Review Focus
Reads a plan from `/trekplan` and implements it with strict discipline. No guessing, no improvising — follows the plan exactly.
Per step: record the start point → **one fresh helper** (`step-implementer`) writes the step's test first, proves it red, locks it, then applies Changes exactly as written → the executor proves it by command: **red at the start point, green at the result** (`red-proof.mjs`), Verify (exit code is truth), manifest audit, and the **floor guard** on the diff (`test-integrity.mjs`: removed asserts, skipped or deleted tests, muted checks, stubs, loosened thresholds) → **one controller** reads the diff — never the helper's summary — and gives two verdicts, *spec* and *quality* → a script writes the step's **done line** only when the test command exits 0 (`done-line.mjs`) → Checkpoint commit. After all steps: independent Phase 7.5 manifest audit from git log + filesystem (ignoring agent bookkeeping); drift triggers Phase 7.6 bounded recovery. A plan with no passing run-the-thing criterion ends **not verified end to end**, never green.
Per step: record the start point → **one fresh helper** (`step-implementer`) writes the step's test first, proves it red, locks it, then applies Changes exactly as written → the executor proves it by command: **red at the start point, green at the result** (`red-proof.mjs`), Verify (exit code is truth), manifest audit, and the **floor guard** on the diff (`test-integrity.mjs`: removed asserts, skipped or deleted tests, muted checks, stubs, loosened thresholds) → **one controller** reads the diff — never the helper's summary — and gives two verdicts, *spec* and *quality* → a script writes the step's **done line** only when the test command exits 0 (`done-line.mjs`) → Checkpoint commit. After all steps: independent Phase 7.5 manifest audit from git log + filesystem (ignoring agent bookkeeping); drift triggers Phase 7.6 bounded recovery. A plan with no passing run-the-thing criterion ends **not verified end to end**, never green. Claude Code's built-in `run` skill can show the app working, but it gives no exit code, so the run-the-thing verdict stays with the plan's `End-to-end:` command.
A run starts only on a plan you approved **as it is now**: the approval log holds the plan's sha256, so a plan edited after its approval is refused before step 1. At step end the repo's own tools check the step's diff (the step check) and the step's `Regression:` suite must stay green. After the last step come the architecture rules and the sweep for files the plan does not name, and then **the review starts by itself**: `/simplify` → `/trekreview` → the verdict gate. A BLOCK keeps the merge into main closed.
@ -674,7 +674,9 @@ The workshop behind them runs in `npm test`: the form check `lib/skills/skill-li
"Use when ...", third person, no YAML traps, under 500 lines, fixed headings, no shell injection)
and the model-free routing test `lib/skills/routing.mjs` (no two descriptions among skills,
commands and agents may collide; 3 firing, 2 quiet and 1 effect case per skill). The eval cases sit
in `evals/skill-*` for the operator's own eval run. How to write one, starting with a baseline the
in `evals/skill-*` for the operator's own eval run. Every case in `evals/`, the fixed test set's
too, names the event it came from (`from=`); the routing test fails a case without it and reports
how many cases it checked for `from=` and how many it routed. A quiet case's owner may be Claude Code's own `run` skill, which is outside the corpus: then only "the skill does not rank first" is checked. How to write one, starting with a baseline the
helper fails without the skill: [docs/skills-authoring.md](docs/skills-authoring.md).
Borrowed, with credit: the form check and routing test from addyosmani/agent-skills (MIT); the red

View file

@ -2087,7 +2087,9 @@ verification failure must not hide a manifest drift.
### End to end — "run the thing" (P9 (6))
Tests passing is not the product working. Copy `end_to_end.status` from the
Tests passing is not the product working. To see and drive the app, use
Claude Code's built-in `run` skill; the verdict is still the `End-to-end:`
command, because `run` gives no exit code. Copy `end_to_end.status` from the
runner's JSON into `end_to_end` — the runner decides it, not this prose:
- `"verified"` — the verification passed and at least one passed criterion

View file

@ -792,17 +792,17 @@ Write the plan following the template structure. The plan MUST include:
(`PLAN_STEP_TESTS_ONLY`).
**The "run the thing" step.** At least one step carries
`End-to-end: \`<command>\` → expected: <what the user sees>`: the product run
the way a user runs it (the CLI, the server and a request, the app; for iOS,
the simulator). Without it the result is "not verified end to end", never
green. The command passes the criteria runner's allowlist — the product's
own binary does not, so the run is wrapped in a script:
`End-to-end: \`<command>\` → expected: <what the user sees>`: the product run the way a
user runs it (the CLI, the server and a request, the app; for iOS, the simulator). Without
it the result is "not verified end to end", never green. To see and drive the app, use
Claude Code's built-in `run` skill; the verdict is still the `End-to-end:` command, because
`run` gives no exit code. The command passes the criteria runner's allowlist — the
product's own binary does not, so the run is wrapped in a script:
`End-to-end: \`bash tests/e2e.sh\``. The same command is also listed under
`## Verification`: Phase 7 of `/trekexecute` runs that section, and a passed
criterion that runs the step's `End-to-end:` command is what verifies the
run end to end. `End-to-end:` rides on the last step that builds behaviour,
with `tests/e2e.sh` in that step's `Files:`: a step of its own that only adds
the script is a step of only tests, and it can never be proven red.
`## Verification`: Phase 7 of `/trekexecute` runs that section, and a passed criterion that
runs the step's `End-to-end:` command is what verifies the run end to end. `End-to-end:`
rides on the last step that builds behaviour, with `tests/e2e.sh` in that step's `Files:`:
a step of its own that only adds the script is a step of only tests, and it can never be proven red.
10. **Execution Strategy** — For plans with > 5 steps: group steps into sessions
(3–5 steps each), organize sessions into waves (parallel where independent),
specify scope fences per session. Omit for plans with ≤ 5 steps.

View file

@ -225,14 +225,18 @@ changes introduced by commits that are not ancestors of the previously
reverted merge." Revert the revert commit instead, and release it as a new
version.
## Not built yet: the eval gate (P10)
## Exempted: the eval gate (P10)
The local gate before a release that reads the date and score of the last run
of the fixed test set and compares it with the last change in the
configuration paths (commands, agents, skills, hooks, and a changed model alias
counts as a change). It waits for the test set to run again; a gate that can
never fire is not built. When it comes, it is one more check in
`scripts/release-check.mjs`, before the door check, and one more step in § 1.
counts as a change). It is exempted from the release, with a reason: it waits
for the fixed test set to run again, and a gate that can never fire is not
built. P10's model-free part is in place: every case in `evals/` names the
event it came from (`from=`), and the routing test (`lib/skills/routing.mjs`,
run by `npm test`) fails a case without it. When the gate comes, it is one more
check in `scripts/release-check.mjs`, before the door check, and one more step
in § 1.
## Credits

View file

@ -51,7 +51,10 @@ the 7 commands and the 21 spawnable agents, with no model:
- Each skill has at least 3 prompts where it should fire, 2 where it should not, and 1 effect
case. A firing prompt must rank the skill in the top 3; every one currently ranks it first, and
that floor is held. A prompt where the skill should stay quiet names the command, agent or skill
that owns it, and that owner must rank higher.
that owns it, and that owner must rank higher. The owner may also be one of Claude Code's own
skills on a short fixed list in `routing.mjs` (`BUILTIN_OWNERS`, only `run` for now). Such an
owner is outside the corpus, so only "not taken" is checked: the skill must not rank first, and
the report says so for each such case.
- The thresholds are pinned in `tests/lib/skill-routing.test.mjs` and never lowered. A failing case
is fixed in a description, never in a number. You do not change another component's
description to make room for yours.
@ -72,6 +75,10 @@ tags: [skill-effect, skill=test-first, from=baseline-tfd-crash-red]
```
`from=` names the event the case came from: a measured failure, a review finding, an analysis.
It is required on every case in `evals/`, not only the skill cases: a case of the fixed test set
(`tags: [proevesett, from=proevesett-eval-suite]`) has no skill tag, so it is not routed, but the
routing test still fails it without `from=`. Its report gives both counts, cases checked for
`from=` and cases routed.
- A **skill-trigger** case has one grader: `type: tool_used`, `tool: Skill`, with an `input_match`
that accepts `voyage:<name>` and the bare name.

View file

@ -1,7 +1,7 @@
---
description: "/trekplan on a brief that passes the sequencing gate but carries no intent approval halts at the intent gate (veikart steg 1)."
expected_outcome: "intent-approval.mjs --check runs and reports BRIEF_INTENT_NOT_APPROVED; /trekplan halts and names /trekbrief --approve; no Agent call (no exploration swarm), no plan.md."
tags: [proevesett]
tags: [proevesett, from=proevesett-intent-gate]
runs: 1
max_turns: 15
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "/trekplan on a brief_version 2.1 brief without phase_signals halts at the sequencing gate."
expected_outcome: "brief-validator runs, reports BRIEF_V51_MISSING_SIGNALS, and /trekplan halts with the phase_signals hint; no Agent call (no exploration swarm), no plan.md."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 15
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "/trekplan --project on a missing folder stops before any exploration."
expected_outcome: "'Error: project directory not initialized. Run /trekbrief to create it.'; no Agent call, no plan.md."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 8
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "/trekplan --export with a removed format names the format and stops."
expected_outcome: "'Error: export format 'pr' is not supported.'; no Agent call, no file written."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 8
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "/trekplan with no arguments prints usage and stops."
expected_outcome: "Usage block ending in 'A brief is required. Produce one with /trekbrief first.'; no Agent call, no file written."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 8
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "/trekreview with no arguments demands --project and stops."
expected_outcome: "'Error: --project <dir> is required.' plus usage; no Agent call, no file written."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 8
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "KNOWN-POSITIVE. /trekreview --validate on a review.md whose finding ID is not 40-char hex fails and names the rule."
expected_outcome: "review-validator runs and reports REVIEW_BAD_FINDING_ID; the one-line summary says FAIL; no Agent call, no file written."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 10
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "KNOWN-NEGATIVE. /trekreview --validate on a valid review.md passes and raises no error code."
expected_outcome: "review-validator runs; the one-line summary says PASS; no REVIEW_* error code appears anywhere in the trace; no Agent call, no file written."
tags: [proevesett]
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 10
timeout_seconds: 300

View file

@ -1,7 +1,7 @@
---
description: "Routing case: the writing-criteria skill must stay quiet here. Starting the app to look at a change is Claude Code's own run skill, which the model-free routing corpus (Voyage's skills, commands and agents) does not hold; there /trekexecute, the Voyage command that runs things, stands in as the owner. The model-run grader only checks that writing-criteria is not called."
description: "Routing case: the writing-criteria skill must stay quiet here. Starting the app to look at a change is Claude Code's own run skill. It is outside the model-free routing corpus (Voyage's skills, commands and agents), so the routing test only checks that writing-criteria does not rank first. The model-run grader only checks that writing-criteria is not called."
expected_outcome: "Claude never calls the Skill tool for voyage:writing-criteria."
tags: [skill-no-trigger, skill=writing-criteria, owner=trekexecute, from=order-7c-negatives]
tags: [skill-no-trigger, skill=writing-criteria, owner=run, from=order-7c-negatives]
runs: 3
max_turns: 4
allowed_tools: [Read, Glob, Grep, Skill]

View file

@ -20,7 +20,11 @@
// A case is tagged in its prompt.md frontmatter:
// tags: [skill-trigger | skill-no-trigger | skill-effect, skill=<name>,
// owner=<command|agent|skill name> (negatives), from=<event>]
// `from=` names the event the case came from (P10: every case is marked).
// An owner may also be one of Claude Code's own skills on BUILTIN_OWNERS (R2);
// it stands outside the corpus, so only "the skill does not rank first" is checked.
// `from=` names the event the case came from (P10, R1): it is required on every
// case in evals/, and a case with no skill tag (the fixed test set) is not
// routed, only checked for `from=`.
//
// What this cannot see: how Claude Code really chooses. A ranking over word
// overlap is a proxy; the operator's eval run is the measurement.
@ -180,8 +184,12 @@ export function loadEntries(root) {
const KIND_TAGS = { 'skill-trigger': 'trigger', 'skill-no-trigger': 'no-trigger', 'skill-effect': 'effect' };
/** The routing-tagged eval cases of a plugin root (evals/<case>/prompt.md). */
export function loadCases(root) {
/**
* Every eval case of a plugin root (evals/<case>/prompt.md), routed or not.
* `kind` is null for a case with no skill tag (the fixed test set): it is not
* routed, only checked for `from=` (P10, R1).
*/
export function loadAllCases(root) {
const d = join(root, 'evals');
if (!existsSync(d)) return [];
const out = [];
@ -192,16 +200,30 @@ export function loadCases(root) {
const tagsField = readFrontmatterFields(text).fields.tags;
const tags = tagsField && Array.isArray(tagsField.value) ? tagsField.value.map(String) : [];
const kindTag = tags.find((t) => Object.hasOwn(KIND_TAGS, t));
if (!kindTag) continue;
const val = (k) => { const t = tags.find((x) => x.startsWith(`${k}=`)); return t ? t.slice(k.length + 1) : null; };
const body = text.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
out.push({ case: name, kind: KIND_TAGS[kindTag], skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body });
out.push({ case: name, kind: kindTag ? KIND_TAGS[kindTag] : null, skill: val('skill'), owner: val('owner'), from: val('from'), prompt: body });
}
return out;
}
/** The routing-tagged eval cases of a plugin root. */
export function loadCases(root) {
return loadAllCases(root).filter((c) => c.kind);
}
// ---- the check ----
/**
* Claude Code's own skills a negative case may name as its owner (R2). They
* stand outside the corpus, so for such an owner only "not taken" is checked:
* the skill under test must not rank first. Kept short and fixed.
* run - the bundled "Launch and drive your app to see a change working"
* skill, seen in Claude Code 2.1.303 (code.claude.com/docs/en/skills,
* "Run and verify your app").
*/
export const BUILTIN_OWNERS = Object.freeze(['run']);
/**
* Run the whole routing check over a plugin root.
* @returns {{ errors: object[], warnings: object[], stats: object }}
@ -214,9 +236,11 @@ export function checkRouting(root, t = THRESHOLDS) {
// name must not take its place. An owner may be any kind; a skill wins a tie.
const skillByName = new Map(skills.map((e) => [e.name, e]));
const byName = new Map([...entries.filter((e) => e.kind !== 'skill'), ...skills].map((e) => [e.name, e]));
const cases = loadCases(root);
const all = loadAllCases(root);
const cases = all.filter((c) => c.kind);
const errors = [];
const warnings = [];
const notes = [];
let positives = 0; let rank1 = 0; let negatives = 0;
for (const c of collisions(corpus, t)) {
@ -225,8 +249,11 @@ export function checkRouting(root, t = THRESHOLDS) {
(c.level === 'error' ? errors : warnings).push(entry);
}
for (const c of cases) {
for (const c of all) {
if (!c.from) errors.push({ code: 'ROUTING_CASE_NO_SOURCE', case: c.case, message: `${c.case}: tag the event the case came from (from=...)` });
}
for (const c of cases) {
const self = c.skill && skillByName.get(c.skill);
if (!self || self.kind !== 'skill') {
errors.push({ code: 'ROUTING_UNKNOWN_SKILL', case: c.case, message: `${c.case}: skill=${c.skill} is not a skill in skills/` });
@ -251,6 +278,9 @@ export function checkRouting(root, t = THRESHOLDS) {
}
if (!c.owner) {
errors.push({ code: 'ROUTING_NEGATIVE_NO_OWNER', case: c.case, message: `${c.case}: a negative case names the owner that should take the prompt (owner=...)` });
} else if (!byName.has(c.owner) && BUILTIN_OWNERS.includes(c.owner)) {
notes.push({ code: 'ROUTING_OWNER_OUTSIDE_CORPUS', case: c.case,
message: `${c.case}: owner=${c.owner} is Claude Code's own; owner outside the corpus: only "not taken" checked` });
} else if (!byName.has(c.owner)) {
errors.push({ code: 'ROUTING_UNKNOWN_OWNER', case: c.case, message: `${c.case}: owner=${c.owner} is no skill, command or spawnable agent` });
} else {
@ -276,7 +306,7 @@ export function checkRouting(root, t = THRESHOLDS) {
if (positives && rate < t.MIN_RANK1_RATE) {
errors.push({ code: 'ROUTING_RANK1_FLOOR', message: `${rank1} of ${positives} positives rank their skill first (${(rate * 100).toFixed(0)}%); the floor is ${(t.MIN_RANK1_RATE * 100).toFixed(0)}%` });
}
return { errors, warnings, stats: { entries: entries.length, skills: skills.length, cases: cases.length, positives, negatives, rank1 } };
return { errors, warnings, notes, stats: { entries: entries.length, skills: skills.length, sourced: all.length, cases: cases.length, positives, negatives, rank1 } };
}
// ---- CLI ----
@ -294,7 +324,8 @@ if (isMain(import.meta.url)) {
else {
for (const e of r.errors) process.stdout.write(`FAIL ${e.code}: ${e.message}\n`);
for (const w of r.warnings) process.stdout.write(`warn ${w.code}: ${w.message}\n`);
process.stdout.write(`${r.stats.entries} descriptions, ${r.stats.cases} cases, ${r.stats.rank1}/${r.stats.positives} positives first; ${r.errors.length} finding(s)\n`);
for (const n of r.notes) process.stdout.write(`note ${n.code}: ${n.message}\n`);
process.stdout.write(`${r.stats.entries} descriptions, ${r.stats.sourced} cases checked for from=, ${r.stats.cases} routed, ${r.stats.rank1}/${r.stats.positives} positives first; ${r.errors.length} finding(s)\n`);
}
process.exit(r.errors.length ? 1 : 0);
}

View file

@ -145,7 +145,7 @@ Several test files share one `File:` line the same way:
- File: `path/to/test.ts` *(existing | new)*
- Verifies: {what the test checks}
- Pattern: `path/to/existing-test.ts`
- **End-to-end:** `{a script that runs the product the way a user does — the app, the CLI, the simulator; usually bash tests/e2e.sh}` → expected: {what the user sees} *(the same command also stands under ## Verification)*
- **End-to-end:** `{a script that runs the product the way a user does — the app, the CLI, the simulator; usually bash tests/e2e.sh}` → expected: {what the user sees} *(the same command also stands under ## Verification; to see and drive the app, use Claude Code's built-in "run" skill — the verdict is still the End-to-end: command, because "run" gives no exit code)*
- **Verify:** `{exact command}` → expected: `{output}`
- **On failure:** {revert | retry | skip | escalate} — {specific instructions}
- **Checkpoint:** `git add {the step's Files and its test files} && git commit -m "{conventional commit message}"`

View file

@ -22,7 +22,7 @@
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1125-1149 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | lib/stats/stats-append.mjs (trekresearch.md Stats tracking) | trekresearch.md:782-808 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | lib/stats/stats-append.mjs (trekplan.md Phase 12) | trekplan.md:1273-1301 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2907-2929 | profile, phase_models, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2909-2931 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:636-686 | profile, phase_models, profile_source | none |

View file

@ -0,0 +1,82 @@
// tests/lib/p10-model-free.test.mjs
// P10's model-free part (wave 4, order A1). Every case in evals/ names the
// event it came from (`from=`), not only the skill cases; the routing check
// covers every case and says how many it checked for `from=` and how many it
// routed; and the release gate that would read the last test-set run is
// written down as exempted with a reason, not built.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFrontmatterFields } from '../../lib/skills/skill-frontmatter.mjs';
import { checkRouting } from '../../lib/skills/routing.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CLI = join(ROOT, 'lib', 'skills', 'routing.mjs');
const tagsOf = (file) => {
const f = readFrontmatterFields(readFileSync(file, 'utf8')).fields.tags;
return f && Array.isArray(f.value) ? f.value.map(String) : [];
};
test('P10: every case in evals/ names the event it came from (from=)', () => {
const dirs = readdirSync(join(ROOT, 'evals')).filter((d) => existsSync(join(ROOT, 'evals', d, 'prompt.md'))).sort();
assert.ok(dirs.length >= 45, `${dirs.length} cases`);
const missing = dirs.filter((d) => !tagsOf(join(ROOT, 'evals', d, 'prompt.md')).some((t) => /^from=\S+$/.test(t)));
assert.deepEqual(missing, [], `${missing.length} of ${dirs.length} cases have no from=: ${missing.join(', ')}`);
});
/** A plugin root with one skill, its 3/2/1 cases, and the extra cases given. */
function stubRoot(extra) {
const root = mkdtempSync(join(tmpdir(), 'p10-model-free-'));
mkdirSync(join(root, 'skills', 'tidy-imports'), { recursive: true });
writeFileSync(join(root, 'skills', 'tidy-imports', 'SKILL.md'),
'---\nname: tidy-imports\ndescription: Sorts the import lines of a source file. Use when imports are out of order.\n---\n');
mkdirSync(join(root, 'commands'), { recursive: true });
writeFileSync(join(root, 'commands', 'rotate-logs.md'), '---\nname: rotate-logs\ndescription: Compresses and deletes old log files on disk.\n---\n');
const cases = {
p1: ['skill-trigger, skill=tidy-imports, from=test', 'sort the import lines'],
p2: ['skill-trigger, skill=tidy-imports, from=test', 'import lines are out of order'],
p3: ['skill-trigger, skill=tidy-imports, from=test', 'sort imports in the source file'],
n1: ['skill-no-trigger, skill=tidy-imports, owner=rotate-logs, from=test', 'delete old log files'],
n2: ['skill-no-trigger, skill=tidy-imports, owner=rotate-logs, from=test', 'compress the logs on disk'],
e1: ['skill-effect, skill=tidy-imports, from=test', 'x'],
...extra,
};
for (const [n, [tags, prompt]] of Object.entries(cases)) {
mkdirSync(join(root, 'evals', n), { recursive: true });
writeFileSync(join(root, 'evals', n, 'prompt.md'), `---\ndescription: "case ${n}"\ntags: [${tags}]\nruns: 1\n---\n\n${prompt}\n`);
}
return root;
}
test('P10 (R1): a case with no skill tag and no from= is a finding of the routing check', () => {
const root = stubRoot({ 'plain-gate': ['proevesett', '/voyage:trekplan'], 'plain-sourced': ['proevesett, from=test', '/voyage:trekplan'] });
try {
const r = checkRouting(root);
const src = r.errors.filter((e) => e.code === 'ROUTING_CASE_NO_SOURCE').map((e) => e.case);
assert.deepEqual(src, ['plain-gate'], JSON.stringify(r.errors));
assert.ok(!r.errors.some((e) => e.case === 'plain-sourced'), 'a sourced case with no skill tag is not routed');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('P10 (R1): the report shows the denominators: cases checked for from= and cases routed', () => {
const root = stubRoot({ 'plain-sourced': ['proevesett, from=test', '/voyage:trekplan'] });
try {
const r = checkRouting(root);
assert.deepEqual([r.stats.sourced, r.stats.cases], [7, 6], JSON.stringify(r.stats));
const out = spawnSync('node', [CLI, '--root', root], { encoding: 'utf8' });
assert.match(out.stdout, /7 cases checked for from=, 6 routed/, out.stdout + out.stderr);
} finally { rmSync(root, { recursive: true, force: true }); }
});
// Found by its words, not its heading: the section title is set at the release.
test('P10: the CHANGELOG says the eval gate is exempted, with the reason', () => {
const md = readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8');
assert.ok(/^- [^\n]*eval gate \(P10\)[^\n]*exempted[^\n]*test set runs again/m.test(md),
'a CHANGELOG line must say the eval gate (P10) is exempted until the test set runs again');
});

View file

@ -118,8 +118,9 @@ test('runbook: the release check exits as the script does — 0 ready, 1 not rea
assert.match(before, /0 = ready, 1 = not ready[^.]*, 2 = could not run — never green/);
});
test('runbook: the eval gate (P10) is named as not built, with where it goes', () => {
const p10 = part(read(RUNBOOK), 'Not built yet: the eval gate (P10)');
test('runbook: the eval gate (P10) is exempted with its reason, and says where it goes', () => {
const p10 = part(read(RUNBOOK), 'Exempted: the eval gate (P10)').replace(/\s+/g, ' ');
assert.match(p10, /waits for the fixed test set to run again/, 'P10 must say why the gate is exempted');
assert.ok(p10.includes('scripts/release-check.mjs'), 'P10 must say which file the gate goes into');
});

View file

@ -384,7 +384,7 @@ test('review 630 r3: routing reads a kind tag only from its own table, never Obj
for (const d of ['skills', 'commands', 'agents', 'evals']) cpSync(join(ROOT, d), join(dir, d), { recursive: true });
const c = join(dir, 'evals', 'zz-proto');
cpSync(join(ROOT, 'evals', 'skill-test-first-quiet-1'), c, { recursive: true });
writeFileSync(join(c, 'prompt.md'), '---\ntags: [constructor, owner=nobody]\n---\n\nWhat time is it?\n');
writeFileSync(join(c, 'prompt.md'), '---\ntags: [constructor, owner=nobody, from=test]\n---\n\nWhat time is it?\n');
const { checkRouting } = awaitImportSync();
const r = checkRouting(dir);
assert.ok(!r.errors.some((e) => String(e.message ?? '').includes('zz-proto') || e.case === 'zz-proto'), JSON.stringify(r.errors));

View file

@ -0,0 +1,59 @@
// tests/lib/run-coupling-prose-pins.test.mjs
// The coupling to Claude Code's built-in `run` skill (wave 4, order A2,
// decisions 54 and 64). The plan's "run the thing" step, the executor's
// `### End to end` and the plan template's run step each say in one sentence
// that the app is seen and driven with `run`, and that the verdict is still
// the `End-to-end:` command, because `run` gives no exit code. Each part is
// read by its heading or marker, never by line number.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
/** From the line holding `marker` to the first line where `stop` is true. */
function partAt(rel, marker, stop) {
const lines = read(rel).split('\n');
const start = lines.findIndex((l) => l.includes(marker));
assert.ok(start !== -1, `${rel} must hold "${marker}"`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) if (stop(lines[i])) { end = i; break; }
return lines.slice(start, end).join(' ').replace(/\s+/g, ' ');
}
const PARTS = [
// An indented paragraph of a numbered list item: it ends at a blank or unindented line.
['commands/trekplan.md', '**The "run the thing" step.**', (l) => l.trim() === '' || /^\S/.test(l)],
['commands/trekexecute.md', '### End to end — "run the thing" (P9 (6))', (l) => l.startsWith('### ')],
['templates/plan-template.md', '### Step 2: {description — the "run the thing" step}', (l) => l.startsWith('### ')],
];
// The template quotes the names instead of backticking them: every backticked
// span on a template step bullet is read as a runnable example
// (tests/lib/skills-criteria-prose-pins.test.mjs), and `run` is no command.
const RUN = '(?:`run`|"run")';
for (const [rel, marker, stop] of PARTS) {
test(`run coupling: ${rel} names Claude Code's run and keeps the verdict on the End-to-end: command`, () => {
const p = partAt(rel, marker, stop);
assert.match(p, new RegExp(`Claude Code's built-in ${RUN} skill`), `${rel}: the run step must name Claude Code's built-in run`);
assert.match(p, /the verdict is still the `?End-to-end:`? command/, `${rel}: the verdict must stay the End-to-end: command`);
assert.match(p, new RegExp(`${RUN} gives no exit code`), `${rel}: the reason must be said`);
});
}
test('run coupling: the run step still says what it runs, through the criteria runner', () => {
const p = partAt(...PARTS[0]);
assert.match(p, /criteria runner's allowlist/);
assert.match(p, /End-to-end: \\`bash tests\/e2e\.sh\\`/);
});
test('run coupling: the writing-criteria quiet case is owned by run, with no stand-in left in its description', () => {
const md = read('evals/skill-writing-criteria-quiet-2/prompt.md');
assert.match(md, /^tags: \[[^\]]*\bowner=run\b[^\]]*\]$/m);
assert.doesNotMatch(md, /stands in/);
assert.doesNotMatch(md, /owner=trekexecute/);
});

View file

@ -174,6 +174,40 @@ test('routing: a negative must name an owner that exists and outranks the skill'
});
});
// ---- R2: an owner from Claude Code's own skills (wave 4, order A2) ----
const R2_SPEC = (prompt, owner = 'run') => ({
skills: { 'tidy-imports': 'Sorts the import lines of a source file. Use when imports are out of order.' },
commands: { 'rotate-logs': 'Compresses and deletes old log files on disk.' },
cases: { n1: { tags: ['skill-no-trigger', 'skill=tidy-imports', `owner=${owner}`, 'from=test'], prompt } },
});
test('routing (R2): owner=run, a built-in Claude Code skill, is no unknown owner, and the report says what was checked', () => {
withPlugin(R2_SPEC('start the app and click through the new screen'), (root) => {
const r = checkRouting(root);
assert.ok(!r.errors.some((e) => e.case === 'n1'), JSON.stringify(r.errors));
const note = (r.notes || []).find((n) => n.case === 'n1');
assert.ok(note, JSON.stringify(r));
assert.match(note.message, /owner outside the corpus: only "not taken" checked/);
const out = spawnSync('node', [CLI, '--root', root], { encoding: 'utf8' });
assert.match(out.stdout, /note ROUTING_OWNER_OUTSIDE_CORPUS: n1: owner=run [^\n]*owner outside the corpus: only "not taken" checked/, out.stdout);
});
});
test('routing (R2): a skill that ranks first is still red when the owner is run', () => {
withPlugin(R2_SPEC('sort the import lines of the source file'), (root) => {
const r = checkRouting(root);
assert.ok(r.errors.some((e) => e.code === 'ROUTING_NEGATIVE_WON' && e.case === 'n1'), JSON.stringify(r.errors));
});
});
test('routing (R2): an owner neither in the corpus nor on the built-in list is still unknown', () => {
withPlugin(R2_SPEC('start the app and click through the new screen', 'nosuchthing'), (root) => {
const r = checkRouting(root);
assert.ok(r.errors.some((e) => e.code === 'ROUTING_UNKNOWN_OWNER' && e.case === 'n1'), JSON.stringify(r.errors));
});
});
test('routing: every case names the event it came from (P10)', () => {
withPlugin({
skills: { 'tidy-imports': 'Sorts the import lines of a source file. Use when imports are out of order.' },