codex-workflow-v2 2.0.0-beta.13.8 → 2.0.0-beta.14

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 (281) hide show
  1. package/README.md +27 -388
  2. package/dist/reviewer-runtime-build.json +96 -32
  3. package/dist/src/alpha6/adoption.d.ts +2 -0
  4. package/dist/src/alpha6/adoption.js +22 -0
  5. package/dist/src/alpha6/adoption.js.map +1 -1
  6. package/dist/src/alpha6/captured-check-evidence.d.ts +51 -0
  7. package/dist/src/alpha6/captured-check-evidence.js +152 -0
  8. package/dist/src/alpha6/captured-check-evidence.js.map +1 -0
  9. package/dist/src/alpha6/component-owner.d.ts +15 -1
  10. package/dist/src/alpha6/component-owner.js +44 -2
  11. package/dist/src/alpha6/component-owner.js.map +1 -1
  12. package/dist/src/alpha6/corrective-decision-boundary.d.ts +4 -0
  13. package/dist/src/alpha6/corrective-decision-boundary.js +47 -0
  14. package/dist/src/alpha6/corrective-decision-boundary.js.map +1 -0
  15. package/dist/src/alpha6/downstream-proof.d.ts +1 -25
  16. package/dist/src/alpha6/downstream-proof.js +0 -171
  17. package/dist/src/alpha6/downstream-proof.js.map +1 -1
  18. package/dist/src/alpha6/failed-step-planning-recovery.d.ts +10 -0
  19. package/dist/src/alpha6/failed-step-planning-recovery.js +93 -0
  20. package/dist/src/alpha6/failed-step-planning-recovery.js.map +1 -0
  21. package/dist/src/alpha6/literal-test-invocation.d.ts +2 -0
  22. package/dist/src/alpha6/literal-test-invocation.js +106 -0
  23. package/dist/src/alpha6/literal-test-invocation.js.map +1 -0
  24. package/dist/src/alpha6/milestone.d.ts +2 -0
  25. package/dist/src/alpha6/milestone.js +10 -1
  26. package/dist/src/alpha6/milestone.js.map +1 -1
  27. package/dist/src/alpha6/navigation-dirty-carryover.d.ts +11 -0
  28. package/dist/src/alpha6/navigation-dirty-carryover.js +130 -0
  29. package/dist/src/alpha6/navigation-dirty-carryover.js.map +1 -0
  30. package/dist/src/alpha6/plan-integrity.js +7 -5
  31. package/dist/src/alpha6/plan-integrity.js.map +1 -1
  32. package/dist/src/alpha6/plan-risk.d.ts +2 -0
  33. package/dist/src/alpha6/plan-risk.js +10 -3
  34. package/dist/src/alpha6/plan-risk.js.map +1 -1
  35. package/dist/src/alpha6/remediation.d.ts +6 -21
  36. package/dist/src/alpha6/remediation.js +161 -476
  37. package/dist/src/alpha6/remediation.js.map +1 -1
  38. package/dist/src/alpha6/root-cause-replan-carryover.d.ts +4 -1
  39. package/dist/src/alpha6/root-cause-replan-carryover.js +29 -5
  40. package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -1
  41. package/dist/src/beta1/project-transaction.d.ts +3 -1
  42. package/dist/src/beta1/project-transaction.js +6 -2
  43. package/dist/src/beta1/project-transaction.js.map +1 -1
  44. package/dist/src/checks/runner.d.ts +26 -0
  45. package/dist/src/checks/runner.js +223 -0
  46. package/dist/src/checks/runner.js.map +1 -0
  47. package/dist/src/checks/task-sync.d.ts +10 -0
  48. package/dist/src/checks/task-sync.js +41 -0
  49. package/dist/src/checks/task-sync.js.map +1 -0
  50. package/dist/src/checks/worker.d.ts +1 -0
  51. package/dist/src/checks/worker.js +154 -0
  52. package/dist/src/checks/worker.js.map +1 -0
  53. package/dist/src/cli-actions.d.ts +2 -2
  54. package/dist/src/cli-actions.js +3 -15
  55. package/dist/src/cli-actions.js.map +1 -1
  56. package/dist/src/cli.js +14 -98
  57. package/dist/src/cli.js.map +1 -1
  58. package/dist/src/contracts.d.ts +35 -32
  59. package/dist/src/dependency-provenance.d.ts +2 -2
  60. package/dist/src/dependency-provenance.js +12 -69
  61. package/dist/src/dependency-provenance.js.map +1 -1
  62. package/dist/src/domain/base-sync-conflict.d.ts +13 -0
  63. package/dist/src/domain/base-sync-conflict.js +32 -0
  64. package/dist/src/domain/base-sync-conflict.js.map +1 -0
  65. package/dist/src/domain/step-start-admission.d.ts +4 -0
  66. package/dist/src/domain/step-start-admission.js +9 -0
  67. package/dist/src/domain/step-start-admission.js.map +1 -0
  68. package/dist/src/domain/virgin-registration.d.ts +3 -0
  69. package/dist/src/domain/virgin-registration.js +65 -0
  70. package/dist/src/domain/virgin-registration.js.map +1 -0
  71. package/dist/src/errors.d.ts +1 -1
  72. package/dist/src/errors.js.map +1 -1
  73. package/dist/src/gateway-handshake.js +0 -8
  74. package/dist/src/gateway-handshake.js.map +1 -1
  75. package/dist/src/git.d.ts +3 -1
  76. package/dist/src/git.js +41 -14
  77. package/dist/src/git.js.map +1 -1
  78. package/dist/src/graph.js +25 -3
  79. package/dist/src/graph.js.map +1 -1
  80. package/dist/src/index.d.ts +1 -0
  81. package/dist/src/lifecycle/corrective-replan.js +3 -3
  82. package/dist/src/lifecycle/corrective-replan.js.map +1 -1
  83. package/dist/src/navigation-actions.d.ts +9 -0
  84. package/dist/src/navigation-actions.js +72 -0
  85. package/dist/src/navigation-actions.js.map +1 -0
  86. package/dist/src/navigation-update-artifact.d.ts +5 -0
  87. package/dist/src/navigation-update-artifact.js +227 -0
  88. package/dist/src/navigation-update-artifact.js.map +1 -0
  89. package/dist/src/navigation-update.d.ts +39 -0
  90. package/dist/src/navigation-update.js +82 -0
  91. package/dist/src/navigation-update.js.map +1 -0
  92. package/dist/src/observation.js +52 -29
  93. package/dist/src/observation.js.map +1 -1
  94. package/dist/src/observed-routes.js +5 -6
  95. package/dist/src/observed-routes.js.map +1 -1
  96. package/dist/src/pending-review-update.d.ts +0 -13
  97. package/dist/src/pending-review-update.js +1 -6
  98. package/dist/src/pending-review-update.js.map +1 -1
  99. package/dist/src/reviewer.d.ts +1 -1
  100. package/dist/src/reviewer.js +20 -17
  101. package/dist/src/reviewer.js.map +1 -1
  102. package/dist/src/runtime.d.ts +3 -0
  103. package/dist/src/runtime.js +6 -0
  104. package/dist/src/runtime.js.map +1 -0
  105. package/dist/src/state/corrective-replan-executor.d.ts +10 -0
  106. package/dist/src/state/corrective-replan-executor.js +37 -1
  107. package/dist/src/state/corrective-replan-executor.js.map +1 -1
  108. package/dist/src/state/corrective-replan-public-schema.js +15 -2
  109. package/dist/src/state/corrective-replan-public-schema.js.map +1 -1
  110. package/dist/src/state/corrective-replan-public.js +6 -5
  111. package/dist/src/state/corrective-replan-public.js.map +1 -1
  112. package/dist/src/state/corrective-replan-transaction.d.ts +1 -0
  113. package/dist/src/state/corrective-replan-transaction.js +12 -11
  114. package/dist/src/state/corrective-replan-transaction.js.map +1 -1
  115. package/dist/src/state/corrective-yield-executor.js +2 -2
  116. package/dist/src/state/corrective-yield-executor.js.map +1 -1
  117. package/dist/src/state/corrective-yield-transaction.d.ts +1 -0
  118. package/dist/src/state/corrective-yield-transaction.js +11 -10
  119. package/dist/src/state/corrective-yield-transaction.js.map +1 -1
  120. package/dist/src/state/lock.d.ts +4 -0
  121. package/dist/src/state/lock.js +21 -0
  122. package/dist/src/state/lock.js.map +1 -1
  123. package/dist/src/version.d.ts +1 -1
  124. package/dist/src/version.js +1 -1
  125. package/dist/src/version.js.map +1 -1
  126. package/dist/src/workflow-blocker-route.d.ts +8 -0
  127. package/dist/src/workflow-blocker-route.js +106 -0
  128. package/dist/src/workflow-blocker-route.js.map +1 -0
  129. package/dist/src/workflow.d.ts +48 -63
  130. package/dist/src/workflow.js +698 -1470
  131. package/dist/src/workflow.js.map +1 -1
  132. package/docs/autonomy-guardrails.md +26 -304
  133. package/docs/decisions.md +14 -112
  134. package/docs/development-flow.md +35 -238
  135. package/docs/project-memory.md +31 -50
  136. package/docs/release-app-evidence.md +190 -0
  137. package/docs/release.md +95 -363
  138. package/docs/updating-existing-project.md +19 -717
  139. package/package.json +11 -13
  140. package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +2 -2
  141. package/plugins/codex-workflow-gateway/references/chat-dispatch.md +73 -197
  142. package/plugins/codex-workflow-gateway/references/codebase-memory-routing.md +57 -0
  143. package/plugins/codex-workflow-gateway/references/protocol.md +42 -445
  144. package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +7 -1
  145. package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +10 -8
  146. package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +23 -5
  147. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +66 -740
  148. package/references/state-machine.md +4 -4
  149. package/roles/technical-planner.md +1 -1
  150. package/schemas/corrective-decision-event.schema.json +3 -1
  151. package/schemas/project-knowledge-map.schema.json +3 -1
  152. package/schemas/remediation-event.schema.json +10 -1
  153. package/schemas/task.schema.json +46 -2
  154. package/schemas/transition-payloads.schema.json +16 -1
  155. package/src/alpha6/adoption.ts +1123 -0
  156. package/src/alpha6/captured-check-evidence.ts +132 -0
  157. package/src/alpha6/check-support-anchor.ts +128 -0
  158. package/src/alpha6/component-owner.ts +168 -0
  159. package/src/alpha6/corrective-decision-boundary.ts +39 -0
  160. package/src/alpha6/downstream-proof.ts +520 -0
  161. package/src/alpha6/failed-step-planning-recovery.ts +88 -0
  162. package/src/alpha6/handoff.ts +1443 -0
  163. package/src/alpha6/journal.ts +473 -0
  164. package/src/alpha6/literal-test-invocation.ts +84 -0
  165. package/src/alpha6/mechanical-feasibility.ts +488 -0
  166. package/src/alpha6/milestone.ts +2192 -0
  167. package/src/alpha6/navigation-dirty-carryover.ts +154 -0
  168. package/src/alpha6/npm-check-contract.ts +47 -0
  169. package/src/alpha6/plan-integrity.ts +298 -0
  170. package/src/alpha6/plan-risk.ts +1480 -0
  171. package/src/alpha6/preexecution-replan.ts +187 -0
  172. package/src/alpha6/remediation-cause.ts +98 -0
  173. package/src/alpha6/remediation.ts +2438 -0
  174. package/src/alpha6/review.ts +1198 -0
  175. package/src/alpha6/root-cause-replan-carryover.ts +491 -0
  176. package/src/alpha6/store-sidecars.ts +335 -0
  177. package/src/alpha7/autonomy.ts +411 -0
  178. package/src/alpha7/corrective-recovery.ts +1332 -0
  179. package/src/artifacts.ts +130 -0
  180. package/src/beta1/project-transaction.ts +355 -0
  181. package/src/change-explanation.ts +153 -0
  182. package/src/checks/runner.ts +245 -0
  183. package/src/checks/task-sync.ts +41 -0
  184. package/src/checks/worker.ts +149 -0
  185. package/src/cli-actions.ts +107 -0
  186. package/src/cli.ts +1457 -0
  187. package/src/contracts.ts +1494 -0
  188. package/src/credential-output.ts +89 -0
  189. package/src/credential-transport.ts +215 -0
  190. package/src/delegation.ts +190 -0
  191. package/src/dependency-provenance.ts +472 -0
  192. package/src/diagnostics.ts +93 -0
  193. package/src/domain/base-sync-conflict.ts +35 -0
  194. package/src/domain/completed-step-carryover.ts +76 -0
  195. package/src/domain/discovery.ts +27 -0
  196. package/src/domain/plan-semantics.ts +58 -0
  197. package/src/domain/step-start-admission.ts +10 -0
  198. package/src/domain/validation.ts +177 -0
  199. package/src/domain/virgin-registration.ts +46 -0
  200. package/src/errors.ts +24 -0
  201. package/src/fs-utils.ts +61 -0
  202. package/src/gateway-handshake.ts +95 -0
  203. package/src/git.ts +183 -0
  204. package/src/graph.ts +342 -0
  205. package/src/historical-step-provenance.ts +136 -0
  206. package/src/index.ts +23 -0
  207. package/src/lifecycle/canonical-hash.ts +28 -0
  208. package/src/lifecycle/catalog.ts +202 -0
  209. package/src/lifecycle/compiler-inspection.ts +29 -0
  210. package/src/lifecycle/core-static-readiness.ts +132 -0
  211. package/src/lifecycle/corrective-replan-authority.ts +136 -0
  212. package/src/lifecycle/corrective-replan-binding-manifest.ts +51 -0
  213. package/src/lifecycle/corrective-replan-credential-core.ts +408 -0
  214. package/src/lifecycle/corrective-replan-credential-schema.ts +54 -0
  215. package/src/lifecycle/corrective-replan-credentials.ts +48 -0
  216. package/src/lifecycle/corrective-replan.ts +843 -0
  217. package/src/lifecycle/evaluator.ts +48 -0
  218. package/src/lifecycle/fingerprint.ts +488 -0
  219. package/src/lifecycle/immutable.ts +8 -0
  220. package/src/lifecycle/implementation-table.ts +118 -0
  221. package/src/lifecycle/index.ts +8 -0
  222. package/src/lifecycle/schema-artifact.ts +263 -0
  223. package/src/lifecycle/semantic-registry.ts +572 -0
  224. package/src/lifecycle/types.ts +838 -0
  225. package/src/memory.ts +273 -0
  226. package/src/migration.ts +161 -0
  227. package/src/navigation-actions.ts +70 -0
  228. package/src/navigation-update-artifact.ts +198 -0
  229. package/src/navigation-update.ts +121 -0
  230. package/src/observation.ts +225 -0
  231. package/src/observed-routes.ts +660 -0
  232. package/src/operational-contract.ts +125 -0
  233. package/src/pending-review-update.ts +175 -0
  234. package/src/repository.ts +99 -0
  235. package/src/reviewer.ts +1879 -0
  236. package/src/runtime.ts +6 -0
  237. package/src/state/corrective-replan-executor.ts +818 -0
  238. package/src/state/corrective-replan-public-schema.ts +83 -0
  239. package/src/state/corrective-replan-public.ts +908 -0
  240. package/src/state/corrective-replan-transaction.ts +949 -0
  241. package/src/state/corrective-yield-executor.ts +327 -0
  242. package/src/state/corrective-yield-transaction.ts +730 -0
  243. package/src/state/lock.ts +902 -0
  244. package/src/state/store.ts +567 -0
  245. package/src/transition-core.ts +330 -0
  246. package/src/ulid.ts +24 -0
  247. package/src/version.ts +2 -0
  248. package/src/workflow-blocker-route.ts +109 -0
  249. package/src/workflow.ts +10172 -0
  250. package/docs/alpha7.1-implementation-brief.md +0 -268
  251. package/docs/alpha7.2-corrective-context-refresh-brief.md +0 -484
  252. package/docs/alpha7.2.1-remediation-recovery-brief.md +0 -86
  253. package/docs/beta1-stabilization-brief.md +0 -165
  254. package/docs/beta11-plan-integrity-recovery-brief.md +0 -38
  255. package/docs/beta13.2-signal-review-recovery.md +0 -38
  256. package/docs/beta2-initial-assembly-navigation-brief.md +0 -616
  257. package/docs/change-model.md +0 -118
  258. package/docs/delegated-approval.md +0 -254
  259. package/docs/lifecycle/state-machine-stabilization.md +0 -641
  260. package/docs/pdf/README.md +0 -24
  261. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  262. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  263. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  264. package/docs/pdf/requirements.txt +0 -1
  265. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +0 -478
  266. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +0 -508
  267. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +0 -778
  268. package/docs/pending-review-update.md +0 -15
  269. package/docs/problem-briefs/01-pre-implementation-integrity.md +0 -482
  270. package/docs/problem-briefs/02-minimal-step-integrity.md +0 -411
  271. package/docs/problem-briefs/03-minimal-agent-context-integrity.md +0 -358
  272. package/docs/problem-briefs/04-task-dependency-and-structural-replacement-integrity.md +0 -573
  273. package/docs/problem-briefs/BRIEF-TEMPLATE.md +0 -56
  274. package/docs/problem-briefs/README.md +0 -120
  275. package/docs/problem-briefs/evidence/p01-mechanical-feasibility-corpus.md +0 -90
  276. package/docs/problem-briefs/evidence/signal-v4-pre-m3-replay.md +0 -246
  277. package/docs/split-required-recovery.md +0 -47
  278. package/docs/stable-release-defect-register.md +0 -730
  279. package/docs/validation-report.md +0 -182
  280. package/scripts/generate-pdf-docs.py +0 -524
  281. package/scripts/run-pdf-docs.mjs +0 -62
@@ -0,0 +1,1443 @@
1
+ import os from 'node:os';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import type {
6
+ MilestoneDependencyBinding,
7
+ TaskCoordinationLevel,
8
+ TaskHandbackRecord,
9
+ TaskHandoffBundle,
10
+ TaskHandoffEvent,
11
+ TaskHandoffEventType,
12
+ TaskState,
13
+ } from '../contracts.js';
14
+ import { WorkflowError } from '../errors.js';
15
+ import type { FileStateStore } from '../state/store.js';
16
+ import { createUlid } from '../ulid.js';
17
+ import {
18
+ assertNoSymlinkPathComponents,
19
+ computeSidecarEventHash,
20
+ prepareSidecarAppend,
21
+ sha256Hex,
22
+ } from './store-sidecars.js';
23
+
24
+ const HANDOFF_SIDECAR = 'handoffs.jsonl';
25
+ const NON_TERMINAL_TASK_STATUSES = new Set<TaskState['status']>([
26
+ 'planning',
27
+ 'awaiting_execution_authorization',
28
+ 'ready',
29
+ 'in_progress',
30
+ 'validating',
31
+ 'blocked',
32
+ 'needs_fix',
33
+ 'awaiting_final_acceptance',
34
+ 'ready_to_merge',
35
+ 'merging',
36
+ ]);
37
+ const HANDOFF_EVENT_TYPES = new Set<TaskHandoffEventType>(['handoff', 'claim', 'credential-replace', 'yield', 'handback']);
38
+ const COORDINATION_LEVEL: TaskCoordinationLevel = 'C1';
39
+
40
+ export type TaskC1Posture =
41
+ | { state: 'none'; latestEvent: TaskHandoffEvent | null }
42
+ | { state: 'pending'; latestEvent: TaskHandoffEvent; targetActor: string }
43
+ | { state: 'claimed'; latestEvent: TaskHandoffEvent; claimant: string; writerLeaseTokenHash: string | null };
44
+
45
+ export interface TaskMutationAuthority {
46
+ actor: string;
47
+ writerToken: string | null;
48
+ }
49
+
50
+ export interface PrepareTaskHandoffInput {
51
+ actor: string;
52
+ targetActor: string;
53
+ reason: string;
54
+ writerToken: string | null;
55
+ milestoneDisplayNumber: number | null;
56
+ taskDisplayNumber: number | null;
57
+ dependencyBinding: MilestoneDependencyBinding | null;
58
+ expectedNext: string;
59
+ grantId: string | null;
60
+ expiresAt: string;
61
+ replacePending: boolean;
62
+ }
63
+
64
+ export interface PreparedTaskCorrectiveYield {
65
+ readonly event: TaskHandoffEvent;
66
+ readonly postimage: Buffer;
67
+ }
68
+
69
+ /**
70
+ * Materializes the exact append-only coordination effect without writing it. The caller must own
71
+ * the resulting bytes in a durable Task transaction before publishing them.
72
+ */
73
+ export function prepareTaskCorrectiveYield(
74
+ store: FileStateStore,
75
+ task: TaskState,
76
+ input: { actor: string; writerToken: string; reason: string; eventId: string },
77
+ now: Date,
78
+ ): PreparedTaskCorrectiveYield {
79
+ assertTaskSupportsC1Handoff(task);
80
+ if (task.steps.some((step) => step.status === 'in_progress')) {
81
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} cannot yield while a Step is in progress.`, {
82
+ taskId: task.id,
83
+ });
84
+ }
85
+ const actor = normalizeActor(input.actor, 'Yield actor');
86
+ const reason = normalizeReason(input.reason);
87
+ if (typeof input.writerToken !== 'string' || input.writerToken.length === 0) {
88
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective yield requires the claimant writer token.');
89
+ }
90
+ if (typeof input.eventId !== 'string' || !/^HOF-[a-f0-9]{64}$/.test(input.eventId)) {
91
+ throw new WorkflowError('INVALID_ARGUMENT', 'Corrective yield eventId must bind the transaction SHA-256.');
92
+ }
93
+ const events = readTaskHandoffEvents(store, task);
94
+ const posture = readTaskC1Posture(store, task);
95
+ const writerLeaseTokenHash = sha256Hex(input.writerToken);
96
+ if (posture.state !== 'claimed' || posture.claimant !== actor
97
+ || posture.writerLeaseTokenHash !== writerLeaseTokenHash) {
98
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} corrective yield requires the exact active claimant and lease.`, {
99
+ taskId: task.id,
100
+ posture: posture.state,
101
+ actor,
102
+ });
103
+ }
104
+ const prepared = prepareSidecarAppend<TaskHandoffEvent>(events, {
105
+ eventId: input.eventId,
106
+ recordedAt: now.toISOString(),
107
+ ...contextFromEvent(posture.latestEvent),
108
+ taskId: task.id,
109
+ taskRevision: task.revision,
110
+ eventType: 'yield',
111
+ coordinationLevel: COORDINATION_LEVEL,
112
+ actor,
113
+ targetActor: null,
114
+ machineEvidence: {
115
+ hostname: os.hostname(),
116
+ pid: process.pid,
117
+ platform: process.platform,
118
+ arch: process.arch,
119
+ nodeVersion: process.version,
120
+ },
121
+ writerLeaseTokenHash,
122
+ reason,
123
+ handback: null,
124
+ }, {
125
+ validateEvent: (event, context) => validateStoredTaskHandoffEvent(event, task, context.file, context.line),
126
+ });
127
+ validateTaskHandoffHistory([...events, prepared.event], task);
128
+ return { event: structuredClone(prepared.event), postimage: Buffer.from(prepared.content, 'utf8') };
129
+ }
130
+
131
+ export function defaultTaskWorkerActor(taskId: string): string {
132
+ if (!/^TASK-[0-9A-HJKMNP-TV-Z]{26}$/.test(taskId)) {
133
+ throw new WorkflowError('INVALID_ARGUMENT', `Cannot derive a C1 Worker actor from invalid Task ID ${taskId}.`);
134
+ }
135
+ return `agent:worker:${taskId.toLowerCase()}`;
136
+ }
137
+
138
+ export function readTaskHandoffEvents(
139
+ store: FileStateStore,
140
+ task: TaskState,
141
+ ): TaskHandoffEvent[] {
142
+ const sidecarPath = path.join(resolveAlpha6TaskRoot(store, task), HANDOFF_SIDECAR);
143
+ assertNoSymlinkPathComponents(sidecarPath, { label: 'Task handoff sidecar path', requireLeaf: true });
144
+ if (!existsSync(sidecarPath)) return [];
145
+ const events = parseTaskHandoffEvents(readFileSync(sidecarPath, 'utf8'), task, sidecarPath);
146
+ validateTaskHandoffHistory(events, task);
147
+ return events;
148
+ }
149
+
150
+ export function readTaskC1Posture(
151
+ store: FileStateStore,
152
+ task: TaskState,
153
+ ): TaskC1Posture {
154
+ const events = readTaskHandoffEvents(store, task);
155
+ const latestEvent = events.at(-1) ?? null;
156
+ if (!latestEvent) return { state: 'none', latestEvent: null };
157
+ if (latestEvent.eventType === 'handoff') {
158
+ return {
159
+ state: 'pending',
160
+ latestEvent,
161
+ targetActor: latestEvent.targetActor!,
162
+ };
163
+ }
164
+ if (latestEvent.eventType === 'claim' || latestEvent.eventType === 'credential-replace') {
165
+ return {
166
+ state: 'claimed',
167
+ latestEvent,
168
+ claimant: latestEvent.actor.trim(),
169
+ writerLeaseTokenHash: latestEvent.writerLeaseTokenHash,
170
+ };
171
+ }
172
+ return { state: 'none', latestEvent };
173
+ }
174
+
175
+ export function appendTaskHandoff(
176
+ store: FileStateStore,
177
+ task: TaskState,
178
+ input: PrepareTaskHandoffInput,
179
+ now: Date,
180
+ ): { event: TaskHandoffEvent; bundle: TaskHandoffBundle } {
181
+ assertTaskSupportsC1Handoff(task);
182
+ const normalizedActor = normalizeActor(input.actor, 'Handoff actor');
183
+ const normalizedTargetActor = normalizeActor(input.targetActor, 'Handoff target actor');
184
+ if (normalizedActor === normalizedTargetActor) {
185
+ throw new WorkflowError('INVALID_ARGUMENT', 'C1 handoff target must differ from the current actor.', {
186
+ taskId: task.id,
187
+ actor: normalizedActor,
188
+ targetActor: normalizedTargetActor,
189
+ });
190
+ }
191
+ const normalizedReason = normalizeReason(input.reason);
192
+ const expiresAt = normalizeFutureExpiry(input.expiresAt, now);
193
+ const posture = readTaskC1Posture(store, task);
194
+ if (posture.state === 'pending') {
195
+ if (!input.replacePending) {
196
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} already has a pending C1 handoff.`, {
197
+ taskId: task.id,
198
+ targetActor: posture.targetActor,
199
+ eventId: posture.latestEvent.eventId,
200
+ });
201
+ }
202
+ if (
203
+ normalizedActor !== posture.latestEvent.actor.trim()
204
+ || normalizedTargetActor !== posture.targetActor
205
+ || input.grantId !== posture.latestEvent.grantId
206
+ ) {
207
+ throw new WorkflowError('TRANSITION_BLOCKED', `Pending C1 handoff ${posture.latestEvent.handoffId} may only replace its lost credential without changing authority.`, {
208
+ taskId: task.id,
209
+ handoffId: posture.latestEvent.handoffId,
210
+ requiredActor: posture.latestEvent.actor,
211
+ requiredTargetActor: posture.targetActor,
212
+ requiredGrantId: posture.latestEvent.grantId,
213
+ });
214
+ }
215
+ } else if (input.replacePending) {
216
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not have a pending C1 handoff credential to replace.`, {
217
+ taskId: task.id,
218
+ posture: posture.state,
219
+ });
220
+ }
221
+ if (posture.state === 'claimed' && posture.claimant !== normalizedActor) {
222
+ throw new WorkflowError('TRANSITION_BLOCKED', `Only claimant ${posture.claimant} may initiate a new handoff for Task ${task.id}.`, {
223
+ taskId: task.id,
224
+ claimant: posture.claimant,
225
+ actor: normalizedActor,
226
+ });
227
+ }
228
+ const leaseTokenHash = input.writerToken ? sha256Hex(input.writerToken) : null;
229
+ if (posture.state === 'claimed' && posture.writerLeaseTokenHash !== leaseTokenHash) {
230
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} handoff requires the claimant's bound writer lease token.`, {
231
+ taskId: task.id,
232
+ claimant: posture.claimant,
233
+ expectedWriterLeaseTokenHash: posture.writerLeaseTokenHash,
234
+ actualWriterLeaseTokenHash: leaseTokenHash,
235
+ });
236
+ }
237
+ const claimToken = randomBytes(32).toString('base64url');
238
+ const handoffId = `THO-${createUlid(now.getTime())}`;
239
+ const context = {
240
+ handoffId,
241
+ milestoneId: task.milestoneId,
242
+ milestoneDisplayNumber: input.milestoneDisplayNumber,
243
+ taskDisplayNumber: input.taskDisplayNumber,
244
+ briefHash: task.briefHash,
245
+ planHash: task.planHash,
246
+ knowledgeMapRevision: task.knowledgeMapRevision,
247
+ knowledgeMapHash: task.knowledgeMapHash,
248
+ dependencyBinding: input.dependencyBinding,
249
+ expectedNext: normalizeReason(input.expectedNext),
250
+ delegate: normalizedTargetActor,
251
+ grantId: input.grantId,
252
+ claimTokenHash: sha256Hex(claimToken),
253
+ expiresAt,
254
+ };
255
+ const event = appendTaskHandoffEvent(store, task, {
256
+ eventType: 'handoff',
257
+ actor: normalizedActor,
258
+ targetActor: normalizedTargetActor,
259
+ writerLeaseTokenHash: leaseTokenHash,
260
+ reason: normalizedReason,
261
+ context,
262
+ handback: null,
263
+ }, now);
264
+ return {
265
+ event,
266
+ bundle: {
267
+ handoffId,
268
+ milestoneId: task.milestoneId,
269
+ milestoneDisplayNumber: input.milestoneDisplayNumber,
270
+ taskId: task.id,
271
+ taskDisplayNumber: input.taskDisplayNumber,
272
+ taskRevision: task.revision,
273
+ briefHash: task.briefHash,
274
+ planHash: task.planHash,
275
+ knowledgeMapRevision: task.knowledgeMapRevision,
276
+ knowledgeMapHash: task.knowledgeMapHash,
277
+ dependencyBinding: input.dependencyBinding,
278
+ expectedNext: context.expectedNext,
279
+ delegate: normalizedTargetActor,
280
+ grantId: input.grantId,
281
+ claimToken,
282
+ createdAt: now.toISOString(),
283
+ expiresAt,
284
+ prompt: buildTaskHandoffPrompt(task, context.expectedNext, normalizedTargetActor),
285
+ },
286
+ };
287
+ }
288
+
289
+ export function appendTaskClaim(
290
+ store: FileStateStore,
291
+ task: TaskState,
292
+ actor: string,
293
+ claimToken: string,
294
+ writerToken: string | null,
295
+ now: Date,
296
+ expectedDependencyBinding?: MilestoneDependencyBinding | null,
297
+ ): TaskHandoffEvent {
298
+ if (writerToken === null) {
299
+ const normalizedActor = normalizeActor(actor, 'Claim actor');
300
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} claim requires a bound writer lease token.`, {
301
+ taskId: task.id,
302
+ actor: normalizedActor,
303
+ });
304
+ }
305
+ const posture = assertTaskClaimAllowed(store, task, actor, claimToken, now, expectedDependencyBinding);
306
+ const normalizedActor = normalizeActor(actor, 'Claim actor');
307
+ return appendTaskHandoffEvent(store, task, {
308
+ eventType: 'claim',
309
+ actor: normalizedActor,
310
+ targetActor: null,
311
+ writerLeaseTokenHash: sha256Hex(writerToken),
312
+ reason: `Claimed pending C1 handoff from ${posture.latestEvent.actor}.`,
313
+ context: contextFromEvent(posture.latestEvent),
314
+ handback: null,
315
+ }, now);
316
+ }
317
+
318
+ export function appendTaskWriterCredentialReplacement(
319
+ store: FileStateStore,
320
+ task: TaskState,
321
+ actor: string,
322
+ writerToken: string,
323
+ now: Date,
324
+ ): TaskHandoffEvent {
325
+ const posture = readTaskC1Posture(store, task);
326
+ const normalizedActor = normalizeActor(actor, 'Writer credential replacement actor');
327
+ if (posture.state !== 'claimed' || posture.claimant !== normalizedActor) {
328
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} writer credential replacement requires the exact active claimant.`, {
329
+ taskId: task.id,
330
+ posture: posture.state,
331
+ claimant: posture.state === 'claimed' ? posture.claimant : null,
332
+ actor: normalizedActor,
333
+ });
334
+ }
335
+ if (!writerToken) throw new WorkflowError('INVALID_ARGUMENT', 'Replacement writer credential is required.');
336
+ return appendTaskHandoffEvent(store, task, {
337
+ eventType: 'credential-replace',
338
+ actor: normalizedActor,
339
+ targetActor: null,
340
+ writerLeaseTokenHash: sha256Hex(writerToken),
341
+ reason: 'Replaced an unavailable active writer credential.',
342
+ context: contextFromEvent(posture.latestEvent),
343
+ handback: null,
344
+ }, now);
345
+ }
346
+
347
+ /**
348
+ * Validates every deterministic Task/handoff blocker without mutating the writer lease or sidecars.
349
+ * Callers may then acquire/renew a lease and use appendTaskClaim as a defensive race re-check.
350
+ */
351
+ export function assertTaskClaimAllowed(
352
+ store: FileStateStore,
353
+ task: TaskState,
354
+ actor: string,
355
+ claimToken: string,
356
+ now: Date,
357
+ expectedDependencyBinding?: MilestoneDependencyBinding | null,
358
+ ): Extract<TaskC1Posture, { state: 'pending' }> {
359
+ assertTaskSupportsC1Handoff(task);
360
+ const normalizedActor = normalizeActor(actor, 'Claim actor');
361
+ const posture = readTaskC1Posture(store, task);
362
+ if (posture.state !== 'pending') {
363
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not have a pending C1 handoff to claim.`, {
364
+ taskId: task.id,
365
+ posture: posture.state,
366
+ });
367
+ }
368
+ if (posture.targetActor !== normalizedActor) {
369
+ throw new WorkflowError('TRANSITION_BLOCKED', `Only handoff target ${posture.targetActor} may claim Task ${task.id}.`, {
370
+ taskId: task.id,
371
+ targetActor: posture.targetActor,
372
+ actor: normalizedActor,
373
+ });
374
+ }
375
+ if (
376
+ posture.latestEvent.taskRevision !== task.revision
377
+ || posture.latestEvent.briefHash !== task.briefHash
378
+ || posture.latestEvent.planHash !== task.planHash
379
+ || posture.latestEvent.knowledgeMapRevision !== task.knowledgeMapRevision
380
+ || posture.latestEvent.knowledgeMapHash !== task.knowledgeMapHash
381
+ ) {
382
+ throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} is stale for the current Task context.`, {
383
+ taskId: task.id,
384
+ handoffId: posture.latestEvent.handoffId,
385
+ handoffTaskRevision: posture.latestEvent.taskRevision,
386
+ currentTaskRevision: task.revision,
387
+ });
388
+ }
389
+ if (
390
+ expectedDependencyBinding !== undefined
391
+ && !dependencyBindingsEqual(posture.latestEvent.dependencyBinding ?? null, expectedDependencyBinding)
392
+ ) {
393
+ throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} has a stale Milestone dependency binding.`, {
394
+ diagnosticCode: 'MILESTONE_DEPENDENCY_BINDING_STALE',
395
+ taskId: task.id,
396
+ handoffId: posture.latestEvent.handoffId,
397
+ handoffDependencyBinding: posture.latestEvent.dependencyBinding ?? null,
398
+ currentDependencyBinding: expectedDependencyBinding,
399
+ requiredAction: 'Prepare a new C1 handoff from the current Milestone Plan.',
400
+ });
401
+ }
402
+ const handoffExpiresAt = Date.parse(posture.latestEvent.expiresAt);
403
+ if (!Number.isFinite(handoffExpiresAt)) {
404
+ throw new WorkflowError('STATE_CORRUPT', `C1 handoff ${posture.latestEvent.handoffId} expiry is malformed.`, {
405
+ taskId: task.id,
406
+ handoffId: posture.latestEvent.handoffId,
407
+ expiresAt: posture.latestEvent.expiresAt,
408
+ });
409
+ }
410
+ if (handoffExpiresAt <= now.getTime()) {
411
+ throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} has expired.`, {
412
+ taskId: task.id,
413
+ handoffId: posture.latestEvent.handoffId,
414
+ expiresAt: posture.latestEvent.expiresAt,
415
+ });
416
+ }
417
+ if (!claimToken || sha256Hex(claimToken) !== posture.latestEvent.claimTokenHash) {
418
+ throw new WorkflowError('TRANSITION_BLOCKED', `C1 handoff ${posture.latestEvent.handoffId} claim token is invalid.`, {
419
+ taskId: task.id,
420
+ handoffId: posture.latestEvent.handoffId,
421
+ });
422
+ }
423
+ return posture;
424
+ }
425
+
426
+ export function appendTaskHandback(
427
+ store: FileStateStore,
428
+ task: TaskState,
429
+ actor: string,
430
+ reason: string,
431
+ writerToken: string | null,
432
+ limitations: readonly string[],
433
+ recommendedNext: string,
434
+ now: Date,
435
+ ): TaskHandoffEvent {
436
+ const normalizedActor = normalizeActor(actor, 'Handback actor');
437
+ const normalizedReason = normalizeReason(reason);
438
+ const posture = readTaskC1Posture(store, task);
439
+ if (posture.state !== 'claimed') {
440
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not have an active C1 claim to hand back.`, {
441
+ taskId: task.id,
442
+ posture: posture.state,
443
+ });
444
+ }
445
+ if (posture.claimant !== normalizedActor) {
446
+ throw new WorkflowError('TRANSITION_BLOCKED', `Only claimant ${posture.claimant} may hand back Task ${task.id}.`, {
447
+ taskId: task.id,
448
+ claimant: posture.claimant,
449
+ actor: normalizedActor,
450
+ });
451
+ }
452
+ const leaseTokenHash = writerToken ? sha256Hex(writerToken) : null;
453
+ if (posture.writerLeaseTokenHash !== leaseTokenHash) {
454
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} handback requires the claimant's bound writer lease token.`, {
455
+ taskId: task.id,
456
+ claimant: posture.claimant,
457
+ expectedWriterLeaseTokenHash: posture.writerLeaseTokenHash,
458
+ actualWriterLeaseTokenHash: leaseTokenHash,
459
+ });
460
+ }
461
+ if (task.status !== 'merged' && task.status !== 'cancelled') {
462
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} handback requires a terminal Task status.`, {
463
+ taskId: task.id,
464
+ taskStatus: task.status,
465
+ terminalStatuses: ['merged', 'cancelled'],
466
+ });
467
+ }
468
+ const handback: TaskHandbackRecord = {
469
+ status: task.status,
470
+ mergedCommit: task.mergedCommit,
471
+ planHash: task.planHash,
472
+ resultHash: task.resultHash,
473
+ evidenceHash: task.evidenceHash,
474
+ reviewAttestationHash: task.review.status === 'pending'
475
+ ? null
476
+ : sha256Hex(JSON.stringify(task.review)),
477
+ knowledgeMapRevision: task.knowledgeMapRevision,
478
+ knowledgeMapHash: task.knowledgeMapHash,
479
+ limitations: normalizeStringList(limitations, 'C1 handback limitations'),
480
+ recommendedNext: normalizeReason(recommendedNext),
481
+ };
482
+ return appendTaskHandoffEvent(store, task, {
483
+ eventType: 'handback',
484
+ actor: normalizedActor,
485
+ targetActor: null,
486
+ writerLeaseTokenHash: leaseTokenHash,
487
+ reason: normalizedReason,
488
+ context: contextFromEvent(posture.latestEvent),
489
+ handback,
490
+ }, now);
491
+ }
492
+
493
+ export function assertTaskMutationAllowedByC1(
494
+ store: FileStateStore,
495
+ task: TaskState,
496
+ authority: TaskMutationAuthority,
497
+ ): void {
498
+ const posture = readTaskC1Posture(store, task);
499
+ if (posture.state === 'none') return;
500
+ if (posture.state === 'pending') {
501
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} has a pending C1 handoff and cannot be mutated until claim or handback.`, {
502
+ taskId: task.id,
503
+ targetActor: posture.targetActor,
504
+ eventId: posture.latestEvent.eventId,
505
+ });
506
+ }
507
+ const normalizedActor = normalizeActor(authority.actor, 'Mutation actor');
508
+ if (normalizedActor !== posture.claimant) {
509
+ throw new WorkflowError('TRANSITION_BLOCKED', `Only active claimant ${posture.claimant} may mutate Task ${task.id}.`, {
510
+ taskId: task.id,
511
+ claimant: posture.claimant,
512
+ actor: normalizedActor,
513
+ });
514
+ }
515
+ if (posture.writerLeaseTokenHash === null) {
516
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} claimed C1 posture is missing a bound writer lease token.`, {
517
+ taskId: task.id,
518
+ claimant: posture.claimant,
519
+ });
520
+ }
521
+ const actualLeaseTokenHash = authority.writerToken ? sha256Hex(authority.writerToken) : null;
522
+ if (posture.writerLeaseTokenHash !== actualLeaseTokenHash) {
523
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} mutation requires the claimant's bound writer lease token.`, {
524
+ taskId: task.id,
525
+ claimant: posture.claimant,
526
+ expectedWriterLeaseTokenHash: posture.writerLeaseTokenHash,
527
+ actualWriterLeaseTokenHash: actualLeaseTokenHash,
528
+ });
529
+ }
530
+ }
531
+
532
+ function appendTaskHandoffEvent(
533
+ store: FileStateStore,
534
+ task: TaskState,
535
+ input: {
536
+ eventType: TaskHandoffEventType;
537
+ actor: string;
538
+ targetActor: string | null;
539
+ writerLeaseTokenHash: string | null;
540
+ reason: string;
541
+ context: Pick<TaskHandoffEvent,
542
+ | 'handoffId'
543
+ | 'milestoneId'
544
+ | 'milestoneDisplayNumber'
545
+ | 'taskDisplayNumber'
546
+ | 'briefHash'
547
+ | 'planHash'
548
+ | 'knowledgeMapRevision'
549
+ | 'knowledgeMapHash'
550
+ | 'dependencyBinding'
551
+ | 'expectedNext'
552
+ | 'delegate'
553
+ | 'grantId'
554
+ | 'claimTokenHash'
555
+ | 'expiresAt'>;
556
+ handback: TaskHandbackRecord | null;
557
+ },
558
+ now: Date,
559
+ ): TaskHandoffEvent {
560
+ return store.appendSidecarEvent<TaskHandoffEvent>(
561
+ store.taskRoot(task.projectId, task.id),
562
+ HANDOFF_SIDECAR,
563
+ {
564
+ eventId: `HOF-${createUlid(now.getTime())}`,
565
+ recordedAt: now.toISOString(),
566
+ ...input.context,
567
+ taskId: task.id,
568
+ taskRevision: task.revision,
569
+ eventType: input.eventType,
570
+ coordinationLevel: COORDINATION_LEVEL,
571
+ actor: input.actor,
572
+ targetActor: input.targetActor,
573
+ machineEvidence: {
574
+ hostname: os.hostname(),
575
+ pid: process.pid,
576
+ platform: process.platform,
577
+ arch: process.arch,
578
+ nodeVersion: process.version,
579
+ },
580
+ writerLeaseTokenHash: input.writerLeaseTokenHash,
581
+ reason: input.reason,
582
+ handback: input.handback,
583
+ },
584
+ {
585
+ validateEvent: (event, context) => validateStoredTaskHandoffEvent(event, task, context.file, context.line),
586
+ },
587
+ );
588
+ }
589
+
590
+ function validateStoredTaskHandoffEvent(
591
+ event: TaskHandoffEvent,
592
+ task: TaskState,
593
+ file: string,
594
+ line: number,
595
+ ): void {
596
+ assertExactTaskHandoffEventShape(event, task, file, line);
597
+ if (event.taskId !== task.id) {
598
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event taskId does not match the current Task.', {
599
+ file,
600
+ line,
601
+ expectedTaskId: task.id,
602
+ actualTaskId: event.taskId,
603
+ });
604
+ }
605
+ if (!Number.isInteger(event.taskRevision) || event.taskRevision < 1) {
606
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event taskRevision must be a positive integer.', {
607
+ file,
608
+ line,
609
+ taskId: task.id,
610
+ taskRevision: event.taskRevision,
611
+ });
612
+ }
613
+ assertNonEmptyStringField(event.handoffId, 'Task handoff handoffId is required.', { file, line, taskId: task.id });
614
+ if (!/^THO-[0-9A-HJKMNP-TV-Z]{26}$/u.test(event.handoffId)) {
615
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff handoffId is invalid.', { file, line, taskId: task.id, handoffId: event.handoffId });
616
+ }
617
+ if (event.milestoneId !== task.milestoneId) {
618
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff milestone binding does not match the Task.', {
619
+ file, line, taskId: task.id, eventMilestoneId: event.milestoneId, taskMilestoneId: task.milestoneId,
620
+ });
621
+ }
622
+ assertStateCorruptHash64(event.briefHash, 'Task handoff briefHash is invalid.', { file, line, taskId: task.id });
623
+ if (event.planHash !== null) assertStateCorruptHash64(event.planHash, 'Task handoff planHash is invalid.', { file, line, taskId: task.id });
624
+ if (event.knowledgeMapHash !== null) {
625
+ assertStateCorruptHash64(event.knowledgeMapHash, 'Task handoff knowledgeMapHash is invalid.', { file, line, taskId: task.id });
626
+ }
627
+ validateDependencyBinding(event.dependencyBinding, event.milestoneId, task, file, line);
628
+ assertNullablePositiveInteger(event.milestoneDisplayNumber, 'Task handoff milestoneDisplayNumber is invalid.', { file, line, taskId: task.id });
629
+ assertNullablePositiveInteger(event.taskDisplayNumber, 'Task handoff taskDisplayNumber is invalid.', { file, line, taskId: task.id });
630
+ assertNullablePositiveInteger(event.knowledgeMapRevision, 'Task handoff knowledgeMapRevision is invalid.', { file, line, taskId: task.id });
631
+ if (event.milestoneId === null && (event.milestoneDisplayNumber !== null || event.taskDisplayNumber !== null)) {
632
+ throw new WorkflowError('STATE_CORRUPT', 'Standalone Task handoff cannot declare Milestone display numbers.', {
633
+ file, line, taskId: task.id,
634
+ });
635
+ }
636
+ assertNonEmptyStringField(event.expectedNext, 'Task handoff expectedNext is required.', { file, line, taskId: task.id });
637
+ assertNonEmptyStringField(event.delegate, 'Task handoff delegate is required.', { file, line, taskId: task.id });
638
+ if (event.grantId !== null && (typeof event.grantId !== 'string' || !event.grantId.trim())) {
639
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff grantId must be null or a non-empty string.', { file, line, taskId: task.id });
640
+ }
641
+ assertStateCorruptHash64(event.claimTokenHash, 'Task handoff claimTokenHash is invalid.', { file, line, taskId: task.id });
642
+ if (!event.expiresAt || Number.isNaN(Date.parse(event.expiresAt))) {
643
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff expiresAt must be an ISO timestamp.', { file, line, taskId: task.id });
644
+ }
645
+ if (!HANDOFF_EVENT_TYPES.has(event.eventType)) {
646
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventType is invalid.', {
647
+ file,
648
+ line,
649
+ taskId: task.id,
650
+ eventType: event.eventType,
651
+ });
652
+ }
653
+ if (event.coordinationLevel !== COORDINATION_LEVEL) {
654
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff coordinationLevel is invalid.', {
655
+ file,
656
+ line,
657
+ taskId: task.id,
658
+ coordinationLevel: event.coordinationLevel,
659
+ });
660
+ }
661
+ if (!event.actor.trim()) {
662
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff actor is required.', {
663
+ file,
664
+ line,
665
+ taskId: task.id,
666
+ });
667
+ }
668
+ if (event.targetActor !== null && !event.targetActor.trim()) {
669
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must be non-empty when present.', {
670
+ file,
671
+ line,
672
+ taskId: task.id,
673
+ });
674
+ }
675
+ if (!event.reason.trim()) {
676
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff reason is required.', {
677
+ file,
678
+ line,
679
+ taskId: task.id,
680
+ });
681
+ }
682
+ if (event.writerLeaseTokenHash !== null) {
683
+ assertStateCorruptHash64(event.writerLeaseTokenHash, 'Task handoff writerLeaseTokenHash is invalid.', {
684
+ file,
685
+ line,
686
+ taskId: task.id,
687
+ writerLeaseTokenHash: event.writerLeaseTokenHash,
688
+ });
689
+ }
690
+ if (event.eventType === 'handoff' && event.targetActor === null) {
691
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event must record targetActor.', {
692
+ file,
693
+ line,
694
+ taskId: task.id,
695
+ eventType: event.eventType,
696
+ });
697
+ }
698
+ if (event.eventType === 'handoff' && event.targetActor !== event.delegate) {
699
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must equal its bound delegate.', {
700
+ file, line, taskId: task.id, targetActor: event.targetActor, delegate: event.delegate,
701
+ });
702
+ }
703
+ if ((event.eventType === 'claim' || event.eventType === 'credential-replace' || event.eventType === 'yield' || event.eventType === 'handback') && event.targetActor !== null) {
704
+ throw new WorkflowError('STATE_CORRUPT', 'Task claim/credential-replace/yield/handback events must not record targetActor.', {
705
+ file,
706
+ line,
707
+ taskId: task.id,
708
+ eventType: event.eventType,
709
+ });
710
+ }
711
+ if (event.eventType === 'handback' && event.handback === null) {
712
+ throw new WorkflowError('STATE_CORRUPT', 'Task handback event must contain the terminal handback bundle.', {
713
+ file, line, taskId: task.id,
714
+ });
715
+ }
716
+ if (event.handback !== null) validateHandbackRecord(event.handback, task, file, line);
717
+ if (event.eventType === 'handback' && event.handback) {
718
+ const expectedReviewAttestationHash = task.review.status === 'pending'
719
+ ? null
720
+ : sha256Hex(JSON.stringify(task.review));
721
+ const currentBindings = {
722
+ status: task.status,
723
+ mergedCommit: task.mergedCommit,
724
+ planHash: task.planHash,
725
+ resultHash: task.resultHash,
726
+ evidenceHash: task.evidenceHash,
727
+ reviewAttestationHash: expectedReviewAttestationHash,
728
+ knowledgeMapRevision: task.knowledgeMapRevision,
729
+ knowledgeMapHash: task.knowledgeMapHash,
730
+ };
731
+ const eventBindings = {
732
+ status: event.handback.status,
733
+ mergedCommit: event.handback.mergedCommit,
734
+ planHash: event.handback.planHash,
735
+ resultHash: event.handback.resultHash,
736
+ evidenceHash: event.handback.evidenceHash,
737
+ reviewAttestationHash: event.handback.reviewAttestationHash,
738
+ knowledgeMapRevision: event.handback.knowledgeMapRevision,
739
+ knowledgeMapHash: event.handback.knowledgeMapHash,
740
+ };
741
+ if (JSON.stringify(currentBindings) !== JSON.stringify(eventBindings)) {
742
+ throw new WorkflowError('STATE_CORRUPT', 'Task handback bundle is stale or inconsistent with terminal Task state.', {
743
+ file, line, taskId: task.id, currentBindings, eventBindings,
744
+ });
745
+ }
746
+ }
747
+ if (event.eventType !== 'handback' && event.handback !== null) {
748
+ throw new WorkflowError('STATE_CORRUPT', 'Only handback events may contain a handback bundle.', {
749
+ file, line, taskId: task.id, eventType: event.eventType,
750
+ });
751
+ }
752
+ }
753
+
754
+ function validateTaskHandoffHistory(events: readonly TaskHandoffEvent[], task: TaskState): void {
755
+ let posture: TaskC1Posture = { state: 'none', latestEvent: null };
756
+ let previousTaskRevision = 0;
757
+ for (const [index, event] of events.entries()) {
758
+ const previous: TaskC1Posture = posture;
759
+ if (event.taskRevision > task.revision) {
760
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event references a future Task revision.', {
761
+ taskId: task.id,
762
+ eventId: event.eventId,
763
+ taskRevision: task.revision,
764
+ eventTaskRevision: event.taskRevision,
765
+ });
766
+ }
767
+ if (event.taskRevision < previousTaskRevision) {
768
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event taskRevision must be nondecreasing through history.', {
769
+ taskId: task.id,
770
+ eventId: event.eventId,
771
+ previousTaskRevision,
772
+ eventTaskRevision: event.taskRevision,
773
+ });
774
+ }
775
+ switch (event.eventType) {
776
+ case 'handoff':
777
+ if (previous.state === 'pending') {
778
+ assertPendingHandoffReplacement(previous.latestEvent, event, task);
779
+ }
780
+ if (previous.state === 'claimed' && previous.claimant !== event.actor.trim()) {
781
+ throw new WorkflowError('STATE_CORRUPT', 'Only the active claimant may issue a new handoff.', {
782
+ taskId: task.id,
783
+ eventId: event.eventId,
784
+ claimant: previous.claimant,
785
+ actor: event.actor,
786
+ });
787
+ }
788
+ if (previous.state === 'claimed' && previous.writerLeaseTokenHash !== event.writerLeaseTokenHash) {
789
+ throw new WorkflowError('STATE_CORRUPT', 'Claimed handoff rewrite must preserve the bound lease token hash.', {
790
+ taskId: task.id,
791
+ eventId: event.eventId,
792
+ claimant: previous.claimant,
793
+ expectedWriterLeaseTokenHash: previous.writerLeaseTokenHash,
794
+ actualWriterLeaseTokenHash: event.writerLeaseTokenHash,
795
+ });
796
+ }
797
+ posture = { state: 'pending', latestEvent: event, targetActor: event.targetActor! };
798
+ break;
799
+ case 'claim':
800
+ if (previous.state !== 'pending') {
801
+ throw new WorkflowError('STATE_CORRUPT', 'Task claim requires a pending handoff.', {
802
+ taskId: task.id,
803
+ eventId: event.eventId,
804
+ previousState: previous.state,
805
+ });
806
+ }
807
+ if (previous.targetActor !== event.actor.trim()) {
808
+ throw new WorkflowError('STATE_CORRUPT', 'Task claim actor must equal the pending handoff target.', {
809
+ taskId: task.id,
810
+ eventId: event.eventId,
811
+ targetActor: previous.targetActor,
812
+ actor: event.actor,
813
+ });
814
+ }
815
+ assertSameHandoffContext(previous.latestEvent, event, task);
816
+ posture = {
817
+ state: 'claimed',
818
+ latestEvent: event,
819
+ claimant: event.actor.trim(),
820
+ writerLeaseTokenHash: event.writerLeaseTokenHash,
821
+ };
822
+ break;
823
+ case 'credential-replace':
824
+ if (previous.state !== 'claimed') {
825
+ throw new WorkflowError('STATE_CORRUPT', 'Task writer credential replacement requires the exact active claimant.', {
826
+ taskId: task.id,
827
+ eventId: event.eventId,
828
+ previousState: previous.state,
829
+ });
830
+ }
831
+ {
832
+ const claimedPrevious = previous as Extract<TaskC1Posture, { state: 'claimed' }>;
833
+ if (claimedPrevious.claimant !== event.actor.trim()) {
834
+ throw new WorkflowError('STATE_CORRUPT', 'Task writer credential replacement actor must equal the active claimant.', {
835
+ taskId: task.id,
836
+ eventId: event.eventId,
837
+ claimant: claimedPrevious.claimant,
838
+ actor: event.actor,
839
+ });
840
+ }
841
+ if (event.writerLeaseTokenHash === null || event.writerLeaseTokenHash === claimedPrevious.writerLeaseTokenHash) {
842
+ throw new WorkflowError('STATE_CORRUPT', 'Task writer credential replacement must bind a distinct non-null lease token hash.', {
843
+ taskId: task.id,
844
+ eventId: event.eventId,
845
+ });
846
+ }
847
+ }
848
+ assertSameHandoffContext(previous.latestEvent, event, task);
849
+ posture = {
850
+ state: 'claimed',
851
+ latestEvent: event,
852
+ claimant: event.actor.trim(),
853
+ writerLeaseTokenHash: event.writerLeaseTokenHash,
854
+ };
855
+ break;
856
+ case 'handback':
857
+ case 'yield':
858
+ if (previous.state !== 'claimed') {
859
+ throw new WorkflowError('STATE_CORRUPT', `Task ${event.eventType} requires an active claim.`, {
860
+ taskId: task.id,
861
+ eventId: event.eventId,
862
+ previousState: previous.state,
863
+ });
864
+ }
865
+ if (previous.claimant !== event.actor.trim()) {
866
+ throw new WorkflowError('STATE_CORRUPT', `Task ${event.eventType} actor must equal the active claimant.`, {
867
+ taskId: task.id,
868
+ eventId: event.eventId,
869
+ claimant: previous.claimant,
870
+ actor: event.actor,
871
+ });
872
+ }
873
+ if (previous.writerLeaseTokenHash !== event.writerLeaseTokenHash) {
874
+ throw new WorkflowError('STATE_CORRUPT', `Task ${event.eventType} must preserve the claimant lease-token binding.`, {
875
+ taskId: task.id,
876
+ eventId: event.eventId,
877
+ expectedWriterLeaseTokenHash: previous.writerLeaseTokenHash,
878
+ actualWriterLeaseTokenHash: event.writerLeaseTokenHash,
879
+ });
880
+ }
881
+ assertSameHandoffContext(previous.latestEvent, event, task);
882
+ if (event.eventType === 'yield' && event.handback !== null) {
883
+ throw new WorkflowError('STATE_CORRUPT', 'Task yield must not contain a terminal handback bundle.', {
884
+ taskId: task.id,
885
+ eventId: event.eventId,
886
+ });
887
+ }
888
+ posture = { state: 'none', latestEvent: event };
889
+ break;
890
+ default:
891
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventType is unknown during history validation.', {
892
+ taskId: task.id,
893
+ eventId: event.eventId,
894
+ eventType: event.eventType,
895
+ });
896
+ }
897
+ const expectedHash = computeSidecarEventHash(event);
898
+ if (expectedHash !== event.eventHash) {
899
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventHash mismatch.', {
900
+ taskId: task.id,
901
+ eventId: event.eventId,
902
+ expectedEventHash: expectedHash,
903
+ actualEventHash: event.eventHash,
904
+ });
905
+ }
906
+ previousTaskRevision = event.taskRevision;
907
+ }
908
+ }
909
+
910
+ function assertPendingHandoffReplacement(
911
+ previous: TaskHandoffEvent,
912
+ replacement: TaskHandoffEvent,
913
+ task: TaskState,
914
+ ): void {
915
+ const fixedPrevious = {
916
+ taskId: previous.taskId,
917
+ taskRevision: previous.taskRevision,
918
+ milestoneId: previous.milestoneId,
919
+ milestoneDisplayNumber: previous.milestoneDisplayNumber,
920
+ taskDisplayNumber: previous.taskDisplayNumber,
921
+ briefHash: previous.briefHash,
922
+ planHash: previous.planHash,
923
+ knowledgeMapRevision: previous.knowledgeMapRevision,
924
+ knowledgeMapHash: previous.knowledgeMapHash,
925
+ expectedNext: previous.expectedNext,
926
+ delegate: previous.delegate,
927
+ grantId: previous.grantId,
928
+ actor: previous.actor.trim(),
929
+ targetActor: previous.targetActor,
930
+ };
931
+ const fixedReplacement = {
932
+ taskId: replacement.taskId,
933
+ taskRevision: replacement.taskRevision,
934
+ milestoneId: replacement.milestoneId,
935
+ milestoneDisplayNumber: replacement.milestoneDisplayNumber,
936
+ taskDisplayNumber: replacement.taskDisplayNumber,
937
+ briefHash: replacement.briefHash,
938
+ planHash: replacement.planHash,
939
+ knowledgeMapRevision: replacement.knowledgeMapRevision,
940
+ knowledgeMapHash: replacement.knowledgeMapHash,
941
+ expectedNext: replacement.expectedNext,
942
+ delegate: replacement.delegate,
943
+ grantId: replacement.grantId,
944
+ actor: replacement.actor.trim(),
945
+ targetActor: replacement.targetActor,
946
+ };
947
+ if (JSON.stringify(fixedPrevious) !== JSON.stringify(fixedReplacement)) {
948
+ throw new WorkflowError('STATE_CORRUPT', 'Pending C1 handoff credential replacement changed its authority or Task context.', {
949
+ taskId: task.id,
950
+ previousEventId: previous.eventId,
951
+ replacementEventId: replacement.eventId,
952
+ });
953
+ }
954
+ }
955
+
956
+ function assertSameHandoffContext(
957
+ expected: TaskHandoffEvent,
958
+ actual: TaskHandoffEvent,
959
+ task: TaskState,
960
+ ): void {
961
+ const expectedContext = contextFromEvent(expected);
962
+ const actualContext = contextFromEvent(actual);
963
+ if (JSON.stringify(expectedContext) !== JSON.stringify(actualContext)) {
964
+ throw new WorkflowError('STATE_CORRUPT', 'C1 claim/handback changed its prepared handoff context.', {
965
+ taskId: task.id,
966
+ handoffId: expected.handoffId,
967
+ eventId: actual.eventId,
968
+ });
969
+ }
970
+ }
971
+
972
+ function parseTaskHandoffEvents(content: string, task: TaskState, file: string): TaskHandoffEvent[] {
973
+ if (content.length === 0) {
974
+ throw new WorkflowError('STATE_CORRUPT', `Task handoff sidecar must not be empty: ${file}`);
975
+ }
976
+ const rawLines = content.split('\n');
977
+ if (rawLines.at(-1) === '') rawLines.pop();
978
+ if (rawLines.some((line) => line.length === 0)) {
979
+ throw new WorkflowError('STATE_CORRUPT', `Task handoff sidecar contains a blank line: ${file}`);
980
+ }
981
+ const events: TaskHandoffEvent[] = [];
982
+ const eventIds = new Set<string>();
983
+ let previousEventHash: string | null = null;
984
+ for (const [index, rawLine] of rawLines.entries()) {
985
+ let parsed: unknown;
986
+ try {
987
+ parsed = JSON.parse(rawLine);
988
+ } catch (error) {
989
+ throw new WorkflowError('STATE_CORRUPT', `Invalid task handoff JSONL: ${file}`, {
990
+ line: index + 1,
991
+ cause: error instanceof Error ? error.message : String(error),
992
+ });
993
+ }
994
+ const event = parsed as TaskHandoffEvent;
995
+ validateStoredTaskHandoffEvent(event, task, file, index + 1);
996
+ if (eventIds.has(event.eventId)) {
997
+ throw new WorkflowError('STATE_CORRUPT', `Duplicate task handoff event id in ${file}`, {
998
+ line: index + 1,
999
+ eventId: event.eventId,
1000
+ });
1001
+ }
1002
+ eventIds.add(event.eventId);
1003
+ if (event.previousEventHash !== previousEventHash) {
1004
+ throw new WorkflowError('STATE_CORRUPT', `Broken task handoff chain in ${file}`, {
1005
+ line: index + 1,
1006
+ expectedPreviousEventHash: previousEventHash,
1007
+ actualPreviousEventHash: event.previousEventHash,
1008
+ });
1009
+ }
1010
+ const expectedEventHash = computeSidecarEventHash(event);
1011
+ if (event.eventHash !== expectedEventHash) {
1012
+ throw new WorkflowError('STATE_CORRUPT', `Task handoff event hash mismatch in ${file}`, {
1013
+ line: index + 1,
1014
+ eventId: event.eventId,
1015
+ expectedEventHash,
1016
+ actualEventHash: event.eventHash,
1017
+ });
1018
+ }
1019
+ previousEventHash = event.eventHash;
1020
+ events.push(event);
1021
+ }
1022
+ return events;
1023
+ }
1024
+
1025
+ function assertExactTaskHandoffEventShape(
1026
+ event: TaskHandoffEvent,
1027
+ task: TaskState,
1028
+ file: string,
1029
+ line: number,
1030
+ ): void {
1031
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
1032
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event must be an object.', {
1033
+ file,
1034
+ line,
1035
+ taskId: task.id,
1036
+ });
1037
+ }
1038
+ const allowedKeys = new Set([
1039
+ 'eventId',
1040
+ 'previousEventHash',
1041
+ 'recordedAt',
1042
+ 'eventHash',
1043
+ 'handoffId',
1044
+ 'taskId',
1045
+ 'taskRevision',
1046
+ 'milestoneId',
1047
+ 'milestoneDisplayNumber',
1048
+ 'taskDisplayNumber',
1049
+ 'briefHash',
1050
+ 'planHash',
1051
+ 'knowledgeMapRevision',
1052
+ 'knowledgeMapHash',
1053
+ 'dependencyBinding',
1054
+ 'expectedNext',
1055
+ 'delegate',
1056
+ 'grantId',
1057
+ 'claimTokenHash',
1058
+ 'expiresAt',
1059
+ 'eventType',
1060
+ 'coordinationLevel',
1061
+ 'actor',
1062
+ 'targetActor',
1063
+ 'machineEvidence',
1064
+ 'writerLeaseTokenHash',
1065
+ 'reason',
1066
+ 'handback',
1067
+ ]);
1068
+ const extraKeys = Object.keys(event as unknown as Record<string, unknown>).filter((key) => !allowedKeys.has(key));
1069
+ if (extraKeys.length > 0) {
1070
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff event contains unexpected fields.', {
1071
+ file,
1072
+ line,
1073
+ taskId: task.id,
1074
+ extraKeys,
1075
+ });
1076
+ }
1077
+ assertNonEmptyStringField(event.eventId, 'Task handoff eventId must be a non-empty string.', {
1078
+ file,
1079
+ line,
1080
+ taskId: task.id,
1081
+ eventId: event.eventId,
1082
+ });
1083
+ if (event.previousEventHash !== null && typeof event.previousEventHash !== 'string') {
1084
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff previousEventHash must be null or a SHA-256 hash string.', {
1085
+ file,
1086
+ line,
1087
+ taskId: task.id,
1088
+ previousEventHash: event.previousEventHash,
1089
+ });
1090
+ }
1091
+ if (event.previousEventHash !== null) {
1092
+ assertStateCorruptHash64(event.previousEventHash, 'Task handoff previousEventHash must be a SHA-256 hash string.', {
1093
+ file,
1094
+ line,
1095
+ taskId: task.id,
1096
+ previousEventHash: event.previousEventHash,
1097
+ });
1098
+ }
1099
+ assertNonEmptyStringField(event.eventHash, 'Task handoff eventHash must be a SHA-256 hash string.', {
1100
+ file,
1101
+ line,
1102
+ taskId: task.id,
1103
+ eventHash: event.eventHash,
1104
+ });
1105
+ assertStateCorruptHash64(event.eventHash, 'Task handoff eventHash must be a SHA-256 hash string.', {
1106
+ file,
1107
+ line,
1108
+ taskId: task.id,
1109
+ eventHash: event.eventHash,
1110
+ });
1111
+ assertNonEmptyStringField(event.taskId, 'Task handoff taskId must be a non-empty string.', {
1112
+ file,
1113
+ line,
1114
+ taskId: task.id,
1115
+ eventTaskId: event.taskId,
1116
+ });
1117
+ if (typeof event.eventType !== 'string') {
1118
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff eventType must be a string.', {
1119
+ file,
1120
+ line,
1121
+ taskId: task.id,
1122
+ eventType: event.eventType,
1123
+ });
1124
+ }
1125
+ if (typeof event.coordinationLevel !== 'string') {
1126
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff coordinationLevel must be a string.', {
1127
+ file,
1128
+ line,
1129
+ taskId: task.id,
1130
+ coordinationLevel: event.coordinationLevel,
1131
+ });
1132
+ }
1133
+ assertNonEmptyStringField(event.actor, 'Task handoff actor must be a non-empty string.', {
1134
+ file,
1135
+ line,
1136
+ taskId: task.id,
1137
+ actor: event.actor,
1138
+ });
1139
+ if (event.targetActor !== null && typeof event.targetActor !== 'string') {
1140
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must be null or a non-empty string.', {
1141
+ file,
1142
+ line,
1143
+ taskId: task.id,
1144
+ targetActor: event.targetActor,
1145
+ });
1146
+ }
1147
+ if (event.targetActor !== null && !event.targetActor.trim()) {
1148
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff targetActor must be non-empty when present.', {
1149
+ file,
1150
+ line,
1151
+ taskId: task.id,
1152
+ targetActor: event.targetActor,
1153
+ });
1154
+ }
1155
+ if (event.writerLeaseTokenHash !== null && typeof event.writerLeaseTokenHash !== 'string') {
1156
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff writerLeaseTokenHash must be null or a SHA-256 hash string.', {
1157
+ file,
1158
+ line,
1159
+ taskId: task.id,
1160
+ writerLeaseTokenHash: event.writerLeaseTokenHash,
1161
+ });
1162
+ }
1163
+ assertNonEmptyStringField(event.reason, 'Task handoff reason must be a non-empty string.', {
1164
+ file,
1165
+ line,
1166
+ taskId: task.id,
1167
+ reason: event.reason,
1168
+ });
1169
+ if (typeof event.recordedAt !== 'string' || !event.recordedAt.trim() || Number.isNaN(Date.parse(event.recordedAt))) {
1170
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff recordedAt must be an ISO timestamp string.', {
1171
+ file,
1172
+ line,
1173
+ taskId: task.id,
1174
+ recordedAt: event.recordedAt,
1175
+ });
1176
+ }
1177
+ if (!event.machineEvidence || typeof event.machineEvidence !== 'object' || Array.isArray(event.machineEvidence)) {
1178
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff machineEvidence is invalid.', {
1179
+ file,
1180
+ line,
1181
+ taskId: task.id,
1182
+ });
1183
+ }
1184
+ const machineEvidence = event.machineEvidence as unknown as Record<string, unknown>;
1185
+ const allowedMachineKeys = new Set(['hostname', 'pid', 'platform', 'arch', 'nodeVersion']);
1186
+ const extraMachineKeys = Object.keys(machineEvidence).filter((key) => !allowedMachineKeys.has(key));
1187
+ if (extraMachineKeys.length > 0) {
1188
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff machineEvidence contains unexpected fields.', {
1189
+ file,
1190
+ line,
1191
+ taskId: task.id,
1192
+ extraMachineKeys,
1193
+ });
1194
+ }
1195
+ if (
1196
+ typeof machineEvidence.hostname !== 'string'
1197
+ || !machineEvidence.hostname.trim()
1198
+ || !Number.isInteger(machineEvidence.pid)
1199
+ || (machineEvidence.pid as number) < 1
1200
+ || typeof machineEvidence.platform !== 'string'
1201
+ || !machineEvidence.platform.trim()
1202
+ || typeof machineEvidence.arch !== 'string'
1203
+ || !machineEvidence.arch.trim()
1204
+ || typeof machineEvidence.nodeVersion !== 'string'
1205
+ || !machineEvidence.nodeVersion.trim()
1206
+ ) {
1207
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff machineEvidence is invalid.', {
1208
+ file,
1209
+ line,
1210
+ taskId: task.id,
1211
+ });
1212
+ }
1213
+ }
1214
+
1215
+ function assertTaskSupportsC1Handoff(task: TaskState): void {
1216
+ if (!NON_TERMINAL_TASK_STATUSES.has(task.status)) {
1217
+ throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} does not support C1 handoff while ${task.status}.`, {
1218
+ taskId: task.id,
1219
+ taskStatus: task.status,
1220
+ });
1221
+ }
1222
+ }
1223
+
1224
+ function normalizeActor(value: string, label: string): string {
1225
+ if (typeof value !== 'string' || !value.trim()) {
1226
+ throw new WorkflowError('INVALID_ARGUMENT', `${label} is required.`);
1227
+ }
1228
+ return value.trim();
1229
+ }
1230
+
1231
+ function normalizeReason(value: string): string {
1232
+ if (typeof value !== 'string' || !value.trim()) {
1233
+ throw new WorkflowError('INVALID_ARGUMENT', 'C1 handoff reason is required.');
1234
+ }
1235
+ return value.trim();
1236
+ }
1237
+
1238
+ function normalizeFutureExpiry(value: string, now: Date): string {
1239
+ const parsed = new Date(value);
1240
+ if (!Number.isFinite(parsed.getTime()) || parsed.getTime() <= now.getTime()) {
1241
+ throw new WorkflowError('INVALID_ARGUMENT', 'C1 handoff expiresAt must be a future ISO timestamp.');
1242
+ }
1243
+ return parsed.toISOString();
1244
+ }
1245
+
1246
+ function normalizeStringList(value: readonly string[], label: string): string[] {
1247
+ if (!Array.isArray(value)) throw new WorkflowError('INVALID_ARGUMENT', `${label} must be an array.`);
1248
+ const normalized = value.map((entry) => normalizeReason(entry));
1249
+ if (new Set(normalized).size !== normalized.length) {
1250
+ throw new WorkflowError('INVALID_ARGUMENT', `${label} must contain unique values.`);
1251
+ }
1252
+ return normalized;
1253
+ }
1254
+
1255
+ function contextFromEvent(event: TaskHandoffEvent): Pick<TaskHandoffEvent,
1256
+ | 'handoffId'
1257
+ | 'milestoneId'
1258
+ | 'milestoneDisplayNumber'
1259
+ | 'taskDisplayNumber'
1260
+ | 'briefHash'
1261
+ | 'planHash'
1262
+ | 'knowledgeMapRevision'
1263
+ | 'knowledgeMapHash'
1264
+ | 'dependencyBinding'
1265
+ | 'expectedNext'
1266
+ | 'delegate'
1267
+ | 'grantId'
1268
+ | 'claimTokenHash'
1269
+ | 'expiresAt'> {
1270
+ return {
1271
+ handoffId: event.handoffId,
1272
+ milestoneId: event.milestoneId,
1273
+ milestoneDisplayNumber: event.milestoneDisplayNumber,
1274
+ taskDisplayNumber: event.taskDisplayNumber,
1275
+ briefHash: event.briefHash,
1276
+ planHash: event.planHash,
1277
+ knowledgeMapRevision: event.knowledgeMapRevision,
1278
+ knowledgeMapHash: event.knowledgeMapHash,
1279
+ dependencyBinding: event.dependencyBinding ?? null,
1280
+ expectedNext: event.expectedNext,
1281
+ delegate: event.delegate,
1282
+ grantId: event.grantId,
1283
+ claimTokenHash: event.claimTokenHash,
1284
+ expiresAt: event.expiresAt,
1285
+ };
1286
+ }
1287
+
1288
+ function validateDependencyBinding(
1289
+ binding: MilestoneDependencyBinding | null | undefined,
1290
+ milestoneId: string | null,
1291
+ task: TaskState,
1292
+ file: string,
1293
+ line: number,
1294
+ ): void {
1295
+ if (binding === undefined) return;
1296
+ if (milestoneId === null) {
1297
+ if (binding !== null) {
1298
+ throw new WorkflowError('STATE_CORRUPT', 'Standalone Task handoff dependencyBinding must be null.', {
1299
+ file, line, taskId: task.id,
1300
+ });
1301
+ }
1302
+ return;
1303
+ }
1304
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding)) {
1305
+ throw new WorkflowError('STATE_CORRUPT', 'Milestone Task handoff dependencyBinding must be an object.', {
1306
+ file, line, taskId: task.id,
1307
+ });
1308
+ }
1309
+ const extraKeys = Object.keys(binding).filter((key) => ![
1310
+ 'milestoneRevision',
1311
+ 'membershipRevision',
1312
+ 'milestonePlanHash',
1313
+ ].includes(key));
1314
+ if (
1315
+ extraKeys.length > 0
1316
+ || !Number.isInteger(binding.milestoneRevision)
1317
+ || binding.milestoneRevision < 1
1318
+ || !Number.isInteger(binding.membershipRevision)
1319
+ || binding.membershipRevision < 1
1320
+ || !/^[0-9a-f]{64}$/u.test(binding.milestonePlanHash)
1321
+ ) {
1322
+ throw new WorkflowError('STATE_CORRUPT', 'Task handoff dependencyBinding is invalid.', {
1323
+ file, line, taskId: task.id, dependencyBinding: binding, extraKeys,
1324
+ });
1325
+ }
1326
+ }
1327
+
1328
+ function dependencyBindingsEqual(
1329
+ left: MilestoneDependencyBinding | null,
1330
+ right: MilestoneDependencyBinding | null,
1331
+ ): boolean {
1332
+ if (left === null || right === null) return left === right;
1333
+ return left.milestoneRevision === right.milestoneRevision
1334
+ && left.membershipRevision === right.membershipRevision
1335
+ && left.milestonePlanHash === right.milestonePlanHash;
1336
+ }
1337
+
1338
+ function buildTaskHandoffPrompt(task: TaskState, expectedNext: string, delegate: string): string {
1339
+ return [
1340
+ `You are ${delegate}, the claimed executor for Task ${task.id}.`,
1341
+ `Work only from Task revision ${task.revision} and Plan ${task.planHash ?? '<unset>'}.`,
1342
+ `Expected next action: ${expectedNext}.`,
1343
+ 'Use the claim-bound writer lease for every mutation.',
1344
+ 'On completion, create a handback containing terminal status, result/evidence bindings, limitations, and recommended next action.',
1345
+ ].join('\n');
1346
+ }
1347
+
1348
+ function assertNonEmptyStringField(
1349
+ value: unknown,
1350
+ message: string,
1351
+ context: Record<string, unknown>,
1352
+ ): asserts value is string {
1353
+ if (typeof value !== 'string' || !value.trim()) {
1354
+ throw new WorkflowError('STATE_CORRUPT', message, context);
1355
+ }
1356
+ }
1357
+
1358
+ function assertStateCorruptHash64(
1359
+ value: string,
1360
+ message: string,
1361
+ context: Record<string, unknown>,
1362
+ ): void {
1363
+ if (!/^[0-9a-f]{64}$/u.test(value)) {
1364
+ throw new WorkflowError('STATE_CORRUPT', message, context);
1365
+ }
1366
+ }
1367
+
1368
+ function assertNullablePositiveInteger(
1369
+ value: unknown,
1370
+ message: string,
1371
+ context: Record<string, unknown>,
1372
+ ): void {
1373
+ if (value !== null && (!Number.isInteger(value) || Number(value) < 1)) {
1374
+ throw new WorkflowError('STATE_CORRUPT', message, { ...context, value });
1375
+ }
1376
+ }
1377
+
1378
+ function validateHandbackRecord(
1379
+ record: TaskHandbackRecord,
1380
+ task: TaskState,
1381
+ file: string,
1382
+ line: number,
1383
+ ): void {
1384
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
1385
+ throw new WorkflowError('STATE_CORRUPT', 'Task handback bundle must be an object.', { file, line, taskId: task.id });
1386
+ }
1387
+ const allowedKeys = new Set([
1388
+ 'status', 'mergedCommit', 'planHash', 'resultHash', 'evidenceHash', 'reviewAttestationHash',
1389
+ 'knowledgeMapRevision', 'knowledgeMapHash', 'limitations', 'recommendedNext',
1390
+ ]);
1391
+ const extraKeys = Object.keys(record as unknown as Record<string, unknown>).filter((key) => !allowedKeys.has(key));
1392
+ if (extraKeys.length > 0 || (record.status !== 'merged' && record.status !== 'cancelled')) {
1393
+ throw new WorkflowError('STATE_CORRUPT', 'Task handback bundle has invalid shape or status.', {
1394
+ file, line, taskId: task.id, extraKeys, status: record.status,
1395
+ });
1396
+ }
1397
+ if (record.mergedCommit !== null && !/^[0-9a-f]{40,64}$/u.test(record.mergedCommit)) {
1398
+ throw new WorkflowError('STATE_CORRUPT', 'Task handback mergedCommit is invalid.', {
1399
+ file, line, taskId: task.id, mergedCommit: record.mergedCommit,
1400
+ });
1401
+ }
1402
+ for (const [label, value] of [
1403
+ ['planHash', record.planHash],
1404
+ ['resultHash', record.resultHash],
1405
+ ['evidenceHash', record.evidenceHash],
1406
+ ['reviewAttestationHash', record.reviewAttestationHash],
1407
+ ['knowledgeMapHash', record.knowledgeMapHash],
1408
+ ] as const) {
1409
+ if (value !== null) assertStateCorruptHash64(value, `Task handback ${label} is invalid.`, { file, line, taskId: task.id });
1410
+ }
1411
+ assertNullablePositiveInteger(record.knowledgeMapRevision, 'Task handback knowledgeMapRevision is invalid.', { file, line, taskId: task.id });
1412
+ if (!Array.isArray(record.limitations) || record.limitations.some((value) => typeof value !== 'string')) {
1413
+ throw new WorkflowError('STATE_CORRUPT', 'Task handback limitations must be a string array.', { file, line, taskId: task.id });
1414
+ }
1415
+ assertNonEmptyStringField(record.recommendedNext, 'Task handback recommendedNext is required.', { file, line, taskId: task.id });
1416
+ }
1417
+
1418
+ function resolveAlpha6TaskRoot(store: FileStateStore, task: TaskState): string {
1419
+ const projectRootLogical = store.projectRoot(task.projectId);
1420
+ const trustedProjectRoot = existsSync(projectRootLogical) ? realpathSync(projectRootLogical) : path.resolve(projectRootLogical);
1421
+ const tasksDirLogical = path.join(projectRootLogical, 'tasks');
1422
+ const trustedTasksDir = existsSync(tasksDirLogical)
1423
+ ? realpathSync(tasksDirLogical)
1424
+ : path.join(trustedProjectRoot, 'tasks');
1425
+ const taskRootLogical = store.taskRoot(task.projectId, task.id);
1426
+ assertNoSymlinkPathComponents(trustedTasksDir, { label: 'Trusted tasks directory', requireLeaf: true });
1427
+ if (existsSync(taskRootLogical) && lstatSync(taskRootLogical).isSymbolicLink()) {
1428
+ throw new WorkflowError('STATE_CORRUPT', `Task root must not be a symlink: ${taskRootLogical}`);
1429
+ }
1430
+ const resolvedTaskRoot = existsSync(taskRootLogical)
1431
+ ? realpathSync(taskRootLogical)
1432
+ : path.resolve(trustedTasksDir, task.id);
1433
+ if (
1434
+ resolvedTaskRoot !== trustedTasksDir
1435
+ && !resolvedTaskRoot.startsWith(`${trustedTasksDir}${path.sep}`)
1436
+ ) {
1437
+ throw new WorkflowError('STATE_CORRUPT', `Task root escaped the trusted tasks directory: ${taskRootLogical}`, {
1438
+ trustedTasksDir,
1439
+ resolvedTaskRoot,
1440
+ });
1441
+ }
1442
+ return resolvedTaskRoot;
1443
+ }