Compare commits

...

50 commits

Author SHA1 Message Date
20cdc22803 fix(review): an anonymous invalid payload is unattributable, not a reviewer named "unnamed reviewer"
Follow-up defect in the reviewer accounting added by e2aec01, found by review
and confirmed by probe before fixing.

validateFindings only WARNS on a missing `reviewer` field, so a payload can
fail schema while carrying no name. ingest then records `reviewer: null`, and
runContract turned that null into the literal reviewer name "unnamed reviewer".

MEASURED before the fix:
  runContract([{findings:[{file:'x.mjs',line:1,rule_key:'NOPE',severity:'MAJOR'}]}],
              {expectedReviewers:['code-correctness-reviewer']})
  -> missing_reviewers = ["unnamed reviewer", "code-correctness-reviewer"]
One failure, two entries, one of them an agent nobody launched. The
`reported.delete(s.reviewer)` line was also inert for that case, since a null
name was never in the set to begin with.

Fix: skipped payloads are split by whether they carry a name. Named ones go to
missing_reviewers as before; anonymous ones increment the new
`unattributable_payloads` count, which forbids ALLOW on its own - so stripping
a reviewer name from a payload cannot restore ALLOW, and the floor does not
depend on the caller passing expectedReviewers. `allow_blocked_by` reports the
two facts separately: `missing-reviewer:<name>` and `unattributable-payload (n)`.

The old behaviour never produced a false ALLOW - it failed in the safe
direction - but it named a reviewer that did not exist, which is the kind of
output an operator would chase.

Iron Law: two failing tests first (double entry; anonymous-payload-alone must
forbid ALLOW), then the fix.

Also verified in this pass, by temporarily adding a fake reason to
UNVERIFIED_REASONS: the prose-vocabulary pin does go red when a reason is
declared in the lib but missing from agents/review-coordinator.md. A pin that
cannot fail is not a pin.

Suite 1034 (1032/0/2) -> 1036 (1034/0/2), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:54:31 +02:00
e2aec019ac fix(review): fail-closed verdicts - an unsubstantiated finding can no longer yield ALLOW
computeVerdict counted only the findings handed to it (reasoned.kept), so a
finding removed by Pass 2 or Pass 3, and a reviewer whose payload was thrown
away or never arrived, were arithmetically identical to a finding that never
existed. All three pushed the verdict toward ALLOW.

Measured before the fix (probes, 2026-09-01):
  - a BLOCKER with a 101-character title -> ALLOW (Pass 2 succinctness)
  - a payload with one ad-hoc rule_key is skipped WHOLE at ingest, taking a
    valid BLOCKER sibling with it -> ALLOW
  - a reviewer that never reported -> ALLOW
Pass 3's own no-citation / unknown-rule_key branches turned out unreachable
through runContract (validateFindings rejects those payloads first), so the
reachable exposure was Pass 2 plus the skipped/absent reviewer.

THE OPEN DESIGN DECISION, and why it went against the order's default.
The order proposed: indeterminate file-existence YES, plain succinctness NO
("a too-long finding is not an uncertain finding"). I kept the first and
overrode the second, on one principle:

  A removal is `dropped` only when the test REFUTED the finding as a claim
  about this codebase. Every other removal is `unverified`.

Succinctness and actionability read a `.length`. They never examine the claim,
so they cannot establish the finding is unreal - and dropping a BLOCKER for a
101-character title is precisely the fail-open shape being fixed. Three things
settled it:

1. Under the order's default the fix would have been almost inert. Pass 3's
   drop branches are unreachable via runContract, so leaving Pass 2 out would
   have left the only reachable finding-level exposure open.
2. Cost asymmetry, priced rather than asserted: the verdict is not a gate.
   Handover 6 feeds `findings` filtered to BLOCKER+MAJOR into /trekplan
   (commands/trekplan.md:218); `verdict` is optional metadata
   (docs/HANDOVER-CONTRACTS.md:353). Nothing loops or re-plans on WARN. So a
   false `unverified` costs WARN plus a printed reason; a false drop costs a
   silent ALLOW over a live BLOCKER.
3. unknown-rule_key joins them for the same reason: an ad-hoc key is a real
   defect wearing the wrong label, and v5.1.1 high-effort mode already KEEPS
   those, normalised to PLAN_EXECUTE_DRIFT. Refuting them at normal effort
   while keeping them at high effort would be incoherent.

no-citation stays a drop: a finding whose file is empty or whose line is
negative names no location, so it makes no checkable claim at all - the one
deterministic refutation, and what the Pass 3 prose already said it was.

Iron Law: tests/lib/coordinator-contract.test.mjs first, red (missing export +
the three measured ALLOWs), then production code. Two existing assertions were
updated AFTER implementation as contract changes, not to make the red pass.
A known-positive control pins that ALLOW is still reachable - without it,
"no ALLOW" is not a fail-closed contract, only a broken one.

lib/review/coordinator-contract.mjs
  + classifySuppression / REFUTING_REASONS / UNVERIFIED_REASONS - one
    vocabulary owned by the lib, including the tokens only the LLM
    coordinator emits (accuracy:refuted, file-existence:refuted/indeterminate),
    so prose and lib cannot drift. Unclassified reasons default to unverified:
    the default fails closed.
  ~ judgeFilter / reasonablenessFilter return {kept, dropped, unverified}
  ~ computeVerdict(findings, {unverified, missingReviewers}) -> + allow_blocked_by.
    Never raises a verdict, only withholds ALLOW. Unverified findings are NOT
    counted into a severity tier: their severity was never substantiated, and
    counting it would be invention.
  ~ runContract(payloads, {expectedReviewers}) -> + unverified,
    missing_reviewers, allow_blocked_by. `suppressed` stays the union of
    dropped + unverified, so existing consumers (gold-eval) keep their meaning.

agents/review-coordinator.md - Pass 2/3 tables gain a fate column, new
  "Suppression is two-valued" section, Pass 4 threshold table gains the two
  fail-closed rows, Executive Summary must state a withheld ALLOW, Suppressed
  Findings tags each line [dropped]/[unverified]. Pass 3's unknown-rule_key
  bullet explicitly says high-effort does not reach that branch, so the same
  input never has two documented fates.

commands/trekreview.md - Phase 5 "Reviewer accounting": the expected set is
  written down before the spawn, a silent reviewer gets one re-ask and then
  STOP. That extends the pattern already in the file (schema failure -> 2
  bounded re-asks -> "do not feed unvalidated findings to the coordinator") to
  the other two ways a reviewer goes missing, rather than softening it to WARN.
  The lib's missing_reviewers stays as belt-and-braces for direct callers.

docs/agent-return-channel-defect.md - the "inferred, not observed" caveat on
  the unnamed arm above 66 lines is struck: akashic-intelligence S27
  (f168630) measured 2/2 unnamed agents returning against a 4370-line plan,
  30449 B and 10989 B, both valid JSON. Recorded with akashic's own two
  caveats intact - the measurer owns the finding, and byte-identity between
  the returned string and the file on disk was not proven. The separate S25
  named-arm figures are left standing; these are two measurements, not a
  correction of one by the other.

No release, no version bump, no tag, no catalogue ref, no Workflow port.
Suite 1025 (1023/0/2) -> 1034 (1032/0/2), 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:46:39 +02:00
d650ff3bac fix(package): mark the manifest private - the npm name voyage belongs to a third party
D-census (order 6242020304, from .claude, 2026-08-20/21) measured that
registry.npmjs.org/voyage answers 200 with someone else's package: "Advanced
HTTP Routing System for Node.js" (NEURS/voyage, v0.0.1, 2022). This manifest
declared `"name": "voyage"` with no `private` flag, so nothing stopped an
accidental `npm publish` against a name we do not own - and publishing is
one-way.

This manifest is test tooling for a Claude Code plugin distributed through the
marketplace catalogue (`ref: vX.Y.Z`), never through npm. The sibling
non-package manifests (okr, repo-mailbox, repo-standard) all carry
`private: true`; voyage was the exception.

Operator decision S93: option A (set `private: true`), not option B (scope the
name to `@<org>/voyage` for a future publish) - there is no publishing plan,
so B would only defer the same choice.

Tests first (Iron Law): tests/lib/doc-consistency.test.mjs pins
`pkg.private === true`, seen red (actual `undefined`) before the manifest
change.

New baseline: 1025 tests, 1023 pass / 0 fail / 2 skip (was 1024: 1022/0/2).

No `npm publish` in any form, no version bump, no tag, no catalogue change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:31:41 +02:00
4e5523fb0a docs(trekresearch): deep-research engine is operator-only from CC 2.1.218; document the real window
Claude Code 2.1.218 changed /deep-research to start only when invoked
manually; from there the Skill tool refuses a model invocation outright
with `disable-model-invocation` (measured in a real `--engine deep-research`
run 2026-09-01 18:32Z - the SC3 fallback to swarm held). The engine was
built against 2.1.196 and the prose still promised a path Claude Code has
removed ("requires Claude Code 2.1.154+").

The flag is KEPT as an additive opt-in that never hard-fails. Prose only:
no lib/ change, no adapter-contract change, no default change, no version
bump.

- commands/trekresearch.md: flag bullet states the closed window
  `2.1.154 <= CC < 2.1.218`; the pre-gate gained an UPPER ceiling (still a
  numeric comparison, not a string one) that skips straight to the fallback
  with reason `disable-model-invocation`; step 3 names the Skill-tool
  refusal as a known failure manifestation; step 4 lists the reason tokens.
- docs/command-modes.md + README.md: same window, one sentence each, on the
  reference row itself.
- CHANGELOG.md: Unreleased/Docs entry.

Tests first (Iron Law): tests/lib/doc-consistency.test.mjs gained a pin
requiring 2.1.218 + disable-model-invocation on all three surfaces, on both
reference rows, and in both load-bearing regions of the command (flag
bullet + pre-gate). Seen red on all three surfaces before the prose change.
tests/commands/trekresearch-engine.test.mjs unchanged and green.

New baseline: 1024 tests, 1022 pass / 0 fail / 2 skip (was 1023: 1021/0/2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:23:09 +02:00
bcd2600918 fix(trekexecute): assign PLAN_PATH inside the Check 2 block, keep both blocks ASCII
Follow-up to 63e78c5. Adversarial review found that the fix had reintroduced,
in a smaller form, the exact defect the order was filed about.

1. The rewritten Check 2 block opened with
   `git ls-files --error-unmatch "$PLAN_PATH"` while the assignment lived in
   the prose above it ("Set PLAN_PATH to the plan path, then run:"). Nothing
   upstream sets it — Check 2 is Phase 2.55, and REPO_ROOT/WORKTREE_DIR are
   only created in Phase 2.6 Step 1, which runs after. An agent copying the
   block verbatim would have run it with PLAN_PATH empty. The note was prose,
   the code was what ran. The block now carries
   `PLAN_PATH="{plan-path}"` as its first line, the same placeholder idiom
   the rest of the file uses (`BRANCH_NAME="trek/{slug}/session-{N}"`).

   The three Check 2 tests could not have caught this: they injected
   PLAN_PATH through the environment, supplying what the doc has to supply
   itself. They now substitute `{plan-path}` the way an agent does, assert
   the placeholder is present, and pass PLAN_PATH="" in the environment so a
   block that fails to assign it goes red. Control run with the assignment
   line stripped: `fatal: empty string is not a valid pathspec` -> exit 1.

2. Both copied blocks contained an em-dash (one in an `echo` string, one in a
   comment). Shell that bash 3.2 executes stays ASCII — a multibyte char
   under `set -u` has crashed it before. Replaced with plain `-`; the prose
   outside the fences keeps its em-dashes. New test asserts both blocks are
   ASCII-clean, with a known-positive proving the detector fires on an
   em-dash.

Suite 1022 (1020/0/2) -> 1023 (1021/0/2). No version bump, no release.
63e78c5 stays valid in history: the order archive and two coord messages
point at it.

Co-Authored-By: Claude <claude-opus-5>
2026-09-01 00:11:19 +02:00
63e78c5ec0 fix(trekexecute): make parallel-mode plan delivery portable and gitignore-tolerant
Two independent defects took down a real voyage on macOS today
(llm-ingestion-okf, wave 1, zero steps executed). Both paths led to the same
end state: the worktree had no plan.

1. Phase 2.6 Step 2a' derived the project relpath with
   `realpath --relative-to`, which is GNU coreutils only. On BSD realpath
   (macOS default) the substitution fails silently: PROJECT_REL ends up
   empty, `mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed, and
   brief.md/plan.md land at the worktree root where no child session looks.
   The portable form existed only as a prose note saying an operator "may
   substitute" it — the note was prose, the code was what ran. The block now
   uses `python3 -c os.path.relpath` (stdlib), resolves both operands with
   bare realpath (identical on BSD and GNU, and correct across the macOS
   /var -> /private/var symlink), and aborts loudly when the relpath is
   empty or escapes the repo instead of dropping files at the root. The
   coreutils note is deleted rather than extended: two forms means the next
   reader picks, and that pick is what failed.

   Measured on this machine 2026-08-31 (Intel Mac, no coreutils):
   `realpath --relative-to=...` -> `realpath: illegal option -- -`;
   bare `realpath /Users/ktg/.claude` -> correct path, exit 0.

2. Phase 2.55 Check 2 ran `git add {plan-path}` unconditionally. When the
   project directory is gitignored (`.claude/projects/` is tool-managed and
   local-only — normal, not exotic) the add refuses and the plan never
   reaches HEAD. Check 2 now probes with `git check-ignore` and branches:
   tracked -> nothing; untracked -> add + commit as before; ignored -> step
   aside and let Step 2a' be the delivery path. `git add -f` was rejected as
   the fix: forcing operator-local artifacts into history publishes them to
   whatever remote the repo pushes to. A `check-ignore` exit code other than
   0 or 1 is fatal and stops execution — a probe that failed is not a probe
   that answered "not ignored".

Also fixed in the same block: `[ -d research ] && cp -r ...` as the last
statement of the loop body made a project without research/ exit the wave
non-zero.

TDD, Iron Law: tests/commands/trekexecute-parallel-portability.test.mjs
extracts both shell blocks from commands/trekexecute.md by grep-able anchor
and executes them, so the tests bind to what an agent actually copies. The
assertions check FILE PLACEMENT, not exit status — the whole defect is that
the broken form exits 0. Controls, all present before the fix: a
known-positive BSD-realpath stub (bare path resolves, GNU flag rejected with
`illegal option`), a negative control running the old GNU form under that
stub and asserting plan.md lands at the worktree root, a known-positive
Check 2 arm where a non-ignored plan is still added and committed, and a
stubbed fatal `git check-ignore` (128). Red first: 7 failed, 2 passed (the
two controls). Green after: 9/9.

Suite 1013 (1011/0/2) -> 1022 (1020/0/2). No version bump, no release.

Order: 20260831T214411Z-941965142-from-.claude (from .claude).

Co-Authored-By: Claude <claude-opus-5>
2026-08-31 23:53:55 +02:00
2ee7b76430 fix(plugin.json): point homepage at the actual standalone repo, not the retired monorepo path
Verified: old URL 404s, host is up (control URLs 200), new URL 200. Unblocks catalog's check-versions.mjs (voyage was the sole ERROR).
2026-08-27 00:33:07 +02:00
cae698a204 docs(brief): sharpen the tiltak-3 citation
commands/trekplan.md:885 documents the effort=='low' route into the
--quick-equivalent code-path, not the --quick flag itself. Quote :122 as the
primary source and describe :885 as the second way in, so the anchor says what
the line actually says.
2026-08-20 23:01:45 +02:00
9ee120dd7e docs(brief): assess VURDERING-V2 §5.12 against ground truth
External assessment from claude-playlist-corpus, verified per tiltak against
code in this repo and against the claude-code-llm-wiki OKF bundle (bundle wins
on feature claims). No implementation — scope-guard.

Verdicts across the six tiltak:

1. Workflow port — split, not global. /trekreview is already ported (opt-in
   --workflow) and measured fidelity-equivalent; /trekplan and /trekresearch are
   unported, but the claimed benefit is UNMEASURED, not refuted. T2's +4.4% is
   subagent tokens and states main context is uncounted; T1's Δ≈0 covers Phase 7
   synthesis only. The measurement design already exists in T1-cc26. Measure
   before porting.
2. Holdout — (a) and (b) hold today but are uncodified; (d) is already partly
   there; (c) fail-closed is a REAL defect. computeVerdict counts only kept
   findings, so a dropped or unreturned finding is arithmetically identical to
   one that never existed. Codified in lib/review/coordinator-contract.mjs, so
   it is testable test-first.
3. Lightweight lane — narrower than stated: --quick exists but does not bypass
   the 2.2 framing gate. Already an open operator decision.
4. Definition-of-done as object — confirmed gap, but it is a Handover 1
   breaking change (brief_version bump), not a cheap edit.
5. Prune — mostly already decided: synthesis-agent dormant by measurement, the
   24 opus pins are an operator pin with a decision record (40d8742), and
   gemini-bridge is already flagged DROP→NATIVE.
6. LSP — confirmed absent.

Bundle-gap: V2 overstates two Workflow primitives (token budgets, automatic
retry — neither documented on the mirrored page) and omits that resumability is
session-scoped, which matters for a multi-session tool.
2026-08-20 22:59:15 +02:00
4f90aa8af0 docs(security): fix unreachable reporting channel and wrong repo pointer
Forgejo registration is disabled instance-wide, so the documented
private-issue path required an account no external reporter could
create. Add security@fromaitochitta.com as the primary channel (matches
convention in repo-standard, llm-security, .profile) and keep the
private-issue path as a secondary option for reporters who already have
an account. Also fixes the issue-tracker URL, which pointed at
open/ktg-plugin-marketplace instead of this repo's own open/voyage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KHo7hCEHKzLCmAVRKXkJNJ
2026-08-20 22:02:56 +02:00
692a4a526c chore(release): v5.10.0 — STORM bounded loop (default-off) + the spawn-name defect
Version sync across the five surfaces that carry it (plugin.json, package.json,
package-lock.json x2, README badge) plus the CHANGELOG section for the 38
commits since v5.9.1. The two docs that print "voyage 5.9.1" are measurement
records and keep the version they were measured against.

Suite 1013 (1011 pass / 0 fail / 2 skip), unchanged by the bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018w5KWrbxzmki5wdXULT8nU
2026-08-18 16:49:02 +02:00
f9ebf08cba docs(agent-return-channel): record what akashic's full-scale numerator closes, and what it does not
akashic-intelligence recovered its four S25 agents' on-disk final text against
the full 3730-line / 277 KB plan (commit e281a9d): 4/4 produced correct output,
delivery alone failed. That closes the size-dependent-work question the caveat
left open. It does not close the returning arm above 66 lines -- all four of
their cells were named -- so that stays stated as inferred, not observed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018w5KWrbxzmki5wdXULT8nU
2026-08-18 16:45:45 +02:00
ec9bbde8f8 docs(agent-return-channel): record the idle-notification signature the defect presents as
The five mute teammates surfaced nine minutes after finishing - as
content-free idle notifications. That is akashic's "innholdslose idle-pings"
verbatim, and it arrived after the write-up was committed, so it is added
here as the field signature to recognise the defect by.

Two readable facts in it:
- `summary` is populated only for the teammate that called SendMessage; the
  five tool-less ones carry no content at all, not even an error. Presence of
  `summary` is the fastest mute-vs-delivering discriminator.
- The pings lag by minutes (ctrl-pong went idle 10s after spawn, notified ~9
  min later), so "no ping yet" is not evidence of work in progress and the
  ping is not a result. Judging liveness from these is how a finished run gets
  re-prompted nine times.

Suite unchanged: 1013 (1011/0/2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MfB5Ecp8vvGyou8uFGxBV
2026-08-17 22:17:48 +02:00
f79c5f6606 fix(spawn): forbid the Agent tool's name parameter at every voyage spawn site
akashic-intelligence lost /trekplan Phase 9 twice to reviewer agents that
never returned. Reproduced here and measured: the cause is the Agent tool's
`name` parameter, not the agent definitions.

Passing `name` does not label a subagent - it changes its kind. The spawn is
recorded as taskKind "in_process_teammate" (spawnDepth 0) instead of a real
subagent (spawnDepth 1). A teammate's final assistant text is not a return
value; it reaches the parent only if the teammate itself calls
SendMessage(to: "main"). plan-critic and scope-guardian declare
tools: [Read, Glob, Grep] - no SendMessage - so as teammates they are
structurally incapable of returning, whatever the prompt says.

Denominators: named 0/5 returned; named + explicit SendMessage 1/1; unnamed
3/3 (plan-critic and scope-guardian each returned full findings + the JSON
block in ~110s). Model override 2/2 non-returning, so the override is not
the variable. All 5 named agents produced correct final text in their
transcripts - only delivery failed, and that output is recoverable on disk.

The defect is a harness behaviour, so it is documented rather than silently
worked around: docs/agent-return-channel-defect.md carries the mechanism,
every denominator, the two broken queries that nearly became facts, the
unmeasured cells, and a recommended working shape for consumers (use agents,
drop `name`; do not fall back to inline review, which costs the dedup step).

Prevention is pinned, TDD red->green: the four spawning commands each state
the rule and name the mechanism, and doc-consistency derives the spawning set
from the command files so the pin cannot go vacuous.

Suite 1013 (1011/0/2), +5 from baseline 1008.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MfB5Ecp8vvGyou8uFGxBV
2026-08-17 22:10:11 +02:00
4b261db005 fix(readme): describe the Gemini MCP tool contract instead of a dead cookbook link
The old URL pointed at anthropics/anthropic-cookbook/tree/main/tool-use/gemini-mcp,
which never existed: the repo was renamed to claude-cookbooks, the path used a
hyphen where the directory uses an underscore, and the tool_use directory (21
entries, checked via the API) has no gemini-mcp entry at all. The server voyage
actually calls is the operator's private gemini-mcp repo, which isn't published
anywhere a stranger could reach. Replace the false promise with the tool
contract itself (the four MCP tool names) so the line stays actionable without
asserting a URL that doesn't resolve.
2026-08-16 15:12:17 +02:00
bbdfd8af91 chore(gitignore): ignore operator PDFs in docs/, which origin must never carry
origin is a PUBLIC mirror. S83 staged docs/SLDC-AI.pdf (9.97 MB) with a
broad `git add -A docs` even though STATE.md said not to commit it, and
undoing that took a history rewrite of the ten commits that followed.

Ignoring the type removes the need to remember. Operator working documents
land in docs/ regularly; none of them are part of the published plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-13 10:44:31 +02:00
74e700af79 docs(trekresearch): split the Independence crossing's two risks and name the right control for each
The Phase 4.5 amendment to the Independence hard rule crosses that rule
deliberately, and the crossing is defensible: bounded to Phase 4.5 and the
Phase 5 loop, the initial swarm stays blind, and it resolves a tension the
brief created itself by asking for exactly this mining of Phase-4 output.

The defect was the compensating-control claim. query-privacy-gate.mjs was
named as THE control for the crossing, but it inspects outbound query
CONTENT for paths, repo identifiers and secret-shaped strings. It cannot
prevent a local finding from steering an external agent's question. It
compensates the EGRESS risk; the BIAS risk was left with no control while
the text read as though it had one.

Both risks are now stated separately with the control that actually bears on
each:

  Bias   - structural, not a gate. The initial external swarm stays blind, so
           an independent baseline exists BEFORE anything crosses; the
           crossing only ADDS to that baseline; 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.
  Egress - query-privacy-gate.mjs, unchanged, with its non-overridable
           hard-block tier.

Fixed at both sites: the Hard rules entry and the Phase 4.5 paragraph that
repeated the same attribution.

A doc-consistency pin now fails if the amendment names the privacy gate as
the compensating control again, and requires it to name both
contrarian-researcher and the egress framing - so the two cannot quietly
re-merge.

Review finding 4888ae847240142933469d70bafb6a9c23b7b2eb (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:10:41 +02:00
84612b2641 feat(research-loop-cap): give the discovery ceiling a reader, not just a sentence
The bounded-cost NFR asks for explicit ceilings on BOTH axes - max
conversation turns and max discovered dimensions. The turn axis got
MAX_CONV_TURNS, a ledger-backed reader and a PreToolUse enforcer. The
discovery axis got one sentence in Phase 4.5 prose ("append candidates only
while the whole list stays at or below maxDimensions: 8") with no constant
of its own, no reader, and no test that a run exceeding it is caught. That
is the brief_reviewer_iter_cap shape the operator decision warned about: a
cap nothing reads.

checkDimensionCeiling() is the reader, exposed on the CLI as
--check-dimensions N (exit 0 within, exit 1 rejected), and Phase 4.5 step 3
now calls it once the final list is settled instead of merely describing the
bound.

Three deliberate choices:

- The ceiling IS MAX_TOTAL_DIMENSIONS, the constant that sizes the turn
  budget. Both axes read one settings.json:16 value, so they cannot end up
  enforcing different numbers - a second constant is how that drift starts.
- An unreadable count is REJECTED ('abc', null, undefined, {}, -1, NaN,
  non-integers). A cost ceiling that waves through what it cannot measure is
  not a ceiling.
- --check-dimensions requires no run id, effort or VOYAGE_STORM_ENABLED.
  Phase 4.5 never calls the budget gate - that is why its skip-guard reads
  the flag directly - so the ceiling check must not inherit the gate's
  preconditions.

The mitigation the review already verified still holds and is unchanged:
MAX_TOTAL_DIMENSIONS bounds actual retrieval cost regardless of how many
dimensions discovery appends. What was missing was anything that FAILS on a
list over the bound, and now a run over it is rejected by exit code.

Review finding 96a3ee51152dfe72aca703f771843f2f3639e7b6 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:09:11 +02:00
22cb7df403 fix(cap-hook): shrink the inherited deny window and print the way out of it
DEFAULT_TTL_MS was 6h, measured from marker.startedAt rather than last
activity, and `claude --resume` keeps the same session_id - so a run that
died holding its marker handed the resumed session the remainder of that
window, denying every WebSearch/WebFetch/Task including work unrelated to
research. The header's design goal ("An unrelated session must never be
denied") held across sessions and read as broader than it was: by this
scope key a resume IS the same session.

Three changes, none of which pretends to close it:

- The tombstone boundary (32e20fc) already removed the common case. A run
  that crashed MID-loop leaves no denial record, so the resume is allowed;
  only a crash AFTER the cap denied a turn opens a window at all. Pinned by
  a test with a part-spent ledger and no tombstone.
- TTL 6h -> 2h. A 24-turn loop at a couple of minutes per turn is under an
  hour, so nothing needed six, and debris no longer owns the rest of the
  working day.
- Every denial now prints the marker path with "if this loop is not
  running, delete it", plus the auto-reset horizon. The window existed
  before with no stated remedy, which is what made it expensive.

A liveness check would close it properly. This hook has nothing
trustworthy to check liveness against - the marker's writer is a shell
snippet whose $$ is a subshell, not the session - so the limit is written
into the header as a limit instead of being papered over.

Review finding d913d1b655012fe206ea925b9fc77b401566a39e (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:06:47 +02:00
9dfdc4a42f test(cap-hook): cover the crash-time marker branches and EXECUTE the marker snippet
Two gaps, both test-side. No production behaviour changes here: the
branches were already correct, they were simply unpinned, so a later edit
could have broken them silently.

1. Crash-time states the hook header's TTL discussion anticipates and no
   test exercised: a partially written marker (valid prefix, no close - what
   an interrupted printf leaves), a marker with no runId, a marker whose
   runId is empty or not a string, and malformed ledger lines. All must
   ALLOW, because a marker we cannot read cannot say which run we are in,
   and a half-written ledger line is not a spent turn. One test pins the
   other direction too: skipping bad lines must not mean skipping the run's
   tombstone.

2. The marker snippet is now RUN, not asserted about. Every existing pin on
   the marker lifecycle (tests/lib/doc-consistency.test.mjs) is a substring
   assertion on prose, so a snippet emitting invalid JSON or writing to a
   path the hook never reads would keep the whole suite green while the hook
   allowed everything - the failure S82 had to find by hand with a
   scratchpad probe. Three tests now extract the real ```bash blocks from
   commands/trekresearch.md and execute them with CLAUDE_PLUGIN_DATA
   stripped and HOME sandboxed:
     - the write snippet lands parseable JSON at the exact path the hook
       looks up, with runId and a Date.parse-able startedAt;
     - an empty CLAUDE_CODE_SESSION_ID produces NO `.json` marker and says
       the cap stays inert;
     - write snippet -> real hook denies -> removal snippet -> real hook
       allows, which is the writer/reader agreement end to end.

On the non-string runId: it clears the falsy guard and then matches no
ledger record, so the run reads as 0 turns and is allowed. Allow is correct
either way and no writer emits one, so that stays a pin on the outcome
rather than an argument for a type guard on an unreachable state.

Review finding 823d8c28d7c993717606433fd6711d5a67877967 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:04:25 +02:00
f19474acc6 fix(storm-measure): check BOTH halves of the activation SC, not just the count delta
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. activationCheck computed
dimensions - dimensions_baseline >= 1 and returned ok on that alone. A
count delta says nothing about membership: a run that dropped two
interview dimensions and appended three discovered ones is +1 and passed
the check while violating the second half outright. Supersetness was
asserted only by Phase 4.5's prose contract that discovery appends -
nothing read it.

The stats record cannot carry the dimension names that would show it
directly: names are free prose, and lib/exporters/field-allowlist.mjs
denies prose by omission (question, project_dir, brief_path are already
excluded for that reason). So the run attests membership with
dimensions_baseline_preserved, a boolean set in a new Phase 4.5 step 5,
and the gate refuses to call activation OK without it. An ABSENT
attestation fails rather than falling back to the old count-only rule -
otherwise legacy rows would keep passing on the defect.

Producer side wired end to end: the record format, the measurement-fields
prose (five fields -> six), the exporter allowlist, the jsonl-schemas
fixture row, and the --activation-check comment in
docs/storm-measurement.md.

Review finding d2786604458207a5a73478cdcb6a54bbdb92141d (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:01:59 +02:00
5ba7c64ace fix(storm-measure): exclude an unreadable empty_turns, and stop printing BOTH for an OR rule
Two defects in the adoption gate, both in the direction that flatters
adoption.

1. A non-numeric empty_turns counted as an eligible run.
   Number('many') is NaN, and the test was `Number.isFinite(empty) &&
   empty > 0`, so NaN fell through to the eligible branch. Measured
   before: 0 -> eligible, 2 -> excluded, undefined -> eligible, null ->
   eligible, 'many' -> ELIGIBLE, NaN -> ELIGIBLE. The exclusion is one of
   the two properties docs/storm-measurement.md names as carrying this
   gate's honesty, and the run whose bookkeeping broke is the run whose
   numbers deserve the least trust. Now excluded. Absent and null stay
   eligible via `?? 0` - a field never written is a genuine zero on a run
   where the loop never armed.

2. The printed threshold line said "adopt >= 30.0% on BOTH - decline <
   15.0% on BOTH" while decideVerdict evaluates OR on both sides. S82
   restored the pre-registered OR rule in the logic (c37bf50d) and left
   this line describing the stricter AND gate, one line above the verdict
   that OR produced. The summary is the only form of the rule most readers
   ever see, so it now states EITHER on both sides and that adopt wins
   ties. Found while fixing (1); not a review finding.

A test pins the printed line against the string "on BOTH" so the two
cannot drift apart again silently.

Review finding 24a76c21ffc694cd782cd449212c9502d31aeda6 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:59:08 +02:00
bee248b71f fix(cap): move the enforcement boundary to a denial tombstone, not the turn count
allowTurn() appends BEFORE the turn runs, so during granted turn N the
ledger holds N records. The hook denied at `used >= budget`, which blocked
every tool call of the FINAL granted turn: the primitive granted B turns
and the harness permitted B-1. Worse, an exhausted run therefore always
terminated through an exit-2 tool denial instead of the graceful "cap
exhausted" exit at commands/trekresearch.md - and the prose says in as
many words that exit 2 is not exit 1, so the model was pushed out through
the one exit it is told NOT to treat as a cap.

The review recommended denying at `used > budget`. Taken alone that fixes
the count and breaks the hook: once the O_EXCL claim (previous commit)
makes a breached ledger impossible, `granted > budget` can no longer fire,
and the case this hook exists for - the loop consults the gate, is denied,
and issues the tool call anyway - would be allowed. A deny branch that
cannot be reached is a dead security claim, which is the same thing S82
removed two of rather than leave standing.

So the denial itself became a record. allowTurn() appends a tombstone
{runId, exhausted: true} when it denies for budget, and the hook denies on
the tombstone. Both properties now hold at once:

  granted == budget, no tombstone  -> turn B is in flight   -> ALLOW
  tombstone present                -> the gate already said no -> DENY
  granted  >  budget               -> breached, any cause   -> DENY

A tombstone is not a turn: readLedger reports {granted, exhausted}
separately so it can never consume budget. allowTurn short-circuits on an
existing tombstone, so a hammered gate neither re-walks every slot nor
grows the ledger. The tombstone write is best effort on purpose - the
denial is already the correct answer, so a ledger that cannot take the
record must not turn a denial into a grant.

The parallel-boundary test now asserts GRANTED turns rather than raw
ledger lines, because the denied callers legitimately add tombstones.

Review finding 8eb53458ac3efec778094f9f03b09e1cc1077a09 (MINOR).
Operator decision: tombstone over the literal recommended_action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:56:55 +02:00
1d279fb875 fix(research-loop-cap): claim each turn slot with O_EXCL so the bound survives concurrency
countTurns-then-appendFileSync is read-then-write. N callers that all
observe used == budget-1 all decide to grant, and the bound is exceeded
by N-1. The comment above allowTurn asserted "Append-only: never
read-modify-write" and named the concurrent case - Phase 4.5/5 may spawn
several agents in a single message - as the reason it had to be. The
decision path was exactly what the comment denied, so the concurrency
claim had nothing under it.

Each grant now creates <data root>/trekresearch-loop-claims/<runId>-<slot>.claim
with flag 'wx' (O_CREAT|O_EXCL) before appending. The kernel picks the
winner per slot, slot numbers are bounded by the budget, and each can be
created exactly once - so total grants for a run cannot exceed the budget
however many callers arrive together. The ledger count now only says
where to start looking for a free slot.

Two of the three tests are deterministic and do not race anything: they
assert the invariant directly by pre-creating claims, including the state
a mid-append competitor leaves behind (ledger 7, slots 1-8 claimed, budget
8 -> deny). That matters because the third test - six real concurrent shim
processes at the boundary - passed even BEFORE the fix, since process
startup jitter serialised them. A race test that passes by luck is not
evidence, so it ships as a real-world regression guard next to the two
that are.

Stated rather than left to be discovered: claim files are empty, at most
budget per run, and never cleaned - the same standing as the ledger, which
also grows for the life of the data root. Reusing a runId across runs, or
two runIds colliding after filename sanitisation, both deny a turn, which
is the safe direction for a budget control.

Review finding 3994491ef1fdba6e0e3645b5b713cbdbdeb2b328 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:52:21 +02:00
869dc9cf4a fix(cap): fail closed when the ledger cannot be READ, in both modules
An unreadable ledger returned 0 from countTurns in BOTH the primitive and
the hook, so a run whose ledger existed but could not be read (EISDIR,
EACCES, EIO) was handed the full budget again on every call - unbounded.
research-loop-cap.mjs argues against exactly that three lines above the
code that did it, and its missing-DIRECTORY case already failed closed.
The unreadable-FILE case now agrees with it.

Only ENOENT still counts as zero turns spent: that is the legitimate
first-turn state, and the reason this cannot just throw on any read
failure.

The hook no longer carries its own countTurns. It imports the primitive's
exported readLedger(), the same way it already resolves the data root
through resolveDataRoot() - a reader and a writer with private copies of
the counting rule is how a hook ends up enforcing a different bound than
the gate it backs. In scope + cannot count now exits 2 with a message
that says counting failed, not that the budget is spent.

Fail-closed stays scoped to the loop: a test pins that an unreadable
ledger in an OUT-of-scope session still exits 0, because a PreToolUse
hook that over-blocks bricks every session on the box.

Also dropped the existsSync pre-check before the read - readFileSync's
own ENOENT carries the same information without a second syscall that
can disagree with the read that follows it.

Review finding 5e1c6230f48ead38fa77cd8f4b06bfdc2b5b7bbf (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:49:08 +02:00
def6c05384 fix(research-loop-cap): floor the turn cap before the guard, not after
A fractional TREKRESEARCH_MAX_CONV_TURNS below 1 cleared the `n <= 0`
guard on its raw value and only then floored, so '0.5' and '0.9' became
0 and the budget became 0 x MAX_TOTAL_DIMENSIONS = 0: every turn denied,
the loop silently dead rather than bounded.

README.md:229 and docs/architecture.md:15 both promise that invalid
values fall back to 3. docs/command-modes.md:42 enumerated "empty,
non-numeric, zero, or negative" and happened to sidestep the case; the
enumeration is now exhaustive about it.

Measured before: '0.5' -> 0, '0.9' -> 0, '2.7' -> 2, '' / 'abc' / '-2'
/ '0' -> 3. Measured after: '0.5' -> 3, '0.9' -> 3, '2.7' -> 2, and
'Infinity' -> 3 (it is not a cap either).

A cap of 0 is not a narrower cap, it is an off switch. The tests pin
both directions: the fraction falls back, and allowTurn cannot report a
budget of 0 under it.

Review finding fc516799e6042e246a4b62d81903ac27c2efab84 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:46:35 +02:00
066b9da1a2 docs(governance): drop the local copy, point both links at the canonical file
D11 (org-ops census) consolidates GOVERNANCE.md to one canonical file in
repo-standard. Our copy was byte-identical to the removal-wave baseline
(md5 736fc9d6af84fbd83c9cc7f860d8c8b7, 131 lines); the canonical file
differs only by generalising plugin/marketplace wording to
repository/organisation (md5 3df3603325d3d6937fd560c3f67b5a5d, 131 lines,
HTTP 200 - both measured before this commit).

Removal and repointing land in the SAME commit so no
LINK-INTERNAL-MISSING window opens.

The census listed one link site (README.md:9). There are two: MIGRATION.md:9
carries a backtick prose reference that their suggested README-only grep
would not have caught. Both are repointed here; MIGRATION.md:9 becomes a
markdown link with its text unchanged.

Nothing gates on the file for this repo: repo-standard/register/repos.json
puts voyage in class "plugin", whose required_files are README.md, LICENSE,
CHANGELOG.md and .claude-plugin/plugin.json. GOVERNANCE.md is required only
for class "catalog".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:32:47 +02:00
6dafdf2a2a fix(research-loop-cap): resolve the data root in code so the loop can run
CLAUDE_PLUGIN_DATA is empty in the Bash tool's process env, and the Phase 5
bash snippet is the cap's only caller. resolveLedgerPath() returned null there
and allowTurn() failed closed, so the budget gate denied turn 1 of every real
run: the loop this delivery exists to bound could never spend a turn, and the
pre-registered measurement could not be run at all.

resolveDataRoot() is now the single root for everything the loop writes --
CLAUDE_PLUGIN_DATA when the harness sets it, ~/.claude/voyage when it does
not. Three consumers resolve through it, which is the point: the cap ledger,
the PreToolUse hook's scope-marker lookup, and the command's bash snippets.
A writer and a reader that resolved the root separately are what made the
enforcement hook allow unconditionally in every real run while CLAUDE.md and
docs/architecture.md called it enforcing.

Same root cause, same commit:
- Marker write and remove now share ONE absolute-path guard and one root; the
  write requires a non-empty CLAUDE_CODE_SESSION_ID before composing the path
  (unset, the marker was named `.json`, which no lookup matches and no TTL
  sweep cleans up).
- The per-turn gates resolve VOYAGE_ROOT with a plugin-cache fallback and
  reserve exit 2 for "gate could not run". Interpolating an empty
  ${CLAUDE_PLUGIN_ROOT} ran `node /lib/...` -> exit 1, which the contract read
  as "privacy gate says no" -- an unsatisfiable rewrite loop no query could
  clear.

Two now-unreachable deny branches are removed rather than left as dead safety
claims (allowTurn's no_plugin_data_dir; the hook's uncountable-ledger deny).
The fail-closed stance stays where it is still real: a ledger that cannot be
WRITTEN denies the turn.

Verified end-to-end through the real bash snippets and the real hook with both
variables stripped and HOME sandboxed: marker written under the fallback root,
8 turns spent, 9th denied, hook exits 2, and exits 0 again after removal.
Note: the fallback exit-2 branch fires against the installed v5.9.1 cache,
which predates lib/util/research-loop-cap.mjs -- correct behaviour, and it
clears when the plugin is reinstalled.

Review findings 2670c10a, fbd6d534, 93550dfb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
2026-08-12 22:26:46 +02:00
2e352a7dbb fix(trekresearch): gate Phase 4.5 on VOYAGE_STORM_ENABLED, not effort alone
CLAUDE.md claimed both STORM phases go inert when the flag is unset. Only
Phase 5 did: the flag check lives in research-loop-cap.mjs, and Phase 4.5
never invokes the cap — it was gated on effort: high alone. At high effort
with the flag unset, discovery still mined Phase-4 output and mutated the
dimension list, so `dimensions` diverged from `dimensions_baseline` and the
decline branch was unreachable for half the mechanism.

The code was the deviator, so the guard is fixed rather than the claim: the
Phase 4.5 skip-guard now names both conditions, with the reason inline.

Three surfaces scoped the flag to "the loop" and are corrected with it
(README, docs/command-modes, docs/architecture), plus the orchestrator phase
map. CLAUDE.md's claim is now true, but its stated MECHANISM was not — Phase
4.5's inertness comes from its own guard, not from the cap module — so that
sentence is corrected too.

New doc-consistency pin: the flag must be documented as gating both phases on
all four reference surfaces. Review finding 00a3af1a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
2026-08-12 22:17:56 +02:00
4c4457f6e9 fix(storm-measure): restore the pre-registered OR decision rule
The brief pre-registers "median forbedring >= 30 % pa (a) eller (b) ->
adopt. < 15 % -> decline." decideVerdict() required BOTH metrics to clear
the adopt bar and BOTH to fall under the decline bar, a rule introduced at
execution time (plan Step 11 restated the brief's wording unchanged).

The divergence is behavioural, not cosmetic: sources +90% / dimensions +10%
is adopt under the brief and was inconclusive in code; sources +2% /
dimensions +20% is decline under the brief and was inconclusive in code.

Adopt is evaluated before decline, so the OR bars do not conflict where they
overlap. docs/storm-measurement.md §2 and the test pins follow the same rule.

The stricter AND rule may well be the better decision procedure — but
changing it after the fact is exactly what pre-registration exists to
prevent. Review finding c37bf50d.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
2026-08-12 22:15:07 +02:00
21a96b9e31 fix(validators): hard-block the token formats the run-length patterns missed
Review finding 91e21c1f (MAJOR emitted, catalogue tier BLOCKER). Live in every
session regardless of the STORM flag.

The hard-block tier is the one thing no operator flag unlocks - not `strict`,
not `--soft`, not VOYAGE_QUERY_PRIVACY_ALLOW=1 - so a format it misses is a
secret leaving the machine in an outbound query with no second gate behind it.
Two patterns keyed on a run of consecutive alphanumerics, which a `-` or `_`
inside the token body breaks:

- /\bsk-[A-Za-z0-9]{20,}\b/ was commented "OpenAI/Anthropic-style" but the run
  ends after `api03` (3 chars) in an Anthropic Console key, so
  sk-ant-api03-<~95> passed through. Measured by execution, not read.
- /\bghp_[A-Za-z0-9]{36,}\b/ covered only the classic prefix: github_pat_<...>
  and gho_<36> passed through; ghp_<36> was blocked.

Widened with one pattern per real-world format rather than one loose pattern,
so each stays readable and its length floor stays honest:

- sk-ant-<scheme>-<20+ base64url>  (covers api03 and oat01)
- gh[pousr]_<36+>                  (classic PAT, OAuth, user, server, refresh)
- github_pat_<20+ incl. underscore> (fine-grained, real format is <22>_<59>)

Patterns whose body class includes `-`/`_` carry no trailing \b - it would not
fire on a non-word final character. The existing sk-/AKIA/xox/PEM patterns are
unchanged; the AWS comment is accurate as written, so it was left alone.

Formats verified against GitHub's token-format documentation and Anthropic key
anatomy before the patterns were written, not from memory:
- github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/
- gh[pousr]_ + 36 chars; github_pat_ + <22>_<59> = 93 total
- sk-ant-api03- + ~95 base64url chars (base64url includes _ and -)

6 new tests: three table rows for the missed formats, and three that pin each
one blocked with `strict: false` AND the opt-in env var set at once - the
property that makes this tier meaningful.

Suite 952 (950/0/2, baseline 937 + 15 across both Track A fixes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zNqxP8qTWgJhn3wMYUFEh
2026-08-12 22:03:43 +02:00
fa2404b63c fix(hooks): close the bypasses anchoring opened in the destructive-command rule
Review finding 4638fea9 (MAJOR emitted, catalogue tier BLOCKER). Live in every
session regardless of the STORM flag, so it is not deferrable to the measurement
decision.

Anchoring the rule to command position (S75-S78) was right in intent - the
unanchored word match blocked quoted grep patterns, heredoc data and commit
messages - but it ran against the whitespace-collapsed string. Measured against
normalizeCommand() output, five forms that the previous rule blocked were
allowed:

- newline separator: \s+ -> ' ' collapsed the newline BEFORE the pattern ran,
  making the \n branch of the separator class dead code
- `&` background separator: absent from the class entirely
- `bash -c` / `sh -c`: the wrapped command sits inside quotes, never at
  command position
- `xargs <cmd>`: no separator in front of the command at all

And it missed its own motivating case: `grep "a|b" f` stayed blocked, because
the `|` inside the quotes still read as a separator.

Fix: the rule now runs against a command-position view (`commandView: true`,
per-rule input selection) instead of the collapsed string. The view keeps
newlines, adds `&` to the separator class, and classifies each span:

- quoted spans -> data (one space), so a grep alternation, echoed prose and a
  commit message pass
- EXCEPT the argument of a shell wrapper (`sh -c`, `bash -c`, with optional
  sudo and absolute path) -> spliced back in at command position
- heredoc bodies -> data, keeping the operator line. Restoring the newline
  separator without this would newly block every heredoc line starting with a
  matched word - the exact friction anchoring existed to remove
- `xargs [flags]` -> separator inserted after the flags

Two defects found while verifying, both the same regression class and both
fixed here rather than left:

- `\name` runs name (the backslash only suppresses alias expansion). The old
  unanchored rule blocked it; the anchored one allowed it.
- heredoc bodies, as above - a false positive this change would otherwise have
  introduced.

Known limit, stated rather than implied: `xargs -I {} <cmd>` is not parsed, so
the inserted separator lands before the argument, not the command.

Other BLOCK rules are untouched and still run against the collapsed string.

Verified by a 37-case adversarial probe through the real hook (all five bypass
forms, both wrapper forms, backslash, heredoc, quoted alternation, ordinary
commands, and the unrelated rules): 37/37 as expected. 9 new tests.

Suite 952 (950/0/2, baseline 937 + 15 across both Track A fixes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zNqxP8qTWgJhn3wMYUFEh
2026-08-12 22:03:22 +02:00
cef3e7fa24 feat(trekresearch): wire Phase 5 scope marker so the cap hook enforces
pre-agent-cap.mjs (S78) enforces the Phase 5 loop bound only while a scope
marker exists for the calling session. Nothing wrote that marker, so the hook
shipped correct but latent. Phase 5 now writes it at loop start and removes it
on all three exits.

- Write: ${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json with
  {runId, startedAt}, keyed by CLAUDE_CODE_SESSION_ID. Verified 2026-08-12 that
  this equals the session_id on the hook's PreToolUse payload.
- runId must be the same --run-id the ledger is counted under; a mismatched id
  counts zero turns and enforces nothing.
- Fail-soft on write: the hook is defence in depth, research-loop-cap.mjs stays
  the gate. Report and continue. The reverse (skipping the budget gate because
  a marker exists) stays forbidden.
- Removal on every exit, load-bearing on the exhausted one: the hook keeps
  denying WebSearch/WebFetch/Task while the marker is there, and Phase 6 spawns
  agents. Crash is covered by the hook TTL, not by cleanup - stated as such
  rather than claiming cleanup covers it.
- Marker written in Phase 5, not Phase 4.5: 4.5 mines already-retrieved Phase-4
  results and spends no loop turns, so scoping there widens the window for
  nothing. Pinned by a test.

Six pins in tests/lib/doc-consistency.test.mjs derive the directory name from
SCOPE_DIRNAME in the hook and the payload fields from marker.runId/startedAt,
so drift in either direction fails. hooks/scripts/pre-agent-cap.mjs untouched.

Verified end-to-end with the snippets as shipped: marker written -> hook allows
under budget, denies 8/8 at budget, allows again after removal; removal is
idempotent; unset CLAUDE_PLUGIN_DATA takes the fail-soft branch.

Suite 937 (935/0/2, baseline 931 + 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R77nGJjZ1hqjAQQHefFdnc
2026-08-12 20:55:55 +02:00
fce11a1178 docs(research): remove literal keyword tripping verify SC1 2026-08-12 20:29:35 +02:00
9c91211fc0 docs(trekresearch): document STORM mechanisms across four surfaces 2026-08-12 20:28:26 +02:00
b194630842 feat(scripts): add STORM measurement harness with pre-registered thresholds 2026-08-12 20:25:27 +02:00
e9ff8ab023 feat(hooks): enforce research loop cap at PreToolUse or document the gap 2026-08-12 20:21:42 +02:00
f3874946ad feat(trekresearch): emit and export the five STORM measurement fields 2026-08-09 15:01:15 +02:00
d9cba9c6ea feat(trekresearch): add Phase 4.5 dimension discovery and amend Independence rule 2026-08-09 14:58:48 +02:00
3e8af75015 feat(trekresearch): replace single follow-up pass with bounded conversation loop 2026-08-09 14:56:57 +02:00
0a569eec55 feat(validators): add outbound query privacy gate for research egress 2026-08-09 14:26:17 +02:00
abc5bd8967 feat(util): add stateful default-off research loop cap 2026-08-09 14:23:24 +02:00
0556743bad docs(hooks): spike PreToolUse reach into sub-agent tool calls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:09:40 +02:00
9a38500a63 fix(exporters): allowlist trekresearch engine field and pin schema fixture agreement
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:04:47 +02:00
e1cf545a0c fix(hooks): anchor shutdown rule to command position, not any substring
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:02:01 +02:00
5bb6735c94 test(trekresearch): bound composition-rule slice by heading, not magic 2000
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 13:00:24 +02:00
9ffeae0e2e docs(readme): complete AI disclosure per org D12 (ownership basis)
D12 (org-ops docs/decisions.md, 2026-08-01) requires the plugin-class
disclosure line to be self-contained with three parts: generator, process,
and the ownership basis. Voyage carried the first two; adds the Anthropic
Consumer Terms §4 clause so the line no longer depends on a cross-repo
anchor that does not exist.

repo-standard v0.3.0: 14/14 OK. Suite: 832 (830 pass / 0 fail / 2 skipped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WfGRt8MkPVvnkGCzidTuis
2026-08-09 09:58:58 +02:00
2728a43656 docs(readme): replace dead disclosure link with inline text
The `[Full disclosure →](../../README.md#ai-generated-code-disclosure)`
link was inherited from the monorepo layout. In a standalone repo `../../`
resolves outside the repository (404), and the anchor never existed in any
README. Replaced with the inline sentence the polyrepo migration intended.

Reported by catalog (coord 20260803T191159Z-4541213484), confirmed by
repo-standard v0.2.0 gate (LINK-OUTSIDE-REPO, README.md:11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRYUCVByhmzWBMCCFSYK2j
2026-08-04 12:36:39 +02:00
79044624db docs(readme): align first screen with the org repo standard
repo-standard gate (plugin class) flagged 6 ERROR. Fixed all six:

- README-DESC: H1 is now `# voyage` (was `# trekplan — …`, stale from the
  rename) and the opening line is byte-identical to the forge description,
  so description == catalog == README on all three surfaces.
- INSTALL-NO-CLI + HEADING-MISSING: new first-screen `## Install` with the
  CLI install command (`claude plugin install voyage@ktg-plugin-marketplace`)
  beside `marketplace add`; the `enabledPlugins` JSON is kept as the second
  form. Retires the duplicate `## Installation` section further down (its
  architect paragraph already exists verbatim under Quick start) and drops
  the now-redundant install lines from the Quick start block.
- HEADING-MISSING: `## Non-goals` (promotes the existing "What it doesn't
  solve" list) and `## Changelog` (release notes moved off the first screen,
  per the standard, now a link + one line per recent version — keeps the
  doc-consistency pin on framing / brief_version 2.2).

Remaining 3 gate ERROR are a classifier defect in the gate, not this repo:
directory link targets (`agents/`, `examples/observability/`) are checked
against `git ls-files`, which never contains directories. Links are correct
and left as-is by operator decision; reported to repo-standard via coord.

Suite: 832 tests, 829 pass, 1 fail, 2 skipped — the failure is the
load-dependent otel tail-latency NFR (passes 3/3 in isolation), unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRYUCVByhmzWBMCCFSYK2j
2026-08-03 21:57:51 +02:00
8d39e1d4a5 fix(trekendsession): release v5.9.1 - eager-exec blocks crashed command load
Phase 3 (atomic-write) and Phase 4 (validator) used !`...` eager-exec
with unresolved runtime placeholders; the harness executes those at
command LOAD time, so zsh parsed <project-dir> as input redirection and
/trekendsession aborted before the model saw a single instruction.
Both blocks are now plain runtime Bash fences with {curly} placeholders
(shell-inert, trekplan.md convention) and absolute ${CLAUDE_PLUGIN_ROOT}
paths (cwd-relative plugin paths were a latent ERR_MODULE_NOT_FOUND in
any user repo). Phase 1 discovery block keeps its legitimate eager-exec.

Regression guard: new tests/commands/trekendsession.test.mjs flags any
!`-block in commands/*.md containing <angle>/{curly} placeholders, and
pins Phase 3/4 as runtime Bash. Suite 828 -> 832 (830/0/2). E2E: fixed
blocks run with real values write both state files, validator valid:true;
trekcontinue.md:147 runtime-verified self-contained.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NXzF3F2zAM8S7FCBXCqAb
2026-07-03 01:11:02 +02:00
49 changed files with 6204 additions and 291 deletions

View file

@ -1,12 +1,23 @@
{
"name": "voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"version": "5.9.0",
"version": "5.10.0",
"author": {
"name": "Kjell Tore Guttormsen"
},
"homepage": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace/src/branch/main/plugins/voyage",
"homepage": "https://git.fromaitochitta.com/open/voyage",
"repository": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git",
"license": "MIT",
"keywords": ["voyage", "trek", "planning", "implementation", "research", "context-engineering", "agents", "adversarial-review", "headless", "execution"]
"keywords": [
"voyage",
"trek",
"planning",
"implementation",
"research",
"context-engineering",
"agents",
"adversarial-review",
"headless",
"execution"
]
}

6
.gitignore vendored
View file

@ -42,3 +42,9 @@ NEXT-SESSION-PROMPT*.local.md
*.local.sh
.DS_Store
.claude/
# Operator working documents dropped into docs/ — never part of the published
# plugin. `origin` is a PUBLIC mirror, and S83 committed one of these with a
# broad `git add -A docs`, which needed a history rewrite to undo. Ignoring the
# type is cheaper than remembering not to stage it.
docs/*.pdf

View file

@ -4,6 +4,163 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased
### Fixed
- **`/trekreview` verdicts are fail-closed.** A finding removed by Pass 2 or
Pass 3, and a reviewer whose payload was thrown away or never arrived, were
arithmetically identical to a finding that never existed — all three pushed
the verdict toward `ALLOW`. Measured before the fix: a BLOCKER with a
101-character title → `ALLOW`; a payload carrying one ad-hoc `rule_key` was
skipped whole at ingest, taking a valid BLOCKER sibling with it → `ALLOW`.
`lib/review/coordinator-contract.mjs` now splits every removal two ways —
**dropped** when the test refuted the finding as a claim about this codebase,
**unverified** otherwise — and a non-empty `unverified` bucket, or a reviewer
in `expectedReviewers` that did not report, forbids `ALLOW` (verdict `WARN`,
with `allow_blocked_by` naming why). The rule never *raises* a verdict: the
severity catalogue and the BLOCKER/MAJOR thresholds are untouched. New
exports: `classifySuppression`, `REFUTING_REASONS`, `UNVERIFIED_REASONS`;
`runContract` gains `unverified`, `missing_reviewers`,
`unattributable_payloads` and `allow_blocked_by` (`suppressed` stays the
union, so existing consumers keep their meaning). A payload that fails schema
without carrying a `reviewer` name is counted as unattributable rather than
reported as a reviewer called "unnamed reviewer" — naming one would invent an
agent nobody launched and double-count with `expectedReviewers`.
Mirrored in `agents/review-coordinator.md` (Pass 2/3 fate columns, the new
§*Suppression is two-valued*, Pass 4 threshold table) and
`commands/trekreview.md` (Phase 5 reviewer accounting → STOP; Phase 6).
Driven test-first: 11 new tests in `tests/lib/coordinator-contract.test.mjs`,
incl. a known-positive control proving `ALLOW` is still reachable.
### Docs
- **`/trekresearch --engine deep-research`: document the real version window.**
Claude Code **2.1.218** changed `/deep-research` to start only when the operator
invokes it; from there the Skill tool refuses a model invocation outright with
`disable-model-invocation` (measured in a real `--engine deep-research` run on
2026-09-01 — the SC3 fallback to `swarm` held). The engine was built against
2.1.196 and the prose still promised a path Claude Code has removed
("requires Claude Code 2.1.154+"). `commands/trekresearch.md` (flag bullet +
pre-gate + fallback reason tokens), `docs/command-modes.md` and `README.md` now
state the closed window `2.1.154 <= CC < 2.1.218` and name
`disable-model-invocation` as the expected fallback reason on any current CC.
The pre-gate gained an upper ceiling (still a numeric comparison, not a string
one). **The flag is kept** as an additive opt-in that never hard-fails; no
`lib/` change, no adapter-contract change, no default change, no version bump.
Pinned by `tests/lib/doc-consistency.test.mjs`.
## v5.10.0 — 2026-08-18 — STORM bounded research loop (default-off) + the Agent-tool `name` spawn defect
Additive. Every new research mechanism ships **inert**: `VOYAGE_STORM_ENABLED` is
unset by default, and with it unset both new `/trekresearch` phases are no-ops.
The one change that affects consumers regardless of flags is the spawn rule under
*Fixed* — it changes how any repo should call the Agent tool.
### STORM bounded loop — shipped, default-off, behind a pre-registered adoption gate
- `/trekresearch` Phase 4.5 (dimension discovery, under the existing
`maxDimensions: 8` ceiling) and Phase 5 (bounded multi-turn follow-up,
replacing the single follow-up pass) run only at `effort: high` **and** only
when `VOYAGE_STORM_ENABLED=1`. Unset, Phase 5 is inert because
`lib/util/research-loop-cap.mjs` grants a budget of 0, and Phase 4.5 is inert
because its skip-guard reads the flag directly (it never calls the cap) — two
independent off-switches, so a bug in one does not silently activate the other.
- `TREKRESEARCH_MAX_CONV_TURNS` (default `3`; invalid values fall back to `3`)
sets turns per dimension. The total budget is that value × `maxDimensions`.
- New `lib/util/research-loop-cap.mjs`: an append-only ledger the cap counts its
own turns from. Each turn slot is claimed with `O_EXCL`, so the bound survives
concurrent turns; an unreadable ledger **fails closed** in both modules; and
the enforcement boundary is a denial tombstone rather than the turn count, so
a crash mid-turn cannot hand back a free turn.
- New `hooks/scripts/pre-agent-cap.mjs`, registered as a `PreToolUse` hook on
`WebSearch|WebFetch|Task`: the budget is enforced by the harness, not by prose
in a command file. `VOYAGE_DISABLE_CAP_HOOK=1` switches it off.
`docs/spike-pretooluse-subagent-reach.md` records the measured reach of
`PreToolUse` into sub-agent tool calls — including where the gap is.
- Five measurement fields (`unique_sources`, `dimensions_baseline`, `conv_turns`,
`empty_turns`, `dimensions_baseline_preserved`) are emitted to
`trekresearch-stats.jsonl` and allowlisted in `lib/exporters/field-allowlist.mjs`.
All five are counters or booleans — none carry prose or paths.
- `scripts/storm-measure.mjs` + `docs/storm-measurement.md`: the adoption gate,
with thresholds registered **before** the first measurement by design.
≥ 30 % improvement on either metric → adopt; < 15 % → decline; an empty arm is
`insufficient-data`, not a decline. Decline is a no-op — the mechanism simply
stays default-off; adopt is one constant. No measurement has been run yet.
### Added — outbound query privacy gate
- `lib/validators/query-privacy-gate.mjs` inspects a research query before it
leaves the machine, in the same two-tier shape as the existing SSRF gate: a
WARN tier (absolute filesystem paths, repo-internal identifiers) that
`--soft` / `VOYAGE_QUERY_PRIVACY_ALLOW=1` can override, and a HARD-BLOCK tier
(secret-shaped tokens) that **nothing** overrides. Called only from the two new
high-effort steps; the existing single-pass path is unchanged.
### Fixed — the Agent tool's `name` parameter silently breaks a subagent's return channel
- **Consumers of this pipeline should read `docs/agent-return-channel-defect.md`.**
Passing `name` to the Agent tool does not label a subagent — it changes what is
spawned: `taskKind: "in_process_teammate"` / `spawnDepth: 0` instead of a real
subagent (`spawnDepth: 1`). A teammate's final text is not a return value; it
reaches the parent only if the teammate itself calls `SendMessage(to: "main")`.
Voyage's reviewer agents carry `tools: [Read, Glob, Grep]` — no `SendMessage`
so when named they are *structurally* unable to answer. No error, no warning.
Measured numerators (66-line target): named without explicit `SendMessage`
**0/5** returned · named + explicit `SendMessage` **1/1** · unnamed **3/3** ·
work actually performed while named **5/5** — only delivery fails, and the
output is recoverable from
`~/.claude/projects/<project>/<session>/subagents/agent-*.jsonl`.
Independently confirmed at 3730 lines / 277 KB by `akashic-intelligence`
(4/4 correct final text on disk); the *returning* arm above 66 lines remains
inferred rather than observed, and the doc says so.
- The four spawning commands (`trekbrief`, `trekplan`, `trekresearch`,
`trekreview`) now state the rule at their spawn sites and name the mechanism.
`trekexecute` spawns nothing and is excluded. The `doc-consistency` test
**derives** the spawning set from the command files, so the pin cannot go
vacuous when a command starts or stops spawning.
### Fixed — hook and validator hardening
- The destructive-command rule anchored on command position rather than any
substring (a path containing a blocked word no longer trips it), and the
bypasses that anchoring itself opened are closed.
- The secret-detection validator hard-blocks token formats the run-length
patterns missed.
- The exporter allowlist covers `/trekresearch`'s `engine` field, with the JSONL
schema fixture pinned to agree — a new stats field is otherwise dropped
silently at export.
### Docs
- README: first screen aligned with the org repo standard, complete AI
disclosure, and the Gemini MCP tool contract described inline instead of via a
dead cookbook link. Governance keeps one canonical file, not a local copy.
- `/trekresearch`: STORM mechanisms documented across four surfaces; the
Independence crossing's two risks split, each naming its own control.
- `docs/*.pdf` is gitignored — `origin` is a public mirror and must never carry
operator PDFs.
### Tests
- Suite baseline 832 (830 pass / 0 fail / 2 skip) → **1013 (1011 / 0 / 2)**.
New coverage: research-loop-cap (including the concurrency and fail-closed
paths), the cap hook's crash-time marker branches, the query privacy gate, the
measurement harness's decision rule, and the spawn-rule doc pin.
## v5.9.1 — 2026-07-03 — Fix /trekendsession load-time crash (eager-exec placeholders)
Patch, no functional additions.
### Fixed
- `/trekendsession` was unusable in every invocation: two of its three `` !`...` `` eager-exec blocks (Phase 3 atomic-write, Phase 4 validator call) contained unresolved runtime placeholders (`<project-dir>` etc.). The harness executes eager-exec blocks at command LOAD time, so zsh parsed `<project-dir>` as input redirection and the command aborted before the model saw a single instruction. Both blocks are now plain runtime Bash fences with the `{curly}` placeholder convention (shell-inert), matching `trekplan.md`/`trekresearch.md`. The Phase 1 discovery block (self-contained) keeps its legitimate eager-exec prefix; `trekcontinue.md`'s discovery block was runtime-verified unaffected.
- Latent secondary bug in the same blocks: cwd-relative plugin paths (`lib/validators/...`, `./lib/util/atomic-write.mjs`) would have failed with `ERR_MODULE_NOT_FOUND` even after substitution, since the Bash cwd is the user's repo. Both now use absolute `${CLAUDE_PLUGIN_ROOT}` paths per the existing command convention (Node ESM accepts absolute-path import specifiers — verified on Node 18+).
### Added
- Regression guard `tests/commands/trekendsession.test.mjs`: scans every `` !` ``-block in `commands/*.md` for unresolved `<angle>`/`{curly}` placeholders (this bug class is silent until first invocation), plus structure tests pinning Phase 3/4 as runtime Bash with `${CLAUDE_PLUGIN_ROOT}` paths and exactly one surviving eager block. Suite baseline 828 → 832 (830 pass / 0 fail / 2 skip).
## v5.9.0 — 2026-07-02 — Fable model tier + deep-research engine
Additive, plus one behavior alignment: profile `phase_models` now reach sub-agent spawn sites (previously documented but never wired), and the seven command orchestrators no longer pin `model: opus` — frontmatter omits `model:`, so the orchestrator follows the session model.

View file

@ -24,6 +24,20 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
Full flag reference for each command (modes, `--gates`, `--profile`, breaking changes): see `docs/command-modes.md`.
> **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.
## Agents
| Agent | Model | Role |

View file

@ -1,131 +0,0 @@
# Governance
How this marketplace is maintained, what you can expect from upstream, and how it's meant to be used.
## TL;DR
- Solo-maintained, AI-assisted development, MIT licensed.
- **Fork-and-own is the default model.** Upstream is a starting point, not a vendor.
- Issues welcome as signals. Pull requests are not accepted — see [Why no PRs](#pull-requests--no).
- No SLA. Best-effort bug fixes and security advisories. Breaking changes happen and are noted in each plugin's CHANGELOG.
---
## Can I trust this?
Be honest with yourself about what you're adopting:
- **One maintainer.** If I get hit by a bus, the bus wins. The repos stay up under MIT, but no one owes you a fix.
- **AI-generated code with human review.** Every plugin is built through dialog-driven development with Claude Code. I read, test, and judge the output before it ships, but I'm not auditing every line the way a security firm would. Treat it accordingly.
- **No commercial interests.** I'm not selling a SaaS, not steering you toward a paid tier, not collecting telemetry. The plugins run locally in your Claude Code installation.
- **MIT licensed.** Fork it, modify it, ship it under your own name.
If you work somewhere that needs vendor accountability, support contracts, or signed assurances — **this isn't that.** Use it as a reference implementation, fork it into your own organization, and own the result.
---
## How this is meant to be used
### Fork-and-own
The intended workflow:
1. **Fork** the marketplace (or a single plugin) into your own organization or namespace.
2. **Tailor** it to your context — terminology, integrations, cycle lengths, regulatory framing, whatever doesn't fit out of the box.
3. **Maintain it yourself.** Treat your fork as the canonical version for your team.
4. **Watch upstream selectively.** Cherry-pick changes that help, ignore changes that don't. There's no obligation to stay in sync.
This isn't a workaround for not accepting PRs. It's the actual recommended adoption pattern, especially for plugins like `okr` and `ms-ai-architect` where every Norwegian public sector organization will need its own tildelingsbrev mappings, terminology, and integrations. A central "one true plugin" would be wrong for everyone.
### What to change first when you fork
Each plugin differs, but the common edits are:
- **Identity** — rename the plugin, replace authorship, update README.
- **External integrations** — issue trackers, knowledge bases, dashboards, observability backends. The plugins ship as starting points, not pre-wired. Every organization must configure its own integrations.
- **Norwegian-specific framing** — relevant for `okr` and `ms-ai-architect`. Other plugins are jurisdiction-neutral. Rewrite for your jurisdiction if you're outside Norway.
- **Reference docs** — the knowledge base in each plugin reflects my reading. Replace with your organization's authoritative sources.
- **Hooks and policies** — security thresholds, blocked commands, and audit gates are tuned to my taste. Tune them to yours.
### Staying current with upstream
If you want to pull in upstream changes later:
- **Cherry-pick, don't merge.** Each plugin moves independently and breaking changes land without ceremony.
- **Read the CHANGELOG first.** Every plugin has one.
- **Keep your customizations in clearly-named files.** The harder upstream is to merge cleanly, the more painful staying current becomes. A `local/` directory or `*.local.md` convention helps.
---
## What upstream provides
| | What I do | What I don't |
|---|---|---|
| **Bug fixes** | Best-effort when I notice or get a clear report | No SLA, no triage commitment |
| **Security issues** | Investigate within reasonable time, document in CHANGELOG | No CVE process, no embargo coordination |
| **New features** | When they fit my own usage | Not on request |
| **Norwegian public sector context** | Kept current as long as the project lives | If I lose interest or change jobs, the framing freezes |
| **Breaking changes** | Documented in CHANGELOG | They happen — version pin if you need stability |
| **Compatibility** | Tracked against current Claude Code releases | No long-term support branches |
If any of this is a dealbreaker — fork now, version-pin, and stop reading upstream.
---
## How to contribute
### Issues — yes, please
Issues are the most valuable thing you can send me:
- **Bug reports** with reproduction steps. Even a screenshot helps.
- **Use-case feedback.** "I tried to use this in my organization and X didn't fit" is genuinely useful, even if I can't fix it for you.
- **Pointers to better sources.** If you know a DFØ veileder, an NSM guideline, or an academic paper that contradicts what's in a knowledge base, tell me.
- **Security findings.** See each plugin's `SECURITY.md` for disclosure preference where one exists; otherwise email rather than open a public issue.
### Pull requests — no
This is deliberate, not laziness:
- **Solo review is a bottleneck.** Honest PR review takes me longer than rewriting from scratch. The math doesn't work.
- **Forks are where the value is.** The fork-and-own model means upstream consolidation isn't the point. Your organization's adaptations belong in your fork, not mine.
- **AI-generated code complicates provenance.** Every line here is produced through dialog with Claude Code, with me as the judge. Mixing in PRs from contributors with different processes and licensing assumptions creates a mess I'd rather not untangle.
If you've built something useful on top of a fork, **publish it under your own name and link back.** I'll happily list notable forks here once they exist.
### Notable forks
*(To be populated as forks emerge. If you've forked one of these plugins for production use, open an issue and I'll add a link.)*
---
## Relationship between plugins
These plugins are **independent**. Install one without the others, fork one without the others. They share conventions (slash command naming, hook patterns, AI-generated disclosure) but no runtime dependencies.
The marketplace is a **catalog**, not a suite. Don't fork the whole repo unless you actually want to maintain everything.
---
## Versioning and stability
- **Semantic versioning per plugin.** Each plugin has its own `CHANGELOG.md` and version number.
- **Breaking changes happen.** I bump the major version when they do, but I don't run an LTS branch.
- **Pin your version.** If stability matters more than features, install a specific version and stay there until you choose to upgrade.
---
## Public sector adoption notes
For Norwegian etater specifically:
- **DPIA-relevant data flows are documented in the relevant plugin README where applicable.** Read them before installation.
- **No data leaves your machine** beyond what Claude Code itself sends to Anthropic. The plugins themselves do not call external services unless you configure an integration.
- **Drøftingsplikt and ledelsesansvar** are not replaced by these tools. The `okr` plugin coaches; it does not decide. The `ms-ai-architect` plugin advises; it does not approve.
- **Choose your Claude deployment carefully.** claude.ai vs. API direct vs. Bedrock in EU region have different data residency profiles. The plugins don't choose for you.
---
## License
MIT for all plugins in this marketplace. See each plugin's `LICENSE` file.

View file

@ -6,7 +6,7 @@ v3.x → v4.0.0 is a rebrand. All command names changed:
`/ultrareview-local``/trekreview`, `/ultracontinue-local``/trekcontinue`,
`/ultraplan-end-session-local``/trekendsession`. The plugin is now
named `voyage`. Re-fork from main if upgrading. There is no migration
path — see `GOVERNANCE.md` for the fork-and-own model.
path — see [`GOVERNANCE.md`](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the fork-and-own model.
Prior version migration notes (v1→v2, v2→v3) are preserved in
`CHANGELOG.md` only.

View file

@ -1,17 +1,17 @@
# trekplan — Brief, Research, Plan, Execute, Review, Continue
# voyage
![Version](https://img.shields.io/badge/version-5.9.0-blue)
Contract-driven Claude Code pipeline: brief, research, plan, execute, review. Agent swarms, research triangulation, adversarial review, multi-session resumption.
![Version](https://img.shields.io/badge/version-5.10.0-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](GOVERNANCE.md) for the full model and what upstream provides.
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the full model and what upstream provides.
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
*AI-generated: all code produced by Claude Code through dialog-driven development. Every change is human-directed, reviewed, and validated before commit. Per Anthropic Consumer Terms §4, ownership of outputs is assigned to the user; this plugin is licensed MIT.*
A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep implementation planning, multi-source research, autonomous execution, independent post-hoc review, and zero-friction multi-session resumption. Six commands, one pipeline:
> **What's new — v5.8.0: offline gold-scored output eval (SKAL-1·4b).** The review-coordinator self-eval gains a scoring run: `lib/review/gold-scorer.mjs` grades a committed agent-run fixture against the golden corpus at `(file, rule_key)` granularity (precision/recall/f1 + verdict match), and the suite census gains a third category (`goldEval`) so a scoring run is counted apart from behavior coverage and doc-pins. Offline + deterministic — committed reviewer payloads, no live agent spawn (the LLM-in-the-loop tier is the separate 4c). Internal eval infrastructure; no command/agent/Handover change. **v5.7.1:** leaner always-loaded agent listing — `<example>` blocks relocated to agent bodies (~3,180 tok/turn, no behavior change). **v5.7.0:** opt-in per-session token/cost metering (SKAL-2) + eval foundation (SKAL-1·4a). **v5.6.1:** one-line `description:` for the four reference/dormant agents (~700 tok). **v5.5.0:** brief **framing** enforcement (`brief_version 2.2`) + a `/trekreview` reviewer-schema contract. Additive — no breaking changes. **Full version history → [CHANGELOG.md](CHANGELOG.md).**
| Command | What it does |
|---------|-------------|
| **`/trekbrief`** | Brief — interactive interview produces a task brief with explicit research plan |
@ -21,6 +21,23 @@ A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep
| **`/trekreview`** | Review — independent post-hoc review of delivered code against the brief, severity-tagged findings |
| **`/trekcontinue`** | Continue — read `.session-state.local.json` and resume the next session in a multi-session project |
## Install
```bash
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
claude plugin install voyage@ktg-plugin-marketplace
```
Or enable directly in `~/.claude/settings.json`:
```json
{
"enabledPlugins": {
"voyage@ktg-plugin-marketplace": true
}
}
```
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written artifact and printing the resulting `file://<abs path>` link. The operator opens the HTML in a browser, clicks any line of the document, writes their own note in the inline textarea, watches a sidebar of all notes (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt that they paste back into Claude — Claude then revises the `.md` from the notes. **The operator drives every annotation.** See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v502).
Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, and `review.md`.
@ -68,9 +85,6 @@ Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle
## Quick start
```bash
# Install the marketplace, then browse and enable plugins with /plugin
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
# Capture intent (interactive)
/trekbrief Add user authentication with JWT tokens
# → .claude/projects/2026-04-18-jwt-auth/brief.md
@ -127,7 +141,10 @@ Concrete capabilities, observable in the code — not aspirations.
**Virksomhet / regulated environment.** Defense-in-depth security across four layers (plugin hooks, prompt-level denylist, pre-execution plan scan, scoped tool access). `disableSkillShellExecution: true` recommendation for fork-ers handling untrusted briefs. No cloud dependency, no GitHub requirement. Validators are plain-Node CLIs — invocable from CI, custom hooks, or external tools, not just from voyage commands.
**What it doesn't solve:**
## Non-goals
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.
@ -195,10 +212,28 @@ Output:
| **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; falls back to `swarm` if unavailable. Default `swarm`. |
| **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) plus the optional Gemini bridge for an independent second opinion. Per-agent details in [`agents/`](agents/).
---
@ -757,26 +792,6 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
**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.
## Installation
Add the marketplace and browse plugins with `/plugin`:
```bash
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
```
Or enable directly in `~/.claude/settings.json`:
```json
{
"enabledPlugins": {
"voyage@ktg-plugin-marketplace": true
}
}
```
An optional architect step between research and plan was previously available via a separate plugin; that architect plugin is no longer publicly distributed. The `architecture/overview.md` filesystem slot remains supported by `/trekplan` for any compatible producer.
## Profile system (v4.1.0)
Four built-in model profiles plus operator-defined `<custom>.yaml` (drop in `lib/profiles/`). Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>` and emitted to JSONL stats for cost-attribution.
@ -822,7 +837,7 @@ For per-profile cost estimates, see [`docs/profiles.md`](docs/profiles.md).
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (CLI, desktop app, or web app)
- Claude subscription with Opus access (Max plan recommended)
- Optional: [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) for enhanced external research
- Optional: [Gemini MCP server](https://github.com/anthropics/anthropic-cookbook/tree/main/tool-use/gemini-mcp) for independent second opinion via Gemini Deep Research
- Optional: a Gemini Deep Research MCP server exposing `gemini_deep_research`, `gemini_get_research_status`, `gemini_get_research_result`, `gemini_research_followup` for an independent second opinion (voyage calls these tools; any server implementing them works)
## Architecture
@ -834,7 +849,7 @@ trekplan/
│ └ 21 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned)
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
├── templates/ Frontmatter templates for brief, research, plan, session, launch
├── hooks/ 7 hooks (pre-bash, pre-write, session-title, post-bash-stats, pre-compact-flush, post-compact-flush, otel-export)
├── hooks/ 8 hooks (pre-bash, pre-write, pre-agent-cap, session-title, post-bash-stats, pre-compact-flush, post-compact-flush, otel-export)
├── 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 + architect-bridge-test.md
@ -921,6 +936,18 @@ suppress this, leave the `architecture/` directory absent from your
project directory. Discovery is additive — missing file is fine, no
error.
## Changelog
Full version history → [CHANGELOG.md](CHANGELOG.md).
Recent, in one line each:
- **v5.8.0** — offline gold-scored output eval (SKAL-1·4b): `lib/review/gold-scorer.mjs` grades a committed agent-run fixture against the golden corpus at `(file, rule_key)` granularity; suite census gains a `goldEval` category. Offline + deterministic, no live agent spawn.
- **v5.7.1** — leaner always-loaded agent listing (`<example>` blocks moved into agent bodies, ~3,180 tok/turn, no behavior change).
- **v5.7.0** — opt-in per-session token/cost metering (SKAL-2) + eval foundation (SKAL-1·4a).
- **v5.6.1** — one-line `description:` for the four reference/dormant agents (~700 tok).
- **v5.5.0** — brief **framing** enforcement (`brief_version 2.2`) + a `/trekreview` reviewer-schema contract. Additive, no breaking changes.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).

View file

@ -2,13 +2,14 @@
## Reporting a vulnerability
Open a **private** issue on Forgejo:
Report privately to <security@fromaitochitta.com> — do not open a public
issue. There is no SLA — this is a solo-maintained plugin — but
acknowledged reports are usually triaged within 7 days.
> https://git.fromaitochitta.com/open/ktg-plugin-marketplace
If you already have an account on the Forgejo instance, you may instead
open a **private** issue on the repo, tagged `security`:
Tag it `security` and mark it private. Do not file public issues for
unpatched vulnerabilities. There is no SLA — this is a solo-maintained
plugin — but acknowledged reports are usually triaged within 7 days.
> https://git.fromaitochitta.com/open/voyage
## Supported versions

View file

@ -7,12 +7,21 @@ tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
---
<!-- Phase mapping: orchestrator → command
Corrected in v5.10: every row below was off by one, and the old last row
pointed at a ninth command phase that does not exist — the command ends
at Phase 8.
Orchestrator Phase 1 = Command Phase 4 (Agent group selection)
Orchestrator Phase 2 = Command Phase 5 (Parallel research)
Orchestrator Phase 3 = Command Phase 6 (Targeted follow-ups)
Orchestrator Phase 4 = Command Phase 7 (Triangulation)
Orchestrator Phase 5 = Command Phase 8 (Synthesis + write brief)
Orchestrator Phase 6 = Command Phase 9 (Completion)
Orchestrator Phase 2 = Command Phase 4 (Parallel research — same
command phase; the orchestrator
splits selection from launch)
(no orchestrator phase)= Command Phase 4.5 (Dimension discovery, high
effort AND VOYAGE_STORM_ENABLED
=1 only — v5.10)
Orchestrator Phase 3 = Command Phase 5 (Targeted follow-ups; bounded
conversation loop at high effort)
Orchestrator Phase 4 = Command Phase 6 (Triangulation)
Orchestrator Phase 5 = Command Phase 7 (Synthesis + write brief)
Orchestrator Phase 6 = Command Phase 8 (Present and track / completion)
As of v2.4.0, /trekresearch runs these phases inline in main
context instead of spawning this agent. Keep this file as the canonical
reference for what those phases do. -->

View file

@ -84,31 +84,48 @@ identical `(file, rule_key)` and `line == 0` collide.
### Pass 2 — HubSpot Judge filters (3 criteria)
Drop findings that fail ANY of these filters:
Remove findings that fail ANY of these filters. **The `Removed as` column
is load-bearing** — see *Suppression is two-valued* below:
| Filter | Test | Drop if |
|--------|------|---------|
| Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text |
| Accuracy | `file` resolves under the repo root AND `line` is plausible (≥ 0; ≤ file line count when known) | Path traversal escape, negative line, or impossibly large line number |
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title |
| Filter | Test | Fails if | Removed as |
|--------|------|----------|------------|
| Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text | `unverified` (`succinctness:title` / `succinctness:detail`) |
| Accuracy | `file` resolves under the repo root AND `line` is plausible (≥ 0; ≤ file line count when known) | Path traversal escape, negative line, or impossibly large line number | **dropped** (`accuracy:refuted`) |
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title | `unverified` (`actionability:empty`) |
When dropping a finding, preserve a one-line note in the
Succinctness and Actionability read the finding's *packaging*; neither
examines the claim, so neither can establish the finding is unreal. Accuracy
does: a citation that escapes the repo root refutes the finding as a claim
about this codebase.
When removing a finding, preserve a one-line note in the
`Suppressed Findings` body section so the user knows why the count
shrank.
### Pass 3 — Cloudflare reasonableness (skipped in quick mode)
Drop findings that fail ANY of these tests:
Remove findings that fail ANY of these tests:
- **No file:line citation.** `file` is empty, or `line < 0`. Speculative
"code might break somewhere" findings have no anchor and are dropped.
- **Unknown rule_key.** `rule_key` is not in `RULE_CATALOGUE`. Reviewers
occasionally emit ad-hoc rule keys; the catalogue is the contract.
- **No file:line citation****dropped** (`no-citation`). `file` is empty,
or `line < 0`. Speculative "code might break somewhere" findings name no
location, so they make no checkable claim at all.
- **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.
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
both tree and diff → **dropped** (`file-existence:refuted`); Glob resolves
and the file is present → keep; **Glob cannot decide** (path outside the
working tree, unreadable, or the tool errored) → `unverified`
(`file-existence:indeterminate`). Never collapse *unresolvable* into
*refuted*.
- **Catalogue severity mismatch.** `severity` does not match the rule's
catalogue tier (e.g., `MISSING_TEST` emitted as MINOR). Reset to the
catalogue tier; this is a correction, not a drop.
catalogue tier; this is a correction, neither a drop nor an unverified.
In `quick` mode, skip this pass entirely. Note the skip in the
Executive Summary so the reader knows reasonableness was not applied.
@ -125,6 +142,32 @@ purposes. This normalization happens BEFORE writing review.md,
ensuring all `rule_key` values in the final review match the
catalogue.
### Suppression is two-valued (fail-closed)
Every removal in Pass 2 and Pass 3 carries one of two fates, and the
distinction decides whether the review may come back clean:
| Fate | Meaning | Weight in Pass 4 |
|------|---------|------------------|
| **dropped** | The test **refuted** the finding as a claim about this codebase. | None. It weighs nothing, correctly. |
| **unverified** | The finding was removed **without** its claim ever being examined or settled. | Forbids `ALLOW`. |
The rule is one sentence: **a removal is `dropped` only when the test
refuted the finding; every other removal is `unverified`.** A reason you
cannot place is `unverified` — the default fails closed.
Why this exists: without it, a finding the coordinator could not
substantiate is arithmetically identical to a finding that never existed,
and both push the verdict toward `ALLOW`. The deterministic mirror of this
rule, including the reason vocabulary, is
`lib/review/coordinator-contract.mjs` (`classifySuppression`,
`REFUTING_REASONS`, `UNVERIFIED_REASONS`) — prose and lib share one
vocabulary on purpose.
**Unverified findings are not counted into a severity tier.** Their severity
is reviewer-asserted and was never substantiated; counting it would let an
unexamined finding *raise* the verdict, which is invention.
### Pass 4 — Compute verdict
Count findings by severity AFTER dedup and filtering. Verdict thresholds:
@ -133,7 +176,19 @@ Count findings by severity AFTER dedup and filtering. Verdict thresholds:
|--------|---------|
| `BLOCKER ≥ 1` | `BLOCK` |
| `BLOCKER == 0` AND `MAJOR ≥ 1` | `WARN` |
| `BLOCKER == 0` AND `MAJOR == 0` | `ALLOW` |
| `BLOCKER == 0` AND `MAJOR == 0` AND nothing `unverified` AND every reviewer reported | `ALLOW` |
| `BLOCKER == 0` AND `MAJOR == 0` AND (`unverified` non-empty OR a reviewer did not report) | `WARN` |
The fail-closed row never RAISES a verdict — it only withholds the clean
one. The worst case of a false `unverified` is `WARN` plus a stated reason;
the worst case of the old behaviour was a silent `ALLOW` over a live
BLOCKER.
**When `ALLOW` is withheld, the Executive Summary's FIRST sentence must say
so and name why** — e.g. "WARN: no blocking findings survived, but 1 finding
could not be verified (succinctness:title) and brief-conformance-reviewer did
not report." A withheld ALLOW that the reader cannot see is the same defect
in a new place.
Verdict is mechanical — never override. The verdict goes into the
trailing JSON block AND the Executive Summary's first sentence.
@ -181,8 +236,10 @@ prefix). Flow-style `findings: [a, b]` breaks the frontmatter parser.
5. `## Findings (MAJOR)` — one subsection per MAJOR finding.
6. `## Findings (MINOR)` — one subsection per MINOR finding.
7. `## Findings (SUGGESTION)` — one subsection per SUGGESTION finding.
8. `## Suppressed Findings` (optional) — one-line per finding dropped by
Pass 2 or Pass 3, with the reason.
8. `## Suppressed Findings` (optional) — one line per finding removed by
Pass 2 or Pass 3, with the reason AND its fate, tagged `[dropped]` or
`[unverified]`. Unverified lines come first: they are the ones that
withheld `ALLOW`.
9. `## Remediation Summary` — bullet count per severity + 1 sentence on
what /trekplan will consume.
@ -204,6 +261,7 @@ The LAST fenced block in the file is a `json` block:
{
"verdict": "BLOCK | WARN | ALLOW",
"counts": { "BLOCKER": N, "MAJOR": N, "MINOR": N, "SUGGESTION": N },
"allow_blocked_by": ["unverified:succinctness:title (1)", "missing-reviewer:brief-conformance-reviewer", "unattributable-payload (1)"],
"findings": [
{
"id": "<40-char-hex>",
@ -243,9 +301,14 @@ for the ID list.
the canonical 40-char SHA1 from `(file, line, rule_key, title)` using
the algorithm in `lib/parsers/finding-id.mjs`. The frontmatter
`findings:` list and the JSON block IDs must match.
- **Suppressed findings are accountable.** When you drop a finding via
Pass 2 or Pass 3, log it in `## Suppressed Findings` with the reason.
Silent drops break the audit trail.
- **Suppressed findings are accountable.** When you remove a finding via
Pass 2 or Pass 3, log it in `## Suppressed Findings` with the reason and
its fate (`[dropped]` / `[unverified]`). Silent drops break the audit
trail.
- **Never spend an unexamined finding as evidence of a clean review.** If a
removal did not refute the finding, it is `unverified` and `ALLOW` is off
the table. This is the one place where you may not be minimal: when in
doubt about a reason's fate, it is `unverified`.
- **No invention.** Never add a finding that did not appear in the
reviewer outputs. Never escalate a finding's severity beyond what the
catalogue specifies.

View file

@ -507,6 +507,18 @@ is available, pass nothing — the reviewer scores the memory-alignment dimensio
Launch the `brief-reviewer` agent (foreground, blocking) with the prompt:
> **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"` (`spawnDepth: 0`)
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
> not a return value — it reaches the orchestrator only if the teammate itself
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
> `tools:` allowlist without SendMessage. The agent still runs and still
> produces correct output; the result is simply never delivered, so the phase
> presents as a hung agent that no re-prompting can revive. 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`.
> "Review this task brief for quality: `{PROJECT_DIR}/brief.md.draft`.
> Check completeness, consistency, testability, scope clarity,
> research-plan validity, and memory alignment. Report findings, verdict, and

View file

@ -90,16 +90,16 @@ want an interactive flow, use `/trekcontinue --help` to see the full pipeline.
## Phase 3 — Atomically write `.session-state.local.json` + sibling NEXT-SESSION-PROMPT.local.md
Write `<project-dir>/.session-state.local.json` with the schema-v1 object:
Write `{project_dir}/.session-state.local.json` with the schema-v1 object:
```json
{
"schema_version": 1,
"project": "<project-dir>",
"next_session_brief_path": "<arg 1>",
"next_session_label": "<arg 2>",
"project": "{project_dir}",
"next_session_brief_path": "{arg 1}",
"next_session_label": "{arg 2}",
"status": "in_progress",
"updated_at": "<now, ISO-8601>"
"updated_at": "{now, ISO-8601}"
}
```
@ -114,14 +114,22 @@ Under `node --input-type=module -e "<script>" arg1 arg2 arg3`, Node sets
This phase ALSO writes a sibling `NEXT-SESSION-PROMPT.local.md` in the
project directory with YAML frontmatter (`produced_by: trekendsession`,
`produced_at: <ISO-8601>`, `project: <project-dir>`). Both files are written
in a single ESM block so the writes succeed or fail together:
`produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written
in a single ESM block so the writes succeed or fail together.
Run the block below via the Bash tool at runtime, substituting the resolved
values for the `{curly}` placeholders (Phase 1 gives `{project_dir}`, Phase 2
gives `{next_brief_path}` and `{next_label}`). This is NOT an eager-exec
block — the values do not exist at command-load time. The import path must
stay absolute via `${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's
repo, not the plugin root, so a cwd-relative import throws
`ERR_MODULE_NOT_FOUND`:
```bash
!`node --input-type=module -e "
node --input-type=module -e "
import path from 'node:path';
import { writeFileSync } from 'node:fs';
import { atomicWriteJson } from './lib/util/atomic-write.mjs';
import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs';
const [, dir, brief, label] = process.argv;
const now = new Date().toISOString();
const stateObj = { schema_version: 1, project: dir, next_session_brief_path: brief, next_session_label: label, status: 'in_progress', updated_at: now };
@ -132,26 +140,28 @@ const promptBody = '---\\nproduced_by: trekendsession\\nproduced_at: ' + now + '
writeFileSync(promptFile, promptBody);
console.log(stateFile);
console.log(promptFile);
" '<project-dir>' '<next-brief-path>' '<next-label>'`
" '{project_dir}' '{next_brief_path}' '{next_label}'
```
## Phase 4 — Validate + narrate
Validate the freshly-written state file:
Validate the freshly-written state file via the Bash tool at runtime,
substituting the resolved `{project_dir}` (NOT eager-exec — the file does
not exist at command-load time):
```bash
!`node lib/validators/session-state-validator.mjs --json <project-dir>/.session-state.local.json`
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/session-state-validator.mjs --json {project_dir}/.session-state.local.json
```
If `valid: true`, print the success block matching `/trekcontinue` Phase 3
narration (SC-8 cross-project consistency — same template both sides):
```
Session state written: <project-dir>/.session-state.local.json
Session state written: {project_dir}/.session-state.local.json
Project: <project-dir>
Next session: <next-label>
Brief: <next-brief-path>
Project: {project_dir}
Next session: {next_label}
Brief: {next_brief_path}
In a fresh Claude session, run /trekcontinue to resume.
```

View file

@ -419,19 +419,46 @@ to stderr but do NOT block the stop; `progress.json` is still authoritative.
`status: stopped`) so the next-session producer-mismatch check has both
candidates available. Use the same combined ESM block pattern as Phase 8.
### Check 2 — Plan file is tracked by git
### Check 2 — Plan file reaches every worktree
Run `git ls-files --error-unmatch {plan-path} 2>/dev/null`. If the plan file is
untracked (exit code != 0):
Worktrees are created from HEAD, so tracking the plan file is the cheapest way
to make it visible in each one. But the project directory may be **gitignored**
`.claude/projects/` is tool-managed and local-only, and a repo that ignores it
is normal, not exotic. `git add -f` is **not** the answer there: it would push
operator-local artifacts into history, and into whatever remote the repo
publishes to. When the plan file is ignored, Phase 2.6 Step 2a' (which copies
brief/plan/research into each worktree) is the delivery path, and this check
must step aside instead of failing.
```bash
git add {plan-path}
PLAN_PATH="{plan-path}"
if git ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
PLAN_TRACKING="tracked"
else
git check-ignore -q "$PLAN_PATH"
case "$?" in
0) PLAN_TRACKING="ignored" ;;
1) PLAN_TRACKING="untracked" ;;
*) echo "Error: git check-ignore failed on $PLAN_PATH - a fatal probe is not an answer about ignore status." >&2
exit 1 ;;
esac
fi
if [ "$PLAN_TRACKING" = "untracked" ]; then
git add "$PLAN_PATH"
git commit -m "chore: track plan file for parallel execution"
fi
```
Report: `Plan file committed for worktree visibility.`
Report by outcome:
This ensures every worktree created from HEAD will have the plan file.
| `PLAN_TRACKING` | Report |
|---|---|
| `tracked` | `Plan file already tracked.` |
| `untracked` | `Plan file committed for worktree visibility.` |
| `ignored` | `Plan file is gitignored — not forced into history. Step 2a' copies it into each worktree.` |
Any other `git check-ignore` exit code is fatal and stops execution: a probe
that failed is not a probe that answered "not ignored".
### Check 3 — Scope fence overlap validation
@ -478,7 +505,7 @@ If cleanup fails, report the manual commands and stop.
After all 4 checks pass:
```
Pre-flight: PASS (clean tree, plan tracked, no overlaps, no stale worktrees)
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
```
## Phase 2.6 — Multi-session orchestration (worktree-isolated)
@ -604,27 +631,45 @@ Insert this block AFTER the worktree-creation loop and BEFORE wave dispatch
```bash
PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"
REPO_ROOT_REAL="$(realpath "${REPO_ROOT}")"
# Compute destination relpath: PROJECT_DIR relative to REPO_ROOT.
# This makes $wt/$PROJECT_REL valid regardless of whether the operator
# passed --project as relative (.claude/projects/...) or absolute.
PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"
# python3 + os.path.relpath is stdlib and portable - see the note below the
# block for why no realpath flag may be used here.
PROJECT_REL="$(python3 -c 'import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' "$PROJECT_SOURCE" "$REPO_ROOT_REAL")"
case "$PROJECT_REL" in
""|..*)
echo "Error: cannot derive a project relpath inside the repo ($PROJECT_SOURCE vs $REPO_ROOT_REAL)." >&2
exit 1 ;;
esac
for wt in "$WORKTREE_DIR"/session-*; do
[ -d "$wt" ] || continue
mkdir -p "$wt/$PROJECT_REL"
cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"
cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"
[ -d "$PROJECT_SOURCE/research" ] && \
if [ -d "$PROJECT_SOURCE/research" ]; then
cp -r "$PROJECT_SOURCE/research" "$wt/$PROJECT_REL/"
fi
done
```
Note: `realpath --relative-to` is GNU coreutils. macOS users without
`coreutils` (Homebrew `brew install coreutils` provides `grealpath`) may
substitute a Python fallback:
`python3 -c "import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))" "$PROJECT_SOURCE" "$REPO_ROOT"`.
Do not "improve" the relpath line into `realpath --relative-to=...`. That flag
is GNU coreutils only; BSD `realpath` (the macOS default) rejects it, and the
failure is **silent** — the command substitution leaves `PROJECT_REL` empty, so
`mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed and drop `brief.md`/`plan.md`
at the worktree root, where no child session looks for them. Measured on an
Intel Mac 2026-08-31: `realpath --relative-to=... ` → `realpath: illegal option
-- -`, while bare `realpath <path>` works; a whole wave ran with zero steps
executed. Both `realpath` calls above are bare path resolution, which BSD and
GNU handle identically; resolving both operands before the relpath is what keeps
it correct when one side goes through a symlink (macOS `/var``/private/var`).
Failure mode: any `cp` failure exits the wave non-zero; reported via Step 4
cleanup. Source: brief Constraint 2.
Failure modes: an underivable relpath (empty, or outside the repo) aborts
before anything is copied — better a loud stop than files delivered where no
session reads them; any `cp` failure exits the wave non-zero, reported via
Step 4 cleanup. A project without `research/` is not a failure.
Source: brief Constraint 2.
**2b. Launch sessions in this wave (each in its own worktree):**

View file

@ -406,6 +406,18 @@ the quick-scan results.
**All other modes:** Launch exploration agents **in parallel** (all in a single
message). Use the specialized agents from the `agents/` directory.
> **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"` (`spawnDepth: 0`)
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
> not a return value — it reaches the orchestrator only if the teammate itself
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
> `tools:` allowlist without SendMessage. The agent still runs and still
> produces correct output; the result is simply never delivered, so the phase
> presents as a hung agent that no re-prompting can revive. 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`.
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved,
medium: default, large: default) instead of dropping agents.

View file

@ -87,9 +87,14 @@ Supported flags:
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 (requires Claude Code 2.1.154+ and dynamic workflows enabled; falls back
to `swarm` and notes the fallback if unavailable — never hard-fails). Orthogonal
to `--profile`/`phase_signals`; only affects the external phase. Set
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:
@ -328,9 +333,13 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
1. **Coarse pre-gate (best-effort, NOT a trust signal).** `Bash: claude --version`;
parse the leading `X.Y.Z` (e.g. from `2.1.196 (Claude Code)`) and compare
numerically against `2.1.154` — split each on `.` and compare major, then minor,
numerically against **both ends** of the supported window
`2.1.154 <= version < 2.1.218` — split each on `.` and compare major, then minor,
then patch as integers (do NOT string-compare; lexical comparison mis-orders
multi-digit patch numbers). If the version is `< 2.1.154`, OR if
multi-digit patch numbers). If the version is `< 2.1.154` (below the
dynamic-workflows floor), OR `>= 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), OR if
`disableWorkflows: true` / `CLAUDE_CODE_DISABLE_WORKFLOWS=1` is set, skip to the
fallback (step 4). **If `claude` is not on PATH inside the Bash tool (possible
under `claude -p`) or the version cannot be parsed, treat the pre-gate as
@ -347,14 +356,18 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
`/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), because the disabled-headless behavior is
undocumented: no recognizable cited report in context → fall back, regardless of
how the failure surfaces.
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`. **NEVER fabricate
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), `version-below-floor`, `workflows-disabled`,
`no-cited-report`. **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.
@ -387,19 +400,264 @@ other agents — the value of Gemini is independence.
- Launch ALL selected agents **in parallel** in a single message
- Use model: "opus" for all sub-agents (the orchestrator runs on Opus)
- **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"`
(`spawnDepth: 0`) instead of a subagent (`spawnDepth: 1`). A teammate's final
assistant text is not a return value — it reaches the orchestrator only if
the teammate itself calls `SendMessage(to: "main")`, and every agent in
`agents/` declares a `tools:` allowlist without SendMessage. The agent still
runs and still produces correct output; the result is simply never delivered,
so the phase presents as a hung agent that no re-prompting can revive.
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`.
- Scale maxTurns by codebase size for local agents (same as trekplan):
small = halved, medium/large = default
- 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` (`settings.json:16`). The ceiling is **not**
raised here, so the documented 38 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 `settings.json:16` value.
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 — areas where findings are
thin, contradictory, or missing.
Review all agent results. Identify knowledge gaps — dimensions where findings
are thin, contradictory, or missing (**under-illuminated dimensions**).
For each significant gap, launch a targeted follow-up agent (model: "opus")
with a narrow, specific brief. Maximum 2 follow-ups.
**Standard and low effort — unchanged single pass.** For each significant gap,
launch a targeted follow-up agent (model: "opus") with a narrow, specific
brief. Maximum 2 follow-ups. If no gaps exist, skip: "Initial research
sufficient — no follow-ups needed." Then go to Phase 6.
If no gaps exist, skip: "Initial research sufficient — no follow-ups needed."
**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 (`settings.json:16`), 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.
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
case "$VOYAGE_ROOT" in
/*) ;;
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage 2>/dev/null | head -1)" ;;
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.
**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
@ -535,6 +793,12 @@ Record format (one JSON line):
"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": {true|false},
@ -544,6 +808,21 @@ Record format (one JSON line):
}
```
**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).
If `${CLAUDE_PLUGIN_DATA}` is not set or not writable, skip tracking silently.
## Profile (v4.1)
@ -623,6 +902,14 @@ significant architectural questions or when triangulation value is
high; in high-effort mode it runs unconditionally to provide an
independent second opinion.
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): use the existing conditional triggers.
Low effort: inline research only, no agent swarm (existing
`--quick`-equivalent code-path).
@ -634,6 +921,32 @@ Low effort: inline research only, no agent swarm (existing
- **Sources required:** Every claim must cite a source. No unsourced findings.
- **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, Gemini, MS Learn),
proceed with available tools and note limitations in brief metadata.
- **Cost:** Model resolution at Agent-spawn sites is a three-layer fallback:

View file

@ -199,6 +199,18 @@ described in the rest of this phase.
Launch two reviewer agents **in parallel** via the Agent tool — one
message, multiple tool calls.
> **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"` (`spawnDepth: 0`)
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
> not a return value — it reaches the orchestrator only if the teammate itself
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
> `tools:` allowlist without SendMessage. The agent still runs and still
> produces correct output; the result is simply never delivered, so the phase
> presents as a hung agent that no re-prompting can revive. 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`.
Reviewers run independently. Do NOT pre-feed findings between them.
| Agent | Mode-gated | Purpose |
@ -237,6 +249,37 @@ do not feed unvalidated findings to the coordinator.
In `quick` mode, launch only `code-correctness-reviewer`. The Executive
Summary will note the brief-conformance pass was skipped.
### Reviewer accounting — every expected reviewer MUST report
Write down the expected reviewer set BEFORE the spawn: both reviewers in
default mode, `code-correctness-reviewer` alone in `quick` mode. After the
spawn, account for each one by name.
**Zero findings from a silent reviewer is indistinguishable from zero findings
from a clean diff** — unless you check. A reviewer is *accounted for* only when
it returned a payload that validated. Three ways it fails to:
| Failure | Handling |
|---------|----------|
| Output fails the schema after the 2 bounded re-asks | STOP (already specified above) |
| Returned no final message at all | Re-ask that reviewer **once**. Still nothing → STOP. |
| Was never launched (spawn error, wrong mode) | STOP. |
**On STOP: name the reviewer and the failure, and do not proceed to Phase 6.**
Do not let the coordinator compute a verdict over a review one of whose
reviewers never spoke — the count would be complete-looking and wrong. This is
the same shape as the schema branch above ("do not feed unvalidated findings to
the coordinator"), applied to the other two ways a reviewer can go missing.
A reviewer that ran but never delivered is most often the return-channel
defect: check `~/.claude/projects/<proj>/<session>/subagents/agent-*.jsonl` for
its final assistant block before re-asking, and confirm no `name` parameter was
passed at the spawn (see the warning at the top of this phase).
If you proceed anyway under an explicit operator instruction, pass the expected
set to the coordinator as `expectedReviewers` so the missing reviewer at least
forbids `ALLOW` (`lib/review/coordinator-contract.mjs`, `missing_reviewers`).
## Phase 6 — Coordinator dedup + verdict
Launch `review-coordinator` (Agent tool) with the merged findings array
@ -247,10 +290,20 @@ The coordinator runs the 4-pass process documented in
1. **Dedup** by `(file, line, rule_key)` triplet.
2. **HubSpot Judge filters** — Succinctness, Accuracy, Actionability.
3. **Cloudflare reasonableness**drop speculative or catalogue-violating
3. **Cloudflare reasonableness**remove speculative or catalogue-violating
findings (skipped in `quick` mode).
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either
**dropped** (the test refuted the finding as a claim about this codebase) or
**unverified** (the finding was removed without its claim ever being settled).
A non-empty `unverified` bucket forbids `ALLOW`; the verdict becomes `WARN` and
the Executive Summary's first sentence must say why. The fail-closed rule never
raises a verdict — it only withholds the clean one. Fate table, reason
vocabulary, and the `allow_blocked_by` field: `agents/review-coordinator.md`
§*Suppression is two-valued*, mirrored deterministically in
`lib/review/coordinator-contract.mjs`.
The coordinator's output is the full review.md content — frontmatter +
body sections + trailing JSON block. Do NOT re-run the reviewers based
on the coordinator's output.

295
docs/BRIEF-vurdering-v2.md Normal file
View file

@ -0,0 +1,295 @@
# BRIEF — external assessment (VURDERING-V2 §5.12), verified against this repo
**Status:** assessment complete. **No implementation in this session** (scope-guard).
**Source under review:** `claude-playlist-corpus/docs/VURDERING-V2.md` §5.12 (+ §3 G1G8, §4),
transferred via that repo's `docs/OVERFORING-V2.md` §5. It is an external recommendation
built on 442 video analyses; its repo facts come from a subagent survey (2026-07-17) that
is **not** re-verified and in which at least one error was already demonstrated.
**Method.** Every asserted GAP is marked **BEKREFTET** (confirmed) / **AVKREFTET**
(refuted) / **ENDRET** (true in altered form) only after checking it against code in this
repo. Every Claude Code *feature* claim is checked against the OKF bundle at
`claude-code-llm-wiki/bundle/`, index-first — **where V2 and the bundle disagree, the
bundle wins**. Absences are positive-controlled before being reported (a grep that finds
nothing is a measurement, not a fact).
---
## TL;DR
Of six recommended tiltak, **one is a real defect** (fail-open verdict computation),
**one is unmeasured rather than unresolved** (the Workflow port), **three are already
decided or already partly built**, and **one is a cheap confirmed gap**.
The single most consequential correction: V2 presents the Workflow port as the answer to
this repo's "main-context relief is unmeasured" caveat. The bundle confirms the
*mechanism*, but **this repo has still never measured main-context relief on a large
fan-out** — the one bake-off that exists measured *subagent* tokens and says so. The port
is therefore neither refuted nor justified. The next step is a measurement, not a port,
and the measurement design already exists in `docs/T1-cc26-delegated-orchestration.md`.
---
## Tiltak 1 — Port orchestration to dynamic Workflows
**Verdict: split per command. Do not issue one global verdict.**
### `/trekreview`**ENDRET** (already ported, and measured)
The port exists: opt-in `--workflow`, `scripts/trekreview-armB.workflow.mjs`, documented at
`commands/trekreview.md:270` (*§ Phase 56 via the Workflow substrate*). The S10 bake-off
(`docs/T2-bakeoff-results.md`, ≥3 runs/arm) found it **fidelity-equivalent**: verdict-match
1.0, issue-coverage 100%, `(file,rule_key)` jaccard ≥ within-arm. It stays opt-in because
the Workflow tool raises the consumer floor to Claude Code 2.1.154+, and the prose path
keeps the lower floor.
### `/trekplan`, `/trekresearch` — **BEKREFTET as unported, UNMEASURED as beneficial**
Both still orchestrate their swarms from main context (`commands/trekplan.md:406`,
`commands/trekresearch.md:388`). That part of V2 is correct.
What is **not** established is V2's stated effect — that Workflow answers the
main-context-relief caveat. Two measurements exist in this repo and **neither measures it**:
| Measurement | What it actually measured | Bearing on the relief claim |
|---|---|---|
| `docs/T2-bakeoff-results.md:154` | **Subagent** tokens: Arm A median 92.8k vs Arm B 96.9k ⇒ **+4.4%** | None. The same line states Arm A's main-context hand-orchestration cost is **uncounted**. |
| `docs/T1-synthesis-poc-results.md` | Δ **main-context****0%** — for delegating *Phase 7 synthesis alone* | Negative, but out of scope: that doc itself names a **Phase-5 redesign** as the prerequisite for any relief. |
So the Phase-5 swarm — the case V2 is actually recommending — has never been measured in
either arm, in either command.
**The measurement already has a design.** `docs/T1-cc26-delegated-orchestration.md:118`
defines the gate metric (Δ main-context tokens resident in the main session at
plan-complete, from stream-json `usage`) with pre-registered bars: adopt at ≥30%, reject
below 15%. Running Arm A vs Arm B once against the trekplan swarm settles this.
**Cost framing must be corrected.** The bundle states plainly that a workflow run "can use
meaningfully more tokens than working through the same task in conversation"
(`bundle/concepts/docs-en-workflows.md:364`) — consistent with the measured +4.4%. The port
cannot be justified as a cost saving. **Main-context headroom is the only defensible
motive, and it is exactly the unmeasured quantity.**
**Recommendation:** measure before porting. Treat V2's tiltak 1 as a *hypothesis with a
ready-made experiment*, not as an approved change.
---
## Tiltak 2 — Codify the holdout rules
**Verdict: (a) and (b) satisfied in practice but uncodified · (c) BEKREFTET, a real defect ·
(d) ENDRET.** This is the tiltak with genuine substance.
### (a) Reviewers must never be forks — **AVKREFTET as a live defect, BEKREFTET as uncodified**
Reviewers are spawned as plain subagents (`voyage:brief-conformance-reviewer`,
`voyage:code-correctness-reviewer`). `subagent_type: "fork"` appears nowhere in `commands/`
or `agents/`; the only `subagent_type` in the tree is `"Explore"` at
`commands/trekplan.md:501`, an exploration spawn, not a review spawn. The bundle confirms
the rule is real — a forked subagent "inherits your full conversation context instead of
starting fresh" (`bundle/concepts/docs-en-agents.md:37`). Voyage satisfies it by habit;
nothing written forbids a future edit from breaking it.
### (b) Explicit "you shall not see" list — **AVKREFTET as a live defect, BEKREFTET as uncodified**
Phase 5 feeds reviewers exactly four things: the Phase-3 unified diff, the triage map, the
brief path, and the rule catalogue (`commands/trekreview.md`, Phase 5 input list).
Implementation plans and commit messages are **not** fed; `git log` appears only to compute
the diff range and to print a suggested narrowing command (`commands/trekreview.md:132`,
`:181`). Again: correct today, unprotected tomorrow.
### (c) Fail-closed verdicts — **BEKREFTET. This is the one real defect.**
`agents/review-coordinator.md` computes the verdict mechanically from the findings that
*survive* filtering:
- **Pass 2** (HubSpot judge) and **Pass 3** (Cloudflare reasonableness) **drop** findings
that fail their tests (`agents/review-coordinator.md:87`, `:101`).
- **Pass 4** counts the survivors: `BLOCKER ≥ 1 → BLOCK`; else `MAJOR ≥ 1 → WARN`; else
`ALLOW` (`:132`). "Verdict is mechanical — never override."
There is no third state. Three consequences follow:
1. **A finding that cannot be substantiated is dropped, and a dropped finding contributes
zero — which moves the verdict toward ALLOW.** Pass 3's non-existent-file test ("`file`
does not exist in the working tree AND the diff does not show it as `(new file)`") has
no inconclusive branch: unresolvable and refuted are treated identically. This is
structurally the failure V2 attributes to its source case, where "not E2E testable"
passed as approved.
2. **A reviewer that returns nothing produces the same result.** Phase 5 has no
empty-return handling — grep for `empty|no findings|did not return|abort` over
`commands/trekreview.md` returns 0 (positive control: `Phase 5` = 7 hits, `reviewer` =
24 hits, so the file and the query are both live). Zero findings from a silent reviewer
is indistinguishable from zero findings from a clean diff. This is the same surface as
the idle-agent hole recorded in STATE's open decisions.
3. **One fail-closed branch already exists, and it is the pattern to copy.** When reviewer
output fails schema validation, Phase 5 allows 2 bounded re-asks and then *stops*: "do
not feed unvalidated findings to the coordinator." That is the correct shape, applied to
one failure mode only.
**The defect is codified, not merely prose — which makes it testable.** The four passes
exist deterministically in `lib/review/coordinator-contract.mjs`, and
`computeVerdict(findings)` (`:184``:193`) counts *only* the findings handed to it.
`runContract` hands it `reasoned.kept` (`:206`); `suppressed` and `skipped` are returned in
the result object but **carry no weight in the verdict**. So a dropped finding is
arithmetically identical to a finding that never existed.
**Minimal fix shape (not implemented here):** an `unverified` bucket that is neither kept
nor dropped, plus one rule — *a non-empty `unverified` bucket forbids ALLOW* — and a
Phase-5 check that a reviewer actually returned. Both are additive; neither touches the
severity catalogue or the existing thresholds. Because the logic is deterministic ("No LLM,
no network, no time, no randomness", `:23`) and already has
`tests/lib/coordinator-contract.test.mjs`, this can be driven test-first under the Iron
Law — a failing test asserting that an unverifiable BLOCKER-severity finding cannot yield
ALLOW is writable before any production change.
### (d) Presume-failure framing — **ENDRET**
Both reviewers are already adversarially framed: "Adversarial reviewer" in each
`description`, "You never praise", "You never say 'looks good'"
(`agents/code-correctness-reviewer.md:36`, `agents/brief-conformance-reviewer.md:35`). What
is absent is specifically the presume-failure formulation V2 names ("this agent was lazy —
find out why"). This is a wording change with no measurement behind it in the corpus; treat
it as optional polish, not a gap.
---
## Tiltak 3 — Lightweight lane — **ENDRET** (narrower gap than stated, already tracked)
A lightweight path exists: `--quick` on both `/trekbrief` and `/trekplan`, and
`/trekplan --quick` already skips the Phase-5 exploration swarm — "Skip agent swarm; use
lightweight Glob/Grep scan and go directly to planning + adversarial review"
(`commands/trekplan.md:122`). The same code-path is reachable a second way: a brief
carrying `effort == 'low'` activates it without the flag (`:885`). What it does **not** do
is bypass the 2.2 ceremony —
`commands/trekbrief.md:126` states the framing question "is asked even in `--quick` mode",
and a 2.2 brief still requires `framing` and a `## TL;DR` (`:472`, `:474`).
So the accurate gap is not "no lightweight path" but "the lightweight path still pays the
2.2 toll". Already carried as open operator decision 1 in STATE — this is not a discovery.
---
## Tiltak 4 — Definition-of-done as an object — **BEKREFTET**
`commands/trekbrief.md` contains no notion of evidence, proof, verifier, residual risk, or
next-step owner (grep for `evidence|proof|screenshot|verifier|residual risk|owner` → 0;
positive control: `Success Criteri` = 5 hits). The gap is real.
**Cost note V2 does not carry:** the brief schema is **Handover 1**, a public contract
(`docs/HANDOVER-CONTRACTS.md`). Adding required DoD fields is a breaking change for
downstream consumers, i.e. a `brief_version` bump with a gate, not an edit. That moves it
out of "cheap win" and into planned work.
---
## Tiltak 5 — Prune, don't build — **mostly ALREADY DECIDED**
- **`synthesis-agent`**: already dormant and labelled as such (`agents/synthesis-agent.md:3`),
with the measurement that justified it (`docs/T1-synthesis-poc-results.md`, Δ≈0,
DECLINED). Retiring versus keeping was decided in favour of keeping it as a
re-measurable building block. V2 recommends a decision that has been made and recorded.
- **24 all-opus agents**: 24 of 24 agent files carry `model: opus` (verified by count).
This is an **operator pin**, not drift — commit `40d8742` "pin all sub-agents to Opus
permanently (operator request)" — and the reconsideration V2 asks for already happened
and is written down in `docs/voyage-vs-cc-balance-analysis.md` §10. Re-opening it is an
operator decision, not an analysis task.
- **`gemini-bridge`**: **BEKREFTET**, and the cheapest real item in this tiltak. It is
already flagged `THIN_WRAP` / `DROP→NATIVE` in the same analysis (§V09, lines 81, 121,
149) *and* it is broken at the engine (the `gemini-mcp` server fails deterministically on
an SDK/API mismatch). Fix-or-drop is decidable today.
---
## Tiltak 6 — LSP as a harness component — **BEKREFTET**
`LSP` returns 0 hits across `commands/`, `agents/`, `lib/`, `docs/`. Voyage navigates with
Glob/Grep, as V2 says. One qualifier: LSP is a harness-level capability available to
agents, not a voyage feature — adoption is mostly permitting and prompting agents to use
it, not code in this repo. Value is highest in large target repos, which is where voyage's
exploration swarm actually runs.
---
## Bundle-gap
Checked against `bundle/concepts/docs-en-workflows.md`
(`source_sha 363819ed9ec325275ca22023f6bb6b98fbbf6fcc12db0667478c5207e117751b`,
timestamp 2026-08-15), plus `docs-en-agents.md` and `docs-en-sub-agents.md`.
**Confirmed by the bundle:**
- The **2.1.154 floor** for dynamic workflows, on paid plans.
- The **main-context relief mechanism**: "A workflow script holds the loop, the branching,
and the intermediate results itself, so Claude's context holds only the final answer"
(`:38`); "Intermediate results stay in script variables instead of landing in Claude's
context" (`:316`). V2's mechanism claim is sound — it is the *magnitude* that is unmeasured.
- **Per-stage model routing** (`:375``:378`).
- **Cost caps**: agent caps, size guideline, and a `Large workflow` warning above 25 agents
or 1.5M projected tokens (`:368`).
**Where V2 overstates, and the bundle wins:**
- **"tokenbudsjetter" (token budgets).** The page documents *caps and size guidelines*, not
a token budget — `budget` returns 0 hits on the page. V2's warning to "set an explicit
token budget" has no documented primitive to point at in the official docs.
- **"automatisk retry".** 0 hits. Not documented on the page.
**Limitation V2 omits, material for this repo:** resumability is **session-scoped**
"Resume works within the same Claude Code session. If you exit Claude Code while a workflow
is running, the next session starts the workflow fresh" (`:360`). Further, stopping mid
fan-out re-runs every agent that started after the stopped one (`:354`). Voyage is
explicitly a *multi-session* tool, so resumability is a weaker argument here than V2 implies.
**Gap proper (feedback toward wiki v1.0):**
1. The mirrored docs describe no programmatic **token-budget** primitive for workflows,
while the Workflow tool's own runtime surface does expose a budget derived from an
operator token directive. If that surface is real and stable, it is missing from the
mirror. Flagged as a discrepancy, not asserted as a doc error.
2. Nothing in the mirrored pages quantifies what a workflow's **final return** costs the
orchestrating session. The relief mechanism is described qualitatively only, so a
consumer cannot size the benefit from the docs alone — which is precisely why the
measurement below cannot be replaced by reading.
---
## Recommended order (operator decides; nothing started)
1. **Fail-closed coordinator (tiltak 2c)** — smallest surface, highest value, no
dependencies, and it closes a defect rather than adding a feature.
2. **Measure Δ main-context on the `/trekplan` Phase-5 swarm (tiltak 1)** — Arm A vs Arm B
against the pre-registered bars in `docs/T1-cc26-delegated-orchestration.md`. The port
decision follows the number; it does not precede it.
3. **`gemini-bridge` fix-or-drop (tiltak 5)** — already analysed, currently broken, cheap.
**Deferred with reasons:** tiltak 3 (already open operator decision 1) · tiltak 4 (requires
a `brief_version` bump against a public contract) · tiltak 5's opus/synthesis items
(decided; operator-pinned) · tiltak 6 (harness-level, not repo code).
---
## Verification log
| Claim | How verified |
|---|---|
| `/trekreview` Workflow port exists and was measured | `commands/trekreview.md:270`; `docs/T2-bakeoff-results.md` §Full run |
| +4.4% is **subagent** tokens, main context uncounted | `docs/T2-bakeoff-results.md:154` (verbatim) |
| Δ main-context ≈ 0 applies to Phase 7 only | `docs/T1-synthesis-poc-results.md:1`, `:100` |
| Measurement design already exists | `docs/T1-cc26-delegated-orchestration.md:118`, `:125` |
| Reviewers are not forks | `grep subagent_type\|fork commands/ agents/` → only `Explore` at `commands/trekplan.md:501` |
| Plans/commit messages not fed to reviewers | Phase 5 input list; `commands/trekreview.md:132`, `:181` |
| Verdict is computed from survivors only | `agents/review-coordinator.md:87`, `:101`, `:132` |
| Same rule codified deterministically in `lib/` | `lib/review/coordinator-contract.mjs:184``:193`, `:206`; existing test `tests/lib/coordinator-contract.test.mjs` |
| No empty-return handling in Phase 5 | grep → 0, positive control `Phase 5`=7, `reviewer`=24 |
| Existing fail-closed branch on schema failure | `commands/trekreview.md`, Phase 5 bounded-retry paragraph |
| `--quick` does not bypass 2.2 | `commands/trekbrief.md:126`, `:472`, `:474` |
| No DoD fields in trekbrief | grep → 0, positive control `Success Criteri`=5 |
| 24/24 agents pinned opus, by operator | file count 24/24; `git log 40d8742` |
| gemini-bridge already flagged for drop | `docs/voyage-vs-cc-balance-analysis.md:81`, `:121`, `:149` |
| No LSP usage | `grep -rn LSP commands/ agents/ lib/ docs/` → 0 |
| Workflow floor, relief mechanism, model routing, caps | `bundle/concepts/docs-en-workflows.md:19`, `:38`, `:316`, `:375`, `:368` |
| No documented token budget or retry in the bundle | `grep -i budget\|retry` on that page → 0 relevant |
| Resume is session-scoped | `bundle/concepts/docs-en-workflows.md:354`, `:360` |
| Fork inherits full conversation context | `bundle/concepts/docs-en-agents.md:37` |
**Not verified / stated as unverified:** V2's underlying video-corpus claims (the StrongDM
bad-merge case, the 5.5-hour bake-off, the "$200 plan in 30 minutes" workflow) were not
independently checked — they are cited here as V2's evidence, not as this repo's findings.

View file

@ -0,0 +1,268 @@
# The Agent tool's `name` parameter silently destroys the return channel
**Date:** 2026-08-17 (voyage S85)
**Claude Code:** 2.1.226 · **Plugin:** voyage 5.9.1 (installed cache — verified byte-identical to the repo working tree for both reviewer agents before measuring)
**Status:** Root cause MEASURED and mechanistically confirmed. The defect is in
how the harness treats one Agent-tool parameter, not in voyage's agent
definitions. A one-line workaround exists and is now pinned in every voyage
spawn site.
## The report that triggered this
`akashic-intelligence` lost `/trekplan` Phase 9 twice. S25 (2026-08-17, plan
rev 5.0): four subagents spawned via the Agent tool — `voyage:plan-critic` ×2,
`voyage:scope-guardian` ×2, all `model: "opus"`, not Workflow. Nine
`SendMessage` reminders, zero replies. The round before had the same outcome
with tighter scope and an explicit tool-call budget. Consequence: the
hand-dedup step fell away (one source instead of two) and review coverage came
out **narrower** than the four preceding rounds.
Their own conclusion was "the channel, not the agents" — directionally right.
Two details in it are wrong and matter: they attributed it to the agents having
no `Write` tool, and they concluded the remedy is to abandon agents and run the
review inline. `Write` is irrelevant, and the agents do not need abandoning.
## Root cause
**Passing `name` to the Agent tool does not name a subagent. It changes what
kind of thing gets spawned.**
The spawn metadata says it outright. From
`~/.claude/projects/<project>/<session>/subagents/agent-*.meta.json`, same
session, same model, same `subagent_type`:
| spawn | recorded `agentType` | `taskKind` | `spawnDepth` |
|---|---|---|---|
| without `name` | `voyage:plan-critic` | *(none — a real subagent)* | `1` |
| with `name` | `pc-opus` (the name) | `in_process_teammate` | `0` |
The two kinds have different return semantics:
- **Subagent** (`spawnDepth: 1`) — the agent's final assistant text **is** the
return value. It arrives at the parent as a task notification.
- **Teammate** (`in_process_teammate`, `spawnDepth: 0`) — a peer of the main
session, not a child of it. Its plain final text is **not** a return value; it
is only transcript. A teammate reaches the parent **only** by calling
`SendMessage(to: "main")`.
Every voyage agent declares a `tools:` allowlist. `plan-critic` and
`scope-guardian` declare `["Read", "Glob", "Grep"]`. **No SendMessage.** As
teammates they are therefore *structurally* incapable of returning anything —
no prompt, no scope tightening, and no tool-call budget can change that. This
is exactly why akashic's second attempt with a stricter prompt failed
identically: the prompt was never the variable.
### Why it looks like a hung agent
The teammate runs. It reads the plan, reasons, and writes a complete, correct
final answer to its transcript. Then it stops. From the orchestrator's side
this is indistinguishable from an agent that stalled — so the natural response
is to poke it with `SendMessage`, which produces more transcript that also
never comes back. Nine reminders, zero replies.
### The symptom signature, exactly
The teammates do eventually surface — as content-free idle notifications,
delivered long after the fact. Observed in this session, all six named agents
(times UTC):
```
{"type":"idle_notification","from":"ctrl-pong", "idleReason":"available"} 19:57:30
{"type":"idle_notification","from":"ctrl-read", "idleReason":"available"} 19:57:35
{"type":"idle_notification","from":"pc-nooverride", "idleReason":"available"} 19:59:19
{"type":"idle_notification","from":"pc-opus", "idleReason":"available"} 19:59:24
{"type":"idle_notification","from":"sg-opus", "idleReason":"available"} 19:59:37
{"type":"idle_notification","from":"ctrl-sendback", "idleReason":"available",
"summary":"[to main] PONG-VIA-SENDMESSAGE"} 20:06:16
```
This is akashic's "innholdsløse idle-pings", verbatim, and it is the field
signature to recognise the defect by. Two things to read off it:
- **`summary` is present only for the teammate that called SendMessage.** For
the five tool-less ones the notification carries no content at all — not a
truncated result, not an error, nothing. The presence or absence of `summary`
is the fastest way to tell a delivering teammate from a mute one.
- **The idle notifications lag by minutes.** `ctrl-pong` went idle 10 seconds
after spawn; its notification arrived ~9 minutes later. So "no idle ping yet"
is not evidence the agent is still working, and the ping, when it comes, is
not a result. Judging liveness from these is how a completed run gets
re-prompted nine times.
**The work is not lost.** It is on disk at
`~/.claude/projects/<project-slug>/<session-id>/subagents/agent-a<name>-<hash>.jsonl`.
The final assistant text block in that file is the answer the orchestrator never
received. This is the recovery path when a run has already been burned.
## Measurements
One session, one target unless noted. Target for the reviewer cells:
`docs/devils-advocate-plan.md` (66 lines) — a real plan file, small enough that
runtime cannot be confused with hanging.
| # | `subagent_type` | `model` | `name`? | Returned to parent? | Time |
|---|---|---|---|---|---|
| 1 | `general-purpose` ("reply PONG", 0 tools) | `opus` | yes | **no** | >17 min, never |
| 2 | `general-purpose` (read file, count headings) | `opus` | yes | **no** | never |
| 3 | `voyage:plan-critic` | `opus` | yes | **no** | never |
| 4 | `voyage:scope-guardian` | `opus` | yes | **no** | never |
| 5 | `voyage:plan-critic` | *(no override)* | yes | **no** | never |
| 6 | `general-purpose`, told to call `SendMessage(to: "main")` | `opus` | yes | **yes** | ~30 s |
| 7 | `general-purpose` ("reply PONG", 0 tools) | `opus` | **no** | **yes** | **2.19 s** |
| 8 | `voyage:plan-critic` | `opus` | **no** | **yes** — full findings + JSON block | **110.3 s**, 7 tool uses |
| 9 | `voyage:scope-guardian` | `opus` | **no** | **yes** — full findings + JSON block | **109.1 s**, 8 tool uses |
Denominators, per varied dimension:
- **`name` present, no explicit SendMessage: 0/5 returned.** `name` absent:
**3/3 returned.** This is the only dimension that moved the outcome.
- **`name` present *with* an explicit `SendMessage(to: "main")` instruction:
1/1 returned** (cell 6). This is the positive control for the teammate
channel: it is not broken, it is merely never used by tool-less agents.
- **Model override: 2/2 non-returning.** Cells 3 (`model: "opus"`) and 5 (no
override) are identical in outcome — the override is not the variable, which
rules out akashic's stated `model: "opus"` detail as causal.
- **Agent type: 2/2 in each arm.** `plan-critic` and `scope-guardian` behave
identically named (both silent) and unnamed (both complete). The agent
definitions are not the variable.
- **Work actually performed while named: 5/5.** All five named agents produced
correct, complete final text in their transcripts within ~3 minutes —
`PONG`, `8`, plan-critic's blocker list with the correct legacy-format
advisory, and scope-guardian's coverage table. Nothing failed except delivery.
### Known-positive discipline (Verifiseringsloven face 4)
Two negative results in this investigation were produced by broken queries and
would have been consumed as facts:
1. **`ListAgents` returned no in-process subagents** while five were running. It
listed 26 peer sessions and none of mine. Probing one directly with
`SendMessage` proved the agents existed and had live inboxes. Had the empty
listing been believed, the diagnosis would have been "the spawns silently
no-op" — wrong.
2. **The first known-positive control failed.** Cell 1 (named PONG) was meant to
prove the spawn mechanism works, and it did not return either. A failed
control proves nothing; it only means the control shared the defect. The
control that discriminates is cell 7 — same prompt, same model, same agent
type, `name` removed — which returned in 2.19 s.
**Unmeasured cells, stated as unmeasured:** the returning arm was only exercised
against the 66-line target. Large targets (voyage's 573-line plan;
akashic's 3730-line / 277 KB `features/01-sun-position/plan.md`) were **not**
measured in either arm. Nothing here rules out a *separate*, size-dependent
failure at akashic's scale — it only establishes that the failure they observed
reproduces at 66 lines, where size cannot be the cause.
**External measurement closes half of that (2026-08-17).** `akashic-intelligence`
recovered its own S25 transcripts from
`~/.claude/projects/<slug>/<session>/subagents/` and read the last assistant
text block out of each. Denominator: 4 of 4 agents, all **named**, all against
the full 3730-line / 277 KB plan. Three produced valid JSON review output
(17, 8 and 13 findings); the fourth was their PONG control and produced
`PONG`. Their commit `e281a9d`.
What that closes: **the work is performed at 3730 lines.** No size-dependent
failure of the agent's *reasoning or output* exists at that scale — the named
arm produces correct final text at 66 lines and at 3730 lines alike, and only
delivery fails, identically at both. The 38 recovered findings were re-used
instead of re-run.
**A second external measurement closes the other half (akashic-intelligence
S27, commit `f168630`).** The gap left above was that all four S25 cells were
named, so the *returning* (unnamed) arm had no measurement above 66 lines.
S27 supplies one: denominator **2 of 2 unnamed agents**, against a 4370-line
plan; both returned, 30449 B and 10989 B, both valid JSON. The unnamed arm
therefore returns at full scale as observed fact, not as inference.
Two caveats, kept at the strength akashic itself stated them. The measurement
was taken by the repo that owns the finding, not by an independent third party.
And byte-identity between the returned string and the file on disk was not
proven — what is established is that a well-formed result of that size arrived,
not that it arrived unaltered.
Their PONG control also carries the same lesson as cell 1 above, in a third
repo: S25 reported that agent as having "gone idle without sending PONG". It
sent PONG. Absence of *delivery* was read as absence of *work* — the same
face-4 error, three times in one evening.
## Where the defect lives
**Outside this repository.** Nothing in `agents/plan-critic.md` or
`agents/scope-guardian.md` is wrong: correct frontmatter, a `tools:` list
appropriate to a read-only reviewer, a terminating prompt, and an output format
that both agents produced verbatim when spawned as real subagents. The failure
is a harness behaviour — a parameter that silently changes a spawn's kind and
its return contract, with no error, no warning, and no observable difference at
the call site beyond the wording of the tool result.
Per the standing rule, this is **not** worked around silently. It is documented
here, reported to `.claude` (which owns cross-repo quality and can escalate),
and guarded in voyage's own spawn instructions so no consumer re-enters it.
## In-repo fix
Every voyage command that spawns agents — `trekbrief`, `trekplan`,
`trekresearch`, `trekreview` — now states the rule at its spawn site, naming
the mechanism (`in_process_teammate`) so it cannot be mistaken for style
preference. `trekexecute` spawns nothing (Hard Rule 10) and is excluded.
Pinned by `tests/lib/doc-consistency.test.mjs`:
- `S85: the set of agent-spawning commands is exactly the four that carry the
no-name rule` — derives the spawning set from the command files themselves, so
the pin cannot go vacuous when a command starts or stops spawning.
- `S85: commands/<cmd>.md forbids the Agent tool's name parameter at its spawn
sites` — one per command.
Verified red before the prose was written, green after.
## Recommended working shape for consumers
For anyone running voyage's reviewers (akashic included), grounded in the
measurements above rather than in preference:
**1. Use agents. Spawn them without `name`.** Cells 8 and 9 are the evidence:
both reviewers returned complete, schema-conformant output — human-readable
findings *and* the machine-readable JSON block the dedup helper consumes — in
~110 seconds, in parallel, from a single message. This is the intended shape and
it works. Removing `name` is the entire fix.
**2. Do not switch to inline review as a remedy.** akashic's fallback was
rational under their diagnosis but it pays a real, measured price: with one
source there is no dedup step, and their own record shows `scope-guardian`
finding blockers in the rev 3.0/4.0 rounds that `plan-critic` did not see at
all. The two agents are not redundant — cells 8 and 9 here returned largely
*different* findings on the same 66-line plan (plan-critic led on
placeholder/headless defects; scope-guardian led on the plan already having been
executed and its deliverable never being written). Two independent passes with
different mandates is the property worth keeping, and unnamed agents deliver it
for free. Running two inline passes is the fallback if agents fail *for a
different, verified reason* — not for this one.
**3. If you deliberately want a named teammate, give it SendMessage and say so.**
Cell 6 shows the teammate channel works when used. That means adding
`SendMessage` to the agent's `tools:` list *and* instructing it in the prompt to
call `SendMessage(to: "main")` with its result. voyage does not do this: its
reviewers are read-only by design, and the subagent path already returns.
**4. A hard tool-call budget is not the lever here.** akashic tried a tighter
budget and it changed nothing, which this measurement explains: a 0-tool-call
agent (cell 1) failed exactly like a 7-tool-call one. Budget caps are a cost
control, not a liveness control.
**5. When an agent looks hung, check its transcript before re-prompting.** The
final assistant block in
`~/.claude/projects/<project-slug>/<session-id>/subagents/agent-*.jsonl` tells
you within seconds whether the agent failed or merely could not deliver. Reading
that file directly recovers a burned run's output. Extract only the last
assistant text block — these transcripts run to 140 KB and reading one whole
will flood the orchestrator's context.
## Open
- The `--gates`-adjacent "idle-agent gap" already logged as open operator
decision #3 in `STATE.md` (`/trekreview` Phase 5, `/trekplan` Phases 5/6/9,
`/trekresearch` Phase 4 have no empty-return detection) now has a measured
root cause for its most likely trigger. Whether to add active detection — as
opposed to the prevention pinned here — remains open and is not decided by
this document.

View file

@ -12,6 +12,8 @@ Imported from `CLAUDE.md` via pointer.
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (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, `settings.json:16`). **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 `settings.json:16` value; 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>"`
Wiring points (replaces previous prose-grep instructions):
- `/trekbrief` Phase 4g → `brief-validator` (post-write sanity check)
@ -31,6 +33,8 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
`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
@ -92,7 +96,7 @@ Which native Claude Code primitive each pipeline step runs on today, and the alt
| **execute** | Inline step loop; multi-session via `git worktree` + `claude -p` waves; 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 56 (bake-off POSITIVE: +4.4 % tokens / +54 % wall-time → shipped **opt-in `--workflow`**, not default; 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** | 7 hook scripts: `pre-bash` + `pre-write` guards, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — |
| **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 | — |
¹ MCP per research agent: `docs-researcher` → Microsoft Learn + Tavily · `community-`/`security-`/`contrarian-researcher` → Tavily (+ WebSearch/WebFetch) · `gemini-bridge` → Gemini Deep Research MCP. Graceful degradation when an MCP server is absent.

View file

@ -26,10 +26,26 @@ Always interactive. Phase 3 is a section-driven completeness loop (no hard cap o
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## 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 (CC 2.1.154+), falls back to `swarm`. Default `swarm`. |
| `--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, `settings.json:16`). 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 |

View file

@ -77,7 +77,10 @@ floor + a `swarm` default.
[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 `ultracode` keyword. A plugin command whose
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 —

View file

@ -0,0 +1,160 @@
# 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.

131
docs/storm-measurement.md Normal file
View file

@ -0,0 +1,131 @@
# 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.
```bash
# Control arm (loop inert) — n >= 5
claude -p "/trekresearch --project .claude/projects/<brief-standard-effort>"
# Treatment arm (loop live, effort: high in the brief's phase_signals) — n >= 5
VOYAGE_STORM_ENABLED=1 \
claude -p "/trekresearch --project .claude/projects/<brief-high-effort>"
# 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.

View file

@ -20,6 +20,16 @@
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-write-executor.mjs"]
}
]
},
{
"matcher": "WebSearch|WebFetch|Task",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-agent-cap.mjs"]
}
]
}
],
"UserPromptSubmit": [

View file

@ -0,0 +1,211 @@
#!/usr/bin/env node
// Hook: pre-agent-cap.mjs
// Event: PreToolUse (WebSearch | WebFetch | Task)
// 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.
//
// 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: `claude --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 `claude --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 settings.json:16's maxDimensions 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();

View file

@ -105,7 +105,16 @@ const BLOCK_RULES = [
// --- Executor-specific additions ---
{
name: 'System shutdown/reboot',
pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/,
// Anchored to command position — start of string/line, or after a
// separator (`;`, `|`, `&`, `&&`), with optional `sudo` and an optional
// absolute path. An unanchored \b match blocked the bare word anywhere,
// including quoted grep patterns, heredoc data, and commit messages.
//
// Runs against commandView, not the whitespace-collapsed string: collapsing
// \s+ to ' ' would erase the newline separator before the pattern ever saw
// it, and quoted spans must read as data, not as command position.
commandView: true,
pattern: /(?:^|[\n;|&])\s*(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:shutdown|reboot|halt|poweroff)\b/,
description: 'System shutdown/reboot commands are blocked during execution.',
},
{
@ -198,6 +207,59 @@ function normalizeCommand(cmd) {
.trim();
}
// ---------------------------------------------------------------------------
// Command-position view — for rules that must distinguish a command from data
// that merely names one. Whitespace is NOT collapsed, so newline stays a
// separator. Quoted spans and heredoc bodies become data; the argument of a
// shell wrapper stays a command; backslash-escaped names are seen through.
// ---------------------------------------------------------------------------
// `out` ends with the `-c` of a shell invocation — the next quoted span is a
// command string, not data. Optional leading `sudo` and absolute path.
const SHELL_C_TAIL =
/(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/;
// Drop heredoc bodies, keeping the operator line. Their newlines are not
// command separators, and without this every heredoc line that happens to
// start with a matched word reads as command position. Runs before the quote
// scan, since a body may contain quotes that would desync it.
function stripHeredocBodies(cmd) {
return cmd.replace(
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
(_match, head) => head,
);
}
function commandPositionView(cmd) {
const src = stripHeredocBodies(cmd);
let out = '';
let i = 0;
while (i < src.length) {
const ch = src[i];
if (ch === "'" || ch === '"') {
const close = src.indexOf(ch, i + 1);
const inner = close === -1 ? src.slice(i + 1) : src.slice(i + 1, close);
// Unterminated quote — treat the remainder as one span and stop.
out += SHELL_C_TAIL.test(out) ? `;${inner};` : ' ';
i = close === -1 ? src.length : close + 1;
} else {
out += ch;
i += 1;
}
}
return (
out
// `xargs [flags] <cmd>` puts <cmd> at command position with no separator
// in front of it. Flags taking a separate argument (`-I {}`) are not
// parsed — the separator lands before the argument, not the command.
.replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;')
// `\name` runs name — the backslash only suppresses alias expansion.
// normalizeBashExpansion covers the between-word-chars case; this covers
// a backslash at command position.
.replace(/\\(\w)/g, '$1')
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
@ -219,10 +281,11 @@ if (!command || typeof command !== 'string') {
// Strip bash evasion, then normalize whitespace
const deobfuscated = normalizeBashExpansion(command);
const normalized = normalizeCommand(deobfuscated);
const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, ''));
// Check BLOCK rules first
for (const rule of BLOCK_RULES) {
if (rule.pattern.test(normalized)) {
if (rule.pattern.test(rule.commandView ? commandView : normalized)) {
process.stderr.write(
`[voyage] BLOCKED: ${rule.name}\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +

View file

@ -25,11 +25,27 @@ const TREKBRIEF_ALLOWED = Object.freeze(new Set([
]));
// 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', 'dimensions', 'agents_local',
'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)

View file

@ -19,8 +19,10 @@
// What IS implemented, purely: Pass 1 (triplet dedup → highest-severity-wins
// survivor + conformance tiebreak + detail concat + raised_by provenance),
// Pass 2 succinctness + actionability-presence, Pass 3 reasonableness
// (citation / unknown-rule_key drop, severity-mismatch correction), Pass 4
// verdict thresholds. No LLM, no network, no time, no randomness.
// (citation / unknown-rule_key suppression, severity-mismatch correction),
// Pass 4 verdict thresholds — fail-closed: a suppression that did not REFUTE
// the finding, and a reviewer that never reported, forbid ALLOW (see
// classifySuppression). No LLM, no network, no time, no randomness.
//
// Reuses: SEVERITY_VALUES / RULE_KEYS / getRule (rule-catalogue.mjs),
// computeFindingId (finding-id.mjs, triplet), validateFindings
@ -35,6 +37,71 @@ import { validateFindings } from './findings-schema.mjs';
export const JUDGE_TITLE_MAX = 100;
export const JUDGE_DETAIL_MAX = 800;
// ---- Suppression classification (fail-closed) --------------------------------
//
// A removal is `dropped` ONLY when the test refuted the finding as a claim
// about this codebase. Every other removal is `unverified`: the coordinator
// took the finding out of the count without ever establishing it was unreal,
// so it may not be spent as evidence of a clean review.
/**
* Reasons that REFUTE. `no-citation` is the only one this deterministic subset
* can emit: a finding whose `file` is empty or whose `line` is negative names
* no location, so it makes no checkable claim at all
* (agents/review-coordinator.md Pass 3 "Speculative 'code might break
* somewhere' findings have no anchor").
*
* `accuracy:refuted` (Pass 2 Accuracy a citation escaping the repo root) and
* `file-existence:refuted` (Pass 3 absent from both working tree and diff)
* are emitted by the LLM coordinator, whose fs/judgement branches this module
* excludes. They are declared here anyway: the vocabulary is owned in one
* place so prose and lib cannot drift.
*/
export const REFUTING_REASONS = Object.freeze(new Set([
'no-citation',
'accuracy:refuted',
'file-existence:refuted',
]));
/**
* The reason vocabulary on the unverified side. `file-existence:indeterminate`
* is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this
* module deliberately excludes); the vocabulary is owned here so prose and lib
* cannot drift.
*/
export const UNVERIFIED_REASONS = Object.freeze([
'succinctness:title',
'succinctness:detail',
'actionability:empty',
'unknown-rule_key',
'file-existence:indeterminate',
]);
/**
* Classify a suppression reason. Anything not declared refuting is
* `unverified` the default is fail-CLOSED, so a reason introduced later
* without a decision cannot silently move the verdict toward ALLOW.
* @param {string} reason
* @returns {'refuted'|'unverified'}
*/
export function classifySuppression(reason) {
return REFUTING_REASONS.has(reason) ? 'refuted' : 'unverified';
}
/**
* Tag a finding with its suppression reason and route it to the refuted
* (`dropped`) or the `unverified` bucket.
* @param {object} finding
* @param {string} reason
* @param {object[]} dropped
* @param {object[]} unverified
*/
function suppress(finding, reason, dropped, unverified) {
const tagged = { ...finding, suppressed_reason: reason };
if (classifySuppression(reason) === 'refuted') dropped.push(tagged);
else unverified.push(tagged);
}
/**
* Catalogue-tier rank of a severity: lower number = higher severity.
* BLOCKER=0 SUGGESTION=3; an unknown severity ranks last.
@ -122,12 +189,18 @@ export function dedupByTriplet(findings) {
* (title > 100 or detail > 800 chars) and actionability (recommended_action,
* when present, must be a non-empty string). The imperative-verb test is
* excluded (LLM judgement).
*
* Both tests read a `.length`; neither examines the claim, so neither can
* establish the finding is unreal. Both therefore route to `unverified`.
* `dropped` stays in the signature for the refuting Pass-2 filter this subset
* excludes (Accuracy: a path-traversal escape IS a refutation).
* @param {object[]} findings
* @returns {{ kept: object[], dropped: object[] }}
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
*/
export function judgeFilter(findings) {
const kept = [];
const dropped = [];
const unverified = [];
for (const f of findings) {
const titleLen = (f.title ?? '').length;
const detailLen = (f.detail ?? '').length;
@ -138,31 +211,38 @@ export function judgeFilter(findings) {
(typeof f.recommended_action !== 'string' || f.recommended_action.trim().length === 0)) {
reason = 'actionability:empty';
}
if (reason) dropped.push({ ...f, suppressed_reason: reason });
if (reason) suppress(f, reason, dropped, unverified);
else kept.push(f);
}
return { kept, dropped };
return { kept, dropped, unverified };
}
/**
* Pass 3 Cloudflare reasonableness (deterministic subset): drop findings
* with no citation (empty file / line < 0) or an unknown rule_key; CORRECT a
* severity that does not match the catalogue tier (a correction, not a drop).
* The fs file-existence glob is excluded (I/O).
* The fs file-existence glob is excluded (I/O) its indeterminate branch is
* prose-side, tokenised as `file-existence:indeterminate`.
*
* `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`.
* @param {object[]} findings
* @returns {{ kept: object[], dropped: object[] }}
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
*/
export function reasonablenessFilter(findings) {
const kept = [];
const dropped = [];
const unverified = [];
for (const f of findings) {
if (typeof f.file !== 'string' || f.file.length === 0 ||
(typeof f.line === 'number' && f.line < 0)) {
dropped.push({ ...f, suppressed_reason: 'no-citation' });
suppress(f, 'no-citation', dropped, unverified);
continue;
}
if (!RULE_KEYS.has(f.rule_key)) {
dropped.push({ ...f, suppressed_reason: 'unknown-rule_key' });
suppress(f, 'unknown-rule_key', dropped, unverified);
continue;
}
const rule = getRule(f.rule_key);
@ -172,44 +252,114 @@ export function reasonablenessFilter(findings) {
kept.push(f);
}
}
return { kept, dropped };
return { kept, dropped, unverified };
}
/**
* Pass 4 compute the verdict from severity counts (after dedup + filtering).
* BLOCKER 1 BLOCK; else MAJOR 1 WARN; else ALLOW.
*
* FAIL-CLOSED: ALLOW additionally requires that nothing is `unverified` and
* that every expected reviewer reported. Neither ever RAISES a verdict the
* severity thresholds are untouched they only forbid the clean one, so the
* worst case of a false unverified is WARN plus a stated reason, never a
* silent pass. Unverified findings are NOT counted into a severity tier: their
* severity is reviewer-asserted and was never substantiated.
*
* @param {object[]} findings
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number> }}
* @param {{ unverified?: object[], missingReviewers?: string[], unattributablePayloads?: number }} [options]
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number>, allow_blocked_by: string[] }}
*/
export function computeVerdict(findings) {
export function computeVerdict(findings, options = {}) {
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const f of findings) {
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
}
const unverified = options.unverified ?? [];
const missingReviewers = options.missingReviewers ?? [];
const unattributablePayloads = options.unattributablePayloads ?? 0;
const allow_blocked_by = [];
const byReason = new Map();
for (const f of unverified) {
const reason = f?.suppressed_reason ?? 'unspecified';
byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
}
for (const [reason, n] of byReason) allow_blocked_by.push(`unverified:${reason} (${n})`);
for (const r of missingReviewers) allow_blocked_by.push(`missing-reviewer:${r}`);
if (unattributablePayloads > 0) allow_blocked_by.push(`unattributable-payload (${unattributablePayloads})`);
let verdict;
if (counts.BLOCKER >= 1) verdict = 'BLOCK';
else if (counts.MAJOR >= 1) verdict = 'WARN';
else if (allow_blocked_by.length > 0) verdict = 'WARN';
else verdict = 'ALLOW';
return { verdict, counts };
return { verdict, counts, allow_blocked_by };
}
/**
* Run the full deterministic contract: ingest Pass 1 Pass 2 Pass 3 Pass 4.
*
* `options.expectedReviewers` names the reviewers this review was supposed to
* hear from. A reviewer that is absent from the payloads, or whose named
* payload failed schema validation and was thrown away at ingest, lands in
* `missing_reviewers`; a payload that failed schema WITHOUT a reviewer name is
* counted in `unattributable_payloads` instead. Either forbids ALLOW: an
* unread reviewer is an absent one, and zero findings from a silent reviewer
* must not read like zero findings from a clean diff.
*
* `suppressed` stays the UNION of `dropped` (refuted) and `unverified` so
* existing consumers keep their meaning; `unverified` is the subset that
* forbids ALLOW. Do not iterate both and count twice.
*
* @param {Array<{reviewer?: string, findings: object[]}>} reviewerPayloads
* @returns {{ verdict: string, counts: Record<string, number>, findings: object[], suppressed: object[], skipped: object[] }}
* @param {{ expectedReviewers?: string[] }} [options]
* @returns {{ verdict: string, counts: Record<string, number>, findings: object[], suppressed: object[], unverified: object[], skipped: object[], missing_reviewers: string[], unattributable_payloads: number, allow_blocked_by: string[] }}
*/
export function runContract(reviewerPayloads) {
export function runContract(reviewerPayloads, options = {}) {
const { findings: ingested, skipped } = ingest(reviewerPayloads);
const deduped = dedupByTriplet(ingested);
const judged = judgeFilter(deduped);
const reasoned = reasonablenessFilter(judged.kept);
const { verdict, counts } = computeVerdict(reasoned.kept);
const unverified = [...judged.unverified, ...reasoned.unverified];
// A reviewer counts as REPORTED only when a payload carrying its name
// validated. `validateFindings` merely warns on a missing `reviewer`, so a
// payload can fail schema anonymously: that is an unattributable payload, not
// a reviewer called "unnamed reviewer". Naming one would invent an agent
// nobody launched, and would double-count with expectedReviewers when the two
// are in fact the same failure.
const skippedNames = new Set();
let unattributable_payloads = 0;
for (const s of skipped) {
if (typeof s.reviewer === 'string' && s.reviewer.length > 0) skippedNames.add(s.reviewer);
else unattributable_payloads += 1;
}
const reported = new Set();
for (const payload of reviewerPayloads) {
const name = payload?.reviewer;
if (typeof name === 'string' && name.length > 0 && !skippedNames.has(name)) reported.add(name);
}
const missing_reviewers = [...skippedNames];
for (const r of options.expectedReviewers ?? []) {
if (!reported.has(r) && !missing_reviewers.includes(r)) missing_reviewers.push(r);
}
const { verdict, counts, allow_blocked_by } = computeVerdict(reasoned.kept, {
unverified,
missingReviewers: missing_reviewers,
unattributablePayloads: unattributable_payloads,
});
return {
verdict,
counts,
findings: reasoned.kept,
suppressed: [...judged.dropped, ...reasoned.dropped],
suppressed: [...judged.dropped, ...judged.unverified, ...reasoned.dropped, ...reasoned.unverified],
unverified,
skipped,
missing_reviewers,
unattributable_payloads,
allow_blocked_by,
};
}

View file

@ -0,0 +1,350 @@
// 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 settings.json:16's 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';
export const MAX_CONV_TURNS = 3;
export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 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 settings.json:16's 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 (import.meta.url === `file://${process.argv[1]}`) {
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);
}

View file

@ -0,0 +1,119 @@
// 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).
//
// Two-tier, same shape as lib/exporters/endpoint-validator.mjs's SSRF gate:
// - 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
// unlocks the permanently-blocked one.
//
// CLI shim:
// node lib/validators/query-privacy-gate.mjs [--soft] "<query text>"
// → JSON {valid, errors, warnings}; exit 0 valid, 1 invalid.
import { issue } from '../util/result.mjs';
// WARN tier — absolute filesystem paths (leaks local directory layout).
export const ABSOLUTE_PATH_PATTERNS = Object.freeze([
/\/Users\/[^\s"'`]+/,
/\/home\/[^\s"'`]+/,
/[A-Za-z]:\\[^\s"'`]+/,
/\$\{?HOME\}?\/[^\s"'`]+/,
]);
// WARN tier — repo-internal identifiers that don't need to leave the
// machine in a generic research query.
export const REPO_IDENTIFIER_PATTERNS = Object.freeze([
/git\.fromaitochitta\.com[^\s"'`]*/,
/\bktg-plugin-marketplace\b/,
/\bplugins\/cache\/[^\s"'`]+/,
]);
// HARD-BLOCK tier — secret-shaped strings. Never operator-overridable.
//
// Token bodies that contain `-` or `_` break a plain `[A-Za-z0-9]{n,}` run, so
// each such format needs its own pattern rather than relying on run length:
// an Anthropic Console key runs out after `api03` (3 alphanumerics), and a
// fine-grained GitHub PAT after its 22-character segment. Patterns whose body
// class includes `-`/`_` carry no trailing `\b`, which would not fire on a
// non-word final character.
export const SECRET_SHAPED_PATTERNS = Object.freeze([
/\bsk-[A-Za-z0-9]{20,}\b/, // OpenAI-style API key (sk-<48>)
/\bsk-ant-[a-z0-9]+-[A-Za-z0-9_-]{20,}/, // Anthropic Console key (sk-ant-api03-/-oat01- + ~95 base64url)
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key ID
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, // GitHub classic PAT / OAuth / user / server / refresh token
/\bgithub_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT (github_pat_<22>_<59>)
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack token
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key block
]);
function findMatch(patterns, text) {
for (const re of patterns) {
const m = re.exec(text);
if (m) return m[0];
}
return null;
}
/**
* @param {string} text
* @param {{strict?: boolean, env?: object}} [opts]
* @returns {{valid: boolean, errors: import('../util/result.mjs').Issue[], warnings: import('../util/result.mjs').Issue[]}}
*/
export function validateOutboundQuery(text, opts = {}) {
const strict = opts.strict !== false;
const env = opts.env || process.env;
// Bypasses the WARN tier entirely — never affects the hard-block tier below.
const allowWarnTier = env.VOYAGE_QUERY_PRIVACY_ALLOW === '1';
if (typeof text !== 'string' || text.length === 0) {
return { valid: false, errors: [issue('PRIVACY_EMPTY_QUERY', 'Outbound query must be a non-empty string')], warnings: [] };
}
const errors = [];
const warnings = [];
// Hard-block tier — checked unconditionally; no opt-in reaches this branch.
const secretMatch = findMatch(SECRET_SHAPED_PATTERNS, text);
if (secretMatch) {
errors.push(issue('PRIVACY_SECRET_SHAPED', `Outbound query contains a secret-shaped token: ${secretMatch}`));
}
if (!allowWarnTier) {
const pathMatch = findMatch(ABSOLUTE_PATH_PATTERNS, text);
if (pathMatch) {
const issueObj = issue('PRIVACY_ABSOLUTE_PATH', `Outbound query contains an absolute filesystem path: ${pathMatch}`);
if (strict) errors.push(issueObj); else warnings.push(issueObj);
}
const repoMatch = findMatch(REPO_IDENTIFIER_PATTERNS, text);
if (repoMatch) {
const issueObj = issue('PRIVACY_REPO_IDENTIFIER', `Outbound query contains a repo-internal identifier: ${repoMatch}`);
if (strict) errors.push(issueObj); else warnings.push(issueObj);
}
}
return { valid: errors.length === 0, errors, warnings };
}
// ---- CLI shim ----------------------------------------------------------------
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const text = args.find(a => !a.startsWith('--'));
if (text === undefined) {
process.stderr.write('Usage: query-privacy-gate.mjs [--soft] "<query text>"\n');
process.exit(2);
}
const r = validateOutboundQuery(text, { strict });
process.stdout.write(JSON.stringify(r) + '\n');
process.exit(r.valid ? 0 : 1);
}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "voyage",
"version": "5.9.0",
"version": "5.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voyage",
"version": "5.9.0",
"version": "5.10.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -1,6 +1,7 @@
{
"name": "voyage",
"version": "5.9.0",
"version": "5.10.0",
"private": true,
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"type": "module",
"engines": {

342
scripts/storm-measure.mjs Normal file
View file

@ -0,0 +1,342 @@
#!/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';
// 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 %
* (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 (import.meta.url === `file://${process.argv[1]}`) {
mainCli();
}

View file

@ -0,0 +1,127 @@
// tests/commands/trekendsession.test.mjs
// Regression tests for /trekendsession (commands/trekendsession.md).
//
// Bug (2026-07-03): two of the three !`...` eager-exec blocks contained
// unresolved placeholders (<project-dir> etc.). The harness executes
// eager-exec blocks at command LOAD time, so zsh parsed <project-dir> as
// input redirection and the command aborted before the model saw a single
// instruction. Eager-exec is only valid for self-contained commands.
//
// Pattern D (markdown structure) — assertions against command prose.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMANDS_DIR = join(ROOT, 'commands');
const COMMAND_FILE = join(COMMANDS_DIR, 'trekendsession.md');
function readCommand() {
return readFileSync(COMMAND_FILE, 'utf8');
}
function extractPhase(commandText, phaseHeader) {
const startIdx = commandText.indexOf(phaseHeader);
if (startIdx === -1) return '';
const rest = commandText.slice(startIdx);
const nextPhase = rest.search(/\n## (?:Phase |Hard )/);
if (nextPhase === -1) return rest;
return rest.slice(0, nextPhase);
}
// Extract all eager-exec blocks (!`...`) from a command/skill file,
// including multi-line blocks. Returns [{ content, line }].
function extractEagerBlocks(text) {
const blocks = [];
const re = /!`([^`]+)`/g;
let m;
while ((m = re.exec(text)) !== null) {
const line = text.slice(0, m.index).split('\n').length;
blocks.push({ content: m[1], line });
}
return blocks;
}
// ---------------------------------------------------------------
// Marketplace-wide regression guard: eager-exec blocks must be
// self-contained. An unresolved placeholder (<angle> or {curly}) in an
// eager block is executed verbatim by the shell at load time — <x> is
// parsed as input redirection and aborts the whole command load.
// ---------------------------------------------------------------
test('eager-exec guard — no !`-block in commands/ contains an unresolved placeholder', () => {
const offenders = [];
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
const text = readFileSync(join(COMMANDS_DIR, file), 'utf8');
for (const { content, line } of extractEagerBlocks(text)) {
// Placeholder conventions: <angle-word> or {curly_word}. Curly must
// contain a separator (- or _) so JS destructuring like {join} in a
// legitimate self-contained script does not false-positive; angle
// placeholders are unambiguous (shell would parse them as redirects).
if (/<[a-z][a-z0-9_-]*>/.test(content) || /\{[a-z][a-z0-9]*([_-][a-z0-9]+)+\}/.test(content)) {
offenders.push(`${file}:${line}`);
}
}
}
assert.deepEqual(
offenders,
[],
`eager-exec !\`-blocks run at command LOAD time and must be self-contained; ` +
`placeholder found in: ${offenders.join(', ')}`,
);
});
// ---------------------------------------------------------------
// trekendsession-specific: exactly one eager block (Phase 1 project
// discovery — self-contained, legitimate); Phases 3 and 4 are runtime
// Bash-tool commands with model-substituted values, never eager.
// ---------------------------------------------------------------
test('trekendsession — exactly one eager-exec block remains (Phase 1 discovery)', () => {
const cmd = readCommand();
const blocks = extractEagerBlocks(cmd);
assert.equal(
blocks.length,
1,
`expected exactly 1 eager-exec block (Phase 1 discovery), got ${blocks.length} at line(s) ${blocks.map((b) => b.line).join(', ')}`,
);
assert.match(
blocks[0].content,
/readdirSync\(root\)/,
'the surviving eager block must be the self-contained Phase 1 discovery script',
);
});
test('trekendsession Phase 3 — atomic-write block is runtime Bash (no eager prefix) with plugin-root import', () => {
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
assert.doesNotMatch(phase3, /!`/, 'Phase 3 must not use eager-exec — values exist only at runtime');
assert.match(
phase3,
/\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/atomic-write\.mjs/,
'Phase 3 import must use the absolute ${CLAUDE_PLUGIN_ROOT} path — cwd is the user repo, not the plugin root',
);
assert.doesNotMatch(
phase3,
/['"]\.\/lib\/util\/atomic-write\.mjs['"]/,
'Phase 3 must not import atomic-write.mjs via a cwd-relative path',
);
});
test('trekendsession Phase 4 — validator call is runtime Bash (no eager prefix) with plugin-root path', () => {
const phase4 = extractPhase(readCommand(), '## Phase 4 ');
assert.doesNotMatch(phase4, /!`/, 'Phase 4 must not use eager-exec — the state-file path exists only at runtime');
assert.match(
phase4,
/\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/session-state-validator\.mjs/,
'Phase 4 validator path must use the absolute ${CLAUDE_PLUGIN_ROOT} convention',
);
assert.doesNotMatch(
phase4,
/<[a-z][a-z0-9_-]*>/,
'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection',
);
});

View file

@ -0,0 +1,349 @@
// tests/commands/trekexecute-parallel-portability.test.mjs
//
// Executable tests for the two shell blocks /trekexecute Phase 2.6 Step 2a' and
// Phase 2.55 Check 2 tell the agent to run. Both blocks wrecked a real voyage on
// macOS (order 20260831T214411Z-941965142-from-.claude):
//
// Defect 1: `realpath --relative-to` is GNU coreutils. On BSD realpath the
// command substitution fails, PROJECT_REL becomes EMPTY, and `mkdir -p
// "$wt/"` + `cp ... "$wt//"` both SUCCEED — brief.md/plan.md land at the
// worktree root instead of the project relpath. Exit status stays 0; only
// file location tells the truth. Every assertion here checks placement.
// Defect 2: Check 2 ran `git add {plan-path}` unconditionally. When the
// project directory is gitignored (normal — .claude/projects/ is tool-
// managed and local-only) the add fails and the plan never reaches HEAD.
//
// The blocks are EXTRACTED from commands/trekexecute.md and executed, so the
// test binds to what an agent actually copies, not to prose about it. Anchors
// are grep-able strings, never line numbers.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
const COPY_BLOCK_ANCHOR = "**2a'. Copy gitignored project artifacts";
const CHECK2_ANCHOR = '### Check 2 —';
// The pre-fix form, kept ONLY as the negative control for Defect 1.
const LEGACY_GNU_LINE = 'PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"';
// --- helpers -------------------------------------------------------------
/** Extract the first ```bash fence that follows `anchor` in commands/trekexecute.md. */
function extractBashBlock(anchor) {
const text = readFileSync(COMMAND_FILE, 'utf8');
const at = text.indexOf(anchor);
assert.ok(at >= 0, `anchor not found in trekexecute.md: ${anchor}`);
const fenceOpen = text.indexOf('```bash', at);
assert.ok(fenceOpen >= 0, `no bash fence after anchor: ${anchor}`);
const bodyStart = text.indexOf('\n', fenceOpen) + 1;
const fenceClose = text.indexOf('```', bodyStart);
assert.ok(fenceClose > bodyStart, `unterminated bash fence after anchor: ${anchor}`);
return text.slice(bodyStart, fenceClose);
}
/**
* A PATH directory whose `realpath` behaves like BSD realpath: it rejects every
* GNU long option and resolves bare paths correctly. Stubbed, never assumed
* the machine running the suite may or may not have GNU coreutils.
*/
function bsdRealpathStubDir() {
const dir = mkdtempSync(join(tmpdir(), 'trek-bsdstub-'));
const stub = join(dir, 'realpath');
writeFileSync(stub, [
'#!/bin/sh',
'# BSD realpath stand-in: no GNU long options.',
'for a in "$@"; do',
' case "$a" in',
' --*) echo "realpath: illegal option -- -" >&2; exit 1 ;;',
' esac',
'done',
"exec python3 -c 'import os,sys",
'for p in sys.argv[1:]: print(os.path.realpath(p))',
"' \"$@\"",
'',
].join('\n'));
chmodSync(stub, 0o755);
return dir;
}
/** A PATH directory whose `git check-ignore` dies fatally (128), everything else real. */
function fatalCheckIgnoreGitStubDir() {
const realGit = execFileSync('/usr/bin/env', ['sh', '-c', 'command -v git'], { encoding: 'utf8' }).trim();
assert.ok(realGit, 'git not on PATH — cannot build the check-ignore stub');
const dir = mkdtempSync(join(tmpdir(), 'trek-gitstub-'));
const stub = join(dir, 'git');
writeFileSync(stub, [
'#!/bin/sh',
'# `git check-ignore` fatal (128): NOT an answer about ignore status.',
'for a in "$@"; do',
' if [ "$a" = "check-ignore" ]; then',
' echo "fatal: simulated check-ignore failure" >&2',
' exit 128',
' fi',
'done',
`exec ${realGit} "$@"`,
'',
].join('\n'));
chmodSync(stub, 0o755);
return dir;
}
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
/**
* A temp repo with a committed file plus a project directory holding brief/plan
* (+ research unless opts.research === false). `gitignoreProject: true` adds the
* .gitignore line that makes the project directory invisible to `git add`
* and, note, invisible to `git status --porcelain` too, which is exactly why
* this topology sails through Check 1 and only trips on Check 2.
*/
function makeRepo(opts = {}) {
const root = realpathSync(mkdtempSync(join(tmpdir(), 'trek-exec-')));
git(root, 'init', '-q', '-b', 'main');
git(root, 'config', 'user.email', 'test@example.invalid');
git(root, 'config', 'user.name', 'Test');
writeFileSync(join(root, 'README.md'), '# fixture\n');
if (opts.gitignoreProject) writeFileSync(join(root, '.gitignore'), '.claude/projects/\n');
git(root, 'add', 'README.md', ...(opts.gitignoreProject ? ['.gitignore'] : []));
git(root, 'commit', '-qm', 'init');
const projectRel = join('.claude', 'projects', 'demo');
const projectDir = join(root, projectRel);
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, 'brief.md'), '# brief\n');
writeFileSync(join(projectDir, 'plan.md'), '# plan\n');
if (opts.research !== false) {
mkdirSync(join(projectDir, 'research'), { recursive: true });
writeFileSync(join(projectDir, 'research', '01-x.md'), '# r\n');
}
const worktreeDir = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees');
mkdirSync(join(worktreeDir, 'session-1'), { recursive: true });
mkdirSync(join(worktreeDir, 'session-2'), { recursive: true });
return { root, projectRel, projectDir, worktreeDir };
}
function runBlock(script, { cwd, env }) {
return spawnSync('bash', ['-c', script], { cwd, env: { ...process.env, ...env }, encoding: 'utf8' });
}
function copyBlockEnv(repo, pathPrefixDir) {
return {
REPO_ROOT: repo.root,
PROJECT_DIR: repo.projectDir,
WORKTREE_DIR: repo.worktreeDir,
PATH: `${pathPrefixDir}:${process.env.PATH}`,
};
}
function exists(p) {
try { readFileSync(p); return true; } catch { return false; }
}
// --- Defect 1: portable relpath derivation -------------------------------
test("2a' — the BSD realpath stub is known-positive: bare path resolves, --relative-to is rejected", () => {
const stubDir = bsdRealpathStubDir();
try {
const bare = runBlock('realpath "$HOME"', { cwd: ROOT, env: { PATH: `${stubDir}:${process.env.PATH}` } });
assert.equal(bare.status, 0, 'stub must resolve a bare path (proves it can succeed)');
assert.equal(bare.stdout.trim(), realpathSync(process.env.HOME));
const gnu = runBlock('realpath --relative-to=/ "$HOME"', { cwd: ROOT, env: { PATH: `${stubDir}:${process.env.PATH}` } });
assert.notEqual(gnu.status, 0, 'stub must reject the GNU long option');
assert.match(gnu.stderr, /illegal option/, 'stub must fail the way BSD realpath fails');
assert.equal(gnu.stdout.trim(), '', 'no stdout — this is what leaves PROJECT_REL empty');
} finally {
rmSync(stubDir, { recursive: true, force: true });
}
});
test("2a' — NEGATIVE CONTROL: the GNU form drops plan.md at the worktree ROOT under BSD realpath", () => {
const repo = makeRepo();
const stubDir = bsdRealpathStubDir();
try {
const legacy = [
'PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"',
LEGACY_GNU_LINE,
'for wt in "$WORKTREE_DIR"/session-*; do',
' [ -d "$wt" ] || continue',
' mkdir -p "$wt/$PROJECT_REL"',
' cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"',
' cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"',
'done',
].join('\n');
runBlock(legacy, { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
const wt = join(repo.worktreeDir, 'session-1');
assert.equal(exists(join(wt, repo.projectRel, 'plan.md')), false,
'the broken form must NOT put plan.md at the project relpath');
assert.equal(exists(join(wt, 'plan.md')), true,
'the broken form silently drops plan.md at the worktree root — the measured havari');
} finally {
rmSync(repo.root, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
});
test("2a' — the shipped block copies brief/plan/research to $wt/$PROJECT_REL without GNU realpath", () => {
const repo = makeRepo();
const stubDir = bsdRealpathStubDir();
try {
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
assert.equal(r.status, 0, `block must succeed without GNU realpath. stderr: ${r.stderr}`);
for (const s of ['session-1', 'session-2']) {
const dest = join(repo.worktreeDir, s, repo.projectRel);
assert.equal(exists(join(dest, 'plan.md')), true, `${s}: plan.md must reach $wt/$PROJECT_REL`);
assert.equal(exists(join(dest, 'brief.md')), true, `${s}: brief.md must reach $wt/$PROJECT_REL`);
assert.equal(exists(join(dest, 'research', '01-x.md')), true, `${s}: research/ must reach $wt/$PROJECT_REL`);
assert.equal(exists(join(repo.worktreeDir, s, 'plan.md')), false,
`${s}: nothing may land at the worktree root`);
}
} finally {
rmSync(repo.root, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
});
test("2a' — a project without research/ is not a failure (block still exits 0)", () => {
const repo = makeRepo({ research: false });
const stubDir = bsdRealpathStubDir();
try {
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
assert.equal(r.status, 0, `missing research/ must not fail the wave. stderr: ${r.stderr}`);
assert.equal(exists(join(repo.worktreeDir, 'session-1', repo.projectRel, 'plan.md')), true);
} finally {
rmSync(repo.root, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
});
test("2a' — an underivable relpath fails LOUDLY instead of dropping files at the worktree root", () => {
const repo = makeRepo();
const stubDir = bsdRealpathStubDir();
const outside = realpathSync(mkdtempSync(join(tmpdir(), 'trek-outside-')));
try {
mkdirSync(join(outside, 'p'), { recursive: true });
writeFileSync(join(outside, 'p', 'brief.md'), 'b');
writeFileSync(join(outside, 'p', 'plan.md'), 'p');
const env = { ...copyBlockEnv(repo, stubDir), PROJECT_DIR: join(outside, 'p') };
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env });
assert.notEqual(r.status, 0, 'a project outside REPO_ROOT must abort the wave');
assert.match(r.stderr, /relpath/i, 'the abort must name the cause');
assert.equal(exists(join(repo.worktreeDir, 'session-1', 'plan.md')), false,
'nothing may be dropped at the worktree root');
} finally {
rmSync(repo.root, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
});
test("2a' — the GNU-only form is gone from the block agents copy", () => {
const block = extractBashBlock(COPY_BLOCK_ANCHOR);
assert.equal(block.includes('--relative-to'), false,
'the copied block must not contain `realpath --relative-to` (GNU-only)');
assert.ok(block.includes('os.path.relpath'), 'the copied block must derive the relpath portably');
});
test("both copied blocks are ASCII-clean (bash 3.2 dies on a multibyte char under set -u)", () => {
const nonAscii = (s) => s.split('\n')
.map((line, i) => [i + 1, line])
.filter(([, line]) => /[^\x00-\x7F]/.test(line));
// Known-positive: the detector must actually fire on a multibyte char.
assert.equal(nonAscii('echo "a — b"').length, 1, 'detector must find an em-dash');
for (const anchor of [COPY_BLOCK_ANCHOR, CHECK2_ANCHOR]) {
assert.deepEqual(nonAscii(extractBashBlock(anchor)), [],
`non-ASCII inside the shell block after ${anchor} (prose outside the fence is fine)`);
}
});
// --- Defect 2: gitignored project directory ------------------------------
// The block carries the `{plan-path}` placeholder the way every other block in
// trekexecute.md does. Substitute it exactly as an agent would — never inject
// PLAN_PATH through the environment: that would supply what the doc must supply
// itself, and a block that never assigns the variable would still pass.
function check2Script(planPath) {
const block = extractBashBlock(CHECK2_ANCHOR);
assert.ok(block.includes('{plan-path}'),
'Check 2 block must carry the {plan-path} placeholder for the agent to substitute');
return block.replace('{plan-path}', planPath);
}
function check2Env(repo, pathPrefixDir) {
const env = { REPO_ROOT: repo.root, PLAN_PATH: '' };
if (pathPrefixDir) env.PATH = `${pathPrefixDir}:${process.env.PATH}`;
return env;
}
test('Check 2 — gitignored project dir: no commit, no failure, and 2a\' still delivers the plan', () => {
const repo = makeRepo({ gitignoreProject: true });
const stubDir = bsdRealpathStubDir();
try {
// Known-positive on the premise: the plan file really is ignored here.
const ci = spawnSync('git', ['check-ignore', '-v', join(repo.projectRel, 'plan.md')],
{ cwd: repo.root, encoding: 'utf8' });
assert.equal(ci.status, 0, 'fixture premise: the plan file must actually be gitignored');
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
const r = runBlock(check2Script(join(repo.projectRel, 'plan.md')),
{ cwd: repo.root, env: check2Env(repo) });
assert.equal(r.status, 0, `Check 2 must tolerate a gitignored plan file. stderr: ${r.stderr}`);
assert.equal(git(repo.root, 'rev-parse', 'HEAD').trim(), head,
'an ignored plan file must NOT be forced into history (origin is a public mirror)');
const copy = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
assert.equal(copy.status, 0, `copy step must succeed. stderr: ${copy.stderr}`);
assert.equal(exists(join(repo.worktreeDir, 'session-1', repo.projectRel, 'plan.md')), true,
'the plan must reach the worktree even though git never tracked it');
} finally {
rmSync(repo.root, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
});
test('Check 2 — KNOWN-POSITIVE: an untracked, NOT-ignored plan file is still added and committed', () => {
const repo = makeRepo();
try {
const planPath = join(repo.projectRel, 'plan.md');
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
const r = runBlock(check2Script(planPath), { cwd: repo.root, env: check2Env(repo) });
assert.equal(r.status, 0, `Check 2 must succeed on a normal untracked plan. stderr: ${r.stderr}`);
assert.notEqual(git(repo.root, 'rev-parse', 'HEAD').trim(), head,
'a trackable plan file must still be committed for worktree visibility');
const ls = spawnSync('git', ['ls-files', '--error-unmatch', planPath], { cwd: repo.root, encoding: 'utf8' });
assert.equal(ls.status, 0, 'the plan file must now be tracked');
} finally {
rmSync(repo.root, { recursive: true, force: true });
}
});
test('Check 2 — a FATAL git check-ignore (128) is not read as "not ignored"', () => {
const repo = makeRepo({ gitignoreProject: true });
const stubDir = fatalCheckIgnoreGitStubDir();
try {
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
const r = runBlock(check2Script(join(repo.projectRel, 'plan.md')),
{ cwd: repo.root, env: check2Env(repo, stubDir) });
assert.notEqual(r.status, 0, 'a fatal check-ignore must stop, not fall through to git add');
assert.match(r.stderr, /check-ignore/, 'the stop must name the failing probe');
assert.equal(git(repo.root, 'rev-parse', 'HEAD').trim(), head, 'no commit may be made on a fatal probe');
} finally {
rmSync(repo.root, { recursive: true, force: true });
rmSync(stubDir, { recursive: true, force: true });
}
});

View file

@ -34,10 +34,277 @@ test('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNAL
test('trekresearch — low-effort path references --quick equivalent', () => {
const text = read();
const compIdx = text.indexOf('## Composition rule (v5.1)');
// 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 section = text.slice(compIdx, compIdx + 2000);
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
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.
function phaseSlice(doc, startHeading, endHeading) {
const start = doc.indexOf(startHeading);
assert.ok(start >= 0, `${startHeading} missing`);
const end = doc.indexOf(endHeading, start);
assert.ok(end > start, `${endHeading} missing — could not bound ${startHeading}`);
return doc.slice(start, end);
}
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 — 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 keeps the standard/low effort sentences verbatim', () => {
const text = read();
assert.ok(
text.includes('Standard effort (or absent): use the existing conditional triggers.'),
'the standard-effort sentence must survive the Phase 5 rewrite verbatim',
);
assert.ok(
text.includes('Low effort: inline research only, no agent swarm'),
'the low-effort sentence must survive the Phase 5 rewrite verbatim',
);
});
// --- Step 8: Phase 4.5 dimension discovery + Independence amendment ---
const ORCHESTRATOR_FILE = join(ROOT, 'agents', 'research-orchestrator.md');
function readOrchestrator() { return readFileSync(ORCHESTRATOR_FILE, 'utf8'); }
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');
});
test('trekresearch — orchestrator phase map is correct, has no Phase 9, and carries Phase 4.5', () => {
const doc = readOrchestrator();
const start = doc.indexOf('<!-- Phase mapping');
assert.ok(start >= 0, 'phase mapping comment missing');
const end = doc.indexOf('-->', start);
assert.ok(end > start, 'phase mapping comment not terminated');
const map = doc.slice(start, end);
assert.doesNotMatch(map, /Command Phase 9/,
'the command ends at Phase 8 — a Command Phase 9 row is a fiction');
// Six orchestrator rows, each pointing at the phase the command actually has.
const expected = [
[1, '4'],
[2, '4'],
[3, '5'],
[4, '6'],
[5, '7'],
[6, '8'],
];
for (const [orch, cmd] of expected) {
const re = new RegExp(`Orchestrator Phase ${orch}\\s+= Command Phase ${cmd.replace('.', '\\.')}\\b`);
assert.match(map, re, `map row for Orchestrator Phase ${orch} must point at Command Phase ${cmd}`);
}
assert.match(map, /Command Phase 4\.5/, 'the map must carry the new Phase 4.5 row');
});
// --- v5.1.1 runtime SC4 + SC7 ---

View file

@ -33,19 +33,31 @@ voyage_trekplan_deep_dives{_schema_id="trekplan",slug="add-auth",mode="default",
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",profile="premium",profile_source="default"} 3
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",profile="premium",profile_source="default"} 5
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",profile="premium",profile_source="default"} 1
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",profile="premium",profile_source="default"} 4
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",profile="premium",profile_source="default"} 2
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

View file

@ -20,7 +20,7 @@
| 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, project_dir | commands/trekbrief.md (orchestrator-emit Phase 7) | trekbrief.md:657-672 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, slug, project_dir, brief_path, dimensions, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:388-410 | profile, phase_models, parallel_agents, external_research_enabled, 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 | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:634-676 | 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 | commands/trekplan.md (orchestrator-emit Phase 12) | trekplan.md:805-826 | 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 | commands/trekexecute.md (orchestrator-emit Phase 9) | trekexecute.md:1479-1494 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:64-86 | payload.profile, payload.phase_models, payload.profile_source | none |

View file

@ -2,4 +2,4 @@
{"_schema_id":"trekexecute","ts":"2026-05-09T08:30:00.000Z","plan":"trekplan-add-auth.md","plan_type":"plan","mode":"execute","result":"completed","steps_total":12,"steps_passed":12,"steps_failed":0,"steps_skipped":0,"profile":"premium","profile_source":"inheritance"}
{"_schema_id":"trekreview","ts":"2026-05-09T09:00:00.000Z","slug":"add-auth","verdict":"ALLOW","reviewed_files_count":18,"mode":"default","duration_ms":4521,"profile":"balanced","profile_source":"flag"}
{"_schema_id":"trekbrief","ts":"2026-05-09T07:00:00.000Z","slug":"add-auth","mode":"default","interview_turns":7,"review_iterations":2,"research_topics":3,"profile":"economy","profile_source":"env"}
{"_schema_id":"trekresearch","ts":"2026-05-09T07:30:00.000Z","slug":"add-auth","mode":"default","scope":"both","dimensions":4,"agents_local":5,"agents_external":3,"contradictions":1,"open_questions":2,"profile":"premium","profile_source":"default"}
{"_schema_id":"trekresearch","ts":"2026-05-09T07:30:00.000Z","slug":"add-auth","mode":"default","scope":"both","dimensions":4,"dimensions_baseline":3,"effort":"high","conv_turns":5,"empty_turns":1,"unique_sources":17,"agents_local":5,"agents_external":3,"contradictions":1,"open_questions":2,"profile":"premium","profile_source":"default"}

View file

@ -0,0 +1,556 @@
// tests/hooks/agent-cap.test.mjs
// Step 10 — pins hooks/scripts/pre-agent-cap.mjs, the PreToolUse enforcement
// of the /trekresearch Phase 5 loop bound.
//
// The spike (docs/spike-pretooluse-subagent-reach.md, RESULT: FIRES) proved a
// plugin PreToolUse hook observes sub-agent tool calls, so the cap can be
// enforced rather than merely documented. This file pins the two properties
// that matter in opposite directions:
//
// (a) it DENIES (exit 2) once the ledger shows the budget spent, and
// (b) it does NOT over-block — an unrelated session, unparsable stdin, a
// stale marker, or the kill switch all exit 0.
//
// Pattern: tests/hooks/bash-guard.test.mjs (child process via runHook).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const CAP_HOOK = join(ROOT, 'hooks', 'scripts', 'pre-agent-cap.mjs');
const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
const SESSION = 'sess-abc123';
const RUN_ID = 'run-xyz789';
/**
* Build a throwaway CLAUDE_PLUGIN_DATA dir holding a scope marker for
* `sessionId` and `turns` ledger entries for RUN_ID.
*/
function fixture({ turns = 0, sessionId = SESSION, startedAt = new Date(), exhausted = false } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-'));
mkdirSync(join(dir, 'trekresearch-loop-scope'), { recursive: true });
writeFileSync(
join(dir, 'trekresearch-loop-scope', `${sessionId}.json`),
JSON.stringify({ runId: RUN_ID, startedAt: startedAt.toISOString() }),
);
const lines = Array.from({ length: turns }, (_, i) =>
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high', slot: i + 1 }),
);
// The tombstone research-loop-cap.mjs appends when it denies a turn for
// budget. Its presence is what tells this hook "the gate already said no".
if (exhausted) lines.push(JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, exhausted: true }));
writeFileSync(join(dir, 'trekresearch-loop-ledger.jsonl'), lines.length ? lines.join('\n') + '\n' : '');
return dir;
}
// TREKRESEARCH_MAX_CONV_TURNS=1 => budget = 1 * MAX_TOTAL_DIMENSIONS (8).
const CAPPED_ENV = { VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
const BUDGET = 8;
function searchInput(sessionId = SESSION) {
return {
session_id: sessionId,
hook_event_name: 'PreToolUse',
tool_name: 'WebSearch',
tool_input: { query: 'claude code hooks reference' },
agent_id: 'aa6d19525a4680fe0',
agent_type: 'general-purpose',
};
}
// -----------------------------------------------------------------------
// DENY — budget spent
// -----------------------------------------------------------------------
test('pre-agent-cap DENIES once the budget gate has denied a turn (tombstone present)', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 2);
assert.match(stderr, /loop cap/i, 'stderr must name the cap it enforced');
assert.match(stderr, new RegExp(`${BUDGET}`), 'stderr must state the budget');
});
test('pre-agent-cap DENIES above the budget too (a breached ledger, whatever caused it)', async () => {
const dir = fixture({ turns: BUDGET + 5 });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// ALLOW — under the cap, and ON the cap.
//
// allowTurn appends BEFORE the turn runs, so during the FINAL granted turn the
// ledger already holds `budget` records. Denying at `used >= budget` therefore
// blocked that turn's own tool calls: the primitive granted B turns, the
// harness permitted B-1, and an exhausted run always ended through an exit-2
// denial rather than the graceful "cap exhausted" exit the prose defines. The
// boundary belongs one turn later, and the tombstone above — not the count —
// is what marks a run actually finished.
// -----------------------------------------------------------------------
test('pre-agent-cap ALLOWS a loop turn under the budget', async () => {
const dir = fixture({ turns: BUDGET - 1 });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
test('pre-agent-cap ALLOWS the FINAL granted turn — its own record is already on the ledger', async () => {
const dir = fixture({ turns: BUDGET });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(
code, 0,
'turn B is granted and in flight; denying it makes the harness permit B-1 turns and forces the wrong exit',
);
});
// -----------------------------------------------------------------------
// DOES NOT OVER-BLOCK — the property that keeps this hook safe to wire
// globally. A broken PreToolUse hook would brick every session on the box.
// -----------------------------------------------------------------------
test('pre-agent-cap ALLOWS an unrelated session even when a loop is exhausted', async () => {
const dir = fixture({ turns: BUDGET });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput('some-other-session'), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0, 'no scope marker for this session_id => out of scope');
});
test('pre-agent-cap ALLOWS when no scope marker directory exists at all', async () => {
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-empty-'));
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
test('pre-agent-cap ALLOWS on unparsable stdin', async () => {
const dir = fixture({ turns: BUDGET });
const { code } = await runHookWithEnv(CAP_HOOK, 'not json at all', {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
test('pre-agent-cap ALLOWS when the input carries no session_id', async () => {
const dir = fixture({ turns: BUDGET });
const input = searchInput();
delete input.session_id;
const { code } = await runHookWithEnv(CAP_HOOK, input, {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
// -----------------------------------------------------------------------
// TTL / auto-reset — a marker left behind by a crashed run must not deny
// tool calls forever.
// -----------------------------------------------------------------------
test('pre-agent-cap ALLOWS when the scope marker is older than the TTL', async () => {
const dir = fixture({ turns: BUDGET, startedAt: new Date(Date.now() - 48 * 3600 * 1000) });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
VOYAGE_CAP_SCOPE_TTL_MS: '1000',
});
assert.strictEqual(code, 0, 'a stale marker must auto-reset, not deny forever');
});
// The TTL runs from marker.startedAt, not from last activity, and `claude
// --resume` keeps the same session_id — so a run that died leaving its marker
// behind hands the resumed session whatever deny window is left. Two things
// bound that: the window is hours, not the machine's life (below), and it only
// opens at all once the budget gate has actually denied a turn.
test('pre-agent-cap ALLOWS a resumed session whose crashed run never exhausted its budget', async () => {
// Marker still fresh, ledger part-spent, no tombstone: the run died mid-loop.
const dir = fixture({ turns: 5 });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(
code, 0,
'a part-spent run leaves no denial record, so resuming its session must not brick unrelated work',
);
});
test('pre-agent-cap uses a default TTL of hours, not a day — a 3h-old marker auto-resets', async () => {
const dir = fixture({
turns: BUDGET,
exhausted: true,
startedAt: new Date(Date.now() - 3 * 3600 * 1000),
});
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
}); // no VOYAGE_CAP_SCOPE_TTL_MS — this is the built-in default
assert.strictEqual(code, 0, 'no real research run lasts 3h; a marker that old is debris');
});
test('pre-agent-cap names the marker path when it denies, so the operator has a remedy', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 2);
assert.ok(
stderr.includes(join(dir, 'trekresearch-loop-scope', `${SESSION}.json`)),
`stderr must name the marker to delete; got:\n${stderr}`,
);
});
// -----------------------------------------------------------------------
// Fail CLOSED once in scope — the hook's own header says a budget control
// that cannot count must not grant. The unreadable-ledger branch returned 0
// and therefore ALLOWED, which is the opposite. A directory standing where
// the ledger file belongs reproduces it portably (EISDIR).
// -----------------------------------------------------------------------
test('pre-agent-cap DENIES when the ledger cannot be read at all (fail closed)', async () => {
const dir = fixture({ turns: 0 });
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
rmSync(ledgerPath, { force: true });
mkdirSync(ledgerPath, { recursive: true });
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 2, 'an in-scope run whose ledger cannot be counted must not be granted');
assert.match(stderr, /could not be read|unreadable/i, 'stderr must say counting failed, not that the budget is spent');
});
test('pre-agent-cap ALLOWS an unreadable ledger when the session is OUT of scope', async () => {
const dir = fixture({ turns: 0, sessionId: 'a-different-session' });
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
rmSync(ledgerPath, { force: true });
mkdirSync(ledgerPath, { recursive: true });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0, 'fail-closed is scoped to the loop, it must not brick unrelated sessions');
});
// -----------------------------------------------------------------------
// Kill switch + default-off
// -----------------------------------------------------------------------
test('pre-agent-cap kill switch VOYAGE_DISABLE_CAP_HOOK=1 allows an exhausted loop', async () => {
const dir = fixture({ turns: BUDGET });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
VOYAGE_DISABLE_CAP_HOOK: '1',
});
assert.strictEqual(code, 0);
});
test('pre-agent-cap is inert when VOYAGE_STORM_ENABLED is not 1 (default-off)', async () => {
const dir = fixture({ turns: BUDGET });
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
VOYAGE_STORM_ENABLED: '0',
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
// -----------------------------------------------------------------------
// The measured environment — CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool
// env, so that is the environment every real run happens in. The writer (the
// Phase 5 bash snippet) and the reader (this hook) must land on the SAME
// fallback root, or the hook allows unconditionally while claiming to enforce.
// -----------------------------------------------------------------------
test('pre-agent-cap enforces via the fallback root when CLAUDE_PLUGIN_DATA is absent', async () => {
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
const root = join(home, '.claude', 'voyage');
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
writeFileSync(
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
);
writeFileSync(
join(root, 'trekresearch-loop-ledger.jsonl'),
[
...Array.from({ length: BUDGET }, (_, i) =>
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high', slot: i + 1 })),
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, exhausted: true }),
].join('\n') + '\n',
);
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: '',
HOME: home,
});
assert.strictEqual(code, 2, 'the hook must find marker AND ledger under the fallback root and deny');
});
test('pre-agent-cap allows under budget in the fallback root — the fallback is not a blanket deny', async () => {
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
const root = join(home, '.claude', 'voyage');
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
writeFileSync(
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
);
writeFileSync(join(root, 'trekresearch-loop-ledger.jsonl'), '');
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: '',
HOME: home,
});
assert.strictEqual(code, 0);
});
// -----------------------------------------------------------------------
// Append-only counting — the hook reads the ledger, it never writes it.
// Writing per tool call would make the cap count its own enforcement.
// -----------------------------------------------------------------------
test('pre-agent-cap never writes to the ledger', async () => {
const dir = fixture({ turns: 2 });
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
const before = readFileSync(ledgerPath, 'utf-8');
await runHookWithEnv(CAP_HOOK, searchInput(), { ...CAPPED_ENV, CLAUDE_PLUGIN_DATA: dir });
assert.strictEqual(readFileSync(ledgerPath, 'utf-8'), before);
});
// -----------------------------------------------------------------------
// Crash-time marker states. These are the states the TTL discussion in the
// hook header anticipates, and none of them had a test: a marker written
// half-way, and a marker whose runId never made it. Both must ALLOW — a
// marker we cannot read cannot tell us which run we are in, and guessing
// would deny tool calls in a session we know nothing about.
// -----------------------------------------------------------------------
test('pre-agent-cap ALLOWS on a partially written (corrupt) scope marker', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
// Exactly what an interrupted printf leaves behind: valid prefix, no close.
writeFileSync(join(dir, 'trekresearch-loop-scope', `${SESSION}.json`), '{"runId":"run-xyz789","star');
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0, 'an unparsable marker is not evidence of a loop turn');
});
test('pre-agent-cap ALLOWS a marker that carries no runId', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
writeFileSync(
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ startedAt: new Date().toISOString() }),
);
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0, 'without a runId there are no ledger lines to count against');
});
test('pre-agent-cap ALLOWS a marker whose runId is not a string', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
// Truthy, so it clears the `!marker?.runId` guard and the session counts as
// in scope — but readLedger compares runId with ===, so a number matches no
// record and the run reads as 0 turns spent. Allow is the right answer either
// way, which is why this stays a pin on the OUTCOME and not an argument for a
// type guard: no writer emits a non-string runId, and the two routes are
// indistinguishable from outside.
writeFileSync(
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ runId: 5, startedAt: new Date().toISOString() }),
);
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
test('pre-agent-cap ALLOWS a marker whose runId is the empty string', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
writeFileSync(
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
JSON.stringify({ runId: '', startedAt: new Date().toISOString() }),
);
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0);
});
// -----------------------------------------------------------------------
// Malformed ledger lines — a truncated final write must not be counted as a
// turn, and must not stop the well-formed lines around it from counting.
// -----------------------------------------------------------------------
test('pre-agent-cap does not count a malformed ledger line as a turn', async () => {
const dir = fixture({ turns: BUDGET - 1 });
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
writeFileSync(ledgerPath, readFileSync(ledgerPath, 'utf-8') + '{"runId":"run-xyz789","dimen\n');
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 0, 'a half-written line is not a spent turn');
});
test('pre-agent-cap still finds the tombstone with malformed lines around it', async () => {
const dir = fixture({ turns: BUDGET, exhausted: true });
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
writeFileSync(ledgerPath, '{ garbage\n' + readFileSync(ledgerPath, 'utf-8') + 'also garbage\n');
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: dir,
});
assert.strictEqual(code, 2, 'skipping bad lines must not mean skipping the runs denial record');
});
// -----------------------------------------------------------------------
// The marker snippet is EXECUTED, not asserted about.
//
// Every existing pin on the marker lifecycle is a substring assertion on the
// prose in commands/trekresearch.md. A snippet that emitted invalid JSON, or
// wrote to a path the hook never looks at, would keep the whole suite green
// while the hook silently allowed everything — which is the exact failure S82
// found by hand. So these tests run the real shell blocks out of the command
// file, with CLAUDE_PLUGIN_DATA stripped and HOME sandboxed, and then run the
// real hook against what they produced.
// -----------------------------------------------------------------------
const CMD_FILE = join(ROOT, 'commands', 'trekresearch.md');
/** Pull the ```bash block that contains `needle` out of the command file. */
function bashBlockContaining(needle) {
const text = readFileSync(CMD_FILE, 'utf-8');
const at = text.indexOf(needle);
assert.ok(at > -1, `commands/trekresearch.md no longer contains ${JSON.stringify(needle)}`);
const open = text.lastIndexOf('```bash', at);
assert.ok(open > -1, `no \`\`\`bash fence opens before ${JSON.stringify(needle)}`);
const bodyStart = text.indexOf('\n', open) + 1;
const close = text.indexOf('```', bodyStart);
assert.ok(close > bodyStart, 'unterminated bash fence');
return text.slice(bodyStart, close);
}
function runSnippet(snippet, env) {
return execFileSync('bash', ['-c', snippet], {
encoding: 'utf-8',
env: { PATH: process.env.PATH, ...env },
});
}
test('the marker WRITE snippet lands valid JSON exactly where the hook looks for it', () => {
const home = mkdtempSync(join(tmpdir(), 'voyage-snippet-'));
const sessionId = 'snippet-session-1';
const snippet = bashBlockContaining('Arms the PreToolUse cap').replace(/\{run_id\}/g, 'snippet-run-1');
runSnippet(snippet, { HOME: home, CLAUDE_CODE_SESSION_ID: sessionId });
const markerPath = join(home, '.claude', 'voyage', 'trekresearch-loop-scope', `${sessionId}.json`);
assert.ok(existsSync(markerPath), `snippet wrote no marker at ${markerPath}`);
const marker = JSON.parse(readFileSync(markerPath, 'utf-8')); // throws if the printf emits bad JSON
assert.strictEqual(marker.runId, 'snippet-run-1', 'runId must be the same id passed to --run-id');
assert.ok(Number.isFinite(Date.parse(marker.startedAt)), `startedAt must parse, got ${marker.startedAt}`);
});
test('the marker snippet writes NO file when CLAUDE_CODE_SESSION_ID is empty', () => {
const home = mkdtempSync(join(tmpdir(), 'voyage-snippet-'));
const snippet = bashBlockContaining('Arms the PreToolUse cap').replace(/\{run_id\}/g, 'snippet-run-2');
const out = runSnippet(snippet, { HOME: home });
const scopeDir = join(home, '.claude', 'voyage', 'trekresearch-loop-scope');
assert.ok(!existsSync(join(scopeDir, '.json')), 'an empty session id must not produce a `.json` marker');
assert.match(out, /stays inert/i, 'the snippet must say the harness cap is inert, not fail silently');
});
test('write snippet then real hook: the loops own writer arms the enforcement end to end', async () => {
const home = mkdtempSync(join(tmpdir(), 'voyage-snippet-'));
const sessionId = 'snippet-session-3';
const runId = 'snippet-run-3';
runSnippet(
bashBlockContaining('Arms the PreToolUse cap').replace(/\{run_id\}/g, runId),
{ HOME: home, CLAUDE_CODE_SESSION_ID: sessionId },
);
// A spent, tombstoned ledger for that same runId, under the same resolved root.
writeFileSync(
join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl'),
[
...Array.from({ length: BUDGET }, (_, i) =>
JSON.stringify({ ts: new Date().toISOString(), runId, dimension: `d${i}`, effort: 'high', slot: i + 1 })),
JSON.stringify({ ts: new Date().toISOString(), runId, exhausted: true }),
].join('\n') + '\n',
);
const denied = await runHookWithEnv(CAP_HOOK, searchInput(sessionId), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: '',
HOME: home,
});
assert.strictEqual(denied.code, 2, 'the hook must find the snippets marker and enforce against it');
// And the removal snippet must disarm it again — same root, same guard.
runSnippet(
bashBlockContaining('Removal — idempotent').replace(/\{run_id\}/g, runId),
{ HOME: home, CLAUDE_CODE_SESSION_ID: sessionId },
);
assert.ok(
!existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-scope', `${sessionId}.json`)),
'the removal snippet must delete the marker the write snippet created',
);
const allowed = await runHookWithEnv(CAP_HOOK, searchInput(sessionId), {
...CAPPED_ENV,
CLAUDE_PLUGIN_DATA: '',
HOME: home,
});
assert.strictEqual(allowed.code, 0, 'a removed marker must take the session back out of scope');
});
// -----------------------------------------------------------------------
// Wiring — pattern from tests/hooks/hooks-json-stop-wired.test.mjs
// -----------------------------------------------------------------------
function invocationOf(h) {
return [h.command || '', ...(h.args || [])].join(' ').trim();
}
test('hooks.json wires pre-agent-cap.mjs on PreToolUse with ${CLAUDE_PLUGIN_ROOT}', () => {
const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
const invocations = (cfg.hooks.PreToolUse || []).flatMap((entry) =>
(entry.hooks || []).map(invocationOf),
);
const capInvocation = invocations.find((cmd) => cmd.includes('pre-agent-cap.mjs'));
assert.ok(capInvocation, `no PreToolUse hook references pre-agent-cap.mjs. Found: ${JSON.stringify(invocations)}`);
assert.match(capInvocation, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'relative paths fail in headless sessions');
assert.match(capInvocation, /^node\s+/);
});
test('hooks.json matcher for pre-agent-cap covers the loops outbound surface', () => {
const cfg = JSON.parse(readFileSync(HOOKS_JSON, 'utf8'));
const entry = (cfg.hooks.PreToolUse || []).find((e) =>
(e.hooks || []).some((h) => invocationOf(h).includes('pre-agent-cap.mjs')),
);
assert.ok(entry, 'pre-agent-cap entry missing from PreToolUse');
for (const tool of ['WebSearch', 'WebFetch', 'Task']) {
assert.match(entry.matcher, new RegExp(tool), `matcher must cover ${tool}`);
}
});

View file

@ -129,6 +129,100 @@ test('pre-bash-executor BLOCKS system shutdown command', async () => {
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a privileged halt at command position', async () => {
const { code } = await runHook(PRE_BASH, bashInput('sudo shutdown -h now'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive keyword after a separator', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo done && poweroff'));
assert.strictEqual(code, 2);
});
// Bypasses opened when the rule was anchored to command position: whitespace
// was collapsed BEFORE the pattern ran (killing the newline branch), `&` was
// missing from the separator class, and a keyword handed to a shell wrapper
// sits at command position without any separator in front of it.
test('pre-bash-executor BLOCKS a destructive keyword after a newline separator', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo done\npoweroff'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive keyword after a background separator', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo done & poweroff'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive command wrapped in bash -c', async () => {
const { code } = await runHook(PRE_BASH, bashInput('bash -c "poweroff"'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive command wrapped in sh -c', async () => {
const { code } = await runHook(PRE_BASH, bashInput("sh -c 'reboot'"));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a destructive command handed to xargs', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo x | xargs reboot'));
assert.strictEqual(code, 2);
});
test('pre-bash-executor BLOCKS a backslash-escaped destructive command', async () => {
// `\reboot` runs reboot — the backslash suppresses alias expansion, nothing
// else. The command-position anchor must see through it.
const { code } = await runHook(PRE_BASH, bashInput('\\reboot'));
assert.strictEqual(code, 2);
});
// -----------------------------------------------------------------------
// ALLOW — the same keywords as DATA, not at command position.
// The rule matched the bare word anywhere in the string, so a quoted grep
// pattern, ordinary prose, or a commit message that merely named the rule
// was blocked. Anchoring to command position is what separates the two.
// -----------------------------------------------------------------------
test('pre-bash-executor ALLOWS the keyword inside a quoted grep pattern', async () => {
const { code } = await runHook(PRE_BASH, bashInput("grep 'halt' f.mjs"));
assert.strictEqual(code, 0);
});
// The change's own motivating case: a quoted grep alternation. Anchoring alone
// did not reach it — the `|` inside the quotes reads as a separator unless
// quoted spans are treated as data.
test('pre-bash-executor ALLOWS a quoted grep alternation over the keywords', async () => {
const { code } = await runHook(PRE_BASH, bashInput('grep "halt|poweroff" f.mjs'));
assert.strictEqual(code, 0);
});
// Heredoc bodies are data too, and a newline separator is what makes them look
// like command position. The rule's own comment names heredoc data as the
// friction anchoring was meant to remove.
test('pre-bash-executor ALLOWS the keyword at the start of a heredoc body line', async () => {
const { code } = await runHook(PRE_BASH, bashInput('cat <<EOF\nreboot is a word here\nEOF'));
assert.strictEqual(code, 0);
});
test('pre-bash-executor ALLOWS a commit message piped through a heredoc', async () => {
const { code } = await runHook(
PRE_BASH,
bashInput("git commit -F - <<'MSG'\nhalt the loop on empty turns\nMSG"),
);
assert.strictEqual(code, 0);
});
test('pre-bash-executor ALLOWS the keyword inside echoed prose', async () => {
const { code } = await runHook(PRE_BASH, bashInput('echo "we should halt here"'));
assert.strictEqual(code, 0);
});
test('pre-bash-executor ALLOWS a commit message that names the rule', async () => {
const { code } = await runHook(
PRE_BASH,
bashInput('git commit -m "fix(hooks): anchor shutdown rule to command position"'),
);
assert.strictEqual(code, 0);
});
// -----------------------------------------------------------------------
// BLOCK — cron persistence
// -----------------------------------------------------------------------

View file

@ -31,6 +31,25 @@ test('SC #12: stats-sample.jsonl → expected.prom snapshot byte-for-byte match'
` node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom`);
});
test('Step 9: the four numeric STORM fields are metric families and effort is a label', () => {
const expected = readFileSync(join(FIXTURES, 'expected.prom'), 'utf-8');
for (const field of ['unique_sources', 'dimensions_baseline', 'conv_turns', 'empty_turns']) {
assert.match(
expected,
new RegExp(`^# TYPE voyage_trekresearch_${field} `, 'm'),
`${field} must appear as its own metric family — a numeric that never becomes a metric cannot be measured`,
);
}
// effort is a low-cardinality string: it must ride along as a LABEL, never
// as a metric family (a label is what makes high-vs-standard groupable).
assert.match(expected, /effort="[a-z]+"/, 'effort must be emitted as a label');
assert.doesNotMatch(
expected,
/^# TYPE voyage_trekresearch_effort /m,
'effort must not become a metric family',
);
});
test('empty-input handling: [] returns empty string (no headers)', () => {
assert.equal(transformToPrometheus([]), '');
assert.equal(transformToPrometheus(null), '');

View file

@ -14,6 +14,7 @@ import {
POST_BASH_STATS_ALLOWED,
EVENT_EMIT_PAYLOAD_ALLOWED,
TOKEN_USAGE_ALLOWED,
TREKRESEARCH_ALLOWED,
} from '../../lib/exporters/field-allowlist.mjs';
// ---- path-validator: CWE-22 mitigation -------------------------------------
@ -278,6 +279,43 @@ test('field-allowlist: token-usage INCLUDES numeric/label fields, EXCLUDES sessi
assert.equal('cwd' in out, false, 'cwd MUST be stripped (CWE-212)');
});
// ---- trekresearch allowlist: the `engine` field ----------------------------
test('field-allowlist: trekresearch INCLUDES engine, EXCLUDES question/project_dir/brief_path (two-sided)', () => {
const record = {
ts: '2026-08-09T12:00:00.000Z',
question: 'which retrieval strategy survives contradiction?',
mode: 'default',
scope: 'both',
engine: 'deep-research',
slug: 'storm-upgrade',
project_dir: '/Users/ktg/secret/project',
brief_path: '/Users/ktg/secret/project/brief.md',
dimensions: 4,
agents_local: 7,
agents_external: 4,
gemini_used: false,
confidence: 0.82,
contradictions: 1,
open_questions: 3,
};
const out = applyFieldAllowlist(record, 'trekresearch');
// INCLUDED — low-cardinality label, emitted (trekresearch.md:533) and
// promised in prose (:570-572); it was silently dropped before this pin.
assert.equal('engine' in out, true, 'engine MUST be allowlisted — it is emitted and documented');
assert.equal(out.engine, 'deep-research');
assert.equal(out._schema_id, 'trekresearch');
// EXCLUDED (CWE-212 boundary)
assert.equal('question' in out, false, 'question MUST be stripped (prose, CWE-212)');
assert.equal('project_dir' in out, false, 'project_dir MUST be stripped (path, CWE-212)');
assert.equal('brief_path' in out, false, 'brief_path MUST be stripped (path, CWE-212)');
});
test('field-allowlist: TREKRESEARCH_ALLOWED is frozen (drift-pin)', () => {
assert.equal(Object.isFrozen(TREKRESEARCH_ALLOWED), true,
'TREKRESEARCH_ALLOWED must be frozen — runtime mutation prevention');
});
test('field-allowlist: null/undefined record handled safely', () => {
assert.deepEqual(applyFieldAllowlist(null, 'trekplan'), {});
assert.deepEqual(applyFieldAllowlist(undefined, 'trekplan'), {});

View file

@ -6,6 +6,9 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
severityRank,
ingest,
@ -13,9 +16,14 @@ import {
judgeFilter,
reasonablenessFilter,
computeVerdict,
classifySuppression,
REFUTING_REASONS,
UNVERIFIED_REASONS,
runContract,
} from '../../lib/review/coordinator-contract.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
// ---- Pass 1 — dedup --------------------------------------------------------
test('dedupByTriplet — genuine cross-reviewer collapse (identical triplet) → 1, raised_by both', () => {
@ -98,29 +106,41 @@ test('computeVerdict — counts each severity tier', () => {
// ---- Pass 3 — reasonableness -----------------------------------------------
test('reasonablenessFilter — drops unknown rule_key + citation-less, corrects severity mismatch', () => {
test('reasonablenessFilter — citation-less is REFUTED, unknown rule_key is UNVERIFIED, severity mismatch corrected', () => {
// Contract change (fail-closed): only `no-citation` refutes. An ad-hoc
// rule_key is a real defect wearing the wrong label — v5.1.1 high-effort mode
// already keeps those, normalised to PLAN_EXECUTE_DRIFT.
const r = reasonablenessFilter([
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → drop
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → unverified
{ file: '', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR' }, // no file → drop
{ file: 'x.mjs', line: -1, rule_key: 'MISSING_TEST', severity: 'MAJOR' }, // line < 0 → drop
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MINOR' }, // catalogue is MAJOR → correct, keep
]);
assert.equal(r.kept.length, 1);
assert.equal(r.dropped.length, 3);
assert.equal(r.dropped.length, 2);
assert.deepEqual(r.dropped.map((f) => f.suppressed_reason), ['no-citation', 'no-citation']);
assert.equal(r.unverified.length, 1);
assert.equal(r.unverified[0].suppressed_reason, 'unknown-rule_key');
assert.equal(r.kept[0].severity, 'MAJOR');
assert.equal(r.kept[0].original_severity, 'MINOR');
});
// ---- Pass 2 — judge --------------------------------------------------------
test('judgeFilter — drops over-long title and empty recommended_action', () => {
test('judgeFilter — over-long title and empty recommended_action are UNVERIFIED, not dropped', () => {
// Contract change (fail-closed): both implemented Pass 2 tests read a
// `.length` and never examine the claim, so neither refutes the finding.
// `dropped` is empty here on purpose — the refuting Pass 2 filter (Accuracy)
// is the one this deterministic subset excludes.
const j = judgeFilter([
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'x'.repeat(101) }, // too long → drop
{ file: 'x.mjs', line: 2, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok', recommended_action: ' ' }, // empty action → drop
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'x'.repeat(101) }, // too long → unverified
{ file: 'x.mjs', line: 2, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok', recommended_action: ' ' }, // empty action → unverified
{ file: 'x.mjs', line: 3, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok' }, // keep (no action field is fine)
]);
assert.equal(j.kept.length, 1);
assert.equal(j.dropped.length, 2);
assert.equal(j.dropped.length, 0);
assert.equal(j.unverified.length, 2);
assert.deepEqual(j.unverified.map((f) => f.suppressed_reason), ['succinctness:title', 'actionability:empty']);
});
// ---- ingest ----------------------------------------------------------------
@ -161,3 +181,155 @@ test('runContract — deterministic: identical input yields identical output', (
];
assert.deepEqual(runContract(input), runContract(input));
});
// ---- Fail-closed: the `unverified` bucket (ORDRE 834432937) -----------------
//
// The defect: a finding REMOVED by Pass 2/Pass 3, and a reviewer whose payload
// was thrown away or never arrived, are all arithmetically identical to a
// finding that never existed -- they push the verdict toward ALLOW. Measured
// before the fix (probe, 2026-09-01): an over-long-title BLOCKER -> ALLOW; a
// payload with one ad-hoc rule_key -> the whole payload skipped, its valid
// BLOCKER sibling gone -> ALLOW.
//
// The rule under test: a removal is `dropped` ONLY when the test refutes the
// finding as a claim about this codebase. Every other removal is `unverified`,
// and a non-empty `unverified` -- or a reviewer that did not report -- forbids
// ALLOW.
test('classifySuppression — only no-citation refutes; form and taxonomy failures are unverified', () => {
assert.equal(classifySuppression('no-citation'), 'refuted',
'a finding that names no location makes no checkable claim');
assert.equal(classifySuppression('succinctness:title'), 'unverified');
assert.equal(classifySuppression('succinctness:detail'), 'unverified');
assert.equal(classifySuppression('actionability:empty'), 'unverified');
assert.equal(classifySuppression('unknown-rule_key'), 'unverified');
assert.equal(classifySuppression('file-existence:indeterminate'), 'unverified');
assert.equal(classifySuppression('something-nobody-declared'), 'unverified',
'an unclassified reason must fail CLOSED, not open');
});
test('computeVerdict — non-empty unverified forbids ALLOW but never downgrades BLOCK or WARN', () => {
const u = [{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'BLOCKER' }];
const withUnverified = computeVerdict([], { unverified: u });
assert.equal(withUnverified.verdict, 'WARN', 'ALLOW is forbidden while anything is unverified');
assert.deepEqual(withUnverified.counts, { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 },
'the unverified finding is NOT counted into a severity tier');
assert.ok(withUnverified.allow_blocked_by.length > 0);
assert.equal(computeVerdict([{ severity: 'BLOCKER' }], { unverified: u }).verdict, 'BLOCK',
'BLOCK stands regardless of the unverified bucket');
assert.equal(computeVerdict([{ severity: 'MAJOR' }], { unverified: u }).verdict, 'WARN');
assert.equal(computeVerdict([], { unverified: [] }).verdict, 'ALLOW',
'known-positive control: an empty unverified bucket still allows ALLOW');
});
test('computeVerdict — a reviewer that did not report forbids ALLOW', () => {
const r = computeVerdict([], { missingReviewers: ['brief-conformance-reviewer'] });
assert.equal(r.verdict, 'WARN');
assert.ok(r.allow_blocked_by.some((x) => x.includes('brief-conformance-reviewer')));
});
test('runContract — a BLOCKER dropped for an over-long title cannot yield ALLOW', () => {
// Pass 2 succinctness reads `.length`. It never examines the claim, so it
// cannot establish the finding is unreal -- it is unverified, not refuted.
const result = runContract([
{ reviewer: 'code-correctness-reviewer', findings: [
{ file: 'lib/auth/jwt.mjs', line: 19, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER', title: 'x'.repeat(101), detail: 'algo taken from the JWT header' },
] },
]);
assert.notEqual(result.verdict, 'ALLOW', 'an unsubstantiated BLOCKER must never clear the review');
assert.equal(result.findings.length, 0, 'it is still not a kept finding');
assert.equal(result.unverified.length, 1);
assert.equal(result.unverified[0].suppressed_reason, 'succinctness:title');
assert.equal(result.suppressed.length, 1, 'suppressed stays the union of dropped + unverified');
});
test('runContract — a schema-invalid payload cannot yield ALLOW (an unread reviewer is an absent one)', () => {
// Measured: one ad-hoc rule_key invalidates the WHOLE payload at ingest, so a
// valid BLOCKER sibling disappears with it. That must not read as "clean".
const result = runContract([
{ reviewer: 'code-correctness-reviewer', findings: [
{ file: 'lib/auth/jwt.mjs', line: 19, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER', title: 'real' },
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'MINOR', title: 'ad-hoc key' },
] },
]);
assert.equal(result.skipped.length, 1);
assert.notEqual(result.verdict, 'ALLOW');
assert.ok(result.allow_blocked_by.some((x) => x.includes('code-correctness-reviewer')));
});
test('runContract — a reviewer named in expectedReviewers that never reported cannot yield ALLOW', () => {
const result = runContract(
[{ reviewer: 'code-correctness-reviewer', findings: [] }],
{ expectedReviewers: ['code-correctness-reviewer', 'brief-conformance-reviewer'] },
);
assert.deepEqual(result.missing_reviewers, ['brief-conformance-reviewer']);
assert.notEqual(result.verdict, 'ALLOW');
});
test('runContract — known-positive control: every reviewer reported, nothing suppressed → ALLOW', () => {
// Proves ALLOW is still REACHABLE. Without this, "no ALLOW" is not a
// fail-closed contract, only a broken one.
const result = runContract(
[
{ reviewer: 'code-correctness-reviewer', findings: [
{ file: 'a.mjs', line: 1, rule_key: 'MISSING_ERROR_HANDLING', severity: 'MINOR', title: 'unguarded await', recommended_action: 'Wrap the await in a try/catch.' },
] },
{ reviewer: 'brief-conformance-reviewer', findings: [] },
],
{ expectedReviewers: ['code-correctness-reviewer', 'brief-conformance-reviewer'] },
);
assert.equal(result.verdict, 'ALLOW');
assert.equal(result.unverified.length, 0);
assert.deepEqual(result.missing_reviewers, []);
assert.deepEqual(result.allow_blocked_by, []);
});
test('classifySuppression — the refuting reasons the LLM coordinator emits are declared here too', () => {
// agents/review-coordinator.md Pass 2 "Accuracy" and Pass 3 "Non-existent
// file" DO refute (a citation outside the repo root, a file absent from both
// tree and diff). Both are fs/judgement branches this deterministic subset
// excludes, but the vocabulary is owned here so prose and lib cannot drift.
assert.equal(classifySuppression('accuracy:refuted'), 'refuted');
assert.equal(classifySuppression('file-existence:refuted'), 'refuted');
assert.equal(classifySuppression('file-existence:indeterminate'), 'unverified',
'unresolvable must never collapse into refuted');
});
test('suppression vocabulary — the two sets are disjoint and every reason is documented in the prose', () => {
const refuting = [...REFUTING_REASONS];
const overlap = refuting.filter((r) => UNVERIFIED_REASONS.includes(r));
assert.deepEqual(overlap, [], 'a reason cannot be both refuting and unverified');
const prose = readFileSync(join(ROOT, 'agents/review-coordinator.md'), 'utf-8');
assert.ok(prose.includes('review-coordinator'), 'known-positive control: the prose file loaded');
for (const reason of [...refuting, ...UNVERIFIED_REASONS]) {
assert.ok(prose.includes(reason),
`reason "${reason}" is declared in the lib but never documented in agents/review-coordinator.md`);
}
});
test('runContract — an anonymous invalid payload is unattributable, not a reviewer named "unnamed reviewer"', () => {
// `validateFindings` only WARNS on a missing `reviewer`, so a payload can
// fail schema while carrying no name. Reporting it as a reviewer name
// invents an agent nobody launched, and double-counts with expectedReviewers
// when they are in fact the same failure.
const result = runContract(
[{ findings: [{ file: 'x.mjs', line: 1, rule_key: 'NOPE', severity: 'MAJOR' }] }],
{ expectedReviewers: ['code-correctness-reviewer'] },
);
assert.deepEqual(result.missing_reviewers, ['code-correctness-reviewer'],
'missing_reviewers carries real names only');
assert.equal(result.unattributable_payloads, 1);
assert.ok(result.allow_blocked_by.some((x) => x.startsWith('unattributable-payload')));
assert.ok(!result.allow_blocked_by.some((x) => x.includes('unnamed reviewer')));
});
test('runContract — an anonymous invalid payload forbids ALLOW on its own, with no expectedReviewers', () => {
// The fail-closed floor must not depend on the caller passing an expected
// set: without this, dropping the name from a payload would restore ALLOW.
const result = runContract([{ findings: [{ file: 'x.mjs', line: 1, rule_key: 'NOPE', severity: 'MAJOR' }] }]);
assert.deepEqual(result.missing_reviewers, []);
assert.equal(result.unattributable_payloads, 1);
assert.notEqual(result.verdict, 'ALLOW');
});

View file

@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { resolveProfile, loadProfile } from '../../lib/profiles/resolver.mjs';
import { STATES } from '../../lib/util/autonomy-gate.mjs';
import { TREKRESEARCH_ALLOWED } from '../../lib/exporters/field-allowlist.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -700,6 +701,23 @@ test('producing commands emit file:// link in final report (operator-UX contract
}
});
test('package.json is marked private — the npm name `voyage` belongs to a third party', () => {
// D-census 2026-08-20/21: registry.npmjs.org/voyage answers 200, but that is
// NOT this package — it is "Advanced HTTP Routing System for Node.js"
// (NEURS/voyage, v0.0.1, 2022, maintainer kevin.martin). This manifest is test
// tooling for a Claude Code plugin distributed through the marketplace catalogue
// (`ref: vX.Y.Z`), never through npm. Without `private`, nothing in the manifest
// stops an accidental `npm publish` against a name someone else owns — a one-way
// action. Sibling non-package manifests (okr, repo-mailbox, repo-standard) all
// carry the flag; voyage was the exception. Operator decision (S93): option A.
const pkg = JSON.parse(read('package.json'));
assert.equal(
pkg.private,
true,
'package.json must declare "private": true — the npm name `voyage` is owned by a third party',
);
});
test('package.json still has no "npm run render" script (removed in v5.0.1)', () => {
const pkg = JSON.parse(read('package.json'));
assert.equal(
@ -961,6 +979,31 @@ test('S15: profile tables encode each built-in yaml phase_models exactly', () =>
}
});
// STRUCTURAL pin: the exporter allowlist and the authoring fixture must agree.
// `engine` was emitted, documented in prose, and still dropped at the export
// boundary because nothing tied the two together. Derives one side from the
// frozen Set, so it survives rewording of the fixture row.
test('S74: every TREKRESEARCH_ALLOWED name is declared in the jsonl-schemas fixture row', () => {
const row = read('tests/fixtures/jsonl-schemas.md')
.split('\n')
.find((l) => l.startsWith('| trekresearch-stats '));
assert.ok(row, 'jsonl-schemas.md is missing the `trekresearch-stats` row');
// Columns: '' | schema_id | fields | writer_path | line_ref | v4.1 additive | PII | ''
const cells = row.split('|').map((c) => c.trim());
// Both columns are required: profile/profile_source/parallel_agents live in
// the `v4.1 additive` column only, so checking `fields` alone fails at once.
const declared = new Set(
[cells[2], cells[5]].flatMap((c) => c.split(',').map((f) => f.trim())).filter(Boolean),
);
for (const name of TREKRESEARCH_ALLOWED) {
assert.ok(
declared.has(name),
`\`${name}\` is allowlisted in field-allowlist.mjs but absent from the fixture row's `
+ '`fields` + `v4.1 additive` columns — fix the SOURCE, not this pin',
);
}
});
// --- S34 (V30) — economy is self-declared experimental until the cross-tier
// Jaccard floor (0.55) is empirically calibrated (Step-17 calibration deferred
// to v4.2). The status must be visible in BOTH the profile data
@ -1091,6 +1134,289 @@ test('deep-research-engine: --engine is documented + consistent across surfaces'
);
});
test('deep-research-engine: the CC 2.1.218 operator-only ceiling is documented on every surface', () => {
// CC 2.1.218 changed /deep-research to "start only when invoked manually"; from
// there the Skill tool refuses a model invocation outright with
// `disable-model-invocation`. The flag stays (additive opt-in, always degrades to
// swarm), but no surface may keep promising a path Claude Code has removed, and
// none may leave `2.1.154+` standing alone as the engine's requirement.
const SURFACES = ['commands/trekresearch.md', 'docs/command-modes.md', 'README.md'];
for (const f of SURFACES) {
const body = read(f);
assert.ok(
body.includes('2.1.218'),
`${f} must document the CC 2.1.218 ceiling for the deep-research engine`,
);
assert.ok(
body.includes('disable-model-invocation'),
`${f} must name disable-model-invocation as the fallback reason from CC 2.1.218`,
);
}
// The one-line reference rows carry the whole truth on their own line — an
// operator reading the flag table never scrolls to the command prose.
const ROWS = [
['docs/command-modes.md', /^.*`--engine \{swarm\\\|deep-research\}`.*$/m],
['README.md', /^\|\s*\*\*Engine\*\*.*$/m],
];
for (const [f, re] of ROWS) {
const m = read(f).match(re);
assert.ok(m, `${f} must still carry the --engine reference row`);
assert.ok(
m[0].includes('2.1.218') && m[0].includes('disable-model-invocation'),
`${f} --engine row must state the real window (2.1.218 + disable-model-invocation)`,
);
}
// Both load-bearing regions of the command itself: the flag bullet and the
// pre-gate that decides whether the delegation is attempted at all.
const research = read('commands/trekresearch.md');
const bullet = research.match(/8\. `--engine <name>`[\s\S]*?Flags can be combined/);
assert.ok(bullet, 'trekresearch.md must still carry the --engine flag bullet');
assert.ok(
bullet[0].includes('2.1.218') && bullet[0].includes('disable-model-invocation'),
'trekresearch.md --engine bullet must state the real window, not just the 2.1.154 floor',
);
const pregate = research.match(/\*\*Coarse pre-gate[\s\S]*?authoritative guard\./);
assert.ok(pregate, 'trekresearch.md must still carry the deep-research pre-gate');
assert.ok(
pregate[0].includes('2.1.218'),
'the pre-gate must carry an upper ceiling at 2.1.218, not only the 2.1.154 floor',
);
});
// ── STORM bounded loop — env-vars documented across the four surfaces ──────
// Same cross-doc shape as the --engine pin above. An operator-facing switch
// documented on one surface is a switch most operators never find; and the
// three below decide cost, so they are the ones worth pinning.
const STORM_SURFACES = ['docs/command-modes.md', 'CLAUDE.md', 'README.md', 'docs/architecture.md'];
const STORM_ENV_VARS = ['VOYAGE_STORM_ENABLED', 'TREKRESEARCH_MAX_CONV_TURNS', 'VOYAGE_DISABLE_CAP_HOOK'];
for (const envVar of STORM_ENV_VARS) {
test(`STORM: ${envVar} is documented on all four reference surfaces`, () => {
for (const f of STORM_SURFACES) {
assert.ok(read(f).includes(envVar), `${f} must document ${envVar} (STORM bounded-loop env-vars)`);
}
});
}
test('STORM: VOYAGE_STORM_ENABLED is documented WITH its default-off contract', () => {
// Naming a switch without its default is how a default-on mechanism ships by
// accident. Default-off is also the decline branch: declining costs nothing.
for (const f of STORM_SURFACES) {
const t = read(f);
const i = t.indexOf('VOYAGE_STORM_ENABLED');
const window = t.slice(Math.max(0, i - 400), i + 400);
assert.match(
window,
/default-off|default off|opt-in/i,
`${f}: VOYAGE_STORM_ENABLED must be documented together with its default-off contract`,
);
}
});
// The flag gates BOTH STORM phases, not just the Phase 5 loop. A surface that
// scopes it to "the loop" tells an operator that unsetting it still leaves
// Phase 4.5 discovery running — which was true until the guard was fixed, and
// is the half-off state the decline branch cannot survive.
test('STORM: VOYAGE_STORM_ENABLED is documented as gating BOTH phases, not the loop alone', () => {
for (const f of STORM_SURFACES) {
const t = read(f);
const i = t.indexOf('VOYAGE_STORM_ENABLED');
const window = t.slice(Math.max(0, i - 400), i + 400);
assert.match(
window,
/4\.5|discovery|dimension discovery|both/i,
`${f}: VOYAGE_STORM_ENABLED must be documented as gating Phase 4.5 too, not only the Phase 5 loop`,
);
}
});
// The variable is empty in the Bash tool env, so "missing CLAUDE_PLUGIN_DATA
// denies" described a loop that could never spend turn 1. The root is resolved
// in code now; a doc that still promises the deny describes a mechanism the
// code does not have.
test('STORM: no surface claims a missing CLAUDE_PLUGIN_DATA denies — the root falls back', () => {
for (const f of ['docs/architecture.md', 'CLAUDE.md', 'README.md', 'docs/command-modes.md']) {
const t = read(f);
assert.doesNotMatch(
t,
/(missing|no|absent) `?CLAUDE_PLUGIN_DATA`?[^.\n]*(denies|fails closed)/i,
`${f}: CLAUDE_PLUGIN_DATA absence no longer denies — it resolves to ~/.claude/voyage`,
);
}
assert.match(
read('docs/architecture.md'),
/~\/\.claude\/voyage/,
'docs/architecture.md must name the fallback data root the cap and the hook share',
);
});
test('STORM: README research-dimension prose stays at the existing 38 ceiling', () => {
// Phase 4.5 discovers dimensions UNDER settings.json:16's maxDimensions: 8.
// Rewriting this prose upward would raise a ceiling the brief asked us to hold.
assert.ok(
read('README.md').includes('38 research dimensions'),
'README.md must keep the "38 research dimensions" prose — augmentation happens under the existing cap, it does not raise it',
);
});
// The Phase 4.5 amendment to the Independence hard rule crosses that rule
// deliberately: discovered dimensions are mined from Phase-4 output, which holds
// local-agent findings, so a discovered dimension can steer an external query.
// The crossing is defensible — bounded, disclosed, and resolving a tension the
// brief created itself. What was not defensible was naming query-privacy-gate.mjs
// as its compensating control: that gate inspects outbound query CONTENT for
// paths, repo identifiers and secret-shaped strings. It compensates the EGRESS
// risk the crossing creates. It cannot stop a local finding from shaping an
// external agent's question, so the bias risk was left with no named control
// while the text read as though it had one.
test('STORM: the Independence amendment does not name the privacy gate as the BIAS control', () => {
const t = read('commands/trekresearch.md');
const amendment = t.slice(t.indexOf('**Independence:**'), t.indexOf('**Graceful degradation:**'));
assert.ok(amendment.length > 100, 'the Independence hard rule and its amendment must still be present');
assert.ok(
!/compensating\s+control\s+is\s+`query-privacy-gate/.test(amendment),
'query-privacy-gate.mjs compensates egress, not bias — naming it as THE compensating control for the ' +
'Independence crossing claims a control the gate cannot provide',
);
// The bias risk must carry a control that actually bears on bias.
assert.match(
amendment,
/contrarian-researcher/,
'the amendment must name the control that does bear on bias — contrarian-researcher runs unconditionally ' +
'at effort: high, the only effort at which the crossing happens',
);
assert.match(
amendment,
/egress/i,
'the privacy gate should still be named, as the control for the egress risk the same crossing creates',
);
});
test('STORM: no banned Sonnet-swarm phrase introduced on any STORM surface', () => {
const BANNED = [
'Sonnet exploration',
'Sonnet runs the exploration',
'front-loads cheap Sonnet',
'exploration agents stay on Sonnet',
];
for (const f of STORM_SURFACES) {
const t = read(f);
for (const phrase of BANNED) {
assert.ok(!t.includes(phrase), `${f} must not claim "${phrase}" — sub-agents are opus-pinned`);
}
}
});
// ── STORM bounded loop — Phase 5 scope-marker wiring (S79) ────────────────
// hooks/scripts/pre-agent-cap.mjs enforces the loop bound ONLY while a scope
// marker exists for the calling session. Nothing wrote that marker, so the
// hook shipped correct-but-latent. These pins bind the two ends of one
// contract: the reader (the hook) and the writer (Phase 5 prose). Both sides
// are derived from the hook source where possible, so drift in EITHER
// direction fails — renaming the directory in the hook breaks the prose pin
// just as rewriting the prose does.
const CAP_HOOK_SRC = read('hooks/scripts/pre-agent-cap.mjs');
const RESEARCH_CMD = read('commands/trekresearch.md');
function phase5Section(text) {
const start = text.indexOf('## Phase 5');
const end = text.indexOf('## Phase 6', start);
assert.ok(start > 0 && end > start, 'commands/trekresearch.md must keep ## Phase 5 … ## Phase 6');
return text.slice(start, end);
}
test('STORM marker: the directory the hook reads is the directory Phase 5 writes', () => {
const m = CAP_HOOK_SRC.match(/const SCOPE_DIRNAME = '([^']+)'/);
assert.ok(m, 'pre-agent-cap.mjs must keep SCOPE_DIRNAME as a single-quoted literal');
const scopeDir = m[1];
assert.ok(
phase5Section(RESEARCH_CMD).includes(scopeDir),
`commands/trekresearch.md Phase 5 must write the scope marker under ${scopeDir}/ — a hook keyed on a directory nobody writes is latent, not enforcing`,
);
assert.ok(
phase5Section(RESEARCH_CMD).includes('CLAUDE_PLUGIN_DATA'),
'Phase 5 must root the marker at CLAUDE_PLUGIN_DATA — the same root the hook resolves',
);
});
test('STORM marker: both payload fields the hook reads are named in Phase 5', () => {
// The hook rejects a marker without runId, and treats an unparsable
// startedAt as stale. A writer that emits neither name produces a marker
// that is silently ignored.
const phase5 = phase5Section(RESEARCH_CMD);
for (const field of ['runId', 'startedAt']) {
assert.ok(
CAP_HOOK_SRC.includes(`marker.${field}`) || CAP_HOOK_SRC.includes(`marker?.${field}`),
`pre-agent-cap.mjs must still read marker.${field}`,
);
assert.ok(
phase5.includes(field),
`commands/trekresearch.md Phase 5 must write the ${field} field the hook reads`,
);
}
});
test('STORM marker: Phase 5 keys the marker filename by the harness session id', () => {
// Verified 2026-08-12: the session_id on a PreToolUse payload equals
// $CLAUDE_CODE_SESSION_ID for the same session. The marker filename is the
// scope key — key it by anything else and the hook never matches.
assert.ok(
phase5Section(RESEARCH_CMD).includes('CLAUDE_CODE_SESSION_ID'),
'Phase 5 must name CLAUDE_CODE_SESSION_ID as the marker filename key (== the hook payload session_id)',
);
});
test('STORM marker: only Phase 5 writes it — Phase 4.5 does not', () => {
// The hook contract says "a marker file only the Phase 5 loop writes".
// Phase 4.5 mines already-retrieved Phase-4 results and spends no loop
// turns; scoping enforcement there widens the window for nothing.
const m = CAP_HOOK_SRC.match(/const SCOPE_DIRNAME = '([^']+)'/);
const scopeDir = m[1];
const start = RESEARCH_CMD.indexOf('## Phase 4.5');
const end = RESEARCH_CMD.indexOf('## Phase 5', start);
assert.ok(start > 0 && end > start, 'commands/trekresearch.md must keep ## Phase 4.5 … ## Phase 5');
assert.ok(
!RESEARCH_CMD.slice(start, end).includes(scopeDir),
'Phase 4.5 must not write the scope marker — only the Phase 5 loop does',
);
});
test('STORM marker: every one of the three loop exits removes the marker', () => {
// A marker left behind after the cap is spent denies WebSearch/WebFetch/Task
// for the REST of the session — Phase 6 synthesis spawns agents. Cleanup on
// the exhausted exit is what keeps enforcement from becoming a session brick.
const phase5 = phase5Section(RESEARCH_CMD);
const exitsStart = phase5.indexOf('### Exits');
assert.ok(exitsStart > 0, 'Phase 5 must keep the ### Exits section');
const exits = phase5.slice(exitsStart, phase5.indexOf('###', exitsStart + 5));
for (const [n, label] of [['1.', 'converged'], ['2.', 'cap exhausted'], ['3.', 'operator stop']]) {
const from = exits.indexOf(`\n${n}`);
assert.ok(from > 0, `Exits must keep numbered entry ${n} (${label})`);
const nextMarker = exits.indexOf(`\n${Number(n[0]) + 1}.`, from);
const entry = exits.slice(from, nextMarker > from ? nextMarker : undefined);
assert.match(
entry,
/remove the (scope )?marker|rm -f/i,
`Exit ${n} (${label}) must remove the scope marker — a marker outliving the loop blocks the rest of the session`,
);
}
});
test('STORM marker: the crash path is documented as TTL auto-reset, not cleanup', () => {
// A crashed session runs no cleanup at all. The honest statement is that the
// hook's TTL covers it; claiming cleanup covers crashes would be false.
const phase5 = phase5Section(RESEARCH_CMD);
assert.match(
phase5,
/TTL/,
'Phase 5 must state that a crashed run is covered by the hook TTL, not by exit cleanup',
);
});
test('S18: HANDOVER-CONTRACTS documents the pre-2.2 zero-framing-enforcement hole', () => {
// The framing defense is producer-elective: a brief declaring ≤ 2.1 sidesteps
// it entirely. Handover 1 (PUBLIC CONTRACT) must disclose this and name the remedy.
@ -1324,6 +1650,48 @@ for (const cmd of ['trekresearch', 'trekplan', 'trekreview', 'trekexecute']) {
// fixed model in every session; if deterministic pinning is ever wanted again,
// do it consciously and update this pin's rationale.
// ── S85 — every spawn site must forbid the Agent tool's `name` parameter ────
// MEASURED 2026-08-17 (this repo, session c37b89f7), after akashic-intelligence
// lost a whole Phase 9 to it. Passing `name` does not rename a subagent — it
// changes its kind: the meta record flips from a real subagent
// (`spawnDepth: 1`) to `taskKind: "in_process_teammate"` (`spawnDepth: 0`).
// A teammate's final assistant text is NOT a return value; it reaches the
// parent only if the teammate itself calls SendMessage(to: "main").
// voyage's reviewers declare `tools: ["Read","Glob","Grep"]` — no SendMessage —
// so as teammates they are STRUCTURALLY incapable of returning, whatever the
// prompt says. Denominators: named 0/5 returned, named-with-explicit-
// SendMessage 1/1, unnamed 3/3 (plan-critic + scope-guardian both returned
// full findings + JSON block in ~110s). Full measurement:
// docs/agent-return-channel-defect.md.
//
// The derived set is asserted exactly, so the pin cannot go vacuous if a
// command stops spawning or a new spawning command is added.
const SPAWNING_COMMANDS = ['trekbrief', 'trekplan', 'trekresearch', 'trekreview'];
test('S85: the set of agent-spawning commands is exactly the four that carry the no-name rule', () => {
const spawns = listMd('commands')
.filter((f) => /^Launch\b/m.test(read(`commands/${f}`)))
.map((f) => f.replace(/\.md$/, ''))
.sort();
assert.deepEqual(spawns, [...SPAWNING_COMMANDS].sort(),
'a command started or stopped spawning agents — add/remove it from SPAWNING_COMMANDS and give it the no-name rule');
});
for (const cmd of SPAWNING_COMMANDS) {
test(`S85: commands/${cmd}.md forbids the Agent tool's name parameter at its spawn sites`, () => {
const text = read(`commands/${cmd}.md`);
assert.ok(
text.includes('in_process_teammate'),
`commands/${cmd}.md must name the mechanism (in_process_teammate) so the rule is not mistaken for style`,
);
assert.ok(
/never pass .*`name`/i.test(text),
`commands/${cmd}.md must state that the Agent tool's \`name\` parameter is never passed — a named agent completes its work but its result never reaches the orchestrator`,
);
});
}
test('v5.9: no commands/*.md frontmatter carries a model: key (session inheritance by omission)', () => {
const offenders = [];
for (const f of listMd('commands')) {

View file

@ -94,6 +94,81 @@ test('SC #11(b): commands/trekplan.md prose mentions phase_models + parallel_age
'trekplan.md prose must mention parallel_agents (additive stats field)');
});
// --- Step 9: the five STORM measurement fields -----------------------------
// Four numerics + one low-cardinality label. Without `effort` there is no axis
// to group high-vs-standard runs on, and the measurement gate cannot be
// computed at all.
const STORM_FIELDS = [
'effort',
'unique_sources',
'dimensions_baseline',
'conv_turns',
'empty_turns',
];
test('Step 9: a standard-run trekresearch record parses and survives applyFieldAllowlist', async () => {
const { applyFieldAllowlist } = await import('../../lib/exporters/field-allowlist.mjs');
// A standard run: loop never armed, so no discovered dimensions and no turns.
const raw = JSON.parse(JSON.stringify({
_schema_id: 'trekresearch',
ts: '2026-08-09T12:00:00.000Z',
slug: 'add-auth',
mode: 'default',
scope: 'both',
engine: 'swarm',
question: 'free prose that must never reach the exporter',
project_dir: '/Users/somebody/repos/x',
brief_path: '/Users/somebody/repos/x/brief.md',
dimensions: 4,
dimensions_baseline: 4,
conv_turns: 0,
empty_turns: 0,
unique_sources: 11,
effort: 'standard',
agents_local: 5,
agents_external: 4,
gemini_used: false,
confidence: 0.8,
contradictions: 1,
open_questions: 2,
profile: 'premium',
profile_source: 'default',
}));
assert.equal(raw.dimensions_baseline, raw.dimensions,
'a standard run discovers no dimensions — baseline must equal the final count');
const out = applyFieldAllowlist(raw, 'trekresearch');
for (const field of STORM_FIELDS) {
assert.ok(field in out, `${field} must survive the trekresearch allowlist`);
}
assert.equal(out.conv_turns, 0);
assert.equal(out.empty_turns, 0);
assert.equal(out.effort, 'standard');
// Deny-by-omission must still hold for the PII-ish fields.
for (const denied of ['question', 'project_dir', 'brief_path']) {
assert.equal(denied in out, false, `${denied} must NOT reach the exporter`);
}
});
test('Step 9: commands/trekresearch.md prose names all five measurement fields', () => {
const content = readFileSync(join(REPO_ROOT, 'commands', 'trekresearch.md'), 'utf-8');
for (const field of STORM_FIELDS) {
assert.ok(content.includes(field),
`trekresearch.md prose must name ${field} — an emitted-but-undocumented field is unauditable`);
}
});
test('Step 9: tests/fixtures/jsonl-schemas.md trekresearch row lists the five fields', () => {
const doc = readFileSync(join(REPO_ROOT, 'tests', 'fixtures', 'jsonl-schemas.md'), 'utf-8');
const row = doc.split('\n').find(l => l.startsWith('| trekresearch-stats '));
assert.ok(row, 'jsonl-schemas.md is missing the trekresearch-stats row');
for (const field of STORM_FIELDS) {
assert.ok(row.includes(field), `authoring reference must list ${field}`);
}
});
test('SC #11(b): commands/trekresearch.md prose mentions external_research_enabled', () => {
const content = readFileSync(join(REPO_ROOT, 'commands', 'trekresearch.md'), 'utf-8');
assert.match(content, /external_research_enabled/,

View file

@ -0,0 +1,597 @@
// tests/lib/research-loop-cap.test.mjs
// Cover lib/util/research-loop-cap.mjs: default-off, worst-case arithmetic,
// anti-dead-data (different caps → different denial points), statefulness
// (identical args → different answers once the budget is hit), env
// coercion, fail-closed on missing CLAUDE_PLUGIN_DATA, and the CLI shim.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync, execFile } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
allowTurn,
isStormEnabled,
resolveMaxConvTurns,
resolveLedgerPath,
resolveDataRoot,
readLedger,
checkDimensionCeiling,
MAX_CONV_TURNS,
MAX_TOTAL_DIMENSIONS,
} from '../../lib/util/research-loop-cap.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs');
function withTmpDataDir(fn) {
const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-'));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function runShim(args, env) {
try {
const out = execFileSync(process.execPath, [SHIM, ...args], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
return { code: 0, out };
} catch (e) {
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
}
}
/**
* Run the shim in a BUILT env rather than an inherited one. Spreading
* process.env means no test can express "CLAUDE_PLUGIN_DATA is absent" the
* exact condition that holds in every real run so the shim's behaviour there
* went uncovered while the module was denying turn 1.
*/
function runShimStripped(args, env = {}) {
try {
const out = execFileSync(process.execPath, [SHIM, ...args], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { PATH: process.env.PATH, ...env },
});
return { code: 0, out };
} catch (e) {
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
}
}
// ---- (a) default-off --------------------------------------------------------
test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir };
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, false);
assert.equal(r.reason, 'storm_disabled');
assert.equal(r.budget, 0);
});
});
test('allowTurn — VOYAGE_STORM_ENABLED=0 denies same as unset', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '0' };
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, false);
assert.equal(r.reason, 'storm_disabled');
});
});
test('allowTurn — enabled but effort !== high denies with budget 0', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'standard' }, { env });
assert.equal(r.ok, false);
assert.equal(r.reason, 'effort_not_high');
assert.equal(r.budget, 0);
});
});
// ---- (f) CLAUDE_PLUGIN_DATA unset => documented fallback root ----------------
//
// CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool's process env (measured in a
// live plugin-enabled session), and the Bash snippet in commands/trekresearch.md
// is the module's only caller. Denying on its absence therefore denied turn 1
// of every real run: the loop could never spend a turn, and the pre-registered
// measurement could not be run at all. The root is resolved in code, not
// demanded of the environment.
test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => {
withTmpDataDir((home) => {
const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA');
assert.equal(r.used, 1);
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
assert.ok(
existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')),
'the ledger must be written under the fallback root',
);
});
});
test('resolveDataRoot — CLAUDE_PLUGIN_DATA wins; empty or unset falls back to ~/.claude/voyage', () => {
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }), '/tmp/plugin-data');
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
assert.equal(resolveDataRoot({ HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
});
// ---- (c) worst-case arithmetic ----------------------------------------------
test('allowTurn — budget is max_conv_turns × max_total_dimensions (default 3×8=24)', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, true);
assert.equal(r.budget, 24);
assert.equal(r.used, 1);
});
});
test('allowTurn — grants exactly `budget` turns then denies the next one (default 24)', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
let last;
for (let i = 0; i < 24; i++) {
last = allowTurn({ runId: 'r-exhaust', dimension: `d${i % 8}`, effort: 'high' }, { env });
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
}
const denied = allowTurn({ runId: 'r-exhaust', dimension: 'd0', effort: 'high' }, { env });
assert.equal(denied.ok, false);
assert.equal(denied.reason, 'budget_exhausted');
assert.equal(denied.used, 24);
assert.equal(denied.budget, 24);
});
});
// ---- (c)/(anti-dead-data) — different caps → observably different denial points
test('allowTurn — TREKRESEARCH_MAX_CONV_TURNS=1 denies after 8 turns (1×8), not 24', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
let last;
for (let i = 0; i < 8; i++) {
last = allowTurn({ runId: 'r-narrow', dimension: `d${i}`, effort: 'high' }, { env });
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
}
const denied = allowTurn({ runId: 'r-narrow', dimension: 'd8', effort: 'high' }, { env });
assert.equal(denied.ok, false);
assert.equal(denied.budget, 8);
assert.notEqual(denied.budget, 24, 'a narrower cap must produce a different denial point than the default');
});
});
// ---- (d) stateful — identical args give different answers once exhausted ---
test('allowTurn — identical {runId, dimension, effort} args diverge once the budget is hit (proves statefulness)', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
const args = { runId: 'r-identical', dimension: 'same-dim', effort: 'high' };
const results = [];
for (let i = 0; i < 9; i++) results.push(allowTurn(args, { env }));
// First 8 (budget = 1*8) granted, 9th denied — same exact input object each time.
assert.deepEqual(results.slice(0, 8).map(r => r.ok), Array(8).fill(true));
assert.equal(results[8].ok, false);
assert.equal(results[8].reason, 'budget_exhausted');
});
});
// ---- (e) env coercion --------------------------------------------------------
test('resolveMaxConvTurns — NaN string falls back to default', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'abc' }), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — empty string falls back to default', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '' }), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — negative value falls back to default', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '-5' }), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — zero falls back to default (never unbounded)', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0' }), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — unset falls back to default', () => {
assert.equal(resolveMaxConvTurns({}), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — valid positive integer string is honored', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2);
});
// A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and
// 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop
// silently dead, while README.md and docs/architecture.md both promise a
// fallback of 3. The guard has to see the floored value, not the raw one.
test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS);
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS);
});
test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2);
});
test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => {
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS);
});
test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' };
const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded');
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
});
});
// ---- pure-core unit coverage --------------------------------------------------
test('isStormEnabled — only the literal string "1" enables', () => {
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: '1' }), true);
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: 'true' }), false);
assert.equal(isStormEnabled({}), false);
});
test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => {
const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl');
assert.equal(resolveLedgerPath({ HOME: '/home/x' }), expected);
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), expected);
});
test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => {
const p = resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' });
assert.equal(p, join('/tmp/plugin-data', 'trekresearch-loop-ledger.jsonl'));
});
test('allowTurn — missing runId or dimension denies with missing_args', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
const r1 = allowTurn({ dimension: 'd1', effort: 'high' }, { env });
assert.equal(r1.ok, false);
assert.equal(r1.reason, 'missing_args');
const r2 = allowTurn({ runId: 'r1', effort: 'high' }, { env });
assert.equal(r2.ok, false);
assert.equal(r2.reason, 'missing_args');
});
});
// ---- fail-closed on an unreadable ledger ------------------------------------
//
// The module's own header states that a budget control "must never silently
// grant unlimited turns just because the data dir is missing". The
// missing-DIRECTORY case already failed closed; the unreadable-FILE case
// returned 0 from countTurns and therefore re-granted the full budget on every
// call, unbounded — a fail-open in the same module that argues against one.
// A directory standing where the ledger file belongs reproduces it portably
// (EISDIR), with no chmod that a root test runner would ignore.
function withUnreadableLedger(fn) {
return withTmpDataDir((dir) => {
mkdirSync(join(dir, 'trekresearch-loop-ledger.jsonl'), { recursive: true });
return fn(dir);
});
}
test('readLedger — a missing ledger is 0 turns, not an error (turn 1 must be grantable)', () => {
withTmpDataDir((dir) => {
assert.equal(readLedger(join(dir, 'nope.jsonl'), 'r1').granted, 0);
});
});
test('readLedger — an unreadable ledger throws rather than reporting 0 turns spent', () => {
withUnreadableLedger((dir) => {
assert.throws(
() => readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r1'),
/unreadable/i,
);
});
});
test('readLedger — malformed lines are skipped, well-formed ones for the run still count', () => {
withTmpDataDir((dir) => {
const p = join(dir, 'trekresearch-loop-ledger.jsonl');
writeFileSync(p, [
JSON.stringify({ runId: 'r1', dimension: 'd1' }),
'{ not json',
'',
JSON.stringify({ runId: 'other', dimension: 'd1' }),
JSON.stringify({ runId: 'r1', dimension: 'd2' }),
].join('\n') + '\n');
assert.equal(readLedger(p, 'r1').granted, 2);
});
});
test('allowTurn — an unreadable ledger DENIES the turn instead of granting a fresh budget', () => {
withUnreadableLedger((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
const r = allowTurn({ runId: 'r-unreadable', dimension: 'd1', effort: 'high' }, { env });
assert.equal(r.ok, false, 'a budget control that cannot count must not grant');
assert.match(r.reason, /ledger-read-failed/);
});
});
// ---- the bound holds under concurrency --------------------------------------
//
// countTurns-then-appendFileSync had no atomic claim, while the comment above
// allowTurn asserted "Append-only: never read-modify-write" and named the
// concurrent case (Phase 4.5/5 may spawn several agents in one message) as the
// reason. The decision path WAS read-then-write: N callers that all observe
// used == budget-1 all grant, and the bound is exceeded by N-1.
//
// Two of these tests are deterministic. They do not race anything — they assert
// the invariant the claim introduces: a slot that is already claimed is spent,
// even when the ledger has not caught up yet, which is exactly the state a
// mid-append competitor leaves behind. The third runs real processes.
function seedLedger(dir, runId, n) {
writeFileSync(
join(dir, 'trekresearch-loop-ledger.jsonl'),
Array.from({ length: n }, (_, i) =>
JSON.stringify({ ts: new Date().toISOString(), runId, dimension: `d${i}`, effort: 'high' }),
).join('\n') + (n ? '\n' : ''),
);
}
function seedClaims(dir, runId, slots) {
const claimDir = join(dir, 'trekresearch-loop-claims');
mkdirSync(claimDir, { recursive: true });
for (const s of slots) writeFileSync(join(claimDir, `${runId}-${s}.claim`), '');
}
function runShimAsync(args, env) {
return new Promise((resolve) => {
execFile(
process.execPath,
[SHIM, ...args],
{ encoding: 'utf-8', env: { PATH: process.env.PATH, ...env } },
(err, stdout) => resolve({ code: err ? (err.code ?? 1) : 0, out: stdout ?? '' }),
);
});
}
test('allowTurn — a slot already claimed is spent even when the ledger has not caught up', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
// budget = 1 × 8 = 8. Ledger shows 7 turns; a competitor already claimed
// slot 8 and has not appended yet. Counting the ledger alone says "one slot
// free" and grants a 9th turn overall — the breach this closes.
seedLedger(dir, 'r-race', 7);
seedClaims(dir, 'r-race', [1, 2, 3, 4, 5, 6, 7, 8]);
const r = allowTurn({ runId: 'r-race', dimension: 'd0', effort: 'high' }, { env });
assert.equal(r.ok, false, 'every slot up to the budget is claimed, so there is nothing to grant');
assert.equal(r.reason, 'budget_exhausted');
assert.equal(r.budget, 8);
});
});
test('allowTurn — it takes the first FREE slot and claims it, so a repeat call cannot retake it', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
seedLedger(dir, 'r-slot', 7);
seedClaims(dir, 'r-slot', [1, 2, 3, 4, 5, 6, 7]);
const first = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env });
assert.equal(first.ok, true, 'slot 8 is free and must be grantable');
assert.equal(first.used, 8, 'used is the slot number, so it never double-counts a claimed slot');
assert.ok(
existsSync(join(dir, 'trekresearch-loop-claims', 'r-slot-8.claim')),
'the grant must leave the claim behind as the atomic record of the slot',
);
const second = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env });
assert.equal(second.ok, false);
assert.equal(second.reason, 'budget_exhausted');
});
});
test('allowTurn — a different runId is unaffected by another runs claims', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
seedClaims(dir, 'r-other', [1, 2, 3, 4, 5, 6, 7, 8]);
const r = allowTurn({ runId: 'r-mine', dimension: 'd0', effort: 'high' }, { env });
assert.equal(r.ok, true, 'claims are per-run; one run must not exhaust another');
});
});
test('allowTurn — parallel processes at the boundary cannot exceed the budget', async () => {
const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-par-'));
try {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
seedLedger(dir, 'r-par', 7); // budget 8 → exactly one turn left
const results = await Promise.all(
Array.from({ length: 6 }, (_, i) =>
runShimAsync(['--run-id', 'r-par', '--dimension', `p${i}`, '--effort', 'high'], env),
),
);
const winners = results.filter((r) => r.code === 0).length;
assert.equal(winners, 1, `exactly one of six concurrent callers may take the last slot, got ${winners}`);
// Granted turns, not raw lines: the five denied callers also record
// exhaustion tombstones, and a tombstone is not a turn.
const { granted } = readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r-par');
assert.equal(granted, 8, `granted turns must never exceed the budget, got ${granted}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// ---- the exhaustion tombstone -----------------------------------------------
//
// allowTurn appends BEFORE the turn runs, so during granted turn N the ledger
// holds N records. The hook denied at `used >= budget`, which blocked every
// tool call of the FINAL granted turn: the primitive granted B turns and the
// harness permitted B-1. Worse, an exhausted run then always terminated through
// an exit-2 tool denial instead of the graceful "cap exhausted" exit at
// commands/trekresearch.md, which is the only exit the prose teaches.
//
// Letting the hook allow at `used == budget` fixes the count but would leave it
// unable to catch the one case it exists for — the loop consults the gate, is
// denied, and issues the tool call anyway. So the denial itself becomes a
// record: a tombstone the hook can see.
test('allowTurn — denying for budget writes an exhaustion tombstone', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
for (let i = 0; i < 8; i++) {
assert.equal(allowTurn({ runId: 'r-tomb', dimension: `d${i}`, effort: 'high' }, { env }).ok, true);
}
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
assert.equal(readLedger(ledgerPath, 'r-tomb').exhausted, 0, 'no tombstone before the gate has denied anything');
const denied = allowTurn({ runId: 'r-tomb', dimension: 'd0', effort: 'high' }, { env });
assert.equal(denied.ok, false);
assert.equal(denied.reason, 'budget_exhausted');
const after = readLedger(ledgerPath, 'r-tomb');
assert.equal(after.exhausted, 1, 'the denial must leave a record the harness gate can read');
assert.equal(after.granted, 8, 'a tombstone is not a granted turn and must not count as one');
});
});
test('allowTurn — the tombstone is written once, not once per repeated denial', () => {
withTmpDataDir((dir) => {
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
for (let i = 0; i < 8; i++) allowTurn({ runId: 'r-once', dimension: `d${i}`, effort: 'high' }, { env });
for (let i = 0; i < 5; i++) allowTurn({ runId: 'r-once', dimension: 'd0', effort: 'high' }, { env });
const after = readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r-once');
assert.equal(after.exhausted, 1, 'a hammered gate must not grow the ledger without bound');
assert.equal(after.granted, 8);
});
});
test('readLedger — a tombstone is reported separately and never as a granted turn', () => {
withTmpDataDir((dir) => {
const p = join(dir, 'trekresearch-loop-ledger.jsonl');
writeFileSync(p, [
JSON.stringify({ runId: 'r1', dimension: 'd1', slot: 1 }),
JSON.stringify({ runId: 'r1', exhausted: true }),
JSON.stringify({ runId: 'other', exhausted: true }),
].join('\n') + '\n');
const l = readLedger(p, 'r1');
assert.equal(l.granted, 1);
assert.equal(l.exhausted, 1);
assert.equal(readLedger(p, 'other').granted, 0, 'another runs tombstone is not a granted turn either');
});
});
// ---- the discovery ceiling has a reader, not just a sentence ----------------
//
// The bounded-cost NFR asks for explicit ceilings on BOTH axes: max conversation
// turns and max discovered dimensions. The turn axis got MAX_CONV_TURNS, a
// ledger-backed reader and a PreToolUse enforcer. The discovery axis got a
// sentence in Phase 4.5 — "append candidates only while the whole list stays at
// or below maxDimensions: 8" — with no constant of its own, no reader, and no
// test that a run exceeding it is caught. That is the same shape as the
// brief_reviewer_iter_cap failure the operator decision warned about: a cap
// nothing reads.
//
// The ceiling is deliberately the SAME constant that sizes the turn budget. Two
// constants for one settings.json:16 value is how the two drift apart.
test('checkDimensionCeiling — a list at the ceiling is accepted', () => {
const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS }, (_, i) => `d${i}`));
assert.equal(r.ok, true);
assert.equal(r.count, MAX_TOTAL_DIMENSIONS);
assert.equal(r.ceiling, MAX_TOTAL_DIMENSIONS);
});
test('checkDimensionCeiling — one dimension over the ceiling is REJECTED', () => {
const r = checkDimensionCeiling(Array.from({ length: MAX_TOTAL_DIMENSIONS + 1 }, (_, i) => `d${i}`));
assert.equal(r.ok, false, 'a ceiling that accepts ceiling+1 is not a ceiling');
assert.equal(r.reason, 'ceiling_exceeded');
assert.equal(r.count, MAX_TOTAL_DIMENSIONS + 1);
});
test('checkDimensionCeiling — a plain count works as well as a list', () => {
assert.equal(checkDimensionCeiling(8).ok, true);
assert.equal(checkDimensionCeiling(9).ok, false);
assert.equal(checkDimensionCeiling('8').ok, true);
});
test('checkDimensionCeiling — an unreadable count is rejected, never waved through', () => {
for (const bad of ['abc', null, undefined, {}, -1, NaN]) {
const r = checkDimensionCeiling(bad);
assert.equal(r.ok, false, `${JSON.stringify(bad)} must not pass a cost ceiling`);
assert.equal(r.reason, 'unreadable_dimension_count');
}
});
test('checkDimensionCeiling — the ceiling is the same constant that sizes the turn budget', () => {
// Phase 4.5 and the Phase 5 budget must not be able to disagree about 8.
assert.equal(checkDimensionCeiling(0).ceiling, MAX_TOTAL_DIMENSIONS);
});
test('CLI shim — --check-dimensions exits 0 at the ceiling and 1 above it', () => {
const at = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS)], {});
assert.equal(at.code, 0, `at the ceiling must exit 0; got ${at.out}`);
assert.equal(JSON.parse(at.out.trim()).ok, true);
const over = runShim(['--check-dimensions', String(MAX_TOTAL_DIMENSIONS + 1)], {});
assert.equal(over.code, 1, 'a run over the ceiling must be rejected by exit code, not by prose');
const parsed = JSON.parse(over.out.trim());
assert.equal(parsed.ok, false);
assert.equal(parsed.reason, 'ceiling_exceeded');
});
test('CLI shim — --check-dimensions needs no runId, effort or STORM flag', () => {
// It is a cost ceiling on Phase 4.5, which never calls the budget gate, so it
// must not inherit the budget gate's preconditions.
const r = runShimStripped(['--check-dimensions', '3'], {});
assert.equal(r.code, 0, `should not require --run-id/--effort; got ${r.out}`);
});
// ---- (g) shim contract --------------------------------------------------------
test('CLI shim — grants and exits 0 when enabled + high effort + budget available', () => {
withTmpDataDir((dir) => {
const r = runShim(
['--run-id', 'shim-1', '--dimension', 'd1', '--effort', 'high'],
{ CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' },
);
assert.equal(r.code, 0);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, true);
});
});
test('CLI shim — denies and exits 1 when disabled', () => {
const r = runShim(['--run-id', 'shim-2', '--dimension', 'd1', '--effort', 'high'], { VOYAGE_STORM_ENABLED: '0' });
assert.equal(r.code, 1);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.equal(parsed.reason, 'storm_disabled');
});
test('CLI shim — grants with CLAUDE_PLUGIN_DATA STRIPPED from the environment', () => {
withTmpDataDir((home) => {
const r = runShimStripped(
['--run-id', 'shim-stripped', '--dimension', 'd1', '--effort', 'high'],
{ VOYAGE_STORM_ENABLED: '1', HOME: home },
);
assert.equal(r.code, 0, `shim must grant without CLAUDE_PLUGIN_DATA; got: ${r.out}`);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, true);
assert.ok(existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')));
});
});
test('CLI shim — missing required args exits 1 with usage reason', () => {
const r = runShim(['--run-id', 'shim-3']);
assert.equal(r.code, 1);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.ok, false);
assert.match(parsed.reason, /usage:/);
});

View file

@ -0,0 +1,324 @@
// tests/scripts/storm-measure.test.mjs
// Step 11 — the STORM adoption gate's deterministic accounting core.
//
// The gate decides ONE thing: does the bounded Phase 5 loop buy enough extra
// source/coverage breadth to be worth flipping VOYAGE_STORM_ENABLED on by
// default. Thresholds are pre-registered in docs/storm-measurement.md BEFORE
// any measurement run, so this file pins the arithmetic that turns a
// trekresearch-stats.jsonl into a verdict — not the verdict itself.
//
// Two properties carry the gate's honesty:
// - runs with empty_turns > 0 are EXCLUDED from the gain and COUNTED, so
// adoption is never decided on a broken denominator, and
// - a stats file with no `effort` field is a loud error, never a silently
// empty group that reads as "no gain".
//
// Pattern: tests/scripts/synthesis-measure.test.mjs (pure core, no fixtures on disk).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import {
median,
parseStats,
partitionEligible,
measure,
decideVerdict,
activationCheck,
ADOPT_THRESHOLD,
DECLINE_THRESHOLD,
} from '../../scripts/storm-measure.mjs';
// ---------------------------------------------------------------------------
// helpers — synthetic JSONL, one object per line, exactly as the orchestrator emits
// ---------------------------------------------------------------------------
function run({ effort, unique_sources, dimensions, dimensions_baseline, empty_turns = 0, conv_turns = 0 }) {
return JSON.stringify({
ts: '2026-08-12T00:00:00.000Z',
question: 'q',
mode: 'full',
scope: 'both',
engine: 'swarm',
effort,
unique_sources,
dimensions,
dimensions_baseline,
conv_turns,
empty_turns,
});
}
function jsonl(...lines) {
return lines.join('\n') + '\n';
}
// A control arm at 10 sources / 5 dimensions, and a treatment arm at 13
// sources / 8 dimensions: +30.0% sources, +60.0% dimensions.
const STANDARD = [
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
];
const HIGH = [
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
];
// ---------------------------------------------------------------------------
// median
// ---------------------------------------------------------------------------
test('median: odd, even, single', () => {
assert.equal(median([3, 1, 2]), 2);
assert.equal(median([1, 2, 3, 4]), 2.5);
assert.equal(median([7]), 7);
});
test('median: empty list is null, never 0 — 0 would read as a real measurement', () => {
assert.equal(median([]), null);
});
test('median does not mutate its input', () => {
const xs = [3, 1, 2];
median(xs);
assert.deepEqual(xs, [3, 1, 2]);
});
// ---------------------------------------------------------------------------
// parseStats — the loud-error requirement
// ---------------------------------------------------------------------------
test('parseStats: a file with no effort field throws, it does not yield empty groups', () => {
const noEffort = jsonl(
JSON.stringify({ ts: 'x', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
JSON.stringify({ ts: 'y', unique_sources: 11, dimensions: 5, dimensions_baseline: 5 }),
);
assert.throws(() => parseStats(noEffort), /effort/i);
});
test('parseStats: skips blank and malformed lines but keeps the good ones', () => {
const text = jsonl(STANDARD[0], '', 'not json', HIGH[0]);
const { records, malformed } = parseStats(text);
assert.equal(records.length, 2);
assert.equal(malformed, 1);
});
test('parseStats: an empty file throws rather than reporting a zero-gain verdict', () => {
assert.throws(() => parseStats(''), /no records/i);
});
// ---------------------------------------------------------------------------
// exclusion of broken runs
// ---------------------------------------------------------------------------
test('partitionEligible: runs with empty_turns > 0 are excluded and counted', () => {
const { records } = parseStats(jsonl(
...HIGH,
run({ effort: 'high', unique_sources: 99, dimensions: 8, dimensions_baseline: 5, empty_turns: 2 }),
));
const { eligible, excluded } = partitionEligible(records);
assert.equal(eligible.length, 3);
assert.equal(excluded, 1);
});
test('partitionEligible: empty_turns === 0 is eligible; a missing field counts as 0', () => {
const { records } = parseStats(jsonl(
STANDARD[0],
JSON.stringify({ ts: 'z', effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
));
const { eligible, excluded } = partitionEligible(records);
assert.equal(eligible.length, 2);
assert.equal(excluded, 0);
});
// A malformed empty_turns used to land in the ELIGIBLE arm: Number('many') is
// NaN, NaN fails the isFinite test, and the `else` branch pushed it in. The
// exclusion is one of the two properties carrying this gate's honesty, so a
// garbage value silently re-entering the denominator defeats it — and it does so
// in the direction that flatters adoption, since the run that broke is the run
// whose numbers are least trustworthy.
test('partitionEligible: a non-numeric empty_turns is EXCLUDED, never silently eligible', () => {
const { records } = parseStats(jsonl(
STANDARD[0],
JSON.stringify({
ts: 'm', effort: 'high', unique_sources: 999,
dimensions: 8, dimensions_baseline: 5, empty_turns: 'many',
}),
));
const { eligible, excluded } = partitionEligible(records);
assert.equal(excluded, 1, 'a value that cannot be read as a turn count is not evidence of zero empty turns');
assert.equal(eligible.length, 1);
});
test('partitionEligible: an unparsable empty_turns cannot move the median either', () => {
const m = measure(parseStats(jsonl(
...STANDARD,
...HIGH,
JSON.stringify({
ts: 'm', effort: 'high', unique_sources: 900,
dimensions: 8, dimensions_baseline: 5, empty_turns: {},
}),
)).records);
assert.equal(m.excluded, 1);
assert.equal(m.sources.treatment, 13, 'the 900-source malformed run must not reach the median');
});
// ---------------------------------------------------------------------------
// measure — the known-answer test
// ---------------------------------------------------------------------------
test('measure: median gains on synthetic runs give the known answer', () => {
const m = measure(parseStats(jsonl(...STANDARD, ...HIGH)).records);
assert.equal(m.control.n, 3);
assert.equal(m.treatment.n, 3);
assert.equal(m.sources.control, 10);
assert.equal(m.sources.treatment, 13);
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9, `sources gain ${m.sources.gain}`);
// (8 - 5) / 5 = 0.60 within each treatment run.
assert.ok(Math.abs(m.dimensions.gain - 0.60) < 1e-9, `dimensions gain ${m.dimensions.gain}`);
});
test('measure: an excluded run cannot move the median', () => {
const withBroken = jsonl(
...STANDARD,
...HIGH,
run({ effort: 'high', unique_sources: 900, dimensions: 8, dimensions_baseline: 5, empty_turns: 1 }),
);
const m = measure(parseStats(withBroken).records);
assert.equal(m.excluded, 1);
assert.equal(m.sources.treatment, 13, 'the 900-source broken run must not reach the median');
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9);
});
test('measure: reports null gain (not 0) when an arm has no eligible runs', () => {
const m = measure(parseStats(jsonl(...HIGH)).records);
assert.equal(m.control.n, 0);
assert.equal(m.sources.gain, null);
assert.equal(m.verdict, 'insufficient-data');
});
// ---------------------------------------------------------------------------
// verdict mapping — both sides of both thresholds
// ---------------------------------------------------------------------------
test('decideVerdict: at and above the adopt threshold', () => {
assert.equal(decideVerdict(ADOPT_THRESHOLD, ADOPT_THRESHOLD), 'adopt');
assert.equal(decideVerdict(0.55, 0.44), 'adopt');
});
test('decideVerdict: both metrics between the bars is inconclusive, not adopt', () => {
assert.equal(decideVerdict(ADOPT_THRESHOLD - 0.0001, 0.2), 'inconclusive');
});
test('decideVerdict: below the decline threshold on both metrics declines', () => {
assert.equal(decideVerdict(0.14, 0.05), 'decline');
assert.equal(decideVerdict(DECLINE_THRESHOLD - 0.0001, 0), 'decline');
});
test('decideVerdict: at the decline threshold is inconclusive, not decline', () => {
assert.equal(decideVerdict(DECLINE_THRESHOLD, DECLINE_THRESHOLD), 'inconclusive');
});
// The brief pre-registers "median forbedring >= 30 % på (a) eller (b) → adopt.
// < 15 % → decline." — OR on both sides, with adopt evaluated first.
test('decideVerdict: adopt needs EITHER metric — one strong metric carries a weak one', () => {
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
assert.equal(decideVerdict(0.10, 0.90), 'adopt');
});
test('decideVerdict: either metric below the decline bar declines', () => {
assert.equal(decideVerdict(0.02, 0.20), 'decline');
assert.equal(decideVerdict(0.20, 0.02), 'decline');
});
test('decideVerdict: adopt outranks decline when one metric clears and the other is under the decline bar', () => {
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
});
test('decideVerdict: a null gain is insufficient data, never a decline', () => {
assert.equal(decideVerdict(null, 0.4), 'insufficient-data');
assert.equal(decideVerdict(0.4, null), 'insufficient-data');
});
// ---------------------------------------------------------------------------
// activationCheck — BOTH halves of the SC, not just the count
//
// The SC requires that an `effort: high` run discovers at least one dimension
// AND that the dimension list in the output brief is a TRUE SUPERSET of the
// interview-derived ones. activationCheck only computed dimensions -
// dimensions_baseline >= 1, so a run that replaced two interview dimensions with
// three discovered ones passed the check while violating the SC's second half.
// Supersetness was asserted only by Phase 4.5's prose contract that discovery
// appends; nothing read it.
//
// The stats record carries counts, not names — names are free prose and
// field-allowlist.mjs denies prose by omission — so the run attests membership
// with a low-cardinality boolean instead, and the gate refuses to call
// activation OK without it.
// ---------------------------------------------------------------------------
function highRun(over = {}) {
return JSON.stringify({
ts: '2026-08-12T00:00:00.000Z',
effort: 'high',
unique_sources: 13,
dimensions: 8,
dimensions_baseline: 5,
conv_turns: 3,
empty_turns: 0,
dimensions_baseline_preserved: true,
...over,
});
}
test('activationCheck: discovery plus a preserved baseline is activation', () => {
const r = activationCheck(parseStats(jsonl(highRun())).records);
assert.equal(r.ok, true);
assert.equal(r.discovered_dimensions, 3);
assert.equal(r.dimensions_baseline_preserved, true);
});
test('activationCheck: a REPLACED baseline is not activation, however many were discovered', () => {
const r = activationCheck(parseStats(jsonl(
highRun({ dimensions: 8, dimensions_baseline: 5, dimensions_baseline_preserved: false }),
)).records);
assert.equal(r.ok, false, 'a count delta of +3 says nothing about which dimensions survived');
assert.match(r.reason, /superset|baseline/i);
});
test('activationCheck: a run that does not attest baseline membership cannot pass', () => {
const rec = JSON.parse(highRun());
delete rec.dimensions_baseline_preserved;
const r = activationCheck(parseStats(jsonl(JSON.stringify(rec))).records);
assert.equal(r.ok, false, 'an absent attestation is not an attestation');
assert.match(r.reason, /dimensions_baseline_preserved/);
});
test('activationCheck: no discovery is still not activation even with the baseline preserved', () => {
const r = activationCheck(parseStats(jsonl(
highRun({ dimensions: 5, dimensions_baseline: 5 }),
)).records);
assert.equal(r.ok, false);
assert.equal(r.discovered_dimensions, 0);
});
test('activationCheck: no effort:high run at all is reported as such', () => {
const r = activationCheck(parseStats(jsonl(...STANDARD)).records);
assert.equal(r.ok, false);
assert.match(r.reason, /high/);
});
// ---------------------------------------------------------------------------
// pre-registration — the thresholds are the doc's, and the doc is committed first
// ---------------------------------------------------------------------------
test('thresholds are the pre-registered 30% / 15%', () => {
assert.equal(ADOPT_THRESHOLD, 0.30);
assert.equal(DECLINE_THRESHOLD, 0.15);
});
// The human-readable summary is the only form of the rule most readers will
// ever see. It said "on BOTH" on both sides while decideVerdict evaluated OR —
// so the report described a stricter gate than the one that produced the verdict
// printed one line below it.
test('the printed threshold line states the OR rule that decideVerdict actually applies', () => {
const src = readFileSync(new URL('../../scripts/storm-measure.mjs', import.meta.url), 'utf-8');
const line = src.split('\n').find((l) => l.includes('thresholds: adopt'));
assert.ok(line, 'the summary must still print its threshold rule');
assert.doesNotMatch(line, /on BOTH/, 'the rule is OR on both sides — printing BOTH misstates the gate');
assert.match(line, /EITHER/);
});

View file

@ -0,0 +1,174 @@
// tests/validators/query-privacy-gate.test.mjs
// Cover lib/validators/query-privacy-gate.mjs: two-sided code table
// (absolute path / repo-internal identifier / secret-shaped token), a
// benign query passing untouched, the opt-in env var reaching only the
// warn tier (never the hard-block tier), strict/soft severity, and the
// CLI shim.
//
// Secret-shaped fixtures are built via string concatenation/repeat, never
// as literal tokens — the repo's own secrets pre-edit hook (correctly)
// treats a literal AKIA/sk-/ghp_ string as a real credential.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
validateOutboundQuery,
ABSOLUTE_PATH_PATTERNS,
REPO_IDENTIFIER_PATTERNS,
SECRET_SHAPED_PATTERNS,
} from '../../lib/validators/query-privacy-gate.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const SHIM = join(HERE, '..', '..', 'lib', 'validators', 'query-privacy-gate.mjs');
const FAKE_OPENAI_KEY = 'sk-' + 'a'.repeat(24);
const FAKE_AWS_KEY = 'AKIA' + 'Q'.repeat(16);
const FAKE_GITHUB_PAT = 'ghp_' + 'b'.repeat(36);
// Real-world formats whose token body contains hyphens/underscores. A run of
// plain alphanumerics is broken by those separators, so a naive
// `[A-Za-z0-9]{20,}` run-length pattern lets them through — the gap this file
// pins. Anthropic Console keys are `sk-ant-api03-` + ~95 base64url chars;
// GitHub fine-grained PATs are `github_pat_<22>_<59>`; `gho_` is the OAuth
// sibling of the classic `ghp_` token.
const FAKE_ANTHROPIC_KEY = 'sk-' + 'ant-' + 'api03-' + 'A1b2_-x9'.repeat(12);
const FAKE_GITHUB_FINE_GRAINED = 'github' + '_pat_' + 'A'.repeat(22) + '_' + 'c'.repeat(59);
const FAKE_GITHUB_OAUTH = 'gho' + '_' + 'd'.repeat(36);
function runShim(args) {
try {
const out = execFileSync(process.execPath, [SHIM, ...args], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return { code: 0, out };
} catch (e) {
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
}
}
// ---- two-sided code table ----------------------------------------------------
const TABLE = [
{ label: 'absolute path (/Users/...)', text: 'find every caller of foo in /Users/ktg/repos/voyage/lib/util/foo.mjs', code: 'PRIVACY_ABSOLUTE_PATH' },
{ label: 'absolute path (/home/...)', text: 'trace /home/alice/projects/app/src/index.js for imports', code: 'PRIVACY_ABSOLUTE_PATH' },
{ label: 'repo-internal identifier (forgejo host)', text: 'what changed recently on git.fromaitochitta.com/open/voyage', code: 'PRIVACY_REPO_IDENTIFIER' },
{ label: 'repo-internal identifier (repo name)', text: 'search issues for ktg-plugin-marketplace regressions', code: 'PRIVACY_REPO_IDENTIFIER' },
{ label: 'secret-shaped (OpenAI/Anthropic-style key)', text: `auth failing with key ${FAKE_OPENAI_KEY}`, code: 'PRIVACY_SECRET_SHAPED' },
{ label: 'secret-shaped (AWS access key)', text: `rotate ${FAKE_AWS_KEY} now`, code: 'PRIVACY_SECRET_SHAPED' },
{ label: 'secret-shaped (GitHub PAT)', text: `token leaked: ${FAKE_GITHUB_PAT}`, code: 'PRIVACY_SECRET_SHAPED' },
{ label: 'secret-shaped (Anthropic Console key)', text: `why does ${FAKE_ANTHROPIC_KEY} 401`, code: 'PRIVACY_SECRET_SHAPED' },
{ label: 'secret-shaped (GitHub fine-grained PAT)', text: `pushed with ${FAKE_GITHUB_FINE_GRAINED}`, code: 'PRIVACY_SECRET_SHAPED' },
{ label: 'secret-shaped (GitHub OAuth token)', text: `oauth flow returned ${FAKE_GITHUB_OAUTH}`, code: 'PRIVACY_SECRET_SHAPED' },
];
for (const { label, text, code } of TABLE) {
test(`validateOutboundQuery — ${label}${code} (strict, error)`, () => {
const r = validateOutboundQuery(text, { strict: true, env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === code), JSON.stringify(r.errors));
});
}
test('validateOutboundQuery — benign generic query passes untouched', () => {
const r = validateOutboundQuery('What are the tradeoffs between optimistic and pessimistic locking?', { env: {} });
assert.equal(r.valid, true);
assert.deepEqual(r.errors, []);
assert.deepEqual(r.warnings, []);
});
// ---- strict vs soft (warn tier only) -----------------------------------------
test('validateOutboundQuery — soft mode downgrades warn-tier findings to warnings, stays valid', () => {
const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { strict: false, env: {} });
assert.equal(r.valid, true);
assert.equal(r.errors.length, 0);
assert.ok(r.warnings.find(w => w.code === 'PRIVACY_ABSOLUTE_PATH'));
});
test('validateOutboundQuery — soft mode does NOT downgrade the hard-block tier', () => {
const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { strict: false, env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
});
// ---- opt-in env var reaches only the warn tier -------------------------------
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 bypasses the warn tier entirely', () => {
const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
assert.equal(r.valid, true);
assert.equal(r.errors.length, 0);
assert.equal(r.warnings.length, 0);
});
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 does NOT open the hard-block tier', () => {
const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'), 'opt-in must never unlock the hard-block tier');
});
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 combined with a secret still denies', () => {
const r = validateOutboundQuery(`/Users/ktg/x leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
assert.equal(r.valid, false);
assert.equal(r.errors.length, 1);
assert.equal(r.errors[0].code, 'PRIVACY_SECRET_SHAPED');
});
// The hard-block tier is the one thing no operator flag unlocks, so a format
// it misses is a secret leaving the machine with no second gate behind it.
// Pin every real-world format against BOTH escape hatches at once.
for (const [label, token] of [
['Anthropic Console key', FAKE_ANTHROPIC_KEY],
['GitHub fine-grained PAT', FAKE_GITHUB_FINE_GRAINED],
['GitHub OAuth token', FAKE_GITHUB_OAUTH],
]) {
test(`validateOutboundQuery — ${label} stays blocked under --soft and the opt-in`, () => {
const r = validateOutboundQuery(`leaked ${token}`, {
strict: false,
env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' },
});
assert.equal(r.valid, false, 'hard-block tier must never be overridable');
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
});
}
// ---- empty input --------------------------------------------------------------
test('validateOutboundQuery — empty string is invalid', () => {
const r = validateOutboundQuery('', { env: {} });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PRIVACY_EMPTY_QUERY'));
});
// ---- pattern set is frozen ----------------------------------------------------
test('pattern sets are Object.frozen', () => {
assert.equal(Object.isFrozen(ABSOLUTE_PATH_PATTERNS), true);
assert.equal(Object.isFrozen(REPO_IDENTIFIER_PATTERNS), true);
assert.equal(Object.isFrozen(SECRET_SHAPED_PATTERNS), true);
});
// ---- CLI shim -----------------------------------------------------------------
test('CLI shim — benign query exits 0 with valid:true', () => {
const r = runShim(['harmless generic question about caching strategies']);
assert.equal(r.code, 0);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.valid, true);
});
test('CLI shim — secret-shaped query exits 1 even with --soft', () => {
const r = runShim(['--soft', `leaked ${FAKE_OPENAI_KEY}`]);
assert.equal(r.code, 1);
const parsed = JSON.parse(r.out.trim());
assert.equal(parsed.valid, false);
assert.ok(parsed.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
});
test('CLI shim — missing query argument exits 2 (usage error)', () => {
const r = runShim([]);
assert.equal(r.code, 2);
});