Compare commits

..

14 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
51 changed files with 2320 additions and 176 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. /trekbrief, /trekplan, and /trekreview each end by printing a copy-paste-ready /playground document-critique invocation for the produced artifact — one paste launches an interactive annotation HTML in the browser."
"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.1 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a literal copy-paste-ready `/playground build a document-critique playground for {artifact_path}` invocation — one paste launches an interactive annotation HTML, Copy Prompt button returns the operator notes to Claude, Claude revises the .md. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 dropped the redundant standalone HTML render (`render-artifact.mjs`) and made the /playground invocation literal.
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.1`
### [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.1 finishes the annotation-UX work that v5.0.0 started: at the end of `/trekbrief`, `/trekplan`, and `/trekreview` the operator now gets a single boxed, copy-paste-ready line — `/playground build a document-critique playground for {artifact_path}` — that launches the official `claude-plugins-official` `playground` skill's `document-critique` template. One paste builds a self-contained HTML with the artifact on the left, per-line Approve/Reject/Comment cards on the right, and a Copy Prompt button at the bottom; copy the generated prompt, paste back, Claude revises the `.md` freehand. v5.0.1 also dropped the v5.0.0 stop-gap `scripts/render-artifact.mjs` (a separate read-only HTML render) and the `npm run render` alias — they were redundant with what `/playground` produces. v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, 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 — a browser walkthrough had found the bespoke surface borderline unusable, and it duplicated the official `/playground` plugin. Forks depending on the removed surfaces migrate to `/playground`. See `plugins/voyage/CHANGELOG.md` § v5.0.0 + § v5.0.1.
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,7 +96,7 @@ 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 end by printing a single boxed `/playground build a document-critique playground for {artifact_path}` line. The operator copy-pastes that into Claude, the official `playground` skill (`document-critique` template) builds an interactive HTML, the operator marks Approve/Reject/Comment + clicks Copy Prompt, the operator pastes the prompt back, Claude revises the `.md`.
`/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`, `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`.
@ -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 · Copy-paste-ready `/playground` annotation invocation · 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. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.",
"version": "5.0.1",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any 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,227 @@ 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

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,27 +236,32 @@ 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`.
**Post-command annotation invocation (v5.0.1):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` prints — verbatim — a copy-paste-ready `/playground` command pointing at the just-written artifact. Concretely: `/playground build a document-critique playground for {abs_path}/{artifact}.md`. When the operator pastes that into Claude, the official `claude-plugins-official` `playground` skill loads its `document-critique` template, reads the `.md`, generates per-line suggestions, and writes a single self-contained HTML file with the artifact nicely formatted on the left, Approve/Reject/Comment cards on the right, and a "Copy Prompt" button at the bottom. The operator marks suggestions, clicks Copy Prompt, pastes the prompt back into Claude — Claude revises the artifact freehand from the notes. **One paste → playground → mark → copy → paste back.** The v5.0.0 standalone `.html` render (`scripts/render-artifact.mjs`) was a separate read-only view that did not afford annotation; v5.0.1 dropped it because it was redundant with the `/playground` HTML (which already shows the artifact nicely) and the two HTMLs created operator confusion. The v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 — all removed in v5.0.0 — were never re-introduced. See [CHANGELOG.md](CHANGELOG.md) § v5.0.1.
**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 end by printing a copy-paste-ready `/playground build a document-critique playground for {artifact_path}` command — the operator pastes it into Claude to launch the official `playground` skill (`document-critique` template), which builds an interactive HTML for browser annotation. All `.md` 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 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 annotates
progress.json ← trekexecute writes
review.md ← trekreview writes; trekplan reads (Handover 6)
review.html ← trekreview annotates
```
When an operator runs the `/playground build a document-critique playground for <artifact>` command that the producing commands print, the `playground` skill writes the resulting HTML into its own working directory (typically next to the project dir or in `/tmp/`); voyage does not own the `.html` filename. The annotation HTML is ephemeral — re-built on each invocation from the current `.md` source.
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.
@ -260,13 +269,13 @@ No code-level dependency between plugins — the contract is filesystem-level on
All artifacts in one project directory (default):
- Project root: `.claude/projects/{YYYY-MM-DD}-{slug}/`
- `brief.md` (task brief from `/trekbrief`)
- `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` (from `/trekplan --project`)
- `plan.md` + `plan.html` (from `/trekplan --project`)
- `sessions/session-*.md` (from `--decompose`)
- `progress.json` (from `/trekexecute --project`)
- `review.md` (from `/trekreview --project`)
- `review.md` + `review.html` (from `/trekreview --project`)
- `.session-state.local.json` (Handover 7 — gitignored via `*.local.json`; written by `/trekexecute` Phase 8/2.55/4 or `/trekendsession`; read by `/trekcontinue`)
Legacy paths (still work without `--project`):

View file

@ -1,6 +1,6 @@
# trekplan — Brief, Research, Plan, Execute, Review, Continue
![Version](https://img.shields.io/badge/version-5.0.1-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,7 +21,7 @@ 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` each end by printing a single copy-paste-ready `/playground build a document-critique playground for <artifact_path>` command. Paste it into Claude, the official `playground` skill builds a browser-readable HTML with per-line Approve/Reject/Comment annotations, copy the generated prompt, paste back — Claude revises the artifact. See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v501).
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written artifact and printing the resulting `file://<abs path>` link. The operator opens the HTML in a browser, clicks any line of the document, writes their own note in the inline textarea, watches a sidebar of all notes (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt that they paste back into Claude — Claude then revises the `.md` from the notes. **The operator drives every annotation.** See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v502).
Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, and `review.md`.
@ -503,55 +505,81 @@ Both arguments are required. No interactive prompt — headless-safe.
---
## Reviewing and annotating artifacts (v5.0.1)
## Reviewing and annotating artifacts (v5.0.3)
`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single
copy-paste-ready command. After they finish, you see something like:
`/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:
```
Brief written: .claude/projects/2026-05-13-foo/brief.md
Annotation HTML: file:///abs/path/.claude/projects/2026-05-13-foo/brief.html
────────────────────────────────────────────────────────────────────
To review and annotate this brief, copy and paste this into Claude:
To review and annotate this brief, open the HTML above in a browser:
/playground build a document-critique playground for .claude/projects/2026-05-13-foo/brief.md
open file:///abs/path/.claude/projects/2026-05-13-foo/brief.html
That builds a self-contained HTML file with the brief on the left,
per-line approve/reject/comment annotations on the right, and a
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
it back here, and Claude revises brief.md from your notes.
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.
────────────────────────────────────────────────────────────────────
```
You copy that one `/playground …` line, paste it into Claude, and the
official `claude-plugins-official` `playground` skill takes over: it loads
its `document-critique` template, reads the `.md`, analyses it, generates
per-line suggestions, and writes a single self-contained HTML file that
opens in your browser. In the browser:
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`:
- **Left:** the artifact, nicely formatted, with line numbers and per-line
highlight bars for any line a suggestion targets.
- **Right:** suggestion cards (Approve / Reject / Comment), filterable by
status, with a counter in the header.
- **Bottom:** a live-updated prompt covering everything you approved or
commented on, with a **Copy Prompt** button.
- **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 mark what you want, click Copy Prompt, paste it back into Claude, and
Claude revises the `.md` artifact freehand from your notes. **One paste
→ playground → mark → copy → paste back.**
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.1 changed from v5.0.0.** v5.0.0 had `/trekbrief`, `/trekplan`,
> and `/trekreview` each render their `.md` to a separate read-only `.html`
> view 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 (which also shows the artifact
> nicely formatted), and the instruction wasn't copy-paste-ready — operators
> had to guess the right invocation. v5.0.1 drops `render-artifact.mjs` and
> its `.html` output, and makes the printed `/playground` invocation
> literal and copy-paste-ready.
>
> **Still removed from v5.0.0 onward:** the v4.2/v4.3 bespoke playground SPA,
> `/trekrevise`, Handover 8 (annotation → revision), the supporting `lib/`
> modules, and the Playwright e2e suite. See [CHANGELOG.md](CHANGELOG.md)
> § v5.0.0 and § v5.0.1.
> **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.
---
@ -685,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.
**Annotation requires the official `/playground` skill.** Voyage no longer ships an annotation UI of its own. The pasted `/playground build a document-critique playground for …` command depends on the `claude-plugins-official` `playground` skill being installed in the operator's environment. If it isn't, paste the artifact source `.md` into Claude with a "review this" prompt and revise freehand — the same end-result, just without the visual approve/reject/comment surface.
**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,34 +595,46 @@ 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.
**Report — single block, printed verbatim** (substitute the bracketed
fields, but keep the structure and the `/playground` invocation literal —
operators copy-paste that line directly into Claude). The `/playground`
command points at the official `claude-plugins-official` `playground`
skill, which loads its `document-critique` template, reads the brief,
generates per-line suggestions, and writes a single self-contained HTML
file that opens in the browser. The HTML has the brief on the left
(nicely formatted, line-numbered), suggestion cards on the right
(Approve / Reject / Comment), and a "Copy Prompt" button at the bottom
that gathers everything marked into one prompt. Paste that prompt back
into Claude — Claude then revises `brief.md` freehand from the notes.
**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
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.
```
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`):
```
Brief written: {PROJECT_DIR}/brief.md
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 review and annotate this brief, copy and paste this into Claude:
To review and annotate this brief, open the HTML above in a browser:
/playground build a document-critique playground for {PROJECT_DIR}/brief.md
open file://{$ANNOT_HTML}
That builds a self-contained HTML file with the brief on the left,
per-line approve/reject/comment annotations on the right, and a
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
it back here, and Claude revises brief.md from your notes.
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.
────────────────────────────────────────────────────────────────────
```

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,30 +769,43 @@ If the user asks questions or requests changes:
- Show what changed
- Re-present the summary
### Print the annotation invocation
### Build the operator-annotation HTML and print the link
After the plan summary, print this block **verbatim** (substituting only
`{plan_path}` with the absolute path). The `/playground` command must
appear literally — operators copy-paste it directly into Claude. It
points at the official `claude-plugins-official` `playground` skill,
which loads its `document-critique` template, reads `plan.md`, generates
per-line suggestions, and writes a single self-contained HTML file that
opens in the browser. The HTML has the plan on the left (nicely
formatted, line-numbered), suggestion cards on the right (Approve /
Reject / Comment), and a "Copy Prompt" button at the bottom that gathers
everything marked into one prompt. Paste that prompt back into Claude —
Claude then revises `plan.md` freehand from the notes.
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
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{plan_path}" 2>&1)
# stdout is the absolute path to the .html on success.
```
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`):
```
────────────────────────────────────────────────────────────────────
To review and annotate this plan, copy and paste this into Claude:
Plan written: {plan_path}
Annotation HTML: file://{$ANNOT_HTML}
/playground build a document-critique playground for {plan_path}
To review and annotate the plan, open it in a browser:
That builds a self-contained HTML file with the plan on the left,
per-line approve/reject/comment annotations on the right, and a
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
it back here, and Claude revises plan.md from your notes.
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.
────────────────────────────────────────────────────────────────────
```
@ -881,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,6 +262,20 @@ 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.
**Build the operator-annotation HTML.** After stats land, run:
```bash
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1)
```
`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`)
When `mode == validate`:
@ -282,6 +296,7 @@ After the write succeeds, print:
**Brief:** {brief_path}
**Project:** {project_dir}
**Review:** {review_path}
**Annotation HTML:** file://{$ANNOT_HTML}
**Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files)
**Verdict:** {BLOCK | WARN | ALLOW}
@ -297,14 +312,15 @@ After the write succeeds, print:
{up to 5 highest-severity findings}
────────────────────────────────────────────────────────────────────
To review and annotate this review, copy and paste this into Claude:
To review and annotate the review, open it in a browser:
/playground build a document-critique playground for {review_path}
open file://{$ANNOT_HTML}
That builds a self-contained HTML file with the review on the left,
per-line approve/reject/comment annotations on the right, and a
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
it back here, and Claude revises review.md from your notes.
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:
@ -341,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.1",
"version": "5.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voyage",
"version": "5.0.1",
"version": "5.1.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -1,13 +1,14 @@
{
"name": "voyage",
"version": "5.0.1",
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.",
"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'",
"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,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

@ -430,42 +430,85 @@ 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 no longer exists (removed in v5.0.1)', () => {
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 should be deleted — v5.0.1 drops the redundant standalone HTML render in favour of the /playground document-critique invocation printed by the producing commands',
'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 print a literal /playground document-critique invocation', () => {
// The exact substring must appear in each producing command's prose so the
// operator copy-pastes a verbatim line. Drift on this is the friction point
// that motivated v5.0.1 — fail loudly if the prose softens back to "run the
// /playground plugin" without the literal command.
const REQUIRED = '/playground build a document-critique playground for';
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(REQUIRED),
`commands/${f} must include the literal invocation "${REQUIRED}" so the operator copy-pastes it directly (v5.0.1)`,
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 no longer reference the removed scripts/render-artifact.mjs', () => {
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('render-artifact.mjs'),
`commands/${f} still references scripts/render-artifact.mjs — that script was removed in v5.0.1`,
!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('package.json no longer has an "npm run render" script (removed in v5.0.1)', () => {
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 be gone in v5.0.1',
'package.json scripts.render should remain gone',
);
});
@ -479,6 +522,11 @@ test('CHANGELOG.md has v5.0.1 entry', () => {
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');
@ -503,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

@ -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'));
});