diff --git a/scripts/agent/run-scheduler-cycle.mjs b/scripts/agent/run-scheduler-cycle.mjs index 64b2f03..d3ee006 100644 --- a/scripts/agent/run-scheduler-cycle.mjs +++ b/scripts/agent/run-scheduler-cycle.mjs @@ -256,6 +256,7 @@ async function main() { } const promptPath = path.resolve(process.cwd(), 'src/prompts', cadence, `${selectedAgent}.md`); + const runArtifactSince = new Date().toISOString(); if (schedulerConfig.handoffCommand) { const handoff = await runCommand('bash', ['-lc', schedulerConfig.handoffCommand], { env: { AGENT_PLATFORM: platform, SCHEDULER_AGENT: selectedAgent, SCHEDULER_CADENCE: cadence, SCHEDULER_PROMPT_PATH: promptPath }, @@ -272,6 +273,24 @@ async function main() { process.exit(1); } + const artifactCheck = await runCommand('node', [ + 'scripts/agent/verify-run-artifacts.mjs', + '--since', + runArtifactSince, + '--check-failure-notes', + ]); + if (artifactCheck.code !== 0) { + const detail = artifactCheck.stderr.trim() || artifactCheck.stdout.trim() || 'Artifact verification failed.'; + await writeLog({ + cadence, + agent: selectedAgent, + status: 'failed', + reason: 'Missing required run artifacts', + detail, + }); + process.exit(artifactCheck.code); + } + for (const validation of schedulerConfig.validationCommands) { const parts = validation.split(' ').filter(Boolean); if (!parts.length) continue; diff --git a/scripts/agent/verify-run-artifacts.mjs b/scripts/agent/verify-run-artifacts.mjs new file mode 100644 index 0000000..c212be5 --- /dev/null +++ b/scripts/agent/verify-run-artifacts.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const REQUIRED_ARTIFACTS = [ + { dir: 'src/context', expectedPrefix: 'CONTEXT_' }, + { dir: 'src/todo', expectedPrefix: 'TODO_' }, + { dir: 'src/decisions', expectedPrefix: 'DECISIONS_' }, + { dir: 'src/test_logs', expectedPrefix: 'TEST_LOG_' }, +]; + +function parseArgs(argv) { + const args = { + since: null, + session: null, + checkFailureNotes: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const value = argv[i]; + if (value === '--since') { + args.since = argv[i + 1] || null; + i += 1; + continue; + } + if (value === '--session') { + args.session = argv[i + 1] || null; + i += 1; + continue; + } + if (value === '--check-failure-notes') { + args.checkFailureNotes = true; + } + } + + return args; +} + +function toMs(iso) { + if (!iso) return null; + const value = Date.parse(iso); + return Number.isFinite(value) ? value : null; +} + +function isNotNeededNote(content) { + return /\bnot needed\b/i.test(content); +} + +async function listRecentFiles(dirPath, sinceMs) { + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + const filePath = path.join(dirPath, entry.name); + const stat = await fs.stat(filePath); + if (sinceMs != null && stat.mtimeMs < sinceMs) continue; + files.push({ name: entry.name, filePath, mtimeMs: stat.mtimeMs }); + } + return files.sort((a, b) => a.mtimeMs - b.mtimeMs); + } catch { + return []; + } +} + +async function hasExplicitNotNeeded(files) { + for (const file of files) { + const content = await fs.readFile(file.filePath, 'utf8').catch(() => ''); + if (isNotNeededNote(content)) return true; + } + return false; +} + +function hasExpectedArtifact(files, { expectedPrefix, session }) { + if (!files.length) return false; + if (session) { + return files.some((file) => file.name.includes(session)); + } + return files.some((file) => file.name.startsWith(expectedPrefix)); +} + +async function checkFailureTracking({ sinceMs }) { + const recentTestLogs = await listRecentFiles(path.resolve(process.cwd(), 'src/test_logs'), sinceMs); + let sawFailure = false; + + for (const file of recentTestLogs) { + const content = await fs.readFile(file.filePath, 'utf8').catch(() => ''); + if (/\b(fail|failed|failure|error)\b/i.test(content)) { + sawFailure = true; + break; + } + } + + if (!sawFailure) { + return { ok: true, message: null }; + } + + const knownIssuesStat = await fs.stat(path.resolve(process.cwd(), 'KNOWN_ISSUES.md')).catch(() => null); + const incidents = await listRecentFiles(path.resolve(process.cwd(), 'docs/agent-handoffs/incidents'), sinceMs); + + const touchedKnownIssues = Boolean(knownIssuesStat && (sinceMs == null || knownIssuesStat.mtimeMs >= sinceMs)); + const touchedIncident = incidents.length > 0; + + if (touchedKnownIssues || touchedIncident) { + return { ok: true, message: null }; + } + + return { + ok: false, + message: 'Failure-related output detected in src/test_logs but neither KNOWN_ISSUES.md nor docs/agent-handoffs/incidents/ were updated during this run.', + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const sinceMs = toMs(args.since); + + if (args.since && sinceMs == null) { + console.error(`Invalid --since value: ${args.since}`); + process.exit(2); + } + + const missing = []; + + for (const artifact of REQUIRED_ARTIFACTS) { + const dirPath = path.resolve(process.cwd(), artifact.dir); + const files = await listRecentFiles(dirPath, sinceMs); + const hasExpected = hasExpectedArtifact(files, { + expectedPrefix: artifact.expectedPrefix, + session: args.session, + }); + + if (hasExpected) continue; + + const hasNotNeeded = await hasExplicitNotNeeded(files); + if (hasNotNeeded) continue; + + const expectedName = args.session + ? `${artifact.expectedPrefix}${args.session}.md` + : `${artifact.expectedPrefix}.md`; + missing.push(`${artifact.dir}/${expectedName}`); + } + + if (args.checkFailureNotes) { + const failureTracking = await checkFailureTracking({ sinceMs }); + if (!failureTracking.ok && failureTracking.message) { + missing.push(failureTracking.message); + } + } + + if (missing.length) { + console.error('Missing required run artifacts:'); + for (const item of missing) { + console.error(`- ${item}`); + } + process.exit(1); + } + + console.log('Run artifacts verified.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});