chore(voyage): release v5.0.2 — operator-driven annotation HTML (scripts/annotate.mjs)
v5.0.0 added a read-only HTML render. v5.0.1 deleted that and pointed at /playground document-critique, which pre-generates Claude's suggestions and asks the operator to approve/reject them. The operator asked for the opposite — a surface where THEY drive every annotation. v5.0.2 lands it. scripts/annotate.mjs (~430 lines, zero deps) 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 add their own note (inline textarea, save with Cmd+Enter or button), keeps a sidebar of all notes (editable + deletable + persisted in localStorage per artifact path), and exposes Copy Prompt to gather every note into one structured prompt. Operator copies, pastes back, Claude revises the .md. The three producing commands now run annotate.mjs at their last step and print the file:// link with explicit "Click any line to add YOUR OWN note" instructions. The v5.0.1 /playground document-critique line is gone. npm test green: 516 tests, 514 pass, 0 fail, 2 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2e0892cdaf
commit
8ea692bc60
15 changed files with 995 additions and 118 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "voyage",
|
||||
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.",
|
||||
"version": "5.0.1",
|
||||
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. /trekbrief, /trekplan, and /trekreview each end by building a self-contained operator-annotation HTML (scripts/annotate.mjs) and printing the file:// link — the operator clicks lines, adds their own notes, copies a structured prompt, pastes back, Claude revises the .md.",
|
||||
"version": "5.0.2",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,58 @@ 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.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 `<artifact.md>`, writes `<artifact>.html` (or `--out <file>`). Self-contained, design-system-aligned (light + dark + print), zero external network, deterministic. CLI: `node scripts/annotate.mjs <artifact.md> [--out <file.html>]`. Also `npm run annotate -- <artifact.md>`.
|
||||
- **`tests/scripts/annotate.test.mjs`** (10 tests) — self-contained HTML shape, no external `<link>`/`<script src>`, inline script parses, source content + path embedded, HTML escaping in title + body (XSS surface), determinism, default output path, arg parsing, and the operator-driven affordances (Click any line, Your annotations sidebar, Copy Prompt, Clear all, localStorage).
|
||||
- **`npm run annotate`** convenience script.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`commands/trekbrief.md` Step 4g, `commands/trekplan.md` Phase 10, `commands/trekreview.md` Phase 8** — each now runs `scripts/annotate.mjs` after the artifact is final and prints the resulting `file://<abs path>` link with explicit "Click any line to add YOUR OWN note" instructions. The v5.0.1 `/playground build a document-critique playground for …` line is removed from all three.
|
||||
- **`tests/lib/doc-consistency.test.mjs`** — replaced the v5.0.1 `/playground` pins with v5.0.2 pins: `scripts/annotate.mjs` exists; producing commands invoke it; producing commands no longer print the v5.0.1 `/playground document-critique` line; producing commands signal operator-driven annotation in their prose; CHANGELOG has a v5.0.2 entry.
|
||||
- **Plugin `CLAUDE.md` + `README.md` + root `CLAUDE.md` + root `README.md` + `.claude-plugin/marketplace.json`** — voyage description updated from "v5.0.1 /playground invocation" to "v5.0.2 operator-annotation HTML (`scripts/annotate.mjs`)".
|
||||
|
||||
### Notes
|
||||
|
||||
- `/playground` is unchanged — the official `claude-plugins-official` `playground` skill is great for the Claude-leads, operator-reacts flow; it just wasn't the right tool for operator-leads, Claude-reacts.
|
||||
- `npm test`: 516 tests, 514 pass, 0 fail, 2 skipped (up from 503 — 10 new `annotate.test.mjs` tests + 3 net new doc-consistency pins).
|
||||
- Version bump 5.0.1 → 5.0.2 in `.claude-plugin/plugin.json`, `package.json`, `package-lock.json`, plugin `README.md` badge.
|
||||
|
||||
## v5.0.1 — 2026-05-13 — Drop the standalone HTML render; print a literal /playground invocation
|
||||
|
||||
**No new breaking changes beyond v5.0.0.** Forks that consumed
|
||||
|
|
|
|||
|
|
@ -232,27 +232,30 @@ Local Docker Compose stack: `examples/observability/`. Operator docs: `docs/obse
|
|||
|
||||
**Continue:** `/trekcontinue` reads `{dir}/.session-state.local.json` (Handover 7), validates schema-v1 via `session-state-validator`, narrates a 3-line summary (project / next-session-label / brief-path), and immediately begins executing the next session. Auto-discovers active project state files under `.claude/projects/*/.session-state.local.json` if no explicit `<project-dir>` argument. Operator-invoked only — never auto-loaded via SessionStart. The `/trekendsession` helper is the informal-flow producer: writes the same state file for ad-hoc multi-session handovers that don't run through `/trekexecute`.
|
||||
|
||||
**Post-command annotation invocation (v5.0.1):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` prints — verbatim — a copy-paste-ready `/playground` command pointing at the just-written artifact. Concretely: `/playground build a document-critique playground for {abs_path}/{artifact}.md`. When the operator pastes that into Claude, the official `claude-plugins-official` `playground` skill loads its `document-critique` template, reads the `.md`, generates per-line suggestions, and writes a single self-contained HTML file with the artifact nicely formatted on the left, Approve/Reject/Comment cards on the right, and a "Copy Prompt" button at the bottom. The operator marks suggestions, clicks Copy Prompt, pastes the prompt back into Claude — Claude revises the artifact freehand from the notes. **One paste → playground → mark → copy → paste back.** The v5.0.0 standalone `.html` render (`scripts/render-artifact.mjs`) was a separate read-only view that did not afford annotation; v5.0.1 dropped it because it was redundant with the `/playground` HTML (which already shows the artifact nicely) and the two HTMLs created operator confusion. The v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 — all removed in v5.0.0 — were never re-introduced. See [CHANGELOG.md](CHANGELOG.md) § v5.0.1.
|
||||
**Operator-annotation HTML (v5.0.2):** the last step of `/trekbrief`, `/trekplan`, and `/trekreview` runs `scripts/annotate.mjs` against the just-written `.md` and prints the resulting `file://<abs path>` link. The HTML is self-contained (zero npm deps, zero external network, design-system-styled, light + dark + print), and built around the operator. The operator opens the file, clicks any line of the document, writes their own note in an inline textarea, hits Save. The sidebar collects every note (editable + deletable, sorted by line). Notes persist in `localStorage` per artifact path — refresh or browser-close doesn't lose work. A "Copy Prompt" button at the bottom assembles every note into one structured prompt; the operator copies it and pastes it back into Claude, and Claude revises the `.md` freehand from the notes. **The operator drives every annotation.** Claude does not pre-generate suggestions in this flow — that was the v5.0.1 path (via `/playground document-critique`), which inverted the direction the operator actually wanted. v5.0.2 makes the loop OPERATOR-LEADS, CLAUDE-REACTS, which is what was asked for all along. The v5.0.0 standalone `.html` render (`scripts/render-artifact.mjs`) was a read-only view that didn't afford annotation; the v4.2/v4.3 bespoke playground SPA + `/trekrevise` + Handover 8 were 388 KB of broken UX. Both stay removed. See [CHANGELOG.md](CHANGELOG.md) § v5.0.2.
|
||||
|
||||
**Security:** 4-layer defense-in-depth: plugin hooks (pre-bash-executor, pre-write-executor), prompt-level denylist (works in headless sessions), pre-execution plan scan (Phase 2.4), scoped `--allowedTools` replacing `--dangerously-skip-permissions`. Hard Rules 14-16 enforce verify command security, repo-boundary writes, and sensitive path protection.
|
||||
|
||||
**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project <dir>` fills in `{dir}/research/`. `/trekplan --project <dir>` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project <dir>` executes and writes `{dir}/progress.json`. `/trekreview --project <dir>` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a copy-paste-ready `/playground build a document-critique playground for {artifact_path}` command — the operator pastes it into Claude to launch the official `playground` skill (`document-critique` template), which builds an interactive HTML for browser annotation. All `.md` artifacts live in one project directory.
|
||||
**Pipeline:** `/trekbrief` produces the task brief. `/trekresearch --project <dir>` fills in `{dir}/research/`. `/trekplan --project <dir>` reads brief + research to produce `{dir}/plan.md` (and auto-discovers `{dir}/architecture/overview.md` if an opt-in upstream architect plugin produced one). `/trekexecute --project <dir>` executes and writes `{dir}/progress.json`. `/trekreview --project <dir>` produces `{dir}/review.md`. `/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` on the just-written artifact, producing `{dir}/{artifact}.html` — a self-contained operator-annotation surface — and printing the `file://` link. The operator opens it, clicks lines, writes their own notes, copies a structured prompt, pastes back, Claude revises the `.md`. All artifacts live in one project directory.
|
||||
|
||||
**Project-directory contract (v3.0.0):** trekplan owns the directory layout below. The `architecture/` subdirectory is opt-in and produced by an opt-in upstream architect plugin (not bundled) — the architect plugin is no longer publicly distributed, but the `architecture/overview.md` slot remains available for any compatible producer.
|
||||
|
||||
```
|
||||
.claude/projects/{YYYY-MM-DD}-{slug}/
|
||||
brief.md ← trekbrief writes; everyone reads
|
||||
brief.html ← trekbrief annotates (operator-annotation HTML, gitignored, re-buildable from brief.md)
|
||||
research/*.md ← trekresearch writes; plan + architect read
|
||||
architecture/ ← OPT-IN, owned by an opt-in upstream architect plugin (not bundled)
|
||||
overview.md
|
||||
gaps.md
|
||||
plan.md ← trekplan writes; trekexecute reads
|
||||
plan.html ← trekplan annotates
|
||||
progress.json ← trekexecute writes
|
||||
review.md ← trekreview writes; trekplan reads (Handover 6)
|
||||
review.html ← trekreview annotates
|
||||
```
|
||||
|
||||
When an operator runs the `/playground build a document-critique playground for <artifact>` command that the producing commands print, the `playground` skill writes the resulting HTML into its own working directory (typically next to the project dir or in `/tmp/`); voyage does not own the `.html` filename. The annotation HTML is ephemeral — re-built on each invocation from the current `.md` source.
|
||||
The `.html` files (`brief.html`, `plan.html`, `review.html`) are produced by `scripts/annotate.mjs` and live alongside their `.md` siblings in the project directory. They are re-buildable from the `.md` source at any time (deterministic, byte-identical output on re-run), so they are conventionally gitignored along with the rest of `.claude/projects/`. Operator annotations live in browser `localStorage` keyed on the absolute artifact path — they survive refresh and browser-close, but are local to the operator's machine.
|
||||
|
||||
No code-level dependency between plugins — the contract is filesystem-level only.
|
||||
|
||||
|
|
@ -260,13 +263,13 @@ No code-level dependency between plugins — the contract is filesystem-level on
|
|||
|
||||
All artifacts in one project directory (default):
|
||||
- Project root: `.claude/projects/{YYYY-MM-DD}-{slug}/`
|
||||
- `brief.md` (task brief from `/trekbrief`)
|
||||
- `brief.md` + `brief.html` (task brief from `/trekbrief`; `.html` is the operator-annotation surface from `scripts/annotate.mjs`)
|
||||
- `research/{NN}-{slug}.md` (research briefs from `/trekresearch --project`)
|
||||
- `architecture/overview.md` + `architecture/gaps.md` (opt-in, produced by an opt-in upstream architect plugin, not bundled)
|
||||
- `plan.md` (from `/trekplan --project`)
|
||||
- `plan.md` + `plan.html` (from `/trekplan --project`)
|
||||
- `sessions/session-*.md` (from `--decompose`)
|
||||
- `progress.json` (from `/trekexecute --project`)
|
||||
- `review.md` (from `/trekreview --project`)
|
||||
- `review.md` + `review.html` (from `/trekreview --project`)
|
||||
- `.session-state.local.json` (Handover 7 — gitignored via `*.local.json`; written by `/trekexecute` Phase 8/2.55/4 or `/trekendsession`; read by `/trekcontinue`)
|
||||
|
||||
Legacy paths (still work without `--project`):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# trekplan — Brief, Research, Plan, Execute, Review, Continue
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
|
@ -19,7 +19,7 @@ A [Claude Code](https://docs.anthropic.com/en/docs/claude-code) plugin for deep
|
|||
| **`/trekreview`** | Review — independent post-hoc review of delivered code against the brief, severity-tagged findings |
|
||||
| **`/trekcontinue`** | Continue — read `.session-state.local.json` and resume the next session in a multi-session project |
|
||||
|
||||
`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single copy-paste-ready `/playground build a document-critique playground for <artifact_path>` command. Paste it into Claude, the official `playground` skill builds a browser-readable HTML with per-line Approve/Reject/Comment annotations, copy the generated prompt, paste back — Claude revises the artifact. See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v501).
|
||||
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running `scripts/annotate.mjs` against the just-written artifact and printing the resulting `file://<abs path>` link. The operator opens the HTML in a browser, clicks any line of the document, writes their own note in the inline textarea, watches a sidebar of all notes (editable, deletable, persisted in browser `localStorage`), and clicks "Copy Prompt" to get one structured prompt that they paste back into Claude — Claude then revises the `.md` from the notes. **The operator drives every annotation.** See [Reviewing and annotating artifacts](#reviewing-and-annotating-artifacts-v502).
|
||||
|
||||
Every artifact lives in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `plan.md`, `sessions/`, `progress.json`, and `review.md`.
|
||||
|
||||
|
|
@ -503,55 +503,65 @@ Both arguments are required. No interactive prompt — headless-safe.
|
|||
|
||||
---
|
||||
|
||||
## Reviewing and annotating artifacts (v5.0.1)
|
||||
## Reviewing and annotating artifacts (v5.0.2)
|
||||
|
||||
`/trekbrief`, `/trekplan`, and `/trekreview` each end by printing a single
|
||||
copy-paste-ready command. After they finish, you see something like:
|
||||
`/trekbrief`, `/trekplan`, and `/trekreview` each end by running
|
||||
`scripts/annotate.mjs` against the just-written `.md` and printing the
|
||||
resulting `file://<abs path>` link. After they finish you see something
|
||||
like:
|
||||
|
||||
```
|
||||
Brief written: .claude/projects/2026-05-13-foo/brief.md
|
||||
Annotation HTML: file:///abs/path/.claude/projects/2026-05-13-foo/brief.html
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
To review and annotate this brief, copy and paste this into Claude:
|
||||
To review and annotate this brief, open the HTML above in a browser:
|
||||
|
||||
/playground build a document-critique playground for .claude/projects/2026-05-13-foo/brief.md
|
||||
open file:///abs/path/.claude/projects/2026-05-13-foo/brief.html
|
||||
|
||||
That builds a self-contained HTML file with the brief on the left,
|
||||
per-line approve/reject/comment annotations on the right, and a
|
||||
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
|
||||
it back here, and Claude revises brief.md from your notes.
|
||||
Click any line to add YOUR OWN note. The sidebar collects every note,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat and Claude revises brief.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
You copy that one `/playground …` line, paste it into Claude, and the
|
||||
official `claude-plugins-official` `playground` skill takes over: it loads
|
||||
its `document-critique` template, reads the `.md`, analyses it, generates
|
||||
per-line suggestions, and writes a single self-contained HTML file that
|
||||
opens in your browser. In the browser:
|
||||
You run `open` (or click the `file://` link in your terminal), the HTML
|
||||
opens in your default browser. In the browser:
|
||||
|
||||
- **Left:** the artifact, nicely formatted, with line numbers and per-line
|
||||
highlight bars for any line a suggestion targets.
|
||||
- **Right:** suggestion cards (Approve / Reject / Comment), filterable by
|
||||
status, with a counter in the header.
|
||||
- **Bottom:** a live-updated prompt covering everything you approved or
|
||||
commented on, with a **Copy Prompt** button.
|
||||
- **Left:** the artifact, rendered with line numbers. Hover any line — the
|
||||
gutter shows a `+`. Click — an inline textarea appears under that line.
|
||||
Write your note, hit Save (or `⌘Enter`). The line gets a colored left
|
||||
border + a number badge showing the annotation count on that line.
|
||||
- **Right (sidebar):** every annotation you've made, sorted by line. Each
|
||||
card shows the line reference (click to scroll), the target text
|
||||
snippet, and your note text. Click the note to edit it, click `✕` to
|
||||
delete. A **Clear all** button at the top wipes the slate.
|
||||
- **Bottom:** a structured prompt that updates live with every note. A
|
||||
**Copy Prompt** button. Click — your clipboard now has the prompt.
|
||||
- **Persistence:** every annotation is saved to browser `localStorage`
|
||||
keyed on the artifact's absolute path. Refresh the tab or close the
|
||||
browser and re-open — your notes are still there.
|
||||
|
||||
You mark what you want, click Copy Prompt, paste it back into Claude, and
|
||||
Claude revises the `.md` artifact freehand from your notes. **One paste
|
||||
→ playground → mark → copy → paste back.**
|
||||
You write your notes, click Copy Prompt, paste back into Claude. Claude
|
||||
revises the `.md` freehand from your notes. **The operator drives every
|
||||
annotation.** Claude never pre-generates suggestions in this flow.
|
||||
|
||||
> **What v5.0.1 changed from v5.0.0.** v5.0.0 had `/trekbrief`, `/trekplan`,
|
||||
> and `/trekreview` each render their `.md` to a separate read-only `.html`
|
||||
> view via `scripts/render-artifact.mjs` *and* print a vague "run the
|
||||
> `/playground` plugin" instruction. In practice the read-only `.html` was
|
||||
> redundant with what `/playground` produces (which also shows the artifact
|
||||
> nicely formatted), and the instruction wasn't copy-paste-ready — operators
|
||||
> had to guess the right invocation. v5.0.1 drops `render-artifact.mjs` and
|
||||
> its `.html` output, and makes the printed `/playground` invocation
|
||||
> literal and copy-paste-ready.
|
||||
> **What v5.0.2 changed from v5.0.1.** v5.0.0 added a read-only HTML
|
||||
> render that didn't afford annotation. v5.0.1 deleted that and pointed
|
||||
> operators at `/playground document-critique` — but the `document-critique`
|
||||
> template pre-generates **Claude's** suggestions and asks the operator to
|
||||
> approve/reject them. That's "Claude leads, operator reacts" — the
|
||||
> opposite of what was asked for. v5.0.2 ships `scripts/annotate.mjs`, a
|
||||
> self-contained zero-dependency operator-annotation HTML generator. The
|
||||
> operator clicks lines, writes their own notes, copies a structured prompt
|
||||
> with every note. No Claude-generated suggestions in the loop.
|
||||
>
|
||||
> **Still removed from v5.0.0 onward:** the v4.2/v4.3 bespoke playground SPA,
|
||||
> `/trekrevise`, Handover 8 (annotation → revision), the supporting `lib/`
|
||||
> modules, and the Playwright e2e suite. See [CHANGELOG.md](CHANGELOG.md)
|
||||
> § v5.0.0 and § v5.0.1.
|
||||
> § v5.0.0, § v5.0.1, and § v5.0.2.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -685,7 +695,7 @@ The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/treke
|
|||
|
||||
**Infrastructure-as-code (IaC) gets reduced value.** The exploration agents are designed for application code. Terraform, Helm, Pulumi, CDK projects will get a plan, but agents like `architecture-mapper` and `test-strategist` produce less useful output for IaC. Use trekplan for the structural plan, then supplement IaC-specific steps manually.
|
||||
|
||||
**Annotation requires the official `/playground` skill.** Voyage no longer ships an annotation UI of its own. The pasted `/playground build a document-critique playground for …` command depends on the `claude-plugins-official` `playground` skill being installed in the operator's environment. If it isn't, paste the artifact source `.md` into Claude with a "review this" prompt and revise freehand — the same end-result, just without the visual approve/reject/comment surface.
|
||||
**Annotation HTML requires a desktop browser.** `scripts/annotate.mjs` produces a single self-contained `.html` file you open with `file://` in any modern browser (Chrome / Safari / Firefox / Edge — last two versions). No CDN, no server, no npm runtime deps. State persists in `localStorage` so closing and re-opening the tab keeps your work, but it's local to one browser on one machine — not synced anywhere. If you want to annotate without a browser, paste the `.md` into Claude with "comments inline below" and write notes in chat — same end result, just without the visual surface.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
|
|
@ -483,34 +483,46 @@ If the validator returns errors, report them to the user and offer to
|
|||
re-enter Phase 4 with the validator's hints in scope. If only warnings,
|
||||
note them in the final report.
|
||||
|
||||
**Report — single block, printed verbatim** (substitute the bracketed
|
||||
fields, but keep the structure and the `/playground` invocation literal —
|
||||
operators copy-paste that line directly into Claude). The `/playground`
|
||||
command points at the official `claude-plugins-official` `playground`
|
||||
skill, which loads its `document-critique` template, reads the brief,
|
||||
generates per-line suggestions, and writes a single self-contained HTML
|
||||
file that opens in the browser. The HTML has the brief on the left
|
||||
(nicely formatted, line-numbered), suggestion cards on the right
|
||||
(Approve / Reject / Comment), and a "Copy Prompt" button at the bottom
|
||||
that gathers everything marked into one prompt. Paste that prompt back
|
||||
into Claude — Claude then revises `brief.md` freehand from the notes.
|
||||
**Build the operator-annotation HTML, then print the report.** After the
|
||||
brief is validated, run `scripts/annotate.mjs` to produce a self-contained
|
||||
HTML file the operator opens in their browser. The HTML renders the brief
|
||||
with line numbers, lets the operator click any line to attach their own
|
||||
note (not Claude-generated suggestions — the operator drives every
|
||||
annotation), keeps a sidebar of all notes, persists state in localStorage,
|
||||
and exposes a "Copy Prompt" button that generates a single structured
|
||||
prompt with every note. The operator copies that prompt and pastes it
|
||||
back into Claude; Claude revises `brief.md` freehand from the notes.
|
||||
|
||||
```bash
|
||||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{PROJECT_DIR}/brief.md" 2>&1)
|
||||
# stdout is the absolute path to the .html on success.
|
||||
```
|
||||
|
||||
If `annotate.mjs` exits non-zero, surface a one-line warning and continue
|
||||
— the annotation HTML is a convenience, not a gate. The report below
|
||||
still mentions the (failed) path so the operator can debug.
|
||||
|
||||
Then print this block **verbatim** (substitute `{PROJECT_DIR}` and
|
||||
`$ANNOT_HTML`):
|
||||
|
||||
```
|
||||
Brief written: {PROJECT_DIR}/brief.md
|
||||
Brief written: {PROJECT_DIR}/brief.md
|
||||
Annotation HTML: file://{$ANNOT_HTML}
|
||||
Review iterations: {1..3}
|
||||
Final quality: {complete | partial}
|
||||
Validator: {PASS | warnings(N)}
|
||||
Final quality: {complete | partial}
|
||||
Validator: {PASS | warnings(N)}
|
||||
Research topics identified: {N}
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
To review and annotate this brief, copy and paste this into Claude:
|
||||
To review and annotate this brief, open the HTML above in a browser:
|
||||
|
||||
/playground build a document-critique playground for {PROJECT_DIR}/brief.md
|
||||
open file://{$ANNOT_HTML}
|
||||
|
||||
That builds a self-contained HTML file with the brief on the left,
|
||||
per-line approve/reject/comment annotations on the right, and a
|
||||
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
|
||||
it back here, and Claude revises brief.md from your notes.
|
||||
Click any line to add YOUR OWN note. The sidebar collects every note,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat and Claude revises brief.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -769,30 +769,43 @@ If the user asks questions or requests changes:
|
|||
- Show what changed
|
||||
- Re-present the summary
|
||||
|
||||
### Print the annotation invocation
|
||||
### Build the operator-annotation HTML and print the link
|
||||
|
||||
After the plan summary, print this block **verbatim** (substituting only
|
||||
`{plan_path}` with the absolute path). The `/playground` command must
|
||||
appear literally — operators copy-paste it directly into Claude. It
|
||||
points at the official `claude-plugins-official` `playground` skill,
|
||||
which loads its `document-critique` template, reads `plan.md`, generates
|
||||
per-line suggestions, and writes a single self-contained HTML file that
|
||||
opens in the browser. The HTML has the plan on the left (nicely
|
||||
formatted, line-numbered), suggestion cards on the right (Approve /
|
||||
Reject / Comment), and a "Copy Prompt" button at the bottom that gathers
|
||||
everything marked into one prompt. Paste that prompt back into Claude —
|
||||
Claude then revises `plan.md` freehand from the notes.
|
||||
After the plan summary, run `scripts/annotate.mjs` to produce a
|
||||
self-contained HTML the operator opens in their browser. The HTML renders
|
||||
`plan.md` with line numbers, lets the operator click any line to attach
|
||||
their own note (not Claude-generated suggestions — the operator drives
|
||||
every annotation), keeps a sidebar of all notes, persists state in
|
||||
localStorage, and exposes a "Copy Prompt" button that generates a single
|
||||
structured prompt with every note. The operator copies that prompt and
|
||||
pastes it back into Claude; Claude revises `plan.md` freehand from the
|
||||
notes.
|
||||
|
||||
```bash
|
||||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{plan_path}" 2>&1)
|
||||
# stdout is the absolute path to the .html on success.
|
||||
```
|
||||
|
||||
If `annotate.mjs` exits non-zero, surface a one-line warning and continue
|
||||
— the annotation HTML is a convenience, not a gate.
|
||||
|
||||
Then print this block **verbatim** (substituting `{plan_path}` and
|
||||
`$ANNOT_HTML`):
|
||||
|
||||
```
|
||||
────────────────────────────────────────────────────────────────────
|
||||
To review and annotate this plan, copy and paste this into Claude:
|
||||
Plan written: {plan_path}
|
||||
Annotation HTML: file://{$ANNOT_HTML}
|
||||
|
||||
/playground build a document-critique playground for {plan_path}
|
||||
To review and annotate the plan, open it in a browser:
|
||||
|
||||
That builds a self-contained HTML file with the plan on the left,
|
||||
per-line approve/reject/comment annotations on the right, and a
|
||||
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
|
||||
it back here, and Claude revises plan.md from your notes.
|
||||
open file://{$ANNOT_HTML}
|
||||
|
||||
Click any line to add YOUR OWN note. The sidebar collects every note,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat and Claude revises plan.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -262,6 +262,20 @@ Append a stats line to `${CLAUDE_PLUGIN_DATA}/trekreview-stats.jsonl`
|
|||
If `${CLAUDE_PLUGIN_DATA}` is unset or not writable, skip stats silently.
|
||||
Never let stats failures block the main workflow.
|
||||
|
||||
**Build the operator-annotation HTML.** After stats land, run:
|
||||
|
||||
```bash
|
||||
ANNOT_HTML=$(node ${CLAUDE_PLUGIN_ROOT}/scripts/annotate.mjs "{review_path}" 2>&1)
|
||||
```
|
||||
|
||||
`stdout` is the absolute path to the `.html` on success. The HTML renders
|
||||
`review.md` with line numbers, lets the operator click any line to attach
|
||||
their own note (not Claude-generated suggestions — the operator drives
|
||||
every annotation), keeps a sidebar of all notes, persists state in
|
||||
localStorage, and exposes a "Copy Prompt" button. If `annotate.mjs`
|
||||
exits non-zero, surface a one-line warning and continue — the annotation
|
||||
HTML is a convenience, not a gate.
|
||||
|
||||
## Phase 8.5 — Validate-only mode (`--validate`)
|
||||
|
||||
When `mode == validate`:
|
||||
|
|
@ -282,6 +296,7 @@ After the write succeeds, print:
|
|||
**Brief:** {brief_path}
|
||||
**Project:** {project_dir}
|
||||
**Review:** {review_path}
|
||||
**Annotation HTML:** file://{$ANNOT_HTML}
|
||||
**Scope:** {before_sha}..{after_sha} ({reviewed_files_count} files)
|
||||
**Verdict:** {BLOCK | WARN | ALLOW}
|
||||
|
||||
|
|
@ -297,14 +312,15 @@ After the write succeeds, print:
|
|||
{up to 5 highest-severity findings}
|
||||
|
||||
────────────────────────────────────────────────────────────────────
|
||||
To review and annotate this review, copy and paste this into Claude:
|
||||
To review and annotate the review, open it in a browser:
|
||||
|
||||
/playground build a document-critique playground for {review_path}
|
||||
open file://{$ANNOT_HTML}
|
||||
|
||||
That builds a self-contained HTML file with the review on the left,
|
||||
per-line approve/reject/comment annotations on the right, and a
|
||||
"Copy Prompt" button at the bottom. Copy the generated prompt, paste
|
||||
it back here, and Claude revises review.md from your notes.
|
||||
Click any line to add YOUR OWN note. The sidebar collects every note,
|
||||
the "Copy Prompt" button gathers them into one structured prompt.
|
||||
Paste that prompt back into this chat and Claude revises review.md
|
||||
from your notes. Annotations persist in your browser if you close
|
||||
the tab and reopen the same file.
|
||||
────────────────────────────────────────────────────────────────────
|
||||
|
||||
You can also:
|
||||
|
|
|
|||
4
plugins/voyage/package-lock.json
generated
4
plugins/voyage/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "voyage",
|
||||
"version": "5.0.1",
|
||||
"version": "5.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "voyage",
|
||||
"version": "5.0.1",
|
||||
"version": "5.0.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "voyage",
|
||||
"version": "5.0.1",
|
||||
"description": "Voyage — brief, research, plan, execute, review, continue. Contract-driven Claude Code pipeline. At the end of /trekbrief, /trekplan, and /trekreview, the operator gets a copy-paste-ready /playground invocation that builds an interactive document-critique HTML for the artifact.",
|
||||
"version": "5.0.2",
|
||||
"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) and printing the file:// link — the operator clicks lines, adds their own notes, copies a structured prompt, pastes back, Claude revises the .md.",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test 'tests/**/*.test.mjs'",
|
||||
"annotate": "node scripts/annotate.mjs",
|
||||
"verify": "bash verify.sh"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
|
|||
573
plugins/voyage/scripts/annotate.mjs
Normal file
573
plugins/voyage/scripts/annotate.mjs
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/annotate.mjs
|
||||
//
|
||||
// Builds a self-contained operator-annotation HTML for a voyage artifact
|
||||
// (brief.md / plan.md / review.md). The producing commands call this on
|
||||
// their last step and print the file:// link; the operator opens it in
|
||||
// a browser, clicks any line to attach their own note, watches a sidebar
|
||||
// of all their annotations, and copies a single prompt (with every note)
|
||||
// back into Claude. Claude revises the source .md from the notes.
|
||||
//
|
||||
// • Operator drives every annotation. No pre-generated suggestions.
|
||||
// • Each annotation is anchored to the source line number (1-based).
|
||||
// • Annotations persist in localStorage per artifact path — refresh
|
||||
// does not lose work; closing the browser does not either.
|
||||
// • Zero external network, zero npm deps, deterministic output.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { splitFrontmatter } from '../lib/util/frontmatter.mjs';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function deriveTitle(mdText, fallbackName) {
|
||||
const { hasFrontmatter, frontmatter } = splitFrontmatter(mdText);
|
||||
if (hasFrontmatter) {
|
||||
const taskMatch = frontmatter.match(/^task:\s*(.+)$/m);
|
||||
if (taskMatch) return taskMatch[1].trim().replace(/^["']|["']$/g, '');
|
||||
const slugMatch = frontmatter.match(/^slug:\s*(.+)$/m);
|
||||
if (slugMatch) return slugMatch[1].trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
const h1 = mdText.match(/^#\s+(.+)$/m);
|
||||
if (h1) return h1[1].trim();
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
function buildHtml(artifactPath, mdText) {
|
||||
const lines = mdText.replace(/\r\n/g, '\n').split('\n');
|
||||
const fileName = basename(artifactPath);
|
||||
const title = deriveTitle(mdText, fileName);
|
||||
const linesJson = JSON.stringify(lines);
|
||||
const artifactPathJson = JSON.stringify(artifactPath);
|
||||
const titleEsc = escapeHtml(title);
|
||||
const fileNameEsc = escapeHtml(fileName);
|
||||
|
||||
return '<!DOCTYPE html>\n'
|
||||
+ '<html lang="en">\n'
|
||||
+ '<head>\n'
|
||||
+ '<meta charset="utf-8">\n'
|
||||
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
+ '<title>' + titleEsc + ' — annotate</title>\n'
|
||||
+ '<style>\n' + STYLE + '\n</style>\n'
|
||||
+ '</head>\n'
|
||||
+ '<body>\n'
|
||||
+ '<div class="app">\n'
|
||||
+ '<header class="topbar">\n'
|
||||
+ ' <div class="hdr-meta">\n'
|
||||
+ ' <h1>' + titleEsc + '</h1>\n'
|
||||
+ ' <p class="path" title="' + escapeHtml(artifactPath) + '">' + fileNameEsc + '</p>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <div class="hdr-stats" id="stats"></div>\n'
|
||||
+ '</header>\n'
|
||||
+ '<main class="split">\n'
|
||||
+ ' <section class="doc-panel">\n'
|
||||
+ ' <div class="doc-help"><strong>Click any line</strong> to add your own annotation. Annotations are saved in your browser per artifact path.</div>\n'
|
||||
+ ' <div class="doc-content" id="doc"></div>\n'
|
||||
+ ' </section>\n'
|
||||
+ ' <aside class="notes-panel">\n'
|
||||
+ ' <div class="notes-header">\n'
|
||||
+ ' <h3>Your annotations</h3>\n'
|
||||
+ ' <button id="clear-all" class="ghost-btn" title="Remove all annotations">Clear all</button>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <div class="notes-list" id="notes-list"></div>\n'
|
||||
+ ' </aside>\n'
|
||||
+ '</main>\n'
|
||||
+ '<section class="prompt-panel">\n'
|
||||
+ ' <div class="prompt-head">\n'
|
||||
+ ' <span class="prompt-title">Prompt for Claude</span>\n'
|
||||
+ ' <button class="copy-btn" id="copy" disabled>Copy Prompt</button>\n'
|
||||
+ ' </div>\n'
|
||||
+ ' <pre class="prompt-body empty" id="prompt">Click a line and add a note to generate a prompt.</pre>\n'
|
||||
+ '</section>\n'
|
||||
+ '</div>\n'
|
||||
+ '<script>\n'
|
||||
+ 'const DOC_LINES = ' + linesJson + ';\n'
|
||||
+ 'const ARTIFACT_PATH = ' + artifactPathJson + ';\n'
|
||||
+ 'const ARTIFACT_NAME = ' + JSON.stringify(fileName) + ';\n'
|
||||
+ APP_JS
|
||||
+ '\n</script>\n'
|
||||
+ '</body>\n'
|
||||
+ '</html>\n';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stylesheet — design-system-aligned, light + dark, no external fonts/CDN.
|
||||
// ---------------------------------------------------------------------------
|
||||
const STYLE = `
|
||||
:root {
|
||||
--bg: #f7f7f8;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-soft: #ececef;
|
||||
--border: #d6d8dc;
|
||||
--border-strong: #b3b7bd;
|
||||
--text: #1a1a1a;
|
||||
--text-dim: #555a63;
|
||||
--text-mute: #8a8f97;
|
||||
--accent: #0855a8;
|
||||
--accent-soft: #e4ecf6;
|
||||
--amber: #a86b00;
|
||||
--amber-soft: #fbeed1;
|
||||
--green: #1a7f37;
|
||||
--green-soft: #d5ecdb;
|
||||
--red: #b3262d;
|
||||
--red-soft: #f6d9da;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0e1218;
|
||||
--bg-elev: #161b22;
|
||||
--bg-soft: #1c232c;
|
||||
--border: #2a323c;
|
||||
--border-strong: #3b4554;
|
||||
--text: #e5e9ef;
|
||||
--text-dim: #a5adba;
|
||||
--text-mute: #6e7681;
|
||||
--accent: #6db0ee;
|
||||
--accent-soft: rgba(109, 176, 238, 0.15);
|
||||
--amber: #d4a017;
|
||||
--amber-soft: rgba(212, 160, 23, 0.12);
|
||||
--green: #3fb950;
|
||||
--green-soft: rgba(63, 185, 80, 0.12);
|
||||
--red: #f0626a;
|
||||
--red-soft: rgba(240, 98, 106, 0.12);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--text);
|
||||
font-family: var(--sans); font-size: 14px; line-height: 1.55; }
|
||||
.app { display: grid; grid-template-rows: auto 1fr auto; height: 100vh; }
|
||||
header.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 12px 20px; background: var(--bg-elev); border-bottom: 1px solid var(--border); }
|
||||
.hdr-meta h1 { font-size: 16px; font-weight: 650; margin: 0; }
|
||||
.hdr-meta .path { color: var(--text-dim); font-size: 12px; font-family: var(--mono); margin: 2px 0 0; word-break: break-all; }
|
||||
.hdr-stats { display: flex; gap: 8px; font-size: 12px; color: var(--text-dim); }
|
||||
.pill { padding: 2px 10px; border-radius: 99px; background: var(--bg-soft); border: 1px solid var(--border); }
|
||||
.pill.accent { color: var(--accent); border-color: var(--accent); }
|
||||
main.split { display: grid; grid-template-columns: 1fr 380px; overflow: hidden; min-height: 0; }
|
||||
.doc-panel { overflow-y: auto; background: var(--bg); padding: 16px 24px 80px; }
|
||||
.doc-help { font-size: 12px; color: var(--text-dim); background: var(--bg-soft);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; margin: 0 0 18px; }
|
||||
.doc-help strong { color: var(--text); }
|
||||
.doc-content { font-family: var(--mono); font-size: 13px; line-height: 1.6; }
|
||||
.doc-line { display: grid; grid-template-columns: 44px 24px 1fr; gap: 6px;
|
||||
padding: 1px 8px 1px 0; border-left: 3px solid transparent; cursor: pointer; transition: background .08s; }
|
||||
.doc-line:hover { background: var(--accent-soft); }
|
||||
.doc-line:hover .gutter { color: var(--accent); }
|
||||
.doc-line .ln { color: var(--text-mute); text-align: right; user-select: none; font-size: 11px; padding-top: 2px; }
|
||||
.doc-line .gutter { font-size: 13px; color: transparent; text-align: center; user-select: none; line-height: 1.6; }
|
||||
.doc-line .content { white-space: pre-wrap; word-break: break-word; color: var(--text); }
|
||||
.doc-line.annotated { border-left-color: var(--amber); background: var(--amber-soft); }
|
||||
.doc-line.annotated .gutter { color: var(--amber); font-weight: 700; font-size: 11px; }
|
||||
.doc-line.annotated:hover { background: var(--amber-soft); filter: brightness(0.97); }
|
||||
.doc-line.active { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.doc-line .heading-1 { color: var(--text); font-size: 18px; font-weight: 700; }
|
||||
.doc-line .heading-2 { color: var(--text); font-size: 15px; font-weight: 700; padding-top: 6px; }
|
||||
.doc-line .heading-3 { color: var(--text); font-size: 14px; font-weight: 650; }
|
||||
.doc-line .ic { background: var(--bg-soft); padding: 0 4px; border-radius: 3px; font-size: 0.95em; }
|
||||
.doc-line .strong { font-weight: 700; }
|
||||
.doc-line .em { font-style: italic; color: var(--accent); }
|
||||
.doc-line a { color: var(--accent); text-decoration: underline; }
|
||||
.input-row { grid-column: 1 / -1; margin: 4px 0 8px 44px; padding: 8px;
|
||||
background: var(--bg-elev); border: 1px solid var(--accent); border-radius: 6px; }
|
||||
.input-row textarea { width: 100%; min-height: 60px; padding: 6px 8px;
|
||||
font-family: var(--sans); font-size: 13px; line-height: 1.4; color: var(--text);
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 4px; resize: vertical; }
|
||||
.input-row textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
||||
.input-row .input-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
|
||||
.btn { padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-dim); font-family: inherit; font-size: 12px; font-weight: 500; cursor: pointer; }
|
||||
.btn:hover { color: var(--text); border-color: var(--border-strong); }
|
||||
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.btn.primary:hover { filter: brightness(1.1); color: #fff; }
|
||||
.btn.primary:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; filter: none; border-color: var(--border); }
|
||||
.ghost-btn { background: transparent; color: var(--text-dim); border: 1px solid var(--border);
|
||||
border-radius: 5px; padding: 4px 10px; font-family: inherit; font-size: 12px; cursor: pointer; }
|
||||
.ghost-btn:hover { color: var(--red); border-color: var(--red); }
|
||||
.notes-panel { border-left: 1px solid var(--border); background: var(--bg-elev);
|
||||
display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
|
||||
.notes-header { display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.notes-header h3 { font-size: 13px; font-weight: 650; margin: 0; color: var(--text); }
|
||||
.notes-list { overflow-y: auto; padding: 12px; flex: 1; min-height: 0; }
|
||||
.notes-empty { color: var(--text-mute); font-size: 12px; text-align: center; padding: 24px 8px;
|
||||
font-style: italic; line-height: 1.5; }
|
||||
.note-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 10px 12px; margin-bottom: 10px; }
|
||||
.note-card:hover { border-color: var(--border-strong); }
|
||||
.note-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
.note-card .note-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.note-card .lineref { font-family: var(--mono); font-size: 11px; color: var(--accent); cursor: pointer; font-weight: 600; }
|
||||
.note-card .lineref:hover { text-decoration: underline; }
|
||||
.note-card .delete-btn { background: transparent; border: none; color: var(--text-mute);
|
||||
cursor: pointer; padding: 2px 6px; font-size: 12px; border-radius: 4px; }
|
||||
.note-card .delete-btn:hover { color: var(--red); background: var(--red-soft); }
|
||||
.note-card .target { font-family: var(--mono); font-size: 11px; color: var(--text-mute);
|
||||
margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note-card .note-text { font-size: 12.5px; color: var(--text); line-height: 1.45; white-space: pre-wrap; word-break: break-word; }
|
||||
.note-card textarea { width: 100%; min-height: 60px; padding: 6px 8px;
|
||||
font-family: var(--sans); font-size: 12.5px; line-height: 1.4; color: var(--text);
|
||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 4px; resize: vertical; }
|
||||
.note-card textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
||||
.note-card .edit-actions { display: flex; gap: 6px; margin-top: 6px; justify-content: flex-end; }
|
||||
.prompt-panel { border-top: 1px solid var(--border); background: var(--bg-elev);
|
||||
padding: 12px 20px 14px; display: flex; flex-direction: column; gap: 8px; max-height: 30vh; }
|
||||
.prompt-head { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--text-dim); }
|
||||
.prompt-title { font-weight: 600; color: var(--text); font-size: 13px; }
|
||||
.prompt-body { flex: 1; min-height: 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 10px 12px; font-family: var(--mono); font-size: 12px; line-height: 1.5;
|
||||
overflow-y: auto; white-space: pre-wrap; color: var(--text); margin: 0; }
|
||||
.prompt-body.empty { color: var(--text-mute); font-style: italic; }
|
||||
.copy-btn { background: var(--accent); color: #fff; border: none; border-radius: 5px;
|
||||
padding: 6px 14px; font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.copy-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.copy-btn:disabled { background: var(--bg-soft); color: var(--text-mute); cursor: not-allowed; }
|
||||
.copy-btn.copied { background: var(--green); }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-mute); }
|
||||
`.trim();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded JS app — operator annotation surface.
|
||||
// Uses concatenation (no template literals) to avoid backtick collisions
|
||||
// with the outer mjs string assembly.
|
||||
// ---------------------------------------------------------------------------
|
||||
const APP_JS = `
|
||||
const STORAGE_KEY = 'voyage-annotate:' + ARTIFACT_PATH;
|
||||
let state = { annotations: [], openInputLine: null, editingId: null, activeId: null };
|
||||
let nextId = 1;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (data && Array.isArray(data.annotations)) {
|
||||
state.annotations = data.annotations;
|
||||
nextId = (data.nextId || data.annotations.reduce(function(m, a){return Math.max(m, a.id);}, 0) + 1) || 1;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
function save() {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ annotations: state.annotations, nextId: nextId })); } catch (e) {}
|
||||
}
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function renderInline(raw) {
|
||||
// raw is the ALREADY-escaped line content
|
||||
let s = raw;
|
||||
s = s.replace(/\\\`([^\\\`]+)\\\`/g, '<span class="ic">$1</span>');
|
||||
s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<span class="strong">$1</span>');
|
||||
s = s.replace(/(^|[\\s])\\*([^*\\s][^*]*?)\\*(?=\\s|[.,;:!?]|$)/g, '$1<span class="em">$2</span>');
|
||||
s = s.replace(/\\[([^\\]]+)\\]\\(([^)\\s]+)\\)/g, function(m, t, h) {
|
||||
const safe = /^(https?:|mailto:|#)/i.test(h) ? h : '#';
|
||||
return '<a href="' + safe + '" target="_blank" rel="noopener">' + t + '</a>';
|
||||
});
|
||||
return s;
|
||||
}
|
||||
function classifyLine(raw) {
|
||||
if (/^#{1,6}\\s/.test(raw)) {
|
||||
const m = raw.match(/^(#{1,6})\\s+(.*)$/);
|
||||
return { kind: 'heading', level: m[1].length, content: m[2] };
|
||||
}
|
||||
if (raw.trim() === '') return { kind: 'blank' };
|
||||
return { kind: 'text', content: raw };
|
||||
}
|
||||
function renderDocLine(raw, lineNum) {
|
||||
const cls = classifyLine(raw);
|
||||
const escaped = escapeHtml(cls.content || raw);
|
||||
let inner = '';
|
||||
if (cls.kind === 'heading') {
|
||||
inner = '<span class="heading-' + Math.min(cls.level, 3) + '">' + renderInline(escaped) + '</span>';
|
||||
} else if (cls.kind === 'blank') {
|
||||
inner = ' ';
|
||||
} else {
|
||||
inner = renderInline(escaped);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
function getAnnotationsForLine(lineNum) {
|
||||
return state.annotations.filter(function(a){ return a.line === lineNum; });
|
||||
}
|
||||
function renderDoc() {
|
||||
const root = document.getElementById('doc');
|
||||
const html = DOC_LINES.map(function(raw, i) {
|
||||
const lineNum = i + 1;
|
||||
const anns = getAnnotationsForLine(lineNum);
|
||||
const annotated = anns.length > 0 ? ' annotated' : '';
|
||||
const active = state.activeId && anns.some(function(a){return a.id === state.activeId;}) ? ' active' : '';
|
||||
const gutter = anns.length > 0 ? String(anns.length) : '';
|
||||
const content = renderDocLine(raw, lineNum);
|
||||
let row = '<div class="doc-line' + annotated + active + '" data-line="' + lineNum + '" id="ln-' + lineNum + '">'
|
||||
+ '<span class="ln">' + lineNum + '</span>'
|
||||
+ '<span class="gutter">' + (gutter || '+') + '</span>'
|
||||
+ '<span class="content">' + content + '</span>'
|
||||
+ '</div>';
|
||||
if (state.openInputLine === lineNum) {
|
||||
const placeholder = anns.length > 0 ? 'Add another note for line ' + lineNum + '...' : 'Your note for line ' + lineNum + '...';
|
||||
row += '<div class="input-row" data-input-line="' + lineNum + '">'
|
||||
+ '<textarea id="input-' + lineNum + '" placeholder="' + escapeHtml(placeholder) + '" autofocus></textarea>'
|
||||
+ '<div class="input-actions">'
|
||||
+ '<button class="btn" data-act="cancel-input">Cancel (Esc)</button>'
|
||||
+ '<button class="btn primary" data-act="save-input" data-line="' + lineNum + '" disabled>Save (⌘Enter)</button>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
return row;
|
||||
}).join('');
|
||||
root.innerHTML = html;
|
||||
|
||||
root.querySelectorAll('.doc-line').forEach(function(el) {
|
||||
el.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.input-row')) return;
|
||||
const ln = parseInt(el.getAttribute('data-line'), 10);
|
||||
state.openInputLine = state.openInputLine === ln ? null : ln;
|
||||
state.activeId = null;
|
||||
renderAll();
|
||||
if (state.openInputLine) {
|
||||
setTimeout(function(){
|
||||
const ta = document.getElementById('input-' + ln);
|
||||
if (ta) ta.focus();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('button[data-act]').forEach(function(b) {
|
||||
b.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const act = b.dataset.act;
|
||||
if (act === 'cancel-input') { state.openInputLine = null; renderAll(); }
|
||||
else if (act === 'save-input') {
|
||||
const ln = parseInt(b.dataset.line, 10);
|
||||
const ta = document.getElementById('input-' + ln);
|
||||
const text = ta.value.trim();
|
||||
if (!text) return;
|
||||
addAnnotation(ln, text);
|
||||
}
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('textarea[id^="input-"]').forEach(function(ta) {
|
||||
ta.addEventListener('input', function() {
|
||||
const ln = parseInt(ta.id.replace('input-', ''), 10);
|
||||
const saveBtn = document.querySelector('button[data-act="save-input"][data-line="' + ln + '"]');
|
||||
if (saveBtn) saveBtn.disabled = ta.value.trim().length === 0;
|
||||
});
|
||||
ta.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') { state.openInputLine = null; renderAll(); }
|
||||
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
const ln = parseInt(ta.id.replace('input-', ''), 10);
|
||||
const text = ta.value.trim();
|
||||
if (text) addAnnotation(ln, text);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function addAnnotation(lineNum, text) {
|
||||
const raw = DOC_LINES[lineNum - 1] || '';
|
||||
const snippet = raw.trim().length > 80 ? raw.trim().slice(0, 77) + '…' : raw.trim();
|
||||
const a = { id: nextId++, line: lineNum, target: snippet || '(blank line)', text: text, ts: new Date().toISOString() };
|
||||
state.annotations.push(a);
|
||||
state.openInputLine = null;
|
||||
state.activeId = a.id;
|
||||
save();
|
||||
renderAll();
|
||||
setTimeout(function(){
|
||||
const card = document.getElementById('card-' + a.id);
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 50);
|
||||
}
|
||||
function deleteAnnotation(id) {
|
||||
state.annotations = state.annotations.filter(function(a){ return a.id !== id; });
|
||||
if (state.activeId === id) state.activeId = null;
|
||||
if (state.editingId === id) state.editingId = null;
|
||||
save();
|
||||
renderAll();
|
||||
}
|
||||
function updateAnnotation(id, text) {
|
||||
const a = state.annotations.find(function(x){ return x.id === id; });
|
||||
if (!a) return;
|
||||
if (!text.trim()) { deleteAnnotation(id); return; }
|
||||
a.text = text.trim();
|
||||
a.ts = new Date().toISOString();
|
||||
state.editingId = null;
|
||||
save();
|
||||
renderAll();
|
||||
}
|
||||
function renderNotesList() {
|
||||
const root = document.getElementById('notes-list');
|
||||
if (state.annotations.length === 0) {
|
||||
root.innerHTML = '<div class="notes-empty">No annotations yet.<br><br>Click any line on the left to add your first note.</div>';
|
||||
return;
|
||||
}
|
||||
const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; });
|
||||
root.innerHTML = sorted.map(function(a) {
|
||||
const active = a.id === state.activeId ? ' active' : '';
|
||||
const editing = a.id === state.editingId;
|
||||
return '<div class="note-card' + active + '" id="card-' + a.id + '">'
|
||||
+ '<div class="note-head">'
|
||||
+ '<span class="lineref" data-jump="' + a.line + '">Line ' + a.line + '</span>'
|
||||
+ '<button class="delete-btn" data-del="' + a.id + '" title="Delete">✕</button>'
|
||||
+ '</div>'
|
||||
+ '<p class="target">' + escapeHtml(a.target) + '</p>'
|
||||
+ (editing
|
||||
? '<textarea id="edit-' + a.id + '">' + escapeHtml(a.text) + '</textarea>'
|
||||
+ '<div class="edit-actions">'
|
||||
+ '<button class="btn" data-act="cancel-edit">Cancel</button>'
|
||||
+ '<button class="btn primary" data-act="save-edit" data-id="' + a.id + '">Save</button>'
|
||||
+ '</div>'
|
||||
: '<div class="note-text" data-edit="' + a.id + '" title="Click to edit">' + escapeHtml(a.text) + '</div>')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
root.querySelectorAll('.lineref[data-jump]').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
const ln = parseInt(el.dataset.jump, 10);
|
||||
const target = document.getElementById('ln-' + ln);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const id = parseInt(el.closest('.note-card').id.replace('card-', ''), 10);
|
||||
state.activeId = id;
|
||||
renderAll();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('button[data-del]').forEach(function(b) {
|
||||
b.addEventListener('click', function() {
|
||||
if (confirm('Delete this annotation?')) deleteAnnotation(parseInt(b.dataset.del, 10));
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('.note-text[data-edit]').forEach(function(el) {
|
||||
el.addEventListener('click', function() {
|
||||
state.editingId = parseInt(el.dataset.edit, 10);
|
||||
renderAll();
|
||||
setTimeout(function(){
|
||||
const ta = document.getElementById('edit-' + state.editingId);
|
||||
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll('button[data-act="cancel-edit"]').forEach(function(b) {
|
||||
b.addEventListener('click', function() { state.editingId = null; renderAll(); });
|
||||
});
|
||||
root.querySelectorAll('button[data-act="save-edit"]').forEach(function(b) {
|
||||
b.addEventListener('click', function() {
|
||||
const id = parseInt(b.dataset.id, 10);
|
||||
const ta = document.getElementById('edit-' + id);
|
||||
updateAnnotation(id, ta.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
function renderStats() {
|
||||
const n = state.annotations.length;
|
||||
document.getElementById('stats').innerHTML = n === 0
|
||||
? '<span class="pill">No annotations yet</span>'
|
||||
: '<span class="pill accent">' + n + ' annotation' + (n === 1 ? '' : 's') + '</span>';
|
||||
const copyBtn = document.getElementById('copy');
|
||||
copyBtn.disabled = n === 0;
|
||||
}
|
||||
function renderPrompt() {
|
||||
const out = document.getElementById('prompt');
|
||||
if (state.annotations.length === 0) {
|
||||
out.classList.add('empty');
|
||||
out.textContent = 'Click a line and add a note to generate a prompt.';
|
||||
return;
|
||||
}
|
||||
out.classList.remove('empty');
|
||||
const sorted = state.annotations.slice().sort(function(a, b){ return a.line - b.line || a.id - b.id; });
|
||||
let p = 'Please revise the voyage artifact at \`' + ARTIFACT_PATH + '\` with the operator annotations below.\\n';
|
||||
p += 'Each annotation is anchored to a specific line of the source markdown.\\n';
|
||||
p += 'Treat the operator notes as authoritative direction for what should change.\\n\\n';
|
||||
p += '## Annotations (' + state.annotations.length + ' total)\\n\\n';
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const a = sorted[i];
|
||||
p += '### Line ' + a.line + '\\n';
|
||||
p += 'Source: ' + a.target + '\\n';
|
||||
p += 'Operator note: ' + a.text + '\\n\\n';
|
||||
}
|
||||
out.textContent = p;
|
||||
}
|
||||
document.getElementById('copy').addEventListener('click', async function() {
|
||||
const txt = document.getElementById('prompt').textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(txt);
|
||||
const btn = document.getElementById('copy');
|
||||
btn.classList.add('copied');
|
||||
const old = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(function(){ btn.classList.remove('copied'); btn.textContent = old; }, 1500);
|
||||
} catch (e) { alert('Copy failed: ' + e.message); }
|
||||
});
|
||||
document.getElementById('clear-all').addEventListener('click', function() {
|
||||
if (state.annotations.length === 0) return;
|
||||
if (confirm('Remove all ' + state.annotations.length + ' annotations? This cannot be undone.')) {
|
||||
state.annotations = []; state.activeId = null; state.editingId = null;
|
||||
save(); renderAll();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && state.openInputLine !== null) {
|
||||
state.openInputLine = null;
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
function renderAll() {
|
||||
renderDoc();
|
||||
renderNotesList();
|
||||
renderStats();
|
||||
renderPrompt();
|
||||
}
|
||||
load();
|
||||
renderAll();
|
||||
`.trim();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseArgs(argv) {
|
||||
const args = { input: null, out: null, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--out') args.out = argv[++i];
|
||||
else if (a === '--help' || a === '-h') args.help = true;
|
||||
else if (!args.input) args.input = a;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function render(inputPath, outputPath) {
|
||||
if (!existsSync(inputPath)) {
|
||||
process.stderr.write('annotate: input not found: ' + inputPath + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
const text = readFileSync(inputPath, 'utf-8');
|
||||
const html = buildHtml(resolve(inputPath), text);
|
||||
const out = outputPath || inputPath.replace(/\.md$/, '.html');
|
||||
writeFileSync(out, html);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help || !args.input) {
|
||||
process.stdout.write(
|
||||
'Usage: annotate <artifact.md> [--out <file.html>]\n\n'
|
||||
+ 'Builds a self-contained operator-annotation HTML for a voyage\n'
|
||||
+ 'artifact. The operator opens the HTML, clicks lines to attach\n'
|
||||
+ 'their own notes, copies a structured prompt, pastes back into\n'
|
||||
+ 'Claude. Annotations persist in localStorage per artifact path.\n\n'
|
||||
+ 'Default output: <input-basename>.html next to input.\n',
|
||||
);
|
||||
process.exit(args.help ? 0 : 2);
|
||||
}
|
||||
const out = render(args.input, args.out);
|
||||
process.stdout.write(out + '\n');
|
||||
}
|
||||
|
||||
export { render, buildHtml, parseArgs };
|
||||
|
|
@ -430,42 +430,67 @@ test('Handover 8 deleted from HANDOVER-CONTRACTS.md (back to seven handovers)',
|
|||
assert.ok(text.includes('## Handover 7'), 'Handover 7 must remain');
|
||||
});
|
||||
|
||||
test('scripts/render-artifact.mjs no longer exists (removed in v5.0.1)', () => {
|
||||
test('scripts/render-artifact.mjs is still removed (v5.0.1 + v5.0.2)', () => {
|
||||
assert.ok(
|
||||
!existsSync(join(ROOT, 'scripts/render-artifact.mjs')),
|
||||
'scripts/render-artifact.mjs should be deleted — v5.0.1 drops the redundant standalone HTML render in favour of the /playground document-critique invocation printed by the producing commands',
|
||||
'scripts/render-artifact.mjs should be deleted — v5.0.1 dropped the standalone HTML render; v5.0.2 kept it removed (annotate.mjs is the replacement)',
|
||||
);
|
||||
});
|
||||
|
||||
test('producing commands print a literal /playground document-critique invocation', () => {
|
||||
// The exact substring must appear in each producing command's prose so the
|
||||
// operator copy-pastes a verbatim line. Drift on this is the friction point
|
||||
// that motivated v5.0.1 — fail loudly if the prose softens back to "run the
|
||||
// /playground plugin" without the literal command.
|
||||
const REQUIRED = '/playground build a document-critique playground for';
|
||||
test('scripts/annotate.mjs exists (v5.0.2 operator-annotation HTML generator)', () => {
|
||||
assert.ok(
|
||||
existsSync(join(ROOT, 'scripts/annotate.mjs')),
|
||||
'scripts/annotate.mjs is required — producing commands call it to build the operator-annotation HTML',
|
||||
);
|
||||
});
|
||||
|
||||
test('producing commands reference scripts/annotate.mjs (v5.0.2 render-and-link step)', () => {
|
||||
// v5.0.0 → v5.0.1 → v5.0.2 chain: v5.0.0 added an HTML render that didn't
|
||||
// afford annotation; v5.0.1 pointed at /playground document-critique (which
|
||||
// pre-generates Claude's suggestions, not operator-driven annotation); v5.0.2
|
||||
// ships scripts/annotate.mjs — an operator-driven annotation surface where
|
||||
// the OPERATOR clicks lines and writes their own notes. Pin the wiring.
|
||||
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
|
||||
assert.ok(
|
||||
read(`commands/${f}`).includes(REQUIRED),
|
||||
`commands/${f} must include the literal invocation "${REQUIRED}" so the operator copy-pastes it directly (v5.0.1)`,
|
||||
read(`commands/${f}`).includes('scripts/annotate.mjs'),
|
||||
`commands/${f} must invoke scripts/annotate.mjs to build the operator-annotation HTML (v5.0.2)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('producing commands no longer reference the removed scripts/render-artifact.mjs', () => {
|
||||
test('producing commands no longer print the v5.0.1 /playground document-critique line', () => {
|
||||
// v5.0.1 told operators to copy-paste "/playground build a document-critique
|
||||
// playground for X" — but that flow pre-generates Claude's suggestions. The
|
||||
// operator asked for their own annotations, not a critique of Claude's.
|
||||
// v5.0.2 removes that line from the producing commands' final report.
|
||||
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
|
||||
assert.ok(
|
||||
!read(`commands/${f}`).includes('render-artifact.mjs'),
|
||||
`commands/${f} still references scripts/render-artifact.mjs — that script was removed in v5.0.1`,
|
||||
!read(`commands/${f}`).includes('/playground build a document-critique'),
|
||||
`commands/${f} must not print the v5.0.1 /playground document-critique invocation — v5.0.2 replaces it with annotate.mjs`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('package.json no longer has an "npm run render" script (removed in v5.0.1)', () => {
|
||||
test('producing commands tell the operator the flow is THEIR own annotations', () => {
|
||||
// Pin language: every producing command's prose must mention that the
|
||||
// OPERATOR drives annotation, not Claude. Phrase variants are allowed
|
||||
// ("YOUR OWN note", "operator drives", etc.) — we look for the operator-
|
||||
// ownership signal.
|
||||
for (const f of ['trekbrief.md', 'trekplan.md', 'trekreview.md']) {
|
||||
const text = read(`commands/${f}`);
|
||||
assert.ok(
|
||||
/YOUR OWN|operator drives|your own/i.test(text),
|
||||
`commands/${f} must signal that the operator drives annotation (v5.0.2 contract)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('package.json still has no "npm run render" script (removed in v5.0.1)', () => {
|
||||
const pkg = JSON.parse(read('package.json'));
|
||||
assert.equal(
|
||||
pkg.scripts && pkg.scripts.render,
|
||||
undefined,
|
||||
'package.json scripts.render should be gone in v5.0.1',
|
||||
'package.json scripts.render should remain gone',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -479,6 +504,11 @@ test('CHANGELOG.md has v5.0.1 entry', () => {
|
|||
assert.match(cl, /## v5\.0\.1\b/, 'CHANGELOG.md must include "## v5.0.1" entry');
|
||||
});
|
||||
|
||||
test('CHANGELOG.md has v5.0.2 entry', () => {
|
||||
const cl = read('CHANGELOG.md');
|
||||
assert.match(cl, /## v5\.0\.2\b/, 'CHANGELOG.md must include "## v5.0.2" entry');
|
||||
});
|
||||
|
||||
test('CHANGELOG.md retains v4.2.0 entry (history is not rewritten)', () => {
|
||||
const cl = read('CHANGELOG.md');
|
||||
assert.match(cl, /## v4\.2\.0\b/, 'CHANGELOG.md must keep the historical "## v4.2.0" entry');
|
||||
|
|
|
|||
167
plugins/voyage/tests/scripts/annotate.test.mjs
Normal file
167
plugins/voyage/tests/scripts/annotate.test.mjs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// tests/scripts/annotate.test.mjs
|
||||
// Covers scripts/annotate.mjs — the v5.0.2 operator-annotation HTML
|
||||
// generator. The producing commands call it to print a file:// link the
|
||||
// operator opens in a browser, where they click lines, write their own
|
||||
// notes, copy a structured prompt, and paste back into Claude.
|
||||
//
|
||||
// What we pin:
|
||||
// • Output is a complete, self-contained HTML document.
|
||||
// • Zero external network references in the static HTML.
|
||||
// • The embedded inline <script> parses as valid JavaScript.
|
||||
// • Source document content + artifact path are embedded verbatim
|
||||
// (so the browser-side app can render exactly the same lines).
|
||||
// • HTML special chars and code-fence content are escaped — no raw
|
||||
// <script>-injection from the source .md.
|
||||
// • render() is deterministic — two runs produce byte-identical output.
|
||||
// • Default output path is <input-basename>.html next to the input.
|
||||
|
||||
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, render, parseArgs } from '../../scripts/annotate.mjs';
|
||||
|
||||
const SAMPLE = `---
|
||||
type: trekplan
|
||||
plan_version: "1.7"
|
||||
task: "Operator-annotation smoke test"
|
||||
slug: annotate-smoke
|
||||
---
|
||||
|
||||
# Operator-annotation smoke test
|
||||
|
||||
This is a paragraph with **bold**, \`inline code\`, and a [link](https://example.com).
|
||||
|
||||
## Steps
|
||||
|
||||
- first item
|
||||
- second item
|
||||
|
||||
\`\`\`js
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
> a blockquote
|
||||
`;
|
||||
|
||||
test('buildHtml produces a complete self-contained HTML document', () => {
|
||||
const html = buildHtml('/abs/path/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');
|
||||
assert.ok(html.includes('<script>'), 'must inline the app script');
|
||||
});
|
||||
|
||||
test('buildHtml has zero external network references in static HTML', () => {
|
||||
const html = buildHtml('/abs/path/plan.md', SAMPLE);
|
||||
// Strip the script section (the embedded clipboard-API safe link list may
|
||||
// technically include example.com in inline content; that's allowed).
|
||||
// Static HTML structure must not have any <link href=...> or <script src=...>.
|
||||
assert.ok(!/<link[^>]+href\s*=/i.test(html), 'no external <link href> stylesheets');
|
||||
assert.ok(!/<script[^>]+src\s*=/i.test(html), 'no external <script src>');
|
||||
});
|
||||
|
||||
test('buildHtml embeds the inline <script> as parseable JavaScript', () => {
|
||||
const html = buildHtml('/abs/path/plan.md', SAMPLE);
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
assert.ok(m, 'must contain a <script> block');
|
||||
// Function() parses but doesn't execute references to document/localStorage,
|
||||
// so a SyntaxError here is the only failure mode we want to surface.
|
||||
assert.doesNotThrow(() => new Function(m[1]), 'inline script must parse without SyntaxError');
|
||||
});
|
||||
|
||||
test('buildHtml embeds the artifact path and source lines verbatim', () => {
|
||||
const html = buildHtml('/abs/projects/2026-05-13-foo/brief.md', SAMPLE);
|
||||
assert.ok(html.includes('/abs/projects/2026-05-13-foo/brief.md'),
|
||||
'artifact path must appear in the HTML so the script can use it as the localStorage key + prompt context');
|
||||
// The DOC_LINES JSON literal must contain the actual content of the source.
|
||||
assert.match(html, /DOC_LINES\s*=\s*\[/);
|
||||
assert.ok(html.includes('Operator-annotation smoke test'),
|
||||
'source document content must round-trip into the embedded DOC_LINES');
|
||||
assert.ok(html.includes('inline code'),
|
||||
'inline content from the source markdown must appear in DOC_LINES');
|
||||
});
|
||||
|
||||
test('buildHtml escapes HTML metacharacters in the title (XSS surface)', () => {
|
||||
const md = '---\ntype: trekbrief\ntask: "<script>alert(1)</script>"\n---\n\n# Foo\n';
|
||||
const html = buildHtml('/abs/path/brief.md', md);
|
||||
// The raw <script> from the title must NOT appear unescaped anywhere
|
||||
// outside our own inline <script>.
|
||||
const titleMatch = html.match(/<title>([\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.ok(titleMatch[1].includes('<script>') || titleMatch[1].includes('alert'),
|
||||
'title must be HTML-escaped');
|
||||
});
|
||||
|
||||
test('buildHtml escapes source-document content so a malicious .md cannot inject code', () => {
|
||||
// The embedded DOC_LINES is a JSON literal, which already escapes anything
|
||||
// dangerous. But the script then renders content into the DOM — the inline
|
||||
// renderer escapes everything before DOM-insertion. This test pins the
|
||||
// JSON-level escaping invariant.
|
||||
const md = '# Heading\n\n<img src=x onerror="alert(1)">\n';
|
||||
const html = buildHtml('/abs/path/brief.md', md);
|
||||
// The dangerous attribute must NOT appear as a live HTML construct outside
|
||||
// a JSON string. Easiest pin: the literal substring should appear inside
|
||||
// quoted JSON (with backslash-escaped quotes), and the raw construct
|
||||
// `onerror="alert(1)"` should not appear with unescaped double quotes
|
||||
// outside the script-embedded JSON literal.
|
||||
const m = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
assert.ok(m, 'must contain script');
|
||||
// The JSON encoding of `"` is `\"`, so we expect at least the escaped form.
|
||||
assert.ok(m[1].includes('onerror=\\"alert(1)\\"') || m[1].includes("onerror=\\'alert(1)\\'"),
|
||||
'dangerous source attribute should be JSON-escaped in DOC_LINES');
|
||||
// Static HTML outside <script> must not carry the live attribute either.
|
||||
const outsideScript = html.replace(/<script>[\s\S]*?<\/script>/, '');
|
||||
assert.ok(!/onerror\s*=\s*"alert/i.test(outsideScript),
|
||||
'static HTML must not carry a live onerror attribute');
|
||||
});
|
||||
|
||||
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 output contains the operator-driven copy-paste loop affordances', () => {
|
||||
// Pin the user-visible affordances that v5.0.2 promised:
|
||||
// - Per-line click target (gutter '+' marker)
|
||||
// - "Your annotations" sidebar
|
||||
// - "Copy Prompt" button
|
||||
// - "Clear all" button (so the operator can reset state)
|
||||
// - localStorage persistence
|
||||
const html = buildHtml('/abs/path/brief.md', SAMPLE);
|
||||
assert.ok(html.includes('Your annotations'), 'must show a "Your annotations" sidebar');
|
||||
assert.ok(html.includes('Copy Prompt'), 'must have a "Copy Prompt" button');
|
||||
assert.ok(html.includes('Clear all'), 'must have a "Clear all" affordance');
|
||||
assert.ok(html.includes('localStorage'), 'must persist state in localStorage');
|
||||
assert.ok(html.includes('Click any line'), 'must tell the operator how to start');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue