merge: wave 1 order 2 (intent) into main

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

View file

@ -19,7 +19,7 @@
| schema_id | fields | writer_path | line_ref | v4.1 additive | PII |
|-----------|--------|-------------|----------|---------------|-----|
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:961-976 | profile, phase_models, profile_source | none |
| trekbrief-stats | ts, task, slug, mode, interview_turns, review_iterations, brief_quality, research_topics, auto_research, auto_result, intent_approved, project_dir, order_id, order_claimed_at, order_reason | commands/trekbrief.md (orchestrator-emit Phase 7; order_* added by lib/stats/stats-append.mjs) | trekbrief.md:1124-1139 | profile, phase_models, profile_source | none |
| trekresearch-stats | ts, question, mode, scope, engine, slug, project_dir, brief_path, dimensions, dimensions_baseline, dimensions_baseline_preserved, effort, conv_turns, empty_turns, unique_sources, agents_local, agents_external, gemini_used, confidence, contradictions, open_questions | commands/trekresearch.md (orchestrator-emit Stats tracking) | trekresearch.md:634-676 | profile, phase_models, parallel_agents, external_research_enabled, profile_source | none |
| trekplan-stats | ts, task, mode, slug, brief_path, project_dir, codebase_size, codebase_files, agents_deployed, deep_dives, research_briefs_used, research_scout_used, critic_verdict, guardian_verdict, outcome | commands/trekplan.md (orchestrator-emit Phase 12) | trekplan.md:805-826 | profile, phase_models, parallel_agents, profile_source | none |
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:1890-1912 | profile, phase_models, profile_source | none |

View file

@ -0,0 +1,282 @@
// tests/lib/approval-log.test.mjs
// K2 — the approval log: append-only, hash-chained, bound to the content's sha256.
// Contract (docs/bolge1 § 4, K2): `append --kind <intent|plan|review|merge|release>
// --subject <path> --sha256 <hex> [--expires <ISO>]` and `verify`; exit 0 = ok,
// 1 = broken chain / refused, 2 = could not run (never green).
//
// The module is imported dynamically so a missing module fails each test on its own.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } 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 CLI = join(ROOT, 'lib', 'util', 'approval-log.mjs');
const load = () => import(CLI);
const H1 = 'a'.repeat(64);
const H2 = 'b'.repeat(64);
function tmp() { return mkdtempSync(join(tmpdir(), 'approval-log-')); }
function cli(args, cwd) {
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' });
}
function git(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout;
}
function gitRepo() {
const d = tmp();
git(d, 'init', '-q');
git(d, 'config', 'user.name', 'Test');
git(d, 'config', 'user.email', 'test@example.invalid');
git(d, 'config', 'commit.gpgsign', 'false');
mkdirSync(join(d, '.hooks-none'));
git(d, 'config', 'core.hooksPath', '.hooks-none');
return d;
}
function logFile(repo) { return join(repo, '.claude', 'voyage', 'approvals.jsonl'); }
// ---- the test that fails first (order 2) ----
test('two appends, then one byte changed in the FIRST record → verify exits 1', () => {
const d = tmp();
try {
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 0);
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d).status, 0);
assert.equal(cli(['verify'], d).status, 0, 'an untouched chain verifies');
const p = logFile(d);
const text = readFileSync(p, 'utf8');
const i = text.indexOf('brief.md');
writeFileSync(p, text.slice(0, i) + 'B' + text.slice(i + 1)); // one byte, first record
const r = cli(['verify', '--json'], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).status, 'broken');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('a changed byte inside the stored hash field also breaks the chain', () => {
const d = tmp();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
const p = logFile(d);
const lines = readFileSync(p, 'utf8').split('\n');
const rec = JSON.parse(lines[0]);
rec.sha256 = H2; // re-bind the first approval to other content
lines[0] = JSON.stringify(rec);
writeFileSync(p, lines.join('\n'));
assert.equal(cli(['verify'], d).status, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('deleting the first record breaks the chain (prev no longer links)', () => {
const d = tmp();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
const p = logFile(d);
const lines = readFileSync(p, 'utf8').split('\n');
writeFileSync(p, lines.slice(1).join('\n'));
assert.equal(cli(['verify'], d).status, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('a record carries kind, subject, sha256, ts, prev and hash; prev links the chain', async () => {
const { readLog } = await load();
const d = tmp();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2, '--expires', '2099-01-01T00:00:00Z'], d);
const recs = readLog(d).records;
assert.equal(recs.length, 2);
assert.equal(recs[0].kind, 'intent');
assert.equal(recs[0].subject, 'brief.md');
assert.equal(recs[0].sha256, H1);
assert.equal(recs[0].prev, '0'.repeat(64));
assert.equal(recs[1].prev, recs[0].hash);
assert.equal(recs[1].expires, '2099-01-01T00:00:00.000Z');
assert.match(recs[1].ts, /^\d{4}-\d\d-\d\dT/);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('append refuses a bad kind, a bad hash and a bad expiry (exit 1), and writes nothing', () => {
const d = tmp();
try {
const bad = cli(['append', '--kind', 'vibes', '--subject', 'x', '--sha256', H1], d);
assert.equal(bad.status, 1);
assert.equal(JSON.parse(bad.stdout).appended, false, 'a refusal is reported, not a crash');
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'x', '--sha256', 'nothex'], d).status, 1);
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'x', '--sha256', H1, '--expires', 'tomorrow'], d).status, 1);
assert.equal(cli(['append', '--kind', 'intent', '--sha256', H1], d).status, 1, 'subject is required');
assert.equal(existsSync(logFile(d)), false);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('append accepts the "sha256:<hex>" form the intent stamp prints', () => {
const d = tmp();
try {
assert.equal(cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', `sha256:${H1}`], d).status, 0);
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 0);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('append refuses to extend a broken chain (exit 1)', () => {
const d = tmp();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
const p = logFile(d);
writeFileSync(p, readFileSync(p, 'utf8').replace('brief.md', 'brjef.md'));
const before = readFileSync(p, 'utf8');
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d).status, 1);
assert.equal(readFileSync(p, 'utf8'), before, 'nothing appended onto a broken chain');
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ---- verify for one subject: the gate /trekplan runs ----
test('verify --subject: the current approval passes; other content, no record, or expiry is exit 1', () => {
const d = tmp();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
const ok = cli(['verify', '--json', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
assert.equal(ok.status, 0, ok.stdout);
assert.equal(JSON.parse(ok.stdout).status, 'approved');
const other = cli(['verify', '--json', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
assert.equal(other.status, 1);
assert.equal(JSON.parse(other.stdout).status, 'mismatch');
const none = cli(['verify', '--json', '--kind', 'intent', '--subject', 'other.md', '--sha256', H1], d);
assert.equal(none.status, 1);
assert.equal(JSON.parse(none.stdout).status, 'not-logged');
cli(['append', '--kind', 'merge', '--subject', 'main', '--sha256', H2, '--expires', '2000-01-01T00:00:00Z'], d);
const old = cli(['verify', '--json', '--kind', 'merge', '--subject', 'main', '--sha256', H2], d);
assert.equal(old.status, 1);
assert.equal(JSON.parse(old.stdout).status, 'expired');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('verify --subject: the LATEST record for the subject decides (re-approval supersedes)', () => {
const d = tmp();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d).status, 0);
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('the subject is stored relative to the repo, so an absolute and a relative path match', () => {
const d = tmp();
try {
mkdirSync(join(d, 'docs'));
writeFileSync(join(d, 'docs', 'b.md'), 'x');
cli(['append', '--kind', 'intent', '--subject', join(d, 'docs', 'b.md'), '--sha256', H1], d);
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'docs/b.md', '--sha256', H1], d).status, 0);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('verify with no log: the empty chain is intact (0); a subject check is not-logged (1)', () => {
const d = tmp();
try {
assert.equal(cli(['verify'], d).status, 0);
assert.equal(cli(['verify', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d).status, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('verify that cannot run is exit 2, never green (bad args, log is a directory)', () => {
const d = tmp();
try {
assert.equal(cli(['verify', '--kind', 'intent'], d).status, 2, 'a partial subject filter is a usage error');
assert.equal(cli(['frobnicate'], d).status, 2);
mkdirSync(logFile(d), { recursive: true });
assert.equal(cli(['verify'], d).status, 2);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('--sha256-of <file> binds to the file bytes; digest prints the same hex', () => {
const d = tmp();
try {
writeFileSync(join(d, 'plan.md'), '# Plan\n');
const hex = cli(['digest', join(d, 'plan.md')], d).stdout.trim();
assert.match(hex, /^[0-9a-f]{64}$/);
assert.equal(cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256-of', 'plan.md'], d).status, 0);
assert.equal(cli(['verify', '--kind', 'plan', '--subject', 'plan.md', '--sha256', hex], d).status, 0);
writeFileSync(join(d, 'plan.md'), '# Plan, edited after approval\n');
assert.equal(cli(['verify', '--kind', 'plan', '--subject', 'plan.md', '--sha256-of', 'plan.md'], d).status, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ---- committed log: a rewrite of committed records shows; uncommitted records are listed ----
test('in git: records not yet committed are reported; a committed record removed from the file is exit 1', () => {
const d = gitRepo();
try {
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H1], d);
git(d, 'add', '.claude/voyage/approvals.jsonl');
git(d, 'commit', '-q', '-m', 'log');
cli(['append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H2], d);
const r = cli(['verify', '--json'], d);
assert.equal(r.status, 0, r.stdout);
assert.equal(JSON.parse(r.stdout).uncommitted, 1, 'the second approval is visible as not committed');
// A session that rewrites the log from scratch makes a valid chain that does not
// start with what was committed: the chain alone cannot see that, the commit can.
rmSync(logFile(d));
cli(['append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H2], d);
const w = cli(['verify', '--json'], d);
assert.equal(w.status, 1, w.stdout);
assert.equal(JSON.parse(w.stdout).status, 'diverged');
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ---- answers that are not approval (agent-skills interview-me) ----
test('«høres bra ut», «gjør som du vil» and an empty answer are not approval; "Approve" is', async () => {
const { isExplicitApproval } = await load();
for (const a of ['høres bra ut', 'Høres bra ut!', 'gjør som du vil', 'sounds good', 'Whatever you think', 'looks good', 'ok', '', ' ']) {
assert.equal(isExplicitApproval(a).approved, false, `«${a}» must not count as approval`);
}
for (const a of ['Approve', 'approve', 'Godkjenn', 'godkjent']) {
assert.equal(isExplicitApproval(a).approved, true, `«${a}» is an explicit approval`);
}
assert.equal(isExplicitApproval('').reason, 'empty');
assert.equal(isExplicitApproval('høres bra ut').reason, 'hollow');
});
test('the answer CLI: exit 0 only for an explicit approval', () => {
const d = tmp();
try {
assert.equal(cli(['answer', '--text', 'Approve'], d).status, 0);
assert.equal(cli(['answer', '--text', 'høres bra ut'], d).status, 1);
assert.equal(cli(['answer', '--text', 'gjør som du vil'], d).status, 1);
assert.equal(cli(['answer', '--text', ''], d).status, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ---- honesty and the main-module guard ----
test('the module states its trust limits (a trace, not a signature) and credits ai-native-sdlc', () => {
const src = readFileSync(CLI, 'utf8');
assert.match(src, /NOT a signature/);
assert.match(src, /same user, same machine/i);
assert.match(src, /bashebr\/ai-native-sdlc@11fb8d7 \(MIT/);
});
test('the CLI runs through a symlinked path with a space in it (realpath main-module guard)', () => {
const d = tmp();
try {
const link = join(d, 'with space');
spawnSync('ln', ['-s', join(ROOT, 'lib'), link]);
const r = spawnSync(process.execPath, [join(link, 'util', 'approval-log.mjs'), 'verify'], { cwd: d, encoding: 'utf8' });
assert.equal(r.status, 0, r.stdout + r.stderr);
} finally { rmSync(d, { recursive: true, force: true }); }
});

View file

@ -0,0 +1,115 @@
// tests/lib/artifact-commit.test.mjs
// The commit in the target repo: the approval log always, the brief or plan only
// when the operator chose `git` (operator decision 2026-10-06: «Loggen alltid,
// briefen etter valg»). Never sweeps up anything else the operator has staged.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, 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 CLI = join(ROOT, 'lib', 'util', 'artifact-commit.mjs');
const LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
const H1 = 'c'.repeat(64);
function git(cwd, ...args) {
return spawnSync('git', args, { cwd, encoding: 'utf8' });
}
function repo() {
const d = mkdtempSync(join(tmpdir(), 'artifact-commit-'));
git(d, 'init', '-q');
git(d, 'config', 'user.name', 'Test');
git(d, 'config', 'user.email', 'test@example.invalid');
git(d, 'config', 'commit.gpgsign', 'false');
mkdirSync(join(d, '.hooks-none'));
git(d, 'config', 'core.hooksPath', '.hooks-none');
writeFileSync(join(d, 'README.md'), 'x\n');
git(d, 'add', 'README.md');
git(d, 'commit', '-q', '-m', 'init');
const proj = join(d, '.claude', 'projects', '2026-10-06-toggle');
mkdirSync(proj, { recursive: true });
writeFileSync(join(proj, 'brief.md'), '# brief\n');
spawnSync(process.execPath, [LOG, 'append', '--kind', 'intent', '--subject', join(proj, 'brief.md'), '--sha256', H1], { cwd: d });
return { d, brief: join(proj, 'brief.md') };
}
function run(args, cwd) {
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8' });
}
const committedFiles = (d) => git(d, 'show', '--name-only', '--format=', 'HEAD').stdout.trim().split('\n').filter(Boolean);
test('store git: the brief and the log land in one commit, and nothing else the operator staged', () => {
const { d, brief } = repo();
try {
writeFileSync(join(d, 'unrelated.txt'), 'staged by the operator\n');
git(d, 'add', 'unrelated.txt');
const r = run(['--store', 'git', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle'], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).committed, true);
assert.deepEqual(committedFiles(d).sort(),
['.claude/projects/2026-10-06-toggle/brief.md', '.claude/voyage/approvals.jsonl']);
assert.match(git(d, 'diff', '--cached', '--name-only').stdout, /unrelated\.txt/, 'the operator\'s staged file stays staged, uncommitted');
assert.match(git(d, 'log', '-1', '--format=%s').stdout, /approve intent toggle/);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('store local: only the log is committed, and the project folder is kept out of git locally', () => {
const { d, brief } = repo();
try {
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent toggle'], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.deepEqual(committedFiles(d), ['.claude/voyage/approvals.jsonl']);
const exclude = readFileSync(join(d, '.git', 'info', 'exclude'), 'utf8');
assert.match(exclude, /^\/\.claude\/projects\/2026-10-06-toggle\/$/m);
assert.equal(git(d, 'status', '--porcelain', '--', '.claude/projects').stdout.trim(), '',
'the local-only brief does not show as untracked');
// idempotent: a second run adds no second exclude line
run(['--store', 'local', '--artifact', brief, '--message', 'again'], d);
const again = readFileSync(join(d, '.git', 'info', 'exclude'), 'utf8');
assert.equal(again.split('\n').filter((l) => l.includes('2026-10-06-toggle')).length, 1);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('nothing new to commit is exit 0 with committed:false (a re-run is not an error)', () => {
const { d, brief } = repo();
try {
assert.equal(run(['--store', 'git', '--artifact', brief, '--message', 'one'], d).status, 0);
const r = run(['--store', 'git', '--artifact', brief, '--message', 'two'], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).committed, false);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('a path the repo ignores is not forced in: exit 1 with the reason', () => {
const { d, brief } = repo();
try {
writeFileSync(join(d, '.gitignore'), '.claude/\n');
const r = run(['--store', 'git', '--artifact', brief, '--message', 'x'], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(JSON.parse(r.stdout).reason, /ignored/i);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('not a git repository: exit 2 (could not run), never a silent pass', () => {
const d = mkdtempSync(join(tmpdir(), 'artifact-commit-nogit-'));
try {
writeFileSync(join(d, 'brief.md'), 'x');
const r = run(['--store', 'git', '--artifact', join(d, 'brief.md'), '--message', 'x'], d);
assert.equal(r.status, 2, r.stdout + r.stderr);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('a missing or unknown --store is a usage error (exit 2)', () => {
const { d, brief } = repo();
try {
assert.equal(run(['--artifact', brief, '--message', 'x'], d).status, 2);
assert.equal(run(['--store', 'cloud', '--artifact', brief, '--message', 'x'], d).status, 2);
} finally { rmSync(d, { recursive: true, force: true }); }
});

View file

@ -0,0 +1,182 @@
// tests/lib/intent-prose-pins.test.mjs
// Order 2 (P1 — intent as a stored artifact): the prose pins for /trekbrief's
// ceremony by size, the question round, answers that are not approval, the
// approval log and the commit; and /trekplan's Phase 1 log gate and Phase 11
// plan approval. The command lines are EXTRACTED from the prose and executed,
// so removing a line, or pointing it at the wrong module, fails here.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, 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 read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
/** 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);
}
/** The single line in `text` that runs `lib/<mod>` with `verb`. */
function line(text, mod, verb) {
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
assert.equal(hits.length, 1, `expected exactly one \`${mod} ${verb}\` line; found ${hits.length}`);
return hits[0].trim();
}
function bash(cmd, cwd) {
return spawnSync('bash', ['-c', cmd.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT)], { cwd, encoding: 'utf8' });
}
const brief = read('commands/trekbrief.md');
const plan = read('commands/trekplan.md');
// ---- /trekbrief: ceremony by size ----
test('trekbrief: three classes, heaviest in doubt, said aloud, overridable, only upward mid-task', () => {
const p3 = section(brief, 'Phase 3');
for (const c of ['small', 'bounded', 'architectural']) assert.ok(p3.includes(`\`${c}\``), `class ${c}`);
assert.match(p3, /heaviest/i);
assert.match(p3, /aloud/i);
assert.match(p3, /override/i);
assert.match(p3, /only (ever )?(moves? )?up/i);
assert.match(p3, /`ceremony:/, 'the class is written to the brief (K1)');
});
test('trekbrief: the round — all answerable questions at once, recommendation first, no fact questions', () => {
const p3 = section(brief, 'Phase 3');
assert.match(p3, /Use `AskUserQuestion` for every question/);
assert.doesNotMatch(brief, /Ask one question at a time/, 'the one-at-a-time rule is replaced by the round');
assert.match(p3, /one round/i);
assert.match(p3, /recommend(ed|ation)[^.\n]*first/i);
assert.match(p3, /never ask[^.\n]*fact/i);
assert.match(p3, /Impact × Uncertainty/);
assert.match(p3, /[Dd]eferred/);
assert.match(p3, /next three answers/);
});
test('trekbrief: the budget for a small task is at most 5 questions and fits one AskUserQuestion call', () => {
const p3 = section(brief, 'Phase 3');
const m = p3.match(/`small`[^\n|]*\|\s*(\d+)/);
assert.ok(m, 'the budget table must give a number for `small`');
assert.ok(Number(m[1]) <= 5 && Number(m[1]) <= 4, `small budget ${m[1]} must be ≤ 5 and fit one call (max 4 questions)`);
assert.match(p3, /framing/i, 'framing is one of the round\'s questions');
assert.match(p3, /artifact_store/, 'the storage choice is one of the round\'s questions');
});
test('trekbrief: credits every borrowing of the round and the classes', () => {
const p3 = section(brief, 'Phase 3');
for (const src of ['obra/superpowers@8ca22db', 'mattpocock/skills@c55ee46', 'GitHub Spec Kit', 'addyosmani/agent-skills@9d0c60d', 'kiro.dev']) {
assert.ok(p3.includes(src), `credit for ${src}`);
}
});
test('trekbrief: hard rule 8 no longer says Phase 3 has no cap', () => {
assert.doesNotMatch(brief, /Phase 3 has no cap/);
});
// ---- /trekbrief Phase 4h: answers that are not approval, the log, the commit ----
test('trekbrief 4h: the answer check runs before the stamp, then append, then commit', () => {
const p4h = section(brief, 'Phase 4h');
const ans = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' answer'));
const stamp = p4h.indexOf('intent-approval.mjs --stamp');
const app = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' append'));
const com = p4h.indexOf(line(p4h, 'util/artifact-commit.mjs', '--store'));
assert.ok(ans >= 0 && ans < stamp && stamp < app && app < com, 'order: answer → stamp → append → commit');
assert.match(p4h, /høres bra ut/);
assert.match(p4h, /gjør som du vil/);
assert.match(p4h, /empty/i);
});
test('trekbrief 4h: the answer line, run as written, refuses «høres bra ut» and «gjør som du vil»', () => {
const p4h = section(brief, 'Phase 4h');
const start = p4h.indexOf(line(p4h, 'util/approval-log.mjs', ' answer'));
const block = p4h.slice(start, p4h.indexOf('```', start)).trim(); // the heredoc, up to the fence
const d = mkdtempSync(join(tmpdir(), 'intent-pins-'));
try {
for (const [answer, code] of [['høres bra ut', 1], ['gjør som du vil', 1], ['', 1], ['Approve', 0]]) {
const r = bash(block.split('{OPERATOR_ANSWER}').join(answer), d);
assert.equal(r.status, code, `«${answer}» → ${r.status} (${r.stdout}${r.stderr})`);
}
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('trekbrief 4h: append binds the intent hash; commit uses the operator\'s storage choice', () => {
const p4h = section(brief, 'Phase 4h');
const app = line(p4h, 'util/approval-log.mjs', ' append');
assert.match(app, /--kind intent/);
assert.match(app, /--subject "\{BRIEF_PATH\}"/);
assert.match(app, /--sha256 "\{INTENT_HASH\}"/);
const com = line(p4h, 'util/artifact-commit.mjs', '--store');
assert.match(com, /--store \{ARTIFACT_STORE\}/);
assert.match(com, /--artifact "\{BRIEF_PATH\}"/);
});
// ---- /trekplan Phase 1: the log gate ----
function gateLine() {
const read1 = plan.slice(plan.indexOf('### Read the brief'), plan.indexOf('## Phase 1.5'));
return line(read1, 'util/approval-log.mjs', ' verify');
}
test('trekplan Phase 1: the log gate sits after the marker check, before planning, and says halt', () => {
const readIdx = plan.indexOf('### Read the brief');
const check = plan.indexOf('intent-approval.mjs --check');
const gate = plan.indexOf(gateLine());
const p15 = plan.indexOf('## Phase 1.5');
assert.ok(readIdx < check && check < gate && gate < p15);
const after = plan.slice(gate, gate + 2000);
assert.match(after, /\*\*halt\*\*/i);
for (const s of ['broken', 'diverged', 'not-logged', 'mismatch']) assert.ok(after.includes(`\`${s}\``), s);
assert.match(gateLine(), /--kind intent --subject "\{brief_path\}" --sha256 "\{intent_approved_hash\}"/);
});
test('trekplan Phase 1: the gate line, run as written, stops (exit 1) when the brief\'s log is broken', () => {
const d = mkdtempSync(join(tmpdir(), 'intent-pins-gate-'));
try {
const H = 'd'.repeat(64);
const logCli = join(ROOT, 'lib', 'util', 'approval-log.mjs');
writeFileSync(join(d, 'brief.md'), 'x');
spawnSync(process.execPath, [logCli, 'append', '--kind', 'intent', '--subject', 'brief.md', '--sha256', H], { cwd: d });
spawnSync(process.execPath, [logCli, 'append', '--kind', 'plan', '--subject', 'plan.md', '--sha256', H], { cwd: d });
const cmd = gateLine().split('{brief_path}').join('brief.md').split('{intent_approved_hash}').join(`sha256:${H}`);
assert.equal(bash(cmd, d).status, 0, 'intact log with the brief\'s approval passes');
const p = join(d, '.claude', 'voyage', 'approvals.jsonl');
const text = readFileSync(p, 'utf8');
writeFileSync(p, text.replace('"intent"', '"intenT"'));
const r = bash(cmd, d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).status, 'broken');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('trekplan Phase 1: reads ceremony (K1, absent = architectural) and artifact_store (absent = local)', () => {
const read1 = plan.slice(plan.indexOf('### Read the brief'), plan.indexOf('### When the input is type:trekreview'));
assert.match(read1, /`ceremony`[^\n]*absent[^\n]*`architectural`/);
assert.match(read1, /`artifact_store`[^\n]*absent[^\n]*`local`/);
});
// ---- /trekplan Phase 11: plan approval before any handoff ----
test('trekplan Phase 11: plan approval (answer check → append plan → commit) comes before every handoff', () => {
const p11 = section(plan, 'Phase 11');
const ans = p11.indexOf(line(p11, 'util/approval-log.mjs', ' answer'));
const app = p11.indexOf(line(p11, 'util/approval-log.mjs', ' append'));
const com = p11.indexOf(line(p11, 'util/artifact-commit.mjs', '--store'));
const save = p11.indexOf('### "save"');
assert.ok(ans >= 0 && ans < app && app < com && com < save);
assert.match(line(p11, 'util/approval-log.mjs', ' append'), /--kind plan --subject "\{plan_path\}" --sha256-of "\{plan_path\}"/);
assert.match(line(p11, 'util/artifact-commit.mjs', '--store'), /--store \{artifact_store\} --artifact "\{plan_path\}"/);
assert.match(p11, /AskUserQuestion/);
});

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 }); }
});