Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.
- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
text says so (red-proof, trekexecute Sub-step B, step-implementer,
HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
already in the tree when the step began no longer counts against it, and a
plan placed outside .claude/ reads the same. The round-6 test that had the
diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
(", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
not "could not start"; the release check tells a timeout from output past
its buffer and from another signal. One reading of a stop
(criteria-runner stoppedBy) for red proof, the criteria runner and the
release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
the realpath guard and the 20 modules still on the file:// guard, which
exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
passed at once; it fails when 127 is read as could-not-run); the prose now
names the git revision `@~1` as the one leading-@ exception.
Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
7.3 KiB
JavaScript
209 lines
7.3 KiB
JavaScript
// lib/validators/next-session-prompt-validator.mjs
|
|
// Validate NEXT-SESSION-PROMPT.local.md frontmatter (Bug 3 contract).
|
|
//
|
|
// Producers (trekexecute Phase 8/2.55/4, trekendsession Phase 3) MUST write
|
|
// `produced_by:` and `produced_at:` (ISO-8601) frontmatter.
|
|
// Consumers (/trekcontinue Phase 1.5) compare two candidate files and refuse
|
|
// when producers disagree on a non-stale pair.
|
|
//
|
|
// Schema is forward-compatible: unknown frontmatter keys are tolerated.
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { issue, fail } from '../util/result.mjs';
|
|
import { splitFrontmatter, parseFrontmatter } from '../util/frontmatter.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
export const NEXT_SESSION_PROMPT_REQUIRED_FIELDS = Object.freeze(['produced_by', 'produced_at']);
|
|
|
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
export function validateNextSessionPromptContent(text) {
|
|
const split = splitFrontmatter(text);
|
|
if (!split.hasFrontmatter) {
|
|
return {
|
|
valid: true,
|
|
errors: [],
|
|
warnings: [issue(
|
|
'NEXT_SESSION_PROMPT_NO_FRONTMATTER',
|
|
'NEXT-SESSION-PROMPT.local.md has no YAML frontmatter',
|
|
'Producers should write produced_by and produced_at; legacy files are tolerated.',
|
|
)],
|
|
parsed: null,
|
|
};
|
|
}
|
|
const fm = parseFrontmatter(split.frontmatter);
|
|
if (!fm.valid) {
|
|
return { valid: false, errors: fm.errors, warnings: [], parsed: fm.parsed || null };
|
|
}
|
|
return validateNextSessionPromptObject(fm.parsed);
|
|
}
|
|
|
|
export function validateNextSessionPromptObject(parsed) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
return fail(issue('NEXT_SESSION_PROMPT_NOT_OBJECT', 'Frontmatter is not an object'));
|
|
}
|
|
|
|
for (const k of NEXT_SESSION_PROMPT_REQUIRED_FIELDS) {
|
|
if (!(k in parsed)) {
|
|
errors.push(issue(
|
|
'NEXT_SESSION_PROMPT_MISSING_FIELD',
|
|
`Required frontmatter field missing: ${k}`,
|
|
));
|
|
}
|
|
}
|
|
|
|
if (parsed.produced_at !== undefined) {
|
|
if (typeof parsed.produced_at !== 'string' || Number.isNaN(Date.parse(parsed.produced_at))) {
|
|
errors.push(issue(
|
|
'NEXT_SESSION_PROMPT_INVALID_TIMESTAMP',
|
|
`produced_at "${parsed.produced_at}" is not a valid ISO-8601 timestamp`,
|
|
));
|
|
}
|
|
}
|
|
|
|
if (parsed.produced_by !== undefined) {
|
|
if (typeof parsed.produced_by !== 'string' || parsed.produced_by.length === 0) {
|
|
errors.push(issue(
|
|
'NEXT_SESSION_PROMPT_INVALID_PRODUCER',
|
|
'produced_by must be a non-empty string',
|
|
));
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings, parsed };
|
|
}
|
|
|
|
export function validateNextSessionPrompt(filePath) {
|
|
if (!existsSync(filePath)) {
|
|
return fail(issue('NEXT_SESSION_PROMPT_NOT_FOUND', `File not found: ${filePath}`));
|
|
}
|
|
let text;
|
|
try { text = readFileSync(filePath, 'utf-8'); }
|
|
catch (e) {
|
|
return fail(issue('NEXT_SESSION_PROMPT_READ_ERROR', `Cannot read ${filePath}: ${e.message}`));
|
|
}
|
|
return validateNextSessionPromptContent(text);
|
|
}
|
|
|
|
/**
|
|
* Compare two NEXT-SESSION-PROMPT files for consistency.
|
|
* Optional state object enables state-anchored staleness check.
|
|
*
|
|
* @param {{path:string, parsed:object|null}} a
|
|
* @param {{path:string, parsed:object|null}} b
|
|
* @param {{state?: {updated_at?: string}, now?: number}} opts
|
|
*/
|
|
export function validateNextSessionPromptConsistency(a, b, opts = {}) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
const now = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
const stateUpdatedAt = opts.state && opts.state.updated_at
|
|
? Date.parse(opts.state.updated_at)
|
|
: NaN;
|
|
|
|
const stale = (cand) => {
|
|
if (!cand || !cand.parsed || !cand.parsed.produced_at) return false;
|
|
if (Number.isNaN(stateUpdatedAt)) return false;
|
|
const t = Date.parse(cand.parsed.produced_at);
|
|
if (Number.isNaN(t)) return false;
|
|
return t < stateUpdatedAt;
|
|
};
|
|
|
|
const aStale = stale(a);
|
|
const bStale = stale(b);
|
|
const aFm = a && a.parsed;
|
|
const bFm = b && b.parsed;
|
|
|
|
if (aFm && bFm) {
|
|
const producerMismatch = aFm.produced_by !== bFm.produced_by;
|
|
const bothFresh = !aStale && !bStale;
|
|
if (producerMismatch && bothFresh) {
|
|
errors.push(issue(
|
|
'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH',
|
|
`Frontmatter "produced_by" disagrees: "${aFm.produced_by}" (${a.path}) vs "${bFm.produced_by}" (${b.path})`,
|
|
'One file is stale or producers wrote conflicting frontmatter. Resolve manually.',
|
|
));
|
|
} else if (producerMismatch && (aStale || bStale)) {
|
|
const fresh = aStale ? b : a;
|
|
warnings.push(issue(
|
|
'NEXT_SESSION_PROMPT_STALE_IGNORED',
|
|
`Stale candidate ignored; using fresher prompt from ${fresh.path}`,
|
|
));
|
|
}
|
|
|
|
for (const cand of [a, b]) {
|
|
if (!cand || !cand.parsed || !cand.parsed.produced_at) continue;
|
|
const t = Date.parse(cand.parsed.produced_at);
|
|
if (Number.isNaN(t)) continue;
|
|
if (now - t > ONE_DAY_MS) {
|
|
warnings.push(issue(
|
|
'NEXT_SESSION_PROMPT_WALL_CLOCK_DRIFT',
|
|
`${cand.path} produced_at is more than 24h old (${cand.parsed.produced_at})`,
|
|
'Soft warning only. Resuming after a long pause is fine; verify state is still relevant.',
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings, parsed: { a: aFm || null, b: bFm || null } };
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const positionals = args.filter(a => !a.startsWith('--'));
|
|
const wantJson = args.includes('--json');
|
|
const consistency = args.includes('--consistency');
|
|
const stateIdx = args.indexOf('--state-file');
|
|
const stateFile = stateIdx >= 0 ? args[stateIdx + 1] : null;
|
|
|
|
function emit(r) {
|
|
if (wantJson) {
|
|
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
|
|
} else {
|
|
process.stdout.write(`next-session-prompt-validator: ${r.valid ? 'PASS' : 'FAIL'}\n`);
|
|
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
|
|
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
|
|
}
|
|
process.exit(r.valid ? 0 : 1);
|
|
}
|
|
|
|
if (consistency) {
|
|
const fileArgs = positionals;
|
|
if (fileArgs.length !== 2) {
|
|
process.stderr.write('Usage: next-session-prompt-validator.mjs --json --consistency <path-a> <path-b> [--state-file <state.json>]\n');
|
|
process.exit(2);
|
|
}
|
|
const [pathA, pathB] = fileArgs;
|
|
const ra = validateNextSessionPrompt(pathA);
|
|
const rb = validateNextSessionPrompt(pathB);
|
|
let stateObj = null;
|
|
if (stateFile) {
|
|
try {
|
|
const txt = readFileSync(stateFile, 'utf-8');
|
|
stateObj = JSON.parse(txt);
|
|
} catch (_e) {
|
|
stateObj = null;
|
|
}
|
|
}
|
|
const r = validateNextSessionPromptConsistency(
|
|
{ path: pathA, parsed: ra.parsed },
|
|
{ path: pathB, parsed: rb.parsed },
|
|
{ state: stateObj },
|
|
);
|
|
emit({
|
|
valid: r.valid && ra.valid !== false,
|
|
errors: [...(ra.errors || []), ...(rb.errors || []), ...r.errors],
|
|
warnings: [...(ra.warnings || []), ...(rb.warnings || []), ...r.warnings],
|
|
});
|
|
} else {
|
|
if (positionals.length !== 1) {
|
|
process.stderr.write('Usage: next-session-prompt-validator.mjs [--json] <NEXT-SESSION-PROMPT.local.md>\n');
|
|
process.exit(2);
|
|
}
|
|
const r = validateNextSessionPrompt(positionals[0]);
|
|
emit(r);
|
|
}
|
|
}
|