@deksden-com/dd-flow-cli 0.7.0 → 0.8.0

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 (69) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +7 -2
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +88 -10
  5. package/dist/cli/run-cli.js +523 -28
  6. package/dist/domain/stage-catalog.js +22 -0
  7. package/dist/domain/validation.js +1 -1
  8. package/dist/schemas/code-review-decision.schema.json +26 -0
  9. package/dist/schemas/code-review-result.schema.json +14 -0
  10. package/dist/schemas/code-verification.schema.json +14 -0
  11. package/dist/schemas/code-work-batch.schema.json +24 -0
  12. package/dist/schemas/code-work-result.schema.json +16 -0
  13. package/dist/schemas/compatibility.schema.json +32 -0
  14. package/dist/schemas/flow-contract.schema.json +9 -5
  15. package/dist/schemas/flow-run.schema.json +16 -123
  16. package/dist/schemas/plan-aspect-map.schema.json +22 -0
  17. package/dist/schemas/plan-review-decision.schema.json +14 -0
  18. package/dist/schemas/plan-review-result.schema.json +42 -0
  19. package/dist/schemas/protocol-plan.schema.json +15 -182
  20. package/dist/schemas/stage-finish-input.schema.json +16 -2
  21. package/dist/schemas/stage-report.schema.json +8 -7
  22. package/dist/schemas/stage-start-response.schema.json +4 -2
  23. package/dist/schemas/status-report.schema.json +76 -0
  24. package/dist/schemas/vnext-protocol-plan.schema.json +37 -0
  25. package/dist/schemas/vnext-protocolize-result.schema.json +29 -0
  26. package/dist/schemas/vnext-specify.schema.json +45 -0
  27. package/dist/services/branch-context.js +1 -1
  28. package/dist/services/cleanup.js +8 -8
  29. package/dist/services/cli-operation-classifier.js +10 -2
  30. package/dist/services/code-checks.js +244 -0
  31. package/dist/services/config.js +7 -1
  32. package/dist/services/dashboard.js +12 -12
  33. package/dist/services/engines.js +1 -1
  34. package/dist/services/eval-snapshots.js +404 -0
  35. package/dist/services/hooks.js +774 -18
  36. package/dist/services/ids.js +16 -6
  37. package/dist/services/lanes.js +1 -1
  38. package/dist/services/merge-queue.js +5 -5
  39. package/dist/services/merge-worker.js +2 -2
  40. package/dist/services/migrations.js +2 -2
  41. package/dist/services/plan-runtime.js +1 -1
  42. package/dist/services/projects.js +4 -4
  43. package/dist/services/prompts.js +17 -11
  44. package/dist/services/protocols.js +8 -8
  45. package/dist/services/run-projection.js +49 -13
  46. package/dist/services/runs.js +504 -51
  47. package/dist/services/schema-validation.js +21 -3
  48. package/dist/services/sessions.js +51 -12
  49. package/dist/services/stage-blocker.js +57 -0
  50. package/dist/services/stage-context.js +90 -0
  51. package/dist/services/stage-lifecycle.js +198 -75
  52. package/dist/services/stage-pause.js +175 -0
  53. package/dist/services/stage-report-renderer.js +65 -0
  54. package/dist/services/usage.js +526 -18
  55. package/dist/services/vnext-code-review.js +305 -0
  56. package/dist/services/vnext-code.js +686 -0
  57. package/dist/services/vnext-contracts.js +1 -0
  58. package/dist/services/vnext-execution-profile.js +27 -0
  59. package/dist/services/vnext-fanout.js +79 -0
  60. package/dist/services/vnext-plan-review.js +499 -0
  61. package/dist/services/vnext-plan.js +552 -0
  62. package/dist/services/vnext-protocolize.js +542 -0
  63. package/dist/services/vnext-specify.js +595 -0
  64. package/dist/services/vnext-workspace-policy.js +87 -0
  65. package/dist/services/work-registry.js +522 -0
  66. package/dist/services/worktrees.js +58 -37
  67. package/dist/storage/database.js +263 -34
  68. package/dist/storage/paths.js +47 -1
  69. package/package.json +12 -12
@@ -0,0 +1,522 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { findRecentMatchingHookEvent, claimStageStartHookEvent, claimWorkStartHookEvent, hookSessionIdentity, workStartMatchKey } from "./hooks.js";
6
+ import { validateSchema } from "./schema-validation.js";
7
+ import { resolveProjectRoot, resolveRunReferences, writeJsonAtomic } from "../storage/paths.js";
8
+ import { refreshRunSessionProjection } from "./run-projection.js";
9
+ import { runCodeChecks } from "./code-checks.js";
10
+ import { nextWorkId } from "./ids.js";
11
+ import { appendFlowRunTimelineEvent } from "./runs.js";
12
+ 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";
13
+ export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
14
+ /** Validate a proposed batch before PLAN accepts it, without registering Work. */
15
+ export function validateWorkBatchFile(file) {
16
+ const parsed = readJson(file);
17
+ if (!Array.isArray(parsed.works) || parsed.works.length === 0)
18
+ throw new AppError("validation", "work batch requires non-empty works", 2);
19
+ const items = parsed.works.map((item) => validateItem(item, true));
20
+ const keys = new Set(items.map((item) => item.key));
21
+ if (keys.size !== items.length)
22
+ throw new AppError("validation", "work batch keys must be unique", 2);
23
+ for (const item of items)
24
+ for (const dependency of item.depends_on ?? [])
25
+ if (!keys.has(dependency))
26
+ throw new AppError("validation", "PLAN batch dependency must name a local key", 2, { key: item.key, dependency });
27
+ for (const item of items)
28
+ if (item.parent && !keys.has(item.parent))
29
+ throw new AppError("validation", "PLAN batch parent must name a local key", 2, { key: item.key, parent: item.parent });
30
+ assertNoCycles(items.map((item) => ({ id: item.key, dependencies: item.depends_on ?? [] })));
31
+ assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map((item) => ({ id: item.key, parent: item.parent })));
32
+ }
33
+ export function addWorkBatch(context, input) {
34
+ ensureWorkRegistry(context);
35
+ const parent = requireWork(context, input.parentWorkId);
36
+ if (parent.status !== "running")
37
+ throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
38
+ const parsed = readJson(input.file);
39
+ if (!Array.isArray(parsed.works) || parsed.works.length === 0)
40
+ throw new AppError("validation", "work batch requires non-empty works", 2);
41
+ const items = parsed.works.map((item) => validateItem(item));
42
+ const keys = new Set(items.map((item) => item.key));
43
+ if (keys.size !== items.length)
44
+ throw new AppError("validation", "work batch keys must be unique", 2);
45
+ const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
46
+ const existingIds = new Set(existing.map((row) => row.work_id));
47
+ for (const item of items)
48
+ for (const dependency of item.depends_on ?? [])
49
+ if (!keys.has(dependency) && !existingIds.has(dependency))
50
+ throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
51
+ for (const item of items)
52
+ if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
53
+ throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
54
+ const now = context.now();
55
+ context.db.exec("BEGIN IMMEDIATE");
56
+ try {
57
+ const ids = new Map(items.map((item) => [item.key, nextWorkId(context, parent.project_id, item.key)]));
58
+ const resolve = (value) => ids.get(value) ?? value;
59
+ const proposed = items.map((item) => ({ ...item, id: ids.get(item.key), parentId: item.parent ? resolve(item.parent) : parent.work_id, dependencies: (item.depends_on ?? []).map(resolve) }));
60
+ assertNoCycles(proposed.map((item) => ({ id: item.id, dependencies: item.dependencies })));
61
+ assertNoParentCycles(proposed.map((item) => ({ id: item.id, parent: item.parentId })));
62
+ for (const item of proposed)
63
+ 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)
64
+ 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]);
65
+ context.db.exec("COMMIT");
66
+ for (const item of proposed)
67
+ 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" });
68
+ refreshRunWorkProjection(context, parent.project_id, parent.run_id);
69
+ return { ok: true, work_ids: Object.fromEntries(ids) };
70
+ }
71
+ catch (error) {
72
+ context.db.exec("ROLLBACK");
73
+ throw error;
74
+ }
75
+ }
76
+ export function listWorks(context, input) {
77
+ ensureWorkRegistry(context);
78
+ if (!input.runId && !input.parentWorkId)
79
+ throw new AppError("usage", "work ls requires --run or --parent", 2);
80
+ const where = ["task IS NOT NULL"];
81
+ const params = [];
82
+ if (input.runId) {
83
+ where.push("run_id = ?");
84
+ params.push(input.runId);
85
+ }
86
+ if (input.parentWorkId) {
87
+ where.push("parent_work_id = ?");
88
+ params.push(input.parentWorkId);
89
+ }
90
+ if (input.status) {
91
+ where.push("status = ?");
92
+ params.push(input.status);
93
+ }
94
+ const rows = context.db.all(`SELECT ${workColumns} FROM works WHERE ${where.join(" AND ")} ORDER BY created_at, work_id`, params);
95
+ const ready = (work) => isReady(context, work);
96
+ const filtered = input.ready ? rows.filter(ready) : rows;
97
+ return { works: filtered.slice(0, input.limit ?? filtered.length).map((work) => {
98
+ const isReadyNow = ready(work);
99
+ return { ...work, payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), ready: isReadyNow, ...(isReadyNow ? { start_command: workStartCommand(context, work) } : {}), ...(input.includeResults ? {} : { result: undefined }) };
100
+ }) };
101
+ }
102
+ export function showWork(context, id) { const work = requireWork(context, id); const sessions = context.db.all("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? ORDER BY created_at", [work.work_id]); return { work: { ...work, short_id: shortWorkId(work.work_id), payload: parsePayload(work), payload_json: undefined, depends_on: parseDependencies(work), sessions } }; }
103
+ export function mutateWorkDeps(context, input) {
104
+ const work = requireWork(context, input.workId);
105
+ if (input.action === "list")
106
+ return { work_id: work.work_id, depends_on: parseDependencies(work) };
107
+ if (work.status !== "created")
108
+ throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
109
+ const dependencies = new Set(parseDependencies(work));
110
+ if (input.action === "clear")
111
+ dependencies.clear();
112
+ for (const id of input.on ?? []) {
113
+ const dependency = requireWork(context, id);
114
+ if (dependency.run_id !== work.run_id)
115
+ throw new AppError("validation", "Dependencies must stay in one RUN", 2);
116
+ if (input.action === "add")
117
+ dependencies.add(dependency.work_id);
118
+ else
119
+ dependencies.delete(dependency.work_id);
120
+ }
121
+ if (dependencies.has(work.work_id))
122
+ throw new AppError("validation", "Work cannot depend on itself", 2);
123
+ 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) })));
124
+ context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify([...dependencies]), context.now(), work.work_id]);
125
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
126
+ return { work_id: work.work_id, depends_on: [...dependencies] };
127
+ }
128
+ 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}%`]))
129
+ 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 }; }
130
+ export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
131
+ export function workStartCommand(context, work) { const run = requireRun(context, work.project_id, work.run_id); return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow work start ${work.work_id} --project-root ${JSON.stringify(run.project_root)} --json`; }
132
+ export function startWork(context, id, input) {
133
+ ensureWorkRegistry(context);
134
+ const work = requireWork(context, id);
135
+ if (work.status !== "created")
136
+ throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: id, status: work.status });
137
+ if (!isReady(context, work)) {
138
+ const blockers = readinessBlockers(context, work);
139
+ 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) }));
140
+ 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 });
141
+ }
142
+ const run = requireRun(context, work.project_id, work.run_id);
143
+ if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
144
+ throw new AppError("project_mismatch", "work start project root does not match its RUN", 1, { work_id: id });
145
+ let hookEventId = input.hookEventId;
146
+ if (!hookEventId) {
147
+ try {
148
+ 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;
149
+ }
150
+ catch {
151
+ hookEventId = findRecentMatchingHookEvent(context, { projectId: work.project_id, matchKey: workStartMatchKey(work.work_id, run.project_root), errorCode: "trusted_session_binding_required", operation: "work start" }).eventKey;
152
+ }
153
+ }
154
+ const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root });
155
+ const started = startBoundWork(context, work, run, identity, hookEventId);
156
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
157
+ return started;
158
+ }
159
+ /** Binds the already-running coordinator of a stage to its trusted hook Session. */
160
+ export function bindRunningWorkSession(context, input) {
161
+ const work = requireWork(context, input.workId);
162
+ if (work.status !== "running")
163
+ throw new AppError("invalid_work_state", "Stage coordinator Work must be running", 1, { work_id: work.work_id, status: work.status });
164
+ const run = requireRun(context, work.project_id, work.run_id);
165
+ const identity = hookSessionIdentity(context, work.project_id, input.hookEventId);
166
+ 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]);
167
+ if (active) {
168
+ if (active.session_id !== identity.sessionId) {
169
+ // A frozen `new_session` policy may transfer the same coordinator Work
170
+ // only to its next stage. Repeating the same stage from another Session
171
+ // remains a conflict because its result path is unchanged.
172
+ if (!input.allowStageHandoff || path.resolve(active.result_path ?? "") === path.resolve(input.resultPath ?? ""))
173
+ throw new AppError("handoff_session_mismatch", "The active Work is already bound to a different Session", 1, { work_id: work.work_id });
174
+ const now = context.now();
175
+ const id = `WS-${crypto.randomUUID()}`;
176
+ context.db.exec("BEGIN IMMEDIATE");
177
+ try {
178
+ context.db.run("UPDATE work_sessions SET status = 'completed', completed_at = ?, updated_at = ? WHERE id = ?", [now, now, active.id]);
179
+ const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [active.session_id]);
180
+ if (!stillRunning)
181
+ context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, active.session_id]);
182
+ bindSession(context, work, run, identity, now);
183
+ 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]);
184
+ context.db.exec("COMMIT");
185
+ }
186
+ catch (error) {
187
+ context.db.exec("ROLLBACK");
188
+ throw error;
189
+ }
190
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
191
+ return { work_session_id: id, session_id: identity.sessionId, handed_off: true };
192
+ }
193
+ context.db.run("UPDATE work_sessions SET prompt_path = ?, result_path = COALESCE(?, result_path), updated_at = ? WHERE id = ?", [input.promptPath, input.resultPath ?? null, context.now(), active.id]);
194
+ return { work_session_id: active.id, session_id: identity.sessionId, reused: true };
195
+ }
196
+ const now = context.now();
197
+ const id = `WS-${crypto.randomUUID()}`;
198
+ context.db.exec("BEGIN IMMEDIATE");
199
+ try {
200
+ bindSession(context, work, run, identity, now);
201
+ 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]);
202
+ context.db.exec("COMMIT");
203
+ }
204
+ catch (error) {
205
+ context.db.exec("ROLLBACK");
206
+ throw error;
207
+ }
208
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
209
+ return { work_session_id: id, session_id: identity.sessionId };
210
+ }
211
+ /** A stage coordinator is launched by `stage start`, not by an invented nested `work start`. */
212
+ export function bindStageCoordinatorWork(context, input) {
213
+ const work = requireWork(context, input.workId);
214
+ const run = requireRun(context, work.project_id, work.run_id);
215
+ 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 } : {}) });
216
+ 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) ?? "{}");
217
+ const binding = bindRunningWorkSession(context, { ...input, allowStageHandoff: snapshot.execution_profile?.settings?.stage_session_mode === "new_session" });
218
+ 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]);
219
+ refreshRunSessionProjection(context, work.project_id, work.run_id);
220
+ return binding;
221
+ }
222
+ /** Starts a created coordinator Work from its trusted `stage start` event. */
223
+ export function startStageCoordinatorWork(context, input) {
224
+ const work = requireWork(context, input.workId);
225
+ if (work.status !== "created")
226
+ throw new AppError("invalid_work_state", "Stage coordinator Work is not created", 2, { work_id: work.work_id, status: work.status });
227
+ const run = requireRun(context, work.project_id, work.run_id);
228
+ if (input.projectRoot && resolveProjectRoot(input.projectRoot) !== resolveProjectRoot(run.project_root))
229
+ throw new AppError("project_mismatch", "stage coordinator project root does not match its RUN", 1, { work_id: work.work_id });
230
+ const identity = claimStageStartHookEvent(context, { projectId: work.project_id, eventKey: input.hookEventId, runId: work.run_id, stage: input.stage, projectRoot: run.project_root });
231
+ const binding = startBoundWork(context, work, run, identity, input.hookEventId);
232
+ const sessionId = String(binding.session_binding.session_id ?? "");
233
+ context.db.run("UPDATE sessions SET current_stage = ?, updated_at = ? WHERE project_id = ? AND session_id = ?", [input.stage, context.now(), work.project_id, sessionId]);
234
+ refreshRunSessionProjection(context, work.project_id, work.run_id);
235
+ return binding;
236
+ }
237
+ /** Stage entry points call this only after their own trusted hook claim. */
238
+ export function startBoundWork(context, work, run, identity, hookEventId) {
239
+ if (work.status !== "created")
240
+ throw new AppError("invalid_work_state", "Work is not created", 2, { work_id: work.work_id, status: work.status });
241
+ const now = context.now();
242
+ const linkId = `WS-${crypto.randomUUID()}`;
243
+ const directory = path.join(requireRunHome(run), "works", work.work_id);
244
+ fs.mkdirSync(directory, { recursive: true });
245
+ const promptPath = path.join(directory, "prompt.md");
246
+ const resultPath = path.join(directory, "result.json");
247
+ context.db.exec("BEGIN IMMEDIATE");
248
+ try {
249
+ bindSession(context, work, run, identity, now);
250
+ 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]);
251
+ if (claimed.changes !== 1)
252
+ throw new AppError("conflict", "Work was claimed concurrently", 1, { work_id: work.work_id });
253
+ 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]);
254
+ context.db.exec("COMMIT");
255
+ }
256
+ catch (error) {
257
+ context.db.exec("ROLLBACK");
258
+ throw error;
259
+ }
260
+ const dependencyResults = parseDependencies(work).map((dependency) => context.db.get("SELECT work_id, result FROM works WHERE work_id = ?", [dependency])).filter(Boolean);
261
+ const prompt = renderWorkerPrompt(context, work, run, dependencyResults, resultPath);
262
+ writeJsonAtomic(path.join(directory, "context.json"), { 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 });
263
+ fs.writeFileSync(promptPath, prompt);
264
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
265
+ return { ok: true, work_id: work.work_id, work_session_id: linkId, worker_prompt_markdown: prompt, prompt_path: promptPath, session_binding: { source: "PreToolUse", session_id: identity.sessionId } };
266
+ }
267
+ export function finishWork(context, id, result, progress) { return settle(context, id, "completed", result, progress); }
268
+ export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
269
+ export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
270
+ export function retryWork(context, id, reason) { const work = requireWork(context, id); id = work.work_id; if (work.status !== "failed")
271
+ 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"]) {
272
+ const source = path.join(directory, file);
273
+ if (fs.existsSync(source))
274
+ fs.renameSync(source, path.join(archive, file));
275
+ } 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 }; }
276
+ async function settle(context, id, status, result, progress) {
277
+ const work = requireWork(context, id);
278
+ id = work.work_id;
279
+ if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
280
+ throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
281
+ if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running') LIMIT 1", [id]))
282
+ throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: id });
283
+ const run = requireRun(context, work.project_id, work.run_id);
284
+ const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [id]);
285
+ if (work.status === "running" && !link)
286
+ throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: id });
287
+ let receipts = [];
288
+ let coordinationDrift = [];
289
+ if (status === "completed") {
290
+ validateWorkResult(work, result, run.project_root, run.workspace_root, run.id, link?.result_path ?? null);
291
+ const packet = codePacket(work);
292
+ if (packet) {
293
+ coordinationDrift = plannedAreaDrift(packet, result);
294
+ const artifactDir = path.relative(requireRunHome(run), path.dirname(link.result_path));
295
+ 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 } : {}) });
296
+ const failed = receipts.filter((receipt) => receipt.status === "failed");
297
+ if (failed.length)
298
+ throw new AppError("work_checks_failed", "Work remains running because required checks failed", 2, { work_id: id, failures: failed, all_receipts: receipts });
299
+ }
300
+ }
301
+ const now = context.now();
302
+ context.db.exec("BEGIN IMMEDIATE");
303
+ try {
304
+ context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [status, result, now, now, id]);
305
+ if (link) {
306
+ if (link.result_path && work.parent_work_id !== null)
307
+ fs.writeFileSync(link.result_path, result);
308
+ context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?", [status, now, now, link.id]);
309
+ const stillRunning = context.db.get("SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running' LIMIT 1", [link.session_id]);
310
+ if (!stillRunning)
311
+ context.db.run("UPDATE sessions SET status = 'idle', updated_at = ? WHERE project_id = ? AND session_id = ?", [now, work.project_id, link.session_id]);
312
+ }
313
+ context.db.exec("COMMIT");
314
+ }
315
+ catch (error) {
316
+ context.db.exec("ROLLBACK");
317
+ throw error;
318
+ }
319
+ refreshRunWorkProjection(context, work.project_id, work.run_id);
320
+ 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) }));
321
+ 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 } : {}) });
322
+ for (const ready of newlyReady)
323
+ appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_dependency_unblocked", work_id: ready.work_id, completed_dependency: work.work_id });
324
+ 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" } };
325
+ }
326
+ function plannedAreaDrift(packet, result) {
327
+ const changed = JSON.parse(result).changed_paths;
328
+ if (!Array.isArray(changed) || packet.planned_write_areas.length === 0)
329
+ return [];
330
+ return changed.filter((item) => typeof item === "string" && !packet.planned_write_areas.some((area) => item === area || item.startsWith(`${area}/`)));
331
+ }
332
+ function bindSession(context, work, run, identity, now) {
333
+ 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;
334
+ const inferredParentSession = work.parent_work_id
335
+ ? context.db.get("SELECT session_id FROM work_sessions WHERE work_id = ? ORDER BY created_at DESC LIMIT 1", [work.parent_work_id])?.session_id ?? null
336
+ : priorWorkSession && priorWorkSession !== identity.sessionId ? priorWorkSession : null;
337
+ const parentSession = identity.parentSessionId ?? inferredParentSession;
338
+ if (work.parent_work_id && !parentSession)
339
+ 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 });
340
+ 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])))
341
+ 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 });
342
+ const existing = context.db.get("SELECT session_id, parent_session_id FROM sessions WHERE project_id = ? AND session_id = ?", [work.project_id, identity.sessionId]);
343
+ if (existing && existing.parent_session_id && parentSession && existing.parent_session_id !== parentSession)
344
+ throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { session_id: identity.sessionId });
345
+ context.db.run(`INSERT INTO sessions (session_id, project_id, harness, provider_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), 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, 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]);
346
+ reactivateBoundSession(context, work.project_id, identity.sessionId, now);
347
+ }
348
+ function reactivateBoundSession(context, projectId, sessionId, now) {
349
+ context.db.run("UPDATE sessions SET status = 'active', stop_reason = NULL, stopped_at = NULL, updated_at = ? WHERE project_id = ? AND session_id = ?", [now, projectId, sessionId]);
350
+ }
351
+ function validateWorkResult(work, result, projectRoot, workspaceRoot, runId, resultPath) {
352
+ if (!work.result_schema)
353
+ return;
354
+ if (!resultPath)
355
+ throw new AppError("runtime_missing", "Work result path is unavailable", 1, { work_id: work.work_id });
356
+ let parsed;
357
+ try {
358
+ parsed = JSON.parse(result);
359
+ }
360
+ catch {
361
+ throw new AppError("validation", "Work result must be JSON for its declared result schema", 2, { work_id: work.work_id, result_schema: work.result_schema });
362
+ }
363
+ fs.writeFileSync(resultPath, result);
364
+ validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: resultPath, projectRoot, runId });
365
+ if (work.result_schema === "dd-flow/code-work-result@2")
366
+ validateCodeWorkResult(work, parsed, workspaceRoot);
367
+ if (work.result_schema === "dd-flow/code-review-result@1")
368
+ validateCodeReviewResultIdentity(work, parsed);
369
+ }
370
+ function validateCodeWorkResult(work, value, projectRoot) {
371
+ const result = value;
372
+ if ((result.deviations?.length ?? 0) > 0 || (result.blockers?.length ?? 0) > 0)
373
+ 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 ?? [] });
374
+ const packet = codePacket(work);
375
+ if (!packet)
376
+ return;
377
+ const documentUpdates = (packet.document_updates ?? []);
378
+ const changed = new Set(result.changed_paths ?? []);
379
+ const missingDocuments = documentUpdates.map((entry) => entry.path).filter((entry) => !changed.has(entry));
380
+ if (missingDocuments.length)
381
+ 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 });
382
+ const unchangedDocuments = documentUpdates.filter((entry) => { const file = path.join(projectRoot, entry.path); if (!fs.existsSync(file) || !fs.statSync(file).isFile())
383
+ 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);
384
+ if (unchangedDocuments.length)
385
+ 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 });
386
+ const assigned = packet.repair?.review_finding_ids ?? [];
387
+ if (assigned.length) {
388
+ const resolved = new Set(result.resolved_finding_refs ?? []);
389
+ const missing = assigned.filter((finding) => !resolved.has(finding));
390
+ const unexpected = [...resolved].filter((finding) => !assigned.includes(finding));
391
+ if (missing.length || unexpected.length)
392
+ throw new AppError("review_repair_incomplete", "Review repair must explicitly resolve exactly its assigned finding references", 2, { work_id: work.work_id, missing, unexpected });
393
+ }
394
+ }
395
+ function renderWorkerPrompt(context, work, run, dependencies, resultPath) {
396
+ const command = `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
397
+ const packet = codePacket(work);
398
+ 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>", ""] : [];
399
+ if (packet)
400
+ 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>", "");
401
+ 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: all project 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.", "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), `Write it to ${resultPath}.`, "</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: ${command} work finish ${work.work_id} --result-file ${JSON.stringify(resultPath)} --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");
402
+ }
403
+ function resultSchemaGuidance(work) {
404
+ const schema = work.result_schema;
405
+ if (schema === "dd-flow/code-work-result@2")
406
+ return ["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"] }], deviations: [], blockers: [], resolved_finding_refs: [] }, null, 2), "```"];
407
+ if (schema === "dd-flow/code-review-result@1") {
408
+ return ["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"] }], 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), "```"];
409
+ }
410
+ if (schema !== "dd-flow/plan-review-result@1")
411
+ return [];
412
+ return ["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"], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
413
+ }
414
+ export function validateCodeReviewResultIdentity(work, value) {
415
+ const group = codeReviewGroup(work);
416
+ if (!group)
417
+ throw new AppError("review_evidence_invalid", "CODE reviewer Work has no assigned review group", 2, { work_id: work.work_id });
418
+ const result = value;
419
+ const aspectIds = (result.aspects ?? []).map((item) => item.aspect_id ?? "");
420
+ const expected = new Set(group.aspect_ids);
421
+ const missing = group.aspect_ids.filter((id) => !aspectIds.includes(id));
422
+ const unexpected = aspectIds.filter((id) => !expected.has(id));
423
+ if (new Set(aspectIds).size !== aspectIds.length || missing.length || unexpected.length) {
424
+ throw new AppError("review_evidence_invalid", "CODE reviewer result must assess every assigned aspect exactly once", 2, { work_id: work.work_id, group: group.key, missing, unexpected });
425
+ }
426
+ const findingIds = (result.findings ?? []).map((item) => item.finding_id ?? "");
427
+ const invalid = findingIds.filter((id) => !/^FIND-\d{3}$/.test(id));
428
+ const wrongAspect = (result.findings ?? []).filter((item) => !item.aspect_id || !expected.has(item.aspect_id)).map((item) => item.finding_id ?? "");
429
+ if (new Set(findingIds).size !== findingIds.length || invalid.length || wrongAspect.length) {
430
+ throw new AppError("review_evidence_invalid", "CODE reviewer finding ids must be local FIND-NNN ids and aspect refs must stay inside the assigned review group", 2, { work_id: work.work_id, group: group.key, required_format: "FIND-NNN", invalid, wrong_aspect: wrongAspect });
431
+ }
432
+ }
433
+ function codeReviewGroup(work) {
434
+ const payload = parsePayload(work);
435
+ const group = payload?.group;
436
+ if (!group || typeof group !== "object" || Array.isArray(group))
437
+ return null;
438
+ const value = group;
439
+ return typeof value.key === "string" && Array.isArray(value.aspect_ids) && value.aspect_ids.every((item) => typeof item === "string")
440
+ ? { key: value.key, aspect_ids: value.aspect_ids }
441
+ : null;
442
+ }
443
+ function requireWork(context, id) { ensureWorkRegistry(context); const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?`, [id]); if (exact)
444
+ return exact; if (!/^WRK-\d{3,}$/.test(id))
445
+ 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)
446
+ 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]; }
447
+ 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)
448
+ throw new AppError("runtime_missing", "RUN workspace is unavailable", 1, { run_id: runId }); return run; }
449
+ function requireRunHome(run) { if (!run.run_home_path)
450
+ throw new AppError("runtime_missing", "RUN workspace is unavailable", 1, { run_id: run.id }); return run.run_home_path; }
451
+ function parseDependencies(work) { try {
452
+ const value = JSON.parse(work.depends_on_json);
453
+ return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : [];
454
+ }
455
+ catch {
456
+ return [];
457
+ } }
458
+ function isReady(context, work) { return work.status === "created" && readinessBlockers(context, work).length === 0; }
459
+ function readinessBlockers(context, work) {
460
+ const blockers = parseDependencies(work).flatMap((id) => { const dependency = context.db.get("SELECT status FROM works WHERE work_id = ?", [id]); return dependency?.status === "completed" ? [] : [{ kind: "dependency", work_id: id, status: dependency?.status ?? "missing" }]; });
461
+ const areas = workPlannedWriteAreas(work);
462
+ if (areas.length === 0)
463
+ return blockers;
464
+ for (const running of context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ? AND status = 'running' AND work_id != ?`, [work.project_id, work.run_id, work.work_id])) {
465
+ const overlap = overlappingAreas(areas, workPlannedWriteAreas(running));
466
+ if (overlap.length)
467
+ blockers.push({ kind: "planned_write_area", work_id: running.work_id, paths: overlap });
468
+ }
469
+ return blockers;
470
+ }
471
+ function workPlannedWriteAreas(work) { const payload = parsePayload(work); return Array.isArray(payload?.planned_write_areas) ? payload.planned_write_areas.filter((item) => typeof item === "string").map((item) => item.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "")) : []; }
472
+ function overlappingAreas(left, right) { const overlap = new Set(); for (const a of left)
473
+ for (const b of right)
474
+ if (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`))
475
+ overlap.add(a.length <= b.length ? a : b); return [...overlap]; }
476
+ function validateItem(value, requireExecutionContext = false) { if (!value || typeof value !== "object" || Array.isArray(value))
477
+ throw new AppError("validation", "work item must be an object", 2); const item = value; if (typeof item.key !== "string" || !item.key || typeof item.task !== "string" || !item.task.trim())
478
+ throw new AppError("validation", "work item requires key and task", 2); const code = item.schema_id === "dd-flow/code-work-packet@5"; if (requireExecutionContext && !code)
479
+ throw new AppError("validation", "CODE batch requires code-work-packet@5 items", 2, { key: item.key }); for (const key of code ? ["required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions"] : [])
480
+ if (!Array.isArray(item[key]) || (!['provides_checks', 'planned_write_areas'].includes(key) && item[key].length === 0))
481
+ throw new AppError("validation", `CODE work item requires ${['provides_checks', 'planned_write_areas'].includes(key) ? "an array" : `non-empty ${key}`}`, 2, { key: item.key }); if (item.depends_on !== undefined && (!Array.isArray(item.depends_on) || !item.depends_on.every((entry) => typeof entry === "string")))
482
+ throw new AppError("validation", "depends_on must be a string array", 2); if (item.parent !== undefined && typeof item.parent !== "string")
483
+ throw new AppError("validation", "parent must be a string", 2); if (item.launch_policy !== undefined && item.launch_policy !== "reuse_allowed" && item.launch_policy !== "fresh_agent_required")
484
+ 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))
485
+ 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 } : {}) }; }
486
+ function readJson(file) { try {
487
+ return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
488
+ }
489
+ catch (error) {
490
+ throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
491
+ } }
492
+ 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) {
493
+ const obsolete = path.join(run.run_home_path, "work.json");
494
+ if (fs.existsSync(obsolete))
495
+ fs.rmSync(obsolete);
496
+ } }
497
+ 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) })) }; }
498
+ function parsePayload(work) { if (!work.payload_json)
499
+ return null; try {
500
+ const value = JSON.parse(work.payload_json);
501
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
502
+ }
503
+ catch {
504
+ return null;
505
+ } }
506
+ function codePacket(work) { const value = parsePayload(work); if (value?.schema_id !== "dd-flow/code-work-packet@5")
507
+ return null; return value; }
508
+ 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))
509
+ throw new AppError("validation", "Work dependencies contain a cycle", 2); if (done.has(id))
510
+ return; active.add(id); for (const dependency of local.get(id) ?? [])
511
+ visit(dependency); active.delete(id); done.add(id); }; for (const id of local.keys())
512
+ visit(id); }
513
+ function assertNoParentCycles(nodes) { const parents = new Map(nodes.map((node) => [node.id, node.parent])); for (const node of nodes) {
514
+ const seen = new Set([node.id]);
515
+ let parent = node.parent;
516
+ while (parents.has(parent)) {
517
+ if (seen.has(parent))
518
+ throw new AppError("validation", "Work parents contain a cycle", 2);
519
+ seen.add(parent);
520
+ parent = parents.get(parent);
521
+ }
522
+ } }