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>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-13 14:04:28 +02:00
commit 8ea692bc60
15 changed files with 995 additions and 118 deletions

View file

@ -23,7 +23,7 @@
{ {
"name": "voyage", "name": "voyage",
"source": "./plugins/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): operator clicks any line, writes their own notes, copies a structured prompt, pastes back into Claude — Claude revises the .md."
}, },
{ {
"name": "linkedin-thought-leadership", "name": "linkedin-thought-leadership",

View file

@ -13,7 +13,7 @@ plugins/
llm-security/ v6.0.0 — Security scanning, auditing, threat modeling 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 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 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.2 — 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: operator clicks lines, writes their own notes (no Claude-generated suggestions in the loop), notes persist in localStorage, Copy Prompt button gathers them all, paste back, Claude revises .md. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 pointed at /playground document-critique (Claude-leads, wrong direction); v5.0.2 ships annotate.mjs (operator-leads, the actual ask).
shared/ 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 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,11 @@ Key commands: `/config-audit posture`, `/config-audit feature-gap`, `/config-aud
--- ---
### [Voyage](plugins/voyage/) `v5.0.1` ### [Voyage](plugins/voyage/) `v5.0.2`
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. `/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.
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.0.2 finally lands the annotation UX that v5.0.0 and v5.0.1 each missed: at the end of `/trekbrief`, `/trekplan`, and `/trekreview`, voyage now runs `scripts/annotate.mjs` against the just-written `.md` and prints a `file://<abs path>` link to a self-contained operator-annotation HTML. The operator opens it, **clicks any line of the document, writes their own note**, watches a sidebar of every note (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt with every note. They paste it back into Claude, Claude revises the `.md`. **The operator drives every annotation** — no Claude-generated suggestions in the loop. v5.0.1 (now superseded) had pointed at `/playground document-critique`, but that template pre-generates Claude's suggestions and asks the operator to approve/reject them — the 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. `scripts/annotate.mjs` is ~430 lines of self-contained `.mjs` (zero npm deps, zero external network, deterministic output) and replaces all of that with the *concept* the bespoke playground was reaching for but never achieved. See `plugins/voyage/CHANGELOG.md` § v5.0.0 + § v5.0.1 + § v5.0.2.
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`. 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 +94,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). - **`/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. - **`/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`. 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 +120,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 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) → [Full documentation](plugins/voyage/README.md) · [Migration guide](plugins/voyage/MIGRATION.md)

View file

@ -1,7 +1,7 @@
{ {
"name": "voyage", "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.", "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) and printing the file:// link — the operator clicks lines, adds their own notes, copies a structured prompt, pastes back, Claude revises the .md.",
"version": "5.0.1", "version": "5.0.2",
"author": { "author": {
"name": "Kjell Tore Guttormsen" "name": "Kjell Tore Guttormsen"
}, },

View file

@ -4,6 +4,58 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## 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 ## 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 **No new breaking changes beyond v5.0.0.** Forks that consumed

View file

@ -232,27 +232,30 @@ 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`. **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-annotation HTML (v5.0.2):** 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 built around the operator. The operator opens the file, clicks any line of the document, writes their own note in an inline textarea, hits Save. The sidebar collects every note (editable + deletable, sorted by line). Notes persist in `localStorage` per artifact path — refresh or browser-close doesn't lose work. A "Copy Prompt" button at the bottom assembles every note into one structured prompt; the operator copies it and pastes it back into Claude, and Claude revises the `.md` freehand from the notes. **The operator drives every annotation.** Claude does not pre-generate suggestions in this flow — that was the v5.0.1 path (via `/playground document-critique`), which inverted the direction the operator actually wanted. v5.0.2 makes the loop OPERATOR-LEADS, CLAUDE-REACTS, which is what was asked for all along. The v5.0.0 standalone `.html` render (`scripts/render-artifact.mjs`) was a read-only view that didn't afford annotation; the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 were 388 KB of broken UX. Both stay removed. See [CHANGELOG.md](CHANGELOG.md) § v5.0.2.
**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. **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. **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}/ .claude/projects/{YYYY-MM-DD}-{slug}/
brief.md ← trekbrief writes; everyone reads 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 research/*.md ← trekresearch writes; plan + architect read
architecture/ ← OPT-IN, owned by an opt-in upstream architect plugin (not bundled) architecture/ ← OPT-IN, owned by an opt-in upstream architect plugin (not bundled)
overview.md overview.md
gaps.md gaps.md
plan.md ← trekplan writes; trekexecute reads plan.md ← trekplan writes; trekexecute reads
plan.html ← trekplan annotates
progress.json ← trekexecute writes progress.json ← trekexecute writes
review.md ← trekreview writes; trekplan reads (Handover 6) 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. No code-level dependency between plugins — the contract is filesystem-level only.
@ -260,13 +263,13 @@ No code-level dependency between plugins — the contract is filesystem-level on
All artifacts in one project directory (default): All artifacts in one project directory (default):
- Project root: `.claude/projects/{YYYY-MM-DD}-{slug}/` - 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`) - `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) - `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`) - `sessions/session-*.md` (from `--decompose`)
- `progress.json` (from `/trekexecute --project`) - `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`) - `.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`): Legacy paths (still work without `--project`):

View file

@ -1,6 +1,6 @@
# trekplan — Brief, Research, Plan, Execute, Review, Continue # 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.0.2-blue)
![License](https://img.shields.io/badge/license-MIT-green) ![License](https://img.shields.io/badge/license-MIT-green)
![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple) ![Platform](https://img.shields.io/badge/platform-Claude%20Code-purple)
@ -19,7 +19,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 | | **`/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 | | **`/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`. 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 +503,65 @@ Both arguments are required. No interactive prompt — headless-safe.
--- ---
## Reviewing and annotating artifacts (v5.0.1) ## Reviewing and annotating artifacts (v5.0.2)
`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single `/trekbrief`, `/trekplan`, and `/trekreview` each end by running
copy-paste-ready command. After they finish, you see something like: `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, Click any line to add YOUR OWN note. The sidebar collects every note,
per-line approve/reject/comment annotations on the right, and a the "Copy Prompt" button gathers them into one structured prompt.
"Copy Prompt" button at the bottom. Copy the generated prompt, paste Paste that prompt back into this chat and Claude revises brief.md
it back here, and Claude revises brief.md from your notes. 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 You run `open` (or click the `file://` link in your terminal), the HTML
official `claude-plugins-official` `playground` skill takes over: it loads opens in your default browser. In the browser:
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:
- **Left:** the artifact, nicely formatted, with line numbers and per-line - **Left:** the artifact, rendered with line numbers. Hover any line — the
highlight bars for any line a suggestion targets. gutter shows a `+`. Click — an inline textarea appears under that line.
- **Right:** suggestion cards (Approve / Reject / Comment), filterable by Write your note, hit Save (or `⌘Enter`). The line gets a colored left
status, with a counter in the header. border + a number badge showing the annotation count on that line.
- **Bottom:** a live-updated prompt covering everything you approved or - **Right (sidebar):** every annotation you've made, sorted by line. Each
commented on, with a **Copy Prompt** button. card shows the line reference (click to scroll), the target text
snippet, and your note text. Click the note to edit it, click `✕` to
delete. A **Clear all** button at the top wipes the slate.
- **Bottom:** a structured prompt that updates live with every note. A
**Copy Prompt** button. Click — your clipboard now has the prompt.
- **Persistence:** every annotation is saved to browser `localStorage`
keyed on the artifact's absolute path. Refresh the tab or close the
browser and re-open — your notes are still there.
You mark what you want, click Copy Prompt, paste it back into Claude, and You write your notes, click Copy Prompt, paste back into Claude. Claude
Claude revises the `.md` artifact freehand from your notes. **One paste revises the `.md` freehand from your notes. **The operator drives every
→ playground → mark → copy → paste back.** annotation.** Claude never pre-generates suggestions in this flow.
> **What v5.0.1 changed from v5.0.0.** v5.0.0 had `/trekbrief`, `/trekplan`, > **What v5.0.2 changed from v5.0.1.** v5.0.0 added a read-only HTML
> and `/trekreview` each render their `.md` to a separate read-only `.html` > render that didn't afford annotation. v5.0.1 deleted that and pointed
> view via `scripts/render-artifact.mjs` *and* print a vague "run the > operators at `/playground document-critique` — but the `document-critique`
> `/playground` plugin" instruction. In practice the read-only `.html` was > template pre-generates **Claude's** suggestions and asks the operator to
> redundant with what `/playground` produces (which also shows the artifact > approve/reject them. That's "Claude leads, operator reacts" — the
> nicely formatted), and the instruction wasn't copy-paste-ready — operators > opposite of what was asked for. v5.0.2 ships `scripts/annotate.mjs`, a
> had to guess the right invocation. v5.0.1 drops `render-artifact.mjs` and > self-contained zero-dependency operator-annotation HTML generator. The
> its `.html` output, and makes the printed `/playground` invocation > operator clicks lines, writes their own notes, copies a structured prompt
> literal and copy-paste-ready. > with every note. No Claude-generated suggestions in the loop.
> >
> **Still removed from v5.0.0 onward:** the v4.2/v4.3 bespoke playground SPA, > **Still removed from v5.0.0 onward:** the v4.2/v4.3 bespoke playground SPA,
> `/trekrevise`, Handover 8 (annotation → revision), the supporting `lib/` > `/trekrevise`, Handover 8 (annotation → revision), the supporting `lib/`
> modules, and the Playwright e2e suite. See [CHANGELOG.md](CHANGELOG.md) > modules, and the Playwright e2e suite. See [CHANGELOG.md](CHANGELOG.md)
> § v5.0.0 and § v5.0.1. > § v5.0.0, § v5.0.1, and § v5.0.2.
--- ---
@ -685,7 +695,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. **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 ## Installation

View file

@ -483,34 +483,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, re-enter Phase 4 with the validator's hints in scope. If only warnings,
note them in the final report. note them in the final report.
**Report — single block, printed verbatim** (substitute the bracketed **Build the operator-annotation HTML, then print the report.** After the
fields, but keep the structure and the `/playground` invocation literal — brief is validated, run `scripts/annotate.mjs` to produce a self-contained
operators copy-paste that line directly into Claude). The `/playground` HTML file the operator opens in their browser. The HTML renders the brief
command points at the official `claude-plugins-official` `playground` with line numbers, lets the operator click any line to attach their own
skill, which loads its `document-critique` template, reads the brief, note (not Claude-generated suggestions — the operator drives every
generates per-line suggestions, and writes a single self-contained HTML annotation), keeps a sidebar of all notes, persists state in localStorage,
file that opens in the browser. The HTML has the brief on the left and exposes a "Copy Prompt" button that generates a single structured
(nicely formatted, line-numbered), suggestion cards on the right prompt with every note. The operator copies that prompt and pastes it
(Approve / Reject / Comment), and a "Copy Prompt" button at the bottom back into Claude; Claude revises `brief.md` freehand from the notes.
that gathers everything marked into one prompt. Paste that prompt back
into Claude — Claude then 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} Review iterations: {1..3}
Final quality: {complete | partial} Final quality: {complete | partial}
Validator: {PASS | warnings(N)} Validator: {PASS | warnings(N)}
Research topics identified: {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, Click any line to add YOUR OWN note. The sidebar collects every note,
per-line approve/reject/comment annotations on the right, and a the "Copy Prompt" button gathers them into one structured prompt.
"Copy Prompt" button at the bottom. Copy the generated prompt, paste Paste that prompt back into this chat and Claude revises brief.md
it back here, and Claude revises brief.md from your notes. from your notes. Annotations persist in your browser if you close
the tab and reopen the same file.
──────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────
``` ```

View file

@ -769,30 +769,43 @@ If the user asks questions or requests changes:
- Show what changed - Show what changed
- Re-present the summary - 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 After the plan summary, run `scripts/annotate.mjs` to produce a
`{plan_path}` with the absolute path). The `/playground` command must self-contained HTML the operator opens in their browser. The HTML renders
appear literally — operators copy-paste it directly into Claude. It `plan.md` with line numbers, lets the operator click any line to attach
points at the official `claude-plugins-official` `playground` skill, their own note (not Claude-generated suggestions — the operator drives
which loads its `document-critique` template, reads `plan.md`, generates every annotation), keeps a sidebar of all notes, persists state in
per-line suggestions, and writes a single self-contained HTML file that localStorage, and exposes a "Copy Prompt" button that generates a single
opens in the browser. The HTML has the plan on the left (nicely structured prompt with every note. The operator copies that prompt and
formatted, line-numbered), suggestion cards on the right (Approve / pastes it back into Claude; Claude revises `plan.md` freehand from the
Reject / Comment), and a "Copy Prompt" button at the bottom that gathers notes.
everything marked into one prompt. Paste that prompt back into Claude —
Claude then 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, open file://{$ANNOT_HTML}
per-line approve/reject/comment annotations on the right, and a
"Copy Prompt" button at the bottom. Copy the generated prompt, paste Click any line to add YOUR OWN note. The sidebar collects every note,
it back here, and Claude revises plan.md from your notes. 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.
──────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────
``` ```

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. If `${CLAUDE_PLUGIN_DATA}` is unset or not writable, skip stats silently.
Never let stats failures block the main workflow. 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`) ## Phase 8.5 — Validate-only mode (`--validate`)
When `mode == validate`: When `mode == validate`:
@ -282,6 +296,7 @@ After the write succeeds, print:
**Brief:** {brief_path} **Brief:** {brief_path}
**Project:** {project_dir} **Project:** {project_dir}
**Review:** {review_path} **Review:** {review_path}
**Annotation HTML:** file://{$ANNOT_HTML}
**Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files) **Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files)
**Verdict:** {BLOCK | WARN | ALLOW} **Verdict:** {BLOCK | WARN | ALLOW}
@ -297,14 +312,15 @@ After the write succeeds, print:
{up to 5 highest-severity findings} {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, Click any line to add YOUR OWN note. The sidebar collects every note,
per-line approve/reject/comment annotations on the right, and a the "Copy Prompt" button gathers them into one structured prompt.
"Copy Prompt" button at the bottom. Copy the generated prompt, paste Paste that prompt back into this chat and Claude revises review.md
it back here, and Claude revises review.md from your notes. from your notes. Annotations persist in your browser if you close
the tab and reopen the same file.
──────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────
You can also: You can also:

View file

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

View file

@ -1,13 +1,14 @@
{ {
"name": "voyage", "name": "voyage",
"version": "5.0.1", "version": "5.0.2",
"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.", "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) and printing the file:// link — the operator clicks lines, adds their own notes, copies a structured prompt, pastes back, Claude revises the .md.",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"scripts": { "scripts": {
"test": "node --test 'tests/**/*.test.mjs'", "test": "node --test 'tests/**/*.test.mjs'",
"annotate": "node scripts/annotate.mjs",
"verify": "bash verify.sh" "verify": "bash verify.sh"
}, },
"keywords": [ "keywords": [

View file

@ -0,0 +1,573 @@
#!/usr/bin/env node
// scripts/annotate.mjs
//
// Builds a self-contained operator-annotation HTML for a voyage artifact
// (brief.md / plan.md / review.md). The producing commands call this on
// their last step and print the file:// link; the operator opens it in
// a browser, clicks any line to attach their own note, watches a sidebar
// of all their annotations, and copies a single prompt (with every note)
// back into Claude. Claude revises the source .md from the notes.
//
// • Operator drives every annotation. No pre-generated suggestions.
// • Each annotation is anchored to the source line number (1-based).
// • Annotations persist in localStorage per artifact path — refresh
// does not lose work; closing the browser does not either.
// • Zero external network, zero npm deps, 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 taskMatch = frontmatter.match(/^task:\s*(.+)$/m);
if (taskMatch) return taskMatch[1].trim().replace(/^["']|["']$/g, '');
const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
if (slugMatch) return slugMatch[1].trim().replace(/^["']|["']$/g, '');
}
const h1 = mdText.match(/^#\s+(.+)$/m);
if (h1) return h1[1].trim();
return fallbackName;
}
function buildHtml(artifactPath, mdText) {
const lines = mdText.replace(/\r\n/g, '\n').split('\n');
const fileName = basename(artifactPath);
const title = deriveTitle(mdText, fileName);
const linesJson = JSON.stringify(lines);
const artifactPathJson = JSON.stringify(artifactPath);
const titleEsc = escapeHtml(title);
const fileNameEsc = escapeHtml(fileName);
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>' + titleEsc + ' — annotate</title>\n'
+ '<style>\n' + STYLE + '\n</style>\n'
+ '</head>\n'
+ '<body>\n'
+ '<div class="app">\n'
+ '<header class="topbar">\n'
+ ' <div class="hdr-meta">\n'
+ ' <h1>' + titleEsc + '</h1>\n'
+ ' <p class="path" title="' + escapeHtml(artifactPath) + '">' + fileNameEsc + '</p>\n'
+ ' </div>\n'
+ ' <div class="hdr-stats" id="stats"></div>\n'
+ '</header>\n'
+ '<main class="split">\n'
+ ' <section class="doc-panel">\n'
+ ' <div class="doc-help"><strong>Click any line</strong> to add your own annotation. Annotations are saved in your browser per artifact path.</div>\n'
+ ' <div class="doc-content" id="doc"></div>\n'
+ ' </section>\n'
+ ' <aside class="notes-panel">\n'
+ ' <div class="notes-header">\n'
+ ' <h3>Your annotations</h3>\n'
+ ' <button id="clear-all" class="ghost-btn" title="Remove all annotations">Clear all</button>\n'
+ ' </div>\n'
+ ' <div class="notes-list" id="notes-list"></div>\n'
+ ' </aside>\n'
+ '</main>\n'
+ '<section class="prompt-panel">\n'
+ ' <div class="prompt-head">\n'
+ ' <span class="prompt-title">Prompt for Claude</span>\n'
+ ' <button class="copy-btn" id="copy" disabled>Copy Prompt</button>\n'
+ ' </div>\n'
+ ' <pre class="prompt-body empty" id="prompt">Click a line and add a note to generate a prompt.</pre>\n'
+ '</section>\n'
+ '</div>\n'
+ '<script>\n'
+ 'const DOC_LINES = ' + linesJson + ';\n'
+ 'const ARTIFACT_PATH = ' + artifactPathJson + ';\n'
+ 'const ARTIFACT_NAME = ' + JSON.stringify(fileName) + ';\n'
+ APP_JS
+ '\n</script>\n'
+ '</body>\n'
+ '</html>\n';
}
// ---------------------------------------------------------------------------
// Stylesheet — design-system-aligned, light + dark, no external fonts/CDN.
// ---------------------------------------------------------------------------
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;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-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);
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 14px; line-height: 1.55; }
.app { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; }
header.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 12px 20px; 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-stats { display: flex; gap: 8px; font-size: 12px; color: var(--text-dim); }
.pill { padding: 2px 10px; border-radius: 99px; background: var(--bg-soft); border: 1px solid var(--border); }
.pill.accent { color: var(--accent); border-color: var(--accent); }
main.split { display: grid; grid-template-columns: 1fr 380px; overflow: hidden; min-height: 0; }
.doc-panel { overflow-y: auto; background: var(--bg); padding: 16px 24px 80px; }
.doc-help { font-size: 12px; color: var(--text-dim); background: var(--bg-soft);
border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; margin: 0 0 18px; }
.doc-help strong { color: var(--text); }
.doc-content { font-family: var(--mono); font-size: 13px; line-height: 1.6; }
.doc-line { display: grid; grid-template-columns: 44px 24px 1fr; gap: 6px;
padding: 1px 8px 1px 0; border-left: 3px solid transparent; cursor: pointer; transition: background .08s; }
.doc-line:hover { background: var(--accent-soft); }
.doc-line:hover .gutter { color: var(--accent); }
.doc-line .ln { color: var(--text-mute); text-align: right; user-select: none; font-size: 11px; padding-top: 2px; }
.doc-line .gutter { font-size: 13px; color: transparent; text-align: center; user-select: none; line-height: 1.6; }
.doc-line .content { white-space: pre-wrap; word-break: break-word; color: var(--text); }
.doc-line.annotated { border-left-color: var(--amber); background: var(--amber-soft); }
.doc-line.annotated .gutter { color: var(--amber); font-weight: 700; font-size: 11px; }
.doc-line.annotated:hover { background: var(--amber-soft); filter: brightness(0.97); }
.doc-line.active { outline: 2px solid var(--accent); outline-offset: -2px; }
.doc-line .heading-1 { color: var(--text); font-size: 18px; font-weight: 700; }
.doc-line .heading-2 { color: var(--text); font-size: 15px; font-weight: 700; padding-top: 6px; }
.doc-line .heading-3 { color: var(--text); font-size: 14px; font-weight: 650; }
.doc-line .ic { background: var(--bg-soft); padding: 0 4px; border-radius: 3px; font-size: 0.95em; }
.doc-line .strong { font-weight: 700; }
.doc-line .em { font-style: italic; color: var(--accent); }
.doc-line a { color: var(--accent); text-decoration: underline; }
.input-row { grid-column: 1 / -1; margin: 4px 0 8px 44px; padding: 8px;
background: var(--bg-elev); border: 1px solid var(--accent); border-radius: 6px; }
.input-row textarea { width: 100%; min-height: 60px; padding: 6px 8px;
font-family: var(--sans); font-size: 13px; line-height: 1.4; color: var(--text);
background: var(--bg); border: 1px solid var(--border); border-radius: 4px; resize: vertical; }
.input-row textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.input-row .input-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border);
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 500; 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 { filter: brightness(1.1); color: #fff; }
.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; filter: none; border-color: var(--border); }
.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border);
border-radius: 5px; padding: 4px 10px; font-family: inherit; font-size: 12px; cursor: pointer; }
.ghost-btn:hover { color: var(--red); border-color: var(--red); }
.notes-panel { border-left: 1px solid var(--border); background: var(--bg-elev);
display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
.notes-header { display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border); }
.notes-header h3 { font-size: 13px; font-weight: 650; margin: 0; color: var(--text); }
.notes-list { overflow-y: auto; padding: 12px; flex: 1; min-height: 0; }
.notes-empty { color: var(--text-mute); font-size: 12px; text-align: center; padding: 24px 8px;
font-style: italic; line-height: 1.5; }
.note-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 12px; margin-bottom: 10px; }
.note-card:hover { border-color: var(--border-strong); }
.note-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.note-card .note-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.note-card .lineref { font-family: var(--mono); font-size: 11px; color: var(--accent); cursor: pointer; font-weight: 600; }
.note-card .lineref:hover { text-decoration: underline; }
.note-card .delete-btn { background: transparent; border: none; color: var(--text-mute);
cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 4px; }
.note-card .delete-btn:hover { color: var(--red); background: var(--red-soft); }
.note-card .target { font-family: var(--mono); font-size: 11px; color: var(--text-mute);
margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-card .note-text { font-size: 12.5px; color: var(--text); line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
.note-card textarea { width: 100%; min-height: 60px; padding: 6px 8px;
font-family: var(--sans); font-size: 12.5px; line-height: 1.4; color: var(--text);
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 4px; resize: vertical; }
.note-card textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.note-card .edit-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
.prompt-panel { border-top: 1px solid var(--border); background: var(--bg-elev);
padding: 12px 20px 14px; display: flex; flex-direction: column; gap: 8px; max-height: 30vh; }
.prompt-head { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-dim); }
.prompt-title { font-weight: 600; color: var(--text); font-size: 13px; }
.prompt-body { flex: 1; min-height: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.5;
overflow-y: auto; white-space: pre-wrap; color: var(--text); margin: 0; }
.prompt-body.empty { color: var(--text-mute); font-style: italic; }
.copy-btn { background: var(--accent); color: #fff; border: none; border-radius: 5px;
padding: 6px 14px; font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.copy-btn:hover:not(:disabled) { filter: brightness(1.1); }
.copy-btn:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; }
.copy-btn.copied { background: var(--green); }
::-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); }
`.trim();
// ---------------------------------------------------------------------------
// Embedded JS app — operator annotation surface.
// Uses concatenation (no template literals) to avoid backtick collisions
// with the outer mjs string assembly.
// ---------------------------------------------------------------------------
const APP_JS = `
const STORAGE_KEY = 'voyage-annotate:' + ARTIFACT_PATH;
let state = { annotations: [], openInputLine: null, editingId: null, activeId: null };
let nextId = 1;
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data && Array.isArray(data.annotations)) {
state.annotations = data.annotations;
nextId = (data.nextId || data.annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1) || 1;
}
} catch (e) {}
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: state.annotations, nextId: nextId })); } catch (e) {}
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderInline(raw) {
// raw is the ALREADY-escaped line content
let s = raw;
s = s.replace(/\\\`([^\\\`]+)\\\`/g, '<span class="ic">$1</span>');
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<span class="strong">$1</span>');
s = s.replace(/(^|[\\s])\\*([^*\\s][^*]*?)\\*(?=\\s|[.,;:!?]|$)/g, '$1<span class="em">$2</span>');
s = s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, function(m, t, h) {
const safe = /^(https?:|mailto:|#)/i.test(h) ? h : '#';
return '<a href="' + safe + '" target="_blank" rel="noopener">' + t + '</a>';
});
return s;
}
function classifyLine(raw) {
if (/^#{1,6}\\s/.test(raw)) {
const m = raw.match(/^(#{1,6})\\s+(.*)$/);
return { kind: 'heading', level: m[1].length, content: m[2] };
}
if (raw.trim() === '') return { kind: 'blank' };
return { kind: 'text', content: raw };
}
function renderDocLine(raw, lineNum) {
const cls = classifyLine(raw);
const escaped = escapeHtml(cls.content || raw);
let inner = '';
if (cls.kind === 'heading') {
inner = '<span class="heading-' + Math.min(cls.level, 3) + '">' + renderInline(escaped) + '</span>';
} else if (cls.kind === 'blank') {
inner = '&nbsp;';
} else {
inner = renderInline(escaped);
}
return inner;
}
function getAnnotationsForLine(lineNum) {
return state.annotations.filter(function(a){ return a.line === lineNum; });
}
function renderDoc() {
const root = document.getElementById('doc');
const html = DOC_LINES.map(function(raw, i) {
const lineNum = i + 1;
const anns = getAnnotationsForLine(lineNum);
const annotated = anns.length > 0 ? ' annotated' : '';
const active = state.activeId && anns.some(function(a){return a.id === state.activeId;}) ? ' active' : '';
const gutter = anns.length > 0 ? String(anns.length) : '';
const content = renderDocLine(raw, lineNum);
let row = '<div class="doc-line' + annotated + active + '" data-line="' + lineNum + '" id="ln-' + lineNum + '">'
+ '<span class="ln">' + lineNum + '</span>'
+ '<span class="gutter">' + (gutter || '+') + '</span>'
+ '<span class="content">' + content + '</span>'
+ '</div>';
if (state.openInputLine === lineNum) {
const placeholder = anns.length > 0 ? 'Add another note for line ' + lineNum + '...' : 'Your note for line ' + lineNum + '...';
row += '<div class="input-row" data-input-line="' + lineNum + '">'
+ '<textarea id="input-' + lineNum + '" placeholder="' + escapeHtml(placeholder) + '" autofocus></textarea>'
+ '<div class="input-actions">'
+ '<button class="btn" data-act="cancel-input">Cancel (Esc)</button>'
+ '<button class="btn primary" data-act="save-input" data-line="' + lineNum + '" disabled>Save (⌘Enter)</button>'
+ '</div></div>';
}
return row;
}).join('');
root.innerHTML = html;
root.querySelectorAll('.doc-line').forEach(function(el) {
el.addEventListener('click', function(e) {
if (e.target.closest('.input-row')) return;
const ln = parseInt(el.getAttribute('data-line'), 10);
state.openInputLine = state.openInputLine === ln ? null : ln;
state.activeId = null;
renderAll();
if (state.openInputLine) {
setTimeout(function(){
const ta = document.getElementById('input-' + ln);
if (ta) ta.focus();
}, 0);
}
});
});
root.querySelectorAll('button[data-act]').forEach(function(b) {
b.addEventListener('click', function(e) {
e.stopPropagation();
const act = b.dataset.act;
if (act === 'cancel-input') { state.openInputLine = null; renderAll(); }
else if (act === 'save-input') {
const ln = parseInt(b.dataset.line, 10);
const ta = document.getElementById('input-' + ln);
const text = ta.value.trim();
if (!text) return;
addAnnotation(ln, text);
}
});
});
root.querySelectorAll('textarea[id^="input-"]').forEach(function(ta) {
ta.addEventListener('input', function() {
const ln = parseInt(ta.id.replace('input-', ''), 10);
const saveBtn = document.querySelector('button[data-act="save-input"][data-line="' + ln + '"]');
if (saveBtn) saveBtn.disabled = ta.value.trim().length === 0;
});
ta.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { state.openInputLine = null; renderAll(); }
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const ln = parseInt(ta.id.replace('input-', ''), 10);
const text = ta.value.trim();
if (text) addAnnotation(ln, text);
}
});
});
}
function addAnnotation(lineNum, text) {
const raw = DOC_LINES[lineNum - 1] || '';
const snippet = raw.trim().length > 80 ? raw.trim().slice(0, 77) + '…' : raw.trim();
const a = { id: nextId++, line: lineNum, target: snippet || '(blank line)', text: text, ts: new Date().toISOString() };
state.annotations.push(a);
state.openInputLine = null;
state.activeId = a.id;
save();
renderAll();
setTimeout(function(){
const card = document.getElementById('card-' + a.id);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
function deleteAnnotation(id) {
state.annotations = state.annotations.filter(function(a){ return a.id !== id; });
if (state.activeId === id) state.activeId = null;
if (state.editingId === id) state.editingId = null;
save();
renderAll();
}
function updateAnnotation(id, text) {
const a = state.annotations.find(function(x){ return x.id === id; });
if (!a) return;
if (!text.trim()) { deleteAnnotation(id); return; }
a.text = text.trim();
a.ts = new Date().toISOString();
state.editingId = null;
save();
renderAll();
}
function renderNotesList() {
const root = document.getElementById('notes-list');
if (state.annotations.length === 0) {
root.innerHTML = '<div class="notes-empty">No annotations yet.<br><br>Click any line on the left to add your first note.</div>';
return;
}
const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; });
root.innerHTML = sorted.map(function(a) {
const active = a.id === state.activeId ? ' active' : '';
const editing = a.id === state.editingId;
return '<div class="note-card' + active + '" id="card-' + a.id + '">'
+ '<div class="note-head">'
+ '<span class="lineref" data-jump="' + a.line + '">Line ' + a.line + '</span>'
+ '<button class="delete-btn" data-del="' + a.id + '" title="Delete">✕</button>'
+ '</div>'
+ '<p class="target">' + escapeHtml(a.target) + '</p>'
+ (editing
? '<textarea id="edit-' + a.id + '">' + escapeHtml(a.text) + '</textarea>'
+ '<div class="edit-actions">'
+ '<button class="btn" data-act="cancel-edit">Cancel</button>'
+ '<button class="btn primary" data-act="save-edit" data-id="' + a.id + '">Save</button>'
+ '</div>'
: '<div class="note-text" data-edit="' + a.id + '" title="Click to edit">' + escapeHtml(a.text) + '</div>')
+ '</div>';
}).join('');
root.querySelectorAll('.lineref[data-jump]').forEach(function(el) {
el.addEventListener('click', function() {
const ln = parseInt(el.dataset.jump, 10);
const target = document.getElementById('ln-' + ln);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
const id = parseInt(el.closest('.note-card').id.replace('card-', ''), 10);
state.activeId = id;
renderAll();
});
});
root.querySelectorAll('button[data-del]').forEach(function(b) {
b.addEventListener('click', function() {
if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10));
});
});
root.querySelectorAll('.note-text[data-edit]').forEach(function(el) {
el.addEventListener('click', function() {
state.editingId = parseInt(el.dataset.edit, 10);
renderAll();
setTimeout(function(){
const ta = document.getElementById('edit-' + state.editingId);
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
}, 0);
});
});
root.querySelectorAll('button[data-act="cancel-edit"]').forEach(function(b) {
b.addEventListener('click', function() { state.editingId = null; renderAll(); });
});
root.querySelectorAll('button[data-act="save-edit"]').forEach(function(b) {
b.addEventListener('click', function() {
const id = parseInt(b.dataset.id, 10);
const ta = document.getElementById('edit-' + id);
updateAnnotation(id, ta.value);
});
});
}
function renderStats() {
const n = state.annotations.length;
document.getElementById('stats').innerHTML = n === 0
? '<span class="pill">No annotations yet</span>'
: '<span class="pill accent">' + n + ' annotation' + (n === 1 ? '' : 's') + '</span>';
const copyBtn = document.getElementById('copy');
copyBtn.disabled = n === 0;
}
function renderPrompt() {
const out = document.getElementById('prompt');
if (state.annotations.length === 0) {
out.classList.add('empty');
out.textContent = 'Click a line and add a note to generate a prompt.';
return;
}
out.classList.remove('empty');
const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; });
let p = 'Please revise the voyage artifact at \`' + ARTIFACT_PATH + '\` with the operator annotations below.\\n';
p += 'Each annotation is anchored to a specific line of the source markdown.\\n';
p += 'Treat the operator notes as authoritative direction for what should change.\\n\\n';
p += '## Annotations (' + state.annotations.length + ' total)\\n\\n';
for (let i = 0; i < sorted.length; i++) {
const a = sorted[i];
p += '### Line ' + a.line + '\\n';
p += 'Source: ' + a.target + '\\n';
p += 'Operator note: ' + a.text + '\\n\\n';
}
out.textContent = p;
}
document.getElementById('copy').addEventListener('click', async function() {
const txt = document.getElementById('prompt').textContent;
try {
await navigator.clipboard.writeText(txt);
const btn = document.getElementById('copy');
btn.classList.add('copied');
const old = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function(){ btn.classList.remove('copied'); btn.textContent = old; }, 1500);
} catch (e) { alert('Copy failed: ' + e.message); }
});
document.getElementById('clear-all').addEventListener('click', function() {
if (state.annotations.length === 0) return;
if (confirm('Remove all ' + state.annotations.length + ' annotations? This cannot be undone.')) {
state.annotations = []; state.activeId = null; state.editingId = null;
save(); renderAll();
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && state.openInputLine !== null) {
state.openInputLine = null;
renderAll();
}
});
function renderAll() {
renderDoc();
renderNotesList();
renderStats();
renderPrompt();
}
load();
renderAll();
`.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, clicks lines to attach\n'
+ 'their own notes, copies a structured prompt, pastes back into\n'
+ 'Claude. 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, parseArgs };

View file

@ -430,42 +430,67 @@ test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)',
assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain'); 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( assert.ok(
!existsSync(join(ROOT, 'scripts/render-artifact.mjs')), !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', () => { test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => {
// The exact substring must appear in each producing command's prose so the assert.ok(
// operator copy-pastes a verbatim line. Drift on this is the friction point existsSync(join(ROOT, 'scripts/annotate.mjs')),
// that motivated v5.0.1 — fail loudly if the prose softens back to "run the 'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML',
// /playground plugin" without the literal command. );
const REQUIRED = '/playground build a document-critique playground for'; });
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']) { for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
assert.ok( assert.ok(
read(`commands/${f}`).includes(REQUIRED), read(`commands/${f}`).includes('scripts/annotate.mjs'),
`commands/${f} must include the literal invocation "${REQUIRED}" so the operator copy-pastes it directly (v5.0.1)`, `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']) { for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
assert.ok( assert.ok(
!read(`commands/${f}`).includes('render-artifact.mjs'), !read(`commands/${f}`).includes('/playground build a document-critique'),
`commands/${f} still references scripts/render-artifact.mjs — that script was removed in v5.0.1`, `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('package.json still has no "npm run render" script (removed in v5.0.1)', () => {
const pkg = JSON.parse(read('package.json')); const pkg = JSON.parse(read('package.json'));
assert.equal( assert.equal(
pkg.scripts && pkg.scripts.render, pkg.scripts && pkg.scripts.render,
undefined, undefined,
'package.json scripts.render should be gone in v5.0.1', 'package.json scripts.render should remain gone',
); );
}); });
@ -479,6 +504,11 @@ test('CHANGELOG.md has v5.0.1 entry', () => {
assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## 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)', () => { test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => {
const cl = read('CHANGELOG.md'); const cl = read('CHANGELOG.md');
assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry'); assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry');

View file

@ -0,0 +1,167 @@
// tests/scripts/annotate.test.mjs
// Covers scripts/annotate.mjs — the v5.0.2 operator-annotation HTML
// generator. The producing commands call it to print a file:// link the
// operator opens in a browser, where they click lines, write their own
// notes, copy a structured prompt, and paste back into Claude.
//
// What we pin:
// • Output is a complete, self-contained HTML document.
// • Zero external network references in the static HTML.
// • The embedded inline <script> parses as valid JavaScript.
// • Source document content + artifact path are embedded verbatim
// (so the browser-side app can render exactly the same lines).
// • HTML special chars and code-fence content are escaped — no raw
// <script>-injection from the source .md.
// • render() is deterministic — two runs produce byte-identical output.
// • Default output path is <input-basename>.html next to the input.
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, 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);
// Strip the script section (the embedded clipboard-API safe link list may
// technically include example.com in inline content; that's allowed).
// Static HTML structure must not have any <link href=...> or <script src=...>.
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');
// Function() parses but doesn't execute references to document/localStorage,
// so a SyntaxError here is the only failure mode we want to surface.
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
});
test('buildHtml embeds the artifact path and source lines verbatim', () => {
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');
// The DOC_LINES JSON literal must contain the actual content of the source.
assert.match(html, /DOC_LINES\s*=\s*\[/);
assert.ok(html.includes('Operator-annotation smoke test'),
'source document content must round-trip into the embedded DOC_LINES');
assert.ok(html.includes('inline code'),
'inline content from the source markdown must appear in DOC_LINES');
});
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);
// The raw <script> from the title must NOT appear unescaped anywhere
// outside our own inline <script>.
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.ok(titleMatch[1].includes('&lt;script&gt;') || titleMatch[1].includes('alert'),
'title must be HTML-escaped');
});
test('buildHtml escapes source-document content so a malicious .md cannot inject code', () => {
// The embedded DOC_LINES is a JSON literal, which already escapes anything
// dangerous. But the script then renders content into the DOM — the inline
// renderer escapes everything before DOM-insertion. This test pins the
// JSON-level escaping invariant.
const md = '# Heading\n\n<img src=x onerror="alert(1)">\n';
const html = buildHtml('/abs/path/brief.md', md);
// The dangerous attribute must NOT appear as a live HTML construct outside
// a JSON string. Easiest pin: the literal substring should appear inside
// quoted JSON (with backslash-escaped quotes), and the raw construct
// `onerror="alert(1)"` should not appear with unescaped double quotes
// outside the script-embedded JSON literal.
const m = html.match(/<script>([\s\S]*?)<\/script>/);
assert.ok(m, 'must contain script');
// The JSON encoding of `"` is `\"`, so we expect at least the escaped form.
assert.ok(m[1].includes('onerror=\\"alert(1)\\"') || m[1].includes("onerror=\\'alert(1)\\'"),
'dangerous source attribute should be JSON-escaped in DOC_LINES');
// Static HTML outside <script> must not carry the live attribute either.
const outsideScript = html.replace(/<script>[\s\S]*?<\/script>/, '');
assert.ok(!/onerror\s*=\s*"alert/i.test(outsideScript),
'static HTML must not carry a live onerror attribute');
});
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 output contains the operator-driven copy-paste loop affordances', () => {
// Pin the user-visible affordances that v5.0.2 promised:
// - Per-line click target (gutter '+' marker)
// - "Your annotations" sidebar
// - "Copy Prompt" button
// - "Clear all" button (so the operator can reset state)
// - localStorage persistence
const html = buildHtml('/abs/path/brief.md', SAMPLE);
assert.ok(html.includes('Your annotations'), 'must show a "Your annotations" sidebar');
assert.ok(html.includes('Copy Prompt'), 'must have a "Copy Prompt" button');
assert.ok(html.includes('Clear all'), 'must have a "Clear all" affordance');
assert.ok(html.includes('localStorage'), 'must persist state in localStorage');
assert.ok(html.includes('Click any line'), 'must tell the operator how to start');
});