codex-workflow-v2 2.0.0-beta.13.14 → 2.0.0-beta.13.16

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 (125) hide show
  1. package/README.md +17 -0
  2. package/dist/reviewer-runtime-build.json +20 -8
  3. package/dist/src/alpha6/captured-check-evidence.d.ts +49 -0
  4. package/dist/src/alpha6/captured-check-evidence.js +146 -0
  5. package/dist/src/alpha6/captured-check-evidence.js.map +1 -0
  6. package/dist/src/alpha6/corrective-decision-boundary.d.ts +4 -0
  7. package/dist/src/alpha6/corrective-decision-boundary.js +47 -0
  8. package/dist/src/alpha6/corrective-decision-boundary.js.map +1 -0
  9. package/dist/src/alpha6/root-cause-replan-carryover.d.ts +3 -1
  10. package/dist/src/alpha6/root-cause-replan-carryover.js +4 -0
  11. package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -1
  12. package/dist/src/contracts.d.ts +5 -1
  13. package/dist/src/dependency-provenance.js +10 -0
  14. package/dist/src/dependency-provenance.js.map +1 -1
  15. package/dist/src/graph.js +25 -3
  16. package/dist/src/graph.js.map +1 -1
  17. package/dist/src/version.d.ts +1 -1
  18. package/dist/src/version.js +1 -1
  19. package/dist/src/workflow-blocker-route.d.ts +8 -0
  20. package/dist/src/workflow-blocker-route.js +106 -0
  21. package/dist/src/workflow-blocker-route.js.map +1 -0
  22. package/dist/src/workflow.d.ts +1 -0
  23. package/dist/src/workflow.js +139 -19
  24. package/dist/src/workflow.js.map +1 -1
  25. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  26. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  27. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  28. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +1 -1
  29. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +2 -2
  30. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +2 -2
  31. package/docs/release.md +49 -0
  32. package/package.json +2 -1
  33. package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +1 -1
  34. package/plugins/codex-workflow-gateway/references/chat-dispatch.md +40 -11
  35. package/plugins/codex-workflow-gateway/references/codebase-memory-routing.md +55 -0
  36. package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +7 -1
  37. package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +10 -8
  38. package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +23 -5
  39. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +78 -12
  40. package/src/alpha6/adoption.ts +1105 -0
  41. package/src/alpha6/captured-check-evidence.ts +126 -0
  42. package/src/alpha6/check-support-anchor.ts +128 -0
  43. package/src/alpha6/component-owner.ts +168 -0
  44. package/src/alpha6/corrective-decision-boundary.ts +39 -0
  45. package/src/alpha6/downstream-proof.ts +732 -0
  46. package/src/alpha6/failed-step-planning-recovery.ts +88 -0
  47. package/src/alpha6/handoff.ts +1443 -0
  48. package/src/alpha6/journal.ts +473 -0
  49. package/src/alpha6/literal-test-invocation.ts +84 -0
  50. package/src/alpha6/mechanical-feasibility.ts +488 -0
  51. package/src/alpha6/milestone.ts +2182 -0
  52. package/src/alpha6/npm-check-contract.ts +47 -0
  53. package/src/alpha6/plan-integrity-update.ts +20 -0
  54. package/src/alpha6/plan-integrity.ts +298 -0
  55. package/src/alpha6/plan-risk.ts +1480 -0
  56. package/src/alpha6/preexecution-replan.ts +187 -0
  57. package/src/alpha6/remediation-cause.ts +98 -0
  58. package/src/alpha6/remediation.ts +2965 -0
  59. package/src/alpha6/review.ts +1198 -0
  60. package/src/alpha6/root-cause-replan-carryover.ts +471 -0
  61. package/src/alpha6/store-sidecars.ts +335 -0
  62. package/src/alpha7/autonomy.ts +411 -0
  63. package/src/alpha7/corrective-recovery.ts +1332 -0
  64. package/src/artifacts.ts +130 -0
  65. package/src/beta1/project-transaction.ts +351 -0
  66. package/src/change-explanation.ts +153 -0
  67. package/src/cli-actions.ts +119 -0
  68. package/src/cli.ts +1583 -0
  69. package/src/contracts.ts +1510 -0
  70. package/src/credential-output.ts +89 -0
  71. package/src/credential-transport.ts +215 -0
  72. package/src/delegation.ts +190 -0
  73. package/src/dependency-provenance.ts +538 -0
  74. package/src/diagnostics.ts +93 -0
  75. package/src/domain/completed-step-carryover.ts +76 -0
  76. package/src/domain/discovery.ts +27 -0
  77. package/src/domain/plan-semantics.ts +58 -0
  78. package/src/domain/validation.ts +177 -0
  79. package/src/errors.ts +23 -0
  80. package/src/fs-utils.ts +61 -0
  81. package/src/gateway-handshake.ts +103 -0
  82. package/src/git.ts +158 -0
  83. package/src/graph.ts +342 -0
  84. package/src/historical-step-provenance.ts +136 -0
  85. package/src/index.ts +21 -0
  86. package/src/lifecycle/canonical-hash.ts +28 -0
  87. package/src/lifecycle/catalog.ts +202 -0
  88. package/src/lifecycle/compiler-inspection.ts +29 -0
  89. package/src/lifecycle/core-static-readiness.ts +132 -0
  90. package/src/lifecycle/corrective-replan-authority.ts +136 -0
  91. package/src/lifecycle/corrective-replan-binding-manifest.ts +51 -0
  92. package/src/lifecycle/corrective-replan-credential-core.ts +408 -0
  93. package/src/lifecycle/corrective-replan-credential-schema.ts +54 -0
  94. package/src/lifecycle/corrective-replan-credentials.ts +48 -0
  95. package/src/lifecycle/corrective-replan.ts +843 -0
  96. package/src/lifecycle/evaluator.ts +48 -0
  97. package/src/lifecycle/fingerprint.ts +488 -0
  98. package/src/lifecycle/immutable.ts +8 -0
  99. package/src/lifecycle/implementation-table.ts +118 -0
  100. package/src/lifecycle/index.ts +8 -0
  101. package/src/lifecycle/schema-artifact.ts +263 -0
  102. package/src/lifecycle/semantic-registry.ts +572 -0
  103. package/src/lifecycle/types.ts +838 -0
  104. package/src/memory.ts +273 -0
  105. package/src/migration.ts +161 -0
  106. package/src/navigation-actions.ts +70 -0
  107. package/src/observation.ts +198 -0
  108. package/src/observed-routes.ts +661 -0
  109. package/src/operational-contract.ts +125 -0
  110. package/src/pending-review-update.ts +191 -0
  111. package/src/repository.ts +99 -0
  112. package/src/reviewer.ts +1876 -0
  113. package/src/state/corrective-replan-executor.ts +818 -0
  114. package/src/state/corrective-replan-public-schema.ts +83 -0
  115. package/src/state/corrective-replan-public.ts +908 -0
  116. package/src/state/corrective-replan-transaction.ts +947 -0
  117. package/src/state/corrective-yield-executor.ts +327 -0
  118. package/src/state/corrective-yield-transaction.ts +728 -0
  119. package/src/state/lock.ts +880 -0
  120. package/src/state/store.ts +567 -0
  121. package/src/transition-core.ts +330 -0
  122. package/src/ulid.ts +24 -0
  123. package/src/version.ts +2 -0
  124. package/src/workflow-blocker-route.ts +109 -0
  125. package/src/workflow.ts +11436 -0
@@ -0,0 +1,2965 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {
4
+ CorrectiveDecisionEvent,
5
+ CorrectiveDecisionRecoveryEvent,
6
+ CorrectivePlanAuditInput,
7
+ GuardedRiskCategory,
8
+ PlanIntegrityConflictEvidence,
9
+ PlanRiskAuditEvent,
10
+ RemediationEvent,
11
+ RemediationCauseBinding,
12
+ RemediationFailureEvidence,
13
+ RemediationFailureKind,
14
+ RemediationMode,
15
+ RemediationModeRecoveryEvent,
16
+ StopEscalateOverrideEvent,
17
+ StopEscalateOverridePreparation,
18
+ TaskState,
19
+ WriterLease,
20
+ } from '../contracts.js';
21
+ import { assertKnowledgeBindingOnlyPlanChange, normalizePlanKnowledgeBinding, readVerifiedPlanArtifact } from '../domain/plan-semantics.js';
22
+ import { pathAllowed } from '../domain/validation.js';
23
+ import { WorkflowError } from '../errors.js';
24
+ import { hashCanonical } from '../lifecycle/fingerprint.js';
25
+ import type { FileStateStore } from '../state/store.js';
26
+ import { createUlid } from '../ulid.js';
27
+ import { PACKAGE_VERSION } from '../version.js';
28
+ import { readPlanRiskAuditEvents, type CurrentPlanRiskAudit } from './plan-risk.js';
29
+ import {
30
+ assertHash64,
31
+ canonicalJsonStringify,
32
+ prepareSidecarAppend,
33
+ sha256Hex,
34
+ } from './store-sidecars.js';
35
+ import { readTaskC1Posture } from './handoff.js';
36
+ import {
37
+ buildCurrentStrictStepReviewCycle,
38
+ latestPassedVerifiedStepReviewEvent,
39
+ readReviewerAttestations,
40
+ readStepReviewEvents,
41
+ } from './review.js';
42
+ import { correctiveReplanBoundaryMatchesStep, readCompletedCorrectiveReplanHistory } from '../state/corrective-replan-executor.js';
43
+ import { semanticBoundaryHash, validLiteralTestInvocationEvidence } from './component-owner.js';
44
+ import { reviewerCauseFingerprint } from './remediation-cause.js';
45
+
46
+ export const REMEDIATION_EVENTS_SIDECAR = 'remediation-events.jsonl';
47
+ export const CORRECTIVE_DECISIONS_SIDECAR = 'corrective-decisions.jsonl';
48
+ export const CORRECTIVE_DECISION_RECOVERIES_SIDECAR = 'corrective-decision-recoveries.jsonl';
49
+ export const REMEDIATION_MODE_RECOVERIES_SIDECAR = 'remediation-mode-recoveries.jsonl';
50
+ export const STOP_ESCALATE_OVERRIDES_SIDECAR = 'stop-escalate-overrides.jsonl';
51
+ const REMEDIATION_MODE_RECOVERY_LOCK = '.remediation-mode-recovery.lock';
52
+ const STOP_ESCALATE_OVERRIDE_LOCK = '.stop-escalate-override.lock';
53
+ const LEGACY_STOP_OVERRIDE_POLICY = 'beta11-attempt4-stop-compatibility-v1' as const;
54
+
55
+ const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/;
56
+ const REMEDIATION_FAILURE_KINDS = new Set<RemediationFailureKind>([
57
+ 'checks-failed',
58
+ 'review-failed',
59
+ 'review-unverified',
60
+ ]);
61
+ const REMEDIATION_MODES = new Set<RemediationMode>(['ordinary', 'corrective']);
62
+ const CORRECTIVE_DECISION_VALUES = new Set<CorrectiveDecisionEvent['decision']>([
63
+ 'continue-fix',
64
+ 'replan-required',
65
+ 'split-required',
66
+ 'stop-escalate',
67
+ ]);
68
+
69
+ export interface GuardedRemediationAttemptGate {
70
+ stepId: string;
71
+ guardedCategory: GuardedRiskCategory;
72
+ attemptCount: number;
73
+ nextAttemptOrdinal: number;
74
+ mode: RemediationMode;
75
+ correctiveDecision: CorrectiveDecisionEvent | null;
76
+ posture:
77
+ | 'ordinary'
78
+ | 'decision-required'
79
+ | 'continue-fix'
80
+ | 'replan-required'
81
+ | 'replan-ready'
82
+ | 'split-required'
83
+ | 'stop-escalate';
84
+ }
85
+
86
+ export interface RemediationModeRecoveryAssessment {
87
+ task: TaskState;
88
+ remediation: RemediationEvent;
89
+ decision: CorrectiveDecisionEvent;
90
+ correctiveRecovery: CorrectiveDecisionRecoveryEvent;
91
+ existingRecovery: RemediationModeRecoveryEvent | null;
92
+ actor: string;
93
+ c1ClaimEventHash: string | null;
94
+ writerLeaseTokenHash: string | null;
95
+ evidenceFingerprint: string;
96
+ }
97
+
98
+ export interface PreparedRemediationEvent {
99
+ event: RemediationEvent;
100
+ postimage: string | null;
101
+ }
102
+
103
+ export function readRemediationEvents(
104
+ store: FileStateStore,
105
+ task: TaskState,
106
+ ): RemediationEvent[] {
107
+ return validateRemediationHistory(store, task).remediationEvents;
108
+ }
109
+
110
+ export function readCorrectiveDecisionEvents(
111
+ store: FileStateStore,
112
+ task: TaskState,
113
+ ): CorrectiveDecisionEvent[] {
114
+ return validateRemediationHistory(store, task).correctiveDecisionEvents;
115
+ }
116
+
117
+ export function defaultCorrectiveAuditorActor(taskId: string): string {
118
+ if (!/^TASK-[0-9A-HJKMNP-TV-Z]{26}$/.test(taskId)) {
119
+ throw new WorkflowError('INVALID_ARGUMENT', `Cannot derive a corrective Auditor actor from invalid Task ID ${taskId}.`);
120
+ }
121
+ return `agent:corrective-auditor:${taskId.toLowerCase()}`;
122
+ }
123
+
124
+ export function assertCorrectiveAuditorIndependence(
125
+ store: FileStateStore,
126
+ task: TaskState,
127
+ stepId: string,
128
+ auditor: string,
129
+ ): void {
130
+ const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
131
+ const latestRemediationEvent = remediationEvents.at(-1) ?? null;
132
+ if (!latestRemediationEvent || latestRemediationEvent.failureKind === 'checks-failed') return;
133
+ const reviewerAttestationHash = latestRemediationEvent.failureEvidence.reviewerAttestationHash;
134
+ if (!reviewerAttestationHash) {
135
+ throw new WorkflowError('STATE_CORRUPT', 'Latest guarded remediation event is missing its strict-review attestation binding.', {
136
+ taskId: task.id,
137
+ stepId,
138
+ eventId: latestRemediationEvent.eventId,
139
+ });
140
+ }
141
+ const attestation = readReviewerAttestations(store, task)
142
+ .find((event) => event.eventHash === reviewerAttestationHash) ?? null;
143
+ if (!attestation) {
144
+ throw new WorkflowError('STATE_CORRUPT', 'Latest guarded remediation event references a missing reviewer attestation.', {
145
+ taskId: task.id,
146
+ stepId,
147
+ reviewerAttestationHash,
148
+ });
149
+ }
150
+ if (attestation.reviewer.trim() === auditor.trim()) {
151
+ throw new WorkflowError('TRANSITION_BLOCKED', `Corrective decision for ${stepId} must be recorded by an auditor distinct from the latest strict reviewer.`, {
152
+ taskId: task.id,
153
+ stepId,
154
+ reviewer: attestation.reviewer,
155
+ auditor: auditor.trim(),
156
+ });
157
+ }
158
+ }
159
+
160
+ function validateRemediationHistory(
161
+ store: FileStateStore,
162
+ task: TaskState,
163
+ ): {
164
+ remediationEvents: RemediationEvent[];
165
+ correctiveDecisionEvents: CorrectiveDecisionEvent[];
166
+ } {
167
+ const remediationEvents = readRawRemediationEvents(store, task);
168
+ const correctiveDecisionEvents = readRawCorrectiveDecisionEvents(store, task);
169
+ const modeRecoveries = readRemediationModeRecoveryEvents(store, task);
170
+ const overriddenStopDecisionEventIds = validateStopEscalateOverrideHistory(
171
+ task,
172
+ remediationEvents,
173
+ correctiveDecisionEvents,
174
+ readRawStopEscalateOverrideEvents(store, task),
175
+ );
176
+ validateRemediationAttemptOrdinals(remediationEvents, task);
177
+ validateRemediationReviewReferences(store, task, remediationEvents);
178
+ validateCorrectiveDecisionCoverage(store, task, correctiveDecisionEvents, remediationEvents);
179
+ const recoveredEventIds = validateRemediationModeRecoveries(
180
+ store,
181
+ task,
182
+ remediationEvents,
183
+ correctiveDecisionEvents,
184
+ modeRecoveries,
185
+ );
186
+ validateHistoricalRemediationModesForAllSteps(
187
+ store,
188
+ task,
189
+ remediationEvents,
190
+ correctiveDecisionEvents,
191
+ recoveredEventIds,
192
+ overriddenStopDecisionEventIds,
193
+ );
194
+ return {
195
+ remediationEvents,
196
+ correctiveDecisionEvents,
197
+ };
198
+ }
199
+
200
+ export function readRemediationModeRecoveryEvents(
201
+ store: FileStateStore,
202
+ task: TaskState,
203
+ ): RemediationModeRecoveryEvent[] {
204
+ const root = store.taskRoot(task.projectId, task.id);
205
+ const file = path.join(root, REMEDIATION_MODE_RECOVERIES_SIDECAR);
206
+ if (!existsSync(file)) return [];
207
+ return store.readSidecarEvents<RemediationModeRecoveryEvent>(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
208
+ validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, task, context.file, context.line),
209
+ });
210
+ }
211
+
212
+ function readRawCorrectiveDecisionRecoveries(
213
+ store: FileStateStore,
214
+ task: TaskState,
215
+ ): CorrectiveDecisionRecoveryEvent[] {
216
+ const root = store.taskRoot(task.projectId, task.id);
217
+ const file = path.join(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR);
218
+ if (!existsSync(file)) return [];
219
+ return store.readSidecarEvents<CorrectiveDecisionRecoveryEvent>(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR, {
220
+ validateEvent: (event, context) => validateStoredCorrectiveDecisionRecoveryReference(event, task, context.file, context.line),
221
+ });
222
+ }
223
+
224
+ function readRawRemediationEvents(
225
+ store: FileStateStore,
226
+ task: TaskState,
227
+ ): RemediationEvent[] {
228
+ const sidecarFile = path.join(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR);
229
+ if (!existsSync(sidecarFile)) return [];
230
+ return store.readSidecarEvents<RemediationEvent>(
231
+ store.taskRoot(task.projectId, task.id),
232
+ REMEDIATION_EVENTS_SIDECAR,
233
+ {
234
+ validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
235
+ },
236
+ );
237
+ }
238
+
239
+ function readRawCorrectiveDecisionEvents(
240
+ store: FileStateStore,
241
+ task: TaskState,
242
+ ): CorrectiveDecisionEvent[] {
243
+ const sidecarFile = path.join(store.taskRoot(task.projectId, task.id), CORRECTIVE_DECISIONS_SIDECAR);
244
+ if (!existsSync(sidecarFile)) return [];
245
+ return store.readSidecarEvents<CorrectiveDecisionEvent>(
246
+ store.taskRoot(task.projectId, task.id),
247
+ CORRECTIVE_DECISIONS_SIDECAR,
248
+ {
249
+ validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
250
+ },
251
+ );
252
+ }
253
+
254
+ function readRawStopEscalateOverrideEvents(
255
+ store: FileStateStore,
256
+ task: TaskState,
257
+ ): StopEscalateOverrideEvent[] {
258
+ const root = store.taskRoot(task.projectId, task.id);
259
+ const sidecarFile = path.join(root, STOP_ESCALATE_OVERRIDES_SIDECAR);
260
+ if (!existsSync(sidecarFile)) return [];
261
+ return store.readSidecarEvents<StopEscalateOverrideEvent>(root, STOP_ESCALATE_OVERRIDES_SIDECAR, {
262
+ validateEvent: (event, context) => validateStoredStopEscalateOverride(event, task, context.file, context.line),
263
+ });
264
+ }
265
+
266
+ export function readStopEscalateOverrideEvents(
267
+ store: FileStateStore,
268
+ task: TaskState,
269
+ ): StopEscalateOverrideEvent[] {
270
+ const remediations = readRawRemediationEvents(store, task);
271
+ const decisions = readRawCorrectiveDecisionEvents(store, task);
272
+ const overrides = readRawStopEscalateOverrideEvents(store, task);
273
+ validateRemediationAttemptOrdinals(remediations, task);
274
+ validateCorrectiveDecisionCoverage(store, task, decisions, remediations);
275
+ validateStopEscalateOverrideHistory(task, remediations, decisions, overrides);
276
+ return overrides;
277
+ }
278
+
279
+ export function stopEscalateOverrideDecisionEventIds(
280
+ store: FileStateStore,
281
+ task: TaskState,
282
+ ): ReadonlySet<string> {
283
+ const remediations = readRawRemediationEvents(store, task);
284
+ const decisions = readRawCorrectiveDecisionEvents(store, task);
285
+ const overrides = readRawStopEscalateOverrideEvents(store, task);
286
+ validateRemediationAttemptOrdinals(remediations, task);
287
+ validateCorrectiveDecisionCoverage(store, task, decisions, remediations);
288
+ return validateStopEscalateOverrideHistory(task, remediations, decisions, overrides);
289
+ }
290
+
291
+ export function appendRemediationEvent(
292
+ store: FileStateStore,
293
+ task: TaskState,
294
+ stepId: string,
295
+ failureKind: RemediationFailureKind,
296
+ failureEvidence: RemediationFailureEvidence,
297
+ planRiskAudit: CurrentPlanRiskAudit,
298
+ now: Date,
299
+ reboundStopOverrideDecisionEventId: string | null = null,
300
+ causeBindings: readonly RemediationCauseBinding[] = [],
301
+ ): RemediationEvent {
302
+ const prepared = prepareRemediationEvent(
303
+ store,
304
+ task,
305
+ stepId,
306
+ failureKind,
307
+ failureEvidence,
308
+ planRiskAudit,
309
+ now,
310
+ reboundStopOverrideDecisionEventId,
311
+ causeBindings,
312
+ );
313
+ if (prepared.postimage === null) return prepared.event;
314
+ return store.appendSidecarEvent<RemediationEvent>(
315
+ store.taskRoot(task.projectId, task.id),
316
+ REMEDIATION_EVENTS_SIDECAR,
317
+ prepared.event,
318
+ {
319
+ validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
320
+ },
321
+ );
322
+ }
323
+
324
+ export function prepareRemediationEvent(
325
+ store: FileStateStore,
326
+ task: TaskState,
327
+ stepId: string,
328
+ failureKind: RemediationFailureKind,
329
+ failureEvidence: RemediationFailureEvidence,
330
+ planRiskAudit: CurrentPlanRiskAudit,
331
+ now: Date,
332
+ reboundStopOverrideDecisionEventId: string | null = null,
333
+ causeBindings: readonly RemediationCauseBinding[] = [],
334
+ ): PreparedRemediationEvent {
335
+ const recoveredDecisionEventId = findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId);
336
+ const allExisting = readRemediationEvents(store, task);
337
+ const existing = allExisting.filter((event) => event.stepId === stepId);
338
+ const correctiveDecisions = readCorrectiveDecisionEvents(store, task);
339
+ const matching = existing.filter((event) => remediationEventMatchesFailure(event, failureKind, failureEvidence));
340
+ if (matching.length > 1) {
341
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation events match the same failure identity.', {
342
+ taskId: task.id,
343
+ stepId,
344
+ failureKind,
345
+ matchingEventIds: matching.map((event) => event.eventId),
346
+ });
347
+ }
348
+ if (matching.length === 1) {
349
+ const reused = matching[0]!;
350
+ if (canonicalJsonStringify(reused.failureEvidence) !== canonicalJsonStringify(failureEvidence)) {
351
+ throw new WorkflowError('STATE_CONFLICT', 'Remediation failure evidence changed within the same execution identity.', {
352
+ taskId: task.id,
353
+ stepId,
354
+ eventId: reused.eventId,
355
+ executionTaskRevision: failureEvidence.executionTaskRevision ?? null,
356
+ });
357
+ }
358
+ const priorEvents = existing.filter((event) => event.attemptOrdinal < reused.attemptOrdinal);
359
+ const cycle = selectCurrentPlanRemediationCycle(store, task, priorEvents, correctiveDecisions);
360
+ const gate = deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, cycle.remediationEvents, cycle.correctiveDecisions, {
361
+ recoveredDecisionEventId,
362
+ reboundStopOverrideDecisionEventId,
363
+ overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
364
+ priorRemediationEvents: cycle.priorRemediationEvents,
365
+ nextAttemptOrdinal: reused.attemptOrdinal,
366
+ });
367
+ if (reused.attemptOrdinal !== gate.nextAttemptOrdinal || reused.mode !== gate.mode || reused.guardedCategory !== gate.guardedCategory) {
368
+ throw new WorkflowError('STATE_CORRUPT', 'Existing remediation event conflicts with the current guarded retry binding.', {
369
+ taskId: task.id,
370
+ stepId,
371
+ eventId: reused.eventId,
372
+ expectedAttemptOrdinal: gate.nextAttemptOrdinal,
373
+ actualAttemptOrdinal: reused.attemptOrdinal,
374
+ expectedMode: gate.mode,
375
+ actualMode: reused.mode,
376
+ });
377
+ }
378
+ return { event: reused, postimage: null };
379
+ }
380
+ const cycle = selectCurrentPlanRemediationCycle(store, task, existing, correctiveDecisions);
381
+ const gate = deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, cycle.remediationEvents, cycle.correctiveDecisions, {
382
+ recoveredDecisionEventId,
383
+ reboundStopOverrideDecisionEventId,
384
+ overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
385
+ priorRemediationEvents: cycle.priorRemediationEvents,
386
+ nextAttemptOrdinal: existing.length + 1,
387
+ });
388
+ const prepared = prepareSidecarAppend<RemediationEvent>(
389
+ allExisting,
390
+ {
391
+ eventId: `RME-${createUlid(now.getTime())}`,
392
+ recordedAt: now.toISOString(),
393
+ taskId: task.id,
394
+ stepId,
395
+ guardedCategory: gate.guardedCategory,
396
+ failureKind,
397
+ failureEvidence,
398
+ attemptOrdinal: gate.nextAttemptOrdinal,
399
+ mode: gate.mode,
400
+ causeBindings: causeBindings.map((binding) => ({ ...binding })),
401
+ }, {
402
+ validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
403
+ },
404
+ );
405
+ return { event: prepared.event, postimage: prepared.content };
406
+ }
407
+
408
+ function findCorrectiveRecoveryDecisionIdForFailure(
409
+ store: FileStateStore,
410
+ task: TaskState,
411
+ stepId: string,
412
+ ): string | null {
413
+ const remediations = readRawRemediationEvents(store, task).filter((event) => event.stepId === stepId);
414
+ if (remediations.length !== 2) return null;
415
+ const decisions = readRawCorrectiveDecisionEvents(store, task).filter((event) =>
416
+ event.stepId === stepId
417
+ && event.triggeringAttemptCount === 3
418
+ && event.decision === 'continue-fix'
419
+ && event.planHash !== task.planHash,
420
+ );
421
+ if (decisions.length !== 1) return null;
422
+ const recoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) =>
423
+ event.stepId === stepId
424
+ && event.triggeringAttemptCount === 3
425
+ && event.correctiveDecisionEventId === decisions[0]!.eventId
426
+ && event.correctiveDecisionEventHash === decisions[0]!.eventHash
427
+ && event.reboundPlanHash === task.planHash,
428
+ );
429
+ if (recoveries.length > 1) {
430
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple corrective recoveries match one remediation attempt.', {
431
+ taskId: task.id,
432
+ stepId,
433
+ recoveryEventIds: recoveries.map((event) => event.eventId),
434
+ });
435
+ }
436
+ return recoveries[0]?.correctiveDecisionEventId ?? null;
437
+ }
438
+
439
+ export function findRemediationModeRecoveryCandidate(
440
+ store: FileStateStore,
441
+ task: TaskState,
442
+ options: { authorityMode?: 'enforce' | 'observe' } = {},
443
+ ): RemediationModeRecoveryAssessment | null {
444
+ const raw = readRawRemediationEvents(store, task);
445
+ const candidates = raw.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
446
+ if (candidates.length === 0) return null;
447
+ if (candidates.length !== 1) {
448
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple malformed remediation modes are not recoverable.', {
449
+ taskId: task.id,
450
+ eventIds: candidates.map((event) => event.eventId),
451
+ });
452
+ }
453
+ const posture = readTaskC1Posture(store, task);
454
+ const lease = readWriterLeaseForModeRecovery(store, task);
455
+ const actor = posture.state === 'claimed' ? posture.claimant : lease?.owner ?? 'worker';
456
+ return assessRemediationModeRecovery(
457
+ store,
458
+ task,
459
+ candidates[0]!.stepId,
460
+ actor,
461
+ lease?.token ?? null,
462
+ options,
463
+ );
464
+ }
465
+
466
+ export function assessRemediationModeRecovery(
467
+ store: FileStateStore,
468
+ task: TaskState,
469
+ stepId: string,
470
+ actor: string,
471
+ writerToken: string | null,
472
+ options: { authorityMode?: 'enforce' | 'observe' } = {},
473
+ ): RemediationModeRecoveryAssessment {
474
+ const normalizedActor = actor.trim();
475
+ if (!normalizedActor) throw new WorkflowError('INVALID_ARGUMENT', 'Remediation mode recovery actor is required.');
476
+ const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
477
+ if (!step) throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
478
+ if (task.status !== 'needs_fix' || step.status !== 'failed') {
479
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Remediation mode recovery requires the exact failed Task/Step posture.', {
480
+ taskId: task.id,
481
+ taskStatus: task.status,
482
+ stepId,
483
+ stepStatus: step.status,
484
+ });
485
+ }
486
+ if (!task.planHash) throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} is missing its Plan hash.`);
487
+
488
+ const remediations = readRawRemediationEvents(store, task);
489
+ const decisions = readRawCorrectiveDecisionEvents(store, task);
490
+ validateRemediationAttemptOrdinals(remediations, task);
491
+ validateRemediationReviewReferences(store, task, remediations);
492
+ validateCorrectiveDecisionCoverage(store, task, decisions, remediations);
493
+ const malformed = remediations.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
494
+ if (malformed.length !== 1 || malformed[0]!.stepId !== stepId || malformed[0]!.attemptOrdinal !== 3) {
495
+ throw new WorkflowError('STATE_CORRUPT', 'State does not contain the one exact alpha.7.2 remediation mode omission.', {
496
+ taskId: task.id,
497
+ stepId,
498
+ malformedEventIds: malformed.map((event) => event.eventId),
499
+ });
500
+ }
501
+ const stepRemediations = remediations.filter((event) => event.stepId === stepId);
502
+ if (
503
+ stepRemediations.length !== 3
504
+ || stepRemediations[0]!.mode !== 'ordinary'
505
+ || stepRemediations[1]!.mode !== 'ordinary'
506
+ || stepRemediations[2]!.eventId !== malformed[0]!.eventId
507
+ ) {
508
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires exactly two ordinary attempts and one malformed third attempt.', {
509
+ taskId: task.id,
510
+ stepId,
511
+ });
512
+ }
513
+ const decisionMatches = decisions.filter((event) =>
514
+ event.stepId === stepId
515
+ && event.triggeringAttemptCount === 3
516
+ && event.decision === 'continue-fix'
517
+ && sameStringArray(event.coveredEventIds, stepRemediations.slice(0, 2).map((event) => event.eventId)),
518
+ );
519
+ if (decisionMatches.length !== 1) {
520
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires one exact continue-fix decision.', {
521
+ taskId: task.id,
522
+ stepId,
523
+ decisionEventIds: decisionMatches.map((event) => event.eventId),
524
+ });
525
+ }
526
+ const decision = decisionMatches[0]!;
527
+ const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) =>
528
+ event.stepId === stepId
529
+ && event.triggeringAttemptCount === 3
530
+ && event.correctiveDecisionEventId === decision.eventId
531
+ && event.correctiveDecisionEventHash === decision.eventHash
532
+ && event.reboundPlanHash === task.planHash,
533
+ );
534
+ if (correctiveRecoveries.length !== 1) {
535
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires one exact corrective-decision recovery.', {
536
+ taskId: task.id,
537
+ stepId,
538
+ recoveryEventIds: correctiveRecoveries.map((event) => event.eventId),
539
+ });
540
+ }
541
+ const correctiveRecovery = correctiveRecoveries[0]!;
542
+ const authority = options.authorityMode === 'observe'
543
+ ? observeRemediationModeRecoveryAuthority(store, task)
544
+ : requireRemediationModeRecoveryAuthority(store, task, normalizedActor, writerToken);
545
+ const existing = readRemediationModeRecoveryEvents(store, task).filter((event) =>
546
+ event.remediationEventId === malformed[0]!.eventId,
547
+ );
548
+ if (existing.length > 1) {
549
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation mode recoveries target one event.', {
550
+ taskId: task.id,
551
+ remediationEventId: malformed[0]!.eventId,
552
+ });
553
+ }
554
+ if (existing[0]) {
555
+ validateRemediationModeRecoveryBinding(existing[0], malformed[0]!, decision, correctiveRecovery);
556
+ const retryMismatches = [
557
+ existing[0].taskRevision !== task.revision ? 'taskRevision' : null,
558
+ existing[0].planHash !== task.planHash ? 'planHash' : null,
559
+ existing[0].actor !== normalizedActor ? 'actor' : null,
560
+ existing[0].c1ClaimEventHash !== authority.c1ClaimEventHash ? 'c1ClaimEventHash' : null,
561
+ existing[0].writerLeaseTokenHash !== authority.writerLeaseTokenHash ? 'writerLeaseTokenHash' : null,
562
+ ].filter((value): value is string => value !== null);
563
+ if (retryMismatches.length > 0) {
564
+ throw new WorkflowError('STATE_CONFLICT', 'Existing remediation mode recovery conflicts with the requested retry binding.', {
565
+ taskId: task.id,
566
+ stepId,
567
+ recoveryEventId: existing[0].eventId,
568
+ mismatches: retryMismatches,
569
+ });
570
+ }
571
+ }
572
+ const evidenceFingerprint = sha256Hex(canonicalJsonStringify({
573
+ taskId: task.id,
574
+ taskRevision: task.revision,
575
+ stepId,
576
+ planHash: task.planHash,
577
+ remediationEventHash: malformed[0]!.eventHash,
578
+ correctiveDecisionEventHash: decision.eventHash,
579
+ correctiveDecisionRecoveryEventHash: correctiveRecovery.eventHash,
580
+ actor: normalizedActor,
581
+ c1ClaimEventHash: authority.c1ClaimEventHash,
582
+ writerLeaseTokenHash: authority.writerLeaseTokenHash,
583
+ }));
584
+ return {
585
+ task,
586
+ remediation: malformed[0]!,
587
+ decision,
588
+ correctiveRecovery,
589
+ existingRecovery: existing[0] ?? null,
590
+ actor: normalizedActor,
591
+ c1ClaimEventHash: authority.c1ClaimEventHash,
592
+ writerLeaseTokenHash: authority.writerLeaseTokenHash,
593
+ evidenceFingerprint,
594
+ };
595
+ }
596
+
597
+ function observeRemediationModeRecoveryAuthority(
598
+ store: FileStateStore,
599
+ task: TaskState,
600
+ ): { c1ClaimEventHash: string | null; writerLeaseTokenHash: string | null } {
601
+ const posture = readTaskC1Posture(store, task);
602
+ const lease = readWriterLeaseForModeRecovery(store, task);
603
+ return {
604
+ c1ClaimEventHash: posture.state === 'none' ? null : posture.latestEvent.eventHash,
605
+ writerLeaseTokenHash: lease ? sha256Hex(lease.token) : null,
606
+ };
607
+ }
608
+
609
+ export function appendRemediationModeRecovery(
610
+ store: FileStateStore,
611
+ task: TaskState,
612
+ stepId: string,
613
+ expectedRevision: number,
614
+ actor: string,
615
+ writerToken: string | null,
616
+ now: Date,
617
+ ): RemediationModeRecoveryEvent {
618
+ if (task.revision !== expectedRevision) {
619
+ throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
620
+ expectedRevision,
621
+ actualRevision: task.revision,
622
+ });
623
+ }
624
+ const preflight = assessRemediationModeRecovery(store, task, stepId, actor, writerToken);
625
+ const root = store.taskRoot(task.projectId, task.id);
626
+ const lockFile = path.join(root, REMEDIATION_MODE_RECOVERY_LOCK);
627
+ let descriptor: number;
628
+ try {
629
+ descriptor = openSync(lockFile, 'wx', 0o600);
630
+ closeSync(descriptor);
631
+ } catch (error) {
632
+ if (existsSync(lockFile)) {
633
+ throw new WorkflowError('LOCKED', `Remediation mode recovery for ${task.id} is already being serialized.`, {
634
+ taskId: task.id,
635
+ stepId,
636
+ });
637
+ }
638
+ throw error;
639
+ }
640
+ try {
641
+ const currentTask = store.readTask(task.projectId, task.id);
642
+ if (currentTask.revision !== expectedRevision) {
643
+ throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
644
+ expectedRevision,
645
+ actualRevision: currentTask.revision,
646
+ });
647
+ }
648
+ let underLock: RemediationModeRecoveryAssessment;
649
+ try {
650
+ underLock = assessRemediationModeRecovery(store, currentTask, stepId, actor, writerToken);
651
+ } catch (error) {
652
+ throw new WorkflowError('STATE_CONFLICT', 'Remediation mode recovery evidence changed after preflight.', {
653
+ taskId: task.id,
654
+ stepId,
655
+ cause: error instanceof Error ? error.message : String(error),
656
+ });
657
+ }
658
+ if (underLock.existingRecovery) return underLock.existingRecovery;
659
+ if (underLock.evidenceFingerprint !== preflight.evidenceFingerprint) {
660
+ throw new WorkflowError('STATE_CONFLICT', 'Remediation mode recovery evidence changed after preflight.', {
661
+ taskId: task.id,
662
+ stepId,
663
+ });
664
+ }
665
+ return store.appendSidecarEvent<RemediationModeRecoveryEvent>(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
666
+ eventId: `RMR-${createUlid(now.getTime())}`,
667
+ recordedAt: now.toISOString(),
668
+ taskId: task.id,
669
+ stepId,
670
+ taskRevision: expectedRevision,
671
+ remediationEventId: underLock.remediation.eventId,
672
+ remediationEventHash: underLock.remediation.eventHash,
673
+ correctiveDecisionEventId: underLock.decision.eventId,
674
+ correctiveDecisionEventHash: underLock.decision.eventHash,
675
+ correctiveDecisionRecoveryEventId: underLock.correctiveRecovery.eventId,
676
+ correctiveDecisionRecoveryEventHash: underLock.correctiveRecovery.eventHash,
677
+ attemptOrdinal: 3,
678
+ planHash: currentTask.planHash!,
679
+ actor: underLock.actor,
680
+ c1ClaimEventHash: underLock.c1ClaimEventHash,
681
+ writerLeaseTokenHash: underLock.writerLeaseTokenHash,
682
+ fromMode: 'ordinary',
683
+ toMode: 'corrective',
684
+ reason: 'alpha7.2-recovered-continue-fix-mode-omission',
685
+ }, {
686
+ validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, currentTask, context.file, context.line),
687
+ });
688
+ } finally {
689
+ if (existsSync(lockFile)) unlinkSync(lockFile);
690
+ }
691
+ }
692
+
693
+ function requireRemediationModeRecoveryAuthority(
694
+ store: FileStateStore,
695
+ task: TaskState,
696
+ actor: string,
697
+ writerToken: string | null,
698
+ ): { c1ClaimEventHash: string | null; writerLeaseTokenHash: string | null } {
699
+ const posture = readTaskC1Posture(store, task);
700
+ const lease = readWriterLeaseForModeRecovery(store, task);
701
+ if (posture.state === 'pending') {
702
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Pending C1 handoff must be claimed before remediation mode recovery.');
703
+ }
704
+ if (posture.state === 'claimed') {
705
+ const tokenHash = writerToken ? sha256Hex(writerToken) : null;
706
+ if (
707
+ !lease
708
+ || actor !== posture.claimant
709
+ || lease.owner !== posture.claimant
710
+ || !writerToken
711
+ || lease.token !== writerToken
712
+ || tokenHash !== posture.writerLeaseTokenHash
713
+ ) {
714
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Remediation mode recovery requires the claimed C1 actor and exact writer token.', {
715
+ taskId: task.id,
716
+ claimant: posture.claimant,
717
+ leaseOwner: lease?.owner ?? null,
718
+ });
719
+ }
720
+ return { c1ClaimEventHash: posture.latestEvent.eventHash, writerLeaseTokenHash: tokenHash };
721
+ }
722
+ if (lease && (lease.owner !== actor || !writerToken || lease.token !== writerToken)) {
723
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Existing writer lease is not owned by the remediation recovery actor/token.');
724
+ }
725
+ if (!lease && writerToken) {
726
+ throw new WorkflowError('TRANSITION_BLOCKED', 'A writer token was provided without an existing writer lease.');
727
+ }
728
+ return {
729
+ c1ClaimEventHash: posture.latestEvent?.eventHash ?? null,
730
+ writerLeaseTokenHash: lease ? sha256Hex(lease.token) : null,
731
+ };
732
+ }
733
+
734
+ function readWriterLeaseForModeRecovery(store: FileStateStore, task: TaskState): WriterLease | null {
735
+ const file = path.join(store.lockRoot(task.projectId), `${task.id}.json`);
736
+ if (!existsSync(file)) return null;
737
+ let lease: WriterLease;
738
+ try {
739
+ lease = JSON.parse(readFileSync(file, 'utf8')) as WriterLease;
740
+ } catch {
741
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease is unreadable: ${file}`);
742
+ }
743
+ if (lease.entityId !== task.id || typeof lease.owner !== 'string' || typeof lease.token !== 'string') {
744
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease does not match Task ${task.id}.`);
745
+ }
746
+ return lease;
747
+ }
748
+
749
+ export function appendCorrectiveDecisionEvent(
750
+ store: FileStateStore,
751
+ task: TaskState,
752
+ stepId: string,
753
+ correctiveAudit: CorrectivePlanAuditInput,
754
+ planRiskAudit: CurrentPlanRiskAudit,
755
+ planHash: string,
756
+ now: Date,
757
+ ): CorrectiveDecisionEvent {
758
+ validateCorrectiveDecisionAudit(correctiveAudit);
759
+ const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
760
+ const triggeringAttemptCount = remediationEvents.length + 1;
761
+ if (triggeringAttemptCount < 3) {
762
+ throw new WorkflowError(
763
+ 'TRANSITION_BLOCKED',
764
+ `Corrective decision is not available for ${stepId} before two guarded remediation failures exist.`,
765
+ {
766
+ taskId: task.id,
767
+ stepId,
768
+ triggeringAttemptCount,
769
+ },
770
+ );
771
+ }
772
+ const decisions = readCorrectiveDecisionEvents(store, task);
773
+ const matching = decisions.filter((event) =>
774
+ event.taskId === task.id
775
+ && event.stepId === stepId
776
+ && event.triggeringAttemptCount === triggeringAttemptCount,
777
+ );
778
+ if (matching.length > 1) {
779
+ throw new WorkflowError(
780
+ 'STATE_CORRUPT',
781
+ 'Multiple corrective decisions exist for the current guarded remediation binding.',
782
+ {
783
+ taskId: task.id,
784
+ stepId,
785
+ triggeringAttemptCount,
786
+ eventIds: matching.map((event) => event.eventId),
787
+ },
788
+ );
789
+ }
790
+ const coveredFindingIds = normalizeStringList(correctiveAudit.reviewedFindingIds, 'Corrective decision reviewedFindingIds');
791
+ const coveredEventIds = remediationEvents.map((event) => event.eventId);
792
+ if (matching.length === 1) {
793
+ const reused = matching[0]!;
794
+ if (reused.planHash !== planHash) {
795
+ throw new WorkflowError('STATE_CONFLICT', 'A corrective decision already exists for this Step and triggering ordinal under a different Plan hash.', {
796
+ taskId: task.id,
797
+ stepId,
798
+ triggeringAttemptCount,
799
+ existingPlanHash: reused.planHash,
800
+ requestedPlanHash: planHash,
801
+ eventId: reused.eventId,
802
+ });
803
+ }
804
+ if (
805
+ reused.decision !== correctiveAudit.decision
806
+ || reused.auditor !== correctiveAudit.auditor.trim()
807
+ || !sameStringArray(reused.coveredFindingIds, coveredFindingIds)
808
+ || !sameStringArray(reused.coveredEventIds, coveredEventIds)
809
+ ) {
810
+ throw new WorkflowError('STATE_CONFLICT', 'Existing corrective decision payload does not match the requested guarded remediation binding.', {
811
+ taskId: task.id,
812
+ stepId,
813
+ triggeringAttemptCount,
814
+ eventId: reused.eventId,
815
+ });
816
+ }
817
+ return reused;
818
+ }
819
+ requireGuardedCategory(planRiskAudit, stepId);
820
+ return store.appendSidecarEvent<CorrectiveDecisionEvent>(
821
+ store.taskRoot(task.projectId, task.id),
822
+ CORRECTIVE_DECISIONS_SIDECAR,
823
+ {
824
+ eventId: `CRD-${createUlid(now.getTime())}`,
825
+ recordedAt: now.toISOString(),
826
+ taskId: task.id,
827
+ stepId,
828
+ triggeringAttemptCount,
829
+ decision: correctiveAudit.decision,
830
+ auditor: correctiveAudit.auditor.trim(),
831
+ coveredFindingIds,
832
+ coveredEventIds,
833
+ planHash,
834
+ },
835
+ {
836
+ validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
837
+ },
838
+ );
839
+ }
840
+
841
+ export function prepareStopEscalateOverride(
842
+ store: FileStateStore,
843
+ task: TaskState,
844
+ stepId: string,
845
+ expectedRevision: number,
846
+ actor: string,
847
+ reason: string,
848
+ headCommit: string,
849
+ ): StopEscalateOverridePreparation {
850
+ if (task.revision !== expectedRevision) {
851
+ throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
852
+ expectedRevision,
853
+ actualRevision: task.revision,
854
+ });
855
+ }
856
+ const normalizedActor = actor.trim();
857
+ const normalizedReason = reason.trim();
858
+ if (!normalizedActor) throw new WorkflowError('INVALID_ARGUMENT', 'Stop override requires a human actor.');
859
+ if (!normalizedReason) throw new WorkflowError('INVALID_ARGUMENT', 'Stop override requires a reason.');
860
+ if (!COMMIT_SHA_PATTERN.test(headCommit)) {
861
+ throw new WorkflowError('INVALID_ARGUMENT', 'Stop override requires the exact current Git HEAD.');
862
+ }
863
+ if (task.status !== 'needs_fix') {
864
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Stop override requires a needs_fix Task.', {
865
+ taskId: task.id,
866
+ taskStatus: task.status,
867
+ });
868
+ }
869
+ const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
870
+ if (!step) throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
871
+ if (step.status !== 'failed') {
872
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Stop override requires the exact failed Step.', {
873
+ taskId: task.id,
874
+ stepId,
875
+ stepStatus: step.status,
876
+ });
877
+ }
878
+ if (!task.planHash) throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} is missing its Plan hash.`);
879
+
880
+ const history = readOpenGuardedRemediationHistory(store, task, stepId);
881
+ const remediations = history.remediationEvents;
882
+ if (
883
+ remediations.length !== 3
884
+ || remediations[0]?.attemptOrdinal !== 1
885
+ || remediations[0]?.mode !== 'ordinary'
886
+ || remediations[1]?.attemptOrdinal !== 2
887
+ || remediations[1]?.mode !== 'ordinary'
888
+ || remediations[2]?.attemptOrdinal !== 3
889
+ || remediations[2]?.mode !== 'corrective'
890
+ ) {
891
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Stop override is limited to the exact beta.11 three-failure history.', {
892
+ taskId: task.id,
893
+ stepId,
894
+ attempts: remediations.map((event) => ({ eventId: event.eventId, ordinal: event.attemptOrdinal, mode: event.mode })),
895
+ });
896
+ }
897
+ const priorContinueMatches = history.correctiveDecisions.filter((event) =>
898
+ event.triggeringAttemptCount === 3
899
+ && event.decision === 'continue-fix'
900
+ && event.planHash === task.planHash
901
+ && sameStringArray(event.coveredEventIds, remediations.slice(0, 2).map((candidate) => candidate.eventId)),
902
+ );
903
+ const stopMatches = history.correctiveDecisions.filter((event) =>
904
+ event.triggeringAttemptCount === 4
905
+ && event.decision === 'stop-escalate'
906
+ && event.planHash === task.planHash
907
+ && sameStringArray(event.coveredEventIds, remediations.map((candidate) => candidate.eventId)),
908
+ );
909
+ if (priorContinueMatches.length !== 1 || stopMatches.length !== 1) {
910
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Stop override requires one exact attempt-3 continue decision and one exact attempt-4 stop decision.', {
911
+ taskId: task.id,
912
+ stepId,
913
+ priorContinueDecisionEventIds: priorContinueMatches.map((event) => event.eventId),
914
+ stopDecisionEventIds: stopMatches.map((event) => event.eventId),
915
+ });
916
+ }
917
+ const priorContinue = priorContinueMatches[0]!;
918
+ const stopDecision = stopMatches[0]!;
919
+ const binding = {
920
+ taskId: task.id,
921
+ taskRevision: task.revision,
922
+ stepId,
923
+ stopDecisionEventId: stopDecision.eventId,
924
+ stopDecisionEventHash: stopDecision.eventHash,
925
+ priorContinueDecisionEventId: priorContinue.eventId,
926
+ priorContinueDecisionEventHash: priorContinue.eventHash,
927
+ triggeringAttemptCount: 4 as const,
928
+ coveredEventIds: remediations.map((event) => event.eventId),
929
+ planHash: task.planHash,
930
+ headCommit,
931
+ actor: normalizedActor,
932
+ reason: normalizedReason,
933
+ packageVersion: PACKAGE_VERSION,
934
+ policy: LEGACY_STOP_OVERRIDE_POLICY,
935
+ };
936
+ const confirmationCodeBindingHash = stopEscalateOverrideBindingHash(binding);
937
+ const existing = readStopEscalateOverrideEvents(store, task).find(
938
+ (event) => event.stopDecisionEventId === stopDecision.eventId,
939
+ ) ?? null;
940
+ if (existing && existing.confirmationCodeBindingHash !== confirmationCodeBindingHash) {
941
+ throw new WorkflowError('STATE_CONFLICT', 'The stop decision already has an override with a different human binding.', {
942
+ taskId: task.id,
943
+ stepId,
944
+ overrideEventId: existing.eventId,
945
+ });
946
+ }
947
+ return {
948
+ eligible: true,
949
+ action: 'task stop-override-apply',
950
+ ...binding,
951
+ confirmationCodeBindingHash,
952
+ confirmationCode: `SOO-${confirmationCodeBindingHash.slice(0, 24).toUpperCase()}`,
953
+ appendOnly: true,
954
+ preservesOriginalDecision: true,
955
+ delegatedApprovalAllowed: false,
956
+ };
957
+ }
958
+
959
+ export function appendStopEscalateOverride(
960
+ store: FileStateStore,
961
+ task: TaskState,
962
+ stepId: string,
963
+ expectedRevision: number,
964
+ actor: string,
965
+ reason: string,
966
+ headCommit: string,
967
+ confirmationCode: string,
968
+ now: Date,
969
+ ): StopEscalateOverrideEvent {
970
+ const prepared = prepareStopEscalateOverride(
971
+ store,
972
+ task,
973
+ stepId,
974
+ expectedRevision,
975
+ actor,
976
+ reason,
977
+ headCommit,
978
+ );
979
+ if (confirmationCode.trim() !== prepared.confirmationCode) {
980
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Stop override confirmation code does not match the current state binding.', {
981
+ taskId: task.id,
982
+ stepId,
983
+ confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
984
+ });
985
+ }
986
+ const root = store.taskRoot(task.projectId, task.id);
987
+ const existing = readStopEscalateOverrideEvents(store, task).find(
988
+ (event) => event.stopDecisionEventId === prepared.stopDecisionEventId,
989
+ ) ?? null;
990
+ if (existing) return existing;
991
+
992
+ const lockFile = path.join(root, STOP_ESCALATE_OVERRIDE_LOCK);
993
+ let descriptor: number;
994
+ try {
995
+ descriptor = openSync(lockFile, 'wx', 0o600);
996
+ closeSync(descriptor);
997
+ } catch (error) {
998
+ if (existsSync(lockFile)) {
999
+ throw new WorkflowError('LOCKED', `Stop override for ${task.id} is already being serialized.`, {
1000
+ taskId: task.id,
1001
+ stepId,
1002
+ });
1003
+ }
1004
+ throw error;
1005
+ }
1006
+ try {
1007
+ const currentTask = store.readTask(task.projectId, task.id);
1008
+ const underLock = prepareStopEscalateOverride(
1009
+ store,
1010
+ currentTask,
1011
+ stepId,
1012
+ expectedRevision,
1013
+ actor,
1014
+ reason,
1015
+ headCommit,
1016
+ );
1017
+ if (
1018
+ underLock.confirmationCode !== prepared.confirmationCode
1019
+ || underLock.confirmationCodeBindingHash !== prepared.confirmationCodeBindingHash
1020
+ ) {
1021
+ throw new WorkflowError('STATE_CONFLICT', 'Stop override evidence changed after preparation.', {
1022
+ taskId: task.id,
1023
+ stepId,
1024
+ });
1025
+ }
1026
+ return store.appendSidecarEvent<StopEscalateOverrideEvent>(root, STOP_ESCALATE_OVERRIDES_SIDECAR, {
1027
+ eventId: `SOO-${createUlid(now.getTime())}`,
1028
+ recordedAt: now.toISOString(),
1029
+ taskId: underLock.taskId,
1030
+ taskRevision: underLock.taskRevision,
1031
+ stepId: underLock.stepId,
1032
+ stopDecisionEventId: underLock.stopDecisionEventId,
1033
+ stopDecisionEventHash: underLock.stopDecisionEventHash,
1034
+ priorContinueDecisionEventId: underLock.priorContinueDecisionEventId,
1035
+ priorContinueDecisionEventHash: underLock.priorContinueDecisionEventHash,
1036
+ triggeringAttemptCount: 4,
1037
+ coveredEventIds: underLock.coveredEventIds,
1038
+ planHash: underLock.planHash,
1039
+ headCommit: underLock.headCommit,
1040
+ actor: underLock.actor,
1041
+ reason: underLock.reason,
1042
+ packageVersion: underLock.packageVersion,
1043
+ confirmationCodeBindingHash: underLock.confirmationCodeBindingHash,
1044
+ policy: LEGACY_STOP_OVERRIDE_POLICY,
1045
+ }, {
1046
+ validateEvent: (event, context) => validateStoredStopEscalateOverride(event, currentTask, context.file, context.line),
1047
+ });
1048
+ } finally {
1049
+ if (existsSync(lockFile)) unlinkSync(lockFile);
1050
+ }
1051
+ }
1052
+
1053
+ export function appendPlanIntegrityRecoveryDecision(
1054
+ store: FileStateStore,
1055
+ task: TaskState,
1056
+ stepId: string,
1057
+ auditor: string,
1058
+ evidence: PlanIntegrityConflictEvidence,
1059
+ now: Date,
1060
+ ): CorrectiveDecisionEvent {
1061
+ if (!task.planHash) {
1062
+ throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} has no Plan hash for Plan-integrity recovery.`);
1063
+ }
1064
+ if (!auditor.trim()) {
1065
+ throw new WorkflowError('INVALID_ARGUMENT', 'Plan-integrity recovery requires a derived auditor identity.');
1066
+ }
1067
+ const allRemediationEvents = readRemediationEvents(store, task).filter(event => event.stepId === stepId);
1068
+ const support = evidence.kind === 'check-support-anchor-outside-allowed-writes' || evidence.kind === 'failed-check-component-owner-outside-allowed-writes';
1069
+ const remediationEvents = support ? selectPlanIntegrityRemediations(store, task, allRemediationEvents,
1070
+ readCorrectiveDecisionEvents(store, task).filter(event => event.stepId === stepId)) : allRemediationEvents;
1071
+ if (!remediationEvents) throw new WorkflowError('TRANSITION_BLOCKED', 'Mechanical recovery lacks a verified current Plan cycle.');
1072
+ const expectedBindings = evidence.kind === 'missing-npm-script-outside-allowed-writes'
1073
+ ? [{
1074
+ eventId: evidence.remediationEventId,
1075
+ eventHash: evidence.remediationEventHash,
1076
+ attemptOrdinal: 1,
1077
+ }]
1078
+ : evidence.remediationEvents;
1079
+ if (remediationEvents.length !== expectedBindings.length
1080
+ || remediationEvents.some((event, index) => {
1081
+ const binding = expectedBindings[index];
1082
+ return event.failureKind !== 'checks-failed'
1083
+ || event.eventId !== binding?.eventId
1084
+ || event.eventHash !== binding.eventHash
1085
+ || event.attemptOrdinal !== binding.attemptOrdinal;
1086
+ })) {
1087
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Plan-integrity recovery requires the exact checks-failed remediation history.', {
1088
+ taskId: task.id,
1089
+ stepId,
1090
+ remediationEventIds: remediationEvents.map((event) => event.eventId),
1091
+ });
1092
+ }
1093
+ const triggeringAttemptCount = allRemediationEvents.length + 1;
1094
+ const recoveryDecision = evidence.kind === 'check-support-anchor-outside-allowed-writes'
1095
+ || evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
1096
+ ? 'continue-fix' as const
1097
+ : 'replan-required' as const;
1098
+ const decisions = readCorrectiveDecisionEvents(store, task).filter((event) =>
1099
+ event.stepId === stepId && event.triggeringAttemptCount === triggeringAttemptCount);
1100
+ const findingId = `PLAN-INTEGRITY-${evidence.conflictHash.slice(0, 16).toUpperCase()}`;
1101
+ if (decisions.length > 1) {
1102
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple Plan-integrity recovery decisions exist for the same Step.', {
1103
+ taskId: task.id,
1104
+ stepId,
1105
+ eventIds: decisions.map((event) => event.eventId),
1106
+ });
1107
+ }
1108
+ if (decisions.length === 1) {
1109
+ const existing = decisions[0]!;
1110
+ if (
1111
+ existing.planHash !== task.planHash
1112
+ || existing.decision !== recoveryDecision
1113
+ || existing.auditor !== auditor.trim()
1114
+ || existing.planIntegrityEvidence?.conflictHash !== evidence.conflictHash
1115
+ ) {
1116
+ throw new WorkflowError('STATE_CONFLICT', 'Existing Plan-integrity recovery decision does not match the requested conflict.', {
1117
+ taskId: task.id,
1118
+ stepId,
1119
+ eventId: existing.eventId,
1120
+ });
1121
+ }
1122
+ return existing;
1123
+ }
1124
+ return store.appendSidecarEvent<CorrectiveDecisionEvent>(
1125
+ store.taskRoot(task.projectId, task.id),
1126
+ CORRECTIVE_DECISIONS_SIDECAR,
1127
+ {
1128
+ eventId: `CRD-${createUlid(now.getTime())}`,
1129
+ recordedAt: now.toISOString(),
1130
+ taskId: task.id,
1131
+ stepId,
1132
+ triggeringAttemptCount,
1133
+ decision: recoveryDecision,
1134
+ auditor: auditor.trim(),
1135
+ coveredFindingIds: [findingId],
1136
+ coveredEventIds: allRemediationEvents.map((event) => event.eventId),
1137
+ planHash: task.planHash,
1138
+ recoveryBasis: 'plan-integrity',
1139
+ planIntegrityEvidence: evidence,
1140
+ },
1141
+ {
1142
+ validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
1143
+ },
1144
+ );
1145
+ }
1146
+
1147
+ export function assertGuardedRemediationAttemptAllowed(
1148
+ store: FileStateStore,
1149
+ task: TaskState,
1150
+ stepId: string,
1151
+ planRiskAudit: CurrentPlanRiskAudit,
1152
+ recoveredDecisionEventId: string | null = null,
1153
+ reboundStopOverrideDecisionEventId: string | null = null,
1154
+ ): GuardedRemediationAttemptGate {
1155
+ const history = readOpenGuardedRemediationHistory(store, task, stepId);
1156
+ return deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, history.remediationEvents, history.correctiveDecisions, {
1157
+ enforceRuntimeGate: true,
1158
+ recoveredDecisionEventId,
1159
+ reboundStopOverrideDecisionEventId,
1160
+ overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
1161
+ priorRemediationEvents: history.priorRemediationEvents,
1162
+ nextAttemptOrdinal: history.nextAttemptOrdinal,
1163
+ });
1164
+ }
1165
+
1166
+ export function readGuardedRemediationPostureForStep(
1167
+ store: FileStateStore,
1168
+ task: TaskState,
1169
+ stepId: string,
1170
+ planRiskAudit: CurrentPlanRiskAudit,
1171
+ ): GuardedRemediationAttemptGate | null {
1172
+ if (!planRiskAudit.reviewRequiredStepIds.includes(stepId)) return null;
1173
+ const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
1174
+ if (!step) {
1175
+ throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} is missing guarded Step ${stepId}.`, {
1176
+ taskId: task.id,
1177
+ stepId,
1178
+ });
1179
+ }
1180
+ const history = readOpenGuardedRemediationHistory(store, task, stepId);
1181
+ const remediationEvents = history.remediationEvents;
1182
+ const earlyPlanIntegrityDecision = remediationEvents.length === 1
1183
+ ? findCurrentCorrectiveDecision(task, stepId, 2, history.correctiveDecisions)
1184
+ : null;
1185
+ const currentDecision = findCurrentCorrectiveDecision(task, stepId, history.nextAttemptOrdinal, history.correctiveDecisions);
1186
+ if (remediationEvents.length < 2 && earlyPlanIntegrityDecision?.recoveryBasis !== 'plan-integrity'
1187
+ && currentDecision === null
1188
+ && deriveCurrentCauseDecision(remediationEvents, history.priorRemediationEvents) !== 'root-cause-replan') return null;
1189
+ if (step.status === 'failed' || step.status === 'planned') {
1190
+ return deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, remediationEvents, history.correctiveDecisions, {
1191
+ overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
1192
+ priorRemediationEvents: history.priorRemediationEvents,
1193
+ nextAttemptOrdinal: history.nextAttemptOrdinal,
1194
+ });
1195
+ }
1196
+ if (task.status !== 'blocked' || step.status !== 'in_progress' || !step.evidence) return null;
1197
+ const currentCycle = buildCurrentStrictStepReviewCycle(store, task, stepId);
1198
+ if (currentCycle.resolution !== 'unverified') return null;
1199
+ return deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, remediationEvents, history.correctiveDecisions, {
1200
+ overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
1201
+ priorRemediationEvents: history.priorRemediationEvents,
1202
+ nextAttemptOrdinal: history.nextAttemptOrdinal,
1203
+ });
1204
+ }
1205
+
1206
+ interface GuardedRemediationHistory {
1207
+ remediationEvents: RemediationEvent[];
1208
+ priorRemediationEvents: RemediationEvent[];
1209
+ correctiveDecisions: CorrectiveDecisionEvent[];
1210
+ nextAttemptOrdinal: number;
1211
+ }
1212
+
1213
+ interface RemediationPlanCycleBinding {
1214
+ semanticHash: string;
1215
+ planHash: string;
1216
+ sourceTaskRevision: number;
1217
+ }
1218
+
1219
+ function readOpenGuardedRemediationHistory(
1220
+ store: FileStateStore,
1221
+ task: TaskState,
1222
+ stepId: string,
1223
+ ): GuardedRemediationHistory {
1224
+ const allStepRemediations = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
1225
+ const allStepDecisions = readCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId);
1226
+ const passedBoundary = latestPassedVerifiedStepReviewEvent(store, task, stepId);
1227
+ const remediationEvents = passedBoundary
1228
+ ? allStepRemediations.filter((event) => event.recordedAt > passedBoundary.recordedAt)
1229
+ : allStepRemediations;
1230
+ const correctiveDecisions = passedBoundary
1231
+ ? allStepDecisions.filter((event) => event.recordedAt > passedBoundary.recordedAt)
1232
+ : allStepDecisions;
1233
+ return {
1234
+ ...selectCurrentPlanRemediationCycle(store, task, remediationEvents, correctiveDecisions),
1235
+ nextAttemptOrdinal: allStepRemediations.length + 1,
1236
+ };
1237
+ }
1238
+
1239
+ function selectCurrentPlanRemediationCycle(
1240
+ store: FileStateStore,
1241
+ task: TaskState,
1242
+ remediationEvents: readonly RemediationEvent[],
1243
+ correctiveDecisions: readonly CorrectiveDecisionEvent[],
1244
+ canonicalPlanHash = task.planHash,
1245
+ ): Omit<GuardedRemediationHistory, 'nextAttemptOrdinal'> {
1246
+ const unchanged = () => ({
1247
+ remediationEvents: [...remediationEvents],
1248
+ priorRemediationEvents: [],
1249
+ correctiveDecisions: [...correctiveDecisions],
1250
+ });
1251
+ if (remediationEvents.length === 0 || !task.planHash) return unchanged();
1252
+ const audits = readPlanRiskAuditEvents(store, task);
1253
+ const currentAudit = [...audits].reverse().find((event) =>
1254
+ event.planHash === task.planHash
1255
+ && event.taskRevision <= task.revision
1256
+ && event.decision !== 'split-required'
1257
+ && event.decision !== 'stop-escalate') ?? null;
1258
+ if (!currentAudit) return unchanged();
1259
+ const taskRoot = store.taskRoot(task.projectId, task.id);
1260
+ let currentSemanticHash: string;
1261
+ try {
1262
+ currentSemanticHash = normalizePlanKnowledgeBinding(
1263
+ readVerifiedPlanArtifact(taskRoot, task.planHash, canonicalPlanHash!),
1264
+ 'current',
1265
+ ).semanticHash;
1266
+ } catch {
1267
+ return unchanged();
1268
+ }
1269
+ const reviews = readStepReviewEvents(store, task);
1270
+ const bindings = remediationEvents.map((event) =>
1271
+ resolveRemediationPlanCycleBinding(taskRoot, canonicalPlanHash!, event, reviews, audits));
1272
+ if (bindings.some((binding) => binding === null)) return unchanged();
1273
+ let cycleStart = remediationEvents.length;
1274
+ while (cycleStart > 0 && bindings[cycleStart - 1]!.semanticHash === currentSemanticHash) cycleStart -= 1;
1275
+ if (cycleStart === remediationEvents.length
1276
+ && currentAudit.taskRevision <= bindings.at(-1)!.sourceTaskRevision) return unchanged();
1277
+ return {
1278
+ remediationEvents: remediationEvents.slice(cycleStart),
1279
+ priorRemediationEvents: remediationEvents.slice(0, cycleStart),
1280
+ // Corrective decisions remain globally authoritative. The semantic cycle scopes
1281
+ // cause counting only; filtering decisions could discard stop/split authority.
1282
+ correctiveDecisions: [...correctiveDecisions],
1283
+ };
1284
+ }
1285
+
1286
+ /** Scope mechanical support evidence only; global corrective authority is never discarded. */
1287
+ export function selectPlanIntegrityRemediations(
1288
+ store: FileStateStore, task: TaskState, events: readonly RemediationEvent[], decisions: readonly CorrectiveDecisionEvent[],
1289
+ canonicalPlanHash = task.planHash,
1290
+ ): RemediationEvent[] | null {
1291
+ if (events.length === 0) return null;
1292
+ const stepId = events[0]!.stepId;
1293
+ if (events.some(event => event.stepId !== stepId) || decisions.some(event => event.stepId !== stepId)) return null;
1294
+ const cycle = selectCurrentPlanRemediationCycle(store, task, events, decisions, canonicalPlanHash);
1295
+ if (cycle.priorRemediationEvents.length === 0) return decisions.length === 0 ? [...events] : null;
1296
+ if (!task.planHash || cycle.remediationEvents.length === 0
1297
+ || decisions.some(d => d.decision === 'stop-escalate' || d.decision === 'split-required')) return null;
1298
+ const root = store.taskRoot(task.projectId, task.id);
1299
+ const history = readCompletedCorrectiveReplanHistory(store, task);
1300
+ const semantic = (hash: string) => {
1301
+ const saved = history.flatMap(boundary => [boundary.beforePlan, boundary.afterPlan]).find(bytes => sha256Hex(bytes) === hash);
1302
+ return normalizePlanKnowledgeBinding(saved ?? readVerifiedPlanArtifact(root, hash, canonicalPlanHash!), 'cycle').semanticHash;
1303
+ };
1304
+ const currentSemantic = semantic(task.planHash);
1305
+ const audits = readPlanRiskAuditEvents(store, task), reviews = readStepReviewEvents(store, task);
1306
+ const priorBindings = cycle.priorRemediationEvents.map(event => resolveRemediationPlanCycleBinding(root, canonicalPlanHash!, event, reviews, audits));
1307
+ const currentBindings = cycle.remediationEvents.map(event => resolveRemediationPlanCycleBinding(root, canonicalPlanHash!, event, reviews, audits));
1308
+ if (priorBindings.some(b => !b) || currentBindings.some(b => !b)) return null;
1309
+ const boundaries = history.filter(boundary =>
1310
+ correctiveReplanBoundaryMatchesStep(boundary.before, stepId)
1311
+ && boundary.after.planHash && boundary.before.planHash
1312
+ && semantic(boundary.after.planHash) === currentSemantic && semantic(boundary.before.planHash) !== currentSemantic
1313
+ && boundary.after.revision <= task.revision
1314
+ && priorBindings.every(b => b!.sourceTaskRevision < boundary.after.revision)
1315
+ && currentBindings.every(b => b!.sourceTaskRevision > boundary.after.revision));
1316
+ if (boundaries.length !== 1) return null;
1317
+ const boundary = boundaries[0]!;
1318
+ let cursor = task.planHash;
1319
+ const visited = new Set<string>();
1320
+ const planBytes = (hash: string) => history.flatMap(b => [b.beforePlan, b.afterPlan]).find(bytes => sha256Hex(bytes) === hash)
1321
+ ?? readVerifiedPlanArtifact(root, hash, canonicalPlanHash!);
1322
+ while (cursor !== boundary.after.planHash) {
1323
+ if (visited.has(cursor)) return null;
1324
+ visited.add(cursor);
1325
+ const links = task.knowledgeRebinds.filter(link => link.reboundPlanHash === cursor);
1326
+ if (links.length !== 1 || !links[0]!.previousPlanHash) return null;
1327
+ const link = links[0]!;
1328
+ assertKnowledgeBindingOnlyPlanChange(planBytes(link.previousPlanHash!), planBytes(cursor));
1329
+ if (!planBytes(link.previousPlanHash!).includes(`- Knowledge map: revision ${link.fromKnowledgeMapRevision}, hash ${link.fromKnowledgeMapHash}\n`)
1330
+ || !planBytes(cursor).includes(`- Knowledge map: revision ${link.toKnowledgeMapRevision}, hash ${link.toKnowledgeMapHash}\n`)) return null;
1331
+ cursor = link.previousPlanHash!;
1332
+ }
1333
+ if (!task.authorizations.some(a => a.kind === 'execution' && ['approved', 'superseded'].includes(a.decision)
1334
+ && a.planHash === task.planHash && a.briefHash === task.briefHash
1335
+ && a.recordedAt >= boundary.admittedAt)) return null;
1336
+ if (decisions.length === 0 || decisions.some(d => d.recordedAt > boundary.admittedAt
1337
+ || semantic(d.planHash) === currentSemantic)) return null;
1338
+ const last = decisions.at(-1)!;
1339
+ if (last.decision !== 'replan-required' || last.triggeringAttemptCount !== cycle.priorRemediationEvents.length + 1
1340
+ || semantic(last.planHash) !== semantic(boundary.before.planHash!)) return null;
1341
+ return cycle.remediationEvents;
1342
+ }
1343
+
1344
+ function resolveRemediationPlanCycleBinding(
1345
+ taskRoot: string,
1346
+ currentPlanHash: string,
1347
+ event: RemediationEvent,
1348
+ reviews: readonly import('../contracts.js').StepReviewEvent[],
1349
+ audits: readonly PlanRiskAuditEvent[],
1350
+ ): RemediationPlanCycleBinding | null {
1351
+ let planHash: string | null = null;
1352
+ let sourceTaskRevision: number | null = null;
1353
+ if (event.failureEvidence.reviewEventHash) {
1354
+ const review = reviews.find((candidate) => candidate.eventHash === event.failureEvidence.reviewEventHash) ?? null;
1355
+ if (!review) return null;
1356
+ planHash = review.planHash;
1357
+ sourceTaskRevision = review.taskRevision;
1358
+ } else if (event.failureEvidence.executionTaskRevision !== undefined) {
1359
+ sourceTaskRevision = event.failureEvidence.executionTaskRevision;
1360
+ planHash = [...audits].reverse().find((audit) => audit.taskRevision <= sourceTaskRevision!)?.planHash ?? null;
1361
+ }
1362
+ if (!planHash || sourceTaskRevision === null
1363
+ || !audits.some((audit) => audit.planHash === planHash && audit.taskRevision <= sourceTaskRevision)) return null;
1364
+ try {
1365
+ return {
1366
+ semanticHash: normalizePlanKnowledgeBinding(
1367
+ readVerifiedPlanArtifact(taskRoot, planHash, currentPlanHash),
1368
+ 'remediation ' + event.eventId,
1369
+ ).semanticHash,
1370
+ planHash,
1371
+ sourceTaskRevision,
1372
+ };
1373
+ } catch {
1374
+ return null;
1375
+ }
1376
+ }
1377
+
1378
+ export function deriveCurrentCauseDecisionForTask(
1379
+ store: FileStateStore,
1380
+ task: TaskState,
1381
+ stepId: string,
1382
+ remediationEvents: readonly RemediationEvent[],
1383
+ ): 'same-task-fix' | 'root-cause-replan' | null {
1384
+ const decisions = readCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId);
1385
+ const cycle = selectCurrentPlanRemediationCycle(store, task, remediationEvents, decisions);
1386
+ return deriveCurrentCauseDecision(cycle.remediationEvents, cycle.priorRemediationEvents);
1387
+ }
1388
+
1389
+ export function findCurrentGuardedRemediationPosture(
1390
+ store: FileStateStore,
1391
+ task: TaskState,
1392
+ planRiskAudit: CurrentPlanRiskAudit,
1393
+ ): GuardedRemediationAttemptGate | null {
1394
+ const matches = planRiskAudit.reviewRequiredStepIds
1395
+ .map((stepId) => readGuardedRemediationPostureForStep(store, task, stepId, planRiskAudit))
1396
+ .filter((value): value is GuardedRemediationAttemptGate => value !== null);
1397
+ if (matches.length === 0) return null;
1398
+ if (matches.length > 1) {
1399
+ throw new WorkflowError(
1400
+ 'STATE_CORRUPT',
1401
+ 'Multiple guarded failed Steps simultaneously require corrective remediation decisions.',
1402
+ {
1403
+ taskId: task.id,
1404
+ stepIds: matches.map((match) => match.stepId),
1405
+ },
1406
+ );
1407
+ }
1408
+ return matches[0]!;
1409
+ }
1410
+
1411
+ export function findFailedGuardedStepRequiringCorrectiveDecision(
1412
+ store: FileStateStore,
1413
+ task: TaskState,
1414
+ planRiskAudit: CurrentPlanRiskAudit,
1415
+ ): GuardedRemediationAttemptGate | null {
1416
+ const posture = findCurrentGuardedRemediationPosture(store, task, planRiskAudit);
1417
+ if (!posture) return null;
1418
+ return posture.posture === 'decision-required'
1419
+ ? posture
1420
+ : null;
1421
+ }
1422
+
1423
+ function deriveAttemptGateFromHistory(
1424
+ store: FileStateStore,
1425
+ task: TaskState,
1426
+ stepId: string,
1427
+ planRiskAudit: CurrentPlanRiskAudit,
1428
+ remediationEvents: readonly RemediationEvent[],
1429
+ correctiveDecisions: readonly CorrectiveDecisionEvent[],
1430
+ options: {
1431
+ enforceRuntimeGate?: boolean;
1432
+ recoveredDecisionEventId?: string | null;
1433
+ reboundStopOverrideDecisionEventId?: string | null;
1434
+ overriddenStopDecisionEventIds?: ReadonlySet<string>;
1435
+ priorRemediationEvents?: readonly RemediationEvent[];
1436
+ nextAttemptOrdinal?: number;
1437
+ } = {},
1438
+ ): GuardedRemediationAttemptGate {
1439
+ const attemptCount = remediationEvents.length + (options.priorRemediationEvents?.length ?? 0);
1440
+ const nextAttemptOrdinal = options.nextAttemptOrdinal ?? attemptCount + 1;
1441
+ const guardedCategory = requireGuardedCategory(planRiskAudit, stepId);
1442
+ const rollingCauseDecision = deriveCurrentCauseDecision(remediationEvents, options.priorRemediationEvents ?? []);
1443
+ const currentCorrectiveDecision = findCurrentCorrectiveDecision(
1444
+ task,
1445
+ stepId,
1446
+ nextAttemptOrdinal,
1447
+ correctiveDecisions,
1448
+ );
1449
+ const planIntegrityDecision = currentCorrectiveDecision;
1450
+ if (planIntegrityDecision?.recoveryBasis === 'plan-integrity'
1451
+ && (planIntegrityDecision.planHash === task.planHash || planIntegrityDecision.decision === 'replan-required'
1452
+ || isKnowledgeReboundSupportDecision(store, task, planIntegrityDecision))) {
1453
+ if (planIntegrityDecision.planHash !== task.planHash && planIntegrityDecision.decision === 'replan-required') {
1454
+ return {
1455
+ stepId,
1456
+ guardedCategory,
1457
+ attemptCount,
1458
+ nextAttemptOrdinal,
1459
+ mode: 'corrective',
1460
+ correctiveDecision: planIntegrityDecision,
1461
+ posture: planIntegrityDecision.decision === 'replan-required' ? 'replan-ready' : 'continue-fix',
1462
+ };
1463
+ }
1464
+ if (planIntegrityDecision.decision === 'continue-fix'
1465
+ && (planIntegrityDecision.planIntegrityEvidence?.kind === 'check-support-anchor-outside-allowed-writes'
1466
+ || planIntegrityDecision.planIntegrityEvidence?.kind === 'failed-check-component-owner-outside-allowed-writes')) {
1467
+ return {
1468
+ stepId,
1469
+ guardedCategory,
1470
+ attemptCount,
1471
+ nextAttemptOrdinal,
1472
+ mode: 'ordinary',
1473
+ correctiveDecision: planIntegrityDecision,
1474
+ posture: 'continue-fix',
1475
+ };
1476
+ }
1477
+ if (planIntegrityDecision.decision !== 'replan-required') {
1478
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity decision has an unsupported disposition.', {
1479
+ taskId: task.id,
1480
+ stepId,
1481
+ decisionEventId: planIntegrityDecision.eventId,
1482
+ });
1483
+ }
1484
+ return {
1485
+ stepId,
1486
+ guardedCategory,
1487
+ attemptCount,
1488
+ nextAttemptOrdinal,
1489
+ mode: 'ordinary',
1490
+ correctiveDecision: planIntegrityDecision,
1491
+ posture: 'replan-required',
1492
+ };
1493
+ }
1494
+ // A recorded corrective decision remains authoritative over an ordinary
1495
+ // rolling continuation. A repeated authoritative cause is fresh evidence
1496
+ // that the current Plan itself is no longer viable and still forces replan.
1497
+ if (rollingCauseDecision !== null
1498
+ && !(rollingCauseDecision === 'same-task-fix' && currentCorrectiveDecision)) {
1499
+ if (rollingCauseDecision === 'root-cause-replan' && options.enforceRuntimeGate) {
1500
+ throw new WorkflowError(
1501
+ 'TRANSITION_BLOCKED',
1502
+ `Guarded Step ${stepId} has an explicit Plan conflict or repeated authoritative cause and requires replan.`,
1503
+ { taskId: task.id, stepId, attemptCount, requiredAction: 'task plan-set' },
1504
+ );
1505
+ }
1506
+ return {
1507
+ stepId,
1508
+ guardedCategory,
1509
+ attemptCount,
1510
+ nextAttemptOrdinal,
1511
+ mode: 'ordinary',
1512
+ correctiveDecision: null,
1513
+ posture: rollingCauseDecision === 'root-cause-replan' ? 'replan-required' : 'ordinary',
1514
+ };
1515
+ }
1516
+ if (attemptCount < 2) {
1517
+ return {
1518
+ stepId,
1519
+ guardedCategory,
1520
+ attemptCount,
1521
+ nextAttemptOrdinal,
1522
+ mode: 'ordinary',
1523
+ correctiveDecision: null,
1524
+ posture: 'ordinary',
1525
+ };
1526
+ }
1527
+ const correctiveDecision = currentCorrectiveDecision;
1528
+ if (!correctiveDecision) {
1529
+ return {
1530
+ stepId,
1531
+ guardedCategory,
1532
+ attemptCount,
1533
+ nextAttemptOrdinal,
1534
+ mode: 'corrective',
1535
+ correctiveDecision: null,
1536
+ posture: 'continue-fix',
1537
+ };
1538
+ }
1539
+ if (correctiveDecision.planHash !== task.planHash) {
1540
+ if (
1541
+ correctiveDecision.decision === 'continue-fix'
1542
+ && options.recoveredDecisionEventId === correctiveDecision.eventId
1543
+ ) {
1544
+ return {
1545
+ stepId,
1546
+ guardedCategory,
1547
+ attemptCount,
1548
+ nextAttemptOrdinal,
1549
+ mode: 'corrective',
1550
+ correctiveDecision,
1551
+ posture: 'continue-fix',
1552
+ };
1553
+ }
1554
+ if (
1555
+ correctiveDecision.decision === 'stop-escalate'
1556
+ && options.reboundStopOverrideDecisionEventId === correctiveDecision.eventId
1557
+ && options.overriddenStopDecisionEventIds?.has(correctiveDecision.eventId)
1558
+ ) {
1559
+ return {
1560
+ stepId,
1561
+ guardedCategory,
1562
+ attemptCount,
1563
+ nextAttemptOrdinal,
1564
+ mode: 'corrective',
1565
+ correctiveDecision,
1566
+ posture: 'continue-fix',
1567
+ };
1568
+ }
1569
+ if (correctiveDecision.decision === 'replan-required') {
1570
+ return {
1571
+ stepId,
1572
+ guardedCategory,
1573
+ attemptCount,
1574
+ nextAttemptOrdinal,
1575
+ mode: 'corrective',
1576
+ correctiveDecision,
1577
+ posture: 'replan-ready',
1578
+ };
1579
+ }
1580
+ if (options.enforceRuntimeGate) {
1581
+ throw new WorkflowError(
1582
+ 'TRANSITION_BLOCKED',
1583
+ `Guarded Step ${stepId} has a corrective decision for a different Plan hash and cannot continue under the current Plan.`,
1584
+ {
1585
+ taskId: task.id,
1586
+ stepId,
1587
+ triggeringAttemptCount: nextAttemptOrdinal,
1588
+ currentPlanHash: task.planHash,
1589
+ decisionPlanHash: correctiveDecision.planHash,
1590
+ decisionEventId: correctiveDecision.eventId,
1591
+ },
1592
+ );
1593
+ }
1594
+ return {
1595
+ stepId,
1596
+ guardedCategory,
1597
+ attemptCount,
1598
+ nextAttemptOrdinal,
1599
+ mode: 'ordinary',
1600
+ correctiveDecision,
1601
+ posture: 'decision-required',
1602
+ };
1603
+ }
1604
+ if (
1605
+ correctiveDecision.decision === 'stop-escalate'
1606
+ && options.overriddenStopDecisionEventIds?.has(correctiveDecision.eventId)
1607
+ ) {
1608
+ return {
1609
+ stepId,
1610
+ guardedCategory,
1611
+ attemptCount,
1612
+ nextAttemptOrdinal,
1613
+ mode: 'corrective',
1614
+ correctiveDecision,
1615
+ posture: 'continue-fix',
1616
+ };
1617
+ }
1618
+ if (correctiveDecision.decision !== 'continue-fix' && options.enforceRuntimeGate) {
1619
+ throw correctiveDecisionBlockedError(task, stepId, attemptCount, correctiveDecision);
1620
+ }
1621
+ return {
1622
+ stepId,
1623
+ guardedCategory,
1624
+ attemptCount,
1625
+ nextAttemptOrdinal,
1626
+ mode: correctiveDecision.decision === 'continue-fix' ? 'corrective' : 'ordinary',
1627
+ correctiveDecision,
1628
+ posture: correctiveDecision.decision === 'continue-fix' ? 'continue-fix' : correctiveDecision.decision,
1629
+ };
1630
+ }
1631
+
1632
+ // Current execution must respect a proved Plan obstruction immediately. Keep the
1633
+ // historical rolling classifier unchanged: older valid histories may have been
1634
+ // recorded before this gate existed and must not be reclassified as corrupt.
1635
+ export function deriveCurrentCauseDecision(
1636
+ remediationEvents: readonly RemediationEvent[],
1637
+ priorRemediationEvents: readonly RemediationEvent[] = [],
1638
+ ): 'same-task-fix' | 'root-cause-replan' | null {
1639
+ // A verified replacement clears the active cause, not the global attempt
1640
+ // count or an explicit corrective decision's dirty-work continuation authority.
1641
+ if (remediationEvents.length === 0 && priorRemediationEvents.length > 0
1642
+ && priorRemediationEvents.every((event) => event.causeBindings !== undefined)) return 'same-task-fix';
1643
+ if (remediationEvents.at(-1)?.causeBindings?.some(cause =>
1644
+ cause.authority === 'external-reviewer' && cause.planConflict)) return 'root-cause-replan';
1645
+ return deriveRollingCauseDecision(remediationEvents, priorRemediationEvents);
1646
+ }
1647
+
1648
+ export function deriveRollingCauseDecision(
1649
+ remediationEvents: readonly RemediationEvent[],
1650
+ priorRemediationEvents: readonly RemediationEvent[] = [],
1651
+ ): 'same-task-fix' | 'root-cause-replan' | null {
1652
+ if (remediationEvents.length === 0 || remediationEvents.some((event) => event.causeBindings === undefined)) return null;
1653
+ const latest = remediationEvents.at(-1)!;
1654
+ const priorFingerprints = new Set([...priorRemediationEvents, ...remediationEvents.slice(0, -1)]
1655
+ .flatMap((event) => event.causeBindings?.map((cause) => cause.causeFingerprint) ?? []));
1656
+ const authoritativeRepeat = latest.causeBindings!.some((cause) =>
1657
+ priorFingerprints.has(cause.causeFingerprint)
1658
+ && (cause.authority === 'mechanical' || (cause.authority === 'external-reviewer' && cause.planConflict)));
1659
+ return authoritativeRepeat ? 'root-cause-replan' : 'same-task-fix';
1660
+ }
1661
+
1662
+ function findCurrentCorrectiveDecision(
1663
+ task: TaskState,
1664
+ stepId: string,
1665
+ triggeringAttemptOrdinal: number,
1666
+ events: readonly CorrectiveDecisionEvent[],
1667
+ ): CorrectiveDecisionEvent | null {
1668
+ const matches = events.filter((event) =>
1669
+ event.taskId === task.id
1670
+ && event.stepId === stepId
1671
+ && event.triggeringAttemptCount === triggeringAttemptOrdinal,
1672
+ );
1673
+ if (matches.length === 0) return null;
1674
+ if (matches.length > 1) {
1675
+ throw new WorkflowError(
1676
+ 'STATE_CORRUPT',
1677
+ 'Multiple corrective decisions exist for the same guarded remediation binding.',
1678
+ {
1679
+ taskId: task.id,
1680
+ stepId,
1681
+ triggeringAttemptCount: triggeringAttemptOrdinal,
1682
+ eventIds: matches.map((event) => event.eventId),
1683
+ },
1684
+ );
1685
+ }
1686
+ return matches[0]!;
1687
+ }
1688
+
1689
+ function correctiveDecisionBlockedError(
1690
+ task: TaskState,
1691
+ stepId: string,
1692
+ attemptCount: number,
1693
+ event: CorrectiveDecisionEvent,
1694
+ ): WorkflowError {
1695
+ if (event.decision === 'replan-required') {
1696
+ return new WorkflowError(
1697
+ 'TRANSITION_BLOCKED',
1698
+ `Guarded Step ${stepId} cannot continue because the corrective decision requires a replan.`,
1699
+ {
1700
+ taskId: task.id,
1701
+ stepId,
1702
+ attemptCount,
1703
+ decision: event.decision,
1704
+ decisionEventId: event.eventId,
1705
+ auditor: event.auditor,
1706
+ },
1707
+ );
1708
+ }
1709
+ if (event.decision === 'split-required') {
1710
+ return new WorkflowError(
1711
+ 'TRANSITION_BLOCKED',
1712
+ `Guarded Step ${stepId} cannot continue because the corrective decision requires splitting work into a separate Task.`,
1713
+ {
1714
+ taskId: task.id,
1715
+ stepId,
1716
+ attemptCount,
1717
+ decision: event.decision,
1718
+ decisionEventId: event.eventId,
1719
+ auditor: event.auditor,
1720
+ },
1721
+ );
1722
+ }
1723
+ return new WorkflowError(
1724
+ 'TRANSITION_BLOCKED',
1725
+ `Guarded Step ${stepId} cannot continue because the corrective decision requires coordinator escalation.`,
1726
+ {
1727
+ taskId: task.id,
1728
+ stepId,
1729
+ attemptCount,
1730
+ decision: event.decision,
1731
+ decisionEventId: event.eventId,
1732
+ auditor: event.auditor,
1733
+ },
1734
+ );
1735
+ }
1736
+
1737
+ function requireGuardedCategory(
1738
+ planRiskAudit: CurrentPlanRiskAudit,
1739
+ stepId: string,
1740
+ ): GuardedRiskCategory {
1741
+ const classification = planRiskAudit.audit.stepClassifications.find((candidate) => candidate.stepId === stepId) ?? null;
1742
+ if (!classification || classification.categories.length === 0 || !classification.reviewRequired) {
1743
+ throw new WorkflowError(
1744
+ 'STATE_CORRUPT',
1745
+ `Current Plan Risk Audit does not define a guarded category for ${stepId}.`,
1746
+ {
1747
+ taskId: planRiskAudit.audit.taskId,
1748
+ stepId,
1749
+ },
1750
+ );
1751
+ }
1752
+ return classification.categories[0]!;
1753
+ }
1754
+
1755
+ function validateStoredRemediationEvent(
1756
+ event: RemediationEvent,
1757
+ task: TaskState,
1758
+ file: string,
1759
+ line: number,
1760
+ ): void {
1761
+ if (event.taskId !== task.id) {
1762
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event taskId does not match the current Task.', {
1763
+ file,
1764
+ line,
1765
+ taskId: task.id,
1766
+ eventTaskId: event.taskId,
1767
+ });
1768
+ }
1769
+ if (typeof event.stepId !== 'string' || !event.stepId.trim()) {
1770
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event is missing stepId.', { file, line, taskId: task.id });
1771
+ }
1772
+ if (!REMEDIATION_FAILURE_KINDS.has(event.failureKind)) {
1773
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event has an unknown failureKind.', {
1774
+ file,
1775
+ line,
1776
+ taskId: task.id,
1777
+ stepId: event.stepId,
1778
+ failureKind: event.failureKind,
1779
+ });
1780
+ }
1781
+ if (!REMEDIATION_MODES.has(event.mode)) {
1782
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event has an unknown mode.', {
1783
+ file,
1784
+ line,
1785
+ taskId: task.id,
1786
+ stepId: event.stepId,
1787
+ mode: event.mode,
1788
+ });
1789
+ }
1790
+ assertCommitSha(event.failureEvidence.completionCommit, 'Remediation failure evidence completionCommit', file, line);
1791
+ assertHash64(event.failureEvidence.checksHash, `Remediation failure evidence checksHash at ${file}:${line}`);
1792
+ if (event.failureEvidence.reviewEventHash !== null) {
1793
+ assertHash64(event.failureEvidence.reviewEventHash, `Remediation failure evidence reviewEventHash at ${file}:${line}`);
1794
+ }
1795
+ if (event.failureEvidence.reviewerAttestationHash !== null) {
1796
+ assertHash64(
1797
+ event.failureEvidence.reviewerAttestationHash,
1798
+ `Remediation failure evidence reviewerAttestationHash at ${file}:${line}`,
1799
+ );
1800
+ }
1801
+ const hasExecutionRevision = event.failureEvidence.executionTaskRevision !== undefined;
1802
+ const hasDirtyWorktreeHash = event.failureEvidence.dirtyWorktreeHash !== undefined;
1803
+ if (hasExecutionRevision !== hasDirtyWorktreeHash) {
1804
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation execution revision and dirty-worktree hash must be recorded together.', {
1805
+ file,
1806
+ line,
1807
+ taskId: task.id,
1808
+ stepId: event.stepId,
1809
+ eventId: event.eventId,
1810
+ });
1811
+ }
1812
+ if (hasExecutionRevision
1813
+ && (!Number.isInteger(event.failureEvidence.executionTaskRevision)
1814
+ || event.failureEvidence.executionTaskRevision! <= 0)) {
1815
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation execution task revision must be a positive integer.', {
1816
+ file,
1817
+ line,
1818
+ taskId: task.id,
1819
+ stepId: event.stepId,
1820
+ eventId: event.eventId,
1821
+ executionTaskRevision: event.failureEvidence.executionTaskRevision,
1822
+ });
1823
+ }
1824
+ if (hasDirtyWorktreeHash) {
1825
+ assertHash64(
1826
+ event.failureEvidence.dirtyWorktreeHash!,
1827
+ `Remediation dirty-worktree hash at ${file}:${line}`,
1828
+ );
1829
+ }
1830
+ if (!Number.isInteger(event.attemptOrdinal) || event.attemptOrdinal <= 0) {
1831
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event attemptOrdinal must be a positive integer.', {
1832
+ file,
1833
+ line,
1834
+ taskId: task.id,
1835
+ stepId: event.stepId,
1836
+ attemptOrdinal: event.attemptOrdinal,
1837
+ });
1838
+ }
1839
+ if (event.causeBindings !== undefined) {
1840
+ const fingerprints = new Set<string>();
1841
+ for (const cause of event.causeBindings) {
1842
+ if (!['mechanical', 'external-reviewer'].includes(cause.authority)
1843
+ || !cause.causeId?.trim()
1844
+ || !/^[a-f0-9]{64}$/.test(cause.causeFingerprint)
1845
+ || typeof cause.planConflict !== 'boolean'
1846
+ || (cause.authority === 'mechanical'
1847
+ && (cause.planConflict || cause.causeId !== `mechanical:${cause.causeFingerprint}`))
1848
+ || (cause.authority === 'external-reviewer'
1849
+ && reviewerCauseFingerprint(cause.causeId) !== cause.causeFingerprint)) {
1850
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation cause binding is invalid.', {
1851
+ file, line, eventId: event.eventId,
1852
+ });
1853
+ }
1854
+ if (fingerprints.has(cause.causeFingerprint)) {
1855
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation cause fingerprints must be unique per event.', {
1856
+ file, line, eventId: event.eventId,
1857
+ });
1858
+ }
1859
+ fingerprints.add(cause.causeFingerprint);
1860
+ }
1861
+ }
1862
+ if (event.failureKind === 'checks-failed') {
1863
+ if (event.failureEvidence.reviewEventHash !== null
1864
+ || event.failureEvidence.reviewerAttestationHash !== null
1865
+ || event.causeBindings?.some((cause) => cause.authority !== 'mechanical')) {
1866
+ throw new WorkflowError('STATE_CORRUPT', 'checks-failed remediation events must not reference strict review sidecars.', {
1867
+ file,
1868
+ line,
1869
+ taskId: task.id,
1870
+ stepId: event.stepId,
1871
+ eventId: event.eventId,
1872
+ });
1873
+ }
1874
+ if (event.causeBindings !== undefined && (!hasExecutionRevision || !hasDirtyWorktreeHash)) {
1875
+ throw new WorkflowError('STATE_CORRUPT', 'Native checks-failed remediation events must bind the declared execution and dirty product bytes.', {
1876
+ file,
1877
+ line,
1878
+ taskId: task.id,
1879
+ stepId: event.stepId,
1880
+ eventId: event.eventId,
1881
+ });
1882
+ }
1883
+ } else if (event.failureEvidence.reviewEventHash === null
1884
+ || event.failureEvidence.reviewerAttestationHash === null
1885
+ || event.causeBindings?.some((cause) => cause.authority !== 'external-reviewer')
1886
+ || hasExecutionRevision
1887
+ || hasDirtyWorktreeHash) {
1888
+ throw new WorkflowError('STATE_CORRUPT', 'review-failed and review-unverified remediation events must reference both strict review sidecars.', {
1889
+ file,
1890
+ line,
1891
+ taskId: task.id,
1892
+ stepId: event.stepId,
1893
+ eventId: event.eventId,
1894
+ });
1895
+ }
1896
+ }
1897
+
1898
+ function validateStoredCorrectiveDecisionEvent(
1899
+ event: CorrectiveDecisionEvent,
1900
+ task: TaskState,
1901
+ file: string,
1902
+ line: number,
1903
+ ): void {
1904
+ if (event.taskId !== task.id) {
1905
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision taskId does not match the current Task.', {
1906
+ file,
1907
+ line,
1908
+ taskId: task.id,
1909
+ eventTaskId: event.taskId,
1910
+ });
1911
+ }
1912
+ if (typeof event.stepId !== 'string' || !event.stepId.trim()) {
1913
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is missing stepId.', { file, line, taskId: task.id });
1914
+ }
1915
+ if (!Number.isInteger(event.triggeringAttemptCount) || event.triggeringAttemptCount < 2) {
1916
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision triggeringAttemptCount must be an integer >= 2.', {
1917
+ file,
1918
+ line,
1919
+ taskId: task.id,
1920
+ stepId: event.stepId,
1921
+ triggeringAttemptCount: event.triggeringAttemptCount,
1922
+ });
1923
+ }
1924
+ if (!CORRECTIVE_DECISION_VALUES.has(event.decision)) {
1925
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision has an unknown decision value.', {
1926
+ file,
1927
+ line,
1928
+ taskId: task.id,
1929
+ stepId: event.stepId,
1930
+ decision: event.decision,
1931
+ });
1932
+ }
1933
+ if (typeof event.auditor !== 'string' || !event.auditor.trim()) {
1934
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is missing auditor.', {
1935
+ file,
1936
+ line,
1937
+ taskId: task.id,
1938
+ stepId: event.stepId,
1939
+ });
1940
+ }
1941
+ normalizeStringList(event.coveredFindingIds, 'Corrective decision coveredFindingIds');
1942
+ normalizeStringList(event.coveredEventIds, 'Corrective decision coveredEventIds');
1943
+ assertHash64(event.planHash, `Corrective decision planHash at ${file}:${line}`);
1944
+ if (event.recoveryBasis === 'plan-integrity' || event.planIntegrityEvidence !== undefined) {
1945
+ validatePlanIntegrityDecision(event, task, file, line);
1946
+ } else if (event.triggeringAttemptCount === 2) {
1947
+ throw new WorkflowError('STATE_CORRUPT', 'A second-attempt corrective decision requires bounded Plan-integrity evidence.', {
1948
+ file, line, taskId: task.id, stepId: event.stepId,
1949
+ });
1950
+ }
1951
+ }
1952
+
1953
+ function validateStoredStopEscalateOverride(
1954
+ event: StopEscalateOverrideEvent,
1955
+ task: TaskState,
1956
+ file: string,
1957
+ line: number,
1958
+ ): void {
1959
+ if (!/^SOO-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)) {
1960
+ throw new WorkflowError('STATE_CORRUPT', 'Stop override eventId is malformed.', { file, line });
1961
+ }
1962
+ if (
1963
+ event.taskId !== task.id
1964
+ || !Number.isInteger(event.taskRevision)
1965
+ || event.taskRevision < 1
1966
+ || event.taskRevision > task.revision
1967
+ || typeof event.stepId !== 'string'
1968
+ || !event.stepId.trim()
1969
+ || event.triggeringAttemptCount !== 4
1970
+ || event.policy !== LEGACY_STOP_OVERRIDE_POLICY
1971
+ ) {
1972
+ throw new WorkflowError('STATE_CORRUPT', 'Stop override does not match its Task or compatibility policy.', {
1973
+ file,
1974
+ line,
1975
+ taskId: task.id,
1976
+ eventTaskId: event.taskId,
1977
+ });
1978
+ }
1979
+ if (
1980
+ !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.stopDecisionEventId)
1981
+ || !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.priorContinueDecisionEventId)
1982
+ || !COMMIT_SHA_PATTERN.test(event.headCommit)
1983
+ || typeof event.actor !== 'string'
1984
+ || !event.actor.trim()
1985
+ || typeof event.reason !== 'string'
1986
+ || !event.reason.trim()
1987
+ || typeof event.packageVersion !== 'string'
1988
+ || !event.packageVersion.trim()
1989
+ ) {
1990
+ throw new WorkflowError('STATE_CORRUPT', 'Stop override identifiers or human evidence are malformed.', {
1991
+ file,
1992
+ line,
1993
+ taskId: task.id,
1994
+ });
1995
+ }
1996
+ const coveredEventIds = normalizeStringList(event.coveredEventIds, 'Stop override coveredEventIds');
1997
+ if (coveredEventIds.length !== 3 || new Set(coveredEventIds).size !== coveredEventIds.length) {
1998
+ throw new WorkflowError('STATE_CORRUPT', 'Stop override must bind exactly three distinct remediation events.', {
1999
+ file,
2000
+ line,
2001
+ taskId: task.id,
2002
+ stepId: event.stepId,
2003
+ });
2004
+ }
2005
+ for (const [label, value] of [
2006
+ ['stopDecisionEventHash', event.stopDecisionEventHash],
2007
+ ['priorContinueDecisionEventHash', event.priorContinueDecisionEventHash],
2008
+ ['planHash', event.planHash],
2009
+ ['confirmationCodeBindingHash', event.confirmationCodeBindingHash],
2010
+ ] as const) {
2011
+ assertHash64(value, `Stop override ${label} at ${file}:${line}`);
2012
+ }
2013
+ const expectedBindingHash = stopEscalateOverrideBindingHash(event);
2014
+ if (event.confirmationCodeBindingHash !== expectedBindingHash) {
2015
+ throw new WorkflowError('STATE_CORRUPT', 'Stop override confirmation binding hash is invalid.', {
2016
+ file,
2017
+ line,
2018
+ taskId: task.id,
2019
+ expectedBindingHash,
2020
+ actualBindingHash: event.confirmationCodeBindingHash,
2021
+ });
2022
+ }
2023
+ }
2024
+
2025
+ function validateStopEscalateOverrideHistory(
2026
+ task: TaskState,
2027
+ remediations: readonly RemediationEvent[],
2028
+ decisions: readonly CorrectiveDecisionEvent[],
2029
+ overrides: readonly StopEscalateOverrideEvent[],
2030
+ ): Set<string> {
2031
+ const overriddenDecisionIds = new Set<string>();
2032
+ for (const override of overrides) {
2033
+ if (overriddenDecisionIds.has(override.stopDecisionEventId)) {
2034
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple stop overrides target one corrective decision.', {
2035
+ taskId: task.id,
2036
+ stepId: override.stepId,
2037
+ stopDecisionEventId: override.stopDecisionEventId,
2038
+ });
2039
+ }
2040
+ const stepRemediations = remediations
2041
+ .filter((event) => event.stepId === override.stepId && event.attemptOrdinal <= 3)
2042
+ .sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
2043
+ const stopDecision = decisions.find((event) => event.eventId === override.stopDecisionEventId) ?? null;
2044
+ const priorContinue = decisions.find((event) => event.eventId === override.priorContinueDecisionEventId) ?? null;
2045
+ if (
2046
+ stepRemediations.length !== 3
2047
+ || stepRemediations[0]?.attemptOrdinal !== 1
2048
+ || stepRemediations[0]?.mode !== 'ordinary'
2049
+ || stepRemediations[1]?.attemptOrdinal !== 2
2050
+ || stepRemediations[1]?.mode !== 'ordinary'
2051
+ || stepRemediations[2]?.attemptOrdinal !== 3
2052
+ || stepRemediations[2]?.mode !== 'corrective'
2053
+ || !stopDecision
2054
+ || stopDecision.eventHash !== override.stopDecisionEventHash
2055
+ || stopDecision.decision !== 'stop-escalate'
2056
+ || stopDecision.triggeringAttemptCount !== 4
2057
+ || stopDecision.planHash !== override.planHash
2058
+ || !priorContinue
2059
+ || priorContinue.eventHash !== override.priorContinueDecisionEventHash
2060
+ || priorContinue.decision !== 'continue-fix'
2061
+ || priorContinue.triggeringAttemptCount !== 3
2062
+ || priorContinue.planHash !== override.planHash
2063
+ || !sameStringArray(override.coveredEventIds, stepRemediations.map((event) => event.eventId))
2064
+ || !sameStringArray(stopDecision.coveredEventIds, override.coveredEventIds)
2065
+ || !sameStringArray(priorContinue.coveredEventIds, override.coveredEventIds.slice(0, 2))
2066
+ ) {
2067
+ throw new WorkflowError('STATE_CORRUPT', 'Stop override does not bind the exact beta.11 attempt-4 stop history.', {
2068
+ taskId: task.id,
2069
+ stepId: override.stepId,
2070
+ overrideEventId: override.eventId,
2071
+ });
2072
+ }
2073
+ overriddenDecisionIds.add(override.stopDecisionEventId);
2074
+ }
2075
+ return overriddenDecisionIds;
2076
+ }
2077
+
2078
+ function stopEscalateOverrideBindingHash(input: {
2079
+ taskId: string;
2080
+ taskRevision: number;
2081
+ stepId: string;
2082
+ stopDecisionEventId: string;
2083
+ stopDecisionEventHash: string;
2084
+ priorContinueDecisionEventId: string;
2085
+ priorContinueDecisionEventHash: string;
2086
+ triggeringAttemptCount: 4;
2087
+ coveredEventIds: readonly string[];
2088
+ planHash: string;
2089
+ headCommit: string;
2090
+ actor: string;
2091
+ reason: string;
2092
+ packageVersion: string;
2093
+ policy: typeof LEGACY_STOP_OVERRIDE_POLICY;
2094
+ }): string {
2095
+ return hashCanonical({
2096
+ domain: 'codex-workflow/stop-escalate-override/v1',
2097
+ taskId: input.taskId,
2098
+ taskRevision: input.taskRevision,
2099
+ stepId: input.stepId,
2100
+ stopDecisionEventId: input.stopDecisionEventId,
2101
+ stopDecisionEventHash: input.stopDecisionEventHash,
2102
+ priorContinueDecisionEventId: input.priorContinueDecisionEventId,
2103
+ priorContinueDecisionEventHash: input.priorContinueDecisionEventHash,
2104
+ triggeringAttemptCount: input.triggeringAttemptCount,
2105
+ coveredEventIds: [...input.coveredEventIds],
2106
+ planHash: input.planHash,
2107
+ headCommit: input.headCommit,
2108
+ actor: input.actor,
2109
+ reason: input.reason,
2110
+ packageVersion: input.packageVersion,
2111
+ policy: input.policy,
2112
+ });
2113
+ }
2114
+
2115
+ function validatePlanIntegrityDecision(
2116
+ event: CorrectiveDecisionEvent,
2117
+ task: TaskState,
2118
+ file: string,
2119
+ line: number,
2120
+ ): void {
2121
+ const evidence = event.planIntegrityEvidence;
2122
+ if (
2123
+ event.recoveryBasis !== 'plan-integrity'
2124
+ || !evidence
2125
+ || (evidence.kind !== 'failed-check-component-owner-outside-allowed-writes'
2126
+ && evidence.manifestPath !== 'package.json')
2127
+ || !Number.isInteger(evidence.taskRevision)
2128
+ || evidence.taskRevision < 1
2129
+ || evidence.taskRevision > task.revision
2130
+ || !/^[a-f0-9]{40,64}$/.test(evidence.gitHead)
2131
+ ) {
2132
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is not a valid bounded Plan-integrity recovery.', {
2133
+ file,
2134
+ line,
2135
+ taskId: task.id,
2136
+ stepId: event.stepId,
2137
+ });
2138
+ }
2139
+ if (evidence.kind !== 'failed-check-component-owner-outside-allowed-writes'
2140
+ && evidence.manifestHash !== null) {
2141
+ assertHash64(evidence.manifestHash, `Plan-integrity manifestHash at ${file}:${line}`);
2142
+ }
2143
+ assertHash64(evidence.stepDefinitionHash, `Plan-integrity stepDefinitionHash at ${file}:${line}`);
2144
+ assertHash64(evidence.conflictHash, `Plan-integrity conflictHash at ${file}:${line}`);
2145
+ const changedFiles = normalizeStringList(evidence.changedFiles, 'Plan-integrity changedFiles');
2146
+ let domain: string;
2147
+ if (evidence.kind === 'missing-npm-script-outside-allowed-writes') {
2148
+ if (event.decision !== 'replan-required'
2149
+ || event.triggeringAttemptCount !== 2
2150
+ || typeof evidence.remediationEventId !== 'string'
2151
+ || !evidence.remediationEventId) {
2152
+ throw new WorkflowError('STATE_CORRUPT', 'Legacy Plan-integrity evidence is valid only for the exact second-attempt boundary.', {
2153
+ file, line, taskId: task.id, stepId: event.stepId,
2154
+ });
2155
+ }
2156
+ assertHash64(evidence.remediationEventHash, `Plan-integrity remediationEventHash at ${file}:${line}`);
2157
+ const missingScripts = normalizeStringList(evidence.missingScripts, 'Plan-integrity missingScripts');
2158
+ const checkCommands = normalizeStringList(evidence.checkCommands, 'Plan-integrity checkCommands');
2159
+ if (missingScripts.length === 0 || checkCommands.length === 0) {
2160
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence requires at least one missing script and check command.', {
2161
+ file, line, taskId: task.id, stepId: event.stepId,
2162
+ });
2163
+ }
2164
+ domain = 'codex-workflow/plan-integrity-conflict/v1';
2165
+ } else if (evidence.kind === 'non-executable-npm-check-outside-allowed-writes') {
2166
+ if (event.decision !== 'replan-required') {
2167
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v2 evidence requires replan-required.', {
2168
+ file, line, taskId: task.id, stepId: event.stepId,
2169
+ });
2170
+ }
2171
+ if (evidence.remediationEvents.length === 0
2172
+ || event.triggeringAttemptCount !== evidence.remediationEvents.length + 1
2173
+ || evidence.remediationEvents.some((binding, index) => {
2174
+ if (!binding.eventId || binding.attemptOrdinal !== index + 1) return true;
2175
+ assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
2176
+ return false;
2177
+ })
2178
+ || evidence.conflicts.length === 0
2179
+ || evidence.conflicts.some((conflict) => {
2180
+ if (!['MISSING_NPM_SCRIPT', 'REQUIRED_SELECTOR_MISSING'].includes(conflict.reasonCode)
2181
+ || !conflict.script.trim()
2182
+ || !conflict.command.trim()) return true;
2183
+ if (conflict.runnerHash !== null) {
2184
+ assertHash64(conflict.runnerHash, `Plan-integrity runnerHash at ${file}:${line}`);
2185
+ }
2186
+ return conflict.reasonCode === 'MISSING_NPM_SCRIPT'
2187
+ ? conflict.scriptCommand !== null || conflict.runnerPath !== null || conflict.runnerHash !== null
2188
+ : conflict.scriptCommand === null || conflict.runnerPath === null || conflict.runnerHash === null;
2189
+ })) {
2190
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v2 evidence has invalid remediation or command-conflict bindings.', {
2191
+ file, line, taskId: task.id, stepId: event.stepId,
2192
+ });
2193
+ }
2194
+ domain = 'codex-workflow/plan-integrity-conflict/v2';
2195
+ } else if (evidence.kind === 'check-support-anchor-outside-allowed-writes') {
2196
+ const step = task.steps.find((candidate) => candidate.id === event.stepId) ?? null;
2197
+ const supportPaths = [...new Set(evidence.conflicts.map((conflict) => conflict.supportPath))].sort();
2198
+ const validateCurrentPlanBinding = event.planHash === task.planHash;
2199
+ if (event.decision !== 'continue-fix'
2200
+ || evidence.remediationEvents.length === 0
2201
+ || event.triggeringAttemptCount !== evidence.remediationEvents.at(-1)!.attemptOrdinal + 1
2202
+ || evidence.remediationEvents.some((binding, index) => {
2203
+ if (!binding.eventId || binding.attemptOrdinal !== evidence.remediationEvents[0]!.attemptOrdinal + index) return true;
2204
+ assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
2205
+ return false;
2206
+ })
2207
+ || evidence.conflicts.length === 0
2208
+ || !sameStringArray(evidence.scopeExtensions, supportPaths)
2209
+ || (validateCurrentPlanBinding && (!step
2210
+ || evidence.scopeExtensions.some((scopePath) => pathAllowed(scopePath, step.allowedWrites)
2211
+ || pathAllowed(scopePath, step.forbiddenScope))
2212
+ || changedFiles.some((changedPath) => !pathAllowed(changedPath, step.allowedWrites))))
2213
+ || evidence.conflicts.some((conflict) => {
2214
+ assertHash64(conflict.runnerHash, `Plan-integrity runnerHash at ${file}:${line}`);
2215
+ assertHash64(conflict.supportHash, `Plan-integrity supportHash at ${file}:${line}`);
2216
+ return conflict.reasonCode !== 'CHECK_SUPPORT_ANCHOR_OUTSIDE_ALLOWED_WRITES'
2217
+ || !conflict.script.trim()
2218
+ || !conflict.command.trim()
2219
+ || !conflict.scriptCommand.trim()
2220
+ || !conflict.runnerPath.trim()
2221
+ || !conflict.supportPath.trim()
2222
+ || !conflict.importSpecifier.startsWith('.')
2223
+ || normalizeStringList(conflict.expectedOutputPaths, 'Plan-integrity expectedOutputPaths').length === 0
2224
+ || normalizeStringList(conflict.anchoredMigrationNames, 'Plan-integrity anchoredMigrationNames').length === 0
2225
+ || !evidence.scopeExtensions.includes(conflict.supportPath)
2226
+ || (validateCurrentPlanBinding && (!step || conflict.expectedOutputPaths.some((output) =>
2227
+ !step.expectedOutputs.some((declared) =>
2228
+ (declared.startsWith('path:') ? declared.slice(5) : declared) === output)
2229
+ || !pathAllowed(output, step.allowedWrites))));
2230
+ })) {
2231
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v3 evidence has invalid check-support bindings.', {
2232
+ file, line, taskId: task.id, stepId: event.stepId,
2233
+ });
2234
+ }
2235
+ domain = 'codex-workflow/plan-integrity-conflict/v3';
2236
+ } else if (evidence.kind === 'failed-check-component-owner-outside-allowed-writes') {
2237
+ const step = task.steps.find((candidate) => candidate.id === event.stepId) ?? null;
2238
+ const ownerPaths = [...new Set(evidence.conflicts.map((conflict) => conflict.ownerPath))].sort();
2239
+ const validateCurrentPlanBinding = event.planHash === task.planHash;
2240
+ assertHash64(evidence.semanticBoundaryHash, `Plan-integrity semanticBoundaryHash at ${file}:${line}`);
2241
+ assertHash64(evidence.failedCheckCauseFingerprint, `Plan-integrity failedCheckCauseFingerprint at ${file}:${line}`);
2242
+ if (event.decision !== 'continue-fix'
2243
+ || evidence.remediationEvents.length === 0
2244
+ || event.triggeringAttemptCount !== evidence.remediationEvents.at(-1)!.attemptOrdinal + 1
2245
+ || evidence.remediationEvents.some((binding, index) => {
2246
+ if (!binding.eventId || binding.attemptOrdinal !== evidence.remediationEvents[0]!.attemptOrdinal + index) return true;
2247
+ assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
2248
+ return false;
2249
+ })
2250
+ || evidence.conflicts.length === 0
2251
+ || !sameStringArray(evidence.scopeExtensions, ownerPaths)
2252
+ || (validateCurrentPlanBinding && (!step
2253
+ || evidence.semanticBoundaryHash !== semanticBoundaryHash(task)
2254
+ || evidence.scopeExtensions.some((scopePath) => pathAllowed(scopePath, step.allowedWrites)
2255
+ || pathAllowed(scopePath, step.forbiddenScope))
2256
+ || changedFiles.some((changedPath) => !pathAllowed(changedPath, step.allowedWrites))))
2257
+ || evidence.conflicts.some((conflict) => {
2258
+ assertHash64(conflict.ownerHash, `Plan-integrity component owner hash at ${file}:${line}`);
2259
+ const importChain = normalizeStringList(conflict.importChain, 'Plan-integrity importChain');
2260
+ return conflict.reasonCode !== 'FAILED_CHECK_COMPONENT_OWNER_OUTSIDE_ALLOWED_WRITES'
2261
+ || !conflict.command.trim()
2262
+ || !conflict.runnerPath.trim()
2263
+ || !conflict.ownerPath.trim()
2264
+ || conflict.diagnosticPath !== conflict.ownerPath
2265
+ || !conflict.componentRoot.trim()
2266
+ || !(conflict.ownerPath === conflict.componentRoot
2267
+ || conflict.ownerPath.startsWith(`${conflict.componentRoot}/`))
2268
+ || (conflict.testInvocation
2269
+ ? !validLiteralTestInvocationEvidence(conflict) || importChain.length !== 1
2270
+ : importChain.length < 2 || importChain.at(-1) !== conflict.ownerPath)
2271
+ || importChain[0] !== conflict.runnerPath
2272
+ || !evidence.scopeExtensions.includes(conflict.ownerPath);
2273
+ })) {
2274
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v4 evidence has invalid component-owner bindings.', {
2275
+ file, line, taskId: task.id, stepId: event.stepId,
2276
+ });
2277
+ }
2278
+ domain = 'codex-workflow/plan-integrity-conflict/v4';
2279
+ } else {
2280
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence kind is unsupported.', {
2281
+ file, line, taskId: task.id, stepId: event.stepId,
2282
+ });
2283
+ }
2284
+ const { conflictHash: _conflictHash, ...evidenceBase } = evidence;
2285
+ const expectedConflictHash = hashCanonical({
2286
+ domain,
2287
+ taskId: task.id,
2288
+ planHash: event.planHash,
2289
+ stepId: event.stepId,
2290
+ evidence: evidenceBase,
2291
+ });
2292
+ if (evidence.conflictHash !== expectedConflictHash) {
2293
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence conflictHash is invalid.', {
2294
+ file,
2295
+ line,
2296
+ taskId: task.id,
2297
+ stepId: event.stepId,
2298
+ expectedConflictHash,
2299
+ actualConflictHash: evidence.conflictHash,
2300
+ });
2301
+ }
2302
+ if (changedFiles.some((value) => value.trim() !== value)) {
2303
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity changedFiles must be normalized.', { file, line });
2304
+ }
2305
+ }
2306
+
2307
+ /** A validated support decision survives only a hash-verified Knowledge-only Plan chain.
2308
+ * This preserves scope evidence, never execution authorization or a semantic replan. */
2309
+ export function isKnowledgeReboundSupportDecision(
2310
+ store: FileStateStore, task: TaskState, event: CorrectiveDecisionEvent,
2311
+ ): boolean {
2312
+ const evidence = event.planIntegrityEvidence;
2313
+ if (!task.planHash || event.planHash === task.planHash || event.taskId !== task.id
2314
+ || event.recoveryBasis !== 'plan-integrity' || event.decision !== 'continue-fix'
2315
+ || (evidence?.kind !== 'check-support-anchor-outside-allowed-writes'
2316
+ && evidence?.kind !== 'failed-check-component-owner-outside-allowed-writes')) return false;
2317
+ const step = task.steps.find(s => s.id === event.stepId);
2318
+ if (!step || evidence.stepDefinitionHash !== hashCanonical({
2319
+ id: step.id, title: step.title, objective: step.objective, requirements: step.requirements,
2320
+ dependencies: step.dependencies, expectedOutputs: step.expectedOutputs,
2321
+ allowedWrites: step.allowedWrites, forbiddenScope: step.forbiddenScope, checks: step.checks,
2322
+ }) || evidence.scopeExtensions.some(p => pathAllowed(p, step.allowedWrites) || pathAllowed(p, step.forbiddenScope))
2323
+ || (evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
2324
+ && evidence.semanticBoundaryHash !== semanticBoundaryHash(task))) return false;
2325
+ const root = store.taskRoot(task.projectId, task.id);
2326
+ let cursor = task.planHash;
2327
+ const visited = new Set<string>();
2328
+ while (cursor !== event.planHash) {
2329
+ if (visited.has(cursor)) return false;
2330
+ visited.add(cursor);
2331
+ const links = task.knowledgeRebinds.filter(link => link.reboundPlanHash === cursor);
2332
+ if (links.length !== 1 || !links[0]!.previousPlanHash) return false;
2333
+ const link = links[0]!;
2334
+ const before = readVerifiedPlanArtifact(root, link.previousPlanHash!, task.planHash);
2335
+ const after = readVerifiedPlanArtifact(root, cursor, task.planHash);
2336
+ assertKnowledgeBindingOnlyPlanChange(before, after);
2337
+ const from = normalizePlanKnowledgeBinding(before, 'source').binding;
2338
+ const to = normalizePlanKnowledgeBinding(after, 'target').binding;
2339
+ if (from.revision !== link.fromKnowledgeMapRevision || from.hash !== link.fromKnowledgeMapHash
2340
+ || to.revision !== link.toKnowledgeMapRevision || to.hash !== link.toKnowledgeMapHash) return false;
2341
+ cursor = link.previousPlanHash!;
2342
+ }
2343
+ return true;
2344
+ }
2345
+
2346
+ export function effectiveStepAllowedWrites(
2347
+ store: FileStateStore,
2348
+ task: TaskState,
2349
+ stepId: string,
2350
+ ): string[] {
2351
+ const step = task.steps.find((candidate) => candidate.id === stepId);
2352
+ if (!step) throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
2353
+ const extensions = readCorrectiveDecisionEvents(store, task)
2354
+ .filter((event) => event.stepId === stepId
2355
+ && (event.planHash === task.planHash || isKnowledgeReboundSupportDecision(store, task, event))
2356
+ && event.decision === 'continue-fix'
2357
+ && event.recoveryBasis === 'plan-integrity')
2358
+ .flatMap((event) => event.planIntegrityEvidence?.kind === 'check-support-anchor-outside-allowed-writes'
2359
+ || event.planIntegrityEvidence?.kind === 'failed-check-component-owner-outside-allowed-writes'
2360
+ ? event.planIntegrityEvidence.scopeExtensions
2361
+ : []);
2362
+ return [...step.allowedWrites, ...new Set(extensions.filter((value) => !pathAllowed(value, step.allowedWrites)).sort())];
2363
+ }
2364
+
2365
+ function validateStoredCorrectiveDecisionRecoveryReference(
2366
+ event: CorrectiveDecisionRecoveryEvent,
2367
+ task: TaskState,
2368
+ file: string,
2369
+ line: number,
2370
+ ): void {
2371
+ if (
2372
+ !/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)
2373
+ || event.taskId !== task.id
2374
+ || event.triggeringAttemptCount !== 3
2375
+ ) {
2376
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference does not match the Task/ordinal.', {
2377
+ file,
2378
+ line,
2379
+ taskId: task.id,
2380
+ eventTaskId: event.taskId,
2381
+ triggeringAttemptCount: event.triggeringAttemptCount,
2382
+ });
2383
+ }
2384
+ if (typeof event.stepId !== 'string' || !event.stepId.trim() || typeof event.actor !== 'string' || !event.actor.trim()) {
2385
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference is missing Step or actor.', { file, line });
2386
+ }
2387
+ for (const hash of [
2388
+ event.correctiveDecisionEventHash,
2389
+ event.decisionPlanHash,
2390
+ event.reboundPlanHash,
2391
+ event.semanticPlanHash,
2392
+ event.fromKnowledgeMapHash,
2393
+ event.toKnowledgeMapHash,
2394
+ event.sourcePlanRiskAuditEventHash,
2395
+ event.reboundPlanRiskAuditEventHash,
2396
+ event.knowledgeMapApprovalFingerprint,
2397
+ event.taskAuthorizationFingerprint,
2398
+ event.delegationGrantFingerprint,
2399
+ event.c1EvidenceFingerprint,
2400
+ event.evidenceFingerprint,
2401
+ ]) {
2402
+ assertHash64(hash, `Corrective recovery hash at ${file}:${line}`);
2403
+ }
2404
+ if (
2405
+ !Number.isInteger(event.fromKnowledgeMapRevision)
2406
+ || event.fromKnowledgeMapRevision < 1
2407
+ || !Number.isInteger(event.toKnowledgeMapRevision)
2408
+ || event.toKnowledgeMapRevision < 1
2409
+ ) {
2410
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery Knowledge Map revisions are malformed.', { file, line });
2411
+ }
2412
+ }
2413
+
2414
+ function validateStoredRemediationModeRecovery(
2415
+ event: RemediationModeRecoveryEvent,
2416
+ task: TaskState,
2417
+ file: string,
2418
+ line: number,
2419
+ ): void {
2420
+ if (!/^RMR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)) {
2421
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery eventId is malformed.', { file, line });
2422
+ }
2423
+ if (event.taskId !== task.id || typeof event.stepId !== 'string' || !event.stepId.trim()) {
2424
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match the current Task.', {
2425
+ file,
2426
+ line,
2427
+ taskId: task.id,
2428
+ eventTaskId: event.taskId,
2429
+ });
2430
+ }
2431
+ if (!Number.isInteger(event.taskRevision) || event.taskRevision < 1 || event.attemptOrdinal !== 3) {
2432
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an invalid revision or ordinal.', { file, line });
2433
+ }
2434
+ if (
2435
+ event.fromMode !== 'ordinary'
2436
+ || event.toMode !== 'corrective'
2437
+ || event.reason !== 'alpha7.2-recovered-continue-fix-mode-omission'
2438
+ ) {
2439
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an unsupported correction.', { file, line });
2440
+ }
2441
+ if (
2442
+ !/^RME-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.remediationEventId)
2443
+ || !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionEventId)
2444
+ || !/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionRecoveryEventId)
2445
+ || typeof event.actor !== 'string'
2446
+ || !event.actor.trim()
2447
+ ) {
2448
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery identifiers are malformed.', { file, line });
2449
+ }
2450
+ for (const [label, value] of [
2451
+ ['remediationEventHash', event.remediationEventHash],
2452
+ ['correctiveDecisionEventHash', event.correctiveDecisionEventHash],
2453
+ ['correctiveDecisionRecoveryEventHash', event.correctiveDecisionRecoveryEventHash],
2454
+ ['planHash', event.planHash],
2455
+ ] as const) {
2456
+ assertHash64(value, `Remediation mode recovery ${label} at ${file}:${line}`);
2457
+ }
2458
+ if (event.c1ClaimEventHash !== null) assertHash64(event.c1ClaimEventHash, `Remediation mode recovery C1 hash at ${file}:${line}`);
2459
+ if (event.writerLeaseTokenHash !== null) assertHash64(event.writerLeaseTokenHash, `Remediation mode recovery token hash at ${file}:${line}`);
2460
+ }
2461
+
2462
+ function validateRemediationModeRecoveryBinding(
2463
+ recovery: RemediationModeRecoveryEvent,
2464
+ remediation: RemediationEvent,
2465
+ decision: CorrectiveDecisionEvent,
2466
+ correctiveRecovery: CorrectiveDecisionRecoveryEvent,
2467
+ ): void {
2468
+ const mismatches = [
2469
+ recovery.taskId !== remediation.taskId ? 'taskId' : null,
2470
+ recovery.stepId !== remediation.stepId ? 'stepId' : null,
2471
+ recovery.remediationEventId !== remediation.eventId ? 'remediationEventId' : null,
2472
+ recovery.remediationEventHash !== remediation.eventHash ? 'remediationEventHash' : null,
2473
+ recovery.correctiveDecisionEventId !== decision.eventId ? 'correctiveDecisionEventId' : null,
2474
+ recovery.correctiveDecisionEventHash !== decision.eventHash ? 'correctiveDecisionEventHash' : null,
2475
+ recovery.correctiveDecisionRecoveryEventId !== correctiveRecovery.eventId ? 'correctiveDecisionRecoveryEventId' : null,
2476
+ recovery.correctiveDecisionRecoveryEventHash !== correctiveRecovery.eventHash ? 'correctiveDecisionRecoveryEventHash' : null,
2477
+ recovery.attemptOrdinal !== remediation.attemptOrdinal ? 'attemptOrdinal' : null,
2478
+ recovery.planHash !== correctiveRecovery.reboundPlanHash ? 'planHash' : null,
2479
+ recovery.actor !== correctiveRecovery.actor ? 'actor' : null,
2480
+ ].filter((value): value is string => value !== null);
2481
+ if (mismatches.length > 0) {
2482
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match its append-only evidence.', {
2483
+ recoveryEventId: recovery.eventId,
2484
+ mismatches,
2485
+ });
2486
+ }
2487
+ }
2488
+
2489
+ function validateRemediationModeRecoveries(
2490
+ store: FileStateStore,
2491
+ task: TaskState,
2492
+ remediations: readonly RemediationEvent[],
2493
+ decisions: readonly CorrectiveDecisionEvent[],
2494
+ recoveries: readonly RemediationModeRecoveryEvent[],
2495
+ ): Set<string> {
2496
+ const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task);
2497
+ const recoveredEventIds = new Set<string>();
2498
+ for (const recovery of recoveries) {
2499
+ if (recoveredEventIds.has(recovery.remediationEventId)) {
2500
+ throw new WorkflowError('STATE_CORRUPT', 'Duplicate remediation mode recoveries target one event.', {
2501
+ taskId: task.id,
2502
+ remediationEventId: recovery.remediationEventId,
2503
+ });
2504
+ }
2505
+ const remediation = remediations.find((event) => event.eventId === recovery.remediationEventId) ?? null;
2506
+ const decision = decisions.find((event) => event.eventId === recovery.correctiveDecisionEventId) ?? null;
2507
+ const correctiveRecovery = correctiveRecoveries.find(
2508
+ (event) => event.eventId === recovery.correctiveDecisionRecoveryEventId,
2509
+ ) ?? null;
2510
+ if (!remediation || !decision || !correctiveRecovery) {
2511
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references missing evidence.', {
2512
+ taskId: task.id,
2513
+ recoveryEventId: recovery.eventId,
2514
+ });
2515
+ }
2516
+ if (
2517
+ recovery.taskRevision !== task.revision
2518
+ || recovery.planHash !== task.planHash
2519
+ || remediation.mode !== 'ordinary'
2520
+ || remediation.attemptOrdinal !== 3
2521
+ || decision.decision !== 'continue-fix'
2522
+ || decision.triggeringAttemptCount !== 3
2523
+ || correctiveRecovery.correctiveDecisionEventId !== decision.eventId
2524
+ ) {
2525
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references an ineligible lifecycle shape.', {
2526
+ taskId: task.id,
2527
+ recoveryEventId: recovery.eventId,
2528
+ });
2529
+ }
2530
+ validateRemediationModeRecoveryBinding(recovery, remediation, decision, correctiveRecovery);
2531
+ recoveredEventIds.add(remediation.eventId);
2532
+ }
2533
+ return recoveredEventIds;
2534
+ }
2535
+
2536
+ function validateRemediationAttemptOrdinals(
2537
+ events: readonly RemediationEvent[],
2538
+ task: TaskState,
2539
+ ): void {
2540
+ const attemptsByStep = new Map<string, number>();
2541
+ for (const event of events) {
2542
+ const expectedAttemptOrdinal = (attemptsByStep.get(event.stepId) ?? 0) + 1;
2543
+ if (event.attemptOrdinal !== expectedAttemptOrdinal) {
2544
+ throw new WorkflowError(
2545
+ 'STATE_CORRUPT',
2546
+ 'Remediation event attemptOrdinal does not match the sidecar history for this Step.',
2547
+ {
2548
+ taskId: task.id,
2549
+ stepId: event.stepId,
2550
+ eventId: event.eventId,
2551
+ expectedAttemptOrdinal,
2552
+ actualAttemptOrdinal: event.attemptOrdinal,
2553
+ },
2554
+ );
2555
+ }
2556
+ attemptsByStep.set(event.stepId, event.attemptOrdinal);
2557
+ }
2558
+ }
2559
+
2560
+ function validateRemediationReviewReferences(
2561
+ store: FileStateStore,
2562
+ task: TaskState,
2563
+ events: readonly RemediationEvent[],
2564
+ ): void {
2565
+ if (events.length === 0) return;
2566
+ const requiresStrictReviewLookups = events.some((event) =>
2567
+ event.failureEvidence.reviewEventHash !== null || event.failureEvidence.reviewerAttestationHash !== null,
2568
+ );
2569
+ if (!requiresStrictReviewLookups) {
2570
+ for (const event of events) {
2571
+ if (event.failureKind !== 'checks-failed') {
2572
+ throw new WorkflowError('STATE_CORRUPT', 'Only checks-failed remediation events may omit strict review references.', {
2573
+ taskId: task.id,
2574
+ stepId: event.stepId,
2575
+ eventId: event.eventId,
2576
+ failureKind: event.failureKind,
2577
+ });
2578
+ }
2579
+ }
2580
+ return;
2581
+ }
2582
+ const reviewEvents = readStepReviewEvents(store, task);
2583
+ const reviewEventsByHash = new Map(reviewEvents.map((event) => [event.eventHash, event]));
2584
+ const reviewerAttestations = readReviewerAttestations(store, task);
2585
+ const reviewerAttestationsByHash = new Map(reviewerAttestations.map((event) => [event.eventHash, event]));
2586
+ const eventKeys = new Set<string>();
2587
+ for (const event of events) {
2588
+ const key = remediationFailureIdentityKey(event.failureKind, event.failureEvidence);
2589
+ if (eventKeys.has(`${event.stepId}\0${key}`)) {
2590
+ throw new WorkflowError('STATE_CORRUPT', 'Duplicate remediation events reference the same failure identity.', {
2591
+ taskId: task.id,
2592
+ stepId: event.stepId,
2593
+ eventId: event.eventId,
2594
+ });
2595
+ }
2596
+ eventKeys.add(`${event.stepId}\0${key}`);
2597
+ if (event.failureEvidence.reviewEventHash === null && event.failureEvidence.reviewerAttestationHash === null) {
2598
+ if (event.failureKind !== 'checks-failed') {
2599
+ throw new WorkflowError('STATE_CORRUPT', 'Only checks-failed remediation events may omit strict review references.', {
2600
+ taskId: task.id,
2601
+ stepId: event.stepId,
2602
+ eventId: event.eventId,
2603
+ failureKind: event.failureKind,
2604
+ });
2605
+ }
2606
+ continue;
2607
+ }
2608
+ if (event.failureEvidence.reviewEventHash === null || event.failureEvidence.reviewerAttestationHash === null) {
2609
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event must provide both reviewEventHash and reviewerAttestationHash together.', {
2610
+ taskId: task.id,
2611
+ stepId: event.stepId,
2612
+ eventId: event.eventId,
2613
+ });
2614
+ }
2615
+ const reviewEvent = reviewEventsByHash.get(event.failureEvidence.reviewEventHash) ?? null;
2616
+ const attestation = reviewerAttestationsByHash.get(event.failureEvidence.reviewerAttestationHash) ?? null;
2617
+ if (!reviewEvent || !attestation) {
2618
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event references missing strict review sidecar history.', {
2619
+ taskId: task.id,
2620
+ stepId: event.stepId,
2621
+ eventId: event.eventId,
2622
+ reviewEventHash: event.failureEvidence.reviewEventHash,
2623
+ reviewerAttestationHash: event.failureEvidence.reviewerAttestationHash,
2624
+ });
2625
+ }
2626
+ if (
2627
+ reviewEvent.taskId !== task.id
2628
+ || reviewEvent.stepId !== event.stepId
2629
+ || reviewEvent.completionCommit !== event.failureEvidence.completionCommit
2630
+ || reviewEvent.checksHash !== event.failureEvidence.checksHash
2631
+ ) {
2632
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event reviewEventHash does not match the exact strict review binding.', {
2633
+ taskId: task.id,
2634
+ stepId: event.stepId,
2635
+ eventId: event.eventId,
2636
+ reviewEventId: reviewEvent.eventId,
2637
+ });
2638
+ }
2639
+ if (
2640
+ attestation.taskId !== task.id
2641
+ || attestation.stepId !== event.stepId
2642
+ || attestation.reviewedCommit !== event.failureEvidence.completionCommit
2643
+ || attestation.reviewEventHash !== reviewEvent.eventHash
2644
+ ) {
2645
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event reviewerAttestationHash does not match the exact strict review binding.', {
2646
+ taskId: task.id,
2647
+ stepId: event.stepId,
2648
+ eventId: event.eventId,
2649
+ reviewerAttestationEventId: attestation.eventId,
2650
+ });
2651
+ }
2652
+ const expectedDecision = event.failureKind === 'review-failed' ? 'failed' : 'unverified';
2653
+ if (reviewEvent.decision !== expectedDecision) {
2654
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation event failureKind does not match the referenced strict review decision.', {
2655
+ taskId: task.id,
2656
+ stepId: event.stepId,
2657
+ eventId: event.eventId,
2658
+ failureKind: event.failureKind,
2659
+ reviewDecision: reviewEvent.decision,
2660
+ });
2661
+ }
2662
+ const expectedCauses = (reviewEvent.remediationCauses ?? []).map((cause) => ({
2663
+ authority: 'external-reviewer' as const,
2664
+ causeId: cause.causeId,
2665
+ causeFingerprint: cause.causeFingerprint,
2666
+ planConflict: cause.planConflict,
2667
+ }));
2668
+ if (canonicalJsonStringify(event.causeBindings ?? []) !== canonicalJsonStringify(expectedCauses)) {
2669
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation reviewer causes do not match the exact strict review event.', {
2670
+ taskId: task.id,
2671
+ stepId: event.stepId,
2672
+ eventId: event.eventId,
2673
+ reviewEventId: reviewEvent.eventId,
2674
+ });
2675
+ }
2676
+ }
2677
+ }
2678
+
2679
+ function validateCorrectiveDecisionCoverage(
2680
+ store: FileStateStore,
2681
+ task: TaskState,
2682
+ decisions: readonly CorrectiveDecisionEvent[],
2683
+ remediationEvents: readonly RemediationEvent[],
2684
+ ): void {
2685
+ const remediationEventsById = new Map(remediationEvents.map((event) => [event.eventId, event]));
2686
+ const decisionBindings = new Set<string>();
2687
+ for (const decision of decisions) {
2688
+ const binding = `${decision.stepId}\0${decision.triggeringAttemptCount}`;
2689
+ if (decisionBindings.has(binding)) {
2690
+ throw new WorkflowError('STATE_CORRUPT', 'Duplicate corrective decisions exist for the same Step and triggering ordinal.', {
2691
+ taskId: task.id,
2692
+ stepId: decision.stepId,
2693
+ triggeringAttemptCount: decision.triggeringAttemptCount,
2694
+ });
2695
+ }
2696
+ decisionBindings.add(binding);
2697
+ const priorEvents = remediationEvents
2698
+ .filter((event) => event.stepId === decision.stepId && event.attemptOrdinal < decision.triggeringAttemptCount)
2699
+ .sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
2700
+ const coveredEventIds = normalizeStringList(decision.coveredEventIds, 'Corrective decision coveredEventIds');
2701
+ if (decision.triggeringAttemptCount !== priorEvents.length + 1) {
2702
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision triggeringAttemptCount must equal the blocked next attempt ordinal.', {
2703
+ taskId: task.id,
2704
+ stepId: decision.stepId,
2705
+ decisionEventId: decision.eventId,
2706
+ triggeringAttemptCount: decision.triggeringAttemptCount,
2707
+ priorAttemptCount: priorEvents.length,
2708
+ });
2709
+ }
2710
+ if (!sameStringArray(coveredEventIds, priorEvents.map((event) => event.eventId))) {
2711
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision coveredEventIds must exactly cover prior remediation events for that Step binding.', {
2712
+ taskId: task.id,
2713
+ stepId: decision.stepId,
2714
+ decisionEventId: decision.eventId,
2715
+ coveredEventIds,
2716
+ expectedCoveredEventIds: priorEvents.map((event) => event.eventId),
2717
+ });
2718
+ }
2719
+ for (const eventId of coveredEventIds) {
2720
+ const linked = remediationEventsById.get(eventId);
2721
+ if (!linked || linked.stepId !== decision.stepId) {
2722
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision coveredEventIds references an invalid remediation event.', {
2723
+ taskId: task.id,
2724
+ stepId: decision.stepId,
2725
+ decisionEventId: decision.eventId,
2726
+ eventId,
2727
+ });
2728
+ }
2729
+ }
2730
+ if (decision.recoveryBasis === 'plan-integrity') {
2731
+ const evidence = decision.planIntegrityEvidence;
2732
+ const bindings = evidence?.kind === 'missing-npm-script-outside-allowed-writes'
2733
+ ? [{ eventId: evidence.remediationEventId, eventHash: evidence.remediationEventHash, attemptOrdinal: 1 }]
2734
+ : evidence?.remediationEvents ?? [];
2735
+ const support = evidence?.kind === 'check-support-anchor-outside-allowed-writes'
2736
+ || evidence?.kind === 'failed-check-component-owner-outside-allowed-writes';
2737
+ const scoped = support && bindings[0]?.attemptOrdinal !== 1
2738
+ ? selectPlanIntegrityRemediations(store, { ...task, planHash: decision.planHash, revision: evidence.taskRevision }, priorEvents,
2739
+ decisions.filter(d => d.stepId === decision.stepId && d.triggeringAttemptCount < decision.triggeringAttemptCount), task.planHash)
2740
+ : priorEvents;
2741
+ if (!evidence || !scoped
2742
+ || bindings.length !== scoped.length
2743
+ || scoped.some((linked, index) => linked.failureKind !== 'checks-failed'
2744
+ || linked.eventId !== bindings[index]?.eventId
2745
+ || linked.eventHash !== bindings[index]?.eventHash
2746
+ || linked.attemptOrdinal !== bindings[index]?.attemptOrdinal)) {
2747
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity recovery must bind the exact checks-failed remediation history.', {
2748
+ taskId: task.id,
2749
+ stepId: decision.stepId,
2750
+ decisionEventId: decision.eventId,
2751
+ });
2752
+ }
2753
+ if (evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
2754
+ && !priorEvents.some((linked) => linked.causeBindings?.some((cause) =>
2755
+ cause.authority === 'mechanical'
2756
+ && cause.causeFingerprint === evidence.failedCheckCauseFingerprint))) {
2757
+ throw new WorkflowError('STATE_CORRUPT', 'Component-owner recovery must bind a machine-derived failed-check cause.', {
2758
+ taskId: task.id,
2759
+ stepId: decision.stepId,
2760
+ decisionEventId: decision.eventId,
2761
+ });
2762
+ }
2763
+ }
2764
+ }
2765
+ }
2766
+
2767
+ function validateHistoricalRemediationModes(
2768
+ store: FileStateStore,
2769
+ task: TaskState,
2770
+ stepId: string,
2771
+ remediationEvents: readonly RemediationEvent[],
2772
+ correctiveDecisions: readonly CorrectiveDecisionEvent[],
2773
+ recoveredEventIds: ReadonlySet<string>,
2774
+ overriddenStopDecisionEventIds: ReadonlySet<string>,
2775
+ ): void {
2776
+ const causeAwareHistory = remediationEvents.length > 0
2777
+ && remediationEvents.every((event) => event.causeBindings !== undefined);
2778
+ if (causeAwareHistory) {
2779
+ const audits = readPlanRiskAuditEvents(store, task);
2780
+ const reviews = readStepReviewEvents(store, task);
2781
+ const taskRoot = store.taskRoot(task.projectId, task.id);
2782
+ const cycleBindings = task.planHash
2783
+ ? remediationEvents.map((event) =>
2784
+ resolveRemediationPlanCycleBinding(taskRoot, task.planHash!, event, reviews, audits))
2785
+ : remediationEvents.map(() => null);
2786
+ if (cycleBindings.every((binding) => binding !== null)) {
2787
+ let cycleStart = 0;
2788
+ for (let index = 0; index < remediationEvents.length; index += 1) {
2789
+ if (index > 0 && cycleBindings[index]!.semanticHash !== cycleBindings[index - 1]!.semanticHash) {
2790
+ cycleStart = index;
2791
+ }
2792
+ const cyclePrefix = remediationEvents.slice(cycleStart, index + 1);
2793
+ const priorCycles = remediationEvents.slice(0, cycleStart);
2794
+ if (deriveRollingCauseDecision(cyclePrefix, priorCycles) === 'root-cause-replan'
2795
+ && index < remediationEvents.length - 1
2796
+ && cycleBindings[index + 1]!.semanticHash === cycleBindings[index]!.semanticHash) {
2797
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation continued after an authoritative cause required semantic replan.', {
2798
+ taskId: task.id,
2799
+ stepId,
2800
+ eventId: remediationEvents[index]!.eventId,
2801
+ });
2802
+ }
2803
+ }
2804
+ return;
2805
+ }
2806
+ for (let index = 0; index < remediationEvents.length; index += 1) {
2807
+ const prefix = remediationEvents.slice(0, index + 1);
2808
+ if (deriveRollingCauseDecision(prefix) === 'root-cause-replan' && index < remediationEvents.length - 1) {
2809
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation continued after an authoritative repeated cause required replan.', {
2810
+ taskId: task.id,
2811
+ stepId,
2812
+ eventId: remediationEvents[index]!.eventId,
2813
+ });
2814
+ }
2815
+ }
2816
+ return;
2817
+ }
2818
+ for (const event of remediationEvents) {
2819
+ if (event.attemptOrdinal <= 2) {
2820
+ if (event.mode !== 'ordinary') {
2821
+ throw new WorkflowError('STATE_CORRUPT', 'The first two remediation attempts must remain ordinary.', {
2822
+ taskId: task.id,
2823
+ stepId,
2824
+ eventId: event.eventId,
2825
+ attemptOrdinal: event.attemptOrdinal,
2826
+ mode: event.mode,
2827
+ });
2828
+ }
2829
+ continue;
2830
+ }
2831
+ if (event.mode === 'ordinary' && !recoveredEventIds.has(event.eventId)) {
2832
+ throw new WorkflowError('STATE_CORRUPT', 'Guarded remediation attempt ordinals above 2 must not remain ordinary.', {
2833
+ taskId: task.id,
2834
+ stepId,
2835
+ eventId: event.eventId,
2836
+ attemptOrdinal: event.attemptOrdinal,
2837
+ });
2838
+ }
2839
+ const priorEvents = remediationEvents
2840
+ .filter((candidate) => candidate.stepId === stepId && candidate.attemptOrdinal < event.attemptOrdinal)
2841
+ .sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
2842
+ const matching = correctiveDecisions.filter((decision) =>
2843
+ decision.taskId === task.id
2844
+ && decision.stepId === stepId
2845
+ && decision.triggeringAttemptCount === event.attemptOrdinal
2846
+ && (
2847
+ decision.decision === 'continue-fix'
2848
+ || decision.decision === 'replan-required'
2849
+ || (decision.decision === 'stop-escalate' && overriddenStopDecisionEventIds.has(decision.eventId))
2850
+ )
2851
+ && sameStringArray(decision.coveredEventIds, priorEvents.map((candidate) => candidate.eventId)),
2852
+ );
2853
+ if (matching.length > 1) {
2854
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective remediation mode has conflicting decisions for the exact attempt ordinal.', {
2855
+ taskId: task.id,
2856
+ stepId,
2857
+ eventId: event.eventId,
2858
+ attemptOrdinal: event.attemptOrdinal,
2859
+ matchingDecisionIds: matching.map((decision) => decision.eventId),
2860
+ });
2861
+ }
2862
+ }
2863
+ }
2864
+
2865
+ function validateHistoricalRemediationModesForAllSteps(
2866
+ store: FileStateStore,
2867
+ task: TaskState,
2868
+ remediationEvents: readonly RemediationEvent[],
2869
+ correctiveDecisions: readonly CorrectiveDecisionEvent[],
2870
+ recoveredEventIds: ReadonlySet<string>,
2871
+ overriddenStopDecisionEventIds: ReadonlySet<string>,
2872
+ ): void {
2873
+ const stepIds = new Set(remediationEvents.map((event) => event.stepId));
2874
+ for (const stepId of stepIds) {
2875
+ const stepEvents = remediationEvents
2876
+ .filter((event) => event.stepId === stepId)
2877
+ .sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
2878
+ validateHistoricalRemediationModes(
2879
+ store,
2880
+ task,
2881
+ stepId,
2882
+ stepEvents,
2883
+ correctiveDecisions,
2884
+ recoveredEventIds,
2885
+ overriddenStopDecisionEventIds,
2886
+ );
2887
+ }
2888
+ }
2889
+
2890
+ function validateCorrectiveDecisionAudit(audit: CorrectivePlanAuditInput): void {
2891
+ if (!audit.auditor.trim() || !audit.summary.trim()) {
2892
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective decision requires auditor and summary.');
2893
+ }
2894
+ if (!CORRECTIVE_DECISION_VALUES.has(audit.decision)) {
2895
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective decision value is not supported.', {
2896
+ decision: audit.decision,
2897
+ });
2898
+ }
2899
+ normalizeStringList(audit.reviewedFindingIds, 'Corrective decision reviewedFindingIds');
2900
+ }
2901
+
2902
+ function normalizeStringList(values: readonly string[], label: string): string[] {
2903
+ if (!Array.isArray(values)) {
2904
+ throw new WorkflowError('INVALID_ARGUMENT', `${label} must be an array.`);
2905
+ }
2906
+ const normalized = values.map((value) => {
2907
+ if (typeof value !== 'string' || !value.trim()) {
2908
+ throw new WorkflowError('INVALID_ARGUMENT', `${label} must contain non-empty strings.`);
2909
+ }
2910
+ return value.trim();
2911
+ });
2912
+ if (new Set(normalized).size !== normalized.length) {
2913
+ throw new WorkflowError('INVALID_ARGUMENT', `${label} must not contain duplicates.`);
2914
+ }
2915
+ return normalized;
2916
+ }
2917
+
2918
+ function remediationEventMatchesFailure(
2919
+ event: RemediationEvent,
2920
+ failureKind: RemediationFailureKind,
2921
+ failureEvidence: RemediationFailureEvidence,
2922
+ ): boolean {
2923
+ if (event.failureKind !== failureKind) return false;
2924
+ const eventExecutionRevision = event.failureEvidence.executionTaskRevision;
2925
+ const candidateExecutionRevision = failureEvidence.executionTaskRevision;
2926
+ if (eventExecutionRevision !== undefined || candidateExecutionRevision !== undefined) {
2927
+ return eventExecutionRevision !== undefined
2928
+ && candidateExecutionRevision !== undefined
2929
+ && eventExecutionRevision === candidateExecutionRevision;
2930
+ }
2931
+ return event.failureEvidence.completionCommit === failureEvidence.completionCommit
2932
+ && event.failureEvidence.checksHash === failureEvidence.checksHash
2933
+ && event.failureEvidence.reviewEventHash === failureEvidence.reviewEventHash
2934
+ && event.failureEvidence.reviewerAttestationHash === failureEvidence.reviewerAttestationHash;
2935
+ }
2936
+
2937
+ function remediationFailureIdentityKey(
2938
+ failureKind: RemediationFailureKind,
2939
+ failureEvidence: RemediationFailureEvidence,
2940
+ ): string {
2941
+ if (failureEvidence.executionTaskRevision !== undefined) {
2942
+ return ['execution', failureKind, String(failureEvidence.executionTaskRevision)].join('\0');
2943
+ }
2944
+ return [
2945
+ failureKind,
2946
+ failureEvidence.completionCommit,
2947
+ failureEvidence.checksHash,
2948
+ failureEvidence.reviewEventHash ?? '',
2949
+ failureEvidence.reviewerAttestationHash ?? '',
2950
+ ].join('\0');
2951
+ }
2952
+
2953
+ function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
2954
+ return left.length === right.length && left.every((value, index) => value === right[index]);
2955
+ }
2956
+
2957
+ function assertCommitSha(value: string, label: string, file: string, line: number): void {
2958
+ if (!COMMIT_SHA_PATTERN.test(value)) {
2959
+ throw new WorkflowError('STATE_CORRUPT', `${label} must be a full lowercase Git commit SHA.`, {
2960
+ file,
2961
+ line,
2962
+ value,
2963
+ });
2964
+ }
2965
+ }