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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/command-inputs.js +300 -0
  4. package/dist/cli/help.js +1 -1
  5. package/dist/cli/hook-ingress.js +82 -0
  6. package/dist/cli/input-preparation.js +115 -0
  7. package/dist/cli/run-cli.js +1015 -423
  8. package/dist/cli.js +6 -2
  9. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  10. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  11. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  12. package/dist/harness-runtime/lib/dd-droid.mjs +3 -2
  13. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  14. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  15. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  16. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  17. package/dist/services/canon.js +11 -8
  18. package/dist/services/cleanup.js +49 -8
  19. package/dist/services/cli-operation-classifier.js +12 -24
  20. package/dist/services/codex-hook-delivery.js +28 -0
  21. package/dist/services/config.js +11 -8
  22. package/dist/services/controller-fanout.js +7 -11
  23. package/dist/services/dashboard.js +94 -31
  24. package/dist/services/engines.js +75 -61
  25. package/dist/services/eval-snapshots.js +138 -38
  26. package/dist/services/execution-policy.js +5 -12
  27. package/dist/services/hooks.js +226 -203
  28. package/dist/services/lanes.js +60 -52
  29. package/dist/services/lifecycle-command.js +13 -1
  30. package/dist/services/lifecycle-invocations.js +285 -94
  31. package/dist/services/managed-processes.js +158 -18
  32. package/dist/services/merge-queue.js +173 -98
  33. package/dist/services/migrations.js +13 -8
  34. package/dist/services/plan-runtime.js +19 -8
  35. package/dist/services/plans.js +28 -25
  36. package/dist/services/projects.js +11 -2
  37. package/dist/services/prompts.js +23 -16
  38. package/dist/services/protocols.js +54 -19
  39. package/dist/services/recovery-observation-budget.js +1 -1
  40. package/dist/services/run-controller-adapter.js +31 -20
  41. package/dist/services/run-controller-state.js +5 -0
  42. package/dist/services/run-controller.js +102 -56
  43. package/dist/services/run-fork.js +36 -22
  44. package/dist/services/run-recovery.js +32 -17
  45. package/dist/services/runs.js +110 -38
  46. package/dist/services/runtime-budget.js +120 -48
  47. package/dist/services/runtime-scope-capture.js +2 -2
  48. package/dist/services/runtime-scope-control.js +2 -2
  49. package/dist/services/runtime-scope-resume.js +34 -12
  50. package/dist/services/runtime-scope-worker.js +49 -16
  51. package/dist/services/runtime-service.js +8 -7
  52. package/dist/services/schema-validation.js +9 -7
  53. package/dist/services/sessions.js +35 -69
  54. package/dist/services/stage-blocker.js +17 -7
  55. package/dist/services/stage-context.js +42 -16
  56. package/dist/services/stage-lifecycle.js +82 -100
  57. package/dist/services/stage-pause.js +54 -24
  58. package/dist/services/vnext-code-review.js +82 -32
  59. package/dist/services/vnext-code.js +78 -45
  60. package/dist/services/vnext-fanout.js +13 -12
  61. package/dist/services/vnext-merge.js +68 -35
  62. package/dist/services/vnext-plan-review.js +85 -42
  63. package/dist/services/vnext-plan.js +91 -70
  64. package/dist/services/vnext-protocolize.js +116 -50
  65. package/dist/services/vnext-specify.js +70 -57
  66. package/dist/services/work-registry.js +207 -94
  67. package/dist/services/worktrees.js +85 -35
  68. package/dist/storage/database.js +104 -10
  69. package/dist/storage/paths.js +17 -4
  70. package/package.json +1 -1
@@ -2,8 +2,7 @@ import fs from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { parseJsonObject } from "../shared/json.js";
6
- import { ensureReadableFile } from "../storage/database.js";
5
+ import { prepareJsonFile } from "../cli/input-preparation.js";
7
6
  import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
8
7
  import { appendAudit } from "./audit.js";
9
8
  import { requireProjectByRoot } from "./projects.js";
@@ -173,20 +172,29 @@ export function cleanupScan(context, input) {
173
172
  plan: { project_root: project.root, project_id: project.id, actions }
174
173
  };
175
174
  }
176
- export function cleanupApply(context, input) {
177
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
175
+ export function prepareCleanupApply(context, input) {
178
176
  const reason = input.reason.trim();
179
177
  if (!reason) {
180
178
  throw new AppError("validation", "cleanup apply requires --reason", 2);
181
179
  }
182
- ensureReadableFile(input.planFile);
183
- const plan = validateCleanupPlan(parseJsonObject(fs.readFileSync(input.planFile, "utf8"), input.planFile), project);
180
+ const value = prepareJsonFile(input.planFile, "cleanup plan").value;
181
+ if (!value || typeof value !== "object" || Array.isArray(value))
182
+ throw new AppError("validation", "cleanup plan must be an object", 2);
183
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
184
+ const plan = validateCleanupPlan(value, project);
184
185
  validateNoDestructiveSurprises(context, plan, input.force);
186
+ return { project, reason, plan };
187
+ }
188
+ export function cleanupApply(context, input) {
189
+ const { project, reason, plan } = input.prepared ?? prepareCleanupApply(context, input);
185
190
  const now = context.now();
186
191
  const results = [];
187
192
  context.db.beginWriteTransaction();
193
+ let executing = false;
188
194
  try {
195
+ validateNoDestructiveSurprises(context, plan, input.force);
189
196
  for (const action of plan.actions) {
197
+ executing = true;
190
198
  results.push(applyAction(context, action, reason, input.force, now));
191
199
  }
192
200
  const changed = results.filter((result) => result.changed).length;
@@ -203,6 +211,8 @@ export function cleanupApply(context, input) {
203
211
  }
204
212
  catch (error) {
205
213
  context.db.exec("ROLLBACK");
214
+ if (executing)
215
+ throw new AppError("cleanup_apply_failed", "Cleanup failed after execution began; inspect retained artifacts before retrying", 1, { effect: "unknown", database_rolled_back: true, attempted_results: results, cause: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { cause_code: error.code } : {}) });
206
216
  throw error;
207
217
  }
208
218
  return {
@@ -251,11 +261,29 @@ function validateCleanupPlan(value, project) {
251
261
  throw new AppError("validation", "close_flow_session requires a native session identity", 2);
252
262
  }
253
263
  }
264
+ const requiredField = action.kind === "discard_stale_run" ? "run_id"
265
+ : ["repair_protocol_state", "cancel_queue_job", "close_worktree_record"].includes(String(action.kind)) ? "protocol_id" : undefined;
266
+ if (requiredField && (typeof action[requiredField] !== "string" || !action[requiredField].trim()))
267
+ throw new AppError("validation", `cleanup ${String(action.kind)} requires ${requiredField}`, 2);
268
+ const numericField = action.kind === "expire_lane_lock" ? "lock_id" : action.kind === "expire_lane_waiter" ? "waiter_id" : undefined;
269
+ if (numericField && (!Number.isSafeInteger(action[numericField]) || action[numericField] < 1))
270
+ throw new AppError("validation", `cleanup ${String(action.kind)} requires a positive integer ${numericField}`, 2);
271
+ if (action.worktree_path !== undefined && (typeof action.worktree_path !== "string" || !path.isAbsolute(action.worktree_path)))
272
+ throw new AppError("validation", "cleanup worktree_path must be an absolute path", 2);
254
273
  return action;
255
274
  });
256
275
  return { project_root: project.root, project_id: project.id, actions };
257
276
  }
258
277
  function validateNoDestructiveSurprises(context, plan, force) {
278
+ for (const action of plan.actions) {
279
+ if (action.kind === "repair_protocol_state")
280
+ requireProtocol(context, action.protocol_id, action.project_id);
281
+ if (action.kind === "discard_stale_run") {
282
+ const run = context.db.get("SELECT index_json, status FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
283
+ if (run && ["running", "discarded"].includes(run.status))
284
+ cleanupRunIndex(run.index_json);
285
+ }
286
+ }
259
287
  const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
260
288
  if (destructive && !force) {
261
289
  throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
@@ -410,7 +438,7 @@ function applyAction(context, action, reason, force, now) {
410
438
  return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
411
439
  }
412
440
  function discardedRunIndex(indexJson, reason, now) {
413
- const index = JSON.parse(indexJson);
441
+ const index = cleanupRunIndex(indexJson);
414
442
  index.schema_id = "dd-flow/flow-run@3";
415
443
  index.status = "discarded";
416
444
  index.verdict = "discarded";
@@ -420,12 +448,25 @@ function discardedRunIndex(indexJson, reason, now) {
420
448
  index.finished_at ??= now;
421
449
  return index;
422
450
  }
451
+ function cleanupRunIndex(indexJson) {
452
+ try {
453
+ const index = JSON.parse(indexJson);
454
+ if (!index || typeof index !== "object" || Array.isArray(index))
455
+ throw new Error("RUN index must be an object");
456
+ return index;
457
+ }
458
+ catch (error) {
459
+ throw new AppError("runtime_state_invalid", "Cleanup cannot read retained RUN state", 1, { cause: String(error) });
460
+ }
461
+ }
423
462
  function dirtyGit(worktreePath) {
424
463
  if (!fs.existsSync(worktreePath)) {
425
464
  return false;
426
465
  }
427
466
  const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
428
- return result.status === 0 && result.stdout.trim().length > 0;
467
+ if (result.error || result.status !== 0)
468
+ throw new AppError("workspace_status_failed", "Cannot establish worktree status before cleanup", 1, { path: worktreePath, cause: result.error?.message ?? result.stderr });
469
+ return result.stdout.trim().length > 0;
429
470
  }
430
471
  function safeCleanupWorktreePath(context, projectId, worktreePath) {
431
472
  if (!fs.existsSync(worktreePath)) {
@@ -1,3 +1,4 @@
1
+ import { parseCommandArgs } from "./lifecycle-command.js";
1
2
  export function classifyCliOperation(args, env = process.env) {
2
3
  const positional = positionalArgs(args);
3
4
  const [family = null, command = null, action = null] = positional;
@@ -14,19 +15,7 @@ export function classifyCliOperation(args, env = process.env) {
14
15
  return { mode: "normal_write", operation, family, command, action };
15
16
  }
16
17
  function positionalArgs(args) {
17
- const positional = [];
18
- for (let index = 0; index < args.length; index += 1) {
19
- const value = args[index];
20
- if (!value)
21
- continue;
22
- if (value.startsWith("--")) {
23
- if (!value.includes("=") && args[index + 1] && !args[index + 1]?.startsWith("--"))
24
- index += 1;
25
- continue;
26
- }
27
- positional.push(value);
28
- }
29
- return positional;
18
+ return parseCommandArgs(args).positional;
30
19
  }
31
20
  export function isReadOnlyCliOperation(args, env = process.env) {
32
21
  return classifyCliOperation(args, env).mode === "read_only_diagnostics";
@@ -57,7 +46,7 @@ function isMbUpgradeMode(args, env) {
57
46
  }
58
47
  return marked && isUpgradeAllowlisted(args);
59
48
  }
60
- function isUpgradeAllowlisted(args) {
49
+ export function isUpgradeAllowlisted(args) {
61
50
  const positional = positionalArgs(args);
62
51
  const [family, command] = positional;
63
52
  if (family === "status")
@@ -82,14 +71,9 @@ function hasRunBinding(args) {
82
71
  return Boolean(optionValue(args, "run") || positionalArgs(args).some((value) => value.startsWith("RUN-")));
83
72
  }
84
73
  function optionValue(args, name) {
85
- for (let index = args.length - 1; index >= 0; index -= 1) {
86
- const arg = args[index];
87
- if (arg === `--${name}`)
88
- return args[index + 1] ?? null;
89
- if (arg?.startsWith(`--${name}=`))
90
- return arg.slice(name.length + 3);
91
- }
92
- return null;
74
+ const values = parseCommandArgs(args).options.get(name);
75
+ const value = values?.[values.length - 1];
76
+ return value && value !== "__dd_flow_flag__" ? value : null;
93
77
  }
94
78
  function isRouterNative(args) {
95
79
  const first = args[0];
@@ -125,13 +109,17 @@ function isReadOnlyDiagnostic(family, command, action) {
125
109
  if (family === "canon")
126
110
  return command === "status" || command === "resolve";
127
111
  if (family === "project")
128
- return command === "status" || command === "resolve";
112
+ return command === "status" || command === "resolve" || (command === "config" && action === "status");
129
113
  if (family === "protocol")
130
114
  return ["status", "branch-status", "ready", "blockers", "implement"].includes(command ?? "");
131
115
  if (family === "plan")
132
116
  return command === "status";
133
117
  if (family === "run")
134
- return command === "status" || command === "list" || (command === "recovery" && action === "inspect") || (["drive", "control"].includes(command ?? "") && action === "status");
118
+ return ["status", "list", "timeline"].includes(command ?? "") || (["config", "flags", "drive", "control"].includes(command ?? "") && action === "status") || (command === "vars" && ["ls", "get"].includes(action ?? "")) || (command === "recovery" && action === "inspect");
119
+ if (family === "runtime" && command === "process" && ["status", "check-admission"].includes(action ?? ""))
120
+ return true;
121
+ if (family === "runtime" && command === "scope" && action === "status")
122
+ return true;
135
123
  if (family === "work")
136
124
  return ["status", "ls", "show"].includes(command ?? "");
137
125
  if (family === "stage")
@@ -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 } 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")
10
+ return undefined;
11
+ const managed = 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
+ }
@@ -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);
@@ -3,6 +3,7 @@ import { getVnextFanoutStatus, observeVnextNativeChildren, reconcileVnextFanout,
3
3
  import { getFlowRunVariables } from "./runs.js";
4
4
  import { dispatchVnextPlanReview } from "./vnext-plan-review.js";
5
5
  import { resolveExecutionPolicy } from "./execution-policy.js";
6
+ import { renderDelegationInstructions } from "../harness-runtime/lib/delegation-instructions.mjs";
6
7
  /** Stage entry acknowledges the packet before the controller issues child commands. */
7
8
  export function controllerStageEntryPrompt(stage, command, responseFile) {
8
9
  return [
@@ -11,7 +12,7 @@ export function controllerStageEntryPrompt(stage, command, responseFile) {
11
12
  `Read the complete authoritative packet saved to ${JSON.stringify(responseFile)}, including worker_prompt_markdown. If a read is truncated, continue until the entire packet has been read.`,
12
13
  "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).",
13
14
  "Otherwise perform this Stage's semantic work and stop at its boundary or a declared HITL boundary. Do not start a successor Stage.",
14
- "Copy runtime-issued lifecycle commands verbatim as standalone calls. Never generate an invocation ID, substitute a shell variable for it, or borrow one from another command. If a required command is missing or rejected without a returned retry command, report the exact error and end the Turn so the controller can handle it."
15
+ "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."
15
16
  ].join("\n\n");
16
17
  }
17
18
  /** The shared controller owns this former eval-only Work-graph decision. */
@@ -63,21 +64,16 @@ export async function nextControllerFanout(context, input) {
63
64
  if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1)
64
65
  return { kind: "wait", reason: "qualified_capacity_required" };
65
66
  const wave = choices.slice(0, capacity).map(choice => choice.work);
66
- return prompt([
67
- `Launch exactly ${wave.length} direct native children for the current ${input.stage} Stage; the recorded capacity is ${capacity}.`,
68
- "Use this harness's native child mechanism, not external roots or forks. Wait for these children to settle; do not cancel siblings because one failed. Do not finish this Stage or start a successor in this Turn.",
69
- ...wave.map(work => {
70
- if (!work.work_id || !work.start_command)
71
- throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
72
- return `Assign one leaf child Work ${work.work_id}. ${work.launch_policy === "fresh_agent_required" ? "Use empty, non-inherited context when supported; a new Session id alone does not prove empty context." : "Inherit context only through the harness's declared mechanism."}\nIts first technical action must be this exact standalone command:\n${work.start_command}\nUse only the Work packet returned by it. Complete its declared result and exact work finish command, then stop. Do not create children or start another Work. Missing facts go through the Work's failure/result contract; only the coordinator handles Stage HITL.`;
73
- })
74
- ].join("\n\n"));
67
+ for (const work of wave)
68
+ if (!work.work_id || !work.start_command)
69
+ throw new AppError("fanout_contract_invalid", "Ready Work omits its trusted start command", 1);
70
+ return prompt(renderDelegationInstructions({ harness: input.harness, stage: input.stage, capacity, works: wave }));
75
71
  }
76
72
  if (fanout.works.running)
77
73
  return { kind: "wait", reason: "work_receipts_pending" };
78
74
  if (fanout.works.created)
79
75
  throw new AppError("fanout_graph_stalled", "Created Work has no ready or running dependency path", 1);
80
- return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. Execute an exact repair command returned by a rejected stage finish; a rejection alone does not create repair Work. Return after finish or after declaring a repair graph. Do not start a successor Stage.`);
76
+ return prompt(`All declared ${input.stage} child Work has settled (${fanout.works.completed} completed). Read the accepted result receipts and continue this Stage's semantic decision under its original packet. A rejected stage finish may return either a repair creation command or an already-registered repair Work. Execute only a returned repair creation command, then return. If it reports repair_required with an existing repair Work/start_command, do not invoke that start_command in this coordinator Session: return immediately so the controller can launch a fresh child. Do not start a successor Stage.`);
81
77
  }
82
78
  /** Normalize provider-observed topology only; never parse assistant prose. */
83
79
  export function controllerNativeChildren(receipt, rootSessionId) {
@@ -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,44 +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));
22
23
  if (projectRecoveryGuarded(context, project.id))
23
24
  throw new AppError("run_recovery_guarded", "Project dashboard is frozen while its RUN is retained for recovery", 1);
24
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);
25
41
  if (format === "html") {
26
42
  if (input.protocol) {
27
43
  return renderProtocolDashboardHtml(context, project, input.protocol, input.output);
28
44
  }
29
45
  return renderProjectDashboardHtml(context, project, input.output);
30
46
  }
31
- const config = readProjectConfig(context, project.id);
32
- const output = input.output
33
- ? path.resolve(path.isAbsolute(input.output) ? input.output : path.join(project.root, input.output))
34
- : dashboardMarkdownPath(project.root, config);
35
47
  const markdown = renderProjectDashboardMarkdown(context, project, output);
36
48
  writeMarkdown(output, markdown);
37
49
  return { ok: true, project_root: project.root, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
38
50
  }
39
- export function renderGlobalDashboard(context, input = {}) {
51
+ export function prepareGlobalDashboardRender(context, input = {}) {
40
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);
41
61
  if (format === "html") {
42
62
  return renderGlobalDashboardHtml(context, input.output);
43
63
  }
44
- const output = input.output
45
- ? path.resolve(input.output)
46
- : globalDashboardMarkdownPath(context);
47
64
  const markdown = renderGlobalDashboardMarkdown(context, output);
48
65
  writeMarkdown(output, markdown);
49
66
  return { ok: true, dashboard: { path: output, bytes: Buffer.byteLength(markdown) } };
50
67
  }
51
68
  export function openDashboard(context, input) {
69
+ prepareDashboardOptions(input);
52
70
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
53
71
  const config = readProjectConfig(context, project.id);
54
72
  const format = parseDashboardFormat(input.format, "html");
55
- if (input.viewer && input.viewer !== "cmux") {
56
- throw new AppError("validation", "--viewer must be cmux", 2);
57
- }
58
73
  const dashboardPath = format === "html" ? projectDashboardHtmlPath(context, project.id) : dashboardMarkdownPath(project.root, config);
59
74
  return {
60
75
  ok: true,
@@ -64,9 +79,7 @@ export function openDashboard(context, input) {
64
79
  };
65
80
  }
66
81
  export function openGlobalDashboard(context, input = {}) {
67
- if (input.viewer && input.viewer !== "cmux") {
68
- throw new AppError("validation", "--viewer must be cmux", 2);
69
- }
82
+ prepareDashboardOptions(input);
70
83
  const format = parseDashboardFormat(input.format, "html");
71
84
  const rendered = renderGlobalDashboard(context, { format });
72
85
  return {
@@ -76,9 +89,7 @@ export function openGlobalDashboard(context, input = {}) {
76
89
  };
77
90
  }
78
91
  export function openProtocolDashboard(context, input) {
79
- if (input.viewer && input.viewer !== "cmux") {
80
- throw new AppError("validation", "--viewer must be cmux", 2);
81
- }
92
+ prepareDashboardOptions(input);
82
93
  const format = parseDashboardFormat(input.format, "html");
83
94
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
84
95
  if (format !== "html") {
@@ -95,14 +106,40 @@ export function openProtocolDashboard(context, input) {
95
106
  open: openCmuxDashboard(context, project.root, rendered.dashboard.path, format)
96
107
  };
97
108
  }
98
- export function refreshDashboard(context, input) {
109
+ export function prepareDashboardRefresh(context, input) {
110
+ prepareDashboardOptions(input);
99
111
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
100
112
  const config = readProjectConfig(context, project.id);
101
113
  const format = parseDashboardFormat(input.format);
102
114
  const openMode = input.open ?? "auto";
103
- if (!["auto", "true", "false"].includes(openMode)) {
104
- 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);
105
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);
106
143
  if (!config.dashboard.project) {
107
144
  return {
108
145
  ok: true,
@@ -145,8 +182,9 @@ export function refreshAllDashboards(context, input = {}) {
145
182
  return { ok: true, project_id: project.id, project_root: project.root, skipped: true, reason: "dashboard_project_disabled" };
146
183
  }
147
184
  try {
148
- const projectSummary = publishProjectSummary(context, { project });
185
+ prepareRefreshOutputs(context, project, { ...config, dashboard: { ...config.dashboard, global: false } }, format);
149
186
  const rendered = renderDashboard(context, { projectRoot: project.root, format });
187
+ const projectSummary = publishProjectSummary(context, { project });
150
188
  return { ok: true, project_id: project.id, project_root: project.root, dashboard: rendered.dashboard, project_summary: projectSummary };
151
189
  }
152
190
  catch (error) {
@@ -180,6 +218,7 @@ export function autoRefreshDashboards(context, input) {
180
218
  if (!config.dashboard.auto_refresh) {
181
219
  return { ok: true, skipped: true, reason: "dashboard_auto_refresh_disabled" };
182
220
  }
221
+ prepareRefreshOutputs(context, project, config, "markdown");
183
222
  const result = { ok: true, project_root: project.root };
184
223
  if (config.dashboard.project) {
185
224
  publishProjectSummary(context, { project });
@@ -291,6 +330,14 @@ function detectCmux(context) {
291
330
  ? { available: true, source: "path", version: result.stdout.trim() }
292
331
  : { available: false, source: "path", reason: result.error?.message ?? result.stderr.trim() ?? "cmux not found" };
293
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
+ }
294
341
  function parseDashboardFormat(value, defaultFormat = "markdown") {
295
342
  const format = value ?? defaultFormat;
296
343
  if (format !== "markdown" && format !== "html") {
@@ -303,15 +350,18 @@ function renderProjectDashboardHtml(context, project, output) {
303
350
  ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
304
351
  : projectDashboardHtmlPath(context, project.id);
305
352
  const jsonPath = projectDashboardJsonPath(context, project.id);
353
+ prepareOutputFile(htmlPath);
354
+ prepareOutputFile(jsonPath);
306
355
  const data = buildProjectDashboardData(context, project, htmlPath);
307
356
  const html = renderDashboardHtmlPage(data);
308
- writeJsonFile(jsonPath, data);
309
- writeTextFile(htmlPath, html);
310
357
  const protocolCards = Array.isArray(data.protocol_cards) ? data.protocol_cards : [];
311
- const protocolPages = protocolCards
358
+ const preparedPages = protocolCards
312
359
  .filter((card) => card.generate_page !== false)
313
360
  .slice(0, 24)
314
- .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)
315
365
  .map((result) => ({
316
366
  id: String(result.protocol_id),
317
367
  path: result.dashboard.path,
@@ -330,12 +380,23 @@ function renderProjectDashboardHtml(context, project, output) {
330
380
  };
331
381
  }
332
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);
333
388
  const htmlPath = output
334
389
  ? path.resolve(path.isAbsolute(output) ? output : path.join(project.root, output))
335
390
  : protocolDashboardHtmlPath(context, project.id, protocolId);
336
391
  const jsonPath = protocolDashboardJsonPath(context, project.id, protocolId);
392
+ prepareOutputFile(htmlPath);
393
+ prepareOutputFile(jsonPath);
337
394
  const data = buildProtocolDashboardData(context, project, protocolId, htmlPath);
338
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;
339
400
  writeJsonFile(jsonPath, data);
340
401
  writeTextFile(htmlPath, html);
341
402
  return {
@@ -353,6 +414,8 @@ function renderProtocolDashboardHtml(context, project, protocolId, output) {
353
414
  function renderGlobalDashboardHtml(context, output) {
354
415
  const htmlPath = output ? path.resolve(output) : globalDashboardHtmlPath(context);
355
416
  const jsonPath = globalDashboardJsonPath(context);
417
+ prepareOutputFile(htmlPath);
418
+ prepareOutputFile(jsonPath);
356
419
  const data = buildGlobalDashboardData(context, htmlPath);
357
420
  const html = renderDashboardHtmlPage(data);
358
421
  writeJsonFile(jsonPath, data);
@@ -897,12 +960,12 @@ function protocolsForProject(context, projectId) {
897
960
  FROM protocols WHERE project_id = ? ORDER BY updated_at DESC`, [projectId]);
898
961
  }
899
962
  function planSummaryForProtocol(context, projectId, protocolId) {
900
- 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]);
901
964
  if (!protocol) {
902
965
  return { plan_id: "none", total: 0, done: 0, blocked: 0 };
903
966
  }
904
967
  try {
905
- 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 });
906
969
  return { plan_id: summary.plan_id, total: summary.total, done: summary.done, blocked: summary.blocked };
907
970
  }
908
971
  catch {
@@ -912,13 +975,13 @@ function planSummaryForProtocol(context, projectId, protocolId) {
912
975
  function activePlanItems(context, projectId, protocols) {
913
976
  const items = [];
914
977
  for (const protocol of protocols) {
915
- 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]);
916
979
  if (!record) {
917
980
  continue;
918
981
  }
919
982
  let plan;
920
983
  try {
921
- 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;
922
985
  }
923
986
  catch {
924
987
  continue;