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

@ -6,7 +6,8 @@
// optional fields (jf. the source_findings precedent on trekreview) can be
// added without a brief_version bump.
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { parseDocument } from '../util/frontmatter.mjs';
import { issue, ok, fail } from '../util/result.mjs';
import { BASE_ALLOWED_MODELS } from './profile-validator.mjs';
@ -24,6 +25,25 @@ export const BRIEF_FRAMING_VALUES = Object.freeze(['preserve', 'refine', 'replac
// v5.5 — obligatory TL;DR section (≤ 5 content lines) at the top of brief.md,
// gated at brief_version ≥ 2.2. Soft cap is a warning, not a blocker.
export const BRIEF_TLDR_MAX_LINES = 5;
// K1 — the brief's size class (ceremony by size). Optional, additive, no
// brief_version bump; absent (or invalid) reads as the heaviest class.
export const BRIEF_CEREMONY_VALUES = Object.freeze(['small', 'bounded', 'architectural']);
export const BRIEF_CEREMONY_DEFAULT = 'architectural';
// Where the brief and the plan live: committed (`git`) or kept out of git
// (`local`). The approval log is committed either way. Optional, additive;
// absent reads as `local`, so nothing is published without the operator's yes.
export const BRIEF_ARTIFACT_STORE_VALUES = Object.freeze(['git', 'local']);
export const BRIEF_ARTIFACT_STORE_DEFAULT = 'local';
/** The brief's size class; absent or invalid → architectural (never lighter in doubt). */
export function briefCeremony(fm) {
return BRIEF_CEREMONY_VALUES.includes(fm?.ceremony) ? fm.ceremony : BRIEF_CEREMONY_DEFAULT;
}
/** Where the brief and plan are stored; absent or invalid → local. */
export function briefArtifactStore(fm) {
return BRIEF_ARTIFACT_STORE_VALUES.includes(fm?.artifact_store) ? fm.artifact_store : BRIEF_ARTIFACT_STORE_DEFAULT;
}
// Extract the raw text of a `## {heading}` section body (between its heading line
// and the next `## ` heading, or end of document). Returns null if absent.
@ -115,6 +135,21 @@ export function validateBriefContent(text, opts = {}) {
));
}
if ('ceremony' in fm && !BRIEF_CEREMONY_VALUES.includes(fm.ceremony)) {
errors.push(issue(
'BRIEF_INVALID_CEREMONY',
`ceremony "${fm.ceremony}" not in [${BRIEF_CEREMONY_VALUES.join(', ')}]`,
'ceremony is the brief\'s size class; leave it out to mean architectural (the heaviest).',
));
}
if ('artifact_store' in fm && !BRIEF_ARTIFACT_STORE_VALUES.includes(fm.artifact_store)) {
errors.push(issue(
'BRIEF_INVALID_ARTIFACT_STORE',
`artifact_store "${fm.artifact_store}" not in [${BRIEF_ARTIFACT_STORE_VALUES.join(', ')}]`,
'git = the brief and plan are committed; local = kept out of git. Leave it out to mean local.',
));
}
if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') {
const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/);
if (vm) {
@ -246,7 +281,15 @@ export function validateBrief(filePath, opts = {}) {
return { ...r, parsed: { ...r.parsed, filePath } };
}
if (import.meta.url === `file://${process.argv[1]}`) {
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) {
const args = process.argv.slice(2);
const strict = !args.includes('--soft');
const minIdx = args.indexOf('--min-version');