diff --git a/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs b/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs new file mode 100644 index 0000000..34e2edd --- /dev/null +++ b/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs @@ -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 [--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); + } +} diff --git a/plugins/ultraplan-local/tests/validators/next-session-prompt-validator.test.mjs b/plugins/ultraplan-local/tests/validators/next-session-prompt-validator.test.mjs new file mode 100644 index 0000000..299aae6 --- /dev/null +++ b/plugins/ultraplan-local/tests/validators/next-session-prompt-validator.test.mjs @@ -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 }); + } +});