chore(cleanup): R-b R3 — profiles, phase signals and the effort dialog removed, one fixed path per command

The four profiles, the resolver, the profile validator and docs/profiles.md go;
/trekbrief Phase 3.5 and the brief's phase_signals checks go (an older brief's
key is ignored); MANIFEST_PROFILE_DRIFT and profile_used go. Every Profile,
Composition rule, Sequencing gate and High-effort section leaves the commands.
Decision 209: /trekresearch runs the full swarm with contrarian-researcher as
wave 2 every run; /trekreview and the coordinator run all four passes with no
high-effort normalization. Voyage agents are spawned with no model (their own
model: opus applies); the two built-in spawns pass model "opus". --profile
stays in the parser as a retired flag that swallows its value. §Model & effort
moves to docs/architecture.md; the sequencing-gate eval case goes.

Files removed: 44 (43 from the R3 list + tests/commands/trekreview.test.mjs,
whose every test pinned removed text). Files edited: 43.
Tests: 2467 (2466 pass / 0 fail / 1 skipped) before, 2309 (2309 pass / 0 fail /
0 skipped) after. Durable check: all three groups green, none pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-07 21:12:57 +01:00
commit 2a35d067d9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
87 changed files with 225 additions and 4967 deletions

View file

@ -38,9 +38,9 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | session |
| `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | session |
`session` = command frontmatter pins no `model:` (since v5.9.0), so the orchestrator follows the session model; sub-agent models come from the profile (see Model & effort below).
`session` = command frontmatter pins no `model:` (since v5.9.0), so the orchestrator follows the session model; sub-agent models come from each agent's own `model:` pin (see Model & effort below).
Full flag reference for each command (modes, `--gates`, `--profile`, breaking changes): see `docs/command-modes.md`.
Full flag reference for each command (modes, `--gates`, breaking changes): see `docs/command-modes.md`.
> **The critic loop converges (decision 43).** Round 1 reviews the whole plan.
> From round 2 the reviewers get `plan-gate.mjs --diff` and mark each previous
@ -106,9 +106,9 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
| security-researcher | opus | CVEs, audit history, supply chain risks |
| contrarian-researcher | opus | Counter-evidence, overlooked alternatives |
> **Inventory.** 20 agent files, **all 20 spawnable**. All 20 stay `model: opus` (operator pin `40d8742`); why → `docs/profiles.md` §Model & effort axes.
> **Inventory.** 20 agent files, **all 20 spawnable**. All 20 stay `model: opus` (operator pin `40d8742`); why → `docs/architecture.md` §Model & effort.
> **Model & effort.** On the Anthropic API, `opus` = Opus 5.5 and `sonnet` = Sonnet 5.5 (both default reasoning effort `medium`); `fable` = Fable 5.1 (the most capable model in Claude Code; default `high`, so xhigh must be chosen explicitly). An agent without `effort:` inherits the session level. Nine agents pin native per-spawn `effort: high` and the rest follow the session; none pins below `high` — a different axis from brief `phase_signals.effort` (orchestration shape: which agents/passes run). Per-agent table + axes → `docs/profiles.md` §Model & effort axes.
> **Model & effort.** On the Anthropic API, `opus` = Opus 5.5 and `sonnet` = Sonnet 5.5 (both default reasoning effort `medium`); `fable` = Fable 5.1 (the most capable model in Claude Code; default `high`, so xhigh must be chosen explicitly). An agent without `effort:` inherits the session level. Nine agents pin native per-spawn `effort: high` and the rest follow the session; none pins below `high`. Per-agent list + precedence → `docs/architecture.md` §Model & effort.
## Skills
@ -131,7 +131,6 @@ Full flag reference for each command (modes, `--gates`, `--profile`, breaking ch
- **Architecture, workflows, project-directory contract, state, terminology:** `docs/architecture.md`
- **Quality infrastructure (`lib/` validators, parsers, autonomy primitives, hooks):** `docs/architecture.md` §Quality infrastructure
- **Autonomy gates (`--gates`), Path A/B/C decision:** `docs/operations.md`
- **Profile system (`--profile economy/balanced/premium/fable`), lookup order, custom profiles:** `docs/operations.md`
- **Observability:** `docs/operations.md`
- **Handover contracts (the 7 pipeline handovers):** `docs/HANDOVER-CONTRACTS.md`
- **Release and rollback (P13):** `docs/release-runbook.md` — the drilled runbook (before / release / rollback).

View file

@ -123,7 +123,6 @@ An optional architect step can sit between research and plan — `/trekplan` aut
Concrete capabilities, observable in the code — not aspirations.
**Across all profiles:**
- Strategy-to-execution on seven explicit handover points. Each transition is a filesystem contract (`docs/HANDOVER-CONTRACTS.md`), not a conversation. You can stop after any stage and resume later without context loss.
- Resume safety after long sessions. The PreCompact hook reconciles `progress.json` with git history before context compaction (CC v2.1.105+) — closes a documented `--resume` failure mode.
- Schema discipline. `plan-validator --strict` enforces `### Step N:` form and rejects narrative drift (`### Fase`, `### Phase`, `### Stage`, `### Steg`) before execution.
@ -141,7 +140,7 @@ What this pipeline does **not** solve — read this before adopting it:
- LLM output truthfulness. Validators check shape, not facts. A plan with hallucinated paths passes schema but fails in execute. Plan-critic catches some, not all.
- Multi-user concurrency on a single project directory. Two simultaneous executors will clobber `progress.json`.
- Cost management. Opus on the orchestrator layer is expensive; documented in [Cost profile](#cost-profile), no automatic model downgrade.
- Cost management. Opus on the orchestrator layer is expensive; no automatic model downgrade.
- Linear/Jira/Slack integrations. Intentional omission — solo project, no enterprise wiring.
**One-line summary:** an executable contract pipeline where each stage is filesystem-validated, session-survivable, and skill-independent — in exchange for writing an actual brief before planning and an actual plan before coding.
@ -172,7 +171,6 @@ Borrowed, with credit: size classes and the ratchet from obra/superpowers (MIT),
|------|-------|----------|
| **Default** | `/trekbrief <task>` | Question round sized by class, then review and approval. |
| **Quick** | `/trekbrief --quick <task>` | Proposes `small` as the class; still said aloud, overridable, and raised if the task proves bigger. |
| **Profile** | `/trekbrief --profile <name> <task>` | (v4.1.0) Pin model profile for the brief phase: `economy` / `balanced` / `premium` / `fable` / `<custom>`. See [Profile system](#profile-system-v410) below. |
| **Approve** | `/trekbrief --approve <project-dir \| brief-file>` | No interview: show an existing brief's `## Intent` + `## Goal` verbatim and ask the operator to approve them. The same step (Phase 4h) ends every normal `/trekbrief` run. |
`/trekbrief` is **always interactive**. There is no foreground/background mode — the interview requires user input.
@ -217,7 +215,6 @@ Output:
| **Local** | `/trekresearch --local <question>` | Only codebase analysis agents (skip external research) |
| **External** | `/trekresearch --external <question>` | Only external research agents (skip codebase analysis) |
| **Foreground** | `/trekresearch --fg <question>` | No-op alias (foreground is default since v2.4.0) |
| **Profile** | `/trekresearch --profile <name> <question>` | (v4.1.0) Pin model profile for the research phase. See [Profile system](#profile-system-v410). |
Flags combine: `--project <dir> --external`.
@ -269,7 +266,6 @@ Output:
| **Quick** | `/trekplan --project <dir> --quick` | No agent swarm, lightweight scan only |
| **Decompose** | `/trekplan --decompose plan.md` | Split plan into session specs + `orders.md` |
| **Export** | `/trekplan --export headless plan.md` | Legacy alias for `--decompose` (the only remaining export format) |
| **Profile** | `/trekplan --profile <name> --project <dir>` | (v4.1.0) Pin model profile; emitted as `profile:` in plan.md frontmatter. See [Profile system](#profile-system-v410). |
`--brief` or `--project` is **required**. `/trekplan` with no brief exits with an error and a pointer to `/trekbrief`.
@ -371,7 +367,6 @@ Borrowed: the order from Cherny; the gate on a verdict in a file from Kiro; the
| **Single step** | `/trekexecute --project <dir> --step 3` | Execute only step 3 |
| **Foreground** | `/trekexecute --project <dir> --fg` | Force sequential, ignore Execution Strategy |
| **Single session** | `/trekexecute --project <dir> --session 2` | Execute only session 2 from Execution Strategy |
| **Profile** | `/trekexecute --profile <name> --project <dir>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Plan-frontmatter `profile:` is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
### Multi-session execution through session orders (worktree-isolated)
@ -512,7 +507,6 @@ Declined to Judge list, and the coordinator carries those lines into
| **Quick** | `/trekreview --project <dir> --quick` | `/code-review` + code-correctness reviewer only; skip brief-conformance reviewer, `/security-review` and the coordinator's reasonableness filter except the brief-conflict rule — fast correctness-only pass |
| **Validate** | `/trekreview --project <dir> --validate` | Schema-only check on existing `review.md`. No LLM calls |
| **Dry run** | `/trekreview --project <dir> --dry-run` | Print discovered scope + triage map; skip writes |
| **Profile** | `/trekreview --profile <name> --project <dir>` | (v4.1.0) Pin model profile for the review phase. See [Profile system](#profile-system-v410). |
### What review produces
@ -576,7 +570,6 @@ schema and producer/consumer contract.
| **Default** | `/trekcontinue` | Auto-discover `.session-state.local.json` under cwd, validate, narrate, and begin executing the next session |
| **Explicit** | `/trekcontinue <project-dir>` | Use the named project directory; helpful when several active projects coexist under cwd |
| **Help** | `/trekcontinue --help` | Print usage block and the schema-v1 reference |
| **Profile** | `/trekcontinue --profile <name> [<project-dir>]` | (v4.1.0) Pin model profile for the resumed session. Plan-frontmatter `profile:` from the previous session is honored when this flag is omitted. See [Profile system](#profile-system-v410). |
### Schema v1 — `.session-state.local.json`
@ -776,7 +769,7 @@ flowchart TB
BRG["brief-reviewer (gate · ≤3 iter)"]
end
subgraph RES["/trekresearch · Phase 4 — parallel"]
RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner*"]
RL["LOCAL: architecture-mapper · dependency-tracer<br/>task-finder · git-historian · convention-scanner"]
RE["EXTERNAL: docs-researcher · community-researcher<br/>security-researcher · contrarian-researcher*"]
end
subgraph PL["/trekplan"]
@ -796,7 +789,7 @@ flowchart TB
BR --> RES --> PL --> EX --> RV
```
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher, when selected, runs in wave 2 after wave 1 returns, with the leading answer of wave 1's external findings to attack, always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
`* = conditional`: convention-scanner / test-strategist on medium+ codebases (50+ files); research-scout for unknown external tech; contrarian-researcher runs in wave 2 of every research run, after wave 1 returns, with the leading answer of wave 1's external findings to attack, always at `effort=high`; brief-conformance-reviewer skipped under `--quick`.
> **Which Claude Code primitive each phase uses — and the alternatives considered (Workflow substrate, delegated orchestrator)** → see [docs/architecture.md §Primitives per step](docs/architecture.md#primitives-per-step-decision-matrix).
@ -915,40 +908,10 @@ The `pre-compact-flush.mjs` hook directly fixes a documented P0: in skill-driven
**Annotation HTML requires a desktop browser.** `scripts/annotate.mjs` produces a single self-contained `.html` file you open with `file://` in any modern browser (Chrome / Safari / Firefox / Edge — last two versions). No CDN, no server, no npm runtime deps. State persists in `localStorage` so closing and re-opening the tab keeps your work, but it's local to one browser on one machine — not synced anywhere. If you want to annotate without a browser, paste the `.md` into Claude with "comments inline below" and write notes in chat — same end result, just without the visual surface.
## Profile system (v4.1.0)
Four built-in model profiles (`lib/profiles/*.yaml`) plus your own: a `<name>.yaml` in `voyage-profiles/` at the repo root, or in `~/.claude/voyage-profiles/`. A custom file with a built-in's name is ignored. Each profile pins `phase_models` for the six pipeline phases. The active profile is recorded in plan.md frontmatter as `profile: <name>`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------|
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | ⚠ **Experimental** (uncalibrated Jaccard floor) — lowest cost; high-confidence small-scope tasks (opt-in via `--profile economy`) |
| `balanced` | sonnet | sonnet | opus | sonnet | opus | sonnet | Mixed — opus where reasoning depth pays off (opt-in via `--profile balanced`) |
| `premium` (default) | opus | opus | opus | opus | opus | opus | Maximum quality — Opus on every phase (default since the 2026-05-13 operator decision) |
| `fable` | fable | fable | fable | fable | fable | fable | Max quality — Fable 5.1 (the most capable model in Claude Code) on every phase (opt-in via `--profile fable`); reasoning effort inherits from the session |
Lookup order:
1. Explicit `--profile <name>` flag passed to the command
2. Plan-file frontmatter `profile:` (when resuming via `/trekexecute --resume` or `/trekcontinue`)
3. `VOYAGE_PROFILE` environment variable (not read on a resume)
4. Default `premium`
A brief's `phase_signals` (written by `/trekbrief`) can set the model for research, plan, execute and review; where it does, it comes before the profile.
See [`docs/profiles.md`](docs/profiles.md) for the decision tree, custom-profile authoring, and cost estimation disclaimer (the per-profile cost numbers are estimates, not contractual SLAs).
## Observability
Claude Code's own OpenTelemetry (`CLAUDE_CODE_ENABLE_TELEMETRY`) gives tool durations, tokens and cost per agent and skill: see [Monitoring usage](https://code.claude.com/docs/en/monitoring-usage) (read 2026-11-07, Claude Code 2.1.319).
## Cost profile
The default `premium` profile runs **Opus on every phase** of the pipeline's agent work — the exploration and review swarms (5–10 sub-agents per command; spawn sites inject the composed brief > profile > frontmatter resolution, with `agents/*.md` `model: opus` pins as the fallback) and the executor (one per plan session). The command orchestrator itself is not profile-controlled: as of v5.9, command frontmatter omits `model:`, so the orchestrator follows the session model. For cheaper runs, opt into `--profile balanced` (Sonnet on brief/research/execute/continue, Opus on plan + review) or `--profile economy` (Sonnet everywhere); for maximum quality, `--profile fable` (Fable 5.1 on every phase).
On the Anthropic API the `opus` alias resolves to **Opus 5.5** and `sonnet` to **Sonnet 5.5** (both default to reasoning effort `medium`), and `fable` to **Fable 5.1** (the most capable model in Claude Code; default reasoning effort `high` — xhigh must be chosen explicitly, see [`docs/profiles.md`](docs/profiles.md)). Other providers resolve `opus` and `sonnet` to other versions. Note two distinct effort axes that share the word "effort": brief `phase_signals.effort` (low/standard/high) tunes *orchestration shape* — how many agents and passes run — while native `effort:` (nine agents pin `high`; the rest follow the session) tunes the *per-spawn reasoning budget*. See [`docs/profiles.md`](docs/profiles.md) § Model & effort axes.
For per-profile cost estimates, see [`docs/profiles.md`](docs/profiles.md).
## Requirements
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (CLI, desktop app, or web app)
@ -961,7 +924,7 @@ Top-level layout:
```
voyage/
├── agents/ 20 specialized agents, all spawnable (all `model: opus`-pinned; per-phase model set by `--profile`)
├── agents/ 20 specialized agents, all spawnable (all `model: opus`-pinned)
├── commands/ 6 slash commands (trekbrief, trekresearch, trekplan, trekexecute, trekreview, trekcontinue) + trekendsession helper
├── skills/ 7 skills (test-first, debugging: preloaded into the step helper; researching-sources: preloaded into the five web-search agents;
│ └ reviewing-adversarially: the review agents; writing-criteria: brief-reviewer, plan-critic; interviewing: named by /trekbrief;
@ -970,7 +933,7 @@ voyage/
├── hooks/ 9 hooks (pre-bash, pre-write, pre-plan-gate, session-title, post-approval-log, post-edit-check, pre-compact-flush, post-compact-flush, stop-test-lock)
├── lib/ Zero-dep parsers and validators (CLI shims under lib/validators/)
├── tests/ comprehensive node:test suite — `npm test` is the fork-readiness gate
├── docs/ HANDOVER-CONTRACTS.md, architecture.md, operations.md, profiles.md, command-modes.md, release-runbook.md
├── docs/ HANDOVER-CONTRACTS.md, architecture.md, operations.md, command-modes.md, release-runbook.md
└── examples/ 02-real-cli/ — runnable pipeline fixture
```
@ -992,8 +955,8 @@ synthesis.
cp agents/architecture-mapper.md agents/my-new-agent.md
```
2. Update the frontmatter `name`, `description`, `tools`, and `model`.
Every shipped agent is `model: opus`; the profile picks the model at
the spawn site, so the pin is only the fallback.
Every shipped agent is `model: opus`, and the spawn sites pass no
model, so the pin applies.
3. Add the agent to the swarm in `commands/trekplan.md` Phase 5
(the command runs the swarm inline) — register it under the
codebase-size bucket where it should fire.
@ -1010,10 +973,8 @@ The command orchestrators pin no model: `/trekbrief`, `/trekresearch`,
To run them on Sonnet for cost or latency, start the session on Sonnet
or switch with `/model` inside the session.
The exploration and review sub-agents are chosen separately, at the spawn
sites: brief `phase_signals` first, then the profile's `phase_models`, with the
`model: opus` pins in `agents/*.md` as the fallback. Run `--profile economy`
to put the sub-agents on Sonnet as well.
The exploration and review sub-agents run on their own `model: opus` pins
in `agents/*.md`; the spawn sites pass no model.
### Disable external research

View file

@ -164,9 +164,6 @@ Remove findings that fail ANY of these tests:
- **Unknown rule_key** → `unverified` (`unknown-rule_key`). `rule_key` is not
in `RULE_CATALOGUE`. Reviewers occasionally emit ad-hoc rule keys; the
catalogue is the contract, but a mislabelled finding is not a refuted one.
*(High-effort mode does not reach this branch: Pass 3 is bypassed and the
key is normalised to `PLAN_EXECUTE_DRIFT` and KEPT — see High-effort
normalization below. The two fates never apply to the same input.)*
- **Non-existent file.** `file` does not exist in the working tree AND
the diff does not show it as `(new file)`. Use Glob to verify. **This test
has three outcomes, not two:** Glob resolves and the file is absent from
@ -206,19 +203,6 @@ Executive Summary so the reader knows reasonableness was not applied.
The brief-conflict rule above still applies in quick mode (the mark and the
one lowering) and changes nothing else.
**High-effort normalization (v5.1.1):** When the review is invoked
under high-effort mode (`phase_signals[review].effort: high`), Pass 3
reasonableness filtering is bypassed. To prevent unknown rule_keys
from polluting downstream plans, the coordinator MUST substitute any
rule_key not exported from `lib/review/rule-catalogue.mjs:RULE_KEYS`
with the literal string `PLAN_EXECUTE_DRIFT` (the most general drift
category in the catalogue). The original rule_key is
preserved in the finding's `original_rule_key` field for diagnostic
purposes. This normalization happens BEFORE writing review.md,
ensuring all `rule_key` values in the final review match the
catalogue. The brief-conflict rule (Pass 3) still applies under high-effort
mode (the mark and the one lowering) and changes nothing else.
### Suppression is two-valued (fail-closed)
Every removal in Pass 2 and Pass 3 carries one of two fates, and the

View file

@ -184,9 +184,9 @@ Put the task in one of three size classes. In doubt, take the heaviest that fits
| Class | Typical task | What the pipeline does |
|---|---|---|
| `small` | one change in one place, no new dependency, no interface change, easy to undo | one round, no follow-up; brief-reviewer runs once and its gaps become written assumptions, not questions, written after the review stamp (Step 4e, passed gate, step 4); Phase 3.5 is skipped |
| `bounded` | several files or one module behind a clear boundary, no architecture decision | the round + at most one follow-up; at most one brief-review follow-up; Phase 3.5 asks one question for all four phases |
| `architectural` | a new module, interface, dependency, data model or migration; anything hard to undo | follow-up rounds while answers open new branches; the full brief-review loop; Phase 3.5 asks per phase |
| `small` | one change in one place, no new dependency, no interface change, easy to undo | one round, no follow-up; brief-reviewer runs once and its gaps become written assumptions, not questions, written after the review stamp (Step 4e, passed gate, step 4) |
| `bounded` | several files or one module behind a clear boundary, no architecture decision | the round + at most one follow-up; at most one brief-review follow-up |
| `architectural` | a new module, interface, dependency, data model or migration; anything hard to undo | follow-up rounds while answers open new branches; the full brief-review loop |
- **Said aloud.** State the class and why in one line *before* the round
("This reads as `small`: one setting on one screen, no new dependency."),
@ -445,135 +445,6 @@ Stored: {in git | only on this machine}
Proceeding to draft and review.
```
## Phase 3.5 — Per-phase effort dialog
Phase 3.5 is the v5.1 entry-point for **adaptive-depth execution**. After
Phase 3 has gathered intent / goal / success criteria / research plan, the
operator commits an effort level and (optional) model per downstream phase
(`research`, `plan`, `execute`, `review`). The committed signals are written
to brief frontmatter as a `phase_signals:` list that the four downstream
commands read via their `## Composition rule (v5.1)` section.
### State requirements
Before entering Phase 3.5 the following must be populated:
- `state.intent` — the Phase 3 Intent answer (1+ paragraph)
- `state.goal` — the Phase 3 Goal answer
- `state.success_criteria` — at least one falsifiable SC
- `state.research_plan.topics` — list (may be empty)
If any are absent: skip Phase 3.5 entirely and write `phase_signals_partial:
true` to the draft frontmatter. Do not block.
### By size class (and --quick)
- `small` (and `--quick` while the class stays `small`): skip Phase 3.5
entirely and auto-write `phase_signals_partial: true` to draft frontmatter.
The brief satisfies the v5.1 sequencing gate without the dialog, and the
downstream commands fall back to the profile.
- `bounded`: ask ONE question in the Step 3c round — the tier for all four
phases, with 4 options from the mapping below — and write the same tier
for `research`, `plan`, `execute` and `review`.
- `architectural`: the per-phase dialog below, as one `AskUserQuestion` call
carrying four questions (one per phase).
### Default-derivation heuristic (LLM judgment, not algorithmic)
Before each phase question, propose a default tier marked `(default)`. Use
these signals — they are weak heuristics, not rules:
- `research_topics_count` → high (`high`), low (`low`), absent (`low`)
- `sc_count` (count of falsifiable SCs) → high (≥6 ⇒ `high`), low (≤2 ⇒ `low`)
- Goal complexity: keywords like "rewrite", "migration", "refactor across",
"new platform" ⇒ `high`; "typo", "small bugfix", "docs touch-up" ⇒ `low`
- Otherwise: `standard`
Mix these into one proposed default per phase. Document the proposed tier
in the question body so the operator sees why it was picked.
### The dialog — one call, one tier-coupled question per phase
For `[research, plan, execute, review]`, ask one question each, all four in
one `AskUserQuestion` call, each question with 4 options:
| Option | Maps to phase_signals entry |
|--------|----------------------------|
| **Low effort** | `{phase: <name>, effort: low, model: sonnet}` |
| **Standard (default)** | `{phase: <name>, effort: standard}` *(model omitted — composition falls through to profile)* |
| **High effort** | `{phase: <name>, effort: high, model: opus}` |
| **Fable (max quality)** | `{phase: <name>, effort: high, model: fable}` |
The proposed tier per phase (from the default-derivation heuristic) MUST be
labelled `(default)` and listed first so the operator can one-click
accept. Commit the chosen tiers to an in-memory `effort_state` dict as the
answers come back — no bulk summary-before-commit. A hollow or empty answer
for a phase leaves that phase uncommitted (the force-stop path below).
The mapping table is canonical:
- `low → {effort: low, model: sonnet}` (force sonnet for the low-cost path)
- `standard → {effort: standard}` (model omitted; composition rule resolves via profile)
- `high → {effort: high, model: opus}` (force opus for the high-confidence path)
- `fable → {effort: high, model: fable}` (force Fable 5.1 for the max-quality path)
The fable tier reuses `effort: high` semantics — full swarm, contrarian
always-on; `EFFORT_LEVELS` is unchanged (Voyage effort is orchestration
shape, not model reasoning effort). Model reasoning effort is inherited from
the session: Fable 5.1's default effort is `high`, NOT xhigh. To run xhigh, the
operator chooses it explicitly via `/effort xhigh`, `--effort xhigh`, or
`CLAUDE_CODE_EFFORT_LEVEL` — Claude Code saves effort per model, so a level
saved for one model does not carry to another.
### Force-stop handling
If in answer to the dialog the operator says "stop",
"skip", "enough", "just write it", or similar, do NOT exit silently — apply
the Phase 4f force-stop pattern verbatim:
```
You stopped before committing per-phase signals. Remaining phases:
- {list of phases not yet answered}
The brief will still be valid (v5.1 supports `phase_signals_partial: true`
as a force-stop record). Downstream commands will fall back to the profile
resolver for the un-committed phases.
Continue anyway?
```
Then `AskUserQuestion`:
| Option | Action |
|--------|--------|
| **Answer one more phase** | Return to the next un-answered phase question. |
| **Stop now (record partial)** | Drop any in-progress `effort_state` and set `phase_signals_partial: true` in draft frontmatter. Mutually exclusive with `phase_signals`. Break Phase 3.5. |
This pattern matches Step 4f (line 436-458) so the force-stop UX is
identical across both surfaces.
### Hand-off to Phase 4a
If `effort_state` is fully populated (4 commits, no force-stop): write a
`phase_signals:` block to draft frontmatter — one entry per phase,
preserving the canonical-mapping form above. Omit `model:` for standard
tier (composition falls through to profile).
If `phase_signals_partial: true` was set: write that single line to draft
frontmatter and skip the `phase_signals:` block (mutually exclusive per
validator).
Phase 4a (Step 4a — Draft in memory) reads from `effort_state` /
`phase_signals_partial` and incorporates the appropriate frontmatter block
into the draft brief.
### Sequencing gate (downstream)
`brief_version: 2.1` activates the validator's sequencing gate. If the
final brief reaches `/trekplan`, `/trekresearch`, `/trekexecute`, or
`/trekreview` WITHOUT `phase_signals` and WITHOUT `phase_signals_partial:
true`, the validator emits `BRIEF_V51_MISSING_SIGNALS` and the command
halts with a friendly hint pointing back to `/trekbrief`.
## Phase 4 — Draft, review, and revise
Phase 4 runs a **draft → brief-reviewer → revise** loop. The draft is
@ -1127,22 +998,6 @@ Or:
/trekexecute --validate --project {PROJECT_DIR} # schema check
```
## Profile (v4.1)
Accepts `--profile <name>` where `<name>` is one of `economy`, `balanced`,
`premium`, `fable`, or a custom profile under `voyage-profiles/`. Default: `premium`.
Resolution order (per `lib/profiles/resolver.mjs`):
1. `--profile` flag (source: `flag`)
2. `VOYAGE_PROFILE` environment variable (source: `env`)
3. `premium` default (source: `default`)
Examples:
```
/trekbrief --profile economy
VOYAGE_PROFILE=balanced /trekbrief
```
## Hard rules
1. **Interactive only.** This command requires user input. There is no

View file

@ -268,34 +268,6 @@ If the brief file does not exist (validator emits a warning but does not
fail), print: `Warning: next_session_brief_path "{path}" does not exist on
disk. Cannot continue automatically.` and exit. Do not guess.
## Profile (v4.1) — inheritance from plan-frontmatter
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`,
or a custom profile under `voyage-profiles/`. Default: `premium`.
Unlike the other voyage commands, `/trekcontinue` defaults to **inheritance**
from the resumed plan's frontmatter `profile:` field rather than to `premium`.
This keeps a multi-session run consistent across resumptions.
Resolution order (per `lib/profiles/resolver.mjs` `resolveTrekcontinueProfile`):
1. `--profile` flag (source: `flag`) — explicit override, emits stderr advisory
`[voyage] profile inheritance overridden by --profile flag: <plan-profile> → <flag-profile>`
2. `VOYAGE_PROFILE` env-var (source: `env`)
3. Plan-frontmatter `profile:` field (source: `inheritance`) — typical case
4. `premium` default for v4.0-style plans without `profile:` (source: `default`)
The inherited profile drives `phase_models.continue` for the next session's
orchestration. Operators who explicitly want to switch profiles mid-run pass
`--profile <name>` and accept the advisory (e.g. drop from `premium` to
`balanced` mid-run to save cost on later sessions).
Examples:
```
/trekcontinue # inherits from plan.md
/trekcontinue --profile balanced # explicit override + advisory
VOYAGE_PROFILE=economy /trekcontinue # env-var override
```
## Hard rules
- **Idempotent.** Running `/trekcontinue` twice in the same Claude session

View file

@ -1,7 +1,7 @@
---
name: trekexecute
description: Disciplined plan executor — single-session, or multi-session through session orders the user starts, with failure recovery
argument-hint: "[--project <dir>] [--profile <name>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
argument-hint: "[--project <dir>] [--gates [true|false|closed|open]] [--fg | --resume | --dry-run | --validate | --step N | --session N] [plan.md]"
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion, Agent, Skill
disallowed-tools: TeamCreate
---
@ -49,21 +49,14 @@ Parse `$ARGUMENTS` for mode flags:
recovery session spec inside the project).
- Continue parsing remaining flags (they combine with `--project`).
- Brief check, once every flag below is parsed: when `{dir}/brief.md`
exists, run the two lines `/trekplan` runs on it (its Phase 1), for the
execute phase; append `--profile {profile}` when the operator passed
`--profile`:
exists, run the line `/trekplan` runs on it (its Phase 1):
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json
```
The validator exits 1 when its `errors` are not empty; read its JSON
either way. Halt only when the validator's `errors` hold
`BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate
surface" below. Any other error is printed in one line and the run
continues. A validator or resolver that cannot run (no JSON) is "not
measured" in one line, and the run continues. Capture the resolver's
JSON as `phase_signal_result` (Composition rule below). No
`{dir}/brief.md`: skip both lines.
either way. Any error is printed in one line and the run continues. A
validator that cannot run (no JSON) is "not measured" in one line, and
the run continues. No `{dir}/brief.md`: skip the line.
2. If arguments contain `--fg`: extract the file path. Set **mode = foreground**.
3. If arguments contain `--resume`: extract the file path. Set **mode = resume**.
4. If arguments contain `--dry-run`: extract the file path. Set **mode = dry-run**.
@ -95,8 +88,7 @@ Parse `$ARGUMENTS` for mode flags:
(operator decision 2026-10-08: two questions for one merge became one). The
MAIN_MERGE_GATE in Phase 8 is always on, regardless of `gates_mode`: it is
a lock, and the guard's question is the one question.
9. If arguments contain `--profile <name>`: set `profile = {name}`; strip both tokens.
10. Otherwise: the arguments left after every flag above is stripped are the file path.
9. Otherwise: the arguments left after every flag above is stripped are the file path.
Set **mode = execute**.
If no path is provided (and `--project` was not used to derive one), output
@ -111,7 +103,6 @@ Usage: /trekexecute <plan.md>
/trekexecute --validate <plan.md>
/trekexecute --step N <plan.md>
/trekexecute --session N <plan.md>
/trekexecute --profile <name> <plan.md>
/trekexecute --project <dir> --resume
/trekexecute --project <dir> --session N
@ -125,7 +116,6 @@ Modes:
--validate Schema-only check — parse steps + manifests, no security scan, no execution
--step N Execute only step N (foreground)
--session N Execute only session N from the plan's Execution Strategy
--profile Profile for this run's resolver; the step helper and the controller stay on opus
--gates Accepted, pauses on nothing: bare, true or closed = on; false or open = off
Examples:
@ -465,7 +455,6 @@ Determine how to execute this plan:
- `--step N` mode
- `--resume` mode
- `--session N` mode (runs only that session's steps, foreground)
- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when `.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.
- Plan has no `## Execution Strategy` section
- Plan has Execution Strategy with only 1 session
@ -2565,7 +2554,7 @@ Default **25** is an independent value from the cap research (practitioner range
10–50; OWASP Agentic ASI08 financial-DoS), tuned down from the research ~50
starting point for a tighter backstop. Operators may override:
`TREKEXECUTE_MAX_RECOVERY_ITERATIONS=10 /trekexecute --project ...`. The
`effort=='low'` single-foreground-loop path and the wave path are both subject to
single-foreground-loop path and the wave path are both subject to
this aggregate ceiling; it is an execution-spec ceiling (enforced via the
`iterations_remaining` counter), not a code-loop edit.
@ -2974,80 +2963,6 @@ run resumes at the lowest wave not yet merged, or re-runs Step 4 and returns to
the gate, and the `clear` command in Step 0 starts the plan over from its first
wave instead.
## Profile (v4.1)
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`,
`fable`, or a custom profile under `voyage-profiles/`. Default: `premium`.
Resolution order (per `lib/profiles/resolver.mjs`):
1. `--profile` flag (source: `flag`)
2. `VOYAGE_PROFILE` env-var (source: `env`)
3. Inherited from `plan.md` frontmatter `profile:` field (source: `inheritance`)
4. `premium` default for v4.0-style plans without `profile:` (source: `default`)
The selected profile goes to the resolver. It changes no model here: the step helper and the
controller run opus (Composition rule below), and nothing in this command reads `parallel_agents`.
Examples:
```
/trekexecute --profile economy --project .claude/projects/2026-05-09-add-auth
VOYAGE_PROFILE=balanced /trekexecute --project ...
```
## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
effort + model as:
```
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
```
The brief signal wins per-phase when present; the profile fills any
gaps. Both fields are mechanically resolved by the single composed CLI,
invoked in Phase 1 alongside the sequencing-gate brief-validator call:
```bash
# v5.9 — composed phase-model resolution (brief > profile > default) for the
# execute phase. ONE call returns {model, source}, plus
# effort only when the brief's phase signal sets one; without it the phase
# runs at effort `standard` (no change). Captured as phase_signal_result.
# Append --profile {profile} when the operator passed --profile.
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json
```
`phase_signal_result.effort` (absent = `standard`) is consumed when picking the execution
strategy (the parallel-wave choice, Phase 2.5). The resolver does NOT control the orchestrator's own
model — that is fixed at invocation time (command frontmatter omits
`model:`, so it follows the session model) and cannot be switched mid-turn.
`/trekexecute` spawns no swarm: one step helper at a time and one controller
(Hard Rule 10), and both run `opus` — the step agent's pin and the
controller's `model: "opus"` — so `phase_signal_result.model` still has no
spawn site here; it is returned for cross-command uniformity.
For `/trekexecute` specifically: `effort == 'low'` runs the plan as one
session, step after step (Phase 2.5). `effort == 'standard'` (or absent) →
no change (default execution strategy applies). `effort == 'high'`: see
`### High-effort behavior (v5.1.1)` below.
### Sequencing gate surface
When `/trekexecute --project <dir>` is invoked, ALWAYS run
`brief-validator.mjs --soft --json` against `{dir}/brief.md`. If
`BRIEF_V51_MISSING_SIGNALS` appears in `errors` (brief_version ≥ 2.1
without `phase_signals` or `phase_signals_partial: true`), halt with:
`Brief is brief_version 2.1 but does not carry phase_signals — re-run
/trekbrief to commit them (Phase 3.5).` Enforcement is validator-only;
commands surface, don't re-enforce.
### High-effort behavior (v5.1.1)
High effort changes nothing in `/trekexecute`: the step helper and the
controller already run opus, and every gate already runs. No `gates_mode` is
set from the effort.
## Hard rules
1. **No AskUserQuestion for execution decisions.** All execution decisions come

View file

@ -74,14 +74,6 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
# older brief that sidesteps framing enforcement.
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json [--min-version {min_brief_version}] "{dir}/brief.md"
# v5.9 — composed phase-model resolution (brief > profile > default) for
# the plan phase. ONE call returns {model, source}, plus
# effort only when the brief's phase signal sets one; without it the
# phase runs at effort `standard` (no change). Captured as
# phase_signal_result and injected at Agent-spawn sites below.
# Append --profile {profile} when the operator passed --profile.
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path "{dir}/brief.md" [--profile {profile}] --json
# Research briefs (if any) — drift-warn only, none of these block the run
[ -d "{dir}/research" ] && \
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/research-validator.mjs --soft --dir "{dir}/research" --json
@ -325,9 +317,7 @@ Determine the output directory from the plan slug:
Launch the **session-decomposer** agent:
Phase 1 runs the model resolver only for `--project`, so `--decompose` has no
resolved model: this spawn passes no `model:`, and the agent's own `model:`
applies.
This spawn passes no `model:`: the agent's own `model: opus` applies.
```
Plan file: {plan path}
@ -468,9 +458,7 @@ Follow the writing-criteria skill (each success criterion is one command the
criteria runner accepts, and it can go red) and the reviewing-adversarially skill.
Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names)."
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
This spawn passes no `model:`: the agent's own `model: opus` applies.
Handle the verdict:
- **PROCEED** — continue to Phase 5.
@ -529,9 +517,7 @@ research-scout's web search is not a local read; the line above bounds its local
### Always launch (all codebase sizes):
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
This spawn passes no `model:`: the agent's own `model: opus` applies.
**architecture-mapper** — full codebase structure, tech stack, patterns, anti-patterns.
Prompt: "Analyze the architecture of this codebase. The task being planned is: {task}.
@ -570,7 +556,7 @@ Read local files only inside the repo under exploration, plus paths this prompt
### Launch for medium+ codebases (50+ files):
**Convention Scanner** — use the `convention-scanner` plugin agent (model: phase_signal_result.model, the agent's frontmatter as the fallback)
**Convention Scanner** — use the `convention-scanner` plugin agent (no `model:`; its own `model: opus` applies)
for medium+ codebases only.
Prompt: "Discover the coding conventions relevant to this task: {task}. Brief file: {brief_path} — read it first. Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names). Provide concrete examples from the codebase, not generic advice."
@ -588,9 +574,7 @@ topics have corresponding research files, skip research-scout entirely.
If yes (and not covered by attached briefs): launch **research-scout** in
parallel with the other agents.
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
This spawn passes no `model:`: the agent's own `model: opus` applies.
Prompt: "Research the following technologies for this task: {task}.
Specific questions: {list specific questions about the technology}.
Technologies to research: {list}. Brief file: {brief_path} — read it first.
@ -611,9 +595,9 @@ Common reasons for deep-dives:
- A test pattern was identified but the test infrastructure needs more detail
- A risk was flagged but the actual impact needs verification
For each significant gap, spawn a targeted deep-dive agent (model: phase_signal_result.model, else opus —
Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to,
subagent_type: "Explore") with a narrow, specific brief. Each deep-dive prompt
For each significant gap, spawn a targeted deep-dive agent (subagent_type:
"Explore", Claude Code's built-in agent) with a narrow, specific brief.
Spawn it with `model: "opus"`: a built-in agent has no Voyage pin. Each deep-dive prompt
carries these two lines:
Brief file: {brief_path} — read it first.
Read local files only inside the repo under exploration, plus paths this prompt gives you (the brief, the plan, and templates it names).
@ -986,9 +970,7 @@ Both reviewers are asked for `voyage:writing-criteria` and
writing-criteria rule in one sentence in its prompt, for the brief's criteria
it traces into `## Verification`.
Pass `model: {phase_signal_result.model}` at this spawn; without it (the
`--brief` branch, where the resolver does not run, or a resolver that printed
nothing) the agent's own `model:` applies.
This spawn passes no `model:`: the agent's own `model: opus` applies.
**plan-critic** — adversarial review of the plan.
Prompt: "Review this implementation plan for the task: {task}.
@ -1301,97 +1283,6 @@ one step helper per step and the review.
There is no team path. Print the same `/trekexecute` line; parallel work is `--decompose` followed by `/trekexecute`'s session orders.
## Profile (v4.1)
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`, `fable`,
or a custom profile under `voyage-profiles/`. Default: `premium`.
Resolution order (per `lib/profiles/resolver.mjs`):
1. `--profile` flag (source: `flag`)
2. `VOYAGE_PROFILE` env-var (source: `env`)
3. `premium` default (source: `default`)
The selected profile drives `phase_models.plan` (model used for the planning
LLM), `parallel_agents_min`/`max` (the exploration swarm size), and is
recorded in `plan.md` frontmatter so `/trekexecute` and `/trekcontinue` can
inherit it across the pipeline.
Examples:
```
/trekplan --profile economy --project .claude/projects/2026-05-09-add-auth
/trekplan --profile balanced --brief brief.md
VOYAGE_PROFILE=balanced /trekplan --project ...
```
## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
effort + model as:
```
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
```
The brief signal wins per-phase when present; the profile fills any
gaps. Both fields are mechanically resolved by the single composed CLI
`node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model`
invoked in Phase 1; the resolved JSON is `{model, source}`, plus
`effort` only when the brief's phase signal sets one (without it the
phase runs at effort `standard`: no change). It is captured as
`phase_signal_result` and passed to `Agent` tool calls explicitly. The
resolver controls the `model` parameter at Agent-spawn sites only — the
orchestrator's own model is fixed at invocation time (command frontmatter
omits `model:`, so it follows the session model) and cannot be switched
mid-turn. Sub-agents fall back to `model:` in their own `agents/*.md`
frontmatter when no spawn-site injection happens.
For `/trekplan` specifically: `effort == 'low'` activates the existing
`--quick`-equivalent code-path (skip Phase 5 agent swarm — plan directly
without exploration agents). `effort == 'standard'` (or absent) → no
change. `effort == 'high'` activates the high-effort behavior documented
under `### High-effort behavior (v5.1.1)` below.
### Sequencing gate surface
Phase 1 already calls `brief-validator.mjs --soft`. If the validator
returns `BRIEF_V51_MISSING_SIGNALS` in `errors` (brief_version ≥ 2.1
without `phase_signals` or `phase_signals_partial: true`), halt with a
one-line message: `Brief is brief_version 2.1 but does not carry phase_signals
— re-run /trekbrief to commit them (Phase 3.5).` Enforcement is
validator-only; this surface just makes the friendly hint readable.
### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `plan` phase, the
additional adversarial Pass 2 on the post-revision plan is
**unavailable, skipped**. Append the section header to plan.md before
the trailing JSON block with exactly that status, so a reader can tell
a deliberately dropped pass from one that failed:
```
## Adversarial Pass 2 (v5.1.1 high-effort)
**Status:** unavailable, skipped — the third-party bridge agent that
ran this pass was dropped in v5.10.1 by operator directive. No
substitute is wired. Phase 9 (plan-critic + scope-guardian) is the
complete adversarial coverage for this plan.
```
Do NOT fail, retry, or silently omit the section: a high-effort plan
that carries no Pass 2 marker is indistinguishable from one whose Pass
2 crashed.
History (why the slot exists): Decision B, 2026-05-14, substituted a
bridge pass for an "extra plan-critic-iterasjon" because plan-critic
dedup keys on `(file, line, rule_key)` triplets and post-revision line
numbers shift, risking a revision-loop. Dropping the bridge does not
revive that option — re-running plan-critic carries the same loop risk
it always did.
Standard and low effort: do NOT emit the section at all.
## Hard rules
- **Brief-driven**: Every plan decision must trace back to a section of the
@ -1404,11 +1295,6 @@ Standard and low effort: do NOT emit the section at all.
own requirements.
- **Scope**: Only explore the current working directory and its subdirectories.
Never read files outside the repo (no ~/.env, no credentials, no other repos).
- **Cost**: Model resolution at Agent-spawn sites is a three-layer fallback:
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >
agent frontmatter `model:`. The composed resolver returns the first two
layers as `phase_signal_result.model`; spawn sites inject it, and agent
frontmatter is the fallback when no injection happens.
- **Privacy**: Never log, store, or repeat file contents that look like
secrets, tokens, or credentials. Never log prompt text.
- **No premature execution**: Do not modify any project files until the user

View file

@ -54,22 +54,12 @@ Supported flags:
Create `{dir}/research/` if it does not already exist.
When `{dir}/brief.md` exists, ALWAYS run the brief-validator (soft mode)
AND the composed phase-model resolver for this command's phase before
continuing. The resolver's JSON output (brief signal > profile > default)
is `{model, source}`, plus
`effort` only when the brief's phase signal sets one (without it the
phase runs at effort `standard`: no change). It is captured as
`phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject
the resolved model:
before continuing:
```bash
# When --min-brief-version was passed, append --min-version {min_brief_version}
# so an older brief raises BRIEF_VERSION_BELOW_MINIMUM (warn, never block).
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json [--min-version {min_brief_version}] "{dir}/brief.md"
# v5.9 — composed resolver: ONE call returns {model, source}, plus
# effort only when the brief's phase signal sets one (without it: standard).
# Append --profile {profile} when the operator passed --profile.
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase research --brief-path "{dir}/brief.md" [--profile {profile}] --json
```
6. `--gates` — autonomy control. `--gates` takes the next token as its
@ -280,6 +270,11 @@ Skip to Phase 8 after writing the brief.
**Determine which agents to launch based on scope:**
Every run launches the FULL swarm for its scope: all 5 local agents and all 4
external agents, and `contrarian-researcher` runs every time, as wave 2 (see
Launch rules), so the final brief always carries an adversarial
counter-evidence pass.
### Local agents (scope = both or local)
Reuse existing plugin agents with research-focused prompts. These agents are
@ -336,7 +331,7 @@ ticket IDs) already removed: the agents search with the words they are given.
- Launch in two waves, both in Phase 4.
- **Wave 1:** ALL selected agents except `contrarian-researcher` **in parallel**
in a single message.
- **Wave 2:** `contrarian-researcher`, when selected, after wave 1 returns, in a
- **Wave 2:** `contrarian-researcher`, after wave 1 returns, in a
message of its own. Give it a conclusion to attack: the leading answer (one or
two sentences), the claims for it and the alternatives wave 1 dismissed,
taken from wave 1's external findings only, never from the local agents'
@ -346,9 +341,8 @@ ticket IDs) already removed: the agents search with the words they are given.
local file name or project term may be in them. When wave 1's external
findings hold no leading answer, say so and give it the research question
alone, stripped the same way.
- Inject the resolved model at every spawn: `phase_signal_result.model` (brief
signal > profile), with the agent's frontmatter `model:` as the fallback — see
Hard rules → Cost. The orchestrator itself follows the session model.
- Pass no model at a Voyage agent's spawn: the agent's own frontmatter
`model: opus` applies. The orchestrator itself follows the session model.
- **Never pass the Agent tool's `name` parameter — at this or any other spawn
site in this command.** `name` does not label a subagent, it changes its
kind: the spawn is recorded as `taskKind: "in_process_teammate"`
@ -361,17 +355,16 @@ ticket IDs) already removed: the agents search with the words they are given.
Measured 2026-08-17: named 0/5 returned, unnamed 3/3. Mechanism,
denominators, and the on-disk recovery path:
`docs/agent-return-channel-defect.md`.
- convention-scanner: medium+ codebases only (50+ files)
## Phase 5 — Targeted follow-ups
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: phase_signal_result.model, else opus — a
follow-up may be one of Claude Code's built-in agents, which have no Voyage
frontmatter to fall back to; see Launch rules) with a narrow, specific
brief; a follow-up that searches the web names the `researching-sources` skill
For each significant gap, launch a targeted follow-up agent with a narrow,
specific brief. A follow-up may be one of Claude Code's built-in agents:
Spawn it with `model: "opus"`: a built-in agent has no Voyage pin.
A follow-up that searches the web names the `researching-sources` skill
in its prompt, as above. Maximum 2 follow-ups. If no gaps exist, skip: "Initial research
sufficient — no follow-ups needed." Then go to Phase 6.
@ -456,86 +449,6 @@ You can:
- Ask follow-up questions about specific findings
```
## Profile (v4.1)
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`, `fable`,
or a custom profile under `voyage-profiles/`. Default: `premium`.
Resolution order (per `lib/profiles/resolver.mjs`):
1. `--profile` flag (source: `flag`)
2. `VOYAGE_PROFILE` env-var (source: `env`)
3. `premium` default (source: `default`)
The profile dictates `phase_models.research`, `parallel_agents`, and
`external_research_enabled`. `economy` and `balanced` profiles auto-disable
the external research swarm regardless of `--external` flag (operator
override deferred to v4.2).
Examples:
```
/trekresearch --profile economy --project .claude/projects/2026-05-09-add-auth
VOYAGE_PROFILE=balanced /trekresearch
```
## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
effort + model as:
```
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
```
The brief signal wins per-phase when present; the profile fills any
gaps. Both fields are mechanically resolved by the single composed CLI
`node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model`
invoked in Phase 1; the resolved JSON is `{model, source}`, plus
`effort` only when the brief's phase signal sets one (without it the
phase runs at effort `standard`: no change). It is captured as
`phase_signal_result` and passed to `Agent` tool calls explicitly. The
resolver controls the `model` parameter at Agent-spawn sites only — the
orchestrator's own model is fixed at invocation time (command frontmatter
omits `model:`, so it follows the session model) and cannot be switched
mid-turn. Sub-agents fall back to `model:` in their own `agents/*.md`
frontmatter when no spawn-site injection happens.
For `/trekresearch` specifically: `effort == 'low'` activates the
existing `--quick`-equivalent code-path (inline research, no agent swarm).
`effort == 'standard'` (or absent) → no change. `effort == 'high'`
activates the high-effort behavior documented under `### High-effort
behavior (v5.1.1)` below.
### Sequencing gate surface
When `/trekresearch --project <dir>` is invoked and `{dir}/brief.md`
exists, ALWAYS run `brief-validator.mjs --soft --json` against it.
If `BRIEF_V51_MISSING_SIGNALS` appears in `errors` (brief_version ≥ 2.1
without `phase_signals` or `phase_signals_partial: true`), halt with:
`Brief is brief_version 2.1 but does not carry phase_signals — re-run
/trekbrief to commit them (Phase 3.5).` Enforcement is validator-only;
commands surface, don't re-enforce.
### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `research` phase,
run the FULL swarm regardless of normal selection rules: 5 local
agents + 4 external agents, AND force `contrarian-researcher` to
always-on: in high-effort mode `contrarian-researcher` runs
unconditionally, as wave 2 (see Launch rules), so the final brief
always carries an adversarial counter-evidence pass.
The former third-party second-opinion bridge pass is **unavailable,
skipped** (dropped in v5.10.1 by operator directive). High effort no
longer has a bridge arm.
Standard effort (or absent): the selected agents run as in Phase 4, and
`contrarian-researcher`, when selected, runs as wave 2 after the other
agents return (see Launch rules).
Low effort: inline research only, no agent swarm (existing
`--quick`-equivalent code-path).
## Hard rules
- **No planning:** This command produces research briefs, not implementation plans.
@ -546,11 +459,6 @@ Low effort: inline research only, no agent swarm (existing
Triangulate AFTER independent research.
- **Graceful degradation:** If MCP tools are unavailable (Tavily, MS Learn),
proceed with available tools and note limitations in brief metadata.
- **Cost:** Model resolution at Agent-spawn sites is a three-layer fallback:
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >
agent frontmatter `model:`. The composed resolver returns the first two
layers as `phase_signal_result.model`; spawn sites inject it, and agent
frontmatter is the fallback when no injection happens.
- **Privacy:** Never log secrets, tokens, or credentials.
- **Honesty:** If the question is trivially answerable, say so. Don't inflate research.
- **Scope of codebase:** Only analyze the current working directory for local research.

View file

@ -98,15 +98,6 @@ as the file is parseable:
```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{brief_path}"
# v5.9 — composed phase-model resolution (brief > profile > default) for the
# review phase. ONE call returns {model, source}, plus
# effort only when the brief's phase signal sets one; without it the phase
# runs at effort `standard` (no change). Captured as
# phase_signal_result and used in Phase 7 at the reviewer-launch site to
# inject the resolved model. Append --profile {profile} when the operator
# passed --profile.
node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase review --brief-path "{brief_path}" [--profile {profile}] --json
```
Read the JSON output. If `valid: false` AND any error has code
@ -568,9 +559,7 @@ The coordinator runs the 4-pass process documented in
`rule_key` is not in `RULE_CATALOGUE` (`unknown-rule_key`) or whose file
cannot be checked (`file-existence:indeterminate`) to `unverified`, which
forbids `ALLOW`; reset a severity that does not match its catalogue tier.
Skipped at high effort too, where an unknown `rule_key` is normalized to
`PLAN_EXECUTE_DRIFT` instead (`### High-effort behavior (v5.1.1)` below);
the brief-conflict rule still applies in every mode.
The brief-conflict rule still applies in every mode.
4. **Verdict** — BLOCK / WARN / ALLOW per the threshold table.
**Fail-closed.** Every removal in Pass 2 and Pass 3 is either
@ -931,85 +920,6 @@ Per **Handover 6**, BLOCKER and MAJOR findings are consumed by
review's frontmatter `findings:` list and the trailing JSON block are
the contract for that handover (see `docs/HANDOVER-CONTRACTS.md`).
## Profile (v4.1)
Accepts `--profile <name>` where `<name>` is `economy`, `balanced`, `premium`, `fable`,
or a custom profile under `voyage-profiles/`. Default: `premium`.
Resolution order (per `lib/profiles/resolver.mjs`):
1. `--profile` flag (source: `flag`)
2. `VOYAGE_PROFILE` env-var (source: `env`)
3. `premium` default (source: `default`)
The selected profile drives `phase_models.review` — `economy` uses sonnet
for the brief-conformance + code-correctness reviewers; `balanced` and
`premium` use opus (review benefits from deeper reasoning).
Examples:
```
/trekreview --profile balanced --project .claude/projects/2026-05-09-add-auth
VOYAGE_PROFILE=premium /trekreview --project ...
```
## Composition rule (v5.1)
Independent of the profile system. When `brief.md` carries
`phase_signals` (brief_version ≥ 2.1), each downstream phase resolves
effort + model as:
```
effort_for_phase = brief.phase_signals[<phase>]?.effort ?? 'standard'
model_for_phase = brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]
```
The brief signal wins per-phase when present; the profile fills any
gaps. Both fields are mechanically resolved by the single composed CLI
`node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model`
invoked in Phase 2; the resolved JSON is `{model, source}`, plus
`effort` only when the brief's phase signal sets one (without it the
phase runs at effort `standard`: no change). It is captured as
`phase_signal_result` and passed to `Agent` tool calls explicitly. The
resolver controls the `model` parameter at Agent-spawn sites only — the
orchestrator's own model is fixed at invocation time (command frontmatter
omits `model:`, so it follows the session model) and cannot be switched
mid-turn. Sub-agents fall back to `model:` in their own `agents/*.md`
frontmatter when no spawn-site injection happens.
For `/trekreview` specifically: `effort == 'low'` activates the existing
`--quick`-equivalent code-path (skip the brief-conformance reviewer; run
correctness-only). `effort == 'standard'` (or absent) → no change.
`effort == 'high'` activates the high-effort behavior documented under
`### High-effort behavior (v5.1.1)` below.
### Sequencing gate surface
Phase 1 already calls `brief-validator.mjs --soft` against `{brief_path}`.
If the validator returns `BRIEF_V51_MISSING_SIGNALS` in `errors`
(brief_version ≥ 2.1 without `phase_signals` or `phase_signals_partial:
true`), halt with: `Brief is brief_version 2.1 but does not carry
phase_signals — re-run /trekbrief to commit them (Phase 3.5).`
Enforcement is validator-only; this surface just makes the friendly hint
readable.
### High-effort behavior (v5.1.1)
When `phase_signal_result.effort == 'high'` for the `review` phase,
skip Pass 3 (Cloudflare reasonableness filter) in `agents/review-coordinator.md`, except the brief-conflict rule, which applies in every mode.
Passes 1, 2, and 4 still run.
Rationale: high-effort review trusts the operator to weigh borderline
findings rather than have the coordinator drop them. To prevent
unknown `rule_key` values from polluting downstream remediation plans
(Handover 6), the coordinator applies its v5.1.1 high-effort
normalization rule — substituting unknown `rule_key` values with the
literal string `PLAN_EXECUTE_DRIFT` (the most general drift category
in the catalogue) and preserving the original in
`original_rule_key`. See `agents/review-coordinator.md` § Pass 3
"High-effort normalization (v5.1.1)" for the full normalization spec.
Standard effort (or absent): run all 4 passes as usual.
Low effort: skip the brief-conformance reviewer entirely (existing
`--quick`-equivalent code-path).
## Hard rules
- **Brief is the contract.** Every finding in the review traces to a
@ -1030,11 +940,6 @@ Low effort: skip the brief-conformance reviewer entirely (existing
`findings:\n - a\n - b`.
- **Refuse-with-suggestion above 100 files / 100K tokens.** Never run
blind on a giant diff. Use AskUserQuestion to surface the gate.
- **Cost.** Model resolution at Agent-spawn sites is a three-layer fallback:
brief `phase_signals[<phase>].model` > `profile.phase_models[<phase>]` >
agent frontmatter `model:`. The composed resolver returns the first two
layers as `phase_signal_result.model`; spawn sites inject it, and agent
frontmatter is the fallback when no injection happens.
- **Privacy.** Never log secrets, tokens, or credentials in review.md.
Findings citing files with secret-like content must redact the secret
in the `detail` field.

View file

@ -71,8 +71,6 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
| `interview_turns` | number | optional | ≥ 0 | |
| `source` | string | optional | `interview \| manual` | |
| `brief_quality` | string | optional | `complete \| partial` | Set when iteration cap is hit |
| `phase_signals` | list | optional (v5.1+) | list of `{phase, effort?, model?}` entries | Per-phase effort + model commitment from Phase 3.5. Mutually exclusive with `phase_signals_partial`. |
| `phase_signals_partial` | bool | optional (v5.1+) | `true` | Force-stop record from Phase 3.5. Mutually exclusive with `phase_signals`. |
| `framing` | string | **required at ≥ 2.2** (v5.5) | `preserve \| refine \| replace \| new-direction` | How this brief relates to prior operator intent. Enum-checked on any version when present; missing → `BRIEF_MISSING_FRAMING` at `brief_version ≥ 2.2`. Layer 1 of the framing-alignment defense. |
| `intent_approved_hash` | string | optional in the schema; **required by `/trekplan`** (veikart steg 1) | `"sha256:<64 hex>"` | Hash over the normalized `## Intent` + `## Goal`. Written only by `lib/validators/intent-approval.mjs --stamp`, which `/trekbrief` runs on the operator's explicit "Approve" (Phase 4h, or `--approve <project-dir \| brief-file>`). |
| `intent_approved_at` | string | optional | ISO-8601 | When the stamp was written. Informational; the gate reads the hash. |
@ -80,6 +78,8 @@ Every validator exposes a CLI: `node lib/validators/<name>.mjs --json <path>` re
| `ceremony` | string | optional (K1) | `small \| bounded \| architectural` | The brief's size class: sets `/trekbrief`'s question budget and review cap, and what downstream phases scale to. **Absent (or unreadable) = `architectural`**, the heaviest, so another producer's brief is never treated lighter by accident. Enum-checked when present (`BRIEF_INVALID_CEREMONY`). Read it through `briefCeremony(fm)` in `brief-validator.mjs`. |
| `artifact_store` | string | optional | `git \| local` | Whether the brief and plan are committed to the target repo (`git`) or kept out of git through `.git/info/exclude` (`local`). The approval log is committed either way. **Absent = `local`**: nothing is published without the operator's yes. Enum-checked when present (`BRIEF_INVALID_ARTIFACT_STORE`). |
An older brief's `phase_signals` key is ignored.
**Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time):
- `## TL;DR` — **required at `brief_version ≥ 2.2`** (v5.5); ≤ 5 content lines (soft cap → `BRIEF_TLDR_TOO_LONG` warning). Layer 3 of the framing-alignment defense.
- `## Intent`
@ -97,16 +97,15 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
| Type discriminator | every read | `type === "trekbrief"` |
| Status enum | every read | `research_status ∈ allowed values` |
| **State machine** | every read | `research_topics > 0 && research_status === "skipped"` requires `brief_quality === "partial"` |
| **v5.1 sequencing gate** | every read | `brief_version ≥ 2.1` requires `phase_signals` (list) OR `phase_signals_partial: true` — error `BRIEF_V51_MISSING_SIGNALS` on miss. Validator-only enforcement; commands surface, don't re-enforce. |
| **v5.5 framing gate** | every read | `framing` enum-checked on any version when present (`BRIEF_INVALID_FRAMING`); at `brief_version ≥ 2.2` a missing `framing` → `BRIEF_MISSING_FRAMING` and a missing `## TL;DR` → `BRIEF_MISSING_SECTION` (strict) / warning (soft). `trekreview` briefs are exempt. |
| Body sections | strict only | All `BRIEF_BODY_SECTIONS` present (`## TL;DR` added at ≥ 2.2) |
| Runnable criteria | every read | A `## Success Criteria` bullet whose first backticked span is a command the review's criteria runner never runs (the product's own binary, `jq`, `grep`, a pipe), or whose first span is not a command while a later span is one (the runner reads only the first, voyage-lekerepo trial 5) → warning `BRIEF_SC_NOT_RUNNABLE`, never an error: a criterion a human judges stays allowed. Prose-only criteria, also those that only name a flag or a path, are not flagged. `trekreview` briefs are exempt. No schema change. Indented sub-bullets belong to the criterion above them (counted, never criteria of their own); a criterion with sub-points and no command → warning `BRIEF_SC_SUBPOINTS`. A brief whose Constraints or NFRs require bash 3.2 and a criterion that runs plain `bash` → warning `BRIEF_SC_BASH_NOT_PINNED` (use `/bin/bash`). |
**State machine** detail: a brief that says it has research topics but skipped them must explicitly admit it (via `brief_quality: partial`). This is the most common failure mode the validator catches.
**Versioning:** the `2.1` schema axis (v5.1 — adds optional `phase_signals` + `phase_signals_partial`; current is `2.2`, see the next paragraph). The forward-compat policy in `brief-validator.mjs` header still applies: unknown frontmatter keys flow through silently, so a `2.1` brief still validates against pre-v5.1 consumers. The version bump exists because v2.1 activates the **version-conditional sequencing gate** (above) — the only check in the validator that triggers on `brief_version` rather than field-presence. There are no live `1.x` briefs; remove legacy paths in next major. The v5.5.0 contract formalization **established `2.1` as the public-contract baseline** (see the PUBLIC CONTRACT callout under the Handover 1 heading): per the S3 effort-axis decision, `phase_signals` stays **optional**, and the v2.1 sequencing gate (`phase_signals` **or** `phase_signals_partial`) is the stability mechanism. Promoting `phase_signals` to required would be a future breaking change under the protocol above — explicitly *not* part of the v5.5.0 formalization.
**Versioning:** current is `2.2`, see the next paragraph. The forward-compat policy in `brief-validator.mjs` header still applies: unknown frontmatter keys flow through silently. There are no live `1.x` briefs; remove legacy paths in next major. The v5.5.0 contract formalization **established `2.1` as the public-contract baseline** (see the PUBLIC CONTRACT callout under the Handover 1 heading).
**v5.5 → `2.2` (framing enforcement).** `2.2` adds two **required-at-2.2** elements — the `framing` enum field and the `## TL;DR` body section — gated identically to the v2.1 mechanism: the new requirements fire only on `brief_version ≥ 2.2`, so every existing `2.0` / `2.1` brief still validates (forward- and backward-compatible). Because adding required elements is a breaking change for any producer that declares `2.2`, this is a controlled version bump under the breaking-change protocol: downstream producers (e.g. a Tier-2 per-app producer) that emit `brief_version: "2.2"` MUST also emit `framing` + `## TL;DR`. The framing enum is additionally enforced on *any* version when the field is present (`BRIEF_INVALID_FRAMING`). The three framing-alignment layers (framing field, `brief-reviewer` memory-alignment dimension, obligatory TL;DR) implement the `CLAUDE.md` cross-cutting invariant. Note: the *plugin* version bump + CHANGELOG entry for this schema change land at the coordinated release (matrix §S10), separate from this schema axis.
**v5.5 → `2.2` (framing enforcement).** `2.2` adds two **required-at-2.2** elements — the `framing` enum field and the `## TL;DR` body section — gated on `brief_version`: the new requirements fire only on `brief_version ≥ 2.2`, so every existing `2.0` / `2.1` brief still validates (forward- and backward-compatible). Because adding required elements is a breaking change for any producer that declares `2.2`, this is a controlled version bump under the breaking-change protocol: downstream producers (e.g. a Tier-2 per-app producer) that emit `brief_version: "2.2"` MUST also emit `framing` + `## TL;DR`. The framing enum is additionally enforced on *any* version when the field is present (`BRIEF_INVALID_FRAMING`). The three framing-alignment layers (framing field, `brief-reviewer` memory-alignment dimension, obligatory TL;DR) implement the `CLAUDE.md` cross-cutting invariant. Note: the *plugin* version bump + CHANGELOG entry for this schema change land at the coordinated release (matrix §S10), separate from this schema axis.
**Pre-2.2 briefs receive zero framing enforcement (producer-elective defense).** The framing-alignment defense is gated at `brief_version ≥ 2.2`, so a brief declaring `2.0` or `2.1` gets **none** of it: `framing` is optional and a missing value is not an error, the `## TL;DR` section is not required, and the `brief-reviewer` memory-alignment dimension scores N/A. The backward-compatibility this buys is deliberate, but it also means the framing defense is **producer-elective**: any producer can sidestep the entire defense — deliberately or by shipping a legacy template — simply by declaring `brief_version: "2.1"`, and the pipeline raises no signal by default. This is a documented property of the contract, not a bug. Two remedies close it for a given consumer: (1) require upstream producers to emit `brief_version: "2.2"` (which forces `framing` + `## TL;DR`); or (2) pass the opt-in **`--min-brief-version <ver>`** flag to `/trekplan` or `/trekresearch` (forwarded to the validator as `--min-version`), which raises a `BRIEF_VERSION_BELOW_MINIMUM` **warning** — never a block — when a brief declares a version below the floor. The flag defaults to off, so the N-1 compatibility window for `2.0`/`2.1` briefs is preserved unless a consumer explicitly opts into the stricter floor.
@ -124,24 +123,10 @@ Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preference
- `BRIEF_MISSING_FIELD` → strict halt; soft-mode warning
- `BRIEF_STATE_INCOHERENT` → strict halt; soft-mode warning (incoherence will haunt downstream agents)
- `BRIEF_MISSING_SECTION` → strict halt; soft-mode warning
- `BRIEF_V51_MISSING_SIGNALS` → strict halt (v5.1+ sequencing gate); soft-mode warning. Commands surface a friendly hint pointing back to `/trekbrief` (Phase 3.5).
- `BRIEF_INVALID_PHASE_SIGNALS` → strict halt; phase_signals must be a list of `{phase, effort?, model?}` entries.
- `BRIEF_INVALID_PHASE_SIGNAL_PHASE` → strict halt; phase ∉ `[research, plan, execute, review]`.
- `BRIEF_INVALID_EFFORT` → strict halt; effort ∉ `[low, standard, high]`.
- `BRIEF_INVALID_MODEL` → strict halt; model ∉ `BASE_ALLOWED_MODELS` (currently `[sonnet, opus, fable]`).
- `BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE` → strict halt; cannot set both `phase_signals` and `phase_signals_partial: true`.
- `BRIEF_SC_NOT_RUNNABLE` → warning in both modes; `/trekbrief` names each one and offers a script under `tests/` the criterion runs (`bash tests/<script>.sh`).
- `BRIEF_SC_SUBPOINTS` → warning in both modes; `/trekbrief` offers one script that checks all sub-points, or one criterion per sub-point.
- `BRIEF_SC_BASH_NOT_PINNED` → warning in both modes; `/trekbrief` offers `/bin/bash tests/<script>.sh` so a bash 3.2 requirement is measured under the system bash.
**Compatibility direction of the v5.9 allowlist widening (`fable`):** enum
widening is safe for new readers of old data, not old readers of new data.
Existing sonnet/opus briefs stay valid under the v5.9+ validator (non-breaking,
no `brief_version` bump — value-space extension, not a schema change). The
reverse does NOT hold: a fable-bearing brief REQUIRES a v5.9+ validator — an
older cached brief-validator (e.g. a stale v5.8 marketplace clone) rejects it
with `BRIEF_INVALID_MODEL`.
---
## Handover 2 — research/*.md → plan

View file

@ -34,9 +34,7 @@ Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table co
## Architecture
**Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 3.5 per-phase effort dialog (v5.1) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0).
**Phase 3.5 (v5.1) — adaptive-depth signals:** Between Phase 3 completeness exit and Phase 4 draft, the operator commits an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus | fable`) per downstream phase (`research`, `plan`, `execute`, `review`) via 4 tier-coupled `AskUserQuestion` calls. The choices land in `brief.md` frontmatter as `phase_signals:` (a list of `{phase, effort?, model?}` entries) when committed, or `phase_signals_partial: true` when the operator force-stops. `brief_version: 2.1` activates the **sequencing gate**: validator emits `BRIEF_V51_MISSING_SIGNALS` if a 2.1-versioned brief lacks both fields. Downstream commands surface a friendly hint pointing back to `/trekbrief` — enforcement is validator-only. Composition is documented prose in each downstream command's `## Composition rule (v5.1)` section: `brief.phase_signals[phase] > profile.phase_models[phase]`. The brief signal wins per-phase when present; the profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low effort = one session at a time). High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
**Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0).
**Research:** Foreground workflow (v2.4.0): Parse mode → Interview → Parallel research swarm (5 local + 4 external + 1 bridge, spawned from main context) → Follow-ups → Triangulation → Synthesis + brief. With `--project`, writes to `{dir}/research/NN-slug.md`.
@ -97,6 +95,47 @@ Which native Claude Code primitive each pipeline step runs on today, and the alt
**Legend:** *production* = wired and active · *not wired* = considered, deferred (delegated orchestrator, the `AskUserQuestion` brief engine).
## Model & effort
`opus`, `sonnet`, and `fable` are model **aliases**, not pinned ids. On the
Anthropic API (verified 2026-10-02 against the Claude Code model-config docs)
the `opus` alias resolves to **Opus 5.5** and `sonnet` to **Sonnet 5.5**, both
with default reasoning effort **`medium`**; `fable` resolves to **Fable 5.1**
(the most capable model in Claude Code), whose default reasoning effort is
**`high`**. Other providers resolve `opus` and `sonnet` to other versions.
**Reasoning effort inherits from the session.** Fable 5.1's default reasoning
effort is `high`, NOT xhigh, and Claude Code saves effort per model, so a level
saved for one model does not carry to another. To run Fable 5.1 at xhigh,
choose it explicitly: `/effort xhigh`, `--effort xhigh`, or
`CLAUDE_CODE_EFFORT_LEVEL`.
**Native `effort:` on agents.** Voyage sets the reasoning axis statically on
selected agents, overriding the session level:
- **Nine agents pin `high`:** the retrieval agents `task-finder`,
`git-historian`, `dependency-tracer`, `architecture-mapper`, the
adversarial-reasoning agents `plan-critic`, `risk-assessor`,
`contrarian-researcher`, `review-coordinator`, and `/trekexecute`'s step
helper `step-implementer`. No agent pins below `high`
(operator rule, 2026-10-05: standard is Opus 5.5 at effort High).
- **All other agents:** unset → inherits the session's effort level (a session
that nothing sets runs at its model's default — `medium` on Opus 5.5).
**Every agent is pinned `model: opus`.** The pin is set in each agent's
frontmatter (operator decision, commit `40d8742`). The operator wants Opus on
all Voyage work and accepted the roughly fivefold sub-agent cost against
Sonnet. The glue, mechanical, retrieval and dormant roles were reconsidered
for a Sonnet downgrade (operator, 2026-06-20) and kept on Opus: the pin stays
firm.
Native-effort precedence (harness): env `CLAUDE_CODE_EFFORT_LEVEL` > frontmatter
`effort:` > session setting > model default, and a `maxEffortLevel` setting or
an organization effort cap still limits the result. `MAX_THINKING_TOKENS=0` has
no effect on Opus 5.5, Sonnet 5.5 or the Fable models: thinking cannot be turned
off there. `availableModels` restricts which models can be selected — it does
**not** bound effort.
## State
All artifacts in one project directory (default):

View file

@ -9,7 +9,6 @@ Per-command flag tables, imported from `CLAUDE.md` via pointer.
| _(default)_ | Question round sized by class (`small` / `bounded` / `architectural`), brief-review gate, approval → brief.md with research plan |
| `--quick` | Proposes `small` as the class (still said aloud, overridable, raised when the task proves bigger) → brief.md with research plan |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile: `economy` / `balanced` / `premium` / `fable` / `<custom>`. Sets `phase_models` for the brief phase. See `## Profile system` in `docs/operations.md`. |
| `--approve <project-dir \| brief-file>` | (veikart steg 1) No interview: show the existing brief's `## Intent` + `## Goal` verbatim, ask the operator to approve, and on "Approve" stamp `intent_approved_hash` + `intent_approved_at`. The remedy `/trekplan` names when it halts on `BRIEF_INTENT_NOT_APPROVED` / `BRIEF_INTENT_APPROVAL_STALE`. |
Always interactive. Phase 3 puts the task in a size class (`ceremony:`, K1; heaviest in doubt, said aloud, overridable, only moved up) and asks one round of questions within the class budget (`small` 4, `bounded` 8, `architectural` 12 per round), recommendation first, facts looked up instead of asked, a visible Deferred list; it also asks where the brief is stored (`artifact_store: git | local`). Phase 4 runs a `brief-reviewer` stop-gate with 1 / 2 / 3 review iterations by class. Phase 4h takes only an explicit "Approve" («høres bra ut» and an empty answer are refused by `lib/util/approval-log.mjs answer`), stamps the brief, appends the approval to the target repo's log (`approval-log.mjs append`, K2) and commits it (`lib/util/artifact-commit.mjs`: the log always, the brief when `git`). After that, asks the user to choose manual (print commands) or auto (Claude runs research + plan in foreground).
@ -26,7 +25,6 @@ Always interactive. Phase 3 puts the task in a size class (`ceremony:`, K1; heav
| `--fg` | No-op alias (foreground is default since v2.4.0) |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--min-brief-version <ver>` | (S18) Warn — never block — if an attached `--project` brief declares a version below `<ver>` (e.g. `2.2`), i.e. sidesteps framing enforcement |
| `--profile <name>` | (v4.1.0) Model profile for the research phase. |
Flags combine: `--project <dir> --local`, `--external --quick`.
@ -43,7 +41,6 @@ Flags combine: `--project <dir> --local`, `--external --quick`.
| `--export headless <plan>` | Legacy alias for `--decompose` — the only remaining export format (the `pr` / `issue` / `markdown` variants were removed; Claude reformats a plan ad-hoc on request) |
| `--decompose <plan>` | Split plan into self-contained session specs + `orders.md` (one starter line per session) |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. |
| `--profile <name>` | (v4.1.0) Model profile for the plan phase (and others, since plan emits `profile:` to plan.md frontmatter). |
**Breaking change (v2.0):** one of `--brief` or `--project` is required. There is no interview inside `/trekplan`. The `--spec` flag has been removed — use `/trekbrief` to produce a brief instead.
@ -64,7 +61,6 @@ If `{project_dir}/architecture/overview.md` exists (typically produced by an opt
| `--fg` | Force foreground — run all steps sequentially, ignore Execution Strategy |
| `--session N` | Execute only session N from plan's Execution Strategy |
| `--gates [true\|false\|closed\|open]` | (v3.4.0) Autonomy flag: bare, `true` or `closed` = on; `false` or `open` = off; any other next token is not its value. See `## Autonomy mode` in `docs/operations.md`. `/trekexecute` accepts the flag and pauses on nothing; it adds no question of its own before a merge: the guard asks there (operator decision 2026-10-08). |
| `--profile <name>` | (v4.1.0) Profile for this run's resolver; the step helper and the controller stay on opus. Inherited from plan.md frontmatter `profile:` if present. |
Every mode that executes refuses a plan whose approval in the approval log does not cover its current sha256 (K2). A run ends with the review it starts by itself (`/simplify` → `/trekreview` → verdict gate); a BLOCK or an undecided verdict keeps the merge into main closed. `MAX_REVIEW_ROUNDS` in `lib/review/review-rounds.mjs` (3) caps the reviews per run; `/trekreview` refuses a fourth round.
@ -80,7 +76,6 @@ Every mode that executes refuses a plan whose approval in the approval log does
| `--validate` | Schema-only check on existing `{dir}/review.md` plus the verdict gate. No LLM calls. A 1.0 review without `verdict:` fails here (it still seeds a plan in soft mode) |
| `--dry-run` | Print discovered scope + triage map; skip writes |
| `--fg` | No-op alias (foreground is default) |
| `--profile <name>` | (v4.1.0) Model profile for the review phase. |
## /trekcontinue modes
@ -88,6 +83,5 @@ Every mode that executes refuses a plan whose approval in the approval log does
|------|----------|
| _(default)_ | Auto-discover active project's `.session-state.local.json` and resume |
| `<project-dir>` | Resume the next session of an explicit project directory |
| `--profile <name>` | (v4.1.0) Model profile for the resumed session. Inherited from the previous session's plan.md frontmatter when absent. |
The triage gate is deterministic — path-pattern classifier produces `{file → deep-review|summary-only|skip}`. Hard refuse-with-suggestion above 100 files / 100K diff tokens.

View file

@ -1,4 +1,4 @@
# Voyage — Autonomy gates, profile system, observability
# Voyage — Autonomy gates, observability
Imported from `CLAUDE.md` via pointer.
@ -22,30 +22,6 @@ Three architectural options were considered for the speedup work:
A revived Path C (post-v2.2.xxx) would require: (1) re-architecting tool-list to be identical across all wave children, (2) cache-telemetry analysis confirming the new fork-cache behaviour holds, (3) prompt-level deny re-enablement to compensate for tool scoping rollback.
## Profile system (`--profile`, v4.1.0)
Four built-in model profiles plus operator-defined `<custom>.yaml`. Each profile pins `phase_models` for the six pipeline phases (`brief`, `research`, `plan`, `execute`, `review`, `continue`). Profile is recorded in plan.md frontmatter as `profile: <name>`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------|
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | ⚠ **Experimental** (uncalibrated Jaccard floor) — lowest cost; high-confidence small-scope tasks (operator-opt-in via `--profile economy`) |
| `balanced` | sonnet | sonnet | opus | sonnet | opus | sonnet | Mixed — opus where reasoning depth pays off (operator-opt-in via `--profile balanced`) |
| `premium` (default) | opus | opus | opus | opus | opus | opus | Maximum quality — Opus on every phase. Default since 2026-05-13 operator request; also the hardcoded resolver default returned by `resolveProfile()` in `lib/profiles/resolver.mjs` |
| `fable` | fable | fable | fable | fable | fable | fable | Max quality — Fable 5.1 (the most capable model in Claude Code) on every phase (operator-opt-in via `--profile fable`); reasoning effort inherits from the session — see `docs/profiles.md` §Model & effort axes |
### Lookup order
1. Explicit `--profile <name>` flag passed to the command
2. Plan-file frontmatter `profile:` (when resuming via `/trekexecute --resume` or `/trekcontinue`)
3. `VOYAGE_PROFILE` environment variable
4. Default `premium`
### Custom profiles
Create `voyage-profiles/<custom>.yaml` in the repo root (or `~/.claude/voyage-profiles/<custom>.yaml`) to define a **new** tier — the name must not be a built-in. The validator (`lib/validators/profile-validator.mjs`) enforces: every `phase_models[].phase` must be a known phase enum; every `phase_models[].model` must exactly match an entry in `BASE_ALLOWED_MODELS` (`['sonnet', 'opus', 'fable']`; `haiku` only with `VOYAGE_ALLOW_HAIKU=1`). `findProfilePath` (`lib/profiles/resolver.mjs`) resolves **built-in first** (`lib/profiles/<name>.yaml` for `economy`/`balanced`/`premium`/`fable`), then repo-root `voyage-profiles/`, then `~/.claude/voyage-profiles/`. A custom file named after a built-in therefore **cannot** shadow it (custom profiles must use new names); for the same custom name, repo-root takes precedence over home.
Drift between plan-frontmatter `profile:` and step-manifest `profile_used:` emits a `MANIFEST_PROFILE_DRIFT` warning from `plan-validator --strict` (Step 20). Plan remains valid; the warning surfaces accidental tier-mismatch.
## Observability
Claude Code's own OpenTelemetry (`CLAUDE_CODE_ENABLE_TELEMETRY`) gives tool durations, tokens and cost per agent and skill: see [Monitoring usage](https://code.claude.com/docs/en/monitoring-usage) (read 2026-11-07, Claude Code 2.1.319).

View file

@ -1,6 +1,7 @@
# Prøvesett — Voyage's fixed test set as a plugin eval suite
**Status:** v2, eight cases. v1 (seven cases) first run **6 of 7**; v2 first run
**Status:** seven cases: v2's eight, less the sequencing-gate case (v1 case 4), which
went with the phase signals in cleanup R-b. v1 (seven cases) first run **6 of 7**; v2 first run
**8 of 8** (both 2026-09-23, Claude Code 2.1.280). Each version's cases and
expectations were committed *before* its first run; the run did not change them.
Red is data here, not a defect in the suite. What v2 changed and why: § v2 below.
@ -53,12 +54,12 @@ promises and ends in seconds. Nothing starts the `/trekplan` exploration swarm o
| `plan-requires-brief` | `/trekplan` without a brief prints usage and stops | "A brief is required"; no Agent, no Write | pass |
| `plan-project-not-initialized` | `/trekplan --project` on a missing folder stops before exploring | "project directory not initialized"; no Agent, no `plan.md` | pass |
| `plan-rejects-unknown-export` | a removed export format is named and refused | "format 'pr' is not supported"; no Agent, no Write | pass |
| `plan-halts-without-phase-signals` | a `brief_version` 2.1 brief without `phase_signals` halts at the sequencing gate | brief-validator runs, reply names `phase_signals`; no Agent, no `plan.md` | pass |
| `review-requires-project` | `/trekreview` without `--project` refuses | "--project <dir> is required"; no Agent, no Write | **fail** (2 of 3 graders) |
| `review-validate-flags-bad-finding-id` | **known-positive:** `--validate` fails a review whose finding ID is not 40-char hex, and names the rule | review-validator runs; `REVIEW_BAD_FINDING_ID` in the trace; reply says FAIL; no Agent, no Write | pass |
| `review-validate-passes-clean-review` | **known-negative:** `--validate` passes a valid review and raises nothing | review-validator runs; reply says PASS; no `REVIEW_*` error code in the trace; no Agent, no Write | pass |
The v1 table above is kept as it was run. v2 rows and grader changes: § v2.
The v1 table above is kept as it was run, less case 4 (the sequencing gate, pass),
removed in cleanup R-b with the phase signals it tested. v2 rows and grader changes: § v2.
### The one red case (v1; fixed in v2, see § v2)
@ -99,7 +100,7 @@ Expectations committed in `3892a83` before the v2 run. Offline structure tests i
| Change | Why |
|--------|-----|
| **New case `plan-halts-without-intent-approval`**: a `brief_version` 2.1 brief *with* `phase_signals` and no `intent_approved_hash`. Graders: `gate-ran` (`intent-approval.mjs --check` in a Bash call), `names-code` (the gate's own JSON `code` for `BRIEF_INTENT_NOT_APPROVED` in the trace), `no-agent`, `no-plan-file` | Veikart steg 1's gate had no case. No v1 trace reached it: case 4 halts at the sequencing gate first |
| **New case `plan-halts-without-intent-approval`**: a `brief_version` 2.1 brief with no `intent_approved_hash` (it carried phase signals until cleanup R-b). Graders: `gate-ran` (`intent-approval.mjs --check` in a Bash call), `names-code` (the gate's own JSON `code` for `BRIEF_INTENT_NOT_APPROVED` in the trace), `no-agent`, `no-plan-file` | Veikart steg 1's gate had no case. No v1 trace reached it: case 4 halts at the sequencing gate first |
| **`review-requires-project`**: the arg parser got a CLI that checks `--project` itself and prints `Error: --project <dir> is required.` + usage (exit 1, code `ARG_REQUIRED_MISSING`). `trekreview.md` runs it with `$ARGUMENTS` and relays its stderr. The reply-regex grader `project-required` is replaced by `parser-ran` + `names-missing-project` (the parser's code in the trace, the same form as `names-rule`) | The fix this doc recommended for the red case. Also: the prose passed `"$@"`, which the Bash tool never has. The parser never ran (0 Bash calls in the case-5 traces), so the model composed the line from prose. That failed 2 of 10 on backticks |
| `no-error-code` → `code\W{1,12}REVIEW_[A-Z_]+`, bound to the validator's JSON output | The listed codes missed `REVIEW_WRONG_TYPE` and `REVIEW_VERSION_FORMAT` |
| `says-pass` / `says-fail` reject a preceding `NOT` and a longer word | Raw substrings: "did not PASS" passed `says-pass` |
@ -265,7 +266,7 @@ From the plugin root:
claude plugin eval . --trust-plugin --ablation none --runs 1 --scaffold --no-publish --allow-tools Bash Write
```
- `--scaffold` runs each case's `scaffold.sh` as you, outside the sandbox. The four
- `--scaffold` runs each case's `scaffold.sh` as you, outside the sandbox. The three
scaffolds in this suite only write fixture files into the run's workspace.
- `--allow-tools Bash Write` grants the tools the commands need (Bash runs under the OS
sandbox). Write is granted so that the `no-write` and `no-plan-file` graders measure a
@ -291,4 +292,4 @@ will sometimes stop a change that is innocent. That is the intended price. Movin
into code removes the instability; editing the grader does not. v2 did the move: the
parser now writes the line, and the grader reads the parser's code. One green run does
not show the instability is gone. That takes more runs. § Stability has them:
`review-requires-project` 5 of 5, and case 4 15 of 15 after its own fix.
`review-requires-project` 5 of 5, and case 4 (removed in cleanup R-b) 15 of 15 after its own fix.

View file

@ -1,236 +0,0 @@
# Profile system — voyage v4.1
This document describes the model profile system: built-in tiers,
lookup precedence, custom-profile authoring, drift detection, and
cost estimation (with disclaimer).
## Built-in profiles
Four pre-defined tiers ship with the plugin (fable added in v5.9), located at
`lib/profiles/{economy,balanced,premium,fable}.yaml`.
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------|
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | ⚠ **Experimental** (uncalibrated Jaccard floor) — lowest cost; small-scope tasks where you have high confidence the brief is right |
| `balanced` | sonnet | sonnet | opus | sonnet | opus | sonnet | Mixed — opus where reasoning depth pays off (plan synthesis + adversarial review); opt-in via `--profile balanced` |
| `premium` (default) | opus | opus | opus | opus | opus | opus | Maximum quality — Opus on every phase + external research on (default since the 2026-05-13 operator decision) |
| `fable` | fable | fable | fable | fable | fable | fable | Max quality — Fable 5.1 (the most capable model in Claude Code) on every phase; opt-in via `--profile fable`; reasoning effort inherits from the session (see Model & effort axes) |
`premium` is the default tier — set by the 2026-05-13 operator decision and
matched by the hardcoded resolver default in `lib/profiles/resolver.mjs`. It
runs opus on every phase and turns external research on: maximum quality, at
roughly 5× the sub-agent cost of an all-sonnet run, accepted as a deliberate
trade-off. Drop to `--profile balanced` (opus only on the two phases where
quality matters most — Plan synthesis + Review — and sonnet everywhere else)
or `--profile economy` (sonnet everywhere) when cost or latency matters more
than depth. Step up to `--profile fable` (Fable 5.1 on every phase) when
maximum quality is wanted end-to-end and cost is not a constraint.
`economy` is *strictly experimental* in v4.1, and says so in the profile
data itself: `lib/profiles/economy.yaml` carries `experimental: true`. The
cross-tier Jaccard floor (0.55) is grounded in parked-synthetic fixtures, not
empirical runs (Step 17 calibration was deferred — see
`tests/synthetic/profile-jaccard-calibration.md`). The flag is pinned: it must
stay `true` while the calibration status is `parked-synthetic`, and must be
dropped in the same change that lands empirical calibration
(`status: empirical`). If you observe economy-plan quality regressions, fall
back to `balanced`.
## Model & effort axes
`opus`, `sonnet`, and `fable` are model **aliases**, not pinned ids. On the
Anthropic API (verified 2026-10-02 against the Claude Code model-config docs)
the `opus` alias resolves to **Opus 5.5** and `sonnet` to **Sonnet 5.5**, both
with default reasoning effort **`medium`**; `fable` resolves to **Fable 5.1**
(the most capable model in Claude Code), whose default reasoning effort is
**`high`**. Other providers resolve `opus` and `sonnet` to other versions. The
profile table above selects *which alias* runs each phase — it does not touch
reasoning effort.
**Reasoning effort inherits from the session.** Voyage effort (orchestration
shape — which agents/passes run) and model reasoning effort are different
axes. Fable 5.1's default reasoning effort is `high`, NOT xhigh, and Claude
Code saves effort per model, so a level saved for one model does not carry to
another. To run the fable tier at xhigh, choose it explicitly: `/effort xhigh`,
`--effort xhigh`, or `CLAUDE_CODE_EFFORT_LEVEL`.
Two different things share the word "effort" in Voyage. They are **orthogonal
axes** — same name, different mechanism:
| | **Orchestration effort** | **Reasoning effort (native)** |
|---|---|---|
| Where | brief `phase_signals.effort`, consumed by command prose | native Claude Code `effort:` in agent frontmatter |
| Values | `low` / `standard` / `high` | `low` / `medium` / `high` / `xhigh` / `max` |
| Controls | *which agents/passes/gates run* (swarm cardinality, gate strictness, sequential-vs-parallel) | *per-spawn reasoning-token budget* the harness gives one agent |
| `low` means | "run fewer agents" (the `--quick`-equivalent code-path) | "think with a smaller budget" |
| Applied by | Voyage command logic | the harness, at spawn time |
The `phase-signal-resolver.mjs` helper only reads the **orchestration** axis
(`phase_signals.effort`, gated against `low/standard/high`) plus the optional
per-phase `model` (gated against `['sonnet','opus','fable']`). It never emits
native `effort:`.
**Native `effort:` on agents.** Voyage sets the reasoning axis statically on
selected agents, overriding the session level:
- **Nine agents pin `high`:** the retrieval agents `task-finder`,
`git-historian`, `dependency-tracer`, `architecture-mapper`, the
adversarial-reasoning agents `plan-critic`, `risk-assessor`,
`contrarian-researcher`, `review-coordinator`, and `/trekexecute`'s step
helper `step-implementer`. No agent pins below `high`
(operator rule, 2026-10-05: standard is Opus 5.5 at effort High).
- **All other agents:** unset → inherits the session's effort level (a session
that nothing sets runs at its model's default — `medium` on Opus 5.5).
**Every agent is pinned `model: opus`.** The pin is set in each agent's
frontmatter (operator decision, commit `40d8742`), because the profile
resolver does not reach sub-agents: the profile picks the model for the
phases, the frontmatter for the agents. The operator wants Opus on all Voyage
work and accepted the roughly fivefold sub-agent cost against Sonnet. The
glue, mechanical, retrieval and dormant roles were reconsidered for a Sonnet
downgrade (operator, 2026-06-20) and kept on Opus: the pin stays firm.
Native-effort precedence (harness): env `CLAUDE_CODE_EFFORT_LEVEL` > frontmatter
`effort:` > session setting > model default, and a `maxEffortLevel` setting or
an organization effort cap still limits the result. `MAX_THINKING_TOKENS=0` has
no effect on Opus 5.5, Sonnet 5.5 or the Fable models: thinking cannot be turned
off there. `availableModels` restricts which models can be selected — it does
**not** bound effort.
## Decision tree
```
Are you uncertain whether the brief is correctly framed?
└── Yes → premium (opus on brief + plan + review)
└── No → continue
↓
Is the change small (≤ 5 steps in the plan)?
└── Yes → economy (sonnet everywhere)
└── No → balanced (opus on plan + review)
Special cases:
- Critical-infrastructure plan → premium
- Migration with rollback risk → premium
- Research-heavy task (≥ 4 dimensions) → balanced (research-stage benefits)
- Bug fix with clear reproducer → economy
- Documentation-only PR → economy
```
## Lookup order
Voyage resolves the profile in this priority order:
1. **Explicit `--profile <name>` flag** — passed to the command
2. **Plan-file frontmatter `profile:`** — when resuming via
`/trekexecute --resume` or `/trekcontinue`
3. **`VOYAGE_PROFILE` environment variable** — useful for headless CI
4. **Default `premium`** — final fallback
The plan-file fields for the resolved value:
- Plan-file frontmatter `profile: <name>` and `phase_models: [...]`
## Custom profiles
Drop a YAML file at `voyage-profiles/<name>.yaml` in the repo root, or at
`~/.claude/voyage-profiles/<name>.yaml`, to define a new tier (the repo root
is tried first). `lib/profiles/` holds only the four built-ins; a custom file
placed there is never found.
The validator (`lib/validators/profile-validator.mjs`) enforces:
- Every `phase_models[].phase` must be a known phase enum:
`brief` / `research` / `plan` / `execute` / `review` / `continue`
- Every `phase_models[].model` must exactly match an entry in
`BASE_ALLOWED_MODELS` (`['sonnet', 'opus', 'fable']` in
`lib/validators/profile-validator.mjs`; `haiku` only with
`VOYAGE_ALLOW_HAIKU=1`)
- All six phases must be present (no partial profiles)
The four built-in names (`economy`, `balanced`, `premium`, `fable`) resolve to their
bundled yaml first — `findProfilePath()` returns the built-in before consulting
`voyage-profiles/`, so a same-named custom file is ignored and cannot shadow a
built-in. To customize, give your profile a new name and reference it via
`--profile <new-name>` or `VOYAGE_PROFILE=<new-name>`.
### Example custom profile
```yaml
# lib/profiles/critical.yaml — opus everywhere except continue
phase_models:
- phase: brief
model: opus
- phase: research
model: opus
- phase: plan
model: opus
- phase: execute
model: opus
- phase: review
model: opus
- phase: continue
model: sonnet
```
Validate with: `node lib/validators/profile-validator.mjs --json lib/profiles/critical.yaml`
## Drift detection
In `--strict` mode, `plan-validator.mjs` emits a `MANIFEST_PROFILE_DRIFT`
warning when the plan-level `profile:` differs from any step manifest's
`profile_used`. The warning is a *signal*, not a failure — the plan
remains `valid: true`. This catches:
- Manual edits where an operator changed a single step's profile
- Resume from a partial run where the previous session used a different
tier
- Copy-paste errors when stitching plan fragments
To suppress the warning intentionally (e.g. when a critical step
genuinely needs a higher tier), document the override in the step's
prose and re-run with `--soft` to validate without strict-mode warnings.
## Cost estimation
> **Disclaimer:** the table below is an *anslag*, not a contractual
> SLA. Real cost depends on context size, agent-swarm cardinality,
> tool-use density, and Claude Code billing schedule. Treat these as
> rough order-of-magnitude.
| Profile | Brief | Research | Plan | Execute | Review | Total |
|---------|-------|----------|------|---------|--------|-------|
| `economy` | $0.10–0.50 | $0.50–2.00 | $0.50–2.00 | $1.00–5.00 | $0.20–1.00 | **$2–10** |
| `balanced` | $0.10–0.50 | $0.50–2.00 | $1.00–4.00 | $1.00–5.00 | $0.50–2.00 | **$3–14** |
| `premium` | $0.50–2.00 | $0.50–2.00 | $1.00–4.00 | $1.00–5.00 | $0.50–2.00 | **$4–15** |
Numbers are per *full pipeline run* (brief + research + plan +
execute + review) on a moderate-complexity task. Numbers scale roughly
linearly with the size of the resulting plan (10 steps ≈ baseline; 30
steps ≈ 3× the execute column).
The stats records carry no profile field, so they cannot split cost by
profile. Replace the table above with your own measured
numbers after ≥ 3 runs of each profile.
## Deferred to v4.2
- **`balanced.external_research_enabled` operator-override** —
v4.1 omits this per scope-guardian SG2. v4.2 may add an opt-in
flag to enable external research agents in the balanced tier
without forcing premium.
- **Empirical Jaccard re-calibration** — parked-synthetic fixtures
in v4.1 use a 0.55 conservative starting threshold. v4.2 plans an
empirical re-run with $60-120 LLM budget to derive a calibrated
threshold from real economy-vs-premium plan pairs.
- **ROUGE-L + char-4gram MinHash** as primary/secondary cross-tier
gates per research/02 Recommendation #7. Jaccard remains the gate
in v4.1; v4.2 may layer ROUGE-L on top.
## See also
- [`README.md` § Profile system](../README.md) — top-level overview
- [`CLAUDE.md` § Profile system](../CLAUDE.md) — internal reference
- [`tests/synthetic/profile-jaccard-calibration.md`](../tests/synthetic/profile-jaccard-calibration.md)
— calibration status and threshold rationale
- [`lib/profiles/`](../lib/profiles/) — built-in profile YAMLs
- [`lib/validators/profile-validator.mjs`](../lib/validators/profile-validator.mjs)
— schema validator with CLI shim

View file

@ -1,7 +1,6 @@
#!/bin/bash
# Seeds the empty run workspace with a project folder whose brief passes the
# sequencing gate (brief_version 2.1 WITH phase_signals) but carries no
# intent_approved_hash, so /trekplan must stop at the intent-approval gate.
# Seeds the empty run workspace with a project folder whose brief is valid but
# carries no intent_approved_hash, so /trekplan must stop at the intent-approval gate.
# Self-contained on purpose: the run cannot read the eval directory.
set -eu
mkdir -p proj
@ -18,15 +17,6 @@ research_status: skipped
auto_research: false
interview_turns: 1
source: fixture
phase_signals:
- phase: research
effort: low
- phase: plan
effort: standard
- phase: execute
effort: standard
- phase: review
effort: standard
---
# Task: add a greeting helper

View file

@ -1,4 +0,0 @@
schema_version: "1.1"
name: plan-halts-without-phase-signals
context:
scaffold_script: scaffold.sh

View file

@ -1,4 +0,0 @@
---
type: regex
pattern: "phase_signals"
---

View file

@ -1,5 +0,0 @@
---
type: regex
pattern: 'code\W{1,12}BRIEF_V51_MISSING_SIGNALS'
target: trace
---

View file

@ -1,6 +0,0 @@
---
type: tool_used
tool: Agent
min: 0
max: 0
---

View file

@ -1,6 +0,0 @@
---
type: regex
pattern: 'code\W{1,12}BRIEF_INTENT_'
target: trace
match: not_contains
---

View file

@ -1,5 +0,0 @@
---
type: file_exists
path: "**/plan.md"
exists: false
---

View file

@ -1,5 +0,0 @@
---
type: tool_used
tool: Bash
input_match: "brief-validator"
---

View file

@ -1,11 +0,0 @@
---
description: "/trekplan on a brief_version 2.1 brief without phase_signals halts at the sequencing gate."
expected_outcome: "brief-validator runs, reports BRIEF_V51_MISSING_SIGNALS, and /trekplan halts with the phase_signals hint; no Agent call (no exploration swarm), no plan.md."
tags: [proevesett, from=proevesett-eval-suite]
runs: 1
max_turns: 15
timeout_seconds: 300
allowed_tools: [Bash, Read, Glob, Grep, Write, Agent]
---
/voyage:trekplan --project proj

View file

@ -1,40 +0,0 @@
#!/bin/bash
# Seeds the empty run workspace with a project folder whose brief declares
# brief_version 2.1 but carries neither phase_signals nor phase_signals_partial.
# The intent IS approved (a current intent_approved_hash over ## Intent + ## Goal),
# so the intent gate passes and only the sequencing gate can stop the run.
# The marker is written literally, not by `intent-approval.mjs --stamp`.
# Self-contained on purpose: the run cannot read the eval directory.
set -eu
mkdir -p proj
cat > proj/brief.md <<'BRIEF'
---
type: trekbrief
brief_version: "2.1"
created: 2026-09-23
task: "Eval fixture: add a greeting helper"
slug: eval-greeting
project_dir: proj/
research_topics: 0
research_status: skipped
auto_research: false
interview_turns: 1
source: fixture
intent_approved_hash: "sha256:d435093683f06b5832b1a546c5ea54b9b30aebf556bfbf6b05e91a2a85149542"
intent_approved_at: "2026-09-23T00:00:00.000Z"
---
# Task: add a greeting helper
## Intent
Eval fixture for the Voyage proevesett. Not a real task.
## Goal
A function `greet(name)` returns `Hello, <name>!`.
## Success Criteria
- `greet("Ada")` returns `Hello, Ada!`.
BRIEF

View file

@ -17,11 +17,6 @@ research_status: skipped
auto_research: false
interview_turns: 1
source: fixture
phase_signals:
- phase: plan
effort: low
- phase: review
effort: low
---
# Task: add a greeting helper

View file

@ -17,11 +17,6 @@ research_status: skipped
auto_research: false
interview_turns: 1
source: fixture
phase_signals:
- phase: plan
effort: low
- phase: review
effort: low
---
# Task: add a greeting helper

View file

@ -13,6 +13,7 @@
import { readFileSync } from 'node:fs';
import { isMain } from '../util/is-main.mjs';
// --profile: retired in cleanup R-b: the value is consumed and ignored.
const FLAG_SCHEMA = {
trekbrief: {
boolean: ['--quick', '--fg'],

View file

@ -32,15 +32,10 @@ const OPTIONAL_KEYS = [
const OPTIONAL_BOOLEAN_KEYS = new Set(OPTIONAL_KEYS);
// Optional string-typed manifest keys (v4.1 Step 3 — additive forward-compat).
// `profile_used`: name of the model profile (economy|balanced|premium|fable|<custom>) the
// step was executed under. Absence is fine (v4.0 manifests have no
// profile concept); presence MUST be a string.
// Unlike OPTIONAL_BOOLEAN_KEYS, absence is NOT defaulted — the field is simply
// missing from `parsed` so downstream consumers can distinguish "not set" from
// "explicitly empty string".
const OPTIONAL_STRING_KEYS = [
'profile_used',
];
const OPTIONAL_STRING_KEYS = [];
const OPTIONAL_STRING_KEYS_SET = new Set(OPTIONAL_STRING_KEYS);

View file

@ -1,70 +0,0 @@
// lib/parsers/profile-jaccard.mjs
// String-normalisering helper for cross-tier Jaccard smoke-test (Step 18).
//
// Plan steps from different model tiers (sonnet vs opus) often differ
// only in punctuation, casing, or trivial wording (`logger.info` vs
// "logger.info" vs `logger info`). To avoid trivial false-negatives in
// cross-tier Jaccard, every step title passes through `normalizeStep`
// before set membership is computed.
//
// Normalisering rules (per research/02 §3.4):
// 1. Lowercase the entire string.
// 2. Strip backticks and parentheses (`...` and (...)).
// 3. Collapse runs of whitespace to a single space.
// 4. Trim leading + trailing whitespace.
//
// We do NOT stem or lemmatize — that would over-normalize and mask real
// disagreement (e.g. "Add tests for X" vs "Verify tests for X" should
// remain distinct).
/**
* Normalize a single step-title string.
* @param {unknown} step
* @returns {string}
*/
export function normalizeStep(step) {
if (typeof step !== 'string') return '';
return step
.toLowerCase()
.replace(/[`()]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Normalize an array of step titles.
* @param {string[]} steps
* @returns {string[]}
*/
export function normalizeSteps(steps) {
if (!Array.isArray(steps)) return [];
return steps.map(normalizeStep).filter((s) => s.length > 0);
}
/**
* Verify step-count parity within a tolerance band.
* @param {string[]} stepsA
* @param {string[]} stepsB
* @param {number} tolerance fraction (default 0.20 = ±20%)
* @returns {{ok: boolean, ratio: number, message: string}}
*/
export function checkStepCountParity(stepsA, stepsB, tolerance = 0.2) {
const a = Array.isArray(stepsA) ? stepsA.length : 0;
const b = Array.isArray(stepsB) ? stepsB.length : 0;
if (a === 0 || b === 0) {
return {
ok: false,
ratio: 0,
message: `step-count parity failed: empty input (a=${a}, b=${b})`,
};
}
const ratio = Math.abs(a - b) / Math.max(a, b);
return {
ok: ratio <= tolerance,
ratio,
message:
ratio <= tolerance
? `step-count parity OK (a=${a}, b=${b}, ratio=${ratio.toFixed(3)})`
: `step-count parity exceeded ${tolerance}: a=${a}, b=${b}, ratio=${ratio.toFixed(3)}`,
};
}

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: balanced
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 4
parallel_agents_max: 6
external_research_enabled: false
brief_reviewer_iter_cap: 2
---

View file

@ -1,26 +0,0 @@
---
profile_version: "1.0"
name: economy
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: sonnet
- phase: execute
model: sonnet
- phase: review
model: sonnet
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 3
external_research_enabled: false
brief_reviewer_iter_cap: 1
# Experimental: the cross-tier Jaccard floor (0.55) rests on parked-synthetic
# fixtures — empirical Step-17 calibration is deferred to v4.2. Drop this flag
# in the SAME change that lands calibration (status: empirical). See
# tests/synthetic/profile-jaccard-calibration.md and docs/profiles.md.
experimental: true
---

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: fable
phase_models:
- phase: brief
model: fable
- phase: research
model: fable
- phase: plan
model: fable
- phase: execute
model: fable
- phase: review
model: fable
- phase: continue
model: fable
parallel_agents_min: 6
parallel_agents_max: 8
external_research_enabled: true
brief_reviewer_iter_cap: 3
---

View file

@ -1,99 +0,0 @@
// lib/profiles/phase-signal-resolver.mjs
// v5.1.1 — extract per-phase signal from a brief's frontmatter.
//
// Decision A wiring: commands invoke this helper via Bash CLI shim
// (`node lib/profiles/phase-signal-resolver.mjs --brief <path> --phase <name> --json`)
// to obtain the {effort, model} pair for a specific pipeline phase.
//
// Sole source of truth for PHASE_SIGNAL_PHASES + EFFORT_LEVELS is
// lib/validators/brief-validator.mjs — re-imported here so the helper
// cannot drift from the schema validator.
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../validators/brief-validator.mjs';
import { BASE_ALLOWED_MODELS } from '../validators/profile-validator.mjs';
import { isMain } from '../util/is-main.mjs';
/**
* Resolve a brief's phase_signal entry for one phase.
*
* @param {object|null} briefFrontmatter Parsed YAML frontmatter dict (or null/undefined).
* @param {string} phase One of PHASE_SIGNAL_PHASES; anything else returns null.
* @returns {{effort?: string, model?: string} | null}
*
* Never throws. Returns null on:
* - Falsy / non-object frontmatter
* - phase not in PHASE_SIGNAL_PHASES (e.g. 'brief' or 'continue')
* - Missing phase_signals array
* - No entry for the requested phase
*
* Returns partial `{effort}` (with `model: undefined`) when the signal omits model.
* `effort` is gated against EFFORT_LEVELS and `model` against BASE_ALLOWED_MODELS;
* values outside those allowlists are dropped (treated as absent).
*/
export function resolvePhaseSignal(briefFrontmatter, phase) {
if (!briefFrontmatter || typeof briefFrontmatter !== 'object') return null;
if (typeof phase !== 'string' || !PHASE_SIGNAL_PHASES.includes(phase)) return null;
const signals = briefFrontmatter.phase_signals;
if (!Array.isArray(signals)) return null;
for (const entry of signals) {
if (entry && typeof entry === 'object' && entry.phase === phase) {
const out = {};
if ('effort' in entry && EFFORT_LEVELS.includes(entry.effort)) out.effort = entry.effort;
// MAJOR fix (S4): gate `model` against BASE_ALLOWED_MODELS, mirroring the
// effort gate above. brief-validator already rejects out-of-allowlist
// models at validation time; this is defense-in-depth so a brief that
// slipped validation cannot hand a junk model to an agent spawn.
if ('model' in entry && BASE_ALLOWED_MODELS.includes(entry.model)) out.model = entry.model;
return out;
}
}
return null;
}
/**
* Convenience wrapper: read a brief file, parse, and resolve a single phase.
* Returns null on any read or parse failure (graceful degradation).
*/
export function resolvePhaseSignalFromFile(briefPath, phase) {
if (typeof briefPath !== 'string' || briefPath.length === 0) return null;
if (!existsSync(briefPath)) return null;
let text;
try { text = readFileSync(briefPath, 'utf-8'); } catch { return null; }
const doc = parseDocument(text);
if (!doc || !doc.valid) return null;
const fm = doc.parsed && doc.parsed.frontmatter;
return resolvePhaseSignal(fm, phase);
}
// CLI shim — mirrors lib/validators/brief-validator.mjs:168 pattern.
// Footgun guard (v5.9): this shim's `model` output is brief-signal-only — it
// never consults the profile layer. For command wiring, the composed resolver
// CLI (`resolver.mjs --resolve-phase-model`, brief > profile > default) is the
// single resolution source for {effort, model}. Do not re-wire commands/*.md
// Bash blocks back to this shim.
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const getArg = (name) => {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
};
const briefPath = getArg('--brief');
const phase = getArg('--phase');
if (!briefPath || !phase) {
process.stderr.write('Usage: phase-signal-resolver.mjs --brief <path> --phase <name> [--json]\n');
process.exit(2);
}
const result = resolvePhaseSignalFromFile(briefPath, phase);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify(result) + '\n');
} else if (result === null) {
process.stdout.write('null\n');
} else {
const effort = 'effort' in result ? result.effort : '';
const model = 'model' in result ? result.model : '';
process.stdout.write(`effort=${effort} model=${model}\n`);
}
process.exit(0);
}

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: premium
phase_models:
- phase: brief
model: opus
- phase: research
model: opus
- phase: plan
model: opus
- phase: execute
model: opus
- phase: review
model: opus
- phase: continue
model: opus
parallel_agents_min: 6
parallel_agents_max: 8
external_research_enabled: true
brief_reviewer_iter_cap: 3
---

View file

@ -1,324 +0,0 @@
// lib/profiles/resolver.mjs
// Profile resolution layer (v4.1 SC #5-#9; v5.1.1 brief-signal extension).
//
// Locked interface contract (per brief Preferences):
// loadProfile(name) → ProfileObject
// - Reads lib/profiles/<name>.yaml or custom voyage-profiles/<name>.yaml.
// - Throws Error (cause: PROFILE_NOT_FOUND) when not found.
// - Returns parsed object with phase_models flattened to {brief: 'sonnet', ...}
// (object form for downstream JSON-stats; conversion from YAML list-of-dicts).
//
// resolveProfile(argv, env) → {profile, profile_source}
// - Resolution order: --profile flag > VOYAGE_PROFILE env > 'premium' default.
// - profile_source: 'flag' | 'env' | 'default'.
//
// resolveTrekcontinueProfile(planPath, argv) → {profile, profile_source}
// - --profile flag in argv wins with 'flag'.
// - Otherwise reads plan.md frontmatter via parseDocument; returns
// plan-frontmatter `profile` field with 'inheritance'.
// - If flag overrides inheritance, console.error emits an advisory.
// - For v4.0-style plans without `profile:` field, returns 'default' premium.
//
// validateProfileFile(path) → Result
// - Thin wrapper around validateProfile from profile-validator.mjs.
//
// resolvePhaseModel(phase, briefPath, argv, env)
// → {model, source} (v5.1.1 ADDITIVE)
// - Highest-priority lookup step: brief.phase_signals[phase].model (source='brief-signal').
// - Falls through via resolveProfile (flag → env → default) when brief signal absent.
// - Never throws; ENOENT/parse failures degrade silently to the next step.
// - phase ∈ {brief, continue} (outside PHASE_SIGNAL_PHASES) always falls through.
// - Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter
// directly; commands must inject {resolved model} into Agent-tool spawn sites
// for sub-agents to honor brief signals. See feedback_voyage_opus_always.md.
//
// Custom.yaml lookup order: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.yaml
// Both attempted paths included in error message on miss (HIGH-risk-mitigering).
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { parseDocument } from '../util/frontmatter.mjs';
import { validateProfile } from '../validators/profile-validator.mjs';
import { resolvePhaseSignal } from './phase-signal-resolver.mjs';
import { isMain } from '../util/is-main.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUILTIN_PROFILES_DIR = __dirname; // lib/profiles/
const BUILTIN_NAMES = new Set(['economy', 'balanced', 'premium', 'fable']);
/**
* Resolve the path to a profile file.
* Built-in profiles: lib/profiles/<name>.yaml
* Custom profiles: <repo-root>/voyage-profiles/<name>.yaml > ~/.claude/voyage-profiles/<name>.yaml
*
* @returns {{path: string|null, attempted: string[]}}
*/
export function findProfilePath(name, opts = {}) {
const cwd = opts.cwd || process.cwd();
const home = opts.home || homedir();
const attempted = [];
if (BUILTIN_NAMES.has(name)) {
const builtinPath = join(BUILTIN_PROFILES_DIR, `${name}.yaml`);
attempted.push(builtinPath);
if (existsSync(builtinPath)) return { path: builtinPath, attempted };
}
// Custom: repo-root first, then home
const repoCustom = join(cwd, 'voyage-profiles', `${name}.yaml`);
attempted.push(repoCustom);
if (existsSync(repoCustom)) return { path: repoCustom, attempted };
const homeCustom = join(home, '.claude', 'voyage-profiles', `${name}.yaml`);
attempted.push(homeCustom);
if (existsSync(homeCustom)) return { path: homeCustom, attempted };
return { path: null, attempted };
}
/**
* Flatten phase_models list-of-dicts to object form: {brief: 'sonnet', research: 'opus', ...}
*/
function flattenPhaseModels(list) {
const out = {};
if (!Array.isArray(list)) return out;
for (const entry of list) {
if (entry && typeof entry === 'object' && typeof entry.phase === 'string' && typeof entry.model === 'string') {
out[entry.phase] = entry.model;
}
}
return out;
}
/**
* Load and parse a profile file by name.
* @param {string} name
* @param {{cwd?: string, home?: string}} [opts]
* @returns {{name: string, profile_version: string, phase_models: object, parallel_agents_min: number, parallel_agents_max: number, external_research_enabled: boolean, brief_reviewer_iter_cap: number, _path: string}}
* @throws {Error} cause: PROFILE_NOT_FOUND | PROFILE_PARSE_ERROR
*/
export function loadProfile(name, opts = {}) {
const { path, attempted } = findProfilePath(name, opts);
if (!path) {
const err = new Error(
`Profile "${name}" not found. Attempted paths:\n - ${attempted.join('\n - ')}`,
);
err.cause = 'PROFILE_NOT_FOUND';
err.attempted = attempted;
throw err;
}
let text;
try { text = readFileSync(path, 'utf-8'); }
catch (e) {
const err = new Error(`Cannot read profile "${name}" at ${path}: ${e.message}`);
err.cause = 'PROFILE_READ_ERROR';
throw err;
}
const doc = parseDocument(text);
if (!doc.valid) {
const err = new Error(`Profile "${name}" parse error: ${doc.errors[0]?.message || 'unknown'}`);
err.cause = 'PROFILE_PARSE_ERROR';
throw err;
}
const fm = doc.parsed.frontmatter || {};
return {
name: fm.name,
profile_version: fm.profile_version,
phase_models: flattenPhaseModels(fm.phase_models),
parallel_agents_min: fm.parallel_agents_min,
parallel_agents_max: fm.parallel_agents_max,
external_research_enabled: fm.external_research_enabled,
brief_reviewer_iter_cap: fm.brief_reviewer_iter_cap,
_path: path,
};
}
/**
* Resolve profile name from argv + env + default.
* Order: --profile flag > VOYAGE_PROFILE env > 'premium'.
*
* @param {{flags?: object} | string[]} argv Either parsed argv or a flags-object.
* @param {object} [env]
* @returns {{profile: string, profile_source: 'flag'|'env'|'default'}}
*/
export function resolveProfile(argv, env = process.env) {
const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {});
if (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0) {
return { profile: flags['--profile'], profile_source: 'flag' };
}
if (typeof env.VOYAGE_PROFILE === 'string' && env.VOYAGE_PROFILE.length > 0) {
return { profile: env.VOYAGE_PROFILE, profile_source: 'env' };
}
return { profile: 'premium', profile_source: 'default' };
}
/**
* Resolve profile for /trekcontinue: prefers explicit flag, falls back to plan
* frontmatter (inheritance), then 'premium' default if plan has no profile field.
*
* @param {string} planPath Path to plan.md
* @param {{flags?: object} | string[]} argv
* @param {{env?: object, console?: Console}} [opts]
* @returns {{profile: string, profile_source: 'flag'|'inheritance'|'default'}}
*/
export function resolveTrekcontinueProfile(planPath, argv, opts = {}) {
const env = opts.env || process.env;
const con = opts.console || console;
const flags = (argv && typeof argv === 'object' && argv.flags) ? argv.flags : (argv || {});
const flagProfile = (typeof flags['--profile'] === 'string' && flags['--profile'].length > 0)
? flags['--profile'] : null;
// Read plan-frontmatter to detect inheritance
let planProfile = null;
if (planPath && existsSync(planPath)) {
try {
const text = readFileSync(planPath, 'utf-8');
const doc = parseDocument(text);
if (doc.valid) {
const fm = doc.parsed.frontmatter || {};
if (typeof fm.profile === 'string' && fm.profile.length > 0) {
planProfile = fm.profile;
}
}
} catch {
// swallow — degrades gracefully to default
}
}
if (flagProfile) {
if (planProfile && planProfile !== flagProfile) {
con.error(`[voyage] profile inheritance overridden by --profile flag: ${planProfile} → ${flagProfile}`);
}
return { profile: flagProfile, profile_source: 'flag' };
}
if (planProfile) {
return { profile: planProfile, profile_source: 'inheritance' };
}
// v4.0-style plan without profile: default to premium
return { profile: 'premium', profile_source: 'default' };
}
/**
* Validate a profile YAML file.
* Thin wrapper for locked-interface compatibility.
*/
export function validateProfileFile(path, opts = {}) {
return validateProfile(path, opts);
}
/**
* Resolve the model for a specific pipeline phase, applying brief-signal override
* as the new highest-priority lookup step (v5.1.1).
*
* @param {string} phase One of brief|research|plan|execute|review|continue
* @param {string|null} briefPath Absolute or repo-relative path to brief.md, or null
* @param {string[]|object} argv Full process.argv array OR parsed flags object
* @param {object} [env] Environment-variable record (defaults to process.env)
* @returns {{effort?: string, model: string, source: 'brief-signal'|'flag'|'env'|'default'}}
*
* `effort` (v5.9 ADDITIVE) is the brief signal's effort passed through when
* present — so commands consume ONE coherent {effort, model, source} result
* instead of two split CLI calls. Absent when the brief carries no valid
* effort signal for the phase (commands default to 'standard' per the
* composition rule).
*
* Error handling contract:
* - Never throws. Any failure (ENOENT on briefPath, malformed YAML, missing
* phase_signals entry, missing model field) silently falls through.
* - Treats unreadable briefPath as equivalent to briefPath=null.
* - Returns the fallthrough resolution when phase ∈ {brief, continue} (not in
* PHASE_SIGNAL_PHASES).
*
* Controls ORCHESTRATOR model only. Sub-agents read agents/*.md frontmatter
* directly; commands must inject {resolved model} at Agent-tool spawn sites.
*/
export function resolvePhaseModel(phase, briefPath, argv, env = process.env) {
// Step 1: brief-signal lookup. `effort` is captured independently of `model`
// so a signal like {effort: high} (no model) still passes effort through
// while the model falls to the profile layer.
let effort;
if (typeof briefPath === 'string' && briefPath.length > 0 && existsSync(briefPath)) {
let fm = null;
try {
const text = readFileSync(briefPath, 'utf-8');
const doc = parseDocument(text);
if (doc && doc.valid) fm = doc.parsed && doc.parsed.frontmatter;
} catch {
// swallow — degrade gracefully to fallthrough
}
if (fm) {
const signal = resolvePhaseSignal(fm, phase);
if (signal && typeof signal.effort === 'string') effort = signal.effort;
if (signal && typeof signal.model === 'string' && signal.model.length > 0) {
return effort !== undefined
? { effort, model: signal.model, source: 'brief-signal' }
: { model: signal.model, source: 'brief-signal' };
}
}
}
// Step 2+3+4: fall through via existing resolveProfile chain
// (flag → env → default), then index into profile.phase_models[phase].
// Normalize argv: resolveProfile expects flags-object form. When called with
// a raw process.argv-style array, extract --profile here.
let normalizedFlags = argv;
if (Array.isArray(argv)) {
const idx = argv.indexOf('--profile');
normalizedFlags = (idx >= 0 && idx + 1 < argv.length)
? { '--profile': argv[idx + 1] }
: {};
}
const { profile, profile_source } = resolveProfile(normalizedFlags, env);
let phaseModels = {};
try {
const p = loadProfile(profile);
phaseModels = p.phase_models || {};
} catch {
// Unknown profile → fall back to premium
try {
const p = loadProfile('premium');
phaseModels = p.phase_models || {};
} catch {
phaseModels = {};
}
}
const model = phaseModels[phase] || 'opus';
return effort !== undefined
? { effort, model, source: profile_source }
: { model, source: profile_source };
}
// CLI shim — invoked by commands/trek*.md via Bash.
// Usage: node lib/profiles/resolver.mjs --resolve-phase-model --phase plan --brief-path foo.md --json
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
if (args.includes('--resolve-phase-model')) {
const getArg = (name) => {
const i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
};
const phase = getArg('--phase');
const briefPath = getArg('--brief-path');
if (!phase) {
process.stderr.write('Usage: resolver.mjs --resolve-phase-model --phase <name> [--brief-path <path>] [--json]\n');
process.exit(2);
}
const r = resolvePhaseModel(phase, briefPath || null, args, process.env);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify(r) + '\n');
} else {
const effort = 'effort' in r ? ` effort=${r.effort}` : '';
process.stdout.write(`model=${r.model} source=${r.source}${effort}\n`);
}
process.exit(0);
}
// Otherwise no-op (the module is primarily imported, not run directly).
}

View file

@ -520,8 +520,7 @@ export function judgeFilter(findings) {
*
* `no-citation` REFUTES (the finding names no location, so it makes no
* checkable claim) and is dropped. `unknown-rule_key` does not: an ad-hoc key
* is a real defect wearing the wrong label — v5.1.1 high-effort mode already
* KEEPS these, normalised to PLAN_EXECUTE_DRIFT — so it routes to `unverified`.
* is a real defect wearing the wrong label, so it routes to `unverified`.
*
* The brief rule runs here too: a finding `lowerableBriefConflict` accepts is
* set to MINOR with `brief_lowered_from: 'MAJOR'`; every other finding gets

View file

@ -9,7 +9,6 @@
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
import { parseSuccessCriteria, runnableCheck, firstCommand } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
@ -18,8 +17,6 @@ export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'pr
export const BRIEF_TYPE_VALUES = Object.freeze(['trekbrief', 'trekreview']);
export const BRIEF_RESEARCH_STATUS_VALUES = ['pending', 'in_progress', 'complete', 'skipped'];
export const BRIEF_BODY_SECTIONS = ['Intent', 'Goal', 'Success Criteria'];
export const PHASE_SIGNAL_PHASES = Object.freeze(['research', 'plan', 'execute', 'review']);
export const EFFORT_LEVELS = Object.freeze(['low', 'standard', 'high']);
// v5.5 — framing: how this brief relates to prior operator intent (the first layer
// of the framing-alignment defense). Required at brief_version ≥ 2.2.
export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replace', 'new-direction']);
@ -77,55 +74,6 @@ export function validateBriefContent(text, opts = {}) {
}
}
// v5.1 — phase_signals (additive optional field) + version-conditional sequencing gate.
// Composition rule documented in each downstream command's "Composition rule (v5.1)" section.
const hasSignals = 'phase_signals' in fm;
const hasPartial = 'phase_signals_partial' in fm;
if (hasSignals && hasPartial) {
errors.push(issue(
'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE',
'phase_signals and phase_signals_partial are mutually exclusive — set exactly one',
'Either commit per-phase signals OR record phase_signals_partial: true (force-stop).',
));
}
if (hasSignals) {
if (!Array.isArray(fm.phase_signals)) {
errors.push(issue(
'BRIEF_INVALID_PHASE_SIGNALS',
'phase_signals must be a list of {phase, effort?, model?} entries',
));
} else {
for (const entry of fm.phase_signals) {
if (!entry || typeof entry !== 'object' || !('phase' in entry)) {
errors.push(issue('BRIEF_INVALID_PHASE_SIGNALS', `phase_signals entry must include a "phase" key`));
continue;
}
if (!PHASE_SIGNAL_PHASES.includes(entry.phase)) {
errors.push(issue(
'BRIEF_INVALID_PHASE_SIGNAL_PHASE',
`phase_signals.phase "${entry.phase}" not in [${PHASE_SIGNAL_PHASES.join(', ')}]`,
));
}
if ('effort' in entry && !EFFORT_LEVELS.includes(entry.effort)) {
errors.push(issue(
'BRIEF_INVALID_EFFORT',
`phase_signals.effort "${entry.effort}" not in [${EFFORT_LEVELS.join(', ')}]`,
));
}
if ('model' in entry && !BASE_ALLOWED_MODELS.includes(entry.model)) {
errors.push(issue(
'BRIEF_INVALID_MODEL',
`phase_signals.model "${entry.model}" not in [${BASE_ALLOWED_MODELS.join(', ')}]`,
));
}
}
}
}
// Sequencing gate: brief_version ≥ 2.1 requires phase_signals OR phase_signals_partial.
// Coerce to String so the gate fires regardless of whether YAML parsed the value as
// a string ("2.1") or a number (2.1). v5.1.0 shipped with an unquoted-2.1 template
// that silently bypassed this gate — fix locked in by quoting the template AND
// accepting both shapes here as defense-in-depth (v5.1.1, finding 3c834097/df1435a2).
// v5.5 — framing enum check fires on ANY version when the field is present but
// malformed. The missing-framing BLOCKER below is version-gated (≥ 2.2).
if ('framing' in fm && !BRIEF_FRAMING_VALUES.includes(fm.framing)) {
@ -156,15 +104,7 @@ export function validateBriefContent(text, opts = {}) {
if (vm) {
const major = Number(vm[1]);
const minor = Number(vm[2]);
const atLeast21 = major > 2 || (major === 2 && minor >= 1);
const atLeast22 = major > 2 || (major === 2 && minor >= 2);
if (atLeast21 && !hasSignals && !hasPartial && fm.type !== 'trekreview') {
errors.push(issue(
'BRIEF_V51_MISSING_SIGNALS',
'brief_version ≥ 2.1 requires phase_signals (or phase_signals_partial: true)',
'Re-run /trekbrief — Phase 3.5 collects per-phase effort + model signals.',
));
}
// v5.5 framing enforcement — gated at ≥ 2.2 (trekreview briefs are exempt).
if (atLeast22 && fm.type !== 'trekreview') {
if (!('framing' in fm)) {

View file

@ -58,7 +58,6 @@ import {
testFirstFileEntries, testFirstFiles,
} from '../parsers/plan-schema.mjs';
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, fail } from '../util/result.mjs';
import { parsePlanVerification, firstCommand, runnableCheck, refusal } from '../verification/criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
@ -426,29 +425,6 @@ export function validatePlanContent(rawText, opts = {}) {
else warnings.push(...v18);
}
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
// If plan frontmatter declares `profile: <name>` and any step manifest
// declares `profile_used: <other>`, emit a warning (NOT an error) so
// operators see drift but parsing remains forward-compat.
if (strict) {
const planFm = parseDocument(text).parsed?.frontmatter;
const planProfile =
planFm && typeof planFm.profile === 'string' ? planFm.profile : null;
if (planProfile) {
for (const m of manRes.parsed) {
const stepProfile = m.manifest && m.manifest.profile_used;
if (typeof stepProfile === 'string' && stepProfile !== planProfile) {
warnings.push(issue(
'MANIFEST_PROFILE_DRIFT',
`step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`,
'A step manifest declares a different profile than the plan frontmatter; ' +
'verify whether this is intentional (manual override) or accidental drift.',
));
}
}
}
}
return {
valid: errors.length === 0,
errors,

View file

@ -1,195 +0,0 @@
// lib/validators/profile-validator.mjs
// Validate model-profile YAML files (lib/profiles/*.yaml + custom voyage-profiles/*.yaml).
//
// Profile schema (v4.1, profile_version 1.0):
// name : string
// phase_models : list-of-dicts [{phase: string, model: string}, ...]
// 6 entries required (brief, research, plan, execute, review, continue)
// parallel_agents_min : number (≥ 1)
// parallel_agents_max : number (≥ parallel_agents_min)
// external_research_enabled : boolean
// brief_reviewer_iter_cap : number (≥ 1)
// experimental : boolean (OPTIONAL) — true marks a tier whose
// gating constants are not yet empirically
// calibrated (e.g. economy's parked-synthetic
// cross-tier Jaccard floor). Absent ⇒ stable.
//
// Issue codes:
// PROFILE_MISSING_FIELD — required top-level frontmatter field absent
// PROFILE_INVALID_MODEL — phase_models[N].model not in allowlist
// PROFILE_INVALID_ENUM — wrong-type value (e.g. external_research_enabled is string)
// PROFILE_READ_ERROR — file unreadable or parse-error
// PROFILE_NOT_FOUND — file does not exist
//
// Allowed model values: ['sonnet', 'opus', 'fable']. Haiku is allowed only when
// VOYAGE_ALLOW_HAIKU=1 (the model-choice rule: Haiku is never a default, only
// an explicit opt-in for special use).
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { isMain } from '../util/is-main.mjs';
export const PROFILE_REQUIRED_FIELDS = Object.freeze([
'name',
'phase_models',
'parallel_agents_min',
'parallel_agents_max',
'external_research_enabled',
'brief_reviewer_iter_cap',
]);
export const PROFILE_REQUIRED_PHASES = Object.freeze([
'brief', 'research', 'plan', 'execute', 'review', 'continue',
]);
export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus', 'fable']);
function getAllowedModels(env = process.env) {
if (env.VOYAGE_ALLOW_HAIKU === '1') {
return [...BASE_ALLOWED_MODELS, 'haiku'];
}
return BASE_ALLOWED_MODELS;
}
export function validateProfileContent(text, opts = {}) {
const env = opts.env || process.env;
const allowedModels = getAllowedModels(env);
const doc = parseDocument(text);
if (!doc.valid) {
return fail(issue('PROFILE_READ_ERROR', `Frontmatter parse error: ${doc.errors[0]?.message || 'unknown'}`));
}
const fm = doc.parsed.frontmatter || {};
const errors = [];
const warnings = [];
// Required field presence
for (const k of PROFILE_REQUIRED_FIELDS) {
if (!(k in fm)) {
errors.push(issue('PROFILE_MISSING_FIELD', `Required profile field missing: ${k}`));
}
}
// Type checks for scalar fields
if ('name' in fm && typeof fm.name !== 'string') {
errors.push(issue('PROFILE_INVALID_ENUM', `name must be string (got ${typeof fm.name})`));
}
if ('parallel_agents_min' in fm && typeof fm.parallel_agents_min !== 'number') {
errors.push(issue('PROFILE_INVALID_ENUM',
`parallel_agents_min must be number (got ${typeof fm.parallel_agents_min})`));
}
if ('parallel_agents_max' in fm && typeof fm.parallel_agents_max !== 'number') {
errors.push(issue('PROFILE_INVALID_ENUM',
`parallel_agents_max must be number (got ${typeof fm.parallel_agents_max})`));
}
if (typeof fm.parallel_agents_min === 'number' && typeof fm.parallel_agents_max === 'number') {
if (fm.parallel_agents_max < fm.parallel_agents_min) {
errors.push(issue('PROFILE_INVALID_ENUM',
`parallel_agents_max (${fm.parallel_agents_max}) < parallel_agents_min (${fm.parallel_agents_min})`));
}
}
if ('external_research_enabled' in fm && typeof fm.external_research_enabled !== 'boolean') {
errors.push(issue('PROFILE_INVALID_ENUM',
`external_research_enabled must be boolean (got ${typeof fm.external_research_enabled})`));
}
if ('brief_reviewer_iter_cap' in fm && typeof fm.brief_reviewer_iter_cap !== 'number') {
errors.push(issue('PROFILE_INVALID_ENUM',
`brief_reviewer_iter_cap must be number (got ${typeof fm.brief_reviewer_iter_cap})`));
}
// Optional field: `experimental` (boolean). Absent ⇒ tier is stable.
if ('experimental' in fm && typeof fm.experimental !== 'boolean') {
errors.push(issue('PROFILE_INVALID_ENUM',
`experimental must be boolean (got ${typeof fm.experimental})`));
}
// phase_models validation
if ('phase_models' in fm) {
if (!Array.isArray(fm.phase_models)) {
errors.push(issue('PROFILE_INVALID_ENUM', `phase_models must be a list-of-dicts (got ${typeof fm.phase_models})`));
} else {
const seenPhases = new Set();
for (let i = 0; i < fm.phase_models.length; i++) {
const entry = fm.phase_models[i];
if (!entry || typeof entry !== 'object') {
errors.push(issue('PROFILE_INVALID_ENUM',
`phase_models[${i}] must be a dict {phase, model} (got ${typeof entry})`,
undefined,
`phase_models[${i}]`));
continue;
}
if (typeof entry.phase !== 'string') {
errors.push(issue('PROFILE_INVALID_ENUM',
`phase_models[${i}].phase must be string`,
undefined,
`phase_models[${i}].phase`));
} else {
seenPhases.add(entry.phase);
if (!PROFILE_REQUIRED_PHASES.includes(entry.phase)) {
errors.push(issue('PROFILE_INVALID_ENUM',
`phase_models[${i}].phase "${entry.phase}" not in [${PROFILE_REQUIRED_PHASES.join(', ')}]`,
undefined,
`phase_models[${i}].phase`));
}
}
if (typeof entry.model !== 'string') {
errors.push(issue('PROFILE_INVALID_MODEL',
`phase_models[${i}].model must be string`,
undefined,
`phase_models[${i}].model`));
} else if (!allowedModels.includes(entry.model)) {
errors.push(issue('PROFILE_INVALID_MODEL',
`phase_models[${i}].model "${entry.model}" not in [${allowedModels.join(', ')}]` +
(entry.model === 'haiku' ? ' (set VOYAGE_ALLOW_HAIKU=1 to allow)' : ''),
undefined,
`phase_models[${i}].model`));
}
}
// All 6 required phases must be present
for (const required of PROFILE_REQUIRED_PHASES) {
if (!seenPhases.has(required)) {
errors.push(issue('PROFILE_MISSING_FIELD',
`phase_models missing required phase entry: ${required}`));
}
}
}
}
return { valid: errors.length === 0, errors, warnings, parsed: { frontmatter: fm } };
}
export function validateProfile(filePath, opts = {}) {
if (!existsSync(filePath)) {
return fail(issue('PROFILE_NOT_FOUND', `Profile file not found: ${filePath}`));
}
let text;
try { text = readFileSync(filePath, 'utf-8'); }
catch (e) { return fail(issue('PROFILE_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
const r = validateProfileContent(text, opts);
return { ...r, parsed: { ...(r.parsed || {}), filePath } };
}
if (isMain(import.meta.url)) {
const args = process.argv.slice(2);
const filePath = args.find(a => !a.startsWith('--'));
if (!filePath) {
process.stderr.write('Usage: profile-validator.mjs [--json] <profile.yaml>\n');
process.exit(2);
}
const r = validateProfile(filePath);
if (args.includes('--json')) {
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
} else {
process.stdout.write(`profile-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}${e.location ? ' at ' + e.location : ''}\n`);
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
}
process.exit(r.valid ? 0 : 1);
}

View file

@ -24,21 +24,6 @@ ceremony: {small | bounded | architectural}
# .git/info/exclude). The approval log is committed either way. Optional: absent
# reads as local. Asked once per task in the /trekbrief question round.
artifact_store: {git | local}
# v5.1 — per-phase effort + model signal (Phase 3.5).
# `effort` ∈ {low, standard, high}; `model` ∈ {sonnet, opus, fable} (v5.9).
# Omit `model:` for `standard` so composition falls through to profile
# resolver. Force-stop alternative is the commented
# `phase_signals_partial: true` below (mutually exclusive with `phase_signals`).
phase_signals:
- phase: research
effort: standard
- phase: plan
effort: standard
- phase: execute
effort: standard
- phase: review
effort: standard
# phase_signals_partial: true # uncomment to record force-stop instead of phase_signals
---
# Task: {title}

View file

@ -1,118 +1,22 @@
// tests/commands/trekbrief.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC1 tests.
//
// Pattern D prose-pins kept as doc-anchors for the .md file. Runtime tests
// added per finding 350853 (BLOCKER SC1) + a7f4f95a (MAJOR Plan Step 5 drift).
//
// SC1 re-interpretation (per plan Step 10 amendment): "asserts on 4
// AskUserQuestion calls" → "asserts resolvePhaseSignal returns non-null for
// all 4 entries in PHASE_SIGNAL_PHASES when applied to a brief with a
// committed phase_signals block." See brief amendment for full rationale.
// Pattern D prose-pins kept as doc-anchors for commands/trekbrief.md.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent, PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../../lib/validators/brief-validator.mjs';
import { BASE_ALLOWED_MODELS } from '../../lib/validators/profile-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md');
const REVIEWER_FILE = join(ROOT, 'agents', 'brief-reviewer.md');
const TEMPLATE_FILE = join(ROOT, 'templates', 'trekbrief-template.md');
const FIXTURE = (name) => join(ROOT, 'tests', 'fixtures', name);
function read() {
return readFileSync(COMMAND_FILE, 'utf8');
}
function readFixture(name) {
return readFileSync(FIXTURE(name), 'utf8');
}
function frontmatterOf(text) {
const doc = parseDocument(text);
return doc.parsed && doc.parsed.frontmatter;
}
// --- Pattern D prose-pins (doc-anchors) ---
test('trekbrief — Phase 3.5 heading is present', () => {
const text = read();
assert.match(text, /^## Phase 3\.5 — Per-phase effort dialog$/m,
'Phase 3.5 heading missing from commands/trekbrief.md');
});
test('trekbrief — Phase 3.5 references all 4 downstream phases', () => {
const text = read();
const startIdx = text.indexOf('## Phase 3.5');
assert.ok(startIdx >= 0, 'Phase 3.5 not found');
const section = text.slice(startIdx, text.indexOf('## Phase 4', startIdx));
for (const phase of ['research', 'plan', 'execute', 'review']) {
assert.ok(section.includes(phase),
`Phase 3.5 missing reference to "${phase}"`);
}
});
test('trekbrief — Phase 3.5 documents phase_signals_partial force-stop', () => {
const text = read();
assert.ok(text.includes('phase_signals_partial'),
'phase_signals_partial not mentioned in /trekbrief command prose');
});
// --- v5.1.1 runtime SC1 tests ---
test('trekbrief — SC1: resolvePhaseSignal returns non-null for all 4 phases on committed brief (brief-effort-low)', () => {
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
for (const phase of PHASE_SIGNAL_PHASES) {
const r = resolvePhaseSignal(fm, phase);
assert.ok(r && typeof r === 'object',
`phase=${phase}: resolver must return non-null for committed brief; got ${JSON.stringify(r)}`);
assert.ok(typeof r.effort === 'string',
`phase=${phase}: resolver result must include effort`);
}
});
test('trekbrief — SC1: each of 4 phases has both effort AND model on full-signals fixture', () => {
const fm = frontmatterOf(readFixture('brief-with-phase-signals.md'));
for (const phase of PHASE_SIGNAL_PHASES) {
const r = resolvePhaseSignal(fm, phase);
assert.ok(r && typeof r === 'object', `phase=${phase}: must resolve`);
assert.ok(EFFORT_LEVELS.includes(r.effort),
`phase=${phase}: effort "${r.effort}" not in EFFORT_LEVELS`);
if ('model' in r) {
assert.ok(BASE_ALLOWED_MODELS.includes(r.model),
`phase=${phase}: model "${r.model}" not in [${BASE_ALLOWED_MODELS.join(', ')}]`);
}
}
});
test('trekbrief — SC1: missing phase_signals + brief_version 2.1 triggers BRIEF_V51_MISSING_SIGNALS', () => {
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`gate must fire; errors=${JSON.stringify(r.errors)}`,
);
});
// --- v5.9 — fable tier option in the Phase 3.5 loop ---
test('trekbrief — v5.9 Phase 3.5 canonical mapping contains the fable row and offers 4 options', () => {
const text = read();
const startIdx = text.indexOf('## Phase 3.5');
assert.ok(startIdx >= 0, 'Phase 3.5 not found');
const section = text.slice(startIdx, text.indexOf('## Phase 4', startIdx));
assert.ok(section.includes('fable → {effort: high, model: fable}'),
'Phase 3.5 canonical mapping must contain the fable tier row');
assert.ok(section.includes('with 4 options'),
'Phase 3.5 loop must offer 4 options (AskUserQuestion maxItems: 4)');
});
// --- v5.5 — framing enforcement + TL;DR + memory-alignment prose-pins ---
test('trekbrief — v5.5 Phase 2.5 framing declaration heading present', () => {
@ -174,35 +78,3 @@ test('trekbrief — v5.5 template carries framing field, 2.2, and TL;DR section'
'template frontmatter must include the framing field');
assert.match(tpl, /^## TL;DR$/m, 'template must include the ## TL;DR section');
});
test('trekbrief — SC1: phase_signals_partial: true does NOT trigger the gate', () => {
const partial = `---
type: trekbrief
brief_version: "2.1"
created: 2026-05-14
task: "Partial brief"
slug: partial-brief
project_dir: .claude/projects/2026-05-14-partial-brief/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 2
source: fixture
phase_signals_partial: true
---
# Task
## Intent
Stop early.
## Goal
Test partial mode.
## Success Criteria
- gate does not fire.
`;
const r = validateBriefContent(partial, { strict: true });
assert.equal(r.valid, true, `errors=${JSON.stringify(r.errors)}`);
assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});

View file

@ -1,84 +1,21 @@
// tests/commands/trekexecute.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute.
// Low effort runs the plan as one session, step after step; it sets no --gates
// (fix round 4-5a, #33).
// Prose-pin and runtime tests for /trekexecute.
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { sectionText } from '../helpers/md-section.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
const PHASE = 'execute';
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
function frontmatterOf(text) {
const doc = parseDocument(text);
return doc.parsed && doc.parsed.frontmatter;
}
// --- Pattern D prose-pins ---
test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
const text = read();
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
'/trekexecute must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
assert.ok(text.includes('phase_signals'),
'/trekexecute must reference phase_signals (v5.1 composition rule)');
});
test('trekexecute — low-effort path runs one session, step after step, and sets no --gates open', () => {
const text = read();
const compIdx = text.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const section = text.slice(compIdx, compIdx + 2000);
assert.doesNotMatch(section, /--gates open/, 'Low effort must not set --gates open');
assert.match(section.replace(/\s+/g, ' '), /`effort == 'low'` runs the plan as one session, step after step \(Phase 2\.5\)/,
'Low-effort path must run the plan as one session, step after step');
});
// --- v5.1.1 runtime SC4 + SC7 ---
test('trekexecute — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'low');
assert.equal(r.model, 'sonnet');
});
test('trekexecute — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'standard');
assert.equal(r.model, undefined);
});
test('trekexecute — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'high');
assert.equal(r.model, 'opus');
});
test('trekexecute — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
);
});
// --- S38 loop-discipline hardening: completion gate (Step 2) ---
@ -201,9 +138,9 @@ test('trekexecute Phase 8: the state-file block imports through the plugin root
}
});
// --- Phase 1 --project: the brief check and the execute resolver -------------
// Phase 1's --project branch runs the two lines /trekplan runs, for the execute
// phase, and halts only on BRIEF_V51_MISSING_SIGNALS.
// --- Phase 1 --project: the brief check -------------------------------------
// Phase 1's --project branch runs the brief-validator line /trekplan runs, and
// never halts on what it reports.
/** Phase 1's rule 1 (the --project branch), as lines. */
function projectRule() {
@ -215,35 +152,25 @@ function projectRule() {
return sec.slice(start, end).join('\n');
}
test('trekexecute Phase 1 --project runs brief-validator --soft --json and the execute resolver, and halts only on BRIEF_V51_MISSING_SIGNALS', () => {
test('trekexecute Phase 1 --project runs brief-validator --soft --json and never halts on its errors', () => {
const rule = projectRule();
const validator = libLine(rule, 'validators/brief-validator.mjs', '--soft --json');
const resolver = libLine(rule, 'profiles/resolver.mjs', '--resolve-phase-model --phase execute');
assert.equal(validator, 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/brief-validator.mjs --soft --json "{dir}/brief.md"');
assert.equal(resolver,
'node ${CLAUDE_PLUGIN_ROOT}/lib/profiles/resolver.mjs --resolve-phase-model --phase execute --brief-path "{dir}/brief.md" [--profile {profile}] --json');
const f = flat(rule);
for (const s of [
'The validator exits 1 when its `errors` are not empty; read its JSON either way.',
'Halt only when the validator\'s `errors` hold `BRIEF_V51_MISSING_SIGNALS`, with the message from "Sequencing gate surface" below.',
'Any other error is printed in one line and the run continues.',
'A validator or resolver that cannot run (no JSON) is "not measured" in one line, and the run continues.',
'Capture the resolver\'s JSON as `phase_signal_result` (Composition rule below).',
'No `{dir}/brief.md`: skip both lines.',
'Any error is printed in one line and the run continues.',
'A validator that cannot run (no JSON) is "not measured" in one line, and the run continues.',
'No `{dir}/brief.md`: skip the line.',
]) assert.ok(f.includes(s), `the --project rule is missing: ${s}`);
// The two lines run as printed: the validator names the one halting code for a
// 2.1 brief without signals, and the resolver hands Phase 2.5 its low effort.
// The line runs as printed: a brief with an error exits 1 and still prints JSON.
const dir = mkdtempSync(join(tmpdir(), 'trekexecute-project-'));
try {
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-v21-no-signals.md'), join(dir, 'brief.md'));
writeFileSync(join(dir, 'brief.md'), '---\ntype: trekbrief\n---\n\n# Task\n');
const v = bash(validator.split('{dir}').join(dir), ROOT);
assert.equal(v.status, 1, `validator: ${v.stdout}${v.stderr}`);
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_V51_MISSING_SIGNALS'), v.stdout);
copyFileSync(join(ROOT, 'tests', 'fixtures', 'brief-effort-low.md'), join(dir, 'brief.md'));
const r = bash(resolver.split('{dir}').join(dir).replace(' [--profile {profile}]', ''), ROOT);
assert.equal(r.status, 0, `resolver: ${r.stdout}${r.stderr}`);
assert.equal(JSON.parse(r.stdout).effort, 'low');
assert.ok(JSON.parse(v.stdout).errors.some((e) => e.code === 'BRIEF_MISSING_FIELD'), v.stdout);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
@ -362,36 +289,9 @@ test('trekexecute merge lock: Phase 7.2 step 4 run on a BLOCK review of an older
assert.equal(JSON.parse(r.stdout).verdict, 'BLOCK');
});
// --- The resolver runs in Phase 1; low effort is one session; no gates escalation
// (fix round 4-5a, #33).
// --- No gates escalation (fix round 4-5a, #33).
test('[#33] the Composition rule says the resolver runs in Phase 1, and no line says Phase 2.4 runs it', () => {
assert.doesNotMatch(flat(read()), /invoked in Phase 2\.4/);
assert.ok(flat(sectionText(read(), '## Composition rule (v5.1)')).includes('invoked in Phase 1 alongside the sequencing-gate brief-validator call'),
'the Composition rule does not say the resolver is invoked in Phase 1');
});
test('[#33] sibling: Composition rule has no "uniformity and stats" and no gates auto-escalation', () => {
const f = flat(sectionText(read(), '## Composition rule (v5.1)'));
assert.doesNotMatch(f, /uniformity and stats/, 'the Composition rule still says the model is returned for stats');
assert.ok(f.includes('it is returned for cross-command uniformity.'),
'the Composition rule does not say why the model is returned');
assert.doesNotMatch(f, /gates auto-escalation/);
});
test('[#33] Phase 2.5 names low effort as one session, steps in order, never while a wave is in flight', () => {
const sec = sectionText(read(), '## Phase 2.5 — Execution strategy decision').split('\n');
const start = sec.findIndex((l) => l.startsWith('**Run as single session (foreground)** when ANY of these are true:'));
assert.ok(start !== -1, 'Phase 2.5 must still list when it runs as a single session');
const end = sec.findIndex((l, i) => i > start && l.trim() === '');
const bullets = sec.slice(start + 1, end);
assert.ok(bullets.includes("- `phase_signal_result.effort == 'low'` → one session, steps in order, unless a wave is in flight: when "
+ '`.claude/trekplan-sessions/{slug}/orders/wave.json` exists in the main checkout (the first entry of `git worktree list`), '
+ 'this bullet does not apply and the multi-session path below collects the wave. The check only reads the file and never stops the run.'),
`the single-session list does not name low effort: ${bullets.join(' | ')}`);
});
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left, and high effort says it changes nothing', () => {
test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_override is left', () => {
const text = read();
const f = flat(text);
assert.doesNotMatch(f, /gates_mode = 'closed'/);
@ -399,9 +299,6 @@ test('[#33] no gates_mode = \'closed\', "strictest" or --event gates_mode_flag_o
assert.doesNotMatch(f, /halts immediately/);
assert.doesNotMatch(text, /--event gates_mode_flag_override/);
assert.doesNotMatch(f, /gates_mode = '(adaptive|open)'/);
assert.equal(flat(sectionText(text, '### High-effort behavior (v5.1.1)')),
'### High-effort behavior (v5.1.1) High effort changes nothing in `/trekexecute`: the step helper and the controller '
+ 'already run opus, and every gate already runs. No `gates_mode` is set from the effort.');
});
// --- Phase 8 writes the merge evidence file before the merge call (4.5b2, MEV) --

View file

@ -1,76 +1,17 @@
// tests/commands/trekplan.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekplan.
// Prose-pin tests for /trekplan.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekplan.md');
const PHASE = 'plan';
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
function frontmatterOf(text) {
const doc = parseDocument(text);
return doc.parsed && doc.parsed.frontmatter;
}
// --- Pattern D prose-pins (kept) ---
test('trekplan — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
const text = read();
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
'/trekplan must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
assert.ok(text.includes('phase_signals'),
'/trekplan must reference phase_signals (v5.1 composition rule)');
});
test('trekplan — low-effort path references --quick equivalent', () => {
const text = read();
const compIdx = text.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const section = text.slice(compIdx, compIdx + 2000);
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
});
// --- v5.1.1 runtime SC4 + SC7 tests ---
test('trekplan — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'low');
assert.equal(r.model, 'sonnet');
});
test('trekplan — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'standard');
assert.equal(r.model, undefined);
});
test('trekplan — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'high');
assert.equal(r.model, 'opus');
});
test('trekplan — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
);
});
// --- S31 / V15: export trim — drop pr|issue|markdown, keep headless as a --decompose alias ---

View file

@ -1,63 +1,21 @@
// tests/commands/trekresearch.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekresearch.
// Prose-pin tests for /trekresearch.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekresearch.md');
const PHASE = 'research';
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
function frontmatterOf(text) {
const doc = parseDocument(text);
return doc.parsed && doc.parsed.frontmatter;
}
// --- Pattern D prose-pins ---
test('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
const text = read();
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
'/trekresearch must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
assert.ok(text.includes('phase_signals'),
'/trekresearch must reference phase_signals (v5.1 composition rule)');
});
test('trekresearch — low-effort path references --quick equivalent', () => {
const text = read();
// Bound the Composition rule section by the next `###` heading rather than a
// magic 2000-character window: a fixed count silently drops the match as soon
// as prose is inserted above it, turning a real pin into a no-op.
const sectionOf = (doc) => {
const compIdx = doc.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const nextHeading = doc.indexOf('\n### ', compIdx);
return nextHeading > compIdx ? doc.slice(compIdx, nextHeading) : doc.slice(compIdx);
};
// (a) positive: the low-effort path is documented inside the bounded section.
assert.match(sectionOf(text), /--quick/, 'Low-effort path must mention --quick equivalent');
// (b) negative: an actual removal must still be caught — a bound that can
// never fail proves nothing.
const mutated = text.replace(/--quick/g, '--removed');
assert.doesNotMatch(sectionOf(mutated), /--quick/,
'heading-bounded slice must still fail on a genuine removal');
});
// --- Phase 5 slice (heading-bounded) ---
// Same bounding discipline as the Composition-rule pin above: slice from the
// phase heading to the NEXT phase heading, never a fixed character window.
// Slice from the phase heading to the NEXT phase heading, never a fixed character window.
function phaseSlice(doc, startHeading, endHeading) {
const start = doc.indexOf(startHeading);
assert.ok(start >= 0, `${startHeading} missing`);
@ -70,52 +28,15 @@ function phase5(doc) {
return phaseSlice(doc, '## Phase 5 —', '## Phase 6 —');
}
test('trekresearch — High-effort behavior gives standard effort the wave-2 sentence and keeps the low effort sentence verbatim', () => {
const text = read();
const flatText = text.replace(/\s+/g, ' ');
test('trekresearch — Phase 4 runs the full swarm with contrarian-researcher as wave 2 every time (decision 209)', () => {
const flatText = read().replace(/\s+/g, ' ');
assert.ok(
flatText.includes('Every run launches the FULL swarm for its scope: all 5 local agents and all 4 external agents, and `contrarian-researcher` runs every time, as wave 2 (see Launch rules)'),
'Phase 4 must state the one fixed path: the full swarm, contrarian-researcher as wave 2 every run',
);
assert.ok(
!flatText.includes('use the existing conditional triggers'),
'standard effort must not point at a conditional trigger that nothing defines',
);
assert.ok(
flatText.includes('Standard effort (or absent): the selected agents run as in Phase 4, and `contrarian-researcher`, when selected, runs as wave 2 after the other agents return (see Launch rules).'),
'the standard-effort sentence must say the contrarian runs as wave 2 when selected',
);
assert.ok(
text.includes('Low effort: inline research only, no agent swarm'),
'the low-effort sentence must survive the Phase 5 rewrite verbatim',
);
});
// --- v5.1.1 runtime SC4 + SC7 ---
test('trekresearch — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'low');
assert.equal(r.model, 'sonnet');
});
test('trekresearch — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'standard');
assert.equal(r.model, undefined);
});
test('trekresearch — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'high');
assert.equal(r.model, 'opus');
});
test('trekresearch — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
'no effort must point at a conditional trigger that nothing defines',
);
});
@ -134,17 +55,14 @@ function paragraphAt(text, marker) {
return flat(lines.slice(start, end).join(' '));
}
test('trekresearch — Phase 5 follow-ups take phase_signal_result.model, else opus', () => {
test('trekresearch — Phase 5 follow-ups: a built-in agent is spawned with model opus, at most 2', () => {
const text = read();
assert.doesNotMatch(flat(phase5(text)), /model: "opus"/);
const part = paragraphAt(phase5(text), 'launch a targeted follow-up agent');
assert.match(part, /Maximum 2 follow-ups\./);
assert.match(part, /launch a targeted follow-up agent \(model: phase_signal_result\.model, else opus — a follow-up may be one of Claude Code's built-in agents, which have no Voyage frontmatter to fall back to; see Launch rules\)/);
assert.match(flat(text), /captured as `phase_signal_result` and used at Agent-spawn sites in Phase 4 and Phase 5 to inject the resolved model/);
assert.ok(part.includes("A follow-up may be one of Claude Code's built-in agents: Spawn it with `model: \"opus\"`: a built-in agent has no Voyage pin."), part);
});
test('trekresearch — promises no maxTurns budget', () => {
const text = read();
assert.doesNotMatch(text, /maxTurns/);
assert.match(text, /- convention-scanner: medium\+ codebases only \(50\+ files\)/);
});

View file

@ -1,74 +0,0 @@
// tests/commands/trekreview.test.mjs
// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekreview.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekreview.md');
const PHASE = 'review';
function read() { return readFileSync(COMMAND_FILE, 'utf8'); }
function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); }
function frontmatterOf(text) {
const doc = parseDocument(text);
return doc.parsed && doc.parsed.frontmatter;
}
// --- Pattern D prose-pins ---
test('trekreview — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => {
const text = read();
assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'),
'/trekreview must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate');
assert.ok(text.includes('phase_signals'),
'/trekreview must reference phase_signals (v5.1 composition rule)');
});
test('trekreview — low-effort path references --quick equivalent', () => {
const text = read();
const compIdx = text.indexOf('## Composition rule (v5.1)');
assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing');
const section = text.slice(compIdx, compIdx + 2000);
assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent');
});
// --- v5.1.1 runtime SC4 + SC7 ---
test('trekreview — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => {
const fm = frontmatterOf(readFixture('brief-effort-low.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'low');
assert.equal(r.model, 'sonnet');
});
test('trekreview — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => {
const fm = frontmatterOf(readFixture('brief-effort-standard.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'standard');
assert.equal(r.model, undefined);
});
test('trekreview — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => {
const fm = frontmatterOf(readFixture('brief-effort-high.md'));
const r = resolvePhaseSignal(fm, PHASE);
assert.equal(r.effort, 'high');
assert.equal(r.model, 'opus');
});
test('trekreview — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => {
// Falsification via brief-v21-no-signals fixture: validator must catch missing signals.
const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`sequencing gate must fire; errors=${JSON.stringify(r.errors)}`,
);
});

View file

@ -1,45 +0,0 @@
---
type: trekbrief
brief_version: "2.1"
created: 2026-07-02
task: "Fixture: high-effort all phases on fable (v5.9 allowlist test)"
slug: brief-effort-fable
project_dir: .claude/projects/2026-07-02-brief-effort-fable/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 4
source: fixture
phase_signals:
- phase: research
effort: high
model: fable
- phase: plan
effort: high
model: fable
- phase: execute
effort: high
model: fable
- phase: review
effort: high
model: fable
---
# Task: High-effort fable fixture
## Intent
Test fixture for the v5.9 fable model tier — all 4 phases at the
high effort tier with explicit fable model overrides. Mirrors
brief-effort-high.md with `model: opus` replaced by `model: fable`.
## Goal
Resolver returns `{effort: 'high', model: 'fable'}` for each of the 4
PHASE_SIGNAL_PHASES.
## Success Criteria
- Validator passes with no BRIEF_INVALID_MODEL.
- resolvePhaseSignal(fm, phase).effort === 'high' for all 4 phases.
- resolvePhaseSignal(fm, phase).model === 'fable' for all 4 phases.

View file

@ -1,45 +0,0 @@
---
type: trekbrief
brief_version: "2.1"
created: 2026-05-14
task: "Fixture: high-effort all phases (v5.1.1 runtime test)"
slug: brief-effort-high
project_dir: .claude/projects/2026-05-14-brief-effort-high/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 4
source: fixture
phase_signals:
- phase: research
effort: high
model: opus
- phase: plan
effort: high
model: opus
- phase: execute
effort: high
model: opus
- phase: review
effort: high
model: opus
---
# Task: High-effort fixture
## Intent
Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the
high effort tier with explicit opus model overrides. Mirrors the
production-grade premium-profile scenario.
## Goal
Resolver returns `{effort: 'high', model: 'opus'}` for each of the 4
PHASE_SIGNAL_PHASES.
## Success Criteria
- Validator passes.
- resolvePhaseSignal(fm, phase).effort === 'high' for all 4 phases.
- resolvePhaseSignal(fm, phase).model === 'opus' for all 4 phases.

View file

@ -1,43 +0,0 @@
---
type: trekbrief
brief_version: "2.1"
created: 2026-05-14
task: "Fixture: low-effort all phases (v5.1.1 runtime test)"
slug: brief-effort-low
project_dir: .claude/projects/2026-05-14-brief-effort-low/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 4
source: fixture
phase_signals:
- phase: research
effort: low
model: sonnet
- phase: plan
effort: low
model: sonnet
- phase: execute
effort: low
model: sonnet
- phase: review
effort: low
model: sonnet
---
# Task: Low-effort fixture
## Intent
Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the lowest
effort tier with explicit sonnet model overrides.
## Goal
Resolver returns `{effort: 'low', model: 'sonnet'}` for each of the 4
PHASE_SIGNAL_PHASES.
## Success Criteria
- Validator passes.
- resolvePhaseSignal(fm, phase) is non-null for all 4 phases.

View file

@ -1,42 +0,0 @@
---
type: trekbrief
brief_version: "2.1"
created: 2026-05-14
task: "Fixture: standard-effort all phases, no model (v5.1.1 runtime test)"
slug: brief-effort-standard
project_dir: .claude/projects/2026-05-14-brief-effort-standard/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 4
source: fixture
phase_signals:
- phase: research
effort: standard
- phase: plan
effort: standard
- phase: execute
effort: standard
- phase: review
effort: standard
---
# Task: Standard-effort fixture (no model override)
## Intent
Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the
standard tier WITHOUT explicit model fields. This is the operator-skipped
model path that should fall through to the profile.
## Goal
Resolver returns `{effort: 'standard', model: undefined}` for each of the 4
PHASE_SIGNAL_PHASES. The orchestrator-model path then falls through to the
active profile's phase_models.
## Success Criteria
- Validator passes.
- resolvePhaseSignal(fm, phase).model is undefined.
- resolvePhaseSignal(fm, phase).effort is 'standard'.

View file

@ -1,32 +0,0 @@
---
type: trekbrief
brief_version: "2.1"
created: 2026-05-14
task: "Fixture: v5.1 brief WITHOUT phase_signals or partial (falsification target)"
slug: brief-v21-no-signals
project_dir: .claude/projects/2026-05-14-brief-v21-no-signals/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 4
source: fixture
---
# Task: brief_version 2.1 without phase_signals
## Intent
Falsification fixture for the v5.1 sequencing gate. The brief declares
`brief_version: "2.1"` but omits BOTH `phase_signals` AND
`phase_signals_partial: true`. The brief-validator MUST emit
`BRIEF_V51_MISSING_SIGNALS` for this file — the runtime test for the
sequencing gate asserts the error code fires.
## Goal
Validate that brief-validator catches the missing-signals scenario.
## Success Criteria
- brief-validator returns valid: false.
- errors contains BRIEF_V51_MISSING_SIGNALS.

View file

@ -1,42 +0,0 @@
---
type: trekbrief
brief_version: "2.1"
created: 2026-05-13
task: "Add per-phase effort dialog to /trekbrief"
slug: phase-signals-example
project_dir: .claude/projects/2026-05-13-phase-signals-example/
research_topics: 2
research_status: complete
auto_research: false
interview_turns: 6
source: interview
phase_signals:
- phase: research
effort: low
model: sonnet
- phase: plan
effort: standard
- phase: execute
effort: high
model: opus
- phase: review
effort: standard
---
# Task: Phase-signals example
## Intent
A minimal brief that exercises the v5.1 phase_signals additive field with a
mix of effort levels and model overrides. Used by tests/validators to confirm
the validator accepts well-formed signals across the supported tier matrix.
## Goal
Validator returns valid: true. annotate.mjs strips phase_signals from the
rendered HTML body (frontmatter stays in source).
## Success Criteria
- Validator passes.
- annotate.mjs determinism: re-run produces byte-identical HTML.

View file

@ -1,31 +0,0 @@
---
type: trekbrief
brief_version: "2.0"
created: 2026-05-13
task: "Backward-compat fixture for v5.0-style brief"
slug: legacy-brief-example
project_dir: .claude/projects/2026-05-13-legacy-brief-example/
research_topics: 0
research_status: complete
auto_research: false
interview_turns: 3
source: interview
---
# Task: Legacy brief example
## Intent
A pre-v5.1 brief that pre-dates the phase_signals field. Used by
tests/validators to confirm backward-compatibility: the brief is accepted
without phase_signals as long as brief_version is < 2.1.
## Goal
Validator returns valid: true. The sequencing gate
(BRIEF_V51_MISSING_SIGNALS) does NOT fire for brief_version 2.0.
## Success Criteria
- Validator passes.
- No BRIEF_V51_MISSING_SIGNALS error in r.errors.

View file

@ -1,57 +0,0 @@
---
plan_version: "1.7"
profile: economy
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: sonnet
- phase: execute
model: sonnet
- phase: review
model: sonnet
- phase: continue
model: sonnet
---
# Test plan — profile drift fixture
Frontmatter declares `profile: economy`. Step 1 manifest has matching
profile_used. Step 2 manifest declares `profile_used: premium` — the
drift case Step 20 of v4.1 plan-validator must catch in --strict mode.
## Implementation Plan
### Step 1: matching profile
- Files: a.ts
- Manifest:
```yaml
manifest:
expected_paths:
- a.ts
min_file_count: 1
commit_message_pattern: "^feat:"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
profile_used: economy
```
### Step 2: drift to premium
- Files: b.ts
- Manifest:
```yaml
manifest:
expected_paths:
- b.ts
min_file_count: 1
commit_message_pattern: "^feat:"
bash_syntax_check: []
forbidden_paths: []
must_contain: []
profile_used: premium
```

View file

@ -1,26 +0,0 @@
---
plan_version: "1.7"
profile: balanced
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
---
# Test plan (with profile)
This fixture has explicit profile + phase_models in frontmatter.
## Implementation Plan
### Step 1: Stub
- Files: src/stub.mjs

View file

@ -1,15 +0,0 @@
---
plan_version: "1.6"
---
# Test plan (v4.0-style, no profile field)
This fixture is a v4.0-style plan WITHOUT the v4.1 profile/phase_models fields.
Used by tests/lib/profile-application.test.mjs to verify backward-compat
edge-case: resolveTrekcontinueProfile returns {profile: 'premium', profile_source: 'default'}
without throwing when the plan has no profile concept.
## Implementation Plan
### Step 1: Stub
- Files: src/stub.mjs

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: invalid-enum
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 4
parallel_agents_max: 6
external_research_enabled: "yes"
brief_reviewer_iter_cap: 2
---

View file

@ -1,21 +0,0 @@
---
profile_version: "1.0"
name: invalid-model
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: gpt-4
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 4
external_research_enabled: false
brief_reviewer_iter_cap: 2
---

View file

@ -1,153 +0,0 @@
// tests/integration/profile-jaccard-smoke.test.mjs
// SC #18 — cross-tier Jaccard smoke-test for v4.1 model profiles.
//
// Pairs the 4 parked-synthetic fixtures from Step 17:
// profile-plan-run-economy-{1,2}.md × profile-plan-run-premium-{1,2}.md
//
// Asserts that every cross-tier pair clears CROSS_TIER_JACCARD_FLOOR
// after string-normalisering (lowercase, strip backticks/parens, collapse
// whitespace). The pre-gates run BEFORE Jaccard:
// 1. Frontmatter parses cleanly on both fixtures
// 2. Step-count parity (±20 %) — hard fail independent of Jaccard
//
// Empirically calibrated, NOT literature-canonical (see
// research/02-jaccard-syntese-quality.md). arXiv:2412.12148: there is no
// universal threshold; 0.55 is conservative starting point per Step 17
// calibration file (tests/synthetic/profile-jaccard-calibration.md).
//
// Plan-critic-fallback (auto-tighten if Jaccard insufficient) is NOT in
// v4.1 — deferred to v4.2 per research/02 Recommendation #5.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { jaccardSimilarity } from '../../lib/parsers/jaccard.mjs';
import { normalizeSteps, checkStepCountParity } from '../../lib/parsers/profile-jaccard.mjs';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
// Empirically calibrated, NOT literature-canonical.
// See tests/synthetic/profile-jaccard-calibration.md for derivation.
const CROSS_TIER_JACCARD_FLOOR = 0.55;
const ECONOMY_FIXTURES = [
'tests/synthetic/profile-plan-run-economy-1.md',
'tests/synthetic/profile-plan-run-economy-2.md',
];
const PREMIUM_FIXTURES = [
'tests/synthetic/profile-plan-run-premium-1.md',
'tests/synthetic/profile-plan-run-premium-2.md',
];
function loadSteps(rel) {
const text = readFileSync(join(ROOT, rel), 'utf-8');
const doc = parseDocument(text);
assert.ok(
doc.valid,
`frontmatter of ${rel} did not parse: ${(doc.errors || []).map((e) => e.message).join(', ')}`,
);
const steps = doc.parsed.frontmatter && doc.parsed.frontmatter.steps;
assert.ok(
Array.isArray(steps) && steps.length > 0,
`frontmatter.steps of ${rel} is missing or empty`,
);
return steps;
}
// --- Pre-gate 1: structural frontmatter integrity (acts as plan-validator
// pre-gate for synthetic frontmatter-only fixtures; real plan-md goes
// through node lib/validators/plan-validator.mjs --strict separately).
test('profile-jaccard-smoke — pre-gate: all 4 fixtures parse cleanly with frontmatter.steps', () => {
for (const rel of [...ECONOMY_FIXTURES, ...PREMIUM_FIXTURES]) {
const steps = loadSteps(rel);
assert.ok(steps.length >= 10, `${rel}: < 10 steps (got ${steps.length})`);
// Sanity: all entries are non-empty strings
for (const s of steps) {
assert.equal(typeof s, 'string', `${rel}: non-string step: ${JSON.stringify(s)}`);
assert.ok(s.trim().length > 0, `${rel}: empty step entry`);
}
}
});
// --- Pre-gate 2: step-count parity (±20 % cross-tier).
test('profile-jaccard-smoke — pre-gate: step-count parity ±20% across cross-tier pairs', () => {
for (const eFix of ECONOMY_FIXTURES) {
for (const pFix of PREMIUM_FIXTURES) {
const eSteps = loadSteps(eFix);
const pSteps = loadSteps(pFix);
const r = checkStepCountParity(eSteps, pSteps, 0.34);
// Note: synthetic economy=30, premium=40 → ratio = 10/40 = 0.25.
// We allow 0.34 here because empirical cross-tier may exceed 0.20
// when one tier prunes verification steps. Tighten in v4.2 once
// empirical data lands.
assert.ok(r.ok, `${eFix} × ${pFix}: ${r.message}`);
}
}
});
// --- Cross-tier Jaccard: every pair must clear floor (after normalisering).
test('profile-jaccard-smoke — cross-tier Jaccard ≥ floor for all 4 economy×premium pairs', () => {
const pairs = [];
for (const eFix of ECONOMY_FIXTURES) {
for (const pFix of PREMIUM_FIXTURES) {
const eSteps = normalizeSteps(loadSteps(eFix));
const pSteps = normalizeSteps(loadSteps(pFix));
const sim = jaccardSimilarity(eSteps, pSteps);
pairs.push({ eFix, pFix, sim });
}
}
// Report all pairs in failure message for diagnostic clarity.
const failures = pairs.filter((p) => p.sim < CROSS_TIER_JACCARD_FLOOR);
if (failures.length > 0) {
const summary = pairs
.map((p) => ` ${p.eFix.split('/').pop()} × ${p.pFix.split('/').pop()}: ${p.sim.toFixed(3)}`)
.join('\n');
assert.fail(
`${failures.length}/${pairs.length} cross-tier pairs below floor ${CROSS_TIER_JACCARD_FLOOR}:\n${summary}`,
);
}
// Sanity-floor: at least 4 pairs measured (2×2 cross product).
assert.equal(pairs.length, 4, 'expected 4 cross-tier pairs (2 economy × 2 premium)');
});
// --- Intra-tier sanity: same-profile pairs must have HIGHER Jaccard than
// cross-tier (otherwise the smoke-test is not actually discriminating).
test('profile-jaccard-smoke — intra-tier Jaccard > cross-tier mean (sanity for discriminator)', () => {
const intraEconomy = jaccardSimilarity(
normalizeSteps(loadSteps(ECONOMY_FIXTURES[0])),
normalizeSteps(loadSteps(ECONOMY_FIXTURES[1])),
);
const intraPremium = jaccardSimilarity(
normalizeSteps(loadSteps(PREMIUM_FIXTURES[0])),
normalizeSteps(loadSteps(PREMIUM_FIXTURES[1])),
);
let crossSum = 0;
let crossN = 0;
for (const eFix of ECONOMY_FIXTURES) {
for (const pFix of PREMIUM_FIXTURES) {
crossSum += jaccardSimilarity(
normalizeSteps(loadSteps(eFix)),
normalizeSteps(loadSteps(pFix)),
);
crossN += 1;
}
}
const crossMean = crossSum / crossN;
assert.ok(
intraEconomy > crossMean,
`intra-tier Jaccard (economy: ${intraEconomy.toFixed(3)}) must exceed cross-tier mean (${crossMean.toFixed(3)})`,
);
assert.ok(
intraPremium > crossMean,
`intra-tier Jaccard (premium: ${intraPremium.toFixed(3)}) must exceed cross-tier mean (${crossMean.toFixed(3)})`,
);
});

View file

@ -281,3 +281,8 @@ test('trekreview --answers: the parser accepts it as a boolean, and the usage li
assert.equal(usage.status, 1, usage.stdout + usage.stderr);
assert.match(usage.stderr, /Usage: \/trekreview --project <dir> [^\n]*\[--answers\]/, `the usage line names --answers: ${usage.stderr}`);
});
test('retired --profile swallows its value: --profile economy leaves positional untouched', () => {
const r = parseArgs('--profile economy .claude/projects/x', 'trekcontinue');
assert.deepEqual(r.positional, ['.claude/projects/x'], `the retired flag's value is no positional: ${JSON.stringify(r)}`);
});

View file

@ -169,10 +169,9 @@ test('[#1] sibling: commands/trekexecute.md: `gates_mode` changes nothing in `/t
'trekexecute does not say gates_mode changes nothing there and the one stop is the merge into main');
});
test('[#1] sibling: docs/architecture.md does not call --gates open the executor\'s low effort', () => {
test('[#1] sibling: docs/architecture.md does not name --gates open', () => {
const text = read('docs/architecture.md');
assert.doesNotMatch(text, /--gates open/);
assert.match(text, /\(`\/trekexecute` low effort = one session at a time\)/);
});
test('[#1] sibling: docs/command-modes.md gives every --gates row the four values and no strictness policy', () => {

View file

@ -124,8 +124,7 @@ test('computeVerdict — counts each severity tier', () => {
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.
// rule_key is a real defect wearing the wrong label.
const r = reasonablenessFilter([
{ 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

View file

@ -833,7 +833,7 @@ test('runCriteria: a nested `node --test` on a failing test is red even when NOD
function briefWith(sections) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
...sections,
].join('\n');

View file

@ -7,8 +7,8 @@
//
// Two kinds of pin live here (devil's-advocate audit §Top changes #8, S19):
// • STRUCTURAL cross-file invariants — derive one side from a source-of-truth
// (file counts, dimension counts from `### N.` headers, profile tables from
// the yaml, version-sync) and survive harmless rewording. These are the
// (file counts, dimension counts from `### N.` headers, version-sync) and
// survive harmless rewording. These are the
// load-bearing ones; keep and grow them.
// • PROSE / EXISTENCE pins — assert a literal string or file fact. Most still
// guard a real wiring (CLI shims, annotate.mjs, anti-stale CHANGELOG,
@ -22,7 +22,6 @@ import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../../lib/util/frontmatter.mjs';
import { resolveProfile, loadProfile } from '../../lib/profiles/resolver.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -310,27 +309,6 @@ test('S23: Phase-9 dedup hand-off uses --stdin and the reviewers emit a json fin
}
});
// --- S20 — anti-false-claim: operations.md custom-profile prose must match findProfilePath ---
//
// findProfilePath (lib/profiles/resolver.mjs:57) resolves built-in FIRST, then
// repo-root voyage-profiles/, then ~/.claude/voyage-profiles/. Custom profiles
// live in voyage-profiles/ (NOT lib/profiles/), must use NEW names, and CANNOT
// shadow a built-in. The old prose claimed the opposite (lib/profiles/<custom>;
// "override built-ins of the same name"; "alphabetical … precedence") — guard it.
test('operations.md custom-profile prose matches findProfilePath resolution order [S20]', () => {
const ops = read('docs/operations.md');
for (const bad of ['lib/profiles/<custom>', 'alphabetical', 'override built-ins of the same name']) {
assert.ok(
!ops.includes(bad),
`docs/operations.md: false custom-profile claim "${bad}" contradicts findProfilePath — fix the prose, not this test`,
);
}
assert.ok(
ops.includes('voyage-profiles/'),
'docs/operations.md must point custom profiles at voyage-profiles/ (not lib/profiles/)',
);
});
// --- S24 — anti-false-claim: operations.md + command-modes.md autonomy-gate truth-pass ---
//
// autonomy-gate.mjs is the --gates flag parser: --gates [true|false|closed|open]
@ -371,113 +349,6 @@ test('operations.md + command-modes.md describe the real autonomy-gate, not a fa
}
});
// --- v4.1 Step 21 — pin --profile + phase_models on the 6 commands ---
//
// CLAUDE.md / README.md pinning is deferred to Step 22 (post-write of
// those documents). Step 21 only verifies command-file content, which
// was written in Step 7 (Wave 3).
const PIPELINE_COMMANDS = [
'trekbrief.md',
'trekresearch.md',
'trekplan.md',
'trekexecute.md',
'trekreview.md',
'trekcontinue.md',
];
test('every pipeline command-file documents the --profile flag (SC #20)', () => {
for (const f of PIPELINE_COMMANDS) {
const text = read(`commands/${f}`);
assert.match(
text,
/--profile\b/,
`commands/${f}: --profile flag is required documentation in v4.1`,
);
}
});
test('command-files mentioning model profiles use canonical name `phase_models`', () => {
// Reject legacy / brainstormed alternatives that would confuse readers.
const FORBIDDEN = ['model_per_phase', 'phase_to_model', 'profile_phase_models'];
for (const f of PIPELINE_COMMANDS) {
const text = read(`commands/${f}`);
for (const bad of FORBIDDEN) {
assert.ok(
!text.includes(bad),
`commands/${f}: forbidden alias "${bad}" — canonical name is "phase_models"`,
);
}
}
});
test('at least one pipeline command-file references `phase_models` canonical name', () => {
// Sanity: not every command has to enumerate phase_models inline (e.g.
// trekbrief and trekcontinue may only mention --profile), but ≥ 1
// command-file must spell out the canonical name so the regression test
// pins drift.
let mentioned = 0;
for (const f of PIPELINE_COMMANDS) {
if (read(`commands/${f}`).includes('phase_models')) mentioned += 1;
}
assert.ok(
mentioned >= 1,
`expected ≥ 1 command-file to mention canonical name "phase_models", got ${mentioned}`,
);
});
// --- v4.1 Step 22 — post-write CLAUDE.md / README.md pinning ---
//
// Plan-critic Blocker 2 fix: Step 21 only pinned commands/*.md (which
// are written in Step 7 / Wave 3). Step 22 writes the top-level docs
// and extends pinning here so doc-consistency stays green AFTER Step 22.
test('CLAUDE.md documents --profile flag', () => {
const md = read('CLAUDE.md');
assert.match(
md,
/--profile\b/,
'CLAUDE.md must document the --profile flag (v4.1 SC #20)',
);
});
test('canonical name `phase_models` documented in docs/profiles.md', () => {
// v5.x doc consolidation (67f6dd5) moved profile/phase detail OUT of
// CLAUDE.md and into docs/. docs/profiles.md is now the canonical home for
// phase_models; CLAUDE.md only links to it via "Reference docs". Assert the
// canonical name lives in the canonical doc and no legacy alias crept in.
// (was: read('CLAUDE.md') — v4.1 SC #20.)
const md = read('docs/profiles.md');
assert.match(
md,
/phase_models/,
'docs/profiles.md must use canonical name "phase_models" (v4.1 SC #20)',
);
for (const bad of ['model_per_phase', 'phase_to_model', 'profile_phase_models']) {
assert.ok(
!md.includes(bad),
`docs/profiles.md must NOT use legacy alias "${bad}"`,
);
}
});
test('README.md documents --profile flag for all 6 commands', () => {
// SG1: README flag-table coverage is gating for SC #20. README is the
// primary discovery surface for new users.
const md = read('README.md');
// Top-level Profile system section is required so the flag is
// discoverable independent of per-command tables.
assert.match(md, /## Profile system/, 'README.md missing top-level "## Profile system" section');
// Every per-command Modes table must include --profile (count of
// --profile occurrences should be ≥ 6 — one per command + Profile
// system section).
const profileMentions = (md.match(/--profile\b/g) || []).length;
assert.ok(
profileMentions >= 6,
`README.md must mention --profile ≥ 6 times (one per command + section), got ${profileMentions}`,
);
});
test('CHANGELOG.md has v4.1.0 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(
@ -487,19 +358,6 @@ test('CHANGELOG.md has v4.1.0 entry', () => {
);
});
test('docs/profiles.md exists and documents Custom.yaml authoring', () => {
const dp = read('docs/profiles.md');
assert.ok(dp.length > 1000, 'docs/profiles.md must be substantive (> 1000 chars)');
// Must document custom-profile authoring (Step 22 manifest must_contain
// pattern: "Custom.yaml" — case-insensitive match handled here as
// /[Cc]ustom[. ]/ to allow either "custom.yaml" or "Custom profile" prose).
assert.match(
dp,
/[Cc]ustom\.yaml|[Cc]ustom profile|<custom>\.yaml/,
'docs/profiles.md must document custom profile authoring',
);
});
test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => {
const cmd = read('commands/trekplan.md');
// Locate Phase 8 section
@ -714,7 +572,7 @@ test('operational files no longer reference trekrevise (v5.0.0 removal)', () =>
}
});
// --- v5.1 — phase_signals + brief_version 2.1 ---
// --- v5.1 — brief_version 2.1 ---
test('v5.5 — templates/trekbrief-template.md declares brief_version: "2.2" (quoted)', () => {
const t = read('templates/trekbrief-template.md');
@ -722,67 +580,11 @@ test('v5.5 — templates/trekbrief-template.md declares brief_version: "2.2" (qu
'trekbrief-template.md must declare brief_version: "2.2" (quoted) — unquoted parses as Number and bypasses sequencing gate; v5.5 bumped 2.1→2.2 for framing enforcement');
});
test('v5.1 — templates/trekbrief-template.md contains phase_signals: block', () => {
const t = read('templates/trekbrief-template.md');
assert.match(t, /^phase_signals:$/m,
'trekbrief-template.md must contain a phase_signals: block in frontmatter');
});
test('v5.1 — HANDOVER-CONTRACTS.md schema row includes phase_signals + phase_signals_partial', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('| `phase_signals` |'),
'HANDOVER-CONTRACTS must add a phase_signals row to the Handover 1 schema table');
assert.ok(t.includes('| `phase_signals_partial` |'),
'HANDOVER-CONTRACTS must add a phase_signals_partial row to the Handover 1 schema table');
});
test('v5.1 — phase_signals documented in docs/HANDOVER-CONTRACTS.md', () => {
// v5.x doc consolidation (67f6dd5) moved phase_signals out of CLAUDE.md.
// phase_signals is a Handover 1 (brief) field, so docs/HANDOVER-CONTRACTS.md
// is its canonical home (the schema rows are pinned separately above).
// CLAUDE.md only links to it. (was: read('CLAUDE.md') — v5.1.)
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(t.includes('phase_signals'),
'docs/HANDOVER-CONTRACTS.md must document phase_signals (v5.1)');
});
test('v5.1 — voyage README.md mentions phase_signals', () => {
const t = read('README.md');
assert.ok(t.includes('phase_signals'),
'voyage README.md must mention phase_signals (v5.1 "What\'s new" bullet)');
});
// --- v5.1.1 — High-effort behavior sub-section per command (Step 10) ---
test('v5.1.1 — commands/trekplan.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekplan.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekplan.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (Adversarial Pass 2, unavailable/skipped since v5.10.1)');
});
test('v5.1.1 — commands/trekresearch.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekresearch.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekresearch.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (contrarian-researcher always-on)');
});
test('v5.1.1 — commands/trekreview.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekreview.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekreview.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (skip Pass 3 + coordinator normalization)');
});
test('v5.1.1 — commands/trekexecute.md contains ### High-effort behavior (v5.1.1) sub-section', () => {
const t = read('commands/trekexecute.md');
assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m,
'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (high effort changes nothing in /trekexecute)');
});
// --- v5.4 — brief.md formalized as PUBLIC CONTRACT (Handover 1) ---
// S5: the brief schema is the only public producer↔Voyage integration boundary
// (Trinity asymmetry invariant). These pin the formalization as enforced doc-truth,
// not prose — same pattern as the v5.1 Handover-1 pins above. Freeze, not promote:
// phase_signals stays optional; brief_version 2.1 is the frozen baseline (S3 decision).
// brief_version 2.1 is the frozen baseline (S3 decision).
test('v5.4 — HANDOVER-CONTRACTS.md Handover 1 heading is labeled (PUBLIC CONTRACT)', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
@ -808,10 +610,8 @@ test('v5.5 — brief_version 2.2 is current in the public-contract schema table
'Handover 1 schema table must mark brief_version "2.2" as current');
});
test('v5.5 — phase_signals stays optional: not promoted to required', () => {
test('v5.5 — the Versioning note cites no phantom v5.4 and records the 2.1 baseline', () => {
const t = read('docs/HANDOVER-CONTRACTS.md');
assert.ok(!t.includes('v5.4 may promote'),
'the speculative "v5.4 may promote phase_signals to required" line must be resolved (S3 froze the shape)');
assert.ok(!/v5\.4/.test(t),
'the contract doc must not cite the phantom v5.4 release (v5.2–5.4 never shipped; S16 → v5.5.0)');
assert.ok(t.includes('established `2.1` as the public-contract baseline'),
@ -851,103 +651,6 @@ test('v5.5 — voyage README.md mentions framing enforcement / brief_version 2.2
'voyage README.md must carry a "What\'s new" note for framing enforcement (brief_version 2.2)');
});
// --- S15 — default-profile name + per-phase model claims (cost-claim truth-pass) ---
// The resolver's hardcoded default (lib/profiles/resolver.mjs) is the single
// source of truth for which built-in profile a fresh user gets. README,
// docs/profiles.md, and docs/operations.md must all mark THAT profile as the
// default and must not mark any other built-in as default. Guards the
// code-vs-docs contradiction the S14 audit surfaced (Addendum finding #3).
const PROFILE_DOCS = ['README.md', 'docs/profiles.md', 'docs/operations.md'];
test('S15: default-profile name is consistent across resolver + all profile docs', () => {
// Empty env so the operator's VOYAGE_PROFILE does not mask the hardcoded default.
const { profile: def, profile_source } = resolveProfile({}, {});
assert.equal(profile_source, 'default', 'resolveProfile({}, {}) must report source=default');
assert.equal(def, 'premium', 'resolver hardcoded default is premium (operator decision 2026-05-13, commit 40d8742)');
const OTHERS = ['economy', 'balanced', 'premium', 'fable'].filter((p) => p !== def);
for (const doc of PROFILE_DOCS) {
const body = read(doc);
assert.ok(
body.includes(`\`${def}\` (default)`),
`${doc}: profile table must mark \`${def}\` as "(default)" to match resolver`,
);
for (const other of OTHERS) {
assert.ok(
!body.includes(`\`${other}\` (default)`),
`${doc}: must NOT mark \`${other}\` as "(default)" — resolver default is ${def}`,
);
assert.ok(
!body.includes(`Default \`${other}\``),
`${doc}: lookup-order must not say "Default \`${other}\`" — resolver default is ${def}`,
);
}
}
});
test('S15: profile tables encode each built-in yaml phase_models exactly', () => {
// Column order in every profile table: Profile | Brief | Research | Plan | Execute | Review | Continue | Use case
const PHASES = ['brief', 'research', 'plan', 'execute', 'review', 'continue'];
for (const name of ['economy', 'balanced', 'premium', 'fable']) {
const pm = loadProfile(name).phase_models; // {brief:'opus', ...}
const expected = PHASES.map((ph) => pm[ph]);
for (const doc of PROFILE_DOCS) {
const row = read(doc)
.split('\n')
.find((l) => new RegExp(`^\\|\\s*\`${name}\``).test(l));
assert.ok(row, `${doc}: profile table is missing a \`${name}\` row`);
const cells = row.split('|').map((c) => c.trim());
// cells[0] is '' (leading pipe), cells[1] is the name, cells[2..7] are the 6 models
const docModels = cells.slice(2, 8);
assert.deepEqual(
docModels,
expected,
`${doc}: \`${name}\` row models ${JSON.stringify(docModels)} must equal lib/profiles/${name}.yaml ${JSON.stringify(expected)}`,
);
}
}
});
// --- 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
// (lib/profiles/economy.yaml `experimental: true`) and every profile doc's
// economy row. Fix the SOURCE doc/profile, not these pins.
test('S34: economy profile row is flagged experimental across all profile docs', () => {
for (const doc of PROFILE_DOCS) {
const body = read(doc);
const row = body.split('\n').find((l) => /^\|\s*`economy`/.test(l));
assert.ok(row, `${doc}: profile table missing an \`economy\` row`);
assert.match(
row,
/experimental/i,
`${doc}: the \`economy\` table row must flag it experimental ` +
`(uncalibrated Jaccard floor, S34/V30) — matches lib/profiles/economy.yaml experimental: true`,
);
}
});
test('S34: economy experimental flag agrees with parked-synthetic calibration status', () => {
// economy.yaml experimental:true is honest ONLY while calibration is still
// parked-synthetic. When empirical calibration lands (status: empirical),
// drop experimental:true in the SAME change — this pin enforces the linkage.
const eco = parseDocument(read('lib/profiles/economy.yaml')).parsed.frontmatter;
const cal = parseDocument(read('tests/synthetic/profile-jaccard-calibration.md')).parsed.frontmatter;
if (cal.status === 'parked-synthetic') {
assert.equal(
eco.experimental,
true,
'while calibration is parked-synthetic, economy.yaml must stay experimental: true',
);
} else {
assert.notEqual(
eco.experimental,
true,
'calibration is no longer parked-synthetic — drop economy experimental: true in the same change',
);
}
});
test('S15: README cost prose does not resurrect the false Sonnet-swarm claim', () => {
// All 24 sub-agents are model: opus (operator-pinned, commit 40d8742) and the
// model is uniform per phase — there is no "Opus orchestrates / Sonnet runs
@ -962,7 +665,7 @@ test('S15: README cost prose does not resurrect the false Sonnet-swarm claim', (
for (const phrase of BANNED) {
assert.ok(
!md.includes(phrase),
`README.md must not claim "${phrase}" — sub-agents are opus-pinned; default profile runs Opus on every phase`,
`README.md must not claim "${phrase}" — sub-agents are opus-pinned`,
);
}
});
@ -1070,7 +773,7 @@ test('S32: trekbrief Phase 3 delegates Q&A turn-taking to AskUserQuestion (V01
);
const p3Start = text.indexOf('## Phase 3 — Completeness loop');
assert.ok(p3Start >= 0, 'trekbrief.md must have a "## Phase 3 — Completeness loop" section');
const p3End = text.indexOf('## Phase 3.5', p3Start);
const p3End = text.indexOf('## Phase 4', p3Start);
const p3 = text.slice(p3Start, p3End > p3Start ? p3End : undefined);
assert.ok(
/Use `AskUserQuestion` for every question/.test(p3),
@ -1125,29 +828,6 @@ test('S38: forward-guard — no product-facing doc asserts main-context relief u
);
});
// ── v5.9 (fable-tier step 6) — composed-resolver wiring pin ────────────────
// The four pipeline commands must resolve {effort, model} via the composed
// CLI (`resolver.mjs --resolve-phase-model`, brief > profile > default). A
// direct `phase-signal-resolver.mjs --brief` invocation in a command Bash
// block is the brief-only CLI: it silently drops the profile layer (the
// AP2-1 footgun — `--profile <x>` would never reach sub-agent spawns again).
// If this pin fails, re-wire the command to the composed CLI — do not relax
// the pin.
for (const cmd of ['trekresearch', 'trekplan', 'trekreview', 'trekexecute']) {
test(`v5.9: commands/${cmd}.md invokes the composed resolver, not the brief-only CLI`, () => {
const text = read(`commands/${cmd}.md`);
assert.ok(
text.includes('--resolve-phase-model'),
`commands/${cmd}.md must invoke resolver.mjs --resolve-phase-model (composed brief > profile > default)`,
);
assert.ok(
!/phase-signal-resolver\.mjs --brief/.test(text),
`commands/${cmd}.md must not invoke the brief-only phase-signal-resolver CLI — the profile layer would be silently dropped`,
);
});
}
// ── v5.9 (fable-tier step 7) — orchestrator model-pin ABSENCE pin ───────────
// Command frontmatter deliberately omits `model:` — omission is the only
// session-inheritance spelling documented by BOTH official surfaces (AP2-2:
@ -1221,11 +901,7 @@ test('v5.9: no commands/*.md frontmatter carries a model: key (session inheritan
// class as bumping a version string inside a measurement doc.
//
// BLIND SPOT, stated rather than hidden: THIS file cannot be in the live-surface
// list, because the S95 block below is necessarily full of the word. So the two
// v5.1.1 assertion messages above (which describe the high-effort rules) are not
// covered by the scanner and were found stale by review, not by npm test — they
// match on the heading regex, so a wrong message never fails. When the high-effort
// rules change, re-read those messages by hand.
// list, because the S95 block below is necessarily full of the word.
// Live = anything that can still steer a run. Explicit, so a new file forces a decision.
const GEMINI_LIVE_SURFACES = [
@ -1275,20 +951,10 @@ test('S95: known-positive control — the historical records still DO carry gemi
);
});
test('S95: trekplan high-effort Pass 2 degrades explicitly instead of failing', () => {
const t = read('commands/trekplan.md');
const section = t.split('\n### High-effort behavior (v5.1.1)\n')[1];
assert.ok(section, 'trekplan.md must still carry the High-effort behavior sub-section');
const head = section.split('\n## ')[0];
assert.match(
head, /unavailable, skipped/,
'the dropped Pass 2 must read "unavailable, skipped" explicitly — a removed pass that is silent is indistinguishable from one that failed',
);
});
// End-state defect D-07: since v5.9.0 no command pins `model:`, so the orchestrator follows
// the session model, and sub-agent models are resolved at the spawn sites (brief signal >
// profile > agent frontmatter). No user-facing doc may claim otherwise. Fix the SOURCE.
// the session model; a Voyage agent's spawn passes no model (its own `model: opus` applies)
// and a built-in agent's spawn passes `model: "opus"`. No user-facing doc may claim
// otherwise. Fix the SOURCE.
test('D-07: no doc claims an opus orchestrator or ships a recipe for model: lines no command has', () => {
const readme = read('README.md');
assert.ok(
@ -1304,15 +970,19 @@ test('D-07: no doc claims an opus orchestrator or ships a recipe for model: line
}
});
test('D-07: trekresearch launch rules inject the resolved model instead of a blanket opus', () => {
test('D-07: trekresearch launch rules leave a Voyage agent on its own pin, with no blanket opus rule', () => {
const t = read('commands/trekresearch.md');
const rules = (t.split('\n### Launch rules\n')[1] || '').split('\n## ')[0];
assert.ok(rules.length > 0, 'trekresearch.md must still carry the Launch rules sub-section');
assert.ok(
!/model: "opus" for all sub-agents/i.test(rules),
'a blanket opus rule contradicts the file\'s own resolution (brief > profile > agent frontmatter, wired in v5.9.0)',
'a blanket opus rule contradicts the file\'s own rule: a Voyage agent keeps its own pin, only a built-in agent gets opus',
);
assert.match(rules, /phase_signal_result\.model/, 'the launch rules must name the resolved model the spawn sites inject');
const flatRules = rules.replace(/\s+/g, ' ');
assert.ok(flatRules.includes("Pass no model at a Voyage agent's spawn: the agent's own frontmatter `model: opus` applies."),
'the launch rules must say a Voyage agent\'s spawn passes no model');
assert.ok(t.replace(/\s+/g, ' ').includes('Spawn it with `model: "opus"`: a built-in agent has no Voyage pin.'),
'a built-in follow-up agent is spawned with model opus');
});
// End-state defect D-03: a trekplan's `## Verification` is where the brief's success
@ -1447,7 +1117,7 @@ test('trekexecute Phase 7 says NO_CRITERIA out loud instead of failing silently'
// what is true now; historical records keep what was true when written and are never
// rewritten. When an alias moves again, re-verify against the docs and fix the SOURCE.
const ALIAS_LIVE_SURFACES = [
'CLAUDE.md', 'README.md', 'docs/profiles.md', 'docs/operations.md',
'CLAUDE.md', 'README.md', 'docs/architecture.md', 'docs/operations.md',
...listMd('commands').map((f) => `commands/${f}`),
...listMd('agents').map((f) => `agents/${f}`),
];
@ -1487,10 +1157,10 @@ test('model aliases: known-positive control — the same query still finds every
assert.deepEqual(silent, [], `no known-positive left for: ${silent.join(', ')}`);
});
test('model aliases: profiles.md states the current targets, defaults and session inheritance', () => {
const t = read('docs/profiles.md');
const axes = (t.split('\n## Model & effort axes\n')[1] || '').split('\n## ')[0];
assert.ok(axes.length > 0, 'docs/profiles.md must still carry the Model & effort axes section');
test('model aliases: architecture.md states the current targets, defaults and session inheritance', () => {
const t = read('docs/architecture.md');
const axes = (t.split('\n## Model & effort\n')[1] || '').split('\n## ')[0];
assert.ok(axes.length > 0, 'docs/architecture.md must still carry the Model & effort section');
assert.match(axes, /Opus 5\.5[^\n]*\n?[^\n]*`medium`/, 'opus -> Opus 5.5, default effort `medium`');
assert.match(axes, /Sonnet 5\.5/, 'sonnet -> Sonnet 5.5');
assert.match(axes, /Fable 5\.1[\s\S]{0,120}`high`/, 'fable -> Fable 5.1, default effort `high`');
@ -1499,7 +1169,8 @@ test('model aliases: profiles.md states the current targets, defaults and sessio
assert.ok(!/overrides effort entirely/.test(axes),
'`MAX_THINKING_TOKENS=0` has no effect on Opus 5.5, Sonnet 5.5 or the Fable models');
assert.match(axes, /maxEffortLevel/, 'effort is capped by `maxEffortLevel` / an organization cap, not by `availableModels`');
for (const f of ['CLAUDE.md', 'README.md']) {
// README's alias sentence went with its §Cost profile (cleanup R-b).
for (const f of ['CLAUDE.md']) {
for (const target of ['Opus 5.5', 'Sonnet 5.5', 'Fable 5.1']) {
assert.ok(read(f).includes(target), `${f} must name the current alias target ${target}`);
}
@ -1527,7 +1198,7 @@ const RETRIEVAL_MEDIUM = /retrieval(?: agents)?\s*(?:→|->|at)\s*`medium`/i;
test('effort floor: no live doc claims retrieval agents run at medium', () => {
const hits = [];
for (const f of ['docs/profiles.md', 'CLAUDE.md', 'README.md']) {
for (const f of ['docs/architecture.md', 'CLAUDE.md', 'README.md']) {
read(f).split('\n').forEach((l, i) => { if (RETRIEVAL_MEDIUM.test(l)) hits.push(`${f}:${i + 1}`); });
}
assert.deepEqual(hits, [], 'the four retrieval agents pin `high` now');
@ -1540,29 +1211,10 @@ test('effort floor: known-positive control — the query finds the old phrases,
assert.doesNotMatch('both with default reasoning effort `medium`', RETRIEVAL_MEDIUM);
});
test('effort floor: profiles.md names the four retrieval agents as high', () => {
const axes = (read('docs/profiles.md').split('\n## Model & effort axes\n')[1] || '').split('\n## ')[0];
test('effort floor: architecture.md names the four retrieval agents as high', () => {
const axes = (read('docs/architecture.md').split('\n## Model & effort\n')[1] || '').split('\n## ')[0];
const bullet = axes.split('\n- ').find((b) => ['task-finder', 'git-historian', 'dependency-tracer', 'architecture-mapper'].every((a) => b.includes(a)));
assert.ok(bullet, 'one bullet in §Model & effort axes names all four retrieval agents');
assert.ok(bullet, 'one bullet in §Model & effort names all four retrieval agents');
assert.match(bullet, /`high`/);
assert.doesNotMatch(bullet, /`medium`/);
});
// The resolver returns `effort` only when the brief's phase signal sets one;
// no command says it always does.
const RESOLVER_COMMANDS = ['trekplan', 'trekexecute', 'trekresearch', 'trekreview'];
test('resolver: no command says the resolver always returns {effort, model, source}', () => {
for (const f of readdirSync(join(ROOT, 'commands')).filter((x) => x.endsWith('.md'))) {
assert.doesNotMatch(read(`commands/${f}`), /\{effort, model, source\}/, f);
}
});
test('resolver: each resolver-using command says `effort` comes only from the brief signal, else standard', () => {
for (const c of RESOLVER_COMMANDS) {
const t = read(`commands/${c}.md`);
const hits = t.match(/\{model, source\}[^\n]*\n?[^\n]*effort[^\n]*only when the brief's phase signal sets one/g) ?? [];
assert.ok(hits.length >= 1, `${c}: no statement that effort is only present with a brief signal`);
assert.match(t, /without it[^\n]*\n?[^\n]*`?standard`?/i, c);
}
});

View file

@ -91,43 +91,8 @@ test('extension does NOT break REQUIRED_KEYS contract', () => {
}
});
// v4.1 Step 3 — OPTIONAL_STRING_KEYS dispatch (profile_used)
// v4.1 Step 3 — OPTIONAL_STRING_KEYS dispatch; its one key went in cleanup R-b.
test('OPTIONAL_STRING_KEYS exports profile_used', () => {
assert.deepEqual(
[...OPTIONAL_STRING_KEYS].sort(),
['profile_used'].sort(),
'OPTIONAL_STRING_KEYS export drift — pin contract',
);
});
test('profile_used: economy parses successfully (SC #10 forward-compat)', () => {
const r = parseManifest(bodyWithExtras(' profile_used: economy'));
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.profile_used, 'economy');
});
test('profile_used: numeric rejected with MANIFEST_OPTIONAL_TYPE', () => {
const r = parseManifest(bodyWithExtras(' profile_used: 42'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'MANIFEST_OPTIONAL_TYPE');
assert.ok(found, `expected MANIFEST_OPTIONAL_TYPE, got: ${JSON.stringify(r.errors)}`);
assert.match(found.message, /profile_used/);
assert.match(found.message, /string/);
});
test('absence of profile_used: field is NOT in parsed (NOT defaulted, unlike boolean)', () => {
const r = parseManifest(bodyOnlyRequired());
assert.equal(r.valid, true, JSON.stringify(r.errors));
// Absence semantics differ from boolean: parsed should NOT contain the key
assert.equal('profile_used' in r.parsed, false,
'profile_used must NOT be auto-defaulted when absent — string-key semantics');
});
test('profile_used works alongside boolean optional keys (skip_commit_check + memory_write)', () => {
const r = parseManifest(bodyWithExtras(' skip_commit_check: true\n memory_write: true\n profile_used: balanced'));
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.skip_commit_check, true);
assert.equal(r.parsed.memory_write, true);
assert.equal(r.parsed.profile_used, 'balanced');
test('OPTIONAL_STRING_KEYS exports no key', () => {
assert.deepEqual([...OPTIONAL_STRING_KEYS], [], 'OPTIONAL_STRING_KEYS export drift — pin contract');
});

View file

@ -1,112 +0,0 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { writeFileSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { resolvePhaseSignal, resolvePhaseSignalFromFile } from '../../lib/profiles/phase-signal-resolver.mjs';
const FULL_SIGNALS_FM = {
phase_signals: [
{ phase: 'research', effort: 'low', model: 'sonnet' },
{ phase: 'plan', effort: 'standard' },
{ phase: 'execute', effort: 'high', model: 'opus' },
{ phase: 'review', effort: 'standard', model: 'sonnet' },
],
};
test('resolvePhaseSignal — returns {effort, model} for all 4 phases on full-signals brief', () => {
for (const phase of ['research', 'plan', 'execute', 'review']) {
const r = resolvePhaseSignal(FULL_SIGNALS_FM, phase);
assert.ok(r && typeof r === 'object', `phase=${phase} should resolve non-null`);
assert.ok(typeof r.effort === 'string', `phase=${phase} should have effort`);
}
});
test('resolvePhaseSignal — returns null when brief has no phase_signals', () => {
const r = resolvePhaseSignal({ task: 'x' }, 'plan');
assert.equal(r, null);
});
test('resolvePhaseSignal — returns partial {effort} with model undefined when signal omits model', () => {
const r = resolvePhaseSignal(FULL_SIGNALS_FM, 'plan');
assert.equal(r.effort, 'standard');
assert.equal(r.model, undefined);
assert.ok(!('model' in r), 'model key should be absent when not in signal');
});
test('resolvePhaseSignal — returns null when phase is not in PHASE_SIGNAL_PHASES', () => {
assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'brief'), null);
assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'continue'), null);
assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'nonsense'), null);
});
test('resolvePhaseSignal — defensive: null/non-object input returns null', () => {
assert.equal(resolvePhaseSignal(null, 'plan'), null);
assert.equal(resolvePhaseSignal(undefined, 'plan'), null);
assert.equal(resolvePhaseSignal('string', 'plan'), null);
assert.equal(resolvePhaseSignal({ phase_signals: 'not-array' }, 'plan'), null);
});
test('resolvePhaseSignal — drops model not in BASE_ALLOWED_MODELS (defense-in-depth gate)', () => {
// MAJOR fix (S4): line that copies `model` must gate against the same
// allowlist brief-validator uses (BASE_ALLOWED_MODELS = ['sonnet','opus','fable']),
// mirroring how effort is gated against EFFORT_LEVELS. A brief that slipped
// validation (hand-edited, validation skipped) must not hand a junk model
// string to a command that then spawns an agent with `model: <junk>`.
const fm = {
phase_signals: [
{ phase: 'plan', effort: 'high', model: 'gpt-4' }, // invalid model
{ phase: 'review', effort: 'standard', model: 'haiku' }, // not in BASE_ALLOWED_MODELS
],
};
const plan = resolvePhaseSignal(fm, 'plan');
assert.equal(plan.effort, 'high', 'valid effort still resolves');
assert.equal(plan.model, undefined, 'invalid model "gpt-4" must be dropped');
assert.ok(!('model' in plan), 'model key absent when value not in allowlist');
const review = resolvePhaseSignal(fm, 'review');
assert.equal(review.model, undefined, 'haiku must be dropped (not in BASE_ALLOWED_MODELS)');
assert.ok(!('model' in review), 'model key absent for haiku');
});
test('resolvePhaseSignal — keeps valid models (sonnet, opus, fable) after gating', () => {
const fm = {
phase_signals: [
{ phase: 'research', effort: 'low', model: 'sonnet' },
{ phase: 'execute', effort: 'high', model: 'opus' },
{ phase: 'review', effort: 'high', model: 'fable' },
],
};
assert.equal(resolvePhaseSignal(fm, 'research').model, 'sonnet');
assert.equal(resolvePhaseSignal(fm, 'execute').model, 'opus');
assert.equal(resolvePhaseSignal(fm, 'review').model, 'fable');
});
test('resolvePhaseSignalFromFile + CLI shim — writes JSON to stdout, exit 0', () => {
const fixture = join(tmpdir(), `phase-signal-test-${process.pid}.md`);
writeFileSync(fixture, `---
type: trekbrief
brief_version: "2.1"
phase_signals:
- phase: plan
effort: high
model: opus
---
# x
`);
try {
// Programmatic invocation
const r = resolvePhaseSignalFromFile(fixture, 'plan');
assert.deepEqual(r, { effort: 'high', model: 'opus' });
// CLI shim
const helperPath = new URL('../../lib/profiles/phase-signal-resolver.mjs', import.meta.url).pathname;
const out = execFileSync('node', [helperPath, '--brief', fixture, '--phase', 'plan', '--json'], {
encoding: 'utf-8',
});
const parsed = JSON.parse(out.trim());
assert.deepEqual(parsed, { effort: 'high', model: 'opus' });
} finally {
try { unlinkSync(fixture); } catch { /* swallow */ }
}
});

View file

@ -151,12 +151,13 @@ function paraAfter(rel, marker) {
return flat(lines.slice(start, i).join('\n'));
}
// ---- spawn model: phase_signal_result.model, the agent's own model: as the fallback ----
// ---- spawn model: a Voyage agent gets no model:, a built-in agent gets opus ----
const PASS_MODEL = /Pass `model: \{phase_signal_result\.model\}` at this spawn; without it \(the `--brief` branch, where the resolver does not run, or a resolver that printed nothing\) the agent's own `model:` applies\./;
const NO_MODEL = "This spawn passes no `model:`: the agent's own `model: opus` applies.";
const BUILT_IN = 'Spawn it with `model: "opus"`: a built-in agent has no Voyage pin.';
test('spawn model: commands/trekplan.md pins no spawn to `model: "opus"`', () => {
assert.doesNotMatch(PLAN_CMD, /model: "opus"/);
test('spawn model: commands/trekplan.md pins `model: "opus"` once, at the built-in Explore deep-dive', () => {
assert.equal(PLAN_CMD.split('model: "opus"').length - 1, 1, 'expected exactly one `model: "opus"` in commands/trekplan.md');
});
for (const [where, marker] of [
@ -164,32 +165,25 @@ for (const [where, marker] of [
['### Always launch (Phase 5)', '### Always launch (all codebase sizes):'],
['research-scout (Phase 5)', 'launch **research-scout** in'],
['plan-critic and scope-guardian (Phase 9)', 'it traces into `## Verification`.'],
['session-decomposer (Phase 1.5)', 'Launch the **session-decomposer** agent:'],
]) {
test(`spawn model: ${where}: the spawn takes phase_signal_result.model, the agent's own model: as the fallback`, () => {
test(`spawn model: ${where}: the spawn passes no model:, the agent's own model: opus applies`, () => {
const part = paraAfter('commands/trekplan.md', marker);
assert.match(part, PASS_MODEL, `${where}: missing the resolved-model paragraph after "${marker}"`);
assert.ok(part.includes(NO_MODEL), `${where}: missing the no-model sentence after "${marker}": ${part}`);
assert.doesNotMatch(part, /model: "opus"/);
});
}
test('spawn model: session-decomposer (Phase 1.5): --decompose has no resolved model, so the spawn passes no model:', () => {
const part = paraAfter('commands/trekplan.md', 'Launch the **session-decomposer** agent:');
assert.ok(part.includes('Phase 1 runs the model resolver only for `--project`, so `--decompose` has no resolved model: this spawn '
+ 'passes no `model:`, and the agent\'s own `model:` applies.'), `the session-decomposer paragraph does not say it passes no model: ${part}`);
assert.ok(!part.includes('phase_signal_result'), 'the session-decomposer spawn still names phase_signal_result');
assert.doesNotMatch(part, /model: "opus"/);
});
test('spawn model: convention-scanner (Phase 5): the spawn takes phase_signal_result.model, the frontmatter as the fallback', () => {
test('spawn model: convention-scanner (Phase 5): the spawn passes no model:, its own model: opus applies', () => {
const part = paraAfter('commands/trekplan.md', 'use the `convention-scanner` plugin agent');
assert.match(part, /\(model: phase_signal_result\.model, the agent's frontmatter as the fallback\)/);
assert.ok(part.includes('(no `model:`; its own `model: opus` applies)'), part);
assert.doesNotMatch(part, /model: "opus"/);
});
test('spawn model: deep-dives (Phase 6): the spawn takes phase_signal_result.model, else opus', () => {
test('spawn model: deep-dives (Phase 6): the built-in Explore agent is spawned with model opus', () => {
const part = paraAfter('commands/trekplan.md', 'spawn a targeted deep-dive agent');
assert.match(part, /\(model: phase_signal_result\.model, else opus — Explore is Claude Code's built-in agent and has no Voyage frontmatter to fall back to, subagent_type: "Explore"\)/);
assert.doesNotMatch(part, /model: "opus"/);
assert.ok(part.includes('(subagent_type: "Explore", Claude Code\'s built-in agent)'), part);
assert.ok(part.includes(BUILT_IN), part);
});
// ---- Phase 4 sizing: no turn budget, and the count it runs ---------------------

View file

@ -4,9 +4,7 @@
// can be checked offline: that each grader can see what it claims to see.
//
// - the new case for veikart steg 1's gate exists, and its fixture really
// reaches the intent gate (passes the sequencing gate, has no marker)
// - case 4 (plan-halts-without-phase-signals) can only be stopped by the
// sequencing gate: its fixture carries a current intent approval
// reaches the intent gate (passes the brief validator, has no marker)
// - review-requires-project: the error line is written by CODE (the arg
// parser), and the grader reads the parser's code from the trace
// - grader weaknesses the PM re-measurement found (2026-09-23):
@ -77,14 +75,13 @@ test('proevesett: a case exists for the steg 1 intent gate, with its graders', (
const re = new RegExp(n.pattern);
assert.match(inTrace({ valid: false, errors: [{ code: 'BRIEF_INTENT_NOT_APPROVED' }] }, 2), re);
assert.doesNotMatch(COMMAND_PROSE, re, 'the pattern must not be satisfiable by quoting the command prose');
assert.doesNotMatch(inTrace({ valid: false, errors: [{ code: 'BRIEF_V51_MISSING_SIGNALS' }] }, 2), re);
const noAgent = grader(`evals/${GATE_CASE}/graders/no-agent.md`);
assert.deepEqual([noAgent.type, noAgent.tool, noAgent.min, noAgent.max], ['tool_used', 'Agent', '0', '0']);
const noPlan = grader(`evals/${GATE_CASE}/graders/no-plan-file.md`);
assert.deepEqual([noPlan.type, noPlan.path, noPlan.exists], ['file_exists', '**/plan.md', 'false']);
});
test('proevesett: the gate case fixture passes the sequencing gate and stops at the intent gate', () => {
test('proevesett: the gate case fixture passes the brief validator and stops at the intent gate', () => {
const ws = mkdtempSync(join(tmpdir(), 'proevesett-'));
try {
const s = spawnSync('bash', [join(EVALS, GATE_CASE, 'scaffold.sh')], { cwd: ws, encoding: 'utf8' });
@ -98,54 +95,6 @@ test('proevesett: the gate case fixture passes the sequencing gate and stops at
} finally { rmSync(ws, { recursive: true, force: true }); }
});
// ---- case 4: only the sequencing gate may stop it ----
// Measured 2026-09-23: with no intent marker the child sometimes ran the
// intent gate first and halted there (PM: 6 of 12 on dc9b480, 7 of 10 on
// 66e1fa1). The halt was right, but the case then measured the wrong gate.
const SEQ_CASE = 'plan-halts-without-phase-signals';
test('plan-halts-without-phase-signals: the fixture carries a current intent approval and fails only the sequencing gate', () => {
const ws = mkdtempSync(join(tmpdir(), 'proevesett-'));
try {
const s = spawnSync('bash', [join(EVALS, SEQ_CASE, 'scaffold.sh')], { cwd: ws, encoding: 'utf8' });
assert.equal(s.status, 0, s.stderr);
const brief = join(ws, 'proj', 'brief.md');
const g = spawnSync('node', [join(ROOT, 'lib/validators/intent-approval.mjs'), '--check', '--json', brief], { encoding: 'utf8' });
assert.equal(g.status, 0, `the intent gate must pass so only the sequencing gate can stop the run: ${g.stdout}`);
const v = spawnSync('node', [join(ROOT, 'lib/validators/brief-validator.mjs'), '--soft', '--json', brief], { encoding: 'utf8' });
assert.deepEqual(JSON.parse(v.stdout).errors.map((e) => e.code), ['BRIEF_V51_MISSING_SIGNALS']);
} finally { rmSync(ws, { recursive: true, force: true }); }
});
test('plan-halts-without-phase-signals: a halt at the intent gate fails the case', () => {
assert.ok(graders(SEQ_CASE).includes('no-intent-halt.md'), graders(SEQ_CASE).join(','));
const g = grader(`evals/${SEQ_CASE}/graders/no-intent-halt.md`);
assert.deepEqual([g.type, g.target, g.match], ['regex', 'trace', 'not_contains']);
const re = new RegExp(g.pattern);
for (const code of ['BRIEF_INTENT_NOT_APPROVED', 'BRIEF_INTENT_APPROVAL_STALE', 'BRIEF_INTENT_APPROVAL_INVALID']) {
assert.match(inTrace({ valid: false, errors: [{ code }] }, 2), re, `${code} must fail the case`);
}
assert.doesNotMatch(inTrace({ valid: true, errors: [], warnings: [] }, 2), re);
assert.doesNotMatch(inTrace({ valid: false, errors: [{ code: 'BRIEF_V51_MISSING_SIGNALS' }] }, 2), re);
assert.doesNotMatch(COMMAND_PROSE, re, 'the command prose must not trip the grader');
});
// Known-positive, measured 2026-09-23: with the check in brief-validator.mjs
// disabled, the child still halted on the prose rule and named phase_signals
// (3 of 3 passed). The halt alone does not show that the validator fired.
test('plan-halts-without-phase-signals: the validator\'s own code must be in the trace', () => {
assert.ok(graders(SEQ_CASE).includes('names-code.md'), graders(SEQ_CASE).join(','));
const g = grader(`evals/${SEQ_CASE}/graders/names-code.md`);
assert.deepEqual([g.type, g.target], ['regex', 'trace']);
const re = new RegExp(g.pattern);
assert.match(inTrace({ valid: false, errors: [{ code: 'BRIEF_V51_MISSING_SIGNALS' }] }, 2), re);
assert.doesNotMatch(inTrace({ valid: true, errors: [], warnings: [] }, 2), re);
assert.doesNotMatch(inTrace({ valid: false, errors: [{ code: 'BRIEF_INTENT_NOT_APPROVED' }] }, 2), re);
assert.doesNotMatch(COMMAND_PROSE, re, 'the pattern must not be satisfiable by quoting the command prose');
assert.doesNotMatch(read('lib/validators/brief-validator.mjs'), re, 'nor by reading the validator source');
});
// ---- case 5: the missing-flag error comes from code ----
test('review-requires-project: the arg parser, run as trekreview.md spells it, prints the error (exit 1)', () => {

View file

@ -1,244 +0,0 @@
// tests/lib/profile-application.test.mjs
// SC #5-#9 + backward-compat edge-case for lib/profiles/resolver.mjs.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
loadProfile,
resolveProfile,
resolveTrekcontinueProfile,
validateProfileFile,
findProfilePath,
} from '../../lib/profiles/resolver.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..', '..');
// SC #5: loadProfile returns matrix-match for all 6 phase_models
test('SC #5: loadProfile("economy") returns flattened phase_models with all 6 phases', () => {
const p = loadProfile('economy');
assert.equal(p.name, 'economy');
assert.equal(p.phase_models.brief, 'sonnet');
assert.equal(p.phase_models.research, 'sonnet');
assert.equal(p.phase_models.plan, 'sonnet');
assert.equal(p.phase_models.execute, 'sonnet');
assert.equal(p.phase_models.review, 'sonnet');
assert.equal(p.phase_models.continue, 'sonnet');
assert.equal(p.parallel_agents_min, 2);
assert.equal(p.parallel_agents_max, 3);
assert.equal(p.external_research_enabled, false);
assert.equal(p.brief_reviewer_iter_cap, 1);
});
test('SC #5: loadProfile("balanced") returns mixed phase_models', () => {
const p = loadProfile('balanced');
assert.equal(p.phase_models.plan, 'opus');
assert.equal(p.phase_models.review, 'opus');
assert.equal(p.phase_models.brief, 'sonnet');
assert.equal(p.phase_models.execute, 'sonnet');
});
test('SC #5: loadProfile("premium") returns all-opus', () => {
const p = loadProfile('premium');
for (const phase of ['brief', 'research', 'plan', 'execute', 'review', 'continue']) {
assert.equal(p.phase_models[phase], 'opus', `premium ${phase} should be opus`);
}
});
test('SC #5: loadProfile("fable") returns all-fable (BUILTIN_NAMES canary)', () => {
// Throws PROFILE_NOT_FOUND if 'fable' regresses out of BUILTIN_NAMES —
// the silent-fallback-to-premium failure mode this pin exists to catch.
const p = loadProfile('fable');
assert.equal(p.name, 'fable');
for (const phase of ['brief', 'research', 'plan', 'execute', 'review', 'continue']) {
assert.equal(p.phase_models[phase], 'fable', `fable ${phase} should be fable`);
}
assert.equal(p.parallel_agents_min, 6);
assert.equal(p.parallel_agents_max, 8);
assert.equal(p.external_research_enabled, true);
assert.equal(p.brief_reviewer_iter_cap, 3);
});
test('SC #5: loadProfile throws PROFILE_NOT_FOUND for unknown profile', () => {
try {
loadProfile('does-not-exist-xyz');
assert.fail('expected throw');
} catch (e) {
assert.equal(e.cause, 'PROFILE_NOT_FOUND');
assert.match(e.message, /not found/);
assert.ok(Array.isArray(e.attempted), 'should expose attempted paths');
}
});
// SC #6: env-var fallback flag > env > default
test('SC #6: resolveProfile flag > env > default', () => {
// flag wins
const r1 = resolveProfile({ flags: { '--profile': 'balanced' } }, { VOYAGE_PROFILE: 'economy' });
assert.equal(r1.profile, 'balanced');
assert.equal(r1.profile_source, 'flag');
// env wins when no flag
const r2 = resolveProfile({ flags: {} }, { VOYAGE_PROFILE: 'economy' });
assert.equal(r2.profile, 'economy');
assert.equal(r2.profile_source, 'env');
// default when neither
const r3 = resolveProfile({ flags: {} }, {});
assert.equal(r3.profile, 'premium');
assert.equal(r3.profile_source, 'default');
});
// SC #7: performance — loadProfile 1000 iter < 50ms average (allowing some headroom)
test('SC #7: loadProfile 1000-iter performance < 50ms average', () => {
const iterations = 1000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
loadProfile('economy');
}
const elapsed = performance.now() - start;
const avgMs = elapsed / iterations;
assert.ok(avgMs < 50, `loadProfile too slow: ${avgMs.toFixed(3)}ms average over ${iterations} iter`);
});
// SC #8: custom.yaml from repo-root trumps ~/.claude/
test('SC #8: custom profile from <cwd>/voyage-profiles/<name>.yaml takes precedence over ~/.claude/', () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'voyage-resolver-repo-'));
const tmpHome = mkdtempSync(join(tmpdir(), 'voyage-resolver-home-'));
try {
// Place custom profile in repo and home — repo should win
mkdirSync(join(tmpRepo, 'voyage-profiles'), { recursive: true });
mkdirSync(join(tmpHome, '.claude', 'voyage-profiles'), { recursive: true });
writeFileSync(join(tmpRepo, 'voyage-profiles', 'mycustom.yaml'),
`---
profile_version: "1.0"
name: mycustom-repo
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: sonnet
- phase: execute
model: sonnet
- phase: review
model: sonnet
- phase: continue
model: sonnet
parallel_agents_min: 1
parallel_agents_max: 2
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`);
writeFileSync(join(tmpHome, '.claude', 'voyage-profiles', 'mycustom.yaml'),
`---
profile_version: "1.0"
name: mycustom-home
phase_models:
- phase: brief
model: opus
- phase: research
model: opus
- phase: plan
model: opus
- phase: execute
model: opus
- phase: review
model: opus
- phase: continue
model: opus
parallel_agents_min: 1
parallel_agents_max: 2
external_research_enabled: true
brief_reviewer_iter_cap: 3
---
`);
const found = findProfilePath('mycustom', { cwd: tmpRepo, home: tmpHome });
assert.ok(found.path, `expected to find mycustom; attempted: ${found.attempted.join(', ')}`);
assert.ok(found.path.startsWith(tmpRepo),
`expected repo-rot win (path under ${tmpRepo}), got: ${found.path}`);
const p = loadProfile('mycustom', { cwd: tmpRepo, home: tmpHome });
assert.equal(p.name, 'mycustom-repo', 'repo profile should win');
assert.equal(p.phase_models.brief, 'sonnet');
} finally {
rmSync(tmpRepo, { recursive: true, force: true });
rmSync(tmpHome, { recursive: true, force: true });
}
});
test('SC #8: missing profile error message includes both attempted paths', () => {
const tmpRepo = mkdtempSync(join(tmpdir(), 'voyage-resolver-empty-'));
const tmpHome = mkdtempSync(join(tmpdir(), 'voyage-resolver-emptyhome-'));
try {
try {
loadProfile('not-a-real-profile', { cwd: tmpRepo, home: tmpHome });
assert.fail('expected throw');
} catch (e) {
assert.equal(e.cause, 'PROFILE_NOT_FOUND');
// Both attempted paths should be in the error message for diagnostic clarity
const msg = e.message;
assert.match(msg, /voyage-profiles\/not-a-real-profile\.yaml/);
assert.match(msg, /\.claude\/voyage-profiles\/not-a-real-profile\.yaml/);
}
} finally {
rmSync(tmpRepo, { recursive: true, force: true });
rmSync(tmpHome, { recursive: true, force: true });
}
});
// SC #9: resolveTrekcontinueProfile inheritance from plan-frontmatter
test('SC #9: resolveTrekcontinueProfile inherits from plan-frontmatter (profile: balanced)', () => {
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-with-profile.md');
const r = resolveTrekcontinueProfile(planPath, { flags: {} });
assert.equal(r.profile, 'balanced');
assert.equal(r.profile_source, 'inheritance');
});
test('SC #9: resolveTrekcontinueProfile flag overrides plan-frontmatter (advisory)', () => {
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-with-profile.md');
const advisories = [];
const fakeConsole = { error: (m) => advisories.push(m) };
const r = resolveTrekcontinueProfile(planPath,
{ flags: { '--profile': 'economy' } },
{ console: fakeConsole });
assert.equal(r.profile, 'economy');
assert.equal(r.profile_source, 'flag');
assert.equal(advisories.length, 1, 'expected one advisory message');
assert.match(advisories[0], /balanced.*economy/);
assert.match(advisories[0], /\[voyage\]/);
});
// Backward-compat edge-case: v4.0-style plan WITHOUT profile field
test('Backward-compat: resolveTrekcontinueProfile on v4.0 plan without profile field returns default premium', () => {
const planPath = join(REPO_ROOT, 'tests', 'fixtures', 'plan-without-profile.md');
const r = resolveTrekcontinueProfile(planPath, { flags: {} });
assert.equal(r.profile, 'premium');
assert.equal(r.profile_source, 'default');
});
test('Backward-compat: resolveTrekcontinueProfile with non-existent plan path returns default premium', () => {
const r = resolveTrekcontinueProfile('/tmp/does-not-exist-plan-xyz.md', { flags: {} });
assert.equal(r.profile, 'premium');
assert.equal(r.profile_source, 'default');
});
// validateProfileFile re-export sanity
test('validateProfileFile re-exports validateProfile (locked-interface compat)', () => {
const r = validateProfileFile(join(REPO_ROOT, 'lib', 'profiles', 'economy.yaml'));
assert.equal(r.valid, true);
});

View file

@ -1,41 +0,0 @@
// tests/lib/profile-flag-coverage.test.mjs
// SC #4 (docs side): every command file must document --profile + VOYAGE_PROFILE.
// /trekcontinue.md must additionally describe profile-arv (inheritance) policy.
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';
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMMANDS_DIR = join(__dirname, '..', '..', 'commands');
const COMMAND_FILES = [
'trekbrief.md',
'trekresearch.md',
'trekplan.md',
'trekexecute.md',
'trekreview.md',
'trekcontinue.md',
];
for (const filename of COMMAND_FILES) {
test(`${filename} documents --profile flag`, () => {
const content = readFileSync(join(COMMANDS_DIR, filename), 'utf-8');
assert.match(content, /--profile/,
`${filename} must contain --profile flag documentation`);
});
test(`${filename} mentions VOYAGE_PROFILE env-var`, () => {
const content = readFileSync(join(COMMANDS_DIR, filename), 'utf-8');
assert.match(content, /VOYAGE_PROFILE/,
`${filename} must mention VOYAGE_PROFILE env-var (resolution order)`);
});
}
test('trekcontinue.md documents inheritance policy (profile arves fra plan-frontmatter)', () => {
const content = readFileSync(join(COMMANDS_DIR, 'trekcontinue.md'), 'utf-8');
assert.match(content, /inheritance/,
'trekcontinue.md must describe profile-arv (inheritance) policy from plan-frontmatter');
});

View file

@ -1,89 +0,0 @@
// tests/lib/profile-resolver.test.mjs
// v5.1.1 SC5 — non-interference cases for resolvePhaseModel().
// Verifies the new highest-priority lookup step (brief.phase_signals[phase].model)
// wins over --profile flag and VOYAGE_PROFILE env; falls through cleanly when
// no brief signal is present.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolvePhaseModel } from '../../lib/profiles/resolver.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..', '..');
const FIXTURE = (name) => join(REPO_ROOT, 'tests', 'fixtures', name);
test('resolvePhaseModel — Case 1: brief signal wins over VOYAGE_PROFILE env', () => {
// brief-effort-low.md pins all 4 phases to model: sonnet.
// env says premium (would normally select opus). Brief must win.
const r = resolvePhaseModel('research', FIXTURE('brief-effort-low.md'), [], { VOYAGE_PROFILE: 'premium' });
assert.equal(r.model, 'sonnet', `brief signal should beat env; got ${JSON.stringify(r)}`);
assert.equal(r.source, 'brief-signal');
});
test('resolvePhaseModel — Case 2: brief signal wins over --profile flag', () => {
// brief-effort-high.md pins all 4 phases to model: opus.
// flag says economy (would normally select sonnet). Brief must win.
const r = resolvePhaseModel('execute', FIXTURE('brief-effort-high.md'), ['--profile', 'economy'], {});
assert.equal(r.model, 'opus', `brief signal should beat flag; got ${JSON.stringify(r)}`);
assert.equal(r.source, 'brief-signal');
});
test('resolvePhaseModel — Case 3: no phase_signals → fallthrough to --profile flag', () => {
// brief-without-phase-signals fixture lacks phase_signals entirely.
// --profile balanced is set. Should return balanced.phase_models.plan (= opus per yaml).
const r = resolvePhaseModel('plan', FIXTURE('brief-without-phase-signals.md'), ['--profile', 'balanced'], {});
assert.equal(r.model, 'opus', `balanced.plan should be opus; got ${JSON.stringify(r)}`);
assert.equal(r.source, 'flag');
});
test('resolvePhaseModel — Case 4: phase not in PHASE_SIGNAL_PHASES falls through gracefully', () => {
// brief-effort-high.md has signals for the 4 supported phases.
// Asking for 'continue' (not in PHASE_SIGNAL_PHASES) must fall through.
// --profile premium is set, so continue resolves to premium.phase_models.continue (= opus).
const r = resolvePhaseModel('continue', FIXTURE('brief-effort-high.md'), ['--profile', 'premium'], {});
assert.equal(r.model, 'opus', `premium.continue should be opus; got ${JSON.stringify(r)}`);
assert.ok(r.source !== 'brief-signal', 'continue must not resolve via brief-signal');
});
test('resolvePhaseModel — Case 5 (defensive): missing brief file falls through cleanly', () => {
// Non-existent path. Must not throw; must fall through to flag/env/default.
const r = resolvePhaseModel('plan', '/nonexistent/brief.md', ['--profile', 'economy'], {});
assert.equal(r.model, 'sonnet', 'economy.plan should be sonnet on fallthrough');
assert.equal(r.source, 'flag');
});
test('resolvePhaseModel — Case 6 (defensive): null briefPath falls through to default', () => {
// null briefPath, no flag, no env → default = premium.
const r = resolvePhaseModel('plan', null, [], {});
assert.equal(r.model, 'opus', 'premium.plan default = opus');
assert.equal(r.source, 'default');
});
// v5.9 — fable profile composition (brief SC 4) + effort passthrough coherence
test('resolvePhaseModel — --profile fable: all six phases resolve model fable (no brief signal)', () => {
for (const phase of ['brief', 'research', 'plan', 'execute', 'review', 'continue']) {
const r = resolvePhaseModel(phase, null, ['--profile', 'fable'], {});
assert.equal(r.model, 'fable', `fable.${phase} should be fable; got ${JSON.stringify(r)}`);
assert.equal(r.source, 'flag');
}
});
test('resolvePhaseModel — brief signal (opus) beats --profile fable (composition precedence pin)', () => {
// brief-effort-high.md pins execute to model: opus. Brief must beat the fable profile.
const r = resolvePhaseModel('execute', FIXTURE('brief-effort-high.md'), ['--profile', 'fable'], {});
assert.equal(r.model, 'opus', `brief signal should beat fable profile; got ${JSON.stringify(r)}`);
assert.equal(r.source, 'brief-signal');
});
test('resolvePhaseModel — composed output carries {effort, model} atomically (v5.9 passthrough)', () => {
// brief-effort-high.md: execute → {effort: high, model: opus}. The composed
// resolver must return BOTH fields from one call — the split-CLI design this
// passthrough replaced could drift effort and model apart.
const r = resolvePhaseModel('execute', FIXTURE('brief-effort-high.md'), [], {});
assert.equal(r.effort, 'high', `effort must pass through; got ${JSON.stringify(r)}`);
assert.equal(r.model, 'opus');
assert.equal(r.source, 'brief-signal');
});

View file

@ -9,8 +9,8 @@
// files (never tracked) and this test. A term is a plain substring, except a
// flag, which must stand as its own token (`--engine`, `--profile`).
//
// When this test fails, remove the mention; add an EXEMPT entry only for a
// line that must keep the term, with the reason.
// Every group always runs. When this test fails, remove the mention; add an
// EXEMPT entry only for a line that must keep the term, with the reason.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
@ -45,13 +45,25 @@ export const GROUPS = {
],
};
// Groups whose removal stage has not run yet. Each stage of R-b empties its
// own entry; REMOVED_SURFACES_ALL=1 checks every group (the red proof).
const PENDING = new Set(['R3']);
// Named exceptions: "path:line" -> reason. Each must still hit (a stale
// exemption fails below).
const EXEMPT = new Map([
['docs/HANDOVER-CONTRACTS.md:81', 'public contract: an older brief\'s key is ignored (decisions 179/191)'],
['docs/proevesett.md:139', 'dated v1/v2 measurement record of the fixed test set, kept unedited'],
['docs/proevesett.md:143', 'dated v1/v2 measurement record of the fixed test set, kept unedited'],
['docs/proevesett.md:146', 'dated v1/v2 measurement record of the fixed test set, kept unedited'],
['lib/parsers/arg-parser.mjs:16', 'retired flag swallows its value'],
['lib/parsers/arg-parser.mjs:20', 'retired flag swallows its value'],
['lib/parsers/arg-parser.mjs:25', 'retired flag swallows its value'],
['lib/parsers/arg-parser.mjs:30', 'retired flag swallows its value'],
['lib/parsers/arg-parser.mjs:36', 'retired flag swallows its value'],
['lib/parsers/arg-parser.mjs:41', 'retired flag swallows its value'],
['lib/parsers/arg-parser.mjs:46', 'retired flag swallows its value'],
['tests/lib/arg-parser.test.mjs:285', 'retired flag swallows its value'],
['tests/lib/arg-parser.test.mjs:286', 'retired flag swallows its value'],
['tests/validators/brief-validator.test.mjs:200', 'older brief stays valid (decision 191)'],
['tests/validators/brief-validator.test.mjs:203', 'older brief stays valid (decision 191)'],
['tests/validators/brief-validator.test.mjs:205', 'older brief stays valid (decision 191)'],
]);
const matches = (term, line) => (typeof term === 'string' ? line.includes(term) : term.test(line));
@ -108,10 +120,7 @@ test('scope leaves out history, local files and this test', () => {
});
for (const group of Object.keys(GROUPS)) {
test(`${group}: no tracked file names a removed surface`, (t) => {
if (PENDING.has(group) && process.env.REMOVED_SURFACES_ALL !== '1') {
return t.skip(`${group} is not removed yet`);
}
test(`${group}: no tracked file names a removed surface`, () => {
const all = tracked();
assert.ok(all.length > 100, `git ls-files returned ${all.length} files; refusing to pass on an empty scan`);
const hits = scan(ROOT, all, GROUPS[group]).filter(h => !EXEMPT.has(h));

View file

@ -1043,7 +1043,7 @@ test('review 630 r9 / f8087ca4 criteria-runner.mjs:172: a check refused for its
// (2) A brief SC with such a comment is BRIEF_SC_NOT_RUNNABLE, not prose-only.
const brief = (sc) => [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
'## Success Criteria', '', sc, '',
].join('\n');

View file

@ -1,53 +0,0 @@
// tests/parsers/arg-parser-profile.test.mjs
// SC #4: --profile valued flag MUST be recognized on all 6 voyage commands.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseArgs } from '../../lib/parsers/arg-parser.mjs';
const COMMANDS = ['trekbrief', 'trekresearch', 'trekplan', 'trekexecute', 'trekreview', 'trekcontinue'];
for (const cmd of COMMANDS) {
test(`${cmd} — --profile economy parses as valued`, () => {
const r = parseArgs('--profile economy', cmd);
assert.equal(r.flags['--profile'], 'economy', `${cmd} should accept --profile economy`);
assert.equal(r.errors.length, 0, `${cmd} should have no errors`);
assert.equal(r.unknown.length, 0, `${cmd} should not mark --profile as unknown`);
});
}
test('trekplan — --profile without value emits ARG_MISSING_VALUE', () => {
const r = parseArgs('--profile', 'trekplan');
const missing = r.errors.find((e) => e.code === 'ARG_MISSING_VALUE');
assert.ok(missing, 'must surface ARG_MISSING_VALUE');
assert.match(missing.message, /--profile/);
});
test('trekplan — --profile economy --quick combines correctly', () => {
const r = parseArgs('--profile economy --quick', 'trekplan');
assert.equal(r.flags['--profile'], 'economy');
assert.equal(r.flags['--quick'], true);
});
test('trekplan — --profile economy --gates open: --gates is unknown (parsed inline by command prose, not in FLAG_SCHEMA)', () => {
// Edge case from plan.md Step 2 (per plan-critic minor): --gates is intentionally
// NOT in FLAG_SCHEMA; commands parse it inline. Verify --profile still parses cleanly
// and --gates ends up in unknown[] / positional[] rather than colliding with --profile.
const r = parseArgs('--profile economy --gates open', 'trekplan');
assert.equal(r.flags['--profile'], 'economy');
// --gates is unknown to FLAG_SCHEMA; it lands in unknown[] and 'open' becomes positional
assert.ok(r.unknown.includes('--gates'), `expected --gates in unknown, got: ${JSON.stringify(r.unknown)}`);
assert.ok(r.positional.includes('open'), `expected 'open' as positional, got: ${JSON.stringify(r.positional)}`);
});
test('trekexecute — --profile balanced --project /tmp/p combines correctly', () => {
const r = parseArgs('--profile balanced --project /tmp/p', 'trekexecute');
assert.equal(r.flags['--profile'], 'balanced');
assert.equal(r.flags['--project'], '/tmp/p');
});
test('trekcontinue — --profile premium parses without --project', () => {
// trekcontinue had empty valued[] before v4.1 — sanity check the array is now extended
const r = parseArgs('--profile premium', 'trekcontinue');
assert.equal(r.flags['--profile'], 'premium');
});

View file

@ -386,14 +386,12 @@ test('review-coordinator Pass 3 — only an engine finding is marked, and the on
assert.match(p3, /verdict gate refuses \(exit 2\) every other marked or lowered shape/);
});
test('review-coordinator — quick mode and high-effort normalization each still apply the brief-conflict rule', () => {
test('review-coordinator — quick mode still applies the brief-conflict rule', () => {
const coord = readDoc('agents', 'review-coordinator.md');
const pass3 = coord.indexOf('### Pass 3');
assert.ok(pass3 >= 0, 'Pass 3 heading');
const quick = cut(coord.slice(pass3), 'In `quick` mode, skip this pass entirely.', '\n\n', 'quick-mode paragraph');
assert.match(quick, /brief-conflict rule[^.]*still applies/, 'the quick-mode paragraph says the rule still applies');
const high = cut(coord, '**High-effort normalization (v5.1.1):**', '\n### ', 'high-effort paragraph');
assert.match(high, /brief-conflict rule[^.]*still applies/, 'the high-effort paragraph says the rule still applies');
});
test('review-coordinator Pass 2 — the trim rule judges a lowerable brief conflict as MINOR, inside its own sentence', () => {

View file

@ -1,106 +0,0 @@
---
type: trekplan-jaccard-calibration
plan_version: "1.7"
created: 2026-05-09
status: parked-synthetic
threshold: 0.55
threshold_basis: "research/02 conservative starting value (arXiv:2412.12148)"
empirical_runs: 0
synthetic_runs: 4
ramp_target: v4.2
---
# Cross-tier Jaccard calibration — voyage v4.1
## Status: PARKED-SYNTHETIC
> **Profile linkage (S34/V30).** Because this calibration is still
> parked-synthetic, `lib/profiles/economy.yaml` carries `experimental: true`
> and every profile doc flags the `economy` row experimental. When the
> empirical calibration in *How to replace* below lands (`status: empirical`),
> drop `experimental: true` in the **same change** — the linkage is guarded by
> `tests/lib/doc-consistency.test.mjs` ("economy experimental flag agrees with
> parked-synthetic calibration status").
Empirical Jaccard calibration was deferred from v4.1 because the four
required `/trekplan` invocations cost an estimated $60-120 of LLM-budget
that was not authorized for the v4.1-execute-4b session. Per Step 17
escalate-handler, this file documents:
1. The synthetic placeholder fixtures used by Step 18's smoke-test, and
2. The pinned conservative threshold (`0.55`) from research/02.
## Threshold rationale
`threshold: 0.55` is pinned per research/02 (Recommendation #5):
> "There is no universal Jaccard threshold for cross-model plan
> agreement. arXiv:2412.12148 reports 0.45–0.65 for n=10 task-pair
> samples on coding tasks. We recommend a *conservative starting value
> of 0.55* — this absorbs intra-tier variance and most cross-tier drift,
> while still flagging severe disagreement (e.g. when one tier produces
> a fundamentally different decomposition strategy)."
The 0.55 floor is enforced by `tests/integration/profile-jaccard-smoke.test.mjs`
(Step 18) as a module-local constant `CROSS_TIER_JACCARD_FLOOR`. The test
also gates on a structural pre-check (step-count parity ±20 % and
plan-validator strict pass on both fixtures) — these are *non-negotiable*
even when Jaccard happens to clear 0.55.
## Synthetic fixture pairs
The four parked-synthetic plan-runs in `tests/synthetic/`:
| run-A | run-B | jaccard (synthetic, normalized) |
|-------|-------|---------------------------------|
| profile-plan-run-economy-1.md | profile-plan-run-premium-1.md | 0.707 |
| profile-plan-run-economy-1.md | profile-plan-run-premium-2.md | 0.707 |
| profile-plan-run-economy-2.md | profile-plan-run-premium-1.md | 0.750 |
| profile-plan-run-economy-2.md | profile-plan-run-premium-2.md | 0.750 |
Intra-tier (sanity): economy-1 × economy-2 = 0.935;
premium-1 × premium-2 = 0.905. Intra-tier > cross-tier confirms the
fixtures discriminate.
Min observed cross-tier (synthetic): 0.707. Min minus 0.05 buffer = 0.657.
We pin `threshold: 0.55` — the lower of (research/02 conservative value)
vs (min - 0.05 buffer). This is the same rule plan.md Step 17 prescribes:
`floor(min(jaccard_values), 2) - 0.05` or `0.55`, whichever is lower.
Synthetic Jaccards above are *expected* values for the placeholder
fixtures; real LLM runs will likely differ. The 0.55 pin remains valid
across that uncertainty.
## When to replace these fixtures
Trigger empirical calibration when **any** of the following holds:
1. Cross-tier Jaccard smoke-test (Step 18) flips from green to red on a
real plan run — indicates the synthetic threshold no longer reflects
reality and needs re-grounding.
2. v4.2 ROADMAP item "empirical Jaccard calibration" is approved and
$60-120 LLM-budget is authorized.
3. A new profile is added (`balanced` already exists; if a fourth tier
like `frontier` is added, recalibrate against premium baseline).
## How to replace
1. Run `/trekplan --profile economy --brief examples/01-add-verbose-flag/brief.md`
twice. Save each plan's `steps:` frontmatter to
`profile-plan-run-economy-{1,2}.md` (overwrite synthetic content).
Update `status: parked-synthetic` → `status: empirical`.
2. Same for `--profile premium`, twice.
3. Recompute the four cross-tier Jaccards. Update the table above.
4. Repin threshold: `min(jaccard_values, 2) - 0.05` or 0.55, whichever
lower. (Tighter is fine; do not loosen below 0.55.)
5. Run `tests/integration/profile-jaccard-smoke.test.mjs` — must pass.
6. Update `empirical_runs: 4`, `synthetic_runs: 0`,
`status: empirical`, `ramp_target: stabilized` in this frontmatter.
## Fallback strategy in the meantime
Until real calibration is run, operators are advised to use the
`balanced` profile (sonnet for most phases, opus for plan + review) as
the lowest-risk choice. `balanced` was selected as the v4.1 default in
`commands/trekplan.md` Phase 5.5 specifically to avoid stress-testing
the cross-tier Jaccard floor with parked-synthetic data.

View file

@ -1,83 +0,0 @@
---
type: trekplan-synthetic
plan_version: "1.7"
created: 2026-05-09
task: "Add --verbose flag to CLI"
slug: verbose-flag
run_id: economy-1
profile_used: economy
status: parked-synthetic
steps:
- "Add config entry for verbose flag in package.json"
- "Define types for verbose mode in types.ts"
- "Update parseArgs to recognize --verbose flag"
- "Pass verbose context through main entry point"
- "Add log level enum (silent, normal, verbose)"
- "Wire log level into logger module"
- "Replace console.log with logger.info in handler.ts"
- "Add tests for parseArgs --verbose recognition"
- "Add tests for log level enum mapping"
- "Update README with --verbose flag documentation"
- "Add CHANGELOG entry for verbose flag"
- "Bump package.json minor version"
- "Add lint rule blocking direct console usage"
- "Run lint and fix new violations"
- "Add CLI integration test for --verbose end-to-end"
- "Add fixture file for verbose log capture"
- "Document verbose output format in docs/cli.md"
- "Add jsdoc for new logger API"
- "Verify all existing tests pass with verbose disabled"
- "Add backward-compat test for legacy quiet behavior"
- "Update help text to list --verbose flag"
- "Add usage example to docs/quickstart.md"
- "Verify CI matrix runs on Node 18 and 20"
- "Update .gitignore for verbose log dump files"
- "Add cleanup logic for stale verbose logs"
- "Verify exit code on verbose mode error"
- "Add stderr routing for warnings in verbose"
- "Update troubleshooting guide with verbose flag"
- "Verify version sync across all docs"
- "Document verbose changes in release notes"
---
# Synthetic plan run economy-1 — Add --verbose flag to CLI (PARKED)
This fixture is a SYNTHETIC PLACEHOLDER for empirical Jaccard calibration
that requires live LLM-budget ($60-120 for 4 plan-runs). Marked
`status: parked-synthetic` per the Step 17 escalate-handler.
## Why parked
Per NEXT-SESSION-PROMPT.local.md fallback: "Hvis Step 17 LLM-budget
blokkerer: dokumentér `economy`-Plan som `parked` i kalibrasjons-fil og
fortsett med Step 18-19 ved bruk av `balanced` som lavterskel-profil."
The session running v4.1-execute-4b did not have authorization for live
LLM invocation against `/trekplan --profile economy --brief
examples/01-add-verbose-flag/brief.md`. Synthetic fixtures here represent
the *shape* of what such a run would produce — a near-subset of the
`premium` plan's steps (covering the same task surface) but with ~25 %
fewer sub-verification entries (no edge-case-collision step, no security
audit step, no PII test, no benchmark, etc).
## How this fixture is consumed
`tests/integration/profile-jaccard-smoke.test.mjs` (Step 18) reads the
`steps` array from the frontmatter and pairs it with the corresponding
`premium` fixtures to compute cross-tier Jaccard.
When real LLM budget is approved (deferred to v4.2), regenerate this
fixture by running the actual command and overwriting the frontmatter
`steps` array. Update `status: parked-synthetic` → `status: empirical`.
## Step-shape rationale
Economy profile uses sonnet for all phases (per
`lib/profiles/economy.yaml`). Empirical observation from research/02:
sonnet plans tend toward fewer verification entries, fewer edge-case
branches, and slightly less granular decomposition than opus plans. The
30 entries here represent the typical "skip the marginal sub-verification"
behaviour while keeping wording aligned with what an opus run would
produce on the same brief — modeling the realistic expectation that
profile choice changes *what* steps get included more than *how* the
included ones are phrased.

View file

@ -1,63 +0,0 @@
---
type: trekplan-synthetic
plan_version: "1.7"
created: 2026-05-09
task: "Add --verbose flag to CLI"
slug: verbose-flag
run_id: economy-2
profile_used: economy
status: parked-synthetic
steps:
- "Add config entry for verbose flag in package.json"
- "Define types for verbose mode in types.ts"
- "Update parseArgs to recognize --verbose flag"
- "Pass verbose context through main entry point"
- "Add log level enum (silent, normal, verbose)"
- "Wire log level into logger module"
- "Replace console.log with logger.info in handler.ts"
- "Add tests for parseArgs --verbose recognition"
- "Add tests for log level enum mapping"
- "Update README with --verbose flag documentation"
- "Add CHANGELOG entry for verbose flag"
- "Bump package.json minor version"
- "Add lint rule blocking direct console usage"
- "Run lint and fix new violations"
- "Add CLI integration test for --verbose end-to-end"
- "Add fixture file for verbose log capture"
- "Document verbose output format in docs/cli.md"
- "Add jsdoc for new logger API"
- "Verify all existing tests pass with verbose disabled"
- "Add backward-compat test for legacy quiet behavior"
- "Update help text to list --verbose flag"
- "Add usage example to docs/quickstart.md"
- "Verify CI matrix runs on Node 18 and 20"
- "Update .gitignore for verbose log dump files"
- "Add cleanup logic for stale verbose logs"
- "Verify exit code on verbose mode error"
- "Add stderr routing for warnings in verbose"
- "Update troubleshooting guide with verbose flag"
- "Verify version sync across all docs"
- "Add timestamp prefix in verbose log lines"
---
# Synthetic plan run economy-2 — Add --verbose flag to CLI (PARKED)
Companion fixture to `profile-plan-run-economy-1.md`. Same `economy`
profile, simulated as a second run of the same brief, with the final
step replaced (release notes → timestamp prefix) to model intra-tier
variance.
See `profile-plan-run-economy-1.md` for full parked-synthetic rationale.
## Intra-tier Jaccard
Economy-1 vs economy-2 share 29/30 step titles (final step differs);
union = 31. Jaccard = 29/31 ≈ 0.935 — well above any reasonable
cross-tier floor. This is the expected intra-tier band: small variance
because the same profile produces near-identical plans modulo language
drift.
When real LLM-budget runs replace this synthetic, the empirical
intra-tier Jaccard is expected to land in the 0.85–0.95 band per
research/02. Cross-tier (economy vs premium) is the discriminating
measurement and is documented in `profile-jaccard-calibration.md`.

View file

@ -1,80 +0,0 @@
---
type: trekplan-synthetic
plan_version: "1.7"
created: 2026-05-09
task: "Add --verbose flag to CLI"
slug: verbose-flag
run_id: premium-1
profile_used: premium
status: parked-synthetic
steps:
- "Add config entry for verbose flag in package.json"
- "Define types for verbose mode in types.ts"
- "Update parseArgs to recognize --verbose flag"
- "Pass verbose context through main entry point"
- "Add log level enum (silent, normal, verbose)"
- "Wire log level into logger module"
- "Replace console.log with logger.info in handler.ts"
- "Add tests for parseArgs --verbose recognition"
- "Add tests for log level enum mapping"
- "Update README with --verbose flag documentation"
- "Add CHANGELOG entry for verbose flag"
- "Bump package.json minor version"
- "Add lint rule blocking direct console usage"
- "Run lint and fix new violations"
- "Add CLI integration test for --verbose end-to-end"
- "Add fixture file for verbose log capture"
- "Document verbose output format in docs/cli.md"
- "Add jsdoc for new logger API"
- "Verify all existing tests pass with verbose disabled"
- "Add backward-compat test for legacy quiet behavior"
- "Add edge-case test for repeated --verbose flags"
- "Add edge-case test for --verbose with --silent collision"
- "Update help text to list --verbose flag"
- "Add usage example to docs/quickstart.md"
- "Verify CI matrix runs on Node 18 and 20"
- "Add npm script for verbose mode debugging"
- "Run security audit on logger dependency tree"
- "Verify no PII leaks in verbose log output"
- "Add manual test checklist to CONTRIBUTING.md"
- "Update .gitignore for verbose log dump files"
- "Add cleanup logic for stale verbose logs"
- "Add unit test for cleanup logic"
- "Verify exit code on verbose mode error"
- "Add stderr routing for warnings in verbose"
- "Add timestamp prefix in verbose log lines"
- "Add test for timestamp format"
- "Update troubleshooting guide with verbose flag"
- "Verify version sync across all docs"
- "Add benchmark for verbose log emission cost"
- "Document benchmark methodology in PERF.md"
---
# Synthetic plan run premium-1 — Add --verbose flag to CLI (PARKED)
This fixture is a SYNTHETIC PLACEHOLDER for empirical Jaccard calibration
that requires live LLM-budget ($60-120 for 4 plan-runs). Marked
`status: parked-synthetic` per the Step 17 escalate-handler.
## Why parked
Same rationale as `profile-plan-run-economy-1.md`. The session running
v4.1-execute-4b did not have authorization for live LLM invocation. This
fixture mirrors the existing baseline `plan-run-A.md` (40 steps, opus
granularity) since premium profile uses opus for `plan` and `review`
phases per `lib/profiles/premium.yaml`.
## Step-shape rationale
Premium profile uses opus for plan + review phases (per
`lib/profiles/premium.yaml`). Empirical observation from research/02:
opus plans tend toward finer-grained steps, more explicit verification
entries, and richer edge-case decomposition than sonnet plans. The 40
entries here capture the level of detail typical of an opus run.
## Cross-tier Jaccard pairing
Paired with `profile-plan-run-economy-1.md` and `-economy-2.md` in
`tests/integration/profile-jaccard-smoke.test.mjs` (Step 18). Expected
cross-tier Jaccard for the parked-synthetic run-pair is documented in
`profile-jaccard-calibration.md`.

View file

@ -1,73 +0,0 @@
---
type: trekplan-synthetic
plan_version: "1.7"
created: 2026-05-09
task: "Add --verbose flag to CLI"
slug: verbose-flag
run_id: premium-2
profile_used: premium
status: parked-synthetic
steps:
- "Add config entry for verbose flag in package.json"
- "Define types for verbose mode in types.ts"
- "Update parseArgs to recognize --verbose flag"
- "Pass verbose context through main entry point"
- "Add log level enum (silent, normal, verbose)"
- "Wire log level into logger module"
- "Replace console.log with logger.info in handler.ts"
- "Add tests for parseArgs --verbose recognition"
- "Add tests for log level enum mapping"
- "Update README with --verbose flag documentation"
- "Add CHANGELOG entry for verbose flag"
- "Bump package.json minor version"
- "Add lint rule blocking direct console usage"
- "Run lint and fix new violations"
- "Add CLI integration test for --verbose end-to-end"
- "Add fixture file for verbose log capture"
- "Document verbose output format in docs/cli.md"
- "Add jsdoc for new logger API"
- "Verify all existing tests pass with verbose disabled"
- "Add backward-compat test for legacy quiet behavior"
- "Add edge-case test for repeated --verbose flags"
- "Add edge-case test for --verbose with --silent collision"
- "Update help text to list --verbose flag"
- "Add usage example to docs/quickstart.md"
- "Verify CI matrix runs on Node 18 and 20"
- "Add npm script for verbose mode debugging"
- "Run security audit on logger dependency tree"
- "Verify no PII leaks in verbose log output"
- "Add manual test checklist to CONTRIBUTING.md"
- "Update .gitignore for verbose log dump files"
- "Add cleanup logic for stale verbose logs"
- "Add unit test for cleanup logic"
- "Verify exit code on verbose mode error"
- "Add stderr routing for warnings in verbose"
- "Add timestamp prefix in verbose log lines"
- "Add test for timestamp format"
- "Update troubleshooting guide with verbose flag"
- "Verify version sync across all docs"
- "Add benchmark for verbose log capture overhead"
- "Document overhead methodology in PERF.md"
---
# Synthetic plan run premium-2 — Add --verbose flag to CLI (PARKED)
Companion to `profile-plan-run-premium-1.md`. Same `premium` profile,
simulated as a second run with two terminal steps replaced
(emission cost / benchmark methodology → capture overhead / overhead
methodology) to model intra-tier variance.
## Intra-tier Jaccard
Premium-1 vs premium-2 share 38/40 step titles; union = 42.
Jaccard = 38/42 ≈ 0.905 — matches the existing baseline plan-run-A vs
plan-run-B floor (≥ 0.833 in plan-determinism.test.mjs).
## Cross-tier Jaccard rationale
Pairing premium fixtures (40 steps) against economy fixtures (30 steps)
yields ~30 shared titles (after string-normalisering), with union ~40.
Conservative cross-tier Jaccard ≈ 30/40 = 0.75 in this synthetic — but
the calibration file pins a *more conservative* floor (0.55) per
research/02 to absorb empirical variance once real runs replace these
fixtures. See `profile-jaccard-calibration.md` for threshold derivation.

View file

@ -27,7 +27,6 @@ project_dir: .claude/projects/2026-10-06-dark-mode-toggle/
research_topics: 0
research_status: complete
framing: new-direction
phase_signals_partial: true
${extra}---
# Task

View file

@ -69,7 +69,6 @@ auto_research: false
interview_turns: 5
source: interview
framing: new-direction
phase_signals_partial: true
---
# Task: JWT auth
@ -116,28 +115,7 @@ Find what was missed.
- All BLOCKER findings get a fix-plan.
`;
// signals blocks
const GOOD_SIGNALS = `phase_signals:
- phase: plan
effort: high
model: opus
`;
const BAD_PHASE_SIGNALS = `phase_signals:
- phase: nonsense
effort: high
`;
const BAD_EFFORT_SIGNALS = `phase_signals:
- phase: plan
effort: turbo
`;
const BAD_MODEL_SIGNALS = `phase_signals:
- phase: plan
effort: high
model: gpt4
`;
// convenience builders
const v21 = (s) => s.replace('brief_version: "2.0"', 'brief_version: "2.1"');
const withLine = (s, line) => s.replace('source: interview\n', `source: interview\n${line}`);
// ---- the canonical in-scope set + a class-balanced pair for each -------------
@ -151,12 +129,6 @@ const IN_SCOPE_CODES = [
'BRIEF_MISSING_SECTION',
'BRIEF_INVALID_FRAMING',
'BRIEF_MISSING_FRAMING',
'BRIEF_V51_MISSING_SIGNALS',
'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE',
'BRIEF_INVALID_PHASE_SIGNALS',
'BRIEF_INVALID_PHASE_SIGNAL_PHASE',
'BRIEF_INVALID_EFFORT',
'BRIEF_INVALID_MODEL',
];
const CASES = {
@ -192,30 +164,6 @@ const CASES = {
fail: GOOD_BRIEF_22.replace('framing: new-direction\n', ''),
pass: GOOD_BRIEF_22,
},
BRIEF_V51_MISSING_SIGNALS: {
fail: v21(GOOD_BRIEF),
pass: GOOD_BRIEF, // 2.0 — gate inactive
},
BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE: {
fail: withLine(v21(GOOD_BRIEF), `phase_signals_partial: true\n${GOOD_SIGNALS}`),
pass: withLine(v21(GOOD_BRIEF), 'phase_signals_partial: true\n'),
},
BRIEF_INVALID_PHASE_SIGNALS: {
fail: withLine(GOOD_BRIEF, 'phase_signals: notalist\n'),
pass: withLine(v21(GOOD_BRIEF), GOOD_SIGNALS),
},
BRIEF_INVALID_PHASE_SIGNAL_PHASE: {
fail: withLine(v21(GOOD_BRIEF), BAD_PHASE_SIGNALS),
pass: withLine(v21(GOOD_BRIEF), GOOD_SIGNALS),
},
BRIEF_INVALID_EFFORT: {
fail: withLine(v21(GOOD_BRIEF), BAD_EFFORT_SIGNALS),
pass: withLine(v21(GOOD_BRIEF), GOOD_SIGNALS),
},
BRIEF_INVALID_MODEL: {
fail: withLine(v21(GOOD_BRIEF), BAD_MODEL_SIGNALS),
pass: withLine(v21(GOOD_BRIEF), GOOD_SIGNALS),
},
};
test('gate coverage — CASES keys exactly match IN_SCOPE_CODES (no silent gap)', () => {

View file

@ -157,127 +157,10 @@ test('validateBrief — wrong-type error message includes accepted set', () => {
assert.ok(/trekreview/.test(wrongType.message));
});
// --- v5.1 — phase_signals additive field + sequencing gate ---
const SIGNALS_BLOCK = `phase_signals:
- phase: research
effort: standard
- phase: plan
effort: high
model: opus
- phase: execute
effort: low
model: sonnet
- phase: review
effort: standard
`;
test('validateBrief — v5.1 well-formed phase_signals accepted', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateBrief — pre-v5.1 brief without phase_signals accepted (backward-compat)', () => {
const r = validateBriefContent(GOOD_BRIEF, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});
test('validateBrief — v5.1+ brief missing phase_signals + partial emits BRIEF_V51_MISSING_SIGNALS', () => {
const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: "2.1"');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});
test('validateBrief — v5.1+ brief with phase_signals_partial: true accepted', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', 'source: interview\nphase_signals_partial: true\n');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateBrief — phase_signals + phase_signals_partial both set rejected (mutually exclusive)', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\nphase_signals_partial: true\n${SIGNALS_BLOCK}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE'));
});
test('validateBrief — phase_signals with unknown phase rejected', () => {
const BAD_SIGNALS = `phase_signals:
- phase: nonsense
effort: standard
`;
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\n${BAD_SIGNALS}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_INVALID_PHASE_SIGNAL_PHASE'));
});
// --- v5.1.1 regression: YAML-number bypass closed ---
// Findings 3c834097 + df1435a2: v5.1.0 shipped with an unquoted `brief_version: 2.1`
// template. parseScalar coerces unquoted "2.1" to Number 2.1, and the original gate
// guarded `typeof === 'string'`, silently bypassing the sequencing check. v5.1.1
// coerces via String() so both shapes trigger the gate.
test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 without signals triggers gate', () => {
const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: 2.1');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(
r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'),
`gate must fire for unquoted brief_version: 2.1 (YAML Number); errors=${JSON.stringify(r.errors)}`,
);
});
test('validateBrief — v5.1.1: QUOTED brief_version "2.1" without signals triggers gate (regression guard)', () => {
const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: "2.1"');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});
test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 WITH phase_signals is valid (positive case)', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: 2.1')
.replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'));
});
// --- v5.9 — fable model tier (BASE_ALLOWED_MODELS widened to three values) ---
test('validateBrief — v5.9: fable phase_signals fixture accepted (no BRIEF_INVALID_MODEL)', () => {
const t = readFileSync(new URL('../fixtures/brief-effort-fable.md', import.meta.url), 'utf-8');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_INVALID_MODEL'));
});
test('validateBrief — v5.9: unknown model gpt5 in phase_signals rejected with BRIEF_INVALID_MODEL', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK.replace('model: opus', 'model: gpt5')}`);
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'BRIEF_INVALID_MODEL'),
`expected BRIEF_INVALID_MODEL for gpt5, got: ${JSON.stringify(r.errors)}`);
});
// --- v5.5 — framing enforcement + obligatory TL;DR (gated at brief_version ≥ 2.2) ---
// Operator decision (S6, option A1): framing + TL;DR are hard BLOCKERs for briefs
// declaring brief_version ≥ 2.2; existing 2.0/2.1 briefs stay valid (forward-compat,
// mirroring the phase_signals ≥ 2.1 precedent). The framing ENUM check fires on any
// declaring brief_version ≥ 2.2; existing 2.0/2.1 briefs stay valid (forward-compat).
// The framing ENUM check fires on any
// version when the field is present but malformed.
const GOOD_BRIEF_22 = `---
@ -293,7 +176,6 @@ auto_research: false
interview_turns: 5
source: interview
framing: new-direction
phase_signals_partial: true
---
# Task: JWT auth
@ -315,6 +197,18 @@ What success looks like.
- All tests pass.
`;
// An older brief may still carry a phase_signals block (removed in cleanup R-b,
// decision 191): the key is ignored, with no error and no warning.
test('validateBrief — a 2.2 brief that still carries phase_signals validates with no error or warning about it', () => {
const t = GOOD_BRIEF_22.replace('framing: new-direction\n',
'framing: new-direction\nphase_signals:\n - phase: plan\n effort: high\n - phase: brief\n effort: xhigh\n');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.deepEqual(r.errors, []);
assert.deepEqual(r.warnings.filter(w => /signal/i.test(w.code + ' ' + w.message)), []);
});
test('validateBrief — v5.5 well-formed 2.2 brief (framing + TL;DR) accepted', () => {
const r = validateBriefContent(GOOD_BRIEF_22, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
@ -367,8 +261,7 @@ test('validateBrief — v5.5 TL;DR exceeding 5 lines emits BRIEF_TLDR_TOO_LONG w
test('validateBrief — v5.5 backward-compat: 2.1 brief without framing/TL;DR stays valid', () => {
const t = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', 'source: interview\nphase_signals_partial: true\n');
.replace('brief_version: "2.0"', 'brief_version: "2.1"');
const r = validateBriefContent(t, { strict: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(!r.errors.find(e => e.code === 'BRIEF_MISSING_FRAMING'));
@ -385,8 +278,7 @@ test('validateBrief — v5.5 trekreview brief not subject to framing/TL;DR gate'
// S18 — opt-in --min-brief-version gate. Warns (never blocks) when a brief
// declares a version below the caller's floor, sidestepping framing enforcement.
const BRIEF_21 = GOOD_BRIEF
.replace('brief_version: "2.0"', 'brief_version: "2.1"')
.replace('source: interview\n', 'source: interview\nphase_signals_partial: true\n');
.replace('brief_version: "2.0"', 'brief_version: "2.1"');
test('validateBrief — S18 min-version: older brief below floor warns but does not block', () => {
const r = validateBriefContent(BRIEF_21, { strict: true, minBriefVersion: '2.2' });
@ -457,7 +349,7 @@ const codes = (list) => list.map((e) => e.code);
function briefWith(sections) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
...sections,
].join('\n');

View file

@ -42,7 +42,6 @@ auto_research: false
interview_turns: 4
source: interview
framing: new-direction
phase_signals_partial: true
---
# Task: Dark-mode toggle

View file

@ -1,68 +0,0 @@
// tests/validators/plan-validator-profile-drift.test.mjs
// SC #20 — MANIFEST_PROFILE_DRIFT warning per brief Assumptions block 7.
// In strict mode, plan-validator must emit a warning (NOT an error) when a
// step manifest's profile_used differs from the plan's frontmatter profile.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..', '..');
function loadFixture(name) {
return readFileSync(resolve(ROOT, 'tests/fixtures', name), 'utf-8');
}
const DRIFT_FIXTURE = loadFixture('plan-profile-drift.md');
test('drift detected in strict mode — emits MANIFEST_PROFILE_DRIFT warning, not error', () => {
const r = validatePlanContent(DRIFT_FIXTURE, { strict: true });
assert.equal(r.valid, true, `plan must remain valid; errors: ${JSON.stringify(r.errors)}`);
const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT');
assert.equal(drift.length, 1, `expected 1 drift warning, got ${drift.length}: ${JSON.stringify(drift)}`);
assert.match(drift[0].message, /step 2/, `warning message must reference step 2: ${drift[0].message}`);
assert.match(drift[0].message, /premium/, 'warning must include offending profile_used value');
assert.match(drift[0].message, /economy/, 'warning must include plan-level profile value');
});
test('drift NOT detected in soft mode — strict gate honored', () => {
const r = validatePlanContent(DRIFT_FIXTURE, { strict: false });
const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT');
assert.equal(
drift.length,
0,
'MANIFEST_PROFILE_DRIFT must only emit in strict mode (per brief assumption 7)',
);
});
test('matching profile — no drift warning emitted', () => {
// Same fixture body, but rewrite step 2 profile_used to match plan profile.
// Use /g to catch both the doc-comment mention and the actual manifest entry.
const matching = DRIFT_FIXTURE.replace(/profile_used: premium/g, 'profile_used: economy');
const r = validatePlanContent(matching, { strict: true });
assert.equal(r.valid, true);
const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT');
assert.equal(
drift.length,
0,
`no drift expected when all step profile_used match plan profile; got ${JSON.stringify(drift)}`,
);
});
test('plan without frontmatter profile — no drift warnings emitted', () => {
// If plan-level profile is absent, step-level profile_used can be anything
// without triggering drift (drift is only meaningful relative to a baseline).
const noProfile = DRIFT_FIXTURE.replace(/profile: economy\n/, '');
const r = validatePlanContent(noProfile, { strict: true });
const drift = r.warnings.filter((w) => w.code === 'MANIFEST_PROFILE_DRIFT');
assert.equal(
drift.length,
0,
'no plan-level profile means no baseline; drift detection must be silent',
);
});

View file

@ -1,276 +0,0 @@
// tests/validators/profile-validator.test.mjs
// SC #1, #2, #3: profile-validator validates lib/profiles/{economy,balanced,premium,fable}.yaml
// (innebygde profiler) plus rejects invalid models and invalid enum types.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import {
validateProfile,
validateProfileContent,
PROFILE_REQUIRED_FIELDS,
PROFILE_REQUIRED_PHASES,
BASE_ALLOWED_MODELS,
} from '../../lib/validators/profile-validator.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..', '..');
// SC #1: alle 4 innebygde profiler grønne
for (const profileName of ['economy', 'balanced', 'premium', 'fable']) {
test(`SC #1: lib/profiles/${profileName}.yaml validates clean`, () => {
const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', `${profileName}.yaml`));
assert.equal(r.valid, true,
`expected valid, got errors: ${JSON.stringify(r.errors, null, 2)}`);
assert.equal(r.errors.length, 0);
// Spot-check: all 6 phases present
const phases = r.parsed.frontmatter.phase_models.map(p => p.phase);
for (const required of PROFILE_REQUIRED_PHASES) {
assert.ok(phases.includes(required), `${profileName} missing phase: ${required}`);
}
});
}
// SC #2: PROFILE_INVALID_MODEL fired when phase_models[N].model not in allowlist
test('SC #2: profile-invalid-model.yaml rejected with PROFILE_INVALID_MODEL at phase_models[2].model', () => {
const r = validateProfile(join(REPO_ROOT, 'tests', 'fixtures', 'profile-invalid-model.yaml'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_MODEL');
assert.ok(found, `expected PROFILE_INVALID_MODEL, got: ${JSON.stringify(r.errors)}`);
assert.equal(found.location, 'phase_models[2].model',
`expected location phase_models[2].model, got ${found.location}`);
assert.match(found.message, /gpt-4/);
});
// SC #3: PROFILE_INVALID_ENUM for wrong-type values
test('SC #3: profile-invalid-enum.yaml rejected with PROFILE_INVALID_ENUM (external_research_enabled is string)', () => {
const r = validateProfile(join(REPO_ROOT, 'tests', 'fixtures', 'profile-invalid-enum.yaml'));
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_ENUM' && /external_research_enabled/.test(e.message));
assert.ok(found, `expected PROFILE_INVALID_ENUM for external_research_enabled, got: ${JSON.stringify(r.errors)}`);
assert.match(found.message, /boolean/);
});
// VOYAGE_ALLOW_HAIKU env-var allows haiku model
test('VOYAGE_ALLOW_HAIKU=1 allows haiku in phase_models', () => {
const haikuProfile = `---
profile_version: "1.0"
name: haiku-allowed
phase_models:
- phase: brief
model: haiku
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 4
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`;
// Default env: haiku rejected
const denied = validateProfileContent(haikuProfile, { env: { /* no VOYAGE_ALLOW_HAIKU */ } });
assert.equal(denied.valid, false);
const haikuErr = denied.errors.find(e => e.code === 'PROFILE_INVALID_MODEL' && /haiku/.test(e.message));
assert.ok(haikuErr, `expected haiku rejection: ${JSON.stringify(denied.errors)}`);
assert.match(haikuErr.message, /VOYAGE_ALLOW_HAIKU/);
// With opt-in: haiku accepted
const allowed = validateProfileContent(haikuProfile, { env: { VOYAGE_ALLOW_HAIKU: '1' } });
assert.equal(allowed.valid, true,
`expected valid with VOYAGE_ALLOW_HAIKU=1, got: ${JSON.stringify(allowed.errors)}`);
});
// Fable tier (v5.9): fable accepted under DEFAULT env (no opt-in flag),
// unknown models still rejected — the allowlist gate must demonstrably fire.
test('fable accepted in phase_models under default env (no env flag)', () => {
const fableProfile = `---
profile_version: "1.0"
name: fable-inline
phase_models:
- phase: brief
model: fable
- phase: research
model: fable
- phase: plan
model: fable
- phase: execute
model: fable
- phase: review
model: fable
- phase: continue
model: fable
parallel_agents_min: 6
parallel_agents_max: 8
external_research_enabled: true
brief_reviewer_iter_cap: 3
---
`;
const r = validateProfileContent(fableProfile, { env: { /* default: no flags */ } });
assert.equal(r.valid, true,
`expected fable accepted under default env, got: ${JSON.stringify(r.errors)}`);
assert.equal(r.errors.length, 0);
});
test('unknown model gpt5 rejected with PROFILE_INVALID_MODEL under default env', () => {
const gpt5Profile = `---
profile_version: "1.0"
name: gpt5-inline
phase_models:
- phase: brief
model: gpt5
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 4
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`;
const r = validateProfileContent(gpt5Profile, { env: { /* default: no flags */ } });
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_MODEL' && /gpt5/.test(e.message));
assert.ok(found, `expected PROFILE_INVALID_MODEL for gpt5, got: ${JSON.stringify(r.errors)}`);
});
// BASE_ALLOWED_MODELS allowlist drift-pin (mirrors the PROFILE_REQUIRED_FIELDS pin)
test('BASE_ALLOWED_MODELS export drift-pin', () => {
assert.deepEqual(
[...BASE_ALLOWED_MODELS],
['sonnet', 'opus', 'fable'],
'BASE_ALLOWED_MODELS contract drift — pin contract',
);
});
// Required fields presence
test('PROFILE_MISSING_FIELD when name absent', () => {
const r = validateProfileContent(`---
profile_version: "1.0"
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: opus
- phase: execute
model: sonnet
- phase: review
model: opus
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 4
external_research_enabled: false
brief_reviewer_iter_cap: 1
---
`);
assert.equal(r.valid, false);
const found = r.errors.find(e => e.code === 'PROFILE_MISSING_FIELD' && /name/.test(e.message));
assert.ok(found, `expected PROFILE_MISSING_FIELD for name, got: ${JSON.stringify(r.errors)}`);
});
// PROFILE_NOT_FOUND for missing file
test('PROFILE_NOT_FOUND for non-existent file', () => {
const r = validateProfile('/tmp/does-not-exist-profile-xyz.yaml');
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROFILE_NOT_FOUND'));
});
// REQUIRED_FIELDS frontmatter contract drift-pin
test('PROFILE_REQUIRED_FIELDS export drift-pin', () => {
assert.deepEqual(
[...PROFILE_REQUIRED_FIELDS].sort(),
['brief_reviewer_iter_cap', 'external_research_enabled', 'name',
'parallel_agents_max', 'parallel_agents_min', 'phase_models'].sort(),
'PROFILE_REQUIRED_FIELDS contract drift — pin contract',
);
});
test('PROFILE_REQUIRED_PHASES export drift-pin', () => {
assert.deepEqual(
[...PROFILE_REQUIRED_PHASES].sort(),
['brief', 'research', 'plan', 'execute', 'review', 'continue'].sort(),
'PROFILE_REQUIRED_PHASES contract drift — pin contract',
);
});
// S34 (V30) — economy carries an explicit `experimental` marker.
// The cross-tier Jaccard floor (0.55) rests on parked-synthetic fixtures;
// empirical Step-17 calibration is deferred to v4.2 ($60-120 LLM budget,
// unauthorized). Until a real calibration lands, `economy` is self-declared
// experimental in the profile DATA — not only in prose — so the status
// survives a docs rewrite. See tests/synthetic/profile-jaccard-calibration.md.
test('S34: economy.yaml declares experimental: true (uncalibrated Jaccard floor)', () => {
const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', 'economy.yaml'));
assert.equal(r.valid, true, `economy.yaml should validate: ${JSON.stringify(r.errors)}`);
assert.equal(r.parsed.frontmatter.experimental, true,
'economy.yaml must carry experimental: true until the Jaccard floor is empirically calibrated (S34/V30)');
});
test('S34: premium + balanced are NOT flagged experimental', () => {
for (const name of ['premium', 'balanced']) {
const r = validateProfile(join(REPO_ROOT, 'lib', 'profiles', `${name}.yaml`));
assert.equal(r.valid, true, `${name}.yaml should validate: ${JSON.stringify(r.errors)}`);
assert.notEqual(r.parsed.frontmatter.experimental, true,
`${name}.yaml must not be flagged experimental`);
}
});
test('S34: validator type-checks experimental as boolean (PROFILE_INVALID_ENUM)', () => {
const bad = `---
profile_version: "1.0"
name: bad-experimental
phase_models:
- phase: brief
model: sonnet
- phase: research
model: sonnet
- phase: plan
model: sonnet
- phase: execute
model: sonnet
- phase: review
model: sonnet
- phase: continue
model: sonnet
parallel_agents_min: 2
parallel_agents_max: 3
external_research_enabled: false
brief_reviewer_iter_cap: 1
experimental: "yes"
---
`;
const r = validateProfileContent(bad);
assert.equal(r.valid, false, 'experimental: "yes" (string) must be rejected');
const found = r.errors.find(e => e.code === 'PROFILE_INVALID_ENUM' && /experimental/.test(e.message));
assert.ok(found, `expected PROFILE_INVALID_ENUM for experimental, got: ${JSON.stringify(r.errors)}`);
assert.match(found.message, /boolean/);
});

View file

@ -171,7 +171,7 @@ test('runner: a failing verification is never verified end to end', () => {
function brief(criteria) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
'## Success Criteria', '', ...criteria, '',
].join('\n');