// 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'; 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 (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 [--state-file ]\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] \n'); process.exit(2); } const r = validateNextSessionPrompt(positionals[0]); emit(r); } }