diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3fe6a5c..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, revise, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, operator-driven artifact annotation (Handover 8) via a dashboard-centric marketplace playground, multi-session resumption, session decomposition, and headless execution." + "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 b604e94..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/ v4.3.0 — Brief, research, plan, execute, review, revise, continue. Contract-driven Claude Code pipeline (seven-command universal pipeline + multi-session resumption + --gates autonomy chain + Handover 8 annotation pipeline + dashboard-centric marketplace playground). v4.3.0 ships with 3 known re-review findings deferred to v4.3.1 (defense-in-depth + conformance; ready Wave-4 plan exists). + 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 b0bb57f..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 → ` - - - -
- -
-
-

Ingen artifact lastet enda. Lim inn innhold over og trykk «Render».

-
-
- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - diff --git a/plugins/voyage/playwright.config.mjs b/plugins/voyage/playwright.config.mjs deleted file mode 100644 index b4c1fc3..0000000 --- a/plugins/voyage/playwright.config.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: 'tests/e2e', - testMatch: '**/*.spec.mjs', - snapshotPathTemplate: '{testDir}/snapshots/{arg}{ext}', - timeout: 30_000, - expect: { timeout: 5_000 }, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: 0, - reporter: process.env.CI ? 'github' : 'list', - use: { - baseURL: `file://${import.meta.dirname}/playground/`, - trace: 'retain-on-failure', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], -}); diff --git a/plugins/voyage/scripts/annotate.mjs b/plugins/voyage/scripts/annotate.mjs new file mode 100644 index 0000000..2d66294 --- /dev/null +++ b/plugins/voyage/scripts/annotate.mjs @@ -0,0 +1,956 @@ +#!/usr/bin/env node +// scripts/annotate.mjs +// +// Operator-annotation HTML for a voyage artifact (brief.md / plan.md / +// review.md). The producing commands run this on their last step and +// print the file:// link. The operator opens the HTML in their browser, +// the page renders the artifact as a proper article (headings, lists, +// paragraphs, code blocks — not raw lines), and the operator drives every +// annotation themselves: select text or click any element, choose intent +// (Fiks / Endre / Spørsmål), write a comment, save. The sidebar shows +// every annotation grouped by section; Copy Prompt assembles them into +// one structured markdown the operator pastes back into Claude. +// +// UX modelled on the claude-code-100x annotation surface +// (build-site.js, 2026 — same pencil-toggle, intent buttons, form popover, +// localStorage persistence, structured markdown export). +// +// • Operator drives every annotation. No Claude-generated suggestions. +// • Three intent categories: Fiks (fix) / Endre (change) / Spørsmål (question). +// • Element + selection anchoring — clicking an element captures it whole; +// selecting text inside an element captures the exact substring. +// • Section context auto-detected (nearest h1/h2 above). +// • Annotations persist in localStorage keyed on the absolute artifact path. +// • Zero npm deps, zero external network, deterministic output. + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { splitFrontmatter } from '../lib/util/frontmatter.mjs'; + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function deriveTitle(mdText, fallbackName) { + const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText); + if (hasFrontmatter) { + const m = frontmatter.match(/^task:\s*(.+)$/m) || frontmatter.match(/^slug:\s*(.+)$/m); + if (m) return m[1].trim().replace(/^["']|["']$/g, ''); + } + const h1 = mdText.match(/^#\s+(.+)$/m); + if (h1) return h1[1].trim(); + return fallbackName; +} + +// --------------------------------------------------------------------------- +// Markdown → HTML with data-anchor-id on every annotatable element. +// Hand-rolled subset matching what artifact templates emit. +// --------------------------------------------------------------------------- + +function renderInline(escaped) { + let s = escaped.replace(/`([^`]+)`/g, (_, c) => `${c}`); + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, h) => { + const safe = /^(https?:|mailto:|#|\.|\/)/i.test(h) ? h : '#'; + return `${t}`; + }); + s = s.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); + s = s.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}${c}`); + return s; +} + +function renderMarkdown(md) { + const lines = md.replace(/\r\n/g, '\n').split('\n'); + let html = ''; + let anchorId = 0; + const anchor = () => `anch-${anchorId++}`; + let i = 0; + let paraBuf = []; + + const flushPara = () => { + if (paraBuf.length) { + const text = paraBuf.join(' '); + html += `

${renderInline(escapeHtml(text))}

\n`; + paraBuf = []; + } + }; + + while (i < lines.length) { + const line = lines[i]; + + // Fenced code block — NOT annotatable as a whole; we keep it readable + // but skip the data-anchor-id so the operator clicks around it. + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + flushPara(); + const marker = fence[2][0]; + const lang = (fence[3] || '').trim().split(/\s+/)[0]; + const buf = []; + i++; + while (i < lines.length && !new RegExp('^\\s*' + marker + '{3,}\\s*$').test(lines[i])) { + buf.push(lines[i]); + i++; + } + i++; // closing fence + const cls = lang ? ` class="language-${escapeHtml(lang)}"` : ''; + html += `
${escapeHtml(buf.join('\n'))}\n
\n`; + continue; + } + + // ATX heading + const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/); + if (h) { + flushPara(); + const lvl = h[1].length; + html += `${renderInline(escapeHtml(h[2]))}\n`; + i++; + continue; + } + + // Horizontal rule + if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { + flushPara(); + html += '
\n'; + i++; + continue; + } + + // Table + if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && + /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) { + flushPara(); + const rows = []; + while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(lines[i]); i++; } + const cells = (l) => l.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim()); + const header = cells(rows[0]); + const body = rows.slice(2).map(cells); + html += '\n'; + for (const c of header) html += ``; + html += '\n\n'; + for (const r of body) { + html += ''; + for (let k = 0; k < header.length; k++) html += ``; + html += '\n'; + } + html += '\n
${renderInline(escapeHtml(c))}
${renderInline(escapeHtml(r[k] || ''))}
\n'; + continue; + } + + // Blockquote + if (/^\s*>\s?/.test(line)) { + flushPara(); + const buf = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + buf.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + html += `
${renderInline(escapeHtml(buf.join(' ')))}
\n`; + continue; + } + + // Lists — one block, allow blank lines between items + const listMatch = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); + if (listMatch) { + flushPara(); + const items = []; + while (i < lines.length) { + const m = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/); + if (m) { + items.push({ indent: m[1].length, ordered: /\d/.test(m[2]), text: m[3] }); + i++; + } else if (lines[i].trim() === '' && i + 1 < lines.length && + lines[i + 1].match(/^(\s*)([-*+]|\d+[.)])\s+/)) { + i++; + } else { + break; + } + } + html += renderList(items, anchor); + continue; + } + + // Blank + if (line.trim() === '') { + flushPara(); + i++; + continue; + } + + // Default: paragraph accumulation + paraBuf.push(line.trim()); + i++; + } + flushPara(); + return html; +} + +function renderList(items, anchor) { + let html = ''; + const stack = []; + for (const { indent, ordered, text } of items) { + while (stack.length && (indent < stack[stack.length - 1].indent || + (indent === stack[stack.length - 1].indent && ordered !== stack[stack.length - 1].ordered))) { + const top = stack.pop(); + html += top.ordered ? '' : ''; + } + if (!stack.length || indent > stack[stack.length - 1].indent) { + html += ordered ? '
    ' : '
' : ''; + } + return html + '\n'; +} + +// --------------------------------------------------------------------------- +// Build full HTML document +// --------------------------------------------------------------------------- + +function buildHtml(artifactPath, mdText) { + const fileName = basename(artifactPath); + const title = deriveTitle(mdText, fileName); + const { body } = splitFrontmatter(mdText); + const articleHtml = renderMarkdown(body); + return '\n' + + '\n' + + '\n' + + '\n' + + '\n' + + '' + escapeHtml(title) + ' — annotate\n' + + '\n' + + '\n' + + '\n' + + '
\n' + + '
\n' + + '

' + escapeHtml(title) + '

\n' + + '

' + escapeHtml(fileName) + '

\n' + + '
\n' + + '
\n' + + ' \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' + + '
\n' + + '
\n' + + '\n' + + '\n' + + '\n'; +} + +// --------------------------------------------------------------------------- +// Stylesheet — light + dark + print. Design-system-aligned. +// --------------------------------------------------------------------------- + +const STYLE = ` +:root { + --bg: #f7f7f8; + --bg-elev: #ffffff; + --bg-soft: #ececef; + --border: #d6d8dc; + --border-strong: #b3b7bd; + --text: #1a1a1a; + --text-dim: #555a63; + --text-mute: #8a8f97; + --accent: #0855a8; + --accent-soft: #e4ecf6; + --amber: #a86b00; + --amber-soft: #fbeed1; + --green: #1a7f37; + --green-soft: #d5ecdb; + --red: #b3262d; + --red-soft: #f6d9da; + --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 { + --bg: #0e1218; + --bg-elev: #161b22; + --bg-soft: #1c232c; + --border: #2a323c; + --border-strong: #3b4554; + --text: #e5e9ef; + --text-dim: #a5adba; + --text-mute: #6e7681; + --accent: #6db0ee; + --accent-soft: rgba(109, 176, 238, 0.15); + --amber: #d4a017; + --amber-soft: rgba(212, 160, 23, 0.12); + --green: #3fb950; + --green-soft: rgba(63, 185, 80, 0.12); + --red: #f0626a; + --red-soft: rgba(240, 98, 106, 0.12); + --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; 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-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: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; } +.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. Uses concatenation (no template literals) to avoid +// backtick collisions with the outer mjs string assembly. +// --------------------------------------------------------------------------- + +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)) { + annotations = data.annotations; + nextId = data.nextId || (annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1); + } + } catch (e) {} +} +function saveState() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: annotations, nextId: nextId })); } catch (e) {} +} +function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + +// ── 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; + } + } + 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)'; +} + +// ── 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) { + annotations = annotations.filter(function(a) { return a.id !== id; }); + saveState(); + refreshArticleAnnotations(); + renderPanel(); + updateCounts(); + showToast('Annotasjon slettet'); +} + +// ── 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]); + } + } +} + +// ── 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; + } + // 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; + }); + 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)); + }); + }); + 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'); + } + }); + }); +} + +// ── Counts + toggle label ── +function updateCounts() { + annBadge.textContent = String(annotations.length); + copyBtn.disabled = annotations.length === 0; +} + +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; + } + e.preventDefault(); + openForm(e, target); +}); + +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' && 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'); + } +}); + +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++) { + const a = argv[i]; + if (a === '--out') args.out = argv[++i]; + else if (a === '--help' || a === '-h') args.help = true; + else if (!args.input) args.input = a; + } + return args; +} + +function render(inputPath, outputPath) { + if (!existsSync(inputPath)) { + process.stderr.write('annotate: input not found: ' + inputPath + '\n'); + process.exit(2); + } + const text = readFileSync(inputPath, 'utf-8'); + const html = buildHtml(resolve(inputPath), text); + const out = outputPath || inputPath.replace(/\.md$/, '.html'); + writeFileSync(out, html); + return out; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.input) { + process.stdout.write( + 'Usage: annotate [--out ]\n\n' + + 'Builds a self-contained operator-annotation HTML for a voyage\n' + + 'artifact. The operator opens the HTML, 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); + } + const out = render(args.input, args.out); + process.stdout.write(out + '\n'); +} + +export { render, buildHtml, renderMarkdown, parseArgs }; diff --git a/plugins/voyage/scripts/render-artifact.mjs b/plugins/voyage/scripts/render-artifact.mjs deleted file mode 100644 index fcbed66..0000000 --- a/plugins/voyage/scripts/render-artifact.mjs +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node -// scripts/render-artifact.mjs -// CLI renderer for v4.2 — satisfies brief SC1 + SC11 (zero-network, self-eat). -// -// Usage: -// node scripts/render-artifact.mjs [--out ] -// -// Reads input.md, renders it via the same vendored markdown-it + -// markdown-it-front-matter + highlight.js bundle that the browser -// playground uses (playground/lib/*.min.js), and emits a self-contained -// HTML file with inlined CSS + inlined highlight.js so the output renders -// correctly with zero network requests. -// -// Determinism contract (SC11): two invocations on the same input produce -// byte-identical output. No timestamps, no random IDs. - -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { dirname, basename, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(HERE, '..'); -const PLAYGROUND_LIB = join(ROOT, 'playground', 'lib'); -const DS_DIR = join(ROOT, 'playground', 'vendor', 'playground-design-system'); - -// --- argument parsing ------------------------------------------------------- - -function parseArgs(argv) { - const args = { input: null, out: null }; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--out') { - args.out = argv[++i]; - } else if (a === '--help' || a === '-h') { - args.help = true; - } else if (!args.input) { - args.input = a; - } - } - return args; -} - -// --- vendored-lib loader (CommonJS shim) ------------------------------------ - -function loadVendoredScript(name, globalName) { - const src = readFileSync(join(PLAYGROUND_LIB, name), 'utf-8'); - const sandbox = {}; - // Minimal browser-shim: provide window/globalThis aliases the IIFE bundles - // expect when running outside the browser. - const fn = new Function('window', 'globalThis', 'self', src); - fn(sandbox, sandbox, sandbox); - return sandbox[globalName]; -} - -// --- inline-asset loaders --------------------------------------------------- - -function readDsCss() { - const order = [ - 'tokens.css', - 'base.css', - 'fonts.css', - 'components.css', - 'components-tier2.css', - 'components-tier3.css', - 'components-tier3-supplement.css', - 'print.css', - ]; - const parts = []; - for (const f of order) { - const p = join(DS_DIR, f); - if (existsSync(p)) parts.push('/* === ' + f + ' === */\n' + readFileSync(p, 'utf-8')); - } - return parts.join('\n'); -} - -function readHighlightInline() { - // Inline the assembled highlight.min.js so the output HTML can re-highlight - // pre/code blocks on view (purely defensive — they're already pre-highlighted - // server-side at render time, but inlining keeps the static HTML resilient). - // - // Zero-network constraint (SC1): the highlight.js source contains URL - // strings inside language-comment metadata (e.g. references to MDN). These - // are inert string-literals (not network refs) but a literal grep for - // "http://" would still match. Strip URL strings to preserve SC1's - // grep-based check while keeping the runtime functional. - const raw = readFileSync(join(PLAYGROUND_LIB, 'highlight.min.js'), 'utf-8'); - return raw.replace(/https?:\/\/[^\s"'\\)]+/g, 'about:blank'); -} - -// --- renderer --------------------------------------------------------------- - -function escapeHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function render(inputPath, outputPath) { - if (!existsSync(inputPath)) { - process.stderr.write(`render-artifact: input not found: ${inputPath}\n`); - process.exit(2); - } - const text = readFileSync(inputPath, 'utf-8'); - - // Load vendored libs (deterministic — no network, no timestamps in output) - const markdownit = loadVendoredScript('markdown-it.min.js', 'markdownit'); - const markdownitFrontMatter = loadVendoredScript('markdown-it-front-matter.min.js', 'markdownitFrontMatter'); - const hljs = loadVendoredScript('highlight.min.js', 'hljs'); - - let capturedFrontmatter = ''; - const md = markdownit({ - html: true, - linkify: false, - typographer: false, - highlight: function (code, lang) { - if (hljs && lang && hljs.getLanguage(lang)) { - try { - return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value; - } catch (e) { - /* fall through */ - } - } - return ''; - }, - }); - try { - md.use(markdownitFrontMatter, function (fm) { - capturedFrontmatter = fm || ''; - }); - } catch (e) { - process.stderr.write(`render-artifact: front-matter plugin error: ${e.message}\n`); - } - - const bodyHtml = md.render(text); - const fmHtml = capturedFrontmatter - ? '
Frontmatter
' +
-      escapeHtml(capturedFrontmatter) + '
' - : ''; - - // Determine title from frontmatter slug or first H1 fallback - let title = basename(inputPath); - const slugMatch = capturedFrontmatter.match(/^slug:\s*(.+)$/m); - if (slugMatch) title = slugMatch[1].replace(/^["']|["']$/g, ''); - const taskMatch = capturedFrontmatter.match(/^task:\s*(.+)$/m); - if (taskMatch) title = taskMatch[1].replace(/^["']|["']$/g, ''); - - const css = readDsCss(); - const hljsInline = readHighlightInline(); - - // Self-contained HTML — zero network references. Determinism: - // no Date.now(), no Math.random(), no timestamps. - const html = - '\n' + - '\n' + - '\n' + - ' \n' + - ' \n' + - ' ' + escapeHtml(title) + '\n' + - ' \n' + - ' \n' + - '\n' + - '\n' + - '
\n' + - '

' + escapeHtml(title) + '

\n' + - fmHtml + '\n' + - bodyHtml + '\n' + - '
\n' + - '\n' + - '\n'; - - const out = outputPath || inputPath.replace(/\.md$/, '.html'); - writeFileSync(out, html); - process.stdout.write('render-artifact: wrote ' + out + ' (' + Buffer.byteLength(html, 'utf-8') + ' bytes)\n'); - return out; -} - -// --- CLI entry point -------------------------------------------------------- - -if (import.meta.url === `file://${process.argv[1]}`) { - const args = parseArgs(process.argv.slice(2)); - if (args.help || !args.input) { - process.stdout.write( - 'Usage: render-artifact [--out ]\n' + - '\n' + - 'Reads input.md and emits a self-contained HTML file with inlined\n' + - 'CSS + highlight.js. Default output: .html next to input.\n', - ); - process.exit(args.help ? 0 : 2); - } - render(args.input, args.out); -} - -export { render, parseArgs }; diff --git a/plugins/voyage/scripts/vendor-playground-libs.mjs b/plugins/voyage/scripts/vendor-playground-libs.mjs deleted file mode 100644 index 1f90517..0000000 --- a/plugins/voyage/scripts/vendor-playground-libs.mjs +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node -// scripts/vendor-playground-libs.mjs -// Reproducible vendor script for v4.2 playground render-pipeline. -// -// Usage: node scripts/vendor-playground-libs.mjs -// -// Pins (locked per plan-critic B3 — never use highlightjs.org website builder -// or any other interactive UI; this script is fully headless): -// - markdown-it@14.1.0 (UMD bundle copied verbatim) -// - markdown-it-front-matter@0.2.4 (CommonJS module wrapped in IIFE) -// - highlight.js@11.11.1 (5-lang bundle assembled from CommonJS sources) -// - dompurify@3.2.6 (UMD bundle copied verbatim) — v4.3 Step 24 -// -// Output: playground/lib/{markdown-it.min.js, markdown-it-front-matter.min.js, -// highlight.min.js, dompurify.min.js} -// -// All three output files are zero-network browser-loadable scripts that -// expose globals (`window.markdownit`, `window.markdownitFrontMatter`, -// `window.hljs`). They also work under Node.js dynamic-import via the -// pattern in scripts/render-artifact.mjs (UMD + global-eval). - -import { execSync } from 'node:child_process'; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(HERE, '..'); -const OUT = join(ROOT, 'playground', 'lib'); - -const PINS = { - 'markdown-it': '14.1.0', - 'markdown-it-front-matter': '0.2.4', - 'highlight.js': '11.11.1', - // v4.3 Step 24 — pinned ≥ 3.1.1 (PortSwigger HTML-comment mutation-XSS bypass - // was fixed in 3.1.x; 3.2.6 is the current stable line as of 2026-05-10). - 'dompurify': '3.2.6', -}; - -const HL_LANGS = ['yaml', 'json', 'javascript', 'bash', 'markdown', 'diff']; - -function vendor() { - mkdirSync(OUT, { recursive: true }); - - const tmp = mkdtempSync(join(tmpdir(), 'voyage-vendor-')); - const log = (msg) => process.stdout.write(`[vendor] ${msg}\n`); - - try { - // 1. markdown-it — copy UMD min bundle directly - log('packing markdown-it@' + PINS['markdown-it']); - execSync(`npm pack markdown-it@${PINS['markdown-it']} --silent`, { cwd: tmp }); - execSync(`tar xzf markdown-it-${PINS['markdown-it']}.tgz`, { cwd: tmp }); - copyFileSync( - join(tmp, 'package', 'dist', 'markdown-it.min.js'), - join(OUT, 'markdown-it.min.js'), - ); - log(`wrote ${join(OUT, 'markdown-it.min.js')}`); - - // 2. markdown-it-front-matter — wrap CommonJS in IIFE that exposes a global - log('packing markdown-it-front-matter@' + PINS['markdown-it-front-matter']); - execSync(`npm pack markdown-it-front-matter@${PINS['markdown-it-front-matter']} --silent`, { cwd: tmp }); - execSync(`tar xzf markdown-it-front-matter-${PINS['markdown-it-front-matter']}.tgz`, { cwd: tmp }); - const fmSrc = readFileSync(join(tmp, 'package', 'index.js'), 'utf-8'); - const fmBundle = wrapCommonJS('markdownitFrontMatter', fmSrc); - writeFileSync(join(OUT, 'markdown-it-front-matter.min.js'), fmBundle); - log(`wrote ${join(OUT, 'markdown-it-front-matter.min.js')}`); - - // 3. highlight.js — assemble core + 5 languages from CommonJS sources - log('packing highlight.js@' + PINS['highlight.js']); - execSync(`npm pack highlight.js@${PINS['highlight.js']} --silent`, { cwd: tmp }); - execSync(`tar xzf highlight.js-${PINS['highlight.js']}.tgz`, { cwd: tmp }); - - const coreSrc = readFileSync(join(tmp, 'package', 'lib', 'core.js'), 'utf-8'); - const langSrcs = HL_LANGS.map((lang) => ({ - lang, - src: readFileSync(join(tmp, 'package', 'lib', 'languages', `${lang}.js`), 'utf-8'), - })); - - const hlBundle = assembleHighlight(coreSrc, langSrcs); - writeFileSync(join(OUT, 'highlight.min.js'), hlBundle); - log(`wrote ${join(OUT, 'highlight.min.js')} (${HL_LANGS.length} langs)`); - - // 4. dompurify — copy UMD min bundle directly (v4.3 Step 24). - // Mirrors markdown-it-vendoring: npm pack → tar xzf → copy - // dist/purify.min.js → playground/lib/dompurify.min.js. The UMD bundle - // exposes `window.DOMPurify` for browser-loadable use. - log('packing dompurify@' + PINS['dompurify']); - execSync(`npm pack dompurify@${PINS['dompurify']} --silent`, { cwd: tmp }); - execSync(`tar xzf dompurify-${PINS['dompurify']}.tgz`, { cwd: tmp }); - copyFileSync( - join(tmp, 'package', 'dist', 'purify.min.js'), - join(OUT, 'dompurify.min.js'), - ); - log(`wrote ${join(OUT, 'dompurify.min.js')}`); - - // 5. MANIFEST — record the vendored versions for audit - const manifest = { - generated_at: new Date().toISOString(), - pins: PINS, - highlight_languages: HL_LANGS, - output_files: [ - 'markdown-it.min.js', - 'markdown-it-front-matter.min.js', - 'highlight.min.js', - 'dompurify.min.js', - ], - }; - writeFileSync( - join(OUT, 'VENDOR-MANIFEST.json'), - JSON.stringify(manifest, null, 2) + '\n', - ); - log(`wrote ${join(OUT, 'VENDOR-MANIFEST.json')}`); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - log('done'); -} - -/** - * Wrap a CommonJS module body (uses `module.exports = ...`) in an IIFE - * that exposes the export as a global on `window` (browser) or - * `globalThis` (Node). - */ -function wrapCommonJS(globalName, src) { - return [ - `// vendored by scripts/vendor-playground-libs.mjs — DO NOT EDIT`, - `// global: ${globalName}`, - `(function (root, factory) {`, - ` var __mod = { exports: {} };`, - ` (function (module, exports) {`, - ` ${src.replace(/\n/g, '\n ')}`, - ` })(__mod, __mod.exports);`, - ` root[${JSON.stringify(globalName)}] = __mod.exports;`, - `})(typeof window !== 'undefined' ? window : globalThis);`, - ``, - ].join('\n'); -} - -/** - * Assemble a self-contained highlight.js IIFE with core + N languages. - * - * Output exposes `window.hljs` (and `globalThis.hljs` under Node). - */ -function assembleHighlight(coreSrc, langSrcs) { - const parts = [ - `// vendored by scripts/vendor-playground-libs.mjs — DO NOT EDIT`, - `// global: hljs (highlight.js@${PINS['highlight.js']} — core + ${langSrcs.map(l => l.lang).join('/')})`, - `(function (root) {`, - ` function loadCommonJS(src) {`, - ` var __mod = { exports: {} };`, - ` var fn = new Function('module', 'exports', src);`, - ` fn(__mod, __mod.exports);`, - ` return __mod.exports;`, - ` }`, - ` var coreSrc = ${JSON.stringify(coreSrc)};`, - ` var hljs = loadCommonJS(coreSrc);`, - ]; - for (const { lang, src } of langSrcs) { - parts.push(` var lang_${lang.replace(/\W/g, '_')} = loadCommonJS(${JSON.stringify(src)});`); - parts.push(` hljs.registerLanguage(${JSON.stringify(lang)}, lang_${lang.replace(/\W/g, '_')});`); - } - parts.push(` root.hljs = hljs;`); - parts.push(`})(typeof window !== 'undefined' ? window : globalThis);`); - parts.push(''); - return parts.join('\n'); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - vendor(); -} - -export { vendor, wrapCommonJS, assembleHighlight }; diff --git a/plugins/voyage/settings.json b/plugins/voyage/settings.json index 4903705..2f9a447 100644 --- a/plugins/voyage/settings.json +++ b/plugins/voyage/settings.json @@ -27,12 +27,5 @@ "enabled": true, "statsFile": "trekresearch-stats.jsonl" } - }, - "trekrevise": { - "defaultMode": "default", - "tracking": { - "enabled": true, - "statsFile": "trekrevise-stats.jsonl" - } } } \ No newline at end of file diff --git a/plugins/voyage/templates/plan-template.md b/plugins/voyage/templates/plan-template.md index 40b0ff2..f249ff4 100644 --- a/plugins/voyage/templates/plan-template.md +++ b/plugins/voyage/templates/plan-template.md @@ -14,22 +14,6 @@ source_findings: --- --> - - # {Task Title} > **Plan quality: {grade}** ({score}/100) — {APPROVE | APPROVE_WITH_NOTES | REVISE | REPLAN} diff --git a/plugins/voyage/templates/trekbrief-template.md b/plugins/voyage/templates/trekbrief-template.md index bc7088c..e0c2232 100644 --- a/plugins/voyage/templates/trekbrief-template.md +++ b/plugins/voyage/templates/trekbrief-template.md @@ -1,22 +1,6 @@ - - --- type: trekbrief -brief_version: 2.0 +brief_version: "2.1" created: {YYYY-MM-DD} task: "{one-line task description}" slug: {slug} @@ -26,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/templates/trekreview-template.md b/plugins/voyage/templates/trekreview-template.md index 3a5491f..a47c7cb 100644 --- a/plugins/voyage/templates/trekreview-template.md +++ b/plugins/voyage/templates/trekreview-template.md @@ -1,19 +1,3 @@ - - --- type: trekreview review_version: "1.0" 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/e2e/snapshots/voyage-playground-dark.png b/plugins/voyage/tests/e2e/snapshots/voyage-playground-dark.png deleted file mode 100644 index 3ad1ce7..0000000 Binary files a/plugins/voyage/tests/e2e/snapshots/voyage-playground-dark.png and /dev/null differ diff --git a/plugins/voyage/tests/e2e/snapshots/voyage-playground-light.png b/plugins/voyage/tests/e2e/snapshots/voyage-playground-light.png deleted file mode 100644 index ef928a4..0000000 Binary files a/plugins/voyage/tests/e2e/snapshots/voyage-playground-light.png and /dev/null differ diff --git a/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs b/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs deleted file mode 100644 index b8f2bea..0000000 --- a/plugins/voyage/tests/e2e/voyage-playground-a11y.spec.mjs +++ /dev/null @@ -1,143 +0,0 @@ -// tests/e2e/voyage-playground-a11y.spec.mjs -// v4.3 Group D e2e a11y + pixel-diff + SC24 XSS guard specs. -// -// Tests: -// 1. Light-theme axe-core scan — zero critical/serious violations (absolute) -// 2. Dark-theme axe-core scan — zero critical/serious violations (absolute) -// 3. SC1.6 inline gallery — data:image PNG rendered via scheduleRender hook -// 4. Pixel-diff smoke (1280×900) against baseline PNGs in -// tests/e2e/snapshots/. Threshold maxDiffPixelRatio: 0.02. -// 5. SC24-security — script injection in artifact body does not execute -// -// SC2 authoritative verification (axe-core). v4.3 Sesjon 17 (Wave 3 Step 5) -// converted the SC2 assertion from delta-baseline to absolute zero-violation -// after Wave 2 remediation (Step 4 color-contrast fix + Step 3 sidebar -// toggle restructure) reduced the critical/serious count to zero. - -import { test, expect } from '@playwright/test'; -import AxeBuilder from '@axe-core/playwright'; - -test.describe('voyage-playground a11y (axe-core)', () => { - test('light theme — zero critical/serious violations (absolute)', async ({ page }) => { - await page.goto('voyage-playground.html'); - await page.evaluate(() => { - window.localStorage.setItem('voyage-theme', 'light'); - document.documentElement.setAttribute('data-theme', 'light'); - document.documentElement.style.colorScheme = 'light'; - }); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .analyze(); - const violations = results.violations.filter(v => ['critical','serious'].includes(v.impact)); - expect( - violations, - JSON.stringify(violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })), null, 2), - ).toEqual([]); - }); - - test('dark theme — zero critical/serious violations (absolute)', async ({ page }) => { - await page.goto('voyage-playground.html'); - await page.evaluate(() => { - window.localStorage.setItem('voyage-theme', 'dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - document.documentElement.style.colorScheme = 'dark'; - }); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); - const results = await new AxeBuilder({ page }) - .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) - .analyze(); - const violations = results.violations.filter(v => ['critical','serious'].includes(v.impact)); - expect( - violations, - JSON.stringify(violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })), null, 2), - ).toEqual([]); - }); - - // v4.3 Step 8 — inline screenshot gallery (finding 31d28f65). - // Injects a pre-built artifacts object with screenshots[] via the - // window.__voyage.scheduleRender hook (avoids webkitdirectory which - // is not programmatically triggerable). Asserts the dashboard renders - // at least one data:image PNG tag. - test('SC1.6 inline gallery — data:image PNGs rendered (31d28f65)', async ({ page }) => { - await page.goto('voyage-playground.html'); - await page.waitForLoadState('domcontentloaded'); - // 1×1 transparent PNG (same base64 as the fixture file) - const SAMPLE_DATA_URL = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='; - await page.evaluate((dataUrl) => { - window.__voyage.scheduleRender({ - artifacts: { - basePath: 'fixture-project', - storageKey: 'voyage_proj_fixture', - brief: { path: 'brief.md', content: '# Fixture', frontmatter: {} }, - plan: null, - review: null, - progress: null, - research: [], - architecture: { overview: null, gaps: null, looseFiles: [] }, - screenshots: [{ path: 'docs/screenshots/dashboard/sample.png', dataUrl: dataUrl }], - looseFiles: [], - }, - }); - }, SAMPLE_DATA_URL); - // The gallery is rendered inside #voyage-dashboard - const imgCount = await page.locator('#voyage-dashboard img[src^="data:image/png"]').count(); - expect(imgCount, 'expected at least one data:image/png in the gallery').toBeGreaterThan(0); - }); - - test('pixel-diff smoke 1280×900 — light + dark within 2% threshold (SC1 backup)', async ({ page }) => { - await page.setViewportSize({ width: 1280, height: 900 }); - // Light theme baseline - await page.goto('voyage-playground.html'); - await page.evaluate(() => { - window.localStorage.setItem('voyage-theme', 'light'); - document.documentElement.setAttribute('data-theme', 'light'); - document.documentElement.style.colorScheme = 'light'; - }); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); - await expect(page).toHaveScreenshot('voyage-playground-light.png', { - maxDiffPixelRatio: 0.02, - fullPage: false, - }); - - // Dark theme baseline - await page.evaluate(() => { - window.localStorage.setItem('voyage-theme', 'dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - document.documentElement.style.colorScheme = 'dark'; - }); - await page.reload(); - await page.waitForLoadState('domcontentloaded'); - await expect(page).toHaveScreenshot('voyage-playground-dark.png', { - maxDiffPixelRatio: 0.02, - fullPage: false, - }); - }); - - // v4.3 Step 2 — Group D Playwright XSS injection runtime guard - // (finding 1d3591d4). Behavioral counterpart to the DOMPurify fix in - // renderArtifact (Step 1). Injects a markdown - // payload via scheduleRender and verifies neither a JS dialog fires nor - // a \n# title', - }); - }); - expect(dialogCount, `expected zero dialogs but got ${dialogCount}`).toBe(0); - expect(await page.locator('#voyage-viewport script').count()).toBe(0); - }); -}); diff --git a/plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs b/plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs deleted file mode 100644 index 5d2ad28..0000000 --- a/plugins/voyage/tests/e2e/voyage-playground-network.spec.mjs +++ /dev/null @@ -1,33 +0,0 @@ -// tests/e2e/voyage-playground-network.spec.mjs -// v4.3 Step 30 — Group D SC7 authoritative network-intercept gate. -// -// Instruments page.on('request', ...) to capture every outbound request -// during playground load. Allowlist: nothing (zero external requests). -// All assets MUST be bundled locally (./lib/, ./vendor/, file://...). -// -// Why authoritative: voyage-playground.test.mjs already greps the static -// HTML for http/https URLs (Step 28 SC7), but a runtime intercept also -// catches fetch()/XHR/import calls that are constructed dynamically. - -import { test, expect } from '@playwright/test'; - -test.describe('voyage-playground network — SC7 zero external requests', () => { - test('no http/https requests during page load', async ({ page }) => { - const externalRequests = []; - - page.on('request', (request) => { - const url = request.url(); - // file:// URLs are local — playground is loaded via file:// baseURL - if (url.startsWith('file://') || url.startsWith('data:') || url.startsWith('blob:')) { - return; - } - // Anything else is external (http://, https://, ws://, ftp://, etc.) - externalRequests.push({ url, method: request.method(), resourceType: request.resourceType() }); - }); - - await page.goto('voyage-playground.html'); - await page.waitForLoadState('networkidle'); - - expect(externalRequests, JSON.stringify(externalRequests, null, 2)).toEqual([]); - }); -}); diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-brief.md b/plugins/voyage/tests/fixtures/annotation/annotation-brief.md deleted file mode 100644 index 864b3b9..0000000 --- a/plugins/voyage/tests/fixtures/annotation/annotation-brief.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: trekbrief -brief_version: "1.0" -task: Demo task for annotation round-trip fixture -slug: annotation-brief-demo -research_topics: 0 -research_status: complete ---- - -# Demo brief for annotation round-trip - -This fixture is used by `tests/integration/annotation-roundtrip.test.mjs` -to verify SC2 (byte-identical empty-anchor round-trip) and SC7 (per-target -isolation against `validateBrief`). - -It carries no anchors. The round-trip test runs: -`stripAnchors(addAnchors(body, [])) === body`. - -## Intent - -Provide a minimal brief that validates against `brief-validator.mjs` so -the round-trip integration test has a real artifact to revise. - -## Goal - -The brief should validate cleanly (no errors, no warnings) and contain -enough body text that adding an anchor and stripping it back is a -non-trivial operation. - -## Success Criteria - -- File parses via `parseDocument`. -- `validateBrief` returns `valid: true`. -- `stripAnchors(addAnchors(body, []))` is byte-identical to body. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-example.md b/plugins/voyage/tests/fixtures/annotation/annotation-example.md deleted file mode 100644 index bbbc51b..0000000 --- a/plugins/voyage/tests/fixtures/annotation/annotation-example.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -type: trekplan-fixture -plan_version: "1.7" -created: 2026-05-09 -slug: annotation-example ---- - -# Sample plan with one anchor - -This fixture is referenced by `docs/annotation-quickstart.md` and the SC12 -machine-proxy verification (`parseAnchors` exits 0). - -## Section A - -A normal paragraph in section A. - - - -## Section B - -A paragraph in section B that the anchor above refers to. The anchor is -placed on its own line with a blank line above and below — the canonical -v4.2 placement disipline. - -## Section C - -Another paragraph. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md b/plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md deleted file mode 100644 index 09a0aa5..0000000 --- a/plugins/voyage/tests/fixtures/annotation/annotation-plan-large.md +++ /dev/null @@ -1,1090 +0,0 @@ ---- -plan_version: 1.7 -profile: balanced ---- - -# Scale plan for annotation round-trip (51 steps) - -This fixture is used by tests/integration/annotation-roundtrip.test.mjs to verify SC3 (>=50 steps + >=100 anchors) without breaking the parser at scale. - -## Context - -Each step is a sentinel-only step with a valid manifest. The plan validates against plan-validator --strict. - -## Implementation Plan - -### Step 1: Sentinel step 1 - -- **Files:** `tmp/sentinel-1.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-1". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-1.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 1"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-1.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 1" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 2: Sentinel step 2 - -- **Files:** `tmp/sentinel-2.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-2". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-2.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 2"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-2.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 2" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 3: Sentinel step 3 - -- **Files:** `tmp/sentinel-3.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-3". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-3.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 3"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-3.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 3" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 4: Sentinel step 4 - -- **Files:** `tmp/sentinel-4.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-4". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-4.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 4"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-4.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 4" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 5: Sentinel step 5 - -- **Files:** `tmp/sentinel-5.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-5". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-5.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 5"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-5.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 5" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 6: Sentinel step 6 - -- **Files:** `tmp/sentinel-6.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-6". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-6.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 6"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-6.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 6" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 7: Sentinel step 7 - -- **Files:** `tmp/sentinel-7.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-7". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-7.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 7"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-7.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 7" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 8: Sentinel step 8 - -- **Files:** `tmp/sentinel-8.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-8". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-8.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 8"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-8.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 8" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 9: Sentinel step 9 - -- **Files:** `tmp/sentinel-9.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-9". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-9.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 9"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-9.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 9" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 10: Sentinel step 10 - -- **Files:** `tmp/sentinel-10.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-10". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-10.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 10"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-10.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 10" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 11: Sentinel step 11 - -- **Files:** `tmp/sentinel-11.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-11". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-11.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 11"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-11.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 11" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 12: Sentinel step 12 - -- **Files:** `tmp/sentinel-12.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-12". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-12.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 12"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-12.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 12" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 13: Sentinel step 13 - -- **Files:** `tmp/sentinel-13.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-13". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-13.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 13"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-13.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 13" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 14: Sentinel step 14 - -- **Files:** `tmp/sentinel-14.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-14". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-14.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 14"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-14.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 14" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 15: Sentinel step 15 - -- **Files:** `tmp/sentinel-15.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-15". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-15.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 15"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-15.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 15" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 16: Sentinel step 16 - -- **Files:** `tmp/sentinel-16.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-16". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-16.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 16"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-16.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 16" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 17: Sentinel step 17 - -- **Files:** `tmp/sentinel-17.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-17". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-17.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 17"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-17.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 17" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 18: Sentinel step 18 - -- **Files:** `tmp/sentinel-18.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-18". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-18.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 18"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-18.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 18" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 19: Sentinel step 19 - -- **Files:** `tmp/sentinel-19.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-19". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-19.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 19"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-19.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 19" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 20: Sentinel step 20 - -- **Files:** `tmp/sentinel-20.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-20". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-20.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 20"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-20.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 20" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 21: Sentinel step 21 - -- **Files:** `tmp/sentinel-21.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-21". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-21.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 21"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-21.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 21" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 22: Sentinel step 22 - -- **Files:** `tmp/sentinel-22.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-22". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-22.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 22"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-22.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 22" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 23: Sentinel step 23 - -- **Files:** `tmp/sentinel-23.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-23". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-23.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 23"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-23.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 23" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 24: Sentinel step 24 - -- **Files:** `tmp/sentinel-24.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-24". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-24.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 24"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-24.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 24" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 25: Sentinel step 25 - -- **Files:** `tmp/sentinel-25.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-25". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-25.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 25"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-25.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 25" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 26: Sentinel step 26 - -- **Files:** `tmp/sentinel-26.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-26". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-26.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 26"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-26.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 26" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 27: Sentinel step 27 - -- **Files:** `tmp/sentinel-27.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-27". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-27.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 27"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-27.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 27" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 28: Sentinel step 28 - -- **Files:** `tmp/sentinel-28.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-28". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-28.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 28"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-28.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 28" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 29: Sentinel step 29 - -- **Files:** `tmp/sentinel-29.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-29". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-29.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 29"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-29.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 29" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 30: Sentinel step 30 - -- **Files:** `tmp/sentinel-30.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-30". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-30.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 30"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-30.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 30" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 31: Sentinel step 31 - -- **Files:** `tmp/sentinel-31.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-31". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-31.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 31"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-31.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 31" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 32: Sentinel step 32 - -- **Files:** `tmp/sentinel-32.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-32". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-32.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 32"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-32.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 32" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 33: Sentinel step 33 - -- **Files:** `tmp/sentinel-33.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-33". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-33.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 33"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-33.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 33" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 34: Sentinel step 34 - -- **Files:** `tmp/sentinel-34.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-34". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-34.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 34"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-34.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 34" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 35: Sentinel step 35 - -- **Files:** `tmp/sentinel-35.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-35". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-35.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 35"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-35.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 35" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 36: Sentinel step 36 - -- **Files:** `tmp/sentinel-36.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-36". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-36.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 36"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-36.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 36" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 37: Sentinel step 37 - -- **Files:** `tmp/sentinel-37.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-37". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-37.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 37"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-37.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 37" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 38: Sentinel step 38 - -- **Files:** `tmp/sentinel-38.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-38". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-38.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 38"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-38.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 38" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 39: Sentinel step 39 - -- **Files:** `tmp/sentinel-39.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-39". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-39.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 39"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-39.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 39" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 40: Sentinel step 40 - -- **Files:** `tmp/sentinel-40.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-40". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-40.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 40"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-40.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 40" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 41: Sentinel step 41 - -- **Files:** `tmp/sentinel-41.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-41". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-41.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 41"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-41.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 41" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 42: Sentinel step 42 - -- **Files:** `tmp/sentinel-42.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-42". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-42.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 42"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-42.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 42" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 43: Sentinel step 43 - -- **Files:** `tmp/sentinel-43.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-43". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-43.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 43"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-43.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 43" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 44: Sentinel step 44 - -- **Files:** `tmp/sentinel-44.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-44". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-44.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 44"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-44.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 44" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 45: Sentinel step 45 - -- **Files:** `tmp/sentinel-45.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-45". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-45.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 45"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-45.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 45" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 46: Sentinel step 46 - -- **Files:** `tmp/sentinel-46.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-46". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-46.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 46"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-46.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 46" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 47: Sentinel step 47 - -- **Files:** `tmp/sentinel-47.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-47". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-47.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 47"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-47.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 47" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 48: Sentinel step 48 - -- **Files:** `tmp/sentinel-48.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-48". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-48.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 48"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-48.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 48" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 49: Sentinel step 49 - -- **Files:** `tmp/sentinel-49.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-49". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-49.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 49"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-49.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 49" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 50: Sentinel step 50 - -- **Files:** `tmp/sentinel-50.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-50". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-50.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 50"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-50.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 50" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 51: Sentinel step 51 - -- **Files:** `tmp/sentinel-51.txt` (new) -- **Changes:** Create sentinel file with the literal content "step-51". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-51.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 51"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-51.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 51" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -## Verification - -- All 51 sentinel files exist. -- npm test passes. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-plan.md b/plugins/voyage/tests/fixtures/annotation/annotation-plan.md deleted file mode 100644 index 63f0341..0000000 --- a/plugins/voyage/tests/fixtures/annotation/annotation-plan.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -plan_version: 1.7 -profile: balanced ---- - -# Demo plan for annotation round-trip - -This fixture is used by `tests/integration/annotation-roundtrip.test.mjs` -to verify SC2 (byte-identical empty-anchor round-trip) and SC7 (per-target -isolation against `validatePlan`). - -## Context - -A minimal plan with two steps. Each step has a Manifest block so -`plan-validator --strict` accepts the file. - -## Implementation Plan - -### Step 1: Touch a sentinel file - -- **Files:** `tmp/sentinel-1.txt` (new) -- **Changes:** Create the sentinel file with the literal content "step-1". -- **Reuses:** none. -- **Test first:** none — sentinel-only step. -- **Verify:** `test -f tmp/sentinel-1.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 1"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-1.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 1" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 2: Touch a second sentinel file - -- **Files:** `tmp/sentinel-2.txt` (new) -- **Changes:** Create the sentinel file with the literal content "step-2". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-2.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 2"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-2.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 2" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -## Verification - -- `npm test` passes. -- Both sentinel files exist. diff --git a/plugins/voyage/tests/fixtures/annotation/annotation-review.md b/plugins/voyage/tests/fixtures/annotation/annotation-review.md deleted file mode 100644 index c680ea7..0000000 --- a/plugins/voyage/tests/fixtures/annotation/annotation-review.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -type: trekreview -review_version: "1.0" -task: Demo review for annotation round-trip -slug: annotation-review-demo -project_dir: .claude/projects/2026-05-09-annotation-demo -brief_path: .claude/projects/2026-05-09-annotation-demo/brief.md -scope_sha_end: 0000000000000000000000000000000000000000 -reviewed_files_count: 0 -findings: [] ---- - -# Demo review for annotation round-trip - -This fixture is used by `tests/integration/annotation-roundtrip.test.mjs` -to verify SC2 (byte-identical empty-anchor round-trip) and SC7 (per-target -isolation against `validateReview`). - -## Executive Summary - -Verdict: ALLOW. No findings. This is a synthetic fixture used to exercise -the round-trip mechanics; it does not represent a real review. - -## Coverage - -| File | Treatment | -|------|-----------| -| _none_ | _no diff_ | - -## Remediation Summary - -No remediation needed. ALLOW. 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/fixtures/playground/v43-export-bundle.json b/plugins/voyage/tests/fixtures/playground/v43-export-bundle.json deleted file mode 100644 index 200fa0b..0000000 --- a/plugins/voyage/tests/fixtures/playground/v43-export-bundle.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schema_version": 1, - "exported_at": "2026-05-10T18:00:00Z", - "target_artifact": "plan", - "target_filename": "annotated-plan.md", - "annotations": [ - { - "id": "ANN-0001", - "target_artifact": "plan", - "target_anchor": "step-1-sentinel-touch", - "intent": "question", - "comment": "Should this sentinel use a deterministic timestamp?", - "timestamp": "2026-05-10T18:01:00Z" - }, - { - "id": "ANN-0002", - "target_artifact": "plan", - "target_anchor": "step-2-sentinel-touch-paired", - "intent": "fix", - "comment": "Step 2 manifest should reference Step 1 in must_contain.", - "timestamp": "2026-05-10T18:02:00Z" - } - ], - "annotation_digest": "PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME" -} diff --git a/plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md b/plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md deleted file mode 100644 index f334698..0000000 --- a/plugins/voyage/tests/fixtures/playground/v43-plan-pre-annotate.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -plan_version: 1.7 -profile: balanced -revision: 0 ---- - -# v4.3 fixture — pre-annotate plan - -Minimal plan used by Group C tests to seed an annotated round-trip. -Two anchors target `Step 1` and `Step 2` so the export-bundle has at -least 2 ANN-IDs to canonicalize for `annotation_digest`. - -## Context - -Fixture only — not executed. Anchors below match the v4.2 anchor format -`` and -sit on their own line surrounded by blank lines (block-boundary rule). - -## Implementation Plan - -### Step 1: Sentinel touch - - - -- **Files:** `tmp/sentinel-1.txt` (new) -- **Changes:** Create the sentinel file with the literal content "step-1". -- **Reuses:** none. -- **Test first:** none — sentinel-only step. -- **Verify:** `test -f tmp/sentinel-1.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 1"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-1.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 1" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -### Step 2: Sentinel touch (paired) - - - -- **Files:** `tmp/sentinel-2.txt` (new) -- **Changes:** Create the sentinel file with the literal content "step-2". -- **Reuses:** none. -- **Test first:** none. -- **Verify:** `test -f tmp/sentinel-2.txt` -- **On failure:** revert. -- **Checkpoint:** `git commit -m "chore: sentinel step 2"` -- **Manifest:** - ```yaml - manifest: - expected_paths: - - tmp/sentinel-2.txt - min_file_count: 1 - commit_message_pattern: "^chore: sentinel step 2" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - ``` - -## Verification - -- Both sentinel files exist after execution. diff --git a/plugins/voyage/tests/fixtures/screenshot-project/brief.md b/plugins/voyage/tests/fixtures/screenshot-project/brief.md deleted file mode 100644 index d52957a..0000000 --- a/plugins/voyage/tests/fixtures/screenshot-project/brief.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -task: Screenshot gallery fixture for Group D test -slug: screenshot-project -project_dir: tests/fixtures/screenshot-project ---- - -# Screenshot fixture brief - -Minimal brief.md so `loadProjectDirectory` reaches its render phase -without emitting the "brief.md mangler" warning. Real verification -is the data:image PNG count assertion in the Group D test. diff --git a/plugins/voyage/tests/fixtures/screenshot-project/docs/screenshots/dashboard/sample.png b/plugins/voyage/tests/fixtures/screenshot-project/docs/screenshots/dashboard/sample.png deleted file mode 100644 index 0f2de37..0000000 Binary files a/plugins/voyage/tests/fixtures/screenshot-project/docs/screenshots/dashboard/sample.png and /dev/null differ diff --git a/plugins/voyage/tests/integration/annotation-block-boundary.test.mjs b/plugins/voyage/tests/integration/annotation-block-boundary.test.mjs deleted file mode 100644 index 3dd2285..0000000 --- a/plugins/voyage/tests/integration/annotation-block-boundary.test.mjs +++ /dev/null @@ -1,168 +0,0 @@ -// tests/integration/annotation-block-boundary.test.mjs -// Step 17 — verify relocateAnchorsToBlockBoundaries pure-function transforms -// markdown anchors away from atomic-block interiors (fenced code, tables, -// deeply-nested lists) toward the block-boundary line. -// -// Function lives in playground/voyage-playground.html as inline-script (file:// -// compat). We extract it via balanced-brace scan and exercise via Function(). - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '..', '..'); -const HTML = join(ROOT, 'playground', 'voyage-playground.html'); - -function extractFunctionSource(text, fnName) { - const needle = `function ${fnName}`; - const start = text.indexOf(needle); - if (start === -1) return null; - const braceStart = text.indexOf('{', start); - if (braceStart === -1) return null; - let depth = 0; - for (let i = braceStart; i < text.length; i++) { - if (text[i] === '{') depth++; - else if (text[i] === '}') { - depth--; - if (depth === 0) return text.slice(start, i + 1); - } - } - return null; -} - -function loadRelocate() { - const html = readFileSync(HTML, 'utf-8'); - const src = extractFunctionSource(html, 'relocateAnchorsToBlockBoundaries'); - if (!src) throw new Error('relocateAnchorsToBlockBoundaries not found in HTML'); - // Function() factory creates an isolated scope; safe for pure function. - // eslint-disable-next-line no-new-func - const factory = new Function(`${src}; return relocateAnchorsToBlockBoundaries;`); - return factory(); -} - -const relocate = loadRelocate(); - -test('relocateAnchorsToBlockBoundaries returns input unchanged when anchors empty', () => { - const md = 'Line 1\nLine 2\nLine 3\n'; - assert.equal(relocate(md, []), md); -}); - -test('relocateAnchorsToBlockBoundaries leaves anchor outside atomic block at original line', () => { - const lines = []; - for (let i = 1; i <= 20; i++) lines.push(`Line ${i}`); - const md = lines.join('\n'); - const out = relocate(md, [{ id: 'ANN-0001', target: 'sec-a', line: 5 }]); - const outLines = out.split('\n'); - // Anchor injected at output line 5 (1-indexed = index 4); blank line at index 5 - assert.match(outLines[4], /\s*$/; -const VOYAGE_ANCHOR_ATTR_RE = /(\w+)="([^"]*)"/g; -const VOYAGE_ANCHOR_ID_RE = /^ANN-\d{4}$/; -const VOYAGE_ANCHOR_INTENTS = ['fix', 'change', 'question', 'block']; - -function parseAnchor(line) { - if (typeof line !== 'string') return null; - const m = line.match(VOYAGE_ANCHOR_RE); - if (!m) return null; - const attrs = {}; - VOYAGE_ANCHOR_ATTR_RE.lastIndex = 0; - let a; - while ((a = VOYAGE_ANCHOR_ATTR_RE.exec(m[2])) !== null) attrs[a[1]] = a[2]; - if (!attrs.id || !VOYAGE_ANCHOR_ID_RE.test(attrs.id)) return null; - if (typeof attrs.target !== 'string' || attrs.target.length === 0) return null; - if (attrs.line !== undefined) { - const n = parseInt(attrs.line, 10); - if (!Number.isInteger(n) || n <= 0) return null; - } - if (attrs.snippet && attrs.snippet.length > 80) return null; - if (attrs.intent && VOYAGE_ANCHOR_INTENTS.indexOf(attrs.intent) === -1) return null; - return { id: attrs.id, target: attrs.target }; -} - -function stripUnsafeComments(text) { - if (typeof text !== 'string') return text; - return text.replace(//g, (match) => parseAnchor(match) ? match : ''); -} - -// --- Step 25 — HTML-comment indirect prompt-injection mitigation --------- - -test('stripUnsafeComments — drops prompt-injection comment, keeps voyage:anchor (v4.3 Step 25)', () => { - const fixture = [ - '# Document', - '', - '', - '', - '', - 'Body text.', - ].join('\n'); - const out = stripUnsafeComments(fixture); - assert.ok(!out.includes('IGNORE PREVIOUS INSTRUCTIONS'), 'malicious comment must be stripped'); - assert.ok(out.includes('voyage:anchor id="ANN-0001"'), 'valid voyage:anchor must survive'); -}); - -test('stripUnsafeComments — strips arbitrary HTML comments (v4.3 Step 25)', () => { - const fixture = '

Hi

'; - const out = stripUnsafeComments(fixture); - assert.equal(out, '

Hi

', 'all non-voyage comments must be stripped'); -}); - -test('stripUnsafeComments — rejects malformed voyage:anchor (Sec T4) (v4.3 Step 25)', () => { - // A comment that LOOKS like voyage:anchor but fails the strict allowlist - // (missing id, bad id format, missing target, bogus intent). - const cases = [ - '', // no id - '', // bad id format - '', // no target - '', // bad intent - ]; - for (const c of cases) { - const out = stripUnsafeComments('A\n' + c + '\nB'); - assert.ok(!out.includes('voyage:anchor'), 'malformed comment "' + c + '" must be stripped'); - } -}); - -test('voyage-playground.html stripUnsafeComments wired into renderArtifact (v4.3 Step 25)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Function declared - assert.match(text, /function\s+stripUnsafeComments\s*\(/, 'stripUnsafeComments() function required'); - // Renderer must call it before md.render to enforce the allowlist - assert.match(text, /var\s+safeText\s*=\s*stripUnsafeComments\(/, 'renderArtifact must call stripUnsafeComments before md.render'); -}); - -// --- Step 26 — path-traversal + symlink/dotfile filter ------------------ -// Mirror of the browser-side isProjectPathSafe predicate. Kept verbatim so -// the playground's filter cannot drift without breaking this test. -function isProjectPathSafe(inside) { - if (typeof inside !== 'string' || !inside) return false; - if (inside.indexOf('..') !== -1) return false; - if (inside.charAt(0) === '.') return false; - if (inside.indexOf('/.') !== -1) return false; - if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false; - if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false; - if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false; - return true; -} - -test('isProjectPathSafe — rejects path-traversal (v4.3 Step 26)', () => { - assert.equal(isProjectPathSafe('../etc/passwd'), false); - assert.equal(isProjectPathSafe('foo/../etc/passwd'), false); - assert.equal(isProjectPathSafe('a/b/../c'), false); -}); - -test('isProjectPathSafe — rejects dotfiles at root + nested (v4.3 Step 26)', () => { - assert.equal(isProjectPathSafe('.gitignore'), false); - assert.equal(isProjectPathSafe('.git/config'), false); - assert.equal(isProjectPathSafe('.DS_Store'), false); - assert.equal(isProjectPathSafe('.env'), false); - assert.equal(isProjectPathSafe('docs/.hidden/file'), false); - assert.equal(isProjectPathSafe('research/.git/HEAD'), false); -}); - -test('isProjectPathSafe — rejects node_modules / dist / build at any depth (v4.3 Step 26)', () => { - assert.equal(isProjectPathSafe('node_modules/foo/index.js'), false); - assert.equal(isProjectPathSafe('packages/sub/node_modules/x'), false); - assert.equal(isProjectPathSafe('dist/bundle.js'), false); - assert.equal(isProjectPathSafe('packages/x/dist/y.js'), false); - assert.equal(isProjectPathSafe('build/output.js'), false); - assert.equal(isProjectPathSafe('packages/x/build/y.js'), false); -}); - -test('isProjectPathSafe — accepts valid project artifacts (v4.3 Step 26)', () => { - assert.equal(isProjectPathSafe('brief.md'), true); - assert.equal(isProjectPathSafe('plan.md'), true); - assert.equal(isProjectPathSafe('review.md'), true); - assert.equal(isProjectPathSafe('progress.json'), true); - assert.equal(isProjectPathSafe('research/01-foo.md'), true); - assert.equal(isProjectPathSafe('architecture/overview.md'), true); - assert.equal(isProjectPathSafe('architecture/gaps.md'), true); -}); - -test('isProjectPathSafe — fixture FileList survives filter to brief.md only (v4.3 Step 26)', () => { - // Fixture mirroring Step 26 plan-Verify scenario: load a directory - // containing the four hostile entries plus a valid brief.md and verify - // only brief.md survives. - const fixture = [ - '../etc/passwd', - '.git/config', - 'node_modules/foo/index.js', - 'brief.md', - '.DS_Store', - 'dist/junk.js', - ]; - const survivors = fixture.filter(isProjectPathSafe); - assert.deepEqual(survivors, ['brief.md'], 'only brief.md should survive the filter'); -}); - -// ===================================================================== -// Group C — v4.3 Step 29 export-bundle schema validation (Wave 7). -// -// Verifies the JSON shape that /trekrevise consumes when an operator -// applies a playground-exported annotation batch back into the source -// artifact. The shape comes from buildAnnotatedMarkdown + -// downloadAnnotatedBlob (markdown export — primary) but the -// trekrevise-side reader (lib/parsers/anchor-parser.mjs + -// lib/parsers/annotation-digest.mjs) accepts a parallel JSON payload -// with the same canonical fields. The fixture in -// tests/fixtures/playground/v43-export-bundle.json is the contract. -// ===================================================================== - -import { computeAnnotationDigest } from '../../lib/parsers/annotation-digest.mjs'; - -const FIXTURES = join(ROOT, 'tests', 'fixtures', 'playground'); -const BUNDLE = join(FIXTURES, 'v43-export-bundle.json'); -const PLAN_FIXTURE = join(FIXTURES, 'v43-plan-pre-annotate.md'); - -test('Group C.1 — export bundle JSON parses (v4.3 Step 29)', () => { - const raw = readFileSync(BUNDLE, 'utf-8'); - const bundle = JSON.parse(raw); // throws on parse error - assert.equal(typeof bundle, 'object', 'bundle must be object'); - assert.ok(bundle !== null, 'bundle must not be null'); -}); - -test('Group C.2 — export bundle has required top-level keys (v4.3 Step 29)', () => { - const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); - for (const key of ['schema_version', 'exported_at', 'target_artifact', 'annotations', 'annotation_digest']) { - assert.ok(key in bundle, `required key missing: ${key}`); - } - assert.equal(bundle.schema_version, 1, 'schema_version must be 1'); - assert.ok(['brief', 'plan', 'review', 'artifact'].includes(bundle.target_artifact), 'target_artifact must be one of brief|plan|review|artifact'); - assert.ok(Array.isArray(bundle.annotations), 'annotations must be array'); -}); - -test('Group C.3 — every annotation has id + target_anchor + intent (v4.3 Step 29)', () => { - const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); - assert.ok(bundle.annotations.length >= 2, 'fixture must include ≥2 annotations'); - for (const a of bundle.annotations) { - assert.match(a.id, /^ANN-\d{4}$/, `id ${a.id} must match ANN-NNNN`); - assert.equal(typeof a.target_anchor, 'string', 'target_anchor must be string'); - assert.ok(VOYAGE_ANCHOR_INTENTS.includes(a.intent), `intent ${a.intent} must be one of fix|change|question|block`); - } -}); - -test('Group C.4 — empty-export edge case produces valid bundle (v4.3 Step 29)', () => { - // Mirror the export-shape with zero annotations (download button still works - // — produces a bundle with annotations=[] and digest of empty canonical). - const emptyBundle = { - schema_version: 1, - exported_at: '2026-05-10T00:00:00Z', - target_artifact: 'brief', - target_filename: 'annotated-brief.md', - annotations: [], - annotation_digest: computeAnnotationDigest([]), - }; - // Round-trip: serialize + parse must equal - const roundTripped = JSON.parse(JSON.stringify(emptyBundle)); - assert.deepEqual(roundTripped, emptyBundle, 'empty bundle must round-trip'); - assert.equal(emptyBundle.annotations.length, 0, 'annotations array must be empty'); - assert.match(emptyBundle.annotation_digest, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix'); -}); - -test('Group C.5 — annotation_digest is order-independent (v4.3 Step 29)', () => { - const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); - const ascending = computeAnnotationDigest(bundle.annotations); - const reversed = computeAnnotationDigest([...bundle.annotations].reverse()); - assert.equal(ascending, reversed, 'digest must be deterministic regardless of input order'); -}); - -// SC6 — annotation_digest SHA-256 validity (per scope-guardian SC-GAP-3). -test('Group C.6 — annotation_digest is valid 16-hex-char SHA-256 prefix (v4.3 Step 29 / SC-GAP-3)', () => { - const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); - // Recompute the digest server-side and verify it matches the canonical form. - // The fixture stores PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME — the canonical - // value comes from computeAnnotationDigest(annotations). - const canonical = computeAnnotationDigest(bundle.annotations); - assert.match(canonical, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix of SHA-256'); - // Determinism: two calls with the same input MUST produce identical output - const second = computeAnnotationDigest(bundle.annotations); - assert.equal(canonical, second, 'digest must be deterministic'); -}); - -test('Group C.7 — fixture plan parses with anchors at block boundaries (v4.3 Step 29)', () => { - const planText = readFileSync(PLAN_FIXTURE, 'utf-8'); - // Frontmatter declares revision: 0 — the entry point for /trekrevise - assert.match(planText, /^---\s*$/m, 'YAML frontmatter required'); - assert.match(planText, /^revision:\s*0\s*$/m, 'revision: 0 required (round-trip seed)'); - // Both anchors present in canonical format - assert.match(planText, //, 'ANN-0001 anchor required'); - assert.match(planText, //, 'ANN-0002 anchor required'); -}); - -// Group C.8 — SC6 round-trip: readAndUpdate raises revision to 1 + populates -// source_annotations + annotation_digest (finding 1bc37231). Verifies the -// trekrevise mutation contract end-to-end against a tmpdir copy of the -// pre-annotate plan fixture. -test('Group C.8 — SC6 round-trip: readAndUpdate raises revision to 1, source_annotations populated (1bc37231)', () => { - const tmpDir = mkdtempSync(join(tmpdir(), 'voyage-c8-')); - const tmpPath = join(tmpDir, 'plan.md'); - try { - writeFileSync(tmpPath, readFileSync(PLAN_FIXTURE, 'utf-8')); - const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); - - const result = readAndUpdate(tmpPath, ({ frontmatter, body }) => { - frontmatter.revision = (frontmatter.revision || 0) + 1; - frontmatter.source_annotations = bundle.annotations; - frontmatter.annotation_digest = computeAnnotationDigest(bundle.annotations); - return { frontmatter, body }; - }); - assert.equal(result.valid, true, `readAndUpdate must return valid: ${JSON.stringify(result.errors || [])}`); - - const parsed = parseDocument(readFileSync(tmpPath, 'utf-8')); - assert.equal(parsed.valid, true, `re-parsed file must be valid: ${JSON.stringify(parsed.errors || [])}`); - const fm = parsed.parsed.frontmatter; - assert.equal(fm.revision, 1, 'revision must be 1 after first round-trip'); - assert.equal(Array.isArray(fm.source_annotations), true, 'source_annotations must be array'); - assert.equal(fm.source_annotations.length, 2, 'source_annotations must have 2 entries from bundle fixture'); - assert.match(fm.annotation_digest, /^[0-9a-f]{16}$/, 'annotation_digest must be 16-hex-char SHA-256 prefix'); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } -}); diff --git a/plugins/voyage/tests/integration/annotation-roundtrip.test.mjs b/plugins/voyage/tests/integration/annotation-roundtrip.test.mjs deleted file mode 100644 index d6f820e..0000000 --- a/plugins/voyage/tests/integration/annotation-roundtrip.test.mjs +++ /dev/null @@ -1,133 +0,0 @@ -// tests/integration/annotation-roundtrip.test.mjs -// SC2 + SC3 + SC7 integration tests for the annotation round-trip pipeline. -// -// SC2 (byte-identical empty round-trip): -// For each target fixture (brief/plan/review), assert that -// stripAnchors(addAnchors(body, [])) === body, byte-for-byte. -// -// SC3 (scale: >=50 steps + >=100 anchors): -// On the 51-step scale fixture, generate 100 anchors above varied lines, -// run addAnchors -> stripAnchors, assert the original body is restored -// byte-for-byte. -// -// SC7 (per-target isolation): -// parseAnchors(stripAnchors(addAnchors(body, anchors))) === [] — once -// anchors are stripped, no residual voyage:anchor markers remain that -// parseAnchors would re-detect. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parseDocument } from '../../lib/util/frontmatter.mjs'; -import { parseAnchors, addAnchors, stripAnchors } from '../../lib/parsers/anchor-parser.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(HERE, '..', '..'); -const FIX_DIR = join(ROOT, 'tests/fixtures/annotation'); - -function readBody(fixture) { - const text = readFileSync(join(FIX_DIR, fixture), 'utf-8'); - const doc = parseDocument(text); - assert.ok(doc.valid, `fixture ${fixture} did not parse: ${(doc.errors || []).map(e => e.message).join(', ')}`); - return doc.parsed.body; -} - -test('annotation-brief.md byte-identical empty round-trip (SC2)', () => { - const body = readBody('annotation-brief.md'); - const out = stripAnchors(addAnchors(body, [])); - assert.strictEqual(out, body, 'empty addAnchors+stripAnchors must be byte-identical'); -}); - -test('annotation-plan.md byte-identical empty round-trip (SC2)', () => { - const body = readBody('annotation-plan.md'); - const out = stripAnchors(addAnchors(body, [])); - assert.strictEqual(out, body, 'empty addAnchors+stripAnchors must be byte-identical'); -}); - -test('annotation-review.md byte-identical empty round-trip (SC2)', () => { - const body = readBody('annotation-review.md'); - const out = stripAnchors(addAnchors(body, [])); - assert.strictEqual(out, body, 'empty addAnchors+stripAnchors must be byte-identical'); -}); - -test('annotation-plan-large.md scale (51 steps + 100 anchors) round-trip (SC3)', () => { - const body = readBody('annotation-plan-large.md'); - const lineCount = body.split('\n').length; - // Generate 100 anchors targeting safe paragraph lines. Place them above - // line numbers that are deliberately avoided by anchor-parser placement - // rules: skip anchor insertion above headings and inside fenced blocks. - // Strategy: pick 100 safe insertion points by walking blank lines outside - // fenced blocks; anchor at line N inserts above line N (so line N must - // be a content line, not a fence delimiter). - const lines = body.split('\n'); - const safe = []; - let inFence = false; - for (let i = 0; i < lines.length; i++) { - const ln = lines[i]; - if (/^```/.test(ln)) { inFence = !inFence; continue; } - if (inFence) continue; - // Skip headings, blank lines, list items, and structural anchors - if (ln.startsWith('#') || ln.trim() === '' || /^\s*[-*+]\s/.test(ln)) continue; - safe.push(i + 1); // 1-indexed line number - } - assert.ok(safe.length >= 100, `need >=100 safe insertion points; got ${safe.length}`); - const anchors = []; - for (let n = 0; n < 100; n++) { - anchors.push({ - id: `ANN-${String(n + 1).padStart(4, '0')}`, - target: `step-${(n % 51) + 1}`, - line: safe[n], - intent: ['fix', 'change', 'question', 'block'][n % 4], - }); - } - const annotated = addAnchors(body, anchors); - // sanity: 100 anchors produced - const parsed = parseAnchors(annotated); - assert.ok(parsed.valid, `parseAnchors on annotated body failed: ${(parsed.errors || []).map(e => e.message).join('; ')}`); - assert.strictEqual(parsed.parsed.length, 100, `expected 100 anchors after addAnchors, got ${parsed.parsed.length}`); - // Round-trip restores body byte-for-byte. - const restored = stripAnchors(annotated); - assert.strictEqual(restored, body, 'addAnchors -> stripAnchors must round-trip byte-identical at scale'); -}); - -test('parseAnchors(stripAnchors(addAnchors(brief, anchors))) === [] (SC7 brief)', () => { - const body = readBody('annotation-brief.md'); - const lines = body.split('\n'); - // Pick a content line — first non-blank, non-heading line - const target = lines.findIndex(l => l.length > 0 && !l.startsWith('#')) + 1; - assert.ok(target > 0, 'brief fixture has no content lines'); - const anchors = [{ id: 'ANN-0001', target: 'intent', line: target, intent: 'change' }]; - const annotated = addAnchors(body, anchors); - const stripped = stripAnchors(annotated); - const result = parseAnchors(stripped); - assert.ok(result.valid, 'parseAnchors on stripped body should be valid'); - assert.deepStrictEqual(result.parsed, [], 'no anchors should remain after stripAnchors'); -}); - -test('parseAnchors(stripAnchors(addAnchors(plan, anchors))) === [] (SC7 plan)', () => { - const body = readBody('annotation-plan.md'); - const lines = body.split('\n'); - const target = lines.findIndex(l => l.startsWith('A minimal')) + 1; - assert.ok(target > 0, 'plan fixture missing expected content line'); - const anchors = [{ id: 'ANN-0001', target: 'context', line: target, intent: 'fix' }]; - const annotated = addAnchors(body, anchors); - const stripped = stripAnchors(annotated); - const result = parseAnchors(stripped); - assert.ok(result.valid); - assert.deepStrictEqual(result.parsed, []); -}); - -test('parseAnchors(stripAnchors(addAnchors(review, anchors))) === [] (SC7 review)', () => { - const body = readBody('annotation-review.md'); - const lines = body.split('\n'); - const target = lines.findIndex(l => l.startsWith('Verdict')) + 1; - assert.ok(target > 0, 'review fixture missing Verdict line'); - const anchors = [{ id: 'ANN-0001', target: 'executive-summary', line: target, intent: 'question' }]; - const annotated = addAnchors(body, anchors); - const stripped = stripAnchors(annotated); - const result = parseAnchors(stripped); - assert.ok(result.valid); - assert.deepStrictEqual(result.parsed, []); -}); diff --git a/plugins/voyage/tests/integration/schema-rollback.test.mjs b/plugins/voyage/tests/integration/schema-rollback.test.mjs deleted file mode 100644 index eb9b514..0000000 --- a/plugins/voyage/tests/integration/schema-rollback.test.mjs +++ /dev/null @@ -1,135 +0,0 @@ -// tests/integration/schema-rollback.test.mjs -// SC5b: post-write validator failure rolls back byte-identical pre-revision state. -// -// Exercises lib/util/revision-guard.mjs revisionGuard(): -// - Apply a deliberately-corrupting mutator that produces an artifact -// the validator will reject (missing required section / wrong type). -// - Assert outcome === 'rolled-back'. -// - Assert sha256_after === sha256_before (byte-identical recovery). -// - Assert .local.bak is deleted on the rollback path. -// -// Cases: -// 1. brief-rollback — strip a required body section -// 2. plan-rollback — break plan structure (rename Implementation Plan) -// 3. review-rollback — flip type to non-trekreview -// 4. sha256-invariance-cross-target — across all three targets, verify -// the byte-invariance holds for at least one common corrupting class -// (frontmatter `type:` flip). - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createHash } from 'node:crypto'; -import { revisionGuard } from '../../lib/util/revision-guard.mjs'; -import { validateBrief } from '../../lib/validators/brief-validator.mjs'; -import { validatePlan } from '../../lib/validators/plan-validator.mjs'; -import { validateReview } from '../../lib/validators/review-validator.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(HERE, '..', '..'); -const FIX_DIR = join(ROOT, 'tests/fixtures/annotation'); - -function sha256(p) { - return createHash('sha256').update(readFileSync(p)).digest('hex'); -} - -function tmpCopy(name) { - const dir = mkdtempSync(join(tmpdir(), 'voyage-rollback-')); - const dst = join(dir, name); - copyFileSync(join(FIX_DIR, name), dst); - return { dir, path: dst }; -} - -test('brief-rollback: strip Goal section -> validator FAIL -> byte-identical restore', () => { - const { dir, path } = tmpCopy('annotation-brief.md'); - try { - const sha_before = sha256(path); - const result = revisionGuard( - path, - ({ frontmatter, body }) => ({ - frontmatter, - body: body.replace(/## Goal[\s\S]*?(?=\n## Success Criteria)/, ''), // strip Goal section - }), - validateBrief, - ); - assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`); - const sha_after = sha256(path); - assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback'); - assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('plan-rollback: rename Implementation Plan heading -> validator FAIL -> byte-identical restore', () => { - const { dir, path } = tmpCopy('annotation-plan.md'); - try { - const sha_before = sha256(path); - const result = revisionGuard( - path, - ({ frontmatter, body }) => ({ - frontmatter, - // Inject a forbidden phase-style heading the plan-schema rejects (PLAN_FORBIDDEN_HEADING) - body: body + '\n\n### Fase 99: This forbidden heading triggers PLAN_FORBIDDEN_HEADING\n', - }), - validatePlan, - ); - assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`); - const sha_after = sha256(path); - assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback'); - assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('review-rollback: flip type to non-trekreview -> validator FAIL -> byte-identical restore', () => { - const { dir, path } = tmpCopy('annotation-review.md'); - try { - const sha_before = sha256(path); - const result = revisionGuard( - path, - ({ frontmatter, body }) => ({ - frontmatter: { ...frontmatter, type: 'not-a-real-type' }, - body, - }), - validateReview, - ); - assert.strictEqual(result.outcome, 'rolled-back', `expected rolled-back, got ${result.outcome}`); - const sha_after = sha256(path); - assert.strictEqual(sha_after, sha_before, 'sha256 must be byte-identical after rollback'); - assert.ok(!existsSync(path + '.local.bak'), '.local.bak must be deleted after rollback'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('sha256-invariance-cross-target: byte-identical rollback for all three targets', () => { - const cases = [ - { fixture: 'annotation-brief.md', validator: validateBrief, frontmatterCorruption: { type: 'wrong-type' } }, - { fixture: 'annotation-plan.md', validator: validatePlan, bodyCorruption: '\n\n### Fase 1: forbidden\n' }, - { fixture: 'annotation-review.md', validator: validateReview, frontmatterCorruption: { findings: 'not-an-array' } }, - ]; - for (const c of cases) { - const { dir, path } = tmpCopy(c.fixture); - try { - const sha_before = sha256(path); - const result = revisionGuard( - path, - ({ frontmatter, body }) => ({ - frontmatter: c.frontmatterCorruption ? { ...frontmatter, ...c.frontmatterCorruption } : frontmatter, - body: c.bodyCorruption ? body + c.bodyCorruption : body, - }), - c.validator, - ); - assert.strictEqual(result.outcome, 'rolled-back', `${c.fixture}: expected rolled-back, got ${result.outcome}`); - assert.strictEqual(sha256(path), sha_before, `${c.fixture}: sha256 must be byte-identical after rollback`); - assert.ok(!existsSync(path + '.local.bak'), `${c.fixture}: .local.bak must be deleted after rollback`); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - } -}); diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index 686805e..bc96ab4 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -80,7 +80,7 @@ test('commands/trekexecute.md still parses v1.7 plan schema', () => { test('settings.json has only known top-level scopes after Spor 0 cleanup', () => { const cfg = JSON.parse(read('settings.json')); - const known = ['trekplan', 'trekresearch', 'trekrevise']; + const known = ['trekplan', 'trekresearch']; for (const k of Object.keys(cfg)) { assert.ok(known.includes(k), `Unknown top-level scope in settings.json: ${k}`); } @@ -94,10 +94,9 @@ test('settings.json no longer carries vestigial exploration block', () => { 'agentTeam block was vestigial — should be deleted in v3.1.0 Spor 0'); }); -test('CLAUDE.md mentions all seven pipeline commands', () => { +test('CLAUDE.md mentions all six pipeline commands', () => { // v4.1 Step 21 — added /trekcontinue to coverage (was 5/6 before). - // v4.2 Step 12 — added /trekrevise (Handover 8 producer), bringing the - // canonical pipeline to seven commands. + // v5.0.0 — /trekrevise removed (bespoke playground retired); back to six. const md = read('CLAUDE.md'); for (const c of [ '/trekbrief', @@ -105,7 +104,6 @@ test('CLAUDE.md mentions all seven pipeline commands', () => { '/trekplan', '/trekexecute', '/trekreview', - '/trekrevise', '/trekcontinue', ]) { assert.ok(md.includes(c), `CLAUDE.md missing reference to ${c}`); @@ -261,7 +259,6 @@ const PIPELINE_COMMANDS = [ 'trekplan.md', 'trekexecute.md', 'trekreview.md', - 'trekrevise.md', 'trekcontinue.md', ]; @@ -403,246 +400,214 @@ test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => { ); }); -// --- v4.2 Step 12 — Handover 8 + annotation pipeline pins --- +// --- v5.0.0 / v5.0.1 — bespoke playground removed; /playground invocation explicit --- // -// CLAUDE.md / README.md / CHANGELOG / annotation-quickstart pins are deferred -// to Step 13 (post-write of those files). Step 12 only pins HANDOVER-CONTRACTS, -// templates, scaffold-files, and the parseAnchors round-trip on the example -// fixture. +// v5.0.0 removed the bespoke playground SPA, /trekrevise, and Handover 8. +// v5.0.1 dropped the v5.0.0 stop-gap (scripts/render-artifact.mjs) and made +// the producing commands print a literal, copy-paste-ready /playground +// document-critique invocation instead. These pins lock both removals in +// AND pin the new copy-paste invocation as the operator-facing contract. -import { existsSync, statSync } from 'node:fs'; +import { existsSync } from 'node:fs'; -test('HANDOVER-CONTRACTS.md contains Handover 8 section (annotation → revision)', () => { +test('playground/ directory no longer exists (removed in v5.0.0)', () => { + assert.ok( + !existsSync(join(ROOT, 'playground')), + 'plugins/voyage/playground/ should be deleted — the bespoke playground was retired in v5.0.0', + ); +}); + +test('commands/trekrevise.md no longer exists (removed in v5.0.0)', () => { + assert.ok( + !existsSync(join(ROOT, 'commands/trekrevise.md')), + '/trekrevise was removed in v5.0.0 — its command file should be gone', + ); +}); + +test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)', () => { const text = read('docs/HANDOVER-CONTRACTS.md'); + assert.ok(!text.includes('## Handover 8'), 'Handover 8 section should be removed in v5.0.0'); + assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain'); +}); + +test('scripts/render-artifact.mjs is still removed (v5.0.1 + v5.0.2)', () => { assert.ok( - text.includes('## Handover 8'), - 'docs/HANDOVER-CONTRACTS.md should document Handover 8 (annotation → revision) — added in v4.2', + !existsSync(join(ROOT, 'scripts/render-artifact.mjs')), + 'scripts/render-artifact.mjs should be deleted — v5.0.1 dropped the standalone HTML render; v5.0.2 kept it removed (annotate.mjs is the replacement)', ); }); -test('HANDOVER-CONTRACTS.md Handover 8 names annotation_digest and source_annotations', () => { - const text = read('docs/HANDOVER-CONTRACTS.md'); - const h8Start = text.indexOf('## Handover 8'); - assert.ok(h8Start >= 0, 'Handover 8 heading missing'); - const h8End = text.indexOf('## Stability summary', h8Start); - assert.ok(h8End > h8Start, 'Stability summary heading missing — could not bound Handover 8'); - const h8 = text.slice(h8Start, h8End); +test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => { assert.ok( - h8.includes('annotation_digest'), - 'Handover 8 section should document the annotation_digest frontmatter field', - ); - assert.ok( - h8.includes('source_annotations'), - 'Handover 8 section should document the source_annotations frontmatter field', - ); - assert.ok( - h8.includes('revision'), - 'Handover 8 section should document the revision counter field', + existsSync(join(ROOT, 'scripts/annotate.mjs')), + 'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML', ); }); -test('templates/plan-template.md documents annotation revision fields', () => { - const tpl = read('templates/plan-template.md'); - assert.ok( - tpl.includes('revision:'), - 'plan-template.md must document optional revision counter (Handover 8)', - ); - assert.ok( - tpl.includes('source_annotations:'), - 'plan-template.md must document optional source_annotations list (Handover 8)', - ); - assert.ok( - tpl.includes('annotation_digest'), - 'plan-template.md must document optional annotation_digest field (Handover 8)', - ); -}); - -test('templates/trekbrief-template.md documents annotation revision fields', () => { - const tpl = read('templates/trekbrief-template.md'); - assert.ok( - tpl.includes('revision:'), - 'trekbrief-template.md must document optional revision counter (Handover 8)', - ); - assert.ok( - tpl.includes('source_annotations:'), - 'trekbrief-template.md must document optional source_annotations list (Handover 8)', - ); - assert.ok( - tpl.includes('annotation_digest'), - 'trekbrief-template.md must document optional annotation_digest field (Handover 8)', - ); -}); - -test('templates/trekreview-template.md documents annotation revision fields', () => { - const tpl = read('templates/trekreview-template.md'); - assert.ok( - tpl.includes('revision:'), - 'trekreview-template.md must document optional revision counter (Handover 8)', - ); - assert.ok( - tpl.includes('source_annotations:'), - 'trekreview-template.md must document optional source_annotations list (Handover 8)', - ); - assert.ok( - tpl.includes('annotation_digest'), - 'trekreview-template.md must document optional annotation_digest field (Handover 8)', - ); -}); - -test('playground/ directory exists at voyage root (Handover 8 producer surface)', () => { - const playgroundDir = join(ROOT, 'playground'); - assert.ok(existsSync(playgroundDir), 'playground/ directory missing'); - assert.ok(statSync(playgroundDir).isDirectory(), 'playground/ is not a directory'); - // Self-contained HTML must exist - assert.ok( - existsSync(join(playgroundDir, 'voyage-playground.html')), - 'playground/voyage-playground.html missing — operator-facing entry point', - ); -}); - -test('playground/ files do NOT import or reference `marked` (risk-assessor H1)', () => { - // Walk playground/ recursively. Exclude vendor/playground-design-system - // (consumed via the shared design system; not part of voyage's playground - // markdown renderer). Exclude any *MANIFEST.json files. Assert no file - // contains the standalone identifier `marked` (case-sensitive, word-boundary). - // markdown-it is the locked renderer per research-03 + alternatives table. - const playgroundDir = join(ROOT, 'playground'); - assert.ok(existsSync(playgroundDir), 'playground/ directory missing — cannot verify marked-ban'); - const offenders = []; - function walk(dir) { - for (const entry of readdirSync(dir)) { - const p = join(dir, entry); - const s = statSync(p); - if (s.isDirectory()) { - // Skip vendor design-system trees (shared infra, not voyage's renderer) - if (entry === 'playground-design-system') continue; - walk(p); - } else if (s.isFile()) { - // Skip vendor manifest JSONs - if (entry.endsWith('MANIFEST.json')) continue; - if (entry === 'VENDOR-MANIFEST.json') continue; - const txt = readFileSync(p, 'utf-8'); - if (/\bmarked\b/.test(txt)) { - offenders.push(p.slice(ROOT.length + 1)); - } - } - } - } - walk(playgroundDir); - assert.deepStrictEqual( - offenders, - [], - `playground/ files contain banned identifier "marked": ${offenders.join(', ')}. ` + - `Use markdown-it instead — see plan Alternatives table (Issue #3515 disqualifies marked).`, - ); -}); - -test('scripts/render-artifact.mjs exists (SC1/SC11 self-render gate)', () => { - assert.ok( - existsSync(join(ROOT, 'scripts/render-artifact.mjs')), - 'scripts/render-artifact.mjs missing — required by SC1 (offline render) and SC11 (pipeline-self-eat)', - ); -}); - -test('lib/util/revision-guard.mjs exists (plan-critic M4 — atomic-write rollback guard)', () => { - assert.ok( - existsSync(join(ROOT, 'lib/util/revision-guard.mjs')), - 'lib/util/revision-guard.mjs missing — required for /trekrevise rollback hygiene', - ); -}); - -test('tests/fixtures/annotation/annotation-example.md parses cleanly via parseAnchors (ESM)', async () => { - // Plan-critic m4 — fix the SC12 require/import mixup. Use ESM dynamic import, - // not require(). The parser is pure — no I/O, no side effects. - const { parseAnchors } = await import('../../lib/parsers/anchor-parser.mjs'); - const fixturePath = join(ROOT, 'tests/fixtures/annotation/annotation-example.md'); - assert.ok(existsSync(fixturePath), 'tests/fixtures/annotation/annotation-example.md missing'); - const result = parseAnchors(readFileSync(fixturePath, 'utf-8')); - assert.ok( - result.valid, - `parseAnchors failed on annotation-example.md fixture: ${JSON.stringify(result.errors || [])}`, - ); -}); - -// --- v4.2 Step 13 — late doc-consistency pins (post-write of CLAUDE / READMEs / CHANGELOG / quickstart) --- -// -// These were deferred from Step 12 per plan-critic M1 ordering finding — -// Step 13 is where these files are written, so pins go here. - -test('plugin README.md mentions /trekrevise in commands section', () => { - // Already covered for CLAUDE.md by the "all seven pipeline commands" test; - // this pin extends coverage to the plugin-level README. - const md = read('README.md'); - assert.ok( - md.includes('/trekrevise'), - 'plugin README.md must reference /trekrevise (added in v4.2 Step 13)', - ); -}); - -test('marketplace root README.md mentions /trekrevise and v4.2.0', () => { - // ../../README.md is the marketplace landing — must surface v4.2 ship. - // Path traversal is allowed here per feedback_plugin_scope_strict - // (root README updates are explicitly in Step 13's scope). - const md = read('../../README.md'); - assert.ok( - md.includes('/trekrevise') || md.includes('trekrevise'), - 'marketplace root README.md must reference /trekrevise (v4.2)', - ); - assert.ok( - md.includes('v4.2.0'), - 'marketplace root README.md must reference voyage v4.2.0', - ); -}); - -test('CHANGELOG.md has v4.2.0 entry', () => { - const cl = read('CHANGELOG.md'); - assert.match( - cl, - /## v4\.2\.0\b/, - 'CHANGELOG.md must include "## v4.2.0" entry per Keep-a-Changelog 1.1.0', - ); -}); - -test('docs/annotation-quickstart.md exists with ≤7 numbered steps and example-fixture reference', () => { - // SC12 — operator-facing quickstart. The plan caps numbered steps at 7 - // to keep cognitive load minimal; reference to the example fixture - // anchors the doc to a concrete artifact operators can replay. - const path = 'docs/annotation-quickstart.md'; - assert.ok(existsSync(join(ROOT, path)), `${path} missing`); - const text = read(path); - // Numbered top-level steps: lines starting with "1." through "7." at - // line-start. Forbid 8.+ line-starts. - const numberedSteps = (text.match(/^[1-9]\./gm) || []); - for (const s of numberedSteps) { - const n = parseInt(s, 10); +test('producing commands reference scripts/annotate.mjs (v5.0.2 render-and-link step)', () => { + // v5.0.0 → v5.0.1 → v5.0.2 chain: v5.0.0 added an HTML render that didn't + // afford annotation; v5.0.1 pointed at /playground document-critique (which + // pre-generates Claude's suggestions, not operator-driven annotation); v5.0.2 + // ships scripts/annotate.mjs — an operator-driven annotation surface where + // the OPERATOR clicks lines and writes their own notes. Pin the wiring. + for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - n >= 1 && n <= 7, - `${path} contains step ${s} — only 1.-7. permitted (single-screen quickstart)`, + read(`commands/${f}`).includes('scripts/annotate.mjs'), + `commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`, ); } - assert.ok( - text.includes('tests/fixtures/annotation/annotation-example.md'), - `${path} must reference the canonical example fixture for hands-on verification`, +}); + +test('producing commands no longer print the v5.0.1 /playground document-critique line', () => { + // v5.0.1 told operators to copy-paste "/playground build a document-critique + // playground for X" — but that flow pre-generates Claude's suggestions. The + // operator asked for their own annotations, not a critique of Claude's. + // v5.0.2 removes that line from the producing commands' final report. + for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { + assert.ok( + !read(`commands/${f}`).includes('/playground build a document-critique'), + `commands/${f} must not print the v5.0.1 /playground document-critique invocation — v5.0.2 replaces it with annotate.mjs`, + ); + } +}); + +test('producing commands tell the operator the flow is THEIR own annotations', () => { + // Pin language: every producing command's prose must mention that the + // OPERATOR drives annotation, not Claude. Phrase variants are allowed + // ("YOUR OWN note", "operator drives", etc.) — we look for the operator- + // ownership signal. + for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { + const text = read(`commands/${f}`); + assert.ok( + /YOUR OWN|operator drives|your own/i.test(text), + `commands/${f} must signal that the operator drives annotation (v5.0.2 contract)`, + ); + } +}); + +test('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( + pkg.scripts && pkg.scripts.render, + undefined, + 'package.json scripts.render should remain gone', ); }); -test('commands/trekplan.md Phase 9 documents plan_critic injection via readAndUpdate (906f155d)', () => { - // Phase 9 (adversarial review) writes the plan-critic verdict back into - // plan.md frontmatter AFTER plan-review-dedup completes. The inject must - // happen post-Phase-8 (write) because Phase 8 precedes Phase 9 in the - // pipeline — the value cannot be in Phase 8's frontmatter template. - // Both the field name (plan_critic) and the inject mechanism - // (readAndUpdate from lib/util/markdown-write.mjs) must be documented - // so future maintainers can trace the contract. - const text = read('commands/trekplan.md'); - assert.match( - text, - /plan_critic/, - 'commands/trekplan.md must document plan_critic frontmatter field (906f155d)', - ); - assert.match( - text, - /readAndUpdate/, - 'commands/trekplan.md must reference readAndUpdate from lib/util/markdown-write.mjs (906f155d)', - ); +test('CHANGELOG.md has v5.0.0 entry', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v5\.0\.0\b/, 'CHANGELOG.md must include "## v5.0.0" entry'); +}); + +test('CHANGELOG.md has v5.0.1 entry', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry'); +}); + +test('CHANGELOG.md has v5.0.2 entry', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v5\.0\.2\b/, 'CHANGELOG.md must include "## v5.0.2" entry'); +}); + +test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry'); +}); + +test('operational files no longer reference trekrevise (v5.0.0 removal)', () => { + // Templates, the touched command/orchestrator files, settings.json, and the + // handover-contracts doc must be fully scrubbed. CLAUDE.md / README.md are + // intentionally allowed to mention /trekrevise in their "removed in v5.0.0" + // prose — those are historical notes, not live references. + const targets = [ + 'settings.json', + 'docs/HANDOVER-CONTRACTS.md', + 'templates/plan-template.md', 'templates/trekbrief-template.md', 'templates/trekreview-template.md', + 'commands/trekplan.md', 'commands/trekbrief.md', 'commands/trekreview.md', + 'agents/planning-orchestrator.md', + ]; + for (const t of targets) { + assert.ok( + !read(t).includes('trekrevise'), + `${t} still references trekrevise — it was removed in v5.0.0`, + ); + } +}); + +// --- 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/markdown-write.test.mjs b/plugins/voyage/tests/lib/markdown-write.test.mjs deleted file mode 100644 index f7d06d7..0000000 --- a/plugins/voyage/tests/lib/markdown-write.test.mjs +++ /dev/null @@ -1,189 +0,0 @@ -// tests/lib/markdown-write.test.mjs -// Unit tests for lib/util/markdown-write.mjs (v4.2) - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync, statSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - serializeFrontmatter, - atomicWriteMarkdown, - readAndUpdate, -} from '../../lib/util/markdown-write.mjs'; -import { parseFrontmatter, parseDocument } from '../../lib/util/frontmatter.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const FIXTURES_ROOT = resolve(__dirname, '..', 'fixtures'); - -test('serializeFrontmatter — empty object returns empty string', () => { - assert.equal(serializeFrontmatter({}), ''); -}); - -test('serializeFrontmatter — round-trip fidelity for scalars + arrays + list-of-dicts', () => { - const obj = { - name: 'voyage-test', - revision: 0, - enabled: true, - notes: null, - tags: ['alpha', 'beta', 'gamma'], - findings: [ - { id: 'a', severity: 'major' }, - { id: 'b', severity: 'minor' }, - ], - }; - const yaml = serializeFrontmatter(obj); - const reparsed = parseFrontmatter(yaml).parsed; - assert.deepEqual(reparsed, obj); -}); - -test('serializeFrontmatter — block-style YAML for arrays (no flow style)', () => { - const yaml = serializeFrontmatter({ tags: ['a', 'b'] }); - assert.ok(!yaml.includes('[a, b]'), 'flow-style array forbidden'); - assert.ok(yaml.includes('tags:\n - a\n - b'), 'block-style required'); -}); - -test('serializeFrontmatter — strings with colons are quoted', () => { - const yaml = serializeFrontmatter({ task: 'Re-architect: phase 2' }); - assert.match(yaml, /task: ".*Re-architect.*phase 2.*"/); - const reparsed = parseFrontmatter(yaml).parsed; - assert.equal(reparsed.task, 'Re-architect: phase 2'); -}); - -test('serializeFrontmatter — integer revision: 0 emitted unquoted', () => { - const yaml = serializeFrontmatter({ revision: 0 }); - assert.equal(yaml, 'revision: 0'); -}); - -test('serializeFrontmatter — round-trips 6-key source_annotations dict (v4.2 schema)', () => { - const obj = { - revision: 1, - source_annotations: [ - { - id: 'ANN-0001', - target_artifact: 'plan.md', - target_anchor: 'step-3', - intent: 'change', - comment: 'Reorder before step 4', - timestamp: '2026-05-09T10:00:00Z', - }, - { - id: 'ANN-0002', - target_artifact: 'plan.md', - target_anchor: 'step-7', - intent: 'fix', - comment: 'typo in heading', - timestamp: '2026-05-09T10:05:00Z', - }, - ], - annotation_digest: 'abc123def4567890', - }; - const yaml = serializeFrontmatter(obj); - const reparsed = parseFrontmatter(yaml).parsed; - assert.deepEqual(reparsed, obj, '6-key list-of-dict must round-trip'); -}); - -test('atomicWriteMarkdown — writes file with frontmatter + body', () => { - const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Title\n\nBody.\n'); - const text = readFileSync(path, 'utf-8'); - assert.match(text, /^---\nplan_version: "?1\.7"?\nrevision: 0\n---\n# Title\n\nBody\.\n$/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('atomicWriteMarkdown — leaves no .tmp orphan after success', () => { - const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { ok: true }, 'body'); - assert.ok(existsSync(path)); - assert.ok(!existsSync(path + '.tmp')); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('atomicWriteMarkdown — overwrites existing file atomically', () => { - const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); - try { - const path = join(dir, 'plan.md'); - writeFileSync(path, 'old content'); - atomicWriteMarkdown(path, { new: true }, 'new body\n'); - const text = readFileSync(path, 'utf-8'); - assert.match(text, /new: true/); - assert.match(text, /new body/); - assert.ok(!text.includes('old content')); - assert.ok(!existsSync(path + '.tmp')); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('atomicWriteMarkdown — preserves body bytes verbatim', () => { - const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); - try { - const path = join(dir, 'plan.md'); - const body = '# Title\n\n- item with `code`\n\n```yaml\nmanifest:\n expected_paths:\n - foo\n```\n\nTrailing text.'; - atomicWriteMarkdown(path, { v: 1 }, body); - const text = readFileSync(path, 'utf-8'); - const split = text.split('---\n'); - const recoveredBody = split.slice(2).join('---\n'); - assert.equal(recoveredBody, body); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('readAndUpdate — round-trips frontmatter + body via mutator', () => { - const dir = mkdtempSync(join(tmpdir(), 'mdw-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Original\nBody.\n'); - const result = readAndUpdate(path, ({ frontmatter, body }) => ({ - frontmatter: { ...frontmatter, revision: 1 }, - body, - })); - assert.equal(result.valid, true); - const re = parseDocument(readFileSync(path, 'utf-8')); - assert.equal(re.parsed.frontmatter.revision, 1); - assert.match(re.parsed.body, /# Original/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -// Round-trip ALL existing fixture frontmatters (per risk-assessor C3). -// Walk tests/fixtures/**, parse + serialize + parse, assert deep-equal. -function walkMd(root, out = []) { - if (!existsSync(root)) return out; - for (const entry of readdirSync(root)) { - const p = join(root, entry); - const st = statSync(p); - if (st.isDirectory()) walkMd(p, out); - else if (entry.endsWith('.md')) out.push(p); - } - return out; -} - -test('serializeFrontmatter — round-trips ALL existing fixture frontmatters', () => { - const fixtures = walkMd(FIXTURES_ROOT); - let checked = 0; - for (const path of fixtures) { - const text = readFileSync(path, 'utf-8'); - const parsed = parseDocument(text); - if (!parsed.valid) continue; // some fixtures are intentionally malformed - const fm = parsed.parsed.frontmatter; - if (!fm || Object.keys(fm).length === 0) continue; - const yaml = serializeFrontmatter(fm); - const reparsed = parseFrontmatter(yaml); - if (!reparsed.valid) continue; // skip malformed-on-purpose fixtures - assert.deepEqual(reparsed.parsed, fm, `round-trip failed for fixture: ${path}`); - checked++; - } - assert.ok(checked > 0, 'expected to round-trip at least one fixture'); -}); 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/lib/revision-guard.test.mjs b/plugins/voyage/tests/lib/revision-guard.test.mjs deleted file mode 100644 index 3e7e4b0..0000000 --- a/plugins/voyage/tests/lib/revision-guard.test.mjs +++ /dev/null @@ -1,135 +0,0 @@ -// tests/lib/revision-guard.test.mjs -// Unit tests for lib/util/revision-guard.mjs (v4.2) - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, copyFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createHash } from 'node:crypto'; -import { revisionGuard } from '../../lib/util/revision-guard.mjs'; -import { atomicWriteMarkdown } from '../../lib/util/markdown-write.mjs'; - -function sha256(path) { - return createHash('sha256').update(readFileSync(path)).digest('hex'); -} - -const ALWAYS_VALID = () => ({ valid: true, errors: [], warnings: [] }); -const ALWAYS_INVALID = () => ({ valid: false, errors: [{ code: 'TEST', message: 'forced fail' }], warnings: [] }); - -test('revisionGuard — validator-PASS commits revision and deletes bak', () => { - const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n'); - const r = revisionGuard( - path, - ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }), - ALWAYS_VALID, - ); - assert.equal(r.outcome, 'applied'); - assert.ok(!existsSync(path + '.local.bak'), 'bak should be deleted on success'); - const text = readFileSync(path, 'utf-8'); - assert.match(text, /revision: 1/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('revisionGuard — validator-FAIL rolls back to byte-identical pre-revision', () => { - const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n'); - const before = sha256(path); - const r = revisionGuard( - path, - ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }), - ALWAYS_INVALID, - ); - assert.equal(r.outcome, 'rolled-back'); - const after = sha256(path); - assert.equal(after, before, 'rollback must restore byte-identical content'); - assert.ok(!existsSync(path + '.local.bak'), 'bak should be cleaned up after rollback'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('revisionGuard — pre-existing .local.bak aborts with operator guidance', () => { - const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n'); - const bak = path + '.local.bak'; - writeFileSync(bak, 'stale backup from prior run'); - const r = revisionGuard(path, ({ frontmatter, body }) => ({ frontmatter, body }), ALWAYS_VALID); - assert.equal(r.outcome, 'mutator-failed'); - assert.match(r.error, /pre-existing backup/); - // Original file untouched, stale bak preserved for operator inspection - assert.equal(readFileSync(bak, 'utf-8'), 'stale backup from prior run'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('revisionGuard — mutator that throws restores original via bak', () => { - const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n'); - const before = sha256(path); - const r = revisionGuard( - path, - () => { throw new Error('boom'); }, - ALWAYS_VALID, - ); - assert.equal(r.outcome, 'mutator-failed'); - assert.match(r.error, /boom/); - const after = sha256(path); - assert.equal(after, before, 'mutator-throw must preserve original'); - assert.ok(!existsSync(path + '.local.bak'), 'bak cleaned up after mutator-throw'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('revisionGuard — mutator returns invalid object rejected before validator runs', () => { - const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n'); - const before = sha256(path); - let validatorCalled = false; - const r = revisionGuard( - path, - () => null, // not an object - () => { validatorCalled = true; return { valid: true, errors: [], warnings: [] }; }, - ); - assert.equal(r.outcome, 'mutator-failed'); - assert.equal(validatorCalled, false, 'validator must not run if mutator returned invalid result'); - const after = sha256(path); - assert.equal(after, before, 'invalid mutator must preserve original'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('revisionGuard — sha256 fields populated and stable', () => { - const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); - try { - const path = join(dir, 'plan.md'); - atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n'); - const before = sha256(path); - const r = revisionGuard( - path, - ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }), - ALWAYS_VALID, - ); - assert.equal(r.sha256_before, before); - assert.equal(typeof r.sha256_after, 'string'); - assert.notEqual(r.sha256_after, r.sha256_before, 'sha256 must change after applied revision'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/plugins/voyage/tests/lib/source-annotations.test.mjs b/plugins/voyage/tests/lib/source-annotations.test.mjs deleted file mode 100644 index c2aeb88..0000000 --- a/plugins/voyage/tests/lib/source-annotations.test.mjs +++ /dev/null @@ -1,244 +0,0 @@ -// tests/lib/source-annotations.test.mjs -// Additive-field invariant for source_annotations: array (Handover 8). -// -// Mirrors tests/lib/source-findings.test.mjs:9-13 — the structural three-part -// contract that v4.2 brief-validator + plan-validator + review-validator must -// uphold for the new optional source_annotations frontmatter field: -// -// 1. validators accept an artifact with source_annotations (additive optional) -// 2. frontmatter parser extracts source_annotations as an array -// 3. each entry has the documented annotation shape -// ({id, target_artifact, target_anchor, intent, ...}) -// -// LLM behavior (the planner actually emitting source_annotations) is -// non-testable without live invocation — this test only covers the schema -// half. See Step 12 doc-pin for the operator-level contract. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { parseDocument } from '../../lib/util/frontmatter.mjs'; -import { validateBrief } from '../../lib/validators/brief-validator.mjs'; -import { validatePlan } from '../../lib/validators/plan-validator.mjs'; -import { validateReview } from '../../lib/validators/review-validator.mjs'; - -const ID_RE = /^ANN-\d{4}$/; -const VALID_INTENT = new Set(['fix', 'change', 'question', 'block']); - -function makeFixture(name, body) { - const dir = mkdtempSync(join(tmpdir(), 'voyage-source-ann-')); - const path = join(dir, name); - writeFileSync(path, body); - return { dir, path }; -} - -const BRIEF_WITH_SOURCE_ANNOTATIONS = `--- -type: trekbrief -brief_version: "1.0" -task: Demo brief with source_annotations -slug: source-annotations-demo-brief -research_topics: 0 -research_status: complete -revision: 1 -annotation_digest: deadbeefcafe1234 -source_annotations: - - id: ANN-0001 - target_artifact: brief.md - target_anchor: goal - line: 20 - intent: change - - id: ANN-0002 - target_artifact: brief.md - target_anchor: success-criteria - line: 30 - intent: fix ---- - -# Demo - -## Intent - -Test fixture. - -## Goal - -Test fixture. - -## Success Criteria - -- It validates. -`; - -const PLAN_WITH_SOURCE_ANNOTATIONS = `--- -plan_version: 1.7 -profile: balanced -revision: 2 -annotation_digest: cafebabe98765432 -source_annotations: - - id: ANN-0001 - target_artifact: plan.md - target_anchor: step-1 - line: 25 - intent: fix ---- - -# Demo plan - -## Implementation Plan - -### Step 1: Sentinel - -- **Files:** \`tmp/x.txt\` (new) -- **Changes:** Touch. -- **Verify:** \`test -f tmp/x.txt\` -- **On failure:** revert. -- **Checkpoint:** \`git commit -m "chore: x"\` -- **Manifest:** - \`\`\`yaml - manifest: - expected_paths: - - tmp/x.txt - min_file_count: 1 - commit_message_pattern: "^chore: x" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - \`\`\` - -## Verification - -- It validates. -`; - -const REVIEW_WITH_SOURCE_ANNOTATIONS = `--- -type: trekreview -review_version: "1.0" -task: Demo review with source_annotations -slug: source-annotations-demo-review -project_dir: .claude/projects/2026-05-09-demo -brief_path: .claude/projects/2026-05-09-demo/brief.md -scope_sha_end: 0000000000000000000000000000000000000000 -reviewed_files_count: 0 -findings: [] -revision: 1 -annotation_digest: 0123456789abcdef -source_annotations: - - id: ANN-0001 - target_artifact: review.md - target_anchor: executive-summary - line: 18 - intent: question ---- - -# Demo - -## Executive Summary - -Verdict: ALLOW. - -## Coverage - -| File | Treatment | -|------|-----------| -| _none_ | _no diff_ | - -## Remediation Summary - -ALLOW. -`; - -test('validators accept artifacts with source_annotations field (additive optional, brief)', () => { - const { dir, path } = makeFixture('brief.md', BRIEF_WITH_SOURCE_ANNOTATIONS); - try { - const r = validateBrief(path, { strict: true }); - assert.ok( - r.valid, - `brief-validator rejected synthetic brief with source_annotations: ` + - `${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`, - ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('validators accept artifacts with source_annotations field (additive optional, plan)', () => { - const { dir, path } = makeFixture('plan.md', PLAN_WITH_SOURCE_ANNOTATIONS); - try { - const r = validatePlan(path, { strict: true }); - assert.ok( - r.valid, - `plan-validator rejected synthetic plan with source_annotations: ` + - `${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`, - ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('validators accept artifacts with source_annotations field (additive optional, review)', () => { - const { dir, path } = makeFixture('review.md', REVIEW_WITH_SOURCE_ANNOTATIONS); - try { - const r = validateReview(path, { strict: true }); - assert.ok( - r.valid, - `review-validator rejected synthetic review with source_annotations: ` + - `${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`, - ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('frontmatter parser extracts source_annotations as array of dicts (per artifact)', () => { - const cases = [ - { name: 'brief.md', body: BRIEF_WITH_SOURCE_ANNOTATIONS, expected: 2 }, - { name: 'plan.md', body: PLAN_WITH_SOURCE_ANNOTATIONS, expected: 1 }, - { name: 'review.md', body: REVIEW_WITH_SOURCE_ANNOTATIONS, expected: 1 }, - ]; - for (const c of cases) { - const doc = parseDocument(c.body); - assert.ok(doc.valid, `${c.name}: frontmatter did not parse: ${(doc.errors || []).map(e => e.message).join(', ')}`); - const sa = doc.parsed.frontmatter && doc.parsed.frontmatter.source_annotations; - assert.ok(Array.isArray(sa), `${c.name}: frontmatter.source_annotations is not an array (got ${typeof sa})`); - assert.strictEqual(sa.length, c.expected, `${c.name}: expected ${c.expected} entries, got ${sa.length}`); - } -}); - -test('source_annotations entries match documented annotation shape', () => { - const doc = parseDocument(BRIEF_WITH_SOURCE_ANNOTATIONS); - const entries = doc.parsed.frontmatter.source_annotations; - for (const e of entries) { - assert.strictEqual(typeof e, 'object', `source_annotations entry is not an object: ${JSON.stringify(e)}`); - assert.ok(typeof e.id === 'string' && ID_RE.test(e.id), `source_annotations[*].id must match /^ANN-\\d{4}$/, got ${JSON.stringify(e.id)}`); - assert.ok(typeof e.target_artifact === 'string' && e.target_artifact.endsWith('.md'), - `source_annotations[*].target_artifact must be a *.md path, got ${JSON.stringify(e.target_artifact)}`); - assert.ok(typeof e.target_anchor === 'string' && e.target_anchor.length > 0, - `source_annotations[*].target_anchor must be a non-empty string, got ${JSON.stringify(e.target_anchor)}`); - if (e.intent !== undefined && e.intent !== null) { - assert.ok(VALID_INTENT.has(e.intent), - `source_annotations[*].intent must be in {fix|change|question|block}, got ${JSON.stringify(e.intent)}`); - } - } -}); - -test('artifacts WITHOUT source_annotations still validate (forward-compat baseline)', () => { - // Forward-compat: artifacts that predate v4.2 must still validate. Fall back - // to an artifact with neither revision nor source_annotations. - const baseline = BRIEF_WITH_SOURCE_ANNOTATIONS - .replace(/^revision:.*\n/m, '') - .replace(/^annotation_digest:.*\n/m, '') - .replace(/^source_annotations:[\s\S]*?(?=^---$|^[A-Za-z])/m, ''); - const { dir, path } = makeFixture('brief.md', baseline); - try { - const r = validateBrief(path, { strict: true }); - assert.ok( - r.valid, - `brief-validator must accept artifacts WITHOUT source_annotations (forward-compat baseline): ` + - `${(r.errors || []).map(e => `[${e.code}] ${e.message}`).join('; ')}`, - ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/plugins/voyage/tests/parsers/anchor-parser.test.mjs b/plugins/voyage/tests/parsers/anchor-parser.test.mjs deleted file mode 100644 index 800834d..0000000 --- a/plugins/voyage/tests/parsers/anchor-parser.test.mjs +++ /dev/null @@ -1,130 +0,0 @@ -// tests/parsers/anchor-parser.test.mjs -// Unit tests for lib/parsers/anchor-parser.mjs (v4.2) - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - parseAnchors, - addAnchors, - stripAnchors, - validateAnchorPlacement, -} from '../../lib/parsers/anchor-parser.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const EXAMPLE_PATH = resolve(__dirname, '..', 'fixtures', 'annotation', 'annotation-example.md'); - -const PLAIN = `# Title - -A normal paragraph. - -## Section - -More text. -`; - -test('parseAnchors — empty array on plain markdown without anchors', () => { - const r = parseAnchors(PLAIN); - assert.equal(r.valid, true); - assert.deepEqual(r.parsed, []); -}); - -test('parseAnchors — extracts id/target/line/intent from valid anchor', () => { - const md = readFileSync(EXAMPLE_PATH, 'utf-8'); - const r = parseAnchors(md); - assert.equal(r.valid, true, JSON.stringify(r.errors)); - assert.equal(r.parsed.length, 1); - assert.equal(r.parsed[0].id, 'ANN-0001'); - assert.equal(r.parsed[0].target, 'section-b'); - assert.equal(r.parsed[0].line, 20); - assert.equal(r.parsed[0].intent, 'change'); -}); - -test('parseAnchors — rejects ID not matching ANN-NNNN', () => { - const md = `# X\n\n\n`; - const r = parseAnchors(md); - assert.equal(r.valid, false); - assert.ok(r.errors.find(e => e.code === 'ANCHOR_BAD_ID')); -}); - -test('parseAnchors — rejects malformed (missing id)', () => { - const md = `# X\n\n\n`; - const r = parseAnchors(md); - assert.equal(r.valid, false); - assert.ok(r.errors.find(e => e.code === 'ANCHOR_MALFORMED')); -}); - -test('parseAnchors — rejects duplicate IDs', () => { - const md = `# X\n\n\n\nFoo.\n\n\n`; - const r = parseAnchors(md); - assert.equal(r.valid, false); - assert.ok(r.errors.find(e => e.code === 'ANCHOR_DUPLICATE_ID')); -}); - -test('parseAnchors — ignores anchors inside fenced code blocks', () => { - const md = `# X\n\n\`\`\`yaml\n\n\`\`\`\n`; - const r = parseAnchors(md); - assert.equal(r.valid, true); - assert.deepEqual(r.parsed, []); -}); - -test('addAnchors — empty list returns input byte-identical', () => { - const r = addAnchors(PLAIN, []); - assert.equal(r, PLAIN); -}); - -test('addAnchors — inserts anchor on its own line with blank-line separation', () => { - const md = `# Title\n\nLine 3.\n`; - const result = addAnchors(md, [{ id: 'ANN-0001', target: 'title', line: 3, intent: 'change' }]); - assert.match(result, //); - // Anchor inserted above target line - const lines = result.split('\n'); - const anchorIdx = lines.findIndex(l => l.startsWith('\n- next\n`; - const r = validateAnchorPlacement(md, []); - assert.equal(r.valid, false); - assert.ok(r.errors.find(e => e.code === 'ANCHOR_IN_LIST_ITEM')); -}); - -test('validateAnchorPlacement — rejects anchor inside fenced yaml block', () => { - const md = `# X\n\n\`\`\`yaml\nfoo: bar\n\n\`\`\`\n`; - const r = validateAnchorPlacement(md, []); - assert.equal(r.valid, false); - assert.ok(r.errors.find(e => e.code === 'ANCHOR_IN_FENCED_BLOCK')); -}); - -test('validateAnchorPlacement — accepts anchor in body paragraph', () => { - const md = readFileSync(EXAMPLE_PATH, 'utf-8'); - const r = validateAnchorPlacement(md, []); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('parseAnchors — anchor with intent block sets intent field', () => { - const md = `# X\n\n\n`; - const r = parseAnchors(md); - assert.equal(r.valid, true); - assert.equal(r.parsed[0].intent, 'block'); -}); diff --git a/plugins/voyage/tests/parsers/annotation-digest.test.mjs b/plugins/voyage/tests/parsers/annotation-digest.test.mjs deleted file mode 100644 index bb8e61c..0000000 --- a/plugins/voyage/tests/parsers/annotation-digest.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -// tests/parsers/annotation-digest.test.mjs -// Unit tests for lib/parsers/annotation-digest.mjs (v4.2) - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { computeAnnotationDigest } from '../../lib/parsers/annotation-digest.mjs'; - -test('computeAnnotationDigest — empty array yields deterministic 16-char hex', () => { - const d = computeAnnotationDigest([]); - assert.equal(typeof d, 'string'); - assert.equal(d.length, 16); - assert.match(d, /^[0-9a-f]{16}$/); - // Empty-array digest is a known constant (sha256 of empty string) - assert.equal(d, 'e3b0c44298fc1c14'); -}); - -test('computeAnnotationDigest — array order does not affect digest', () => { - const a = [ - { id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'a', intent: 'fix', comment: 'one', timestamp: 't1' }, - { id: 'ANN-0002', target_artifact: 'plan.md', target_anchor: 'b', intent: 'change', comment: 'two', timestamp: 't2' }, - ]; - const b = [a[1], a[0]]; // reversed - assert.equal(computeAnnotationDigest(a), computeAnnotationDigest(b)); -}); - -test('computeAnnotationDigest — different intent produces different digest', () => { - const a = [{ id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'a', intent: 'fix', comment: '', timestamp: '' }]; - const b = [{ id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'a', intent: 'change', comment: '', timestamp: '' }]; - assert.notEqual(computeAnnotationDigest(a), computeAnnotationDigest(b)); -}); - -test('computeAnnotationDigest — output is exactly 16 lowercase hex chars', () => { - const a = [{ id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'a', intent: 'fix', comment: 'x', timestamp: 't' }]; - const d = computeAnnotationDigest(a); - assert.equal(d.length, 16); - assert.match(d, /^[0-9a-f]{16}$/); -}); - -test('computeAnnotationDigest — single annotation produces fixed golden value', () => { - // This pins the canonicalization. Changing the format will break this test. - const a = [{ - id: 'ANN-0001', - target_artifact: 'plan.md', - target_anchor: 'step-3', - intent: 'change', - comment: 'reorder', - timestamp: '2026-05-09T10:00:00Z', - }]; - const d = computeAnnotationDigest(a); - // Canonical: "ANN-0001|plan.md|step-3|change|reorder|2026-05-09T10:00:00Z" - // Computed once and pinned here: - assert.equal(d.length, 16); - assert.match(d, /^[0-9a-f]{16}$/); - // Recompute deterministically — same input must always give same output - const d2 = computeAnnotationDigest(a); - assert.equal(d, d2); -}); - -test('computeAnnotationDigest — undefined optional fields treated identically to empty string', () => { - const a = [{ id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'a', intent: 'fix' }]; // no comment, no timestamp - const b = [{ id: 'ANN-0001', target_artifact: 'plan.md', target_anchor: 'a', intent: 'fix', comment: '', timestamp: '' }]; - assert.equal(computeAnnotationDigest(a), computeAnnotationDigest(b)); -}); diff --git a/plugins/voyage/tests/playground/voyage-playground-structure.test.mjs b/plugins/voyage/tests/playground/voyage-playground-structure.test.mjs deleted file mode 100644 index bc3c700..0000000 --- a/plugins/voyage/tests/playground/voyage-playground-structure.test.mjs +++ /dev/null @@ -1,88 +0,0 @@ -// tests/playground/voyage-playground-structure.test.mjs -// v4.3 Step 29 — Group B structural assertions for the voyage playground. -// -// Group B verifies that DS-token classes, theme-toggle wiring, and the -// sidebar-tab/keyboard pattern are present in voyage-playground.html. -// All assertions are static-grep (no DOM, no browser). Companion to: -// - tests/playground/voyage-playground.test.mjs (Group A — SC1/3/6/7) -// - tests/integration/annotation-export-schema.test.mjs (Group C — SC6) - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '..', '..'); -const HTML = join(ROOT, 'playground', 'voyage-playground.html'); - -// --- DS-token classes present ---------------------------------------- -test('Group B — DS badge--scope-voyage class present (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /badge--scope-voyage/, 'badge--scope-voyage required'); -}); - -test('Group B — DS guide-panel + key-stats classes present (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /class="guide-panel/, 'guide-panel base class required'); - assert.match(text, /class="key-stats/, 'key-stats class required'); -}); - -test('Group B — DS fleet-grid + fleet-tile classes present (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /class="fleet-grid"/, 'fleet-grid required'); - assert.match(text, /class="fleet-tile/, 'fleet-tile required'); -}); - -// --- Theme-toggle wired --------------------------------------------- -test('Group B — theme-toggle button has data-action attribute (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /data-action="toggle-theme"/, 'data-action=toggle-theme required'); - assert.match(text, /aria-label="Bytt tema"/, 'theme-toggle aria-label required'); -}); - -test('Group B — wireThemeToggle handler exists (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+wireThemeToggle\s*\(/, 'wireThemeToggle function required'); -}); - -test('Group B — theme persistence to localStorage (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /(voyage-theme|voyage_theme)/, 'theme localStorage key required'); -}); - -// --- Sidebar-tab / keyboard pattern --------------------------------- -test('Group B — sidebar role=tablist with aria-selected (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /role="tablist"/, 'role=tablist required'); - assert.match(text, /aria-selected="(true|false)"/, 'aria-selected attribute required'); -}); - -test('Group B — keyboard nav J/K + Esc handlers wired (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Step 20 — J/K navigation + Esc dismiss - assert.match(text, /(keydown|keypress|keyup)/, 'keyboard event listener required'); - assert.match(text, /(['"]j['"]|['"]J['"]|KeyJ)/, 'J navigation required'); - assert.match(text, /(['"]k['"]|['"]K['"]|KeyK)/, 'K navigation required'); -}); - -test('Group B — anchor-ID format ANN-NNNN matches Node-side parser (v4.3 Step 29)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Mirror of lib/parsers/anchor-parser.mjs ID_RE (^ANN-\d{4}$) - assert.match(text, /\/\^ANN-\\d\{4\}\$\//, 'VOYAGE_ANCHOR_ID_RE pattern required'); - assert.match(text, /function\s+parseAnchor\s*\(/, 'parseAnchor function required'); -}); - -// --- Fleet-grid CSS parity vs vendored DS (v4.3 Step 9 / 99707f51) --- -test('Group B — SC1.4 fleet-grid CSS parity vs vendored DS (99707f51)', () => { - const cssPath = join(ROOT, 'playground', 'vendor', 'playground-design-system', 'components-tier3-supplement.css'); - const css = readFileSync(cssPath, 'utf-8'); - const startIdx = css.indexOf('.fleet-grid {'); - assert.notStrictEqual(startIdx, -1, '.fleet-grid block required in vendored DS components-tier3-supplement.css'); - const endIdx = css.indexOf('}', startIdx); - assert.notStrictEqual(endIdx, -1, '.fleet-grid block must terminate'); - const block = css.slice(startIdx, endIdx + 1); - assert.match(block, /grid-template-columns:\s*repeat\(4,\s*1fr\)/, '.fleet-grid grid-template-columns: repeat(4, 1fr) required'); - assert.match(block, /gap:\s*var\(--space-3\)/, '.fleet-grid gap: var(--space-3) required'); -}); diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs deleted file mode 100644 index cdc7798..0000000 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ /dev/null @@ -1,710 +0,0 @@ -// tests/playground/voyage-playground.test.mjs -// Filesystem + content tests for v4.2 voyage playground. -// Pure existence + grep checks — no browser launch. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { existsSync, statSync, readFileSync, readdirSync } from 'node:fs'; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(__dirname, '..', '..'); -const PLAYGROUND = join(ROOT, 'playground'); -const HTML = join(PLAYGROUND, 'voyage-playground.html'); -const VENDOR = join(PLAYGROUND, 'vendor', 'playground-design-system'); -const MANIFEST = join(VENDOR, 'MANIFEST.json'); - -test('voyage-playground.html exists and has nonzero size', () => { - assert.ok(existsSync(HTML), 'voyage-playground.html must exist'); - assert.ok(statSync(HTML).size > 0, 'must have content'); -}); - -test('voyage-playground.html has DOCTYPE + html closing tag', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /^/i); - assert.match(text, /<\/html>\s*$/); -}); - -test('voyage-playground.html does NOT contain external (http/https) URLs', () => { - // SC1 zero-network constraint: all assets must be relative to ./vendor/ - const text = readFileSync(HTML, 'utf-8'); - assert.ok(!/https?:\/\//.test(text), 'no external URLs allowed in playground HTML'); -}); - -test('voyage-playground.html does NOT contain literal `marked` (renderer ban per risk-assessor H1)', () => { - const text = readFileSync(HTML, 'utf-8'); - // marked is disqualified by issue #3515; markdown-it locked instead - // Allow comments mentioning "marked" as an explanatory artifact, but no actual import paths - assert.ok(!/from ['"].*marked/.test(text), 'no import from marked'); - assert.ok(!/]*marked\.min\.js/.test(text), 'no marked script tag'); -}); - -test('voyage-playground.html includes skip-to-main link (A11Y baseline)', () => { - const text = readFileSync(HTML, 'utf-8'); - // v4.3 Step 10 — Norwegian skip-link: "Hopp til hovedinnhold" - assert.match(text, /class="skip-link"[^>]*href="#main-content"/); - assert.match(text, /Hopp til hovedinnhold/); -}); - -test('voyage-playground.html declares aria-live region', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /aria-live="polite"/); -}); - -test('playground/vendor/playground-design-system/MANIFEST.json exists and parses as JSON with expected keys', () => { - assert.ok(existsSync(MANIFEST), 'MANIFEST.json must be present from sync-design-system.mjs'); - const obj = JSON.parse(readFileSync(MANIFEST, 'utf-8')); - assert.ok(obj.source_commit, 'source_commit field required'); - assert.ok(obj.sync_date, 'sync_date field required'); - assert.ok(obj.files && typeof obj.files === 'object', 'files map required'); -}); - -test('playground/vendor/playground-design-system/ contains expected DS files', () => { - const files = readdirSync(VENDOR); - for (const expected of ['tokens.css', 'base.css', 'components.css', 'fonts.css', 'print.css']) { - assert.ok(files.includes(expected), `${expected} expected in vendor/`); - } - assert.ok(files.includes('fonts'), 'fonts/ subdirectory expected'); -}); - -// --- Step 8 — render pipeline + vendored libs --------------------------- - -const PLAYGROUND_LIB = join(PLAYGROUND, 'lib'); - -test('voyage-playground.html references markdown-it (Step 8 render pipeline)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /markdown-it/, 'voyage-playground.html should load/initialize markdown-it'); -}); - -test('voyage-playground.html references highlight.js (Step 8 syntax highlighting)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /highlight/, 'voyage-playground.html should load highlight.js'); -}); - -test('voyage-playground.html includes paste-import-row (Step 8 import affordance)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /paste-import-row/, 'voyage-playground.html should include the paste-import-row pattern'); -}); - -test('voyage-playground.html declares voyage_ann_ localStorage key prefix (Step 8 risk-assessor H7)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /voyage_ann_/, 'localStorage key prefix voyage_ann___ must appear'); -}); - -test('playground/lib/ contains vendored markdown-it + front-matter + highlight bundles', () => { - for (const f of ['markdown-it.min.js', 'markdown-it-front-matter.min.js', 'highlight.min.js', 'VENDOR-MANIFEST.json']) { - assert.ok(existsSync(join(PLAYGROUND_LIB, f)), `playground/lib/${f} expected from vendor-playground-libs.mjs`); - } -}); - -// --- Step 9 — annotation creation gestures + form modal --------------- - -test('voyage-playground.html declares aria-modal="true" (Step 9 form modal A11Y)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /aria-modal="true"/, 'form modal must carry aria-modal="true"'); -}); - -test('voyage-playground.html declares ANN- anchor-ID prefix (Step 9 ID generation)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /ANN-/, 'sequential ANN-NNNN ID generation must appear in playground JS'); -}); - -test('voyage-playground.html declares 300ms grace constant (Step 9 adder-popup grace)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /300\s*ms|GRACE_MS\s*=\s*300|ADDER_GRACE_MS/i, '300ms grace period for adder-popup must be present'); -}); - -// --- Step 10 — sidebar with tabs + critique-card-list ---------------- - -test('voyage-playground.html includes role="tablist" (Step 10 sidebar tabs A11Y)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /role="tablist"/, 'sidebar must declare role="tablist" for A11Y'); -}); - -test('voyage-playground.html declares tabindex (Step 10 focus management)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /tabindex/i, 'sidebar tabs must use tabindex for keyboard focus management'); -}); - -// --- Step 11 — export flow + A11Y baseline ----------------------------- - -test('voyage-playground.html declares aria-live="polite" toast region (Step 11 A11Y)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /aria-live="polite"/, 'aria-live="polite" toast region required for status announcements'); -}); - -test('voyage-playground.html includes Skip to main link (Step 11 A11Y baseline)', () => { - const text = readFileSync(HTML, 'utf-8'); - // v4.3 Step 10 — text re-localized to Norwegian; semantic check via class. - assert.match(text, /class="skip-link"/, 'skip-link class required for keyboard A11Y'); -}); - -test('voyage-playground.html uses Blob for download flow (Step 11 export)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /\bnew Blob\b/, 'Blob download path required for annotated.md export'); -}); - -test('voyage-playground.html uses clipboard.writeText for copy flow (Step 11 export)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /clipboard\.writeText/, 'navigator.clipboard.writeText path required for command-copy'); -}); - -// --- v4.3 Sesjon 3 — Step 14 (dashboard) + Step 15 (drill-down + URL routing) ---- - -test('voyage-playground.html declares fleet-grid container (v4.3 Step 14 dashboard)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /fleet-grid/, 'fleet-grid container required for dashboard layout'); -}); - -test('voyage-playground.html declares fleet-tile (v4.3 Step 14 dashboard)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /fleet-tile/, 'fleet-tile required for per-artifact dashboard cell'); -}); - -test('voyage-playground.html declares renderDashboard JS function (v4.3 Step 14)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function renderDashboard\b/, 'renderDashboard function required'); -}); - -test('voyage-playground.html declares dashboard status vocabulary (v4.3 Step 14)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Status vocabulary per plan: complete, in-progress, blocked, missing, stale - assert.match(text, /'complete'/, 'status complete required'); - assert.match(text, /'in-progress'/, 'status in-progress required'); - assert.match(text, /'blocked'/, 'status blocked required'); - assert.match(text, /'missing'/, 'status missing required'); - assert.match(text, /'stale'/, 'status stale required'); -}); - -test('voyage-playground.html declares renderArtifactDetail JS function (v4.3 Step 15 drill-down)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function renderArtifactDetail\b/, 'renderArtifactDetail function required for drill-down'); -}); - -test('voyage-playground.html declares URLSearchParams routing (v4.3 Step 15)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Presence-only: URLSearchParams already used at line 810 for project-key - // derivation; Step 15 adds ?project= dashboard/detail routing. - assert.match(text, /URLSearchParams/, 'URLSearchParams required for ?project= routing'); -}); - -test('voyage-playground.html declares data-action="back-to-dashboard" (v4.3 Step 15 back-nav)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Stricter than Step 14 wording — must appear as data-action attribute - // somewhere in the JS template, not only in HTML comments. - assert.match(text, /data-action="back-to-dashboard"/, 'data-action="back-to-dashboard" required for return-nav handler'); -}); - -test('voyage-playground.html declares popstate handler (v4.3 Step 15 back/forward)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /'popstate'/, 'popstate listener required for browser back/forward'); -}); - -test('voyage-playground.html declares VOYAGE_ANCHOR_RE constant (v4.3 Step 16 anchor allowlist)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /VOYAGE_ANCHOR_RE\s*=\s*\/\^/, 'VOYAGE_ANCHOR_RE regex constant required'); - assert.match(text, /VOYAGE_ANCHOR_ATTR_RE\s*=\s*\//, 'VOYAGE_ANCHOR_ATTR_RE constant required'); - assert.match(text, /VOYAGE_ANCHOR_ID_RE\s*=\s*\/\^ANN-/, 'VOYAGE_ANCHOR_ID_RE constant required'); -}); - -test('voyage-playground.html anchor regex matches Node-side allowlist (v4.3 Step 16 cross-file sync)', () => { - const html = readFileSync(HTML, 'utf-8'); - const node = readFileSync(join(ROOT, 'lib', 'parsers', 'anchor-parser.mjs'), 'utf-8'); - const htmlMatch = html.match(/voyage:anchor[^/]+/)?.[0]; - const nodeMatch = node.match(/voyage:anchor[^/]+/)?.[0]; - assert.equal(htmlMatch, nodeMatch, 'first voyage:anchor token in HTML must mirror Node-side parser exactly'); -}); - -test('voyage-playground.html declares parseAnchor validator (v4.3 Step 16)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+parseAnchor\s*\(\s*line\s*\)/, 'parseAnchor(line) function required'); -}); - -test('voyage-playground.html declares relocateAnchorsToBlockBoundaries pure function (v4.3 Step 17)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+relocateAnchorsToBlockBoundaries\s*\(\s*text\s*,\s*anchors\s*\)/, - 'relocateAnchorsToBlockBoundaries(text, anchors) pure function required'); -}); - -test('voyage-playground.html declares .voyage-anchor-badge gutter component (v4.3 Step 18)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /\.voyage-anchor-badge\s*\{/, '.voyage-anchor-badge CSS class required'); - assert.match(text, /position:\s*absolute/, '.voyage-anchor-badge must use absolute positioning'); - assert.match(text, /var\(--color-scope-voyage\)/, 'badge must use --color-scope-voyage token'); -}); - -test('voyage-playground.html declares .voyage-anchor-active yellow-tint highlight (v4.3 Step 18)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /\.voyage-anchor-active\s*\{/, '.voyage-anchor-active CSS class required'); - assert.match(text, /rgba\(255,\s*235,\s*59,\s*0\.25\)/, 'yellow-tint rgba(255, 235, 59, 0.25) required'); -}); - -test('voyage-playground.html does NOT contain v4.2 pencil-icon references (v4.3 Step 18 cleanup)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.doesNotMatch(text, /voyage-pencil-btn/, 'pencil-btn class must be removed'); - assert.doesNotMatch(text, /injectPencilIcons/, 'injectPencilIcons function must be replaced by injectAnchorBadges'); -}); - -test('voyage-playground.html declares injectAnchorBadges JS function (v4.3 Step 18)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+injectAnchorBadges\s*\(\s*\)/, 'injectAnchorBadges() function required'); -}); - -test('voyage-playground.html declares voyage-sidebar hidden-by-default (v4.3 Step 19)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /id="voyage-sidebar"[\s\S]{0,200}aria-hidden="true"/, 'voyage-sidebar must default aria-hidden="true"'); -}); - -test('voyage-playground.html declares data-action="toggle-sidebar" on FAB (v4.3 Step 19)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /data-action="toggle-sidebar"/, 'data-action="toggle-sidebar" required on FAB toggle button'); -}); - -test('voyage-playground.html declares voyage-jumplist + count "X av N" (v4.3 Step 19)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /id="voyage-jumplist"/, 'voyage-jumplist ordered list required'); - assert.match(text, /id="voyage-jumplist-count"/, 'voyage-jumplist-count container required'); - assert.match(text, /' av '/, '"X av N" jumplist count format string required in JS'); -}); - -test('voyage-playground.html declares filter-buttons Alle/Åpne/Resolved (v4.3 Step 19)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /data-filter="all"/, 'filter button data-filter="all" required'); - assert.match(text, /data-filter="open"/, 'filter button data-filter="open" required'); - assert.match(text, /data-filter="resolved"/, 'filter button data-filter="resolved" required'); -}); - -test('voyage-playground.html declares renderAnnotationList JS function (v4.3 Step 19)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+renderAnnotationList\s*\(\s*\)/, 'renderAnnotationList() function required'); -}); - -test('voyage-playground.html declares wireKeyboardNav with j/k/]/Escape (v4.3 Step 20)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+wireKeyboardNav\s*\(\s*\)/, 'wireKeyboardNav() function required'); - assert.match(text, /e\.key === 'j'/, "'j' key handler required"); - assert.match(text, /e\.key === 'k'/, "'k' key handler required"); - assert.match(text, /e\.key === '\]'/, "']' key (toggle-sidebar) required"); - assert.match(text, /e\.key === 'Escape'/, "'Escape' key handler required"); -}); - -test('voyage-playground.html keyboard nav skips inputs/textareas (v4.3 Step 20)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /matches\([^)]*input[^)]*textarea/, 'input/textarea matches() guard required'); -}); - -test('voyage-playground.html keyboard nav announces via aria-live region (v4.3 Step 20)', () => { - const text = readFileSync(HTML, 'utf-8'); - // The wireKeyboardNav body contains announce(... ' av ' ...) for nav-position announce - assert.match(text, /announce\('Annotering '/, 'aria-live announce on annotation navigation required'); -}); - -// v4.3 Step 21 — two-opacity pattern (active 100% / inactive 40% / resolved 30% strikethrough) -test('voyage-playground.html declares two-opacity inactive default for badges (v4.3 Step 21)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Default badge rule must include opacity: 0.4 (inactive) - assert.match(text, /\.voyage-anchor-badge\s*\{[^}]*opacity:\s*0\.4/s, '.voyage-anchor-badge default opacity: 0.4 required'); -}); - -test('voyage-playground.html declares two-opacity active state for badges (v4.3 Step 21)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Active state: data-active="true" must restore opacity to 1 - assert.match(text, /\.voyage-anchor-badge\[data-active="true"\]\s*\{[^}]*opacity:\s*1/s, 'data-active opacity: 1 required'); -}); - -test('voyage-playground.html declares two-opacity resolved state for badges (v4.3 Step 21)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Resolved state: data-resolved="true" must produce opacity 0.3 + line-through - assert.match(text, /\.voyage-anchor-badge\[data-resolved="true"\]\s*\{[^}]*opacity:\s*0\.3/s, 'data-resolved opacity: 0.3 required'); - assert.match(text, /\.voyage-anchor-badge\[data-resolved="true"\]\s*\{[^}]*text-decoration:\s*line-through/s, 'data-resolved line-through required'); -}); - -test('voyage-playground.html declares two-opacity for sidebar list-items (v4.3 Step 21)', () => { - const text = readFileSync(HTML, 'utf-8'); - // List-item default opacity 0.4 - assert.match(text, /\.voyage-annotation-list__items\s+li\s*\{[^}]*opacity:\s*0\.4/s, 'list-item default opacity: 0.4 required'); - // List-item active overrides to 1 - assert.match(text, /\.voyage-annotation-list__items\s+li\[data-active="true"\][^}]*opacity:\s*1/s, 'list-item active opacity: 1 required'); - // List-item resolved opacity 0.3 - assert.match(text, /\.voyage-annotation-list__items\s+li\[data-resolved="true"\][^}]*opacity:\s*0\.3/s, 'list-item resolved opacity: 0.3 required'); -}); - -test('voyage-playground.html setActiveAnchor toggles data-active on badges (v4.3 Step 21)', () => { - const text = readFileSync(HTML, 'utf-8'); - // setActiveAnchor must clear prior data-active and set new one - assert.match(text, /setAttribute\('data-active',\s*'true'\)/, 'data-active set on active badge required'); - // injectAnchorBadges must propagate resolved state to badge data-resolved - assert.match(text, /setAttribute\('data-resolved',\s*'true'\)/, 'data-resolved set on resolved badge required'); -}); - -// v4.3 Step 22 — A11Y-panel built from DS-primitives (greenfield) -test('voyage-playground.html declares voyage-a11y-panel with guide-panel--info (v4.3 Step 22)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /id="voyage-a11y-panel"[^>]*guide-panel guide-panel--info/, 'voyage-a11y-panel with guide-panel--info required'); - // Must be hidden by default (placeholder until Wave 7) - assert.match(text, /id="voyage-a11y-panel"[\s\S]{0,300}\bhidden\b/, 'voyage-a11y-panel hidden by default required'); -}); - -test('voyage-playground.html declares data-action="toggle-a11y-panel" toggle-button (v4.3 Step 22)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /data-action="toggle-a11y-panel"/, 'toggle-a11y-panel button required'); - // aria-controls must point at the panel id - assert.match(text, /data-action="toggle-a11y-panel"[\s\S]*?aria-controls="voyage-a11y-panel"/, 'aria-controls binding required'); -}); - -test('voyage-playground.html A11Y-panel uses key-stats severity grid (v4.3 Step 22)', () => { - const text = readFileSync(HTML, 'utf-8'); - // key-stats grid with critical/high/medium/low severity modifiers - assert.match(text, /class="key-stat key-stat--critical"/, 'key-stat--critical required'); - assert.match(text, /class="key-stat key-stat--high"/, 'key-stat--high (serious) required'); - assert.match(text, /class="key-stat key-stat--medium"/, 'key-stat--medium (moderate) required'); - assert.match(text, /class="key-stat key-stat--low"/, 'key-stat--low (minor) required'); - // axe-core severity vocabulary on data-a11y-stat - assert.match(text, /data-a11y-stat="critical"/, 'data-a11y-stat="critical" required'); - assert.match(text, /data-a11y-stat="serious"/, 'data-a11y-stat="serious" required'); - assert.match(text, /data-a11y-stat="moderate"/, 'data-a11y-stat="moderate" required'); - assert.match(text, /data-a11y-stat="minor"/, 'data-a11y-stat="minor" required'); -}); - -test('voyage-playground.html A11Y-panel uses findings__items placeholder list (v4.3 Step 22)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Match either attribute order (class= or id= first); just confirm both live on the same
    . - assert.match(text, /]*class="findings__items"[^>]*id="voyage-a11y-findings"|]*id="voyage-a11y-findings"[^>]*class="findings__items"/, 'findings__items list (id=voyage-a11y-findings) required'); - // Placeholder line referencing the Wave 7 Playwright spec - assert.match(text, /Kjør axe-spec/, 'placeholder hint "Kjør axe-spec" required'); -}); - -test('voyage-playground.html declares wireA11yToggle JS function (v4.3 Step 22)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /function\s+wireA11yToggle\s*\(\s*\)/, 'wireA11yToggle() function required'); - // Toggle must flip hidden + aria-expanded - assert.match(text, /panel\.hidden\s*=\s*!willOpen/, 'panel.hidden toggle required'); - assert.match(text, /setAttribute\('aria-expanded'/, 'aria-expanded update required'); -}); - -// v4.3 Step 23 — screenshots-spor convention (window.__hooks + docs/screenshots/) -test('voyage-playground.html exposes window.__voyage automation hooks (v4.3 Step 23)', () => { - const text = readFileSync(HTML, 'utf-8'); - // window.__voyage must be assigned (object literal or assignment expression) - assert.match(text, /window\.__voyage\s*=\s*\{/, 'window.__voyage = { ... } assignment required'); -}); - -test('voyage-playground.html window.__voyage exposes navigate/scheduleRender/getProjectArtifacts (v4.3 Step 23)', () => { - const text = readFileSync(HTML, 'utf-8'); - // Each method must appear as a property of the exposed object. - assert.match(text, /navigate:\s*function/, 'navigate method required'); - assert.match(text, /scheduleRender:\s*function/, 'scheduleRender method required'); - assert.match(text, /getProjectArtifacts:\s*function/, 'getProjectArtifacts method required'); -}); - -test('docs/screenshots/README.md documents mappestruktur + hooks (v4.3 Step 23)', () => { - const path = join(ROOT, 'docs', 'screenshots', 'README.md'); - const text = readFileSync(path, 'utf-8'); - assert.match(text, /Mappestruktur/, 'Mappestruktur heading required'); - // Must list each documented subfolder - assert.match(text, /dashboard\//, 'dashboard/ subfolder documented'); - assert.match(text, /artifact-detail\//, 'artifact-detail/ subfolder documented'); - assert.match(text, /annotation\//, 'annotation/ subfolder documented'); - assert.match(text, /dark-mode\//, 'dark-mode/ subfolder documented'); - assert.match(text, /light-mode\//, 'light-mode/ subfolder documented'); - // Hooks documentation must reference all three methods - assert.match(text, /window\.__voyage\.navigate/, 'navigate hook documented'); - assert.match(text, /window\.__voyage\.scheduleRender/, 'scheduleRender hook documented'); - assert.match(text, /window\.__voyage\.getProjectArtifacts/, 'getProjectArtifacts hook documented'); -}); - -// v4.3 Step 24 — vendor DOMPurify + sanitize annotation-content -test('playground/lib/dompurify.min.js is vendored (v4.3 Step 24)', () => { - const path = join(PLAYGROUND, 'lib', 'dompurify.min.js'); - assert.equal(existsSync(path), true, 'playground/lib/dompurify.min.js must exist (run scripts/vendor-playground-libs.mjs)'); - const size = statSync(path).size; - // Sanity floor — DOMPurify min bundle is ~22 KB; reject empty/0-byte - assert.ok(size > 5000, 'dompurify.min.js too small (' + size + ' bytes) — vendor script may have failed'); -}); - -test('playground/lib/VENDOR-MANIFEST.json pins dompurify >= 3.1.1 (v4.3 Step 24)', () => { - const path = join(PLAYGROUND, 'lib', 'VENDOR-MANIFEST.json'); - const manifest = JSON.parse(readFileSync(path, 'utf-8')); - assert.ok(manifest.pins && manifest.pins.dompurify, 'manifest must pin dompurify'); - // semver compare on major.minor: must be >= 3.1.1 - const m = String(manifest.pins.dompurify).match(/^(\d+)\.(\d+)\.(\d+)/); - assert.ok(m, 'invalid dompurify pin format: ' + manifest.pins.dompurify); - const [, maj, min] = m; - assert.ok(Number(maj) > 3 || (Number(maj) === 3 && Number(min) >= 1), 'dompurify pin must be >= 3.1.1, got ' + manifest.pins.dompurify); - assert.ok(manifest.output_files.includes('dompurify.min.js'), 'manifest output_files must list dompurify.min.js'); -}); - -test('voyage-playground.html loads dompurify.min.js (v4.3 Step 24)', () => { - const text = readFileSync(HTML, 'utf-8'); - assert.match(text, /"\n---\n\n# Foo\n'; + const html = buildHtml('/abs/path/brief.md', md); + const titleMatch = html.match(/([\s\S]*?)<\/title>/); + assert.ok(titleMatch, 'must have a title'); + assert.ok(!titleMatch[1].includes('<script>'), 'title must not carry a raw <script> tag'); + assert.match(titleMatch[1], /<script>/, 'title must be HTML-escaped'); +}); + +test('hostile inline content cannot inject as live HTML attributes', () => { + const md = '# Heading\n\nA paragraph with <img src=x onerror="alert(1)"> embedded.\n'; + const html = buildHtml('/abs/path/brief.md', md); + // The article body must not carry a live onerror="..." attribute (the renderer + // HTML-escapes everything in the body, so `<` → `<`). + const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/); + assert.ok(articleMatch, 'must have article body'); + assert.ok(!/onerror\s*=\s*"alert/i.test(articleMatch[1]), + 'article body must not carry a live onerror attribute'); + assert.ok(articleMatch[1].includes('<img'), + 'hostile <img> must be escaped to <img'); +}); + +test('render() is deterministic — two runs byte-identical', () => { + const dir = mkdtempSync(join(tmpdir(), 'claude-annotate-')); + try { + const md = join(dir, 'plan.md'); + writeFileSync(md, SAMPLE); + const a = render(md, join(dir, 'a.html')); + const b = render(md, join(dir, 'b.html')); + assert.ok(existsSync(a) && existsSync(b)); + assert.equal(readFileSync(a, 'utf-8'), readFileSync(b, 'utf-8')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('render() defaults output to <input-basename>.html next to input', () => { + const dir = mkdtempSync(join(tmpdir(), 'claude-annotate-')); + try { + const md = join(dir, 'review.md'); + writeFileSync(md, '# Review\n\nok\n'); + const out = render(md); + assert.equal(out, join(dir, 'review.html')); + assert.ok(existsSync(out)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('parseArgs handles --out, positional input, and --help', () => { + assert.deepEqual(parseArgs(['x.md']), { input: 'x.md', out: null, help: false }); + assert.deepEqual(parseArgs(['x.md', '--out', 'y.html']), { input: 'x.md', out: 'y.html', help: false }); + assert.equal(parseArgs(['--help']).help, true); +}); + +test('buildHtml wires the v5.0.3 operator-driven annotation affordances', () => { + // Pin every UX-critical affordance modelled on claude-code-100x/build-site.js: + // - Pencil-toggle button (annotation mode on/off) + // - Form popover with three intent buttons (Fiks/Endre/Spørsmål) + // - Annotations sidebar (Your annotations + Clear all + Copy Prompt) + // - Selection capture (window.getSelection()) + // - Section context auto-detection (findSection) + // - localStorage persistence (voyage-annotate:v2:...) + // - Annotatable elements (data-anchor-id on h1-h6, p, li, td, blockquote, pre) + const html = buildHtml('/abs/path/brief.md', SAMPLE); + // Toggle + assert.ok(html.includes('ann-toggle'), 'must have the pencil-toggle button'); + assert.ok(html.includes('Annotation mode: ON'), 'must label the toggle state'); + // Form + intents (the three CSS classes for selected state) + assert.ok(html.includes('data-intent="fiks"'), 'must have Fiks intent button'); + assert.ok(html.includes('data-intent="endre"'), 'must have Endre intent button'); + assert.ok(html.includes('data-intent="spørsmål"'), 'must have Spørsmål intent button'); + // Form popover + assert.ok(html.includes('ann-form'), 'must have the form popover'); + assert.ok(html.includes('ann-form-comment'), 'must have a comment textarea'); + assert.ok(html.includes('ann-form-save'), 'must have a Save button'); + // Sidebar + assert.ok(html.includes('ann-panel'), 'must have the annotations sidebar'); + assert.ok(html.includes('Your annotations'), 'sidebar must title the list'); + assert.ok(html.includes('Clear all'), 'sidebar must offer Clear all'); + assert.ok(html.includes('Copy Prompt'), 'sidebar must offer Copy Prompt'); + // Selection + section + assert.ok(html.includes('window.getSelection'), 'must capture selection'); + assert.ok(html.includes('findSection'), 'must auto-detect section context'); + // Persistence + assert.ok(html.includes("'voyage-annotate:v2:'"), 'must use the v2 localStorage key prefix'); + // Anchor coverage + const anchors = (html.match(/data-anchor-id="anch-/g) || []).length; + assert.ok(anchors >= 5, 'must emit data-anchor-id on enough elements (got ' + anchors + ')'); +}); + +test('renderMarkdown produces headings, lists, code, table, blockquote with anchors', () => { + const html = renderMarkdown(`# H1 +## H2 +- a +- b + +1. one +2. two + +| Col | Val | +|-----|-----| +| x | 1 | + +\`\`\` +plain code +\`\`\` + +> quote +`); + assert.match(html, /<h1 data-anchor-id="anch-0">H1<\/h1>/); + assert.match(html, /<h2 data-anchor-id="anch-1">H2<\/h2>/); + assert.match(html, /<ul><li data-anchor-id=/); + assert.match(html, /<ol><li data-anchor-id=/); + assert.match(html, /<table>[\s\S]*<th data-anchor-id=/); + assert.match(html, /<pre data-anchor-id=/); + assert.match(html, /<blockquote data-anchor-id=/); +}); diff --git a/plugins/voyage/tests/scripts/render-artifact.test.mjs b/plugins/voyage/tests/scripts/render-artifact.test.mjs deleted file mode 100644 index 6e9bc44..0000000 --- a/plugins/voyage/tests/scripts/render-artifact.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -// tests/scripts/render-artifact.test.mjs -// CLI renderer contract — brief SC1 (zero-network) + SC11 (self-eat). -// -// Verifies: -// 1. CLI produces a non-empty .html file from a valid input.md -// 2. Output has DOCTYPE + closing </html> + inlined <style> + inlined <script> -// 3. Output contains NO http:// or https:// URLs (zero-network constraint) -// 4. Output title comes from frontmatter (slug or task) -// 5. Two invocations on the same input produce byte-identical output - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, statSync, mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createHash } from 'node:crypto'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(HERE, '..', '..'); -const RENDERER = join(ROOT, 'scripts', 'render-artifact.mjs'); -const FIX_BRIEF = join(ROOT, 'tests', 'fixtures', 'annotation', 'annotation-brief.md'); - -function runRender(input, out) { - return execFileSync('node', [RENDERER, input, '--out', out], { encoding: 'utf-8' }); -} - -function sha256(p) { - return createHash('sha256').update(readFileSync(p)).digest('hex'); -} - -test('render-artifact CLI exits 0 and produces a non-empty .html file', () => { - const dir = mkdtempSync(join(tmpdir(), 'voyage-render-')); - try { - const out = join(dir, 'brief.html'); - const stdout = runRender(FIX_BRIEF, out); - assert.match(stdout, /render-artifact: wrote/, 'CLI should announce written path'); - assert.ok(existsSync(out), 'output file must exist'); - assert.ok(statSync(out).size > 0, 'output file must be non-empty'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('render-artifact output has DOCTYPE + closing </html> + inlined <style> + inlined <script>', () => { - const dir = mkdtempSync(join(tmpdir(), 'voyage-render-')); - try { - const out = join(dir, 'brief.html'); - runRender(FIX_BRIEF, out); - const html = readFileSync(out, 'utf-8'); - assert.match(html, /^<!DOCTYPE html>/i, 'must start with DOCTYPE'); - assert.match(html, /<\/html>\s*$/, 'must end with </html>'); - assert.match(html, /<style>[\s\S]+<\/style>/, 'must inline <style>'); - assert.match(html, /<script>[\s\S]+<\/script>/, 'must inline <script>'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('render-artifact output contains NO http:// or https:// URLs (zero-network SC1)', () => { - const dir = mkdtempSync(join(tmpdir(), 'voyage-render-')); - try { - const out = join(dir, 'brief.html'); - runRender(FIX_BRIEF, out); - const html = readFileSync(out, 'utf-8'); - assert.ok(!/https?:\/\//.test(html), 'output must contain no http:// or https:// URLs'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('render-artifact output title derives from frontmatter task/slug', () => { - const dir = mkdtempSync(join(tmpdir(), 'voyage-render-')); - try { - const out = join(dir, 'brief.html'); - runRender(FIX_BRIEF, out); - const html = readFileSync(out, 'utf-8'); - // annotation-brief.md has task: "Demo task for annotation round-trip fixture" - // and slug: annotation-brief-demo. Either should appear in <title>. - assert.match(html, /<title>[^<]*(Demo task for annotation round-trip fixture|annotation-brief-demo)[^<]*<\/title>/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('render-artifact is deterministic (two invocations -> byte-identical sha256)', () => { - const dir = mkdtempSync(join(tmpdir(), 'voyage-render-')); - try { - const a = join(dir, 'brief-a.html'); - const b = join(dir, 'brief-b.html'); - runRender(FIX_BRIEF, a); - runRender(FIX_BRIEF, b); - assert.strictEqual(sha256(a), sha256(b), 'same input must produce byte-identical output'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/plugins/voyage/tests/validators/brief-validator-annotation-fields.test.mjs b/plugins/voyage/tests/validators/brief-validator-annotation-fields.test.mjs deleted file mode 100644 index 33fee13..0000000 --- a/plugins/voyage/tests/validators/brief-validator-annotation-fields.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -// tests/validators/brief-validator-annotation-fields.test.mjs -// Pin forward-compat for v4.2 annotation frontmatter fields on brief.md. -// Adding revision/source_annotations/annotation_digest/revision_reason must NOT -// trigger BRIEF_UNKNOWN_FIELD or similar — validator is purely additive-tolerant -// per source_findings precedent. No code change required; this test pins the policy. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { validateBriefContent } from '../../lib/validators/brief-validator.mjs'; - -const BASE_BRIEF = `--- -type: trekbrief -brief_version: "2.0" -created: 2026-05-09 -task: "Annotated revision for testing forward-compat" -slug: ann-fwd-compat -project_dir: .claude/projects/2026-05-09-ann-fwd-compat/ -research_topics: 0 -research_status: skipped -auto_research: false -interview_turns: 1 -source: interview ---- - -# Task: Annotated revision - -## Intent - -Why. - -## Goal - -What. - -## Success Criteria - -- Done. -`; - -test('brief-validator forward-compat — baseline (no annotation fields) still valid', () => { - const r = validateBriefContent(BASE_BRIEF, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — accepts revision: 0', () => { - const t = BASE_BRIEF.replace('---\ninterview_turns: 1', '---\ninterview_turns: 1\nrevision: 0'); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — accepts revision: 5', () => { - const t = BASE_BRIEF.replace('source: interview', 'source: interview\nrevision: 5'); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — accepts source_annotations list-of-dict', () => { - const inject = `\nrevision: 1\nsource_annotations:\n - id: ANN-0001\n target_artifact: brief.md\n target_anchor: intent\n intent: change\n comment: "tighten the intent paragraph"\n timestamp: "2026-05-09T10:00:00Z"`; - const t = BASE_BRIEF.replace('source: interview', 'source: interview' + inject); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — accepts annotation_digest string', () => { - const t = BASE_BRIEF.replace('source: interview', 'source: interview\nrevision: 1\nannotation_digest: abc123def4567890'); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — accepts revision_reason for non-additive revision', () => { - const t = BASE_BRIEF.replace('source: interview', 'source: interview\nrevision: 2\nrevision_reason: "restructured Goals section"'); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — all 4 fields together still valid', () => { - const inject = `\nrevision: 3\nrevision_reason: "applied 5 annotations"\nannotation_digest: 0123456789abcdef\nsource_annotations:\n - id: ANN-0001\n target_artifact: brief.md\n target_anchor: goal\n intent: change`; - const t = BASE_BRIEF.replace('source: interview', 'source: interview' + inject); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('brief-validator forward-compat — unrecognized future field still tolerated (forward-compat policy)', () => { - const t = BASE_BRIEF.replace('source: interview', 'source: interview\nfuture_v4_3_field: "anything"'); - const r = validateBriefContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); diff --git a/plugins/voyage/tests/validators/brief-validator.test.mjs b/plugins/voyage/tests/validators/brief-validator.test.mjs index 6e501d2..69e250f 100644 --- a/plugins/voyage/tests/validators/brief-validator.test.mjs +++ b/plugins/voyage/tests/validators/brief-validator.test.mjs @@ -152,3 +152,101 @@ test('validateBrief — wrong-type error message includes accepted set', () => { assert.ok(/trekbrief/.test(wrongType.message)); assert.ok(/trekreview/.test(wrongType.message)); }); + +// --- v5.1 — phase_signals additive field + sequencing gate --- + +const SIGNALS_BLOCK = `phase_signals: + - phase: research + effort: standard + - phase: plan + effort: high + model: opus + - phase: execute + effort: low + model: sonnet + - phase: review + effort: standard +`; + +test('validateBrief — v5.1 well-formed phase_signals accepted', () => { + const t = GOOD_BRIEF + .replace('brief_version: "2.0"', 'brief_version: "2.1"') + .replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK}`); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); +}); + +test('validateBrief — pre-v5.1 brief without phase_signals accepted (backward-compat)', () => { + const r = validateBriefContent(GOOD_BRIEF, { strict: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +}); + +test('validateBrief — v5.1+ brief missing phase_signals + partial emits BRIEF_V51_MISSING_SIGNALS', () => { + const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: "2.1"'); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +}); + +test('validateBrief — v5.1+ brief with phase_signals_partial: true accepted', () => { + const t = GOOD_BRIEF + .replace('brief_version: "2.0"', 'brief_version: "2.1"') + .replace('source: interview\n', 'source: interview\nphase_signals_partial: true\n'); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); +}); + +test('validateBrief — phase_signals + phase_signals_partial both set rejected (mutually exclusive)', () => { + const t = GOOD_BRIEF + .replace('brief_version: "2.0"', 'brief_version: "2.1"') + .replace('source: interview\n', `source: interview\nphase_signals_partial: true\n${SIGNALS_BLOCK}`); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE')); +}); + +test('validateBrief — phase_signals with unknown phase rejected', () => { + const BAD_SIGNALS = `phase_signals: + - phase: nonsense + effort: standard +`; + const t = GOOD_BRIEF + .replace('brief_version: "2.0"', 'brief_version: "2.1"') + .replace('source: interview\n', `source: interview\n${BAD_SIGNALS}`); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'BRIEF_INVALID_PHASE_SIGNAL_PHASE')); +}); + +// --- v5.1.1 regression: YAML-number bypass closed --- +// Findings 3c834097 + df1435a2: v5.1.0 shipped with an unquoted `brief_version: 2.1` +// template. parseScalar coerces unquoted "2.1" to Number 2.1, and the original gate +// guarded `typeof === 'string'`, silently bypassing the sequencing check. v5.1.1 +// coerces via String() so both shapes trigger the gate. + +test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 without signals triggers gate', () => { + const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: 2.1'); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `gate must fire for unquoted brief_version: 2.1 (YAML Number); errors=${JSON.stringify(r.errors)}`, + ); +}); + +test('validateBrief — v5.1.1: QUOTED brief_version "2.1" without signals triggers gate (regression guard)', () => { + const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: "2.1"'); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +}); + +test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 WITH phase_signals is valid (positive case)', () => { + const t = GOOD_BRIEF + .replace('brief_version: "2.0"', 'brief_version: 2.1') + .replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK}`); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +}); diff --git a/plugins/voyage/tests/validators/plan-validator-annotation-fields.test.mjs b/plugins/voyage/tests/validators/plan-validator-annotation-fields.test.mjs deleted file mode 100644 index 6a74f5d..0000000 --- a/plugins/voyage/tests/validators/plan-validator-annotation-fields.test.mjs +++ /dev/null @@ -1,79 +0,0 @@ -// tests/validators/plan-validator-annotation-fields.test.mjs -// Pin forward-compat for v4.2 annotation frontmatter fields on plan.md. -// Adding revision/source_annotations/annotation_digest/revision_reason must NOT -// trigger PLAN_UNKNOWN_FIELD or similar — validator is purely additive-tolerant -// per source_findings precedent. No code change required; this test pins the policy. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { validatePlanContent } from '../../lib/validators/plan-validator.mjs'; - -const STEP_BLOCK = `### Step 1: Do thing - -- Files: a.ts -- Manifest: - \`\`\`yaml - manifest: - expected_paths: - - a.ts - min_file_count: 1 - commit_message_pattern: "^feat:" - bash_syntax_check: [] - forbidden_paths: [] - must_contain: [] - \`\`\` -`; - -const baseFm = (extra = '') => `--- -plan_version: "1.7" -profile: balanced${extra} ---- - -# Plan - -## Implementation Plan - -${STEP_BLOCK} -`; - -test('plan-validator forward-compat — baseline (no annotation fields) still valid', () => { - const r = validatePlanContent(baseFm(), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — accepts revision: 0', () => { - const r = validatePlanContent(baseFm('\nrevision: 0'), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — accepts revision: 5', () => { - const r = validatePlanContent(baseFm('\nrevision: 5'), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — accepts source_annotations list-of-dict', () => { - const inject = `\nrevision: 1\nsource_annotations:\n - id: ANN-0001\n target_artifact: plan.md\n target_anchor: step-3\n intent: change\n comment: "reorder ahead of step 4"\n timestamp: "2026-05-09T10:00:00Z"`; - const r = validatePlanContent(baseFm(inject), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — accepts annotation_digest string', () => { - const r = validatePlanContent(baseFm('\nrevision: 1\nannotation_digest: 0123456789abcdef'), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — accepts revision_reason', () => { - const r = validatePlanContent(baseFm('\nrevision: 2\nrevision_reason: "structural step reorder"'), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — all 4 fields together with source_findings', () => { - const inject = `\nrevision: 3\nrevision_reason: "applied 5 annotations"\nannotation_digest: abc1234567890def\nsource_annotations:\n - id: ANN-0001\n target_artifact: plan.md\n target_anchor: step-3\n intent: change\nsource_findings:\n - 0123456789abcdef0123456789abcdef01234567`; - const r = validatePlanContent(baseFm(inject), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('plan-validator forward-compat — unrecognized future field tolerated', () => { - const r = validatePlanContent(baseFm('\nfuture_v4_3_key: "any"'), { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); diff --git a/plugins/voyage/tests/validators/review-validator-annotation-fields.test.mjs b/plugins/voyage/tests/validators/review-validator-annotation-fields.test.mjs deleted file mode 100644 index d791509..0000000 --- a/plugins/voyage/tests/validators/review-validator-annotation-fields.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -// tests/validators/review-validator-annotation-fields.test.mjs -// Pin forward-compat for v4.2 annotation frontmatter fields on review.md. -// Adding revision/source_annotations/annotation_digest/revision_reason must NOT -// trigger REVIEW_UNKNOWN_FIELD or similar — validator is purely additive-tolerant -// per source_findings precedent. No code change required; this test pins the policy. - -import { test } from 'node:test'; -import { strict as assert } from 'node:assert'; -import { validateReviewContent } from '../../lib/validators/review-validator.mjs'; - -const BASE_REVIEW = `--- -type: trekreview -review_version: "1.0" -created: 2026-05-09 -task: "Annotated revision forward-compat" -slug: ann-fwd-compat -project_dir: .claude/projects/2026-05-09-ann-fwd-compat/ -brief_path: .claude/projects/2026-05-09-ann-fwd-compat/brief.md -scope_sha_start: abc123 -scope_sha_end: def456 -reviewed_files_count: 1 -findings: [] ---- - -# Review - -## Executive Summary - -Verdict: ALLOW. - -## Coverage - -| File | Treatment | Reason | -|------|-----------|--------| -| lib/foo.mjs | deep-review | risk | - -## Remediation Summary - -None. -`; - -test('review-validator forward-compat — baseline (no annotation fields) still valid', () => { - const r = validateReviewContent(BASE_REVIEW, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — accepts revision: 0', () => { - const t = BASE_REVIEW.replace('findings: []', 'findings: []\nrevision: 0'); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — accepts revision: 5', () => { - const t = BASE_REVIEW.replace('findings: []', 'findings: []\nrevision: 5'); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — accepts source_annotations alongside source-style findings', () => { - const inject = `\nrevision: 1\nsource_annotations:\n - id: ANN-0001\n target_artifact: review.md\n target_anchor: executive-summary\n intent: question\n comment: "wording is ambiguous"\n timestamp: "2026-05-09T10:00:00Z"`; - const t = BASE_REVIEW.replace('findings: []', 'findings: []' + inject); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — accepts annotation_digest string', () => { - const t = BASE_REVIEW.replace('findings: []', 'findings: []\nrevision: 1\nannotation_digest: 0123456789abcdef'); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — accepts revision_reason for non-additive revision', () => { - const t = BASE_REVIEW.replace('findings: []', 'findings: []\nrevision: 2\nrevision_reason: "removed coverage section"'); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — all 4 annotation fields together still valid', () => { - const inject = `\nrevision: 3\nrevision_reason: "applied 2 annotations"\nannotation_digest: 0123456789abcdef\nsource_annotations:\n - id: ANN-0001\n target_artifact: review.md\n target_anchor: coverage\n intent: change`; - const t = BASE_REVIEW.replace('findings: []', 'findings: []' + inject); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); - -test('review-validator forward-compat — unrecognized future field tolerated', () => { - const t = BASE_REVIEW.replace('findings: []', 'findings: []\nfuture_v4_3_key: "any"'); - const r = validateReviewContent(t, { strict: true }); - assert.equal(r.valid, true, JSON.stringify(r.errors)); -}); diff --git a/shared/playground-design-system/CHANGELOG.md b/shared/playground-design-system/CHANGELOG.md index 1594aa0..3d489a7 100644 --- a/shared/playground-design-system/CHANGELOG.md +++ b/shared/playground-design-system/CHANGELOG.md @@ -1,5 +1,53 @@ # playground-design-system — CHANGELOG +## 0.6.0 — 2026-05-15 + +### Added — Project-view archetype (Tier 4) + +Generic "project as artifact-collection" archetype for plugins where a project owns 0-N read-only report artifacts grouped by category. Default view is an aggregated dashboard; clicking a sidebar item swaps the main panel to the per-artifact render. Edit-mode is paste-import only (no inline editor). + +- **New file `components-tier4-project-view.css`** — 11 sections covering: + - `.project-view` + `.project-view__layout` (grid: nav 280px + main 1fr, responsive collapse at 1280 / 960px) + - `.project-view__header` (CSS Grid with eyebrow/title/lede/verdict/key-stats/actions areas) + - `.verdict-pill` (small pill variant — companion to existing `.verdict-pill-lg` in tier2) + - `.project-view__nav` + `.project-view__nav-search` (sticky sidebar with search) + - `.artifact-list` + `__group` / `__group-label` / `__group-count` / `__group-items` / `__item` / `__item-marker` / `__item-body` / `__item-name` / `__item-meta` (grouped, severity-coded sidebar) + - `.artifact-status[data-severity]` (mini-pill: positive | medium | critical) + - `.project-view__main` (main column container) + - `.project-overview` + `__intro` / `__verdict-grid` / `__verdict-tile[data-severity]` / `__section` / `__top-risks` / `__next-actions` / `__missing-reports` (aggregated dashboard) + - `.project-view__artifact` + `__artifact-header` / `__artifact-title` / `__artifact-meta` / `__artifact-actions` / `__artifact-body` (single-rapport viewer wrapper) + - `.empty-artifact-prompt` + `__icon` / `__title` / `__text` / `__actions` (empty-state) + - `.import-modal` + `__backdrop` / `__panel` / `__head` / `__title` / `__close` / `__form` / `__detect` / `__preview` / `__preview-label` / `__footer` (overlay modal for paste-import) + +- **6 new tokens in `tokens.css`:** + - `--project-view-nav-width: 280px` — sidebar width at full layout + - `--project-view-collapse-bp: 960px` — doc-only token referenced by responsive breakpoints + - `--artifact-list-item-pad-y: var(--space-2)` — sidebar row vertical padding + - `--artifact-list-item-pad-x: var(--space-3)` — sidebar row horizontal padding + - `--artifact-marker-size: 14px` — sidebar status marker diameter + - `--artifact-marker-border: 1.5px` — sidebar status marker border thickness + +### Påvirkning + +Endringen er **additiv**: ny komponent-fil + 6 nye tokens, ingen eksisterende selectors eller verdier endres. Plugin-konsumenter (`ms-ai-architect`, `llm-security`, `okr`, `config-audit`, `voyage`) får silent drift mot ny source-commit, men kan re-sync på eget tempo. Bare `ms-ai-architect` og `llm-security` re-syncer i samme commit som denne DS-bumpen (forberedelse til koordinert v1.15.0 / v7.7.0-release etter ~8 sesjoner med JS-implementasjon). + +Førsteadoptere: `ms-ai-architect` v1.15.0 (17 artefakter, 5 kategorier) + `llm-security` v7.7.0 (≥18 artefakter, 6 kategorier). State-driven visibility håndteres i plugin-JS, ikke i denne CSS-en — kun aktiv state rendres per pass. + +### Plugins som må laste den nye filen + +Etter `<link>` til `components-tier3-supplement.css`, legg til: + +```html +<link rel="stylesheet" href="vendor/playground-design-system/components-tier4-project-view.css"> +``` + +### For å adoptere v0.6.0 + +```bash +node scripts/sync-design-system.mjs <plugin-name> +# --force hvis drift detected +``` + ## 0.5.0 — 2026-05-10 ### Added diff --git a/shared/playground-design-system/components-tier4-project-view.css b/shared/playground-design-system/components-tier4-project-view.css new file mode 100644 index 0000000..3db37b8 --- /dev/null +++ b/shared/playground-design-system/components-tier4-project-view.css @@ -0,0 +1,665 @@ +/* ============================================================================= + Playground Design System — components-tier4-project-view.css + v0.6.0 — Tier 4 project-view archetype + ============================================================================ + + Generic "project as artifact-collection" archetype. Default-view is an + aggregated overview dashboard; clicking a sidebar item swaps main to a + per-artifact render. Tracks 0-N read-only artifacts; edit-mode is paste- + import only (markdown from terminal → parser → store). + + First adopters: ms-ai-architect v1.15.0 (17 artifacts, 5 categories) + + llm-security v7.7.0 (≥18 artifacts, 6 categories). Each plugin injects a + PROJECT_VIEW_CONFIG object that maps commands → renderers, categories, + verdict-aggregators, missing-report heuristics. + + The CSS in this file is plugin-agnostic. Plugin-specific shape (category + names, artifact ordering, custom severity-mappings) lives in JS config. + + State-driven visibility is NOT handled here — production playgrounds emit + only the active state (overview | artifact | empty | import) per render + pass. The mockup uses body[data-state="..."] for prototyping; production + renders one branch at a time. + ============================================================================= */ + + +/* === 1. Project-view top-level layout ===================================== */ + +.project-view { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.project-view__layout { + display: grid; + grid-template-columns: var(--project-view-nav-width) 1fr; + gap: var(--space-6); + align-items: start; +} + +@media (max-width: 1279px) { + .project-view__layout { grid-template-columns: 240px 1fr; } +} + +@media (max-width: 959px) { + .project-view__layout { grid-template-columns: 1fr; } +} + + +/* === 2. Project-view header =============================================== */ + +.project-view__header { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-5) var(--space-6); + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "title verdict" + "title keystats" + "actions actions"; + gap: var(--space-4) var(--space-6); + align-items: start; +} + +.project-view__title-block { grid-area: title; } +.project-view__verdict { grid-area: verdict; justify-self: end; } +.project-view__key-stats { grid-area: keystats; justify-self: end; } +.project-view__actions { grid-area: actions; display: flex; gap: var(--space-2); justify-content: flex-end; } + +.project-view__eyebrow { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-tertiary); + font-weight: var(--font-weight-semibold); + margin: 0 0 var(--space-2) 0; +} + +.project-view__title { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0 0 var(--space-2) 0; +} + +.project-view__lede { + color: var(--color-text-secondary); + margin: 0; + max-width: 60ch; +} + +.project-view__key-stats { + display: flex; + gap: var(--space-5); +} + +.project-view__key-stat-label { + font-size: 10px; + text-transform: uppercase; + color: var(--color-text-tertiary); + letter-spacing: 0.06em; +} + +.project-view__key-stat-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + font-variant-numeric: tabular-nums; +} + + +/* === 3. Verdict-pill (small) ============================================== + Companion to .verdict-pill-lg (Tier 2). Inline-flex pill used in project + header + sidebar status badges. The larger -lg variant lives in + components-tier2.css; both share the same severity-band semantics. */ + +.verdict-pill { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 4px 12px; + border-radius: 999px; + font-weight: var(--font-weight-semibold); + font-size: var(--font-size-sm); +} + +.verdict-pill--positive { background: var(--color-state-success); color: #fff; } +.verdict-pill--medium { background: var(--color-severity-medium); color: var(--color-severity-medium-on); } +.verdict-pill--critical { background: var(--color-severity-critical); color: #fff; } +.verdict-pill--in-progress { + background: var(--color-bg-soft); + color: var(--color-text-secondary); + border: 1px dashed var(--color-border-moderate); +} + + +/* === 4. Sidebar nav ======================================================= */ + +.project-view__nav { + position: sticky; + top: var(--space-6); + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.project-view__nav-search input { + width: 100%; + box-sizing: border-box; + padding: 6px 10px; + font-size: var(--font-size-sm); + background: var(--color-bg); + color: var(--color-text-primary); + border: 1px solid var(--color-border-moderate); + border-radius: var(--radius-sm); +} + + +/* === 5. Artifact-list ===================================================== */ + +.artifact-list { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin: 0; + padding: 0; + list-style: none; +} + +.artifact-list__group { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.artifact-list__group-label { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-tertiary); + font-weight: var(--font-weight-semibold); + padding: 0 var(--space-2); +} + +.artifact-list__group-count { + background: var(--color-bg-soft); + color: var(--color-text-tertiary); + font-family: var(--font-family-mono); + font-size: 10px; + padding: 1px 6px; + border-radius: 999px; +} + +.artifact-list__group-items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.artifact-list__item { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-2); + padding: var(--artifact-list-item-pad-y) var(--artifact-list-item-pad-x); + border-radius: var(--radius-sm); + cursor: pointer; + background: transparent; + border: 1px solid transparent; + transition: background 120ms ease, border-color 120ms ease; +} + +.artifact-list__item:hover { background: var(--color-bg-soft); } + +.artifact-list__item[data-state="active"] { + background: var(--color-bg-soft); + border-color: var(--color-primary-500); + box-shadow: inset 3px 0 0 var(--color-primary-500); + padding-left: calc(var(--artifact-list-item-pad-x) - 3px); +} + +.artifact-list__item-marker { + width: var(--artifact-marker-size); + height: var(--artifact-marker-size); + border-radius: 50%; + border: var(--artifact-marker-border) solid var(--color-border-moderate); + background: transparent; + flex-shrink: 0; +} + +.artifact-list__item[data-state="filled"][data-severity="positive"] .artifact-list__item-marker { + background: var(--color-state-success); + border-color: var(--color-state-success); +} +.artifact-list__item[data-state="filled"][data-severity="medium"] .artifact-list__item-marker { + background: var(--color-severity-medium); + border-color: var(--color-severity-medium); +} +.artifact-list__item[data-state="filled"][data-severity="critical"] .artifact-list__item-marker { + background: var(--color-severity-critical); + border-color: var(--color-severity-critical); +} + +.artifact-list__item-body { min-width: 0; } + +.artifact-list__item-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.artifact-list__item[data-state="empty"] .artifact-list__item-name { + color: var(--color-text-tertiary); + font-weight: var(--font-weight-regular); +} + +.artifact-list__item-meta { + font-size: 11px; + color: var(--color-text-tertiary); +} + + +/* === 6. Artifact-status (mini pill in sidebar) =========================== */ + +.artifact-status { + font-family: var(--font-family-mono); + font-size: 10px; + font-weight: var(--font-weight-semibold); + padding: 1px 5px; + border-radius: var(--radius-sm); + letter-spacing: 0.04em; +} + +.artifact-status[data-severity="positive"] { background: var(--color-severity-low-soft); color: var(--color-severity-low-on); } +.artifact-status[data-severity="medium"] { background: var(--color-severity-medium-soft); color: var(--color-severity-medium-on); } +.artifact-status[data-severity="critical"] { background: var(--color-severity-critical-soft); color: var(--color-severity-critical-on); } + + +/* === 7. Project-view main panel ========================================== */ + +.project-view__main { + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-5); +} + + +/* === 8. Project-overview (default dashboard) ============================= */ + +.project-overview { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.project-overview__intro { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.project-overview__intro h2 { + font-size: var(--font-size-lg); + margin: 0 0 var(--space-2) 0; +} + +.project-overview__intro p { + color: var(--color-text-secondary); + margin: 0; +} + +.project-overview__verdict-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-3); +} + +.project-overview__verdict-tile { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-left: 4px solid var(--color-border-moderate); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.project-overview__verdict-tile[data-severity="positive"] { border-left-color: var(--color-state-success); } +.project-overview__verdict-tile[data-severity="medium"] { border-left-color: var(--color-severity-medium); } +.project-overview__verdict-tile[data-severity="critical"] { border-left-color: var(--color-severity-critical); } +.project-overview__verdict-tile[data-severity="empty"] { border-left-style: dashed; } + +.project-overview__verdict-tile-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-tertiary); + font-weight: var(--font-weight-semibold); +} + +.project-overview__verdict-tile-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); +} + +.project-overview__verdict-tile-meta { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +.project-overview__section h3 { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-tertiary); + font-weight: var(--font-weight-semibold); + margin: 0 0 var(--space-3) 0; +} + +.project-overview__top-risks, +.project-overview__next-actions { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.project-overview__top-risks ol, +.project-overview__next-actions ol { + list-style: none; + counter-reset: rank; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.project-overview__top-risks li, +.project-overview__next-actions li { + counter-increment: rank; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-bg-soft); +} + +.project-overview__top-risks li::before, +.project-overview__next-actions li::before { + content: counter(rank); + font-family: var(--font-family-mono); + font-weight: var(--font-weight-bold); + color: var(--color-text-tertiary); + font-size: var(--font-size-sm); + min-width: 20px; +} + +.project-overview__missing-reports { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-5); +} + +.project-overview__missing-reports ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.project-overview__missing-reports li { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-soft); + border-radius: var(--radius-sm); + border-left: 3px dashed var(--color-border-moderate); +} + + +/* === 9. Artifact-view (one report rendered) ============================== */ + +.project-view__artifact { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.project-view__artifact-header { + display: flex; + justify-content: space-between; + align-items: start; + gap: var(--space-4); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); +} + +.project-view__artifact-title { + font-size: var(--font-size-xl); + margin: 0 0 var(--space-1) 0; +} + +.project-view__artifact-meta { + font-size: var(--font-size-sm); + color: var(--color-text-tertiary); + margin: 0; +} + +.project-view__artifact-actions { + display: flex; + gap: var(--space-2); + flex-shrink: 0; +} + +.project-view__artifact-body { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + + +/* === 10. Empty-artifact-prompt (no report imported yet) ================== */ + +.empty-artifact-prompt { + background: var(--color-surface); + border: 2px dashed var(--color-border-moderate); + border-radius: var(--radius-md); + padding: var(--space-8); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + text-align: center; +} + +.empty-artifact-prompt__icon { + font-size: 48px; + opacity: 0.5; +} + +.empty-artifact-prompt__title { + font-size: var(--font-size-lg); + margin: 0; +} + +.empty-artifact-prompt__text { + color: var(--color-text-secondary); + margin: 0; + max-width: 50ch; +} + +.empty-artifact-prompt__actions { + display: flex; + gap: var(--space-2); + margin-top: var(--space-2); +} + + +/* === 11. Import-modal (overlay) ========================================== */ + +.import-modal { + position: fixed; + inset: 0; + z-index: 200; + display: none; +} + +.import-modal[data-open="true"] { + display: flex; + align-items: center; + justify-content: center; +} + +.import-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); +} + +.import-modal__panel { + position: relative; + width: min(720px, 92vw); + max-height: 90vh; + overflow: auto; + background: var(--color-surface); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; +} + +.import-modal__head { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border-subtle); +} + +.import-modal__title { + margin: 0; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); +} + +.import-modal__close { + background: transparent; + border: none; + cursor: pointer; + padding: 4px 10px; + color: var(--color-text-tertiary); + font-size: 20px; + line-height: 1; + border-radius: var(--radius-sm); +} + +.import-modal__close:hover { + background: var(--color-bg-soft); + color: var(--color-text-primary); +} + +.import-modal__form { + padding: var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.import-modal__form .field { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.import-modal__form label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); +} + +.import-modal__form select, +.import-modal__form textarea { + width: 100%; + box-sizing: border-box; + padding: var(--space-2) var(--space-3); + background: var(--color-bg); + color: var(--color-text-primary); + border: 1px solid var(--color-border-moderate); + border-radius: var(--radius-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); +} + +.import-modal__form textarea { + resize: vertical; + min-height: 180px; +} + +.import-modal__detect { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-severity-low-soft); + color: var(--color-severity-low-on); + font-size: var(--font-size-sm); +} + +.import-modal__preview { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + padding: var(--space-3); + background: var(--color-bg); + max-height: 200px; + overflow: auto; +} + +.import-modal__preview-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-tertiary); + margin-bottom: var(--space-2); +} + +.import-modal__footer { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--color-border-subtle); + background: var(--color-bg-soft); +} diff --git a/shared/playground-design-system/tokens.css b/shared/playground-design-system/tokens.css index 95ef620..1686a5c 100644 --- a/shared/playground-design-system/tokens.css +++ b/shared/playground-design-system/tokens.css @@ -142,6 +142,14 @@ --container-default: 1080px; --container-wide: 1280px; --sidebar-width: 280px; + + /* ---------- Project-view (Tier 4 — v0.6.0) --------------------------- */ + --project-view-nav-width: 280px; + --project-view-collapse-bp: 960px; /* doc-only — referenced by media queries */ + --artifact-list-item-pad-y: var(--space-2); + --artifact-list-item-pad-x: var(--space-3); + --artifact-marker-size: 14px; + --artifact-marker-border: 1.5px; } :root { color-scheme: light; }