feat(ultraplan-local): next-session-prompt-validator (Bug 3 consistency check) [skip-docs]

Step 6 of v3.4.1 plan. Adds the validator quartet
(Content/Object/Consistency/CLI) for NEXT-SESSION-PROMPT.local.md
frontmatter (produced_by, produced_at). State-anchored staleness check
is the primary refusal; 24h wall-clock drift downgraded to soft warning
to avoid false positives on weekend pauses.

Internal scaffolding consumed by Step 8 (Phase 1.5 wire-up). User-facing
docs land with Step 14 (CHANGELOG + README + version bump).

Tests 335 -> 346 (+11): 9 unit + 2 CLI shim cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-04 17:34:16 +02:00
commit 46e036e1c3
2 changed files with 343 additions and 0 deletions

View file

@ -0,0 +1,208 @@
// lib/validators/next-session-prompt-validator.mjs
// Validate NEXT-SESSION-PROMPT.local.md frontmatter (Bug 3 contract).
//
// Producers (ultraexecute-local Phase 8/2.55/4, ultraplan-end-session-local
// Phase 3) MUST write `produced_by:` and `produced_at:` (ISO-8601) frontmatter.
// Consumers (/ultracontinue 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';
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',
`Producers disagree: "${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 (import.meta.url === `file://${process.argv[1]}`) {
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);
}
}

View file

@ -0,0 +1,135 @@
// tests/validators/next-session-prompt-validator.test.mjs
// Unit + CLI integration tests for lib/validators/next-session-prompt-validator.mjs.
// Covers Bug 3 contract: producer-mismatch detection + state-anchored staleness +
// 24h soft-warning + missing-frontmatter downgrade.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import {
validateNextSessionPromptContent,
validateNextSessionPromptObject,
validateNextSessionPromptConsistency,
} from '../../lib/validators/next-session-prompt-validator.mjs';
function frontmatter(producedBy, producedAt, extra = '') {
return `---\nproduced_by: ${producedBy}\nproduced_at: ${producedAt}\n${extra}---\n\n# A1 — example\n\nbody\n`;
}
test('validateNextSessionPromptContent — both consistent producers (valid)', () => {
const text = frontmatter('ultraexecute-local', '2026-05-04T16:00:00.000Z');
const r = validateNextSessionPromptContent(text);
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.produced_by, 'ultraexecute-local');
});
test('validateNextSessionPromptObject — missing produced_by is invalid', () => {
const r = validateNextSessionPromptObject({ produced_at: '2026-05-04T16:00:00Z' });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_MISSING_FIELD' && /produced_by/.test(e.message)));
});
test('validateNextSessionPromptObject — missing produced_at is invalid', () => {
const r = validateNextSessionPromptObject({ produced_by: 'ultraexecute-local' });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_MISSING_FIELD' && /produced_at/.test(e.message)));
});
test('validateNextSessionPromptObject — invalid produced_at timestamp rejected', () => {
const r = validateNextSessionPromptObject({ produced_by: 'x', produced_at: 'not-a-date' });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_INVALID_TIMESTAMP'));
});
test('validateNextSessionPromptContent — no frontmatter downgrades to warning (valid)', () => {
const r = validateNextSessionPromptContent('# Plain markdown, no frontmatter\n\ntext\n');
assert.equal(r.valid, true);
assert.ok(r.warnings.find(w => w.code === 'NEXT_SESSION_PROMPT_NO_FRONTMATTER'));
});
test('validateNextSessionPromptConsistency — producer mismatch with both fresh fails', () => {
const a = { path: '/a', parsed: { produced_by: 'ultraexecute-local', produced_at: '2026-05-04T16:00:00.000Z' } };
const b = { path: '/b', parsed: { produced_by: 'graceful-handoff', produced_at: '2026-05-04T16:05:00.000Z' } };
const state = { updated_at: '2026-05-04T15:00:00.000Z' };
const r = validateNextSessionPromptConsistency(a, b, { state, now: Date.parse('2026-05-04T16:30:00.000Z') });
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH'));
});
test('validateNextSessionPromptConsistency — state-anchored stale candidate ignored', () => {
const a = { path: '/a', parsed: { produced_by: 'graceful-handoff', produced_at: '2026-05-03T10:00:00.000Z' } };
const b = { path: '/b', parsed: { produced_by: 'ultraexecute-local', produced_at: '2026-05-04T16:05:00.000Z' } };
const state = { updated_at: '2026-05-04T16:00:00.000Z' };
const r = validateNextSessionPromptConsistency(a, b, { state, now: Date.parse('2026-05-04T16:30:00.000Z') });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.ok(r.warnings.find(w => w.code === 'NEXT_SESSION_PROMPT_STALE_IGNORED'));
});
test('validateNextSessionPromptConsistency — 24h wall-clock drift emits soft warning', () => {
const a = { path: '/a', parsed: { produced_by: 'ultraexecute-local', produced_at: '2026-05-01T16:00:00.000Z' } };
const b = { path: '/b', parsed: { produced_by: 'ultraexecute-local', produced_at: '2026-05-01T16:00:00.000Z' } };
const r = validateNextSessionPromptConsistency(a, b, { now: Date.parse('2026-05-04T16:30:00.000Z') });
assert.equal(r.valid, true);
assert.ok(r.warnings.find(w => w.code === 'NEXT_SESSION_PROMPT_WALL_CLOCK_DRIFT'));
});
test('validateNextSessionPromptConsistency — same producer, both fresh, no errors', () => {
const a = { path: '/a', parsed: { produced_by: 'ultraexecute-local', produced_at: '2026-05-04T16:00:00.000Z' } };
const b = { path: '/b', parsed: { produced_by: 'ultraexecute-local', produced_at: '2026-05-04T16:01:00.000Z' } };
const r = validateNextSessionPromptConsistency(a, b, { now: Date.parse('2026-05-04T16:30:00.000Z') });
assert.equal(r.valid, true);
assert.deepEqual(r.errors, []);
// No 24h warning: produced_at is well within 24h of `now`.
assert.deepEqual(r.warnings.filter(w => w.code === 'NEXT_SESSION_PROMPT_WALL_CLOCK_DRIFT'), []);
});
test('CLI shim — single-file mode returns JSON for valid file', () => {
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
try {
const file = join(dir, 'NEXT-SESSION-PROMPT.local.md');
writeFileSync(file, frontmatter('ultraexecute-local', '2026-05-04T16:00:00.000Z'));
const out = execFileSync(process.execPath, [
'lib/validators/next-session-prompt-validator.mjs',
'--json',
file,
], { encoding: 'utf-8' });
const parsed = JSON.parse(out);
assert.equal(parsed.valid, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('CLI shim — consistency mode flags producer mismatch', () => {
const dir = mkdtempSync(join(tmpdir(), 'nspv-cli-'));
try {
const a = join(dir, 'a.md');
const b = join(dir, 'b.md');
writeFileSync(a, frontmatter('ultraexecute-local', '2026-05-04T16:00:00.000Z'));
writeFileSync(b, frontmatter('graceful-handoff', '2026-05-04T16:01:00.000Z'));
let exitCode = 0;
let out = '';
try {
out = execFileSync(process.execPath, [
'lib/validators/next-session-prompt-validator.mjs',
'--json',
'--consistency',
a,
b,
], { encoding: 'utf-8' });
} catch (e) {
exitCode = e.status;
out = e.stdout ? e.stdout.toString() : '';
}
assert.notEqual(exitCode, 0);
const parsed = JSON.parse(out);
assert.equal(parsed.valid, false);
assert.ok(parsed.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});