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