Compare commits

..

No commits in common. "main" and "v5.9.1" have entirely different histories.

53 changed files with 518 additions and 6282 deletions

View file

@ -1,11 +1,11 @@
{
"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.10.1",
"version": "5.9.1",
"author": {
"name": "Kjell Tore Guttormsen"
},
"homepage": "https://git.fromaitochitta.com/open/voyage",
"homepage": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace/src/branch/main/plugins/voyage",
"repository": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git",
"license": "MIT",
"keywords": [

9
.gitignore vendored
View file

@ -42,12 +42,3 @@ 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
# Page renders of those same documents (SLDC-AI-1_1.png etc). Narrow on purpose:
# legitimate plugin assets may still be .png under docs/.
docs/SLDC-AI*.png

View file

@ -4,209 +4,6 @@ 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/).
## v5.10.1 — 2026-09-03 — gemini-bridge dropped; fail-closed review verdicts
### Removed
- **`gemini-bridge` is dropped from the pipeline (operator directive).** The
agent file is deleted and every live surface that could still steer a run
toward it is cleared: `commands/trekresearch.md` (the Bridge-agent launch
block, the `--local` help text, the high-effort always-on pair, the stats
record, the graceful-degradation list), `commands/trekplan.md`,
`commands/trekbrief.md`, `agents/research-orchestrator.md` (mode table,
agent table, prompting block, summary line), `README.md` (feature prose, mode
table, agent prose, the mermaid EXTERNAL node, the `* = conditional` legend,
the optional-MCP requirement, the `--local` section), `CLAUDE.md`,
`settings.json` (the whole `trekresearch.geminiBridge` block),
`templates/research-brief-template.md` (the *Gemini Second Opinion* section
and the sources-table `gemini` type), `templates/trekbrief-template.md`,
`docs/architecture.md`, `docs/command-modes.md`.
- **`/trekplan` high-effort Adversarial Pass 2 degrades explicitly.** It now
emits a `## Adversarial Pass 2 (v5.1.1 high-effort)` section whose status
reads **"unavailable, skipped"**, rather than failing or vanishing: a
high-effort plan carrying no Pass 2 marker is indistinguishable from one whose
Pass 2 crashed. `/trekresearch` high effort forces only `contrarian-researcher`
always-on, which remains the complete adversarial coverage.
- **Not removed:** `gemini_used` survives as a vestigial `/trekresearch` stats
field pinned to `false`. Dropping it would break the observability export
schema for existing consumers, and no consumer asked for that.
- Agent inventory: **24 → 23 files** (20 spawnable + 3 orchestrator reference
docs; `synthesis-agent` still dormant). Counts reconciled in `README.md` and
`CLAUDE.md`; the `<example>`-block floor in
`tests/lib/agent-frontmatter.test.mjs` moves 34 → 32 because an agent
legitimately left the inventory — not because examples went missing from a
surviving one.
- Pinned by 5 new tests in `tests/lib/doc-consistency.test.mjs`, including a
**known-positive control** asserting the historical records *still do* carry
gemini references — so the empty result on live surfaces is a measurement and
not a broken query. CHANGELOG entries and measurement/decision docs keep their
references deliberately: they record what a past version did.
### 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
- **T1 §6 PoC status corrected — it was already run and declined.**
`docs/T1-cc26-delegated-orchestration.md` §8 item 3 still claimed both the §6
synthesis-agent PoC and the §5 full bake-off were "designed but unbuilt". That
was written in S7 and falsified the same afternoon by S12, which ran the §6
PoC and recorded **Δ main-context (faithful flow) = 0.0%, NEGATIVE**
(`docs/T1-synthesis-poc-results.md`; reproduce with
`node scripts/synthesis-measure.mjs`). The stale wording caused the settled
PoC to be re-ordered as new work on 2026-09-02, so it is struck rather than
deleted and §6 gained an explicit **RUN AND DECLINED** status block. The
finding is structural, not stochastic: `/trekplan` Phase 5 spawns the
exploration swarm **foreground** (`commands/trekplan.md:158,338-341`), so the
610 outputs are already resident in main before Phase 7 begins — delegating
only the Phase-7 digest returns on top of them and evicts nothing. A saving
needs the Phase-5 delivery moved off-main, which is §5.
- **§5's environment premise updated (measured 2026-09-03, CC 2.1.259).** S12
recorded the live bake-off as blocked for want of an `ANTHROPIC_API_KEY`;
`claude -p --output-format stream-json` in fact runs on subscription auth with
no API key and now emits a `subagent_stats` block (spawned / max_depth /
refused) that did not exist in June — a ready-made classifier-interference
counter for §5's feasibility guard. S12's other blocker (the installed plugin
is the cache copy, so a repo-local agent is invisible to `claude -p`) stands
unmeasured. This changes nothing about the §6 verdict; it lowers the cost of §5.
- **`/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.

View file

@ -24,20 +24,6 @@ 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 |
@ -65,8 +51,9 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| community-researcher | opus | Community experience: issues, blogs, discussions |
| security-researcher | opus | CVEs, audit history, supply chain risks |
| contrarian-researcher | opus | Counter-evidence, overlooked alternatives |
| gemini-bridge | opus | Gemini Deep Research second opinion (conditional) |
> **Inventory (S33 reconcile, updated v5.10.1).** 23 agent files = **20 spawnable** (one, `synthesis-agent`, ships **dormant** — Δ≈0, wired to nothing) **+ 3 orchestrator reference docs** (`planning-/research-/review-orchestrator` document the inline `/trek*` workflow, not spawnable capabilities). All 23 stay `model: opus` (operator pin `40d8742`); the glue/mechanical/retrieval/dormant roles were reconsidered for a sonnet downgrade and **kept opus** — decision record: `docs/voyage-vs-cc-balance-analysis.md` §10.
> **Inventory (S33 reconcile).** 24 agent files = **21 spawnable** (one, `synthesis-agent`, ships **dormant** — Δ≈0, wired to nothing) **+ 3 orchestrator reference docs** (`planning-/research-/review-orchestrator` document the inline `/trek*` workflow, not spawnable capabilities). All 24 stay `model: opus` (operator pin `40d8742`); the glue/mechanical/retrieval/dormant roles were reconsidered for a sonnet downgrade and **kept opus** — decision record: `docs/voyage-vs-cc-balance-analysis.md` §10.
> **Model & effort.** `opus` = Opus 4.8 (default reasoning effort `high`); `sonnet` = Sonnet 4.6; `fable` = Fable 5 (Mythos-class, above Opus — reasoning effort inherits from the session; xhigh requires a session-level setting). Select agents carry native per-spawn `effort:` (retrieval → `medium`, adversarial-reasoning → `high`) — a different axis from brief `phase_signals.effort` (orchestration shape: which agents/passes run). Per-agent table + axes → `docs/profiles.md` §Model & effort axes.

131
GOVERNANCE.md Normal file
View file

@ -0,0 +1,131 @@
# 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`](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the fork-and-own model.
path — see `GOVERNANCE.md` for the fork-and-own model.
Prior version migration notes (v1→v2, v2→v3) are preserved in
`CHANGELOG.md` only.

112
README.md
View file

@ -1,17 +1,17 @@
# voyage
# trekplan — Brief, Research, Plan, Execute, Review, Continue
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.1-blue)
![Version](https://img.shields.io/badge/version-5.9.1-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](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/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](GOVERNANCE.md) for the full model and what upstream provides.
*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.*
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
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,23 +21,6 @@ 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`.
@ -85,6 +68,9 @@ 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
@ -141,10 +127,7 @@ 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.
## Non-goals
What this pipeline does **not** solve — read this before adopting it:
**What it doesn't solve:**
- 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,7 +178,7 @@ If you say "stop" or "enough" during Phase 4, the current review findings are su
Deep, multi-phase research that combines local codebase analysis with external knowledge. Uses specialized agent swarms to investigate multiple dimensions in parallel, then triangulates findings.
A parallel swarm of up to 5 local + 4 external agents investigates 38 research dimensions. Findings are triangulated (local vs. external, confidence per dimension, contradictions flagged) and synthesized into a structured research brief.
A parallel swarm of up to 5 local + 4 external agents investigates 38 research dimensions, with optional Gemini Deep Research as an independent second opinion. Findings are triangulated (local vs. external, confidence per dimension, contradictions flagged) and synthesized into a structured research brief.
Output:
- With `--project <dir>`: `{dir}/research/{NN}-{slug}.md` (auto-incremented index)
@ -205,36 +188,18 @@ Output:
| Mode | Usage | Behavior |
|------|-------|----------|
| **Default** | `/trekresearch <question>` | Interview + research swarm (local + external), foreground |
| **Default** | `/trekresearch <question>` | Interview + research swarm (local + external + Gemini), foreground |
| **Project** | `/trekresearch --project <dir> <question>` | Write brief into `{dir}/research/NN-slug.md` |
| **Quick** | `/trekresearch --quick <question>` | Interview (short) + inline research, no agent swarm |
| **Local** | `/trekresearch --local <question>` | Only codebase analysis agents (skip external research) |
| **Local** | `/trekresearch --local <question>` | Only codebase analysis agents (skip external + Gemini) |
| **External** | `/trekresearch --external <question>` | Only external research agents (skip codebase analysis) |
| **Foreground** | `/trekresearch --fg <question>` | No-op alias (foreground is default since v2.4.0) |
| **Profile** | `/trekresearch --profile <name> <question>` | (v4.1.0) Pin model profile for the research phase. See [Profile system](#profile-system-v410). |
| **Engine** | `/trekresearch --external --engine deep-research <question>` | Delegate the external phase to Claude Code's built-in `/deep-research` workflow. Only works on `2.1.154 <= CC < 2.1.218`; from **2.1.218** `/deep-research` is operator-invoked only (Skill tool: `disable-model-invocation`), so it always falls back to `swarm`. Never hard-fails. Default `swarm`. |
| **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`. |
Flags combine: `--project <dir> --external`.
#### Bounded conversation loop (default-off)
At `effort: high`, research can discover additional dimensions (Phase 4.5) and
run a bounded multi-turn follow-up loop on under-illuminated ones (Phase 5).
Both are **default-off** and env-gated rather than flag-gated, because enabling
them costs turns:
| Env-var | Default | Behavior |
|---------|---------|----------|
| `VOYAGE_STORM_ENABLED` | _(unset — default-off)_ | `=1` gives the Phase 5 loop a non-zero turn budget and lets Phase 4.5 discovery run. Unset, the budget is 0 and **both** phases are inert. |
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Turns per under-illuminated dimension. Invalid values fall back to `3`, never to unbounded. |
| `VOYAGE_DISABLE_CAP_HOOK` | _(unset)_ | `=1` disables the `PreToolUse` hook that enforces the turn budget. |
The cap counts turns itself from an append-only ledger — it never asks the loop
how many turns it has used. Whether the loop becomes the default is decided by a
pre-registered measurement, not by preference: see
[`docs/storm-measurement.md`](docs/storm-measurement.md).
Research uses up to 5 local agents (architecture-mapper, dependency-tracer, task-finder, git-historian, convention-scanner) and 4 external agents (docs-researcher, community-researcher, security-researcher, contrarian-researcher). Per-agent details in [`agents/`](agents/).
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/).
---
@ -657,7 +622,7 @@ flowchart TB
end
subgraph RES["/trekresearch · Phase 4 — parallel"]
RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner*"]
RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher* · contrarian-researcher*"]
RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher* · contrarian-researcher* · gemini-bridge*"]
end
subgraph PL["/trekplan"]
PLG["Phase 4b · brief-reviewer (gate)"]
@ -676,7 +641,7 @@ flowchart TB
BR --> RES --> PL --> EX --> RV
```
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; security-/contrarian-researcher when a leading recommendation forms (or always at `effort=high`); brief-conformance-reviewer skipped under `--quick`.
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; security-/contrarian-researcher + gemini-bridge when a leading recommendation forms (or always at `effort=high`); brief-conformance-reviewer skipped under `--quick`.
> **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator, dormant synthesis-agent)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix).
@ -792,6 +757,26 @@ 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.
@ -837,6 +822,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
## Architecture
@ -844,11 +830,11 @@ Top-level layout:
```
trekplan/
├── agents/ 23 specialized agents (all `model: opus`-pinned; per-phase model set by `--profile`)
│ └ 20 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned)
├── agents/ 24 specialized agents (all `model: opus`-pinned; per-phase model set by `--profile`)
│ └ 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/ 8 hooks (pre-bash, pre-write, pre-agent-cap, session-title, post-bash-stats, pre-compact-flush, post-compact-flush, otel-export)
├── hooks/ 7 hooks (pre-bash, pre-write, 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
@ -904,8 +890,8 @@ just run `--profile economy`, to put the whole pipeline on Sonnet.
### Disable external research
`/trekresearch --local` skips Tavily and Microsoft Learn. To make
`--local` the default, edit the front of
`/trekresearch --local` skips Tavily, Microsoft Learn, and the
Gemini bridge. To make `--local` the default, edit the front of
`commands/trekresearch.md` Phase 1 and flip the default branch
of the `--local` argument check. Or just always pass `--local` and
document it in your team's CLAUDE.md.
@ -935,18 +921,6 @@ 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,14 +2,13 @@
## Reporting a vulnerability
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.
Open a **private** issue on Forgejo:
If you already have an account on the Forgejo instance, you may instead
open a **private** issue on the repo, tagged `security`:
> https://git.fromaitochitta.com/open/ktg-plugin-marketplace
> https://git.fromaitochitta.com/open/voyage
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.
## Supported versions

151
agents/gemini-bridge.md Normal file
View file

@ -0,0 +1,151 @@
---
name: gemini-bridge
description: |
Use this agent when an independent second opinion from Gemini Deep Research is
needed on a technology choice, architectural question, or complex research topic.
Provides triangulation value by running a completely independent research path
that can confirm or challenge findings from other agents.
model: opus
color: magenta
tools: ["mcp__gemini-mcp__gemini_deep_research", "mcp__gemini-mcp__gemini_get_research_status", "mcp__gemini-mcp__gemini_get_research_result", "mcp__gemini-mcp__gemini_research_followup"]
---
You are a bridge to Google Gemini Deep Research. Your role is to obtain an independent,
thorough research result that provides triangulation value — a completely independent
research path that can confirm or challenge findings from other agents.
The value of this agent is INDEPENDENCE. Do not pre-bias Gemini with conclusions from
other agents. Submit the research question cleanly so Gemini's findings stand on their
own merits.
## Workflow
### 1. Check availability
Attempt to call gemini_deep_research. If the tool is not available (MCP server not
connected), return IMMEDIATELY with:
```
## Gemini Bridge Result
**Status:** Unavailable
**Reason:** Gemini MCP server not connected. Proceeding without second opinion.
```
Do NOT error, block, or retry. Unavailability is an expected operational state.
### 2. Formulate query
Take the research question and reformulate it for Gemini to maximize result quality:
- Add context about what dimensions to cover (trade-offs, maturity, ecosystem, operational
concerns, known failure modes, community consensus)
- Use format_instructions to request structured output with clear sections, source citations,
and explicit confidence levels per claim
- Set parameters:
- `research_mode`: "custom"
- `source_tier`: 2
- `research_window_days`: 90
Example format_instructions to include:
> "Structure your response with: Executive Summary, Key Findings (bullet points),
> Trade-offs, Known Issues and Gotchas, Community Consensus, and Sources. For each
> major claim, indicate your confidence level (high/medium/low) and cite the source."
### 3. Submit research
Call `gemini_deep_research` with the reformulated query and parameters.
### 4. Poll for completion
Call `gemini_get_research_status` repeatedly until the research completes:
- Call the status tool, then call it again after it returns — repeat until done
- Do not use bash or sleep commands — use repeated tool calls to simulate waiting
- Continue polling until status is `"completed"` or `"failed"`
- If `"failed"`: report the failure reason and return gracefully — do not retry
- Timeout: if still running after 40 polls (~20 minutes of equivalent wait), report
timeout and return whatever partial result is available
### 5. Retrieve result
Call `gemini_get_research_result` with `include_citations: true`.
### 6. Optional follow-up
If the result has clear gaps on specific dimensions that are directly relevant to the
research question, call `gemini_research_followup` with a targeted follow-up question.
Rules for follow-up:
- Maximum 1 follow-up call
- Only if there is a genuine gap — do not follow up out of habit
- Make the follow-up question narrow and specific, not a re-statement of the original
### 7. Format output
Structure the final result as:
```
## Gemini Bridge Result
**Status:** Completed
**Research duration:** {time taken}
**Sources cited:** {count}
### Key Findings
- {finding 1}
- {finding 2}
- {finding 3}
### Trade-offs and Known Issues
- {trade-off or issue 1}
- {trade-off or issue 2}
### Sources
| # | Source | Relevance |
|---|--------|-----------|
| 1 | {URL} | {one-line relevance} |
### Areas for Triangulation
*Claims that should be cross-checked against local codebase analysis
and other external agents:*
- {claim 1 — check against local architecture}
- {claim 2 — verify with community experience}
- {claim 3 — validate against codebase constraints}
```
## Rules
- **Never block the research pipeline.** If Gemini is slow or unavailable, return what
you have with a clear status note.
- **Do not interpret or editorialize.** Report Gemini's findings as-is, formatted for
integration. Your job is formatting and delivery, not analysis.
- **Flag "Areas for Triangulation"** — claims that the research-orchestrator or other
agents should cross-check against local codebase analysis, team experience, or other
external sources.
- **Independence is the point.** Do not include findings from other agents in your query
to Gemini. The value of a second opinion is that it is uninfluenced by the first.
- **Cite everything.** Every major claim in the output must trace to a source in the
Sources table. Remove claims that Gemini did not support with a source.
- **Graceful degradation at every step.** Unavailable tool, failed research, timeout —
all are handled with a clear status message and immediate return. Never leave the
pipeline hanging.
## When to use — examples
<example>
Context: trekresearch launches gemini-bridge for an independent second opinion on a technology choice
user: "/trekplan Should we use Kafka or NATS for our event streaming layer?"
assistant: "Launching gemini-bridge for an independent second opinion on Kafka vs NATS."
<commentary>
Technology choice with significant architectural implications triggers gemini-bridge
to provide an independent research path alongside local exploration agents.
</commentary>
</example>
<example>
Context: user wants deep research via Gemini on a complex architectural question
user: "Get me a Gemini deep research on event sourcing patterns for distributed systems"
assistant: "I'll use the gemini-bridge agent to run a deep research on event sourcing patterns."
<commentary>
Direct request for Gemini research on a complex architectural question triggers the agent.
</commentary>
</example>

View file

@ -7,21 +7,12 @@ 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 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)
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)
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. -->
@ -61,16 +52,13 @@ Execute these phases in order. Do not skip phases.
Based on the mode, determine which agent groups to launch:
| Mode | Local agents | External agents |
|------|-------------|-----------------|
| `default` | Yes | Yes |
| `local` | Yes | No |
| `external` | No | Yes |
| Mode | Local agents | External agents | Gemini bridge |
|------|-------------|-----------------|---------------|
| `default` | Yes | Yes | Yes (if enabled in settings) |
| `local` | Yes | No | No |
| `external` | No | Yes | Yes (if enabled) |
| `quick` | N/A — handled inline by the command, not the orchestrator |
> **No bridge agent (v5.10.1).** The third-party second-opinion bridge was dropped
> by operator directive; there is no bridge column to select.
**Local agents** (reuse existing plugin agents with research-focused prompts):
| Agent | Purpose in research context |
@ -90,6 +78,12 @@ Based on the mode, determine which agent groups to launch:
| `security-researcher` | CVEs, audit history, supply chain risks |
| `contrarian-researcher` | Counter-evidence, overlooked alternatives, reasons to reconsider |
**Bridge agent:**
| Agent | Purpose |
|-------|---------|
| `gemini-bridge` | Independent second opinion via Gemini Deep Research |
### Phase 2 — Parallel research
Launch ALL selected agents **in parallel** using the Agent tool — one message,
@ -124,6 +118,11 @@ Examples:
Pass the research question, specific dimensions to investigate, and any context from
the interview about what the user already knows or cares about.
**Prompting gemini-bridge:**
Pass the research question as-is. Do NOT pre-bias with findings from other agents —
the value of Gemini is independence.
### Phase 3 — Targeted follow-ups
Review all agent results. Identify knowledge gaps — areas where findings are thin,
@ -187,7 +186,7 @@ When done, your output message should contain:
**Brief:** {brief path}
**Confidence:** {overall confidence 0.0-1.0}
**Dimensions:** {N} researched
**Agents:** {N} local + {N} external
**Agents:** {N} local + {N} external + {gemini status}
### Key Findings
- {Finding 1}
@ -218,7 +217,7 @@ You can:
- **Sources:** Every claim in the brief must cite a source (URL or file path).
Never invent findings.
- **Honesty:** If a question is trivially answerable, say so. Don't inflate research.
- **Graceful degradation:** If MCP tools are unavailable (Tavily, MS Learn), proceed
- **Graceful degradation:** If MCP tools are unavailable (Tavily, Gemini), proceed
with available tools and note the limitation in the brief metadata.
- **Independence:** Do not pre-bias external agents with local findings or vice versa.
The value is in independent perspectives that are THEN triangulated.

View file

@ -84,48 +84,31 @@ identical `(file, rule_key)` and `line == 0` collide.
### Pass 2 — HubSpot Judge filters (3 criteria)
Remove findings that fail ANY of these filters. **The `Removed as` column
is load-bearing** — see *Suppression is two-valued* below:
Drop findings that fail ANY of these filters:
| 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`) |
| 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 |
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
When dropping 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)
Remove findings that fail ANY of these tests:
Drop findings that fail ANY of these tests:
- **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.)*
- **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.
- **Non-existent file.** `file` does not exist in the working tree AND
the diff does not show it as `(new file)`. Use Glob to verify. **This test
has three outcomes, not two:** Glob resolves and the file is absent from
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*.
the diff does not show it as `(new file)`. Use Glob to verify.
- **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, neither a drop nor an unverified.
catalogue tier; this is a correction, not a drop.
In `quick` mode, skip this pass entirely. Note the skip in the
Executive Summary so the reader knows reasonableness was not applied.
@ -142,32 +125,6 @@ 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:
@ -176,19 +133,7 @@ Count findings by severity AFTER dedup and filtering. Verdict thresholds:
|--------|---------|
| `BLOCKER ≥ 1` | `BLOCK` |
| `BLOCKER == 0` AND `MAJOR ≥ 1` | `WARN` |
| `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.
| `BLOCKER == 0` AND `MAJOR == 0` | `ALLOW` |
Verdict is mechanical — never override. The verdict goes into the
trailing JSON block AND the Executive Summary's first sentence.
@ -236,10 +181,8 @@ 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 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`.
8. `## Suppressed Findings` (optional) — one-line per finding dropped by
Pass 2 or Pass 3, with the reason.
9. `## Remediation Summary` — bullet count per severity + 1 sentence on
what /trekplan will consume.
@ -261,7 +204,6 @@ 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>",
@ -301,14 +243,9 @@ 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 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`.
- **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.
- **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

@ -386,8 +386,8 @@ The mapping table is canonical:
- `high → {effort: high, model: opus}` (force opus for the high-confidence path)
- `fable → {effort: high, model: fable}` (force Fable 5 for the max-quality path)
The fable tier reuses `effort: high` semantics — full swarm, contrarian
always-on; `EFFORT_LEVELS` is unchanged (Voyage effort is orchestration
The fable tier reuses `effort: high` semantics — full swarm, contrarian +
gemini always-on; `EFFORT_LEVELS` is unchanged (Voyage effort is orchestration
shape, not model reasoning effort). Model reasoning effort is inherited from
the session: Fable 5's default effort is `high`, NOT xhigh. To run xhigh, the
operator sets it at session level via `/effort xhigh`, the `effortLevel`
@ -507,18 +507,6 @@ 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
@ -825,7 +813,7 @@ topic.
> background orchestrator-agents could not spawn the research swarm —
> before Claude Code 2.1.172 the harness did not expose the Agent tool to
> sub-agents, so a background run silently degraded to single-context
> reasoning without WebSearch / Tavily / WebFetch (see v2.4.0
> reasoning without WebSearch / Tavily / WebFetch / Gemini (see v2.4.0
> release notes). As of CC 2.1.172 sub-agents can spawn sub-agents (up to 5
> levels deep), so a delegated redesign is under evaluation (see
> `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26). Until then,

View file

@ -419,46 +419,19 @@ 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 reaches every worktree
### Check 2 — Plan file is tracked by git
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.
Run `git ls-files --error-unmatch {plan-path} 2>/dev/null`. If the plan file is
untracked (exit code != 0):
```bash
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 add {plan-path}
git commit -m "chore: track plan file for parallel execution"
fi
```
Report by outcome:
Report: `Plan file committed for worktree visibility.`
| `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".
This ensures every worktree created from HEAD will have the plan file.
### Check 3 — Scope fence overlap validation
@ -505,7 +478,7 @@ If cleanup fails, report the manual commands and stop.
After all 4 checks pass:
```
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
Pre-flight: PASS (clean tree, plan tracked, no overlaps, no stale worktrees)
```
## Phase 2.6 — Multi-session orchestration (worktree-isolated)
@ -631,45 +604,27 @@ 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.
# 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
PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"
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/"
if [ -d "$PROJECT_SOURCE/research" ]; then
[ -d "$PROJECT_SOURCE/research" ] && \
cp -r "$PROJECT_SOURCE/research" "$wt/$PROJECT_REL/"
fi
done
```
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`).
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"`.
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.
Failure mode: any `cp` failure exits the wave non-zero; reported via Step 4
cleanup. Source: brief Constraint 2.
**2b. Launch sessions in this wave (each in its own worktree):**

View file

@ -406,18 +406,6 @@ 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.
@ -898,33 +886,22 @@ validator-only; this surface just makes the friendly hint readable.
### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `plan` phase, the
additional adversarial Pass 2 on the post-revision plan is
**unavailable, skipped**. Append the section header to plan.md before
the trailing JSON block with exactly that status, so a reader can tell
a deliberately dropped pass from one that failed:
When `phase_signal_result.effort == 'high'` for the `plan` phase, after
Phase 9 (plan-critic + scope-guardian dedup pass) runs to completion on
the post-revision plan, run an ADDITIONAL `gemini-bridge` plan-review
pass on the post-revision plan. Surface its findings as a separate
`## Adversarial Pass 2 (gemini-bridge, v5.1.1 high-effort)` section
appended to plan.md before the trailing JSON block.
```
## Adversarial Pass 2 (v5.1.1 high-effort)
Rationale (per risk-assessor finding + Decision B substitution
2026-05-14): the originally-considered "extra plan-critic-iterasjon"
risked a revision-loop because plan-critic dedup keys on
`(file, line, rule_key)` triplets and post-revision line numbers shift.
The gemini-bridge pass is independent (different agent, different
perspective) and does not re-tread the same dedup space — it surfaces
genuinely new findings rather than re-emitting closed ones.
**Status:** unavailable, skipped — the third-party bridge agent that
ran this pass was dropped in v5.10.1 by operator directive. No
substitute is wired. Phase 9 (plan-critic + scope-guardian) is the
complete adversarial coverage for this plan.
```
Do NOT fail, retry, or silently omit the section: a high-effort plan
that carries no Pass 2 marker is indistinguishable from one whose Pass
2 crashed.
History (why the slot exists): Decision B, 2026-05-14, substituted a
bridge pass for an "extra plan-critic-iterasjon" because plan-critic
dedup keys on `(file, line, rule_key)` triplets and post-revision line
numbers shift, risking a revision-loop. Dropping the bridge does not
revive that option — re-running plan-critic carries the same loop risk
it always did.
Standard and low effort: do NOT emit the section at all.
Standard and low effort: do NOT run the additional pass.
## Hard rules

View file

@ -32,7 +32,7 @@ Supported flags:
1. `--quick` — lightweight research, no agent swarm. The command itself does
3-5 targeted searches inline. Set **mode = quick**.
2. `--local` — only codebase research. Skip external research agents.
2. `--local` — only codebase research. Skip external agents and gemini bridge.
Set **scope = local**.
3. `--external` — only external research. Skip codebase analysis agents.
@ -87,14 +87,9 @@ 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. **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
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
**engine = {swarm|deep-research}** (the *requested* engine).
Flags can be combined:
@ -121,7 +116,7 @@ Usage: /trekresearch <research question>
Modes:
default Interview → foreground research (local + external) → brief
--quick Interview (short) → inline research (no agent swarm)
--local Only codebase analysis agents (skip external research)
--local Only codebase analysis agents (skip external + Gemini)
--external Only external research agents (skip codebase analysis)
--fg No-op alias (foreground is the only mode as of v2.4.0)
--project Write brief into an trekbrief project folder (auto-indexed)
@ -240,8 +235,8 @@ Then continue to the next phase inline.
> did not expose the Agent tool to sub-agents, so an orchestrator launched
> with `run_in_background: true` could not spawn the documented research
> swarm (`docs-researcher`, `community-researcher`, etc.) and silently
> degraded to single-context reasoning without WebSearch / Tavily / WebFetch.
> As of CC 2.1.172 sub-agents can spawn sub-agents (up to 5 levels
> degraded to single-context reasoning without WebSearch / Tavily / WebFetch
> / Gemini. As of CC 2.1.172 sub-agents can spawn sub-agents (up to 5 levels
> deep), so that block no longer holds — a delegated redesign is under
> evaluation (see `docs/cc-upgrade-2.1.181-decision-matrix.md`, W1/CC-26).
> Until then, running the phases inline in main context keeps the swarm
@ -333,13 +328,9 @@ 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 **both ends** of the supported window
`2.1.154 <= version < 2.1.218` — split each on `.` and compare major, then minor,
numerically against `2.1.154` — 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` (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
multi-digit patch numbers). If the version is `< 2.1.154`, 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
@ -356,18 +347,14 @@ 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, 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.
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.
4. **On no real report (fallback):** set `effective_engine = swarm`, run the swarm
blocks below, and **log the fallback at this decision point** — print
`Engine: deep-research → swarm (fallback: <reason>)` and carry the reason into the
Phase-8 Present summary and the brief's `## Executive Summary`. Known reason
tokens: `disable-model-invocation` (CC >= 2.1.218 — the expected reason on any
current Claude Code), `version-below-floor`, `workflows-disabled`,
`no-cited-report`. **NEVER fabricate
Phase-8 Present summary and the brief's `## Executive Summary`. **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.
@ -391,268 +378,28 @@ Launch the new research-specialized agents:
For each external agent, pass: the research question, specific dimensions to
investigate, and any context from the interview.
### Bridge agent (scope = both or external, if enabled)
Launch `gemini-bridge` with the research question. Do NOT include findings from
other agents — the value of Gemini is independence.
### Launch rules
- 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 — dimensions where findings
are thin, contradictory, or missing (**under-illuminated dimensions**).
Review all agent results. Identify knowledge gaps — areas where findings are
thin, contradictory, or missing.
**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.
For each significant gap, launch a targeted follow-up agent (model: "opus")
with a narrow, specific brief. Maximum 2 follow-ups.
**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.
If no gaps exist, skip: "Initial research sufficient — no follow-ups needed."
## Phase 6 — Triangulation
@ -751,7 +498,7 @@ Present a summary to the user:
**Engine (effective):** {swarm | deep-research}{, with fallback reason if it fell back}
**Confidence:** {overall confidence 0.0-1.0}
**Dimensions:** {N} researched
**Agents:** {N} local + {N} external
**Agents:** {N} local + {N} external + {gemini: used | unavailable | skipped}
### Key Findings
- {Finding 1}
@ -788,36 +535,15 @@ 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": false,
"gemini_used": {true|false},
"confidence": {0.0-1.0},
"contradictions": {N},
"open_questions": {N}
}
```
**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)
@ -887,24 +613,15 @@ commands surface, don't re-enforce.
When `phase_signal_result.effort == 'high'` for the `research` phase,
run the FULL swarm regardless of normal triggering rules: 5 local
agents + 4 external agents, AND force `contrarian-researcher` to
always-on. Normally `contrarian-researcher` triggers conditionally when
a leading recommendation is emerging from initial agents; in
high-effort mode it runs unconditionally so the final brief always
carries an adversarial counter-evidence pass.
The former third-party second-opinion bridge pass is **unavailable,
skipped** (dropped in v5.10.1 by operator directive). High effort no
longer has a bridge arm; `gemini_used` is a vestigial stats field
pinned to `false`.
High effort additionally arms the Phase 5 bounded follow-up loop (max 3
turns per under-illuminated dimension, budgeted by
`research-loop-cap.mjs`, every outbound query gated by
`query-privacy-gate.mjs`). The loop stays default-off until
`VOYAGE_STORM_ENABLED=1`, and the moot matrix in Phase 5 (`--quick`,
`--local`, `external_research_enabled: false`) overrides the effort
signal whenever the external phase does not run at all.
agents + 4 external agents + 1 bridge agent, AND force
`contrarian-researcher` AND `gemini-bridge` to always-on. Normally
`contrarian-researcher` triggers conditionally when a leading
recommendation is emerging from initial agents; in high-effort mode it
runs unconditionally so the final brief always carries an adversarial
counter-evidence pass. Similarly, `gemini-bridge` normally activates on
significant architectural questions or when triangulation value is
high; in high-effort mode it runs unconditionally to provide an
independent second opinion.
Standard effort (or absent): use the existing conditional triggers.
Low effort: inline research only, no agent swarm (existing
@ -917,33 +634,7 @@ 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, MS Learn),
- **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:
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >

View file

@ -199,18 +199,6 @@ 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 |
@ -249,37 +237,6 @@ 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
@ -290,20 +247,10 @@ 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**remove speculative or catalogue-violating
3. **Cloudflare reasonableness**drop 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.

View file

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

@ -135,17 +135,6 @@ reused.
## 6. Cheaper PoC (audit-recommended, preferred over the full bake-off)
> **STATUS: RUN AND DECLINED (S12, 2026-06-18) — do not re-order as new work.**
> Result: **Δ main-context (faithful flow) = 0.0%**, NEGATIVE against the 15% keep-floor.
> Full method, fixtures and BASE-sweep: `docs/T1-synthesis-poc-results.md`;
> reproduce with `node scripts/synthesis-measure.mjs`.
> The finding is **structural, not stochastic**, so re-running it cannot change the number:
> Phase 5 spawns the exploration swarm **foreground** (`commands/trekplan.md:158,338-341`
> "foreground is the only mode as of v2.4.0"), so the 6-10 outputs are **already resident in
> main** before Phase 7 begins. Delegating only the Phase-7 digest returns *on top of* them
> and evicts nothing. A saving is reachable only by also moving Phase-5 delivery off-main —
> which is §5, not §6. `agents/synthesis-agent.md` therefore ships **dormant**.
Per `subagent-delegation-audit.md` §Recommendation, the lowest-risk way to test the delegation
*premise* is **not** a wholesale orchestrator rewrite but **one narrow synthesis-agent**
(intervention #1/#3): delegate only trekplan Phase 7 synthesis (the heaviest single inline read —
@ -180,17 +169,5 @@ promising orchestration-substrate question and is untouched by this.
only matters if a future nested pipeline approaches 5.
2. Classifier behaviour for a *purposeful* swarm under `auto`/`bypassPermissions` is unverified
(F4) — must be in the §5/§6 test matrix before any delegated spawn ships to a headless path.
3. ~~The §6 synthesis-agent PoC and the §5 full bake-off are both designed but unbuilt.~~
**CORRECTED 2026-09-03 (S95).** This item was written in S7 and falsified the same afternoon
by S12: the **§6 PoC was RUN and DECLINED** (`docs/T1-synthesis-poc-results.md` — Δ_faithful
= 0.0%, NEGATIVE). Only the **§5 full bake-off** remains unbuilt. The stale wording is what
caused the §6 PoC to be re-ordered as new work on 2026-09-02; it is struck rather than
deleted so the re-order has a traceable cause.
4. **Environment premise updated (measured 2026-09-03, CC 2.1.259).** S12 recorded the live run
as environment-blocked for want of an `ANTHROPIC_API_KEY`. That half no longer holds:
`claude -p --output-format stream-json` runs on subscription auth with no API key and emits
per-turn `usage` plus a `subagent_stats` block (spawned / max_depth / refused) that did not
exist in June — a ready-made classifier-interference counter for §5's feasibility guard.
S12's *other* blocker stands unmeasured: the installed plugin is the cache copy, so a
repo-local agent is invisible to `claude -p`. This changes nothing about the §6 verdict,
which is structural (see §6 status), and only lowers the cost of §5.
3. The §6 synthesis-agent PoC and the §5 full bake-off are both **designed but unbuilt** — ready
to run if CC-26 is greenlit toward delegation.

View file

@ -1,268 +0,0 @@
# 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,8 +12,6 @@ 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)
@ -33,8 +31,6 @@ 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
@ -96,9 +92,9 @@ 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** | 8 hook scripts: `pre-bash` + `pre-write` guards, `pre-agent-cap` loop-bound enforcement, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — |
| **cross-cutting** | 7 hook scripts: `pre-bash` + `pre-write` guards, `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). Graceful degradation when an MCP server is absent.
¹ 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.
**Legend:** *production* = wired and active · *dormant* = shipped but wired to nothing (`synthesis-agent`) · *opt-in* = behind a flag (`--workflow`) · *not wired* = considered, deferred (delegated orchestrator, the `AskUserQuestion` brief engine).

View file

@ -20,32 +20,16 @@ Always interactive. Phase 3 is a section-driven completeness loop (no hard cap o
| _(default)_ | Interview + research (local + external) + synthesis + brief (foreground) |
| `--project <dir>` | Write brief to `{dir}/research/{NN}-{slug}.md` (auto-incremented) |
| `--quick` | Interview (short) + inline research (no agent swarm) |
| `--local` | Only codebase analysis agents (skip external research) |
| `--local` | Only codebase analysis agents (skip external + Gemini) |
| `--external` | Only external research agents (skip codebase analysis) |
| `--fg` | No-op alias (foreground is default since v2.4.0) |
| `--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, 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`. |
| `--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`. |
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,10 +77,7 @@ 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 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
workflow" / "run a workflow") or via the `ultracode` keyword. 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

@ -1,160 +0,0 @@
# Spike: does a plugin `PreToolUse` hook reach sub-agent tool calls?
**Date:** 2026-08-09
**Claude Code version:** 2.1.226
**Plugin:** voyage 5.9.1 (installed from `ktg-plugin-marketplace`)
**Gates:** Step 10 of `plan.md` (`2026-06-30-trekresearch-storm-upgrade`)
## Question
Step 10 wants to enforce the conversation-turn cap in a `PreToolUse` hook. That
is only viable if a **plugin** `PreToolUse` hook fires on tool calls made
*inside a sub-agent*. If it does not, the cap must be enforced somewhere else
and Step 10 becomes a documented downgrade instead.
The question is genuinely open, not answerable from docs alone: the current
[hooks reference](https://code.claude.com/docs/en/hooks) states that sub-agent
tool calls fire the same hooks and carry `agent_id` / `agent_type`, while
[issue #34692](https://github.com/anthropics/claude-code/issues/34692) reported
the exact opposite behaviour. The answer is therefore version-dependent and had
to be measured on the version actually in use.
## Method
A one-shot probe hook was registered for the `WebSearch` matcher, and a
**headless child session** was launched to exercise it. The child was used
because hooks are resolved when a session starts — a matcher added mid-session
cannot be observed by the session that added it.
Two deviations from the step as originally written, both forced and both
verified not to affect the result:
1. **The matcher was injected into the installed plugin's `hooks.json`, not the
repository's.** The plan assumed the repo working tree *is* the active plugin
root. It is not: `~/.claude/plugins/cache/ktg-plugin-marketplace/voyage/5.9.1/`
is a plain directory holding its own copy, and that copy is what loads.
Editing `hooks/hooks.json` in the repo would have measured nothing. The cache
file was backed up, modified, and restored — verified byte-identical to the
repo file afterwards.
2. **The probe script lives under the session scratchpad, not `${TMPDIR}`.** A
pathguard hook refuses writes to `${TMPDIR}`. The load-bearing property was
only that the script sit **outside `hooks/scripts/`**, which
`tests/lib/doc-consistency.test.mjs:75-84` counts via `readdirSync`; the
scratchpad satisfies that just as well. The directory still holds 7 scripts.
### Probe hook
Logged every invocation as one JSON line (`tool_name`, `agent_id`,
`agent_type`, plus the untouched stdin) and always exited `0`, so it could not
alter the child's behaviour.
### Commands
```bash
# 1. inject the temporary matcher into the INSTALLED plugin
node -e '...push {matcher:"WebSearch", ...voyage-spike-hook.mjs} into hooks.PreToolUse...'
# 2. exercise it from a fresh child session
claude -p "Spawn exactly one sub-agent via the Agent tool (subagent_type: general-purpose).
Instruct that sub-agent to perform exactly ONE WebSearch for the query
'claude code hooks reference' and report back the first result title.
You MUST NOT call WebSearch yourself in the main context - only the
sub-agent may call it. When the sub-agent returns, reply with the word DONE." \
--allowedTools "Agent,Task,WebSearch" \
--max-turns 15
# 3. restore
cp "${TMPDIR}voyage-hooks-backup.json" <cache>/hooks/hooks.json
```
The child returned `DONE`.
> An earlier attempt additionally passed `--permission-mode bypassPermissions`
> and was refused by the auto-mode classifier. The flag was dropped;
> `--allowedTools` alone was sufficient.
## Raw observation
The log contains **exactly one** record — so the main context did not call
`WebSearch` itself, and the single entry is unambiguously the sub-agent's call:
```json
{"at":"2026-08-09T12:07:47.796Z","tool_name":"WebSearch",
"agent_id":"aa6d19525a4680fe0","agent_type":"general-purpose",
"raw_stdin":"{\"session_id\":\"b126fd6a-...\",\"cwd\":\"/Users/ktg/repos/ktg-plugin-marketplace/voyage\",
\"permission_mode\":\"auto\",\"agent_id\":\"aa6d19525a4680fe0\",\"agent_type\":\"general-purpose\",
\"effort\":{\"level\":\"xhigh\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"WebSearch\",
\"tool_input\":{\"query\":\"claude code hooks reference\"},\"tool_use_id\":\"toolu_01Ka4...\"}"}
```
Both `agent_id` and `agent_type` are populated, matching the documented
common-input fields for sub-agent-originated tool events. A main-context call
would have carried neither.
## Consequence for Step 10
A plugin `PreToolUse` hook **does** observe sub-agent tool calls on CC 2.1.226,
and can attribute them via `agent_id` / `agent_type`. Step 10 may therefore take
the enforcement branch rather than the documented-downgrade branch.
Two limits worth carrying forward, neither of which changes the verdict:
- This measures `WebSearch` on one CC version. The behaviour regressed once
before (#34692), so the hook must fail **open**, never assume it is the only
gate, and the cap must remain correct if the hook silently stops firing.
- The probe only establishes *reach*. Whether a **blocking** (exit 2) decision
from inside a sub-agent propagates usefully was not measured — the probe
always exited 0 by design.
RESULT: FIRES
## Enforcement outcome
Step 10 took the **enforcement branch**: `hooks/scripts/pre-agent-cap.mjs`
(PreToolUse, matcher `WebSearch|WebFetch|Task`), pinned by
`tests/hooks/agent-cap.test.mjs`.
What it does: counts turns spent by a run — read-only, from the append-only
ledger that `lib/util/research-loop-cap.mjs` writes — and exits 2 once
`turns_used >= max_conv_turns × maxDimensions`. It never appends to the
ledger; a cap that recorded its own enforcement would count itself.
Scope key, the part that makes a globally-wired `PreToolUse` hook safe:
`session_id` **+** a marker file only the Phase 5 loop writes, at
`${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json`:
```json
{ "runId": "<run id>", "startedAt": "<ISO-8601>" }
```
No marker for the calling session ⇒ out of scope ⇒ allow, unconditionally.
An unrelated session is never denied because some other run spent its budget.
Fail-open and fail-closed are split deliberately:
| Condition | Outcome | Why |
|---|---|---|
| No marker / no `session_id` / unparsable stdin | allow | Not evidence of a loop turn |
| Marker older than TTL (default 2h, `VOYAGE_CAP_SCOPE_TTL_MS`) | allow + auto-reset | A crashed run must not deny tool calls forever, and `--resume` keeps the same `session_id` |
| `VOYAGE_DISABLE_CAP_HOOK=1` | allow | Kill switch |
| `VOYAGE_STORM_ENABLED``1` | allow | Default-off: no loop runs, nothing to enforce |
| In scope, `CLAUDE_PLUGIN_DATA` absent | **deny** | A budget control that cannot count must not grant — same stance as `research-loop-cap.mjs` |
| In scope, budget spent | **deny (exit 2)** | The bound |
Both limits recorded above still hold and are not closed by this step. Reach
was measured on one CC version for one tool, and blocking-propagation from
inside a sub-agent was never measured — so this hook is **defence in depth**,
and `research-loop-cap.mjs` must remain correct on its own if the hook
silently stops firing.
**Follow-up, closed (S79).** Step 10 shipped the hook correct but **latent**
nothing wrote the marker, and it enforces exactly when a marker exists.
`commands/trekresearch.md` Phase 5 now writes it (`### Loop scope marker`,
keyed by `CLAUDE_CODE_SESSION_ID`, carrying the same `run_id` the ledger is
counted under) and removes it on all three exits. Cleanup covers exits only; a
crashed session is covered by the TTL above. Verified end-to-end: the snippet
as shipped arms the hook, the hook denies at `8/8` turns, and the removal
snippet returns it to allow so Phase 6 can still spawn agents. Pinned by
`tests/lib/doc-consistency.test.mjs` (STORM marker), which derives the
directory name from `SCOPE_DIRNAME` in the hook, so renaming either side
fails.

View file

@ -1,131 +0,0 @@
# STORM adoption gate — pre-registered measurement protocol
**Status:** thresholds registered, **no measurement run yet.** This document is
committed *before* the first measurement by design: a threshold chosen after
seeing the numbers is not a threshold. **Harness:** `scripts/storm-measure.mjs`
(pinned by `tests/scripts/storm-measure.test.mjs`). **Decides:** whether the
bounded Phase 5 conversation loop (`commands/trekresearch.md` §Loop bound) is
worth turning on by default.
---
## 1. What this gate measures — and what it does not
The gate measures **source and coverage breadth**:
| Metric | Definition | Arm |
|---|---|---|
| `unique_sources` | distinct external sources cited by the run | between-arm median gain |
| dimensions over baseline | `(dimensions dimensions_baseline) / dimensions_baseline` | within-run median across the treatment arm |
It does **not** measure outline quality, answer correctness, synthesis
usefulness, or operator satisfaction. A breadth win is a *necessary* condition
for adoption, never a sufficient one. If the loop widens coverage by 40% and
the resulting briefs read worse, the correct action is still decline — the
number does not overrule a reading of the artifacts.
The second metric is a within-run delta by construction (`dimensions_baseline`
is the interview dimension count, `dimensions` the post-Phase-4.5 list), so it
needs no control arm; the control arm's value is 0 because the loop is inert
below `effort: high`.
## 2. Pre-registered thresholds
Either metric clearing the bar is enough. The brief pre-registers "median
forbedring ≥ 30 % på (a) eller (b) → adopt. < 15 % → decline" — a breadth win
on one axis counts, because either axis widening is the effect the loop claims.
Adopt is evaluated first, so a run that clears the adopt bar on one metric is
an adopt even when the other metric sits under the decline bar.
| Median gain | Verdict | Action |
|---|---|---|
| ≥ 30% on **either** metric | **adopt** | Flip the `VOYAGE_STORM_ENABLED` default (see §5) |
| < 15% on **either** metric (and no adopt) | **decline** | Leave the mechanism default-off. This is a **no-op**: nothing is rolled back |
| both metrics in 15% 30% | **inconclusive** | Keep default-off, gather more runs, re-measure |
| either arm empty | **insufficient-data** | Not a decline — measure more |
`ADOPT_THRESHOLD = 0.30` and `DECLINE_THRESHOLD = 0.15` are exported constants
in `scripts/storm-measure.mjs` and pinned by the test suite. Changing them is a
deliberate, reviewable act, not a tuning knob to be nudged toward a result.
## 3. Excluded runs (the honest denominator)
Runs with `empty_turns > 0` are **excluded from the gain and reported as a
count**. An empty turn is one that spent budget and returned no findings, or
findings without citations. A run whose `empty_turns` cannot be **read** as a
number is excluded on the same footing: a field that says `"many"` is not
evidence of zero empty turns, and treating it as one would let the least
trustworthy run back into the denominator in the direction that flatters
adoption. Including those runs decides adoption on a broken
denominator — the loop looks cheap because its failures are averaged into its
successes. The harness prints the excluded count on every invocation; if that
count is a large fraction of the treatment arm, the finding is about the loop's
reliability, and it should be read before the gain figure is read at all.
Rows predating the Step 9 measurement fields carry no `effort` and are dropped
as `legacy` with a count. A stats file where *no* row carries `effort` is a hard
error, not an empty treatment group: a schema gap must never present itself as
"no gain".
## 4. The measurement runs (operator-run, outside this plan)
The measurement itself is **not** part of the implementation plan that built
this harness. It is an operator-run gate between that plan and any adopt commit.
Protocol:
- **n ≥ 5 runs per arm.** Fewer, and the median is an anecdote.
- **The same question set in both arms.** Two briefs, run as `--project` runs.
- The arms differ in exactly one thing: whether the loop is enabled.
- Both arms append to the same `trekresearch-stats.jsonl`; `effort` is the
grouping key that separates them.
```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,16 +20,6 @@
"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

@ -1,211 +0,0 @@
#!/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,16 +105,7 @@ const BLOCK_RULES = [
// --- Executor-specific additions ---
{
name: 'System shutdown/reboot',
// 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/,
pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/,
description: 'System shutdown/reboot commands are blocked during execution.',
},
{
@ -207,59 +198,6 @@ 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
// ---------------------------------------------------------------------------
@ -281,11 +219,10 @@ 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(rule.commandView ? commandView : normalized)) {
if (rule.pattern.test(normalized)) {
process.stderr.write(
`[voyage] BLOCKED: ${rule.name}\n` +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +

View file

@ -25,27 +25,11 @@ 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', 'engine', 'dimensions', 'agents_local',
'ts', 'slug', 'mode', 'scope', '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,10 +19,8 @@
// 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 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.
// (citation / unknown-rule_key drop, severity-mismatch correction), Pass 4
// verdict thresholds. No LLM, no network, no time, no randomness.
//
// Reuses: SEVERITY_VALUES / RULE_KEYS / getRule (rule-catalogue.mjs),
// computeFindingId (finding-id.mjs, triplet), validateFindings
@ -37,71 +35,6 @@ 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.
@ -189,18 +122,12 @@ 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[], unverified: object[] }}
* @returns {{ kept: object[], dropped: 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;
@ -211,38 +138,31 @@ export function judgeFilter(findings) {
(typeof f.recommended_action !== 'string' || f.recommended_action.trim().length === 0)) {
reason = 'actionability:empty';
}
if (reason) suppress(f, reason, dropped, unverified);
if (reason) dropped.push({ ...f, suppressed_reason: reason });
else kept.push(f);
}
return { kept, dropped, unverified };
return { kept, dropped };
}
/**
* 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) 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`.
* The fs file-existence glob is excluded (I/O).
* @param {object[]} findings
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
* @returns {{ kept: object[], dropped: 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)) {
suppress(f, 'no-citation', dropped, unverified);
dropped.push({ ...f, suppressed_reason: 'no-citation' });
continue;
}
if (!RULE_KEYS.has(f.rule_key)) {
suppress(f, 'unknown-rule_key', dropped, unverified);
dropped.push({ ...f, suppressed_reason: 'unknown-rule_key' });
continue;
}
const rule = getRule(f.rule_key);
@ -252,114 +172,44 @@ export function reasonablenessFilter(findings) {
kept.push(f);
}
}
return { kept, dropped, unverified };
return { kept, dropped };
}
/**
* 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
* @param {{ unverified?: object[], missingReviewers?: string[], unattributablePayloads?: number }} [options]
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number>, allow_blocked_by: string[] }}
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number> }}
*/
export function computeVerdict(findings, options = {}) {
export function computeVerdict(findings) {
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, allow_blocked_by };
return { verdict, counts };
}
/**
* 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
* @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[] }}
* @returns {{ verdict: string, counts: Record<string, number>, findings: object[], suppressed: object[], skipped: object[] }}
*/
export function runContract(reviewerPayloads, options = {}) {
export function runContract(reviewerPayloads) {
const { findings: ingested, skipped } = ingest(reviewerPayloads);
const deduped = dedupByTriplet(ingested);
const judged = judgeFilter(deduped);
const reasoned = reasonablenessFilter(judged.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,
});
const { verdict, counts } = computeVerdict(reasoned.kept);
return {
verdict,
counts,
findings: reasoned.kept,
suppressed: [...judged.dropped, ...judged.unverified, ...reasoned.dropped, ...reasoned.unverified],
unverified,
suppressed: [...judged.dropped, ...reasoned.dropped],
skipped,
missing_reviewers,
unattributable_payloads,
allow_blocked_by,
};
}

View file

@ -1,350 +0,0 @@
// lib/util/research-loop-cap.mjs
// Stateful, default-off cost cap for the /trekresearch bounded conversation
// loop (Phase 4.5 dimension discovery + Phase 5 loop turns).
//
// Three properties the plan review required:
// (a) Default-off — VOYAGE_STORM_ENABLED must be '1'; otherwise the budget
// is 0 regardless of effort. This IS the decline branch: doing nothing
// leaves the mechanism off, and adopt is flipping this one constant.
// (b) The cap counts itself — allowTurn() derives used-turn count from an
// append-only JSONL ledger, never from a caller-supplied number. A cap
// that asks the caller how many turns it has used is not a cap. Each
// grant additionally claims a turn SLOT with O_EXCL, so the bound holds
// when several callers decide at once instead of only when they queue.
// (c) Correct size bound — worst case is max_conv_turns × max_total_dimensions,
// where max_total_dimensions is the WHOLE list (interview + discovered)
// under 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

@ -1,119 +0,0 @@
// 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.10.0",
"version": "5.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voyage",
"version": "5.10.0",
"version": "5.9.1",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -1,7 +1,6 @@
{
"name": "voyage",
"version": "5.10.1",
"private": true,
"version": "5.9.1",
"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": {

View file

@ -1,342 +0,0 @@
#!/usr/bin/env node
// scripts/storm-measure.mjs
// Step 11 — the STORM adoption gate: deterministic Δ accounting over
// ${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl.
//
// What this decides: whether the bounded Phase 5 conversation loop buys enough
// extra source/coverage breadth to justify flipping VOYAGE_STORM_ENABLED on by
// default in lib/util/research-loop-cap.mjs. Nothing else. The thresholds live
// in docs/storm-measurement.md and were committed BEFORE any measurement run —
// that pre-registration is the whole point, so this script never invents them.
//
// What this does NOT measure: outline quality, answer correctness, or operator
// satisfaction. It measures breadth (distinct sources, dimensions covered).
// A breadth win is necessary for adoption, not sufficient on its own.
//
// Honesty properties, both load-bearing:
// - Runs with empty_turns > 0 are EXCLUDED from the gain and REPORTED. An
// empty turn means the loop spent budget and returned nothing; leaving those
// in decides adoption on a broken denominator.
// - A stats file carrying no `effort` field is a loud error. The silent
// failure this prevents is an empty treatment group reading as "no gain",
// which would decline the mechanism for a schema reason.
//
// Zero deps. Node stdlib only.
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
// 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

@ -14,6 +14,11 @@
"trekresearch": {
"defaultMode": "default",
"maxDimensions": 8,
"geminiBridge": {
"enabled": true,
"pollIntervalSeconds": 30,
"timeoutMinutes": 25
},
"interview": {
"maxQuestions": 4,
"typicalQuestions": 3

View file

@ -74,6 +74,15 @@ findings exist.*
### Known Issues
{Common pitfalls, gotchas, real-world problems from community-researcher}
## Gemini Second Opinion
*Independent research result from Gemini Deep Research. Provides a second
perspective for triangulation. Omit this section if gemini-bridge was not used
or was unavailable.*
{Gemini findings reformatted into key findings, sources cited, and areas of
agreement/disagreement with other agents.}
## Synthesis
*Cross-cutting insights that emerge from combining local and external knowledge.
@ -105,7 +114,7 @@ this section entirely.*
| # | Source | Type | Quality | Used in |
|---|--------|------|---------|---------|
| 1 | {URL or codebase path} | {official / community / codebase} | {high / medium / low} | {dimension name} |
| 1 | {URL or codebase path} | {official / community / codebase / gemini} | {high / medium / low} | {dimension name} |
*Quality assessment:*
- **high** — official documentation, verified codebase analysis, peer-reviewed

View file

@ -129,7 +129,7 @@ the codebase and this brief contain sufficient context for planning."}
- **Required for plan steps:** {which kinds of steps will consume this — e.g.,
"migration strategy", "library selection", "threat model"}
- **Confidence needed:** {high | medium | low}
- **Estimated cost:** {quick — inline research | standard — agent swarm | deep — with contrarian}
- **Estimated cost:** {quick — inline research | standard — agent swarm | deep — with contrarian + gemini}
- **Scope hint:** {local | external | both}
### Topic 2: {Short title}

View file

@ -1,349 +0,0 @@
// 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,277 +34,10 @@ test('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNAL
test('trekresearch — low-effort path references --quick equivalent', () => {
const text = read();
// Bound the Composition rule section by the next `###` heading rather than a
// magic 2000-character window: a fixed count silently drops the match as soon
// as prose is inserted above it, turning a real pin into a no-op.
const sectionOf = (doc) => {
const compIdx = doc.indexOf('## Composition rule (v5.1)');
const compIdx = text.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const nextHeading = doc.indexOf('\n### ', compIdx);
return nextHeading > compIdx ? doc.slice(compIdx, nextHeading) : doc.slice(compIdx);
};
// (a) positive: the low-effort path is documented inside the bounded section.
assert.match(sectionOf(text), /--quick/, 'Low-effort path must mention --quick equivalent');
// (b) negative: an actual removal must still be caught — a bound that can
// never fail proves nothing.
const mutated = text.replace(/--quick/g, '--removed');
assert.doesNotMatch(sectionOf(mutated), /--quick/,
'heading-bounded slice must still fail on a genuine removal');
});
// --- Step 7: Phase 5 bounded conversation loop (heading-bounded slices) ---
// Same bounding discipline as the Composition-rule pin above: slice from the
// phase heading to the NEXT phase heading, never a fixed character window.
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');
const section = text.slice(compIdx, compIdx + 2000);
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
});
// --- v5.1.1 runtime SC4 + SC7 ---

View file

@ -33,31 +33,19 @@ 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",effort="high",profile="premium",profile_source="default"} 3
voyage_trekresearch_agents_external{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 3
# HELP voyage_trekresearch_agents_local voyage stats — trekresearch_agents_local
# TYPE voyage_trekresearch_agents_local gauge
voyage_trekresearch_agents_local{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 5
voyage_trekresearch_agents_local{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 5
# HELP voyage_trekresearch_contradictions voyage stats — trekresearch_contradictions
# TYPE voyage_trekresearch_contradictions gauge
voyage_trekresearch_contradictions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 1
# HELP voyage_trekresearch_conv_turns voyage stats — trekresearch_conv_turns
# TYPE voyage_trekresearch_conv_turns gauge
voyage_trekresearch_conv_turns{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 5
voyage_trekresearch_contradictions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 1
# HELP voyage_trekresearch_dimensions voyage stats — trekresearch_dimensions
# TYPE voyage_trekresearch_dimensions gauge
voyage_trekresearch_dimensions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 4
# HELP voyage_trekresearch_dimensions_baseline voyage stats — trekresearch_dimensions_baseline
# TYPE voyage_trekresearch_dimensions_baseline gauge
voyage_trekresearch_dimensions_baseline{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 3
# HELP voyage_trekresearch_empty_turns voyage stats — trekresearch_empty_turns
# TYPE voyage_trekresearch_empty_turns gauge
voyage_trekresearch_empty_turns{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 1
voyage_trekresearch_dimensions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 4
# HELP voyage_trekresearch_open_questions voyage stats — trekresearch_open_questions
# TYPE voyage_trekresearch_open_questions gauge
voyage_trekresearch_open_questions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 2
# HELP voyage_trekresearch_unique_sources voyage stats — trekresearch_unique_sources
# TYPE voyage_trekresearch_unique_sources gauge
voyage_trekresearch_unique_sources{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 17
voyage_trekresearch_open_questions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 2
# 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, 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 |
| 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 |
| 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,"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"}
{"_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"}

View file

@ -1,556 +0,0 @@
// 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,100 +129,6 @@ 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,25 +31,6 @@ 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,7 +14,6 @@ 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 -------------------------------------
@ -279,43 +278,6 @@ 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

@ -152,18 +152,14 @@ test('no agents/*.md frontmatter contains an <example> block (M4: examples live
}
});
// Floor lowered 34 -> 32 in v5.10.1: `gemini-bridge` (2 examples) was DELETED as an
// agent by operator directive, not relocated. The pin guards M4's "relocation moves,
// never deletes" invariant, so it must move only when an agent legitimately leaves the
// inventory — never to absorb examples that went missing from a surviving agent.
test('agent bodies retain at least 32 <example> blocks (M4: relocation moves, never deletes)', () => {
test('agent bodies retain at least 34 <example> blocks (M4: relocation moves, never deletes)', () => {
let total = 0;
for (const f of agentFiles) {
total += (bodyOf(read(`agents/${f}`)).match(/<example>/g) || []).length;
}
assert.ok(
total >= 32,
`expected >= 32 <example> blocks across agent bodies (16 agents x 2), got ${total} ` +
total >= 34,
`expected >= 34 <example> blocks across agent bodies (17 agents x 2), got ${total} ` +
`— examples may have been deleted instead of relocated (M4)`,
);
});

View file

@ -6,9 +6,6 @@
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,
@ -16,14 +13,9 @@ 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', () => {
@ -106,41 +98,29 @@ test('computeVerdict — counts each severity tier', () => {
// ---- Pass 3 — reasonableness -----------------------------------------------
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.
test('reasonablenessFilter — drops unknown rule_key + citation-less, corrects severity mismatch', () => {
const r = reasonablenessFilter([
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → unverified
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → drop
{ 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, 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.dropped.length, 3);
assert.equal(r.kept[0].severity, 'MAJOR');
assert.equal(r.kept[0].original_severity, 'MINOR');
});
// ---- Pass 2 — judge --------------------------------------------------------
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.
test('judgeFilter — drops over-long title and empty recommended_action', () => {
const j = judgeFilter([
{ 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: 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: 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, 0);
assert.equal(j.unverified.length, 2);
assert.deepEqual(j.unverified.map((f) => f.suppressed_reason), ['succinctness:title', 'actionability:empty']);
assert.equal(j.dropped.length, 2);
});
// ---- ingest ----------------------------------------------------------------
@ -181,155 +161,3 @@ 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,7 +26,6 @@ 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, '..', '..');
@ -701,23 +700,6 @@ 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(
@ -979,31 +961,6 @@ 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
@ -1134,289 +1091,6 @@ 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.
@ -1650,48 +1324,6 @@ 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')) {
@ -1702,94 +1334,3 @@ test('v5.9: no commands/*.md frontmatter carries a model: key (session inheritan
assert.deepEqual(offenders, [],
`command frontmatter must omit model: (orchestrator follows the session model); offenders: ${offenders.join(', ')}`);
});
// --- S95 (③): gemini-bridge is DROPPED from the pipeline (operator directive) ---
//
// Operator directive, three repetitions: gemini-mcp and voyage:gemini-bridge are
// never to be used. The drop is permanent, not a wait-for-upgrade — so the pin is
// on the LIVE pipeline surfaces only. Historical records (CHANGELOG entries,
// measurement/decision docs) must KEEP their references: they say what a past
// version did or what was measured then, and rewriting them is the same defect
// class as bumping a version string inside a measurement doc.
//
// `gemini_used` is deliberately NOT banned. It is a vestigial trekresearch stats
// field, pinned to false by the drop and kept so the observability export schema
// does not break for existing consumers (see TREKRESEARCH_ALLOWED).
// Live = anything that can still steer a run. Explicit, so a new file forces a decision.
const GEMINI_LIVE_SURFACES = [
'README.md',
'CLAUDE.md',
'settings.json',
'docs/architecture.md',
'docs/command-modes.md',
'templates/research-brief-template.md',
'templates/trekbrief-template.md',
'tests/fixtures/jsonl-schemas.md',
...listMd('commands').map((f) => `commands/${f}`),
...listMd('agents').map((f) => `agents/${f}`),
];
// Historical/measurement records that MUST keep their gemini references.
const GEMINI_HISTORICAL = [
'CHANGELOG.md',
'docs/cc-upgrade-2.1.181-decision-matrix.md',
'docs/voyage-vs-cc-balance-analysis.md',
'docs/BRIEF-vurdering-v2.md',
'docs/balance-backlog-plan.md',
'docs/agent-description-token-trim-brief.md',
'docs/S22-happy-path-dogfood.md',
'docs/deep-research-engine-brief.md',
'examples/02-real-cli/REGENERATED.md',
];
test('S95: agents/gemini-bridge.md is removed from the agent inventory', () => {
assert.ok(
!listMd('agents').includes('gemini-bridge.md'),
'gemini-bridge is dropped by operator directive — agents/gemini-bridge.md must not exist',
);
});
test('S95: no live pipeline surface still names the gemini-bridge agent', () => {
const offenders = [];
for (const rel of GEMINI_LIVE_SURFACES) {
// Everything gemini EXCEPT the vestigial gemini_used stats field.
const hits = (read(rel).match(/gemini[\w-]*/gi) || []).filter(
(m) => m.toLowerCase() !== 'gemini_used',
);
if (hits.length) offenders.push(`${rel} (${[...new Set(hits)].join(', ')})`);
}
assert.deepEqual(
offenders, [],
`gemini-bridge is dropped — these live surfaces still reference it: ${offenders.join(' · ')}`,
);
});
test('S95: known-positive control — the historical records still DO carry gemini references', () => {
// Verifiseringsloven ansikt 4: a scanner that finds nothing anywhere proves
// nothing. This pins that the same query CAN find, so the empty result above
// is a measurement and not a broken query.
const silent = GEMINI_HISTORICAL.filter((rel) => !/gemini/i.test(read(rel)));
assert.deepEqual(
silent, [],
`these historical records lost their gemini references — history must not be rewritten: ${silent.join(', ')}`,
);
});
test('S95: trekplan high-effort Pass 2 degrades explicitly instead of failing', () => {
const t = read('commands/trekplan.md');
const section = t.split('\n### High-effort behavior (v5.1.1)\n')[1];
assert.ok(section, 'trekplan.md must still carry the High-effort behavior sub-section');
const head = section.split('\n## ')[0];
assert.match(
head, /unavailable, skipped/,
'the dropped Pass 2 must read "unavailable, skipped" explicitly — a removed pass that is silent is indistinguishable from one that failed',
);
});
test('S95: gemini_used survives as a vestigial stats field (export schema unbroken)', () => {
assert.ok(
TREKRESEARCH_ALLOWED.has('gemini_used'),
'gemini_used stays in the trekresearch export allowlist — the agent is dropped, the field is pinned false, and removing it would break existing consumers',
);
});

View file

@ -94,81 +94,6 @@ 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

@ -1,597 +0,0 @@
// 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

@ -1,324 +0,0 @@
// 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

@ -1,174 +0,0 @@
// 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);
});