Compare commits
50 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20cdc22803 | |||
| e2aec019ac | |||
| d650ff3bac | |||
| 4e5523fb0a | |||
| bcd2600918 | |||
| 63e78c5ec0 | |||
| 2ee7b76430 | |||
| cae698a204 | |||
| 9ee120dd7e | |||
| 4f90aa8af0 | |||
| 692a4a526c | |||
| f9ebf08cba | |||
| ec9bbde8f8 | |||
| f79c5f6606 | |||
| 4b261db005 | |||
| bbdfd8af91 | |||
| 74e700af79 | |||
| 84612b2641 | |||
| 22cb7df403 | |||
| 9dfdc4a42f | |||
| f19474acc6 | |||
| 5ba7c64ace | |||
| bee248b71f | |||
| 1d279fb875 | |||
| 869dc9cf4a | |||
| def6c05384 | |||
| 066b9da1a2 | |||
| 6dafdf2a2a | |||
| 2e352a7dbb | |||
| 4c4457f6e9 | |||
| 21a96b9e31 | |||
| fa2404b63c | |||
| cef3e7fa24 | |||
| fce11a1178 | |||
| 9c91211fc0 | |||
| b194630842 | |||
| e9ff8ab023 | |||
| f3874946ad | |||
| d9cba9c6ea | |||
| 3e8af75015 | |||
| 0a569eec55 | |||
| abc5bd8967 | |||
| 0556743bad | |||
| 9a38500a63 | |||
| e1cf545a0c | |||
| 5bb6735c94 | |||
| 9ffeae0e2e | |||
| 2728a43656 | |||
| 79044624db | |||
| 8d39e1d4a5 |
49 changed files with 6204 additions and 291 deletions
|
|
@ -1,12 +1,23 @@
|
|||
{
|
||||
"name": "voyage",
|
||||
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
|
||||
"version": "5.9.0",
|
||||
"version": "5.10.0",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
},
|
||||
"homepage": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace/src/branch/main/plugins/voyage",
|
||||
"homepage": "https://git.fromaitochitta.com/open/voyage",
|
||||
"repository": "https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git",
|
||||
"license": "MIT",
|
||||
"keywords": ["voyage", "trek", "planning", "implementation", "research", "context-engineering", "agents", "adversarial-review", "headless", "execution"]
|
||||
"keywords": [
|
||||
"voyage",
|
||||
"trek",
|
||||
"planning",
|
||||
"implementation",
|
||||
"research",
|
||||
"context-engineering",
|
||||
"agents",
|
||||
"adversarial-review",
|
||||
"headless",
|
||||
"execution"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -42,3 +42,9 @@ NEXT-SESSION-PROMPT*.local.md
|
|||
*.local.sh
|
||||
.DS_Store
|
||||
.claude/
|
||||
|
||||
# Operator working documents dropped into docs/ — never part of the published
|
||||
# plugin. `origin` is a PUBLIC mirror, and S83 committed one of these with a
|
||||
# broad `git add -A docs`, which needed a history rewrite to undo. Ignoring the
|
||||
# type is cheaper than remembering not to stage it.
|
||||
docs/*.pdf
|
||||
|
|
|
|||
157
CHANGELOG.md
157
CHANGELOG.md
|
|
@ -4,6 +4,163 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/trekreview` verdicts are fail-closed.** A finding removed by Pass 2 or
|
||||
Pass 3, and a reviewer whose payload was thrown away or never arrived, were
|
||||
arithmetically identical to a finding that never existed — all three pushed
|
||||
the verdict toward `ALLOW`. Measured before the fix: a BLOCKER with a
|
||||
101-character title → `ALLOW`; a payload carrying one ad-hoc `rule_key` was
|
||||
skipped whole at ingest, taking a valid BLOCKER sibling with it → `ALLOW`.
|
||||
`lib/review/coordinator-contract.mjs` now splits every removal two ways —
|
||||
**dropped** when the test refuted the finding as a claim about this codebase,
|
||||
**unverified** otherwise — and a non-empty `unverified` bucket, or a reviewer
|
||||
in `expectedReviewers` that did not report, forbids `ALLOW` (verdict `WARN`,
|
||||
with `allow_blocked_by` naming why). The rule never *raises* a verdict: the
|
||||
severity catalogue and the BLOCKER/MAJOR thresholds are untouched. New
|
||||
exports: `classifySuppression`, `REFUTING_REASONS`, `UNVERIFIED_REASONS`;
|
||||
`runContract` gains `unverified`, `missing_reviewers`,
|
||||
`unattributable_payloads` and `allow_blocked_by` (`suppressed` stays the
|
||||
union, so existing consumers keep their meaning). A payload that fails schema
|
||||
without carrying a `reviewer` name is counted as unattributable rather than
|
||||
reported as a reviewer called "unnamed reviewer" — naming one would invent an
|
||||
agent nobody launched and double-count with `expectedReviewers`.
|
||||
Mirrored in `agents/review-coordinator.md` (Pass 2/3 fate columns, the new
|
||||
§*Suppression is two-valued*, Pass 4 threshold table) and
|
||||
`commands/trekreview.md` (Phase 5 reviewer accounting → STOP; Phase 6).
|
||||
Driven test-first: 11 new tests in `tests/lib/coordinator-contract.test.mjs`,
|
||||
incl. a known-positive control proving `ALLOW` is still reachable.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`/trekresearch --engine deep-research`: document the real version window.**
|
||||
Claude Code **2.1.218** changed `/deep-research` to start only when the operator
|
||||
invokes it; from there the Skill tool refuses a model invocation outright with
|
||||
`disable-model-invocation` (measured in a real `--engine deep-research` run on
|
||||
2026-09-01 — the SC3 fallback to `swarm` held). The engine was built against
|
||||
2.1.196 and the prose still promised a path Claude Code has removed
|
||||
("requires Claude Code 2.1.154+"). `commands/trekresearch.md` (flag bullet +
|
||||
pre-gate + fallback reason tokens), `docs/command-modes.md` and `README.md` now
|
||||
state the closed window `2.1.154 <= CC < 2.1.218` and name
|
||||
`disable-model-invocation` as the expected fallback reason on any current CC.
|
||||
The pre-gate gained an upper ceiling (still a numeric comparison, not a string
|
||||
one). **The flag is kept** as an additive opt-in that never hard-fails; no
|
||||
`lib/` change, no adapter-contract change, no default change, no version bump.
|
||||
Pinned by `tests/lib/doc-consistency.test.mjs`.
|
||||
|
||||
## v5.10.0 — 2026-08-18 — STORM bounded research loop (default-off) + the Agent-tool `name` spawn defect
|
||||
|
||||
Additive. Every new research mechanism ships **inert**: `VOYAGE_STORM_ENABLED` is
|
||||
unset by default, and with it unset both new `/trekresearch` phases are no-ops.
|
||||
The one change that affects consumers regardless of flags is the spawn rule under
|
||||
*Fixed* — it changes how any repo should call the Agent tool.
|
||||
|
||||
### STORM bounded loop — shipped, default-off, behind a pre-registered adoption gate
|
||||
|
||||
- `/trekresearch` Phase 4.5 (dimension discovery, under the existing
|
||||
`maxDimensions: 8` ceiling) and Phase 5 (bounded multi-turn follow-up,
|
||||
replacing the single follow-up pass) run only at `effort: high` **and** only
|
||||
when `VOYAGE_STORM_ENABLED=1`. Unset, Phase 5 is inert because
|
||||
`lib/util/research-loop-cap.mjs` grants a budget of 0, and Phase 4.5 is inert
|
||||
because its skip-guard reads the flag directly (it never calls the cap) — two
|
||||
independent off-switches, so a bug in one does not silently activate the other.
|
||||
- `TREKRESEARCH_MAX_CONV_TURNS` (default `3`; invalid values fall back to `3`)
|
||||
sets turns per dimension. The total budget is that value × `maxDimensions`.
|
||||
- New `lib/util/research-loop-cap.mjs`: an append-only ledger the cap counts its
|
||||
own turns from. Each turn slot is claimed with `O_EXCL`, so the bound survives
|
||||
concurrent turns; an unreadable ledger **fails closed** in both modules; and
|
||||
the enforcement boundary is a denial tombstone rather than the turn count, so
|
||||
a crash mid-turn cannot hand back a free turn.
|
||||
- New `hooks/scripts/pre-agent-cap.mjs`, registered as a `PreToolUse` hook on
|
||||
`WebSearch|WebFetch|Task`: the budget is enforced by the harness, not by prose
|
||||
in a command file. `VOYAGE_DISABLE_CAP_HOOK=1` switches it off.
|
||||
`docs/spike-pretooluse-subagent-reach.md` records the measured reach of
|
||||
`PreToolUse` into sub-agent tool calls — including where the gap is.
|
||||
- Five measurement fields (`unique_sources`, `dimensions_baseline`, `conv_turns`,
|
||||
`empty_turns`, `dimensions_baseline_preserved`) are emitted to
|
||||
`trekresearch-stats.jsonl` and allowlisted in `lib/exporters/field-allowlist.mjs`.
|
||||
All five are counters or booleans — none carry prose or paths.
|
||||
- `scripts/storm-measure.mjs` + `docs/storm-measurement.md`: the adoption gate,
|
||||
with thresholds registered **before** the first measurement by design.
|
||||
≥ 30 % improvement on either metric → adopt; < 15 % → decline; an empty arm is
|
||||
`insufficient-data`, not a decline. Decline is a no-op — the mechanism simply
|
||||
stays default-off; adopt is one constant. No measurement has been run yet.
|
||||
|
||||
### Added — outbound query privacy gate
|
||||
|
||||
- `lib/validators/query-privacy-gate.mjs` inspects a research query before it
|
||||
leaves the machine, in the same two-tier shape as the existing SSRF gate: a
|
||||
WARN tier (absolute filesystem paths, repo-internal identifiers) that
|
||||
`--soft` / `VOYAGE_QUERY_PRIVACY_ALLOW=1` can override, and a HARD-BLOCK tier
|
||||
(secret-shaped tokens) that **nothing** overrides. Called only from the two new
|
||||
high-effort steps; the existing single-pass path is unchanged.
|
||||
|
||||
### Fixed — the Agent tool's `name` parameter silently breaks a subagent's return channel
|
||||
|
||||
- **Consumers of this pipeline should read `docs/agent-return-channel-defect.md`.**
|
||||
Passing `name` to the Agent tool does not label a subagent — it changes what is
|
||||
spawned: `taskKind: "in_process_teammate"` / `spawnDepth: 0` instead of a real
|
||||
subagent (`spawnDepth: 1`). A teammate's final text is not a return value; it
|
||||
reaches the parent only if the teammate itself calls `SendMessage(to: "main")`.
|
||||
Voyage's reviewer agents carry `tools: [Read, Glob, Grep]` — no `SendMessage` —
|
||||
so when named they are *structurally* unable to answer. No error, no warning.
|
||||
Measured numerators (66-line target): named without explicit `SendMessage`
|
||||
**0/5** returned · named + explicit `SendMessage` **1/1** · unnamed **3/3** ·
|
||||
work actually performed while named **5/5** — only delivery fails, and the
|
||||
output is recoverable from
|
||||
`~/.claude/projects/<project>/<session>/subagents/agent-*.jsonl`.
|
||||
Independently confirmed at 3730 lines / 277 KB by `akashic-intelligence`
|
||||
(4/4 correct final text on disk); the *returning* arm above 66 lines remains
|
||||
inferred rather than observed, and the doc says so.
|
||||
- The four spawning commands (`trekbrief`, `trekplan`, `trekresearch`,
|
||||
`trekreview`) now state the rule at their spawn sites and name the mechanism.
|
||||
`trekexecute` spawns nothing and is excluded. The `doc-consistency` test
|
||||
**derives** the spawning set from the command files, so the pin cannot go
|
||||
vacuous when a command starts or stops spawning.
|
||||
|
||||
### Fixed — hook and validator hardening
|
||||
|
||||
- The destructive-command rule anchored on command position rather than any
|
||||
substring (a path containing a blocked word no longer trips it), and the
|
||||
bypasses that anchoring itself opened are closed.
|
||||
- The secret-detection validator hard-blocks token formats the run-length
|
||||
patterns missed.
|
||||
- The exporter allowlist covers `/trekresearch`'s `engine` field, with the JSONL
|
||||
schema fixture pinned to agree — a new stats field is otherwise dropped
|
||||
silently at export.
|
||||
|
||||
### Docs
|
||||
|
||||
- README: first screen aligned with the org repo standard, complete AI
|
||||
disclosure, and the Gemini MCP tool contract described inline instead of via a
|
||||
dead cookbook link. Governance keeps one canonical file, not a local copy.
|
||||
- `/trekresearch`: STORM mechanisms documented across four surfaces; the
|
||||
Independence crossing's two risks split, each naming its own control.
|
||||
- `docs/*.pdf` is gitignored — `origin` is a public mirror and must never carry
|
||||
operator PDFs.
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite baseline 832 (830 pass / 0 fail / 2 skip) → **1013 (1011 / 0 / 2)**.
|
||||
New coverage: research-loop-cap (including the concurrency and fail-closed
|
||||
paths), the cap hook's crash-time marker branches, the query privacy gate, the
|
||||
measurement harness's decision rule, and the spawn-rule doc pin.
|
||||
|
||||
## v5.9.1 — 2026-07-03 — Fix /trekendsession load-time crash (eager-exec placeholders)
|
||||
|
||||
Patch, no functional additions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/trekendsession` was unusable in every invocation: two of its three `` !`...` `` eager-exec blocks (Phase 3 atomic-write, Phase 4 validator call) contained unresolved runtime placeholders (`<project-dir>` etc.). The harness executes eager-exec blocks at command LOAD time, so zsh parsed `<project-dir>` as input redirection and the command aborted before the model saw a single instruction. Both blocks are now plain runtime Bash fences with the `{curly}` placeholder convention (shell-inert), matching `trekplan.md`/`trekresearch.md`. The Phase 1 discovery block (self-contained) keeps its legitimate eager-exec prefix; `trekcontinue.md`'s discovery block was runtime-verified unaffected.
|
||||
- Latent secondary bug in the same blocks: cwd-relative plugin paths (`lib/validators/...`, `./lib/util/atomic-write.mjs`) would have failed with `ERR_MODULE_NOT_FOUND` even after substitution, since the Bash cwd is the user's repo. Both now use absolute `${CLAUDE_PLUGIN_ROOT}` paths per the existing command convention (Node ESM accepts absolute-path import specifiers — verified on Node 18+).
|
||||
|
||||
### Added
|
||||
|
||||
- Regression guard `tests/commands/trekendsession.test.mjs`: scans every `` !` ``-block in `commands/*.md` for unresolved `<angle>`/`{curly}` placeholders (this bug class is silent until first invocation), plus structure tests pinning Phase 3/4 as runtime Bash with `${CLAUDE_PLUGIN_ROOT}` paths and exactly one surviving eager block. Suite baseline 828 → 832 (830 pass / 0 fail / 2 skip).
|
||||
|
||||
## v5.9.0 — 2026-07-02 — Fable model tier + deep-research engine
|
||||
|
||||
Additive, plus one behavior alignment: profile `phase_models` now reach sub-agent spawn sites (previously documented but never wired), and the seven command orchestrators no longer pin `model: opus` — frontmatter omits `model:`, so the orchestrator follows the session model.
|
||||
|
|
|
|||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -24,6 +24,20 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
|
|||
|
||||
Full flag reference for each command (modes, `--gates`, `--profile`, breaking changes): see `docs/command-modes.md`.
|
||||
|
||||
> **STORM bounded loop — default-off, env-gated.** `/trekresearch` Phase 4.5
|
||||
> (dimension discovery, under the existing `maxDimensions: 8` ceiling) and
|
||||
> Phase 5 (bounded multi-turn follow-up) run only at `effort: high` **and** only
|
||||
> when `VOYAGE_STORM_ENABLED=1`; unset, both are inert — Phase 5 because
|
||||
> `lib/util/research-loop-cap.mjs` grants a budget of 0, Phase 4.5 because its
|
||||
> skip-guard reads the flag directly (it never calls the cap). `TREKRESEARCH_MAX_CONV_TURNS` (default `3`,
|
||||
> invalid values fall back to `3`) sets turns per dimension; the budget is that
|
||||
> × `maxDimensions`. `VOYAGE_DISABLE_CAP_HOOK=1` switches off
|
||||
> `hooks/scripts/pre-agent-cap.mjs`, the `PreToolUse` gate that enforces the
|
||||
> budget in the harness rather than trusting prose. The cap counts turns from
|
||||
> its own append-only ledger. Adoption as a default is gated on the
|
||||
> pre-registered measurement in `docs/storm-measurement.md` — decline is a
|
||||
> no-op, adopt is one constant.
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Model | Role |
|
||||
|
|
|
|||
131
GOVERNANCE.md
131
GOVERNANCE.md
|
|
@ -1,131 +0,0 @@
|
|||
# Governance
|
||||
|
||||
How this marketplace is maintained, what you can expect from upstream, and how it's meant to be used.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Solo-maintained, AI-assisted development, MIT licensed.
|
||||
- **Fork-and-own is the default model.** Upstream is a starting point, not a vendor.
|
||||
- Issues welcome as signals. Pull requests are not accepted — see [Why no PRs](#pull-requests--no).
|
||||
- No SLA. Best-effort bug fixes and security advisories. Breaking changes happen and are noted in each plugin's CHANGELOG.
|
||||
|
||||
---
|
||||
|
||||
## Can I trust this?
|
||||
|
||||
Be honest with yourself about what you're adopting:
|
||||
|
||||
- **One maintainer.** If I get hit by a bus, the bus wins. The repos stay up under MIT, but no one owes you a fix.
|
||||
- **AI-generated code with human review.** Every plugin is built through dialog-driven development with Claude Code. I read, test, and judge the output before it ships, but I'm not auditing every line the way a security firm would. Treat it accordingly.
|
||||
- **No commercial interests.** I'm not selling a SaaS, not steering you toward a paid tier, not collecting telemetry. The plugins run locally in your Claude Code installation.
|
||||
- **MIT licensed.** Fork it, modify it, ship it under your own name.
|
||||
|
||||
If you work somewhere that needs vendor accountability, support contracts, or signed assurances — **this isn't that.** Use it as a reference implementation, fork it into your own organization, and own the result.
|
||||
|
||||
---
|
||||
|
||||
## How this is meant to be used
|
||||
|
||||
### Fork-and-own
|
||||
|
||||
The intended workflow:
|
||||
|
||||
1. **Fork** the marketplace (or a single plugin) into your own organization or namespace.
|
||||
2. **Tailor** it to your context — terminology, integrations, cycle lengths, regulatory framing, whatever doesn't fit out of the box.
|
||||
3. **Maintain it yourself.** Treat your fork as the canonical version for your team.
|
||||
4. **Watch upstream selectively.** Cherry-pick changes that help, ignore changes that don't. There's no obligation to stay in sync.
|
||||
|
||||
This isn't a workaround for not accepting PRs. It's the actual recommended adoption pattern, especially for plugins like `okr` and `ms-ai-architect` where every Norwegian public sector organization will need its own tildelingsbrev mappings, terminology, and integrations. A central "one true plugin" would be wrong for everyone.
|
||||
|
||||
### What to change first when you fork
|
||||
|
||||
Each plugin differs, but the common edits are:
|
||||
|
||||
- **Identity** — rename the plugin, replace authorship, update README.
|
||||
- **External integrations** — issue trackers, knowledge bases, dashboards, observability backends. The plugins ship as starting points, not pre-wired. Every organization must configure its own integrations.
|
||||
- **Norwegian-specific framing** — relevant for `okr` and `ms-ai-architect`. Other plugins are jurisdiction-neutral. Rewrite for your jurisdiction if you're outside Norway.
|
||||
- **Reference docs** — the knowledge base in each plugin reflects my reading. Replace with your organization's authoritative sources.
|
||||
- **Hooks and policies** — security thresholds, blocked commands, and audit gates are tuned to my taste. Tune them to yours.
|
||||
|
||||
### Staying current with upstream
|
||||
|
||||
If you want to pull in upstream changes later:
|
||||
|
||||
- **Cherry-pick, don't merge.** Each plugin moves independently and breaking changes land without ceremony.
|
||||
- **Read the CHANGELOG first.** Every plugin has one.
|
||||
- **Keep your customizations in clearly-named files.** The harder upstream is to merge cleanly, the more painful staying current becomes. A `local/` directory or `*.local.md` convention helps.
|
||||
|
||||
---
|
||||
|
||||
## What upstream provides
|
||||
|
||||
| | What I do | What I don't |
|
||||
|---|---|---|
|
||||
| **Bug fixes** | Best-effort when I notice or get a clear report | No SLA, no triage commitment |
|
||||
| **Security issues** | Investigate within reasonable time, document in CHANGELOG | No CVE process, no embargo coordination |
|
||||
| **New features** | When they fit my own usage | Not on request |
|
||||
| **Norwegian public sector context** | Kept current as long as the project lives | If I lose interest or change jobs, the framing freezes |
|
||||
| **Breaking changes** | Documented in CHANGELOG | They happen — version pin if you need stability |
|
||||
| **Compatibility** | Tracked against current Claude Code releases | No long-term support branches |
|
||||
|
||||
If any of this is a dealbreaker — fork now, version-pin, and stop reading upstream.
|
||||
|
||||
---
|
||||
|
||||
## How to contribute
|
||||
|
||||
### Issues — yes, please
|
||||
|
||||
Issues are the most valuable thing you can send me:
|
||||
|
||||
- **Bug reports** with reproduction steps. Even a screenshot helps.
|
||||
- **Use-case feedback.** "I tried to use this in my organization and X didn't fit" is genuinely useful, even if I can't fix it for you.
|
||||
- **Pointers to better sources.** If you know a DFØ veileder, an NSM guideline, or an academic paper that contradicts what's in a knowledge base, tell me.
|
||||
- **Security findings.** See each plugin's `SECURITY.md` for disclosure preference where one exists; otherwise email rather than open a public issue.
|
||||
|
||||
### Pull requests — no
|
||||
|
||||
This is deliberate, not laziness:
|
||||
|
||||
- **Solo review is a bottleneck.** Honest PR review takes me longer than rewriting from scratch. The math doesn't work.
|
||||
- **Forks are where the value is.** The fork-and-own model means upstream consolidation isn't the point. Your organization's adaptations belong in your fork, not mine.
|
||||
- **AI-generated code complicates provenance.** Every line here is produced through dialog with Claude Code, with me as the judge. Mixing in PRs from contributors with different processes and licensing assumptions creates a mess I'd rather not untangle.
|
||||
|
||||
If you've built something useful on top of a fork, **publish it under your own name and link back.** I'll happily list notable forks here once they exist.
|
||||
|
||||
### Notable forks
|
||||
|
||||
*(To be populated as forks emerge. If you've forked one of these plugins for production use, open an issue and I'll add a link.)*
|
||||
|
||||
---
|
||||
|
||||
## Relationship between plugins
|
||||
|
||||
These plugins are **independent**. Install one without the others, fork one without the others. They share conventions (slash command naming, hook patterns, AI-generated disclosure) but no runtime dependencies.
|
||||
|
||||
The marketplace is a **catalog**, not a suite. Don't fork the whole repo unless you actually want to maintain everything.
|
||||
|
||||
---
|
||||
|
||||
## Versioning and stability
|
||||
|
||||
- **Semantic versioning per plugin.** Each plugin has its own `CHANGELOG.md` and version number.
|
||||
- **Breaking changes happen.** I bump the major version when they do, but I don't run an LTS branch.
|
||||
- **Pin your version.** If stability matters more than features, install a specific version and stay there until you choose to upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Public sector adoption notes
|
||||
|
||||
For Norwegian etater specifically:
|
||||
|
||||
- **DPIA-relevant data flows are documented in the relevant plugin README where applicable.** Read them before installation.
|
||||
- **No data leaves your machine** beyond what Claude Code itself sends to Anthropic. The plugins themselves do not call external services unless you configure an integration.
|
||||
- **Drøftingsplikt and ledelsesansvar** are not replaced by these tools. The `okr` plugin coaches; it does not decide. The `ms-ai-architect` plugin advises; it does not approve.
|
||||
- **Choose your Claude deployment carefully.** claude.ai vs. API direct vs. Bedrock in EU region have different data residency profiles. The plugins don't choose for you.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT for all plugins in this marketplace. See each plugin's `LICENSE` file.
|
||||
|
|
@ -6,7 +6,7 @@ v3.x → v4.0.0 is a rebrand. All command names changed:
|
|||
`/ultrareview-local` → `/trekreview`, `/ultracontinue-local` → `/trekcontinue`,
|
||||
`/ultraplan-end-session-local` → `/trekendsession`. The plugin is now
|
||||
named `voyage`. Re-fork from main if upgrading. There is no migration
|
||||
path — see `GOVERNANCE.md` for the fork-and-own model.
|
||||
path — see [`GOVERNANCE.md`](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the fork-and-own model.
|
||||
|
||||
Prior version migration notes (v1→v2, v2→v3) are preserved in
|
||||
`CHANGELOG.md` only.
|
||||
|
|
|
|||
93
README.md
93
README.md
|
|
@ -1,17 +1,17 @@
|
|||
# trekplan — Brief, Research, Plan, Execute, Review, Continue
|
||||
# voyage
|
||||
|
||||

|
||||
Contract-driven Claude Code pipeline: brief, research, plan, execute, review. Agent swarms, research triangulation, adversarial review, multi-session resumption.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](GOVERNANCE.md) for the full model and what upstream provides.
|
||||
> **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See [GOVERNANCE.md](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the full model and what upstream provides.
|
||||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)*
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development. Every change is human-directed, reviewed, and validated before commit. Per Anthropic Consumer Terms §4, ownership of outputs is assigned to the user; this plugin is licensed MIT.*
|
||||
|
||||
A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep implementation planning, multi-source research, autonomous execution, independent post-hoc review, and zero-friction multi-session resumption. Six commands, one pipeline:
|
||||
|
||||
> **What's new — v5.8.0: offline gold-scored output eval (SKAL-1·4b).** The review-coordinator self-eval gains a scoring run: `lib/review/gold-scorer.mjs` grades a committed agent-run fixture against the golden corpus at `(file, rule_key)` granularity (precision/recall/f1 + verdict match), and the suite census gains a third category (`goldEval`) so a scoring run is counted apart from behavior coverage and doc-pins. Offline + deterministic — committed reviewer payloads, no live agent spawn (the LLM-in-the-loop tier is the separate 4c). Internal eval infrastructure; no command/agent/Handover change. **v5.7.1:** leaner always-loaded agent listing — `<example>` blocks relocated to agent bodies (~3,180 tok/turn, no behavior change). **v5.7.0:** opt-in per-session token/cost metering (SKAL-2) + eval foundation (SKAL-1·4a). **v5.6.1:** one-line `description:` for the four reference/dormant agents (~700 tok). **v5.5.0:** brief **framing** enforcement (`brief_version 2.2`) + a `/trekreview` reviewer-schema contract. Additive — no breaking changes. **Full version history → [CHANGELOG.md](CHANGELOG.md).**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| **`/trekbrief`** | Brief — interactive interview produces a task brief with explicit research plan |
|
||||
|
|
@ -21,6 +21,23 @@ A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep
|
|||
| **`/trekreview`** | Review — independent post-hoc review of delivered code against the brief, severity-tagged findings |
|
||||
| **`/trekcontinue`** | Continue — read `.session-state.local.json` and resume the next session in a multi-session project |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
claude plugin install voyage@ktg-plugin-marketplace
|
||||
```
|
||||
|
||||
Or enable directly in `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"voyage@ktg-plugin-marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written artifact and printing the resulting `file://<abs path>` link. The operator opens the HTML in a browser, clicks any line of the document, writes their own note in the inline textarea, watches a sidebar of all notes (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt that they paste back into Claude — Claude then revises the `.md` from the notes. **The operator drives every annotation.** See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v502).
|
||||
|
||||
Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, and `review.md`.
|
||||
|
|
@ -68,9 +85,6 @@ Under the hood, `lib/util/autonomy-gate.mjs` runs a small state machine (`idle
|
|||
## Quick start
|
||||
|
||||
```bash
|
||||
# Install the marketplace, then browse and enable plugins with /plugin
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
|
||||
# Capture intent (interactive)
|
||||
/trekbrief Add user authentication with JWT tokens
|
||||
# → .claude/projects/2026-04-18-jwt-auth/brief.md
|
||||
|
|
@ -127,7 +141,10 @@ Concrete capabilities, observable in the code — not aspirations.
|
|||
|
||||
**Virksomhet / regulated environment.** Defense-in-depth security across four layers (plugin hooks, prompt-level denylist, pre-execution plan scan, scoped tool access). `disableSkillShellExecution: true` recommendation for fork-ers handling untrusted briefs. No cloud dependency, no GitHub requirement. Validators are plain-Node CLIs — invocable from CI, custom hooks, or external tools, not just from voyage commands.
|
||||
|
||||
**What it doesn't solve:**
|
||||
## Non-goals
|
||||
|
||||
What this pipeline does **not** solve — read this before adopting it:
|
||||
|
||||
- LLM output truthfulness. Validators check shape, not facts. A plan with hallucinated paths passes schema but fails in execute. Plan-critic catches some, not all.
|
||||
- Multi-user concurrency on a single project directory. Two simultaneous executors will clobber `progress.json`.
|
||||
- Cost management. Opus on the orchestrator layer is expensive; documented in [Cost profile](#cost-profile), no automatic model downgrade.
|
||||
|
|
@ -195,10 +212,28 @@ Output:
|
|||
| **External** | `/trekresearch --external <question>` | Only external research agents (skip codebase analysis) |
|
||||
| **Foreground** | `/trekresearch --fg <question>` | No-op alias (foreground is default since v2.4.0) |
|
||||
| **Profile** | `/trekresearch --profile <name> <question>` | (v4.1.0) Pin model profile for the research phase. See [Profile system](#profile-system-v410). |
|
||||
| **Engine** | `/trekresearch --external --engine deep-research <question>` | Delegate the external phase to Claude Code's built-in `/deep-research` workflow; falls back to `swarm` if unavailable. Default `swarm`. |
|
||||
| **Engine** | `/trekresearch --external --engine deep-research <question>` | Delegate the external phase to Claude Code's built-in `/deep-research` workflow. Only works on `2.1.154 <= CC < 2.1.218`; from **2.1.218** `/deep-research` is operator-invoked only (Skill tool: `disable-model-invocation`), so it always falls back to `swarm`. Never hard-fails. Default `swarm`. |
|
||||
|
||||
Flags combine: `--project <dir> --external`.
|
||||
|
||||
#### Bounded conversation loop (default-off)
|
||||
|
||||
At `effort: high`, research can discover additional dimensions (Phase 4.5) and
|
||||
run a bounded multi-turn follow-up loop on under-illuminated ones (Phase 5).
|
||||
Both are **default-off** and env-gated rather than flag-gated, because enabling
|
||||
them costs turns:
|
||||
|
||||
| Env-var | Default | Behavior |
|
||||
|---------|---------|----------|
|
||||
| `VOYAGE_STORM_ENABLED` | _(unset — default-off)_ | `=1` gives the Phase 5 loop a non-zero turn budget and lets Phase 4.5 discovery run. Unset, the budget is 0 and **both** phases are inert. |
|
||||
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Turns per under-illuminated dimension. Invalid values fall back to `3`, never to unbounded. |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK` | _(unset)_ | `=1` disables the `PreToolUse` hook that enforces the turn budget. |
|
||||
|
||||
The cap counts turns itself from an append-only ledger — it never asks the loop
|
||||
how many turns it has used. Whether the loop becomes the default is decided by a
|
||||
pre-registered measurement, not by preference: see
|
||||
[`docs/storm-measurement.md`](docs/storm-measurement.md).
|
||||
|
||||
Research uses up to 5 local agents (architecture-mapper, dependency-tracer, task-finder, git-historian, convention-scanner) and 4 external agents (docs-researcher, community-researcher, security-researcher, contrarian-researcher) plus the optional Gemini bridge for an independent second opinion. Per-agent details in [`agents/`](agents/).
|
||||
|
||||
---
|
||||
|
|
@ -757,26 +792,6 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
|
|||
|
||||
**Annotation HTML requires a desktop browser.** `scripts/annotate.mjs` produces a single self-contained `.html` file you open with `file://` in any modern browser (Chrome / Safari / Firefox / Edge — last two versions). No CDN, no server, no npm runtime deps. State persists in `localStorage` so closing and re-opening the tab keeps your work, but it's local to one browser on one machine — not synced anywhere. If you want to annotate without a browser, paste the `.md` into Claude with "comments inline below" and write notes in chat — same end result, just without the visual surface.
|
||||
|
||||
## Installation
|
||||
|
||||
Add the marketplace and browse plugins with `/plugin`:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
|
||||
```
|
||||
|
||||
Or enable directly in `~/.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"voyage@ktg-plugin-marketplace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An optional architect step between research and plan was previously available via a separate plugin; that architect plugin is no longer publicly distributed. The `architecture/overview.md` filesystem slot remains supported by `/trekplan` for any compatible producer.
|
||||
|
||||
## Profile system (v4.1.0)
|
||||
|
||||
Four built-in model profiles plus operator-defined `<custom>.yaml` (drop in `lib/profiles/`). Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>` and emitted to JSONL stats for cost-attribution.
|
||||
|
|
@ -822,7 +837,7 @@ For per-profile cost estimates, see [`docs/profiles.md`](docs/profiles.md).
|
|||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (CLI, desktop app, or web app)
|
||||
- Claude subscription with Opus access (Max plan recommended)
|
||||
- Optional: [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) for enhanced external research
|
||||
- Optional: [Gemini MCP server](https://github.com/anthropics/anthropic-cookbook/tree/main/tool-use/gemini-mcp) for independent second opinion via Gemini Deep Research
|
||||
- Optional: a Gemini Deep Research MCP server exposing `gemini_deep_research`, `gemini_get_research_status`, `gemini_get_research_result`, `gemini_research_followup` for an independent second opinion (voyage calls these tools; any server implementing them works)
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -834,7 +849,7 @@ trekplan/
|
|||
│ └ 21 spawnable (1 dormant: synthesis-agent, Δ≈0) + 3 orchestrator reference docs (not spawned)
|
||||
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
|
||||
├── templates/ Frontmatter templates for brief, research, plan, session, launch
|
||||
├── hooks/ 7 hooks (pre-bash, pre-write, session-title, post-bash-stats, pre-compact-flush, post-compact-flush, otel-export)
|
||||
├── hooks/ 8 hooks (pre-bash, pre-write, pre-agent-cap, session-title, post-bash-stats, pre-compact-flush, post-compact-flush, otel-export)
|
||||
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)
|
||||
├── tests/ comprehensive node:test suite — `npm test` is the fork-readiness gate
|
||||
├── docs/ HANDOVER-CONTRACTS.md + architect-bridge-test.md
|
||||
|
|
@ -921,6 +936,18 @@ suppress this, leave the `architecture/` directory absent from your
|
|||
project directory. Discovery is additive — missing file is fine, no
|
||||
error.
|
||||
|
||||
## Changelog
|
||||
|
||||
Full version history → [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
Recent, in one line each:
|
||||
|
||||
- **v5.8.0** — offline gold-scored output eval (SKAL-1·4b): `lib/review/gold-scorer.mjs` grades a committed agent-run fixture against the golden corpus at `(file, rule_key)` granularity; suite census gains a `goldEval` category. Offline + deterministic, no live agent spawn.
|
||||
- **v5.7.1** — leaner always-loaded agent listing (`<example>` blocks moved into agent bodies, ~3,180 tok/turn, no behavior change).
|
||||
- **v5.7.0** — opt-in per-session token/cost metering (SKAL-2) + eval foundation (SKAL-1·4a).
|
||||
- **v5.6.1** — one-line `description:` for the four reference/dormant agents (~700 tok).
|
||||
- **v5.5.0** — brief **framing** enforcement (`brief_version 2.2`) + a `/trekreview` reviewer-schema contract. Additive, no breaking changes.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
|
|
|||
11
SECURITY.md
11
SECURITY.md
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Open a **private** issue on Forgejo:
|
||||
Report privately to <security@fromaitochitta.com> — do not open a public
|
||||
issue. There is no SLA — this is a solo-maintained plugin — but
|
||||
acknowledged reports are usually triaged within 7 days.
|
||||
|
||||
> https://git.fromaitochitta.com/open/ktg-plugin-marketplace
|
||||
If you already have an account on the Forgejo instance, you may instead
|
||||
open a **private** issue on the repo, tagged `security`:
|
||||
|
||||
Tag it `security` and mark it private. Do not file public issues for
|
||||
unpatched vulnerabilities. There is no SLA — this is a solo-maintained
|
||||
plugin — but acknowledged reports are usually triaged within 7 days.
|
||||
> https://git.fromaitochitta.com/open/voyage
|
||||
|
||||
## Supported versions
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,21 @@ tools: ["Read", "Glob", "Grep", "Write", "Edit", "Bash"]
|
|||
---
|
||||
|
||||
<!-- Phase mapping: orchestrator → command
|
||||
Corrected in v5.10: every row below was off by one, and the old last row
|
||||
pointed at a ninth command phase that does not exist — the command ends
|
||||
at Phase 8.
|
||||
Orchestrator Phase 1 = Command Phase 4 (Agent group selection)
|
||||
Orchestrator Phase 2 = Command Phase 5 (Parallel research)
|
||||
Orchestrator Phase 3 = Command Phase 6 (Targeted follow-ups)
|
||||
Orchestrator Phase 4 = Command Phase 7 (Triangulation)
|
||||
Orchestrator Phase 5 = Command Phase 8 (Synthesis + write brief)
|
||||
Orchestrator Phase 6 = Command Phase 9 (Completion)
|
||||
Orchestrator Phase 2 = Command Phase 4 (Parallel research — same
|
||||
command phase; the orchestrator
|
||||
splits selection from launch)
|
||||
(no orchestrator phase)= Command Phase 4.5 (Dimension discovery, high
|
||||
effort AND VOYAGE_STORM_ENABLED
|
||||
=1 only — v5.10)
|
||||
Orchestrator Phase 3 = Command Phase 5 (Targeted follow-ups; bounded
|
||||
conversation loop at high effort)
|
||||
Orchestrator Phase 4 = Command Phase 6 (Triangulation)
|
||||
Orchestrator Phase 5 = Command Phase 7 (Synthesis + write brief)
|
||||
Orchestrator Phase 6 = Command Phase 8 (Present and track / completion)
|
||||
As of v2.4.0, /trekresearch runs these phases inline in main
|
||||
context instead of spawning this agent. Keep this file as the canonical
|
||||
reference for what those phases do. -->
|
||||
|
|
|
|||
|
|
@ -84,31 +84,48 @@ identical `(file, rule_key)` and `line == 0` collide.
|
|||
|
||||
### Pass 2 — HubSpot Judge filters (3 criteria)
|
||||
|
||||
Drop findings that fail ANY of these filters:
|
||||
Remove findings that fail ANY of these filters. **The `Removed as` column
|
||||
is load-bearing** — see *Suppression is two-valued* below:
|
||||
|
||||
| Filter | Test | Drop if |
|
||||
|--------|------|---------|
|
||||
| Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text |
|
||||
| Accuracy | `file` resolves under the repo root AND `line` is plausible (≥ 0; ≤ file line count when known) | Path traversal escape, negative line, or impossibly large line number |
|
||||
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title |
|
||||
| Filter | Test | Fails if | Removed as |
|
||||
|--------|------|----------|------------|
|
||||
| Succinctness | `title.length ≤ 100` and `detail.length ≤ 800` chars | Title is a paragraph or detail is a wall of text | `unverified` (`succinctness:title` / `succinctness:detail`) |
|
||||
| Accuracy | `file` resolves under the repo root AND `line` is plausible (≥ 0; ≤ file line count when known) | Path traversal escape, negative line, or impossibly large line number | **dropped** (`accuracy:refuted`) |
|
||||
| Actionability | `recommended_action` is non-empty AND begins with an imperative verb | Empty action, "consider …" hedges, or restating the title | `unverified` (`actionability:empty`) |
|
||||
|
||||
When dropping a finding, preserve a one-line note in the
|
||||
Succinctness and Actionability read the finding's *packaging*; neither
|
||||
examines the claim, so neither can establish the finding is unreal. Accuracy
|
||||
does: a citation that escapes the repo root refutes the finding as a claim
|
||||
about this codebase.
|
||||
|
||||
When removing a finding, preserve a one-line note in the
|
||||
`Suppressed Findings` body section so the user knows why the count
|
||||
shrank.
|
||||
|
||||
### Pass 3 — Cloudflare reasonableness (skipped in quick mode)
|
||||
|
||||
Drop findings that fail ANY of these tests:
|
||||
Remove findings that fail ANY of these tests:
|
||||
|
||||
- **No file:line citation.** `file` is empty, or `line < 0`. Speculative
|
||||
"code might break somewhere" findings have no anchor and are dropped.
|
||||
- **Unknown rule_key.** `rule_key` is not in `RULE_CATALOGUE`. Reviewers
|
||||
occasionally emit ad-hoc rule keys; the catalogue is the contract.
|
||||
- **No file:line citation** → **dropped** (`no-citation`). `file` is empty,
|
||||
or `line < 0`. Speculative "code might break somewhere" findings name no
|
||||
location, so they make no checkable claim at all.
|
||||
- **Unknown rule_key** → `unverified` (`unknown-rule_key`). `rule_key` is not
|
||||
in `RULE_CATALOGUE`. Reviewers occasionally emit ad-hoc rule keys; the
|
||||
catalogue is the contract, but a mislabelled finding is not a refuted one.
|
||||
*(High-effort mode does not reach this branch: Pass 3 is bypassed and the
|
||||
key is normalised to `PLAN_EXECUTE_DRIFT` and KEPT — see High-effort
|
||||
normalization below. The two fates never apply to the same input.)*
|
||||
- **Non-existent file.** `file` does not exist in the working tree AND
|
||||
the diff does not show it as `(new file)`. Use Glob to verify.
|
||||
the diff does not show it as `(new file)`. Use Glob to verify. **This test
|
||||
has three outcomes, not two:** Glob resolves and the file is absent from
|
||||
both tree and diff → **dropped** (`file-existence:refuted`); Glob resolves
|
||||
and the file is present → keep; **Glob cannot decide** (path outside the
|
||||
working tree, unreadable, or the tool errored) → `unverified`
|
||||
(`file-existence:indeterminate`). Never collapse *unresolvable* into
|
||||
*refuted*.
|
||||
- **Catalogue severity mismatch.** `severity` does not match the rule's
|
||||
catalogue tier (e.g., `MISSING_TEST` emitted as MINOR). Reset to the
|
||||
catalogue tier; this is a correction, not a drop.
|
||||
catalogue tier; this is a correction, neither a drop nor an unverified.
|
||||
|
||||
In `quick` mode, skip this pass entirely. Note the skip in the
|
||||
Executive Summary so the reader knows reasonableness was not applied.
|
||||
|
|
@ -125,6 +142,32 @@ purposes. This normalization happens BEFORE writing review.md,
|
|||
ensuring all `rule_key` values in the final review match the
|
||||
catalogue.
|
||||
|
||||
### Suppression is two-valued (fail-closed)
|
||||
|
||||
Every removal in Pass 2 and Pass 3 carries one of two fates, and the
|
||||
distinction decides whether the review may come back clean:
|
||||
|
||||
| Fate | Meaning | Weight in Pass 4 |
|
||||
|------|---------|------------------|
|
||||
| **dropped** | The test **refuted** the finding as a claim about this codebase. | None. It weighs nothing, correctly. |
|
||||
| **unverified** | The finding was removed **without** its claim ever being examined or settled. | Forbids `ALLOW`. |
|
||||
|
||||
The rule is one sentence: **a removal is `dropped` only when the test
|
||||
refuted the finding; every other removal is `unverified`.** A reason you
|
||||
cannot place is `unverified` — the default fails closed.
|
||||
|
||||
Why this exists: without it, a finding the coordinator could not
|
||||
substantiate is arithmetically identical to a finding that never existed,
|
||||
and both push the verdict toward `ALLOW`. The deterministic mirror of this
|
||||
rule, including the reason vocabulary, is
|
||||
`lib/review/coordinator-contract.mjs` (`classifySuppression`,
|
||||
`REFUTING_REASONS`, `UNVERIFIED_REASONS`) — prose and lib share one
|
||||
vocabulary on purpose.
|
||||
|
||||
**Unverified findings are not counted into a severity tier.** Their severity
|
||||
is reviewer-asserted and was never substantiated; counting it would let an
|
||||
unexamined finding *raise* the verdict, which is invention.
|
||||
|
||||
### Pass 4 — Compute verdict
|
||||
|
||||
Count findings by severity AFTER dedup and filtering. Verdict thresholds:
|
||||
|
|
@ -133,7 +176,19 @@ Count findings by severity AFTER dedup and filtering. Verdict thresholds:
|
|||
|--------|---------|
|
||||
| `BLOCKER ≥ 1` | `BLOCK` |
|
||||
| `BLOCKER == 0` AND `MAJOR ≥ 1` | `WARN` |
|
||||
| `BLOCKER == 0` AND `MAJOR == 0` | `ALLOW` |
|
||||
| `BLOCKER == 0` AND `MAJOR == 0` AND nothing `unverified` AND every reviewer reported | `ALLOW` |
|
||||
| `BLOCKER == 0` AND `MAJOR == 0` AND (`unverified` non-empty OR a reviewer did not report) | `WARN` |
|
||||
|
||||
The fail-closed row never RAISES a verdict — it only withholds the clean
|
||||
one. The worst case of a false `unverified` is `WARN` plus a stated reason;
|
||||
the worst case of the old behaviour was a silent `ALLOW` over a live
|
||||
BLOCKER.
|
||||
|
||||
**When `ALLOW` is withheld, the Executive Summary's FIRST sentence must say
|
||||
so and name why** — e.g. "WARN: no blocking findings survived, but 1 finding
|
||||
could not be verified (succinctness:title) and brief-conformance-reviewer did
|
||||
not report." A withheld ALLOW that the reader cannot see is the same defect
|
||||
in a new place.
|
||||
|
||||
Verdict is mechanical — never override. The verdict goes into the
|
||||
trailing JSON block AND the Executive Summary's first sentence.
|
||||
|
|
@ -181,8 +236,10 @@ prefix). Flow-style `findings: [a, b]` breaks the frontmatter parser.
|
|||
5. `## Findings (MAJOR)` — one subsection per MAJOR finding.
|
||||
6. `## Findings (MINOR)` — one subsection per MINOR finding.
|
||||
7. `## Findings (SUGGESTION)` — one subsection per SUGGESTION finding.
|
||||
8. `## Suppressed Findings` (optional) — one-line per finding dropped by
|
||||
Pass 2 or Pass 3, with the reason.
|
||||
8. `## Suppressed Findings` (optional) — one line per finding removed by
|
||||
Pass 2 or Pass 3, with the reason AND its fate, tagged `[dropped]` or
|
||||
`[unverified]`. Unverified lines come first: they are the ones that
|
||||
withheld `ALLOW`.
|
||||
9. `## Remediation Summary` — bullet count per severity + 1 sentence on
|
||||
what /trekplan will consume.
|
||||
|
||||
|
|
@ -204,6 +261,7 @@ The LAST fenced block in the file is a `json` block:
|
|||
{
|
||||
"verdict": "BLOCK | WARN | ALLOW",
|
||||
"counts": { "BLOCKER": N, "MAJOR": N, "MINOR": N, "SUGGESTION": N },
|
||||
"allow_blocked_by": ["unverified:succinctness:title (1)", "missing-reviewer:brief-conformance-reviewer", "unattributable-payload (1)"],
|
||||
"findings": [
|
||||
{
|
||||
"id": "<40-char-hex>",
|
||||
|
|
@ -243,9 +301,14 @@ for the ID list.
|
|||
the canonical 40-char SHA1 from `(file, line, rule_key, title)` using
|
||||
the algorithm in `lib/parsers/finding-id.mjs`. The frontmatter
|
||||
`findings:` list and the JSON block IDs must match.
|
||||
- **Suppressed findings are accountable.** When you drop a finding via
|
||||
Pass 2 or Pass 3, log it in `## Suppressed Findings` with the reason.
|
||||
Silent drops break the audit trail.
|
||||
- **Suppressed findings are accountable.** When you remove a finding via
|
||||
Pass 2 or Pass 3, log it in `## Suppressed Findings` with the reason and
|
||||
its fate (`[dropped]` / `[unverified]`). Silent drops break the audit
|
||||
trail.
|
||||
- **Never spend an unexamined finding as evidence of a clean review.** If a
|
||||
removal did not refute the finding, it is `unverified` and `ALLOW` is off
|
||||
the table. This is the one place where you may not be minimal: when in
|
||||
doubt about a reason's fate, it is `unverified`.
|
||||
- **No invention.** Never add a finding that did not appear in the
|
||||
reviewer outputs. Never escalate a finding's severity beyond what the
|
||||
catalogue specifies.
|
||||
|
|
|
|||
|
|
@ -507,6 +507,18 @@ is available, pass nothing — the reviewer scores the memory-alignment dimensio
|
|||
|
||||
Launch the `brief-reviewer` agent (foreground, blocking) with the prompt:
|
||||
|
||||
> **Never pass the Agent tool's `name` parameter — at this or any other spawn
|
||||
> site in this command.** `name` does not label a subagent, it changes its kind:
|
||||
> the spawn is recorded as `taskKind: "in_process_teammate"` (`spawnDepth: 0`)
|
||||
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
|
||||
> not a return value — it reaches the orchestrator only if the teammate itself
|
||||
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
|
||||
> `tools:` allowlist without SendMessage. The agent still runs and still
|
||||
> produces correct output; the result is simply never delivered, so the phase
|
||||
> presents as a hung agent that no re-prompting can revive. Measured
|
||||
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
|
||||
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
|
||||
|
||||
> "Review this task brief for quality: `{PROJECT_DIR}/brief.md.draft`.
|
||||
> Check completeness, consistency, testability, scope clarity,
|
||||
> research-plan validity, and memory alignment. Report findings, verdict, and
|
||||
|
|
|
|||
|
|
@ -90,16 +90,16 @@ want an interactive flow, use `/trekcontinue --help` to see the full pipeline.
|
|||
|
||||
## Phase 3 — Atomically write `.session-state.local.json` + sibling NEXT-SESSION-PROMPT.local.md
|
||||
|
||||
Write `<project-dir>/.session-state.local.json` with the schema-v1 object:
|
||||
Write `{project_dir}/.session-state.local.json` with the schema-v1 object:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"project": "<project-dir>",
|
||||
"next_session_brief_path": "<arg 1>",
|
||||
"next_session_label": "<arg 2>",
|
||||
"project": "{project_dir}",
|
||||
"next_session_brief_path": "{arg 1}",
|
||||
"next_session_label": "{arg 2}",
|
||||
"status": "in_progress",
|
||||
"updated_at": "<now, ISO-8601>"
|
||||
"updated_at": "{now, ISO-8601}"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -114,14 +114,22 @@ Under `node --input-type=module -e "<script>" arg1 arg2 arg3`, Node sets
|
|||
|
||||
This phase ALSO writes a sibling `NEXT-SESSION-PROMPT.local.md` in the
|
||||
project directory with YAML frontmatter (`produced_by: trekendsession`,
|
||||
`produced_at: <ISO-8601>`, `project: <project-dir>`). Both files are written
|
||||
in a single ESM block so the writes succeed or fail together:
|
||||
`produced_at: {ISO-8601}`, `project: {project_dir}`). Both files are written
|
||||
in a single ESM block so the writes succeed or fail together.
|
||||
|
||||
Run the block below via the Bash tool at runtime, substituting the resolved
|
||||
values for the `{curly}` placeholders (Phase 1 gives `{project_dir}`, Phase 2
|
||||
gives `{next_brief_path}` and `{next_label}`). This is NOT an eager-exec
|
||||
block — the values do not exist at command-load time. The import path must
|
||||
stay absolute via `${CLAUDE_PLUGIN_ROOT}` — your Bash cwd is the user's
|
||||
repo, not the plugin root, so a cwd-relative import throws
|
||||
`ERR_MODULE_NOT_FOUND`:
|
||||
|
||||
```bash
|
||||
!`node --input-type=module -e "
|
||||
node --input-type=module -e "
|
||||
import path from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { atomicWriteJson } from './lib/util/atomic-write.mjs';
|
||||
import { atomicWriteJson } from '${CLAUDE_PLUGIN_ROOT}/lib/util/atomic-write.mjs';
|
||||
const [, dir, brief, label] = process.argv;
|
||||
const now = new Date().toISOString();
|
||||
const stateObj = { schema_version: 1, project: dir, next_session_brief_path: brief, next_session_label: label, status: 'in_progress', updated_at: now };
|
||||
|
|
@ -132,26 +140,28 @@ const promptBody = '---\\nproduced_by: trekendsession\\nproduced_at: ' + now + '
|
|||
writeFileSync(promptFile, promptBody);
|
||||
console.log(stateFile);
|
||||
console.log(promptFile);
|
||||
" '<project-dir>' '<next-brief-path>' '<next-label>'`
|
||||
" '{project_dir}' '{next_brief_path}' '{next_label}'
|
||||
```
|
||||
|
||||
## Phase 4 — Validate + narrate
|
||||
|
||||
Validate the freshly-written state file:
|
||||
Validate the freshly-written state file via the Bash tool at runtime,
|
||||
substituting the resolved `{project_dir}` (NOT eager-exec — the file does
|
||||
not exist at command-load time):
|
||||
|
||||
```bash
|
||||
!`node lib/validators/session-state-validator.mjs --json <project-dir>/.session-state.local.json`
|
||||
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/session-state-validator.mjs --json {project_dir}/.session-state.local.json
|
||||
```
|
||||
|
||||
If `valid: true`, print the success block matching `/trekcontinue` Phase 3
|
||||
narration (SC-8 cross-project consistency — same template both sides):
|
||||
|
||||
```
|
||||
Session state written: <project-dir>/.session-state.local.json
|
||||
Session state written: {project_dir}/.session-state.local.json
|
||||
|
||||
Project: <project-dir>
|
||||
Next session: <next-label>
|
||||
Brief: <next-brief-path>
|
||||
Project: {project_dir}
|
||||
Next session: {next_label}
|
||||
Brief: {next_brief_path}
|
||||
|
||||
In a fresh Claude session, run /trekcontinue to resume.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -419,19 +419,46 @@ to stderr but do NOT block the stop; `progress.json` is still authoritative.
|
|||
`status: stopped`) so the next-session producer-mismatch check has both
|
||||
candidates available. Use the same combined ESM block pattern as Phase 8.
|
||||
|
||||
### Check 2 — Plan file is tracked by git
|
||||
### Check 2 — Plan file reaches every worktree
|
||||
|
||||
Run `git ls-files --error-unmatch {plan-path} 2>/dev/null`. If the plan file is
|
||||
untracked (exit code != 0):
|
||||
Worktrees are created from HEAD, so tracking the plan file is the cheapest way
|
||||
to make it visible in each one. But the project directory may be **gitignored**
|
||||
— `.claude/projects/` is tool-managed and local-only, and a repo that ignores it
|
||||
is normal, not exotic. `git add -f` is **not** the answer there: it would push
|
||||
operator-local artifacts into history, and into whatever remote the repo
|
||||
publishes to. When the plan file is ignored, Phase 2.6 Step 2a' (which copies
|
||||
brief/plan/research into each worktree) is the delivery path, and this check
|
||||
must step aside instead of failing.
|
||||
|
||||
```bash
|
||||
git add {plan-path}
|
||||
PLAN_PATH="{plan-path}"
|
||||
if git ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
|
||||
PLAN_TRACKING="tracked"
|
||||
else
|
||||
git check-ignore -q "$PLAN_PATH"
|
||||
case "$?" in
|
||||
0) PLAN_TRACKING="ignored" ;;
|
||||
1) PLAN_TRACKING="untracked" ;;
|
||||
*) echo "Error: git check-ignore failed on $PLAN_PATH - a fatal probe is not an answer about ignore status." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$PLAN_TRACKING" = "untracked" ]; then
|
||||
git add "$PLAN_PATH"
|
||||
git commit -m "chore: track plan file for parallel execution"
|
||||
fi
|
||||
```
|
||||
|
||||
Report: `Plan file committed for worktree visibility.`
|
||||
Report by outcome:
|
||||
|
||||
This ensures every worktree created from HEAD will have the plan file.
|
||||
| `PLAN_TRACKING` | Report |
|
||||
|---|---|
|
||||
| `tracked` | `Plan file already tracked.` |
|
||||
| `untracked` | `Plan file committed for worktree visibility.` |
|
||||
| `ignored` | `Plan file is gitignored — not forced into history. Step 2a' copies it into each worktree.` |
|
||||
|
||||
Any other `git check-ignore` exit code is fatal and stops execution: a probe
|
||||
that failed is not a probe that answered "not ignored".
|
||||
|
||||
### Check 3 — Scope fence overlap validation
|
||||
|
||||
|
|
@ -478,7 +505,7 @@ If cleanup fails, report the manual commands and stop.
|
|||
|
||||
After all 4 checks pass:
|
||||
```
|
||||
Pre-flight: PASS (clean tree, plan tracked, no overlaps, no stale worktrees)
|
||||
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
|
||||
```
|
||||
|
||||
## Phase 2.6 — Multi-session orchestration (worktree-isolated)
|
||||
|
|
@ -604,27 +631,45 @@ Insert this block AFTER the worktree-creation loop and BEFORE wave dispatch
|
|||
|
||||
```bash
|
||||
PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"
|
||||
REPO_ROOT_REAL="$(realpath "${REPO_ROOT}")"
|
||||
# Compute destination relpath: PROJECT_DIR relative to REPO_ROOT.
|
||||
# This makes $wt/$PROJECT_REL valid regardless of whether the operator
|
||||
# passed --project as relative (.claude/projects/...) or absolute.
|
||||
PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"
|
||||
# python3 + os.path.relpath is stdlib and portable - see the note below the
|
||||
# block for why no realpath flag may be used here.
|
||||
PROJECT_REL="$(python3 -c 'import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' "$PROJECT_SOURCE" "$REPO_ROOT_REAL")"
|
||||
case "$PROJECT_REL" in
|
||||
""|..*)
|
||||
echo "Error: cannot derive a project relpath inside the repo ($PROJECT_SOURCE vs $REPO_ROOT_REAL)." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
for wt in "$WORKTREE_DIR"/session-*; do
|
||||
[ -d "$wt" ] || continue
|
||||
mkdir -p "$wt/$PROJECT_REL"
|
||||
cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"
|
||||
cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"
|
||||
[ -d "$PROJECT_SOURCE/research" ] && \
|
||||
if [ -d "$PROJECT_SOURCE/research" ]; then
|
||||
cp -r "$PROJECT_SOURCE/research" "$wt/$PROJECT_REL/"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Note: `realpath --relative-to` is GNU coreutils. macOS users without
|
||||
`coreutils` (Homebrew `brew install coreutils` provides `grealpath`) may
|
||||
substitute a Python fallback:
|
||||
`python3 -c "import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))" "$PROJECT_SOURCE" "$REPO_ROOT"`.
|
||||
Do not "improve" the relpath line into `realpath --relative-to=...`. That flag
|
||||
is GNU coreutils only; BSD `realpath` (the macOS default) rejects it, and the
|
||||
failure is **silent** — the command substitution leaves `PROJECT_REL` empty, so
|
||||
`mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed and drop `brief.md`/`plan.md`
|
||||
at the worktree root, where no child session looks for them. Measured on an
|
||||
Intel Mac 2026-08-31: `realpath --relative-to=... ` → `realpath: illegal option
|
||||
-- -`, while bare `realpath <path>` works; a whole wave ran with zero steps
|
||||
executed. Both `realpath` calls above are bare path resolution, which BSD and
|
||||
GNU handle identically; resolving both operands before the relpath is what keeps
|
||||
it correct when one side goes through a symlink (macOS `/var` → `/private/var`).
|
||||
|
||||
Failure mode: any `cp` failure exits the wave non-zero; reported via Step 4
|
||||
cleanup. Source: brief Constraint 2.
|
||||
Failure modes: an underivable relpath (empty, or outside the repo) aborts
|
||||
before anything is copied — better a loud stop than files delivered where no
|
||||
session reads them; any `cp` failure exits the wave non-zero, reported via
|
||||
Step 4 cleanup. A project without `research/` is not a failure.
|
||||
Source: brief Constraint 2.
|
||||
|
||||
**2b. Launch sessions in this wave (each in its own worktree):**
|
||||
|
||||
|
|
|
|||
|
|
@ -406,6 +406,18 @@ the quick-scan results.
|
|||
**All other modes:** Launch exploration agents **in parallel** (all in a single
|
||||
message). Use the specialized agents from the `agents/` directory.
|
||||
|
||||
> **Never pass the Agent tool's `name` parameter — at this or any other spawn
|
||||
> site in this command.** `name` does not label a subagent, it changes its kind:
|
||||
> the spawn is recorded as `taskKind: "in_process_teammate"` (`spawnDepth: 0`)
|
||||
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
|
||||
> not a return value — it reaches the orchestrator only if the teammate itself
|
||||
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
|
||||
> `tools:` allowlist without SendMessage. The agent still runs and still
|
||||
> produces correct output; the result is simply never delivered, so the phase
|
||||
> presents as a hung agent that no re-prompting can revive. Measured
|
||||
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
|
||||
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
|
||||
|
||||
**All agents run for all codebase sizes.** Scale `maxTurns` by size (small: halved,
|
||||
medium: default, large: default) instead of dropping agents.
|
||||
|
||||
|
|
|
|||
|
|
@ -87,9 +87,14 @@ Supported flags:
|
|||
behavior). `swarm` runs Voyage's own external-research agent swarm;
|
||||
`deep-research` delegates the external phase to Claude Code's built-in
|
||||
`/deep-research` dynamic workflow and adapts its report into the research-brief
|
||||
schema (requires Claude Code 2.1.154+ and dynamic workflows enabled; falls back
|
||||
to `swarm` and notes the fallback if unavailable — never hard-fails). Orthogonal
|
||||
to `--profile`/`phase_signals`; only affects the external phase. Set
|
||||
schema. **The delegation only works inside a closed version window:
|
||||
`2.1.154 <= CC < 2.1.218`** (dynamic workflows enabled). From Claude Code
|
||||
**2.1.218** `/deep-research` starts only when the operator invokes it, and the
|
||||
Skill tool refuses a model invocation outright (`disable-model-invocation`), so
|
||||
on 2.1.218 or newer the engine **always** falls back to `swarm`. The flag is
|
||||
kept as an additive opt-in and **never hard-fails**: outside the window it
|
||||
degrades to `swarm` and logs the reason. Orthogonal to
|
||||
`--profile`/`phase_signals`; only affects the external phase. Set
|
||||
**engine = {swarm|deep-research}** (the *requested* engine).
|
||||
|
||||
Flags can be combined:
|
||||
|
|
@ -328,9 +333,13 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
|
|||
|
||||
1. **Coarse pre-gate (best-effort, NOT a trust signal).** `Bash: claude --version`;
|
||||
parse the leading `X.Y.Z` (e.g. from `2.1.196 (Claude Code)`) and compare
|
||||
numerically against `2.1.154` — split each on `.` and compare major, then minor,
|
||||
numerically against **both ends** of the supported window
|
||||
`2.1.154 <= version < 2.1.218` — split each on `.` and compare major, then minor,
|
||||
then patch as integers (do NOT string-compare; lexical comparison mis-orders
|
||||
multi-digit patch numbers). If the version is `< 2.1.154`, OR if
|
||||
multi-digit patch numbers). If the version is `< 2.1.154` (below the
|
||||
dynamic-workflows floor), OR `>= 2.1.218` (the ceiling: `/deep-research` starts
|
||||
only when the operator invokes it, and the Skill tool refuses a model invocation
|
||||
with `disable-model-invocation` — see step 4's reason token), OR if
|
||||
`disableWorkflows: true` / `CLAUDE_CODE_DISABLE_WORKFLOWS=1` is set, skip to the
|
||||
fallback (step 4). **If `claude` is not on PATH inside the Bash tool (possible
|
||||
under `claude -p`) or the version cannot be parsed, treat the pre-gate as
|
||||
|
|
@ -347,14 +356,18 @@ changes nothing (SC1). Keep the native-swarm anchors intact ("in parallel",
|
|||
`/deep-research` report actually landed in context — substantive findings with
|
||||
citations, not an empty/denied/errored turn and not bare error text. This check
|
||||
must be **robust to all failure manifestations** (workflow disabled, approval
|
||||
denied, runtime error, empty output), because the disabled-headless behavior is
|
||||
undocumented: no recognizable cited report in context → fall back, regardless of
|
||||
how the failure surfaces.
|
||||
denied, runtime error, empty output, or the Skill tool refusing with
|
||||
`disable-model-invocation` on CC 2.1.218+), because the disabled-headless
|
||||
behavior is undocumented: no recognizable cited report in context → fall back,
|
||||
regardless of how the failure surfaces.
|
||||
|
||||
4. **On no real report (fallback):** set `effective_engine = swarm`, run the swarm
|
||||
blocks below, and **log the fallback at this decision point** — print
|
||||
`Engine: deep-research → swarm (fallback: <reason>)` and carry the reason into the
|
||||
Phase-8 Present summary and the brief's `## Executive Summary`. **NEVER fabricate
|
||||
Phase-8 Present summary and the brief's `## Executive Summary`. Known reason
|
||||
tokens: `disable-model-invocation` (CC >= 2.1.218 — the expected reason on any
|
||||
current Claude Code), `version-below-floor`, `workflows-disabled`,
|
||||
`no-cited-report`. **NEVER fabricate
|
||||
or synthesize a substitute report** — a structurally-valid-but-invented brief
|
||||
passes the structure-only validator and silently poisons `/trekplan`; that is the
|
||||
worst outcome of this feature.
|
||||
|
|
@ -387,19 +400,264 @@ other agents — the value of Gemini is independence.
|
|||
|
||||
- Launch ALL selected agents **in parallel** in a single message
|
||||
- Use model: "opus" for all sub-agents (the orchestrator runs on Opus)
|
||||
- **Never pass the Agent tool's `name` parameter — at this or any other spawn
|
||||
site in this command.** `name` does not label a subagent, it changes its
|
||||
kind: the spawn is recorded as `taskKind: "in_process_teammate"`
|
||||
(`spawnDepth: 0`) instead of a subagent (`spawnDepth: 1`). A teammate's final
|
||||
assistant text is not a return value — it reaches the orchestrator only if
|
||||
the teammate itself calls `SendMessage(to: "main")`, and every agent in
|
||||
`agents/` declares a `tools:` allowlist without SendMessage. The agent still
|
||||
runs and still produces correct output; the result is simply never delivered,
|
||||
so the phase presents as a hung agent that no re-prompting can revive.
|
||||
Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism,
|
||||
denominators, and the on-disk recovery path:
|
||||
`docs/agent-return-channel-defect.md`.
|
||||
- Scale maxTurns by codebase size for local agents (same as trekplan):
|
||||
small = halved, medium/large = default
|
||||
- convention-scanner: medium+ codebases only (50+ files)
|
||||
|
||||
## Phase 4.5 — Dimension discovery
|
||||
|
||||
**Skip this phase entirely unless `phase_signal_result.effort == 'high'` AND
|
||||
`VOYAGE_STORM_ENABLED=1`.** Both conditions, never either.
|
||||
|
||||
This phase never invokes `research-loop-cap.mjs`, so the cap's own flag check
|
||||
does not cover it — the flag has to be read here. Gating on effort alone would
|
||||
leave discovery mutating the dimension list at `effort: high` with the flag
|
||||
unset, making `dimensions` diverge from `dimensions_baseline` and putting the
|
||||
decline branch out of reach for half the mechanism. Doing nothing must leave
|
||||
**both** STORM phases inert.
|
||||
|
||||
Phase 4 retrieves more than the interview knew to ask for. This phase mines
|
||||
that surplus: findings that were **retrieved but unintegrated** — material an
|
||||
agent surfaced that no interview dimension claims.
|
||||
|
||||
1. **Mine.** Walk the Phase-4 agent results and collect findings that map to
|
||||
no existing dimension.
|
||||
2. **Rerank.** Order candidates by relevance to the research question **and**
|
||||
dissimilarity to the dimensions already on the list. A candidate that
|
||||
restates an existing dimension is not a discovery.
|
||||
3. **Augment under the existing ceiling.** Append candidates to the dimension
|
||||
list only while the **whole** list (interview + discovered) stays at or
|
||||
below `maxDimensions: 8` (`settings.json:16`). The ceiling is **not**
|
||||
raised here, so the documented 3–8 dimension range stays true and the
|
||||
README prose about it stays untouched. If the interview already produced 8
|
||||
dimensions, this phase discovers nothing and says so.
|
||||
|
||||
**The ceiling has a reader — use it.** Once the final list is settled, run
|
||||
the check below. Exit 1 means the list exceeded the ceiling: drop discovered
|
||||
dimensions until it passes. Do not proceed to Phase 5 on a rejected list —
|
||||
the turn budget is sized against this same ceiling, so a list over it spends
|
||||
a budget that was never approved for it.
|
||||
|
||||
```bash
|
||||
# Same VOYAGE_ROOT resolution as the per-turn protocol in Phase 5. Exit 0 =
|
||||
# within the ceiling, exit 1 = rejected. JSON on stdout: {ok, count, ceiling, reason?}
|
||||
node "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" --check-dimensions {final dimension count}
|
||||
```
|
||||
|
||||
The ceiling constant is `MAX_TOTAL_DIMENSIONS` in
|
||||
`lib/util/research-loop-cap.mjs` — deliberately the same constant that sizes
|
||||
the Phase 5 turn budget, so the two axes of the bounded-cost NFR cannot end
|
||||
up enforcing different numbers for one `settings.json:16` value.
|
||||
4. **Record the baseline.** Keep the interview-derived count as
|
||||
`dimensions_baseline` so the discovered delta is machine-readable against
|
||||
the final `dimensions` (Phase 8 stats).
|
||||
5. **Attest membership, not just the count.** Set
|
||||
`dimensions_baseline_preserved: true` only if EVERY interview-derived
|
||||
dimension is still on the final list — this phase appends, it never replaces.
|
||||
Set it `false` if any was dropped, merged away, or rewritten. A count delta
|
||||
cannot show this: dropping two interview dimensions and appending three
|
||||
discovered ones is `+1` and still not a superset, which is exactly what the
|
||||
Success Criterion forbids. `storm-measure.mjs --activation-check` reads the
|
||||
field and fails when it is absent, so omitting it is not the silent default.
|
||||
|
||||
Every outbound query generated from a discovered dimension passes
|
||||
`query-privacy-gate.mjs` before it leaves the machine — see the per-turn
|
||||
protocol in Phase 5. That gate controls the **egress** risk this phase's
|
||||
Independence crossing creates: local paths and identifiers travelling inside a
|
||||
query. It does **not** control the **bias** risk — it inspects query content and
|
||||
cannot stop a local finding from steering an external agent's question. The bias
|
||||
controls are structural (blind initial swarm, append-only crossing, unconditional
|
||||
`contrarian-researcher` at `effort: high`); see Hard rules → Independence.
|
||||
|
||||
## Phase 5 — Targeted follow-ups
|
||||
|
||||
Review all agent results. Identify knowledge gaps — areas where findings are
|
||||
thin, contradictory, or missing.
|
||||
Review all agent results. Identify knowledge gaps — dimensions where findings
|
||||
are thin, contradictory, or missing (**under-illuminated dimensions**).
|
||||
|
||||
For each significant gap, launch a targeted follow-up agent (model: "opus")
|
||||
with a narrow, specific brief. Maximum 2 follow-ups.
|
||||
**Standard and low effort — unchanged single pass.** For each significant gap,
|
||||
launch a targeted follow-up agent (model: "opus") with a narrow, specific
|
||||
brief. Maximum 2 follow-ups. If no gaps exist, skip: "Initial research
|
||||
sufficient — no follow-ups needed." Then go to Phase 6.
|
||||
|
||||
If no gaps exist, skip: "Initial research sufficient — no follow-ups needed."
|
||||
**The bounded loop below runs ONLY when `phase_signal_result.effort == 'high'`**
|
||||
(resolved in Phase 1; see `### High-effort behavior (v5.1.1)`). At any other
|
||||
effort this whole sub-section is inert — no loop, no cap ledger, no new
|
||||
counters beyond zero.
|
||||
|
||||
### Loop bound
|
||||
|
||||
**Maximum 3 turns per under-illuminated dimension.** The bound is per
|
||||
dimension, not per run: the worst case is 3 turns × the whole dimension list
|
||||
under the `maxDimensions: 8` ceiling (`settings.json:16`), which is what
|
||||
`research-loop-cap.mjs` sizes itself against. The cap counts itself from its
|
||||
own append-only ledger — it never asks this prose how many turns it has used.
|
||||
|
||||
The loop is **default-off**: `research-loop-cap.mjs` grants a budget of 0
|
||||
unless `VOYAGE_STORM_ENABLED=1`. Doing nothing leaves the mechanism off.
|
||||
|
||||
### Loop scope marker
|
||||
|
||||
`hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`)
|
||||
enforces the same bound in the harness rather than trusting this prose — but it
|
||||
enforces **only** for a session that carries a scope marker, and allows
|
||||
unconditionally for every session that does not. That is what keeps a globally
|
||||
wired PreToolUse hook from denying tool calls in unrelated sessions. Write the
|
||||
marker once, immediately before the first turn:
|
||||
|
||||
`CLAUDE_PLUGIN_DATA` is **empty in the Bash tool's process env** even in a
|
||||
plugin-enabled session, so the root is resolved with the same fallback
|
||||
`research-loop-cap.mjs` uses — `~/.claude/voyage`. Reader and writer must
|
||||
resolve identically; a marker written where the hook does not look leaves the
|
||||
hook allowing unconditionally while the docs call it enforcing.
|
||||
|
||||
```bash
|
||||
# Arms the PreToolUse cap for THIS session only.
|
||||
# CLAUDE_CODE_SESSION_ID is the same id the hook reads as `session_id`.
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
|
||||
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||
if [ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && mkdir -p "$SCOPE_DIR" 2>/dev/null; then
|
||||
printf '{"runId":"%s","startedAt":"%s"}\n' "{run_id}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
> "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
else
|
||||
echo "[voyage] scope marker not written — harness cap stays inert for this run"
|
||||
fi
|
||||
```
|
||||
|
||||
An empty `CLAUDE_CODE_SESSION_ID` is checked before the path is composed, not
|
||||
after: unset, the marker becomes `.json`, which no hook lookup matches and no
|
||||
TTL sweep ever cleans up.
|
||||
|
||||
`runId` MUST be the same `{run_id}` passed to `research-loop-cap.mjs --run-id`.
|
||||
The hook counts ledger lines carrying that id, so a marker written with any
|
||||
other id counts zero turns and enforces nothing.
|
||||
|
||||
**A failed marker write is not a reason to stop.** The hook is defence in
|
||||
depth; `research-loop-cap.mjs` is the gate and stays correct on its own. Report
|
||||
the failure to the operator and run the loop. The reverse — skipping the budget
|
||||
gate because a marker exists — is never allowed.
|
||||
|
||||
**Removal belongs to every exit below, especially the exhausted one.** The hook
|
||||
denies `WebSearch`/`WebFetch`/`Task` once `research-loop-cap.mjs` has denied a
|
||||
turn — the gate records its own denials, so the LAST granted turn still runs its
|
||||
queries and exhaustion reaches you through exit 2 of the budget gate below, not
|
||||
through a blocked tool call. Once denied, the hook keeps denying for as long as
|
||||
the marker is there — including Phase 6, which spawns agents. A marker that outlives the loop turns a bound on this loop into a brick
|
||||
on the rest of the session. Cleanup covers the three exits and nothing else: a
|
||||
crashed session runs no cleanup at all, and is covered instead by the hook's
|
||||
TTL (default 2h, `VOYAGE_CAP_SCOPE_TTL_MS`), which auto-resets a stale marker.
|
||||
A crash mid-loop leaves no denial record, so a resumed session is not blocked by
|
||||
it; only a crash AFTER the cap denied a turn hands the resume a deny window, and
|
||||
every denial prints the marker path to delete.
|
||||
|
||||
```bash
|
||||
# Removal — idempotent, safe to repeat. Same root, same absolute-path guard as
|
||||
# the write: a remove that accepts a root the write rejected (or vice versa)
|
||||
# leaves markers the loop believes it cleaned up.
|
||||
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/voyage}"
|
||||
case "$DATA" in /*) SCOPE_DIR="$DATA/trekresearch-loop-scope" ;; *) SCOPE_DIR="" ;; esac
|
||||
[ -n "$SCOPE_DIR" ] && [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && \
|
||||
rm -f "$SCOPE_DIR/${CLAUDE_CODE_SESSION_ID}.json"
|
||||
```
|
||||
|
||||
### Per-turn protocol
|
||||
|
||||
Each turn targets exactly one under-illuminated dimension, and runs two gates
|
||||
before it spends anything:
|
||||
|
||||
```bash
|
||||
# 0. Resolve the plugin root ONCE. ${CLAUDE_PLUGIN_ROOT} is substituted in this
|
||||
# command's text but is EMPTY in the Bash tool's process env, and a bare
|
||||
# `node ${CLAUDE_PLUGIN_ROOT}/lib/…` then runs `node /lib/…`, which exits 1 —
|
||||
# indistinguishable from a gate that said no.
|
||||
VOYAGE_ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
||||
case "$VOYAGE_ROOT" in
|
||||
/*) ;;
|
||||
*) VOYAGE_ROOT="$(ls -d "$HOME"/.claude/plugins/cache/*/voyage 2>/dev/null | head -1)" ;;
|
||||
esac
|
||||
if [ ! -f "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" ]; then
|
||||
echo "[voyage] gates could not run — plugin root unresolved (exit 2). NOT a denial:"
|
||||
echo " stop the loop and report to the operator. Never proceed ungated."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 1. Budget gate — per turn, per dimension. Exit 0 = granted, exit 1 = denied.
|
||||
# JSON on stdout: {ok, used, budget, reason?}
|
||||
node "$VOYAGE_ROOT/lib/util/research-loop-cap.mjs" \
|
||||
--run-id {run_id} --dimension {dimension} --effort {phase_signal_result.effort}
|
||||
|
||||
# 2. Privacy gate — EVERY outbound query, before it leaves the machine.
|
||||
# Exit 0 = send as-is; exit 1 = rewrite the query and re-gate. Never bypass.
|
||||
node "$VOYAGE_ROOT/lib/validators/query-privacy-gate.mjs" "{query text}"
|
||||
```
|
||||
|
||||
**Exit 2 is not exit 1.** A denied budget gate is an exit condition, not a
|
||||
retry. A failed privacy gate is a rewrite: the hard-block tier (secret-shaped
|
||||
strings) is never operator-overridable, so a query that trips it must be
|
||||
reformulated, not forced through. A gate that *could not run* is neither — no
|
||||
rewrite can clear it, so treat it as a hard stop and say so, rather than
|
||||
rewriting a query that was never the problem.
|
||||
|
||||
**Empty turns.** A turn that returns no findings, or findings without
|
||||
citations, is marked `empty`. An empty turn is counted in `empty_turns` and
|
||||
does NOT re-target the same dimension — re-asking the same question of the
|
||||
same silence is how a bounded loop turns into an unbounded one. Move to the
|
||||
next under-illuminated dimension, or exit.
|
||||
|
||||
### Exits (all three, always one of them)
|
||||
|
||||
1. **Converged** — the dimension carries findings with citations and no
|
||||
remaining contradiction. Stop turning on it. This is the normal exit. Once
|
||||
the last dimension has converged, remove the scope marker.
|
||||
2. **Cap exhausted** — `research-loop-cap.mjs` denies the turn. Print the
|
||||
exhaustion **visibly** to the operator, never silently:
|
||||
`Loop bound reached for dimension {dimension} after {N} turns — remaining
|
||||
gaps are carried into the brief as open questions.` A silent cap is
|
||||
indistinguishable from convergence, and that confusion is exactly what this
|
||||
phase exists to prevent. Then remove the scope marker — leaving it here is
|
||||
what would block Phase 6.
|
||||
3. **Operator stop** — the operator interrupts. Remove the scope marker first,
|
||||
then carry whatever has been gathered into Phase 6 and record the remaining
|
||||
gaps as open questions. Do not re-enter the loop after a stop.
|
||||
|
||||
### When the loop does not apply
|
||||
|
||||
**No-brief default.** Without `--project` (or with a project whose `brief.md`
|
||||
is absent), there are no `phase_signals` to resolve, so `effort = 'standard'`,
|
||||
the loop does not run, and all new counters (`conv_turns`, `empty_turns`) are
|
||||
emitted as `0`.
|
||||
|
||||
**Precedence matrix — each entry independently makes the loop moot**, the same
|
||||
way `--engine` is moot when the external phase does not run (see the moot gate
|
||||
in Phase 4):
|
||||
|
||||
| Condition | Effect on the loop |
|
||||
|-----------|--------------------|
|
||||
| `--quick` | Moot — Phase 3.5 skips to Phase 8; the swarm never runs |
|
||||
| `--local` | Moot — no outbound queries to bound |
|
||||
| `external_research_enabled: false` (profile) | Moot — the profile's on/off switch wins |
|
||||
|
||||
**Interaction rule.** A brief that carries `effort: high` **without** a
|
||||
`model`, under a cheap profile (`economy`/`balanced`): the effort signal
|
||||
governs orchestration shape, so the loop is armed, but the profile still
|
||||
supplies the model — and if that profile disables external research, the
|
||||
matrix above wins and the loop is moot regardless of effort.
|
||||
|
||||
**Honesty (hard rule, restated for this loop).** More turns do not make a
|
||||
finding more credible. Turn count is a cost, not evidence: report what the
|
||||
citations support, and let an exhausted cap show up as open questions rather
|
||||
than as confidence.
|
||||
|
||||
## Phase 6 — Triangulation
|
||||
|
||||
|
|
@ -535,6 +793,12 @@ Record format (one JSON line):
|
|||
"project_dir": "{project_dir or null}",
|
||||
"brief_path": "{brief_destination}",
|
||||
"dimensions": {N},
|
||||
"dimensions_baseline": {N},
|
||||
"dimensions_baseline_preserved": {true|false},
|
||||
"effort": "{low|standard|high}",
|
||||
"conv_turns": {N},
|
||||
"empty_turns": {N},
|
||||
"unique_sources": {N},
|
||||
"agents_local": {N},
|
||||
"agents_external": {N},
|
||||
"gemini_used": {true|false},
|
||||
|
|
@ -544,6 +808,21 @@ Record format (one JSON line):
|
|||
}
|
||||
```
|
||||
|
||||
**The six measurement fields (v5.10).** `effort` is the grouping key — the
|
||||
resolved `phase_signal_result.effort` for the `research` phase, a
|
||||
low-cardinality label (`low|standard|high`), and the only axis on which a
|
||||
high-effort run can be compared against a standard one. Four are numeric:
|
||||
`unique_sources` (distinct sources cited across the brief),
|
||||
`dimensions_baseline` (the interview-derived dimension count, so the Phase 4.5
|
||||
delta against `dimensions` is machine-readable), `conv_turns` (Phase 5 loop
|
||||
turns actually spent), and `empty_turns` (loop turns that returned no findings
|
||||
or no citations). The sixth is boolean: `dimensions_baseline_preserved`, the
|
||||
Phase 4.5 attestation (step 5) that every interview dimension survived onto the
|
||||
final list — the count delta cannot show membership, and the dimension NAMES
|
||||
that could are prose the exporter allowlist denies. On a standard run the loop
|
||||
never arms, so `dimensions_baseline == dimensions`, both turn counters are `0`,
|
||||
and `dimensions_baseline_preserved` is `true` (nothing touched the list).
|
||||
|
||||
If `${CLAUDE_PLUGIN_DATA}` is not set or not writable, skip tracking silently.
|
||||
|
||||
## Profile (v4.1)
|
||||
|
|
@ -623,6 +902,14 @@ significant architectural questions or when triangulation value is
|
|||
high; in high-effort mode it runs unconditionally to provide an
|
||||
independent second opinion.
|
||||
|
||||
High effort additionally arms the Phase 5 bounded follow-up loop (max 3
|
||||
turns per under-illuminated dimension, budgeted by
|
||||
`research-loop-cap.mjs`, every outbound query gated by
|
||||
`query-privacy-gate.mjs`). The loop stays default-off until
|
||||
`VOYAGE_STORM_ENABLED=1`, and the moot matrix in Phase 5 (`--quick`,
|
||||
`--local`, `external_research_enabled: false`) overrides the effort
|
||||
signal whenever the external phase does not run at all.
|
||||
|
||||
Standard effort (or absent): use the existing conditional triggers.
|
||||
Low effort: inline research only, no agent swarm (existing
|
||||
`--quick`-equivalent code-path).
|
||||
|
|
@ -634,6 +921,32 @@ Low effort: inline research only, no agent swarm (existing
|
|||
- **Sources required:** Every claim must cite a source. No unsourced findings.
|
||||
- **Independence:** Do not pre-bias external agents with local findings or vice versa.
|
||||
Triangulate AFTER independent research.
|
||||
**Amended (v5.10) for Phase 4.5:** dimension discovery deliberately crosses this
|
||||
rule. Its candidate dimensions are mined from the Phase-4 result set, which
|
||||
contains output from the five local codebase agents, so a discovered dimension
|
||||
can carry local context into an external query.
|
||||
|
||||
The crossing creates **two distinct risks**, and they do not share a control:
|
||||
|
||||
- **Bias** — a local finding shapes what an external agent is asked. Its
|
||||
controls are structural, not a gate: the initial external swarm stays blind
|
||||
to local findings, so an **independent baseline already exists** before
|
||||
anything crosses; the crossing is confined to Phase 4.5 and the Phase 5 loop
|
||||
it feeds, which only ADD to that baseline and never revise it; and at
|
||||
`effort: high` — the only effort at which any of this runs —
|
||||
`contrarian-researcher` is forced always-on, so the brief always carries an
|
||||
adversarial counter-evidence pass over the result the crossed queries fed.
|
||||
Triangulation still happens AFTER independent research.
|
||||
- **Egress** — local paths, repo identifiers or secret-shaped strings leave the
|
||||
machine inside a query. That is what `query-privacy-gate.mjs` controls: every
|
||||
outbound query is inspected before it leaves, with a hard-block tier for
|
||||
secret-shaped strings that no operator flag can override.
|
||||
|
||||
The privacy gate was previously named as the compensating control for the
|
||||
crossing as a whole. It is not: it inspects query CONTENT and cannot stop a
|
||||
local finding from steering an external agent's question. Attributing the bias
|
||||
risk to it left that risk with no control while the text read as though it had
|
||||
one.
|
||||
- **Graceful degradation:** If MCP tools are unavailable (Tavily, Gemini, MS Learn),
|
||||
proceed with available tools and note limitations in brief metadata.
|
||||
- **Cost:** Model resolution at Agent-spawn sites is a three-layer fallback:
|
||||
|
|
|
|||
|
|
@ -199,6 +199,18 @@ described in the rest of this phase.
|
|||
Launch two reviewer agents **in parallel** via the Agent tool — one
|
||||
message, multiple tool calls.
|
||||
|
||||
> **Never pass the Agent tool's `name` parameter — at this or any other spawn
|
||||
> site in this command.** `name` does not label a subagent, it changes its kind:
|
||||
> the spawn is recorded as `taskKind: "in_process_teammate"` (`spawnDepth: 0`)
|
||||
> instead of a subagent (`spawnDepth: 1`). A teammate's final assistant text is
|
||||
> not a return value — it reaches the orchestrator only if the teammate itself
|
||||
> calls `SendMessage(to: "main")`, and every agent in `agents/` declares a
|
||||
> `tools:` allowlist without SendMessage. The agent still runs and still
|
||||
> produces correct output; the result is simply never delivered, so the phase
|
||||
> presents as a hung agent that no re-prompting can revive. Measured
|
||||
> 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism, denominators, and the
|
||||
> on-disk recovery path: `docs/agent-return-channel-defect.md`.
|
||||
|
||||
Reviewers run independently. Do NOT pre-feed findings between them.
|
||||
|
||||
| Agent | Mode-gated | Purpose |
|
||||
|
|
@ -237,6 +249,37 @@ do not feed unvalidated findings to the coordinator.
|
|||
In `quick` mode, launch only `code-correctness-reviewer`. The Executive
|
||||
Summary will note the brief-conformance pass was skipped.
|
||||
|
||||
### Reviewer accounting — every expected reviewer MUST report
|
||||
|
||||
Write down the expected reviewer set BEFORE the spawn: both reviewers in
|
||||
default mode, `code-correctness-reviewer` alone in `quick` mode. After the
|
||||
spawn, account for each one by name.
|
||||
|
||||
**Zero findings from a silent reviewer is indistinguishable from zero findings
|
||||
from a clean diff** — unless you check. A reviewer is *accounted for* only when
|
||||
it returned a payload that validated. Three ways it fails to:
|
||||
|
||||
| Failure | Handling |
|
||||
|---------|----------|
|
||||
| Output fails the schema after the 2 bounded re-asks | STOP (already specified above) |
|
||||
| Returned no final message at all | Re-ask that reviewer **once**. Still nothing → STOP. |
|
||||
| Was never launched (spawn error, wrong mode) | STOP. |
|
||||
|
||||
**On STOP: name the reviewer and the failure, and do not proceed to Phase 6.**
|
||||
Do not let the coordinator compute a verdict over a review one of whose
|
||||
reviewers never spoke — the count would be complete-looking and wrong. This is
|
||||
the same shape as the schema branch above ("do not feed unvalidated findings to
|
||||
the coordinator"), applied to the other two ways a reviewer can go missing.
|
||||
|
||||
A reviewer that ran but never delivered is most often the return-channel
|
||||
defect: check `~/.claude/projects/<proj>/<session>/subagents/agent-*.jsonl` for
|
||||
its final assistant block before re-asking, and confirm no `name` parameter was
|
||||
passed at the spawn (see the warning at the top of this phase).
|
||||
|
||||
If you proceed anyway under an explicit operator instruction, pass the expected
|
||||
set to the coordinator as `expectedReviewers` so the missing reviewer at least
|
||||
forbids `ALLOW` (`lib/review/coordinator-contract.mjs`, `missing_reviewers`).
|
||||
|
||||
## Phase 6 — Coordinator dedup + verdict
|
||||
|
||||
Launch `review-coordinator` (Agent tool) with the merged findings array
|
||||
|
|
@ -247,10 +290,20 @@ The coordinator runs the 4-pass process documented in
|
|||
|
||||
1. **Dedup** by `(file, line, rule_key)` triplet.
|
||||
2. **HubSpot Judge filters** — Succinctness, Accuracy, Actionability.
|
||||
3. **Cloudflare reasonableness** — drop speculative or catalogue-violating
|
||||
3. **Cloudflare reasonableness** — remove speculative or catalogue-violating
|
||||
findings (skipped in `quick` mode).
|
||||
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
|
||||
|
||||
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either
|
||||
**dropped** (the test refuted the finding as a claim about this codebase) or
|
||||
**unverified** (the finding was removed without its claim ever being settled).
|
||||
A non-empty `unverified` bucket forbids `ALLOW`; the verdict becomes `WARN` and
|
||||
the Executive Summary's first sentence must say why. The fail-closed rule never
|
||||
raises a verdict — it only withholds the clean one. Fate table, reason
|
||||
vocabulary, and the `allow_blocked_by` field: `agents/review-coordinator.md`
|
||||
§*Suppression is two-valued*, mirrored deterministically in
|
||||
`lib/review/coordinator-contract.mjs`.
|
||||
|
||||
The coordinator's output is the full review.md content — frontmatter +
|
||||
body sections + trailing JSON block. Do NOT re-run the reviewers based
|
||||
on the coordinator's output.
|
||||
|
|
|
|||
295
docs/BRIEF-vurdering-v2.md
Normal file
295
docs/BRIEF-vurdering-v2.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# BRIEF — external assessment (VURDERING-V2 §5.12), verified against this repo
|
||||
|
||||
**Status:** assessment complete. **No implementation in this session** (scope-guard).
|
||||
**Source under review:** `claude-playlist-corpus/docs/VURDERING-V2.md` §5.12 (+ §3 G1–G8, §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 5–6 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.
|
||||
268
docs/agent-return-channel-defect.md
Normal file
268
docs/agent-return-channel-defect.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# The Agent tool's `name` parameter silently destroys the return channel
|
||||
|
||||
**Date:** 2026-08-17 (voyage S85)
|
||||
**Claude Code:** 2.1.226 · **Plugin:** voyage 5.9.1 (installed cache — verified byte-identical to the repo working tree for both reviewer agents before measuring)
|
||||
**Status:** Root cause MEASURED and mechanistically confirmed. The defect is in
|
||||
how the harness treats one Agent-tool parameter, not in voyage's agent
|
||||
definitions. A one-line workaround exists and is now pinned in every voyage
|
||||
spawn site.
|
||||
|
||||
## The report that triggered this
|
||||
|
||||
`akashic-intelligence` lost `/trekplan` Phase 9 twice. S25 (2026-08-17, plan
|
||||
rev 5.0): four subagents spawned via the Agent tool — `voyage:plan-critic` ×2,
|
||||
`voyage:scope-guardian` ×2, all `model: "opus"`, not Workflow. Nine
|
||||
`SendMessage` reminders, zero replies. The round before had the same outcome
|
||||
with tighter scope and an explicit tool-call budget. Consequence: the
|
||||
hand-dedup step fell away (one source instead of two) and review coverage came
|
||||
out **narrower** than the four preceding rounds.
|
||||
|
||||
Their own conclusion was "the channel, not the agents" — directionally right.
|
||||
Two details in it are wrong and matter: they attributed it to the agents having
|
||||
no `Write` tool, and they concluded the remedy is to abandon agents and run the
|
||||
review inline. `Write` is irrelevant, and the agents do not need abandoning.
|
||||
|
||||
## Root cause
|
||||
|
||||
**Passing `name` to the Agent tool does not name a subagent. It changes what
|
||||
kind of thing gets spawned.**
|
||||
|
||||
The spawn metadata says it outright. From
|
||||
`~/.claude/projects/<project>/<session>/subagents/agent-*.meta.json`, same
|
||||
session, same model, same `subagent_type`:
|
||||
|
||||
| spawn | recorded `agentType` | `taskKind` | `spawnDepth` |
|
||||
|---|---|---|---|
|
||||
| without `name` | `voyage:plan-critic` | *(none — a real subagent)* | `1` |
|
||||
| with `name` | `pc-opus` (the name) | `in_process_teammate` | `0` |
|
||||
|
||||
The two kinds have different return semantics:
|
||||
|
||||
- **Subagent** (`spawnDepth: 1`) — the agent's final assistant text **is** the
|
||||
return value. It arrives at the parent as a task notification.
|
||||
- **Teammate** (`in_process_teammate`, `spawnDepth: 0`) — a peer of the main
|
||||
session, not a child of it. Its plain final text is **not** a return value; it
|
||||
is only transcript. A teammate reaches the parent **only** by calling
|
||||
`SendMessage(to: "main")`.
|
||||
|
||||
Every voyage agent declares a `tools:` allowlist. `plan-critic` and
|
||||
`scope-guardian` declare `["Read", "Glob", "Grep"]`. **No SendMessage.** As
|
||||
teammates they are therefore *structurally* incapable of returning anything —
|
||||
no prompt, no scope tightening, and no tool-call budget can change that. This
|
||||
is exactly why akashic's second attempt with a stricter prompt failed
|
||||
identically: the prompt was never the variable.
|
||||
|
||||
### Why it looks like a hung agent
|
||||
|
||||
The teammate runs. It reads the plan, reasons, and writes a complete, correct
|
||||
final answer to its transcript. Then it stops. From the orchestrator's side
|
||||
this is indistinguishable from an agent that stalled — so the natural response
|
||||
is to poke it with `SendMessage`, which produces more transcript that also
|
||||
never comes back. Nine reminders, zero replies.
|
||||
|
||||
### The symptom signature, exactly
|
||||
|
||||
The teammates do eventually surface — as content-free idle notifications,
|
||||
delivered long after the fact. Observed in this session, all six named agents
|
||||
(times UTC):
|
||||
|
||||
```
|
||||
{"type":"idle_notification","from":"ctrl-pong", "idleReason":"available"} 19:57:30
|
||||
{"type":"idle_notification","from":"ctrl-read", "idleReason":"available"} 19:57:35
|
||||
{"type":"idle_notification","from":"pc-nooverride", "idleReason":"available"} 19:59:19
|
||||
{"type":"idle_notification","from":"pc-opus", "idleReason":"available"} 19:59:24
|
||||
{"type":"idle_notification","from":"sg-opus", "idleReason":"available"} 19:59:37
|
||||
{"type":"idle_notification","from":"ctrl-sendback", "idleReason":"available",
|
||||
"summary":"[to main] PONG-VIA-SENDMESSAGE"} 20:06:16
|
||||
```
|
||||
|
||||
This is akashic's "innholdsløse idle-pings", verbatim, and it is the field
|
||||
signature to recognise the defect by. Two things to read off it:
|
||||
|
||||
- **`summary` is present only for the teammate that called SendMessage.** For
|
||||
the five tool-less ones the notification carries no content at all — not a
|
||||
truncated result, not an error, nothing. The presence or absence of `summary`
|
||||
is the fastest way to tell a delivering teammate from a mute one.
|
||||
- **The idle notifications lag by minutes.** `ctrl-pong` went idle 10 seconds
|
||||
after spawn; its notification arrived ~9 minutes later. So "no idle ping yet"
|
||||
is not evidence the agent is still working, and the ping, when it comes, is
|
||||
not a result. Judging liveness from these is how a completed run gets
|
||||
re-prompted nine times.
|
||||
|
||||
**The work is not lost.** It is on disk at
|
||||
`~/.claude/projects/<project-slug>/<session-id>/subagents/agent-a<name>-<hash>.jsonl`.
|
||||
The final assistant text block in that file is the answer the orchestrator never
|
||||
received. This is the recovery path when a run has already been burned.
|
||||
|
||||
## Measurements
|
||||
|
||||
One session, one target unless noted. Target for the reviewer cells:
|
||||
`docs/devils-advocate-plan.md` (66 lines) — a real plan file, small enough that
|
||||
runtime cannot be confused with hanging.
|
||||
|
||||
| # | `subagent_type` | `model` | `name`? | Returned to parent? | Time |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `general-purpose` ("reply PONG", 0 tools) | `opus` | yes | **no** | >17 min, never |
|
||||
| 2 | `general-purpose` (read file, count headings) | `opus` | yes | **no** | never |
|
||||
| 3 | `voyage:plan-critic` | `opus` | yes | **no** | never |
|
||||
| 4 | `voyage:scope-guardian` | `opus` | yes | **no** | never |
|
||||
| 5 | `voyage:plan-critic` | *(no override)* | yes | **no** | never |
|
||||
| 6 | `general-purpose`, told to call `SendMessage(to: "main")` | `opus` | yes | **yes** | ~30 s |
|
||||
| 7 | `general-purpose` ("reply PONG", 0 tools) | `opus` | **no** | **yes** | **2.19 s** |
|
||||
| 8 | `voyage:plan-critic` | `opus` | **no** | **yes** — full findings + JSON block | **110.3 s**, 7 tool uses |
|
||||
| 9 | `voyage:scope-guardian` | `opus` | **no** | **yes** — full findings + JSON block | **109.1 s**, 8 tool uses |
|
||||
|
||||
Denominators, per varied dimension:
|
||||
|
||||
- **`name` present, no explicit SendMessage: 0/5 returned.** `name` absent:
|
||||
**3/3 returned.** This is the only dimension that moved the outcome.
|
||||
- **`name` present *with* an explicit `SendMessage(to: "main")` instruction:
|
||||
1/1 returned** (cell 6). This is the positive control for the teammate
|
||||
channel: it is not broken, it is merely never used by tool-less agents.
|
||||
- **Model override: 2/2 non-returning.** Cells 3 (`model: "opus"`) and 5 (no
|
||||
override) are identical in outcome — the override is not the variable, which
|
||||
rules out akashic's stated `model: "opus"` detail as causal.
|
||||
- **Agent type: 2/2 in each arm.** `plan-critic` and `scope-guardian` behave
|
||||
identically named (both silent) and unnamed (both complete). The agent
|
||||
definitions are not the variable.
|
||||
- **Work actually performed while named: 5/5.** All five named agents produced
|
||||
correct, complete final text in their transcripts within ~3 minutes —
|
||||
`PONG`, `8`, plan-critic's blocker list with the correct legacy-format
|
||||
advisory, and scope-guardian's coverage table. Nothing failed except delivery.
|
||||
|
||||
### Known-positive discipline (Verifiseringsloven face 4)
|
||||
|
||||
Two negative results in this investigation were produced by broken queries and
|
||||
would have been consumed as facts:
|
||||
|
||||
1. **`ListAgents` returned no in-process subagents** while five were running. It
|
||||
listed 26 peer sessions and none of mine. Probing one directly with
|
||||
`SendMessage` proved the agents existed and had live inboxes. Had the empty
|
||||
listing been believed, the diagnosis would have been "the spawns silently
|
||||
no-op" — wrong.
|
||||
2. **The first known-positive control failed.** Cell 1 (named PONG) was meant to
|
||||
prove the spawn mechanism works, and it did not return either. A failed
|
||||
control proves nothing; it only means the control shared the defect. The
|
||||
control that discriminates is cell 7 — same prompt, same model, same agent
|
||||
type, `name` removed — which returned in 2.19 s.
|
||||
|
||||
**Unmeasured cells, stated as unmeasured:** the returning arm was only exercised
|
||||
against the 66-line target. Large targets (voyage's 573-line plan;
|
||||
akashic's 3730-line / 277 KB `features/01-sun-position/plan.md`) were **not**
|
||||
measured in either arm. Nothing here rules out a *separate*, size-dependent
|
||||
failure at akashic's scale — it only establishes that the failure they observed
|
||||
reproduces at 66 lines, where size cannot be the cause.
|
||||
|
||||
**External measurement closes half of that (2026-08-17).** `akashic-intelligence`
|
||||
recovered its own S25 transcripts from
|
||||
`~/.claude/projects/<slug>/<session>/subagents/` and read the last assistant
|
||||
text block out of each. Denominator: 4 of 4 agents, all **named**, all against
|
||||
the full 3730-line / 277 KB plan. Three produced valid JSON review output
|
||||
(17, 8 and 13 findings); the fourth was their PONG control and produced
|
||||
`PONG`. Their commit `e281a9d`.
|
||||
|
||||
What that closes: **the work is performed at 3730 lines.** No size-dependent
|
||||
failure of the agent's *reasoning or output* exists at that scale — the named
|
||||
arm produces correct final text at 66 lines and at 3730 lines alike, and only
|
||||
delivery fails, identically at both. The 38 recovered findings were re-used
|
||||
instead of re-run.
|
||||
|
||||
**A second external measurement closes the other half (akashic-intelligence
|
||||
S27, commit `f168630`).** The gap left above was that all four S25 cells were
|
||||
named, so the *returning* (unnamed) arm had no measurement above 66 lines.
|
||||
S27 supplies one: denominator **2 of 2 unnamed agents**, against a 4370-line
|
||||
plan; both returned, 30449 B and 10989 B, both valid JSON. The unnamed arm
|
||||
therefore returns at full scale as observed fact, not as inference.
|
||||
|
||||
Two caveats, kept at the strength akashic itself stated them. The measurement
|
||||
was taken by the repo that owns the finding, not by an independent third party.
|
||||
And byte-identity between the returned string and the file on disk was not
|
||||
proven — what is established is that a well-formed result of that size arrived,
|
||||
not that it arrived unaltered.
|
||||
|
||||
Their PONG control also carries the same lesson as cell 1 above, in a third
|
||||
repo: S25 reported that agent as having "gone idle without sending PONG". It
|
||||
sent PONG. Absence of *delivery* was read as absence of *work* — the same
|
||||
face-4 error, three times in one evening.
|
||||
|
||||
## Where the defect lives
|
||||
|
||||
**Outside this repository.** Nothing in `agents/plan-critic.md` or
|
||||
`agents/scope-guardian.md` is wrong: correct frontmatter, a `tools:` list
|
||||
appropriate to a read-only reviewer, a terminating prompt, and an output format
|
||||
that both agents produced verbatim when spawned as real subagents. The failure
|
||||
is a harness behaviour — a parameter that silently changes a spawn's kind and
|
||||
its return contract, with no error, no warning, and no observable difference at
|
||||
the call site beyond the wording of the tool result.
|
||||
|
||||
Per the standing rule, this is **not** worked around silently. It is documented
|
||||
here, reported to `.claude` (which owns cross-repo quality and can escalate),
|
||||
and guarded in voyage's own spawn instructions so no consumer re-enters it.
|
||||
|
||||
## In-repo fix
|
||||
|
||||
Every voyage command that spawns agents — `trekbrief`, `trekplan`,
|
||||
`trekresearch`, `trekreview` — now states the rule at its spawn site, naming
|
||||
the mechanism (`in_process_teammate`) so it cannot be mistaken for style
|
||||
preference. `trekexecute` spawns nothing (Hard Rule 10) and is excluded.
|
||||
|
||||
Pinned by `tests/lib/doc-consistency.test.mjs`:
|
||||
|
||||
- `S85: the set of agent-spawning commands is exactly the four that carry the
|
||||
no-name rule` — derives the spawning set from the command files themselves, so
|
||||
the pin cannot go vacuous when a command starts or stops spawning.
|
||||
- `S85: commands/<cmd>.md forbids the Agent tool's name parameter at its spawn
|
||||
sites` — one per command.
|
||||
|
||||
Verified red before the prose was written, green after.
|
||||
|
||||
## Recommended working shape for consumers
|
||||
|
||||
For anyone running voyage's reviewers (akashic included), grounded in the
|
||||
measurements above rather than in preference:
|
||||
|
||||
**1. Use agents. Spawn them without `name`.** Cells 8 and 9 are the evidence:
|
||||
both reviewers returned complete, schema-conformant output — human-readable
|
||||
findings *and* the machine-readable JSON block the dedup helper consumes — in
|
||||
~110 seconds, in parallel, from a single message. This is the intended shape and
|
||||
it works. Removing `name` is the entire fix.
|
||||
|
||||
**2. Do not switch to inline review as a remedy.** akashic's fallback was
|
||||
rational under their diagnosis but it pays a real, measured price: with one
|
||||
source there is no dedup step, and their own record shows `scope-guardian`
|
||||
finding blockers in the rev 3.0/4.0 rounds that `plan-critic` did not see at
|
||||
all. The two agents are not redundant — cells 8 and 9 here returned largely
|
||||
*different* findings on the same 66-line plan (plan-critic led on
|
||||
placeholder/headless defects; scope-guardian led on the plan already having been
|
||||
executed and its deliverable never being written). Two independent passes with
|
||||
different mandates is the property worth keeping, and unnamed agents deliver it
|
||||
for free. Running two inline passes is the fallback if agents fail *for a
|
||||
different, verified reason* — not for this one.
|
||||
|
||||
**3. If you deliberately want a named teammate, give it SendMessage and say so.**
|
||||
Cell 6 shows the teammate channel works when used. That means adding
|
||||
`SendMessage` to the agent's `tools:` list *and* instructing it in the prompt to
|
||||
call `SendMessage(to: "main")` with its result. voyage does not do this: its
|
||||
reviewers are read-only by design, and the subagent path already returns.
|
||||
|
||||
**4. A hard tool-call budget is not the lever here.** akashic tried a tighter
|
||||
budget and it changed nothing, which this measurement explains: a 0-tool-call
|
||||
agent (cell 1) failed exactly like a 7-tool-call one. Budget caps are a cost
|
||||
control, not a liveness control.
|
||||
|
||||
**5. When an agent looks hung, check its transcript before re-prompting.** The
|
||||
final assistant block in
|
||||
`~/.claude/projects/<project-slug>/<session-id>/subagents/agent-*.jsonl` tells
|
||||
you within seconds whether the agent failed or merely could not deliver. Reading
|
||||
that file directly recovers a burned run's output. Extract only the last
|
||||
assistant text block — these transcripts run to 140 KB and reading one whole
|
||||
will flood the orchestrator's context.
|
||||
|
||||
## Open
|
||||
|
||||
- The `--gates`-adjacent "idle-agent gap" already logged as open operator
|
||||
decision #3 in `STATE.md` (`/trekreview` Phase 5, `/trekplan` Phases 5/6/9,
|
||||
`/trekresearch` Phase 4 have no empty-return detection) now has a measured
|
||||
root cause for its most likely trigger. Whether to add active detection — as
|
||||
opposed to the prevention pinned here — remains open and is not decided by
|
||||
this document.
|
||||
|
|
@ -12,6 +12,8 @@ Imported from `CLAUDE.md` via pointer.
|
|||
- `lib/stats/event-emit.mjs` — single-source stats event emitter for autonomy-gate transitions and main-merge-gate (v3.4.0)
|
||||
- `lib/validators/{brief,research,plan,progress,session-state}-validator.mjs` — schema validators with CLI shims (`node lib/validators/X.mjs --json <path>`)
|
||||
- `lib/validators/architecture-discovery.mjs` — drift-WARN external-contract discovery for `architecture/overview.md`
|
||||
- `lib/util/research-loop-cap.mjs` — stateful, **default-off** turn budget for the `/trekresearch` bounded conversation loop. `allowTurn()` derives the used-turn count from its own append-only JSONL ledger; it never asks the caller how many turns it has spent, because a cap that does is not a cap. Each grant first claims a turn **slot** with `O_EXCL` under `trekresearch-loop-claims/`, so the bound survives several callers deciding at once — counting the ledger and then appending is read-then-write, and Phase 4.5/5 can spawn several agents in one message. Budget = `TREKRESEARCH_MAX_CONV_TURNS` (default `3`, invalid values fall back to `3`) × `maxDimensions` (8, `settings.json:16`). **Default-off:** grants 0 unless `VOYAGE_STORM_ENABLED=1`, which is also the second condition on Phase 4.5's skip-guard (that phase does not call this module): unset, **both** STORM phases are inert. `resolveDataRoot()` is the single root for everything the loop writes — `CLAUDE_PLUGIN_DATA` when the harness sets it, `~/.claude/voyage` when it does not (it is empty in the Bash tool's process env, which is where the loop actually runs); the cap hook resolves through the same function, so writer and reader cannot disagree. A ledger that cannot be **written** denies the turn, and one that exists but cannot be **read** denies it too — only `ENOENT` counts as zero turns spent, that being the legitimate first-turn state (fail-closed — the opposite of `event-emit.mjs`, which is telemetry and must never block). The exported `readLedger()` is the single counting rule; the cap hook calls it rather than keeping a private copy. `checkDimensionCeiling()` is the reader for the OTHER axis of the bounded-cost NFR — the size of the whole dimension list after Phase 4.5 discovery — against the same `MAX_TOTAL_DIMENSIONS`, so the two axes cannot enforce different numbers for one `settings.json:16` value; an unreadable count is rejected, not waved through. CLI shim, two modes: `--run-id ID --dimension D --effort E` (budget gate) and `--check-dimensions N` (ceiling, no run id/effort/flag required since Phase 4.5 never calls the budget gate)
|
||||
- `lib/validators/query-privacy-gate.mjs` — gates **every** outbound research query before it leaves the machine; the hard-block tier (secret-shaped strings) is not operator-overridable, so a query that trips it must be reformulated rather than forced through. CLI shim: `node lib/validators/query-privacy-gate.mjs "<query>"`
|
||||
|
||||
Wiring points (replaces previous prose-grep instructions):
|
||||
- `/trekbrief` Phase 4g → `brief-validator` (post-write sanity check)
|
||||
|
|
@ -31,6 +33,8 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
|
|||
|
||||
`hooks/scripts/post-bash-stats.mjs` (PostToolUse, CC v2.1.97+) appends `duration_ms` for each Bash call into `${CLAUDE_PLUGIN_DATA}/trekexecute-stats.jsonl`. Useful for finding long-running verify or checkpoint commands.
|
||||
|
||||
`hooks/scripts/pre-agent-cap.mjs` (PreToolUse on `WebSearch|WebFetch|Task`) enforces the `/trekresearch` Phase 5 loop bound in the harness, so the cap is not merely prose the model is asked to obey. It counts spent turns read-only from the append-only ledger `research-loop-cap.mjs` writes, through that module's own exported `readLedger()`. It denies (exit 2) once the budget **gate has denied a turn** — the primitive records its denials as tombstones, and the tombstone is the boundary rather than the count, because `allowTurn()` appends before the turn runs and so the final granted turn already shows `budget` records. A ledger showing more granted turns than the budget denies too, as a backstop. Being in scope but unable to count the ledger also denies: a budget control that cannot count must not grant. Scope key = `session_id` + a marker file only the loop writes (`<resolveDataRoot()>/trekresearch-loop-scope/<session_id>.json`, the same root the ledger uses); without a marker the hook allows unconditionally, which is what keeps a globally-wired `PreToolUse` hook from over-blocking unrelated sessions. Stale markers auto-reset on a TTL and `VOYAGE_DISABLE_CAP_HOOK=1` is the kill switch. Defence in depth only — `lib/util/research-loop-cap.mjs` must stay correct if the hook stops firing (see `docs/spike-pretooluse-subagent-reach.md`).
|
||||
|
||||
`hooks/scripts/post-compact-flush.mjs` (PostCompact event, v3.4.0) re-injects `.session-state.local.json` after context compaction so multi-session work survives a compaction boundary. Companion to `pre-compact-flush.mjs` (which writes the state file before compaction); together they form the rehydrate cycle that keeps `/trekcontinue` reliable across long-running multi-session work.
|
||||
|
||||
## Architecture
|
||||
|
|
@ -92,7 +96,7 @@ Which native Claude Code primitive each pipeline step runs on today, and the alt
|
|||
| **execute** | Inline step loop; multi-session via `git worktree` + `claude -p` waves; deterministic manifest audit | CC `TaskCreate`/`TodoWrite` for progress/resume (insufficient — carries no step status / attempts / SHA / drift → own typed `progress.json` contract) |
|
||||
| **review** | Inline parallel reviewers (no cross-feed) → `review-coordinator` Judge | **Workflow** substrate for Phase 5–6 (bake-off POSITIVE: +4.4 % tokens / +54 % wall-time → shipped **opt-in `--workflow`**, not default; wholesale substrate swap declined) |
|
||||
| **continue** | Inline reads `.session-state.local.json` → zero-confirm resume | CC `--resume` (transcript replay, not typed work-state → insufficient) |
|
||||
| **cross-cutting** | 7 hook scripts: `pre-bash` + `pre-write` guards, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — |
|
||||
| **cross-cutting** | 8 hook scripts: `pre-bash` + `pre-write` guards, `pre-agent-cap` loop-bound enforcement, `post-bash` stats, `session-title`, `pre-`/`post-compact` flush, **`Stop`→OTEL** export | — |
|
||||
|
||||
¹ MCP per research agent: `docs-researcher` → Microsoft Learn + Tavily · `community-`/`security-`/`contrarian-researcher` → Tavily (+ WebSearch/WebFetch) · `gemini-bridge` → Gemini Deep Research MCP. Graceful degradation when an MCP server is absent.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,26 @@ Always interactive. Phase 3 is a section-driven completeness loop (no hard cap o
|
|||
| `--gates {true\|false}` | (v3.4.0) Boolean autonomy-gate flag; present → gating on. Policy (`gates_mode`) detailed under `## Autonomy mode` in `docs/operations.md`. |
|
||||
| `--min-brief-version <ver>` | (S18) Warn — never block — if an attached `--project` brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement |
|
||||
| `--profile <name>` | (v4.1.0) Model profile for the research phase. |
|
||||
| `--engine {swarm\|deep-research}` | (deep-research-engine) Opt-in external-research engine; `deep-research` delegates the external phase to Claude Code's built-in `/deep-research` workflow (CC 2.1.154+), falls back to `swarm`. Default `swarm`. |
|
||||
| `--engine {swarm\|deep-research}` | (deep-research-engine) Opt-in external-research engine; `deep-research` delegates the external phase to Claude Code's built-in `/deep-research` workflow, which only works on `2.1.154 <= CC < 2.1.218` — from **2.1.218** `/deep-research` is operator-invoked only (the Skill tool refuses with `disable-model-invocation`), so the engine always falls back to `swarm`. Never hard-fails. Default `swarm`. |
|
||||
|
||||
Flags combine: `--project <dir> --local`, `--external --quick`.
|
||||
|
||||
### Bounded conversation loop (Phase 4.5 + Phase 5) — env-vars
|
||||
|
||||
Dimension discovery and the multi-turn follow-up loop are **default-off** and
|
||||
have no flag; they are environment-gated, because turning them on costs turns.
|
||||
They run only at `effort: high` (resolved from the brief's `phase_signals`).
|
||||
|
||||
| Env-var | Default | Behavior |
|
||||
|---------|---------|----------|
|
||||
| `VOYAGE_STORM_ENABLED` | _(unset — default-off)_ | `=1` grants the Phase 5 loop a non-zero turn budget and is the second condition on Phase 4.5's skip-guard. Unset, `research-loop-cap.mjs` grants 0 turns and **both** phases are inert: doing nothing keeps the whole mechanism off. |
|
||||
| `TREKRESEARCH_MAX_CONV_TURNS` | `3` | Max turns per under-illuminated dimension. Budget = this × `maxDimensions` (8, `settings.json:16`). Empty, non-numeric, zero, negative, `Infinity`, or any fraction that floors below `1` (`0.5`, `0.9`) fall back to `3` — never to unbounded, and never to `0`. A fraction at or above `1` floors (`2.7` → `2`). |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK` | _(unset)_ | `=1` disables `hooks/scripts/pre-agent-cap.mjs`, the `PreToolUse` enforcement of the turn budget. The cap primitive still applies; only the second gate is switched off. |
|
||||
|
||||
Adoption of the loop as a default is gated on a pre-registered measurement —
|
||||
protocol, thresholds, and the exact commands in
|
||||
[`docs/storm-measurement.md`](storm-measurement.md).
|
||||
|
||||
## /trekplan modes
|
||||
|
||||
| Flag | Behavior |
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ floor + a `swarm` default.
|
|||
[VERIFIED — code.claude.com/docs/en/skills.md]
|
||||
- **Instruction-based delegation is the documented mechanism.** Workflows launch when the
|
||||
user types the command, or **when Claude is asked in natural language** ("use a
|
||||
workflow" / "run a workflow") or via the `ultracode` keyword. A plugin command whose
|
||||
workflow" / "run a workflow") or via the dedicated opt-in keyword CC ships for
|
||||
multi-agent orchestration (CC 2.1.160; spelled out in
|
||||
`docs/cc-upgrade-2.1.181-decision-matrix.md`, which `verify.sh`'s SC1 excludes
|
||||
precisely because it legitimately cites CC keywords). A plugin command whose
|
||||
markdown instructs Claude to run `/deep-research <q>` is therefore the supported path.
|
||||
[VERIFIED — workflows.md "Have Claude write a workflow"]
|
||||
- **Approval gate caveat:** launching a workflow triggers a per-run approval prompt —
|
||||
|
|
|
|||
160
docs/spike-pretooluse-subagent-reach.md
Normal file
160
docs/spike-pretooluse-subagent-reach.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Spike: does a plugin `PreToolUse` hook reach sub-agent tool calls?
|
||||
|
||||
**Date:** 2026-08-09
|
||||
**Claude Code version:** 2.1.226
|
||||
**Plugin:** voyage 5.9.1 (installed from `ktg-plugin-marketplace`)
|
||||
**Gates:** Step 10 of `plan.md` (`2026-06-30-trekresearch-storm-upgrade`)
|
||||
|
||||
## Question
|
||||
|
||||
Step 10 wants to enforce the conversation-turn cap in a `PreToolUse` hook. That
|
||||
is only viable if a **plugin** `PreToolUse` hook fires on tool calls made
|
||||
*inside a sub-agent*. If it does not, the cap must be enforced somewhere else
|
||||
and Step 10 becomes a documented downgrade instead.
|
||||
|
||||
The question is genuinely open, not answerable from docs alone: the current
|
||||
[hooks reference](https://code.claude.com/docs/en/hooks) states that sub-agent
|
||||
tool calls fire the same hooks and carry `agent_id` / `agent_type`, while
|
||||
[issue #34692](https://github.com/anthropics/claude-code/issues/34692) reported
|
||||
the exact opposite behaviour. The answer is therefore version-dependent and had
|
||||
to be measured on the version actually in use.
|
||||
|
||||
## Method
|
||||
|
||||
A one-shot probe hook was registered for the `WebSearch` matcher, and a
|
||||
**headless child session** was launched to exercise it. The child was used
|
||||
because hooks are resolved when a session starts — a matcher added mid-session
|
||||
cannot be observed by the session that added it.
|
||||
|
||||
Two deviations from the step as originally written, both forced and both
|
||||
verified not to affect the result:
|
||||
|
||||
1. **The matcher was injected into the installed plugin's `hooks.json`, not the
|
||||
repository's.** The plan assumed the repo working tree *is* the active plugin
|
||||
root. It is not: `~/.claude/plugins/cache/ktg-plugin-marketplace/voyage/5.9.1/`
|
||||
is a plain directory holding its own copy, and that copy is what loads.
|
||||
Editing `hooks/hooks.json` in the repo would have measured nothing. The cache
|
||||
file was backed up, modified, and restored — verified byte-identical to the
|
||||
repo file afterwards.
|
||||
2. **The probe script lives under the session scratchpad, not `${TMPDIR}`.** A
|
||||
pathguard hook refuses writes to `${TMPDIR}`. The load-bearing property was
|
||||
only that the script sit **outside `hooks/scripts/`**, which
|
||||
`tests/lib/doc-consistency.test.mjs:75-84` counts via `readdirSync`; the
|
||||
scratchpad satisfies that just as well. The directory still holds 7 scripts.
|
||||
|
||||
### Probe hook
|
||||
|
||||
Logged every invocation as one JSON line (`tool_name`, `agent_id`,
|
||||
`agent_type`, plus the untouched stdin) and always exited `0`, so it could not
|
||||
alter the child's behaviour.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# 1. inject the temporary matcher into the INSTALLED plugin
|
||||
node -e '...push {matcher:"WebSearch", ...voyage-spike-hook.mjs} into hooks.PreToolUse...'
|
||||
|
||||
# 2. exercise it from a fresh child session
|
||||
claude -p "Spawn exactly one sub-agent via the Agent tool (subagent_type: general-purpose).
|
||||
Instruct that sub-agent to perform exactly ONE WebSearch for the query
|
||||
'claude code hooks reference' and report back the first result title.
|
||||
You MUST NOT call WebSearch yourself in the main context - only the
|
||||
sub-agent may call it. When the sub-agent returns, reply with the word DONE." \
|
||||
--allowedTools "Agent,Task,WebSearch" \
|
||||
--max-turns 15
|
||||
|
||||
# 3. restore
|
||||
cp "${TMPDIR}voyage-hooks-backup.json" <cache>/hooks/hooks.json
|
||||
```
|
||||
|
||||
The child returned `DONE`.
|
||||
|
||||
> An earlier attempt additionally passed `--permission-mode bypassPermissions`
|
||||
> and was refused by the auto-mode classifier. The flag was dropped;
|
||||
> `--allowedTools` alone was sufficient.
|
||||
|
||||
## Raw observation
|
||||
|
||||
The log contains **exactly one** record — so the main context did not call
|
||||
`WebSearch` itself, and the single entry is unambiguously the sub-agent's call:
|
||||
|
||||
```json
|
||||
{"at":"2026-08-09T12:07:47.796Z","tool_name":"WebSearch",
|
||||
"agent_id":"aa6d19525a4680fe0","agent_type":"general-purpose",
|
||||
"raw_stdin":"{\"session_id\":\"b126fd6a-...\",\"cwd\":\"/Users/ktg/repos/ktg-plugin-marketplace/voyage\",
|
||||
\"permission_mode\":\"auto\",\"agent_id\":\"aa6d19525a4680fe0\",\"agent_type\":\"general-purpose\",
|
||||
\"effort\":{\"level\":\"xhigh\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"WebSearch\",
|
||||
\"tool_input\":{\"query\":\"claude code hooks reference\"},\"tool_use_id\":\"toolu_01Ka4...\"}"}
|
||||
```
|
||||
|
||||
Both `agent_id` and `agent_type` are populated, matching the documented
|
||||
common-input fields for sub-agent-originated tool events. A main-context call
|
||||
would have carried neither.
|
||||
|
||||
## Consequence for Step 10
|
||||
|
||||
A plugin `PreToolUse` hook **does** observe sub-agent tool calls on CC 2.1.226,
|
||||
and can attribute them via `agent_id` / `agent_type`. Step 10 may therefore take
|
||||
the enforcement branch rather than the documented-downgrade branch.
|
||||
|
||||
Two limits worth carrying forward, neither of which changes the verdict:
|
||||
|
||||
- This measures `WebSearch` on one CC version. The behaviour regressed once
|
||||
before (#34692), so the hook must fail **open**, never assume it is the only
|
||||
gate, and the cap must remain correct if the hook silently stops firing.
|
||||
- The probe only establishes *reach*. Whether a **blocking** (exit 2) decision
|
||||
from inside a sub-agent propagates usefully was not measured — the probe
|
||||
always exited 0 by design.
|
||||
|
||||
RESULT: FIRES
|
||||
|
||||
## Enforcement outcome
|
||||
|
||||
Step 10 took the **enforcement branch**: `hooks/scripts/pre-agent-cap.mjs`
|
||||
(PreToolUse, matcher `WebSearch|WebFetch|Task`), pinned by
|
||||
`tests/hooks/agent-cap.test.mjs`.
|
||||
|
||||
What it does: counts turns spent by a run — read-only, from the append-only
|
||||
ledger that `lib/util/research-loop-cap.mjs` writes — and exits 2 once
|
||||
`turns_used >= max_conv_turns × maxDimensions`. It never appends to the
|
||||
ledger; a cap that recorded its own enforcement would count itself.
|
||||
|
||||
Scope key, the part that makes a globally-wired `PreToolUse` hook safe:
|
||||
`session_id` **+** a marker file only the Phase 5 loop writes, at
|
||||
`${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json`:
|
||||
|
||||
```json
|
||||
{ "runId": "<run id>", "startedAt": "<ISO-8601>" }
|
||||
```
|
||||
|
||||
No marker for the calling session ⇒ out of scope ⇒ allow, unconditionally.
|
||||
An unrelated session is never denied because some other run spent its budget.
|
||||
|
||||
Fail-open and fail-closed are split deliberately:
|
||||
|
||||
| Condition | Outcome | Why |
|
||||
|---|---|---|
|
||||
| No marker / no `session_id` / unparsable stdin | allow | Not evidence of a loop turn |
|
||||
| Marker older than TTL (default 2h, `VOYAGE_CAP_SCOPE_TTL_MS`) | allow + auto-reset | A crashed run must not deny tool calls forever, and `--resume` keeps the same `session_id` |
|
||||
| `VOYAGE_DISABLE_CAP_HOOK=1` | allow | Kill switch |
|
||||
| `VOYAGE_STORM_ENABLED` ≠ `1` | allow | Default-off: no loop runs, nothing to enforce |
|
||||
| In scope, `CLAUDE_PLUGIN_DATA` absent | **deny** | A budget control that cannot count must not grant — same stance as `research-loop-cap.mjs` |
|
||||
| In scope, budget spent | **deny (exit 2)** | The bound |
|
||||
|
||||
Both limits recorded above still hold and are not closed by this step. Reach
|
||||
was measured on one CC version for one tool, and blocking-propagation from
|
||||
inside a sub-agent was never measured — so this hook is **defence in depth**,
|
||||
and `research-loop-cap.mjs` must remain correct on its own if the hook
|
||||
silently stops firing.
|
||||
|
||||
**Follow-up, closed (S79).** Step 10 shipped the hook correct but **latent** —
|
||||
nothing wrote the marker, and it enforces exactly when a marker exists.
|
||||
`commands/trekresearch.md` Phase 5 now writes it (`### Loop scope marker`,
|
||||
keyed by `CLAUDE_CODE_SESSION_ID`, carrying the same `run_id` the ledger is
|
||||
counted under) and removes it on all three exits. Cleanup covers exits only; a
|
||||
crashed session is covered by the TTL above. Verified end-to-end: the snippet
|
||||
as shipped arms the hook, the hook denies at `8/8` turns, and the removal
|
||||
snippet returns it to allow so Phase 6 can still spawn agents. Pinned by
|
||||
`tests/lib/doc-consistency.test.mjs` (STORM marker), which derives the
|
||||
directory name from `SCOPE_DIRNAME` in the hook, so renaming either side
|
||||
fails.
|
||||
131
docs/storm-measurement.md
Normal file
131
docs/storm-measurement.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# STORM adoption gate — pre-registered measurement protocol
|
||||
|
||||
**Status:** thresholds registered, **no measurement run yet.** This document is
|
||||
committed *before* the first measurement by design: a threshold chosen after
|
||||
seeing the numbers is not a threshold. **Harness:** `scripts/storm-measure.mjs`
|
||||
(pinned by `tests/scripts/storm-measure.test.mjs`). **Decides:** whether the
|
||||
bounded Phase 5 conversation loop (`commands/trekresearch.md` §Loop bound) is
|
||||
worth turning on by default.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this gate measures — and what it does not
|
||||
|
||||
The gate measures **source and coverage breadth**:
|
||||
|
||||
| Metric | Definition | Arm |
|
||||
|---|---|---|
|
||||
| `unique_sources` | distinct external sources cited by the run | between-arm median gain |
|
||||
| dimensions over baseline | `(dimensions − dimensions_baseline) / dimensions_baseline` | within-run median across the treatment arm |
|
||||
|
||||
It does **not** measure outline quality, answer correctness, synthesis
|
||||
usefulness, or operator satisfaction. A breadth win is a *necessary* condition
|
||||
for adoption, never a sufficient one. If the loop widens coverage by 40% and
|
||||
the resulting briefs read worse, the correct action is still decline — the
|
||||
number does not overrule a reading of the artifacts.
|
||||
|
||||
The second metric is a within-run delta by construction (`dimensions_baseline`
|
||||
is the interview dimension count, `dimensions` the post-Phase-4.5 list), so it
|
||||
needs no control arm; the control arm's value is 0 because the loop is inert
|
||||
below `effort: high`.
|
||||
|
||||
## 2. Pre-registered thresholds
|
||||
|
||||
Either metric clearing the bar is enough. The brief pre-registers "median
|
||||
forbedring ≥ 30 % på (a) eller (b) → adopt. < 15 % → decline" — a breadth win
|
||||
on one axis counts, because either axis widening is the effect the loop claims.
|
||||
Adopt is evaluated first, so a run that clears the adopt bar on one metric is
|
||||
an adopt even when the other metric sits under the decline bar.
|
||||
|
||||
| Median gain | Verdict | Action |
|
||||
|---|---|---|
|
||||
| ≥ 30% on **either** metric | **adopt** | Flip the `VOYAGE_STORM_ENABLED` default (see §5) |
|
||||
| < 15% on **either** metric (and no adopt) | **decline** | Leave the mechanism default-off. This is a **no-op**: nothing is rolled back |
|
||||
| both metrics in 15% – 30% | **inconclusive** | Keep default-off, gather more runs, re-measure |
|
||||
| either arm empty | **insufficient-data** | Not a decline — measure more |
|
||||
|
||||
`ADOPT_THRESHOLD = 0.30` and `DECLINE_THRESHOLD = 0.15` are exported constants
|
||||
in `scripts/storm-measure.mjs` and pinned by the test suite. Changing them is a
|
||||
deliberate, reviewable act, not a tuning knob to be nudged toward a result.
|
||||
|
||||
## 3. Excluded runs (the honest denominator)
|
||||
|
||||
Runs with `empty_turns > 0` are **excluded from the gain and reported as a
|
||||
count**. An empty turn is one that spent budget and returned no findings, or
|
||||
findings without citations. A run whose `empty_turns` cannot be **read** as a
|
||||
number is excluded on the same footing: a field that says `"many"` is not
|
||||
evidence of zero empty turns, and treating it as one would let the least
|
||||
trustworthy run back into the denominator in the direction that flatters
|
||||
adoption. Including those runs decides adoption on a broken
|
||||
denominator — the loop looks cheap because its failures are averaged into its
|
||||
successes. The harness prints the excluded count on every invocation; if that
|
||||
count is a large fraction of the treatment arm, the finding is about the loop's
|
||||
reliability, and it should be read before the gain figure is read at all.
|
||||
|
||||
Rows predating the Step 9 measurement fields carry no `effort` and are dropped
|
||||
as `legacy` with a count. A stats file where *no* row carries `effort` is a hard
|
||||
error, not an empty treatment group: a schema gap must never present itself as
|
||||
"no gain".
|
||||
|
||||
## 4. The measurement runs (operator-run, outside this plan)
|
||||
|
||||
The measurement itself is **not** part of the implementation plan that built
|
||||
this harness. It is an operator-run gate between that plan and any adopt commit.
|
||||
|
||||
Protocol:
|
||||
|
||||
- **n ≥ 5 runs per arm.** Fewer, and the median is an anecdote.
|
||||
- **The same question set in both arms.** Two briefs, run as `--project` runs.
|
||||
- The arms differ in exactly one thing: whether the loop is enabled.
|
||||
- Both arms append to the same `trekresearch-stats.jsonl`; `effort` is the
|
||||
grouping key that separates them.
|
||||
|
||||
```bash
|
||||
# Control arm (loop inert) — n >= 5
|
||||
claude -p "/trekresearch --project .claude/projects/<brief-standard-effort>"
|
||||
|
||||
# Treatment arm (loop live, effort: high in the brief's phase_signals) — n >= 5
|
||||
VOYAGE_STORM_ENABLED=1 \
|
||||
claude -p "/trekresearch --project .claude/projects/<brief-high-effort>"
|
||||
|
||||
# The gate
|
||||
node scripts/storm-measure.mjs --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
|
||||
# Machine-readable, for a decision record
|
||||
node scripts/storm-measure.mjs --json --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
|
||||
# SC activation check — BOTH halves: did the latest high-effort run discover at
|
||||
# least one dimension, AND is its final list a true SUPERSET of the interview
|
||||
# ones? The second half is read from the run's own `dimensions_baseline_preserved`
|
||||
# attestation, because the dimension NAMES that would show it directly are prose
|
||||
# the exporter allowlist denies. A run that does not attest it FAILS — dropping
|
||||
# two interview dimensions and appending three discovered ones is a +1 count
|
||||
# delta and not a superset. Exit 0 = both halves hold.
|
||||
node scripts/storm-measure.mjs --activation-check --stats "${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl"
|
||||
```
|
||||
|
||||
## 5. What "adopt" concretely means
|
||||
|
||||
Adoption is **one constant**: `isStormEnabled()` in
|
||||
`lib/util/research-loop-cap.mjs` currently requires `VOYAGE_STORM_ENABLED === '1'`.
|
||||
Adopt = make the loop's budget non-zero without that opt-in, in a commit that
|
||||
cites the measurement output.
|
||||
|
||||
This asymmetry is deliberate and was designed in before any code was written:
|
||||
|
||||
- **decline costs nothing** — the mechanism ships default-off, so declining is
|
||||
doing nothing. No revert, no removal from a command file two other steps
|
||||
already rewrote.
|
||||
- **adopt costs one constant** — plus the enforcement hook
|
||||
(`hooks/scripts/pre-agent-cap.mjs`) already in place to bound what gets turned
|
||||
on, and the operator-visible cap-exhaustion message already required by
|
||||
Phase 5's exit conditions.
|
||||
|
||||
## 6. Reading the result honestly
|
||||
|
||||
- The gate measures breadth. Say "breadth" in the decision record, not "quality".
|
||||
- Report the excluded count alongside the gain, always. A 35% gain computed
|
||||
after excluding 6 of 10 treatment runs is a finding about instability.
|
||||
- `insufficient-data` is not a decline. Do not resolve it by lowering n.
|
||||
- A verdict computed from a stats file mixing several question sets measures the
|
||||
question sets, not the loop.
|
||||
|
|
@ -20,6 +20,16 @@
|
|||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-write-executor.mjs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "WebSearch|WebFetch|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node",
|
||||
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-agent-cap.mjs"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
|
|
|
|||
211
hooks/scripts/pre-agent-cap.mjs
Normal file
211
hooks/scripts/pre-agent-cap.mjs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env node
|
||||
// Hook: pre-agent-cap.mjs
|
||||
// Event: PreToolUse (WebSearch | WebFetch | Task)
|
||||
// Purpose: Enforce the /trekresearch Phase 5 loop bound at the harness level,
|
||||
// so the cap is a reader that fells rather than prose the model obeys.
|
||||
//
|
||||
// Why this exists: the Phase 5 budget gate (lib/util/research-loop-cap.mjs) is
|
||||
// invoked BY the loop. A gate the caller chooses to consult is advice. The
|
||||
// spike in docs/spike-pretooluse-subagent-reach.md (RESULT: FIRES) established
|
||||
// that a plugin PreToolUse hook does observe tool calls made INSIDE sub-agents
|
||||
// on CC 2.1.226, which is what makes a second, non-optional gate possible.
|
||||
//
|
||||
// Two limits carried over from that spike, neither of which changes the design:
|
||||
// - Reach was measured on one CC version and regressed once before (#34692),
|
||||
// so this hook is defence in depth, never the only gate. research-loop-cap
|
||||
// must stay correct if this hook silently stops firing.
|
||||
// - Whether a blocking (exit 2) decision from inside a sub-agent propagates
|
||||
// usefully was NOT measured — the probe always exited 0 by design.
|
||||
//
|
||||
// Scope key — the property that makes this safe to wire globally:
|
||||
// session_id + a scope marker file that only the Phase 5 loop writes, at
|
||||
// <data root>/trekresearch-loop-scope/<session_id>.json, where the data root
|
||||
// comes from research-loop-cap.mjs's resolveDataRoot() — the same function
|
||||
// the writer resolves through, because a writer and a reader that resolve
|
||||
// the root separately are a hook that enforces nothing while reporting that
|
||||
// it does:
|
||||
// { "runId": "<run id>", "startedAt": "<ISO-8601>" }
|
||||
// No marker for this session => out of scope => allow, unconditionally. An
|
||||
// unrelated session must never be denied because some other run spent its
|
||||
// budget; a PreToolUse hook that over-blocks breaks every session on the box.
|
||||
//
|
||||
// Stated limit, because the guarantee above is about OTHER sessions and reads
|
||||
// as broader than it is: `claude --resume` keeps the same session_id, so a
|
||||
// resumed session is the same session by this key. If a run reached its cap
|
||||
// and then died before removing the marker, the resume inherits the remainder
|
||||
// of the TTL, for any WebSearch/WebFetch/Task — research or not. Three things
|
||||
// bound it rather than close it: only an EXHAUSTED run denies at all (a
|
||||
// part-spent crash leaves no tombstone and is allowed), the TTL is 2h rather
|
||||
// than a working day, and every denial prints the marker path to delete. A
|
||||
// liveness check would close it properly, but a PreToolUse hook has nothing
|
||||
// trustworthy to check liveness against — the marker's writer is a shell
|
||||
// snippet whose $$ is a subshell, not the session.
|
||||
//
|
||||
// Fail-open vs fail-closed, deliberately split:
|
||||
// - Out of scope (no marker, no session_id, unparsable stdin, stale marker,
|
||||
// kill switch, STORM off) => exit 0. Fail OPEN.
|
||||
// - In scope and over budget => exit 2. Fail CLOSED, mirroring
|
||||
// research-loop-cap.mjs's own stance: a budget control that cannot count
|
||||
// must not grant. (The former "CLAUDE_PLUGIN_DATA absent" deny is gone —
|
||||
// the root now always resolves, so that branch could no longer fire.)
|
||||
// - In scope and the ledger cannot be counted (EISDIR, EACCES, EIO — anything
|
||||
// but ENOENT) => exit 2, same reason. This branch used to ALLOW: the hook
|
||||
// carried a private countTurns() whose catch returned 0, so an unreadable
|
||||
// ledger read as "no turns spent". Counting now goes through the
|
||||
// primitive's exported readLedger(), so reader and writer cannot hold
|
||||
// different rules about what an unreadable ledger means.
|
||||
//
|
||||
// Counting is read-only. The ledger is append-only and written solely by
|
||||
// research-loop-cap.mjs's allowTurn(); if this hook appended, the cap would
|
||||
// count its own enforcement.
|
||||
//
|
||||
// Kill switch: VOYAGE_DISABLE_CAP_HOOK=1 disables enforcement entirely.
|
||||
|
||||
import { readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const { resolveLedgerPath, resolveDataRoot, resolveMaxConvTurns, isStormEnabled, readLedger, MAX_TOTAL_DIMENSIONS } =
|
||||
await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'));
|
||||
|
||||
const SCOPE_DIRNAME = 'trekresearch-loop-scope';
|
||||
// 2h — comfortably longer than any real research run (a 24-turn loop at a couple
|
||||
// of minutes a turn is under an hour), and short enough that debris does not own
|
||||
// the rest of the working day. The TTL is measured from marker.startedAt rather
|
||||
// than from last activity, and `claude --resume` keeps the same session_id, so
|
||||
// this window is what a resumed session can inherit from a run that died holding
|
||||
// the marker. It was 6h; nothing needed six.
|
||||
const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
const env = process.env;
|
||||
|
||||
function allow() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function deny(message) {
|
||||
process.stderr.write(`[voyage] BLOCKED: trekresearch loop cap\n${message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// 1. Kill switch.
|
||||
if (env.VOYAGE_DISABLE_CAP_HOOK === '1') allow();
|
||||
|
||||
// 2. Default-off: no loop runs unless STORM is enabled, so nothing to enforce.
|
||||
if (!isStormEnabled(env)) allow();
|
||||
|
||||
// 3. Parse stdin. Unparsable input is not evidence of a loop turn.
|
||||
let input;
|
||||
try {
|
||||
input = JSON.parse(readFileSync(0, 'utf-8'));
|
||||
} catch {
|
||||
allow();
|
||||
}
|
||||
|
||||
const sessionId = input?.session_id;
|
||||
if (!sessionId || typeof sessionId !== 'string') allow();
|
||||
|
||||
// 4. Resolve the scope marker through the writer's own root resolution.
|
||||
// VOYAGE_CAP_SCOPE_DIR stays as a test/override seam; unset, this lands on
|
||||
// exactly the directory the Phase 5 snippet writes into.
|
||||
const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || resolveDataRoot(env);
|
||||
|
||||
const markerPath = join(scopeDir, SCOPE_DIRNAME, `${sessionId}.json`);
|
||||
if (!existsSync(markerPath)) allow();
|
||||
|
||||
let marker;
|
||||
try {
|
||||
marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
|
||||
} catch {
|
||||
allow(); // A marker we cannot read cannot tell us which run we are in.
|
||||
}
|
||||
|
||||
if (!marker?.runId) allow();
|
||||
|
||||
// 5. TTL / auto-reset. A marker left behind by a crashed run must not deny
|
||||
// tool calls for the rest of the machine's life.
|
||||
const ttlRaw = Number(env.VOYAGE_CAP_SCOPE_TTL_MS);
|
||||
const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? ttlRaw : DEFAULT_TTL_MS;
|
||||
const startedAt = Date.parse(marker.startedAt ?? '');
|
||||
if (!Number.isFinite(startedAt) || Date.now() - startedAt > ttlMs) {
|
||||
try { rmSync(markerPath, { force: true }); } catch { /* best effort */ }
|
||||
allow();
|
||||
}
|
||||
|
||||
// --- In scope from here on. ---
|
||||
|
||||
// 6. The ledger is the only source of truth for turns spent, and it is counted
|
||||
// through the primitive's OWN readLedger(). This hook used to carry a
|
||||
// private copy of the counting rule whose read error returned 0 — so an
|
||||
// unreadable ledger read as "no turns spent" and ALLOWED, in the one branch
|
||||
// where this hook is supposed to fail closed.
|
||||
const ledgerPath = resolveLedgerPath(env);
|
||||
|
||||
// 7. Same bound the primitive uses: turns-per-dimension × the whole dimension
|
||||
// list under settings.json:16's maxDimensions ceiling.
|
||||
const budget = resolveMaxConvTurns(env) * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
let ledger;
|
||||
try {
|
||||
ledger = readLedger(ledgerPath, marker.runId);
|
||||
} catch (e) {
|
||||
deny(
|
||||
` Run ${marker.runId} is in scope, but its turn ledger could not be read:\n` +
|
||||
` ${e.message}\n` +
|
||||
` A budget control that cannot count must not grant. Fix or remove the\n` +
|
||||
` ledger, or set VOYAGE_DISABLE_CAP_HOOK=1 to disable enforcement.`,
|
||||
);
|
||||
}
|
||||
|
||||
const toolLine =
|
||||
` Tool: ${input?.tool_name ?? 'unknown'}${input?.agent_type ? ` (agent: ${input.agent_type})` : ''}\n`;
|
||||
|
||||
// Every denial names the marker. If this run is over and the marker outlived it
|
||||
// — the loop's own cleanup covers its three exits, but a crash between the
|
||||
// exhaustion record and the removal runs no cleanup at all — deleting this file
|
||||
// is the remedy, and a resumed session (same session_id) would otherwise sit out
|
||||
// the remaining TTL for work that has nothing to do with research.
|
||||
const remedyLines =
|
||||
` If this loop is not running, the marker is debris — delete it:\n` +
|
||||
` ${markerPath}\n` +
|
||||
` It also auto-resets ${Math.round(ttlMs / 3600000)}h after the run started (VOYAGE_CAP_SCOPE_TTL_MS).\n`;
|
||||
|
||||
// 8. The boundary is the TOMBSTONE, not the count.
|
||||
//
|
||||
// allowTurn() appends before the turn runs, so during the final granted turn the
|
||||
// ledger already holds `budget` records. Denying at `granted >= budget` blocked
|
||||
// that turn's own tool calls — the primitive granted B turns and this hook
|
||||
// permitted B-1 — and it forced every exhausted run out through an exit-2 tool
|
||||
// denial rather than the graceful "cap exhausted" exit, the only exit the prose
|
||||
// at commands/trekresearch.md teaches the model to handle.
|
||||
//
|
||||
// Moving the boundary to `granted > budget` alone would have made this hook
|
||||
// unable to fire at all once the claim mechanism made a breached ledger
|
||||
// impossible — a deny branch that cannot be reached is a dead security claim,
|
||||
// not a backstop. So the primitive records its own denials, and the case this
|
||||
// hook exists for is the one it now catches: the gate said no and a tool call
|
||||
// arrived anyway.
|
||||
if (ledger.exhausted > 0) {
|
||||
deny(
|
||||
` Run ${marker.runId} was already denied a turn by the budget gate\n` +
|
||||
` (${ledger.granted}/${budget} loop turns spent), and this call came after it.\n` +
|
||||
toolLine +
|
||||
` Remaining gaps belong in the brief as open questions, not in another turn.\n` +
|
||||
remedyLines +
|
||||
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 9. Backstop for a ledger that exceeded the bound however it managed to.
|
||||
if (ledger.granted > budget) {
|
||||
deny(
|
||||
` Run ${marker.runId} shows ${ledger.granted} granted turns against a budget of ${budget}.\n` +
|
||||
toolLine +
|
||||
` The ledger has been breached; the loop is over regardless of cause.\n` +
|
||||
remedyLines +
|
||||
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
|
||||
);
|
||||
}
|
||||
|
||||
allow();
|
||||
|
|
@ -105,7 +105,16 @@ const BLOCK_RULES = [
|
|||
// --- Executor-specific additions ---
|
||||
{
|
||||
name: 'System shutdown/reboot',
|
||||
pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/,
|
||||
// Anchored to command position — start of string/line, or after a
|
||||
// separator (`;`, `|`, `&`, `&&`), with optional `sudo` and an optional
|
||||
// absolute path. An unanchored \b match blocked the bare word anywhere,
|
||||
// including quoted grep patterns, heredoc data, and commit messages.
|
||||
//
|
||||
// Runs against commandView, not the whitespace-collapsed string: collapsing
|
||||
// \s+ to ' ' would erase the newline separator before the pattern ever saw
|
||||
// it, and quoted spans must read as data, not as command position.
|
||||
commandView: true,
|
||||
pattern: /(?:^|[\n;|&])\s*(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:shutdown|reboot|halt|poweroff)\b/,
|
||||
description: 'System shutdown/reboot commands are blocked during execution.',
|
||||
},
|
||||
{
|
||||
|
|
@ -198,6 +207,59 @@ function normalizeCommand(cmd) {
|
|||
.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command-position view — for rules that must distinguish a command from data
|
||||
// that merely names one. Whitespace is NOT collapsed, so newline stays a
|
||||
// separator. Quoted spans and heredoc bodies become data; the argument of a
|
||||
// shell wrapper stays a command; backslash-escaped names are seen through.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `out` ends with the `-c` of a shell invocation — the next quoted span is a
|
||||
// command string, not data. Optional leading `sudo` and absolute path.
|
||||
const SHELL_C_TAIL =
|
||||
/(?:^|[\s;|&])(?:sudo\s+(?:-[a-zA-Z]+\s+)*)?(?:[\w./-]*\/)?(?:ba|z|k|da|a)?sh\s+(?:-[a-zA-Z]+\s+)*-c\s*$/;
|
||||
|
||||
// Drop heredoc bodies, keeping the operator line. Their newlines are not
|
||||
// command separators, and without this every heredoc line that happens to
|
||||
// start with a matched word reads as command position. Runs before the quote
|
||||
// scan, since a body may contain quotes that would desync it.
|
||||
function stripHeredocBodies(cmd) {
|
||||
return cmd.replace(
|
||||
/(<<-?\s*(['"]?)(\w+)\2[^\n]*\n)[\s\S]*?(?:\n[ \t]*\3[ \t]*(?=\n|$)|$)/g,
|
||||
(_match, head) => head,
|
||||
);
|
||||
}
|
||||
|
||||
function commandPositionView(cmd) {
|
||||
const src = stripHeredocBodies(cmd);
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "'" || ch === '"') {
|
||||
const close = src.indexOf(ch, i + 1);
|
||||
const inner = close === -1 ? src.slice(i + 1) : src.slice(i + 1, close);
|
||||
// Unterminated quote — treat the remainder as one span and stop.
|
||||
out += SHELL_C_TAIL.test(out) ? `;${inner};` : ' ';
|
||||
i = close === -1 ? src.length : close + 1;
|
||||
} else {
|
||||
out += ch;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return (
|
||||
out
|
||||
// `xargs [flags] <cmd>` puts <cmd> at command position with no separator
|
||||
// in front of it. Flags taking a separate argument (`-I {}`) are not
|
||||
// parsed — the separator lands before the argument, not the command.
|
||||
.replace(/\bxargs((?:\s+-[a-zA-Z0-9-]+)*)/g, 'xargs$1 ;')
|
||||
// `\name` runs name — the backslash only suppresses alias expansion.
|
||||
// normalizeBashExpansion covers the between-word-chars case; this covers
|
||||
// a backslash at command position.
|
||||
.replace(/\\(\w)/g, '$1')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -219,10 +281,11 @@ if (!command || typeof command !== 'string') {
|
|||
// Strip bash evasion, then normalize whitespace
|
||||
const deobfuscated = normalizeBashExpansion(command);
|
||||
const normalized = normalizeCommand(deobfuscated);
|
||||
const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g, ''));
|
||||
|
||||
// Check BLOCK rules first
|
||||
for (const rule of BLOCK_RULES) {
|
||||
if (rule.pattern.test(normalized)) {
|
||||
if (rule.pattern.test(rule.commandView ? commandView : normalized)) {
|
||||
process.stderr.write(
|
||||
`[voyage] BLOCKED: ${rule.name}\n` +
|
||||
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
||||
|
|
|
|||
|
|
@ -25,11 +25,27 @@ const TREKBRIEF_ALLOWED = Object.freeze(new Set([
|
|||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 2 (trekresearch)
|
||||
// `engine` is a low-cardinality label (swarm|deep-research) emitted at
|
||||
// commands/trekresearch.md:533 and promised in prose (:570-572).
|
||||
// DENY BY OMISSION: question (free prose), project_dir + brief_path
|
||||
// (filesystem paths) are written into the jsonl but MUST NOT reach the
|
||||
// exporter.
|
||||
// The five v5.10 measurement fields are allowlisted too: `effort` is a
|
||||
// low-cardinality label (low|standard|high) and the grouping key the
|
||||
// measurement gate is computed on; `unique_sources`, `dimensions_baseline`,
|
||||
// `conv_turns` and `empty_turns` are plain counters. None of them carry prose
|
||||
// or paths. `dimensions_baseline_preserved` is a boolean, and it exists BECAUSE
|
||||
// prose is denied here: the activation check needs to know that the final
|
||||
// dimension list is a superset of the interview-derived one, and the dimension
|
||||
// NAMES that would show it directly are free prose that must not reach the
|
||||
// exporter. A boolean attestation carries the fact without the payload.
|
||||
const TREKRESEARCH_ALLOWED = Object.freeze(new Set([
|
||||
'ts', 'slug', 'mode', 'scope', 'dimensions', 'agents_local',
|
||||
'ts', 'slug', 'mode', 'scope', 'engine', 'dimensions', 'agents_local',
|
||||
'agents_external', 'gemini_used', 'confidence', 'contradictions',
|
||||
'open_questions', 'profile', 'parallel_agents',
|
||||
'external_research_enabled', 'profile_source',
|
||||
'effort', 'unique_sources', 'dimensions_baseline', 'conv_turns',
|
||||
'empty_turns', 'dimensions_baseline_preserved',
|
||||
]));
|
||||
|
||||
// Source: tests/fixtures/jsonl-schemas.md row 3 (trekplan)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@
|
|||
// What IS implemented, purely: Pass 1 (triplet dedup → highest-severity-wins
|
||||
// survivor + conformance tiebreak + detail concat + raised_by provenance),
|
||||
// Pass 2 succinctness + actionability-presence, Pass 3 reasonableness
|
||||
// (citation / unknown-rule_key drop, severity-mismatch correction), Pass 4
|
||||
// verdict thresholds. No LLM, no network, no time, no randomness.
|
||||
// (citation / unknown-rule_key suppression, severity-mismatch correction),
|
||||
// Pass 4 verdict thresholds — fail-closed: a suppression that did not REFUTE
|
||||
// the finding, and a reviewer that never reported, forbid ALLOW (see
|
||||
// classifySuppression). No LLM, no network, no time, no randomness.
|
||||
//
|
||||
// Reuses: SEVERITY_VALUES / RULE_KEYS / getRule (rule-catalogue.mjs),
|
||||
// computeFindingId (finding-id.mjs, triplet), validateFindings
|
||||
|
|
@ -35,6 +37,71 @@ import { validateFindings } from './findings-schema.mjs';
|
|||
export const JUDGE_TITLE_MAX = 100;
|
||||
export const JUDGE_DETAIL_MAX = 800;
|
||||
|
||||
// ---- Suppression classification (fail-closed) --------------------------------
|
||||
//
|
||||
// A removal is `dropped` ONLY when the test refuted the finding as a claim
|
||||
// about this codebase. Every other removal is `unverified`: the coordinator
|
||||
// took the finding out of the count without ever establishing it was unreal,
|
||||
// so it may not be spent as evidence of a clean review.
|
||||
|
||||
/**
|
||||
* Reasons that REFUTE. `no-citation` is the only one this deterministic subset
|
||||
* can emit: a finding whose `file` is empty or whose `line` is negative names
|
||||
* no location, so it makes no checkable claim at all
|
||||
* (agents/review-coordinator.md Pass 3 — "Speculative 'code might break
|
||||
* somewhere' findings have no anchor").
|
||||
*
|
||||
* `accuracy:refuted` (Pass 2 Accuracy — a citation escaping the repo root) and
|
||||
* `file-existence:refuted` (Pass 3 — absent from both working tree and diff)
|
||||
* are emitted by the LLM coordinator, whose fs/judgement branches this module
|
||||
* excludes. They are declared here anyway: the vocabulary is owned in one
|
||||
* place so prose and lib cannot drift.
|
||||
*/
|
||||
export const REFUTING_REASONS = Object.freeze(new Set([
|
||||
'no-citation',
|
||||
'accuracy:refuted',
|
||||
'file-existence:refuted',
|
||||
]));
|
||||
|
||||
/**
|
||||
* The reason vocabulary on the unverified side. `file-existence:indeterminate`
|
||||
* is emitted by the LLM coordinator's Pass 3 (which runs the fs Glob this
|
||||
* module deliberately excludes); the vocabulary is owned here so prose and lib
|
||||
* cannot drift.
|
||||
*/
|
||||
export const UNVERIFIED_REASONS = Object.freeze([
|
||||
'succinctness:title',
|
||||
'succinctness:detail',
|
||||
'actionability:empty',
|
||||
'unknown-rule_key',
|
||||
'file-existence:indeterminate',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify a suppression reason. Anything not declared refuting is
|
||||
* `unverified` — the default is fail-CLOSED, so a reason introduced later
|
||||
* without a decision cannot silently move the verdict toward ALLOW.
|
||||
* @param {string} reason
|
||||
* @returns {'refuted'|'unverified'}
|
||||
*/
|
||||
export function classifySuppression(reason) {
|
||||
return REFUTING_REASONS.has(reason) ? 'refuted' : 'unverified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a finding with its suppression reason and route it to the refuted
|
||||
* (`dropped`) or the `unverified` bucket.
|
||||
* @param {object} finding
|
||||
* @param {string} reason
|
||||
* @param {object[]} dropped
|
||||
* @param {object[]} unverified
|
||||
*/
|
||||
function suppress(finding, reason, dropped, unverified) {
|
||||
const tagged = { ...finding, suppressed_reason: reason };
|
||||
if (classifySuppression(reason) === 'refuted') dropped.push(tagged);
|
||||
else unverified.push(tagged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalogue-tier rank of a severity: lower number = higher severity.
|
||||
* BLOCKER=0 … SUGGESTION=3; an unknown severity ranks last.
|
||||
|
|
@ -122,12 +189,18 @@ export function dedupByTriplet(findings) {
|
|||
* (title > 100 or detail > 800 chars) and actionability (recommended_action,
|
||||
* when present, must be a non-empty string). The imperative-verb test is
|
||||
* excluded (LLM judgement).
|
||||
*
|
||||
* Both tests read a `.length`; neither examines the claim, so neither can
|
||||
* establish the finding is unreal. Both therefore route to `unverified`.
|
||||
* `dropped` stays in the signature for the refuting Pass-2 filter this subset
|
||||
* excludes (Accuracy: a path-traversal escape IS a refutation).
|
||||
* @param {object[]} findings
|
||||
* @returns {{ kept: object[], dropped: object[] }}
|
||||
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
|
||||
*/
|
||||
export function judgeFilter(findings) {
|
||||
const kept = [];
|
||||
const dropped = [];
|
||||
const unverified = [];
|
||||
for (const f of findings) {
|
||||
const titleLen = (f.title ?? '').length;
|
||||
const detailLen = (f.detail ?? '').length;
|
||||
|
|
@ -138,31 +211,38 @@ export function judgeFilter(findings) {
|
|||
(typeof f.recommended_action !== 'string' || f.recommended_action.trim().length === 0)) {
|
||||
reason = 'actionability:empty';
|
||||
}
|
||||
if (reason) dropped.push({ ...f, suppressed_reason: reason });
|
||||
if (reason) suppress(f, reason, dropped, unverified);
|
||||
else kept.push(f);
|
||||
}
|
||||
return { kept, dropped };
|
||||
return { kept, dropped, unverified };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass 3 — Cloudflare reasonableness (deterministic subset): drop findings
|
||||
* with no citation (empty file / line < 0) or an unknown rule_key; CORRECT a
|
||||
* severity that does not match the catalogue tier (a correction, not a drop).
|
||||
* The fs file-existence glob is excluded (I/O).
|
||||
* The fs file-existence glob is excluded (I/O) — its indeterminate branch is
|
||||
* prose-side, tokenised as `file-existence:indeterminate`.
|
||||
*
|
||||
* `no-citation` REFUTES (the finding names no location, so it makes no
|
||||
* checkable claim) and is dropped. `unknown-rule_key` does not: an ad-hoc key
|
||||
* is a real defect wearing the wrong label — v5.1.1 high-effort mode already
|
||||
* KEEPS these, normalised to PLAN_EXECUTE_DRIFT — so it routes to `unverified`.
|
||||
* @param {object[]} findings
|
||||
* @returns {{ kept: object[], dropped: object[] }}
|
||||
* @returns {{ kept: object[], dropped: object[], unverified: object[] }}
|
||||
*/
|
||||
export function reasonablenessFilter(findings) {
|
||||
const kept = [];
|
||||
const dropped = [];
|
||||
const unverified = [];
|
||||
for (const f of findings) {
|
||||
if (typeof f.file !== 'string' || f.file.length === 0 ||
|
||||
(typeof f.line === 'number' && f.line < 0)) {
|
||||
dropped.push({ ...f, suppressed_reason: 'no-citation' });
|
||||
suppress(f, 'no-citation', dropped, unverified);
|
||||
continue;
|
||||
}
|
||||
if (!RULE_KEYS.has(f.rule_key)) {
|
||||
dropped.push({ ...f, suppressed_reason: 'unknown-rule_key' });
|
||||
suppress(f, 'unknown-rule_key', dropped, unverified);
|
||||
continue;
|
||||
}
|
||||
const rule = getRule(f.rule_key);
|
||||
|
|
@ -172,44 +252,114 @@ export function reasonablenessFilter(findings) {
|
|||
kept.push(f);
|
||||
}
|
||||
}
|
||||
return { kept, dropped };
|
||||
return { kept, dropped, unverified };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass 4 — compute the verdict from severity counts (after dedup + filtering).
|
||||
* BLOCKER ≥ 1 → BLOCK; else MAJOR ≥ 1 → WARN; else ALLOW.
|
||||
*
|
||||
* FAIL-CLOSED: ALLOW additionally requires that nothing is `unverified` and
|
||||
* that every expected reviewer reported. Neither ever RAISES a verdict — the
|
||||
* severity thresholds are untouched — they only forbid the clean one, so the
|
||||
* worst case of a false unverified is WARN plus a stated reason, never a
|
||||
* silent pass. Unverified findings are NOT counted into a severity tier: their
|
||||
* severity is reviewer-asserted and was never substantiated.
|
||||
*
|
||||
* @param {object[]} findings
|
||||
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number> }}
|
||||
* @param {{ unverified?: object[], missingReviewers?: string[], unattributablePayloads?: number }} [options]
|
||||
* @returns {{ verdict: 'BLOCK'|'WARN'|'ALLOW', counts: Record<string, number>, allow_blocked_by: string[] }}
|
||||
*/
|
||||
export function computeVerdict(findings) {
|
||||
export function computeVerdict(findings, options = {}) {
|
||||
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
|
||||
for (const f of findings) {
|
||||
if (counts[f.severity] !== undefined) counts[f.severity] += 1;
|
||||
}
|
||||
|
||||
const unverified = options.unverified ?? [];
|
||||
const missingReviewers = options.missingReviewers ?? [];
|
||||
const unattributablePayloads = options.unattributablePayloads ?? 0;
|
||||
const allow_blocked_by = [];
|
||||
const byReason = new Map();
|
||||
for (const f of unverified) {
|
||||
const reason = f?.suppressed_reason ?? 'unspecified';
|
||||
byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
|
||||
}
|
||||
for (const [reason, n] of byReason) allow_blocked_by.push(`unverified:${reason} (${n})`);
|
||||
for (const r of missingReviewers) allow_blocked_by.push(`missing-reviewer:${r}`);
|
||||
if (unattributablePayloads > 0) allow_blocked_by.push(`unattributable-payload (${unattributablePayloads})`);
|
||||
|
||||
let verdict;
|
||||
if (counts.BLOCKER >= 1) verdict = 'BLOCK';
|
||||
else if (counts.MAJOR >= 1) verdict = 'WARN';
|
||||
else if (allow_blocked_by.length > 0) verdict = 'WARN';
|
||||
else verdict = 'ALLOW';
|
||||
return { verdict, counts };
|
||||
return { verdict, counts, allow_blocked_by };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full deterministic contract: ingest → Pass 1 → Pass 2 → Pass 3 → Pass 4.
|
||||
*
|
||||
* `options.expectedReviewers` names the reviewers this review was supposed to
|
||||
* hear from. A reviewer that is absent from the payloads, or whose named
|
||||
* payload failed schema validation and was thrown away at ingest, lands in
|
||||
* `missing_reviewers`; a payload that failed schema WITHOUT a reviewer name is
|
||||
* counted in `unattributable_payloads` instead. Either forbids ALLOW: an
|
||||
* unread reviewer is an absent one, and zero findings from a silent reviewer
|
||||
* must not read like zero findings from a clean diff.
|
||||
*
|
||||
* `suppressed` stays the UNION of `dropped` (refuted) and `unverified` so
|
||||
* existing consumers keep their meaning; `unverified` is the subset that
|
||||
* forbids ALLOW. Do not iterate both and count twice.
|
||||
*
|
||||
* @param {Array<{reviewer?: string, findings: object[]}>} reviewerPayloads
|
||||
* @returns {{ verdict: string, counts: Record<string, number>, findings: object[], suppressed: object[], skipped: object[] }}
|
||||
* @param {{ expectedReviewers?: string[] }} [options]
|
||||
* @returns {{ verdict: string, counts: Record<string, number>, findings: object[], suppressed: object[], unverified: object[], skipped: object[], missing_reviewers: string[], unattributable_payloads: number, allow_blocked_by: string[] }}
|
||||
*/
|
||||
export function runContract(reviewerPayloads) {
|
||||
export function runContract(reviewerPayloads, options = {}) {
|
||||
const { findings: ingested, skipped } = ingest(reviewerPayloads);
|
||||
const deduped = dedupByTriplet(ingested);
|
||||
const judged = judgeFilter(deduped);
|
||||
const reasoned = reasonablenessFilter(judged.kept);
|
||||
const { verdict, counts } = computeVerdict(reasoned.kept);
|
||||
const unverified = [...judged.unverified, ...reasoned.unverified];
|
||||
|
||||
// A reviewer counts as REPORTED only when a payload carrying its name
|
||||
// validated. `validateFindings` merely warns on a missing `reviewer`, so a
|
||||
// payload can fail schema anonymously: that is an unattributable payload, not
|
||||
// a reviewer called "unnamed reviewer". Naming one would invent an agent
|
||||
// nobody launched, and would double-count with expectedReviewers when the two
|
||||
// are in fact the same failure.
|
||||
const skippedNames = new Set();
|
||||
let unattributable_payloads = 0;
|
||||
for (const s of skipped) {
|
||||
if (typeof s.reviewer === 'string' && s.reviewer.length > 0) skippedNames.add(s.reviewer);
|
||||
else unattributable_payloads += 1;
|
||||
}
|
||||
const reported = new Set();
|
||||
for (const payload of reviewerPayloads) {
|
||||
const name = payload?.reviewer;
|
||||
if (typeof name === 'string' && name.length > 0 && !skippedNames.has(name)) reported.add(name);
|
||||
}
|
||||
const missing_reviewers = [...skippedNames];
|
||||
for (const r of options.expectedReviewers ?? []) {
|
||||
if (!reported.has(r) && !missing_reviewers.includes(r)) missing_reviewers.push(r);
|
||||
}
|
||||
|
||||
const { verdict, counts, allow_blocked_by } = computeVerdict(reasoned.kept, {
|
||||
unverified,
|
||||
missingReviewers: missing_reviewers,
|
||||
unattributablePayloads: unattributable_payloads,
|
||||
});
|
||||
return {
|
||||
verdict,
|
||||
counts,
|
||||
findings: reasoned.kept,
|
||||
suppressed: [...judged.dropped, ...reasoned.dropped],
|
||||
suppressed: [...judged.dropped, ...judged.unverified, ...reasoned.dropped, ...reasoned.unverified],
|
||||
unverified,
|
||||
skipped,
|
||||
missing_reviewers,
|
||||
unattributable_payloads,
|
||||
allow_blocked_by,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
350
lib/util/research-loop-cap.mjs
Normal file
350
lib/util/research-loop-cap.mjs
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
// lib/util/research-loop-cap.mjs
|
||||
// Stateful, default-off cost cap for the /trekresearch bounded conversation
|
||||
// loop (Phase 4.5 dimension discovery + Phase 5 loop turns).
|
||||
//
|
||||
// Three properties the plan review required:
|
||||
// (a) Default-off — VOYAGE_STORM_ENABLED must be '1'; otherwise the budget
|
||||
// is 0 regardless of effort. This IS the decline branch: doing nothing
|
||||
// leaves the mechanism off, and adopt is flipping this one constant.
|
||||
// (b) The cap counts itself — allowTurn() derives used-turn count from an
|
||||
// append-only JSONL ledger, never from a caller-supplied number. A cap
|
||||
// that asks the caller how many turns it has used is not a cap. Each
|
||||
// grant additionally claims a turn SLOT with O_EXCL, so the bound holds
|
||||
// when several callers decide at once instead of only when they queue.
|
||||
// (c) Correct size bound — worst case is max_conv_turns × max_total_dimensions,
|
||||
// where max_total_dimensions is the WHOLE list (interview + discovered)
|
||||
// under settings.json:16's cap of 8 — not × discovered-only.
|
||||
//
|
||||
// CLAUDE_PLUGIN_DATA absent => fall back to ~/.claude/voyage. The variable is
|
||||
// EMPTY in the Bash tool's process env (measured in a live plugin-enabled
|
||||
// session), and the Phase 5 bash snippet is this module's only caller — so
|
||||
// denying on its absence denied turn 1 of every real run. The root is resolved
|
||||
// in code rather than demanded of the environment, and hooks/scripts/
|
||||
// pre-agent-cap.mjs resolves it through the SAME function, so the writer and
|
||||
// the reader can never disagree about where the ledger lives.
|
||||
//
|
||||
// The fail-closed stance covers both directions of ledger IO: a ledger that
|
||||
// cannot be WRITTEN denies the turn, and a ledger that exists but cannot be
|
||||
// READ denies it too. Only ENOENT counts as zero turns spent, because that is
|
||||
// the legitimate first-turn state. This module is a budget control, not
|
||||
// telemetry — the opposite of lib/stats/event-emit.mjs's fail-open.
|
||||
//
|
||||
// CLI shim, two modes:
|
||||
// node lib/util/research-loop-cap.mjs --run-id ID --dimension D --effort E
|
||||
// → JSON: { ok, used, budget, reason? } (exit 0 = granted, exit 1 = denied)
|
||||
//
|
||||
// node lib/util/research-loop-cap.mjs --check-dimensions N
|
||||
// → JSON: { ok, count, ceiling, reason? } (exit 0 = within, exit 1 = rejected)
|
||||
// The second axis of the bounded-cost NFR. Phase 4.5 never calls the budget
|
||||
// gate, so this mode requires no run id, effort or STORM flag — but it reads
|
||||
// the SAME MAX_TOTAL_DIMENSIONS the budget is sized against.
|
||||
|
||||
import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export const MAX_CONV_TURNS = 3;
|
||||
export const MAX_TOTAL_DIMENSIONS = 8; // settings.json:16 maxDimensions — whole list, not discovered-only
|
||||
|
||||
const LEDGER_FILENAME = 'trekresearch-loop-ledger.jsonl';
|
||||
const CLAIM_DIRNAME = 'trekresearch-loop-claims';
|
||||
|
||||
export function isStormEnabled(env = process.env) {
|
||||
return env.VOYAGE_STORM_ENABLED === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* The OTHER cost ceiling: how large the whole dimension list may get after
|
||||
* Phase 4.5 discovery has appended to it.
|
||||
*
|
||||
* The bounded-cost NFR asks for explicit ceilings on both axes. The turn axis
|
||||
* had a constant, a ledger-backed reader and a PreToolUse enforcer; the
|
||||
* discovery axis had only a sentence in Phase 4.5 prose — a cap nothing reads,
|
||||
* which is the failure mode the operator decision on brief_reviewer_iter_cap
|
||||
* warned about. This is the reader.
|
||||
*
|
||||
* The ceiling is MAX_TOTAL_DIMENSIONS on purpose: the value that sizes the turn
|
||||
* budget IS settings.json:16's maxDimensions, and a second constant for the same
|
||||
* number is how two readers end up enforcing different bounds.
|
||||
*
|
||||
* Accepts the dimension list or its count, because Phase 4.5 has the list and
|
||||
* the CLI has a number. A count that cannot be read is REJECTED — a cost ceiling
|
||||
* that waves through what it cannot measure is not a ceiling.
|
||||
*
|
||||
* @param {string[]|number|string} dimensions
|
||||
* @param {{ceiling?: number}} [opts]
|
||||
* @returns {{ok: boolean, count: number|null, ceiling: number, reason?: string}}
|
||||
*/
|
||||
export function checkDimensionCeiling(dimensions, opts = {}) {
|
||||
const ceiling = Number.isFinite(opts.ceiling) ? opts.ceiling : MAX_TOTAL_DIMENSIONS;
|
||||
const count = Array.isArray(dimensions) ? dimensions.length : Number(dimensions);
|
||||
if (dimensions === null || dimensions === undefined || !Number.isInteger(count) || count < 0) {
|
||||
return { ok: false, count: null, ceiling, reason: 'unreadable_dimension_count' };
|
||||
}
|
||||
if (count > ceiling) {
|
||||
return { ok: false, count, ceiling, reason: 'ceiling_exceeded' };
|
||||
}
|
||||
return { ok: true, count, ceiling };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce TREKRESEARCH_MAX_CONV_TURNS. NaN, empty, negative, zero, Infinity, or
|
||||
* any fraction that floors below 1 all fall back to MAX_CONV_TURNS — never to
|
||||
* unbounded, and never to 0.
|
||||
*
|
||||
* The floor is applied BEFORE the `<= 0` guard, not after. Flooring afterwards
|
||||
* let '0.5' and '0.9' clear a guard written against the raw value and then
|
||||
* become 0, making the budget 0 × MAX_TOTAL_DIMENSIONS = 0: every turn denied
|
||||
* and the loop silently dead rather than bounded. A cap of 0 is not a narrower
|
||||
* cap, it is an off switch that the documented fallback promises not to be.
|
||||
*/
|
||||
export function resolveMaxConvTurns(env = process.env) {
|
||||
const raw = env.TREKRESEARCH_MAX_CONV_TURNS;
|
||||
if (raw === undefined || raw === null || raw === '') return MAX_CONV_TURNS;
|
||||
const n = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(n) || n <= 0) return MAX_CONV_TURNS;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one data root for everything this loop writes: the turn ledger and the
|
||||
* PreToolUse scope marker. CLAUDE_PLUGIN_DATA when the harness provides it,
|
||||
* ~/.claude/voyage when it does not — which is the case in every Bash tool
|
||||
* invocation today.
|
||||
*/
|
||||
export function resolveDataRoot(env = process.env) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
if (dir && typeof dir === 'string' && dir.length > 0) return dir;
|
||||
const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir();
|
||||
return join(home, '.claude', 'voyage');
|
||||
}
|
||||
|
||||
export function resolveLedgerPath(env = process.env) {
|
||||
return join(resolveDataRoot(env), LEDGER_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the per-turn claim files live. A claim is the ATOMIC record that a turn
|
||||
* slot is taken; the ledger is the readable record of what that turn was for.
|
||||
*
|
||||
* The claim exists because the ledger alone cannot bound the loop. Counting the
|
||||
* ledger and then appending is read-then-write: N callers that all observe
|
||||
* `used == budget - 1` all decide to grant, and the bound is exceeded by N-1 —
|
||||
* precisely the concurrent case (several agents spawned in one message) that
|
||||
* allowTurn's own comment named as the reason it had to be append-only.
|
||||
*
|
||||
* Claim files are empty, at most `budget` per run, and never cleaned up — the
|
||||
* same standing as the ledger itself, which also grows for the life of the data
|
||||
* root. Two consequences worth stating rather than discovering: reusing a
|
||||
* runId across runs finds its slots already taken and denies, and two runIds
|
||||
* that collide after filename sanitisation block each other. Both err toward
|
||||
* denying a turn, which is the safe direction for a budget control.
|
||||
*/
|
||||
export function resolveClaimDir(env = process.env) {
|
||||
return join(resolveDataRoot(env), CLAIM_DIRNAME);
|
||||
}
|
||||
|
||||
function claimFileName(runId, slot) {
|
||||
return `${String(runId).replace(/[^A-Za-z0-9._-]/g, '_')}-${slot}.claim`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to take turn slot `slot` for `runId`. `wx` is O_CREAT|O_EXCL: the kernel
|
||||
* decides the winner, so exactly one caller can ever create a given slot file.
|
||||
*
|
||||
* @returns {boolean} true when this caller took the slot, false when it was already taken
|
||||
* @throws on any IO error other than EEXIST — the caller turns that into a denial
|
||||
*/
|
||||
function claimSlot(claimDir, runId, slot) {
|
||||
try {
|
||||
writeFileSync(join(claimDir, claimFileName(runId, slot)), '', { flag: 'wx' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e && e.code === 'EEXIST') return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one run's turn count off the append-only ledger.
|
||||
*
|
||||
* ENOENT is 0 turns spent — the legitimate first-turn state, and the reason
|
||||
* this cannot simply throw on every read failure. Every OTHER read error
|
||||
* (EISDIR, EACCES, EIO) THROWS, because returning 0 from an unreadable ledger
|
||||
* re-granted the full budget on every call: unbounded, and the exact
|
||||
* silently-grant-unlimited failure this module's header argues against three
|
||||
* lines above the code that did it. The missing-directory case already failed
|
||||
* closed; this makes the unreadable-file case agree with it.
|
||||
*
|
||||
* The `existsSync` pre-check is deliberately gone: readFileSync's own ENOENT
|
||||
* carries the same information without a second syscall that can disagree with
|
||||
* the read that follows it.
|
||||
*
|
||||
* Exported so hooks/scripts/pre-agent-cap.mjs counts through this exact
|
||||
* function. A reader and a writer with private copies of the counting rule are
|
||||
* how a hook ends up enforcing a different bound than the gate it backs.
|
||||
*
|
||||
* Two counts, deliberately separate. `granted` is turns handed out. `exhausted`
|
||||
* is tombstones — records this gate wrote when it DENIED a turn. A tombstone is
|
||||
* not a turn and must never consume budget; it exists so the PreToolUse hook can
|
||||
* tell "turn B is in flight" (granted == budget, no tombstone) apart from "the
|
||||
* gate already said no and something kept going" (tombstone present).
|
||||
*
|
||||
* @param {string} ledgerPath
|
||||
* @param {string} runId
|
||||
* @returns {{granted: number, exhausted: number}}
|
||||
* @throws when the ledger exists but cannot be read
|
||||
*/
|
||||
export function readLedger(ledgerPath, runId) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(ledgerPath, 'utf-8');
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT') return { granted: 0, exhausted: 0 };
|
||||
const err = new Error(`ledger unreadable at ${ledgerPath}: ${e.message}`);
|
||||
err.code = 'VOYAGE_LEDGER_UNREADABLE';
|
||||
throw err;
|
||||
}
|
||||
let granted = 0;
|
||||
let exhausted = 0;
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const rec = JSON.parse(line);
|
||||
if (rec.runId !== runId) continue;
|
||||
if (rec.exhausted === true) exhausted++;
|
||||
else granted++;
|
||||
} catch { /* skip malformed lines */ }
|
||||
}
|
||||
return { granted, exhausted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this run has been denied a turn for budget.
|
||||
*
|
||||
* Best effort on purpose: the denial itself is already the correct answer, so a
|
||||
* ledger that cannot take the tombstone must not turn a denial into a grant. The
|
||||
* tombstone only strengthens the harness-level backstop.
|
||||
*/
|
||||
function markExhausted(ledgerPath, runId, now) {
|
||||
try {
|
||||
appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, exhausted: true }) + '\n');
|
||||
} catch { /* best effort — see above */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether one more research-loop turn may run.
|
||||
*
|
||||
* Phase 4.5/5 may spawn several agents in a single message, so the decision has
|
||||
* to survive concurrent callers. It does that by CLAIMING a turn slot with
|
||||
* O_EXCL (see claimSlot) and only then appending to the ledger. The comment
|
||||
* that used to sit here asserted "append-only: never read-modify-write" as if
|
||||
* appending were itself the concurrency guarantee — but the decision path was
|
||||
* count-then-append, which is read-then-write, so the claim was unsupported by
|
||||
* the code beneath it. The kernel now picks the winner for each slot.
|
||||
*
|
||||
* @param {{runId: string, dimension: string, effort: string}} args
|
||||
* @param {{env?: object, now?: Date}} [opts]
|
||||
* @returns {{ok: boolean, used: number, budget: number, reason?: string}}
|
||||
*/
|
||||
export function allowTurn({ runId, dimension, effort } = {}, opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const now = opts.now || new Date();
|
||||
|
||||
if (!isStormEnabled(env)) {
|
||||
return { ok: false, used: 0, budget: 0, reason: 'storm_disabled' };
|
||||
}
|
||||
if (effort !== 'high') {
|
||||
return { ok: false, used: 0, budget: 0, reason: 'effort_not_high' };
|
||||
}
|
||||
if (!runId || !dimension) {
|
||||
return { ok: false, used: 0, budget: 0, reason: 'missing_args' };
|
||||
}
|
||||
|
||||
const maxConvTurns = resolveMaxConvTurns(env);
|
||||
const budget = maxConvTurns * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
const ledgerPath = resolveLedgerPath(env);
|
||||
let ledger;
|
||||
try {
|
||||
ledger = readLedger(ledgerPath, runId);
|
||||
} catch (e) {
|
||||
return { ok: false, used: 0, budget, reason: `ledger-read-failed: ${e.message}` };
|
||||
}
|
||||
const used = ledger.granted;
|
||||
|
||||
// Already tombstoned: this run is over. Short-circuit so a hammered gate
|
||||
// neither walks every slot again nor appends a second tombstone.
|
||||
if (ledger.exhausted > 0) {
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
// Claim a turn SLOT before spending anything. The ledger count only says
|
||||
// where to start looking; the claim is what makes the grant exclusive. Slot
|
||||
// numbers are bounded by `budget`, and each can be created exactly once, so
|
||||
// the total number of grants for a run can never exceed the budget however
|
||||
// many callers arrive at once.
|
||||
const claimDir = resolveClaimDir(env);
|
||||
let slot = used + 1;
|
||||
let claimed = false;
|
||||
try {
|
||||
mkdirSync(claimDir, { recursive: true });
|
||||
while (slot <= budget) {
|
||||
if (claimSlot(claimDir, runId, slot)) { claimed = true; break; }
|
||||
slot++;
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, used, budget, reason: `claim-failed: ${e.message}` };
|
||||
}
|
||||
if (!claimed) {
|
||||
markExhausted(ledgerPath, runId, now);
|
||||
return { ok: false, used, budget, reason: 'budget_exhausted' };
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = dirname(ledgerPath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(ledgerPath, JSON.stringify({ ts: now.toISOString(), runId, dimension, effort, slot }) + '\n');
|
||||
} catch (e) {
|
||||
return { ok: false, used, budget, reason: `ledger-write-failed: ${e.message}` };
|
||||
}
|
||||
|
||||
return { ok: true, used: slot, budget };
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--run-id') out.runId = argv[++i];
|
||||
else if (a === '--dimension') out.dimension = argv[++i];
|
||||
else if (a === '--effort') out.effort = argv[++i];
|
||||
else if (a === '--check-dimensions') out.checkDimensions = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// The dimension ceiling is a Phase 4.5 concern, and Phase 4.5 never calls the
|
||||
// budget gate — so this branch must not inherit the gate's preconditions
|
||||
// (run id, effort, STORM flag). It is a pure bound on list size.
|
||||
if (args.checkDimensions !== undefined) {
|
||||
const result = checkDimensionCeiling(args.checkDimensions);
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
if (!args.runId || !args.dimension || !args.effort) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
ok: false,
|
||||
reason: 'usage: research-loop-cap.mjs --run-id ID --dimension D --effort standard|high|low',
|
||||
}) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const result = allowTurn(args);
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
119
lib/validators/query-privacy-gate.mjs
Normal file
119
lib/validators/query-privacy-gate.mjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// lib/validators/query-privacy-gate.mjs
|
||||
// Inspect an outbound research query before it leaves the machine. Called
|
||||
// only from the new high-effort steps (Phase 4.5 dimension discovery + the
|
||||
// bounded Phase 5 loop turns) — the existing single-pass Phase 5 path is
|
||||
// unchanged (Step 6, plan-v2).
|
||||
//
|
||||
// Two-tier, same shape as lib/exporters/endpoint-validator.mjs's SSRF gate:
|
||||
// - WARN tier — absolute filesystem paths, repo-internal identifiers.
|
||||
// Operator-overridable via `strict: false` / `--soft` (matches
|
||||
// lib/validators/research-validator.mjs's strict/soft convention), and
|
||||
// fully bypassable via the VOYAGE_QUERY_PRIVACY_ALLOW=1 opt-in.
|
||||
// - HARD-BLOCK tier — secret-shaped tokens. NEVER overridable by strict,
|
||||
// --soft, or the opt-in env var — mirrors endpoint-validator.mjs's
|
||||
// HARD_BLOCKED_HOSTS, where an opt-in widens the warn tier but never
|
||||
// unlocks the permanently-blocked one.
|
||||
//
|
||||
// CLI shim:
|
||||
// node lib/validators/query-privacy-gate.mjs [--soft] "<query text>"
|
||||
// → JSON {valid, errors, warnings}; exit 0 valid, 1 invalid.
|
||||
|
||||
import { issue } from '../util/result.mjs';
|
||||
|
||||
// WARN tier — absolute filesystem paths (leaks local directory layout).
|
||||
export const ABSOLUTE_PATH_PATTERNS = Object.freeze([
|
||||
/\/Users\/[^\s"'`]+/,
|
||||
/\/home\/[^\s"'`]+/,
|
||||
/[A-Za-z]:\\[^\s"'`]+/,
|
||||
/\$\{?HOME\}?\/[^\s"'`]+/,
|
||||
]);
|
||||
|
||||
// WARN tier — repo-internal identifiers that don't need to leave the
|
||||
// machine in a generic research query.
|
||||
export const REPO_IDENTIFIER_PATTERNS = Object.freeze([
|
||||
/git\.fromaitochitta\.com[^\s"'`]*/,
|
||||
/\bktg-plugin-marketplace\b/,
|
||||
/\bplugins\/cache\/[^\s"'`]+/,
|
||||
]);
|
||||
|
||||
// HARD-BLOCK tier — secret-shaped strings. Never operator-overridable.
|
||||
//
|
||||
// Token bodies that contain `-` or `_` break a plain `[A-Za-z0-9]{n,}` run, so
|
||||
// each such format needs its own pattern rather than relying on run length:
|
||||
// an Anthropic Console key runs out after `api03` (3 alphanumerics), and a
|
||||
// fine-grained GitHub PAT after its 22-character segment. Patterns whose body
|
||||
// class includes `-`/`_` carry no trailing `\b`, which would not fire on a
|
||||
// non-word final character.
|
||||
export const SECRET_SHAPED_PATTERNS = Object.freeze([
|
||||
/\bsk-[A-Za-z0-9]{20,}\b/, // OpenAI-style API key (sk-<48>)
|
||||
/\bsk-ant-[a-z0-9]+-[A-Za-z0-9_-]{20,}/, // Anthropic Console key (sk-ant-api03-/-oat01- + ~95 base64url)
|
||||
/\bAKIA[0-9A-Z]{16}\b/, // AWS access key ID
|
||||
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, // GitHub classic PAT / OAuth / user / server / refresh token
|
||||
/\bgithub_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT (github_pat_<22>_<59>)
|
||||
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, // Slack token
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM private key block
|
||||
]);
|
||||
|
||||
function findMatch(patterns, text) {
|
||||
for (const re of patterns) {
|
||||
const m = re.exec(text);
|
||||
if (m) return m[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {{strict?: boolean, env?: object}} [opts]
|
||||
* @returns {{valid: boolean, errors: import('../util/result.mjs').Issue[], warnings: import('../util/result.mjs').Issue[]}}
|
||||
*/
|
||||
export function validateOutboundQuery(text, opts = {}) {
|
||||
const strict = opts.strict !== false;
|
||||
const env = opts.env || process.env;
|
||||
// Bypasses the WARN tier entirely — never affects the hard-block tier below.
|
||||
const allowWarnTier = env.VOYAGE_QUERY_PRIVACY_ALLOW === '1';
|
||||
|
||||
if (typeof text !== 'string' || text.length === 0) {
|
||||
return { valid: false, errors: [issue('PRIVACY_EMPTY_QUERY', 'Outbound query must be a non-empty string')], warnings: [] };
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
// Hard-block tier — checked unconditionally; no opt-in reaches this branch.
|
||||
const secretMatch = findMatch(SECRET_SHAPED_PATTERNS, text);
|
||||
if (secretMatch) {
|
||||
errors.push(issue('PRIVACY_SECRET_SHAPED', `Outbound query contains a secret-shaped token: ${secretMatch}`));
|
||||
}
|
||||
|
||||
if (!allowWarnTier) {
|
||||
const pathMatch = findMatch(ABSOLUTE_PATH_PATTERNS, text);
|
||||
if (pathMatch) {
|
||||
const issueObj = issue('PRIVACY_ABSOLUTE_PATH', `Outbound query contains an absolute filesystem path: ${pathMatch}`);
|
||||
if (strict) errors.push(issueObj); else warnings.push(issueObj);
|
||||
}
|
||||
|
||||
const repoMatch = findMatch(REPO_IDENTIFIER_PATTERNS, text);
|
||||
if (repoMatch) {
|
||||
const issueObj = issue('PRIVACY_REPO_IDENTIFIER', `Outbound query contains a repo-internal identifier: ${repoMatch}`);
|
||||
if (strict) errors.push(issueObj); else warnings.push(issueObj);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
const strict = !args.includes('--soft');
|
||||
const text = args.find(a => !a.startsWith('--'));
|
||||
if (text === undefined) {
|
||||
process.stderr.write('Usage: query-privacy-gate.mjs [--soft] "<query text>"\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const r = validateOutboundQuery(text, { strict });
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
process.exit(r.valid ? 0 : 1);
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "voyage",
|
||||
"version": "5.9.0",
|
||||
"version": "5.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "voyage",
|
||||
"version": "5.9.0",
|
||||
"version": "5.10.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"name": "voyage",
|
||||
"version": "5.9.0",
|
||||
"version": "5.10.0",
|
||||
"private": true,
|
||||
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
342
scripts/storm-measure.mjs
Normal file
342
scripts/storm-measure.mjs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/storm-measure.mjs
|
||||
// Step 11 — the STORM adoption gate: deterministic Δ accounting over
|
||||
// ${CLAUDE_PLUGIN_DATA}/trekresearch-stats.jsonl.
|
||||
//
|
||||
// What this decides: whether the bounded Phase 5 conversation loop buys enough
|
||||
// extra source/coverage breadth to justify flipping VOYAGE_STORM_ENABLED on by
|
||||
// default in lib/util/research-loop-cap.mjs. Nothing else. The thresholds live
|
||||
// in docs/storm-measurement.md and were committed BEFORE any measurement run —
|
||||
// that pre-registration is the whole point, so this script never invents them.
|
||||
//
|
||||
// What this does NOT measure: outline quality, answer correctness, or operator
|
||||
// satisfaction. It measures breadth (distinct sources, dimensions covered).
|
||||
// A breadth win is necessary for adoption, not sufficient on its own.
|
||||
//
|
||||
// Honesty properties, both load-bearing:
|
||||
// - Runs with empty_turns > 0 are EXCLUDED from the gain and REPORTED. An
|
||||
// empty turn means the loop spent budget and returned nothing; leaving those
|
||||
// in decides adoption on a broken denominator.
|
||||
// - A stats file carrying no `effort` field is a loud error. The silent
|
||||
// failure this prevents is an empty treatment group reading as "no gain",
|
||||
// which would decline the mechanism for a schema reason.
|
||||
//
|
||||
// Zero deps. Node stdlib only.
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Pre-registered in docs/storm-measurement.md. Do not tune to fit a result.
|
||||
export const ADOPT_THRESHOLD = 0.30;
|
||||
export const DECLINE_THRESHOLD = 0.15;
|
||||
|
||||
const STATS_FILENAME = 'trekresearch-stats.jsonl';
|
||||
|
||||
// ---- pure core (unit-tested) -------------------------------------------------
|
||||
|
||||
/** @param {number[]} xs @returns {number|null} null for an empty list — 0 would read as a measurement. */
|
||||
export function median(xs) {
|
||||
if (!Array.isArray(xs) || xs.length === 0) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const mid = s.length >> 1;
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a trekresearch-stats.jsonl body into effort-carrying records.
|
||||
*
|
||||
* Rows predating Step 9 have no `effort` field; they are dropped and counted as
|
||||
* `legacy` rather than silently pooled into the control arm. A file where NO row
|
||||
* carries `effort` throws — see the header note on silent failure.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{records: object[], malformed: number, legacy: number}}
|
||||
*/
|
||||
export function parseStats(text) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const records = [];
|
||||
let malformed = 0;
|
||||
let legacy = 0;
|
||||
let parsed = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let rec;
|
||||
try {
|
||||
rec = JSON.parse(line);
|
||||
} catch {
|
||||
malformed++;
|
||||
continue;
|
||||
}
|
||||
parsed++;
|
||||
if (typeof rec?.effort !== 'string' || rec.effort.length === 0) {
|
||||
legacy++;
|
||||
continue;
|
||||
}
|
||||
records.push(rec);
|
||||
}
|
||||
|
||||
if (parsed === 0) {
|
||||
throw new Error(`storm-measure: no records in stats file (${malformed} malformed line(s)).`);
|
||||
}
|
||||
if (records.length === 0) {
|
||||
throw new Error(
|
||||
`storm-measure: no records carry an \`effort\` field (${legacy} legacy row(s)). ` +
|
||||
`The gate groups on \`effort\`; without it there is no treatment arm to measure. ` +
|
||||
`Re-run the measurement set on a build that emits the Step 9 fields.`,
|
||||
);
|
||||
}
|
||||
return { records, malformed, legacy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split off the runs that must not count toward a gain.
|
||||
*
|
||||
* A value that cannot be READ as a turn count is excluded, not treated as zero.
|
||||
* `Number('many')` is NaN, and testing `Number.isFinite(empty) && empty > 0`
|
||||
* sent NaN down the eligible branch — so a garbage field silently re-entered the
|
||||
* denominator, in the direction that flatters adoption: the run whose bookkeeping
|
||||
* broke is the run whose numbers deserve the least trust. Absent and null stay
|
||||
* eligible via `?? 0`, because a field that was never written is a genuine zero
|
||||
* on any run where the loop did not arm.
|
||||
*
|
||||
* @param {object[]} records
|
||||
* @returns {{eligible: object[], excluded: number}}
|
||||
*/
|
||||
export function partitionEligible(records) {
|
||||
const eligible = [];
|
||||
let excluded = 0;
|
||||
for (const r of records) {
|
||||
const empty = Number(r.empty_turns ?? 0);
|
||||
if (!Number.isFinite(empty) || empty > 0) excluded++;
|
||||
else eligible.push(r);
|
||||
}
|
||||
return { eligible, excluded };
|
||||
}
|
||||
|
||||
function numbers(records, pick) {
|
||||
return records.map(pick).filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
/** Relative gain (treatment − control)/control. null when control is absent or zero. */
|
||||
function relGain(control, treatment) {
|
||||
if (control === null || treatment === null || control === 0) return null;
|
||||
return (treatment - control) / control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full measurement over a parsed record set.
|
||||
*
|
||||
* Treatment arm = `effort: high` (the only effort at which the loop runs).
|
||||
* Control arm = every other effort.
|
||||
*
|
||||
* - sources: between-arm median gain in `unique_sources`.
|
||||
* - dimensions: within-run median gain of (dimensions − dimensions_baseline)
|
||||
* / dimensions_baseline across the treatment arm. It is a
|
||||
* within-run delta by construction, so it needs no control arm —
|
||||
* the control arm's is 0, the loop being inert there.
|
||||
*
|
||||
* @param {object[]} records
|
||||
*/
|
||||
export function measure(records) {
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
const treatment = eligible.filter((r) => r.effort === 'high');
|
||||
const control = eligible.filter((r) => r.effort !== 'high');
|
||||
|
||||
const srcControl = median(numbers(control, (r) => Number(r.unique_sources)));
|
||||
const srcTreatment = median(numbers(treatment, (r) => Number(r.unique_sources)));
|
||||
const sourcesGain = relGain(srcControl, srcTreatment);
|
||||
|
||||
const dimDeltas = treatment
|
||||
.map((r) => ({ d: Number(r.dimensions), b: Number(r.dimensions_baseline) }))
|
||||
.filter(({ d, b }) => Number.isFinite(d) && Number.isFinite(b) && b > 0)
|
||||
.map(({ d, b }) => (d - b) / b);
|
||||
const dimensionsGain = median(dimDeltas);
|
||||
|
||||
return {
|
||||
control: { n: control.length, sources: srcControl },
|
||||
treatment: { n: treatment.length, sources: srcTreatment },
|
||||
sources: { control: srcControl, treatment: srcTreatment, gain: sourcesGain },
|
||||
dimensions: { gain: dimensionsGain, n: dimDeltas.length },
|
||||
excluded,
|
||||
verdict: decideVerdict(sourcesGain, dimensionsGain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-registered mapping, verbatim from the brief: "median forbedring >= 30 %
|
||||
* på (a) eller (b) → adopt. < 15 % → decline." OR on both sides, adopt
|
||||
* evaluated first — so a strong win on one axis is an adopt even when the
|
||||
* other axis sits under the decline bar. A stricter AND rule may well be the
|
||||
* better decision procedure, but changing it here is changing the
|
||||
* pre-registration after the fact, which is the one thing the constraint
|
||||
* exists to prevent.
|
||||
*
|
||||
* @param {number|null} sourcesGain
|
||||
* @param {number|null} dimensionsGain
|
||||
* @returns {'adopt'|'decline'|'inconclusive'|'insufficient-data'}
|
||||
*/
|
||||
export function decideVerdict(sourcesGain, dimensionsGain) {
|
||||
if (sourcesGain === null || sourcesGain === undefined) return 'insufficient-data';
|
||||
if (dimensionsGain === null || dimensionsGain === undefined) return 'insufficient-data';
|
||||
if (sourcesGain >= ADOPT_THRESHOLD || dimensionsGain >= ADOPT_THRESHOLD) return 'adopt';
|
||||
if (sourcesGain < DECLINE_THRESHOLD || dimensionsGain < DECLINE_THRESHOLD) return 'decline';
|
||||
return 'inconclusive';
|
||||
}
|
||||
|
||||
/**
|
||||
* SC activation check, BOTH halves.
|
||||
*
|
||||
* The SC asks two things of an `effort: high` run: that it discovered at least
|
||||
* one dimension, AND that the dimension list in the output brief is a TRUE
|
||||
* SUPERSET of the interview-derived ones. This function used to check only
|
||||
* `dimensions - dimensions_baseline >= 1`, which is a count delta and says
|
||||
* nothing about membership — a run that dropped two interview dimensions and
|
||||
* added three discovered ones passed while violating the second half.
|
||||
* Supersetness was asserted only by Phase 4.5's prose contract that discovery
|
||||
* APPENDS; nothing read it.
|
||||
*
|
||||
* The record cannot carry the dimension names: names are free prose, and
|
||||
* lib/exporters/field-allowlist.mjs denies prose by omission. So the run attests
|
||||
* membership with `dimensions_baseline_preserved`, a low-cardinality boolean set
|
||||
* in Phase 4.5, and this gate refuses to call activation OK without it. An
|
||||
* ABSENT attestation is not an attestation — legacy rows fail here rather than
|
||||
* passing on the old count-only rule.
|
||||
*
|
||||
* @param {object[]} records
|
||||
*/
|
||||
export function activationCheck(records) {
|
||||
const high = records.filter((r) => r.effort === 'high');
|
||||
if (high.length === 0) {
|
||||
return { ok: false, reason: 'no `effort: high` run found in stats', discovered_dimensions: null };
|
||||
}
|
||||
const last = high[high.length - 1];
|
||||
const d = Number(last.dimensions);
|
||||
const b = Number(last.dimensions_baseline);
|
||||
if (!Number.isFinite(d) || !Number.isFinite(b)) {
|
||||
return { ok: false, reason: 'latest high run lacks dimensions/dimensions_baseline', discovered_dimensions: null };
|
||||
}
|
||||
const discovered = d - b;
|
||||
const preserved = last.dimensions_baseline_preserved;
|
||||
|
||||
const base = {
|
||||
ts: last.ts ?? null,
|
||||
dimensions: d,
|
||||
dimensions_baseline: b,
|
||||
discovered_dimensions: discovered,
|
||||
dimensions_baseline_preserved: preserved ?? null,
|
||||
conv_turns: Number(last.conv_turns ?? 0),
|
||||
empty_turns: Number(last.empty_turns ?? 0),
|
||||
};
|
||||
|
||||
if (typeof preserved !== 'boolean') {
|
||||
return {
|
||||
...base,
|
||||
ok: false,
|
||||
reason:
|
||||
'latest high run does not attest `dimensions_baseline_preserved`; the SC needs a true ' +
|
||||
'superset of the interview dimensions, and a count delta cannot show membership',
|
||||
};
|
||||
}
|
||||
if (preserved === false) {
|
||||
return {
|
||||
...base,
|
||||
ok: false,
|
||||
reason:
|
||||
`latest high run discovered ${discovered} dimension(s) but did NOT preserve its interview ` +
|
||||
'baseline, so the final list is not a superset of it',
|
||||
};
|
||||
}
|
||||
if (discovered < 1) {
|
||||
return { ...base, ok: false, reason: 'latest high run discovered no dimensions beyond its baseline' };
|
||||
}
|
||||
return { ...base, ok: true };
|
||||
}
|
||||
|
||||
// ---- CLI shim ----------------------------------------------------------------
|
||||
|
||||
function pct(x) {
|
||||
return x === null ? 'n/a' : `${(x * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function defaultStatsPath(env = process.env) {
|
||||
const dir = env.CLAUDE_PLUGIN_DATA;
|
||||
return dir ? join(dir, STATS_FILENAME) : null;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { stats: null, json: false, activation: false, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--stats') o.stats = argv[++i];
|
||||
else if (a === '--json') o.json = true;
|
||||
else if (a === '--activation-check') o.activation = true;
|
||||
else if (a === '--help' || a === '-h') o.help = true;
|
||||
else { process.stderr.write(`Unknown argument: ${a}\n`); process.exit(2); }
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
function mainCli() {
|
||||
const o = parseArgs(process.argv.slice(2));
|
||||
if (o.help) {
|
||||
process.stdout.write(
|
||||
'Usage: storm-measure.mjs [--stats FILE] [--activation-check] [--json]\n' +
|
||||
' Default --stats: ${CLAUDE_PLUGIN_DATA}/' + STATS_FILENAME + '\n' +
|
||||
' Thresholds (pre-registered, docs/storm-measurement.md): ' +
|
||||
`adopt >= ${ADOPT_THRESHOLD * 100}%, decline < ${DECLINE_THRESHOLD * 100}%\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const statsPath = o.stats || defaultStatsPath();
|
||||
if (!statsPath) {
|
||||
process.stderr.write('storm-measure: CLAUDE_PLUGIN_DATA is not set and no --stats FILE was given.\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!existsSync(statsPath)) {
|
||||
process.stderr.write(`storm-measure: stats file not found: ${statsPath}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseStats(readFileSync(statsPath, 'utf-8'));
|
||||
} catch (e) {
|
||||
process.stderr.write(`${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (o.activation) {
|
||||
const res = activationCheck(parsed.records);
|
||||
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
||||
process.exit(res.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
const m = measure(parsed.records);
|
||||
|
||||
if (o.json) {
|
||||
process.stdout.write(JSON.stringify({ statsPath, ...m, malformed: parsed.malformed, legacy: parsed.legacy }, null, 2) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const L = [];
|
||||
L.push(`STORM adoption gate — ${statsPath}`);
|
||||
L.push(` control (effort != high): n=${m.control.n} median unique_sources=${m.control.sources ?? 'n/a'}`);
|
||||
L.push(` treatment (effort = high): n=${m.treatment.n} median unique_sources=${m.treatment.sources ?? 'n/a'}`);
|
||||
L.push(` excluded (empty_turns > 0): ${m.excluded}`);
|
||||
if (parsed.legacy) L.push(` legacy rows without \`effort\`: ${parsed.legacy}`);
|
||||
if (parsed.malformed) L.push(` malformed lines: ${parsed.malformed}`);
|
||||
L.push('');
|
||||
L.push(` median gain, unique_sources: ${pct(m.sources.gain)}`);
|
||||
L.push(` median gain, dimensions over baseline: ${pct(m.dimensions.gain)} (n=${m.dimensions.n})`);
|
||||
L.push('');
|
||||
L.push(` thresholds: adopt >= ${pct(ADOPT_THRESHOLD)} on EITHER · decline < ${pct(DECLINE_THRESHOLD)} on EITHER · adopt wins ties`);
|
||||
L.push(` VERDICT: ${m.verdict}`);
|
||||
process.stdout.write(L.join('\n') + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
mainCli();
|
||||
}
|
||||
127
tests/commands/trekendsession.test.mjs
Normal file
127
tests/commands/trekendsession.test.mjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// tests/commands/trekendsession.test.mjs
|
||||
// Regression tests for /trekendsession (commands/trekendsession.md).
|
||||
//
|
||||
// Bug (2026-07-03): two of the three !`...` eager-exec blocks contained
|
||||
// unresolved placeholders (<project-dir> etc.). The harness executes
|
||||
// eager-exec blocks at command LOAD time, so zsh parsed <project-dir> as
|
||||
// input redirection and the command aborted before the model saw a single
|
||||
// instruction. Eager-exec is only valid for self-contained commands.
|
||||
//
|
||||
// Pattern D (markdown structure) — assertions against command prose.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMANDS_DIR = join(ROOT, 'commands');
|
||||
const COMMAND_FILE = join(COMMANDS_DIR, 'trekendsession.md');
|
||||
|
||||
function readCommand() {
|
||||
return readFileSync(COMMAND_FILE, 'utf8');
|
||||
}
|
||||
|
||||
function extractPhase(commandText, phaseHeader) {
|
||||
const startIdx = commandText.indexOf(phaseHeader);
|
||||
if (startIdx === -1) return '';
|
||||
const rest = commandText.slice(startIdx);
|
||||
const nextPhase = rest.search(/\n## (?:Phase |Hard )/);
|
||||
if (nextPhase === -1) return rest;
|
||||
return rest.slice(0, nextPhase);
|
||||
}
|
||||
|
||||
// Extract all eager-exec blocks (!`...`) from a command/skill file,
|
||||
// including multi-line blocks. Returns [{ content, line }].
|
||||
function extractEagerBlocks(text) {
|
||||
const blocks = [];
|
||||
const re = /!`([^`]+)`/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const line = text.slice(0, m.index).split('\n').length;
|
||||
blocks.push({ content: m[1], line });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Marketplace-wide regression guard: eager-exec blocks must be
|
||||
// self-contained. An unresolved placeholder (<angle> or {curly}) in an
|
||||
// eager block is executed verbatim by the shell at load time — <x> is
|
||||
// parsed as input redirection and aborts the whole command load.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
test('eager-exec guard — no !`-block in commands/ contains an unresolved placeholder', () => {
|
||||
const offenders = [];
|
||||
for (const file of readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'))) {
|
||||
const text = readFileSync(join(COMMANDS_DIR, file), 'utf8');
|
||||
for (const { content, line } of extractEagerBlocks(text)) {
|
||||
// Placeholder conventions: <angle-word> or {curly_word}. Curly must
|
||||
// contain a separator (- or _) so JS destructuring like {join} in a
|
||||
// legitimate self-contained script does not false-positive; angle
|
||||
// placeholders are unambiguous (shell would parse them as redirects).
|
||||
if (/<[a-z][a-z0-9_-]*>/.test(content) || /\{[a-z][a-z0-9]*([_-][a-z0-9]+)+\}/.test(content)) {
|
||||
offenders.push(`${file}:${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`eager-exec !\`-blocks run at command LOAD time and must be self-contained; ` +
|
||||
`placeholder found in: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// trekendsession-specific: exactly one eager block (Phase 1 project
|
||||
// discovery — self-contained, legitimate); Phases 3 and 4 are runtime
|
||||
// Bash-tool commands with model-substituted values, never eager.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
test('trekendsession — exactly one eager-exec block remains (Phase 1 discovery)', () => {
|
||||
const cmd = readCommand();
|
||||
const blocks = extractEagerBlocks(cmd);
|
||||
assert.equal(
|
||||
blocks.length,
|
||||
1,
|
||||
`expected exactly 1 eager-exec block (Phase 1 discovery), got ${blocks.length} at line(s) ${blocks.map((b) => b.line).join(', ')}`,
|
||||
);
|
||||
assert.match(
|
||||
blocks[0].content,
|
||||
/readdirSync\(root\)/,
|
||||
'the surviving eager block must be the self-contained Phase 1 discovery script',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekendsession Phase 3 — atomic-write block is runtime Bash (no eager prefix) with plugin-root import', () => {
|
||||
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
|
||||
assert.doesNotMatch(phase3, /!`/, 'Phase 3 must not use eager-exec — values exist only at runtime');
|
||||
assert.match(
|
||||
phase3,
|
||||
/\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/util\/atomic-write\.mjs/,
|
||||
'Phase 3 import must use the absolute ${CLAUDE_PLUGIN_ROOT} path — cwd is the user repo, not the plugin root',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
phase3,
|
||||
/['"]\.\/lib\/util\/atomic-write\.mjs['"]/,
|
||||
'Phase 3 must not import atomic-write.mjs via a cwd-relative path',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekendsession Phase 4 — validator call is runtime Bash (no eager prefix) with plugin-root path', () => {
|
||||
const phase4 = extractPhase(readCommand(), '## Phase 4 ');
|
||||
assert.doesNotMatch(phase4, /!`/, 'Phase 4 must not use eager-exec — the state-file path exists only at runtime');
|
||||
assert.match(
|
||||
phase4,
|
||||
/\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/validators\/session-state-validator\.mjs/,
|
||||
'Phase 4 validator path must use the absolute ${CLAUDE_PLUGIN_ROOT} convention',
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
phase4,
|
||||
/<[a-z][a-z0-9_-]*>/,
|
||||
'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection',
|
||||
);
|
||||
});
|
||||
349
tests/commands/trekexecute-parallel-portability.test.mjs
Normal file
349
tests/commands/trekexecute-parallel-portability.test.mjs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// tests/commands/trekexecute-parallel-portability.test.mjs
|
||||
//
|
||||
// Executable tests for the two shell blocks /trekexecute Phase 2.6 Step 2a' and
|
||||
// Phase 2.55 Check 2 tell the agent to run. Both blocks wrecked a real voyage on
|
||||
// macOS (order 20260831T214411Z-941965142-from-.claude):
|
||||
//
|
||||
// Defect 1: `realpath --relative-to` is GNU coreutils. On BSD realpath the
|
||||
// command substitution fails, PROJECT_REL becomes EMPTY, and `mkdir -p
|
||||
// "$wt/"` + `cp ... "$wt//"` both SUCCEED — brief.md/plan.md land at the
|
||||
// worktree root instead of the project relpath. Exit status stays 0; only
|
||||
// file location tells the truth. Every assertion here checks placement.
|
||||
// Defect 2: Check 2 ran `git add {plan-path}` unconditionally. When the
|
||||
// project directory is gitignored (normal — .claude/projects/ is tool-
|
||||
// managed and local-only) the add fails and the plan never reaches HEAD.
|
||||
//
|
||||
// The blocks are EXTRACTED from commands/trekexecute.md and executed, so the
|
||||
// test binds to what an agent actually copies, not to prose about it. Anchors
|
||||
// are grep-able strings, never line numbers.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync } from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
|
||||
|
||||
const COPY_BLOCK_ANCHOR = "**2a'. Copy gitignored project artifacts";
|
||||
const CHECK2_ANCHOR = '### Check 2 —';
|
||||
|
||||
// The pre-fix form, kept ONLY as the negative control for Defect 1.
|
||||
const LEGACY_GNU_LINE = 'PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"';
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
/** Extract the first ```bash fence that follows `anchor` in commands/trekexecute.md. */
|
||||
function extractBashBlock(anchor) {
|
||||
const text = readFileSync(COMMAND_FILE, 'utf8');
|
||||
const at = text.indexOf(anchor);
|
||||
assert.ok(at >= 0, `anchor not found in trekexecute.md: ${anchor}`);
|
||||
const fenceOpen = text.indexOf('```bash', at);
|
||||
assert.ok(fenceOpen >= 0, `no bash fence after anchor: ${anchor}`);
|
||||
const bodyStart = text.indexOf('\n', fenceOpen) + 1;
|
||||
const fenceClose = text.indexOf('```', bodyStart);
|
||||
assert.ok(fenceClose > bodyStart, `unterminated bash fence after anchor: ${anchor}`);
|
||||
return text.slice(bodyStart, fenceClose);
|
||||
}
|
||||
|
||||
/**
|
||||
* A PATH directory whose `realpath` behaves like BSD realpath: it rejects every
|
||||
* GNU long option and resolves bare paths correctly. Stubbed, never assumed —
|
||||
* the machine running the suite may or may not have GNU coreutils.
|
||||
*/
|
||||
function bsdRealpathStubDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'trek-bsdstub-'));
|
||||
const stub = join(dir, 'realpath');
|
||||
writeFileSync(stub, [
|
||||
'#!/bin/sh',
|
||||
'# BSD realpath stand-in: no GNU long options.',
|
||||
'for a in "$@"; do',
|
||||
' case "$a" in',
|
||||
' --*) echo "realpath: illegal option -- -" >&2; exit 1 ;;',
|
||||
' esac',
|
||||
'done',
|
||||
"exec python3 -c 'import os,sys",
|
||||
'for p in sys.argv[1:]: print(os.path.realpath(p))',
|
||||
"' \"$@\"",
|
||||
'',
|
||||
].join('\n'));
|
||||
chmodSync(stub, 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A PATH directory whose `git check-ignore` dies fatally (128), everything else real. */
|
||||
function fatalCheckIgnoreGitStubDir() {
|
||||
const realGit = execFileSync('/usr/bin/env', ['sh', '-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
||||
assert.ok(realGit, 'git not on PATH — cannot build the check-ignore stub');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'trek-gitstub-'));
|
||||
const stub = join(dir, 'git');
|
||||
writeFileSync(stub, [
|
||||
'#!/bin/sh',
|
||||
'# `git check-ignore` fatal (128): NOT an answer about ignore status.',
|
||||
'for a in "$@"; do',
|
||||
' if [ "$a" = "check-ignore" ]; then',
|
||||
' echo "fatal: simulated check-ignore failure" >&2',
|
||||
' exit 128',
|
||||
' fi',
|
||||
'done',
|
||||
`exec ${realGit} "$@"`,
|
||||
'',
|
||||
].join('\n'));
|
||||
chmodSync(stub, 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* A temp repo with a committed file plus a project directory holding brief/plan
|
||||
* (+ research unless opts.research === false). `gitignoreProject: true` adds the
|
||||
* .gitignore line that makes the project directory invisible to `git add` —
|
||||
* and, note, invisible to `git status --porcelain` too, which is exactly why
|
||||
* this topology sails through Check 1 and only trips on Check 2.
|
||||
*/
|
||||
function makeRepo(opts = {}) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'trek-exec-')));
|
||||
git(root, 'init', '-q', '-b', 'main');
|
||||
git(root, 'config', 'user.email', 'test@example.invalid');
|
||||
git(root, 'config', 'user.name', 'Test');
|
||||
writeFileSync(join(root, 'README.md'), '# fixture\n');
|
||||
if (opts.gitignoreProject) writeFileSync(join(root, '.gitignore'), '.claude/projects/\n');
|
||||
git(root, 'add', 'README.md', ...(opts.gitignoreProject ? ['.gitignore'] : []));
|
||||
git(root, 'commit', '-qm', 'init');
|
||||
|
||||
const projectRel = join('.claude', 'projects', 'demo');
|
||||
const projectDir = join(root, projectRel);
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, 'brief.md'), '# brief\n');
|
||||
writeFileSync(join(projectDir, 'plan.md'), '# plan\n');
|
||||
if (opts.research !== false) {
|
||||
mkdirSync(join(projectDir, 'research'), { recursive: true });
|
||||
writeFileSync(join(projectDir, 'research', '01-x.md'), '# r\n');
|
||||
}
|
||||
|
||||
const worktreeDir = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees');
|
||||
mkdirSync(join(worktreeDir, 'session-1'), { recursive: true });
|
||||
mkdirSync(join(worktreeDir, 'session-2'), { recursive: true });
|
||||
|
||||
return { root, projectRel, projectDir, worktreeDir };
|
||||
}
|
||||
|
||||
function runBlock(script, { cwd, env }) {
|
||||
return spawnSync('bash', ['-c', script], { cwd, env: { ...process.env, ...env }, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function copyBlockEnv(repo, pathPrefixDir) {
|
||||
return {
|
||||
REPO_ROOT: repo.root,
|
||||
PROJECT_DIR: repo.projectDir,
|
||||
WORKTREE_DIR: repo.worktreeDir,
|
||||
PATH: `${pathPrefixDir}:${process.env.PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
function exists(p) {
|
||||
try { readFileSync(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// --- Defect 1: portable relpath derivation -------------------------------
|
||||
|
||||
test("2a' — the BSD realpath stub is known-positive: bare path resolves, --relative-to is rejected", () => {
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const bare = runBlock('realpath "$HOME"', { cwd: ROOT, env: { PATH: `${stubDir}:${process.env.PATH}` } });
|
||||
assert.equal(bare.status, 0, 'stub must resolve a bare path (proves it can succeed)');
|
||||
assert.equal(bare.stdout.trim(), realpathSync(process.env.HOME));
|
||||
|
||||
const gnu = runBlock('realpath --relative-to=/ "$HOME"', { cwd: ROOT, env: { PATH: `${stubDir}:${process.env.PATH}` } });
|
||||
assert.notEqual(gnu.status, 0, 'stub must reject the GNU long option');
|
||||
assert.match(gnu.stderr, /illegal option/, 'stub must fail the way BSD realpath fails');
|
||||
assert.equal(gnu.stdout.trim(), '', 'no stdout — this is what leaves PROJECT_REL empty');
|
||||
} finally {
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — NEGATIVE CONTROL: the GNU form drops plan.md at the worktree ROOT under BSD realpath", () => {
|
||||
const repo = makeRepo();
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const legacy = [
|
||||
'PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"',
|
||||
LEGACY_GNU_LINE,
|
||||
'for wt in "$WORKTREE_DIR"/session-*; do',
|
||||
' [ -d "$wt" ] || continue',
|
||||
' mkdir -p "$wt/$PROJECT_REL"',
|
||||
' cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"',
|
||||
' cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"',
|
||||
'done',
|
||||
].join('\n');
|
||||
runBlock(legacy, { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
|
||||
const wt = join(repo.worktreeDir, 'session-1');
|
||||
assert.equal(exists(join(wt, repo.projectRel, 'plan.md')), false,
|
||||
'the broken form must NOT put plan.md at the project relpath');
|
||||
assert.equal(exists(join(wt, 'plan.md')), true,
|
||||
'the broken form silently drops plan.md at the worktree root — the measured havari');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — the shipped block copies brief/plan/research to $wt/$PROJECT_REL without GNU realpath", () => {
|
||||
const repo = makeRepo();
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
assert.equal(r.status, 0, `block must succeed without GNU realpath. stderr: ${r.stderr}`);
|
||||
for (const s of ['session-1', 'session-2']) {
|
||||
const dest = join(repo.worktreeDir, s, repo.projectRel);
|
||||
assert.equal(exists(join(dest, 'plan.md')), true, `${s}: plan.md must reach $wt/$PROJECT_REL`);
|
||||
assert.equal(exists(join(dest, 'brief.md')), true, `${s}: brief.md must reach $wt/$PROJECT_REL`);
|
||||
assert.equal(exists(join(dest, 'research', '01-x.md')), true, `${s}: research/ must reach $wt/$PROJECT_REL`);
|
||||
assert.equal(exists(join(repo.worktreeDir, s, 'plan.md')), false,
|
||||
`${s}: nothing may land at the worktree root`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — a project without research/ is not a failure (block still exits 0)", () => {
|
||||
const repo = makeRepo({ research: false });
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
assert.equal(r.status, 0, `missing research/ must not fail the wave. stderr: ${r.stderr}`);
|
||||
assert.equal(exists(join(repo.worktreeDir, 'session-1', repo.projectRel, 'plan.md')), true);
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — an underivable relpath fails LOUDLY instead of dropping files at the worktree root", () => {
|
||||
const repo = makeRepo();
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
const outside = realpathSync(mkdtempSync(join(tmpdir(), 'trek-outside-')));
|
||||
try {
|
||||
mkdirSync(join(outside, 'p'), { recursive: true });
|
||||
writeFileSync(join(outside, 'p', 'brief.md'), 'b');
|
||||
writeFileSync(join(outside, 'p', 'plan.md'), 'p');
|
||||
const env = { ...copyBlockEnv(repo, stubDir), PROJECT_DIR: join(outside, 'p') };
|
||||
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env });
|
||||
assert.notEqual(r.status, 0, 'a project outside REPO_ROOT must abort the wave');
|
||||
assert.match(r.stderr, /relpath/i, 'the abort must name the cause');
|
||||
assert.equal(exists(join(repo.worktreeDir, 'session-1', 'plan.md')), false,
|
||||
'nothing may be dropped at the worktree root');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — the GNU-only form is gone from the block agents copy", () => {
|
||||
const block = extractBashBlock(COPY_BLOCK_ANCHOR);
|
||||
assert.equal(block.includes('--relative-to'), false,
|
||||
'the copied block must not contain `realpath --relative-to` (GNU-only)');
|
||||
assert.ok(block.includes('os.path.relpath'), 'the copied block must derive the relpath portably');
|
||||
});
|
||||
|
||||
test("both copied blocks are ASCII-clean (bash 3.2 dies on a multibyte char under set -u)", () => {
|
||||
const nonAscii = (s) => s.split('\n')
|
||||
.map((line, i) => [i + 1, line])
|
||||
.filter(([, line]) => /[^\x00-\x7F]/.test(line));
|
||||
// Known-positive: the detector must actually fire on a multibyte char.
|
||||
assert.equal(nonAscii('echo "a — b"').length, 1, 'detector must find an em-dash');
|
||||
for (const anchor of [COPY_BLOCK_ANCHOR, CHECK2_ANCHOR]) {
|
||||
assert.deepEqual(nonAscii(extractBashBlock(anchor)), [],
|
||||
`non-ASCII inside the shell block after ${anchor} (prose outside the fence is fine)`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Defect 2: gitignored project directory ------------------------------
|
||||
|
||||
// The block carries the `{plan-path}` placeholder the way every other block in
|
||||
// trekexecute.md does. Substitute it exactly as an agent would — never inject
|
||||
// PLAN_PATH through the environment: that would supply what the doc must supply
|
||||
// itself, and a block that never assigns the variable would still pass.
|
||||
function check2Script(planPath) {
|
||||
const block = extractBashBlock(CHECK2_ANCHOR);
|
||||
assert.ok(block.includes('{plan-path}'),
|
||||
'Check 2 block must carry the {plan-path} placeholder for the agent to substitute');
|
||||
return block.replace('{plan-path}', planPath);
|
||||
}
|
||||
|
||||
function check2Env(repo, pathPrefixDir) {
|
||||
const env = { REPO_ROOT: repo.root, PLAN_PATH: '' };
|
||||
if (pathPrefixDir) env.PATH = `${pathPrefixDir}:${process.env.PATH}`;
|
||||
return env;
|
||||
}
|
||||
|
||||
test('Check 2 — gitignored project dir: no commit, no failure, and 2a\' still delivers the plan', () => {
|
||||
const repo = makeRepo({ gitignoreProject: true });
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
// Known-positive on the premise: the plan file really is ignored here.
|
||||
const ci = spawnSync('git', ['check-ignore', '-v', join(repo.projectRel, 'plan.md')],
|
||||
{ cwd: repo.root, encoding: 'utf8' });
|
||||
assert.equal(ci.status, 0, 'fixture premise: the plan file must actually be gitignored');
|
||||
|
||||
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
|
||||
const r = runBlock(check2Script(join(repo.projectRel, 'plan.md')),
|
||||
{ cwd: repo.root, env: check2Env(repo) });
|
||||
assert.equal(r.status, 0, `Check 2 must tolerate a gitignored plan file. stderr: ${r.stderr}`);
|
||||
assert.equal(git(repo.root, 'rev-parse', 'HEAD').trim(), head,
|
||||
'an ignored plan file must NOT be forced into history (origin is a public mirror)');
|
||||
|
||||
const copy = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
assert.equal(copy.status, 0, `copy step must succeed. stderr: ${copy.stderr}`);
|
||||
assert.equal(exists(join(repo.worktreeDir, 'session-1', repo.projectRel, 'plan.md')), true,
|
||||
'the plan must reach the worktree even though git never tracked it');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Check 2 — KNOWN-POSITIVE: an untracked, NOT-ignored plan file is still added and committed', () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const planPath = join(repo.projectRel, 'plan.md');
|
||||
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
|
||||
const r = runBlock(check2Script(planPath), { cwd: repo.root, env: check2Env(repo) });
|
||||
assert.equal(r.status, 0, `Check 2 must succeed on a normal untracked plan. stderr: ${r.stderr}`);
|
||||
assert.notEqual(git(repo.root, 'rev-parse', 'HEAD').trim(), head,
|
||||
'a trackable plan file must still be committed for worktree visibility');
|
||||
const ls = spawnSync('git', ['ls-files', '--error-unmatch', planPath], { cwd: repo.root, encoding: 'utf8' });
|
||||
assert.equal(ls.status, 0, 'the plan file must now be tracked');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Check 2 — a FATAL git check-ignore (128) is not read as "not ignored"', () => {
|
||||
const repo = makeRepo({ gitignoreProject: true });
|
||||
const stubDir = fatalCheckIgnoreGitStubDir();
|
||||
try {
|
||||
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
|
||||
const r = runBlock(check2Script(join(repo.projectRel, 'plan.md')),
|
||||
{ cwd: repo.root, env: check2Env(repo, stubDir) });
|
||||
assert.notEqual(r.status, 0, 'a fatal check-ignore must stop, not fall through to git add');
|
||||
assert.match(r.stderr, /check-ignore/, 'the stop must name the failing probe');
|
||||
assert.equal(git(repo.root, 'rev-parse', 'HEAD').trim(), head, 'no commit may be made on a fatal probe');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -34,10 +34,277 @@ test('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNAL
|
|||
|
||||
test('trekresearch — low-effort path references --quick equivalent', () => {
|
||||
const text = read();
|
||||
const compIdx = text.indexOf('## Composition rule (v5.1)');
|
||||
// Bound the Composition rule section by the next `###` heading rather than a
|
||||
// magic 2000-character window: a fixed count silently drops the match as soon
|
||||
// as prose is inserted above it, turning a real pin into a no-op.
|
||||
const sectionOf = (doc) => {
|
||||
const compIdx = doc.indexOf('## Composition rule (v5.1)');
|
||||
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
|
||||
const section = text.slice(compIdx, compIdx + 2000);
|
||||
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
|
||||
const nextHeading = doc.indexOf('\n### ', compIdx);
|
||||
return nextHeading > compIdx ? doc.slice(compIdx, nextHeading) : doc.slice(compIdx);
|
||||
};
|
||||
|
||||
// (a) positive: the low-effort path is documented inside the bounded section.
|
||||
assert.match(sectionOf(text), /--quick/, 'Low-effort path must mention --quick equivalent');
|
||||
|
||||
// (b) negative: an actual removal must still be caught — a bound that can
|
||||
// never fail proves nothing.
|
||||
const mutated = text.replace(/--quick/g, '--removed');
|
||||
assert.doesNotMatch(sectionOf(mutated), /--quick/,
|
||||
'heading-bounded slice must still fail on a genuine removal');
|
||||
});
|
||||
|
||||
// --- Step 7: Phase 5 bounded conversation loop (heading-bounded slices) ---
|
||||
|
||||
// Same bounding discipline as the Composition-rule pin above: slice from the
|
||||
// phase heading to the NEXT phase heading, never a fixed character window.
|
||||
function phaseSlice(doc, startHeading, endHeading) {
|
||||
const start = doc.indexOf(startHeading);
|
||||
assert.ok(start >= 0, `${startHeading} missing`);
|
||||
const end = doc.indexOf(endHeading, start);
|
||||
assert.ok(end > start, `${endHeading} missing — could not bound ${startHeading}`);
|
||||
return doc.slice(start, end);
|
||||
}
|
||||
|
||||
function phase5(doc) {
|
||||
return phaseSlice(doc, '## Phase 5 —', '## Phase 6 —');
|
||||
}
|
||||
|
||||
test('trekresearch — Phase 5 loop is gated on effort == high and names both primitives', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /effort == 'high'/, 'Phase 5 loop must be gated on effort == \'high\'');
|
||||
assert.match(p5, /research-loop-cap\.mjs/, 'Phase 5 must call the loop-cap shim per turn');
|
||||
assert.match(p5, /query-privacy-gate\.mjs/, 'Phase 5 must route outbound queries through the privacy gate');
|
||||
assert.match(p5, /\$\{CLAUDE_PLUGIN_ROOT\}/, 'shim invocations must use the ${CLAUDE_PLUGIN_ROOT} path form');
|
||||
});
|
||||
|
||||
// CLAUDE_PLUGIN_DATA and CLAUDE_PLUGIN_ROOT are substituted in this command's
|
||||
// TEXT but are EMPTY in the Bash tool's process env. Every snippet below runs
|
||||
// in that env, so each needs a resolution that does not depend on it.
|
||||
test('trekresearch — the scope-marker snippets resolve a root instead of requiring CLAUDE_PLUGIN_DATA', () => {
|
||||
const p5 = phase5(read());
|
||||
const blocks = [...p5.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||
const write = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('printf'));
|
||||
const remove = blocks.find((b) => b.includes('trekresearch-loop-scope') && b.includes('rm -f'));
|
||||
assert.ok(write, 'Phase 5 must carry the scope-marker write snippet');
|
||||
assert.ok(remove, 'Phase 5 must carry the scope-marker removal snippet');
|
||||
|
||||
for (const [name, block] of [['write', write], ['remove', remove]]) {
|
||||
assert.match(
|
||||
block,
|
||||
/\$\{CLAUDE_PLUGIN_DATA:-\$HOME\/\.claude\/voyage\}/,
|
||||
`the ${name} snippet must fall back to the same root research-loop-cap.mjs resolves`,
|
||||
);
|
||||
assert.match(
|
||||
block,
|
||||
/case .* in\s*\n?\s*\/\*\)/,
|
||||
`the ${name} snippet must guard on ONE absolute-path test — write and remove must not disagree on what counts as usable`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unset, ${CLAUDE_CODE_SESSION_ID} composes a marker named `.json`, which no
|
||||
// hook lookup and no TTL sweep ever matches or cleans up.
|
||||
assert.match(
|
||||
write,
|
||||
/-n "\$\{?CLAUDE_CODE_SESSION_ID/,
|
||||
'the write snippet must require a non-empty CLAUDE_CODE_SESSION_ID before composing the marker path',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — the per-turn gates separate "gate could not run" from "gate says no"', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(
|
||||
p5,
|
||||
/VOYAGE_ROOT/,
|
||||
'the gate snippet must resolve a plugin root rather than interpolating ${CLAUDE_PLUGIN_ROOT} straight into `node`',
|
||||
);
|
||||
assert.match(
|
||||
p5,
|
||||
/exit 2|could not run/i,
|
||||
'an unresolvable gate must be distinguishable from a denial — otherwise every query reads as a privacy violation no rewrite can clear',
|
||||
);
|
||||
assert.match(
|
||||
p5,
|
||||
/plugins\/cache/,
|
||||
'the fallback must name the plugin cache location it searches',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 declares the loop bound and all three exits', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /### Loop bound/, 'Phase 5 must carry a `### Loop bound` sub-heading');
|
||||
assert.match(
|
||||
p5,
|
||||
/\*\*Maximum 3 turns per under-illuminated dimension\.\*\*/,
|
||||
'the bound must be stated verbatim',
|
||||
);
|
||||
// Three exits — converged / cap exhausted / operator stop.
|
||||
assert.match(p5, /converged/i, 'exit 1 (converged) must be documented');
|
||||
assert.match(p5, /exhaust/i, 'exit 2 (cap exhausted) must be documented');
|
||||
assert.match(p5, /operator stop/i, 'exit 3 (operator stop) must be documented');
|
||||
// Exhaustion must reach the operator — a silent cap is indistinguishable
|
||||
// from convergence, which is the failure this loop exists to avoid.
|
||||
assert.match(
|
||||
p5,
|
||||
/visibl|visible|print/i,
|
||||
'cap exhaustion must be written visibly to the operator',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 marks empty turns without re-targeting the same dimension', () => {
|
||||
const p5 = phase5(read());
|
||||
assert.match(p5, /`empty`/, 'a finding-less or citation-less turn must be marked `empty`');
|
||||
assert.match(p5, /empty_turns/, 'empty turns must be counted (empty_turns)');
|
||||
assert.match(
|
||||
p5,
|
||||
/does NOT re-target|not re-target/i,
|
||||
'an empty turn must not re-target the same dimension',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 states the no-brief default and the moot precedence matrix', () => {
|
||||
const p5 = phase5(read());
|
||||
// (a) no-brief default
|
||||
assert.match(p5, /effort = 'standard'/, 'no-brief default effort must be stated');
|
||||
assert.match(
|
||||
p5,
|
||||
/--project/,
|
||||
'the no-brief default must be anchored to the absence of --project/brief.md',
|
||||
);
|
||||
// (b) precedence matrix — each entry independently makes the loop moot,
|
||||
// mirroring the --engine moot gate in Phase 4.
|
||||
for (const token of ['--quick', '--local', 'external_research_enabled']) {
|
||||
assert.ok(p5.includes(token), `moot matrix must name ${token}`);
|
||||
}
|
||||
assert.match(p5, /moot/i, 'the matrix must use the same moot vocabulary as the engine gate');
|
||||
// (c) interaction rule — effort: high without model under a cheap profile.
|
||||
assert.match(
|
||||
p5,
|
||||
/effort: high/,
|
||||
'the interaction rule for a brief carrying effort: high without model must be stated',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 restates the honesty rule for loop output', () => {
|
||||
const p5 = phase5(read());
|
||||
// Whitespace-tolerant: the pin is on the sentence, not on where the
|
||||
// paragraph happens to wrap.
|
||||
assert.match(
|
||||
p5,
|
||||
/more\s+turns\s+do\s+not\s+make\s+a\s+finding\s+more\s+credible/i,
|
||||
'the honesty hard rule must be restated for the loop output',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — Phase 5 pins survive only while the prose does (mutation control)', () => {
|
||||
const text = read();
|
||||
const mutated = text.replace(/research-loop-cap\.mjs/g, 'removed-cap.mjs');
|
||||
assert.doesNotMatch(
|
||||
phase5(mutated),
|
||||
/research-loop-cap\.mjs/,
|
||||
'heading-bounded Phase 5 slice must still fail on a genuine removal',
|
||||
);
|
||||
});
|
||||
|
||||
test('trekresearch — High-effort behavior keeps the standard/low effort sentences verbatim', () => {
|
||||
const text = read();
|
||||
assert.ok(
|
||||
text.includes('Standard effort (or absent): use the existing conditional triggers.'),
|
||||
'the standard-effort sentence must survive the Phase 5 rewrite verbatim',
|
||||
);
|
||||
assert.ok(
|
||||
text.includes('Low effort: inline research only, no agent swarm'),
|
||||
'the low-effort sentence must survive the Phase 5 rewrite verbatim',
|
||||
);
|
||||
});
|
||||
|
||||
// --- Step 8: Phase 4.5 dimension discovery + Independence amendment ---
|
||||
|
||||
const ORCHESTRATOR_FILE = join(ROOT, 'agents', 'research-orchestrator.md');
|
||||
function readOrchestrator() { return readFileSync(ORCHESTRATOR_FILE, 'utf8'); }
|
||||
|
||||
test('trekresearch — Phase 4.5 exists between Phase 4 and Phase 5 with the effort skip-guard', () => {
|
||||
const text = read();
|
||||
const p45 = text.indexOf('## Phase 4.5 —');
|
||||
assert.ok(p45 >= 0, 'Phase 4.5 heading missing');
|
||||
const p4 = text.indexOf('## Phase 4 —');
|
||||
const p5 = text.indexOf('## Phase 5 —');
|
||||
assert.ok(p4 >= 0 && p5 > p45 && p45 > p4, 'Phase 4.5 must sit between Phase 4 and Phase 5');
|
||||
|
||||
const slice = text.slice(p45, p5);
|
||||
assert.match(
|
||||
slice,
|
||||
/\*\*Skip this phase entirely unless `phase_signal_result\.effort == 'high'`/,
|
||||
'Phase 4.5 must carry the bolded skip-guard in the Phase 3.5 form',
|
||||
);
|
||||
assert.match(slice, /query-privacy-gate\.mjs/,
|
||||
'Phase 4.5 must name the privacy gate as its compensating control');
|
||||
assert.match(slice, /maxDimensions: 8|maxDimensions` *: *8/,
|
||||
'Phase 4.5 must augment under the existing maxDimensions ceiling, not raise it');
|
||||
});
|
||||
|
||||
// Phase 4.5 never invokes research-loop-cap.mjs, so the cap's own flag check
|
||||
// does not reach it. Gating on effort alone means unsetting VOYAGE_STORM_ENABLED
|
||||
// leaves half the mechanism live and the decline branch unreachable — while
|
||||
// CLAUDE.md claims both phases go inert. The guard has to name both conditions.
|
||||
test('trekresearch — Phase 4.5 skip-guard is gated on VOYAGE_STORM_ENABLED as well as effort', () => {
|
||||
const text = read();
|
||||
const p45 = text.indexOf('## Phase 4.5 —');
|
||||
const p5 = text.indexOf('## Phase 5 —');
|
||||
const slice = text.slice(p45, p5);
|
||||
|
||||
const guard = slice.slice(0, slice.indexOf('\n\n', slice.indexOf('**Skip this phase')));
|
||||
assert.match(guard, /VOYAGE_STORM_ENABLED/,
|
||||
'the Phase 4.5 skip-guard must name VOYAGE_STORM_ENABLED, not effort alone');
|
||||
assert.match(guard, /\bAND\b|\*\*and\*\*/,
|
||||
'the guard must be a conjunction — both conditions, not either');
|
||||
assert.match(slice, /decline/i,
|
||||
'Phase 4.5 must say why the flag gates it: the decline branch has to stay reachable');
|
||||
});
|
||||
|
||||
test('trekresearch — Independence hard rule carries an explicit Phase 4.5 amendment', () => {
|
||||
const text = read();
|
||||
const rulesIdx = text.indexOf('## Hard rules');
|
||||
assert.ok(rulesIdx >= 0, 'Hard rules section missing');
|
||||
const rules = text.slice(rulesIdx);
|
||||
const indIdx = rules.indexOf('**Independence:**');
|
||||
assert.ok(indIdx >= 0, 'Independence hard rule missing');
|
||||
// Bound the rule at the next bullet so the amendment must live inside it.
|
||||
const nextBullet = rules.indexOf('\n- **', indIdx);
|
||||
const independence = nextBullet > indIdx ? rules.slice(indIdx, nextBullet) : rules.slice(indIdx);
|
||||
assert.match(independence, /Amend(ed|ment)/i,
|
||||
'Independence must be explicitly amended, not silently contradicted');
|
||||
assert.match(independence, /Phase 4\.5/, 'the amendment must name Phase 4.5 as the crossing');
|
||||
assert.match(independence, /query-privacy-gate\.mjs/,
|
||||
'the amendment must name the compensating control');
|
||||
});
|
||||
|
||||
test('trekresearch — orchestrator phase map is correct, has no Phase 9, and carries Phase 4.5', () => {
|
||||
const doc = readOrchestrator();
|
||||
const start = doc.indexOf('<!-- Phase mapping');
|
||||
assert.ok(start >= 0, 'phase mapping comment missing');
|
||||
const end = doc.indexOf('-->', start);
|
||||
assert.ok(end > start, 'phase mapping comment not terminated');
|
||||
const map = doc.slice(start, end);
|
||||
|
||||
assert.doesNotMatch(map, /Command Phase 9/,
|
||||
'the command ends at Phase 8 — a Command Phase 9 row is a fiction');
|
||||
|
||||
// Six orchestrator rows, each pointing at the phase the command actually has.
|
||||
const expected = [
|
||||
[1, '4'],
|
||||
[2, '4'],
|
||||
[3, '5'],
|
||||
[4, '6'],
|
||||
[5, '7'],
|
||||
[6, '8'],
|
||||
];
|
||||
for (const [orch, cmd] of expected) {
|
||||
const re = new RegExp(`Orchestrator Phase ${orch}\\s+= Command Phase ${cmd.replace('.', '\\.')}\\b`);
|
||||
assert.match(map, re, `map row for Orchestrator Phase ${orch} must point at Command Phase ${cmd}`);
|
||||
}
|
||||
|
||||
assert.match(map, /Command Phase 4\.5/, 'the map must carry the new Phase 4.5 row');
|
||||
});
|
||||
|
||||
// --- v5.1.1 runtime SC4 + SC7 ---
|
||||
|
|
|
|||
22
tests/fixtures/expected.prom
vendored
22
tests/fixtures/expected.prom
vendored
|
|
@ -33,19 +33,31 @@ voyage_trekplan_deep_dives{_schema_id="trekplan",slug="add-auth",mode="default",
|
|||
voyage_trekplan_research_briefs_used{_schema_id="trekplan",slug="add-auth",mode="default",profile="premium",profile_source="flag"} 3
|
||||
# HELP voyage_trekresearch_agents_external voyage stats — trekresearch_agents_external
|
||||
# TYPE voyage_trekresearch_agents_external gauge
|
||||
voyage_trekresearch_agents_external{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 3
|
||||
voyage_trekresearch_agents_external{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 3
|
||||
# HELP voyage_trekresearch_agents_local voyage stats — trekresearch_agents_local
|
||||
# TYPE voyage_trekresearch_agents_local gauge
|
||||
voyage_trekresearch_agents_local{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 5
|
||||
voyage_trekresearch_agents_local{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 5
|
||||
# HELP voyage_trekresearch_contradictions voyage stats — trekresearch_contradictions
|
||||
# TYPE voyage_trekresearch_contradictions gauge
|
||||
voyage_trekresearch_contradictions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 1
|
||||
voyage_trekresearch_contradictions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 1
|
||||
# HELP voyage_trekresearch_conv_turns voyage stats — trekresearch_conv_turns
|
||||
# TYPE voyage_trekresearch_conv_turns gauge
|
||||
voyage_trekresearch_conv_turns{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 5
|
||||
# HELP voyage_trekresearch_dimensions voyage stats — trekresearch_dimensions
|
||||
# TYPE voyage_trekresearch_dimensions gauge
|
||||
voyage_trekresearch_dimensions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 4
|
||||
voyage_trekresearch_dimensions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 4
|
||||
# HELP voyage_trekresearch_dimensions_baseline voyage stats — trekresearch_dimensions_baseline
|
||||
# TYPE voyage_trekresearch_dimensions_baseline gauge
|
||||
voyage_trekresearch_dimensions_baseline{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 3
|
||||
# HELP voyage_trekresearch_empty_turns voyage stats — trekresearch_empty_turns
|
||||
# TYPE voyage_trekresearch_empty_turns gauge
|
||||
voyage_trekresearch_empty_turns{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 1
|
||||
# HELP voyage_trekresearch_open_questions voyage stats — trekresearch_open_questions
|
||||
# TYPE voyage_trekresearch_open_questions gauge
|
||||
voyage_trekresearch_open_questions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",profile="premium",profile_source="default"} 2
|
||||
voyage_trekresearch_open_questions{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 2
|
||||
# HELP voyage_trekresearch_unique_sources voyage stats — trekresearch_unique_sources
|
||||
# TYPE voyage_trekresearch_unique_sources gauge
|
||||
voyage_trekresearch_unique_sources{_schema_id="trekresearch",slug="add-auth",mode="default",scope="both",effort="high",profile="premium",profile_source="default"} 17
|
||||
# HELP voyage_trekreview_duration_ms voyage stats — trekreview_duration_ms
|
||||
# TYPE voyage_trekreview_duration_ms histogram
|
||||
voyage_trekreview_duration_ms{_schema_id="trekreview",slug="add-auth",verdict="ALLOW",mode="default",profile="balanced",profile_source="flag"} 4521
|
||||
|
|
|
|||
2
tests/fixtures/jsonl-schemas.md
vendored
2
tests/fixtures/jsonl-schemas.md
vendored
|
|
@ -20,7 +20,7 @@
|
|||
| schema_id | fields | writer_path | line_ref | v4.1 additive | PII |
|
||||
|-----------|--------|-------------|----------|---------------|-----|
|
||||
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, project_dir | commands/trekbrief.md (orchestrator-emit Phase 7) | trekbrief.md:657-672 | profile, phase_models, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, slug, project_dir, brief_path, dimensions, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:388-410 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:634-676 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
|
||||
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | commands/trekplan.md (orchestrator-emit Phase 12) | trekplan.md:805-826 | profile, phase_models, parallel_agents, profile_source | none |
|
||||
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | commands/trekexecute.md (orchestrator-emit Phase 9) | trekexecute.md:1479-1494 | profile, phase_models, profile_source | none |
|
||||
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:64-86 | payload.profile, payload.phase_models, payload.profile_source | none |
|
||||
|
|
|
|||
2
tests/fixtures/stats-sample.jsonl
vendored
2
tests/fixtures/stats-sample.jsonl
vendored
|
|
@ -2,4 +2,4 @@
|
|||
{"_schema_id":"trekexecute","ts":"2026-05-09T08:30:00.000Z","plan":"trekplan-add-auth.md","plan_type":"plan","mode":"execute","result":"completed","steps_total":12,"steps_passed":12,"steps_failed":0,"steps_skipped":0,"profile":"premium","profile_source":"inheritance"}
|
||||
{"_schema_id":"trekreview","ts":"2026-05-09T09:00:00.000Z","slug":"add-auth","verdict":"ALLOW","reviewed_files_count":18,"mode":"default","duration_ms":4521,"profile":"balanced","profile_source":"flag"}
|
||||
{"_schema_id":"trekbrief","ts":"2026-05-09T07:00:00.000Z","slug":"add-auth","mode":"default","interview_turns":7,"review_iterations":2,"research_topics":3,"profile":"economy","profile_source":"env"}
|
||||
{"_schema_id":"trekresearch","ts":"2026-05-09T07:30:00.000Z","slug":"add-auth","mode":"default","scope":"both","dimensions":4,"agents_local":5,"agents_external":3,"contradictions":1,"open_questions":2,"profile":"premium","profile_source":"default"}
|
||||
{"_schema_id":"trekresearch","ts":"2026-05-09T07:30:00.000Z","slug":"add-auth","mode":"default","scope":"both","dimensions":4,"dimensions_baseline":3,"effort":"high","conv_turns":5,"empty_turns":1,"unique_sources":17,"agents_local":5,"agents_external":3,"contradictions":1,"open_questions":2,"profile":"premium","profile_source":"default"}
|
||||
|
|
|
|||
556
tests/hooks/agent-cap.test.mjs
Normal file
556
tests/hooks/agent-cap.test.mjs
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
// tests/hooks/agent-cap.test.mjs
|
||||
// Step 10 — pins hooks/scripts/pre-agent-cap.mjs, the PreToolUse enforcement
|
||||
// of the /trekresearch Phase 5 loop bound.
|
||||
//
|
||||
// The spike (docs/spike-pretooluse-subagent-reach.md, RESULT: FIRES) proved a
|
||||
// plugin PreToolUse hook observes sub-agent tool calls, so the cap can be
|
||||
// enforced rather than merely documented. This file pins the two properties
|
||||
// that matter in opposite directions:
|
||||
//
|
||||
// (a) it DENIES (exit 2) once the ledger shows the budget spent, and
|
||||
// (b) it does NOT over-block — an unrelated session, unparsable stdin, a
|
||||
// stale marker, or the kill switch all exit 0.
|
||||
//
|
||||
// Pattern: tests/hooks/bash-guard.test.mjs (child process via runHook).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const CAP_HOOK = join(ROOT, 'hooks', 'scripts', 'pre-agent-cap.mjs');
|
||||
const HOOKS_JSON = join(ROOT, 'hooks', 'hooks.json');
|
||||
|
||||
const SESSION = 'sess-abc123';
|
||||
const RUN_ID = 'run-xyz789';
|
||||
|
||||
/**
|
||||
* Build a throwaway CLAUDE_PLUGIN_DATA dir holding a scope marker for
|
||||
* `sessionId` and `turns` ledger entries for RUN_ID.
|
||||
*/
|
||||
function fixture({ turns = 0, sessionId = SESSION, startedAt = new Date(), exhausted = false } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-'));
|
||||
mkdirSync(join(dir, 'trekresearch-loop-scope'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'trekresearch-loop-scope', `${sessionId}.json`),
|
||||
JSON.stringify({ runId: RUN_ID, startedAt: startedAt.toISOString() }),
|
||||
);
|
||||
const lines = Array.from({ length: turns }, (_, i) =>
|
||||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high', slot: i + 1 }),
|
||||
);
|
||||
// The tombstone research-loop-cap.mjs appends when it denies a turn for
|
||||
// budget. Its presence is what tells this hook "the gate already said no".
|
||||
if (exhausted) lines.push(JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, exhausted: true }));
|
||||
writeFileSync(join(dir, 'trekresearch-loop-ledger.jsonl'), lines.length ? lines.join('\n') + '\n' : '');
|
||||
return dir;
|
||||
}
|
||||
|
||||
// TREKRESEARCH_MAX_CONV_TURNS=1 => budget = 1 * MAX_TOTAL_DIMENSIONS (8).
|
||||
const CAPPED_ENV = { VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
const BUDGET = 8;
|
||||
|
||||
function searchInput(sessionId = SESSION) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'WebSearch',
|
||||
tool_input: { query: 'claude code hooks reference' },
|
||||
agent_id: 'aa6d19525a4680fe0',
|
||||
agent_type: 'general-purpose',
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DENY — budget spent
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap DENIES once the budget gate has denied a turn (tombstone present)', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 2);
|
||||
assert.match(stderr, /loop cap/i, 'stderr must name the cap it enforced');
|
||||
assert.match(stderr, new RegExp(`${BUDGET}`), 'stderr must state the budget');
|
||||
});
|
||||
|
||||
test('pre-agent-cap DENIES above the budget too (a breached ledger, whatever caused it)', async () => {
|
||||
const dir = fixture({ turns: BUDGET + 5 });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ALLOW — under the cap, and ON the cap.
|
||||
//
|
||||
// allowTurn appends BEFORE the turn runs, so during the FINAL granted turn the
|
||||
// ledger already holds `budget` records. Denying at `used >= budget` therefore
|
||||
// blocked that turn's own tool calls: the primitive granted B turns, the
|
||||
// harness permitted B-1, and an exhausted run always ended through an exit-2
|
||||
// denial rather than the graceful "cap exhausted" exit the prose defines. The
|
||||
// boundary belongs one turn later, and the tombstone above — not the count —
|
||||
// is what marks a run actually finished.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap ALLOWS a loop turn under the budget', async () => {
|
||||
const dir = fixture({ turns: BUDGET - 1 });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS the FINAL granted turn — its own record is already on the ledger', async () => {
|
||||
const dir = fixture({ turns: BUDGET });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(
|
||||
code, 0,
|
||||
'turn B is granted and in flight; denying it makes the harness permit B-1 turns and forces the wrong exit',
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DOES NOT OVER-BLOCK — the property that keeps this hook safe to wire
|
||||
// globally. A broken PreToolUse hook would brick every session on the box.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap ALLOWS an unrelated session even when a loop is exhausted', async () => {
|
||||
const dir = fixture({ turns: BUDGET });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput('some-other-session'), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0, 'no scope marker for this session_id => out of scope');
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS when no scope marker directory exists at all', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'voyage-cap-empty-'));
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS on unparsable stdin', async () => {
|
||||
const dir = fixture({ turns: BUDGET });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, 'not json at all', {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS when the input carries no session_id', async () => {
|
||||
const dir = fixture({ turns: BUDGET });
|
||||
const input = searchInput();
|
||||
delete input.session_id;
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, input, {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// TTL / auto-reset — a marker left behind by a crashed run must not deny
|
||||
// tool calls forever.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap ALLOWS when the scope marker is older than the TTL', async () => {
|
||||
const dir = fixture({ turns: BUDGET, startedAt: new Date(Date.now() - 48 * 3600 * 1000) });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
VOYAGE_CAP_SCOPE_TTL_MS: '1000',
|
||||
});
|
||||
assert.strictEqual(code, 0, 'a stale marker must auto-reset, not deny forever');
|
||||
});
|
||||
|
||||
// The TTL runs from marker.startedAt, not from last activity, and `claude
|
||||
// --resume` keeps the same session_id — so a run that died leaving its marker
|
||||
// behind hands the resumed session whatever deny window is left. Two things
|
||||
// bound that: the window is hours, not the machine's life (below), and it only
|
||||
// opens at all once the budget gate has actually denied a turn.
|
||||
test('pre-agent-cap ALLOWS a resumed session whose crashed run never exhausted its budget', async () => {
|
||||
// Marker still fresh, ledger part-spent, no tombstone: the run died mid-loop.
|
||||
const dir = fixture({ turns: 5 });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(
|
||||
code, 0,
|
||||
'a part-spent run leaves no denial record, so resuming its session must not brick unrelated work',
|
||||
);
|
||||
});
|
||||
|
||||
test('pre-agent-cap uses a default TTL of hours, not a day — a 3h-old marker auto-resets', async () => {
|
||||
const dir = fixture({
|
||||
turns: BUDGET,
|
||||
exhausted: true,
|
||||
startedAt: new Date(Date.now() - 3 * 3600 * 1000),
|
||||
});
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
}); // no VOYAGE_CAP_SCOPE_TTL_MS — this is the built-in default
|
||||
assert.strictEqual(code, 0, 'no real research run lasts 3h; a marker that old is debris');
|
||||
});
|
||||
|
||||
test('pre-agent-cap names the marker path when it denies, so the operator has a remedy', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 2);
|
||||
assert.ok(
|
||||
stderr.includes(join(dir, 'trekresearch-loop-scope', `${SESSION}.json`)),
|
||||
`stderr must name the marker to delete; got:\n${stderr}`,
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fail CLOSED once in scope — the hook's own header says a budget control
|
||||
// that cannot count must not grant. The unreadable-ledger branch returned 0
|
||||
// and therefore ALLOWED, which is the opposite. A directory standing where
|
||||
// the ledger file belongs reproduces it portably (EISDIR).
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap DENIES when the ledger cannot be read at all (fail closed)', async () => {
|
||||
const dir = fixture({ turns: 0 });
|
||||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
rmSync(ledgerPath, { force: true });
|
||||
mkdirSync(ledgerPath, { recursive: true });
|
||||
const { code, stderr } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 2, 'an in-scope run whose ledger cannot be counted must not be granted');
|
||||
assert.match(stderr, /could not be read|unreadable/i, 'stderr must say counting failed, not that the budget is spent');
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS an unreadable ledger when the session is OUT of scope', async () => {
|
||||
const dir = fixture({ turns: 0, sessionId: 'a-different-session' });
|
||||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
rmSync(ledgerPath, { force: true });
|
||||
mkdirSync(ledgerPath, { recursive: true });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0, 'fail-closed is scoped to the loop, it must not brick unrelated sessions');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Kill switch + default-off
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap kill switch VOYAGE_DISABLE_CAP_HOOK=1 allows an exhausted loop', async () => {
|
||||
const dir = fixture({ turns: BUDGET });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
VOYAGE_DISABLE_CAP_HOOK: '1',
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-agent-cap is inert when VOYAGE_STORM_ENABLED is not 1 (default-off)', async () => {
|
||||
const dir = fixture({ turns: BUDGET });
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
VOYAGE_STORM_ENABLED: '0',
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The measured environment — CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool
|
||||
// env, so that is the environment every real run happens in. The writer (the
|
||||
// Phase 5 bash snippet) and the reader (this hook) must land on the SAME
|
||||
// fallback root, or the hook allows unconditionally while claiming to enforce.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap enforces via the fallback root when CLAUDE_PLUGIN_DATA is absent', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
|
||||
const root = join(home, '.claude', 'voyage');
|
||||
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||||
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, 'trekresearch-loop-ledger.jsonl'),
|
||||
[
|
||||
...Array.from({ length: BUDGET }, (_, i) =>
|
||||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, dimension: `d${i}`, effort: 'high', slot: i + 1 })),
|
||||
JSON.stringify({ ts: new Date().toISOString(), runId: RUN_ID, exhausted: true }),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: '',
|
||||
HOME: home,
|
||||
});
|
||||
assert.strictEqual(code, 2, 'the hook must find marker AND ledger under the fallback root and deny');
|
||||
});
|
||||
|
||||
test('pre-agent-cap allows under budget in the fallback root — the fallback is not a blanket deny', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'voyage-cap-home-'));
|
||||
const root = join(home, '.claude', 'voyage');
|
||||
mkdirSync(join(root, 'trekresearch-loop-scope'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||||
JSON.stringify({ runId: RUN_ID, startedAt: new Date().toISOString() }),
|
||||
);
|
||||
writeFileSync(join(root, 'trekresearch-loop-ledger.jsonl'), '');
|
||||
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: '',
|
||||
HOME: home,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Append-only counting — the hook reads the ledger, it never writes it.
|
||||
// Writing per tool call would make the cap count its own enforcement.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap never writes to the ledger', async () => {
|
||||
const dir = fixture({ turns: 2 });
|
||||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
const before = readFileSync(ledgerPath, 'utf-8');
|
||||
await runHookWithEnv(CAP_HOOK, searchInput(), { ...CAPPED_ENV, CLAUDE_PLUGIN_DATA: dir });
|
||||
assert.strictEqual(readFileSync(ledgerPath, 'utf-8'), before);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Crash-time marker states. These are the states the TTL discussion in the
|
||||
// hook header anticipates, and none of them had a test: a marker written
|
||||
// half-way, and a marker whose runId never made it. Both must ALLOW — a
|
||||
// marker we cannot read cannot tell us which run we are in, and guessing
|
||||
// would deny tool calls in a session we know nothing about.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap ALLOWS on a partially written (corrupt) scope marker', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
// Exactly what an interrupted printf leaves behind: valid prefix, no close.
|
||||
writeFileSync(join(dir, 'trekresearch-loop-scope', `${SESSION}.json`), '{"runId":"run-xyz789","star');
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0, 'an unparsable marker is not evidence of a loop turn');
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS a marker that carries no runId', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
writeFileSync(
|
||||
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||||
JSON.stringify({ startedAt: new Date().toISOString() }),
|
||||
);
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0, 'without a runId there are no ledger lines to count against');
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS a marker whose runId is not a string', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
// Truthy, so it clears the `!marker?.runId` guard and the session counts as
|
||||
// in scope — but readLedger compares runId with ===, so a number matches no
|
||||
// record and the run reads as 0 turns spent. Allow is the right answer either
|
||||
// way, which is why this stays a pin on the OUTCOME and not an argument for a
|
||||
// type guard: no writer emits a non-string runId, and the two routes are
|
||||
// indistinguishable from outside.
|
||||
writeFileSync(
|
||||
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||||
JSON.stringify({ runId: 5, startedAt: new Date().toISOString() }),
|
||||
);
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-agent-cap ALLOWS a marker whose runId is the empty string', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
writeFileSync(
|
||||
join(dir, 'trekresearch-loop-scope', `${SESSION}.json`),
|
||||
JSON.stringify({ runId: '', startedAt: new Date().toISOString() }),
|
||||
);
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Malformed ledger lines — a truncated final write must not be counted as a
|
||||
// turn, and must not stop the well-formed lines around it from counting.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-agent-cap does not count a malformed ledger line as a turn', async () => {
|
||||
const dir = fixture({ turns: BUDGET - 1 });
|
||||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
writeFileSync(ledgerPath, readFileSync(ledgerPath, 'utf-8') + '{"runId":"run-xyz789","dimen\n');
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 0, 'a half-written line is not a spent turn');
|
||||
});
|
||||
|
||||
test('pre-agent-cap still finds the tombstone with malformed lines around it', async () => {
|
||||
const dir = fixture({ turns: BUDGET, exhausted: true });
|
||||
const ledgerPath = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
writeFileSync(ledgerPath, '{ garbage\n' + readFileSync(ledgerPath, 'utf-8') + 'also garbage\n');
|
||||
const { code } = await runHookWithEnv(CAP_HOOK, searchInput(), {
|
||||
...CAPPED_ENV,
|
||||
CLAUDE_PLUGIN_DATA: dir,
|
||||
});
|
||||
assert.strictEqual(code, 2, 'skipping bad lines must not mean skipping the run’s 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 loop’s 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 snippet’s 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 loop’s 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}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -129,6 +129,100 @@ test('pre-bash-executor BLOCKS system shutdown command', async () => {
|
|||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a privileged halt at command position', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('sudo shutdown -h now'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a destructive keyword after a separator', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('echo done && poweroff'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
// Bypasses opened when the rule was anchored to command position: whitespace
|
||||
// was collapsed BEFORE the pattern ran (killing the newline branch), `&` was
|
||||
// missing from the separator class, and a keyword handed to a shell wrapper
|
||||
// sits at command position without any separator in front of it.
|
||||
test('pre-bash-executor BLOCKS a destructive keyword after a newline separator', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('echo done\npoweroff'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a destructive keyword after a background separator', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('echo done & poweroff'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a destructive command wrapped in bash -c', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('bash -c "poweroff"'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a destructive command wrapped in sh -c', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput("sh -c 'reboot'"));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a destructive command handed to xargs', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('echo x | xargs reboot'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
test('pre-bash-executor BLOCKS a backslash-escaped destructive command', async () => {
|
||||
// `\reboot` runs reboot — the backslash suppresses alias expansion, nothing
|
||||
// else. The command-position anchor must see through it.
|
||||
const { code } = await runHook(PRE_BASH, bashInput('\\reboot'));
|
||||
assert.strictEqual(code, 2);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ALLOW — the same keywords as DATA, not at command position.
|
||||
// The rule matched the bare word anywhere in the string, so a quoted grep
|
||||
// pattern, ordinary prose, or a commit message that merely named the rule
|
||||
// was blocked. Anchoring to command position is what separates the two.
|
||||
// -----------------------------------------------------------------------
|
||||
test('pre-bash-executor ALLOWS the keyword inside a quoted grep pattern', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput("grep 'halt' f.mjs"));
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// The change's own motivating case: a quoted grep alternation. Anchoring alone
|
||||
// did not reach it — the `|` inside the quotes reads as a separator unless
|
||||
// quoted spans are treated as data.
|
||||
test('pre-bash-executor ALLOWS a quoted grep alternation over the keywords', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('grep "halt|poweroff" f.mjs'));
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// Heredoc bodies are data too, and a newline separator is what makes them look
|
||||
// like command position. The rule's own comment names heredoc data as the
|
||||
// friction anchoring was meant to remove.
|
||||
test('pre-bash-executor ALLOWS the keyword at the start of a heredoc body line', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('cat <<EOF\nreboot is a word here\nEOF'));
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-bash-executor ALLOWS a commit message piped through a heredoc', async () => {
|
||||
const { code } = await runHook(
|
||||
PRE_BASH,
|
||||
bashInput("git commit -F - <<'MSG'\nhalt the loop on empty turns\nMSG"),
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-bash-executor ALLOWS the keyword inside echoed prose', async () => {
|
||||
const { code } = await runHook(PRE_BASH, bashInput('echo "we should halt here"'));
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
test('pre-bash-executor ALLOWS a commit message that names the rule', async () => {
|
||||
const { code } = await runHook(
|
||||
PRE_BASH,
|
||||
bashInput('git commit -m "fix(hooks): anchor shutdown rule to command position"'),
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// BLOCK — cron persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -31,6 +31,25 @@ test('SC #12: stats-sample.jsonl → expected.prom snapshot byte-for-byte match'
|
|||
` node scripts/gen-expected-prom.mjs > tests/fixtures/expected.prom`);
|
||||
});
|
||||
|
||||
test('Step 9: the four numeric STORM fields are metric families and effort is a label', () => {
|
||||
const expected = readFileSync(join(FIXTURES, 'expected.prom'), 'utf-8');
|
||||
for (const field of ['unique_sources', 'dimensions_baseline', 'conv_turns', 'empty_turns']) {
|
||||
assert.match(
|
||||
expected,
|
||||
new RegExp(`^# TYPE voyage_trekresearch_${field} `, 'm'),
|
||||
`${field} must appear as its own metric family — a numeric that never becomes a metric cannot be measured`,
|
||||
);
|
||||
}
|
||||
// effort is a low-cardinality string: it must ride along as a LABEL, never
|
||||
// as a metric family (a label is what makes high-vs-standard groupable).
|
||||
assert.match(expected, /effort="[a-z]+"/, 'effort must be emitted as a label');
|
||||
assert.doesNotMatch(
|
||||
expected,
|
||||
/^# TYPE voyage_trekresearch_effort /m,
|
||||
'effort must not become a metric family',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty-input handling: [] returns empty string (no headers)', () => {
|
||||
assert.equal(transformToPrometheus([]), '');
|
||||
assert.equal(transformToPrometheus(null), '');
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
POST_BASH_STATS_ALLOWED,
|
||||
EVENT_EMIT_PAYLOAD_ALLOWED,
|
||||
TOKEN_USAGE_ALLOWED,
|
||||
TREKRESEARCH_ALLOWED,
|
||||
} from '../../lib/exporters/field-allowlist.mjs';
|
||||
|
||||
// ---- path-validator: CWE-22 mitigation -------------------------------------
|
||||
|
|
@ -278,6 +279,43 @@ test('field-allowlist: token-usage INCLUDES numeric/label fields, EXCLUDES sessi
|
|||
assert.equal('cwd' in out, false, 'cwd MUST be stripped (CWE-212)');
|
||||
});
|
||||
|
||||
// ---- trekresearch allowlist: the `engine` field ----------------------------
|
||||
|
||||
test('field-allowlist: trekresearch INCLUDES engine, EXCLUDES question/project_dir/brief_path (two-sided)', () => {
|
||||
const record = {
|
||||
ts: '2026-08-09T12:00:00.000Z',
|
||||
question: 'which retrieval strategy survives contradiction?',
|
||||
mode: 'default',
|
||||
scope: 'both',
|
||||
engine: 'deep-research',
|
||||
slug: 'storm-upgrade',
|
||||
project_dir: '/Users/ktg/secret/project',
|
||||
brief_path: '/Users/ktg/secret/project/brief.md',
|
||||
dimensions: 4,
|
||||
agents_local: 7,
|
||||
agents_external: 4,
|
||||
gemini_used: false,
|
||||
confidence: 0.82,
|
||||
contradictions: 1,
|
||||
open_questions: 3,
|
||||
};
|
||||
const out = applyFieldAllowlist(record, 'trekresearch');
|
||||
// INCLUDED — low-cardinality label, emitted (trekresearch.md:533) and
|
||||
// promised in prose (:570-572); it was silently dropped before this pin.
|
||||
assert.equal('engine' in out, true, 'engine MUST be allowlisted — it is emitted and documented');
|
||||
assert.equal(out.engine, 'deep-research');
|
||||
assert.equal(out._schema_id, 'trekresearch');
|
||||
// EXCLUDED (CWE-212 boundary)
|
||||
assert.equal('question' in out, false, 'question MUST be stripped (prose, CWE-212)');
|
||||
assert.equal('project_dir' in out, false, 'project_dir MUST be stripped (path, CWE-212)');
|
||||
assert.equal('brief_path' in out, false, 'brief_path MUST be stripped (path, CWE-212)');
|
||||
});
|
||||
|
||||
test('field-allowlist: TREKRESEARCH_ALLOWED is frozen (drift-pin)', () => {
|
||||
assert.equal(Object.isFrozen(TREKRESEARCH_ALLOWED), true,
|
||||
'TREKRESEARCH_ALLOWED must be frozen — runtime mutation prevention');
|
||||
});
|
||||
|
||||
test('field-allowlist: null/undefined record handled safely', () => {
|
||||
assert.deepEqual(applyFieldAllowlist(null, 'trekplan'), {});
|
||||
assert.deepEqual(applyFieldAllowlist(undefined, 'trekplan'), {});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
severityRank,
|
||||
ingest,
|
||||
|
|
@ -13,9 +16,14 @@ import {
|
|||
judgeFilter,
|
||||
reasonablenessFilter,
|
||||
computeVerdict,
|
||||
classifySuppression,
|
||||
REFUTING_REASONS,
|
||||
UNVERIFIED_REASONS,
|
||||
runContract,
|
||||
} from '../../lib/review/coordinator-contract.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
// ---- Pass 1 — dedup --------------------------------------------------------
|
||||
|
||||
test('dedupByTriplet — genuine cross-reviewer collapse (identical triplet) → 1, raised_by both', () => {
|
||||
|
|
@ -98,29 +106,41 @@ test('computeVerdict — counts each severity tier', () => {
|
|||
|
||||
// ---- Pass 3 — reasonableness -----------------------------------------------
|
||||
|
||||
test('reasonablenessFilter — drops unknown rule_key + citation-less, corrects severity mismatch', () => {
|
||||
test('reasonablenessFilter — citation-less is REFUTED, unknown rule_key is UNVERIFIED, severity mismatch corrected', () => {
|
||||
// Contract change (fail-closed): only `no-citation` refutes. An ad-hoc
|
||||
// rule_key is a real defect wearing the wrong label — v5.1.1 high-effort mode
|
||||
// already keeps those, normalised to PLAN_EXECUTE_DRIFT.
|
||||
const r = reasonablenessFilter([
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → drop
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'BLOCKER' }, // unknown → unverified
|
||||
{ file: '', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR' }, // no file → drop
|
||||
{ file: 'x.mjs', line: -1, rule_key: 'MISSING_TEST', severity: 'MAJOR' }, // line < 0 → drop
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MINOR' }, // catalogue is MAJOR → correct, keep
|
||||
]);
|
||||
assert.equal(r.kept.length, 1);
|
||||
assert.equal(r.dropped.length, 3);
|
||||
assert.equal(r.dropped.length, 2);
|
||||
assert.deepEqual(r.dropped.map((f) => f.suppressed_reason), ['no-citation', 'no-citation']);
|
||||
assert.equal(r.unverified.length, 1);
|
||||
assert.equal(r.unverified[0].suppressed_reason, 'unknown-rule_key');
|
||||
assert.equal(r.kept[0].severity, 'MAJOR');
|
||||
assert.equal(r.kept[0].original_severity, 'MINOR');
|
||||
});
|
||||
|
||||
// ---- Pass 2 — judge --------------------------------------------------------
|
||||
|
||||
test('judgeFilter — drops over-long title and empty recommended_action', () => {
|
||||
test('judgeFilter — over-long title and empty recommended_action are UNVERIFIED, not dropped', () => {
|
||||
// Contract change (fail-closed): both implemented Pass 2 tests read a
|
||||
// `.length` and never examine the claim, so neither refutes the finding.
|
||||
// `dropped` is empty here on purpose — the refuting Pass 2 filter (Accuracy)
|
||||
// is the one this deterministic subset excludes.
|
||||
const j = judgeFilter([
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'x'.repeat(101) }, // too long → drop
|
||||
{ file: 'x.mjs', line: 2, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok', recommended_action: ' ' }, // empty action → drop
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'x'.repeat(101) }, // too long → unverified
|
||||
{ file: 'x.mjs', line: 2, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok', recommended_action: ' ' }, // empty action → unverified
|
||||
{ file: 'x.mjs', line: 3, rule_key: 'MISSING_TEST', severity: 'MAJOR', title: 'ok' }, // keep (no action field is fine)
|
||||
]);
|
||||
assert.equal(j.kept.length, 1);
|
||||
assert.equal(j.dropped.length, 2);
|
||||
assert.equal(j.dropped.length, 0);
|
||||
assert.equal(j.unverified.length, 2);
|
||||
assert.deepEqual(j.unverified.map((f) => f.suppressed_reason), ['succinctness:title', 'actionability:empty']);
|
||||
});
|
||||
|
||||
// ---- ingest ----------------------------------------------------------------
|
||||
|
|
@ -161,3 +181,155 @@ test('runContract — deterministic: identical input yields identical output', (
|
|||
];
|
||||
assert.deepEqual(runContract(input), runContract(input));
|
||||
});
|
||||
|
||||
// ---- Fail-closed: the `unverified` bucket (ORDRE 834432937) -----------------
|
||||
//
|
||||
// The defect: a finding REMOVED by Pass 2/Pass 3, and a reviewer whose payload
|
||||
// was thrown away or never arrived, are all arithmetically identical to a
|
||||
// finding that never existed -- they push the verdict toward ALLOW. Measured
|
||||
// before the fix (probe, 2026-09-01): an over-long-title BLOCKER -> ALLOW; a
|
||||
// payload with one ad-hoc rule_key -> the whole payload skipped, its valid
|
||||
// BLOCKER sibling gone -> ALLOW.
|
||||
//
|
||||
// The rule under test: a removal is `dropped` ONLY when the test refutes the
|
||||
// finding as a claim about this codebase. Every other removal is `unverified`,
|
||||
// and a non-empty `unverified` -- or a reviewer that did not report -- forbids
|
||||
// ALLOW.
|
||||
|
||||
test('classifySuppression — only no-citation refutes; form and taxonomy failures are unverified', () => {
|
||||
assert.equal(classifySuppression('no-citation'), 'refuted',
|
||||
'a finding that names no location makes no checkable claim');
|
||||
assert.equal(classifySuppression('succinctness:title'), 'unverified');
|
||||
assert.equal(classifySuppression('succinctness:detail'), 'unverified');
|
||||
assert.equal(classifySuppression('actionability:empty'), 'unverified');
|
||||
assert.equal(classifySuppression('unknown-rule_key'), 'unverified');
|
||||
assert.equal(classifySuppression('file-existence:indeterminate'), 'unverified');
|
||||
assert.equal(classifySuppression('something-nobody-declared'), 'unverified',
|
||||
'an unclassified reason must fail CLOSED, not open');
|
||||
});
|
||||
|
||||
test('computeVerdict — non-empty unverified forbids ALLOW but never downgrades BLOCK or WARN', () => {
|
||||
const u = [{ file: 'x.mjs', line: 1, rule_key: 'MISSING_TEST', severity: 'BLOCKER' }];
|
||||
const withUnverified = computeVerdict([], { unverified: u });
|
||||
assert.equal(withUnverified.verdict, 'WARN', 'ALLOW is forbidden while anything is unverified');
|
||||
assert.deepEqual(withUnverified.counts, { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 },
|
||||
'the unverified finding is NOT counted into a severity tier');
|
||||
assert.ok(withUnverified.allow_blocked_by.length > 0);
|
||||
|
||||
assert.equal(computeVerdict([{ severity: 'BLOCKER' }], { unverified: u }).verdict, 'BLOCK',
|
||||
'BLOCK stands regardless of the unverified bucket');
|
||||
assert.equal(computeVerdict([{ severity: 'MAJOR' }], { unverified: u }).verdict, 'WARN');
|
||||
assert.equal(computeVerdict([], { unverified: [] }).verdict, 'ALLOW',
|
||||
'known-positive control: an empty unverified bucket still allows ALLOW');
|
||||
});
|
||||
|
||||
test('computeVerdict — a reviewer that did not report forbids ALLOW', () => {
|
||||
const r = computeVerdict([], { missingReviewers: ['brief-conformance-reviewer'] });
|
||||
assert.equal(r.verdict, 'WARN');
|
||||
assert.ok(r.allow_blocked_by.some((x) => x.includes('brief-conformance-reviewer')));
|
||||
});
|
||||
|
||||
test('runContract — a BLOCKER dropped for an over-long title cannot yield ALLOW', () => {
|
||||
// Pass 2 succinctness reads `.length`. It never examines the claim, so it
|
||||
// cannot establish the finding is unreal -- it is unverified, not refuted.
|
||||
const result = runContract([
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [
|
||||
{ file: 'lib/auth/jwt.mjs', line: 19, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER', title: 'x'.repeat(101), detail: 'algo taken from the JWT header' },
|
||||
] },
|
||||
]);
|
||||
assert.notEqual(result.verdict, 'ALLOW', 'an unsubstantiated BLOCKER must never clear the review');
|
||||
assert.equal(result.findings.length, 0, 'it is still not a kept finding');
|
||||
assert.equal(result.unverified.length, 1);
|
||||
assert.equal(result.unverified[0].suppressed_reason, 'succinctness:title');
|
||||
assert.equal(result.suppressed.length, 1, 'suppressed stays the union of dropped + unverified');
|
||||
});
|
||||
|
||||
test('runContract — a schema-invalid payload cannot yield ALLOW (an unread reviewer is an absent one)', () => {
|
||||
// Measured: one ad-hoc rule_key invalidates the WHOLE payload at ingest, so a
|
||||
// valid BLOCKER sibling disappears with it. That must not read as "clean".
|
||||
const result = runContract([
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [
|
||||
{ file: 'lib/auth/jwt.mjs', line: 19, rule_key: 'SECURITY_INJECTION', severity: 'BLOCKER', title: 'real' },
|
||||
{ file: 'x.mjs', line: 1, rule_key: 'NOPE_KEY', severity: 'MINOR', title: 'ad-hoc key' },
|
||||
] },
|
||||
]);
|
||||
assert.equal(result.skipped.length, 1);
|
||||
assert.notEqual(result.verdict, 'ALLOW');
|
||||
assert.ok(result.allow_blocked_by.some((x) => x.includes('code-correctness-reviewer')));
|
||||
});
|
||||
|
||||
test('runContract — a reviewer named in expectedReviewers that never reported cannot yield ALLOW', () => {
|
||||
const result = runContract(
|
||||
[{ reviewer: 'code-correctness-reviewer', findings: [] }],
|
||||
{ expectedReviewers: ['code-correctness-reviewer', 'brief-conformance-reviewer'] },
|
||||
);
|
||||
assert.deepEqual(result.missing_reviewers, ['brief-conformance-reviewer']);
|
||||
assert.notEqual(result.verdict, 'ALLOW');
|
||||
});
|
||||
|
||||
test('runContract — known-positive control: every reviewer reported, nothing suppressed → ALLOW', () => {
|
||||
// Proves ALLOW is still REACHABLE. Without this, "no ALLOW" is not a
|
||||
// fail-closed contract, only a broken one.
|
||||
const result = runContract(
|
||||
[
|
||||
{ reviewer: 'code-correctness-reviewer', findings: [
|
||||
{ file: 'a.mjs', line: 1, rule_key: 'MISSING_ERROR_HANDLING', severity: 'MINOR', title: 'unguarded await', recommended_action: 'Wrap the await in a try/catch.' },
|
||||
] },
|
||||
{ reviewer: 'brief-conformance-reviewer', findings: [] },
|
||||
],
|
||||
{ expectedReviewers: ['code-correctness-reviewer', 'brief-conformance-reviewer'] },
|
||||
);
|
||||
assert.equal(result.verdict, 'ALLOW');
|
||||
assert.equal(result.unverified.length, 0);
|
||||
assert.deepEqual(result.missing_reviewers, []);
|
||||
assert.deepEqual(result.allow_blocked_by, []);
|
||||
});
|
||||
|
||||
test('classifySuppression — the refuting reasons the LLM coordinator emits are declared here too', () => {
|
||||
// agents/review-coordinator.md Pass 2 "Accuracy" and Pass 3 "Non-existent
|
||||
// file" DO refute (a citation outside the repo root, a file absent from both
|
||||
// tree and diff). Both are fs/judgement branches this deterministic subset
|
||||
// excludes, but the vocabulary is owned here so prose and lib cannot drift.
|
||||
assert.equal(classifySuppression('accuracy:refuted'), 'refuted');
|
||||
assert.equal(classifySuppression('file-existence:refuted'), 'refuted');
|
||||
assert.equal(classifySuppression('file-existence:indeterminate'), 'unverified',
|
||||
'unresolvable must never collapse into refuted');
|
||||
});
|
||||
|
||||
test('suppression vocabulary — the two sets are disjoint and every reason is documented in the prose', () => {
|
||||
const refuting = [...REFUTING_REASONS];
|
||||
const overlap = refuting.filter((r) => UNVERIFIED_REASONS.includes(r));
|
||||
assert.deepEqual(overlap, [], 'a reason cannot be both refuting and unverified');
|
||||
|
||||
const prose = readFileSync(join(ROOT, 'agents/review-coordinator.md'), 'utf-8');
|
||||
assert.ok(prose.includes('review-coordinator'), 'known-positive control: the prose file loaded');
|
||||
for (const reason of [...refuting, ...UNVERIFIED_REASONS]) {
|
||||
assert.ok(prose.includes(reason),
|
||||
`reason "${reason}" is declared in the lib but never documented in agents/review-coordinator.md`);
|
||||
}
|
||||
});
|
||||
|
||||
test('runContract — an anonymous invalid payload is unattributable, not a reviewer named "unnamed reviewer"', () => {
|
||||
// `validateFindings` only WARNS on a missing `reviewer`, so a payload can
|
||||
// fail schema while carrying no name. Reporting it as a reviewer name
|
||||
// invents an agent nobody launched, and double-counts with expectedReviewers
|
||||
// when they are in fact the same failure.
|
||||
const result = runContract(
|
||||
[{ findings: [{ file: 'x.mjs', line: 1, rule_key: 'NOPE', severity: 'MAJOR' }] }],
|
||||
{ expectedReviewers: ['code-correctness-reviewer'] },
|
||||
);
|
||||
assert.deepEqual(result.missing_reviewers, ['code-correctness-reviewer'],
|
||||
'missing_reviewers carries real names only');
|
||||
assert.equal(result.unattributable_payloads, 1);
|
||||
assert.ok(result.allow_blocked_by.some((x) => x.startsWith('unattributable-payload')));
|
||||
assert.ok(!result.allow_blocked_by.some((x) => x.includes('unnamed reviewer')));
|
||||
});
|
||||
|
||||
test('runContract — an anonymous invalid payload forbids ALLOW on its own, with no expectedReviewers', () => {
|
||||
// The fail-closed floor must not depend on the caller passing an expected
|
||||
// set: without this, dropping the name from a payload would restore ALLOW.
|
||||
const result = runContract([{ findings: [{ file: 'x.mjs', line: 1, rule_key: 'NOPE', severity: 'MAJOR' }] }]);
|
||||
assert.deepEqual(result.missing_reviewers, []);
|
||||
assert.equal(result.unattributable_payloads, 1);
|
||||
assert.notEqual(result.verdict, 'ALLOW');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { fileURLToPath } from 'node:url';
|
|||
import { parseDocument } from '../../lib/util/frontmatter.mjs';
|
||||
import { resolveProfile, loadProfile } from '../../lib/profiles/resolver.mjs';
|
||||
import { STATES } from '../../lib/util/autonomy-gate.mjs';
|
||||
import { TREKRESEARCH_ALLOWED } from '../../lib/exporters/field-allowlist.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
|
|
@ -700,6 +701,23 @@ test('producing commands emit file:// link in final report (operator-UX contract
|
|||
}
|
||||
});
|
||||
|
||||
test('package.json is marked private — the npm name `voyage` belongs to a third party', () => {
|
||||
// D-census 2026-08-20/21: registry.npmjs.org/voyage answers 200, but that is
|
||||
// NOT this package — it is "Advanced HTTP Routing System for Node.js"
|
||||
// (NEURS/voyage, v0.0.1, 2022, maintainer kevin.martin). This manifest is test
|
||||
// tooling for a Claude Code plugin distributed through the marketplace catalogue
|
||||
// (`ref: vX.Y.Z`), never through npm. Without `private`, nothing in the manifest
|
||||
// stops an accidental `npm publish` against a name someone else owns — a one-way
|
||||
// action. Sibling non-package manifests (okr, repo-mailbox, repo-standard) all
|
||||
// carry the flag; voyage was the exception. Operator decision (S93): option A.
|
||||
const pkg = JSON.parse(read('package.json'));
|
||||
assert.equal(
|
||||
pkg.private,
|
||||
true,
|
||||
'package.json must declare "private": true — the npm name `voyage` is owned by a third party',
|
||||
);
|
||||
});
|
||||
|
||||
test('package.json still has no "npm run render" script (removed in v5.0.1)', () => {
|
||||
const pkg = JSON.parse(read('package.json'));
|
||||
assert.equal(
|
||||
|
|
@ -961,6 +979,31 @@ test('S15: profile tables encode each built-in yaml phase_models exactly', () =>
|
|||
}
|
||||
});
|
||||
|
||||
// STRUCTURAL pin: the exporter allowlist and the authoring fixture must agree.
|
||||
// `engine` was emitted, documented in prose, and still dropped at the export
|
||||
// boundary because nothing tied the two together. Derives one side from the
|
||||
// frozen Set, so it survives rewording of the fixture row.
|
||||
test('S74: every TREKRESEARCH_ALLOWED name is declared in the jsonl-schemas fixture row', () => {
|
||||
const row = read('tests/fixtures/jsonl-schemas.md')
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('| trekresearch-stats '));
|
||||
assert.ok(row, 'jsonl-schemas.md is missing the `trekresearch-stats` row');
|
||||
// Columns: '' | schema_id | fields | writer_path | line_ref | v4.1 additive | PII | ''
|
||||
const cells = row.split('|').map((c) => c.trim());
|
||||
// Both columns are required: profile/profile_source/parallel_agents live in
|
||||
// the `v4.1 additive` column only, so checking `fields` alone fails at once.
|
||||
const declared = new Set(
|
||||
[cells[2], cells[5]].flatMap((c) => c.split(',').map((f) => f.trim())).filter(Boolean),
|
||||
);
|
||||
for (const name of TREKRESEARCH_ALLOWED) {
|
||||
assert.ok(
|
||||
declared.has(name),
|
||||
`\`${name}\` is allowlisted in field-allowlist.mjs but absent from the fixture row's `
|
||||
+ '`fields` + `v4.1 additive` columns — fix the SOURCE, not this pin',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// --- S34 (V30) — economy is self-declared experimental until the cross-tier
|
||||
// Jaccard floor (0.55) is empirically calibrated (Step-17 calibration deferred
|
||||
// to v4.2). The status must be visible in BOTH the profile data
|
||||
|
|
@ -1091,6 +1134,289 @@ test('deep-research-engine: --engine is documented + consistent across surfaces'
|
|||
);
|
||||
});
|
||||
|
||||
test('deep-research-engine: the CC 2.1.218 operator-only ceiling is documented on every surface', () => {
|
||||
// CC 2.1.218 changed /deep-research to "start only when invoked manually"; from
|
||||
// there the Skill tool refuses a model invocation outright with
|
||||
// `disable-model-invocation`. The flag stays (additive opt-in, always degrades to
|
||||
// swarm), but no surface may keep promising a path Claude Code has removed, and
|
||||
// none may leave `2.1.154+` standing alone as the engine's requirement.
|
||||
const SURFACES = ['commands/trekresearch.md', 'docs/command-modes.md', 'README.md'];
|
||||
for (const f of SURFACES) {
|
||||
const body = read(f);
|
||||
assert.ok(
|
||||
body.includes('2.1.218'),
|
||||
`${f} must document the CC 2.1.218 ceiling for the deep-research engine`,
|
||||
);
|
||||
assert.ok(
|
||||
body.includes('disable-model-invocation'),
|
||||
`${f} must name disable-model-invocation as the fallback reason from CC 2.1.218`,
|
||||
);
|
||||
}
|
||||
|
||||
// The one-line reference rows carry the whole truth on their own line — an
|
||||
// operator reading the flag table never scrolls to the command prose.
|
||||
const ROWS = [
|
||||
['docs/command-modes.md', /^.*`--engine \{swarm\\\|deep-research\}`.*$/m],
|
||||
['README.md', /^\|\s*\*\*Engine\*\*.*$/m],
|
||||
];
|
||||
for (const [f, re] of ROWS) {
|
||||
const m = read(f).match(re);
|
||||
assert.ok(m, `${f} must still carry the --engine reference row`);
|
||||
assert.ok(
|
||||
m[0].includes('2.1.218') && m[0].includes('disable-model-invocation'),
|
||||
`${f} --engine row must state the real window (2.1.218 + disable-model-invocation)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Both load-bearing regions of the command itself: the flag bullet and the
|
||||
// pre-gate that decides whether the delegation is attempted at all.
|
||||
const research = read('commands/trekresearch.md');
|
||||
const bullet = research.match(/8\. `--engine <name>`[\s\S]*?Flags can be combined/);
|
||||
assert.ok(bullet, 'trekresearch.md must still carry the --engine flag bullet');
|
||||
assert.ok(
|
||||
bullet[0].includes('2.1.218') && bullet[0].includes('disable-model-invocation'),
|
||||
'trekresearch.md --engine bullet must state the real window, not just the 2.1.154 floor',
|
||||
);
|
||||
const pregate = research.match(/\*\*Coarse pre-gate[\s\S]*?authoritative guard\./);
|
||||
assert.ok(pregate, 'trekresearch.md must still carry the deep-research pre-gate');
|
||||
assert.ok(
|
||||
pregate[0].includes('2.1.218'),
|
||||
'the pre-gate must carry an upper ceiling at 2.1.218, not only the 2.1.154 floor',
|
||||
);
|
||||
});
|
||||
|
||||
// ── STORM bounded loop — env-vars documented across the four surfaces ──────
|
||||
// Same cross-doc shape as the --engine pin above. An operator-facing switch
|
||||
// documented on one surface is a switch most operators never find; and the
|
||||
// three below decide cost, so they are the ones worth pinning.
|
||||
|
||||
const STORM_SURFACES = ['docs/command-modes.md', 'CLAUDE.md', 'README.md', 'docs/architecture.md'];
|
||||
const STORM_ENV_VARS = ['VOYAGE_STORM_ENABLED', 'TREKRESEARCH_MAX_CONV_TURNS', 'VOYAGE_DISABLE_CAP_HOOK'];
|
||||
|
||||
for (const envVar of STORM_ENV_VARS) {
|
||||
test(`STORM: ${envVar} is documented on all four reference surfaces`, () => {
|
||||
for (const f of STORM_SURFACES) {
|
||||
assert.ok(read(f).includes(envVar), `${f} must document ${envVar} (STORM bounded-loop env-vars)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('STORM: VOYAGE_STORM_ENABLED is documented WITH its default-off contract', () => {
|
||||
// Naming a switch without its default is how a default-on mechanism ships by
|
||||
// accident. Default-off is also the decline branch: declining costs nothing.
|
||||
for (const f of STORM_SURFACES) {
|
||||
const t = read(f);
|
||||
const i = t.indexOf('VOYAGE_STORM_ENABLED');
|
||||
const window = t.slice(Math.max(0, i - 400), i + 400);
|
||||
assert.match(
|
||||
window,
|
||||
/default-off|default off|opt-in/i,
|
||||
`${f}: VOYAGE_STORM_ENABLED must be documented together with its default-off contract`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The flag gates BOTH STORM phases, not just the Phase 5 loop. A surface that
|
||||
// scopes it to "the loop" tells an operator that unsetting it still leaves
|
||||
// Phase 4.5 discovery running — which was true until the guard was fixed, and
|
||||
// is the half-off state the decline branch cannot survive.
|
||||
test('STORM: VOYAGE_STORM_ENABLED is documented as gating BOTH phases, not the loop alone', () => {
|
||||
for (const f of STORM_SURFACES) {
|
||||
const t = read(f);
|
||||
const i = t.indexOf('VOYAGE_STORM_ENABLED');
|
||||
const window = t.slice(Math.max(0, i - 400), i + 400);
|
||||
assert.match(
|
||||
window,
|
||||
/4\.5|discovery|dimension discovery|both/i,
|
||||
`${f}: VOYAGE_STORM_ENABLED must be documented as gating Phase 4.5 too, not only the Phase 5 loop`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The variable is empty in the Bash tool env, so "missing CLAUDE_PLUGIN_DATA
|
||||
// denies" described a loop that could never spend turn 1. The root is resolved
|
||||
// in code now; a doc that still promises the deny describes a mechanism the
|
||||
// code does not have.
|
||||
test('STORM: no surface claims a missing CLAUDE_PLUGIN_DATA denies — the root falls back', () => {
|
||||
for (const f of ['docs/architecture.md', 'CLAUDE.md', 'README.md', 'docs/command-modes.md']) {
|
||||
const t = read(f);
|
||||
assert.doesNotMatch(
|
||||
t,
|
||||
/(missing|no|absent) `?CLAUDE_PLUGIN_DATA`?[^.\n]*(denies|fails closed)/i,
|
||||
`${f}: CLAUDE_PLUGIN_DATA absence no longer denies — it resolves to ~/.claude/voyage`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
read('docs/architecture.md'),
|
||||
/~\/\.claude\/voyage/,
|
||||
'docs/architecture.md must name the fallback data root the cap and the hook share',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM: README research-dimension prose stays at the existing 3–8 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('3–8 research dimensions'),
|
||||
'README.md must keep the "3–8 research dimensions" prose — augmentation happens under the existing cap, it does not raise it',
|
||||
);
|
||||
});
|
||||
|
||||
// The Phase 4.5 amendment to the Independence hard rule crosses that rule
|
||||
// deliberately: discovered dimensions are mined from Phase-4 output, which holds
|
||||
// local-agent findings, so a discovered dimension can steer an external query.
|
||||
// The crossing is defensible — bounded, disclosed, and resolving a tension the
|
||||
// brief created itself. What was not defensible was naming query-privacy-gate.mjs
|
||||
// as its compensating control: that gate inspects outbound query CONTENT for
|
||||
// paths, repo identifiers and secret-shaped strings. It compensates the EGRESS
|
||||
// risk the crossing creates. It cannot stop a local finding from shaping an
|
||||
// external agent's question, so the bias risk was left with no named control
|
||||
// while the text read as though it had one.
|
||||
test('STORM: the Independence amendment does not name the privacy gate as the BIAS control', () => {
|
||||
const t = read('commands/trekresearch.md');
|
||||
const amendment = t.slice(t.indexOf('**Independence:**'), t.indexOf('**Graceful degradation:**'));
|
||||
assert.ok(amendment.length > 100, 'the Independence hard rule and its amendment must still be present');
|
||||
assert.ok(
|
||||
!/compensating\s+control\s+is\s+`query-privacy-gate/.test(amendment),
|
||||
'query-privacy-gate.mjs compensates egress, not bias — naming it as THE compensating control for the ' +
|
||||
'Independence crossing claims a control the gate cannot provide',
|
||||
);
|
||||
// The bias risk must carry a control that actually bears on bias.
|
||||
assert.match(
|
||||
amendment,
|
||||
/contrarian-researcher/,
|
||||
'the amendment must name the control that does bear on bias — contrarian-researcher runs unconditionally ' +
|
||||
'at effort: high, the only effort at which the crossing happens',
|
||||
);
|
||||
assert.match(
|
||||
amendment,
|
||||
/egress/i,
|
||||
'the privacy gate should still be named, as the control for the egress risk the same crossing creates',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM: no banned Sonnet-swarm phrase introduced on any STORM surface', () => {
|
||||
const BANNED = [
|
||||
'Sonnet exploration',
|
||||
'Sonnet runs the exploration',
|
||||
'front-loads cheap Sonnet',
|
||||
'exploration agents stay on Sonnet',
|
||||
];
|
||||
for (const f of STORM_SURFACES) {
|
||||
const t = read(f);
|
||||
for (const phrase of BANNED) {
|
||||
assert.ok(!t.includes(phrase), `${f} must not claim "${phrase}" — sub-agents are opus-pinned`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── STORM bounded loop — Phase 5 scope-marker wiring (S79) ────────────────
|
||||
// hooks/scripts/pre-agent-cap.mjs enforces the loop bound ONLY while a scope
|
||||
// marker exists for the calling session. Nothing wrote that marker, so the
|
||||
// hook shipped correct-but-latent. These pins bind the two ends of one
|
||||
// contract: the reader (the hook) and the writer (Phase 5 prose). Both sides
|
||||
// are derived from the hook source where possible, so drift in EITHER
|
||||
// direction fails — renaming the directory in the hook breaks the prose pin
|
||||
// just as rewriting the prose does.
|
||||
|
||||
const CAP_HOOK_SRC = read('hooks/scripts/pre-agent-cap.mjs');
|
||||
const RESEARCH_CMD = read('commands/trekresearch.md');
|
||||
|
||||
function phase5Section(text) {
|
||||
const start = text.indexOf('## Phase 5');
|
||||
const end = text.indexOf('## Phase 6', start);
|
||||
assert.ok(start > 0 && end > start, 'commands/trekresearch.md must keep ## Phase 5 … ## Phase 6');
|
||||
return text.slice(start, end);
|
||||
}
|
||||
|
||||
test('STORM marker: the directory the hook reads is the directory Phase 5 writes', () => {
|
||||
const m = CAP_HOOK_SRC.match(/const SCOPE_DIRNAME = '([^']+)'/);
|
||||
assert.ok(m, 'pre-agent-cap.mjs must keep SCOPE_DIRNAME as a single-quoted literal');
|
||||
const scopeDir = m[1];
|
||||
assert.ok(
|
||||
phase5Section(RESEARCH_CMD).includes(scopeDir),
|
||||
`commands/trekresearch.md Phase 5 must write the scope marker under ${scopeDir}/ — a hook keyed on a directory nobody writes is latent, not enforcing`,
|
||||
);
|
||||
assert.ok(
|
||||
phase5Section(RESEARCH_CMD).includes('CLAUDE_PLUGIN_DATA'),
|
||||
'Phase 5 must root the marker at CLAUDE_PLUGIN_DATA — the same root the hook resolves',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM marker: both payload fields the hook reads are named in Phase 5', () => {
|
||||
// The hook rejects a marker without runId, and treats an unparsable
|
||||
// startedAt as stale. A writer that emits neither name produces a marker
|
||||
// that is silently ignored.
|
||||
const phase5 = phase5Section(RESEARCH_CMD);
|
||||
for (const field of ['runId', 'startedAt']) {
|
||||
assert.ok(
|
||||
CAP_HOOK_SRC.includes(`marker.${field}`) || CAP_HOOK_SRC.includes(`marker?.${field}`),
|
||||
`pre-agent-cap.mjs must still read marker.${field}`,
|
||||
);
|
||||
assert.ok(
|
||||
phase5.includes(field),
|
||||
`commands/trekresearch.md Phase 5 must write the ${field} field the hook reads`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('STORM marker: Phase 5 keys the marker filename by the harness session id', () => {
|
||||
// Verified 2026-08-12: the session_id on a PreToolUse payload equals
|
||||
// $CLAUDE_CODE_SESSION_ID for the same session. The marker filename is the
|
||||
// scope key — key it by anything else and the hook never matches.
|
||||
assert.ok(
|
||||
phase5Section(RESEARCH_CMD).includes('CLAUDE_CODE_SESSION_ID'),
|
||||
'Phase 5 must name CLAUDE_CODE_SESSION_ID as the marker filename key (== the hook payload session_id)',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM marker: only Phase 5 writes it — Phase 4.5 does not', () => {
|
||||
// The hook contract says "a marker file only the Phase 5 loop writes".
|
||||
// Phase 4.5 mines already-retrieved Phase-4 results and spends no loop
|
||||
// turns; scoping enforcement there widens the window for nothing.
|
||||
const m = CAP_HOOK_SRC.match(/const SCOPE_DIRNAME = '([^']+)'/);
|
||||
const scopeDir = m[1];
|
||||
const start = RESEARCH_CMD.indexOf('## Phase 4.5');
|
||||
const end = RESEARCH_CMD.indexOf('## Phase 5', start);
|
||||
assert.ok(start > 0 && end > start, 'commands/trekresearch.md must keep ## Phase 4.5 … ## Phase 5');
|
||||
assert.ok(
|
||||
!RESEARCH_CMD.slice(start, end).includes(scopeDir),
|
||||
'Phase 4.5 must not write the scope marker — only the Phase 5 loop does',
|
||||
);
|
||||
});
|
||||
|
||||
test('STORM marker: every one of the three loop exits removes the marker', () => {
|
||||
// A marker left behind after the cap is spent denies WebSearch/WebFetch/Task
|
||||
// for the REST of the session — Phase 6 synthesis spawns agents. Cleanup on
|
||||
// the exhausted exit is what keeps enforcement from becoming a session brick.
|
||||
const phase5 = phase5Section(RESEARCH_CMD);
|
||||
const exitsStart = phase5.indexOf('### Exits');
|
||||
assert.ok(exitsStart > 0, 'Phase 5 must keep the ### Exits section');
|
||||
const exits = phase5.slice(exitsStart, phase5.indexOf('###', exitsStart + 5));
|
||||
for (const [n, label] of [['1.', 'converged'], ['2.', 'cap exhausted'], ['3.', 'operator stop']]) {
|
||||
const from = exits.indexOf(`\n${n}`);
|
||||
assert.ok(from > 0, `Exits must keep numbered entry ${n} (${label})`);
|
||||
const nextMarker = exits.indexOf(`\n${Number(n[0]) + 1}.`, from);
|
||||
const entry = exits.slice(from, nextMarker > from ? nextMarker : undefined);
|
||||
assert.match(
|
||||
entry,
|
||||
/remove the (scope )?marker|rm -f/i,
|
||||
`Exit ${n} (${label}) must remove the scope marker — a marker outliving the loop blocks the rest of the session`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('STORM marker: the crash path is documented as TTL auto-reset, not cleanup', () => {
|
||||
// A crashed session runs no cleanup at all. The honest statement is that the
|
||||
// hook's TTL covers it; claiming cleanup covers crashes would be false.
|
||||
const phase5 = phase5Section(RESEARCH_CMD);
|
||||
assert.match(
|
||||
phase5,
|
||||
/TTL/,
|
||||
'Phase 5 must state that a crashed run is covered by the hook TTL, not by exit cleanup',
|
||||
);
|
||||
});
|
||||
|
||||
test('S18: HANDOVER-CONTRACTS documents the pre-2.2 zero-framing-enforcement hole', () => {
|
||||
// The framing defense is producer-elective: a brief declaring ≤ 2.1 sidesteps
|
||||
// it entirely. Handover 1 (PUBLIC CONTRACT) must disclose this and name the remedy.
|
||||
|
|
@ -1324,6 +1650,48 @@ for (const cmd of ['trekresearch', 'trekplan', 'trekreview', 'trekexecute']) {
|
|||
// fixed model in every session; if deterministic pinning is ever wanted again,
|
||||
// do it consciously and update this pin's rationale.
|
||||
|
||||
// ── S85 — every spawn site must forbid the Agent tool's `name` parameter ────
|
||||
// MEASURED 2026-08-17 (this repo, session c37b89f7), after akashic-intelligence
|
||||
// lost a whole Phase 9 to it. Passing `name` does not rename a subagent — it
|
||||
// changes its kind: the meta record flips from a real subagent
|
||||
// (`spawnDepth: 1`) to `taskKind: "in_process_teammate"` (`spawnDepth: 0`).
|
||||
// A teammate's final assistant text is NOT a return value; it reaches the
|
||||
// parent only if the teammate itself calls SendMessage(to: "main").
|
||||
// voyage's reviewers declare `tools: ["Read","Glob","Grep"]` — no SendMessage —
|
||||
// so as teammates they are STRUCTURALLY incapable of returning, whatever the
|
||||
// prompt says. Denominators: named 0/5 returned, named-with-explicit-
|
||||
// SendMessage 1/1, unnamed 3/3 (plan-critic + scope-guardian both returned
|
||||
// full findings + JSON block in ~110s). Full measurement:
|
||||
// docs/agent-return-channel-defect.md.
|
||||
//
|
||||
// The derived set is asserted exactly, so the pin cannot go vacuous if a
|
||||
// command stops spawning or a new spawning command is added.
|
||||
|
||||
const SPAWNING_COMMANDS = ['trekbrief', 'trekplan', 'trekresearch', 'trekreview'];
|
||||
|
||||
test('S85: the set of agent-spawning commands is exactly the four that carry the no-name rule', () => {
|
||||
const spawns = listMd('commands')
|
||||
.filter((f) => /^Launch\b/m.test(read(`commands/${f}`)))
|
||||
.map((f) => f.replace(/\.md$/, ''))
|
||||
.sort();
|
||||
assert.deepEqual(spawns, [...SPAWNING_COMMANDS].sort(),
|
||||
'a command started or stopped spawning agents — add/remove it from SPAWNING_COMMANDS and give it the no-name rule');
|
||||
});
|
||||
|
||||
for (const cmd of SPAWNING_COMMANDS) {
|
||||
test(`S85: commands/${cmd}.md forbids the Agent tool's name parameter at its spawn sites`, () => {
|
||||
const text = read(`commands/${cmd}.md`);
|
||||
assert.ok(
|
||||
text.includes('in_process_teammate'),
|
||||
`commands/${cmd}.md must name the mechanism (in_process_teammate) so the rule is not mistaken for style`,
|
||||
);
|
||||
assert.ok(
|
||||
/never pass .*`name`/i.test(text),
|
||||
`commands/${cmd}.md must state that the Agent tool's \`name\` parameter is never passed — a named agent completes its work but its result never reaches the orchestrator`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('v5.9: no commands/*.md frontmatter carries a model: key (session inheritance by omission)', () => {
|
||||
const offenders = [];
|
||||
for (const f of listMd('commands')) {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,81 @@ test('SC #11(b): commands/trekplan.md prose mentions phase_models + parallel_age
|
|||
'trekplan.md prose must mention parallel_agents (additive stats field)');
|
||||
});
|
||||
|
||||
// --- Step 9: the five STORM measurement fields -----------------------------
|
||||
// Four numerics + one low-cardinality label. Without `effort` there is no axis
|
||||
// to group high-vs-standard runs on, and the measurement gate cannot be
|
||||
// computed at all.
|
||||
|
||||
const STORM_FIELDS = [
|
||||
'effort',
|
||||
'unique_sources',
|
||||
'dimensions_baseline',
|
||||
'conv_turns',
|
||||
'empty_turns',
|
||||
];
|
||||
|
||||
test('Step 9: a standard-run trekresearch record parses and survives applyFieldAllowlist', async () => {
|
||||
const { applyFieldAllowlist } = await import('../../lib/exporters/field-allowlist.mjs');
|
||||
// A standard run: loop never armed, so no discovered dimensions and no turns.
|
||||
const raw = JSON.parse(JSON.stringify({
|
||||
_schema_id: 'trekresearch',
|
||||
ts: '2026-08-09T12:00:00.000Z',
|
||||
slug: 'add-auth',
|
||||
mode: 'default',
|
||||
scope: 'both',
|
||||
engine: 'swarm',
|
||||
question: 'free prose that must never reach the exporter',
|
||||
project_dir: '/Users/somebody/repos/x',
|
||||
brief_path: '/Users/somebody/repos/x/brief.md',
|
||||
dimensions: 4,
|
||||
dimensions_baseline: 4,
|
||||
conv_turns: 0,
|
||||
empty_turns: 0,
|
||||
unique_sources: 11,
|
||||
effort: 'standard',
|
||||
agents_local: 5,
|
||||
agents_external: 4,
|
||||
gemini_used: false,
|
||||
confidence: 0.8,
|
||||
contradictions: 1,
|
||||
open_questions: 2,
|
||||
profile: 'premium',
|
||||
profile_source: 'default',
|
||||
}));
|
||||
|
||||
assert.equal(raw.dimensions_baseline, raw.dimensions,
|
||||
'a standard run discovers no dimensions — baseline must equal the final count');
|
||||
|
||||
const out = applyFieldAllowlist(raw, 'trekresearch');
|
||||
for (const field of STORM_FIELDS) {
|
||||
assert.ok(field in out, `${field} must survive the trekresearch allowlist`);
|
||||
}
|
||||
assert.equal(out.conv_turns, 0);
|
||||
assert.equal(out.empty_turns, 0);
|
||||
assert.equal(out.effort, 'standard');
|
||||
// Deny-by-omission must still hold for the PII-ish fields.
|
||||
for (const denied of ['question', 'project_dir', 'brief_path']) {
|
||||
assert.equal(denied in out, false, `${denied} must NOT reach the exporter`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Step 9: commands/trekresearch.md prose names all five measurement fields', () => {
|
||||
const content = readFileSync(join(REPO_ROOT, 'commands', 'trekresearch.md'), 'utf-8');
|
||||
for (const field of STORM_FIELDS) {
|
||||
assert.ok(content.includes(field),
|
||||
`trekresearch.md prose must name ${field} — an emitted-but-undocumented field is unauditable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Step 9: tests/fixtures/jsonl-schemas.md trekresearch row lists the five fields', () => {
|
||||
const doc = readFileSync(join(REPO_ROOT, 'tests', 'fixtures', 'jsonl-schemas.md'), 'utf-8');
|
||||
const row = doc.split('\n').find(l => l.startsWith('| trekresearch-stats '));
|
||||
assert.ok(row, 'jsonl-schemas.md is missing the trekresearch-stats row');
|
||||
for (const field of STORM_FIELDS) {
|
||||
assert.ok(row.includes(field), `authoring reference must list ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('SC #11(b): commands/trekresearch.md prose mentions external_research_enabled', () => {
|
||||
const content = readFileSync(join(REPO_ROOT, 'commands', 'trekresearch.md'), 'utf-8');
|
||||
assert.match(content, /external_research_enabled/,
|
||||
|
|
|
|||
597
tests/lib/research-loop-cap.test.mjs
Normal file
597
tests/lib/research-loop-cap.test.mjs
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
// tests/lib/research-loop-cap.test.mjs
|
||||
// Cover lib/util/research-loop-cap.mjs: default-off, worst-case arithmetic,
|
||||
// anti-dead-data (different caps → different denial points), statefulness
|
||||
// (identical args → different answers once the budget is hit), env
|
||||
// coercion, fail-closed on missing CLAUDE_PLUGIN_DATA, and the CLI shim.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync, execFile } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
allowTurn,
|
||||
isStormEnabled,
|
||||
resolveMaxConvTurns,
|
||||
resolveLedgerPath,
|
||||
resolveDataRoot,
|
||||
readLedger,
|
||||
checkDimensionCeiling,
|
||||
MAX_CONV_TURNS,
|
||||
MAX_TOTAL_DIMENSIONS,
|
||||
} from '../../lib/util/research-loop-cap.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs');
|
||||
|
||||
function withTmpDataDir(fn) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'research-loop-cap-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runShim(args, env) {
|
||||
try {
|
||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the shim in a BUILT env rather than an inherited one. Spreading
|
||||
* process.env means no test can express "CLAUDE_PLUGIN_DATA is absent" — the
|
||||
* exact condition that holds in every real run — so the shim's behaviour there
|
||||
* went uncovered while the module was denying turn 1.
|
||||
*/
|
||||
function runShimStripped(args, env = {}) {
|
||||
try {
|
||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (a) default-off --------------------------------------------------------
|
||||
|
||||
test('allowTurn — VOYAGE_STORM_ENABLED unset denies with budget 0, regardless of effort', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'storm_disabled');
|
||||
assert.equal(r.budget, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — VOYAGE_STORM_ENABLED=0 denies same as unset', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '0' };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'storm_disabled');
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — enabled but effort !== high denies with budget 0', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'standard' }, { env });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.reason, 'effort_not_high');
|
||||
assert.equal(r.budget, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (f) CLAUDE_PLUGIN_DATA unset => documented fallback root ----------------
|
||||
//
|
||||
// CLAUDE_PLUGIN_DATA is EMPTY in the Bash tool's process env (measured in a
|
||||
// live plugin-enabled session), and the Bash snippet in commands/trekresearch.md
|
||||
// is the module's only caller. Denying on its absence therefore denied turn 1
|
||||
// of every real run: the loop could never spend a turn, and the pre-registered
|
||||
// measurement could not be run at all. The root is resolved in code, not
|
||||
// demanded of the environment.
|
||||
|
||||
test('allowTurn — CLAUDE_PLUGIN_DATA unset falls back to the documented root and grants', () => {
|
||||
withTmpDataDir((home) => {
|
||||
const env = { VOYAGE_STORM_ENABLED: '1', HOME: home }; // no CLAUDE_PLUGIN_DATA
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'the loop must be able to spend turn 1 without CLAUDE_PLUGIN_DATA');
|
||||
assert.equal(r.used, 1);
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
assert.ok(
|
||||
existsSync(join(home, '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl')),
|
||||
'the ledger must be written under the fallback root',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveDataRoot — CLAUDE_PLUGIN_DATA wins; empty or unset falls back to ~/.claude/voyage', () => {
|
||||
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' }), '/tmp/plugin-data');
|
||||
assert.equal(resolveDataRoot({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
|
||||
assert.equal(resolveDataRoot({ HOME: '/home/x' }), join('/home/x', '.claude', 'voyage'));
|
||||
});
|
||||
|
||||
// ---- (c) worst-case arithmetic ----------------------------------------------
|
||||
|
||||
test('allowTurn — budget is max_conv_turns × max_total_dimensions (default 3×8=24)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r = allowTurn({ runId: 'r1', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.budget, 24);
|
||||
assert.equal(r.used, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — grants exactly `budget` turns then denies the next one (default 24)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
let last;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
last = allowTurn({ runId: 'r-exhaust', dimension: `d${i % 8}`, effort: 'high' }, { env });
|
||||
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
|
||||
}
|
||||
const denied = allowTurn({ runId: 'r-exhaust', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(denied.reason, 'budget_exhausted');
|
||||
assert.equal(denied.used, 24);
|
||||
assert.equal(denied.budget, 24);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (c)/(anti-dead-data) — different caps → observably different denial points
|
||||
|
||||
test('allowTurn — TREKRESEARCH_MAX_CONV_TURNS=1 denies after 8 turns (1×8), not 24', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
let last;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
last = allowTurn({ runId: 'r-narrow', dimension: `d${i}`, effort: 'high' }, { env });
|
||||
assert.equal(last.ok, true, `turn ${i + 1} should be granted`);
|
||||
}
|
||||
const denied = allowTurn({ runId: 'r-narrow', dimension: 'd8', effort: 'high' }, { env });
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(denied.budget, 8);
|
||||
assert.notEqual(denied.budget, 24, 'a narrower cap must produce a different denial point than the default');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (d) stateful — identical args give different answers once exhausted ---
|
||||
|
||||
test('allowTurn — identical {runId, dimension, effort} args diverge once the budget is hit (proves statefulness)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
const args = { runId: 'r-identical', dimension: 'same-dim', effort: 'high' };
|
||||
const results = [];
|
||||
for (let i = 0; i < 9; i++) results.push(allowTurn(args, { env }));
|
||||
// First 8 (budget = 1*8) granted, 9th denied — same exact input object each time.
|
||||
assert.deepEqual(results.slice(0, 8).map(r => r.ok), Array(8).fill(true));
|
||||
assert.equal(results[8].ok, false);
|
||||
assert.equal(results[8].reason, 'budget_exhausted');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- (e) env coercion --------------------------------------------------------
|
||||
|
||||
test('resolveMaxConvTurns — NaN string falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'abc' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — empty string falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — negative value falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '-5' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — zero falls back to default (never unbounded)', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — unset falls back to default', () => {
|
||||
assert.equal(resolveMaxConvTurns({}), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — valid positive integer string is honored', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2' }), 2);
|
||||
});
|
||||
|
||||
// A fractional value passed the `n <= 0` guard and only THEN floored, so 0.5 and
|
||||
// 0.9 became 0 and the budget became 0 × 8 = 0 — every turn denied, the loop
|
||||
// silently dead, while README.md and docs/architecture.md both promise a
|
||||
// fallback of 3. The guard has to see the floored value, not the raw one.
|
||||
test('resolveMaxConvTurns — a fractional value below 1 falls back to the default, never 0', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.5' }), MAX_CONV_TURNS);
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '0.9' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — a fractional value above 1 still floors (2.7 → 2)', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: '2.7' }), 2);
|
||||
});
|
||||
|
||||
test('resolveMaxConvTurns — Infinity is not a cap and falls back to the default', () => {
|
||||
assert.equal(resolveMaxConvTurns({ TREKRESEARCH_MAX_CONV_TURNS: 'Infinity' }), MAX_CONV_TURNS);
|
||||
});
|
||||
|
||||
test('allowTurn — a fractional cap below 1 cannot produce a budget of 0', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '0.5' };
|
||||
const r = allowTurn({ runId: 'r-frac', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, true, 'a budget of 0 would make the loop silently dead, not bounded');
|
||||
assert.equal(r.budget, MAX_CONV_TURNS * MAX_TOTAL_DIMENSIONS);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- pure-core unit coverage --------------------------------------------------
|
||||
|
||||
test('isStormEnabled — only the literal string "1" enables', () => {
|
||||
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: '1' }), true);
|
||||
assert.equal(isStormEnabled({ VOYAGE_STORM_ENABLED: 'true' }), false);
|
||||
assert.equal(isStormEnabled({}), false);
|
||||
});
|
||||
|
||||
test('resolveLedgerPath — falls back under ~/.claude/voyage when CLAUDE_PLUGIN_DATA is unset or empty', () => {
|
||||
const expected = join('/home/x', '.claude', 'voyage', 'trekresearch-loop-ledger.jsonl');
|
||||
assert.equal(resolveLedgerPath({ HOME: '/home/x' }), expected);
|
||||
assert.equal(resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '', HOME: '/home/x' }), expected);
|
||||
});
|
||||
|
||||
test('resolveLedgerPath — joins CLAUDE_PLUGIN_DATA with the ledger filename', () => {
|
||||
const p = resolveLedgerPath({ CLAUDE_PLUGIN_DATA: '/tmp/plugin-data' });
|
||||
assert.equal(p, join('/tmp/plugin-data', 'trekresearch-loop-ledger.jsonl'));
|
||||
});
|
||||
|
||||
test('allowTurn — missing runId or dimension denies with missing_args', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r1 = allowTurn({ dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r1.ok, false);
|
||||
assert.equal(r1.reason, 'missing_args');
|
||||
const r2 = allowTurn({ runId: 'r1', effort: 'high' }, { env });
|
||||
assert.equal(r2.ok, false);
|
||||
assert.equal(r2.reason, 'missing_args');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- fail-closed on an unreadable ledger ------------------------------------
|
||||
//
|
||||
// The module's own header states that a budget control "must never silently
|
||||
// grant unlimited turns just because the data dir is missing". The
|
||||
// missing-DIRECTORY case already failed closed; the unreadable-FILE case
|
||||
// returned 0 from countTurns and therefore re-granted the full budget on every
|
||||
// call, unbounded — a fail-open in the same module that argues against one.
|
||||
// A directory standing where the ledger file belongs reproduces it portably
|
||||
// (EISDIR), with no chmod that a root test runner would ignore.
|
||||
|
||||
function withUnreadableLedger(fn) {
|
||||
return withTmpDataDir((dir) => {
|
||||
mkdirSync(join(dir, 'trekresearch-loop-ledger.jsonl'), { recursive: true });
|
||||
return fn(dir);
|
||||
});
|
||||
}
|
||||
|
||||
test('readLedger — a missing ledger is 0 turns, not an error (turn 1 must be grantable)', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
assert.equal(readLedger(join(dir, 'nope.jsonl'), 'r1').granted, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('readLedger — an unreadable ledger throws rather than reporting 0 turns spent', () => {
|
||||
withUnreadableLedger((dir) => {
|
||||
assert.throws(
|
||||
() => readLedger(join(dir, 'trekresearch-loop-ledger.jsonl'), 'r1'),
|
||||
/unreadable/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('readLedger — malformed lines are skipped, well-formed ones for the run still count', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const p = join(dir, 'trekresearch-loop-ledger.jsonl');
|
||||
writeFileSync(p, [
|
||||
JSON.stringify({ runId: 'r1', dimension: 'd1' }),
|
||||
'{ not json',
|
||||
'',
|
||||
JSON.stringify({ runId: 'other', dimension: 'd1' }),
|
||||
JSON.stringify({ runId: 'r1', dimension: 'd2' }),
|
||||
].join('\n') + '\n');
|
||||
assert.equal(readLedger(p, 'r1').granted, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — an unreadable ledger DENIES the turn instead of granting a fresh budget', () => {
|
||||
withUnreadableLedger((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1' };
|
||||
const r = allowTurn({ runId: 'r-unreadable', dimension: 'd1', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false, 'a budget control that cannot count must not grant');
|
||||
assert.match(r.reason, /ledger-read-failed/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- the bound holds under concurrency --------------------------------------
|
||||
//
|
||||
// countTurns-then-appendFileSync had no atomic claim, while the comment above
|
||||
// allowTurn asserted "Append-only: never read-modify-write" and named the
|
||||
// concurrent case (Phase 4.5/5 may spawn several agents in one message) as the
|
||||
// reason. The decision path WAS read-then-write: N callers that all observe
|
||||
// used == budget-1 all grant, and the bound is exceeded by N-1.
|
||||
//
|
||||
// Two of these tests are deterministic. They do not race anything — they assert
|
||||
// the invariant the claim introduces: a slot that is already claimed is spent,
|
||||
// even when the ledger has not caught up yet, which is exactly the state a
|
||||
// mid-append competitor leaves behind. The third runs real processes.
|
||||
|
||||
function seedLedger(dir, runId, n) {
|
||||
writeFileSync(
|
||||
join(dir, 'trekresearch-loop-ledger.jsonl'),
|
||||
Array.from({ length: n }, (_, i) =>
|
||||
JSON.stringify({ ts: new Date().toISOString(), runId, dimension: `d${i}`, effort: 'high' }),
|
||||
).join('\n') + (n ? '\n' : ''),
|
||||
);
|
||||
}
|
||||
|
||||
function seedClaims(dir, runId, slots) {
|
||||
const claimDir = join(dir, 'trekresearch-loop-claims');
|
||||
mkdirSync(claimDir, { recursive: true });
|
||||
for (const s of slots) writeFileSync(join(claimDir, `${runId}-${s}.claim`), '');
|
||||
}
|
||||
|
||||
function runShimAsync(args, env) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[SHIM, ...args],
|
||||
{ encoding: 'utf-8', env: { PATH: process.env.PATH, ...env } },
|
||||
(err, stdout) => resolve({ code: err ? (err.code ?? 1) : 0, out: stdout ?? '' }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('allowTurn — a slot already claimed is spent even when the ledger has not caught up', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
// budget = 1 × 8 = 8. Ledger shows 7 turns; a competitor already claimed
|
||||
// slot 8 and has not appended yet. Counting the ledger alone says "one slot
|
||||
// free" and grants a 9th turn overall — the breach this closes.
|
||||
seedLedger(dir, 'r-race', 7);
|
||||
seedClaims(dir, 'r-race', [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const r = allowTurn({ runId: 'r-race', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(r.ok, false, 'every slot up to the budget is claimed, so there is nothing to grant');
|
||||
assert.equal(r.reason, 'budget_exhausted');
|
||||
assert.equal(r.budget, 8);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — it takes the first FREE slot and claims it, so a repeat call cannot retake it', () => {
|
||||
withTmpDataDir((dir) => {
|
||||
const env = { CLAUDE_PLUGIN_DATA: dir, VOYAGE_STORM_ENABLED: '1', TREKRESEARCH_MAX_CONV_TURNS: '1' };
|
||||
seedLedger(dir, 'r-slot', 7);
|
||||
seedClaims(dir, 'r-slot', [1, 2, 3, 4, 5, 6, 7]);
|
||||
const first = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(first.ok, true, 'slot 8 is free and must be grantable');
|
||||
assert.equal(first.used, 8, 'used is the slot number, so it never double-counts a claimed slot');
|
||||
assert.ok(
|
||||
existsSync(join(dir, 'trekresearch-loop-claims', 'r-slot-8.claim')),
|
||||
'the grant must leave the claim behind as the atomic record of the slot',
|
||||
);
|
||||
const second = allowTurn({ runId: 'r-slot', dimension: 'd0', effort: 'high' }, { env });
|
||||
assert.equal(second.ok, false);
|
||||
assert.equal(second.reason, 'budget_exhausted');
|
||||
});
|
||||
});
|
||||
|
||||
test('allowTurn — a different runId is unaffected by another run’s 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 run’s 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:/);
|
||||
});
|
||||
324
tests/scripts/storm-measure.test.mjs
Normal file
324
tests/scripts/storm-measure.test.mjs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
// tests/scripts/storm-measure.test.mjs
|
||||
// Step 11 — the STORM adoption gate's deterministic accounting core.
|
||||
//
|
||||
// The gate decides ONE thing: does the bounded Phase 5 loop buy enough extra
|
||||
// source/coverage breadth to be worth flipping VOYAGE_STORM_ENABLED on by
|
||||
// default. Thresholds are pre-registered in docs/storm-measurement.md BEFORE
|
||||
// any measurement run, so this file pins the arithmetic that turns a
|
||||
// trekresearch-stats.jsonl into a verdict — not the verdict itself.
|
||||
//
|
||||
// Two properties carry the gate's honesty:
|
||||
// - runs with empty_turns > 0 are EXCLUDED from the gain and COUNTED, so
|
||||
// adoption is never decided on a broken denominator, and
|
||||
// - a stats file with no `effort` field is a loud error, never a silently
|
||||
// empty group that reads as "no gain".
|
||||
//
|
||||
// Pattern: tests/scripts/synthesis-measure.test.mjs (pure core, no fixtures on disk).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
median,
|
||||
parseStats,
|
||||
partitionEligible,
|
||||
measure,
|
||||
decideVerdict,
|
||||
activationCheck,
|
||||
ADOPT_THRESHOLD,
|
||||
DECLINE_THRESHOLD,
|
||||
} from '../../scripts/storm-measure.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers — synthetic JSONL, one object per line, exactly as the orchestrator emits
|
||||
// ---------------------------------------------------------------------------
|
||||
function run({ effort, unique_sources, dimensions, dimensions_baseline, empty_turns = 0, conv_turns = 0 }) {
|
||||
return JSON.stringify({
|
||||
ts: '2026-08-12T00:00:00.000Z',
|
||||
question: 'q',
|
||||
mode: 'full',
|
||||
scope: 'both',
|
||||
engine: 'swarm',
|
||||
effort,
|
||||
unique_sources,
|
||||
dimensions,
|
||||
dimensions_baseline,
|
||||
conv_turns,
|
||||
empty_turns,
|
||||
});
|
||||
}
|
||||
|
||||
function jsonl(...lines) {
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// A control arm at 10 sources / 5 dimensions, and a treatment arm at 13
|
||||
// sources / 8 dimensions: +30.0% sources, +60.0% dimensions.
|
||||
const STANDARD = [
|
||||
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
run({ effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
];
|
||||
const HIGH = [
|
||||
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
||||
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
||||
run({ effort: 'high', unique_sources: 13, dimensions: 8, dimensions_baseline: 5, conv_turns: 3 }),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// median
|
||||
// ---------------------------------------------------------------------------
|
||||
test('median: odd, even, single', () => {
|
||||
assert.equal(median([3, 1, 2]), 2);
|
||||
assert.equal(median([1, 2, 3, 4]), 2.5);
|
||||
assert.equal(median([7]), 7);
|
||||
});
|
||||
|
||||
test('median: empty list is null, never 0 — 0 would read as a real measurement', () => {
|
||||
assert.equal(median([]), null);
|
||||
});
|
||||
|
||||
test('median does not mutate its input', () => {
|
||||
const xs = [3, 1, 2];
|
||||
median(xs);
|
||||
assert.deepEqual(xs, [3, 1, 2]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseStats — the loud-error requirement
|
||||
// ---------------------------------------------------------------------------
|
||||
test('parseStats: a file with no effort field throws, it does not yield empty groups', () => {
|
||||
const noEffort = jsonl(
|
||||
JSON.stringify({ ts: 'x', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
JSON.stringify({ ts: 'y', unique_sources: 11, dimensions: 5, dimensions_baseline: 5 }),
|
||||
);
|
||||
assert.throws(() => parseStats(noEffort), /effort/i);
|
||||
});
|
||||
|
||||
test('parseStats: skips blank and malformed lines but keeps the good ones', () => {
|
||||
const text = jsonl(STANDARD[0], '', 'not json', HIGH[0]);
|
||||
const { records, malformed } = parseStats(text);
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(malformed, 1);
|
||||
});
|
||||
|
||||
test('parseStats: an empty file throws rather than reporting a zero-gain verdict', () => {
|
||||
assert.throws(() => parseStats(''), /no records/i);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exclusion of broken runs
|
||||
// ---------------------------------------------------------------------------
|
||||
test('partitionEligible: runs with empty_turns > 0 are excluded and counted', () => {
|
||||
const { records } = parseStats(jsonl(
|
||||
...HIGH,
|
||||
run({ effort: 'high', unique_sources: 99, dimensions: 8, dimensions_baseline: 5, empty_turns: 2 }),
|
||||
));
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
assert.equal(eligible.length, 3);
|
||||
assert.equal(excluded, 1);
|
||||
});
|
||||
|
||||
test('partitionEligible: empty_turns === 0 is eligible; a missing field counts as 0', () => {
|
||||
const { records } = parseStats(jsonl(
|
||||
STANDARD[0],
|
||||
JSON.stringify({ ts: 'z', effort: 'standard', unique_sources: 10, dimensions: 5, dimensions_baseline: 5 }),
|
||||
));
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
assert.equal(eligible.length, 2);
|
||||
assert.equal(excluded, 0);
|
||||
});
|
||||
|
||||
// A malformed empty_turns used to land in the ELIGIBLE arm: Number('many') is
|
||||
// NaN, NaN fails the isFinite test, and the `else` branch pushed it in. The
|
||||
// exclusion is one of the two properties carrying this gate's honesty, so a
|
||||
// garbage value silently re-entering the denominator defeats it — and it does so
|
||||
// in the direction that flatters adoption, since the run that broke is the run
|
||||
// whose numbers are least trustworthy.
|
||||
test('partitionEligible: a non-numeric empty_turns is EXCLUDED, never silently eligible', () => {
|
||||
const { records } = parseStats(jsonl(
|
||||
STANDARD[0],
|
||||
JSON.stringify({
|
||||
ts: 'm', effort: 'high', unique_sources: 999,
|
||||
dimensions: 8, dimensions_baseline: 5, empty_turns: 'many',
|
||||
}),
|
||||
));
|
||||
const { eligible, excluded } = partitionEligible(records);
|
||||
assert.equal(excluded, 1, 'a value that cannot be read as a turn count is not evidence of zero empty turns');
|
||||
assert.equal(eligible.length, 1);
|
||||
});
|
||||
|
||||
test('partitionEligible: an unparsable empty_turns cannot move the median either', () => {
|
||||
const m = measure(parseStats(jsonl(
|
||||
...STANDARD,
|
||||
...HIGH,
|
||||
JSON.stringify({
|
||||
ts: 'm', effort: 'high', unique_sources: 900,
|
||||
dimensions: 8, dimensions_baseline: 5, empty_turns: {},
|
||||
}),
|
||||
)).records);
|
||||
assert.equal(m.excluded, 1);
|
||||
assert.equal(m.sources.treatment, 13, 'the 900-source malformed run must not reach the median');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// measure — the known-answer test
|
||||
// ---------------------------------------------------------------------------
|
||||
test('measure: median gains on synthetic runs give the known answer', () => {
|
||||
const m = measure(parseStats(jsonl(...STANDARD, ...HIGH)).records);
|
||||
assert.equal(m.control.n, 3);
|
||||
assert.equal(m.treatment.n, 3);
|
||||
assert.equal(m.sources.control, 10);
|
||||
assert.equal(m.sources.treatment, 13);
|
||||
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9, `sources gain ${m.sources.gain}`);
|
||||
// (8 - 5) / 5 = 0.60 within each treatment run.
|
||||
assert.ok(Math.abs(m.dimensions.gain - 0.60) < 1e-9, `dimensions gain ${m.dimensions.gain}`);
|
||||
});
|
||||
|
||||
test('measure: an excluded run cannot move the median', () => {
|
||||
const withBroken = jsonl(
|
||||
...STANDARD,
|
||||
...HIGH,
|
||||
run({ effort: 'high', unique_sources: 900, dimensions: 8, dimensions_baseline: 5, empty_turns: 1 }),
|
||||
);
|
||||
const m = measure(parseStats(withBroken).records);
|
||||
assert.equal(m.excluded, 1);
|
||||
assert.equal(m.sources.treatment, 13, 'the 900-source broken run must not reach the median');
|
||||
assert.ok(Math.abs(m.sources.gain - 0.30) < 1e-9);
|
||||
});
|
||||
|
||||
test('measure: reports null gain (not 0) when an arm has no eligible runs', () => {
|
||||
const m = measure(parseStats(jsonl(...HIGH)).records);
|
||||
assert.equal(m.control.n, 0);
|
||||
assert.equal(m.sources.gain, null);
|
||||
assert.equal(m.verdict, 'insufficient-data');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// verdict mapping — both sides of both thresholds
|
||||
// ---------------------------------------------------------------------------
|
||||
test('decideVerdict: at and above the adopt threshold', () => {
|
||||
assert.equal(decideVerdict(ADOPT_THRESHOLD, ADOPT_THRESHOLD), 'adopt');
|
||||
assert.equal(decideVerdict(0.55, 0.44), 'adopt');
|
||||
});
|
||||
|
||||
test('decideVerdict: both metrics between the bars is inconclusive, not adopt', () => {
|
||||
assert.equal(decideVerdict(ADOPT_THRESHOLD - 0.0001, 0.2), 'inconclusive');
|
||||
});
|
||||
|
||||
test('decideVerdict: below the decline threshold on both metrics declines', () => {
|
||||
assert.equal(decideVerdict(0.14, 0.05), 'decline');
|
||||
assert.equal(decideVerdict(DECLINE_THRESHOLD - 0.0001, 0), 'decline');
|
||||
});
|
||||
|
||||
test('decideVerdict: at the decline threshold is inconclusive, not decline', () => {
|
||||
assert.equal(decideVerdict(DECLINE_THRESHOLD, DECLINE_THRESHOLD), 'inconclusive');
|
||||
});
|
||||
|
||||
// The brief pre-registers "median forbedring >= 30 % på (a) eller (b) → adopt.
|
||||
// < 15 % → decline." — OR on both sides, with adopt evaluated first.
|
||||
test('decideVerdict: adopt needs EITHER metric — one strong metric carries a weak one', () => {
|
||||
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
|
||||
assert.equal(decideVerdict(0.10, 0.90), 'adopt');
|
||||
});
|
||||
|
||||
test('decideVerdict: either metric below the decline bar declines', () => {
|
||||
assert.equal(decideVerdict(0.02, 0.20), 'decline');
|
||||
assert.equal(decideVerdict(0.20, 0.02), 'decline');
|
||||
});
|
||||
|
||||
test('decideVerdict: adopt outranks decline when one metric clears and the other is under the decline bar', () => {
|
||||
assert.equal(decideVerdict(0.90, 0.10), 'adopt');
|
||||
});
|
||||
|
||||
test('decideVerdict: a null gain is insufficient data, never a decline', () => {
|
||||
assert.equal(decideVerdict(null, 0.4), 'insufficient-data');
|
||||
assert.equal(decideVerdict(0.4, null), 'insufficient-data');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// activationCheck — BOTH halves of the SC, not just the count
|
||||
//
|
||||
// The SC requires that an `effort: high` run discovers at least one dimension
|
||||
// AND that the dimension list in the output brief is a TRUE SUPERSET of the
|
||||
// interview-derived ones. activationCheck only computed dimensions -
|
||||
// dimensions_baseline >= 1, so a run that replaced two interview dimensions with
|
||||
// three discovered ones passed the check while violating the SC's second half.
|
||||
// Supersetness was asserted only by Phase 4.5's prose contract that discovery
|
||||
// appends; nothing read it.
|
||||
//
|
||||
// The stats record carries counts, not names — names are free prose and
|
||||
// field-allowlist.mjs denies prose by omission — so the run attests membership
|
||||
// with a low-cardinality boolean instead, and the gate refuses to call
|
||||
// activation OK without it.
|
||||
// ---------------------------------------------------------------------------
|
||||
function highRun(over = {}) {
|
||||
return JSON.stringify({
|
||||
ts: '2026-08-12T00:00:00.000Z',
|
||||
effort: 'high',
|
||||
unique_sources: 13,
|
||||
dimensions: 8,
|
||||
dimensions_baseline: 5,
|
||||
conv_turns: 3,
|
||||
empty_turns: 0,
|
||||
dimensions_baseline_preserved: true,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
test('activationCheck: discovery plus a preserved baseline is activation', () => {
|
||||
const r = activationCheck(parseStats(jsonl(highRun())).records);
|
||||
assert.equal(r.ok, true);
|
||||
assert.equal(r.discovered_dimensions, 3);
|
||||
assert.equal(r.dimensions_baseline_preserved, true);
|
||||
});
|
||||
|
||||
test('activationCheck: a REPLACED baseline is not activation, however many were discovered', () => {
|
||||
const r = activationCheck(parseStats(jsonl(
|
||||
highRun({ dimensions: 8, dimensions_baseline: 5, dimensions_baseline_preserved: false }),
|
||||
)).records);
|
||||
assert.equal(r.ok, false, 'a count delta of +3 says nothing about which dimensions survived');
|
||||
assert.match(r.reason, /superset|baseline/i);
|
||||
});
|
||||
|
||||
test('activationCheck: a run that does not attest baseline membership cannot pass', () => {
|
||||
const rec = JSON.parse(highRun());
|
||||
delete rec.dimensions_baseline_preserved;
|
||||
const r = activationCheck(parseStats(jsonl(JSON.stringify(rec))).records);
|
||||
assert.equal(r.ok, false, 'an absent attestation is not an attestation');
|
||||
assert.match(r.reason, /dimensions_baseline_preserved/);
|
||||
});
|
||||
|
||||
test('activationCheck: no discovery is still not activation even with the baseline preserved', () => {
|
||||
const r = activationCheck(parseStats(jsonl(
|
||||
highRun({ dimensions: 5, dimensions_baseline: 5 }),
|
||||
)).records);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.discovered_dimensions, 0);
|
||||
});
|
||||
|
||||
test('activationCheck: no effort:high run at all is reported as such', () => {
|
||||
const r = activationCheck(parseStats(jsonl(...STANDARD)).records);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.reason, /high/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pre-registration — the thresholds are the doc's, and the doc is committed first
|
||||
// ---------------------------------------------------------------------------
|
||||
test('thresholds are the pre-registered 30% / 15%', () => {
|
||||
assert.equal(ADOPT_THRESHOLD, 0.30);
|
||||
assert.equal(DECLINE_THRESHOLD, 0.15);
|
||||
});
|
||||
|
||||
// The human-readable summary is the only form of the rule most readers will
|
||||
// ever see. It said "on BOTH" on both sides while decideVerdict evaluated OR —
|
||||
// so the report described a stricter gate than the one that produced the verdict
|
||||
// printed one line below it.
|
||||
test('the printed threshold line states the OR rule that decideVerdict actually applies', () => {
|
||||
const src = readFileSync(new URL('../../scripts/storm-measure.mjs', import.meta.url), 'utf-8');
|
||||
const line = src.split('\n').find((l) => l.includes('thresholds: adopt'));
|
||||
assert.ok(line, 'the summary must still print its threshold rule');
|
||||
assert.doesNotMatch(line, /on BOTH/, 'the rule is OR on both sides — printing BOTH misstates the gate');
|
||||
assert.match(line, /EITHER/);
|
||||
});
|
||||
174
tests/validators/query-privacy-gate.test.mjs
Normal file
174
tests/validators/query-privacy-gate.test.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// tests/validators/query-privacy-gate.test.mjs
|
||||
// Cover lib/validators/query-privacy-gate.mjs: two-sided code table
|
||||
// (absolute path / repo-internal identifier / secret-shaped token), a
|
||||
// benign query passing untouched, the opt-in env var reaching only the
|
||||
// warn tier (never the hard-block tier), strict/soft severity, and the
|
||||
// CLI shim.
|
||||
//
|
||||
// Secret-shaped fixtures are built via string concatenation/repeat, never
|
||||
// as literal tokens — the repo's own secrets pre-edit hook (correctly)
|
||||
// treats a literal AKIA/sk-/ghp_ string as a real credential.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
validateOutboundQuery,
|
||||
ABSOLUTE_PATH_PATTERNS,
|
||||
REPO_IDENTIFIER_PATTERNS,
|
||||
SECRET_SHAPED_PATTERNS,
|
||||
} from '../../lib/validators/query-privacy-gate.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SHIM = join(HERE, '..', '..', 'lib', 'validators', 'query-privacy-gate.mjs');
|
||||
|
||||
const FAKE_OPENAI_KEY = 'sk-' + 'a'.repeat(24);
|
||||
const FAKE_AWS_KEY = 'AKIA' + 'Q'.repeat(16);
|
||||
const FAKE_GITHUB_PAT = 'ghp_' + 'b'.repeat(36);
|
||||
|
||||
// Real-world formats whose token body contains hyphens/underscores. A run of
|
||||
// plain alphanumerics is broken by those separators, so a naive
|
||||
// `[A-Za-z0-9]{20,}` run-length pattern lets them through — the gap this file
|
||||
// pins. Anthropic Console keys are `sk-ant-api03-` + ~95 base64url chars;
|
||||
// GitHub fine-grained PATs are `github_pat_<22>_<59>`; `gho_` is the OAuth
|
||||
// sibling of the classic `ghp_` token.
|
||||
const FAKE_ANTHROPIC_KEY = 'sk-' + 'ant-' + 'api03-' + 'A1b2_-x9'.repeat(12);
|
||||
const FAKE_GITHUB_FINE_GRAINED = 'github' + '_pat_' + 'A'.repeat(22) + '_' + 'c'.repeat(59);
|
||||
const FAKE_GITHUB_OAUTH = 'gho' + '_' + 'd'.repeat(36);
|
||||
|
||||
function runShim(args) {
|
||||
try {
|
||||
const out = execFileSync(process.execPath, [SHIM, ...args], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { code: 0, out };
|
||||
} catch (e) {
|
||||
return { code: e.status ?? 1, out: e.stdout?.toString() ?? '' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- two-sided code table ----------------------------------------------------
|
||||
|
||||
const TABLE = [
|
||||
{ label: 'absolute path (/Users/...)', text: 'find every caller of foo in /Users/ktg/repos/voyage/lib/util/foo.mjs', code: 'PRIVACY_ABSOLUTE_PATH' },
|
||||
{ label: 'absolute path (/home/...)', text: 'trace /home/alice/projects/app/src/index.js for imports', code: 'PRIVACY_ABSOLUTE_PATH' },
|
||||
{ label: 'repo-internal identifier (forgejo host)', text: 'what changed recently on git.fromaitochitta.com/open/voyage', code: 'PRIVACY_REPO_IDENTIFIER' },
|
||||
{ label: 'repo-internal identifier (repo name)', text: 'search issues for ktg-plugin-marketplace regressions', code: 'PRIVACY_REPO_IDENTIFIER' },
|
||||
{ label: 'secret-shaped (OpenAI/Anthropic-style key)', text: `auth failing with key ${FAKE_OPENAI_KEY}`, code: 'PRIVACY_SECRET_SHAPED' },
|
||||
{ label: 'secret-shaped (AWS access key)', text: `rotate ${FAKE_AWS_KEY} now`, code: 'PRIVACY_SECRET_SHAPED' },
|
||||
{ label: 'secret-shaped (GitHub PAT)', text: `token leaked: ${FAKE_GITHUB_PAT}`, code: 'PRIVACY_SECRET_SHAPED' },
|
||||
{ label: 'secret-shaped (Anthropic Console key)', text: `why does ${FAKE_ANTHROPIC_KEY} 401`, code: 'PRIVACY_SECRET_SHAPED' },
|
||||
{ label: 'secret-shaped (GitHub fine-grained PAT)', text: `pushed with ${FAKE_GITHUB_FINE_GRAINED}`, code: 'PRIVACY_SECRET_SHAPED' },
|
||||
{ label: 'secret-shaped (GitHub OAuth token)', text: `oauth flow returned ${FAKE_GITHUB_OAUTH}`, code: 'PRIVACY_SECRET_SHAPED' },
|
||||
];
|
||||
|
||||
for (const { label, text, code } of TABLE) {
|
||||
test(`validateOutboundQuery — ${label} → ${code} (strict, error)`, () => {
|
||||
const r = validateOutboundQuery(text, { strict: true, env: {} });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === code), JSON.stringify(r.errors));
|
||||
});
|
||||
}
|
||||
|
||||
test('validateOutboundQuery — benign generic query passes untouched', () => {
|
||||
const r = validateOutboundQuery('What are the tradeoffs between optimistic and pessimistic locking?', { env: {} });
|
||||
assert.equal(r.valid, true);
|
||||
assert.deepEqual(r.errors, []);
|
||||
assert.deepEqual(r.warnings, []);
|
||||
});
|
||||
|
||||
// ---- strict vs soft (warn tier only) -----------------------------------------
|
||||
|
||||
test('validateOutboundQuery — soft mode downgrades warn-tier findings to warnings, stays valid', () => {
|
||||
const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { strict: false, env: {} });
|
||||
assert.equal(r.valid, true);
|
||||
assert.equal(r.errors.length, 0);
|
||||
assert.ok(r.warnings.find(w => w.code === 'PRIVACY_ABSOLUTE_PATH'));
|
||||
});
|
||||
|
||||
test('validateOutboundQuery — soft mode does NOT downgrade the hard-block tier', () => {
|
||||
const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { strict: false, env: {} });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
|
||||
});
|
||||
|
||||
// ---- opt-in env var reaches only the warn tier -------------------------------
|
||||
|
||||
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 bypasses the warn tier entirely', () => {
|
||||
const r = validateOutboundQuery('inspect /Users/ktg/repos/voyage', { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
|
||||
assert.equal(r.valid, true);
|
||||
assert.equal(r.errors.length, 0);
|
||||
assert.equal(r.warnings.length, 0);
|
||||
});
|
||||
|
||||
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 does NOT open the hard-block tier', () => {
|
||||
const r = validateOutboundQuery(`leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'), 'opt-in must never unlock the hard-block tier');
|
||||
});
|
||||
|
||||
test('validateOutboundQuery — VOYAGE_QUERY_PRIVACY_ALLOW=1 combined with a secret still denies', () => {
|
||||
const r = validateOutboundQuery(`/Users/ktg/x leaked ${FAKE_OPENAI_KEY}`, { env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' } });
|
||||
assert.equal(r.valid, false);
|
||||
assert.equal(r.errors.length, 1);
|
||||
assert.equal(r.errors[0].code, 'PRIVACY_SECRET_SHAPED');
|
||||
});
|
||||
|
||||
// The hard-block tier is the one thing no operator flag unlocks, so a format
|
||||
// it misses is a secret leaving the machine with no second gate behind it.
|
||||
// Pin every real-world format against BOTH escape hatches at once.
|
||||
for (const [label, token] of [
|
||||
['Anthropic Console key', FAKE_ANTHROPIC_KEY],
|
||||
['GitHub fine-grained PAT', FAKE_GITHUB_FINE_GRAINED],
|
||||
['GitHub OAuth token', FAKE_GITHUB_OAUTH],
|
||||
]) {
|
||||
test(`validateOutboundQuery — ${label} stays blocked under --soft and the opt-in`, () => {
|
||||
const r = validateOutboundQuery(`leaked ${token}`, {
|
||||
strict: false,
|
||||
env: { VOYAGE_QUERY_PRIVACY_ALLOW: '1' },
|
||||
});
|
||||
assert.equal(r.valid, false, 'hard-block tier must never be overridable');
|
||||
assert.ok(r.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- empty input --------------------------------------------------------------
|
||||
|
||||
test('validateOutboundQuery — empty string is invalid', () => {
|
||||
const r = validateOutboundQuery('', { env: {} });
|
||||
assert.equal(r.valid, false);
|
||||
assert.ok(r.errors.find(e => e.code === 'PRIVACY_EMPTY_QUERY'));
|
||||
});
|
||||
|
||||
// ---- pattern set is frozen ----------------------------------------------------
|
||||
|
||||
test('pattern sets are Object.frozen', () => {
|
||||
assert.equal(Object.isFrozen(ABSOLUTE_PATH_PATTERNS), true);
|
||||
assert.equal(Object.isFrozen(REPO_IDENTIFIER_PATTERNS), true);
|
||||
assert.equal(Object.isFrozen(SECRET_SHAPED_PATTERNS), true);
|
||||
});
|
||||
|
||||
// ---- CLI shim -----------------------------------------------------------------
|
||||
|
||||
test('CLI shim — benign query exits 0 with valid:true', () => {
|
||||
const r = runShim(['harmless generic question about caching strategies']);
|
||||
assert.equal(r.code, 0);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.valid, true);
|
||||
});
|
||||
|
||||
test('CLI shim — secret-shaped query exits 1 even with --soft', () => {
|
||||
const r = runShim(['--soft', `leaked ${FAKE_OPENAI_KEY}`]);
|
||||
assert.equal(r.code, 1);
|
||||
const parsed = JSON.parse(r.out.trim());
|
||||
assert.equal(parsed.valid, false);
|
||||
assert.ok(parsed.errors.find(e => e.code === 'PRIVACY_SECRET_SHAPED'));
|
||||
});
|
||||
|
||||
test('CLI shim — missing query argument exits 2 (usage error)', () => {
|
||||
const r = runShim([]);
|
||||
assert.equal(r.code, 2);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue