codex-workflow-v2 2.0.0-beta.10 → 2.0.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +43 -9
  2. package/dist/src/alpha6/handoff.d.ts +4 -3
  3. package/dist/src/alpha6/handoff.js +58 -3
  4. package/dist/src/alpha6/handoff.js.map +1 -1
  5. package/dist/src/alpha6/mechanical-feasibility.d.ts +7 -0
  6. package/dist/src/alpha6/mechanical-feasibility.js +303 -0
  7. package/dist/src/alpha6/mechanical-feasibility.js.map +1 -0
  8. package/dist/src/alpha6/milestone.d.ts +25 -2
  9. package/dist/src/alpha6/milestone.js +359 -32
  10. package/dist/src/alpha6/milestone.js.map +1 -1
  11. package/dist/src/alpha6/plan-integrity.d.ts +14 -0
  12. package/dist/src/alpha6/plan-integrity.js +127 -0
  13. package/dist/src/alpha6/plan-integrity.js.map +1 -0
  14. package/dist/src/alpha6/remediation.d.ts +2 -1
  15. package/dist/src/alpha6/remediation.js +168 -3
  16. package/dist/src/alpha6/remediation.js.map +1 -1
  17. package/dist/src/cli.js +13 -1
  18. package/dist/src/cli.js.map +1 -1
  19. package/dist/src/contracts.d.ts +69 -1
  20. package/dist/src/git.d.ts +1 -1
  21. package/dist/src/git.js +8 -1
  22. package/dist/src/git.js.map +1 -1
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.js +1 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/lifecycle/corrective-replan.js +8 -3
  27. package/dist/src/lifecycle/corrective-replan.js.map +1 -1
  28. package/dist/src/version.d.ts +1 -1
  29. package/dist/src/version.js +1 -1
  30. package/dist/src/workflow.d.ts +5 -0
  31. package/dist/src/workflow.js +399 -90
  32. package/dist/src/workflow.js.map +1 -1
  33. package/docs/autonomy-guardrails.md +21 -0
  34. package/docs/beta11-plan-integrity-recovery-brief.md +38 -0
  35. package/docs/development-flow.md +31 -6
  36. package/docs/lifecycle/state-machine-stabilization.md +1 -1
  37. package/docs/pdf/README.md +24 -0
  38. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  39. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  40. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  41. package/docs/pdf/requirements.txt +1 -0
  42. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +234 -0
  43. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +334 -0
  44. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +413 -0
  45. package/docs/problem-briefs/01-pre-implementation-integrity.md +478 -0
  46. package/docs/problem-briefs/02-minimal-step-integrity.md +411 -0
  47. package/docs/problem-briefs/03-minimal-agent-context-integrity.md +358 -0
  48. package/docs/problem-briefs/04-task-dependency-and-structural-replacement-integrity.md +566 -0
  49. package/docs/problem-briefs/BRIEF-TEMPLATE.md +56 -0
  50. package/docs/problem-briefs/README.md +120 -0
  51. package/docs/problem-briefs/evidence/p01-mechanical-feasibility-corpus.md +90 -0
  52. package/docs/problem-briefs/evidence/signal-v4-pre-m3-replay.md +246 -0
  53. package/docs/release.md +15 -6
  54. package/docs/split-required-recovery.md +19 -26
  55. package/docs/validation-report.md +116 -77
  56. package/package.json +5 -1
  57. package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +1 -1
  58. package/plugins/codex-workflow-gateway/references/protocol.md +19 -2
  59. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +17 -3
  60. package/references/state-machine.md +9 -5
  61. package/schemas/authorization-event.schema.json +64 -1
  62. package/schemas/corrective-decision-event.schema.json +48 -3
  63. package/schemas/milestone-scope-change-event.schema.json +6 -1
  64. package/schemas/milestone.schema.json +6 -1
  65. package/schemas/task-handoff-event.schema.json +18 -2
  66. package/scripts/generate-pdf-docs.py +512 -0
  67. package/scripts/run-pdf-docs.mjs +62 -0
@@ -6,6 +6,7 @@ import { readMilestoneAutonomyEvents } from '../alpha7/autonomy.js';
6
6
  import { createJournalSnapshotFromContent, captureJournalTargetSnapshot, listMilestoneTransactionJournals, readJournalSnapshotContent, removeMilestoneTransactionJournal, resolveMilestoneTransactionTargetPath, updateMilestoneTransactionJournalPhase, writeMilestoneTransactionJournal, } from './journal.js';
7
7
  import { assertHash64, assertNoSymlinkPathComponents, canonicalJsonStringify, computeSidecarEventHash, prepareSidecarAppend, sha256Hex, } from './store-sidecars.js';
8
8
  const SCOPE_CHANGE_SIDECAR = 'scope-change-events.jsonl';
9
+ const INITIAL_PLAN_TARGETS = ['state.json', 'plan.json'];
9
10
  const MILESTONE_TARGETS = ['state.json', 'plan.json', SCOPE_CHANGE_SIDECAR];
10
11
  const SCOPE_CHANGE_ALLOWED_STATUSES = new Set([
11
12
  'planning',
@@ -68,17 +69,144 @@ export function prepareMilestoneScopeChangeBundle(options) {
68
69
  };
69
70
  }
70
71
  export function normalizeMilestonePlan(plan) {
71
- return {
72
+ const normalized = {
72
73
  outcome: plan.outcome.trim(),
73
74
  successSignal: plan.successSignal.trim(),
74
75
  acceptance: [...plan.acceptance],
75
- memberships: plan.memberships.map((membership) => ({
76
- ...membership,
77
- reason: membership.reason.trim(),
78
- })),
76
+ memberships: plan.memberships.map((membership, index) => {
77
+ if (!membership || typeof membership !== 'object' || Array.isArray(membership)) {
78
+ throw new WorkflowError('INVALID_ARGUMENT', 'Milestone membership must be an object.', {
79
+ membershipIndex: index,
80
+ });
81
+ }
82
+ if (typeof membership.taskId !== 'string' || !membership.taskId.trim()) {
83
+ throw new WorkflowError('INVALID_ARGUMENT', 'Milestone membership requires a Task ID.', {
84
+ membershipIndex: index,
85
+ });
86
+ }
87
+ if (typeof membership.reason !== 'string') {
88
+ throw new WorkflowError('INVALID_ARGUMENT', 'Milestone membership reason must be a string.', {
89
+ membershipIndex: index,
90
+ taskId: membership.taskId,
91
+ });
92
+ }
93
+ if (!Array.isArray(membership.dependsOnTaskIds)) {
94
+ throw new WorkflowError('INVALID_ARGUMENT', 'Every Milestone membership must declare dependsOnTaskIds explicitly.', {
95
+ membershipIndex: index,
96
+ taskId: membership.taskId,
97
+ });
98
+ }
99
+ if (membership.dependsOnTaskIds.some((taskId) => typeof taskId !== 'string' || !taskId.trim())) {
100
+ throw new WorkflowError('INVALID_ARGUMENT', 'Milestone dependency IDs must be non-empty strings.', {
101
+ membershipIndex: index,
102
+ taskId: membership.taskId,
103
+ });
104
+ }
105
+ return {
106
+ taskId: membership.taskId.trim(),
107
+ disposition: membership.disposition,
108
+ reason: membership.reason.trim(),
109
+ dependsOnTaskIds: [...membership.dependsOnTaskIds].map((taskId) => taskId.trim()).sort(),
110
+ };
111
+ }),
79
112
  checks: [...plan.checks],
80
- taskIds: plan.memberships.map((membership) => membership.taskId),
113
+ taskIds: plan.memberships.map((membership) => membership.taskId.trim()),
81
114
  };
115
+ assertCanonicalMilestoneDependencyGraph(normalized.memberships, 'INVALID_ARGUMENT', 'Milestone Plan');
116
+ return normalized;
117
+ }
118
+ export function assessMilestoneTaskDependencies(milestone, taskId, readTask) {
119
+ const membership = milestone.memberships.find((candidate) => candidate.taskId === taskId);
120
+ if (!membership) {
121
+ return {
122
+ dependencyState: milestone.memberships.every((candidate) => Array.isArray(candidate.dependsOnTaskIds))
123
+ ? 'canonical'
124
+ : 'legacy-unknown',
125
+ dependsOnTaskIds: [],
126
+ unsatisfiedPredecessors: [],
127
+ runnable: false,
128
+ blockReason: `Task ${taskId} is not a Milestone member.`,
129
+ };
130
+ }
131
+ if (milestone.memberships.some((candidate) => !Array.isArray(candidate.dependsOnTaskIds))) {
132
+ return {
133
+ dependencyState: 'legacy-unknown',
134
+ dependsOnTaskIds: [],
135
+ unsatisfiedPredecessors: [],
136
+ runnable: false,
137
+ blockReason: 'Milestone dependency graph is unknown for this legacy nonterminal Plan.',
138
+ };
139
+ }
140
+ assertCanonicalMilestoneDependencyGraph(milestone.memberships, 'STATE_CORRUPT', `Milestone ${milestone.id}`);
141
+ const dependsOnTaskIds = [...(membership.dependsOnTaskIds ?? [])];
142
+ const unsatisfiedPredecessors = dependsOnTaskIds.flatMap((predecessorTaskId) => {
143
+ const predecessorMembership = milestone.memberships.find((candidate) => candidate.taskId === predecessorTaskId);
144
+ let predecessor = null;
145
+ try {
146
+ predecessor = readTask(predecessorTaskId);
147
+ }
148
+ catch (error) {
149
+ if (!(error instanceof WorkflowError) || error.code !== 'NOT_FOUND')
150
+ throw error;
151
+ }
152
+ if (predecessorMembership?.disposition === 'required' && predecessor?.status === 'merged')
153
+ return [];
154
+ return [{
155
+ taskId: predecessorTaskId,
156
+ disposition: predecessorMembership?.disposition ?? 'missing',
157
+ status: predecessor?.status ?? 'missing',
158
+ }];
159
+ });
160
+ const runnable = membership.disposition === 'required' && unsatisfiedPredecessors.length === 0;
161
+ return {
162
+ dependencyState: 'canonical',
163
+ dependsOnTaskIds,
164
+ unsatisfiedPredecessors,
165
+ runnable,
166
+ blockReason: membership.disposition !== 'required'
167
+ ? `Milestone membership is ${membership.disposition}, not required.`
168
+ : unsatisfiedPredecessors.length > 0
169
+ ? 'Required predecessor Tasks have not all merged.'
170
+ : null,
171
+ };
172
+ }
173
+ export function assertMilestoneDependenciesDeclared(milestone) {
174
+ if (milestone.memberships.some((membership) => !Array.isArray(membership.dependsOnTaskIds))) {
175
+ throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} has no canonical dependency graph.`, {
176
+ diagnosticCode: 'MILESTONE_DEPENDENCIES_UNKNOWN',
177
+ milestoneId: milestone.id,
178
+ membershipRevision: milestone.membershipRevision,
179
+ requiredAction: 'Apply an explicit Milestone scope change containing dependsOnTaskIds for every membership.',
180
+ });
181
+ }
182
+ assertCanonicalMilestoneDependencyGraph(milestone.memberships, 'STATE_CORRUPT', `Milestone ${milestone.id}`);
183
+ }
184
+ export function assertMilestoneTaskRunnable(milestone, taskId, readTask) {
185
+ const assessment = assessMilestoneTaskDependencies(milestone, taskId, readTask);
186
+ if (milestone.status !== 'active' || !assessment.runnable) {
187
+ const diagnosticCode = assessment.dependencyState === 'legacy-unknown'
188
+ ? 'MILESTONE_DEPENDENCIES_UNKNOWN'
189
+ : milestone.status !== 'active'
190
+ ? 'MILESTONE_NOT_ACTIVE'
191
+ : assessment.unsatisfiedPredecessors.length > 0
192
+ ? 'MILESTONE_PREDECESSORS_UNSATISFIED'
193
+ : 'MILESTONE_MEMBERSHIP_NOT_REQUIRED';
194
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not runnable in Milestone ${milestone.id}.`, {
195
+ diagnosticCode,
196
+ milestoneId: milestone.id,
197
+ milestoneStatus: milestone.status,
198
+ taskId,
199
+ ...assessment,
200
+ requiredAction: assessment.dependencyState === 'legacy-unknown'
201
+ ? 'Apply an explicit Milestone scope change containing dependsOnTaskIds for every membership.'
202
+ : milestone.status !== 'active'
203
+ ? 'Authorize the current Milestone Plan before continuing this Task.'
204
+ : assessment.unsatisfiedPredecessors.length > 0
205
+ ? 'Merge every required predecessor Task before continuing this Task.'
206
+ : 'Restore a required Milestone membership before continuing this Task.',
207
+ });
208
+ }
209
+ return assessment;
82
210
  }
83
211
  export function readMilestoneWithIntegrity(store, projectId, milestoneId, readTask, listTasks) {
84
212
  recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask);
@@ -192,6 +320,17 @@ export function assertMilestoneMembershipIntegrity(milestone, readTask, options
192
320
  membershipTaskIds,
193
321
  });
194
322
  }
323
+ const dependencyArraysPresent = milestone.memberships.filter((membership) => Array.isArray(membership.dependsOnTaskIds)).length;
324
+ if (dependencyArraysPresent > 0 && dependencyArraysPresent !== milestone.memberships.length) {
325
+ throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} mixes canonical and legacy dependency memberships.`, {
326
+ diagnosticCode: 'MILESTONE_DEPENDENCY_GRAPH_PARTIAL',
327
+ canonicalMemberships: dependencyArraysPresent,
328
+ totalMemberships: milestone.memberships.length,
329
+ });
330
+ }
331
+ if (dependencyArraysPresent === milestone.memberships.length && milestone.memberships.length > 0) {
332
+ assertCanonicalMilestoneDependencyGraph(milestone.memberships, 'STATE_CORRUPT', `Milestone ${milestone.id}`);
333
+ }
195
334
  for (const membership of milestone.memberships) {
196
335
  const task = readTask(membership.taskId);
197
336
  if (task.milestoneId !== milestone.id) {
@@ -232,6 +371,74 @@ export function assertMilestoneMembershipIntegrity(milestone, readTask, options
232
371
  }
233
372
  }
234
373
  }
374
+ function assertCanonicalMilestoneDependencyGraph(memberships, code, label) {
375
+ const byTaskId = new Map(memberships.map((membership) => [membership.taskId, membership]));
376
+ if (byTaskId.size !== memberships.length) {
377
+ throw new WorkflowError(code, `${label} memberships contain duplicate Task IDs.`);
378
+ }
379
+ for (const membership of memberships) {
380
+ if (!Array.isArray(membership.dependsOnTaskIds)) {
381
+ throw new WorkflowError(code, `${label} membership ${membership.taskId} is missing dependsOnTaskIds.`);
382
+ }
383
+ if (new Set(membership.dependsOnTaskIds).size !== membership.dependsOnTaskIds.length) {
384
+ throw new WorkflowError(code, `${label} membership ${membership.taskId} contains duplicate dependencies.`, {
385
+ taskId: membership.taskId,
386
+ dependsOnTaskIds: membership.dependsOnTaskIds,
387
+ });
388
+ }
389
+ if (membership.dependsOnTaskIds.some((taskId) => !taskId)) {
390
+ throw new WorkflowError(code, `${label} membership ${membership.taskId} contains an empty dependency.`);
391
+ }
392
+ const sorted = [...membership.dependsOnTaskIds].sort();
393
+ if (canonicalJsonStringify(sorted) !== canonicalJsonStringify(membership.dependsOnTaskIds)) {
394
+ throw new WorkflowError(code, `${label} membership ${membership.taskId} dependencies are not canonical.`, {
395
+ taskId: membership.taskId,
396
+ expected: sorted,
397
+ actual: membership.dependsOnTaskIds,
398
+ });
399
+ }
400
+ if (membership.dependsOnTaskIds.includes(membership.taskId)) {
401
+ throw new WorkflowError(code, `${label} membership ${membership.taskId} cannot depend on itself.`);
402
+ }
403
+ for (const predecessorTaskId of membership.dependsOnTaskIds) {
404
+ const predecessor = byTaskId.get(predecessorTaskId);
405
+ if (!predecessor) {
406
+ throw new WorkflowError(code, `${label} membership ${membership.taskId} has a dangling dependency.`, {
407
+ taskId: membership.taskId,
408
+ predecessorTaskId,
409
+ });
410
+ }
411
+ if (membership.disposition === 'required' && predecessor.disposition !== 'required') {
412
+ throw new WorkflowError(code, `${label} required membership ${membership.taskId} depends on a non-required provider.`, {
413
+ taskId: membership.taskId,
414
+ predecessorTaskId,
415
+ predecessorDisposition: predecessor.disposition,
416
+ });
417
+ }
418
+ }
419
+ }
420
+ const visiting = new Set();
421
+ const visited = new Set();
422
+ const visit = (taskId, path) => {
423
+ if (visiting.has(taskId)) {
424
+ const cycleStart = path.indexOf(taskId);
425
+ throw new WorkflowError(code, `${label} dependency graph contains a cycle.`, {
426
+ cycle: [...path.slice(Math.max(cycleStart, 0)), taskId],
427
+ });
428
+ }
429
+ if (visited.has(taskId))
430
+ return;
431
+ visiting.add(taskId);
432
+ const membership = byTaskId.get(taskId);
433
+ for (const predecessorTaskId of membership.dependsOnTaskIds) {
434
+ visit(predecessorTaskId, [...path, taskId]);
435
+ }
436
+ visiting.delete(taskId);
437
+ visited.add(taskId);
438
+ };
439
+ for (const membership of memberships)
440
+ visit(membership.taskId, []);
441
+ }
235
442
  function hasActiveMilestoneScopeChangeTransaction(store, projectId, milestoneId) {
236
443
  const milestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
237
444
  return listMilestoneTransactionJournals(milestoneRoot, { expectedMilestoneId: milestoneId })
@@ -483,6 +690,90 @@ export function applyMilestoneScopeChangeTransaction(options) {
483
690
  expectedRevisionAfterApply: expectedRevision + 1,
484
691
  });
485
692
  }
693
+ export function applyInitialMilestonePlanTransaction(options) {
694
+ const { store, milestone, expectedRevision, normalizedPlan, now, readTask, listTasks } = options;
695
+ if (milestone.status !== 'planning'
696
+ || milestone.planHash !== null
697
+ || milestone.membershipRevision !== 0
698
+ || milestone.taskIds.length > 0
699
+ || milestone.memberships.length > 0) {
700
+ throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} is not in the initial Plan posture.`);
701
+ }
702
+ const milestoneRoot = store.milestoneRoot(milestone.projectId, milestone.id);
703
+ const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, milestone.projectId, milestone.id);
704
+ const planText = renderMilestonePlanArtifact(normalizedPlan);
705
+ const stagedPlan = store.stageArtifact(milestoneRoot, 'plan.json', planText);
706
+ const authorizations = milestone.authorizations.map((authorization) => authorization.kind === 'execution' && authorization.decision === 'approved'
707
+ ? { ...authorization, decision: 'superseded' }
708
+ : authorization);
709
+ const candidateState = {
710
+ ...milestone,
711
+ revision: milestone.revision + 1,
712
+ updatedAt: now.toISOString(),
713
+ status: 'awaiting_execution_authorization',
714
+ outcome: normalizedPlan.outcome,
715
+ successSignal: normalizedPlan.successSignal,
716
+ acceptance: normalizedPlan.acceptance,
717
+ taskIds: normalizedPlan.taskIds,
718
+ memberships: normalizedPlan.memberships,
719
+ checks: normalizedPlan.checks,
720
+ membershipRevision: 1,
721
+ authorizations,
722
+ planHash: stagedPlan.hash,
723
+ resultHash: null,
724
+ evidenceHash: null,
725
+ acceptedHead: null,
726
+ cancellationReason: null,
727
+ };
728
+ assertMilestoneMembershipIntegrity(candidateState, readTask, {}, listTasks);
729
+ const stagedState = store.stageArtifact(milestoneRoot, 'state.json', `${JSON.stringify(candidateState, null, 2)}\n`);
730
+ const transactionId = `tx-${createEntityId('MS', now.getTime())}`;
731
+ const actor = 'workflow:milestone-plan-set';
732
+ const confirmationCodeBindingHash = sha256Hex(canonicalJsonStringify({
733
+ kind: 'milestone-initial-plan-v1',
734
+ milestoneId: milestone.id,
735
+ expectedRevision,
736
+ planHash: stagedPlan.hash,
737
+ membershipRevision: candidateState.membershipRevision,
738
+ }));
739
+ const journal = {
740
+ transactionId,
741
+ milestoneId: milestone.id,
742
+ expectedMilestoneRevision: milestone.revision,
743
+ actor,
744
+ confirmationCodeBindingHash,
745
+ phase: 'prepared',
746
+ targetFiles: [
747
+ {
748
+ path: 'state.json',
749
+ preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'state.json'),
750
+ staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedState.stagedFile), { mode: 'inline' }),
751
+ },
752
+ {
753
+ path: 'plan.json',
754
+ preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'plan.json'),
755
+ staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedPlan.stagedFile), { mode: 'inline' }),
756
+ },
757
+ ],
758
+ preparedAt: now.toISOString(),
759
+ updatedAt: now.toISOString(),
760
+ };
761
+ writeMilestoneTransactionJournal(alpha6MilestoneRoot, journal);
762
+ try {
763
+ updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, transactionId, 'applying', now.toISOString(), {
764
+ expectedMilestoneId: milestone.id,
765
+ });
766
+ store.publishArtifact(stagedPlan);
767
+ store.publishArtifact(stagedState);
768
+ }
769
+ catch (error) {
770
+ rollbackMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask);
771
+ throw error;
772
+ }
773
+ return finalizeCompletedMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask, {
774
+ expectedRevisionAfterApply: expectedRevision + 1,
775
+ });
776
+ }
486
777
  function recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask) {
487
778
  const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
488
779
  const transactionDir = path.join(alpha6MilestoneRoot, '.transactions');
@@ -643,12 +934,13 @@ function validateMilestoneScopeChangeEvent(event, expectedMilestoneId) {
643
934
  function validateMilestoneTransactionTargets(journal) {
644
935
  const paths = journal.targetFiles.map((target) => target.path);
645
936
  const unique = new Set(paths);
646
- if (unique.size !== MILESTONE_TARGETS.length || paths.length !== MILESTONE_TARGETS.length) {
937
+ const expectedTargets = unique.has(SCOPE_CHANGE_SIDECAR) ? MILESTONE_TARGETS : INITIAL_PLAN_TARGETS;
938
+ if (unique.size !== expectedTargets.length || paths.length !== expectedTargets.length) {
647
939
  throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} target set is invalid.`, {
648
940
  paths,
649
941
  });
650
942
  }
651
- for (const target of MILESTONE_TARGETS) {
943
+ for (const target of expectedTargets) {
652
944
  if (!unique.has(target)) {
653
945
  throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} is missing ${target}.`);
654
946
  }
@@ -759,7 +1051,7 @@ function readStagedMilestoneTransactionBundle(store, projectId, milestoneId, jou
759
1051
  const stateSnapshot = journal.targetFiles.find((target) => target.path === 'state.json')?.staged;
760
1052
  const planSnapshot = journal.targetFiles.find((target) => target.path === 'plan.json')?.staged;
761
1053
  const sidecarSnapshot = journal.targetFiles.find((target) => target.path === SCOPE_CHANGE_SIDECAR)?.staged;
762
- if (!stateSnapshot || !planSnapshot || !sidecarSnapshot) {
1054
+ if (!stateSnapshot || !planSnapshot) {
763
1055
  throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} is missing a staged bundle.`);
764
1056
  }
765
1057
  const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
@@ -771,26 +1063,27 @@ function readStagedMilestoneTransactionBundle(store, projectId, milestoneId, jou
771
1063
  expectedMilestoneId: milestoneId,
772
1064
  transactionId: journal.transactionId,
773
1065
  });
774
- const sidecarBuffer = readJournalSnapshotContent(alpha6MilestoneRoot, sidecarSnapshot, {
775
- expectedMilestoneId: milestoneId,
776
- transactionId: journal.transactionId,
777
- });
778
- if (!stateBuffer || !planBuffer || !sidecarBuffer) {
1066
+ const sidecarBuffer = sidecarSnapshot
1067
+ ? readJournalSnapshotContent(alpha6MilestoneRoot, sidecarSnapshot, {
1068
+ expectedMilestoneId: milestoneId,
1069
+ transactionId: journal.transactionId,
1070
+ })
1071
+ : null;
1072
+ if (!stateBuffer || !planBuffer || (sidecarSnapshot && !sidecarBuffer)) {
779
1073
  throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} has an incomplete staged bundle.`);
780
1074
  }
781
1075
  const state = parseStagedMilestoneStateArtifact(stateBuffer.toString('utf8'), projectId, journal.milestoneId, journal.transactionId);
782
1076
  const plan = parseMilestonePlanArtifact(planBuffer.toString('utf8'), journal.milestoneId);
783
- const events = parseMilestoneScopeChangeEvents(sidecarBuffer.toString('utf8'), journal.milestoneId);
784
- const latestEvent = events.at(-1);
785
- if (!latestEvent) {
786
- throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} has no staged scope-change event.`);
787
- }
788
- if (latestEvent.candidatePlanHash !== state.planHash
789
- || latestEvent.candidateMembershipRevision !== state.membershipRevision
790
- || canonicalJsonStringify(latestEvent.after) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
791
- || canonicalJsonStringify(milestoneScopeSnapshotFromPlan(plan)) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
792
- || latestEvent.actor !== journal.actor
793
- || latestEvent.confirmationCodeBindingHash !== journal.confirmationCodeBindingHash) {
1077
+ const events = sidecarBuffer
1078
+ ? parseMilestoneScopeChangeEvents(sidecarBuffer.toString('utf8'), journal.milestoneId)
1079
+ : [];
1080
+ const latestEvent = events.at(-1) ?? null;
1081
+ if (canonicalJsonStringify(milestoneScopeSnapshotFromPlan(plan)) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
1082
+ || (latestEvent !== null && (latestEvent.candidatePlanHash !== state.planHash
1083
+ || latestEvent.candidateMembershipRevision !== state.membershipRevision
1084
+ || canonicalJsonStringify(latestEvent.after) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
1085
+ || latestEvent.actor !== journal.actor
1086
+ || latestEvent.confirmationCodeBindingHash !== journal.confirmationCodeBindingHash))) {
794
1087
  throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} staged bundle is inconsistent.`);
795
1088
  }
796
1089
  if (state.revision !== journal.expectedMilestoneRevision + 1) {
@@ -799,7 +1092,7 @@ function readStagedMilestoneTransactionBundle(store, projectId, milestoneId, jou
799
1092
  actualRevision: state.revision,
800
1093
  });
801
1094
  }
802
- if (latestEvent.previousMilestoneRevision !== journal.expectedMilestoneRevision) {
1095
+ if (latestEvent && latestEvent.previousMilestoneRevision !== journal.expectedMilestoneRevision) {
803
1096
  throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} latest event revision does not match journal precondition.`, {
804
1097
  expectedPreviousMilestoneRevision: journal.expectedMilestoneRevision,
805
1098
  actualPreviousMilestoneRevision: latestEvent.previousMilestoneRevision,
@@ -837,13 +1130,21 @@ function parseMilestonePlanArtifact(content, milestoneId) {
837
1130
  const acceptance = parseStringArray(record.acceptance, `Milestone ${milestoneId} plan.json acceptance`);
838
1131
  const checks = parseStringArray(record.checks, `Milestone ${milestoneId} plan.json checks`);
839
1132
  const memberships = parseMilestoneMembershipArray(record.memberships, milestoneId);
840
- return normalizeMilestonePlan({
841
- outcome: record.outcome,
842
- successSignal: record.successSignal,
1133
+ const canonicalMembershipCount = memberships.filter((membership) => Array.isArray(membership.dependsOnTaskIds)).length;
1134
+ if (canonicalMembershipCount > 0 && canonicalMembershipCount !== memberships.length) {
1135
+ throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json mixes canonical and legacy dependency memberships.`);
1136
+ }
1137
+ if (canonicalMembershipCount === memberships.length) {
1138
+ assertCanonicalMilestoneDependencyGraph(memberships, 'STATE_CORRUPT', `Milestone ${milestoneId} plan.json`);
1139
+ }
1140
+ return {
1141
+ outcome: record.outcome.trim(),
1142
+ successSignal: record.successSignal.trim(),
843
1143
  acceptance,
844
1144
  memberships,
845
1145
  checks,
846
- });
1146
+ taskIds: memberships.map((membership) => membership.taskId),
1147
+ };
847
1148
  }
848
1149
  function parseMilestoneScopeChangeEvents(content, milestoneId) {
849
1150
  if (content.length === 0) {
@@ -1061,7 +1362,7 @@ function parseMilestoneMembership(value, milestoneId, index, line) {
1061
1362
  throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership at index ${index} must be an object.`, line !== undefined ? { line, index } : { index });
1062
1363
  }
1063
1364
  const record = value;
1064
- const allowedKeys = new Set(['taskId', 'disposition', 'reason']);
1365
+ const allowedKeys = new Set(['taskId', 'disposition', 'reason', 'dependsOnTaskIds']);
1065
1366
  const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
1066
1367
  if (extraKeys.length > 0) {
1067
1368
  throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership contains unexpected fields.`, line !== undefined ? { line, index, extraKeys } : { index, extraKeys });
@@ -1076,7 +1377,33 @@ function parseMilestoneMembership(value, milestoneId, index, line) {
1076
1377
  if (disposition !== 'required' && reason.length === 0) {
1077
1378
  throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership ${disposition} requires a reason.`, line !== undefined ? { line, index } : { index });
1078
1379
  }
1079
- return { taskId, disposition, reason };
1380
+ const dependsOnTaskIds = record.dependsOnTaskIds === undefined
1381
+ ? undefined
1382
+ : parseTaskDependencyArray(record.dependsOnTaskIds, `Milestone ${milestoneId} membership.dependsOnTaskIds`, line !== undefined ? { line, index } : { index });
1383
+ return {
1384
+ taskId,
1385
+ disposition,
1386
+ reason,
1387
+ ...(dependsOnTaskIds === undefined ? {} : { dependsOnTaskIds }),
1388
+ };
1389
+ }
1390
+ function parseTaskDependencyArray(value, label, details = {}) {
1391
+ if (!Array.isArray(value)) {
1392
+ throw new WorkflowError('STATE_CORRUPT', `${label} must be an array.`, details);
1393
+ }
1394
+ const taskIds = value.map((entry, index) => {
1395
+ if (typeof entry !== 'string' || !entry.trim()) {
1396
+ throw new WorkflowError('STATE_CORRUPT', `${label}[${index}] must be a non-empty Task ID.`, {
1397
+ ...details,
1398
+ index,
1399
+ });
1400
+ }
1401
+ return entry.trim();
1402
+ });
1403
+ if (new Set(taskIds).size !== taskIds.length) {
1404
+ throw new WorkflowError('STATE_CORRUPT', `${label} contains duplicate Task IDs.`, { ...details, taskIds });
1405
+ }
1406
+ return taskIds;
1080
1407
  }
1081
1408
  function parseStringArray(value, label, details = {}) {
1082
1409
  if (!Array.isArray(value) || value.length === 0) {