diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index be2aa36..d87c54d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -23,7 +23,7 @@ { "name": "voyage", "source": "./plugins/voyage", - "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline with specialized agent swarms, external research triangulation, adversarial review, post-hoc independent review with Handover 6 feedback loop, multi-session resumption, session decomposition, and headless execution. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs, 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." + "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. Renders produced artifacts to self-contained HTML + link; annotation via the official /playground plugin." }, { "name": "linkedin-thought-leadership", diff --git a/CLAUDE.md b/CLAUDE.md index 97ba05b..d79deb4 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.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. + voyage/ v5.0.0 — Brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline (six-command universal pipeline + multi-session resumption + --gates autonomy chain). Renders produced artifacts to self-contained HTML + link; annotation via the official /playground plugin. v5.0.0 removed the v4.2/v4.3 bespoke playground + /trekrevise + Handover 8 (NIH; duplicated /playground's document-critique). 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 2d7ad87..9438d86 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,11 @@ Key commands: `/config-audit posture`, `/config-audit feature-gap`, `/config-aud --- -### [Voyage](plugins/voyage/) `v5.1.0` +### [Voyage](plugins/voyage/) `v5.0.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 + adaptive-depth per-phase effort dialog. `/trekbrief`, `/trekplan`, and `/trekreview` render their artifact to a self-contained HTML view and print the `file://` link. +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. -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. +v5.0.0 (breaking) **removes the bespoke playground.** v4.2/v4.3 shipped a ~388 KB bespoke playground SPA + `/trekrevise` + Handover 8 (annotation → revision); a browser walkthrough found it borderline unusable and it duplicated the official `/playground` plugin's `document-critique` / `diff-review` templates. The SPA, the `/trekrevise` command, 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 are all deleted. In their place: a small, zero-dependency `scripts/render-artifact.mjs` that renders any brief/plan/review `.md` to a self-contained, design-system-styled, zero-network `.html` (frontmatter folded into a `
` block). The producing commands call it on their last step and print the link; to annotate, run `/playground` (`document-critique`) on the `.md` and paste the generated prompt back — Claude revises the artifact freehand. Forks depending on the removed surfaces migrate to the `/playground` plugin. See `plugins/voyage/CHANGELOG.md` § v5.0.0. 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`. @@ -96,9 +94,9 @@ 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 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. +`/trekbrief`, `/trekplan`, and `/trekreview` each finish by rendering their `.md` artifact to a self-contained `.html` next to it (`scripts/render-artifact.mjs` — zero deps, zero network) and printing the `file://` link. To annotate, run the official `/playground` plugin (`document-critique`) on the `.md` and paste its generated prompt back into the conversation. -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`. +All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md` (+ `brief.html`), `research/NN-*.md`, `plan.md` (+ `plan.html`), `sessions/`, `progress.json`, `review.md` (+ `review.html`), and `.session-state.local.json` (gitignored). `--project ` works across `/trekresearch`, `/trekplan`, `/trekexecute`, `/trekreview`, and (optionally) `/trekcontinue`. v3.4.0 (non-breaking) adds the **autonomy chain from brief approval to main-merge** plus parallel-wave hardenings. New `lib/util/autonomy-gate.mjs` state machine (`idle → approved → executing → merge-pending → main-merged`), `lib/review/plan-review-dedup.mjs` for Phase 9 inline dedup, `lib/stats/event-emit.mjs` for autonomy-gate transitions and main-merge gate, and `--gates {open|closed|adaptive}` flag on all four pipeline commands. `commands/trekplan.md` Phase 8 seals Opus-4.7 plan/list-emission schema-drift via `plan-validator --strict`. `commands/trekexecute.md` Phase 2.6 wave-executor adds 11 hardenings for plugin-in-monorepo + gitignored-state topology (GIT_OPTIONAL_LOCKS, --max-turns, --max-budget-usd, scoped --allowedTools, push-before-cleanup ordering). New `hooks/scripts/post-compact-flush.mjs` PostCompact hook re-injects session-state after compaction. SC7 synthetic determinism floor (Jaccard ≥ 0.833) for plan + review fixtures. Hook baseline regression pins. Architecture decision: Path B (sequential `--no-ff` parallel waves with manifest-driven failure recovery) ships; Path C (cache-first hybrid) deferred to v3.5.0 contingent on cache-telemetry harvest. @@ -122,7 +120,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 · Operator-driven HTML annotation surface · No cloud dependency +23 specialized agents · 6 commands (+ 1 helper) · 5 plugin hooks · 500+ tests · Self-contained HTML artifact rendering · 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 68fb63b..c9bfcf3 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. /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", + "description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline; renders produced artifacts to HTML + link, annotate via the /playground plugin.", + "version": "5.0.0", "author": { "name": "Kjell Tore Guttormsen" }, diff --git a/plugins/voyage/CHANGELOG.md b/plugins/voyage/CHANGELOG.md index ab3267c..275a531 100644 --- a/plugins/voyage/CHANGELOG.md +++ b/plugins/voyage/CHANGELOG.md @@ -4,273 +4,6 @@ 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/scripts/render-artifact.mjs b/plugins/voyage/scripts/render-artifact.mjs new file mode 100644 index 0000000..09b9287 --- /dev/null +++ b/plugins/voyage/scripts/render-artifact.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node +// scripts/render-artifact.mjs +// +// Renders a voyage artifact (brief.md / plan.md / review.md) to a +// self-contained HTML file in the same directory, with inlined CSS and +// zero external network references. The producing commands (/trekbrief, +// /trekplan, /trekreview) call this at the end and print the file:// link +// so the operator can read the artifact in a browser — and, when they want +// to annotate it, run the official `/playground` plugin (document-critique +// template) on it and paste the generated prompt back into Claude Code. +// +// Usage: +// node scripts/render-artifact.mjs [--out ] +// +// Determinism: no timestamps, no random IDs — two runs on the same input +// produce byte-identical output. +// +// Zero npm deps (marketplace convention). The markdown→HTML conversion is a +// small hand-rolled subset that covers what the artifact templates emit: +// ATX headings, ordered/unordered/nested lists, fenced code blocks, inline +// code, bold, links, blockquotes, GitHub-style tables, and horizontal rules. + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { basename } from 'node:path'; +import { splitFrontmatter } from '../lib/util/frontmatter.mjs'; + +// --------------------------------------------------------------------------- + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// Inline spans, applied to already-HTML-escaped text. Order matters: code +// spans first (so their contents aren't re-processed), then links, bold, em. +function renderInline(escaped) { + let out = escaped.replace(/`([^`]+)`/g, (_, c) => `${c}`); + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, href) => { + const safe = /^(https?:|mailto:|#|\.|\/)/i.test(href) ? href : '#'; + return `${text}`; + }); + out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); + out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, (_, pre, c) => `${pre}${c}`); + return out; +} + +function renderTable(rows) { + const cells = (line) => line.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((c) => c.trim()); + const header = cells(rows[0]); + const body = rows.slice(2).map(cells); + let html = '
    \n'; + for (const h of header) html += ``; + html += '\n\n'; + for (const r of body) { + html += ''; + for (let i = 0; i < header.length; i++) html += ``; + html += '\n'; + } + return html + '\n
    ${renderInline(escapeHtml(h))}
    ${renderInline(escapeHtml(r[i] || ''))}
    \n'; +} + +// Build nested
      /
        from a run of list lines (2-space indent = 1 level). +function renderList(items) { + let html = ''; + const stack = []; // { indent, ordered } + 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 ? '
      ' : '
        '; + stack.push({ indent, ordered }); + } else { + html += ''; + } + html += `
      • ${renderInline(escapeHtml(text))}`; + } + while (stack.length) { + const top = stack.pop(); + html += top.ordered ? '
    ' : '
'; + } + return html + '\n'; +} + +function renderMarkdown(md) { + const lines = md.replace(/\r\n/g, '\n').split('\n'); + let html = ''; + let i = 0; + let para = []; + + const flushPara = () => { + if (para.length) { + html += `

${renderInline(escapeHtml(para.join(' ')))}

\n`; + para = []; + } + }; + + while (i < lines.length) { + const line = lines[i]; + + // Fenced code block + const fence = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fence) { + flushPara(); + const marker = fence[2]; + const lang = (fence[3] || '').trim().split(/\s+/)[0]; + const buf = []; + i++; + while (i < lines.length && !lines[i].match(new RegExp('^\\s*' + marker[0] + '{3,}\\s*$'))) { + buf.push(lines[i]); + i++; + } + i++; // consume 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 (header row + separator row) + 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++; } + // include the separator that was matched as part of rows already + html += renderTable(rows); + 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 (consume a contiguous block, allowing 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++; // blank line inside the list + } else { + break; + } + } + html += renderList(items); + continue; + } + + // Blank line — paragraph break + if (line.trim() === '') { + flushPara(); + i++; + continue; + } + + // Default — accumulate into paragraph + para.push(line.trim()); + i++; + } + flushPara(); + return html; +} + +// --------------------------------------------------------------------------- + +const STYLE = ` +:root { color-scheme: light; } +* { box-sizing: border-box; } +body { + margin: 0; padding: 2.5rem 1.25rem 4rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; line-height: 1.6; color: #1a1a1a; background: #f7f7f8; +} +main { max-width: 56rem; margin: 0 auto; background: #fff; border: 1px solid #e2e2e6; + border-radius: 12px; padding: 2.5rem 3rem; box-shadow: 0 1px 3px rgba(0,0,0,.06); } +h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.8em 0 .6em; font-weight: 650; } +h1 { font-size: 2rem; margin-top: 0; } +h2 { font-size: 1.5rem; border-bottom: 1px solid #ececef; padding-bottom: .3em; } +h3 { font-size: 1.2rem; } +h4 { font-size: 1.05rem; } +p { margin: .8em 0; } +a { color: #0855a8; text-decoration: underline; text-underline-offset: 2px; } +a:hover { color: #06408a; } +code { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + font-size: .9em; background: #f0f0f3; padding: .12em .35em; border-radius: 4px; } +pre { background: #1e1e24; color: #e6e6eb; padding: 1rem 1.25rem; border-radius: 8px; + overflow-x: auto; font-size: .85rem; line-height: 1.5; } +pre code { background: none; padding: 0; color: inherit; font-size: inherit; } +blockquote { margin: 1em 0; padding: .4em 1.2em; border-left: 4px solid #0855a8; + background: #f0f5fb; color: #34495e; border-radius: 0 6px 6px 0; } +ul, ol { padding-left: 1.6em; margin: .8em 0; } +li { margin: .25em 0; } +table { border-collapse: collapse; width: 100%; margin: 1.2em 0; font-size: .92rem; } +th, td { border: 1px solid #e2e2e6; padding: .5em .75em; text-align: left; vertical-align: top; } +th { background: #f0f0f3; font-weight: 600; } +tr:nth-child(even) td { background: #fafafb; } +hr { border: none; border-top: 1px solid #e2e2e6; margin: 2em 0; } +details.frontmatter { margin: 0 0 2rem; border: 1px solid #e2e2e6; border-radius: 8px; + background: #fafafb; padding: .6em 1em; } +details.frontmatter > summary { cursor: pointer; font-weight: 600; font-size: .9rem; color: #555; } +details.frontmatter pre { margin: .8em 0 .2em; background: #f4f4f6; color: #333; } +.artifact-meta { color: #888; font-size: .82rem; margin: 0 0 1.5rem; } +@media (prefers-color-scheme: dark) { + :root { color-scheme: dark; } + body { color: #e6e6eb; background: #18181b; } + main { background: #1f1f23; border-color: #2e2e34; box-shadow: none; } + h2 { border-bottom-color: #2e2e34; } + a { color: #6db0ee; } a:hover { color: #93c5fd; } + code { background: #2a2a30; } + blockquote { background: #1a242f; color: #b6c5d4; border-left-color: #6db0ee; } + th, td { border-color: #2e2e34; } th { background: #26262c; } + tr:nth-child(even) td { background: #222226; } + hr { border-top-color: #2e2e34; } + details.frontmatter { background: #222226; border-color: #2e2e34; } + details.frontmatter > summary { color: #aaa; } + details.frontmatter pre { background: #1a1a1d; color: #ccc; } + .artifact-meta { color: #777; } +} +@media print { body { background: #fff; padding: 0; } main { border: none; box-shadow: none; max-width: none; } } +`.trim(); + +function buildHtml(mdPath, mdText) { + const { hasFrontmatter, frontmatter, body } = splitFrontmatter(mdText); + const fm = hasFrontmatter ? frontmatter : ''; + const fmLine = (key) => { + const m = fm.match(new RegExp('^' + key + ':\\s*(.+)$', 'm')); + return m ? m[1].trim().replace(/^["']|["']$/g, '') : null; + }; + const title = fmLine('task') || fmLine('slug') || (body.match(/^#\s+(.+)$/m) || [])[1] || basename(mdPath); + const kind = fmLine('type') || basename(mdPath).replace(/\.md$/, ''); + + const fmBlock = hasFrontmatter + ? `
Frontmatter
${escapeHtml(fm)}\n
\n` + : ''; + + const bodyHtml = renderMarkdown(body); + + return '\n' + + '\n\n\n' + + '\n' + + `${escapeHtml(String(title))}\n` + + `\n\n\n
\n` + + `

voyage artifact — ${escapeHtml(String(kind))}

\n` + + fmBlock + + bodyHtml + + '
\n\n\n'; +} + +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'); + const html = buildHtml(inputPath, text); + const out = outputPath || inputPath.replace(/\.md$/, '.html'); + writeFileSync(out, html); + return out; +} + +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; +} + +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' + + 'Renders a voyage artifact to a self-contained HTML file (zero network).\n' + + 'Default output: .html next to the 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 ff72ac4..b35d893 100644 --- a/plugins/voyage/templates/trekbrief-template.md +++ b/plugins/voyage/templates/trekbrief-template.md @@ -1,6 +1,6 @@ --- type: trekbrief -brief_version: 2.1 +brief_version: 2.0 created: {YYYY-MM-DD} task: "{one-line task description}" slug: {slug} @@ -10,20 +10,6 @@ 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 deleted file mode 100644 index 3db8030..0000000 --- a/plugins/voyage/tests/commands/trekbrief.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// 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 deleted file mode 100644 index e848119..0000000 --- a/plugins/voyage/tests/commands/trekexecute.test.mjs +++ /dev/null @@ -1,34 +0,0 @@ -// 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 deleted file mode 100644 index 901936d..0000000 --- a/plugins/voyage/tests/commands/trekplan.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -// 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 deleted file mode 100644 index 4fd2a8c..0000000 --- a/plugins/voyage/tests/commands/trekresearch.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -// 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 deleted file mode 100644 index 9d1a53c..0000000 --- a/plugins/voyage/tests/commands/trekreview.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -// 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 deleted file mode 100644 index c68e37c..0000000 --- a/plugins/voyage/tests/fixtures/brief-with-phase-signals.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -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 deleted file mode 100644 index 8bec99e..0000000 --- a/plugins/voyage/tests/fixtures/brief-without-phase-signals.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -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 717ee29..debb032 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -400,13 +400,13 @@ test('commands/trekplan.md Phase 8 seals Opus-4.7 schema-drift defense', () => { ); }); -// --- v5.0.0 / v5.0.1 — bespoke playground removed; /playground invocation explicit --- +// --- v5.0.0 — bespoke playground + /trekrevise + Handover 8 removed --- // -// 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. +// The v4.2/v4.3 bespoke playground SPA, the /trekrevise command, and +// Handover 8 (annotation → revision) were removed in v5.0.0. Producing +// commands now render artifacts to self-contained HTML via +// scripts/render-artifact.mjs and direct operators at the official +// `/playground` plugin for annotation. These pins lock the removal in. import { existsSync } from 'node:fs'; @@ -430,103 +430,36 @@ 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 is still removed (v5.0.1 + v5.0.2)', () => { +test('scripts/render-artifact.mjs exists (v5.0.0 render-and-link step)', () => { assert.ok( - !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)', + existsSync(join(ROOT, 'scripts/render-artifact.mjs')), + 'scripts/render-artifact.mjs is required — producing commands call it to render artifacts to HTML', ); }); -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. +test('producing commands reference render-artifact.mjs (render-and-link step)', () => { for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) { assert.ok( - read(`commands/${f}`).includes('scripts/annotate.mjs'), - `commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`, + read(`commands/${f}`).includes('render-artifact.mjs'), + `commands/${f} must wire the render-artifact.mjs render-and-link step (v5.0.0)`, ); } }); -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. +test('producing commands point operators at the /playground plugin for annotation', () => { 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`, + read(`commands/${f}`).includes('/playground'), + `commands/${f} must mention the /playground plugin as the annotation path (v5.0.0)`, ); } }); -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('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'); @@ -551,37 +484,3 @@ 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 deleted file mode 100644 index 3044447..0000000 --- a/plugins/voyage/tests/scripts/annotate.test.mjs +++ /dev/null @@ -1,208 +0,0 @@ -// 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/scripts/render-artifact.test.mjs b/plugins/voyage/tests/scripts/render-artifact.test.mjs new file mode 100644 index 0000000..835f68a --- /dev/null +++ b/plugins/voyage/tests/scripts/render-artifact.test.mjs @@ -0,0 +1,122 @@ +// tests/scripts/render-artifact.test.mjs +// Covers scripts/render-artifact.mjs — the v5.0.0 self-contained HTML +// renderer that /trekbrief, /trekplan, /trekreview call at the end of their +// run to produce a browser-readable view of the just-written artifact. + +import { test } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildHtml, renderMarkdown, render, parseArgs } from '../../scripts/render-artifact.mjs'; + +const SAMPLE = `--- +type: trekplan +plan_version: "1.7" +task: "Render-artifact smoke test" +slug: render-smoke +--- + +# Render-artifact smoke test + +A paragraph with **bold**, \`inline code\`, and a [link](https://example.com). + +## Steps + +- top item + - nested item +- second top item + +1. ordered one +2. ordered two + +\`\`\`js +const x = 1; +\`\`\` + +> a blockquote line + +| Col A | Col B | +|-------|-------| +| 1 | 2 | +`; + +test('buildHtml produces a complete self-contained HTML document', () => { + const html = buildHtml('plan.md', SAMPLE); + assert.ok(html.startsWith('<!DOCTYPE html>'), 'must start with doctype'); + assert.ok(html.includes('</html>'), 'must close html'); + assert.ok(html.includes('<style>'), 'must inline a stylesheet'); + // Zero external network references. + assert.ok(!/<link[^>]+href=/i.test(html), 'no external <link> stylesheets'); + assert.ok(!/<script[^>]+src=/i.test(html), 'no external <script src>'); + assert.ok(!/https?:\/\/(?!example\.com)/.test(html.replace(/<style>[\s\S]*?<\/style>/, '')), 'no unexpected http(s) URLs outside example link'); +}); + +test('buildHtml folds frontmatter into a <details> block', () => { + const html = buildHtml('plan.md', SAMPLE); + assert.ok(html.includes('<details class="frontmatter">'), 'frontmatter wrapped in <details>'); + assert.ok(html.includes('plan_version'), 'frontmatter content preserved'); + // Frontmatter must NOT leak into the rendered body as a literal "---" rule. + const bodyOnly = html.split('</details>')[1] || ''; + assert.ok(!bodyOnly.startsWith('\n<hr>'), 'frontmatter fence should not become an <hr>'); +}); + +test('buildHtml derives the <title> from frontmatter task', () => { + const html = buildHtml('plan.md', SAMPLE); + assert.match(html, /<title>Render-artifact smoke test<\/title>/); +}); + +test('renderMarkdown renders headings, code fences, lists, tables, blockquotes', () => { + const out = renderMarkdown(SAMPLE.split('---\n').slice(2).join('---\n')); + assert.match(out, /<h1>Render-artifact smoke test<\/h1>/); + assert.match(out, /<h2>Steps<\/h2>/); + assert.match(out, /<pre><code class="language-js">/); + assert.ok(out.includes('const x = 1;'), 'code fence body preserved'); + assert.match(out, /<ul><li>top item<ul><li>nested item<\/li><\/ul><\/li>/); + assert.match(out, /<ol><li>ordered one<\/li><li>ordered two<\/li><\/ol>/); + assert.match(out, /<blockquote>a blockquote line<\/blockquote>/); + assert.match(out, /<table>[\s\S]*<th>Col A<\/th>[\s\S]*<td>1<\/td>[\s\S]*<\/table>/); + assert.match(out, /<strong>bold<\/strong>/); + assert.match(out, /<code>inline code<\/code>/); + assert.match(out, /<a href="https:\/\/example\.com">link<\/a>/); +}); + +test('renderMarkdown escapes HTML in body and code', () => { + const out = renderMarkdown('A <tag> & "quote".\n\n```\n<script>alert(1)</script>\n```\n'); + assert.ok(!out.includes('<tag>'), 'raw tag escaped'); + assert.ok(out.includes('<tag>'), 'tag rendered as entity'); + assert.ok(out.includes('<script>alert(1)</script>'), 'code-fence content escaped'); +}); + +test('render() is deterministic — two runs byte-identical', () => { + const dir = mkdtempSync(join(tmpdir(), 'render-artifact-')); + try { + const md = join(dir, 'plan.md'); + writeFileSync(md, SAMPLE); + const out1 = render(md, join(dir, 'a.html')); + const out2 = render(md, join(dir, 'b.html')); + assert.ok(existsSync(out1) && existsSync(out2)); + assert.equal(readFileSync(out1, 'utf-8'), readFileSync(out2, 'utf-8')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('render() defaults output to <input-basename>.html', () => { + const dir = mkdtempSync(join(tmpdir(), 'render-artifact-')); + 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 and positional input', () => { + 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); +}); diff --git a/plugins/voyage/tests/validators/brief-validator.test.mjs b/plugins/voyage/tests/validators/brief-validator.test.mjs index a9fd185..6e501d2 100644 --- a/plugins/voyage/tests/validators/brief-validator.test.mjs +++ b/plugins/voyage/tests/validators/brief-validator.test.mjs @@ -152,69 +152,3 @@ 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')); -});