diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fc063f9..53e2f25 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 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." + "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, modelled on claude-code-100x): pencil-toggle annotation mode, select text or click any element, pick intent (Fiks/Endre/Spørsmål), comment, Copy Prompt, paste back, Claude revises the .md." }, { "name": "linkedin-thought-leadership", @@ -54,6 +54,11 @@ "name": "human-friendly-style", "source": "./plugins/human-friendly-style", "description": "Shared Claude Code output style for the ktg-plugin-marketplace. Plain-language tone — explains what and why, hides paths/JSON/stack traces by default, matches the user's language." + }, + { + "name": "claude-design", + "source": "./plugins/claude-design", + "description": "End-to-end facilitator for prompting Claude Design (claude.ai/design) — idea to copy-paste-ready prompt with iteration coaching, citing Anthropic primary sources." } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 03c17c0..af11fc2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,13 +10,13 @@ plugins/ config-audit/ v3.1.0 — Configuration intelligence (health, opportunities, auto-fix, whats-active) graceful-handoff/ v2.1.0 — Auto-trigger handoff via Stop hook (skill + JSON pipeline + 4-step model-aware context resolution) linkedin-thought-leadership/ v1.2.0 — LinkedIn content pipeline + analytics - 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 + llm-security/ v7.7.2 — Security scanning, auditing, threat modeling. HTML report output for all 18 skill commands (render-report CLI + canonical ESM module mirrored bit-identical into the playground). v7.7.2 translated the remaining Norwegian surface text in the playground UI, the canonical renderer, the agent prompts, and the README/CLAUDE.md state sections to English. v7.7.1 stripped the playground to the catalog as the only routable surface. + ms-ai-architect/ v1.15.0 — Microsoft AI architecture (Cosmo Skyberg persona) + manual KB-refresh slash command + v3 project-view (sidebar med 17 artifacts + main + import-modal overlay, v2-surface fjernet i v1.15.0) okr/ v1.0.0 — OKR guidance for Norwegian public sector - 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). + voyage/ v5.0.3 — 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 modelled on claude-code-100x/build-site.js: pencil-toggle annotation mode, select text or click any element, choose intent (Fiks/Endre/Spørsmål), comment, sidebar groups by section with delete + Copy Prompt, localStorage persistence per artifact path. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 pointed at /playground document-critique (wrong direction); v5.0.2 was operator-led but too thin; v5.0.3 matches the reference the operator pointed at from day one. shared/ - playground-design-system/ v0.1 — Aksel/Digdir-aligned CSS design system + JSON schemas + self-hosted Inter/JetBrains Mono/Source Serif 4 fonts (Tier 1+2+3 wave 1+wave 2 = 20 Tier 3 components total). Consumed by ms-ai-architect, okr, llm-security, voyage, config-audit + playground-design-system/ v0.6.0 — Aksel/Digdir-aligned CSS design system + JSON schemas + self-hosted Inter/JetBrains Mono/Source Serif 4 fonts. Tier 1 base + Tier 2 + Tier 3 wave 1+2 (20 components) + Tier 4 project-view-arketype (v0.6.0 — sidebar + main + import-modal overlay). Consumed by ms-ai-architect, okr, llm-security, voyage, config-audit. playground-examples/ — Reference scenarios (ROS-Lier, OKR-Bærum, security-Direktorat) + showcase landing + 12 isolated Tier 3 wave 2 component demos under components/ ``` @@ -53,3 +53,20 @@ Disse trackes IKKE i git. Oppdater ved sesjonsslutt. 3. Les REMEMBER.md og TODO.md for sesjonsstatus 4. Jobb innenfor scope 5. Oppdater REMEMBER.md ved avslutning + +## Communication patterns + +### Linking to local files + +When pointing to local files in responses, always use markdown link syntax with a descriptive name: + +- Use `[Human-friendly name](file:///absolute/path)` — never bare `file:///...` URLs or autolinks ``. +- Always use absolute paths. Never `~/` or relative paths. +- For multiple files, render as a bullet list of named markdown links. + +Why: bare `file://` URLs only render the first as clickable across multiple lines. Named markdown links make each entry independently clickable and look cleaner. + +Example: + +- [Brief](file:///Users/ktg/.../brief.html) +- [Research summary](file:///Users/ktg/.../research/summary.md) diff --git a/README.md b/README.md index 3c1b6a2..0f4df4e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Then open Claude Code and type `/plugin` to browse and install plugins from the ## Plugins -### [LLM Security](plugins/llm-security/) `v7.6.1` +### [LLM Security](plugins/llm-security/) `v7.7.2` Security scanning, auditing, and threat modeling for agentic AI projects. @@ -36,9 +36,12 @@ Built on OWASP LLM Top 10 (2025), OWASP Agentic AI Top 10, and the AI Agent Trap - **Deterministic scanning** — 23 Node.js scanners (10 orchestrated + 13 standalone) for byte-level analysis: Shannon entropy, Unicode codepoints, typosquatting detection, taint flow, DNS resolution, git forensics, AI-BOM, attack simulation, IDE extension prescan (VS Code + JetBrains — URL fetch from Marketplace / OpenVSX / direct VSIX / JetBrains Marketplace, hardened ZIP extractor for zip-slip / symlinks / bombs, plus OS sandbox via `sandbox-exec` / `bwrap` so the kernel enforces FS confinement), MCP cumulative-drift baseline reset (E14 — sticky baseline catches slow-burn rug-pulls). Bash-normalize T1-T6 for obfuscation-resistant denylists - **Advisory analysis** — 20 commands that scan, audit, and model threats with structured reports, letter grades, and actionable remediation - **Enterprise governance** — Compliance mapping (EU AI Act, NIST AI RMF, ISO 42001), SARIF 2.1.0 output, structured audit trail, policy-as-code, standalone CLI -- **v7.6.1 playground visuell-patch (2026-05-06)** — Seks bugs fanget av maintainer ved manuell verifisering i nettleser etter v7.6.0-release. Alle skyldtes mismatch mellom DS-klasser og hvordan playground-rendrere brukte dem (eller manglende DS-implementasjoner av klasser playground-rendrere antok eksisterte): `renderFindingsBlock` brukte `.findings` outer-class (DS' 2-kolonners list+detail-grid) → erstattet med `
` + korrekt `findings__list`-mønster; `.report-table` manglet helt i DS men brukes i 7+ rendrere → lokal CSS-implementasjon; `renderPreDeploy` traffic-lights brukte fast 28×28 px `.sm-card__grade` for "PASS"/"PASS-WITH-NOTES"/"FAIL" → bredde-tilpasset status-pill; threat-model matrix-bobler ikke klikkbare → `\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' + + '
Click any heading, paragraph, list item, table cell, or quote to add an annotation. To anchor on a specific phrase, select the text first, then click. Toggle annotation mode off (pencil button) to read normally / follow links.
\n' + + '
\n' + + articleHtml + + '\n
\n' + '
\n' - + '
\n' - + '
\n' - + ' Prompt for Claude\n' - + ' \n' + + '
\n' + '\n' + + '\n' + + '
\n' + + '
\n' + '\n' @@ -97,8 +288,9 @@ function buildHtml(artifactPath, mdText) { } // --------------------------------------------------------------------------- -// Stylesheet — design-system-aligned, light + dark, no external fonts/CDN. +// Stylesheet — light + dark + print. Design-system-aligned. // --------------------------------------------------------------------------- + const STYLE = ` :root { --bg: #f7f7f8; @@ -117,8 +309,15 @@ const STYLE = ` --green-soft: #d5ecdb; --red: #b3262d; --red-soft: #f6d9da; + --blue: #0855a8; + --blue-soft: #e4ecf6; + --orange: #d4790a; + --orange-soft: #fceede; + --purple: #6638b6; + --purple-soft: #ebe1f9; --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; --sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif; + --serif: ui-serif, "Source Serif 4", Georgia, "Times New Roman", serif; } @media (prefers-color-scheme: dark) { :root { @@ -138,398 +337,581 @@ const STYLE = ` --green-soft: rgba(63, 185, 80, 0.12); --red: #f0626a; --red-soft: rgba(240, 98, 106, 0.12); + --blue: #6db0ee; + --blue-soft: rgba(109, 176, 238, 0.15); + --orange: #f6ad55; + --orange-soft: rgba(246, 173, 85, 0.15); + --purple: #d2a8ff; + --purple-soft: rgba(210, 168, 255, 0.15); } } * { 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); } +html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); + font-family: var(--sans); font-size: 15px; line-height: 1.6; } +body { min-height: 100vh; } +/* Topbar */ +.topbar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; justify-content: space-between; + gap: 16px; padding: 12px 24px; 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; } +.hdr-actions { display: flex; gap: 8px; align-items: center; } +.ann-toggle { display: inline-flex; align-items: center; gap: 6px; + background: var(--accent); color: #fff; border: 1px solid var(--accent); + border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; } +.ann-toggle:hover { filter: brightness(1.05); } +body:not(.ann-mode) .ann-toggle { background: var(--bg-soft); color: var(--text-dim); border-color: var(--border); } +body:not(.ann-mode) .ann-toggle:hover { color: var(--text); border-color: var(--border-strong); } +.ann-badge { background: rgba(255,255,255,0.25); color: inherit; padding: 0 6px; border-radius: 99px; font-size: 11px; font-weight: 700; } +body:not(.ann-mode) .ann-badge { background: var(--bg); color: var(--text-dim); } +.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border); + border-radius: 5px; padding: 6px 12px; font-family: inherit; font-size: 13px; cursor: pointer; } +.ghost-btn:hover { color: var(--text); border-color: var(--border-strong); } +.icon-btn { background: transparent; border: none; color: var(--text-dim); cursor: pointer; + font-size: 16px; padding: 4px 8px; border-radius: 4px; } +.icon-btn:hover { color: var(--text); background: var(--bg-soft); } +/* Article */ +.article-wrap { max-width: 820px; margin: 0 auto; padding: 24px 32px 96px; } +.article-help { font-size: 13px; color: var(--text-dim); background: var(--accent-soft); + border: 1px solid var(--accent); border-radius: 6px; padding: 10px 14px; margin: 0 0 24px; line-height: 1.5; } +body:not(.ann-mode) .article-help { display: none; } +.article-help strong { color: var(--text); } +.article { font-size: 15px; line-height: 1.7; } +.article h1, .article h2, .article h3, .article h4, .article h5, .article h6 { + font-family: var(--serif); font-weight: 700; line-height: 1.25; margin: 1.8em 0 .55em; color: var(--text); } +.article h1 { font-size: 2rem; margin-top: 0; } +.article h2 { font-size: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: .3em; } +.article h3 { font-size: 1.2rem; } +.article h4 { font-size: 1.05rem; } +.article p { margin: .9em 0; } +.article a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } +.article code { font-family: var(--mono); font-size: .9em; background: var(--bg-soft); + padding: .12em .4em; border-radius: 4px; } +.article pre { position: relative; background: #1e1e24; color: #e6e6eb; padding: 16px 18px; border-radius: 8px; + overflow-x: auto; font-size: .88rem; line-height: 1.55; margin: 1.2em 0; } +.article pre code { background: none; padding: 0; color: inherit; font-size: inherit; } +.code-copy-btn { position: absolute; top: 8px; right: 8px; background: rgba(255,255,255,0.08); + color: #e6e6eb; border: 1px solid rgba(255,255,255,0.15); border-radius: 5px; + padding: 4px 10px; font: 600 11px var(--sans); cursor: pointer; opacity: 0; + transition: opacity .15s ease, background .15s ease; letter-spacing: .02em; z-index: 2; } +.article pre:hover .code-copy-btn, +.article pre:focus-within .code-copy-btn { opacity: 1; } +.code-copy-btn:hover { background: rgba(255,255,255,0.16); } +.code-copy-btn:focus { opacity: 1; outline: 2px solid var(--accent); outline-offset: 2px; } +.code-copy-btn.copied { background: var(--green); color: #fff; border-color: var(--green); } +.article blockquote { margin: 1.2em 0; padding: .5em 1.2em; border-left: 4px solid var(--accent); + background: var(--accent-soft); color: var(--text-dim); border-radius: 0 6px 6px 0; } +.article ul, .article ol { padding-left: 1.8em; margin: .9em 0; } +.article li { margin: .3em 0; } +.article table { border-collapse: collapse; width: 100%; margin: 1.4em 0; font-size: .92em; } +.article th, .article td { border: 1px solid var(--border); padding: .55em .8em; text-align: left; vertical-align: top; } +.article th { background: var(--bg-soft); font-weight: 650; } +.article hr { border: none; border-top: 1px solid var(--border); margin: 2.2em 0; } +.article strong { font-weight: 700; } +.article em { font-style: italic; } +/* Annotation mode: highlight annotatable elements on hover, mark annotated ones */ +.article [data-anchor-id] { position: relative; transition: background .08s, outline .08s; border-radius: 3px; } +body.ann-mode .article [data-anchor-id] { cursor: pointer; } +body.ann-mode .article [data-anchor-id]:hover { + outline: 2px dashed var(--accent); outline-offset: 2px; background: var(--accent-soft); +} +.article [data-anchor-id].annotated { + background: var(--amber-soft); + outline: 1px solid var(--amber); outline-offset: 1px; +} +.article [data-anchor-id].annotated::after { + content: attr(data-ann-count); position: absolute; right: -22px; top: 2px; + background: var(--amber); color: #fff; font-size: 10px; font-weight: 700; + padding: 1px 6px; border-radius: 99px; font-family: var(--sans); +} +body.ann-mode .article [data-anchor-id].annotated:hover { outline-color: var(--amber); } +.article [data-anchor-id].flash { + animation: flash 1.6s ease-out; +} +@keyframes flash { + 0% { background: var(--accent-soft); outline: 2px solid var(--accent); } + 100% { background: var(--amber-soft); outline: 1px solid var(--amber); } +} +/* Form popover */ +.ann-form { position: fixed; z-index: 200; background: var(--bg-elev); border: 1px solid var(--border-strong); + border-radius: 8px; padding: 14px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); + width: 380px; max-width: calc(100vw - 24px); display: none; flex-direction: column; gap: 10px; + font-family: var(--sans); } +.ann-form.visible { display: flex; } +.ann-form-section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--text-mute); font-weight: 600; margin-bottom: 3px; } +.ann-form-section-value { font-size: 13px; color: var(--text-dim); font-style: italic; } +.ann-form-snippet-text { margin: 0; padding: 6px 10px; border-left: 3px solid var(--accent); + background: var(--bg); border-radius: 0 4px 4px 0; font-family: var(--mono); font-size: 12px; + color: var(--text); max-height: 100px; overflow-y: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-word; } +.ann-form-intents { display: flex; gap: 6px; } +.ann-intent { flex: 1; padding: 7px 10px; border-radius: 5px; border: 1px solid var(--border); + background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; } +.ann-intent:hover { color: var(--text); border-color: var(--border-strong); } +.ann-intent[data-intent="fiks"].selected { background: var(--red); color: #fff; border-color: var(--red); } +.ann-intent[data-intent="endre"].selected { background: var(--orange); color: #fff; border-color: var(--orange); } +.ann-intent[data-intent="spørsmål"].selected { background: var(--blue); color: #fff; border-color: var(--blue); } +.ann-form-comment { width: 100%; min-height: 80px; padding: 8px 10px; + font-family: inherit; font-size: 13px; line-height: 1.5; color: var(--text); + background: var(--bg); border: 1px solid var(--border); border-radius: 5px; resize: vertical; } +.ann-form-comment:focus { outline: 1px solid var(--accent); border-color: var(--accent); } +.ann-form-actions { display: flex; gap: 6px; justify-content: flex-end; } +.btn { padding: 6px 14px; border-radius: 5px; border: 1px solid var(--border); + background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 600; 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; +.btn.primary:hover:not(:disabled) { filter: brightness(1.1); color: #fff; } +.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); border-color: var(--border); cursor: not-allowed; filter: none; } +/* Annotations panel (slide-in sidebar) */ +.ann-panel { position: fixed; top: 0; right: 0; bottom: 0; width: 420px; max-width: 100vw; + background: var(--bg-elev); border-left: 1px solid var(--border); z-index: 150; + transform: translateX(100%); transition: transform .2s ease; + display: flex; flex-direction: column; box-shadow: -4px 0 20px rgba(0,0,0,0.15); } +.ann-panel.open { transform: translateX(0); } +.ann-panel-head { display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; border-bottom: 1px solid var(--border); } +.ann-panel-head h2 { font-size: 14px; font-weight: 650; margin: 0; } +.ann-panel-body { flex: 1; overflow-y: auto; padding: 12px 14px; } +.ann-panel-foot { display: flex; justify-content: space-between; gap: 8px; + padding: 12px 14px; border-top: 1px solid var(--border); } +.ann-panel-empty { color: var(--text-mute); font-size: 13px; text-align: center; padding: 32px 12px; 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); } +.ann-section { margin: 12px 0 6px; font-size: 11px; font-weight: 650; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--text-mute); padding: 0 4px; } +.ann-section:first-child { margin-top: 0; } +.ann-item { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + padding: 10px 12px; margin-bottom: 8px; cursor: pointer; } +.ann-item:hover { border-color: var(--border-strong); } +.ann-item .ann-item-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; gap: 6px; } +.ann-item-intent { font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.04em; padding: 2px 8px; border-radius: 99px; } +.ann-item-intent.fiks { background: var(--red-soft); color: var(--red); } +.ann-item-intent.endre { background: var(--orange-soft); color: var(--orange); } +.ann-item-intent.spørsmål { background: var(--blue-soft); color: var(--blue); } +.ann-item-delete { background: transparent; border: none; color: var(--text-mute); + cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 13px; } +.ann-item-delete:hover { color: var(--red); background: var(--red-soft); } +.ann-item-snippet { font-family: var(--mono); font-size: 11px; color: var(--text-mute); + margin: 0 0 6px; line-height: 1.5; padding: 4px 8px; background: var(--bg-soft); + border-left: 2px solid var(--border-strong); border-radius: 0 4px 4px 0; + max-height: 60px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; } +.ann-item-comment { font-size: 13px; color: var(--text); line-height: 1.5; white-space: pre-wrap; word-break: break-word; } +.ann-item-comment.empty { color: var(--text-mute); font-style: italic; } +/* Toast */ +.ann-toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); + background: var(--text); color: var(--bg-elev); padding: 9px 16px; border-radius: 6px; + font-size: 13px; font-weight: 500; opacity: 0; pointer-events: none; + transition: opacity .2s, transform .2s; z-index: 300; } +.ann-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); } +/* Overlay (form backdrop) */ +.ann-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 100; + opacity: 0; pointer-events: none; transition: opacity .15s; } +.ann-overlay.visible { opacity: 1; pointer-events: auto; } +/* Scrollbar */ ::-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); } +/* Print: hide annotation chrome, show article only */ +@media print { + .topbar, .ann-form, .ann-panel, .ann-toast, .ann-overlay, .article-help { display: none !important; } + .article-wrap { max-width: none; padding: 0; } + body { background: #fff; color: #000; } +} `.trim(); // --------------------------------------------------------------------------- -// Embedded JS app — operator annotation surface. -// Uses concatenation (no template literals) to avoid backtick collisions -// with the outer mjs string assembly. +// Embedded JS app. 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() { +const APP_JS = ` +const STORAGE_KEY = 'voyage-annotate:v2:' + ARTIFACT_PATH; +const INTENT_LABELS = { fiks: 'Fiks', endre: 'Endre', 'spørsmål': 'Spørsmål' }; +const INTENT_ORDER = ['fiks', 'endre', 'spørsmål']; + +let annotations = []; +let nextId = 1; +let mode = true; +let currentTarget = null; +let currentSection = null; +let currentSnippet = null; +let currentIntent = null; + +// ── Storage ── +function loadState() { 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; + annotations = data.annotations; + nextId = data.nextId || (annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1); } } catch (e) {} } -function save() { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: state.annotations, nextId: nextId })); } catch (e) {} +function saveState() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: 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; +function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - 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); +// ── DOM refs ── +const body = document.body; +const article = document.getElementById('article'); +const form = document.getElementById('ann-form'); +const formSection = document.getElementById('ann-form-section'); +const formSnippet = document.getElementById('ann-form-snippet'); +const formComment = document.getElementById('ann-form-comment'); +const formSave = document.getElementById('ann-form-save'); +const formCancel = document.getElementById('ann-form-cancel'); +const intents = document.querySelectorAll('.ann-intent'); +const panel = document.getElementById('ann-panel'); +const panelBody = document.getElementById('ann-panel-body'); +const panelCloseBtn = document.getElementById('ann-panel-close'); +const openPanelBtn = document.getElementById('open-panel'); +const clearAllBtn = document.getElementById('ann-clear-all'); +const copyBtn = document.getElementById('ann-copy'); +const annToggle = document.getElementById('ann-toggle'); +const annToggleLabel = document.getElementById('ann-toggle-label'); +const annBadge = document.getElementById('ann-badge'); +const toast = document.getElementById('ann-toast'); +const overlay = document.getElementById('ann-overlay'); + +// ── Section lookup ── +function findSection(el) { + let p = el; + while (p) { + if (p.previousElementSibling) { + let s = p.previousElementSibling; + while (s) { + if (s.matches && (s.matches('h1') || s.matches('h2'))) return s.textContent.trim(); + s = s.previousElementSibling; } - }); - }); - 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); - } - }); - }); + } + p = p.parentElement; + if (p && p.tagName === 'ARTICLE') break; + } + // Fallback: first h1 in article + const firstH = article.querySelector('h1, h2'); + return firstH ? firstH.textContent.trim() : '(top)'; } -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); + +// ── Snippet from selection or element text ── +function captureSnippet(el) { + const sel = window.getSelection(); + if (sel && sel.toString().trim().length > 0 && el.contains(sel.anchorNode)) { + return sel.toString().trim().slice(0, 300); + } + return (el.textContent || '').trim().slice(0, 200); } + +// ── Form open/close ── +function openForm(evt, target) { + currentTarget = target; + currentSection = findSection(target); + currentSnippet = captureSnippet(target); + currentIntent = null; + formSection.textContent = currentSection || '(top)'; + formSnippet.textContent = currentSnippet || '(empty)'; + formComment.value = ''; + intents.forEach(function(b) { b.classList.remove('selected'); }); + formSave.disabled = true; + + // Position near the click (clamped to viewport) + const fw = 380, fh = 320; + let x = evt.clientX + 14; + let y = evt.clientY + 14; + if (x + fw > window.innerWidth) x = window.innerWidth - fw - 12; + if (y + fh > window.innerHeight) y = Math.max(12, window.innerHeight - fh - 12); + if (x < 12) x = 12; + if (y < 12) y = 12; + form.style.left = x + 'px'; + form.style.top = y + 'px'; + form.classList.add('visible'); + overlay.classList.add('visible'); + setTimeout(function() { formComment.focus(); }, 50); +} +function closeForm() { + form.classList.remove('visible'); + overlay.classList.remove('visible'); + currentTarget = null; + currentSection = null; + currentSnippet = null; + currentIntent = null; +} + +// ── Save ── +function saveAnnotation() { + if (!currentIntent || !currentTarget) return; + const a = { + id: nextId++, + anchorId: currentTarget.getAttribute('data-anchor-id'), + section: currentSection || '(top)', + snippet: currentSnippet || '', + intent: currentIntent, + comment: (formComment.value || '').trim(), + ts: new Date().toISOString(), + }; + annotations.push(a); + saveState(); + closeForm(); + refreshArticleAnnotations(); + renderPanel(); + updateCounts(); + showToast('Annotasjon lagret (' + annotations.length + ')'); +} + +// ── Delete ── 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(); + annotations = annotations.filter(function(a) { return a.id !== id; }); + saveState(); + refreshArticleAnnotations(); + renderPanel(); + updateCounts(); + showToast('Annotasjon slettet'); } -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(); + +// ── Refresh article markers ── +function refreshArticleAnnotations() { + // Clear all current markers + article.querySelectorAll('[data-anchor-id].annotated').forEach(function(el) { + el.classList.remove('annotated'); + el.removeAttribute('data-ann-count'); + }); + // Group by anchorId + const byAnchor = {}; + for (const a of annotations) { + if (!a.anchorId) continue; + if (!byAnchor[a.anchorId]) byAnchor[a.anchorId] = 0; + byAnchor[a.anchorId]++; + } + for (const anchorId in byAnchor) { + const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchorId) + '"]'); + if (el) { + el.classList.add('annotated'); + el.setAttribute('data-ann-count', byAnchor[anchorId]); + } + } } -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.
'; + +// ── Sidebar panel render ── +function renderPanel() { + if (annotations.length === 0) { + panelBody.innerHTML = '
No annotations yet.

Click any heading, paragraph, list item, or quote in the article to add one.
'; 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(); - }); + // Group by section (preserve insertion order) + const groups = []; + const groupMap = {}; + // Sort by document order using anchorId numerical suffix + const sorted = annotations.slice().sort(function(a, b) { + const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0; + const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0; + if (ai !== bi) return ai - bi; + return a.id - b.id; }); - root.querySelectorAll('button[data-del]').forEach(function(b) { - b.addEventListener('click', function() { + for (const a of sorted) { + if (!groupMap[a.section]) { + groupMap[a.section] = { section: a.section, items: [] }; + groups.push(groupMap[a.section]); + } + groupMap[a.section].items.push(a); + } + let html = ''; + for (const g of groups) { + html += '
' + escHtml(g.section) + '
'; + for (const a of g.items) { + html += '
' + + '
' + + '' + escHtml(INTENT_LABELS[a.intent] || a.intent) + '' + + '' + + '
' + + '
' + escHtml(a.snippet || '(empty)') + '
' + + '
' + escHtml(a.comment || '(no comment)') + '
' + + '
'; + } + } + panelBody.innerHTML = html; + + panelBody.querySelectorAll('.ann-item-delete').forEach(function(b) { + b.addEventListener('click', function(e) { + e.stopPropagation(); 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); + panelBody.querySelectorAll('.ann-item').forEach(function(card) { + card.addEventListener('click', function() { + const anchor = card.getAttribute('data-anchor-id'); + const el = article.querySelector('[data-anchor-id="' + CSS.escape(anchor) + '"]'); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.remove('flash'); + void el.offsetWidth; + el.classList.add('flash'); + } }); }); } -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; + +// ── Counts + toggle label ── +function updateCounts() { + annBadge.textContent = String(annotations.length); + copyBtn.disabled = annotations.length === 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.'; + +function setMode(on) { + mode = on; + body.classList.toggle('ann-mode', on); + annToggleLabel.textContent = on ? 'Annotation mode: ON' : 'Annotation mode: OFF'; + if (!on) closeForm(); +} + +// ── Toast ── +function showToast(msg) { + toast.textContent = msg; + toast.classList.add('visible'); + setTimeout(function() { toast.classList.remove('visible'); }, 1800); +} + +// ── Copy Prompt ── +function buildPromptMarkdown() { + if (annotations.length === 0) return ''; + const sorted = annotations.slice().sort(function(a, b) { + const ai = parseInt((a.anchorId || '').replace('anch-', ''), 10) || 0; + const bi = parseInt((b.anchorId || '').replace('anch-', ''), 10) || 0; + if (ai !== bi) return ai - bi; + return a.id - b.id; + }); + let p = 'Please revise the voyage artifact at \\\`' + ARTIFACT_PATH + '\\\` with the operator annotations below.\\n'; + p += 'Each annotation has an intent — **Fiks** (something is wrong / fix it), **Endre** (change wording/content),\\n'; + p += 'or **Spørsmål** (operator question — clarify or answer). The quote shows what the operator anchored to.\\n'; + p += 'Treat the operator notes as authoritative direction.\\n\\n'; + p += '## Annotations (' + annotations.length + ' total)\\n\\n'; + let n = 0; + for (const a of sorted) { + n++; + p += '### ' + n + '. [' + (INTENT_LABELS[a.intent] || a.intent) + '] Section: ' + a.section + '\\n'; + if (a.snippet) p += 'Quote: «' + a.snippet + '»\\n'; + p += 'Comment: ' + (a.comment || '(no comment)') + '\\n\\n'; + } + return p; +} + +async function copyPrompt() { + const md = buildPromptMarkdown(); + if (!md) return; + try { + await navigator.clipboard.writeText(md); + showToast('Prompt copied (' + annotations.length + ' annotation' + (annotations.length === 1 ? '' : 's') + ')'); + } catch (e) { + // Fallback + const ta = document.createElement('textarea'); + ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; + document.body.appendChild(ta); ta.select(); + try { document.execCommand('copy'); showToast('Prompt copied'); } catch (e2) { alert('Copy failed: ' + e2.message); } + ta.remove(); + } +} + +// ── Wiring ── +article.addEventListener('click', function(e) { + if (!mode) return; + const target = e.target.closest('[data-anchor-id]'); + if (!target) return; + // Don't open form when clicking inside an already-open form (overlay catches outside clicks) + if (e.target.closest('.ann-form')) return; + // Don't open form when clicking a link the user wants to follow — but only if they didn't select text + if (e.target.tagName === 'A' && (!window.getSelection() || window.getSelection().toString().trim().length === 0)) { + // Allow link clicks in mode if no selection 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); } + e.preventDefault(); + openForm(e, target); }); -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(); + +intents.forEach(function(b) { + b.addEventListener('click', function() { + intents.forEach(function(x) { x.classList.remove('selected'); }); + b.classList.add('selected'); + currentIntent = b.dataset.intent; + formSave.disabled = false; + }); +}); + +formSave.addEventListener('click', saveAnnotation); +formCancel.addEventListener('click', closeForm); +overlay.addEventListener('click', closeForm); + +formComment.addEventListener('keydown', function(e) { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !formSave.disabled) { + saveAnnotation(); + } else if (e.key === 'Escape') { + closeForm(); } }); + document.addEventListener('keydown', function(e) { - if (e.key === 'Escape' && state.openInputLine !== null) { - state.openInputLine = null; - renderAll(); + if (e.key === 'Escape' && form.classList.contains('visible')) closeForm(); +}); + +annToggle.addEventListener('click', function() { setMode(!mode); }); + +openPanelBtn.addEventListener('click', function() { + panel.classList.toggle('open'); +}); +panelCloseBtn.addEventListener('click', function() { panel.classList.remove('open'); }); + +clearAllBtn.addEventListener('click', function() { + if (annotations.length === 0) return; + if (confirm('Remove all ' + annotations.length + ' annotations? This cannot be undone.')) { + annotations = []; + saveState(); + refreshArticleAnnotations(); + renderPanel(); + updateCounts(); + showToast('All annotations cleared'); } }); -function renderAll() { - renderDoc(); - renderNotesList(); - renderStats(); - renderPrompt(); -} -load(); -renderAll(); + +copyBtn.addEventListener('click', copyPrompt); + +// ── Init ── +loadState(); +refreshArticleAnnotations(); +renderPanel(); +updateCounts(); +setMode(true); + +// ── Code-block copy buttons ── +document.querySelectorAll('.article pre').forEach(function(pre) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'code-copy-btn'; + btn.textContent = 'Copy'; + btn.setAttribute('aria-label', 'Copy code block to clipboard'); + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var code = pre.querySelector('code'); + var text = code ? code.textContent : pre.textContent; + navigator.clipboard.writeText(text).then(function() { + btn.textContent = 'Copied'; + btn.classList.add('copied'); + setTimeout(function() { + btn.textContent = 'Copy'; + btn.classList.remove('copied'); + }, 1500); + }).catch(function() { + btn.textContent = 'Failed'; + setTimeout(function() { btn.textContent = 'Copy'; }, 1500); + }); + }); + pre.appendChild(btn); +}); `.trim(); // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- + function parseArgs(argv) { const args = { input: null, out: null, help: false }; for (let i = 0; i < argv.length; i++) { @@ -559,9 +941,10 @@ if (import.meta.url === `file://${process.argv[1]}`) { 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' + + 'artifact. The operator opens the HTML, selects text or clicks any\n' + + 'element, picks an intent (Fiks / Endre / Spørsmål), writes a\n' + + 'comment, and copies a structured prompt to paste back into Claude.\n' + + 'Annotations persist in localStorage per artifact path.\n\n' + 'Default output: .html next to input.\n', ); process.exit(args.help ? 0 : 2); @@ -570,4 +953,4 @@ if (import.meta.url === `file://${process.argv[1]}`) { process.stdout.write(out + '\n'); } -export { render, buildHtml, parseArgs }; +export { render, buildHtml, renderMarkdown, parseArgs }; diff --git a/plugins/voyage/templates/trekbrief-template.md b/plugins/voyage/templates/trekbrief-template.md index b35d893..e0c2232 100644 --- a/plugins/voyage/templates/trekbrief-template.md +++ b/plugins/voyage/templates/trekbrief-template.md @@ -1,6 +1,6 @@ --- type: trekbrief -brief_version: 2.0 +brief_version: "2.1" created: {YYYY-MM-DD} task: "{one-line task description}" slug: {slug} @@ -10,6 +10,20 @@ research_status: pending # pending | in_progress | complete | skipped auto_research: false # true if user opted into Claude-managed research interview_turns: {N} source: {interview | manual} +# v5.1 — per-phase effort + model signal (Phase 3.5). +# `effort` ∈ {low, standard, high}. Omit `model:` for `standard` so composition +# falls through to profile resolver. Force-stop alternative is the commented +# `phase_signals_partial: true` below (mutually exclusive with `phase_signals`). +phase_signals: + - phase: research + effort: standard + - phase: plan + effort: standard + - phase: execute + effort: standard + - phase: review + effort: standard +# phase_signals_partial: true # uncomment to record force-stop instead of phase_signals --- # Task: {title} diff --git a/plugins/voyage/tests/commands/trekbrief.test.mjs b/plugins/voyage/tests/commands/trekbrief.test.mjs new file mode 100644 index 0000000..0788f67 --- /dev/null +++ b/plugins/voyage/tests/commands/trekbrief.test.mjs @@ -0,0 +1,130 @@ +// tests/commands/trekbrief.test.mjs +// v5.1 prose-pin tests + v5.1.1 runtime SC1 tests. +// +// Pattern D prose-pins kept as doc-anchors for the .md file. Runtime tests +// added per finding 350853 (BLOCKER SC1) + a7f4f95a (MAJOR Plan Step 5 drift). +// +// SC1 re-interpretation (per plan Step 10 amendment): "asserts on 4 +// AskUserQuestion calls" → "asserts resolvePhaseSignal returns non-null for +// all 4 entries in PHASE_SIGNAL_PHASES when applied to a brief with a +// committed phase_signals block." See brief amendment for full rationale. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs'; +import { validateBriefContent, PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../../lib/validators/brief-validator.mjs'; +import { parseDocument } from '../../lib/util/frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md'); +const FIXTURE = (name) => join(ROOT, 'tests', 'fixtures', name); + +function read() { + return readFileSync(COMMAND_FILE, 'utf8'); +} + +function readFixture(name) { + return readFileSync(FIXTURE(name), 'utf8'); +} + +function frontmatterOf(text) { + const doc = parseDocument(text); + return doc.parsed && doc.parsed.frontmatter; +} + +// --- Pattern D prose-pins (doc-anchors) --- + +test('trekbrief — Phase 3.5 heading is present', () => { + const text = read(); + assert.match(text, /^## Phase 3\.5 — Per-phase effort dialog$/m, + 'Phase 3.5 heading missing from commands/trekbrief.md'); +}); + +test('trekbrief — Phase 3.5 references all 4 downstream phases', () => { + const text = read(); + const startIdx = text.indexOf('## Phase 3.5'); + assert.ok(startIdx >= 0, 'Phase 3.5 not found'); + const section = text.slice(startIdx, text.indexOf('## Phase 4', startIdx)); + for (const phase of ['research', 'plan', 'execute', 'review']) { + assert.ok(section.includes(phase), + `Phase 3.5 missing reference to "${phase}"`); + } +}); + +test('trekbrief — Phase 3.5 documents phase_signals_partial force-stop', () => { + const text = read(); + assert.ok(text.includes('phase_signals_partial'), + 'phase_signals_partial not mentioned in /trekbrief command prose'); +}); + +// --- v5.1.1 runtime SC1 tests --- + +test('trekbrief — SC1: resolvePhaseSignal returns non-null for all 4 phases on committed brief (brief-effort-low)', () => { + const fm = frontmatterOf(readFixture('brief-effort-low.md')); + for (const phase of PHASE_SIGNAL_PHASES) { + const r = resolvePhaseSignal(fm, phase); + assert.ok(r && typeof r === 'object', + `phase=${phase}: resolver must return non-null for committed brief; got ${JSON.stringify(r)}`); + assert.ok(typeof r.effort === 'string', + `phase=${phase}: resolver result must include effort`); + } +}); + +test('trekbrief — SC1: each of 4 phases has both effort AND model on full-signals fixture', () => { + const fm = frontmatterOf(readFixture('brief-with-phase-signals.md')); + for (const phase of PHASE_SIGNAL_PHASES) { + const r = resolvePhaseSignal(fm, phase); + assert.ok(r && typeof r === 'object', `phase=${phase}: must resolve`); + assert.ok(EFFORT_LEVELS.includes(r.effort), + `phase=${phase}: effort "${r.effort}" not in EFFORT_LEVELS`); + if ('model' in r) { + assert.ok(['sonnet', 'opus'].includes(r.model), + `phase=${phase}: model "${r.model}" not in [sonnet, opus]`); + } + } +}); + +test('trekbrief — SC1: missing phase_signals + brief_version 2.1 triggers BRIEF_V51_MISSING_SIGNALS', () => { + const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `gate must fire; errors=${JSON.stringify(r.errors)}`, + ); +}); + +test('trekbrief — SC1: phase_signals_partial: true does NOT trigger the gate', () => { + const partial = `--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-14 +task: "Partial brief" +slug: partial-brief +project_dir: .claude/projects/2026-05-14-partial-brief/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 2 +source: fixture +phase_signals_partial: true +--- + +# Task + +## Intent +Stop early. + +## Goal +Test partial mode. + +## Success Criteria +- gate does not fire. +`; + const r = validateBriefContent(partial, { strict: true }); + assert.equal(r.valid, true, `errors=${JSON.stringify(r.errors)}`); + assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +}); diff --git a/plugins/voyage/tests/commands/trekexecute.test.mjs b/plugins/voyage/tests/commands/trekexecute.test.mjs new file mode 100644 index 0000000..15a67c7 --- /dev/null +++ b/plugins/voyage/tests/commands/trekexecute.test.mjs @@ -0,0 +1,75 @@ +// tests/commands/trekexecute.test.mjs +// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekexecute. +// Plan Assumption 2 locks low-effort to --gates open + sequential-only. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs'; +import { validateBriefContent } from '../../lib/validators/brief-validator.mjs'; +import { parseDocument } from '../../lib/util/frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md'); +const PHASE = 'execute'; + +function read() { return readFileSync(COMMAND_FILE, 'utf8'); } +function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); } +function frontmatterOf(text) { + const doc = parseDocument(text); + return doc.parsed && doc.parsed.frontmatter; +} + +// --- Pattern D prose-pins --- + +test('trekexecute — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => { + const text = read(); + assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'), + '/trekexecute must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate'); + assert.ok(text.includes('phase_signals'), + '/trekexecute must reference phase_signals (v5.1 composition rule)'); +}); + +test('trekexecute — low-effort path references --gates open + sequential', () => { + const text = read(); + const compIdx = text.indexOf('## Composition rule (v5.1)'); + assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing'); + const section = text.slice(compIdx, compIdx + 2000); + assert.match(section, /--gates open/, 'Low-effort path must mention --gates open'); + assert.match(section, /sequential/, 'Low-effort path must mention sequential-only execution'); +}); + +// --- v5.1.1 runtime SC4 + SC7 --- + +test('trekexecute — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => { + const fm = frontmatterOf(readFixture('brief-effort-low.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'low'); + assert.equal(r.model, 'sonnet'); +}); + +test('trekexecute — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => { + const fm = frontmatterOf(readFixture('brief-effort-standard.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'standard'); + assert.equal(r.model, undefined); +}); + +test('trekexecute — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => { + const fm = frontmatterOf(readFixture('brief-effort-high.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'high'); + assert.equal(r.model, 'opus'); +}); + +test('trekexecute — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => { + const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `sequencing gate must fire; errors=${JSON.stringify(r.errors)}`, + ); +}); diff --git a/plugins/voyage/tests/commands/trekplan.test.mjs b/plugins/voyage/tests/commands/trekplan.test.mjs new file mode 100644 index 0000000..1ab3ee0 --- /dev/null +++ b/plugins/voyage/tests/commands/trekplan.test.mjs @@ -0,0 +1,73 @@ +// tests/commands/trekplan.test.mjs +// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekplan. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs'; +import { validateBriefContent } from '../../lib/validators/brief-validator.mjs'; +import { parseDocument } from '../../lib/util/frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekplan.md'); +const PHASE = 'plan'; + +function read() { return readFileSync(COMMAND_FILE, 'utf8'); } +function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); } +function frontmatterOf(text) { + const doc = parseDocument(text); + return doc.parsed && doc.parsed.frontmatter; +} + +// --- Pattern D prose-pins (kept) --- + +test('trekplan — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => { + const text = read(); + assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'), + '/trekplan must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate'); + assert.ok(text.includes('phase_signals'), + '/trekplan must reference phase_signals (v5.1 composition rule)'); +}); + +test('trekplan — low-effort path references --quick equivalent', () => { + const text = read(); + const compIdx = text.indexOf('## Composition rule (v5.1)'); + assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing'); + const section = text.slice(compIdx, compIdx + 2000); + assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent'); +}); + +// --- v5.1.1 runtime SC4 + SC7 tests --- + +test('trekplan — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => { + const fm = frontmatterOf(readFixture('brief-effort-low.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'low'); + assert.equal(r.model, 'sonnet'); +}); + +test('trekplan — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => { + const fm = frontmatterOf(readFixture('brief-effort-standard.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'standard'); + assert.equal(r.model, undefined); +}); + +test('trekplan — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => { + const fm = frontmatterOf(readFixture('brief-effort-high.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'high'); + assert.equal(r.model, 'opus'); +}); + +test('trekplan — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => { + const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `sequencing gate must fire; errors=${JSON.stringify(r.errors)}`, + ); +}); diff --git a/plugins/voyage/tests/commands/trekresearch.test.mjs b/plugins/voyage/tests/commands/trekresearch.test.mjs new file mode 100644 index 0000000..0e10351 --- /dev/null +++ b/plugins/voyage/tests/commands/trekresearch.test.mjs @@ -0,0 +1,73 @@ +// tests/commands/trekresearch.test.mjs +// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekresearch. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs'; +import { validateBriefContent } from '../../lib/validators/brief-validator.mjs'; +import { parseDocument } from '../../lib/util/frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekresearch.md'); +const PHASE = 'research'; + +function read() { return readFileSync(COMMAND_FILE, 'utf8'); } +function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); } +function frontmatterOf(text) { + const doc = parseDocument(text); + return doc.parsed && doc.parsed.frontmatter; +} + +// --- Pattern D prose-pins --- + +test('trekresearch — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => { + const text = read(); + assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'), + '/trekresearch must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate'); + assert.ok(text.includes('phase_signals'), + '/trekresearch must reference phase_signals (v5.1 composition rule)'); +}); + +test('trekresearch — low-effort path references --quick equivalent', () => { + const text = read(); + const compIdx = text.indexOf('## Composition rule (v5.1)'); + assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing'); + const section = text.slice(compIdx, compIdx + 2000); + assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent'); +}); + +// --- v5.1.1 runtime SC4 + SC7 --- + +test('trekresearch — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => { + const fm = frontmatterOf(readFixture('brief-effort-low.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'low'); + assert.equal(r.model, 'sonnet'); +}); + +test('trekresearch — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => { + const fm = frontmatterOf(readFixture('brief-effort-standard.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'standard'); + assert.equal(r.model, undefined); +}); + +test('trekresearch — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => { + const fm = frontmatterOf(readFixture('brief-effort-high.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'high'); + assert.equal(r.model, 'opus'); +}); + +test('trekresearch — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => { + const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `sequencing gate must fire; errors=${JSON.stringify(r.errors)}`, + ); +}); diff --git a/plugins/voyage/tests/commands/trekreview.test.mjs b/plugins/voyage/tests/commands/trekreview.test.mjs new file mode 100644 index 0000000..e66ef00 --- /dev/null +++ b/plugins/voyage/tests/commands/trekreview.test.mjs @@ -0,0 +1,74 @@ +// tests/commands/trekreview.test.mjs +// v5.1 prose-pin tests + v5.1.1 runtime SC4 + SC7 tests for /trekreview. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolvePhaseSignal } from '../../lib/profiles/phase-signal-resolver.mjs'; +import { validateBriefContent } from '../../lib/validators/brief-validator.mjs'; +import { parseDocument } from '../../lib/util/frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekreview.md'); +const PHASE = 'review'; + +function read() { return readFileSync(COMMAND_FILE, 'utf8'); } +function readFixture(name) { return readFileSync(join(ROOT, 'tests', 'fixtures', name), 'utf8'); } +function frontmatterOf(text) { + const doc = parseDocument(text); + return doc.parsed && doc.parsed.frontmatter; +} + +// --- Pattern D prose-pins --- + +test('trekreview — sequencing-gate surface mentions BRIEF_V51_MISSING_SIGNALS + phase_signals', () => { + const text = read(); + assert.ok(text.includes('BRIEF_V51_MISSING_SIGNALS'), + '/trekreview must surface the BRIEF_V51_MISSING_SIGNALS sequencing gate'); + assert.ok(text.includes('phase_signals'), + '/trekreview must reference phase_signals (v5.1 composition rule)'); +}); + +test('trekreview — low-effort path references --quick equivalent', () => { + const text = read(); + const compIdx = text.indexOf('## Composition rule (v5.1)'); + assert.ok(compIdx >= 0, 'Composition rule (v5.1) section missing'); + const section = text.slice(compIdx, compIdx + 2000); + assert.match(section, /--quick/, 'Low-effort path must mention --quick equivalent'); +}); + +// --- v5.1.1 runtime SC4 + SC7 --- + +test('trekreview — SC4: low-effort fixture → resolver returns {effort: low, model: sonnet}', () => { + const fm = frontmatterOf(readFixture('brief-effort-low.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'low'); + assert.equal(r.model, 'sonnet'); +}); + +test('trekreview — SC4: standard-effort fixture → resolver returns {effort: standard, model: undefined}', () => { + const fm = frontmatterOf(readFixture('brief-effort-standard.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'standard'); + assert.equal(r.model, undefined); +}); + +test('trekreview — SC4: high-effort fixture → resolver returns {effort: high, model: opus}', () => { + const fm = frontmatterOf(readFixture('brief-effort-high.md')); + const r = resolvePhaseSignal(fm, PHASE); + assert.equal(r.effort, 'high'); + assert.equal(r.model, 'opus'); +}); + +test('trekreview — SC7: brief_version 2.1 + no phase_signals + no partial → BRIEF_V51_MISSING_SIGNALS', () => { + // Falsification via brief-v21-no-signals fixture: validator must catch missing signals. + const r = validateBriefContent(readFixture('brief-v21-no-signals.md'), { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `sequencing gate must fire; errors=${JSON.stringify(r.errors)}`, + ); +}); diff --git a/plugins/voyage/tests/fixtures/brief-effort-high.md b/plugins/voyage/tests/fixtures/brief-effort-high.md new file mode 100644 index 0000000..0d119b1 --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-effort-high.md @@ -0,0 +1,45 @@ +--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-14 +task: "Fixture: high-effort all phases (v5.1.1 runtime test)" +slug: brief-effort-high +project_dir: .claude/projects/2026-05-14-brief-effort-high/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 4 +source: fixture +phase_signals: + - phase: research + effort: high + model: opus + - phase: plan + effort: high + model: opus + - phase: execute + effort: high + model: opus + - phase: review + effort: high + model: opus +--- + +# Task: High-effort fixture + +## Intent + +Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the +high effort tier with explicit opus model overrides. Mirrors the +production-grade premium-profile scenario. + +## Goal + +Resolver returns `{effort: 'high', model: 'opus'}` for each of the 4 +PHASE_SIGNAL_PHASES. + +## Success Criteria + +- Validator passes. +- resolvePhaseSignal(fm, phase).effort === 'high' for all 4 phases. +- resolvePhaseSignal(fm, phase).model === 'opus' for all 4 phases. diff --git a/plugins/voyage/tests/fixtures/brief-effort-low.md b/plugins/voyage/tests/fixtures/brief-effort-low.md new file mode 100644 index 0000000..40b4f93 --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-effort-low.md @@ -0,0 +1,43 @@ +--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-14 +task: "Fixture: low-effort all phases (v5.1.1 runtime test)" +slug: brief-effort-low +project_dir: .claude/projects/2026-05-14-brief-effort-low/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 4 +source: fixture +phase_signals: + - phase: research + effort: low + model: sonnet + - phase: plan + effort: low + model: sonnet + - phase: execute + effort: low + model: sonnet + - phase: review + effort: low + model: sonnet +--- + +# Task: Low-effort fixture + +## Intent + +Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the lowest +effort tier with explicit sonnet model overrides. + +## Goal + +Resolver returns `{effort: 'low', model: 'sonnet'}` for each of the 4 +PHASE_SIGNAL_PHASES. + +## Success Criteria + +- Validator passes. +- resolvePhaseSignal(fm, phase) is non-null for all 4 phases. diff --git a/plugins/voyage/tests/fixtures/brief-effort-standard.md b/plugins/voyage/tests/fixtures/brief-effort-standard.md new file mode 100644 index 0000000..f0bb3dd --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-effort-standard.md @@ -0,0 +1,42 @@ +--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-14 +task: "Fixture: standard-effort all phases, no model (v5.1.1 runtime test)" +slug: brief-effort-standard +project_dir: .claude/projects/2026-05-14-brief-effort-standard/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 4 +source: fixture +phase_signals: + - phase: research + effort: standard + - phase: plan + effort: standard + - phase: execute + effort: standard + - phase: review + effort: standard +--- + +# Task: Standard-effort fixture (no model override) + +## Intent + +Test fixture for v5.1.1 runtime resolver tests — all 4 phases at the +standard tier WITHOUT explicit model fields. This is the operator-skipped +model path that should fall through to the profile. + +## Goal + +Resolver returns `{effort: 'standard', model: undefined}` for each of the 4 +PHASE_SIGNAL_PHASES. The orchestrator-model path then falls through to the +active profile's phase_models. + +## Success Criteria + +- Validator passes. +- resolvePhaseSignal(fm, phase).model is undefined. +- resolvePhaseSignal(fm, phase).effort is 'standard'. diff --git a/plugins/voyage/tests/fixtures/brief-v21-no-signals.md b/plugins/voyage/tests/fixtures/brief-v21-no-signals.md new file mode 100644 index 0000000..d705406 --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-v21-no-signals.md @@ -0,0 +1,32 @@ +--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-14 +task: "Fixture: v5.1 brief WITHOUT phase_signals or partial (falsification target)" +slug: brief-v21-no-signals +project_dir: .claude/projects/2026-05-14-brief-v21-no-signals/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 4 +source: fixture +--- + +# Task: brief_version 2.1 without phase_signals + +## Intent + +Falsification fixture for the v5.1 sequencing gate. The brief declares +`brief_version: "2.1"` but omits BOTH `phase_signals` AND +`phase_signals_partial: true`. The brief-validator MUST emit +`BRIEF_V51_MISSING_SIGNALS` for this file — the runtime test for the +sequencing gate asserts the error code fires. + +## Goal + +Validate that brief-validator catches the missing-signals scenario. + +## Success Criteria + +- brief-validator returns valid: false. +- errors contains BRIEF_V51_MISSING_SIGNALS. diff --git a/plugins/voyage/tests/fixtures/brief-with-phase-signals.md b/plugins/voyage/tests/fixtures/brief-with-phase-signals.md new file mode 100644 index 0000000..c68e37c --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-with-phase-signals.md @@ -0,0 +1,42 @@ +--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-13 +task: "Add per-phase effort dialog to /trekbrief" +slug: phase-signals-example +project_dir: .claude/projects/2026-05-13-phase-signals-example/ +research_topics: 2 +research_status: complete +auto_research: false +interview_turns: 6 +source: interview +phase_signals: + - phase: research + effort: low + model: sonnet + - phase: plan + effort: standard + - phase: execute + effort: high + model: opus + - phase: review + effort: standard +--- + +# Task: Phase-signals example + +## Intent + +A minimal brief that exercises the v5.1 phase_signals additive field with a +mix of effort levels and model overrides. Used by tests/validators to confirm +the validator accepts well-formed signals across the supported tier matrix. + +## Goal + +Validator returns valid: true. annotate.mjs strips phase_signals from the +rendered HTML body (frontmatter stays in source). + +## Success Criteria + +- Validator passes. +- annotate.mjs determinism: re-run produces byte-identical HTML. diff --git a/plugins/voyage/tests/fixtures/brief-without-phase-signals.md b/plugins/voyage/tests/fixtures/brief-without-phase-signals.md new file mode 100644 index 0000000..8bec99e --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-without-phase-signals.md @@ -0,0 +1,31 @@ +--- +type: trekbrief +brief_version: "2.0" +created: 2026-05-13 +task: "Backward-compat fixture for v5.0-style brief" +slug: legacy-brief-example +project_dir: .claude/projects/2026-05-13-legacy-brief-example/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 3 +source: interview +--- + +# Task: Legacy brief example + +## Intent + +A pre-v5.1 brief that pre-dates the phase_signals field. Used by +tests/validators to confirm backward-compatibility: the brief is accepted +without phase_signals as long as brief_version is < 2.1. + +## Goal + +Validator returns valid: true. The sequencing gate +(BRIEF_V51_MISSING_SIGNALS) does NOT fire for brief_version 2.0. + +## Success Criteria + +- Validator passes. +- No BRIEF_V51_MISSING_SIGNALS error in r.errors. diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index 5c7bef8..bc96ab4 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -485,6 +485,24 @@ test('producing commands tell the operator the flow is THEIR own annotations', ( } }); +test('producing commands emit file:// link in final report (operator-UX contract, 2026-05-13)', () => { + // Operator runs Ghostty / iTerm2 / modern Terminal.app — all support cmd+click + // on file:// URLs. Producing commands MUST emit both forms: (a) plain file:// + // line in the report block, (b) `open file://...` copy-pasteable command. + // Both must reference $ANNOT_HTML (absolute path from scripts/annotate.mjs). + for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { + const text = read(`commands/${f}`); + assert.ok( + /file:\/\/\{\$ANNOT_HTML\}/.test(text), + `commands/${f} must include "file://{$ANNOT_HTML}" plain URL in the final report block`, + ); + assert.ok( + /open file:\/\/\{\$ANNOT_HTML\}/.test(text), + `commands/${f} must include "open file://{$ANNOT_HTML}" copy-pasteable command in the final report block`, + ); + } +}); + test('package.json still has no "npm run render" script (removed in v5.0.1)', () => { const pkg = JSON.parse(read('package.json')); assert.equal( @@ -533,3 +551,63 @@ test('operational files no longer reference trekrevise (v5.0.0 removal)', () => ); } }); + +// --- v5.1 — phase_signals + brief_version 2.1 --- + +test('v5.1 — templates/trekbrief-template.md declares brief_version: "2.1" (quoted)', () => { + const t = read('templates/trekbrief-template.md'); + assert.match(t, /^brief_version: "2\.1"$/m, + 'trekbrief-template.md must declare brief_version: "2.1" (quoted) — unquoted parses as Number and bypasses sequencing gate'); +}); + +test('v5.1 — templates/trekbrief-template.md contains phase_signals: block', () => { + const t = read('templates/trekbrief-template.md'); + assert.match(t, /^phase_signals:$/m, + 'trekbrief-template.md must contain a phase_signals: block in frontmatter'); +}); + +test('v5.1 — HANDOVER-CONTRACTS.md schema row includes phase_signals + phase_signals_partial', () => { + const t = read('docs/HANDOVER-CONTRACTS.md'); + assert.ok(t.includes('| `phase_signals` |'), + 'HANDOVER-CONTRACTS must add a phase_signals row to the Handover 1 schema table'); + assert.ok(t.includes('| `phase_signals_partial` |'), + 'HANDOVER-CONTRACTS must add a phase_signals_partial row to the Handover 1 schema table'); +}); + +test('v5.1 — voyage CLAUDE.md mentions phase_signals', () => { + const t = read('CLAUDE.md'); + assert.ok(t.includes('phase_signals'), + 'voyage CLAUDE.md must document phase_signals (v5.1)'); +}); + +test('v5.1 — voyage README.md mentions phase_signals', () => { + const t = read('README.md'); + assert.ok(t.includes('phase_signals'), + 'voyage README.md must mention phase_signals (v5.1 "What\'s new" bullet)'); +}); + +// --- v5.1.1 — High-effort behavior sub-section per command (Step 10) --- + +test('v5.1.1 — commands/trekplan.md contains ### High-effort behavior (v5.1.1) sub-section', () => { + const t = read('commands/trekplan.md'); + assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m, + 'trekplan.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (Decision B + gemini-bridge)'); +}); + +test('v5.1.1 — commands/trekresearch.md contains ### High-effort behavior (v5.1.1) sub-section', () => { + const t = read('commands/trekresearch.md'); + assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m, + 'trekresearch.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (contrarian-researcher + gemini-bridge always-on)'); +}); + +test('v5.1.1 — commands/trekreview.md contains ### High-effort behavior (v5.1.1) sub-section', () => { + const t = read('commands/trekreview.md'); + assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m, + 'trekreview.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (skip Pass 3 + coordinator normalization)'); +}); + +test('v5.1.1 — commands/trekexecute.md contains ### High-effort behavior (v5.1.1) sub-section', () => { + const t = read('commands/trekexecute.md'); + assert.match(t, /^### High-effort behavior \(v5\.1\.1\)$/m, + 'trekexecute.md must contain ### High-effort behavior (v5.1.1) sub-section under Composition rule (gates_mode = closed)'); +}); diff --git a/plugins/voyage/tests/lib/phase-signal-resolver.test.mjs b/plugins/voyage/tests/lib/phase-signal-resolver.test.mjs new file mode 100644 index 0000000..5461740 --- /dev/null +++ b/plugins/voyage/tests/lib/phase-signal-resolver.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolvePhaseSignal, resolvePhaseSignalFromFile } from '../../lib/profiles/phase-signal-resolver.mjs'; + +const FULL_SIGNALS_FM = { + phase_signals: [ + { phase: 'research', effort: 'low', model: 'sonnet' }, + { phase: 'plan', effort: 'standard' }, + { phase: 'execute', effort: 'high', model: 'opus' }, + { phase: 'review', effort: 'standard', model: 'sonnet' }, + ], +}; + +test('resolvePhaseSignal — returns {effort, model} for all 4 phases on full-signals brief', () => { + for (const phase of ['research', 'plan', 'execute', 'review']) { + const r = resolvePhaseSignal(FULL_SIGNALS_FM, phase); + assert.ok(r && typeof r === 'object', `phase=${phase} should resolve non-null`); + assert.ok(typeof r.effort === 'string', `phase=${phase} should have effort`); + } +}); + +test('resolvePhaseSignal — returns null when brief has no phase_signals', () => { + const r = resolvePhaseSignal({ task: 'x' }, 'plan'); + assert.equal(r, null); +}); + +test('resolvePhaseSignal — returns partial {effort} with model undefined when signal omits model', () => { + const r = resolvePhaseSignal(FULL_SIGNALS_FM, 'plan'); + assert.equal(r.effort, 'standard'); + assert.equal(r.model, undefined); + assert.ok(!('model' in r), 'model key should be absent when not in signal'); +}); + +test('resolvePhaseSignal — returns null when phase is not in PHASE_SIGNAL_PHASES', () => { + assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'brief'), null); + assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'continue'), null); + assert.equal(resolvePhaseSignal(FULL_SIGNALS_FM, 'nonsense'), null); +}); + +test('resolvePhaseSignal — defensive: null/non-object input returns null', () => { + assert.equal(resolvePhaseSignal(null, 'plan'), null); + assert.equal(resolvePhaseSignal(undefined, 'plan'), null); + assert.equal(resolvePhaseSignal('string', 'plan'), null); + assert.equal(resolvePhaseSignal({ phase_signals: 'not-array' }, 'plan'), null); +}); + +test('resolvePhaseSignalFromFile + CLI shim — writes JSON to stdout, exit 0', () => { + const fixture = join(tmpdir(), `phase-signal-test-${process.pid}.md`); + writeFileSync(fixture, `--- +type: trekbrief +brief_version: "2.1" +phase_signals: + - phase: plan + effort: high + model: opus +--- +# x +`); + try { + // Programmatic invocation + const r = resolvePhaseSignalFromFile(fixture, 'plan'); + assert.deepEqual(r, { effort: 'high', model: 'opus' }); + // CLI shim + const helperPath = new URL('../../lib/profiles/phase-signal-resolver.mjs', import.meta.url).pathname; + const out = execFileSync('node', [helperPath, '--brief', fixture, '--phase', 'plan', '--json'], { + encoding: 'utf-8', + }); + const parsed = JSON.parse(out.trim()); + assert.deepEqual(parsed, { effort: 'high', model: 'opus' }); + } finally { + try { unlinkSync(fixture); } catch { /* swallow */ } + } +}); diff --git a/plugins/voyage/tests/lib/profile-resolver.test.mjs b/plugins/voyage/tests/lib/profile-resolver.test.mjs new file mode 100644 index 0000000..4eef940 --- /dev/null +++ b/plugins/voyage/tests/lib/profile-resolver.test.mjs @@ -0,0 +1,62 @@ +// tests/lib/profile-resolver.test.mjs +// v5.1.1 SC5 — non-interference cases for resolvePhaseModel(). +// Verifies the new highest-priority lookup step (brief.phase_signals[phase].model) +// wins over --profile flag and VOYAGE_PROFILE env; falls through cleanly when +// no brief signal is present. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolvePhaseModel } from '../../lib/profiles/resolver.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const FIXTURE = (name) => join(REPO_ROOT, 'tests', 'fixtures', name); + +test('resolvePhaseModel — Case 1: brief signal wins over VOYAGE_PROFILE env', () => { + // brief-effort-low.md pins all 4 phases to model: sonnet. + // env says premium (would normally select opus). Brief must win. + const r = resolvePhaseModel('research', FIXTURE('brief-effort-low.md'), [], { VOYAGE_PROFILE: 'premium' }); + assert.equal(r.model, 'sonnet', `brief signal should beat env; got ${JSON.stringify(r)}`); + assert.equal(r.source, 'brief-signal'); +}); + +test('resolvePhaseModel — Case 2: brief signal wins over --profile flag', () => { + // brief-effort-high.md pins all 4 phases to model: opus. + // flag says economy (would normally select sonnet). Brief must win. + const r = resolvePhaseModel('execute', FIXTURE('brief-effort-high.md'), ['--profile', 'economy'], {}); + assert.equal(r.model, 'opus', `brief signal should beat flag; got ${JSON.stringify(r)}`); + assert.equal(r.source, 'brief-signal'); +}); + +test('resolvePhaseModel — Case 3: no phase_signals → fallthrough to --profile flag', () => { + // brief-without-phase-signals fixture lacks phase_signals entirely. + // --profile balanced is set. Should return balanced.phase_models.plan (= opus per yaml). + const r = resolvePhaseModel('plan', FIXTURE('brief-without-phase-signals.md'), ['--profile', 'balanced'], {}); + assert.equal(r.model, 'opus', `balanced.plan should be opus; got ${JSON.stringify(r)}`); + assert.equal(r.source, 'flag'); +}); + +test('resolvePhaseModel — Case 4: phase not in PHASE_SIGNAL_PHASES falls through gracefully', () => { + // brief-effort-high.md has signals for the 4 supported phases. + // Asking for 'continue' (not in PHASE_SIGNAL_PHASES) must fall through. + // --profile premium is set, so continue resolves to premium.phase_models.continue (= opus). + const r = resolvePhaseModel('continue', FIXTURE('brief-effort-high.md'), ['--profile', 'premium'], {}); + assert.equal(r.model, 'opus', `premium.continue should be opus; got ${JSON.stringify(r)}`); + assert.ok(r.source !== 'brief-signal', 'continue must not resolve via brief-signal'); +}); + +test('resolvePhaseModel — Case 5 (defensive): missing brief file falls through cleanly', () => { + // Non-existent path. Must not throw; must fall through to flag/env/default. + const r = resolvePhaseModel('plan', '/nonexistent/brief.md', ['--profile', 'economy'], {}); + assert.equal(r.model, 'sonnet', 'economy.plan should be sonnet on fallthrough'); + assert.equal(r.source, 'flag'); +}); + +test('resolvePhaseModel — Case 6 (defensive): null briefPath falls through to default', () => { + // null briefPath, no flag, no env → default = premium. + const r = resolvePhaseModel('plan', null, [], {}); + assert.equal(r.model, 'opus', 'premium.plan default = opus'); + assert.equal(r.source, 'default'); +}); diff --git a/plugins/voyage/tests/scripts/annotate.test.mjs b/plugins/voyage/tests/scripts/annotate.test.mjs index 4bd2a9f..3044447 100644 --- a/plugins/voyage/tests/scripts/annotate.test.mjs +++ b/plugins/voyage/tests/scripts/annotate.test.mjs @@ -1,26 +1,29 @@ // 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. +// Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML +// generator. UX modelled on claude-code-100x/build-site.js (pencil +// toggle, intent buttons, form popover, selection-anchoring, localStorage +// persistence, structured markdown export). // // What we pin: // • Output is a complete, self-contained HTML document. -// • Zero external network references in the static HTML. +// • No external or "\n---\n\n# Foo\n'; const html = buildHtml('/abs/path/brief.md', md); - // The raw