From 8ea692bc600bfe85cfdfe255cb3c0dbcf3ace734 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 13 May 2026 14:04:28 +0200 Subject: [PATCH] =?UTF-8?q?chore(voyage):=20release=20v5.0.2=20=E2=80=94?= =?UTF-8?q?=20operator-driven=20annotation=20HTML=20(scripts/annotate.mjs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 2 +- README.md | 8 +- plugins/voyage/.claude-plugin/plugin.json | 4 +- plugins/voyage/CHANGELOG.md | 52 ++ plugins/voyage/CLAUDE.md | 15 +- plugins/voyage/README.md | 82 +-- plugins/voyage/commands/trekbrief.md | 52 +- plugins/voyage/commands/trekplan.md | 49 +- plugins/voyage/commands/trekreview.md | 28 +- plugins/voyage/package-lock.json | 4 +- plugins/voyage/package.json | 5 +- plugins/voyage/scripts/annotate.mjs | 573 ++++++++++++++++++ .../voyage/tests/lib/doc-consistency.test.mjs | 60 +- .../voyage/tests/scripts/annotate.test.mjs | 167 +++++ 15 files changed, 990 insertions(+), 113 deletions(-) create mode 100644 plugins/voyage/scripts/annotate.mjs create mode 100644 plugins/voyage/tests/scripts/annotate.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c7f26c8..fc063f9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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): operator clicks any line, writes their own notes, copies a structured prompt, pastes back into Claude — Claude revises the .md." }, { "name": "linkedin-thought-leadership", diff --git a/CLAUDE.md b/CLAUDE.md index 9d2e48f..03c17c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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.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/ 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 diff --git a/README.md b/README.md index a2911de..3c1b6a2 100644 --- a/README.md +++ b/README.md @@ -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. -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://` 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`. @@ -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). - **`/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://` 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 ` 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 -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) diff --git a/plugins/voyage/.claude-plugin/plugin.json b/plugins/voyage/.claude-plugin/plugin.json index 42c6106..82a017b 100644 --- a/plugins/voyage/.claude-plugin/plugin.json +++ b/plugins/voyage/.claude-plugin/plugin.json @@ -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) 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.2", "author": { "name": "Kjell Tore Guttormsen" }, diff --git a/plugins/voyage/CHANGELOG.md b/plugins/voyage/CHANGELOG.md index c5ad231..e7ba3ce 100644 --- a/plugins/voyage/CHANGELOG.md +++ b/plugins/voyage/CHANGELOG.md @@ -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/). +## 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 ``, writes `.html` (or `--out `). Self-contained, design-system-aligned (light + dark + print), zero external network, deterministic. CLI: `node scripts/annotate.mjs [--out ]`. Also `npm run annotate -- `. +- **`tests/scripts/annotate.test.mjs`** (10 tests) — self-contained HTML shape, no external ``/`\n' + + '\n' + + '\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, '&').replace(//g, '>').replace(/"/g, '"'); +} +function renderInline(raw) { + // raw is the ALREADY-escaped line content + let s = raw; + s = s.replace(/\\\`([^\\\`]+)\\\`/g, '$1'); + s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '$1'); + s = s.replace(/(^|[\\s])\\*([^*\\s][^*]*?)\\*(?=\\s|[.,;:!?]|$)/g, '$1$2'); + s = s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, function(m, t, h) { + const safe = /^(https?:|mailto:|#)/i.test(h) ? h : '#'; + return '' + t + ''; + }); + 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 = '' + renderInline(escaped) + ''; + } else if (cls.kind === 'blank') { + inner = ' '; + } 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 = '
' + + '' + lineNum + '' + + '' + (gutter || '+') + '' + + '' + content + '' + + '
'; + if (state.openInputLine === lineNum) { + const placeholder = anns.length > 0 ? 'Add another note for line ' + lineNum + '...' : 'Your note for line ' + lineNum + '...'; + row += '
' + + '' + + '
' + + '' + + '' + + '
'; + } + 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 = '
No annotations yet.

Click any line on the left to add your first note.
'; + 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 '
' + + '
' + + 'Line ' + a.line + '' + + '' + + '
' + + '

' + escapeHtml(a.target) + '

' + + (editing + ? '' + + '
' + + '' + + '' + + '
' + : '
' + escapeHtml(a.text) + '
') + + '
'; + }).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 + ? 'No annotations yet' + : '' + n + ' annotation' + (n === 1 ? '' : 's') + ''; + 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 [--out ]\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: .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 }; diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index cbc1c81..5c7bef8 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -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'); }); -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('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 +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'); }); +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'); diff --git a/plugins/voyage/tests/scripts/annotate.test.mjs b/plugins/voyage/tests/scripts/annotate.test.mjs new file mode 100644 index 0000000..4bd2a9f --- /dev/null +++ b/plugins/voyage/tests/scripts/annotate.test.mjs @@ -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 "\n---\n\n# Foo\n'; + const html = buildHtml('/abs/path/brief.md', md); + // The raw