@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
@@ -4,7 +4,7 @@ import { appendAudit } from "./audit.js";
4
4
  import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
5
5
  import { requireProjectByRoot } from "./projects.js";
6
6
  import { resolveProjectRoot } from "../storage/paths.js";
7
- import { planSummary, planWithProgress, updatePlanProgress } from "./plan-runtime.js";
7
+ import { planSummary, planWithProgress, prepareCanonicalPlan, updatePlanProgress } from "./plan-runtime.js";
8
8
  export function getPlanStatus(context, input) {
9
9
  const protocol = scopedProtocol(context, input.projectRoot, input.protocolId);
10
10
  const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, { bind: false });
@@ -20,40 +20,43 @@ export function getPlanStatus(context, input) {
20
20
  };
21
21
  }
22
22
  export function startPlanItem(context, input) {
23
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
24
- assertDependenciesClosed(plan, item);
25
- return { ...item, status: "in_progress", summary: item.summary || "Started." };
26
- });
23
+ return updatePlanItem(context, { ...input, action: "start" });
27
24
  }
28
25
  export function completePlanItem(context, input) {
29
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item, plan) => {
30
- assertDependenciesClosed(plan, item);
31
- return { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] };
32
- });
26
+ return updatePlanItem(context, { ...input, action: "done" });
33
27
  }
34
28
  export function blockPlanItem(context, input) {
35
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
36
- ...item,
37
- status: "blocked",
38
- summary: input.reason,
39
- block_reason: input.reason,
40
- user_required: input.userRequired
41
- }));
29
+ return updatePlanItem(context, { ...input, action: "block" });
42
30
  }
43
31
  export function skipPlanItem(context, input) {
44
- return updatePlanItem(context, input.projectRoot, input.protocolId, input.itemId, (item) => ({
45
- ...item,
46
- status: "skipped",
47
- summary: input.reason
48
- }));
32
+ return updatePlanItem(context, { ...input, action: "skip" });
49
33
  }
50
- function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
34
+ export function preparePlanItemMutation(context, input) {
35
+ const { projectRoot, protocolId, itemId } = input;
36
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
37
+ if (!text(itemId) || !["start", "done", "block", "skip"].includes(input.action) ||
38
+ (input.action === "done" && (!text(input.summary) || !Array.isArray(input.evidence) || !input.evidence.every(text))) ||
39
+ ((input.action === "block" || input.action === "skip") && !text(input.reason)) ||
40
+ (input.action === "block" && typeof input.userRequired !== "boolean")) {
41
+ throw new AppError("validation", "Plan item requires a valid action, item ID, nonempty summary/reason, text evidence and boolean userRequired", 2, { phase: "prepare", effect: "no_effect", recoverable: true });
42
+ }
51
43
  const protocol = scopedProtocol(context, projectRoot, protocolId);
52
- const current = planWithProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path });
44
+ const scope = { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path };
45
+ const current = planWithProgress(context, scope, { bind: false });
46
+ prepareCanonicalPlan(context, scope, current.canonical);
53
47
  const item = current.plan.items.find((candidate) => candidate.id === itemId);
54
48
  if (!item)
55
49
  throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
56
- const nextItem = transform(item, current.plan);
50
+ if (input.action === "start" || input.action === "done")
51
+ assertDependenciesClosed(current.plan, item);
52
+ const nextItem = input.action === "start" ? { ...item, status: "in_progress", summary: item.summary || "Started." }
53
+ : input.action === "done" ? { ...item, status: "done", summary: input.summary, evidence: [...new Set([...item.evidence, ...input.evidence])] }
54
+ : input.action === "block" ? { ...item, status: "blocked", summary: input.reason, block_reason: input.reason, user_required: input.userRequired }
55
+ : { ...item, status: "skipped", summary: input.reason };
56
+ return { protocol, item, nextItem };
57
+ }
58
+ export function updatePlanItem(context, input) {
59
+ const { protocol, item, nextItem } = preparePlanItemMutation(context, input);
57
60
  updatePlanProgress(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path, item: nextItem });
58
61
  if (nextItem.status === "done") {
59
62
  context.db.run("UPDATE flow_jobs SET status = 'done', finished_at = COALESCE(finished_at, ?), updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_item_id = ?", [context.now(), context.now(), protocol.project_id, protocol.id, nextItem.id]);
@@ -63,7 +66,7 @@ function updatePlanItem(context, projectRoot, protocolId, itemId, transform) {
63
66
  protocolId: protocol.id,
64
67
  projectId: protocol.project_id,
65
68
  eventType: "plan.progress.updated",
66
- payload: { protocol_id: protocol.id, item_id: itemId, from: item.status, to: nextItem.status }
69
+ payload: { protocol_id: protocol.id, item_id: input.itemId, from: item.status, to: nextItem.status }
67
70
  });
68
71
  return { ok: true, protocol_id: protocol.id, item: nextItem, plan: planSummary(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }) };
69
72
  }
@@ -37,8 +37,13 @@ export function registerProject(context, input) {
37
37
  }
38
38
  return { ok: true, project: { ...project, status: "active", updated_at: now }, db_path: context.db.path };
39
39
  }
40
- export function archiveProject(context, input) {
41
- const reason = input.reason.trim();
40
+ export function prepareProjectArchive(context, input) {
41
+ if (Boolean(input.idOrAlias) === Boolean(input.root))
42
+ throw new AppError("usage", "Project archive requires exactly one project ID/alias or --root", 2);
43
+ for (const value of [input.idOrAlias, input.root])
44
+ if (value !== undefined && (typeof value !== "string" || !value.trim()))
45
+ throw new AppError("validation", "Project archive target must be a non-empty string", 2);
46
+ const reason = typeof input.reason === "string" ? input.reason.trim() : "";
42
47
  if (!reason) {
43
48
  throw new AppError("validation", "project archive requires --reason", 2);
44
49
  }
@@ -50,6 +55,10 @@ export function archiveProject(context, input) {
50
55
  if (!project) {
51
56
  throw new AppError("not_found", "Project is not registered.", 1, { root: input.root, input: input.idOrAlias });
52
57
  }
58
+ return { project, reason };
59
+ }
60
+ export function archiveProject(context, input) {
61
+ const { project, reason } = prepareProjectArchive(context, input);
53
62
  const now = context.now();
54
63
  const result = context.db.run("UPDATE projects SET status = 'archived', updated_at = ? WHERE id = ? AND status <> 'archived'", [now, project.id]);
55
64
  if (result.changes === 1) {
@@ -9,7 +9,8 @@ import { validatePlanItem } from "../domain/validation.js";
9
9
  import { AppError } from "../shared/errors.js";
10
10
  import { parseJsonObject } from "../shared/json.js";
11
11
  import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
12
- import { planWithProgress } from "./plan-runtime.js";
12
+ import { boundCanonicalPlan, planWithProgress, prepareCanonicalPlan } from "./plan-runtime.js";
13
+ import { prepareTextFile } from "../cli/input-preparation.js";
13
14
  import { registerFlowJob } from "./sessions.js";
14
15
  const profiles = {
15
16
  code_implementation: {
@@ -22,7 +23,7 @@ const profiles = {
22
23
  static_files: [".memory-bank/dd-flow/common/worker-session.md", ".memory-bank/dd-flow/workers/verify.md"]
23
24
  }
24
25
  };
25
- export function renderWorkerPrompt(context, input) {
26
+ export function prepareWorkerPrompt(context, input) {
26
27
  const projectRoot = resolveProjectRoot(input.projectRoot);
27
28
  const project = requireProjectByRoot(context, projectRoot);
28
29
  const protocol = requireProtocol(context, protocolIdForRun(context, project.id, input.runId), project.id);
@@ -43,8 +44,10 @@ export function renderWorkerPrompt(context, input) {
43
44
  projectId: protocol.project_id,
44
45
  protocolId: protocol.id,
45
46
  planPath: protocol.plan_path
46
- });
47
+ }, { bind: false });
47
48
  const plan = planRuntime?.plan ?? null;
49
+ if (planRuntime)
50
+ prepareCanonicalPlan(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, planRuntime.canonical);
48
51
  const genericTask = input.taskFile ? readWorkerTask(input.taskFile, runHomePath(run)) : null;
49
52
  const item = genericTask?.item ?? plan?.items.find((candidate) => candidate.id === input.planItemId);
50
53
  if (!item)
@@ -64,13 +67,6 @@ export function renderWorkerPrompt(context, input) {
64
67
  const profile = profiles[input.profile];
65
68
  if (!profile)
66
69
  throw new AppError("prompt_profile_unsupported", `Unsupported prompt profile: ${input.profile}`, 2);
67
- const job = registerFlowJob(context, {
68
- projectId: protocol.project_id,
69
- runId: run.id,
70
- protocolId: protocol.id,
71
- planItemId: item.id,
72
- groupId: item.execution_context.planned_write_areas[0] ?? null
73
- });
74
70
  const staticInputs = profile.static_files.map((file) => readStaticInput(projectRoot, file));
75
71
  const requiredRead = item.execution_context.required_read.map((file) => checkedReference(projectRoot, runHomePath(run), file, "required_read", true));
76
72
  const discoveryBoundary = item.execution_context.discovery_boundary.map((file) => checkedReference(projectRoot, runHomePath(run), file, "discovery_boundary", false));
@@ -78,7 +74,6 @@ export function renderWorkerPrompt(context, input) {
78
74
  const runHome = runHomePath(run);
79
75
  const outputDir = path.join(runHome, stage.dir, "subagents", item.id);
80
76
  assertWithin(runHome, outputDir, "output directory");
81
- ensureDir(outputDir);
82
77
  const prompt = renderPrompt({
83
78
  item,
84
79
  input,
@@ -90,6 +85,20 @@ export function renderWorkerPrompt(context, input) {
90
85
  staticInputs,
91
86
  ...(genericTask ? { handoff: genericTask.handoff } : {})
92
87
  });
88
+ return { protocol, run, item, plan, planRuntime, genericTask, workspaceRoot, staticInputs, requiredRead, discoveryBoundary, plannedWriteAreas, outputDir, prompt, canonVersion: readCanonVersion(projectRoot) };
89
+ }
90
+ export function renderWorkerPrompt(context, input, prepared = prepareWorkerPrompt(context, input)) {
91
+ const { protocol, run, item, plan, planRuntime, genericTask, workspaceRoot, staticInputs, requiredRead, discoveryBoundary, plannedWriteAreas, outputDir, prompt, canonVersion } = prepared;
92
+ if (planRuntime)
93
+ boundCanonicalPlan(context, { projectId: protocol.project_id, protocolId: protocol.id, planPath: protocol.plan_path }, planRuntime.canonical);
94
+ const job = registerFlowJob(context, {
95
+ projectId: protocol.project_id,
96
+ runId: run.id,
97
+ protocolId: protocol.id,
98
+ planItemId: item.id,
99
+ groupId: item.execution_context.planned_write_areas[0] ?? null
100
+ });
101
+ ensureDir(outputDir);
93
102
  const promptPath = path.join(outputDir, "launch-prompt.md");
94
103
  const stackPath = path.join(outputDir, "prompt-stack.json");
95
104
  const reportPath = path.join(outputDir, "render-report.json");
@@ -104,7 +113,7 @@ export function renderWorkerPrompt(context, input) {
104
113
  plan_item_id: input.taskFile ? null : item.id,
105
114
  task_id: input.taskFile ? item.id : null,
106
115
  job_id: job.job_id,
107
- canon_version: readCanonVersion(projectRoot),
116
+ canon_version: canonVersion,
108
117
  renderer_version: getCliVersionReport().cli.version,
109
118
  static_inputs: staticInputs.map(({ path: inputPath, sha256 }) => ({ path: inputPath, sha256 })),
110
119
  validation: { required_read: requiredRead, discovery_boundary: discoveryBoundary, planned_write_areas: plannedWriteAreas },
@@ -143,9 +152,7 @@ function runHomePath(run) {
143
152
  function readWorkerTask(taskFile, runHome) {
144
153
  const resolved = path.resolve(taskFile);
145
154
  assertWithin(runHome, resolved, "worker task file");
146
- if (!fs.existsSync(resolved))
147
- throw new AppError("not_found", "Worker task file is missing", 1, { path: taskFile });
148
- const task = parseJsonObject(fs.readFileSync(resolved, "utf8"), resolved);
155
+ const task = parseJsonObject(prepareTextFile(resolved, "worker task").text, resolved);
149
156
  if (task.schema_id !== "dd-flow/worker-task@1" || !task.task || typeof task.task !== "object") {
150
157
  throw new AppError("validation", "Worker task must use dd-flow/worker-task@1 with a task object", 2, { path: taskFile });
151
158
  }
@@ -180,7 +187,7 @@ function validateWorkerTaskHandoff(value, runHome) {
180
187
  const reportPath = path.resolve(report.report_path);
181
188
  assertWithin(runHome, reportPath, "predecessor report");
182
189
  if (!fs.existsSync(reportPath))
183
- throw new AppError("not_found", "Accepted predecessor report is missing", 1, { path: report.report_path });
190
+ throw new AppError("runtime_artifact_missing", "Accepted predecessor report is missing", 1, { path: report.report_path });
184
191
  return { aspect_id: report.aspect_id, verdict, report_path: reportPath };
185
192
  });
186
193
  const recovery = handoff.recovery_attempt_paths ?? [];
@@ -6,9 +6,8 @@ import { canTransition } from "../domain/contracts.js";
6
6
  import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
7
7
  import { requireStage } from "../domain/validation.js";
8
8
  import { AppError } from "../shared/errors.js";
9
- import { parseJsonObject } from "../shared/json.js";
10
- import { ensureReadableFile } from "../storage/database.js";
11
- import { ensureDir, planJsonPath, resolveProjectRoot, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
9
+ import { prepareJsonFile } from "../cli/input-preparation.js";
10
+ import { ensureDir, planJsonPath, resolveProjectRoot, resolveProtocolPlanPath, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
12
11
  import { appendAudit, getAuditEvents } from "./audit.js";
13
12
  import { requireProjectByRoot } from "./projects.js";
14
13
  import { ensureRuntimeProtocolFiles, readStateFile, writeState } from "../protocol/local-files.js";
@@ -17,14 +16,20 @@ import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProje
17
16
  import { buildProtocolFlowGuidance } from "./flow-guidance.js";
18
17
  import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
19
18
  import { getProtocolBranchContext } from "./branch-context.js";
20
- export function registerProtocol(context, input) {
19
+ export function prepareProtocolRegistration(context, input) {
20
+ if (typeof input.handshakeId !== "string" || !input.handshakeId.trim() || /[/\\\0]/.test(input.handshakeId) || [".", ".."].includes(input.handshakeId))
21
+ throw new AppError("validation", "Protocol handshake must be a non-empty identifier, not a path", 2);
21
22
  const projectRoot = resolveProjectRoot(input.projectRoot);
22
23
  const project = requireProjectByRoot(context, projectRoot);
23
- const now = context.now();
24
24
  const workspacePath = input.workspacePath ? resolveProjectRoot(input.workspacePath) : undefined;
25
25
  const idRoot = workspacePath ?? projectRoot;
26
26
  const protocolId = inferProtocolId(idRoot, input.handshakeId);
27
27
  const runtimeDir = runtimeProtocolDir(context.ddFlowHome, project.id, protocolId);
28
+ return { projectRoot, project, workspacePath, protocolId, runtimeDir };
29
+ }
30
+ export function registerProtocol(context, input) {
31
+ const { projectRoot, project, workspacePath, protocolId, runtimeDir } = prepareProtocolRegistration(context, input);
32
+ const now = context.now();
28
33
  const state = ensureRuntimeProtocolFiles({
29
34
  runtimeDir,
30
35
  protocolId,
@@ -243,7 +248,22 @@ export function implementProtocol(context, input) {
243
248
  }
244
249
  };
245
250
  }
246
- export function transitionProtocol(context, input) {
251
+ export function prepareProtocolTransition(context, input) {
252
+ const { value } = prepareJsonFile(input.jsonFile, "Protocol transition payload");
253
+ if (!value || typeof value !== "object" || Array.isArray(value))
254
+ throw new AppError("validation", "Protocol transition payload must be an object", 2);
255
+ const payload = value;
256
+ for (const [key, field] of Object.entries(payload)) {
257
+ const valid = key === "next_action" ? typeof field === "string"
258
+ : ["route", "workspace"].includes(key) ? Boolean(field && typeof field === "object" && !Array.isArray(field))
259
+ : ["blockers", "active_def"].includes(key) && Array.isArray(field);
260
+ if (!valid)
261
+ throw new AppError("validation", `Unsupported field or invalid value in protocol transition: ${key}`, 2, { file: input.jsonFile, parameter: key });
262
+ }
263
+ protocolTransitionState(context, input);
264
+ return payload;
265
+ }
266
+ function protocolTransitionState(context, input) {
247
267
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
248
268
  const protocol = requireProtocol(context, input.protocolId, project.id);
249
269
  const state = readProtocolRuntimeState(context, protocol).state;
@@ -256,8 +276,10 @@ export function transitionProtocol(context, input) {
256
276
  if (input.force && (!input.reason || input.reason.trim().length === 0)) {
257
277
  throw new AppError("validation", "Forced transitions require --reason", 2);
258
278
  }
259
- ensureReadableFile(input.jsonFile);
260
- const payload = parseJsonObject(fs.readFileSync(input.jsonFile, "utf8"), input.jsonFile);
279
+ return { project, protocol, state, flowContract, from, to };
280
+ }
281
+ export function transitionProtocol(context, input, payload = prepareProtocolTransition(context, input)) {
282
+ const { project, protocol, state, flowContract, from, to } = protocolTransitionState(context, input);
261
283
  const nextState = {
262
284
  ...state,
263
285
  stage: to,
@@ -579,7 +601,10 @@ export function requireProtocol(context, protocolId, projectId) {
579
601
  candidates: matches.map((protocol) => ({ project_id: protocol.project_id, project_root: protocol.project_root }))
580
602
  });
581
603
  }
582
- return matches[0];
604
+ return resolvedProtocolRecord(matches[0]);
605
+ }
606
+ function resolvedProtocolRecord(protocol) {
607
+ return { ...protocol, plan_path: resolveProtocolPlanPath(protocol.project_root, protocol.id, protocol.plan_path) };
583
608
  }
584
609
  function cancelQueueForProtocol(context, protocol, reason, force) {
585
610
  const job = context.db.get("SELECT id, status, claimed_by_session_id FROM merge_queue WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
@@ -736,12 +761,20 @@ export function readProtocolRuntimeState(context, protocol) {
736
761
  const expected = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
737
762
  if (protocol.state_path !== expected)
738
763
  throw new AppError("protocol_state_migration_required", "Protocol runtime path requires explicit cleanup scan/apply before use", 1, { protocol_id: protocol.id, path: protocol.state_path, expected_path: expected });
739
- return repairProtocolRuntimeState(context, protocol);
764
+ return resolveProtocolRuntimeState(context, { ...protocol }, false);
740
765
  }
741
766
  /** Explicit cleanup migration; productive readers accept only the current path. */
742
767
  export function repairProtocolRuntimeState(context, protocol) {
768
+ // Readers project the canonical plan path without migrating storage. Repair
769
+ // must start from the stored locations, not that already-normalized view.
770
+ const stored = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [protocol.project_id, protocol.id]);
771
+ if (stored)
772
+ Object.assign(protocol, stored);
773
+ return resolveProtocolRuntimeState(context, protocol, true);
774
+ }
775
+ function resolveProtocolRuntimeState(context, protocol, repair) {
743
776
  const diagnostics = [];
744
- normalizeProtocolPlanPath(context, protocol, diagnostics);
777
+ normalizeProtocolPlanPath(context, protocol, diagnostics, repair);
745
778
  try {
746
779
  if (protocol.state_path !== runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id))
747
780
  throw new AppError("protocol_state_migration_required", "Retired state files are not runtime authority", 1);
@@ -759,7 +792,8 @@ export function repairProtocolRuntimeState(context, protocol) {
759
792
  recommended_action: "legacy protocol state_path was moved to stable runtime storage"
760
793
  });
761
794
  const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
762
- context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
795
+ if (repair && context.db.writable)
796
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
763
797
  protocol.state_path = stableStatePath;
764
798
  protocol.plan_path = stablePlanPath;
765
799
  return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
@@ -792,7 +826,7 @@ export function repairProtocolRuntimeState(context, protocol) {
792
826
  flow_contract: flowContract,
793
827
  updated_at: protocol.updated_at
794
828
  };
795
- if (!context.db.writable) {
829
+ if (!repair || !context.db.writable) {
796
830
  return { state, diagnostics };
797
831
  }
798
832
  const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
@@ -811,9 +845,9 @@ export function repairProtocolRuntimeState(context, protocol) {
811
845
  });
812
846
  return { state, diagnostics };
813
847
  }
814
- function normalizeProtocolPlanPath(context, protocol, diagnostics) {
815
- const canonicalPlanPath = planJsonPath(protocol.project_root, protocol.id);
816
- if (protocol.plan_path === canonicalPlanPath || !fs.existsSync(canonicalPlanPath))
848
+ function normalizeProtocolPlanPath(context, protocol, diagnostics, repair) {
849
+ const canonicalPlanPath = resolveProtocolPlanPath(protocol.project_root, protocol.id, protocol.plan_path);
850
+ if (protocol.plan_path === canonicalPlanPath)
817
851
  return;
818
852
  diagnostics.push({
819
853
  code: "protocol_plan_relocated",
@@ -823,7 +857,7 @@ function normalizeProtocolPlanPath(context, protocol, diagnostics) {
823
857
  source: "project_canonical_plan",
824
858
  recommended_action: "use the project canonical plan as the sole semantic plan source"
825
859
  });
826
- if (context.db.writable) {
860
+ if (repair && context.db.writable) {
827
861
  context.db.run("UPDATE protocols SET plan_path = ?, updated_at = ? WHERE project_id = ? AND id = ?", [canonicalPlanPath, context.now(), protocol.project_id, protocol.id]);
828
862
  }
829
863
  protocol.plan_path = canonicalPlanPath;
@@ -884,10 +918,11 @@ export function persistProtocolState(context, protocol, state) {
884
918
  }
885
919
  function findProtocol(context, protocolId, projectId) {
886
920
  if (projectId) {
887
- return context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
921
+ const protocol = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
922
+ return protocol ? resolvedProtocolRecord(protocol) : undefined;
888
923
  }
889
924
  const matches = context.db.all("SELECT * FROM protocols WHERE id = ? ORDER BY project_id", [protocolId]);
890
- return matches.length === 1 ? matches[0] : undefined;
925
+ return matches.length === 1 ? resolvedProtocolRecord(matches[0]) : undefined;
891
926
  }
892
927
  function protocolDocumentsForProject(projectRoot) {
893
928
  const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
@@ -15,7 +15,7 @@ export const recoveryRpcTimeout = (maximum) => {
15
15
  /** Call only while holding the existing owner-claim transaction. A replay of
16
16
  * an explicit request never replenishes time, even after another request. */
17
17
  export function renewRecoveryObservation(snapshot, requestId) {
18
- if (!requestId.trim() || requestId.length > 200)
18
+ if (typeof requestId !== "string" || !requestId.trim() || requestId.length > 200)
19
19
  throw new AppError("usage", "Reconciliation requires a nonempty request ID of at most 200 characters", 2);
20
20
  const previous = snapshot.recovery_observation;
21
21
  const ids = previous?.reconciliation_ids ?? [];
@@ -103,7 +103,7 @@ async function recoverCompletedOperation(context, manifest, session, executable,
103
103
  // is reconciled by run-control after the owner is fenced; probing it from the
104
104
  // failing transport path would be a second adapter invocation and can itself
105
105
  // be mistaken for replay.
106
- if (args[0] === "session" && args[1] === "create")
106
+ if (args[0] === "session" && ["create", "inspect", "status"].includes(args[1]))
107
107
  return null;
108
108
  // A detached adapter without a native daemon has no durable operation
109
109
  // journal to inspect. Avoid a second adapter invocation in that case; the
@@ -131,6 +131,10 @@ async function awaitSettlement(context, row, manifest, session, executable, args
131
131
  while (["pending", "blocked"].includes(String(settlement(current)?.state))) {
132
132
  if (signal?.aborted)
133
133
  throw new AppError("harness_settlement_interrupted", "Harness settlement was interrupted", 1, { operation_id: operationId });
134
+ // A completed parent with live children is normal controller waiting, not
135
+ // failed bookkeeping. Keep its budget reserved until a later inspection.
136
+ if (args[0] === "session" && ["prompt", "inspect", "status"].includes(args[1]) && settlement(current)?.reason === "tree_unsettled")
137
+ return { ...initial, ...current, ...(initial.assistant_text !== undefined ? { assistant_text: initial.assistant_text } : {}) };
134
138
  if (Date.now() >= deadline)
135
139
  throw new AppError("settlement_recovery_required", "Completed provider operation requires settlement recovery", 1, { operation_id: operationId, settlement: settlement(current) });
136
140
  await new Promise(resolve => setTimeout(resolve, Math.min(delay, Math.max(0, deadline - Date.now()))));
@@ -141,9 +145,14 @@ async function awaitSettlement(context, row, manifest, session, executable, args
141
145
  try {
142
146
  // This only retries durable bookkeeping against the same operation
143
147
  // receipt. It cannot replay the provider request.
144
- current = await runHarnessAdapter({ executable, args: settlementRetryArgs(args, operationId), env: controllerEnvironment(context, manifest, session), operationId: `${operationId}:settlement`, runtimeOwner: session.owner, timeoutMs: Math.min(30_000, Math.max(1, deadline - Date.now())), ...(signal ? { signal } : {}) });
148
+ const inspect = args[0] === "session" && ["inspect", "status"].includes(args[1]);
149
+ current = await runHarnessAdapter({ executable, args: inspect ? settlementInspectArgs(args) : settlementRetryArgs(args, operationId), env: controllerEnvironment(context, manifest, session), operationId: `${operationId}:settlement`, runtimeOwner: session.owner, timeoutMs: Math.min(30_000, Math.max(1, deadline - Date.now())), ...(signal ? { signal } : {}) });
150
+ if (inspect)
151
+ validateReceipt(current, session, args);
145
152
  }
146
153
  catch (error) {
154
+ if (error instanceof AppError && ["session_identity_mismatch", "profile_integrity_violation"].includes(error.code))
155
+ throw error;
147
156
  current = { settlement: { state: "pending", reason: "inspection_error", error: errorRecord(error) } };
148
157
  }
149
158
  if (!(["pending", "blocked"].includes(String(settlement(current)?.state))))
@@ -154,12 +163,15 @@ async function awaitSettlement(context, row, manifest, session, executable, args
154
163
  continue;
155
164
  try {
156
165
  current = await runHarnessAdapter({ executable, args: settlementInspectArgs(args), env: controllerEnvironment(context, manifest, session), operationId: `${operationId}:settlement-inspect`, runtimeOwner: session.owner, timeoutMs: Math.min(30_000, Math.max(1, deadline - Date.now())), ...(signal ? { signal } : {}) });
166
+ validateReceipt(current, session, args);
157
167
  }
158
168
  catch (error) {
169
+ if (error instanceof AppError && ["session_identity_mismatch", "profile_integrity_violation"].includes(error.code))
170
+ throw error;
159
171
  current = { settlement: { state: "pending", reason: "inspection_error", error: errorRecord(error) } };
160
172
  }
161
173
  }
162
- return current;
174
+ return adapterSessionId(current) ? { ...initial, ...current, ...(initial.assistant_text !== undefined ? { assistant_text: initial.assistant_text } : {}) } : { ...initial, settlement: settlement(current) };
163
175
  }
164
176
  export async function controllerAdapter(context, row, manifest, session, args, assertOwner, retainedOperationId, signal) {
165
177
  assertOwner?.();
@@ -173,33 +185,32 @@ export async function controllerAdapter(context, row, manifest, session, args, a
173
185
  appendEvent(context, row.controller_id, "operation_requested", { operation_id: operationId, operation: args.slice(0, 2), session_id: session.id || null });
174
186
  try {
175
187
  assertOwner?.();
176
- let receipt = await runHarnessAdapter({ executable, args, env: controllerEnvironment(context, manifest, session), operationId, runtimeOwner: session.owner, timeoutMs: args[0] === "session" && args[1] === "prompt" ? null : 120_000, ...(signal ? { signal } : {}) });
188
+ let receipt;
189
+ let recoveredOperation = false;
190
+ try {
191
+ receipt = await runHarnessAdapter({ executable, args, env: controllerEnvironment(context, manifest, session), operationId, runtimeOwner: session.owner, timeoutMs: args[0] === "session" && args[1] === "prompt" ? null : 120_000, ...(signal ? { signal } : {}) });
192
+ }
193
+ catch (error) {
194
+ const retained = await recoverCompletedOperation(context, manifest, session, executable, args, operationId, signal);
195
+ if (!retained)
196
+ throw error;
197
+ receipt = retained;
198
+ recoveredOperation = true;
199
+ }
200
+ validateReceipt(receipt, session, args);
201
+ context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
177
202
  if (["pending", "blocked"].includes(String(settlement(receipt)?.state))) {
178
203
  const recovered = await awaitSettlement(context, row, manifest, session, executable, args, operationId, receipt, assertOwner, signal);
179
- receipt = { ...receipt, settlement: settlement(recovered) };
204
+ receipt = recovered;
180
205
  }
181
206
  context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
182
207
  validateReceipt(receipt, session, args);
183
208
  context.db.run("UPDATE run_controller_operations SET status = 'completed', updated_at = ? WHERE operation_id = ?", [context.now(), operationId]);
184
209
  assertOwner?.();
185
- appendEvent(context, row.controller_id, "operation_completed", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
210
+ appendEvent(context, row.controller_id, recoveredOperation ? "operation_recovered" : "operation_completed", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
186
211
  return receipt;
187
212
  }
188
213
  catch (error) {
189
- const retained = await recoverCompletedOperation(context, manifest, session, executable, args, operationId, signal);
190
- if (retained) {
191
- let receipt = retained;
192
- if (["pending", "blocked"].includes(String(settlement(receipt)?.state))) {
193
- const recovered = await awaitSettlement(context, row, manifest, session, executable, args, operationId, receipt, assertOwner, signal);
194
- receipt = { ...receipt, settlement: settlement(recovered) };
195
- }
196
- context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
197
- validateReceipt(receipt, session, args);
198
- context.db.run("UPDATE run_controller_operations SET status = 'completed', error_json = NULL, updated_at = ? WHERE operation_id = ?", [context.now(), operationId]);
199
- assertOwner?.();
200
- appendEvent(context, row.controller_id, "operation_recovered", { operation_id: operationId, operation: args.slice(0, 2), session_id: adapterSessionId(receipt) });
201
- return receipt;
202
- }
203
214
  try {
204
215
  context.db.run("UPDATE run_controller_operations SET status = CASE WHEN status = 'dispatching' THEN 'outcome_unknown' ELSE status END, error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
205
216
  }
@@ -20,6 +20,11 @@ export function controllerReceipt(row) {
20
20
  }
21
21
  export function appendEvent(context, id, type, data) { context.db.run("INSERT INTO run_controller_events (controller_id, event_type, data_json, created_at) VALUES (?, ?, ?, ?)", [id, type, JSON.stringify(data), context.now()]); }
22
22
  export function saveState(context, row, state) { context.db.run("UPDATE run_controllers SET state_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [JSON.stringify(state), context.now(), row.controller_id, row.owner_token]); }
23
+ export function retainControllerContinuation(state, fingerprint, error) {
24
+ if (state.last_continuation === fingerprint)
25
+ throw new AppError(error.code, error.message, 1, error.details);
26
+ state.last_continuation = fingerprint;
27
+ }
23
28
  export function digest(value) { return digestBytes(JSON.stringify(value)); }
24
29
  export function digestBytes(value) { return crypto.createHash("sha256").update(value).digest("hex"); }
25
30
  export function errorRecord(error) {