feat(skills): voyage:annotating, the operator-annotation loop as one skill (order B)

The three producing commands name voyage:annotating where they build the page; the description of the page and the freehand revision left them. The skill: the person writes every note, Fix/Change edit only the quoted text, a Question is answered and changes nothing, the approval an edit costs is said before the edit, the page is rebuilt. Research documents on request (decision 62). Red baseline 3 runs per scenario without and with the skill; six eval cases; 11 pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-19 18:36:00 +02:00
commit 843897ae33
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
25 changed files with 550 additions and 46 deletions

View file

@ -0,0 +1,140 @@
// tests/lib/skills-annotating-prose-pins.test.mjs
// Wave 4, order B: the `annotating` skill and its wiring into the three
// producing commands (/trekbrief, /trekplan, /trekreview).
//
// What is pinned, and why:
// • the skill's form is not pinned here: the form check
// (lib/skills/skill-lint.mjs) and the routing test (lib/skills/routing.mjs)
// run over every skill in `npm test` and are the authority (contract S1);
// • the three commands ask for `voyage:annotating` by name (contract R3);
// • the long explanation of the page and the loop left the commands: the
// skill is the one source. What each command keeps is the call to
// scripts/annotate.mjs, the "not a gate" line and the printed report,
// which tests/lib/doc-consistency.test.mjs also pins;
// • /trekresearch gets no annotation step (decision 62): the skill says it
// can be used on a research document when the user asks;
// • the loop the skill carries, aimed at what the red baseline showed;
// • the skill says plainly what is deterministic and what is prose only.
// Counted as doc pins by lib/util/test-census.mjs (file name *-prose-pins).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
const flat = (t) => t.replace(/\s+/g, ' ');
const SKILL = 'skills/annotating/SKILL.md';
const skill = () => {
assert.ok(existsSync(join(ROOT, SKILL)), `${SKILL} missing`);
return read(SKILL);
};
/** Text from `## <heading>` up to the next `## ` heading. */
function section(text, heading) {
const start = text.indexOf(`\n## ${heading}`);
assert.ok(start >= 0, `missing ## ${heading}`);
const next = text.indexOf('\n## ', start + 4);
return text.slice(start, next < 0 ? undefined : next);
}
const PRODUCERS = ['trekbrief.md', 'trekplan.md', 'trekreview.md'];
// ---- the commands ask for the skill, and keep only the call and the report ----
test('pin: the three producing commands ask for voyage:annotating by name', () => {
for (const f of PRODUCERS) {
assert.ok(read(`commands/${f}`).includes('`voyage:annotating`'), `commands/${f} must name voyage:annotating`);
}
});
test('pin: the explanation the skill took over is gone from the commands', () => {
// The sentences that described the page and the loop in each command
// (1b61342: trekbrief.md 833-842, trekplan.md 1189-1197, trekreview.md
// 694-699). The page is described in the skill and the README; the loop
// ("revises freehand") is what the skill replaces.
const moved = [
/lets the operator click any line to attach/i,
/keeps a sidebar of all notes/i,
/persists state in localStorage/i,
/exposes a "Copy Prompt" button/i,
/freehand from the notes/i,
];
for (const f of PRODUCERS) {
const t = flat(read(`commands/${f}`));
for (const re of moved) assert.doesNotMatch(t, re, `commands/${f} still carries: ${re}`);
}
});
test('pin: each command keeps the call, the not-a-gate line and the printed links', () => {
for (const f of PRODUCERS) {
const t = read(`commands/${f}`);
assert.match(t, /ANNOT_HTML=\$\(node \$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/annotate\.mjs "/, `${f}: the call`);
assert.match(flat(t), /convenience, not a gate/, `${f}: a failed page never blocks`);
assert.match(t, /open file:\/\/\{\$ANNOT_HTML\}/, `${f}: the open line`);
assert.match(t, /YOUR OWN/, `${f}: the operator writes the notes`);
}
for (const f of ['trekbrief.md', 'trekplan.md']) {
assert.match(read(`commands/${f}`), /Annotation HTML:\s+file:\/\/\{\$ANNOT_HTML\}/, `${f}: the report line`);
}
});
test('pin: /trekresearch has no annotation step (decision 62)', () => {
const t = read('commands/trekresearch.md');
assert.ok(!t.includes('annotate.mjs'), 'trekresearch must not run the annotation script');
});
// ---- the skill carries the loop, aimed at what the red baseline showed ----
// Baseline (order B, 3 runs per scenario, no skill): 3 of 3 changed the plan
// because of a Question; 3 of 3 applied a Fix to steps the note did not name;
// 2 of 3 edited brief text no note asked for; 1 of 3 wrote its own critique
// next to the page; 0 of 3 named the stale approval before the edit.
test('pin: annotating builds the page with the script and leaves every note to the person', () => {
const p = flat(section(skill(), 'Process'));
assert.match(p, /node \$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/annotate\.mjs/);
assert.match(p, /The person writes the notes\. Claude writes none\./);
assert.match(p, /convenience, never a gate/);
});
test('pin: annotating — a Question is answered and the text stays as it was', () => {
const p = flat(section(skill(), 'Process'));
assert.match(p, /\*\*Question: answer it in the reply\. The text stays as it was\.\*\*/);
assert.match(p, /Only their yes turns a Question into a change/);
});
test('pin: annotating — Fix and Change edit only what the note points at; the rest is asked', () => {
const p = flat(section(skill(), 'Process'));
assert.match(p, /\*\*Fix and Change: edit the text the note points at, and only that\.\*\*/);
assert.match(p, /the same gap in another step/);
assert.match(p, /already there before the notes is reported, not fixed in passing/);
});
test('pin: annotating — the approval an edit costs is said before the edit', () => {
const p = flat(section(skill(), 'Process'));
assert.match(p, /`## Intent` or `## Goal`[^.]*before the edit, that the edit makes the approval stale/);
assert.match(p, /new sha256[^.]*`\/trekexecute` refuses it until the plan is approved again\. Say that before the edit\./);
assert.match(p, /Never stamp, log or commit an approval in this loop/);
});
test('pin: annotating — the page is built again, and a research document is covered on request', () => {
assert.match(flat(section(skill(), 'Process')), /\*\*Build the page again\*\* on the revised file/);
assert.match(flat(section(skill(), 'When to use')), /research document under `research\/`\. `\/trekresearch` does not build the page itself/);
assert.match(flat(skill()), /"Clear all"/);
});
test('pin: annotating says what is enforced and what is prose only', () => {
const e = flat(section(skill(), 'Enforced by'));
for (const p of ['tests/lib/doc-consistency.test.mjs', 'scripts/annotate.mjs', 'lib/validators/intent-approval.mjs', 'lib/util/approval-log.mjs']) {
assert.ok(e.includes(`\`${p}\``), `Enforced by must name ${p}`);
}
assert.match(e, /prose only: that the person writes every note and Claude none, that a Question changes nothing/);
});
test("pin: annotating is Voyage's own content", () => {
assert.match(flat(section(skill(), 'Credits')), /Voyage's own content: [\s\S]*Nothing here is borrowed\./);
});