Merge branch 'chore/cleanup-r-b' — cleanup R-b (decisions 175, 189-191, 209)
Removes Voyage's own stats/OTel export (R1), STORM and the --engine research choice (R2), and profiles, phase signals and the effort dialog (R3), plus the R-a leftovers. One fixed path per command (209). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e6fb8bc12d
169 changed files with 525 additions and 19339 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -58,6 +58,10 @@ Part B makes a WARN open the merge lock and the release check only when the oper
|
|||
- **`~` and `$HOME` in the arguments of `/trekendsession`, `/trekcontinue` and `/trekreview` are no longer expanded** (ARG, fix round 4.5b). The arguments reach the arg parser as text on stdin, not as shell words, so nothing in them is expanded: write the path out (`~/x` stays the three characters `~/x`).
|
||||
- **The autonomy gate is the `--gates` flag parser** (cleanup R-a, R9). `lib/util/autonomy-gate.mjs` keeps only `gatesValue` and `parseGatesArgs`: `--gates [value]` prints `{"ok":true,"gates":true|false}`, a bad value a usage error (exit 1). The state machine (`STATES`, `EVENTS`, `transition`), which no command used, is gone; the four commands call the parser with the flag only.
|
||||
- **The live tests left the round files** (cleanup R-a, R8). The tests of live behaviour in the `retterunde*`, `trial*`, `fix-round-4-5a-prose` and `fix-round-4-5b2-b-prose` files moved into the test file named after the source they test, each seen failing under a mutation of that source; the round files, the eight round end-to-end scripts and `tests/synthetic/plan-run-C.md` (now `tests/fixtures/plan-run-c.md`) are gone. `timing-under-load.test.mjs` is `timing.test.mjs`; `lib-imports.test.mjs` holds the import checks. The suite goes from 3336 to 2930 tests: duplicates and the tests of removed files left with them.
|
||||
- **One fixed path per command** (cleanup R-b, decision 209). With the phase signals gone, each command takes the heavier of its old paths. `/trekresearch` launches every agent its scope allows, and `contrarian-researcher` runs as wave 2 whenever the scope includes external. `/trekreview` always runs all four coordinator passes: a finding with no citation is dropped, and an unknown `rule_key` goes to `unverified` (the old high path's normalization to `PLAN_EXECUTE_DRIFT` is gone). `/trekplan` no longer writes the `## Adversarial Pass 2 (v5.1.1 high-effort)` section that only said the pass was skipped. The low-effort shortcuts are gone; the `--quick` modes stay. This also ends the earlier entry's "spawn sites follow the profile": a Voyage agent is spawned with no model parameter, so its own `model: opus` applies, and the two spawns of Claude Code's built-in agents (`/trekplan`'s Explore deep-dive, `/trekresearch`'s Phase 5 follow-up) pass `model: "opus"`.
|
||||
- **The brief carries no phase signals** (cleanup R-b, decision 191; Handover 1). `/trekbrief` no longer asks for per-phase effort and model, the template has no `phase_signals` block, and the brief validator's signal codes (`BRIEF_V51_MISSING_SIGNALS`, `BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE`, `BRIEF_INVALID_PHASE_SIGNALS` and the phase, effort and model codes) are gone. An older brief's `phase_signals` key is ignored, so every existing brief stays valid. A consumer that read the signals from a brief gets none from a new one.
|
||||
- **`--engine` and `--profile` are retired** (cleanup R-b). Nothing uses them. A typed `--profile` (and `/trekresearch --engine`) is dropped together with the word after it when that word is not a flag, so the value is not taken as a path or as part of the question: the six commands that had the flag say so where they parse their arguments, and the arg parser does the same (see Known issues).
|
||||
- **`intent-approval.mjs --stamp` prints `stamped`, `intent_hash` and `approved_at`** (cleanup R-b). The `record` field and the `brief-approved` stats record are gone; the stamp writes nothing outside the brief.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
@ -218,21 +222,32 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
**Cleanup R-a (decisions 188, 200-208).** What existed only for history or had no user, removed before the new design (187); git history keeps all of it.
|
||||
|
||||
- **The June trials and their docs** (R4): the balance charter and analysis, the T1 synthesis and T2 bakeoff and workflow-substrate results, the devil's-advocate plan and results, the narrow-wins, balance-backlog, token-trim and brief-assessment docs, the S22 dogfood, the 2.1.181 decision matrix, the delegation audit, the eval-corpus README, `MIGRATION.md` and `verify.sh` (`npm run verify` with it).
|
||||
- **The three orchestrator reference docs and the dormant `synthesis-agent`** (R4, R5): `agents/` holds 20 agents, all spawnable. Why orchestration runs in the session is now one sentence in `docs/architecture.md`, and why every agent is pinned `opus` is in `docs/profiles.md` §Model & effort axes.
|
||||
- **The three orchestrator reference docs and the dormant `synthesis-agent`** (R4, R5): `agents/` holds 20 agents, all spawnable. Why orchestration runs in the session is now one sentence in `docs/architecture.md`, and why every agent is pinned `opus` is in `docs/architecture.md` §Model & effort (moved there from `docs/profiles.md` in R-b).
|
||||
- **Code with no caller** (R6): `lib/parsers/project-discovery.mjs`, `lib/parsers/bash-normalize.mjs`, `lib/plan/synthesis-digest-schema.mjs`, `lib/review/fidelity-diff.mjs`, `lib/util/test-census.mjs`, `lib/validators/next-session-prompt-validator.mjs` (`/trekcontinue` Phase 1.5 now uses the project folder's prompt file and names a root one as ignored), the bakeoff scripts, `templates/spec-template.md`, the root `settings.json` and `examples/01-add-verbose-flag/`.
|
||||
- **Arm B and `--workflow`** (R6, X10): `scripts/trekreview-armB.workflow.mjs` and the `/trekreview --workflow` flag; the standard path is the only review path.
|
||||
- **The fix-round chain** (R7): the round red scripts, regression scripts, mutation files and round prose and script tests. `tests/helpers/red-round.sh`, `red-judge.mjs` and `red-mutate.mjs` stay (K2), as do the 4.5b2 part A corpus and end-to-end script.
|
||||
|
||||
**Cleanup R-b (decisions 175, 189-191, 209).** Voyage's own measurement, the STORM loop and the profiles went, in three commits on one branch; 109 files and about 19 000 lines. `npm test` went from 2930 tests to 2308, 0 failing. `tests/lib/removed-surfaces.test.mjs` fails when a tracked file names a removed surface again (seen red in all three groups before the removal).
|
||||
|
||||
- **Measurement, statistics and export** (R1, decision 189). `lib/stats/`, `lib/exporters/`, the `post-bash-stats` and `otel-export` hooks, the per-command stats phases (`/trekbrief` Phase 7, `/trekplan` Phase 12, `/trekexecute` Phase 9, `/trekcontinue` Phase 5, and the stats half of `/trekresearch` and `/trekreview` Phase 8), the events `/trekexecute` Phase 8 wrote around the merge (the merge lock never read them), the yardstick, the end-state gate and registry, the comparison gate, `docs/observability.md` and `examples/observability/`. Claude Code's own OpenTelemetry (`CLAUDE_CODE_ENABLE_TELEMETRY`) gives tool durations, tokens and cost per agent and skill; before the removal it was seen working against a local receiver with Claude Code 2.1.319: the command name, Bash durations, and model, effort and tokens per helper (condition 175).
|
||||
- **The STORM loop and the research engine choice** (R2, decision 190). `/trekresearch` Phase 4.5 and the Phase 5 follow-up loop (Phase 5 keeps its single pass, at most two follow-ups, every run), the `--engine` flag with the deep-research adapter and its self-check, the `pre-agent-cap` hook, `lib/util/research-loop-cap.mjs`, `scripts/storm-measure.mjs` and the engine and STORM docs.
|
||||
- **Profiles, phase signals and the effort dialog** (R3, decision 191). `lib/profiles/` (the four profiles and both resolvers), `lib/validators/profile-validator.mjs`, `lib/parsers/profile-jaccard.mjs`, `docs/profiles.md` (its model and effort part is now `docs/architecture.md` §Model & effort), `/trekbrief` Phase 3.5, the Profile, Composition rule, Sequencing gate and High-effort sections of the five commands, the plan validator's `MANIFEST_PROFILE_DRIFT`, the manifest's `profile_used`, `resolveTrekcontinueProfile`, and the test-set case `plan-halts-without-phase-signals` (the test set has seven cases). Every helper is a Voyage agent pinned `model: opus`; the telemetry run above saw a pinned and an unpinned one run Opus 5.5 at effort high.
|
||||
- **The files R-a left for R-b** (decision 205): `docs/T1-cc26-delegated-orchestration.md`, `lib/review/gold-scorer.mjs` with its test, `tests/lib/gold-eval.test.mjs`, `tests/fixtures/bakeoff-rich/runs/run-perfect.json` and `scripts/synthesis-measure.mjs` with its test; the path check has no "removed in R-b" exception left.
|
||||
|
||||
### Known issues
|
||||
|
||||
- **Some files wait for R-b** (cleanup R-a, decision 205). `docs/T1-cc26-delegated-orchestration.md` and `lib/review/gold-scorer.mjs` with its test stay: `scripts/end-state-registry.json`, whose hash is frozen, names them (D-06 probes the scorer). `tests/lib/gold-eval.test.mjs` and `tests/fixtures/bakeoff-rich/runs/run-perfect.json` stay with the scorer: they are the one end-to-end check that the review contract keeps every seeded finding. `scripts/comparison-gate.mjs` still lists the removed `settings.json` and `verify.sh` in a part's scope, and the profile calibration notes under `tests/synthetic/` still name the removed `examples/01-add-verbose-flag/brief.md`; both are R-b files. `scripts/synthesis-measure.mjs` and its test stay for R-b's measurement tooling (R1), although their default paths name the removed synthesis fixtures and results doc. `tests/fixtures/bakeoff-rich/` stays for V3 (decision 202); its README still describes the removed bakeoff harness as current.
|
||||
- **`tests/fixtures/bakeoff-rich/` has no reader** (cleanup R-b). It stays for V3 (decision 202); with `gold-eval` gone nothing reads `gold.json`, and its README still describes the removed bakeoff harness as current.
|
||||
- **The query guard has no caller** (cleanup R-b). `lib/validators/query-privacy-gate.mjs` and its tests stay, but its callers were the removed STORM steps, so no research query passes through it; its header and `skills/researching-sources/` say so. Left for the design (187).
|
||||
- **Eleven agents take the session's effort** (cleanup R-b). Nine agents pin `effort: high`; the other eleven inherit the session level, so a session started below high runs them below high. Left for the design (187).
|
||||
- **A retired flag is dropped without a word** (cleanup R-b). A typed `--profile x` (or `/trekresearch --engine x`) is stripped with its value, and no command says the flag does nothing.
|
||||
- **The plan gate still leaves out a Pass 2 heading** (cleanup R-b). `lib/validators/plan-gate.mjs` keeps its exclusion of the "Adversarial Pass 2 (v5.1.1 high-effort)" heading that `/trekplan`'s high-effort path wrote, so older plans hash the same; no command writes the marker now.
|
||||
- **The lessons apply still skips `original_rule_key`** (cleanup R-b). `lib/review/recurrence.mjs` skips a finding's `original_rule_key`, which only the removed high-effort normalization wrote; harmless, left.
|
||||
- **Part A's end-to-end script no longer runs** (cleanup R-a). `tests/e2e-fix-round-4-5b2-a.sh` stays, but its callers were the removed round scripts, and `npm test` runs only `*.test.mjs`; it is listed for an upgrade (A1).
|
||||
- **The path check has blind spots** (cleanup R-a). `tests/lib/living-paths.test.mjs` skips a line with an `@<sha>` or `MIT` on it or on the two lines above, so a live path next to a credit is not checked, and it reads a backticked span only when the whole span is a path, so `lib/x.mjs --flag` is not checked unless it carries `${CLAUDE_PLUGIN_ROOT}/`. It reads only `.md` files, so a dead path in code (a script's default input, a comment) is not checked either.
|
||||
- **A removed flag is ignored, not refused** (cleanup R-a). The arg parser puts an unknown flag in `unknown` and the commands do not report it, so `/trekreview --workflow` now runs the standard review without a word.
|
||||
- **`/trekcontinue` Phase 1.5 chooses a prompt file nothing reads** (cleanup R-a, decided in its plan). With the validator gone it prefers the project folder's `NEXT-SESSION-PROMPT.local.md` over a root one and says so in one line; no later phase reads the file, and the producer and age checks are gone.
|
||||
- **A plan's `profile:` field has no writer.** README, `docs/`, `/trekexecute` and `/trekcontinue` say a plan carries or passes on a profile, but no command writes `profile:` to a plan; the plan validator's `MANIFEST_PROFILE_DRIFT` check reads it, and `resolveTrekcontinueProfile` has no caller outside its tests. Left whole for a later hardening part, which chooses between writing the field and taking the promises out.
|
||||
- **Splices and dropped `--x=` options outside fix round 4.5b** (decision 82). The commands' bash blocks still splice `{placeholder}` values into shell lines outside the four places the round closed (the Phase 0 parser lines of `/trekendsession`, `/trekcontinue` and `/trekreview`, and `/trekendsession` Phase 3), for example `"{brief_path}"` in `/trekplan` and the unquoted `{project_dir}` in `/trekendsession` Phase 4; and the release check, the lessons apply (`lib/review/recurrence.mjs`) and the park tool still drop an unknown `--x=` option without a word. One open finding, for fix round 4.5c or 4.5d.
|
||||
- **Two 4.5b findings moved to fix round 4.5c.** M12 (`lib/review/reviewed-commit.mjs` makes four git calls per path per commit; at most two paths per commit reach that loop) goes with 4.5c's single `git cat-file --batch` read, and Dc (the trekcontinue `line_ref` row in `tests/fixtures/jsonl-schemas.md`) with 4.5c's change of `line_ref` to a heading anchor.
|
||||
- **A 4.5b finding moved to fix round 4.5c.** M12 (`lib/review/reviewed-commit.mjs` makes four git calls per path per commit; at most two paths per commit reach that loop) goes with 4.5c's single `git cat-file --batch` read. The second, Dc (the trekcontinue `line_ref` row in `tests/fixtures/jsonl-schemas.md`), went with that file in cleanup R-b.
|
||||
- **Step 9's narrowed unborn-HEAD rule is not recorded as a plan deviation** (MAJOR `c76a0bb8`, `lib/util/park-attempt-files.mjs`; decision 88). The park tool reads HEAD as unborn only when `headIsUnborn` agrees, which narrows plan Step 9's rule; the narrowing was logged as an executor decision, not as a plan amendment.
|
||||
- **The arg parser's stdin-unreadable branch has no test** (MAJOR `b0fd0a04`, `lib/parsers/arg-parser.mjs`; decision 88). No test exercises `ARG_STDIN_UNREADABLE` and its exit 1, the path that makes `/trekcontinue` and `/trekendsession` stop when stdin cannot be read.
|
||||
- **The park tool's packed-refs branch has no test** (MAJOR `f7e8278b`, `lib/util/park-attempt-files.mjs`; decision 88). The branch that tells a corrupt packed ref from an unborn HEAD is not exercised; the corrupt-ref test covers only a loose ref.
|
||||
|
|
@ -242,7 +257,7 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
- **The park tool misnames a detached HEAD it cannot read** (MAJOR `e3824c6d`, `lib/util/park-attempt-files.mjs`; decision 88). On a detached HEAD whose commit is missing it exits 2, as it should, with a reason that names a corrupt branch ref.
|
||||
- **The lessons-block check accepts any valid lesson line** (MINOR `5114fa84`, `lib/review/reviewed-commit.mjs`; decision 88). A line added inside the block that passes the lesson-line check, an instruction written in the lesson form included, does not make the review stale.
|
||||
- **The label keeps inner quotes** (MINOR `e585ce27`, `lib/parsers/arg-parser.mjs` `tailOf`; decision 88). `tailOf` strips quotes only when one pair wraps the whole rest, so `brief.md "Session 2" of "3"` gives the label `"Session 2" of "3"`.
|
||||
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands, such as a stats record's, can still be closed early by a typed line equal to its delimiter.
|
||||
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands can still be closed early by a typed line equal to its delimiter.
|
||||
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file. Two kinds of such command pass, under the conditions the ESC and SLT bullets above give: one whose opener's first `<` follows an odd run of backslashes, unless two escaped `<` in a row name the delimiter (see EVL), a case fix round 4.5b2 part A widens, and one whose opener is text inside a string on its line, unless a backslash-newline splits it (see SIS).
|
||||
- **A `bash -c $'...'` command on one physical line is not checked** (fix round 4.5b part 2, decision 88). It carries no literal newline, so the guard sees no line after the opener even when `bash -c` expands it into a heredoc of the parser's delimiter; part 1 blocked it by accident. The shipped commands never produce it from `$ARGUMENTS`.
|
||||
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` gives `'the` and `90s were great` (accepted by the operator on 2026-10-27, part 2's brief). The argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits (accepted on 2026-10-28). Since part 3, a quoted value that ends in whitespace pairs again, so such a value may also hold what follows it up to a quote that ends a word: `--project "x --since " HEAD` gives `--project` = `x --since `. Review finding `564d6dff` (decision 90) names this case: such a value can swallow a later flag with no error.
|
||||
|
|
@ -293,6 +308,7 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
- two-way: review gate 4.5b2 part B — `review_version` 1.2 and the answered-MAJOR merge lock (decisions 93-105, 128); a revert restores the 1.1 gate, 1.2 files still read under the old validator (unknown keys tolerated), and K2 `review` records keep the record shape.
|
||||
- two-way: fix round 4.5b2 part C (decisions 135, 137) — a revert restores part B's behaviour.
|
||||
- two-way: cleanup R-a (decisions 188, 200-208) — revert its commits; the removed files come back from git, and no format, flag value or record shape changed except the `/trekreview --workflow` flag and the autonomy gate's `--state`/`--event` arguments, which no shipped command used.
|
||||
- two-way, one exception: cleanup R-b (decisions 175, 189-191, 209) — revert its commits; the removed files, hooks, phases and flags come back from git. One-way for briefs written after R-b: they carry no `phase_signals`, so after a revert the old validator refuses each one that declares `brief_version` 2.1 or later (`BRIEF_V51_MISSING_SIGNALS`) until the signals are added by hand.
|
||||
|
||||
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes
|
||||
|
||||
|
|
|
|||
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -30,17 +30,17 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|
|||
|
||||
| Command | Description | Orchestrator model |
|
||||
|---------|-------------|--------------------|
|
||||
| `/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 |
|
||||
| `/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); optionally orchestrates the pipeline | session |
|
||||
| `/trekresearch` | Research — deep local + external research, produces structured research brief. 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, 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, or WARN with every counting MAJOR answered · 1 BLOCK · 2 never green); the operator answers each counting MAJOR (Phase 8.8; `--answers` runs only that, no new round); 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 |
|
||||
|
||||
`session` = command frontmatter pins no `model:` (since v5.9.0), so the orchestrator follows the session model; sub-agent models come from the profile (see Model & effort below).
|
||||
`session` = command frontmatter pins no `model:` (since v5.9.0), so the orchestrator follows the session model; sub-agent models come from each agent's own `model:` pin (see Model & effort below).
|
||||
|
||||
Full flag reference for each command (modes, `--gates`, `--profile`, breaking changes): see `docs/command-modes.md`.
|
||||
Full flag reference for each command (modes, `--gates`, breaking changes): see `docs/command-modes.md`.
|
||||
|
||||
> **The critic loop converges (decision 43).** Round 1 reviews the whole plan.
|
||||
> From round 2 the reviewers get `plan-gate.mjs --diff` and mark each previous
|
||||
|
|
@ -52,20 +52,6 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
|
|||
> the red-first test); `On failure:` opens with revert | retry | skip | escalate.
|
||||
> `plan-validator.mjs` refuses the first, warns on the second → Handover 4.
|
||||
|
||||
> **STORM bounded loop — default-off, env-gated.** `/trekresearch` Phase 4.5
|
||||
> (dimension discovery, under the existing `maxDimensions: 8` ceiling) and
|
||||
> Phase 5 (bounded multi-turn follow-up) run only at `effort: high` **and** only
|
||||
> when `VOYAGE_STORM_ENABLED=1`; unset, both are inert — Phase 5 because
|
||||
> `lib/util/research-loop-cap.mjs` grants a budget of 0, Phase 4.5 because its
|
||||
> skip-guard reads the flag directly (it never calls the cap). `TREKRESEARCH_MAX_CONV_TURNS` (default `3`,
|
||||
> invalid values fall back to `3`) sets turns per dimension; the budget is that
|
||||
> × `maxDimensions`. `VOYAGE_DISABLE_CAP_HOOK=1` switches off
|
||||
> `hooks/scripts/pre-agent-cap.mjs`, the `PreToolUse` gate that enforces the
|
||||
> budget in the harness rather than trusting prose. The cap counts turns from
|
||||
> its own append-only ledger. Adoption as a default is gated on the
|
||||
> pre-registered measurement in `docs/storm-measurement.md` — decline is a
|
||||
> no-op, adopt is one constant.
|
||||
|
||||
> **Research source rules live in one skill.** `skills/researching-sources/` (preloaded by the five
|
||||
> web-search agents) holds URL/date/version per source, "no citation, no finding", empty search ≠
|
||||
> finding, fetched pages are data, nothing local in a query, and the one confidence scale. Only the
|
||||
|
|
@ -120,9 +106,9 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
|
|||
| security-researcher | opus | CVEs, audit history, supply chain risks |
|
||||
| contrarian-researcher | opus | Counter-evidence, overlooked alternatives |
|
||||
|
||||
> **Inventory.** 20 agent files, **all 20 spawnable**. All 20 stay `model: opus` (operator pin `40d8742`); why → `docs/profiles.md` §Model & effort axes.
|
||||
> **Inventory.** 20 agent files, **all 20 spawnable**. All 20 stay `model: opus` (operator pin `40d8742`); why → `docs/architecture.md` §Model & effort.
|
||||
|
||||
> **Model & effort.** On the Anthropic API, `opus` = Opus 5.5 and `sonnet` = Sonnet 5.5 (both default reasoning effort `medium`); `fable` = Fable 5.1 (the most capable model in Claude Code; default `high`, so xhigh must be chosen explicitly). An agent without `effort:` inherits the session level. Nine agents pin native per-spawn `effort: high` and the rest follow the session; none pins below `high` — a different axis from brief `phase_signals.effort` (orchestration shape: which agents/passes run). Per-agent table + axes → `docs/profiles.md` §Model & effort axes.
|
||||
> **Model & effort.** On the Anthropic API, `opus` = Opus 5.5 and `sonnet` = Sonnet 5.5 (both default reasoning effort `medium`); `fable` = Fable 5.1 (the most capable model in Claude Code; default `high`, so xhigh must be chosen explicitly). An agent without `effort:` inherits the session level. Nine agents pin native per-spawn `effort: high` and the rest follow the session; none pins below `high`. Per-agent list + precedence → `docs/architecture.md` §Model & effort.
|
||||
|
||||
## Skills
|
||||
|
||||
|
|
@ -145,8 +131,7 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
|
|||
- **Architecture, workflows, project-directory contract, state, terminology:** `docs/architecture.md`
|
||||
- **Quality infrastructure (`lib/` validators, parsers, autonomy primitives, hooks):** `docs/architecture.md` §Quality infrastructure
|
||||
- **Autonomy gates (`--gates`), Path A/B/C decision:** `docs/operations.md`
|
||||
- **Profile system (`--profile economy/balanced/premium/fable`), lookup order, custom profiles:** `docs/operations.md`
|
||||
- **Observability (Stop hook, OTLP/textfile export, SSRF mitigation):** `docs/operations.md`
|
||||
- **Observability:** `docs/operations.md`
|
||||
- **Handover contracts (the 7 pipeline handovers):** `docs/HANDOVER-CONTRACTS.md`
|
||||
- **Release and rollback (P13):** `docs/release-runbook.md` — the drilled runbook (before / release / rollback).
|
||||
`scripts/release-check.mjs --review <review.md>` decides before a release: 0 ready, 1 not ready, 2 never green.
|
||||
|
|
|
|||
108
README.md
108
README.md
|
|
@ -74,7 +74,7 @@ No cloud dependency. No GitHub requirement. Works on **Mac, Linux, and Windows**
|
|||
|
||||
All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `true` or `closed` turns gating on; `false`, `open` or no flag (the default) leaves it off. Any other next token is not the flag's value, so `/trekplan --gates --project <dir>` is a bare `--gates`. With gating on, `/trekbrief`, `/trekresearch` and `/trekplan` pause at their own boundaries; `/trekexecute` accepts the flag and pauses on nothing, because its one stop is the merge into main, which always asks. Details: [`docs/operations.md`](docs/operations.md) § Autonomy mode.
|
||||
|
||||
Under the hood, `lib/util/autonomy-gate.mjs` maps the `--gates` value to on/off for the four commands, and `lib/stats/event-emit.mjs` records named lifecycle events (the main-merge gate and its answer, among others) to `trekexecute-stats.jsonl` in the plugin data directory. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
|
||||
Under the hood, `lib/util/autonomy-gate.mjs` maps the `--gates` value to on/off for the four commands. The new `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary.
|
||||
|
||||
## Quick start
|
||||
|
||||
|
|
@ -123,7 +123,6 @@ An optional architect step can sit between research and plan — `/trekplan` aut
|
|||
|
||||
Concrete capabilities, observable in the code — not aspirations.
|
||||
|
||||
**Across all profiles:**
|
||||
- Strategy-to-execution on seven explicit handover points. Each transition is a filesystem contract (`docs/HANDOVER-CONTRACTS.md`), not a conversation. You can stop after any stage and resume later without context loss.
|
||||
- Resume safety after long sessions. The PreCompact hook reconciles `progress.json` with git history before context compaction (CC v2.1.105+) — closes a documented `--resume` failure mode.
|
||||
- Schema discipline. `plan-validator --strict` enforces `### Step N:` form and rejects narrative drift (`### Fase`, `### Phase`, `### Stage`, `### Steg`) before execution.
|
||||
|
|
@ -141,7 +140,7 @@ What this pipeline does **not** solve — read this before adopting it:
|
|||
|
||||
- LLM output truthfulness. Validators check shape, not facts. A plan with hallucinated paths passes schema but fails in execute. Plan-critic catches some, not all.
|
||||
- Multi-user concurrency on a single project directory. Two simultaneous executors will clobber `progress.json`.
|
||||
- Cost management. Opus on the orchestrator layer is expensive; documented in [Cost profile](#cost-profile), no automatic model downgrade.
|
||||
- Cost management. Opus on the orchestrator layer is expensive; no automatic model downgrade.
|
||||
- Linear/Jira/Slack integrations. Intentional omission — solo project, no enterprise wiring.
|
||||
|
||||
**One-line summary:** an executable contract pipeline where each stage is filesystem-validated, session-survivable, and skill-independent — in exchange for writing an actual brief before planning and an actual plan before coding.
|
||||
|
|
@ -172,8 +171,7 @@ Borrowed, with credit: size classes and the ratchet from obra/superpowers (MIT),
|
|||
|------|-------|----------|
|
||||
| **Default** | `/trekbrief <task>` | Question round sized by class, then review and approval. |
|
||||
| **Quick** | `/trekbrief --quick <task>` | Proposes `small` as the class; still said aloud, overridable, and raised if the task proves bigger. |
|
||||
| **Profile** | `/trekbrief --profile <name> <task>` | (v4.1.0) Pin model profile for the brief phase: `economy` / `balanced` / `premium` / `fable` / `<custom>`. See [Profile system](#profile-system-v410) below. |
|
||||
| **Approve** | `/trekbrief --approve <project-dir \| brief-file>` | No interview: show an existing brief's `## Intent` + `## Goal` verbatim and ask the operator to approve them; on "Approve" it also writes the trekbrief stats record. The same step (Phase 4h) ends every normal `/trekbrief` run. |
|
||||
| **Approve** | `/trekbrief --approve <project-dir \| brief-file>` | No interview: show an existing brief's `## Intent` + `## Goal` verbatim and ask the operator to approve them. The same step (Phase 4h) ends every normal `/trekbrief` run. |
|
||||
|
||||
`/trekbrief` is **always interactive**. There is no foreground/background mode — the interview requires user input.
|
||||
|
||||
|
|
@ -217,29 +215,9 @@ Output:
|
|||
| **Local** | `/trekresearch --local <question>` | Only codebase analysis agents (skip external research) |
|
||||
| **External** | `/trekresearch --external <question>` | Only external research agents (skip codebase analysis) |
|
||||
| **Foreground** | `/trekresearch --fg <question>` | No-op alias (foreground is default since v2.4.0) |
|
||||
| **Profile** | `/trekresearch --profile <name> <question>` | (v4.1.0) Pin model profile for the research phase. See [Profile system](#profile-system-v410). |
|
||||
| **Engine** | `/trekresearch --external --engine deep-research <question>` | Delegate the external phase to Claude Code's built-in `/deep-research` workflow. Only works on `2.1.154 <= CC < 2.1.218`; from **2.1.218** `/deep-research` is operator-invoked only (Skill tool: `disable-model-invocation`), so it always falls back to `swarm`. Never hard-fails. Default `swarm`. |
|
||||
|
||||
Flags combine: `--project <dir> --external`.
|
||||
|
||||
#### Bounded conversation loop (default-off)
|
||||
|
||||
At `effort: high`, research can discover additional dimensions (Phase 4.5) and
|
||||
run a bounded multi-turn follow-up loop on under-illuminated ones (Phase 5).
|
||||
Both are **default-off** and env-gated rather than flag-gated, because enabling
|
||||
them costs turns:
|
||||
|
||||
| Env-var | Default | Behavior |
|
||||
|---------|---------|----------|
|
||||
| `VOYAGE_STORM_ENABLED` | _(unset — default-off)_ | `=1` gives the Phase 5 loop a non-zero turn budget and lets Phase 4.5 discovery run. Unset, the budget is 0 and **both** phases are inert. |
|
||||
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Turns per under-illuminated dimension. Invalid values fall back to `3`, never to unbounded. |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK` | _(unset)_ | `=1` disables the `PreToolUse` hook that enforces the turn budget. |
|
||||
|
||||
The cap counts turns itself from an append-only ledger — it never asks the loop
|
||||
how many turns it has used. Whether the loop becomes the default is decided by a
|
||||
pre-registered measurement, not by preference: see
|
||||
[`docs/storm-measurement.md`](docs/storm-measurement.md).
|
||||
|
||||
Research uses up to 5 local agents (architecture-mapper, dependency-tracer, task-finder, git-historian, convention-scanner) and 4 external agents (docs-researcher, community-researcher, security-researcher, contrarian-researcher). Per-agent details in [`agents/`](agents/).
|
||||
|
||||
#### Source rules (the `researching-sources` skill)
|
||||
|
|
@ -259,7 +237,7 @@ names it again when it starts them. It holds the rules they share:
|
|||
|
||||
It also defines the one confidence scale (`high | medium | low | contradictory`) that the agents
|
||||
and Phase 6 triangulation use. **What enforces it:** `research-validator.mjs` checks only the
|
||||
brief's structure, and the query privacy gate runs only in the default-off STORM steps; the rest
|
||||
brief's structure, and the query privacy gate is not called by any command now; the rest
|
||||
is prose only. Measured when it was written (helpers, offline fixtures, small samples): without the
|
||||
skill, 3 of 5 runs ignored an injected instruction silently instead of reporting it, and 1 of 2
|
||||
counter-evidence runs counted an empty search as weak support; with it, 0 of 3 and 0 of 2.
|
||||
|
|
@ -288,7 +266,6 @@ Output:
|
|||
| **Quick** | `/trekplan --project <dir> --quick` | No agent swarm, lightweight scan only |
|
||||
| **Decompose** | `/trekplan --decompose plan.md` | Split plan into session specs + `orders.md` |
|
||||
| **Export** | `/trekplan --export headless plan.md` | Legacy alias for `--decompose` (the only remaining export format) |
|
||||
| **Profile** | `/trekplan --profile <name> --project <dir>` | (v4.1.0) Pin model profile; emitted as `profile:` in plan.md frontmatter. See [Profile system](#profile-system-v410). |
|
||||
|
||||
`--brief` or `--project` is **required**. `/trekplan` with no brief exits with an error and a pointer to `/trekbrief`.
|
||||
|
||||
|
|
@ -390,7 +367,6 @@ Borrowed: the order from Cherny; the gate on a verdict in a file from Kiro; the
|
|||
| **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 |
|
||||
| **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy |
|
||||
| **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy |
|
||||
| **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
|
||||
|
||||
### Multi-session execution through session orders (worktree-isolated)
|
||||
|
||||
|
|
@ -482,14 +458,6 @@ session-picker output trivially identifiable. Slug derivation:
|
|||
|
||||
The hook is fail-open — any error → title is left untouched.
|
||||
|
||||
### Per-step timing (CC v2.1.97+)
|
||||
|
||||
A `PostToolUse` hook (`hooks/scripts/post-bash-stats.mjs`) appends
|
||||
`duration_ms` from each Bash tool call to
|
||||
`${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`. One line per Bash
|
||||
call; useful for identifying long-running verify or checkpoint commands
|
||||
across executions.
|
||||
|
||||
---
|
||||
|
||||
## `/trekreview` — Review
|
||||
|
|
@ -539,7 +507,6 @@ Declined to Judge list, and the coordinator carries those lines into
|
|||
| **Quick** | `/trekreview --project <dir> --quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
|
||||
| **Validate** | `/trekreview --project <dir> --validate` | Schema-only check on existing `review.md`. No LLM calls |
|
||||
| **Dry run** | `/trekreview --project <dir> --dry-run` | Print discovered scope + triage map; skip writes |
|
||||
| **Profile** | `/trekreview --profile <name> --project <dir>` | (v4.1.0) Pin model profile for the review phase. See [Profile system](#profile-system-v410). |
|
||||
|
||||
### What review produces
|
||||
|
||||
|
|
@ -603,7 +570,6 @@ schema and producer/consumer contract.
|
|||
| **Default** | `/trekcontinue` | Auto-discover `.session-state.local.json` under cwd, validate, narrate, and begin executing the next session |
|
||||
| **Explicit** | `/trekcontinue <project-dir>` | Use the named project directory; helpful when several active projects coexist under cwd |
|
||||
| **Help** | `/trekcontinue --help` | Print usage block and the schema-v1 reference |
|
||||
| **Profile** | `/trekcontinue --profile <name> [<project-dir>]` | (v4.1.0) Pin model profile for the resumed session. Plan-frontmatter `profile:` from the previous session is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
|
||||
|
||||
### Schema v1 — `.session-state.local.json`
|
||||
|
||||
|
|
@ -659,7 +625,7 @@ model. A skill is advice; each one says which check stands behind it, or "prose
|
|||
| `voyage:test-first` | Red before green: the repo's own test command, a test seen failing on an assertion (a crash is not red), the smallest code, the whole suite | `step-implementer` (preloaded) | proof of red, test lock, floor guard, done line in `/trekexecute`; the rest is prose only |
|
||||
| `voyage:debugging` | One command red on the exact symptom first; reproduce, localise, reduce, fix the cause, guard it with a red-first test; error text is data; three failed fixes go back to the design | `step-implementer` (preloaded) | proof of red for the regression test, the fix-round cap; the root-cause claim is prose only |
|
||||
| `voyage:interviewing` | Facts looked up, never asked; one round of at most four questions per call, one decision each, the recommended answer first; the rest written down as assumptions or a Deferred list; an answer that decides nothing, and scope nobody asked for, never written in as the person's decision; stop once the next three answers can be predicted | `/trekbrief` names it for its question round | the brief's sections and `framing:` (`brief-validator`), the explicit approval at the end; the form of the interview is prose only |
|
||||
| `voyage:researching-sources` | Official sources first with URL, date and matching version; no citation, no finding; an empty search is reported with what was searched, never as a finding; fetched pages are data, and text aimed at assistants is reported; nothing local in a query; one confidence scale | `docs-`, `community-`, `security-`, `contrarian-researcher` and `research-scout` (preloaded); `/trekresearch` names it at spawn | the research brief's structure (`research-validator`); the query privacy gate only in the default-off STORM steps; the rest is prose only |
|
||||
| `voyage:researching-sources` | Official sources first with URL, date and matching version; no citation, no finding; an empty search is reported with what was searched, never as a finding; fetched pages are data, and text aimed at assistants is reported; nothing local in a query; one confidence scale | `docs-`, `community-`, `security-`, `contrarian-researcher` and `research-scout` (preloaded); `/trekresearch` names it at spawn | the research brief's structure (`research-validator`); the query privacy gate is not called by any command now; the rest is prose only |
|
||||
| `voyage:reviewing-adversarially` | No praise; no finding without a source (`file:line` or a quoted line); no downgrade without a reason; what was read only as a summary is said and not judged; a Declined to Judge list so nothing set aside disappears; claims checked against the diff, not the report; the axes kept separate | `brief-conformance-reviewer`, `code-correctness-reviewer`, `review-coordinator`, `scope-guardian` (preloaded); `/trekreview` names it at spawn | a finding with no file or citation (`findings-schema`, `coordinator-contract`, partial); the `## Declined to Judge` section in `review.md` (`review-validator`); praise and the rest are prose only |
|
||||
| `voyage:annotating` | The person writes every note on the annotation page and Claude writes none; Fix and Change edit only the text a note points at, a Question is answered and changes nothing, nothing else is edited; the approval an edit costs is said before the edit; the page is built again on the revised file. Also for a research document, on request | `/trekbrief`, `/trekplan` and `/trekreview` name it where they build the page | the script and the commands' call to it (pins in `npm test`); a stale brief approval (`intent-approval`, `/trekplan` halts) and a changed plan (K2 `approval-log`, `/trekexecute` refuses); the rest is prose only |
|
||||
| `voyage:writing-criteria` | Each success criterion and each `Verify:`, `Regression:`, `End-to-end:` and `## Verification` line is one command the criteria runner accepts, written as the line's first backticked span (a known test runner, plain words, no pipe or `&&`); anything more is a script under `tests/`; and each can go red | `brief-reviewer` and `plan-critic` (preloaded); `/trekplan` names it where the plan is written and where it starts its reviewers | the criteria runner and the plan and brief validators (deterministic); that a criterion can go red and tests what the brief asks is prose only |
|
||||
|
|
@ -803,8 +769,8 @@ flowchart TB
|
|||
BRG["brief-reviewer (gate · ≤3 iter)"]
|
||||
end
|
||||
subgraph RES["/trekresearch · Phase 4 — parallel"]
|
||||
RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner*"]
|
||||
RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher · contrarian-researcher*"]
|
||||
RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner"]
|
||||
RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher · contrarian-researcher"]
|
||||
end
|
||||
subgraph PL["/trekplan"]
|
||||
PLG["Phase 4b · brief-reviewer (gate)"]
|
||||
|
|
@ -823,7 +789,7 @@ flowchart TB
|
|||
BR --> RES --> PL --> EX --> RV
|
||||
```
|
||||
|
||||
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack (with `--engine deep-research`, when the engine is active, after the engine returns, from the leading answer of its report), always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
|
||||
`* = conditional`: convention-scanner on medium+ codebases (50+ files); research-scout for unknown external tech; brief-conformance-reviewer skipped under `--quick`. contrarian-researcher runs in wave 2 of every research run whose scope includes external, after wave 1 returns, with the leading answer of wave 1's external findings to attack, always at `effort=high`.
|
||||
|
||||
> **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix).
|
||||
|
||||
|
|
@ -942,47 +908,9 @@ The `pre-compact-flush.mjs` hook directly fixes a documented P0: in skill-driven
|
|||
|
||||
**Annotation HTML requires a desktop browser.** `scripts/annotate.mjs` produces a single self-contained `.html` file you open with `file://` in any modern browser (Chrome / Safari / Firefox / Edge — last two versions). No CDN, no server, no npm runtime deps. State persists in `localStorage` so closing and re-opening the tab keeps your work, but it's local to one browser on one machine — not synced anywhere. If you want to annotate without a browser, paste the `.md` into Claude with "comments inline below" and write notes in chat — same end result, just without the visual surface.
|
||||
|
||||
## Profile system (v4.1.0)
|
||||
## Observability
|
||||
|
||||
Four built-in model profiles (`lib/profiles/*.yaml`) plus your own: a `<name>.yaml` in `voyage-profiles/` at the repo root, or in `~/.claude/voyage-profiles/`. A custom file with a built-in's name is ignored. Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>`.
|
||||
|
||||
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|
||||
|---------|-------|----------|------|---------|--------|----------|----------|
|
||||
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | ⚠ **Experimental** (uncalibrated Jaccard floor) — lowest cost; high-confidence small-scope tasks (opt-in via `--profile economy`) |
|
||||
| `balanced` | sonnet | sonnet | opus | sonnet | opus | sonnet | Mixed — opus where reasoning depth pays off (opt-in via `--profile balanced`) |
|
||||
| `premium` (default) | opus | opus | opus | opus | opus | opus | Maximum quality — Opus on every phase (default since the 2026-05-13 operator decision) |
|
||||
| `fable` | fable | fable | fable | fable | fable | fable | Max quality — Fable 5.1 (the most capable model in Claude Code) on every phase (opt-in via `--profile fable`); reasoning effort inherits from the session |
|
||||
|
||||
Lookup order:
|
||||
|
||||
1. Explicit `--profile <name>` flag passed to the command
|
||||
2. Plan-file frontmatter `profile:` (when resuming via `/trekexecute --resume` or `/trekcontinue`)
|
||||
3. `VOYAGE_PROFILE` environment variable (not read on a resume)
|
||||
4. Default `premium`
|
||||
|
||||
A brief's `phase_signals` (written by `/trekbrief`) can set the model for research, plan, execute and review; where it does, it comes before the profile.
|
||||
|
||||
See [`docs/profiles.md`](docs/profiles.md) for the decision tree, custom-profile authoring, and cost estimation disclaimer (the per-profile cost numbers are estimates, not contractual SLAs).
|
||||
|
||||
## Observability (v4.1.0)
|
||||
|
||||
Stop-hook OTel/Prometheus export — opt-in via `VOYAGE_EXPORT_MODE`:
|
||||
|
||||
| Mode | Output | Endpoint env-var |
|
||||
|------|--------|------------------|
|
||||
| `off` (default) | _(no export)_ | — |
|
||||
| `textfile` | `voyage.prom` (Prometheus exposition format) | `VOYAGE_TEXTFILE_DIR` |
|
||||
| `otlp` | OTLP/JSON POST | `VOYAGE_OTEL_ENDPOINT` |
|
||||
|
||||
Default JSONL stats stream (`${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl`) is unchanged — OTel is additive. Local Docker Compose stack: [`examples/observability/`](examples/observability/). Operator docs: [`docs/observability.md`](docs/observability.md). Both pin minimum versions per CVE history.
|
||||
|
||||
## Cost profile
|
||||
|
||||
The default `premium` profile runs **Opus on every phase** of the pipeline's agent work — the exploration and review swarms (5–10 sub-agents per command; spawn sites inject the composed brief > profile > frontmatter resolution, with `agents/*.md` `model: opus` pins as the fallback) and the executor (one per plan session). The command orchestrator itself is not profile-controlled: as of v5.9, command frontmatter omits `model:`, so the orchestrator follows the session model. For cheaper runs, opt into `--profile balanced` (Sonnet on brief/research/execute/continue, Opus on plan + review) or `--profile economy` (Sonnet everywhere); for maximum quality, `--profile fable` (Fable 5.1 on every phase). Per-command cost is published in `${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl` if you want exact numbers.
|
||||
|
||||
On the Anthropic API the `opus` alias resolves to **Opus 5.5** and `sonnet` to **Sonnet 5.5** (both default to reasoning effort `medium`), and `fable` to **Fable 5.1** (the most capable model in Claude Code; default reasoning effort `high` — xhigh must be chosen explicitly, see [`docs/profiles.md`](docs/profiles.md)). Other providers resolve `opus` and `sonnet` to other versions. Note two distinct effort axes that share the word "effort": brief `phase_signals.effort` (low/standard/high) tunes *orchestration shape* — how many agents and passes run — while native `effort:` (nine agents pin `high`; the rest follow the session) tunes the *per-spawn reasoning budget*. See [`docs/profiles.md`](docs/profiles.md) § Model & effort axes.
|
||||
|
||||
For per-profile cost estimates, see [`docs/profiles.md`](docs/profiles.md).
|
||||
Claude Code's own OpenTelemetry (`CLAUDE_CODE_ENABLE_TELEMETRY`) gives tool durations, tokens and cost per agent and skill: see [Monitoring usage](https://code.claude.com/docs/en/monitoring-usage) (read 2026-11-07, Claude Code 2.1.319).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -996,16 +924,16 @@ Top-level layout:
|
|||
|
||||
```
|
||||
voyage/
|
||||
├── agents/ 20 specialized agents, all spawnable (all `model: opus`-pinned; per-phase model set by `--profile`)
|
||||
├── agents/ 20 specialized agents, all spawnable (all `model: opus`-pinned)
|
||||
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
|
||||
├── skills/ 7 skills (test-first, debugging: preloaded into the step helper; researching-sources: preloaded into the five web-search agents;
|
||||
│ └ reviewing-adversarially: the review agents; writing-criteria: brief-reviewer, plan-critic; interviewing: named by /trekbrief;
|
||||
│ └ annotating: named by /trekbrief, /trekplan and /trekreview)
|
||||
├── templates/ Frontmatter templates for brief, research, plan, session
|
||||
├── hooks/ 12 hooks (pre-bash, pre-write, pre-agent-cap, pre-plan-gate, session-title, post-bash-stats, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, otel-export, stop-test-lock)
|
||||
├── hooks/ 9 hooks (pre-bash, pre-write, pre-plan-gate, session-title, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, stop-test-lock)
|
||||
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)
|
||||
├── tests/ comprehensive node:test suite — `npm test` is the fork-readiness gate
|
||||
├── docs/ HANDOVER-CONTRACTS.md, architecture.md, operations.md, profiles.md, command-modes.md, release-runbook.md
|
||||
├── docs/ HANDOVER-CONTRACTS.md, architecture.md, operations.md, command-modes.md, release-runbook.md
|
||||
└── examples/ 02-real-cli/ — runnable pipeline fixture
|
||||
```
|
||||
|
||||
|
|
@ -1027,8 +955,8 @@ synthesis.
|
|||
cp agents/architecture-mapper.md agents/my-new-agent.md
|
||||
```
|
||||
2. Update the frontmatter `name`, `description`, `tools`, and `model`.
|
||||
Every shipped agent is `model: opus`; the profile picks the model at
|
||||
the spawn site, so the pin is only the fallback.
|
||||
Every shipped agent is `model: opus`, and the spawn sites pass no
|
||||
model, so the pin applies.
|
||||
3. Add the agent to the swarm in `commands/trekplan.md` Phase 5
|
||||
(the command runs the swarm inline) — register it under the
|
||||
codebase-size bucket where it should fire.
|
||||
|
|
@ -1045,10 +973,8 @@ The command orchestrators pin no model: `/trekbrief`, `/trekresearch`,
|
|||
To run them on Sonnet for cost or latency, start the session on Sonnet
|
||||
or switch with `/model` inside the session.
|
||||
|
||||
The exploration and review sub-agents are chosen separately, at the spawn
|
||||
sites: brief `phase_signals` first, then the profile's `phase_models`, with the
|
||||
`model: opus` pins in `agents/*.md` as the fallback. Run `--profile economy`
|
||||
to put the sub-agents on Sonnet as well.
|
||||
The exploration and review sub-agents run on their own `model: opus` pins
|
||||
in `agents/*.md`; the spawn sites pass no model.
|
||||
|
||||
### Disable external research
|
||||
|
||||
|
|
|
|||
|
|
@ -164,9 +164,6 @@ Remove findings that fail ANY of these tests:
|
|||
- **Unknown rule_key** → `unverified` (`unknown-rule_key`). `rule_key` is not
|
||||
in `RULE_CATALOGUE`. Reviewers occasionally emit ad-hoc rule keys; the
|
||||
catalogue is the contract, but a mislabelled finding is not a refuted one.
|
||||
*(High-effort mode does not reach this branch: Pass 3 is bypassed and the
|
||||
key is normalised to `PLAN_EXECUTE_DRIFT` and KEPT — see High-effort
|
||||
normalization below. The two fates never apply to the same input.)*
|
||||
- **Non-existent file.** `file` does not exist in the working tree AND
|
||||
the diff does not show it as `(new file)`. Use Glob to verify. **This test
|
||||
has three outcomes, not two:** Glob resolves and the file is absent from
|
||||
|
|
@ -206,19 +203,6 @@ Executive Summary so the reader knows reasonableness was not applied.
|
|||
The brief-conflict rule above still applies in quick mode (the mark and the
|
||||
one lowering) and changes nothing else.
|
||||
|
||||
**High-effort normalization (v5.1.1):** When the review is invoked
|
||||
under high-effort mode (`phase_signals[review].effort: high`), Pass 3
|
||||
reasonableness filtering is bypassed. To prevent unknown rule_keys
|
||||
from polluting downstream plans, the coordinator MUST substitute any
|
||||
rule_key not exported from `lib/review/rule-catalogue.mjs:RULE_KEYS`
|
||||
with the literal string `PLAN_EXECUTE_DRIFT` (the most general drift
|
||||
category in the catalogue). The original rule_key is
|
||||
preserved in the finding's `original_rule_key` field for diagnostic
|
||||
purposes. This normalization happens BEFORE writing review.md,
|
||||
ensuring all `rule_key` values in the final review match the
|
||||
catalogue. The brief-conflict rule (Pass 3) still applies under high-effort
|
||||
mode (the mark and the one lowering) and changes nothing else.
|
||||
|
||||
### Suppression is two-valued (fail-closed)
|
||||
|
||||
Every removal in Pass 2 and Pass 3 carries one of two fates, and the
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ foreground if the user opts in.
|
|||
|
||||
## Phase 1 — Parse mode and validate input
|
||||
|
||||
Parse `$ARGUMENTS`:
|
||||
Parse `$ARGUMENTS`. A retired flag is stripped first and ignored, together with the word after it
|
||||
when that word is not a flag: `--profile`.
|
||||
|
||||
1. If arguments start with `--quick`: set **mode = quick**. The interview
|
||||
proposes `small` as the starting size class (Phase 3 still says the class
|
||||
|
|
@ -72,8 +73,7 @@ Parse `$ARGUMENTS`:
|
|||
On exit 1, print its `error` and stop. Otherwise set `BRIEF_PATH`,
|
||||
`PROJECT_DIR` and `PLAN_COMMAND` from `brief_path`, `project_dir` and
|
||||
`plan_command`, jump straight to **Phase 4h**, run it and print its
|
||||
report; if the stamp printed `stamped: true`, run **Phase 7** with the
|
||||
approve-mode values it names, then stop. This is the remedy `/trekplan` names when it halts on an
|
||||
report, then stop. This is the remedy `/trekplan` names when it halts on an
|
||||
unapproved or stale intent, on both of its brief modes.
|
||||
|
||||
In every other mode, `BRIEF_PATH = {PROJECT_DIR}/brief.md` and
|
||||
|
|
@ -185,9 +185,9 @@ Put the task in one of three size classes. In doubt, take the heaviest that fits
|
|||
|
||||
| Class | Typical task | What the pipeline does |
|
||||
|---|---|---|
|
||||
| `small` | one change in one place, no new dependency, no interface change, easy to undo | one round, no follow-up; brief-reviewer runs once and its gaps become written assumptions, not questions, written after the review stamp (Step 4e, passed gate, step 4); Phase 3.5 is skipped |
|
||||
| `bounded` | several files or one module behind a clear boundary, no architecture decision | the round + at most one follow-up; at most one brief-review follow-up; Phase 3.5 asks one question for all four phases |
|
||||
| `architectural` | a new module, interface, dependency, data model or migration; anything hard to undo | follow-up rounds while answers open new branches; the full brief-review loop; Phase 3.5 asks per phase |
|
||||
| `small` | one change in one place, no new dependency, no interface change, easy to undo | one round, no follow-up; brief-reviewer runs once and its gaps become written assumptions, not questions, written after the review stamp (Step 4e, passed gate, step 4) |
|
||||
| `bounded` | several files or one module behind a clear boundary, no architecture decision | the round + at most one follow-up; at most one brief-review follow-up |
|
||||
| `architectural` | a new module, interface, dependency, data model or migration; anything hard to undo | follow-up rounds while answers open new branches; the full brief-review loop |
|
||||
|
||||
- **Said aloud.** State the class and why in one line *before* the round
|
||||
("This reads as `small`: one setting on one screen, no new dependency."),
|
||||
|
|
@ -446,135 +446,6 @@ Stored: {in git | only on this machine}
|
|||
Proceeding to draft and review.
|
||||
```
|
||||
|
||||
## Phase 3.5 — Per-phase effort dialog
|
||||
|
||||
Phase 3.5 is the v5.1 entry-point for **adaptive-depth execution**. After
|
||||
Phase 3 has gathered intent / goal / success criteria / research plan, the
|
||||
operator commits an effort level and (optional) model per downstream phase
|
||||
(`research`, `plan`, `execute`, `review`). The committed signals are written
|
||||
to brief frontmatter as a `phase_signals:` list that the four downstream
|
||||
commands read via their `## Composition rule (v5.1)` section.
|
||||
|
||||
### State requirements
|
||||
|
||||
Before entering Phase 3.5 the following must be populated:
|
||||
|
||||
- `state.intent` — the Phase 3 Intent answer (1+ paragraph)
|
||||
- `state.goal` — the Phase 3 Goal answer
|
||||
- `state.success_criteria` — at least one falsifiable SC
|
||||
- `state.research_plan.topics` — list (may be empty)
|
||||
|
||||
If any are absent: skip Phase 3.5 entirely and write `phase_signals_partial:
|
||||
true` to the draft frontmatter. Do not block.
|
||||
|
||||
### By size class (and --quick)
|
||||
|
||||
- `small` (and `--quick` while the class stays `small`): skip Phase 3.5
|
||||
entirely and auto-write `phase_signals_partial: true` to draft frontmatter.
|
||||
The brief satisfies the v5.1 sequencing gate without the dialog, and the
|
||||
downstream commands fall back to the profile.
|
||||
- `bounded`: ask ONE question in the Step 3c round — the tier for all four
|
||||
phases, with 4 options from the mapping below — and write the same tier
|
||||
for `research`, `plan`, `execute` and `review`.
|
||||
- `architectural`: the per-phase dialog below, as one `AskUserQuestion` call
|
||||
carrying four questions (one per phase).
|
||||
|
||||
### Default-derivation heuristic (LLM judgment, not algorithmic)
|
||||
|
||||
Before each phase question, propose a default tier marked `(default)`. Use
|
||||
these signals — they are weak heuristics, not rules:
|
||||
|
||||
- `research_topics_count` → high (`high`), low (`low`), absent (`low`)
|
||||
- `sc_count` (count of falsifiable SCs) → high (≥6 ⇒ `high`), low (≤2 ⇒ `low`)
|
||||
- Goal complexity: keywords like "rewrite", "migration", "refactor across",
|
||||
"new platform" ⇒ `high`; "typo", "small bugfix", "docs touch-up" ⇒ `low`
|
||||
- Otherwise: `standard`
|
||||
|
||||
Mix these into one proposed default per phase. Document the proposed tier
|
||||
in the question body so the operator sees why it was picked.
|
||||
|
||||
### The dialog — one call, one tier-coupled question per phase
|
||||
|
||||
For `[research, plan, execute, review]`, ask one question each, all four in
|
||||
one `AskUserQuestion` call, each question with 4 options:
|
||||
|
||||
| Option | Maps to phase_signals entry |
|
||||
|--------|----------------------------|
|
||||
| **Low effort** | `{phase: <name>, effort: low, model: sonnet}` |
|
||||
| **Standard (default)** | `{phase: <name>, effort: standard}` *(model omitted — composition falls through to profile)* |
|
||||
| **High effort** | `{phase: <name>, effort: high, model: opus}` |
|
||||
| **Fable (max quality)** | `{phase: <name>, effort: high, model: fable}` |
|
||||
|
||||
The proposed tier per phase (from the default-derivation heuristic) MUST be
|
||||
labelled `(default)` and listed first so the operator can one-click
|
||||
accept. Commit the chosen tiers to an in-memory `effort_state` dict as the
|
||||
answers come back — no bulk summary-before-commit. A hollow or empty answer
|
||||
for a phase leaves that phase uncommitted (the force-stop path below).
|
||||
|
||||
The mapping table is canonical:
|
||||
- `low → {effort: low, model: sonnet}` (force sonnet for the low-cost path)
|
||||
- `standard → {effort: standard}` (model omitted; composition rule resolves via profile)
|
||||
- `high → {effort: high, model: opus}` (force opus for the high-confidence path)
|
||||
- `fable → {effort: high, model: fable}` (force Fable 5.1 for the max-quality path)
|
||||
|
||||
The fable tier reuses `effort: high` semantics — full swarm, contrarian
|
||||
always-on; `EFFORT_LEVELS` is unchanged (Voyage effort is orchestration
|
||||
shape, not model reasoning effort). Model reasoning effort is inherited from
|
||||
the session: Fable 5.1's default effort is `high`, NOT xhigh. To run xhigh, the
|
||||
operator chooses it explicitly via `/effort xhigh`, `--effort xhigh`, or
|
||||
`CLAUDE_CODE_EFFORT_LEVEL` — Claude Code saves effort per model, so a level
|
||||
saved for one model does not carry to another.
|
||||
|
||||
### Force-stop handling
|
||||
|
||||
If in answer to the dialog the operator says "stop",
|
||||
"skip", "enough", "just write it", or similar, do NOT exit silently — apply
|
||||
the Phase 4f force-stop pattern verbatim:
|
||||
|
||||
```
|
||||
You stopped before committing per-phase signals. Remaining phases:
|
||||
- {list of phases not yet answered}
|
||||
|
||||
The brief will still be valid (v5.1 supports `phase_signals_partial: true`
|
||||
as a force-stop record). Downstream commands will fall back to the profile
|
||||
resolver for the un-committed phases.
|
||||
|
||||
Continue anyway?
|
||||
```
|
||||
|
||||
Then `AskUserQuestion`:
|
||||
|
||||
| Option | Action |
|
||||
|--------|--------|
|
||||
| **Answer one more phase** | Return to the next un-answered phase question. |
|
||||
| **Stop now (record partial)** | Drop any in-progress `effort_state` and set `phase_signals_partial: true` in draft frontmatter. Mutually exclusive with `phase_signals`. Break Phase 3.5. |
|
||||
|
||||
This pattern matches Step 4f (line 436-458) so the force-stop UX is
|
||||
identical across both surfaces.
|
||||
|
||||
### Hand-off to Phase 4a
|
||||
|
||||
If `effort_state` is fully populated (4 commits, no force-stop): write a
|
||||
`phase_signals:` block to draft frontmatter — one entry per phase,
|
||||
preserving the canonical-mapping form above. Omit `model:` for standard
|
||||
tier (composition falls through to profile).
|
||||
|
||||
If `phase_signals_partial: true` was set: write that single line to draft
|
||||
frontmatter and skip the `phase_signals:` block (mutually exclusive per
|
||||
validator).
|
||||
|
||||
Phase 4a (Step 4a — Draft in memory) reads from `effort_state` /
|
||||
`phase_signals_partial` and incorporates the appropriate frontmatter block
|
||||
into the draft brief.
|
||||
|
||||
### Sequencing gate (downstream)
|
||||
|
||||
`brief_version: 2.1` activates the validator's sequencing gate. If the
|
||||
final brief reaches `/trekplan`, `/trekresearch`, `/trekexecute`, or
|
||||
`/trekreview` WITHOUT `phase_signals` and WITHOUT `phase_signals_partial:
|
||||
true`, the validator emits `BRIEF_V51_MISSING_SIGNALS` and the command
|
||||
halts with a friendly hint pointing back to `/trekbrief`.
|
||||
|
||||
## Phase 4 — Draft, review, and revise
|
||||
|
||||
Phase 4 runs a **draft → brief-reviewer → revise** loop. The draft is
|
||||
|
|
@ -923,8 +794,7 @@ node ${CLAUDE_PLUGIN_ROOT}/lib/validators/intent-approval.mjs --stamp "{BRIEF_PA
|
|||
```
|
||||
|
||||
This writes `intent_approved_hash` (sha256 over the normalized `## Intent`
|
||||
+ `## Goal`) and `intent_approved_at` into the frontmatter, and emits the
|
||||
`brief-approved` lifecycle event. It prints JSON; if `stamped` is `false`
|
||||
+ `## Goal`) and `intent_approved_at` into the frontmatter. It prints JSON; if `stamped` is `false`
|
||||
(e.g. `## Goal` is empty), report the `reason` and return to step 1. Editing
|
||||
`## Intent` or `## Goal` afterwards makes the approval stale — `/trekplan`
|
||||
halts until the operator approves the new text.
|
||||
|
|
@ -969,16 +839,14 @@ Logged: {record {seq} in .claude/voyage/approvals.jsonl | no — {reason}}
|
|||
Committed: {{short sha}: brief + log | {short sha}: log only, brief kept on this machine | no — {reason}}
|
||||
```
|
||||
|
||||
In `--approve` mode, run **Phase 7** (`"mode": "approve"`) when the stamp
|
||||
printed `stamped: true`, then stop and print the next step:
|
||||
In `--approve` mode, stop and print the next step:
|
||||
`{PLAN_COMMAND}` (only if approved).
|
||||
|
||||
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)
|
||||
|
||||
**With research_topics = 0**, print the manual-path output below without the
|
||||
`/trekresearch` lines (Brief, then `/trekplan --project {PROJECT_DIR}`, then
|
||||
`/trekexecute --project {PROJECT_DIR}`), then run Phase 7 (`auto_result: manual`)
|
||||
and stop. Phase 6 is never entered without the operator's opt-in.
|
||||
`/trekexecute --project {PROJECT_DIR}`), then stop. Phase 6 is never entered without the operator's opt-in.
|
||||
|
||||
Ask the user via `AskUserQuestion`:
|
||||
|
||||
|
|
@ -1012,23 +880,19 @@ Then:
|
|||
/trekexecute --project {PROJECT_DIR}
|
||||
```
|
||||
|
||||
Run Phase 7 (`auto_result: manual`), then stop. Do not continue to Phase 6.
|
||||
Stop. Do not continue to Phase 6.
|
||||
|
||||
### Auto path
|
||||
|
||||
**Auto requires an approved intent.** If Phase 4h ended "Leave unapproved",
|
||||
the auto chain would halt at `/trekplan` anyway — print the manual-path
|
||||
output instead, run Phase 7 (`auto_result: manual`) and stop.
|
||||
output instead and stop.
|
||||
|
||||
Set `auto_research: true` in the brief's frontmatter (edit the file).
|
||||
|
||||
The `brief-approved` lifecycle event was already emitted by the Phase 4h
|
||||
stamp — on both paths — so it is not emitted again here.
|
||||
|
||||
If `gates_mode == true`: pause here via `AskUserQuestion` —
|
||||
"Auto-mode confirmed. Proceed to research now? (yes/no)". If the user
|
||||
answers no, fall back to the manual path output, run Phase 7
|
||||
(`auto_result: cancelled`) and stop. Otherwise proceed to Phase 6.
|
||||
answers no, fall back to the manual path output and stop. Otherwise proceed to Phase 6.
|
||||
|
||||
If `gates_mode == false` (default in auto): proceed directly to Phase 6.
|
||||
The chain ends when `/trekplan` has written the plan (Step 6e); it never
|
||||
|
|
@ -1052,10 +916,9 @@ Continue?"
|
|||
| Option | Action |
|
||||
|--------|--------|
|
||||
| **Continue — auto** | Proceed. |
|
||||
| **Cancel — do manual** | Revert to manual path (run Phase 7 with `auto_result: cancelled`, print commands, stop). |
|
||||
| **Cancel — do manual** | Revert to manual path (print commands, stop). |
|
||||
|
||||
If cancelled → fall back to manual path output, run Phase 7
|
||||
(`auto_result: cancelled`) and stop.
|
||||
If cancelled → fall back to manual path output and stop.
|
||||
|
||||
### Step 6b — Run research topics sequentially (inline)
|
||||
|
||||
|
|
@ -1091,8 +954,7 @@ ls -1 {PROJECT_DIR}/research/*.md | wc -l
|
|||
```
|
||||
|
||||
Expected count: N. If any are missing, report and ask the user how to
|
||||
proceed (retry, skip missing topic, cancel). On cancel, run Phase 7
|
||||
(`auto_result: cancelled`) and stop.
|
||||
proceed (retry, skip missing topic, cancel). On cancel, stop.
|
||||
|
||||
Update brief frontmatter: `research_status: complete`.
|
||||
|
||||
|
|
@ -1107,7 +969,7 @@ Invoke the planning command inline in this session:
|
|||
The planning pipeline runs all phases (exploration, synthesis, review) in
|
||||
main context. Wait for the plan to be written to `{PROJECT_DIR}/plan.md`
|
||||
before continuing. If `/trekplan` stops before it writes the plan, report
|
||||
where it stopped, run Phase 7 (`auto_result: failed`) and stop.
|
||||
where it stopped and stop.
|
||||
|
||||
### Step 6e — Report completion
|
||||
|
||||
|
|
@ -1137,68 +999,6 @@ Or:
|
|||
/trekexecute --validate --project {PROJECT_DIR} # schema check
|
||||
```
|
||||
|
||||
## Phase 7 — Stats tracking
|
||||
|
||||
Append one record to `trekbrief-stats.jsonl` in the plugin data directory.
|
||||
Run it exactly like this. The record goes on stdin, and the writer resolves
|
||||
the directory: `CLAUDE_PLUGIN_DATA`, which the Bash tool env does not carry,
|
||||
else the directory `scripts/yardstick.mjs` reads.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/stats-append.mjs trekbrief <<'JSON'
|
||||
{
|
||||
"ts": "{ISO-8601}",
|
||||
"task": "{task description (first 100 chars)}",
|
||||
"slug": "{slug}",
|
||||
"mode": "{default | quick | approve}",
|
||||
"interview_turns": {N},
|
||||
"review_iterations": {1..3},
|
||||
"brief_quality": "{complete | partial}",
|
||||
"research_topics": {N},
|
||||
"auto_research": {true | false},
|
||||
"auto_result": "{completed | cancelled | failed | manual}",
|
||||
"intent_approved": {true | false},
|
||||
"project_dir": "{path}"
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
`intent_approved` is `true` only when Phase 4h ran the stamp (the operator
|
||||
answered "Approve"). `slug` is the brief's `slug`, the same one the stamp's
|
||||
`brief-approved` record and `/trekplan`'s record carry.
|
||||
|
||||
In `--approve` mode the record carries fixed values: `"mode": "approve"`,
|
||||
`"interview_turns": 0`, `"review_iterations": 0`, `brief_quality`,
|
||||
`research_topics` and `auto_research` from the brief's frontmatter (`complete`,
|
||||
`0`, `false` when absent), `"auto_result": "manual"`, `"intent_approved": true`.
|
||||
|
||||
The writer adds three keys itself: `order_id`, `order_claimed_at` and
|
||||
`order_reason` - the coord-queue order this run is working on, read from the
|
||||
queue's claimed orders (exactly one: its id and claim time; zero or several:
|
||||
`order_id: null` and the reason). Never type them: any `order_*` key in the
|
||||
record is replaced. `scripts/yardstick.mjs` joins its `use` number on
|
||||
`order_id`.
|
||||
|
||||
The writer prints `{"written": true, ...}`. On `"written": false` (exit 1),
|
||||
print its `reason` in one line and continue. A stats failure never blocks the
|
||||
workflow, but it is reported, not skipped silently.
|
||||
|
||||
## Profile (v4.1)
|
||||
|
||||
Accepts `--profile <name>` where `<name>` is one of `economy`, `balanced`,
|
||||
`premium`, `fable`, or a custom profile under `voyage-profiles/`. Default: `premium`.
|
||||
|
||||
Resolution order (per `lib/profiles/resolver.mjs`):
|
||||
1. `--profile` flag (source: `flag`)
|
||||
2. `VOYAGE_PROFILE` environment variable (source: `env`)
|
||||
3. `premium` default (source: `default`)
|
||||
|
||||
Examples:
|
||||
```
|
||||
/trekbrief --profile economy
|
||||
VOYAGE_PROFILE=balanced /trekbrief
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Interactive only.** This command requires user input. There is no
|
||||
|
|
|
|||
|
|
@ -268,51 +268,6 @@ If the brief file does not exist (validator emits a warning but does not
|
|||
fail), print: `Warning: next_session_brief_path "{path}" does not exist on
|
||||
disk. Cannot continue automatically.` and exit. Do not guess.
|
||||
|
||||
## Phase 5 — Stats tracking
|
||||
|
||||
Append one record to `trekcontinue-stats.jsonl` in the plugin data directory.
|
||||
Run it exactly like this. The record goes on stdin, and the writer resolves
|
||||
the directory: `CLAUDE_PLUGIN_DATA`, which the Bash tool env does not carry,
|
||||
else the directory `scripts/yardstick.mjs` reads.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/stats-append.mjs trekcontinue <<'JSON'
|
||||
{"ts":"<iso-8601>","project":"<project>","next_session_label":"<label>","status":"<status>"}
|
||||
JSON
|
||||
```
|
||||
|
||||
The writer prints `{"written": true, ...}`. On `"written": false` (exit 1),
|
||||
print its `reason` in one line and continue. A tracking failure never blocks
|
||||
the main workflow, but it is reported, not skipped silently.
|
||||
|
||||
## Profile (v4.1) — inheritance from plan-frontmatter
|
||||
|
||||
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`,
|
||||
or a custom profile under `voyage-profiles/`. Default: `premium`.
|
||||
|
||||
Unlike the other voyage commands, `/trekcontinue` defaults to **inheritance**
|
||||
from the resumed plan's frontmatter `profile:` field rather than to `premium`.
|
||||
This keeps a multi-session run consistent across resumptions.
|
||||
|
||||
Resolution order (per `lib/profiles/resolver.mjs` `resolveTrekcontinueProfile`):
|
||||
1. `--profile` flag (source: `flag`) — explicit override, emits stderr advisory
|
||||
`[voyage] profile inheritance overridden by --profile flag: <plan-profile> → <flag-profile>`
|
||||
2. `VOYAGE_PROFILE` env-var (source: `env`)
|
||||
3. Plan-frontmatter `profile:` field (source: `inheritance`) — typical case
|
||||
4. `premium` default for v4.0-style plans without `profile:` (source: `default`)
|
||||
|
||||
The inherited profile drives `phase_models.continue` for the next session's
|
||||
orchestration. Operators who explicitly want to switch profiles mid-run pass
|
||||
`--profile <name>` and accept the advisory (e.g. drop from `premium` to
|
||||
`balanced` mid-run to save cost on later sessions).
|
||||
|
||||
Examples:
|
||||
```
|
||||
/trekcontinue # inherits from plan.md
|
||||
/trekcontinue --profile balanced # explicit override + advisory
|
||||
VOYAGE_PROFILE=economy /trekcontinue # env-var override
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Idempotent.** Running `/trekcontinue` twice in the same Claude session
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: trekexecute
|
||||
description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery
|
||||
argument-hint: "[--project <dir>] [--profile <name>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
|
||||
argument-hint: "[--project <dir>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
|
||||
disallowed-tools: TeamCreate
|
||||
---
|
||||
|
|
@ -32,7 +32,8 @@ never start helpers.
|
|||
|
||||
## Phase 1 — Parse mode and validate input
|
||||
|
||||
Parse `$ARGUMENTS` for mode flags:
|
||||
Parse `$ARGUMENTS` for mode flags. A retired flag is stripped first and ignored, together with the word after it
|
||||
when that word is not a flag: `--profile`.
|
||||
|
||||
1. If arguments contain `--project <dir>`: extract the directory path.
|
||||
- Resolve `{dir}` (trim trailing slash).
|
||||
|
|
@ -49,21 +50,14 @@ Parse `$ARGUMENTS` for mode flags:
|
|||
recovery session spec inside the project).
|
||||
- Continue parsing remaining flags (they combine with `--project`).
|
||||
- Brief check, once every flag below is parsed: when `{dir}/brief.md`
|
||||
exists, run the two lines `/trekplan` runs on it (its Phase 1), for the
|
||||
execute phase; append `--profile {profile}` when the operator passed
|
||||
`--profile`:
|
||||
exists, run the line `/trekplan` runs on it (its Phase 1):
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json
|
||||
```
|
||||
The validator exits 1 when its `errors` are not empty; read its JSON
|
||||
either way. Halt only when the validator's `errors` hold
|
||||
`BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate
|
||||
surface" below. Any other error is printed in one line and the run
|
||||
continues. A validator or resolver that cannot run (no JSON) is "not
|
||||
measured" in one line, and the run continues. Capture the resolver's
|
||||
JSON as `phase_signal_result` (Composition rule below). No
|
||||
`{dir}/brief.md`: skip both lines.
|
||||
either way. Any error is printed in one line and the run continues. A
|
||||
validator that cannot run (no JSON) is "not measured" in one line, and
|
||||
the run continues. No `{dir}/brief.md`: skip the line.
|
||||
2. If arguments contain `--fg`: extract the file path. Set **mode = foreground**.
|
||||
3. If arguments contain `--resume`: extract the file path. Set **mode = resume**.
|
||||
4. If arguments contain `--dry-run`: extract the file path. Set **mode = dry-run**.
|
||||
|
|
@ -95,8 +89,7 @@ Parse `$ARGUMENTS` for mode flags:
|
|||
(operator decision 2026-10-08: two questions for one merge became one). The
|
||||
MAIN_MERGE_GATE in Phase 8 is always on, regardless of `gates_mode`: it is
|
||||
a lock, and the guard's question is the one question.
|
||||
9. If arguments contain `--profile <name>`: set `profile = {name}`; strip both tokens.
|
||||
10. Otherwise: the arguments left after every flag above is stripped are the file path.
|
||||
9. Otherwise: the arguments left after every flag above is stripped are the file path.
|
||||
Set **mode = execute**.
|
||||
|
||||
If no path is provided (and `--project` was not used to derive one), output
|
||||
|
|
@ -111,7 +104,6 @@ Usage: /trekexecute <plan.md>
|
|||
/trekexecute --validate <plan.md>
|
||||
/trekexecute --step N <plan.md>
|
||||
/trekexecute --session N <plan.md>
|
||||
/trekexecute --profile <name> <plan.md>
|
||||
/trekexecute --project <dir> --resume
|
||||
/trekexecute --project <dir> --session N
|
||||
|
||||
|
|
@ -125,7 +117,6 @@ Modes:
|
|||
--validate Schema-only check — parse steps + manifests, no security scan, no execution
|
||||
--step N Execute only step N (foreground)
|
||||
--session N Execute only session N from the plan's Execution Strategy
|
||||
--profile Profile for this run's resolver; the step helper and the controller stay on opus
|
||||
--gates Accepted, pauses on nothing: bare, true or closed = on; false or open = off
|
||||
|
||||
Examples:
|
||||
|
|
@ -465,7 +456,6 @@ Determine how to execute this plan:
|
|||
- `--step N` mode
|
||||
- `--resume` mode
|
||||
- `--session N` mode (runs only that session's steps, foreground)
|
||||
- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when `.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.
|
||||
- Plan has no `## Execution Strategy` section
|
||||
- Plan has Execution Strategy with only 1 session
|
||||
|
||||
|
|
@ -2565,7 +2555,7 @@ Default **25** is an independent value from the cap research (practitioner range
|
|||
10–50; OWASP Agentic ASI08 financial-DoS), tuned down from the research ~50
|
||||
starting point for a tighter backstop. Operators may override:
|
||||
`TREKEXECUTE_MAX_RECOVERY_ITERATIONS=10 /trekexecute --project ...`. The
|
||||
`effort=='low'` single-foreground-loop path and the wave path are both subject to
|
||||
single-foreground-loop path and the wave path are both subject to
|
||||
this aggregate ceiling; it is an execution-spec ceiling (enforced via the
|
||||
`iterations_remaining` counter), not a code-loop edit.
|
||||
|
||||
|
|
@ -2575,29 +2565,6 @@ Always produce a final report.
|
|||
|
||||
### Main-merge gate (MAIN_MERGE_GATE — always pauses)
|
||||
|
||||
Before writing the final progress + state files (and before any "merge to
|
||||
main" prose), emit the `main-merge-gate` lifecycle event so observability
|
||||
and operator tooling can see that the pipeline reached its terminal
|
||||
boundary:
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs --event main-merge-gate --payload - <<'JSON'
|
||||
{"plan":"{plan-path}","wave_count":{wave-count}}
|
||||
JSON
|
||||
```
|
||||
|
||||
Fill in the placeholders yourself. The payload goes on stdin through a quoted
|
||||
heredoc, so the shell expands nothing in it: not a variable (shell state from an
|
||||
earlier Bash call does not survive, and an expanded variable is how this event
|
||||
used to print `payload-not-json`), and not a quote character or `$(...)` in the
|
||||
plan path. `{plan-path}` is the repo-relative plan path written as a JSON
|
||||
string (escape a `"` or `\` in it). `{wave-count}` is the number of merged
|
||||
waves, `1` on the single-session path.
|
||||
|
||||
Run each event call as its own command, never chained with `&&`. The writer
|
||||
prints `{"written": true, ...}`. On `"written": false` (exit 1), print its
|
||||
`reason` in one line and continue — an event failure never blocks the gate.
|
||||
|
||||
**The gate is a lock, not a question** (operator decision 2026-10-08). The
|
||||
guard (`pre-bash-executor`) asks the operator before every merge into main and
|
||||
every push, in auto mode too. When the executor first asked "ready to merge?"
|
||||
|
|
@ -2606,8 +2573,7 @@ merge may run at all, and the guard's question is the one question.
|
|||
|
||||
**Only the closing run passes the lock** (Phase 7.2: a single-session run of
|
||||
the whole plan, or the wave parent after the last wave). A `--session N`
|
||||
child, a `--step N` run and a session spec deliver a part: they emit no gate
|
||||
event, merge nothing, and their result is left as Phase 7.5 decided it
|
||||
child, a `--step N` run and a session spec deliver a part: they merge nothing, and their result is left as Phase 7.5 decided it
|
||||
(`main_merge: "n/a"`). A wave session's `completed` must reach its parent as
|
||||
`completed`, or Step 3 would read the wave as failed.
|
||||
|
||||
|
|
@ -2641,9 +2607,9 @@ has confirmed. Any other exit 2 that does not start `review-stale` or
|
|||
is reported as "not measured", never green, and the merge stays locked with
|
||||
the decline reason `review-not-measured`.
|
||||
|
||||
The merge runs only on exit 0 and a `completed` result. **Closed:** emit
|
||||
`main-merge-declined` with the reason `review-block`, `review-stale`,
|
||||
`review-unanswered`, `review-not-measured` or `run-not-completed`. Nothing is finalized: a `completed` result becomes
|
||||
The merge runs only on exit 0 and a `completed` result. **Closed** (reason
|
||||
`review-block`, `review-stale`,
|
||||
`review-unanswered`, `review-not-measured` or `run-not-completed`). Nothing is finalized: a `completed` result becomes
|
||||
`partial` (`failed` and `stopped` stay), and the commits stay where they are
|
||||
for review. Say in one line why it is closed and what opens it: the
|
||||
BLOCKERs fixed through Phase 7.2's loop, a review that runs in full, a review
|
||||
|
|
@ -2660,9 +2626,9 @@ git worktree list --porcelain
|
|||
```
|
||||
|
||||
- **No `MAIN=` line** (the repo has neither branch): there is nothing to merge
|
||||
into. Emit `main-merge-declined` with the reason `no-main-branch`.
|
||||
into (reason `no-main-branch`).
|
||||
- **The run's branch IS main (or master):** there is nothing to merge; the
|
||||
commits are already on main. Emit `main-merge-approved` and say in the
|
||||
commits are already on main. Say in the
|
||||
report that the review ran after the commits landed on main and could not
|
||||
hold them back: run on a branch when the review should gate the merge.
|
||||
- **Another branch:** first, before the merge call, write the run evidence
|
||||
|
|
@ -2713,20 +2679,19 @@ git worktree list --porcelain
|
|||
as a `merge` approval (K2). The executor asks nothing before or after it. In
|
||||
the permission mode where Claude Code asks nothing, the guard refuses the
|
||||
call instead, because there is no one to ask (operator decision 2026-10-09).
|
||||
- `MERGED` → emit `main-merge-approved`.
|
||||
- `MERGED` → the merge ran.
|
||||
- The call did not run (the operator declined at the guard's question, or
|
||||
the guard refused it) → emit `main-merge-declined` with the reason
|
||||
the guard refused it) → reason
|
||||
`hold-for-review`; the run is `partial`, and the commits stay on the run's
|
||||
branch. When the guard refused, say so, and that the operator merges by
|
||||
hand.
|
||||
- `MERGE_CONFLICT` → list the conflicting files the block printed (the
|
||||
merge is already aborted), emit `main-merge-declined` with the reason
|
||||
merge is already aborted), reason
|
||||
`merge-conflict`; the run is `partial`. Never resolve a conflict with
|
||||
`-X ours/theirs`.
|
||||
- `MERGE_REFUSED` → git refused to start (local changes in the main tree, or
|
||||
untracked files in the way: its message says which). Emit
|
||||
`main-merge-declined` with the reason `merge-refused` and report git's
|
||||
message; the run is `partial`.
|
||||
untracked files in the way: its message says which). Report git's
|
||||
message (reason `merge-refused`); the run is `partial`.
|
||||
|
||||
This gate is **always on**, regardless of `gates_mode`: a lock that every run
|
||||
passes through, the one boundary that ALWAYS pauses on every run, either at
|
||||
|
|
@ -2739,14 +2704,9 @@ and the merged-waves record is kept until the confirm below.
|
|||
|
||||
On confirm:
|
||||
(the merge ran, or there was nothing to merge)
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs --event main-merge-approved --payload - <<'JSON'
|
||||
{"plan":"{plan-path}"}
|
||||
JSON
|
||||
```
|
||||
|
||||
On the multi-session wave path (Phase 2.6), and only when `next-wave` said
|
||||
`none` (every wave merged) and Step 4 passed, also clear the plan's orders
|
||||
`none` (every wave merged) and Step 4 passed, clear the plan's orders
|
||||
directory — orders, archived progress copies and the merged-waves record — so a
|
||||
later plan with the same `{slug}` starts clean. This is the one place it is
|
||||
cleared:
|
||||
|
|
@ -2756,17 +2716,8 @@ cleared:
|
|||
|
||||
On decline:
|
||||
(a closed lock, or the guard's question declined, or a conflict)
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/event-emit.mjs --event main-merge-declined --payload - <<'JSON'
|
||||
{"plan":"{plan-path}","reason":"{reason}"}
|
||||
JSON
|
||||
```
|
||||
|
||||
The reason is one of the literal words above (`review-block`,
|
||||
`review-stale`, `review-unanswered`, `review-not-measured`, `run-not-completed`, `no-main-branch`, `hold-for-review`,
|
||||
`merge-conflict`, `merge-refused`), never the operator's free text (free text breaks the JSON).
|
||||
|
||||
On decline, nothing is cleared: the merged-waves record is kept.
|
||||
Nothing is cleared: the merged-waves record is kept.
|
||||
|
||||
**Recovery surface:** on the single-session path, if declined,
|
||||
`--resume re-enters at the gate` — re-running
|
||||
|
|
@ -3013,110 +2964,6 @@ run resumes at the lowest wave not yet merged, or re-runs Step 4 and returns to
|
|||
the gate, and the `clear` command in Step 0 starts the plan over from its first
|
||||
wave instead.
|
||||
|
||||
## Phase 9 — Stats tracking
|
||||
|
||||
Append one record to `trekexecute-stats.jsonl` in the plugin data directory.
|
||||
Run it exactly like this. The record goes on stdin, and the writer resolves
|
||||
the directory: `CLAUDE_PLUGIN_DATA`, which the Bash tool env does not carry,
|
||||
else the directory `scripts/yardstick.mjs` reads.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/stats-append.mjs trekexecute <<'JSON'
|
||||
{
|
||||
"ts": "{ISO-8601}",
|
||||
"plan": "{filename only}",
|
||||
"plan_type": "{plan | session-spec}",
|
||||
"mode": "{execute | resume | dry-run | step}",
|
||||
"result": "{completed | failed | stopped | partial}",
|
||||
"steps_total": 0,
|
||||
"steps_passed": 0,
|
||||
"steps_failed": 0,
|
||||
"steps_skipped": 0,
|
||||
"failed_at_step": null
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
`steps_passed` counts the steps whose status is `completed`.
|
||||
|
||||
The writer prints `{"written": true, ...}`. On `"written": false` (exit 1),
|
||||
print its `reason` in one line and continue. A stats failure never blocks the
|
||||
workflow, but it is reported, not skipped silently.
|
||||
|
||||
## Profile (v4.1)
|
||||
|
||||
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`,
|
||||
`fable`, or a custom profile under `voyage-profiles/`. Default: `premium`.
|
||||
|
||||
Resolution order (per `lib/profiles/resolver.mjs`):
|
||||
1. `--profile` flag (source: `flag`)
|
||||
2. `VOYAGE_PROFILE` env-var (source: `env`)
|
||||
3. Inherited from `plan.md` frontmatter `profile:` field (source: `inheritance`)
|
||||
4. `premium` default for v4.0-style plans without `profile:` (source: `default`)
|
||||
|
||||
The selected profile goes to the resolver. It changes no model here: the step helper and the
|
||||
controller run opus (Composition rule below), and nothing in this command reads `parallel_agents`.
|
||||
|
||||
Examples:
|
||||
```
|
||||
/trekexecute --profile economy --project .claude/projects/2026-05-09-add-auth
|
||||
VOYAGE_PROFILE=balanced /trekexecute --project ...
|
||||
```
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
|
||||
effort + model as:
|
||||
|
||||
```
|
||||
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
|
||||
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
|
||||
```
|
||||
|
||||
The brief signal wins per-phase when present; the profile fills any
|
||||
gaps. Both fields are mechanically resolved by the single composed CLI,
|
||||
invoked in Phase 1 alongside the sequencing-gate brief-validator call:
|
||||
|
||||
```bash
|
||||
# v5.9 — composed phase-model resolution (brief > profile > default) for the
|
||||
# execute phase. ONE call returns {model, source}, plus
|
||||
# effort only when the brief's phase signal sets one; without it the phase
|
||||
# runs at effort `standard` (no change). Captured as phase_signal_result.
|
||||
# Append --profile {profile} when the operator passed --profile.
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json
|
||||
```
|
||||
|
||||
`phase_signal_result.effort` (absent = `standard`) is consumed when picking the execution
|
||||
strategy (the parallel-wave choice, Phase 2.5). The resolver does NOT control the orchestrator's own
|
||||
model — that is fixed at invocation time (command frontmatter omits
|
||||
`model:`, so it follows the session model) and cannot be switched mid-turn.
|
||||
`/trekexecute` spawns no swarm: one step helper at a time and one controller
|
||||
(Hard Rule 10), and both run `opus` — the step agent's pin and the
|
||||
controller's `model: "opus"` — so `phase_signal_result.model` still has no
|
||||
spawn site here; it is returned for cross-command uniformity; no stats record carries it.
|
||||
|
||||
For `/trekexecute` specifically: `effort == 'low'` runs the plan as one
|
||||
session, step after step (Phase 2.5). `effort == 'standard'` (or absent) →
|
||||
no change (default execution strategy applies). `effort == 'high'`: see
|
||||
`### High-effort behavior (v5.1.1)` below.
|
||||
|
||||
### Sequencing gate surface
|
||||
|
||||
When `/trekexecute --project <dir>` is invoked, ALWAYS run
|
||||
`brief-validator.mjs --soft --json` against `{dir}/brief.md`. If
|
||||
`BRIEF_V51_MISSING_SIGNALS` appears in `errors` (brief_version ≥ 2.1
|
||||
without `phase_signals` or `phase_signals_partial: true`), halt with:
|
||||
`Brief is brief_version 2.1 but does not carry phase_signals — re-run
|
||||
/trekbrief to commit them (Phase 3.5).` Enforcement is validator-only;
|
||||
commands surface, don't re-enforce.
|
||||
|
||||
### High-effort behavior (v5.1.1)
|
||||
|
||||
High effort changes nothing in `/trekexecute`: the step helper and the
|
||||
controller already run opus, and every gate already runs. No `gates_mode` is
|
||||
set from the effort.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **No AskUserQuestion for execution decisions.** All execution decisions come
|
||||
|
|
@ -3196,7 +3043,7 @@ set from the effort.
|
|||
|
||||
15. **No writing outside the repository.** During step execution, never write
|
||||
files outside the git repository root (`git rev-parse --show-toplevel`).
|
||||
Exception: `.claude/` paths for plans, progress files, and stats,
|
||||
Exception: `.claude/` paths for plans and progress files,
|
||||
AND `~/.claude/projects/*/memory/` paths for memory files when the
|
||||
step's manifest declares `memory_write: true`. The `memory_write: true`
|
||||
opt-in is a second gate: unauthorized memory writes still fail. This
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ Pipeline position:
|
|||
|
||||
## Phase 1 — Parse mode and validate input
|
||||
|
||||
Parse `$ARGUMENTS` for mode flags. Order of precedence:
|
||||
Parse `$ARGUMENTS` for mode flags. A retired flag is stripped first and ignored, together with the word after it
|
||||
when that word is not a flag: `--profile`. Order of precedence:
|
||||
|
||||
1. **`--export headless <plan-path>`** — extract `{format}` (first token after
|
||||
`--export`) and `{plan-path}` (remainder). `headless` is the only export
|
||||
|
|
@ -74,14 +75,6 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
|
|||
# older brief that sidesteps framing enforcement.
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json [--min-version {min_brief_version}] "{dir}/brief.md"
|
||||
|
||||
# v5.9 — composed phase-model resolution (brief > profile > default) for
|
||||
# the plan phase. ONE call returns {model, source}, plus
|
||||
# effort only when the brief's phase signal sets one; without it the
|
||||
# phase runs at effort `standard` (no change). Captured as
|
||||
# phase_signal_result and injected at Agent-spawn sites below.
|
||||
# Append --profile {profile} when the operator passed --profile.
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path "{dir}/brief.md" [--profile {profile}] --json
|
||||
|
||||
# Research briefs (if any) — drift-warn only, none of these block the run
|
||||
[ -d "{dir}/research" ] && \
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/research-validator.mjs --soft --dir "{dir}/research" --json
|
||||
|
|
@ -325,9 +318,7 @@ Determine the output directory from the plan slug:
|
|||
|
||||
Launch the **session-decomposer** agent:
|
||||
|
||||
Phase 1 runs the model resolver only for `--project`, so `--decompose` has no
|
||||
resolved model: this spawn passes no `model:`, and the agent's own `model:`
|
||||
applies.
|
||||
This spawn passes no `model:`: the agent's own `model: opus` applies.
|
||||
|
||||
```
|
||||
Plan file: {plan path}
|
||||
|
|
@ -468,9 +459,7 @@ Follow the writing-criteria skill (each success criterion is one command the
|
|||
criteria runner accepts, and it can go red) and the reviewing-adversarially skill.
|
||||
Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names)."
|
||||
|
||||
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
|
||||
`--brief` branch, where the resolver does not run, or a resolver that printed
|
||||
nothing) the agent's own `model:` applies.
|
||||
This spawn passes no `model:`: the agent's own `model: opus` applies.
|
||||
|
||||
Handle the verdict:
|
||||
- **PROCEED** — continue to Phase 5.
|
||||
|
|
@ -529,9 +518,7 @@ research-scout's web search is not a local read; the line above bounds its local
|
|||
|
||||
### Always launch (all codebase sizes):
|
||||
|
||||
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
|
||||
`--brief` branch, where the resolver does not run, or a resolver that printed
|
||||
nothing) the agent's own `model:` applies.
|
||||
This spawn passes no `model:`: the agent's own `model: opus` applies.
|
||||
|
||||
**architecture-mapper** — full codebase structure, tech stack, patterns, anti-patterns.
|
||||
Prompt: "Analyze the architecture of this codebase. The task being planned is: {task}.
|
||||
|
|
@ -570,7 +557,7 @@ Read local files only inside the repo under exploration, plus paths this prompt
|
|||
|
||||
### Launch for medium+ codebases (50+ files):
|
||||
|
||||
**Convention Scanner** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
|
||||
**Convention Scanner** — use the `convention-scanner` plugin agent (no `model:`; its own `model: opus` applies)
|
||||
for medium+ codebases only.
|
||||
Prompt: "Discover the coding conventions relevant to this task: {task}. Brief file: {brief_path} — read it first. Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names). Provide concrete examples from the codebase, not generic advice."
|
||||
|
||||
|
|
@ -588,9 +575,7 @@ topics have corresponding research files, skip research-scout entirely.
|
|||
|
||||
If yes (and not covered by attached briefs): launch **research-scout** in
|
||||
parallel with the other agents.
|
||||
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
|
||||
`--brief` branch, where the resolver does not run, or a resolver that printed
|
||||
nothing) the agent's own `model:` applies.
|
||||
This spawn passes no `model:`: the agent's own `model: opus` applies.
|
||||
Prompt: "Research the following technologies for this task: {task}.
|
||||
Specific questions: {list specific questions about the technology}.
|
||||
Technologies to research: {list}. Brief file: {brief_path} — read it first.
|
||||
|
|
@ -611,9 +596,9 @@ Common reasons for deep-dives:
|
|||
- A test pattern was identified but the test infrastructure needs more detail
|
||||
- A risk was flagged but the actual impact needs verification
|
||||
|
||||
For each significant gap, spawn a targeted deep-dive agent (model: phase_signal_result.model, else opus —
|
||||
Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to,
|
||||
subagent_type: "Explore") with a narrow, specific brief. Each deep-dive prompt
|
||||
For each significant gap, spawn a targeted deep-dive agent (subagent_type:
|
||||
"Explore", Claude Code's built-in agent) with a narrow, specific brief.
|
||||
Spawn it with `model: "opus"`: a built-in agent has no Voyage pin. Each deep-dive prompt
|
||||
carries these two lines:
|
||||
Brief file: {brief_path} — read it first.
|
||||
Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names).
|
||||
|
|
@ -986,9 +971,7 @@ Both reviewers are asked for `voyage:writing-criteria` and
|
|||
writing-criteria rule in one sentence in its prompt, for the brief's criteria
|
||||
it traces into `## Verification`.
|
||||
|
||||
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
|
||||
`--brief` branch, where the resolver does not run, or a resolver that printed
|
||||
nothing) the agent's own `model:` applies.
|
||||
This spawn passes no `model:`: the agent's own `model: opus` applies.
|
||||
|
||||
**plan-critic** — adversarial review of the plan.
|
||||
Prompt: "Review this implementation plan for the task: {task}.
|
||||
|
|
@ -1293,142 +1276,13 @@ Confirm the plan and brief file locations and exit.
|
|||
|
||||
Do not implement in this session. Print the line the operator gives
|
||||
`/trekexecute` — `/trekexecute --project {project_dir}`, or
|
||||
`/trekexecute {plan_path}` without a project directory — then run Phase 12 (`outcome: execute`).
|
||||
`/trekexecute {plan_path}` without a project directory.
|
||||
`/trekexecute` is what checks the plan's approval (K2), locks the tests, runs
|
||||
one step helper per step and the review.
|
||||
|
||||
### "execute with team" / "team"
|
||||
|
||||
There is no team path. Print the same `/trekexecute` line, then run Phase 12
|
||||
(`outcome: execute_team`); parallel work is `--decompose` followed by `/trekexecute`'s session orders.
|
||||
|
||||
## Phase 12 — Session tracking
|
||||
|
||||
After the plan is presented (Phase 10) or after handoff (Phase 11), append a
|
||||
session record to `trekplan-stats.jsonl` in the plugin data directory. Run it
|
||||
exactly like this. The record goes on stdin, and the writer resolves the
|
||||
directory: `CLAUDE_PLUGIN_DATA`, which the Bash tool env does not carry, else
|
||||
the directory `scripts/yardstick.mjs` reads.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/stats-append.mjs trekplan <<'JSON'
|
||||
{
|
||||
"ts": "{ISO-8601 timestamp}",
|
||||
"task": "{task description (first 100 chars)}",
|
||||
"mode": "{default|fg|quick}",
|
||||
"slug": "{the brief's slug}",
|
||||
"brief_path": "{brief_path}",
|
||||
"project_dir": "{project_dir or null}",
|
||||
"codebase_size": "{small|medium|large}",
|
||||
"codebase_files": {N},
|
||||
"agents_deployed": {N},
|
||||
"deep_dives": {N},
|
||||
"research_briefs_used": {N},
|
||||
"research_scout_used": {true|false},
|
||||
"critic_verdict": "{BLOCK|REVISE|PASS}",
|
||||
"guardian_verdict": "{ALIGNED|CREEP|GAP|MIXED}",
|
||||
"outcome": "{execute|execute_team|save|refine}"
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
`slug` is the brief's `slug`, so this record pairs with the brief's
|
||||
`brief-approved` and `trekbrief` records. `critic_verdict` is the `verdict` of
|
||||
the last round `plan-gate.mjs --record` wrote into the plan. The writer prints
|
||||
`{"written": true, ...}`. On `"written": false` (exit 1), print its `reason` in
|
||||
one line and continue. A tracking failure never blocks the main workflow, but
|
||||
it is reported, not skipped silently.
|
||||
|
||||
## Profile (v4.1)
|
||||
|
||||
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`, `fable`,
|
||||
or a custom profile under `voyage-profiles/`. Default: `premium`.
|
||||
|
||||
Resolution order (per `lib/profiles/resolver.mjs`):
|
||||
1. `--profile` flag (source: `flag`)
|
||||
2. `VOYAGE_PROFILE` env-var (source: `env`)
|
||||
3. `premium` default (source: `default`)
|
||||
|
||||
The selected profile drives `phase_models.plan` (model used for the planning
|
||||
LLM), `parallel_agents_min`/`max` (the exploration swarm size), and is
|
||||
recorded in `plan.md` frontmatter so `/trekexecute` and `/trekcontinue` can
|
||||
inherit it across the pipeline.
|
||||
|
||||
Examples:
|
||||
```
|
||||
/trekplan --profile economy --project .claude/projects/2026-05-09-add-auth
|
||||
/trekplan --profile balanced --brief brief.md
|
||||
VOYAGE_PROFILE=balanced /trekplan --project ...
|
||||
```
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
|
||||
effort + model as:
|
||||
|
||||
```
|
||||
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
|
||||
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
|
||||
```
|
||||
|
||||
The brief signal wins per-phase when present; the profile fills any
|
||||
gaps. Both fields are mechanically resolved by the single composed CLI
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model`
|
||||
invoked in Phase 1; the resolved JSON is `{model, source}`, plus
|
||||
`effort` only when the brief's phase signal sets one (without it the
|
||||
phase runs at effort `standard`: no change). It is captured as
|
||||
`phase_signal_result` and passed to `Agent` tool calls explicitly. The
|
||||
resolver controls the `model` parameter at Agent-spawn sites only — the
|
||||
orchestrator's own model is fixed at invocation time (command frontmatter
|
||||
omits `model:`, so it follows the session model) and cannot be switched
|
||||
mid-turn. Sub-agents fall back to `model:` in their own `agents/*.md`
|
||||
frontmatter when no spawn-site injection happens.
|
||||
|
||||
For `/trekplan` specifically: `effort == 'low'` activates the existing
|
||||
`--quick`-equivalent code-path (skip Phase 5 agent swarm — plan directly
|
||||
without exploration agents). `effort == 'standard'` (or absent) → no
|
||||
change. `effort == 'high'` activates the high-effort behavior documented
|
||||
under `### High-effort behavior (v5.1.1)` below.
|
||||
|
||||
### Sequencing gate surface
|
||||
|
||||
Phase 1 already calls `brief-validator.mjs --soft`. If the validator
|
||||
returns `BRIEF_V51_MISSING_SIGNALS` in `errors` (brief_version ≥ 2.1
|
||||
without `phase_signals` or `phase_signals_partial: true`), halt with a
|
||||
one-line message: `Brief is brief_version 2.1 but does not carry phase_signals
|
||||
— re-run /trekbrief to commit them (Phase 3.5).` Enforcement is
|
||||
validator-only; this surface just makes the friendly hint readable.
|
||||
|
||||
### High-effort behavior (v5.1.1)
|
||||
|
||||
When `phase_signal_result.effort == 'high'` for the `plan` phase, the
|
||||
additional adversarial Pass 2 on the post-revision plan is
|
||||
**unavailable, skipped**. Append the section header to plan.md before
|
||||
the trailing JSON block with exactly that status, so a reader can tell
|
||||
a deliberately dropped pass from one that failed:
|
||||
|
||||
```
|
||||
## Adversarial Pass 2 (v5.1.1 high-effort)
|
||||
|
||||
**Status:** unavailable, skipped — the third-party bridge agent that
|
||||
ran this pass was dropped in v5.10.1 by operator directive. No
|
||||
substitute is wired. Phase 9 (plan-critic + scope-guardian) is the
|
||||
complete adversarial coverage for this plan.
|
||||
```
|
||||
|
||||
Do NOT fail, retry, or silently omit the section: a high-effort plan
|
||||
that carries no Pass 2 marker is indistinguishable from one whose Pass
|
||||
2 crashed.
|
||||
|
||||
History (why the slot exists): Decision B, 2026-05-14, substituted a
|
||||
bridge pass for an "extra plan-critic-iterasjon" because plan-critic
|
||||
dedup keys on `(file, line, rule_key)` triplets and post-revision line
|
||||
numbers shift, risking a revision-loop. Dropping the bridge does not
|
||||
revive that option — re-running plan-critic carries the same loop risk
|
||||
it always did.
|
||||
|
||||
Standard and low effort: do NOT emit the section at all.
|
||||
There is no team path. Print the same `/trekexecute` line; parallel work is `--decompose` followed by `/trekexecute`'s session orders.
|
||||
|
||||
## Hard rules
|
||||
|
||||
|
|
@ -1442,11 +1296,6 @@ Standard and low effort: do NOT emit the section at all.
|
|||
own requirements.
|
||||
- **Scope**: Only explore the current working directory and its subdirectories.
|
||||
Never read files outside the repo (no ~/.env, no credentials, no other repos).
|
||||
- **Cost**: Model resolution at Agent-spawn sites is a three-layer fallback:
|
||||
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >
|
||||
agent frontmatter `model:`. The composed resolver returns the first two
|
||||
layers as `phase_signal_result.model`; spawn sites inject it, and agent
|
||||
frontmatter is the fallback when no injection happens.
|
||||
- **Privacy**: Never log, store, or repeat file contents that look like
|
||||
secrets, tokens, or credentials. Never log prompt text.
|
||||
- **No premature execution**: Do not modify any project files until the user
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: trekresearch
|
||||
description: Deep research combining local codebase analysis with external knowledge, producing structured research briefs with triangulation and confidence ratings
|
||||
argument-hint: "[--project <dir>] [--quick | --local | --external | --fg] [--engine swarm|deep-research] <research question>"
|
||||
argument-hint: "[--project <dir>] [--quick | --local | --external | --fg] <research question>"
|
||||
allowed-tools: Agent, Read, Glob, Grep, Write, Edit, Bash, AskUserQuestion, WebSearch, WebFetch, mcp__tavily__tavily_search, mcp__tavily__tavily_research
|
||||
---
|
||||
|
||||
|
|
@ -25,7 +25,8 @@ specialized agents, each seeing only what they need. The value is in triangulati
|
|||
|
||||
Parse `$ARGUMENTS` for mode flags. Flags can appear in any order before the
|
||||
research question. Collect all flags first, then treat the remainder as the
|
||||
research question.
|
||||
research question. A retired flag is stripped first and ignored, together with the word after it
|
||||
when that word is not a flag: `--profile` or `--engine`.
|
||||
|
||||
Supported flags:
|
||||
|
||||
|
|
@ -54,22 +55,12 @@ Supported flags:
|
|||
Create `{dir}/research/` if it does not already exist.
|
||||
|
||||
When `{dir}/brief.md` exists, ALWAYS run the brief-validator (soft mode)
|
||||
AND the composed phase-model resolver for this command's phase before
|
||||
continuing. The resolver's JSON output (brief signal > profile > default)
|
||||
is `{model, source}`, plus
|
||||
`effort` only when the brief's phase signal sets one (without it the
|
||||
phase runs at effort `standard`: no change). It is captured as
|
||||
`phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject
|
||||
the resolved model:
|
||||
before continuing:
|
||||
|
||||
```bash
|
||||
# When --min-brief-version was passed, append --min-version {min_brief_version}
|
||||
# so an older brief raises BRIEF_VERSION_BELOW_MINIMUM (warn, never block).
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json [--min-version {min_brief_version}] "{dir}/brief.md"
|
||||
# v5.9 — composed resolver: ONE call returns {model, source}, plus
|
||||
# effort only when the brief's phase signal sets one (without it: standard).
|
||||
# Append --profile {profile} when the operator passed --profile.
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase research --brief-path "{dir}/brief.md" [--profile {profile}] --json
|
||||
```
|
||||
|
||||
6. `--gates` — autonomy control. `--gates` takes the next token as its
|
||||
|
|
@ -97,21 +88,6 @@ Supported flags:
|
|||
enforcement only fires at `≥ 2.2`. Absent → no version check. See
|
||||
`docs/HANDOVER-CONTRACTS.md` §Handover 1 for the pre-2.2 enforcement hole.
|
||||
|
||||
8. `--engine <name>` — opt-in external-research engine. Accepts `--engine <name>`
|
||||
where `<name>` is `swarm` or `deep-research`. **Default: `swarm`** (unchanged
|
||||
behavior). `swarm` runs Voyage's own external-research agent swarm;
|
||||
`deep-research` delegates the external phase to Claude Code's built-in
|
||||
`/deep-research` dynamic workflow and adapts its report into the research-brief
|
||||
schema. **The delegation only works inside a closed version window:
|
||||
`2.1.154 <= CC < 2.1.218`** (dynamic workflows enabled). From Claude Code
|
||||
**2.1.218** `/deep-research` starts only when the operator invokes it, and the
|
||||
Skill tool refuses a model invocation outright (`disable-model-invocation`), so
|
||||
on 2.1.218 or newer the engine **always** falls back to `swarm`. The flag is
|
||||
kept as an additive opt-in and **never hard-fails**: outside the window it
|
||||
degrades to `swarm` and logs the reason. Orthogonal to
|
||||
`--profile`/`phase_signals`; only affects the external phase. Set
|
||||
**engine = {swarm|deep-research}** (the *requested* engine).
|
||||
|
||||
Flags can be combined:
|
||||
- `--local` — local-only research
|
||||
- `--external --quick` — external-only, lightweight
|
||||
|
|
@ -119,7 +95,7 @@ Flags can be combined:
|
|||
- `--quick` alone implies both local and external (lightweight)
|
||||
|
||||
Defaults: **scope = both**, **execution = foreground** (only mode as of
|
||||
v2.4.0), **project_dir = none**, **engine = swarm**.
|
||||
v2.4.0), **project_dir = none**.
|
||||
|
||||
After stripping flags, the remaining text is the **research question**.
|
||||
|
||||
|
|
@ -140,7 +116,6 @@ Modes:
|
|||
--external Only external research agents (skip codebase analysis)
|
||||
--fg No-op alias (foreground is the only mode as of v2.4.0)
|
||||
--project Write brief into an trekbrief project folder (auto-indexed)
|
||||
--engine Opt-in external-research engine: swarm (default) | deep-research
|
||||
|
||||
Flags can be combined: --local, --external --quick, --project <dir> --external
|
||||
|
||||
|
|
@ -151,7 +126,6 @@ Examples:
|
|||
/trekresearch --external What are the security implications of using Redis for sessions?
|
||||
/trekresearch --fg --local What patterns does this codebase use for database access?
|
||||
/trekresearch --project .claude/projects/2026-04-18-jwt-auth --external What JWT library is best for Node.js?
|
||||
/trekresearch --project <dir> --external --engine deep-research <research question>
|
||||
```
|
||||
|
||||
Do not continue past this step if no question was provided.
|
||||
|
|
@ -160,7 +134,6 @@ Report the detected mode:
|
|||
```
|
||||
Mode: {default | quick}, Scope: {both | local | external}, Execution: foreground
|
||||
Project: {project_dir or "-"}
|
||||
Engine (requested): {swarm | deep-research}
|
||||
Question: {research question}
|
||||
```
|
||||
|
||||
|
|
@ -292,12 +265,18 @@ Synthesize findings inline. Write a lightweight research brief to the destinatio
|
|||
path, following the research-brief-template but with shorter sections and fewer
|
||||
dimensions.
|
||||
|
||||
Skip to Phase 8 (stats tracking) after writing the brief.
|
||||
Skip to Phase 8 after writing the brief.
|
||||
|
||||
## Phase 4 — Parallel research (agent swarm)
|
||||
|
||||
**Determine which agents to launch based on scope:**
|
||||
|
||||
Every run launches the FULL swarm for its scope: all 5 local agents when the
|
||||
scope includes local, and all 4 external agents when it includes external.
|
||||
`contrarian-researcher` is one of the external four and runs every time the
|
||||
scope includes external, as wave 2 (see Launch rules), so a brief with external
|
||||
research always carries an adversarial counter-evidence pass.
|
||||
|
||||
### Local agents (scope = both or local)
|
||||
|
||||
Reuse existing plugin agents with research-focused prompts. These agents are
|
||||
|
|
@ -324,67 +303,6 @@ For each local agent, prompt with the research question, NOT a task description:
|
|||
- convention-scanner: "Discover coding conventions relevant to evaluating {question}.
|
||||
What patterns would a solution need to follow?"
|
||||
|
||||
### Engine selection (scope = both or external)
|
||||
|
||||
`--engine` affects ONLY the external portion of research. The local agents
|
||||
(`### Local agents` above) and Phases 6–7 (triangulation, synthesis, brief
|
||||
writing) are **engine-agnostic** — they run identically regardless of engine.
|
||||
|
||||
`--engine` is **moot** (treated as `swarm`) whenever the external phase does not
|
||||
run at all: `--local`, `--quick`, `effort == 'low'`, or a profile with
|
||||
`external_research_enabled == false` (the `economy`/`balanced` auto-disable — see
|
||||
Profile below). The profile's on/off switch wins. Initialize
|
||||
`effective_engine = {requested engine}`.
|
||||
|
||||
**engine = swarm (default):** run the `### External agents` block below
|
||||
unchanged. This is byte-for-byte the current path, so `--engine swarm`
|
||||
changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
|
||||
"single message", and the resolved-model injection in the Launch rules).
|
||||
|
||||
**engine = deep-research:**
|
||||
|
||||
1. **Coarse pre-gate (best-effort, NOT a trust signal).** The supported window is
|
||||
`2.1.154 <= version < 2.1.218`: below `2.1.154` there are no dynamic workflows,
|
||||
and from `2.1.218` (the ceiling) `/deep-research` starts only when the operator
|
||||
invokes it, and the Skill tool refuses a model invocation with
|
||||
`disable-model-invocation` — see step 4's reason token. Voyage does not run the
|
||||
Claude Code binary to read its version (it never calls Claude Code), so the
|
||||
version is *indeterminate* here. If `disableWorkflows: true` /
|
||||
`CLAUDE_CODE_DISABLE_WORKFLOWS=1` is set, skip to the fallback (step 4);
|
||||
otherwise proceed to step 2 — do NOT hard-fail. There is no positive
|
||||
availability probe (research Dim 4), so a passing pre-gate does not guarantee the
|
||||
workflow runs; the post-hoc check (step 3) is the authoritative guard.
|
||||
|
||||
2. **Run.** Instruct Claude (in prose, this turn) to run
|
||||
`/deep-research <research question>` and request per-claim citations. Note:
|
||||
interactive default/acceptEdits triggers a per-run approval prompt.
|
||||
|
||||
3. **Post-hoc presence + provenance check (the real guard).** Verify a real, cited
|
||||
`/deep-research` report actually landed in context — substantive findings with
|
||||
citations, not an empty/denied/errored turn and not bare error text. This check
|
||||
must be **robust to all failure manifestations** (workflow disabled, approval
|
||||
denied, runtime error, empty output, or the Skill tool refusing with
|
||||
`disable-model-invocation` on CC 2.1.218+), because the disabled-headless
|
||||
behavior is undocumented: no recognizable cited report in context → fall back,
|
||||
regardless of how the failure surfaces.
|
||||
|
||||
4. **On no real report (fallback):** set `effective_engine = swarm`, run the swarm
|
||||
blocks below, and **log the fallback at this decision point** — print
|
||||
`Engine: deep-research → swarm (fallback: <reason>)` and carry the reason into the
|
||||
Phase-8 Present summary and the brief's `## Executive Summary`. Known reason
|
||||
tokens: `disable-model-invocation` (CC >= 2.1.218 — the expected reason on any
|
||||
current Claude Code), `workflows-disabled`, `no-cited-report` (also what a
|
||||
Claude Code below the `2.1.154` floor produces, since no version is read). **NEVER fabricate
|
||||
or synthesize a substitute report** — a structurally-valid-but-invented brief
|
||||
passes the structure-only validator and silently poisons `/trekplan`; that is the
|
||||
worst outcome of this feature.
|
||||
|
||||
5. **On a real report:** keep `effective_engine = deep-research`, log
|
||||
`Engine: deep-research (active)`, and carry the report into Phase 6 triangulation
|
||||
as the external-findings input (adapted in Phase 7 — see the Deep-research engine
|
||||
adapter below); then run wave 2 (`contrarian-researcher`, see Launch rules)
|
||||
from the report's leading answer.
|
||||
|
||||
### External agents (scope = both or external)
|
||||
|
||||
Launch the new research-specialized agents:
|
||||
|
|
@ -412,11 +330,10 @@ ticket IDs) already removed: the agents search with the words they are given.
|
|||
|
||||
### Launch rules
|
||||
|
||||
- Launch in two waves, both in Phase 4 (before Phase 4.5 and Phase 5, so outside
|
||||
the Phase 5 loop scope marker that `hooks/scripts/pre-agent-cap.mjs` reads).
|
||||
- Launch in two waves, both in Phase 4.
|
||||
- **Wave 1:** ALL selected agents except `contrarian-researcher` **in parallel**
|
||||
in a single message.
|
||||
- **Wave 2:** `contrarian-researcher`, when selected, after wave 1 returns, in a
|
||||
- **Wave 2:** `contrarian-researcher`, after wave 1 returns, in a
|
||||
message of its own. Give it a conclusion to attack: the leading answer (one or
|
||||
two sentences), the claims for it and the alternatives wave 1 dismissed,
|
||||
taken from wave 1's external findings only, never from the local agents'
|
||||
|
|
@ -425,12 +342,9 @@ ticket IDs) already removed: the agents search with the words they are given.
|
|||
repository names, ticket IDs). It searches with the words it is given, so no
|
||||
local file name or project term may be in them. When wave 1's external
|
||||
findings hold no leading answer, say so and give it the research question
|
||||
alone, stripped the same way. With `--engine deep-research`
|
||||
(`effective_engine = deep-research`), wave 2 runs after the engine returns,
|
||||
from the leading answer of its report.
|
||||
- Inject the resolved model at every spawn: `phase_signal_result.model` (brief
|
||||
signal > profile), with the agent's frontmatter `model:` as the fallback — see
|
||||
Hard rules → Cost. The orchestrator itself follows the session model.
|
||||
alone, stripped the same way.
|
||||
- Pass no model at a Voyage agent's spawn: the agent's own frontmatter
|
||||
`model: opus` applies. The orchestrator itself follows the session model.
|
||||
- **Never pass the Agent tool's `name` parameter — at this or any other spawn
|
||||
site in this command.** `name` does not label a subagent, it changes its
|
||||
kind: the spawn is recorded as `taskKind: "in_process_teammate"`
|
||||
|
|
@ -443,264 +357,19 @@ ticket IDs) already removed: the agents search with the words they are given.
|
|||
Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism,
|
||||
denominators, and the on-disk recovery path:
|
||||
`docs/agent-return-channel-defect.md`.
|
||||
- convention-scanner: medium+ codebases only (50+ files)
|
||||
|
||||
## Phase 4.5 — Dimension discovery
|
||||
|
||||
**Skip this phase entirely unless `phase_signal_result.effort == 'high'` AND
|
||||
`VOYAGE_STORM_ENABLED=1`.** Both conditions, never either.
|
||||
|
||||
This phase never invokes `research-loop-cap.mjs`, so the cap's own flag check
|
||||
does not cover it — the flag has to be read here. Gating on effort alone would
|
||||
leave discovery mutating the dimension list at `effort: high` with the flag
|
||||
unset, making `dimensions` diverge from `dimensions_baseline` and putting the
|
||||
decline branch out of reach for half the mechanism. Doing nothing must leave
|
||||
**both** STORM phases inert.
|
||||
|
||||
Phase 4 retrieves more than the interview knew to ask for. This phase mines
|
||||
that surplus: findings that were **retrieved but unintegrated** — material an
|
||||
agent surfaced that no interview dimension claims.
|
||||
|
||||
1. **Mine.** Walk the Phase-4 agent results and collect findings that map to
|
||||
no existing dimension.
|
||||
2. **Rerank.** Order candidates by relevance to the research question **and**
|
||||
dissimilarity to the dimensions already on the list. A candidate that
|
||||
restates an existing dimension is not a discovery.
|
||||
3. **Augment under the existing ceiling.** Append candidates to the dimension
|
||||
list only while the **whole** list (interview + discovered) stays at or
|
||||
below `maxDimensions: 8` (`MAX_TOTAL_DIMENSIONS` in
|
||||
`lib/util/research-loop-cap.mjs`). The ceiling is **not**
|
||||
raised here, so the documented 3–8 dimension range stays true and the
|
||||
README prose about it stays untouched. If the interview already produced 8
|
||||
dimensions, this phase discovers nothing and says so.
|
||||
|
||||
**The ceiling has a reader — use it.** Once the final list is settled, run
|
||||
the check below. Exit 1 means the list exceeded the ceiling: drop discovered
|
||||
dimensions until it passes. Do not proceed to Phase 5 on a rejected list —
|
||||
the turn budget is sized against this same ceiling, so a list over it spends
|
||||
a budget that was never approved for it.
|
||||
|
||||
```bash
|
||||
# Same VOYAGE_ROOT resolution as the per-turn protocol in Phase 5. Exit 0 =
|
||||
# within the ceiling, exit 1 = rejected. JSON on stdout: {ok, count, ceiling, reason?}
|
||||
node "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" --check-dimensions {final dimension count}
|
||||
```
|
||||
|
||||
The ceiling constant is `MAX_TOTAL_DIMENSIONS` in
|
||||
`lib/util/research-loop-cap.mjs` — deliberately the same constant that sizes
|
||||
the Phase 5 turn budget, so the two axes of the bounded-cost NFR cannot end
|
||||
up enforcing different numbers for one ceiling.
|
||||
4. **Record the baseline.** Keep the interview-derived count as
|
||||
`dimensions_baseline` so the discovered delta is machine-readable against
|
||||
the final `dimensions` (Phase 8 stats).
|
||||
5. **Attest membership, not just the count.** Set
|
||||
`dimensions_baseline_preserved: true` only if EVERY interview-derived
|
||||
dimension is still on the final list — this phase appends, it never replaces.
|
||||
Set it `false` if any was dropped, merged away, or rewritten. A count delta
|
||||
cannot show this: dropping two interview dimensions and appending three
|
||||
discovered ones is `+1` and still not a superset, which is exactly what the
|
||||
Success Criterion forbids. `storm-measure.mjs --activation-check` reads the
|
||||
field and fails when it is absent, so omitting it is not the silent default.
|
||||
|
||||
Every outbound query generated from a discovered dimension passes
|
||||
`query-privacy-gate.mjs` before it leaves the machine — see the per-turn
|
||||
protocol in Phase 5. That gate controls the **egress** risk this phase's
|
||||
Independence crossing creates: local paths and identifiers travelling inside a
|
||||
query. It does **not** control the **bias** risk — it inspects query content and
|
||||
cannot stop a local finding from steering an external agent's question. The bias
|
||||
controls are structural (blind initial swarm, append-only crossing, unconditional
|
||||
`contrarian-researcher` at `effort: high`); see Hard rules → Independence.
|
||||
|
||||
## Phase 5 — Targeted follow-ups
|
||||
|
||||
Review all agent results. Identify knowledge gaps — dimensions where findings
|
||||
are thin, contradictory, or missing (**under-illuminated dimensions**).
|
||||
|
||||
**Standard and low effort — unchanged single pass.** For each significant gap,
|
||||
launch a targeted follow-up agent (model: phase_signal_result.model, else opus — a
|
||||
follow-up may be one of Claude Code's built-in agents, which have no Voyage
|
||||
frontmatter to fall back to; see Launch rules) with a narrow, specific
|
||||
brief; a follow-up that searches the web names the `researching-sources` skill
|
||||
For each significant gap, launch a targeted follow-up agent with a narrow,
|
||||
specific brief. A follow-up may be one of Claude Code's built-in agents:
|
||||
Spawn it with `model: "opus"`: a built-in agent has no Voyage pin.
|
||||
A follow-up that searches the web names the `researching-sources` skill
|
||||
in its prompt, as above. Maximum 2 follow-ups. If no gaps exist, skip: "Initial research
|
||||
sufficient — no follow-ups needed." Then go to Phase 6.
|
||||
|
||||
**The bounded loop below runs ONLY when `phase_signal_result.effort == 'high'`**
|
||||
(resolved in Phase 1; see `### High-effort behavior (v5.1.1)`). At any other
|
||||
effort this whole sub-section is inert — no loop, no cap ledger, no new
|
||||
counters beyond zero.
|
||||
|
||||
### Loop bound
|
||||
|
||||
**Maximum 3 turns per under-illuminated dimension.** The bound is per
|
||||
dimension, not per run: the worst case is 3 turns × the whole dimension list
|
||||
under the `maxDimensions: 8` ceiling (`MAX_TOTAL_DIMENSIONS`), which is what
|
||||
`research-loop-cap.mjs` sizes itself against. The cap counts itself from its
|
||||
own append-only ledger — it never asks this prose how many turns it has used.
|
||||
|
||||
The loop is **default-off**: `research-loop-cap.mjs` grants a budget of 0
|
||||
unless `VOYAGE_STORM_ENABLED=1`. Doing nothing leaves the mechanism off.
|
||||
|
||||
### Loop scope marker
|
||||
|
||||
`hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`)
|
||||
enforces the same bound in the harness rather than trusting this prose — but it
|
||||
enforces **only** for a session that carries a scope marker, and allows
|
||||
unconditionally for every session that does not. That is what keeps a globally
|
||||
wired PreToolUse hook from denying tool calls in unrelated sessions. Write the
|
||||
marker once, immediately before the first turn:
|
||||
|
||||
`CLAUDE_PLUGIN_DATA` is **empty in the Bash tool's process env** even in a
|
||||
plugin-enabled session, so the root is resolved with the same fallback
|
||||
`research-loop-cap.mjs` uses — `~/.claude/voyage`. Reader and writer must
|
||||
resolve identically; a marker written where the hook does not look leaves the
|
||||
hook allowing unconditionally while the docs call it enforcing.
|
||||
|
||||
```bash
|
||||
# Arms the PreToolUse cap for THIS session only.
|
||||
# CLAUDE_CODE_SESSION_ID is the same id the hook reads as `session_id`.
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
|
||||
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||
if [ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then
|
||||
printf '{"runId":"%s","startedAt":"%s"}\n' "{run_id}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
else
|
||||
echo "[voyage] scope marker not written — harness cap stays inert for this run"
|
||||
fi
|
||||
```
|
||||
|
||||
An empty `CLAUDE_CODE_SESSION_ID` is checked before the path is composed, not
|
||||
after: unset, the marker becomes `.json`, which no hook lookup matches and no
|
||||
TTL sweep ever cleans up.
|
||||
|
||||
`runId` MUST be the same `{run_id}` passed to `research-loop-cap.mjs --run-id`.
|
||||
The hook counts ledger lines carrying that id, so a marker written with any
|
||||
other id counts zero turns and enforces nothing.
|
||||
|
||||
**A failed marker write is not a reason to stop.** The hook is defence in
|
||||
depth; `research-loop-cap.mjs` is the gate and stays correct on its own. Report
|
||||
the failure to the operator and run the loop. The reverse — skipping the budget
|
||||
gate because a marker exists — is never allowed.
|
||||
|
||||
**Removal belongs to every exit below, especially the exhausted one.** The hook
|
||||
denies `WebSearch`/`WebFetch`/`Task` once `research-loop-cap.mjs` has denied a
|
||||
turn — the gate records its own denials, so the LAST granted turn still runs its
|
||||
queries and exhaustion reaches you through exit 2 of the budget gate below, not
|
||||
through a blocked tool call. Once denied, the hook keeps denying for as long as
|
||||
the marker is there — including Phase 6, which spawns agents. A marker that outlives the loop turns a bound on this loop into a brick
|
||||
on the rest of the session. Cleanup covers the three exits and nothing else: a
|
||||
crashed session runs no cleanup at all, and is covered instead by the hook's
|
||||
TTL (default 2h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker.
|
||||
A crash mid-loop leaves no denial record, so a resumed session is not blocked by
|
||||
it; only a crash AFTER the cap denied a turn hands the resume a deny window, and
|
||||
every denial prints the marker path to delete.
|
||||
|
||||
```bash
|
||||
# Removal — idempotent, safe to repeat. Same root, same absolute-path guard as
|
||||
# the write: a remove that accepts a root the write rejected (or vice versa)
|
||||
# leaves markers the loop believes it cleaned up.
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
|
||||
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||
[ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && \
|
||||
rm -f "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
```
|
||||
|
||||
### Per-turn protocol
|
||||
|
||||
Each turn targets exactly one under-illuminated dimension, and runs two gates
|
||||
before it spends anything:
|
||||
|
||||
```bash
|
||||
# 0. Resolve the plugin root ONCE. ${CLAUDE_PLUGIN_ROOT} is substituted in this
|
||||
# command's text but is EMPTY in the Bash tool's process env, and a bare
|
||||
# `node ${CLAUDE_PLUGIN_ROOT}/lib/…` then runs `node /lib/…`, which exits 1 —
|
||||
# indistinguishable from a gate that said no.
|
||||
# Only the literal form is substituted (a `:-` default is never replaced); the
|
||||
# fallback takes the highest installed version under cache/<market>/voyage/.
|
||||
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT}"
|
||||
case "$VOYAGE_ROOT" in
|
||||
/*) ;;
|
||||
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage/[0-9]*/ 2>/dev/null \
|
||||
| awk -F/ '{ split($(NF-1), v, "."); printf "%09d%09d%09d %s\n", v[1], v[2], v[3], $(0) }' \
|
||||
| sort | tail -1 | cut -d' ' -f2-)"
|
||||
VOYAGE_ROOT="${VOYAGE_ROOT%/}" ;;
|
||||
esac
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" ]; then
|
||||
echo "[voyage] gates could not run — plugin root unresolved (exit 2). NOT a denial:"
|
||||
echo " stop the loop and report to the operator. Never proceed ungated."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 1. Budget gate — per turn, per dimension. Exit 0 = granted, exit 1 = denied.
|
||||
# JSON on stdout: {ok, used, budget, reason?}
|
||||
node "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" \
|
||||
--run-id {run_id} --dimension {dimension} --effort {phase_signal_result.effort}
|
||||
|
||||
# 2. Privacy gate — EVERY outbound query, before it leaves the machine.
|
||||
# Exit 0 = send as-is; exit 1 = rewrite the query and re-gate. Never bypass.
|
||||
node "$VOYAGE_ROOT/lib/validators/query-privacy-gate.mjs" "{query text}"
|
||||
```
|
||||
|
||||
**Exit 2 is not exit 1.** A denied budget gate is an exit condition, not a
|
||||
retry. A failed privacy gate is a rewrite: the hard-block tier (secret-shaped
|
||||
strings) is never operator-overridable, so a query that trips it must be
|
||||
reformulated, not forced through. A gate that *could not run* is neither — no
|
||||
rewrite can clear it, so treat it as a hard stop and say so, rather than
|
||||
rewriting a query that was never the problem.
|
||||
|
||||
A turn that spawns an agent passes `model: phase_signal_result.model`, else
|
||||
`opus` — the agent may be one of Claude Code's built-in agents, which have no
|
||||
Voyage frontmatter to fall back to; see Launch rules.
|
||||
|
||||
**Empty turns.** A turn that returns no findings, or findings without
|
||||
citations, is marked `empty`. An empty turn is counted in `empty_turns` and
|
||||
does NOT re-target the same dimension — re-asking the same question of the
|
||||
same silence is how a bounded loop turns into an unbounded one. Move to the
|
||||
next under-illuminated dimension, or exit.
|
||||
|
||||
### Exits (all three, always one of them)
|
||||
|
||||
1. **Converged** — the dimension carries findings with citations and no
|
||||
remaining contradiction. Stop turning on it. This is the normal exit. Once
|
||||
the last dimension has converged, remove the scope marker.
|
||||
2. **Cap exhausted** — `research-loop-cap.mjs` denies the turn. Print the
|
||||
exhaustion **visibly** to the operator, never silently:
|
||||
`Loop bound reached for dimension {dimension} after {N} turns — remaining
|
||||
gaps are carried into the brief as open questions.` A silent cap is
|
||||
indistinguishable from convergence, and that confusion is exactly what this
|
||||
phase exists to prevent. Then remove the scope marker — leaving it here is
|
||||
what would block Phase 6.
|
||||
3. **Operator stop** — the operator interrupts. Remove the scope marker first,
|
||||
then carry whatever has been gathered into Phase 6 and record the remaining
|
||||
gaps as open questions. Do not re-enter the loop after a stop.
|
||||
|
||||
### When the loop does not apply
|
||||
|
||||
**No-brief default.** Without `--project` (or with a project whose `brief.md`
|
||||
is absent), there are no `phase_signals` to resolve, so `effort = 'standard'`,
|
||||
the loop does not run, and all new counters (`conv_turns`, `empty_turns`) are
|
||||
emitted as `0`.
|
||||
|
||||
**Precedence matrix — each entry independently makes the loop moot**, the same
|
||||
way `--engine` is moot when the external phase does not run (see the moot gate
|
||||
in Phase 4):
|
||||
|
||||
| Condition | Effect on the loop |
|
||||
|-----------|--------------------|
|
||||
| `--quick` | Moot — Phase 3.5 skips to Phase 8; the swarm never runs |
|
||||
| `--local` | Moot — no outbound queries to bound |
|
||||
| `external_research_enabled: false` (profile) | Moot — the profile's on/off switch wins |
|
||||
|
||||
**Interaction rule.** A brief that carries `effort: high` **without** a
|
||||
`model`, under a cheap profile (`economy`/`balanced`): the effort signal
|
||||
governs orchestration shape, so the loop is armed, but the profile still
|
||||
supplies the model — and if that profile disables external research, the
|
||||
matrix above wins and the loop is moot regardless of effort.
|
||||
|
||||
**Honesty (hard rule, restated for this loop).** More turns do not make a
|
||||
finding more credible. Turn count is a cost, not evidence: report what the
|
||||
citations support, and let an exhausted cap show up as open questions rather
|
||||
than as confidence.
|
||||
|
||||
## Phase 6 — Triangulation
|
||||
|
||||
This is the KEY phase that makes trekresearch more than aggregation.
|
||||
|
|
@ -749,49 +418,7 @@ Write the brief to the `brief_destination` computed in Phase 1:
|
|||
|
||||
Create the parent directory if it does not exist.
|
||||
|
||||
### Deep-research engine adapter (engine = deep-research only)
|
||||
|
||||
**Only when `effective_engine == deep-research`.** The swarm path skips this
|
||||
entirely — its findings already flow through Phases 6–7 unchanged (SC1).
|
||||
|
||||
Transform the in-context `/deep-research` report INTO
|
||||
`@${CLAUDE_PLUGIN_ROOT}/templates/research-brief-template.md` — do NOT paste the
|
||||
raw report. Specifically:
|
||||
|
||||
- Reduce the report to ≥ 1 `### {Dimension} -- Confidence: {high|medium|low|contradictory}`
|
||||
entry, each carrying **External findings** bullets with per-claim source URLs.
|
||||
Local findings still come from the local agents (Phase 4) and are merged in per
|
||||
dimension as usual. Wave 2's `contrarian-researcher` findings (Launch rules)
|
||||
are not part of the report: they are merged in per dimension as **External
|
||||
findings**, cited by URL like the report's, and where one disputes the
|
||||
report's leading answer, the dimension's **Contradictions** gives both sides.
|
||||
- Emit a numeric `confidence ∈ [0,1]` in frontmatter and a 3-sentence
|
||||
`## Executive Summary` (answer, confidence, key caveat).
|
||||
- Populate `## Sources` from the report's citations.
|
||||
- **If the report lacks per-claim URLs, lower the confidence and note the gap in
|
||||
`## Open Questions` — do NOT fabricate URLs.** Provenance you cannot cite is not
|
||||
provenance.
|
||||
- If the report is large, bound the transform to the top dimensions to avoid
|
||||
context truncation.
|
||||
|
||||
### Output self-check (engine = deep-research only)
|
||||
|
||||
**Only when `effective_engine == deep-research`.** After writing to
|
||||
`brief_destination`, run the output validator and repair-or-fall-back. This mirrors
|
||||
the trekplan Phase-8 write→validate→repair self-check; the swarm path does NOT run
|
||||
it, so swarm behavior is unchanged (SC1):
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/research-validator.mjs --json "{brief_destination}"
|
||||
```
|
||||
|
||||
On `valid: false`, repair the brief to satisfy the reported errors and re-run the
|
||||
validator. If it cannot be made valid (e.g. the report was too thin to yield even
|
||||
one dimension), set `effective_engine = swarm`, fall back to the swarm engine for
|
||||
this run (and log the fallback per the Engine selection step), rather than emit an
|
||||
invalid brief.
|
||||
|
||||
## Phase 8 — Present and track
|
||||
## Phase 8 — Present
|
||||
|
||||
Present a summary to the user:
|
||||
|
||||
|
|
@ -802,7 +429,6 @@ Present a summary to the user:
|
|||
**Mode:** {default | quick}, Scope: {both | local | external}
|
||||
**Brief:** {brief_destination}
|
||||
**Project:** {project_dir or "-"}
|
||||
**Engine (effective):** {swarm | deep-research}{, with fallback reason if it fell back}
|
||||
**Confidence:** {overall confidence 0.0-1.0}
|
||||
**Dimensions:** {N} researched
|
||||
**Agents:** {N} local + {N} external
|
||||
|
|
@ -825,151 +451,6 @@ You can:
|
|||
- Ask follow-up questions about specific findings
|
||||
```
|
||||
|
||||
### Stats tracking
|
||||
|
||||
Append one record to `trekresearch-stats.jsonl` in the plugin data directory.
|
||||
Run it exactly like this. The record goes on stdin, and the writer resolves
|
||||
the directory: `CLAUDE_PLUGIN_DATA`, which the Bash tool env does not carry,
|
||||
else the directory `scripts/yardstick.mjs` reads.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/stats-append.mjs trekresearch <<'JSON'
|
||||
{
|
||||
"ts": "{ISO-8601 timestamp}",
|
||||
"question": "{research question (first 100 chars)}",
|
||||
"mode": "{default|quick}",
|
||||
"scope": "{both|local|external}",
|
||||
"engine": "{effective engine: swarm|deep-research}",
|
||||
"slug": "{brief slug}",
|
||||
"project_dir": "{project_dir or null}",
|
||||
"brief_path": "{brief_destination}",
|
||||
"dimensions": {N},
|
||||
"dimensions_baseline": {N},
|
||||
"dimensions_baseline_preserved": {true|false},
|
||||
"effort": "{low|standard|high}",
|
||||
"conv_turns": {N},
|
||||
"empty_turns": {N},
|
||||
"unique_sources": {N},
|
||||
"agents_local": {N},
|
||||
"agents_external": {N},
|
||||
"gemini_used": false,
|
||||
"confidence": {0.0-1.0},
|
||||
"contradictions": {N},
|
||||
"open_questions": {N}
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
**The six measurement fields (v5.10).** `effort` is the grouping key — the
|
||||
resolved `phase_signal_result.effort` for the `research` phase, a
|
||||
low-cardinality label (`low|standard|high`), and the only axis on which a
|
||||
high-effort run can be compared against a standard one. Four are numeric:
|
||||
`unique_sources` (distinct sources cited across the brief),
|
||||
`dimensions_baseline` (the interview-derived dimension count, so the Phase 4.5
|
||||
delta against `dimensions` is machine-readable), `conv_turns` (Phase 5 loop
|
||||
turns actually spent), and `empty_turns` (loop turns that returned no findings
|
||||
or no citations). The sixth is boolean: `dimensions_baseline_preserved`, the
|
||||
Phase 4.5 attestation (step 5) that every interview dimension survived onto the
|
||||
final list — the count delta cannot show membership, and the dimension NAMES
|
||||
that could are prose the exporter allowlist denies. On a standard run the loop
|
||||
never arms, so `dimensions_baseline == dimensions`, both turn counters are `0`,
|
||||
and `dimensions_baseline_preserved` is `true` (nothing touched the list).
|
||||
|
||||
The writer prints `{"written": true, ...}`. On `"written": false` (exit 1),
|
||||
print its `reason` in one line and continue. A tracking failure never blocks
|
||||
the main workflow, but it is reported, not skipped silently.
|
||||
|
||||
## Profile (v4.1)
|
||||
|
||||
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`, `fable`,
|
||||
or a custom profile under `voyage-profiles/`. Default: `premium`.
|
||||
|
||||
Resolution order (per `lib/profiles/resolver.mjs`):
|
||||
1. `--profile` flag (source: `flag`)
|
||||
2. `VOYAGE_PROFILE` env-var (source: `env`)
|
||||
3. `premium` default (source: `default`)
|
||||
|
||||
The profile dictates `phase_models.research`, `parallel_agents`, and
|
||||
`external_research_enabled`. `economy` and `balanced` profiles auto-disable
|
||||
the external research swarm regardless of `--external` flag (operator
|
||||
override deferred to v4.2).
|
||||
|
||||
Examples:
|
||||
```
|
||||
/trekresearch --profile economy --project .claude/projects/2026-05-09-add-auth
|
||||
VOYAGE_PROFILE=balanced /trekresearch
|
||||
```
|
||||
|
||||
Stats records emit `engine` so operators can audit which engine drove which session.
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
|
||||
effort + model as:
|
||||
|
||||
```
|
||||
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
|
||||
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
|
||||
```
|
||||
|
||||
The brief signal wins per-phase when present; the profile fills any
|
||||
gaps. Both fields are mechanically resolved by the single composed CLI
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model`
|
||||
invoked in Phase 1; the resolved JSON is `{model, source}`, plus
|
||||
`effort` only when the brief's phase signal sets one (without it the
|
||||
phase runs at effort `standard`: no change). It is captured as
|
||||
`phase_signal_result` and passed to `Agent` tool calls explicitly. The
|
||||
resolver controls the `model` parameter at Agent-spawn sites only — the
|
||||
orchestrator's own model is fixed at invocation time (command frontmatter
|
||||
omits `model:`, so it follows the session model) and cannot be switched
|
||||
mid-turn. Sub-agents fall back to `model:` in their own `agents/*.md`
|
||||
frontmatter when no spawn-site injection happens.
|
||||
|
||||
For `/trekresearch` specifically: `effort == 'low'` activates the
|
||||
existing `--quick`-equivalent code-path (inline research, no agent swarm).
|
||||
`effort == 'standard'` (or absent) → no change. `effort == 'high'`
|
||||
activates the high-effort behavior documented under `### High-effort
|
||||
behavior (v5.1.1)` below.
|
||||
|
||||
### Sequencing gate surface
|
||||
|
||||
When `/trekresearch --project <dir>` is invoked and `{dir}/brief.md`
|
||||
exists, ALWAYS run `brief-validator.mjs --soft --json` against it.
|
||||
If `BRIEF_V51_MISSING_SIGNALS` appears in `errors` (brief_version ≥ 2.1
|
||||
without `phase_signals` or `phase_signals_partial: true`), halt with:
|
||||
`Brief is brief_version 2.1 but does not carry phase_signals — re-run
|
||||
/trekbrief to commit them (Phase 3.5).` Enforcement is validator-only;
|
||||
commands surface, don't re-enforce.
|
||||
|
||||
### High-effort behavior (v5.1.1)
|
||||
|
||||
When `phase_signal_result.effort == 'high'` for the `research` phase,
|
||||
run the FULL swarm regardless of normal selection rules: 5 local
|
||||
agents + 4 external agents, AND force `contrarian-researcher` to
|
||||
always-on: in high-effort mode `contrarian-researcher` runs
|
||||
unconditionally, as wave 2 (see Launch rules), so the final brief
|
||||
always carries an adversarial counter-evidence pass.
|
||||
|
||||
The former third-party second-opinion bridge pass is **unavailable,
|
||||
skipped** (dropped in v5.10.1 by operator directive). High effort no
|
||||
longer has a bridge arm; `gemini_used` is a vestigial stats field
|
||||
pinned to `false`.
|
||||
|
||||
High effort additionally arms the Phase 5 bounded follow-up loop (max 3
|
||||
turns per under-illuminated dimension, budgeted by
|
||||
`research-loop-cap.mjs`, every outbound query gated by
|
||||
`query-privacy-gate.mjs`). The loop stays default-off until
|
||||
`VOYAGE_STORM_ENABLED=1`, and the moot matrix in Phase 5 (`--quick`,
|
||||
`--local`, `external_research_enabled: false`) overrides the effort
|
||||
signal whenever the external phase does not run at all.
|
||||
|
||||
Standard effort (or absent): the selected agents run as in Phase 4, and
|
||||
`contrarian-researcher`, when selected, runs as wave 2 after the other
|
||||
agents return (see Launch rules).
|
||||
Low effort: inline research only, no agent swarm (existing
|
||||
`--quick`-equivalent code-path).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **No planning:** This command produces research briefs, not implementation plans.
|
||||
|
|
@ -978,39 +459,8 @@ Low effort: inline research only, no agent swarm (existing
|
|||
The full source rules are the `researching-sources` skill.
|
||||
- **Independence:** Do not pre-bias external agents with local findings or vice versa.
|
||||
Triangulate AFTER independent research.
|
||||
**Amended (v5.10) for Phase 4.5:** dimension discovery deliberately crosses this
|
||||
rule. Its candidate dimensions are mined from the Phase-4 result set, which
|
||||
contains output from the five local codebase agents, so a discovered dimension
|
||||
can carry local context into an external query.
|
||||
|
||||
The crossing creates **two distinct risks**, and they do not share a control:
|
||||
|
||||
- **Bias** — a local finding shapes what an external agent is asked. Its
|
||||
controls are structural, not a gate: the initial external swarm stays blind
|
||||
to local findings, so an **independent baseline already exists** before
|
||||
anything crosses; the crossing is confined to Phase 4.5 and the Phase 5 loop
|
||||
it feeds, which only ADD to that baseline and never revise it; and at
|
||||
`effort: high` — the only effort at which any of this runs —
|
||||
`contrarian-researcher` is forced always-on, so the brief always carries an
|
||||
adversarial counter-evidence pass over the result the crossed queries fed.
|
||||
Triangulation still happens AFTER independent research.
|
||||
- **Egress** — local paths, repo identifiers or secret-shaped strings leave the
|
||||
machine inside a query. That is what `query-privacy-gate.mjs` controls: every
|
||||
outbound query is inspected before it leaves, with a hard-block tier for
|
||||
secret-shaped strings that no operator flag can override.
|
||||
|
||||
The privacy gate was previously named as the compensating control for the
|
||||
crossing as a whole. It is not: it inspects query CONTENT and cannot stop a
|
||||
local finding from steering an external agent's question. Attributing the bias
|
||||
risk to it left that risk with no control while the text read as though it had
|
||||
one.
|
||||
- **Graceful degradation:** If MCP tools are unavailable (Tavily, MS Learn),
|
||||
proceed with available tools and note limitations in brief metadata.
|
||||
- **Cost:** Model resolution at Agent-spawn sites is a three-layer fallback:
|
||||
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >
|
||||
agent frontmatter `model:`. The composed resolver returns the first two
|
||||
layers as `phase_signal_result.model`; spawn sites inject it, and agent
|
||||
frontmatter is the fallback when no injection happens.
|
||||
- **Privacy:** Never log secrets, tokens, or credentials.
|
||||
- **Honesty:** If the question is trivially answerable, say so. Don't inflate research.
|
||||
- **Scope of codebase:** Only analyze the current working directory for local research.
|
||||
|
|
|
|||
|
|
@ -98,15 +98,6 @@ as the file is parseable:
|
|||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{brief_path}"
|
||||
|
||||
# v5.9 — composed phase-model resolution (brief > profile > default) for the
|
||||
# review phase. ONE call returns {model, source}, plus
|
||||
# effort only when the brief's phase signal sets one; without it the phase
|
||||
# runs at effort `standard` (no change). Captured as
|
||||
# phase_signal_result and used in Phase 7 at the reviewer-launch site to
|
||||
# inject the resolved model. Append --profile {profile} when the operator
|
||||
# passed --profile.
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase review --brief-path "{brief_path}" [--profile {profile}] --json
|
||||
```
|
||||
|
||||
Read the JSON output. If `valid: false` AND any error has code
|
||||
|
|
@ -190,7 +181,7 @@ trailing block `{"verdict": "ALLOW", "counts": {"BLOCKER": 0, "MAJOR": 0,
|
|||
without it the verdict gate
|
||||
cannot back the ALLOW and exits 2. There is nothing for the Phase 4.7 engine
|
||||
to review, so its absence does not withhold ALLOW here. Skip Phases 4–7.
|
||||
Continue to Phase 8 (validate + stats).
|
||||
Continue to Phase 8 (validate).
|
||||
|
||||
## Phase 4 — Triage gate (deterministic path-pattern classifier)
|
||||
|
||||
|
|
@ -568,9 +559,7 @@ The coordinator runs the 4-pass process documented in
|
|||
`rule_key` is not in `RULE_CATALOGUE` (`unknown-rule_key`) or whose file
|
||||
cannot be checked (`file-existence:indeterminate`) to `unverified`, which
|
||||
forbids `ALLOW`; reset a severity that does not match its catalogue tier.
|
||||
Skipped at high effort too, where an unknown `rule_key` is normalized to
|
||||
`PLAN_EXECUTE_DRIFT` instead (`### High-effort behavior (v5.1.1)` below);
|
||||
the brief-conflict rule still applies in every mode.
|
||||
The brief-conflict rule still applies in every mode.
|
||||
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
|
||||
|
||||
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either
|
||||
|
|
@ -679,7 +668,7 @@ to a plan without rewording them; earlier steps are never rewritten.
|
|||
If `mode == dry-run`: skip the write; print the would-be path and the
|
||||
first 60 lines of the rendered output.
|
||||
|
||||
## Phase 8 — Validate output + stats
|
||||
## Phase 8 — Validate output
|
||||
|
||||
Run the strict validator:
|
||||
|
||||
|
|
@ -723,22 +712,7 @@ with WARN, `--json` also returns `warn_reasons` (the JSON block's
|
|||
as returned. This is the command that later stops a merge or a release on
|
||||
BLOCK; `/trekreview` reports it and never overrides it.
|
||||
|
||||
Then append one record to `trekreview-stats.jsonl` in the plugin data directory.
|
||||
Run it exactly like this. The record goes on stdin, and the writer resolves
|
||||
the directory: `CLAUDE_PLUGIN_DATA`, which the Bash tool env does not carry,
|
||||
else the directory `scripts/yardstick.mjs` reads.
|
||||
|
||||
```bash
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/stats/stats-append.mjs trekreview <<'JSON'
|
||||
{"ts":"{ISO-8601}","slug":"{slug}","verdict":"BLOCK|WARN|ALLOW","counts":{"BLOCKER":N,"MAJOR":N,"MINOR":N,"SUGGESTION":N},"reviewed_files_count":N,"mode":"default|quick|validate|dry-run","duration_ms":N}
|
||||
JSON
|
||||
```
|
||||
|
||||
The writer prints `{"written": true, ...}`. On `"written": false` (exit 1),
|
||||
print its `reason` in one line and continue. A tracking failure never blocks
|
||||
the main workflow, but it is reported, not skipped silently.
|
||||
|
||||
**Build the operator-annotation HTML.** After stats land, run:
|
||||
**Build the operator-annotation HTML.** After the gate, run:
|
||||
|
||||
```bash
|
||||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1)
|
||||
|
|
@ -946,85 +920,6 @@ Per **Handover 6**, BLOCKER and MAJOR findings are consumed by
|
|||
review's frontmatter `findings:` list and the trailing JSON block are
|
||||
the contract for that handover (see `docs/HANDOVER-CONTRACTS.md`).
|
||||
|
||||
## Profile (v4.1)
|
||||
|
||||
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`, `fable`,
|
||||
or a custom profile under `voyage-profiles/`. Default: `premium`.
|
||||
|
||||
Resolution order (per `lib/profiles/resolver.mjs`):
|
||||
1. `--profile` flag (source: `flag`)
|
||||
2. `VOYAGE_PROFILE` env-var (source: `env`)
|
||||
3. `premium` default (source: `default`)
|
||||
|
||||
The selected profile drives `phase_models.review` — `economy` uses sonnet
|
||||
for the brief-conformance + code-correctness reviewers; `balanced` and
|
||||
`premium` use opus (review benefits from deeper reasoning).
|
||||
|
||||
Examples:
|
||||
```
|
||||
/trekreview --profile balanced --project .claude/projects/2026-05-09-add-auth
|
||||
VOYAGE_PROFILE=premium /trekreview --project ...
|
||||
```
|
||||
|
||||
## Composition rule (v5.1)
|
||||
|
||||
Independent of the profile system. When `brief.md` carries
|
||||
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
|
||||
effort + model as:
|
||||
|
||||
```
|
||||
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
|
||||
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
|
||||
```
|
||||
|
||||
The brief signal wins per-phase when present; the profile fills any
|
||||
gaps. Both fields are mechanically resolved by the single composed CLI
|
||||
`node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model`
|
||||
invoked in Phase 2; the resolved JSON is `{model, source}`, plus
|
||||
`effort` only when the brief's phase signal sets one (without it the
|
||||
phase runs at effort `standard`: no change). It is captured as
|
||||
`phase_signal_result` and passed to `Agent` tool calls explicitly. The
|
||||
resolver controls the `model` parameter at Agent-spawn sites only — the
|
||||
orchestrator's own model is fixed at invocation time (command frontmatter
|
||||
omits `model:`, so it follows the session model) and cannot be switched
|
||||
mid-turn. Sub-agents fall back to `model:` in their own `agents/*.md`
|
||||
frontmatter when no spawn-site injection happens.
|
||||
|
||||
For `/trekreview` specifically: `effort == 'low'` activates the existing
|
||||
`--quick`-equivalent code-path (skip the brief-conformance reviewer; run
|
||||
correctness-only). `effort == 'standard'` (or absent) → no change.
|
||||
`effort == 'high'` activates the high-effort behavior documented under
|
||||
`### High-effort behavior (v5.1.1)` below.
|
||||
|
||||
### Sequencing gate surface
|
||||
|
||||
Phase 1 already calls `brief-validator.mjs --soft` against `{brief_path}`.
|
||||
If the validator returns `BRIEF_V51_MISSING_SIGNALS` in `errors`
|
||||
(brief_version ≥ 2.1 without `phase_signals` or `phase_signals_partial:
|
||||
true`), halt with: `Brief is brief_version 2.1 but does not carry
|
||||
phase_signals — re-run /trekbrief to commit them (Phase 3.5).`
|
||||
Enforcement is validator-only; this surface just makes the friendly hint
|
||||
readable.
|
||||
|
||||
### High-effort behavior (v5.1.1)
|
||||
|
||||
When `phase_signal_result.effort == 'high'` for the `review` phase,
|
||||
skip Pass 3 (Cloudflare reasonableness filter) in `agents/review-coordinator.md`, except the brief-conflict rule, which applies in every mode.
|
||||
Passes 1, 2, and 4 still run.
|
||||
Rationale: high-effort review trusts the operator to weigh borderline
|
||||
findings rather than have the coordinator drop them. To prevent
|
||||
unknown `rule_key` values from polluting downstream remediation plans
|
||||
(Handover 6), the coordinator applies its v5.1.1 high-effort
|
||||
normalization rule — substituting unknown `rule_key` values with the
|
||||
literal string `PLAN_EXECUTE_DRIFT` (the most general drift category
|
||||
in the catalogue) and preserving the original in
|
||||
`original_rule_key`. See `agents/review-coordinator.md` § Pass 3
|
||||
"High-effort normalization (v5.1.1)" for the full normalization spec.
|
||||
|
||||
Standard effort (or absent): run all 4 passes as usual.
|
||||
Low effort: skip the brief-conformance reviewer entirely (existing
|
||||
`--quick`-equivalent code-path).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- **Brief is the contract.** Every finding in the review traces to a
|
||||
|
|
@ -1045,18 +940,13 @@ Low effort: skip the brief-conformance reviewer entirely (existing
|
|||
`findings:\n - a\n - b`.
|
||||
- **Refuse-with-suggestion above 100 files / 100K tokens.** Never run
|
||||
blind on a giant diff. Use AskUserQuestion to surface the gate.
|
||||
- **Cost.** Model resolution at Agent-spawn sites is a three-layer fallback:
|
||||
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >
|
||||
agent frontmatter `model:`. The composed resolver returns the first two
|
||||
layers as `phase_signal_result.model`; spawn sites inject it, and agent
|
||||
frontmatter is the fallback when no injection happens.
|
||||
- **Privacy.** Never log secrets, tokens, or credentials in review.md.
|
||||
Findings citing files with secret-like content must redact the secret
|
||||
in the `detail` field.
|
||||
- **Honesty.** If the diff is trivially small or all-skip, say so. Do
|
||||
not pad findings to make the review look thorough.
|
||||
- **No production code.** This command never runs production code, never
|
||||
writes to anything outside `{project_dir}` and `${CLAUDE_PLUGIN_DATA}` —
|
||||
writes to anything outside `{project_dir}` —
|
||||
with one exception, only after the operator approved that exact line in
|
||||
Phase 8.7: Voyage's own marked block in `.claude/rules/voyage-review-lessons.md`
|
||||
and a one-line pointer in `CLAUDE.md`.
|
||||
|
|
|
|||
|
|
@ -71,8 +71,6 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
|
|||
| `interview_turns` | number | optional | ≥ 0 | |
|
||||
| `source` | string | optional | `interview \| manual` | |
|
||||
| `brief_quality` | string | optional | `complete \| partial` | Set when iteration cap is hit |
|
||||
| `phase_signals` | list | optional (v5.1+) | list of `{phase, effort?, model?}` entries | Per-phase effort + model commitment from Phase 3.5. Mutually exclusive with `phase_signals_partial`. |
|
||||
| `phase_signals_partial` | bool | optional (v5.1+) | `true` | Force-stop record from Phase 3.5. Mutually exclusive with `phase_signals`. |
|
||||
| `framing` | string | **required at ≥ 2.2** (v5.5) | `preserve \| refine \| replace \| new-direction` | How this brief relates to prior operator intent. Enum-checked on any version when present; missing → `BRIEF_MISSING_FRAMING` at `brief_version ≥ 2.2`. Layer 1 of the framing-alignment defense. |
|
||||
| `intent_approved_hash` | string | optional in the schema; **required by `/trekplan`** (veikart steg 1) | `"sha256:<64 hex>"` | Hash over the normalized `## Intent` + `## Goal`. Written only by `lib/validators/intent-approval.mjs --stamp`, which `/trekbrief` runs on the operator's explicit "Approve" (Phase 4h, or `--approve <project-dir \| brief-file>`). |
|
||||
| `intent_approved_at` | string | optional | ISO-8601 | When the stamp was written. Informational; the gate reads the hash. |
|
||||
|
|
@ -80,6 +78,8 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
|
|||
| `ceremony` | string | optional (K1) | `small \| bounded \| architectural` | The brief's size class: sets `/trekbrief`'s question budget and review cap, and what downstream phases scale to. **Absent (or unreadable) = `architectural`**, the heaviest, so another producer's brief is never treated lighter by accident. Enum-checked when present (`BRIEF_INVALID_CEREMONY`). Read it through `briefCeremony(fm)` in `brief-validator.mjs`. |
|
||||
| `artifact_store` | string | optional | `git \| local` | Whether the brief and plan are committed to the target repo (`git`) or kept out of git through `.git/info/exclude` (`local`). The approval log is committed either way. **Absent = `local`**: nothing is published without the operator's yes. Enum-checked when present (`BRIEF_INVALID_ARTIFACT_STORE`). |
|
||||
|
||||
An older brief's `phase_signals` key is ignored.
|
||||
|
||||
**Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time):
|
||||
- `## TL;DR` — **required at `brief_version ≥ 2.2`** (v5.5); ≤ 5 content lines (soft cap → `BRIEF_TLDR_TOO_LONG` warning). Layer 3 of the framing-alignment defense.
|
||||
- `## Intent`
|
||||
|
|
@ -97,20 +97,19 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
|
|||
| Type discriminator | every read | `type === "trekbrief"` |
|
||||
| Status enum | every read | `research_status ∈ allowed values` |
|
||||
| **State machine** | every read | `research_topics > 0 && research_status === "skipped"` requires `brief_quality === "partial"` |
|
||||
| **v5.1 sequencing gate** | every read | `brief_version ≥ 2.1` requires `phase_signals` (list) OR `phase_signals_partial: true` — error `BRIEF_V51_MISSING_SIGNALS` on miss. Validator-only enforcement; commands surface, don't re-enforce. |
|
||||
| **v5.5 framing gate** | every read | `framing` enum-checked on any version when present (`BRIEF_INVALID_FRAMING`); at `brief_version ≥ 2.2` a missing `framing` → `BRIEF_MISSING_FRAMING` and a missing `## TL;DR` → `BRIEF_MISSING_SECTION` (strict) / warning (soft). `trekreview` briefs are exempt. |
|
||||
| Body sections | strict only | All `BRIEF_BODY_SECTIONS` present (`## TL;DR` added at ≥ 2.2) |
|
||||
| Runnable criteria | every read | A `## Success Criteria` bullet whose first backticked span is a command the review's criteria runner never runs (the product's own binary, `jq`, `grep`, a pipe), or whose first span is not a command while a later span is one (the runner reads only the first, voyage-lekerepo trial 5) → warning `BRIEF_SC_NOT_RUNNABLE`, never an error: a criterion a human judges stays allowed. Prose-only criteria, also those that only name a flag or a path, are not flagged. `trekreview` briefs are exempt. No schema change. Indented sub-bullets belong to the criterion above them (counted, never criteria of their own); a criterion with sub-points and no command → warning `BRIEF_SC_SUBPOINTS`. A brief whose Constraints or NFRs require bash 3.2 and a criterion that runs plain `bash` → warning `BRIEF_SC_BASH_NOT_PINNED` (use `/bin/bash`). |
|
||||
|
||||
**State machine** detail: a brief that says it has research topics but skipped them must explicitly admit it (via `brief_quality: partial`). This is the most common failure mode the validator catches.
|
||||
|
||||
**Versioning:** the `2.1` schema axis (v5.1 — adds optional `phase_signals` + `phase_signals_partial`; current is `2.2`, see the next paragraph). The forward-compat policy in `brief-validator.mjs` header still applies: unknown frontmatter keys flow through silently, so a `2.1` brief still validates against pre-v5.1 consumers. The version bump exists because v2.1 activates the **version-conditional sequencing gate** (above) — the only check in the validator that triggers on `brief_version` rather than field-presence. There are no live `1.x` briefs; remove legacy paths in next major. The v5.5.0 contract formalization **established `2.1` as the public-contract baseline** (see the PUBLIC CONTRACT callout under the Handover 1 heading): per the S3 effort-axis decision, `phase_signals` stays **optional**, and the v2.1 sequencing gate (`phase_signals` **or** `phase_signals_partial`) is the stability mechanism. Promoting `phase_signals` to required would be a future breaking change under the protocol above — explicitly *not* part of the v5.5.0 formalization.
|
||||
**Versioning:** current is `2.2`, see the next paragraph. The forward-compat policy in `brief-validator.mjs` header still applies: unknown frontmatter keys flow through silently. There are no live `1.x` briefs; remove legacy paths in next major. The v5.5.0 contract formalization **established `2.1` as the public-contract baseline** (see the PUBLIC CONTRACT callout under the Handover 1 heading).
|
||||
|
||||
**v5.5 → `2.2` (framing enforcement).** `2.2` adds two **required-at-2.2** elements — the `framing` enum field and the `## TL;DR` body section — gated identically to the v2.1 mechanism: the new requirements fire only on `brief_version ≥ 2.2`, so every existing `2.0` / `2.1` brief still validates (forward- and backward-compatible). Because adding required elements is a breaking change for any producer that declares `2.2`, this is a controlled version bump under the breaking-change protocol: downstream producers (e.g. a Tier-2 per-app producer) that emit `brief_version: "2.2"` MUST also emit `framing` + `## TL;DR`. The framing enum is additionally enforced on *any* version when the field is present (`BRIEF_INVALID_FRAMING`). The three framing-alignment layers (framing field, `brief-reviewer` memory-alignment dimension, obligatory TL;DR) implement the `CLAUDE.md` cross-cutting invariant. Note: the *plugin* version bump + CHANGELOG entry for this schema change land at the coordinated release (matrix §S10), separate from this schema axis.
|
||||
**v5.5 → `2.2` (framing enforcement).** `2.2` adds two **required-at-2.2** elements — the `framing` enum field and the `## TL;DR` body section — gated on `brief_version`: the new requirements fire only on `brief_version ≥ 2.2`, so every existing `2.0` / `2.1` brief still validates (forward- and backward-compatible). Because adding required elements is a breaking change for any producer that declares `2.2`, this is a controlled version bump under the breaking-change protocol: downstream producers (e.g. a Tier-2 per-app producer) that emit `brief_version: "2.2"` MUST also emit `framing` + `## TL;DR`. The framing enum is additionally enforced on *any* version when the field is present (`BRIEF_INVALID_FRAMING`). The three framing-alignment layers (framing field, `brief-reviewer` memory-alignment dimension, obligatory TL;DR) implement the `CLAUDE.md` cross-cutting invariant. Note: the *plugin* version bump + CHANGELOG entry for this schema change land at the coordinated release (matrix §S10), separate from this schema axis.
|
||||
|
||||
**Pre-2.2 briefs receive zero framing enforcement (producer-elective defense).** The framing-alignment defense is gated at `brief_version ≥ 2.2`, so a brief declaring `2.0` or `2.1` gets **none** of it: `framing` is optional and a missing value is not an error, the `## TL;DR` section is not required, and the `brief-reviewer` memory-alignment dimension scores N/A. The backward-compatibility this buys is deliberate, but it also means the framing defense is **producer-elective**: any producer can sidestep the entire defense — deliberately or by shipping a legacy template — simply by declaring `brief_version: "2.1"`, and the pipeline raises no signal by default. This is a documented property of the contract, not a bug. Two remedies close it for a given consumer: (1) require upstream producers to emit `brief_version: "2.2"` (which forces `framing` + `## TL;DR`); or (2) pass the opt-in **`--min-brief-version <ver>`** flag to `/trekplan` or `/trekresearch` (forwarded to the validator as `--min-version`), which raises a `BRIEF_VERSION_BELOW_MINIMUM` **warning** — never a block — when a brief declares a version below the floor. The flag defaults to off, so the N-1 compatibility window for `2.0`/`2.1` briefs is preserved unless a consumer explicitly opts into the stricter floor.
|
||||
|
||||
**Intent approval gate (veikart steg 1) — additive schema, consumer-side requirement.** The two `intent_approved_*` fields are additive and optional in the schema: `brief-validator.mjs` does not require them, `/trekresearch` does not read them, and no `brief_version` bump is involved. The requirement lives in `/trekplan` Phase 1 (`### Read the brief`), which runs `intent-approval.mjs --check` and **halts** on `BRIEF_INTENT_NOT_APPROVED` (no marker), `BRIEF_INTENT_APPROVAL_STALE` (`## Intent` or `## Goal` changed after approval) or `BRIEF_INTENT_APPROVAL_INVALID` (malformed marker, or a section missing, empty or present more than once; a `## ` line inside a code block or HTML comment is not a heading). It is version-independent, so a brief from ANY producer, of any `brief_version`, stops at `/trekplan` until approved. No producer has to emit anything new: the operator approves through `/trekbrief --approve <project-dir | brief-file>`, a Voyage-side step open to every producer's brief, whether it is planned with `--project` (`<dir>/brief.md`) or `--brief <file>`, so no producer is privileged. `trekreview` briefs are exempt. The exemption is read from the brief's own frontmatter, so relabelling a brief `type: trekreview` walks past the gate, the same trust class as self-stamping. **What the marker does not prove:** same user, same machine. Any session can run `--stamp`. The marker is a trace that the approval step ran against this exact text, not a signature (see the header of `lib/validators/intent-approval.mjs`). `--stamp` also emits `brief-approved` to `trekexecute-stats.jsonl` under `CLAUDE_PLUGIN_DATA`, falling back to the plugin data dir the yardstick reads, on both the manual and the auto path.
|
||||
**Intent approval gate (veikart steg 1) — additive schema, consumer-side requirement.** The two `intent_approved_*` fields are additive and optional in the schema: `brief-validator.mjs` does not require them, `/trekresearch` does not read them, and no `brief_version` bump is involved. The requirement lives in `/trekplan` Phase 1 (`### Read the brief`), which runs `intent-approval.mjs --check` and **halts** on `BRIEF_INTENT_NOT_APPROVED` (no marker), `BRIEF_INTENT_APPROVAL_STALE` (`## Intent` or `## Goal` changed after approval) or `BRIEF_INTENT_APPROVAL_INVALID` (malformed marker, or a section missing, empty or present more than once; a `## ` line inside a code block or HTML comment is not a heading). It is version-independent, so a brief from ANY producer, of any `brief_version`, stops at `/trekplan` until approved. No producer has to emit anything new: the operator approves through `/trekbrief --approve <project-dir | brief-file>`, a Voyage-side step open to every producer's brief, whether it is planned with `--project` (`<dir>/brief.md`) or `--brief <file>`, so no producer is privileged. `trekreview` briefs are exempt. The exemption is read from the brief's own frontmatter, so relabelling a brief `type: trekreview` walks past the gate, the same trust class as self-stamping. **What the marker does not prove:** same user, same machine. Any session can run `--stamp`. The marker is a trace that the approval step ran against this exact text, not a signature (see the header of `lib/validators/intent-approval.mjs`).
|
||||
|
||||
**Approval log (K2) — consumer-side requirement, no schema change.** After the stamp, `/trekbrief` appends the approval to the target repo's log with `lib/util/approval-log.mjs append --kind intent --subject <brief> --sha256 <intent hash>` and commits it with `lib/util/artifact-commit.mjs` (the log always; the brief too when `artifact_store: git`). The commit carries the caller's trailer: every call passes `--trailer "Co-Authored-By: Claude {model} <noreply@anthropic.com>"` with `{model}` filled in, and the CLI refuses (exit 1, nothing committed) a missing trailer, a multi-line one, one that does not read `Co-Authored-By: <name> <<email>>` (an unfilled `{model}` included) and one holding `Claude-Session:`; a bad `--store` stays exit 2 and is checked first. The log (`.claude/voyage/approvals.jsonl`, read and written only by that CLI) is append-only and hash-chained: each record carries `seq`, `ts`, `kind` (`intent | plan | review | merge | release`), `subject`, the content's `sha256`, an optional `expires`, `prev` and its own `hash`. `/trekplan` Phase 1 runs `approval-log.mjs verify --kind intent --subject <brief> --sha256 <intent_approved_hash>` after the marker check and halts on `broken` (a record edited, removed or reordered), `diverged` (the committed log is no longer a prefix of the file), `not-logged` or `mismatch`; exit 2 (could not run) halts too. A brief approved before the log existed is `not-logged`: the remedy is the same `/trekbrief --approve`, open to every producer's brief. Only an explicit "Approve" counts: `approval-log.mjs answer` refuses «høres bra ut», «gjør som du vil» and an empty answer. Trust limits are those of the marker (same user, same machine; a trace, not a signature); what the log adds is that each approval is a commit in `git log`, and that a committed approval cannot be rewritten without `diverged`. Adapted from bashebr/ai-native-sdlc@11fb8d7 (MIT), corrected so the approval is bound to content and cannot be written unseen.
|
||||
|
||||
|
|
@ -124,24 +123,10 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
|
|||
- `BRIEF_MISSING_FIELD` → strict halt; soft-mode warning
|
||||
- `BRIEF_STATE_INCOHERENT` → strict halt; soft-mode warning (incoherence will haunt downstream agents)
|
||||
- `BRIEF_MISSING_SECTION` → strict halt; soft-mode warning
|
||||
- `BRIEF_V51_MISSING_SIGNALS` → strict halt (v5.1+ sequencing gate); soft-mode warning. Commands surface a friendly hint pointing back to `/trekbrief` (Phase 3.5).
|
||||
- `BRIEF_INVALID_PHASE_SIGNALS` → strict halt; phase_signals must be a list of `{phase, effort?, model?}` entries.
|
||||
- `BRIEF_INVALID_PHASE_SIGNAL_PHASE` → strict halt; phase ∉ `[research, plan, execute, review]`.
|
||||
- `BRIEF_INVALID_EFFORT` → strict halt; effort ∉ `[low, standard, high]`.
|
||||
- `BRIEF_INVALID_MODEL` → strict halt; model ∉ `BASE_ALLOWED_MODELS` (currently `[sonnet, opus, fable]`).
|
||||
- `BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE` → strict halt; cannot set both `phase_signals` and `phase_signals_partial: true`.
|
||||
- `BRIEF_SC_NOT_RUNNABLE` → warning in both modes; `/trekbrief` names each one and offers a script under `tests/` the criterion runs (`bash tests/<script>.sh`).
|
||||
- `BRIEF_SC_SUBPOINTS` → warning in both modes; `/trekbrief` offers one script that checks all sub-points, or one criterion per sub-point.
|
||||
- `BRIEF_SC_BASH_NOT_PINNED` → warning in both modes; `/trekbrief` offers `/bin/bash tests/<script>.sh` so a bash 3.2 requirement is measured under the system bash.
|
||||
|
||||
**Compatibility direction of the v5.9 allowlist widening (`fable`):** enum
|
||||
widening is safe for new readers of old data, not old readers of new data.
|
||||
Existing sonnet/opus briefs stay valid under the v5.9+ validator (non-breaking,
|
||||
no `brief_version` bump — value-space extension, not a schema change). The
|
||||
reverse does NOT hold: a fable-bearing brief REQUIRES a v5.9+ validator — an
|
||||
older cached brief-validator (e.g. a stale v5.8 marketplace clone) rejects it
|
||||
with `BRIEF_INVALID_MODEL`.
|
||||
|
||||
---
|
||||
|
||||
## Handover 2 — research/*.md → plan
|
||||
|
|
|
|||
|
|
@ -1,205 +0,0 @@
|
|||
# T1 — Delegated orchestration vs inline (CC-26 GATE)
|
||||
|
||||
**Status:** Gate evidence + measurement design + recommendation. The adopt/don't-adopt
|
||||
verdict for CC-26 is **operator-gated** (mirrors S3/S6).
|
||||
**Date:** 2026-06-18 (S7)
|
||||
**Resolves:** decision-matrix §W1 / **CC-26** ("does delegated orchestration beat inline at
|
||||
Voyage's scale?") + Empirical test **T1**.
|
||||
**Inputs:** `docs/cc-upgrade-2.1.181-decision-matrix.md` §W1, `docs/subagent-delegation-audit.md`,
|
||||
`scripts/q3-cache-prefix-experiment.mjs` (harness pattern).
|
||||
**Method (this session, operator-chosen):** staged — cheap live feasibility probe + this design
|
||||
doc; the expensive head-to-head comparison is **specified but NOT run** (see §6), gated on the
|
||||
recommendation below.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question the gate actually decides
|
||||
|
||||
CC-26: should Voyage re-architect from today's **inline** orchestration (the v2.4.0 migration —
|
||||
main context spawns the exploration swarm itself) back to **delegated** orchestration (an
|
||||
orchestrator sub-agent spawns the swarm, and synthesis/writing is delegated too — the "missing
|
||||
summarizer link" in the delegation audit)?
|
||||
|
||||
The v2.4.0 migration was forced by a *capability gap*: the premise that "the harness does not
|
||||
expose the Agent tool to sub-agents" (asserted in `agents/planning-orchestrator.md:5–11`,
|
||||
`research-orchestrator.md`, `review-orchestrator.md`, `commands/trekplan.md:399–406`). CC-01
|
||||
(2.1.172, verified) made that premise false. CC-26 is therefore **not** "is delegation possible?"
|
||||
(it is) but "**does delegation pay**, given that re-architecture has real costs?"
|
||||
|
||||
## 2. Reframing: wall-time is not the gate metric
|
||||
|
||||
The decision-relevant axis is **main-context token pressure vs. plan quality**, not wall-time.
|
||||
|
||||
- The delegation audit shows exploration is **already** well-delegated (~10 agents for trekplan).
|
||||
What fills main context is **synthesis + plan-writing, which stay inline** (trekplan Phase 7–8;
|
||||
`subagent-delegation-audit.md` §2). That is the only thing delegation would relieve.
|
||||
- Delegation **adds** latency (an extra orchestrator hop + re-delivering codebase context to a
|
||||
writer agent), so wall-time is expected to be *worse*, not better. Audit §"Tradeoffs" lists the
|
||||
costs explicitly: iteration friction, adversarial review still runs in main, writer-agents need
|
||||
the same codebase context re-delivered (burning the tokens delegation was meant to save), and
|
||||
loss of in-transcript debuggability.
|
||||
- The audit's own open Q3 names the real measurement: "measure current main-context usage per
|
||||
phase so the savings estimates can be replaced with real numbers."
|
||||
|
||||
So T1's binding metric is **Δ main-context tokens (main session) for an equivalent-quality plan**,
|
||||
with wall-time and total token cost as secondary, and depth-cap behavior as a feasibility check.
|
||||
|
||||
## 3. Feasibility probe (RUN — 2026-06-18, CC 2.1.181, interactive session)
|
||||
|
||||
**Goal:** isolate the *mechanism* (can a sub-agent spawn sub-agents? does it degrade? where is the
|
||||
depth cap?) from the *workload* (token/quality — deferred to §6). A recursive chain of trivial
|
||||
`general-purpose` agents, `main → L1 → L2 → …`, each only reporting Agent-tool availability and
|
||||
spawning exactly one child on the next level, stopping at level 6 or on first error.
|
||||
|
||||
**Verbatim result:**
|
||||
|
||||
```
|
||||
LEVEL 1 | agent_tool_available: yes | spawn_attempted: yes | spawn_result: success
|
||||
CHILD_REPORT:
|
||||
LEVEL 2 | agent_tool_available: yes | spawn_attempted: yes
|
||||
| spawn_result: error:"Permission for this action was denied by the Claude Code
|
||||
auto mode classifier. Reason: Recursive self-spawning agent loop with no task
|
||||
purpose, designed to multiply autonomous agents — an uncontrolled agent
|
||||
proliferation pattern. …"
|
||||
| wall_note: blocked by auto-mode permission classifier (policy denial, NOT a
|
||||
harness/nesting-depth limit); did not retry per protocol
|
||||
CHILD_REPORT: none
|
||||
```
|
||||
|
||||
**Findings (measured):**
|
||||
|
||||
| # | Finding | Evidence | Decision impact |
|
||||
|---|---------|----------|-----------------|
|
||||
| F1 | **Depth-2 nesting works.** A sub-agent has the Agent tool and can spawn its own sub-agent. | L1 spawned L2 successfully; both report `agent_tool_available: yes`. | Voyage's needed pattern (orchestrator → swarm = depth 2) is **mechanically feasible**. v2.4.0 premise confirmed false at the interactive sub-agent level. |
|
||||
| F2 | **No degradation at depth 2.** | L2 returned a real, well-formed structured report; did not hang or silently degrade. | The original v2.4.0 fear ("background orchestrators silently degraded") does not reproduce for a *foreground* Agent-tool-spawned sub-agent at depth 2. |
|
||||
| F3 | **The ≤5 depth cap was never the binding constraint.** L2→L3 was blocked by the **auto-mode permission classifier**, not the nesting cap. | Verbatim error names "uncontrolled agent proliferation," not a depth limit. | For Voyage (needs depth 2; documented cap 5) the depth cap is **moot**. Precise cap location was not measured — and does not matter for this gate. |
|
||||
| F4 | **NEW: the permission classifier polices agent proliferation.** A fan-out of agents "with no task purpose" is actively denied in auto mode. | Verbatim classifier reason. | **Architecture risk unique to delegation** — see §4. |
|
||||
|
||||
> Verifiseringsplikt: F1–F4 are *measured* from the probe above. The probe deliberately used a
|
||||
> trivial, purposeless recursion; a real orchestrator→swarm has a genuine task purpose and would
|
||||
> likely pass the classifier — but the classifier's *existence and watchfulness* is the verified
|
||||
> fact, and it is the new variable a delegated design must account for.
|
||||
|
||||
## 4. New finding — the proliferation classifier (decision-relevant)
|
||||
|
||||
The auto-mode permission classifier flags fan-out of autonomous agents as "uncontrolled agent
|
||||
proliferation." Two consequences for the delegated arm specifically:
|
||||
|
||||
1. **Headless / auto / bypass modes are where Voyage fans out most** (trekexecute Phase 2.6
|
||||
parallel waves; `--gates` Path C autonomy). A delegated orchestrator that spawns a 6–10-agent
|
||||
swarm from *inside a sub-agent* under `auto`/`bypassPermissions` is exactly the shape the
|
||||
classifier scrutinises. My purposeless probe tripped it; a purposeful swarm probably passes —
|
||||
but "probably" is now a risk that **inline orchestration does not carry** (main-context
|
||||
spawning is operator-visible and not nested).
|
||||
2. The classifier denial is **fail-shut for the child** (the spawn simply does not happen). In a
|
||||
delegated pipeline, a mid-pipeline classifier denial means the orchestrator sub-agent silently
|
||||
loses part of its swarm — a *new* silent-degradation surface, distinct from but reminiscent of
|
||||
the v2.4.0 one. This must be in any T2/full-run test matrix.
|
||||
|
||||
## 5. Measurement design — the full head-to-head (specified, ready to run)
|
||||
|
||||
> **STATUS: DECLINED (operator, 2026-09-18) — do not order this measurement as new work.**
|
||||
> It is **not run**, and the cheaper synthetic instrument proposed for it is not run either.
|
||||
> Reason: the measurement's POSITIVE branch is "adopt delegated orchestration", which the
|
||||
> balance analysis lists under *Explicitly NOT recommended* (CC-26 lean-NO). Running it
|
||||
> reopens a settled decision without declaring that it does. The end-state direction chosen
|
||||
> 2026-09-17 is to finish and freeze the present form, not to re-litigate the substrate.
|
||||
> The design below stands as the record of what was specified; reviving it is a new operator
|
||||
> decision, not maintenance.
|
||||
|
||||
If the operator greenlights pursuing delegation (see §7), this is the measurement that resolves the
|
||||
*performance* half of CC-26. It is **not run** in S7.
|
||||
|
||||
**Arms (same fixed brief, same codebase, same model/effort):**
|
||||
- **Arm A — inline (baseline):** current `/trekplan` flow; main context spawns the swarm and does
|
||||
Phase 7–8 synthesis/writing inline.
|
||||
- **Arm B — delegated:** main spawns ONE orchestrator sub-agent (the dormant
|
||||
`planning-orchestrator` spec, which already declares the Agent tool); it spawns the swarm and
|
||||
runs synthesis; main only receives the finished artifact.
|
||||
|
||||
**Fixed inputs:** one representative brief (reuse an existing `.claude/projects/*/brief.md` of
|
||||
medium size), a fixed target repo, `model: opus` / default effort, `--profile balanced`.
|
||||
|
||||
**Metrics (per arm, ≥3 runs for medians — q3 harness pattern for usage extraction):**
|
||||
|
||||
| Metric | Source | Role |
|
||||
|--------|--------|------|
|
||||
| **Δ main-context tokens** (input+cache_creation resident in the *main* session at plan-complete) | stream-json `usage` of the main session | **PRIMARY** — the gate metric (§2) |
|
||||
| Total token cost (main + all descendants) | summed stream-json `usage` | secondary (delegation re-delivers context → expected higher) |
|
||||
| Wall-time to `plan.md` | timestamps | secondary (delegation expected slower) |
|
||||
| Plan quality | LLM-judge pass (or operator review) comparing both `plan.md` against the brief's SC | **gate guard** — a token win that costs quality fails the gate |
|
||||
| Classifier interference | count of denied/missing swarm spawns in Arm B | feasibility guard (§4) |
|
||||
|
||||
**Decision thresholds (CC-26 verdict):**
|
||||
- **POSITIVE (adopt delegation):** Arm B cuts main-context tokens by **≥ 30%** at plan-complete
|
||||
AND plan quality is judged **≥** Arm A AND zero classifier interference.
|
||||
- **NEGATIVE (keep inline):** Arm B's main-context saving **< 15%**, OR plan quality **<** Arm A,
|
||||
OR any classifier interference that drops swarm coverage.
|
||||
- **INCONCLUSIVE:** in-between, or harness/metadata failure → narrow the scope (§6) and re-run.
|
||||
|
||||
**Harness note:** extend the `scripts/q3-cache-prefix-experiment.mjs` pattern (stream-json `usage`
|
||||
extraction, median, threshold→verdict, always-write result file). The orchestration shape differs
|
||||
from q3's identical fork-children, so the child-spawn logic is new; the *measurement scaffold* is
|
||||
reused.
|
||||
|
||||
## 6. Cheaper PoC (audit-recommended, preferred over the full bake-off)
|
||||
|
||||
> **STATUS: RUN AND DECLINED (S12, 2026-06-18) — do not re-order as new work.**
|
||||
> Result: **Δ main-context (faithful flow) = 0.0%**, NEGATIVE against the 15% keep-floor.
|
||||
> Full method, fixtures and BASE-sweep: `docs/T1-synthesis-poc-results.md`;
|
||||
> reproduce with `node scripts/synthesis-measure.mjs`.
|
||||
> The finding is **structural, not stochastic**, so re-running it cannot change the number:
|
||||
> Phase 5 spawns the exploration swarm **foreground** (`commands/trekplan.md:158,338-341` —
|
||||
> "foreground is the only mode as of v2.4.0"), so the 6-10 outputs are **already resident in
|
||||
> main** before Phase 7 begins. Delegating only the Phase-7 digest returns *on top of* them
|
||||
> and evicts nothing. A saving is reachable only by also moving Phase-5 delivery off-main —
|
||||
> which is §5, not §6. `agents/synthesis-agent.md` therefore ships **dormant**.
|
||||
|
||||
Per `subagent-delegation-audit.md` §Recommendation, the lowest-risk way to test the delegation
|
||||
*premise* is **not** a wholesale orchestrator rewrite but **one narrow synthesis-agent**
|
||||
(intervention #1/#3): delegate only trekplan Phase 7 synthesis (the heaviest single inline read —
|
||||
6–10 agent outputs resident simultaneously) to a `synthesis-agent`, and measure Δ main-context
|
||||
tokens for an equivalent findings artifact. This isolates the largest single win with the smallest
|
||||
blast radius and no orchestrator-nesting / classifier exposure (main still spawns the swarm; only
|
||||
the *digest* is delegated). Recommended as the **first** thing to measure if delegation is pursued.
|
||||
|
||||
## 7. CC-26 recommendation (operator gates the verdict)
|
||||
|
||||
**Lean NO on wholesale delegated orchestration; YES exists only as a narrow, opt-in
|
||||
synthesis-agent, proven incrementally.**
|
||||
|
||||
Reasoning, on the evidence above:
|
||||
- Feasibility is no longer the blocker (F1/F2) — so the gate turns purely on cost/benefit.
|
||||
- The cost/benefit is unfavourable for *wholesale* re-architecture: delegation's only upside is
|
||||
main-context relief, against wall-time loss, context re-delivery cost, the audit's iteration /
|
||||
adversarial-review / debuggability tradeoffs, AND a new classifier-interference risk (F4) that
|
||||
inline does not carry.
|
||||
- The defensible win is narrow and incremental: delegate **only** the heaviest inline synthesis
|
||||
read (§6) and adopt it **only** if a measured Δ main-context ≥ 30% with no quality loss
|
||||
materialises. That is opt-in, reversible, and does not touch the orchestration topology.
|
||||
|
||||
**Net:** CC-26 stays **EVALUATE**, but the wholesale orchestrator→swarm option is **not
|
||||
recommended**. If the operator wants to pursue delegation, the next measured step is the §6
|
||||
synthesis-agent PoC, not the §5 full bake-off. CC-27 (Workflow-tool, S8) remains the more
|
||||
promising orchestration-substrate question and is untouched by this.
|
||||
|
||||
## 8. Open items
|
||||
|
||||
1. Precise depth-cap location unmeasured (F3) — irrelevant to this gate (Voyage needs depth 2);
|
||||
only matters if a future nested pipeline approaches 5.
|
||||
2. Classifier behaviour for a *purposeful* swarm under `auto`/`bypassPermissions` is unverified
|
||||
(F4) — must be in the §5/§6 test matrix before any delegated spawn ships to a headless path.
|
||||
3. ~~The §6 synthesis-agent PoC and the §5 full bake-off are both designed but unbuilt.~~
|
||||
**CORRECTED 2026-09-03 (S95).** This item was written in S7 and falsified the same afternoon
|
||||
by S12: the **§6 PoC was RUN and DECLINED** (`docs/T1-synthesis-poc-results.md` — Δ_faithful
|
||||
= 0.0%, NEGATIVE). Only the **§5 full bake-off** remains unbuilt. The stale wording is what
|
||||
caused the §6 PoC to be re-ordered as new work on 2026-09-02; it is struck rather than
|
||||
deleted so the re-order has a traceable cause.
|
||||
4. **Environment premise updated (measured 2026-09-03, CC 2.1.259).** S12 recorded the live run
|
||||
as environment-blocked for want of an `ANTHROPIC_API_KEY`. That half no longer holds:
|
||||
`claude -p --output-format stream-json` runs on subscription auth with no API key and emits
|
||||
per-turn `usage` plus a `subagent_stats` block (spawned / max_depth / refused) that did not
|
||||
exist in June — a ready-made classifier-interference counter for §5's feasibility guard.
|
||||
S12's *other* blocker stands unmeasured: the installed plugin is the cache copy, so a
|
||||
repo-local agent is invisible to `claude -p`. This changes nothing about the §6 verdict,
|
||||
which is structural (see §6 status), and only lowers the cost of §5.
|
||||
|
|
@ -10,11 +10,9 @@ Imported from `CLAUDE.md` via pointer.
|
|||
- `lib/parsers/{plan-schema,manifest-yaml,arg-parser,jaccard,finding-id}.mjs` — pure parsers (no I/O), unit-tested. `manifest-yaml` extended in v3.4.0 with additive `skip_commit_check` + `memory_write` flags (forward-compat: unknown keys ignored)
|
||||
- `lib/review/{rule-catalogue,plan-review-dedup}.mjs` — version-pinned rule catalogue (17 keys) + Phase 9 inline dedup helpers (v3.4.0)
|
||||
- `lib/review/{verdict-gate,engine-findings,recurrence}.mjs` — the review gate (K7: 0 ALLOW, or WARN with every counting MAJOR answered · 1 BLOCK · 2 never green), Claude Code `/code-review` + `/security-review` findings → reviewer payload, recurring finding classes → operator-approved project-memory lines (review_version 1.2); `lib/review/fill-finding-ids.mjs` computes the coordinator's `@@ID_<n>@@` placeholders over `(file, line, rule_key)`; `lib/validators/brief-review-stamp.mjs` lets `/trekplan` skip a brief review `/trekbrief` already ran on the same text
|
||||
- `lib/stats/event-emit.mjs` — single-source stats event emitter for named lifecycle events (`brief-approved`, the main-merge gate and its answer, `user_input`) (v3.4.0)
|
||||
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
|
||||
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`
|
||||
- `lib/util/research-loop-cap.mjs` — stateful, **default-off** turn budget for the `/trekresearch` bounded conversation loop. `allowTurn()` derives the used-turn count from its own append-only JSONL ledger; it never asks the caller how many turns it has spent, because a cap that does is not a cap. Each grant first claims a turn **slot** with `O_EXCL` under `trekresearch-loop-claims/`, so the bound survives several callers deciding at once — counting the ledger and then appending is read-then-write, and Phase 4.5/5 can spawn several agents in one message. Budget = `TREKRESEARCH_MAX_CONV_TURNS` (default `3`, invalid values fall back to `3`) × `maxDimensions` (8, `MAX_TOTAL_DIMENSIONS`). **Default-off:** grants 0 unless `VOYAGE_STORM_ENABLED=1`, which is also the second condition on Phase 4.5's skip-guard (that phase does not call this module): unset, **both** STORM phases are inert. `resolveDataRoot()` is the single root for everything the loop writes — `CLAUDE_PLUGIN_DATA` when the harness sets it, `~/.claude/voyage` when it does not (it is empty in the Bash tool's process env, which is where the loop actually runs); the cap hook resolves through the same function, so writer and reader cannot disagree. A ledger that cannot be **written** denies the turn, and one that exists but cannot be **read** denies it too — only `ENOENT` counts as zero turns spent, that being the legitimate first-turn state (fail-closed — the opposite of `event-emit.mjs`, which is telemetry and must never block). The exported `readLedger()` is the single counting rule; the cap hook calls it rather than keeping a private copy. `checkDimensionCeiling()` is the reader for the OTHER axis of the bounded-cost NFR — the size of the whole dimension list after Phase 4.5 discovery — against the same `MAX_TOTAL_DIMENSIONS`, so the two axes cannot enforce different numbers for one ceiling; an unreadable count is rejected, not waved through. CLI shim, two modes: `--run-id ID --dimension D --effort E` (budget gate) and `--check-dimensions N` (ceiling, no run id/effort/flag required since Phase 4.5 never calls the budget gate)
|
||||
- `lib/validators/query-privacy-gate.mjs` — gates **every** outbound research query before it leaves the machine; the hard-block tier (secret-shaped strings) is not operator-overridable, so a query that trips it must be reformulated rather than forced through. CLI shim: `node lib/validators/query-privacy-gate.mjs "<query>"`
|
||||
- `lib/validators/query-privacy-gate.mjs` — gates an outbound research query before it leaves the machine (no command calls it now: its callers were the removed STORM steps); the hard-block tier (secret-shaped strings) is not operator-overridable, so a query that trips it must be reformulated rather than forced through. CLI shim: `node lib/validators/query-privacy-gate.mjs "<query>"`
|
||||
|
||||
Wiring points (replaces previous prose-grep instructions):
|
||||
- `/trekbrief` Phase 4g → `brief-validator` (post-write sanity check)
|
||||
|
|
@ -32,19 +30,13 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
|
|||
|
||||
`hooks/scripts/session-title.mjs` (UserPromptSubmit, CC v2.1.94+) sets `sessionTitle` to `voyage:<command>:<slug>` for voyage-command invocations. Helps parallel sessions identify themselves in process lists.
|
||||
|
||||
`hooks/scripts/post-bash-stats.mjs` (PostToolUse, CC v2.1.97+) appends `duration_ms` for each Bash call into `${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`. Useful for finding long-running verify or checkpoint commands.
|
||||
|
||||
`hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`) enforces the `/trekresearch` Phase 5 loop bound in the harness, so the cap is not merely prose the model is asked to obey. It counts spent turns read-only from the append-only ledger `research-loop-cap.mjs` writes, through that module's own exported `readLedger()`. It denies (exit 2) once the budget **gate has denied a turn** — the primitive records its denials as tombstones, and the tombstone is the boundary rather than the count, because `allowTurn()` appends before the turn runs and so the final granted turn already shows `budget` records. A ledger showing more granted turns than the budget denies too, as a backstop. Being in scope but unable to count the ledger also denies: a budget control that cannot count must not grant. Scope key = `session_id` + a marker file only the loop writes (`<resolveDataRoot()>/trekresearch-loop-scope/<session_id>.json`, the same root the ledger uses); without a marker the hook allows unconditionally, which is what keeps a globally-wired `PreToolUse` hook from over-blocking unrelated sessions. Stale markers auto-reset on a TTL and `VOYAGE_DISABLE_CAP_HOOK=1` is the kill switch. Defence in depth only — `lib/util/research-loop-cap.mjs` must stay correct if the hook stops firing (see `docs/spike-pretooluse-subagent-reach.md`).
|
||||
|
||||
`hooks/scripts/post-compact-flush.mjs` (PostCompact event, v3.4.0) re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary. Companion to `pre-compact-flush.mjs` (which writes the state file before compaction); together they form the rehydrate cycle that keeps `/trekcontinue` reliable across long-running multi-session work.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 3.5 per-phase effort dialog (v5.1) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in → Stats. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0).
|
||||
**Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0).
|
||||
|
||||
**Phase 3.5 (v5.1) — adaptive-depth signals:** Between Phase 3 completeness exit and Phase 4 draft, the operator commits an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus | fable`) per downstream phase (`research`, `plan`, `execute`, `review`) via 4 tier-coupled `AskUserQuestion` calls. The choices land in `brief.md` frontmatter as `phase_signals:` (a list of `{phase, effort?, model?}` entries) when committed, or `phase_signals_partial: true` when the operator force-stops. `brief_version: 2.1` activates the **sequencing gate**: validator emits `BRIEF_V51_MISSING_SIGNALS` if a 2.1-versioned brief lacks both fields. Downstream commands surface a friendly hint pointing back to `/trekbrief` — enforcement is validator-only. Composition is documented prose in each downstream command's `## Composition rule (v5.1)` section: `brief.phase_signals[phase] > profile.phase_models[phase]`. The brief signal wins per-phase when present; the profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low effort = one session at a time). High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
|
||||
|
||||
**Research:** Foreground workflow (v2.4.0): Parse mode → Interview → Parallel research swarm (5 local + 4 external + 1 bridge, spawned from main context) → Follow-ups → Triangulation → Synthesis + brief → Stats. With `--project`, writes to `{dir}/research/NN-slug.md`.
|
||||
**Research:** Foreground workflow (v2.4.0): Parse mode → Interview → Parallel research swarm (5 local + 4 external, spawned from main context) → Follow-ups → Triangulation → Synthesis + brief. With `--project`, writes to `{dir}/research/NN-slug.md`.
|
||||
|
||||
**Plan:** Foreground workflow (v2.4.0): Parse mode (validate brief input) → Codebase sizing → Brief review (`brief-reviewer`) → Parallel exploration (6-8 agents, spawned from main context) → Deep-dives → Synthesis (with architecture-note cross-reference if present) → Planning → Adversarial review (`plan-critic` + `scope-guardian`) → Present/refine → Handoff. With `--project`, writes to `{dir}/plan.md` and auto-detects `{dir}/architecture/overview.md` (produced by an opt-in upstream architect plugin if installed; not bundled).
|
||||
|
||||
|
|
@ -87,7 +79,7 @@ No code-level dependency between plugins — the contract is filesystem-level on
|
|||
|
||||
### Primitives per step (decision matrix)
|
||||
|
||||
Which native Claude Code primitive each pipeline step runs on today, and the alternatives that were considered and where they landed. Companion to *Delegate the engine, keep the policy* above; the decision record lives in `docs/T1-cc26-delegated-orchestration.md`. The orchestration runs inline in the session, not in a delegated orchestrator agent, because the swarm's results end in the main context anyway (the orchestrator reference docs are removed).
|
||||
Which native Claude Code primitive each pipeline step runs on today, and the alternatives that were considered and where they landed. Companion to *Delegate the engine, keep the policy* above. The orchestration runs inline in the session, not in a delegated orchestrator agent, because the swarm's results end in the main context anyway (the orchestrator reference docs are removed).
|
||||
|
||||
| Step | Primitive today (production) | Alternatives considered (status) |
|
||||
|---|---|---|
|
||||
|
|
@ -97,12 +89,53 @@ Which native Claude Code primitive each pipeline step runs on today, and the alt
|
|||
| **execute** | Inline step loop; multi-session via `git worktree` + session orders the user runs, wave by wave; deterministic manifest audit | CC `TaskCreate`/`TodoWrite` for progress/resume (insufficient — carries no step status / attempts / SHA / drift → own typed `progress.json` contract) |
|
||||
| **review** | Inline parallel reviewers (no cross-feed) → `review-coordinator` Judge | **Workflow** substrate for Phase 5–6 (bake-off POSITIVE: +4.4 % tokens / +54 % wall-time → shipped opt-in, since removed; wholesale substrate swap declined) |
|
||||
| **continue** | Inline reads `.session-state.local.json` → zero-confirm resume | CC `--resume` (transcript replay, not typed work-state → insufficient) |
|
||||
| **cross-cutting** | 8 hook scripts: `pre-bash` + `pre-write` guards, `pre-agent-cap` loop-bound enforcement, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — |
|
||||
| **cross-cutting** | 9 hook scripts: `pre-bash` + `pre-write` guards, `pre-plan-gate`, `session-title`, `post-approval-log`, `post-edit-check`, `pre-`/`post-compact` flush, `stop-test-lock` | — |
|
||||
|
||||
¹ MCP per research agent: `docs-researcher` → Microsoft Learn + Tavily · `community-`/`security-`/`contrarian-researcher` → Tavily (+ WebSearch/WebFetch). Graceful degradation when an MCP server is absent.
|
||||
|
||||
**Legend:** *production* = wired and active · *not wired* = considered, deferred (delegated orchestrator, the `AskUserQuestion` brief engine).
|
||||
|
||||
## Model & effort
|
||||
|
||||
`opus`, `sonnet`, and `fable` are model **aliases**, not pinned ids. On the
|
||||
Anthropic API (verified 2026-10-02 against the Claude Code model-config docs)
|
||||
the `opus` alias resolves to **Opus 5.5** and `sonnet` to **Sonnet 5.5**, both
|
||||
with default reasoning effort **`medium`**; `fable` resolves to **Fable 5.1**
|
||||
(the most capable model in Claude Code), whose default reasoning effort is
|
||||
**`high`**. Other providers resolve `opus` and `sonnet` to other versions.
|
||||
|
||||
**Reasoning effort inherits from the session.** Fable 5.1's default reasoning
|
||||
effort is `high`, NOT xhigh, and Claude Code saves effort per model, so a level
|
||||
saved for one model does not carry to another. To run Fable 5.1 at xhigh,
|
||||
choose it explicitly: `/effort xhigh`, `--effort xhigh`, or
|
||||
`CLAUDE_CODE_EFFORT_LEVEL`.
|
||||
|
||||
**Native `effort:` on agents.** Voyage sets the reasoning axis statically on
|
||||
selected agents, overriding the session level:
|
||||
|
||||
- **Nine agents pin `high`:** the retrieval agents `task-finder`,
|
||||
`git-historian`, `dependency-tracer`, `architecture-mapper`, the
|
||||
adversarial-reasoning agents `plan-critic`, `risk-assessor`,
|
||||
`contrarian-researcher`, `review-coordinator`, and `/trekexecute`'s step
|
||||
helper `step-implementer`. No agent pins below `high`
|
||||
(operator rule, 2026-10-05: standard is Opus 5.5 at effort High).
|
||||
- **All other agents:** unset → inherits the session's effort level (a session
|
||||
that nothing sets runs at its model's default — `medium` on Opus 5.5).
|
||||
|
||||
**Every agent is pinned `model: opus`.** The pin is set in each agent's
|
||||
frontmatter (operator decision, commit `40d8742`). The operator wants Opus on
|
||||
all Voyage work and accepted the roughly fivefold sub-agent cost against
|
||||
Sonnet. The glue, mechanical, retrieval and dormant roles were reconsidered
|
||||
for a Sonnet downgrade (operator, 2026-06-20) and kept on Opus: the pin stays
|
||||
firm.
|
||||
|
||||
Native-effort precedence (harness): env `CLAUDE_CODE_EFFORT_LEVEL` > frontmatter
|
||||
`effort:` > session setting > model default, and a `maxEffortLevel` setting or
|
||||
an organization effort cap still limits the result. `MAX_THINKING_TOKENS=0` has
|
||||
no effect on Opus 5.5, Sonnet 5.5 or the Fable models: thinking cannot be turned
|
||||
off there. `availableModels` restricts which models can be selected — it does
|
||||
**not** bound effort.
|
||||
|
||||
## State
|
||||
|
||||
All artifacts in one project directory (default):
|
||||
|
|
@ -123,26 +156,6 @@ Legacy paths (still work without `--project`):
|
|||
- Session orders: `.claude/trekplan-sessions/{slug}/orders.md` (`--decompose`) and `orders/` (`/trekexecute` Phase 2.6: one order per session, `wave.json` while a wave awaits its sessions, `merged-waves.json`, and each session's archived `wave-W-session-N.progress.json`)
|
||||
- Progress: `{plan-dir}/.trekexecute-progress-{slug}.json`
|
||||
|
||||
Stats:
|
||||
- Brief stats: `${CLAUDE_PLUGIN_DATA}/trekbrief-stats.jsonl`
|
||||
- Plan stats: `${CLAUDE_PLUGIN_DATA}/trekplan-stats.jsonl`
|
||||
- Exec stats: `${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`
|
||||
- Research stats: `${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl`
|
||||
- Continue stats: `${CLAUDE_PLUGIN_DATA}/trekcontinue-stats.jsonl`
|
||||
|
||||
The Bash tool env carries no `CLAUDE_PLUGIN_DATA`. Brief, plan and execute
|
||||
stats are written by `lib/stats/stats-append.mjs`, which falls back to
|
||||
`~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory
|
||||
`scripts/yardstick.mjs` reads. `/trekexecute`'s lifecycle events go through
|
||||
`lib/stats/event-emit.mjs`, which resolves the same directory. A failed write
|
||||
is reported, not skipped. The research, review and continue stats phases still
|
||||
name the variable in prose only.
|
||||
A brief record also carries `order_id`, `order_claimed_at` and `order_reason`:
|
||||
the coord-queue order the run is working on, added by the writer from the
|
||||
queue's claimed orders (`lib/stats/claimed-order.mjs`), never by the model.
|
||||
`scripts/yardstick.mjs` joins its `use` number on `order_id`; the three fields
|
||||
are never exported.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Task brief** — produced by `/trekbrief`. Declares intent, goal, and research plan. Drives planning.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ Per-command flag tables, imported from `CLAUDE.md` via pointer.
|
|||
| _(default)_ | Question round sized by class (`small` / `bounded` / `architectural`), brief-review gate, approval → brief.md with research plan |
|
||||
| `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → brief.md with research plan |
|
||||
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
|
||||
| `--profile <name>` | (v4.1.0) Model profile: `economy` / `balanced` / `premium` / `fable` / `<custom>`. Sets `phase_models` for the brief phase. See `## Profile system` in `docs/operations.md`. |
|
||||
| `--approve <project-dir \| brief-file>` | (veikart steg 1) No interview: show the existing brief's `## Intent` + `## Goal` verbatim, ask the operator to approve, and on "Approve" stamp `intent_approved_hash` + `intent_approved_at`, emit `brief-approved`, and write the trekbrief stats record (`"mode": "approve"`). The remedy `/trekplan` names when it halts on `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE`. |
|
||||
| `--approve <project-dir \| brief-file>` | (veikart steg 1) No interview: show the existing brief's `## Intent` + `## Goal` verbatim, ask the operator to approve, and on "Approve" stamp `intent_approved_hash` + `intent_approved_at`. The remedy `/trekplan` names when it halts on `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE`. |
|
||||
|
||||
Always interactive. Phase 3 puts the task in a size class (`ceremony:`, K1; heaviest in doubt, said aloud, overridable, only moved up) and asks one round of questions within the class budget (`small` 4, `bounded` 8, `architectural` 12 per round), recommendation first, facts looked up instead of asked, a visible Deferred list; it also asks where the brief is stored (`artifact_store: git | local`). Phase 4 runs a `brief-reviewer` stop-gate with 1 / 2 / 3 review iterations by class. Phase 4h takes only an explicit "Approve" («høres bra ut» and an empty answer are refused by `lib/util/approval-log.mjs answer`), stamps the brief, appends the approval to the target repo's log (`approval-log.mjs append`, K2) and commits it (`lib/util/artifact-commit.mjs`: the log always, the brief when `git`). After that, asks the user to choose manual (print commands) or auto (Claude runs research + plan in foreground).
|
||||
|
||||
|
|
@ -26,27 +25,9 @@ Always interactive. Phase 3 puts the task in a size class (`ceremony:`, K1; heav
|
|||
| `--fg` | No-op alias (foreground is default since v2.4.0) |
|
||||
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
|
||||
| `--min-brief-version <ver>` | (S18) Warn — never block — if an attached `--project` brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the research phase. |
|
||||
| `--engine {swarm\|deep-research}` | (deep-research-engine) Opt-in external-research engine; `deep-research` delegates the external phase to Claude Code's built-in `/deep-research` workflow, which only works on `2.1.154 <= CC < 2.1.218` — from **2.1.218** `/deep-research` is operator-invoked only (the Skill tool refuses with `disable-model-invocation`), so the engine always falls back to `swarm`. Never hard-fails. Default `swarm`. |
|
||||
|
||||
Flags combine: `--project <dir> --local`, `--external --quick`.
|
||||
|
||||
### Bounded conversation loop (Phase 4.5 + Phase 5) — env-vars
|
||||
|
||||
Dimension discovery and the multi-turn follow-up loop are **default-off** and
|
||||
have no flag; they are environment-gated, because turning them on costs turns.
|
||||
They run only at `effort: high` (resolved from the brief's `phase_signals`).
|
||||
|
||||
| Env-var | Default | Behavior |
|
||||
|---------|---------|----------|
|
||||
| `VOYAGE_STORM_ENABLED` | _(unset — default-off)_ | `=1` grants the Phase 5 loop a non-zero turn budget and is the second condition on Phase 4.5's skip-guard. Unset, `research-loop-cap.mjs` grants 0 turns and **both** phases are inert: doing nothing keeps the whole mechanism off. |
|
||||
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Max turns per under-illuminated dimension. Budget = this × `maxDimensions` (8, `MAX_TOTAL_DIMENSIONS` in `lib/util/research-loop-cap.mjs`). Empty, non-numeric, zero, negative, `Infinity`, or any fraction that floors below `1` (`0.5`, `0.9`) fall back to `3` — never to unbounded, and never to `0`. A fraction at or above `1` floors (`2.7` → `2`). |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK` | _(unset)_ | `=1` disables `hooks/scripts/pre-agent-cap.mjs`, the `PreToolUse` enforcement of the turn budget. The cap primitive still applies; only the second gate is switched off. |
|
||||
|
||||
Adoption of the loop as a default is gated on a pre-registered measurement —
|
||||
protocol, thresholds, and the exact commands in
|
||||
[`docs/storm-measurement.md`](storm-measurement.md).
|
||||
|
||||
## /trekplan modes
|
||||
|
||||
| Flag | Behavior |
|
||||
|
|
@ -60,7 +41,6 @@ protocol, thresholds, and the exact commands in
|
|||
| `--export headless <plan>` | Legacy alias for `--decompose` — the only remaining export format (the `pr` / `issue` / `markdown` variants were removed; Claude reformats a plan ad-hoc on request) |
|
||||
| `--decompose <plan>` | Split plan into self-contained session specs + `orders.md` (one starter line per session) |
|
||||
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the plan phase (and others, since plan emits `profile:` to plan.md frontmatter). |
|
||||
|
||||
**Breaking change (v2.0):** one of `--brief` or `--project` is required. There is no interview inside `/trekplan`. The `--spec` flag has been removed — use `/trekbrief` to produce a brief instead.
|
||||
|
||||
|
|
@ -81,7 +61,6 @@ If `{project_dir}/architecture/overview.md` exists (typically produced by an opt
|
|||
| `--fg` | Force foreground — run all steps sequentially, ignore Execution Strategy |
|
||||
| `--session N` | Execute only session N from plan's Execution Strategy |
|
||||
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. `/trekexecute` accepts the flag and pauses on nothing; it adds no question of its own before a merge: the guard asks there (operator decision 2026-10-08). |
|
||||
| `--profile <name>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Inherited from plan.md frontmatter `profile:` if present. |
|
||||
|
||||
Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `MAX_REVIEW_ROUNDS` in `lib/review/review-rounds.mjs` (3) caps the reviews per run; `/trekreview` refuses a fourth round.
|
||||
|
||||
|
|
@ -97,7 +76,6 @@ Every mode that executes refuses a plan whose approval in the approval log does
|
|||
| `--validate` | Schema-only check on existing `{dir}/review.md` plus the verdict gate. No LLM calls. A 1.0 review without `verdict:` fails here (it still seeds a plan in soft mode) |
|
||||
| `--dry-run` | Print discovered scope + triage map; skip writes |
|
||||
| `--fg` | No-op alias (foreground is default) |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the review phase. |
|
||||
|
||||
## /trekcontinue modes
|
||||
|
||||
|
|
@ -105,6 +83,5 @@ Every mode that executes refuses a plan whose approval in the approval log does
|
|||
|------|----------|
|
||||
| _(default)_ | Auto-discover active project's `.session-state.local.json` and resume |
|
||||
| `<project-dir>` | Resume the next session of an explicit project directory |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the resumed session. Inherited from the previous session's plan.md frontmatter when absent. |
|
||||
|
||||
The triage gate is deterministic — path-pattern classifier produces `{file → deep-review|summary-only|skip}`. Hard refuse-with-suggestion above 100 files / 100K diff tokens.
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.2"
|
||||
task: "Opt-in /deep-research-motor for /trekresearch ekstern-fase"
|
||||
slug: deep-research-engine
|
||||
framing: refine
|
||||
status: ready
|
||||
brief_quality: complete
|
||||
research_topics: 3
|
||||
research_status: complete
|
||||
phase_signals_partial: true
|
||||
---
|
||||
|
||||
# Brief — Opt-in `/deep-research`-motor for `/trekresearch`
|
||||
|
||||
## TL;DR
|
||||
- `--engine {swarm|deep-research}` på `/trekresearch` ekstern-fase; `swarm` default (uendret oppførsel).
|
||||
- `deep-research` delegerer ekstern research til Claude Codes innebygde workflow og adapterer inn i research-brief-skjemaet.
|
||||
- Lokal analyse, triangulering og H2-output (`research/NN-*.md`) er motor-uavhengig.
|
||||
- Feature-detekteres med auto-fallback til `swarm` — aldri hard feil.
|
||||
- Surface-only: `commands/` + `agents/`, ingen nye `lib/`-avhengigheter.
|
||||
|
||||
## Intent
|
||||
`/trekresearch` eier i dag hele den eksterne research-fasen selv (Tavily / MS-Learn / Gemini-sverm). Det er portabelt, men du vedlikeholder fan-out, kryssjekk og syntese selv. Anthropic sin innebygde `/deep-research` vedlikeholder fan-out, adversariell påstandsverifisering, sitatfiltrering og websøk for deg. Lar vi operatøren *velge* `/deep-research` for den eksterne fasen, får brukeren en vedlikeholdsfri "turbo" når den er tilgjengelig — uten at Voyage mister sin egen lokal-analyse, triangulering eller H2-kontrakt.
|
||||
|
||||
## Goal
|
||||
`/trekresearch` får et `--engine {swarm|deep-research}`-valg på den eksterne fasen. `swarm` er default (dagens oppførsel). `deep-research` delegerer den eksterne fasen til den innebygde workflowen og adapterer resultatet inn i research-brief-skjemaet. Lokal analyse, triangulering og H2-output (`research/NN-*.md`) er uendret uansett motor.
|
||||
|
||||
## Non-Goals
|
||||
- Bygge om `/trekresearch` på et eget dynamic-workflow-substrat (vurdert og forkastet, Δ≈0).
|
||||
- Multi-provider (Perplexity / Google) — hører til en senere egen `workflow`-motor, ikke denne.
|
||||
- Endre den lokale svermen, handover-kontraktene eller validator-skjemaene.
|
||||
- Gjøre `/deep-research` til default.
|
||||
|
||||
## Constraints / NFR
|
||||
- `/deep-research` krever Claude Code v2.1.154+ og må være på (Pro: via `/config`). Må feature-detekteres med auto-fallback til `swarm` — aldri hard feil.
|
||||
- Output må passere `research-validator.mjs` (confidence ∈ [0,1], dimensions ≥ 1, påkrevde body-seksjoner).
|
||||
- Ingen nye `lib/`-avhengigheter; overflate-endring i `commands/` + `agents/`.
|
||||
- Doc-consistency: oppdater command-tabellen i `CLAUDE.md` + `README.md`, kjør `npm test`.
|
||||
|
||||
## Success Criteria
|
||||
1. `/trekresearch --external --engine swarm "<q>"` gir identisk oppførsel som før (regresjon grønn).
|
||||
2. `/trekresearch --external --engine deep-research "<q>"` produserer en `research/NN-*.md` der `research-validator.mjs --json` rapporterer `valid: true`.
|
||||
3. Med dynamic workflows avskrudd faller `--engine deep-research` automatisk tilbake til `swarm` og logger valget — ingen exception.
|
||||
4. `npm test` (inkl. doc-consistency) er grønn.
|
||||
|
||||
## Research Plan
|
||||
1. **Programmatisk trigging av `/deep-research`** — Kan en plugin-kommando starte den innebygde workflowen og fange artefakten, eller må `commands/trekresearch.md` instruere Claude til å kjøre den? Scope: external · Konfidens: høy · Kost: lav.
|
||||
`/trekresearch --external "Kan en Claude Code plugin-kommando programmatisk starte /deep-research og hente rapporten?"`
|
||||
2. **Output-adapter** — Hvordan mappe `/deep-research` sin siterte rapport til research-brief-skjemaet (dimensjoner, confidence, sitater)? Scope: both · Konfidens: høy · Kost: lav.
|
||||
`/trekresearch --project <dir> --local "Hva krever research-validator.mjs av research/NN-*.md?"`
|
||||
3. **Feature-deteksjon + fallback** — Hvordan oppdage om workflows er på, og hvor i fasen fallback-grenen bør sitte? Scope: local · Konfidens: middels · Kost: lav.
|
||||
|
||||
> **Research-status (2026-06-30, operatør-beslutning «option A»):** Topic 1 (eneste ekte eksterne ukjente) er undersøkt → `docs/deep-research-engine-research.md` (validator-grønn). Funn: `/deep-research` er en innebygd **dynamic workflow** (ikke skill) → trigging KUN via prosa-instruksjon, output **inline i kontekst** (ingen on-disk-artefakt) → motoren må være instruksjons-basert + in-context transform. **SC3 er korrekt som skrevet** (`/deep-research` ER en dynamic workflow). Topic 2 (validator-skjema: `type/created/question` + `## Executive Summary`/`## Dimensions`, `dimensions ≥ 1`) og topic 3 (fallback-plassering) er lokale kode-spørsmål reklassifisert til `/trekplan`-utforskning. `research_status: complete` reflekterer denne beslutningen.
|
||||
|
||||
## Open Questions / Assumptions
|
||||
- Antar at `/deep-research`-rapporten kan reduseres til ≥ 1 dimensjon med per-påstand-sitater uten å bryte trianguleringen. Verifiseres i topic 2.
|
||||
- Uavklart om delegering skjer via instruksjon (trygt) eller programmatisk API (raskere) — topic 1 avgjør.
|
||||
|
||||
## Prior Attempts
|
||||
- Dvalende synthesis-agent bygget, målt (Δ≈0) og forkastet → samme lærdom gjelder substrat-bytte: mål før du adopterer.
|
||||
- Workflow-substratet er allerede dokumentert som vurdert-og-valgt-bort i `docs/architecture.md` §Primitives per step.
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
---
|
||||
type: trekresearch-brief
|
||||
created: 2026-06-30
|
||||
question: "Can a Claude Code plugin command programmatically trigger the built-in /deep-research workflow and capture its report, or must it instruct Claude to run it?"
|
||||
confidence: 0.85
|
||||
dimensions: 4
|
||||
mcp_servers_used: []
|
||||
local_agents_used: [claude-code-guide]
|
||||
external_agents_used: []
|
||||
slug: deep-research-engine
|
||||
feeds_brief: docs/deep-research-engine-brief.md
|
||||
research_topic: 1
|
||||
---
|
||||
|
||||
# Research — Programmatic trigging of `/deep-research` from a plugin command
|
||||
|
||||
> Targeted single-topic research for the `deep-research-engine` brief (Topic 1 of
|
||||
> the brief's Research Plan). Topics 2 & 3 are local Voyage-code questions folded
|
||||
> into `/trekplan` exploration; only Topic 1 was a genuine external unknown.
|
||||
> Method: `claude-code-guide` agent (Anthropic docs + CHANGELOG, cited) +
|
||||
> direct inspection of this machine (CC 2.1.196 binary, a real local `/deep-research`
|
||||
> run). Not run through the `/trekresearch` swarm — see brief reconcile note.
|
||||
|
||||
## Research Question
|
||||
|
||||
Can a Claude Code **plugin slash-command** (markdown under `commands/`)
|
||||
**programmatically** start the built-in `/deep-research` workflow and **capture its
|
||||
report artifact** for adaptation into the research-brief schema — or must the command
|
||||
instead **instruct** Claude (in prose) to run `/deep-research` and then transform the
|
||||
in-context result?
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Programmatic trigger + file-based capture is **not feasible**: `/deep-research` is a
|
||||
built-in **dynamic workflow** (not a skill), deliberately outside the Skill-tool
|
||||
allowlist, and it returns its report **inline into conversation context with no
|
||||
documented on-disk report artifact**. The **only reliable path is instruction-based
|
||||
delegation** — the command's prose tells Claude to run `/deep-research <q>`, then
|
||||
transforms the in-context report in the same turn. Confidence **high** (Anthropic docs +
|
||||
CHANGELOG + local run), with one residual gap: there is no positive "is it enabled?"
|
||||
probe, so feature-detection must lean on the documented *disable* switches + version
|
||||
floor + a `swarm` default.
|
||||
|
||||
## Dimensions
|
||||
|
||||
### 1. Origin + gating -- Confidence: high
|
||||
|
||||
**External findings:**
|
||||
- `/deep-research` is a **built-in dynamic workflow**, not a command and not a bundled
|
||||
skill. `commands.md` marks the `/deep-research <question>` row as **[Workflow]**;
|
||||
`workflows.md`: "Claude Code includes `/deep-research` as a built-in workflow."
|
||||
[VERIFIED — code.claude.com/docs/en/commands.md, code.claude.com/docs/en/workflows.md]
|
||||
- Gating: available on **all paid plans** (pro/max/team/enterprise) + API/Bedrock/Vertex/
|
||||
Foundry. **On Pro it must be turned on** in the *Dynamic workflows* row of `/config`.
|
||||
Also requires the **WebSearch tool** to be available.
|
||||
[VERIFIED — workflows.md, commands.md bundled-workflows row]
|
||||
- Version floor: dynamic workflows were **introduced in CC 2.1.154**; "Dynamic workflows
|
||||
require Claude Code v2.1.154 or later." The brief's `v2.1.154+` + `(Pro: via /config)`
|
||||
constraints are **both correct**. The exact version that first shipped the *named*
|
||||
`/deep-research` workflow is **[NOT DOCUMENTED]** (only a 2.1.196 bugfix mentions it by
|
||||
name) — treat 2.1.154 as the substrate floor, not a proven introduction point.
|
||||
[VERIFIED — workflows.md + CHANGELOG 2.1.154]
|
||||
|
||||
**Local findings:**
|
||||
- This machine runs **CC 2.1.196** (`claude --version`) — substrate floor satisfied.
|
||||
- The exact skill-description string lives **compiled into the binary**
|
||||
(`/Users/ktg/.local/share/claude/versions/2.1.196`, Mach-O 235 MB); there is **no
|
||||
`SKILL.md`** for it anywhere under `~/.claude` (system-wide `find`/`grep` — only hits
|
||||
are this brief + unrelated harness notes). Confirms "Anthropic-bundled, not user skill."
|
||||
|
||||
### 2. Invocation mechanism (programmatic vs instruction) -- Confidence: high
|
||||
|
||||
**External findings:**
|
||||
- **Not** via the `Skill` tool. The Skill-tool built-in allowlist is closed: only
|
||||
`/init`, `/review`, `/security-review` are reachable; "Other built-in commands such as
|
||||
`/compact` are not." `/deep-research` is a Workflow and is not on that list.
|
||||
[VERIFIED — code.claude.com/docs/en/skills.md]
|
||||
- **Instruction-based delegation is the documented mechanism.** Workflows launch when the
|
||||
user types the command, or **when Claude is asked in natural language** ("use a
|
||||
workflow" / "run a workflow") or via the dedicated opt-in keyword CC ships for
|
||||
multi-agent orchestration (CC 2.1.160; spelled out in
|
||||
`docs/cc-upgrade-2.1.181-decision-matrix.md`, which `verify.sh`'s SC1 excludes
|
||||
precisely because it legitimately cites CC keywords). A plugin command whose
|
||||
markdown instructs Claude to run `/deep-research <q>` is therefore the supported path.
|
||||
[VERIFIED — workflows.md "Have Claude write a workflow"]
|
||||
- **Approval gate caveat:** launching a workflow triggers a per-run approval prompt —
|
||||
*every run* in default/acceptEdits; *first launch only* in auto; **never in `claude -p`
|
||||
/ Agent SDK / bypass-permissions** ("the run starts immediately"). Voyage's headless
|
||||
surface (`claude -p`) thus delegates without an interactive gate; interactive sessions
|
||||
hit a prompt. [VERIFIED — workflows.md "Behavior and limits"]
|
||||
- No documented blanket "commands/skills cannot nest" prohibition beyond the Skill-tool
|
||||
allowlist + workflow runtime limits (no mid-run user input; 16 concurrent agents;
|
||||
1000 agents/run). [VERIFIED — workflows.md; NOT DOCUMENTED for a general nesting ban]
|
||||
|
||||
### 3. Output capture -- Confidence: high
|
||||
|
||||
**External findings:**
|
||||
- "When the run finishes, **the report lands in your session**"; "Claude's context holds
|
||||
only the final answer." The report is **in-context**, not a file.
|
||||
[VERIFIED — workflows.md]
|
||||
- What *is* written to disk is the orchestration **script**, not the report: "Every run
|
||||
writes its script to a file under your session's directory in `~/.claude/projects/`."
|
||||
[VERIFIED — workflows.md "How a workflow runs"]
|
||||
|
||||
**Local findings:**
|
||||
- A real `/deep-research` run on this machine left exactly one file —
|
||||
`~/.claude/projects/<session>/workflows/scripts/deep-research-wf_<id>.js` — and **no
|
||||
`.md` report** beside it. [VERIFIED — local filesystem inspection by claude-code-guide]
|
||||
- **Consequence:** a calling command cannot `grep` a results file off disk (none is
|
||||
documented to exist). It can only consume the report **as it sits in conversation
|
||||
context in the same turn** — Claude reads its own prior output and transforms it into
|
||||
research-brief schema. [INFERENCE — file-based capture not feasible; in-context
|
||||
transform is the only avenue]
|
||||
|
||||
### 4. Feature detection + fallback -- Confidence: medium
|
||||
|
||||
**External findings:**
|
||||
- **No positive enumeration / "is-enabled" API or flag is documented.** `claude --help`
|
||||
exposes `--disable-slash-commands` but no "list skills/workflows" or "is-feature-on"
|
||||
flag. [VERIFIED — local `claude --help`; NOT DOCUMENTED for any positive probe]
|
||||
- The documented signals are the **off-switches**, read defensively: `/config` Dynamic-
|
||||
workflows off; `disableWorkflows: true` / `disableBundledSkills: true` in settings.json;
|
||||
`CLAUDE_CODE_DISABLE_WORKFLOWS=1` / `CLAUDE_CODE_DISABLE_BUNDLED_SKILLS=1`.
|
||||
[VERIFIED — workflows.md "Turn workflows off"; CHANGELOG 2.1.x]
|
||||
- **The gap:** the Pro `/config` *on*-state is the very thing you most need to detect, and
|
||||
only the *disable* keys are documented; the persisted key/value for the Pro enable-state
|
||||
is **[NOT DOCUMENTED]**, so it cannot be reliably grepped. A failed/disabled
|
||||
`/deep-research` is also **not documented** to raise a signal a sibling command can
|
||||
catch. [VERIFIED gap]
|
||||
|
||||
**Local findings:**
|
||||
- On this machine both disable keys are unset/absent — workflows are not disabled.
|
||||
|
||||
## External Knowledge
|
||||
|
||||
### Best Practice
|
||||
The "Have Claude write a workflow" + "Behavior and limits" sections of `workflows.md`
|
||||
establish that workflows are operator/Claude-launched, run isolated, and return one
|
||||
in-context report. The Skill-tool allowlist (`skills.md`) is the authoritative statement
|
||||
that only three built-ins are tool-invocable.
|
||||
|
||||
### Known Issues
|
||||
Per-run approval prompts outside `-p`/SDK/bypass mean an interactive `/trekresearch
|
||||
--engine deep-research` will pause for operator approval on each launch — acceptable, but
|
||||
worth documenting in the command UX. The absence of a positive availability probe is the
|
||||
single biggest design constraint (see Dimension 4).
|
||||
|
||||
## Synthesis
|
||||
|
||||
Three cross-cutting insights that only emerge from combining the docs with Voyage's brief:
|
||||
|
||||
1. **The brief's SC3 is correct as written — an earlier review note was wrong.** Because
|
||||
`/deep-research` *is itself* a dynamic workflow, "med dynamic workflows avskrudd faller
|
||||
`--engine deep-research` tilbake til swarm" is the right feature-detection axis. A
|
||||
prior brief-review remark that SC3 "conflated `/deep-research` (skill) with dynamic
|
||||
workflows" was based on a wrong premise (that `/deep-research` was a skill) and is
|
||||
retracted. **No SC3 brief edit is needed.**
|
||||
|
||||
2. **The engine must be instruction-based + in-context, never file-based.** Topic 1's
|
||||
open question ("instruction (safe) vs. programmatic API (faster) — topic 1 decides")
|
||||
resolves decisively to **instruction-based**: there is no programmatic API and no
|
||||
on-disk report. The `deep-research` engine path in `commands/trekresearch.md` must
|
||||
(a) instruct Claude to run `/deep-research <q>`, then (b) transform the in-context
|
||||
report into the `research/NN-*.md` schema in the same turn. This is surface-only
|
||||
(`commands/` prose), matching the brief's "ingen nye `lib/`-avhengigheter."
|
||||
|
||||
3. **SC3's "ingen exception" cannot rest on runtime detection — pin it to a default.**
|
||||
Since no positive availability probe exists, robust fallback = version-floor check
|
||||
(≥ 2.1.154) + disable-key heuristic (`disableWorkflows` / env) + **`--engine` default
|
||||
of `swarm`** (explicit opt-in). The fallback is "graceful degradation by design,"
|
||||
not "catch an exception at runtime." This refines, but does not contradict, SC3.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Adapter fidelity (brief Topic 2, partly answered locally):** `research-validator.mjs`
|
||||
requires `type: trekresearch-brief` + `created` + `question`; validates `confidence ∈
|
||||
[0,1]` and `dimensions ≥ 1` if present; body must carry `## Executive Summary` +
|
||||
`## Dimensions`. So the `/deep-research` report **can** be reduced to ≥ 1 dimension with
|
||||
per-claim citations and a confidence number — the brief's assumption holds. The
|
||||
remaining open part (how cleanly the in-context report maps to per-dimension
|
||||
local/external splits) is a `/trekplan` exploration concern, not an external unknown.
|
||||
- **Exact intro version of the *named* `/deep-research` workflow** — not documented; the
|
||||
2.1.154 dynamic-workflows floor is the safe pin.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Build the `deep-research` engine as **instruction-based delegation with in-context
|
||||
adaptation**, not a programmatic trigger:
|
||||
1. `--engine deep-research` makes `commands/trekresearch.md`'s external phase instruct
|
||||
Claude to run `/deep-research <q>` and transform the returned in-context report into
|
||||
`research/NN-*.md` (validator-conformant: `## Executive Summary` + `## Dimensions`,
|
||||
`confidence`, `dimensions ≥ 1`).
|
||||
2. Feature-detect by **graceful degradation**: version floor + disable-key heuristic +
|
||||
`--engine` default `swarm`. Do not depend on a positive availability probe (none
|
||||
exists). Log the chosen engine. This satisfies SC3 without a runtime exception.
|
||||
3. Keep `swarm` the default (brief's Non-Goal: "ikke default-bytte"). Document the
|
||||
per-run approval prompt for interactive (non-`-p`) sessions.
|
||||
|
||||
Confidence in the recommendation: **high** for the mechanism (instruction-based +
|
||||
in-context), **medium** for the exact fallback-detection ergonomics (the one documented
|
||||
gap). This is sufficient to green-light `/trekplan` with Topic 1 resolved.
|
||||
|
||||
## Sources
|
||||
|
||||
| # | Source | Type | Quality | Used in |
|
||||
|---|--------|------|---------|---------|
|
||||
| 1 | code.claude.com/docs/en/workflows.md | official | high | Dim 1,2,3,4 + Synthesis |
|
||||
| 2 | code.claude.com/docs/en/commands.md | official | high | Dim 1 (Workflow classification, WebSearch req) |
|
||||
| 3 | code.claude.com/docs/en/skills.md | official | high | Dim 2 (Skill-tool allowlist) |
|
||||
| 4 | github.com/anthropics/claude-code CHANGELOG (2.1.154, 2.1.x, 2.1.196) | official | high | Dim 1,4 (version floor, disable keys) |
|
||||
| 5 | Local: CC 2.1.196 binary inspection (`find`/`grep`, `claude --version`) | codebase | high | Dim 1 (bundled, no SKILL.md) |
|
||||
| 6 | Local: real `/deep-research` run — only `.js` script written, no `.md` report | codebase | high | Dim 3 (no on-disk artifact) |
|
||||
| 7 | Local: `lib/validators/research-validator.mjs` schema | codebase | high | Open Questions (adapter feasibility / Topic 2) |
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
# Observability — voyage v4.1
|
||||
|
||||
This document describes the *opt-in* OpenTelemetry / Prometheus export
|
||||
path added in v4.1. The default JSONL stats stream
|
||||
(`${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl`) remains unchanged — it is the
|
||||
canonical event log and continues to be written regardless of OTel mode.
|
||||
|
||||
## Overview
|
||||
|
||||
Voyage v4.0 wrote per-command stats to JSONL files only. Operators who
|
||||
wanted dashboards had to roll their own log-pipeline. v4.1 adds a Stop-hook
|
||||
called `hooks/scripts/otel-export.mjs` that, when activated via
|
||||
`VOYAGE_EXPORT_MODE`, transforms the JSONL records into either a
|
||||
Prometheus textfile or OTLP/HTTP push at session-end.
|
||||
|
||||
The hook is *additive*. With `VOYAGE_EXPORT_MODE=off` (default), the
|
||||
binary exits silently and no work is done — your existing JSONL workflow
|
||||
is untouched.
|
||||
|
||||
## Activating OTel export
|
||||
|
||||
Set `VOYAGE_EXPORT_MODE` in the shell before invoking any voyage command:
|
||||
|
||||
```bash
|
||||
# Default — no export, JSONL only
|
||||
unset VOYAGE_EXPORT_MODE # equivalent to VOYAGE_EXPORT_MODE=off
|
||||
|
||||
# Path A — Prometheus textfile (recommended for local dashboards)
|
||||
export VOYAGE_EXPORT_MODE=textfile
|
||||
export VOYAGE_TEXTFILE_DIR=/var/lib/node_exporter/textfile
|
||||
|
||||
# Path B — OTLP/HTTP push (recommended for centralized telemetry)
|
||||
export VOYAGE_EXPORT_MODE=otlp
|
||||
export VOYAGE_OTEL_ENDPOINT=https://otel.example.com/v1/metrics
|
||||
```
|
||||
|
||||
`hooks/hooks.json` wires the Stop event to `otel-export.mjs`, so the
|
||||
export runs automatically when Claude Code finishes a session. No manual
|
||||
invocation is required.
|
||||
|
||||
## Output formats
|
||||
|
||||
| Mode | Wire format | Endpoint shape | Cardinality cap |
|
||||
|------|-------------|----------------|-----------------|
|
||||
| `textfile` | Prometheus exposition format (text) | local file: `${VOYAGE_TEXTFILE_DIR}/voyage.prom` | low — voyage controls labels |
|
||||
| `otlp` | OTLP/JSON v1.0 metric ResourceMetrics | HTTPS POST: `${VOYAGE_OTEL_ENDPOINT}` | low — same allowlist as textfile |
|
||||
| `off` | (none) | — | — |
|
||||
|
||||
Both formats apply the **same field allowlist** — see
|
||||
`lib/exporters/field-allowlist.mjs` for the per-schema list. Fields not in
|
||||
the allowlist are dropped before export. This is a CWE-212 mitigation:
|
||||
operator-defined endpoints must never receive accidentally-leaked
|
||||
operator-private data (paths, prompts, brief content).
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `VOYAGE_EXPORT_MODE` | `off` | One of `off` / `textfile` / `otlp` |
|
||||
| `VOYAGE_TEXTFILE_DIR` | `${CLAUDE_PLUGIN_DATA}` | Directory for `voyage.prom` (textfile mode) |
|
||||
| `VOYAGE_OTEL_ENDPOINT` | _(none)_ | HTTPS URL for OTLP/HTTP POST |
|
||||
| `VOYAGE_OTEL_ALLOW_PRIVATE` | _(unset)_ | Set to `1` to allow loopback / RFC1918 endpoints |
|
||||
| `VOYAGE_TOKEN_METER` | _(unset)_ | Set to a truthy value to capture per-session token/cost into `token-usage-stats.jsonl` on Stop (default off → zero added latency). See **Token/cost metering** below. |
|
||||
|
||||
## Docker Compose quickstart
|
||||
|
||||
A pre-pinned local stack lives at `examples/observability/`:
|
||||
|
||||
```bash
|
||||
cd examples/observability
|
||||
mkdir -p voyage-textfile
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This brings up Prometheus, Grafana, node-exporter (textfile mode), and
|
||||
otel-collector (OTLP mode) on `localhost`. See
|
||||
`examples/observability/README.md` for endpoint URLs and version pins.
|
||||
|
||||
## Stats schema
|
||||
|
||||
Each Voyage command emits one JSONL record per significant event. Schemas
|
||||
are documented in `tests/fixtures/jsonl-schemas.md` (Step 1 of v4.1) and
|
||||
locked by `tests/lib/profile-stats-fields.test.mjs`.
|
||||
|
||||
The exporter applies the field allowlist defined in
|
||||
`lib/exporters/field-allowlist.mjs`. Adding a new field to the JSONL
|
||||
schema does **not** automatically expose it in OTel — you must add it to
|
||||
the allowlist explicitly. This is intentional: `${CLAUDE_PLUGIN_DATA}` is
|
||||
trusted local storage; OTel endpoints are operator-controlled and may be
|
||||
external.
|
||||
|
||||
## Token/cost metering
|
||||
|
||||
> **SKAL-2.** Opt-in capture of per-session token usage and a cache-aware USD
|
||||
> cost estimate, folded into the existing Stop hook (`hooks/scripts/otel-export.mjs`).
|
||||
> No new hook is added — capture rides the same Stop event, so there is no
|
||||
> second-Stop-hook ordering race.
|
||||
|
||||
**Activation.** Set `VOYAGE_TOKEN_METER` to any truthy value. When unset (the
|
||||
default) the capture path is skipped entirely — zero added Stop latency. When
|
||||
set, the hook reads the Claude Code transcript (`transcript_path` from the Stop
|
||||
payload), sums token usage, derives cost, and **upserts** one record per session
|
||||
into `${CLAUDE_PLUGIN_DATA}/token-usage-stats.jsonl`. Capture is fail-open: any
|
||||
error (malformed payload, unreadable transcript) is swallowed and never blocks
|
||||
Stop. Once captured, the record is exported like any other stats file when
|
||||
`VOYAGE_EXPORT_MODE` is `textfile` or `otlp`.
|
||||
|
||||
**Schema (`token-usage`).** Flat numeric record:
|
||||
`ts`, `session_id`, `scope`, `model`, `tokens_input`, `tokens_output`,
|
||||
`tokens_cache_creation`, `tokens_cache_read`, `cost_usd`, `is_estimate`,
|
||||
`price_table_version`. The field allowlist (`lib/exporters/field-allowlist.mjs`,
|
||||
`TOKEN_USAGE_ALLOWED`) admits only the numeric + low-cardinality-label fields and
|
||||
**strips `session_id` at export** (CWE-212). The exporter auto-promotes each
|
||||
numeric field to a metric: `voyage_token_usage_tokens_input` (Prometheus) /
|
||||
`voyage.token-usage.tokens_input` (OTLP).
|
||||
|
||||
**Cost contract (honesty).** `cost_usd` is computed from a dated, in-source
|
||||
`PRICE_TABLE` (per-MTok USD), cache-aware:
|
||||
`input + output + cache_creation×(5m write rate) + cache_read×(read rate)`.
|
||||
Each record carries `price_table_version` (the date the prices were resolved)
|
||||
and `is_estimate`. When the transcript's model is **not** in the price table, the
|
||||
meter **refuses to guess**: `cost_usd` is `null` and `is_estimate` is `true`.
|
||||
Prices are volatile — re-resolve them against the `claude-api` reference and bump
|
||||
`PRICE_TABLE_VERSION` when they change.
|
||||
|
||||
**v1 limitation — MAIN-CONTEXT only.** The meter reads the main-session
|
||||
transcript, which contains only `isSidechain:false` records. Sub-agent (swarm)
|
||||
turns live in separate `agent-*.jsonl` sibling files and are **not** counted, so
|
||||
`cost_usd` is a lower bound on total Voyage cost, not the session total. Every
|
||||
record is stamped `scope:'main-context'` to make this explicit. Per-subagent
|
||||
attribution is a documented v2 follow-on (it was a Non-Goal for v1).
|
||||
|
||||
## Security
|
||||
|
||||
The exporter is hardened against three CWE classes:
|
||||
|
||||
- **CWE-22 (path traversal)** — `lib/exporters/path-validator.mjs`
|
||||
rejects relative paths, symlinks, and paths outside `allowedRoots`
|
||||
(`VOYAGE_TEXTFILE_DIR` and `CLAUDE_PLUGIN_DATA`). Tested in
|
||||
`tests/hooks/otel-export-validators.test.mjs`.
|
||||
- **CWE-918 (SSRF)** — `lib/exporters/endpoint-validator.mjs` requires
|
||||
HTTPS, blocks loopback (127.0.0.0/8) and RFC1918 (10/8, 172.16/12,
|
||||
192.168/16), unless `VOYAGE_OTEL_ALLOW_PRIVATE=1` is set explicitly.
|
||||
Cloud metadata endpoints (169.254.169.254) are permanently blocked.
|
||||
- **CWE-212 (improper data sanitization)** — every record passes through
|
||||
`lib/exporters/field-allowlist.mjs` before any I/O. Adding a field to
|
||||
the JSONL stream does not expose it externally; operators must update
|
||||
the allowlist intentionally.
|
||||
|
||||
### Minimum versions per CVE history
|
||||
|
||||
| Component | Minimum version | Reason |
|
||||
|-----------|-----------------|--------|
|
||||
| `otel/opentelemetry-collector-contrib` | `0.115.0` | post-CVE-2024-42368 |
|
||||
| `prom/prometheus` | `3.0.1` | OOM regression fix in 2.x |
|
||||
| `prom/node-exporter` | `1.10.2` | textfile collector path normalization |
|
||||
| `grafana/grafana` | `11.4.0` | datasource provisioning hardening |
|
||||
|
||||
## Why direct export rather than a native collector
|
||||
|
||||
A balance review (V32) asked
|
||||
whether the custom exporters should be dropped in favour of pointing the
|
||||
standard `OTEL_*` environment variables at a co-located OTLP collector,
|
||||
letting that collector own egress and field selection. The operator
|
||||
decision (D2, 2026-06-20) is to **keep direct export**. The rationale is
|
||||
the security boundary, not a preference for re-hosting a collector:
|
||||
|
||||
- **The three guards run in-process, before any byte leaves Voyage.**
|
||||
`path-validator.mjs` (CWE-22), `endpoint-validator.mjs` (CWE-918 / SSRF),
|
||||
and `field-allowlist.mjs` (CWE-212) are applied inside `otel-export.mjs`
|
||||
and covered by `tests/hooks/otel-export-validators.test.mjs`. The
|
||||
records carry operator-private data (paths, prompts, brief content);
|
||||
the allowlist drops everything not explicitly named before export.
|
||||
- **A native-collector design moves that boundary out of audited code.**
|
||||
Handing raw JSONL to a sidecar collector means either re-expressing the
|
||||
field allowlist in collector config (a second source of truth that can
|
||||
drift) or shipping un-allowlisted private fields and trusting the
|
||||
collector's egress rules. The S21 SSRF hardening — 169.254.169.254
|
||||
permanently blocked, loopback/RFC1918 gated behind
|
||||
`VOYAGE_OTEL_ALLOW_PRIVATE` — is a property of `endpoint-validator.mjs`
|
||||
and would have to be re-created in collector configuration to be
|
||||
preserved.
|
||||
- **The collector path is still available, by design.** Operators who
|
||||
want collector semantics (retry, persistence, relabelling) use
|
||||
`textfile` mode and scrape `voyage.prom` with node-exporter / vector /
|
||||
otel-collector. Direct export is the minimal default, not a rejection
|
||||
of collectors — it keeps the data-sanitization boundary in Voyage's
|
||||
own validated code for the common case.
|
||||
|
||||
This is a deliberate direct-export-over-collector choice; the custom
|
||||
exporters and their guards are kept, not pruned.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Stop-hook is normal-exit only.** If Claude Code crashes or is killed
|
||||
with SIGKILL, the final session's metrics are not flushed. Use
|
||||
`--resume` on next start to recover plan/progress state; the missing
|
||||
session will not appear in dashboards.
|
||||
- **Tail-latency NFR is best-effort.** Textfile mode targets <5 ms p99,
|
||||
OTLP <1500 ms (AbortController guards). If the network endpoint is
|
||||
slow, the timeout fires and stats for that session are dropped — the
|
||||
hook always exits 0 to avoid blocking session shutdown.
|
||||
- **No retry on transport failure.** Stop-hook runs at most once per
|
||||
session. If the OTLP endpoint is unreachable, that session's metrics
|
||||
are lost. Production deployments should use `textfile` + a robust
|
||||
scrape pipeline (node-exporter, vector, otel-collector with persistent
|
||||
queue) to handle delivery semantics.
|
||||
- **No per-tenant labelling.** v4.1 emits flat metrics with command and
|
||||
schema_id labels only. Multi-tenant deployments needing per-user or
|
||||
per-project segmentation should layer a relabel stage in their
|
||||
collector or use external metadata.
|
||||
|
||||
## Cost-estimering disclaimer
|
||||
|
||||
The ROUGE-L / Jaccard / character n-gram thresholds in v4.1 are
|
||||
*starting points*, not contractual SLAs. The brief Risk-tabell explicitly
|
||||
flags these as anslag — they were calibrated against synthetic plan
|
||||
runs (Step 17) using `economy` and `premium` profiles. Real cross-tier
|
||||
agreement varies by task complexity. Treat the thresholds as smoke-test
|
||||
floors; tighten them in v4.2 once you have ≥10 production runs of data.
|
||||
|
||||
## See also
|
||||
|
||||
- `examples/observability/` — local Docker Compose stack
|
||||
- `tests/fixtures/jsonl-schemas.md` — canonical record shapes
|
||||
- `lib/exporters/field-allowlist.mjs` — per-schema allowed fields
|
||||
- `hooks/scripts/otel-export.mjs` — Stop-hook orchestrator
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Voyage — Autonomy gates, profile system, observability
|
||||
# Voyage — Autonomy gates, observability
|
||||
|
||||
Imported from `CLAUDE.md` via pointer.
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ Imported from `CLAUDE.md` via pointer.
|
|||
|
||||
All four pipeline commands accept `--gates [true|false|closed|open]`. A bare `--gates`, `--gates true` or `--gates closed` turns gating **on**; `--gates false`, `--gates open` or no flag (the default) leaves it off. The next token is the flag's value only when it is one of those four; any other token stays the command's own argument, so `/trekplan --gates --project <dir>` and `/trekexecute --gates plan.md` are a bare `--gates`. Each command maps the value once through `lib/util/autonomy-gate.mjs`; a shim that fails falls back to the same mapping in prose, and the run goes on. With gating on, `/trekbrief` pauses before its auto chain, `/trekresearch` after each topic, and `/trekplan` after exploration, synthesis and adversarial review. `/trekexecute` accepts the flag and pauses on nothing: its one stop is the merge into main, which always asks (below).
|
||||
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` is the `--gates` flag parser. It prints `{"ok":true,"gates":true}` when `--gates` is bare, `true` or `closed`, `{"ok":true,"gates":false}` for `false`, `open` or no flag, and a usage error (exit 1) for anything else. The module is pure data with no I/O. Separately, `lib/stats/event-emit.mjs` records named lifecycle events (`brief-approved`, `main-merge-gate`, `main-merge-approved`, `main-merge-declined`, `user_input`) to `trekexecute-stats.jsonl` in the plugin data directory (`CLAUDE_PLUGIN_DATA`, else `~/.claude/plugins/data/voyage-ktg-plugin-marketplace/`, the directory `scripts/yardstick.mjs` reads); a write that fails is reported, never skipped. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
Under the hood: `lib/util/autonomy-gate.mjs` is the `--gates` flag parser. It prints `{"ok":true,"gates":true}` when `--gates` is bare, `true` or `closed`, `{"ok":true,"gates":false}` for `false`, `open` or no flag, and a usage error (exit 1) for anything else. The module is pure data with no I/O. The main-merge gate is the final autonomy boundary before HEAD lands on `main`.
|
||||
|
||||
**The main-merge gate is a lock, and the guard asks the one question** (operator decision 2026-10-08). The gate opens only when the run is `completed` and the review's verdict gate (`lib/review/verdict-gate.mjs`, K7) exits 0; BLOCK (1) and "could not decide" (2) keep it closed. When it opens, `/trekexecute` runs the merge into main itself and `pre-bash-executor` answers "ask" before it runs, in auto mode too, so that question is the only one. In the permission mode where Claude Code shows no prompts, the guard refuses the merge instead, and every other call it would ask about (push, tag or publish, destructive git), since no one can be asked (operator decisions 2026-10-09). The executor no longer asks "ready to merge?" first, and `--gates` no longer adds a pause before the wave merge-back: the guard asks there as well, once per session, because the push and the merge go in one call. Before this, one merge cost two questions in three places: the `--gates` pause before the wave merge-back, the gate's own question after the waves were merged, and the gate's question on the single-session path followed by the guard's at the merge itself.
|
||||
|
||||
|
|
@ -16,46 +16,12 @@ Three architectural options were considered for the speedup work:
|
|||
|
||||
- **Path A — cache-first** (drop `--allowedTools` per child to recover cross-phase cache sharing): REJECTED. Inverts the security model; plugin hooks don't fire reliably in `claude -p` (research/06 GH #36071).
|
||||
- **Path B — sequential `--no-ff` parallel waves with manifest-driven failure recovery**: CHOSEN. Ships in v3.4.0. Phase 2.6 of `/trekexecute` runs the wave executor with hardenings for plugin-in-monorepo + gitignored-state topology.
|
||||
- **Path C — hybrid (cache-warm sentinel + identical-tool parallel)**: **CLOSED 2026-05-05.** Q3 experiment measured median `cache_creation_input_tokens` = 163,903 across 3 fork-children at 186K parent context (CC v2.1.128, Sonnet 4.6). Master-plan thresholds: ≤ 1,500 POSITIVE / ≥ 3,500 NEGATIVE. Result is solidly NEGATIVE — `CLAUDE_CODE_FORK_SUBAGENT` does not preserve cache prefix across identical-tool children at our context size. Path C migration is deferred indefinitely; reassessment is appropriate when CC v2.2.xxx ships fork-cache-relevant features. Companion analyser: `lib/stats/cache-analyzer.mjs`.
|
||||
- **Path C — hybrid (cache-warm sentinel + identical-tool parallel)**: **CLOSED 2026-05-05.** Q3 experiment measured median `cache_creation_input_tokens` = 163,903 across 3 fork-children at 186K parent context (CC v2.1.128, Sonnet 4.6). Master-plan thresholds: ≤ 1,500 POSITIVE / ≥ 3,500 NEGATIVE. Result is solidly NEGATIVE — `CLAUDE_CODE_FORK_SUBAGENT` does not preserve cache prefix across identical-tool children at our context size. Path C migration is deferred indefinitely; reassessment is appropriate when CC v2.2.xxx ships fork-cache-relevant features.
|
||||
|
||||
> **Status (6.0.0):** `/trekexecute` no longer starts wave children. Phase 2.6 prepares one wave at a time and hands each session over as an order the user runs in a session they open (CHANGELOG, 6.0.0). The Q3 harness was removed in 6.0.0 because it started the binary; its result above stands, and the script remains in git history before that tag.
|
||||
|
||||
A revived Path C (post-v2.2.xxx) would require: (1) re-architecting tool-list to be identical across all wave children, (2) cache-telemetry analysis confirming the new fork-cache behaviour holds, (3) prompt-level deny re-enablement to compensate for tool scoping rollback.
|
||||
|
||||
## Profile system (`--profile`, v4.1.0)
|
||||
## Observability
|
||||
|
||||
Four built-in model profiles plus operator-defined `<custom>.yaml`. Each profile pins `phase_models` for the six pipeline phases (`brief`, `research`, `plan`, `execute`, `review`, `continue`). Profile is recorded in plan.md frontmatter as `profile: <name>`.
|
||||
|
||||
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|
||||
|---------|-------|----------|------|---------|--------|----------|----------|
|
||||
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | ⚠ **Experimental** (uncalibrated Jaccard floor) — lowest cost; high-confidence small-scope tasks (operator-opt-in via `--profile economy`) |
|
||||
| `balanced` | sonnet | sonnet | opus | sonnet | opus | sonnet | Mixed — opus where reasoning depth pays off (operator-opt-in via `--profile balanced`) |
|
||||
| `premium` (default) | opus | opus | opus | opus | opus | opus | Maximum quality — Opus on every phase. Default since 2026-05-13 operator request; also the hardcoded resolver default returned by `resolveProfile()` in `lib/profiles/resolver.mjs` |
|
||||
| `fable` | fable | fable | fable | fable | fable | fable | Max quality — Fable 5.1 (the most capable model in Claude Code) on every phase (operator-opt-in via `--profile fable`); reasoning effort inherits from the session — see `docs/profiles.md` §Model & effort axes |
|
||||
|
||||
### Lookup order
|
||||
|
||||
1. Explicit `--profile <name>` flag passed to the command
|
||||
2. Plan-file frontmatter `profile:` (when resuming via `/trekexecute --resume` or `/trekcontinue`)
|
||||
3. `VOYAGE_PROFILE` environment variable
|
||||
4. Default `premium`
|
||||
|
||||
### Custom profiles
|
||||
|
||||
Create `voyage-profiles/<custom>.yaml` in the repo root (or `~/.claude/voyage-profiles/<custom>.yaml`) to define a **new** tier — the name must not be a built-in. The validator (`lib/validators/profile-validator.mjs`) enforces: every `phase_models[].phase` must be a known phase enum; every `phase_models[].model` must exactly match an entry in `BASE_ALLOWED_MODELS` (`['sonnet', 'opus', 'fable']`; `haiku` only with `VOYAGE_ALLOW_HAIKU=1`). `findProfilePath` (`lib/profiles/resolver.mjs`) resolves **built-in first** (`lib/profiles/<name>.yaml` for `economy`/`balanced`/`premium`/`fable`), then repo-root `voyage-profiles/`, then `~/.claude/voyage-profiles/`. A custom file named after a built-in therefore **cannot** shadow it (custom profiles must use new names); for the same custom name, repo-root takes precedence over home.
|
||||
|
||||
Drift between plan-frontmatter `profile:` and step-manifest `profile_used:` emits a `MANIFEST_PROFILE_DRIFT` warning from `plan-validator --strict` (Step 20). Plan remains valid; the warning surfaces accidental tier-mismatch.
|
||||
|
||||
## Observability (Stop hook, v4.1.0)
|
||||
|
||||
The `Stop` hook in `hooks/hooks.json` runs `hooks/scripts/otel-export.mjs` at session-end. The hook is **opt-in** — when `VOYAGE_EXPORT_MODE` is unset or `off`, no work is done.
|
||||
|
||||
| Mode | Output | Endpoint env-var |
|
||||
|------|--------|------------------|
|
||||
| `off` (default) | _(no export)_ | — |
|
||||
| `textfile` | `voyage.prom` (Prometheus exposition format) | `VOYAGE_TEXTFILE_DIR` |
|
||||
| `otlp` | OTLP/JSON POST | `VOYAGE_OTEL_ENDPOINT` |
|
||||
|
||||
Endpoint validation: `VOYAGE_OTEL_ALLOW_PRIVATE=1` is required to send to loopback or RFC1918 destinations (CWE-918 SSRF mitigation). Allowlist `lib/exporters/field-allowlist.mjs` redacts records before export (CWE-212). Path validation (`lib/exporters/path-validator.mjs`) rejects symlink + traversal (CWE-22).
|
||||
|
||||
Local Docker Compose stack: `examples/observability/`. Operator docs: `docs/observability.md`. Both pin minimum versions per CVE history (`prom/prometheus:v3.0.1`, `grafana/grafana:11.4.0`, `otel/opentelemetry-collector-contrib:0.115.0`).
|
||||
Claude Code's own OpenTelemetry (`CLAUDE_CODE_ENABLE_TELEMETRY`) gives tool durations, tokens and cost per agent and skill: see [Monitoring usage](https://code.claude.com/docs/en/monitoring-usage) (read 2026-11-07, Claude Code 2.1.319).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Prøvesett — Voyage's fixed test set as a plugin eval suite
|
||||
|
||||
**Status:** v2, eight cases. v1 (seven cases) first run **6 of 7**; v2 first run
|
||||
**Status:** seven cases: v2's eight, less the sequencing-gate case (v1 case 4), which
|
||||
went with the phase signals in cleanup R-b. v1 (seven cases) first run **6 of 7**; v2 first run
|
||||
**8 of 8** (both 2026-09-23, Claude Code 2.1.280). Each version's cases and
|
||||
expectations were committed *before* its first run; the run did not change them.
|
||||
Red is data here, not a defect in the suite. What v2 changed and why: § v2 below.
|
||||
|
|
@ -53,12 +54,12 @@ promises and ends in seconds. Nothing starts the `/trekplan` exploration swarm o
|
|||
| `plan-requires-brief` | `/trekplan` without a brief prints usage and stops | "A brief is required"; no Agent, no Write | pass |
|
||||
| `plan-project-not-initialized` | `/trekplan --project` on a missing folder stops before exploring | "project directory not initialized"; no Agent, no `plan.md` | pass |
|
||||
| `plan-rejects-unknown-export` | a removed export format is named and refused | "format 'pr' is not supported"; no Agent, no Write | pass |
|
||||
| `plan-halts-without-phase-signals` | a `brief_version` 2.1 brief without `phase_signals` halts at the sequencing gate | brief-validator runs, reply names `phase_signals`; no Agent, no `plan.md` | pass |
|
||||
| `review-requires-project` | `/trekreview` without `--project` refuses | "--project <dir> is required"; no Agent, no Write | **fail** (2 of 3 graders) |
|
||||
| `review-validate-flags-bad-finding-id` | **known-positive:** `--validate` fails a review whose finding ID is not 40-char hex, and names the rule | review-validator runs; `REVIEW_BAD_FINDING_ID` in the trace; reply says FAIL; no Agent, no Write | pass |
|
||||
| `review-validate-passes-clean-review` | **known-negative:** `--validate` passes a valid review and raises nothing | review-validator runs; reply says PASS; no `REVIEW_*` error code in the trace; no Agent, no Write | pass |
|
||||
|
||||
The v1 table above is kept as it was run. v2 rows and grader changes: § v2.
|
||||
The v1 table above is kept as it was run, less case 4 (the sequencing gate, pass),
|
||||
removed in cleanup R-b with the phase signals it tested. v2 rows and grader changes: § v2.
|
||||
|
||||
### The one red case (v1; fixed in v2, see § v2)
|
||||
|
||||
|
|
@ -99,7 +100,7 @@ Expectations committed in `3892a83` before the v2 run. Offline structure tests i
|
|||
|
||||
| Change | Why |
|
||||
|--------|-----|
|
||||
| **New case `plan-halts-without-intent-approval`**: a `brief_version` 2.1 brief *with* `phase_signals` and no `intent_approved_hash`. Graders: `gate-ran` (`intent-approval.mjs --check` in a Bash call), `names-code` (the gate's own JSON `code` for `BRIEF_INTENT_NOT_APPROVED` in the trace), `no-agent`, `no-plan-file` | Veikart steg 1's gate had no case. No v1 trace reached it: case 4 halts at the sequencing gate first |
|
||||
| **New case `plan-halts-without-intent-approval`**: a `brief_version` 2.1 brief with no `intent_approved_hash` (it carried phase signals until cleanup R-b). Graders: `gate-ran` (`intent-approval.mjs --check` in a Bash call), `names-code` (the gate's own JSON `code` for `BRIEF_INTENT_NOT_APPROVED` in the trace), `no-agent`, `no-plan-file` | Veikart steg 1's gate had no case. No v1 trace reached it: case 4 halts at the sequencing gate first |
|
||||
| **`review-requires-project`**: the arg parser got a CLI that checks `--project` itself and prints `Error: --project <dir> is required.` + usage (exit 1, code `ARG_REQUIRED_MISSING`). `trekreview.md` runs it with `$ARGUMENTS` and relays its stderr. The reply-regex grader `project-required` is replaced by `parser-ran` + `names-missing-project` (the parser's code in the trace, the same form as `names-rule`) | The fix this doc recommended for the red case. Also: the prose passed `"$@"`, which the Bash tool never has. The parser never ran (0 Bash calls in the case-5 traces), so the model composed the line from prose. That failed 2 of 10 on backticks |
|
||||
| `no-error-code` → `code\W{1,12}REVIEW_[A-Z_]+`, bound to the validator's JSON output | The listed codes missed `REVIEW_WRONG_TYPE` and `REVIEW_VERSION_FORMAT` |
|
||||
| `says-pass` / `says-fail` reject a preceding `NOT` and a longer word | Raw substrings: "did not PASS" passed `says-pass` |
|
||||
|
|
@ -133,7 +134,7 @@ same trace.
|
|||
|
||||
| Change | Why |
|
||||
|--------|-----|
|
||||
| The case-4 scaffold writes a current `intent_approved_hash` into the brief | The intent gate now passes, so only the sequencing gate can stop the case. The marker is written literally, not by `intent-approval.mjs --stamp`: `--stamp` emits a `brief-approved` record into the live stats that the yardstick reads |
|
||||
| The case-4 scaffold writes a current `intent_approved_hash` into the brief | The intent gate now passes, so only the sequencing gate can stop the case. The marker is written literally, not by `intent-approval.mjs --stamp` |
|
||||
| New case-4 grader `no-intent-halt` (trace, `not_contains`, any `BRIEF_INTENT_*` code) | If the marker ever goes stale, the case fails and names the reason. A halt on the intent gate no longer counts as a pass |
|
||||
| New case-4 grader `names-code` (the validator's `BRIEF_V51_MISSING_SIGNALS` code in the trace) | The known-positive below: without it, the case passed with the gate's code switched off |
|
||||
| `says-fail` / `says-pass` take `FAILED` / `PASSED` as the verdict word | v2's word boundary rejected them. v1's raw substring accepted them. A correct reply must not fail the case |
|
||||
|
|
@ -265,7 +266,7 @@ From the plugin root:
|
|||
claude plugin eval . --trust-plugin --ablation none --runs 1 --scaffold --no-publish --allow-tools Bash Write
|
||||
```
|
||||
|
||||
- `--scaffold` runs each case's `scaffold.sh` as you, outside the sandbox. The four
|
||||
- `--scaffold` runs each case's `scaffold.sh` as you, outside the sandbox. The three
|
||||
scaffolds in this suite only write fixture files into the run's workspace.
|
||||
- `--allow-tools Bash Write` grants the tools the commands need (Bash runs under the OS
|
||||
sandbox). Write is granted so that the `no-write` and `no-plan-file` graders measure a
|
||||
|
|
@ -291,4 +292,4 @@ will sometimes stop a change that is innocent. That is the intended price. Movin
|
|||
into code removes the instability; editing the grader does not. v2 did the move: the
|
||||
parser now writes the line, and the grader reads the parser's code. One green run does
|
||||
not show the instability is gone. That takes more runs. § Stability has them:
|
||||
`review-requires-project` 5 of 5, and case 4 15 of 15 after its own fix.
|
||||
`review-requires-project` 5 of 5, and case 4 (removed in cleanup R-b) 15 of 15 after its own fix.
|
||||
|
|
|
|||
237
docs/profiles.md
237
docs/profiles.md
|
|
@ -1,237 +0,0 @@
|
|||
# Profile system — voyage v4.1
|
||||
|
||||
This document describes the model profile system: built-in tiers,
|
||||
lookup precedence, custom-profile authoring, drift detection, and
|
||||
cost estimation (with disclaimer).
|
||||
|
||||
## Built-in profiles
|
||||
|
||||
Four pre-defined tiers ship with the plugin (fable added in v5.9), located at
|
||||
`lib/profiles/{economy,balanced,premium,fable}.yaml`.
|
||||
|
||||
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|
||||
|---------|-------|----------|------|---------|--------|----------|----------|
|
||||
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | ⚠ **Experimental** (uncalibrated Jaccard floor) — lowest cost; small-scope tasks where you have high confidence the brief is right |
|
||||
| `balanced` | sonnet | sonnet | opus | sonnet | opus | sonnet | Mixed — opus where reasoning depth pays off (plan synthesis + adversarial review); opt-in via `--profile balanced` |
|
||||
| `premium` (default) | opus | opus | opus | opus | opus | opus | Maximum quality — Opus on every phase + external research on (default since the 2026-05-13 operator decision) |
|
||||
| `fable` | fable | fable | fable | fable | fable | fable | Max quality — Fable 5.1 (the most capable model in Claude Code) on every phase; opt-in via `--profile fable`; reasoning effort inherits from the session (see Model & effort axes) |
|
||||
|
||||
`premium` is the default tier — set by the 2026-05-13 operator decision and
|
||||
matched by the hardcoded resolver default in `lib/profiles/resolver.mjs`. It
|
||||
runs opus on every phase and turns external research on: maximum quality, at
|
||||
roughly 5× the sub-agent cost of an all-sonnet run, accepted as a deliberate
|
||||
trade-off. Drop to `--profile balanced` (opus only on the two phases where
|
||||
quality matters most — Plan synthesis + Review — and sonnet everywhere else)
|
||||
or `--profile economy` (sonnet everywhere) when cost or latency matters more
|
||||
than depth. Step up to `--profile fable` (Fable 5.1 on every phase) when
|
||||
maximum quality is wanted end-to-end and cost is not a constraint.
|
||||
|
||||
`economy` is *strictly experimental* in v4.1, and says so in the profile
|
||||
data itself: `lib/profiles/economy.yaml` carries `experimental: true`. The
|
||||
cross-tier Jaccard floor (0.55) is grounded in parked-synthetic fixtures, not
|
||||
empirical runs (Step 17 calibration was deferred — see
|
||||
`tests/synthetic/profile-jaccard-calibration.md`). The flag is pinned: it must
|
||||
stay `true` while the calibration status is `parked-synthetic`, and must be
|
||||
dropped in the same change that lands empirical calibration
|
||||
(`status: empirical`). If you observe economy-plan quality regressions, fall
|
||||
back to `balanced`.
|
||||
|
||||
## Model & effort axes
|
||||
|
||||
`opus`, `sonnet`, and `fable` are model **aliases**, not pinned ids. On the
|
||||
Anthropic API (verified 2026-10-02 against the Claude Code model-config docs)
|
||||
the `opus` alias resolves to **Opus 5.5** and `sonnet` to **Sonnet 5.5**, both
|
||||
with default reasoning effort **`medium`**; `fable` resolves to **Fable 5.1**
|
||||
(the most capable model in Claude Code), whose default reasoning effort is
|
||||
**`high`**. Other providers resolve `opus` and `sonnet` to other versions. The
|
||||
profile table above selects *which alias* runs each phase — it does not touch
|
||||
reasoning effort.
|
||||
|
||||
**Reasoning effort inherits from the session.** Voyage effort (orchestration
|
||||
shape — which agents/passes run) and model reasoning effort are different
|
||||
axes. Fable 5.1's default reasoning effort is `high`, NOT xhigh, and Claude
|
||||
Code saves effort per model, so a level saved for one model does not carry to
|
||||
another. To run the fable tier at xhigh, choose it explicitly: `/effort xhigh`,
|
||||
`--effort xhigh`, or `CLAUDE_CODE_EFFORT_LEVEL`.
|
||||
|
||||
Two different things share the word "effort" in Voyage. They are **orthogonal
|
||||
axes** — same name, different mechanism:
|
||||
|
||||
| | **Orchestration effort** | **Reasoning effort (native)** |
|
||||
|---|---|---|
|
||||
| Where | brief `phase_signals.effort`, consumed by command prose | native Claude Code `effort:` in agent frontmatter |
|
||||
| Values | `low` / `standard` / `high` | `low` / `medium` / `high` / `xhigh` / `max` |
|
||||
| Controls | *which agents/passes/gates run* (swarm cardinality, gate strictness, sequential-vs-parallel) | *per-spawn reasoning-token budget* the harness gives one agent |
|
||||
| `low` means | "run fewer agents" (the `--quick`-equivalent code-path) | "think with a smaller budget" |
|
||||
| Applied by | Voyage command logic | the harness, at spawn time |
|
||||
|
||||
The `phase-signal-resolver.mjs` helper only reads the **orchestration** axis
|
||||
(`phase_signals.effort`, gated against `low/standard/high`) plus the optional
|
||||
per-phase `model` (gated against `['sonnet','opus','fable']`). It never emits
|
||||
native `effort:`.
|
||||
|
||||
**Native `effort:` on agents.** Voyage sets the reasoning axis statically on
|
||||
selected agents, overriding the session level:
|
||||
|
||||
- **Nine agents pin `high`:** the retrieval agents `task-finder`,
|
||||
`git-historian`, `dependency-tracer`, `architecture-mapper`, the
|
||||
adversarial-reasoning agents `plan-critic`, `risk-assessor`,
|
||||
`contrarian-researcher`, `review-coordinator`, and `/trekexecute`'s step
|
||||
helper `step-implementer`. No agent pins below `high`
|
||||
(operator rule, 2026-10-05: standard is Opus 5.5 at effort High).
|
||||
- **All other agents:** unset → inherits the session's effort level (a session
|
||||
that nothing sets runs at its model's default — `medium` on Opus 5.5).
|
||||
|
||||
**Every agent is pinned `model: opus`.** The pin is set in each agent's
|
||||
frontmatter (operator decision, commit `40d8742`), because the profile
|
||||
resolver does not reach sub-agents: the profile picks the model for the
|
||||
phases, the frontmatter for the agents. The operator wants Opus on all Voyage
|
||||
work and accepted the roughly fivefold sub-agent cost against Sonnet. The
|
||||
glue, mechanical, retrieval and dormant roles were reconsidered for a Sonnet
|
||||
downgrade (operator, 2026-06-20) and kept on Opus: the pin stays firm.
|
||||
|
||||
Native-effort precedence (harness): env `CLAUDE_CODE_EFFORT_LEVEL` > frontmatter
|
||||
`effort:` > session setting > model default, and a `maxEffortLevel` setting or
|
||||
an organization effort cap still limits the result. `MAX_THINKING_TOKENS=0` has
|
||||
no effect on Opus 5.5, Sonnet 5.5 or the Fable models: thinking cannot be turned
|
||||
off there. `availableModels` restricts which models can be selected — it does
|
||||
**not** bound effort.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
Are you uncertain whether the brief is correctly framed?
|
||||
└── Yes → premium (opus on brief + plan + review)
|
||||
└── No → continue
|
||||
↓
|
||||
Is the change small (≤ 5 steps in the plan)?
|
||||
└── Yes → economy (sonnet everywhere)
|
||||
└── No → balanced (opus on plan + review)
|
||||
|
||||
Special cases:
|
||||
- Critical-infrastructure plan → premium
|
||||
- Migration with rollback risk → premium
|
||||
- Research-heavy task (≥ 4 dimensions) → balanced (research-stage benefits)
|
||||
- Bug fix with clear reproducer → economy
|
||||
- Documentation-only PR → economy
|
||||
```
|
||||
|
||||
## Lookup order
|
||||
|
||||
Voyage resolves the profile in this priority order:
|
||||
|
||||
1. **Explicit `--profile <name>` flag** — passed to the command
|
||||
2. **Plan-file frontmatter `profile:`** — when resuming via
|
||||
`/trekexecute --resume` or `/trekcontinue`
|
||||
3. **`VOYAGE_PROFILE` environment variable** — useful for headless CI
|
||||
4. **Default `premium`** — final fallback
|
||||
|
||||
The plan-file fields for the resolved value:
|
||||
|
||||
- Plan-file frontmatter `profile: <name>` and `phase_models: [...]`
|
||||
|
||||
## Custom profiles
|
||||
|
||||
Drop a YAML file at `voyage-profiles/<name>.yaml` in the repo root, or at
|
||||
`~/.claude/voyage-profiles/<name>.yaml`, to define a new tier (the repo root
|
||||
is tried first). `lib/profiles/` holds only the four built-ins; a custom file
|
||||
placed there is never found.
|
||||
The validator (`lib/validators/profile-validator.mjs`) enforces:
|
||||
|
||||
- Every `phase_models[].phase` must be a known phase enum:
|
||||
`brief` / `research` / `plan` / `execute` / `review` / `continue`
|
||||
- Every `phase_models[].model` must exactly match an entry in
|
||||
`BASE_ALLOWED_MODELS` (`['sonnet', 'opus', 'fable']` in
|
||||
`lib/validators/profile-validator.mjs`; `haiku` only with
|
||||
`VOYAGE_ALLOW_HAIKU=1`)
|
||||
- All six phases must be present (no partial profiles)
|
||||
|
||||
The four built-in names (`economy`, `balanced`, `premium`, `fable`) resolve to their
|
||||
bundled yaml first — `findProfilePath()` returns the built-in before consulting
|
||||
`voyage-profiles/`, so a same-named custom file is ignored and cannot shadow a
|
||||
built-in. To customize, give your profile a new name and reference it via
|
||||
`--profile <new-name>` or `VOYAGE_PROFILE=<new-name>`.
|
||||
|
||||
### Example custom profile
|
||||
|
||||
```yaml
|
||||
# lib/profiles/critical.yaml — opus everywhere except continue
|
||||
phase_models:
|
||||
- phase: brief
|
||||
model: opus
|
||||
- phase: research
|
||||
model: opus
|
||||
- phase: plan
|
||||
model: opus
|
||||
- phase: execute
|
||||
model: opus
|
||||
- phase: review
|
||||
model: opus
|
||||
- phase: continue
|
||||
model: sonnet
|
||||
```
|
||||
|
||||
Validate with: `node lib/validators/profile-validator.mjs --json lib/profiles/critical.yaml`
|
||||
|
||||
## Drift detection
|
||||
|
||||
In `--strict` mode, `plan-validator.mjs` emits a `MANIFEST_PROFILE_DRIFT`
|
||||
warning when the plan-level `profile:` differs from any step manifest's
|
||||
`profile_used`. The warning is a *signal*, not a failure — the plan
|
||||
remains `valid: true`. This catches:
|
||||
|
||||
- Manual edits where an operator changed a single step's profile
|
||||
- Resume from a partial run where the previous session used a different
|
||||
tier
|
||||
- Copy-paste errors when stitching plan fragments
|
||||
|
||||
To suppress the warning intentionally (e.g. when a critical step
|
||||
genuinely needs a higher tier), document the override in the step's
|
||||
prose and re-run with `--soft` to validate without strict-mode warnings.
|
||||
|
||||
## Cost estimation
|
||||
|
||||
> **Disclaimer:** the table below is an *anslag*, not a contractual
|
||||
> SLA. Real cost depends on context size, agent-swarm cardinality,
|
||||
> tool-use density, and Claude Code billing schedule. Treat these as
|
||||
> rough order-of-magnitude.
|
||||
|
||||
| Profile | Brief | Research | Plan | Execute | Review | Total |
|
||||
|---------|-------|----------|------|---------|--------|-------|
|
||||
| `economy` | $0.10–0.50 | $0.50–2.00 | $0.50–2.00 | $1.00–5.00 | $0.20–1.00 | **$2–10** |
|
||||
| `balanced` | $0.10–0.50 | $0.50–2.00 | $1.00–4.00 | $1.00–5.00 | $0.50–2.00 | **$3–14** |
|
||||
| `premium` | $0.50–2.00 | $0.50–2.00 | $1.00–4.00 | $1.00–5.00 | $0.50–2.00 | **$4–15** |
|
||||
|
||||
Numbers are per *full pipeline run* (brief + research + plan +
|
||||
execute + review) on a moderate-complexity task. Numbers scale roughly
|
||||
linearly with the size of the resulting plan (10 steps ≈ baseline; 30
|
||||
steps ≈ 3× the execute column).
|
||||
|
||||
The stats records carry no profile field, so they cannot split cost by
|
||||
profile. Replace the table above with your own measured
|
||||
numbers after ≥ 3 runs of each profile.
|
||||
|
||||
## Deferred to v4.2
|
||||
|
||||
- **`balanced.external_research_enabled` operator-override** —
|
||||
v4.1 omits this per scope-guardian SG2. v4.2 may add an opt-in
|
||||
flag to enable external research agents in the balanced tier
|
||||
without forcing premium.
|
||||
- **Empirical Jaccard re-calibration** — parked-synthetic fixtures
|
||||
in v4.1 use a 0.55 conservative starting threshold. v4.2 plans an
|
||||
empirical re-run with $60-120 LLM budget to derive a calibrated
|
||||
threshold from real economy-vs-premium plan pairs.
|
||||
- **ROUGE-L + char-4gram MinHash** as primary/secondary cross-tier
|
||||
gates per research/02 Recommendation #7. Jaccard remains the gate
|
||||
in v4.1; v4.2 may layer ROUGE-L on top.
|
||||
|
||||
## See also
|
||||
|
||||
- [`README.md` § Profile system](../README.md) — top-level overview
|
||||
- [`CLAUDE.md` § Profile system](../CLAUDE.md) — internal reference
|
||||
- [`docs/observability.md`](observability.md) — JSONL → OTel pipeline
|
||||
- [`tests/synthetic/profile-jaccard-calibration.md`](../tests/synthetic/profile-jaccard-calibration.md)
|
||||
— calibration status and threshold rationale
|
||||
- [`lib/profiles/`](../lib/profiles/) — built-in profile YAMLs
|
||||
- [`lib/validators/profile-validator.mjs`](../lib/validators/profile-validator.mjs)
|
||||
— schema validator with CLI shim
|
||||
|
|
@ -33,7 +33,7 @@ By stage — Plan 0/1/0 · Design 0/0/1 · Build 2/3/1 · Test 0/2/0 · Deploy 0
|
|||
| P11 | AI in the PR review loop (Deploy) | partial | `commands/trekreview.md`; `lib/review/rule-catalogue.mjs`; `lib/review/coordinator-contract.mjs` | Independent reviewers, a judge, a versioned rule catalogue and a four-level severity taxonomy are strongly implemented — but the input is a SHA-range diff, not a PR: no forge integration, no code-owner approval, and the verdict blocks nothing |
|
||||
| P12 | Hooks as approval gates (Deploy) | partial | `hooks/scripts/pre-bash-executor.mjs` (asks before merge into main and push); `commands/trekexecute.md` main-merge gate | The guard answers "ask" before merge into main and push, in auto mode too, and the main-merge gate is a lock on the review verdict — but the other approval pauses are `AskUserQuestion` prose in command files |
|
||||
| P13 | CI/CD integration and deployment (Deploy) | none | — | No CI workflows, no deploy/status/rollback tooling, no per-environment autonomy ladder |
|
||||
| P14 | Close the loop from maintenance (Maintain) | none | `hooks/scripts/otel-export.mjs`, `lib/exporters/`, `lib/stats/` | Observability exists but measures Voyage's own runs, not a product in production; no control bands, and the one feedback path (review → plan) is started by a human |
|
||||
| P14 | Close the loop from maintenance (Maintain) | none | — | No control bands, and the one feedback path (review → plan) is started by a human |
|
||||
| P15 | Recurring codebase scans (Maintain) | none | `hooks/scripts/pre-bash-executor.mjs` | No scheduler and no periodic entry point; the only `cron` hits are the rule that blocks crontab persistence |
|
||||
| P16 | Agent on call in chat (Maintain) | none | `README.md` | Declared an intentional omission for a solo project |
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ goes to it under MIT, and to the Anthropic article it is itself derived from.
|
|||
|
||||
| Candidate | Does Voyage already have it | Verdict |
|
||||
|---|---|---|
|
||||
| Hash-chained, tamper-evident approval ledger | No. No hash chain exists anywhere; `lib/util/research-loop-cap.mjs` is append-only but unchained | **Partly.** The idea answers "did we build what we agreed", but a new ledger engine is out of scope; the cheap form is a `prev_hash` field on the event ledger that already exists |
|
||||
| Hash-chained, tamper-evident approval ledger | No. No hash chain exists anywhere | **Partly.** The idea answers "did we build what we agreed", but a new ledger engine is out of scope |
|
||||
| Plan as a machine-readable file list with a lock | Yes — `plan_version 1.7` manifest, `plan-validator.mjs --strict`, and the independent Phase 7.5 audit | **Borrow the boundary only:** their named process-file exclusion list, so a state or docs update does not need a manifest entry |
|
||||
| Short intent and spec templates | Partly — the brief already requires `## Intent`, `## Goal` and `## Success Criteria` | **No** to a separate spec artifact (the operator is both orderer and builder). **Yes** to their "Gotchas" section: one fixed place for where two constraints collided and who decided |
|
||||
| A rule → enforcement table ("advisory or enforced, and where") | No such table; the doc-pins are that idea applied narrowly | **Yes, cheap.** It makes visible which of Voyage's own rules are only prose |
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
# Spike: does a plugin `PreToolUse` hook reach sub-agent tool calls?
|
||||
|
||||
**Date:** 2026-08-09
|
||||
**Claude Code version:** 2.1.226
|
||||
**Plugin:** voyage 5.9.1 (installed from `ktg-plugin-marketplace`)
|
||||
**Gates:** Step 10 of `plan.md` (`2026-06-30-trekresearch-storm-upgrade`)
|
||||
|
||||
## Question
|
||||
|
||||
Step 10 wants to enforce the conversation-turn cap in a `PreToolUse` hook. That
|
||||
is only viable if a **plugin** `PreToolUse` hook fires on tool calls made
|
||||
*inside a sub-agent*. If it does not, the cap must be enforced somewhere else
|
||||
and Step 10 becomes a documented downgrade instead.
|
||||
|
||||
The question is genuinely open, not answerable from docs alone: the current
|
||||
[hooks reference](https://code.claude.com/docs/en/hooks) states that sub-agent
|
||||
tool calls fire the same hooks and carry `agent_id` / `agent_type`, while
|
||||
[issue #34692](https://github.com/anthropics/claude-code/issues/34692) reported
|
||||
the exact opposite behaviour. The answer is therefore version-dependent and had
|
||||
to be measured on the version actually in use.
|
||||
|
||||
## Method
|
||||
|
||||
A one-shot probe hook was registered for the `WebSearch` matcher, and a
|
||||
**headless child session** was launched to exercise it. The child was used
|
||||
because hooks are resolved when a session starts — a matcher added mid-session
|
||||
cannot be observed by the session that added it.
|
||||
|
||||
Two deviations from the step as originally written, both forced and both
|
||||
verified not to affect the result:
|
||||
|
||||
1. **The matcher was injected into the installed plugin's `hooks.json`, not the
|
||||
repository's.** The plan assumed the repo working tree *is* the active plugin
|
||||
root. It is not: `~/.claude/plugins/cache/ktg-plugin-marketplace/voyage/5.9.1/`
|
||||
is a plain directory holding its own copy, and that copy is what loads.
|
||||
Editing `hooks/hooks.json` in the repo would have measured nothing. The cache
|
||||
file was backed up, modified, and restored — verified byte-identical to the
|
||||
repo file afterwards.
|
||||
2. **The probe script lives under the session scratchpad, not `${TMPDIR}`.** A
|
||||
pathguard hook refuses writes to `${TMPDIR}`. The load-bearing property was
|
||||
only that the script sit **outside `hooks/scripts/`**, which
|
||||
`tests/lib/doc-consistency.test.mjs:75-84` counts via `readdirSync`; the
|
||||
scratchpad satisfies that just as well. The directory still holds 7 scripts.
|
||||
|
||||
### Probe hook
|
||||
|
||||
Logged every invocation as one JSON line (`tool_name`, `agent_id`,
|
||||
`agent_type`, plus the untouched stdin) and always exited `0`, so it could not
|
||||
alter the child's behaviour.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# 1. inject the temporary matcher into the INSTALLED plugin
|
||||
node -e '...push {matcher:"WebSearch", ...voyage-spike-hook.mjs} into hooks.PreToolUse...'
|
||||
|
||||
# 2. exercise it from a fresh child session
|
||||
claude -p "Spawn exactly one sub-agent via the Agent tool (subagent_type: general-purpose).
|
||||
Instruct that sub-agent to perform exactly ONE WebSearch for the query
|
||||
'claude code hooks reference' and report back the first result title.
|
||||
You MUST NOT call WebSearch yourself in the main context - only the
|
||||
sub-agent may call it. When the sub-agent returns, reply with the word DONE." \
|
||||
--allowedTools "Agent,Task,WebSearch" \
|
||||
--max-turns 15
|
||||
|
||||
# 3. restore
|
||||
cp "${TMPDIR}voyage-hooks-backup.json" <cache>/hooks/hooks.json
|
||||
```
|
||||
|
||||
The child returned `DONE`.
|
||||
|
||||
> An earlier attempt additionally passed `--permission-mode bypassPermissions`
|
||||
> and was refused by the auto-mode classifier. The flag was dropped;
|
||||
> `--allowedTools` alone was sufficient.
|
||||
|
||||
## Raw observation
|
||||
|
||||
The log contains **exactly one** record — so the main context did not call
|
||||
`WebSearch` itself, and the single entry is unambiguously the sub-agent's call:
|
||||
|
||||
```json
|
||||
{"at":"2026-08-09T12:07:47.796Z","tool_name":"WebSearch",
|
||||
"agent_id":"aa6d19525a4680fe0","agent_type":"general-purpose",
|
||||
"raw_stdin":"{\"session_id\":\"b126fd6a-...\",\"cwd\":\"/Users/ktg/repos/ktg-plugin-marketplace/voyage\",
|
||||
\"permission_mode\":\"auto\",\"agent_id\":\"aa6d19525a4680fe0\",\"agent_type\":\"general-purpose\",
|
||||
\"effort\":{\"level\":\"xhigh\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"WebSearch\",
|
||||
\"tool_input\":{\"query\":\"claude code hooks reference\"},\"tool_use_id\":\"toolu_01Ka4...\"}"}
|
||||
```
|
||||
|
||||
Both `agent_id` and `agent_type` are populated, matching the documented
|
||||
common-input fields for sub-agent-originated tool events. A main-context call
|
||||
would have carried neither.
|
||||
|
||||
## Consequence for Step 10
|
||||
|
||||
A plugin `PreToolUse` hook **does** observe sub-agent tool calls on CC 2.1.226,
|
||||
and can attribute them via `agent_id` / `agent_type`. Step 10 may therefore take
|
||||
the enforcement branch rather than the documented-downgrade branch.
|
||||
|
||||
Two limits worth carrying forward, neither of which changes the verdict:
|
||||
|
||||
- This measures `WebSearch` on one CC version. The behaviour regressed once
|
||||
before (#34692), so the hook must fail **open**, never assume it is the only
|
||||
gate, and the cap must remain correct if the hook silently stops firing.
|
||||
- The probe only establishes *reach*. Whether a **blocking** (exit 2) decision
|
||||
from inside a sub-agent propagates usefully was not measured — the probe
|
||||
always exited 0 by design.
|
||||
|
||||
RESULT: FIRES
|
||||
|
||||
## Enforcement outcome
|
||||
|
||||
Step 10 took the **enforcement branch**: `hooks/scripts/pre-agent-cap.mjs`
|
||||
(PreToolUse, matcher `WebSearch|WebFetch|Task`), pinned by
|
||||
`tests/hooks/agent-cap.test.mjs`.
|
||||
|
||||
What it does: counts turns spent by a run — read-only, from the append-only
|
||||
ledger that `lib/util/research-loop-cap.mjs` writes — and exits 2 once
|
||||
`turns_used >= max_conv_turns × maxDimensions`. It never appends to the
|
||||
ledger; a cap that recorded its own enforcement would count itself.
|
||||
|
||||
Scope key, the part that makes a globally-wired `PreToolUse` hook safe:
|
||||
`session_id` **+** a marker file only the Phase 5 loop writes, at
|
||||
`${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json`:
|
||||
|
||||
```json
|
||||
{ "runId": "<run id>", "startedAt": "<ISO-8601>" }
|
||||
```
|
||||
|
||||
No marker for the calling session ⇒ out of scope ⇒ allow, unconditionally.
|
||||
An unrelated session is never denied because some other run spent its budget.
|
||||
|
||||
Fail-open and fail-closed are split deliberately:
|
||||
|
||||
| Condition | Outcome | Why |
|
||||
|---|---|---|
|
||||
| No marker / no `session_id` / unparsable stdin | allow | Not evidence of a loop turn |
|
||||
| Marker older than TTL (default 2h, `VOYAGE_CAP_SCOPE_TTL_MS`) | allow + auto-reset | A crashed run must not deny tool calls forever, and `--resume` keeps the same `session_id` |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK=1` | allow | Kill switch |
|
||||
| `VOYAGE_STORM_ENABLED` ≠ `1` | allow | Default-off: no loop runs, nothing to enforce |
|
||||
| In scope, `CLAUDE_PLUGIN_DATA` absent | **deny** | A budget control that cannot count must not grant — same stance as `research-loop-cap.mjs` |
|
||||
| In scope, budget spent | **deny (exit 2)** | The bound |
|
||||
|
||||
Both limits recorded above still hold and are not closed by this step. Reach
|
||||
was measured on one CC version for one tool, and blocking-propagation from
|
||||
inside a sub-agent was never measured — so this hook is **defence in depth**,
|
||||
and `research-loop-cap.mjs` must remain correct on its own if the hook
|
||||
silently stops firing.
|
||||
|
||||
**Follow-up, closed (S79).** Step 10 shipped the hook correct but **latent** —
|
||||
nothing wrote the marker, and it enforces exactly when a marker exists.
|
||||
`commands/trekresearch.md` Phase 5 now writes it (`### Loop scope marker`,
|
||||
keyed by `CLAUDE_CODE_SESSION_ID`, carrying the same `run_id` the ledger is
|
||||
counted under) and removes it on all three exits. Cleanup covers exits only; a
|
||||
crashed session is covered by the TTL above. Verified end-to-end: the snippet
|
||||
as shipped arms the hook, the hook denies at `8/8` turns, and the removal
|
||||
snippet returns it to allow so Phase 6 can still spawn agents. Pinned by
|
||||
`tests/lib/doc-consistency.test.mjs` (STORM marker), which derives the
|
||||
directory name from `SCOPE_DIRNAME` in the hook, so renaming either side
|
||||
fails.
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
# STORM adoption gate — pre-registered measurement protocol
|
||||
|
||||
**Status:** thresholds registered, **no measurement run yet.** This document is
|
||||
committed *before* the first measurement by design: a threshold chosen after
|
||||
seeing the numbers is not a threshold. **Harness:** `scripts/storm-measure.mjs`
|
||||
(pinned by `tests/scripts/storm-measure.test.mjs`). **Decides:** whether the
|
||||
bounded Phase 5 conversation loop (`commands/trekresearch.md` §Loop bound) is
|
||||
worth turning on by default.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this gate measures — and what it does not
|
||||
|
||||
The gate measures **source and coverage breadth**:
|
||||
|
||||
| Metric | Definition | Arm |
|
||||
|---|---|---|
|
||||
| `unique_sources` | distinct external sources cited by the run | between-arm median gain |
|
||||
| dimensions over baseline | `(dimensions − dimensions_baseline) / dimensions_baseline` | within-run median across the treatment arm |
|
||||
|
||||
It does **not** measure outline quality, answer correctness, synthesis
|
||||
usefulness, or operator satisfaction. A breadth win is a *necessary* condition
|
||||
for adoption, never a sufficient one. If the loop widens coverage by 40% and
|
||||
the resulting briefs read worse, the correct action is still decline — the
|
||||
number does not overrule a reading of the artifacts.
|
||||
|
||||
The second metric is a within-run delta by construction (`dimensions_baseline`
|
||||
is the interview dimension count, `dimensions` the post-Phase-4.5 list), so it
|
||||
needs no control arm; the control arm's value is 0 because the loop is inert
|
||||
below `effort: high`.
|
||||
|
||||
## 2. Pre-registered thresholds
|
||||
|
||||
Either metric clearing the bar is enough. The brief pre-registers "median
|
||||
forbedring ≥ 30 % på (a) eller (b) → adopt. < 15 % → decline" — a breadth win
|
||||
on one axis counts, because either axis widening is the effect the loop claims.
|
||||
Adopt is evaluated first, so a run that clears the adopt bar on one metric is
|
||||
an adopt even when the other metric sits under the decline bar.
|
||||
|
||||
| Median gain | Verdict | Action |
|
||||
|---|---|---|
|
||||
| ≥ 30% on **either** metric | **adopt** | Flip the `VOYAGE_STORM_ENABLED` default (see §5) |
|
||||
| < 15% on **either** metric (and no adopt) | **decline** | Leave the mechanism default-off. This is a **no-op**: nothing is rolled back |
|
||||
| both metrics in 15% – 30% | **inconclusive** | Keep default-off, gather more runs, re-measure |
|
||||
| either arm empty | **insufficient-data** | Not a decline — measure more |
|
||||
|
||||
`ADOPT_THRESHOLD = 0.30` and `DECLINE_THRESHOLD = 0.15` are exported constants
|
||||
in `scripts/storm-measure.mjs` and pinned by the test suite. Changing them is a
|
||||
deliberate, reviewable act, not a tuning knob to be nudged toward a result.
|
||||
|
||||
## 3. Excluded runs (the honest denominator)
|
||||
|
||||
Runs with `empty_turns > 0` are **excluded from the gain and reported as a
|
||||
count**. An empty turn is one that spent budget and returned no findings, or
|
||||
findings without citations. A run whose `empty_turns` cannot be **read** as a
|
||||
number is excluded on the same footing: a field that says `"many"` is not
|
||||
evidence of zero empty turns, and treating it as one would let the least
|
||||
trustworthy run back into the denominator in the direction that flatters
|
||||
adoption. Including those runs decides adoption on a broken
|
||||
denominator — the loop looks cheap because its failures are averaged into its
|
||||
successes. The harness prints the excluded count on every invocation; if that
|
||||
count is a large fraction of the treatment arm, the finding is about the loop's
|
||||
reliability, and it should be read before the gain figure is read at all.
|
||||
|
||||
Rows predating the Step 9 measurement fields carry no `effort` and are dropped
|
||||
as `legacy` with a count. A stats file where *no* row carries `effort` is a hard
|
||||
error, not an empty treatment group: a schema gap must never present itself as
|
||||
"no gain".
|
||||
|
||||
## 4. The measurement runs (operator-run, outside this plan)
|
||||
|
||||
The measurement itself is **not** part of the implementation plan that built
|
||||
this harness. It is an operator-run gate between that plan and any adopt commit.
|
||||
|
||||
Protocol:
|
||||
|
||||
- **n ≥ 5 runs per arm.** Fewer, and the median is an anecdote.
|
||||
- **The same question set in both arms.** Two briefs, run as `--project` runs.
|
||||
- The arms differ in exactly one thing: whether the loop is enabled.
|
||||
- Both arms append to the same `trekresearch-stats.jsonl`; `effort` is the
|
||||
grouping key that separates them.
|
||||
|
||||
Each run is a Claude Code session the operator opens (Voyage starts none of
|
||||
them; changed 2026-09-27 before any run — the thresholds above are untouched):
|
||||
|
||||
```text
|
||||
# Control arm (loop inert) — n >= 5, in a session opened with VOYAGE_STORM_ENABLED unset
|
||||
/trekresearch --project .claude/projects/<brief-standard-effort>
|
||||
|
||||
# Treatment arm (loop live, effort: high in the brief's phase_signals) — n >= 5,
|
||||
# in a session opened with VOYAGE_STORM_ENABLED=1 in its environment
|
||||
/trekresearch --project .claude/projects/<brief-high-effort>
|
||||
```
|
||||
|
||||
```bash
|
||||
# The gate
|
||||
node scripts/storm-measure.mjs --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
|
||||
# Machine-readable, for a decision record
|
||||
node scripts/storm-measure.mjs --json --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
|
||||
# SC activation check — BOTH halves: did the latest high-effort run discover at
|
||||
# least one dimension, AND is its final list a true SUPERSET of the interview
|
||||
# ones? The second half is read from the run's own `dimensions_baseline_preserved`
|
||||
# attestation, because the dimension NAMES that would show it directly are prose
|
||||
# the exporter allowlist denies. A run that does not attest it FAILS — dropping
|
||||
# two interview dimensions and appending three discovered ones is a +1 count
|
||||
# delta and not a superset. Exit 0 = both halves hold.
|
||||
node scripts/storm-measure.mjs --activation-check --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
```
|
||||
|
||||
## 5. What "adopt" concretely means
|
||||
|
||||
Adoption is **one constant**: `isStormEnabled()` in
|
||||
`lib/util/research-loop-cap.mjs` currently requires `VOYAGE_STORM_ENABLED === '1'`.
|
||||
Adopt = make the loop's budget non-zero without that opt-in, in a commit that
|
||||
cites the measurement output.
|
||||
|
||||
This asymmetry is deliberate and was designed in before any code was written:
|
||||
|
||||
- **decline costs nothing** — the mechanism ships default-off, so declining is
|
||||
doing nothing. No revert, no removal from a command file two other steps
|
||||
already rewrote.
|
||||
- **adopt costs one constant** — plus the enforcement hook
|
||||
(`hooks/scripts/pre-agent-cap.mjs`) already in place to bound what gets turned
|
||||
on, and the operator-visible cap-exhaustion message already required by
|
||||
Phase 5's exit conditions.
|
||||
|
||||
## 6. Reading the result honestly
|
||||
|
||||
- The gate measures breadth. Say "breadth" in the decision record, not "quality".
|
||||
- Report the excluded count alongside the gain, always. A 35% gain computed
|
||||
after excluding 6 of 10 treatment runs is a finding about instability.
|
||||
- `insufficient-data` is not a decline. Do not resolve it by lowering n.
|
||||
- A verdict computed from a stats file mixing several question sets measures the
|
||||
question sets, not the loop.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
description: "/trekplan on a brief that passes the sequencing gate but carries no intent approval halts at the intent gate (veikart steg 1)."
|
||||
description: "/trekplan on a brief that passes the brief validator 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, from=proevesett-intent-gate]
|
||||
runs: 1
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
#!/bin/bash
|
||||
# Seeds the empty run workspace with a project folder whose brief passes the
|
||||
# sequencing gate (brief_version 2.1 WITH phase_signals) but carries no
|
||||
# intent_approved_hash, so /trekplan must stop at the intent-approval gate.
|
||||
# Seeds the empty run workspace with a project folder whose brief is valid but
|
||||
# carries no intent_approved_hash, so /trekplan must stop at the intent-approval gate.
|
||||
# Self-contained on purpose: the run cannot read the eval directory.
|
||||
set -eu
|
||||
mkdir -p proj
|
||||
|
|
@ -18,15 +17,6 @@ research_status: skipped
|
|||
auto_research: false
|
||||
interview_turns: 1
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: low
|
||||
- phase: plan
|
||||
effort: standard
|
||||
- phase: execute
|
||||
effort: standard
|
||||
- phase: review
|
||||
effort: standard
|
||||
---
|
||||
|
||||
# Task: add a greeting helper
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
schema_version: "1.1"
|
||||
name: plan-halts-without-phase-signals
|
||||
context:
|
||||
scaffold_script: scaffold.sh
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
---
|
||||
type: regex
|
||||
pattern: "phase_signals"
|
||||
---
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
type: regex
|
||||
pattern: 'code\W{1,12}BRIEF_V51_MISSING_SIGNALS'
|
||||
target: trace
|
||||
---
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Agent
|
||||
min: 0
|
||||
max: 0
|
||||
---
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
type: regex
|
||||
pattern: 'code\W{1,12}BRIEF_INTENT_'
|
||||
target: trace
|
||||
match: not_contains
|
||||
---
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
type: file_exists
|
||||
path: "**/plan.md"
|
||||
exists: false
|
||||
---
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
type: tool_used
|
||||
tool: Bash
|
||||
input_match: "brief-validator"
|
||||
---
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
---
|
||||
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, from=proevesett-eval-suite]
|
||||
runs: 1
|
||||
max_turns: 15
|
||||
timeout_seconds: 300
|
||||
allowed_tools: [Bash, Read, Glob, Grep, Write, Agent]
|
||||
---
|
||||
|
||||
/voyage:trekplan --project proj
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Seeds the empty run workspace with a project folder whose brief declares
|
||||
# brief_version 2.1 but carries neither phase_signals nor phase_signals_partial.
|
||||
# The intent IS approved (a current intent_approved_hash over ## Intent + ## Goal),
|
||||
# so the intent gate passes and only the sequencing gate can stop the run.
|
||||
# The marker is written literally, not by `intent-approval.mjs --stamp`: --stamp
|
||||
# emits a brief-approved record into the live stats the yardstick reads.
|
||||
# Self-contained on purpose: the run cannot read the eval directory.
|
||||
set -eu
|
||||
mkdir -p proj
|
||||
cat > proj/brief.md <<'BRIEF'
|
||||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-09-23
|
||||
task: "Eval fixture: add a greeting helper"
|
||||
slug: eval-greeting
|
||||
project_dir: proj/
|
||||
research_topics: 0
|
||||
research_status: skipped
|
||||
auto_research: false
|
||||
interview_turns: 1
|
||||
source: fixture
|
||||
intent_approved_hash: "sha256:d435093683f06b5832b1a546c5ea54b9b30aebf556bfbf6b05e91a2a85149542"
|
||||
intent_approved_at: "2026-09-23T00:00:00.000Z"
|
||||
---
|
||||
|
||||
# Task: add a greeting helper
|
||||
|
||||
## Intent
|
||||
|
||||
Eval fixture for the Voyage proevesett. Not a real task.
|
||||
|
||||
## Goal
|
||||
|
||||
A function `greet(name)` returns `Hello, <name>!`.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- `greet("Ada")` returns `Hello, Ada!`.
|
||||
BRIEF
|
||||
|
|
@ -17,11 +17,6 @@ research_status: skipped
|
|||
auto_research: false
|
||||
interview_turns: 1
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: plan
|
||||
effort: low
|
||||
- phase: review
|
||||
effort: low
|
||||
---
|
||||
|
||||
# Task: add a greeting helper
|
||||
|
|
|
|||
|
|
@ -17,11 +17,6 @@ research_status: skipped
|
|||
auto_research: false
|
||||
interview_turns: 1
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: plan
|
||||
effort: low
|
||||
- phase: review
|
||||
effort: low
|
||||
---
|
||||
|
||||
# Task: add a greeting helper
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
# Voyage observability — local Docker Compose stack
|
||||
|
||||
A version-pinned local-development stack for inspecting Voyage v4.1 metrics
|
||||
emitted by `hooks/scripts/otel-export.mjs`. Two complementary paths:
|
||||
|
||||
| Mode | env var | Pull/push | Container that scrapes |
|
||||
|------|---------|-----------|------------------------|
|
||||
| Prometheus textfile | `VOYAGE_EXPORT_MODE=textfile` | voyage writes `voyage.prom` to `./voyage-textfile/`, node-exporter scrapes | `node-exporter` |
|
||||
| OTLP/HTTP | `VOYAGE_EXPORT_MODE=otlp` | voyage POSTs to `http://localhost:4318/v1/metrics` | `otel-collector` (re-exposed as Prometheus on `:8889`) |
|
||||
|
||||
Both modes feed Prometheus → Grafana.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cd examples/observability
|
||||
mkdir -p voyage-textfile
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Endpoints (all bound to `localhost`):
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Prometheus UI | http://localhost:9090 |
|
||||
| Grafana UI | http://localhost:3000 (anonymous Viewer enabled; admin/admin) |
|
||||
| OTLP/HTTP receiver | http://localhost:4318/v1/metrics |
|
||||
| Node Exporter | http://localhost:9100/metrics |
|
||||
| OTel Collector Prometheus exporter | http://localhost:8889/metrics |
|
||||
|
||||
Stop with `docker compose down`. Add `-v` to wipe Prometheus + Grafana volumes.
|
||||
|
||||
## Activating Voyage export
|
||||
|
||||
In another terminal, set one of the env vars before invoking a Voyage command:
|
||||
|
||||
```bash
|
||||
# Path A — textfile mode
|
||||
export VOYAGE_EXPORT_MODE=textfile
|
||||
export VOYAGE_TEXTFILE_DIR="$(pwd)/voyage-textfile"
|
||||
|
||||
# Path B — OTLP mode
|
||||
export VOYAGE_EXPORT_MODE=otlp
|
||||
export VOYAGE_OTEL_ENDPOINT=http://localhost:4318/v1/metrics
|
||||
export VOYAGE_OTEL_ALLOW_PRIVATE=1 # required: localhost is loopback (RFC1122)
|
||||
```
|
||||
|
||||
The Stop hook (wired in `hooks/hooks.json`) will run
|
||||
`hooks/scripts/otel-export.mjs` automatically at session-end.
|
||||
|
||||
See `docs/observability.md` for the full env-var matrix and security notes.
|
||||
|
||||
## Pinned versions (per research/01)
|
||||
|
||||
| Component | Image | Pinned to |
|
||||
|-----------|-------|-----------|
|
||||
| Prometheus | `prom/prometheus` | `v3.0.1` |
|
||||
| Node Exporter | `prom/node-exporter` | `v1.10.2` |
|
||||
| Grafana | `grafana/grafana` | `11.4.0` |
|
||||
| OTel Collector (contrib) | `otel/opentelemetry-collector-contrib` | `0.115.0` |
|
||||
|
||||
These are reference versions for the v4.1 release window; bump only after
|
||||
re-testing the full smoke flow.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Stop-hook export runs at *normal* session end. If Claude Code exits via
|
||||
crash or hard kill, the final metrics for that session are not flushed.
|
||||
Use `--resume` on next start to recover plan/progress state; metrics for
|
||||
the unflushed session will be missing from Prometheus.
|
||||
- The `VOYAGE_OTEL_ALLOW_PRIVATE=1` escape hatch enables sending to
|
||||
RFC1918 addresses (home-lab use). It is **off by default** so accidental
|
||||
internal-network exfiltration is blocked. See `docs/observability.md`.
|
||||
- This stack is for *local development*. Do not expose ports `:9090` /
|
||||
`:3000` / `:4318` outside loopback — Grafana ships with anonymous viewer
|
||||
access enabled and admin/admin credentials.
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
services:
|
||||
# OpenTelemetry Collector — receives OTLP/HTTP push from voyage hooks/scripts/otel-export.mjs
|
||||
# and forwards metrics to Prometheus via prometheus exporter (scrape endpoint :8889)
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:0.115.0
|
||||
container_name: voyage-otel-collector
|
||||
command: ["--config=/etc/otel-collector-config.yaml"]
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
|
||||
ports:
|
||||
- "4317:4317" # OTLP/gRPC (not used by voyage, kept for parity)
|
||||
- "4318:4318" # OTLP/HTTP — voyage sends here when VOYAGE_EXPORT_MODE=otlp
|
||||
- "8889:8889" # Prometheus exporter scrape endpoint
|
||||
restart: unless-stopped
|
||||
|
||||
# Node Exporter with textfile collector — scrapes voyage.prom files written by voyage hooks
|
||||
# when VOYAGE_EXPORT_MODE=textfile. Volume-mount: ./voyage-textfile/ matches voyage default.
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.10.2
|
||||
container_name: voyage-node-exporter
|
||||
command:
|
||||
- "--path.rootfs=/host"
|
||||
- "--collector.textfile.directory=/var/lib/node_exporter/textfile"
|
||||
- "--no-collector.arp"
|
||||
- "--no-collector.bcache"
|
||||
volumes:
|
||||
- ./voyage-textfile:/var/lib/node_exporter/textfile:ro
|
||||
- /:/host:ro,rslave
|
||||
ports:
|
||||
- "9100:9100"
|
||||
restart: unless-stopped
|
||||
|
||||
# Prometheus — scrapes both node-exporter (textfile) and otel-collector (OTLP-derived)
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.0.1
|
||||
container_name: voyage-prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=14d"
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
depends_on:
|
||||
- node-exporter
|
||||
- otel-collector
|
||||
restart: unless-stopped
|
||||
|
||||
# Grafana — preconfigured Prometheus datasource for voyage dashboards
|
||||
grafana:
|
||||
image: grafana/grafana:11.4.0
|
||||
container_name: voyage-grafana
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
|
||||
volumes:
|
||||
- ./grafana-datasource.yml:/etc/grafana/provisioning/datasources/voyage.yml:ro
|
||||
- grafana-data:/var/lib/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- prometheus
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Grafana datasource provisioning for voyage v4.1 observability stack.
|
||||
# Auto-loaded from /etc/grafana/provisioning/datasources/ on Grafana start.
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: true
|
||||
jsonData:
|
||||
timeInterval: 15s
|
||||
httpMethod: POST
|
||||
manageAlerts: false
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# OpenTelemetry Collector config for voyage v4.1
|
||||
# Receives OTLP/HTTP push from hooks/scripts/otel-export.mjs (port 4318)
|
||||
# and exposes a Prometheus scrape endpoint at :8889 for the Prometheus
|
||||
# container to pull voyage metrics.
|
||||
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
|
||||
processors:
|
||||
batch:
|
||||
send_batch_size: 1024
|
||||
timeout: 5s
|
||||
# Conservative resource attribute limits — voyage emits small payloads but
|
||||
# we cap to prevent runaway label-cardinality blowing up Prometheus.
|
||||
memory_limiter:
|
||||
check_interval: 5s
|
||||
limit_mib: 256
|
||||
spike_limit_mib: 64
|
||||
|
||||
exporters:
|
||||
prometheus:
|
||||
endpoint: 0.0.0.0:8889
|
||||
namespace: voyage
|
||||
send_timestamps: true
|
||||
metric_expiration: 5m
|
||||
enable_open_metrics: true
|
||||
# Debug exporter — echoes every received metric to stderr. Useful for
|
||||
# local development; comment out in production to reduce log volume.
|
||||
debug:
|
||||
verbosity: detailed
|
||||
sampling_initial: 5
|
||||
sampling_thereafter: 200
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [memory_limiter, batch]
|
||||
exporters: [prometheus, debug]
|
||||
telemetry:
|
||||
logs:
|
||||
level: info
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# Prometheus config for voyage v4.1 observability stack.
|
||||
# Two scrape targets:
|
||||
# 1. node-exporter — picks up voyage.prom files written by hooks/scripts/otel-export.mjs
|
||||
# when VOYAGE_EXPORT_MODE=textfile (default location: ./voyage-textfile/)
|
||||
# 2. otel-collector — exposes voyage metrics from OTLP push when VOYAGE_EXPORT_MODE=otlp
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
monitor: voyage-local
|
||||
|
||||
scrape_configs:
|
||||
# Path A: voyage textfile mode → node-exporter textfile collector
|
||||
- job_name: voyage-textfile
|
||||
static_configs:
|
||||
- targets: ["node-exporter:9100"]
|
||||
labels:
|
||||
voyage_export_mode: textfile
|
||||
|
||||
# Path B: voyage OTLP mode → otel-collector prometheus exporter
|
||||
- job_name: voyage-otlp
|
||||
static_configs:
|
||||
- targets: ["otel-collector:8889"]
|
||||
labels:
|
||||
voyage_export_mode: otlp
|
||||
|
||||
# Self-scrape so Prometheus shows its own up=1 in dashboards.
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
|
|
@ -21,16 +21,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "WebSearch|WebFetch|Task|Agent",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-agent-cap.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "ExitPlanMode",
|
||||
"hooks": [
|
||||
|
|
@ -54,16 +44,6 @@
|
|||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/post-bash-stats.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash|ExitPlanMode",
|
||||
"hooks": [
|
||||
|
|
@ -108,15 +88,6 @@
|
|||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/otel-export.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// otel-export.mjs — Stop-event hook (CC v2.1.105+)
|
||||
//
|
||||
// Reads ${CLAUDE_PLUGIN_DATA}/trek*-stats.jsonl, applies field-allowlist,
|
||||
// and exports either Prometheus textfile (default off) or OTLP/HTTP.
|
||||
//
|
||||
// Strict opt-in via VOYAGE_EXPORT_MODE env-var:
|
||||
// off (default) — silent exit, no work done
|
||||
// textfile — write voyage.prom to VOYAGE_TEXTFILE_DIR or CLAUDE_PLUGIN_DATA
|
||||
// otlp — POST OTLP/JSON to VOYAGE_OTEL_ENDPOINT (https required for non-private)
|
||||
//
|
||||
// Hard invariants:
|
||||
// - Outer try/catch with process.exit(0). Stats failures MUST NOT block Stop.
|
||||
// - Tail-latency NFR: textfile <5ms p99, otlp <1500ms (AbortController).
|
||||
// - Allowlist redaction MUST happen before any export (CWE-212).
|
||||
// - Path / endpoint validation MUST happen before any I/O (CWE-22, CWE-918).
|
||||
// - All stderr prefixed with [voyage].
|
||||
// - EXDEV mitigation: tmp file in same dir as target (do NOT use atomicWriteJson).
|
||||
|
||||
import { stdin } from 'node:process';
|
||||
import { readFileSync, existsSync, writeFileSync, renameSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { transformToPrometheus } from '../../lib/exporters/textfile-format.mjs';
|
||||
import { transformToOtlpJson } from '../../lib/exporters/otlp-format.mjs';
|
||||
import { validateTextfilePath } from '../../lib/exporters/path-validator.mjs';
|
||||
import { validateOtlpEndpoint } from '../../lib/exporters/endpoint-validator.mjs';
|
||||
import { applyFieldAllowlist } from '../../lib/exporters/field-allowlist.mjs';
|
||||
import { captureTokenUsage } from '../../lib/stats/token-usage.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
let data = '';
|
||||
for await (const chunk of stdin) data += chunk;
|
||||
return data;
|
||||
}
|
||||
|
||||
const VALID_MODES = new Set(['off', 'textfile', 'otlp']);
|
||||
const TEXTFILE_NAME = 'voyage.prom';
|
||||
const TEXTFILE_TMP_NAME = '.voyage-prom.tmp';
|
||||
const OTLP_TIMEOUT_MS = 1500;
|
||||
|
||||
// Map JSONL filename → schema_id for allowlist + exporter grouping
|
||||
const STATS_FILES = [
|
||||
{ file: 'trekbrief-stats.jsonl', schema: 'trekbrief' },
|
||||
{ file: 'trekresearch-stats.jsonl', schema: 'trekresearch' },
|
||||
{ file: 'trekplan-stats.jsonl', schema: 'trekplan' },
|
||||
{ file: 'trekexecute-stats.jsonl', schema: 'trekexecute' },
|
||||
{ file: 'trekreview-stats.jsonl', schema: 'trekreview' },
|
||||
{ file: 'trekcontinue-stats.jsonl', schema: 'trekcontinue' },
|
||||
{ file: 'token-usage-stats.jsonl', schema: 'token-usage' },
|
||||
];
|
||||
|
||||
function loadAndAllowlist(dataDir) {
|
||||
const out = [];
|
||||
for (const { file, schema } of STATS_FILES) {
|
||||
const path = join(dataDir, file);
|
||||
if (!existsSync(path)) continue;
|
||||
let text;
|
||||
try { text = readFileSync(path, 'utf-8'); }
|
||||
catch { continue; }
|
||||
const lines = text.split('\n').filter(l => l.trim());
|
||||
for (const line of lines) {
|
||||
let record;
|
||||
try { record = JSON.parse(line); }
|
||||
catch { continue; }
|
||||
|
||||
let actualSchema = schema;
|
||||
if (schema === 'trekexecute') {
|
||||
if ('event' in record) actualSchema = 'event-emit';
|
||||
else if ('command_excerpt' in record || 'session_id' in record) actualSchema = 'post-bash-stats';
|
||||
}
|
||||
|
||||
out.push(applyFieldAllowlist(record, actualSchema));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function exportTextfile(records, env) {
|
||||
const targetDir = env.VOYAGE_TEXTFILE_DIR || env.CLAUDE_PLUGIN_DATA;
|
||||
if (!targetDir) {
|
||||
process.stderr.write('[voyage] otel-export: textfile mode requires VOYAGE_TEXTFILE_DIR or CLAUDE_PLUGIN_DATA\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = join(targetDir, TEXTFILE_NAME);
|
||||
const allowedRoots = [];
|
||||
if (env.VOYAGE_TEXTFILE_DIR) allowedRoots.push(env.VOYAGE_TEXTFILE_DIR);
|
||||
if (env.CLAUDE_PLUGIN_DATA) allowedRoots.push(env.CLAUDE_PLUGIN_DATA);
|
||||
const pathCheck = validateTextfilePath(targetPath, { allowedRoots });
|
||||
if (!pathCheck.valid) {
|
||||
process.stderr.write(`[voyage] otel-export: invalid textfile path: ${pathCheck.errors[0].message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = transformToPrometheus(records);
|
||||
const tmpPath = join(dirname(targetPath), TEXTFILE_TMP_NAME);
|
||||
try {
|
||||
writeFileSync(tmpPath, text);
|
||||
renameSync(tmpPath, targetPath);
|
||||
} catch (e) {
|
||||
process.stderr.write(`[voyage] otel-export: textfile write failed: ${e.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportOtlp(records, env) {
|
||||
const url = env.VOYAGE_OTEL_ENDPOINT;
|
||||
if (!url) {
|
||||
process.stderr.write('[voyage] otel-export: otlp mode requires VOYAGE_OTEL_ENDPOINT\n');
|
||||
return;
|
||||
}
|
||||
const epCheck = validateOtlpEndpoint(url, { env });
|
||||
if (!epCheck.valid) {
|
||||
process.stderr.write(`[voyage] otel-export: invalid OTLP endpoint: ${epCheck.errors[0].message}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = transformToOtlpJson(records);
|
||||
const body = JSON.stringify(payload);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OTLP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(epCheck.parsed.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'voyage/4.1.0' },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`[voyage] otel-export: OTLP endpoint returned ${res.status}\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
process.stderr.write(`[voyage] otel-export: OTLP request timed out after ${OTLP_TIMEOUT_MS}ms\n`);
|
||||
} else {
|
||||
process.stderr.write(`[voyage] otel-export: OTLP send failed: ${e.message}\n`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const env = process.env;
|
||||
|
||||
// SKAL-2: opt-in main-context token/cost capture (default off → zero added
|
||||
// latency). Rides this existing Stop hook rather than a new hook script —
|
||||
// avoids a second-Stop-hook race and keeps the README hook-count pin intact.
|
||||
// Runs BEFORE the export-mode gate so capture is independent of export.
|
||||
// Fail-open: any error is swallowed; capture must never block Stop.
|
||||
if (env.VOYAGE_TOKEN_METER) {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
if (raw.trim()) {
|
||||
const payload = JSON.parse(raw);
|
||||
captureTokenUsage({
|
||||
transcriptPath: payload.transcript_path,
|
||||
sessionId: payload.session_id,
|
||||
dataDir: env.CLAUDE_PLUGIN_DATA,
|
||||
});
|
||||
}
|
||||
} catch { /* fail-open: never block Stop, never throw */ }
|
||||
}
|
||||
|
||||
const mode = (env.VOYAGE_EXPORT_MODE || 'off').toLowerCase();
|
||||
|
||||
if (mode === 'off') return;
|
||||
if (!VALID_MODES.has(mode)) {
|
||||
process.stderr.write(`[voyage] otel-export: unknown VOYAGE_EXPORT_MODE="${mode}", expected one of [${[...VALID_MODES].join(', ')}]\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dataDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = loadAndAllowlist(dataDir);
|
||||
if (records.length === 0) return;
|
||||
|
||||
if (mode === 'textfile') await exportTextfile(records, env);
|
||||
else if (mode === 'otlp') await exportOtlp(records, env);
|
||||
} catch (e) {
|
||||
try { process.stderr.write(`[voyage] otel-export: unexpected error: ${e.message}\n`); } catch {}
|
||||
}
|
||||
process.exit(0);
|
||||
})();
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// post-bash-stats.mjs — PostToolUse hook (CC v2.1.97+)
|
||||
//
|
||||
// Captures duration_ms from PostToolUse payload for Bash tool calls and
|
||||
// appends a structured stats line to ${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl
|
||||
// when the running session is an trekexecute session.
|
||||
//
|
||||
// Detection: only fires when the tool input matches the verify/checkpoint
|
||||
// pattern of an trekexecute step (i.e., the command was issued from inside
|
||||
// /trekexecute). We err on the side of "log everything in plugin
|
||||
// scope" — duration data is cheap and the alternative is missing real
|
||||
// per-step timings.
|
||||
//
|
||||
// Fail-open invariant: any error → exit 0, no output, no log line.
|
||||
|
||||
import { stdin } from 'node:process';
|
||||
import { appendFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
async function readStdin() {
|
||||
let data = '';
|
||||
for await (const chunk of stdin) data += chunk;
|
||||
return data;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
if (!raw.trim()) return;
|
||||
const payload = JSON.parse(raw);
|
||||
|
||||
if (payload.tool_name !== 'Bash') return;
|
||||
const duration = payload.duration_ms;
|
||||
if (typeof duration !== 'number') return;
|
||||
|
||||
const dataDir = process.env.CLAUDE_PLUGIN_DATA;
|
||||
if (!dataDir) return;
|
||||
|
||||
const cmd = payload.tool_input?.command || '';
|
||||
if (!cmd) return;
|
||||
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
session_id: payload.session_id || null,
|
||||
command_excerpt: cmd.slice(0, 120),
|
||||
duration_ms: duration,
|
||||
success: payload.tool_response?.success !== false,
|
||||
});
|
||||
|
||||
const target = join(dataDir, 'trekexecute-stats.jsonl');
|
||||
try {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
} catch {}
|
||||
appendFileSync(target, line + '\n');
|
||||
} catch {
|
||||
// fail open
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-agent-cap.mjs
|
||||
// Event: PreToolUse (WebSearch | WebFetch | Task | Agent)
|
||||
// Purpose: Enforce the /trekresearch Phase 5 loop bound at the harness level,
|
||||
// so the cap is a reader that fells rather than prose the model obeys.
|
||||
// The subagent tool is `Agent` since Claude Code 2.1.63; the docs keep `Task`
|
||||
// as an alias only "in settings and agent definitions", so the matcher names
|
||||
// both (tests/hooks/storm-matcher.test.mjs).
|
||||
//
|
||||
// Precondition: Voyage's guards are deterministic only when no installed mod
|
||||
// handles `tool.check`. A mod that does answers after the PreToolUse hooks
|
||||
// and can replace their answer (code.claude.com/docs/en/permissions section Extend
|
||||
// permissions with hooks). A hook cannot see whether such a mod is installed.
|
||||
//
|
||||
// Why this exists: the Phase 5 budget gate (lib/util/research-loop-cap.mjs) is
|
||||
// invoked BY the loop. A gate the caller chooses to consult is advice. The
|
||||
// spike in docs/spike-pretooluse-subagent-reach.md (RESULT: FIRES) established
|
||||
// that a plugin PreToolUse hook does observe tool calls made INSIDE sub-agents
|
||||
// on CC 2.1.226, which is what makes a second, non-optional gate possible.
|
||||
//
|
||||
// Two limits carried over from that spike, neither of which changes the design:
|
||||
// - Reach was measured on one CC version and regressed once before (#34692),
|
||||
// so this hook is defence in depth, never the only gate. research-loop-cap
|
||||
// must stay correct if this hook silently stops firing.
|
||||
// - Whether a blocking (exit 2) decision from inside a sub-agent propagates
|
||||
// usefully was NOT measured — the probe always exited 0 by design.
|
||||
//
|
||||
// Scope key — the property that makes this safe to wire globally:
|
||||
// session_id + a scope marker file that only the Phase 5 loop writes, at
|
||||
// <data root>/trekresearch-loop-scope/<session_id>.json, where the data root
|
||||
// comes from research-loop-cap.mjs's resolveDataRoot() — the same function
|
||||
// the writer resolves through, because a writer and a reader that resolve
|
||||
// the root separately are a hook that enforces nothing while reporting that
|
||||
// it does:
|
||||
// { "runId": "<run id>", "startedAt": "<ISO-8601>" }
|
||||
// No marker for this session => out of scope => allow, unconditionally. An
|
||||
// unrelated session must never be denied because some other run spent its
|
||||
// budget; a PreToolUse hook that over-blocks breaks every session on the box.
|
||||
//
|
||||
// Stated limit, because the guarantee above is about OTHER sessions and reads
|
||||
// as broader than it is: resuming a session (`--resume`) keeps the same session_id, so a
|
||||
// resumed session is the same session by this key. If a run reached its cap
|
||||
// and then died before removing the marker, the resume inherits the remainder
|
||||
// of the TTL, for any WebSearch/WebFetch/Task — research or not. Three things
|
||||
// bound it rather than close it: only an EXHAUSTED run denies at all (a
|
||||
// part-spent crash leaves no tombstone and is allowed), the TTL is 2h rather
|
||||
// than a working day, and every denial prints the marker path to delete. A
|
||||
// liveness check would close it properly, but a PreToolUse hook has nothing
|
||||
// trustworthy to check liveness against — the marker's writer is a shell
|
||||
// snippet whose $$ is a subshell, not the session.
|
||||
//
|
||||
// Fail-open vs fail-closed, deliberately split:
|
||||
// - Out of scope (no marker, no session_id, unparsable stdin, stale marker,
|
||||
// kill switch, STORM off) => exit 0. Fail OPEN.
|
||||
// - In scope and over budget => exit 2. Fail CLOSED, mirroring
|
||||
// research-loop-cap.mjs's own stance: a budget control that cannot count
|
||||
// must not grant. (The former "CLAUDE_PLUGIN_DATA absent" deny is gone —
|
||||
// the root now always resolves, so that branch could no longer fire.)
|
||||
// - In scope and the ledger cannot be counted (EISDIR, EACCES, EIO — anything
|
||||
// but ENOENT) => exit 2, same reason. This branch used to ALLOW: the hook
|
||||
// carried a private countTurns() whose catch returned 0, so an unreadable
|
||||
// ledger read as "no turns spent". Counting now goes through the
|
||||
// primitive's exported readLedger(), so reader and writer cannot hold
|
||||
// different rules about what an unreadable ledger means.
|
||||
//
|
||||
// Counting is read-only. The ledger is append-only and written solely by
|
||||
// research-loop-cap.mjs's allowTurn(); if this hook appended, the cap would
|
||||
// count its own enforcement.
|
||||
//
|
||||
// Kill switch: VOYAGE_DISABLE_CAP_HOOK=1 disables enforcement entirely.
|
||||
|
||||
import { readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const { resolveLedgerPath, resolveDataRoot, resolveMaxConvTurns, isStormEnabled, readLedger, MAX_TOTAL_DIMENSIONS } =
|
||||
await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'));
|
||||
|
||||
const SCOPE_DIRNAME = 'trekresearch-loop-scope';
|
||||
// 2h — comfortably longer than any real research run (a 24-turn loop at a couple
|
||||
// of minutes a turn is under an hour), and short enough that debris does not own
|
||||
// the rest of the working day. The TTL is measured from marker.startedAt rather
|
||||
// than from last activity, and resuming a session (`--resume`) keeps the same session_id, so
|
||||
// this window is what a resumed session can inherit from a run that died holding
|
||||
// the marker. It was 6h; nothing needed six.
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
const env = process.env;
|
||||
|
||||
function allow() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function deny(message) {
|
||||
process.stderr.write(`[voyage] BLOCKED: trekresearch loop cap\n${message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 1. Kill switch.
|
||||
if (env.VOYAGE_DISABLE_CAP_HOOK === '1') allow();
|
||||
|
||||
// 2. Default-off: no loop runs unless STORM is enabled, so nothing to enforce.
|
||||
if (!isStormEnabled(env)) allow();
|
||||
|
||||
// 3. Parse stdin. Unparsable input is not evidence of a loop turn.
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
allow();
|
||||
}
|
||||
|
||||
const sessionId = input?.session_id;
|
||||
if (!sessionId || typeof sessionId !== 'string') allow();
|
||||
|
||||
// 4. Resolve the scope marker through the writer's own root resolution.
|
||||
// VOYAGE_CAP_SCOPE_DIR stays as a test/override seam; unset, this lands on
|
||||
// exactly the directory the Phase 5 snippet writes into.
|
||||
const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || resolveDataRoot(env);
|
||||
|
||||
const markerPath = join(scopeDir, SCOPE_DIRNAME, `${sessionId}.json`);
|
||||
if (!existsSync(markerPath)) allow();
|
||||
|
||||
let marker;
|
||||
try {
|
||||
marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
|
||||
} catch {
|
||||
allow(); // A marker we cannot read cannot tell us which run we are in.
|
||||
}
|
||||
|
||||
if (!marker?.runId) allow();
|
||||
|
||||
// 5. TTL / auto-reset. A marker left behind by a crashed run must not deny
|
||||
// tool calls for the rest of the machine's life.
|
||||
const ttlRaw = Number(env.VOYAGE_CAP_SCOPE_TTL_MS);
|
||||
const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? ttlRaw : DEFAULT_TTL_MS;
|
||||
const startedAt = Date.parse(marker.startedAt ?? '');
|
||||
if (!Number.isFinite(startedAt) || Date.now() - startedAt > ttlMs) {
|
||||
try { rmSync(markerPath, { force: true }); } catch { /* best effort */ }
|
||||
allow();
|
||||
}
|
||||
|
||||
// --- In scope from here on. ---
|
||||
|
||||
// 6. The ledger is the only source of truth for turns spent, and it is counted
|
||||
// through the primitive's OWN readLedger(). This hook used to carry a
|
||||
// private copy of the counting rule whose read error returned 0 — so an
|
||||
// unreadable ledger read as "no turns spent" and ALLOWED, in the one branch
|
||||
// where this hook is supposed to fail closed.
|
||||
const ledgerPath = resolveLedgerPath(env);
|
||||
|
||||
// 7. Same bound the primitive uses: turns-per-dimension × the whole dimension
|
||||
// list under the maxDimensions: 8 ceiling.
|
||||
const budget = resolveMaxConvTurns(env) * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
let ledger;
|
||||
try {
|
||||
ledger = readLedger(ledgerPath, marker.runId);
|
||||
} catch (e) {
|
||||
deny(
|
||||
` Run ${marker.runId} is in scope, but its turn ledger could not be read:\n` +
|
||||
` ${e.message}\n` +
|
||||
` A budget control that cannot count must not grant. Fix or remove the\n` +
|
||||
` ledger, or set VOYAGE_DISABLE_CAP_HOOK=1 to disable enforcement.`,
|
||||
);
|
||||
}
|
||||
|
||||
const toolLine =
|
||||
` Tool: ${input?.tool_name ?? 'unknown'}${input?.agent_type ? ` (agent: ${input.agent_type})` : ''}\n`;
|
||||
|
||||
// Every denial names the marker. If this run is over and the marker outlived it
|
||||
// — the loop's own cleanup covers its three exits, but a crash between the
|
||||
// exhaustion record and the removal runs no cleanup at all — deleting this file
|
||||
// is the remedy, and a resumed session (same session_id) would otherwise sit out
|
||||
// the remaining TTL for work that has nothing to do with research.
|
||||
const remedyLines =
|
||||
` If this loop is not running, the marker is debris — delete it:\n` +
|
||||
` ${markerPath}\n` +
|
||||
` It also auto-resets ${Math.round(ttlMs / 3600000)}h after the run started (VOYAGE_CAP_SCOPE_TTL_MS).\n`;
|
||||
|
||||
// 8. The boundary is the TOMBSTONE, not the count.
|
||||
//
|
||||
// allowTurn() appends before the turn runs, so during the final granted turn the
|
||||
// ledger already holds `budget` records. Denying at `granted >= budget` blocked
|
||||
// that turn's own tool calls — the primitive granted B turns and this hook
|
||||
// permitted B-1 — and it forced every exhausted run out through an exit-2 tool
|
||||
// denial rather than the graceful "cap exhausted" exit, the only exit the prose
|
||||
// at commands/trekresearch.md teaches the model to handle.
|
||||
//
|
||||
// Moving the boundary to `granted > budget` alone would have made this hook
|
||||
// unable to fire at all once the claim mechanism made a breached ledger
|
||||
// impossible — a deny branch that cannot be reached is a dead security claim,
|
||||
// not a backstop. So the primitive records its own denials, and the case this
|
||||
// hook exists for is the one it now catches: the gate said no and a tool call
|
||||
// arrived anyway.
|
||||
if (ledger.exhausted > 0) {
|
||||
deny(
|
||||
` Run ${marker.runId} was already denied a turn by the budget gate\n` +
|
||||
` (${ledger.granted}/${budget} loop turns spent), and this call came after it.\n` +
|
||||
toolLine +
|
||||
` Remaining gaps belong in the brief as open questions, not in another turn.\n` +
|
||||
remedyLines +
|
||||
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 9. Backstop for a ledger that exceeded the bound however it managed to.
|
||||
if (ledger.granted > budget) {
|
||||
deny(
|
||||
` Run ${marker.runId} shows ${ledger.granted} granted turns against a budget of ${budget}.\n` +
|
||||
toolLine +
|
||||
` The ledger has been breached; the loop is over regardless of cause.\n` +
|
||||
remedyLines +
|
||||
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
|
||||
);
|
||||
}
|
||||
|
||||
allow();
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
// lib/exporters/endpoint-validator.mjs
|
||||
// Validate OTLP/HTTP endpoint URLs for the OTel exporter.
|
||||
//
|
||||
// CWE-918 (Server-Side Request Forgery) mitigation: reject loopback, RFC-1918,
|
||||
// link-local (cloud metadata 169.254.169.254), and require HTTPS for non-loopback.
|
||||
// Operator opt-in for private endpoints via VOYAGE_OTEL_ALLOW_PRIVATE=1
|
||||
// (legitimate home-lab / docker-compose operator scenario).
|
||||
|
||||
import { ok, fail, issue } from '../util/result.mjs';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']);
|
||||
const LINK_LOCAL_PREFIXES = ['169.254.', 'fe80:'];
|
||||
|
||||
// Cloud metadata service endpoints — PERMANENTLY blocked even with
|
||||
// VOYAGE_OTEL_ALLOW_PRIVATE=1. These addresses expose IAM credentials,
|
||||
// instance secrets, and user-data on AWS/GCP/Azure/AliCloud workloads.
|
||||
// Operator-trust is NOT extended to these specific IPs because the
|
||||
// blast-radius (cloud-account compromise) is qualitatively different
|
||||
// from home-lab RFC-1918 access.
|
||||
const HARD_BLOCKED_HOSTS = new Set([
|
||||
'169.254.169.254', // AWS / GCP / Azure metadata service
|
||||
'100.100.100.200', // AliCloud metadata service
|
||||
'metadata.google.internal',
|
||||
'metadata.azure.com',
|
||||
]);
|
||||
|
||||
function isRfc1918(host) {
|
||||
// 10.0.0.0/8
|
||||
if (/^10\./.test(host)) return true;
|
||||
// 172.16.0.0/12
|
||||
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
|
||||
// 192.168.0.0/16
|
||||
if (/^192\.168\./.test(host)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLoopback(host) {
|
||||
return LOOPBACK_HOSTS.has(host);
|
||||
}
|
||||
|
||||
function isLinkLocal(host) {
|
||||
return LINK_LOCAL_PREFIXES.some(p => host.startsWith(p));
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:a.b.c.d) routes to the embedded IPv4. Node renders
|
||||
// the literal as ::ffff:HHHH:HHHH, so decode the embedded IPv4 and classify on
|
||||
// THAT — otherwise the loopback / RFC-1918 / link-local / cloud-metadata guards
|
||||
// below are all bypassable via the mapped form over https (CWE-918, S21).
|
||||
// Returns dotted-decimal IPv4, or null when host is not an IPv4-mapped literal.
|
||||
function mappedV4(host) {
|
||||
const m = /^::ffff:(.+)$/i.exec(host);
|
||||
if (!m) return null;
|
||||
const rest = m[1];
|
||||
if (rest.includes('.')) {
|
||||
// Dotted form: ::ffff:127.0.0.1
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(rest) ? rest : null;
|
||||
}
|
||||
// Hex-pair form: ::ffff:7f00:1 → two 16-bit groups → 4 octets
|
||||
const parts = rest.split(':');
|
||||
if (parts.length !== 2) return null;
|
||||
const hi = parseInt(parts[0], 16);
|
||||
const lo = parseInt(parts[1], 16);
|
||||
if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff) return null;
|
||||
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an OTLP/HTTP endpoint URL.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{env?: object}} [opts]
|
||||
* @returns {import('../util/result.mjs').Result}
|
||||
*/
|
||||
export function validateOtlpEndpoint(url, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const allowPrivate = env.VOYAGE_OTEL_ALLOW_PRIVATE === '1';
|
||||
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return fail(issue('ENDPOINT_EMPTY', 'Endpoint must be a non-empty string'));
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try { parsed = new URL(url); }
|
||||
catch (e) {
|
||||
return fail(issue('ENDPOINT_PARSE_ERROR', `Invalid URL: ${e.message}`));
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return fail(issue('ENDPOINT_BAD_PROTOCOL',
|
||||
`Endpoint protocol must be http or https, got ${parsed.protocol}`));
|
||||
}
|
||||
|
||||
// Strip brackets from IPv6
|
||||
let host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
// Canonicalize IPv4-mapped IPv6 to its embedded IPv4 so every guard below
|
||||
// classifies (and reports) the address the request would actually reach.
|
||||
const embedded = mappedV4(host);
|
||||
if (embedded) host = embedded;
|
||||
|
||||
// Cloud metadata services — PERMANENTLY blocked. VOYAGE_OTEL_ALLOW_PRIVATE
|
||||
// does NOT override this; metadata endpoints expose IAM credentials.
|
||||
if (HARD_BLOCKED_HOSTS.has(host)) {
|
||||
return fail(issue('ENDPOINT_HARD_BLOCKED',
|
||||
`Endpoint ${host} is permanently blocked (cloud metadata service). ` +
|
||||
`VOYAGE_OTEL_ALLOW_PRIVATE does not override this restriction.`));
|
||||
}
|
||||
|
||||
// Other link-local addresses — rejected unless explicit opt-in
|
||||
if (isLinkLocal(host) && !allowPrivate) {
|
||||
return fail(issue('ENDPOINT_LINK_LOCAL_REJECTED',
|
||||
`Link-local endpoint ${host} rejected (potential cloud-metadata access). ` +
|
||||
`Set VOYAGE_OTEL_ALLOW_PRIVATE=1 to allow.`));
|
||||
}
|
||||
|
||||
// Loopback / RFC-1918 — rejected unless opt-in
|
||||
if (isLoopback(host) && !allowPrivate) {
|
||||
return fail(issue('ENDPOINT_LOOPBACK_REJECTED',
|
||||
`Loopback endpoint ${host} rejected. Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for ` +
|
||||
`home-lab / docker-compose scenarios.`));
|
||||
}
|
||||
|
||||
if (isRfc1918(host) && !allowPrivate) {
|
||||
return fail(issue('ENDPOINT_RFC1918_REJECTED',
|
||||
`RFC-1918 private endpoint ${host} rejected. ` +
|
||||
`Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for home-lab scenarios.`));
|
||||
}
|
||||
|
||||
// For non-loopback, non-private endpoints: require HTTPS
|
||||
const isPrivate = isLoopback(host) || isRfc1918(host) || isLinkLocal(host);
|
||||
if (!isPrivate && parsed.protocol === 'http:') {
|
||||
return fail(issue('ENDPOINT_HTTPS_REQUIRED',
|
||||
`Public endpoint ${host} requires https:// (got http://)`));
|
||||
}
|
||||
|
||||
return ok({ url: parsed.href, host, isPrivate });
|
||||
}
|
||||
|
||||
export { LOOPBACK_HOSTS, LINK_LOCAL_PREFIXES };
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
// lib/exporters/field-allowlist.mjs
|
||||
// CWE-212 (Improper Cross-boundary Removal of Sensitive Data) mitigation:
|
||||
// inline static allowlist that strips PII / high-cardinality fields before
|
||||
// records reach the OTel exporter.
|
||||
//
|
||||
// The allowlist is INLINE STATIC at module-scope (Object.freeze). NEVER read
|
||||
// runtime from tests/fixtures/ — fixture file is AUTHORING reference only
|
||||
// (Source: tests/fixtures/jsonl-schemas.md per Step 1 audit).
|
||||
//
|
||||
// Per-schema allowlists drop:
|
||||
// - command_excerpt (post-bash-stats: arbitrary Bash slice, CWE-212)
|
||||
// - session_id (UUID, high-cardinality, log-only)
|
||||
// - order_id / order_claimed_at / order_reason (trekbrief: coordination
|
||||
// metadata - order ids name private repos; high-cardinality, never exported)
|
||||
// - task / question / project_dir / brief_path / plan / project (PII-ish prose, paths)
|
||||
// - payload (event-emit: open-ended object, allowlist payload-keys instead)
|
||||
// - phase_models (structured object, summarize via `profile` label instead)
|
||||
// - counts (review nested object, flatten to per-severity metrics elsewhere)
|
||||
|
||||
// ---- Per-schema allowlists (Source: tests/fixtures/jsonl-schemas.md) -------
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 1 (trekbrief)
|
||||
const TREKBRIEF_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'slug', 'mode', 'interview_turns', 'review_iterations',
|
||||
'brief_quality', 'research_topics', 'auto_research', 'auto_result',
|
||||
'intent_approved', // veikart steg 1: boolean, whether Phase 4h stamped the intent
|
||||
'profile', 'profile_source',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 2 (trekresearch)
|
||||
// `engine` is a low-cardinality label (swarm|deep-research) emitted at
|
||||
// commands/trekresearch.md:533 and promised in prose (:570-572).
|
||||
// DENY BY OMISSION: question (free prose), project_dir + brief_path
|
||||
// (filesystem paths) are written into the jsonl but MUST NOT reach the
|
||||
// exporter.
|
||||
// The five v5.10 measurement fields are allowlisted too: `effort` is a
|
||||
// low-cardinality label (low|standard|high) and the grouping key the
|
||||
// measurement gate is computed on; `unique_sources`, `dimensions_baseline`,
|
||||
// `conv_turns` and `empty_turns` are plain counters. None of them carry prose
|
||||
// or paths. `dimensions_baseline_preserved` is a boolean, and it exists BECAUSE
|
||||
// prose is denied here: the activation check needs to know that the final
|
||||
// dimension list is a superset of the interview-derived one, and the dimension
|
||||
// NAMES that would show it directly are free prose that must not reach the
|
||||
// exporter. A boolean attestation carries the fact without the payload.
|
||||
const TREKRESEARCH_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'slug', 'mode', 'scope', 'engine', 'dimensions', 'agents_local',
|
||||
'agents_external', 'gemini_used', 'confidence', 'contradictions',
|
||||
'open_questions', 'profile', 'parallel_agents',
|
||||
'external_research_enabled', 'profile_source',
|
||||
'effort', 'unique_sources', 'dimensions_baseline', 'conv_turns',
|
||||
'empty_turns', 'dimensions_baseline_preserved',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 3 (trekplan)
|
||||
const TREKPLAN_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'slug', 'mode', 'codebase_size', 'codebase_files',
|
||||
'agents_deployed', 'deep_dives', 'research_briefs_used',
|
||||
'research_scout_used', 'critic_verdict', 'guardian_verdict', 'outcome',
|
||||
'profile', 'parallel_agents', 'profile_source',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 4 (trekexecute Phase 9 record)
|
||||
const TREKEXECUTE_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'plan_type', 'mode', 'result', 'steps_total', 'steps_passed',
|
||||
'steps_failed', 'steps_skipped', 'failed_at_step',
|
||||
'profile', 'profile_source',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 5 (trekexecute autonomy events)
|
||||
const EVENT_EMIT_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'event', 'known_event',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 5 (event-emit payload sub-allowlist)
|
||||
const EVENT_EMIT_PAYLOAD_ALLOWED = Object.freeze(new Set([
|
||||
'profile', 'phase_models', 'parallel_agents',
|
||||
'external_research_enabled', 'profile_source',
|
||||
'brief_quality', 'plan_grade',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 6 (post-bash-stats — PII-flag)
|
||||
// CWE-212: command_excerpt + session_id MUST be stripped.
|
||||
const POST_BASH_STATS_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'duration_ms', 'success',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 7 (trekreview)
|
||||
const TREKREVIEW_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'slug', 'verdict', 'reviewed_files_count', 'mode',
|
||||
'duration_ms', 'profile', 'profile_source',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 8 (trekcontinue)
|
||||
const TREKCONTINUE_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'next_session_label', 'status', 'profile', 'profile_source',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 9 (token-usage — SKAL-2)
|
||||
// CWE-212: numeric + low-cardinality-label fields ONLY. DENY BY OMISSION:
|
||||
// session_id (UUID), transcript_path (filesystem path), cwd (filesystem path)
|
||||
// are written into the jsonl for upsert keying but MUST NOT reach the exporter.
|
||||
const TOKEN_USAGE_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'scope', 'model',
|
||||
'tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read',
|
||||
'cost_usd', 'is_estimate', 'price_table_version',
|
||||
]));
|
||||
|
||||
// Schema-id → allowlist set
|
||||
const SCHEMA_ALLOWLISTS = Object.freeze({
|
||||
'trekbrief': TREKBRIEF_ALLOWED,
|
||||
'trekresearch': TREKRESEARCH_ALLOWED,
|
||||
'trekplan': TREKPLAN_ALLOWED,
|
||||
'trekexecute': TREKEXECUTE_ALLOWED,
|
||||
'event-emit': EVENT_EMIT_ALLOWED,
|
||||
'post-bash-stats': POST_BASH_STATS_ALLOWED,
|
||||
'post_bash_stats': POST_BASH_STATS_ALLOWED, // common alt-spelling
|
||||
'trekreview': TREKREVIEW_ALLOWED,
|
||||
'trekcontinue': TREKCONTINUE_ALLOWED,
|
||||
'token-usage': TOKEN_USAGE_ALLOWED,
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply field-allowlist to a record. Drops any field not in the schema's
|
||||
* allowlist. Returns a new object (does not mutate input).
|
||||
*
|
||||
* Always preserves `_schema_id` (caller-supplied identifier used by exporters
|
||||
* to group records by source).
|
||||
*
|
||||
* @param {object} record
|
||||
* @param {string} schemaType Schema-id (e.g. 'trekplan', 'post-bash-stats')
|
||||
* @returns {object} Redacted record with only allowlisted fields.
|
||||
*/
|
||||
export function applyFieldAllowlist(record, schemaType) {
|
||||
if (!record || typeof record !== 'object') return {};
|
||||
const allowed = SCHEMA_ALLOWLISTS[schemaType];
|
||||
if (!allowed) {
|
||||
// Unknown schema-type: be conservative — keep ts only + _schema_id.
|
||||
return record.ts ? { _schema_id: schemaType, ts: record.ts } : { _schema_id: schemaType };
|
||||
}
|
||||
const out = { _schema_id: schemaType };
|
||||
for (const k of allowed) {
|
||||
if (k in record) out[k] = record[k];
|
||||
}
|
||||
|
||||
// Special: event-emit records have a `payload` sub-object that needs its
|
||||
// own allowlist applied recursively.
|
||||
if (schemaType === 'event-emit' && record.payload && typeof record.payload === 'object') {
|
||||
const subOut = {};
|
||||
for (const k of EVENT_EMIT_PAYLOAD_ALLOWED) {
|
||||
if (k in record.payload) subOut[k] = record.payload[k];
|
||||
}
|
||||
out.payload = subOut;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export {
|
||||
SCHEMA_ALLOWLISTS,
|
||||
EVENT_EMIT_PAYLOAD_ALLOWED,
|
||||
POST_BASH_STATS_ALLOWED,
|
||||
TREKBRIEF_ALLOWED,
|
||||
TREKRESEARCH_ALLOWED,
|
||||
TREKPLAN_ALLOWED,
|
||||
TREKEXECUTE_ALLOWED,
|
||||
TREKREVIEW_ALLOWED,
|
||||
TREKCONTINUE_ALLOWED,
|
||||
TOKEN_USAGE_ALLOWED,
|
||||
};
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
// lib/exporters/otlp-format.mjs
|
||||
// Pure transform: voyage JSONL stats records → OTLP/JSON v1.0 metrics payload.
|
||||
//
|
||||
// Per OpenTelemetry Protocol § metrics.proto + research/01 dim 4 (CRITICAL):
|
||||
// AggregationTemporality enum values are INTEGERS in JSON, NOT strings.
|
||||
// "CUMULATIVE" → 2 (not the string)
|
||||
// "DELTA" → 1
|
||||
// timeUnixNano is uint64 over the wire — emit as decimal STRING in JSON to
|
||||
// avoid JS Number precision loss (per research/01 + risk-assessor CRITICAL 2).
|
||||
//
|
||||
// Output contract:
|
||||
// {
|
||||
// resourceMetrics: [{
|
||||
// resource: { attributes: [...] },
|
||||
// scopeMetrics: [{
|
||||
// scope: { name: 'voyage', version: '...' },
|
||||
// metrics: [{
|
||||
// name: 'voyage.<metric>',
|
||||
// description: '...',
|
||||
// unit: '1' | 'ms' | ...,
|
||||
// sum: { dataPoints: [{ ... aggregationTemporality: <int> ...}] }
|
||||
// | gauge: { dataPoints: [...] }
|
||||
// }]
|
||||
// }]
|
||||
// }]
|
||||
// }
|
||||
|
||||
// ---- Inline integer enum constants (CRITICAL: integers, NOT strings) -------
|
||||
|
||||
const AGG_TEMPORALITY_UNSPECIFIED = 0;
|
||||
const AGG_TEMPORALITY_DELTA = 1;
|
||||
const AGG_TEMPORALITY_CUMULATIVE = 2;
|
||||
|
||||
const DATA_POINT_FLAGS_NONE = 0;
|
||||
const DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK = 1;
|
||||
|
||||
const VOYAGE_SCOPE_NAME = 'voyage';
|
||||
const VOYAGE_SCOPE_VERSION = '4.1.0';
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert ISO-8601 timestamp to OTLP timeUnixNano (uint64 as decimal STRING).
|
||||
* Avoids Number precision loss for nanosecond-scale values.
|
||||
*/
|
||||
function toUnixNanoString(iso) {
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return '0';
|
||||
// ms × 1e6 = nanoseconds; use BigInt for precision
|
||||
return (BigInt(ms) * 1000000n).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OTLP attribute object: {key, value: {stringValue: "..."}} or numeric variants.
|
||||
*/
|
||||
function attribute(key, value) {
|
||||
if (typeof value === 'string') return { key, value: { stringValue: value } };
|
||||
if (typeof value === 'boolean') return { key, value: { boolValue: value } };
|
||||
if (typeof value === 'number' && Number.isInteger(value)) return { key, value: { intValue: String(value) } };
|
||||
if (typeof value === 'number') return { key, value: { doubleValue: value } };
|
||||
return { key, value: { stringValue: String(value) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition record into numeric metrics and string/bool labels.
|
||||
* (Same convention as textfile-format.mjs.)
|
||||
*/
|
||||
function partitionRecord(record) {
|
||||
const labels = {};
|
||||
const metrics = {};
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
if (k === 'ts' || k === '_schema_id') continue;
|
||||
if (typeof v === 'number') metrics[k] = v;
|
||||
else if (typeof v === 'boolean') metrics[k] = v ? 1 : 0;
|
||||
else if (typeof v === 'string') labels[k] = v;
|
||||
}
|
||||
return { labels, metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OTLP DataPoint object for a numeric value.
|
||||
*/
|
||||
function dataPoint(value, ts, labels) {
|
||||
const tsNano = toUnixNanoString(ts);
|
||||
return {
|
||||
attributes: Object.entries(labels).map(([k, v]) => attribute(k, v)),
|
||||
startTimeUnixNano: tsNano,
|
||||
timeUnixNano: tsNano,
|
||||
asDouble: Number.isInteger(value) ? undefined : value,
|
||||
asInt: Number.isInteger(value) ? String(value) : undefined,
|
||||
flags: DATA_POINT_FLAGS_NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide metric "kind": sum (for *_total/*_count/*_passed/*_failed) or gauge.
|
||||
* Sum metrics get aggregationTemporality + isMonotonic; gauges get neither.
|
||||
*/
|
||||
function isSumMetric(name) {
|
||||
return /_total$|_count$|_passed$|_failed$|_skipped$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform JSONL records → OTLP/JSON metrics payload. Pure function.
|
||||
*
|
||||
* @param {Array<object>} records Allowlist-redacted records (caller responsibility).
|
||||
* @param {{help?: object}} [opts]
|
||||
* @returns {object} OTLP-shaped payload (POST body for /v1/metrics).
|
||||
*/
|
||||
export function transformToOtlpJson(records, opts = {}) {
|
||||
const helpMap = opts.help || {};
|
||||
|
||||
if (!Array.isArray(records) || records.length === 0) {
|
||||
return {
|
||||
resourceMetrics: [{
|
||||
resource: { attributes: [attribute('service.name', VOYAGE_SCOPE_NAME)] },
|
||||
scopeMetrics: [{
|
||||
scope: { name: VOYAGE_SCOPE_NAME, version: VOYAGE_SCOPE_VERSION },
|
||||
metrics: [],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Group all data points by metric name (schema_id_field).
|
||||
const metricsMap = new Map();
|
||||
|
||||
for (const record of records) {
|
||||
const schemaId = (record && typeof record._schema_id === 'string') ? record._schema_id : 'unknown';
|
||||
const ts = record.ts || new Date().toISOString();
|
||||
const { labels, metrics } = partitionRecord(record);
|
||||
const allLabels = { ...labels, _schema_id: schemaId };
|
||||
|
||||
for (const [field, value] of Object.entries(metrics)) {
|
||||
const name = `${VOYAGE_SCOPE_NAME}.${schemaId}.${field}`;
|
||||
if (!metricsMap.has(name)) {
|
||||
metricsMap.set(name, {
|
||||
name,
|
||||
description: helpMap[name] || `voyage stats — ${schemaId}.${field}`,
|
||||
unit: /_ms$|_duration/.test(field) ? 'ms' : (/_seconds$/.test(field) ? 's' : '1'),
|
||||
dataPoints: [],
|
||||
isSum: isSumMetric(name),
|
||||
});
|
||||
}
|
||||
metricsMap.get(name).dataPoints.push(dataPoint(value, ts, allLabels));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort metrics for deterministic output
|
||||
const sortedNames = [...metricsMap.keys()].sort();
|
||||
const otlpMetrics = sortedNames.map(name => {
|
||||
const m = metricsMap.get(name);
|
||||
if (m.isSum) {
|
||||
return {
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
unit: m.unit,
|
||||
sum: {
|
||||
dataPoints: m.dataPoints,
|
||||
aggregationTemporality: AGG_TEMPORALITY_CUMULATIVE, // INTEGER 2
|
||||
isMonotonic: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
unit: m.unit,
|
||||
gauge: {
|
||||
dataPoints: m.dataPoints,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
resourceMetrics: [{
|
||||
resource: {
|
||||
attributes: [
|
||||
attribute('service.name', VOYAGE_SCOPE_NAME),
|
||||
attribute('service.version', VOYAGE_SCOPE_VERSION),
|
||||
],
|
||||
},
|
||||
scopeMetrics: [{
|
||||
scope: { name: VOYAGE_SCOPE_NAME, version: VOYAGE_SCOPE_VERSION },
|
||||
metrics: otlpMetrics,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
AGG_TEMPORALITY_UNSPECIFIED,
|
||||
AGG_TEMPORALITY_DELTA,
|
||||
AGG_TEMPORALITY_CUMULATIVE,
|
||||
DATA_POINT_FLAGS_NONE,
|
||||
DATA_POINT_FLAGS_NO_RECORDED_VALUE_MASK,
|
||||
};
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
// lib/exporters/path-validator.mjs
|
||||
// Validate textfile output paths for the OTel exporter.
|
||||
//
|
||||
// CWE-22 (Path Traversal) mitigation: restrict writes to allowlist-anchored
|
||||
// directories only. Reject `..`, absolute system paths (`/etc`, `/proc`, `/sys`,
|
||||
// `/var/`, `/usr/`), home-shorthand `~`, and resolve symlinks via
|
||||
// `fs.realpathSync` before checking.
|
||||
|
||||
import { realpathSync, existsSync, statSync } from 'node:fs';
|
||||
import { resolve, normalize, sep } from 'node:path';
|
||||
import { ok, fail, issue } from '../util/result.mjs';
|
||||
|
||||
// macOS quirk: /etc, /tmp, /var are symlinks to /private/etc, /private/tmp,
|
||||
// /private/var; realpathSync resolves the symlink and adds the /private prefix.
|
||||
// Include both forms so the deny check works on macOS + Linux.
|
||||
const FORBIDDEN_PREFIXES = [
|
||||
'/etc/', '/private/etc/',
|
||||
'/proc/',
|
||||
'/sys/',
|
||||
'/var/', '/private/var/',
|
||||
'/usr/',
|
||||
'/bin/',
|
||||
'/sbin/',
|
||||
'/boot/',
|
||||
'/dev/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate that a path is safe for the OTel textfile exporter to write.
|
||||
*
|
||||
* @param {string} path Caller-supplied path.
|
||||
* @param {{
|
||||
* allowedRoots?: string[] // additional allow-list roots (e.g. CLAUDE_PLUGIN_DATA, VOYAGE_TEXTFILE_DIR)
|
||||
* }} [opts]
|
||||
* @returns {import('../util/result.mjs').Result}
|
||||
*/
|
||||
export function validateTextfilePath(path, opts = {}) {
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
return fail(issue('PATH_EMPTY', 'Path must be a non-empty string'));
|
||||
}
|
||||
|
||||
// Reject home-shorthand — caller must expand explicitly
|
||||
if (path.startsWith('~')) {
|
||||
return fail(issue('PATH_HOME_SHORTHAND', `Path uses ~ shorthand (caller must expand): ${path}`));
|
||||
}
|
||||
|
||||
// Normalize to absolute (relative becomes resolved against cwd)
|
||||
const normalized = normalize(path);
|
||||
|
||||
// Reject any path component containing `..` (traversal attempt)
|
||||
// Even after normalize, if `..` survives, the path leaves intended root.
|
||||
const segments = normalized.split(sep);
|
||||
if (segments.some(s => s === '..')) {
|
||||
return fail(issue('PATH_TRAVERSAL', `Path contains traversal segment "..": ${path}`));
|
||||
}
|
||||
|
||||
const absolute = resolve(normalized);
|
||||
|
||||
// Resolve symlinks if file exists; if it doesn't exist yet, resolve parent
|
||||
let resolved;
|
||||
try {
|
||||
if (existsSync(absolute)) {
|
||||
resolved = realpathSync(absolute);
|
||||
} else {
|
||||
// Resolve parent dir (which must exist for any meaningful write target)
|
||||
const parent = absolute.split(sep).slice(0, -1).join(sep) || '/';
|
||||
if (!existsSync(parent)) {
|
||||
return fail(issue('PATH_PARENT_MISSING', `Parent directory does not exist: ${parent}`));
|
||||
}
|
||||
resolved = realpathSync(parent) + sep + absolute.split(sep).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
return fail(issue('PATH_RESOLVE_ERROR', `realpath failed: ${e.message}`));
|
||||
}
|
||||
|
||||
// If allowedRoots is provided, that's the primary defense — caller has
|
||||
// explicitly opted into a root. Reject anything outside; accept anything
|
||||
// inside (callers vetting their roots is the threat model).
|
||||
if (Array.isArray(opts.allowedRoots) && opts.allowedRoots.length > 0) {
|
||||
const inside = opts.allowedRoots.some(root => {
|
||||
if (typeof root !== 'string' || root.length === 0) return false;
|
||||
let resolvedRoot;
|
||||
try { resolvedRoot = realpathSync(root); }
|
||||
catch { resolvedRoot = resolve(root); }
|
||||
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + sep);
|
||||
});
|
||||
if (!inside) {
|
||||
return fail(issue('PATH_OUT_OF_ALLOWLIST',
|
||||
`Path ${resolved} is not under any allowed root: ${opts.allowedRoots.join(', ')}`));
|
||||
}
|
||||
return ok({ path: resolved });
|
||||
}
|
||||
|
||||
// No allowedRoots: fall back to forbidden-system-prefix denylist.
|
||||
for (const prefix of FORBIDDEN_PREFIXES) {
|
||||
if (resolved.startsWith(prefix)) {
|
||||
return fail(issue('PATH_FORBIDDEN_SYSTEM',
|
||||
`Path resolves into forbidden system directory ${prefix}: ${resolved}`));
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ path: resolved });
|
||||
}
|
||||
|
||||
export { FORBIDDEN_PREFIXES };
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
// lib/exporters/textfile-format.mjs
|
||||
// Pure transform: voyage JSONL stats records → Prometheus text-format 0.0.4.
|
||||
//
|
||||
// Output contract (Prometheus exposition format 0.0.4):
|
||||
// # HELP voyage_<metric_name> <description>
|
||||
// # TYPE voyage_<metric_name> {gauge|counter|histogram}
|
||||
// voyage_<metric_name>{label="value",...} <numeric_value>
|
||||
//
|
||||
// Hard rules:
|
||||
// - NO client-side timestamps (per research/01 — node_exporter#1284 known issue
|
||||
// where stale textfile samples re-emit with old timestamps).
|
||||
// - Allowlist-redacted records ONLY (caller must apply field-allowlist first).
|
||||
// - UTF-8 metric names normalized: dots/dashes → underscore, lowercase, prefixed `voyage_`.
|
||||
// - Empty input → empty string output (no headers, no errors).
|
||||
|
||||
const METRIC_PREFIX = 'voyage_';
|
||||
|
||||
/**
|
||||
* Normalize a JSONL field name to a Prometheus-safe metric name.
|
||||
* Per Prometheus 3.0 rules: [a-zA-Z_:][a-zA-Z0-9_:]*. Replace dot/dash/space → '_'.
|
||||
*/
|
||||
function normalizeMetricName(name) {
|
||||
const safe = String(name).toLowerCase().replace(/[.\-\s]+/g, '_').replace(/[^a-zA-Z0-9_:]/g, '_');
|
||||
return METRIC_PREFIX + safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a Prometheus label value per spec § Format: backslash, double-quote, newline.
|
||||
*/
|
||||
function escapeLabel(v) {
|
||||
return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which fields in a record are numeric metrics vs string labels.
|
||||
* Numeric → emitted as samples. String/bool → labels on the sample.
|
||||
*/
|
||||
function partitionRecord(record) {
|
||||
const labels = {};
|
||||
const metrics = {};
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
if (k === 'ts') continue; // never emit ts as label nor metric (Prom server timestamps)
|
||||
if (typeof v === 'number') metrics[k] = v;
|
||||
else if (typeof v === 'boolean') metrics[k] = v ? 1 : 0;
|
||||
else if (typeof v === 'string') labels[k] = v;
|
||||
// Skip arrays/objects — caller's allowlist should have flattened these.
|
||||
}
|
||||
return { labels, metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Group records by their schema_id (an extracted convention: schema_id is the
|
||||
* caller-provided identifier; if absent, fall back to 'unknown').
|
||||
*/
|
||||
function groupBySchema(records) {
|
||||
const groups = new Map();
|
||||
for (const r of records) {
|
||||
const id = (r && typeof r._schema_id === 'string') ? r._schema_id : 'unknown';
|
||||
if (!groups.has(id)) groups.set(id, []);
|
||||
groups.get(id).push(r);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform JSONL records into Prometheus text-format. Pure function.
|
||||
*
|
||||
* @param {Array<object>} records Allowlist-redacted records (caller responsibility).
|
||||
* @param {{help?: object}} [opts] Optional: help-text overrides per metric (object).
|
||||
* @returns {string} Prometheus text-format. Empty input → empty string.
|
||||
*/
|
||||
export function transformToPrometheus(records, opts = {}) {
|
||||
if (!Array.isArray(records) || records.length === 0) return '';
|
||||
|
||||
const lines = [];
|
||||
const helpMap = opts.help || {};
|
||||
const groups = groupBySchema(records);
|
||||
|
||||
// Track which metric names we've emitted HELP/TYPE for (per Prometheus spec:
|
||||
// emit HELP/TYPE once per metric, then all samples for that metric).
|
||||
const emittedMeta = new Set();
|
||||
|
||||
// First pass: collect all unique (schema, metric_field) → sample lines
|
||||
// grouped by metric so HELP/TYPE come before all samples for that metric.
|
||||
const samplesByMetric = new Map();
|
||||
|
||||
for (const [schemaId, group] of groups.entries()) {
|
||||
for (const record of group) {
|
||||
const { labels, metrics } = partitionRecord(record);
|
||||
const labelStr = Object.entries(labels)
|
||||
.map(([k, v]) => `${k}="${escapeLabel(v)}"`)
|
||||
.join(',');
|
||||
const labelBlock = labelStr ? `{${labelStr}}` : '';
|
||||
|
||||
for (const [metricField, value] of Object.entries(metrics)) {
|
||||
const metricName = normalizeMetricName(`${schemaId}_${metricField}`);
|
||||
if (!samplesByMetric.has(metricName)) {
|
||||
samplesByMetric.set(metricName, []);
|
||||
}
|
||||
samplesByMetric.get(metricName).push(`${metricName}${labelBlock} ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort metric names for deterministic output (snapshot-test-friendly)
|
||||
const sortedMetrics = [...samplesByMetric.keys()].sort();
|
||||
|
||||
for (const metricName of sortedMetrics) {
|
||||
const help = helpMap[metricName] || `voyage stats — ${metricName.slice(METRIC_PREFIX.length)}`;
|
||||
const type = inferMetricType(metricName);
|
||||
if (!emittedMeta.has(metricName)) {
|
||||
lines.push(`# HELP ${metricName} ${help}`);
|
||||
lines.push(`# TYPE ${metricName} ${type}`);
|
||||
emittedMeta.add(metricName);
|
||||
}
|
||||
// Sort samples for determinism
|
||||
const samples = samplesByMetric.get(metricName).sort();
|
||||
for (const s of samples) lines.push(s);
|
||||
}
|
||||
|
||||
return lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: counter for *_total / *_count / *_passed / *_failed; histogram for
|
||||
* *_ms / *_duration / *_p50 / *_p99; gauge for everything else (per Prometheus
|
||||
* conventions). Pure & deterministic.
|
||||
*/
|
||||
function inferMetricType(metricName) {
|
||||
if (/_total$|_count$|_passed$|_failed$|_skipped$/.test(metricName)) return 'counter';
|
||||
if (/_ms$|_duration|_p\d+$|_seconds$/.test(metricName)) return 'histogram';
|
||||
return 'gauge';
|
||||
}
|
||||
|
||||
export { normalizeMetricName, partitionRecord, inferMetricType };
|
||||
|
|
@ -13,36 +13,46 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
// Flags retired with a value (cleanup R-b), per command that had them: the
|
||||
// flag and the word after it, when that word is not a flag, are consumed and
|
||||
// dropped, never an error.
|
||||
const RETIRED_PROFILE = ['--profile'];
|
||||
const RETIRED_VALUED = {
|
||||
trekbrief: RETIRED_PROFILE, trekplan: RETIRED_PROFILE, trekexecute: RETIRED_PROFILE,
|
||||
trekreview: RETIRED_PROFILE, trekcontinue: RETIRED_PROFILE,
|
||||
trekresearch: [...RETIRED_PROFILE, '--engine'],
|
||||
};
|
||||
|
||||
const FLAG_SCHEMA = {
|
||||
trekbrief: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: ['--profile', '--approve'],
|
||||
valued: ['--approve'],
|
||||
aliases: {},
|
||||
},
|
||||
trekresearch: {
|
||||
boolean: ['--quick', '--local', '--external', '--fg'],
|
||||
valued: ['--project', '--profile'],
|
||||
valued: ['--project'],
|
||||
aliases: {},
|
||||
},
|
||||
trekplan: {
|
||||
boolean: ['--quick', '--fg'],
|
||||
valued: ['--project', '--brief', '--export', '--decompose', '--profile'],
|
||||
valued: ['--project', '--brief', '--export', '--decompose'],
|
||||
multi: ['--research'],
|
||||
aliases: {},
|
||||
},
|
||||
trekexecute: {
|
||||
boolean: ['--resume', '--dry-run', '--validate', '--fg'],
|
||||
valued: ['--project', '--step', '--session', '--profile'],
|
||||
valued: ['--project', '--step', '--session'],
|
||||
aliases: {},
|
||||
},
|
||||
trekreview: {
|
||||
boolean: ['--quick', '--fg', '--dry-run', '--validate', '--answers'],
|
||||
valued: ['--project', '--since', '--profile'],
|
||||
valued: ['--project', '--since'],
|
||||
aliases: {},
|
||||
},
|
||||
trekcontinue: {
|
||||
boolean: ['--help', '--cleanup', '--confirm', '--dry-run'],
|
||||
valued: ['--profile'],
|
||||
valued: [],
|
||||
aliases: {},
|
||||
},
|
||||
trekendsession: {
|
||||
|
|
@ -111,6 +121,11 @@ export function parseArgs(argString, command) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if ((RETIRED_VALUED[command] || []).includes(tok)) {
|
||||
if (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (schema.multi && schema.multi.includes(tok)) {
|
||||
const collected = [];
|
||||
while (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
|
||||
|
|
|
|||
|
|
@ -31,20 +31,7 @@ const OPTIONAL_KEYS = [
|
|||
|
||||
const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS);
|
||||
|
||||
// Optional string-typed manifest keys (v4.1 Step 3 — additive forward-compat).
|
||||
// `profile_used`: name of the model profile (economy|balanced|premium|fable|<custom>) the
|
||||
// step was executed under. Absence is fine (v4.0 manifests have no
|
||||
// profile concept); presence MUST be a string.
|
||||
// Unlike OPTIONAL_BOOLEAN_KEYS, absence is NOT defaulted — the field is simply
|
||||
// missing from `parsed` so downstream consumers can distinguish "not set" from
|
||||
// "explicitly empty string".
|
||||
const OPTIONAL_STRING_KEYS = [
|
||||
'profile_used',
|
||||
];
|
||||
|
||||
const OPTIONAL_STRING_KEYS_SET = new Set(OPTIONAL_STRING_KEYS);
|
||||
|
||||
export { OPTIONAL_KEYS, OPTIONAL_STRING_KEYS };
|
||||
export { OPTIONAL_KEYS };
|
||||
|
||||
/**
|
||||
* Extract the first fenced YAML block whose first non-blank line begins with
|
||||
|
|
@ -123,19 +110,6 @@ export function parseManifest(stepBody) {
|
|||
}
|
||||
}
|
||||
|
||||
// v4.1 Step 3 — string-typed optional keys. Absence == not-present (no default,
|
||||
// unlike boolean keys above) so downstream can distinguish unset vs empty string.
|
||||
for (const k of OPTIONAL_STRING_KEYS_SET) {
|
||||
if (k in parsed) {
|
||||
if (typeof parsed[k] !== 'string') {
|
||||
errors.push(issue(
|
||||
'MANIFEST_OPTIONAL_TYPE',
|
||||
`${k} must be string if present (got ${typeof parsed[k]})`,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
// lib/parsers/profile-jaccard.mjs
|
||||
// String-normalisering helper for cross-tier Jaccard smoke-test (Step 18).
|
||||
//
|
||||
// Plan steps from different model tiers (sonnet vs opus) often differ
|
||||
// only in punctuation, casing, or trivial wording (`logger.info` vs
|
||||
// "logger.info" vs `logger info`). To avoid trivial false-negatives in
|
||||
// cross-tier Jaccard, every step title passes through `normalizeStep`
|
||||
// before set membership is computed.
|
||||
//
|
||||
// Normalisering rules (per research/02 §3.4):
|
||||
// 1. Lowercase the entire string.
|
||||
// 2. Strip backticks and parentheses (`...` and (...)).
|
||||
// 3. Collapse runs of whitespace to a single space.
|
||||
// 4. Trim leading + trailing whitespace.
|
||||
//
|
||||
// We do NOT stem or lemmatize — that would over-normalize and mask real
|
||||
// disagreement (e.g. "Add tests for X" vs "Verify tests for X" should
|
||||
// remain distinct).
|
||||
|
||||
/**
|
||||
* Normalize a single step-title string.
|
||||
* @param {unknown} step
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeStep(step) {
|
||||
if (typeof step !== 'string') return '';
|
||||
return step
|
||||
.toLowerCase()
|
||||
.replace(/[`()]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of step titles.
|
||||
* @param {string[]} steps
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function normalizeSteps(steps) {
|
||||
if (!Array.isArray(steps)) return [];
|
||||
return steps.map(normalizeStep).filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify step-count parity within a tolerance band.
|
||||
* @param {string[]} stepsA
|
||||
* @param {string[]} stepsB
|
||||
* @param {number} tolerance fraction (default 0.20 = ±20%)
|
||||
* @returns {{ok: boolean, ratio: number, message: string}}
|
||||
*/
|
||||
export function checkStepCountParity(stepsA, stepsB, tolerance = 0.2) {
|
||||
const a = Array.isArray(stepsA) ? stepsA.length : 0;
|
||||
const b = Array.isArray(stepsB) ? stepsB.length : 0;
|
||||
if (a === 0 || b === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
ratio: 0,
|
||||
message: `step-count parity failed: empty input (a=${a}, b=${b})`,
|
||||
};
|
||||
}
|
||||
const ratio = Math.abs(a - b) / Math.max(a, b);
|
||||
return {
|
||||
ok: ratio <= tolerance,
|
||||
ratio,
|
||||
message:
|
||||
ratio <= tolerance
|
||||
? `step-count parity OK (a=${a}, b=${b}, ratio=${ratio.toFixed(3)})`
|
||||
: `step-count parity exceeded ${tolerance}: a=${a}, b=${b}, ratio=${ratio.toFixed(3)}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
profile_version: "1.0"
|
||||
name: balanced
|
||||
phase_models:
|
||||
- phase: brief
|
||||
model: sonnet
|
||||
- phase: research
|
||||
model: sonnet
|
||||
- phase: plan
|
||||
model: opus
|
||||
- phase: execute
|
||||
model: sonnet
|
||||
- phase: review
|
||||
model: opus
|
||||
- phase: continue
|
||||
model: sonnet
|
||||
parallel_agents_min: 4
|
||||
parallel_agents_max: 6
|
||||
external_research_enabled: false
|
||||
brief_reviewer_iter_cap: 2
|
||||
---
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
profile_version: "1.0"
|
||||
name: economy
|
||||
phase_models:
|
||||
- phase: brief
|
||||
model: sonnet
|
||||
- phase: research
|
||||
model: sonnet
|
||||
- phase: plan
|
||||
model: sonnet
|
||||
- phase: execute
|
||||
model: sonnet
|
||||
- phase: review
|
||||
model: sonnet
|
||||
- phase: continue
|
||||
model: sonnet
|
||||
parallel_agents_min: 2
|
||||
parallel_agents_max: 3
|
||||
external_research_enabled: false
|
||||
brief_reviewer_iter_cap: 1
|
||||
# Experimental: the cross-tier Jaccard floor (0.55) rests on parked-synthetic
|
||||
# fixtures — empirical Step-17 calibration is deferred to v4.2. Drop this flag
|
||||
# in the SAME change that lands calibration (status: empirical). See
|
||||
# tests/synthetic/profile-jaccard-calibration.md and docs/profiles.md.
|
||||
experimental: true
|
||||
---
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
profile_version: "1.0"
|
||||
name: fable
|
||||
phase_models:
|
||||
- phase: brief
|
||||
model: fable
|
||||
- phase: research
|
||||
model: fable
|
||||
- phase: plan
|
||||
model: fable
|
||||
- phase: execute
|
||||
model: fable
|
||||
- phase: review
|
||||
model: fable
|
||||
- phase: continue
|
||||
model: fable
|
||||
parallel_agents_min: 6
|
||||
parallel_agents_max: 8
|
||||
external_research_enabled: true
|
||||
brief_reviewer_iter_cap: 3
|
||||
---
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
// lib/profiles/phase-signal-resolver.mjs
|
||||
// v5.1.1 — extract per-phase signal from a brief's frontmatter.
|
||||
//
|
||||
// Decision A wiring: commands invoke this helper via Bash CLI shim
|
||||
// (`node lib/profiles/phase-signal-resolver.mjs --brief <path> --phase <name> --json`)
|
||||
// to obtain the {effort, model} pair for a specific pipeline phase.
|
||||
//
|
||||
// Sole source of truth for PHASE_SIGNAL_PHASES + EFFORT_LEVELS is
|
||||
// lib/validators/brief-validator.mjs — re-imported here so the helper
|
||||
// cannot drift from the schema validator.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../validators/brief-validator.mjs';
|
||||
import { BASE_ALLOWED_MODELS } from '../validators/profile-validator.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
/**
|
||||
* Resolve a brief's phase_signal entry for one phase.
|
||||
*
|
||||
* @param {object|null} briefFrontmatter Parsed YAML frontmatter dict (or null/undefined).
|
||||
* @param {string} phase One of PHASE_SIGNAL_PHASES; anything else returns null.
|
||||
* @returns {{effort?: string, model?: string} | null}
|
||||
*
|
||||
* Never throws. Returns null on:
|
||||
* - Falsy / non-object frontmatter
|
||||
* - phase not in PHASE_SIGNAL_PHASES (e.g. 'brief' or 'continue')
|
||||
* - Missing phase_signals array
|
||||
* - No entry for the requested phase
|
||||
*
|
||||
* Returns partial `{effort}` (with `model: undefined`) when the signal omits model.
|
||||
* `effort` is gated against EFFORT_LEVELS and `model` against BASE_ALLOWED_MODELS;
|
||||
* values outside those allowlists are dropped (treated as absent).
|
||||
*/
|
||||
export function resolvePhaseSignal(briefFrontmatter, phase) {
|
||||
if (!briefFrontmatter || typeof briefFrontmatter !== 'object') return null;
|
||||
if (typeof phase !== 'string' || !PHASE_SIGNAL_PHASES.includes(phase)) return null;
|
||||
const signals = briefFrontmatter.phase_signals;
|
||||
if (!Array.isArray(signals)) return null;
|
||||
for (const entry of signals) {
|
||||
if (entry && typeof entry === 'object' && entry.phase === phase) {
|
||||
const out = {};
|
||||
if ('effort' in entry && EFFORT_LEVELS.includes(entry.effort)) out.effort = entry.effort;
|
||||
// MAJOR fix (S4): gate `model` against BASE_ALLOWED_MODELS, mirroring the
|
||||
// effort gate above. brief-validator already rejects out-of-allowlist
|
||||
// models at validation time; this is defense-in-depth so a brief that
|
||||
// slipped validation cannot hand a junk model to an agent spawn.
|
||||
if ('model' in entry && BASE_ALLOWED_MODELS.includes(entry.model)) out.model = entry.model;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: read a brief file, parse, and resolve a single phase.
|
||||
* Returns null on any read or parse failure (graceful degradation).
|
||||
*/
|
||||
export function resolvePhaseSignalFromFile(briefPath, phase) {
|
||||
if (typeof briefPath !== 'string' || briefPath.length === 0) return null;
|
||||
if (!existsSync(briefPath)) return null;
|
||||
let text;
|
||||
try { text = readFileSync(briefPath, 'utf-8'); } catch { return null; }
|
||||
const doc = parseDocument(text);
|
||||
if (!doc || !doc.valid) return null;
|
||||
const fm = doc.parsed && doc.parsed.frontmatter;
|
||||
return resolvePhaseSignal(fm, phase);
|
||||
}
|
||||
|
||||
// CLI shim — mirrors lib/validators/brief-validator.mjs:168 pattern.
|
||||
// Footgun guard (v5.9): this shim's `model` output is brief-signal-only — it
|
||||
// never consults the profile layer. For command wiring, the composed resolver
|
||||
// CLI (`resolver.mjs --resolve-phase-model`, brief > profile > default) is the
|
||||
// single resolution source for {effort, model}. Do not re-wire commands/*.md
|
||||
// Bash blocks back to this shim.
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const getArg = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
const briefPath = getArg('--brief');
|
||||
const phase = getArg('--phase');
|
||||
if (!briefPath || !phase) {
|
||||
process.stderr.write('Usage: phase-signal-resolver.mjs --brief <path> --phase <name> [--json]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const result = resolvePhaseSignalFromFile(briefPath, phase);
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} else if (result === null) {
|
||||
process.stdout.write('null\n');
|
||||
} else {
|
||||
const effort = 'effort' in result ? result.effort : '';
|
||||
const model = 'model' in result ? result.model : '';
|
||||
process.stdout.write(`effort=${effort} model=${model}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
profile_version: "1.0"
|
||||
name: premium
|
||||
phase_models:
|
||||
- phase: brief
|
||||
model: opus
|
||||
- phase: research
|
||||
model: opus
|
||||
- phase: plan
|
||||
model: opus
|
||||
- phase: execute
|
||||
model: opus
|
||||
- phase: review
|
||||
model: opus
|
||||
- phase: continue
|
||||
model: opus
|
||||
parallel_agents_min: 6
|
||||
parallel_agents_max: 8
|
||||
external_research_enabled: true
|
||||
brief_reviewer_iter_cap: 3
|
||||
---
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
// lib/profiles/resolver.mjs
|
||||
// Profile resolution layer (v4.1 SC #5-#9; v5.1.1 brief-signal extension).
|
||||
//
|
||||
// Locked interface contract (per brief Preferences):
|
||||
// loadProfile(name) → ProfileObject
|
||||
// - Reads lib/profiles/<name>.yaml or custom voyage-profiles/<name>.yaml.
|
||||
// - Throws Error (cause: PROFILE_NOT_FOUND) when not found.
|
||||
// - Returns parsed object with phase_models flattened to {brief: 'sonnet', ...}
|
||||
// (object form for downstream JSON-stats; conversion from YAML list-of-dicts).
|
||||
//
|
||||
// resolveProfile(argv, env) → {profile, profile_source}
|
||||
// - Resolution order: --profile flag > VOYAGE_PROFILE env > 'premium' default.
|
||||
// - profile_source: 'flag' | 'env' | 'default'.
|
||||
//
|
||||
// resolveTrekcontinueProfile(planPath, argv) → {profile, profile_source}
|
||||
// - --profile flag in argv wins with 'flag'.
|
||||
// - Otherwise reads plan.md frontmatter via parseDocument; returns
|
||||
// plan-frontmatter `profile` field with 'inheritance'.
|
||||
// - If flag overrides inheritance, console.error emits an advisory.
|
||||
// - For v4.0-style plans without `profile:` field, returns 'default' premium.
|
||||
//
|
||||
// validateProfileFile(path) → Result
|
||||
// - Thin wrapper around validateProfile from profile-validator.mjs.
|
||||
//
|
||||
// resolvePhaseModel(phase, briefPath, argv, env)
|
||||
// → {model, source} (v5.1.1 ADDITIVE)
|
||||
// - Highest-priority lookup step: brief.phase_signals[phase].model (source='brief-signal').
|
||||
// - Falls through via resolveProfile (flag → env → default) when brief signal absent.
|
||||
// - Never throws; ENOENT/parse failures degrade silently to the next step.
|
||||
// - phase ∈ {brief, continue} (outside PHASE_SIGNAL_PHASES) always falls through.
|
||||
// - Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter
|
||||
// directly; commands must inject {resolved model} into Agent-tool spawn sites
|
||||
// for sub-agents to honor brief signals. See feedback_voyage_opus_always.md.
|
||||
//
|
||||
// Custom.yaml lookup order: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.yaml
|
||||
// Both attempted paths included in error message on miss (HIGH-risk-mitigering).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { validateProfile } from '../validators/profile-validator.mjs';
|
||||
import { resolvePhaseSignal } from './phase-signal-resolver.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUILTIN_PROFILES_DIR = __dirname; // lib/profiles/
|
||||
const BUILTIN_NAMES = new Set(['economy', 'balanced', 'premium', 'fable']);
|
||||
|
||||
/**
|
||||
* Resolve the path to a profile file.
|
||||
* Built-in profiles: lib/profiles/<name>.yaml
|
||||
* Custom profiles: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.yaml
|
||||
*
|
||||
* @returns {{path: string|null, attempted: string[]}}
|
||||
*/
|
||||
export function findProfilePath(name, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const home = opts.home || homedir();
|
||||
const attempted = [];
|
||||
|
||||
if (BUILTIN_NAMES.has(name)) {
|
||||
const builtinPath = join(BUILTIN_PROFILES_DIR, `${name}.yaml`);
|
||||
attempted.push(builtinPath);
|
||||
if (existsSync(builtinPath)) return { path: builtinPath, attempted };
|
||||
}
|
||||
|
||||
// Custom: repo-root first, then home
|
||||
const repoCustom = join(cwd, 'voyage-profiles', `${name}.yaml`);
|
||||
attempted.push(repoCustom);
|
||||
if (existsSync(repoCustom)) return { path: repoCustom, attempted };
|
||||
|
||||
const homeCustom = join(home, '.claude', 'voyage-profiles', `${name}.yaml`);
|
||||
attempted.push(homeCustom);
|
||||
if (existsSync(homeCustom)) return { path: homeCustom, attempted };
|
||||
|
||||
return { path: null, attempted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten phase_models list-of-dicts to object form: {brief: 'sonnet', research: 'opus', ...}
|
||||
*/
|
||||
function flattenPhaseModels(list) {
|
||||
const out = {};
|
||||
if (!Array.isArray(list)) return out;
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === 'object' && typeof entry.phase === 'string' && typeof entry.model === 'string') {
|
||||
out[entry.phase] = entry.model;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse a profile file by name.
|
||||
* @param {string} name
|
||||
* @param {{cwd?: string, home?: string}} [opts]
|
||||
* @returns {{name: string, profile_version: string, phase_models: object, parallel_agents_min: number, parallel_agents_max: number, external_research_enabled: boolean, brief_reviewer_iter_cap: number, _path: string}}
|
||||
* @throws {Error} cause: PROFILE_NOT_FOUND | PROFILE_PARSE_ERROR
|
||||
*/
|
||||
export function loadProfile(name, opts = {}) {
|
||||
const { path, attempted } = findProfilePath(name, opts);
|
||||
if (!path) {
|
||||
const err = new Error(
|
||||
`Profile "${name}" not found. Attempted paths:\n - ${attempted.join('\n - ')}`,
|
||||
);
|
||||
err.cause = 'PROFILE_NOT_FOUND';
|
||||
err.attempted = attempted;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let text;
|
||||
try { text = readFileSync(path, 'utf-8'); }
|
||||
catch (e) {
|
||||
const err = new Error(`Cannot read profile "${name}" at ${path}: ${e.message}`);
|
||||
err.cause = 'PROFILE_READ_ERROR';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const doc = parseDocument(text);
|
||||
if (!doc.valid) {
|
||||
const err = new Error(`Profile "${name}" parse error: ${doc.errors[0]?.message || 'unknown'}`);
|
||||
err.cause = 'PROFILE_PARSE_ERROR';
|
||||
throw err;
|
||||
}
|
||||
|
||||
const fm = doc.parsed.frontmatter || {};
|
||||
return {
|
||||
name: fm.name,
|
||||
profile_version: fm.profile_version,
|
||||
phase_models: flattenPhaseModels(fm.phase_models),
|
||||
parallel_agents_min: fm.parallel_agents_min,
|
||||
parallel_agents_max: fm.parallel_agents_max,
|
||||
external_research_enabled: fm.external_research_enabled,
|
||||
brief_reviewer_iter_cap: fm.brief_reviewer_iter_cap,
|
||||
_path: path,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve profile name from argv + env + default.
|
||||
* Order: --profile flag > VOYAGE_PROFILE env > 'premium'.
|
||||
*
|
||||
* @param {{flags?: object} | string[]} argv Either parsed argv or a flags-object.
|
||||
* @param {object} [env]
|
||||
* @returns {{profile: string, profile_source: 'flag'|'env'|'default'}}
|
||||
*/
|
||||
export function resolveProfile(argv, env = process.env) {
|
||||
const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {});
|
||||
if (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0) {
|
||||
return { profile: flags['--profile'], profile_source: 'flag' };
|
||||
}
|
||||
if (typeof env.VOYAGE_PROFILE === 'string' && env.VOYAGE_PROFILE.length > 0) {
|
||||
return { profile: env.VOYAGE_PROFILE, profile_source: 'env' };
|
||||
}
|
||||
return { profile: 'premium', profile_source: 'default' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve profile for /trekcontinue: prefers explicit flag, falls back to plan
|
||||
* frontmatter (inheritance), then 'premium' default if plan has no profile field.
|
||||
*
|
||||
* @param {string} planPath Path to plan.md
|
||||
* @param {{flags?: object} | string[]} argv
|
||||
* @param {{env?: object, console?: Console}} [opts]
|
||||
* @returns {{profile: string, profile_source: 'flag'|'inheritance'|'default'}}
|
||||
*/
|
||||
export function resolveTrekcontinueProfile(planPath, argv, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const con = opts.console || console;
|
||||
|
||||
const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {});
|
||||
const flagProfile = (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0)
|
||||
? flags['--profile'] : null;
|
||||
|
||||
// Read plan-frontmatter to detect inheritance
|
||||
let planProfile = null;
|
||||
if (planPath && existsSync(planPath)) {
|
||||
try {
|
||||
const text = readFileSync(planPath, 'utf-8');
|
||||
const doc = parseDocument(text);
|
||||
if (doc.valid) {
|
||||
const fm = doc.parsed.frontmatter || {};
|
||||
if (typeof fm.profile === 'string' && fm.profile.length > 0) {
|
||||
planProfile = fm.profile;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// swallow — degrades gracefully to default
|
||||
}
|
||||
}
|
||||
|
||||
if (flagProfile) {
|
||||
if (planProfile && planProfile !== flagProfile) {
|
||||
con.error(`[voyage] profile inheritance overridden by --profile flag: ${planProfile} → ${flagProfile}`);
|
||||
}
|
||||
return { profile: flagProfile, profile_source: 'flag' };
|
||||
}
|
||||
|
||||
if (planProfile) {
|
||||
return { profile: planProfile, profile_source: 'inheritance' };
|
||||
}
|
||||
|
||||
// v4.0-style plan without profile: default to premium
|
||||
return { profile: 'premium', profile_source: 'default' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a profile YAML file.
|
||||
* Thin wrapper for locked-interface compatibility.
|
||||
*/
|
||||
export function validateProfileFile(path, opts = {}) {
|
||||
return validateProfile(path, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the model for a specific pipeline phase, applying brief-signal override
|
||||
* as the new highest-priority lookup step (v5.1.1).
|
||||
*
|
||||
* @param {string} phase One of brief|research|plan|execute|review|continue
|
||||
* @param {string|null} briefPath Absolute or repo-relative path to brief.md, or null
|
||||
* @param {string[]|object} argv Full process.argv array OR parsed flags object
|
||||
* @param {object} [env] Environment-variable record (defaults to process.env)
|
||||
* @returns {{effort?: string, model: string, source: 'brief-signal'|'flag'|'env'|'default'}}
|
||||
*
|
||||
* `effort` (v5.9 ADDITIVE) is the brief signal's effort passed through when
|
||||
* present — so commands consume ONE coherent {effort, model, source} result
|
||||
* instead of two split CLI calls. Absent when the brief carries no valid
|
||||
* effort signal for the phase (commands default to 'standard' per the
|
||||
* composition rule).
|
||||
*
|
||||
* Error handling contract:
|
||||
* - Never throws. Any failure (ENOENT on briefPath, malformed YAML, missing
|
||||
* phase_signals entry, missing model field) silently falls through.
|
||||
* - Treats unreadable briefPath as equivalent to briefPath=null.
|
||||
* - Returns the fallthrough resolution when phase ∈ {brief, continue} (not in
|
||||
* PHASE_SIGNAL_PHASES).
|
||||
*
|
||||
* Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter
|
||||
* directly; commands must inject {resolved model} at Agent-tool spawn sites.
|
||||
*/
|
||||
export function resolvePhaseModel(phase, briefPath, argv, env = process.env) {
|
||||
// Step 1: brief-signal lookup. `effort` is captured independently of `model`
|
||||
// so a signal like {effort: high} (no model) still passes effort through
|
||||
// while the model falls to the profile layer.
|
||||
let effort;
|
||||
if (typeof briefPath === 'string' && briefPath.length > 0 && existsSync(briefPath)) {
|
||||
let fm = null;
|
||||
try {
|
||||
const text = readFileSync(briefPath, 'utf-8');
|
||||
const doc = parseDocument(text);
|
||||
if (doc && doc.valid) fm = doc.parsed && doc.parsed.frontmatter;
|
||||
} catch {
|
||||
// swallow — degrade gracefully to fallthrough
|
||||
}
|
||||
if (fm) {
|
||||
const signal = resolvePhaseSignal(fm, phase);
|
||||
if (signal && typeof signal.effort === 'string') effort = signal.effort;
|
||||
if (signal && typeof signal.model === 'string' && signal.model.length > 0) {
|
||||
return effort !== undefined
|
||||
? { effort, model: signal.model, source: 'brief-signal' }
|
||||
: { model: signal.model, source: 'brief-signal' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2+3+4: fall through via existing resolveProfile chain
|
||||
// (flag → env → default), then index into profile.phase_models[phase].
|
||||
// Normalize argv: resolveProfile expects flags-object form. When called with
|
||||
// a raw process.argv-style array, extract --profile here.
|
||||
let normalizedFlags = argv;
|
||||
if (Array.isArray(argv)) {
|
||||
const idx = argv.indexOf('--profile');
|
||||
normalizedFlags = (idx >= 0 && idx + 1 < argv.length)
|
||||
? { '--profile': argv[idx + 1] }
|
||||
: {};
|
||||
}
|
||||
const { profile, profile_source } = resolveProfile(normalizedFlags, env);
|
||||
let phaseModels = {};
|
||||
try {
|
||||
const p = loadProfile(profile);
|
||||
phaseModels = p.phase_models || {};
|
||||
} catch {
|
||||
// Unknown profile → fall back to premium
|
||||
try {
|
||||
const p = loadProfile('premium');
|
||||
phaseModels = p.phase_models || {};
|
||||
} catch {
|
||||
phaseModels = {};
|
||||
}
|
||||
}
|
||||
const model = phaseModels[phase] || 'opus';
|
||||
return effort !== undefined
|
||||
? { effort, model, source: profile_source }
|
||||
: { model, source: profile_source };
|
||||
}
|
||||
|
||||
// CLI shim — invoked by commands/trek*.md via Bash.
|
||||
// Usage: node lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path foo.md --json
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--resolve-phase-model')) {
|
||||
const getArg = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
const phase = getArg('--phase');
|
||||
const briefPath = getArg('--brief-path');
|
||||
if (!phase) {
|
||||
process.stderr.write('Usage: resolver.mjs --resolve-phase-model --phase <name> [--brief-path <path>] [--json]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const r = resolvePhaseModel(phase, briefPath || null, args, process.env);
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
} else {
|
||||
const effort = 'effort' in r ? ` effort=${r.effort}` : '';
|
||||
process.stdout.write(`model=${r.model} source=${r.source}${effort}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
// Otherwise no-op (the module is primarily imported, not run directly).
|
||||
}
|
||||
|
|
@ -520,8 +520,7 @@ export function judgeFilter(findings) {
|
|||
*
|
||||
* `no-citation` REFUTES (the finding names no location, so it makes no
|
||||
* checkable claim) and is dropped. `unknown-rule_key` does not: an ad-hoc key
|
||||
* is a real defect wearing the wrong label — v5.1.1 high-effort mode already
|
||||
* KEEPS these, normalised to PLAN_EXECUTE_DRIFT — so it routes to `unverified`.
|
||||
* is a real defect wearing the wrong label, so it routes to `unverified`.
|
||||
*
|
||||
* The brief rule runs here too: a finding `lowerableBriefConflict` accepts is
|
||||
* set to MINOR with `brief_lowered_from: 'MAJOR'`; every other finding gets
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
// lib/review/gold-scorer.mjs
|
||||
// SKAL-1·4b — offline gold-scored output eval scorer.
|
||||
//
|
||||
// Scores a recorded agent-run's findings against a golden corpus record
|
||||
// (voyage-eval-gold/1, see docs/eval-corpus/README.md) at (file, rule_key)
|
||||
// granularity. line and severity are deliberately NOT part of the match key:
|
||||
// the eval asks "did the run surface this issue at all", and (file, rule_key)
|
||||
// is the identity pair a delivered diff + the catalogue uniquely determine.
|
||||
//
|
||||
// Pairs with coordinator-contract.mjs (4a): that module turns committed
|
||||
// reviewer payloads into the run findings scored here. This module is the
|
||||
// offline counterpart to the live LLM-in-the-loop eval (4c) — pure: no I/O,
|
||||
// no LLM, no network, no time, no randomness.
|
||||
//
|
||||
// Vacuous-set conventions (documented so the degenerate numbers are honest):
|
||||
// - precision when nothing is predicted (tp+fp === 0) -> 1 (no false positives)
|
||||
// - recall when there is nothing to find (tp+fn === 0) -> 1 (found all zero)
|
||||
// - f1 collapses to 0 whenever precision or recall is 0, so it stays the
|
||||
// honest single-number summary for the degenerate run/gold cases.
|
||||
|
||||
// NUL separator: file paths and rule_keys never contain it, so the join is
|
||||
// an injective (file, rule_key) -> string key.
|
||||
const SEP = '\x00';
|
||||
|
||||
function pairKey(f) {
|
||||
return `${f.file}${SEP}${f.rule_key}`;
|
||||
}
|
||||
|
||||
// Human-readable form of a pair key (for matched/missed/spurious reporting).
|
||||
function pairLabel(key) {
|
||||
return key.replace(SEP, ' ');
|
||||
}
|
||||
|
||||
// Set of unique (file, rule_key) pair keys from a findings list. Findings
|
||||
// missing either field are skipped (they cannot identify a pair).
|
||||
function pairSet(findings) {
|
||||
const s = new Set();
|
||||
for (const f of findings ?? []) {
|
||||
if (f && typeof f.file === 'string' && typeof f.rule_key === 'string') {
|
||||
s.add(pairKey(f));
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score run findings against gold findings at (file, rule_key) granularity.
|
||||
* @param {object[]|null|undefined} runFindings findings produced by the recorded run
|
||||
* @param {object[]|null|undefined} goldFindings the golden corpus findings
|
||||
* @returns {{
|
||||
* tp: number, fp: number, fn: number,
|
||||
* precision: number, recall: number, f1: number,
|
||||
* matched: string[], missed: string[], spurious: string[]
|
||||
* }} matched/missed/spurious are "<file> <rule_key>" labels.
|
||||
*/
|
||||
export function scoreFindings(runFindings, goldFindings) {
|
||||
const runPairs = pairSet(runFindings);
|
||||
const goldPairs = pairSet(goldFindings);
|
||||
|
||||
const matched = [];
|
||||
const spurious = [];
|
||||
for (const p of runPairs) {
|
||||
(goldPairs.has(p) ? matched : spurious).push(pairLabel(p));
|
||||
}
|
||||
const missed = [];
|
||||
for (const p of goldPairs) {
|
||||
if (!runPairs.has(p)) missed.push(pairLabel(p));
|
||||
}
|
||||
|
||||
const tp = matched.length;
|
||||
const fp = spurious.length;
|
||||
const fn = missed.length;
|
||||
|
||||
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
|
||||
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
|
||||
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
|
||||
|
||||
return { tp, fp, fn, precision, recall, f1, matched, missed, spurious };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact match of a run's coordinator verdict against the gold expected_verdict.
|
||||
* @param {string} runVerdict
|
||||
* @param {string} goldVerdict
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function scoreVerdict(runVerdict, goldVerdict) {
|
||||
return runVerdict === goldVerdict;
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
// lib/stats/cache-analyzer.mjs
|
||||
// Summarizes trekexecute-stats.jsonl: total events, percentile wall times,
|
||||
// time range. Companion to event-emit.mjs (which produces the jsonl).
|
||||
//
|
||||
// Designed for /trekplan Spor C: gives C3 telemetry context when
|
||||
// interpreting Q3 experiment numbers (5+ weeks of accumulated data on the
|
||||
// operator's machine as of 2026-05-04).
|
||||
//
|
||||
// Zero npm dependencies. Node stdlib only.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
function usage() {
|
||||
return `cache-analyzer.mjs — summarize trekexecute-stats.jsonl
|
||||
|
||||
USAGE:
|
||||
node lib/stats/cache-analyzer.mjs --json <path-to-jsonl>
|
||||
|
||||
OUTPUT (stdout, JSON):
|
||||
{
|
||||
"total_events": <n>,
|
||||
"events_with_duration": <n>,
|
||||
"wall_time_ms_p50": <ms or null>,
|
||||
"wall_time_ms_p90": <ms or null>,
|
||||
"wall_time_ms_max": <ms or null>,
|
||||
"unique_event_names": [...],
|
||||
"oldest_event_iso": "<iso8601 or null>",
|
||||
"newest_event_iso": "<iso8601 or null>"
|
||||
}
|
||||
|
||||
EXIT:
|
||||
0 success, 1 file not found / read error, 2 usage error.
|
||||
`;
|
||||
}
|
||||
|
||||
export function summarize(lines) {
|
||||
const summary = {
|
||||
total_events: 0,
|
||||
events_with_duration: 0,
|
||||
wall_time_ms_p50: null,
|
||||
wall_time_ms_p90: null,
|
||||
wall_time_ms_max: null,
|
||||
unique_event_names: [],
|
||||
oldest_event_iso: null,
|
||||
newest_event_iso: null,
|
||||
// SKAL-2 token/cost aggregation (additive; zero when no token records).
|
||||
// With upsert semantics (one record per session_id), summing across lines
|
||||
// = correct cross-session aggregate. cost_usd is summed only when finite;
|
||||
// a null (refuse-to-estimate) record still counts in sessions_with_tokens.
|
||||
total_tokens_input: 0,
|
||||
total_tokens_output: 0,
|
||||
total_tokens_cache_creation: 0,
|
||||
total_tokens_cache_read: 0,
|
||||
total_cost_usd: 0,
|
||||
sessions_with_tokens: 0,
|
||||
};
|
||||
|
||||
const durations = [];
|
||||
const names = new Set();
|
||||
let oldestMs = null;
|
||||
let newestMs = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(trimmed); }
|
||||
catch { continue; }
|
||||
summary.total_events++;
|
||||
if (obj.event && typeof obj.event === 'string') names.add(obj.event);
|
||||
else if (obj.name && typeof obj.name === 'string') names.add(obj.name);
|
||||
if (typeof obj.duration_ms === 'number' && Number.isFinite(obj.duration_ms)) {
|
||||
durations.push(obj.duration_ms);
|
||||
summary.events_with_duration++;
|
||||
}
|
||||
const tsField = obj.timestamp || obj.ts || obj.iso || obj.time;
|
||||
if (typeof tsField === 'string') {
|
||||
const t = Date.parse(tsField);
|
||||
if (!Number.isNaN(t)) {
|
||||
if (oldestMs === null || t < oldestMs) oldestMs = t;
|
||||
if (newestMs === null || t > newestMs) newestMs = t;
|
||||
}
|
||||
}
|
||||
|
||||
// SKAL-2: aggregate token/cost from token-bearing records (token-usage
|
||||
// schema). Detect by presence of any numeric token field.
|
||||
const tokenKeys = ['tokens_input', 'tokens_output', 'tokens_cache_creation', 'tokens_cache_read'];
|
||||
const hasTokens = tokenKeys.some(k => typeof obj[k] === 'number' && Number.isFinite(obj[k]));
|
||||
if (hasTokens) {
|
||||
summary.sessions_with_tokens++;
|
||||
if (Number.isFinite(obj.tokens_input)) summary.total_tokens_input += obj.tokens_input;
|
||||
if (Number.isFinite(obj.tokens_output)) summary.total_tokens_output += obj.tokens_output;
|
||||
if (Number.isFinite(obj.tokens_cache_creation)) summary.total_tokens_cache_creation += obj.tokens_cache_creation;
|
||||
if (Number.isFinite(obj.tokens_cache_read)) summary.total_tokens_cache_read += obj.tokens_cache_read;
|
||||
// cost_usd may be null (refuse-to-estimate) — sum finite values only.
|
||||
if (Number.isFinite(obj.cost_usd)) summary.total_cost_usd += obj.cost_usd;
|
||||
}
|
||||
}
|
||||
|
||||
if (durations.length > 0) {
|
||||
durations.sort((a, b) => a - b);
|
||||
const p50Idx = Math.floor(durations.length * 0.5);
|
||||
const p90Idx = Math.floor(durations.length * 0.9);
|
||||
summary.wall_time_ms_p50 = durations[Math.min(p50Idx, durations.length - 1)];
|
||||
summary.wall_time_ms_p90 = durations[Math.min(p90Idx, durations.length - 1)];
|
||||
summary.wall_time_ms_max = durations[durations.length - 1];
|
||||
}
|
||||
|
||||
summary.unique_event_names = [...names].sort();
|
||||
if (oldestMs !== null) summary.oldest_event_iso = new Date(oldestMs).toISOString();
|
||||
if (newestMs !== null) summary.newest_event_iso = new Date(newestMs).toISOString();
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function summarizeFile(path) {
|
||||
if (!existsSync(path)) {
|
||||
return { error: `file not found: ${path}` };
|
||||
}
|
||||
let text;
|
||||
try { text = readFileSync(path, 'utf-8'); }
|
||||
catch (e) { return { error: `read error: ${e.message}` }; }
|
||||
return summarize(text.split('\n'));
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const jsonIdx = args.indexOf('--json');
|
||||
if (jsonIdx === -1 || !args[jsonIdx + 1]) {
|
||||
process.stderr.write(usage());
|
||||
process.exit(2);
|
||||
}
|
||||
const path = args[jsonIdx + 1];
|
||||
const result = summarizeFile(path);
|
||||
if (result.error) {
|
||||
process.stderr.write(`cache-analyzer: ${result.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
// lib/stats/claimed-order.mjs
|
||||
// Which coord-queue order is this Voyage run working on? Read from the queue
|
||||
// itself, never typed by the model and never guessed.
|
||||
//
|
||||
// Why this exists: the yardstick's `use` number joins a new-work order to a
|
||||
// Voyage run. A key declared on the order before any run (a `slug:`) is not
|
||||
// proof a run happened, and no order carried one. The run itself has to say
|
||||
// which order it ran, so /trekbrief's stats record carries the id this module
|
||||
// finds (lib/stats/stats-append.mjs adds it; the model never copies it).
|
||||
//
|
||||
// The contract it mirrors is repo-mailbox's claim verb (coord-order-claim.sh,
|
||||
// 0.37.0), read here, never called:
|
||||
// - coord root ${CLAUDE_COORD_DIR:-$HOME/.claude/coord}
|
||||
// - mailbox basename of `git rev-parse --show-toplevel`; `_*` is reserved
|
||||
// - claim `mv orders/<id>.md orders/claimed/<id>.md`, so the id is the
|
||||
// file stem, plus a best-effort sidecar `claimed/<id>.claim`
|
||||
// holding `claimed-at: <ISO>`
|
||||
// - close coord-order-done.sh deletes the `.claim`, so the claim time
|
||||
// survives only in the record this module feeds.
|
||||
// A worktree's toplevel basename is the worktree's name, so the main repo's
|
||||
// name (the parent of the common git dir) is searched too.
|
||||
//
|
||||
// Exactly one claimed order -> { order_id, order_claimed_at, order_reason: null }.
|
||||
// Anything else -> order_id null (or the id with `no-claim-time`) and a reason:
|
||||
// mailbox-unresolved · coord-missing · coord-unreadable · none-claimed ·
|
||||
// several-claimed:N · to-mismatch · no-claim-time. It only reads, never throws,
|
||||
// and starts nothing. Zero npm dependencies.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const GIT_TIMEOUT_MS = 5000;
|
||||
const unattributed = (order_reason) => ({ order_id: null, order_claimed_at: null, order_reason });
|
||||
|
||||
/** The coord root the claim script uses: CLAUDE_COORD_DIR, else $HOME/.claude/coord. */
|
||||
export function resolveCoordDir(env = process.env) {
|
||||
if (env.CLAUDE_COORD_DIR) return env.CLAUDE_COORD_DIR;
|
||||
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
|
||||
return join(home, '.claude', 'coord');
|
||||
}
|
||||
|
||||
function gitLine(dir, args, env) {
|
||||
try {
|
||||
return execFileSync('git', ['-C', dir, 'rev-parse', ...args], {
|
||||
encoding: 'utf8', timeout: GIT_TIMEOUT_MS, env, stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The mailbox names `dir` can be filed under: the toplevel's basename (the
|
||||
* claim script's rule) and, in a linked worktree, the main repo's name. Two
|
||||
* independent git calls, so one failing never loses the other. An inherited
|
||||
* GIT_DIR / GIT_WORK_TREE would point git at another repo, so both are dropped.
|
||||
*/
|
||||
export function resolveMailboxes(dir, { env = {} } = {}) {
|
||||
const childEnv = { ...process.env, ...env };
|
||||
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete childEnv[key];
|
||||
const names = [];
|
||||
const top = gitLine(dir, ['--show-toplevel'], childEnv);
|
||||
if (top) names.push(basename(top));
|
||||
const common = gitLine(dir, ['--path-format=absolute', '--git-common-dir'], childEnv);
|
||||
// Only `<repo>/.git` names a repo; a submodule's `.git/modules/<x>` does not.
|
||||
if (common && basename(common) === '.git') names.push(basename(dirname(common)));
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
const isDir = (p) => existsSync(p) && statSync(p).isDirectory();
|
||||
|
||||
/** The one claimed order across `mailboxes`, or null and the reason it is not one. */
|
||||
export function findClaimedOrder({ coordDir, mailboxes }) {
|
||||
try {
|
||||
if (!isDir(coordDir)) return unattributed('coord-missing');
|
||||
const names = (mailboxes ?? []).filter((m) => typeof m === 'string' && m !== '' && !m.startsWith('_'));
|
||||
if (names.length === 0) return unattributed('mailbox-unresolved');
|
||||
const found = [];
|
||||
for (const mailbox of names) {
|
||||
const dir = join(coordDir, mailbox, 'orders', 'claimed');
|
||||
if (!isDir(dir)) continue;
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
if (name.endsWith('.md')) found.push({ mailbox, dir, id: name.slice(0, -3) });
|
||||
}
|
||||
}
|
||||
if (found.length === 0) return unattributed('none-claimed');
|
||||
if (found.length > 1) return unattributed(`several-claimed:${found.length}`);
|
||||
const [{ mailbox, dir, id }] = found;
|
||||
const text = readFileSync(join(dir, `${id}.md`), 'utf8');
|
||||
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
||||
const to = fm.match(/^to:[ \t]*(.*?)[ \t]*$/m)?.[1];
|
||||
if (to !== undefined && to !== mailbox) return unattributed('to-mismatch');
|
||||
const claimPath = join(dir, `${id}.claim`);
|
||||
const at = existsSync(claimPath)
|
||||
? readFileSync(claimPath, 'utf8').match(/^claimed-at:[ \t]*(\S+)[ \t]*$/m)?.[1]
|
||||
: undefined;
|
||||
if (!at) return { order_id: id, order_claimed_at: null, order_reason: 'no-claim-time' };
|
||||
return { order_id: id, order_claimed_at: at, order_reason: null };
|
||||
} catch {
|
||||
return unattributed('coord-unreadable');
|
||||
}
|
||||
}
|
||||
|
||||
// The directory to resolve the mailbox from: `projectDir` when it is a directory
|
||||
// inside `cwd` (a brief's project dir), else `cwd`. A path that walks out of cwd
|
||||
// cannot move the lookup to another repo.
|
||||
function baseDir(cwd, projectDir) {
|
||||
if (typeof projectDir !== 'string' || projectDir === '') return cwd;
|
||||
try {
|
||||
const abs = resolve(cwd, projectDir);
|
||||
if (!isDir(abs)) return cwd;
|
||||
const rel = relative(realpathSync(cwd), realpathSync(abs));
|
||||
return rel.startsWith('..') || isAbsolute(rel) ? cwd : abs;
|
||||
} catch {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
/** The attribution a /trekbrief record carries: always the three keys. */
|
||||
export function attributeOrder({ cwd = process.cwd(), env = process.env, projectDir } = {}) {
|
||||
const mailboxes = resolveMailboxes(baseDir(cwd, projectDir), { env });
|
||||
if (mailboxes.length === 0) return unattributed('mailbox-unresolved');
|
||||
return findClaimedOrder({ coordDir: resolveCoordDir(env), mailboxes });
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
// lib/stats/event-emit.mjs
|
||||
// Atomic JSONL append for autonomy-lifecycle events (plan-v2 Step 6).
|
||||
//
|
||||
// Writes one line per event to <data dir>/trekexecute-stats.jsonl, where the data
|
||||
// dir is resolved by stats-append.mjs's resolveStatsDir: CLAUDE_PLUGIN_DATA when
|
||||
// the harness provides it, else the plugin's directory under ~/.claude/plugins/data/,
|
||||
// the one scripts/yardstick.mjs reads (one resolver, no second copy here). The Bash tool env carries no
|
||||
// CLAUDE_PLUGIN_DATA, so the old "unset -> silent skip" dropped every event the
|
||||
// command prose emitted. A failed write is REPORTED ({written:false, reason}),
|
||||
// never thrown: stats still never block the workflow.
|
||||
//
|
||||
// Every emission carries:
|
||||
// - ts : ISO-8601 timestamp (REQUIRED per SC4 contract)
|
||||
// - event : the requested event name
|
||||
// - known_event : true for recognized events, false otherwise
|
||||
// - payload : caller-supplied object (may be {})
|
||||
//
|
||||
// Recognized events: brief-approved, main-merge-gate, main-merge-approved,
|
||||
// main-merge-declined, gates_mode_flag_override, user_input. Unknown event names
|
||||
// are still emitted (with known_event: false) so that the audit trail is
|
||||
// complete; downstream consumers filter as needed.
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/stats/event-emit.mjs --event brief-approved --payload '{...}'
|
||||
// node lib/stats/event-emit.mjs --event main-merge-gate --payload - <<'JSON' (payload on stdin)
|
||||
// → prints {"written":true,"path":...} exit 0 | {"written":false,"reason":...} exit 1
|
||||
// (usage error, payload-not-json and a failed write alike). The command prose
|
||||
// prints the reason and continues.
|
||||
//
|
||||
// Import direction: event-emit imports stats-append; stats-append must never
|
||||
// import event-emit (that would be a cycle).
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { resolveStatsDir } from './stats-append.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const KNOWN_EVENTS = Object.freeze(new Set([
|
||||
'brief-approved',
|
||||
'main-merge-gate',
|
||||
'main-merge-approved',
|
||||
'main-merge-declined',
|
||||
'gates_mode_flag_override',
|
||||
'user_input',
|
||||
]));
|
||||
|
||||
const STATS_FILENAME = 'trekexecute-stats.jsonl';
|
||||
|
||||
/**
|
||||
* Resolve the stats file path: CLAUDE_PLUGIN_DATA when set, else the directory
|
||||
* stats-append.mjs and scripts/yardstick.mjs use. Never null.
|
||||
*/
|
||||
export function resolveStatsPath(env = process.env) {
|
||||
return join(resolveStatsDir(env), STATS_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON record. Pure — no I/O.
|
||||
*/
|
||||
export function buildRecord(event, payload = {}, now = new Date()) {
|
||||
if (typeof event !== 'string' || event.length === 0) {
|
||||
throw new TypeError('event must be a non-empty string');
|
||||
}
|
||||
return {
|
||||
ts: now.toISOString(),
|
||||
event,
|
||||
known_event: KNOWN_EVENTS.has(event),
|
||||
payload: (payload && typeof payload === 'object') ? payload : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event. Never throws — a failed write is returned as {written:false,
|
||||
* reason} because lifecycle telemetry must not block the user's workflow.
|
||||
*
|
||||
* @returns {{ written: boolean, path: string | null, reason?: string }}
|
||||
*/
|
||||
export function emit(event, payload = {}, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const now = opts.now || new Date();
|
||||
let record;
|
||||
try {
|
||||
record = buildRecord(event, payload, now);
|
||||
} catch (e) {
|
||||
return { written: false, path: null, reason: `record-build: ${e.message}` };
|
||||
}
|
||||
const path = opts.path || resolveStatsPath(env);
|
||||
try {
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) {
|
||||
// A dir that cannot be created is reported, never thrown.
|
||||
try { mkdirSync(dir, { recursive: true }); } catch { return { written: false, path, reason: 'dir-mkdir-failed' }; }
|
||||
}
|
||||
appendFileSync(path, JSON.stringify(record) + '\n');
|
||||
return { written: true, path };
|
||||
} catch (e) {
|
||||
return { written: false, path, reason: `append-failed: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--event') out.event = argv[++i];
|
||||
else if (a === '--payload') out.payload = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
let result;
|
||||
if (!args.event) {
|
||||
result = { written: false, path: null, reason: 'usage: --event NAME [--payload JSON]' };
|
||||
} else {
|
||||
let payload = {};
|
||||
let bad = false;
|
||||
if (args.payload) {
|
||||
// `-` = read it from stdin: a quoted heredoc passes it with nothing expanded.
|
||||
try { payload = JSON.parse(args.payload === '-' ? readFileSync(0, 'utf8') : args.payload); } catch { bad = true; }
|
||||
}
|
||||
result = bad ? { written: false, path: null, reason: 'payload-not-json' } : emit(args.event, payload);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.written ? 0 : 1);
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
// lib/stats/stats-append.mjs
|
||||
// Append one command-stats record to <data dir>/<kind>-stats.jsonl.
|
||||
//
|
||||
// Why this exists: the commands' stats phases said "append to
|
||||
// ${CLAUDE_PLUGIN_DATA}/<kind>-stats.jsonl; if it is not set, skip silently".
|
||||
// The Bash tool's env carries no CLAUDE_PLUGIN_DATA, so the record was skipped
|
||||
// whenever the model did not guess the directory itself — the same silent skip
|
||||
// that left brief-approved at 0 records. This writer resolves the directory
|
||||
// the way scripts/yardstick.mjs reads it, and a failed write is REPORTED
|
||||
// (exit 1 + reason), never swallowed. Stats still never block the workflow:
|
||||
// the command prose reports the failure and carries on.
|
||||
//
|
||||
// A `trekbrief` record also carries the coord-queue order the run is working
|
||||
// on: order_id, order_claimed_at, order_reason, found by
|
||||
// lib/stats/claimed-order.mjs in the queue itself. The WRITER adds them - any
|
||||
// order_* key the record arrived with is dropped first - so the model never
|
||||
// types an order id, and the keys are always present (null + reason when no
|
||||
// single order can be named; absent keys mean the record predates this).
|
||||
//
|
||||
// CLI (the record is read from stdin, so no shell quoting of JSON):
|
||||
// node lib/stats/stats-append.mjs <kind> <<'JSON'
|
||||
// { ...record... }
|
||||
// JSON
|
||||
// → prints {"written":true,"path":...} exit 0 | {"written":false,"reason":...} exit 1
|
||||
|
||||
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { attributeOrder } from './claimed-order.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const STATS_KINDS = Object.freeze([
|
||||
'trekbrief', 'trekresearch', 'trekplan', 'trekexecute', 'trekreview', 'trekcontinue',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The plugin data directory: CLAUDE_PLUGIN_DATA when the harness provides it,
|
||||
* otherwise the directory the hooks write to and scripts/yardstick.mjs reads.
|
||||
*/
|
||||
export function resolveStatsDir(env = process.env) {
|
||||
if (env.CLAUDE_PLUGIN_DATA) return env.CLAUDE_PLUGIN_DATA;
|
||||
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
|
||||
return join(home, '.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ written: boolean, path: string|null, reason?: string }}
|
||||
*/
|
||||
export function appendStatsRecord(kind, record, env = process.env, { cwd = process.cwd() } = {}) {
|
||||
if (!STATS_KINDS.includes(kind)) {
|
||||
return { written: false, path: null, reason: `unknown kind "${kind}" (one of ${STATS_KINDS.join(', ')})` };
|
||||
}
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
return { written: false, path: null, reason: 'record must be a JSON object' };
|
||||
}
|
||||
if (kind === 'trekbrief') {
|
||||
const own = Object.fromEntries(Object.entries(record).filter(([k]) => !k.startsWith('order_')));
|
||||
record = { ...own, ...attributeOrder({ cwd, env, projectDir: record.project_dir }) };
|
||||
}
|
||||
const dir = resolveStatsDir(env);
|
||||
const path = join(dir, `${kind}-stats.jsonl`);
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(path, JSON.stringify(record) + '\n');
|
||||
} catch (e) {
|
||||
return { written: false, path, reason: `${e.code || 'write-failed'}: ${e.message}` };
|
||||
}
|
||||
return { written: true, path };
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const kind = process.argv[2];
|
||||
let record = null;
|
||||
let reason = null;
|
||||
try {
|
||||
record = JSON.parse(readFileSync(0, 'utf8'));
|
||||
} catch (e) {
|
||||
reason = `stdin is not JSON: ${e.message}`;
|
||||
}
|
||||
const r = reason ? { written: false, path: null, reason } : appendStatsRecord(kind, record);
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
process.exit(r.written ? 0 : 1);
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
// lib/stats/token-usage.mjs
|
||||
// SKAL-2 — pure token-usage parser + cache-aware cost derivation for Voyage
|
||||
// observability. Captures MAIN-CONTEXT token/cost from the Claude Code
|
||||
// transcript (transcript_path). Each assistant record carries message.usage
|
||||
// with a per-REQUEST snapshot.
|
||||
//
|
||||
// Verified 2026-06-26 against a real local transcript (216 lines): 70
|
||||
// assistant records collapse to 31 distinct requestIds — records duplicate
|
||||
// per requestId (streamed snapshots), so we dedup by requestId keeping the
|
||||
// LAST occurrence (GH #28197 streaming-placeholder mitigation), then sum
|
||||
// across requests. The main transcript carried zero isSidechain:true records.
|
||||
//
|
||||
// v1 scope = MAIN-CONTEXT only. Sub-agent (swarm) turns live in separate
|
||||
// agent-*.jsonl siblings and are a documented v2 follow-on — so the
|
||||
// main-transcript sum UNDER-counts total Voyage cost. Every record is stamped
|
||||
// scope:'main-context' so no reader mistakes cost_usd for the session total.
|
||||
//
|
||||
// The four core functions (parseTranscriptUsage, deriveCost, buildRecord,
|
||||
// upsertSessionRecord) are PURE (no I/O). captureTokenUsage is the impure
|
||||
// shell that wires them to the filesystem (read transcript → upsert jsonl).
|
||||
//
|
||||
// Zero npm dependencies. Node stdlib only.
|
||||
|
||||
import { readFileSync, existsSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
||||
// Per-Mtok USD prices, resolved 2026-06-26 via the claude-api skill reference:
|
||||
// base input/output from the model table; cache rates from the prompt-caching
|
||||
// doc multipliers (cache_read 0.1x, write_5m 1.25x, write_1h 2.0x of input).
|
||||
// claude-fable-5 resolved 2026-07-02 from the official platform pricing docs.
|
||||
export const PRICE_TABLE = Object.freeze({
|
||||
'claude-opus-4-8': Object.freeze({
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cache_read: 0.5,
|
||||
cache_write_5m: 6.25,
|
||||
cache_write_1h: 10.0,
|
||||
}),
|
||||
'claude-fable-5': Object.freeze({
|
||||
input: 10.0,
|
||||
output: 50.0,
|
||||
cache_read: 1.0,
|
||||
cache_write_5m: 12.5,
|
||||
cache_write_1h: 20.0,
|
||||
}),
|
||||
});
|
||||
|
||||
// Date the PRICE_TABLE values were resolved/verified. Bump when prices change.
|
||||
export const PRICE_TABLE_VERSION = '2026-07-02';
|
||||
|
||||
function num(v) {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single pass over transcript JSONL text. Keeps only main-chain assistant
|
||||
* records (type==='assistant', isSidechain!==true), dedups by requestId
|
||||
* keeping the LAST occurrence (per-request usage snapshots duplicate across
|
||||
* streamed records), and sums the four usage token fields. Malformed lines
|
||||
* are skipped.
|
||||
*
|
||||
* @returns {{tokens_input:number, tokens_output:number,
|
||||
* tokens_cache_creation:number, tokens_cache_read:number}}
|
||||
*/
|
||||
export function parseTranscriptUsage(text) {
|
||||
const lines = (text || '').split('\n');
|
||||
// requestId -> usage (last occurrence wins). Records without a requestId
|
||||
// get a unique sentinel key so each is still counted exactly once.
|
||||
const byRequest = new Map();
|
||||
let anon = 0;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(trimmed); }
|
||||
catch { continue; }
|
||||
if (!obj || obj.type !== 'assistant') continue;
|
||||
if (obj.isSidechain === true) continue;
|
||||
const usage = obj.message && obj.message.usage;
|
||||
if (!usage || typeof usage !== 'object') continue;
|
||||
const key = typeof obj.requestId === 'string' && obj.requestId
|
||||
? obj.requestId
|
||||
: `__anon_${anon++}`;
|
||||
byRequest.set(key, usage); // last occurrence wins
|
||||
}
|
||||
const totals = {
|
||||
tokens_input: 0,
|
||||
tokens_output: 0,
|
||||
tokens_cache_creation: 0,
|
||||
tokens_cache_read: 0,
|
||||
};
|
||||
for (const usage of byRequest.values()) {
|
||||
totals.tokens_input += num(usage.input_tokens);
|
||||
totals.tokens_output += num(usage.output_tokens);
|
||||
totals.tokens_cache_creation += num(usage.cache_creation_input_tokens);
|
||||
totals.tokens_cache_read += num(usage.cache_read_input_tokens);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aware USD cost. Lumped cache_creation is priced at the 5m write rate
|
||||
* (Claude Code's default cache TTL; verified the dominant case on a real
|
||||
* transcript — the 1h split is a documented v2 refinement). When the model is
|
||||
* absent from the table we REFUSE to estimate: {cost_usd:null, is_estimate:true}.
|
||||
*
|
||||
* @returns {{cost_usd:number|null, is_estimate:boolean}}
|
||||
*/
|
||||
export function deriveCost(totals, model, priceTable = PRICE_TABLE) {
|
||||
const price = priceTable && priceTable[model];
|
||||
if (!price) return { cost_usd: null, is_estimate: true };
|
||||
const t = totals || {};
|
||||
const cost =
|
||||
(num(t.tokens_input) * price.input +
|
||||
num(t.tokens_output) * price.output +
|
||||
num(t.tokens_cache_creation) * price.cache_write_5m +
|
||||
num(t.tokens_cache_read) * price.cache_read) / 1_000_000;
|
||||
return { cost_usd: cost, is_estimate: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat numeric record. Stamps scope:'main-context' and
|
||||
* price_table_version. session_id is written for upsert keying but MUST be
|
||||
* stripped at export (CWE-212, enforced by the field allowlist in Step 2).
|
||||
*
|
||||
* @param {{sessionId:string, model:string, totals:object,
|
||||
* priceTable?:object, now:string}} args
|
||||
*/
|
||||
export function buildRecord({ sessionId, model, totals, priceTable = PRICE_TABLE, now }) {
|
||||
const t = totals || {};
|
||||
const { cost_usd, is_estimate } = deriveCost(t, model, priceTable);
|
||||
return {
|
||||
ts: now,
|
||||
session_id: sessionId,
|
||||
scope: 'main-context',
|
||||
model,
|
||||
tokens_input: num(t.tokens_input),
|
||||
tokens_output: num(t.tokens_output),
|
||||
tokens_cache_creation: num(t.tokens_cache_creation),
|
||||
tokens_cache_read: num(t.tokens_cache_read),
|
||||
cost_usd,
|
||||
is_estimate,
|
||||
price_table_version: PRICE_TABLE_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure read-modify-write. Parses existing JSONL lines, REPLACES the line whose
|
||||
* session_id matches the record (dropping any further same-session dupes),
|
||||
* else APPENDS. Returns the new file text (one line per session, trailing
|
||||
* newline). One-line-per-session is what keeps cross-session sums from
|
||||
* double-counting (vs append-and-grow).
|
||||
*/
|
||||
export function upsertSessionRecord(existingText, record) {
|
||||
const lines = (existingText || '').split('\n');
|
||||
const out = [];
|
||||
let replaced = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(trimmed); }
|
||||
catch { out.push(trimmed); continue; } // preserve unparseable lines verbatim
|
||||
if (obj && obj.session_id === record.session_id) {
|
||||
if (!replaced) { out.push(JSON.stringify(record)); replaced = true; }
|
||||
// else: duplicate same-session line — drop it
|
||||
} else {
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (!replaced) out.push(JSON.stringify(record));
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Last main-chain (non-sidechain) assistant model in the transcript. Used to
|
||||
* pick the price-table key. Returns null when no model is found (→ deriveCost
|
||||
* refuses to estimate). Pure.
|
||||
*/
|
||||
export function lastMainChainModel(text) {
|
||||
const lines = (text || '').split('\n');
|
||||
let model = null;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(trimmed); }
|
||||
catch { continue; }
|
||||
if (!obj || obj.type !== 'assistant' || obj.isSidechain === true) continue;
|
||||
const m = obj.message && obj.message.model;
|
||||
if (typeof m === 'string' && m) model = m;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Impure capture shell (Step 4). Reads the transcript, derives main-context
|
||||
* token totals + cost, and UPSERTS a one-line-per-session record into
|
||||
* {dataDir}/token-usage-stats.jsonl via an atomic temp+rename write
|
||||
* (EXDEV mitigation: tmp lives in the same dir as the target).
|
||||
*
|
||||
* Returns the written record, or null when skipped (no path / no dataDir /
|
||||
* transcript not a readable regular file). Throws propagate to the caller —
|
||||
* the Stop hook wraps this in try/catch so capture stays fail-open.
|
||||
*
|
||||
* @param {{transcriptPath:string, sessionId:string, dataDir:string,
|
||||
* now?:string}} args
|
||||
*/
|
||||
export function captureTokenUsage({ transcriptPath, sessionId, dataDir, now }) {
|
||||
if (!transcriptPath || !dataDir) return null;
|
||||
let st;
|
||||
try { st = statSync(transcriptPath); }
|
||||
catch { return null; }
|
||||
if (!st.isFile()) return null;
|
||||
|
||||
const text = readFileSync(transcriptPath, 'utf-8');
|
||||
const totals = parseTranscriptUsage(text);
|
||||
const model = lastMainChainModel(text);
|
||||
const record = buildRecord({
|
||||
sessionId,
|
||||
model,
|
||||
totals,
|
||||
now: now || new Date().toISOString(),
|
||||
});
|
||||
|
||||
const outPath = join(dataDir, 'token-usage-stats.jsonl');
|
||||
const existing = existsSync(outPath) ? readFileSync(outPath, 'utf-8') : '';
|
||||
const updated = upsertSessionRecord(existing, record);
|
||||
|
||||
const tmpPath = join(dirname(outPath), '.token-usage-stats.jsonl.tmp');
|
||||
writeFileSync(tmpPath, updated);
|
||||
renameSync(tmpPath, outPath);
|
||||
return record;
|
||||
}
|
||||
|
|
@ -1,351 +0,0 @@
|
|||
// lib/util/research-loop-cap.mjs
|
||||
// Stateful, default-off cost cap for the /trekresearch bounded conversation
|
||||
// loop (Phase 4.5 dimension discovery + Phase 5 loop turns).
|
||||
//
|
||||
// Three properties the plan review required:
|
||||
// (a) Default-off — VOYAGE_STORM_ENABLED must be '1'; otherwise the budget
|
||||
// is 0 regardless of effort. This IS the decline branch: doing nothing
|
||||
// leaves the mechanism off, and adopt is flipping this one constant.
|
||||
// (b) The cap counts itself — allowTurn() derives used-turn count from an
|
||||
// append-only JSONL ledger, never from a caller-supplied number. A cap
|
||||
// that asks the caller how many turns it has used is not a cap. Each
|
||||
// grant additionally claims a turn SLOT with O_EXCL, so the bound holds
|
||||
// when several callers decide at once instead of only when they queue.
|
||||
// (c) Correct size bound — worst case is max_conv_turns × max_total_dimensions,
|
||||
// where max_total_dimensions is the WHOLE list (interview + discovered)
|
||||
// under the maxDimensions cap of 8 — not × discovered-only.
|
||||
//
|
||||
// CLAUDE_PLUGIN_DATA absent => fall back to ~/.claude/voyage. The variable is
|
||||
// EMPTY in the Bash tool's process env (measured in a live plugin-enabled
|
||||
// session), and the Phase 5 bash snippet is this module's only caller — so
|
||||
// denying on its absence denied turn 1 of every real run. The root is resolved
|
||||
// in code rather than demanded of the environment, and hooks/scripts/
|
||||
// pre-agent-cap.mjs resolves it through the SAME function, so the writer and
|
||||
// the reader can never disagree about where the ledger lives.
|
||||
//
|
||||
// The fail-closed stance covers both directions of ledger IO: a ledger that
|
||||
// cannot be WRITTEN denies the turn, and a ledger that exists but cannot be
|
||||
// READ denies it too. Only ENOENT counts as zero turns spent, because that is
|
||||
// the legitimate first-turn state. This module is a budget control, not
|
||||
// telemetry — the opposite of lib/stats/event-emit.mjs's fail-open.
|
||||
//
|
||||
// CLI shim, two modes:
|
||||
// node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E
|
||||
// → JSON: { ok, used, budget, reason? } (exit 0 = granted, exit 1 = denied)
|
||||
//
|
||||
// node lib/util/research-loop-cap.mjs --check-dimensions N
|
||||
// → JSON: { ok, count, ceiling, reason? } (exit 0 = within, exit 1 = rejected)
|
||||
// The second axis of the bounded-cost NFR. Phase 4.5 never calls the budget
|
||||
// gate, so this mode requires no run id, effort or STORM flag — but it reads
|
||||
// the SAME MAX_TOTAL_DIMENSIONS the budget is sized against.
|
||||
|
||||
import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { isMain } from './is-main.mjs';
|
||||
|
||||
export const MAX_CONV_TURNS = 3;
|
||||
export const MAX_TOTAL_DIMENSIONS = 8; // maxDimensions — whole list, not discovered-only
|
||||
|
||||
const LEDGER_FILENAME = 'trekresearch-loop-ledger.jsonl';
|
||||
const CLAIM_DIRNAME = 'trekresearch-loop-claims';
|
||||
|
||||
export function isStormEnabled(env = process.env) {
|
||||
return env.VOYAGE_STORM_ENABLED === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* The OTHER cost ceiling: how large the whole dimension list may get after
|
||||
* Phase 4.5 discovery has appended to it.
|
||||
*
|
||||
* The bounded-cost NFR asks for explicit ceilings on both axes. The turn axis
|
||||
* had a constant, a ledger-backed reader and a PreToolUse enforcer; the
|
||||
* discovery axis had only a sentence in Phase 4.5 prose — a cap nothing reads,
|
||||
* which is the failure mode the operator decision on brief_reviewer_iter_cap
|
||||
* warned about. This is the reader.
|
||||
*
|
||||
* The ceiling is MAX_TOTAL_DIMENSIONS on purpose: the value that sizes the turn
|
||||
* budget IS maxDimensions, and a second constant for the same
|
||||
* number is how two readers end up enforcing different bounds.
|
||||
*
|
||||
* Accepts the dimension list or its count, because Phase 4.5 has the list and
|
||||
* the CLI has a number. A count that cannot be read is REJECTED — a cost ceiling
|
||||
* that waves through what it cannot measure is not a ceiling.
|
||||
*
|
||||
* @param {string[]|number|string} dimensions
|
||||
* @param {{ceiling?: number}} [opts]
|
||||
* @returns {{ok: boolean, count: number|null, ceiling: number, reason?: string}}
|
||||
*/
|
||||
export function checkDimensionCeiling(dimensions, opts = {}) {
|
||||
const ceiling = Number.isFinite(opts.ceiling) ? opts.ceiling : MAX_TOTAL_DIMENSIONS;
|
||||
const count = Array.isArray(dimensions) ? dimensions.length : Number(dimensions);
|
||||
if (dimensions === null || dimensions === undefined || !Number.isInteger(count) || count < 0) {
|
||||
return { ok: false, count: null, ceiling, reason: 'unreadable_dimension_count' };
|
||||
}
|
||||
if (count > ceiling) {
|
||||
return { ok: false, count, ceiling, reason: 'ceiling_exceeded' };
|
||||
}
|
||||
return { ok: true, count, ceiling };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce TREKRESEARCH_MAX_CONV_TURNS. NaN, empty, negative, zero, Infinity, or
|
||||
* any fraction that floors below 1 all fall back to MAX_CONV_TURNS — never to
|
||||
* unbounded, and never to 0.
|
||||
*
|
||||
* The floor is applied BEFORE the `<= 0` guard, not after. Flooring afterwards
|
||||
* let '0.5' and '0.9' clear a guard written against the raw value and then
|
||||
* become 0, making the budget 0 × MAX_TOTAL_DIMENSIONS = 0: every turn denied
|
||||
* and the loop silently dead rather than bounded. A cap of 0 is not a narrower
|
||||
* cap, it is an off switch that the documented fallback promises not to be.
|
||||
*/
|
||||
export function resolveMaxConvTurns(env = process.env) {
|
||||
const raw = env.TREKRESEARCH_MAX_CONV_TURNS;
|
||||
if (raw === undefined || raw === null || raw === '') return MAX_CONV_TURNS;
|
||||
const n = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(n) || n <= 0) return MAX_CONV_TURNS;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one data root for everything this loop writes: the turn ledger and the
|
||||
* PreToolUse scope marker. CLAUDE_PLUGIN_DATA when the harness provides it,
|
||||
* ~/.claude/voyage when it does not — which is the case in every Bash tool
|
||||
* invocation today.
|
||||
*/
|
||||
export function resolveDataRoot(env = process.env) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (dir && typeof dir === 'string' && dir.length > 0) return dir;
|
||||
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
|
||||
return join(home, '.claude', 'voyage');
|
||||
}
|
||||
|
||||
export function resolveLedgerPath(env = process.env) {
|
||||
return join(resolveDataRoot(env), LEDGER_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the per-turn claim files live. A claim is the ATOMIC record that a turn
|
||||
* slot is taken; the ledger is the readable record of what that turn was for.
|
||||
*
|
||||
* The claim exists because the ledger alone cannot bound the loop. Counting the
|
||||
* ledger and then appending is read-then-write: N callers that all observe
|
||||
* `used == budget - 1` all decide to grant, and the bound is exceeded by N-1 —
|
||||
* precisely the concurrent case (several agents spawned in one message) that
|
||||
* allowTurn's own comment named as the reason it had to be append-only.
|
||||
*
|
||||
* Claim files are empty, at most `budget` per run, and never cleaned up — the
|
||||
* same standing as the ledger itself, which also grows for the life of the data
|
||||
* root. Two consequences worth stating rather than discovering: reusing a
|
||||
* runId across runs finds its slots already taken and denies, and two runIds
|
||||
* that collide after filename sanitisation block each other. Both err toward
|
||||
* denying a turn, which is the safe direction for a budget control.
|
||||
*/
|
||||
export function resolveClaimDir(env = process.env) {
|
||||
return join(resolveDataRoot(env), CLAIM_DIRNAME);
|
||||
}
|
||||
|
||||
function claimFileName(runId, slot) {
|
||||
return `${String(runId).replace(/[^A-Za-z0-9._-]/g, '_')}-${slot}.claim`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to take turn slot `slot` for `runId`. `wx` is O_CREAT|O_EXCL: the kernel
|
||||
* decides the winner, so exactly one caller can ever create a given slot file.
|
||||
*
|
||||
* @returns {boolean} true when this caller took the slot, false when it was already taken
|
||||
* @throws on any IO error other than EEXIST — the caller turns that into a denial
|
||||
*/
|
||||
function claimSlot(claimDir, runId, slot) {
|
||||
try {
|
||||
writeFileSync(join(claimDir, claimFileName(runId, slot)), '', { flag: 'wx' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e && e.code === 'EEXIST') return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one run's turn count off the append-only ledger.
|
||||
*
|
||||
* ENOENT is 0 turns spent — the legitimate first-turn state, and the reason
|
||||
* this cannot simply throw on every read failure. Every OTHER read error
|
||||
* (EISDIR, EACCES, EIO) THROWS, because returning 0 from an unreadable ledger
|
||||
* re-granted the full budget on every call: unbounded, and the exact
|
||||
* silently-grant-unlimited failure this module's header argues against three
|
||||
* lines above the code that did it. The missing-directory case already failed
|
||||
* closed; this makes the unreadable-file case agree with it.
|
||||
*
|
||||
* The `existsSync` pre-check is deliberately gone: readFileSync's own ENOENT
|
||||
* carries the same information without a second syscall that can disagree with
|
||||
* the read that follows it.
|
||||
*
|
||||
* Exported so hooks/scripts/pre-agent-cap.mjs counts through this exact
|
||||
* function. A reader and a writer with private copies of the counting rule are
|
||||
* how a hook ends up enforcing a different bound than the gate it backs.
|
||||
*
|
||||
* Two counts, deliberately separate. `granted` is turns handed out. `exhausted`
|
||||
* is tombstones — records this gate wrote when it DENIED a turn. A tombstone is
|
||||
* not a turn and must never consume budget; it exists so the PreToolUse hook can
|
||||
* tell "turn B is in flight" (granted == budget, no tombstone) apart from "the
|
||||
* gate already said no and something kept going" (tombstone present).
|
||||
*
|
||||
* @param {string} ledgerPath
|
||||
* @param {string} runId
|
||||
* @returns {{granted: number, exhausted: number}}
|
||||
* @throws when the ledger exists but cannot be read
|
||||
*/
|
||||
export function readLedger(ledgerPath, runId) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(ledgerPath, 'utf-8');
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') return { granted: 0, exhausted: 0 };
|
||||
const err = new Error(`ledger unreadable at ${ledgerPath}: ${e.message}`);
|
||||
err.code = 'VOYAGE_LEDGER_UNREADABLE';
|
||||
throw err;
|
||||
}
|
||||
let granted = 0;
|
||||
let exhausted = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const rec = JSON.parse(line);
|
||||
if (rec.runId !== runId) continue;
|
||||
if (rec.exhausted === true) exhausted++;
|
||||
else granted++;
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
return { granted, exhausted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this run has been denied a turn for budget.
|
||||
*
|
||||
* Best effort on purpose: the denial itself is already the correct answer, so a
|
||||
* ledger that cannot take the tombstone must not turn a denial into a grant. The
|
||||
* tombstone only strengthens the harness-level backstop.
|
||||
*/
|
||||
function markExhausted(ledgerPath, runId, now) {
|
||||
try {
|
||||
appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, exhausted: true }) + '\n');
|
||||
} catch { /* best effort — see above */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one more research-loop turn may run.
|
||||
*
|
||||
* Phase 4.5/5 may spawn several agents in a single message, so the decision has
|
||||
* to survive concurrent callers. It does that by CLAIMING a turn slot with
|
||||
* O_EXCL (see claimSlot) and only then appending to the ledger. The comment
|
||||
* that used to sit here asserted "append-only: never read-modify-write" as if
|
||||
* appending were itself the concurrency guarantee — but the decision path was
|
||||
* count-then-append, which is read-then-write, so the claim was unsupported by
|
||||
* the code beneath it. The kernel now picks the winner for each slot.
|
||||
*
|
||||
* @param {{runId: string, dimension: string, effort: string}} args
|
||||
* @param {{env?: object, now?: Date}} [opts]
|
||||
* @returns {{ok: boolean, used: number, budget: number, reason?: string}}
|
||||
*/
|
||||
export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const now = opts.now || new Date();
|
||||
|
||||
if (!isStormEnabled(env)) {
|
||||
return { ok: false, used: 0, budget: 0, reason: 'storm_disabled' };
|
||||
}
|
||||
if (effort !== 'high') {
|
||||
return { ok: false, used: 0, budget: 0, reason: 'effort_not_high' };
|
||||
}
|
||||
if (!runId || !dimension) {
|
||||
return { ok: false, used: 0, budget: 0, reason: 'missing_args' };
|
||||
}
|
||||
|
||||
const maxConvTurns = resolveMaxConvTurns(env);
|
||||
const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
const ledgerPath = resolveLedgerPath(env);
|
||||
let ledger;
|
||||
try {
|
||||
ledger = readLedger(ledgerPath, runId);
|
||||
} catch (e) {
|
||||
return { ok: false, used: 0, budget, reason: `ledger-read-failed: ${e.message}` };
|
||||
}
|
||||
const used = ledger.granted;
|
||||
|
||||
// Already tombstoned: this run is over. Short-circuit so a hammered gate
|
||||
// neither walks every slot again nor appends a second tombstone.
|
||||
if (ledger.exhausted > 0) {
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
// Claim a turn SLOT before spending anything. The ledger count only says
|
||||
// where to start looking; the claim is what makes the grant exclusive. Slot
|
||||
// numbers are bounded by `budget`, and each can be created exactly once, so
|
||||
// the total number of grants for a run can never exceed the budget however
|
||||
// many callers arrive at once.
|
||||
const claimDir = resolveClaimDir(env);
|
||||
let slot = used + 1;
|
||||
let claimed = false;
|
||||
try {
|
||||
mkdirSync(claimDir, { recursive: true });
|
||||
while (slot <= budget) {
|
||||
if (claimSlot(claimDir, runId, slot)) { claimed = true; break; }
|
||||
slot++;
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, used, budget, reason: `claim-failed: ${e.message}` };
|
||||
}
|
||||
if (!claimed) {
|
||||
markExhausted(ledgerPath, runId, now);
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = dirname(ledgerPath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, dimension, effort, slot }) + '\n');
|
||||
} catch (e) {
|
||||
return { ok: false, used, budget, reason: `ledger-write-failed: ${e.message}` };
|
||||
}
|
||||
|
||||
return { ok: true, used: slot, budget };
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--run-id') out.runId = argv[++i];
|
||||
else if (a === '--dimension') out.dimension = argv[++i];
|
||||
else if (a === '--effort') out.effort = argv[++i];
|
||||
else if (a === '--check-dimensions') out.checkDimensions = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// The dimension ceiling is a Phase 4.5 concern, and Phase 4.5 never calls the
|
||||
// budget gate — so this branch must not inherit the gate's preconditions
|
||||
// (run id, effort, STORM flag). It is a pure bound on list size.
|
||||
if (args.checkDimensions !== undefined) {
|
||||
const result = checkDimensionCeiling(args.checkDimensions);
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (!args.runId || !args.dimension || !args.effort) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
reason: 'usage: research-loop-cap.mjs --run-id ID --dimension D --effort standard|high|low',
|
||||
}) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const result = allowTurn(args);
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue, ok, fail } from '../util/result.mjs';
|
||||
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
|
||||
import { parseSuccessCriteria, runnableCheck, firstCommand } from '../verification/criteria-runner.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
|
|
@ -18,8 +17,6 @@ export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'pr
|
|||
export const BRIEF_TYPE_VALUES = Object.freeze(['trekbrief', 'trekreview']);
|
||||
export const BRIEF_RESEARCH_STATUS_VALUES = ['pending', 'in_progress', 'complete', 'skipped'];
|
||||
export const BRIEF_BODY_SECTIONS = ['Intent', 'Goal', 'Success Criteria'];
|
||||
export const PHASE_SIGNAL_PHASES = Object.freeze(['research', 'plan', 'execute', 'review']);
|
||||
export const EFFORT_LEVELS = Object.freeze(['low', 'standard', 'high']);
|
||||
// v5.5 — framing: how this brief relates to prior operator intent (the first layer
|
||||
// of the framing-alignment defense). Required at brief_version ≥ 2.2.
|
||||
export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replace', 'new-direction']);
|
||||
|
|
@ -77,55 +74,6 @@ export function validateBriefContent(text, opts = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
// v5.1 — phase_signals (additive optional field) + version-conditional sequencing gate.
|
||||
// Composition rule documented in each downstream command's "Composition rule (v5.1)" section.
|
||||
const hasSignals = 'phase_signals' in fm;
|
||||
const hasPartial = 'phase_signals_partial' in fm;
|
||||
if (hasSignals && hasPartial) {
|
||||
errors.push(issue(
|
||||
'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE',
|
||||
'phase_signals and phase_signals_partial are mutually exclusive — set exactly one',
|
||||
'Either commit per-phase signals OR record phase_signals_partial: true (force-stop).',
|
||||
));
|
||||
}
|
||||
if (hasSignals) {
|
||||
if (!Array.isArray(fm.phase_signals)) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INVALID_PHASE_SIGNALS',
|
||||
'phase_signals must be a list of {phase, effort?, model?} entries',
|
||||
));
|
||||
} else {
|
||||
for (const entry of fm.phase_signals) {
|
||||
if (!entry || typeof entry !== 'object' || !('phase' in entry)) {
|
||||
errors.push(issue('BRIEF_INVALID_PHASE_SIGNALS', `phase_signals entry must include a "phase" key`));
|
||||
continue;
|
||||
}
|
||||
if (!PHASE_SIGNAL_PHASES.includes(entry.phase)) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INVALID_PHASE_SIGNAL_PHASE',
|
||||
`phase_signals.phase "${entry.phase}" not in [${PHASE_SIGNAL_PHASES.join(', ')}]`,
|
||||
));
|
||||
}
|
||||
if ('effort' in entry && !EFFORT_LEVELS.includes(entry.effort)) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INVALID_EFFORT',
|
||||
`phase_signals.effort "${entry.effort}" not in [${EFFORT_LEVELS.join(', ')}]`,
|
||||
));
|
||||
}
|
||||
if ('model' in entry && !BASE_ALLOWED_MODELS.includes(entry.model)) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INVALID_MODEL',
|
||||
`phase_signals.model "${entry.model}" not in [${BASE_ALLOWED_MODELS.join(', ')}]`,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sequencing gate: brief_version ≥ 2.1 requires phase_signals OR phase_signals_partial.
|
||||
// Coerce to String so the gate fires regardless of whether YAML parsed the value as
|
||||
// a string ("2.1") or a number (2.1). v5.1.0 shipped with an unquoted-2.1 template
|
||||
// that silently bypassed this gate — fix locked in by quoting the template AND
|
||||
// accepting both shapes here as defense-in-depth (v5.1.1, finding 3c834097/df1435a2).
|
||||
// v5.5 — framing enum check fires on ANY version when the field is present but
|
||||
// malformed. The missing-framing BLOCKER below is version-gated (≥ 2.2).
|
||||
if ('framing' in fm && !BRIEF_FRAMING_VALUES.includes(fm.framing)) {
|
||||
|
|
@ -156,15 +104,7 @@ export function validateBriefContent(text, opts = {}) {
|
|||
if (vm) {
|
||||
const major = Number(vm[1]);
|
||||
const minor = Number(vm[2]);
|
||||
const atLeast21 = major > 2 || (major === 2 && minor >= 1);
|
||||
const atLeast22 = major > 2 || (major === 2 && minor >= 2);
|
||||
if (atLeast21 && !hasSignals && !hasPartial && fm.type !== 'trekreview') {
|
||||
errors.push(issue(
|
||||
'BRIEF_V51_MISSING_SIGNALS',
|
||||
'brief_version ≥ 2.1 requires phase_signals (or phase_signals_partial: true)',
|
||||
'Re-run /trekbrief — Phase 3.5 collects per-phase effort + model signals.',
|
||||
));
|
||||
}
|
||||
// v5.5 framing enforcement — gated at ≥ 2.2 (trekreview briefs are exempt).
|
||||
if (atLeast22 && fm.type !== 'trekreview') {
|
||||
if (!('framing' in fm)) {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
// node lib/validators/intent-approval.mjs --check [--json] <brief.md>
|
||||
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
|
||||
// node lib/validators/intent-approval.mjs --stamp <brief.md>
|
||||
// writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused
|
||||
// writes the marker; exit 0 stamped, 1 refused
|
||||
// node lib/validators/intent-approval.mjs --resolve <project-dir | brief-file>
|
||||
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
|
||||
|
||||
|
|
@ -43,15 +43,12 @@ import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'n
|
|||
import { basename, dirname, join } from 'node:path';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue } from '../util/result.mjs';
|
||||
import { emit } from '../stats/event-emit.mjs';
|
||||
import { resolveStatsDir } from '../stats/stats-append.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const INTENT_HASH_FIELD = 'intent_approved_hash';
|
||||
export const INTENT_APPROVED_AT_FIELD = 'intent_approved_at';
|
||||
export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
|
||||
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
||||
const STATS_FILENAME = 'trekexecute-stats.jsonl'; // where event-emit writes lifecycle events
|
||||
|
||||
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal and answers "Approve".';
|
||||
|
||||
|
|
@ -200,18 +197,6 @@ export function stampIntentApproval(text, now = new Date()) {
|
|||
return { stamped: true, text: out, hash, approvedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the brief-approved record goes. CLAUDE_PLUGIN_DATA when the harness
|
||||
* provides it; otherwise the plugin data dir the hooks write to and
|
||||
* scripts/yardstick.mjs reads — the Bash tool env carries no CLAUDE_PLUGIN_DATA.
|
||||
* event-emit's fallback used to be a silent skip, which is how brief-approved
|
||||
* reached 0 records; it now resolves the same directory. One resolver for every
|
||||
* stats writer: lib/stats/stats-append.mjs.
|
||||
*/
|
||||
export function resolveApprovalDataDir(env = process.env) {
|
||||
return resolveStatsDir(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* What `/trekbrief --approve <arg>` approves. A directory means `<dir>/brief.md`
|
||||
* (a /trekplan --project brief); a file is itself (a /trekplan --brief brief,
|
||||
|
|
@ -278,12 +263,8 @@ if (isMain(import.meta.url)) {
|
|||
const tmp = join(dirname(file), `.${basename(file)}.approve.tmp`);
|
||||
writeFileSync(tmp, s.text);
|
||||
renameSync(tmp, file);
|
||||
const fm = parseDocument(s.text).parsed.frontmatter || {};
|
||||
const record = emit('brief-approved',
|
||||
{ project: dirname(file), slug: fm.slug ?? null, intent_hash: s.hash },
|
||||
{ path: join(resolveApprovalDataDir(), STATS_FILENAME) });
|
||||
process.stdout.write(JSON.stringify({
|
||||
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt, record,
|
||||
stamped: true, intent_hash: s.hash, approved_at: s.approvedAt,
|
||||
}) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import {
|
|||
testFirstFileEntries, testFirstFiles,
|
||||
} from '../parsers/plan-schema.mjs';
|
||||
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue, fail } from '../util/result.mjs';
|
||||
import { parsePlanVerification, firstCommand, runnableCheck, refusal } from '../verification/criteria-runner.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
|
@ -426,29 +425,6 @@ export function validatePlanContent(rawText, opts = {}) {
|
|||
else warnings.push(...v18);
|
||||
}
|
||||
|
||||
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
|
||||
// If plan frontmatter declares `profile: <name>` and any step manifest
|
||||
// declares `profile_used: <other>`, emit a warning (NOT an error) so
|
||||
// operators see drift but parsing remains forward-compat.
|
||||
if (strict) {
|
||||
const planFm = parseDocument(text).parsed?.frontmatter;
|
||||
const planProfile =
|
||||
planFm && typeof planFm.profile === 'string' ? planFm.profile : null;
|
||||
if (planProfile) {
|
||||
for (const m of manRes.parsed) {
|
||||
const stepProfile = m.manifest && m.manifest.profile_used;
|
||||
if (typeof stepProfile === 'string' && stepProfile !== planProfile) {
|
||||
warnings.push(issue(
|
||||
'MANIFEST_PROFILE_DRIFT',
|
||||
`step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`,
|
||||
'A step manifest declares a different profile than the plan frontmatter; ' +
|
||||
'verify whether this is intentional (manual override) or accidental drift.',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
// lib/validators/profile-validator.mjs
|
||||
// Validate model-profile YAML files (lib/profiles/*.yaml + custom voyage-profiles/*.yaml).
|
||||
//
|
||||
// Profile schema (v4.1, profile_version 1.0):
|
||||
// name : string
|
||||
// phase_models : list-of-dicts [{phase: string, model: string}, ...]
|
||||
// 6 entries required (brief, research, plan, execute, review, continue)
|
||||
// parallel_agents_min : number (≥ 1)
|
||||
// parallel_agents_max : number (≥ parallel_agents_min)
|
||||
// external_research_enabled : boolean
|
||||
// brief_reviewer_iter_cap : number (≥ 1)
|
||||
// experimental : boolean (OPTIONAL) — true marks a tier whose
|
||||
// gating constants are not yet empirically
|
||||
// calibrated (e.g. economy's parked-synthetic
|
||||
// cross-tier Jaccard floor). Absent ⇒ stable.
|
||||
//
|
||||
// Issue codes:
|
||||
// PROFILE_MISSING_FIELD — required top-level frontmatter field absent
|
||||
// PROFILE_INVALID_MODEL — phase_models[N].model not in allowlist
|
||||
// PROFILE_INVALID_ENUM — wrong-type value (e.g. external_research_enabled is string)
|
||||
// PROFILE_READ_ERROR — file unreadable or parse-error
|
||||
// PROFILE_NOT_FOUND — file does not exist
|
||||
//
|
||||
// Allowed model values: ['sonnet', 'opus', 'fable']. Haiku is allowed only when
|
||||
// VOYAGE_ALLOW_HAIKU=1 (the model-choice rule: Haiku is never a default, only
|
||||
// an explicit opt-in for special use).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue, ok, fail } from '../util/result.mjs';
|
||||
import { isMain } from '../util/is-main.mjs';
|
||||
|
||||
export const PROFILE_REQUIRED_FIELDS = Object.freeze([
|
||||
'name',
|
||||
'phase_models',
|
||||
'parallel_agents_min',
|
||||
'parallel_agents_max',
|
||||
'external_research_enabled',
|
||||
'brief_reviewer_iter_cap',
|
||||
]);
|
||||
|
||||
export const PROFILE_REQUIRED_PHASES = Object.freeze([
|
||||
'brief', 'research', 'plan', 'execute', 'review', 'continue',
|
||||
]);
|
||||
|
||||
export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus', 'fable']);
|
||||
|
||||
function getAllowedModels(env = process.env) {
|
||||
if (env.VOYAGE_ALLOW_HAIKU === '1') {
|
||||
return [...BASE_ALLOWED_MODELS, 'haiku'];
|
||||
}
|
||||
return BASE_ALLOWED_MODELS;
|
||||
}
|
||||
|
||||
export function validateProfileContent(text, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const allowedModels = getAllowedModels(env);
|
||||
|
||||
const doc = parseDocument(text);
|
||||
if (!doc.valid) {
|
||||
return fail(issue('PROFILE_READ_ERROR', `Frontmatter parse error: ${doc.errors[0]?.message || 'unknown'}`));
|
||||
}
|
||||
|
||||
const fm = doc.parsed.frontmatter || {};
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
// Required field presence
|
||||
for (const k of PROFILE_REQUIRED_FIELDS) {
|
||||
if (!(k in fm)) {
|
||||
errors.push(issue('PROFILE_MISSING_FIELD', `Required profile field missing: ${k}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Type checks for scalar fields
|
||||
if ('name' in fm && typeof fm.name !== 'string') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM', `name must be string (got ${typeof fm.name})`));
|
||||
}
|
||||
|
||||
if ('parallel_agents_min' in fm && typeof fm.parallel_agents_min !== 'number') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`parallel_agents_min must be number (got ${typeof fm.parallel_agents_min})`));
|
||||
}
|
||||
|
||||
if ('parallel_agents_max' in fm && typeof fm.parallel_agents_max !== 'number') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`parallel_agents_max must be number (got ${typeof fm.parallel_agents_max})`));
|
||||
}
|
||||
|
||||
if (typeof fm.parallel_agents_min === 'number' && typeof fm.parallel_agents_max === 'number') {
|
||||
if (fm.parallel_agents_max < fm.parallel_agents_min) {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`parallel_agents_max (${fm.parallel_agents_max}) < parallel_agents_min (${fm.parallel_agents_min})`));
|
||||
}
|
||||
}
|
||||
|
||||
if ('external_research_enabled' in fm && typeof fm.external_research_enabled !== 'boolean') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`external_research_enabled must be boolean (got ${typeof fm.external_research_enabled})`));
|
||||
}
|
||||
|
||||
if ('brief_reviewer_iter_cap' in fm && typeof fm.brief_reviewer_iter_cap !== 'number') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`brief_reviewer_iter_cap must be number (got ${typeof fm.brief_reviewer_iter_cap})`));
|
||||
}
|
||||
|
||||
// Optional field: `experimental` (boolean). Absent ⇒ tier is stable.
|
||||
if ('experimental' in fm && typeof fm.experimental !== 'boolean') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`experimental must be boolean (got ${typeof fm.experimental})`));
|
||||
}
|
||||
|
||||
// phase_models validation
|
||||
if ('phase_models' in fm) {
|
||||
if (!Array.isArray(fm.phase_models)) {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM', `phase_models must be a list-of-dicts (got ${typeof fm.phase_models})`));
|
||||
} else {
|
||||
const seenPhases = new Set();
|
||||
for (let i = 0; i < fm.phase_models.length; i++) {
|
||||
const entry = fm.phase_models[i];
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`phase_models[${i}] must be a dict {phase, model} (got ${typeof entry})`,
|
||||
undefined,
|
||||
`phase_models[${i}]`));
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.phase !== 'string') {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`phase_models[${i}].phase must be string`,
|
||||
undefined,
|
||||
`phase_models[${i}].phase`));
|
||||
} else {
|
||||
seenPhases.add(entry.phase);
|
||||
if (!PROFILE_REQUIRED_PHASES.includes(entry.phase)) {
|
||||
errors.push(issue('PROFILE_INVALID_ENUM',
|
||||
`phase_models[${i}].phase "${entry.phase}" not in [${PROFILE_REQUIRED_PHASES.join(', ')}]`,
|
||||
undefined,
|
||||
`phase_models[${i}].phase`));
|
||||
}
|
||||
}
|
||||
if (typeof entry.model !== 'string') {
|
||||
errors.push(issue('PROFILE_INVALID_MODEL',
|
||||
`phase_models[${i}].model must be string`,
|
||||
undefined,
|
||||
`phase_models[${i}].model`));
|
||||
} else if (!allowedModels.includes(entry.model)) {
|
||||
errors.push(issue('PROFILE_INVALID_MODEL',
|
||||
`phase_models[${i}].model "${entry.model}" not in [${allowedModels.join(', ')}]` +
|
||||
(entry.model === 'haiku' ? ' (set VOYAGE_ALLOW_HAIKU=1 to allow)' : ''),
|
||||
undefined,
|
||||
`phase_models[${i}].model`));
|
||||
}
|
||||
}
|
||||
// All 6 required phases must be present
|
||||
for (const required of PROFILE_REQUIRED_PHASES) {
|
||||
if (!seenPhases.has(required)) {
|
||||
errors.push(issue('PROFILE_MISSING_FIELD',
|
||||
`phase_models missing required phase entry: ${required}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm } };
|
||||
}
|
||||
|
||||
export function validateProfile(filePath, opts = {}) {
|
||||
if (!existsSync(filePath)) {
|
||||
return fail(issue('PROFILE_NOT_FOUND', `Profile file not found: ${filePath}`));
|
||||
}
|
||||
let text;
|
||||
try { text = readFileSync(filePath, 'utf-8'); }
|
||||
catch (e) { return fail(issue('PROFILE_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
|
||||
const r = validateProfileContent(text, opts);
|
||||
return { ...r, parsed: { ...(r.parsed || {}), filePath } };
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const filePath = args.find(a => !a.startsWith('--'));
|
||||
if (!filePath) {
|
||||
process.stderr.write('Usage: profile-validator.mjs [--json] <profile.yaml>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const r = validateProfile(filePath);
|
||||
if (args.includes('--json')) {
|
||||
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
|
||||
} else {
|
||||
process.stdout.write(`profile-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
|
||||
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}${e.location ? ' at ' + e.location : ''}\n`);
|
||||
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
|
||||
}
|
||||
process.exit(r.valid ? 0 : 1);
|
||||
}
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
// lib/validators/query-privacy-gate.mjs
|
||||
// Inspect an outbound research query before it leaves the machine. Called
|
||||
// only from the new high-effort steps (Phase 4.5 dimension discovery + the
|
||||
// bounded Phase 5 loop turns) — the existing single-pass Phase 5 path is
|
||||
// unchanged (Step 6, plan-v2).
|
||||
// Inspect an outbound research query before it leaves the machine. No
|
||||
// command calls it now: its callers were the removed STORM steps.
|
||||
//
|
||||
// Two-tier, same shape as lib/exporters/endpoint-validator.mjs's SSRF gate:
|
||||
// Two-tier:
|
||||
// - WARN tier — absolute filesystem paths, repo-internal identifiers.
|
||||
// Operator-overridable via `strict: false` / `--soft` (matches
|
||||
// lib/validators/research-validator.mjs's strict/soft convention), and
|
||||
// fully bypassable via the VOYAGE_QUERY_PRIVACY_ALLOW=1 opt-in.
|
||||
// - HARD-BLOCK tier — secret-shaped tokens. NEVER overridable by strict,
|
||||
// --soft, or the opt-in env var — mirrors endpoint-validator.mjs's
|
||||
// HARD_BLOCKED_HOSTS, where an opt-in widens the warn tier but never
|
||||
// --soft, or the opt-in env var: an opt-in widens the warn tier but never
|
||||
// unlocks the permanently-blocked one.
|
||||
//
|
||||
// CLI shim:
|
||||
|
|
|
|||
|
|
@ -1,342 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/comparison-gate.mjs
|
||||
// The comparison gate - the lifted target in PLAN.md (operator, 2026-09-27): Voyage + the
|
||||
// operator's way of working is level with or ahead of the related frameworks in every area,
|
||||
// and every Voyage part a built-in Claude Code primitive covers is removed or has a measured
|
||||
// reason to stay. Two counts, each with a denominator read from its SOURCE:
|
||||
//
|
||||
// comparison areas where no reference is ahead (the areas: the first column of the table
|
||||
// under `source.heading` in `source.loc`;
|
||||
// counted = areas where every reference
|
||||
// cell's verdict is `likt` or `foran`)
|
||||
// tidy covered parts that are resolved (the parts: `git ls-files` over PART_SCOPE,
|
||||
// one part per eval case; counted = parts a
|
||||
// primitive covers that are `fjernet` or
|
||||
// carry a `maalt-grunn` with evidence)
|
||||
//
|
||||
// It fells the table on MISSING EVIDENCE, never on a verdict it disagrees with: a cited file
|
||||
// must exist inside its root and hold the cited lines, a pinned clone must sit at its pin, a
|
||||
// web citation needs a verbatim quote, and an absence needs zero hits plus a known-positive
|
||||
// control. A verdict is a reader's judgement; green also requires `reader.kind: uavhengig`.
|
||||
//
|
||||
// Exit 0 = both counts full, no evidence problem, independent reader; 1 = anything short of
|
||||
// that, including a table that does not exist (NOT MEASURED, never 0); 2 = usage error.
|
||||
// It only READS. The table is local-only (docs/comparison.local.json, `*.local.*`).
|
||||
//
|
||||
// Usage: node scripts/comparison-gate.mjs [--json] [--data <file>] [--parts-from <repo>]
|
||||
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export const DEFAULT_DATA = join(REPO_ROOT, 'docs', 'comparison.local.json');
|
||||
export const PART_SCOPE = ['commands', 'agents', 'hooks', 'lib', 'scripts', 'templates', 'evals', 'settings.json', 'verify.sh'];
|
||||
export const VERDICTS = ['foran', 'likt', 'bak'];
|
||||
const LEVEL_OR_AHEAD = new Set(['foran', 'likt']);
|
||||
export const COVERAGE = ['dekket', 'delvis', 'ikke-dekket'];
|
||||
export const RESOLUTIONS = ['maalt-grunn', 'aapen', 'fjernet'];
|
||||
|
||||
const toPosix = (p) => p.split(sep).join('/');
|
||||
const nonEmpty = (s) => typeof s === 'string' && s.trim() !== '';
|
||||
|
||||
// `ROOT:path:N` or `ROOT:path:N-M`, lines counted from 1. Anything else is not a loc.
|
||||
export function parseLoc(text) {
|
||||
const m = /^([A-Z]{2,}):(.+):(\d+)(?:-(\d+))?$/.exec(String(text ?? ''));
|
||||
if (!m) return null;
|
||||
const start = Number(m[3]);
|
||||
const end = m[4] === undefined ? start : Number(m[4]);
|
||||
if (start < 1 || end < start) return null;
|
||||
return { root: m[1], path: m[2], start, end };
|
||||
}
|
||||
|
||||
// The first column of the first table after `heading`, header and separator rows excluded.
|
||||
export function parseSourceAreas(markdown, heading) {
|
||||
const all = String(markdown).split('\n');
|
||||
const at = all.findIndex((l) => l.trim() === heading);
|
||||
if (at === -1) return null;
|
||||
const rows = [];
|
||||
let inTable = false;
|
||||
for (const line of all.slice(at + 1)) {
|
||||
if (/^#{1,6}\s/.test(line)) break;
|
||||
if (line.trim().startsWith('|')) {
|
||||
inTable = true;
|
||||
rows.push(line);
|
||||
} else if (inTable) break;
|
||||
}
|
||||
return rows.slice(2).map((r) => r.split('|')[1].trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// One part per tracked file in PART_SCOPE, except eval cases: one part per `evals/<case>/`.
|
||||
export function listParts(root) {
|
||||
const out = execFileSync('git', ['ls-files', '-z', '--', ...PART_SCOPE], { cwd: root, encoding: 'utf-8' });
|
||||
const parts = new Set();
|
||||
for (const f of out.split('\0').filter(Boolean)) {
|
||||
const segs = f.split('/');
|
||||
parts.add(segs[0] === 'evals' && segs.length > 2 ? `evals/${segs[1]}/` : f);
|
||||
}
|
||||
return [...parts].sort();
|
||||
}
|
||||
|
||||
function resolveRoot(value) {
|
||||
if (!nonEmpty(value)) return null;
|
||||
if (value === '~' || value.startsWith('~/')) return join(homedir(), value.slice(2));
|
||||
return isAbsolute(value) ? value : resolve(REPO_ROOT, value);
|
||||
}
|
||||
|
||||
function lineCount(file) {
|
||||
const text = readFileSync(file, 'utf-8');
|
||||
if (text === '') return 0;
|
||||
return text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
||||
}
|
||||
|
||||
// Problems with one evidence item, each prefixed with `where`.
|
||||
function checkEvidence(item, where, roots) {
|
||||
if (!item || typeof item !== 'object') return [`${where}: an evidence item must be an object`];
|
||||
const problems = [];
|
||||
if (!nonEmpty(item.says)) problems.push(`${where}: evidence needs \`says\``);
|
||||
if ('loc' in item) {
|
||||
const loc = parseLoc(item.loc);
|
||||
if (!loc) return [...problems, `${where}: ${item.loc} is not ROOT:path:N[-M]`];
|
||||
const root = roots[loc.root];
|
||||
if (!root) return [...problems, `${where}: ${item.loc} names an unknown root ${loc.root}`];
|
||||
const file = resolve(root, loc.path);
|
||||
const rel = relative(root, file);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) return [...problems, `${where}: ${item.loc} points outside its root`];
|
||||
if (!existsSync(file) || !statSync(file).isFile()) return [...problems, `${where}: ${item.loc} does not exist`];
|
||||
const n = lineCount(file);
|
||||
if (loc.end > n) problems.push(`${where}: ${item.loc} cites past the end of a file of ${n} lines`);
|
||||
return problems;
|
||||
}
|
||||
if ('url' in item) {
|
||||
if (!/^https:\/\/\S+$/.test(String(item.url))) problems.push(`${where}: ${item.url} is not an https URL`);
|
||||
if (!nonEmpty(item.quote)) problems.push(`${where}: a web citation needs a verbatim \`quote\``);
|
||||
return problems;
|
||||
}
|
||||
if ('absence' in item) {
|
||||
const a = item.absence ?? {};
|
||||
if (!nonEmpty(a.query)) problems.push(`${where}: an absence needs the \`query\` that was run`);
|
||||
if (a.hits !== 0) problems.push(`${where}: an absence must report hits: 0, got ${JSON.stringify(a.hits)}`);
|
||||
if (!nonEmpty(a.known_positive)) problems.push(`${where}: an absence needs a \`known_positive\` control`);
|
||||
return problems;
|
||||
}
|
||||
return [...problems, `${where}: evidence must be one of loc, url or absence`];
|
||||
}
|
||||
|
||||
function checkEvidenceList(list, where, roots) {
|
||||
if (!Array.isArray(list) || list.length === 0) return [`${where}: no evidence`];
|
||||
return list.flatMap((item, i) => checkEvidence(item, `${where}[${i}]`, roots));
|
||||
}
|
||||
|
||||
function pinProblems(references, roots) {
|
||||
const problems = [];
|
||||
for (const ref of references) {
|
||||
if (!nonEmpty(ref.pin)) continue;
|
||||
const root = roots[ref.id];
|
||||
if (!root || !existsSync(join(root, '.git'))) {
|
||||
problems.push(`reference ${ref.id}: pinned ${ref.pin} but ${root ?? 'no root'} is not a git clone`);
|
||||
continue;
|
||||
}
|
||||
let head = '';
|
||||
try {
|
||||
head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf-8' }).trim();
|
||||
} catch { /* reported below */ }
|
||||
if (!head.startsWith(ref.pin)) problems.push(`reference ${ref.id}: pinned ${ref.pin} but the clone's HEAD is ${head.slice(0, 7) || 'unreadable'}`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function evaluateComparison(data, roots) {
|
||||
const problems = [];
|
||||
const references = Array.isArray(data.references) ? data.references : [];
|
||||
if (references.length === 0) problems.push('no references to compare against');
|
||||
problems.push(...pinProblems(references, roots));
|
||||
|
||||
let sourceAreas = null;
|
||||
const src = parseLoc(`${data.source?.loc ?? ''}:1`);
|
||||
const srcFile = src && roots[src.root] ? resolve(roots[src.root], src.path) : null;
|
||||
if (!srcFile || !existsSync(srcFile)) {
|
||||
problems.push(`source ${data.source?.loc ?? '(none)'}: not readable - the denominator cannot be read`);
|
||||
} else {
|
||||
sourceAreas = parseSourceAreas(readFileSync(srcFile, 'utf-8'), data.source.heading);
|
||||
if (!sourceAreas || sourceAreas.length === 0) {
|
||||
problems.push(`source ${data.source.loc}: no table under ${JSON.stringify(data.source.heading)}`);
|
||||
sourceAreas = null;
|
||||
}
|
||||
}
|
||||
|
||||
const areas = Array.isArray(data.areas) ? data.areas : [];
|
||||
const byTitle = new Map(areas.map((a) => [a.title, a]));
|
||||
const counted = [];
|
||||
const behind = [];
|
||||
const perReference = Object.fromEntries(references.map((r) => [r.id, 0]));
|
||||
const contested = [];
|
||||
for (const title of sourceAreas ?? []) {
|
||||
if (!byTitle.has(title)) problems.push(`area ${title}: in the source but missing from the table`);
|
||||
}
|
||||
for (const area of areas) {
|
||||
const id = area.id ?? area.title;
|
||||
if (sourceAreas && !sourceAreas.includes(area.title)) {
|
||||
problems.push(`area ${area.title}: not in the source - not counted`);
|
||||
continue;
|
||||
}
|
||||
problems.push(...checkEvidenceList(area.own?.evidence, `${id} own`, roots));
|
||||
const behindRefs = [];
|
||||
for (const ref of references) {
|
||||
const cell = area.cells?.[ref.id];
|
||||
if (!cell) { problems.push(`${id} ${ref.id}: cell missing`); continue; }
|
||||
if (!VERDICTS.includes(cell.verdict)) problems.push(`${id} ${ref.id}: verdict ${JSON.stringify(cell.verdict)} is not one of ${VERDICTS.join('|')}`);
|
||||
if (!nonEmpty(cell.why)) problems.push(`${id} ${ref.id}: a verdict needs \`why\``);
|
||||
problems.push(...checkEvidenceList(cell.evidence, `${id} ${ref.id}`, roots));
|
||||
if (cell.contested) contested.push(`${id} (${ref.id})`);
|
||||
if (!LEVEL_OR_AHEAD.has(cell.verdict)) behindRefs.push(ref.id);
|
||||
else perReference[ref.id]++;
|
||||
}
|
||||
if (behindRefs.length) behind.push({ area: id, references: behindRefs });
|
||||
else counted.push(id);
|
||||
}
|
||||
const total = sourceAreas ? sourceAreas.length : null;
|
||||
return {
|
||||
problems,
|
||||
comparison: {
|
||||
total,
|
||||
level_or_ahead: total === null ? null : counted.length,
|
||||
behind,
|
||||
per_reference: perReference,
|
||||
contested,
|
||||
references: references.map((r) => `${r.id}${r.pin ? `@${r.pin}` : ''} ${r.name ?? ''}`.trim()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateTidy(tidy, parts, roots) {
|
||||
const problems = [];
|
||||
if (!tidy || typeof tidy !== 'object') {
|
||||
return { problems: ['tidy: missing - the tidy denominator is not measured'], tidy: { parts: parts.length, covered: null, resolved: null } };
|
||||
}
|
||||
const primitives = Array.isArray(tidy.primitives) ? tidy.primitives : [];
|
||||
const known = new Set();
|
||||
for (const p of primitives) {
|
||||
known.add(p.id);
|
||||
if (!/^https:\/\/\S+$/.test(String(p.doc ?? ''))) problems.push(`primitive ${p.id}: needs a documentation URL`);
|
||||
if (!nonEmpty(p.quote)) problems.push(`primitive ${p.id}: needs a verbatim \`quote\` from its documentation`);
|
||||
}
|
||||
const listed = Array.isArray(tidy.parts) ? tidy.parts : [];
|
||||
const tree = new Set(parts);
|
||||
const seen = new Set();
|
||||
let covered = 0;
|
||||
let resolved = 0;
|
||||
for (const part of listed) {
|
||||
const where = `part ${part.path}`;
|
||||
if (seen.has(part.path)) problems.push(`${where}: listed twice`);
|
||||
seen.add(part.path);
|
||||
if (!COVERAGE.includes(part.coverage)) { problems.push(`${where}: coverage ${JSON.stringify(part.coverage)} is not one of ${COVERAGE.join('|')}`); continue; }
|
||||
if (part.resolution !== 'fjernet' && !tree.has(part.path)) problems.push(`${where}: not in the tree and not marked fjernet`);
|
||||
if (part.coverage === 'ikke-dekket') continue;
|
||||
covered++;
|
||||
const by = Array.isArray(part.covered_by) ? part.covered_by : [];
|
||||
if (by.length === 0) problems.push(`${where}: ${part.coverage} needs \`covered_by\``);
|
||||
for (const id of by) if (!known.has(id)) problems.push(`${where}: covered by an unknown primitive ${id}`);
|
||||
if (!RESOLUTIONS.includes(part.resolution)) {
|
||||
problems.push(`${where}: resolution ${JSON.stringify(part.resolution)} is not one of ${RESOLUTIONS.join('|')}`);
|
||||
} else if (part.resolution === 'maalt-grunn') {
|
||||
if (!part.reason) problems.push(`${where}: maalt-grunn needs a \`reason\` with evidence`);
|
||||
else {
|
||||
const p = checkEvidence(part.reason, `${where} reason`, roots);
|
||||
problems.push(...p);
|
||||
if (p.length === 0) resolved++;
|
||||
}
|
||||
} else if (part.resolution === 'fjernet') {
|
||||
if (tree.has(part.path)) problems.push(`${where}: marked fjernet but still in the tree`);
|
||||
else if (!nonEmpty(part.commit)) problems.push(`${where}: fjernet needs the \`commit\` that removed it`);
|
||||
else resolved++;
|
||||
}
|
||||
}
|
||||
for (const p of parts) if (!seen.has(p)) problems.push(`part ${p}: in the tree but missing from the tidy list`);
|
||||
return { problems, tidy: { parts: parts.length, covered, resolved, primitives: primitives.length } };
|
||||
}
|
||||
|
||||
export function evaluate(data, { parts = [] } = {}) {
|
||||
const roots = {};
|
||||
for (const [id, value] of Object.entries(data?.roots ?? {})) roots[id] = resolveRoot(value);
|
||||
const cmp = evaluateComparison(data ?? {}, roots);
|
||||
const tdy = evaluateTidy(data?.tidy, parts, roots);
|
||||
const problems = [...cmp.problems, ...tdy.problems];
|
||||
const reader = data?.reader ?? {};
|
||||
const reasons = [];
|
||||
if (problems.length) reasons.push(`${problems.length} evidence problem(s)`);
|
||||
const c = cmp.comparison;
|
||||
if (c.total === null) reasons.push('the comparison denominator is not measured');
|
||||
else if (c.level_or_ahead < c.total) reasons.push(`${c.level_or_ahead} of ${c.total} areas level or ahead`);
|
||||
if (reader.kind !== 'uavhengig') reasons.push(`the verdicts are by ${reader.kind ?? 'nobody'}, not an independent reader`);
|
||||
if (tdy.tidy.covered === null) reasons.push('the tidy list is not measured');
|
||||
else if (tdy.tidy.resolved < tdy.tidy.covered) reasons.push(`${tdy.tidy.resolved} of ${tdy.tidy.covered} covered parts resolved`);
|
||||
return {
|
||||
measured: true,
|
||||
reader,
|
||||
comparison: c,
|
||||
tidy: tdy.tidy,
|
||||
problems,
|
||||
reasons,
|
||||
green: reasons.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function render(r) {
|
||||
if (!r.measured) return `Comparison gate: NOT MEASURED - ${r.reasons.join('; ')}\nRED`;
|
||||
const c = r.comparison;
|
||||
const out = [];
|
||||
out.push(`Comparison gate - areas where no reference is ahead of Voyage + the operator's way of working`);
|
||||
out.push(` references: ${c.references.join(' · ')}`);
|
||||
out.push(` level or ahead: ${c.level_or_ahead ?? 'NOT MEASURED'} of ${c.total ?? '?'}`);
|
||||
for (const b of c.behind) out.push(` behind: ${b.area} (${b.references.join(', ')})`);
|
||||
out.push(` per reference: ${Object.entries(c.per_reference).map(([id, n]) => `${id}: ${n} of ${c.total ?? '?'}`).join(' · ')}`);
|
||||
if (c.contested.length) out.push(` contested cells: ${c.contested.join(', ')}`);
|
||||
out.push(` reader: ${r.reader.kind ?? 'none'}${r.reader.who ? ` - ${r.reader.who}` : ''}${r.reader.date ? ` (${r.reader.date})` : ''}`
|
||||
+ `${r.reader.kind === 'uavhengig' ? '' : ' - green requires an independent reader'}`);
|
||||
const t = r.tidy;
|
||||
out.push(`Tidy - Voyage parts a documented Claude Code primitive covers`);
|
||||
out.push(` parts in the tree: ${t.parts} · covered: ${t.covered ?? 'NOT MEASURED'} · resolved: ${t.resolved ?? '-'} of ${t.covered ?? '-'}`);
|
||||
out.push(`Evidence problems: ${r.problems.length}`);
|
||||
for (const p of r.problems) out.push(` - ${p}`);
|
||||
out.push(r.green ? 'GREEN' : `RED - ${r.reasons.join('; ')}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
let json = false;
|
||||
let dataFile = DEFAULT_DATA;
|
||||
let partsFrom = REPO_ROOT;
|
||||
const usage = 'usage: comparison-gate.mjs [--json] [--data <file>] [--parts-from <repo>]';
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const value = argv[i + 1];
|
||||
if (a === '--json') { json = true; continue; }
|
||||
if (a === '--data' && value !== undefined) { dataFile = resolve(value); i++; continue; }
|
||||
if (a === '--parts-from' && value !== undefined) { partsFrom = resolve(value); i++; continue; }
|
||||
process.stderr.write(`comparison-gate: unknown argument ${a}\n${usage}\n`);
|
||||
return 2;
|
||||
}
|
||||
let result;
|
||||
if (!existsSync(dataFile)) {
|
||||
result = { measured: false, green: false, reasons: [`${toPosix(relative(REPO_ROOT, dataFile))} does not exist`] };
|
||||
} else {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(dataFile, 'utf-8'));
|
||||
} catch (err) {
|
||||
result = { measured: false, green: false, reasons: [`${dataFile} is not valid JSON: ${err.message}`] };
|
||||
}
|
||||
if (data) result = evaluate(data, { parts: listParts(partsFrom) });
|
||||
}
|
||||
process.stdout.write(`${json ? JSON.stringify(result, null, 2) : render(result)}\n`);
|
||||
return result.green ? 0 : 1;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
|
|
@ -1,585 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/end-state-gate.mjs
|
||||
// End-state gate — counts the distance to "Voyage is finished" as five tallies:
|
||||
//
|
||||
// defects open pipeline defects (listed in the registry, each with a runnable check)
|
||||
// experiments unresolved experiments/opt-ins (listed in the registry, each with a runnable check)
|
||||
// dormant-agents spawnable agents no command spawns (measured structurally, see spawnedNames)
|
||||
// decisions open operator decisions (counted in STATE.md by a fixed marker)
|
||||
// feat-after-freeze feat commits after the freeze tag (measured from git)
|
||||
//
|
||||
// "Finished" = all five at 0 AND the registry is intact against its frozen denominator
|
||||
// (tests/fixtures/end-state-frozen.json: an entry may close, never disappear or change its
|
||||
// check). Exit 0 = green, 1 = red (a tally > 0, a tally not measurable, or the registry
|
||||
// tampered with), 2 = usage or registry error.
|
||||
//
|
||||
// A registry entry is OPEN while ALL of its conditions hold. An entry whose check is null,
|
||||
// or whose check cannot run (missing file, empty glob, missing section), is NOT FELLABLE and
|
||||
// is counted as open — a check that cannot fire must never read as "fixed".
|
||||
//
|
||||
// Usage: node scripts/end-state-gate.mjs [--json] [--root <dir>]
|
||||
|
||||
import {
|
||||
readFileSync, writeFileSync, existsSync, readdirSync, statSync, realpathSync,
|
||||
mkdirSync, mkdtempSync, symlinkSync, rmSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, dirname, relative, sep, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
|
||||
export const REGISTRY_PATH = 'scripts/end-state-registry.json';
|
||||
export const FROZEN_PATH = 'tests/fixtures/end-state-frozen.json';
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const toPosix = (p) => p.split(sep).join('/');
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) out.push(...walk(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Supported path forms: an exact file, `dir/*.ext`, `dir/**/*.ext`.
|
||||
export function expandPath(root, pattern) {
|
||||
if (!pattern.includes('*')) {
|
||||
const abs = join(root, pattern);
|
||||
if (!existsSync(abs)) throw new Error(`path not found: ${pattern}`);
|
||||
return [abs];
|
||||
}
|
||||
const m = pattern.match(/^(.+?)\/(\*\*\/)?\*(\.[A-Za-z0-9]+)$/);
|
||||
if (!m) throw new Error(`unsupported glob: ${pattern}`);
|
||||
const [, base, deep, ext] = m;
|
||||
const absBase = join(root, base);
|
||||
if (!existsSync(absBase) || !statSync(absBase).isDirectory()) {
|
||||
throw new Error(`glob base not found: ${pattern}`);
|
||||
}
|
||||
const files = deep
|
||||
? walk(absBase)
|
||||
: readdirSync(absBase).map((f) => join(absBase, f)).filter((f) => statSync(f).isFile());
|
||||
const hits = files.filter((f) => f.endsWith(ext)).sort();
|
||||
if (hits.length === 0) throw new Error(`glob matched no files: ${pattern}`);
|
||||
return hits;
|
||||
}
|
||||
|
||||
// The text from the first line starting with `heading` up to the next `## ` heading.
|
||||
function sectionOf(text, heading, label) {
|
||||
const lines = text.split('\n');
|
||||
const start = lines.findIndex((l) => l.startsWith(heading));
|
||||
if (start === -1) throw new Error(`section "${heading}" not found in ${label}`);
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('## ')) { end = i; break; }
|
||||
}
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
|
||||
export const TEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
const escapeRegExp = (t) => String(t).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Every `export` name a module declares, so a stub can offer the same surface
|
||||
// and none of the behaviour.
|
||||
const EXPORT_NAME = /^\s*export\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
||||
|
||||
/**
|
||||
* A stub of a module: the same export names, every one of them inert.
|
||||
*
|
||||
* This is the mutant a behaviour probe must FELL. A test that still passes
|
||||
* when the module it names does nothing is not measuring that module — the
|
||||
* empty test that closed D-03 and D-04 on a tree without `lib/verification/`
|
||||
* is the limiting case (measured 2026-09-18).
|
||||
*/
|
||||
export function stubModule(source) {
|
||||
const names = new Set();
|
||||
for (const m of String(source).matchAll(EXPORT_NAME)) names.add(m[1]);
|
||||
const lines = [...names].map((n) => `export const ${n} = undefined;`);
|
||||
if (/^\s*export\s+default\b/m.test(source)) lines.push('export default undefined;');
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
// Build `dest` as a shallow copy of `root`: every entry is a SYMLINK to the
|
||||
// original, except the files in `files`, which are written for real (and whose
|
||||
// parent directories are therefore real directories too). Node resolves an
|
||||
// ESM import through the realpath, so a symlinked test file would import the
|
||||
// ORIGINAL module and never see the stub — only the files that must differ are
|
||||
// materialised. Nothing is ever written inside the measured tree.
|
||||
function overlay(root, dest, files) {
|
||||
const realDirs = new Set();
|
||||
for (const rel of files.keys()) {
|
||||
const parts = rel.split('/');
|
||||
for (let i = 0; i < parts.length - 1; i++) realDirs.add(parts.slice(0, i + 1).join('/'));
|
||||
}
|
||||
const build = (relDir) => {
|
||||
const absSrc = relDir === '' ? root : join(root, relDir);
|
||||
const absDst = relDir === '' ? dest : join(dest, relDir);
|
||||
mkdirSync(absDst, { recursive: true });
|
||||
if (!existsSync(absSrc)) return;
|
||||
for (const name of readdirSync(absSrc)) {
|
||||
const childRel = relDir === '' ? name : `${relDir}/${name}`;
|
||||
if (realDirs.has(childRel)) { build(childRel); continue; }
|
||||
if (files.has(childRel)) continue;
|
||||
symlinkSync(join(absSrc, name), join(absDst, name));
|
||||
}
|
||||
};
|
||||
build('');
|
||||
for (const [rel, body] of files) writeFileSync(join(dest, rel), body);
|
||||
}
|
||||
|
||||
// Run ONE named test out of one test file and report whether it passed.
|
||||
// `null` = the runner produced no TAP line for that name (it did not run at
|
||||
// all), which is never a pass.
|
||||
function runNamedTest(root, testRel, name) {
|
||||
// The gate itself often runs UNDER the test runner (the gate's own test
|
||||
// spawns it). `NODE_TEST_CONTEXT` inherited into this child makes it report
|
||||
// over the parent's IPC channel instead of stdout, and the TAP line would
|
||||
// never arrive — a probe that silently stops felling anything.
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('NODE_TEST_')) delete env[key];
|
||||
}
|
||||
const r = spawnSync(
|
||||
process.execPath,
|
||||
['--test', '--test-reporter=tap', `--test-name-pattern=^${escapeRegExp(name)}$`, testRel],
|
||||
{ cwd: root, encoding: 'utf8', timeout: TEST_TIMEOUT_MS, env },
|
||||
);
|
||||
if (r.error) throw new Error(`test runner did not run ${testRel}: ${r.error.message}`);
|
||||
// The file itself reports as a subtest too, so the summary counters are
|
||||
// ambiguous; the TAP line carrying the exact name is not. TAP escapes `#`
|
||||
// and `\`, so unescape before comparing.
|
||||
for (const line of String(r.stdout ?? '').split('\n')) {
|
||||
const m = line.match(/^\s*(not )?ok \d+ - (.*)$/);
|
||||
if (!m) continue;
|
||||
if (m[2].replace(/\\(.)/g, '$1').trim() !== name) continue;
|
||||
return m[1] === undefined;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A condition that RUNS a named test, reads its result, and requires that the
|
||||
* same test FELLS a stub of the module it claims to bind.
|
||||
*
|
||||
* A grep condition can only ask whether a string is present, so a "behaviour"
|
||||
* probe built from one is a phrase probe pointed at code: measured
|
||||
* 2026-09-18, a two-line file exporting `runPlanVerification` and
|
||||
* `formatCriteriaEvidence` closed D-03 and D-04 on a tree where nothing was
|
||||
* fixed. Running a NAMED test closed that hole and opened a smaller one one
|
||||
* level up: two EMPTY tests with the right names closed them again. A name is
|
||||
* always forgeable; felling a mutant is not. So the named test is run twice —
|
||||
* once on the tree, once against a stub of each module in `stubs` — and it
|
||||
* must not pass the second time.
|
||||
*
|
||||
* `expect: "passes"` holds when the named test passes; `"fails"` holds when it
|
||||
* does not. A test file that is missing, a name that matches nothing, a runner
|
||||
* that cannot start, a missing or unfelled stub target: all THROW, which the
|
||||
* caller counts as NOT FELLABLE and therefore open. A check that cannot fire
|
||||
* is never "fixed".
|
||||
*/
|
||||
export function evaluateTestCondition(root, cond) {
|
||||
if (cond.expect !== 'passes' && cond.expect !== 'fails') {
|
||||
throw new Error(`a test condition's expect must be "passes" or "fails", got ${JSON.stringify(cond.expect)}`);
|
||||
}
|
||||
if (typeof cond.name !== 'string' || cond.name.trim() === '') {
|
||||
throw new Error('a test condition must name exactly one test');
|
||||
}
|
||||
if (!Array.isArray(cond.stubs) || cond.stubs.length === 0) {
|
||||
throw new Error('a test condition must declare `stubs`: the module(s) whose stub the named test must fell');
|
||||
}
|
||||
const file = join(root, cond.test);
|
||||
if (!existsSync(file)) throw new Error(`test file not found: ${cond.test}`);
|
||||
|
||||
for (const target of cond.stubs) {
|
||||
const abs = join(root, target);
|
||||
if (!existsSync(abs)) throw new Error(`stub target not found: ${target}`);
|
||||
const sandbox = mkdtempSync(join(tmpdir(), 'end-state-mutant-'));
|
||||
try {
|
||||
overlay(root, sandbox, new Map([
|
||||
[cond.test, readFileSync(file, 'utf8')],
|
||||
[target, stubModule(readFileSync(abs, 'utf8'))],
|
||||
]));
|
||||
if (runNamedTest(sandbox, cond.test, cond.name) === true) {
|
||||
throw new Error(
|
||||
`"${cond.name}" still passes against a stub of ${target} — it binds the name, not the behaviour`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Only ever a directory this function made, under the system temp dir.
|
||||
if (sandbox.startsWith(realpathSync(tmpdir()))) rmSync(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const passed = runNamedTest(root, cond.test, cond.name);
|
||||
if (passed === null) throw new Error(`no test named "${cond.name}" ran in ${cond.test}`);
|
||||
return cond.expect === 'passes' ? passed : !passed;
|
||||
}
|
||||
|
||||
export function evaluateCondition(root, cond) {
|
||||
if (cond.test !== undefined) return evaluateTestCondition(root, cond);
|
||||
if (cond.expect !== 'match' && cond.expect !== 'no-match') {
|
||||
throw new Error(`expect must be "match" or "no-match", got ${JSON.stringify(cond.expect)}`);
|
||||
}
|
||||
if (String(cond.flags ?? '').includes('g')) throw new Error('the g flag is not allowed');
|
||||
const paths = Array.isArray(cond.path) ? cond.path : [cond.path];
|
||||
const files = paths.flatMap((p) => expandPath(root, p));
|
||||
const anyMatch = files.some((f) => {
|
||||
let text = readFileSync(f, 'utf8');
|
||||
if (cond.section) text = sectionOf(text, cond.section, toPosix(relative(root, f)));
|
||||
return new RegExp(cond.pattern, cond.flags ?? '').test(text);
|
||||
});
|
||||
return cond.expect === 'match' ? anyMatch : !anyMatch;
|
||||
}
|
||||
|
||||
export function evaluateCheck(root, check) {
|
||||
if (check === null || check === undefined) {
|
||||
return { status: 'not-fellable', detail: 'no runnable check' };
|
||||
}
|
||||
if (!Array.isArray(check) || check.length === 0) {
|
||||
return { status: 'not-fellable', detail: 'check must be a non-empty list of conditions' };
|
||||
}
|
||||
try {
|
||||
// Evaluate every condition (no short-circuit) so a broken one is always reported.
|
||||
const results = check.map((c) => evaluateCondition(root, c));
|
||||
return { status: results.every(Boolean) ? 'open' : 'closed', detail: '' };
|
||||
} catch (err) {
|
||||
return { status: 'not-fellable', detail: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- the frozen denominator -------------------------------------------------
|
||||
|
||||
// JSON with object keys sorted, so a signature does not depend on key order.
|
||||
function canonical(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value ?? null);
|
||||
}
|
||||
|
||||
export function checkSignature(check) {
|
||||
return createHash('sha256').update(canonical(check)).digest('hex');
|
||||
}
|
||||
|
||||
export function loadFrozen(root) {
|
||||
return JSON.parse(readFileSync(join(root, FROZEN_PATH), 'utf8'));
|
||||
}
|
||||
|
||||
// The judged party must not own the ledger: every frozen entry must still be present with
|
||||
// the same check, and the counting configuration must not move. New entries are allowed.
|
||||
export function verifyIntegrity(registry, frozen) {
|
||||
const violations = [];
|
||||
for (const kind of ['defects', 'experiments']) {
|
||||
const byId = new Map();
|
||||
for (const e of registry[kind] || []) {
|
||||
if (byId.has(e.id)) violations.push(`${kind}: duplicate id ${e.id}`);
|
||||
byId.set(e.id, e);
|
||||
}
|
||||
for (const [id, sig] of Object.entries(frozen[kind] || {})) {
|
||||
const e = byId.get(id);
|
||||
if (!e) {
|
||||
violations.push(`${kind}: ${id} is frozen but missing from the registry (an entry may close, never disappear)`);
|
||||
} else if (checkSignature(e.check) !== sig) {
|
||||
violations.push(`${kind}: ${id} has a changed check (signature ${checkSignature(e.check).slice(0, 12)}, frozen ${sig.slice(0, 12)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of ['agents', 'decisions', 'freeze']) {
|
||||
if (canonical(registry[key]) !== canonical(frozen[key])) {
|
||||
violations.push(`${key}: configuration differs from the frozen manifest`);
|
||||
}
|
||||
}
|
||||
const sites = [].concat(registry.agents?.spawnSites ?? []);
|
||||
if (sites.some((s) => /(^|\/)agents\//.test(String(s)))) {
|
||||
violations.push('agents: spawnSites may never include agents/ (an agent file would reference itself)');
|
||||
}
|
||||
return { ok: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
// --- tallies ----------------------------------------------------------------
|
||||
|
||||
const notMeasured = (detail) => ({ open: null, total: null, items: [], detail });
|
||||
|
||||
// What each probe kind can and cannot prove. An entry declares its kind; the
|
||||
// gate states the limit out loud, because a closed check is only ever as strong
|
||||
// as the thing it reads. Every kind a registry entry may declare belongs here —
|
||||
// the entry-shape test rejects a kind the gate cannot explain.
|
||||
// What no probe in this file can prove. /trekexecute and /trekreview are
|
||||
// markdown a model interprets, so no unit test can observe that Phase 7 or
|
||||
// Phase 4.5 was performed. The gate says this out loud rather than letting a
|
||||
// green defect row imply it.
|
||||
export const WIRING_NOTE =
|
||||
'wiring: a behaviour probe proves the capability WORKS. That /trekexecute Phase 7 and ' +
|
||||
'/trekreview Phase 4.5 actually CALL it is pinned by TEXT in tests/lib/doc-consistency.test.mjs, ' +
|
||||
'not proven deterministically - real proof is a headless plugin-eval run against a fixture plan (week 40).';
|
||||
|
||||
export const PROBE_NOTES = {
|
||||
phrase:
|
||||
'a phrase probe closes on rewording: a closed phrase probe is evidence, not proof of behaviour',
|
||||
behaviour:
|
||||
'a behaviour probe RUNS a named test AND re-runs it against a stub of the module it binds, which it must fell: neither a stub that exports the right symbols nor an empty test with the right name closes it; it proves the capability WORKS, not that a prose phase calls it',
|
||||
byte:
|
||||
"a byte probe reads the file's bytes, so it closes only on a real change to them",
|
||||
};
|
||||
|
||||
function tallyEntries(root, entries) {
|
||||
const items = entries.map((e) => ({ id: e.id, summary: e.summary, probe: e.probe, ...evaluateCheck(root, e.check) }));
|
||||
const kinds = Object.keys(PROBE_NOTES).filter((k) => items.some((i) => i.probe === k));
|
||||
return {
|
||||
open: items.filter((i) => i.status !== 'closed').length,
|
||||
total: items.length,
|
||||
items,
|
||||
detail: kinds.map((k) => PROBE_NOTES[k]).join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
// Lines that can carry an instruction: HTML comments and fenced code are blanked (line numbers
|
||||
// are kept so "the next line" still means the next line). Block quotes need no stripping: every
|
||||
// spawn form below is anchored at the start of the line, and a quoted line starts with `>`.
|
||||
function instructionLines(markdown) {
|
||||
const uncommented = markdown.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ''));
|
||||
const out = [];
|
||||
let fenced = false;
|
||||
for (const line of uncommented.split('\n')) {
|
||||
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; out.push(''); continue; }
|
||||
out.push(fenced ? '' : line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const NAME = '[a-z0-9][a-z0-9-]*';
|
||||
const ROSTER_ROW = new RegExp(`^\\s*\\|\\s*\`(${NAME})\`\\s*\\|`);
|
||||
const LAUNCH_LINE = /^\s*(?:[-*]\s+|\d+\.\s+)?(?:\*\*)?(?:launch|spawn)\b(?:\*\*)?(.*)$/i;
|
||||
const LAUNCH_NAME = new RegExp(`\`(?:voyage:)?(${NAME})\`|\\*\\*(${NAME})\\*\\*|voyage:(${NAME})`, 'g');
|
||||
const AGENT_BLOCK = new RegExp(`^\\s*\\*\\*(${NAME})\\*\\*`);
|
||||
|
||||
// The agent names a command markdown file actually spawns. A spawn instruction is one of:
|
||||
// 1. a row of a table whose header's first cell is `Agent` (a roster the step launches);
|
||||
// 2. an imperative line that STARTS with Launch/Spawn and names the agent as `name`,
|
||||
// **name** or voyage:name;
|
||||
// 3. a line starting with **name** whose next non-empty line starts with `Prompt:`.
|
||||
// Prose, negations, HTML comments, fenced code and block quotes never count.
|
||||
export function spawnedNames(markdown) {
|
||||
const lines = instructionLines(markdown);
|
||||
const names = new Set();
|
||||
let inTable = false;
|
||||
let roster = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^\s*\|/.test(line)) {
|
||||
if (!inTable) {
|
||||
inTable = true;
|
||||
roster = /^\s*\|\s*Agent\s*\|/.test(line);
|
||||
continue;
|
||||
}
|
||||
const row = roster && line.match(ROSTER_ROW);
|
||||
if (row) names.add(row[1]);
|
||||
continue;
|
||||
}
|
||||
inTable = false;
|
||||
const launch = line.match(LAUNCH_LINE);
|
||||
if (launch) {
|
||||
for (const m of launch[1].matchAll(LAUNCH_NAME)) names.add(m[1] || m[2] || m[3]);
|
||||
continue;
|
||||
}
|
||||
const block = line.match(AGENT_BLOCK);
|
||||
if (block) {
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() === '') j++;
|
||||
if (j < lines.length && /^\s*Prompt:/.test(lines[j])) names.add(block[1]);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function tallyDormantAgents(root, cfg) {
|
||||
try {
|
||||
const agentFiles = expandPath(root, `${cfg.dir}/*.md`);
|
||||
const spawned = new Set();
|
||||
for (const f of expandPath(root, cfg.spawnSites)) {
|
||||
for (const n of spawnedNames(readFileSync(f, 'utf8'))) spawned.add(n);
|
||||
}
|
||||
const items = [];
|
||||
for (const file of agentFiles) {
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const fm = text.startsWith('---') ? text.split('\n---')[0] : '';
|
||||
if (cfg.referenceMarker && fm.includes(cfg.referenceMarker)) continue;
|
||||
const name = (fm.match(/^name:\s*(\S+)\s*$/m) || [])[1] || basename(file, '.md');
|
||||
const isSpawned = spawned.has(name);
|
||||
items.push({
|
||||
id: name,
|
||||
summary: toPosix(relative(root, file)),
|
||||
status: isSpawned ? 'closed' : 'open',
|
||||
detail: isSpawned ? '' : `no spawn instruction in ${cfg.spawnSites}`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
open: items.filter((i) => i.status === 'open').length,
|
||||
total: items.length,
|
||||
items,
|
||||
detail: 'spawnable = agent files without the reference-document marker; spawned = named in a spawn instruction (an `Agent` table row, a line starting with Launch/Spawn, or a **name** block followed by Prompt:); prose, comments, fences and quotes do not count',
|
||||
};
|
||||
} catch (err) {
|
||||
return notMeasured(`not measurable: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tallyDecisions(root, cfg) {
|
||||
const abs = join(root, cfg.file);
|
||||
if (!existsSync(abs)) {
|
||||
return notMeasured(`not measurable: ${cfg.file} not found (it is local-only)`);
|
||||
}
|
||||
try {
|
||||
const section = sectionOf(readFileSync(abs, 'utf8'), cfg.section, cfg.file);
|
||||
const lines = section.split('\n');
|
||||
const openRe = new RegExp(cfg.open);
|
||||
const closedRe = new RegExp(cfg.closed);
|
||||
const open = lines.filter((l) => openRe.test(l));
|
||||
const closed = lines.filter((l) => closedRe.test(l));
|
||||
// Format drift guard: a list item that carries neither marker would silently read as "0 open".
|
||||
const unmarked = lines.filter((l) => /^\s*(\d+\.|[-*]) /.test(l) && !openRe.test(l) && !closedRe.test(l));
|
||||
if (unmarked.length > 0) {
|
||||
return notMeasured(`not measurable: ${unmarked.length} unmarked list item(s) in ${cfg.section} — mark each with the open/decided marker`);
|
||||
}
|
||||
return {
|
||||
open: open.length,
|
||||
total: open.length + closed.length,
|
||||
items: open.map((l, i) => ({ id: `#${i + 1}`, summary: l.replace(openRe, '').slice(0, 100), status: 'open', detail: '' })),
|
||||
detail: `marker: open = /${cfg.open}/, decided = /${cfg.closed}/; this counts ticks — a ticked line is not verified to be an actual decision`,
|
||||
};
|
||||
} catch (err) {
|
||||
return notMeasured(`not measurable: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tallyFeatAfterFreeze(root, cfg) {
|
||||
const git = (...args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
||||
const top = git('rev-parse', '--show-toplevel');
|
||||
if (top.status !== 0) return notMeasured(`not measured: ${toPosix(root)} is not a git work tree`);
|
||||
let atTop = false;
|
||||
try { atTop = realpathSync(top.stdout.trim()) === realpathSync(root); } catch { atTop = false; }
|
||||
if (!atTop) return notMeasured('not measured: the gate root is not the top of its git work tree');
|
||||
const ref = `refs/tags/${cfg.tag}`;
|
||||
if (git('rev-parse', '-q', '--verify', ref).status !== 0) {
|
||||
return notMeasured(`not measured: no freeze tag "${cfg.tag}" yet — open until the freeze is declared`);
|
||||
}
|
||||
const log = git('log', '--format=%s', `${ref}..HEAD`);
|
||||
if (log.status !== 0) return notMeasured(`not measured: git log failed: ${log.stderr.trim()}`);
|
||||
const subjects = log.stdout.split('\n').filter(Boolean);
|
||||
const featRe = new RegExp(cfg.featPattern);
|
||||
const feats = subjects.filter((s) => featRe.test(s));
|
||||
return {
|
||||
open: feats.length,
|
||||
total: subjects.length,
|
||||
items: feats.map((s, i) => ({ id: `#${i + 1}`, summary: s, status: 'open', detail: '' })),
|
||||
detail: `commits after ${cfg.tag} whose subject matches /${cfg.featPattern}/`,
|
||||
};
|
||||
}
|
||||
|
||||
export function measure(root, registry, frozen) {
|
||||
const d = registry.decisions;
|
||||
const f = registry.freeze;
|
||||
const rows = [
|
||||
{ id: 'defects', label: 'open pipeline defects', source: `${REGISTRY_PATH}#defects`, ...tallyEntries(root, registry.defects) },
|
||||
{ id: 'experiments', label: 'unresolved experiments/opt-ins', source: `${REGISTRY_PATH}#experiments`, ...tallyEntries(root, registry.experiments) },
|
||||
{ id: 'dormant-agents', label: 'dormant agents', source: `${registry.agents.dir}/*.md vs ${registry.agents.spawnSites}`, ...tallyDormantAgents(root, registry.agents) },
|
||||
{ id: 'decisions', label: 'open operator decisions', source: `${d.file} ${d.section.replace(/^#+\s*/, '§ ')}`, ...tallyDecisions(root, d) },
|
||||
{ id: 'feat-after-freeze', label: 'feat commits after the freeze tag', source: `git log ${f.tag}..HEAD`, ...tallyFeatAfterFreeze(root, f) },
|
||||
].map((r) => ({ ...r, target: 0 }));
|
||||
let integrity;
|
||||
try {
|
||||
integrity = { ...verifyIntegrity(registry, frozen ?? loadFrozen(root)), detail: '' };
|
||||
} catch (err) {
|
||||
integrity = { ok: null, violations: [], detail: `not measurable: cannot read ${FROZEN_PATH}: ${err.message}` };
|
||||
}
|
||||
return { green: rows.every((r) => r.open === 0) && integrity.ok === true, rows, integrity, wiring: WIRING_NOTE };
|
||||
}
|
||||
|
||||
export function loadRegistry(root) {
|
||||
const abs = join(root, REGISTRY_PATH);
|
||||
let reg;
|
||||
try {
|
||||
reg = JSON.parse(readFileSync(abs, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`cannot read ${REGISTRY_PATH}: ${err.message}`);
|
||||
}
|
||||
for (const key of ['defects', 'experiments']) {
|
||||
if (!Array.isArray(reg[key])) throw new Error(`${REGISTRY_PATH}: "${key}" must be a list`);
|
||||
}
|
||||
for (const key of ['agents', 'decisions', 'freeze']) {
|
||||
if (!reg[key] || typeof reg[key] !== 'object') throw new Error(`${REGISTRY_PATH}: "${key}" must be an object`);
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
export function render(result) {
|
||||
const zero = result.rows.filter((r) => r.open === 0).length;
|
||||
const out = [];
|
||||
out.push(`Voyage end-state gate: ${result.green ? 'GREEN' : 'RED'} (${zero} of ${result.rows.length} tallies at 0)`);
|
||||
const integrity = result.integrity;
|
||||
if (integrity?.ok === true) {
|
||||
out.push(`registry integrity: intact against ${FROZEN_PATH}`);
|
||||
} else if (integrity?.ok === false) {
|
||||
out.push('registry integrity: VIOLATED — the gate cannot be green while the ledger differs from its frozen denominator');
|
||||
for (const v of integrity.violations) out.push(` - ${v}`);
|
||||
} else if (integrity) {
|
||||
out.push(`registry integrity: n/a — ${integrity.detail}`);
|
||||
}
|
||||
out.push(result.wiring ?? WIRING_NOTE);
|
||||
out.push('');
|
||||
out.push('| tally | open | of | target | source |');
|
||||
out.push('|---|---|---|---|---|');
|
||||
for (const r of result.rows) {
|
||||
const open = r.open === null ? 'n/a' : String(r.open);
|
||||
const total = r.total === null ? 'n/a' : String(r.total);
|
||||
out.push(`| ${r.id} | ${open} | ${total} | ${r.target} | ${r.source} |`);
|
||||
}
|
||||
for (const r of result.rows) {
|
||||
out.push('');
|
||||
out.push(`${r.id} — ${r.label}${r.detail ? ` (${r.detail})` : ''}`);
|
||||
for (const i of r.items) {
|
||||
if (i.status === 'closed') continue;
|
||||
let tag = i.status === 'not-fellable' ? 'NOT FELLABLE, counted open' : 'open';
|
||||
if (i.probe) tag += ` · ${i.probe} probe`;
|
||||
out.push(` [${tag}] ${i.id}: ${i.summary}${i.detail ? ` — ${i.detail}` : ''}`);
|
||||
}
|
||||
const closed = r.items.filter((i) => i.status === 'closed' && i.probe);
|
||||
if (closed.length > 0) {
|
||||
out.push(` closed: ${closed.map((i) => `${i.id} (${i.probe} probe)`).join(', ')}`);
|
||||
}
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
let json = false;
|
||||
let root = REPO_ROOT;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--json') json = true;
|
||||
else if (argv[i] === '--root' && argv[i + 1]) root = resolve(argv[++i]);
|
||||
else {
|
||||
process.stderr.write(`end-state-gate: unknown argument ${argv[i]}\nusage: end-state-gate.mjs [--json] [--root <dir>]\n`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
let registry;
|
||||
try {
|
||||
registry = loadRegistry(root);
|
||||
} catch (err) {
|
||||
process.stderr.write(`end-state-gate: ${err.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
const result = measure(root, registry);
|
||||
process.stdout.write((json ? JSON.stringify(result, null, 2) : render(result)) + '\n');
|
||||
return result.green ? 0 : 1;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
{
|
||||
"defects": [
|
||||
{
|
||||
"id": "D-01",
|
||||
"summary": "commands/trekplan.md still references TeamCreate/TeamDelete (allowed-tools and the execute-with-team path); both tools were removed in Claude Code 2.1.178, so that path always falls back to sequential",
|
||||
"closesWhen": "trekplan.md no longer names TeamCreate or TeamDelete",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": "commands/trekplan.md",
|
||||
"pattern": "\\bTeam(Create|Delete)\\b",
|
||||
"expect": "match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "D-02",
|
||||
"summary": "commands/trekresearch.md tells the swarm engine to run the `### Bridge agent` block, but that heading was removed together with gemini-bridge",
|
||||
"closesWhen": "the dangling reference is gone (or the heading exists again)",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": "commands/trekresearch.md",
|
||||
"pattern": "`### Bridge agent`",
|
||||
"expect": "match"
|
||||
},
|
||||
{
|
||||
"path": "commands/trekresearch.md",
|
||||
"pattern": "^### Bridge agent",
|
||||
"flags": "m",
|
||||
"expect": "no-match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "D-03",
|
||||
"summary": "commands/trekexecute.md never runs a trekplan's `## Verification` (where the brief's success criteria land) on the single-session path: Phase 7 says 'Skip for trekplans', and only the multi-session wave path runs master verification",
|
||||
"closesWhen": "a named test PROVES the single-session path fells a run on a criterion that does not hold (behaviour probe: the gate RUNS tests/lib/criteria-runner.test.mjs, so a stub that only exports the symbol does not close it, and the named test must itself FELL a stub of lib/verification/criteria-runner.mjs, so an EMPTY test with the right name does not close it either; that Phase 7 CALLS it is pinned by tests/lib/doc-consistency.test.mjs, not by this probe)",
|
||||
"probe": "behaviour",
|
||||
"check": [
|
||||
{
|
||||
"test": "tests/lib/criteria-runner.test.mjs",
|
||||
"name": "runPlanVerification: a plan whose success criterion FAILS fells the run",
|
||||
"expect": "fails",
|
||||
"stubs": [
|
||||
"lib/verification/criteria-runner.mjs"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "D-04",
|
||||
"summary": "agents/brief-conformance-reviewer.md must judge whether a success criterion's verification command 'exists and passes', but its tools are Read/Glob/Grep, so it cannot run anything",
|
||||
"closesWhen": "a named test PROVES the evidence block the conformance reviewer judges is BUILT IN CODE from real exit codes (behaviour probe: the gate RUNS tests/lib/criteria-runner.test.mjs, so a stub that only exports the symbol does not close it, and the named test must itself FELL a stub of lib/verification/criteria-runner.mjs, so an EMPTY test with the right name does not close it either; that Phase 4.5 CALLS it is pinned by tests/lib/doc-consistency.test.mjs, not by this probe)",
|
||||
"probe": "behaviour",
|
||||
"check": [
|
||||
{
|
||||
"test": "tests/lib/criteria-runner.test.mjs",
|
||||
"name": "formatCriteriaEvidence: one row per criterion, with command and exit code",
|
||||
"expect": "fails",
|
||||
"stubs": [
|
||||
"lib/verification/criteria-runner.mjs"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "D-05",
|
||||
"summary": "commands/trekplan.md allowed-tools lists TaskCreate/TaskUpdate, which Claude Code 2.1.233 no longer offers on Opus 4.8 / Sonnet 5 / Fable 5 and newer by default (runtime effect not measured)",
|
||||
"closesWhen": "trekplan.md allowed-tools no longer lists TaskCreate or TaskUpdate",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": "commands/trekplan.md",
|
||||
"pattern": "^allowed-tools:.*\\bTask(Create|Update)\\b",
|
||||
"flags": "m",
|
||||
"expect": "match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "D-06",
|
||||
"summary": "lib/review/gold-scorer.mjs contains a literal NUL byte, so git treats the file as binary and hides it from diffs and --numstat",
|
||||
"closesWhen": "the file contains no NUL byte (write the separator as an escape sequence)",
|
||||
"probe": "byte",
|
||||
"check": [
|
||||
{
|
||||
"path": "lib/review/gold-scorer.mjs",
|
||||
"pattern": "\\u0000",
|
||||
"expect": "match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "D-07",
|
||||
"summary": "CLAUDE.md, README.md and commands/trekresearch.md claim the /trek* orchestrators run on opus (README even ships a sed recipe for `model: opus` lines no command has), but since v5.9.0 no command pins model: and the orchestrator follows the session model",
|
||||
"closesWhen": "none of the three files claims an opus orchestrator any more, or the commands pin opus again",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": [
|
||||
"CLAUDE.md",
|
||||
"README.md",
|
||||
"commands/trekresearch.md"
|
||||
],
|
||||
"pattern": "(^\\| `/trek[a-z]+` \\|.*\\| opus \\|\\s*$)|(\\^model: opus\\$)|(default for `/trekbrief`[\\s\\S]{0,80}?is `opus`)|(orchestrator runs on Opus)",
|
||||
"flags": "m",
|
||||
"expect": "match"
|
||||
},
|
||||
{
|
||||
"path": "commands/*.md",
|
||||
"pattern": "^model:",
|
||||
"flags": "m",
|
||||
"expect": "no-match"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"experiments": [
|
||||
{
|
||||
"id": "E-01",
|
||||
"summary": "STORM dimension discovery + bounded research loop ships default-off behind VOYAGE_STORM_ENABLED; adoption is gated on a pre-registered measurement that has not run",
|
||||
"closesWhen": "the env gate is gone from the pipeline surface: adopted as default, or the loop is removed",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": [
|
||||
"commands/*.md",
|
||||
"agents/*.md",
|
||||
"lib/**/*.mjs",
|
||||
"hooks/**/*.mjs"
|
||||
],
|
||||
"pattern": "VOYAGE_STORM_ENABLED",
|
||||
"expect": "match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E-02",
|
||||
"summary": "`/trekresearch --engine deep-research` delegates to Claude Code's /deep-research, which is manual-only since 2.1.218, so the opt-in always falls back to the swarm",
|
||||
"closesWhen": "the --engine opt-in is removed from trekresearch.md",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": "commands/trekresearch.md",
|
||||
"pattern": "--engine\\b",
|
||||
"expect": "match"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "E-03",
|
||||
"summary": "the delegated-orchestration head-to-head (T1 §5) is designed but has no recorded decision",
|
||||
"closesWhen": "T1 §5 carries a STATUS block (run, or declined)",
|
||||
"probe": "phrase",
|
||||
"check": [
|
||||
{
|
||||
"path": "docs/T1-cc26-delegated-orchestration.md",
|
||||
"section": "## 5.",
|
||||
"pattern": "^> \\*\\*STATUS:",
|
||||
"flags": "m",
|
||||
"expect": "no-match"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"dir": "agents",
|
||||
"spawnSites": "commands/*.md",
|
||||
"referenceMarker": "Reference document, not a spawnable capability"
|
||||
},
|
||||
"decisions": {
|
||||
"file": "STATE.md",
|
||||
"section": "## Åpne operatørbeslutninger",
|
||||
"open": "^- \\[ \\] ",
|
||||
"closed": "^- \\[x\\] "
|
||||
},
|
||||
"freeze": {
|
||||
"tag": "end-state-freeze",
|
||||
"featPattern": "^feat(\\(|!|:)"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/gen-expected-prom.mjs
|
||||
// Regenerate tests/fixtures/expected.prom snapshot from tests/fixtures/stats-sample.jsonl.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom
|
||||
//
|
||||
// When the snapshot is stale (e.g. after intentional format change or new
|
||||
// stats-sample row), regenerate via the command above and inspect the diff.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { transformToPrometheus } from '../lib/exporters/textfile-format.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SAMPLE_PATH = join(__dirname, '..', 'tests', 'fixtures', 'stats-sample.jsonl');
|
||||
|
||||
const text = readFileSync(SAMPLE_PATH, 'utf-8');
|
||||
const records = text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
process.stdout.write(transformToPrometheus(records));
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/storm-measure.mjs
|
||||
// Step 11 — the STORM adoption gate: deterministic Δ accounting over
|
||||
// ${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl.
|
||||
//
|
||||
// What this decides: whether the bounded Phase 5 conversation loop buys enough
|
||||
// extra source/coverage breadth to justify flipping VOYAGE_STORM_ENABLED on by
|
||||
// default in lib/util/research-loop-cap.mjs. Nothing else. The thresholds live
|
||||
// in docs/storm-measurement.md and were committed BEFORE any measurement run —
|
||||
// that pre-registration is the whole point, so this script never invents them.
|
||||
//
|
||||
// What this does NOT measure: outline quality, answer correctness, or operator
|
||||
// satisfaction. It measures breadth (distinct sources, dimensions covered).
|
||||
// A breadth win is necessary for adoption, not sufficient on its own.
|
||||
//
|
||||
// Honesty properties, both load-bearing:
|
||||
// - Runs with empty_turns > 0 are EXCLUDED from the gain and REPORTED. An
|
||||
// empty turn means the loop spent budget and returned nothing; leaving those
|
||||
// in decides adoption on a broken denominator.
|
||||
// - A stats file carrying no `effort` field is a loud error. The silent
|
||||
// failure this prevents is an empty treatment group reading as "no gain",
|
||||
// which would decline the mechanism for a schema reason.
|
||||
//
|
||||
// Zero deps. Node stdlib only.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
|
||||
// Pre-registered in docs/storm-measurement.md. Do not tune to fit a result.
|
||||
export const ADOPT_THRESHOLD = 0.30;
|
||||
export const DECLINE_THRESHOLD = 0.15;
|
||||
|
||||
const STATS_FILENAME = 'trekresearch-stats.jsonl';
|
||||
|
||||
// ---- pure core (unit-tested) -------------------------------------------------
|
||||
|
||||
/** @param {number[]} xs @returns {number|null} null for an empty list — 0 would read as a measurement. */
|
||||
export function median(xs) {
|
||||
if (!Array.isArray(xs) || xs.length === 0) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const mid = s.length >> 1;
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a trekresearch-stats.jsonl body into effort-carrying records.
|
||||
*
|
||||
* Rows predating Step 9 have no `effort` field; they are dropped and counted as
|
||||
* `legacy` rather than silently pooled into the control arm. A file where NO row
|
||||
* carries `effort` throws — see the header note on silent failure.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{records: object[], malformed: number, legacy: number}}
|
||||
*/
|
||||
export function parseStats(text) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const records = [];
|
||||
let malformed = 0;
|
||||
let legacy = 0;
|
||||
let parsed = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
parsed++;
|
||||
if (typeof rec?.effort !== 'string' || rec.effort.length === 0) {
|
||||
legacy++;
|
||||
continue;
|
||||
}
|
||||
records.push(rec);
|
||||
}
|
||||
|
||||
if (parsed === 0) {
|
||||
throw new Error(`storm-measure: no records in stats file (${malformed} malformed line(s)).`);
|
||||
}
|
||||
if (records.length === 0) {
|
||||
throw new Error(
|
||||
`storm-measure: no records carry an \`effort\` field (${legacy} legacy row(s)). ` +
|
||||
`The gate groups on \`effort\`; without it there is no treatment arm to measure. ` +
|
||||
`Re-run the measurement set on a build that emits the Step 9 fields.`,
|
||||
);
|
||||
}
|
||||
return { records, malformed, legacy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split off the runs that must not count toward a gain.
|
||||
*
|
||||
* A value that cannot be READ as a turn count is excluded, not treated as zero.
|
||||
* `Number('many')` is NaN, and testing `Number.isFinite(empty) && empty > 0`
|
||||
* sent NaN down the eligible branch — so a garbage field silently re-entered the
|
||||
* denominator, in the direction that flatters adoption: the run whose bookkeeping
|
||||
* broke is the run whose numbers deserve the least trust. Absent and null stay
|
||||
* eligible via `?? 0`, because a field that was never written is a genuine zero
|
||||
* on any run where the loop did not arm.
|
||||
*
|
||||
* @param {object[]} records
|
||||
* @returns {{eligible: object[], excluded: number}}
|
||||
*/
|
||||
export function partitionEligible(records) {
|
||||
const eligible = [];
|
||||
let excluded = 0;
|
||||
for (const r of records) {
|
||||
const empty = Number(r.empty_turns ?? 0);
|
||||
if (!Number.isFinite(empty) || empty > 0) excluded++;
|
||||
else eligible.push(r);
|
||||
}
|
||||
return { eligible, excluded };
|
||||
}
|
||||
|
||||
function numbers(records, pick) {
|
||||
return records.map(pick).filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
/** Relative gain (treatment − control)/control. null when control is absent or zero. */
|
||||
function relGain(control, treatment) {
|
||||
if (control === null || treatment === null || control === 0) return null;
|
||||
return (treatment - control) / control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full measurement over a parsed record set.
|
||||
*
|
||||
* Treatment arm = `effort: high` (the only effort at which the loop runs).
|
||||
* Control arm = every other effort.
|
||||
*
|
||||
* - sources: between-arm median gain in `unique_sources`.
|
||||
* - dimensions: within-run median gain of (dimensions − dimensions_baseline)
|
||||
* / dimensions_baseline across the treatment arm. It is a
|
||||
* within-run delta by construction, so it needs no control arm —
|
||||
* the control arm's is 0, the loop being inert there.
|
||||
*
|
||||
* @param {object[]} records
|
||||
*/
|
||||
export function measure(records) {
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
const treatment = eligible.filter((r) => r.effort === 'high');
|
||||
const control = eligible.filter((r) => r.effort !== 'high');
|
||||
|
||||
const srcControl = median(numbers(control, (r) => Number(r.unique_sources)));
|
||||
const srcTreatment = median(numbers(treatment, (r) => Number(r.unique_sources)));
|
||||
const sourcesGain = relGain(srcControl, srcTreatment);
|
||||
|
||||
const dimDeltas = treatment
|
||||
.map((r) => ({ d: Number(r.dimensions), b: Number(r.dimensions_baseline) }))
|
||||
.filter(({ d, b }) => Number.isFinite(d) && Number.isFinite(b) && b > 0)
|
||||
.map(({ d, b }) => (d - b) / b);
|
||||
const dimensionsGain = median(dimDeltas);
|
||||
|
||||
return {
|
||||
control: { n: control.length, sources: srcControl },
|
||||
treatment: { n: treatment.length, sources: srcTreatment },
|
||||
sources: { control: srcControl, treatment: srcTreatment, gain: sourcesGain },
|
||||
dimensions: { gain: dimensionsGain, n: dimDeltas.length },
|
||||
excluded,
|
||||
verdict: decideVerdict(sourcesGain, dimensionsGain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-registered mapping, verbatim from the brief: "median forbedring >= 30 %
|
||||
* på (a) eller (b) → adopt. < 15 % → decline." OR on both sides, adopt
|
||||
* evaluated first — so a strong win on one axis is an adopt even when the
|
||||
* other axis sits under the decline bar. A stricter AND rule may well be the
|
||||
* better decision procedure, but changing it here is changing the
|
||||
* pre-registration after the fact, which is the one thing the constraint
|
||||
* exists to prevent.
|
||||
*
|
||||
* @param {number|null} sourcesGain
|
||||
* @param {number|null} dimensionsGain
|
||||
* @returns {'adopt'|'decline'|'inconclusive'|'insufficient-data'}
|
||||
*/
|
||||
export function decideVerdict(sourcesGain, dimensionsGain) {
|
||||
if (sourcesGain === null || sourcesGain === undefined) return 'insufficient-data';
|
||||
if (dimensionsGain === null || dimensionsGain === undefined) return 'insufficient-data';
|
||||
if (sourcesGain >= ADOPT_THRESHOLD || dimensionsGain >= ADOPT_THRESHOLD) return 'adopt';
|
||||
if (sourcesGain < DECLINE_THRESHOLD || dimensionsGain < DECLINE_THRESHOLD) return 'decline';
|
||||
return 'inconclusive';
|
||||
}
|
||||
|
||||
/**
|
||||
* SC activation check, BOTH halves.
|
||||
*
|
||||
* The SC asks two things of an `effort: high` run: that it discovered at least
|
||||
* one dimension, AND that the dimension list in the output brief is a TRUE
|
||||
* SUPERSET of the interview-derived ones. This function used to check only
|
||||
* `dimensions - dimensions_baseline >= 1`, which is a count delta and says
|
||||
* nothing about membership — a run that dropped two interview dimensions and
|
||||
* added three discovered ones passed while violating the second half.
|
||||
* Supersetness was asserted only by Phase 4.5's prose contract that discovery
|
||||
* APPENDS; nothing read it.
|
||||
*
|
||||
* The record cannot carry the dimension names: names are free prose, and
|
||||
* lib/exporters/field-allowlist.mjs denies prose by omission. So the run attests
|
||||
* membership with `dimensions_baseline_preserved`, a low-cardinality boolean set
|
||||
* in Phase 4.5, and this gate refuses to call activation OK without it. An
|
||||
* ABSENT attestation is not an attestation — legacy rows fail here rather than
|
||||
* passing on the old count-only rule.
|
||||
*
|
||||
* @param {object[]} records
|
||||
*/
|
||||
export function activationCheck(records) {
|
||||
const high = records.filter((r) => r.effort === 'high');
|
||||
if (high.length === 0) {
|
||||
return { ok: false, reason: 'no `effort: high` run found in stats', discovered_dimensions: null };
|
||||
}
|
||||
const last = high[high.length - 1];
|
||||
const d = Number(last.dimensions);
|
||||
const b = Number(last.dimensions_baseline);
|
||||
if (!Number.isFinite(d) || !Number.isFinite(b)) {
|
||||
return { ok: false, reason: 'latest high run lacks dimensions/dimensions_baseline', discovered_dimensions: null };
|
||||
}
|
||||
const discovered = d - b;
|
||||
const preserved = last.dimensions_baseline_preserved;
|
||||
|
||||
const base = {
|
||||
ts: last.ts ?? null,
|
||||
dimensions: d,
|
||||
dimensions_baseline: b,
|
||||
discovered_dimensions: discovered,
|
||||
dimensions_baseline_preserved: preserved ?? null,
|
||||
conv_turns: Number(last.conv_turns ?? 0),
|
||||
empty_turns: Number(last.empty_turns ?? 0),
|
||||
};
|
||||
|
||||
if (typeof preserved !== 'boolean') {
|
||||
return {
|
||||
...base,
|
||||
ok: false,
|
||||
reason:
|
||||
'latest high run does not attest `dimensions_baseline_preserved`; the SC needs a true ' +
|
||||
'superset of the interview dimensions, and a count delta cannot show membership',
|
||||
};
|
||||
}
|
||||
if (preserved === false) {
|
||||
return {
|
||||
...base,
|
||||
ok: false,
|
||||
reason:
|
||||
`latest high run discovered ${discovered} dimension(s) but did NOT preserve its interview ` +
|
||||
'baseline, so the final list is not a superset of it',
|
||||
};
|
||||
}
|
||||
if (discovered < 1) {
|
||||
return { ...base, ok: false, reason: 'latest high run discovered no dimensions beyond its baseline' };
|
||||
}
|
||||
return { ...base, ok: true };
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function pct(x) {
|
||||
return x === null ? 'n/a' : `${(x * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function defaultStatsPath(env = process.env) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
return dir ? join(dir, STATS_FILENAME) : null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { stats: null, json: false, activation: false, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--stats') o.stats = argv[++i];
|
||||
else if (a === '--json') o.json = true;
|
||||
else if (a === '--activation-check') o.activation = true;
|
||||
else if (a === '--help' || a === '-h') o.help = true;
|
||||
else { process.stderr.write(`Unknown argument: ${a}\n`); process.exit(2); }
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function mainCli() {
|
||||
const o = parseArgs(process.argv.slice(2));
|
||||
if (o.help) {
|
||||
process.stdout.write(
|
||||
'Usage: storm-measure.mjs [--stats FILE] [--activation-check] [--json]\n' +
|
||||
' Default --stats: ${CLAUDE_PLUGIN_DATA}/' + STATS_FILENAME + '\n' +
|
||||
' Thresholds (pre-registered, docs/storm-measurement.md): ' +
|
||||
`adopt >= ${ADOPT_THRESHOLD * 100}%, decline < ${DECLINE_THRESHOLD * 100}%\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const statsPath = o.stats || defaultStatsPath();
|
||||
if (!statsPath) {
|
||||
process.stderr.write('storm-measure: CLAUDE_PLUGIN_DATA is not set and no --stats FILE was given.\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!existsSync(statsPath)) {
|
||||
process.stderr.write(`storm-measure: stats file not found: ${statsPath}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseStats(readFileSync(statsPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
process.stderr.write(`${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (o.activation) {
|
||||
const res = activationCheck(parsed.records);
|
||||
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
||||
process.exit(res.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
const m = measure(parsed.records);
|
||||
|
||||
if (o.json) {
|
||||
process.stdout.write(JSON.stringify({ statsPath, ...m, malformed: parsed.malformed, legacy: parsed.legacy }, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const L = [];
|
||||
L.push(`STORM adoption gate — ${statsPath}`);
|
||||
L.push(` control (effort != high): n=${m.control.n} median unique_sources=${m.control.sources ?? 'n/a'}`);
|
||||
L.push(` treatment (effort = high): n=${m.treatment.n} median unique_sources=${m.treatment.sources ?? 'n/a'}`);
|
||||
L.push(` excluded (empty_turns > 0): ${m.excluded}`);
|
||||
if (parsed.legacy) L.push(` legacy rows without \`effort\`: ${parsed.legacy}`);
|
||||
if (parsed.malformed) L.push(` malformed lines: ${parsed.malformed}`);
|
||||
L.push('');
|
||||
L.push(` median gain, unique_sources: ${pct(m.sources.gain)}`);
|
||||
L.push(` median gain, dimensions over baseline: ${pct(m.dimensions.gain)} (n=${m.dimensions.n})`);
|
||||
L.push('');
|
||||
L.push(` thresholds: adopt >= ${pct(ADOPT_THRESHOLD)} on EITHER · decline < ${pct(DECLINE_THRESHOLD)} on EITHER · adopt wins ties`);
|
||||
L.push(` VERDICT: ${m.verdict}`);
|
||||
process.stdout.write(L.join('\n') + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
mainCli();
|
||||
}
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/synthesis-measure.mjs
|
||||
// NW3 (S12) — deterministic Δ main-context measurement for the synthesis-agent.
|
||||
//
|
||||
// The CC-26 gate metric (T1 §2) is Δ main-context tokens for an equivalent
|
||||
// digest. The live ≥3-run bake-off (T1 §5) is the empirically strongest
|
||||
// instrument, but for THIS gate the binding answer is STRUCTURAL, not
|
||||
// stochastic, so a deterministic token-accounting over real fixtures resolves it
|
||||
// reproducibly and without blocked live infra (no API key; the installed plugin
|
||||
// is a cache copy, so a new agent is invisible to `claude -p`). It models two
|
||||
// framings:
|
||||
//
|
||||
// FAITHFUL (current flow): trekplan Phase 5 runs the swarm FOREGROUND, so its
|
||||
// 6–10 outputs are already RESIDENT in main before Phase 7. Delegating only
|
||||
// the synthesis read cannot evict them → main holds base+out+dig in BOTH
|
||||
// arms → Δ ≈ 0. This is what NW3-as-scoped ("main still spawns the swarm;
|
||||
// only the digest is delegated", T1 §6) would actually ship.
|
||||
//
|
||||
// DISK-POTENTIAL (upper bound): IF the swarm wrote outputs to disk and returned
|
||||
// short (a separate Phase-5 change, OUT of NW3 scope), the delegated arm holds
|
||||
// base+dig only → Δ = out/(base+out+dig). BASE-sensitive; swept, not asserted.
|
||||
//
|
||||
// POSITIVE adopt requires Δ ≥ 30% AND quality ≥ inline (T1 §5).
|
||||
//
|
||||
// Zero deps. Node stdlib only. Token figure is an explicit chars/4 estimate; the
|
||||
// RATIO Δ% is what the gate turns on, and the chars/4 constant cancels in the
|
||||
// `out` portion. BASE is environment-dependent (system prompt + plugin listings
|
||||
// + CLAUDE.md) and NOT API-measured this session → swept across a documented band.
|
||||
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const DEFAULT_EXPLORATION_DIR = join(ROOT, 'tests/fixtures/synthesis/exploration');
|
||||
const DEFAULT_DIGEST = join(ROOT, 'tests/fixtures/synthesis/digest.json');
|
||||
const DEFAULT_OUT = join(ROOT, 'docs/T1-synthesis-poc-results.md');
|
||||
|
||||
// T1 §5 thresholds.
|
||||
export const POSITIVE_THRESHOLD = 0.30;
|
||||
export const NEGATIVE_THRESHOLD = 0.15;
|
||||
|
||||
// Documented BASE sweep: a Voyage main session's fixed resident baseline (CC
|
||||
// system prompt + tool defs + plugin command/agent/skill listings + CLAUDE.md).
|
||||
// Genuinely environment-dependent; not API-measured this session.
|
||||
export const BASE_SWEEP = Object.freeze([30_000, 50_000, 80_000, 120_000]);
|
||||
|
||||
// Fixture-independent disk-potential sensitivity: sweep OUT at one illustrative
|
||||
// typical baseline, so the verdict does not hinge on this run's fixture sizes.
|
||||
export const REFERENCE_BASE = 60_000;
|
||||
export const OUT_SENSITIVITY = Object.freeze([5_000, 10_000, 20_000, 30_000, 40_000]);
|
||||
|
||||
// ---- pure measurement core (unit-tested) ------------------------------------
|
||||
|
||||
/** Explicit chars/4 token estimate. @param {string} text @returns {number} */
|
||||
export function estimateTokens(text) {
|
||||
if (typeof text !== 'string' || text.length === 0) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens resident in MAIN at synthesis-complete, per arm.
|
||||
* @param {{base:number, out:number, dig:number, arm:string}} p
|
||||
* @returns {number}
|
||||
*/
|
||||
export function mainContextTokens({ base, out, dig, arm }) {
|
||||
switch (arm) {
|
||||
case 'inline':
|
||||
// main spawns swarm foreground (out resident) + synthesises inline (dig).
|
||||
return base + out + dig;
|
||||
case 'delegated_faithful':
|
||||
// Phase 5 foreground already made `out` resident; the sub-agent's digest
|
||||
// returns on TOP of it. Delegating Phase 7 evicts nothing.
|
||||
return base + out + dig;
|
||||
case 'delegated_disk':
|
||||
// Hypothetical: outputs on disk, never resident in main; only the digest is.
|
||||
return base + dig;
|
||||
default:
|
||||
throw new Error(`mainContextTokens: unknown arm "${arm}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fractional reduction (A−B)/A. Divide-by-zero guarded to 0. */
|
||||
export function deltaPct(armA, armB) {
|
||||
if (!armA) return 0;
|
||||
return (armA - armB) / armA;
|
||||
}
|
||||
|
||||
/** T1 §5 verdict. Quality loss vetoes a token win. */
|
||||
export function decideVerdict(delta, qualityOK) {
|
||||
if (!qualityOK) return 'NEGATIVE';
|
||||
if (delta >= POSITIVE_THRESHOLD) return 'POSITIVE';
|
||||
if (delta < NEGATIVE_THRESHOLD) return 'NEGATIVE';
|
||||
return 'INCONCLUSIVE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Both framings for one BASE.
|
||||
* @param {{baseTokens:number, outTokens:number, digTokens:number, qualityOK:boolean}} p
|
||||
*/
|
||||
export function analyze({ baseTokens, outTokens, digTokens, qualityOK }) {
|
||||
const armParams = { base: baseTokens, out: outTokens, dig: digTokens };
|
||||
const inline = mainContextTokens({ ...armParams, arm: 'inline' });
|
||||
const faithfulB = mainContextTokens({ ...armParams, arm: 'delegated_faithful' });
|
||||
const diskB = mainContextTokens({ ...armParams, arm: 'delegated_disk' });
|
||||
const fD = deltaPct(inline, faithfulB);
|
||||
const dD = deltaPct(inline, diskB);
|
||||
return {
|
||||
faithful: { armA: inline, armB: faithfulB, deltaPct: fD, verdict: decideVerdict(fD, qualityOK) },
|
||||
disk: { armA: inline, armB: diskB, deltaPct: dD, verdict: decideVerdict(dD, qualityOK) },
|
||||
};
|
||||
}
|
||||
|
||||
/** BASE at which disk-potential Δ crosses exactly POSITIVE_THRESHOLD. */
|
||||
export function breakEvenBase(outTokens, digTokens, threshold = POSITIVE_THRESHOLD) {
|
||||
// out/(base+out+dig) = threshold → base = out/threshold - out - dig
|
||||
return Math.round(outTokens / threshold - outTokens - digTokens);
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function pct(x) { return `${(x * 100).toFixed(1)}%`; }
|
||||
|
||||
function loadExploration(dir) {
|
||||
const files = readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.md') || f.endsWith('.txt'))
|
||||
.sort();
|
||||
return files.map((f) => {
|
||||
const text = readFileSync(join(dir, f), 'utf-8');
|
||||
return { name: f, chars: text.length, tokens: estimateTokens(text) };
|
||||
});
|
||||
}
|
||||
|
||||
function buildResultsDoc({ explorationDir, digestPath, outputs, outTokens, digTokens, qualityOK }) {
|
||||
const breakEven = breakEvenBase(outTokens, digTokens);
|
||||
const L = [];
|
||||
L.push('# T1 — Synthesis-agent PoC: Δ main-context measurement (NW3 / S12)');
|
||||
L.push('');
|
||||
L.push('**Status:** Measurement complete — verdict below. **Method:** deterministic token-');
|
||||
L.push('accounting over real exploration fixtures (the live ≥3-run bake-off of T1 §5 is the');
|
||||
L.push('stronger instrument but is (a) environment-blocked here — no `ANTHROPIC_API_KEY`, and the');
|
||||
L.push('installed plugin is a cache copy so a fresh `synthesis-agent` is invisible to `claude -p`;');
|
||||
L.push('and (b) unnecessary, because the binding answer is STRUCTURAL, not stochastic).');
|
||||
L.push('**Resolves:** decision-matrix §W1 / CC-26 narrow PoC (`docs/T1-cc26-delegated-orchestration.md` §6).');
|
||||
L.push('**Reproduce:** `node scripts/synthesis-measure.mjs` (regenerates this file).');
|
||||
L.push('');
|
||||
L.push('> Verifiseringsplikt: token figures are an explicit **chars/4 estimate** (labelled), not a');
|
||||
L.push('> tokenizer count. The gate turns on the RATIO Δ%, in which the per-token constant cancels');
|
||||
L.push('> for the `out` term. BASE (the fixed main-session baseline) is environment-dependent and');
|
||||
L.push('> was NOT API-measured this session → swept across a documented band, not asserted.');
|
||||
L.push('');
|
||||
L.push('## 1. The decisive structural finding (BASE-independent)');
|
||||
L.push('');
|
||||
L.push('trekplan runs the Phase 5 exploration swarm **foreground** (foreground is the only mode');
|
||||
L.push('since v2.4.0; `commands/trekplan.md`). Foreground Agent/Task results are delivered back');
|
||||
L.push('into the main transcript, so after Phase 5 the 6–10 exploration outputs are **already');
|
||||
L.push('resident in main**. Raw outputs are never written to disk (`trekplan.md:569` reserves the');
|
||||
L.push('"do NOT write to disk" rule for the synthesis text only). Phase 7 synthesis therefore');
|
||||
L.push('*reasons over already-resident context*. Delegating **only** the Phase-7 read to a');
|
||||
L.push('synthesis-agent — "main still spawns the swarm; only the digest is delegated" (T1 §6) —');
|
||||
L.push('**cannot evict those outputs from main**; the digest simply returns on top of them.');
|
||||
L.push('');
|
||||
L.push('⇒ **Δ main-context (faithful flow) ≈ 0** — independent of every token count below. The');
|
||||
L.push('≥30% saving is only realizable by ALSO moving Phase-5 delivery off-main (swarm-writes-to-');
|
||||
L.push('disk, or a nested orchestrator owning the swarm), which is the wholesale change T1 §7');
|
||||
L.push('explicitly declined and is OUT of NW3 scope.');
|
||||
L.push('');
|
||||
L.push('## 2. Fixtures (measured)');
|
||||
L.push('');
|
||||
L.push(`- Exploration dir: \`${explorationDir.replace(ROOT + '/', '')}\``);
|
||||
L.push(`- Digest: \`${digestPath.replace(ROOT + '/', '')}\``);
|
||||
L.push('');
|
||||
L.push('| exploration output | chars | est. tokens |');
|
||||
L.push('|--------------------|-------|-------------|');
|
||||
for (const o of outputs) L.push(`| ${o.name} | ${o.chars} | ${o.tokens} |`);
|
||||
L.push(`| **OUT (Σ resident in main)** | — | **${outTokens}** |`);
|
||||
L.push(`| digest (DIG) | — | ${digTokens} |`);
|
||||
L.push('');
|
||||
L.push('## 3. Δ main-context — both framings, swept over BASE');
|
||||
L.push('');
|
||||
L.push('`inline` = base+out+dig · `delegated (faithful)` = base+out+dig (out already resident) ·');
|
||||
L.push('`delegated (disk-potential)` = base+dig (out off-main).');
|
||||
L.push('');
|
||||
L.push('| BASE (est.) | inline | faithful Δ | faithful verdict | disk-potential Δ | disk verdict |');
|
||||
L.push('|-------------|--------|------------|------------------|------------------|--------------|');
|
||||
for (const base of BASE_SWEEP) {
|
||||
const a = analyze({ baseTokens: base, outTokens, digTokens, qualityOK });
|
||||
L.push(
|
||||
`| ${base} | ${a.faithful.armA} | ${pct(a.faithful.deltaPct)} | ${a.faithful.verdict} ` +
|
||||
`| ${pct(a.disk.deltaPct)} | ${a.disk.verdict} |`,
|
||||
);
|
||||
}
|
||||
L.push('');
|
||||
L.push(`Break-even BASE for the disk-potential upper bound to reach the 30% adopt bar: ` +
|
||||
`**~${breakEven.toLocaleString('en-US')} tokens** (below this BASE the *hypothetical* disk path ` +
|
||||
`would clear 30%; at/above it, even the upper bound fails). A real Voyage main session's BASE ` +
|
||||
`(CC system prompt + plugin command/agent/skill listings + CLAUDE.md) is large, so the disk ` +
|
||||
`upper bound is itself fragile.`);
|
||||
L.push('');
|
||||
L.push('### Fixture-independent break-even (so the verdict does not hinge on fixture size)');
|
||||
L.push('');
|
||||
L.push('disk-potential Δ = out/(base+out+dig), so it clears the 30% adopt bar **iff**');
|
||||
L.push('`out / base > 0.30/0.70 ≈ 0.43` — the combined exploration output must exceed ~43% of the');
|
||||
L.push('fixed main baseline. The table below sweeps OUT at an illustrative typical `BASE = ' +
|
||||
`${REFERENCE_BASE.toLocaleString('en-US')}\` (independent of this run's fixtures):`);
|
||||
L.push('');
|
||||
L.push('| OUT (Σ exploration tokens) | disk-potential Δ @ ref BASE | clears 30%? |');
|
||||
L.push('|----------------------------|----------------------------|-------------|');
|
||||
for (const out of OUT_SENSITIVITY) {
|
||||
const a = analyze({ baseTokens: REFERENCE_BASE, outTokens: out, digTokens: digTokens, qualityOK });
|
||||
L.push(`| ${out.toLocaleString('en-US')} | ${pct(a.disk.deltaPct)} | ${a.disk.deltaPct >= POSITIVE_THRESHOLD ? 'yes' : 'no'} |`);
|
||||
}
|
||||
L.push('');
|
||||
L.push(`This run's fixtures total **OUT = ${outTokens} tokens** across ${outputs.length} concise ` +
|
||||
`representative outputs — one concrete point on the curve. Even a generously large real swarm ` +
|
||||
`(OUT in the tens of thousands) only clears 30% when the main baseline is unusually small, and ` +
|
||||
`*never* in the faithful flow (Δ=0). The verdict is therefore robust to fixture size.`);
|
||||
L.push('');
|
||||
L.push('## 4. Quality');
|
||||
L.push('');
|
||||
L.push('The digest-output contract (`lib/plan/synthesis-digest-schema.mjs`) pins the same Phase-7');
|
||||
L.push('synthesis dimensions main produces inline (task, architecture_model, reusable_code,');
|
||||
L.push('contradictions, risks, gaps, source-tagged findings). A delegated digest that validates is');
|
||||
L.push('structurally quality-equivalent to the inline one — but quality is moot here: the faithful');
|
||||
L.push('Δ is ~0, so there is no token win for quality to defend.');
|
||||
L.push('');
|
||||
L.push('## 5. Verdict');
|
||||
L.push('');
|
||||
const faithfulVerdict = analyze({ baseTokens: BASE_SWEEP[1], outTokens, digTokens, qualityOK }).faithful;
|
||||
L.push('**DECLINED per measurement.** NW3-as-scoped yields **Δ main-context ≈ 0%** (faithful flow,');
|
||||
L.push('structural — the Phase-5 foreground swarm already makes the outputs resident; delegating');
|
||||
L.push('Phase 7 evicts nothing). The disk-potential upper bound is reachable only via an out-of-');
|
||||
L.push('scope Phase-5 change and is itself BASE-fragile.');
|
||||
L.push('');
|
||||
L.push(`RESULT: NEGATIVE (Δ_faithful = ${pct(faithfulVerdict.deltaPct)} < ${pct(NEGATIVE_THRESHOLD)} adopt-floor)`);
|
||||
L.push('');
|
||||
L.push('## 6. Disposition');
|
||||
L.push('');
|
||||
L.push('- `agents/synthesis-agent.md` ships **dormant** (a documented, schema-conformant deliverable);');
|
||||
L.push(' `commands/trekplan.md` Phase 7 is **NOT** wired to it.');
|
||||
L.push('- If main-context relief is later wanted, the prerequisite is a Phase-5 redesign (swarm-');
|
||||
L.push(' writes-to-disk / nested orchestrator) — a separate, larger decision (re-open CC-26 §7).');
|
||||
L.push('- The dormant agent + this harness make that future step cheap to re-measure: drop new');
|
||||
L.push(' fixtures in and re-run.');
|
||||
L.push('');
|
||||
return L.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { explorationDir: DEFAULT_EXPLORATION_DIR, digest: DEFAULT_DIGEST, out: DEFAULT_OUT, qualityOK: true, json: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--exploration') o.explorationDir = resolve(argv[++i]);
|
||||
else if (a === '--digest') o.digest = resolve(argv[++i]);
|
||||
else if (a === '--out') o.out = resolve(argv[++i]);
|
||||
else if (a === '--quality-fail') o.qualityOK = false;
|
||||
else if (a === '--json') o.json = true;
|
||||
else if (a === '--help' || a === '-h') { o.help = true; }
|
||||
else { process.stderr.write(`Unknown argument: ${a}\n`); process.exit(2); }
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function mainCli() {
|
||||
const o = parseArgs(process.argv.slice(2));
|
||||
if (o.help) {
|
||||
process.stdout.write('Usage: synthesis-measure.mjs [--exploration DIR] [--digest FILE] [--out FILE] [--quality-fail] [--json]\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (!existsSync(o.explorationDir)) { process.stderr.write(`exploration dir not found: ${o.explorationDir}\n`); process.exit(2); }
|
||||
if (!existsSync(o.digest)) { process.stderr.write(`digest not found: ${o.digest}\n`); process.exit(2); }
|
||||
|
||||
const outputs = loadExploration(o.explorationDir);
|
||||
const outTokens = outputs.reduce((s, x) => s + x.tokens, 0);
|
||||
const digTokens = estimateTokens(readFileSync(o.digest, 'utf-8'));
|
||||
|
||||
if (o.json) {
|
||||
const rows = BASE_SWEEP.map((base) => ({ base, ...analyze({ baseTokens: base, outTokens, digTokens, qualityOK: o.qualityOK }) }));
|
||||
process.stdout.write(JSON.stringify({ outTokens, digTokens, breakEvenBase: breakEvenBase(outTokens, digTokens), rows }, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const doc = buildResultsDoc({
|
||||
explorationDir: o.explorationDir, digestPath: o.digest,
|
||||
outputs, outTokens, digTokens, qualityOK: o.qualityOK,
|
||||
});
|
||||
if (!existsSync(dirname(o.out))) mkdirSync(dirname(o.out), { recursive: true });
|
||||
writeFileSync(o.out, doc, 'utf-8');
|
||||
const faithful = analyze({ baseTokens: BASE_SWEEP[1], outTokens, digTokens, qualityOK: o.qualityOK }).faithful;
|
||||
process.stderr.write(`[synthesis-measure] OUT=${outTokens} DIG=${digTokens} tok · faithful Δ=${pct(faithful.deltaPct)} → ${faithful.verdict} · wrote ${o.out}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
mainCli();
|
||||
}
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/yardstick.mjs
|
||||
// The yardstick - Voyage's three numbers, each with a denominator read from its SOURCE:
|
||||
//
|
||||
// use new work that went through Voyage (the order queue joined to trekbrief-stats.jsonl
|
||||
// by order id. New work = an order whose
|
||||
// `<!-- loesningsvalg: plan|prove -->` marker
|
||||
// says so, dated on or after the latest of the
|
||||
// cutoff, MARKER_SINCE and USE_COUNTING_SINCE.
|
||||
// YES = a /trekbrief record names the order's
|
||||
// id (its file stem) under one slug that names
|
||||
// no other order. NO = the order's own
|
||||
// `<!-- arbeidsform: ordre -->` line. No record,
|
||||
// or a disputed one, is not measured, never a
|
||||
// no. USE_COUNTING_SINCE unset = counting has
|
||||
// not started: not countable)
|
||||
// trust reviews in a row with nothing that (trekreview-stats.jsonl, read as JSON and
|
||||
// stops a release ordered by `ts` - file order is not
|
||||
// chronological; value = the current ALLOW streak)
|
||||
// interrupts operator interrupts per delivery (every *-stats.jsonl: `user_input` records
|
||||
// that carry a slug or run id)
|
||||
//
|
||||
// plus a fourth row that asks whether the end-state gate can itself be fooled:
|
||||
//
|
||||
// mutants signature-preserving mutants the (M9: every export of the module a behaviour
|
||||
// gate fells probe binds keeps its name and returns
|
||||
// {status:'PASSED'}; every named test only
|
||||
// checks `typeof`. The gate fells it when it
|
||||
// refuses to close the probe on that tree.)
|
||||
//
|
||||
// What `use` can and cannot see (PM 04.10): the "no" side is only what orders
|
||||
// declare, so undeclared non-Voyage work is not measured and the value covers the
|
||||
// measured share only. Known false-positive class: an `arbeidsform: voyage`
|
||||
// order claimed while an unrelated /trekbrief runs would be named by that
|
||||
// record; PM chose no repo-mailbox change (the record must itself name the id) -
|
||||
// a strong link would need the claim to name the claiming session.
|
||||
//
|
||||
// A number the source cannot give is NOT COUNTABLE - never 0. The verdict over the first five
|
||||
// deliveries is not judged by anything yet, and says so. Exit 0 = every number countable, every
|
||||
// mutant felled and the verdict judged and met; 1 = anything short of that; 2 = usage error.
|
||||
// It only READS: the queue, the stats and the repo. The mutant is built in a temp directory.
|
||||
//
|
||||
// Usage: node scripts/yardstick.mjs [--json] [--root <repo>] [--coord <dir>] [--data <dir>]
|
||||
// [--cutoff YYYY-MM-DD]
|
||||
|
||||
import {
|
||||
readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, mkdtempSync,
|
||||
realpathSync, rmSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join, resolve, dirname, relative, sep, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { evaluateCheck, loadRegistry, TEST_TIMEOUT_MS } from './end-state-gate.mjs';
|
||||
import { isMain } from '../lib/util/is-main.mjs';
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
export const DEFAULT_CUTOFF = '2026-09-21';
|
||||
// The day pre-order-loesningsvalg.sh began refusing an order without the marker (PM 02.10).
|
||||
export const MARKER_SINCE = '2026-09-28';
|
||||
// The day `use` starts counting. Unset until the operator declares the work done
|
||||
// (operator 04.10): until then `use` is NOT COUNTABLE and every order not measured.
|
||||
export const USE_COUNTING_SINCE = null;
|
||||
export const DEFAULT_COORD = join(homedir(), '.claude', 'coord');
|
||||
export const DEFAULT_DATA = process.env.CLAUDE_PLUGIN_DATA
|
||||
|| join(homedir(), '.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
|
||||
|
||||
const REVIEW_FILE = 'trekreview-stats.jsonl';
|
||||
const BRIEF_FILE = 'trekbrief-stats.jsonl';
|
||||
const toPosix = (p) => p.split(sep).join('/');
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) out.push(...walk(p));
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- the stats files -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every `*-stats.jsonl` in the data directory, parsed line by line. A blank line
|
||||
* is not a line; a line that is not JSON is a line, and is reported by number.
|
||||
*/
|
||||
export function loadStats(dataDir) {
|
||||
if (!existsSync(dataDir) || !statSync(dataDir).isDirectory()) {
|
||||
return { dir: dataDir, missing: true, files: [] };
|
||||
}
|
||||
const files = readdirSync(dataDir)
|
||||
.filter((n) => n.endsWith('-stats.jsonl'))
|
||||
.sort()
|
||||
.map((name) => {
|
||||
const path = join(dataDir, name);
|
||||
const records = [];
|
||||
const invalid = [];
|
||||
let lines = 0;
|
||||
readFileSync(path, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (line.trim() === '') return;
|
||||
lines++;
|
||||
try {
|
||||
records.push(JSON.parse(line));
|
||||
} catch {
|
||||
invalid.push(i + 1);
|
||||
}
|
||||
});
|
||||
return { name, path, lines, records, invalid };
|
||||
});
|
||||
return { dir: dataDir, missing: false, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* The orders /trekbrief runs named: order id -> its slugs, and slug -> its order
|
||||
* ids. Only trekbrief-stats.jsonl - the one record that carries order_id.
|
||||
*/
|
||||
export function orderRuns(stats) {
|
||||
const byOrder = new Map();
|
||||
const bySlug = new Map();
|
||||
const file = stats.files.find((f) => f.name === BRIEF_FILE);
|
||||
for (const r of file ? file.records : []) {
|
||||
if (!r || typeof r.order_id !== 'string' || r.order_id === '') continue;
|
||||
const slug = typeof r.slug === 'string' ? r.slug : '';
|
||||
if (!byOrder.has(r.order_id)) byOrder.set(r.order_id, new Set());
|
||||
byOrder.get(r.order_id).add(slug);
|
||||
if (!bySlug.has(slug)) bySlug.set(slug, new Set());
|
||||
bySlug.get(slug).add(r.order_id);
|
||||
}
|
||||
return { byOrder, bySlug };
|
||||
}
|
||||
|
||||
const notCountable = (detail, extra = {}) => ({ countable: false, value: null, detail, ...extra });
|
||||
|
||||
// --- 2 · TRUST -------------------------------------------------------------------
|
||||
|
||||
export function measureTrust(stats) {
|
||||
const base = { id: 'trust', label: 'reviews in a row with nothing that stops a release' };
|
||||
const file = stats.files.find((f) => f.name === REVIEW_FILE);
|
||||
if (!file) {
|
||||
return { ...base, source: join(stats.dir, REVIEW_FILE), denominator: 0, facts: {},
|
||||
...notCountable(stats.missing ? `data directory ${stats.dir} not found` : `${REVIEW_FILE} not found`) };
|
||||
}
|
||||
const reviews = file.records.filter((r) => r && typeof r.verdict === 'string');
|
||||
const undated = reviews.filter((r) => Number.isNaN(Date.parse(r.ts))).length;
|
||||
// Stable sort on ts: the file is appended by whoever ran a review, in no fixed order.
|
||||
const ordered = reviews
|
||||
.map((r, i) => ({ r, i, t: Date.parse(r.ts) }))
|
||||
.filter((x) => !Number.isNaN(x.t))
|
||||
.sort((a, b) => a.t - b.t || a.i - b.i)
|
||||
.map((x) => x.r);
|
||||
const verdicts = {};
|
||||
for (const r of reviews) verdicts[r.verdict] = (verdicts[r.verdict] ?? 0) + 1;
|
||||
let longest = 0;
|
||||
let run = 0;
|
||||
for (const r of ordered) {
|
||||
run = r.verdict === 'ALLOW' ? run + 1 : 0;
|
||||
longest = Math.max(longest, run);
|
||||
}
|
||||
const last = ordered.at(-1) ?? null;
|
||||
const facts = {
|
||||
verdicts, longestStreak: longest, invalid: file.invalid.length, undated,
|
||||
last: last ? { ts: last.ts, slug: last.slug, verdict: last.verdict } : null,
|
||||
};
|
||||
const notes = [];
|
||||
if (file.invalid.length) notes.push(`${file.invalid.length} invalid line(s) left out (line ${file.invalid.join(', ')})`);
|
||||
if (undated) notes.push(`${undated} review(s) without a readable ts left out of the streak`);
|
||||
if (reviews.length === 0) {
|
||||
return { ...base, source: file.path, denominator: 0, facts, ...notCountable(['no review rows', ...notes].join('; ')) };
|
||||
}
|
||||
const counts = Object.entries(verdicts).map(([k, v]) => `${k} ${v}`).join(' · ');
|
||||
const detail = [
|
||||
`${counts}; longest ALLOW streak ${longest}; last review ${last ? `${last.ts} ${last.verdict} (${last.slug})` : 'n/a'}`,
|
||||
'ordered by ts, read as JSON',
|
||||
...notes,
|
||||
].join('; ');
|
||||
return { ...base, source: file.path, countable: true, value: run, denominator: reviews.length, facts, detail };
|
||||
}
|
||||
|
||||
// --- 1 · USE ---------------------------------------------------------------------
|
||||
|
||||
// A minimal frontmatter reader: `key: value` lines between the first two `---`.
|
||||
function frontmatter(text) {
|
||||
const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!m) return null;
|
||||
const fm = {};
|
||||
for (const line of m[1].split(/\r?\n/)) {
|
||||
const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*?)\s*$/);
|
||||
if (kv) fm[kv[1]] = kv[2];
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
|
||||
// The solution-choice marker dispatch writes on its own line in the order body. A mention
|
||||
// inside prose (mid-line, in backticks) is not a marker.
|
||||
const MARKER = /^<!--\s*loesningsvalg:\s*([^;\s>]+)/m;
|
||||
const NEW_WORK = new Set(['plan', 'prove']);
|
||||
const NOT_NEW = new Set(['ikke-nytt']);
|
||||
// The work-form marker dispatch writes on its own line; `ordre` declares the work is not done through Voyage.
|
||||
const ARBEIDSFORM = /^<!--\s*arbeidsform:\s*([^;\s>]+)/m;
|
||||
|
||||
/** Every order file under `<coord>/<repo>/orders/` - pending, `archive/` and `claimed/`. */
|
||||
export function readOrders(coordDir) {
|
||||
const orders = [];
|
||||
const unreadable = [];
|
||||
let files = 0;
|
||||
if (existsSync(coordDir)) {
|
||||
for (const repo of readdirSync(coordDir).sort()) {
|
||||
const dir = join(coordDir, repo, 'orders');
|
||||
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
|
||||
for (const path of walk(dir).filter((p) => p.endsWith('.md')).sort()) {
|
||||
files++;
|
||||
const text = readFileSync(path, 'utf8');
|
||||
const fm = frontmatter(text);
|
||||
if (fm && fm['order-id']) {
|
||||
orders.push({
|
||||
path, repo, fm, id: basename(path, '.md'),
|
||||
marker: text.match(MARKER)?.[1], arbeidsform: text.match(ARBEIDSFORM)?.[1],
|
||||
});
|
||||
}
|
||||
else unreadable.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { dir: coordDir, files, orders, unreadable };
|
||||
}
|
||||
|
||||
export function measureUse({ queue, runs, cutoff, countingSince }) {
|
||||
const started = typeof countingSince === 'string' && countingSince !== '';
|
||||
const from = [cutoff, MARKER_SINCE, ...(started ? [countingSince] : [])].reduce((a, b) => (b > a ? b : a));
|
||||
const glob = `${join(queue.dir, '*', 'orders')}/**/*.md`;
|
||||
const base = {
|
||||
id: 'use', label: 'new work that went through Voyage',
|
||||
source: started
|
||||
? `${glob} joined to ${BRIEF_FILE} by order id (loesningsvalg marker, from ${from})`
|
||||
: `${glob} joined to ${BRIEF_FILE} by order id (counting not started - USE_COUNTING_SINCE is not set)`,
|
||||
};
|
||||
const named = queue.orders.filter((o) => runs.byOrder.has(o.id)).length;
|
||||
const withMarker = queue.orders.filter((o) => o.marker !== undefined);
|
||||
const perMailbox = {};
|
||||
for (const o of queue.orders) perMailbox[o.repo] = (perMailbox[o.repo] ?? 0) + 1;
|
||||
const dot = Object.entries(perMailbox).filter(([k]) => k.startsWith('.'));
|
||||
const files = `${queue.files} order files in ${Object.keys(perMailbox).length} mailboxes`
|
||||
+ (dot.length ? ` (dot-mailboxes ${dot.map(([k, v]) => `${k} ${v}`).join(' · ')} - a shell \`*\` skips them)` : '');
|
||||
const unreadableNote = queue.unreadable.length ? `; ${queue.unreadable.length} order file(s) without frontmatter` : '';
|
||||
if (!started) {
|
||||
const facts = { scanned: queue.orders.length, files: queue.files, unreadable: queue.unreadable.length,
|
||||
withMarker: withMarker.length, notMeasured: queue.orders.length, withVoyageRun: 0,
|
||||
ordersNamedByARecord: named, countingSince: null };
|
||||
return { ...base, denominator: 0, facts, ...notCountable(
|
||||
`${files}${unreadableNote}; counting has not started (USE_COUNTING_SINCE is not set) - all ${queue.orders.length} orders not measured`
|
||||
+ `; ${named} order(s) already named by a trekbrief record`) };
|
||||
}
|
||||
const inWindow = queue.orders.filter((o) => String(o.fm.date ?? '').slice(0, 10) >= from);
|
||||
const newWork = inWindow.filter((o) => NEW_WORK.has(o.marker));
|
||||
const notNew = inWindow.filter((o) => NOT_NEW.has(o.marker));
|
||||
const unmarked = inWindow.length - newWork.length - notNew.length;
|
||||
const beforeMarker = queue.orders.length - inWindow.length;
|
||||
const yes = [];
|
||||
let declaredNo = 0;
|
||||
let noRecord = 0;
|
||||
let disputed = 0;
|
||||
for (const o of newWork) {
|
||||
if (o.arbeidsform === 'ordre') { declaredNo++; continue; }
|
||||
const slugs = runs.byOrder.get(o.id);
|
||||
if (!slugs) { noRecord++; continue; }
|
||||
const [slug] = slugs;
|
||||
if (slugs.size !== 1 || runs.bySlug.get(slug).size !== 1) { disputed++; continue; }
|
||||
yes.push(o.id);
|
||||
}
|
||||
const measured = yes.length + declaredNo;
|
||||
const facts = {
|
||||
scanned: queue.orders.length, files: queue.files, unreadable: queue.unreadable.length,
|
||||
withMarker: withMarker.length, newWork: newWork.length, notNew: notNew.length,
|
||||
unmarked, beforeMarker, notMeasured: unmarked + beforeMarker + noRecord + disputed,
|
||||
withVoyageRun: yes.length, declaredNo, noRecord, disputed, matched: yes,
|
||||
ordersNamedByARecord: named, countingSince,
|
||||
};
|
||||
const census = `${files}; ${withMarker.length} of ${queue.orders.length} orders carry a loesningsvalg marker`
|
||||
+ `; from ${from}: ${newWork.length} new (plan|prove) · ${notNew.length} not new (ikke-nytt)`
|
||||
+ `; ${facts.notMeasured} not measured (${beforeMarker} before ${from} · ${unmarked} unmarked or unknown value`
|
||||
+ ` · ${noRecord} new without a trekbrief record · ${disputed} disputed)${unreadableNote}`;
|
||||
if (newWork.length === 0) {
|
||||
return { ...base, denominator: 0, facts, ...notCountable(
|
||||
`${census}; 0 new-work orders dated >= ${from} - the denominator is empty`) };
|
||||
}
|
||||
if (measured === 0) {
|
||||
return { ...base, denominator: 0, facts, ...notCountable(
|
||||
`${census}; none of ${newWork.length} new-work orders is measured - a yes needs a trekbrief record naming the order,`
|
||||
+ ' a no the order\'s own arbeidsform: ordre') };
|
||||
}
|
||||
return {
|
||||
...base, countable: true, value: yes.length, denominator: measured, facts,
|
||||
detail: `${census}; of ${newWork.length} new-work orders: ${yes.length} through Voyage (a trekbrief record names the order)`
|
||||
+ ` · ${declaredNo} declared outside Voyage (arbeidsform: ordre); value = ${yes.length} of ${measured} measured`
|
||||
+ ' - the measured share only: undeclared non-Voyage work is not measured, never a no',
|
||||
};
|
||||
}
|
||||
|
||||
// --- 3 · INTERRUPTS --------------------------------------------------------------
|
||||
|
||||
// A call site EMITS user_input: the CLI shim with `--event user_input`, or a
|
||||
// call `emit…('user_input'`. A name in prose is not a call.
|
||||
const CALL_SITE = /--event[\s'",=\\]+user_input\b|\bemit\w*\(\s*['"]user_input['"]/;
|
||||
const SCAN = [
|
||||
{ dir: 'commands', ext: '.md' },
|
||||
{ dir: 'agents', ext: '.md' },
|
||||
{ dir: 'hooks', ext: '' },
|
||||
{ dir: 'lib', ext: '.mjs' },
|
||||
];
|
||||
|
||||
/** Emitter call sites for user_input outside lib/stats/ (the emitter) and tests/. */
|
||||
export function emitterCallSites(root) {
|
||||
const sites = [];
|
||||
let scanned = 0;
|
||||
for (const { dir, ext } of SCAN) {
|
||||
const abs = join(root, dir);
|
||||
if (!existsSync(abs)) continue;
|
||||
for (const file of walk(abs).sort()) {
|
||||
const rel = toPosix(relative(root, file));
|
||||
if (rel.startsWith('lib/stats/') || (ext && !rel.endsWith(ext))) continue;
|
||||
scanned++;
|
||||
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (CALL_SITE.test(line)) sites.push({ file: rel, line: i + 1 });
|
||||
});
|
||||
}
|
||||
}
|
||||
return { sites, scanned };
|
||||
}
|
||||
|
||||
const deliveryOf = (r) => {
|
||||
const p = r.payload && typeof r.payload === 'object' ? r.payload : {};
|
||||
return p.slug || p.run_id || r.slug || r.run_id || null;
|
||||
};
|
||||
|
||||
export function measureInterrupts({ stats, callSites }) {
|
||||
const base = { id: 'interrupts', label: 'operator interrupts per delivery', source: join(stats.dir, '*-stats.jsonl') };
|
||||
const lines = stats.files.reduce((n, f) => n + f.lines, 0);
|
||||
const records = stats.files.flatMap((f) => f.records);
|
||||
const userInput = records.filter((r) => r && r.event === 'user_input');
|
||||
const perDelivery = {};
|
||||
for (const r of userInput) {
|
||||
const d = deliveryOf(r);
|
||||
if (d) perDelivery[d] = (perDelivery[d] ?? 0) + 1;
|
||||
}
|
||||
const withDelivery = Object.values(perDelivery).reduce((a, b) => a + b, 0);
|
||||
const facts = { lines, files: stats.files.length, userInput: userInput.length, withDelivery, perDelivery,
|
||||
callSites: callSites.sites.length, scannedForCallSites: callSites.scanned };
|
||||
const census = `${userInput.length} user_input records of ${lines} lines in ${stats.files.length} files`
|
||||
+ `; ${withDelivery} carry a slug or run id`
|
||||
+ `; ${callSites.sites.length} emitter call sites outside lib/stats/ and tests/ (${callSites.scanned} files scanned)`;
|
||||
if (withDelivery === 0) {
|
||||
return { ...base, denominator: 0, facts, ...notCountable(`${census} - an interrupt that names no delivery cannot be divided by one`) };
|
||||
}
|
||||
return {
|
||||
...base, countable: true, value: withDelivery, denominator: Object.keys(perDelivery).length, facts,
|
||||
detail: `${census}; per delivery: ${Object.entries(perDelivery).map(([k, v]) => `${k} ${v}`).join(' · ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 4 · the signature-preserving mutant (M9) --------------------------------------
|
||||
|
||||
const EXPORT_NAME = /^\s*export\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
||||
|
||||
// Every entry whose check RUNS a named test and names the module it binds.
|
||||
function behaviourConditions(registry) {
|
||||
const out = [];
|
||||
for (const e of [...(registry.defects ?? []), ...(registry.experiments ?? [])]) {
|
||||
for (const c of Array.isArray(e.check) ? e.check : []) {
|
||||
if (c && c.test !== undefined && Array.isArray(c.stubs)) out.push({ id: e.id, entry: e, cond: c });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const relImport = (fromFile, toFile) => {
|
||||
const r = toPosix(relative(dirname(fromFile), toFile));
|
||||
return r.startsWith('.') ? r : `./${r}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build M9 in a fresh temp directory: every module a behaviour probe binds is
|
||||
* replaced by one whose exports keep their names and always return
|
||||
* {status:'PASSED'}, and every named test is replaced by one that only checks
|
||||
* that each export is a function. Returns the directory. `root` is only read.
|
||||
*/
|
||||
export function buildM9Tree(root, registry) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'yardstick-m9-'));
|
||||
const tests = new Map();
|
||||
const modules = new Set();
|
||||
for (const { cond } of behaviourConditions(registry)) {
|
||||
if (!tests.has(cond.test)) tests.set(cond.test, { names: new Set(), stubs: new Set() });
|
||||
tests.get(cond.test).names.add(cond.name);
|
||||
for (const s of cond.stubs) { tests.get(cond.test).stubs.add(s); modules.add(s); }
|
||||
}
|
||||
const write = (rel, body) => {
|
||||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||
writeFileSync(join(dir, rel), body);
|
||||
};
|
||||
for (const mod of modules) {
|
||||
const source = readFileSync(join(root, mod), 'utf8');
|
||||
const names = new Set([...source.matchAll(EXPORT_NAME)].map((m) => m[1]));
|
||||
const lines = [...names].map((n) => `export function ${n}() { return { status: 'PASSED' }; }`);
|
||||
if (/^\s*export\s+default\b/m.test(source)) lines.push("export default function () { return { status: 'PASSED' }; }");
|
||||
write(mod, `${lines.join('\n')}\n`);
|
||||
}
|
||||
for (const [testRel, { names, stubs }] of tests) {
|
||||
const lines = ["import { test } from 'node:test';", "import { strict as assert } from 'node:assert';"];
|
||||
[...stubs].forEach((s, i) => lines.push(`import * as m${i} from '${relImport(testRel, s)}';`));
|
||||
for (const name of names) {
|
||||
const checks = [...stubs].map((_, i) => `for (const v of Object.values(m${i})) assert.equal(typeof v, 'function');`);
|
||||
lines.push(`test(${JSON.stringify(name)}, () => { ${checks.join(' ')} });`);
|
||||
}
|
||||
write(testRel, `${lines.join('\n')}\n`);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Run ONE named test; true = passed, false = failed, null = it did not run.
|
||||
function runNamedTest(root, testRel, name) {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) if (key.startsWith('NODE_TEST_')) delete env[key];
|
||||
const pattern = `^${String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`;
|
||||
const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', `--test-name-pattern=${pattern}`, testRel],
|
||||
{ cwd: root, encoding: 'utf8', timeout: TEST_TIMEOUT_MS, env });
|
||||
for (const line of String(r.stdout ?? '').split('\n')) {
|
||||
const m = line.match(/^\s*(not )?ok \d+ - (.*)$/);
|
||||
if (m && m[2].replace(/\\(.)/g, '$1').trim() === name) return m[1] === undefined;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function measureMutants(root, { registry, evaluate = evaluateCheck, runTest = runNamedTest } = {}) {
|
||||
const reg = registry ?? loadRegistry(root);
|
||||
const base = {
|
||||
id: 'mutants', label: 'signature-preserving mutants the end-state gate fells',
|
||||
source: 'scripts/end-state-gate.mjs behaviour probes vs M9 (exports return {status:"PASSED"}, tests check typeof)',
|
||||
denominator: 1,
|
||||
};
|
||||
const probes = behaviourConditions(reg);
|
||||
const entryIds = [...new Set(probes.map((p) => p.id))];
|
||||
if (probes.length === 0) {
|
||||
return { ...base, facts: { entries: [] }, ...notCountable('the registry has no behaviour probe to aim M9 at') };
|
||||
}
|
||||
let dir;
|
||||
try {
|
||||
dir = buildM9Tree(root, reg);
|
||||
} catch (err) {
|
||||
return { ...base, facts: { entries: entryIds }, ...notCountable(`M9 could not be built: ${err.message}`) };
|
||||
}
|
||||
try {
|
||||
return judgeM9(base, dir, probes, entryIds, evaluate, runTest);
|
||||
} finally {
|
||||
// Only ever the directory buildM9Tree made, under the system temp dir.
|
||||
if (realpathSync(dir).startsWith(realpathSync(tmpdir()))) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function judgeM9(base, dir, probes, entryIds, evaluate, runTest) {
|
||||
// Liveness: M9's own tests must PASS on the M9 tree, or "felled" would mean "broken fixture".
|
||||
const dead = probes.filter((p) => runTest(dir, p.cond.test, p.cond.name) !== true).map((p) => p.cond.name);
|
||||
if (dead.length > 0) {
|
||||
return { ...base, facts: { entries: entryIds },
|
||||
...notCountable(`M9 is not live: ${dead.length} of its named tests do not pass on the M9 tree`) };
|
||||
}
|
||||
const results = [...new Map(probes.map((p) => [p.id, p.entry])).values()]
|
||||
.map((e) => ({ id: e.id, ...evaluate(dir, e.check) }));
|
||||
const closed = results.filter((r) => r.status === 'closed');
|
||||
const felled = closed.length === 0 ? 1 : 0;
|
||||
const per = results.map((r) => `${r.id} ${r.status === 'closed' ? 'closed' : `not closed (${r.status})`}`).join(', ');
|
||||
return {
|
||||
...base, countable: true, value: felled, facts: { entries: entryIds },
|
||||
detail: `M9 on a temp tree: ${per} - ${felled ? 'the gate refuses to count a typeof-only test as behaviour'
|
||||
: 'a typeof-only test closes the probe, so the gate counts a signature as behaviour'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// --- the whole yardstick ---------------------------------------------------------
|
||||
|
||||
const VERDICT = {
|
||||
judged: false,
|
||||
met: false,
|
||||
detail: 'the verdict needs the first five deliveries through the new Voyage; nothing counts a delivery yet',
|
||||
};
|
||||
|
||||
export function measureAll({ root = REPO_ROOT, coordDir = DEFAULT_COORD, dataDir = DEFAULT_DATA, cutoff = DEFAULT_CUTOFF, countingSince = USE_COUNTING_SINCE, mutantOptions = {} } = {}) {
|
||||
const stats = loadStats(dataDir);
|
||||
const numbers = [
|
||||
measureUse({ queue: readOrders(coordDir), runs: orderRuns(stats), cutoff, countingSince }),
|
||||
measureTrust(stats),
|
||||
measureInterrupts({ stats, callSites: emitterCallSites(root) }),
|
||||
];
|
||||
const mutants = measureMutants(root, mutantOptions);
|
||||
const countable = numbers.filter((r) => r.countable).length;
|
||||
const felled = mutants.countable && mutants.value === mutants.denominator;
|
||||
return {
|
||||
green: countable === numbers.length && felled && VERDICT.judged && VERDICT.met,
|
||||
countable,
|
||||
rows: [...numbers, mutants],
|
||||
verdict: VERDICT,
|
||||
};
|
||||
}
|
||||
|
||||
const fmt = (v) => (v === null || v === undefined ? 'n/a' : String(v));
|
||||
|
||||
export function render(result) {
|
||||
const m = result.rows.find((r) => r.id === 'mutants');
|
||||
const out = [];
|
||||
out.push(`Voyage yardstick: ${result.green ? 'GREEN' : 'RED'} (${result.countable} of 3 numbers countable)`);
|
||||
out.push(`signature-preserving mutants felled: ${m.countable ? `${m.value} of ${m.denominator}` : 'n/a (not measurable)'}`);
|
||||
out.push(`verdict: ${result.verdict.judged ? (result.verdict.met ? 'MET' : 'NOT MET') : 'NOT JUDGED'} - ${result.verdict.detail}`);
|
||||
out.push('');
|
||||
out.push('| number | countable | value | denominator | source |');
|
||||
out.push('|---|---|---|---|---|');
|
||||
for (const r of result.rows) {
|
||||
out.push(`| ${r.id} | ${r.countable ? 'YES' : 'NO'} | ${fmt(r.value)} | ${fmt(r.denominator)} | ${r.source} |`);
|
||||
}
|
||||
for (const r of result.rows) {
|
||||
out.push('');
|
||||
out.push(`${r.id} - ${r.label}: ${r.detail}`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
const opts = {};
|
||||
let json = false;
|
||||
const usage = 'usage: yardstick.mjs [--json] [--root <repo>] [--coord <dir>] [--data <dir>] [--cutoff YYYY-MM-DD]';
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const value = argv[i + 1];
|
||||
if (a === '--json') { json = true; continue; }
|
||||
if (['--root', '--coord', '--data', '--cutoff'].includes(a) && value !== undefined) {
|
||||
i++;
|
||||
if (a === '--cutoff') {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
process.stderr.write(`yardstick: --cutoff must be YYYY-MM-DD, got ${value}\n${usage}\n`);
|
||||
return 2;
|
||||
}
|
||||
opts.cutoff = value;
|
||||
} else {
|
||||
opts[{ '--root': 'root', '--coord': 'coordDir', '--data': 'dataDir' }[a]] = resolve(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
process.stderr.write(`yardstick: unknown argument ${a}\n${usage}\n`);
|
||||
return 2;
|
||||
}
|
||||
const result = measureAll(opts);
|
||||
process.stdout.write(`${json ? JSON.stringify(result, null, 2) : render(result)}\n`);
|
||||
return result.green ? 0 : 1;
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
}
|
||||
|
|
@ -133,10 +133,8 @@ usable source is an open question, not a rating.
|
|||
research brief: required frontmatter, `confidence` a number in [0, 1],
|
||||
the required sections. It does not read sources, dates, versions or
|
||||
queries.
|
||||
- `lib/validators/query-privacy-gate.mjs` blocks secret-shaped strings and
|
||||
warns on local paths in a query, but only in the `/trekresearch` steps
|
||||
that are off by default (dimension discovery and the bounded follow-up
|
||||
loop). Ordinary searches do not pass through it.
|
||||
- `lib/validators/query-privacy-gate.mjs` is not called by any command
|
||||
since its STORM steps were removed, so no search passes through it.
|
||||
- Everything else in this skill is **prose only**. No check stops a missing
|
||||
date, a wrong version, an absence reported as a finding, an injected
|
||||
instruction that was followed, or a local path in an ordinary search.
|
||||
|
|
|
|||
|
|
@ -24,21 +24,6 @@ ceremony: {small | bounded | architectural}
|
|||
# .git/info/exclude). The approval log is committed either way. Optional: absent
|
||||
# reads as local. Asked once per task in the /trekbrief question round.
|
||||
artifact_store: {git | local}
|
||||
# v5.1 — per-phase effort + model signal (Phase 3.5).
|
||||
# `effort` ∈ {low, standard, high}; `model` ∈ {sonnet, opus, fable} (v5.9).
|
||||
# Omit `model:` for `standard` so composition falls through to profile
|
||||
# resolver. Force-stop alternative is the commented
|
||||
# `phase_signals_partial: true` below (mutually exclusive with `phase_signals`).
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: standard
|
||||
- phase: plan
|
||||
effort: standard
|
||||
- phase: execute
|
||||
effort: standard
|
||||
- phase: review
|
||||
effort: standard
|
||||
# phase_signals_partial: true # uncomment to record force-stop instead of phase_signals
|
||||
---
|
||||
|
||||
# Task: {title}
|
||||
|
|
|
|||
|
|
@ -1,118 +1,22 @@
|
|||
// tests/commands/trekbrief.test.mjs
|
||||
// v5.1 prose-pin tests + v5.1.1 runtime SC1 tests.
|
||||
//
|
||||
// Pattern D prose-pins kept as doc-anchors for the .md file. Runtime tests
|
||||
// added per finding 350853 (BLOCKER SC1) + a7f4f95a (MAJOR Plan Step 5 drift).
|
||||
//
|
||||
// SC1 re-interpretation (per plan Step 10 amendment): "asserts on 4
|
||||
// AskUserQuestion calls" → "asserts resolvePhaseSignal returns non-null for
|
||||
// all 4 entries in PHASE_SIGNAL_PHASES when applied to a brief with a
|
||||
// committed phase_signals block." See brief amendment for full rationale.
|
||||
// Pattern D prose-pins kept as doc-anchors for commands/trekbrief.md.
|
||||
|
||||
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';
|
||||
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
||||
import { validateBriefContent, PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../../lib/validators/brief-validator.mjs';
|
||||
import { BASE_ALLOWED_MODELS } from '../../lib/validators/profile-validator.mjs';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md');
|
||||
const REVIEWER_FILE = join(ROOT, 'agents', 'brief-reviewer.md');
|
||||
const TEMPLATE_FILE = join(ROOT, 'templates', 'trekbrief-template.md');
|
||||
const FIXTURE = (name) => join(ROOT, 'tests', 'fixtures', name);
|
||||
|
||||
function read() {
|
||||
return readFileSync(COMMAND_FILE, 'utf8');
|
||||
}
|
||||
|
||||
function readFixture(name) {
|
||||
return readFileSync(FIXTURE(name), 'utf8');
|
||||
}
|
||||
|
||||
function frontmatterOf(text) {
|
||||
const doc = parseDocument(text);
|
||||
return doc.parsed && doc.parsed.frontmatter;
|
||||
}
|
||||
|
||||
// --- Pattern D prose-pins (doc-anchors) ---
|
||||
|
||||
test('trekbrief — Phase 3.5 heading is present', () => {
|
||||
const text = read();
|
||||
assert.match(text, /^## Phase 3\.5 — Per-phase effort dialog$/m,
|
||||
'Phase 3.5 heading missing from commands/trekbrief.md');
|
||||
});
|
||||
|
||||
test('trekbrief — Phase 3.5 references all 4 downstream phases', () => {
|
||||
const text = read();
|
||||
const startIdx = text.indexOf('## Phase 3.5');
|
||||
assert.ok(startIdx >= 0, 'Phase 3.5 not found');
|
||||
const section = text.slice(startIdx, text.indexOf('## Phase 4', startIdx));
|
||||
for (const phase of ['research', 'plan', 'execute', 'review']) {
|
||||
assert.ok(section.includes(phase),
|
||||
`Phase 3.5 missing reference to "${phase}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('trekbrief — Phase 3.5 documents phase_signals_partial force-stop', () => {
|
||||
const text = read();
|
||||
assert.ok(text.includes('phase_signals_partial'),
|
||||
'phase_signals_partial not mentioned in /trekbrief command prose');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC1 tests ---
|
||||
|
||||
test('trekbrief — SC1: resolvePhaseSignal returns non-null for all 4 phases on committed brief (brief-effort-low)', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
|
||||
for (const phase of PHASE_SIGNAL_PHASES) {
|
||||
const r = resolvePhaseSignal(fm, phase);
|
||||
assert.ok(r && typeof r === 'object',
|
||||
`phase=${phase}: resolver must return non-null for committed brief; got ${JSON.stringify(r)}`);
|
||||
assert.ok(typeof r.effort === 'string',
|
||||
`phase=${phase}: resolver result must include effort`);
|
||||
}
|
||||
});
|
||||
|
||||
test('trekbrief — SC1: each of 4 phases has both effort AND model on full-signals fixture', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-with-phase-signals.md'));
|
||||
for (const phase of PHASE_SIGNAL_PHASES) {
|
||||
const r = resolvePhaseSignal(fm, phase);
|
||||
assert.ok(r && typeof r === 'object', `phase=${phase}: must resolve`);
|
||||
assert.ok(EFFORT_LEVELS.includes(r.effort),
|
||||
`phase=${phase}: effort "${r.effort}" not in EFFORT_LEVELS`);
|
||||
if ('model' in r) {
|
||||
assert.ok(BASE_ALLOWED_MODELS.includes(r.model),
|
||||
`phase=${phase}: model "${r.model}" not in [${BASE_ALLOWED_MODELS.join(', ')}]`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('trekbrief — SC1: missing phase_signals + brief_version 2.1 triggers BRIEF_V51_MISSING_SIGNALS', () => {
|
||||
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(
|
||||
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
|
||||
`gate must fire; errors=${JSON.stringify(r.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- v5.9 — fable tier option in the Phase 3.5 loop ---
|
||||
|
||||
test('trekbrief — v5.9 Phase 3.5 canonical mapping contains the fable row and offers 4 options', () => {
|
||||
const text = read();
|
||||
const startIdx = text.indexOf('## Phase 3.5');
|
||||
assert.ok(startIdx >= 0, 'Phase 3.5 not found');
|
||||
const section = text.slice(startIdx, text.indexOf('## Phase 4', startIdx));
|
||||
assert.ok(section.includes('fable → {effort: high, model: fable}'),
|
||||
'Phase 3.5 canonical mapping must contain the fable tier row');
|
||||
assert.ok(section.includes('with 4 options'),
|
||||
'Phase 3.5 loop must offer 4 options (AskUserQuestion maxItems: 4)');
|
||||
});
|
||||
|
||||
// --- v5.5 — framing enforcement + TL;DR + memory-alignment prose-pins ---
|
||||
|
||||
test('trekbrief — v5.5 Phase 2.5 framing declaration heading present', () => {
|
||||
|
|
@ -174,35 +78,3 @@ test('trekbrief — v5.5 template carries framing field, 2.2, and TL;DR section'
|
|||
'template frontmatter must include the framing field');
|
||||
assert.match(tpl, /^## TL;DR$/m, 'template must include the ## TL;DR section');
|
||||
});
|
||||
|
||||
test('trekbrief — SC1: phase_signals_partial: true does NOT trigger the gate', () => {
|
||||
const partial = `---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-05-14
|
||||
task: "Partial brief"
|
||||
slug: partial-brief
|
||||
project_dir: .claude/projects/2026-05-14-partial-brief/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 2
|
||||
source: fixture
|
||||
phase_signals_partial: true
|
||||
---
|
||||
|
||||
# Task
|
||||
|
||||
## Intent
|
||||
Stop early.
|
||||
|
||||
## Goal
|
||||
Test partial mode.
|
||||
|
||||
## Success Criteria
|
||||
- gate does not fire.
|
||||
`;
|
||||
const r = validateBriefContent(partial, { strict: true });
|
||||
assert.equal(r.valid, true, `errors=${JSON.stringify(r.errors)}`);
|
||||
assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -350,10 +350,3 @@ test('trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line
|
|||
assert.doesNotMatch(p05, /After `parseArgs` has resolved/);
|
||||
assert.match(p05, /After the parser has printed/);
|
||||
});
|
||||
|
||||
test('trekcontinue — stats write through stats-append, never silently skipped', () => {
|
||||
const md = readCommand();
|
||||
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekcontinue <<'JSON'/);
|
||||
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
|
||||
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekcontinue-stats\.jsonl/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
// - SC5: every status value the prose instructs the executor to WRITE is in the
|
||||
// validator's lists. Excluded on purpose (their own vocabularies):
|
||||
// plan_verification.status, the session-state / NEXT-SESSION-PROMPT status,
|
||||
// the stats and report `result`, and the tolerant session-orders reader list.
|
||||
// the report `result`, and the tolerant session-orders reader list.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
|
|
|||
|
|
@ -1,84 +1,21 @@
|
|||
// tests/commands/trekexecute.test.mjs
|
||||
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute.
|
||||
// Low effort runs the plan as one session, step after step; it sets no --gates
|
||||
// (fix round 4-5a, #33).
|
||||
// Prose-pin and runtime tests for /trekexecute.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
||||
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
import { sectionText } from '../helpers/md-section.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
|
||||
const PHASE = 'execute';
|
||||
|
||||
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
|
||||
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
|
||||
function frontmatterOf(text) {
|
||||
const doc = parseDocument(text);
|
||||
return doc.parsed && doc.parsed.frontmatter;
|
||||
}
|
||||
|
||||
// --- Pattern D prose-pins ---
|
||||
|
||||
test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
|
||||
const text = read();
|
||||
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
|
||||
'/trekexecute must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
|
||||
assert.ok(text.includes('phase_signals'),
|
||||
'/trekexecute must reference phase_signals (v5.1 composition rule)');
|
||||
});
|
||||
|
||||
test('trekexecute — low-effort path runs one session, step after step, and sets no --gates open', () => {
|
||||
const text = read();
|
||||
const compIdx = text.indexOf('## Composition rule (v5.1)');
|
||||
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
|
||||
const section = text.slice(compIdx, compIdx + 2000);
|
||||
assert.doesNotMatch(section, /--gates open/, 'Low effort must not set --gates open');
|
||||
assert.match(section.replace(/\s+/g, ' '), /`effort == 'low'` runs the plan as one session, step after step \(Phase 2\.5\)/,
|
||||
'Low-effort path must run the plan as one session, step after step');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 ---
|
||||
|
||||
test('trekexecute — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'low');
|
||||
assert.equal(r.model, 'sonnet');
|
||||
});
|
||||
|
||||
test('trekexecute — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'standard');
|
||||
assert.equal(r.model, undefined);
|
||||
});
|
||||
|
||||
test('trekexecute — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'high');
|
||||
assert.equal(r.model, 'opus');
|
||||
});
|
||||
|
||||
test('trekexecute — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
|
||||
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(
|
||||
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
|
||||
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- S38 loop-discipline hardening: completion gate (Step 2) ---
|
||||
|
||||
|
|
@ -201,9 +138,9 @@ test('trekexecute Phase 8: the state-file block imports through the plugin root
|
|||
}
|
||||
});
|
||||
|
||||
// --- Phase 1 --project: the brief check and the execute resolver -------------
|
||||
// Phase 1's --project branch runs the two lines /trekplan runs, for the execute
|
||||
// phase, and halts only on BRIEF_V51_MISSING_SIGNALS.
|
||||
// --- Phase 1 --project: the brief check -------------------------------------
|
||||
// Phase 1's --project branch runs the brief-validator line /trekplan runs, and
|
||||
// never halts on what it reports.
|
||||
|
||||
/** Phase 1's rule 1 (the --project branch), as lines. */
|
||||
function projectRule() {
|
||||
|
|
@ -215,35 +152,25 @@ function projectRule() {
|
|||
return sec.slice(start, end).join('\n');
|
||||
}
|
||||
|
||||
test('trekexecute Phase 1 --project runs brief-validator --soft --json and the execute resolver, and halts only on BRIEF_V51_MISSING_SIGNALS', () => {
|
||||
test('trekexecute Phase 1 --project runs brief-validator --soft --json and never halts on its errors', () => {
|
||||
const rule = projectRule();
|
||||
const validator = libLine(rule, 'validators/brief-validator.mjs', '--soft --json');
|
||||
const resolver = libLine(rule, 'profiles/resolver.mjs', '--resolve-phase-model --phase execute');
|
||||
assert.equal(validator, 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"');
|
||||
assert.equal(resolver,
|
||||
'node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json');
|
||||
const f = flat(rule);
|
||||
for (const s of [
|
||||
'The validator exits 1 when its `errors` are not empty; read its JSON either way.',
|
||||
'Halt only when the validator\'s `errors` hold `BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate surface" below.',
|
||||
'Any other error is printed in one line and the run continues.',
|
||||
'A validator or resolver that cannot run (no JSON) is "not measured" in one line, and the run continues.',
|
||||
'Capture the resolver\'s JSON as `phase_signal_result` (Composition rule below).',
|
||||
'No `{dir}/brief.md`: skip both lines.',
|
||||
'Any error is printed in one line and the run continues.',
|
||||
'A validator that cannot run (no JSON) is "not measured" in one line, and the run continues.',
|
||||
'No `{dir}/brief.md`: skip the line.',
|
||||
]) assert.ok(f.includes(s), `the --project rule is missing: ${s}`);
|
||||
|
||||
// The two lines run as printed: the validator names the one halting code for a
|
||||
// 2.1 brief without signals, and the resolver hands Phase 2.5 its low effort.
|
||||
// The line runs as printed: a brief with an error exits 1 and still prints JSON.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'trekexecute-project-'));
|
||||
try {
|
||||
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-v21-no-signals.md'), join(dir, 'brief.md'));
|
||||
writeFileSync(join(dir, 'brief.md'), '---\ntype: trekbrief\n---\n\n# Task\n');
|
||||
const v = bash(validator.split('{dir}').join(dir), ROOT);
|
||||
assert.equal(v.status, 1, `validator: ${v.stdout}${v.stderr}`);
|
||||
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_V51_MISSING_SIGNALS'), v.stdout);
|
||||
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-effort-low.md'), join(dir, 'brief.md'));
|
||||
const r = bash(resolver.split('{dir}').join(dir).replace(' [--profile {profile}]', ''), ROOT);
|
||||
assert.equal(r.status, 0, `resolver: ${r.stdout}${r.stderr}`);
|
||||
assert.equal(JSON.parse(r.stdout).effort, 'low');
|
||||
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_MISSING_FIELD'), v.stdout);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
|
@ -362,36 +289,9 @@ test('trekexecute merge lock: Phase 7.2 step 4 run on a BLOCK review of an older
|
|||
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
|
||||
});
|
||||
|
||||
// --- The resolver runs in Phase 1; low effort is one session; no gates escalation
|
||||
// (fix round 4-5a, #33).
|
||||
// --- No gates escalation (fix round 4-5a, #33).
|
||||
|
||||
test('[#33] the Composition rule says the resolver runs in Phase 1, and no line says Phase 2.4 runs it', () => {
|
||||
assert.doesNotMatch(flat(read()), /invoked in Phase 2\.4/);
|
||||
assert.ok(flat(sectionText(read(), '## Composition rule (v5.1)')).includes('invoked in Phase 1 alongside the sequencing-gate brief-validator call'),
|
||||
'the Composition rule does not say the resolver is invoked in Phase 1');
|
||||
});
|
||||
|
||||
test('[#33] sibling: Composition rule has no "uniformity and stats" and no gates auto-escalation', () => {
|
||||
const f = flat(sectionText(read(), '## Composition rule (v5.1)'));
|
||||
assert.doesNotMatch(f, /uniformity and stats/, 'the Composition rule still says the model is returned for stats');
|
||||
assert.ok(f.includes('it is returned for cross-command uniformity; no stats record carries it.'),
|
||||
'the Composition rule does not say no stats record carries the model');
|
||||
assert.doesNotMatch(f, /gates auto-escalation/);
|
||||
});
|
||||
|
||||
test('[#33] Phase 2.5 names low effort as one session, steps in order, never while a wave is in flight', () => {
|
||||
const sec = sectionText(read(), '## Phase 2.5 — Execution strategy decision').split('\n');
|
||||
const start = sec.findIndex((l) => l.startsWith('**Run as single session (foreground)** when ANY of these are true:'));
|
||||
assert.ok(start !== -1, 'Phase 2.5 must still list when it runs as a single session');
|
||||
const end = sec.findIndex((l, i) => i > start && l.trim() === '');
|
||||
const bullets = sec.slice(start + 1, end);
|
||||
assert.ok(bullets.includes("- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when "
|
||||
+ '`.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), '
|
||||
+ 'this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.'),
|
||||
`the single-session list does not name low effort: ${bullets.join(' | ')}`);
|
||||
});
|
||||
|
||||
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left, and high effort says it changes nothing', () => {
|
||||
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left', () => {
|
||||
const text = read();
|
||||
const f = flat(text);
|
||||
assert.doesNotMatch(f, /gates_mode = 'closed'/);
|
||||
|
|
@ -399,9 +299,6 @@ test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_o
|
|||
assert.doesNotMatch(f, /halts immediately/);
|
||||
assert.doesNotMatch(text, /--event gates_mode_flag_override/);
|
||||
assert.doesNotMatch(f, /gates_mode = '(adaptive|open)'/);
|
||||
assert.equal(flat(sectionText(text, '### High-effort behavior (v5.1.1)')),
|
||||
'### High-effort behavior (v5.1.1) High effort changes nothing in `/trekexecute`: the step helper and the controller '
|
||||
+ 'already run opus, and every gate already runs. No `gates_mode` is set from the effort.');
|
||||
});
|
||||
|
||||
// --- Phase 8 writes the merge evidence file before the merge call (4.5b2, MEV) --
|
||||
|
|
|
|||
|
|
@ -1,76 +1,17 @@
|
|||
// tests/commands/trekplan.test.mjs
|
||||
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekplan.
|
||||
// Prose-pin tests for /trekplan.
|
||||
|
||||
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';
|
||||
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
||||
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekplan.md');
|
||||
const PHASE = 'plan';
|
||||
|
||||
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
|
||||
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
|
||||
function frontmatterOf(text) {
|
||||
const doc = parseDocument(text);
|
||||
return doc.parsed && doc.parsed.frontmatter;
|
||||
}
|
||||
|
||||
// --- Pattern D prose-pins (kept) ---
|
||||
|
||||
test('trekplan — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
|
||||
const text = read();
|
||||
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
|
||||
'/trekplan must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
|
||||
assert.ok(text.includes('phase_signals'),
|
||||
'/trekplan must reference phase_signals (v5.1 composition rule)');
|
||||
});
|
||||
|
||||
test('trekplan — low-effort path references --quick equivalent', () => {
|
||||
const text = read();
|
||||
const compIdx = text.indexOf('## Composition rule (v5.1)');
|
||||
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
|
||||
const section = text.slice(compIdx, compIdx + 2000);
|
||||
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 tests ---
|
||||
|
||||
test('trekplan — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'low');
|
||||
assert.equal(r.model, 'sonnet');
|
||||
});
|
||||
|
||||
test('trekplan — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'standard');
|
||||
assert.equal(r.model, undefined);
|
||||
});
|
||||
|
||||
test('trekplan — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'high');
|
||||
assert.equal(r.model, 'opus');
|
||||
});
|
||||
|
||||
test('trekplan — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
|
||||
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(
|
||||
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
|
||||
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- S31 / V15: export trim — drop pr|issue|markdown, keep headless as a --decompose alias ---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
// tests/commands/trekresearch-engine.test.mjs
|
||||
// Step 1 (deep-research-engine): pin the contract the `--engine deep-research`
|
||||
// adapter must hit. The adapted in-context `/deep-research` report, reduced into
|
||||
// the research-brief schema, must pass research-validator under the strict
|
||||
// default; and a brief missing a required section must fail. This is the one
|
||||
// genuinely automatable slice of SC2 (schema, not provenance).
|
||||
|
||||
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';
|
||||
import { validateResearchContent } from '../../lib/validators/research-validator.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const FIXTURE = join(ROOT, 'tests', 'fixtures', 'research-deep-research-adapted.md');
|
||||
|
||||
test('deep-research adapter output contract — valid brief passes, missing section fails', () => {
|
||||
const text = readFileSync(FIXTURE, 'utf-8');
|
||||
|
||||
// (a) positive: the adapter's target output passes the validator (default = strict).
|
||||
const okResult = validateResearchContent(text);
|
||||
assert.equal(okResult.valid, true, JSON.stringify(okResult.errors));
|
||||
|
||||
// (b) negative: stripping a required section makes it fail with RESEARCH_MISSING_SECTION,
|
||||
// giving the contract teeth (a fixture that always passes proves nothing).
|
||||
const mutated = text.replace('## Dimensions', '## Removed');
|
||||
const badResult = validateResearchContent(mutated);
|
||||
assert.equal(badResult.valid, false);
|
||||
assert.ok(
|
||||
badResult.errors.find(e => e.code === 'RESEARCH_MISSING_SECTION'),
|
||||
'expected RESEARCH_MISSING_SECTION; got ' + JSON.stringify(badResult.errors),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,63 +1,21 @@
|
|||
// tests/commands/trekresearch.test.mjs
|
||||
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekresearch.
|
||||
// Prose-pin tests for /trekresearch.
|
||||
|
||||
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';
|
||||
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
||||
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekresearch.md');
|
||||
const PHASE = 'research';
|
||||
|
||||
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
|
||||
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
|
||||
function frontmatterOf(text) {
|
||||
const doc = parseDocument(text);
|
||||
return doc.parsed && doc.parsed.frontmatter;
|
||||
}
|
||||
|
||||
// --- Pattern D prose-pins ---
|
||||
// --- Phase 5 slice (heading-bounded) ---
|
||||
|
||||
test('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
|
||||
const text = read();
|
||||
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
|
||||
'/trekresearch must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
|
||||
assert.ok(text.includes('phase_signals'),
|
||||
'/trekresearch must reference phase_signals (v5.1 composition rule)');
|
||||
});
|
||||
|
||||
test('trekresearch — low-effort path references --quick equivalent', () => {
|
||||
const text = read();
|
||||
// Bound the Composition rule section by the next `###` heading rather than a
|
||||
// magic 2000-character window: a fixed count silently drops the match as soon
|
||||
// as prose is inserted above it, turning a real pin into a no-op.
|
||||
const sectionOf = (doc) => {
|
||||
const compIdx = doc.indexOf('## Composition rule (v5.1)');
|
||||
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
|
||||
const nextHeading = doc.indexOf('\n### ', compIdx);
|
||||
return nextHeading > compIdx ? doc.slice(compIdx, nextHeading) : doc.slice(compIdx);
|
||||
};
|
||||
|
||||
// (a) positive: the low-effort path is documented inside the bounded section.
|
||||
assert.match(sectionOf(text), /--quick/, 'Low-effort path must mention --quick equivalent');
|
||||
|
||||
// (b) negative: an actual removal must still be caught — a bound that can
|
||||
// never fail proves nothing.
|
||||
const mutated = text.replace(/--quick/g, '--removed');
|
||||
assert.doesNotMatch(sectionOf(mutated), /--quick/,
|
||||
'heading-bounded slice must still fail on a genuine removal');
|
||||
});
|
||||
|
||||
// --- Step 7: Phase 5 bounded conversation loop (heading-bounded slices) ---
|
||||
|
||||
// Same bounding discipline as the Composition-rule pin above: slice from the
|
||||
// phase heading to the NEXT phase heading, never a fixed character window.
|
||||
// Slice from the phase heading to the NEXT phase heading, never a fixed character window.
|
||||
function phaseSlice(doc, startHeading, endHeading) {
|
||||
const start = doc.indexOf(startHeading);
|
||||
assert.ok(start >= 0, `${startHeading} missing`);
|
||||
|
|
@ -70,250 +28,19 @@ function phase5(doc) {
|
|||
return phaseSlice(doc, '## Phase 5 —', '## Phase 6 —');
|
||||
}
|
||||
|
||||
test('trekresearch — Phase 5 loop is gated on effort == high and names both primitives', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /effort == 'high'/, 'Phase 5 loop must be gated on effort == \'high\'');
|
||||
assert.match(p5, /research-loop-cap\.mjs/, 'Phase 5 must call the loop-cap shim per turn');
|
||||
assert.match(p5, /query-privacy-gate\.mjs/, 'Phase 5 must route outbound queries through the privacy gate');
|
||||
assert.match(p5, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'shim invocations must use the ${CLAUDE_PLUGIN_ROOT} path form');
|
||||
});
|
||||
|
||||
// CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are substituted in this command's
|
||||
// TEXT but are EMPTY in the Bash tool's process env. Every snippet below runs
|
||||
// in that env, so each needs a resolution that does not depend on it.
|
||||
test('trekresearch — the scope-marker snippets resolve a root instead of requiring CLAUDE_PLUGIN_DATA', () => {
|
||||
const p5 = phase5(read());
|
||||
const blocks = [...p5.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
const write = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('printf'));
|
||||
const remove = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('rm -f'));
|
||||
assert.ok(write, 'Phase 5 must carry the scope-marker write snippet');
|
||||
assert.ok(remove, 'Phase 5 must carry the scope-marker removal snippet');
|
||||
|
||||
for (const [name, block] of [['write', write], ['remove', remove]]) {
|
||||
assert.match(
|
||||
block,
|
||||
/\$\{CLAUDE_PLUGIN_DATA:-\$HOME\/\.claude\/voyage\}/,
|
||||
`the ${name} snippet must fall back to the same root research-loop-cap.mjs resolves`,
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/case .* in\s*\n?\s*\/\*\)/,
|
||||
`the ${name} snippet must guard on ONE absolute-path test — write and remove must not disagree on what counts as usable`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unset, ${CLAUDE_CODE_SESSION_ID} composes a marker named `.json`, which no
|
||||
// hook lookup and no TTL sweep ever matches or cleans up.
|
||||
assert.match(
|
||||
write,
|
||||
/-n "\$\{?CLAUDE_CODE_SESSION_ID/,
|
||||
'the write snippet must require a non-empty CLAUDE_CODE_SESSION_ID before composing the marker path',
|
||||
test('trekresearch — Phase 4 runs the full swarm with contrarian-researcher as wave 2 every time (decision 209)', () => {
|
||||
const flatText = read().replace(/\s+/g, ' ');
|
||||
assert.ok(
|
||||
flatText.includes('Every run launches the FULL swarm for its scope: all 5 local agents when the scope includes local, and all 4 external agents when it includes external. `contrarian-researcher` is one of the external four and runs every time the scope includes external, as wave 2 (see Launch rules)'),
|
||||
'Phase 4 must state the one fixed path: the full swarm, contrarian-researcher as wave 2 every run',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — the per-turn gates separate "gate could not run" from "gate says no"', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(
|
||||
p5,
|
||||
/VOYAGE_ROOT/,
|
||||
'the gate snippet must resolve a plugin root rather than interpolating ${CLAUDE_PLUGIN_ROOT} straight into `node`',
|
||||
);
|
||||
assert.match(
|
||||
p5,
|
||||
/exit 2|could not run/i,
|
||||
'an unresolvable gate must be distinguishable from a denial — otherwise every query reads as a privacy violation no rewrite can clear',
|
||||
);
|
||||
assert.match(
|
||||
p5,
|
||||
/plugins\/cache/,
|
||||
'the fallback must name the plugin cache location it searches',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 declares the loop bound and all three exits', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading');
|
||||
assert.match(
|
||||
p5,
|
||||
/\*\*Maximum 3 turns per under-illuminated dimension\.\*\*/,
|
||||
'the bound must be stated verbatim',
|
||||
);
|
||||
// Three exits — converged / cap exhausted / operator stop.
|
||||
assert.match(p5, /converged/i, 'exit 1 (converged) must be documented');
|
||||
assert.match(p5, /exhaust/i, 'exit 2 (cap exhausted) must be documented');
|
||||
assert.match(p5, /operator stop/i, 'exit 3 (operator stop) must be documented');
|
||||
// Exhaustion must reach the operator — a silent cap is indistinguishable
|
||||
// from convergence, which is the failure this loop exists to avoid.
|
||||
assert.match(
|
||||
p5,
|
||||
/visibl|visible|print/i,
|
||||
'cap exhaustion must be written visibly to the operator',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 marks empty turns without re-targeting the same dimension', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /`empty`/, 'a finding-less or citation-less turn must be marked `empty`');
|
||||
assert.match(p5, /empty_turns/, 'empty turns must be counted (empty_turns)');
|
||||
assert.match(
|
||||
p5,
|
||||
/does NOT re-target|not re-target/i,
|
||||
'an empty turn must not re-target the same dimension',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 states the no-brief default and the moot precedence matrix', () => {
|
||||
const p5 = phase5(read());
|
||||
// (a) no-brief default
|
||||
assert.match(p5, /effort = 'standard'/, 'no-brief default effort must be stated');
|
||||
assert.match(
|
||||
p5,
|
||||
/--project/,
|
||||
'the no-brief default must be anchored to the absence of --project/brief.md',
|
||||
);
|
||||
// (b) precedence matrix — each entry independently makes the loop moot,
|
||||
// mirroring the --engine moot gate in Phase 4.
|
||||
for (const token of ['--quick', '--local', 'external_research_enabled']) {
|
||||
assert.ok(p5.includes(token), `moot matrix must name ${token}`);
|
||||
}
|
||||
assert.match(p5, /moot/i, 'the matrix must use the same moot vocabulary as the engine gate');
|
||||
// (c) interaction rule — effort: high without model under a cheap profile.
|
||||
assert.match(
|
||||
p5,
|
||||
/effort: high/,
|
||||
'the interaction rule for a brief carrying effort: high without model must be stated',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 restates the honesty rule for loop output', () => {
|
||||
const p5 = phase5(read());
|
||||
// Whitespace-tolerant: the pin is on the sentence, not on where the
|
||||
// paragraph happens to wrap.
|
||||
assert.match(
|
||||
p5,
|
||||
/more\s+turns\s+do\s+not\s+make\s+a\s+finding\s+more\s+credible/i,
|
||||
'the honesty hard rule must be restated for the loop output',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 pins survive only while the prose does (mutation control)', () => {
|
||||
const text = read();
|
||||
const mutated = text.replace(/research-loop-cap\.mjs/g, 'removed-cap.mjs');
|
||||
assert.doesNotMatch(
|
||||
phase5(mutated),
|
||||
/research-loop-cap\.mjs/,
|
||||
'heading-bounded Phase 5 slice must still fail on a genuine removal',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
|
||||
const text = read();
|
||||
const flatText = text.replace(/\s+/g, ' ');
|
||||
assert.ok(
|
||||
!flatText.includes('use the existing conditional triggers'),
|
||||
'standard effort must not point at a conditional trigger that nothing defines',
|
||||
);
|
||||
assert.ok(
|
||||
flatText.includes('Standard effort (or absent): the selected agents run as in Phase 4, and `contrarian-researcher`, when selected, runs as wave 2 after the other agents return (see Launch rules).'),
|
||||
'the standard-effort sentence must say the contrarian runs as wave 2 when selected',
|
||||
);
|
||||
assert.ok(
|
||||
text.includes('Low effort: inline research only, no agent swarm'),
|
||||
'the low-effort sentence must survive the Phase 5 rewrite verbatim',
|
||||
'no effort must point at a conditional trigger that nothing defines',
|
||||
);
|
||||
});
|
||||
|
||||
// --- Step 8: Phase 4.5 dimension discovery + Independence amendment ---
|
||||
|
||||
test('trekresearch — Phase 4.5 exists between Phase 4 and Phase 5 with the effort skip-guard', () => {
|
||||
const text = read();
|
||||
const p45 = text.indexOf('## Phase 4.5 —');
|
||||
assert.ok(p45 >= 0, 'Phase 4.5 heading missing');
|
||||
const p4 = text.indexOf('## Phase 4 —');
|
||||
const p5 = text.indexOf('## Phase 5 —');
|
||||
assert.ok(p4 >= 0 && p5 > p45 && p45 > p4, 'Phase 4.5 must sit between Phase 4 and Phase 5');
|
||||
|
||||
const slice = text.slice(p45, p5);
|
||||
assert.match(
|
||||
slice,
|
||||
/\*\*Skip this phase entirely unless `phase_signal_result\.effort == 'high'`/,
|
||||
'Phase 4.5 must carry the bolded skip-guard in the Phase 3.5 form',
|
||||
);
|
||||
assert.match(slice, /query-privacy-gate\.mjs/,
|
||||
'Phase 4.5 must name the privacy gate as its compensating control');
|
||||
assert.match(slice, /maxDimensions: 8|maxDimensions` *: *8/,
|
||||
'Phase 4.5 must augment under the existing maxDimensions ceiling, not raise it');
|
||||
});
|
||||
|
||||
// Phase 4.5 never invokes research-loop-cap.mjs, so the cap's own flag check
|
||||
// does not reach it. Gating on effort alone means unsetting VOYAGE_STORM_ENABLED
|
||||
// leaves half the mechanism live and the decline branch unreachable — while
|
||||
// CLAUDE.md claims both phases go inert. The guard has to name both conditions.
|
||||
test('trekresearch — Phase 4.5 skip-guard is gated on VOYAGE_STORM_ENABLED as well as effort', () => {
|
||||
const text = read();
|
||||
const p45 = text.indexOf('## Phase 4.5 —');
|
||||
const p5 = text.indexOf('## Phase 5 —');
|
||||
const slice = text.slice(p45, p5);
|
||||
|
||||
const guard = slice.slice(0, slice.indexOf('\n\n', slice.indexOf('**Skip this phase')));
|
||||
assert.match(guard, /VOYAGE_STORM_ENABLED/,
|
||||
'the Phase 4.5 skip-guard must name VOYAGE_STORM_ENABLED, not effort alone');
|
||||
assert.match(guard, /\bAND\b|\*\*and\*\*/,
|
||||
'the guard must be a conjunction — both conditions, not either');
|
||||
assert.match(slice, /decline/i,
|
||||
'Phase 4.5 must say why the flag gates it: the decline branch has to stay reachable');
|
||||
});
|
||||
|
||||
test('trekresearch — Independence hard rule carries an explicit Phase 4.5 amendment', () => {
|
||||
const text = read();
|
||||
const rulesIdx = text.indexOf('## Hard rules');
|
||||
assert.ok(rulesIdx >= 0, 'Hard rules section missing');
|
||||
const rules = text.slice(rulesIdx);
|
||||
const indIdx = rules.indexOf('**Independence:**');
|
||||
assert.ok(indIdx >= 0, 'Independence hard rule missing');
|
||||
// Bound the rule at the next bullet so the amendment must live inside it.
|
||||
const nextBullet = rules.indexOf('\n- **', indIdx);
|
||||
const independence = nextBullet > indIdx ? rules.slice(indIdx, nextBullet) : rules.slice(indIdx);
|
||||
assert.match(independence, /Amend(ed|ment)/i,
|
||||
'Independence must be explicitly amended, not silently contradicted');
|
||||
assert.match(independence, /Phase 4\.5/, 'the amendment must name Phase 4.5 as the crossing');
|
||||
assert.match(independence, /query-privacy-gate\.mjs/,
|
||||
'the amendment must name the compensating control');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 ---
|
||||
|
||||
test('trekresearch — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'low');
|
||||
assert.equal(r.model, 'sonnet');
|
||||
});
|
||||
|
||||
test('trekresearch — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'standard');
|
||||
assert.equal(r.model, undefined);
|
||||
});
|
||||
|
||||
test('trekresearch — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'high');
|
||||
assert.equal(r.model, 'opus');
|
||||
});
|
||||
|
||||
test('trekresearch — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
|
||||
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(
|
||||
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
|
||||
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Spawn model, turn budget and stats writer ---
|
||||
// --- Spawn model ---
|
||||
|
||||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
|
@ -328,23 +55,14 @@ function paragraphAt(text, marker) {
|
|||
return flat(lines.slice(start, end).join(' '));
|
||||
}
|
||||
|
||||
test('trekresearch — Phase 5 follow-ups take phase_signal_result.model, else opus', () => {
|
||||
test('trekresearch — Phase 5 follow-ups: a built-in agent is spawned with model opus, at most 2', () => {
|
||||
const text = read();
|
||||
assert.doesNotMatch(flat(phase5(text)), /model: "opus"/);
|
||||
const part = paragraphAt(text, 'launch a targeted follow-up agent');
|
||||
assert.match(part, /launch a targeted follow-up agent \(model: phase_signal_result\.model, else opus — a follow-up may be one of Claude Code's built-in agents, which have no Voyage frontmatter to fall back to; see Launch rules\)/);
|
||||
assert.match(flat(text), /captured as `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject the resolved model/);
|
||||
const part = paragraphAt(phase5(text), 'launch a targeted follow-up agent');
|
||||
assert.match(part, /Maximum 2 follow-ups\./);
|
||||
assert.ok(part.includes("A follow-up may be one of Claude Code's built-in agents: Spawn it with `model: \"opus\"`: a built-in agent has no Voyage pin."), part);
|
||||
});
|
||||
|
||||
test('trekresearch — promises no maxTurns budget', () => {
|
||||
const text = read();
|
||||
assert.doesNotMatch(text, /maxTurns/);
|
||||
assert.match(text, /- convention-scanner: medium\+ codebases only \(50\+ files\)/);
|
||||
});
|
||||
|
||||
test('trekresearch — stats write through stats-append, never silently skipped', () => {
|
||||
const md = read();
|
||||
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekresearch <<'JSON'/);
|
||||
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
|
||||
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekresearch-stats\.jsonl/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
// tests/commands/trekreview.test.mjs
|
||||
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekreview.
|
||||
|
||||
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';
|
||||
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
|
||||
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
|
||||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekreview.md');
|
||||
const PHASE = 'review';
|
||||
|
||||
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
|
||||
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
|
||||
function frontmatterOf(text) {
|
||||
const doc = parseDocument(text);
|
||||
return doc.parsed && doc.parsed.frontmatter;
|
||||
}
|
||||
|
||||
// --- Pattern D prose-pins ---
|
||||
|
||||
test('trekreview — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
|
||||
const text = read();
|
||||
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
|
||||
'/trekreview must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
|
||||
assert.ok(text.includes('phase_signals'),
|
||||
'/trekreview must reference phase_signals (v5.1 composition rule)');
|
||||
});
|
||||
|
||||
test('trekreview — low-effort path references --quick equivalent', () => {
|
||||
const text = read();
|
||||
const compIdx = text.indexOf('## Composition rule (v5.1)');
|
||||
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
|
||||
const section = text.slice(compIdx, compIdx + 2000);
|
||||
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 ---
|
||||
|
||||
test('trekreview — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'low');
|
||||
assert.equal(r.model, 'sonnet');
|
||||
});
|
||||
|
||||
test('trekreview — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'standard');
|
||||
assert.equal(r.model, undefined);
|
||||
});
|
||||
|
||||
test('trekreview — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
|
||||
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
|
||||
const r = resolvePhaseSignal(fm, PHASE);
|
||||
assert.equal(r.effort, 'high');
|
||||
assert.equal(r.model, 'opus');
|
||||
});
|
||||
|
||||
test('trekreview — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
|
||||
// Falsification via brief-v21-no-signals fixture: validator must catch missing signals.
|
||||
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(
|
||||
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
|
||||
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('trekreview — stats write through stats-append, never silently skipped', () => {
|
||||
const md = read();
|
||||
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekreview <<'JSON'/);
|
||||
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
|
||||
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekreview-stats\.jsonl/);
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
[
|
||||
{
|
||||
"reviewer": "brief-conformance-reviewer",
|
||||
"findings": [
|
||||
{ "severity": "BLOCKER", "rule_key": "UNIMPLEMENTED_CRITERION", "file": "lib/handlers/login.mjs", "line": 17 },
|
||||
{ "severity": "MAJOR", "rule_key": "PLAN_EXECUTE_DRIFT", "file": "lib/handlers/login.mjs", "line": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"reviewer": "code-correctness-reviewer",
|
||||
"findings": [
|
||||
{ "severity": "BLOCKER", "rule_key": "SECURITY_INJECTION", "file": "lib/auth/jwt.mjs", "line": 19 },
|
||||
{ "severity": "MAJOR", "rule_key": "MISSING_TEST", "file": "lib/auth/refresh.mjs", "line": 0 },
|
||||
{ "severity": "MINOR", "rule_key": "MISSING_ERROR_HANDLING", "file": "lib/auth/refresh.mjs", "line": 10 }
|
||||
]
|
||||
}
|
||||
]
|
||||
45
tests/fixtures/brief-effort-fable.md
vendored
45
tests/fixtures/brief-effort-fable.md
vendored
|
|
@ -1,45 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-07-02
|
||||
task: "Fixture: high-effort all phases on fable (v5.9 allowlist test)"
|
||||
slug: brief-effort-fable
|
||||
project_dir: .claude/projects/2026-07-02-brief-effort-fable/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 4
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: high
|
||||
model: fable
|
||||
- phase: plan
|
||||
effort: high
|
||||
model: fable
|
||||
- phase: execute
|
||||
effort: high
|
||||
model: fable
|
||||
- phase: review
|
||||
effort: high
|
||||
model: fable
|
||||
---
|
||||
|
||||
# Task: High-effort fable fixture
|
||||
|
||||
## Intent
|
||||
|
||||
Test fixture for the v5.9 fable model tier — all 4 phases at the
|
||||
high effort tier with explicit fable model overrides. Mirrors
|
||||
brief-effort-high.md with `model: opus` replaced by `model: fable`.
|
||||
|
||||
## Goal
|
||||
|
||||
Resolver returns `{effort: 'high', model: 'fable'}` for each of the 4
|
||||
PHASE_SIGNAL_PHASES.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Validator passes with no BRIEF_INVALID_MODEL.
|
||||
- resolvePhaseSignal(fm, phase).effort === 'high' for all 4 phases.
|
||||
- resolvePhaseSignal(fm, phase).model === 'fable' for all 4 phases.
|
||||
45
tests/fixtures/brief-effort-high.md
vendored
45
tests/fixtures/brief-effort-high.md
vendored
|
|
@ -1,45 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-05-14
|
||||
task: "Fixture: high-effort all phases (v5.1.1 runtime test)"
|
||||
slug: brief-effort-high
|
||||
project_dir: .claude/projects/2026-05-14-brief-effort-high/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 4
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: high
|
||||
model: opus
|
||||
- phase: plan
|
||||
effort: high
|
||||
model: opus
|
||||
- phase: execute
|
||||
effort: high
|
||||
model: opus
|
||||
- phase: review
|
||||
effort: high
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Task: High-effort fixture
|
||||
|
||||
## Intent
|
||||
|
||||
Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the
|
||||
high effort tier with explicit opus model overrides. Mirrors the
|
||||
production-grade premium-profile scenario.
|
||||
|
||||
## Goal
|
||||
|
||||
Resolver returns `{effort: 'high', model: 'opus'}` for each of the 4
|
||||
PHASE_SIGNAL_PHASES.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Validator passes.
|
||||
- resolvePhaseSignal(fm, phase).effort === 'high' for all 4 phases.
|
||||
- resolvePhaseSignal(fm, phase).model === 'opus' for all 4 phases.
|
||||
43
tests/fixtures/brief-effort-low.md
vendored
43
tests/fixtures/brief-effort-low.md
vendored
|
|
@ -1,43 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-05-14
|
||||
task: "Fixture: low-effort all phases (v5.1.1 runtime test)"
|
||||
slug: brief-effort-low
|
||||
project_dir: .claude/projects/2026-05-14-brief-effort-low/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 4
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: low
|
||||
model: sonnet
|
||||
- phase: plan
|
||||
effort: low
|
||||
model: sonnet
|
||||
- phase: execute
|
||||
effort: low
|
||||
model: sonnet
|
||||
- phase: review
|
||||
effort: low
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Task: Low-effort fixture
|
||||
|
||||
## Intent
|
||||
|
||||
Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the lowest
|
||||
effort tier with explicit sonnet model overrides.
|
||||
|
||||
## Goal
|
||||
|
||||
Resolver returns `{effort: 'low', model: 'sonnet'}` for each of the 4
|
||||
PHASE_SIGNAL_PHASES.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Validator passes.
|
||||
- resolvePhaseSignal(fm, phase) is non-null for all 4 phases.
|
||||
42
tests/fixtures/brief-effort-standard.md
vendored
42
tests/fixtures/brief-effort-standard.md
vendored
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-05-14
|
||||
task: "Fixture: standard-effort all phases, no model (v5.1.1 runtime test)"
|
||||
slug: brief-effort-standard
|
||||
project_dir: .claude/projects/2026-05-14-brief-effort-standard/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 4
|
||||
source: fixture
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: standard
|
||||
- phase: plan
|
||||
effort: standard
|
||||
- phase: execute
|
||||
effort: standard
|
||||
- phase: review
|
||||
effort: standard
|
||||
---
|
||||
|
||||
# Task: Standard-effort fixture (no model override)
|
||||
|
||||
## Intent
|
||||
|
||||
Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the
|
||||
standard tier WITHOUT explicit model fields. This is the operator-skipped
|
||||
model path that should fall through to the profile.
|
||||
|
||||
## Goal
|
||||
|
||||
Resolver returns `{effort: 'standard', model: undefined}` for each of the 4
|
||||
PHASE_SIGNAL_PHASES. The orchestrator-model path then falls through to the
|
||||
active profile's phase_models.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Validator passes.
|
||||
- resolvePhaseSignal(fm, phase).model is undefined.
|
||||
- resolvePhaseSignal(fm, phase).effort is 'standard'.
|
||||
32
tests/fixtures/brief-v21-no-signals.md
vendored
32
tests/fixtures/brief-v21-no-signals.md
vendored
|
|
@ -1,32 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-05-14
|
||||
task: "Fixture: v5.1 brief WITHOUT phase_signals or partial (falsification target)"
|
||||
slug: brief-v21-no-signals
|
||||
project_dir: .claude/projects/2026-05-14-brief-v21-no-signals/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 4
|
||||
source: fixture
|
||||
---
|
||||
|
||||
# Task: brief_version 2.1 without phase_signals
|
||||
|
||||
## Intent
|
||||
|
||||
Falsification fixture for the v5.1 sequencing gate. The brief declares
|
||||
`brief_version: "2.1"` but omits BOTH `phase_signals` AND
|
||||
`phase_signals_partial: true`. The brief-validator MUST emit
|
||||
`BRIEF_V51_MISSING_SIGNALS` for this file — the runtime test for the
|
||||
sequencing gate asserts the error code fires.
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that brief-validator catches the missing-signals scenario.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- brief-validator returns valid: false.
|
||||
- errors contains BRIEF_V51_MISSING_SIGNALS.
|
||||
42
tests/fixtures/brief-with-phase-signals.md
vendored
42
tests/fixtures/brief-with-phase-signals.md
vendored
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.1"
|
||||
created: 2026-05-13
|
||||
task: "Add per-phase effort dialog to /trekbrief"
|
||||
slug: phase-signals-example
|
||||
project_dir: .claude/projects/2026-05-13-phase-signals-example/
|
||||
research_topics: 2
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 6
|
||||
source: interview
|
||||
phase_signals:
|
||||
- phase: research
|
||||
effort: low
|
||||
model: sonnet
|
||||
- phase: plan
|
||||
effort: standard
|
||||
- phase: execute
|
||||
effort: high
|
||||
model: opus
|
||||
- phase: review
|
||||
effort: standard
|
||||
---
|
||||
|
||||
# Task: Phase-signals example
|
||||
|
||||
## Intent
|
||||
|
||||
A minimal brief that exercises the v5.1 phase_signals additive field with a
|
||||
mix of effort levels and model overrides. Used by tests/validators to confirm
|
||||
the validator accepts well-formed signals across the supported tier matrix.
|
||||
|
||||
## Goal
|
||||
|
||||
Validator returns valid: true. annotate.mjs strips phase_signals from the
|
||||
rendered HTML body (frontmatter stays in source).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Validator passes.
|
||||
- annotate.mjs determinism: re-run produces byte-identical HTML.
|
||||
31
tests/fixtures/brief-without-phase-signals.md
vendored
31
tests/fixtures/brief-without-phase-signals.md
vendored
|
|
@ -1,31 +0,0 @@
|
|||
---
|
||||
type: trekbrief
|
||||
brief_version: "2.0"
|
||||
created: 2026-05-13
|
||||
task: "Backward-compat fixture for v5.0-style brief"
|
||||
slug: legacy-brief-example
|
||||
project_dir: .claude/projects/2026-05-13-legacy-brief-example/
|
||||
research_topics: 0
|
||||
research_status: complete
|
||||
auto_research: false
|
||||
interview_turns: 3
|
||||
source: interview
|
||||
---
|
||||
|
||||
# Task: Legacy brief example
|
||||
|
||||
## Intent
|
||||
|
||||
A pre-v5.1 brief that pre-dates the phase_signals field. Used by
|
||||
tests/validators to confirm backward-compatibility: the brief is accepted
|
||||
without phase_signals as long as brief_version is < 2.1.
|
||||
|
||||
## Goal
|
||||
|
||||
Validator returns valid: true. The sequencing gate
|
||||
(BRIEF_V51_MISSING_SIGNALS) does NOT fire for brief_version 2.0.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Validator passes.
|
||||
- No BRIEF_V51_MISSING_SIGNALS error in r.errors.
|
||||
32
tests/fixtures/end-state-frozen.json
vendored
32
tests/fixtures/end-state-frozen.json
vendored
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"why": "Frozen 2026-09-17, when the end-state direction was chosen. These ids and check signatures are the end-state gate's denominator: an entry may CLOSE, it may never disappear or have its check changed. Changing this file is a decision, not a refactor - tests/scripts/end-state-gate.test.mjs pins it literally. Amended 2026-09-18 by operator decision: D-03 and D-04 were rewritten from phrase probes to behaviour probes, so both signatures moved on purpose. That is the mechanism working, not a bypass - the list may change, but only as a decision that is said out loud. Amended a second time the same day, as a maintenance decision by the maintainer (no operator decision was asked for or given): the D-03/D-04 behaviour probes were greps for an export name, and a two-line stub exporting both names closed them on a tree where nothing was fixed. They now RUN a named test. Amended a third time the same day, same standing: running a NAMED test moved the cosmetic close one level up - two EMPTY tests with the right names closed both defects again - so each condition now also names the module whose STUB its test must fell. All three amendments moved signatures on purpose, removed nothing, and are kept honest by permanent mutants in the gate's own test (M6: a stub exporting the symbols; M7: an empty test with the right name).",
|
||||
"defects": {
|
||||
"D-01": "48f7f5cca070091e8f9b9ca0c7cdb7a1880733691935c25d07ea1d3e8d508fc9",
|
||||
"D-02": "dfc94dba04a9116ae9be2097a0c5a9d8313b02c7de1ca6b49a472c1823a6d1e1",
|
||||
"D-03": "98698ce74c243353713786b8604160f6672568b090884e9428d9e5e9fbcad89d",
|
||||
"D-04": "df11a2e95db7b014ce077db0473e5dde4195ace92d8077cacdc1cee273947b47",
|
||||
"D-05": "8fe6df52b43496c3302400507cb98002dcf8d2a12d92085e14bda4c7819bc7ba",
|
||||
"D-06": "72a787c8154d1c18849e9856dcf1a67703d6b5541f88db81d6bab13cab14b418",
|
||||
"D-07": "1bf3e0cd36c621720697475b0a3fd7db78611d66b58a9ce035e7695e57c69f1b"
|
||||
},
|
||||
"experiments": {
|
||||
"E-01": "f7718897cec8ace6879fe79f5f32760ec957c3642e7186f8aa1688ce77b3f4e8",
|
||||
"E-02": "c8ff12c504ba7a9ac36f11bb8f9b713263e9b4eb28c07d984d9e2115980e740d",
|
||||
"E-03": "17153ad8808615a833a49ae0c3523264ce055aa690e5542a75bae1874495e7e8"
|
||||
},
|
||||
"agents": {
|
||||
"dir": "agents",
|
||||
"spawnSites": "commands/*.md",
|
||||
"referenceMarker": "Reference document, not a spawnable capability"
|
||||
},
|
||||
"decisions": {
|
||||
"file": "STATE.md",
|
||||
"section": "## Åpne operatørbeslutninger",
|
||||
"open": "^- \\[ \\] ",
|
||||
"closed": "^- \\[x\\] "
|
||||
},
|
||||
"freeze": {
|
||||
"tag": "end-state-freeze",
|
||||
"featPattern": "^feat(\\(|!|:)"
|
||||
}
|
||||
}
|
||||
66
tests/fixtures/expected.prom
vendored
66
tests/fixtures/expected.prom
vendored
|
|
@ -1,66 +0,0 @@
|
|||
# HELP voyage_trekbrief_interview_turns voyage stats — trekbrief_interview_turns
|
||||
# TYPE voyage_trekbrief_interview_turns gauge
|
||||
voyage_trekbrief_interview_turns{_schema_id="trekbrief",slug="add-auth",mode="default",profile="economy",profile_source="env"} 7
|
||||
# HELP voyage_trekbrief_research_topics voyage stats — trekbrief_research_topics
|
||||
# TYPE voyage_trekbrief_research_topics gauge
|
||||
voyage_trekbrief_research_topics{_schema_id="trekbrief",slug="add-auth",mode="default",profile="economy",profile_source="env"} 3
|
||||
# HELP voyage_trekbrief_review_iterations voyage stats — trekbrief_review_iterations
|
||||
# TYPE voyage_trekbrief_review_iterations gauge
|
||||
voyage_trekbrief_review_iterations{_schema_id="trekbrief",slug="add-auth",mode="default",profile="economy",profile_source="env"} 2
|
||||
# HELP voyage_trekexecute_steps_failed voyage stats — trekexecute_steps_failed
|
||||
# TYPE voyage_trekexecute_steps_failed counter
|
||||
voyage_trekexecute_steps_failed{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 0
|
||||
# HELP voyage_trekexecute_steps_passed voyage stats — trekexecute_steps_passed
|
||||
# TYPE voyage_trekexecute_steps_passed counter
|
||||
voyage_trekexecute_steps_passed{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 12
|
||||
# HELP voyage_trekexecute_steps_skipped voyage stats — trekexecute_steps_skipped
|
||||
# TYPE voyage_trekexecute_steps_skipped counter
|
||||
voyage_trekexecute_steps_skipped{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 0
|
||||
# HELP voyage_trekexecute_steps_total voyage stats — trekexecute_steps_total
|
||||
# TYPE voyage_trekexecute_steps_total counter
|
||||
voyage_trekexecute_steps_total{_schema_id="trekexecute",plan="trekplan-add-auth.md",plan_type="plan",mode="execute",result="completed",profile="premium",profile_source="inheritance"} 12
|
||||
# HELP voyage_trekplan_agents_deployed voyage stats — trekplan_agents_deployed
|
||||
# TYPE voyage_trekplan_agents_deployed gauge
|
||||
voyage_trekplan_agents_deployed{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 7
|
||||
# HELP voyage_trekplan_codebase_files voyage stats — trekplan_codebase_files
|
||||
# TYPE voyage_trekplan_codebase_files gauge
|
||||
voyage_trekplan_codebase_files{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 156
|
||||
# HELP voyage_trekplan_deep_dives voyage stats — trekplan_deep_dives
|
||||
# TYPE voyage_trekplan_deep_dives gauge
|
||||
voyage_trekplan_deep_dives{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 2
|
||||
# HELP voyage_trekplan_research_briefs_used voyage stats — trekplan_research_briefs_used
|
||||
# TYPE voyage_trekplan_research_briefs_used gauge
|
||||
voyage_trekplan_research_briefs_used{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 3
|
||||
# HELP voyage_trekresearch_agents_external voyage stats — trekresearch_agents_external
|
||||
# TYPE voyage_trekresearch_agents_external gauge
|
||||
voyage_trekresearch_agents_external{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 3
|
||||
# HELP voyage_trekresearch_agents_local voyage stats — trekresearch_agents_local
|
||||
# TYPE voyage_trekresearch_agents_local gauge
|
||||
voyage_trekresearch_agents_local{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 5
|
||||
# HELP voyage_trekresearch_contradictions voyage stats — trekresearch_contradictions
|
||||
# TYPE voyage_trekresearch_contradictions gauge
|
||||
voyage_trekresearch_contradictions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 1
|
||||
# HELP voyage_trekresearch_conv_turns voyage stats — trekresearch_conv_turns
|
||||
# TYPE voyage_trekresearch_conv_turns gauge
|
||||
voyage_trekresearch_conv_turns{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 5
|
||||
# HELP voyage_trekresearch_dimensions voyage stats — trekresearch_dimensions
|
||||
# TYPE voyage_trekresearch_dimensions gauge
|
||||
voyage_trekresearch_dimensions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 4
|
||||
# HELP voyage_trekresearch_dimensions_baseline voyage stats — trekresearch_dimensions_baseline
|
||||
# TYPE voyage_trekresearch_dimensions_baseline gauge
|
||||
voyage_trekresearch_dimensions_baseline{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 3
|
||||
# HELP voyage_trekresearch_empty_turns voyage stats — trekresearch_empty_turns
|
||||
# TYPE voyage_trekresearch_empty_turns gauge
|
||||
voyage_trekresearch_empty_turns{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 1
|
||||
# HELP voyage_trekresearch_open_questions voyage stats — trekresearch_open_questions
|
||||
# TYPE voyage_trekresearch_open_questions gauge
|
||||
voyage_trekresearch_open_questions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 2
|
||||
# HELP voyage_trekresearch_unique_sources voyage stats — trekresearch_unique_sources
|
||||
# TYPE voyage_trekresearch_unique_sources gauge
|
||||
voyage_trekresearch_unique_sources{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 17
|
||||
# HELP voyage_trekreview_duration_ms voyage stats — trekreview_duration_ms
|
||||
# TYPE voyage_trekreview_duration_ms histogram
|
||||
voyage_trekreview_duration_ms{_schema_id="trekreview",slug="add-auth",verdict="ALLOW",mode="default",profile="balanced",profile_source="flag"} 4521
|
||||
# HELP voyage_trekreview_reviewed_files_count voyage stats — trekreview_reviewed_files_count
|
||||
# TYPE voyage_trekreview_reviewed_files_count counter
|
||||
voyage_trekreview_reviewed_files_count{_schema_id="trekreview",slug="add-auth",verdict="ALLOW",mode="default",profile="balanced",profile_source="flag"} 18
|
||||
84
tests/fixtures/jsonl-schemas.md
vendored
84
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -1,84 +0,0 @@
|
|||
# Voyage JSONL stats — schema audit (v4.1 input)
|
||||
|
||||
> **Purpose:** Field-allowlist input for v4.1 OTel exporter (Step 11). Lists every
|
||||
> field every voyage stats JSONL writer emits today, plus the additive fields v4.1
|
||||
> introduces. Load-bearing for Step 11 (field-allowlist) and Step 8 (stats plumbing).
|
||||
>
|
||||
> **PII-flag:** `command_excerpt` from `hooks/scripts/post-bash-stats.mjs` slices
|
||||
> the first 120 chars of an arbitrary Bash command — may contain operator paths,
|
||||
> branch names, or fragments of secrets that survived the secrets-hook. CWE-212
|
||||
> (Improper Cross-boundary Removal of Sensitive Data). The OTel exporter MUST
|
||||
> NOT export this field unless the operator explicitly opts in via
|
||||
> `VOYAGE_EXPORT_INCLUDE_COMMAND_EXCERPT=1` (deferred to v4.2 — v4.1 hard-excludes).
|
||||
>
|
||||
> **Additive v4.1 fields:** `profile`, `phase_models`, `parallel_agents`,
|
||||
> `external_research_enabled`, `profile_source`. All are forward-compat: existing
|
||||
> v4.0 consumers ignore unknown keys, v4.1 consumers get richer signal.
|
||||
|
||||
## Field table per JSONL writer
|
||||
|
||||
| schema_id | fields | writer_path | line_ref | v4.1 additive | PII |
|
||||
|-----------|--------|-------------|----------|---------------|-----|
|
||||
| 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:1140-1164 | 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:828-861 | 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:1305-1333 | 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:3016-3038 | 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:682-735 | profile, phase_models, profile_source | none |
|
||||
| trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:271-275 | profile, profile_source | none |
|
||||
| token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) |
|
||||
|
||||
The `main-merge-declined` event's `payload.reason` (the autonomy-events row) is one of the literal words of
|
||||
`commands/trekexecute.md` Phase 8: `review-block`, `review-stale`, `review-unanswered`, `review-not-measured`,
|
||||
`run-not-completed`, `no-main-branch`, `hold-for-review`, `merge-conflict`, `merge-refused`.
|
||||
|
||||
## Field-allowlist input for Step 11
|
||||
|
||||
The OTel exporter (Step 11 `lib/exporters/field-allowlist.mjs`) MUST inline the
|
||||
following static const arrays (NOT load from this file at runtime — Step 11
|
||||
explicit constraint: INLINE static const, IKKE runtime fra tests/fixtures):
|
||||
|
||||
**EXPORT_ALLOWLIST** (numeric/bool/short-string fields safe for OTel metric labels):
|
||||
|
||||
```
|
||||
ts, slug, mode, brief_quality, auto_research, auto_result,
|
||||
codebase_size, codebase_files, agents_deployed, deep_dives,
|
||||
agents_local, agents_external, gemini_used, dimensions, confidence,
|
||||
contradictions, open_questions, interview_turns, review_iterations,
|
||||
research_topics, research_briefs_used, research_scout_used,
|
||||
critic_verdict, guardian_verdict, outcome, plan_type, result,
|
||||
steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step,
|
||||
verdict, reviewed_files_count, duration_ms, status, next_session_label,
|
||||
event, known_event, success, scope,
|
||||
profile, profile_source, parallel_agents, external_research_enabled,
|
||||
model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read,
|
||||
cost_usd, is_estimate, price_table_version
|
||||
```
|
||||
|
||||
**EXPORT_DENYLIST** (PII or high-cardinality, never export):
|
||||
|
||||
```
|
||||
task, question, project_dir, project, plan, brief_path, command_excerpt, payload, counts, phase_models, session_id,
|
||||
order_id, order_claimed_at, order_reason
|
||||
```
|
||||
|
||||
> Notes:
|
||||
> - `task` and `question` may contain user-content prose → high-cardinality + PII risk.
|
||||
> - `project_dir` and paths leak filesystem layout.
|
||||
> - `command_excerpt` per CWE-212 above.
|
||||
> - `phase_models` is a structured object (6 keys) — too high-cardinality for label;
|
||||
> profile name (`profile`) is the safe summary. v4.2 may revisit if operators ask.
|
||||
> - `counts` (review BLOCKER/MAJOR/MINOR/SUGGESTION) is a nested object — Step 11
|
||||
> exporter flattens to `voyage_review_counts_blocker`/`_major`/`_minor`/`_suggestion`
|
||||
> metrics rather than a label.
|
||||
> - `session_id` is a UUID — high-cardinality, not useful as a label, log-only.
|
||||
|
||||
## Cross-reference
|
||||
|
||||
- Step 8 (stats plumbing) — adds `profile` + `phase_models` + `profile_source` to all
|
||||
6 orchestrator-emit sites listed above.
|
||||
- Step 11 (field-allowlist) — codifies the EXPORT_ALLOWLIST/DENYLIST arrays above
|
||||
as inline static consts in `lib/exporters/field-allowlist.mjs`.
|
||||
- Step 9 (Prometheus textfile) — emits one metric line per allowlist-numeric field
|
||||
per JSONL writer; PII-flagged fields are dropped at format-layer, not export-layer.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue