@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.111

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 (162) hide show
  1. package/CHANGELOG.md +526 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/native-hook-ingress.js +48 -0
  9. package/dist/cli/run-cli.js +1465 -384
  10. package/dist/cli.js +8 -2
  11. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  12. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  13. package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
  14. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  15. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  16. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  17. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  18. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  19. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  20. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  22. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
  23. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  25. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  26. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  27. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  28. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +394 -0
  30. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  31. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  32. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  36. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  37. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  38. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  39. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  40. package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
  41. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  42. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
  43. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  44. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  45. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  46. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  47. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  48. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  49. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  50. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  51. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  52. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  53. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  54. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  55. package/dist/protocol/local-files.js +18 -9
  56. package/dist/runtime/context.js +2 -2
  57. package/dist/schemas/agent-profile.schema.json +1 -1
  58. package/dist/schemas/code-review-result.schema.json +1 -1
  59. package/dist/schemas/code-work-batch.schema.json +3 -3
  60. package/dist/schemas/code-work-result.schema.json +3 -3
  61. package/dist/schemas/plan-review-result.schema.json +2 -2
  62. package/dist/schemas/run-control-receipt.schema.json +99 -0
  63. package/dist/schemas/run-control-request.schema.json +36 -0
  64. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  65. package/dist/services/canon.js +11 -8
  66. package/dist/services/cleanup.js +132 -39
  67. package/dist/services/cli-operation-classifier.js +22 -24
  68. package/dist/services/code-checks.js +589 -106
  69. package/dist/services/codex-hook-delivery.js +28 -0
  70. package/dist/services/command-context.js +80 -0
  71. package/dist/services/config.js +11 -8
  72. package/dist/services/continuation-outcome.js +20 -0
  73. package/dist/services/controller-fanout.js +173 -0
  74. package/dist/services/dashboard.js +103 -31
  75. package/dist/services/engines.js +121 -76
  76. package/dist/services/eval-snapshots.js +994 -97
  77. package/dist/services/execution-policy.js +181 -0
  78. package/dist/services/external-work-launch.js +166 -0
  79. package/dist/services/harness-adapter.js +147 -24
  80. package/dist/services/harness-config.js +2 -0
  81. package/dist/services/hooks.js +702 -327
  82. package/dist/services/lanes.js +78 -65
  83. package/dist/services/lifecycle-command.js +113 -18
  84. package/dist/services/lifecycle-contract.js +30 -0
  85. package/dist/services/lifecycle-invocations.js +1379 -0
  86. package/dist/services/managed-daemon-binding.js +38 -0
  87. package/dist/services/managed-processes.js +302 -34
  88. package/dist/services/merge-queue.js +228 -149
  89. package/dist/services/merge-server.js +66 -28
  90. package/dist/services/migrations.js +37 -16
  91. package/dist/services/native-daemon-history.js +49 -0
  92. package/dist/services/native-session-control.js +39 -0
  93. package/dist/services/plan-runtime.js +26 -11
  94. package/dist/services/plans.js +28 -25
  95. package/dist/services/projects.js +20 -15
  96. package/dist/services/prompts.js +25 -16
  97. package/dist/services/protocols.js +160 -86
  98. package/dist/services/recovery-observation-budget.js +61 -0
  99. package/dist/services/recovery-snapshot-database.js +142 -0
  100. package/dist/services/repair-intents.js +40 -0
  101. package/dist/services/review-copy.js +136 -0
  102. package/dist/services/run-control-receipt.js +56 -0
  103. package/dist/services/run-control-worker.js +351 -0
  104. package/dist/services/run-control.js +888 -0
  105. package/dist/services/run-controller-adapter.js +231 -0
  106. package/dist/services/run-controller-capture.js +167 -0
  107. package/dist/services/run-controller-process.js +180 -0
  108. package/dist/services/run-controller-recovery.js +221 -0
  109. package/dist/services/run-controller-state.js +36 -0
  110. package/dist/services/run-controller.js +798 -0
  111. package/dist/services/run-engine-bindings.js +1 -1
  112. package/dist/services/run-fork.js +142 -0
  113. package/dist/services/run-observations.js +112 -0
  114. package/dist/services/run-projection.js +22 -64
  115. package/dist/services/run-recovery-runtime.js +80 -0
  116. package/dist/services/run-recovery.js +345 -0
  117. package/dist/services/runs.js +536 -181
  118. package/dist/services/runtime-budget.js +413 -0
  119. package/dist/services/runtime-command.js +43 -0
  120. package/dist/services/runtime-scope-capture.js +52 -0
  121. package/dist/services/runtime-scope-control.js +460 -0
  122. package/dist/services/runtime-scope-resume.js +577 -0
  123. package/dist/services/runtime-scope-stop.js +159 -0
  124. package/dist/services/runtime-scope-worker.js +242 -0
  125. package/dist/services/runtime-service.js +97 -0
  126. package/dist/services/schema-validation.js +10 -8
  127. package/dist/services/scope-writer-cleanup.js +26 -0
  128. package/dist/services/sessions.js +35 -69
  129. package/dist/services/stage-blocker.js +17 -7
  130. package/dist/services/stage-context.js +50 -19
  131. package/dist/services/stage-lifecycle.js +100 -103
  132. package/dist/services/stage-pause.js +88 -75
  133. package/dist/services/stage-report-renderer.js +116 -6
  134. package/dist/services/stage-work-graph.js +35 -0
  135. package/dist/services/usage.js +35 -98
  136. package/dist/services/vnext-code-review.js +197 -84
  137. package/dist/services/vnext-code.js +278 -251
  138. package/dist/services/vnext-execution-profile.js +5 -3
  139. package/dist/services/vnext-fanout.js +172 -17
  140. package/dist/services/vnext-merge.js +345 -111
  141. package/dist/services/vnext-plan-review.js +171 -111
  142. package/dist/services/vnext-plan.js +180 -87
  143. package/dist/services/vnext-protocolize.js +320 -74
  144. package/dist/services/vnext-specify.js +115 -77
  145. package/dist/services/work-registry.js +1001 -187
  146. package/dist/services/workspace-bootstrap.js +76 -0
  147. package/dist/services/workspace-files.js +83 -0
  148. package/dist/services/worktrees.js +88 -36
  149. package/dist/shared/entity-references.js +8 -0
  150. package/dist/shared/errors.js +12 -0
  151. package/dist/storage/database.js +692 -35
  152. package/dist/storage/foreign-key-transaction.js +39 -0
  153. package/dist/storage/immutable-input.js +45 -0
  154. package/dist/storage/import-guard.js +102 -0
  155. package/dist/storage/native-cleanup.js +52 -0
  156. package/dist/storage/paths.js +17 -4
  157. package/dist/storage/projection-lock.js +73 -0
  158. package/dist/storage/work-references.js +23 -0
  159. package/dist/storage/writer-contract.js +77 -0
  160. package/dist/storage/writer-migration.js +85 -0
  161. package/package.json +24 -13
  162. package/tools/repair-paused-run-status.mjs +84 -0
@@ -1,33 +1,144 @@
1
+ import { bindLifecyclePacketAssignments, managedLifecycleCommand } from "./lifecycle-invocations.js";
2
+ import { bindStageWork, currentStageWorkIds, stageWorkIds } from "./stage-work-graph.js";
3
+ import { runtimeCommand } from "./runtime-command.js";
1
4
  import crypto from "node:crypto";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import { AppError } from "../shared/errors.js";
5
- import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
8
+ import { entityReferenceVariants, shortEntityReference } from "../shared/entity-references.js";
9
+ import { resolveWorkReference } from "../storage/work-references.js";
10
+ import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
6
11
  import { validateSchema } from "./schema-validation.js";
7
12
  import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
8
13
  import { refreshRunSessionProjection } from "./run-projection.js";
9
- import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
14
+ import { ignoredWorkspacePaths, normalizeWorkspacePath, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
10
15
  import { nextWorkId, nextWorkIds } from "./ids.js";
11
16
  import { appendFlowRunTimelineEvent } from "./runs.js";
17
+ import { resolveStageTransition } from "./execution-policy.js";
18
+ import { appendAudit } from "./audit.js";
12
19
  import { flowCommand } from "./stage-pause.js";
13
20
  import { assertPortableArtifactRef } from "./portable-refs.js";
14
21
  import { publicSessionIdentity } from "./session-identity.js";
22
+ import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
23
+ import { runControlDirective } from "./run-control.js";
24
+ import { prepareError, prepareJsonFile } from "../cli/input-preparation.js";
25
+ import { lifecycleRetryInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
26
+ import { acceptedDecisionPrecedence } from "./stage-context.js";
27
+ import { reviewEnvironmentAllowed, reviewExecutionCopyHash, reviewSourceHash, reviewSourcePaths } from "./review-copy.js";
15
28
  const workColumns = "work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, created_at, started_at, updated_at, completed_at";
16
29
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
30
+ function ownsPhysicalWorkSession(context, projectId, owner, identity) {
31
+ if (identity.sessionId !== owner.session_id)
32
+ return false;
33
+ const binding = owner.hook_event_id ? context.db.get("SELECT daemon_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, owner.hook_event_id]) : undefined;
34
+ // Legacy non-daemon sessions remain usable. Once a physical daemon was
35
+ // captured, another process cannot inherit authority merely by resuming
36
+ // the same provider Session ID.
37
+ return !binding?.daemon_id || binding.daemon_id === identity.daemonId;
38
+ }
39
+ /**
40
+ * A Stage terminal command is allowed only from the Session that owns its
41
+ * running coordinator Work. A hook receipt proves the caller; a WorkSession
42
+ * proves the owner. Either fact alone is insufficient.
43
+ */
44
+ export function assertStageLifecycleOwner(context, input) {
45
+ ensureWorkRegistry(context);
46
+ const projectRoot = resolveProjectRoot(input.projectRoot);
47
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
48
+ if (!project)
49
+ throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
50
+ requireRun(context, project.id, input.runId);
51
+ const scopedWork = input.workId ? requireWork(context, input.workId, { projectId: project.id, runId: input.runId }) : undefined;
52
+ let owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
53
+ FROM work_sessions ws
54
+ JOIN works w ON w.work_id = ws.work_id
55
+ JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
56
+ WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
57
+ AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
58
+ ${scopedWork ? "AND w.work_id = ?" : ""}
59
+ ORDER BY ws.created_at DESC LIMIT 1`, scopedWork ? [project.id, input.runId, input.stage, scopedWork.work_id] : [project.id, input.runId, input.stage]);
60
+ if (!owner && input.operation === "stage_finish" && ["code", "code-review"].includes(input.stage)) {
61
+ const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
62
+ const index = row ? JSON.parse(row.index_json) : null;
63
+ const completed = index?.stage_runs.find(entry => entry.stage === input.stage && entry.status === "done");
64
+ if (row && completed && (index?.current_stage ?? index?.stage_runs.at(-1)?.stage) === input.stage)
65
+ owner = context.db.get("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NULL AND w.status = 'completed' AND ws.status = 'completed' AND ws.result_path = ? ORDER BY ws.created_at DESC LIMIT 1", [project.id, input.runId, path.join(row.run_root, completed.dir, "stage-report.json")]);
66
+ }
67
+ if (!owner && input.operation === "stage_finish" && input.stage === "protocolize") {
68
+ const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
69
+ const index = row ? JSON.parse(row.index_json) : null;
70
+ const completed = index?.stage_runs.find(entry => entry.stage === input.stage && entry.status === "done");
71
+ const root = row && completed ? path.join(row.run_root, completed.dir) : null;
72
+ const accepted = root && completed?.attempt && context.db.get("SELECT 1 FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_report' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.stage') = ? AND json_extract(payload_json, '$.attempt') = ? AND json_extract(payload_json, '$.root') = ? LIMIT 1", [project.id, input.runId, input.stage, completed.attempt, root]);
73
+ if (accepted && completed?.data) {
74
+ owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id FROM work_sessions ws
75
+ JOIN works w ON w.work_id = ws.work_id
76
+ WHERE w.project_id = ? AND w.run_id = ?
77
+ AND w.parent_work_id IS NULL AND ws.status = 'completed'
78
+ AND ws.result_path = ? ${scopedWork ? "AND w.work_id = ?" : ""}
79
+ ORDER BY ws.created_at DESC LIMIT 1`, scopedWork
80
+ ? [project.id, input.runId, path.join(root, completed.data), scopedWork.work_id]
81
+ : [project.id, input.runId, path.join(root, completed.data)]);
82
+ }
83
+ }
84
+ if (!owner)
85
+ throw new AppError("trusted_session_binding_required", "Stage lifecycle command has no running coordinator Work/Session binding", 1, { run_id: input.runId, stage: input.stage, ...(input.workId ? { work_id: input.workId } : {}) });
86
+ const identity = claimStageLifecycleHookEvent(context, {
87
+ projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
88
+ runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
89
+ });
90
+ if (ownsPhysicalWorkSession(context, project.id, owner, identity))
91
+ return identity;
92
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
93
+ const payload = { run_id: input.runId, stage: input.stage, work_id: owner.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
94
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
95
+ appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
96
+ throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
97
+ }
98
+ /** A Work terminal command belongs to the Session that started that Work.
99
+ * Controllers can still use their explicit non-agent operations; they cannot
100
+ * borrow a native worker's authority by naming its Work id. */
101
+ export function assertWorkLifecycleOwner(context, input) {
102
+ ensureWorkRegistry(context);
103
+ const work = requireWork(context, input.workId);
104
+ const run = requireRun(context, work.project_id, work.run_id);
105
+ if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
106
+ throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
107
+ const owner = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
108
+ if (!owner)
109
+ throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
110
+ const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
111
+ if (ownsPhysicalWorkSession(context, work.project_id, owner, identity))
112
+ return identity;
113
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
114
+ const payload = { run_id: work.run_id, work_id: work.work_id, operation: input.operation, expected_session_id: owner.session_id, observed_session_id: identity.sessionId, hook_event_id: identity.hookEventId };
115
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
116
+ appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
117
+ throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
118
+ }
17
119
  export function createChildWork(context, input) {
18
- const parent = requireWork(context, input.parentWorkId);
19
- if (parent.status !== "running")
20
- throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
21
- const id = nextWorkId(context, parent.project_id, input.slug);
22
- const now = context.now();
23
- context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
24
- refreshRunWorkProjection(context, parent.project_id, parent.run_id);
25
- return requireWork(context, id);
120
+ return context.db.writeTransaction(() => {
121
+ const parent = requireWork(context, input.parentWorkId);
122
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
123
+ if (parent.status !== "running")
124
+ throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
125
+ const id = nextWorkId(context, parent.project_id, input.slug);
126
+ const now = context.now();
127
+ context.db.run(`INSERT INTO works (${workColumns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', 'created', NULL, ?, NULL, ?, NULL)`, [id, parent.project_id, parent.run_id, parent.work_id, input.task, input.launchPolicy, input.resultSchema ?? null, input.payload ? JSON.stringify(input.payload) : null, now, now]);
128
+ if (input.stage)
129
+ bindStageWork(context, { projectId: parent.project_id, runId: parent.run_id, workId: id, stage: input.stage });
130
+ refreshRunWorkProjection(context, parent.project_id, parent.run_id);
131
+ return requireWork(context, id);
132
+ });
26
133
  }
27
134
  /** Validate a proposed batch before PLAN accepts it, without registering Work. */
28
135
  export function validateWorkBatchFile(file) {
29
- const parsed = readJson(file);
30
- if (!Array.isArray(parsed.works) || parsed.works.length === 0)
136
+ validateWorkBatch(prepareJsonFile(file, "work batch").value);
137
+ }
138
+ /** Validate an already prepared batch without reopening its source file. */
139
+ export function validateWorkBatch(value) {
140
+ const parsed = value;
141
+ if (!parsed || !Array.isArray(parsed.works) || parsed.works.length === 0)
31
142
  throw new AppError("validation", "work batch requires non-empty works", 2);
32
143
  const items = parsed.works.map((item) => validateItem(item, true));
33
144
  const keys = new Set(items.map((item) => item.key));
@@ -43,18 +154,29 @@ export function validateWorkBatchFile(file) {
43
154
  assertNoCycles(items.map((item) => ({ id: item.key, dependencies: item.depends_on ?? [] })));
44
155
  assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map((item) => ({ id: item.key, parent: item.parent })));
45
156
  }
46
- export function addWorkBatch(context, input) {
47
- ensureWorkRegistry(context);
48
- const parent = requireWork(context, input.parentWorkId);
49
- if (parent.status !== "running")
50
- throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
51
- const parsed = readJson(input.file);
52
- if (!Array.isArray(parsed.works) || parsed.works.length === 0)
157
+ export function prepareWorkBatch(context, input) {
158
+ if (input.file !== undefined && input.value !== undefined)
159
+ throw new AppError("usage", "Work batch accepts either file or value, not both", 2);
160
+ if (input.file === undefined && input.value === undefined)
161
+ throw new AppError("usage", "Work batch input is required", 2);
162
+ const parsed = (input.file !== undefined ? prepareJsonFile(input.file, "work batch").value : input.value);
163
+ if (!parsed || !Array.isArray(parsed.works) || parsed.works.length === 0)
53
164
  throw new AppError("validation", "work batch requires non-empty works", 2);
54
165
  const items = parsed.works.map((item) => validateItem(item));
55
166
  const keys = new Set(items.map((item) => item.key));
56
167
  if (keys.size !== items.length)
57
168
  throw new AppError("validation", "work batch keys must be unique", 2);
169
+ assertNoCycles(items.map(item => ({ id: item.key, dependencies: item.depends_on ?? [] })));
170
+ assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map(item => ({ id: item.key, parent: item.parent })));
171
+ prepareBatchParent(context, input.parentWorkId, items);
172
+ return items;
173
+ }
174
+ function prepareBatchParent(context, parentWorkId, items) {
175
+ const keys = new Set(items.map(item => item.key));
176
+ const parent = requireWork(context, parentWorkId);
177
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
178
+ if (parent.status !== "running")
179
+ throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
58
180
  const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
59
181
  const existingIds = new Set(existing.map((row) => row.work_id));
60
182
  for (const item of items)
@@ -64,9 +186,15 @@ export function addWorkBatch(context, input) {
64
186
  for (const item of items)
65
187
  if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
66
188
  throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
67
- const now = context.now();
68
- context.db.exec("BEGIN IMMEDIATE");
69
- try {
189
+ return parent;
190
+ }
191
+ export function addWorkBatch(context, input) {
192
+ const items = input.prepared ?? prepareWorkBatch(context, input);
193
+ return context.db.writeTransaction(() => {
194
+ // Only mutable authority/references are rechecked under the write lock.
195
+ // Caller bytes were read once, before admission, and are never reopened.
196
+ const parent = prepareBatchParent(context, input.parentWorkId, items);
197
+ const now = context.now();
70
198
  const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
71
199
  const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
72
200
  const resolve = (value) => ids.get(value) ?? value;
@@ -76,16 +204,17 @@ export function addWorkBatch(context, input) {
76
204
  for (const item of proposed)
77
205
  context.db.run(`INSERT INTO works (work_id, project_id, run_id, parent_work_id, task, launch_policy, result_schema, payload_json, depends_on_json, status, result, started_at, created_at, updated_at, completed_at)
78
206
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'created', NULL, NULL, ?, ?, NULL)`, [item.id, parent.project_id, parent.run_id, item.parentId, item.task, item.launch_policy ?? "reuse_allowed", item.result_schema ?? null, item.payload ? JSON.stringify(item.payload) : null, JSON.stringify(item.dependencies), now, now]);
79
- context.db.exec("COMMIT");
80
- for (const item of proposed)
207
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [parent.project_id, parent.run_id]);
208
+ const stages = run ? (JSON.parse(run.index_json).stage_runs ?? []) : [];
209
+ const ownerStage = input.stage ?? [...stages].reverse().find(stage => stage.status === "running")?.stage;
210
+ for (const item of proposed) {
211
+ if (ownerStage)
212
+ bindStageWork(context, { projectId: parent.project_id, runId: parent.run_id, workId: item.id, stage: ownerStage, ...(input.cycle ? { cycle: input.cycle } : {}) });
81
213
  appendFlowRunTimelineEvent(context, parent.project_id, parent.run_id, { type: "work_materialized", work_id: item.id, parent_work_id: item.parentId, depends_on: item.dependencies, launch_policy: item.launch_policy ?? "reuse_allowed" });
214
+ }
82
215
  refreshRunWorkProjection(context, parent.project_id, parent.run_id);
83
216
  return { ok: true, work_ids: Object.fromEntries(ids) };
84
- }
85
- catch (error) {
86
- context.db.exec("ROLLBACK");
87
- throw error;
88
- }
217
+ });
89
218
  }
90
219
  export function listWorks(context, input) {
91
220
  ensureWorkRegistry(context);
@@ -105,8 +234,8 @@ export function listWorks(context, input) {
105
234
  where.push("status = ?");
106
235
  params.push(input.status);
107
236
  }
108
- const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params);
109
- const ready = (work) => isReady(context, work);
237
+ const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params).filter(work => !input.workIds || input.workIds.has(work.work_id));
238
+ const ready = (work) => (input.workIds || currentStageWorkIds(context, work.project_id, work.run_id))?.has(work.work_id) !== false && isReady(context, work);
110
239
  const filtered = input.ready ? rows.filter(ready) : rows;
111
240
  return { works: filtered.slice(0, input.limit ?? filtered.length).map((work) => {
112
241
  const isReadyNow = ready(work);
@@ -114,10 +243,172 @@ export function listWorks(context, input) {
114
243
  }) };
115
244
  }
116
245
  export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id, ws.status, ws.prompt_path, ws.result_path, ws.created_at, ws.completed_at, s.harness AS harness_id, COALESCE(s.provider_session_id, s.session_id) AS native_session_id FROM work_sessions ws LEFT JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? ORDER BY ws.created_at", [work.project_id, work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions: sessions.map(({ session_id, harness_id, native_session_id, ...session }) => ({ ...session, ...(harness_id && native_session_id ? { session: publicSessionIdentity({ harness: harness_id, provider_session_id: native_session_id, session_id }) } : {}) })) } }; }
246
+ function receiptIntentPath(resultPath, sessionId) {
247
+ return `${resultPath}.pending-${crypto.createHash("sha256").update(sessionId).digest("hex")}`;
248
+ }
249
+ function receiptFileHash(file) {
250
+ return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
251
+ }
252
+ function sha256(contents) { return crypto.createHash("sha256").update(contents).digest("hex"); }
253
+ function publishReceiptFile(file, contents) {
254
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
255
+ try {
256
+ fs.writeFileSync(temporary, contents);
257
+ const descriptor = fs.openSync(temporary, "r");
258
+ try {
259
+ fs.fsyncSync(descriptor);
260
+ }
261
+ finally {
262
+ fs.closeSync(descriptor);
263
+ }
264
+ fs.renameSync(temporary, file);
265
+ const directory = fs.openSync(path.dirname(file), "r");
266
+ try {
267
+ fs.fsyncSync(directory);
268
+ }
269
+ finally {
270
+ fs.closeSync(directory);
271
+ }
272
+ }
273
+ finally {
274
+ fs.rmSync(temporary, { force: true });
275
+ }
276
+ }
277
+ /** Rebuild prompt/context projections from a committed Work-start receipt. */
278
+ export function materializePendingWorkStarts(context, projectId, runId) {
279
+ const runHome = requireRunHome(requireRun(context, projectId, runId));
280
+ const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, ws.start_receipt_json, ws.start_publication_status FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' AND ws.start_receipt_json IS NOT NULL AND (ws.start_publication_status IS NULL OR ws.start_publication_status != 'published')", [projectId, runId]);
281
+ for (const row of rows) {
282
+ // The same coordinator Session can move on to a different stage packet.
283
+ // A start receipt is not authority to overwrite that later packet.
284
+ let receipt;
285
+ try {
286
+ receipt = JSON.parse(row.start_receipt_json);
287
+ }
288
+ catch {
289
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
290
+ }
291
+ const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.version === 2 || receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
292
+ if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
293
+ continue;
294
+ if (![1, 2].includes(Number(receipt.version)) || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
295
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
296
+ if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'publishing' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status IN ('pending','publishing')", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1)
297
+ continue;
298
+ publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
299
+ publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
300
+ if (row.start_publication_status && context.db.writeTransaction(() => context.db.run("UPDATE work_sessions SET start_publication_status = 'published' WHERE id = ? AND status = 'running' AND prompt_path = ? AND start_receipt_json = ? AND start_publication_status = 'publishing'", [row.id, row.prompt_path, row.start_receipt_json]).changes) !== 1) {
301
+ const current = context.db.get("SELECT status, prompt_path, start_receipt_json, start_publication_status FROM work_sessions WHERE id = ?", [row.id]);
302
+ if (current?.status === "running" && current.prompt_path === row.prompt_path && current.start_receipt_json === row.start_receipt_json
303
+ && current.start_publication_status === "published" && receiptFileHash(row.prompt_path) === receipt.prompt_sha256
304
+ && receiptFileHash(path.join(path.dirname(row.prompt_path), "context.json")) === receipt.context_sha256)
305
+ continue;
306
+ throw new AppError("work_start_projection_conflict", "Work start binding changed during packet publication", 1, { work_id: row.work_id });
307
+ }
308
+ }
309
+ }
310
+ function publishStartReceiptFile(file, contents, expectedHash) {
311
+ const actual = receiptFileHash(file);
312
+ if (actual === expectedHash)
313
+ return;
314
+ if (actual !== null) {
315
+ throw new AppError("work_start_projection_conflict", "Work start packet differs from its committed receipt; preserve it for recovery", 1, { path: file, expected_sha256: expectedHash, actual_sha256: actual });
316
+ }
317
+ fs.mkdirSync(path.dirname(file), { recursive: true });
318
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
319
+ try {
320
+ fs.writeFileSync(temporary, contents, { flag: "wx" });
321
+ const descriptor = fs.openSync(temporary, "r");
322
+ try {
323
+ fs.fsyncSync(descriptor);
324
+ }
325
+ finally {
326
+ fs.closeSync(descriptor);
327
+ }
328
+ try {
329
+ fs.linkSync(temporary, file);
330
+ }
331
+ catch (error) {
332
+ if (error.code !== "EEXIST")
333
+ throw error;
334
+ if (receiptFileHash(file) !== expectedHash) {
335
+ throw new AppError("work_start_projection_conflict", "Concurrent Work start publication differs from its committed receipt", 1, { path: file, expected_sha256: expectedHash, actual_sha256: receiptFileHash(file) });
336
+ }
337
+ }
338
+ const directory = fs.openSync(path.dirname(file), "r");
339
+ try {
340
+ fs.fsyncSync(directory);
341
+ }
342
+ finally {
343
+ fs.closeSync(directory);
344
+ }
345
+ }
346
+ finally {
347
+ fs.rmSync(temporary, { force: true });
348
+ }
349
+ }
350
+ /** Replay only a prepared publication whose exact receipt was committed in SQLite. */
351
+ export function materializePendingWorkResults(context, projectId, runId, workId) {
352
+ const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path, ws.result_receipt_json FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id AND ws.status = w.status WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NOT NULL AND w.result IS NOT NULL AND w.status IN ('completed', 'failed', 'cancelled') AND ws.result_path IS NOT NULL AND (? IS NULL OR w.work_id = ?)`, [projectId, runId, workId ?? null, workId ?? null]);
353
+ for (const row of rows) {
354
+ const intentPath = receiptIntentPath(row.result_path, row.id);
355
+ const legacyIntent = fs.existsSync(intentPath) ? fs.readFileSync(intentPath, "utf8") : null;
356
+ if (!row.result_receipt_json && !legacyIntent)
357
+ continue;
358
+ const intent = JSON.parse(row.result_receipt_json ?? legacyIntent);
359
+ const digest = crypto.createHash("sha256").update(row.result).digest("hex");
360
+ if (intent.work_id !== row.work_id || intent.work_session_id !== row.id || intent.status !== row.status || intent.result_sha256 !== digest)
361
+ throw new AppError("work_result_publication_conflict", "Pending result publication does not identify the committed receipt", 1, { work_id: row.work_id, intent_path: intentPath });
362
+ if (legacyIntent && row.result_receipt_json && legacyIntent !== row.result_receipt_json)
363
+ throw new AppError("work_result_publication_conflict", "File intent differs from the committed result receipt", 1, { work_id: row.work_id, intent_path: intentPath });
364
+ const actual = receiptFileHash(row.result_path);
365
+ if (actual !== digest && actual !== intent.previous_sha256)
366
+ throw new AppError("work_result_artifact_drift", "Result changed after its publication was prepared; preserve it for diagnosis", 1, { work_id: row.work_id, actual_sha256: actual, expected_sha256: digest });
367
+ if (actual !== digest && fs.existsSync(row.result_path)) {
368
+ const previous = fs.readFileSync(row.result_path);
369
+ if (!previous.equals(Buffer.from(row.result))) {
370
+ const retained = `${row.result_path}.conflict-${crypto.createHash("sha256").update(previous).digest("hex")}`;
371
+ try {
372
+ fs.writeFileSync(retained, previous, { flag: "wx", flush: true });
373
+ }
374
+ catch (error) {
375
+ if (error.code !== "EEXIST" || !fs.readFileSync(retained).equals(previous))
376
+ throw error;
377
+ }
378
+ }
379
+ }
380
+ if (actual !== digest)
381
+ publishReceiptFile(row.result_path, row.result);
382
+ if (legacyIntent)
383
+ fs.unlinkSync(intentPath);
384
+ }
385
+ }
117
386
  export function mutateWorkDeps(context, input) {
387
+ if (input.action === "list") {
388
+ const { work, dependencies } = prepareWorkDeps(context, input);
389
+ return { work_id: work.work_id, depends_on: dependencies };
390
+ }
391
+ prepareWorkDeps(context, input);
392
+ return context.db.writeTransaction(() => {
393
+ const { work, dependencies } = prepareWorkDeps(context, input);
394
+ context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify(dependencies), context.now(), work.work_id]);
395
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
396
+ return { work_id: work.work_id, depends_on: dependencies };
397
+ });
398
+ }
399
+ export function prepareWorkDeps(context, input) {
400
+ if (!["list", "add", "remove", "clear"].includes(input.action) || (input.on !== undefined && (!Array.isArray(input.on) || input.on.some(id => typeof id !== "string" || !id.trim()))))
401
+ throw new AppError("usage", "Invalid Work dependency action or --on target", 2);
402
+ if (["add", "remove"].includes(input.action) && !input.on?.length)
403
+ throw new AppError("usage", "Dependency add/remove requires at least one --on target", 2);
404
+ if (["list", "clear"].includes(input.action) && input.on?.length)
405
+ throw new AppError("usage", "Dependency list/clear does not accept --on targets", 2);
406
+ if (input.action === "list") {
407
+ const work = requireWork(context, input.workId);
408
+ return { work, dependencies: parseDependencies(work) };
409
+ }
118
410
  const work = requireWork(context, input.workId);
119
- if (input.action === "list")
120
- return { work_id: work.work_id, depends_on: parseDependencies(work) };
411
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
121
412
  if (work.status !== "created")
122
413
  throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
123
414
  const dependencies = new Set(parseDependencies(work));
@@ -125,8 +416,8 @@ export function mutateWorkDeps(context, input) {
125
416
  dependencies.clear();
126
417
  for (const id of input.on ?? []) {
127
418
  const dependency = requireWork(context, id);
128
- if (dependency.run_id !== work.run_id)
129
- throw new AppError("validation", "Dependencies must stay in one RUN", 2);
419
+ if (dependency.project_id !== work.project_id || dependency.run_id !== work.run_id)
420
+ throw new AppError("validation", "Dependencies must stay in one project and RUN", 2);
130
421
  if (input.action === "add")
131
422
  dependencies.add(dependency.work_id);
132
423
  else
@@ -135,56 +426,78 @@ export function mutateWorkDeps(context, input) {
135
426
  if (dependencies.has(work.work_id))
136
427
  throw new AppError("validation", "Work cannot depend on itself", 2);
137
428
  assertNoCycles(context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [work.project_id, work.run_id]).map((row) => ({ id: row.work_id, dependencies: row.work_id === work.work_id ? [...dependencies] : parseDependencies(row) })));
138
- context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
139
- refreshRunWorkProjection(context, work.project_id, work.run_id);
140
- return { work_id: work.work_id, depends_on: [...dependencies] };
429
+ return { work, dependencies: [...dependencies] };
430
+ }
431
+ export function prepareWorkDelete(context, id) {
432
+ const work = requireWork(context, id);
433
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
434
+ if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [work.work_id]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${work.work_id}%`]))
435
+ throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2);
436
+ return work;
437
+ }
438
+ export function deleteWork(context, id) {
439
+ prepareWorkDelete(context, id);
440
+ return context.db.writeTransaction(() => { const work = prepareWorkDelete(context, id); context.db.run("DELETE FROM works WHERE work_id = ?", [work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: work.work_id }; });
141
441
  }
142
- export function deleteWork(context, id) { const work = requireWork(context, id); const canonical = work.work_id; if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [canonical]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${canonical}%`]))
143
- throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2); context.db.run("DELETE FROM works WHERE work_id = ?", [canonical]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: canonical }; }
144
- export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
442
+ export function shortWorkId(id) { return shortEntityReference(id); }
145
443
  /**
146
444
  * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
147
445
  * Fan-out workers run in independently spawned harnesses where the adapter's
148
446
  * PATH is not inherited; using the shared lifecycle command keeps them on the
149
447
  * same captured runtime as their parent stage.
150
448
  */
151
- export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
152
- export function startWork(context, id, input) {
449
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); const recovery = recoveryGuard(context, work.project_id, work.run_id); return managedLifecycleCommand(context, runtimeCommand(context, ["work", "start", shortWorkId(work.work_id), "--project-root", run.project_root, ...(recovery ? ["--recovery-id", recovery.recovery_id] : []), "--json"])); }
450
+ export function assertWorkLaunchReady(context, id) {
153
451
  ensureWorkRegistry(context);
154
452
  const work = requireWork(context, id);
453
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
155
454
  if (work.status !== "created")
156
455
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
157
456
  if (!isReady(context, work)) {
158
457
  const blockers = readinessBlockers(context, work);
159
- const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
458
+ const ready = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => currentStageWorkIds(context, candidate.project_id, candidate.run_id)?.has(candidate.work_id) !== false && isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
160
459
  throw new AppError("work_not_ready", "Work cannot start until dependencies complete and overlapping planned coordination areas are free", 2, { work_id: id, blockers, ready });
161
460
  }
461
+ if (parsePayload(work)?.read_only === true)
462
+ assertSharedReviewInput(context, work, requireRun(context, work.project_id, work.run_id));
463
+ return work;
464
+ }
465
+ export function startWork(context, id, input) {
466
+ if (input.hookEventId && !context.env.DD_FLOW_CURRENT_INVOCATION) {
467
+ const retainedWork = requireWork(context, id);
468
+ const retainedRun = requireRun(context, retainedWork.project_id, retainedWork.run_id);
469
+ if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(retainedRun.project_root))
470
+ throw new AppError("project_mismatch", "Work start belongs to another project", 1);
471
+ const reply = recoverCommittedWorkStart(context, null, input.hookEventId, recoveryGuard(context, retainedWork.project_id, retainedWork.run_id)?.generation ?? 0, retainedWork.work_id);
472
+ if (reply)
473
+ return reply;
474
+ }
475
+ const work = assertWorkLaunchReady(context, id);
162
476
  const run = requireRun(context, work.project_id, work.run_id);
163
477
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
164
478
  throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
165
- let hookEventId = input.hookEventId;
166
- if (!hookEventId) {
167
- try {
168
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
169
- }
170
- catch {
171
- hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
172
- }
173
- }
174
- const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root });
175
- const started = startBoundWork(context, work, run, identity, hookEventId);
176
- appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
479
+ const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
480
+ if (input.recoveryId !== recoveryId)
481
+ throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
482
+ const hookEventId = input.hookEventId ?? findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: entityReferenceVariants(work.work_id).map((reference) => workStartMatchKey(reference, run.project_root, recoveryId)), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
483
+ const eventKey = hookEventId;
484
+ const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
485
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
177
486
  return started;
178
487
  }
179
488
  /** Binds the already-running coordinator of a stage to its trusted hook Session. */
180
489
  export function bindRunningWorkSession(context, input) {
181
490
  const work = requireWork(context, input.workId);
491
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
182
492
  if (work.status !== "running")
183
493
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
184
494
  const run = requireRun(context, work.project_id, work.run_id);
185
495
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
496
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
186
497
  const active = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
187
498
  if (active) {
499
+ if (active.session_id === identity.sessionId && !ownsPhysicalWorkSession(context, work.project_id, active, identity))
500
+ throw new AppError("handoff_session_mismatch", "The active Work belongs to another physical daemon", 1, { work_id: work.work_id });
188
501
  if (active.session_id !== identity.sessionId) {
189
502
  // A frozen `new_session` policy may transfer the same coordinator Work
190
503
  // only to its next stage. Repeating the same stage from another Session
@@ -193,20 +506,14 @@ export function bindRunningWorkSession(context, input) {
193
506
  throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
194
507
  const now = context.now();
195
508
  const id = `WS-${crypto.randomUUID()}`;
196
- context.db.exec("BEGIN IMMEDIATE");
197
- try {
509
+ context.db.writeTransaction(() => {
198
510
  context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
199
511
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
200
512
  if (!stillRunning)
201
513
  context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, active.session_id]);
202
514
  bindSession(context, work, run, identity, now);
203
515
  context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
204
- context.db.exec("COMMIT");
205
- }
206
- catch (error) {
207
- context.db.exec("ROLLBACK");
208
- throw error;
209
- }
516
+ });
210
517
  refreshRunWorkProjection(context, work.project_id, work.run_id);
211
518
  return { work_session_id: id, session_id: identity.sessionId, handed_off: true };
212
519
  }
@@ -215,26 +522,32 @@ export function bindRunningWorkSession(context, input) {
215
522
  }
216
523
  const now = context.now();
217
524
  const id = `WS-${crypto.randomUUID()}`;
218
- context.db.exec("BEGIN IMMEDIATE");
219
- try {
525
+ context.db.writeTransaction(() => {
220
526
  bindSession(context, work, run, identity, now);
221
527
  context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)", [id, work.work_id, identity.sessionId, input.hookEventId, input.promptPath, input.resultPath ?? null, now, now]);
222
- context.db.exec("COMMIT");
223
- }
224
- catch (error) {
225
- context.db.exec("ROLLBACK");
226
- throw error;
227
- }
528
+ });
228
529
  refreshRunWorkProjection(context, work.project_id, work.run_id);
229
530
  return { work_session_id: id, session_id: identity.sessionId };
230
531
  }
231
532
  /** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
232
533
  export function bindStageCoordinatorWork(context, input) {
233
534
  const work = requireWork(context, input.workId);
535
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
234
536
  const run = requireRun(context, work.project_id, work.run_id);
235
- claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
236
537
  const snapshot = JSON.parse((context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json) ?? "{}");
237
- const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: snapshot.execution_profile?.settings?.stage_session_mode === "new_session" });
538
+ const profile = snapshot.execution_profile;
539
+ const previous = context.db.get("SELECT ws.session_id, s.current_stage FROM work_sessions ws JOIN sessions s ON s.project_id = ? AND s.session_id = ws.session_id WHERE ws.work_id = ? AND ws.status = 'running' ORDER BY ws.created_at DESC LIMIT 1", [work.project_id, work.work_id]);
540
+ let sessionMode = profile?.settings.stage_session_mode ?? "same_session";
541
+ if (profile?.settings.execution) {
542
+ if (!profile.agent_profiles)
543
+ throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
544
+ sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
545
+ }
546
+ const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
547
+ if (previous?.current_stage && previous.current_stage !== input.stage && sessionMode === "new_session" && previous.session_id === identity.sessionId)
548
+ throw new AppError("stage_handoff_required", "This stage requires a fresh coordinator Session under its frozen execution policy", 1, { from_stage: previous.current_stage, stage: input.stage });
549
+ claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
550
+ const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: sessionMode === "new_session" });
238
551
  context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, binding.session_id]);
239
552
  refreshRunSessionProjection(context, work.project_id, work.run_id);
240
553
  return binding;
@@ -242,95 +555,502 @@ export function bindStageCoordinatorWork(context, input) {
242
555
  /** Starts a created coordinator Work from its trusted `stage start` event. */
243
556
  export function startStageCoordinatorWork(context, input) {
244
557
  const work = requireWork(context, input.workId);
558
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
245
559
  if (work.status !== "created")
246
560
  throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
247
561
  const run = requireRun(context, work.project_id, work.run_id);
248
562
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
249
563
  throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
250
- const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
251
- const binding = startBoundWork(context, work, run, identity, input.hookEventId);
252
- const sessionId = String(binding.session_binding.session_id ?? "");
253
- context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
564
+ const binding = startBoundWork(context, work, run, () => claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root, ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) }), input.hookEventId, input.stage);
254
565
  refreshRunSessionProjection(context, work.project_id, work.run_id);
255
566
  return binding;
256
567
  }
257
- /** Stage entry points call this only after their own trusted hook claim. */
258
- export function startBoundWork(context, work, run, identity, hookEventId) {
259
- if (work.status !== "created")
260
- throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
261
- const now = context.now();
262
- const linkId = `WS-${crypto.randomUUID()}`;
568
+ /** Claim, command authority and binding commit together; only packet projection follows. */
569
+ export function startBoundWork(context, work, run, claimIdentity, hookEventId, stage) {
570
+ const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
263
571
  const directory = path.join(requireRunHome(run), "works", work.work_id);
264
- fs.mkdirSync(directory, { recursive: true });
265
- const promptPath = path.join(directory, "prompt.md");
266
- const resultPath = path.join(directory, "result.json");
267
- const contextPath = path.join(directory, "context.json");
268
- const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
269
- const prompt = renderWorkerPrompt(context, work, run, dependencyResults);
270
- const payload = parsePayload(work);
271
- const readOnly = payload?.read_only === true;
272
- const workContext = { schema_id: "dd-flow/work-context@1", run_id: work.run_id, work_id: work.work_id, parent_work_id: work.parent_work_id, project_root: run.project_root, workspace_root: run.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(work), launch_policy: work.launch_policy, result_schema: work.result_schema, read_only: readOnly, ...(readOnly ? { workspace_fingerprint: workspaceFingerprint(run.workspace_root) } : {}) };
273
- const token = crypto.randomUUID();
274
- const promptCandidate = `${promptPath}.${token}.tmp`;
275
- const contextCandidate = `${contextPath}.${token}.tmp`;
276
- fs.writeFileSync(promptCandidate, prompt);
277
- fs.writeFileSync(contextCandidate, `${JSON.stringify(workContext, null, 2)}\n`);
278
- context.db.exec("BEGIN IMMEDIATE");
572
+ const linkId = `WS-${crypto.randomUUID()}`;
573
+ // Recovery can reopen the same Work without retiring its historical packet.
574
+ // Give each new-generation start its own immutable publication destination.
575
+ const packetDirectory = path.join(directory, "starts", linkId);
576
+ const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
577
+ // The first reviewer freezes shared inputs in the same transaction as its claim.
578
+ const readOnly = parsePayload(work)?.read_only === true;
579
+ const workerRun = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE
580
+ ? { ...run, workspace_root: path.resolve(context.env.DD_FLOW_READ_ONLY_WORKSPACE) }
581
+ : run;
582
+ const indexBeforePrepare = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json;
583
+ const preparedReviewInput = readOnly ? assertSharedReviewInput(context, work, run, generation) : null;
584
+ const reviewPaths = preparedReviewInput?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
585
+ const preparedCopy = readOnly && context.env.DD_FLOW_READ_ONLY_WORKSPACE ? prepareBoundReviewCopy(context, work, run, context.env.DD_FLOW_READ_ONLY_WORKSPACE, reviewPaths) : null;
586
+ const fingerprint = readOnly ? preparedReviewInput?.source_sha256 ?? preparedReviewInput?.workspace_fingerprint ?? legacyReviewFingerprint(work, run) ?? workspaceFingerprint(run.workspace_root) : undefined;
587
+ const preparedDependencies = parseDependencies(work).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
588
+ const preparedRecovery = recoveryPromptForWork(context, work.work_id);
589
+ const retryPending = fs.existsSync(path.join(directory, ".retry.json"));
590
+ const finishMarker = `DD_FLOW_FINISH_${crypto.randomUUID()}`;
591
+ const failMarker = `DD_FLOW_FAIL_${crypto.randomUUID()}`;
592
+ let promptTemplate = renderWorkerPrompt(context, work, workerRun, preparedDependencies, { finish: finishMarker, fail: failMarker });
593
+ if (preparedRecovery)
594
+ promptTemplate = `${recoveryPrompt(preparedRecovery)}\n\n${promptTemplate}`;
595
+ let binding;
279
596
  try {
280
- bindSession(context, work, run, identity, now);
281
- if (work.parent_work_id && work.launch_policy === "reuse_allowed")
282
- context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, work.work_id]);
283
- const claimed = context.db.run("UPDATE works SET status = 'running', started_at = ?, updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, work.work_id]);
284
- if (claimed.changes !== 1)
285
- throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
286
- context.db.run(`INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, NULL)`, [linkId, work.work_id, identity.sessionId, hookEventId, promptPath, resultPath, now, now]);
287
- fs.renameSync(promptCandidate, promptPath);
288
- fs.renameSync(contextCandidate, contextPath);
289
- context.db.exec("COMMIT");
597
+ binding = context.db.writeTransaction(() => {
598
+ const current = requireWork(context, work.work_id);
599
+ assertRunMutationAllowed(context, current.project_id, current.run_id, generation);
600
+ if (current.status !== "created")
601
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id, status: current.status });
602
+ if (Object.keys(current).some(key => current[key] !== work[key]))
603
+ throw new AppError("work_not_ready", "Work inputs changed while preparing its start packet", 2, { work_id: current.work_id });
604
+ if (context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id])?.index_json !== indexBeforePrepare)
605
+ throw new AppError("work_not_ready", "RUN revision changed while preparing the Work start packet", 2, { work_id: current.work_id });
606
+ if (!isReady(context, current))
607
+ throw new AppError("work_not_ready", "Work dependencies are not ready", 2, { work_id: current.work_id, blockers: readinessBlockers(context, current) });
608
+ if (retryPending)
609
+ throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
610
+ const identity = claimIdentity();
611
+ assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
612
+ if (readOnly && !context.env.DD_FLOW_READ_ONLY_WORKSPACE
613
+ && context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'work_launches'")
614
+ && context.db.get("SELECT 1 FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [current.work_id]))
615
+ throw new AppError("read_only_workspace_unbound", "External reviewer did not inherit its qualified execution workspace", 1, { work_id: current.work_id });
616
+ if (preparedCopy) {
617
+ const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [current.work_id]);
618
+ if (!launch || launch.provider_session_id !== identity.providerSessionId || launch.provider_session_id !== preparedCopy.providerSessionId || launch.profile_json !== preparedCopy.profileJson)
619
+ throw new AppError("read_only_workspace_unbound", "Read-only execution copy belongs to another physical Session or launch revision", 1, { work_id: current.work_id });
620
+ }
621
+ const reviewInput = preparedReviewInput;
622
+ const reviewCopy = preparedCopy?.copy ?? null;
623
+ if (reviewInput)
624
+ assertReviewSiblingBaseline(context, current, reviewInput, generation);
625
+ const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
626
+ const recovery = recoveryPromptForWork(context, current.work_id);
627
+ if (JSON.stringify(dependencyResults) !== JSON.stringify(preparedDependencies) || JSON.stringify(recovery) !== JSON.stringify(preparedRecovery))
628
+ throw new AppError("work_not_ready", "Work dependencies or recovery segment changed while preparing its packet", 2, { work_id: current.work_id });
629
+ const assignments = new Set();
630
+ const commands = workerLifecycleCommands({ ...context, captureLifecycleAssignment: (id) => assignments.add(id) }, current, workerRun);
631
+ const prompt = promptTemplate.replace(finishMarker, commands.finish).replace(failMarker, commands.fail);
632
+ bindLifecyclePacketAssignments(context, prompt, assignments);
633
+ const workContext = { schema_id: "dd-flow/work-context@1", run_id: current.run_id, work_id: current.work_id, parent_work_id: current.parent_work_id, project_root: run.project_root, workspace_root: workerRun.workspace_root, run_root: requireRunHome(run), depends_on: parseDependencies(current), launch_policy: current.launch_policy, result_schema: current.result_schema, read_only: readOnly, ...(recovery ? { recovery } : {}), ...(readOnly ? { workspace_fingerprint: fingerprint, ...(reviewInput ? { review_input: reviewInput } : {}), ...(reviewCopy ? { review_copy: reviewCopy } : {}) } : {}) };
634
+ const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
635
+ const response = { ok: true, work_id: current.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, ...(recovery ? { recovery } : {}), session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
636
+ const receipt = JSON.stringify({ version: 2, prompt, context: contextBytes, prompt_sha256: sha256(prompt), context_sha256: sha256(contextBytes), response, response_sha256: sha256(JSON.stringify(response)), generation, invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null });
637
+ const now = context.now();
638
+ bindSession(context, current, run, identity, now);
639
+ if (current.parent_work_id && current.launch_policy === "reuse_allowed")
640
+ context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE session_id = ? AND status = 'running' AND work_id <> ?", [now, now, identity.sessionId, current.work_id]);
641
+ if (context.db.run("UPDATE works SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ? WHERE work_id = ? AND status = 'created'", [now, now, current.work_id]).changes !== 1)
642
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id });
643
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, start_publication_status, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, 'pending', ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
644
+ if (stage)
645
+ context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [stage, now, current.project_id, identity.sessionId]);
646
+ if (recovery)
647
+ activateRecoveryWork(context, current.work_id);
648
+ return response;
649
+ });
290
650
  }
291
651
  catch (error) {
292
- context.db.exec("ROLLBACK");
293
- fs.rmSync(promptCandidate, { force: true });
294
- fs.rmSync(contextCandidate, { force: true });
652
+ // This transaction publishes only SQL authority, never files or provider calls.
653
+ if (error instanceof AppError && error.code === "storage_write_failed" && error.details.transaction_rolled_back === true)
654
+ error.details.effect = "no_effect";
655
+ if (error instanceof AppError && error.details.effect === "no_effect" && !context.env.DD_FLOW_CURRENT_INVOCATION) {
656
+ let retry = false;
657
+ try {
658
+ retry = context.db.writeTransaction(() => {
659
+ if (context.db.get("SELECT 1 FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.work_id') = ? AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.storage_retry') = 1", [work.project_id, work.work_id, generation]))
660
+ return false;
661
+ return context.db.run("UPDATE hook_events SET outcome_json = ? WHERE project_id = ? AND event_key = ? AND status = 'observed' AND outcome_json IS NULL", [JSON.stringify({ work_id: work.work_id, run_id: work.run_id, generation, effect: "no_effect", disposition: "retrying", storage_retry: 1, deadline: Date.now() + 60000 }), work.project_id, hookEventId]).changes === 1;
662
+ });
663
+ }
664
+ catch (persistence) {
665
+ error.details.retry_persistence_error = String(persistence);
666
+ }
667
+ if (retry)
668
+ return startBoundWork(context, work, run, claimIdentity, hookEventId, stage);
669
+ }
295
670
  throw error;
296
671
  }
297
- refreshRunWorkProjection(context, work.project_id, work.run_id);
298
- return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session: { harness_id: identity.harness, session_id: identity.nativeSessionId } } };
672
+ try {
673
+ if (readOnly) {
674
+ const committedWork = requireWork(context, work.work_id);
675
+ assertSharedReviewInput(context, committedWork, run, generation);
676
+ const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE id = ?", [linkId]);
677
+ assertReviewCopyUnchanged(context, committedWork, run, link);
678
+ }
679
+ materializePendingWorkStarts(context, work.project_id, work.run_id);
680
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
681
+ }
682
+ catch (error) {
683
+ if (error instanceof AppError) {
684
+ error.details.effect = "committed";
685
+ throw error;
686
+ }
687
+ throw new AppError("work_start_publication_failed", error instanceof Error ? error.message : String(error), 1, { effect: "committed", work_id: work.work_id, hook_event_id: hookEventId, cause: { code: error?.code ?? null } });
688
+ }
689
+ return binding;
299
690
  }
300
- export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
691
+ /** Read the exact committed reply, never bind another Session or regenerate a packet. */
692
+ export function recoverCommittedWorkStart(context, invocationId, eventKey, generation, workId) {
693
+ const row = context.db.get("SELECT ws.start_receipt_json, w.project_id, w.run_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE ws.start_receipt_json IS NOT NULL AND ws.hook_event_id = ? AND json_extract(ws.start_receipt_json, '$.invocation_id') IS ? AND (? IS NULL OR w.work_id = ?)", [eventKey, invocationId, workId ?? null, workId ?? null]);
694
+ if (!row)
695
+ return undefined;
696
+ const receipt = JSON.parse(row.start_receipt_json);
697
+ if (receipt.generation !== generation || !receipt.response || receipt.response_sha256 !== sha256(JSON.stringify(receipt.response)))
698
+ throw new AppError("work_start_receipt_invalid", "Committed reply identity or checksum differs", 1, { invocation_id: invocationId });
699
+ assertRunMutationAllowed(context, row.project_id, row.run_id, generation);
700
+ materializePendingWorkStarts(context, row.project_id, row.run_id);
701
+ return receipt.response;
702
+ }
703
+ /** Root coordinators have no worker checks: settle them inside the stage transaction. */
704
+ export function finishStageCoordinatorWork(context, id, result) {
705
+ const work = requireWork(context, id);
706
+ if (work.parent_work_id !== null || codePacket(work) || parsePayload(work)?.read_only)
707
+ throw new AppError("invalid_work_state", "Stage settlement requires a root coordinator", 2);
708
+ const run = requireRun(context, work.project_id, work.run_id);
709
+ const link = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
710
+ if (!link)
711
+ throw new AppError("runtime_missing", "Coordinator has no open Work/Session link", 1);
712
+ validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link.result_path);
713
+ finishFanInWork(context, id, result);
714
+ context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ? AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')", [context.now(), work.project_id, link.session_id, link.session_id]);
715
+ settleRecoveryWork(context, id, "completed");
716
+ }
717
+ export function finishWork(context, id, result, progress, retry) { return settle(context, id, "completed", result, progress, retry); }
301
718
  /** Structured fan-in closes a parent after its Session was handed to a child. */
302
- export function finishFanInWork(context, id, result) { const work = requireWork(context, id); if (work.status !== "running")
719
+ export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
303
720
  throw new AppError("invalid_work_state", "Fan-in Work is not running", 2, { work_id: work.work_id, status: work.status }); if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [work.work_id]))
304
721
  throw new AppError("active_child_work", "Fan-in Work still has active children", 2, { work_id: work.work_id }); const now = context.now(); context.db.run("UPDATE works SET status = 'completed', result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [result, now, now, work.work_id]); context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = COALESCE(completed_at, ?), updated_at = ? WHERE work_id = ? AND status = 'running'", [now, now, work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_completed", work_id: work.work_id, fan_in: true }); return { ok: true, work_id: work.work_id, status: "completed", fan_in: true }; }
305
722
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
306
723
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
307
- export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
308
- throw new AppError("invalid_work_state", "Only failed Work may be retried", 2); const run = requireRun(context, work.project_id, work.run_id); const directory = path.join(requireRunHome(run), "works", id); const attempts = path.join(directory, "attempts"); const number = fs.existsSync(attempts) ? fs.readdirSync(attempts).filter((entry) => /^ATT-\d{3}$/.test(entry)).length + 1 : 1; const archive = path.join(attempts, `ATT-${String(number).padStart(3, "0")}`); fs.mkdirSync(archive, { recursive: true }); for (const file of ["prompt.md", "result.json", "context.json"]) {
309
- const source = path.join(directory, file);
310
- if (fs.existsSync(source))
311
- fs.renameSync(source, path.join(archive, file));
312
- } context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ?", [context.now(), id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, work_id: id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason }; }
313
- async function settle(context, id, status, result, progress) {
724
+ export function prepareWorkSettlement(context, id, status, result) {
725
+ if (status !== "completed" && (typeof result !== "string" || !result.trim()))
726
+ throw new AppError("usage", "Work settlement requires a nonempty --reason", 2);
314
727
  const work = requireWork(context, id);
315
- id = work.work_id;
728
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
316
729
  if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
317
730
  throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
318
- if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [id]))
319
- throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: id });
731
+ if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [work.work_id]))
732
+ throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: work.work_id });
320
733
  const run = requireRun(context, work.project_id, work.run_id);
321
- const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
734
+ const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
322
735
  if (work.status === "running" && !link)
323
- throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: id });
736
+ throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: work.work_id });
737
+ const startPublication = link ? context.db.get("SELECT start_publication_status FROM work_sessions WHERE id = ?", [link.id])?.start_publication_status : null;
738
+ if (startPublication && startPublication !== "published")
739
+ throw new AppError("work_start_publication_pending", "Finish publishing this Work's committed start packet before settlement", 1, { work_id: work.work_id, effect: "no_effect" });
740
+ return { work, run, link };
741
+ }
742
+ export function prepareWorkRetry(context, id, reason) {
743
+ if (typeof reason !== "string" || !reason.trim())
744
+ throw new AppError("usage", "Work retry requires a nonempty --reason", 2);
745
+ const work = requireWork(context, id);
746
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
747
+ const run = requireRun(context, work.project_id, work.run_id);
748
+ const directory = path.join(requireRunHome(run), "works", work.work_id);
749
+ const journal = path.join(directory, ".retry.json");
750
+ let pending = null;
751
+ if (fs.existsSync(journal)) {
752
+ let value;
753
+ try {
754
+ value = JSON.parse(fs.readFileSync(journal, "utf8"));
755
+ }
756
+ catch (error) {
757
+ if (!(error instanceof SyntaxError))
758
+ throw error;
759
+ throw new AppError("work_retry_invalid", "Retry journal is not valid JSON", 1);
760
+ }
761
+ const item = value;
762
+ if (!item || typeof item !== "object" || item.work_id !== work.work_id || typeof item.archive !== "string" || !/^ATT-\d{3,}$/.test(item.archive) || !Array.isArray(item.entries) || item.entries.some(file => !["prompt.md", "result.json", "context.json"].includes(file)) || new Set(item.entries).size !== item.entries.length || !(item.result_sha256 === null || typeof item.result_sha256 === "string" && /^[a-f0-9]{64}$/.test(item.result_sha256)))
763
+ throw new AppError("work_retry_invalid", "Retry journal does not identify this Work", 1);
764
+ pending = item;
765
+ }
766
+ if (work.status !== "failed" && !(pending && work.status === "created"))
767
+ throw new AppError("invalid_work_state", "Only failed Work may be retried", 2);
768
+ const attempts = path.join(directory, "attempts");
769
+ let number = 1;
770
+ while (fs.existsSync(path.join(attempts, `ATT-${String(number).padStart(3, "0")}`)))
771
+ number += 1;
772
+ const prepared = pending ?? { work_id: work.work_id, archive: `ATT-${String(number).padStart(3, "0")}`, entries: ["prompt.md", "result.json", "context.json"].filter(file => fs.existsSync(path.join(directory, file))), result_sha256: work.result === null ? null : crypto.createHash("sha256").update(work.result).digest("hex") };
773
+ const archive = path.join(attempts, prepared.archive);
774
+ if (work.status === "failed") {
775
+ if (prepared.result_sha256 !== (work.result === null ? null : crypto.createHash("sha256").update(work.result).digest("hex")))
776
+ throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
777
+ const receipt = path.join(archive, "accepted-receipt.json");
778
+ if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== JSON.stringify({ work_id: work.work_id, status: work.status, result: work.result, result_sha256: prepared.result_sha256 }))
779
+ throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
780
+ for (const file of prepared.entries) {
781
+ const source = path.join(directory, file), target = path.join(archive, file);
782
+ if (fs.existsSync(source) && fs.existsSync(target))
783
+ throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
784
+ if (!fs.existsSync(source) && !fs.existsSync(target))
785
+ throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
786
+ }
787
+ }
788
+ return { work, run, directory, journal, pending, prepared, archive };
789
+ }
790
+ export function retryWork(context, id, reason) {
791
+ const pending = requireWork(context, id);
792
+ // A committed failed result must be published (or diagnosed) before its
793
+ // live path can be archived for the next attempt.
794
+ materializePendingWorkResults(context, pending.project_id, pending.run_id, pending.work_id);
795
+ const { work, run, directory, journal, pending: retainedJournal, prepared, archive } = prepareWorkRetry(context, id, reason);
796
+ if (work.status === "failed") {
797
+ // The durable journal and archive are projections of the failed SQL
798
+ // receipt. They must finish before the short transaction opens a new
799
+ // attempt; a crash at any point is replayed from the same journal.
800
+ if (!retainedJournal)
801
+ publishStartReceiptFile(journal, JSON.stringify(prepared), sha256(JSON.stringify(prepared)));
802
+ fs.mkdirSync(archive, { recursive: true });
803
+ const receipt = path.join(archive, "accepted-receipt.json");
804
+ const receiptBytes = JSON.stringify({ work_id: work.work_id, status: work.status, result: work.result, result_sha256: prepared.result_sha256 });
805
+ publishStartReceiptFile(receipt, receiptBytes, sha256(receiptBytes));
806
+ for (const file of prepared.entries) {
807
+ const source = path.join(directory, file), target = path.join(archive, file);
808
+ if (fs.existsSync(source)) {
809
+ if (fs.existsSync(target))
810
+ throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
811
+ fs.renameSync(source, target);
812
+ }
813
+ else if (!fs.existsSync(target))
814
+ throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
815
+ }
816
+ for (const folder of [archive, directory]) {
817
+ const descriptor = fs.openSync(folder, "r");
818
+ try {
819
+ fs.fsyncSync(descriptor);
820
+ }
821
+ finally {
822
+ fs.closeSync(descriptor);
823
+ }
824
+ }
825
+ context.db.writeTransaction(() => {
826
+ const current = requireWork(context, work.work_id);
827
+ assertRunMutationAllowed(context, current.project_id, current.run_id);
828
+ if (current.status !== "failed" || current.result !== work.result || current.updated_at !== work.updated_at)
829
+ throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
830
+ for (const column of ["prompt_path", "result_path"]) {
831
+ const file = column === "prompt_path" ? "prompt.md" : "result.json";
832
+ context.db.run(`UPDATE work_sessions SET ${column} = ? WHERE work_id = ? AND ${column} = ?`, [path.join(archive, file), work.work_id, path.join(directory, file)]);
833
+ }
834
+ if (context.db.run("UPDATE works SET status = 'created', result = NULL, started_at = NULL, completed_at = NULL, updated_at = ? WHERE work_id = ? AND status = 'failed'", [context.now(), work.work_id]).changes !== 1)
835
+ throw new AppError("work_retry_conflict", "Failed Work changed during retry", 1);
836
+ });
837
+ }
838
+ fs.rmSync(journal, { force: true });
839
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
840
+ return { ok: true, work_id: work.work_id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason };
841
+ }
842
+ export function reviewSourceInputs(context, workId) {
843
+ const work = requireWork(context, workId);
844
+ const input = assertSharedReviewInput(context, work, requireRun(context, work.project_id, work.run_id));
845
+ return input?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
846
+ }
847
+ function reviewInputPath(root, relative) {
848
+ const resolved = path.resolve(root, relative);
849
+ const inside = path.relative(root, resolved);
850
+ if (!relative || path.isAbsolute(relative) || inside === ".." || inside.startsWith(`..${path.sep}`))
851
+ throw new AppError("review_input_invalid", "Review input path escapes its declared root", 1, { path: relative });
852
+ if (fs.existsSync(resolved)) {
853
+ const canonicalInside = path.relative(fs.realpathSync(root), fs.realpathSync(resolved));
854
+ if (canonicalInside === ".." || canonicalInside.startsWith(`..${path.sep}`))
855
+ throw new AppError("review_input_invalid", "Review input symlink escapes its declared root", 1, { path: relative });
856
+ }
857
+ return resolved;
858
+ }
859
+ function prepareBoundReviewCopy(context, work, run, requested, reviewPaths) {
860
+ const launch = context.db.get("SELECT provider_session_id, profile_json FROM work_launches WHERE work_id = ? AND status IN ('launching','start_requested')", [work.work_id]);
861
+ const metadata = launch ? JSON.parse(launch.profile_json) : null;
862
+ const copy = metadata?.review_copy;
863
+ if (!copy?.root || !copy.sha256 || !copy.source_sha256 || !launch?.provider_session_id)
864
+ throw new AppError("read_only_workspace_unbound", "Read-only execution copy has no matching trusted launch", 1, { work_id: work.work_id });
865
+ const expected = reviewInputPath(requireRunHome(run), copy.root);
866
+ if (path.resolve(requested) !== expected)
867
+ throw new AppError("read_only_workspace_unbound", "Read-only execution path differs from the trusted launch", 1, { work_id: work.work_id });
868
+ const base = reviewWorkspaceBase(context, work);
869
+ if (reviewSourceHash(run.workspace_root, reviewPaths, base) !== copy.source_sha256)
870
+ throw new AppError("read_only_input_changed", "Authoritative review workspace changed after the execution copy was made", 2, { work_id: work.work_id });
871
+ const actual = reviewExecutionCopyHash(expected, run.workspace_root, true, reviewPaths, base);
872
+ if (actual !== copy.sha256)
873
+ throw new AppError("read_only_input_changed", "Read-only execution copy changed before Work start", 2, { work_id: work.work_id, expected: copy.sha256, current: actual });
874
+ return { copy: { root: copy.root, sha256: copy.sha256, source_sha256: copy.source_sha256 }, providerSessionId: launch.provider_session_id, profileJson: launch.profile_json };
875
+ }
876
+ function assertReviewCopyUnchanged(context, work, run, link) {
877
+ if (!link)
878
+ return;
879
+ const row = context.db.get("SELECT start_receipt_json FROM work_sessions WHERE id = ?", [link.id]);
880
+ const external = context.db.get("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'work_launches'")
881
+ ? context.db.get("SELECT 1 FROM work_launches WHERE work_id = ? LIMIT 1", [work.work_id]) : null;
882
+ if (!row?.start_receipt_json) {
883
+ if (external)
884
+ throw new AppError("read_only_workspace_unbound", "External review has no committed start receipt", 1, { work_id: work.work_id });
885
+ return;
886
+ }
887
+ let copy;
888
+ let reviewPaths = [];
889
+ try {
890
+ const receipt = JSON.parse(row.start_receipt_json);
891
+ const retained = receipt.context ? JSON.parse(receipt.context) : null;
892
+ copy = retained?.review_copy;
893
+ reviewPaths = retained?.review_input?.run_inputs.filter(item => item.root === "workspace").map(item => item.path) ?? [];
894
+ }
895
+ catch {
896
+ throw new AppError("read_only_workspace_unbound", "Review start receipt is malformed", 1, { work_id: work.work_id });
897
+ }
898
+ if (!copy) {
899
+ if (external)
900
+ throw new AppError("read_only_workspace_unbound", "External review start receipt has no copy binding", 1, { work_id: work.work_id });
901
+ return;
902
+ }
903
+ if (!copy.root || !copy.sha256)
904
+ throw new AppError("read_only_workspace_unbound", "Review copy receipt is incomplete", 1, { work_id: work.work_id });
905
+ const base = reviewWorkspaceBase(context, work);
906
+ const actual = reviewExecutionCopyHash(reviewInputPath(requireRunHome(run), copy.root), run.workspace_root, true, reviewPaths, base);
907
+ if (actual !== copy.sha256)
908
+ throw new AppError("read_only_input_changed", "Read-only execution copy changed during review", 2, { work_id: work.work_id, expected: copy.sha256, current: actual });
909
+ if (copy.source_sha256 && reviewSourceHash(run.workspace_root, reviewPaths, base) !== copy.source_sha256)
910
+ throw new AppError("read_only_input_changed", "Authoritative review workspace changed during review", 2, { work_id: work.work_id });
911
+ }
912
+ function reviewWorkspaceBase(context, work) {
913
+ const row = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]);
914
+ const base = row && JSON.parse(row.index_json).workspace?.base;
915
+ return base && base !== "unknown" ? base : "HEAD";
916
+ }
917
+ function legacyReviewFingerprint(work, run) {
918
+ const kind = parsePayload(work)?.kind;
919
+ const relative = kind === "code-review" ? "06-code-review/review-context.json" : kind === "plan-review" ? "04-plan-review/work-context.json" : null;
920
+ if (!relative || !work.parent_work_id)
921
+ return null;
922
+ const file = path.join(requireRunHome(run), relative);
923
+ if (!fs.existsSync(file))
924
+ return null;
925
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
926
+ return value.read_only_input?.version !== 2 && value.read_only_input?.parent_work_id === work.parent_work_id && typeof value.read_only_input.fingerprint === "string" ? value.read_only_input.fingerprint : null;
927
+ }
928
+ function assertSharedReviewInput(context, work, run, generation = recoveryGuard(context, work.project_id, work.run_id)?.generation ?? 0) {
929
+ const payload = parsePayload(work), kind = payload?.kind;
930
+ const relative = kind === "code-review" ? "06-code-review/review-context.json" : kind === "plan-review" ? "04-plan-review/work-context.json" : null;
931
+ if (!relative || !work.parent_work_id)
932
+ return null;
933
+ const home = requireRunHome(run), file = path.join(home, relative);
934
+ if (!fs.existsSync(file))
935
+ throw new AppError("review_input_missing", "The review stage context is missing", 1, { work_id: work.work_id, file: relative });
936
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
937
+ if (value.read_only_input?.parent_work_id !== work.parent_work_id)
938
+ throw new AppError("review_input_invalid", "Reviewer belongs to a different review group", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
939
+ const index = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [work.project_id, work.run_id]).index_json);
940
+ const stage = (name) => index.stage_runs?.find(item => item.stage === name);
941
+ if (value.read_only_input.version !== 2 && value.read_only_input.version !== 3) {
942
+ if (stage(kind === "plan-review" ? "plan" : "code")?.data_sha256)
943
+ throw new AppError("review_input_contract_missing", "A new review run cannot downgrade its input contract", 1, { work_id: work.work_id });
944
+ return null; // Historical receipts retain their original fingerprint contract.
945
+ }
946
+ const expected = new Map();
947
+ const add = (root, relativePath, sha) => {
948
+ if (!/^[a-f0-9]{64}$/.test(sha))
949
+ throw new AppError("review_input_invalid", "Review input lacks an accepted checksum", 1, { path: relativePath });
950
+ reviewInputPath(root === "run" ? home : run.workspace_root, relativePath);
951
+ const key = `${root}:${relativePath}`;
952
+ if (expected.has(key) && expected.get(key) !== sha)
953
+ throw new AppError("review_input_invalid", "Review input has conflicting accepted checksums", 1, { path: relativePath });
954
+ expected.set(key, sha);
955
+ };
956
+ const stageReport = (name, relativePath) => {
957
+ const sha = stage(name)?.data_sha256;
958
+ if (!sha)
959
+ throw new AppError("review_input_contract_missing", "Accepted stage report has no committed checksum", 1, { stage: name });
960
+ add("run", relativePath, sha);
961
+ const reportFile = reviewInputPath(home, relativePath);
962
+ if (!fs.existsSync(reportFile) || sha256(fs.readFileSync(reportFile, "utf8")) !== sha)
963
+ throw new AppError("read_only_input_changed", "Accepted stage report changed before or during review", 2, { path: relativePath, expected: sha });
964
+ return JSON.parse(fs.readFileSync(reportFile, "utf8"));
965
+ };
966
+ if (kind === "plan-review") {
967
+ const accepted = stageReport("plan", "03-plan/stage-report.json").semantic;
968
+ if (!accepted?.plan_checksums?.length || !accepted.aspect_map_checksums?.length || !accepted.batch_checksum)
969
+ throw new AppError("review_input_contract_missing", "Accepted PLAN lacks exact review input checksums", 1);
970
+ for (const item of accepted.plan_checksums)
971
+ add("workspace", item.path, item.sha256);
972
+ for (const item of accepted.aspect_map_checksums)
973
+ add("run", item.path, item.sha256);
974
+ add("run", "03-plan/code-work-batch.json", accepted.batch_checksum);
975
+ if (value.system?.batch_checksum !== accepted.batch_checksum)
976
+ throw new AppError("read_only_input_changed", "PLAN-REVIEW batch differs from accepted PLAN", 2);
977
+ const planSet = accepted.plan_checksums.map(item => ({ protocol_id: path.basename(path.dirname(item.path)), path: item.path, sha256: item.sha256 })).sort((a, b) => a.protocol_id.localeCompare(b.protocol_id));
978
+ if (value.system?.plan_checksum !== sha256(JSON.stringify(planSet)))
979
+ throw new AppError("read_only_input_changed", "PLAN-REVIEW plan revision differs from accepted PLAN", 2);
980
+ }
981
+ else {
982
+ stageReport("code", "05-code/stage-report.json");
983
+ if (value.code_report_sha256 !== stage("code")?.data_sha256 || !stage("code-review")?.input_sha256 || !value.review_batch_sha256 || !Array.isArray(value.aspect_map_checksums) || !value.aspect_map_checksums.length
984
+ || sha256(JSON.stringify({ batch: value.review_batch_sha256, aspect_maps: value.aspect_map_checksums })) !== stage("code-review")?.input_sha256)
985
+ throw new AppError("review_input_contract_missing", "CODE-REVIEW inputs are not bound to their accepted stage", 1);
986
+ add("run", "06-code-review/review-work-batch.json", value.review_batch_sha256);
987
+ for (const item of value.aspect_map_checksums)
988
+ add("run", item.path, item.sha256);
989
+ if (value.cycle !== payload?.review_cycle)
990
+ throw new AppError("review_input_invalid", "CODE-REVIEW cycle differs from its Work", 1);
991
+ }
992
+ const runInputs = [...expected].map(([key, sha]) => { const [root, ...parts] = key.split(":"); return { root: root, path: parts.join(":"), sha256: sha }; }).sort((a, b) => `${a.root}:${a.path}`.localeCompare(`${b.root}:${b.path}`));
993
+ for (const item of runInputs) {
994
+ const target = reviewInputPath(item.root === "run" ? home : run.workspace_root, item.path);
995
+ const actual = fs.existsSync(target) ? sha256(fs.readFileSync(target, "utf8")) : null;
996
+ if (actual !== item.sha256)
997
+ throw new AppError("read_only_input_changed", "Accepted review input changed", 2, { work_id: work.work_id, root: item.root, path: item.path, expected: item.sha256, current: actual });
998
+ }
999
+ const reviewPaths = runInputs.filter(item => item.root === "workspace").map(item => item.path);
1000
+ if (value.read_only_input.version === 3)
1001
+ for (const relative of reviewPaths) {
1002
+ if (!reviewEnvironmentAllowed(relative))
1003
+ throw new AppError("review_input_invalid", "Required review input is excluded from external review", 1, { path: relative });
1004
+ }
1005
+ const base = index.workspace?.base && index.workspace.base !== "unknown" ? index.workspace.base : "HEAD";
1006
+ const selectedPaths = value.read_only_input.version === 3 ? reviewSourcePaths(run.workspace_root, reviewPaths, base) : null;
1007
+ if (selectedPaths && reviewPaths.some(relative => !selectedPaths.includes(relative)))
1008
+ throw new AppError("review_input_invalid", "Required review input is excluded from external review", 1, { work_id: work.work_id });
1009
+ const selectedSource = value.read_only_input.version === 3 ? reviewSourceHash(run.workspace_root, reviewPaths, base) : null;
1010
+ const current = { schema_id: value.read_only_input.version === 3 ? "dd-flow/review-input@3" : "dd-flow/review-input@2", parent_work_id: work.parent_work_id, kind: String(kind), cycle: kind === "code-review" ? String(payload?.review_cycle) : "initial", generation, workspace_fingerprint: selectedSource ?? workspaceFingerprint(run.workspace_root), ...(selectedSource ? { source_policy: "review-inputs@1", source_sha256: selectedSource } : {}), run_inputs: runInputs };
1011
+ assertReviewSiblingBaseline(context, work, current, generation);
1012
+ return current;
1013
+ }
1014
+ function assertReviewSiblingBaseline(context, work, current, generation) {
1015
+ const committed = context.db.all("SELECT ws.start_receipt_json FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id = ? AND json_extract(w.payload_json, '$.kind') = ? AND ws.start_receipt_json IS NOT NULL ORDER BY ws.created_at, ws.id", [work.project_id, work.run_id, work.parent_work_id, current.kind]);
1016
+ for (const row of committed) {
1017
+ const receipt = JSON.parse(row.start_receipt_json);
1018
+ if (!receipt.context)
1019
+ continue;
1020
+ const prior = JSON.parse(receipt.context).review_input;
1021
+ if (!prior || prior.cycle !== current.cycle)
1022
+ continue;
1023
+ // Recovery changes the execution owner, not the review revision. A
1024
+ // completed sibling from an older generation still pins this review cycle.
1025
+ if (prior.schema_id !== current.schema_id || JSON.stringify({ ...prior, generation }) !== JSON.stringify(current))
1026
+ throw new AppError("read_only_input_changed", "Reviewer inputs differ from the first committed review start", 2, { work_id: work.work_id, baseline: prior, current });
1027
+ break;
1028
+ }
1029
+ }
1030
+ async function settle(context, id, status, result, progress, retry) {
1031
+ const { work, run, link } = prepareWorkSettlement(context, id, status, result);
1032
+ id = work.work_id;
324
1033
  let receipts = [];
325
1034
  let coordinationDrift = [];
326
- if (status === "completed") {
327
- if (parsePayload(work)?.read_only === true) {
1035
+ const assertReadOnlyInput = () => {
1036
+ if (status === "completed" && parsePayload(work)?.read_only === true) {
1037
+ assertReviewCopyUnchanged(context, work, run, link);
1038
+ const shared = assertSharedReviewInput(context, work, run);
1039
+ if (shared)
1040
+ return;
1041
+ const legacy = legacyReviewFingerprint(work, run);
1042
+ // The final reviewer commits the shared input boundary under the writer
1043
+ // reservation. Concurrent finishes cannot both skip this verification.
1044
+ if (legacy && context.db.get("SELECT 1 FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND work_id <> ? AND status IN ('created','running','paused') AND json_extract(payload_json, '$.read_only') = 1 AND json_extract(payload_json, '$.kind') = ? LIMIT 1", [work.project_id, work.run_id, work.parent_work_id, work.work_id, parsePayload(work)?.kind]))
1045
+ return;
328
1046
  const contextFile = link ? path.join(path.dirname(link.prompt_path), "context.json") : "";
329
- const baseline = contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null;
1047
+ const baseline = legacy ?? (contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null);
330
1048
  const current = workspaceFingerprint(run.workspace_root);
331
1049
  if (typeof baseline !== "string" || baseline !== current)
332
- throw new AppError("read_only_work_mutated_workspace", "Read-only Work changed the accepted workspace", 2, { work_id: work.work_id, baseline, current });
1050
+ throw new AppError("read_only_input_changed", "The workspace changed during read-only Work; its review input is stale (the writer is unknown)", 2, { work_id: work.work_id, baseline, current });
333
1051
  }
1052
+ };
1053
+ if (status === "completed") {
334
1054
  validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
335
1055
  const packet = codePacket(work);
336
1056
  if (packet) {
@@ -340,42 +1060,58 @@ async function settle(context, id, status, result, progress) {
340
1060
  readCodeCheckProfile(run.workspace_root);
341
1061
  coordinationDrift = plannedAreaDrift(packet, result);
342
1062
  const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
343
- receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: packet.checks.filter((check) => check.run_at === "work"), ...(progress ? { progress } : {}) });
1063
+ const requiredChecks = packet.repair?.verification_check_refs?.length
1064
+ ? uniqueChecks([...packet.checks.filter((check) => check.run_at === "work"), ...packet.repair.verification_check_refs])
1065
+ : packet.checks.filter((check) => check.run_at === "work");
1066
+ if (retry)
1067
+ validateFailedCheckRetry(context, { projectId: work.project_id, runId: work.run_id, receiptId: retry.receiptId, declarations: requiredChecks, reason: retry.reason });
1068
+ receipts = await runCodeChecks(context, { projectId: work.project_id, runId: work.run_id, runHome: requireRunHome(run), workspaceRoot: run.workspace_root, workId: work.work_id, artifactDir, scope: "work", checks: requiredChecks, ...(retry ? { retryReceiptId: retry.receiptId, retryReason: retry.reason } : {}), ...(progress ? { progress } : {}) });
344
1069
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
345
1070
  if (failed.length)
346
1071
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
347
1072
  }
348
1073
  }
1074
+ // Shared-input and workspace fingerprint checks read files and may traverse
1075
+ // the project tree. Keep them before the SQLite writer reservation.
1076
+ assertReadOnlyInput();
1077
+ const resultReceipt = link?.result_path && work.parent_work_id !== null
1078
+ ? JSON.stringify({ work_id: work.work_id, work_session_id: link.id, status, result_sha256: sha256(result), previous_sha256: receiptFileHash(link.result_path) })
1079
+ : null;
349
1080
  const now = context.now();
350
- context.db.exec("BEGIN IMMEDIATE");
351
- try {
352
- context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
1081
+ context.db.writeTransaction(() => {
1082
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
1083
+ if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
1084
+ throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
1085
+ if (link && context.db.get("SELECT start_publication_status FROM work_sessions WHERE id = ?", [link.id])?.start_publication_status && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND start_publication_status = 'published'", [link.id]))
1086
+ throw new AppError("work_start_publication_pending", "Finish publishing this Work's committed start packet before settlement", 1, { work_id: id, effect: "no_effect" });
1087
+ if (context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = ? AND updated_at = ?", [status, result, now, now, id, work.status, work.updated_at]).changes !== 1)
1088
+ throw new AppError("work_generation_stale", "Work changed while its result and review inputs were prepared", 1, { work_id: id });
353
1089
  if (link) {
354
- if (link.result_path && work.parent_work_id !== null)
355
- fs.writeFileSync(link.result_path, result);
356
- context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
1090
+ context.db.run("UPDATE work_sessions SET status = ?, result_receipt_json = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, resultReceipt, now, now, link.id]);
357
1091
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
358
1092
  if (!stillRunning)
359
1093
  context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, link.session_id]);
360
1094
  }
361
- context.db.exec("COMMIT");
362
- }
363
- catch (error) {
364
- context.db.exec("ROLLBACK");
365
- throw error;
366
- }
1095
+ });
1096
+ materializePendingWorkResults(context, work.project_id, work.run_id);
1097
+ settleRecoveryWork(context, work.work_id, status);
367
1098
  refreshRunWorkProjection(context, work.project_id, work.run_id);
368
- const newlyReady = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, start_command: workStartCommand(context, candidate) }));
1099
+ const control = runControlDirective(context, work.project_id, work.run_id);
1100
+ const newlyReady = control ? [] : context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'created' ORDER BY created_at, work_id`, [work.project_id, work.run_id]).filter((candidate) => isReady(context, candidate)).map((candidate) => ({ work_id: candidate.work_id, launch_policy: candidate.launch_policy, start_command: workStartCommand(context, candidate) }));
369
1101
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: `work_${status}`, work_id: work.work_id, session_id: link?.session_id ?? null, ...(coordinationDrift.length ? { coordination_drift: coordinationDrift } : {}) });
370
1102
  for (const ready of newlyReady)
371
1103
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
372
- return { ok: true, work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
1104
+ return { ok: true, ...(control ? { control } : {}), work_id: id, status, checks: receipts, coordination: { planned_write_areas: codePacket(work)?.planned_write_areas ?? [], drift: coordinationDrift }, newly_ready: newlyReady, graph: codeWorkGraph(context, work.project_id, work.run_id), usage: { status: "provisional", reason: "final usage is controller-owned" } };
373
1105
  }
1106
+ function recoveryPrompt(recovery) { return ["<recovery_identity>", `- recovery_id: ${recovery.recoveryId}`, `- execution_generation: ${recovery.generation}`, "This Work was interrupted and is now an authorized recovery segment of the same assignment.", "</recovery_identity>", "", "<recovery_instructions>", `Read the retained pre-interruption evidence at ${recovery.evidencePath}.`, "Before changing files or rerunning a command, inspect the workspace, receipts, logs, and partial artifacts. Do not assume the last command succeeded or failed merely because its reply was lost.", "Preserve accepted work. Do not repeat completed Work, create undeclared Work, alter the original requirements, or edit dd-flow runtime state/evidence.", "Observe an existing check or finish instead of duplicating it. Retry only through an exact runtime retry command after establishing that its prior outcome permits it.", "Record the concrete unresolved operation rather than guessing if an external effect cannot be reconciled.", "</recovery_instructions>"].join("\n"); }
374
1107
  function plannedAreaDrift(packet, result) {
375
1108
  const changed = JSON.parse(result).changed_paths;
376
1109
  if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
377
1110
  return [];
378
- return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => item === area || item.startsWith(`${area}/`)));
1111
+ return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => {
1112
+ const normalized = area.replace(/[\\/]+$/, "");
1113
+ return item === normalized || item.startsWith(`${normalized}/`);
1114
+ }));
379
1115
  }
380
1116
  function bindSession(context, work, run, identity, now) {
381
1117
  const priorWorkSession = context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.work_id])?.session_id ?? null;
@@ -385,17 +1121,31 @@ function bindSession(context, work, run, identity, now) {
385
1121
  // Work ancestry and provider-tree containment are different relations. An
386
1122
  // isolated worker is logically a child Work but physically a provider root.
387
1123
  const parentSession = inferredParentSession ?? identity.parentSessionId;
1124
+ // Stored provider ancestry uses internal keys, just like hook_events.
1125
+ // Resolve native IDs only when rendering diagnostics; changing storage here
1126
+ // breaks existing bindings and usage ancestry joins.
388
1127
  const providerParentSession = identity.parentSessionId;
1128
+ const identityDetails = (sessionKey = identity.sessionId, parentKey = parentSession) => ({
1129
+ internal_session_key: sessionKey,
1130
+ native_session_id: sessionKey === identity.sessionId ? identity.nativeSessionId : context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, sessionKey])?.provider_session_id ?? null,
1131
+ internal_parent_session_key: parentKey,
1132
+ native_parent_session_id: parentKey ? context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, parentKey])?.provider_session_id ?? null : null,
1133
+ harness: identity.harness
1134
+ });
389
1135
  if (work.parent_work_id && !parentSession)
390
1136
  throw new AppError("parent_session_required", "Child Work requires a confirmed parent Work/Session link", 1, { work_id: work.work_id, parent_work_id: work.parent_work_id });
1137
+ if (work.launch_policy === "fresh_agent_required" && inferredParentSession && identity.parentSessionId && identity.parentSessionId !== inferredParentSession)
1138
+ throw new AppError("native_work_parent_mismatch", "Fresh native Work must be started by a direct child of its assigned parent Session", 1, { work_id: work.work_id, effect: "no_effect", ...identityDetails(), observed_provider_parent_session_key: identity.parentSessionId });
391
1139
  if (work.launch_policy === "fresh_agent_required" && (identity.sessionId === parentSession || context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? LIMIT 1", [work.project_id, work.run_id, identity.sessionId])))
392
- throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, session_id: identity.sessionId });
1140
+ throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, effect: "no_effect", recoverable: true, handoff_required: true, ...identityDetails() });
393
1141
  const existing = context.db.get("SELECT session_id, parent_session_id, provider_parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
394
- if (existing && existing.parent_session_id && parentSession && existing.parent_session_id !== parentSession)
395
- throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { session_id: identity.sessionId });
1142
+ // A reuse-allowed Work can follow another Work in this same Session. Its
1143
+ // Work predecessor is not a new physical/logical parent of the Session.
1144
+ if (existing && existing.parent_session_id && parentSession && parentSession !== identity.sessionId && existing.parent_session_id !== parentSession)
1145
+ throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { work_id: work.work_id, ...identityDetails(existing.session_id, existing.parent_session_id), conflicting_internal_parent_session_key: parentSession });
396
1146
  if (existing?.provider_parent_session_id && providerParentSession && existing.provider_parent_session_id !== providerParentSession)
397
- throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { session_id: identity.sessionId });
398
- context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = excluded.cwd, updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, run.workspace_root, now, now]);
1147
+ throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { work_id: work.work_id, ...identityDetails(existing.session_id, existing.parent_session_id), conflicting_internal_provider_parent_session_key: providerParentSession, conflicting_native_parent_session_id: context.db.get("SELECT provider_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, providerParentSession])?.provider_session_id ?? null });
1148
+ context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_session_id, provider_parent_session_id, agent_id, parent_session_id, provider, model, reasoning, mode, agent_type, project_root, flow_kind, status, run_id, protocol_id, worker_id, workspace_path, continuation_policy, current_stage, next_action, last_action_hash, continuation_count, stop_reason, transcript_path, cwd, metadata_json, coverage_units_json, created_at, updated_at, stopped_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'vnext', 'active', ?, NULL, ?, ?, 'go_router', 'work', NULL, NULL, 0, NULL, ?, ?, '{}', '[]', ?, ?, NULL) ON CONFLICT(session_id, project_id) DO UPDATE SET harness = excluded.harness, provider_session_id = COALESCE(excluded.provider_session_id, provider_session_id), provider_parent_session_id = COALESCE(excluded.provider_parent_session_id, provider_parent_session_id), agent_id = COALESCE(excluded.agent_id, agent_id), parent_session_id = COALESCE(excluded.parent_session_id, parent_session_id), provider = COALESCE(excluded.provider, provider), model = COALESCE(excluded.model, model), reasoning = COALESCE(excluded.reasoning, reasoning), mode = COALESCE(excluded.mode, mode), agent_type = COALESCE(excluded.agent_type, agent_type), flow_kind = excluded.flow_kind, run_id = excluded.run_id, worker_id = excluded.worker_id, workspace_path = excluded.workspace_path, transcript_path = COALESCE(excluded.transcript_path, transcript_path), cwd = COALESCE(excluded.cwd, sessions.cwd), updated_at = excluded.updated_at`, [identity.sessionId, work.project_id, identity.harness, identity.providerSessionId, providerParentSession, identity.agentId, existing?.parent_session_id ?? (parentSession === identity.sessionId ? null : parentSession), identity.provider, identity.model, identity.reasoning, identity.mode, identity.agentType, run.project_root, work.run_id, work.work_id, run.workspace_root, identity.transcriptPath, identity.cwd, now, now]);
399
1149
  reactivateBoundSession(context, work.project_id, identity.sessionId, now);
400
1150
  }
401
1151
  /** Register the trusted physical Session before a paused Work moves to it. */
@@ -408,6 +1158,15 @@ export function bindSessionForResume(context, workId, identity, now) {
408
1158
  function reactivateBoundSession(context, projectId, sessionId, now) {
409
1159
  context.db.run("UPDATE sessions SET status = 'active', stop_reason = NULL, stopped_at = NULL, updated_at = ? WHERE project_id = ? AND session_id = ?", [now, projectId, sessionId]);
410
1160
  }
1161
+ export function prepareWorkResult(context, id, result) {
1162
+ try {
1163
+ const { work, run, link } = prepareWorkSettlement(context, id, "completed", result);
1164
+ validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
1165
+ }
1166
+ catch (error) {
1167
+ prepareError(error, { work_id: id, input_sha256: sha256(result) });
1168
+ }
1169
+ }
411
1170
  function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, runId, resultPath) {
412
1171
  if (!work.result_schema)
413
1172
  return;
@@ -420,15 +1179,8 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
420
1179
  catch {
421
1180
  throw new AppError("validation", "Work result must be JSON for its declared result schema", 2, { work_id: work.work_id, result_schema: work.result_schema });
422
1181
  }
423
- const candidate = `${resultPath}.candidate-${process.pid}`;
424
- fs.writeFileSync(candidate, result);
425
- try {
426
- validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: candidate, projectRoot, runId, runRoot: runHome });
427
- }
428
- finally {
429
- fs.rmSync(candidate, { force: true });
430
- }
431
- if (work.result_schema === "dd-flow/code-work-result@2")
1182
+ validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: resultPath, data: parsed, projectRoot, runId, runRoot: runHome });
1183
+ if (work.result_schema === "dd-flow/code-work-result@3")
432
1184
  validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
433
1185
  if (work.result_schema === "dd-flow/code-review-result@1")
434
1186
  validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
@@ -437,20 +1189,29 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
437
1189
  }
438
1190
  function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
439
1191
  const result = value;
1192
+ const changedPaths = (result.changed_paths ?? []).map((item) => normalizeWorkspacePath(projectRoot, item));
1193
+ const ignored = ignoredWorkspacePaths(projectRoot, changedPaths);
1194
+ if (ignored.length)
1195
+ throw new AppError("environment_repair_not_product_change", "CODE Work must record ignored environment repair separately, not as a product changed_path", 2, { work_id: work.work_id, paths: ignored });
440
1196
  if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
441
1197
  throw new AppError("work_contract_incomplete", "CODE Work cannot complete with unresolved deviations or blockers; fail the Work and report the contract mismatch", 2, { work_id: work.work_id, deviations: result.deviations ?? [], blockers: result.blockers ?? [] });
442
1198
  const packet = codePacket(work);
443
1199
  if (!packet)
444
1200
  return;
445
- const acceptedCriteria = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
1201
+ const acceptedObligations = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
1202
+ for (const check of packet.checks)
1203
+ acceptedObligations.add(check.id);
1204
+ for (const finding of packet.repair?.review_findings ?? [])
1205
+ for (const ref of finding.obligation_refs ?? [])
1206
+ acceptedObligations.add(ref);
446
1207
  for (const item of result.evidence ?? []) {
447
- if (!item.criterion_id || !acceptedCriteria.has(item.criterion_id))
448
- throw new AppError("evidence_criterion_unknown", "CODE Work evidence must reference an acceptance criterion assigned to this Work", 2, { work_id: work.work_id, criterion_id: item.criterion_id ?? null });
1208
+ if (!item.obligation_ref || !acceptedObligations.has(item.obligation_ref))
1209
+ throw new AppError("evidence_obligation_unknown", "CODE Work evidence must reference an obligation assigned to this Work", 2, { work_id: work.work_id, obligation_ref: item.obligation_ref ?? null, accepted_obligation_refs: [...acceptedObligations] });
449
1210
  for (const ref of item.refs ?? [])
450
1211
  assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
451
1212
  }
452
1213
  const documentUpdates = (packet.document_updates ?? []);
453
- const changed = new Set(result.changed_paths ?? []);
1214
+ const changed = new Set(changedPaths);
454
1215
  const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
455
1216
  if (missingDocuments.length)
456
1217
  throw new AppError("document_update_missing", "CODE Work did not materialize every document update assigned by PLAN", 2, { work_id: work.work_id, missing_paths: missingDocuments });
@@ -459,10 +1220,10 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
459
1220
  if (unchangedDocuments.length)
460
1221
  throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
461
1222
  const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
1223
+ validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
462
1224
  if (assigned.length) {
463
- if ((result.changed_paths?.length ?? 0) === 0)
1225
+ if (changedPaths.length === 0)
464
1226
  throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
465
- validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
466
1227
  for (const resolution of result.resolutions ?? [])
467
1228
  for (const ref of resolution.evidence_refs ?? [])
468
1229
  assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
@@ -476,25 +1237,72 @@ export function validateReviewResolutions(workId, assigned, resolutions) {
476
1237
  if (missing.length || unexpected.length || duplicate)
477
1238
  throw new AppError("review_repair_incomplete", "Review repair must include exactly one resolution for each assigned finding reference", 2, { work_id: workId, missing, unexpected, duplicate });
478
1239
  }
479
- function renderWorkerPrompt(context, work, run, dependencies) {
1240
+ function renderWorkerPrompt(context, work, run, dependencies, commands) {
1241
+ const body = renderWorkerPromptBody(context, work, run, dependencies, commands);
1242
+ return ["<accepted_decision_precedence>", `Read ${path.join(requireRunHome(run), "01-specify", "specify.json")} and later accepted RUN decisions relevant to this Work.`, acceptedDecisionPrecedence, "</accepted_decision_precedence>", "", body].join("\n");
1243
+ }
1244
+ function workerLifecycleCommands(context, work, run) {
1245
+ const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
1246
+ return {
1247
+ finish: managedLifecycleCommand(context, runtimeCommand(context, ["work", "finish", work.work_id, "--result-file", resultInputPath, "--project-root", run.project_root, "--json", "--progress-jsonl"])),
1248
+ fail: managedLifecycleCommand(context, runtimeCommand(context, ["work", "fail", shortWorkId(work.work_id), "--reason", "receipt path + exact external or semantic blocker", "--project-root", run.project_root, "--json"]))
1249
+ };
1250
+ }
1251
+ function renderWorkerPromptBody(context, work, run, dependencies, commands) {
480
1252
  const command = flowCommand(context);
481
1253
  const packet = codePacket(work);
482
- const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies the materialization and then executes the check.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
1254
+ const readOnly = parsePayload(work)?.read_only === true;
1255
+ const resultInputPath = path.join(requireRunHome(run), "works", work.work_id, "result-input.json");
1256
+ const lifecycleCommands = commands ?? workerLifecycleCommands(context, work, run);
1257
+ const finishWorkCommand = lifecycleCommands.finish;
1258
+ const failWorkCommand = lifecycleCommands.fail;
1259
+ const mergeWork = parsePayload(work)?.kind === "merge";
1260
+ const writeBoundary = readOnly
1261
+ ? [`Review inputs under workspace_root (${run.workspace_root}) and run_home are read-only. After Work start, work from workspace_root and do not edit project source, PLAN, aspect maps, CODE evidence, or another Work's files.`, "Do not write inside workspace_root, including an isolated review copy. Write only this Work's declared result-input.json and explicitly assigned Work-owned evidence paths under run_home. Report changed or inaccessible review inputs as a blocker; do not repair them."]
1262
+ : mergeWork
1263
+ ? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
1264
+ : [`HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not."];
1265
+ const completionRepair = readOnly
1266
+ ? ["If Finish reports input drift or an infrastructure failure, report the exact receipt and stop. Do not change the reviewed files to make the check pass."]
1267
+ : mergeWork
1268
+ ? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
1269
+ : ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then use the returned retry_command with your result. Internal invocation IDs are runtime-owned; do not copy or invent them. Do not invent a cause that does not appear in the retained output."];
1270
+ const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
483
1271
  if (packet)
484
1272
  codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
485
- return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", `HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not.", "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "The CLI runs every declared required check before accepting this Work. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", "Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output.", "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
1273
+ if (packet)
1274
+ codeContext.push("<temporary_services>", "Prefer the declared check launcher: it already owns check resources. If the planned scenario genuinely requires an interactive HTTP service, use the managed supervisor below. This is a template: replace the project service command, port names and readiness path from the plan/project instructions; do not invent a fixed port.", `${command} runtime process start --run ${run.id} --project-root ${JSON.stringify(run.project_root)} --command '<project-service-command>' --ports api --ready-port api --ready-path /health --json --progress-jsonl`, "The service receives DD_FLOW_PORT_API (and equivalent variables for all declared names). The command stays running as its supervisor. Retain its tool handle; wait for the service ready event and read its service.json receipt. Pass those exact ports and the same project environment to reset/seed, API and browser operations.", "A ready receipt proves service readiness only. Record the scenario outcome and real evidence separately. After the scenario, execute the exact stop_command from that receipt, then wait for the supervisor to exit. Never use pkill/killall or stop a sibling's process. If cleanup fails, retain the process id and report the failure; do not claim the resource is free.", "</temporary_services>", "");
1275
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Copy the quoted alias literally; do not add backslashes. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), `Write the completed JSON to ${resultInputPath}. This is an attempt input, not the canonical result.json receipt. dd-flow validates the file and atomically stores the canonical receipt.`, "</result_contract>", ""] : []), "<completion>", packet?.repair?.verification_check_refs?.length ? "Work finish runs its normal work-scoped checks plus the listed causal repair checks. Their original run_at remains an aggregate obligation; this is the additional proof required before accepting this repair." : "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed.", "A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, lifecycleRetryInstruction, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `First write only the result JSON to ${resultInputPath}. Then run this standalone command:\n${finishWorkCommand}`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
486
1276
  }
487
- function resultSchemaGuidance(work, runId) {
1277
+ export function resultSchemaGuidance(work, runId) {
488
1278
  const schema = work.result_schema;
489
1279
  const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
490
- if (schema === "dd-flow/code-work-result@2")
491
- return [refs, "Use this complete minimal shape. For a review repair, include exactly one resolution per assigned finding; explain what changed and cite evidence that demonstrates the required outcome:", "```json", JSON.stringify({ schema_id: schema, summary: "What was implemented.", changed_paths: ["project-relative/path"], evidence: [{ criterion_id: "AC-001", refs: ["project-relative/evidence", `run://${runId}/05-code/checks/receipt.json`] }], deviations: [], blockers: [], resolutions: [{ finding_ref: "WRK-000/FIND-001", summary: "How the required outcome was achieved.", evidence_refs: ["project-relative/evidence"] }] }, null, 2), "```"];
1280
+ if (schema === "dd-flow/code-work-result@3") {
1281
+ const packet = codePacket(work);
1282
+ const obligations = [...new Set([
1283
+ ...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
1284
+ ...(packet?.checks ?? []).map((item) => item.id),
1285
+ ...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
1286
+ ])];
1287
+ const findings = packet?.repair?.review_findings ?? [];
1288
+ return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
1289
+ "This is a shape template, not a completed result. Populate changed_paths and evidence from the actual work and existing files; empty arrays below do not establish completion.",
1290
+ "For a gate repair, cite the assigned check id. For a review repair, replace each resolution summary and fill evidence_refs with existing evidence proving its required outcome. Ordinary Work has resolutions: [].",
1291
+ "```json", JSON.stringify({ schema_id: schema, summary: "Describe the actual implementation.", changed_paths: [], evidence: [], deviations: [], blockers: [], resolutions: findings.map((finding) => ({ finding_ref: finding.finding_ref, summary: "Describe how the assigned required outcome was achieved.", evidence_refs: [] })) }, null, 2), "```",
1292
+ "Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
1293
+ }
492
1294
  if (schema === "dd-flow/code-review-result@1") {
493
- return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Use this complete minimal shape. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: ["path/to/file", `run://${runId}/05-code/checks/receipt.json`] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
1295
+ const aspect = { aspect_id: "assigned_aspect_id", verdict: "pass", summary: "Conclusion.", evidence_refs: ["path/to/evidence"] };
1296
+ const clean = { schema_id: schema, verdict: "pass", summary: "Concise evidence-backed conclusion.", aspects: [aspect], findings: [] };
1297
+ const mixed = { ...clean, verdict: "findings", aspects: [aspect, { ...aspect, aspect_id: "another_assigned_aspect_id", verdict: "findings" }], findings: [{ finding_id: "FIND-001", aspect_id: "another_assigned_aspect_id", priority: "p1", problem: "Describe the evidenced defect.", impact: "Describe its material impact.", required_outcome: "Describe the required correction.", evidence_refs: ["path/to/evidence"], obligation_refs: ["assigned_obligation_ref"] }] };
1298
+ return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Examples below show no findings and mixed aspect outcomes, not completed results. Replace placeholder ids and paths with assigned aspects, obligations and existing evidence. Choose verdict pass, findings or blocked. Keep findings: [] when none exist. Report only material, direct-evidence defects, not taste or cosmetics.", "```json", JSON.stringify(clean, null, 2), "```", "```json", JSON.stringify(mixed, null, 2), "```"];
494
1299
  }
495
1300
  if (schema !== "dd-flow/plan-review-result@1")
496
1301
  return [];
497
- return [refs, "Use this complete minimal shape; do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/file", `run://${runId}/03-plan/plan.json`], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
1302
+ const aspect = { aspect_id: "assigned_aspect_id", verdict: "pass", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/evidence"], findings: [] };
1303
+ const clean = { schema_id: schema, plan_revision: 1, overall_verdict: "pass", summary: "Concise evidence-backed conclusion.", aspects: [aspect] };
1304
+ const mixed = { ...clean, overall_verdict: "needs_changes", aspects: [aspect, { ...aspect, aspect_id: "another_assigned_aspect_id", verdict: "needs_changes", findings: [{ finding_id: "FIND-001", severity: "high", summary: "Describe the evidenced defect.", evidence_refs: ["path/to/evidence"] }] }] };
1305
+ return [refs, "Examples below show no findings and mixed aspect outcomes, not completed results. Assess each assigned aspect once, replace placeholder ids, plan_revision and paths with packet values and existing evidence. Choose verdict pass, watch, needs_changes or blocked. Keep findings: [] when an aspect has no findings. Do not add fields. Number findings locally as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them.", "```json", JSON.stringify(clean, null, 2), "```", "```json", JSON.stringify(mixed, null, 2), "```"];
498
1306
  }
499
1307
  export function validateCodeReviewResultIdentity(work, value) {
500
1308
  const group = codeReviewGroup(work);
@@ -523,6 +1331,7 @@ export function validateCodeReviewResultIdentity(work, value) {
523
1331
  function validateCodeReviewResult(work, value, input) {
524
1332
  validateCodeReviewResultIdentity(work, value);
525
1333
  const result = value;
1334
+ assertReviewAspectEvidence(work, result.aspects ?? [], "CODE");
526
1335
  const findings = result.findings ?? [];
527
1336
  // A reviewer is a child Work and cannot pause its coordinator-owned Stage.
528
1337
  // Its structured blocked result is evidence for the coordinator, which then
@@ -552,6 +1361,7 @@ export function validatePlanReviewResult(work, value, input) {
552
1361
  const unexpected = ids.filter((id) => !expected.has(id));
553
1362
  if (new Set(ids).size !== ids.length || missing.length || unexpected.length)
554
1363
  throw new AppError("review_evidence_invalid", "PLAN reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
1364
+ assertReviewAspectEvidence(work, aspects, "PLAN");
555
1365
  const findingIds = aspects.flatMap((aspect) => aspect.findings ?? []).map((finding) => finding.finding_id ?? "");
556
1366
  const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
557
1367
  if (new Set(findingIds).size !== findingIds.length || invalid.length)
@@ -566,6 +1376,11 @@ export function validatePlanReviewResult(work, value, input) {
566
1376
  assertPortableArtifactRef(ref, input);
567
1377
  }
568
1378
  }
1379
+ function assertReviewAspectEvidence(work, aspects, stage) {
1380
+ const missing = aspects.filter((aspect) => !Array.isArray(aspect.evidence_refs) || aspect.evidence_refs.length === 0).map((aspect) => aspect.aspect_id ?? "");
1381
+ if (missing.length)
1382
+ throw new AppError("review_evidence_invalid", `${stage} reviewer result requires non-empty evidence_refs for every assigned aspect`, 2, { work_id: work.work_id, missing_aspects: missing });
1383
+ }
569
1384
  function codeReviewGroup(work) {
570
1385
  const payload = parsePayload(work);
571
1386
  const group = payload?.group;
@@ -576,10 +1391,14 @@ function codeReviewGroup(work) {
576
1391
  ? { key: value.key, aspect_ids: value.aspect_ids }
577
1392
  : null;
578
1393
  }
579
- function requireWork(context, id) { ensureWorkRegistry(context); const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?`, [id]); if (exact)
580
- return exact; if (!/^WRK-\d{3,}$/.test(id))
581
- throw new AppError("not_found", "Work is not registered", 1, { work_id: id }); const matches = context.db.all(`SELECT ${workColumns} FROM works WHERE work_id LIKE ? ORDER BY work_id`, [`${id}-%`]); if (matches.length !== 1)
582
- throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous" : "Work is not registered", 1, { work_id: id, matches: matches.map((work) => work.work_id) }); return matches[0]; }
1394
+ function requireWork(context, id, explicitScope) {
1395
+ ensureWorkRegistry(context);
1396
+ const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
1397
+ const scope = configured ? JSON.parse(configured) : undefined;
1398
+ const projectId = explicitScope?.projectId ?? (scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined);
1399
+ const runId = explicitScope?.runId ?? scope?.runId;
1400
+ return resolveWorkReference(context, id, { projectId, runId });
1401
+ }
583
1402
  function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_root, r.workspace_root, p.root AS project_root FROM runs r JOIN projects p ON p.id = r.project_id WHERE r.project_id = ? AND r.id = ?", [projectId, runId]); if (!run?.run_root)
584
1403
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
585
1404
  function requireRunHome(run) { if (!run.run_root)
@@ -621,18 +1440,12 @@ function validateItem(value, requireExecutionContext = false) { if (!value || ty
621
1440
  throw new AppError("validation", "result_schema must be a non-empty schema id", 2); const payload = code ? item : (item.payload && typeof item.payload === "object" && !Array.isArray(item.payload) ? item.payload : undefined); return { key: item.key, task: item.task, ...(Array.isArray(item.depends_on) ? { depends_on: item.depends_on } : {}), ...(typeof item.parent === "string" ? { parent: item.parent } : {}), ...(typeof item.launch_policy === "string" ? { launch_policy: item.launch_policy } : {}), ...(typeof item.result_schema === "string" ? { result_schema: item.result_schema } : {}), ...(payload ? { payload } : {}) }; }
622
1441
  function allowsEmptyRepairChecks(item) { const repair = item.repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
623
1442
  return false; const value = repair; return typeof value.check_receipt_id === "string" || (Array.isArray(value.review_findings) && value.review_findings.some((finding) => typeof finding === "object" && finding !== null && Array.isArray(finding.check_refs) && finding.check_refs.some((ref) => typeof ref === "string"))) || (Array.isArray(value.semantic_unresolved) && value.semantic_unresolved.some((item) => typeof item === "string")); }
624
- function readJson(file) { try {
625
- return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
626
- }
627
- catch (error) {
628
- throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
629
- } }
630
1443
  export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT run_root FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (run?.run_root) {
631
1444
  const obsolete = path.join(run.run_root, "work.json");
632
1445
  if (fs.existsSync(obsolete))
633
1446
  fs.rmSync(obsolete);
634
1447
  } }
635
- export function codeWorkGraph(context, projectId, runId) { const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
1448
+ export function codeWorkGraph(context, projectId, runId) { const index = JSON.parse(context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId])?.index_json ?? "{}"); const active = index.stage_runs?.some(item => item.stage === "code-review" && item.status === "running") ? "code-review" : "code"; const members = index.stage_runs?.length ? stageWorkIds(context, projectId, runId, active) : null; const works = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id`, [projectId, runId]).filter((work) => Boolean(codePacket(work)) && (!members || members.has(work.work_id))); const ready = works.filter((work) => isReady(context, work)); return { total: works.length, created: works.filter((work) => work.status === "created").length, running: works.filter((work) => work.status === "running").length, completed: works.filter((work) => work.status === "completed").length, failed: works.filter((work) => work.status === "failed").length, ready: ready.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: workStartCommand(context, work) })), blocked: works.filter((work) => work.status === "created" && !ready.includes(work)).map((work) => ({ work_id: work.work_id, depends_on: parseDependencies(work) })) }; }
636
1449
  function parsePayload(work) { if (!work.payload_json)
637
1450
  return null; try {
638
1451
  const value = JSON.parse(work.payload_json);
@@ -641,6 +1454,7 @@ function parsePayload(work) { if (!work.payload_json)
641
1454
  catch {
642
1455
  return null;
643
1456
  } }
1457
+ function uniqueChecks(checks) { return checks.filter((check, index) => checks.findIndex((candidate) => candidate.id === check.id) === index); }
644
1458
  function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@5")
645
1459
  return null; return value; }
646
1460
  function assertNoCycles(nodes) { const local = new Map(nodes.map((node) => [node.id, node.dependencies.filter((dependency) => nodes.some((candidate) => candidate.id === dependency))])); const active = new Set(); const done = new Set(); const visit = (id) => { if (active.has(id))