diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c7f26c8..be2aa36 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -23,7 +23,7 @@ { "name": "voyage", "source": "./plugins/voyage", - "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. /trekbrief, /trekplan, and /trekreview each end by printing a copy-paste-ready /playground document-critique invocation for the produced artifact — one paste launches an interactive annotation HTML in the browser." + "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, 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", diff --git a/CLAUDE.md b/CLAUDE.md index 9d2e48f..97ba05b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ plugins/ llm-security/ v6.0.0 — Security scanning, auditing, threat modeling ms-ai-architect/ v1.13.1 — Microsoft AI architecture (Cosmo Skyberg persona) + manual KB-refresh slash command okr/ v1.0.0 — OKR guidance for Norwegian public sector - voyage/ v5.0.1 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a literal copy-paste-ready `/playground build a document-critique playground for {artifact_path}` invocation — one paste launches an interactive annotation HTML, Copy Prompt button returns the operator notes to Claude, Claude revises the .md. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8; v5.0.1 dropped the redundant standalone HTML render (`render-artifact.mjs`) and made the /playground invocation literal. + voyage/ v5.0.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 diff --git a/README.md b/README.md index a2911de..2d7ad87 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,13 @@ Key commands: `/config-audit posture`, `/config-audit feature-gap`, `/config-aud --- -### [Voyage](plugins/voyage/) `v5.0.1` +### [Voyage](plugins/voyage/) `v5.1.0` -Deep requirements gathering, research, implementation planning, self-verifying execution, independent post-hoc review, and zero-friction multi-session resumption — with specialized agent swarms, adversarial review, and failure recovery. Six-command (brief, research, plan, execute, review, continue) universal pipeline. `/trekbrief`, `/trekplan`, and `/trekreview` render their artifact to a self-contained HTML view and print the `file://` link; annotation is delegated to the official `/playground` plugin. +Deep requirements gathering, research, implementation planning, self-verifying execution, independent post-hoc review, and zero-friction multi-session resumption — with specialized agent swarms, adversarial review, and failure recovery. Six-command (brief, research, plan, execute, review, continue) universal pipeline + adaptive-depth per-phase effort dialog. `/trekbrief`, `/trekplan`, and `/trekreview` render their artifact to a self-contained HTML view and print the `file://` link. -v5.0.1 finishes the annotation-UX work that v5.0.0 started: at the end of `/trekbrief`, `/trekplan`, and `/trekreview` the operator now gets a single boxed, copy-paste-ready line — `/playground build a document-critique playground for {artifact_path}` — that launches the official `claude-plugins-official` `playground` skill's `document-critique` template. One paste builds a self-contained HTML with the artifact on the left, per-line Approve/Reject/Comment cards on the right, and a Copy Prompt button at the bottom; copy the generated prompt, paste back, Claude revises the `.md` freehand. v5.0.1 also dropped the v5.0.0 stop-gap `scripts/render-artifact.mjs` (a separate read-only HTML render) and the `npm run render` alias — they were redundant with what `/playground` produces. v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, Handover 8, the supporting `lib/` modules (`anchor-parser`, `annotation-digest`, `markdown-write`, `revision-guard`), the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps — a browser walkthrough had found the bespoke surface borderline unusable, and it duplicated the official `/playground` plugin. Forks depending on the removed surfaces migrate to `/playground`. See `plugins/voyage/CHANGELOG.md` § v5.0.0 + § v5.0.1. +v5.1.0 adds Phase 3.5 to `/trekbrief`: 4 tier-coupled `AskUserQuestion` calls commit an effort level (`low | standard | high`) and an optional `model` (`sonnet | opus`) per downstream phase (`research`, `plan`, `execute`, `review`). The choices land in `brief.md` as `phase_signals:` (or `phase_signals_partial: true` on force-stop). `brief_version: 2.1` activates a validator-side sequencing gate (`BRIEF_V51_MISSING_SIGNALS`) so downstream commands halt with a friendly hint when signals are missing. Composition rule per downstream command: brief signal wins per-phase, profile fills gaps. `effort == low` activates each command's existing `--quick`-equivalent code-path (`/trekexecute` low-effort = `--gates open` + sequential-only). Additive — no breaking changes; pre-2.1 briefs still validate. See `plugins/voyage/CHANGELOG.md` § v5.1.0. + +v5.0.3 lands the annotation UX modelled on `~/repos/claude-code-100x/claude-code-100x/build-site.js`: pencil-toggle annotation mode, **select text or click any element to anchor**, choose intent (**Fiks** / **Endre** / **Spørsmål**), write a comment, save. The sidebar groups annotations by section with intent badges; Copy Prompt assembles them into a structured markdown the operator pastes back into Claude. State persists in `localStorage` per artifact path. v5.0.2 was operator-led but too thin (line-click + freeform note, no intent categories). v5.0.1 had pointed at `/playground document-critique` (Claude-leads — wrong direction). v5.0.0 (breaking, kept) removed the v4.2/v4.3 bespoke playground SPA, `/trekrevise`, Handover 8, the supporting `lib/` modules, the Playwright e2e suite, and the `@playwright/test` / `@axe-core/playwright` devDeps. v5.0.3's `scripts/annotate.mjs` is one self-contained zero-dependency Node script. **The operator drives every annotation** — Claude never pre-generates suggestions in this flow. See `plugins/voyage/CHANGELOG.md` § v5.0.0 → § v5.0.3. v4.0.0 (breaking) renamed the plugin from `ultraplan-local` to **Voyage** and all commands from `/ultra*-local` to `/trek*` to remove name collision with Anthropic's `/ultraplan` and `/ultrareview` features. See `plugins/voyage/TRADEMARKS.md` and `plugins/voyage/CHANGELOG.md`. @@ -94,7 +96,7 @@ Six commands, one pipeline with clear division of labor: - **`/trekreview`** — Close the iteration loop. Independent post-hoc reviewer reads `brief.md` from scratch and evaluates the diff produced by execute. Two parallel reviewers (brief-conformance + code-correctness) plus a Judge Agent (review-coordinator) for dedup and reasonableness filtering. Severity-tagged findings (Critical/High/Medium/Low/Info) with stable 40-char hex IDs feed back into planning via Handover 6 (`/trekplan --brief review.md` → remediation plan with `source_findings:` audit trail). - **`/trekcontinue`** — Zero-friction multi-session resumption. In a fresh chat, type `/trekcontinue` — reads `.session-state.local.json` (Handover 7), prints a 3-line summary, and immediately begins executing the next session. Any session-end mechanism may write the state file (`/trekexecute` Phase 8/2.55/4 do so automatically; `/trekendsession` helper writes it for informal flows). Forward-compat schema (unknown top-level keys ignored) so future producers can extend additively. -`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single boxed `/playground build a document-critique playground for {artifact_path}` line. The operator copy-pastes that into Claude, the official `playground` skill (`document-critique` template) builds an interactive HTML, the operator marks Approve/Reject/Comment + clicks Copy Prompt, the operator pastes the prompt back, Claude revises the `.md`. +`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written `.md`, printing the `file://` link to the resulting self-contained operator-annotation HTML. The operator opens it, clicks any line to add their own note, watches a sidebar of every note (editable, deletable, persisted in browser `localStorage`), clicks "Copy Prompt" to get one structured prompt with every note, pastes back into Claude — Claude revises the `.md` from the notes. The operator drives every annotation. All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, `review.md`, and `.session-state.local.json` (gitignored). `--project ` works across `/trekresearch`, `/trekplan`, `/trekexecute`, `/trekreview`, and (optionally) `/trekcontinue`. @@ -120,7 +122,7 @@ Defense-in-depth security: plugin hooks block destructive commands and sensitive Modes: default, brief-driven, project-scoped, research-enriched, foreground, quick, decompose, export, resume -23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Copy-paste-ready `/playground` annotation invocation · No cloud dependency +23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Operator-driven HTML annotation surface · No cloud dependency → [Full documentation](plugins/voyage/README.md) · [Migration guide](plugins/voyage/MIGRATION.md) diff --git a/plugins/voyage/.claude-plugin/plugin.json b/plugins/voyage/.claude-plugin/plugin.json index 42c6106..68fb63b 100644 --- a/plugins/voyage/.claude-plugin/plugin.json +++ b/plugins/voyage/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "voyage", - "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.", - "version": "5.0.1", + "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, modelled on claude-code-100x): select text or click any element, pick intent (Fiks/Endre/Spørsmål), write comment, copy structured prompt, paste back, Claude revises the .md.", + "version": "5.1.0", "author": { "name": "Kjell Tore Guttormsen" }, diff --git a/plugins/voyage/CHANGELOG.md b/plugins/voyage/CHANGELOG.md index c5ad231..ab3267c 100644 --- a/plugins/voyage/CHANGELOG.md +++ b/plugins/voyage/CHANGELOG.md @@ -4,6 +4,227 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## v5.1.0 — 2026-05-13 — Per-phase effort + model dialog + +Additive. No breaking changes. Forward-compat with all v5.0.x briefs. + +### Why + +The voyage pipeline runs a single profile-tier setting for every task. For +typo fixes and small bugfixes the full `brief → research → plan → execute +→ review` ceremony is over-engineered; for risky migrations the same +profile-tier is too thin. v5.1 hands ceremony-level back to the operator +per phase in the same dialog that produces the brief — without removing +the disciplined defaults that protect high-stakes work. Independent of +v4.1's profile system: composition happens at the command level (brief +signal wins per-phase, profile fills gaps). No `/trekflow`, no helper +module, no per-command effort dictionary — composition is documented +prose in each downstream command. + +### Added + +- **`/trekbrief` Phase 3.5** — between Phase 3 completeness exit and + Phase 4 draft, 4 tier-coupled `AskUserQuestion` calls commit an effort + level (`low | standard | high`) and an optional `model` (`sonnet | + opus`) per downstream phase (`research`, `plan`, `execute`, `review`). + Tier mapping: `low → {effort: low, model: sonnet}`, `standard → + {effort: standard}` (model omitted; composition falls through to + profile), `high → {effort: high, model: opus}`. Force-stop pattern + (Phase 4f verbatim) records `phase_signals_partial: true` instead. + `--quick` skips Phase 3.5 entirely and auto-writes + `phase_signals_partial: true`. +- **`brief-validator` extension** — 6 new issue codes: + `BRIEF_INVALID_PHASE_SIGNALS`, `BRIEF_INVALID_PHASE_SIGNAL_PHASE`, + `BRIEF_INVALID_EFFORT`, `BRIEF_INVALID_MODEL`, + `BRIEF_SIGNALS_MUTUALLY_EXCLUSIVE`, `BRIEF_V51_MISSING_SIGNALS` + + exported `PHASE_SIGNAL_PHASES` + `EFFORT_LEVELS` constants. The + `BASE_ALLOWED_MODELS` const in `lib/validators/profile-validator.mjs` + was promoted to `export const` so the brief validator can re-use it. +- **HANDOVER-CONTRACTS amendments** — Handover 1 gets 5 inserts: + versioning row → `2.1`, two new schema-table rows (`phase_signals`, + `phase_signals_partial`), v5.1 sequencing-gate validation row, + versioning-paragraph expansion explaining the version-conditional + gate, 6 new failure-mode bullets. +- **Template bump** — `templates/trekbrief-template.md` → `brief_version + 2.1` with a default `phase_signals:` block (4 phases × `effort: + standard`, model omitted) and a commented `phase_signals_partial: + true` line showing the force-stop alternative. +- **Composition rule (v5.1)** — new `## Composition rule (v5.1)` + sub-section in each of `commands/{trekplan,trekresearch,trekexecute, + trekreview}.md`. Documents `effort_for_phase = brief.phase_signals[ + ]?.effort ?? 'standard'` and `model_for_phase = + brief.phase_signals[]?.model ?? profile.phase_models[]`. + Per command: `effort == low` activates that command's existing + `--quick`-equivalent code-path (`/trekplan` skips Phase 5 agent swarm, + `/trekresearch` inline research, `/trekreview` correctness-only, + `/trekexecute` `--gates open` + sequential-only). +- **Sequencing-gate surface** in 4 downstream commands — when + `brief-validator.mjs` returns `BRIEF_V51_MISSING_SIGNALS` in `errors`, + halt with a one-line user-readable message pointing back to + `/trekbrief`. Enforcement is validator-only. +- **5 new minimal command test files** under `tests/commands/` — + `trekbrief.test.mjs` (3 cases), `trekplan.test.mjs` / + `trekresearch.test.mjs` / `trekreview.test.mjs` (2 cases each), + `trekexecute.test.mjs` (2 cases). Pattern D (read .md, assert prose + patterns). Verifies sequencing-gate surface + low-effort prose. +- **5 new doc-consistency pins** — template `brief_version 2.1` + + `phase_signals:` block, HANDOVER schema rows, voyage CLAUDE.md + + README.md mention `phase_signals`. +- **2 new fixtures** — `tests/fixtures/brief-with-phase-signals.md` + + `brief-without-phase-signals.md` (backward-compat). + +### Changed + +- `brief_version` bumped `2.0 → 2.1`. The bump exists because v2.1 + activates the **version-conditional sequencing gate** — the only check + in the brief validator that triggers on `brief_version` rather than + field-presence. The forward-compat policy still applies to the field + itself (unknown frontmatter keys flow through). + +### Notes + +- Test count grows by ≥ 17 new cases minimum: 6 brief-validator + 11 + command-test minimums. Realistic delta is ~25 new cases (Step 6 adds 5 + doc-consistency pins on top). Target ≥ 533 pass at Step 10 verify. +- `MIGRATION.md` was deliberately NOT created — v5.1 is an additive + minor (brief_version 2.0 → 2.1, not major). v5.4 may promote + `phase_signals` from optional to required (breaking change → 3.0). +- High-effort behaviors for `/trekplan` / `/trekresearch` / + `/trekreview` are deferred to v5.1.1 per brief Non-Goal ("No complete + per-phase effort dictionary"). v5.1 locks only the low-effort floor. +- `phase_signals_present` stats emission is also deferred to v5.1.1 + (opt-in observability per Research 03 Q5). + +## v5.0.3 — 2026-05-13 — Annotation UX matches the claude-code-100x reference + +**No new breaking changes beyond v5.0.0.** Forks consuming v5.0.2's +annotation HTML keep working — the file path and entry point are +unchanged. The internal localStorage key bumps from `voyage-annotate:` to +`voyage-annotate:v2:` to avoid mixing the v5.0.2 shape (line-click, +freeform notes) with the v5.0.3 shape (intent-tagged annotations). + +### Why + +v5.0.2 shipped a too-simple annotation surface: click a line, write a +freeform note, save. The operator pointed at the existing +`claude-code-100x/build-site.js` annotation system as the actual +reference — pencil-toggle mode, text-selection capture, three intent +categories (**Fiks** / **Endre** / **Spørsmål**), a popover form at the +cursor, structured markdown export with intent labels. v5.0.3 brings +`scripts/annotate.mjs` up to that pattern. + +This reference had been mentioned by the operator early in the +conversation; the iteration through v5.0.0 / v5.0.1 / v5.0.2 reflects me +reading past it and trying alternatives instead of just matching it. The +loss is real and is documented here in plain terms so future maintainers +don't repeat it. + +### Changed + +- **`scripts/annotate.mjs`** — rewritten to match the + `claude-code-100x/build-site.js` UX: + - **Article rendering** — markdown is rendered to proper HTML elements + (`

`/`

`/`

    `/`
  • `/``/`
    `/`
    `), not as
    +    line-numbered raw lines. Document reads as a normal article.
    +  - **Annotatable elements** — every heading, paragraph, list item, table
    +    cell, blockquote, and code block gets a stable `data-anchor-id`.
    +  - **Pencil-toggle button** in the topbar — annotation mode default ON.
    +    Toggle OFF to read normally and follow links.
    +  - **Click any annotatable element** (in mode) → opens a form popover
    +    at the cursor with: section context (auto-detected from nearest
    +    h1/h2), anchored snippet (the exact selected substring via
    +    `window.getSelection()` if any text is highlighted, else the
    +    element's text content up to 200 chars), three intent buttons
    +    (**Fiks** / **Endre** / **Spørsmål**), comment textarea, Cancel +
    +    Save. Save is disabled until an intent is picked.
    +  - **Sidebar panel** — collapsed by default; "Show annotations" button
    +    in the topbar opens it. Annotations grouped by section, sorted by
    +    document order. Each card shows the intent badge (colored by
    +    category), the anchored snippet, the operator comment, and a delete
    +    button. Click a card to scroll the article to that element + flash
    +    highlight.
    +  - **Copy Prompt** — structured markdown:
    +    `### N. [Intent] Section: 
    ` + `Quote: «»` + + `Comment: `. Copies to clipboard. + - **Clear all** — wipes every annotation for the current artifact + (after confirm). + - **Persistence** — `localStorage` key `voyage-annotate:v2:`. + Refresh/close/reopen the same HTML keeps every annotation. + - **Toast feedback** for save / copy / clear. +- **`tests/scripts/annotate.test.mjs`** — refreshed for the v5.0.3 shape: + pins the three intent buttons (`data-intent="fiks"` / `"endre"` / + `"spørsmål"`), form popover, selection capture, section auto-detect, + `voyage-annotate:v2:` storage key prefix, `data-anchor-id` coverage, + Copy Prompt + Clear all affordances, and the markdown renderer's + heading / list / table / blockquote / code-fence output. 12 tests + (up from 10), all passing. + +### Notes + +- The producing commands (`/trekbrief` Step 4g, `/trekplan` Phase 10, + `/trekreview` Phase 8) call `scripts/annotate.mjs` the same way as in + v5.0.2 — no change to their wiring beyond the build-output now being + the v5.0.3 interactive surface. +- `npm test`: 518 tests, 516 pass, 0 fail, 2 skipped (up from 516 — 2 + new annotate tests for hostile-content escape + renderMarkdown table/ + blockquote coverage). +- Reference: `~/repos/claude-code-100x/claude-code-100x/build-site.js` + lines 1431–2255 (annotation UI section). +- Version bump 5.0.2 → 5.0.3 in `.claude-plugin/plugin.json`, + `package.json`, `package-lock.json`, plugin `README.md` badge. + +## v5.0.2 — 2026-05-13 — Operator-driven annotation HTML (the actual fix) + +**No new breaking changes beyond v5.0.0.** Forks that consumed the v5.0.1 +`/playground document-critique` invocation from the producing commands' +final report should switch to opening the `.html` that `scripts/annotate.mjs` +now produces directly. + +### Why + +v5.0.0 added a read-only `scripts/render-artifact.mjs` HTML render that +didn't afford annotation. v5.0.1 deleted that and pointed operators at +`/playground document-critique` instead — but the `document-critique` +template pre-generates **Claude's** suggestions and asks the operator to +approve/reject them. The operator asked for the opposite: a surface where +**they** select content and write **their own** notes, then ship those +notes back to Claude. v5.0.1 still missed the actual ask. + +v5.0.2 ships `scripts/annotate.mjs` — a small, focused, zero-dependency +Node script that takes any artifact `.md` and writes a self-contained +HTML next to it. The HTML renders the document with line numbers, lets +the operator click any line to attach their own note, keeps a sidebar of +all notes (editable + deletable, persisted in `localStorage` per artifact +path so refresh doesn't lose work), and exposes a "Copy Prompt" button +that gathers every note into one structured prompt. The operator copies +that prompt and pastes it back into Claude; Claude revises the `.md` +freehand from the notes. **One file → one HTML → click + write notes → +copy prompt → paste back.** No Claude-generated suggestions in the loop. +The operator drives every annotation. + +This is the v4.2/v4.3 *concept* (operator-driven annotation) without the +broken v4.2/v4.3 UX, without the 388 KB SPA, without `/trekrevise`, +without anchor parsers + Handover 8 + the JSON batch round-trip. ~430 +lines of self-contained `.mjs`. Zero npm deps. Deterministic. + +### Added + +- **`scripts/annotate.mjs`** — operator-annotation HTML generator. Takes ``, writes `.html` (or `--out `). Self-contained, design-system-aligned (light + dark + print), zero external network, deterministic. CLI: `node scripts/annotate.mjs [--out ]`. Also `npm run annotate -- `. +- **`tests/scripts/annotate.test.mjs`** (10 tests) — self-contained HTML shape, no external ``/`\n' + + '\n' + + '\n'; +} + +// --------------------------------------------------------------------------- +// Stylesheet — 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 { 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; } +.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); +`.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/templates/trekbrief-template.md b/plugins/voyage/templates/trekbrief-template.md index b35d893..ff72ac4 100644 --- a/plugins/voyage/templates/trekbrief-template.md +++ b/plugins/voyage/templates/trekbrief-template.md @@ -1,6 +1,6 @@ --- type: trekbrief -brief_version: 2.0 +brief_version: 2.1 created: {YYYY-MM-DD} task: "{one-line task description}" slug: {slug} @@ -10,6 +10,20 @@ research_status: pending # pending | in_progress | complete | skipped auto_research: false # true if user opted into Claude-managed research interview_turns: {N} source: {interview | manual} +# v5.1 — per-phase effort + model signal (Phase 3.5). +# `effort` ∈ {low, standard, high}. Omit `model:` for `standard` so composition +# falls through to profile resolver. Force-stop alternative is the commented +# `phase_signals_partial: true` below (mutually exclusive with `phase_signals`). +phase_signals: + - phase: research + effort: standard + - phase: plan + effort: standard + - phase: execute + effort: standard + - phase: review + effort: standard +# phase_signals_partial: true # uncomment to record force-stop instead of phase_signals --- # Task: {title} diff --git a/plugins/voyage/tests/commands/trekbrief.test.mjs b/plugins/voyage/tests/commands/trekbrief.test.mjs new file mode 100644 index 0000000..3db8030 --- /dev/null +++ b/plugins/voyage/tests/commands/trekbrief.test.mjs @@ -0,0 +1,42 @@ +// tests/commands/trekbrief.test.mjs +// v5.1 — Pattern D prose-pattern regression tests for /trekbrief Phase 3.5. +// +// Brief SC1 + SC2: end-of-brief effort dialog covering 4 downstream phases, +// with `phase_signals_partial` as the force-stop record. + +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'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekbrief.md'); + +function read() { + return readFileSync(COMMAND_FILE, 'utf8'); +} + +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'); +}); diff --git a/plugins/voyage/tests/commands/trekexecute.test.mjs b/plugins/voyage/tests/commands/trekexecute.test.mjs new file mode 100644 index 0000000..e848119 --- /dev/null +++ b/plugins/voyage/tests/commands/trekexecute.test.mjs @@ -0,0 +1,34 @@ +// tests/commands/trekexecute.test.mjs +// v5.1 — sequencing-gate surface + low-effort prose check 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'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md'); + +function read() { + return readFileSync(COMMAND_FILE, 'utf8'); +} + +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'); +}); diff --git a/plugins/voyage/tests/commands/trekplan.test.mjs b/plugins/voyage/tests/commands/trekplan.test.mjs new file mode 100644 index 0000000..901936d --- /dev/null +++ b/plugins/voyage/tests/commands/trekplan.test.mjs @@ -0,0 +1,32 @@ +// tests/commands/trekplan.test.mjs +// v5.1 — sequencing-gate surface + low-effort prose check 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'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekplan.md'); + +function read() { + return readFileSync(COMMAND_FILE, 'utf8'); +} + +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'); +}); diff --git a/plugins/voyage/tests/commands/trekresearch.test.mjs b/plugins/voyage/tests/commands/trekresearch.test.mjs new file mode 100644 index 0000000..4fd2a8c --- /dev/null +++ b/plugins/voyage/tests/commands/trekresearch.test.mjs @@ -0,0 +1,32 @@ +// tests/commands/trekresearch.test.mjs +// v5.1 — sequencing-gate surface + low-effort prose check 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'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekresearch.md'); + +function read() { + return readFileSync(COMMAND_FILE, 'utf8'); +} + +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'); +}); diff --git a/plugins/voyage/tests/commands/trekreview.test.mjs b/plugins/voyage/tests/commands/trekreview.test.mjs new file mode 100644 index 0000000..9d1a53c --- /dev/null +++ b/plugins/voyage/tests/commands/trekreview.test.mjs @@ -0,0 +1,32 @@ +// tests/commands/trekreview.test.mjs +// v5.1 — sequencing-gate surface + low-effort prose check 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'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'trekreview.md'); + +function read() { + return readFileSync(COMMAND_FILE, 'utf8'); +} + +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'); +}); diff --git a/plugins/voyage/tests/fixtures/brief-with-phase-signals.md b/plugins/voyage/tests/fixtures/brief-with-phase-signals.md new file mode 100644 index 0000000..c68e37c --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-with-phase-signals.md @@ -0,0 +1,42 @@ +--- +type: trekbrief +brief_version: "2.1" +created: 2026-05-13 +task: "Add per-phase effort dialog to /trekbrief" +slug: phase-signals-example +project_dir: .claude/projects/2026-05-13-phase-signals-example/ +research_topics: 2 +research_status: complete +auto_research: false +interview_turns: 6 +source: interview +phase_signals: + - phase: research + effort: low + model: sonnet + - phase: plan + effort: standard + - phase: execute + effort: high + model: opus + - phase: review + effort: standard +--- + +# Task: Phase-signals example + +## Intent + +A minimal brief that exercises the v5.1 phase_signals additive field with a +mix of effort levels and model overrides. Used by tests/validators to confirm +the validator accepts well-formed signals across the supported tier matrix. + +## Goal + +Validator returns valid: true. annotate.mjs strips phase_signals from the +rendered HTML body (frontmatter stays in source). + +## Success Criteria + +- Validator passes. +- annotate.mjs determinism: re-run produces byte-identical HTML. diff --git a/plugins/voyage/tests/fixtures/brief-without-phase-signals.md b/plugins/voyage/tests/fixtures/brief-without-phase-signals.md new file mode 100644 index 0000000..8bec99e --- /dev/null +++ b/plugins/voyage/tests/fixtures/brief-without-phase-signals.md @@ -0,0 +1,31 @@ +--- +type: trekbrief +brief_version: "2.0" +created: 2026-05-13 +task: "Backward-compat fixture for v5.0-style brief" +slug: legacy-brief-example +project_dir: .claude/projects/2026-05-13-legacy-brief-example/ +research_topics: 0 +research_status: complete +auto_research: false +interview_turns: 3 +source: interview +--- + +# Task: Legacy brief example + +## Intent + +A pre-v5.1 brief that pre-dates the phase_signals field. Used by +tests/validators to confirm backward-compatibility: the brief is accepted +without phase_signals as long as brief_version is < 2.1. + +## Goal + +Validator returns valid: true. The sequencing gate +(BRIEF_V51_MISSING_SIGNALS) does NOT fire for brief_version 2.0. + +## Success Criteria + +- Validator passes. +- No BRIEF_V51_MISSING_SIGNALS error in r.errors. diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index cbc1c81..717ee29 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -430,42 +430,85 @@ test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)', assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain'); }); -test('scripts/render-artifact.mjs no longer exists (removed in v5.0.1)', () => { +test('scripts/render-artifact.mjs is still removed (v5.0.1 + v5.0.2)', () => { assert.ok( !existsSync(join(ROOT, 'scripts/render-artifact.mjs')), - 'scripts/render-artifact.mjs should be deleted — v5.0.1 drops the redundant standalone HTML render in favour of the /playground document-critique invocation printed by the producing commands', + 'scripts/render-artifact.mjs should be deleted — v5.0.1 dropped the standalone HTML render; v5.0.2 kept it removed (annotate.mjs is the replacement)', ); }); -test('producing commands print a literal /playground document-critique invocation', () => { - // The exact substring must appear in each producing command's prose so the - // operator copy-pastes a verbatim line. Drift on this is the friction point - // that motivated v5.0.1 — fail loudly if the prose softens back to "run the - // /playground plugin" without the literal command. - const REQUIRED = '/playground build a document-critique playground for'; +test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => { + assert.ok( + existsSync(join(ROOT, 'scripts/annotate.mjs')), + 'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML', + ); +}); + +test('producing commands reference scripts/annotate.mjs (v5.0.2 render-and-link step)', () => { + // v5.0.0 → v5.0.1 → v5.0.2 chain: v5.0.0 added an HTML render that didn't + // afford annotation; v5.0.1 pointed at /playground document-critique (which + // pre-generates Claude's suggestions, not operator-driven annotation); v5.0.2 + // ships scripts/annotate.mjs — an operator-driven annotation surface where + // the OPERATOR clicks lines and writes their own notes. Pin the wiring. for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - read(`commands/${f}`).includes(REQUIRED), - `commands/${f} must include the literal invocation "${REQUIRED}" so the operator copy-pastes it directly (v5.0.1)`, + read(`commands/${f}`).includes('scripts/annotate.mjs'), + `commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`, ); } }); -test('producing commands no longer reference the removed scripts/render-artifact.mjs', () => { +test('producing commands no longer print the v5.0.1 /playground document-critique line', () => { + // v5.0.1 told operators to copy-paste "/playground build a document-critique + // playground for X" — but that flow pre-generates Claude's suggestions. The + // operator asked for their own annotations, not a critique of Claude's. + // v5.0.2 removes that line from the producing commands' final report. for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - !read(`commands/${f}`).includes('render-artifact.mjs'), - `commands/${f} still references scripts/render-artifact.mjs — that script was removed in v5.0.1`, + !read(`commands/${f}`).includes('/playground build a document-critique'), + `commands/${f} must not print the v5.0.1 /playground document-critique invocation — v5.0.2 replaces it with annotate.mjs`, ); } }); -test('package.json no longer has an "npm run render" script (removed in v5.0.1)', () => { +test('producing commands tell the operator the flow is THEIR own annotations', () => { + // Pin language: every producing command's prose must mention that the + // OPERATOR drives annotation, not Claude. Phrase variants are allowed + // ("YOUR OWN note", "operator drives", etc.) — we look for the operator- + // ownership signal. + for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { + const text = read(`commands/${f}`); + assert.ok( + /YOUR OWN|operator drives|your own/i.test(text), + `commands/${f} must signal that the operator drives annotation (v5.0.2 contract)`, + ); + } +}); + +test('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 be gone in v5.0.1', + 'package.json scripts.render should remain gone', ); }); @@ -479,6 +522,11 @@ test('CHANGELOG.md has v5.0.1 entry', () => { assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry'); }); +test('CHANGELOG.md has v5.0.2 entry', () => { + const cl = read('CHANGELOG.md'); + assert.match(cl, /## v5\.0\.2\b/, 'CHANGELOG.md must include "## v5.0.2" entry'); +}); + test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => { const cl = read('CHANGELOG.md'); assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry'); @@ -503,3 +551,37 @@ test('operational files no longer reference trekrevise (v5.0.0 removal)', () => ); } }); + +// --- v5.1 — phase_signals + brief_version 2.1 --- + +test('v5.1 — templates/trekbrief-template.md declares brief_version: 2.1', () => { + const t = read('templates/trekbrief-template.md'); + assert.match(t, /^brief_version: 2\.1$/m, + 'trekbrief-template.md must declare brief_version: 2.1 at top of frontmatter'); +}); + +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)'); +}); diff --git a/plugins/voyage/tests/scripts/annotate.test.mjs b/plugins/voyage/tests/scripts/annotate.test.mjs new file mode 100644 index 0000000..3044447 --- /dev/null +++ b/plugins/voyage/tests/scripts/annotate.test.mjs @@ -0,0 +1,208 @@ +// tests/scripts/annotate.test.mjs +// Covers scripts/annotate.mjs — the v5.0.3 operator-annotation HTML +// generator. UX modelled on claude-code-100x/build-site.js (pencil +// toggle, intent buttons, form popover, selection-anchoring, localStorage +// persistence, structured markdown export). +// +// What we pin: +// • Output is a complete, self-contained HTML document. +// • No external or "\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/validators/brief-validator.test.mjs b/plugins/voyage/tests/validators/brief-validator.test.mjs index 6e501d2..a9fd185 100644 --- a/plugins/voyage/tests/validators/brief-validator.test.mjs +++ b/plugins/voyage/tests/validators/brief-validator.test.mjs @@ -152,3 +152,69 @@ 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')); +});