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>
108 lines
4.4 KiB
JavaScript
108 lines
4.4 KiB
JavaScript
// 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 }); }
|
|
});
|