@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.103

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 (46) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/hook-ingress.js +5 -10
  3. package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
  4. package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
  5. package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
  6. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  7. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  8. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  9. package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
  10. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
  12. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
  13. package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
  14. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  15. package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
  20. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  21. package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
  22. package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
  23. package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
  24. package/dist/harness-runtime/lib/process-json.mjs +3 -2
  25. package/dist/services/code-checks.js +53 -16
  26. package/dist/services/controller-fanout.js +37 -6
  27. package/dist/services/eval-snapshots.js +148 -38
  28. package/dist/services/external-work-launch.js +60 -5
  29. package/dist/services/prompts.js +2 -0
  30. package/dist/services/review-copy.js +136 -0
  31. package/dist/services/run-control-worker.js +16 -11
  32. package/dist/services/run-controller-adapter.js +9 -0
  33. package/dist/services/runs.js +3 -0
  34. package/dist/services/runtime-scope-stop.js +66 -6
  35. package/dist/services/vnext-code-review.js +6 -2
  36. package/dist/services/vnext-code.js +1 -0
  37. package/dist/services/vnext-fanout.js +9 -1
  38. package/dist/services/vnext-merge.js +8 -4
  39. package/dist/services/vnext-plan-review.js +4 -15
  40. package/dist/services/vnext-plan.js +4 -0
  41. package/dist/services/vnext-protocolize.js +4 -2
  42. package/dist/services/vnext-specify.js +7 -0
  43. package/dist/services/work-registry.js +208 -22
  44. package/dist/services/workspace-files.js +83 -0
  45. package/dist/services/worktrees.js +3 -1
  46. package/package.json +1 -1
@@ -80,40 +80,45 @@ async function launchControlWorker(context, input) {
80
80
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
81
81
  const log = path.join(directory, "control.log");
82
82
  const output = fs.openSync(log, "a", 0o600);
83
+ let launched = null;
83
84
  try {
84
85
  const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome, DD_FLOW_RESOURCE_HOME: home, DD_FLOW_ENGINE_MODE: "1", DD_FLOW_CONTROL_TOKEN: token };
85
86
  delete env.DD_FLOW_CONTROLLER_TOKEN;
86
87
  delete env.DD_FLOW_SCOPE_CONTROL_TOKEN;
87
88
  const child = spawn(process.execPath, [entry, "run", "control", "serve", "--run", input.runId, "--project-root", project.root, "--control-id", input.controlId, "--json"], { cwd: run.workspace_root, detached: true, stdio: ["ignore", output, output], env });
88
89
  await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
89
- context.db.run("UPDATE run_control_operations SET receipt_json = json_set(receipt_json, '$.child', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify({ pid: child.pid, pid_started_at: child.pid ? processStartedAt(child.pid) : null }), context.now(), launchOperationId(input.controlId, token)]);
90
+ launched = { pid: child.pid, pid_started_at: child.pid ? processStartedAt(child.pid) : null };
91
+ context.db.run("UPDATE run_control_operations SET receipt_json = json_set(receipt_json, '$.child', json(?)), updated_at = ? WHERE operation_id = ?", [JSON.stringify(launched), context.now(), launchOperationId(input.controlId, token)]);
90
92
  child.unref();
91
93
  }
92
94
  finally {
93
95
  fs.closeSync(output);
94
96
  }
95
97
  // The detached worker is the durable owner of the stop. Do not let the
96
- // caller tear down its RUN/home before that owner has either registered
97
- // its managed-process lease or recorded a startup failure.
98
- await waitForControlWorkerAdmission(context, input.controlId, token);
98
+ // caller treat an unadmitted but physically live child as a failed owner.
99
+ // Its durable intent/launch identity retains the RUN while it registers.
100
+ await waitForControlWorkerAdmission(context, input.controlId, token, 5_000, launched);
99
101
  }
100
102
  catch (error) {
101
103
  context.db.run("UPDATE run_control_workers SET status = 'failed', error_json = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [JSON.stringify(failure(error)), context.now(), input.controlId, token]);
102
104
  // The durable intent remains accepted even if its worker could not start.
103
105
  }
104
106
  }
105
- async function waitForControlWorkerAdmission(context, controlId, ownerToken, timeoutMs = 5_000) {
107
+ export async function waitForControlWorkerAdmission(context, controlId, ownerToken, timeoutMs = 5_000, launched = null) {
106
108
  const deadline = Date.now() + timeoutMs;
107
109
  for (;;) {
108
110
  const worker = context.db.get("SELECT status, process_id, owner_token FROM run_control_workers WHERE control_id = ?", [controlId]);
109
111
  if (!worker || worker.owner_token !== ownerToken)
110
- return;
111
- // A running worker without a resource lease is still in the vulnerable
112
- // registration gap; keep the parent alive until that gap closes.
112
+ return "admitted";
113
+ // A running worker without a resource lease is still in the registration
114
+ // gap; only a physically live retained child can remain pending at expiry.
113
115
  if (worker.process_id || ["failed", "completed", "superseded"].includes(worker.status))
114
- return;
115
- if (Date.now() >= deadline)
116
- throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease", 1, { control_id: controlId, timeout_ms: timeoutMs });
116
+ return "admitted";
117
+ if (Date.now() >= deadline) {
118
+ if (launched?.pid_started_at && processIsAlive(launched))
119
+ return "pending";
120
+ throw new AppError("run_control_worker_start_timeout", "Control worker did not publish its managed-process lease and is not alive", 1, { control_id: controlId, timeout_ms: timeoutMs });
121
+ }
117
122
  await delay(25);
118
123
  }
119
124
  }
@@ -119,6 +119,15 @@ async function recoverCompletedOperation(context, manifest, session, executable,
119
119
  }
120
120
  const target = args.includes("--session-id") ? args[args.indexOf("--session-id") + 1] : null;
121
121
  const result = observed.result;
122
+ const primary = observed.primary_error;
123
+ if (observed.operation_id === operationId && observed.operation === expectedMethod(args)
124
+ && observed.state !== "completed" && (!target || observed.session_id === target)
125
+ && primary && typeof primary === "object" && !Array.isArray(primary)) {
126
+ const failure = primary;
127
+ if (typeof failure.code === "string" && typeof failure.message === "string") {
128
+ throw new AppError(failure.code, failure.message, 1, { ...(failure.details && typeof failure.details === "object" && !Array.isArray(failure.details) ? failure.details : {}), operation_id: operationId, effect: "unknown" });
129
+ }
130
+ }
122
131
  if (observed.operation_id !== operationId || observed.operation !== expectedMethod(args) || observed.state !== "completed"
123
132
  || !result || typeof result !== "object" || Array.isArray(result)
124
133
  || target && (observed.session_id !== target || adapterSessionId(result) !== target))
@@ -576,6 +576,7 @@ export function attachFlowRunStage(context, input) {
576
576
  stage,
577
577
  dir,
578
578
  status,
579
+ ...(input.inputSha256 ? { input_sha256: input.inputSha256 } : {}),
579
580
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : existing?.data_schema_id ? { data_schema_id: existing.data_schema_id } : {}),
580
581
  updated_at: now
581
582
  };
@@ -638,11 +639,13 @@ export function prepareFlowRunStageCompletion(context, input) {
638
639
  export function completeFlowRunStage(context, input) {
639
640
  const { project, run, index, stage, status, existing } = prepareFlowRunStageCompletion(context, input);
640
641
  const now = context.now();
642
+ const dataPath = input.data ? path.join(runArtifactRoot(run), existing.dir, input.data) : null;
641
643
  const stageRun = {
642
644
  ...existing,
643
645
  status,
644
646
  ...(input.stageReport ? { stage_report: input.stageReport } : {}),
645
647
  ...(input.data ? { data: input.data } : {}),
648
+ ...(dataPath && fs.existsSync(dataPath) ? { data_sha256: crypto.createHash("sha256").update(fs.readFileSync(dataPath)).digest("hex") } : {}),
646
649
  ...(input.dataSchemaId ? { data_schema_id: input.dataSchemaId } : {}),
647
650
  ...(input.report ? { report: input.report } : {}),
648
651
  ...(input.usage ? { usage: input.usage } : {}),
@@ -42,6 +42,10 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
42
42
  const receipt = await runHarnessAdapter({ executable: metadata.adapter_executable,
43
43
  args: prior ? ["daemon", "operation", "--operation-id", operationId, "--state-dir", metadata.state_dir, "--json"] : ["daemon", "stop", "--cancel-tree", "--state-dir", metadata.state_dir, "--json"],
44
44
  env, operationId: prior ? `${operationId}:observe` : operationId, timeoutMs: 30_000 });
45
+ if (prior && receipt.state === "failed" && receipt.operation_id === operationId && receipt.daemon_id === state.daemon_id) {
46
+ const failure = receipt.error;
47
+ throw new AppError(failure?.code ?? "runtime_scope_control_failed", failure?.message ?? "Native close operation failed", 1, failure?.details);
48
+ }
45
49
  if (prior && (receipt.operation_id !== operationId || receipt.daemon_id !== state.daemon_id || receipt.operation !== "daemon.stop" || receipt.state !== "completed"))
46
50
  throw new AppError("runtime_scope_outcome_unknown", "Native close operation has not completed", 1);
47
51
  const result = (prior ? receipt.result : receipt);
@@ -52,10 +56,17 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
52
56
  if (result?.stopped !== true || result.clean !== true || after.daemon_id !== state.daemon_id || after.shutdown_state !== "clean" || after.active_tree !== false || processTreeIsAlive(finalOwner))
53
57
  throw new AppError("runtime_scope_not_settled", "Native close has not proved whole-tree settlement", 1);
54
58
  const processes = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, finalOwner, after) && item.operation_id?.startsWith(`${owner.operation_id}:`) && !processTreeIsAlive(item)).map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) }));
55
- return { process_id: record.id, state_dir: metadata.state_dir, settled: true, operation_id: operationId, owner_operation_id: owner.operation_id, processes };
59
+ return { process_id: record.id, state_dir: metadata.state_dir, settled: true, native_settled: true, physical_stopped: true, operation_id: operationId, owner_operation_id: owner.operation_id, processes };
56
60
  }
57
61
  catch (error) {
58
- return { process_id: record.id, settled: false, reason: error instanceof AppError ? error.code : "runtime_scope_control_failed", message: String(error) };
62
+ const failure = error;
63
+ const native = failure.details?.cause && typeof failure.details.cause === "object" ? failure.details.cause : null;
64
+ const physical = await stopVerifiedNativeProcesses(context, scopeId, record);
65
+ const stateDir = JSON.parse(record.metadata_json).state_dir;
66
+ return { process_id: record.id, ...(stateDir ? { state_dir: stateDir } : {}), settled: false, native_settled: false, physical_stopped: physical.stopped,
67
+ reason: native?.code ?? failure.code ?? "runtime_scope_control_failed", message: native?.message ?? failure.message ?? String(error),
68
+ ...(native ? { cause: { code: native.code ?? null, message: native.message ?? null, details: native.details ?? null } } : {}), ...(physical.error ? { cleanup_error: physical.error } : {}),
69
+ ...(physical.provider ? { processes: [{ process_id: physical.provider.id, native_settled: false, binding: managedProcessBinding(physical.provider, resourceHome(context)) }] } : {}) };
59
70
  }
60
71
  }));
61
72
  const covered = new Set(nodes.map(node => node.process_id));
@@ -90,10 +101,59 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
90
101
  const binding = JSON.stringify(managedProcessBinding(record, resourceHome(context)));
91
102
  const parent = nodes.find(node => node.settled && node.state_dir === metadata.state_dir && "processes" in node && node.processes?.some(item => item.process_id === record.id && item.native_settled && JSON.stringify(item.binding) === binding));
92
103
  if (parent && !processTreeIsAlive(record)) {
93
- nodes.push({ process_id: record.id, state_dir: metadata.state_dir, settled: true, operation_id: parent.operation_id, owner_operation_id: parent.owner_operation_id });
104
+ nodes.push({ process_id: record.id, state_dir: metadata.state_dir, settled: true, physical_stopped: true, operation_id: parent.operation_id, owner_operation_id: parent.owner_operation_id });
105
+ }
106
+ else {
107
+ const retired = nodes.find(node => node.physical_stopped === true && node.state_dir === metadata.state_dir && node.processes?.some(item => item.process_id === record.id && JSON.stringify(item.binding) === binding));
108
+ if (retired && !processTreeIsAlive(record))
109
+ nodes.push({ process_id: record.id, ...(metadata.state_dir ? { state_dir: metadata.state_dir } : {}), settled: false, native_settled: false, physical_stopped: true, ...(retired.reason ? { reason: retired.reason } : {}) });
110
+ else
111
+ nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: "Resource needs its own settlement proof" });
94
112
  }
95
- else
96
- nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: "Resource needs its own settlement proof" });
97
113
  }
98
- return { scope_id: scopeId, fence, settled: nodes.every(node => node.settled), nodes };
114
+ return { scope_id: scopeId, fence, settled: nodes.every(node => node.settled),
115
+ physical_stopped: nodes.every(node => node.physical_stopped === true || node.settled && node.physical_stopped !== null)
116
+ ? true : nodes.some(node => node.physical_stopped === null) ? null : false, nodes };
117
+ }
118
+ async function stopVerifiedNativeProcesses(context, scopeId, daemon) {
119
+ const metadata = JSON.parse(daemon.metadata_json);
120
+ if (!metadata.state_dir || !path.isAbsolute(metadata.state_dir))
121
+ return { stopped: null };
122
+ let state;
123
+ try {
124
+ state = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
125
+ }
126
+ catch {
127
+ return { stopped: null };
128
+ }
129
+ const owner = state.config?.runtime_owner;
130
+ const current = managedProcessStatus(context).find(item => item.id === daemon.id);
131
+ if (!owner || !current || current.lease_token !== daemon.lease_token || current.pid !== daemon.pid || current.pid_started_at !== daemon.pid_started_at
132
+ || !daemon.pid || !daemon.pid_started_at || owner.budget?.scope_id !== scopeId || owner.owner_id !== daemon.owner_id
133
+ || owner.state_dir !== metadata.state_dir || owner.role !== metadata.role || owner.adapter_executable !== metadata.adapter_executable
134
+ || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context)
135
+ || state.resource_process?.id !== daemon.id || state.resource_process.lease_token !== daemon.lease_token
136
+ || (state.pid !== daemon.pid && !(state.pid == null && state.shutdown_state === "starting")))
137
+ return { stopped: null };
138
+ const registered = managedProcessStatus(context);
139
+ const providers = registered.filter(item => managedProviderBelongsToDaemon(item, daemon, state));
140
+ const unpublished = !state.provider_process && !state.provider_pid ? registered.filter(item => {
141
+ const child = JSON.parse(item.metadata_json);
142
+ return item.kind === daemon.kind.replace(/-daemon$/, "-provider") && item.owner_id === daemon.owner_id && item.project_id === daemon.project_id && item.run_id === daemon.run_id && item.work_id === daemon.work_id
143
+ && item.operation_id?.startsWith(`${owner.operation_id}:`) && item.pid && item.pid_started_at && child.owner_pid === daemon.pid && child.owner_pid_started_at === daemon.pid_started_at
144
+ && child.state_dir === metadata.state_dir && child.role === metadata.role && child.adapter_executable === metadata.adapter_executable && child.dd_flow_home === metadata.dd_flow_home
145
+ && child.dispatch_barrier === "run-generation@1" && child.budget?.scope_id === scopeId;
146
+ }) : [];
147
+ const provider = providers.length === 1 ? providers[0] : unpublished.length === 1 ? unpublished[0] : undefined;
148
+ if (providers.length > 1 || unpublished.length > 1 || state.provider_pid && !provider || !provider && state.shutdown_state !== "starting")
149
+ return { stopped: null };
150
+ try {
151
+ if (provider)
152
+ await stopManagedProcess(context, { id: provider.id, leaseToken: provider.lease_token, force: false, graceMs: 500, reason: `scope-stop:${scopeId}` });
153
+ await stopManagedProcess(context, { id: daemon.id, leaseToken: daemon.lease_token, force: false, graceMs: 500, reason: `scope-stop:${scopeId}` });
154
+ return { stopped: !provider ? null : !processTreeIsAlive(daemon) && !processTreeIsAlive(provider), ...(provider ? { provider } : {}) };
155
+ }
156
+ catch (cleanup) {
157
+ return { stopped: false, ...(provider ? { provider } : {}), error: { code: cleanup.code ?? "physical_stop_failed", message: String(cleanup) } };
158
+ }
99
159
  }
@@ -75,10 +75,14 @@ export function startVnextCodeReview(context, input, prepared = prepareVnextCode
75
75
  return { ok: true, resumed: true, run_id: run.id, stage, stage_status: prepared.prior, id: binding.work_session_id, prompt_path: existingPrompt, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), ...(orchestration ? { orchestration } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
76
76
  }
77
77
  const { mode, cycle, groups, batch, items, template, checks, codeReportSha256 } = prepared;
78
+ const aspectMapChecksums = mode.mode === "off" ? [] : fs.readdirSync(path.join(run.run_root, "03-plan"), { withFileTypes: true })
79
+ .filter(entry => entry.isDirectory() && fs.existsSync(path.join(run.run_root, "03-plan", entry.name, "aspect-map.json")))
80
+ .map(entry => ({ path: `03-plan/${entry.name}/aspect-map.json`, sha256: sha256File(path.join(run.run_root, "03-plan", entry.name, "aspect-map.json")) }))
81
+ .sort((a, b) => a.path.localeCompare(b.path));
78
82
  freezeFlowRunReviewMode(context, { projectRoot, runId: run.id, review: "code", mode: mode.mode, source: mode.source, reason: mode.reason });
79
83
  const promptPath = path.join(root, "stage-prompt.md");
80
84
  const reviewContextPath = path.join(root, "review-context.json");
81
- fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, ...(mode.mode !== "off" ? { read_only_input: { parent_work_id: rootWork.work_id } } : {}), code_report_sha256: codeReportSha256 }, null, 2)}\n`);
85
+ fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, ...(mode.mode !== "off" ? { read_only_input: { parent_work_id: rootWork.work_id, version: 3 } } : {}), code_report_sha256: codeReportSha256, review_batch_sha256: sha256Value(`${JSON.stringify(batch, null, 2)}\n`), aspect_map_checksums: aspectMapChecksums }, null, 2)}\n`);
82
86
  if (mode.mode === "off") {
83
87
  fs.writeFileSync(path.join(root, "decision.json"), `${JSON.stringify(disabledReviewDecision(), null, 2)}\n`);
84
88
  const prompt = `<stage_identity>\n- RUN: ${run.id}\n- stage: code-review\n- mode: off\n</stage_identity>\n\nCODE-REVIEW is disabled by the frozen RUN configuration. Finish with: ${finishCommand(context, run.id, projectRoot, path.join(root, "decision.json"))}\n`;
@@ -96,7 +100,7 @@ export function startVnextCodeReview(context, input, prepared = prepareVnextCode
96
100
  const binding = bindStageCoordinatorWork(context, { workId: rootWork.work_id, hookEventId: input.hookEventId, stage, promptPath, resultPath: path.join(root, "stage-report.json"), ...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {}) });
97
101
  const registered = addWorkBatch(context, { parentWorkId: rootWork.work_id, value: batch, prepared: items, stage, cycle });
98
102
  const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true, scope: stageGraphScope(context, project.id, run.id, stage) });
99
- attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@1" });
103
+ attachFlowRunStage(context, { projectRoot, runId: run.id, stage, dir: stageDir, status: "running", dataSchemaId: "dd-flow/stage-report@1", inputSha256: sha256Value(JSON.stringify({ batch: sha256File(batchFile), aspect_maps: aspectMapChecksums })) });
100
104
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "code_review_started", work_id: rootWork.work_id, mode: mode.mode, mode_source: mode.source, groups, registered });
101
105
  return { ok: true, run_id: run.id, stage, mode: mode.mode, mode_source: mode.source, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, review: { groups, works: registered }, next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
102
106
  }
@@ -502,6 +502,7 @@ function coordinatorPrompt(context, input) {
502
502
  `- RUN: ${input.run.id}`,
503
503
  `- root Work: ${input.rootWork.work_id}`,
504
504
  "- stage: code",
505
+ "Before any project edit, change into the immutable write workspace shown below with cd --. Write only that workspace and the named CODE verification artifacts under the stage workspace; never another RUN/native home or Git/worktree control data.",
505
506
  "</stage_identity>",
506
507
  "",
507
508
  "<trusted_runtime_context>",
@@ -124,6 +124,10 @@ export async function reconcileVnextFanout(context, input) {
124
124
  const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
125
125
  const issues = [], reconciled = [];
126
126
  for (const child of value.children) {
127
+ if (child.provenance === "parent_mismatch" || child.parent_session_id !== undefined && child.parent_session_id !== value.parent_session_id) {
128
+ issues.push({ code: "native_child_parent_mismatch", session_id: child.session_id, observed_parent_session_id: child.parent_session_id ?? null, expected_parent_session_id: value.parent_session_id, outcome: child.status });
129
+ continue;
130
+ }
127
131
  if (child.status === "running" || child.status === "unknown")
128
132
  continue;
129
133
  const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
@@ -182,7 +186,7 @@ export async function reconcileVnextFanout(context, input) {
182
186
  export function prepareNativeObservations(observations) {
183
187
  const value = observations;
184
188
  if (!value || typeof value.harness_id !== "string" || !value.harness_id.trim() || typeof value.parent_session_id !== "string" || !value.parent_session_id.trim() || !Array.isArray(value.children)
185
- || value.children.some(child => !child || typeof child.session_id !== "string" || !child.session_id.trim() || typeof child.status !== "string" || !["running", "unknown", "completed", "failed", "cancelled", "settled_by_root"].includes(child.status)))
189
+ || value.children.some(child => !child || typeof child.session_id !== "string" || !child.session_id.trim() || typeof child.status !== "string" || !["running", "unknown", "completed", "failed", "cancelled", "settled_by_root"].includes(child.status) || child.parent_session_id !== undefined && typeof child.parent_session_id !== "string" || child.provenance !== undefined && !["root_child", "parent_mismatch"].includes(String(child.provenance))))
186
190
  throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
187
191
  if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
188
192
  throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
@@ -194,6 +198,10 @@ export function observeVnextNativeChildren(context, input) {
194
198
  const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
195
199
  const issues = [], history = [], pending = [];
196
200
  for (const child of value.children) {
201
+ if (child.provenance === "parent_mismatch" || child.parent_session_id !== undefined && child.parent_session_id !== value.parent_session_id) {
202
+ issues.push({ code: "native_child_parent_mismatch", session_id: child.session_id, observed_parent_session_id: child.parent_session_id ?? null, expected_parent_session_id: value.parent_session_id, outcome: child.status, detected_at_stage: input.stage });
203
+ continue;
204
+ }
197
205
  const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
198
206
  const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id, s.current_stage
199
207
  FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id
@@ -16,7 +16,7 @@ import { runtimeCommand, checkRetryArgs, recoveryEvidenceInput } from "./runtime
16
16
  import { writeStageReport } from "./stage-report-renderer.js";
17
17
  import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
18
18
  import { addVnextCodeRepair } from "./vnext-code.js";
19
- import { checkReceiptEvidenceValid, checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
19
+ import { checkReceiptEvidenceValid, checkReceipts, codeCheckProfileHash, effectiveCheckDeclarations, runCodeChecks, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
20
20
  import { applyExternalStageContext, prepareExternalStageContext } from "./stage-context.js";
21
21
  import { ensureLaneWorkspace, prepareLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
22
22
  import { assertRunMutationAllowed } from "./run-recovery.js";
@@ -67,7 +67,7 @@ export function prepareVnextMergeRequest(context, input) {
67
67
  const freezeFile = path.join(freezeRoot, "source-freeze.json");
68
68
  const gateFile = path.join(freezeRoot, "merge-gate.json");
69
69
  fs.mkdirSync(freezeRoot, { recursive: true });
70
- freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, acceptanceRefs, profileHash: readCodeCheckProfile(targetWorkspace).hash, now: context.now() });
70
+ freezeMergeGate(gateFile, { runId: run.id, protocols, checks: effective, acceptanceRefs, profileHash: codeCheckProfileHash(targetWorkspace, effective), now: context.now() });
71
71
  let sourceCommit;
72
72
  let sourcePaths;
73
73
  if (fs.existsSync(freezeFile)) {
@@ -105,7 +105,7 @@ export function prepareVnextMergeRequest(context, input) {
105
105
  return requestView(context, raced);
106
106
  const child = createChildWork(context, { parentWorkId: root.work_id, stage: "merge", slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
107
107
  const requestId = nextMergeRequestId(context);
108
- context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, replacement_of_merge_request_id, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, replacementOf(run), now, now]);
108
+ context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, replacement_of_merge_request_id, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, codeCheckProfileHash(targetWorkspace, effective), replacementOf(run), now, now]);
109
109
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "merge_request_queued", merge_request_id: requestId, work_id: child.work_id, route, source_commit: sourceCommit, target_branch: targetBranch });
110
110
  return requestView(context, requireRequest(context, requestId));
111
111
  });
@@ -532,7 +532,11 @@ function commitPendingSource(cwd, runId, ignored) { const paths = meaningfulStat
532
532
  function commitIntegration(cwd, runId, ignored) { const mergeHead = fs.existsSync(path.join(gitDirectory(cwd), "MERGE_HEAD")); const paths = meaningfulStatus(cwd, ignored); if (!mergeHead && !paths.length)
533
533
  return; if (paths.length)
534
534
  git(cwd, ["add", "-A", "--", ...paths]); git(cwd, ["commit", "-m", `merge(${runId.toLowerCase()}): integrate accepted source`]); }
535
- function ignoredGitPaths(context, projectId) { return [readProjectConfig(context, projectId).dashboard.markdown_path.replaceAll("\\", "/")]; }
535
+ function ignoredGitPaths(context, projectId) {
536
+ // Shared workspace classification omits only the exact untracked qualified
537
+ // hook. Tracked or mixed user hooks remain ordinary product changes.
538
+ return [readProjectConfig(context, projectId).dashboard.markdown_path.replaceAll("\\", "/")];
539
+ }
536
540
  function meaningfulStatus(cwd, ignored) { return workspaceChangedPaths(cwd).filter((item) => !ignored.includes(item)); }
537
541
  function unmerged(cwd) { const value = gitValue(cwd, ["diff", "--name-only", "--diff-filter=U"], true); return value.split(/\r?\n/).filter(Boolean); }
538
542
  function gitDirectory(cwd) { const value = gitValue(cwd, ["rev-parse", "--git-dir"]); return path.resolve(cwd, value); }
@@ -146,7 +146,7 @@ export function startVnextPlanReview(context, input, prepared = prepareVnextPlan
146
146
  const workSessionId = String(binding.work_session_id);
147
147
  const sessionId = String(binding.session_id);
148
148
  const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: workId, dispatch: "plan-review", capacity_required: true, scope: stageGraphScope(context, project.id, run.id, stage) });
149
- writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", read_only_input: { parent_work_id: workId }, system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: prepared.revision, plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
149
+ writeJson(path.join(root, "work-context.json"), { schema_id: "dd-flow/plan-review-context@1", read_only_input: { parent_work_id: workId, version: 3 }, system: { run_id: run.id, work_id: workId, stage, requested_mode: requested, effective_mode: effective, plan_revision: prepared.revision, plan_checksum: planChecksum, batch_checksum: batchChecksum }, groups, workspace: { project_root: projectRoot, workspace_root: run.workspace_root, stage_root: root, plan_root: planRoot } });
150
150
  refreshRunWorkProjection(context, project.id, run.id);
151
151
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "work_session_started", stage, work_id: workId, id: workSessionId, session_id: sessionId });
152
152
  return { ok: true, run_id: run.id, stage, outcome: "review_required", work_id: workId, id: workSessionId, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), orchestration, plan_review: { requested_mode: requested, effective_mode: effective, policy_source: policySource(run), groups }, next: { dispatch_command: dispatchCommand(context, run.id, projectRoot), finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
@@ -242,11 +242,11 @@ function finishPlanReviewOwned(context, input) {
242
242
  if (decision.outcome === "blocked") {
243
243
  throw new AppError("stage_pause_required", "A user or decision blocker must pause the current PLAN-REVIEW Work with `dd-flow stage pause`; do not finish the stage as blocked", 2, { run_id: run.id, stage, work_id: parent.work_id });
244
244
  }
245
+ if (children.some((child) => !["completed", "failed", "cancelled"].includes(child.status)))
246
+ throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
245
247
  if (["failed", "cancelled"].includes(decision.outcome)) {
246
- return withStageSettlement(context, () => { preserveDecisionReceipt(root, decision, input.decisionText); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, children, contextFile, decision }); });
248
+ return withStageSettlement(context, () => { preserveDecisionReceipt(root, decision, input.decisionText); return finishTerminalReview(context, { projectRoot, projectId: project.id, run, root, planRoot, batch, parentWorkId: parent.work_id, contextFile, decision }); });
247
249
  }
248
- if (children.some((child) => child.status === "created" || child.status === "running"))
249
- throw new AppError("worker_jobs_incomplete", "PLAN-REVIEW finish requires all reviewer Works to settle", 1, { works: children.map((child) => ({ work_id: child.work_id, status: child.status })) });
250
250
  const latestChildren = latestReviewChildren(children);
251
251
  const evidenceFailures = reviewerEvidenceFailures(context, { projectId: project.id, parentWorkId: parent.work_id, children: latestChildren, groups, planRevision: reviewedPlanRevision, workspaceRoot: run.workspace_root, runHome: home, runId: run.id });
252
252
  if (evidenceFailures.length)
@@ -508,10 +508,6 @@ function settleReviewParent(context, input) {
508
508
  function finishTerminalReview(context, input) {
509
509
  const now = context.now();
510
510
  const childStatus = input.decision.outcome === "cancelled" ? "cancelled" : "failed";
511
- // A terminal decision is structured cancellation of the review subtree. It
512
- // must settle semantic reviewers before its parent Work can settle; otherwise
513
- // the RUN contains abandoned active Work/Session links.
514
- settleReviewChildren(context, { projectId: input.projectId, runId: input.run.id, parentWorkId: input.parentWorkId, status: childStatus, reason: `PLAN-REVIEW terminal decision: ${input.decision.summary}`, now });
515
511
  settleReviewParent(context, { projectRoot: input.projectRoot, projectId: input.projectId, runId: input.run.id, parentWorkId: input.parentWorkId, status: childStatus, result: input.decision.summary, reason: `plan_review_${input.decision.outcome}`, resultPath: path.join(input.root, "stage-report.json") });
516
512
  const rootWork = rootWorkId(context, input.projectId, input.run.id);
517
513
  context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [childStatus, input.decision.summary, now, now, rootWork]);
@@ -526,13 +522,6 @@ function finishTerminalReview(context, input) {
526
522
  appendFlowRunTimelineEvent(context, input.projectId, input.run.id, { type: "plan_review_terminal", outcome: input.decision.outcome, work_id: input.parentWorkId });
527
523
  return { ok: true, run_id: input.run.id, stage, outcome: input.decision.outcome, next_action: "resolve_plan_review_blocker" };
528
524
  }
529
- function settleReviewChildren(context, input) {
530
- const active = context.db.all("SELECT work_id FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id = ? AND status IN ('created', 'running')", [input.projectId, input.runId, input.parentWorkId]);
531
- for (const child of active) {
532
- context.db.run("UPDATE works SET status = ?, result = ?, completed_at = ?, updated_at = ? WHERE work_id = ?", [input.status, input.reason, input.now, input.now, child.work_id]);
533
- closeRunningWorkSession(context, child.work_id, input.status, input.now);
534
- }
535
- }
536
525
  function closeRunningWorkSession(context, workId, status, now) {
537
526
  context.db.run("UPDATE work_sessions SET status = ?, completed_at = ?, updated_at = ? WHERE work_id = ? AND status = 'running'", [status, now, now, workId]);
538
527
  }
@@ -168,6 +168,10 @@ function finishVnextPlanOwned(context, input) {
168
168
  refreshRunWorkProjection(context, project.id, run.id);
169
169
  const reviewCommand = managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage plan-review --project-root ${JSON.stringify(projectRoot)} --json`);
170
170
  const report = { schema_id: "dd-flow/stage-report@1", run_id: run.id, stage: "plan", generated_at: now, verdict: "done", semantic: { result: `Accepted ${protocols.length} executable PLAN artifact${protocols.length === 1 ? "" : "s"}.`, acceptance: protocols, changed_files: [...planFiles.map((file) => path.relative(projectRoot, file)), ...mapFiles.map((file) => runRef(run.id, home, file)), runRef(run.id, home, batch)], checks: ["protocol-plan schema", "aspect-map schema", "cross-artifact references", "generated CODE batch"], evidence: [runRef(run.id, home, path.join(root, "stage-report.json"))], next_action: "start_plan_review", plans: planFiles.map((file) => path.relative(projectRoot, file)), aspect_maps: mapFiles.map((file) => runRef(run.id, home, file)), code_work_batch: runRef(run.id, home, batch), batch_checksum: batchChecksum }, mechanical: { started_at: stageStartedAt(home, now), finished_at: now, wall_clock_ms: Math.max(0, Date.parse(now) - Date.parse(stageStartedAt(home, now))), git: gitFacts(run.workspace_root), session_stats_command: `${flowCommand(context)} stat run sessions ls --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, usage_stats_command: `${flowCommand(context)} stat usage --run ${run.id} --project-root ${JSON.stringify(projectRoot)} --json`, next_command: reviewCommand }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html", summary: "stage-report.md" }, validation: { permission_scope: "known_targets_only", memory_bank_scope: "changed_files_and_links_only", status: "passed" } };
171
+ Object.assign(report.semantic, {
172
+ plan_checksums: planFiles.map((file) => ({ path: path.relative(run.workspace_root, file).split(path.sep).join("/"), sha256: checksum(file) })),
173
+ aspect_map_checksums: mapFiles.map((file) => ({ path: path.relative(home, file).split(path.sep).join("/"), sha256: checksum(file) }))
174
+ });
171
175
  const reportJson = writeStageReport(root, report).json;
172
176
  validateSchema({ schemaName: "stage-report", file: reportJson, projectRoot, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
173
177
  completeFlowRunStage(context, { projectRoot, runId: run.id, stage: "plan", status: "done", data: "stage-report.json", dataSchemaId: "dd-flow/stage-report@1", report: "stage-report.md", stageReport: "stage-report.html" });
@@ -76,10 +76,12 @@ export function prepareVnextProtocolize(context, input, prepared = prepareVnextP
76
76
  writeJson(resultPath, resultTemplate(obligations));
77
77
  const pauseCommand = stagePauseCommand(context, { runId: run.id, stage, workId: work.work_id, projectRoot });
78
78
  const prompt = renderPrompt({ projectRoot, runId: run.id, workId: work.work_id, specifyPath, obligations, resultPath, handoff: input.handoff, catalog: projectCatalog, policy, template, pauseCommand, pauseCommandTemplate: stagePauseCommandTemplate(pauseCommand), finishCommand: managedLifecycleCommand(context, `${flowCommand(context)} stage finish ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --result-file ${JSON.stringify(resultPath)} --json`) });
79
- fs.writeFileSync(promptPath, prompt);
79
+ const workspaceContract = ["<workspace_contract>", `- stage artifact directory: ${stageRoot}`, `- project workspace: ${policy.worktree_path ?? projectRoot}`, `- project root (read-only identity): ${projectRoot}`, `Before editing the result, change into the project workspace: cd -- ${JSON.stringify(policy.worktree_path ?? projectRoot)}. Write only the named result file in the stage artifact directory; do not write project files, another RUN, another native home, branches, worktrees or Git commits.`, "The CLI performs all protocol document materialization after the result is accepted.", "</workspace_contract>"].join("\n");
80
+ const promptWithWorkspaceContract = prompt.replace("</work_context>", `</work_context>\n\n${workspaceContract}`);
81
+ fs.writeFileSync(promptPath, promptWithWorkspaceContract);
80
82
  // Packet publication does not itself bind a Session or open the Stage.
81
83
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_prepared", work_id: work.work_id, stage, handoff_mode: input.handoff.stage_handoff.effective });
82
- const directive = input.handoff.stage_handoff.effective === "same_session" ? { kind: "continue_current_session", stage, prompt_path: promptPath, worker_prompt_markdown: prompt } : { kind: "await_new_session", stage, work_id: work.work_id, prompt_path: promptPath, resume_command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`) };
84
+ const directive = input.handoff.stage_handoff.effective === "same_session" ? { kind: "continue_current_session", stage, prompt_path: promptPath, worker_prompt_markdown: promptWithWorkspaceContract } : { kind: "await_new_session", stage, work_id: work.work_id, prompt_path: promptPath, resume_command: managedLifecycleCommand(context, `${flowCommand(context)} stage start ${run.id} --stage protocolize --project-root ${JSON.stringify(projectRoot)} --json`) };
83
85
  return { directive, prompt_path: promptPath };
84
86
  }
85
87
  export function prepareVnextProtocolizeStart(context, input) {
@@ -383,6 +383,13 @@ function renderPrompt(input) {
383
383
  "```",
384
384
  "</trusted_runtime_context>",
385
385
  "",
386
+ "<workspace_contract>",
387
+ `- authoritative SPECIFY artifact directory: ${input.stageRoot}`,
388
+ `- result file to write: ${input.resultPath}`,
389
+ `- project root is read-only grounding for this Work: ${input.projectRoot}`,
390
+ "Write only the named result and stage-local artifacts. Do not switch cwd, create a worktree, or write another RUN/native home.",
391
+ "</workspace_contract>",
392
+ "",
386
393
  "<eval_input_boundary>",
387
394
  "Use only this prompt, the current project root, this stage workspace and project files reached through the supplied grounding. Do not read or search another RUN, transcript, prior result, reviewer material or other project under ~/.dd-flow or ~/.codex.",
388
395
  "If stage finish rejects the result, correct this same JSON result only from the returned validation error. Do not seek another schema or example outside this stage workspace.",