@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.110

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 (162) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/native-hook-ingress.js +48 -0
  9. package/dist/cli/run-cli.js +1465 -384
  10. package/dist/cli.js +8 -2
  11. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  12. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  13. package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
  14. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  15. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  16. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  17. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  18. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  19. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  20. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  22. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
  23. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  25. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  26. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  27. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  28. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +388 -0
  30. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  31. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  32. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  36. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  37. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  38. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  39. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  40. package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
  41. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  42. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
  43. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  44. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  45. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  46. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  47. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  48. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  49. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  50. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  51. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  52. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  53. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  54. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  55. package/dist/protocol/local-files.js +18 -9
  56. package/dist/runtime/context.js +2 -2
  57. package/dist/schemas/agent-profile.schema.json +1 -1
  58. package/dist/schemas/code-review-result.schema.json +1 -1
  59. package/dist/schemas/code-work-batch.schema.json +3 -3
  60. package/dist/schemas/code-work-result.schema.json +3 -3
  61. package/dist/schemas/plan-review-result.schema.json +2 -2
  62. package/dist/schemas/run-control-receipt.schema.json +99 -0
  63. package/dist/schemas/run-control-request.schema.json +36 -0
  64. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  65. package/dist/services/canon.js +11 -8
  66. package/dist/services/cleanup.js +132 -39
  67. package/dist/services/cli-operation-classifier.js +22 -24
  68. package/dist/services/code-checks.js +589 -106
  69. package/dist/services/codex-hook-delivery.js +28 -0
  70. package/dist/services/command-context.js +80 -0
  71. package/dist/services/config.js +11 -8
  72. package/dist/services/continuation-outcome.js +20 -0
  73. package/dist/services/controller-fanout.js +173 -0
  74. package/dist/services/dashboard.js +103 -31
  75. package/dist/services/engines.js +121 -76
  76. package/dist/services/eval-snapshots.js +994 -97
  77. package/dist/services/execution-policy.js +181 -0
  78. package/dist/services/external-work-launch.js +166 -0
  79. package/dist/services/harness-adapter.js +147 -24
  80. package/dist/services/harness-config.js +2 -0
  81. package/dist/services/hooks.js +702 -327
  82. package/dist/services/lanes.js +78 -65
  83. package/dist/services/lifecycle-command.js +113 -18
  84. package/dist/services/lifecycle-contract.js +30 -0
  85. package/dist/services/lifecycle-invocations.js +1379 -0
  86. package/dist/services/managed-daemon-binding.js +38 -0
  87. package/dist/services/managed-processes.js +302 -34
  88. package/dist/services/merge-queue.js +228 -149
  89. package/dist/services/merge-server.js +66 -28
  90. package/dist/services/migrations.js +37 -16
  91. package/dist/services/native-daemon-history.js +49 -0
  92. package/dist/services/native-session-control.js +39 -0
  93. package/dist/services/plan-runtime.js +26 -11
  94. package/dist/services/plans.js +28 -25
  95. package/dist/services/projects.js +20 -15
  96. package/dist/services/prompts.js +25 -16
  97. package/dist/services/protocols.js +160 -86
  98. package/dist/services/recovery-observation-budget.js +61 -0
  99. package/dist/services/recovery-snapshot-database.js +142 -0
  100. package/dist/services/repair-intents.js +40 -0
  101. package/dist/services/review-copy.js +136 -0
  102. package/dist/services/run-control-receipt.js +56 -0
  103. package/dist/services/run-control-worker.js +351 -0
  104. package/dist/services/run-control.js +888 -0
  105. package/dist/services/run-controller-adapter.js +231 -0
  106. package/dist/services/run-controller-capture.js +167 -0
  107. package/dist/services/run-controller-process.js +180 -0
  108. package/dist/services/run-controller-recovery.js +221 -0
  109. package/dist/services/run-controller-state.js +36 -0
  110. package/dist/services/run-controller.js +798 -0
  111. package/dist/services/run-engine-bindings.js +1 -1
  112. package/dist/services/run-fork.js +142 -0
  113. package/dist/services/run-observations.js +112 -0
  114. package/dist/services/run-projection.js +22 -64
  115. package/dist/services/run-recovery-runtime.js +80 -0
  116. package/dist/services/run-recovery.js +345 -0
  117. package/dist/services/runs.js +536 -181
  118. package/dist/services/runtime-budget.js +413 -0
  119. package/dist/services/runtime-command.js +43 -0
  120. package/dist/services/runtime-scope-capture.js +52 -0
  121. package/dist/services/runtime-scope-control.js +460 -0
  122. package/dist/services/runtime-scope-resume.js +577 -0
  123. package/dist/services/runtime-scope-stop.js +159 -0
  124. package/dist/services/runtime-scope-worker.js +242 -0
  125. package/dist/services/runtime-service.js +97 -0
  126. package/dist/services/schema-validation.js +10 -8
  127. package/dist/services/scope-writer-cleanup.js +26 -0
  128. package/dist/services/sessions.js +35 -69
  129. package/dist/services/stage-blocker.js +17 -7
  130. package/dist/services/stage-context.js +50 -19
  131. package/dist/services/stage-lifecycle.js +100 -103
  132. package/dist/services/stage-pause.js +88 -75
  133. package/dist/services/stage-report-renderer.js +116 -6
  134. package/dist/services/stage-work-graph.js +35 -0
  135. package/dist/services/usage.js +35 -98
  136. package/dist/services/vnext-code-review.js +197 -84
  137. package/dist/services/vnext-code.js +278 -251
  138. package/dist/services/vnext-execution-profile.js +5 -3
  139. package/dist/services/vnext-fanout.js +172 -17
  140. package/dist/services/vnext-merge.js +345 -111
  141. package/dist/services/vnext-plan-review.js +171 -111
  142. package/dist/services/vnext-plan.js +180 -87
  143. package/dist/services/vnext-protocolize.js +320 -74
  144. package/dist/services/vnext-specify.js +115 -77
  145. package/dist/services/work-registry.js +1001 -187
  146. package/dist/services/workspace-bootstrap.js +76 -0
  147. package/dist/services/workspace-files.js +83 -0
  148. package/dist/services/worktrees.js +88 -36
  149. package/dist/shared/entity-references.js +8 -0
  150. package/dist/shared/errors.js +12 -0
  151. package/dist/storage/database.js +692 -35
  152. package/dist/storage/foreign-key-transaction.js +39 -0
  153. package/dist/storage/immutable-input.js +45 -0
  154. package/dist/storage/import-guard.js +102 -0
  155. package/dist/storage/native-cleanup.js +52 -0
  156. package/dist/storage/paths.js +17 -4
  157. package/dist/storage/projection-lock.js +73 -0
  158. package/dist/storage/work-references.js +23 -0
  159. package/dist/storage/writer-contract.js +77 -0
  160. package/dist/storage/writer-migration.js +85 -0
  161. package/package.json +24 -13
  162. package/tools/repair-paused-run-status.mjs +84 -0
@@ -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 ?? [];
@@ -293,6 +300,8 @@ function renderPrompt(input) {
293
300
  `- Acceptance contribution: ${item.semantic_spine?.acceptance_contribution ?? "not_applicable"}`,
294
301
  "",
295
302
  "## Hard Write Boundary",
303
+ "Before editing, change into the project workspace shown below (cd -- WORKSPACE) or use its absolute path when the tool cannot persist cwd.",
304
+ "Write only the project workspace and the named report directory; never write another RUN/native home or Git/worktree control data.",
296
305
  `- All project writes must stay under: \`${input.workspaceRoot}\``,
297
306
  "- Do not write outside this workspace, to another RUN, or into Git/worktree control data.",
298
307
  "- Accepted requirements and non-goals remain binding; planned write areas do not.",
@@ -1,31 +1,38 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
  import { spawnSync } from "node:child_process";
4
5
  import { parse as parseYaml } from "yaml";
5
6
  import { canTransition } from "../domain/contracts.js";
6
7
  import { defaultFlowContract, flowContractForState, loadProjectFlowContract } from "../domain/flow-contract.js";
7
8
  import { requireStage } from "../domain/validation.js";
8
9
  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";
10
+ import { prepareJsonFile } from "../cli/input-preparation.js";
11
+ import { ensureDir, planJsonPath, resolveProjectRoot, resolveProtocolPlanPath, runtimeProtocolDir, runtimeStateJsonPath } from "../storage/paths.js";
12
12
  import { appendAudit, getAuditEvents } from "./audit.js";
13
+ import { withProjectionLock } from "../storage/projection-lock.js";
13
14
  import { requireProjectByRoot } from "./projects.js";
14
- import { ensureRuntimeProtocolFiles, readStateFile, writeState } from "../protocol/local-files.js";
15
+ import { prepareRuntimeProtocolFiles, publishRuntimeProtocolMetadata, readStateFile, writeState } from "../protocol/local-files.js";
15
16
  import { planSummary, planWithProgress, readCanonicalPlan } from "./plan-runtime.js";
16
17
  import { activeCodexSessionBindingsForProject, activeFlowSessionBindingsForProject, codexHomeProfilesForProject, codexHookEventsForProject, hookStatusForProject } from "./hooks.js";
17
18
  import { buildProtocolFlowGuidance } from "./flow-guidance.js";
18
19
  import { lifecycleIsTerminalFailure, lifecycleIsTerminalSuccess, normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
19
20
  import { getProtocolBranchContext } from "./branch-context.js";
20
- export function registerProtocol(context, input) {
21
+ export function prepareProtocolRegistration(context, input) {
22
+ if (typeof input.handshakeId !== "string" || !input.handshakeId.trim() || /[/\\\0]/.test(input.handshakeId) || [".", ".."].includes(input.handshakeId))
23
+ throw new AppError("validation", "Protocol handshake must be a non-empty identifier, not a path", 2);
21
24
  const projectRoot = resolveProjectRoot(input.projectRoot);
22
25
  const project = requireProjectByRoot(context, projectRoot);
23
- const now = context.now();
24
26
  const workspacePath = input.workspacePath ? resolveProjectRoot(input.workspacePath) : undefined;
25
27
  const idRoot = workspacePath ?? projectRoot;
26
28
  const protocolId = inferProtocolId(idRoot, input.handshakeId);
27
29
  const runtimeDir = runtimeProtocolDir(context.ddFlowHome, project.id, protocolId);
28
- const state = ensureRuntimeProtocolFiles({
30
+ return { projectRoot, project, workspacePath, protocolId, runtimeDir };
31
+ }
32
+ export function registerProtocol(context, input) {
33
+ const { projectRoot, project, workspacePath, protocolId, runtimeDir } = prepareProtocolRegistration(context, input);
34
+ const now = context.now();
35
+ const initialized = prepareRuntimeProtocolFiles({
29
36
  runtimeDir,
30
37
  protocolId,
31
38
  projectRoot,
@@ -33,25 +40,27 @@ export function registerProtocol(context, input) {
33
40
  workspacePath,
34
41
  now
35
42
  });
36
- const existing = findProtocol(context, protocolId, project.id);
37
- const params = [
38
- protocolId,
39
- input.handshakeId,
40
- project.id,
41
- projectRoot,
42
- state.status,
43
- state.stage,
44
- state.next_action,
45
- JSON.stringify(state.route),
46
- JSON.stringify(state.workspace),
47
- JSON.stringify(state.blockers),
48
- JSON.stringify(state.active_def),
49
- runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
50
- planJsonPath(workspacePath ?? projectRoot, protocolId),
51
- existing?.created_at ?? now,
52
- now
53
- ];
54
- context.db.run(`INSERT INTO protocols
43
+ return context.db.writeTransaction(() => {
44
+ const existing = findProtocol(context, protocolId, project.id);
45
+ const state = existing ? latestProtocolProjection(context, project.id, protocolId) ?? initialized : initialized;
46
+ const params = [
47
+ protocolId,
48
+ input.handshakeId,
49
+ project.id,
50
+ projectRoot,
51
+ state.status,
52
+ state.stage,
53
+ state.next_action,
54
+ JSON.stringify(state.route),
55
+ JSON.stringify(state.workspace),
56
+ JSON.stringify(state.blockers),
57
+ JSON.stringify(state.active_def),
58
+ runtimeStateJsonPath(context.ddFlowHome, project.id, protocolId),
59
+ planJsonPath(workspacePath ?? projectRoot, protocolId),
60
+ existing?.created_at ?? now,
61
+ now
62
+ ];
63
+ context.db.run(`INSERT INTO protocols
55
64
  (id, handshake_id, project_id, project_root, status, stage, next_action,
56
65
  route_json, workspace_json, blockers_json, active_def_json, state_path, plan_path,
57
66
  created_at, updated_at)
@@ -70,19 +79,23 @@ export function registerProtocol(context, input) {
70
79
  state_path = excluded.state_path,
71
80
  plan_path = excluded.plan_path,
72
81
  updated_at = excluded.updated_at`, params);
73
- appendAudit(context, {
74
- protocolId,
75
- projectId: project.id,
76
- eventType: existing ? "protocol.registration_refreshed" : "protocol.registered",
77
- payload: {
78
- protocol_id: protocolId,
79
- handshake_id: input.handshakeId,
80
- project_root: projectRoot,
81
- workspace_path: workspacePath ?? null,
82
- runtime_dir: runtimeDir
83
- }
84
- });
85
- return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId, project.id)), state };
82
+ appendAudit(context, { protocolId, projectId: project.id, eventType: "protocol.state_projection", payload: { state } });
83
+ context.db.afterCommit(() => publishProtocolProjection(context, project.id, protocolId));
84
+ context.db.afterCommit(() => publishRuntimeProtocolMetadata({ runtimeDir, protocolId, handshakeId: input.handshakeId, projectRoot }));
85
+ appendAudit(context, {
86
+ protocolId,
87
+ projectId: project.id,
88
+ eventType: existing ? "protocol.registration_refreshed" : "protocol.registered",
89
+ payload: {
90
+ protocol_id: protocolId,
91
+ handshake_id: input.handshakeId,
92
+ project_root: projectRoot,
93
+ workspace_path: workspacePath ?? null,
94
+ runtime_dir: runtimeDir
95
+ }
96
+ });
97
+ return { ok: true, protocol: protocolStatusPayload(requireProtocol(context, protocolId, project.id)), state };
98
+ }, "protocol.register");
86
99
  }
87
100
  export function getProtocolStatus(context, input) {
88
101
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -243,7 +256,22 @@ export function implementProtocol(context, input) {
243
256
  }
244
257
  };
245
258
  }
246
- export function transitionProtocol(context, input) {
259
+ export function prepareProtocolTransition(context, input) {
260
+ const { value } = prepareJsonFile(input.jsonFile, "Protocol transition payload");
261
+ if (!value || typeof value !== "object" || Array.isArray(value))
262
+ throw new AppError("validation", "Protocol transition payload must be an object", 2);
263
+ const payload = value;
264
+ for (const [key, field] of Object.entries(payload)) {
265
+ const valid = key === "next_action" ? typeof field === "string"
266
+ : ["route", "workspace"].includes(key) ? Boolean(field && typeof field === "object" && !Array.isArray(field))
267
+ : ["blockers", "active_def"].includes(key) && Array.isArray(field);
268
+ if (!valid)
269
+ throw new AppError("validation", `Unsupported field or invalid value in protocol transition: ${key}`, 2, { file: input.jsonFile, parameter: key });
270
+ }
271
+ protocolTransitionState(context, input);
272
+ return payload;
273
+ }
274
+ function protocolTransitionState(context, input) {
247
275
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
248
276
  const protocol = requireProtocol(context, input.protocolId, project.id);
249
277
  const state = readProtocolRuntimeState(context, protocol).state;
@@ -256,8 +284,10 @@ export function transitionProtocol(context, input) {
256
284
  if (input.force && (!input.reason || input.reason.trim().length === 0)) {
257
285
  throw new AppError("validation", "Forced transitions require --reason", 2);
258
286
  }
259
- ensureReadableFile(input.jsonFile);
260
- const payload = parseJsonObject(fs.readFileSync(input.jsonFile, "utf8"), input.jsonFile);
287
+ return { project, protocol, state, flowContract, from, to };
288
+ }
289
+ export function transitionProtocol(context, input, payload = prepareProtocolTransition(context, input)) {
290
+ const { project, protocol, state, flowContract, from, to } = protocolTransitionState(context, input);
261
291
  const nextState = {
262
292
  ...state,
263
293
  stage: to,
@@ -416,8 +446,7 @@ export function syncProtocolFromRun(context, input) {
416
446
  next_action: nextActionForSyncedTarget(targetStage),
417
447
  updated_at: now
418
448
  };
419
- context.db.exec("BEGIN IMMEDIATE");
420
- try {
449
+ context.db.writeTransaction(() => {
421
450
  persistProtocolState(context, protocol, nextState);
422
451
  if (targetStage === "ready_for_merge") {
423
452
  context.db.run(`INSERT INTO merge_queue
@@ -449,12 +478,7 @@ export function syncProtocolFromRun(context, input) {
449
478
  stage_chain: stageChain(index)
450
479
  }
451
480
  });
452
- context.db.exec("COMMIT");
453
- }
454
- catch (error) {
455
- context.db.exec("ROLLBACK");
456
- throw error;
457
- }
481
+ }, "protocols.syncFromRun");
458
482
  return {
459
483
  ok: true,
460
484
  protocol_id: protocol.id,
@@ -486,8 +510,7 @@ export function cancelProtocol(context, input) {
486
510
  const now = context.now();
487
511
  const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
488
512
  const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
489
- context.db.exec("BEGIN IMMEDIATE");
490
- try {
513
+ return context.db.writeTransaction(() => {
491
514
  const nextState = {
492
515
  ...state,
493
516
  stage: "cancelled",
@@ -548,7 +571,6 @@ export function cancelProtocol(context, input) {
548
571
  worktree_outcome: worktreeOutcome
549
572
  }
550
573
  });
551
- context.db.exec("COMMIT");
552
574
  return {
553
575
  ok: true,
554
576
  protocol_id: protocol.id,
@@ -560,11 +582,7 @@ export function cancelProtocol(context, input) {
560
582
  worktree_outcome: worktreeOutcome,
561
583
  worktree: context.db.get("SELECT * FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]) ?? null
562
584
  };
563
- }
564
- catch (error) {
565
- context.db.exec("ROLLBACK");
566
- throw error;
567
- }
585
+ }, "protocols.cancel");
568
586
  }
569
587
  export function requireProtocol(context, protocolId, projectId) {
570
588
  const matches = projectId
@@ -579,7 +597,10 @@ export function requireProtocol(context, protocolId, projectId) {
579
597
  candidates: matches.map((protocol) => ({ project_id: protocol.project_id, project_root: protocol.project_root }))
580
598
  });
581
599
  }
582
- return matches[0];
600
+ return resolvedProtocolRecord(matches[0]);
601
+ }
602
+ function resolvedProtocolRecord(protocol) {
603
+ return { ...protocol, plan_path: resolveProtocolPlanPath(protocol.project_root, protocol.id, protocol.plan_path) };
583
604
  }
584
605
  function cancelQueueForProtocol(context, protocol, reason, force) {
585
606
  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]);
@@ -733,9 +754,42 @@ function releaseRelatedMergeLocks(context, protocol, reason) {
733
754
  return { ok: true, released: released.length, locks: released };
734
755
  }
735
756
  export function readProtocolRuntimeState(context, protocol) {
757
+ const expected = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
758
+ if (protocol.state_path !== expected)
759
+ 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 });
760
+ const accepted = latestProtocolProjection(context, protocol.project_id, protocol.id);
761
+ if (accepted)
762
+ return { state: accepted, diagnostics: [] };
763
+ return resolveProtocolRuntimeState(context, { ...protocol }, false);
764
+ }
765
+ /** Explicit cleanup migration; productive readers accept only the current path. */
766
+ export function repairProtocolRuntimeState(context, protocol) {
767
+ // Readers project the canonical plan path without migrating storage. Repair
768
+ // must start from the stored locations, not that already-normalized view.
769
+ const stored = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [protocol.project_id, protocol.id]);
770
+ if (stored)
771
+ Object.assign(protocol, stored);
772
+ return resolveProtocolRuntimeState(context, protocol, true);
773
+ }
774
+ /** Read-only repair preparation; caller CASes paths, then persists this state. */
775
+ export function prepareProtocolRuntimeRepair(context, protocol) {
776
+ const stored = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [protocol.project_id, protocol.id]);
777
+ if (!stored)
778
+ throw new AppError("not_found", "Protocol is not registered", 1, { protocol_id: protocol.id });
779
+ const state = latestProtocolProjection(context, stored.project_id, stored.id) ?? resolveProtocolRuntimeState(context, { ...stored }, false).state;
780
+ return {
781
+ state,
782
+ statePath: runtimeStateJsonPath(context.ddFlowHome, stored.project_id, stored.id),
783
+ planPath: planJsonPath(stored.project_root, stored.id),
784
+ previousStatePath: stored.state_path
785
+ };
786
+ }
787
+ function resolveProtocolRuntimeState(context, protocol, repair) {
736
788
  const diagnostics = [];
737
- normalizeProtocolPlanPath(context, protocol, diagnostics);
789
+ normalizeProtocolPlanPath(context, protocol, diagnostics, repair);
738
790
  try {
791
+ if (protocol.state_path !== runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id))
792
+ throw new AppError("protocol_state_migration_required", "Retired state files are not runtime authority", 1);
739
793
  return { state: readStateFile(protocol.state_path, protocol.id), diagnostics };
740
794
  }
741
795
  catch {
@@ -750,7 +804,8 @@ export function readProtocolRuntimeState(context, protocol) {
750
804
  recommended_action: "legacy protocol state_path was moved to stable runtime storage"
751
805
  });
752
806
  const stablePlanPath = planJsonPath(protocol.project_root, protocol.id);
753
- context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
807
+ if (repair && context.db.writable)
808
+ context.db.run("UPDATE protocols SET state_path = ?, plan_path = ? WHERE project_id = ? AND id = ?", [stableStatePath, stablePlanPath, protocol.project_id, protocol.id]);
754
809
  protocol.state_path = stableStatePath;
755
810
  protocol.plan_path = stablePlanPath;
756
811
  return { state: readStateFile(stableStatePath, protocol.id), diagnostics };
@@ -783,7 +838,7 @@ export function readProtocolRuntimeState(context, protocol) {
783
838
  flow_contract: flowContract,
784
839
  updated_at: protocol.updated_at
785
840
  };
786
- if (!context.db.writable) {
841
+ if (!repair || !context.db.writable) {
787
842
  return { state, diagnostics };
788
843
  }
789
844
  const stableStatePath = runtimeStateJsonPath(context.ddFlowHome, protocol.project_id, protocol.id);
@@ -802,9 +857,9 @@ export function readProtocolRuntimeState(context, protocol) {
802
857
  });
803
858
  return { state, diagnostics };
804
859
  }
805
- function normalizeProtocolPlanPath(context, protocol, diagnostics) {
806
- const canonicalPlanPath = planJsonPath(protocol.project_root, protocol.id);
807
- if (protocol.plan_path === canonicalPlanPath || !fs.existsSync(canonicalPlanPath))
860
+ function normalizeProtocolPlanPath(context, protocol, diagnostics, repair) {
861
+ const canonicalPlanPath = resolveProtocolPlanPath(protocol.project_root, protocol.id, protocol.plan_path);
862
+ if (protocol.plan_path === canonicalPlanPath)
808
863
  return;
809
864
  diagnostics.push({
810
865
  code: "protocol_plan_relocated",
@@ -814,7 +869,7 @@ function normalizeProtocolPlanPath(context, protocol, diagnostics) {
814
869
  source: "project_canonical_plan",
815
870
  recommended_action: "use the project canonical plan as the sole semantic plan source"
816
871
  });
817
- if (context.db.writable) {
872
+ if (repair && context.db.writable) {
818
873
  context.db.run("UPDATE protocols SET plan_path = ?, updated_at = ? WHERE project_id = ? AND id = ?", [canonicalPlanPath, context.now(), protocol.project_id, protocol.id]);
819
874
  }
820
875
  protocol.plan_path = canonicalPlanPath;
@@ -847,38 +902,57 @@ export function protocolRunDiagnostics(context, protocol, state) {
847
902
  return { latest_run: latestWithStages, diagnostics };
848
903
  }
849
904
  export function persistProtocolState(context, protocol, state) {
850
- ensureDir(path.dirname(protocol.state_path));
851
- writeState(protocol.state_path, state);
852
- context.db.run(`UPDATE protocols SET
905
+ context.db.writeTransaction(() => {
906
+ context.db.run(`UPDATE protocols SET
853
907
  status = ?, stage = ?, next_action = ?, route_json = ?, workspace_json = ?,
854
908
  blockers_json = ?, active_def_json = ?, updated_at = ?
855
909
  WHERE project_id = ? AND id = ?`, [
856
- state.status,
857
- state.stage,
858
- state.next_action,
859
- JSON.stringify(state.route),
860
- JSON.stringify(state.workspace),
861
- JSON.stringify(state.blockers),
862
- JSON.stringify(state.active_def),
863
- state.updated_at,
864
- protocol.project_id,
865
- protocol.id
866
- ]);
867
- if (state.status === "closed" || state.stage === "closed") {
868
- const now = context.now();
869
- context.db.run(`UPDATE sessions
910
+ state.status,
911
+ state.stage,
912
+ state.next_action,
913
+ JSON.stringify(state.route),
914
+ JSON.stringify(state.workspace),
915
+ JSON.stringify(state.blockers),
916
+ JSON.stringify(state.active_def),
917
+ state.updated_at,
918
+ protocol.project_id,
919
+ protocol.id
920
+ ]);
921
+ appendAudit(context, { protocolId: protocol.id, projectId: protocol.project_id, eventType: "protocol.state_projection", payload: { state } });
922
+ if (state.status === "closed" || state.stage === "closed") {
923
+ const now = context.now();
924
+ context.db.run(`UPDATE sessions
870
925
  SET status = 'stopped', stop_reason = 'protocol closed', updated_at = ?, stopped_at = ?
871
926
  WHERE project_id = ?
872
927
  AND protocol_id = ?
873
928
  AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')`, [now, now, protocol.project_id, protocol.id]);
874
- }
929
+ }
930
+ context.db.afterCommit(() => publishProtocolProjection(context, protocol.project_id, protocol.id));
931
+ }, "protocols.persistProtocolState");
932
+ }
933
+ function latestProtocolProjection(context, projectId, protocolId) {
934
+ const row = context.db.get("SELECT payload_json FROM audit_events WHERE project_id = ? AND protocol_id = ? AND event_type = 'protocol.state_projection' ORDER BY id DESC LIMIT 1", [projectId, protocolId]);
935
+ return row ? JSON.parse(row.payload_json).state : null;
936
+ }
937
+ export function publishProtocolProjection(context, projectId, protocolId) {
938
+ const protocol = requireProtocol(context, protocolId, projectId);
939
+ ensureDir(path.dirname(protocol.state_path));
940
+ withProjectionLock(`${protocol.state_path}.publish-lock`, () => {
941
+ const accepted = latestProtocolProjection(context, projectId, protocolId);
942
+ if (!accepted)
943
+ throw new AppError("runtime_state_missing", "Protocol has no accepted SQL state", 1, { protocol_id: protocolId });
944
+ const temporary = `${protocol.state_path}.tmp-${process.pid}-${crypto.randomUUID()}`;
945
+ fs.writeFileSync(temporary, `${JSON.stringify(accepted, null, 2)}\n`);
946
+ fs.renameSync(temporary, protocol.state_path);
947
+ });
875
948
  }
876
949
  function findProtocol(context, protocolId, projectId) {
877
950
  if (projectId) {
878
- return context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
951
+ const protocol = context.db.get("SELECT * FROM protocols WHERE project_id = ? AND id = ?", [projectId, protocolId]);
952
+ return protocol ? resolvedProtocolRecord(protocol) : undefined;
879
953
  }
880
954
  const matches = context.db.all("SELECT * FROM protocols WHERE id = ? ORDER BY project_id", [protocolId]);
881
- return matches.length === 1 ? matches[0] : undefined;
955
+ return matches.length === 1 ? resolvedProtocolRecord(matches[0]) : undefined;
882
956
  }
883
957
  function protocolDocumentsForProject(projectRoot) {
884
958
  const protocolRoot = path.join(projectRoot, ".memory-bank", "protocol");
@@ -0,0 +1,61 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { AppError } from "../shared/errors.js";
3
+ export const recoveryObservationBudgetMs = 120_000;
4
+ const activeObservation = new AsyncLocalStorage();
5
+ export const withRecoveryObservation = (budget, action) => activeObservation.run(budget, action);
6
+ export const recoveryRpcTimeout = (maximum) => {
7
+ const observation = activeObservation.getStore();
8
+ // An unbounded productive transport wait is still bounded while recovery is
9
+ // actively reconciling. Outside recovery, its owner policy decides when to
10
+ // stop rather than a hidden adapter timer.
11
+ if (maximum === null)
12
+ return observation ? observation.timeout() : null;
13
+ return observation?.timeout(maximum ?? 30_000) ?? maximum ?? 30_000;
14
+ };
15
+ /** Call only while holding the existing owner-claim transaction. A replay of
16
+ * an explicit request never replenishes time, even after another request. */
17
+ export function renewRecoveryObservation(snapshot, requestId) {
18
+ if (typeof requestId !== "string" || !requestId.trim() || requestId.length > 200)
19
+ throw new AppError("usage", "Reconciliation requires a nonempty request ID of at most 200 characters", 2);
20
+ const previous = snapshot.recovery_observation;
21
+ const ids = previous?.reconciliation_ids ?? [];
22
+ if (!Array.isArray(ids) || ids.some(id => typeof id !== "string"))
23
+ throw new AppError("recovery_observation_invalid", "Retained reconciliation identities are malformed", 1);
24
+ if (ids.includes(requestId))
25
+ return snapshot;
26
+ return { ...snapshot, recovery_observation: { budget_ms: recoveryObservationBudgetMs, remaining_ms: recoveryObservationBudgetMs, reconciliation_ids: [...ids, requestId], poll_index: 0 } };
27
+ }
28
+ /**
29
+ * Measures only time this owner can actually observe. Large scheduler gaps
30
+ * reset the sample instead of turning host sleep into evidence about a remote
31
+ * process. Persisting the remaining budget makes a recovered owner continue
32
+ * reconciliation rather than silently receiving a new unlimited wait.
33
+ */
34
+ export function recoveryObservationBudget(saved, monotonic = () => performance.now()) {
35
+ const prior = saved && typeof saved === "object" ? saved.recovery_observation : null;
36
+ let remaining = typeof prior?.remaining_ms === "number" && Number.isFinite(prior.remaining_ms)
37
+ ? Math.max(0, Math.min(recoveryObservationBudgetMs, prior.remaining_ms))
38
+ : prior == null ? recoveryObservationBudgetMs : 0;
39
+ let last = monotonic();
40
+ let pollIndex = Number.isSafeInteger(prior?.poll_index) && prior.poll_index >= 0 ? prior.poll_index : 0;
41
+ const observation = {
42
+ exhausted: () => {
43
+ const current = monotonic();
44
+ const elapsed = current - last;
45
+ last = current;
46
+ // A gap means this process was not observing; it never proves progress
47
+ // or death and therefore does not consume the reconciliation budget.
48
+ if (elapsed >= 0 && elapsed <= 60_000)
49
+ remaining = Math.max(0, remaining - elapsed);
50
+ return remaining === 0;
51
+ },
52
+ state: () => ({ budget_ms: recoveryObservationBudgetMs, remaining_ms: Math.ceil(remaining), reconciliation_ids: prior?.reconciliation_ids ?? [], poll_index: pollIndex }),
53
+ timeout: (maximum = 30_000) => {
54
+ if (observation.exhausted())
55
+ throw new AppError("recovery_observation_budget_exhausted", "Recovery observation budget exhausted; explicit reconciliation is required", 1);
56
+ return Math.max(1, Math.min(maximum, Math.ceil(remaining)));
57
+ },
58
+ nextDelay: () => Math.min([1000, 2000, 5000, 10000][Math.min(pollIndex++, 3)], Math.ceil(remaining)),
59
+ };
60
+ return observation;
61
+ }