feat(brief): ceremony by size, one question round, logged and committed approvals

P1 (order 2, wave 1). /trekbrief puts the task in a size class
(ceremony: small|bounded|architectural, K1; heaviest in doubt, said
aloud, overridable, only moved up) that sets the question budget and the
review cap, and asks one round of questions: facts looked up, never
asked; recommendation first; ranked by impact x uncertainty with a
visible Deferred list; stop when the next three answers can be predicted.
"høres bra ut", "gjør som du vil" and an empty answer are not approval.

K2: lib/util/approval-log.mjs, an append-only hash-chained log in the
target repo (.claude/voyage/approvals.jsonl) bound to the approved
content's sha256; append/verify (0/1/2), answer, digest. Committed by
lib/util/artifact-commit.mjs: the log always, the brief and plan only
with artifact_store: git (operator decision 2026-10-06). /trekplan
Phase 1 halts on a broken, diverged, missing or mismatched log entry;
Phase 11 logs and commits an explicit plan approval before any handoff.

Both new brief fields are optional: no brief_version bump. The
main-module guard in intent-approval.mjs and brief-validator.mjs now
compares real paths. Borrowings credited in the prose and module headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 19:44:42 +02:00
commit 5244d2e8e8
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
16 changed files with 1527 additions and 85 deletions

View file

@ -0,0 +1,108 @@
// tests/validators/brief-ceremony.test.mjs
// K1 — the brief's size class: optional `ceremony: small | bounded | architectural`;
// absent = architectural (heaviest when in doubt). Plus the optional
// `artifact_store: git | local` (operator decision 2026-10-06); absent = local,
// so nothing is published without a yes. Both are additive: no brief_version bump.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const MODULE = join(ROOT, 'lib', 'validators', 'brief-validator.mjs');
const load = () => import(MODULE);
const brief = (extra = '') => `---
type: trekbrief
brief_version: "2.2"
created: 2026-10-06
task: "Add a dark-mode toggle"
slug: dark-mode-toggle
project_dir: .claude/projects/2026-10-06-dark-mode-toggle/
research_topics: 0
research_status: complete
framing: new-direction
phase_signals_partial: true
${extra}---
# Task
## TL;DR
A toggle.
## Intent
Readers cook at night.
## Goal
A toggle.
## Success Criteria
- It persists.
`;
const codes = (r) => r.errors.map((e) => e.code);
test('ceremony: huge is rejected (BRIEF_INVALID_CEREMONY)', async () => {
const { validateBriefContent } = await load();
const r = validateBriefContent(brief('ceremony: huge\n'));
assert.equal(r.valid, false);
assert.deepEqual(codes(r), ['BRIEF_INVALID_CEREMONY']);
});
test('ceremony: small, bounded and architectural are accepted', async () => {
const { validateBriefContent } = await load();
for (const c of ['small', 'bounded', 'architectural']) {
const r = validateBriefContent(brief(`ceremony: ${c}\n`));
assert.deepEqual(codes(r), [], c);
}
});
test('ceremony absent is valid and reads as architectural (heaviest in doubt)', async () => {
const { validateBriefContent, briefCeremony, BRIEF_CEREMONY_DEFAULT } = await load();
const r = validateBriefContent(brief());
assert.deepEqual(codes(r), []);
assert.equal(BRIEF_CEREMONY_DEFAULT, 'architectural');
assert.equal(briefCeremony(r.parsed.frontmatter), 'architectural');
assert.equal(briefCeremony({ ceremony: 'small' }), 'small');
assert.equal(briefCeremony({ ceremony: 'huge' }), 'architectural', 'an invalid value never reads lighter');
});
test('artifact_store: git and local accepted, anything else rejected, absent reads as local', async () => {
const { validateBriefContent, briefArtifactStore } = await load();
assert.deepEqual(codes(validateBriefContent(brief('artifact_store: git\n'))), []);
assert.deepEqual(codes(validateBriefContent(brief('artifact_store: local\n'))), []);
assert.deepEqual(codes(validateBriefContent(brief('artifact_store: cloud\n'))), ['BRIEF_INVALID_ARTIFACT_STORE']);
assert.equal(briefArtifactStore({}), 'local');
assert.equal(briefArtifactStore({ artifact_store: 'git' }), 'git');
});
test('no brief_version bump: the template still declares 2.2 and documents both fields', () => {
const tpl = spawnSync('cat', [join(ROOT, 'templates', 'trekbrief-template.md')], { encoding: 'utf8' }).stdout;
assert.match(tpl, /^brief_version: "2\.2"$/m);
assert.match(tpl, /^ceremony: /m);
assert.match(tpl, /^artifact_store: /m);
});
test('the validator CLI runs through a symlinked path with a space (realpath main-module guard)', () => {
const d = mkdtempSync(join(tmpdir(), 'brief-ceremony-'));
try {
const link = join(d, 'with space');
spawnSync('ln', ['-s', join(ROOT, 'lib'), link]);
writeFileSync(join(d, 'b.md'), brief('ceremony: huge\n'));
const r = spawnSync(process.execPath, [join(link, 'validators', 'brief-validator.mjs'), '--json', join(d, 'b.md')], { encoding: 'utf8' });
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.ok(JSON.parse(r.stdout).errors.some((e) => e.code === 'BRIEF_INVALID_CEREMONY'));
const ia = spawnSync(process.execPath, [join(link, 'validators', 'intent-approval.mjs'), '--check', '--json', join(d, 'b.md')], { encoding: 'utf8' });
assert.equal(ia.status, 1, 'intent-approval runs (and stops on the unapproved brief) through the same path');
assert.ok(JSON.parse(ia.stdout).errors.some((e) => e.code === 'BRIEF_INTENT_NOT_APPROVED'));
} finally { rmSync(d, { recursive: true, force: true }); }
});