@deksden-com/dd-flow-cli 0.9.0-beta.144 → 0.9.0-beta.145

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 (197) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +13 -8
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/command-inputs.js +9 -3
  5. package/dist/cli/command-metadata.js +33 -14
  6. package/dist/cli/help.js +32 -1
  7. package/dist/cli/hook-ingress.js +27 -3
  8. package/dist/cli/memory-flow-admission.js +7 -3
  9. package/dist/cli/memory-init-invocation.js +10 -3
  10. package/dist/cli/memory-init.js +74 -68
  11. package/dist/cli/memory-storage-admission.js +3 -2
  12. package/dist/cli/memory-storage-qualification.js +105 -0
  13. package/dist/cli/memory-storage-transition.js +123 -0
  14. package/dist/cli/memory-upgrade.js +189 -0
  15. package/dist/cli/native-hook-ingress.js +14 -4
  16. package/dist/cli/native-hook-preparation.js +55 -0
  17. package/dist/cli/response-file.js +2 -1
  18. package/dist/cli/run-cli.js +70 -32
  19. package/dist/cli/setup.js +2 -1
  20. package/dist/cli.js +12 -0
  21. package/dist/domain/stage-handler-identities.js +8 -0
  22. package/dist/harness-runtime/bin/dd-agy.mjs +3 -3
  23. package/dist/harness-runtime/bin/dd-grok.mjs +3 -3
  24. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  25. package/dist/harness-runtime/lib/agy-error-boundary.mjs +58 -0
  26. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +62 -20
  27. package/dist/harness-runtime/lib/dd-agy.mjs +17 -3
  28. package/dist/harness-runtime/lib/dd-droid.mjs +1 -1
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +70 -13
  30. package/dist/harness-runtime/lib/dd-grok.mjs +6 -3
  31. package/dist/harness-runtime/lib/dd-zcode.mjs +44 -14
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +1 -1
  33. package/dist/harness-runtime/lib/entity-references.d.mts +2 -0
  34. package/dist/harness-runtime/lib/entity-references.mjs +9 -0
  35. package/dist/harness-runtime/lib/managed-daemon.mjs +13 -6
  36. package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
  37. package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -3
  38. package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
  39. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +4 -2
  40. package/dist/harness-runtime/lib/provider-overload-policy.mjs +19 -6
  41. package/dist/harness-runtime/lib/runner-events.mjs +3 -1
  42. package/dist/harness-runtime/lib/setup-progress.mjs +8 -6
  43. package/dist/schemas/mb-upgrade-stage-result.schema.json +863 -0
  44. package/dist/schemas/memory-repair-work-result.schema.json +9 -2
  45. package/dist/schemas/release-impact.schema.json +3 -3
  46. package/dist/schemas/stage-start-response.schema.json +4 -0
  47. package/dist/services/accepted-intent.js +13 -1
  48. package/dist/services/canon.js +43 -7
  49. package/dist/services/cli-operation-classifier.js +1 -1
  50. package/dist/services/cli-upgrade-allowlist.js +1 -1
  51. package/dist/services/code-checks.js +11 -4
  52. package/dist/services/compatibility-preflight.js +5 -2
  53. package/dist/services/controller-fanout.js +21 -4
  54. package/dist/services/controller-memory-task.js +45 -0
  55. package/dist/services/controller-memory-worker.js +48 -0
  56. package/dist/services/dashboard.js +2 -2
  57. package/dist/services/descriptor-run-start.js +26 -19
  58. package/dist/services/descriptor-stage-coordinator.js +30 -10
  59. package/dist/services/descriptor-stage-start.js +40 -4
  60. package/dist/services/engines.js +64 -14
  61. package/dist/services/eval-snapshots.js +104 -93
  62. package/dist/services/external-work-launch.js +5 -3
  63. package/dist/services/flow-catalog.js +8 -4
  64. package/dist/services/frozen-engine.js +113 -11
  65. package/dist/services/hook-memory-routing.js +188 -0
  66. package/dist/services/hook-recovery-guard.js +14 -0
  67. package/dist/services/hook-session-observation.js +24 -0
  68. package/dist/services/hooks.js +27 -8
  69. package/dist/services/hybrid-contracts.js +2 -13
  70. package/dist/services/hybrid-digest.js +14 -0
  71. package/dist/services/installed-flow-source.js +25 -4
  72. package/dist/services/lifecycle-invocations.js +33 -5
  73. package/dist/services/memory-accepted-composition.js +5 -4
  74. package/dist/services/memory-completed-correction-wave.js +23 -15
  75. package/dist/services/memory-compose-handler.js +163 -21
  76. package/dist/services/memory-composition-acceptance.js +186 -10
  77. package/dist/services/memory-composition-graph.js +42 -11
  78. package/dist/services/memory-composition-registration.js +23 -8
  79. package/dist/services/memory-composition-work-custody.js +14 -9
  80. package/dist/services/memory-composition-work-results.js +15 -3
  81. package/dist/services/memory-corrected-review-batch.js +10 -3
  82. package/dist/services/memory-corrected-review-findings.js +4 -2
  83. package/dist/services/memory-corrected-review-inputs.js +10 -5
  84. package/dist/services/memory-corrected-review-receipt.js +17 -9
  85. package/dist/services/memory-corrected-review-registration.js +3 -2
  86. package/dist/services/memory-correction-check-receipt.js +20 -4
  87. package/dist/services/memory-custody-snapshot.js +36 -0
  88. package/dist/services/memory-finish-evidence.js +44 -22
  89. package/dist/services/memory-finish-handler.js +37 -23
  90. package/dist/services/memory-finish-integration.js +218 -48
  91. package/dist/services/memory-finish-readiness.js +66 -19
  92. package/dist/services/memory-git-integration.js +87 -18
  93. package/dist/services/memory-historical-corrected-review.js +21 -15
  94. package/dist/services/memory-historical-original-review.js +22 -9
  95. package/dist/services/memory-historical-repair-registration.js +14 -8
  96. package/dist/services/memory-historical-second-review.js +30 -11
  97. package/dist/services/memory-init-contexts.js +28 -2
  98. package/dist/services/memory-init-controller-start.js +19 -8
  99. package/dist/services/memory-init-git-policy.js +50 -21
  100. package/dist/services/memory-init-lint-capability.js +21 -3
  101. package/dist/services/memory-init-native-capacity.js +13 -0
  102. package/dist/services/memory-init-preflight.js +11 -1
  103. package/dist/services/memory-init-request-candidate.js +73 -17
  104. package/dist/services/memory-init-request-input.js +23 -7
  105. package/dist/services/memory-init-run-bundle.js +49 -12
  106. package/dist/services/memory-init-storage.js +17 -7
  107. package/dist/services/memory-layer.js +68 -4
  108. package/dist/services/memory-lint-config.js +111 -2
  109. package/dist/services/memory-native-review-copy.js +5 -67
  110. package/dist/services/memory-normal-flow-entry.js +12 -0
  111. package/dist/services/memory-ordinary-defaults.js +22 -5
  112. package/dist/services/memory-preflight-handler.js +15 -0
  113. package/dist/services/memory-repair-current-view.js +1 -1
  114. package/dist/services/memory-repair-document-delta.js +70 -33
  115. package/dist/services/memory-repair-history.js +12 -7
  116. package/dist/services/memory-repair-physical-binding.js +1 -119
  117. package/dist/services/memory-repair-publication-plan.js +5 -3
  118. package/dist/services/memory-repair-publication-source.js +1 -1
  119. package/dist/services/memory-repair-publication.js +3 -2
  120. package/dist/services/memory-repair-registration.js +2 -1
  121. package/dist/services/memory-repair-work-baseline.js +4 -2
  122. package/dist/services/memory-repair-work-completion.js +32 -12
  123. package/dist/services/memory-repair-work-results.js +21 -2
  124. package/dist/services/memory-repair-workspace.js +11 -2
  125. package/dist/services/memory-repair-writer-copy.js +2 -1
  126. package/dist/services/memory-result-binding.js +78 -12
  127. package/dist/services/memory-retained-review-authority.js +17 -8
  128. package/dist/services/memory-retained-second-baseline.js +1 -1
  129. package/dist/services/memory-retained-second-repairs.js +12 -6
  130. package/dist/services/memory-review-correction-plan.js +47 -12
  131. package/dist/services/memory-review-handler.js +17 -17
  132. package/dist/services/memory-review-inputs.js +55 -9
  133. package/dist/services/memory-review-limits.js +10 -0
  134. package/dist/services/memory-review-receipt.js +2 -2
  135. package/dist/services/memory-review-registration.js +13 -4
  136. package/dist/services/memory-review-work-custody.js +3 -1
  137. package/dist/services/memory-review-work-input.js +26 -7
  138. package/dist/services/memory-review-work-results.js +26 -4
  139. package/dist/services/memory-second-corrected-review-inputs.js +5 -3
  140. package/dist/services/memory-second-correction-plan.js +2 -1
  141. package/dist/services/memory-second-repair-launch.js +4 -4
  142. package/dist/services/memory-second-repair-source.js +1 -1
  143. package/dist/services/memory-stage-dispatch.js +38 -1
  144. package/dist/services/memory-stage-results.js +113 -22
  145. package/dist/services/memory-upgrade-analysis-handler.js +243 -0
  146. package/dist/services/memory-upgrade-bootstrap-readiness.js +251 -0
  147. package/dist/services/memory-upgrade-evidence.js +100 -0
  148. package/dist/services/memory-upgrade-inventory.js +442 -0
  149. package/dist/services/memory-upgrade-publication-custody.js +73 -0
  150. package/dist/services/memory-upgrade-publication.js +413 -0
  151. package/dist/services/memory-upgrade-selection.js +430 -0
  152. package/dist/services/memory-upgrade-start.js +60 -0
  153. package/dist/services/memory-upgrade-storage-readiness.js +340 -0
  154. package/dist/services/memory-verification-acceptance.js +38 -17
  155. package/dist/services/memory-verification-checks.js +77 -23
  156. package/dist/services/memory-verification-receipt.js +6 -5
  157. package/dist/services/memory-verify-handler.js +15 -13
  158. package/dist/services/memory-work-result-json.js +12 -0
  159. package/dist/services/memory-work-result-preparation.js +5 -4
  160. package/dist/services/merge-server.js +4 -2
  161. package/dist/services/migrations.js +127 -98
  162. package/dist/services/physical-startup.js +45 -0
  163. package/dist/services/project-registry.js +97 -0
  164. package/dist/services/project-summary.js +1 -1
  165. package/dist/services/projects.js +3 -93
  166. package/dist/services/prompt-delivery.js +71 -0
  167. package/dist/services/provider-capacity.js +69 -8
  168. package/dist/services/recovery-snapshot-database.js +32 -4
  169. package/dist/services/repair-intents.js +1 -12
  170. package/dist/services/repair-receipt.js +13 -0
  171. package/dist/services/run-control-worker.js +35 -18
  172. package/dist/services/run-controller-process.js +2 -6
  173. package/dist/services/run-controller-recovery.js +6 -1
  174. package/dist/services/run-controller.js +131 -80
  175. package/dist/services/run-recovery-guard.js +30 -0
  176. package/dist/services/run-recovery.js +11 -44
  177. package/dist/services/run-start-custody.js +17 -5
  178. package/dist/services/runtime-scope-worker.js +46 -19
  179. package/dist/services/sessions.js +3 -24
  180. package/dist/services/setup.js +63 -13
  181. package/dist/services/stage-handlers.js +9 -7
  182. package/dist/services/stage-pause.js +108 -44
  183. package/dist/services/version-status.js +1 -1
  184. package/dist/services/vnext-fanout.js +21 -9
  185. package/dist/services/vnext-plan-review.js +3 -5
  186. package/dist/services/work-registry.js +80 -33
  187. package/dist/services/workspace-files.js +1 -1
  188. package/dist/shared/entity-references.js +1 -8
  189. package/dist/storage/data-preservation.js +65 -0
  190. package/dist/storage/database.js +53 -19
  191. package/dist/storage/import-guard.js +84 -27
  192. package/dist/storage/memory-upgrade-schema-bootstrap.js +38 -0
  193. package/dist/storage/paired-writer-migration.js +424 -0
  194. package/dist/storage/writer-contract.js +11 -4
  195. package/dist/storage/writer-migration.js +313 -52
  196. package/dist/storage/writer-restore.js +217 -0
  197. package/package.json +8 -4
@@ -8,6 +8,7 @@ import { AppError, errorRecord } from "../shared/errors.js";
8
8
  import { getResourceDatabase } from "../storage/database.js";
9
9
  import { assertManagedProcessLease, confirmManagedProcess, finishManagedProcess, renewManagedProcess, processIsAlive, processStartedAt, registerManagedProcess, resourceHome } from "./managed-processes.js";
10
10
  import { managedLeaseMonitor } from "./managed-lease-monitor.js";
11
+ import { physicalStartup } from "./physical-startup.js";
11
12
  import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus, prepareRuntimeScopeFence, parseRuntimeBudget } from "./runtime-budget.js";
12
13
  import { controlRuntimeScopeRuns } from "./runtime-scope-control.js";
13
14
  import { prepareRuntimeScopeResume, recordRuntimeScopeResume } from "./runtime-scope-resume.js";
@@ -138,8 +139,7 @@ export function prepareRuntimeScopeServing(context, scopeId, generation) {
138
139
  }
139
140
  return { key, token };
140
141
  }
141
- function requireStartingScopeWorker(db, key, token) {
142
- const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
142
+ function requireStartingScopeWorker(db, key, token, row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token])) {
143
143
  if (!row)
144
144
  throw new AppError("runtime_scope_owner_unknown", "Scope worker claim changed", 1);
145
145
  const worker = JSON.parse(row.metadata_json);
@@ -150,29 +150,55 @@ function requireStartingScopeWorker(db, key, token) {
150
150
  export async function serveRuntimeScopeControl(context, scopeId, generation) {
151
151
  const { key, token } = prepareRuntimeScopeServing(context, scopeId, generation);
152
152
  const db = getResourceDatabase(resourceHome(context));
153
- const owner = birth();
154
- const worker = db.writeTransaction(() => {
155
- const worker = requireStartingScopeWorker(db, key, token);
156
- worker.owner = owner;
157
- worker.status = "running";
158
- db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
159
- return worker;
160
- });
153
+ const startup = physicalStartup(context.databaseDeadlineAt);
154
+ let claimedOwner = undefined;
155
+ const assertStartupCurrent = () => {
156
+ const control = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control' AND resource_key = ?", [scopeId]);
157
+ const fence = db.get("SELECT 1 FROM managed_resources WHERE resource_kind = 'provider-fence' AND resource_key = ?", [scopeId]);
158
+ const row = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
159
+ const current = row ? JSON.parse(row.metadata_json) : null;
160
+ if (!control || JSON.parse(control.metadata_json).generation !== generation || fence || !current || current.token !== token || current.status !== (claimedOwner ? "running" : "starting") || current.owner?.pid !== claimedOwner?.pid || current.owner?.pid_started_at !== claimedOwner?.pid_started_at)
161
+ throw new AppError("runtime_scope_control_superseded", "Scope startup no longer owns this control", 1);
162
+ };
163
+ const starting = db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
164
+ let worker = requireStartingScopeWorker(db, key, token, starting);
165
+ let retainedMetadata = starting.metadata_json;
166
+ let claimedByThisServe = false;
167
+ const persistWorker = () => {
168
+ const next = JSON.stringify(worker);
169
+ if (db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ? AND metadata_json = ?", [next, context.now(), key, token, retainedMetadata]).changes !== 1)
170
+ throw new AppError("runtime_scope_owner_unknown", "Scope owner changed before its retained result could be saved", 1);
171
+ retainedMetadata = next;
172
+ };
161
173
  let heartbeat;
162
174
  let lease;
163
175
  // A fence must never be held indefinitely by an observer that cannot obtain
164
176
  // fresh provider evidence. A later operator request continues the retained
165
177
  // budget; this owner records why it stopped without claiming settlement.
166
- const observation = recoveryObservationBudget(worker.snapshot);
167
- worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
178
+ let observationCleanup;
168
179
  let cleanupFailure;
169
180
  const result = await (async () => {
170
181
  try {
182
+ const owner = await startup.birth(assertStartupCurrent);
183
+ const claim = db.writeTransaction(() => {
184
+ assertStartupCurrent();
185
+ const current = requireStartingScopeWorker(db, key, token);
186
+ current.owner = owner;
187
+ current.status = "running";
188
+ db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(current), context.now(), key, token]);
189
+ return { worker: current, metadata: JSON.stringify(current) };
190
+ });
191
+ worker = claim.worker;
192
+ retainedMetadata = claim.metadata;
193
+ claimedOwner = owner;
194
+ claimedByThisServe = true;
195
+ const observation = observationCleanup = recoveryObservationBudget(worker.snapshot);
196
+ worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
171
197
  const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
172
198
  worker.process_id = record.id;
173
199
  worker.process_lease_token = record.lease_token;
174
- const confirmed = confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
175
- db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
200
+ const confirmed = await startup.observe(() => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid }), assertStartupCurrent, record.lease_expires_at);
201
+ persistWorker();
176
202
  lease = managedLeaseMonitor(attemptMs => renewManagedProcess({ ...context, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: record.id, leaseToken: record.lease_token }), confirmed.lease_expires_at);
177
203
  let timerFailure;
178
204
  const assertScopeCurrent = () => {
@@ -233,7 +259,7 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
233
259
  await lease.ensureConfirmed(assertScopeCurrent);
234
260
  observation.observe(outcome);
235
261
  worker.snapshot = { ...outcome, recovery_observation: observation.state() };
236
- db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
262
+ persistWorker();
237
263
  if (worker.snapshot.drain?.physical_settled === true) {
238
264
  worker.status = "completed";
239
265
  break;
@@ -259,18 +285,19 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
259
285
  lease?.close();
260
286
  if (heartbeat)
261
287
  clearInterval(heartbeat);
262
- observation.exhausted();
263
- worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
288
+ observationCleanup?.exhausted();
289
+ worker.snapshot = { ...(worker.snapshot ?? {}), ...(observationCleanup ? { recovery_observation: observationCleanup.state() } : {}) };
264
290
  let persistenceFailure;
265
291
  try {
266
- db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
292
+ persistWorker();
267
293
  }
268
294
  catch (error) {
269
295
  persistenceFailure = error;
270
296
  process.stderr.write(`${JSON.stringify({ primary: worker.error, primary_error: worker.snapshot.error, phase: "persist_scope_owner", secondary: errorRecord(error) })}\n`);
271
297
  }
272
298
  try {
273
- if (worker.process_id && worker.process_lease_token)
299
+ const current = claimedByThisServe && db.get("SELECT metadata_json FROM managed_resources WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [key, token]);
300
+ if (current && current.metadata_json === retainedMetadata && !persistenceFailure && worker.process_id && worker.process_lease_token)
274
301
  finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
275
302
  }
276
303
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { recordFlowSessionObservation } from "./hook-session-observation.js";
2
3
  import { continuationPolicies, flowKinds } from "../domain/contracts.js";
3
4
  import { AppError } from "../shared/errors.js";
4
5
  import { hookSessionIdentity } from "./hooks.js";
@@ -23,25 +24,7 @@ export function publicFlowSession(context, session) {
23
24
  delete publicFields.parent_session_id;
24
25
  return { ...publicFields, harness_id: identity.harness_id, session_id: identity.session_id, parent_session: parent ? publicSessionIdentity(parent) : null };
25
26
  }
26
- export function recordFlowSessionObservation(context, input) {
27
- const duplicate = context.db.get("SELECT id FROM flow_session_segments WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
28
- if (duplicate)
29
- return { changed: false, segment_id: duplicate.id };
30
- const open = context.db.get(`SELECT id, run_id, protocol_id FROM flow_session_segments
31
- WHERE project_id = ? AND session_id = ? AND ended_at IS NULL ORDER BY started_at DESC, id DESC LIMIT 1`, [input.projectId, input.sessionId]);
32
- const now = context.now();
33
- const runId = input.runId === undefined ? open?.run_id ?? null : input.runId;
34
- const protocolId = input.protocolId === undefined ? open?.protocol_id ?? null : input.protocolId;
35
- if (open && open.run_id === runId && open.protocol_id === protocolId) {
36
- return { changed: false, segment_id: open.id };
37
- }
38
- if (open)
39
- context.db.run("UPDATE flow_session_segments SET ended_at = ? WHERE id = ? AND ended_at IS NULL", [now, open.id]);
40
- const result = context.db.run(`INSERT INTO flow_session_segments
41
- (project_id, session_id, run_id, protocol_id, started_at, ended_at, cwd, tool_name, event_key)
42
- VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`, [input.projectId, input.sessionId, runId, protocolId, now, input.cwd ?? null, input.toolName ?? null, input.eventKey]);
43
- return { changed: true, segment_id: Number(result.lastInsertRowid) };
44
- }
27
+ export { recordFlowSessionObservation } from "./hook-session-observation.js";
45
28
  export function registerFlowJob(context, input) {
46
29
  const jobId = input.jobId ?? `JOB-${input.runId}-${input.planItemId}`.replace(/[^A-Za-z0-9_-]+/g, "-");
47
30
  const now = context.now();
@@ -167,11 +150,7 @@ export function stoppedMergeWorkerState(context, projectId, workerId) {
167
150
  status: latest?.status ?? null
168
151
  };
169
152
  }
170
- export function activeFlowSessionsForProject(context, projectId) {
171
- return context.db.all(`SELECT * FROM sessions
172
- WHERE project_id = ? AND status IN ('pending', 'active', 'waiting_user', 'blocked', 'stopping')
173
- ORDER BY updated_at DESC`, [projectId]);
174
- }
153
+ export { activeFlowSessionsForProject } from "./hook-session-observation.js";
175
154
  export function flowSessionsForProject(context, projectId, filter = {}) {
176
155
  if (filter.sessionId) {
177
156
  return context.db.all("SELECT * FROM sessions WHERE project_id = ? AND (session_id = ? OR provider_session_id = ?) ORDER BY updated_at DESC", [projectId, filter.sessionId, filter.sessionId]);
@@ -60,7 +60,7 @@ function atomicJson(file, value) {
60
60
  fs.unlinkSync(temporary);
61
61
  }
62
62
  }
63
- function readSetupRequest(file) {
63
+ function readSetupRequest(file, validateExecutor = true) {
64
64
  const value = regularJson(file);
65
65
  const required = ["schema_id", "request_id", "identity", "previous_selection", "phase", "installation"];
66
66
  if (!value || typeof value !== "object" || Array.isArray(value)
@@ -77,7 +77,7 @@ function readSetupRequest(file) {
77
77
  || !["prepared", "canon_cached", "engine_cached", "profiles_saved", "committed"].includes(request.phase)
78
78
  || !request.installation || typeof request.installation !== "object" || Array.isArray(request.installation))
79
79
  refuse("invalid_request_record");
80
- if (Object.hasOwn(request, "executor") && request.executor !== null) {
80
+ if (validateExecutor && Object.hasOwn(request, "executor") && request.executor !== null) {
81
81
  try {
82
82
  prepareOperatorProfile(request.executor);
83
83
  }
@@ -85,7 +85,7 @@ function readSetupRequest(file) {
85
85
  refuse("invalid_request_executor");
86
86
  }
87
87
  }
88
- if (Object.hasOwn(request, "profileCreation")) {
88
+ if (validateExecutor && Object.hasOwn(request, "profileCreation")) {
89
89
  try {
90
90
  validateProfileCreationIntent(request.profileCreation);
91
91
  }
@@ -225,22 +225,72 @@ export function commitSetupOnboarding(home, requestId, selection) {
225
225
  return result;
226
226
  }
227
227
  /** Read-only global readiness. No SQLite connection, migration, project registration or probes. */
228
+ function validateInstallation(root, installation) {
229
+ if (!installation.manifest || typeof installation.manifest_bytes_base64 !== "string" || validateHybridShape("release-manifest", installation.manifest).length ||
230
+ installation.canon_root !== path.join(root, "release-cache", "canon", installation.manifest.canon.version, "payload") ||
231
+ installation.engine_root !== path.join(root, "release-cache", "engine", installation.manifest.engine.version, "payload"))
232
+ refuse("invalid_installation_selection");
233
+ const bytes = Buffer.from(installation.manifest_bytes_base64, "base64");
234
+ if (bytes.toString("base64") !== installation.manifest_bytes_base64 || bytesDigest(bytes) !== installation.manifest_sha256 ||
235
+ hybridDigest(manifestFromBytes(bytes)) !== hybridDigest(installation.manifest))
236
+ refuse("invalid_installation_selection");
237
+ }
238
+ /** Independently committed canonical data, never activation or old code
239
+ * admission. A missing old engine cache is irrelevant to comparing documents.
240
+ * Same canon commit/version with different archive bytes is not disambiguated
241
+ * by picking the newest CLI or whichever request happens to be listed first.
242
+ */
243
+ export function readCommittedCanonRelease(home, version, commit, expectedInstallationSha256) {
244
+ const root = path.resolve(home), directory = path.join(root, "setup-requests");
245
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(version) || !/^[a-f0-9]{40}$/.test(commit)
246
+ || expectedInstallationSha256 !== undefined && !/^[a-f0-9]{64}$/.test(expectedInstallationSha256))
247
+ refuse("invalid_canonical_baseline_selection");
248
+ for (const target of [root, directory])
249
+ if (fs.existsSync(target) && !fs.lstatSync(target).isDirectory())
250
+ refuse("unsafe_setup_directory");
251
+ if (!fs.existsSync(directory))
252
+ return null;
253
+ const matches = [];
254
+ for (const name of fs.readdirSync(directory).filter(name => name.endsWith(".json")).sort()) {
255
+ const request = readSetupRequest(path.join(directory, name), false);
256
+ if (`${request.request_id}.json` !== name)
257
+ refuse("request_identity_conflict");
258
+ if (request.phase !== "committed" || request.installation.manifest?.canon?.version !== version || request.installation.manifest.canon.commit !== commit)
259
+ continue;
260
+ if (expectedInstallationSha256 !== undefined && request.installation.manifest_sha256 !== expectedInstallationSha256)
261
+ continue;
262
+ validateInstallation(root, request.installation);
263
+ const binding = { installation: request.installation,
264
+ ...(Object.hasOwn(request, "executor") ? { executor: request.executor } : {}),
265
+ ...(request.profileCreation ? { profileCreation: request.profileCreation } : {}),
266
+ ...(request.deferredOnboarding ? { deferredOnboarding: true } : {}) };
267
+ if (!["install", "update"].some(action => hybridDigest({ action, ...binding }) === request.identity))
268
+ refuse("request_identity_conflict");
269
+ matches.push(request.installation);
270
+ }
271
+ if (!matches.length)
272
+ return null;
273
+ if (new Set(matches.map(installation => `${installation.manifest.canon.archive.sha256}:${installation.manifest.canon.archive.bytes}`)).size !== 1)
274
+ refuse("ambiguous_canonical_baseline");
275
+ const selected = matches.sort((left, right) => left.manifest_sha256.localeCompare(right.manifest_sha256, "en"))[0];
276
+ try {
277
+ verifyPublishedReleaseCache(path.dirname(selected.canon_root), selected.manifest.canon.archive);
278
+ }
279
+ catch (error) {
280
+ if (error.code === "ENOENT")
281
+ return null;
282
+ throw error;
283
+ }
284
+ return structuredClone(selected);
285
+ }
228
286
  export function readSetupStatus(home) {
229
287
  const root = path.resolve(home);
230
288
  if (fs.existsSync(root) && !fs.lstatSync(root).isDirectory())
231
289
  refuse("unsafe_setup_home");
232
290
  const file = path.join(root, "installation.json");
233
291
  const installation = fs.existsSync(file) ? regularJson(file) : null;
234
- if (installation) {
235
- if (!installation.manifest || typeof installation.manifest_bytes_base64 !== "string" || validateHybridShape("release-manifest", installation.manifest).length ||
236
- installation.canon_root !== path.join(root, "release-cache", "canon", installation.manifest.canon.version, "payload") ||
237
- installation.engine_root !== path.join(root, "release-cache", "engine", installation.manifest.engine.version, "payload"))
238
- refuse("invalid_installation_selection");
239
- const bytes = Buffer.from(installation.manifest_bytes_base64, "base64");
240
- if (bytes.toString("base64") !== installation.manifest_bytes_base64 || bytesDigest(bytes) !== installation.manifest_sha256 ||
241
- hybridDigest(manifestFromBytes(bytes)) !== hybridDigest(installation.manifest))
242
- refuse("invalid_installation_selection");
243
- }
292
+ if (installation)
293
+ validateInstallation(root, installation);
244
294
  let readiness = installation ? "installed" : "not_installed";
245
295
  if (installation) {
246
296
  try {
@@ -9,7 +9,7 @@ import { finishVnextMerge, startVnextMerge } from "./vnext-merge.js";
9
9
  import { ResolvedFlowDescriptor } from "./flow-descriptors.js";
10
10
  import { vnextStages } from "../domain/stage-catalog.js";
11
11
  import { sdlcHandlerIdentities as ids } from "../domain/stage-handler-identities.js";
12
- import { memoryInitStageHandlers } from "./memory-stage-dispatch.js";
12
+ import { memoryInitStageHandlers, memoryUpgradeStageHandlers } from "./memory-stage-dispatch.js";
13
13
  /** Engine-owned code, not a plugin registration surface or executable descriptor expression. */
14
14
  export const sdlcStageHandlers = Object.freeze({
15
15
  specify: Object.freeze({ ...ids.specify, entry: launchVnextSpecify, finish: finishVnextSpecifyStage }),
@@ -23,8 +23,8 @@ export const sdlcStageHandlers = Object.freeze({
23
23
  /** A fresh inventory cannot mutate the registered handler functions. */
24
24
  export function bundledHandlerContext(blobs) {
25
25
  return {
26
- entryHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers)].map(handler => handler.entryId)),
27
- finishHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers)].map(handler => handler.finishId)), blobs
26
+ entryHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers), ...Object.values(memoryUpgradeStageHandlers)].map(handler => handler.entryId)),
27
+ finishHandlers: new Set([...Object.values(sdlcStageHandlers), ...Object.values(memoryInitStageHandlers), ...Object.values(memoryUpgradeStageHandlers)].map(handler => handler.finishId)), blobs
28
28
  };
29
29
  }
30
30
  /** Handler identity is also stage/flow scoped: a known CODE handler is not a memory handler. */
@@ -40,8 +40,9 @@ export function admitBundledFlow(input, sha256, blobs) {
40
40
  throw new AppError("flow_handler_scope_invalid", "SDLC handlers require the registered seven-stage layout and transitions", 2, { phase: "prepare", effect: "no_effect" });
41
41
  }
42
42
  }
43
- if (flow.descriptor.id === "mb-init") {
44
- const stages = Object.keys(memoryInitStageHandlers);
43
+ if (flow.descriptor.id === "mb-init" || flow.descriptor.id === "mb-upgrade") {
44
+ const upgrade = flow.descriptor.id === "mb-upgrade";
45
+ const stages = Object.keys(upgrade ? memoryUpgradeStageHandlers : memoryInitStageHandlers);
45
46
  const edges = stages.slice(0, -1).map((from, index) => ({ from, to: stages[index + 1], on: "accepted" }));
46
47
  // Historical packs have only accepted transitions; diagnostic VERIFY uses
47
48
  // the same REVIEW handler without granting another route or stage.
@@ -59,12 +60,13 @@ export function admitBundledFlow(input, sha256, blobs) {
59
60
  schemaId = JSON.parse(flow.stageBlob(stage.id, "result_schema").toString("utf8")).$id;
60
61
  }
61
62
  catch { /* Invalid schema cannot authorize this stage handler. */ }
62
- if (schemaId !== `dd-flow/mb-init-${stage.id}-result@1`)
63
+ if (schemaId !== (upgrade ? "dd-flow/mb-upgrade-stage-result@1" : `dd-flow/mb-init-${stage.id}-result@1`))
63
64
  throw new AppError("flow_handler_scope_invalid", "Memory init stage requires its own registered result schema", 2, { phase: "prepare", effect: "no_effect", stage: stage.id });
64
65
  }
65
66
  }
66
67
  for (const stage of flow.descriptor.stages) {
67
- const handlers = flow.descriptor.id === "sdlc" ? Object.values(sdlcStageHandlers) : flow.descriptor.id === "mb-init" ? Object.values(memoryInitStageHandlers) : [];
68
+ const handlers = flow.descriptor.id === "sdlc" ? Object.values(sdlcStageHandlers) : flow.descriptor.id === "mb-init" ? Object.values(memoryInitStageHandlers)
69
+ : flow.descriptor.id === "mb-upgrade" ? Object.values(memoryUpgradeStageHandlers) : [];
68
70
  const handler = handlers.find(candidate => candidate.stage === stage.id);
69
71
  if (!handler || stage.entry_handler !== handler.entryId || stage.finish_handler !== handler.finishId) {
70
72
  throw new AppError("flow_handler_scope_invalid", "Registered handlers do not belong to this flow stage", 2, { phase: "prepare", effect: "no_effect", flow_id: flow.descriptor.id, stage: stage.id });
@@ -17,7 +17,7 @@ import { assertRunMutationAllowed } from "./run-recovery.js";
17
17
  import { runtimeCommand } from "./runtime-command.js";
18
18
  import { prepareAgentOutputFile } from "../storage/agent-output.js";
19
19
  import { latestRunVerificationMatrix } from "./verification-matrix.js";
20
- import { validateAcceptedIntentReview } from "./accepted-intent.js";
20
+ import { acceptedIntentReviewFingerprint, prepareAcceptedIntentSources, validatePreparedAcceptedIntentReview } from "./accepted-intent.js";
21
21
  export { flowCommand } from "./runtime-command.js";
22
22
  export function prepareStagePause(context, input) {
23
23
  if (typeof input.question !== "string" || !input.question.trim())
@@ -116,7 +116,31 @@ export function resumeStageAfterUser(context, input) {
116
116
  // reserving SQLite's writer; an unreferenced candidate has no flow authority.
117
117
  publishImmutableInput(answerPath, Buffer.from(input.answer), "hitl_answer_conflict");
118
118
  const answerPacket = prepareStagePacket(answerPath, Buffer.from(input.answer));
119
- context.db.writeTransaction(() => {
119
+ // Capture the exact continuation inputs and already-issued commands before
120
+ // accepting the answer. A publication retry must not issue new authority.
121
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
122
+ const resumeSourcePath = path.join(path.dirname(pause.question_path), `resume-${crypto.createHash("sha256").update(`${pause.id}\0${input.answer}`).digest("hex")}.json`);
123
+ let review;
124
+ let reviewPreparationError;
125
+ const previousAnswer = context.db.get("SELECT MAX(id) AS id FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ?", [project.id, run.id])?.id ?? 0;
126
+ if (["plan-review", "code-review"].includes(stage.stage)) {
127
+ try {
128
+ const home = requireRunHome(run), source = latestRunVerificationMatrix(context, project.id, run.id, stage.stage, "input");
129
+ const reviewContext = path.join(home, stage.stage === "plan-review" ? "04-plan-review/work-context.json" : "06-code-review/review-context.json");
130
+ const retainedContext = fs.existsSync(reviewContext) ? JSON.parse(fs.readFileSync(reviewContext, "utf8")) : null;
131
+ const currentSources = acceptedIntentReviewFingerprint(source?.metadata.accepted_sources) ? prepareAcceptedIntentSources(context, { projectId: project.id, runId: run.id, home, stageDirectory: "intake", marked: true }).sources : [];
132
+ review = { home, source, retainedContext, currentSources };
133
+ }
134
+ catch (error) {
135
+ if (!(error instanceof AppError))
136
+ throw error;
137
+ reviewPreparationError = error;
138
+ }
139
+ }
140
+ return context.db.writeTransaction(() => {
141
+ const currentAnswer = context.db.get("SELECT MAX(id) AS id FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ?", [project.id, run.id])?.id ?? 0;
142
+ if (currentAnswer !== previousAnswer)
143
+ throw new AppError("runtime_state_changed", "Accepted inputs changed during resume preparation", 1);
120
144
  const identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
121
145
  if (workSession.session_id !== identity.sessionId) {
122
146
  bindSessionForResume(context, work.work_id, identity, now);
@@ -130,49 +154,56 @@ export function resumeStageAfterUser(context, input) {
130
154
  acceptStagePacket(context, { projectId: project.id, runId: run.id, stage: stage.stage, prepared: answerPacket });
131
155
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
132
156
  context.db.afterCommit(() => refreshRunWorkProjection(context, project.id, run.id));
133
- });
134
- const prompt = evidence.prompt;
135
- const retryCommands = lifecycleRetryCommands(context, stage.stage);
136
- const intentInstructions = [];
137
- if (["plan-review", "code-review"].includes(stage.stage)) {
138
- try {
139
- const home = requireRunHome(run), source = latestRunVerificationMatrix(context, project.id, run.id, stage.stage, "input");
140
- const reviewContext = path.join(home, stage.stage === "plan-review" ? "04-plan-review/work-context.json" : "06-code-review/review-context.json");
141
- const retainedContext = fs.existsSync(reviewContext) ? JSON.parse(fs.readFileSync(reviewContext, "utf8")) : null;
142
- validateAcceptedIntentReview(context, { projectId: project.id, runId: run.id, home, stage: stage.stage, decisionFrozen: Boolean(retainedContext?.accepted_decision) }, source?.metadata.accepted_sources, retainedContext?.accepted_intent_review_fingerprint);
143
- }
144
- catch (error) {
145
- if (!(error instanceof AppError))
146
- throw error;
147
- intentInstructions.push(`Accepted-input review boundary: ${error.code}: ${error.message}`);
148
- if (error.code === "accepted_intent_review_ack_required")
149
- intentInstructions.push(`Apply/classify this own-stage clarification through the existing coordinator correction/repair route. Include this exact field in the decision: ${JSON.stringify({ accepted_intent_acknowledgement: error.details.accepted_intent_acknowledgement })}. Independent reviewer results prove only the earlier retained inputs; do not claim they reviewed the new answer.`);
150
- else
151
- intentInstructions.push("Do not accept stale independent review as coverage of this answer; report the actionable upstream revision/evidence blocker.");
157
+ const prompt = evidence.prompt;
158
+ const intentInstructions = [];
159
+ if (["plan-review", "code-review"].includes(stage.stage)) {
160
+ try {
161
+ if (reviewPreparationError)
162
+ throw reviewPreparationError;
163
+ if (review) {
164
+ const id = context.db.get("SELECT id FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.pause_id') = ? ORDER BY id DESC LIMIT 1", [project.id, run.id, pause.id]).id;
165
+ const identity = `accepted_hitl:${String(id).padStart(12, "0")}:${pause.id}`;
166
+ const sources = [...review.currentSources, { role: `${identity}:0-question`, sha256: crypto.createHash("sha256").update(evidence.question).digest("hex") }, { role: `${identity}:1-answer`, sha256: crypto.createHash("sha256").update(input.answer).digest("hex") }];
167
+ validatePreparedAcceptedIntentReview(context, { projectId: project.id, runId: run.id, home: review.home, stage: stage.stage, decisionFrozen: Boolean(review.retainedContext?.accepted_decision) }, review.source?.metadata.accepted_sources, review.retainedContext?.accepted_intent_review_fingerprint, sources);
168
+ }
169
+ }
170
+ catch (error) {
171
+ if (!(error instanceof AppError))
172
+ throw error;
173
+ intentInstructions.push(`Accepted-input review boundary: ${error.code}: ${error.message}`);
174
+ if (error.code === "accepted_intent_review_ack_required")
175
+ intentInstructions.push(`Apply/classify this own-stage clarification through the existing coordinator correction/repair route. Include this exact field in the decision: ${JSON.stringify({ accepted_intent_acknowledgement: error.details.accepted_intent_acknowledgement })}. Independent reviewer results prove only the earlier retained inputs; do not claim they reviewed the new answer.`);
176
+ else
177
+ intentInstructions.push("Do not accept stale independent review as coverage of this answer; report the actionable upstream revision/evidence blocker.");
178
+ }
152
179
  }
153
- }
154
- const continuation = [
155
- prompt.trimEnd(),
156
- "",
157
- "<hitl_resume>",
158
- `- Pause: ${pause.id}`,
159
- `- Question source: ${pause.question_path}`,
160
- `- Answer source: ${answerPath}`,
161
- "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
162
- ...intentInstructions,
163
- ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; correct the rejected input and use only these replacements:", ...retryCommands.map(command => ` ${command}`)] : []),
164
- "",
165
- "<user_question>",
166
- evidence.question,
167
- "</user_question>",
168
- "",
169
- "<user_answer>",
170
- input.answer,
171
- "</user_answer>",
172
- "</hitl_resume>",
173
- ""
174
- ].join("\n");
175
- return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
180
+ const continuation = [
181
+ prompt.trimEnd(),
182
+ "",
183
+ "<hitl_resume>",
184
+ `- Pause: ${pause.id}`,
185
+ `- Question source: ${pause.question_path}`,
186
+ `- Answer source: ${answerPath}`,
187
+ "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
188
+ ...intentInstructions,
189
+ ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; correct the rejected input and use only these replacements:", ...retryCommands.map(command => ` ${command}`)] : []),
190
+ "",
191
+ "<user_question>",
192
+ evidence.question,
193
+ "</user_question>",
194
+ "",
195
+ "<user_answer>",
196
+ input.answer,
197
+ "</user_answer>",
198
+ "</hitl_resume>",
199
+ ""
200
+ ].join("\n");
201
+ const result = { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: pause.id, question_path: pause.question_path, answer_path: answerPath, resume_source_path: resumeSourcePath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
202
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE ? JSON.parse(context.env.DD_FLOW_INVOCATION_SCOPE) : null;
203
+ const bytes = Buffer.from(`${JSON.stringify({ invocation_id: context.env.DD_FLOW_CURRENT_INVOCATION ?? null, generation: scope?.generation ?? null, result })}\n`);
204
+ acceptStagePacket(context, { projectId: project.id, runId: run.id, stage: stage.stage, prepared: { file: resumeSourcePath, bytes, sha256: crypto.createHash("sha256").update(bytes).digest("hex") } });
205
+ return result;
206
+ });
176
207
  }
177
208
  export function stagePauseCommand(context, input) {
178
209
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
@@ -180,6 +211,39 @@ export function stagePauseCommand(context, input) {
180
211
  const questionInputPath = prepareAgentOutputFile(path.join(requireRunHome(run), "works", input.workId, "question-input.md"));
181
212
  return managedLifecycleCommand(context, runtimeCommand(context, ["stage", "pause", shortEntityReference(input.runId), "--stage", input.stage, "--work", shortEntityReference(input.workId), "--question-file", questionInputPath, "--project-root", input.projectRoot, "--json"]));
182
213
  }
214
+ /** Recover only the exact resume committed with this invocation; never resume again. */
215
+ export function recoverCommittedStageResume(context, input) {
216
+ const project = requireProjectByRoot(context, input.projectRoot), run = requireRun(context, project.id, input.runId);
217
+ const work = requireWork(context, project.id, run.id, input.workId);
218
+ const index = JSON.parse(run.index_json);
219
+ const current = index.stage_runs.find(stage => stage.stage === input.stage);
220
+ if (!current || current.status !== "running" || index.current_stage && index.current_stage !== input.stage || work.status !== "running")
221
+ return null;
222
+ const resumed = context.db.get("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.stage') = ? AND json_extract(payload_json, '$.work_id') = ? ORDER BY id DESC LIMIT 1", [project.id, run.id, input.stage, work.work_id]);
223
+ if (!resumed)
224
+ return null;
225
+ const acceptedPause = JSON.parse(resumed.payload_json);
226
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.stage_packet' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.stage') = ? AND json_extract(payload_json, '$.attempt') = ? AND json_extract(payload_json, '$.file') LIKE '%/resume-%.json' ORDER BY id DESC LIMIT 257", [project.id, run.id, input.stage, current.attempt]);
227
+ if (rows.length > 256)
228
+ throw new AppError("stage_resume_receipt_invalid", "Resume receipt count exceeds its bound", 1);
229
+ let result = null;
230
+ for (const row of rows) {
231
+ const packet = JSON.parse(row.payload_json);
232
+ const bytes = Buffer.from(packet.base64, "base64");
233
+ if (bytes.length > 8 * 1024 * 1024 || bytes.toString("base64") !== packet.base64 || crypto.createHash("sha256").update(bytes).digest("hex") !== packet.sha256)
234
+ throw new AppError("stage_resume_receipt_invalid", "Resume receipt custody differs from its committed bytes", 1);
235
+ const retained = JSON.parse(bytes.toString("utf8"));
236
+ if (retained.invocation_id !== input.id)
237
+ continue;
238
+ const value = retained.result;
239
+ if (retained.generation !== input.generation || !value || value.run_id !== run.id || value.work_id !== work.work_id || value.stage !== input.stage
240
+ || value.pause_id !== acceptedPause.pause_id || value.answer_path !== acceptedPause.answer_path || value.resume_source_path !== packet.file || typeof value.worker_prompt_markdown !== "string"
241
+ || result && JSON.stringify(result) !== JSON.stringify(value))
242
+ throw new AppError("stage_resume_receipt_invalid", "Resume receipt does not match the current invocation and accepted pause", 1);
243
+ result = value;
244
+ }
245
+ return result;
246
+ }
183
247
  /** Render separate file-write and lifecycle-call instructions for HITL. */
184
248
  export function stagePauseCommandTemplate(command) {
185
249
  const questionFile = command.match(/--question-file\s+(?:'([^']*)'|"([^"]*)"|(\S+))/)?.slice(1).find(Boolean) ?? "<question-file-from-command>";
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { parse as parseYaml } from "yaml";
5
- const canonicalOnlyFlowNames = ["mb-init.md", "mb-upgrade.md", "mb-distill.md"];
5
+ export const canonicalOnlyFlowNames = Object.freeze(["mb-init.md", "mb-upgrade.md", "mb-distill.md"]);
6
6
  export function resolveStatusProjectRoot(input) {
7
7
  if (input.requestedRoot) {
8
8
  const absolute = path.resolve(input.requestedRoot);
@@ -105,7 +105,7 @@ export function verifiedNativeWorkFailure(context, input) {
105
105
  }
106
106
  export function fanoutState(input) {
107
107
  if (!input.hasWork)
108
- return input.dispatch === "none" ? "coordinator_required" : "dispatch_required";
108
+ return input.registeredEmpty ? "children_settled" : input.dispatch === "none" ? "coordinator_required" : "dispatch_required";
109
109
  if (input.capacityRequired && !input.capacityKnown)
110
110
  return "capacity_required";
111
111
  if (input.created || input.running)
@@ -133,14 +133,17 @@ export function readFanoutDescriptor(stageRoot) {
133
133
  * productive assignments, including reviewer/correction waves.
134
134
  */
135
135
  export function memoryStageFanoutDescriptor(context, input) {
136
- if (!["compose", "review"].includes(input.stage))
136
+ if (!["compose", "upgrade", "review"].includes(input.stage))
137
137
  return null;
138
138
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
139
139
  const run = context.db.get("SELECT flow_kind,index_json FROM runs WHERE project_id=? AND id=?", [project.id, input.runId]);
140
- if (run?.flow_kind !== "mb-init")
140
+ if (!run || !["mb-init", "mb-upgrade"].includes(run.flow_kind))
141
+ return null;
142
+ const authoringStage = run.flow_kind === "mb-init" ? "compose" : "upgrade";
143
+ if (input.stage !== authoringStage && input.stage !== "review")
141
144
  return null;
142
145
  const bundle = readFlowRunExecutionBundle(context, input);
143
- if (!bundle || bundle.descriptor.descriptor.id !== "mb-init")
146
+ if (!bundle || bundle.descriptor.descriptor.id !== run.flow_kind)
144
147
  throw new AppError("stage_graph_compatibility_required", "Memory graph requires its admitted RUN bundle", 1);
145
148
  const index = JSON.parse(run.index_json);
146
149
  if (index.current_stage !== input.stage || !index.stage_runs.some(stage => stage.stage === input.stage && stage.status === "running"))
@@ -153,7 +156,7 @@ export function memoryStageFanoutDescriptor(context, input) {
153
156
  if (id === root.workId)
154
157
  continue;
155
158
  const work = context.db.get("SELECT parent_work_id,result_schema FROM works WHERE project_id=? AND run_id=? AND work_id=?", [project.id, input.runId, id]);
156
- const schemas = input.stage === "compose" ? ["dd-flow/memory-composition-work-result@1"] : ["dd-flow/memory-review-work-result@1", "dd-flow/memory-repair-work-result@1"];
159
+ const schemas = input.stage === authoringStage ? ["dd-flow/memory-composition-work-result@1"] : ["dd-flow/memory-review-work-result@1", "dd-flow/memory-repair-work-result@1"];
157
160
  if (!work || work.parent_work_id !== root.workId || !schemas.includes(work.result_schema ?? ""))
158
161
  throw new AppError("stage_graph_conflict", "Memory graph contains an unassigned Work", 1);
159
162
  const custody = readWorkBatchCustody(context, id);
@@ -161,11 +164,11 @@ export function memoryStageFanoutDescriptor(context, input) {
161
164
  || custody.scope.cycle !== scope.cycle || custody.scope.stage !== input.stage || custody.scope.parent_work_id !== root.workId)
162
165
  throw new AppError("stage_graph_conflict", "Memory graph contains foreign allocation custody", 1);
163
166
  }
164
- if (input.stage === "compose") {
167
+ if (input.stage === authoringStage) {
165
168
  const children = context.db.all("SELECT work_id FROM works WHERE project_id=? AND run_id=? AND parent_work_id=?", [project.id, input.runId, root.workId]).filter(work => members.has(work.work_id));
166
169
  const registered = context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type='memory.composition_registration' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.attempt')=? AND json_extract(payload_json,'$.scope.cycle')=? LIMIT 1", [project.id, input.runId, scope.attempt, scope.cycle]);
167
170
  if (children.length || registered) {
168
- const registration = selectMemoryCompositionRegistration(context, { ...scope, stage: "compose", parent_work_id: root.workId });
171
+ const registration = selectMemoryCompositionRegistration(context, { ...scope, stage: authoringStage, parent_work_id: root.workId });
169
172
  const ids = new Set(Object.values(registration.work_ids));
170
173
  if (ids.size !== children.length || children.some(work => !ids.has(work.work_id)))
171
174
  throw new AppError("stage_graph_conflict", "Memory composition differs from its registered graph", 1);
@@ -185,7 +188,8 @@ export function getVnextFanoutStatus(context, input) {
185
188
  const index = JSON.parse(run.index_json);
186
189
  const bundle = index.execution_bundle ? readFlowRunExecutionBundle(context, { projectRoot, runId: input.runId }) : null;
187
190
  const stageRoot = path.join(run.run_root, bundle ? bundle.descriptor.stage(input.stage).directory : vnextStageDirectory(input.stage));
188
- let descriptor = memoryStageFanoutDescriptor(context, input) ?? readFanoutDescriptor(stageRoot);
191
+ const memoryDescriptor = memoryStageFanoutDescriptor(context, input);
192
+ let descriptor = memoryDescriptor ?? readFanoutDescriptor(stageRoot);
189
193
  // Registration is authoritative even if projection publication was interrupted,
190
194
  // or an otherwise review-off stage acquired an aggregate repair graph.
191
195
  if (!descriptor && context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'repair_intents'")) {
@@ -211,6 +215,14 @@ export function getVnextFanoutStatus(context, input) {
211
215
  const ready = { works: all.works.filter(work => work.ready) };
212
216
  const counts = Object.fromEntries(["created", "running", "completed", "failed", "cancelled"].map((status) => [status, all.works.filter((work) => work.status === status).length]));
213
217
  const hasWork = all.works.length > 0;
218
+ // Only SQL-owned memory registration can attest a deliberately empty
219
+ // graph. A mutable fanout.json must never turn missing Work into success.
220
+ let registeredEmpty = false;
221
+ if (memoryDescriptor && input.stage === "upgrade" && !hasWork
222
+ && context.db.get("SELECT 1 FROM audit_events WHERE project_id=? AND event_type='memory.composition_registration' AND json_extract(payload_json,'$.run_id')=? AND json_extract(payload_json,'$.scope.stage')='upgrade' AND json_extract(payload_json,'$.scope.attempt')=? AND json_extract(payload_json,'$.scope.cycle')=? AND json_extract(payload_json,'$.scope.parent_work_id')=? LIMIT 1", [project.id, input.runId, currentScope.attempt, currentScope.cycle, descriptor.parent_work_id])) {
223
+ const registered = selectMemoryCompositionRegistration(context, { ...currentScope, stage: "upgrade", parent_work_id: descriptor.parent_work_id });
224
+ registeredEmpty = Object.keys(registered.work_ids).length === 0;
225
+ }
214
226
  const capacityKnown = capacity.available_slots !== null;
215
227
  return {
216
228
  ok: true,
@@ -221,7 +233,7 @@ export function getVnextFanoutStatus(context, input) {
221
233
  // PLAN-REVIEW has a deterministic dispatcher. CODE and CODE-REVIEW do
222
234
  // not: their coordinator first materializes the Work graph from its
223
235
  // authoritative prompt. An empty graph is therefore not a success.
224
- state: fanoutState({ dispatch: descriptor.dispatch, hasWork, capacityRequired: descriptor.capacity_required, capacityKnown, created: counts.created ?? 0, running: counts.running ?? 0 }),
236
+ state: fanoutState({ dispatch: descriptor.dispatch, hasWork, registeredEmpty, capacityRequired: descriptor.capacity_required, capacityKnown, created: counts.created ?? 0, running: counts.running ?? 0 }),
225
237
  capacity,
226
238
  graph_revision: all.works.map(work => ({ work_id: work.work_id, status: work.status, depends_on: work.depends_on })),
227
239
  works: { ...counts, ready: ready.works.map((work) => ({ work_id: work.work_id, task: work.task, launch_policy: work.launch_policy, start_command: work.start_command })) }