fix(intent): M1 + M2 — the parser fails closed; --approve reaches any brief file
M1: headings inside fenced code blocks and HTML comments are text, not headings, and ## Intent / ## Goal must each appear exactly once. A second section, an example section in a code block or comment, or a '## ' line in a code block inside Intent no longer carries the old approval: check gives INVALID (duplicate) or STALE, stamp refuses a duplicate. A plain brief hashes exactly as before (pinned), so no stamped brief goes stale. Re-run of the PM's 29 probes: vi-a, vi-b, vii-a, vii-b now stop; the rest unchanged. The PM's p7c probe was a no-op (its replace string is not in the base brief); vii-c is covered by its own test, red on66e1fa1. M2: /trekbrief --approve takes <project-dir | brief-file>. New intent-approval.mjs --resolve decides: a directory → <dir>/brief.md and /trekplan --project; a file → itself and /trekplan --brief. Phase 4h and the stamp line use {BRIEF_PATH}; /trekplan's halt table names /trekbrief --approve {brief_path}, which works on both brief modes, and so does the gate's hint. HANDOVER-CONTRACTS' "open to every producer's brief" is now true. Chose fixing the path over rewording the contract because two real briefs (docs/*-brief.md) had no approval path at all. Redff760ed9/29 → green 29/29. Suite 1219: 1217 pass / 0 fail / 2 skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ff760edba2
commit
20f32bb06b
7 changed files with 129 additions and 45 deletions
|
|
@ -29,9 +29,11 @@
|
|||
// exit 0 = approved and current; exit 1 = stop (errors); exit 2 = usage
|
||||
// node lib/validators/intent-approval.mjs --stamp <brief.md>
|
||||
// writes the marker and emits `brief-approved`; exit 0 stamped, 1 refused
|
||||
// node lib/validators/intent-approval.mjs --resolve <project-dir | brief-file>
|
||||
// what `/trekbrief --approve` approves: {brief_path, project_dir, plan_command}; exit 1 = no brief
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { parseDocument } from '../util/frontmatter.mjs';
|
||||
import { issue } from '../util/result.mjs';
|
||||
|
|
@ -44,15 +46,45 @@ export const INTENT_SECTIONS = Object.freeze(['Intent', 'Goal']);
|
|||
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
||||
const STATS_FILENAME = 'trekexecute-stats.jsonl'; // where event-emit writes lifecycle events
|
||||
|
||||
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir> — the operator reads ## Intent + ## Goal and answers "Approve".';
|
||||
const APPROVE_HINT = 'Run /trekbrief --approve <project-dir | brief-file> — the operator reads ## Intent + ## Goal and answers "Approve".';
|
||||
|
||||
function extractSection(body, heading) {
|
||||
const re = new RegExp(`^##\\s+${heading}\\b.*$`, 'm');
|
||||
const m = re.exec(body);
|
||||
if (!m) return null;
|
||||
const after = body.slice(m.index + m[0].length);
|
||||
const next = after.search(/^##\s/m);
|
||||
return next === -1 ? after : after.slice(0, next);
|
||||
/**
|
||||
* Line indexes of the `## ` headings that are real headings: a `## ` line
|
||||
* inside a fenced code block or an HTML comment is text, not a heading. Seeing
|
||||
* it as one let an example `## Intent` capture the hash, or cut the real
|
||||
* section short (M1).
|
||||
*/
|
||||
function headingLines(lines) {
|
||||
const out = [];
|
||||
let fence = null; // { ch, len } of the open fence
|
||||
let inComment = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (fence) {
|
||||
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
|
||||
if (close && close[1][0] === fence.ch && close[1].length >= fence.len) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (inComment) {
|
||||
if (line.includes('-->')) inComment = false;
|
||||
continue;
|
||||
}
|
||||
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
||||
if (open) { fence = { ch: open[1][0], len: open[1].length }; continue; }
|
||||
if (/^##\s/.test(line)) out.push(i);
|
||||
const c = line.lastIndexOf('<!--');
|
||||
if (c >= 0 && line.indexOf('-->', c + 4) < 0) inComment = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every `## <heading>` section's text (one entry per occurrence). */
|
||||
function extractSections(lines, heads, heading) {
|
||||
const re = new RegExp(`^##\\s+${heading}\\b`);
|
||||
return heads.filter((i) => re.test(lines[i])).map((i) => {
|
||||
const next = heads.find((j) => j > i);
|
||||
return lines.slice(i + 1, next === undefined ? lines.length : next).join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(text) {
|
||||
|
|
@ -64,20 +96,33 @@ function normalize(text) {
|
|||
|
||||
/**
|
||||
* Hash ## Intent + ## Goal of a brief body.
|
||||
* @returns {{ hash: string|null, missing: string[] }} hash is null when a section is absent or empty.
|
||||
* @returns {{ hash: string|null, missing: string[], duplicate: string[] }} hash is null when a
|
||||
* section is absent or empty, or appears more than once (which one would the approval cover?).
|
||||
*/
|
||||
export function computeIntentHash(body) {
|
||||
const lines = (body || '').split('\n');
|
||||
const heads = headingLines(lines);
|
||||
const parts = [];
|
||||
const missing = [];
|
||||
const duplicate = [];
|
||||
for (const h of INTENT_SECTIONS) {
|
||||
const s = extractSection(body || '', h);
|
||||
const n = s === null ? '' : normalize(s);
|
||||
if (!n) missing.push(h);
|
||||
const found = extractSections(lines, heads, h);
|
||||
if (found.length > 1) duplicate.push(h);
|
||||
const n = found.length === 1 ? normalize(found[0]) : '';
|
||||
if (!n && found.length <= 1) missing.push(h);
|
||||
parts.push(n);
|
||||
}
|
||||
if (missing.length) return { hash: null, missing };
|
||||
if (missing.length || duplicate.length) return { hash: null, missing, duplicate };
|
||||
const hex = createHash('sha256').update(JSON.stringify(parts), 'utf8').digest('hex');
|
||||
return { hash: `sha256:${hex}`, missing };
|
||||
return { hash: `sha256:${hex}`, missing, duplicate };
|
||||
}
|
||||
|
||||
/** Why the approval cannot bind, in words. */
|
||||
function unbindable({ missing, duplicate }) {
|
||||
const why = [];
|
||||
if (duplicate.length) why.push(`## ${duplicate.join(' and ## ')} appears more than once`);
|
||||
if (missing.length) why.push(`## ${missing.join(' and ## ')} is missing or empty`);
|
||||
return why.join('; ');
|
||||
}
|
||||
|
||||
/** Check the approval marker of a brief's text. Returns a validator Result. */
|
||||
|
|
@ -108,12 +153,13 @@ export function checkIntentApprovalContent(text) {
|
|||
));
|
||||
return result();
|
||||
}
|
||||
const { hash, missing } = computeIntentHash(body);
|
||||
const bound = computeIntentHash(body);
|
||||
const { hash } = bound;
|
||||
if (!hash) {
|
||||
errors.push(issue(
|
||||
'BRIEF_INTENT_APPROVAL_INVALID',
|
||||
`The approval cannot bind: ## ${missing.join(' and ## ')} is missing or empty.`,
|
||||
'Write the missing section(s), then ' + APPROVE_HINT,
|
||||
`The approval cannot bind: ${unbindable(bound)}.`,
|
||||
'Leave exactly one non-empty ## Intent and one ## Goal, then ' + APPROVE_HINT,
|
||||
));
|
||||
return result();
|
||||
}
|
||||
|
|
@ -134,8 +180,9 @@ export function checkIntentApprovalContent(text) {
|
|||
export function stampIntentApproval(text, now = new Date()) {
|
||||
const doc = parseDocument(text);
|
||||
if (!doc.valid) return { stamped: false, text, reason: 'frontmatter does not parse' };
|
||||
const { hash, missing } = computeIntentHash(doc.parsed.body || '');
|
||||
if (!hash) return { stamped: false, text, reason: `## ${missing.join(' and ## ')} missing or empty` };
|
||||
const bound = computeIntentHash(doc.parsed.body || '');
|
||||
const { hash } = bound;
|
||||
if (!hash) return { stamped: false, text, reason: unbindable(bound) };
|
||||
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---)/.exec(text);
|
||||
if (!m) return { stamped: false, text, reason: 'no frontmatter block' };
|
||||
const approvedAt = now.toISOString();
|
||||
|
|
@ -157,12 +204,39 @@ export function resolveApprovalDataDir(env = process.env) {
|
|||
return resolveStatsDir(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* What `/trekbrief --approve <arg>` approves. A directory means `<dir>/brief.md`
|
||||
* (a /trekplan --project brief); a file is itself (a /trekplan --brief brief,
|
||||
* whatever its name). Before M2 only `<dir>/brief.md` was reachable.
|
||||
* @returns {{ brief_path: string, project_dir: string, plan_command: string } | { error: string }}
|
||||
*/
|
||||
export function resolveApproveTarget(arg) {
|
||||
const target = String(arg || '').replace(/\/+$/, '');
|
||||
if (!target) return { error: 'no brief given — /trekbrief --approve <project-dir | brief-file>' };
|
||||
let isDir = false;
|
||||
try { isDir = statSync(target).isDirectory(); } catch { /* absent */ }
|
||||
const briefPath = isDir ? join(target, 'brief.md') : target;
|
||||
if (!existsSync(briefPath) || statSync(briefPath).isDirectory()) {
|
||||
return { error: `no brief at ${briefPath} — run /trekbrief first` };
|
||||
}
|
||||
const projectDir = dirname(briefPath);
|
||||
const planCommand = basename(briefPath) === 'brief.md'
|
||||
? `/trekplan --project ${projectDir}`
|
||||
: `/trekplan --brief ${briefPath}`;
|
||||
return { brief_path: briefPath, project_dir: projectDir, plan_command: planCommand };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find((a) => !a.startsWith('--'));
|
||||
if (args.includes('--resolve')) {
|
||||
const r = resolveApproveTarget(file);
|
||||
process.stdout.write(JSON.stringify(r) + '\n');
|
||||
process.exit(r.error ? 1 : 0);
|
||||
}
|
||||
const mode = args.includes('--stamp') ? 'stamp' : args.includes('--check') ? 'check' : null;
|
||||
if (!file || !mode) {
|
||||
process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md>\n');
|
||||
process.stderr.write('Usage: intent-approval.mjs --check [--json] <brief.md> | --stamp <brief.md> | --resolve <project-dir | brief-file>\n');
|
||||
process.exit(2);
|
||||
}
|
||||
let text = null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue