@deksden-com/dd-flow-cli 0.9.0-beta.9 → 0.9.0-beta.90

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 (143) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +304 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +115 -0
  8. package/dist/cli/run-cli.js +1409 -382
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  11. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  12. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  13. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  14. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  15. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  16. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  17. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  18. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  19. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  21. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  22. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  23. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  24. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  25. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  26. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  27. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  28. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  29. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  30. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  32. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  33. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  34. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  36. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  37. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  38. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  39. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  40. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  41. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  42. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  43. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  44. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  45. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  46. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  47. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  48. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  49. package/dist/schemas/agent-profile.schema.json +1 -1
  50. package/dist/schemas/code-review-result.schema.json +2 -2
  51. package/dist/schemas/code-work-batch.schema.json +4 -3
  52. package/dist/schemas/code-work-result.schema.json +4 -4
  53. package/dist/schemas/plan-review-result.schema.json +2 -2
  54. package/dist/schemas/run-control-receipt.schema.json +99 -0
  55. package/dist/schemas/run-control-request.schema.json +36 -0
  56. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  57. package/dist/services/canon.js +11 -8
  58. package/dist/services/cleanup.js +57 -13
  59. package/dist/services/cli-operation-classifier.js +22 -24
  60. package/dist/services/code-checks.js +543 -96
  61. package/dist/services/codex-hook-delivery.js +28 -0
  62. package/dist/services/command-context.js +80 -0
  63. package/dist/services/config.js +11 -8
  64. package/dist/services/continuation-outcome.js +20 -0
  65. package/dist/services/controller-fanout.js +132 -0
  66. package/dist/services/dashboard.js +103 -31
  67. package/dist/services/engines.js +121 -76
  68. package/dist/services/eval-snapshots.js +750 -73
  69. package/dist/services/execution-policy.js +170 -0
  70. package/dist/services/external-work-launch.js +110 -0
  71. package/dist/services/harness-adapter.js +147 -24
  72. package/dist/services/harness-config.js +2 -0
  73. package/dist/services/hooks.js +587 -299
  74. package/dist/services/lanes.js +61 -53
  75. package/dist/services/lifecycle-command.js +118 -7
  76. package/dist/services/lifecycle-invocations.js +1055 -0
  77. package/dist/services/managed-daemon-binding.js +38 -0
  78. package/dist/services/managed-processes.js +283 -27
  79. package/dist/services/merge-queue.js +179 -104
  80. package/dist/services/merge-server.js +73 -28
  81. package/dist/services/migrations.js +14 -9
  82. package/dist/services/native-daemon-history.js +49 -0
  83. package/dist/services/native-session-control.js +39 -0
  84. package/dist/services/plan-runtime.js +19 -8
  85. package/dist/services/plans.js +28 -25
  86. package/dist/services/projects.js +18 -5
  87. package/dist/services/prompts.js +23 -16
  88. package/dist/services/protocols.js +64 -20
  89. package/dist/services/recovery-observation-budget.js +61 -0
  90. package/dist/services/recovery-snapshot-database.js +107 -0
  91. package/dist/services/repair-intents.js +40 -0
  92. package/dist/services/run-control-receipt.js +56 -0
  93. package/dist/services/run-control-worker.js +363 -0
  94. package/dist/services/run-control.js +876 -0
  95. package/dist/services/run-controller-adapter.js +222 -0
  96. package/dist/services/run-controller-capture.js +134 -0
  97. package/dist/services/run-controller-process.js +189 -0
  98. package/dist/services/run-controller-recovery.js +222 -0
  99. package/dist/services/run-controller-state.js +36 -0
  100. package/dist/services/run-controller.js +789 -0
  101. package/dist/services/run-engine-bindings.js +1 -1
  102. package/dist/services/run-fork.js +138 -0
  103. package/dist/services/run-observations.js +112 -0
  104. package/dist/services/run-recovery-runtime.js +69 -0
  105. package/dist/services/run-recovery.js +343 -0
  106. package/dist/services/runs.js +368 -74
  107. package/dist/services/runtime-budget.js +434 -0
  108. package/dist/services/runtime-command.js +43 -0
  109. package/dist/services/runtime-scope-capture.js +50 -0
  110. package/dist/services/runtime-scope-control.js +450 -0
  111. package/dist/services/runtime-scope-resume.js +565 -0
  112. package/dist/services/runtime-scope-stop.js +99 -0
  113. package/dist/services/runtime-scope-worker.js +243 -0
  114. package/dist/services/runtime-service.js +97 -0
  115. package/dist/services/schema-validation.js +10 -8
  116. package/dist/services/sessions.js +35 -69
  117. package/dist/services/stage-blocker.js +17 -7
  118. package/dist/services/stage-context.js +44 -18
  119. package/dist/services/stage-lifecycle.js +99 -102
  120. package/dist/services/stage-pause.js +84 -55
  121. package/dist/services/stage-work-graph.js +35 -0
  122. package/dist/services/usage.js +37 -93
  123. package/dist/services/vnext-code-review.js +195 -88
  124. package/dist/services/vnext-code.js +285 -252
  125. package/dist/services/vnext-execution-profile.js +5 -3
  126. package/dist/services/vnext-fanout.js +160 -17
  127. package/dist/services/vnext-merge.js +291 -104
  128. package/dist/services/vnext-plan-review.js +139 -81
  129. package/dist/services/vnext-plan.js +138 -69
  130. package/dist/services/vnext-protocolize.js +129 -56
  131. package/dist/services/vnext-specify.js +88 -65
  132. package/dist/services/work-registry.js +716 -168
  133. package/dist/services/workspace-bootstrap.js +76 -0
  134. package/dist/services/worktrees.js +85 -35
  135. package/dist/shared/entity-references.js +8 -0
  136. package/dist/shared/errors.js +12 -0
  137. package/dist/storage/database.js +459 -32
  138. package/dist/storage/paths.js +17 -4
  139. package/dist/storage/work-references.js +23 -0
  140. package/dist/storage/writer-contract.js +77 -0
  141. package/dist/storage/writer-migration.js +102 -0
  142. package/package.json +24 -13
  143. package/tools/repair-paused-run-status.mjs +59 -0
@@ -0,0 +1,28 @@
1
+ import path from "node:path";
2
+ import { callDaemon } from "../harness-runtime/lib/dd-codex-daemon.mjs";
3
+ import { AppError } from "../shared/errors.js";
4
+ import { commandOption, lifecycleCommandFromPayload, parseLifecycleCommand, continuationCommand } from "./lifecycle-command.js";
5
+ /** The hook asks the existing daemon about native evidence; it cannot mint it. */
6
+ export async function verifyCodexHookDelivery(env, payload) {
7
+ const command = lifecycleCommandFromPayload(payload);
8
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
9
+ if (parsed.kind === "none" && !(command && continuationCommand(command)))
10
+ return undefined;
11
+ const managed = (parsed.kind !== "none" && commandOption(parsed.invocation, "invocation-id")) || env.DD_FLOW_DAEMON_ID;
12
+ if (!managed)
13
+ return undefined; // Explicit legacy/unmanaged transport.
14
+ if (!env.DD_FLOW_CODEX_STATE_DIR || !env.DD_FLOW_DAEMON_ID)
15
+ throw new AppError("native_hook_unproven", "Managed hook has no owning native daemon", 1, { effect: "no_effect" });
16
+ const proof = await callDaemon(env.DD_FLOW_CODEX_STATE_DIR, "hook.verify", {
17
+ daemonId: env.DD_FLOW_DAEMON_ID, rootSessionId: payload.session_id,
18
+ sessionId: payload.agent_id ?? payload.session_id, toolCallId: payload.tool_use_id, turnId: payload.turn_id
19
+ }, 5000);
20
+ if (typeof proof.projectRoot !== "string"
21
+ || typeof proof.ddFlowHome !== "string" || !env.DD_FLOW_HOME || path.resolve(env.DD_FLOW_HOME) !== path.resolve(proof.ddFlowHome)
22
+ || proof.daemonId !== env.DD_FLOW_DAEMON_ID || typeof proof.rootSessionId !== "string" || typeof proof.sessionId !== "string"
23
+ || typeof proof.toolCallId !== "string" || !(proof.parentSessionId === null || typeof proof.parentSessionId === "string")) {
24
+ throw new AppError("native_hook_unproven", "Native hook proof differs from the requested project/runtime", 1, { effect: "no_effect" });
25
+ }
26
+ return { daemonId: proof.daemonId, rootSessionId: proof.rootSessionId, sessionId: proof.sessionId,
27
+ parentSessionId: proof.parentSessionId, toolCallId: proof.toolCallId, projectRoot: proof.projectRoot, command: command };
28
+ }
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+ import { commandInputs, physicalPathOptions } from "../cli/command-inputs.js";
3
+ import { parseCommandArgs } from "./lifecycle-command.js";
4
+ import { assertPathWithin, canonicalPath } from "../storage/paths.js";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { resolveWorkReference } from "../storage/work-references.js";
7
+ export function normalizedPathAlias(value) {
8
+ return value.replace(/^\\+(?=@(?:project|workspace|run)(?:\/|$))/, "");
9
+ }
10
+ export function resolveContextualPaths(args, options, roots) {
11
+ const resolve = (option, original) => {
12
+ if (!options.has(option))
13
+ return original;
14
+ const value = normalizedPathAlias(original);
15
+ if (!value.startsWith("@"))
16
+ return value;
17
+ const match = /^@(project|workspace|run)(?:\/(.*))?$/.exec(value);
18
+ if (!match || !roots[match[1]])
19
+ throw new AppError("usage", `Unknown or unavailable path alias: ${value}`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
20
+ const root = roots[match[1]];
21
+ return assertPathWithin(root, path.join(root, match[2] ?? ""), `${match[1]}_alias`);
22
+ };
23
+ return args.map((value, index) => {
24
+ const equal = /^--([^=]+)=(.*)$/s.exec(value);
25
+ if (equal)
26
+ return `--${equal[1]}=${resolve(equal[1], equal[2])}`;
27
+ const option = args[index - 1]?.match(/^--([^=]+)$/)?.[1];
28
+ return option ? resolve(option, value) : value;
29
+ });
30
+ }
31
+ /** Contextual paths are route semantics, not lifecycle invocation authority. */
32
+ export function resolveCommandProjectRoot(context, args, explicit) {
33
+ if (explicit && !normalizedPathAlias(explicit).startsWith("@"))
34
+ return canonicalPath(explicit, false);
35
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'runs'"))
36
+ return null;
37
+ const parsed = parseCommandArgs(args);
38
+ const key = Object.keys(commandInputs).filter(key => key.split(" ").every((part, i) => parsed.positional[i] === part)).sort((a, b) => b.length - a.length)[0];
39
+ const reference = parsed.options.get("run")?.[0] ?? (key?.startsWith("stage ") ? parsed.positional[key.split(" ").length] : undefined);
40
+ if (reference) {
41
+ const rows = context.db.all("SELECT project_root FROM runs WHERE id = ? OR short_id = ?", [reference, reference]);
42
+ if (rows.length === 1)
43
+ return rows[0].project_root;
44
+ if (rows.length > 1)
45
+ throw new AppError("usage", "RUN reference requires an explicit project scope", 2, { phase: "prepare", effect: "no_effect" });
46
+ }
47
+ const work = parsed.options.get("parent")?.[0] ?? (key?.startsWith("work ") && !key.startsWith("work repair") ? parsed.positional[key.split(" ").length] : undefined);
48
+ if (work) {
49
+ const resolved = resolveWorkReference(context, work);
50
+ return context.db.get("SELECT root FROM projects WHERE id = ?", [resolved.project_id])?.root ?? null;
51
+ }
52
+ return null;
53
+ }
54
+ export function resolveCommandPaths(context, args, projectRoot) {
55
+ const parsed = parseCommandArgs(args);
56
+ const key = Object.keys(commandInputs).filter(key => key.split(" ").every((part, i) => parsed.positional[i] === part)).sort((a, b) => b.length - a.length)[0];
57
+ const options = new Set(key ? commandInputs[key]?.contextualPaths ?? [] : []);
58
+ if (!args.some(value => /(?:^|=)\\*@/.test(value)))
59
+ return args;
60
+ for (const [option, values] of parsed.options) {
61
+ if (physicalPathOptions.has(option) && !options.has(option) && values.some(value => normalizedPathAlias(value).startsWith("@")))
62
+ throw new AppError("usage", `--${option} requires a physical path on this route`, 2, { phase: "prepare", effect: "no_effect", recoverable: true, parameter: option });
63
+ }
64
+ const project = projectRoot ? context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]) : undefined;
65
+ const reference = parsed.options.get("run")?.[0] ?? (key?.startsWith("stage ") ? parsed.positional[key.split(" ").length] : undefined);
66
+ let run;
67
+ if (reference && project) {
68
+ const rows = context.db.all("SELECT id, workspace_root, run_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
69
+ if (rows.length !== 1)
70
+ throw new AppError("usage", "Path aliases require one RUN in the selected project", 2, { phase: "prepare", effect: "no_effect" });
71
+ run = rows[0];
72
+ }
73
+ const work = parsed.options.get("parent")?.[0] ?? (key?.startsWith("work ") && !key.startsWith("work repair") ? parsed.positional[key.split(" ").length] : undefined);
74
+ if (work && project) {
75
+ const resolved = resolveWorkReference(context, work, { projectId: project.id, ...(run ? { runId: run.id } : {}) });
76
+ if (!run)
77
+ run = context.db.get("SELECT id, workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, resolved.run_id]);
78
+ }
79
+ return resolveContextualPaths(args, options, { project: projectRoot, workspace: run?.workspace_root, run: run?.run_root });
80
+ }
@@ -28,7 +28,8 @@ export function getProjectConfigStatus(context, project) {
28
28
  };
29
29
  }
30
30
  export function setProjectConfigValue(context, project, input) {
31
- const value = parseConfigValue(input.key, input.value);
31
+ const value = prepareProjectConfigValue(input.key, input.value);
32
+ migrateLegacyConfig(context, project.id);
32
33
  const now = context.now();
33
34
  context.db.run(`INSERT INTO project_config (project_id, key, value_json, created_at, updated_at)
34
35
  VALUES (?, ?, ?, ?, ?)
@@ -38,13 +39,15 @@ export function setProjectConfigValue(context, project, input) {
38
39
  return getProjectConfigStatus(context, project);
39
40
  }
40
41
  export function readProjectConfig(context, projectId) {
41
- // Status and dashboard reads intentionally open SQLite read-only. Legacy
42
- // cleanup is a write-side concern and must not make those commands fail.
43
- if (context.db.writable)
44
- migrateLegacyConfig(context, projectId);
42
+ // Project legacy values without changing storage. Explicit writes migrate
43
+ // them; a canonical override always wins over its legacy alias.
44
+ const rows = configOverrides(context, projectId), keys = new Set(rows.map(row => row.key));
45
45
  const config = structuredClone(defaultProjectConfig);
46
- for (const row of configOverrides(context, projectId)) {
47
- applyConfigValue(config, row.key, JSON.parse(row.value_json));
46
+ for (const row of rows) {
47
+ const key = legacyConfigKey(row.key);
48
+ if (key !== row.key && keys.has(key))
49
+ continue;
50
+ applyConfigValue(config, key, JSON.parse(row.value_json));
48
51
  }
49
52
  return config;
50
53
  }
@@ -89,7 +92,7 @@ export function globalDashboardHtmlPath(context) {
89
92
  function configOverrides(context, projectId) {
90
93
  return context.db.all("SELECT key, value_json, updated_at FROM project_config WHERE project_id = ? ORDER BY key ASC", [projectId]);
91
94
  }
92
- function parseConfigValue(key, value) {
95
+ export function prepareProjectConfigValue(key, value) {
93
96
  if (key === "integrations.cmux.mode") {
94
97
  if (!cmuxModes.includes(value)) {
95
98
  throw new AppError("validation", "--value for integrations.cmux.mode must be off, auto, or required", 2);
@@ -0,0 +1,20 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { isRegisteredRepair } from "./repair-intents.js";
3
+ import { stageAttempt } from "./stage-work-graph.js";
4
+ /** Only the settled coordinator turn can explain a repeated continuation.
5
+ * A stale error from another stage/turn or an error followed by success is not
6
+ * authority. Never inspect the assistant's prose for this decision. */
7
+ export function assertNoCorrelatedRejection(context, scope) {
8
+ const rows = context.db.all(`SELECT outcome_json FROM hook_events
9
+ WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ?
10
+ AND json_extract(outcome_json, '$.generation') = ? AND json_extract(outcome_json, '$.stage') = ?
11
+ AND json_extract(outcome_json, '$.controller_turn') = ? AND json_extract(outcome_json, '$.attempt') = ? ORDER BY id`, [scope.projectId, scope.runId, scope.generation, scope.stage, scope.turn, stageAttempt(context, scope.projectId, scope.runId, scope.stage)]);
12
+ const latest = new Map();
13
+ for (const row of rows) {
14
+ const value = JSON.parse(row.outcome_json);
15
+ latest.set(value.operation, value);
16
+ }
17
+ for (const value of latest.values())
18
+ if (value.error && !isRegisteredRepair(context, value.error.details.continuation, scope))
19
+ throw new AppError(value.error.code, value.error.message, 1, { ...value.error.details, operation: value.operation, hook_event_id: value.hook_event_id, continuation_scope: scope });
20
+ }
@@ -0,0 +1,132 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout, subagentCapacityKey } from "./vnext-fanout.js";
3
+ import { getFlowRunVariables } from "./runs.js";
4
+ import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
+ import { resolveExecutionPolicy } from "./execution-policy.js";
6
+ import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
7
+ function semanticCompletionPrompt(stage, completed) {
8
+ const task = stage === "plan-review"
9
+ ? "Classify the reviewer findings, apply every accepted correction in the current PLAN-REVIEW Work, and write the authoritative review decision."
10
+ : stage === "code-review"
11
+ ? "Classify the review findings and write or retain the authoritative review decision."
12
+ : "Integrate the accepted child results and update the Stage's authoritative verification evidence.";
13
+ return [
14
+ `All declared ${stage} child Work has settled (${completed} completed).`,
15
+ task,
16
+ "Then invoke the exact Stage finish command from the original authoritative packet.",
17
+ "Only if that command actually returns continuation.kind = repair_required has repair Work been registered; in that case end this Turn immediately so the controller can dispatch it. Otherwise follow the command's actual result.",
18
+ "Do not create speculative repair Work or start a successor Stage."
19
+ ].join(" ");
20
+ }
21
+ /** Stage entry acknowledges the packet before the controller issues child commands. */
22
+ export function controllerStageEntryPrompt(stage, command) {
23
+ return [
24
+ `Start the assigned ${stage} Stage now. Your first tool call must be this exact standalone command:`,
25
+ command,
26
+ "Read the complete authoritative packet from the command's response_file result, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.",
27
+ "If the returned packet has orchestration.kind = work_fanout, this Turn is only Stage entry: acknowledge the packet and end your Turn immediately. The packet describes the whole Stage, but its graph/dispatch/finish instructions belong to later controller continuations. Do not list, start, launch or finish Work, create children, change files, run checks, or finish the Stage in this entry Turn. The controller will inspect the graph and send the next assignment with exact issued commands (or dispatch external workers itself).",
28
+ "Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
29
+ "Execute each runtime-issued lifecycle operation and target as one standalone call. Internal invocation IDs are intentionally absent; never add, copy or invent one. For a no-effect input rejection, correct the input and use its returned retry_command. If a required command is missing or no safe retry is returned, report the exact error and end the Turn so the controller can handle it."
30
+ ].join("\n\n");
31
+ }
32
+ /** The shared controller owns this former eval-only Work-graph decision. */
33
+ export async function nextControllerFanout(context, input) {
34
+ const children = input.children.filter(child => child.status !== "settled_by_root");
35
+ const observations = { harness_id: input.harness, parent_session_id: input.sessionId, children };
36
+ if (children.length) {
37
+ const observed = observeVnextNativeChildren(context, { ...input, observations });
38
+ const issues = observed.issues;
39
+ if (issues.length)
40
+ throw new AppError("fanout_reconciliation_required", "Native children have unresolved lifecycle ownership", 1, { issues });
41
+ }
42
+ let fanout = getVnextFanoutStatus(context, input).orchestration;
43
+ if (!fanout)
44
+ return { kind: "none" };
45
+ if (fanout.state === "compatibility_required")
46
+ throw new AppError("stage_graph_compatibility_required", "The retained graph cannot be dispatched by this controller; use the RUN's pinned engine", 1, { stage: input.stage, run_id: input.runId, graph: fanout });
47
+ if (children.length) {
48
+ const reconciled = await reconcileVnextFanout(context, { ...input, observations });
49
+ const issues = reconciled.issues.filter(issue => issue.code !== "child_check_in_progress");
50
+ if (issues.length)
51
+ throw new AppError("fanout_reconciliation_required", "Native child outcome requires reconciliation, not replay", 1, { issues });
52
+ if (children.some(child => child.status === "running" || child.status === "unknown"))
53
+ return { kind: "wait", reason: "native_children_unsettled" };
54
+ fanout = getVnextFanoutStatus(context, input).orchestration;
55
+ }
56
+ if (fanout.state === "dispatch_required") {
57
+ dispatchVnextPlanReview(context, input);
58
+ fanout = getVnextFanoutStatus(context, input).orchestration;
59
+ if (fanout.state === "dispatch_required" || fanout.state === "capacity_required")
60
+ return { kind: "wait", reason: "qualified_capacity_required" };
61
+ }
62
+ const prompt = (action, text) => ({ kind: "prompt", action, prompt: text, fingerprint: JSON.stringify({ stage: input.stage, parent: fanout.parent_work_id, state: fanout.state, graph: fanout.graph_revision }) });
63
+ if (fanout.state === "coordinator_required")
64
+ return prompt("materialize_graph", `Continue the current ${input.stage} Stage using its authoritative packet. Materialize only its required Work graph with real dependencies. Do not launch children, finish the Stage, or start a successor. Return after registering the graph; the runtime will dispatch its ready Work.`);
65
+ if (fanout.works.failed || fanout.works.cancelled)
66
+ return prompt("repair_or_block", `Some ${input.stage} child Work failed or was cancelled. Inspect its recorded receipts. Choose a legal scoped repair/retry or block the Stage with the concrete reason. Do not relaunch settled siblings or infer success from a child that omitted work finish.`);
67
+ if (fanout.works.ready.length) {
68
+ const choices = fanout.works.ready.map(work => {
69
+ const payload = context.db.get("SELECT payload_json FROM works WHERE work_id = ?", [work.work_id])?.payload_json;
70
+ const override = payload ? JSON.parse(payload).execution_override : undefined;
71
+ return { work, resolved: resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: input.policy, profiles: input.profiles, stage: input.stage, ...(override ? { work: override } : {}) }) };
72
+ });
73
+ const external = choices.filter(choice => choice.resolved.delegation.mode === "external");
74
+ if (external.length) {
75
+ const maximum = Math.min(...external.map(choice => choice.resolved.delegation.max_parallel ?? 1));
76
+ return { kind: "external", works: external.slice(0, maximum).map(choice => choice.work) };
77
+ }
78
+ const variables = getFlowRunVariables(context, input);
79
+ const capacity = variables.variables[subagentCapacityKey];
80
+ if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1)
81
+ return { kind: "wait", reason: "qualified_capacity_required" };
82
+ const wave = choices.slice(0, capacity).map(choice => choice.work);
83
+ for (const work of wave)
84
+ if (!work.work_id || !work.start_command)
85
+ throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
86
+ return prompt("dispatch_work", renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave }));
87
+ }
88
+ if (fanout.works.running)
89
+ return { kind: "wait", reason: "work_receipts_pending" };
90
+ if (fanout.works.created)
91
+ throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
92
+ return prompt("semantic_completion", semanticCompletionPrompt(input.stage, fanout.works.completed));
93
+ }
94
+ /** Normalize provider-observed topology only; never parse assistant prose. */
95
+ export function controllerNativeChildren(receipt, rootSessionId) {
96
+ const candidates = [];
97
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
98
+ const list = (value) => Array.isArray(value) ? value : [];
99
+ const text = (value, keys) => { for (const key of keys)
100
+ if (typeof value[key] === "string" && value[key])
101
+ return value[key]; return null; };
102
+ const settledTree = new Set(list(receipt.tree_observations)
103
+ .filter(value => object(value).settled === true)
104
+ .map(value => text(object(value), ["provider_session_id", "session_id"]))
105
+ .filter((id) => id !== null));
106
+ const add = (raw, source, scopedParent = null, fallback = "unknown") => {
107
+ const value = object(raw);
108
+ const id = text(value, ["provider_session_id", "session_id", "sessionId", "childSessionId", "conversation_id", "conversationId", "id", "subagentId"]);
109
+ const parent = text(value, ["parent_provider_session_id", "parent_session_id", "parentSessionId", "parentID", "parentId"]) ?? scopedParent;
110
+ if (!id || parent !== rootSessionId)
111
+ return;
112
+ const state = String(value.status ?? value.state ?? fallback).toLowerCase();
113
+ const status = ["completed", "complete", "done", "idle", "success", "succeeded"].includes(state) ? "completed" : state === "settled_by_root" ? "settled_by_root" : ["failed", "error"].includes(state) ? "failed" : ["cancelled", "canceled"].includes(state) ? "cancelled" : ["running", "active", "pending"].includes(state) ? "running" : fallback;
114
+ candidates.push({ session_id: id, parent_session_id: parent, status: status === "unknown" && settledTree.has(id) ? "settled_by_root" : status, source });
115
+ };
116
+ for (const value of list(receipt.descendants))
117
+ add(value, "adapter.descendants");
118
+ for (const value of list(receipt.children))
119
+ add(value, "opencode.session.children");
120
+ const subagents = object(object(receipt.evidence).subagents ?? receipt.subagents);
121
+ for (const value of list(subagents.running))
122
+ add(value, "zcode/session/subagents", rootSessionId, "running");
123
+ for (const value of [...list(subagents.completed), ...list(object(subagents.ended).items)])
124
+ add(value, "zcode/session/subagents", rootSessionId, "completed");
125
+ const unique = new Map();
126
+ for (const child of candidates) {
127
+ const previous = unique.get(child.session_id);
128
+ if (!previous || previous.status === "unknown" || previous.status === "running" && child.status !== "running")
129
+ unique.set(child.session_id, child);
130
+ }
131
+ return [...unique.values()].sort((left, right) => left.session_id.localeCompare(right.session_id));
132
+ }
@@ -3,7 +3,8 @@ import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { isActiveProtocolStatus, isActiveQueueStatus, isQueueHistoryStatus } from "../domain/contracts.js";
5
5
  import { AppError } from "../shared/errors.js";
6
- import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
6
+ import { prepareOutputFile } from "../cli/input-preparation.js";
7
+ import { ensureDir, resolveProjectRoot, resolveProtocolPlanPath } from "../storage/paths.js";
7
8
  import { dashboardMarkdownPath, globalDashboardHtmlPath, globalDashboardJsonPath, globalDashboardMarkdownPath, projectDashboardHtmlPath, projectDashboardJsonPath, projectSummaryJsonPath, protocolDashboardHtmlPath, protocolDashboardJsonPath, readProjectConfig } from "./config.js";
8
9
  import { queueForProject } from "./merge-queue.js";
9
10
  import { publishProjectSummary } from "./project-summary.js";
@@ -17,42 +18,58 @@ export function getCmuxStatus(context, input) {
17
18
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
18
19
  return { ok: true, project_root: project.root, cmux: detectCmux(context) };
19
20
  }
20
- export function renderDashboard(context, input) {
21
+ export function prepareDashboardRender(context, input) {
21
22
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
23
+ if (projectRecoveryGuarded(context, project.id))
24
+ throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
22
25
  const format = parseDashboardFormat(input.format);
26
+ if (input.protocol)
27
+ requireProtocol(context, input.protocol, project.id);
28
+ const config = readProjectConfig(context, project.id);
29
+ const output = input.output
30
+ ? path.resolve(project.root, input.output)
31
+ : format === "html"
32
+ ? input.protocol ? protocolDashboardHtmlPath(context, project.id, input.protocol) : projectDashboardHtmlPath(context, project.id)
33
+ : dashboardMarkdownPath(project.root, config);
34
+ prepareOutputFile(output);
35
+ if (format === "html")
36
+ prepareOutputFile(input.protocol ? protocolDashboardJsonPath(context, project.id, input.protocol) : projectDashboardJsonPath(context, project.id));
37
+ return { project, format, output };
38
+ }
39
+ export function renderDashboard(context, input) {
40
+ const { project, format, output } = prepareDashboardRender(context, input);
23
41
  if (format === "html") {
24
42
  if (input.protocol) {
25
43
  return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
26
44
  }
27
45
  return renderProjectDashboardHtml(context, project, input.output);
28
46
  }
29
- const config = readProjectConfig(context, project.id);
30
- const output = input.output
31
- ? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
32
- : dashboardMarkdownPath(project.root, config);
33
47
  const markdown = renderProjectDashboardMarkdown(context, project, output);
34
48
  writeMarkdown(output, markdown);
35
49
  return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
36
50
  }
37
- export function renderGlobalDashboard(context, input = {}) {
51
+ export function prepareGlobalDashboardRender(context, input = {}) {
38
52
  const format = parseDashboardFormat(input.format);
53
+ const output = input.output ? path.resolve(input.output) : format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context);
54
+ prepareOutputFile(output);
55
+ if (format === "html")
56
+ prepareOutputFile(globalDashboardJsonPath(context));
57
+ return { format, output };
58
+ }
59
+ export function renderGlobalDashboard(context, input = {}) {
60
+ const { format, output } = prepareGlobalDashboardRender(context, input);
39
61
  if (format === "html") {
40
62
  return renderGlobalDashboardHtml(context, input.output);
41
63
  }
42
- const output = input.output
43
- ? path.resolve(input.output)
44
- : globalDashboardMarkdownPath(context);
45
64
  const markdown = renderGlobalDashboardMarkdown(context, output);
46
65
  writeMarkdown(output, markdown);
47
66
  return { ok: true, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
48
67
  }
49
68
  export function openDashboard(context, input) {
69
+ prepareDashboardOptions(input);
50
70
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
51
71
  const config = readProjectConfig(context, project.id);
52
72
  const format = parseDashboardFormat(input.format, "html");
53
- if (input.viewer && input.viewer !== "cmux") {
54
- throw new AppError("validation", "--viewer must be cmux", 2);
55
- }
56
73
  const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
57
74
  return {
58
75
  ok: true,
@@ -62,9 +79,7 @@ export function openDashboard(context, input) {
62
79
  };
63
80
  }
64
81
  export function openGlobalDashboard(context, input = {}) {
65
- if (input.viewer && input.viewer !== "cmux") {
66
- throw new AppError("validation", "--viewer must be cmux", 2);
67
- }
82
+ prepareDashboardOptions(input);
68
83
  const format = parseDashboardFormat(input.format, "html");
69
84
  const rendered = renderGlobalDashboard(context, { format });
70
85
  return {
@@ -74,9 +89,7 @@ export function openGlobalDashboard(context, input = {}) {
74
89
  };
75
90
  }
76
91
  export function openProtocolDashboard(context, input) {
77
- if (input.viewer && input.viewer !== "cmux") {
78
- throw new AppError("validation", "--viewer must be cmux", 2);
79
- }
92
+ prepareDashboardOptions(input);
80
93
  const format = parseDashboardFormat(input.format, "html");
81
94
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
82
95
  if (format !== "html") {
@@ -93,14 +106,40 @@ export function openProtocolDashboard(context, input) {
93
106
  open: openCmuxDashboard(context, project.root, rendered.dashboard.path, format)
94
107
  };
95
108
  }
96
- export function refreshDashboard(context, input) {
109
+ export function prepareDashboardRefresh(context, input) {
110
+ prepareDashboardOptions(input);
97
111
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
98
112
  const config = readProjectConfig(context, project.id);
99
113
  const format = parseDashboardFormat(input.format);
100
114
  const openMode = input.open ?? "auto";
101
- if (!["auto", "true", "false"].includes(openMode)) {
102
- throw new AppError("validation", "--open must be auto, true, or false", 2);
115
+ if (config.dashboard.project) {
116
+ if (projectRecoveryGuarded(context, project.id))
117
+ throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
118
+ if (input.protocol)
119
+ requireProtocol(context, input.protocol, project.id);
120
+ prepareRefreshOutputs(context, project, config, format, input.protocol);
103
121
  }
122
+ return { project, config, format, openMode };
123
+ }
124
+ function prepareRefreshOutputs(context, project, config, format, protocol) {
125
+ if (config.dashboard.project) {
126
+ if (!protocol)
127
+ prepareOutputFile(projectSummaryJsonPath(context, project.id));
128
+ if (format === "html") {
129
+ prepareOutputFile(protocol ? protocolDashboardHtmlPath(context, project.id, protocol) : projectDashboardHtmlPath(context, project.id));
130
+ prepareOutputFile(protocol ? protocolDashboardJsonPath(context, project.id, protocol) : projectDashboardJsonPath(context, project.id));
131
+ }
132
+ else
133
+ prepareOutputFile(dashboardMarkdownPath(project.root, config));
134
+ }
135
+ if (config.dashboard.global) {
136
+ prepareOutputFile(format === "html" ? globalDashboardHtmlPath(context) : globalDashboardMarkdownPath(context, config));
137
+ if (format === "html")
138
+ prepareOutputFile(globalDashboardJsonPath(context));
139
+ }
140
+ }
141
+ export function refreshDashboard(context, input) {
142
+ const { project, config, format, openMode } = prepareDashboardRefresh(context, input);
104
143
  if (!config.dashboard.project) {
105
144
  return {
106
145
  ok: true,
@@ -143,8 +182,9 @@ export function refreshAllDashboards(context, input = {}) {
143
182
  return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "dashboard_project_disabled" };
144
183
  }
145
184
  try {
146
- const projectSummary = publishProjectSummary(context, { project });
185
+ prepareRefreshOutputs(context, project, { ...config, dashboard: { ...config.dashboard, global: false } }, format);
147
186
  const rendered = renderDashboard(context, { projectRoot: project.root, format });
187
+ const projectSummary = publishProjectSummary(context, { project });
148
188
  return { ok: true, project_id: project.id, project_root: project.root, dashboard: rendered.dashboard, project_summary: projectSummary };
149
189
  }
150
190
  catch (error) {
@@ -170,10 +210,15 @@ export function refreshAllDashboards(context, input = {}) {
170
210
  }
171
211
  export function autoRefreshDashboards(context, input) {
172
212
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
213
+ // Hooks and usage ingestion continue to record receipts after capture.
214
+ // Their derived timestamp must not mutate the captured workspace.
215
+ if (projectRecoveryGuarded(context, project.id))
216
+ return { ok: true, skipped: true, reason: "run_recovery_guarded" };
173
217
  const config = readProjectConfig(context, project.id);
174
218
  if (!config.dashboard.auto_refresh) {
175
219
  return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
176
220
  }
221
+ prepareRefreshOutputs(context, project, config, "markdown");
177
222
  const result = { ok: true, project_root: project.root };
178
223
  if (config.dashboard.project) {
179
224
  publishProjectSummary(context, { project });
@@ -196,6 +241,9 @@ export function autoRefreshDashboards(context, input) {
196
241
  }
197
242
  return result;
198
243
  }
244
+ function projectRecoveryGuarded(context, projectId) {
245
+ return Boolean(context.db.get("SELECT 1 FROM run_recovery_guards WHERE project_id = ? AND status IN ('draining', 'sealed', 'resuming') LIMIT 1", [projectId]));
246
+ }
199
247
  export function dashboardSummary(context, projectId, projectRoot) {
200
248
  const config = readProjectConfig(context, projectId);
201
249
  return {
@@ -282,6 +330,14 @@ function detectCmux(context) {
282
330
  ? { available: true, source: "path", version: result.stdout.trim() }
283
331
  : { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
284
332
  }
333
+ export function prepareDashboardOptions(input) {
334
+ if (input.format !== undefined)
335
+ parseDashboardFormat(input.format);
336
+ if (input.viewer !== undefined && input.viewer !== "cmux")
337
+ throw new AppError("validation", "--viewer must be cmux", 2);
338
+ if (input.open !== undefined && !["auto", "true", "false"].includes(input.open))
339
+ throw new AppError("validation", "--open must be auto, true, or false", 2);
340
+ }
285
341
  function parseDashboardFormat(value, defaultFormat = "markdown") {
286
342
  const format = value ?? defaultFormat;
287
343
  if (format !== "markdown" && format !== "html") {
@@ -294,15 +350,18 @@ function renderProjectDashboardHtml(context, project, output) {
294
350
  ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
295
351
  : projectDashboardHtmlPath(context, project.id);
296
352
  const jsonPath = projectDashboardJsonPath(context, project.id);
353
+ prepareOutputFile(htmlPath);
354
+ prepareOutputFile(jsonPath);
297
355
  const data = buildProjectDashboardData(context, project, htmlPath);
298
356
  const html = renderDashboardHtmlPage(data);
299
- writeJsonFile(jsonPath, data);
300
- writeTextFile(htmlPath, html);
301
357
  const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
302
- const protocolPages = protocolCards
358
+ const preparedPages = protocolCards
303
359
  .filter((card) => card.generate_page !== false)
304
360
  .slice(0, 24)
305
- .map((card) => renderProtocolDashboardHtml(context, project, String(card.id)))
361
+ .map((card) => prepareProtocolDashboardHtml(context, project, String(card.id)));
362
+ writeJsonFile(jsonPath, data);
363
+ writeTextFile(htmlPath, html);
364
+ const protocolPages = preparedPages.map(writeProtocolDashboardHtml)
306
365
  .map((result) => ({
307
366
  id: String(result.protocol_id),
308
367
  path: result.dashboard.path,
@@ -321,12 +380,23 @@ function renderProjectDashboardHtml(context, project, output) {
321
380
  };
322
381
  }
323
382
  function renderProtocolDashboardHtml(context, project, protocolId, output) {
383
+ return writeProtocolDashboardHtml(prepareProtocolDashboardHtml(context, project, protocolId, output));
384
+ }
385
+ function prepareProtocolDashboardHtml(context, project, protocolId, output) {
386
+ if (projectRecoveryGuarded(context, project.id))
387
+ throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
324
388
  const htmlPath = output
325
389
  ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
326
390
  : protocolDashboardHtmlPath(context, project.id, protocolId);
327
391
  const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
392
+ prepareOutputFile(htmlPath);
393
+ prepareOutputFile(jsonPath);
328
394
  const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
329
395
  const html = renderDashboardHtmlPage(data);
396
+ return { project, protocolId, htmlPath, jsonPath, data, html };
397
+ }
398
+ function writeProtocolDashboardHtml(prepared) {
399
+ const { project, protocolId, htmlPath, jsonPath, data, html } = prepared;
330
400
  writeJsonFile(jsonPath, data);
331
401
  writeTextFile(htmlPath, html);
332
402
  return {
@@ -344,6 +414,8 @@ function renderProtocolDashboardHtml(context, project, protocolId, output) {
344
414
  function renderGlobalDashboardHtml(context, output) {
345
415
  const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
346
416
  const jsonPath = globalDashboardJsonPath(context);
417
+ prepareOutputFile(htmlPath);
418
+ prepareOutputFile(jsonPath);
347
419
  const data = buildGlobalDashboardData(context, htmlPath);
348
420
  const html = renderDashboardHtmlPage(data);
349
421
  writeJsonFile(jsonPath, data);
@@ -888,12 +960,12 @@ function protocolsForProject(context, projectId) {
888
960
  FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
889
961
  }
890
962
  function planSummaryForProtocol(context, projectId, protocolId) {
891
- const protocol = context.db.get("SELECT plan_path FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
963
+ const protocol = context.db.get("SELECT plan_path, project_root FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
892
964
  if (!protocol) {
893
965
  return { plan_id: "none", total: 0, done: 0, blocked: 0 };
894
966
  }
895
967
  try {
896
- const summary = planSummary(context, { projectId, protocolId, planPath: protocol.plan_path }, { bind: false });
968
+ const summary = planSummary(context, { projectId, protocolId, planPath: resolveProtocolPlanPath(protocol.project_root, protocolId, protocol.plan_path) }, { bind: false });
897
969
  return { plan_id: summary.plan_id, total: summary.total, done: summary.done, blocked: summary.blocked };
898
970
  }
899
971
  catch {
@@ -903,13 +975,13 @@ function planSummaryForProtocol(context, projectId, protocolId) {
903
975
  function activePlanItems(context, projectId, protocols) {
904
976
  const items = [];
905
977
  for (const protocol of protocols) {
906
- const record = context.db.get("SELECT plan_path FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocol.id]);
978
+ const record = context.db.get("SELECT plan_path, project_root FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocol.id]);
907
979
  if (!record) {
908
980
  continue;
909
981
  }
910
982
  let plan;
911
983
  try {
912
- plan = planWithProgress(context, { projectId, protocolId: protocol.id, planPath: record.plan_path }, { bind: false }).plan;
984
+ plan = planWithProgress(context, { projectId, protocolId: protocol.id, planPath: resolveProtocolPlanPath(record.project_root, protocol.id, record.plan_path) }, { bind: false }).plan;
913
985
  }
914
986
  catch {
915
987
  continue;