codex-workflow-v2 2.0.0-beta.12.5 → 2.0.0-beta.12.7

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 (39) hide show
  1. package/README.md +29 -3
  2. package/dist/src/alpha6/downstream-proof.d.ts +41 -2
  3. package/dist/src/alpha6/downstream-proof.js +360 -5
  4. package/dist/src/alpha6/downstream-proof.js.map +1 -1
  5. package/dist/src/alpha6/plan-risk.js +2 -2
  6. package/dist/src/alpha6/plan-risk.js.map +1 -1
  7. package/dist/src/cli.js +57 -1
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/contracts.d.ts +44 -1
  10. package/dist/src/dependency-provenance.d.ts +9 -2
  11. package/dist/src/dependency-provenance.js +49 -5
  12. package/dist/src/dependency-provenance.js.map +1 -1
  13. package/dist/src/git.d.ts +1 -1
  14. package/dist/src/git.js +4 -2
  15. package/dist/src/git.js.map +1 -1
  16. package/dist/src/version.d.ts +1 -1
  17. package/dist/src/version.js +1 -1
  18. package/dist/src/workflow.d.ts +11 -1
  19. package/dist/src/workflow.js +434 -28
  20. package/dist/src/workflow.js.map +1 -1
  21. package/docs/autonomy-guardrails.md +7 -0
  22. package/docs/development-flow.md +16 -0
  23. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  24. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  25. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  26. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +26 -6
  27. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +27 -7
  28. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +34 -12
  29. package/docs/release.md +11 -1
  30. package/docs/updating-existing-project.md +61 -2
  31. package/docs/validation-report.md +92 -83
  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/protocol.md +12 -1
  35. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +33 -1
  36. package/schemas/downstream-proof-invalidation-event.schema.json +1 -0
  37. package/schemas/downstream-proof-replan-recovery-event.schema.json +61 -0
  38. package/schemas/task.schema.json +56 -0
  39. package/scripts/generate-pdf-docs.py +13 -2
package/README.md CHANGED
@@ -24,6 +24,10 @@ automation do not import anything from the parent V1 project.
24
24
  - read-only update preflight for clean checkout, running-Step, and writer-lease blockers;
25
25
  - beta.10 bounded recovery for one exact dependency-only HEAD, including retained product commits
26
26
  proven by intact passed strict-review and reviewer-attestation chains;
27
+ - beta.12.6 active-downstream-proof update bridge: one exact dependency-only Task HEAD can be
28
+ registered while the already-classified proof work remains dirty and its Step remains active;
29
+ - beta.12.7 stranded-replan recovery: an accidental post-recovery Knowledge rebind cannot resurrect
30
+ the rejected Plan, and exact dirty bytes can be adopted by one newly authorized owning Step;
27
31
  - bounded first-failure Plan-integrity recovery for legacy/in-flight contradictions that could not
28
32
  be rejected by the current pre-authorization feasibility gate;
29
33
  - read-only Milestone progress projection with stable membership ordinals and replacement links;
@@ -43,7 +47,7 @@ cross-machine synchronization are not part of its contract.
43
47
  Node.js 22 or newer is required. Consumers pin the exact package version:
44
48
 
45
49
  ```bash
46
- npm install --save-dev --save-exact codex-workflow-v2@2.0.0-beta.12.5
50
+ npm install --save-dev --save-exact codex-workflow-v2@2.0.0-beta.12.7
47
51
  npx codex-workflow gateway handshake --repo .
48
52
  npx codex-workflow doctor --repo .
49
53
  ```
@@ -222,6 +226,27 @@ C1, and returns the same Task to ordinary planning. A fresh replacement Plan, Pl
222
226
  execution authorization are required. Unrelated dirty files, non-predecessor ownership, unregistered
223
227
  history, or damaged invalidation evidence fail closed.
224
228
 
229
+ If that exact condition is already present on an older package and therefore blocks the ordinary
230
+ clean-checkout update, beta.12.6 provides a bounded compatibility bridge. Update the active
231
+ Milestone base and Task branch with separate commits that each change only `package.json` and
232
+ `package-lock.json`, install the exact package, and continue only when fresh `next` advertises
233
+ `update downstream-proof-dependency-recover`. Its read-only preflight validates the candidate at
234
+ `HEAD`, the previously recorded Task history at `HEAD^`, aligned runtime/declared/locked/installed/
235
+ base versions, absence of leases and transactions, the exact active Step, predecessor ownership,
236
+ and a SHA-256 binding of every dirty product file. Recovery registers only that dependency commit,
237
+ does not touch product bytes or end the Step, and makes the existing `task downstream-proof-recover`
238
+ route reachable. Unknown history, an unrelated dirty path, a non-dependency candidate, stale base,
239
+ or changed authority remains a hard stop.
240
+
241
+ If beta.12.6 already invalidated the predecessor but a later Project Knowledge approval rebound the
242
+ rejected Plan, beta.12.7 exposes a separate exact compatibility route. Run the new package's read-only
243
+ `update downstream-proof-replan-update-preflight` before changing dependencies. After the two
244
+ dependency-only commits, follow fresh `next` through replan dependency registration and
245
+ `task downstream-proof-replan-recover`. Knowledge refresh may follow, but it cannot replace the
246
+ required `task plan-set`. Preserved dirty bytes are executable only when one replacement Step owns the
247
+ entire path set and `next` reports `downstreamProofCarryover.state=validated`; normal checks, Core commit,
248
+ and strict review remain mandatory.
249
+
225
250
  Docker socket `EPERM`/permission errors observed inside a sandbox are access-boundary evidence, not
226
251
  daemon-failure evidence. The gateway repeats the exact read-only probe or Plan check once with
227
252
  sandbox escalation without consuming a remediation/infra retry. Docker Desktop restart or image
@@ -315,11 +340,12 @@ alpha regressions that must not recur are tracked in the
315
340
  - [Chat-only development guide](docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf)
316
341
  - [Deep technical reference](docs/pdf/codex-workflow-v2-technical-reference-ru.pdf)
317
342
 
318
- These beta.12.5 documents cover delegated Discovery, Milestone Autonomy Contract issuance,
343
+ These beta.12.7 documents cover delegated Discovery, Milestone Autonomy Contract issuance,
319
344
  dependency-aware Task routing, C1 credential handling, external-sealed reviews, bounded
320
345
  Plan-integrity recovery, Task-local dependency provenance recovery, the human-confirmed legacy
321
346
  attempt-four stop override (including append-only multi-rebind, dependency-only HEAD continuation,
322
- and evidence-accounted post-audit recovery revisions), and the P04-A
347
+ evidence-accounted post-audit recovery revisions), both downstream-proof update bridges, exact
348
+ dirty-worktree carryover into a replacement Step, and the P04-A
323
349
  structural-replacement stop boundary. Their reviewable
324
350
  sources live in [`docs/pdf/sources`](docs/pdf/sources), and `npm run docs:pdf:check` proves the
325
351
  tracked binaries were generated from the current source and package version. The PDFs remain
@@ -1,4 +1,4 @@
1
- import type { DownstreamProofInvalidationEvent, TaskState } from '../contracts.js';
1
+ import type { DownstreamProofInvalidationEvent, DownstreamProofReplanCompatibility, DownstreamProofReplanRecoveryEvent, TaskState } from '../contracts.js';
2
2
  import type { FileStateStore } from '../state/store.js';
3
3
  export interface DownstreamProofInvalidatedStep {
4
4
  stepId: string;
@@ -13,13 +13,38 @@ export interface DownstreamProofRecoveryAssessment {
13
13
  outsideActiveStepFiles: string[];
14
14
  invalidatedSteps: DownstreamProofInvalidatedStep[];
15
15
  preservedHeadCommit: string | null;
16
+ dirtyWorktreeHash: string | null;
16
17
  blockers: string[];
17
18
  }
18
19
  export interface PreparedDownstreamProofInvalidation {
19
20
  event: DownstreamProofInvalidationEvent;
20
21
  postimage: string;
21
22
  }
22
- export declare function assessDownstreamProofRecovery(store: FileStateStore, repositoryRoot: string, task: TaskState): DownstreamProofRecoveryAssessment;
23
+ export interface DownstreamProofReplanRecoveryAssessment {
24
+ applicable: boolean;
25
+ eligible: boolean;
26
+ invalidation: DownstreamProofInvalidationEvent | null;
27
+ rebind: TaskState['knowledgeRebinds'][number] | null;
28
+ compatibility: DownstreamProofReplanCompatibility | null;
29
+ dependencyRecoveryCommit: string | null;
30
+ blockers: string[];
31
+ }
32
+ export interface PreparedDownstreamProofReplanRecovery {
33
+ event: DownstreamProofReplanRecoveryEvent;
34
+ postimage: string;
35
+ }
36
+ export interface DownstreamProofCarryoverAssessment {
37
+ applicable: boolean;
38
+ eligible: boolean;
39
+ dirtyFiles: string[];
40
+ dirtyWorktreeHash: string | null;
41
+ invalidationEventId: string | null;
42
+ blockers: string[];
43
+ }
44
+ export declare function assessDownstreamProofRecovery(store: FileStateStore, repositoryRoot: string, task: TaskState, options?: {
45
+ historyBoundary?: string;
46
+ preservedHeadCommit?: string;
47
+ }): DownstreamProofRecoveryAssessment;
23
48
  export declare function prepareDownstreamProofInvalidation(store: FileStateStore, resultTask: TaskState, assessment: DownstreamProofRecoveryAssessment, input: {
24
49
  previousTaskRevision: number;
25
50
  actor: string;
@@ -27,3 +52,17 @@ export declare function prepareDownstreamProofInvalidation(store: FileStateStore
27
52
  }, now: Date): PreparedDownstreamProofInvalidation;
28
53
  export declare function readDownstreamProofInvalidations(store: FileStateStore, task: TaskState): DownstreamProofInvalidationEvent[];
29
54
  export declare function downstreamProofInvalidationSidecar(): string;
55
+ export declare function assessDownstreamProofReplanRecovery(store: FileStateStore, repositoryRoot: string, task: TaskState, options?: {
56
+ historyBoundary?: string;
57
+ preservedHeadCommit?: string;
58
+ currentPlanRiskAuditExists?: boolean;
59
+ }): DownstreamProofReplanRecoveryAssessment;
60
+ export declare function prepareDownstreamProofReplanRecovery(store: FileStateStore, resultTask: TaskState, assessment: DownstreamProofReplanRecoveryAssessment, input: {
61
+ previousTaskRevision: number;
62
+ actor: string;
63
+ reason: string;
64
+ }, now: Date): PreparedDownstreamProofReplanRecovery;
65
+ export declare function readDownstreamProofReplanRecoveries(store: FileStateStore, task: TaskState): DownstreamProofReplanRecoveryEvent[];
66
+ export declare function downstreamProofReplanRecoverySidecar(): string;
67
+ export declare function assessDownstreamProofCarryover(store: FileStateStore, repositoryRoot: string, task: TaskState, stepId: string): DownstreamProofCarryoverAssessment;
68
+ export declare function hashDirtyWorktree(repositoryRoot: string, dirtyFiles: readonly string[]): string;
@@ -1,3 +1,6 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs';
3
+ import path from 'node:path';
1
4
  import { WorkflowError } from '../errors.js';
2
5
  import { pathAllowed } from '../domain/validation.js';
3
6
  import { changedFiles, currentBranch, headCommit, } from '../repository.js';
@@ -6,21 +9,37 @@ import { createUlid } from '../ulid.js';
6
9
  import { PACKAGE_VERSION } from '../version.js';
7
10
  import { prepareSidecarAppend } from './store-sidecars.js';
8
11
  const SIDECAR = 'downstream-proof-invalidations.jsonl';
12
+ const REPLAN_RECOVERY_SIDECAR = 'downstream-proof-replan-recoveries.jsonl';
9
13
  const HASH_64 = /^[a-f0-9]{64}$/;
10
14
  const GIT_HASH = /^[a-f0-9]{40}$/;
11
15
  const EVENT_ID = /^DPI-[0-9A-HJKMNP-TV-Z]{26}$/;
12
- export function assessDownstreamProofRecovery(store, repositoryRoot, task) {
16
+ const REPLAN_EVENT_ID = /^DPR-[0-9A-HJKMNP-TV-Z]{26}$/;
17
+ export function assessDownstreamProofRecovery(store, repositoryRoot, task, options = {}) {
13
18
  readDownstreamProofInvalidations(store, task);
14
19
  const activeStep = task.steps.find((step) => step.status === 'in_progress') ?? null;
15
20
  if (task.status !== 'in_progress' || !activeStep)
16
21
  return emptyAssessment();
17
22
  const dirtyFiles = changedFiles(repositoryRoot).sort();
23
+ let dirtyWorktreeHash = null;
24
+ try {
25
+ dirtyWorktreeHash = hashDirtyWorktree(repositoryRoot, dirtyFiles);
26
+ }
27
+ catch (error) {
28
+ return {
29
+ ...emptyAssessment(),
30
+ applicable: true,
31
+ activeStepId: activeStep.id,
32
+ dirtyFiles,
33
+ blockers: [error instanceof Error ? error.message : String(error)],
34
+ };
35
+ }
18
36
  const outsideActiveStepFiles = dirtyFiles.filter((file) => !pathAllowed(file, activeStep.allowedWrites));
19
37
  if (outsideActiveStepFiles.length === 0) {
20
38
  return {
21
39
  ...emptyAssessment(),
22
40
  activeStepId: activeStep.id,
23
41
  dirtyFiles,
42
+ dirtyWorktreeHash,
24
43
  };
25
44
  }
26
45
  const blockers = [];
@@ -64,7 +83,7 @@ export function assessDownstreamProofRecovery(store, repositoryRoot, task) {
64
83
  if (invalidatedSteps.length === 0)
65
84
  blockers.push('No completed predecessor completion authority can be invalidated.');
66
85
  try {
67
- validateTaskHistory(repositoryRoot, task);
86
+ validateTaskHistory(repositoryRoot, task, options.historyBoundary);
68
87
  }
69
88
  catch (error) {
70
89
  blockers.push(error instanceof Error ? error.message : String(error));
@@ -76,7 +95,8 @@ export function assessDownstreamProofRecovery(store, repositoryRoot, task) {
76
95
  dirtyFiles,
77
96
  outsideActiveStepFiles,
78
97
  invalidatedSteps,
79
- preservedHeadCommit: headCommit(repositoryRoot),
98
+ preservedHeadCommit: options.preservedHeadCommit ?? headCommit(repositoryRoot),
99
+ dirtyWorktreeHash,
80
100
  blockers,
81
101
  };
82
102
  }
@@ -108,6 +128,7 @@ export function prepareDownstreamProofInvalidation(store, resultTask, assessment
108
128
  triggerFiles: [...step.triggerFiles],
109
129
  })),
110
130
  dirtyFiles: [...assessment.dirtyFiles],
131
+ dirtyWorktreeHash: assessment.dirtyWorktreeHash,
111
132
  preservedHeadCommit: assessment.preservedHeadCommit,
112
133
  actor,
113
134
  reason,
@@ -123,6 +144,254 @@ export function readDownstreamProofInvalidations(store, task) {
123
144
  export function downstreamProofInvalidationSidecar() {
124
145
  return SIDECAR;
125
146
  }
147
+ export function assessDownstreamProofReplanRecovery(store, repositoryRoot, task, options = {}) {
148
+ const invalidations = readDownstreamProofInvalidations(store, task);
149
+ const recoveries = readDownstreamProofReplanRecoveries(store, task);
150
+ const invalidation = invalidations.at(-1) ?? null;
151
+ const rebind = task.knowledgeRebinds.at(-1) ?? null;
152
+ if (invalidation && recoveries.some((event) => event.invalidationEventId === invalidation.eventId
153
+ && event.resultTaskRevision <= task.revision)) {
154
+ return emptyReplanAssessment();
155
+ }
156
+ if (!invalidation || !rebind
157
+ || rebind.previousPlanHash !== invalidation.planHash
158
+ || rebind.reboundPlanHash !== task.planHash
159
+ || Date.parse(rebind.recordedAt) <= Date.parse(invalidation.recordedAt)) {
160
+ return emptyReplanAssessment();
161
+ }
162
+ const blockers = [];
163
+ const dirtyFiles = changedFiles(repositoryRoot).sort();
164
+ let dirtyWorktreeHash = null;
165
+ try {
166
+ dirtyWorktreeHash = hashDirtyWorktree(repositoryRoot, dirtyFiles);
167
+ }
168
+ catch (error) {
169
+ blockers.push(error instanceof Error ? error.message : String(error));
170
+ }
171
+ const sourceBinding = [...(task.dependencyProvenanceRecoveries ?? [])]
172
+ .reverse()
173
+ .map((record) => record.activeDownstreamProof)
174
+ .find((binding) => binding
175
+ && binding.planHash === invalidation.planHash
176
+ && binding.activeStepId === invalidation.activeStepId
177
+ && JSON.stringify([...binding.dirtyFiles].sort()) === JSON.stringify(invalidation.dirtyFiles)
178
+ && JSON.stringify([...binding.invalidatedStepIds].sort())
179
+ === JSON.stringify(invalidation.invalidatedSteps.map((step) => step.stepId).sort()))
180
+ ?? null;
181
+ if (!sourceBinding) {
182
+ blockers.push('No dependency provenance record preserves the exact active downstream-proof dirty-set binding.');
183
+ }
184
+ else if (dirtyWorktreeHash !== sourceBinding.dirtyWorktreeHash) {
185
+ blockers.push('Dirty file content changed after the downstream-proof invalidation boundary.');
186
+ }
187
+ if (JSON.stringify(dirtyFiles) !== JSON.stringify(invalidation.dirtyFiles)) {
188
+ blockers.push('Dirty files changed after the downstream-proof invalidation boundary.');
189
+ }
190
+ const registeredCompatibility = [...(task.dependencyProvenanceRecoveries ?? [])]
191
+ .reverse()
192
+ .find((record) => record.downstreamProofReplan?.invalidationEventId === invalidation.eventId
193
+ && record.downstreamProofReplan.reboundPlanHash === task.planHash) ?? null;
194
+ const sourceHead = options.preservedHeadCommit
195
+ ?? registeredCompatibility?.parentCommitSha
196
+ ?? headCommit(repositoryRoot);
197
+ if (sourceHead !== invalidation.preservedHeadCommit) {
198
+ blockers.push('The recovery history boundary no longer matches the downstream-proof preserved HEAD.');
199
+ }
200
+ if (task.workspaceOwner !== 'local')
201
+ blockers.push('Recovery requires the recorded local Task workspace.');
202
+ if (!task.taskBranch || currentBranch(repositoryRoot) !== task.taskBranch) {
203
+ blockers.push('Current branch must equal the recorded Task branch.');
204
+ }
205
+ if (task.status !== 'awaiting_execution_authorization') {
206
+ blockers.push('The legacy stranded Task must still be awaiting execution authorization.');
207
+ }
208
+ if (!task.planHash || task.planHash !== rebind.reboundPlanHash) {
209
+ blockers.push('The current Plan must be the exact mechanically rebound legacy Plan.');
210
+ }
211
+ if (task.knowledgeMapRevision !== rebind.toKnowledgeMapRevision
212
+ || task.knowledgeMapHash !== rebind.toKnowledgeMapHash) {
213
+ blockers.push('The current Project Knowledge binding must match the legacy rebind record.');
214
+ }
215
+ for (const invalidated of invalidation.invalidatedSteps) {
216
+ const step = task.steps.find((candidate) => candidate.id === invalidated.stepId);
217
+ if (!step || step.status !== 'planned' || step.evidence !== null
218
+ || !(task.invalidatedStepCommits ?? []).includes(invalidated.evidenceCommit)) {
219
+ blockers.push(`Invalidated predecessor ${invalidated.stepId} no longer has the preserved planned/no-evidence posture.`);
220
+ }
221
+ }
222
+ const activeStep = task.steps.find((step) => step.id === invalidation.activeStepId);
223
+ if (!activeStep || activeStep.status !== 'blocked' || activeStep.evidence !== null) {
224
+ blockers.push(`Former proof Step ${invalidation.activeStepId} no longer has the preserved blocked/no-evidence posture.`);
225
+ }
226
+ if (task.authorizations.some((authorization) => authorization.kind === 'execution'
227
+ && authorization.decision === 'approved'
228
+ && authorization.planHash === task.planHash)) {
229
+ blockers.push('The rebound Plan has already received execution authorization.');
230
+ }
231
+ if (options.currentPlanRiskAuditExists) {
232
+ blockers.push('The rebound Plan already has a recorded current Plan Risk Audit.');
233
+ }
234
+ const expectedRevision = invalidation.resultTaskRevision + (registeredCompatibility ? 2 : 1);
235
+ if (task.revision !== expectedRevision) {
236
+ blockers.push('Task revision contains changes beyond the exact invalidation, accidental rebind, and optional dependency registration sequence.');
237
+ }
238
+ if (registeredCompatibility) {
239
+ if (registeredCompatibility.commitSha !== headCommit(repositoryRoot)
240
+ || registeredCompatibility.downstreamProofReplan?.dirtyWorktreeHash !== dirtyWorktreeHash) {
241
+ blockers.push('The registered dependency compatibility record does not bind the current HEAD and dirty worktree.');
242
+ }
243
+ }
244
+ else if (!options.historyBoundary && headCommit(repositoryRoot) !== invalidation.preservedHeadCommit) {
245
+ blockers.push('Unregistered Git history exists after the downstream-proof invalidation boundary.');
246
+ }
247
+ try {
248
+ validateTaskHistory(repositoryRoot, task, options.historyBoundary);
249
+ }
250
+ catch (error) {
251
+ blockers.push(error instanceof Error ? error.message : String(error));
252
+ }
253
+ const compatibility = sourceBinding && dirtyWorktreeHash
254
+ ? {
255
+ invalidationEventId: invalidation.eventId,
256
+ activeStepId: invalidation.activeStepId,
257
+ sourcePlanHash: invalidation.planHash,
258
+ reboundPlanHash: rebind.reboundPlanHash,
259
+ knowledgeRebindRecordedAt: rebind.recordedAt,
260
+ dirtyFiles: [...invalidation.dirtyFiles],
261
+ dirtyWorktreeHash,
262
+ invalidatedStepIds: invalidation.invalidatedSteps.map((step) => step.stepId).sort(),
263
+ }
264
+ : null;
265
+ return {
266
+ applicable: true,
267
+ eligible: blockers.length === 0 && compatibility !== null,
268
+ invalidation,
269
+ rebind,
270
+ compatibility,
271
+ dependencyRecoveryCommit: registeredCompatibility?.commitSha ?? null,
272
+ blockers,
273
+ };
274
+ }
275
+ export function prepareDownstreamProofReplanRecovery(store, resultTask, assessment, input, now) {
276
+ if (!assessment.eligible || !assessment.invalidation || !assessment.rebind || !assessment.compatibility) {
277
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Downstream-proof replan compatibility assessment is not eligible.');
278
+ }
279
+ const actor = normalizeRequired(input.actor, 'Recovery actor');
280
+ const reason = normalizeRequired(input.reason, 'Recovery reason');
281
+ const events = readDownstreamProofReplanRecoveries(store, resultTask);
282
+ const prepared = prepareSidecarAppend(events, {
283
+ eventId: `DPR-${createUlid(now.getTime())}`,
284
+ recordedAt: now.toISOString(),
285
+ taskId: resultTask.id,
286
+ previousTaskRevision: input.previousTaskRevision,
287
+ resultTaskRevision: resultTask.revision,
288
+ invalidationEventId: assessment.invalidation.eventId,
289
+ invalidationPlanHash: assessment.invalidation.planHash,
290
+ reboundPlanHash: assessment.rebind.reboundPlanHash,
291
+ knowledgeRebindRecordedAt: assessment.rebind.recordedAt,
292
+ dirtyFiles: [...assessment.compatibility.dirtyFiles],
293
+ dirtyWorktreeHash: assessment.compatibility.dirtyWorktreeHash,
294
+ preservedHeadCommit: headCommit(resultTask.repositoryRoot),
295
+ dependencyRecoveryCommit: assessment.dependencyRecoveryCommit,
296
+ actor,
297
+ reason,
298
+ packageVersion: PACKAGE_VERSION,
299
+ }, {
300
+ validateEvent: (event) => validateReplanRecoveryEvent(event, resultTask),
301
+ });
302
+ return { event: prepared.event, postimage: prepared.content };
303
+ }
304
+ export function readDownstreamProofReplanRecoveries(store, task) {
305
+ return store.readSidecarEvents(store.taskRoot(task.projectId, task.id), REPLAN_RECOVERY_SIDECAR, { validateEvent: (event) => validateReplanRecoveryEvent(event, task) });
306
+ }
307
+ export function downstreamProofReplanRecoverySidecar() {
308
+ return REPLAN_RECOVERY_SIDECAR;
309
+ }
310
+ export function assessDownstreamProofCarryover(store, repositoryRoot, task, stepId) {
311
+ const invalidation = readDownstreamProofInvalidations(store, task).at(-1) ?? null;
312
+ if (!invalidation || !task.planHash || task.planHash === invalidation.planHash) {
313
+ return {
314
+ applicable: false,
315
+ eligible: false,
316
+ dirtyFiles: [],
317
+ dirtyWorktreeHash: null,
318
+ invalidationEventId: null,
319
+ blockers: [],
320
+ };
321
+ }
322
+ const recovery = [...readDownstreamProofReplanRecoveries(store, task)]
323
+ .reverse()
324
+ .find((event) => event.invalidationEventId === invalidation.eventId) ?? null;
325
+ const sourceBinding = [...(task.dependencyProvenanceRecoveries ?? [])]
326
+ .reverse()
327
+ .map((record) => record.activeDownstreamProof)
328
+ .find((binding) => binding?.planHash === invalidation.planHash
329
+ && JSON.stringify([...binding.dirtyFiles].sort()) === JSON.stringify(invalidation.dirtyFiles)) ?? null;
330
+ const expectedDirtyHash = recovery?.dirtyWorktreeHash
331
+ ?? invalidation.dirtyWorktreeHash
332
+ ?? sourceBinding?.dirtyWorktreeHash
333
+ ?? null;
334
+ const expectedHead = recovery?.preservedHeadCommit ?? invalidation.preservedHeadCommit;
335
+ const dirtyFiles = changedFiles(repositoryRoot).sort();
336
+ let dirtyWorktreeHash = null;
337
+ const blockers = [];
338
+ try {
339
+ dirtyWorktreeHash = hashDirtyWorktree(repositoryRoot, dirtyFiles);
340
+ }
341
+ catch (error) {
342
+ blockers.push(error instanceof Error ? error.message : String(error));
343
+ }
344
+ const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
345
+ if (!step || step.status !== 'planned' || step.evidence !== null) {
346
+ blockers.push('The replacement carryover Step must be planned and have no completion evidence.');
347
+ }
348
+ if (task.steps.some((candidate) => candidate.status === 'in_progress' || candidate.evidence !== null)) {
349
+ blockers.push('No replacement Plan Step may already be running or completed.');
350
+ }
351
+ if (JSON.stringify(dirtyFiles) !== JSON.stringify(invalidation.dirtyFiles)) {
352
+ blockers.push('Dirty files changed after downstream-proof recovery.');
353
+ }
354
+ if (!expectedDirtyHash) {
355
+ blockers.push('No hash-bound downstream-proof dirty-worktree evidence is available.');
356
+ }
357
+ else if (dirtyWorktreeHash !== expectedDirtyHash) {
358
+ blockers.push('Dirty file content changed after downstream-proof recovery.');
359
+ }
360
+ if (step && dirtyFiles.some((file) => !pathAllowed(file, step.allowedWrites))) {
361
+ blockers.push('The replacement Step does not own the complete preserved dirty set.');
362
+ }
363
+ if (headCommit(repositoryRoot) !== expectedHead) {
364
+ blockers.push('Git HEAD changed after downstream-proof recovery.');
365
+ }
366
+ if (task.workspaceOwner !== 'local' || !task.taskBranch || currentBranch(repositoryRoot) !== task.taskBranch) {
367
+ blockers.push('Carryover execution requires the unchanged recorded local Task branch.');
368
+ }
369
+ try {
370
+ validateTaskHistory(repositoryRoot, task);
371
+ }
372
+ catch (error) {
373
+ blockers.push(error instanceof Error ? error.message : String(error));
374
+ }
375
+ return {
376
+ applicable: true,
377
+ eligible: blockers.length === 0,
378
+ dirtyFiles,
379
+ dirtyWorktreeHash,
380
+ invalidationEventId: invalidation.eventId,
381
+ blockers,
382
+ };
383
+ }
384
+ function emptyReplanAssessment() {
385
+ return {
386
+ applicable: false,
387
+ eligible: false,
388
+ invalidation: null,
389
+ rebind: null,
390
+ compatibility: null,
391
+ dependencyRecoveryCommit: null,
392
+ blockers: [],
393
+ };
394
+ }
126
395
  function emptyAssessment() {
127
396
  return {
128
397
  applicable: false,
@@ -132,9 +401,38 @@ function emptyAssessment() {
132
401
  outsideActiveStepFiles: [],
133
402
  invalidatedSteps: [],
134
403
  preservedHeadCommit: null,
404
+ dirtyWorktreeHash: null,
135
405
  blockers: [],
136
406
  };
137
407
  }
408
+ export function hashDirtyWorktree(repositoryRoot, dirtyFiles) {
409
+ const hash = createHash('sha256');
410
+ for (const file of [...dirtyFiles].sort()) {
411
+ const absolute = path.resolve(repositoryRoot, file);
412
+ const relative = path.relative(repositoryRoot, absolute);
413
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
414
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', `Dirty path escapes the repository boundary: ${file}`);
415
+ }
416
+ hash.update(`path\0${file}\0`);
417
+ if (!existsSync(absolute)) {
418
+ hash.update('deleted\0');
419
+ continue;
420
+ }
421
+ const stat = lstatSync(absolute);
422
+ if (stat.isSymbolicLink()) {
423
+ hash.update(`symlink\0${readlinkSync(absolute)}\0`);
424
+ }
425
+ else if (stat.isFile()) {
426
+ hash.update('file\0');
427
+ hash.update(readFileSync(absolute));
428
+ hash.update('\0');
429
+ }
430
+ else {
431
+ throw new WorkflowError('GIT_PRECONDITION_FAILED', `Dirty path is not a regular file or symlink: ${file}`);
432
+ }
433
+ }
434
+ return hash.digest('hex');
435
+ }
138
436
  function transitivePredecessors(task, stepId, blockers) {
139
437
  const byId = new Map(task.steps.map((step) => [step.id, step]));
140
438
  const result = new Set();
@@ -179,13 +477,17 @@ function completedDependentClosure(task, seedIds, predecessorIds) {
179
477
  return result;
180
478
  }
181
479
  function validateInvalidationEvent(event, task) {
182
- const expectedKeys = [
480
+ const requiredKeys = [
183
481
  'activeStepId', 'actor', 'dirtyFiles', 'eventHash', 'eventId', 'invalidatedSteps', 'packageVersion',
184
482
  'planHash', 'preservedHeadCommit', 'previousEventHash', 'previousTaskRevision', 'reason', 'recordedAt',
185
483
  'resultTaskRevision', 'taskId',
186
484
  ];
485
+ const actualKeys = Object.keys(event).sort();
486
+ const expectedLegacyKeys = [...requiredKeys].sort();
487
+ const expectedCurrentKeys = [...requiredKeys, 'dirtyWorktreeHash'].sort();
187
488
  if (!event || typeof event !== 'object' || Array.isArray(event)
188
- || JSON.stringify(Object.keys(event).sort()) !== JSON.stringify(expectedKeys.sort())) {
489
+ || (JSON.stringify(actualKeys) !== JSON.stringify(expectedLegacyKeys)
490
+ && JSON.stringify(actualKeys) !== JSON.stringify(expectedCurrentKeys))) {
189
491
  throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation event contains unexpected fields.');
190
492
  }
191
493
  if (typeof event.eventId !== 'string' || !EVENT_ID.test(event.eventId)) {
@@ -220,6 +522,10 @@ function validateInvalidationEvent(event, task) {
220
522
  || JSON.stringify(event.dirtyFiles) !== JSON.stringify([...event.dirtyFiles].sort())) {
221
523
  throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation dirtyFiles are invalid.');
222
524
  }
525
+ if (event.dirtyWorktreeHash !== undefined
526
+ && (typeof event.dirtyWorktreeHash !== 'string' || !HASH_64.test(event.dirtyWorktreeHash))) {
527
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation dirtyWorktreeHash is invalid.');
528
+ }
223
529
  if (!Array.isArray(event.invalidatedSteps) || event.invalidatedSteps.length === 0) {
224
530
  throw new WorkflowError('STATE_CORRUPT', 'Downstream proof invalidation must identify invalidated Steps.');
225
531
  }
@@ -240,6 +546,55 @@ function validateInvalidationEvent(event, task) {
240
546
  stepIds.add(step.stepId);
241
547
  }
242
548
  }
549
+ function validateReplanRecoveryEvent(event, task) {
550
+ const expectedKeys = [
551
+ 'actor', 'dependencyRecoveryCommit', 'dirtyFiles', 'dirtyWorktreeHash', 'eventHash', 'eventId',
552
+ 'invalidationEventId', 'invalidationPlanHash', 'knowledgeRebindRecordedAt', 'packageVersion',
553
+ 'preservedHeadCommit', 'previousEventHash', 'previousTaskRevision', 'reason', 'reboundPlanHash',
554
+ 'recordedAt', 'resultTaskRevision', 'taskId',
555
+ ];
556
+ if (!event || typeof event !== 'object' || Array.isArray(event)
557
+ || JSON.stringify(Object.keys(event).sort()) !== JSON.stringify(expectedKeys.sort())) {
558
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery event contains unexpected fields.');
559
+ }
560
+ if (typeof event.eventId !== 'string' || !REPLAN_EVENT_ID.test(event.eventId)) {
561
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery eventId is invalid.');
562
+ }
563
+ if (event.taskId !== task.id
564
+ || !Number.isInteger(event.previousTaskRevision)
565
+ || event.previousTaskRevision < 1
566
+ || event.resultTaskRevision !== event.previousTaskRevision + 1
567
+ || event.resultTaskRevision > task.revision) {
568
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery Task/revision binding is invalid.');
569
+ }
570
+ if (typeof event.invalidationEventId !== 'string' || !EVENT_ID.test(event.invalidationEventId)
571
+ || typeof event.invalidationPlanHash !== 'string' || !HASH_64.test(event.invalidationPlanHash)
572
+ || typeof event.reboundPlanHash !== 'string' || !HASH_64.test(event.reboundPlanHash)
573
+ || typeof event.dirtyWorktreeHash !== 'string' || !HASH_64.test(event.dirtyWorktreeHash)
574
+ || typeof event.preservedHeadCommit !== 'string' || !GIT_HASH.test(event.preservedHeadCommit)
575
+ || (event.dependencyRecoveryCommit !== null
576
+ && (typeof event.dependencyRecoveryCommit !== 'string' || !GIT_HASH.test(event.dependencyRecoveryCommit)))) {
577
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery hash binding is invalid.');
578
+ }
579
+ if (!Number.isFinite(Date.parse(event.knowledgeRebindRecordedAt))) {
580
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery knowledge rebind timestamp is invalid.');
581
+ }
582
+ if (!Array.isArray(event.dirtyFiles) || event.dirtyFiles.length === 0
583
+ || event.dirtyFiles.some((file) => typeof file !== 'string' || !file.trim())
584
+ || new Set(event.dirtyFiles).size !== event.dirtyFiles.length
585
+ || JSON.stringify(event.dirtyFiles) !== JSON.stringify([...event.dirtyFiles].sort())) {
586
+ throw new WorkflowError('STATE_CORRUPT', 'Downstream-proof replan recovery dirtyFiles are invalid.');
587
+ }
588
+ for (const [value, label] of [
589
+ [event.actor, 'actor'],
590
+ [event.reason, 'reason'],
591
+ [event.packageVersion, 'packageVersion'],
592
+ ]) {
593
+ if (typeof value !== 'string' || !value.trim()) {
594
+ throw new WorkflowError('STATE_CORRUPT', `Downstream-proof replan recovery ${label} is required.`);
595
+ }
596
+ }
597
+ }
243
598
  function normalizeRequired(value, label) {
244
599
  if (typeof value !== 'string' || !value.trim()) {
245
600
  throw new WorkflowError('INVALID_ARGUMENT', `${label} is required.`);