@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75

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 (123) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  26. package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
  27. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  28. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  29. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  30. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  31. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  32. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  33. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  34. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  35. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  36. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  37. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  38. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  39. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  40. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  41. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  42. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  43. package/dist/runtime/context.js +3 -1
  44. package/dist/schemas/agent-profile.schema.json +1 -1
  45. package/dist/schemas/code-review-result.schema.json +2 -2
  46. package/dist/schemas/code-work-batch.schema.json +4 -3
  47. package/dist/schemas/code-work-result.schema.json +4 -4
  48. package/dist/schemas/harness-config.schema.json +23 -0
  49. package/dist/schemas/plan-review-result.schema.json +2 -2
  50. package/dist/schemas/run-control-receipt.schema.json +99 -0
  51. package/dist/schemas/run-control-request.schema.json +36 -0
  52. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  53. package/dist/services/cleanup.js +8 -5
  54. package/dist/services/cli-operation-classifier.js +11 -1
  55. package/dist/services/code-checks.js +543 -96
  56. package/dist/services/controller-fanout.js +116 -0
  57. package/dist/services/dashboard.js +9 -0
  58. package/dist/services/engines.js +50 -19
  59. package/dist/services/eval-snapshots.js +639 -60
  60. package/dist/services/execution-policy.js +177 -0
  61. package/dist/services/external-work-launch.js +110 -0
  62. package/dist/services/harness-adapter.js +147 -24
  63. package/dist/services/harness-config.js +68 -0
  64. package/dist/services/hooks.js +413 -168
  65. package/dist/services/lanes.js +1 -1
  66. package/dist/services/lifecycle-command.js +74 -6
  67. package/dist/services/lifecycle-invocations.js +637 -0
  68. package/dist/services/managed-daemon-binding.js +38 -0
  69. package/dist/services/managed-processes.js +138 -22
  70. package/dist/services/merge-queue.js +6 -6
  71. package/dist/services/merge-server.js +75 -29
  72. package/dist/services/migrations.js +1 -1
  73. package/dist/services/native-daemon-history.js +49 -0
  74. package/dist/services/native-session-control.js +39 -0
  75. package/dist/services/projects.js +7 -3
  76. package/dist/services/prompts.js +4 -2
  77. package/dist/services/protocols.js +11 -2
  78. package/dist/services/recovery-observation-budget.js +61 -0
  79. package/dist/services/recovery-snapshot-database.js +107 -0
  80. package/dist/services/run-control-receipt.js +56 -0
  81. package/dist/services/run-control-worker.js +363 -0
  82. package/dist/services/run-control.js +876 -0
  83. package/dist/services/run-controller-adapter.js +211 -0
  84. package/dist/services/run-controller-capture.js +134 -0
  85. package/dist/services/run-controller-process.js +189 -0
  86. package/dist/services/run-controller-recovery.js +222 -0
  87. package/dist/services/run-controller-state.js +31 -0
  88. package/dist/services/run-controller.js +733 -0
  89. package/dist/services/run-engine-bindings.js +20 -62
  90. package/dist/services/run-fork.js +124 -0
  91. package/dist/services/run-observations.js +112 -0
  92. package/dist/services/run-recovery-runtime.js +69 -0
  93. package/dist/services/run-recovery.js +327 -0
  94. package/dist/services/runs.js +280 -40
  95. package/dist/services/runtime-budget.js +362 -0
  96. package/dist/services/runtime-scope-capture.js +50 -0
  97. package/dist/services/runtime-scope-control.js +450 -0
  98. package/dist/services/runtime-scope-resume.js +543 -0
  99. package/dist/services/runtime-scope-stop.js +99 -0
  100. package/dist/services/runtime-scope-worker.js +210 -0
  101. package/dist/services/runtime-service.js +96 -0
  102. package/dist/services/schema-validation.js +12 -12
  103. package/dist/services/stage-context.js +2 -2
  104. package/dist/services/stage-lifecycle.js +32 -10
  105. package/dist/services/stage-pause.js +25 -13
  106. package/dist/services/usage.js +37 -93
  107. package/dist/services/vnext-code-review.js +149 -89
  108. package/dist/services/vnext-code.js +224 -216
  109. package/dist/services/vnext-execution-profile.js +5 -3
  110. package/dist/services/vnext-fanout.js +135 -21
  111. package/dist/services/vnext-merge.js +258 -89
  112. package/dist/services/vnext-plan-review.js +75 -64
  113. package/dist/services/vnext-plan.js +77 -29
  114. package/dist/services/vnext-protocolize.js +21 -14
  115. package/dist/services/vnext-specify.js +26 -16
  116. package/dist/services/work-registry.js +605 -157
  117. package/dist/services/workspace-bootstrap.js +76 -0
  118. package/dist/shared/errors.js +12 -0
  119. package/dist/storage/database.js +341 -31
  120. package/dist/storage/writer-contract.js +77 -0
  121. package/dist/storage/writer-migration.js +102 -0
  122. package/package.json +20 -13
  123. package/tools/repair-paused-run-status.mjs +59 -0
@@ -1,28 +1,107 @@
1
+ import { managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { AppError } from "../shared/errors.js";
5
- import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
6
+ import { findRecentMatchingHookEvent, claimStageLifecycleHookEvent, claimStageStartHookEvent, claimWorkLifecycleHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
6
7
  import { validateSchema } from "./schema-validation.js";
7
8
  import { resolveProjectRoot, resolveRunReferences } from "../storage/paths.js";
8
9
  import { refreshRunSessionProjection } from "./run-projection.js";
9
- import { readCodeCheckProfile, runCodeChecks, workspaceFingerprint } from "./code-checks.js";
10
+ import { ignoredWorkspacePaths, normalizeWorkspacePath, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceFingerprint } from "./code-checks.js";
10
11
  import { nextWorkId, nextWorkIds } from "./ids.js";
11
12
  import { appendFlowRunTimelineEvent } from "./runs.js";
13
+ import { resolveStageTransition } from "./execution-policy.js";
14
+ import { appendAudit } from "./audit.js";
12
15
  import { flowCommand } from "./stage-pause.js";
13
16
  import { assertPortableArtifactRef } from "./portable-refs.js";
14
17
  import { publicSessionIdentity } from "./session-identity.js";
18
+ import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
19
+ import { runControlDirective } from "./run-control.js";
15
20
  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
21
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
22
+ function ownsPhysicalWorkSession(context, projectId, owner, identity) {
23
+ if (identity.sessionId !== owner.session_id)
24
+ return false;
25
+ 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;
26
+ // Legacy non-daemon sessions remain usable. Once a physical daemon was
27
+ // captured, another process cannot inherit authority merely by resuming
28
+ // the same provider Session ID.
29
+ return !binding?.daemon_id || binding.daemon_id === identity.daemonId;
30
+ }
31
+ /**
32
+ * A Stage terminal command is allowed only from the Session that owns its
33
+ * running coordinator Work. A hook receipt proves the caller; a WorkSession
34
+ * proves the owner. Either fact alone is insufficient.
35
+ */
36
+ export function assertStageLifecycleOwner(context, input) {
37
+ ensureWorkRegistry(context);
38
+ const projectRoot = resolveProjectRoot(input.projectRoot);
39
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
40
+ if (!project)
41
+ throw new AppError("not_found", "Project is not registered", 1, { project_root: projectRoot });
42
+ requireRun(context, project.id, input.runId);
43
+ let owner = context.db.get(`SELECT ws.id, ws.work_id, ws.session_id, ws.hook_event_id
44
+ FROM work_sessions ws
45
+ JOIN works w ON w.work_id = ws.work_id
46
+ JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id
47
+ WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running'
48
+ AND w.status IN ('running', 'paused') AND (w.parent_work_id IS NULL OR s.current_stage = ?)
49
+ ${input.workId ? "AND w.work_id = ?" : ""}
50
+ ORDER BY ws.created_at DESC LIMIT 1`, input.workId ? [project.id, input.runId, input.stage, input.workId] : [project.id, input.runId, input.stage]);
51
+ if (!owner && input.operation === "stage_finish" && ["code", "code-review"].includes(input.stage)) {
52
+ const row = context.db.get("SELECT run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
53
+ const index = row ? JSON.parse(row.index_json) : null;
54
+ const completed = index?.stage_runs.find(entry => entry.stage === input.stage && entry.status === "done");
55
+ if (row && completed && (index?.current_stage ?? index?.stage_runs.at(-1)?.stage) === input.stage)
56
+ 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")]);
57
+ }
58
+ if (!owner)
59
+ 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 } : {}) });
60
+ const identity = claimStageLifecycleHookEvent(context, {
61
+ projectId: project.id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation,
62
+ runId: input.runId, stage: input.stage, projectRoot, ...(input.operation === "stage_pause" ? { workId: owner.work_id } : {})
63
+ });
64
+ if (ownsPhysicalWorkSession(context, project.id, owner, identity))
65
+ return identity;
66
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
67
+ 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 };
68
+ appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "lifecycle_caller_mismatch", ...payload });
69
+ appendAudit(context, { projectId: project.id, eventType: "stage.lifecycle_caller_rejected", payload });
70
+ throw new AppError("lifecycle_caller_mismatch", "Stage lifecycle command was issued by a Session that does not own the running Work", 1, payload);
71
+ }
72
+ /** A Work terminal command belongs to the Session that started that Work.
73
+ * Controllers can still use their explicit non-agent operations; they cannot
74
+ * borrow a native worker's authority by naming its Work id. */
75
+ export function assertWorkLifecycleOwner(context, input) {
76
+ ensureWorkRegistry(context);
77
+ const work = requireWork(context, input.workId);
78
+ const run = requireRun(context, work.project_id, work.run_id);
79
+ if (resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
80
+ throw new AppError("project_mismatch", "Work lifecycle project root does not match its RUN", 1, { work_id: work.work_id });
81
+ 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]);
82
+ if (!owner)
83
+ throw new AppError("trusted_session_binding_required", "Work lifecycle command has no running Work/Session binding", 1, { work_id: work.work_id });
84
+ const identity = claimWorkLifecycleHookEvent(context, { projectId: work.project_id, ...(input.hookEventId ? { eventKey: input.hookEventId } : {}), operation: input.operation, workId: work.work_id, projectRoot: run.project_root });
85
+ if (ownsPhysicalWorkSession(context, work.project_id, owner, identity))
86
+ return identity;
87
+ context.db.run("UPDATE hook_events SET status = 'rejected', claimed_at = ? WHERE id = ?", [context.now(), identity.hookEventId]);
88
+ 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 };
89
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_lifecycle_caller_mismatch", ...payload });
90
+ appendAudit(context, { projectId: work.project_id, eventType: "work.lifecycle_caller_rejected", payload });
91
+ throw new AppError("lifecycle_caller_mismatch", "Work lifecycle command was issued by a Session that does not own the running Work", 1, payload);
92
+ }
17
93
  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);
94
+ return context.db.writeTransaction(() => {
95
+ const parent = requireWork(context, input.parentWorkId);
96
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
97
+ if (parent.status !== "running")
98
+ throw new AppError("invalid_work_state", "Child Work requires a running parent", 2, { parent_work_id: parent.work_id, status: parent.status });
99
+ const id = nextWorkId(context, parent.project_id, input.slug);
100
+ const now = context.now();
101
+ 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]);
102
+ refreshRunWorkProjection(context, parent.project_id, parent.run_id);
103
+ return requireWork(context, id);
104
+ });
26
105
  }
27
106
  /** Validate a proposed batch before PLAN accepts it, without registering Work. */
28
107
  export function validateWorkBatchFile(file) {
@@ -45,9 +124,6 @@ export function validateWorkBatchFile(file) {
45
124
  }
46
125
  export function addWorkBatch(context, input) {
47
126
  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
127
  const parsed = readJson(input.file);
52
128
  if (!Array.isArray(parsed.works) || parsed.works.length === 0)
53
129
  throw new AppError("validation", "work batch requires non-empty works", 2);
@@ -55,18 +131,21 @@ export function addWorkBatch(context, input) {
55
131
  const keys = new Set(items.map((item) => item.key));
56
132
  if (keys.size !== items.length)
57
133
  throw new AppError("validation", "work batch keys must be unique", 2);
58
- const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
59
- const existingIds = new Set(existing.map((row) => row.work_id));
60
- for (const item of items)
61
- for (const dependency of item.depends_on ?? [])
62
- if (!keys.has(dependency) && !existingIds.has(dependency))
63
- throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
64
- for (const item of items)
65
- if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
66
- 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 {
134
+ return context.db.writeTransaction(() => {
135
+ const parent = requireWork(context, input.parentWorkId);
136
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
137
+ if (parent.status !== "running")
138
+ throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
139
+ const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
140
+ const existingIds = new Set(existing.map((row) => row.work_id));
141
+ for (const item of items)
142
+ for (const dependency of item.depends_on ?? [])
143
+ if (!keys.has(dependency) && !existingIds.has(dependency))
144
+ throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
145
+ for (const item of items)
146
+ if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
147
+ throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
148
+ const now = context.now();
70
149
  const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
71
150
  const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
72
151
  const resolve = (value) => ids.get(value) ?? value;
@@ -76,16 +155,11 @@ export function addWorkBatch(context, input) {
76
155
  for (const item of proposed)
77
156
  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
157
  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
158
  for (const item of proposed)
81
159
  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" });
82
160
  refreshRunWorkProjection(context, parent.project_id, parent.run_id);
83
161
  return { ok: true, work_ids: Object.fromEntries(ids) };
84
- }
85
- catch (error) {
86
- context.db.exec("ROLLBACK");
87
- throw error;
88
- }
162
+ });
89
163
  }
90
164
  export function listWorks(context, input) {
91
165
  ensureWorkRegistry(context);
@@ -114,33 +188,143 @@ export function listWorks(context, input) {
114
188
  }) };
115
189
  }
116
190
  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 }) } : {}) })) } }; }
191
+ function receiptIntentPath(resultPath, sessionId) {
192
+ return `${resultPath}.pending-${crypto.createHash("sha256").update(sessionId).digest("hex")}`;
193
+ }
194
+ function receiptFileHash(file) {
195
+ return fs.existsSync(file) ? crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") : null;
196
+ }
197
+ function sha256(contents) { return crypto.createHash("sha256").update(contents).digest("hex"); }
198
+ function publishReceiptFile(file, contents) {
199
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
200
+ try {
201
+ fs.writeFileSync(temporary, contents);
202
+ const descriptor = fs.openSync(temporary, "r");
203
+ try {
204
+ fs.fsyncSync(descriptor);
205
+ }
206
+ finally {
207
+ fs.closeSync(descriptor);
208
+ }
209
+ fs.renameSync(temporary, file);
210
+ const directory = fs.openSync(path.dirname(file), "r");
211
+ try {
212
+ fs.fsyncSync(directory);
213
+ }
214
+ finally {
215
+ fs.closeSync(directory);
216
+ }
217
+ }
218
+ finally {
219
+ fs.rmSync(temporary, { force: true });
220
+ }
221
+ }
222
+ /** Rebuild prompt/context projections from a committed Work-start receipt. */
223
+ export function materializePendingWorkStarts(context, projectId, runId) {
224
+ const runHome = requireRunHome(requireRun(context, projectId, runId));
225
+ context.db.writeTransaction(() => {
226
+ const rows = context.db.all("SELECT ws.id, ws.work_id, ws.prompt_path, 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 ws.status = 'running' AND ws.start_receipt_json IS NOT NULL", [projectId, runId]);
227
+ for (const row of rows) {
228
+ // The same coordinator Session can move on to a different stage packet.
229
+ // A start receipt is not authority to overwrite that later packet.
230
+ let receipt;
231
+ try {
232
+ receipt = JSON.parse(row.start_receipt_json);
233
+ }
234
+ catch {
235
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt is not valid JSON", 1, { work_id: row.work_id });
236
+ }
237
+ const originalPromptPath = path.join(runHome, "works", row.work_id, ...(receipt.generation && receipt.generation > 0 ? ["starts", row.id] : []), "prompt.md");
238
+ if (path.resolve(row.prompt_path) !== path.resolve(originalPromptPath))
239
+ continue;
240
+ if (receipt.version !== 1 || typeof receipt.prompt !== "string" || typeof receipt.context !== "string" || receipt.prompt_sha256 !== sha256(receipt.prompt) || receipt.context_sha256 !== sha256(receipt.context))
241
+ throw new AppError("work_start_receipt_invalid", "Committed Work start receipt does not match its retained bytes", 1, { work_id: row.work_id });
242
+ publishStartReceiptFile(row.prompt_path, receipt.prompt, String(receipt.prompt_sha256));
243
+ publishStartReceiptFile(path.join(path.dirname(row.prompt_path), "context.json"), receipt.context, String(receipt.context_sha256));
244
+ }
245
+ });
246
+ }
247
+ function publishStartReceiptFile(file, contents, expectedHash) {
248
+ const actual = receiptFileHash(file);
249
+ if (actual === expectedHash)
250
+ return;
251
+ if (actual !== null) {
252
+ 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 });
253
+ }
254
+ fs.mkdirSync(path.dirname(file), { recursive: true });
255
+ publishReceiptFile(file, contents);
256
+ }
257
+ /** Replay only a prepared publication whose exact receipt was committed in SQLite. */
258
+ export function materializePendingWorkResults(context, projectId, runId) {
259
+ context.db.beginWriteTransaction();
260
+ try {
261
+ const rows = context.db.all(`SELECT w.work_id, w.result, w.status, ws.id, ws.result_path 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`, [projectId, runId]);
262
+ for (const row of rows) {
263
+ const intentPath = receiptIntentPath(row.result_path, row.id);
264
+ if (!fs.existsSync(intentPath))
265
+ continue;
266
+ const intent = JSON.parse(fs.readFileSync(intentPath, "utf8"));
267
+ const digest = crypto.createHash("sha256").update(row.result).digest("hex");
268
+ if (intent.work_id !== row.work_id || intent.work_session_id !== row.id || intent.status !== row.status || intent.result_sha256 !== digest)
269
+ 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 });
270
+ const actual = receiptFileHash(row.result_path);
271
+ if (actual !== digest && actual !== intent.previous_sha256)
272
+ 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 });
273
+ if (fs.existsSync(row.result_path)) {
274
+ const previous = fs.readFileSync(row.result_path);
275
+ if (!previous.equals(Buffer.from(row.result))) {
276
+ const retained = `${row.result_path}.conflict-${crypto.createHash("sha256").update(previous).digest("hex")}`;
277
+ try {
278
+ fs.writeFileSync(retained, previous, { flag: "wx", flush: true });
279
+ }
280
+ catch (error) {
281
+ if (error.code !== "EEXIST" || !fs.readFileSync(retained).equals(previous))
282
+ throw error;
283
+ }
284
+ }
285
+ }
286
+ publishReceiptFile(row.result_path, row.result);
287
+ fs.unlinkSync(intentPath);
288
+ }
289
+ context.db.exec("COMMIT");
290
+ }
291
+ catch (error) {
292
+ context.db.exec("ROLLBACK");
293
+ throw error;
294
+ }
295
+ }
117
296
  export function mutateWorkDeps(context, input) {
118
- const work = requireWork(context, input.workId);
119
- if (input.action === "list")
297
+ if (input.action === "list") {
298
+ const work = requireWork(context, input.workId);
120
299
  return { work_id: work.work_id, depends_on: parseDependencies(work) };
121
- if (work.status !== "created")
122
- throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
123
- const dependencies = new Set(parseDependencies(work));
124
- if (input.action === "clear")
125
- dependencies.clear();
126
- for (const id of input.on ?? []) {
127
- 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);
130
- if (input.action === "add")
131
- dependencies.add(dependency.work_id);
132
- else
133
- dependencies.delete(dependency.work_id);
134
- }
135
- if (dependencies.has(work.work_id))
136
- throw new AppError("validation", "Work cannot depend on itself", 2);
137
- 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] };
300
+ }
301
+ return context.db.writeTransaction(() => {
302
+ const work = requireWork(context, input.workId);
303
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
304
+ if (work.status !== "created")
305
+ throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
306
+ const dependencies = new Set(parseDependencies(work));
307
+ if (input.action === "clear")
308
+ dependencies.clear();
309
+ for (const id of input.on ?? []) {
310
+ const dependency = requireWork(context, id);
311
+ if (dependency.run_id !== work.run_id)
312
+ throw new AppError("validation", "Dependencies must stay in one RUN", 2);
313
+ if (input.action === "add")
314
+ dependencies.add(dependency.work_id);
315
+ else
316
+ dependencies.delete(dependency.work_id);
317
+ }
318
+ if (dependencies.has(work.work_id))
319
+ throw new AppError("validation", "Work cannot depend on itself", 2);
320
+ 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) })));
321
+ context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
322
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
323
+ return { work_id: work.work_id, depends_on: [...dependencies] };
324
+ });
141
325
  }
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 }; }
326
+ export function deleteWork(context, id) { return context.db.writeTransaction(() => { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_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}%`]))
327
+ 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
328
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
145
329
  /**
146
330
  * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
@@ -148,10 +332,11 @@ export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.rep
148
332
  * PATH is not inherited; using the shared lifecycle command keeps them on the
149
333
  * same captured runtime as their parent stage.
150
334
  */
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) {
335
+ 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, `${flowCommand(context)} work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)}${recovery ? ` --recovery-id ${JSON.stringify(recovery.recovery_id)}` : ""} --json`); }
336
+ export function assertWorkLaunchReady(context, id) {
153
337
  ensureWorkRegistry(context);
154
338
  const work = requireWork(context, id);
339
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
155
340
  if (work.status !== "created")
156
341
  throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
157
342
  if (!isReady(context, work)) {
@@ -159,32 +344,52 @@ export function startWork(context, id, input) {
159
344
  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) }));
160
345
  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
346
  }
347
+ return work;
348
+ }
349
+ export function startWork(context, id, input) {
350
+ if (input.hookEventId && !context.env.DD_FLOW_CURRENT_INVOCATION) {
351
+ const retainedWork = requireWork(context, id);
352
+ const retainedRun = requireRun(context, retainedWork.project_id, retainedWork.run_id);
353
+ if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(retainedRun.project_root))
354
+ throw new AppError("project_mismatch", "Work start belongs to another project", 1);
355
+ const reply = recoverCommittedWorkStart(context, null, input.hookEventId, recoveryGuard(context, retainedWork.project_id, retainedWork.run_id)?.generation ?? 0, retainedWork.work_id);
356
+ if (reply)
357
+ return reply;
358
+ }
359
+ const work = assertWorkLaunchReady(context, id);
162
360
  const run = requireRun(context, work.project_id, work.run_id);
163
361
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
164
362
  throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
363
+ const recoveryId = recoveryGuard(context, work.project_id, work.run_id)?.recovery_id;
364
+ if (input.recoveryId !== recoveryId)
365
+ throw new AppError("run_recovery_generation_stale", "Work launch must use the exact current recovery packet", 1, { work_id: id, recovery_id: recoveryId ?? null });
165
366
  let hookEventId = input.hookEventId;
166
367
  if (!hookEventId) {
167
368
  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;
369
+ hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(shortWorkId(work.work_id), run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
169
370
  }
170
371
  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;
372
+ hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root, recoveryId), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
172
373
  }
173
374
  }
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 });
375
+ const eventKey = hookEventId;
376
+ const started = startBoundWork(context, work, run, () => claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) }), eventKey);
377
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, work_session_id: started.work_session_id });
177
378
  return started;
178
379
  }
179
380
  /** Binds the already-running coordinator of a stage to its trusted hook Session. */
180
381
  export function bindRunningWorkSession(context, input) {
181
382
  const work = requireWork(context, input.workId);
383
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
182
384
  if (work.status !== "running")
183
385
  throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
184
386
  const run = requireRun(context, work.project_id, work.run_id);
185
387
  const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
388
+ assertRecoveryHookCurrent(context, work.project_id, work.run_id, identity);
186
389
  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
390
  if (active) {
391
+ if (active.session_id === identity.sessionId && !ownsPhysicalWorkSession(context, work.project_id, active, identity))
392
+ throw new AppError("handoff_session_mismatch", "The active Work belongs to another physical daemon", 1, { work_id: work.work_id });
188
393
  if (active.session_id !== identity.sessionId) {
189
394
  // A frozen `new_session` policy may transfer the same coordinator Work
190
395
  // only to its next stage. Repeating the same stage from another Session
@@ -193,7 +398,7 @@ export function bindRunningWorkSession(context, input) {
193
398
  throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
194
399
  const now = context.now();
195
400
  const id = `WS-${crypto.randomUUID()}`;
196
- context.db.exec("BEGIN IMMEDIATE");
401
+ context.db.beginWriteTransaction();
197
402
  try {
198
403
  context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
199
404
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
@@ -215,7 +420,7 @@ export function bindRunningWorkSession(context, input) {
215
420
  }
216
421
  const now = context.now();
217
422
  const id = `WS-${crypto.randomUUID()}`;
218
- context.db.exec("BEGIN IMMEDIATE");
423
+ context.db.beginWriteTransaction();
219
424
  try {
220
425
  bindSession(context, work, run, identity, now);
221
426
  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]);
@@ -231,10 +436,22 @@ export function bindRunningWorkSession(context, input) {
231
436
  /** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
232
437
  export function bindStageCoordinatorWork(context, input) {
233
438
  const work = requireWork(context, input.workId);
439
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
234
440
  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
441
  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" });
442
+ const profile = snapshot.execution_profile;
443
+ 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]);
444
+ let sessionMode = profile?.settings.stage_session_mode ?? "same_session";
445
+ if (profile?.settings.execution) {
446
+ if (!profile.agent_profiles)
447
+ throw new AppError("execution_profile_not_frozen", "Stage handoff requires frozen RUN profiles", 1);
448
+ sessionMode = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy: profile.settings.execution, profiles: profile.agent_profiles, ...(previous?.current_stage ? { fromStage: previous.current_stage } : {}), stage: input.stage, stageSessionMode: sessionMode, mergeMode: profile.settings.merge_mode }).session_mode;
449
+ }
450
+ const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
451
+ if (previous?.current_stage && previous.current_stage !== input.stage && sessionMode === "new_session" && previous.session_id === identity.sessionId)
452
+ 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 });
453
+ 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 } : {}) });
454
+ const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: sessionMode === "new_session" });
238
455
  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
456
  refreshRunSessionProjection(context, work.project_id, work.run_id);
240
457
  return binding;
@@ -242,76 +459,227 @@ export function bindStageCoordinatorWork(context, input) {
242
459
  /** Starts a created coordinator Work from its trusted `stage start` event. */
243
460
  export function startStageCoordinatorWork(context, input) {
244
461
  const work = requireWork(context, input.workId);
462
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
245
463
  if (work.status !== "created")
246
464
  throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
247
465
  const run = requireRun(context, work.project_id, work.run_id);
248
466
  if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
249
467
  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]);
468
+ 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
469
  refreshRunSessionProjection(context, work.project_id, work.run_id);
255
470
  return binding;
256
471
  }
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()}`;
472
+ /** Claim, command authority and binding commit together; only packet projection follows. */
473
+ export function startBoundWork(context, work, run, claimIdentity, hookEventId, stage) {
474
+ const generation = assertRunMutationAllowed(context, work.project_id, work.run_id);
263
475
  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");
476
+ const linkId = `WS-${crypto.randomUUID()}`;
477
+ // Recovery can reopen the same Work without retiring its historical packet.
478
+ // Give each new-generation start its own immutable publication destination.
479
+ const packetDirectory = generation > 0 ? path.join(directory, "starts", linkId) : directory;
480
+ const promptPath = path.join(packetDirectory, "prompt.md"), resultPath = path.join(directory, "result.json");
481
+ // The first reviewer freezes shared inputs in the same transaction as its claim.
482
+ const readOnly = parsePayload(work)?.read_only === true;
483
+ let fingerprint;
484
+ let binding;
279
485
  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");
486
+ binding = context.db.writeTransaction(() => {
487
+ const current = requireWork(context, work.work_id);
488
+ assertRunMutationAllowed(context, current.project_id, current.run_id, generation);
489
+ if (current.status !== "created")
490
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id, status: current.status });
491
+ if (Object.keys(current).some(key => current[key] !== work[key]))
492
+ throw new AppError("work_not_ready", "Work inputs changed while preparing its start packet", 2, { work_id: current.work_id });
493
+ if (!isReady(context, current))
494
+ throw new AppError("work_not_ready", "Work dependencies are not ready", 2, { work_id: current.work_id, blockers: readinessBlockers(context, current) });
495
+ if (fs.existsSync(path.join(directory, ".retry.json")))
496
+ throw new AppError("work_retry_incomplete", "Complete the interrupted Work retry before starting its next attempt", 1, { work_id: work.work_id });
497
+ const identity = claimIdentity();
498
+ assertRecoveryHookCurrent(context, current.project_id, current.run_id, identity);
499
+ const dependencyResults = parseDependencies(current).map(dependency => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
500
+ const recovery = recoveryPromptForWork(context, current.work_id);
501
+ let prompt = renderWorkerPrompt(context, current, run, dependencyResults);
502
+ if (recovery)
503
+ prompt = `${recoveryPrompt(recovery)}\n\n${prompt}`;
504
+ if (readOnly)
505
+ fingerprint = sharedReviewInput(work, run, true) ?? workspaceFingerprint(run.workspace_root);
506
+ 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: run.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 } : {}) };
507
+ const contextBytes = `${JSON.stringify(workContext, null, 2)}\n`;
508
+ 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 } } };
509
+ const receipt = JSON.stringify({ version: 1, 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 });
510
+ const now = context.now();
511
+ bindSession(context, current, run, identity, now);
512
+ if (current.parent_work_id && current.launch_policy === "reuse_allowed")
513
+ 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]);
514
+ 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)
515
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: current.work_id });
516
+ context.db.run("INSERT INTO work_sessions (id, work_id, session_id, hook_event_id, status, prompt_path, result_path, start_receipt_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, NULL)", [linkId, current.work_id, identity.sessionId, hookEventId, promptPath, resultPath, receipt, now, now]);
517
+ if (stage)
518
+ context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [stage, now, current.project_id, identity.sessionId]);
519
+ if (recovery)
520
+ activateRecoveryWork(context, current.work_id);
521
+ return response;
522
+ });
290
523
  }
291
524
  catch (error) {
292
- context.db.exec("ROLLBACK");
293
- fs.rmSync(promptCandidate, { force: true });
294
- fs.rmSync(contextCandidate, { force: true });
525
+ // This transaction publishes only SQL authority, never files or provider calls.
526
+ if (error instanceof AppError && error.code === "storage_write_failed" && error.details.transaction_rolled_back === true)
527
+ error.details.effect = "no_effect";
528
+ if (error instanceof AppError && error.details.effect === "no_effect" && !context.env.DD_FLOW_CURRENT_INVOCATION) {
529
+ let retry = false;
530
+ try {
531
+ retry = context.db.writeTransaction(() => {
532
+ 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]))
533
+ return false;
534
+ 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;
535
+ });
536
+ }
537
+ catch (persistence) {
538
+ error.details.retry_persistence_error = String(persistence);
539
+ }
540
+ if (retry)
541
+ return startBoundWork(context, work, run, claimIdentity, hookEventId, stage);
542
+ }
295
543
  throw error;
296
544
  }
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 } } };
545
+ try {
546
+ materializePendingWorkStarts(context, work.project_id, work.run_id);
547
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
548
+ }
549
+ catch (error) {
550
+ if (error instanceof AppError) {
551
+ error.details.effect = "committed";
552
+ throw error;
553
+ }
554
+ 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 } });
555
+ }
556
+ return binding;
299
557
  }
300
- export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
558
+ /** Read the exact committed reply, never bind another Session or regenerate a packet. */
559
+ export function recoverCommittedWorkStart(context, invocationId, eventKey, generation, workId) {
560
+ 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]);
561
+ if (!row)
562
+ return undefined;
563
+ const receipt = JSON.parse(row.start_receipt_json);
564
+ if (receipt.generation !== generation || !receipt.response || receipt.response_sha256 !== sha256(JSON.stringify(receipt.response)))
565
+ throw new AppError("work_start_receipt_invalid", "Committed reply identity or checksum differs", 1, { invocation_id: invocationId });
566
+ assertRunMutationAllowed(context, row.project_id, row.run_id, generation);
567
+ materializePendingWorkStarts(context, row.project_id, row.run_id);
568
+ return receipt.response;
569
+ }
570
+ /** Root coordinators have no worker checks: settle them inside the stage transaction. */
571
+ export function finishStageCoordinatorWork(context, id, result) {
572
+ const work = requireWork(context, id);
573
+ if (work.parent_work_id !== null || codePacket(work) || parsePayload(work)?.read_only)
574
+ throw new AppError("invalid_work_state", "Stage settlement requires a root coordinator", 2);
575
+ const run = requireRun(context, work.project_id, work.run_id);
576
+ const link = context.db.get("SELECT * FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
577
+ if (!link)
578
+ throw new AppError("runtime_missing", "Coordinator has no open Work/Session link", 1);
579
+ validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link.result_path);
580
+ finishFanInWork(context, id, result);
581
+ 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]);
582
+ settleRecoveryWork(context, id, "completed");
583
+ }
584
+ export function finishWork(context, id, result, progress, retry) { return settle(context, id, "completed", result, progress, retry); }
301
585
  /** 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")
586
+ export function finishFanInWork(context, id, result) { const work = requireWork(context, id); assertRunMutationAllowed(context, work.project_id, work.run_id); if (work.status !== "running")
303
587
  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
588
  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
589
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
306
590
  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) {
591
+ export function retryWork(context, id, reason) {
314
592
  const work = requireWork(context, id);
593
+ const run = requireRun(context, work.project_id, work.run_id);
594
+ const directory = path.join(requireRunHome(run), "works", work.work_id);
595
+ const journal = path.join(directory, ".retry.json");
596
+ let archive = "";
597
+ context.db.beginWriteTransaction();
598
+ try {
599
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
600
+ const current = requireWork(context, work.work_id);
601
+ const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
602
+ if (pending && (pending.work_id !== work.work_id || !/^ATT-\d{3,}$/.test(pending.archive) || pending.entries.some(file => !["prompt.md", "result.json", "context.json"].includes(file))))
603
+ throw new AppError("work_retry_invalid", "Retry journal does not identify this Work", 1);
604
+ if (current.status !== "failed" && !(pending && current.status === "created"))
605
+ throw new AppError("invalid_work_state", "Only failed Work may be retried", 2);
606
+ const attempts = path.join(directory, "attempts");
607
+ let number = 1;
608
+ while (fs.existsSync(path.join(attempts, `ATT-${String(number).padStart(3, "0")}`)))
609
+ number += 1;
610
+ 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: current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex") };
611
+ archive = path.join(attempts, prepared.archive);
612
+ if (current.status === "failed") {
613
+ if (prepared.result_sha256 !== (current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex")))
614
+ throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
615
+ fs.mkdirSync(archive, { recursive: true });
616
+ if (!pending)
617
+ publishReceiptFile(journal, JSON.stringify(prepared));
618
+ const receipt = path.join(archive, "accepted-receipt.json");
619
+ const receiptBytes = JSON.stringify({ work_id: current.work_id, status: current.status, result: current.result, result_sha256: prepared.result_sha256 });
620
+ if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== receiptBytes)
621
+ throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
622
+ if (!fs.existsSync(receipt))
623
+ publishReceiptFile(receipt, receiptBytes);
624
+ for (const file of prepared.entries) {
625
+ const source = path.join(directory, file), target = path.join(archive, file);
626
+ if (fs.existsSync(source)) {
627
+ if (fs.existsSync(target))
628
+ throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
629
+ fs.renameSync(source, target);
630
+ }
631
+ else if (!fs.existsSync(target))
632
+ throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
633
+ }
634
+ for (const folder of [archive, directory]) {
635
+ const descriptor = fs.openSync(folder, "r");
636
+ try {
637
+ fs.fsyncSync(descriptor);
638
+ }
639
+ finally {
640
+ fs.closeSync(descriptor);
641
+ }
642
+ }
643
+ for (const column of ["prompt_path", "result_path"]) {
644
+ const file = column === "prompt_path" ? "prompt.md" : "result.json";
645
+ context.db.run(`UPDATE work_sessions SET ${column} = ? WHERE work_id = ? AND ${column} = ?`, [path.join(archive, file), work.work_id, path.join(directory, file)]);
646
+ }
647
+ 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]);
648
+ }
649
+ context.db.exec("COMMIT");
650
+ }
651
+ catch (error) {
652
+ context.db.exec("ROLLBACK");
653
+ throw error;
654
+ }
655
+ fs.rmSync(journal, { force: true });
656
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
657
+ return { ok: true, work_id: work.work_id, archived_attempt: path.relative(requireRunHome(run), archive).split(path.sep).join("/"), reason };
658
+ }
659
+ function sharedReviewInput(work, run, initialize = false) {
660
+ const kind = parsePayload(work)?.kind;
661
+ const relative = kind === "code-review" ? "06-code-review/review-context.json" : kind === "plan-review" ? "04-plan-review/work-context.json" : null;
662
+ if (!relative || !work.parent_work_id)
663
+ return null;
664
+ const file = path.join(requireRunHome(run), relative);
665
+ if (!fs.existsSync(file))
666
+ return null;
667
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
668
+ if (value.read_only_input?.parent_work_id !== work.parent_work_id)
669
+ return null;
670
+ if (!value.read_only_input.fingerprint && initialize) {
671
+ // Called under the Work-start writer reservation, after stage setup has
672
+ // finished. The first reviewer accepts the input shared by this group.
673
+ value.read_only_input.fingerprint = workspaceFingerprint(run.workspace_root);
674
+ const temporary = `${file}.tmp-${crypto.randomUUID()}`;
675
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
676
+ fs.renameSync(temporary, file);
677
+ }
678
+ return typeof value.read_only_input.fingerprint === "string" ? value.read_only_input.fingerprint : null;
679
+ }
680
+ async function settle(context, id, status, result, progress, retry) {
681
+ const work = requireWork(context, id);
682
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
315
683
  id = work.work_id;
316
684
  if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
317
685
  throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
@@ -323,14 +691,21 @@ async function settle(context, id, status, result, progress) {
323
691
  throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: id });
324
692
  let receipts = [];
325
693
  let coordinationDrift = [];
326
- if (status === "completed") {
327
- if (parsePayload(work)?.read_only === true) {
694
+ const assertReadOnlyInput = () => {
695
+ if (status === "completed" && parsePayload(work)?.read_only === true) {
696
+ const shared = sharedReviewInput(work, run);
697
+ // The final reviewer commits the shared input boundary under the writer
698
+ // reservation. Concurrent finishes cannot both skip this verification.
699
+ if (shared && 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]))
700
+ return;
328
701
  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;
702
+ const baseline = shared ?? (contextFile && fs.existsSync(contextFile) ? JSON.parse(fs.readFileSync(contextFile, "utf8")).workspace_fingerprint : null);
330
703
  const current = workspaceFingerprint(run.workspace_root);
331
704
  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 });
705
+ 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
706
  }
707
+ };
708
+ if (status === "completed") {
334
709
  validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
335
710
  const packet = codePacket(work);
336
711
  if (packet) {
@@ -340,19 +715,28 @@ async function settle(context, id, status, result, progress) {
340
715
  readCodeCheckProfile(run.workspace_root);
341
716
  coordinationDrift = plannedAreaDrift(packet, result);
342
717
  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 } : {}) });
718
+ const requiredChecks = packet.repair?.verification_check_refs?.length
719
+ ? uniqueChecks([...packet.checks.filter((check) => check.run_at === "work"), ...packet.repair.verification_check_refs])
720
+ : packet.checks.filter((check) => check.run_at === "work");
721
+ if (retry)
722
+ validateFailedCheckRetry(context, { projectId: work.project_id, runId: work.run_id, receiptId: retry.receiptId, declarations: requiredChecks, reason: retry.reason });
723
+ 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
724
  const failed = receipts.filter((receipt) => receipt.status !== "passed");
345
725
  if (failed.length)
346
726
  throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
347
727
  }
348
728
  }
349
729
  const now = context.now();
350
- context.db.exec("BEGIN IMMEDIATE");
730
+ context.db.beginWriteTransaction();
351
731
  try {
732
+ assertReadOnlyInput();
733
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
734
+ if (link && !context.db.get("SELECT 1 FROM work_sessions WHERE id = ? AND status = 'running'", [link.id]))
735
+ throw new AppError("work_generation_stale", "Work Session was interrupted or superseded while checks ran", 1, { work_id: id });
736
+ if (link?.result_path && work.parent_work_id !== null)
737
+ publishReceiptFile(receiptIntentPath(link.result_path, link.id), JSON.stringify({ work_id: work.work_id, work_session_id: link.id, status, result_sha256: crypto.createHash("sha256").update(result).digest("hex"), previous_sha256: receiptFileHash(link.result_path) }));
352
738
  context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
353
739
  if (link) {
354
- if (link.result_path && work.parent_work_id !== null)
355
- fs.writeFileSync(link.result_path, result);
356
740
  context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
357
741
  const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
358
742
  if (!stillRunning)
@@ -364,18 +748,25 @@ async function settle(context, id, status, result, progress) {
364
748
  context.db.exec("ROLLBACK");
365
749
  throw error;
366
750
  }
751
+ materializePendingWorkResults(context, work.project_id, work.run_id);
752
+ settleRecoveryWork(context, work.work_id, status);
367
753
  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) }));
754
+ const control = runControlDirective(context, work.project_id, work.run_id);
755
+ 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
756
  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
757
  for (const ready of newlyReady)
371
758
  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" } };
759
+ 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
760
  }
761
+ 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
762
  function plannedAreaDrift(packet, result) {
375
763
  const changed = JSON.parse(result).changed_paths;
376
764
  if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
377
765
  return [];
378
- return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => item === area || item.startsWith(`${area}/`)));
766
+ return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => {
767
+ const normalized = area.replace(/[\\/]+$/, "");
768
+ return item === normalized || item.startsWith(`${normalized}/`);
769
+ }));
379
770
  }
380
771
  function bindSession(context, work, run, identity, now) {
381
772
  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,16 +776,26 @@ function bindSession(context, work, run, identity, now) {
385
776
  // Work ancestry and provider-tree containment are different relations. An
386
777
  // isolated worker is logically a child Work but physically a provider root.
387
778
  const parentSession = inferredParentSession ?? identity.parentSessionId;
779
+ // Stored provider ancestry uses internal keys, just like hook_events.
780
+ // Resolve native IDs only when rendering diagnostics; changing storage here
781
+ // breaks existing bindings and usage ancestry joins.
388
782
  const providerParentSession = identity.parentSessionId;
783
+ const identityDetails = (sessionKey = identity.sessionId, parentKey = parentSession) => ({
784
+ internal_session_key: sessionKey,
785
+ 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,
786
+ internal_parent_session_key: parentKey,
787
+ 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,
788
+ harness: identity.harness
789
+ });
389
790
  if (work.parent_work_id && !parentSession)
390
791
  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 });
391
792
  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 });
793
+ throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, ...identityDetails() });
393
794
  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
795
  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 });
796
+ 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
797
  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 });
798
+ 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 });
398
799
  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]);
399
800
  reactivateBoundSession(context, work.project_id, identity.sessionId, now);
400
801
  }
@@ -423,12 +824,12 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
423
824
  const candidate = `${resultPath}.candidate-${process.pid}`;
424
825
  fs.writeFileSync(candidate, result);
425
826
  try {
426
- validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: candidate, projectRoot, runId });
827
+ validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: candidate, projectRoot, runId, runRoot: runHome });
427
828
  }
428
829
  finally {
429
830
  fs.rmSync(candidate, { force: true });
430
831
  }
431
- if (work.result_schema === "dd-flow/code-work-result@2")
832
+ if (work.result_schema === "dd-flow/code-work-result@3")
432
833
  validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
433
834
  if (work.result_schema === "dd-flow/code-review-result@1")
434
835
  validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
@@ -437,20 +838,29 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
437
838
  }
438
839
  function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
439
840
  const result = value;
841
+ const changedPaths = (result.changed_paths ?? []).map((item) => normalizeWorkspacePath(projectRoot, item));
842
+ const ignored = ignoredWorkspacePaths(projectRoot, changedPaths);
843
+ if (ignored.length)
844
+ 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
845
  if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
441
846
  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
847
  const packet = codePacket(work);
443
848
  if (!packet)
444
849
  return;
445
- const acceptedCriteria = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
850
+ const acceptedObligations = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
851
+ for (const check of packet.checks)
852
+ acceptedObligations.add(check.id);
853
+ for (const finding of packet.repair?.review_findings ?? [])
854
+ for (const ref of finding.obligation_refs ?? [])
855
+ acceptedObligations.add(ref);
446
856
  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 });
857
+ if (!item.obligation_ref || !acceptedObligations.has(item.obligation_ref))
858
+ 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
859
  for (const ref of item.refs ?? [])
450
860
  assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
451
861
  }
452
862
  const documentUpdates = (packet.document_updates ?? []);
453
- const changed = new Set(result.changed_paths ?? []);
863
+ const changed = new Set(changedPaths);
454
864
  const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
455
865
  if (missingDocuments.length)
456
866
  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 });
@@ -458,36 +868,66 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
458
868
  return true; const current = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); return entry.action === "create" ? entry.baseline_sha256 !== null : current === entry.baseline_sha256; }).map((entry) => entry.path);
459
869
  if (unchangedDocuments.length)
460
870
  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
- const assigned = packet.repair?.review_finding_ids ?? [];
871
+ const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
872
+ validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
462
873
  if (assigned.length) {
463
- if ((result.changed_paths?.length ?? 0) === 0)
874
+ if (changedPaths.length === 0)
464
875
  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
- const resolved = new Set(result.resolved_finding_refs ?? []);
466
- const missing = assigned.filter((finding) => !resolved.has(finding));
467
- const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
468
- if (missing.length || unexpected.length)
469
- throw new AppError("review_repair_incomplete", "Review repair must explicitly resolve exactly its assigned finding references", 2, { work_id: work.work_id, missing, unexpected });
876
+ for (const resolution of result.resolutions ?? [])
877
+ for (const ref of resolution.evidence_refs ?? [])
878
+ assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
470
879
  }
471
880
  }
881
+ export function validateReviewResolutions(workId, assigned, resolutions) {
882
+ const resolved = new Set(resolutions.map((resolution) => resolution.finding_ref).filter((ref) => Boolean(ref)));
883
+ const missing = assigned.filter((finding) => !resolved.has(finding));
884
+ const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
885
+ const duplicate = resolutions.length !== resolved.size;
886
+ if (missing.length || unexpected.length || duplicate)
887
+ 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 });
888
+ }
472
889
  function renderWorkerPrompt(context, work, run, dependencies) {
473
890
  const command = flowCommand(context);
474
891
  const packet = codePacket(work);
475
- 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>", "", "<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>", ""] : [];
892
+ const finishWorkCommand = managedLifecycleCommand(context, `${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
893
+ const failWorkCommand = managedLifecycleCommand(context, `${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`);
894
+ const mergeWork = parsePayload(work)?.kind === "merge";
895
+ const writeBoundary = mergeWork
896
+ ? ["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."]
897
+ : [`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."];
898
+ const completionRepair = mergeWork
899
+ ? ["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."]
900
+ : ["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. If the command has no invocation-id, repeat the original Finish. Do not invent a cause that does not appear in the retained output."];
901
+ 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>", ""] : [];
476
902
  if (packet)
477
903
  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>", "");
478
- 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");
904
+ if (packet)
905
+ 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>", "");
906
+ 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>", ...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), "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>", 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, "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 with a quoted heredoc (replace the example JSON with your result):\n${finishWorkCommand} <<'DD_FLOW_RESULT'\n{}\nDD_FLOW_RESULT`, `Fail only for an evidenced external or semantic-contract blocker: ${failWorkCommand}`, "</completion>", ""].join("\n");
479
907
  }
480
- function resultSchemaGuidance(work, runId) {
908
+ export function resultSchemaGuidance(work, runId) {
481
909
  const schema = work.result_schema;
482
910
  const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
483
- if (schema === "dd-flow/code-work-result@2")
484
- return [refs, "Use this complete minimal shape. For a review repair, also include resolved_finding_refs with exactly the finding references assigned in repair_context:", "```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: [], resolved_finding_refs: [] }, null, 2), "```"];
911
+ if (schema === "dd-flow/code-work-result@3") {
912
+ const packet = codePacket(work);
913
+ const obligations = [...new Set([
914
+ ...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
915
+ ...(packet?.checks ?? []).map((item) => item.id),
916
+ ...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
917
+ ])];
918
+ const findings = packet?.repair?.review_findings ?? [];
919
+ return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
920
+ "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.",
921
+ "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: [].",
922
+ "```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), "```",
923
+ "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."];
924
+ }
485
925
  if (schema === "dd-flow/code-review-result@1") {
486
- 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.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
926
+ 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.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. 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/evidence"] }], 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), "```"];
487
927
  }
488
928
  if (schema !== "dd-flow/plan-review-result@1")
489
929
  return [];
490
- 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), "```"];
930
+ return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. 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/evidence"], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
491
931
  }
492
932
  export function validateCodeReviewResultIdentity(work, value) {
493
933
  const group = codeReviewGroup(work);
@@ -516,6 +956,7 @@ export function validateCodeReviewResultIdentity(work, value) {
516
956
  function validateCodeReviewResult(work, value, input) {
517
957
  validateCodeReviewResultIdentity(work, value);
518
958
  const result = value;
959
+ assertReviewAspectEvidence(work, result.aspects ?? [], "CODE");
519
960
  const findings = result.findings ?? [];
520
961
  // A reviewer is a child Work and cannot pause its coordinator-owned Stage.
521
962
  // Its structured blocked result is evidence for the coordinator, which then
@@ -545,6 +986,7 @@ export function validatePlanReviewResult(work, value, input) {
545
986
  const unexpected = ids.filter((id) => !expected.has(id));
546
987
  if (new Set(ids).size !== ids.length || missing.length || unexpected.length)
547
988
  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 });
989
+ assertReviewAspectEvidence(work, aspects, "PLAN");
548
990
  const findingIds = aspects.flatMap((aspect) => aspect.findings ?? []).map((finding) => finding.finding_id ?? "");
549
991
  const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
550
992
  if (new Set(findingIds).size !== findingIds.length || invalid.length)
@@ -559,6 +1001,11 @@ export function validatePlanReviewResult(work, value, input) {
559
1001
  assertPortableArtifactRef(ref, input);
560
1002
  }
561
1003
  }
1004
+ function assertReviewAspectEvidence(work, aspects, stage) {
1005
+ const missing = aspects.filter((aspect) => !Array.isArray(aspect.evidence_refs) || aspect.evidence_refs.length === 0).map((aspect) => aspect.aspect_id ?? "");
1006
+ if (missing.length)
1007
+ 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 });
1008
+ }
562
1009
  function codeReviewGroup(work) {
563
1010
  const payload = parsePayload(work);
564
1011
  const group = payload?.group;
@@ -573,10 +1020,10 @@ function requireWork(context, id) { ensureWorkRegistry(context); const exact = c
573
1020
  return exact; if (!/^WRK-\d{3,}$/.test(id))
574
1021
  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)
575
1022
  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]; }
576
- function requireRun(context, projectId, runId) { const run = context.db.get("SELECT r.id, r.run_home_path, 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_home_path)
577
- throw new AppError("runtime_missing", "RUN workspace is unavailable", 1, { run_id: runId }); return run; }
578
- function requireRunHome(run) { if (!run.run_home_path)
579
- throw new AppError("runtime_missing", "RUN workspace is unavailable", 1, { run_id: run.id }); return run.run_home_path; }
1023
+ 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)
1024
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
1025
+ function requireRunHome(run) { if (!run.run_root)
1026
+ throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: run.id }); return run.run_root; }
580
1027
  function parseDependencies(work) { try {
581
1028
  const value = JSON.parse(work.depends_on_json);
582
1029
  return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
@@ -613,19 +1060,19 @@ function validateItem(value, requireExecutionContext = false) { if (!value || ty
613
1060
  throw new AppError("validation", "launch_policy must be reuse_allowed or fresh_agent_required", 2); if (item.result_schema !== undefined && (typeof item.result_schema !== "string" || !item.result_schema))
614
1061
  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 } : {}) }; }
615
1062
  function allowsEmptyRepairChecks(item) { const repair = item.repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
616
- return false; const value = repair; return typeof value.check_receipt_id === "string" || (Array.isArray(value.review_check_refs) && value.review_check_refs.some((ref) => typeof ref === "string")) || (Array.isArray(value.semantic_unresolved) && value.semantic_unresolved.some((item) => typeof item === "string")); }
1063
+ 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")); }
617
1064
  function readJson(file) { try {
618
1065
  return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
619
1066
  }
620
1067
  catch (error) {
621
1068
  throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
622
1069
  } }
623
- export function refreshRunWorkProjection(context, projectId, runId) { refreshRunSessionProjection(context, projectId, runId); const run = context.db.get("SELECT run_home_path FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (run?.run_home_path) {
624
- const obsolete = path.join(run.run_home_path, "work.json");
1070
+ 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) {
1071
+ const obsolete = path.join(run.run_root, "work.json");
625
1072
  if (fs.existsSync(obsolete))
626
1073
  fs.rmSync(obsolete);
627
1074
  } }
628
- 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) })) }; }
1075
+ 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, 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) })) }; }
629
1076
  function parsePayload(work) { if (!work.payload_json)
630
1077
  return null; try {
631
1078
  const value = JSON.parse(work.payload_json);
@@ -634,6 +1081,7 @@ function parsePayload(work) { if (!work.payload_json)
634
1081
  catch {
635
1082
  return null;
636
1083
  } }
1084
+ function uniqueChecks(checks) { return checks.filter((check, index) => checks.findIndex((candidate) => candidate.id === check.id) === index); }
637
1085
  function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@5")
638
1086
  return null; return value; }
639
1087
  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))