Compare commits

...

15 commits

Author SHA1 Message Date
8f4b79cfc6 docs(voyage): add CHANGELOG entry for v5.1.0 2026-05-13 21:24:49 +02:00
dfe1986f06 chore(voyage): bump version 5.0.3 → 5.1.0 2026-05-13 21:23:48 +02:00
6efcc62b68 docs(voyage): document phase_signals in CLAUDE + README + marketplace + ROADMAP (v5.1) 2026-05-13 21:22:07 +02:00
113296d7de docs(voyage): amend HANDOVER-CONTRACTS + add 5 doc-consistency pins (v5.1) 2026-05-13 21:18:42 +02:00
4504c9a8cf test(voyage): add 5 minimal command test files for v5.1 (sequencing-gate + low-effort) 2026-05-13 21:15:26 +02:00
d3975c441c feat(voyage): wire 4 downstream commands to brief.phase_signals + composition rule (v5.1) 2026-05-13 21:13:51 +02:00
56fed8f305 feat(voyage): add Phase 3.5 per-phase effort dialog to /trekbrief (v5.1) 2026-05-13 21:11:04 +02:00
0655b57930 feat(voyage): bump trekbrief-template to brief_version 2.1 + add phase_signals fixtures 2026-05-13 21:09:57 +02:00
bf68fe6f5f feat(voyage): add phase_signals validation + sequencing gate to brief-validator (v5.1) 2026-05-13 21:08:37 +02:00
8cbb33e1fd docs(voyage): pin operator-UX contract — always emit file:// link + open command
Operator runs Ghostty (also iTerm2, modern Terminal.app) — all support
cmd+click on file:// URLs. Producing commands (/trekbrief, /trekplan,
/trekreview) already emit both forms but the contract was implicit.
This commit makes it explicit:

1. CLAUDE.md gains an "Operator-UX guarantee" paragraph stating both
   forms must always appear in the final report: (a) plain file://
   URL with absolute path (for cmd+click), (b) copy-pasteable
   `open file://` command (for terminals without cmd+click).

2. tests/lib/doc-consistency.test.mjs gains a pin asserting both
   patterns appear in all three producing commands' final report
   blocks. Drift catches at test time.

Non-functional change to the commands themselves — they already
emit both forms (verified at trekbrief.md L510/L519, trekplan.md
L798/L802, trekreview.md L299/L317).

Operator request 2026-05-13: "Noter ned i Voyage at jeg ALLTID får
en slik direkte file:// lenke."
2026-05-13 20:31:58 +02:00
4b5a3a24dd chore(voyage): pin all sub-agents to Opus permanently (operator request)
Flip model: sonnet → model: opus across 20 agent files, 4 prose references
in commands (trekplan, trekresearch), trekendsession command frontmatter,
and CLAUDE.md tables. Aligns CLAUDE.md premium-profile row to actual
premium.yaml content (all-opus, which has been the case since v4.1.0 but
the doc was drift). Companion to VOYAGE_PROFILE=premium env-var (set in
~/.zshenv same day) — env-var governs orchestrator phase model; this
commit governs sub-agent models which are frontmatter-pinned and not
reachable by the profile resolver.

npm test: 516 pass, 0 fail, 2 skipped (unchanged from baseline).

Operator rationale: complete Opus coverage across all Voyage activity,
including the 20 sub-agents that the profile system does not control
(architecture-mapper, task-finder, plan-critic, scope-guardian,
brief-reviewer, code-correctness-reviewer, brief-conformance-reviewer,
review-coordinator, session-decomposer, plus the 6 researcher agents,
plus the 5 codebase-analysis agents).

Cost implication: sub-agent runs ~5x more expensive vs sonnet. Accepted.
2026-05-13 20:20:08 +02:00
c03695c97b docs(voyage): note trinity context (Tier 1 of voyage/app-creator/app-factory)
Informational blockquote after the v3.0.0 note. Documents that voyage is
Tier 1 (per-task) of a three-tier architecture under the author's private
marketplace: Tier 2 app-creator (per-app), Tier 3 app-factory (per-portfolio).
Both are pre-implementation. Asymmetry-invariant preserved: voyage stays
unaware of Tier 2/3 — Handover 1 (brief format) is the only integration
point. Brief-schema changes therefore breaking for downstream consumers,
formalized in v5.4.
2026-05-13 15:56:03 +02:00
9ba8b682ef chore(voyage): release v5.0.3 — annotation UX matches the claude-code-100x reference
The operator pointed at ~/repos/claude-code-100x/claude-code-100x/build-site.js
as the annotation reference from the start. v4.2/v4.3 built a bespoke
playground instead. v5.0.0 deleted it. v5.0.1 pointed at /playground
document-critique (Claude-leads, wrong direction). v5.0.2 was operator-led
but too thin (line-click + freeform note, no intent). v5.0.3 finally
matches the reference.

scripts/annotate.mjs rewritten:
  - Markdown rendered as proper article HTML (h1/p/li/ul/table/blockquote/pre)
    instead of line-numbered raw lines.
  - Pencil-toggle annotation mode in the topbar, default ON.
  - Select text OR click any element → form popover at cursor.
  - Three intent buttons: Fiks (red) / Endre (orange) / Spørsmål (blue).
  - Comment textarea. Save (Cmd+Enter), Cancel (Esc).
  - Section context auto-detected from nearest h1/h2.
  - Sidebar panel: annotations grouped by section, intent badges,
    snippet quotes, delete buttons, click-to-scroll with flash highlight.
  - Copy Prompt: structured markdown export with intent labels.
  - localStorage persistence keyed on absolute artifact path
    (voyage-annotate:v2: prefix to avoid colliding with v5.0.2 state).

Tests: 12 (up from 10), all passing. npm test: 518 / 516 pass / 0 fail / 2 skipped.

Reference: ~/repos/claude-code-100x/claude-code-100x/build-site.js
lines 1431–2255 (annotation UI section).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 15:08:20 +02:00
8ea692bc60 chore(voyage): release v5.0.2 — operator-driven annotation HTML (scripts/annotate.mjs)
v5.0.0 added a read-only HTML render. v5.0.1 deleted that and pointed at
/playground document-critique, which pre-generates Claude's suggestions
and asks the operator to approve/reject them. The operator asked for the
opposite — a surface where THEY drive every annotation. v5.0.2 lands it.

scripts/annotate.mjs (~430 lines, zero deps) takes any artifact .md and
writes a self-contained HTML next to it. The HTML renders the document
with line numbers, lets the operator click any line to add their own
note (inline textarea, save with Cmd+Enter or button), keeps a sidebar
of all notes (editable + deletable + persisted in localStorage per
artifact path), and exposes Copy Prompt to gather every note into one
structured prompt. Operator copies, pastes back, Claude revises the .md.

The three producing commands now run annotate.mjs at their last step and
print the file:// link with explicit "Click any line to add YOUR OWN note"
instructions. The v5.0.1 /playground document-critique line is gone.

npm test green: 516 tests, 514 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 14:04:28 +02:00
2e0892cdaf chore(voyage): release v5.0.1 — drop standalone HTML render; print literal /playground document-critique invocation
The v5.0.0 stop-gap had /trekbrief, /trekplan, and /trekreview each render
a read-only {artifact}.html (via scripts/render-artifact.mjs) AND print a
vague "run the /playground plugin" instruction. In practice the read-only
HTML was redundant with what /playground produces and the instruction
wasn't copy-paste-ready — the operator had to guess the right invocation.

v5.0.1 deletes scripts/render-artifact.mjs + its test + npm run render,
and makes each producing command end with a single boxed, literal,
copy-paste-ready line:

    /playground build a document-critique playground for {artifact_path}

One paste from the operator launches the official playground skill's
document-critique template, which builds an interactive HTML — artifact
on the left, per-line Approve/Reject/Comment cards on the right, Copy
Prompt button at the bottom. Mark suggestions, click Copy Prompt, paste
back, Claude revises the .md. Doc-consistency test pins the literal
invocation so the prose cannot soften back into vagueness.

npm test green: 503 tests, 501 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 13:24:32 +02:00
53 changed files with 2391 additions and 604 deletions

View file

@ -23,7 +23,7 @@
{
"name": "voyage",
"source": "./plugins/voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. Renders produced artifacts to self-contained HTML + link; annotation via the official /playground plugin."
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): pencil-toggle annotation mode, select text or click any element, pick intent (Fiks/Endre/Spørsmål), comment, Copy Prompt, paste back, Claude revises the .md."
},
{
"name": "linkedin-thought-leadership",

View file

@ -13,7 +13,7 @@ plugins/
llm-security/ v6.0.0 — Security scanning, auditing, threat modeling
ms-ai-architect/ v1.13.1 — Microsoft AI architecture (Cosmo Skyberg persona) + manual KB-refresh slash command
okr/ v1.0.0 — OKR guidance for Norwegian public sector
voyage/ v5.0.0 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). Renders produced artifacts to self-contained HTML + link; annotation via the official /playground plugin. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8 (NIH; duplicated /playground's document-critique).
voyage/ v5.0.3 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). /trekbrief, /trekplan, and /trekreview each end by running scripts/annotate.mjs against the just-written .md and printing the file:// link to a self-contained operator-annotation HTML modelled on claude-code-100x/build-site.js: pencil-toggle annotation mode, select text or click any element, choose intent (Fiks/Endre/Spørsmål), comment, sidebar groups by section with delete + Copy Prompt, localStorage persistence per artifact path. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 pointed at /playground document-critique (wrong direction); v5.0.2 was operator-led but too thin; v5.0.3 matches the reference the operator pointed at from day one.
shared/
playground-design-system/ v0.1 — Aksel/Digdir-aligned CSS design system + JSON schemas + self-hosted Inter/JetBrains Mono/Source Serif 4 fonts (Tier 1+2+3 wave 1+wave 2 = 20 Tier 3 components total). Consumed by ms-ai-architect, okr, llm-security, voyage, config-audit

View file

@ -77,11 +77,13 @@ Key commands: `/config-audit posture`, `/config-audit feature-gap`, `/config-aud
---
### [Voyage](plugins/voyage/) `v5.0.0`
### [Voyage](plugins/voyage/) `v5.1.0`
Deep requirements gathering, research, implementation planning, self-verifying execution, independent post-hoc review, and zero-friction multi-session resumption — with specialized agent swarms, adversarial review, and failure recovery. Six-command (brief, research, plan, execute, review, continue) universal pipeline. `/trekbrief`, `/trekplan`, and `/trekreview` render their artifact to a self-contained HTML view and print the `file://` link; annotation is delegated to the official `/playground` plugin.
Deep requirements gathering, research, implementation planning, self-verifying execution, independent post-hoc review, and zero-friction multi-session resumption — with specialized agent swarms, adversarial review, and failure recovery. Six-command (brief, research, plan, execute, review, continue) universal pipeline + adaptive-depth per-phase effort dialog. `/trekbrief`, `/trekplan`, and `/trekreview` render their artifact to a self-contained HTML view and print the `file://` link.
v5.0.0 (breaking) **removes the bespoke playground.** v4.2/v4.3 shipped a ~388 KB bespoke playground SPA + `/trekrevise` + Handover 8 (annotation → revision); a browser walkthrough found it borderline unusable and it duplicated the official `/playground` plugin's `document-critique` / `diff-review` templates. The SPA, the `/trekrevise` command, Handover 8, the supporting `lib/` modules (`anchor-parser`, `annotation-digest`, `markdown-write`, `revision-guard`), the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps are all deleted. In their place: a small, zero-dependency `scripts/render-artifact.mjs` that renders any brief/plan/review `.md` to a self-contained, design-system-styled, zero-network `.html` (frontmatter folded into a `<details>` block). The producing commands call it on their last step and print the link; to annotate, run `/playground` (`document-critique`) on the `.md` and paste the generated prompt back — Claude revises the artifact freehand. Forks depending on the removed surfaces migrate to the `/playground` plugin. See `plugins/voyage/CHANGELOG.md` § v5.0.0.
v5.1.0 adds Phase 3.5 to `/trekbrief`: 4 tier-coupled `AskUserQuestion` calls commit an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus`) per downstream phase (`research`, `plan`, `execute`, `review`). The choices land in `brief.md` as `phase_signals:` (or `phase_signals_partial: true` on force-stop). `brief_version: 2.1` activates a validator-side sequencing gate (`BRIEF_V51_MISSING_SIGNALS`) so downstream commands halt with a friendly hint when signals are missing. Composition rule per downstream command: brief signal wins per-phase, profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low-effort = `--gates open` + sequential-only). Additive — no breaking changes; pre-2.1 briefs still validate. See `plugins/voyage/CHANGELOG.md` § v5.1.0.
v5.0.3 lands the annotation UX modelled on `~/repos/claude-code-100x/claude-code-100x/build-site.js`: pencil-toggle annotation mode, **select text or click any element to anchor**, choose intent (**Fiks** / **Endre** / **Spørsmål**), write a comment, save. The sidebar groups annotations by section with intent badges; Copy Prompt assembles them into a structured markdown the operator pastes back into Claude. State persists in `localStorage` per artifact path. v5.0.2 was operator-led but too thin (line-click + freeform note, no intent categories). v5.0.1 had pointed at `/playground document-critique` (Claude-leads — wrong direction). v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, Handover 8, the supporting `lib/` modules, the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps. v5.0.3's `scripts/annotate.mjs` is one self-contained zero-dependency Node script. **The operator drives every annotation** — Claude never pre-generates suggestions in this flow. See `plugins/voyage/CHANGELOG.md` § v5.0.0 → § v5.0.3.
v4.0.0 (breaking) renamed the plugin from `ultraplan-local` to **Voyage** and all commands from `/ultra*-local` to `/trek*` to remove name collision with Anthropic's `/ultraplan` and `/ultrareview` features. See `plugins/voyage/TRADEMARKS.md` and `plugins/voyage/CHANGELOG.md`.
@ -94,9 +96,9 @@ Six commands, one pipeline with clear division of labor:
- **`/trekreview`** — Close the iteration loop. Independent post-hoc reviewer reads `brief.md` from scratch and evaluates the diff produced by execute. Two parallel reviewers (brief-conformance + code-correctness) plus a Judge Agent (review-coordinator) for dedup and reasonableness filtering. Severity-tagged findings (Critical/High/Medium/Low/Info) with stable 40-char hex IDs feed back into planning via Handover 6 (`/trekplan --brief review.md` → remediation plan with `source_findings:` audit trail).
- **`/trekcontinue`** — Zero-friction multi-session resumption. In a fresh chat, type `/trekcontinue` — reads `.session-state.local.json` (Handover 7), prints a 3-line summary, and immediately begins executing the next session. Any session-end mechanism may write the state file (`/trekexecute` Phase 8/2.55/4 do so automatically; `/trekendsession` helper writes it for informal flows). Forward-compat schema (unknown top-level keys ignored) so future producers can extend additively.
`/trekbrief`, `/trekplan`, and `/trekreview` each finish by rendering their `.md` artifact to a self-contained `.html` next to it (`scripts/render-artifact.mjs` — zero deps, zero network) and printing the `file://` link. To annotate, run the official `/playground` plugin (`document-critique`) on the `.md` and paste its generated prompt back into the conversation.
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written `.md`, printing the `file://<abs path>` link to the resulting self-contained operator-annotation HTML. The operator opens it, clicks any line to add their own note, watches a sidebar of every note (editable, deletable, persisted in browser `localStorage`), clicks "Copy Prompt" to get one structured prompt with every note, pastes back into Claude — Claude revises the `.md` from the notes. The operator drives every annotation.
All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md` (+ `brief.html`), `research/NN-*.md`, `plan.md` (+ `plan.html`), `sessions/`, `progress.json`, `review.md` (+ `review.html`), and `.session-state.local.json` (gitignored). `--project <dir>` works across `/trekresearch`, `/trekplan`, `/trekexecute`, `/trekreview`, and (optionally) `/trekcontinue`.
All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, `review.md`, and `.session-state.local.json` (gitignored). `--project <dir>` works across `/trekresearch`, `/trekplan`, `/trekexecute`, `/trekreview`, and (optionally) `/trekcontinue`.
v3.4.0 (non-breaking) adds the **autonomy chain from brief approval to main-merge** plus parallel-wave hardenings. New `lib/util/autonomy-gate.mjs` state machine (`idle → approved → executing → merge-pending → main-merged`), `lib/review/plan-review-dedup.mjs` for Phase 9 inline dedup, `lib/stats/event-emit.mjs` for autonomy-gate transitions and main-merge gate, and `--gates {open|closed|adaptive}` flag on all four pipeline commands. `commands/trekplan.md` Phase 8 seals Opus-4.7 plan/list-emission schema-drift via `plan-validator --strict`. `commands/trekexecute.md` Phase 2.6 wave-executor adds 11 hardenings for plugin-in-monorepo + gitignored-state topology (GIT_OPTIONAL_LOCKS, --max-turns, --max-budget-usd, scoped --allowedTools, push-before-cleanup ordering). New `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects session-state after compaction. SC7 synthetic determinism floor (Jaccard ≥ 0.833) for plan + review fixtures. Hook baseline regression pins. Architecture decision: Path B (sequential `--no-ff` parallel waves with manifest-driven failure recovery) ships; Path C (cache-first hybrid) deferred to v3.5.0 contingent on cache-telemetry harvest.
@ -120,7 +122,7 @@ Defense-in-depth security: plugin hooks block destructive commands and sensitive
Modes: default, brief-driven, project-scoped, research-enriched, foreground, quick, decompose, export, resume
23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Self-contained HTML artifact rendering · No cloud dependency
23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Operator-driven HTML annotation surface · No cloud dependency
→ [Full documentation](plugins/voyage/README.md) · [Migration guide](plugins/voyage/MIGRATION.md)

View file

@ -1,7 +1,7 @@
{
"name": "voyage",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline; renders produced artifacts to HTML + link, annotate via the /playground plugin.",
"version": "5.0.0",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"version": "5.1.0",
"author": {
"name": "Kjell Tore Guttormsen"
},

View file

@ -4,6 +4,273 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## v5.1.0 — 2026-05-13 — Per-phase effort + model dialog
Additive. No breaking changes. Forward-compat with all v5.0.x briefs.
### Why
The voyage pipeline runs a single profile-tier setting for every task. For
typo fixes and small bugfixes the full `brief → research → plan → execute
→ review` ceremony is over-engineered; for risky migrations the same
profile-tier is too thin. v5.1 hands ceremony-level back to the operator
per phase in the same dialog that produces the brief — without removing
the disciplined defaults that protect high-stakes work. Independent of
v4.1's profile system: composition happens at the command level (brief
signal wins per-phase, profile fills gaps). No `/trekflow`, no helper
module, no per-command effort dictionary — composition is documented
prose in each downstream command.
### Added
- **`/trekbrief` Phase 3.5** — between Phase 3 completeness exit and
Phase 4 draft, 4 tier-coupled `AskUserQuestion` calls commit an effort
level (`low | standard | high`) and an optional `model` (`sonnet |
opus`) per downstream phase (`research`, `plan`, `execute`, `review`).
Tier mapping: `low → {effort: low, model: sonnet}`, `standard →
{effort: standard}` (model omitted; composition falls through to
profile), `high → {effort: high, model: opus}`. Force-stop pattern
(Phase 4f verbatim) records `phase_signals_partial: true` instead.
`--quick` skips Phase 3.5 entirely and auto-writes
`phase_signals_partial: true`.
- **`brief-validator` extension** — 6 new issue codes:
`BRIEF_INVALID_PHASE_SIGNALS`, `BRIEF_INVALID_PHASE_SIGNAL_PHASE`,
`BRIEF_INVALID_EFFORT`, `BRIEF_INVALID_MODEL`,
`BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE`, `BRIEF_V51_MISSING_SIGNALS` +
exported `PHASE_SIGNAL_PHASES` + `EFFORT_LEVELS` constants. The
`BASE_ALLOWED_MODELS` const in `lib/validators/profile-validator.mjs`
was promoted to `export const` so the brief validator can re-use it.
- **HANDOVER-CONTRACTS amendments** — Handover 1 gets 5 inserts:
versioning row → `2.1`, two new schema-table rows (`phase_signals`,
`phase_signals_partial`), v5.1 sequencing-gate validation row,
versioning-paragraph expansion explaining the version-conditional
gate, 6 new failure-mode bullets.
- **Template bump**`templates/trekbrief-template.md` → `brief_version
2.1` with a default `phase_signals:` block (4 phases × `effort:
standard`, model omitted) and a commented `phase_signals_partial:
true` line showing the force-stop alternative.
- **Composition rule (v5.1)** — new `## Composition rule (v5.1)`
sub-section in each of `commands/{trekplan,trekresearch,trekexecute,
trekreview}.md`. Documents `effort_for_phase = brief.phase_signals[
<phase>]?.effort ?? 'standard'` and `model_for_phase =
brief.phase_signals[<phase>]?.model ?? profile.phase_models[<phase>]`.
Per command: `effort == low` activates that command's existing
`--quick`-equivalent code-path (`/trekplan` skips Phase 5 agent swarm,
`/trekresearch` inline research, `/trekreview` correctness-only,
`/trekexecute` `--gates open` + sequential-only).
- **Sequencing-gate surface** in 4 downstream commands — when
`brief-validator.mjs` returns `BRIEF_V51_MISSING_SIGNALS` in `errors`,
halt with a one-line user-readable message pointing back to
`/trekbrief`. Enforcement is validator-only.
- **5 new minimal command test files** under `tests/commands/`
`trekbrief.test.mjs` (3 cases), `trekplan.test.mjs` /
`trekresearch.test.mjs` / `trekreview.test.mjs` (2 cases each),
`trekexecute.test.mjs` (2 cases). Pattern D (read .md, assert prose
patterns). Verifies sequencing-gate surface + low-effort prose.
- **5 new doc-consistency pins** — template `brief_version 2.1` +
`phase_signals:` block, HANDOVER schema rows, voyage CLAUDE.md +
README.md mention `phase_signals`.
- **2 new fixtures**`tests/fixtures/brief-with-phase-signals.md` +
`brief-without-phase-signals.md` (backward-compat).
### Changed
- `brief_version` bumped `2.0 → 2.1`. The bump exists because v2.1
activates the **version-conditional sequencing gate** — the only check
in the brief validator that triggers on `brief_version` rather than
field-presence. The forward-compat policy still applies to the field
itself (unknown frontmatter keys flow through).
### Notes
- Test count grows by ≥ 17 new cases minimum: 6 brief-validator + 11
command-test minimums. Realistic delta is ~25 new cases (Step 6 adds 5
doc-consistency pins on top). Target ≥ 533 pass at Step 10 verify.
- `MIGRATION.md` was deliberately NOT created — v5.1 is an additive
minor (brief_version 2.0 → 2.1, not major). v5.4 may promote
`phase_signals` from optional to required (breaking change → 3.0).
- High-effort behaviors for `/trekplan` / `/trekresearch` /
`/trekreview` are deferred to v5.1.1 per brief Non-Goal ("No complete
per-phase effort dictionary"). v5.1 locks only the low-effort floor.
- `phase_signals_present` stats emission is also deferred to v5.1.1
(opt-in observability per Research 03 Q5).
## v5.0.3 — 2026-05-13 — Annotation UX matches the claude-code-100x reference
**No new breaking changes beyond v5.0.0.** Forks consuming v5.0.2's
annotation HTML keep working — the file path and entry point are
unchanged. The internal localStorage key bumps from `voyage-annotate:` to
`voyage-annotate:v2:` to avoid mixing the v5.0.2 shape (line-click,
freeform notes) with the v5.0.3 shape (intent-tagged annotations).
### Why
v5.0.2 shipped a too-simple annotation surface: click a line, write a
freeform note, save. The operator pointed at the existing
`claude-code-100x/build-site.js` annotation system as the actual
reference — pencil-toggle mode, text-selection capture, three intent
categories (**Fiks** / **Endre** / **Spørsmål**), a popover form at the
cursor, structured markdown export with intent labels. v5.0.3 brings
`scripts/annotate.mjs` up to that pattern.
This reference had been mentioned by the operator early in the
conversation; the iteration through v5.0.0 / v5.0.1 / v5.0.2 reflects me
reading past it and trying alternatives instead of just matching it. The
loss is real and is documented here in plain terms so future maintainers
don't repeat it.
### Changed
- **`scripts/annotate.mjs`** — rewritten to match the
`claude-code-100x/build-site.js` UX:
- **Article rendering** — markdown is rendered to proper HTML elements
(`<h1>`/`<p>`/`<ul>`/`<li>`/`<table>`/`<blockquote>`/`<pre>`), not as
line-numbered raw lines. Document reads as a normal article.
- **Annotatable elements** — every heading, paragraph, list item, table
cell, blockquote, and code block gets a stable `data-anchor-id`.
- **Pencil-toggle button** in the topbar — annotation mode default ON.
Toggle OFF to read normally and follow links.
- **Click any annotatable element** (in mode) → opens a form popover
at the cursor with: section context (auto-detected from nearest
h1/h2), anchored snippet (the exact selected substring via
`window.getSelection()` if any text is highlighted, else the
element's text content up to 200 chars), three intent buttons
(**Fiks** / **Endre** / **Spørsmål**), comment textarea, Cancel +
Save. Save is disabled until an intent is picked.
- **Sidebar panel** — collapsed by default; "Show annotations" button
in the topbar opens it. Annotations grouped by section, sorted by
document order. Each card shows the intent badge (colored by
category), the anchored snippet, the operator comment, and a delete
button. Click a card to scroll the article to that element + flash
highlight.
- **Copy Prompt** — structured markdown:
`### N. [Intent] Section: <section>` + `Quote: «<snippet>»` +
`Comment: <text>`. Copies to clipboard.
- **Clear all** — wipes every annotation for the current artifact
(after confirm).
- **Persistence**`localStorage` key `voyage-annotate:v2:<abs path>`.
Refresh/close/reopen the same HTML keeps every annotation.
- **Toast feedback** for save / copy / clear.
- **`tests/scripts/annotate.test.mjs`** — refreshed for the v5.0.3 shape:
pins the three intent buttons (`data-intent="fiks"` / `"endre"` /
`"spørsmål"`), form popover, selection capture, section auto-detect,
`voyage-annotate:v2:` storage key prefix, `data-anchor-id` coverage,
Copy Prompt + Clear all affordances, and the markdown renderer's
heading / list / table / blockquote / code-fence output. 12 tests
(up from 10), all passing.
### Notes
- The producing commands (`/trekbrief` Step 4g, `/trekplan` Phase 10,
`/trekreview` Phase 8) call `scripts/annotate.mjs` the same way as in
v5.0.2 — no change to their wiring beyond the build-output now being
the v5.0.3 interactive surface.
- `npm test`: 518 tests, 516 pass, 0 fail, 2 skipped (up from 516 — 2
new annotate tests for hostile-content escape + renderMarkdown table/
blockquote coverage).
- Reference: `~/repos/claude-code-100x/claude-code-100x/build-site.js`
lines 14312255 (annotation UI section).
- Version bump 5.0.2 → 5.0.3 in `.claude-plugin/plugin.json`,
`package.json`, `package-lock.json`, plugin `README.md` badge.
## v5.0.2 — 2026-05-13 — Operator-driven annotation HTML (the actual fix)
**No new breaking changes beyond v5.0.0.** Forks that consumed the v5.0.1
`/playground document-critique` invocation from the producing commands'
final report should switch to opening the `.html` that `scripts/annotate.mjs`
now produces directly.
### Why
v5.0.0 added a read-only `scripts/render-artifact.mjs` HTML render that
didn't afford annotation. v5.0.1 deleted that and pointed operators at
`/playground document-critique` instead — but the `document-critique`
template pre-generates **Claude's** suggestions and asks the operator to
approve/reject them. The operator asked for the opposite: a surface where
**they** select content and write **their own** notes, then ship those
notes back to Claude. v5.0.1 still missed the actual ask.
v5.0.2 ships `scripts/annotate.mjs` — a small, focused, zero-dependency
Node script that takes any artifact `.md` and writes a self-contained
HTML next to it. The HTML renders the document with line numbers, lets
the operator click any line to attach their own note, keeps a sidebar of
all notes (editable + deletable, persisted in `localStorage` per artifact
path so refresh doesn't lose work), and exposes a "Copy Prompt" button
that gathers every note into one structured prompt. The operator copies
that prompt and pastes it back into Claude; Claude revises the `.md`
freehand from the notes. **One file → one HTML → click + write notes →
copy prompt → paste back.** No Claude-generated suggestions in the loop.
The operator drives every annotation.
This is the v4.2/v4.3 *concept* (operator-driven annotation) without the
broken v4.2/v4.3 UX, without the 388 KB SPA, without `/trekrevise`,
without anchor parsers + Handover 8 + the JSON batch round-trip. ~430
lines of self-contained `.mjs`. Zero npm deps. Deterministic.
### Added
- **`scripts/annotate.mjs`** — operator-annotation HTML generator. Takes `<artifact.md>`, writes `<artifact>.html` (or `--out <file>`). Self-contained, design-system-aligned (light + dark + print), zero external network, deterministic. CLI: `node scripts/annotate.mjs <artifact.md> [--out <file.html>]`. Also `npm run annotate -- <artifact.md>`.
- **`tests/scripts/annotate.test.mjs`** (10 tests) — self-contained HTML shape, no external `<link>`/`<script src>`, inline script parses, source content + path embedded, HTML escaping in title + body (XSS surface), determinism, default output path, arg parsing, and the operator-driven affordances (Click any line, Your annotations sidebar, Copy Prompt, Clear all, localStorage).
- **`npm run annotate`** convenience script.
### Changed
- **`commands/trekbrief.md` Step 4g, `commands/trekplan.md` Phase 10, `commands/trekreview.md` Phase 8** — each now runs `scripts/annotate.mjs` after the artifact is final and prints the resulting `file://<abs path>` link with explicit "Click any line to add YOUR OWN note" instructions. The v5.0.1 `/playground build a document-critique playground for …` line is removed from all three.
- **`tests/lib/doc-consistency.test.mjs`** — replaced the v5.0.1 `/playground` pins with v5.0.2 pins: `scripts/annotate.mjs` exists; producing commands invoke it; producing commands no longer print the v5.0.1 `/playground document-critique` line; producing commands signal operator-driven annotation in their prose; CHANGELOG has a v5.0.2 entry.
- **Plugin `CLAUDE.md` + `README.md` + root `CLAUDE.md` + root `README.md` + `.claude-plugin/marketplace.json`** — voyage description updated from "v5.0.1 /playground invocation" to "v5.0.2 operator-annotation HTML (`scripts/annotate.mjs`)".
### Notes
- `/playground` is unchanged — the official `claude-plugins-official` `playground` skill is great for the Claude-leads, operator-reacts flow; it just wasn't the right tool for operator-leads, Claude-reacts.
- `npm test`: 516 tests, 514 pass, 0 fail, 2 skipped (up from 503 — 10 new `annotate.test.mjs` tests + 3 net new doc-consistency pins).
- Version bump 5.0.1 → 5.0.2 in `.claude-plugin/plugin.json`, `package.json`, `package-lock.json`, plugin `README.md` badge.
## v5.0.1 — 2026-05-13 — Drop the standalone HTML render; print a literal /playground invocation
**No new breaking changes beyond v5.0.0.** Forks that consumed
`scripts/render-artifact.mjs` directly (or invoked `npm run render`) must
remove that integration. Nothing else moves.
### Why
v5.0.0 had `/trekbrief`, `/trekplan`, and `/trekreview` each finish by
*both* rendering a read-only `{artifact}.html` view (via the new
`scripts/render-artifact.mjs`) *and* printing a vague instruction to "run
the `/playground` plugin (`document-critique` template) on the `.md` and
paste the prompt back". In practice the operator saw two HTMLs in their
project dir, no annotation UI on the rendered `.html`, and had to guess
the right `/playground` invocation. The read-only `.html` added confusion
without affording annotation — it duplicated work the `/playground`
HTML already does (formatted document on the left, annotations on the
right, Copy Prompt button at the bottom).
v5.0.1 deletes the redundant render and makes the printed `/playground`
invocation literal and copy-paste-ready. One paste from the operator
launches the `playground` skill, which loads its `document-critique`
template, reads the `.md`, builds the interactive HTML, opens it. Mark
suggestions, click Copy Prompt, paste back. Done.
### Removed
- **`scripts/render-artifact.mjs`** — the v5.0.0 standalone Markdown→HTML renderer (~280 lines, zero deps). Redundant with `/playground`'s HTML.
- **`tests/scripts/render-artifact.test.mjs`** (and the now-empty `tests/scripts/` dir).
- **`npm run render`** script alias in `package.json`.
- All references to `render-artifact.mjs`, `brief.html`, `plan.html`, `review.html` in `CLAUDE.md` (plugin + root), `README.md` (plugin + root), `.claude-plugin/marketplace.json`, and the three command files' final-output blocks.
### Changed
- **`commands/trekbrief.md` Step 4g (Finalize), `commands/trekplan.md` Phase 10 (Present and refine), `commands/trekreview.md` Phase 9 (Present summary)** — each now ends by printing a single boxed block with the literal text `/playground build a document-critique playground for {abs_path}` and a one-paragraph explanation of the paste-mark-copy-paste loop. The literal string is pinned by `tests/lib/doc-consistency.test.mjs` so it cannot soften back into "run the `/playground` plugin" without a test failure.
- **`tests/lib/doc-consistency.test.mjs`** — replaced the v5.0.0 `render-artifact.mjs exists` + `producing commands reference render-artifact.mjs` pins with v5.0.1 pins: `render-artifact.mjs` *no longer* exists; producing commands include the literal `/playground build a document-critique playground for` invocation; producing commands no longer reference `render-artifact.mjs`; `package.json scripts.render` is gone; CHANGELOG has both v5.0.0 and v5.0.1 entries.
- **Plugin `CLAUDE.md`** — "Render-and-link (v5.0.0)" paragraph rewritten to "Post-command annotation invocation (v5.0.1)" explaining the literal-paste contract; project-directory contract no longer lists `.html` siblings; "State" section's project-root inventory no longer lists `.html` files.
- **Plugin `README.md`** — "Rendered artifacts & annotation (v5.0.0)" section rewritten to "Reviewing and annotating artifacts (v5.0.1)" with a worked example of the printed output and a "What v5.0.1 changed from v5.0.0" sub-note. Top-of-README one-liner + bottom "Known limitations" note updated.
- **Root `CLAUDE.md`** + **root `README.md`** + **`.claude-plugin/marketplace.json`** — voyage description updated to v5.0.1 + the one-paste invocation model.
### Notes
- `/playground` is the `playground` skill from `claude-plugins-official`. It must be installed in the operator's environment for the printed command to work. If it isn't, the same effect is achievable by pasting the `.md` content into Claude with "review this and suggest changes" — manual freehand revision.
- `npm test`: 503 tests, 501 pass, 0 fail, 2 skipped (down from 509 — 8 `render-artifact.test.mjs` tests removed; the doc-consistency pins were updated to v5.0.1 contracts, net +2 tests).
- Version bump 5.0.0 → 5.0.1 in `.claude-plugin/plugin.json`, `package.json`, `package-lock.json`, plugin `README.md` badge.
## v5.0.0 — 2026-05-12 — Remove the bespoke playground; render artifacts to HTML + link
**Breaking.** `/trekrevise` is removed. The `playground/` directory, Handover 8

View file

@ -6,6 +6,8 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
> **v3.0.0 — architect step extracted from this plugin.** The plan command still auto-discovers `architecture/overview.md` if present, so any compatible producer (architect plugin no longer publicly distributed; the architecture/overview.md slot remains available for any compatible producer) plugs into the same slot. See [CHANGELOG.md](CHANGELOG.md) for migration history.
> **Trinity context (2026-05-13, informational).** Voyage is Tier 1 (per-task) of a three-tier architecture in active design under the author's private marketplace: Tier 2 `app-creator` (per-app — "what does the app need, what's the next brief?") produces briefs Voyage consumes; Tier 3 `app-factory` (per-portfolio — "which app needs me now?") aggregates state across multiple app-creator instances. Both are pre-implementation and will ship to Forgejo when ready. **Asymmetry is a hard invariant:** Voyage stays unaware of Tier 2/3. Handover 1 (brief format) is the only integration point — any compatible producer can feed Voyage, app-creator is not privileged. Brief-schema changes are therefore breaking changes for downstream consumers, formalized as a public contract in v5.4.
## Commands
| Command | Description | Model |
@ -16,7 +18,7 @@ Voyage — a contract-driven Claude Code pipeline: brief, research, plan, execut
| `/trekexecute` | Execute — disciplined plan/session-spec executor with failure recovery | opus |
| `/trekreview` | Review — independent post-hoc review of delivered code against the brief. Produces `review.md` with severity-tagged findings (Handover 6) | opus |
| `/trekcontinue` | Continue — resumes the next session of a multi-session voyage project. Reads `.session-state.local.json` (Handover 7) and immediately begins executing | opus |
| `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | sonnet |
| `/trekendsession` | End-session — mark the current session complete and write session-state pointing at the next session. Helper for informal multi-session flows | opus |
### /trekbrief modes
@ -107,26 +109,26 @@ The triage gate is deterministic — path-pattern classifier produces `{file →
| planning-orchestrator | opus | Inline reference documentation for the planning pipeline workflow (brief-driven) |
| research-orchestrator | opus | Inline reference documentation for the research pipeline workflow |
| review-orchestrator | opus | Inline reference documentation for the review pipeline workflow |
| architecture-mapper | sonnet | Codebase structure, tech stack, patterns |
| dependency-tracer | sonnet | Import chains, data flow, side effects |
| task-finder | sonnet | Task-relevant files, functions, reuse candidates |
| risk-assessor | sonnet | Risks, edge cases, failure modes |
| test-strategist | sonnet | Test patterns, coverage gaps, strategy |
| git-historian | sonnet | Recent changes, ownership, hot files |
| research-scout | sonnet | External docs for unfamiliar tech (conditional, planning only) |
| convention-scanner | sonnet | Coding conventions: naming, style, error handling, test patterns |
| brief-reviewer | sonnet | Task brief quality (5 dimensions: completeness, consistency, testability, scope clarity, research plan validity) |
| brief-conformance-reviewer | sonnet | Brief conformance review (SC + Non-Goal traceability) |
| code-correctness-reviewer | sonnet | Code correctness review (7 dimensions) |
| review-coordinator | sonnet | Judge Agent — dedup + reasonableness filter + verdict |
| plan-critic | sonnet | Adversarial plan review (9 dimensions) |
| scope-guardian | sonnet | Scope alignment (creep + gaps) |
| session-decomposer | sonnet | Splits plans into headless sessions with dependency graph |
| docs-researcher | sonnet | Official documentation, RFCs, vendor docs (Tavily, MS Learn) |
| community-researcher | sonnet | Community experience: issues, blogs, discussions |
| security-researcher | sonnet | CVEs, audit history, supply chain risks |
| contrarian-researcher | sonnet | Counter-evidence, overlooked alternatives |
| gemini-bridge | sonnet | Gemini Deep Research second opinion (conditional) |
| architecture-mapper | opus | Codebase structure, tech stack, patterns |
| dependency-tracer | opus | Import chains, data flow, side effects |
| task-finder | opus | Task-relevant files, functions, reuse candidates |
| risk-assessor | opus | Risks, edge cases, failure modes |
| test-strategist | opus | Test patterns, coverage gaps, strategy |
| git-historian | opus | Recent changes, ownership, hot files |
| research-scout | opus | External docs for unfamiliar tech (conditional, planning only) |
| convention-scanner | opus | Coding conventions: naming, style, error handling, test patterns |
| brief-reviewer | opus | Task brief quality (5 dimensions: completeness, consistency, testability, scope clarity, research plan validity) |
| brief-conformance-reviewer | opus | Brief conformance review (SC + Non-Goal traceability) |
| code-correctness-reviewer | opus | Code correctness review (7 dimensions) |
| review-coordinator | opus | Judge Agent — dedup + reasonableness filter + verdict |
| plan-critic | opus | Adversarial plan review (9 dimensions) |
| scope-guardian | opus | Scope alignment (creep + gaps) |
| session-decomposer | opus | Splits plans into headless sessions with dependency graph |
| docs-researcher | opus | Official documentation, RFCs, vendor docs (Tavily, MS Learn) |
| community-researcher | opus | Community experience: issues, blogs, discussions |
| security-researcher | opus | CVEs, audit history, supply chain risks |
| contrarian-researcher | opus | Counter-evidence, overlooked alternatives |
| gemini-bridge | opus | Gemini Deep Research second opinion (conditional) |
## Quality infrastructure (v3.4.0)
@ -187,9 +189,9 @@ Three built-in model profiles plus operator-defined `<custom>.yaml`. Each profil
| Profile | Brief | Research | Plan | Execute | Review | Continue | Use case |
|---------|-------|----------|------|---------|--------|----------|----------|
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | Lowest cost; high-confidence small-scope tasks |
| `balanced` (default) | sonnet | sonnet | opus | sonnet | opus | sonnet | Default — opus where reasoning depth pays off |
| `premium` | opus | sonnet | opus | sonnet | opus | sonnet | Critical-path planning + review when budget allows |
| `economy` | sonnet | sonnet | sonnet | sonnet | sonnet | sonnet | 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 at `lib/profiles/resolver.mjs:145` |
### Lookup order
@ -220,7 +222,9 @@ Local Docker Compose stack: `examples/observability/`. Operator docs: `docs/obse
## Architecture
**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 → Stats. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0).
**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 → Stats. 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`) 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 = `--gates open` + sequential-only). High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
**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 → Stats. With `--project`, writes to `{dir}/research/NN-slug.md`.
@ -232,36 +236,40 @@ Local Docker Compose stack: `examples/observability/`. Operator docs: `docs/obse
**Continue:** `/trekcontinue` reads `{dir}/.session-state.local.json` (Handover 7), validates schema-v1 via `session-state-validator`, narrates a 3-line summary (project / next-session-label / brief-path), and immediately begins executing the next session. Auto-discovers active project state files under `.claude/projects/*/.session-state.local.json` if no explicit `<project-dir>` argument. Operator-invoked only — never auto-loaded via SessionStart. The `/trekendsession` helper is the informal-flow producer: writes the same state file for ad-hoc multi-session handovers that don't run through `/trekexecute`.
**Render-and-link (v5.0.0):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` renders the just-written `.md` artifact to a self-contained `.html` in the same project directory (`scripts/render-artifact.mjs` — zero npm deps, zero external network, design-system-styled, frontmatter folded into a `<details>` block) and prints the `file://` link. To annotate, the operator runs the official `/playground` plugin (`document-critique` template) on the `.md` and pastes the generated prompt back into the conversation; Claude revises the artifact freehand. This replaces the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 (annotation → revision), all removed in v5.0.0 — see [CHANGELOG.md](CHANGELOG.md) § v5.0.0 for why (the bespoke playground duplicated capabilities the official `/playground` plugin already provides).
**Operator-UX guarantee (since v5.0.2):** `/trekbrief`, `/trekplan`, and `/trekreview` MUST always emit (a) a plain `file://<abs path>` URL AND (b) a copy-pasteable `open file://<abs path>` command in the final report block. The file:// URL must use an ABSOLUTE path (not relative or `~/`-prefixed) so terminals with cmd+click support (Ghostty, iTerm2, modern Terminal.app) can resolve it without shell interpretation. This is a non-negotiable operator-UX contract — the doc-consistency test pins both forms in all three commands' final report blocks.
**Operator-annotation HTML (v5.0.3):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` runs `scripts/annotate.mjs` against the just-written `.md` and prints the resulting `file://<abs path>` link. The HTML is self-contained (zero npm deps, zero external network, design-system-styled, light + dark + print) and modelled on `~/repos/claude-code-100x/claude-code-100x/build-site.js` (lines 14312255). The operator opens the file, the document renders as a proper article (headings / paragraphs / lists / tables / code / quotes — every element gets a stable `data-anchor-id`). In annotation mode (default ON, pencil-toggle in topbar), the operator can **select any text or click any element** → a form popover opens at the cursor with: section context auto-detected from nearest h1/h2, the anchored snippet (selection if any, else element text), **three intent buttons (Fiks / Endre / Spørsmål)**, comment textarea, Save/Cancel. The sidebar (Show annotations button) lists every annotation grouped by section with intent badge + snippet + comment + delete; clicking a card scrolls to and flashes the source element. **Copy Prompt** assembles a structured markdown (`### N. [Intent] Section: <…>` + `Quote: «…»` + `Comment: …`) and copies to clipboard. Persistence: `localStorage` keyed on absolute artifact path (`voyage-annotate:v2:<abs path>`). v5.0.0 removed the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8; v5.0.1 pointed at `/playground document-critique` (Claude-leads, wrong direction); v5.0.2 was operator-led but too thin (line-click + freeform note, no intents); v5.0.3 matches the claude-code-100x reference the operator first pointed at, with pencil-toggle / selection capture / intent categories / popover form / structured export. See [CHANGELOG.md](CHANGELOG.md) § v5.0.3.
**Security:** 4-layer defense-in-depth: plugin hooks (pre-bash-executor, pre-write-executor), prompt-level denylist (works in headless sessions), pre-execution plan scan (Phase 2.4), scoped `--allowedTools` replacing `--dangerously-skip-permissions`. Hard Rules 14-16 enforce verify command security, repo-boundary writes, and sensitive path protection.
**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project <dir>` fills in `{dir}/research/`. `/trekplan --project <dir>` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project <dir>` executes and writes `{dir}/progress.json`. `/trekreview --project <dir>` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each render their artifact to `{dir}/{artifact}.html` and print the link (annotate via the `/playground` plugin). All artifacts live in one project directory.
**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project <dir>` fills in `{dir}/research/`. `/trekplan --project <dir>` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project <dir>` executes and writes `{dir}/progress.json`. `/trekreview --project <dir>` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` on the just-written artifact, producing `{dir}/{artifact}.html` — a self-contained operator-annotation surface — and printing the `file://` link. The operator opens it, clicks lines, writes their own notes, copies a structured prompt, pastes back, Claude revises the `.md`. All artifacts live in one project directory.
**Project-directory contract (v3.0.0):** trekplan owns the directory layout below. The `architecture/` subdirectory is opt-in and produced by an opt-in upstream architect plugin (not bundled) — the architect plugin is no longer publicly distributed, but the `architecture/overview.md` slot remains available for any compatible producer.
```
.claude/projects/{YYYY-MM-DD}-{slug}/
brief.md ← trekbrief writes; everyone reads
brief.html ← trekbrief renders (self-contained; for browser viewing / /playground)
brief.html ← trekbrief annotates (operator-annotation HTML, gitignored, re-buildable from brief.md)
research/*.md ← trekresearch writes; plan + architect read
architecture/ ← OPT-IN, owned by an opt-in upstream architect plugin (not bundled)
overview.md
gaps.md
plan.md ← trekplan writes; trekexecute reads
plan.html ← trekplan renders
plan.html ← trekplan annotates
progress.json ← trekexecute writes
review.md ← trekreview writes; trekplan reads (Handover 6)
review.html ← trekreview renders
review.html ← trekreview annotates
```
The `.html` files (`brief.html`, `plan.html`, `review.html`) are produced by `scripts/annotate.mjs` and live alongside their `.md` siblings in the project directory. They are re-buildable from the `.md` source at any time (deterministic, byte-identical output on re-run), so they are conventionally gitignored along with the rest of `.claude/projects/`. Operator annotations live in browser `localStorage` keyed on the absolute artifact path — they survive refresh and browser-close, but are local to the operator's machine.
No code-level dependency between plugins — the contract is filesystem-level only.
## State
All artifacts in one project directory (default):
- Project root: `.claude/projects/{YYYY-MM-DD}-{slug}/`
- `brief.md` + `brief.html` (task brief from `/trekbrief`; `.html` is the self-contained rendered view)
- `brief.md` + `brief.html` (task brief from `/trekbrief`; `.html` is the operator-annotation surface from `scripts/annotate.mjs`)
- `research/{NN}-{slug}.md` (research briefs from `/trekresearch --project`)
- `architecture/overview.md` + `architecture/gaps.md` (opt-in, produced by an opt-in upstream architect plugin, not bundled)
- `plan.md` + `plan.html` (from `/trekplan --project`)

View file

@ -1,6 +1,6 @@
# trekplan — Brief, Research, Plan, Execute, Review, Continue
![Version](https://img.shields.io/badge/version-5.0.0-blue)
![Version](https://img.shields.io/badge/version-5.1.0-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)
@ -10,6 +10,8 @@
A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep implementation planning, multi-source research, autonomous execution, independent post-hoc review, and zero-friction multi-session resumption. Six commands, one pipeline:
> **What's new in v5.1**`/trekbrief` Phase 3.5 commits per-phase `phase_signals` (effort + optional model for `research`/`plan`/`execute`/`review`) to `brief.md` frontmatter. `brief_version: 2.1` activates a validator-side sequencing gate (`BRIEF_V51_MISSING_SIGNALS`) so downstream commands halt with a friendly hint when signals are missing. Composition rule per downstream command: brief signal wins per-phase, profile fills gaps. `effort == low` activates the existing `--quick`-equivalent code-path in each command (`/trekexecute` low-effort = `--gates open` + sequential). Additive — no breaking changes; pre-2.1 briefs still validate.
| Command | What it does |
|---------|-------------|
| **`/trekbrief`** | Brief — interactive interview produces a task brief with explicit research plan |
@ -19,9 +21,9 @@ A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep
| **`/trekreview`** | Review — independent post-hoc review of delivered code against the brief, severity-tagged findings |
| **`/trekcontinue`** | Continue — read `.session-state.local.json` and resume the next session in a multi-session project |
`/trekbrief`, `/trekplan`, and `/trekreview` also render their artifact to a self-contained `.html` next to it and print the `file://` link — annotate via the official `/playground` plugin (`document-critique`) and paste its prompt back.
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written artifact and printing the resulting `file://<abs path>` link. The operator opens the HTML in a browser, clicks any line of the document, writes their own note in the inline textarea, watches a sidebar of all notes (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt that they paste back into Claude — Claude then revises the `.md` from the notes. **The operator drives every annotation.** See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v502).
Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md` (+ `brief.html`), `research/NN-*.md`, `plan.md` (+ `plan.html`), `sessions/`, `progress.json`, and `review.md` (+ `review.html`).
Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, and `review.md`.
### Division of labor
@ -503,37 +505,81 @@ Both arguments are required. No interactive prompt — headless-safe.
---
## Rendered artifacts & annotation (v5.0.0)
## Reviewing and annotating artifacts (v5.0.3)
`/trekbrief`, `/trekplan`, and `/trekreview` each finish by rendering their
just-written `.md` to a self-contained `.html` next to it
(`{project_dir}/brief.html`, `plan.html`, `review.html`) and printing the
`file://` link. The renderer (`scripts/render-artifact.mjs`) is a small,
zero-dependency Node script: it folds frontmatter into a `<details>` block,
puts code fences in styled `<pre>`, renders tables/lists/links, and inlines a
compact design-system-aligned stylesheet. **No external network, no build
step, no telemetry.** Two runs on the same input produce byte-identical HTML.
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running
`scripts/annotate.mjs` against the just-written `.md` and printing the
resulting `file://<abs path>` link. After they finish you see something
like:
To **annotate** an artifact, run the official `/playground` plugin
(`document-critique` template) on the `.md` file and paste the prompt it
generates back into the conversation — Claude then revises the artifact
freehand from your notes. The `/playground` plugin already produces clean,
self-contained single-file HTML for exactly this; voyage no longer ships its
own annotation UI.
```
Brief written: .claude/projects/2026-05-13-foo/brief.md
Annotation HTML: file:///abs/path/.claude/projects/2026-05-13-foo/brief.html
```bash
# Render any artifact manually (the producing commands do this automatically):
node plugins/voyage/scripts/render-artifact.mjs \
.claude/projects/2026-05-09-feature/plan.md
# → writes .claude/projects/2026-05-09-feature/plan.html, prints the path
────────────────────────────────────────────────────────────────────
To review and annotate this brief, open the HTML above in a browser:
open file:///abs/path/.claude/projects/2026-05-13-foo/brief.html
Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises brief.md
from your notes. Annotations persist in your browser if you close
the tab and reopen the same file.
────────────────────────────────────────────────────────────────────
```
> **Removed in v5.0.0.** v4.2/v4.3 shipped a ~388 KB bespoke playground SPA +
> `/trekrevise` + Handover 8 (annotation → revision). A browser walkthrough
> found it borderline unusable, and it duplicated the official `/playground`
> plugin's `document-critique` / `diff-review` templates. All of it — the SPA,
> the command, the supporting `lib/` modules, the anchor parser, the Playwright
> e2e suite — was deleted. See [CHANGELOG.md](CHANGELOG.md) § v5.0.0.
You run `open` (or click the `file://` link in your terminal), the HTML
opens in your default browser. The annotation UX is modelled on
`claude-code-100x/build-site.js`:
- **Topbar:** pencil-toggle button — annotation mode default ON. Click
to turn off (then you read the article normally, follow links, etc.).
A second button opens the sidebar panel.
- **Article body:** the artifact rendered as a proper article — headings,
paragraphs, lists, tables, code blocks, blockquotes. Hover any element
in mode and it highlights. To anchor on a specific phrase, **select
the text first**, then click. Otherwise the whole element becomes the
anchor.
- **Form popover** appears at the cursor with:
- **Section** (auto-detected from the nearest h1/h2 above).
- **Anchored to** — the exact text you selected, or the element's
first ~200 chars if you didn't select.
- **Three intent buttons:** **Fiks** (something is wrong — fix it),
**Endre** (change the wording / content), **Spørsmål** (an open
question — clarify or answer). Colored: red / orange / blue.
- **Comment** textarea (optional but helpful).
- **Cancel** / **Save**. Save stays disabled until you pick an intent.
Shortcut: `⌘Enter` to save, `Esc` to cancel.
- **Annotated elements** get an amber highlight + a number badge in the
margin showing how many annotations target that element.
- **Sidebar panel** (Show annotations) — every annotation grouped by
section, in document order. Each card shows the intent badge
(colored), the anchored snippet (mono-quote), the comment text, and a
delete button. Click a card to scroll the article to that element and
flash it.
- **Copy Prompt** at the foot of the panel — assembles every annotation
into one structured markdown prompt and copies it to your clipboard.
- **Clear all** wipes every annotation (after confirm).
- **Persistence:** every annotation is saved to browser `localStorage`
keyed on the artifact's absolute path (`voyage-annotate:v2:<abs path>`).
Refresh the tab or close the browser and re-open — your work is there.
You select / click, pick intent, write comment, repeat. When you're
done, Copy Prompt, paste back into this chat. Claude revises the `.md`
freehand from your notes. **The operator drives every annotation.**
Claude never pre-generates suggestions in this flow.
> **What v5.0.3 changed from v5.0.2.** v5.0.2 was operator-led but the UX
> was too thin — click a line, type a freeform note, save. The reference
> the operator pointed at (`~/repos/claude-code-100x/claude-code-100x/build-site.js`)
> already had the right pattern: pencil-toggle, selection capture, three
> intent categories, popover form, structured markdown export. v5.0.3
> rebuilds `scripts/annotate.mjs` against that reference. v5.0.0 / v5.0.1
> / v5.0.2 are all superseded; only the v5.0.0 removals (bespoke
> playground SPA, `/trekrevise`, Handover 8, supporting `lib/` modules,
> Playwright e2e + devDeps) stay. See [CHANGELOG.md](CHANGELOG.md)
> § v5.0.0 → § v5.0.3.
---
@ -667,7 +713,7 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
**Infrastructure-as-code (IaC) gets reduced value.** The exploration agents are designed for application code. Terraform, Helm, Pulumi, CDK projects will get a plan, but agents like `architecture-mapper` and `test-strategist` produce less useful output for IaC. Use trekplan for the structural plan, then supplement IaC-specific steps manually.
**Rendered HTML is read-only.** `scripts/render-artifact.mjs` produces a static, self-contained view for browsing — it is not an editor. To revise an artifact from operator feedback, run the `/playground` plugin (`document-critique`) on the `.md` and paste its prompt back. The markdown subset the renderer supports covers what the artifact templates emit (headings, lists, code fences, tables, links, blockquotes, bold/italic, inline code); exotic markdown extensions are not rendered.
**Annotation HTML requires a desktop browser.** `scripts/annotate.mjs` produces a single self-contained `.html` file you open with `file://` in any modern browser (Chrome / Safari / Firefox / Edge — last two versions). No CDN, no server, no npm runtime deps. State persists in `localStorage` so closing and re-opening the tab keeps your work, but it's local to one browser on one machine — not synced anywhere. If you want to annotate without a browser, paste the `.md` into Claude with "comments inline below" and write notes in chat — same end result, just without the visual surface.
## Installation

View file

@ -21,7 +21,7 @@ description: |
Direct architecture analysis request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: cyan
tools: ["Read", "Glob", "Grep", "Bash"]
---

View file

@ -5,7 +5,7 @@ description: |
against the task brief — every Success Criterion must trace to delivered
code, every Non-Goal must remain unbuilt. Emits findings with rule_keys
from the canonical RULE_CATALOGUE. Never praises.
model: sonnet
model: opus
color: magenta
tools: ["Read", "Glob", "Grep"]
---

View file

@ -23,7 +23,7 @@ description: |
Brief review request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: magenta
tools: ["Read", "Glob", "Grep"]
---

View file

@ -6,7 +6,7 @@ description: |
cross-file regressions, test coverage gaps, placeholder code, security
surface, hidden dependencies. Cites file:line for every finding. Never
praises.
model: sonnet
model: opus
color: red
tools: ["Read", "Glob", "Grep"]
---

View file

@ -24,7 +24,7 @@ description: |
finds the practical signal that helps teams make adoption decisions.
</commentary>
</example>
model: sonnet
model: opus
color: green
tools: ["WebSearch", "WebFetch", "mcp__tavily__tavily_search", "mcp__tavily__tavily_research"]
---

View file

@ -24,7 +24,7 @@ description: |
but to ensure the final recommendation is genuinely considered.
</commentary>
</example>
model: sonnet
model: opus
color: red
tools: ["WebSearch", "WebFetch", "mcp__tavily__tavily_search", "mcp__tavily__tavily_research"]
---

View file

@ -23,7 +23,7 @@ description: |
Direct convention discovery request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: yellow
tools: ["Read", "Glob", "Grep", "Bash"]
---

View file

@ -21,7 +21,7 @@ description: |
Impact analysis request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: blue
tools: ["Read", "Glob", "Grep", "Bash"]
---

View file

@ -23,7 +23,7 @@ description: |
microsoft_docs_fetch) that docs-researcher uses for higher-quality results.
</commentary>
</example>
model: sonnet
model: opus
color: blue
tools: ["WebSearch", "WebFetch", "Read", "mcp__tavily__tavily_search", "mcp__tavily__tavily_research", "mcp__microsoft-learn__microsoft_docs_search", "mcp__microsoft-learn__microsoft_docs_fetch"]
---

View file

@ -24,7 +24,7 @@ description: |
Direct request for Gemini research on a complex architectural question triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: magenta
tools: ["mcp__gemini-mcp__gemini_deep_research", "mcp__gemini-mcp__gemini_get_research_status", "mcp__gemini-mcp__gemini_get_research_result", "mcp__gemini-mcp__gemini_research_followup"]
---

View file

@ -21,7 +21,7 @@ description: |
Git history analysis request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: yellow
tools: ["Bash", "Read", "Glob", "Grep"]
---

View file

@ -21,7 +21,7 @@ description: |
Plan review request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: red
tools: ["Read", "Glob", "Grep"]
---

View file

@ -137,7 +137,7 @@ medium: default, large: default) rather than dropping agents.
| `research-scout` | Conditional | Conditional | Conditional | External docs (only when unfamiliar tech detected AND not covered by briefs) |
| `convention-scanner` | No | Yes | Yes | Coding conventions, naming, style, test patterns |
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "sonnet")
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "opus")
for medium+ codebases only. Pass the task description as context.
**research-scout** — launch conditionally if the task involves technologies, APIs,

View file

@ -21,7 +21,7 @@ description: |
Research request for external technology triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: blue
tools: ["WebSearch", "WebFetch", "Read"]
---

View file

@ -6,7 +6,7 @@ description: |
applies BOUNDED operations: deduplication, severity ranking, HubSpot
Judge filters, Cloudflare reasonableness filter, verdict computation.
Synthesis-level inference across files is forbidden in v1.0.
model: sonnet
model: opus
color: yellow
tools: ["Read", "Glob", "Grep"]
---

View file

@ -21,7 +21,7 @@ description: |
Risk analysis request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: yellow
tools: ["Read", "Glob", "Grep", "Bash"]
---

View file

@ -21,7 +21,7 @@ description: |
Scope verification request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: magenta
tools: ["Read", "Glob", "Grep"]
---

View file

@ -23,7 +23,7 @@ description: |
using CVE databases, OWASP categories, and verified audit reports.
</commentary>
</example>
model: sonnet
model: opus
color: red
tools: ["WebSearch", "WebFetch", "mcp__tavily__tavily_search", "mcp__tavily__tavily_research"]
---

View file

@ -22,7 +22,7 @@ description: |
Plan decomposition request for parallel headless execution.
</commentary>
</example>
model: sonnet
model: opus
color: green
tools: ["Read", "Glob", "Grep", "Write"]
---

View file

@ -22,7 +22,7 @@ description: |
Direct code discovery request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: green
tools: ["Read", "Glob", "Grep", "Bash"]
---

View file

@ -21,7 +21,7 @@ description: |
Test planning request triggers the agent.
</commentary>
</example>
model: sonnet
model: opus
color: green
tools: ["Read", "Glob", "Grep", "Bash"]
---

View file

@ -288,6 +288,118 @@ Phase 3 complete: {N} questions asked across {M} sections.
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.
### --quick mode
If the operator launched with `--quick`: skip Phase 3.5 entirely and
auto-write `phase_signals_partial: true` to draft frontmatter. The brief
will satisfy the v5.1 sequencing gate without going through the dialog.
### 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 loop — 4 tier-coupled AskUserQuestion calls
Loop over `[research, plan, execute, review]` in order. For each phase,
issue one `AskUserQuestion` with 3 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}` |
The proposed tier per phase (from the default-derivation heuristic) MUST be
labelled `(default)` in the option list so the operator can one-click
accept. Commit the chosen tier immediately to an in-memory `effort_state`
dict — no bulk summary-before-commit. The loop is interruptible.
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)
### Force-stop handling
If during any of the four `AskUserQuestion` calls 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
@ -483,30 +595,47 @@ If the validator returns errors, report them to the user and offer to
re-enter Phase 4 with the validator's hints in scope. If only warnings,
note them in the final report.
**Render to HTML + link (annotation via /playground):** after `brief.md`
is final, render it to a self-contained HTML view in the same directory:
**Build the operator-annotation HTML, then print the report.** After the
brief is validated, run `scripts/annotate.mjs` to produce a self-contained
HTML file the operator opens in their browser. The HTML renders the brief
with line numbers, lets the operator click any line to attach their own
note (not Claude-generated suggestions — the operator drives every
annotation), keeps a sidebar of all notes, persists state in localStorage,
and exposes a "Copy Prompt" button that generates a single structured
prompt with every note. The operator copies that prompt and pastes it
back into Claude; Claude revises `brief.md` freehand from the notes.
```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/render-artifact.mjs "{PROJECT_DIR}/brief.md"
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{PROJECT_DIR}/brief.md" 2>&1)
# stdout is the absolute path to the .html on success.
```
This writes `{PROJECT_DIR}/brief.html` — zero-network, design-system-styled
(frontmatter folded into a `<details>` block). If it exits non-zero, surface
a one-line warning and continue — the rendered view is a convenience, not a
gate.
If `annotate.mjs` exits non-zero, surface a one-line warning and continue
— the annotation HTML is a convenience, not a gate. The report below
still mentions the (failed) path so the operator can debug.
Then print this block **verbatim** (substitute `{PROJECT_DIR}` and
`$ANNOT_HTML`):
Report:
```
Brief written: {PROJECT_DIR}/brief.md
Brief rendered: file://{abs path to brief.html}
Brief written: {PROJECT_DIR}/brief.md
Annotation HTML: file://{$ANNOT_HTML}
Review iterations: {1..3}
Final quality: {complete | partial}
Validator: {PASS | warnings(N)}
Final quality: {complete | partial}
Validator: {PASS | warnings(N)}
Research topics identified: {N}
To annotate: open brief.html, then run the `/playground` plugin
(document-critique template) on brief.md and paste the generated
prompt back here. Claude revises brief.md freehand from your notes.
────────────────────────────────────────────────────────────────────
To review and annotate this brief, open the HTML above in a browser:
open file://{$ANNOT_HTML}
Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises brief.md
from your notes. Annotations persist in your browser if you close
the tab and reopen the same file.
────────────────────────────────────────────────────────────────────
```
## Phase 5 — Auto-orchestration opt-in (if research_topics > 0)

View file

@ -2,7 +2,7 @@
name: trekendsession
description: Mark the current session as complete and write session-state pointing at the next session. Helper for informal multi-session flows.
argument-hint: "<next-brief-path> <next-label> | --help"
model: sonnet
model: opus
---
# Voyage End-Session Local v1.0

View file

@ -1519,6 +1519,37 @@ VOYAGE_PROFILE=balanced /trekexecute --project ...
Stats records emit `profile`, `phase_models`, and `profile_source` per
Phase 9 record.
## 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. There is no helper module — composition is documented prose in
each downstream command.
For `/trekexecute` specifically: `effort == 'low'` activates `--gates open`
+ sequential-only execution (no worktree-isolated parallel waves — runs
all sessions in a single foreground loop). `effort == 'standard'` (or
absent) → no change (default execution strategy applies). High-effort
behavior is deferred to v5.1.1 per brief Non-Goal.
### Sequencing gate surface
When `/trekexecute --project <dir>` is invoked, optionally 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.
## Hard rules
1. **No AskUserQuestion for execution decisions.** All execution decisions come

View file

@ -501,7 +501,7 @@ ownership, hot files, and active branches that may affect planning."
### Launch for medium+ codebases (50+ files):
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "sonnet")
**Convention Scanner** — use the `convention-scanner` plugin agent (model: "opus")
for medium+ codebases only.
Provide concrete examples from the codebase, not generic advice."
@ -538,7 +538,7 @@ 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: "sonnet",
For each significant gap, spawn a targeted deep-dive agent (model: "opus",
subagent_type: "Explore") with a narrow, specific brief.
Launch up to 3 deep-dive agents in parallel. If no gaps exist, skip this phase
@ -769,28 +769,45 @@ If the user asks questions or requests changes:
- Show what changed
- Re-present the summary
### Render to HTML + link (annotation via /playground)
### Build the operator-annotation HTML and print the link
After `plan.md` is final, render it to a self-contained HTML view in the
same project directory and print the `file://` link:
After the plan summary, run `scripts/annotate.mjs` to produce a
self-contained HTML the operator opens in their browser. The HTML renders
`plan.md` with line numbers, lets the operator click any line to attach
their own note (not Claude-generated suggestions — the operator drives
every annotation), keeps a sidebar of all notes, persists state in
localStorage, and exposes a "Copy Prompt" button that generates a single
structured prompt with every note. The operator copies that prompt and
pastes it back into Claude; Claude revises `plan.md` freehand from the
notes.
```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/render-artifact.mjs "{plan_path}"
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{plan_path}" 2>&1)
# stdout is the absolute path to the .html on success.
```
This writes `{plan_dir}/plan.html` — a zero-network, design-system-styled
page (frontmatter folded into a `<details>` block, code fences in styled
`<pre>`). Print:
If `annotate.mjs` exits non-zero, surface a one-line warning and continue
— the annotation HTML is a convenience, not a gate.
Then print this block **verbatim** (substituting `{plan_path}` and
`$ANNOT_HTML`):
```
Plan rendered: file://{abs path to plan.html}
To annotate: open it, then run the `/playground` plugin
(document-critique template) on plan.md and paste the generated
prompt back here. Claude revises plan.md freehand from your notes.
```
────────────────────────────────────────────────────────────────────
Plan written: {plan_path}
Annotation HTML: file://{$ANNOT_HTML}
If `render-artifact.mjs` exits non-zero, surface a one-line warning and
continue — the rendered view is a convenience, not a gate.
To review and annotate the plan, open it in a browser:
open file://{$ANNOT_HTML}
Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises plan.md
from your notes. Annotations persist in your browser if you close
the tab and reopen the same file.
────────────────────────────────────────────────────────────────────
```
## Phase 11 — Handoff
@ -877,6 +894,36 @@ VOYAGE_PROFILE=balanced /trekplan --project ...
Stats records emit `profile`, `phase_models`, `parallel_agents`, and
`profile_source` so operators can audit which profile drove which session.
## 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. There is no helper module — composition is documented prose in
each downstream command.
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. High-effort behavior is deferred to v5.1.1 per brief Non-Goal
("No complete per-phase effort dictionary").
### 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.
## Hard rules
- **Brief-driven**: Every plan decision must trace back to a section of the

View file

@ -291,7 +291,7 @@ other agents — the value of Gemini is independence.
### Launch rules
- Launch ALL selected agents **in parallel** in a single message
- Use model: "sonnet" for all sub-agents (the orchestrator runs on Opus)
- Use model: "opus" for all sub-agents (the orchestrator runs on Opus)
- Scale maxTurns by codebase size for local agents (same as trekplan):
small = halved, medium/large = default
- convention-scanner: medium+ codebases only (50+ files)
@ -301,7 +301,7 @@ other agents — the value of Gemini is independence.
Review all agent results. Identify knowledge gaps — areas where findings are
thin, contradictory, or missing.
For each significant gap, launch a targeted follow-up agent (model: "sonnet")
For each significant gap, launch a targeted follow-up agent (model: "opus")
with a narrow, specific brief. Maximum 2 follow-ups.
If no gaps exist, skip: "Initial research sufficient — no follow-ups needed."
@ -435,6 +435,36 @@ Stats records emit `profile`, `phase_models`, `parallel_agents`,
`external_research_enabled`, and `profile_source` so operators can audit
which profile drove which session.
## 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. There is no helper module — composition is documented prose in
each downstream command.
For `/trekresearch` specifically: `effort == 'low'` activates the
existing `--quick`-equivalent code-path (inline research, no agent swarm).
`effort == 'standard'` (or absent) → no change. High-effort behavior is
deferred to v5.1.1 per brief Non-Goal.
### Sequencing gate surface
When `/trekresearch --project <dir>` is invoked and `{dir}/brief.md`
exists, optionally 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.
## Hard rules
- **No planning:** This command produces research briefs, not implementation plans.

View file

@ -262,16 +262,19 @@ Append a stats line to `${CLAUDE_PLUGIN_DATA}/trekreview-stats.jsonl`
If `${CLAUDE_PLUGIN_DATA}` is unset or not writable, skip stats silently.
Never let stats failures block the main workflow.
**Render to HTML + link (annotation via /playground):** after `review.md`
is final, render it to a self-contained HTML view in the same directory:
**Build the operator-annotation HTML.** After stats land, run:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/render-artifact.mjs "{review_path}"
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1)
```
This writes `{project_dir}/review.html` — zero-network, design-system-styled.
If it exits non-zero, surface a one-line warning and continue — the rendered
view is a convenience, not a gate.
`stdout` is the absolute path to the `.html` on success. The HTML renders
`review.md` with line numbers, lets the operator click any line to attach
their own note (not Claude-generated suggestions — the operator drives
every annotation), keeps a sidebar of all notes, persists state in
localStorage, and exposes a "Copy Prompt" button. If `annotate.mjs`
exits non-zero, surface a one-line warning and continue — the annotation
HTML is a convenience, not a gate.
## Phase 8.5 — Validate-only mode (`--validate`)
@ -293,7 +296,7 @@ After the write succeeds, print:
**Brief:** {brief_path}
**Project:** {project_dir}
**Review:** {review_path}
**Rendered:** file://{abs path to review.html}
**Annotation HTML:** file://{$ANNOT_HTML}
**Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files)
**Verdict:** {BLOCK | WARN | ALLOW}
@ -308,12 +311,21 @@ After the write succeeds, print:
...
{up to 5 highest-severity findings}
You can:
- Read the full review at {review_path} (or open review.html in a browser)
────────────────────────────────────────────────────────────────────
To review and annotate the review, open it in a browser:
open file://{$ANNOT_HTML}
Click any line to add YOUR OWN note. The sidebar collects every note,
the "Copy Prompt" button gathers them into one structured prompt.
Paste that prompt back into this chat and Claude revises review.md
from your notes. Annotations persist in your browser if you close
the tab and reopen the same file.
────────────────────────────────────────────────────────────────────
You can also:
- Feed BLOCKER + MAJOR findings into a follow-up plan:
/trekplan --brief {review_path}
- Annotate: run the `/playground` plugin (document-critique template) on
review.md and paste the generated prompt back here
- Re-run with `--quick` for a faster correctness-only pass
- Re-run with `--since <ref>` to narrow scope
```
@ -345,6 +357,36 @@ VOYAGE_PROFILE=premium /trekreview --project ...
Stats records emit `profile` and `profile_source`.
## 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. There is no helper module — composition is documented prose in
each downstream command.
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.
High-effort behavior is deferred to v5.1.1 per brief Non-Goal.
### 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.
## Hard rules
- **Brief is the contract.** Every finding in the review traces to a

View file

@ -10,7 +10,7 @@ Each artifact carries an explicit version field. Schema bumps are coordinated:
| Artifact | Field | Current |
|---|---|---|
| `brief.md` | `brief_version` (frontmatter) | `2.0` |
| `brief.md` | `brief_version` (frontmatter) | `2.1` |
| `research/*.md` | (implicit; tracked via `type: trekresearch-brief`) | unversioned |
| `plan.md` | `plan_version` (frontmatter) | `1.7` |
| `progress.json` | `schema_version` (top-level) | `"1"` |
@ -67,6 +67,8 @@ 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`. |
**Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time):
- `## Intent`
@ -84,11 +86,12 @@ 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. |
| Body sections | strict only | All `BRIEF_BODY_SECTIONS` present |
**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:** current is `2.0`. There are no live `1.x` briefs; remove legacy paths in next major.
**Versioning:** current is `2.1` (v5.1 — adds optional `phase_signals` + `phase_signals_partial`). 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. v5.4 may promote `phase_signals` from optional to required (breaking change → `3.0`).
**Failure modes:**
- `BRIEF_NOT_FOUND` → consumer halts with a usage message
@ -97,6 +100,12 @@ 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]`).
- `BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE` → strict halt; cannot set both `phase_signals` and `phase_signals_partial: true`.
---

View file

@ -9,12 +9,15 @@
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';
export const BRIEF_REQUIRED_FRONTMATTER = ['type', 'brief_version', 'task', 'slug', 'research_topics', 'research_status'];
export const REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER = ['type', 'task', 'slug', 'project_dir', 'findings'];
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']);
function getRequiredFields(type) {
return type === 'trekreview' ? REVIEW_AS_BRIEF_REQUIRED_FRONTMATTER : BRIEF_REQUIRED_FRONTMATTER;
@ -36,6 +39,67 @@ 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.
if (typeof fm.brief_version === 'string') {
const vm = fm.brief_version.match(/^(\d+)\.(\d+)$/);
if (vm) {
const major = Number(vm[1]);
const minor = Number(vm[2]);
const atLeast21 = major > 2 || (major === 2 && minor >= 1);
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.',
));
}
}
}
if (fm.type !== undefined && !BRIEF_TYPE_VALUES.includes(fm.type)) {
errors.push(issue(
'BRIEF_WRONG_TYPE',

View file

@ -38,7 +38,7 @@ export const PROFILE_REQUIRED_PHASES = Object.freeze([
'brief', 'research', 'plan', 'execute', 'review', 'continue',
]);
const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus']);
export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus']);
function getAllowedModels(env = process.env) {
if (env.VOYAGE_ALLOW_HAIKU === '1') {

View file

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

View file

@ -1,14 +1,14 @@
{
"name": "voyage",
"version": "5.0.0",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline; renders produced artifacts to HTML + link, annotate via the /playground plugin.",
"version": "5.1.0",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any heading/paragraph/list-item, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"test": "node --test 'tests/**/*.test.mjs'",
"render": "node scripts/render-artifact.mjs",
"annotate": "node scripts/annotate.mjs",
"verify": "bash verify.sh"
},
"keywords": [

View file

@ -0,0 +1,921 @@
#!/usr/bin/env node
// scripts/annotate.mjs
//
// Operator-annotation HTML for a voyage artifact (brief.md / plan.md /
// review.md). The producing commands run this on their last step and
// print the file:// link. The operator opens the HTML in their browser,
// the page renders the artifact as a proper article (headings, lists,
// paragraphs, code blocks — not raw lines), and the operator drives every
// annotation themselves: select text or click any element, choose intent
// (Fiks / Endre / Spørsmål), write a comment, save. The sidebar shows
// every annotation grouped by section; Copy Prompt assembles them into
// one structured markdown the operator pastes back into Claude.
//
// UX modelled on the claude-code-100x annotation surface
// (build-site.js, 2026 — same pencil-toggle, intent buttons, form popover,
// localStorage persistence, structured markdown export).
//
// • Operator drives every annotation. No Claude-generated suggestions.
// • Three intent categories: Fiks (fix) / Endre (change) / Spørsmål (question).
// • Element + selection anchoring — clicking an element captures it whole;
// selecting text inside an element captures the exact substring.
// • Section context auto-detected (nearest h1/h2 above).
// • Annotations persist in localStorage keyed on the absolute artifact path.
// • Zero npm deps, zero external network, deterministic output.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function deriveTitle(mdText, fallbackName) {
const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText);
if (hasFrontmatter) {
const m = frontmatter.match(/^task:\s*(.+)$/m) || frontmatter.match(/^slug:\s*(.+)$/m);
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
}
const h1 = mdText.match(/^#\s+(.+)$/m);
if (h1) return h1[1].trim();
return fallbackName;
}
// ---------------------------------------------------------------------------
// Markdown → HTML with data-anchor-id on every annotatable element.
// Hand-rolled subset matching what artifact templates emit.
// ---------------------------------------------------------------------------
function renderInline(escaped) {
let s = escaped.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, h) => {
const safe = /^(https?:|mailto:|#|\.|\/)/i.test(h) ? h : '#';
return `<a href="${safe}" target="_blank" rel="noopener">${t}</a>`;
});
s = s.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}<em>${c}</em>`);
return s;
}
function renderMarkdown(md) {
const lines = md.replace(/\r\n/g, '\n').split('\n');
let html = '';
let anchorId = 0;
const anchor = () => `anch-${anchorId++}`;
let i = 0;
let paraBuf = [];
const flushPara = () => {
if (paraBuf.length) {
const text = paraBuf.join(' ');
html += `<p data-anchor-id="${anchor()}">${renderInline(escapeHtml(text))}</p>\n`;
paraBuf = [];
}
};
while (i < lines.length) {
const line = lines[i];
// Fenced code block — NOT annotatable as a whole; we keep it readable
// but skip the data-anchor-id so the operator clicks around it.
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fence) {
flushPara();
const marker = fence[2][0];
const lang = (fence[3] || '').trim().split(/\s+/)[0];
const buf = [];
i++;
while (i < lines.length && !new RegExp('^\\s*' + marker + '{3,}\\s*$').test(lines[i])) {
buf.push(lines[i]);
i++;
}
i++; // closing fence
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
html += `<pre data-anchor-id="${anchor()}"><code${cls}>${escapeHtml(buf.join('\n'))}\n</code></pre>\n`;
continue;
}
// ATX heading
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
if (h) {
flushPara();
const lvl = h[1].length;
html += `<h${lvl} data-anchor-id="${anchor()}">${renderInline(escapeHtml(h[2]))}</h${lvl}>\n`;
i++;
continue;
}
// Horizontal rule
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
flushPara();
html += '<hr>\n';
i++;
continue;
}
// Table
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length &&
/^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
flushPara();
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i]); i++; }
const cells = (l) => l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
const header = cells(rows[0]);
const body = rows.slice(2).map(cells);
html += '<table>\n<thead><tr>';
for (const c of header) html += `<th data-anchor-id="${anchor()}">${renderInline(escapeHtml(c))}</th>`;
html += '</tr></thead>\n<tbody>\n';
for (const r of body) {
html += '<tr>';
for (let k = 0; k < header.length; k++) html += `<td data-anchor-id="${anchor()}">${renderInline(escapeHtml(r[k] || ''))}</td>`;
html += '</tr>\n';
}
html += '</tbody>\n</table>\n';
continue;
}
// Blockquote
if (/^\s*>\s?/.test(line)) {
flushPara();
const buf = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
buf.push(lines[i].replace(/^\s*>\s?/, ''));
i++;
}
html += `<blockquote data-anchor-id="${anchor()}">${renderInline(escapeHtml(buf.join(' ')))}</blockquote>\n`;
continue;
}
// Lists — one block, allow blank lines between items
const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (listMatch) {
flushPara();
const items = [];
while (i < lines.length) {
const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (m) {
items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] });
i++;
} else if (lines[i].trim() === '' && i + 1 < lines.length &&
lines[i + 1].match(/^(\s*)([-*+]|\d+[.)])\s+/)) {
i++;
} else {
break;
}
}
html += renderList(items, anchor);
continue;
}
// Blank
if (line.trim() === '') {
flushPara();
i++;
continue;
}
// Default: paragraph accumulation
paraBuf.push(line.trim());
i++;
}
flushPara();
return html;
}
function renderList(items, anchor) {
let html = '';
const stack = [];
for (const { indent, ordered, text } of items) {
while (stack.length && (indent < stack[stack.length - 1].indent ||
(indent === stack[stack.length - 1].indent && ordered !== stack[stack.length - 1].ordered))) {
const top = stack.pop();
html += top.ordered ? '</li></ol>' : '</li></ul>';
}
if (!stack.length || indent > stack[stack.length - 1].indent) {
html += ordered ? '<ol>' : '<ul>';
stack.push({ indent, ordered });
} else {
html += '</li>';
}
html += `<li data-anchor-id="${anchor()}">${renderInline(escapeHtml(text))}`;
}
while (stack.length) {
const top = stack.pop();
html += top.ordered ? '</li></ol>' : '</li></ul>';
}
return html + '\n';
}
// ---------------------------------------------------------------------------
// Build full HTML document
// ---------------------------------------------------------------------------
function buildHtml(artifactPath, mdText) {
const fileName = basename(artifactPath);
const title = deriveTitle(mdText, fileName);
const { body } = splitFrontmatter(mdText);
const articleHtml = renderMarkdown(body);
return '<!DOCTYPE html>\n'
+ '<html lang="en">\n'
+ '<head>\n'
+ '<meta charset="utf-8">\n'
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
+ '<title>' + escapeHtml(title) + ' — annotate</title>\n'
+ '<style>\n' + STYLE + '\n</style>\n'
+ '</head>\n'
+ '<body class="ann-mode">\n'
+ '<header class="topbar">\n'
+ ' <div class="hdr-meta">\n'
+ ' <h1>' + escapeHtml(title) + '</h1>\n'
+ ' <p class="path" title="' + escapeHtml(artifactPath) + '">' + escapeHtml(fileName) + '</p>\n'
+ ' </div>\n'
+ ' <div class="hdr-actions">\n'
+ ' <button class="ann-toggle" id="ann-toggle" title="Toggle annotation mode (pencil)">✎ <span id="ann-toggle-label">Annotation mode: ON</span> <span class="ann-badge" id="ann-badge">0</span></button>\n'
+ ' <button class="ghost-btn" id="open-panel">Show annotations</button>\n'
+ ' </div>\n'
+ '</header>\n'
+ '<main class="article-wrap">\n'
+ ' <div class="article-help" id="article-help">Click any heading, paragraph, list item, table cell, or quote to add an annotation. To anchor on a specific phrase, <strong>select the text first</strong>, then click. Toggle annotation mode off (pencil button) to read normally / follow links.</div>\n'
+ ' <article class="article" id="article">\n'
+ articleHtml
+ '\n </article>\n'
+ '</main>\n'
+ '<div class="ann-form" id="ann-form" role="dialog" aria-label="New annotation">\n'
+ ' <div class="ann-form-section">\n'
+ ' <div class="ann-form-section-label">Section</div>\n'
+ ' <div class="ann-form-section-value" id="ann-form-section">—</div>\n'
+ ' </div>\n'
+ ' <div class="ann-form-snippet">\n'
+ ' <div class="ann-form-section-label">Anchored to</div>\n'
+ ' <blockquote class="ann-form-snippet-text" id="ann-form-snippet">…</blockquote>\n'
+ ' </div>\n'
+ ' <div class="ann-form-intents">\n'
+ ' <button class="ann-intent" data-intent="fiks" title="Something is wrong or broken — needs to be fixed">Fiks</button>\n'
+ ' <button class="ann-intent" data-intent="endre" title="Change the wording or content">Endre</button>\n'
+ ' <button class="ann-intent" data-intent="spørsmål" title="An open question or clarification request">Spørsmål</button>\n'
+ ' </div>\n'
+ ' <textarea class="ann-form-comment" id="ann-form-comment" placeholder="Your comment (optional but helpful)…"></textarea>\n'
+ ' <div class="ann-form-actions">\n'
+ ' <button class="btn" id="ann-form-cancel">Cancel (Esc)</button>\n'
+ ' <button class="btn primary" id="ann-form-save" disabled>Save (⌘Enter)</button>\n'
+ ' </div>\n'
+ '</div>\n'
+ '<aside class="ann-panel" id="ann-panel" aria-label="Annotations panel">\n'
+ ' <div class="ann-panel-head">\n'
+ ' <h2>Your annotations</h2>\n'
+ ' <button class="icon-btn" id="ann-panel-close" title="Close">✕</button>\n'
+ ' </div>\n'
+ ' <div class="ann-panel-body" id="ann-panel-body"></div>\n'
+ ' <div class="ann-panel-foot">\n'
+ ' <button class="ghost-btn" id="ann-clear-all">Clear all</button>\n'
+ ' <button class="btn primary" id="ann-copy" disabled>Copy Prompt</button>\n'
+ ' </div>\n'
+ '</aside>\n'
+ '<div class="ann-toast" id="ann-toast" role="status" aria-live="polite"></div>\n'
+ '<div class="ann-overlay" id="ann-overlay"></div>\n'
+ '<script>\n'
+ 'const ARTIFACT_PATH = ' + JSON.stringify(resolve(artifactPath)) + ';\n'
+ 'const ARTIFACT_NAME = ' + JSON.stringify(fileName) + ';\n'
+ APP_JS
+ '\n</script>\n'
+ '</body>\n'
+ '</html>\n';
}
// ---------------------------------------------------------------------------
// Stylesheet — light + dark + print. Design-system-aligned.
// ---------------------------------------------------------------------------
const STYLE = `
:root {
--bg: #f7f7f8;
--bg-elev: #ffffff;
--bg-soft: #ececef;
--border: #d6d8dc;
--border-strong: #b3b7bd;
--text: #1a1a1a;
--text-dim: #555a63;
--text-mute: #8a8f97;
--accent: #0855a8;
--accent-soft: #e4ecf6;
--amber: #a86b00;
--amber-soft: #fbeed1;
--green: #1a7f37;
--green-soft: #d5ecdb;
--red: #b3262d;
--red-soft: #f6d9da;
--blue: #0855a8;
--blue-soft: #e4ecf6;
--orange: #d4790a;
--orange-soft: #fceede;
--purple: #6638b6;
--purple-soft: #ebe1f9;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
--serif: ui-serif, "Source Serif 4", Georgia, "Times New Roman", serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1218;
--bg-elev: #161b22;
--bg-soft: #1c232c;
--border: #2a323c;
--border-strong: #3b4554;
--text: #e5e9ef;
--text-dim: #a5adba;
--text-mute: #6e7681;
--accent: #6db0ee;
--accent-soft: rgba(109, 176, 238, 0.15);
--amber: #d4a017;
--amber-soft: rgba(212, 160, 23, 0.12);
--green: #3fb950;
--green-soft: rgba(63, 185, 80, 0.12);
--red: #f0626a;
--red-soft: rgba(240, 98, 106, 0.12);
--blue: #6db0ee;
--blue-soft: rgba(109, 176, 238, 0.15);
--orange: #f6ad55;
--orange-soft: rgba(246, 173, 85, 0.15);
--purple: #d2a8ff;
--purple-soft: rgba(210, 168, 255, 0.15);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 15px; line-height: 1.6; }
body { min-height: 100vh; }
/* Topbar */
.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 12px 24px; background: var(--bg-elev); border-bottom: 1px solid var(--border); }
.hdr-meta h1 { font-size: 16px; font-weight: 650; margin: 0; }
.hdr-meta .path { color: var(--text-dim); font-size: 12px; font-family: var(--mono); margin: 2px 0 0; word-break: break-all; }
.hdr-actions { display: flex; gap: 8px; align-items: center; }
.ann-toggle { display: inline-flex; align-items: center; gap: 6px;
background: var(--accent); color: #fff; border: 1px solid var(--accent);
border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.ann-toggle:hover { filter: brightness(1.05); }
body:not(.ann-mode) .ann-toggle { background: var(--bg-soft); color: var(--text-dim); border-color: var(--border); }
body:not(.ann-mode) .ann-toggle:hover { color: var(--text); border-color: var(--border-strong); }
.ann-badge { background: rgba(255,255,255,0.25); color: inherit; padding: 0 6px; border-radius: 99px; font-size: 11px; font-weight: 700; }
body:not(.ann-mode) .ann-badge { background: var(--bg); color: var(--text-dim); }
.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border);
border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; cursor: pointer; }
.ghost-btn:hover { color: var(--text); border-color: var(--border-strong); }
.icon-btn { background: transparent; border: none; color: var(--text-dim); cursor: pointer;
font-size: 16px; padding: 4px 8px; border-radius: 4px; }
.icon-btn:hover { color: var(--text); background: var(--bg-soft); }
/* Article */
.article-wrap { max-width: 820px; margin: 0 auto; padding: 24px 32px 96px; }
.article-help { font-size: 13px; color: var(--text-dim); background: var(--accent-soft);
border: 1px solid var(--accent); border-radius: 6px; padding: 10px 14px; margin: 0 0 24px; line-height: 1.5; }
body:not(.ann-mode) .article-help { display: none; }
.article-help strong { color: var(--text); }
.article { font-size: 15px; line-height: 1.7; }
.article h1, .article h2, .article h3, .article h4, .article h5, .article h6 {
font-family: var(--serif); font-weight: 700; line-height: 1.25; margin: 1.8em 0 .55em; color: var(--text); }
.article h1 { font-size: 2rem; margin-top: 0; }
.article h2 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: .3em; }
.article h3 { font-size: 1.2rem; }
.article h4 { font-size: 1.05rem; }
.article p { margin: .9em 0; }
.article a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.article code { font-family: var(--mono); font-size: .9em; background: var(--bg-soft);
padding: .12em .4em; border-radius: 4px; }
.article pre { background: #1e1e24; color: #e6e6eb; padding: 16px 18px; border-radius: 8px;
overflow-x: auto; font-size: .88rem; line-height: 1.55; margin: 1.2em 0; }
.article pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
.article blockquote { margin: 1.2em 0; padding: .5em 1.2em; border-left: 4px solid var(--accent);
background: var(--accent-soft); color: var(--text-dim); border-radius: 0 6px 6px 0; }
.article ul, .article ol { padding-left: 1.8em; margin: .9em 0; }
.article li { margin: .3em 0; }
.article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .92em; }
.article th, .article td { border: 1px solid var(--border); padding: .55em .8em; text-align: left; vertical-align: top; }
.article th { background: var(--bg-soft); font-weight: 650; }
.article hr { border: none; border-top: 1px solid var(--border); margin: 2.2em 0; }
.article strong { font-weight: 700; }
.article em { font-style: italic; }
/* Annotation mode: highlight annotatable elements on hover, mark annotated ones */
.article [data-anchor-id] { position: relative; transition: background .08s, outline .08s; border-radius: 3px; }
body.ann-mode .article [data-anchor-id] { cursor: pointer; }
body.ann-mode .article [data-anchor-id]:hover {
outline: 2px dashed var(--accent); outline-offset: 2px; background: var(--accent-soft);
}
.article [data-anchor-id].annotated {
background: var(--amber-soft);
outline: 1px solid var(--amber); outline-offset: 1px;
}
.article [data-anchor-id].annotated::after {
content: attr(data-ann-count); position: absolute; right: -22px; top: 2px;
background: var(--amber); color: #fff; font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 99px; font-family: var(--sans);
}
body.ann-mode .article [data-anchor-id].annotated:hover { outline-color: var(--amber); }
.article [data-anchor-id].flash {
animation: flash 1.6s ease-out;
}
@keyframes flash {
0% { background: var(--accent-soft); outline: 2px solid var(--accent); }
100% { background: var(--amber-soft); outline: 1px solid var(--amber); }
}
/* Form popover */
.ann-form { position: fixed; z-index: 200; background: var(--bg-elev); border: 1px solid var(--border-strong);
border-radius: 8px; padding: 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.25);
width: 380px; max-width: calc(100vw - 24px); display: none; flex-direction: column; gap: 10px;
font-family: var(--sans); }
.ann-form.visible { display: flex; }
.ann-form-section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-mute); font-weight: 600; margin-bottom: 3px; }
.ann-form-section-value { font-size: 13px; color: var(--text-dim); font-style: italic; }
.ann-form-snippet-text { margin: 0; padding: 6px 10px; border-left: 3px solid var(--accent);
background: var(--bg); border-radius: 0 4px 4px 0; font-family: var(--mono); font-size: 12px;
color: var(--text); max-height: 100px; overflow-y: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.ann-form-intents { display: flex; gap: 6px; }
.ann-intent { flex: 1; padding: 7px 10px; border-radius: 5px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.ann-intent:hover { color: var(--text); border-color: var(--border-strong); }
.ann-intent[data-intent="fiks"].selected { background: var(--red); color: #fff; border-color: var(--red); }
.ann-intent[data-intent="endre"].selected { background: var(--orange); color: #fff; border-color: var(--orange); }
.ann-intent[data-intent="spørsmål"].selected { background: var(--blue); color: #fff; border-color: var(--blue); }
.ann-form-comment { width: 100%; min-height: 80px; padding: 8px 10px;
font-family: inherit; font-size: 13px; line-height: 1.5; color: var(--text);
background: var(--bg); border: 1px solid var(--border); border-radius: 5px; resize: vertical; }
.ann-form-comment:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.ann-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
.btn { padding: 6px 14px; border-radius: 5px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.btn:hover { color: var(--text); border-color: var(--border-strong); }
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.primary:hover:not(:disabled) { filter: brightness(1.1); color: #fff; }
.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); border-color: var(--border); cursor: not-allowed; filter: none; }
/* Annotations panel (slide-in sidebar) */
.ann-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 420px; max-width: 100vw;
background: var(--bg-elev); border-left: 1px solid var(--border); z-index: 150;
transform: translateX(100%); transition: transform .2s ease;
display: flex; flex-direction: column; box-shadow: -4px 0 20px rgba(0,0,0,0.15); }
.ann-panel.open { transform: translateX(0); }
.ann-panel-head { display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid var(--border); }
.ann-panel-head h2 { font-size: 14px; font-weight: 650; margin: 0; }
.ann-panel-body { flex: 1; overflow-y: auto; padding: 12px 14px; }
.ann-panel-foot { display: flex; justify-content: space-between; gap: 8px;
padding: 12px 14px; border-top: 1px solid var(--border); }
.ann-panel-empty { color: var(--text-mute); font-size: 13px; text-align: center; padding: 32px 12px;
font-style: italic; line-height: 1.5; }
.ann-section { margin: 12px 0 6px; font-size: 11px; font-weight: 650; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--text-mute); padding: 0 4px; }
.ann-section:first-child { margin-top: 0; }
.ann-item { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 12px; margin-bottom: 8px; cursor: pointer; }
.ann-item:hover { border-color: var(--border-strong); }
.ann-item .ann-item-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 6px; }
.ann-item-intent { font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.04em; padding: 2px 8px; border-radius: 99px; }
.ann-item-intent.fiks { background: var(--red-soft); color: var(--red); }
.ann-item-intent.endre { background: var(--orange-soft); color: var(--orange); }
.ann-item-intent.spørsmål { background: var(--blue-soft); color: var(--blue); }
.ann-item-delete { background: transparent; border: none; color: var(--text-mute);
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.ann-item-delete:hover { color: var(--red); background: var(--red-soft); }
.ann-item-snippet { font-family: var(--mono); font-size: 11px; color: var(--text-mute);
margin: 0 0 6px; line-height: 1.5; padding: 4px 8px; background: var(--bg-soft);
border-left: 2px solid var(--border-strong); border-radius: 0 4px 4px 0;
max-height: 60px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
.ann-item-comment { font-size: 13px; color: var(--text); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.ann-item-comment.empty { color: var(--text-mute); font-style: italic; }
/* Toast */
.ann-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: var(--bg-elev); padding: 9px 16px; border-radius: 6px;
font-size: 13px; font-weight: 500; opacity: 0; pointer-events: none;
transition: opacity .2s, transform .2s; z-index: 300; }
.ann-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Overlay (form backdrop) */
.ann-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 100;
opacity: 0; pointer-events: none; transition: opacity .15s; }
.ann-overlay.visible { opacity: 1; pointer-events: auto; }
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
/* Print: hide annotation chrome, show article only */
@media print {
.topbar, .ann-form, .ann-panel, .ann-toast, .ann-overlay, .article-help { display: none !important; }
.article-wrap { max-width: none; padding: 0; }
body { background: #fff; color: #000; }
}
`.trim();
// ---------------------------------------------------------------------------
// Embedded JS app. Uses concatenation (no template literals) to avoid
// backtick collisions with the outer mjs string assembly.
// ---------------------------------------------------------------------------
const APP_JS = `
const STORAGE_KEY = 'voyage-annotate:v2:' + ARTIFACT_PATH;
const INTENT_LABELS = { fiks: 'Fiks', endre: 'Endre', 'spørsmål': 'Spørsmål' };
const INTENT_ORDER = ['fiks', 'endre', 'spørsmål'];
let annotations = [];
let nextId = 1;
let mode = true;
let currentTarget = null;
let currentSection = null;
let currentSnippet = null;
let currentIntent = null;
// ── Storage ──
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data && Array.isArray(data.annotations)) {
annotations = data.annotations;
nextId = data.nextId || (annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1);
}
} catch (e) {}
}
function saveState() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: annotations, nextId: nextId })); } catch (e) {}
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── DOM refs ──
const body = document.body;
const article = document.getElementById('article');
const form = document.getElementById('ann-form');
const formSection = document.getElementById('ann-form-section');
const formSnippet = document.getElementById('ann-form-snippet');
const formComment = document.getElementById('ann-form-comment');
const formSave = document.getElementById('ann-form-save');
const formCancel = document.getElementById('ann-form-cancel');
const intents = document.querySelectorAll('.ann-intent');
const panel = document.getElementById('ann-panel');
const panelBody = document.getElementById('ann-panel-body');
const panelCloseBtn = document.getElementById('ann-panel-close');
const openPanelBtn = document.getElementById('open-panel');
const clearAllBtn = document.getElementById('ann-clear-all');
const copyBtn = document.getElementById('ann-copy');
const annToggle = document.getElementById('ann-toggle');
const annToggleLabel = document.getElementById('ann-toggle-label');
const annBadge = document.getElementById('ann-badge');
const toast = document.getElementById('ann-toast');
const overlay = document.getElementById('ann-overlay');
// ── Section lookup ──
function findSection(el) {
let p = el;
while (p) {
if (p.previousElementSibling) {
let s = p.previousElementSibling;
while (s) {
if (s.matches && (s.matches('h1') || s.matches('h2'))) return s.textContent.trim();
s = s.previousElementSibling;
}
}
p = p.parentElement;
if (p && p.tagName === 'ARTICLE') break;
}
// Fallback: first h1 in article
const firstH = article.querySelector('h1, h2');
return firstH ? firstH.textContent.trim() : '(top)';
}
// ── Snippet from selection or element text ──
function captureSnippet(el) {
const sel = window.getSelection();
if (sel && sel.toString().trim().length > 0 && el.contains(sel.anchorNode)) {
return sel.toString().trim().slice(0, 300);
}
return (el.textContent || '').trim().slice(0, 200);
}
// ── Form open/close ──
function openForm(evt, target) {
currentTarget = target;
currentSection = findSection(target);
currentSnippet = captureSnippet(target);
currentIntent = null;
formSection.textContent = currentSection || '(top)';
formSnippet.textContent = currentSnippet || '(empty)';
formComment.value = '';
intents.forEach(function(b) { b.classList.remove('selected'); });
formSave.disabled = true;
// Position near the click (clamped to viewport)
const fw = 380, fh = 320;
let x = evt.clientX + 14;
let y = evt.clientY + 14;
if (x + fw > window.innerWidth) x = window.innerWidth - fw - 12;
if (y + fh > window.innerHeight) y = Math.max(12, window.innerHeight - fh - 12);
if (x < 12) x = 12;
if (y < 12) y = 12;
form.style.left = x + 'px';
form.style.top = y + 'px';
form.classList.add('visible');
overlay.classList.add('visible');
setTimeout(function() { formComment.focus(); }, 50);
}
function closeForm() {
form.classList.remove('visible');
overlay.classList.remove('visible');
currentTarget = null;
currentSection = null;
currentSnippet = null;
currentIntent = null;
}
// ── Save ──
function saveAnnotation() {
if (!currentIntent || !currentTarget) return;
const a = {
id: nextId++,
anchorId: currentTarget.getAttribute('data-anchor-id'),
section: currentSection || '(top)',
snippet: currentSnippet || '',
intent: currentIntent,
comment: (formComment.value || '').trim(),
ts: new Date().toISOString(),
};
annotations.push(a);
saveState();
closeForm();
refreshArticleAnnotations();
renderPanel();
updateCounts();
showToast('Annotasjon lagret (' + annotations.length + ')');
}
// ── Delete ──
function deleteAnnotation(id) {
annotations = annotations.filter(function(a) { return a.id !== id; });
saveState();
refreshArticleAnnotations();
renderPanel();
updateCounts();
showToast('Annotasjon slettet');
}
// ── Refresh article markers ──
function refreshArticleAnnotations() {
// Clear all current markers
article.querySelectorAll('[data-anchor-id].annotated').forEach(function(el) {
el.classList.remove('annotated');
el.removeAttribute('data-ann-count');
});
// Group by anchorId
const byAnchor = {};
for (const a of annotations) {
if (!a.anchorId) continue;
if (!byAnchor[a.anchorId]) byAnchor[a.anchorId] = 0;
byAnchor[a.anchorId]++;
}
for (const anchorId in byAnchor) {
const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchorId) + '"]');
if (el) {
el.classList.add('annotated');
el.setAttribute('data-ann-count', byAnchor[anchorId]);
}
}
}
// ── Sidebar panel render ──
function renderPanel() {
if (annotations.length === 0) {
panelBody.innerHTML = '<div class="ann-panel-empty">No annotations yet.<br><br>Click any heading, paragraph, list item, or quote in the article to add one.</div>';
return;
}
// Group by section (preserve insertion order)
const groups = [];
const groupMap = {};
// Sort by document order using anchorId numerical suffix
const sorted = annotations.slice().sort(function(a, b) {
const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0;
const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0;
if (ai !== bi) return ai - bi;
return a.id - b.id;
});
for (const a of sorted) {
if (!groupMap[a.section]) {
groupMap[a.section] = { section: a.section, items: [] };
groups.push(groupMap[a.section]);
}
groupMap[a.section].items.push(a);
}
let html = '';
for (const g of groups) {
html += '<div class="ann-section">' + escHtml(g.section) + '</div>';
for (const a of g.items) {
html += '<div class="ann-item" data-anchor-id="' + escHtml(a.anchorId || '') + '" data-id="' + a.id + '">'
+ '<div class="ann-item-head">'
+ '<span class="ann-item-intent ' + escHtml(a.intent) + '">' + escHtml(INTENT_LABELS[a.intent] || a.intent) + '</span>'
+ '<button class="ann-item-delete" data-del="' + a.id + '" title="Delete">✕</button>'
+ '</div>'
+ '<blockquote class="ann-item-snippet">' + escHtml(a.snippet || '(empty)') + '</blockquote>'
+ '<div class="ann-item-comment' + (a.comment ? '' : ' empty') + '">' + escHtml(a.comment || '(no comment)') + '</div>'
+ '</div>';
}
}
panelBody.innerHTML = html;
panelBody.querySelectorAll('.ann-item-delete').forEach(function(b) {
b.addEventListener('click', function(e) {
e.stopPropagation();
if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10));
});
});
panelBody.querySelectorAll('.ann-item').forEach(function(card) {
card.addEventListener('click', function() {
const anchor = card.getAttribute('data-anchor-id');
const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchor) + '"]');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
});
});
}
// ── Counts + toggle label ──
function updateCounts() {
annBadge.textContent = String(annotations.length);
copyBtn.disabled = annotations.length === 0;
}
function setMode(on) {
mode = on;
body.classList.toggle('ann-mode', on);
annToggleLabel.textContent = on ? 'Annotation mode: ON' : 'Annotation mode: OFF';
if (!on) closeForm();
}
// ── Toast ──
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('visible');
setTimeout(function() { toast.classList.remove('visible'); }, 1800);
}
// ── Copy Prompt ──
function buildPromptMarkdown() {
if (annotations.length === 0) return '';
const sorted = annotations.slice().sort(function(a, b) {
const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0;
const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0;
if (ai !== bi) return ai - bi;
return a.id - b.id;
});
let p = 'Please revise the voyage artifact at \\\`' + ARTIFACT_PATH + '\\\` with the operator annotations below.\\n';
p += 'Each annotation has an intent — **Fiks** (something is wrong / fix it), **Endre** (change wording/content),\\n';
p += 'or **Spørsmål** (operator question — clarify or answer). The quote shows what the operator anchored to.\\n';
p += 'Treat the operator notes as authoritative direction.\\n\\n';
p += '## Annotations (' + annotations.length + ' total)\\n\\n';
let n = 0;
for (const a of sorted) {
n++;
p += '### ' + n + '. [' + (INTENT_LABELS[a.intent] || a.intent) + '] Section: ' + a.section + '\\n';
if (a.snippet) p += 'Quote: «' + a.snippet + '»\\n';
p += 'Comment: ' + (a.comment || '(no comment)') + '\\n\\n';
}
return p;
}
async function copyPrompt() {
const md = buildPromptMarkdown();
if (!md) return;
try {
await navigator.clipboard.writeText(md);
showToast('Prompt copied (' + annotations.length + ' annotation' + (annotations.length === 1 ? '' : 's') + ')');
} catch (e) {
// Fallback
const ta = document.createElement('textarea');
ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); showToast('Prompt copied'); } catch (e2) { alert('Copy failed: ' + e2.message); }
ta.remove();
}
}
// ── Wiring ──
article.addEventListener('click', function(e) {
if (!mode) return;
const target = e.target.closest('[data-anchor-id]');
if (!target) return;
// Don't open form when clicking inside an already-open form (overlay catches outside clicks)
if (e.target.closest('.ann-form')) return;
// Don't open form when clicking a link the user wants to follow — but only if they didn't select text
if (e.target.tagName === 'A' && (!window.getSelection() || window.getSelection().toString().trim().length === 0)) {
// Allow link clicks in mode if no selection
return;
}
e.preventDefault();
openForm(e, target);
});
intents.forEach(function(b) {
b.addEventListener('click', function() {
intents.forEach(function(x) { x.classList.remove('selected'); });
b.classList.add('selected');
currentIntent = b.dataset.intent;
formSave.disabled = false;
});
});
formSave.addEventListener('click', saveAnnotation);
formCancel.addEventListener('click', closeForm);
overlay.addEventListener('click', closeForm);
formComment.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !formSave.disabled) {
saveAnnotation();
} else if (e.key === 'Escape') {
closeForm();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && form.classList.contains('visible')) closeForm();
});
annToggle.addEventListener('click', function() { setMode(!mode); });
openPanelBtn.addEventListener('click', function() {
panel.classList.toggle('open');
});
panelCloseBtn.addEventListener('click', function() { panel.classList.remove('open'); });
clearAllBtn.addEventListener('click', function() {
if (annotations.length === 0) return;
if (confirm('Remove all ' + annotations.length + ' annotations? This cannot be undone.')) {
annotations = [];
saveState();
refreshArticleAnnotations();
renderPanel();
updateCounts();
showToast('All annotations cleared');
}
});
copyBtn.addEventListener('click', copyPrompt);
// ── Init ──
loadState();
refreshArticleAnnotations();
renderPanel();
updateCounts();
setMode(true);
`.trim();
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const args = { input: null, out: null, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--out') args.out = argv[++i];
else if (a === '--help' || a === '-h') args.help = true;
else if (!args.input) args.input = a;
}
return args;
}
function render(inputPath, outputPath) {
if (!existsSync(inputPath)) {
process.stderr.write('annotate: input not found: ' + inputPath + '\n');
process.exit(2);
}
const text = readFileSync(inputPath, 'utf-8');
const html = buildHtml(resolve(inputPath), text);
const out = outputPath || inputPath.replace(/\.md$/, '.html');
writeFileSync(out, html);
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.input) {
process.stdout.write(
'Usage: annotate <artifact.md> [--out <file.html>]\n\n'
+ 'Builds a self-contained operator-annotation HTML for a voyage\n'
+ 'artifact. The operator opens the HTML, selects text or clicks any\n'
+ 'element, picks an intent (Fiks / Endre / Spørsmål), writes a\n'
+ 'comment, and copies a structured prompt to paste back into Claude.\n'
+ 'Annotations persist in localStorage per artifact path.\n\n'
+ 'Default output: <input-basename>.html next to input.\n',
);
process.exit(args.help ? 0 : 2);
}
const out = render(args.input, args.out);
process.stdout.write(out + '\n');
}
export { render, buildHtml, renderMarkdown, parseArgs };

View file

@ -1,321 +0,0 @@
#!/usr/bin/env node
// scripts/render-artifact.mjs
//
// Renders a voyage artifact (brief.md / plan.md / review.md) to a
// self-contained HTML file in the same directory, with inlined CSS and
// zero external network references. The producing commands (/trekbrief,
// /trekplan, /trekreview) call this at the end and print the file:// link
// so the operator can read the artifact in a browser — and, when they want
// to annotate it, run the official `/playground` plugin (document-critique
// template) on it and paste the generated prompt back into Claude Code.
//
// Usage:
// node scripts/render-artifact.mjs <artifact.md> [--out <output.html>]
//
// Determinism: no timestamps, no random IDs — two runs on the same input
// produce byte-identical output.
//
// Zero npm deps (marketplace convention). The markdown→HTML conversion is a
// small hand-rolled subset that covers what the artifact templates emit:
// ATX headings, ordered/unordered/nested lists, fenced code blocks, inline
// code, bold, links, blockquotes, GitHub-style tables, and horizontal rules.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename } from 'node:path';
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
// ---------------------------------------------------------------------------
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Inline spans, applied to already-HTML-escaped text. Order matters: code
// spans first (so their contents aren't re-processed), then links, bold, em.
function renderInline(escaped) {
let out = escaped.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, href) => {
const safe = /^(https?:|mailto:|#|\.|\/)/i.test(href) ? href : '#';
return `<a href="${safe}">${text}</a>`;
});
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}<em>${c}</em>`);
return out;
}
function renderTable(rows) {
const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim());
const header = cells(rows[0]);
const body = rows.slice(2).map(cells);
let html = '<table>\n<thead><tr>';
for (const h of header) html += `<th>${renderInline(escapeHtml(h))}</th>`;
html += '</tr></thead>\n<tbody>\n';
for (const r of body) {
html += '<tr>';
for (let i = 0; i < header.length; i++) html += `<td>${renderInline(escapeHtml(r[i] || ''))}</td>`;
html += '</tr>\n';
}
return html + '</tbody>\n</table>\n';
}
// Build nested <ul>/<ol> from a run of list lines (2-space indent = 1 level).
function renderList(items) {
let html = '';
const stack = []; // { indent, ordered }
for (const { indent, ordered, text } of items) {
while (
stack.length &&
(indent < stack[stack.length - 1].indent ||
(indent === stack[stack.length - 1].indent && ordered !== stack[stack.length - 1].ordered))
) {
const top = stack.pop();
html += top.ordered ? '</li></ol>' : '</li></ul>';
}
if (!stack.length || indent > stack[stack.length - 1].indent) {
html += ordered ? '<ol>' : '<ul>';
stack.push({ indent, ordered });
} else {
html += '</li>';
}
html += `<li>${renderInline(escapeHtml(text))}`;
}
while (stack.length) {
const top = stack.pop();
html += top.ordered ? '</li></ol>' : '</li></ul>';
}
return html + '\n';
}
function renderMarkdown(md) {
const lines = md.replace(/\r\n/g, '\n').split('\n');
let html = '';
let i = 0;
let para = [];
const flushPara = () => {
if (para.length) {
html += `<p>${renderInline(escapeHtml(para.join(' ')))}</p>\n`;
para = [];
}
};
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fence) {
flushPara();
const marker = fence[2];
const lang = (fence[3] || '').trim().split(/\s+/)[0];
const buf = [];
i++;
while (i < lines.length && !lines[i].match(new RegExp('^\\s*' + marker[0] + '{3,}\\s*$'))) {
buf.push(lines[i]);
i++;
}
i++; // consume closing fence
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
html += `<pre><code${cls}>${escapeHtml(buf.join('\n'))}\n</code></pre>\n`;
continue;
}
// ATX heading
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
if (h) {
flushPara();
const lvl = h[1].length;
html += `<h${lvl}>${renderInline(escapeHtml(h[2]))}</h${lvl}>\n`;
i++;
continue;
}
// Horizontal rule
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
flushPara();
html += '<hr>\n';
i++;
continue;
}
// Table (header row + separator row)
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
flushPara();
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i]); i++; }
// include the separator that was matched as part of rows already
html += renderTable(rows);
continue;
}
// Blockquote
if (/^\s*>\s?/.test(line)) {
flushPara();
const buf = [];
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
html += `<blockquote>${renderInline(escapeHtml(buf.join(' ')))}</blockquote>\n`;
continue;
}
// Lists (consume a contiguous block, allowing blank lines between items)
const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (listMatch) {
flushPara();
const items = [];
while (i < lines.length) {
const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
if (m) {
items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] });
i++;
} else if (lines[i].trim() === '' && i + 1 < lines.length && lines[i + 1].match(/^(\s*)([-*+]|\d+[.)])\s+/)) {
i++; // blank line inside the list
} else {
break;
}
}
html += renderList(items);
continue;
}
// Blank line — paragraph break
if (line.trim() === '') {
flushPara();
i++;
continue;
}
// Default — accumulate into paragraph
para.push(line.trim());
i++;
}
flushPara();
return html;
}
// ---------------------------------------------------------------------------
const STYLE = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.25rem 4rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 16px; line-height: 1.6; color: #1a1a1a; background: #f7f7f8;
}
main { max-width: 56rem; margin: 0 auto; background: #fff; border: 1px solid #e2e2e6;
border-radius: 12px; padding: 2.5rem 3rem; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.8em 0 .6em; font-weight: 650; }
h1 { font-size: 2rem; margin-top: 0; }
h2 { font-size: 1.5rem; border-bottom: 1px solid #ececef; padding-bottom: .3em; }
h3 { font-size: 1.2rem; }
h4 { font-size: 1.05rem; }
p { margin: .8em 0; }
a { color: #0855a8; text-decoration: underline; text-underline-offset: 2px; }
a:hover { color: #06408a; }
code { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-size: .9em; background: #f0f0f3; padding: .12em .35em; border-radius: 4px; }
pre { background: #1e1e24; color: #e6e6eb; padding: 1rem 1.25rem; border-radius: 8px;
overflow-x: auto; font-size: .85rem; line-height: 1.5; }
pre code { background: none; padding: 0; color: inherit; font-size: inherit; }
blockquote { margin: 1em 0; padding: .4em 1.2em; border-left: 4px solid #0855a8;
background: #f0f5fb; color: #34495e; border-radius: 0 6px 6px 0; }
ul, ol { padding-left: 1.6em; margin: .8em 0; }
li { margin: .25em 0; }
table { border-collapse: collapse; width: 100%; margin: 1.2em 0; font-size: .92rem; }
th, td { border: 1px solid #e2e2e6; padding: .5em .75em; text-align: left; vertical-align: top; }
th { background: #f0f0f3; font-weight: 600; }
tr:nth-child(even) td { background: #fafafb; }
hr { border: none; border-top: 1px solid #e2e2e6; margin: 2em 0; }
details.frontmatter { margin: 0 0 2rem; border: 1px solid #e2e2e6; border-radius: 8px;
background: #fafafb; padding: .6em 1em; }
details.frontmatter > summary { cursor: pointer; font-weight: 600; font-size: .9rem; color: #555; }
details.frontmatter pre { margin: .8em 0 .2em; background: #f4f4f6; color: #333; }
.artifact-meta { color: #888; font-size: .82rem; margin: 0 0 1.5rem; }
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
body { color: #e6e6eb; background: #18181b; }
main { background: #1f1f23; border-color: #2e2e34; box-shadow: none; }
h2 { border-bottom-color: #2e2e34; }
a { color: #6db0ee; } a:hover { color: #93c5fd; }
code { background: #2a2a30; }
blockquote { background: #1a242f; color: #b6c5d4; border-left-color: #6db0ee; }
th, td { border-color: #2e2e34; } th { background: #26262c; }
tr:nth-child(even) td { background: #222226; }
hr { border-top-color: #2e2e34; }
details.frontmatter { background: #222226; border-color: #2e2e34; }
details.frontmatter > summary { color: #aaa; }
details.frontmatter pre { background: #1a1a1d; color: #ccc; }
.artifact-meta { color: #777; }
}
@media print { body { background: #fff; padding: 0; } main { border: none; box-shadow: none; max-width: none; } }
`.trim();
function buildHtml(mdPath, mdText) {
const { hasFrontmatter, frontmatter, body } = splitFrontmatter(mdText);
const fm = hasFrontmatter ? frontmatter : '';
const fmLine = (key) => {
const m = fm.match(new RegExp('^' + key + ':\\s*(.+)$', 'm'));
return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
};
const title = fmLine('task') || fmLine('slug') || (body.match(/^#\s+(.+)$/m) || [])[1] || basename(mdPath);
const kind = fmLine('type') || basename(mdPath).replace(/\.md$/, '');
const fmBlock = hasFrontmatter
? `<details class="frontmatter"><summary>Frontmatter</summary><pre><code>${escapeHtml(fm)}\n</code></pre></details>\n`
: '';
const bodyHtml = renderMarkdown(body);
return '<!DOCTYPE html>\n'
+ '<html lang="en">\n<head>\n<meta charset="utf-8">\n'
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
+ `<title>${escapeHtml(String(title))}</title>\n`
+ `<style>\n${STYLE}\n</style>\n</head>\n<body>\n<main>\n`
+ `<p class="artifact-meta">voyage artifact — ${escapeHtml(String(kind))}</p>\n`
+ fmBlock
+ bodyHtml
+ '</main>\n</body>\n</html>\n';
}
function render(inputPath, outputPath) {
if (!existsSync(inputPath)) {
process.stderr.write(`render-artifact: input not found: ${inputPath}\n`);
process.exit(2);
}
const text = readFileSync(inputPath, 'utf-8');
const html = buildHtml(inputPath, text);
const out = outputPath || inputPath.replace(/\.md$/, '.html');
writeFileSync(out, html);
return out;
}
function parseArgs(argv) {
const args = { input: null, out: null, help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--out') args.out = argv[++i];
else if (a === '--help' || a === '-h') args.help = true;
else if (!args.input) args.input = a;
}
return args;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.input) {
process.stdout.write(
'Usage: render-artifact <artifact.md> [--out <output.html>]\n\n'
+ 'Renders a voyage artifact to a self-contained HTML file (zero network).\n'
+ 'Default output: <input-basename>.html next to the input.\n',
);
process.exit(args.help ? 0 : 2);
}
const out = render(args.input, args.out);
process.stdout.write(out + '\n');
}
export { render, buildHtml, renderMarkdown, parseArgs };

View file

@ -1,6 +1,6 @@
---
type: trekbrief
brief_version: 2.0
brief_version: 2.1
created: {YYYY-MM-DD}
task: "{one-line task description}"
slug: {slug}
@ -10,6 +10,20 @@ research_status: pending # pending | in_progress | complete | skipped
auto_research: false # true if user opted into Claude-managed research
interview_turns: {N}
source: {interview | manual}
# v5.1 — per-phase effort + model signal (Phase 3.5).
# `effort` ∈ {low, standard, high}. 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

@ -0,0 +1,42 @@
// tests/commands/trekbrief.test.mjs
// v5.1 — Pattern D prose-pattern regression tests for /trekbrief Phase 3.5.
//
// Brief SC1 + SC2: end-of-brief effort dialog covering 4 downstream phases,
// with `phase_signals_partial` as the force-stop record.
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';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md');
function read() {
return readFileSync(COMMAND_FILE, 'utf8');
}
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');
});

View file

@ -0,0 +1,34 @@
// tests/commands/trekexecute.test.mjs
// v5.1 — sequencing-gate surface + low-effort prose check for /trekexecute.
// Plan Assumption 2 locks low-effort to --gates open + sequential-only.
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';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
function read() {
return readFileSync(COMMAND_FILE, 'utf8');
}
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 references --gates open + sequential', () => {
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, /--gates open/, 'Low-effort path must mention --gates open');
assert.match(section, /sequential/, 'Low-effort path must mention sequential-only execution');
});

View file

@ -0,0 +1,32 @@
// tests/commands/trekplan.test.mjs
// v5.1 — sequencing-gate surface + low-effort prose check 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';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekplan.md');
function read() {
return readFileSync(COMMAND_FILE, 'utf8');
}
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');
});

View file

@ -0,0 +1,32 @@
// tests/commands/trekresearch.test.mjs
// v5.1 — sequencing-gate surface + low-effort prose check 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';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekresearch.md');
function read() {
return readFileSync(COMMAND_FILE, 'utf8');
}
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();
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');
});

View file

@ -0,0 +1,32 @@
// tests/commands/trekreview.test.mjs
// v5.1 — sequencing-gate surface + low-effort prose check 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';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekreview.md');
function read() {
return readFileSync(COMMAND_FILE, 'utf8');
}
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');
});

View file

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

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

@ -400,13 +400,13 @@ test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => {
);
});
// --- v5.0.0 — bespoke playground + /trekrevise + Handover 8 removed ---
// --- v5.0.0 / v5.0.1 — bespoke playground removed; /playground invocation explicit ---
//
// The v4.2/v4.3 bespoke playground SPA, the /trekrevise command, and
// Handover 8 (annotation → revision) were removed in v5.0.0. Producing
// commands now render artifacts to self-contained HTML via
// scripts/render-artifact.mjs and direct operators at the official
// `/playground` plugin for annotation. These pins lock the removal in.
// v5.0.0 removed the bespoke playground SPA, /trekrevise, and Handover 8.
// v5.0.1 dropped the v5.0.0 stop-gap (scripts/render-artifact.mjs) and made
// the producing commands print a literal, copy-paste-ready /playground
// document-critique invocation instead. These pins lock both removals in
// AND pin the new copy-paste invocation as the operator-facing contract.
import { existsSync } from 'node:fs';
@ -430,36 +430,103 @@ test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)',
assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain');
});
test('scripts/render-artifact.mjs exists (v5.0.0 render-and-link step)', () => {
test('scripts/render-artifact.mjs is still removed (v5.0.1 + v5.0.2)', () => {
assert.ok(
existsSync(join(ROOT, 'scripts/render-artifact.mjs')),
'scripts/render-artifact.mjs is required — producing commands call it to render artifacts to HTML',
!existsSync(join(ROOT, 'scripts/render-artifact.mjs')),
'scripts/render-artifact.mjs should be deleted — v5.0.1 dropped the standalone HTML render; v5.0.2 kept it removed (annotate.mjs is the replacement)',
);
});
test('producing commands reference render-artifact.mjs (render-and-link step)', () => {
test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => {
assert.ok(
existsSync(join(ROOT, 'scripts/annotate.mjs')),
'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML',
);
});
test('producing commands reference scripts/annotate.mjs (v5.0.2 render-and-link step)', () => {
// v5.0.0 → v5.0.1 → v5.0.2 chain: v5.0.0 added an HTML render that didn't
// afford annotation; v5.0.1 pointed at /playground document-critique (which
// pre-generates Claude's suggestions, not operator-driven annotation); v5.0.2
// ships scripts/annotate.mjs — an operator-driven annotation surface where
// the OPERATOR clicks lines and writes their own notes. Pin the wiring.
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
assert.ok(
read(`commands/${f}`).includes('render-artifact.mjs'),
`commands/${f} must wire the render-artifact.mjs render-and-link step (v5.0.0)`,
read(`commands/${f}`).includes('scripts/annotate.mjs'),
`commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`,
);
}
});
test('producing commands point operators at the /playground plugin for annotation', () => {
test('producing commands no longer print the v5.0.1 /playground document-critique line', () => {
// v5.0.1 told operators to copy-paste "/playground build a document-critique
// playground for X" — but that flow pre-generates Claude's suggestions. The
// operator asked for their own annotations, not a critique of Claude's.
// v5.0.2 removes that line from the producing commands' final report.
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
assert.ok(
read(`commands/${f}`).includes('/playground'),
`commands/${f} must mention the /playground plugin as the annotation path (v5.0.0)`,
!read(`commands/${f}`).includes('/playground build a document-critique'),
`commands/${f} must not print the v5.0.1 /playground document-critique invocation — v5.0.2 replaces it with annotate.mjs`,
);
}
});
test('producing commands tell the operator the flow is THEIR own annotations', () => {
// Pin language: every producing command's prose must mention that the
// OPERATOR drives annotation, not Claude. Phrase variants are allowed
// ("YOUR OWN note", "operator drives", etc.) — we look for the operator-
// ownership signal.
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
const text = read(`commands/${f}`);
assert.ok(
/YOUR OWN|operator drives|your own/i.test(text),
`commands/${f} must signal that the operator drives annotation (v5.0.2 contract)`,
);
}
});
test('producing commands emit file:// link in final report (operator-UX contract, 2026-05-13)', () => {
// Operator runs Ghostty / iTerm2 / modern Terminal.app — all support cmd+click
// on file:// URLs. Producing commands MUST emit both forms: (a) plain file://
// line in the report block, (b) `open file://...` copy-pasteable command.
// Both must reference $ANNOT_HTML (absolute path from scripts/annotate.mjs).
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
const text = read(`commands/${f}`);
assert.ok(
/file:\/\/\{\$ANNOT_HTML\}/.test(text),
`commands/${f} must include "file://{$ANNOT_HTML}" plain URL in the final report block`,
);
assert.ok(
/open file:\/\/\{\$ANNOT_HTML\}/.test(text),
`commands/${f} must include "open file://{$ANNOT_HTML}" copy-pasteable command in the final report block`,
);
}
});
test('package.json still has no "npm run render" script (removed in v5.0.1)', () => {
const pkg = JSON.parse(read('package.json'));
assert.equal(
pkg.scripts && pkg.scripts.render,
undefined,
'package.json scripts.render should remain gone',
);
});
test('CHANGELOG.md has v5.0.0 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.0\.0\b/, 'CHANGELOG.md must include "## v5.0.0" entry');
});
test('CHANGELOG.md has v5.0.1 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry');
});
test('CHANGELOG.md has v5.0.2 entry', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v5\.0\.2\b/, 'CHANGELOG.md must include "## v5.0.2" entry');
});
test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => {
const cl = read('CHANGELOG.md');
assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry');
@ -484,3 +551,37 @@ test('operational files no longer reference trekrevise (v5.0.0 removal)', () =>
);
}
});
// --- v5.1 — phase_signals + brief_version 2.1 ---
test('v5.1 — templates/trekbrief-template.md declares brief_version: 2.1', () => {
const t = read('templates/trekbrief-template.md');
assert.match(t, /^brief_version: 2\.1$/m,
'trekbrief-template.md must declare brief_version: 2.1 at top of frontmatter');
});
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 — voyage CLAUDE.md mentions phase_signals', () => {
const t = read('CLAUDE.md');
assert.ok(t.includes('phase_signals'),
'voyage CLAUDE.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)');
});

View file

@ -0,0 +1,208 @@
// tests/scripts/annotate.test.mjs
// Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML
// generator. UX modelled on claude-code-100x/build-site.js (pencil
// toggle, intent buttons, form popover, selection-anchoring, localStorage
// persistence, structured markdown export).
//
// What we pin:
// • Output is a complete, self-contained HTML document.
// • No external <link href=> or <script src=>.
// • The embedded inline <script> parses as valid JavaScript.
// • The artifact path is embedded (used as the localStorage key + prompt context).
// • The markdown source is rendered to proper HTML (h1/p/li etc.), not as raw lines.
// • HTML metacharacters in the title are escaped (XSS).
// • Inline content from a hostile .md never appears as a live attribute.
// • render() is deterministic — two runs produce byte-identical output.
// • Default output path is <input-basename>.html next to the input.
// • The v5.0.3 affordances are wired into the HTML: pencil-toggle, form
// popover with three intent buttons (Fiks/Endre/Spørsmål), annotations
// sidebar, Copy Prompt button, Clear all, localStorage persistence.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/annotate.mjs';
const SAMPLE = `---
type: trekplan
plan_version: "1.7"
task: "Operator-annotation smoke test"
slug: annotate-smoke
---
# Operator-annotation smoke test
This is a paragraph with **bold**, \`inline code\`, and a [link](https://example.com).
## Steps
- first item
- second item
\`\`\`js
const x = 1;
\`\`\`
> a blockquote
`;
test('buildHtml produces a complete self-contained HTML document', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
assert.ok(html.startsWith('<!DOCTYPE html>'), 'must start with doctype');
assert.ok(html.includes('</html>'), 'must close html');
assert.ok(html.includes('<style>'), 'must inline a stylesheet');
assert.ok(html.includes('<script>'), 'must inline the app script');
});
test('buildHtml has zero external network references in static HTML', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
assert.ok(!/<link[^>]+href\s*=/i.test(html), 'no external <link href> stylesheets');
assert.ok(!/<script[^>]+src\s*=/i.test(html), 'no external <script src>');
});
test('buildHtml embeds the inline <script> as parseable JavaScript', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
const m = html.match(/<script>([\s\S]*?)<\/script>/);
assert.ok(m, 'must contain a <script> block');
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
});
test('buildHtml embeds the artifact path (used as localStorage key + prompt context)', () => {
const html = buildHtml('/abs/projects/2026-05-13-foo/brief.md', SAMPLE);
assert.ok(html.includes('/abs/projects/2026-05-13-foo/brief.md'),
'artifact path must appear in the HTML so the script can use it as the localStorage key + prompt context');
});
test('buildHtml renders the markdown source to proper article HTML', () => {
const html = buildHtml('/abs/path/plan.md', SAMPLE);
// Headings, paragraph content, list items, code fence — all present as HTML.
assert.ok(html.includes('<h1 data-anchor-id='), 'top-level heading rendered as <h1>');
assert.ok(html.includes('<h2 data-anchor-id='), '## heading rendered as <h2>');
assert.ok(html.includes('Operator-annotation smoke test'), 'h1 text preserved');
assert.ok(html.includes('<li data-anchor-id='), 'list items rendered with anchor ids');
assert.ok(html.includes('first item'), 'list content preserved');
assert.ok(html.includes('<pre data-anchor-id='), 'code fence rendered with anchor');
assert.ok(html.includes('const x = 1;'), 'code fence body preserved (escaped)');
assert.ok(html.includes('<blockquote data-anchor-id='), 'blockquote rendered with anchor');
});
test('buildHtml escapes HTML metacharacters in the title (XSS surface)', () => {
const md = '---\ntype: trekbrief\ntask: "<script>alert(1)</script>"\n---\n\n# Foo\n';
const html = buildHtml('/abs/path/brief.md', md);
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/);
assert.ok(titleMatch, 'must have a title');
assert.ok(!titleMatch[1].includes('<script>'), 'title must not carry a raw <script> tag');
assert.match(titleMatch[1], /&lt;script&gt;/, 'title must be HTML-escaped');
});
test('hostile inline content cannot inject as live HTML attributes', () => {
const md = '# Heading\n\nA paragraph with <img src=x onerror="alert(1)"> embedded.\n';
const html = buildHtml('/abs/path/brief.md', md);
// The article body must not carry a live onerror="..." attribute (the renderer
// HTML-escapes everything in the body, so `<` → `&lt;`).
const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/);
assert.ok(articleMatch, 'must have article body');
assert.ok(!/onerror\s*=\s*"alert/i.test(articleMatch[1]),
'article body must not carry a live onerror attribute');
assert.ok(articleMatch[1].includes('&lt;img'),
'hostile <img> must be escaped to &lt;img');
});
test('render() is deterministic — two runs byte-identical', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-annotate-'));
try {
const md = join(dir, 'plan.md');
writeFileSync(md, SAMPLE);
const a = render(md, join(dir, 'a.html'));
const b = render(md, join(dir, 'b.html'));
assert.ok(existsSync(a) && existsSync(b));
assert.equal(readFileSync(a, 'utf-8'), readFileSync(b, 'utf-8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render() defaults output to <input-basename>.html next to input', () => {
const dir = mkdtempSync(join(tmpdir(), 'claude-annotate-'));
try {
const md = join(dir, 'review.md');
writeFileSync(md, '# Review\n\nok\n');
const out = render(md);
assert.equal(out, join(dir, 'review.html'));
assert.ok(existsSync(out));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('parseArgs handles --out, positional input, and --help', () => {
assert.deepEqual(parseArgs(['x.md']), { input: 'x.md', out: null, help: false });
assert.deepEqual(parseArgs(['x.md', '--out', 'y.html']), { input: 'x.md', out: 'y.html', help: false });
assert.equal(parseArgs(['--help']).help, true);
});
test('buildHtml wires the v5.0.3 operator-driven annotation affordances', () => {
// Pin every UX-critical affordance modelled on claude-code-100x/build-site.js:
// - Pencil-toggle button (annotation mode on/off)
// - Form popover with three intent buttons (Fiks/Endre/Spørsmål)
// - Annotations sidebar (Your annotations + Clear all + Copy Prompt)
// - Selection capture (window.getSelection())
// - Section context auto-detection (findSection)
// - localStorage persistence (voyage-annotate:v2:...)
// - Annotatable elements (data-anchor-id on h1-h6, p, li, td, blockquote, pre)
const html = buildHtml('/abs/path/brief.md', SAMPLE);
// Toggle
assert.ok(html.includes('ann-toggle'), 'must have the pencil-toggle button');
assert.ok(html.includes('Annotation mode: ON'), 'must label the toggle state');
// Form + intents (the three CSS classes for selected state)
assert.ok(html.includes('data-intent="fiks"'), 'must have Fiks intent button');
assert.ok(html.includes('data-intent="endre"'), 'must have Endre intent button');
assert.ok(html.includes('data-intent="spørsmål"'), 'must have Spørsmål intent button');
// Form popover
assert.ok(html.includes('ann-form'), 'must have the form popover');
assert.ok(html.includes('ann-form-comment'), 'must have a comment textarea');
assert.ok(html.includes('ann-form-save'), 'must have a Save button');
// Sidebar
assert.ok(html.includes('ann-panel'), 'must have the annotations sidebar');
assert.ok(html.includes('Your annotations'), 'sidebar must title the list');
assert.ok(html.includes('Clear all'), 'sidebar must offer Clear all');
assert.ok(html.includes('Copy Prompt'), 'sidebar must offer Copy Prompt');
// Selection + section
assert.ok(html.includes('window.getSelection'), 'must capture selection');
assert.ok(html.includes('findSection'), 'must auto-detect section context');
// Persistence
assert.ok(html.includes("'voyage-annotate:v2:'"), 'must use the v2 localStorage key prefix');
// Anchor coverage
const anchors = (html.match(/data-anchor-id="anch-/g) || []).length;
assert.ok(anchors >= 5, 'must emit data-anchor-id on enough elements (got ' + anchors + ')');
});
test('renderMarkdown produces headings, lists, code, table, blockquote with anchors', () => {
const html = renderMarkdown(`# H1
## H2
- a
- b
1. one
2. two
| Col | Val |
|-----|-----|
| x | 1 |
\`\`\`
plain code
\`\`\`
> quote
`);
assert.match(html, /<h1 data-anchor-id="anch-0">H1<\/h1>/);
assert.match(html, /<h2 data-anchor-id="anch-1">H2<\/h2>/);
assert.match(html, /<ul><li data-anchor-id=/);
assert.match(html, /<ol><li data-anchor-id=/);
assert.match(html, /<table>[\s\S]*<th data-anchor-id=/);
assert.match(html, /<pre data-anchor-id=/);
assert.match(html, /<blockquote data-anchor-id=/);
});

View file

@ -1,122 +0,0 @@
// tests/scripts/render-artifact.test.mjs
// Covers scripts/render-artifact.mjs — the v5.0.0 self-contained HTML
// renderer that /trekbrief, /trekplan, /trekreview call at the end of their
// run to produce a browser-readable view of the just-written artifact.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/render-artifact.mjs';
const SAMPLE = `---
type: trekplan
plan_version: "1.7"
task: "Render-artifact smoke test"
slug: render-smoke
---
# Render-artifact smoke test
A paragraph with **bold**, \`inline code\`, and a [link](https://example.com).
## Steps
- top item
- nested item
- second top item
1. ordered one
2. ordered two
\`\`\`js
const x = 1;
\`\`\`
> a blockquote line
| Col A | Col B |
|-------|-------|
| 1 | 2 |
`;
test('buildHtml produces a complete self-contained HTML document', () => {
const html = buildHtml('plan.md', SAMPLE);
assert.ok(html.startsWith('<!DOCTYPE html>'), 'must start with doctype');
assert.ok(html.includes('</html>'), 'must close html');
assert.ok(html.includes('<style>'), 'must inline a stylesheet');
// Zero external network references.
assert.ok(!/<link[^>]+href=/i.test(html), 'no external <link> stylesheets');
assert.ok(!/<script[^>]+src=/i.test(html), 'no external <script src>');
assert.ok(!/https?:\/\/(?!example\.com)/.test(html.replace(/<style>[\s\S]*?<\/style>/, '')), 'no unexpected http(s) URLs outside example link');
});
test('buildHtml folds frontmatter into a <details> block', () => {
const html = buildHtml('plan.md', SAMPLE);
assert.ok(html.includes('<details class="frontmatter">'), 'frontmatter wrapped in <details>');
assert.ok(html.includes('plan_version'), 'frontmatter content preserved');
// Frontmatter must NOT leak into the rendered body as a literal "---" rule.
const bodyOnly = html.split('</details>')[1] || '';
assert.ok(!bodyOnly.startsWith('\n<hr>'), 'frontmatter fence should not become an <hr>');
});
test('buildHtml derives the <title> from frontmatter task', () => {
const html = buildHtml('plan.md', SAMPLE);
assert.match(html, /<title>Render-artifact smoke test<\/title>/);
});
test('renderMarkdown renders headings, code fences, lists, tables, blockquotes', () => {
const out = renderMarkdown(SAMPLE.split('---\n').slice(2).join('---\n'));
assert.match(out, /<h1>Render-artifact smoke test<\/h1>/);
assert.match(out, /<h2>Steps<\/h2>/);
assert.match(out, /<pre><code class="language-js">/);
assert.ok(out.includes('const x = 1;'), 'code fence body preserved');
assert.match(out, /<ul><li>top item<ul><li>nested item<\/li><\/ul><\/li>/);
assert.match(out, /<ol><li>ordered one<\/li><li>ordered two<\/li><\/ol>/);
assert.match(out, /<blockquote>a blockquote line<\/blockquote>/);
assert.match(out, /<table>[\s\S]*<th>Col A<\/th>[\s\S]*<td>1<\/td>[\s\S]*<\/table>/);
assert.match(out, /<strong>bold<\/strong>/);
assert.match(out, /<code>inline code<\/code>/);
assert.match(out, /<a href="https:\/\/example\.com">link<\/a>/);
});
test('renderMarkdown escapes HTML in body and code', () => {
const out = renderMarkdown('A <tag> & "quote".\n\n```\n<script>alert(1)</script>\n```\n');
assert.ok(!out.includes('<tag>'), 'raw tag escaped');
assert.ok(out.includes('&lt;tag&gt;'), 'tag rendered as entity');
assert.ok(out.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'code-fence content escaped');
});
test('render() is deterministic — two runs byte-identical', () => {
const dir = mkdtempSync(join(tmpdir(), 'render-artifact-'));
try {
const md = join(dir, 'plan.md');
writeFileSync(md, SAMPLE);
const out1 = render(md, join(dir, 'a.html'));
const out2 = render(md, join(dir, 'b.html'));
assert.ok(existsSync(out1) && existsSync(out2));
assert.equal(readFileSync(out1, 'utf-8'), readFileSync(out2, 'utf-8'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('render() defaults output to <input-basename>.html', () => {
const dir = mkdtempSync(join(tmpdir(), 'render-artifact-'));
try {
const md = join(dir, 'review.md');
writeFileSync(md, '# Review\n\nok\n');
const out = render(md);
assert.equal(out, join(dir, 'review.html'));
assert.ok(existsSync(out));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('parseArgs handles --out and positional input', () => {
assert.deepEqual(parseArgs(['x.md']), { input: 'x.md', out: null, help: false });
assert.deepEqual(parseArgs(['x.md', '--out', 'y.html']), { input: 'x.md', out: 'y.html', help: false });
assert.equal(parseArgs(['--help']).help, true);
});

View file

@ -152,3 +152,69 @@ test('validateBrief — wrong-type error message includes accepted set', () => {
assert.ok(/trekbrief/.test(wrongType.message));
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'));
});