chore(voyage): release v5.0.0 — remove bespoke playground + /trekrevise + Handover 8; render produced artifacts to HTML + link, annotate via /playground

The v4.2/v4.3 bespoke playground SPA (~388 KB), the /trekrevise command,
Handover 8 (annotation → revision), 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 removed. A browser walkthrough found the playground borderline
unusable, and it duplicated the official /playground plugin's
document-critique / diff-review templates.

In their place: scripts/render-artifact.mjs — a small, zero-dependency
renderer that turns a brief/plan/review .md into a self-contained,
design-system-styled, zero-network .html (frontmatter folded into a
<details> block). /trekbrief, /trekplan, and /trekreview call it on their
last step and print the file:// link; to annotate, run /playground
(document-critique) on the .md and paste the generated prompt back.

Resolves the v4.3.1-deferred findings as moot (their target files are
deleted). npm test green: 509 tests, 507 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-12 14:05:07 +02:00
commit 916d30f63e
96 changed files with 620 additions and 14716 deletions

View file

@ -1,241 +0,0 @@
// lib/parsers/anchor-parser.mjs
// Pure I/O-free parser for v4.2 voyage:anchor markdown comments.
//
// Anchor format (block-level only, on its own line, blank line above and below):
// <!-- voyage:anchor id="ANN-NNNN" target="<heading-slug>" line="<N>" [snippet="<≤80c>"] [intent="fix|change|question|block"] -->
//
// Placement rules (validated by validateAnchorPlacement):
// - Not in list-items (Prettier #18066 progressive-whitespace bug)
// - Not inside fenced code blocks (```yaml`/```json`/etc.)
// - Not at line-start positions matching: --- frontmatter delimiter,
// manifest:, plan_version:, ### Step N:, ## <required-section>,
// 40-char hex SHA1 (review finding-IDs)
// - ID must match /^ANN-\d{4}$/
// - No duplicate IDs in same document
//
// Returns Result shape from lib/util/result.mjs.
import { issue, ok, fail } from '../util/result.mjs';
const ANCHOR_LINE_RE = /^(\s*)<!--\s*voyage:anchor\s+([^>]+?)\s*-->\s*$/;
const ATTR_RE = /(\w+)="([^"]*)"/g;
const FENCED_OPEN_RE = /^```([a-zA-Z0-9_-]*)\s*$/;
const FENCED_CLOSE_RE = /^```\s*$/;
const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s+/;
const ID_RE = /^ANN-\d{4}$/;
const FORBIDDEN_LINE_START = [
/^---\s*$/,
/^manifest:\s*$/,
/^plan_version:/,
/^brief_version:/,
/^review_version:/,
/^### Step \d+:/,
/^## (?:Intent|Goal|Success Criteria|Executive Summary|Coverage|Remediation Summary)\b/,
/^[0-9a-f]{40}$/,
];
/**
* Parse anchor attributes string (the contents between voyage:anchor and -->).
* @returns {object} attribute map
*/
function parseAttrs(s) {
const attrs = {};
let m;
ATTR_RE.lastIndex = 0;
while ((m = ATTR_RE.exec(s)) !== null) {
attrs[m[1]] = m[2];
}
return attrs;
}
/**
* Parse all anchor comments in a markdown document.
* @param {string} md
* @returns {Result} { valid, errors, warnings, parsed: Anchor[] }
*/
export function parseAnchors(md) {
if (typeof md !== 'string') {
return fail(issue('ANCHOR_INPUT', 'Input must be a string'));
}
const lines = md.split(/\r?\n/);
const anchors = [];
const errors = [];
const warnings = [];
const seenIds = new Set();
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (FENCED_OPEN_RE.test(line)) {
inFence = !inFence;
continue;
}
if (inFence && FENCED_CLOSE_RE.test(line)) {
inFence = false;
continue;
}
if (inFence) continue;
const m = line.match(ANCHOR_LINE_RE);
if (!m) continue;
const attrs = parseAttrs(m[2]);
if (!attrs.id) {
errors.push(issue('ANCHOR_MALFORMED', `Anchor at line ${i + 1} missing required id attribute`));
continue;
}
if (!ID_RE.test(attrs.id)) {
errors.push(issue('ANCHOR_BAD_ID', `Anchor id "${attrs.id}" at line ${i + 1} does not match /^ANN-\\d{4}$/`));
continue;
}
if (seenIds.has(attrs.id)) {
errors.push(issue('ANCHOR_DUPLICATE_ID', `Duplicate anchor id "${attrs.id}" at line ${i + 1}`));
continue;
}
seenIds.add(attrs.id);
if (!attrs.target) {
errors.push(issue('ANCHOR_MALFORMED', `Anchor "${attrs.id}" at line ${i + 1} missing required target attribute`));
continue;
}
if (attrs.snippet && attrs.snippet.length > 80) {
warnings.push(issue('ANCHOR_SNIPPET_TRUNCATED', `Anchor "${attrs.id}" snippet > 80 chars (${attrs.snippet.length})`));
}
if (attrs.intent && !['fix', 'change', 'question', 'block'].includes(attrs.intent)) {
warnings.push(issue('ANCHOR_BAD_INTENT', `Anchor "${attrs.id}" intent "${attrs.intent}" not in {fix|change|question|block}`));
}
anchors.push({
id: attrs.id,
target: attrs.target,
line: attrs.line ? Number.parseInt(attrs.line, 10) : null,
snippet: attrs.snippet || null,
intent: attrs.intent || null,
raw: line,
position: { line: i + 1, col: 0 },
});
}
if (errors.length > 0) return { valid: false, errors, warnings, parsed: anchors };
return { valid: true, errors: [], warnings, parsed: anchors };
}
/**
* Insert anchor comments into markdown above target lines.
* Each anchor inserted on its own line with blank line separation.
*
* @param {string} md - source markdown
* @param {Array<{id, target, line, snippet?, intent?}>} anchors - anchors to insert (sorted by line ASC)
* @returns {string} markdown with anchors injected
*/
export function addAnchors(md, anchors) {
if (typeof md !== 'string') return md;
if (!Array.isArray(anchors) || anchors.length === 0) return md;
const lines = md.split(/\r?\n/);
// Sort by line desc so insertions don't shift later line numbers
const sorted = [...anchors].sort((a, b) => (b.line || 0) - (a.line || 0));
for (const a of sorted) {
if (!a.line || a.line < 1 || a.line > lines.length + 1) continue;
const attrs = [`id="${a.id}"`, `target="${a.target}"`, `line="${a.line}"`];
if (a.snippet) attrs.push(`snippet="${a.snippet.slice(0, 80)}"`);
if (a.intent) attrs.push(`intent="${a.intent}"`);
const anchorLine = `<!-- voyage:anchor ${attrs.join(' ')} -->`;
// Insert above target line: anchorLine + blank line, then target stays
lines.splice(a.line - 1, 0, anchorLine, '');
}
return lines.join('\n');
}
/**
* Strip all voyage:anchor comments from markdown, restoring the original.
* Matches the format produced by addAnchors() anchor line + following blank.
*
* @param {string} md
* @returns {string} markdown with anchors removed
*/
export function stripAnchors(md) {
if (typeof md !== 'string') return md;
const lines = md.split(/\r?\n/);
const out = [];
for (let i = 0; i < lines.length; i++) {
if (ANCHOR_LINE_RE.test(lines[i])) {
// Skip anchor line; if next line is blank (separator inserted by addAnchors), skip it too
if (i + 1 < lines.length && lines[i + 1].trim() === '') i++;
continue;
}
out.push(lines[i]);
}
return out.join('\n');
}
/**
* Validate anchor placement against voyage's structural constraints.
* Returns errors for placement violations; does not mutate input.
*
* @param {string} md
* @param {Anchor[]} anchors
* @returns {Result}
*/
export function validateAnchorPlacement(md, anchors) {
if (typeof md !== 'string') {
return fail(issue('ANCHOR_INPUT', 'Input must be a string'));
}
const lines = md.split(/\r?\n/);
const errors = [];
// Build fenced-block ranges
const fenced = []; // [{startLine, endLine}]
let openLine = null;
for (let i = 0; i < lines.length; i++) {
if (FENCED_OPEN_RE.test(lines[i])) {
if (openLine === null) {
openLine = i;
} else {
fenced.push({ startLine: openLine, endLine: i });
openLine = null;
}
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!ANCHOR_LINE_RE.test(line)) continue;
// Inside fenced block?
for (const f of fenced) {
if (i > f.startLine && i < f.endLine) {
errors.push(issue('ANCHOR_IN_FENCED_BLOCK', `Anchor at line ${i + 1} is inside fenced code block (lines ${f.startLine + 1}-${f.endLine + 1}); move it above or below the fence`));
break;
}
}
// List item context: either the anchor line itself starts with a list-marker,
// OR the anchor line is indented (whitespace-prefixed) AND the previous
// non-empty line is a list item. v4.2 disipline: anchors must start at col 0.
if (LIST_ITEM_RE.test(line)) {
errors.push(issue('ANCHOR_IN_LIST_ITEM', `Anchor at line ${i + 1} is inside a list-item (Prettier #18066 issue — move above the list)`));
} else if (/^\s+</.test(line)) {
// Anchor line is indented — likely nested inside a list or block-quote
// Walk backwards to find the previous non-empty line
let j = i - 1;
while (j >= 0 && lines[j].trim() === '') j--;
if (j >= 0 && (LIST_ITEM_RE.test(lines[j]) || /^\s+(?:[-*+]|\d+[.)])\s/.test(lines[j]))) {
errors.push(issue('ANCHOR_IN_LIST_ITEM', `Anchor at line ${i + 1} is indented after a list-item — move to col 0 above the list`));
}
}
// Forbidden line-start collision check: the anchor itself starts with `<!--`
// so it cannot collide with these patterns directly. But if the operator
// accidentally pasted an anchor on top of a structural line, that's caught
// by the line-being-anchor check above, so explicit collision-detection
// here is for defense-in-depth on adjacent text.
// (No additional check needed — anchors have a fixed prefix.)
}
if (errors.length > 0) return { valid: false, errors, warnings: [], parsed: null };
return ok(null);
}

View file

@ -1,49 +0,0 @@
// lib/parsers/annotation-digest.mjs
// Canonical SHA-256 digest for an annotation set (v4.2).
//
// Determinism contract: two semantically identical annotation arrays
// MUST produce the same digest, regardless of input array order or
// JS object key insertion order. The digest is the first 16 hex chars
// of SHA-256 over a canonical line-joined serialization.
//
// Canonicalization rules (per risk-assessor H3):
// 1. Sort annotations ascending by `id` (lexicographic — ANN-NNNN collates correctly)
// 2. For each annotation, serialize fields in fixed order:
// id | target_artifact | target_anchor | intent | comment | timestamp
// (pipe-separated, undefined/null normalized to empty string)
// 3. Join all serialized rows with "\n"
// 4. UTF-8 encode -> SHA-256 -> first 16 hex chars
//
// Brief SC4: "annotation_digest: <sha256-prefix>" — SHA-256 (not SHA-1
// from research-05; brief wins).
import { createHash } from 'node:crypto';
const FIELD_ORDER = ['id', 'target_artifact', 'target_anchor', 'intent', 'comment', 'timestamp'];
const SEPARATOR = '|';
function normalize(v) {
if (v === null || v === undefined) return '';
return String(v);
}
/**
* Compute canonical SHA-256 digest of an annotation set.
*
* @param {Array<{id, target_artifact, target_anchor, intent?, comment?, timestamp?}>} annotations
* @returns {string} 16-char lowercase hex prefix of SHA-256
*/
export function computeAnnotationDigest(annotations) {
if (!Array.isArray(annotations)) {
throw new Error('annotations must be an array');
}
const sorted = [...annotations].sort((a, b) => {
const ai = normalize(a && a.id);
const bi = normalize(b && b.id);
return ai < bi ? -1 : ai > bi ? 1 : 0;
});
const rows = sorted.map(a => FIELD_ORDER.map(f => normalize(a && a[f])).join(SEPARATOR));
const canonical = rows.join('\n');
const hash = createHash('sha256').update(canonical, 'utf8').digest('hex');
return hash.slice(0, 16);
}

View file

@ -1,129 +0,0 @@
// lib/util/markdown-write.mjs
// Markdown frontmatter serializer + atomic markdown writer.
//
// Companion to lib/util/frontmatter.mjs (parser-only) and lib/util/atomic-write.mjs
// (JSON-only). Together they enable the /trekrevise in-place revision loop
// (v4.2): read existing artifact -> mutate frontmatter+body -> atomic write.
//
// Subset constraint mirrors the parser at lib/util/frontmatter.mjs:
// - Scalars: string, integer, float, boolean, null
// - Arrays of scalars (block-style only — no flow-style [a, b])
// - Arrays of dicts, one level deep (block-style only)
// Anything outside this subset is silently dropped or quoted as a string.
//
// Why no js-yaml: zero-deps invariant. Templates emit only this subset.
import { writeFileSync, renameSync, unlinkSync, readFileSync } from 'node:fs';
import { splitFrontmatter, parseDocument } from './frontmatter.mjs';
const SPECIAL_CHARS = /[:#\[\]{},&*!|>'"%@`]|^\s|\s$/;
function needsQuote(s) {
if (s === '' || s === 'null' || s === '~' || s === 'true' || s === 'false') return true;
if (s === '[]' || s === '{}') return true;
if (/^-?\d+(\.\d+)?$/.test(s)) return true;
if (SPECIAL_CHARS.test(s)) return true;
return false;
}
function serializeScalar(v) {
if (v === null || v === undefined) return 'null';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (typeof v === 'number') return String(v);
if (typeof v === 'string') {
if (needsQuote(v)) {
const escaped = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
return `"${escaped}"`;
}
return v;
}
return JSON.stringify(v);
}
/**
* Serialize a JS object to YAML frontmatter (subset only).
* Returns the YAML body without --- delimiters.
*/
export function serializeFrontmatter(obj) {
if (obj === null || obj === undefined || typeof obj !== 'object') return '';
const lines = [];
for (const [key, value] of Object.entries(obj)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
continue;
}
lines.push(`${key}:`);
for (const item of value) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
// Dict in list — block style, one level deep
const entries = Object.entries(item).filter(([, v]) => v !== undefined);
if (entries.length === 0) {
lines.push(` - {}`);
continue;
}
const [firstK, firstV] = entries[0];
lines.push(` - ${firstK}: ${serializeScalar(firstV)}`);
for (let i = 1; i < entries.length; i++) {
const [k, v] = entries[i];
lines.push(` ${k}: ${serializeScalar(v)}`);
}
} else {
lines.push(` - ${serializeScalar(item)}`);
}
}
} else if (value !== null && typeof value === 'object') {
// Single-level dict — emit as multi-line key: \n subkey: value
lines.push(`${key}:`);
for (const [k, v] of Object.entries(value)) {
if (v === undefined) continue;
lines.push(` ${k}: ${serializeScalar(v)}`);
}
} else {
lines.push(`${key}: ${serializeScalar(value)}`);
}
}
return lines.join('\n');
}
/**
* Atomically write a markdown file with frontmatter + body.
* Reconstructs as: ---\n{serialized}\n---\n{body}
* Single writeFileSync + renameSync for crash-safety. Body bytes preserved verbatim.
*
* @param {string} path - destination path
* @param {object} frontmatter - object to serialize as YAML frontmatter
* @param {string} body - markdown body, bytes-verbatim (no normalization)
*/
export function atomicWriteMarkdown(path, frontmatter, body) {
const yaml = serializeFrontmatter(frontmatter);
const content = `---\n${yaml}\n---\n${body}`;
const tmp = path + '.tmp';
try {
writeFileSync(tmp, content);
renameSync(tmp, path);
} catch (e) {
try { unlinkSync(tmp); } catch { /* tmp already gone */ }
throw e;
}
}
/**
* Read + parse + mutate + write atomically.
* mutator receives { frontmatter, body }, returns new { frontmatter, body }.
*
* @returns {Result} from parseDocument; if invalid, no write happens.
*/
export function readAndUpdate(path, mutator) {
const text = readFileSync(path, 'utf-8');
const doc = parseDocument(text);
if (!doc.valid) return doc;
const { frontmatter, body } = doc.parsed;
const next = mutator({ frontmatter, body });
if (!next || typeof next !== 'object') {
return { valid: false, errors: [{ code: 'MD_WRITE_MUTATOR_INVALID', message: 'mutator must return { frontmatter, body }' }], warnings: [], parsed: null };
}
atomicWriteMarkdown(path, next.frontmatter || {}, next.body || '');
return { valid: true, errors: [], warnings: [], parsed: next };
}

View file

@ -1,110 +0,0 @@
// lib/util/revision-guard.mjs
// Pre-write backup -> mutate -> atomic write -> post-write validate ->
// restore-on-fail orchestration for /trekrevise (v4.2).
//
// Extracted from commands/trekrevise.md so the rollback logic can be
// unit-tested independently of the prompt-instruction file. The command
// imports revisionGuard() and supplies the validator callback (one of
// validateBrief / validatePlan / validateReview).
//
// Behavior:
// 1. Compute sha256_before
// 2. cp path path.local.bak (backup)
// 3. readAndUpdate(path, mutator) (atomic)
// 4. validator(path) — if validator says invalid, restore from bak
// 5. delete bak on success; preserve bak + return rolled-back on failure
//
// Crash semantics: tmp+rename in atomicWriteMarkdown means a crash
// between steps 2 and 3 leaves either the original (if rename hadn't
// completed) or the new content (if rename had); bak file always reflects
// the pre-revision state so manual recovery is possible.
import { copyFileSync, unlinkSync, readFileSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { readAndUpdate } from './markdown-write.mjs';
function sha256(path) {
if (!existsSync(path)) return null;
const buf = readFileSync(path);
return createHash('sha256').update(buf).digest('hex');
}
/**
* Guard a markdown revision with pre-backup + post-validate + rollback.
*
* @param {string} path - markdown file to revise (in-place)
* @param {Function} mutator - ({frontmatter, body}) => {frontmatter, body}
* @param {Function} validator - (path) => {valid: bool, errors: [...], warnings: [...]}
* @returns {{outcome: 'applied'|'rolled-back'|'mutator-failed',
* validator_result, sha256_before, sha256_after,
* bak_path?: string, error?: string}}
*/
export function revisionGuard(path, mutator, validator) {
const sha256_before = sha256(path);
if (sha256_before === null) {
return { outcome: 'mutator-failed', error: `file does not exist: ${path}`, sha256_before: null, sha256_after: null };
}
const bak = path + '.local.bak';
if (existsSync(bak)) {
return {
outcome: 'mutator-failed',
error: `pre-existing backup at ${bak} — verify it is safe to overwrite, then delete it manually before re-running`,
sha256_before,
sha256_after: sha256_before,
bak_path: bak,
};
}
copyFileSync(path, bak);
let mutateResult;
try {
mutateResult = readAndUpdate(path, mutator);
} catch (e) {
// mutator threw — restore from bak, preserve original byte-identical
copyFileSync(bak, path);
unlinkSync(bak);
return {
outcome: 'mutator-failed',
error: `mutator threw: ${e.message}`,
sha256_before,
sha256_after: sha256(path),
};
}
if (!mutateResult.valid) {
copyFileSync(bak, path);
unlinkSync(bak);
return {
outcome: 'mutator-failed',
error: `mutator returned invalid result: ${(mutateResult.errors || []).map(e => e.code || e.message).join(', ')}`,
sha256_before,
sha256_after: sha256(path),
};
}
const validator_result = validator(path);
const sha256_after_write = sha256(path);
if (!validator_result.valid) {
// Validator failed — restore from bak
copyFileSync(bak, path);
unlinkSync(bak);
return {
outcome: 'rolled-back',
validator_result,
sha256_before,
sha256_after: sha256(path),
};
}
// Validator passed — keep new content, delete bak
unlinkSync(bak);
return {
outcome: 'applied',
validator_result,
sha256_before,
sha256_after: sha256_after_write,
};
}

View file

@ -2,11 +2,9 @@
// Validate trekbrief frontmatter + body invariants.
//
// Schema is forward-compatible: unknown top-level frontmatter keys are
// tolerated silently. Adding new optional fields (e.g. revision,
// source_annotations, annotation_digest, revision_reason from v4.2) does
// not require a brief_version bump (jf. source_findings precedent on
// trekreview). Strict-key checks are intentionally avoided so the
// /trekrevise revision-loop can extend frontmatter without re-versioning.
// tolerated silently. Strict-key checks are intentionally avoided so new
// optional fields (jf. the source_findings precedent on trekreview) can be
// added without a brief_version bump.
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';

View file

@ -3,11 +3,9 @@
// This is the JS equivalent of Phase 5.5 grep checks in planning-orchestrator.
//
// Schema is forward-compatible: unknown top-level frontmatter keys are
// tolerated silently. Adding new optional fields (e.g. revision,
// source_annotations, annotation_digest, revision_reason from v4.2) does
// not require a plan_version bump (jf. source_findings precedent). Strict-key
// checks are intentionally avoided so the /trekrevise revision-loop can
// extend frontmatter without re-versioning.
// tolerated silently. Strict-key checks are intentionally avoided so new
// optional fields (jf. the source_findings precedent) can be added without
// a plan_version bump.
import { readFileSync, existsSync } from 'node:fs';
import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs';

View file

@ -3,11 +3,9 @@
// 3-layer pattern (Content → File → CLI shim) mirroring brief-validator.
//
// Schema is forward-compatible: unknown top-level frontmatter keys are
// tolerated silently. Adding new optional fields (e.g. revision,
// source_annotations, annotation_digest, revision_reason from v4.2) does
// not require a review_version bump (jf. source_findings precedent).
// Strict-key checks are intentionally avoided so the /trekrevise
// revision-loop can extend frontmatter without re-versioning.
// tolerated silently. Strict-key checks are intentionally avoided so new
// optional fields (jf. the source_findings precedent) can be added without
// a review_version bump.
import { readFileSync, existsSync } from 'node:fs';
import { parseDocument } from '../util/frontmatter.mjs';