codex-orchestrator 2.0.10 → 2.0.11

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 (109) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +26 -1
  3. package/dist/src/index.d.ts +3 -2
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +1 -1
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/v2/acceptance-proof.d.ts +29 -16
  8. package/dist/src/v2/acceptance-proof.d.ts.map +1 -1
  9. package/dist/src/v2/acceptance-proof.js +159 -129
  10. package/dist/src/v2/acceptance-proof.js.map +1 -1
  11. package/dist/src/v2/adapters/command.d.ts +1 -0
  12. package/dist/src/v2/adapters/command.d.ts.map +1 -1
  13. package/dist/src/v2/adapters/command.js +1 -1
  14. package/dist/src/v2/adapters/command.js.map +1 -1
  15. package/dist/src/v2/adapters/gh-pull-request-adapter.js +1 -0
  16. package/dist/src/v2/adapters/gh-pull-request-adapter.js.map +1 -1
  17. package/dist/src/v2/adapters/pull-requests.d.ts +1 -0
  18. package/dist/src/v2/adapters/pull-requests.d.ts.map +1 -1
  19. package/dist/src/v2/adapters/pull-requests.js +1 -0
  20. package/dist/src/v2/adapters/pull-requests.js.map +1 -1
  21. package/dist/src/v2/adapters/worktree.js +3 -3
  22. package/dist/src/v2/adapters/worktree.js.map +1 -1
  23. package/dist/src/v2/atomic-store.d.ts +15 -0
  24. package/dist/src/v2/atomic-store.d.ts.map +1 -1
  25. package/dist/src/v2/atomic-store.js +55 -8
  26. package/dist/src/v2/atomic-store.js.map +1 -1
  27. package/dist/src/v2/candidate.d.ts +152 -0
  28. package/dist/src/v2/candidate.d.ts.map +1 -0
  29. package/dist/src/v2/candidate.js +129 -0
  30. package/dist/src/v2/candidate.js.map +1 -0
  31. package/dist/src/v2/checked-change.d.ts +37 -6
  32. package/dist/src/v2/checked-change.d.ts.map +1 -1
  33. package/dist/src/v2/checked-change.js +71 -0
  34. package/dist/src/v2/checked-change.js.map +1 -1
  35. package/dist/src/v2/cli.d.ts +6 -0
  36. package/dist/src/v2/cli.d.ts.map +1 -1
  37. package/dist/src/v2/cli.js +57 -21
  38. package/dist/src/v2/cli.js.map +1 -1
  39. package/dist/src/v2/codex-process.d.ts +1 -0
  40. package/dist/src/v2/codex-process.d.ts.map +1 -1
  41. package/dist/src/v2/codex-process.js +12 -1
  42. package/dist/src/v2/codex-process.js.map +1 -1
  43. package/dist/src/v2/contained-report-operation.d.ts +196 -30
  44. package/dist/src/v2/contained-report-operation.d.ts.map +1 -1
  45. package/dist/src/v2/contained-report-operation.js +464 -150
  46. package/dist/src/v2/contained-report-operation.js.map +1 -1
  47. package/dist/src/v2/direct-delivery.d.ts +10 -28
  48. package/dist/src/v2/direct-delivery.d.ts.map +1 -1
  49. package/dist/src/v2/direct-delivery.js +44 -114
  50. package/dist/src/v2/direct-delivery.js.map +1 -1
  51. package/dist/src/v2/implementation-reviewer.d.ts +4 -24
  52. package/dist/src/v2/implementation-reviewer.d.ts.map +1 -1
  53. package/dist/src/v2/implementation-reviewer.js +29 -35
  54. package/dist/src/v2/implementation-reviewer.js.map +1 -1
  55. package/dist/src/v2/owner-control-lock.d.ts +13 -1
  56. package/dist/src/v2/owner-control-lock.d.ts.map +1 -1
  57. package/dist/src/v2/owner-control-lock.js +66 -7
  58. package/dist/src/v2/owner-control-lock.js.map +1 -1
  59. package/dist/src/v2/proof-report.d.ts +2 -1
  60. package/dist/src/v2/proof-report.d.ts.map +1 -1
  61. package/dist/src/v2/proof-report.js +6 -40
  62. package/dist/src/v2/proof-report.js.map +1 -1
  63. package/dist/src/v2/proof-store.d.ts +19 -7
  64. package/dist/src/v2/proof-store.d.ts.map +1 -1
  65. package/dist/src/v2/proof-store.js +162 -41
  66. package/dist/src/v2/proof-store.js.map +1 -1
  67. package/dist/src/v2/review-feedback-coordinator.d.ts +7 -1
  68. package/dist/src/v2/review-feedback-coordinator.d.ts.map +1 -1
  69. package/dist/src/v2/review-feedback-coordinator.js +97 -70
  70. package/dist/src/v2/review-feedback-coordinator.js.map +1 -1
  71. package/dist/src/v2/review-feedback.d.ts +0 -18
  72. package/dist/src/v2/review-feedback.d.ts.map +1 -1
  73. package/dist/src/v2/review-feedback.js +9 -34
  74. package/dist/src/v2/review-feedback.js.map +1 -1
  75. package/dist/src/v2/route-coordinator.d.ts +7 -12
  76. package/dist/src/v2/route-coordinator.d.ts.map +1 -1
  77. package/dist/src/v2/route-coordinator.js +33 -128
  78. package/dist/src/v2/route-coordinator.js.map +1 -1
  79. package/dist/src/v2/route-decision.d.ts +0 -9
  80. package/dist/src/v2/route-decision.d.ts.map +1 -1
  81. package/dist/src/v2/route-decision.js +7 -27
  82. package/dist/src/v2/route-decision.js.map +1 -1
  83. package/dist/src/v2/run-issue.d.ts +64 -26
  84. package/dist/src/v2/run-issue.d.ts.map +1 -1
  85. package/dist/src/v2/run-issue.js +1298 -620
  86. package/dist/src/v2/run-issue.js.map +1 -1
  87. package/dist/src/v2/run-store.d.ts +39 -25
  88. package/dist/src/v2/run-store.d.ts.map +1 -1
  89. package/dist/src/v2/run-store.js +469 -88
  90. package/dist/src/v2/run-store.js.map +1 -1
  91. package/dist/src/v2/runtime.d.ts +61 -36
  92. package/dist/src/v2/runtime.d.ts.map +1 -1
  93. package/dist/src/v2/runtime.js +992 -402
  94. package/dist/src/v2/runtime.js.map +1 -1
  95. package/dist/src/v2/spec-coordinator.d.ts +16 -28
  96. package/dist/src/v2/spec-coordinator.d.ts.map +1 -1
  97. package/dist/src/v2/spec-coordinator.js +81 -43
  98. package/dist/src/v2/spec-coordinator.js.map +1 -1
  99. package/dist/src/v2/spec-delivery.d.ts +3 -34
  100. package/dist/src/v2/spec-delivery.d.ts.map +1 -1
  101. package/dist/src/v2/spec-delivery.js +42 -107
  102. package/dist/src/v2/spec-delivery.js.map +1 -1
  103. package/docs/deep-dive.md +52 -14
  104. package/internal-workflow/evals/coding-skill-evals.json +58 -17
  105. package/internal-workflow/manifest.json +1 -1
  106. package/internal-workflow/schemas/proof-report-v1.json +1 -1
  107. package/internal-workflow/skills/code-review/SKILL.md +18 -0
  108. package/internal-workflow/skills/spec-implementer/SKILL.md +8 -1
  109. package/package.json +1 -1
@@ -1,10 +1,15 @@
1
- import { validateDirectReview } from './direct-delivery.js';
1
+ import { validateDurableMutableInvocation, validateDurableReportInvocation, } from './contained-report-operation.js';
2
+ import { projectTerminalDirectReview, validateDirectReview } from './direct-delivery.js';
2
3
  import { validateSpecDelivery } from './spec-delivery.js';
3
4
  import { validateRouteExecution, validateRouteReceipt, validateRouteStateInvariant, } from './route-decision.js';
4
5
  import { validateWaitingHumanExecution } from './waiting-human.js';
5
6
  import { posix } from 'node:path';
7
+ import { readFile } from 'node:fs/promises';
6
8
  import { AtomicStateFile } from './atomic-store.js';
7
- import { createReviewFeedbackBootstrap, validateImplementationInvocation, validateReviewFeedbackExecution, } from './review-feedback.js';
9
+ import { writeDurableAtomicFile } from './adapters/durable-atomic-file.js';
10
+ import { canonicalJson, sha256 } from './containment.js';
11
+ import { validateCandidateBinding, validateCandidateExecutionLease } from './candidate.js';
12
+ import { blockReviewFeedback, createReviewFeedbackBootstrap, validateReviewFeedbackExecution, } from './review-feedback.js';
8
13
  const SHA256_PATTERN = /^[0-9a-f]{64}$/;
9
14
  const GIT_SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
10
15
  const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -22,15 +27,132 @@ export class RouteInitializationUnrecoverableError extends Error {
22
27
  }
23
28
  export class FileRunRecordWriter {
24
29
  file;
30
+ backupPath;
31
+ migrationMetadataPath;
32
+ reportLifecycleBackupPath;
33
+ mutableLifecycleBackupPath;
34
+ now;
25
35
  constructor(path, options = {}) {
26
36
  this.file = new AtomicStateFile(path, validateRunStateFile, options);
37
+ this.backupPath = `${path}.pre-candidate-v3`;
38
+ this.migrationMetadataPath = `${this.backupPath}.metadata.json`;
39
+ this.reportLifecycleBackupPath = `${path}.pre-report-lifecycle-v1`;
40
+ this.mutableLifecycleBackupPath = `${path}.pre-mutable-lifecycle-v1`;
41
+ this.now = options.now ?? (() => new Date().toISOString());
27
42
  }
28
43
  async read() {
29
- return await this.file.read() ?? emptyRunState();
44
+ try {
45
+ return await this.file.read() ?? emptyRunState();
46
+ }
47
+ catch (error) {
48
+ return this.migrateLegacyReportLifecycle(error);
49
+ }
30
50
  }
31
51
  async compareAndSwap(expectedGeneration, next) {
32
52
  validateRunStateBody(next);
33
- return this.file.compareAndSwap(expectedGeneration, { ...structuredClone(next), generation: expectedGeneration + 1 });
53
+ const candidate = { ...structuredClone(next), generation: expectedGeneration + 1 };
54
+ return this.file.compareAndSwapWithRaw(expectedGeneration, candidate, async (priorBytes) => {
55
+ if (!priorBytes)
56
+ return;
57
+ const raw = parseRawState(priorBytes);
58
+ if (raw.version === 3 && next.version !== 3)
59
+ throw new Error('run state V3 downgrade requires explicit rollback');
60
+ if (raw.version === 2 && next.version === 3)
61
+ await this.ensureCandidateMigrationBackup(priorBytes, raw.generation);
62
+ });
63
+ }
64
+ async markPublicationEffectPossible() {
65
+ await this.file.withExclusiveRaw(async () => {
66
+ const existing = await readMigrationMetadata(this.migrationMetadataPath);
67
+ if (!existing?.publicationEffectPossible) {
68
+ const now = this.now();
69
+ await writeDurableAtomicFile(this.migrationMetadataPath, `${canonicalJson({
70
+ version: 1,
71
+ sourceGeneration: existing?.sourceGeneration ?? null,
72
+ sourceBytesSha256: existing?.sourceBytesSha256 ?? null,
73
+ publicationEffectPossible: true,
74
+ createdAt: existing?.createdAt ?? now,
75
+ updatedAt: now,
76
+ })}\n`);
77
+ }
78
+ return { result: undefined };
79
+ });
80
+ }
81
+ async rollbackCandidateMigration(input) {
82
+ return this.file.withExclusiveRaw(async (_currentBytes, current) => {
83
+ const metadata = await readMigrationMetadata(this.migrationMetadataPath);
84
+ if (!metadata || metadata.sourceGeneration === null || metadata.sourceBytesSha256 === null)
85
+ throw new Error('candidate V3 rollback backup is unavailable');
86
+ if (metadata.publicationEffectPossible)
87
+ throw new Error('candidate V3 rollback is forbidden after publication boundary');
88
+ if (!current || current.version !== 3)
89
+ throw new Error('candidate V3 rollback requires active V3 state');
90
+ await input.assertNoActiveProcesses();
91
+ await input.cleanupCandidateState(structuredClone(current));
92
+ const backup = await readOptionalFile(this.backupPath);
93
+ if (!backup || sha256(backup) !== metadata.sourceBytesSha256)
94
+ throw new Error('candidate V3 rollback backup identity is invalid');
95
+ const restored = parseRawState(backup);
96
+ if (restored.version !== 2 || restored.generation !== metadata.sourceGeneration)
97
+ throw new Error('candidate V3 rollback backup generation is invalid');
98
+ return { result: restored, replacementBytes: backup };
99
+ });
100
+ }
101
+ async ensureCandidateMigrationBackup(rawV2Bytes, generation) {
102
+ const sourceBytesSha256 = sha256(rawV2Bytes);
103
+ const existing = await readMigrationMetadata(this.migrationMetadataPath);
104
+ const backup = await readOptionalFile(this.backupPath);
105
+ if (existing?.publicationEffectPossible)
106
+ throw new Error('candidate V3 migration backup cannot change after publication boundary');
107
+ if (existing?.sourceGeneration === generation && existing.sourceBytesSha256 === sourceBytesSha256
108
+ && backup?.equals(rawV2Bytes))
109
+ return;
110
+ await writeDurableAtomicFile(this.backupPath, rawV2Bytes);
111
+ const now = this.now();
112
+ await writeDurableAtomicFile(this.migrationMetadataPath, `${canonicalJson({
113
+ version: 1,
114
+ sourceGeneration: generation,
115
+ sourceBytesSha256,
116
+ publicationEffectPossible: false,
117
+ createdAt: now,
118
+ updatedAt: now,
119
+ })}\n`);
120
+ }
121
+ async migrateLegacyReportLifecycle(originalError) {
122
+ return this.file.withExclusiveUnparsedRaw(async (priorBytes) => {
123
+ if (!priorBytes)
124
+ return { result: emptyRunState() };
125
+ let raw;
126
+ try {
127
+ raw = JSON.parse(priorBytes.toString('utf8'));
128
+ }
129
+ catch {
130
+ throw originalError;
131
+ }
132
+ try {
133
+ return { result: validateRunStateFile(raw) };
134
+ }
135
+ catch {
136
+ const mutable = hasLegacyMutableLifecycle(raw);
137
+ let backupPath = mutable ? this.mutableLifecycleBackupPath : this.reportLifecycleBackupPath;
138
+ if (!mutable) {
139
+ const priorReportBackup = await readOptionalFile(backupPath);
140
+ if (priorReportBackup && !priorReportBackup.equals(priorBytes)) {
141
+ const generation = raw.generation;
142
+ backupPath = `${backupPath}.g${String(generation)}-${sha256(priorBytes).slice(0, 16)}`;
143
+ }
144
+ }
145
+ const migrated = canonicalizeLegacyReportLifecycle(raw, backupPath);
146
+ if (!migrated)
147
+ throw originalError;
148
+ const existingBackup = await readOptionalFile(backupPath);
149
+ if (existingBackup && !existingBackup.equals(priorBytes))
150
+ throw new Error('report lifecycle migration backup conflicts with current source bytes');
151
+ if (!existingBackup)
152
+ await writeDurableAtomicFile(backupPath, priorBytes);
153
+ return { result: migrated, replacementBytes: Buffer.from(`${canonicalJson(migrated)}\n`) };
154
+ }
155
+ });
34
156
  }
35
157
  }
36
158
  export class InMemoryRunRecordWriter {
@@ -41,14 +163,17 @@ export class InMemoryRunRecordWriter {
41
163
  async compareAndSwap(expectedGeneration, next) {
42
164
  if (this.state.generation !== expectedGeneration)
43
165
  throw new Error('run state generation conflict');
166
+ if (this.state.version === 3 && next.version !== 3)
167
+ throw new Error('run state V3 downgrade requires explicit rollback');
44
168
  const value = validateRunStateFile({ ...structuredClone(next), generation: expectedGeneration + 1 });
45
169
  this.state = value;
46
170
  return structuredClone(value);
47
171
  }
172
+ async markPublicationEffectPossible() { }
48
173
  }
49
174
  export function validateRunStateFile(value) {
50
175
  assertExactObject(value, ['schema', 'version', 'generation', 'runs'], 'run state');
51
- if (value.schema !== 'codex-orchestrator.agent-auto-state' || (value.version !== 1 && value.version !== 2))
176
+ if (value.schema !== 'codex-orchestrator.agent-auto-state' || ![1, 2, 3].includes(value.version))
52
177
  throw new Error('run state schema/version is invalid');
53
178
  if (!Number.isSafeInteger(value.generation) || value.generation <= 0)
54
179
  throw new Error('run state generation is invalid');
@@ -67,13 +192,17 @@ export function validateRunStateFile(value) {
67
192
  : structuredClone(run)),
68
193
  };
69
194
  }
195
+ if (value.version === 2)
196
+ assertNoCandidateV3Fields(value.runs);
70
197
  return value;
71
198
  }
72
199
  function validateRunStateBody(value) {
73
200
  assertExactObject(value, ['schema', 'version', 'runs'], 'run state body');
74
- if (value.schema !== 'codex-orchestrator.agent-auto-state' || value.version !== 2)
201
+ if (value.schema !== 'codex-orchestrator.agent-auto-state' || (value.version !== 2 && value.version !== 3))
75
202
  throw new Error('run state schema/version is invalid');
76
203
  validateRuns(value.runs);
204
+ if (value.version === 2)
205
+ assertNoCandidateV3Fields(value.runs);
77
206
  }
78
207
  function validateRuns(value) {
79
208
  if (!Array.isArray(value) || value.length > 256)
@@ -88,7 +217,6 @@ function validateRuns(value) {
88
217
  }
89
218
  function validateRunRecord(value, field) {
90
219
  const optional = [
91
- 'process',
92
220
  'checkedChangeSha256',
93
221
  'proofId',
94
222
  'proofReceipt',
@@ -103,10 +231,15 @@ function validateRunRecord(value, field) {
103
231
  'reviewFeedback',
104
232
  'checkQualification',
105
233
  'baselineChecks',
234
+ 'changeBindingVersion',
235
+ 'candidateBinding',
236
+ 'executionLease',
237
+ 'reportInvocation',
238
+ 'mutableInvocation',
106
239
  ].filter((key) => hasOwn(value, key));
107
240
  assertExactObject(value, [
108
241
  'runId', 'issueNumber', 'canonicalRepository', 'baseSha', 'branchName', 'worktreePath', 'lifecycle', 'cycle',
109
- 'reportRepairs', 'transportRetries', 'issueSnapshot', 'frozenCriteria', 'reworkFindings',
242
+ 'reportRepairs', 'issueSnapshot', 'frozenCriteria', 'reworkFindings',
110
243
  'packageVersion', 'workflowGeneration', 'skillHashes', 'checks', 'createdAt', 'updatedAt', ...optional,
111
244
  ], field);
112
245
  if (typeof value.runId !== 'string' || !UUID_V4_PATTERN.test(value.runId))
@@ -126,8 +259,6 @@ function validateRunRecord(value, field) {
126
259
  throw new Error(`${field}.cycle is invalid`);
127
260
  if (value.reportRepairs !== 0 && value.reportRepairs !== 1)
128
261
  throw new Error(`${field}.reportRepairs is invalid`);
129
- if (value.transportRetries !== 0 && value.transportRetries !== 1)
130
- throw new Error(`${field}.transportRetries is invalid`);
131
262
  validateIssueSnapshot(value.issueSnapshot, `${field}.issueSnapshot`);
132
263
  validateFrozenCriteria(value.frozenCriteria, `${field}.frozenCriteria`);
133
264
  validateStringList(value.reworkFindings, `${field}.reworkFindings`);
@@ -146,8 +277,32 @@ function validateRunRecord(value, field) {
146
277
  if (hasOwn(value, 'baselineChecks'))
147
278
  validateChecks(value.baselineChecks, `${field}.baselineChecks`, false);
148
279
  validateChecks(value.checks, `${field}.checks`);
149
- if (hasOwn(value, 'process'))
150
- validateProcess(value.process, `${field}.process`);
280
+ let reportInvocation;
281
+ if (hasOwn(value, 'reportInvocation')) {
282
+ const invocation = validateDurableReportInvocation(value.reportInvocation);
283
+ reportInvocation = invocation;
284
+ const expectedLifecycle = invocation.operation === 'triage' || invocation.operation === 'ambiguity-review'
285
+ ? 'triaging'
286
+ : invocation.operation === 'code-review' ? 'implementing' : 'spec-authoring';
287
+ if (value.lifecycle !== expectedLifecycle)
288
+ throw new Error(`${field}.reportInvocation lifecycle is invalid`);
289
+ }
290
+ if (hasOwn(value, 'mutableInvocation')) {
291
+ const invocation = validateDurableMutableInvocation(value.mutableInvocation);
292
+ if (value.lifecycle !== 'implementing')
293
+ throw new Error(`${field}.mutableInvocation lifecycle is invalid`);
294
+ if (invocation.worktreePath !== value.worktreePath || invocation.generationHash !== routeGenerationHash) {
295
+ throw new Error(`${field}.mutableInvocation binding is invalid`);
296
+ }
297
+ if (invocation.operation === 'qualification-repair' && !hasOwn(value, 'checkQualification')) {
298
+ throw new Error(`${field}.qualification mutableInvocation requires check qualification`);
299
+ }
300
+ const feedbackActive = hasOwn(value, 'reviewFeedback')
301
+ && value.reviewFeedback.activeBatch !== null;
302
+ if ((invocation.operation === 'review-feedback-implementation') !== feedbackActive) {
303
+ throw new Error(`${field}.mutableInvocation feedback binding is invalid`);
304
+ }
305
+ }
151
306
  if (hasOwn(value, 'checkedChangeSha256'))
152
307
  assertSha256(value.checkedChangeSha256, `${field}.checkedChangeSha256`);
153
308
  if (hasOwn(value, 'proofId'))
@@ -175,24 +330,9 @@ function validateRunRecord(value, field) {
175
330
  if (!hasOwn(value, 'routeReceipt') || value.routeReceipt.route !== 'direct') {
176
331
  throw new Error(`${field}.directReview requires a direct route`);
177
332
  }
178
- const rawProcess = hasOwn(value, 'process') && hasOwn(value.process, 'purpose')
179
- ? value.process
180
- : undefined;
181
- const process = rawProcess && !['spec-author', 'spec-review'].includes(rawProcess.purpose)
182
- ? {
183
- purpose: rawProcess.purpose,
184
- resumeLifecycle: rawProcess.resumeLifecycle,
185
- resumeReviewStage: rawProcess.resumeReviewStage,
186
- }
187
- : undefined;
188
333
  validateDirectReview(value.directReview, {
189
334
  lifecycle: value.lifecycle,
190
335
  ...(hasOwn(value, 'terminalOutcome') ? { terminalOutcome: directTerminalOutcome(value.terminalOutcome) } : {}),
191
- ...(process ? { process: {
192
- purpose: process.purpose,
193
- resumeLifecycle: process.resumeLifecycle,
194
- resumeReviewStage: process.resumeReviewStage,
195
- } } : {}),
196
336
  });
197
337
  }
198
338
  if (hasOwn(value, 'specDelivery')) {
@@ -209,6 +349,31 @@ function validateRunRecord(value, field) {
209
349
  validateReviewFeedbackExecution(value.reviewFeedback);
210
350
  validateReviewFeedbackRunInvariant(value, field);
211
351
  }
352
+ if (hasOwn(value, 'changeBindingVersion') && value.changeBindingVersion !== 2)
353
+ throw new Error(`${field}.changeBindingVersion is invalid`);
354
+ if (hasOwn(value, 'candidateBinding'))
355
+ validateCandidateBinding(value.candidateBinding, `${field}.candidateBinding`, value.runId);
356
+ if (hasOwn(value, 'executionLease'))
357
+ validateCandidateExecutionLease(value.executionLease, `${field}.executionLease`);
358
+ if (hasOwn(value, 'candidateBinding') !== hasOwn(value, 'changeBindingVersion'))
359
+ throw new Error(`${field} candidate binding version is incomplete`);
360
+ if (hasOwn(value, 'executionLease')) {
361
+ if (!hasOwn(value, 'candidateBinding'))
362
+ throw new Error(`${field}.executionLease requires candidate binding`);
363
+ const binding = value.candidateBinding;
364
+ const lease = value.executionLease;
365
+ if (lease.bindingId !== binding.bindingId || lease.candidateCommitSha !== binding.candidateCommitSha)
366
+ throw new Error(`${field}.executionLease binding is invalid`);
367
+ }
368
+ if (hasOwn(value, 'intent') && hasOwn(value.intent, 'candidateRef')) {
369
+ if (!hasOwn(value, 'candidateBinding'))
370
+ throw new Error(`${field}.intent candidate ref requires candidate binding`);
371
+ const binding = value.candidateBinding;
372
+ const intent = value.intent;
373
+ if (intent.candidateRef !== binding.candidateRef || intent.treeSha !== binding.candidateTreeSha) {
374
+ throw new Error(`${field}.intent candidate binding is invalid`);
375
+ }
376
+ }
212
377
  if (hasOwn(value, 'checkQualification'))
213
378
  validateCheckQualification(value.checkQualification, `${field}.checkQualification`);
214
379
  assertTimestamp(value.createdAt, `${field}.createdAt`);
@@ -224,14 +389,16 @@ function validateRunRecord(value, field) {
224
389
  }
225
390
  if (value.lifecycle === 'publishing' && !hasOwn(value, 'proofReceipt'))
226
391
  throw new Error(`${field} publishing requires proofReceipt`);
227
- if (value.lifecycle === 'safe-halt' && !hasOwn(value, 'process'))
228
- throw new Error(`${field} safe-halt requires retained process evidence`);
229
392
  if (value.lifecycle === 'review-ready' && (!hasOwn(value, 'proofReceipt') || hasOwn(value, 'intent'))) {
230
393
  throw new Error(`${field} review-ready requires proofReceipt and no intent`);
231
394
  }
232
- if (terminal && hasOwn(value, 'process'))
233
- throw new Error(`${field} terminal lifecycle cannot retain process ownership`);
234
- if (terminal && hasOwn(value, 'intent') && value.lifecycle !== 'transport-failed')
395
+ const retainedCandidateIntent = value.lifecycle === 'blocked'
396
+ && value.terminalOutcome?.status === 'blocked'
397
+ && value.terminalOutcome.kind === 'safety'
398
+ && !value.terminalOutcome.resumable
399
+ && hasOwn(value, 'candidateBinding')
400
+ && (value.intent?.kind === 'commit' || value.intent?.kind === 'review-update-commit');
401
+ if (terminal && hasOwn(value, 'intent') && value.lifecycle !== 'transport-failed' && !retainedCandidateIntent)
235
402
  throw new Error(`${field} terminal lifecycle cannot retain intent`);
236
403
  if (value.lifecycle === 'transport-failed' && hasOwn(value, 'intent')
237
404
  && value.terminalOutcome.resumable) {
@@ -239,6 +406,8 @@ function validateRunRecord(value, field) {
239
406
  }
240
407
  if (value.lifecycle === 'waiting-human' && !hasOwn(value, 'waitingHuman'))
241
408
  throw new Error(`${field} waiting-human lifecycle requires waitingHuman execution`);
409
+ if (reportInvocation)
410
+ validateReportInvocationBinding(value, reportInvocation, routeGenerationHash, field);
242
411
  validateRouteStateInvariant({
243
412
  lifecycle: value.lifecycle,
244
413
  routeExecution: value.routeExecution,
@@ -246,6 +415,39 @@ function validateRunRecord(value, field) {
246
415
  generationHash: routeGenerationHash,
247
416
  });
248
417
  }
418
+ function validateReportInvocationBinding(record, invocation, generationHash, field) {
419
+ if (invocation.generationHash !== generationHash)
420
+ throw new Error(`${field}.reportInvocation generation is invalid`);
421
+ if (invocation.operation === 'triage') {
422
+ if (!record.routeExecution || !['triage-in-flight', 'repair-in-flight'].includes(record.routeExecution.phase)) {
423
+ throw new Error(`${field}.triage reportInvocation phase is invalid`);
424
+ }
425
+ return;
426
+ }
427
+ if (invocation.operation === 'ambiguity-review') {
428
+ if (record.routeExecution?.phase !== 'review-in-flight')
429
+ throw new Error(`${field}.ambiguity reportInvocation phase is invalid`);
430
+ return;
431
+ }
432
+ if (invocation.operation === 'code-review') {
433
+ if (record.directReview?.status !== 'active'
434
+ || !['review-full', 'review-closure'].includes(record.directReview.stage ?? '')
435
+ || record.executionLease?.operation !== 'direct-review') {
436
+ throw new Error(`${field}.code-review reportInvocation binding is invalid`);
437
+ }
438
+ return;
439
+ }
440
+ if (invocation.operation === 'spec-author') {
441
+ if (!record.specDelivery || !['authoring', 'author-repair'].includes(record.specDelivery.stage)
442
+ || record.specDelivery.authorSessionId === null) {
443
+ throw new Error(`${field}.spec-author reportInvocation binding is invalid`);
444
+ }
445
+ return;
446
+ }
447
+ if (!record.specDelivery || !['review-full', 'review-closure'].includes(record.specDelivery.stage)) {
448
+ throw new Error(`${field}.spec-review reportInvocation binding is invalid`);
449
+ }
450
+ }
249
451
  function directTerminalOutcome(outcome) {
250
452
  if (outcome.status === 'review-ready')
251
453
  return undefined;
@@ -265,54 +467,36 @@ function validateWorkflowGeneration(value, field) {
265
467
  }
266
468
  assertSha256(value.contentSha256, `${field}.contentSha256`);
267
469
  }
268
- function validateProcess(value, field) {
269
- assertExactObject(value, [
270
- 'pid', 'processGroupId', 'startedAt', 'baseline', 'purpose', 'resumeLifecycle', 'resumeReviewStage',
271
- ], field);
272
- assertPositiveInteger(value.pid, `${field}.pid`);
273
- assertPositiveInteger(value.processGroupId, `${field}.processGroupId`);
274
- assertTimestamp(value.startedAt, `${field}.startedAt`);
275
- assertExactObject(value.baseline, [
276
- 'headSha', 'indexTreeSha', 'trackedContentSha256', 'untrackedContentSha256', 'worktreeIdentity',
277
- ], `${field}.baseline`);
278
- assertGitSha(value.baseline.headSha, `${field}.baseline.headSha`);
279
- assertGitSha(value.baseline.indexTreeSha, `${field}.baseline.indexTreeSha`);
280
- assertSha256(value.baseline.trackedContentSha256, `${field}.baseline.trackedContentSha256`);
281
- assertSha256(value.baseline.untrackedContentSha256, `${field}.baseline.untrackedContentSha256`);
282
- assertNonEmptyString(value.baseline.worktreeIdentity, `${field}.baseline.worktreeIdentity`);
283
- if (!['route', 'implementation', 'code-review', 'proof', 'spec-author', 'spec-review'].includes(value.purpose)) {
284
- throw new Error(`${field}.purpose is invalid`);
285
- }
286
- if (!isLifecycle(value.resumeLifecycle))
287
- throw new Error(`${field}.resumeLifecycle is invalid`);
288
- if (value.resumeReviewStage !== null && ![
289
- 'review-full', 'review-repair', 'review-closure',
290
- ].includes(value.resumeReviewStage))
291
- throw new Error(`${field}.resumeReviewStage is invalid`);
292
- }
293
470
  function validateChecks(value, field, allowUnchangedFailure = true) {
294
471
  if (!Array.isArray(value) || value.length > 256)
295
472
  throw new Error(`${field} is invalid`);
296
473
  const ids = new Set();
297
474
  for (const [index, check] of value.entries()) {
298
- assertExactObject(check, ['id', 'command', 'status', 'outputSha256'], `${field}[${index}]`);
475
+ const candidate = hasOwn(check, 'bindingId') || hasOwn(check, 'candidateTreeSha') || hasOwn(check, 'checkPolicySha256');
476
+ assertExactObject(check, [
477
+ 'id', 'command', 'status', 'outputSha256',
478
+ ...(candidate ? ['bindingId', 'candidateTreeSha', 'checkPolicySha256'] : []),
479
+ ], `${field}[${index}]`);
299
480
  assertNonEmptyString(check.id, `${field}[${index}].id`);
300
481
  assertNonEmptyString(check.command, `${field}[${index}].command`);
301
482
  if (check.status !== 'passed' && check.status !== 'failed'
302
483
  && (check.status !== 'unchanged-failure' || !allowUnchangedFailure))
303
484
  throw new Error(`${field}[${index}].status is invalid`);
304
485
  assertSha256(check.outputSha256, `${field}[${index}].outputSha256`);
486
+ if (candidate) {
487
+ assertSha256(check.bindingId, `${field}[${index}].bindingId`);
488
+ assertGitSha(check.candidateTreeSha, `${field}[${index}].candidateTreeSha`);
489
+ assertSha256(check.checkPolicySha256, `${field}[${index}].checkPolicySha256`);
490
+ if (check.status === 'unchanged-failure')
491
+ throw new Error(`${field}[${index}] candidate status is invalid`);
492
+ }
305
493
  if (ids.has(check.id))
306
494
  throw new Error(`${field} IDs must be unique`);
307
495
  ids.add(check.id);
308
496
  }
309
497
  }
310
498
  function validateCheckQualification(value, field) {
311
- const optional = [
312
- ...(hasOwn(value, 'implementationStarted') ? ['implementationStarted'] : []),
313
- ...(hasOwn(value, 'repairInvocation') ? ['repairInvocation'] : []),
314
- ...(hasOwn(value, 'deniedPathsBaseline') ? ['deniedPathsBaseline'] : []),
315
- ];
499
+ const optional = hasOwn(value, 'repairFindings') ? ['repairFindings'] : [];
316
500
  assertExactObject(value, ['version', 'checkPolicySha256', 'repairAttempts', 'checks', ...optional], field);
317
501
  if (value.version !== 1)
318
502
  throw new Error(`${field}.version is invalid`);
@@ -321,26 +505,8 @@ function validateCheckQualification(value, field) {
321
505
  throw new Error(`${field}.repairAttempts is invalid`);
322
506
  }
323
507
  validateChecks(value.checks, `${field}.checks`, false);
324
- if (hasOwn(value, 'implementationStarted') && typeof value.implementationStarted !== 'boolean') {
325
- throw new Error(`${field}.implementationStarted is invalid`);
326
- }
327
- if (hasOwn(value, 'repairInvocation') && value.repairInvocation !== null) {
328
- validateImplementationInvocation(value.repairInvocation);
329
- }
330
- if (hasOwn(value, 'deniedPathsBaseline'))
331
- assertSha256(value.deniedPathsBaseline, `${field}.deniedPathsBaseline`);
332
- if (hasOwn(value, 'deniedPathsBaseline') !== (hasOwn(value, 'repairInvocation') && value.repairInvocation !== null)) {
333
- throw new Error(`${field}.deniedPathsBaseline requires a repair invocation`);
334
- }
335
- const repairInvocation = hasOwn(value, 'repairInvocation')
336
- ? value.repairInvocation
337
- : undefined;
338
- if (value.implementationStarted === true && repairInvocation !== null && repairInvocation !== undefined) {
339
- throw new Error(`${field} cannot own qualification repair and implementation launches together`);
340
- }
341
- if (repairInvocation?.phase === 'launched' && value.repairAttempts === 0) {
342
- throw new Error(`${field}.repairAttempts must reserve a launched repair`);
343
- }
508
+ if (hasOwn(value, 'repairFindings'))
509
+ validateStringList(value.repairFindings, `${field}.repairFindings`);
344
510
  }
345
511
  function validateIssueSnapshot(value, field) {
346
512
  const optional = hasOwn(value, 'comments') ? ['comments'] : [];
@@ -408,10 +574,12 @@ function validateIntent(value, field) {
408
574
  validateStringArray(value.expected, `${field}.expected`);
409
575
  }
410
576
  else if (kind === 'commit') {
411
- assertExactObject(value, ['kind', 'parentSha', 'treeSha', 'message'], field);
577
+ assertExactObject(value, ['kind', 'parentSha', 'treeSha', 'message', ...(hasOwn(value, 'candidateRef') ? ['candidateRef'] : [])], field);
412
578
  assertGitSha(value.parentSha, `${field}.parentSha`);
413
579
  assertGitSha(value.treeSha, `${field}.treeSha`);
414
580
  assertNonEmptyString(value.message, `${field}.message`);
581
+ if (hasOwn(value, 'candidateRef'))
582
+ assertCandidateRef(value.candidateRef, `${field}.candidateRef`);
415
583
  }
416
584
  else if (kind === 'push') {
417
585
  assertExactObject(value, ['kind', 'branch', 'sha'], field);
@@ -437,11 +605,13 @@ function validateIntent(value, field) {
437
605
  validateStringArray(value.expected, `${field}.expected`);
438
606
  }
439
607
  else if (kind === 'review-update-commit') {
440
- assertExactObject(value, ['kind', 'batchId', 'parentSha', 'treeSha', 'message'], field);
608
+ assertExactObject(value, ['kind', 'batchId', 'parentSha', 'treeSha', 'message', ...(hasOwn(value, 'candidateRef') ? ['candidateRef'] : [])], field);
441
609
  assertSha256(value.batchId, `${field}.batchId`);
442
610
  assertGitSha(value.parentSha, `${field}.parentSha`);
443
611
  assertGitSha(value.treeSha, `${field}.treeSha`);
444
612
  assertNonEmptyString(value.message, `${field}.message`);
613
+ if (hasOwn(value, 'candidateRef'))
614
+ assertCandidateRef(value.candidateRef, `${field}.candidateRef`);
445
615
  }
446
616
  else if (kind === 'review-update-push') {
447
617
  assertExactObject(value, ['kind', 'batchId', 'branch', 'priorRemoteSha', 'sha', 'treeSha'], field);
@@ -563,7 +733,213 @@ function validateStringArray(value, field) {
563
733
  throw new Error(`${field} must be sorted and unique`);
564
734
  }
565
735
  function emptyRunState() {
566
- return { schema: 'codex-orchestrator.agent-auto-state', version: 2, generation: 0, runs: [] };
736
+ return { schema: 'codex-orchestrator.agent-auto-state', version: 3, generation: 0, runs: [] };
737
+ }
738
+ function canonicalizeLegacyReportLifecycle(value, backupPath) {
739
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
740
+ return undefined;
741
+ const raw = value;
742
+ if (raw.schema !== 'codex-orchestrator.agent-auto-state' || (raw.version !== 2 && raw.version !== 3)
743
+ || !Number.isSafeInteger(raw.generation) || raw.generation <= 0 || !Array.isArray(raw.runs))
744
+ return undefined;
745
+ const runs = structuredClone(raw.runs);
746
+ const changed = runs.map((run) => canonicalizeLegacyReportRun(run, backupPath)).some(Boolean);
747
+ if (!changed)
748
+ return undefined;
749
+ return validateRunStateFile({ schema: raw.schema, version: raw.version, generation: raw.generation + 1, runs });
750
+ }
751
+ function canonicalizeLegacyReportRun(run, backupPath) {
752
+ let changed = false, unsafe = false, routeInFlight = false;
753
+ const mutable = canonicalizeLegacyMutableRun(run);
754
+ changed ||= mutable.changed;
755
+ unsafe ||= mutable.unsafe;
756
+ const route = run.routeExecution;
757
+ if (route) {
758
+ const triageTransport = hasOwn(route, 'triageTransportRetries'), ambiguityTransport = hasOwn(route, 'ambiguityTransportRetries');
759
+ if (triageTransport !== ambiguityTransport)
760
+ throw new Error('legacy route transport budgets are incomplete');
761
+ if (triageTransport) {
762
+ legacyBits([route.triageTransportRetries, route.ambiguityTransportRetries], 'route');
763
+ delete route.triageTransportRetries;
764
+ delete route.ambiguityTransportRetries;
765
+ changed = true;
766
+ }
767
+ if (hasOwn(route, 'previousAttemptId')) {
768
+ delete route.previousAttemptId;
769
+ changed = true;
770
+ }
771
+ routeInFlight = ['triage-in-flight', 'review-in-flight', 'repair-in-flight'].includes(route.phase);
772
+ if (routeInFlight) {
773
+ unsafe = true;
774
+ delete route.attemptId;
775
+ delete route.startedAt;
776
+ }
777
+ }
778
+ const direct = run.directReview;
779
+ if (direct?.review && hasOwn(direct.review, 'transportRetries')) {
780
+ legacyBits([direct.review.transportRetries], 'direct review');
781
+ delete direct.review.transportRetries;
782
+ changed = true;
783
+ }
784
+ if (direct && hasOwn(direct, 'invocation')) {
785
+ delete direct.invocation;
786
+ unsafe = changed = true;
787
+ }
788
+ const spec = run.specDelivery;
789
+ if (spec?.budgets?.author && hasOwn(spec.budgets.author, 'transportRetries')) {
790
+ legacyBits([spec.budgets.author.transportRetries], 'spec author');
791
+ delete spec.budgets.author.transportRetries;
792
+ changed = true;
793
+ }
794
+ if (spec?.budgets?.review && hasOwn(spec.budgets.review, 'transportRetries')) {
795
+ legacyBits([spec.budgets.review.transportRetries], 'spec review');
796
+ delete spec.budgets.review.transportRetries;
797
+ changed = true;
798
+ }
799
+ if (spec?.review && !hasOwn(spec.review, 'reviewerSessionId')) {
800
+ spec.review.reviewerSessionId = spec.review.reviewer?.sessionId ?? (spec.invocation?.purpose === 'review' ? spec.invocation.sessionId : null);
801
+ changed = true;
802
+ }
803
+ if (spec?.invocation?.purpose === 'review') {
804
+ delete spec.invocation;
805
+ unsafe = changed = true;
806
+ }
807
+ if (spec?.invocation?.purpose === 'author') {
808
+ if (spec.invocation.status === 'launched')
809
+ unsafe = true;
810
+ if (spec.authorSessionId === null)
811
+ spec.authorSessionId = spec.invocation.sessionId;
812
+ else if (spec.authorSessionId !== spec.invocation.sessionId)
813
+ unsafe = true;
814
+ delete spec.invocation;
815
+ changed = true;
816
+ }
817
+ if (run.reportInvocation && !hasOwn(run.reportInvocation, 'promptFactsSha256')) {
818
+ delete run.reportInvocation;
819
+ unsafe = changed = true;
820
+ }
821
+ if (run.process && ['route', 'code-review', 'spec-author', 'spec-review', 'proof'].includes(run.process.purpose)) {
822
+ delete run.process;
823
+ unsafe = changed = true;
824
+ }
825
+ if (!unsafe)
826
+ return changed;
827
+ if (routeInFlight)
828
+ delete run.routeExecution;
829
+ delete run.reportInvocation;
830
+ delete run.process;
831
+ delete run.intent;
832
+ if (direct)
833
+ run.directReview = projectTerminalDirectReview(direct, { status: 'blocked', kind: 'safety' }, 'report-lifecycle-migration-identity-unavailable');
834
+ run.lifecycle = 'blocked';
835
+ run.outcomeEvidenceId = `report-lifecycle-migration:${String(run.runId)}`;
836
+ run.terminalOutcome = { status: 'blocked', kind: 'safety', resumable: false, evidencePath: backupPath };
837
+ return true;
838
+ }
839
+ function hasLegacyMutableLifecycle(value) {
840
+ if (typeof value !== 'object' || value === null || !Array.isArray(value.runs))
841
+ return false;
842
+ return (value.runs).some((run) => hasOwn(run, 'transportRetries') || run.process?.purpose === 'implementation'
843
+ || hasOwn(run.checkQualification, 'implementationStarted') || hasOwn(run.checkQualification, 'repairInvocation')
844
+ || hasOwn(run.checkQualification, 'deniedPathsBaseline') || hasOwn(run.reviewFeedback, 'implementationInvocation'));
845
+ }
846
+ function canonicalizeLegacyMutableRun(run) {
847
+ let changed = false, unsafe = false;
848
+ if (hasOwn(run, 'transportRetries')) {
849
+ legacyBits([run.transportRetries], 'mutable invocation');
850
+ delete run.transportRetries;
851
+ changed = true;
852
+ }
853
+ const qualification = run.checkQualification;
854
+ if (qualification) {
855
+ if (qualification.repairInvocation?.phase === 'launched')
856
+ unsafe = true;
857
+ for (const field of ['implementationStarted', 'repairInvocation', 'deniedPathsBaseline'])
858
+ if (hasOwn(qualification, field)) {
859
+ delete qualification[field];
860
+ changed = true;
861
+ }
862
+ }
863
+ const feedback = run.reviewFeedback;
864
+ if (feedback && hasOwn(feedback, 'implementationInvocation')) {
865
+ if (feedback.implementationInvocation?.phase === 'launched')
866
+ unsafe = true;
867
+ delete feedback.implementationInvocation;
868
+ changed = true;
869
+ }
870
+ if (run.process?.purpose === 'implementation') {
871
+ delete run.process;
872
+ changed = unsafe = true;
873
+ }
874
+ if (!unsafe)
875
+ return { changed, unsafe };
876
+ delete run.mutableInvocation;
877
+ if (feedback?.activeBatch)
878
+ run.reviewFeedback = blockReviewFeedback(feedback, 'safety', String(run.updatedAt));
879
+ return { changed: true, unsafe: true };
880
+ }
881
+ function legacyBits(values, owner) {
882
+ if (values.some((value) => value !== 0 && value !== 1))
883
+ throw new Error(`legacy ${owner} transport budget is invalid`);
884
+ }
885
+ function parseRawState(bytes) {
886
+ let value;
887
+ try {
888
+ value = JSON.parse(bytes.toString('utf8'));
889
+ }
890
+ catch {
891
+ throw new Error('run state JSON is malformed');
892
+ }
893
+ return validateRunStateFile(value);
894
+ }
895
+ async function readOptionalFile(path) {
896
+ try {
897
+ return await readFile(path);
898
+ }
899
+ catch (error) {
900
+ if (isErrorCode(error, 'ENOENT'))
901
+ return undefined;
902
+ throw error;
903
+ }
904
+ }
905
+ async function readMigrationMetadata(path) {
906
+ const bytes = await readOptionalFile(path);
907
+ if (!bytes)
908
+ return undefined;
909
+ let value;
910
+ try {
911
+ value = JSON.parse(bytes.toString('utf8'));
912
+ }
913
+ catch {
914
+ throw new Error('candidate migration metadata is malformed');
915
+ }
916
+ assertExactObject(value, [
917
+ 'version', 'sourceGeneration', 'sourceBytesSha256', 'publicationEffectPossible', 'createdAt', 'updatedAt',
918
+ ], 'candidate migration metadata');
919
+ if (value.version !== 1)
920
+ throw new Error('candidate migration metadata version is invalid');
921
+ if (value.sourceGeneration !== null && (!Number.isSafeInteger(value.sourceGeneration) || value.sourceGeneration <= 0)) {
922
+ throw new Error('candidate migration source generation is invalid');
923
+ }
924
+ if (value.sourceBytesSha256 !== null)
925
+ assertSha256(value.sourceBytesSha256, 'candidate migration source hash');
926
+ if ((value.sourceGeneration === null) !== (value.sourceBytesSha256 === null))
927
+ throw new Error('candidate migration source identity is incomplete');
928
+ if (typeof value.publicationEffectPossible !== 'boolean')
929
+ throw new Error('candidate migration publication watermark is invalid');
930
+ assertTimestamp(value.createdAt, 'candidate migration createdAt');
931
+ assertTimestamp(value.updatedAt, 'candidate migration updatedAt');
932
+ return value;
933
+ }
934
+ function assertNoCandidateV3Fields(runs) {
935
+ for (const run of runs) {
936
+ if (run.changeBindingVersion !== undefined || run.candidateBinding !== undefined || run.executionLease !== undefined) {
937
+ throw new Error('V2 run state cannot contain candidate V3 fields');
938
+ }
939
+ }
940
+ }
941
+ function isErrorCode(error, code) {
942
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
567
943
  }
568
944
  function validateReviewFeedbackRunInvariant(run, field) {
569
945
  const feedback = run.reviewFeedback;
@@ -579,7 +955,7 @@ function validateReviewFeedbackRunInvariant(run, field) {
579
955
  && run.lifecycle !== 'review-ready' && !retainedQuiescentHistory) {
580
956
  throw new Error(`${field}.reviewFeedback quiescent phase requires review-ready lifecycle`);
581
957
  }
582
- if (['frozen', 'repairing'].includes(feedback.phase) && !['implementing', 'reworking', 'checking', 'proving', 'safe-halt'].includes(run.lifecycle)) {
958
+ if (['frozen', 'repairing'].includes(feedback.phase) && !['implementing', 'reworking', 'checking', 'proving'].includes(run.lifecycle)) {
583
959
  throw new Error(`${field}.reviewFeedback active repair phase has invalid lifecycle`);
584
960
  }
585
961
  if (feedback.phase === 'verified' && run.lifecycle !== 'publishing') {
@@ -606,7 +982,7 @@ function validateReviewFeedbackRunInvariant(run, field) {
606
982
  }
607
983
  function isLifecycle(value) {
608
984
  return typeof value === 'string' && [
609
- 'claimed', 'triaging', 'routed', 'waiting-human', 'spec-authoring', 'implementing', 'reworking', 'checking', 'proving', 'publishing', 'safe-halt',
985
+ 'claimed', 'triaging', 'routed', 'waiting-human', 'spec-authoring', 'implementing', 'reworking', 'checking', 'proving', 'publishing',
610
986
  'review-ready', 'blocked', 'transport-failed', 'cancelled', 'internal-error',
611
987
  ].includes(value);
612
988
  }
@@ -637,6 +1013,11 @@ function assertGitSha(value, field) {
637
1013
  if (typeof value !== 'string' || !GIT_SHA_PATTERN.test(value))
638
1014
  throw new Error(`${field} must be a Git object ID`);
639
1015
  }
1016
+ function assertCandidateRef(value, field) {
1017
+ if (typeof value !== 'string' || !/^refs\/codex-orchestrator\/candidates\/[0-9a-f-]{36}\/[0-9a-f]{64}$/u.test(value)) {
1018
+ throw new Error(`${field} is invalid`);
1019
+ }
1020
+ }
640
1021
  function assertTimestamp(value, field) {
641
1022
  if (typeof value !== 'string' || Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value)
642
1023
  throw new Error(`${field} is invalid`);