From a9e3a40f4aa63512ce0900874a4d80e4a3b2207c 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/plugin.json | 4 +- CHANGELOG.md | 52 +++ CLAUDE.md | 15 +- README.md | 82 +++-- commands/trekbrief.md | 52 ++- commands/trekplan.md | 49 ++- commands/trekreview.md | 28 +- package-lock.json | 4 +- package.json | 5 +- scripts/annotate.mjs | 573 +++++++++++++++++++++++++++++ tests/lib/doc-consistency.test.mjs | 60 ++- tests/scripts/annotate.test.mjs | 167 +++++++++ 12 files changed, 984 insertions(+), 107 deletions(-) create mode 100644 scripts/annotate.mjs create mode 100644 tests/scripts/annotate.test.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 42c6106..82a017b 100644 --- a/.claude-plugin/plugin.json +++ b/.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/CHANGELOG.md b/CHANGELOG.md index c5ad231..e7ba3ce 100644 --- a/CHANGELOG.md +++ b/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/tests/lib/doc-consistency.test.mjs b/tests/lib/doc-consistency.test.mjs index cbc1c81..5c7bef8 100644 --- a/tests/lib/doc-consistency.test.mjs +++ b/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/tests/scripts/annotate.test.mjs b/tests/scripts/annotate.test.mjs new file mode 100644 index 0000000..4bd2a9f --- /dev/null +++ b/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