@deksden-com/dd-flow-cli 0.9.0-beta.74 → 0.9.0-beta.79

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 (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/command-inputs.js +300 -0
  4. package/dist/cli/help.js +1 -1
  5. package/dist/cli/hook-ingress.js +82 -0
  6. package/dist/cli/input-preparation.js +115 -0
  7. package/dist/cli/run-cli.js +1015 -423
  8. package/dist/cli.js +6 -2
  9. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  10. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  11. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  12. package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  16. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  17. package/dist/services/canon.js +11 -8
  18. package/dist/services/cleanup.js +49 -8
  19. package/dist/services/cli-operation-classifier.js +12 -24
  20. package/dist/services/codex-hook-delivery.js +28 -0
  21. package/dist/services/config.js +11 -8
  22. package/dist/services/controller-fanout.js +7 -11
  23. package/dist/services/dashboard.js +94 -31
  24. package/dist/services/engines.js +75 -61
  25. package/dist/services/eval-snapshots.js +138 -38
  26. package/dist/services/execution-policy.js +5 -12
  27. package/dist/services/hooks.js +226 -203
  28. package/dist/services/lanes.js +60 -52
  29. package/dist/services/lifecycle-command.js +13 -1
  30. package/dist/services/lifecycle-invocations.js +285 -94
  31. package/dist/services/managed-processes.js +158 -18
  32. package/dist/services/merge-queue.js +173 -98
  33. package/dist/services/migrations.js +13 -8
  34. package/dist/services/plan-runtime.js +19 -8
  35. package/dist/services/plans.js +28 -25
  36. package/dist/services/projects.js +11 -2
  37. package/dist/services/prompts.js +23 -16
  38. package/dist/services/protocols.js +54 -19
  39. package/dist/services/recovery-observation-budget.js +1 -1
  40. package/dist/services/run-controller-adapter.js +31 -20
  41. package/dist/services/run-controller-state.js +5 -0
  42. package/dist/services/run-controller.js +102 -56
  43. package/dist/services/run-fork.js +36 -22
  44. package/dist/services/run-recovery.js +32 -17
  45. package/dist/services/runs.js +110 -38
  46. package/dist/services/runtime-budget.js +120 -48
  47. package/dist/services/runtime-scope-capture.js +2 -2
  48. package/dist/services/runtime-scope-control.js +2 -2
  49. package/dist/services/runtime-scope-resume.js +34 -12
  50. package/dist/services/runtime-scope-worker.js +49 -16
  51. package/dist/services/runtime-service.js +8 -7
  52. package/dist/services/schema-validation.js +9 -7
  53. package/dist/services/sessions.js +35 -69
  54. package/dist/services/stage-blocker.js +17 -7
  55. package/dist/services/stage-context.js +42 -16
  56. package/dist/services/stage-lifecycle.js +82 -100
  57. package/dist/services/stage-pause.js +54 -24
  58. package/dist/services/vnext-code-review.js +82 -32
  59. package/dist/services/vnext-code.js +78 -45
  60. package/dist/services/vnext-fanout.js +13 -12
  61. package/dist/services/vnext-merge.js +68 -35
  62. package/dist/services/vnext-plan-review.js +85 -42
  63. package/dist/services/vnext-plan.js +91 -70
  64. package/dist/services/vnext-protocolize.js +116 -50
  65. package/dist/services/vnext-specify.js +70 -57
  66. package/dist/services/work-registry.js +207 -94
  67. package/dist/services/worktrees.js +85 -35
  68. package/dist/storage/database.js +104 -10
  69. package/dist/storage/paths.js +17 -4
  70. package/package.json +1 -1
@@ -17,6 +17,7 @@ import { assertPortableArtifactRef } from "./portable-refs.js";
17
17
  import { publicSessionIdentity } from "./session-identity.js";
18
18
  import { activateRecoveryWork, assertRecoveryHookCurrent, assertRunMutationAllowed, assertWorkSettlementAllowed, recoveryGuard, recoveryPromptForWork, settleRecoveryWork } from "./run-recovery.js";
19
19
  import { runControlDirective } from "./run-control.js";
20
+ import { prepareError, prepareJsonFile } from "../cli/input-preparation.js";
20
21
  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";
21
22
  export function ensureWorkRegistry(context) { context.db.exec("SELECT 1 FROM works LIMIT 1"); context.db.exec("SELECT 1 FROM work_sessions LIMIT 1"); }
22
23
  function ownsPhysicalWorkSession(context, projectId, owner, identity) {
@@ -105,8 +106,12 @@ export function createChildWork(context, input) {
105
106
  }
106
107
  /** Validate a proposed batch before PLAN accepts it, without registering Work. */
107
108
  export function validateWorkBatchFile(file) {
108
- const parsed = readJson(file);
109
- if (!Array.isArray(parsed.works) || parsed.works.length === 0)
109
+ validateWorkBatch(prepareJsonFile(file, "work batch").value);
110
+ }
111
+ /** Validate an already prepared batch without reopening its source file. */
112
+ export function validateWorkBatch(value) {
113
+ const parsed = value;
114
+ if (!parsed || !Array.isArray(parsed.works) || parsed.works.length === 0)
110
115
  throw new AppError("validation", "work batch requires non-empty works", 2);
111
116
  const items = parsed.works.map((item) => validateItem(item, true));
112
117
  const keys = new Set(items.map((item) => item.key));
@@ -122,29 +127,46 @@ export function validateWorkBatchFile(file) {
122
127
  assertNoCycles(items.map((item) => ({ id: item.key, dependencies: item.depends_on ?? [] })));
123
128
  assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map((item) => ({ id: item.key, parent: item.parent })));
124
129
  }
125
- export function addWorkBatch(context, input) {
126
- ensureWorkRegistry(context);
127
- const parsed = readJson(input.file);
128
- if (!Array.isArray(parsed.works) || parsed.works.length === 0)
130
+ export function prepareWorkBatch(context, input) {
131
+ if (input.file !== undefined && input.value !== undefined)
132
+ throw new AppError("usage", "Work batch accepts either file or value, not both", 2);
133
+ if (input.file === undefined && input.value === undefined)
134
+ throw new AppError("usage", "Work batch input is required", 2);
135
+ const parsed = (input.file !== undefined ? prepareJsonFile(input.file, "work batch").value : input.value);
136
+ if (!parsed || !Array.isArray(parsed.works) || parsed.works.length === 0)
129
137
  throw new AppError("validation", "work batch requires non-empty works", 2);
130
138
  const items = parsed.works.map((item) => validateItem(item));
131
139
  const keys = new Set(items.map((item) => item.key));
132
140
  if (keys.size !== items.length)
133
141
  throw new AppError("validation", "work batch keys must be unique", 2);
142
+ assertNoCycles(items.map(item => ({ id: item.key, dependencies: item.depends_on ?? [] })));
143
+ assertNoParentCycles(items.filter((item) => Boolean(item.parent)).map(item => ({ id: item.key, parent: item.parent })));
144
+ prepareBatchParent(context, input.parentWorkId, items);
145
+ return items;
146
+ }
147
+ function prepareBatchParent(context, parentWorkId, items) {
148
+ const keys = new Set(items.map(item => item.key));
149
+ const parent = requireWork(context, parentWorkId);
150
+ assertRunMutationAllowed(context, parent.project_id, parent.run_id);
151
+ if (parent.status !== "running")
152
+ throw new AppError("invalid_work_state", "Batch parent must be running", 2, { work_id: parent.work_id, status: parent.status });
153
+ const existing = context.db.all(`SELECT ${workColumns} FROM works WHERE project_id = ? AND run_id = ?`, [parent.project_id, parent.run_id]);
154
+ const existingIds = new Set(existing.map((row) => row.work_id));
155
+ for (const item of items)
156
+ for (const dependency of item.depends_on ?? [])
157
+ if (!keys.has(dependency) && !existingIds.has(dependency))
158
+ throw new AppError("validation", "work dependency is unknown", 2, { key: item.key, dependency });
159
+ for (const item of items)
160
+ if (item.parent && !keys.has(item.parent) && !existingIds.has(item.parent))
161
+ throw new AppError("validation", "work parent is unknown", 2, { key: item.key, parent: item.parent });
162
+ return parent;
163
+ }
164
+ export function addWorkBatch(context, input) {
165
+ const items = input.prepared ?? prepareWorkBatch(context, input);
134
166
  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 });
167
+ // Only mutable authority/references are rechecked under the write lock.
168
+ // Caller bytes were read once, before admission, and are never reopened.
169
+ const parent = prepareBatchParent(context, input.parentWorkId, items);
148
170
  const now = context.now();
149
171
  const allocated = nextWorkIds(context, parent.project_id, items.map((item) => item.key));
150
172
  const ids = new Map(items.map((item, index) => [item.key, allocated[index]]));
@@ -295,36 +317,60 @@ export function materializePendingWorkResults(context, projectId, runId) {
295
317
  }
296
318
  export function mutateWorkDeps(context, input) {
297
319
  if (input.action === "list") {
298
- const work = requireWork(context, input.workId);
299
- return { work_id: work.work_id, depends_on: parseDependencies(work) };
320
+ const { work, dependencies } = prepareWorkDeps(context, input);
321
+ return { work_id: work.work_id, depends_on: dependencies };
300
322
  }
323
+ prepareWorkDeps(context, input);
301
324
  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]);
325
+ const { work, dependencies } = prepareWorkDeps(context, input);
326
+ context.db.run("UPDATE works SET depends_on_json = ?, updated_at = ? WHERE work_id = ?", [JSON.stringify(dependencies), context.now(), work.work_id]);
322
327
  refreshRunWorkProjection(context, work.project_id, work.run_id);
323
- return { work_id: work.work_id, depends_on: [...dependencies] };
328
+ return { work_id: work.work_id, depends_on: dependencies };
324
329
  });
325
330
  }
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 }; }); }
331
+ export function prepareWorkDeps(context, input) {
332
+ if (!["list", "add", "remove", "clear"].includes(input.action) || (input.on !== undefined && (!Array.isArray(input.on) || input.on.some(id => typeof id !== "string" || !id.trim()))))
333
+ throw new AppError("usage", "Invalid Work dependency action or --on target", 2);
334
+ if (["add", "remove"].includes(input.action) && !input.on?.length)
335
+ throw new AppError("usage", "Dependency add/remove requires at least one --on target", 2);
336
+ if (["list", "clear"].includes(input.action) && input.on?.length)
337
+ throw new AppError("usage", "Dependency list/clear does not accept --on targets", 2);
338
+ if (input.action === "list") {
339
+ const work = requireWork(context, input.workId);
340
+ return { work, dependencies: parseDependencies(work) };
341
+ }
342
+ const work = requireWork(context, input.workId);
343
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
344
+ if (work.status !== "created")
345
+ throw new AppError("invalid_work_state", "Dependencies change only while Work is created", 2);
346
+ const dependencies = new Set(parseDependencies(work));
347
+ if (input.action === "clear")
348
+ dependencies.clear();
349
+ for (const id of input.on ?? []) {
350
+ const dependency = requireWork(context, id);
351
+ if (dependency.project_id !== work.project_id || dependency.run_id !== work.run_id)
352
+ throw new AppError("validation", "Dependencies must stay in one project and RUN", 2);
353
+ if (input.action === "add")
354
+ dependencies.add(dependency.work_id);
355
+ else
356
+ dependencies.delete(dependency.work_id);
357
+ }
358
+ if (dependencies.has(work.work_id))
359
+ throw new AppError("validation", "Work cannot depend on itself", 2);
360
+ 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) })));
361
+ return { work, dependencies: [...dependencies] };
362
+ }
363
+ export function prepareWorkDelete(context, id) {
364
+ const work = requireWork(context, id);
365
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
366
+ if (work.status !== "created" || work.started_at || context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? LIMIT 1", [work.work_id]) || context.db.get("SELECT 1 FROM works WHERE depends_on_json LIKE ? LIMIT 1", [`%${work.work_id}%`]))
367
+ throw new AppError("invalid_work_state", "Only an unstarted unreferenced Work may be deleted", 2);
368
+ return work;
369
+ }
370
+ export function deleteWork(context, id) {
371
+ prepareWorkDelete(context, id);
372
+ return context.db.writeTransaction(() => { const work = prepareWorkDelete(context, id); context.db.run("DELETE FROM works WHERE work_id = ?", [work.work_id]); refreshRunWorkProjection(context, work.project_id, work.run_id); return { ok: true, deleted: work.work_id }; });
373
+ }
328
374
  export function shortWorkId(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
329
375
  /**
330
376
  * Return the engine-bound command, never a PATH-dependent `dd-flow` token.
@@ -588,27 +634,76 @@ export function finishFanInWork(context, id, result) { const work = requireWork(
588
634
  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 }; }
589
635
  export function failWork(context, id, reason) { return settle(context, id, "failed", reason); }
590
636
  export function cancelWork(context, id, reason) { return settle(context, id, "cancelled", reason); }
591
- export function retryWork(context, id, reason) {
637
+ export function prepareWorkSettlement(context, id, status, result) {
638
+ if (status !== "completed" && (typeof result !== "string" || !result.trim()))
639
+ throw new AppError("usage", "Work settlement requires a nonempty --reason", 2);
592
640
  const work = requireWork(context, id);
641
+ assertWorkSettlementAllowed(context, work.project_id, work.run_id, work.work_id);
642
+ if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
643
+ throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
644
+ if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [work.work_id]))
645
+ throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: work.work_id });
646
+ const run = requireRun(context, work.project_id, work.run_id);
647
+ const link = context.db.get("SELECT id, work_id, session_id, hook_event_id, status, prompt_path, result_path, created_at, completed_at FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
648
+ if (work.status === "running" && !link)
649
+ throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: work.work_id });
650
+ return { work, run, link };
651
+ }
652
+ export function prepareWorkRetry(context, id, reason) {
653
+ if (typeof reason !== "string" || !reason.trim())
654
+ throw new AppError("usage", "Work retry requires a nonempty --reason", 2);
655
+ const work = requireWork(context, id);
656
+ assertRunMutationAllowed(context, work.project_id, work.run_id);
593
657
  const run = requireRun(context, work.project_id, work.run_id);
594
658
  const directory = path.join(requireRunHome(run), "works", work.work_id);
595
659
  const journal = path.join(directory, ".retry.json");
660
+ let pending = null;
661
+ if (fs.existsSync(journal)) {
662
+ let value;
663
+ try {
664
+ value = JSON.parse(fs.readFileSync(journal, "utf8"));
665
+ }
666
+ catch (error) {
667
+ if (!(error instanceof SyntaxError))
668
+ throw error;
669
+ throw new AppError("work_retry_invalid", "Retry journal is not valid JSON", 1);
670
+ }
671
+ const item = value;
672
+ if (!item || typeof item !== "object" || item.work_id !== work.work_id || typeof item.archive !== "string" || !/^ATT-\d{3,}$/.test(item.archive) || !Array.isArray(item.entries) || item.entries.some(file => !["prompt.md", "result.json", "context.json"].includes(file)) || new Set(item.entries).size !== item.entries.length || !(item.result_sha256 === null || typeof item.result_sha256 === "string" && /^[a-f0-9]{64}$/.test(item.result_sha256)))
673
+ throw new AppError("work_retry_invalid", "Retry journal does not identify this Work", 1);
674
+ pending = item;
675
+ }
676
+ if (work.status !== "failed" && !(pending && work.status === "created"))
677
+ throw new AppError("invalid_work_state", "Only failed Work may be retried", 2);
678
+ const attempts = path.join(directory, "attempts");
679
+ let number = 1;
680
+ while (fs.existsSync(path.join(attempts, `ATT-${String(number).padStart(3, "0")}`)))
681
+ number += 1;
682
+ const prepared = pending ?? { work_id: work.work_id, archive: `ATT-${String(number).padStart(3, "0")}`, entries: ["prompt.md", "result.json", "context.json"].filter(file => fs.existsSync(path.join(directory, file))), result_sha256: work.result === null ? null : crypto.createHash("sha256").update(work.result).digest("hex") };
683
+ const archive = path.join(attempts, prepared.archive);
684
+ if (work.status === "failed") {
685
+ if (prepared.result_sha256 !== (work.result === null ? null : crypto.createHash("sha256").update(work.result).digest("hex")))
686
+ throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
687
+ const receipt = path.join(archive, "accepted-receipt.json");
688
+ if (fs.existsSync(receipt) && fs.readFileSync(receipt, "utf8") !== JSON.stringify({ work_id: work.work_id, status: work.status, result: work.result, result_sha256: prepared.result_sha256 }))
689
+ throw new AppError("work_retry_conflict", "Archived receipt differs from failed Work", 1);
690
+ for (const file of prepared.entries) {
691
+ const source = path.join(directory, file), target = path.join(archive, file);
692
+ if (fs.existsSync(source) && fs.existsSync(target))
693
+ throw new AppError("archive_conflict", "Both live and archived Work artifact exist; preserve both", 1, { source, target });
694
+ if (!fs.existsSync(source) && !fs.existsSync(target))
695
+ throw new AppError("archive_artifact_missing", "Work artifact is absent from both retry locations", 1, { source, target });
696
+ }
697
+ }
698
+ return { work, run, directory, journal, pending, prepared, archive };
699
+ }
700
+ export function retryWork(context, id, reason) {
701
+ const { work, run, directory, journal } = prepareWorkRetry(context, id, reason);
596
702
  let archive = "";
597
703
  context.db.beginWriteTransaction();
598
704
  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);
705
+ const { work: current, pending, prepared, archive: targetArchive } = prepareWorkRetry(context, work.work_id, reason);
706
+ archive = targetArchive;
612
707
  if (current.status === "failed") {
613
708
  if (prepared.result_sha256 !== (current.result === null ? null : crypto.createHash("sha256").update(current.result).digest("hex")))
614
709
  throw new AppError("work_retry_conflict", "Failed Work receipt changed during retry", 1);
@@ -678,17 +773,8 @@ function sharedReviewInput(work, run, initialize = false) {
678
773
  return typeof value.read_only_input.fingerprint === "string" ? value.read_only_input.fingerprint : null;
679
774
  }
680
775
  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);
776
+ const { work, run, link } = prepareWorkSettlement(context, id, status, result);
683
777
  id = work.work_id;
684
- if (work.status !== "running" && !(status === "cancelled" && work.status === "created"))
685
- throw new AppError("invalid_work_state", "Work is not running", 2, { status: work.status });
686
- if (status === "completed" && context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created', 'running', 'paused') LIMIT 1", [id]))
687
- throw new AppError("active_child_work", "Work cannot complete while a child Work is active", 2, { work_id: id });
688
- const run = requireRun(context, work.project_id, work.run_id);
689
- 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]);
690
- if (work.status === "running" && !link)
691
- throw new AppError("runtime_missing", "Running Work has no open Work/Session link", 1, { work_id: id });
692
778
  let receipts = [];
693
779
  let coordinationDrift = [];
694
780
  const assertReadOnlyInput = () => {
@@ -776,16 +862,28 @@ function bindSession(context, work, run, identity, now) {
776
862
  // Work ancestry and provider-tree containment are different relations. An
777
863
  // isolated worker is logically a child Work but physically a provider root.
778
864
  const parentSession = inferredParentSession ?? identity.parentSessionId;
865
+ // Stored provider ancestry uses internal keys, just like hook_events.
866
+ // Resolve native IDs only when rendering diagnostics; changing storage here
867
+ // breaks existing bindings and usage ancestry joins.
779
868
  const providerParentSession = identity.parentSessionId;
869
+ const identityDetails = (sessionKey = identity.sessionId, parentKey = parentSession) => ({
870
+ internal_session_key: sessionKey,
871
+ 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,
872
+ internal_parent_session_key: parentKey,
873
+ 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,
874
+ harness: identity.harness
875
+ });
780
876
  if (work.parent_work_id && !parentSession)
781
877
  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 });
782
878
  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])))
783
- 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 });
879
+ throw new AppError("fresh_session_required", "This Work requires a fresh Session in this RUN", 1, { work_id: work.work_id, effect: "no_effect", recoverable: true, handoff_required: true, ...identityDetails() });
784
880
  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]);
785
- if (existing && existing.parent_session_id && parentSession && existing.parent_session_id !== parentSession)
786
- throw new AppError("session_parent_conflict", "Observed Session already has a different immutable parent", 1, { session_id: identity.sessionId });
881
+ // A reuse-allowed Work can follow another Work in this same Session. Its
882
+ // Work predecessor is not a new physical/logical parent of the Session.
883
+ if (existing && existing.parent_session_id && parentSession && parentSession !== identity.sessionId && existing.parent_session_id !== parentSession)
884
+ 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 });
787
885
  if (existing?.provider_parent_session_id && providerParentSession && existing.provider_parent_session_id !== providerParentSession)
788
- throw new AppError("provider_session_parent_conflict", "Observed provider Session already has a different immutable provider parent", 1, { session_id: identity.sessionId });
886
+ 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 });
789
887
  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]);
790
888
  reactivateBoundSession(context, work.project_id, identity.sessionId, now);
791
889
  }
@@ -799,6 +897,15 @@ export function bindSessionForResume(context, workId, identity, now) {
799
897
  function reactivateBoundSession(context, projectId, sessionId, now) {
800
898
  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]);
801
899
  }
900
+ export function prepareWorkResult(context, id, result) {
901
+ try {
902
+ const { work, run, link } = prepareWorkSettlement(context, id, "completed", result);
903
+ validateWorkResult(work, result, run.project_root, run.workspace_root, requireRunHome(run), run.id, link?.result_path ?? null);
904
+ }
905
+ catch (error) {
906
+ prepareError(error, { work_id: id });
907
+ }
908
+ }
802
909
  function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, runId, resultPath) {
803
910
  if (!work.result_schema)
804
911
  return;
@@ -811,14 +918,7 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
811
918
  catch {
812
919
  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 });
813
920
  }
814
- const candidate = `${resultPath}.candidate-${process.pid}`;
815
- fs.writeFileSync(candidate, result);
816
- try {
817
- validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: candidate, projectRoot, runId, runRoot: runHome });
818
- }
819
- finally {
820
- fs.rmSync(candidate, { force: true });
821
- }
921
+ validateSchema({ schemaName: work.result_schema.replace(/^dd-flow\//, "").replace(/@\d+$/, ""), file: resultPath, data: parsed, projectRoot, runId, runRoot: runHome });
822
922
  if (work.result_schema === "dd-flow/code-work-result@3")
823
923
  validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
824
924
  if (work.result_schema === "dd-flow/code-review-result@1")
@@ -887,13 +987,13 @@ function renderWorkerPrompt(context, work, run, dependencies) {
887
987
  : [`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."];
888
988
  const completionRepair = mergeWork
889
989
  ? ["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."]
890
- : ["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."];
990
+ : ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then use the returned retry_command with your result. Internal invocation IDs are runtime-owned; do not copy or invent them. Do not invent a cause that does not appear in the retained output."];
891
991
  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>", ""] : [];
892
992
  if (packet)
893
993
  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>", "");
894
994
  if (packet)
895
995
  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>", "");
896
- 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");
996
+ return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<cli_context>", `- Work commands use the short ID ${shortWorkId(work.work_id)}; dd-flow binds it to this RUN.`, `- @project = ${run.project_root}`, `- @workspace = ${run.workspace_root}`, `- @run = ${requireRunHome(run)}`, "- @ aliases are accepted only by declared dd-flow path parameters. Shell tools such as cat and rg require ordinary paths relative to cwd or absolute paths.", "- Lifecycle invocation IDs are internal runtime authority and are intentionally omitted from commands.", "</cli_context>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "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");
897
997
  }
898
998
  export function resultSchemaGuidance(work, runId) {
899
999
  const schema = work.result_schema;
@@ -913,11 +1013,17 @@ export function resultSchemaGuidance(work, runId) {
913
1013
  "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."];
914
1014
  }
915
1015
  if (schema === "dd-flow/code-review-result@1") {
916
- 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), "```"];
1016
+ const aspect = { aspect_id: "assigned_aspect_id", verdict: "pass", summary: "Conclusion.", evidence_refs: ["path/to/evidence"] };
1017
+ const clean = { schema_id: schema, verdict: "pass", summary: "Concise evidence-backed conclusion.", aspects: [aspect], findings: [] };
1018
+ const mixed = { ...clean, verdict: "findings", aspects: [aspect, { ...aspect, aspect_id: "another_assigned_aspect_id", verdict: "findings" }], findings: [{ finding_id: "FIND-001", aspect_id: "another_assigned_aspect_id", priority: "p1", problem: "Describe the evidenced defect.", impact: "Describe its material impact.", required_outcome: "Describe the required correction.", evidence_refs: ["path/to/evidence"], obligation_refs: ["assigned_obligation_ref"] }] };
1019
+ return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "Examples below show no findings and mixed aspect outcomes, not completed results. Replace placeholder ids and paths with assigned aspects, obligations and existing evidence. Choose verdict pass, findings or blocked. Keep findings: [] when none exist. Report only material, direct-evidence defects, not taste or cosmetics.", "```json", JSON.stringify(clean, null, 2), "```", "```json", JSON.stringify(mixed, null, 2), "```"];
917
1020
  }
918
1021
  if (schema !== "dd-flow/plan-review-result@1")
919
1022
  return [];
920
- 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), "```"];
1023
+ const aspect = { aspect_id: "assigned_aspect_id", verdict: "pass", summary: "Conclusion for this aspect.", evidence_refs: ["path/to/evidence"], findings: [] };
1024
+ const clean = { schema_id: schema, plan_revision: 1, overall_verdict: "pass", summary: "Concise evidence-backed conclusion.", aspects: [aspect] };
1025
+ const mixed = { ...clean, overall_verdict: "needs_changes", aspects: [aspect, { ...aspect, aspect_id: "another_assigned_aspect_id", verdict: "needs_changes", findings: [{ finding_id: "FIND-001", severity: "high", summary: "Describe the evidenced defect.", evidence_refs: ["path/to/evidence"] }] }] };
1026
+ return [refs, "Examples below show no findings and mixed aspect outcomes, not completed results. Assess each assigned aspect once, replace placeholder ids, plan_revision and paths with packet values and existing evidence. Choose verdict pass, watch, needs_changes or blocked. Keep findings: [] when an aspect has no findings. Do not add fields. Number findings locally as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them.", "```json", JSON.stringify(clean, null, 2), "```", "```json", JSON.stringify(mixed, null, 2), "```"];
921
1027
  }
922
1028
  export function validateCodeReviewResultIdentity(work, value) {
923
1029
  const group = codeReviewGroup(work);
@@ -1006,10 +1112,23 @@ function codeReviewGroup(work) {
1006
1112
  ? { key: value.key, aspect_ids: value.aspect_ids }
1007
1113
  : null;
1008
1114
  }
1009
- function requireWork(context, id) { ensureWorkRegistry(context); const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?`, [id]); if (exact)
1010
- return exact; if (!/^WRK-\d{3,}$/.test(id))
1011
- 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)
1012
- 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]; }
1115
+ function requireWork(context, id) {
1116
+ ensureWorkRegistry(context);
1117
+ const configured = context.env.DD_FLOW_INVOCATION_SCOPE;
1118
+ const scope = configured ? JSON.parse(configured) : undefined;
1119
+ const projectId = scope?.projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [scope.projectRoot])?.id : undefined;
1120
+ const scoped = projectId && scope?.runId ? " AND project_id = ? AND run_id = ?" : "";
1121
+ const params = projectId && scope?.runId ? [projectId, scope.runId] : [];
1122
+ const exact = context.db.get(`SELECT ${workColumns} FROM works WHERE work_id = ?${scoped}`, [id, ...params]);
1123
+ if (exact)
1124
+ return exact;
1125
+ if (!/^WRK-\d{3,}$/.test(id))
1126
+ throw new AppError("not_found", "Work is not registered in the current RUN", 1, { work_id: id });
1127
+ const matches = context.db.all(`SELECT ${workColumns} FROM works WHERE work_id LIKE ?${scoped} ORDER BY work_id`, [`${id}-%`, ...params]);
1128
+ if (matches.length !== 1)
1129
+ throw new AppError(matches.length ? "ambiguous_work_alias" : "not_found", matches.length ? "Short Work alias is ambiguous in the current RUN" : "Work is not registered in the current RUN", 1, { work_id: id, matches: matches.map((work) => work.work_id) });
1130
+ return matches[0];
1131
+ }
1013
1132
  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)
1014
1133
  throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1, { run_id: runId }); return run; }
1015
1134
  function requireRunHome(run) { if (!run.run_root)
@@ -1051,12 +1170,6 @@ function validateItem(value, requireExecutionContext = false) { if (!value || ty
1051
1170
  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 } : {}) }; }
1052
1171
  function allowsEmptyRepairChecks(item) { const repair = item.repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
1053
1172
  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")); }
1054
- function readJson(file) { try {
1055
- return JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
1056
- }
1057
- catch (error) {
1058
- throw new AppError("validation", `Invalid JSON file: ${String(error)}`, 2, { file });
1059
- } }
1060
1173
  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) {
1061
1174
  const obsolete = path.join(run.run_root, "work.json");
1062
1175
  if (fs.existsSync(obsolete))