dsh-math-modeling-agent 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +247 -187
  2. package/package.json +34 -34
  3. package/skills/math-modeling-agent/SKILL.md +70 -69
  4. package/skills/math-modeling-agent/references/claims-evidence.md +53 -41
  5. package/skills/math-modeling-agent/references/interaction-protocol.md +165 -163
  6. package/skills/math-modeling-agent/references/report-contract.md +130 -122
  7. package/skills/math-modeling-agent/references/run-directory.md +65 -62
  8. package/skills/math-modeling-agent/references/verification-recipes.md +43 -0
  9. package/skills/math-modeling-agent/schemas/attempt.schema.json +89 -70
  10. package/skills/math-modeling-agent/schemas/evidence.schema.json +110 -0
  11. package/skills/math-modeling-agent/schemas/failure.schema.json +30 -0
  12. package/skills/math-modeling-agent/schemas/ledger.schema.json +88 -68
  13. package/skills/math-modeling-agent/schemas/run.schema.json +126 -102
  14. package/skills/math-modeling-agent/schemas/verification.schema.json +60 -0
  15. package/skills/math-modeling-agent/scripts/correction-lineage.mjs +78 -0
  16. package/skills/math-modeling-agent/scripts/evidence-store.mjs +349 -0
  17. package/skills/math-modeling-agent/scripts/failure-insights.mjs +79 -0
  18. package/skills/math-modeling-agent/scripts/input-snapshot.mjs +98 -0
  19. package/skills/math-modeling-agent/scripts/ledger-mutation.mjs +82 -0
  20. package/skills/math-modeling-agent/scripts/migration-v3.mjs +31 -0
  21. package/skills/math-modeling-agent/scripts/output-integrity.mjs +82 -0
  22. package/skills/math-modeling-agent/scripts/paper-evidence.mjs +45 -0
  23. package/skills/math-modeling-agent/scripts/report-contract.mjs +112 -0
  24. package/skills/math-modeling-agent/scripts/run-state.mjs +829 -707
  25. package/skills/math-modeling-agent/scripts/verification-recipes.mjs +52 -0
  26. package/skills/math-modeling-agent/scripts/verification-runner.mjs +8 -0
  27. package/skills/math-modeling-audit/SKILL.md +41 -41
  28. package/skills/math-modeling-audit/references/mcm-icm-final-judge.md +335 -331
  29. package/skills/math-modeling-audit/scripts/mcm-score.mjs +293 -218
  30. package/skills/math-modeling-audit/scripts/paper-final-review.mjs +40 -0
  31. package/skills/math-modeling-audit/scripts/project-initial-review.mjs +41 -0
@@ -1,707 +1,829 @@
1
- #!/usr/bin/env node
2
- import { access, appendFile, mkdir, open, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
3
- import { dirname, join, resolve } from 'node:path'
4
- import { fileURLToPath } from 'node:url'
5
- import { isDeepStrictEqual } from 'node:util'
6
-
7
- export const SCHEMA_VERSION = 2
8
- export const SUPPORTED_SCHEMA_VERSIONS = [1, 2]
9
- export const INTERACTION_DECISIONS = Object.freeze({ D1: 'routing', D2: 'assumptions', D3: 'direction', D4: 'verdict' })
10
- export const MODE_DEFAULTS = Object.freeze({
11
- fast: Object.freeze({ attempts: 2, researchQueries: 0, computeSeconds: 60 }),
12
- standard: Object.freeze({ attempts: 12, researchQueries: 12, computeSeconds: 1800 }),
13
- 'high-assurance': Object.freeze({ attempts: 24, researchQueries: 30, computeSeconds: 7200 }),
14
- })
15
-
16
- const NON_FINAL_STATES = ['TRIAGE', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'RESEARCH', 'FORK']
17
- const FINAL_STATES = ['SOLVED', 'PARTIAL', 'CONDITIONAL', 'INCONCLUSIVE', 'REFUTED', 'INFEASIBLE', 'UNIDENTIFIABLE', 'BLOCKED', 'CANCELLED']
18
- const STATUSES = [...NON_FINAL_STATES, ...FINAL_STATES]
19
- const STOP_TRANSITIONS = ['BLOCKED', 'CANCELLED']
20
- const TRANSITIONS = Object.freeze({
21
- TRIAGE: ['RESEARCH', 'SCOPE_FROZEN', ...STOP_TRANSITIONS],
22
- RESEARCH: ['SCOPE_FROZEN', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
23
- SCOPE_FROZEN: ['INPUT_PROFILED', ...STOP_TRANSITIONS],
24
- INPUT_PROFILED: ['CLAIMS_REGISTERED', ...STOP_TRANSITIONS],
25
- CLAIMS_REGISTERED: ['RESEARCH', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
26
- CANDIDATES_READY: ['ATTEMPT', ...STOP_TRANSITIONS],
27
- ATTEMPT: ['EXECUTE', ...STOP_TRANSITIONS],
28
- EXECUTE: ['VERIFY', ...STOP_TRANSITIONS],
29
- VERIFY: ['REVISE', 'RESEARCH', 'FORK', ...FINAL_STATES],
30
- REVISE: ['ATTEMPT', ...STOP_TRANSITIONS],
31
- FORK: ['ATTEMPT', ...STOP_TRANSITIONS],
32
- })
33
- const TASK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
34
- const RUN_KEYS = ['schemaVersion', 'taskId', 'mode', 'status', 'eventSequence', 'currentAttempt', 'bestCandidateId', 'budget', 'createdAt', 'updatedAt']
35
- const IMMUTABLE_RUN_KEYS = ['schemaVersion', 'taskId', 'mode', 'budget', 'createdAt']
36
- const LEDGER_KEYS = ['schemaVersion', 'taskId', 'scope', 'assumptions', 'claims', 'obligations', 'subproblems', 'candidates', 'issues']
37
- const RUN_FILE = 'run.json'
38
- const LEDGER_FILE = 'ledger.json'
39
- const EVENTS_FILE = 'events.jsonl'
40
- const MUTATION_LOCK_FILE = '.run-state.lock'
41
- const RECLAIM_GUARD_FILE = '.run-state.reclaim'
42
-
43
- function clone(value) { return JSON.parse(JSON.stringify(value)) }
44
- function now() { return new Date().toISOString() }
45
- function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
46
- function exactKeys(value, keys) { return isObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)) }
47
- function nonnegativeInteger(value) { return Number.isInteger(value) && value >= 0 }
48
- function validateBudgetShape(budget, diagnostics, prefix = 'budget') {
49
- addDiagnostic(diagnostics, isObject(budget), `${prefix} must be an object`)
50
- if (!isObject(budget)) return
51
- addDiagnostic(diagnostics, exactKeys(budget, ['attempts', 'researchQueries', 'computeSeconds']), `${prefix} has invalid fields`)
52
- addDiagnostic(diagnostics, Number.isFinite(budget.attempts) && Number.isInteger(budget.attempts) && budget.attempts >= 1, `${prefix}.attempts must be a finite integer >= 1`)
53
- addDiagnostic(diagnostics, Number.isFinite(budget.researchQueries) && nonnegativeInteger(budget.researchQueries), `${prefix}.researchQueries must be a finite nonnegative integer`)
54
- addDiagnostic(diagnostics, Number.isFinite(budget.computeSeconds) && nonnegativeInteger(budget.computeSeconds), `${prefix}.computeSeconds must be a finite nonnegative integer`)
55
- }
56
- async function readJson(path) { return JSON.parse(await readFile(path, 'utf8')) }
57
- const RENAME_DEFAULTS = Object.freeze({ attempts: 5, retryDelayMs: 5 })
58
- const WINDOWS_SHARING_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
59
- async function renameWithRetry(source, target, runtime = {}) {
60
- const options = { ...RENAME_DEFAULTS, ...(runtime.rename ?? {}) }
61
- const renameFile = runtime.fs?.rename ?? rename
62
- for (let attempt = 1; ; attempt += 1) {
63
- try { return await renameFile(source, target) }
64
- catch (error) {
65
- if (!WINDOWS_SHARING_ERRORS.has(error.code) || attempt >= options.attempts) {
66
- if (WINDOWS_SHARING_ERRORS.has(error.code)) error.message = `atomic rename failed after ${attempt} attempts: ${error.message}`
67
- throw error
68
- }
69
- await sleep(options.retryDelayMs * attempt)
70
- }
71
- }
72
- }
73
- async function unlinkWithRetry(path, unlinkFile = unlink, options = RENAME_DEFAULTS) {
74
- for (let attempt = 1; ; attempt += 1) {
75
- try { await unlinkFile(path); return true }
76
- catch (error) {
77
- if (error.code === 'ENOENT') return false
78
- if (!WINDOWS_SHARING_ERRORS.has(error.code) || attempt >= options.attempts) throw error
79
- await sleep(options.retryDelayMs * attempt)
80
- }
81
- }
82
- }
83
- async function atomicWrite(path, value, runtime = {}) {
84
- await mkdir(dirname(path), { recursive: true })
85
- const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
86
- await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
87
- try { await renameWithRetry(temporary, path, runtime) }
88
- finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error }) }
89
- }
90
- function paths(root) { return { run: join(root, RUN_FILE), ledger: join(root, LEDGER_FILE), events: join(root, EVENTS_FILE) } }
91
- const LOCK_DEFAULTS = Object.freeze({ timeoutMs: 2000, retryDelayMs: 10, staleMs: 30_000 })
92
- function sleep(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)) }
93
- function processIsAlive(pid) {
94
- try { process.kill(pid, 0); return true }
95
- catch (error) { return error.code === 'EPERM' }
96
- }
97
- function lockBusyError(operation) {
98
- const error = new Error(`LOCK_BUSY: timed out waiting for run-state lock during ${operation}`)
99
- error.code = 'LOCK_BUSY'
100
- return error
101
- }
102
- async function readLockMetadata(lockPath) {
103
- try { return JSON.parse(await readFile(lockPath, 'utf8')) } catch { return null }
104
- }
105
- async function validCompleteRunExists(root) {
106
- try {
107
- const p = paths(root)
108
- validateAuthoritativeState(await readJson(p.run), await readJson(p.ledger), await readEvents(p.events))
109
- return true
110
- } catch { return false }
111
- }
112
- async function cleanupDeadInitAttempt(root, metadata) {
113
- if (metadata?.operation !== 'init' || await validCompleteRunExists(root)) return
114
- const owned = Array.isArray(metadata.ownedArtifacts) ? metadata.ownedArtifacts : []
115
- for (const file of owned) {
116
- if ([RUN_FILE, LEDGER_FILE, EVENTS_FILE].includes(file)) await unlink(join(root, file)).catch(error => { if (error.code !== 'ENOENT') throw error })
117
- }
118
- if (typeof metadata.stagingDir === 'string' && metadata.stagingDir.startsWith('.run-state-init-')) {
119
- const stagingRoot = join(root, metadata.stagingDir)
120
- for (const file of [RUN_FILE, LEDGER_FILE, EVENTS_FILE]) await unlink(join(stagingRoot, file)).catch(error => { if (error.code !== 'ENOENT') throw error })
121
- await rmdir(stagingRoot).catch(error => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error })
122
- }
123
- }
124
- async function reclaimGuardIsBusy(path, runtime = {}) {
125
- const accessFile = runtime.fs?.access ?? access
126
- try { await accessFile(path); return true }
127
- catch (error) {
128
- if (error.code === 'ENOENT') return false
129
- if (WINDOWS_SHARING_ERRORS.has(error.code)) return true
130
- throw error
131
- }
132
- }
133
- function sameLockGeneration(observedMetadata, observedStat, currentMetadata, currentStat) {
134
- if (observedMetadata === null || currentMetadata === null) {
135
- return observedMetadata === null && currentMetadata === null
136
- && observedStat.dev === currentStat.dev
137
- && observedStat.ino === currentStat.ino
138
- && observedStat.size === currentStat.size
139
- && observedStat.mtimeMs === currentStat.mtimeMs
140
- }
141
- return typeof observedMetadata.ownerId === 'string'
142
- && currentMetadata.ownerId === observedMetadata.ownerId
143
- && observedStat.dev === currentStat.dev
144
- && observedStat.ino === currentStat.ino
145
- && observedStat.size === currentStat.size
146
- && observedStat.mtimeMs === currentStat.mtimeMs
147
- }
148
- function demonstrablyDeadAndStale(metadata, lockStat, lockOptions) {
149
- const acquiredAt = Date.parse(metadata?.acquiredAt)
150
- const staleSince = Number.isFinite(acquiredAt) ? acquiredAt : lockStat.mtimeMs
151
- return typeof metadata?.ownerId === 'string'
152
- && Number.isInteger(metadata.pid)
153
- && Date.now() - staleSince >= lockOptions.staleMs
154
- && !lockOptions.isProcessAlive(metadata.pid)
155
- }
156
-
157
- function isReclaimableLock(metadata, lockStat, lockOptions) {
158
- if (metadata === null) return Date.now() - lockStat.mtimeMs >= lockOptions.staleMs
159
- return demonstrablyDeadAndStale(metadata, lockStat, lockOptions)
160
- }
161
-
162
- async function reclaimStaleGuard(guardPath, lockOptions, runtime = {}) {
163
- const statFile = runtime.fs?.stat ?? stat
164
- const unlinkFile = runtime.fs?.unlink ?? unlink
165
- try {
166
- const guardStat = await statFile(guardPath)
167
- if (Date.now() - guardStat.mtimeMs < lockOptions.staleMs) return false
168
- const guardMetadata = await readLockMetadata(guardPath)
169
- if (guardMetadata !== null && typeof guardMetadata.ownerId !== 'string') return false
170
- await unlinkWithRetry(guardPath, unlinkFile)
171
- return true
172
- } catch (error) {
173
- if (error.code === 'ENOENT') return true
174
- throw error
175
- }
176
- }
177
- async function reclaimDeadStaleLock(root, lockPath, guardPath, lockOptions) {
178
- let observedMetadata, observedStat
179
- try { [observedMetadata, observedStat] = await Promise.all([readLockMetadata(lockPath), stat(lockPath)]) }
180
- catch (error) { if (error.code === 'ENOENT') return true; throw error }
181
- if (!isReclaimableLock(observedMetadata, observedStat, lockOptions)) return false
182
- const guardOwnerId = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
183
- let guard
184
- try {
185
- guard = await open(guardPath, 'wx')
186
- await guard.writeFile(JSON.stringify({ ownerId: guardOwnerId }), 'utf8')
187
- } catch (error) {
188
- if (guard) {
189
- await guard.close()
190
- const currentGuard = await readLockMetadata(guardPath)
191
- if (currentGuard?.ownerId === guardOwnerId) await unlinkWithRetry(guardPath)
192
- }
193
- if (['EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) return false
194
- throw error
195
- }
196
- try {
197
- let currentMetadata, currentStat
198
- try { [currentMetadata, currentStat] = await Promise.all([readLockMetadata(lockPath), stat(lockPath)]) }
199
- catch (error) { if (error.code === 'ENOENT') return true; throw error }
200
- if (!sameLockGeneration(observedMetadata, observedStat, currentMetadata, currentStat)) return false
201
- if (!isReclaimableLock(currentMetadata, currentStat, lockOptions)) return false
202
- await cleanupDeadInitAttempt(root, currentMetadata)
203
- const finalMetadata = await readLockMetadata(lockPath)
204
- if (finalMetadata?.ownerId !== observedMetadata?.ownerId) return false
205
- await unlinkWithRetry(lockPath)
206
- return true
207
- } finally {
208
- await guard.close()
209
- const currentGuard = await readLockMetadata(guardPath)
210
- if (currentGuard?.ownerId === guardOwnerId) await unlinkWithRetry(guardPath)
211
- }
212
- }
213
- async function withLock(root, file, operationName, operation, runtime = {}) {
214
- await mkdir(root, { recursive: true })
215
- const lockPath = join(root, file)
216
- const guardPath = join(root, RECLAIM_GUARD_FILE)
217
- const lockOptions = { ...LOCK_DEFAULTS, ...(runtime.lock ?? {}), isProcessAlive: runtime.lock?.isProcessAlive ?? processIsAlive }
218
- const deadline = Date.now() + lockOptions.timeoutMs
219
- const ownerId = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
220
- let lock
221
- let metadata = { pid: process.pid, acquiredAt: now(), operation: operationName, ownerId }
222
- while (!lock) {
223
- if (await reclaimGuardIsBusy(guardPath, runtime)) {
224
- if (await reclaimStaleGuard(guardPath, lockOptions, runtime)) {
225
- if (Date.now() >= deadline) throw lockBusyError(operationName)
226
- await sleep(lockOptions.retryDelayMs)
227
- continue
228
- }
229
- if (Date.now() >= deadline) throw lockBusyError(operationName)
230
- await sleep(lockOptions.retryDelayMs)
231
- continue
232
- }
233
- try {
234
- const candidate = await open(lockPath, 'wx')
235
- if (await reclaimGuardIsBusy(guardPath, runtime)) {
236
- await candidate.close()
237
- await unlinkWithRetry(lockPath)
238
- if (await reclaimStaleGuard(guardPath, lockOptions, runtime)) {
239
- if (Date.now() >= deadline) throw lockBusyError(operationName)
240
- await sleep(lockOptions.retryDelayMs)
241
- continue
242
- }
243
- if (Date.now() >= deadline) throw lockBusyError(operationName)
244
- await sleep(lockOptions.retryDelayMs)
245
- continue
246
- }
247
- lock = candidate
248
- await lock.writeFile(JSON.stringify(metadata), 'utf8')
249
- } catch (error) {
250
- if (!['EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error
251
- if (error.code === 'EEXIST' && await reclaimDeadStaleLock(root, lockPath, guardPath, lockOptions)) continue
252
- if (Date.now() >= deadline) throw lockBusyError(operationName)
253
- await sleep(lockOptions.retryDelayMs)
254
- }
255
- }
256
- const updateMetadata = async patch => {
257
- metadata = { ...metadata, ...patch }
258
- await writeFile(lockPath, JSON.stringify(metadata), 'utf8')
259
- }
260
- try {
261
- await runtime.hooks?.afterLockAcquired?.({ operation: operationName, ownerId })
262
- return await operation({ ownerId, updateMetadata })
263
- } finally {
264
- await lock.close()
265
- const current = await readLockMetadata(lockPath)
266
- if (current?.ownerId === ownerId) await unlinkWithRetry(lockPath)
267
- }
268
- }
269
- async function assertRunFilesDoNotExist(p) {
270
- for (const path of [p.run, p.ledger, p.events]) {
271
- try { await access(path); throw new Error('contracted run file already exists') }
272
- catch (error) { if (error.code !== 'ENOENT') throw error }
273
- }
274
- }
275
- function assertTaskId(taskId) { if (!TASK_ID.test(taskId ?? '')) throw new Error('taskId must be kebab-case') }
276
- function assertMode(mode) { if (!MODE_DEFAULTS[mode]) throw new Error(`unsupported mode: ${mode}`) }
277
- function linkedIds(input) { return [...(input.evidenceIds ?? []), ...(input.issueIds ?? [])] }
278
- function isAllowedTransition(from, to) { return (TRANSITIONS[from] ?? []).includes(to) }
279
- function illegalTransitionMessage(from, to) { return `illegal transition ${from} -> ${to}` }
280
- function nextAttempt(currentAttempt, to) { return currentAttempt + (to === 'ATTEMPT' ? 1 : 0) }
281
- function addDiagnostic(diagnostics, condition, message) { if (!condition) diagnostics.push(message) }
282
- function deeplyEqual(left, right) { return isDeepStrictEqual(left, right) }
283
-
284
- function validateRunShape(run, diagnostics, prefix = 'run') {
285
- addDiagnostic(diagnostics, exactKeys(run, RUN_KEYS), `${prefix} must contain exactly the contracted fields`)
286
- if (!isObject(run)) return
287
- addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(run.schemaVersion), `${prefix}.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
288
- addDiagnostic(diagnostics, typeof run.taskId === 'string' && TASK_ID.test(run.taskId), `${prefix}.taskId must be kebab-case`)
289
- addDiagnostic(diagnostics, Object.hasOwn(MODE_DEFAULTS, run.mode), `${prefix}.mode is invalid`)
290
- addDiagnostic(diagnostics, STATUSES.includes(run.status), `${prefix}.status is invalid`)
291
- addDiagnostic(diagnostics, nonnegativeInteger(run.eventSequence), `${prefix}.eventSequence must be a nonnegative integer`)
292
- addDiagnostic(diagnostics, nonnegativeInteger(run.currentAttempt), `${prefix}.currentAttempt must be a nonnegative integer`)
293
- addDiagnostic(diagnostics, run.bestCandidateId === null || typeof run.bestCandidateId === 'string', `${prefix}.bestCandidateId must be a string or null`)
294
- validateBudgetShape(run.budget, diagnostics, `${prefix}.budget`)
295
- addDiagnostic(diagnostics, typeof run.createdAt === 'string' && run.createdAt.length > 0, `${prefix}.createdAt must be a string`)
296
- addDiagnostic(diagnostics, typeof run.updatedAt === 'string' && run.updatedAt.length > 0, `${prefix}.updatedAt must be a string`)
297
- }
298
-
299
- function validateLedgerShape(ledger, diagnostics) {
300
- addDiagnostic(diagnostics, exactKeys(ledger, LEDGER_KEYS), 'ledger must contain exactly the contracted fields')
301
- if (!isObject(ledger)) return
302
- addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(ledger.schemaVersion), `ledger.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
303
- addDiagnostic(diagnostics, typeof ledger.taskId === 'string' && TASK_ID.test(ledger.taskId), 'ledger.taskId must be kebab-case')
304
- addDiagnostic(diagnostics, isObject(ledger.scope), 'ledger.scope must be an object')
305
- for (const key of ['assumptions', 'claims', 'obligations', 'subproblems', 'candidates', 'issues']) addDiagnostic(diagnostics, Array.isArray(ledger[key]), `ledger.${key} must be an array`)
306
- }
307
-
308
- function validateEventShape(event, diagnostics, index) {
309
- const prefix = `event[${index}]`
310
- addDiagnostic(diagnostics, isObject(event), `${prefix} must be an object`)
311
- if (!isObject(event)) return
312
- const expectedType = index === 0 ? 'RUN_INITIALIZED' : 'STATUS_TRANSITION'
313
- const expectedKeys = expectedType === 'RUN_INITIALIZED'
314
- ? ['schemaVersion', 'sequence', 'type', 'taskId', 'timestamp', 'snapshot']
315
- : ['schemaVersion', 'sequence', 'type', 'taskId', 'from', 'to', 'reason', 'evidenceIds', 'issueIds', 'timestamp', 'snapshot']
316
- addDiagnostic(diagnostics, exactKeys(event, expectedKeys), `${prefix} must contain exactly the contracted fields`)
317
- addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(event.schemaVersion), `${prefix}.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
318
- addDiagnostic(diagnostics, event.sequence === index, `${prefix}.sequence must equal ${index}`)
319
- addDiagnostic(diagnostics, typeof event.taskId === 'string' && TASK_ID.test(event.taskId), `${prefix}.taskId must be kebab-case`)
320
- addDiagnostic(diagnostics, event.type === expectedType, `${prefix}.type must be ${expectedType}`)
321
- addDiagnostic(diagnostics, typeof event.timestamp === 'string' && event.timestamp.length > 0, `${prefix}.timestamp must be a string`)
322
- addDiagnostic(diagnostics, isObject(event.snapshot), `${prefix}.snapshot must be an object`)
323
- if (isObject(event.snapshot)) {
324
- validateRunShape(event.snapshot, diagnostics, `${prefix}.snapshot`)
325
- addDiagnostic(diagnostics, event.snapshot.taskId === event.taskId, `${prefix} snapshot task mismatch`)
326
- addDiagnostic(diagnostics, event.snapshot.eventSequence === event.sequence, `${prefix} snapshot sequence mismatch`)
327
- if (expectedType === 'RUN_INITIALIZED') {
328
- addDiagnostic(diagnostics, event.snapshot.status === 'TRIAGE', `${prefix}.snapshot.status must be TRIAGE`)
329
- addDiagnostic(diagnostics, event.snapshot.eventSequence === 0, `${prefix}.snapshot.eventSequence must equal 0`)
330
- addDiagnostic(diagnostics, event.snapshot.currentAttempt === 0, `${prefix}.snapshot.currentAttempt must equal 0`)
331
- addDiagnostic(diagnostics, event.snapshot.bestCandidateId === null, `${prefix}.snapshot.bestCandidateId must be null`)
332
- }
333
- }
334
- if (expectedType === 'STATUS_TRANSITION') {
335
- addDiagnostic(diagnostics, STATUSES.includes(event.from) && STATUSES.includes(event.to), `${prefix} transition states are invalid`)
336
- addDiagnostic(diagnostics, typeof event.reason === 'string' && event.reason.trim().length > 0, `${prefix}.reason must be a nonempty string`)
337
- const evidenceIdsValid = Array.isArray(event.evidenceIds) && event.evidenceIds.every(id => typeof id === 'string')
338
- const issueIdsValid = Array.isArray(event.issueIds) && event.issueIds.every(id => typeof id === 'string')
339
- addDiagnostic(diagnostics, evidenceIdsValid, `${prefix}.evidenceIds must be a string array`)
340
- addDiagnostic(diagnostics, issueIdsValid, `${prefix}.issueIds must be a string array`)
341
- addDiagnostic(diagnostics, evidenceIdsValid && issueIdsValid && linkedIds(event).length > 0, `${prefix} requires at least one evidence or issue ID`)
342
- }
343
- }
344
-
345
- function validateJournal(events, diagnostics) {
346
- if (events.length === 0) { diagnostics.push('event journal is empty'); return }
347
- const taskId = events[0]?.taskId
348
- const initializationSnapshot = events[0]?.snapshot
349
- events.forEach((event, index) => {
350
- validateEventShape(event, diagnostics, index)
351
- addDiagnostic(diagnostics, event?.sequence === index, 'non-monotonic events')
352
- addDiagnostic(diagnostics, event?.taskId === taskId, `event[${index}] task mismatch`)
353
- if (index > 0) {
354
- for (const key of IMMUTABLE_RUN_KEYS) {
355
- addDiagnostic(diagnostics, deeplyEqual(event?.snapshot?.[key], initializationSnapshot?.[key]), `event[${index}] snapshot immutable field ${key} mismatch`)
356
- }
357
- const previousSnapshot = events[index - 1]?.snapshot
358
- const previousStatus = previousSnapshot?.status
359
- addDiagnostic(diagnostics, event?.from === previousStatus, `event[${index}] transition from does not match previous snapshot`)
360
- addDiagnostic(diagnostics, event?.to === event?.snapshot?.status, `event[${index}] transition to does not match snapshot`)
361
- addDiagnostic(diagnostics, isAllowedTransition(event?.from, event?.to), illegalTransitionMessage(event?.from, event?.to))
362
- addDiagnostic(diagnostics, !FINAL_STATES.includes(previousStatus), `terminal state ${previousStatus} cannot have later events`)
363
- const expectedAttempt = nextAttempt(previousSnapshot?.currentAttempt, event?.to)
364
- addDiagnostic(diagnostics, event?.snapshot?.currentAttempt === expectedAttempt, `event[${index}] currentAttempt delta is invalid`)
365
- }
366
- })
367
- }
368
-
369
- export async function initRun(root, { taskId, mode = 'standard', budget, budgets, contract }, runtime = {}) {
370
- assertTaskId(taskId); assertMode(mode)
371
- const effectiveBudget = { ...MODE_DEFAULTS[mode], ...(budget ?? budgets ?? {}) }
372
- const budgetDiagnostics = []
373
- validateBudgetShape(effectiveBudget, budgetDiagnostics)
374
- if (budgetDiagnostics.length > 0) throw new Error(budgetDiagnostics.join('; '))
375
- return withLock(root, MUTATION_LOCK_FILE, 'init', async ({ ownerId, updateMetadata }) => {
376
- const timestamp = now()
377
- const run = {
378
- schemaVersion: SCHEMA_VERSION, taskId, mode, status: 'TRIAGE', eventSequence: 0, currentAttempt: 0,
379
- bestCandidateId: null, budget: effectiveBudget, createdAt: timestamp, updatedAt: timestamp,
380
- }
381
- const ledger = {
382
- schemaVersion: SCHEMA_VERSION, taskId,
383
- scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false, ...(contract === false ? { contractExempt: true } : {}) },
384
- assumptions: [], claims: [], obligations: [], subproblems: [], candidates: [], issues: [],
385
- }
386
- const event = { schemaVersion: SCHEMA_VERSION, sequence: 0, type: 'RUN_INITIALIZED', taskId, timestamp, snapshot: clone(run) }
387
- const p = paths(root)
388
- await assertRunFilesDoNotExist(p)
389
- const stagingName = `.run-state-init-${ownerId}`
390
- const stagingRoot = join(root, stagingName)
391
- const staged = paths(stagingRoot)
392
- const published = []
393
- await mkdir(stagingRoot)
394
- await updateMetadata({ stagingDir: stagingName, ownedArtifacts: [LEDGER_FILE, EVENTS_FILE, RUN_FILE] })
395
- try {
396
- await writeFile(staged.ledger, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
397
- await writeFile(staged.events, `${JSON.stringify(event)}\n`, { encoding: 'utf8', flag: 'wx' })
398
- await writeFile(staged.run, `${JSON.stringify(run, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
399
- for (const [source, target] of [[staged.ledger, p.ledger], [staged.events, p.events], [staged.run, p.run]]) {
400
- await renameWithRetry(source, target, runtime)
401
- published.push(target)
402
- await runtime.hooks?.afterPublish?.({ file: target, count: published.length })
403
- }
404
- return clone(run)
405
- } catch (error) {
406
- for (const target of published.reverse()) await unlink(target).catch(cleanupError => { if (cleanupError.code !== 'ENOENT') throw cleanupError })
407
- throw error
408
- } finally {
409
- for (const path of [staged.run, staged.events, staged.ledger]) await unlink(path).catch(error => { if (error.code !== 'ENOENT') throw error })
410
- await rmdir(stagingRoot).catch(error => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error })
411
- }
412
- }, runtime)
413
- }
414
-
415
- /** The v2 interaction contract: decision records, decision-stack backtracking, and the robustness gate. */
416
- function hasDecisionRecord(scope, decisionPoint, { allowAuto = true } = {}) {
417
- return Array.isArray(scope?.interactions) && scope.interactions.some(entry =>
418
- entry && (entry.decisionPoint === decisionPoint || (allowAuto && entry.auto === true)))
419
- }
420
- /** D4 verdict records carry the attempt number they adjudicate: {decisionPoint:'D4', attempt: 2, ...} */
421
- function hasD4ForAttempt(scope, attempt) {
422
- return Array.isArray(scope?.interactions) && scope.interactions.some(entry =>
423
- entry && (entry.auto === true || (entry.decisionPoint === 'D4' && entry.attempt === attempt)))
424
- }
425
- function decisionStackIds(scope) {
426
- return Array.isArray(scope?.decisionStack)
427
- ? scope.decisionStack.filter(entry => entry && typeof entry.id === 'string').map(entry => entry.id)
428
- : []
429
- }
430
- /** D2 records carry the subproblem id(s) they cover: {decisionPoint:'D2', scope:['SP1'], ...} */
431
- function hasSubproblemD2(scope, subproblem) {
432
- if (!subproblem || !Array.isArray(scope?.interactions)) return true
433
- return scope.interactions.some(entry => entry && entry.decisionPoint === 'D2' &&
434
- (Array.isArray(entry.scope) ? entry.scope.includes(subproblem) : entry.subproblem === subproblem))
435
- }
436
- function interactionContractViolations(run, ledger, to, reason, subproblem) {
437
- if (ledger.schemaVersion !== SCHEMA_VERSION) return []
438
- const scope = ledger.scope ?? {}
439
- if (scope.contractExempt === true) return []
440
- const violations = []
441
- const transitional = to !== undefined
442
- const earlyStop = ['BLOCKED', 'CANCELLED'].includes(run.status)
443
- const statusOrder = ['TRIAGE', 'RESEARCH', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'FORK']
444
- const reached = (target) => transitional
445
- ? to === target
446
- : (statusOrder.indexOf(target) <= statusOrder.indexOf(run.status) && !earlyStop)
447
- if (reached('SCOPE_FROZEN')) {
448
- if (!hasDecisionRecord(scope, 'D0')) violations.push('D0 restatement interaction record missing (ledger.scope.interactions)')
449
- if (!hasDecisionRecord(scope, 'D1')) violations.push('D1 routing interaction record missing (ledger.scope.interactions)')
450
- if (!hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (literature survey precedes restatement; auto-authorization is not accepted)')
451
- }
452
- if (reached('CLAIMS_REGISTERED') && !hasDecisionRecord(scope, 'D2-G')) violations.push('D2-G global-assumptions interaction record missing (global assumptions must be confirmed before claims registration)')
453
- const candidatesGate = reached('CANDIDATES_READY')
454
- if (candidatesGate && !hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (backstop check)')
455
- const afterCandidates = ['CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'FORK', ...FINAL_STATES]
456
- const enteringAttemptFromCandidates = transitional ? (to === 'ATTEMPT' && run.status === 'CANDIDATES_READY') : (run.currentAttempt >= 1)
457
- if (enteringAttemptFromCandidates) {
458
- if (transitional && !subproblem) violations.push('ATTEMPT from CANDIDATES_READY requires --subproblem <id>')
459
- if (!hasDecisionRecord(scope, 'D3')) violations.push('D3 direction-selection interaction record missing')
460
- if (transitional && subproblem && !hasSubproblemD2(scope, subproblem)) violations.push(`D2 assumption interaction record missing for subproblem ${subproblem} (run the gate with --subproblem <id>)`)
461
- }
462
- const enteringTerminalFromVerify = transitional
463
- ? (FINAL_STATES.includes(to) && run.status === 'VERIFY')
464
- : (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
465
- if (enteringTerminalFromVerify && !hasD4ForAttempt(scope, run.currentAttempt)) violations.push(`D4 verdict interaction record for attempt ${run.currentAttempt} missing`)
466
- if (transitional && (to === 'REVISE' || (to === 'RESEARCH' && run.status === 'VERIFY'))) {
467
- const ids = decisionStackIds(scope)
468
- if (ids.length > 0 && !ids.some(id => typeof reason === 'string' && reason.includes(id))) {
469
- violations.push(`REVISE/RESEARCH reason must reference a decisionStack entry id (available: ${ids.join(', ')})`)
470
- }
471
- }
472
- const terminalAndExempt = transitional
473
- ? (FINAL_STATES.includes(to) && !['BLOCKED', 'CANCELLED'].includes(to))
474
- : (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
475
- if (terminalAndExempt) {
476
- if (scope.robustnessExempt !== true) {
477
- const robos = Array.isArray(ledger.obligations) ? ledger.obligations.filter(o => o && o.kind === 'robustness' && o.required !== false) : []
478
- const allPassed = robos.length > 0 && robos.every(o => o.status === 'PASS')
479
- if (!allPassed) violations.push(`terminal state requires ALL ${robos.length} required robustness obligations to be PASS (or scope.robustnessExempt: true)`)
480
- }
481
- const subs = Array.isArray(ledger.subproblems) ? ledger.subproblems : []
482
- if (subs.length > 0) {
483
- const done = (sp) => sp.status === 'DONE' || sp.status === 'CLOSED'
484
- const unfinished = subs.filter(sp => !done(sp))
485
- if (unfinished.length > 0) violations.push(`terminal state requires every subproblem DONE (open: ${unfinished.map(sp => sp.id).join(', ')})`)
486
- const depGaps = subs.filter(sp => done(sp) && Array.isArray(sp.dependencies) && sp.dependencies.some(dep => {
487
- const d = subs.find(x => x.id === dep); return d === undefined || !done(d)
488
- }))
489
- if (depGaps.length > 0) violations.push(`subproblem dependencies incomplete: ${depGaps.map(sp => sp.id).join(', ')}`)
490
- }
491
- if (scope.cleanupPassed !== true) violations.push('terminal state requires scope.cleanupPassed: true (run the cleanup checklist in run-directory.md)')
492
- }
493
- return violations
494
- }
495
- function enforceInteractionContract(run, ledger, to, reason) {
496
- const violations = interactionContractViolations(run, ledger, to, reason)
497
- if (violations.length > 0) throw new Error(`interaction contract violated: ${violations.join('; ')}`)
498
- }
499
-
500
- function enforceSolvedGate(run, ledger) {
501
- if (ledger.claims.length < 1) throw new Error('SOLVED requires at least one claim')
502
- if (ledger.obligations.some(item => item.required !== false && item.status !== 'PASS')) throw new Error('required obligations remain open')
503
- if (ledger.issues.some(item => item.severity === 'critical' && item.status !== 'CLOSED')) throw new Error('critical issues remain open')
504
- if (run.mode === 'high-assurance' && ledger.scope.independentAuditPassed !== true) throw new Error('independent audit must pass')
505
- }
506
-
507
- function validateTransitionInput(input) {
508
- if (typeof input?.reason !== 'string' || input.reason.trim().length === 0) throw new Error('transition reason must be a nonempty string')
509
- for (const key of ['evidenceIds', 'issueIds']) {
510
- if (input[key] !== undefined && (!Array.isArray(input[key]) || input[key].some(id => typeof id !== 'string'))) throw new Error(`${key} must be an array of strings`)
511
- }
512
- if (linkedIds(input).length === 0) throw new Error('transition requires at least one evidence or issue ID')
513
- if (input.patch !== undefined && (!isObject(input.patch) || Object.keys(input.patch).some(key => key !== 'bestCandidateId'))) throw new Error('patch may only contain bestCandidateId')
514
- if (input.patch && (!Object.hasOwn(input.patch, 'bestCandidateId') || (input.patch.bestCandidateId !== null && typeof input.patch.bestCandidateId !== 'string'))) throw new Error('bestCandidateId must be a string or null')
515
- }
516
-
517
- function validateAuthoritativeState(run, ledger, events) {
518
- const diagnostics = []
519
- validateRunShape(run, diagnostics)
520
- validateLedgerShape(ledger, diagnostics)
521
- validateJournal(events, diagnostics)
522
- if (run.taskId !== ledger.taskId || events.some(event => event.taskId !== run.taskId)) diagnostics.push('task mismatch')
523
- const last = events.at(-1)
524
- if (!last || run.eventSequence !== last.sequence) diagnostics.push('state/journal sequence mismatch')
525
- if (!last || !deeplyEqual(run, last.snapshot)) diagnostics.push('state/journal snapshot mismatch')
526
- if (diagnostics.length > 0) throw new Error(`invalid run state: ${diagnostics.join('; ')}`)
527
- }
528
-
529
- export async function transitionRun(root, input, runtime = {}) {
530
- validateTransitionInput(input)
531
- return withLock(root, MUTATION_LOCK_FILE, 'transition', async () => {
532
- const p = paths(root)
533
- const run = await readJson(p.run)
534
- const ledger = await readJson(p.ledger)
535
- const events = await readEvents(p.events)
536
- validateAuthoritativeState(run, ledger, events)
537
- if (!isAllowedTransition(run.status, input.to)) throw new Error(illegalTransitionMessage(run.status, input.to))
538
- if (input.to === 'SOLVED') enforceSolvedGate(run, ledger)
539
- const timestamp = now()
540
- const bestCandidateId = input.patch && Object.hasOwn(input.patch, 'bestCandidateId') ? input.patch.bestCandidateId : run.bestCandidateId
541
- const next = { ...run, status: input.to, currentAttempt: nextAttempt(run.currentAttempt, input.to), eventSequence: run.eventSequence + 1, bestCandidateId, updatedAt: timestamp }
542
- const event = {
543
- schemaVersion: SCHEMA_VERSION, sequence: next.eventSequence, type: 'STATUS_TRANSITION', taskId: run.taskId,
544
- from: run.status, to: input.to, reason: input.reason, evidenceIds: input.evidenceIds ?? [], issueIds: input.issueIds ?? [], timestamp, snapshot: clone(next),
545
- }
546
- const prospectiveEvents = [...events, event]
547
- validateAuthoritativeState(next, ledger, prospectiveEvents)
548
- await appendFile(p.events, `${JSON.stringify(event)}\n`, 'utf8')
549
- await atomicWrite(p.run, next, runtime)
550
- return clone(next)
551
- }, runtime)
552
- }
553
-
554
- async function readEvents(path) {
555
- const text = await readFile(path, 'utf8')
556
- if (!text.trim()) return []
557
- return text.trimEnd().split(/\r?\n/).map((line, index) => {
558
- try { return JSON.parse(line) } catch { throw new Error(`invalid event JSON at line ${index + 1}`) }
559
- })
560
- }
561
-
562
- async function validateRunUnlocked(root, expectedTaskId) {
563
- const diagnostics = []
564
- const warnings = []
565
- let run, ledger, events
566
- const p = paths(root)
567
- try { run = await readJson(p.run) } catch (error) { diagnostics.push(`run unreadable: ${error.message}`) }
568
- try { ledger = await readJson(p.ledger) } catch (error) { diagnostics.push(`ledger unreadable: ${error.message}`) }
569
- try { events = await readEvents(p.events) } catch (error) { diagnostics.push(error.message) }
570
- if (!run || !ledger || !events) return { valid: false, diagnostics, warnings }
571
- validateRunShape(run, diagnostics)
572
- validateLedgerShape(ledger, diagnostics)
573
- validateJournal(events, diagnostics)
574
- if (run.taskId !== ledger.taskId || events.some(event => event.taskId !== run.taskId) || (expectedTaskId && expectedTaskId !== run.taskId)) diagnostics.push('task mismatch')
575
- const last = events.at(-1)
576
- if (last && run.eventSequence !== last.sequence) diagnostics.push('state/journal sequence mismatch')
577
- if (last && JSON.stringify(run) !== JSON.stringify(last.snapshot)) diagnostics.push('state/journal snapshot mismatch')
578
- if (ledger.schemaVersion !== SCHEMA_VERSION) warnings.push(`legacy run (ledger schema v${ledger.schemaVersion}): interaction contract not enforced`)
579
- else {
580
- for (const violation of interactionContractViolations(run, ledger, undefined, undefined)) {
581
- diagnostics.push(`interaction contract: ${violation}`)
582
- }
583
- if (run.currentAttempt >= 1) {
584
- const reportPath = join(root, 'attempts', String(run.currentAttempt), 'report.md')
585
- try {
586
- const reportText = await readFile(reportPath, 'utf8')
587
- for (const section of ['问题重述', '问题分析', '模型假设', '模型建立与求解', '验证', '鲁棒性', '评价与改进', '参考文献']) {
588
- if (!reportText.includes(section)) warnings.push(`attempt ${run.currentAttempt} report.md missing runlog digest section: ${section}`)
589
- }
590
- } catch {
591
- warnings.push(`attempt ${run.currentAttempt} report.md unreadable (runlog digest required)`)
592
- }
593
- }
594
- for (const assumption of Array.isArray(ledger.assumptions) ? ledger.assumptions : []) {
595
- const revised = assumption && (assumption.status === 'revised' || (Array.isArray(assumption.revisionHistory) && assumption.revisionHistory.length > 0))
596
- if (!revised) continue
597
- for (const claim of Array.isArray(ledger.claims) ? ledger.claims : []) {
598
- const depends = claim && Array.isArray(claim.assumptions) && claim.assumptions.includes(assumption.id)
599
- if (depends && claim.status === 'VERIFIED' && (!Array.isArray(claim.evidenceIds) || claim.evidenceIds.length === 0)) {
600
- warnings.push(`claim ${claim.id} depends on revised assumption ${assumption.id} but has no post-revision evidence`)
601
- }
602
- }
603
- }
604
- }
605
- return { valid: diagnostics.length === 0, diagnostics, warnings, run: clone(run) }
606
- }
607
-
608
- export async function validateRun(root, expectedTaskId, runtime = {}) {
609
- return withLock(root, MUTATION_LOCK_FILE, 'validate', () => validateRunUnlocked(root, expectedTaskId), runtime)
610
- }
611
-
612
- /**
613
- * Check the v2 interaction contract for a PROSPECTIVE transition without
614
- * mutating anything. The agent workflow must call this before `transition`;
615
- * the CLI `transition` command enforces it internally and has NO escape hatch.
616
- */
617
- export async function gateTransition(root, { to, reason, subproblem }, runtime = {}) {
618
- return withLock(root, MUTATION_LOCK_FILE, 'gate', async () => {
619
- const p = paths(root)
620
- const run = await readJson(p.run)
621
- const ledger = await readJson(p.ledger)
622
- const violations = interactionContractViolations(run, ledger, to, reason, subproblem)
623
- const exempt = ledger.scope?.contractExempt === true
624
- if (to === 'CANDIDATES_READY' && !exempt) {
625
- const sourcesPath = join(root, 'research', 'sources.jsonl')
626
- try {
627
- if (!(await readFile(sourcesPath, 'utf8')).trim()) violations.push('research/sources.jsonl is empty (pre-modeling literature survey artifacts are mandatory)')
628
- } catch {
629
- violations.push('research/sources.jsonl missing (pre-modeling literature survey artifacts are mandatory)')
630
- }
631
- }
632
- if (FINAL_STATES.includes(to) && !['BLOCKED', 'CANCELLED'].includes(to) && !exempt) {
633
- const reportPath = join(root, 'attempts', String(run.currentAttempt), 'report.md')
634
- try {
635
- await readFile(reportPath, 'utf8')
636
- } catch {
637
- violations.push(`attempts/${run.currentAttempt}/report.md missing (write the runlog report before a terminal transition)`)
638
- }
639
- }
640
- return { allowed: violations.length === 0, violations, status: run.status }
641
- }, runtime)
642
- }
643
-
644
- export async function recoverRun(root, runtime = {}) {
645
- return withLock(root, MUTATION_LOCK_FILE, 'recover', async () => {
646
- const p = paths(root)
647
- const events = await readEvents(p.events)
648
- if (events.length === 0) throw new Error('event journal is empty')
649
- const last = events.at(-1)
650
- const diagnostics = []
651
- validateJournal(events, diagnostics)
652
- if (diagnostics.length > 0) throw new Error(`malformed journal: ${diagnostics.join('; ')}`)
653
- const solvedEvent = events.find(event => event.to === 'SOLVED')
654
- if (solvedEvent) {
655
- const ledger = await readJson(p.ledger)
656
- enforceSolvedGate(solvedEvent.snapshot, ledger)
657
- }
658
- await runtime.hooks?.afterJournalValidated?.()
659
- const snapshot = clone(last.snapshot)
660
- await atomicWrite(p.run, snapshot, runtime)
661
- return snapshot
662
- }, runtime)
663
- }
664
-
665
- async function statusRunUnlocked(root) { return readJson(paths(root).run) }
666
- export async function statusRun(root, runtime = {}) {
667
- return withLock(root, MUTATION_LOCK_FILE, 'status', () => statusRunUnlocked(root), runtime)
668
- }
669
-
670
- function parseOptions(args) {
671
- const options = { _: [] }
672
- for (let index = 0; index < args.length; index += 1) {
673
- const token = args[index]
674
- if (!token.startsWith('--')) options._.push(token)
675
- else { const key = token.slice(2); const value = args[index + 1]; options[key] = value; index += 1 }
676
- }
677
- return options
678
- }
679
- async function cli(argv) {
680
- const [command, rootArg = '.', ...rest] = argv
681
- const root = resolve(rootArg)
682
- const options = parseOptions(rest)
683
- if (command === 'init') return initRun(root, { taskId: options['task-id'], mode: options.mode ?? 'standard' })
684
- if (command === 'gate') {
685
- const result = await gateTransition(root, { to: options.to, reason: options.reason, subproblem: options.subproblem })
686
- if (!result.allowed) process.exitCode = 1
687
- return result
688
- }
689
- if (command === 'transition') {
690
- const gate = await gateTransition(root, { to: options.to, reason: options.reason, subproblem: options.subproblem })
691
- if (!gate.allowed) {
692
- process.exitCode = 1
693
- return { error: `interaction contract violated: ${gate.violations.join('; ')}`, gate }
694
- }
695
- return transitionRun(root, { to: options.to, reason: options.reason, evidenceIds: options.evidence ? options.evidence.split(',').filter(Boolean) : [], issueIds: options.issues ? options.issues.split(',').filter(Boolean) : [], patch: options.candidate ? { bestCandidateId: options.candidate } : undefined })
696
- }
697
- if (command === 'validate') {
698
- const result = await validateRun(root, options['task-id'])
699
- if (!result.valid) process.exitCode = 1
700
- return result
701
- }
702
- if (command === 'recover') return recoverRun(root)
703
- if (command === 'status') return statusRun(root)
704
- throw new Error('usage: run-state.mjs <init|transition|gate|validate|recover|status> <run-directory> [options]')
705
- }
706
- const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
707
- if (invoked) cli(process.argv.slice(2)).then(result => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(error => { process.stdout.write(`${JSON.stringify({ error: error.message })}\n`); process.exitCode = 1 })
1
+ #!/usr/bin/env node
2
+ import { access, appendFile, mkdir, open, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
3
+ import { dirname, join, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { isDeepStrictEqual } from 'node:util'
6
+
7
+ import { validateCoverage } from './report-contract.mjs'
8
+
9
+ export const SCHEMA_VERSION = 3
10
+ export const SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3]
11
+ export const INTERACTION_DECISIONS = Object.freeze({ D1: 'routing', D2: 'assumptions', D3: 'direction', D4: 'verdict' })
12
+ export const MODE_DEFAULTS = Object.freeze({
13
+ fast: Object.freeze({ attempts: 2, researchQueries: 0, computeSeconds: 60 }),
14
+ standard: Object.freeze({ attempts: 12, researchQueries: 12, computeSeconds: 1800 }),
15
+ 'high-assurance': Object.freeze({ attempts: 24, researchQueries: 30, computeSeconds: 7200 }),
16
+ })
17
+
18
+ const NON_FINAL_STATES = ['TRIAGE', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'EVALUATE', 'CORRECTION_REQUIRED', 'REVISE', 'RESEARCH', 'FORK']
19
+ const FINAL_STATES = ['SOLVED', 'PARTIAL', 'CONDITIONAL', 'INCONCLUSIVE', 'REFUTED', 'INFEASIBLE', 'UNIDENTIFIABLE', 'BLOCKED', 'CANCELLED']
20
+ const STATUSES = [...NON_FINAL_STATES, ...FINAL_STATES]
21
+ const STOP_TRANSITIONS = ['BLOCKED', 'CANCELLED']
22
+ const TRANSITIONS = Object.freeze({
23
+ TRIAGE: ['RESEARCH', 'SCOPE_FROZEN', ...STOP_TRANSITIONS],
24
+ RESEARCH: ['SCOPE_FROZEN', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
25
+ SCOPE_FROZEN: ['INPUT_PROFILED', ...STOP_TRANSITIONS],
26
+ INPUT_PROFILED: ['CLAIMS_REGISTERED', ...STOP_TRANSITIONS],
27
+ CLAIMS_REGISTERED: ['RESEARCH', 'CANDIDATES_READY', ...STOP_TRANSITIONS],
28
+ CANDIDATES_READY: ['ATTEMPT', ...STOP_TRANSITIONS],
29
+ ATTEMPT: ['EXECUTE', ...STOP_TRANSITIONS],
30
+ EXECUTE: ['VERIFY', ...STOP_TRANSITIONS],
31
+ VERIFY: ['EVALUATE', 'REVISE', 'RESEARCH', 'FORK', ...FINAL_STATES],
32
+ EVALUATE: ['CORRECTION_REQUIRED', 'REVISE', 'CONDITIONAL', ...STOP_TRANSITIONS],
33
+ CORRECTION_REQUIRED: ['REVISE', ...STOP_TRANSITIONS],
34
+ REVISE: ['ATTEMPT', ...STOP_TRANSITIONS],
35
+ FORK: ['ATTEMPT', ...STOP_TRANSITIONS],
36
+ })
37
+ const TASK_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
38
+ const RUN_KEYS_V2 = ['schemaVersion', 'taskId', 'mode', 'status', 'eventSequence', 'currentAttempt', 'bestCandidateId', 'budget', 'createdAt', 'updatedAt']
39
+ const RUN_KEYS = ['schemaVersion', 'taskId', 'mode', 'status', 'eventSequence', 'currentAttempt', 'bestCandidateId', 'budget', 'createdAt', 'updatedAt', 'lineageId', 'parentRunId', 'revision', 'inputSnapshotHash', 'evidenceGraphHash']
40
+ const IMMUTABLE_RUN_KEYS = ['schemaVersion', 'taskId', 'mode', 'budget', 'createdAt', 'lineageId', 'parentRunId', 'revision']
41
+ const LEDGER_KEYS_V2 = ['schemaVersion', 'taskId', 'scope', 'assumptions', 'claims', 'obligations', 'subproblems', 'candidates', 'issues']
42
+ const LEDGER_KEYS = [...LEDGER_KEYS_V2, 'requirements', 'evidence', 'verifications', 'failures']
43
+ const RUN_FILE = 'run.json'
44
+ const LEDGER_FILE = 'ledger.json'
45
+ const EVENTS_FILE = 'events.jsonl'
46
+ const MUTATION_LOCK_FILE = '.run-state.lock'
47
+ const RECLAIM_GUARD_FILE = '.run-state.reclaim'
48
+
49
+ function clone(value) { return JSON.parse(JSON.stringify(value)) }
50
+ function now() { return new Date().toISOString() }
51
+ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
52
+ function exactKeys(value, keys) { return isObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)) }
53
+ function nonnegativeInteger(value) { return Number.isInteger(value) && value >= 0 }
54
+ function validateBudgetShape(budget, diagnostics, prefix = 'budget') {
55
+ addDiagnostic(diagnostics, isObject(budget), `${prefix} must be an object`)
56
+ if (!isObject(budget)) return
57
+ addDiagnostic(diagnostics, exactKeys(budget, ['attempts', 'researchQueries', 'computeSeconds']), `${prefix} has invalid fields`)
58
+ addDiagnostic(diagnostics, Number.isFinite(budget.attempts) && Number.isInteger(budget.attempts) && budget.attempts >= 1, `${prefix}.attempts must be a finite integer >= 1`)
59
+ addDiagnostic(diagnostics, Number.isFinite(budget.researchQueries) && nonnegativeInteger(budget.researchQueries), `${prefix}.researchQueries must be a finite nonnegative integer`)
60
+ addDiagnostic(diagnostics, Number.isFinite(budget.computeSeconds) && nonnegativeInteger(budget.computeSeconds), `${prefix}.computeSeconds must be a finite nonnegative integer`)
61
+ }
62
+ async function readJson(path) { return JSON.parse(await readFile(path, 'utf8')) }
63
+ const RENAME_DEFAULTS = Object.freeze({ attempts: 5, retryDelayMs: 5 })
64
+ const WINDOWS_SHARING_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
65
+ async function renameWithRetry(source, target, runtime = {}) {
66
+ const options = { ...RENAME_DEFAULTS, ...(runtime.rename ?? {}) }
67
+ const renameFile = runtime.fs?.rename ?? rename
68
+ for (let attempt = 1; ; attempt += 1) {
69
+ try { return await renameFile(source, target) }
70
+ catch (error) {
71
+ if (!WINDOWS_SHARING_ERRORS.has(error.code) || attempt >= options.attempts) {
72
+ if (WINDOWS_SHARING_ERRORS.has(error.code)) error.message = `atomic rename failed after ${attempt} attempts: ${error.message}`
73
+ throw error
74
+ }
75
+ await sleep(options.retryDelayMs * attempt)
76
+ }
77
+ }
78
+ }
79
+ async function unlinkWithRetry(path, unlinkFile = unlink, options = RENAME_DEFAULTS) {
80
+ for (let attempt = 1; ; attempt += 1) {
81
+ try { await unlinkFile(path); return true }
82
+ catch (error) {
83
+ if (error.code === 'ENOENT') return false
84
+ if (!WINDOWS_SHARING_ERRORS.has(error.code) || attempt >= options.attempts) throw error
85
+ await sleep(options.retryDelayMs * attempt)
86
+ }
87
+ }
88
+ }
89
+ async function atomicWrite(path, value, runtime = {}) {
90
+ await mkdir(dirname(path), { recursive: true })
91
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
92
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
93
+ try { await renameWithRetry(temporary, path, runtime) }
94
+ finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error }) }
95
+ }
96
+ function paths(root) { return { run: join(root, RUN_FILE), ledger: join(root, LEDGER_FILE), events: join(root, EVENTS_FILE) } }
97
+ const LOCK_DEFAULTS = Object.freeze({ timeoutMs: 2000, retryDelayMs: 10, staleMs: 30_000 })
98
+ function sleep(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)) }
99
+ function processIsAlive(pid) {
100
+ try { process.kill(pid, 0); return true }
101
+ catch (error) { return error.code === 'EPERM' }
102
+ }
103
+ function lockBusyError(operation) {
104
+ const error = new Error(`LOCK_BUSY: timed out waiting for run-state lock during ${operation}`)
105
+ error.code = 'LOCK_BUSY'
106
+ return error
107
+ }
108
+ async function readLockMetadata(lockPath) {
109
+ try { return JSON.parse(await readFile(lockPath, 'utf8')) } catch { return null }
110
+ }
111
+ async function validCompleteRunExists(root) {
112
+ try {
113
+ const p = paths(root)
114
+ validateAuthoritativeState(await readJson(p.run), await readJson(p.ledger), await readEvents(p.events))
115
+ return true
116
+ } catch { return false }
117
+ }
118
+ async function cleanupDeadInitAttempt(root, metadata) {
119
+ if (metadata?.operation !== 'init' || await validCompleteRunExists(root)) return
120
+ const owned = Array.isArray(metadata.ownedArtifacts) ? metadata.ownedArtifacts : []
121
+ for (const file of owned) {
122
+ if ([RUN_FILE, LEDGER_FILE, EVENTS_FILE].includes(file)) await unlink(join(root, file)).catch(error => { if (error.code !== 'ENOENT') throw error })
123
+ }
124
+ if (typeof metadata.stagingDir === 'string' && metadata.stagingDir.startsWith('.run-state-init-')) {
125
+ const stagingRoot = join(root, metadata.stagingDir)
126
+ for (const file of [RUN_FILE, LEDGER_FILE, EVENTS_FILE]) await unlink(join(stagingRoot, file)).catch(error => { if (error.code !== 'ENOENT') throw error })
127
+ await rmdir(stagingRoot).catch(error => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error })
128
+ }
129
+ }
130
+ async function reclaimGuardIsBusy(path, runtime = {}) {
131
+ const accessFile = runtime.fs?.access ?? access
132
+ try { await accessFile(path); return true }
133
+ catch (error) {
134
+ if (error.code === 'ENOENT') return false
135
+ if (WINDOWS_SHARING_ERRORS.has(error.code)) return true
136
+ throw error
137
+ }
138
+ }
139
+ function sameLockGeneration(observedMetadata, observedStat, currentMetadata, currentStat) {
140
+ if (observedMetadata === null || currentMetadata === null) {
141
+ return observedMetadata === null && currentMetadata === null
142
+ && observedStat.dev === currentStat.dev
143
+ && observedStat.ino === currentStat.ino
144
+ && observedStat.size === currentStat.size
145
+ && observedStat.mtimeMs === currentStat.mtimeMs
146
+ }
147
+ return typeof observedMetadata.ownerId === 'string'
148
+ && currentMetadata.ownerId === observedMetadata.ownerId
149
+ && observedStat.dev === currentStat.dev
150
+ && observedStat.ino === currentStat.ino
151
+ && observedStat.size === currentStat.size
152
+ && observedStat.mtimeMs === currentStat.mtimeMs
153
+ }
154
+ function demonstrablyDeadAndStale(metadata, lockStat, lockOptions) {
155
+ const acquiredAt = Date.parse(metadata?.acquiredAt)
156
+ const staleSince = Number.isFinite(acquiredAt) ? acquiredAt : lockStat.mtimeMs
157
+ return typeof metadata?.ownerId === 'string'
158
+ && Number.isInteger(metadata.pid)
159
+ && Date.now() - staleSince >= lockOptions.staleMs
160
+ && !lockOptions.isProcessAlive(metadata.pid)
161
+ }
162
+
163
+ function isReclaimableLock(metadata, lockStat, lockOptions) {
164
+ if (metadata === null) return Date.now() - lockStat.mtimeMs >= lockOptions.staleMs
165
+ return demonstrablyDeadAndStale(metadata, lockStat, lockOptions)
166
+ }
167
+
168
+ async function reclaimStaleGuard(guardPath, lockOptions, runtime = {}) {
169
+ const statFile = runtime.fs?.stat ?? stat
170
+ const unlinkFile = runtime.fs?.unlink ?? unlink
171
+ try {
172
+ const guardStat = await statFile(guardPath)
173
+ if (Date.now() - guardStat.mtimeMs < lockOptions.staleMs) return false
174
+ const guardMetadata = await readLockMetadata(guardPath)
175
+ if (guardMetadata !== null && typeof guardMetadata.ownerId !== 'string') return false
176
+ await unlinkWithRetry(guardPath, unlinkFile)
177
+ return true
178
+ } catch (error) {
179
+ if (error.code === 'ENOENT') return true
180
+ throw error
181
+ }
182
+ }
183
+ async function reclaimDeadStaleLock(root, lockPath, guardPath, lockOptions) {
184
+ let observedMetadata, observedStat
185
+ try { [observedMetadata, observedStat] = await Promise.all([readLockMetadata(lockPath), stat(lockPath)]) }
186
+ catch (error) { if (error.code === 'ENOENT') return true; throw error }
187
+ if (!isReclaimableLock(observedMetadata, observedStat, lockOptions)) return false
188
+ const guardOwnerId = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
189
+ let guard
190
+ try {
191
+ guard = await open(guardPath, 'wx')
192
+ await guard.writeFile(JSON.stringify({ ownerId: guardOwnerId }), 'utf8')
193
+ } catch (error) {
194
+ if (guard) {
195
+ await guard.close()
196
+ const currentGuard = await readLockMetadata(guardPath)
197
+ if (currentGuard?.ownerId === guardOwnerId) await unlinkWithRetry(guardPath)
198
+ }
199
+ if (['EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) return false
200
+ throw error
201
+ }
202
+ try {
203
+ let currentMetadata, currentStat
204
+ try { [currentMetadata, currentStat] = await Promise.all([readLockMetadata(lockPath), stat(lockPath)]) }
205
+ catch (error) { if (error.code === 'ENOENT') return true; throw error }
206
+ if (!sameLockGeneration(observedMetadata, observedStat, currentMetadata, currentStat)) return false
207
+ if (!isReclaimableLock(currentMetadata, currentStat, lockOptions)) return false
208
+ await cleanupDeadInitAttempt(root, currentMetadata)
209
+ const finalMetadata = await readLockMetadata(lockPath)
210
+ if (finalMetadata?.ownerId !== observedMetadata?.ownerId) return false
211
+ await unlinkWithRetry(lockPath)
212
+ return true
213
+ } finally {
214
+ await guard.close()
215
+ const currentGuard = await readLockMetadata(guardPath)
216
+ if (currentGuard?.ownerId === guardOwnerId) await unlinkWithRetry(guardPath)
217
+ }
218
+ }
219
+ async function withLock(root, file, operationName, operation, runtime = {}) {
220
+ await mkdir(root, { recursive: true })
221
+ const lockPath = join(root, file)
222
+ const guardPath = join(root, RECLAIM_GUARD_FILE)
223
+ const lockOptions = { ...LOCK_DEFAULTS, ...(runtime.lock ?? {}), isProcessAlive: runtime.lock?.isProcessAlive ?? processIsAlive }
224
+ const deadline = Date.now() + lockOptions.timeoutMs
225
+ const ownerId = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
226
+ let lock
227
+ let metadata = { pid: process.pid, acquiredAt: now(), operation: operationName, ownerId }
228
+ while (!lock) {
229
+ if (await reclaimGuardIsBusy(guardPath, runtime)) {
230
+ if (await reclaimStaleGuard(guardPath, lockOptions, runtime)) {
231
+ if (Date.now() >= deadline) throw lockBusyError(operationName)
232
+ await sleep(lockOptions.retryDelayMs)
233
+ continue
234
+ }
235
+ if (Date.now() >= deadline) throw lockBusyError(operationName)
236
+ await sleep(lockOptions.retryDelayMs)
237
+ continue
238
+ }
239
+ try {
240
+ const candidate = await open(lockPath, 'wx')
241
+ if (await reclaimGuardIsBusy(guardPath, runtime)) {
242
+ await candidate.close()
243
+ await unlinkWithRetry(lockPath)
244
+ if (await reclaimStaleGuard(guardPath, lockOptions, runtime)) {
245
+ if (Date.now() >= deadline) throw lockBusyError(operationName)
246
+ await sleep(lockOptions.retryDelayMs)
247
+ continue
248
+ }
249
+ if (Date.now() >= deadline) throw lockBusyError(operationName)
250
+ await sleep(lockOptions.retryDelayMs)
251
+ continue
252
+ }
253
+ lock = candidate
254
+ await lock.writeFile(JSON.stringify(metadata), 'utf8')
255
+ } catch (error) {
256
+ if (!['EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error
257
+ if (error.code === 'EEXIST' && await reclaimDeadStaleLock(root, lockPath, guardPath, lockOptions)) continue
258
+ if (Date.now() >= deadline) throw lockBusyError(operationName)
259
+ await sleep(lockOptions.retryDelayMs)
260
+ }
261
+ }
262
+ const updateMetadata = async patch => {
263
+ metadata = { ...metadata, ...patch }
264
+ await writeFile(lockPath, JSON.stringify(metadata), 'utf8')
265
+ }
266
+ try {
267
+ await runtime.hooks?.afterLockAcquired?.({ operation: operationName, ownerId })
268
+ return await operation({ ownerId, updateMetadata })
269
+ } finally {
270
+ await lock.close()
271
+ const current = await readLockMetadata(lockPath)
272
+ if (current?.ownerId === ownerId) await unlinkWithRetry(lockPath)
273
+ }
274
+ }
275
+ async function assertRunFilesDoNotExist(p) {
276
+ for (const path of [p.run, p.ledger, p.events]) {
277
+ try { await access(path); throw new Error('contracted run file already exists') }
278
+ catch (error) { if (error.code !== 'ENOENT') throw error }
279
+ }
280
+ }
281
+ function assertTaskId(taskId) { if (!TASK_ID.test(taskId ?? '')) throw new Error('taskId must be kebab-case') }
282
+ function assertMode(mode) { if (!MODE_DEFAULTS[mode]) throw new Error(`unsupported mode: ${mode}`) }
283
+ function linkedIds(input) { return [...(input.evidenceIds ?? []), ...(input.issueIds ?? [])] }
284
+ function isAllowedTransition(from, to) { return (TRANSITIONS[from] ?? []).includes(to) }
285
+ function illegalTransitionMessage(from, to) { return `illegal transition ${from} -> ${to}` }
286
+ function nextAttempt(currentAttempt, to) { return currentAttempt + (to === 'ATTEMPT' ? 1 : 0) }
287
+ function addDiagnostic(diagnostics, condition, message) { if (!condition) diagnostics.push(message) }
288
+ function deeplyEqual(left, right) { return isDeepStrictEqual(left, right) }
289
+
290
+ function validateRunShape(run, diagnostics, prefix = 'run') {
291
+ const keys = run?.schemaVersion >= 3 ? RUN_KEYS : RUN_KEYS_V2
292
+ addDiagnostic(diagnostics, exactKeys(run, keys), `${prefix} must contain exactly the contracted fields`)
293
+ if (!isObject(run)) return
294
+ addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(run.schemaVersion), `${prefix}.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
295
+ addDiagnostic(diagnostics, typeof run.taskId === 'string' && TASK_ID.test(run.taskId), `${prefix}.taskId must be kebab-case`)
296
+ addDiagnostic(diagnostics, Object.hasOwn(MODE_DEFAULTS, run.mode), `${prefix}.mode is invalid`)
297
+ addDiagnostic(diagnostics, STATUSES.includes(run.status), `${prefix}.status is invalid`)
298
+ addDiagnostic(diagnostics, nonnegativeInteger(run.eventSequence), `${prefix}.eventSequence must be a nonnegative integer`)
299
+ addDiagnostic(diagnostics, nonnegativeInteger(run.currentAttempt), `${prefix}.currentAttempt must be a nonnegative integer`)
300
+ addDiagnostic(diagnostics, run.bestCandidateId === null || typeof run.bestCandidateId === 'string', `${prefix}.bestCandidateId must be a string or null`)
301
+ validateBudgetShape(run.budget, diagnostics, `${prefix}.budget`)
302
+ addDiagnostic(diagnostics, typeof run.createdAt === 'string' && run.createdAt.length > 0, `${prefix}.createdAt must be a string`)
303
+ addDiagnostic(diagnostics, typeof run.updatedAt === 'string' && run.updatedAt.length > 0, `${prefix}.updatedAt must be a string`)
304
+ if (run.schemaVersion >= 3) {
305
+ addDiagnostic(diagnostics, typeof run.lineageId === 'string' && run.lineageId.length > 0, `${prefix}.lineageId must be a nonempty string`)
306
+ addDiagnostic(diagnostics, run.parentRunId === null || typeof run.parentRunId === 'string', `${prefix}.parentRunId must be a string or null`)
307
+ addDiagnostic(diagnostics, nonnegativeInteger(run.revision), `${prefix}.revision must be a nonnegative integer`)
308
+ addDiagnostic(diagnostics, run.inputSnapshotHash === null || typeof run.inputSnapshotHash === 'string', `${prefix}.inputSnapshotHash must be a string or null`)
309
+ addDiagnostic(diagnostics, run.evidenceGraphHash === null || typeof run.evidenceGraphHash === 'string', `${prefix}.evidenceGraphHash must be a string or null`)
310
+ }
311
+ }
312
+
313
+ function validateLedgerShape(ledger, diagnostics) {
314
+ const keys = ledger?.schemaVersion >= 3 ? LEDGER_KEYS : LEDGER_KEYS_V2
315
+ addDiagnostic(diagnostics, exactKeys(ledger, keys), 'ledger must contain exactly the contracted fields')
316
+ if (!isObject(ledger)) return
317
+ addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(ledger.schemaVersion), `ledger.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
318
+ addDiagnostic(diagnostics, typeof ledger.taskId === 'string' && TASK_ID.test(ledger.taskId), 'ledger.taskId must be kebab-case')
319
+ addDiagnostic(diagnostics, isObject(ledger.scope), 'ledger.scope must be an object')
320
+ const arrays = ['assumptions', 'claims', 'obligations', 'subproblems', 'candidates', 'issues']
321
+ if (ledger.schemaVersion >= 3) arrays.push('requirements', 'evidence', 'verifications', 'failures')
322
+ for (const key of arrays) addDiagnostic(diagnostics, Array.isArray(ledger[key]), `ledger.${key} must be an array`)
323
+ }
324
+
325
+ function validateEventShape(event, diagnostics, index) {
326
+ const prefix = `event[${index}]`
327
+ addDiagnostic(diagnostics, isObject(event), `${prefix} must be an object`)
328
+ if (!isObject(event)) return
329
+ const expectedType = index === 0 ? 'RUN_INITIALIZED' : 'STATUS_TRANSITION'
330
+ const expectedKeys = expectedType === 'RUN_INITIALIZED'
331
+ ? ['schemaVersion', 'sequence', 'type', 'taskId', 'timestamp', 'snapshot']
332
+ : ['schemaVersion', 'sequence', 'type', 'taskId', 'from', 'to', 'reason', 'evidenceIds', 'issueIds', 'timestamp', 'snapshot']
333
+ addDiagnostic(diagnostics, exactKeys(event, expectedKeys), `${prefix} must contain exactly the contracted fields`)
334
+ addDiagnostic(diagnostics, SUPPORTED_SCHEMA_VERSIONS.includes(event.schemaVersion), `${prefix}.schemaVersion must be one of ${SUPPORTED_SCHEMA_VERSIONS.join(',')}`)
335
+ addDiagnostic(diagnostics, event.sequence === index, `${prefix}.sequence must equal ${index}`)
336
+ addDiagnostic(diagnostics, typeof event.taskId === 'string' && TASK_ID.test(event.taskId), `${prefix}.taskId must be kebab-case`)
337
+ addDiagnostic(diagnostics, event.type === expectedType, `${prefix}.type must be ${expectedType}`)
338
+ addDiagnostic(diagnostics, typeof event.timestamp === 'string' && event.timestamp.length > 0, `${prefix}.timestamp must be a string`)
339
+ addDiagnostic(diagnostics, isObject(event.snapshot), `${prefix}.snapshot must be an object`)
340
+ if (isObject(event.snapshot)) {
341
+ validateRunShape(event.snapshot, diagnostics, `${prefix}.snapshot`)
342
+ addDiagnostic(diagnostics, event.snapshot.taskId === event.taskId, `${prefix} snapshot task mismatch`)
343
+ addDiagnostic(diagnostics, event.snapshot.eventSequence === event.sequence, `${prefix} snapshot sequence mismatch`)
344
+ if (expectedType === 'RUN_INITIALIZED') {
345
+ addDiagnostic(diagnostics, event.snapshot.status === 'TRIAGE', `${prefix}.snapshot.status must be TRIAGE`)
346
+ addDiagnostic(diagnostics, event.snapshot.eventSequence === 0, `${prefix}.snapshot.eventSequence must equal 0`)
347
+ addDiagnostic(diagnostics, event.snapshot.currentAttempt === 0, `${prefix}.snapshot.currentAttempt must equal 0`)
348
+ addDiagnostic(diagnostics, event.snapshot.bestCandidateId === null, `${prefix}.snapshot.bestCandidateId must be null`)
349
+ }
350
+ }
351
+ if (expectedType === 'STATUS_TRANSITION') {
352
+ addDiagnostic(diagnostics, STATUSES.includes(event.from) && STATUSES.includes(event.to), `${prefix} transition states are invalid`)
353
+ addDiagnostic(diagnostics, typeof event.reason === 'string' && event.reason.trim().length > 0, `${prefix}.reason must be a nonempty string`)
354
+ const evidenceIdsValid = Array.isArray(event.evidenceIds) && event.evidenceIds.every(id => typeof id === 'string')
355
+ const issueIdsValid = Array.isArray(event.issueIds) && event.issueIds.every(id => typeof id === 'string')
356
+ addDiagnostic(diagnostics, evidenceIdsValid, `${prefix}.evidenceIds must be a string array`)
357
+ addDiagnostic(diagnostics, issueIdsValid, `${prefix}.issueIds must be a string array`)
358
+ addDiagnostic(diagnostics, evidenceIdsValid && issueIdsValid && linkedIds(event).length > 0, `${prefix} requires at least one evidence or issue ID`)
359
+ }
360
+ }
361
+
362
+ function validateJournal(events, diagnostics) {
363
+ if (events.length === 0) { diagnostics.push('event journal is empty'); return }
364
+ const taskId = events[0]?.taskId
365
+ const initializationSnapshot = events[0]?.snapshot
366
+ events.forEach((event, index) => {
367
+ validateEventShape(event, diagnostics, index)
368
+ addDiagnostic(diagnostics, event?.sequence === index, 'non-monotonic events')
369
+ addDiagnostic(diagnostics, event?.taskId === taskId, `event[${index}] task mismatch`)
370
+ if (index > 0) {
371
+ for (const key of IMMUTABLE_RUN_KEYS) {
372
+ addDiagnostic(diagnostics, deeplyEqual(event?.snapshot?.[key], initializationSnapshot?.[key]), `event[${index}] snapshot immutable field ${key} mismatch`)
373
+ }
374
+ const previousSnapshot = events[index - 1]?.snapshot
375
+ const previousStatus = previousSnapshot?.status
376
+ addDiagnostic(diagnostics, event?.from === previousStatus, `event[${index}] transition from does not match previous snapshot`)
377
+ addDiagnostic(diagnostics, event?.to === event?.snapshot?.status, `event[${index}] transition to does not match snapshot`)
378
+ addDiagnostic(diagnostics, isAllowedTransition(event?.from, event?.to), illegalTransitionMessage(event?.from, event?.to))
379
+ addDiagnostic(diagnostics, !FINAL_STATES.includes(previousStatus), `terminal state ${previousStatus} cannot have later events`)
380
+ const expectedAttempt = nextAttempt(previousSnapshot?.currentAttempt, event?.to)
381
+ addDiagnostic(diagnostics, event?.snapshot?.currentAttempt === expectedAttempt, `event[${index}] currentAttempt delta is invalid`)
382
+ }
383
+ })
384
+ }
385
+
386
+ export async function initRun(root, { taskId, mode = 'standard', budget, budgets, contract }, runtime = {}) {
387
+ assertTaskId(taskId); assertMode(mode)
388
+ const effectiveBudget = { ...MODE_DEFAULTS[mode], ...(budget ?? budgets ?? {}) }
389
+ const budgetDiagnostics = []
390
+ validateBudgetShape(effectiveBudget, budgetDiagnostics)
391
+ if (budgetDiagnostics.length > 0) throw new Error(budgetDiagnostics.join('; '))
392
+ return withLock(root, MUTATION_LOCK_FILE, 'init', async ({ ownerId, updateMetadata }) => {
393
+ const timestamp = now()
394
+ const run = {
395
+ schemaVersion: SCHEMA_VERSION, taskId, mode, status: 'TRIAGE', eventSequence: 0, currentAttempt: 0,
396
+ bestCandidateId: null, budget: effectiveBudget, createdAt: timestamp, updatedAt: timestamp,
397
+ lineageId: taskId, parentRunId: null, revision: 0, inputSnapshotHash: null, evidenceGraphHash: null,
398
+ }
399
+ const ledger = {
400
+ schemaVersion: SCHEMA_VERSION, taskId,
401
+ scope: { independentAuditPassed: false, interactions: [], decisionStack: [], robustnessExempt: false, ...(contract === false ? { contractExempt: true } : {}) },
402
+ assumptions: [], claims: [], obligations: [], subproblems: [], candidates: [], issues: [],
403
+ requirements: [], evidence: [], verifications: [], failures: [],
404
+ }
405
+ const event = { schemaVersion: SCHEMA_VERSION, sequence: 0, type: 'RUN_INITIALIZED', taskId, timestamp, snapshot: clone(run) }
406
+ const p = paths(root)
407
+ await assertRunFilesDoNotExist(p)
408
+ const stagingName = `.run-state-init-${ownerId}`
409
+ const stagingRoot = join(root, stagingName)
410
+ const staged = paths(stagingRoot)
411
+ const published = []
412
+ await mkdir(stagingRoot)
413
+ await updateMetadata({ stagingDir: stagingName, ownedArtifacts: [LEDGER_FILE, EVENTS_FILE, RUN_FILE] })
414
+ try {
415
+ await writeFile(staged.ledger, `${JSON.stringify(ledger, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
416
+ await writeFile(staged.events, `${JSON.stringify(event)}\n`, { encoding: 'utf8', flag: 'wx' })
417
+ await writeFile(staged.run, `${JSON.stringify(run, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
418
+ for (const [source, target] of [[staged.ledger, p.ledger], [staged.events, p.events], [staged.run, p.run]]) {
419
+ await renameWithRetry(source, target, runtime)
420
+ published.push(target)
421
+ await runtime.hooks?.afterPublish?.({ file: target, count: published.length })
422
+ }
423
+ return clone(run)
424
+ } catch (error) {
425
+ for (const target of published.reverse()) await unlink(target).catch(cleanupError => { if (cleanupError.code !== 'ENOENT') throw cleanupError })
426
+ throw error
427
+ } finally {
428
+ for (const path of [staged.run, staged.events, staged.ledger]) await unlink(path).catch(error => { if (error.code !== 'ENOENT') throw error })
429
+ await rmdir(stagingRoot).catch(error => { if (!['ENOENT', 'ENOTEMPTY'].includes(error.code)) throw error })
430
+ }
431
+ }, runtime)
432
+ }
433
+
434
+ function graphItem(graph, collection, id, label) {
435
+ const items = Array.isArray(graph?.[collection]) ? graph[collection] : []
436
+ const item = items.find(value => value?.id === id)
437
+ if (!item) throw new Error('unknown ' + label + ' id: ' + id)
438
+ return item
439
+ }
440
+
441
+ function linkedVerifications(graph, obligation) {
442
+ const ids = Array.isArray(obligation.verificationIds) ? obligation.verificationIds : []
443
+ const values = Array.isArray(graph?.verifications) ? graph.verifications : []
444
+ return ids.map(id => values.find(value => value?.id === id)).filter(Boolean)
445
+ }
446
+
447
+ export function aggregateObligation(graph, id) {
448
+ const obligation = graphItem(graph, 'obligations', id, 'obligation')
449
+ if (obligation.status === 'WAIVED') return 'WAIVED'
450
+ if (obligation.stale === true || obligation.status === 'STALE') return 'STALE'
451
+ if (obligation.status === 'BLOCKED') return 'BLOCKED'
452
+ const verifications = linkedVerifications(graph, obligation)
453
+ if (verifications.some(value => value.verdict === 'FAIL')) return 'BLOCKED'
454
+ const required = Number.isInteger(obligation.minimumEvidenceCount) && obligation.minimumEvidenceCount > 0
455
+ ? obligation.minimumEvidenceCount
456
+ : 1
457
+ const independentKeys = new Set()
458
+ for (const verification of verifications) {
459
+ if (verification.verdict === 'PASS' && typeof verification.independenceKey === 'string' && verification.independenceKey.trim() !== '') independentKeys.add(verification.independenceKey)
460
+ }
461
+ if (independentKeys.size >= required) return 'SATISFIED'
462
+ return 'OPEN'
463
+ }
464
+
465
+ export function aggregateClaim(graph, id) {
466
+ const claim = graphItem(graph, 'claims', id, 'claim')
467
+ if (claim.stale === true || claim.verdict === 'STALE') return 'STALE'
468
+ if (claim.verdict === 'REFUTED') return 'REFUTED'
469
+ const obligationIds = Array.isArray(claim.obligationIds) ? claim.obligationIds : []
470
+ if (obligationIds.length === 0) return claim.verdict === 'SUPPORTED' ? 'SUPPORTED' : 'INCONCLUSIVE'
471
+ const statuses = obligationIds.map(obligationId => aggregateObligation(graph, obligationId))
472
+ if (statuses.some(status => status === 'BLOCKED')) return 'INCONCLUSIVE'
473
+ if (statuses.some(status => status === 'STALE')) return 'STALE'
474
+ if (statuses.some(status => status === 'WAIVED')) return 'CONDITIONAL'
475
+ return statuses.every(status => status === 'SATISFIED') ? 'SUPPORTED' : 'INCONCLUSIVE'
476
+ }
477
+
478
+ export function classifyGate(graph, targetState) {
479
+ const blockers = []
480
+ const conditionals = []
481
+ for (const obligation of Array.isArray(graph?.obligations) ? graph.obligations : []) {
482
+ if (obligation.required === false) continue
483
+ const status = aggregateObligation(graph, obligation.id)
484
+ if (status === 'BLOCKED' || status === 'STALE') blockers.push({ id: obligation.id, status, reason: 'required obligation is not valid' })
485
+ else if (status === 'WAIVED') conditionals.push({ id: obligation.id, status, reason: 'user waiver' })
486
+ else if (status !== 'SATISFIED') {
487
+ if (targetState === 'CONDITIONAL') conditionals.push({ id: obligation.id, status, reason: 'required evidence is incomplete' })
488
+ else blockers.push({ id: obligation.id, status, reason: 'required obligation is not satisfied' })
489
+ }
490
+ }
491
+ for (const claim of Array.isArray(graph?.claims) ? graph.claims : []) {
492
+ const verdict = aggregateClaim(graph, claim.id)
493
+ if (verdict === 'REFUTED' || verdict === 'STALE') blockers.push({ id: claim.id, verdict, reason: 'claim is refuted or stale' })
494
+ else if (verdict === 'CONDITIONAL') conditionals.push({ id: claim.id, verdict, reason: 'claim scope is conditional' })
495
+ else if (verdict === 'INCONCLUSIVE') {
496
+ if (targetState === 'CONDITIONAL') conditionals.push({ id: claim.id, verdict, reason: 'claim evidence is incomplete' })
497
+ else blockers.push({ id: claim.id, verdict, reason: 'claim is not supported' })
498
+ }
499
+ }
500
+ return { allowed: blockers.length === 0 && (targetState !== 'SOLVED' || conditionals.length === 0), blockers, conditionals, requiredNextState: blockers.length > 0 ? 'REVISE' : (conditionals.length > 0 ? 'CONDITIONAL' : targetState) }
501
+ }
502
+
503
+ /** The v3 interaction contract: decision records, layered verdicts, and the robustness gate. */
504
+ function hasDecisionRecord(scope, decisionPoint, { allowAuto = true } = {}) {
505
+ return Array.isArray(scope?.interactions) && scope.interactions.some(entry =>
506
+ entry && (entry.decisionPoint === decisionPoint || (allowAuto && entry.auto === true)))
507
+ }
508
+ /** D4 verdict records carry the attempt number they adjudicate: {decisionPoint:'D4', attempt: 2, ...} */
509
+ function hasD4ForAttempt(scope, attempt) {
510
+ return Array.isArray(scope?.interactions) && scope.interactions.some(entry =>
511
+ entry && (entry.auto === true || (entry.decisionPoint === 'D4' && entry.attempt === attempt)))
512
+ }
513
+ function decisionStackIds(scope) {
514
+ return Array.isArray(scope?.decisionStack)
515
+ ? scope.decisionStack.filter(entry => entry && typeof entry.id === 'string').map(entry => entry.id)
516
+ : []
517
+ }
518
+ /** D2 records carry the subproblem id(s) they cover: {decisionPoint:'D2', scope:['SP1'], ...} */
519
+ function hasSubproblemD2(scope, subproblem) {
520
+ if (!subproblem || !Array.isArray(scope?.interactions)) return true
521
+ return scope.interactions.some(entry => entry && entry.decisionPoint === 'D2' &&
522
+ (Array.isArray(entry.scope) ? entry.scope.includes(subproblem) : entry.subproblem === subproblem))
523
+ }
524
+ function interactionContractViolations(run, ledger, to, reason, subproblem) {
525
+ if (ledger.schemaVersion !== SCHEMA_VERSION) return []
526
+ const scope = ledger.scope ?? {}
527
+ if (scope.contractExempt === true) return []
528
+ const violations = []
529
+ const transitional = to !== undefined
530
+ const earlyStop = ['BLOCKED', 'CANCELLED'].includes(run.status)
531
+ const statusOrder = ['TRIAGE', 'RESEARCH', 'SCOPE_FROZEN', 'INPUT_PROFILED', 'CLAIMS_REGISTERED', 'CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'EVALUATE', 'CORRECTION_REQUIRED', 'REVISE', 'FORK']
532
+ const reached = (target) => transitional
533
+ ? to === target
534
+ : (statusOrder.indexOf(target) <= statusOrder.indexOf(run.status) && !earlyStop)
535
+ if (reached('SCOPE_FROZEN')) {
536
+ if (!hasDecisionRecord(scope, 'D0')) violations.push('D0 restatement interaction record missing (ledger.scope.interactions)')
537
+ if (!hasDecisionRecord(scope, 'D1')) violations.push('D1 routing interaction record missing (ledger.scope.interactions)')
538
+ if (!hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (literature survey precedes restatement; auto-authorization is not accepted)')
539
+ }
540
+ if (reached('CLAIMS_REGISTERED') && !hasDecisionRecord(scope, 'D2-G')) violations.push('D2-G global-assumptions interaction record missing (global assumptions must be confirmed before claims registration)')
541
+ const candidatesGate = reached('CANDIDATES_READY')
542
+ if (candidatesGate && !hasDecisionRecord(scope, 'D-R', { allowAuto: false })) violations.push('D-R literature-research interaction record missing (backstop check)')
543
+ const afterCandidates = ['CANDIDATES_READY', 'ATTEMPT', 'EXECUTE', 'VERIFY', 'REVISE', 'FORK', ...FINAL_STATES]
544
+ const enteringAttemptFromCandidates = transitional ? (to === 'ATTEMPT' && run.status === 'CANDIDATES_READY') : (run.currentAttempt >= 1)
545
+ if (enteringAttemptFromCandidates) {
546
+ if (transitional && !subproblem) violations.push('ATTEMPT from CANDIDATES_READY requires --subproblem <id>')
547
+ if (!hasDecisionRecord(scope, 'D3')) violations.push('D3 direction-selection interaction record missing')
548
+ if (transitional && subproblem && !hasSubproblemD2(scope, subproblem)) violations.push(`D2 assumption interaction record missing for subproblem ${subproblem} (run the gate with --subproblem <id>)`)
549
+ }
550
+ const enteringTerminalFromVerify = transitional
551
+ ? (FINAL_STATES.includes(to) && run.status === 'VERIFY')
552
+ : (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
553
+ if (enteringTerminalFromVerify && !hasD4ForAttempt(scope, run.currentAttempt)) violations.push(`D4 verdict interaction record for attempt ${run.currentAttempt} missing`)
554
+ if (transitional && (to === 'REVISE' || (to === 'RESEARCH' && run.status === 'VERIFY'))) {
555
+ const ids = decisionStackIds(scope)
556
+ if (ids.length > 0 && !ids.some(id => typeof reason === 'string' && reason.includes(id))) {
557
+ violations.push(`REVISE/RESEARCH reason must reference a decisionStack entry id (available: ${ids.join(', ')})`)
558
+ }
559
+ }
560
+ const terminalAndExempt = transitional
561
+ ? (FINAL_STATES.includes(to) && !['BLOCKED', 'CANCELLED'].includes(to))
562
+ : (FINAL_STATES.includes(run.status) && !['BLOCKED', 'CANCELLED'].includes(run.status))
563
+ if (terminalAndExempt) {
564
+ if (to === 'SOLVED' && scope.robustnessExempt !== true) {
565
+ const robos = Array.isArray(ledger.obligations) ? ledger.obligations.filter(o => o && o.kind === 'robustness' && o.required !== false) : []
566
+ const allSatisfied = robos.length > 0 && robos.every(o => o.status === 'SATISFIED' || (ledger.schemaVersion < 3 && o.status === 'PASS'))
567
+ if (!allSatisfied) violations.push(`terminal state requires ALL ${robos.length} required robustness obligations to be SATISFIED (or scope.robustnessExempt: true)`)
568
+ }
569
+ const subs = Array.isArray(ledger.subproblems) ? ledger.subproblems : []
570
+ if (subs.length > 0) {
571
+ const done = (sp) => sp.status === 'DONE' || sp.status === 'CLOSED'
572
+ const unfinished = subs.filter(sp => !done(sp))
573
+ if (unfinished.length > 0) violations.push(`terminal state requires every subproblem DONE (open: ${unfinished.map(sp => sp.id).join(', ')})`)
574
+ const depGaps = subs.filter(sp => done(sp) && Array.isArray(sp.dependencies) && sp.dependencies.some(dep => {
575
+ const d = subs.find(x => x.id === dep); return d === undefined || !done(d)
576
+ }))
577
+ if (depGaps.length > 0) violations.push(`subproblem dependencies incomplete: ${depGaps.map(sp => sp.id).join(', ')}`)
578
+ }
579
+ if (scope.cleanupPassed !== true) violations.push('terminal state requires scope.cleanupPassed: true (run the cleanup checklist in run-directory.md)')
580
+ }
581
+ return violations
582
+ }
583
+ function enforceInteractionContract(run, ledger, to, reason) {
584
+ const violations = interactionContractViolations(run, ledger, to, reason)
585
+ if (violations.length > 0) throw new Error(`interaction contract violated: ${violations.join('; ')}`)
586
+ }
587
+
588
+ function enforceSolvedGate(run, ledger) {
589
+ if (ledger.claims.length < 1) throw new Error('SOLVED requires at least one claim')
590
+ const graphClaims = ledger.schemaVersion >= 3 && ledger.claims.some(item => Object.hasOwn(item, 'verdict') || Object.hasOwn(item, 'obligationIds'))
591
+ if (graphClaims) {
592
+ const gate = classifyGate(ledger, 'SOLVED')
593
+ if (gate.blockers.length > 0) throw new Error('required obligations remain open: ' + gate.blockers.map(item => item.id).join(', '))
594
+ if (gate.conditionals.length > 0) throw new Error('SOLVED cannot accept conditional claims: ' + gate.conditionals.map(item => item.id).join(', '))
595
+ } else if (ledger.obligations.some(item => item.required !== false && item.status !== 'PASS' && item.status !== 'SATISFIED')) {
596
+ throw new Error('required obligations remain open')
597
+ }
598
+ if (ledger.issues.some(item => item.severity === 'critical' && item.status !== 'CLOSED')) throw new Error('critical issues remain open')
599
+ if (run.mode === 'high-assurance' && ledger.scope.independentAuditPassed !== true) throw new Error('independent audit must pass')
600
+ }
601
+
602
+ function validateTransitionInput(input) {
603
+ if (typeof input?.reason !== 'string' || input.reason.trim().length === 0) throw new Error('transition reason must be a nonempty string')
604
+ for (const key of ['evidenceIds', 'issueIds']) {
605
+ if (input[key] !== undefined && (!Array.isArray(input[key]) || input[key].some(id => typeof id !== 'string'))) throw new Error(`${key} must be an array of strings`)
606
+ }
607
+ if (linkedIds(input).length === 0) throw new Error('transition requires at least one evidence or issue ID')
608
+ if (input.patch !== undefined && (!isObject(input.patch) || Object.keys(input.patch).some(key => key !== 'bestCandidateId'))) throw new Error('patch may only contain bestCandidateId')
609
+ if (input.patch && (!Object.hasOwn(input.patch, 'bestCandidateId') || (input.patch.bestCandidateId !== null && typeof input.patch.bestCandidateId !== 'string'))) throw new Error('bestCandidateId must be a string or null')
610
+ }
611
+
612
+ function validateAuthoritativeState(run, ledger, events) {
613
+ const diagnostics = []
614
+ validateRunShape(run, diagnostics)
615
+ validateLedgerShape(ledger, diagnostics)
616
+ validateJournal(events, diagnostics)
617
+ if (run.taskId !== ledger.taskId || events.some(event => event.taskId !== run.taskId)) diagnostics.push('task mismatch')
618
+ const last = events.at(-1)
619
+ if (!last || run.eventSequence !== last.sequence) diagnostics.push('state/journal sequence mismatch')
620
+ if (!last || !deeplyEqual(run, last.snapshot)) diagnostics.push('state/journal snapshot mismatch')
621
+ if (diagnostics.length > 0) throw new Error(`invalid run state: ${diagnostics.join('; ')}`)
622
+ }
623
+
624
+ export async function transitionRun(root, input, runtime = {}) {
625
+ validateTransitionInput(input)
626
+ return withLock(root, MUTATION_LOCK_FILE, 'transition', async () => {
627
+ const p = paths(root)
628
+ const run = await readJson(p.run)
629
+ const ledger = await readJson(p.ledger)
630
+ const events = await readEvents(p.events)
631
+ validateAuthoritativeState(run, ledger, events)
632
+ if (!isAllowedTransition(run.status, input.to)) throw new Error(illegalTransitionMessage(run.status, input.to))
633
+ if (input.to === 'SOLVED') enforceSolvedGate(run, ledger)
634
+ const timestamp = now()
635
+ const bestCandidateId = input.patch && Object.hasOwn(input.patch, 'bestCandidateId') ? input.patch.bestCandidateId : run.bestCandidateId
636
+ const next = { ...run, status: input.to, currentAttempt: nextAttempt(run.currentAttempt, input.to), eventSequence: run.eventSequence + 1, bestCandidateId, updatedAt: timestamp }
637
+ const event = {
638
+ schemaVersion: SCHEMA_VERSION, sequence: next.eventSequence, type: 'STATUS_TRANSITION', taskId: run.taskId,
639
+ from: run.status, to: input.to, reason: input.reason, evidenceIds: input.evidenceIds ?? [], issueIds: input.issueIds ?? [], timestamp, snapshot: clone(next),
640
+ }
641
+ const prospectiveEvents = [...events, event]
642
+ validateAuthoritativeState(next, ledger, prospectiveEvents)
643
+ await appendFile(p.events, `${JSON.stringify(event)}\n`, 'utf8')
644
+ await atomicWrite(p.run, next, runtime)
645
+ return clone(next)
646
+ }, runtime)
647
+ }
648
+
649
+ async function readEvents(path) {
650
+ const text = await readFile(path, 'utf8')
651
+ if (!text.trim()) return []
652
+ return text.trimEnd().split(/\r?\n/).map((line, index) => {
653
+ try { return JSON.parse(line) } catch { throw new Error(`invalid event JSON at line ${index + 1}`) }
654
+ })
655
+ }
656
+
657
+ async function validateRunUnlocked(root, expectedTaskId) {
658
+ const diagnostics = []
659
+ const warnings = []
660
+ let run, ledger, events
661
+ const p = paths(root)
662
+ try { run = await readJson(p.run) } catch (error) { diagnostics.push(`run unreadable: ${error.message}`) }
663
+ try { ledger = await readJson(p.ledger) } catch (error) { diagnostics.push(`ledger unreadable: ${error.message}`) }
664
+ try { events = await readEvents(p.events) } catch (error) { diagnostics.push(error.message) }
665
+ if (!run || !ledger || !events) return { valid: false, diagnostics, warnings }
666
+ validateRunShape(run, diagnostics)
667
+ validateLedgerShape(ledger, diagnostics)
668
+ validateJournal(events, diagnostics)
669
+ if (run.taskId !== ledger.taskId || events.some(event => event.taskId !== run.taskId) || (expectedTaskId && expectedTaskId !== run.taskId)) diagnostics.push('task mismatch')
670
+ const last = events.at(-1)
671
+ if (last && run.eventSequence !== last.sequence) diagnostics.push('state/journal sequence mismatch')
672
+ if (last && JSON.stringify(run) !== JSON.stringify(last.snapshot)) diagnostics.push('state/journal snapshot mismatch')
673
+ if (ledger.schemaVersion !== SCHEMA_VERSION) warnings.push(`legacy run (ledger schema v${ledger.schemaVersion}): interaction contract not enforced`)
674
+ else {
675
+ for (const violation of interactionContractViolations(run, ledger, undefined, undefined)) {
676
+ diagnostics.push(`interaction contract: ${violation}`)
677
+ }
678
+ if (run.currentAttempt >= 1) {
679
+ const reportPath = join(root, 'attempts', String(run.currentAttempt), 'report.md')
680
+ try {
681
+ const reportText = await readFile(reportPath, 'utf8')
682
+ for (const section of ['问题重述', '问题分析', '模型假设', '模型建立与求解', '验证', '鲁棒性', '评价与改进', '参考文献']) {
683
+ if (!reportText.includes(section)) warnings.push(`attempt ${run.currentAttempt} report.md missing runlog digest section: ${section}`)
684
+ }
685
+ } catch {
686
+ warnings.push(`attempt ${run.currentAttempt} report.md unreadable (runlog digest required)`)
687
+ }
688
+ }
689
+ for (const assumption of Array.isArray(ledger.assumptions) ? ledger.assumptions : []) {
690
+ const revised = assumption && (assumption.status === 'revised' || (Array.isArray(assumption.revisionHistory) && assumption.revisionHistory.length > 0))
691
+ if (!revised) continue
692
+ for (const claim of Array.isArray(ledger.claims) ? ledger.claims : []) {
693
+ const depends = claim && Array.isArray(claim.assumptions) && claim.assumptions.includes(assumption.id)
694
+ if (depends && claim.status === 'VERIFIED' && (!Array.isArray(claim.evidenceIds) || claim.evidenceIds.length === 0)) {
695
+ warnings.push(`claim ${claim.id} depends on revised assumption ${assumption.id} but has no post-revision evidence`)
696
+ }
697
+ }
698
+ }
699
+ }
700
+ return { valid: diagnostics.length === 0, diagnostics, warnings, run: clone(run) }
701
+ }
702
+
703
+ export async function validateRun(root, expectedTaskId, runtime = {}) {
704
+ return withLock(root, MUTATION_LOCK_FILE, 'validate', () => validateRunUnlocked(root, expectedTaskId), runtime)
705
+ }
706
+
707
+ async function coverageGate(root, ledger, to) {
708
+ const gatedStates = new Set(['EVALUATE', 'SOLVED', 'CONDITIONAL'])
709
+ if (!gatedStates.has(to) || ledger.schemaVersion < 3 || !Array.isArray(ledger.requirements) || ledger.requirements.length === 0) return { blockers: [], conditionals: [], violations: [] }
710
+ const path = join(root, 'answer-coverage.json')
711
+ let raw
712
+ try { raw = JSON.parse(await readFile(path, 'utf8')) } catch (error) {
713
+ const message = error.code === 'ENOENT' ? 'answer-coverage.json missing' : 'answer-coverage.json invalid: ' + error.message
714
+ return { blockers: [{ id: 'coverage', reason: message }], conditionals: [], violations: [message] }
715
+ }
716
+ const entries = Array.isArray(raw) ? raw : raw.entries
717
+ const result = validateCoverage(entries)
718
+ const blockers = result.blockers.map(id => ({ id, reason: 'requirement coverage is ' + id }))
719
+ const conditionals = result.conditionals.map(id => ({ id, reason: 'partial requirement coverage is ' + id }))
720
+ if (to === 'SOLVED' && conditionals.length > 0) {
721
+ blockers.push(...conditionals)
722
+ conditionals.length = 0
723
+ }
724
+ return { blockers, conditionals, violations: [...blockers, ...conditionals].map(item => item.reason) }
725
+ }
726
+
727
+ /**
728
+ * Check the v2 interaction contract for a PROSPECTIVE transition without
729
+ * mutating anything. The agent workflow must call this before `transition`;
730
+ * the CLI `transition` command enforces it internally and has NO escape hatch.
731
+ */
732
+ export async function gateTransition(root, { to, reason, subproblem }, runtime = {}) {
733
+ return withLock(root, MUTATION_LOCK_FILE, 'gate', async () => {
734
+ const p = paths(root)
735
+ const run = await readJson(p.run)
736
+ const ledger = await readJson(p.ledger)
737
+ const violations = interactionContractViolations(run, ledger, to, reason, subproblem)
738
+ const coverage = await coverageGate(root, ledger, to)
739
+ violations.push(...coverage.violations)
740
+ const blockers = [
741
+ ...violations.map(reason => ({ id: 'interaction', reason })),
742
+ ...coverage.blockers,
743
+ ]
744
+ const conditionals = [...coverage.conditionals]
745
+ const exempt = ledger.scope?.contractExempt === true
746
+ if (to === 'CANDIDATES_READY' && !exempt) {
747
+ const sourcesPath = join(root, 'research', 'sources.jsonl')
748
+ try {
749
+ if (!(await readFile(sourcesPath, 'utf8')).trim()) violations.push('research/sources.jsonl is empty (pre-modeling literature survey artifacts are mandatory)')
750
+ } catch {
751
+ violations.push('research/sources.jsonl missing (pre-modeling literature survey artifacts are mandatory)')
752
+ }
753
+ }
754
+ if (FINAL_STATES.includes(to) && !['BLOCKED', 'CANCELLED'].includes(to) && !exempt) {
755
+ const reportPath = join(root, 'attempts', String(run.currentAttempt), 'report.md')
756
+ try {
757
+ await readFile(reportPath, 'utf8')
758
+ } catch {
759
+ violations.push(`attempts/${run.currentAttempt}/report.md missing (write the runlog report before a terminal transition)`)
760
+ }
761
+ }
762
+ return { allowed: blockers.length === 0 && conditionals.length === 0, violations, blockers, conditionals, requiredNextState: blockers.length > 0 ? 'REVISE' : (conditionals.length > 0 ? 'CONDITIONAL' : to), status: run.status }
763
+ }, runtime)
764
+ }
765
+
766
+ export async function recoverRun(root, runtime = {}) {
767
+ return withLock(root, MUTATION_LOCK_FILE, 'recover', async () => {
768
+ const p = paths(root)
769
+ const events = await readEvents(p.events)
770
+ if (events.length === 0) throw new Error('event journal is empty')
771
+ const last = events.at(-1)
772
+ const diagnostics = []
773
+ validateJournal(events, diagnostics)
774
+ if (diagnostics.length > 0) throw new Error(`malformed journal: ${diagnostics.join('; ')}`)
775
+ const solvedEvent = events.find(event => event.to === 'SOLVED')
776
+ if (solvedEvent) {
777
+ const ledger = await readJson(p.ledger)
778
+ enforceSolvedGate(solvedEvent.snapshot, ledger)
779
+ }
780
+ await runtime.hooks?.afterJournalValidated?.()
781
+ const snapshot = clone(last.snapshot)
782
+ await atomicWrite(p.run, snapshot, runtime)
783
+ return snapshot
784
+ }, runtime)
785
+ }
786
+
787
+ async function statusRunUnlocked(root) { return readJson(paths(root).run) }
788
+ export async function statusRun(root, runtime = {}) {
789
+ return withLock(root, MUTATION_LOCK_FILE, 'status', () => statusRunUnlocked(root), runtime)
790
+ }
791
+
792
+ function parseOptions(args) {
793
+ const options = { _: [] }
794
+ for (let index = 0; index < args.length; index += 1) {
795
+ const token = args[index]
796
+ if (!token.startsWith('--')) options._.push(token)
797
+ else { const key = token.slice(2); const value = args[index + 1]; options[key] = value; index += 1 }
798
+ }
799
+ return options
800
+ }
801
+ async function cli(argv) {
802
+ const [command, rootArg = '.', ...rest] = argv
803
+ const root = resolve(rootArg)
804
+ const options = parseOptions(rest)
805
+ if (command === 'init') return initRun(root, { taskId: options['task-id'], mode: options.mode ?? 'standard' })
806
+ if (command === 'gate') {
807
+ const result = await gateTransition(root, { to: options.to, reason: options.reason, subproblem: options.subproblem })
808
+ if (!result.allowed) process.exitCode = 1
809
+ return result
810
+ }
811
+ if (command === 'transition') {
812
+ const gate = await gateTransition(root, { to: options.to, reason: options.reason, subproblem: options.subproblem })
813
+ if (!gate.allowed) {
814
+ process.exitCode = 1
815
+ return { error: `interaction contract violated: ${gate.violations.join('; ')}`, gate }
816
+ }
817
+ return transitionRun(root, { to: options.to, reason: options.reason, evidenceIds: options.evidence ? options.evidence.split(',').filter(Boolean) : [], issueIds: options.issues ? options.issues.split(',').filter(Boolean) : [], patch: options.candidate ? { bestCandidateId: options.candidate } : undefined })
818
+ }
819
+ if (command === 'validate') {
820
+ const result = await validateRun(root, options['task-id'])
821
+ if (!result.valid) process.exitCode = 1
822
+ return result
823
+ }
824
+ if (command === 'recover') return recoverRun(root)
825
+ if (command === 'status') return statusRun(root)
826
+ throw new Error('usage: run-state.mjs <init|transition|gate|validate|recover|status> <run-directory> [options]')
827
+ }
828
+ const invoked = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
829
+ if (invoked) cli(process.argv.slice(2)).then(result => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(error => { process.stdout.write(`${JSON.stringify({ error: error.message })}\n`); process.exitCode = 1 })