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>
115 lines
5.5 KiB
JavaScript
115 lines
5.5 KiB
JavaScript
// 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 }); }
|
|
});
|