Commit graph

175 commits

Author SHA1 Message Date
5f9faeed4a
fix: close the six defects an independent review found in the trial fixes
Independent read-only review of c56f852..2d29966 (2026-10-08), each defect
reproduced by the reviewer; each new test fails on that code.

- test-lock green ran ANY allowlisted command: `green --cmd "git status"`
  marked a red step green and the stop guard let it stop. The lock now
  stores the step's test command (`lock --cmd`); `green` runs only that
  one (a different --cmd, or a lock without one, exits 2). trekexecute D1
  calls `green` with no command, which also removes the quoting hazard.
- plan-gate --accept-open worked at round 1 and passed the gate; it now
  refuses while rounds are left.
- findings-schema --repo-root swallowed a following flag, or took a root
  that does not exist, and silently skipped the line check: exit 2.
- brief-review-stamp hashed only the body, so a changed `framing` or
  `research_topics` stayed "current"; it now covers the reviewed
  frontmatter too (only the post-review stamps are left out).
- red-proof red_kind read an assertion message naming SyntaxError as a
  load error; the signatures are now specific.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:17:45 +02:00
2d29966f51
fix(plan): an open MAJOR stops the plan; after the last round the operator decides
voyage-lekerepo trial (2026-10-06) point 6, operator decision 2026-10-08
(«samme skala, og du spør»): the critics rated a non-discriminating test
minor in round 3, there was no way to fix it after the last round, it was
built, and /trekreview called the same weakness MAJOR (MISSING_TEST).

- plan-critic, scope-guardian: rate on the review catalogue's tiers; a test
  that cannot fail on a wrong implementation is major, never minor.
- plan-gate: an open MAJOR stops (PLAN_GATE_OPEN_MAJOR: revise and run the
  next round; PLAN_GATE_OPEN_MAJOR_FINAL after the last round: ask the
  operator). --accept-open records "build as is" after their yes, bound to
  the round and the plan hash; never for a blocker.
- trekplan Phase 9: REVISE after the last round asks fix now (one extra
  round) or build as is, instead of carrying the majors silently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:07:00 +02:00
1b5218e8f4
fix(plan): every exploration agent runs; the brief is reviewed again only when it changed
voyage-lekerepo trial (2026-10-06) point 14, operator decision 2026-10-08
(«alle agenter, ny briefsjekk bare ved endring»):

- trekplan: the "Adaptive" hard rule (a 10-file project does not need 7
  agents) contradicted Phase 5 ("All agents run for all codebase sizes");
  it is gone, in line with quality before cost.
- brief-review-stamp.mjs: /trekbrief stamps the brief body when its
  brief-reviewer gate passes; /trekplan Phase 4b skips its own review only
  while the stamp is current. Stale (edited) or absent (another producer,
  or a gate that did not pass) reviews as before. Additive optional
  frontmatter fields, documented in Handover 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:01:44 +02:00
393b8e5a0a
fix(guards): a step whose test is seen green may stop while it waits for its controller
voyage-lekerepo trial (2026-10-06) point 7, operator decision 2026-10-08
(«grønt merke på låsen»): the stop guard blocked Stop three times while the
executor waited for the controller, with the step's test already green.

- test-lock.mjs green --cmd "<Verify>" runs the step's test command (same
  allowlist + denylist as done-line) and records green_at only on exit 0;
  pending clears it; a new lock starts without it.
- stop-test-lock lets a green-marked lock stop; the write guard still
  refuses the locked test files.
- trekexecute: D1 marks green after a PASS; each fix round marks pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:59:14 +02:00
81dc5af680
fix: the trial's mechanical faults -- approval label, import path, stats, annotate path, red kind
voyage-lekerepo end-to-end trial (2026-10-06), report points 8, 10, 12, 15, 16:

- approval-log: «Approve (Recommended)» / «Godkjenn (anbefalt)» is the
  approval it labels; the suffix never turns a hollow answer into a yes.
- trekexecute Phase 8: the state-file block imports atomic-write through
  ${CLAUDE_PLUGIN_ROOT}; a relative import only ran with cwd = plugin root
  (the test runs the block from another directory).
- trekreview, trekresearch, trekcontinue: stats go through stats-append like
  the other commands, and a failed write is reported, never skipped
  silently (closes the open STATE finding for all three). The schema
  fixture's stale line refs for these and trekplan are re-measured.
- annotate prints an absolute path, so file://{$ANNOT_HTML} is a live link.
- red-proof: `red_kind` = load-error | test-failure on a proven red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:53:15 +02:00
609eb35015
fix(review): the review keeps the findings that are right
voyage-lekerepo end-to-end trial (2026-10-06), report points 1, 5, 11:

- engine-findings: recommended_action is "Fix <file:line> so this no longer
  happens: <failure scenario>", not "Resolve: <title>" (8 of 8 engine
  findings were set aside as actionability:empty); the coordinator never
  sets an engine finding aside for Actionability.
- findings-schema --repo-root: a cited line past the end of an existing
  file is FINDING_LINE_OUT_OF_RANGE, so Phase 5's bounded re-ask fixes it
  before the coordinator sees it. The reviewers' instruction said "the
  actual line number from the diff"; it now says the file's own line,
  never the patch's.
- fill-finding-ids.mjs: the coordinator (no shell) writes @@ID_<n>@@ and
  trekreview Phase 7 computes them over (file, line, rule_key); an
  unresolved placeholder or a wrong 40-hex id fails. The 4-field id with
  `title` the docs named is gone; the code always used three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:48:13 +02:00
c56f852ab6
fix(validate): a check that can never run is caught when the brief or plan is written
voyage-lekerepo end-to-end trial (2026-10-06), report points 2, 3, 4, 9:
11 of 13 brief criteria were NOT RUN at review with no warning in /trekbrief;
a plan `git grep` criterion passed validator, gate and three critic rounds,
then felled the run in Phase 7; the End-to-end field and Phase 7's text
search never met; a prose Verify left the test command undefined.

- criteria-runner: runnableCheck() screens a declared check without running
  it (same allowlist; an npm run script is not looked up at write time);
  /bin/bash tests/<script> allowed; the plan report carries end_to_end,
  verified when a passed criterion runs a step's End-to-end command (the
  older text form still counts).
- plan-validator 1.8: PLAN_VERIFICATION_NOT_RUNNABLE,
  PLAN_STEP_VERIFY_NOT_RUNNABLE, PLAN_END_TO_END_NOT_RUNNABLE,
  PLAN_END_TO_END_NOT_VERIFIED (the gate inherits them).
- brief-validator: BRIEF_SC_NOT_RUNNABLE warning; /trekbrief offers a
  tests/ script; brief-reviewer no longer proposes unrunnable commands.
- trekexecute: Phase 7 copies end_to_end from the runner; Verify's test
  command is its first backticked span.

Measured on the trial's own brief and plan: 11 warnings (the 11 NOT RUN),
and the plan stops on V5 git grep and the unlisted End-to-end command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:42:33 +02:00
ca6ee8779a
merge: wave 1 order 1 (executor) into main
CLAUDE.md command table: each row from its owner (trekplan from order 3,
trekexecute from order 1, trekreview from order 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:42:47 +02:00
47864f91b4
merge: wave 1 order 4 (guards) into main
CLAUDE.md: the review paragraph (order 5) and the guards paragraph
(order 4) are both kept (planned union line).

The launch gate went red on the merged tree: post-approval-log reads
the session's permission_mode, and its regression test feeds that value.
Both were untracked when the branch ran its suite, so the gate never saw
them there. Operator decision 2026-10-06: a counted exemption in a new
class (mode-read: 3 + 1 hits), and the binding-rule sentence in CLAUDE.md
names it. An extra hit in either file turns the gate red again (checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:41:21 +02:00
21c28df3bc
merge: wave 1 order 2 (intent) into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:37:12 +02:00
885d515c3b
merge: wave 1 order 3 (plan and design) into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:36:31 +02:00
44dcc779a9
fix(execute): the step loop survives an adversarial review
An independent review of abc3083 found 15 defects, the two worst verified
in scratch repos. Each fix started with a test that failed.

- Proof of red could be "proven" by the environment: a start point
  unpacked with git archive had no node_modules and no .git, so any test
  failed there. The start point is now a git clone --shared checked out at
  start_sha, gitignored top-level entries are linked in, and a control run
  (start point + the change) must pass, or the result is not-run.
- The Test first file was never committed (the template lists it apart
  from Files:) and leaked into the next step's diff. The step's scope is
  now Files: + test files, staged at the checkpoint.
- New files outside Files: were invisible to every check. Every untracked
  file is registered with --intent-to-add; a revert unregisters them.
- The floor guard paired renames (foo.test.js -> .bak read as clean) and
  missed skipIf and node's t.skip/t.todo; prose files no longer count as
  stubs.
- Phase 7.5 audited from the last step's start_sha; it now uses the run's
  session_start_sha.
- The done line is written after the checkpoint and checked with --commit.
- fix_rounds never passes 5; attempts are never reset; re-control keeps
  the spec/quality verdicts and earlier floor-guard rulings; the closed
  stop list maps every existing stop; failed vs stopped made consistent;
  the controller's effort is said, not assumed.

Tests: 1470 (1468 pass, 0 fail, 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:17:39 +02:00
f5ca220bc0
feat(plan): plan 1.8, the plan gate, the design part and the plan-side checks
Wave 1, order 3 (P3 plan mode as the start, P2 design as its own part, P9
plan side). Contracts K3-K6 for the later merges.

- plan_version 1.8 (1.7 plans stay valid): Test first on every step, one
  End-to-end ("run the thing") step, optional Interface in / Interface out /
  Review focus fields, a ceremony class in the header (absent =
  architectural), and for architectural plans a ## Design part: module map
  without cycles, architecture rules that name their enforcer and the step
  that proves them, a requirements check asked as keep / change.
- K3 lib/validators/plan-gate.mjs: the critic verdict is recorded INSIDE the
  plan (Claude Code's plan approval hands a hook only the plan file's text),
  bound to the hash taken before the reviewers read it; 0 go / 1 stop / 2
  could not run; at most 3 critic rounds, then stop and ask.
- K5 lib/verification/arch-rules.mjs: runs the target repo's own
  dependency-cruiser or import-linter (installs nothing; 2 = not measured,
  never green); --prove --rule shows pass -> fail on a planted violation of
  that rule -> pass. Verified against dependency-cruiser 18.5.0 and
  import-linter 2.15; madge 8.0.0 left out (missed a planted .mjs cycle).
- K6 lib/verification/plan-scope-sweep.mjs: changed files no step names.
- /trekplan Phases 8-10: design part, bounded critic loop, the gate before
  "execute", a plan-mode path through ExitPlanMode (not yet run end to end
  inside plan mode). plan-critic 10 -> 12 dimensions; scope-guardian checks
  the ceremony.
- Fixed on the way: plan-validator and plan-review-dedup used the
  file://argv[1] main guard (no-op under a symlink or a space); CRLF plans
  failed every manifest; the dedup dropped finding severity.

Borrowed, credited in place: obra/superpowers, Kiro (docs), addyosmani/
agent-skills, mattpocock/skills, GitHub Spec Kit, bashebr/ai-native-sdlc,
Boris Cherny's public threads.

Suite 1497 (1495 / 0 / 2), 86 new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:10:57 +02:00
2ee8137e7c
feat(hooks): wave-1 guards -- approval ask, plan gate, test lock, post-edit check
Wave-1 order 4 (P7, P12, the P9 test lock, the P3 plan-approval guard).
All command hooks; none runs a model.

- P12: pre-bash-executor answers permissionDecision "ask" before merge
  into main, push, tag/publish and destructive git (holds in auto mode).
  post-approval-log writes exactly what the person was asked to the K2
  approval log: merge/push to main as `merge`, tag/publish as `release`,
  with a one-hour expiry; nothing when no person was asked.
- P3: pre-plan-gate runs the K3 plan gate on a Voyage plan leaving plan
  mode and sends a refused plan back; "not measured" asks, never passes.
- P9: lib/util/test-lock.mjs (K8). pre-write-executor refuses edits to
  locked test files; stop-test-lock refuses "done" for the locking
  session (stop_hook_active guarded).
- P7: post-edit-check runs the target repo's own formatter/linter per
  edit, only during a Voyage plan run or after opt-in
  (.claude/voyage-guards.json); lib/verification/step-check.mjs (K9) fails
  the step on findings; no tool reads "not measured", never green.
- STORM cap matcher also names `Agent` (the tool's name since 2.1.63).
- Mod precondition stated in README, SECURITY.md and every PreToolUse stop
  guard: Voyage's guards are deterministic only when no installed mod
  handles `tool.check`.

K2, K3 and K5 are stubbed in tests until orders 2 and 3 are merged.
Two independent review rounds; every finding has a regression test.
Suite 1600 (1598 pass / 0 fail / 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:09:32 +02:00
22e792ce19
fix(review): a review with a control that did not run stops a merge
Operator decision 2026-10-06. Before, a WARN that existed only because a
reviewer or engine never reported (e.g. /security-review without an origin
remote) mapped to verdict-gate exit 0, so a review nobody performed in full
was green. Now `missing-reviewer:` / `unattributable-payload` in the JSON
block's allow_blocked_by is exit 2 (could not decide, never green); a WARN
for a MAJOR or a set-aside finding still passes with its reasons. A 1.1
review must carry allow_blocked_by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:06:29 +02:00
40f6ae1177
fix(review): close the gate's fail-open paths an independent review found
An adversarial read of d88245c reproduced 15 problems; all fixed here, each
with a test that fails on the old code.

- verdict-gate: a greener verdict must be backed by the LAST fence being a
  `json` block of the right shape, with no BLOCKER in the JSON (any case),
  in `## Findings (BLOCKER)`, or a differing finding-id list; two verdict
  lines and more than one file argument are exit 2. ({}, null, lowercase
  "blocker", a trailing ```jsonc and a later json fence all gave exit 0.)
- engine-findings: a transcribed "narrower" key can no longer lower an
  engine tier; absolute paths under --repo-root match the scope; "12" lines
  parse; an empty scope is an error; out-of-scope engine findings are
  unverified (`engine:out-of-scope`) and forbid ALLOW.
- recurrence: the catch-all engine keys and keys normalised from unknown
  ones are never proposed; the same unfixed finding in two iterations is not
  a recurrence; proposals are validated before they are offered; \r/U+2028
  and damaged markers are refused; writes go through a symlinked CLAUDE.md.
  Prose points --repo at the session directory, not the git toplevel.
- Workflow substrate: engines are counted from what arrived, and the
  verdict's floor is recomputed from the coordinator's findings; now tested
  by running the script with stubbed Workflow globals. Two literal NUL bytes
  written as \u0000 (git saw the file as binary).
- Trimming keeps Pass 1's "Also flagged by" provenance; `## Declined to
  Judge` and remediation steps are required from review_version 1.1 only
  (N-1); the zero-diff review now carries the JSON block the gate needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:03:16 +02:00
abc3083c1e
feat(execute): one fresh helper per step, a controller on the diff, proof by command
/trekexecute no longer builds steps itself. Each plan step is built by one
fresh helper (new agent step-implementer: opus, effort high, no Agent tool)
and judged by one controller (a general helper on opus) that reads the
diff from a recorded start_sha and gives two verdicts, spec and quality.
Fix rounds go up, maximum 5: three fixes by a fresh helper, then back to
the plan, then to the operator. No cheaper-model ladder.

Proof is a command, never the model's word:
- lib/verification/red-proof.mjs: the step's test must fail at the start
  point and pass at the result (exit 0/1/2; 2 is never green). Reproduces
  the 02243c6 red-first measurement (6 of 55) through the shim fixture.
- lib/verification/test-integrity.mjs: the floor guard on the diff
  (removed asserts, skipped/deleted tests, muted checks, stubs, loosened
  thresholds). Over the last 150 commits: 149 clean, 1 real finding.
- lib/verification/done-line.mjs: DONE step N only when the test command
  exits 0.

Test first is required from plan_version 1.8. The test lock (K8) is used
through its CLI and reported as not-locked while it is missing. A run
stops only on a closed list of four classes; every other call goes into a
decision log with its cost if wrong. Without a passing run-the-thing
criterion the result is not verified end to end, never green.

Handover 5 gains optional per-step start_sha, report_path, red_proof,
test_lock, fix_rounds (0-5) and run-level decisions and end_to_end; old
progress files still validate and resume.

No isolation on the helper: measured, a worktree-isolated subagent
branches from the default branch (02f320f), not the executor's HEAD
(2c89a6b), so it would miss the earlier steps.

Borrowings credited in commands/trekexecute.md ## Credits (Superpowers,
ai-native-sdlc, Kiro, GitHub Spec Kit, agent-skills, Cherny).

Tests: 1411 -> 1457 (1455 pass, 0 fail, 2 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:58:18 +02:00
d88245c06e
feat(review): the review is a gate - verdict in the file, CC engine, project memory
Ordre 5 (wave 1, P11 + P5), review_version 1.1.

- K7: `verdict: ALLOW | WARN | BLOCK` is required in review.md (strict
  error, soft warning for 1.0 reviews). New CLI lib/review/verdict-gate.mjs:
  0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide (never green); a verdict
  greener than the file's own BLOCKERs or JSON block is exit 2. Realpath
  main guard (the old file:// guard exits 0 under a symlink - measured).
- No finding is struck for length: succinctness trims only MINOR/SUGGESTION
  text (catalogue tier), keeps BLOCKER/MAJOR whole, counts trims; the
  coordinator lists what it declined to judge.
- Claude Code's /code-review and /security-review run as the engine via the
  Skill tool (never --fix/--comment/--post/ultra); lib/review/engine-findings.mjs
  turns their findings into a reviewer payload; two catalogue keys
  (CODE_REVIEW_DEFECT, SECURITY_REVIEW_VULNERABILITY), catalogue 12 -> 14.
  An engine that did not run forbids ALLOW.
- Every BLOCKER is written as a numbered `### Step R<n>` with its source
  finding (strict error REVIEW_MISSING_REMEDIATION_STEP).
- P5: lib/review/recurrence.mjs - a finding class in two review.md files
  (current + git history, no new store) becomes one proposal, routed by the
  catalogue's memory_route; written only between Voyage markers in
  .claude/rules/voyage-review-lessons.md under a size budget, CLAUDE.md gets
  a pointer only; the operator approves every line (Phase 8.7).
- Workflow substrate: catalogue enum in step; a missing reviewer or engine
  forbids ALLOW there too (it returned ALLOW on zero findings before).
- Fixtures review-run-A/B said WARN over a BLOCKER; now BLOCK.

Borrowed and credited in code: Superpowers (declined-to-judge), ai-native-sdlc
(cap only nits; mistake/fix/check), Spec Kit (managed block; converge steps),
Pocock (retro routing), Kiro (verdict file + gate; evidence per lesson),
Cherny (engine -> brief control order; review -> memory; budget).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:52 +02:00
5244d2e8e8
feat(brief): ceremony by size, one question round, logged and committed approvals
P1 (order 2, wave 1). /trekbrief puts the task in a size class
(ceremony: small|bounded|architectural, K1; heaviest in doubt, said
aloud, overridable, only moved up) that sets the question budget and the
review cap, and asks one round of questions: facts looked up, never
asked; recommendation first; ranked by impact x uncertainty with a
visible Deferred list; stop when the next three answers can be predicted.
"høres bra ut", "gjør som du vil" and an empty answer are not approval.

K2: lib/util/approval-log.mjs, an append-only hash-chained log in the
target repo (.claude/voyage/approvals.jsonl) bound to the approved
content's sha256; append/verify (0/1/2), answer, digest. Committed by
lib/util/artifact-commit.mjs: the log always, the brief and plan only
with artifact_store: git (operator decision 2026-10-06). /trekplan
Phase 1 halts on a broken, diverged, missing or mismatched log entry;
Phase 11 logs and commits an explicit plan approval before any handoff.

Both new brief fields are optional: no brief_version bump. The
main-module guard in intent-approval.mjs and brief-validator.mjs now
compares real paths. Borrowings credited in the prose and module headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:44:42 +02:00
cb68c4f125
fix(execute): the Phase 8 payload goes on stdin; /trekreview findings closed
/trekreview of 5680591..84a0621 (verdict WARN: 0 BLOCKER, 3 MAJOR, 1 MINOR,
1 MAJOR suppressed as unverified for length only). All closed red -> green:

- Suppressed MAJOR 1d0171a7 (verified live): Phase 8 spliced {plan-path}
  into a single-quoted --payload. A lone quote broke the shell parse (event
  lost, no written:false to report); a quoted pair ran a command
  substitution and still reported written:true. event-emit now reads
  `--payload -` from stdin and the three Phase 8 blocks pass the payload
  through a quoted heredoc. Checked under bash and zsh.
- 570ba19b: the SC5 set-step extractor read line by line and never saw the
  wrapped Step 0 sentinel (`blocked`); it now matches across the break and
  the test asserts `blocked` is read.
- c69a865a: the Phase 9 line_ref in jsonl-schemas.md was 9 lines off. Both
  trekexecute rows are now pinned to the exact span by a test.
- 0004fa23 (MINOR): the gates_mode_flag_override block says what to do on
  written:false (print the reason, continue).
- emit() docstring no longer says failures are swallowed silently.
- 7ffaede5: chosen to KEEP the prose-only legacy `passed` tolerance, because
  a public-plugin run started under the 6.1.0 prose would otherwise re-run
  every finished step on resume; recorded as an accepted deviation in the
  (local-only) brief. No code change.

Suite 1400 (1398/0/2), was 1393.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:16:46 +02:00
27d586841a
fix(stats): event-emit lands without the env var and reports every failure
event-emit returned {written:false, reason:'CLAUDE_PLUGIN_DATA unset'} whenever the harness did not export the variable - the normal case for the Bash tool - so /trekexecute's Phase 8 events never landed. It now resolves the directory through stats-append's resolveStatsDir (one resolver; opts.path still wins, so intent-approval is unchanged). The CLI uses the realpath isMain() guard (the file:// guard was a silent no-op under a symlink or a path with a space) and exits 1 on every written:false - usage error, payload-not-json, failed write - instead of always 0. KNOWN_EVENTS gains main-merge-approved, main-merge-declined and gates_mode_flag_override, which /trekexecute emits by design. Header, intent-approval docstring (comment only), docs/operations.md and the README autonomy sentence (wrong state names, 'records each transition') updated.

Red: 10 of 20 tests fail on the pre-fix code - 7 new (CLI lands with CLAUDE_PLUGIN_DATA empty / absent, unwritable target exit 1, usage error exit 1, symlink, space path, known events) and 3 inverted contracts. Regression guards (green on arrival): never throws when unwritable; no second copy of the fallback directory name.

Inverted (the contract this changes, not deleted): emit with no CLAUDE_PLUGIN_DATA (silent skip) -> lands under a pinned HOME; never throws when unwritable -> a genuinely unwritable target (env {} now resolves to the real home); resolveStatsPath({}) === null -> the fallback path; malformed --payload exit 0 -> exit 1.

Readers checked: otel-export keys on 'event', yardstick counts only user_input, cache-analyzer and research-loop-cap name event-emit only in comments. Verify 268/268 (stats-event-emit, intent-approval, doc-consistency, otel-export-validators, yardstick). Suite 1376 (1374/0/2); non-hook lines in the real trekexecute-stats.jsonl 60 before and after.

Voyage order 20261005T104513Z-1250514346, plan step 2/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:35:11 +02:00
8a56187341
fix(validators): the progress validator knows stopped and blocked
/trekexecute writes run status stopped (dirty-tree stop, entry-condition stop, escalation) and step status blocked (Step 0 sandbox sentinel); the validator rejected the first and warned on the second. Both are real states lib/util/session-orders.mjs already reads (ENDED_BAD), so the validator learns them. The step-status list is exported as PROGRESS_STEP_STATUSES (was an inline literal) so tests check prose against it instead of keeping a copy. docs/HANDOVER-CONTRACTS.md Handover 5 rows updated.

Red: 3 of 4 new tests fail on the pre-fix code (stopped run status, PROGRESS_STEP_STATUSES export, blocked step status). Regression guard (green on arrival): every run status but completed stays resumable.
progress-validator 16/16. Suite 1368 (1366/0/2).

Voyage order 20261005T104513Z-1250514346, plan step 1/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:32:54 +02:00
3f073b7bd7
fix(docs): the order fields stand on all five surfaces (green)
/trekbrief Phase 7 says the writer adds order_id / order_claimed_at /
order_reason (never typed by the model) and that the yardstick joins use on
order_id; the fixture row lists them in `fields`, EXPORT_DENYLIST names them,
and its stale line_ref (657-672) now reads 961-976; the allowlist header
names them as dropped; docs/architecture.md Stats explains them.

/trekbrief --approve now runs Phase 7 after a successful stamp, with fixed
values (mode "approve", 0 turns, brief_quality/research_topics/auto_research
from the brief's frontmatter, auto_result "manual", intent_approved true).
Valgt fordi PM 04.10 (point 3): fix the code, not the criterion - the
approval must leave the trekbrief record. Measured hole: okf-wiki v1-k has
brief-approved 04.10 17:12Z and trekplan 17:29Z, but only a 03.10 trekbrief
record with intent_approved false. CLAUDE.md, README.md and
docs/command-modes.md say so. The manual path is unchanged.

npm test 1309 -> 1362 (1360 pass / 0 fail / 2 skip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:55:17 +02:00
43e6a2b23a
fix(stats): a trekbrief record carries the order Voyage runs, written by the writer (green)
appendStatsRecord(kind, record, env, { cwd }): for kind trekbrief it drops
every order_* key the record came with and adds attributeOrder()'s three
keys (lib/stats/claimed-order.mjs), resolving the mailbox from the record's
project_dir when that lies inside cwd. Other kinds are written untouched.

The CLI guard compares real paths (realpathSync on argv[1] and on
import.meta.url). The old `import.meta.url === file://${argv[1]}` was a
silent no-op - exit 0, nothing written - under a symlink and under a path
with a space (measured 04.10). Valgt realpath fordi `resolve()` alone fixes
only the space.

stats-landing 15/15; intent-approval, claimed-order, proevesett green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:50:32 +02:00
2337d7f2ac
feat(stats): the recorder finds the one claimed order Voyage runs (green) [skip-docs]
lib/stats/claimed-order.mjs reads <coord>/<mailbox>/orders/claimed/ the way
repo-mailbox's claim verb writes it (read, never called): the stem is the id,
the .claim sidecar gives claimed-at, every .md counts toward "several".
Mailboxes come from two independent git calls (toplevel basename, the claim
script's rule; and the main repo behind a linked worktree), with GIT_DIR /
GIT_WORK_TREE dropped and a 5 s timeout. A project_dir outside cwd cannot
steer the lookup. Never throws, never guesses, starts nothing.

Valgt: identity = file stem, not frontmatter, fordi the claim script moves
<id>.md and never reads the frontmatter; a quoted order-id would otherwise
never join.

claimed-order.test.mjs 20/20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:49:11 +02:00
d70e3d2af7
fix(execute): clear the wave record only on the main-merge gate's confirm (green)
1. Step 6 cleared the orders dir before Phase 8's main-merge gate asked,
   so a declined gate lost the merged-waves record and the next run
   prepared wave 1 again on merged code. The clear now sits only under
   the gate's "On confirm" (wave path, next-wave none, Step 4 passed);
   Step 6 and a declined gate keep the record, so a re-run goes
   next-wave none -> Step 4 -> back to the gate.
2. The gate's Decline row and Recovery surface now say what holds on the
   wave path: the waves are already merged on the current branch,
   nothing is finalized, the record is kept, and re-running the same
   command (not --resume, which Phase 2.5 runs as a single session)
   re-runs master verification and returns to the gate. The branch name
   is trek/{slug}/session-N; the push-before-cleanup note now holds only
   where the push succeeded. The single-session --resume line stays.
3. The report footer adds the multi-session way to continue.
4. wave.json stores the branch the wave was prepared on (write
   --branch); Step 3's status call passes the current branch and stops,
   touching nothing, when the main checkout is on another branch.

npm test: 1277 tests, 1275 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:24:33 +02:00
a915b9d4b0
fix(execute): the multi-session path never force-deletes (green)
One invariant closes the data-loss class: Phase 2.55, 2.6 and 7.6 remove
worktrees only with `git worktree remove` without force and branches only
with `git branch -d`. Whatever git refuses (a worktree with changes, an
unmerged or checked-out branch) is kept, reported with what the user can
do, and never retried with force; every unmerged trek/{slug}/* branch
survives every failure path. Step 1 stops on a branch or worktree left
by an earlier attempt instead of deleting it. After a merged wave, a
progress file is dropped from its worktree only when untracked and
byte-identical to its archived copy, so the happy path still cleans up.

B1: Step 3 refuses to merge into a dirty main checkout, and tells a
refused merge (no MERGE_HEAD: stop, keep wave.json) from a real conflict
(abort, Step 5 keeps the branches).
B2: Phase 2.5, Check 4 and Step 0 take the main checkout from the first
`git worktree list` entry and refuse to run from a linked worktree.
B3: wave.json and merged-waves.json carry the plan's sha256 and refuse
another plan or version, naming the run that holds the orders dir and
the clear command; Step 0 stops on that. Step 6 clears the orders dir
when every wave is merged AND Step 4 passed, no longer keyed on the
Phase 8 label the wave path cannot reach.
Also: every session-orders.mjs call runs from the main checkout with a
main-relative plan path; Check 1 no longer counts .claude/trekplan-sessions/
as a dirty tree (it blocked every re-run after a failed wave); a stuck
wave has a documented non-force way out; Hard rule 12 states the
invariant. The test file only narrows one call-site filter that also
matched Step 0's plugin-root existence check.

npm test: 1273 tests, 1271 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:18:33 +02:00
dbc07b4006
fix(execute): never tear down a running or merged wave; keep progress past cleanup (green)
Seven defects from an independent review of the 6.0 session-order flow,
all verified against the text before fixing:

1. Step 3 branched only on pending/failed/stopped/unreadable, so a session
   still writing in-progress (or in_progress) next to a failed one had its
   live worktree force-removed. status now classifies each session as
   completed, running (no file, pending, in-progress, in_progress, or any
   unknown status) or ended-bad (failed, stopped, partial, blocked,
   unreadable), keeping the raw status; Step 3 stops on any running FIRST
   and touches no worktree, branch or wave.json.
2. The Phase 2.5 wave-in-flight guard was cwd-relative; from a subdirectory
   it missed wave.json and Check 4 force-removed the wave. The guard is now
   anchored at the git toplevel, and Check 4 opens with a block that refuses
   to clean while wave.json exists.
3. Step 6 read progress files from worktrees that Step 3/5 had removed. A
   new archive subcommand copies each to orders/wave-W-session-N.progress.json
   before any worktree goes; Step 6 and Phase 8 read the copies.
4. NO_WAVE always prepared the lowest wave, so a re-run after a wave-2
   failure re-ran wave 1. record-merged writes orders/merged-waves.json
   after each merged wave (Step 5 keeps it); next-wave returns the lowest
   unmerged wave or none (then Step 4). Phase 8 clears the orders dir only
   after a completed report; the record refuses another plan's path.
5. Phase 7.6 named a deleted worktree and promised an unwired re-run. The
   order now names the main checkout and says the recovery session's own
   audit is the check.
6. (test side, previous commit) progress paths are asserted literally.
7. Hard rule 12 now carries the awaiting-sessions exception.

npm test: 1262 tests, 1260 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:09:12 +02:00
219e40cd91
feat(execute)!: hand waves over as session orders, never start Claude Code (green)
Operator decision 27.09.2026: Voyage works the PM way, so nothing in it
starts Claude Code. Work that needs another session becomes an order plus
a starter line the user gives a session they open themselves; Voyage
never starts the session.

/trekexecute's multi-session path (Phase 2.6) now prepares one wave at a
time: worktree per session, then lib/util/session-orders.mjs writes one
order per session (worktree, steps, done-condition, starter) plus
orders/wave.json, and the run stops. Running the same command again reads
each session's progress file (status mode), merges sequentially with
push-before-cleanup as before, and prepares the next wave. Phase 2.5 skips
the destructive pre-flight while a wave is in flight. Phase 7.6 recovery is
handed over the same way. Hard rule 21: never start Claude Code.

Removed with it, because they existed only for launched children: the
ANTHROPIC_API_KEY billing check (and its dry-run line and Hard rule 1
exception), the hooks-fire version precheck, the safety preamble, the
scoped-tool/turn/budget flags, templates/headless-launch-template.md, the
decomposer's launch.sh (now orders.md), /trekplan's "launch", the README
billing and MCP tuning sections, and scripts/q3-cache-prefix-experiment.mjs
(an experiment that spawned the binary; its results stay in docs/).
/trekresearch's deep-research pre-gate no longer runs the binary for its
version; the post-hoc check was already the authoritative guard.

Chose orders + starters over in-session subagents because the order's done
criterion asks for exactly that, and because /trekexecute disallows the
Agent tool on purpose (Hard rule 10); reopening that is a separate change.

Gate: 54 -> 0 hit lines outside EXEMPT. npm test 1250 (1245 pass, 0 fail,
5 skipped: 3 are the retired-reference scan without its local list).
verify.sh 7/0. The fixture run (tests/lib/session-orders.test.mjs) writes
two orders and wave.json for wave 1 and leaves a trap binary untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:35:22 +02:00
3892a835dd
test(proevesett): a case for the intent gate; case 5's error comes from code; graders see what they claim
New case plan-halts-without-intent-approval (expectation committed before
its first run): a 2.1 brief WITH phase_signals and no intent marker.
Graders: intent-approval.mjs --check ran; the trace carries the gate's own
JSON code for BRIEF_INTENT_NOT_APPROVED; no Agent; no plan.md.

review-requires-project: the arg parser now has a CLI that checks the
required flag and prints 'Error: --project <dir> is required.' + usage
(exit 1, code ARG_REQUIRED_MISSING). trekreview.md runs it with
$ARGUMENTS (it passed "$@", which the Bash tool never has, so the parser
never ran — 0 Bash calls in the PM's case-5 traces) and relays its stderr
instead of composing the line. The grader project-required (reply regex,
unstable on backticks) is replaced by parser-ran + names-missing-project
(trace, the parser's JSON code), the same form as names-rule.

Graders: no-error-code is bound to the validator's JSON output form and
covers every REVIEW_* code (WRONG_TYPE and VERSION_FORMAT were missing);
says-pass/says-fail reject a preceding NOT and a longer word; every case
with no-write also gets no-bash-write (redirect, tee, touch, cp, mv, rm,
sed -i in the Bash command). Checked on the PM's 10 recorded traces: no
false positive; known-positive/negative still split.

Red 348aa95 6/7 → green 7/7. Suite 1231: 1229 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:28:38 +02:00
4592b40873
docs(intent): M3 + M7 — the header says only what is true
M3: the header claimed "reflowing a paragraph keeps the approval"; joining
lines gives STALE (PM probe iii-c). It now says what keeps the approval
(indentation, in-line spacing, blank lines, CRLF) and that joining or
splitting lines makes it stale — erring safe. Also states the one-section
and code-block/comment rules from M1.
M7: the trekreview exemption is read from the brief's own frontmatter, so a
relabelled brief walks past the gate — the same trust class as self-stamping.
Now said in the module header, HANDOVER-CONTRACTS and /trekplan's gate prose.
HANDOVER-CONTRACTS also names both brief modes the --approve path reaches.

Red 097bae8 (M3, M7) → green. Suite 1224: 1222 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:24:16 +02:00
20f32bb06b
fix(intent): M1 + M2 — the parser fails closed; --approve reaches any brief file
M1: headings inside fenced code blocks and HTML comments are text, not
headings, and ## Intent / ## Goal must each appear exactly once. A second
section, an example section in a code block or comment, or a '## ' line in
a code block inside Intent no longer carries the old approval: check gives
INVALID (duplicate) or STALE, stamp refuses a duplicate. A plain brief
hashes exactly as before (pinned), so no stamped brief goes stale.
Re-run of the PM's 29 probes: vi-a, vi-b, vii-a, vii-b now stop; the rest
unchanged. The PM's p7c probe was a no-op (its replace string is not in the
base brief); vii-c is covered by its own test, red on 66e1fa1.

M2: /trekbrief --approve takes <project-dir | brief-file>. New
intent-approval.mjs --resolve decides: a directory → <dir>/brief.md and
/trekplan --project; a file → itself and /trekplan --brief. Phase 4h and the
stamp line use {BRIEF_PATH}; /trekplan's halt table names
/trekbrief --approve {brief_path}, which works on both brief modes, and so
does the gate's hint. HANDOVER-CONTRACTS' "open to every producer's brief"
is now true. Chose fixing the path over rewording the contract because two
real briefs (docs/*-brief.md) had no approval path at all.

Red ff760ed 9/29 → green 29/29. Suite 1219: 1217 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:22:03 +02:00
d906999036
fix(stats): M6 — trekbrief and trekplan records land where the yardstick reads
New lib/stats/stats-append.mjs: one record on stdin, appended to
<data dir>/<kind>-stats.jsonl. The data dir is CLAUDE_PLUGIN_DATA, else
~/.claude/plugins/data/voyage-ktg-plugin-marketplace (the yardstick's
default). A failed write exits 1 with a reason: reported, never silent.
/trekbrief Phase 7 and /trekplan Phase 12 now run it instead of
"append to ${CLAUDE_PLUGIN_DATA}/…; skip silently". intent-approval's
resolveApprovalDataDir delegates to the same resolver.

Chose a stdin heredoc over a --json argument because a JSON record in a
shell argument breaks on quotes in task text. Only trekbrief and trekplan
are rewired (økt 2's countable form needs exactly those two); the other
commands' stats prose is unchanged and docs/architecture.md says so.

Red a358059 7/7 → green 7/7. Suite 1208: 1206 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:17:52 +02:00
2045432ce7
fix(stats): intent_approved reaches the OTel allowlist like every other trekbrief field
The trekbrief-stats record gained intent_approved in f5dc08f (prose +
jsonl-schemas row) but not lib/exporters/field-allowlist.mjs, so the
exporter would drop it silently. Boolean, low-cardinality — safe label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 07:52:45 +02:00
f5dc08f660
feat(intent): /trekplan halts without an approved intent; the approval is stamped on both paths
Veikart steg 1, økt 1 av 2. The three holes measured in the grounds:
(1) ## Intent / ## Goal were only checked for existence, (2) no approval
marker existed, (3) brief-approved was emitted only on the auto path.

- lib/validators/intent-approval.mjs: --check / --stamp. The marker is
  intent_approved_hash (sha256 over normalized ## Intent + ## Goal) +
  intent_approved_at. Editing either section after approval → STALE.
- /trekplan Phase 1 (Read the brief, both --brief and --project): runs
  --check and HALTS on BRIEF_INTENT_NOT_APPROVED / _STALE / _INVALID with
  the remedy spelled out; an unrunnable check halts too.
- /trekbrief Phase 4h (before the Phase 5 fork, asked even in --quick):
  shows Intent + Goal verbatim, AskUserQuestion Approve / Revise / Leave;
  only "Approve" runs --stamp. --stamp emits brief-approved, so the manual
  (default) path records it; the auto path's own emission is removed.
  New mode /trekbrief --approve <project-dir> = Phase 4h alone.
- README, CLAUDE.md, command-modes, HANDOVER-CONTRACTS §Handover 1,
  jsonl-schemas (trekbrief-stats gains intent_approved).

Valgt ingen brief_version-bump fordi skjemaendringen er rent additiv (to
valgfrie felt) og kravet sitter i /trekplan — enhver produsents brief kan
godkjennes via /trekbrief --approve uten produsentendring. Valgt eget
--check-kall i stedet for et flagg på brief-validator fordi --brief-stien
i dag ikke kjører validatoren i det hele tatt; et nytt validatorkall der
ville også stoppe på andre feil. Valgt fallback-datamappe = målestokkens
(plugins/data/voyage-…) fordi CLAUDE_PLUGIN_DATA er tom i Bash-miljøet og
event-emits egen fallback er stille skip — slik ble brief-approved 0 records.

What the marker does NOT prove (module header, command prose, contract):
same user, same machine — any session can stamp. A trace, not a signature.

Suite 1183 → 1201 (1199/0/2). Mutants M1–M5 (stale check off, check
always valid, gate line removed, stamp emits nothing, no normalization)
each fell ≥ 1 test. yardstick unchanged: RED, 1 of 3 countable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 07:50:51 +02:00
f90e1cf02d
fix(verification): a section the runner cannot read SAYS so, and trekplan is pinned to the format it reads
Measured 2026-09-18: nine ordinary shapes of a plan's `## Verification`
section parsed to zero criteria - an untagged fence, a ```text fence, a
markdown table, `## Verification (acceptance)`, `## Verification:`,
`### Verification`, an unclosed fence earlier in the document. Every one came
out as `0 of 0`, NOT OK, exit 1, and Phase 7 then forbade `result: completed`
without anyone being told that the FORMAT, not the code, was the problem.
"The section is empty" and "I cannot read this format" are different facts.

Three changes, one hole:

- The runner reports `NO_CRITERIA` with a source line (`plan.md:NN`) when the
  section is there and nothing in it parsed, and names the two forms it does
  read. Same for a brief's `## Success Criteria`, so the evidence block the
  conformance reviewer gets says which of the two it is looking at rather than
  showing an empty table.
- Phase 7 says it out loud instead of failing silently: report the source line
  and the two forms, and say that the plan is what failed there, not the run.
- `/trekplan` now pins what it produces to what the runner reads: the heading
  is exactly `## Verification`, the criteria are a bullet whose first
  backticked span is the command or a shell-tagged fence, and the command must
  be one the allowlist runs. A doc-consistency test holds the writer and the
  reader together, so a runner that learns a new form must update the source.

Honest about the round trip: the two round-trip tests were GREEN on arrival -
the template already writes the bullet form the runner reads. What was missing
was not the format but the PIN: `/trekplan` mandated neither the heading string
nor the format, so a plan could satisfy the command's own instructions and
still parse to nothing. The tests now hold that.

Red first: 5 of the 7 new tests failed before the change (3 NO_CRITERIA, 2
doc-consistency); the 2 round-trip tests are guards, and said so above.
Suite 1161 (1159/0/2). Gate unchanged: defects 0 of 7, intact, exit 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 03:00:13 +02:00
02243c6365
fix(verification): the criteria runner screens with an ALLOWLIST, not a denylist
A denylist in front of /bin/sh is whack-a-mole. Measured 2026-09-18, end to
end through both screens: 5 of 11 named evasions ran with real effect - a
`command` prefix reached git, an escaped `rm` inside a shell fence deleted a
directory, `find -delete` deleted a file, `>|` and `tee` wrote outside the
working tree, a python one-liner deleted the whole tree - and 19 of 28 got
past the refusal list on its own. Every quoting, aliasing and indirection form
of the shell is another mole.

So the screen is now an ALLOWLIST. A criterion runs only when its first word
is a known test runner (npm test, npm run <script package.json declares>,
node --test, vitest, jest, pytest, python -m pytest, uv run pytest,
cargo test, go test, make test, bash <script under tests/>, a read-only git
subcommand) AND the command carries no shell operator and no newline.
Everything else is NOT RUN with the reason said out loud: never run, and never
reported as a failure either - an absent measurement is not a finding. That
also closes the smaller hole in the same file: a bare word a sentence merely
names (`whoami`, `login`, `package.json`) is no longer executed, because it is
not a runner.

REFUSED_BY_POLICY is gone with the list that produced it; a command outside
the allowlist is `unrunnable`, which in plan mode still fells the run and in
brief mode is reported to the reviewer as an absent measurement.

What the allowlist deliberately does NOT do, said in the file and in the
reviewer's rubric: it is not a sandbox. `npm test`, `npm run <script>` and
`make test` run whatever the repo's own package.json/Makefile says they run,
including a script that pushes - that is the repo's responsibility. And it
rejects honest commands too: an env prefix, a project's own binary, anything
piped. A check that needs one of those is declared through
`bash tests/<script>.sh`, the documented way in.

Red first: 6 of the new tests fail against the previous runner (measured with
an always-allow shim so the module still loads), including the end-to-end one
where the canary directory was deleted and files were written outside the
tree. The fixtures move from `true`/`false` to two allowlisted shell fixtures,
because `false` is no longer a runner - the fail case must still be a real
non-zero exit, not an unrun criterion.

Suite 1148 (1146/0/2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:51:40 +02:00
52b87978cb
fix(verification): the runner refuses writes a brief may not perform
/trekreview now runs the commands a BRIEF declares, and a brief is an artifact
that can arrive from outside the repo. Measured 2026-09-18 on 6cafb4c: the
executor denylist stopped a download piped into a shell, but the remote-writing
git subcommand and a recursive delete of a path both RAN. The denylist screens
catastrophe (root deletion, fork bombs, mkfs); it was never meant to screen an
artifact under review.

A second screen, in the runner and ahead of the denylist, refuses four classes:

- a remote-writing git subcommand. The subcommand is found by walking git's own
  options (`-C`, `-c`, `--git-dir`, ... take a value), so `git status` and
  `git log` still run and `git -C sub push` does not.
- a recursive delete: any `rm` carrying `-r`/`-rf`/`--recursive`. A plain
  `rm build/artifact.txt` still runs.
- a download piped straight into a shell (also caught by the denylist; pinned
  here so the runner does not depend on another file for it).
- a write outside the working tree. `/dev/null`-class devices are fine, and so
  is anything under the working tree; `~/...`, an absolute path elsewhere, and
  a target carrying an unexpanded `$VAR` are refused - the runner cannot know
  where a variable points, and guessing is how a screen stops screening.

A refusal is its own outcome, REFUSED_BY_POLICY: the command never reaches a
shell, and `summary.ok` is false in both plan and brief mode. For the reviewer,
REFUSED is like NOT RUN - the absence of a measurement, never on its own a
finding - and the rubric and the evidence block both say so.

Chosen deliberately, and it is stricter than today's habit: writing scratch to
/tmp is refused too. The repo's own example plan does `> /tmp/out`. Verification
output belongs in the working tree; exempting the whole system temp dir would
have made the rule unstatable, since a working tree created under /tmp then
contains its own escape hatch.

NOT covered, stated rather than implied:
- other writing git subcommands (tag, remote, config, gc) - only push is listed
- writes through a wrapper: `sh -c '...'`, `xargs`, `find -exec`, a Makefile
  target, a script the criterion invokes. The screen reads the command it is
  given, not what that command goes on to do.
- `>` inside a quoted string reads as a redirect, so a criterion echoing a
  literal `>` is refused. Fail-closed, on purpose.
- the whole surface still runs with the invoking process's permissions; this is
  a refusal list, not a sandbox.

The denylist-layer test now uses a stand-in command with a screen double: the
refusal list catches a recursive delete first, so naming one there would have
stopped exercising the denylist layer at all.

Red first: the 6 new tests failed before this change (`refuseCommand` did not
exist), and the fixture brief's four writes ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:14:01 +02:00
106dcb0091
fix(verification): a backticked span is only run when it IS a command
"The first backtick span is the command" is right for a plan, whose template
puts the command first, and wrong for a brief, whose criterion usually opens
by NAMING the thing under discussion. Measured 2026-09-18 on the repo's own
example brief: 5 of 6 criteria FAILED, 3 of them parse artifacts - `--verbose`
run as a command gave exit 2 ("invalid option"), `tests/` gave exit 126 ("is a
directory"). The rubric reads a FAILED result as decisive, so each one became
a BROKEN_SUCCESS_CRITERION BLOCKER about prose.

looksLikeCommand() screens the span by SHAPE only - no filesystem lookup, so a
span parses the same everywhere. Refused: a leading flag, a directory, a token
carrying quotes/braces/prose, and a lone relative path with a slash (an
explicit ./, ../, / or ~/ still runs, as do env-var prefixes). A refused span
is `unrunnable` with reason `not-a-command` - its own outcome, never FAILED,
and it never reaches a shell.

It deliberately does NOT scan on to a later span. "The first span that LOOKS
like a command" invents commands out of prose: in that same example brief it
would have run `whoami` and `login`, two real binaries a sentence happens to
name. An absent measurement is honest; a guessed one is not.

The shape check applies to prose spans only. Inside a shell-tagged fence the
author has already declared shell, so `[ -f x ] || exit 1` still runs.

The rubric follows: a NOT RUN result is never on its own a finding. The
Partial row now describes half-built DELIVERED CODE, and the reviewer gets a
table of the three reason strings - no-command, placeholder, not-a-command -
with what each says about the sentence rather than about the code.

Not covered, stated for the record: a multi-token span whose first token is a
non-executable file (`tests/golden/login.stdout --check`) still runs, and a
criterion whose command is real but whose binary is absent still reports the
shell's exit 127 - that is a true measurement of a missing binary, not a
parse artifact.

Red first: 4 runner tests + 1 doc-consistency pin failed before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:04:46 +02:00
0013d292d6
fix(verification): the criteria runner reads the repo's own plan format
The runner read bullet lines only. The repo's own example plan writes its
whole acceptance run as a fenced bash block, so `## Verification` parsed to
ZERO criteria, the runner exited 1, and Phase 7 forbade `result: completed`
- a correct plan felled every single-session run. Measured 2026-09-18 on
6cafb4c: none of the repo's plan artifacts exited 0.

Fences are now read twice over, for two opposite reasons:

- a `## ` heading INSIDE a fence is quoted text and no longer opens a
  section. examples/02-real-cli/REGENERATED.md is a report that quotes a
  plan outline inside one fence; it used to yield an empty section that
  read as "0 of 0", and now yields the honest NO_VERIFICATION_SECTION.
- a shell-tagged fence inside the section holds the commands. The tag list
  is closed (bash/sh/shell/zsh/console/shell-session): an untagged fence is
  more often expected OUTPUT than input, and inventing a criterion from
  output is the failure this file exists to prevent.

Blank and comment-only lines inside the block declare nothing. A `$`/`>`
console prompt is stripped; a `#` root prompt is NOT, because it cannot be
told from a comment and running a comment is the worse mistake.

Measured after the fix (parse only - one example names a fictional CLI):
examples/01 6 criteria, plan-template 2 (both placeholders, correctly NOT
RUN), the two runner fixtures 2 each, plan-run-C 1, REGENERATED.md 0 with
NO_VERIFICATION_SECTION.

Divergence from the order's premise, stated for the record: it said 2 of 3
example plans write `## Verification` as a fenced bash block. Ground truth
is 1 of 3 - REGENERATED.md has no section of its own at all - and the
example plan's block holds 6 command lines, not 5.

Red first: 4 of the 6 new tests failed before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:02:14 +02:00
c23b009738
fix(review): run the success-criteria commands and hand the reviewer the result (D-04)
The rubric required `brief-conformance-reviewer` to classify a Success
Criterion as Full only when "its verification command/test exists and passes".
Its tools are `Read`, `Glob`, `Grep`. It cannot run anything, so "passes" was
either guessed from the command's mere existence or quietly downgraded to
"exists" — a BLOCKER-tier rule key resting on an impression.

The reviewer stays read-only — a reviewer that executes the code it reviews is
not an independent reviewer. The command does the running instead:

- `/trekreview` Phase 4.5 runs the brief's `## Success Criteria` commands
  through `lib/verification/criteria-runner.mjs --brief --evidence` and captures
  the block as `sc_evidence_block`, pasted verbatim into the reviewer prompt in
  Phase 5. The exit code does not stop the review — a failing criterion is
  exactly what the review exists to find.
- `formatCriteriaEvidence` builds that block in code: one row per criterion with
  the command, the exit code and the first output line. Chose a code-built block
  over an orchestrator-written summary so the orchestrator cannot narrate a pass
  that never happened.
- The rubric now judges the supplied result: `PASS` supports Full, `FAILED` /
  `BLOCKED` is `Broken` with the exit code cited, and `NOT RUN` is the absence
  of a measurement — never evidence in either direction.
- Phase 4.5 is skipped in `quick` mode: that mode does not launch the
  conformance reviewer, so there is nobody to hand the result to.

Red first: seven tests in `tests/lib/criteria-runner.test.mjs` against a
committed brief fixture whose three criteria pass, fail, and are prose-only.
The two doc pins were verified red against the pre-fix files (rubric asked
"exists and passes"; no Phase 4.5; the block reached nobody).

Suite: 1117 (1115/0/2), up 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 01:34:18 +02:00
e55ca9fc89
fix(execute): run the plan's Verification on the single-session path (D-03)
A trekplan's `## Verification` section is where the brief's success criteria
land. Phase 7 opened with "**Skip for trekplans.**", and only the multi-session
wave path (Phase 2.6 Step 3) ran master verification. A plan executed in ONE
session therefore reported `completed` without ever running the criteria it was
measured against — the executor's own belief was the only evidence.

The check now exists as code, not as an instruction:

- `lib/verification/criteria-runner.mjs` parses the criteria an artifact
  DECLARES (a plan's `## Verification`, a brief's `## Success Criteria`), runs
  each command, and returns a verdict built from exit codes. Fail-closed
  throughout: a placeholder, a prose-only criterion, or an unavailable screen
  is `unrunnable`/`blocked`, never `passed`. A plan with no `## Verification`
  section exits 1 — a plan that promises no end-to-end check cannot be reported
  as verified.
- Every command is screened through the plugin's own PreToolUse denylist
  (`hooks/scripts/pre-bash-executor.mjs`) before it reaches a shell. Chose
  invoking that hook over its documented stdin protocol rather than copying its
  rules, because a command spawned from node never passes through the Bash tool
  and so the hook cannot fire by itself — this keeps exactly one denylist.
- Phase 7 is now "Exit / verification check": session specs run the exit
  condition, trekplans run the criteria runner. Phase 4's entry-condition skip
  for trekplans stands — a plan carries no entry condition; the exit side is
  not symmetrical.
- A failing criterion FELLS the run: `plan_verification.status != "passed"`
  forbids `result: completed`. That is clause 2 of the stop-signal contract,
  now enforced on the single-session path too.

Red first: `tests/lib/criteria-runner.test.mjs` (26 tests) against two committed
fixture plans, one of which declares a criterion that fails on purpose. The
doc pin in `tests/lib/doc-consistency.test.mjs` guards the wiring — a capability
no phase calls is the same defect wearing a lib/ file; verified red against the
pre-fix Phase 7 (skip present, runner absent, no fell-the-run clause).

Suite: 1108 (1106/0/2), up 27 from 1081.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 01:30:57 +02:00
f1c2c9eb4a fix(review): write the gold-scorer key separator as an escape, not a raw NUL byte
lib/review/gold-scorer.mjs held its (file, rule_key) separator as a literal NUL byte
inside a string literal. Git's binary heuristic therefore classed the whole file as
binary. Every diff of the scorer read "Binary files differ", `--numstat` printed "-  -",
and plain grep matched nothing in it. The separator is now written as the escape
sequence '\x00', which is the same one-character string at runtime.

Proof that the change is behaviour-neutral:
- A snapshot script recorded every number the scorer produces, before and after:
  - the committed run-perfect fixture through the coordinator contract;
  - empty, half, spurious (keys containing spaces and colons), duplicated and empty-gold
    runs;
  - all 9 verdict pairs.
  `diff` of the two outputs is empty (141 lines each).
- tests/lib/gold-scorer.test.mjs + tests/lib/gold-eval.test.mjs: 13/13 before and after.
- `git diff --no-index --numstat /dev/null <file>`: the new file reads "89 0" (text); the
  old blob, as a control, reads "- -" (binary).

End-state gate D-06: open -> closed (defects 4 -> 3 of 7). Suite 1059 (1057/0/2), run
on a clean export of the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:36:18 +02:00
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
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
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
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