#!/usr/bin/env node
// scripts/annotate.mjs
//
// Builds a self-contained operator-annotation HTML for a voyage artifact
// (brief.md / plan.md / review.md). The producing commands call this on
// their last step and print the file:// link; the operator opens it in
// a browser, clicks any line to attach their own note, watches a sidebar
// of all their annotations, and copies a single prompt (with every note)
// back into Claude. Claude revises the source .md from the notes.
//
// • Operator drives every annotation. No pre-generated suggestions.
// • Each annotation is anchored to the source line number (1-based).
// • Annotations persist in localStorage per artifact path — refresh
// does not lose work; closing the browser does not either.
// • Zero external network, zero npm deps, deterministic output.
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function deriveTitle(mdText, fallbackName) {
const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText);
if (hasFrontmatter) {
const taskMatch = frontmatter.match(/^task:\s*(.+)$/m);
if (taskMatch) return taskMatch[1].trim().replace(/^["']|["']$/g, '');
const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
if (slugMatch) return slugMatch[1].trim().replace(/^["']|["']$/g, '');
}
const h1 = mdText.match(/^#\s+(.+)$/m);
if (h1) return h1[1].trim();
return fallbackName;
}
function buildHtml(artifactPath, mdText) {
const lines = mdText.replace(/\r\n/g, '\n').split('\n');
const fileName = basename(artifactPath);
const title = deriveTitle(mdText, fileName);
const linesJson = JSON.stringify(lines);
const artifactPathJson = JSON.stringify(artifactPath);
const titleEsc = escapeHtml(title);
const fileNameEsc = escapeHtml(fileName);
return '\n'
+ '\n'
+ '
\n'
+ ' \n'
+ ' \n'
+ '' + titleEsc + ' — annotate \n'
+ '\n'
+ '\n'
+ '\n'
+ '\n'
+ '
\n'
+ '
\n'
+ ' \n'
+ ' Click any line to add your own annotation. Annotations are saved in your browser per artifact path.
\n'
+ '
\n'
+ ' \n'
+ ' \n'
+ ' \n'
+ '
\n'
+ ' \n'
+ ' Prompt for Claude \n'
+ ' Copy Prompt \n'
+ '
\n'
+ ' Click a line and add a note to generate a prompt. \n'
+ ' \n'
+ '
\n'
+ '\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
? '
'
+ '
'
+ 'Cancel '
+ 'Save '
+ '
'
: '
' + 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 };