@deksden-com/dd-flow-cli 0.9.0-beta.152 → 0.9.0-beta.156

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 (227) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/build-info.json +8 -8
  3. package/dist/cli/command-inputs.js +33 -3
  4. package/dist/cli/command-metadata.js +73 -2
  5. package/dist/cli/delivery.js +108 -0
  6. package/dist/cli/diagnostic-invocation.js +23 -7
  7. package/dist/cli/diagnostic.js +14 -3
  8. package/dist/cli/help.js +117 -1
  9. package/dist/cli/hook-ingress.js +36 -49
  10. package/dist/cli/interactive-invocation.js +33 -0
  11. package/dist/cli/interactive.js +114 -0
  12. package/dist/cli/memory-fix-invocation.js +52 -0
  13. package/dist/cli/memory-fix.js +100 -0
  14. package/dist/cli/memory-flow-admission.js +7 -3
  15. package/dist/cli/memory-init.js +5 -4
  16. package/dist/cli/native-hook-ingress.js +5 -1
  17. package/dist/cli/native-hook-preparation.js +31 -4
  18. package/dist/cli/run-cli.js +274 -19
  19. package/dist/cli.js +17 -3
  20. package/dist/domain/delivery-flows.js +8 -0
  21. package/dist/domain/distill-flow.js +20 -0
  22. package/dist/domain/interactive-flow.js +1 -0
  23. package/dist/domain/memory-fix-flow.js +2 -0
  24. package/dist/domain/review-fix-flow.js +2 -0
  25. package/dist/domain/source-snapshot-flows.js +4 -0
  26. package/dist/harness-runtime/bin/dd-agy.mjs +18 -3
  27. package/dist/harness-runtime/bin/dd-grok.mjs +15 -5
  28. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  29. package/dist/harness-runtime/lib/agy-error-boundary.mjs +18 -10
  30. package/dist/harness-runtime/lib/agy-native-inspection.mjs +12 -3
  31. package/dist/harness-runtime/lib/agy-native-inventory.mjs +19 -0
  32. package/dist/harness-runtime/lib/agy-terminal-envelope.mjs +13 -6
  33. package/dist/harness-runtime/lib/agy-transcript-stream.mjs +54 -0
  34. package/dist/harness-runtime/lib/daemon-observation.d.mts +1 -0
  35. package/dist/harness-runtime/lib/daemon-observation.mjs +12 -4
  36. package/dist/harness-runtime/lib/daemon-operations.mjs +20 -5
  37. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +371 -114
  39. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +43 -3
  40. package/dist/harness-runtime/lib/dd-codex.mjs +36 -4
  41. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +25 -11
  42. package/dist/harness-runtime/lib/dd-droid.mjs +124 -11
  43. package/dist/harness-runtime/lib/dd-grok-daemon.d.mts +1 -0
  44. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +137 -25
  45. package/dist/harness-runtime/lib/dd-grok.mjs +4 -2
  46. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +115 -17
  47. package/dist/harness-runtime/lib/dd-opencode.mjs +10 -7
  48. package/dist/harness-runtime/lib/dd-zcode-daemon.d.mts +1 -0
  49. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +115 -22
  50. package/dist/harness-runtime/lib/dd-zcode.mjs +75 -36
  51. package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -1
  52. package/dist/harness-runtime/lib/delegation-instructions.mjs +15 -5
  53. package/dist/harness-runtime/lib/delivery-command.mjs +84 -0
  54. package/dist/harness-runtime/lib/driver-recovery.mjs +0 -19
  55. package/dist/harness-runtime/lib/droid-observation.mjs +9 -5
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +1 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +2 -1
  58. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -2
  59. package/dist/harness-runtime/lib/native-hook-command.mjs +63 -8
  60. package/dist/harness-runtime/lib/native-streams.mjs +3 -10
  61. package/dist/harness-runtime/lib/opencode-plugin.mjs +36 -9
  62. package/dist/harness-runtime/lib/operation-errors.mjs +13 -0
  63. package/dist/harness-runtime/lib/operation-progress.mjs +6 -2
  64. package/dist/harness-runtime/lib/process-json.mjs +4 -5
  65. package/dist/harness-runtime/lib/tool-observations.mjs +20 -10
  66. package/dist/harness-runtime/lib/zcode-native-hook-ownership.mjs +109 -0
  67. package/dist/schemas/code-work-result.schema.json +2 -1
  68. package/dist/schemas/delivery-stage-result.schema.json +286 -0
  69. package/dist/schemas/distill-stage-result.schema.json +667 -0
  70. package/dist/schemas/distill-work-result.schema.json +428 -0
  71. package/dist/schemas/execution-bundle.schema.json +6 -2
  72. package/dist/schemas/interactive-stage-result.schema.json +47 -0
  73. package/dist/schemas/interactive-work-result.schema.json +63 -0
  74. package/dist/schemas/mb-fix-stage-result.schema.json +93 -0
  75. package/dist/schemas/project-flow-pack-manifest.schema.json +2 -2
  76. package/dist/schemas/review-fix-stage-result.schema.json +210 -0
  77. package/dist/schemas/run-control-receipt.schema.json +1 -0
  78. package/dist/services/assigned-lifecycle.js +382 -0
  79. package/dist/services/check-runner-evidence.js +45 -8
  80. package/dist/services/cli-operation-classifier.js +6 -1
  81. package/dist/services/code-checks.js +46 -29
  82. package/dist/services/codex-hook-delivery.js +11 -2
  83. package/dist/services/controller-child-repair.js +95 -0
  84. package/dist/services/controller-execution.js +4 -1
  85. package/dist/services/controller-fanout.js +19 -9
  86. package/dist/services/controller-memory-worker.js +6 -4
  87. package/dist/services/controller-result-authority.js +122 -0
  88. package/dist/services/dashboard.js +4 -2
  89. package/dist/services/delivery-access.js +123 -0
  90. package/dist/services/delivery-approval.js +68 -0
  91. package/dist/services/delivery-artifact.js +86 -0
  92. package/dist/services/delivery-checks.js +178 -0
  93. package/dist/services/delivery-execution.js +181 -0
  94. package/dist/services/delivery-intents.js +212 -0
  95. package/dist/services/delivery-operation.js +81 -0
  96. package/dist/services/delivery-policy.js +91 -0
  97. package/dist/services/delivery-procedures.js +43 -0
  98. package/dist/services/delivery-request.js +99 -0
  99. package/dist/services/delivery-result.js +144 -0
  100. package/dist/services/delivery-stage-commands.js +26 -0
  101. package/dist/services/delivery-stage-entry.js +56 -0
  102. package/dist/services/delivery-stage-handlers.js +135 -0
  103. package/dist/services/delivery-stage-results.js +94 -0
  104. package/dist/services/descriptor-coordinator-session.js +3 -1
  105. package/dist/services/descriptor-run-start.js +24 -4
  106. package/dist/services/descriptor-stage-coordinator.js +78 -10
  107. package/dist/services/descriptor-stage-settlement.js +59 -14
  108. package/dist/services/diagnostic-repair-intake.js +123 -0
  109. package/dist/services/diagnostic-repair-source-intake.js +102 -0
  110. package/dist/services/diagnostic-report.js +30 -0
  111. package/dist/services/diagnostic-run-bundle.js +48 -9
  112. package/dist/services/diagnostic-snapshot.js +2 -2
  113. package/dist/services/diagnostic-stage-handlers.js +3 -2
  114. package/dist/services/diagnostic-stage-results.js +26 -0
  115. package/dist/services/diagnostic-start.js +56 -6
  116. package/dist/services/diagnostic-work-collection.js +3 -3
  117. package/dist/services/distill-document-publication.js +280 -0
  118. package/dist/services/distill-promotion.js +197 -0
  119. package/dist/services/distill-publication-custody.js +162 -0
  120. package/dist/services/distill-stage-handlers.js +249 -0
  121. package/dist/services/distill-stage-results.js +105 -0
  122. package/dist/services/distill-user-inputs.js +4 -0
  123. package/dist/services/distill-work-batch.js +42 -0
  124. package/dist/services/distill-work-collection.js +74 -0
  125. package/dist/services/distill-work-registration.js +93 -0
  126. package/dist/services/distill-work-results.js +98 -0
  127. package/dist/services/eval-snapshots.js +17 -7
  128. package/dist/services/external-work-launch.js +8 -2
  129. package/dist/services/flow-catalog.js +24 -0
  130. package/dist/services/frozen-engine.js +19 -2
  131. package/dist/services/generic-execution.js +4 -3
  132. package/dist/services/harness-adapter.js +2 -14
  133. package/dist/services/hook-memory-routing.js +1 -1
  134. package/dist/services/hooks.js +31 -6
  135. package/dist/services/hybrid-contracts.js +6 -1
  136. package/dist/services/installed-flow-source.js +37 -2
  137. package/dist/services/interactive-attached-session.js +215 -0
  138. package/dist/services/interactive-baseline-custody.js +54 -0
  139. package/dist/services/interactive-before-versions.js +148 -0
  140. package/dist/services/interactive-bundle-data.js +85 -0
  141. package/dist/services/interactive-checks.js +179 -0
  142. package/dist/services/interactive-execution.js +33 -0
  143. package/dist/services/interactive-finish-integration.js +236 -0
  144. package/dist/services/interactive-git-baseline.js +230 -0
  145. package/dist/services/interactive-git-dispatch.js +278 -0
  146. package/dist/services/interactive-input-delivery.js +82 -0
  147. package/dist/services/interactive-mutation-scope.js +231 -0
  148. package/dist/services/interactive-request.js +287 -0
  149. package/dist/services/interactive-run-bundle.js +109 -0
  150. package/dist/services/interactive-stage-entry.js +41 -0
  151. package/dist/services/interactive-stage-handlers.js +289 -0
  152. package/dist/services/interactive-stage-observation.js +136 -0
  153. package/dist/services/interactive-stage-results.js +111 -0
  154. package/dist/services/interactive-user-inputs.js +142 -0
  155. package/dist/services/interactive-work-batch.js +43 -0
  156. package/dist/services/interactive-work-collection.js +119 -0
  157. package/dist/services/interactive-work-completion.js +180 -0
  158. package/dist/services/interactive-work-context.js +249 -0
  159. package/dist/services/interactive-work-history.js +112 -0
  160. package/dist/services/interactive-work-registration.js +124 -0
  161. package/dist/services/interactive-work-results.js +115 -0
  162. package/dist/services/interactive-work-start.js +155 -0
  163. package/dist/services/lifecycle-invocations.js +250 -33
  164. package/dist/services/managed-daemon-binding.js +52 -1
  165. package/dist/services/memory-compose-handler.js +2 -22
  166. package/dist/services/memory-finish-integration.js +58 -17
  167. package/dist/services/memory-fix-finish-integration.js +129 -0
  168. package/dist/services/memory-fix-git-dispatch.js +139 -0
  169. package/dist/services/memory-fix-intake-custody.js +11 -0
  170. package/dist/services/memory-fix-intake-settlement.js +127 -0
  171. package/dist/services/memory-fix-publication.js +196 -0
  172. package/dist/services/memory-fix-repair-admission.js +116 -0
  173. package/dist/services/memory-fix-request.js +141 -0
  174. package/dist/services/memory-fix-run-bundle.js +83 -0
  175. package/dist/services/memory-fix-scope.js +78 -0
  176. package/dist/services/memory-fix-sources.js +85 -0
  177. package/dist/services/memory-fix-stage-entry.js +24 -0
  178. package/dist/services/memory-fix-stage-handlers.js +282 -0
  179. package/dist/services/memory-fix-stage-results.js +106 -0
  180. package/dist/services/memory-fix-workspace.js +127 -0
  181. package/dist/services/memory-flow-user-inputs.js +88 -0
  182. package/dist/services/memory-init-contexts.js +55 -5
  183. package/dist/services/memory-init-controller-start.js +20 -4
  184. package/dist/services/memory-layer.js +7 -3
  185. package/dist/services/memory-native-review-copy.js +27 -3
  186. package/dist/services/memory-repair-document-delta.js +1 -0
  187. package/dist/services/project-flow-source.js +195 -0
  188. package/dist/services/project-git-integration.js +92 -0
  189. package/dist/services/repair-intents.js +2 -7
  190. package/dist/services/retained-native-hook.js +274 -0
  191. package/dist/services/review-fix-handoff.js +82 -0
  192. package/dist/services/review-fix-intake-custody.js +17 -0
  193. package/dist/services/review-fix-stage-entry.js +43 -0
  194. package/dist/services/review-fix-stage-handlers.js +176 -0
  195. package/dist/services/review-fix-stage-results.js +86 -0
  196. package/dist/services/run-control-worker.js +42 -1
  197. package/dist/services/run-control.js +40 -5
  198. package/dist/services/run-controller-adapter.js +37 -3
  199. package/dist/services/run-controller-process.js +25 -4
  200. package/dist/services/run-controller-recovery.js +46 -6
  201. package/dist/services/run-controller-state.js +3 -2
  202. package/dist/services/run-controller.js +257 -44
  203. package/dist/services/run-recovery.js +31 -2
  204. package/dist/services/run-timeline.js +59 -0
  205. package/dist/services/runs.js +18 -31
  206. package/dist/services/sessions.js +2 -1
  207. package/dist/services/stage-handlers.js +55 -3
  208. package/dist/services/stage-lifecycle.js +4 -3
  209. package/dist/services/stage-pause.js +26 -9
  210. package/dist/services/stage-report-renderer.js +2 -6
  211. package/dist/services/technical-notes.js +31 -0
  212. package/dist/services/verification-matrix.js +1 -1
  213. package/dist/services/version-status.js +1 -1
  214. package/dist/services/vnext-code-review.js +74 -5
  215. package/dist/services/vnext-code.js +8 -32
  216. package/dist/services/vnext-fanout.js +53 -8
  217. package/dist/services/vnext-merge.js +10 -5
  218. package/dist/services/vnext-plan-review.js +1 -1
  219. package/dist/services/vnext-plan.js +1 -1
  220. package/dist/services/vnext-protocolize.js +1 -1
  221. package/dist/services/vnext-specify.js +10 -5
  222. package/dist/services/work-registry.js +255 -69
  223. package/dist/services/worktrees.js +7 -4
  224. package/dist/storage/database.js +2 -1
  225. package/dist/verification-components.json +82 -0
  226. package/package.json +3 -2
  227. package/tools/verification-jobs.mjs +35 -10
@@ -0,0 +1,278 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { appendAudit } from "./audit.js";
5
+ import { assertRunMutationAllowed } from "./run-recovery-guard.js";
6
+ import { readInteractiveStageScope } from "./interactive-stage-entry.js";
7
+ import { prepareInteractiveReviewedGitInputs } from "./interactive-finish-integration.js";
8
+ import { readInteractiveWorkGit } from "./interactive-work-start.js";
9
+ import { readDescriptorStageInput } from "./descriptor-stage-coordinator.js";
10
+ import { readActiveDescriptorStageCoordinator } from "./descriptor-stage-settlement.js";
11
+ import { integrateReviewedProjectFiles } from "./memory-finish-integration.js";
12
+ import { observeReviewedProjectGitIntegration } from "./project-git-integration.js";
13
+ const event = "interactive.integration_operation", phaseEvent = "interactive.integration_intent", anchorPath = '$.variables."interactive.integration"';
14
+ function refuse(reason) {
15
+ throw new AppError("interactive_git_dispatch_invalid", "Interactive Git requires its original reviewed operation and active coordinator", 2, { reason, effect: "no_effect" });
16
+ }
17
+ function pending(reason) {
18
+ throw new AppError("interactive_integration_pending", "Reconcile the original Git effect before dispatching again", 2, { reason, effect: "unknown", recoverable: true, next_action: "reconcile_original_integration" });
19
+ }
20
+ function stored(context, scope) {
21
+ const rows = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.plan.run_id')=? LIMIT 2", [scope.projectId, event, scope.runId]);
22
+ const phases = context.db.all("SELECT payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=? ORDER BY id LIMIT 3", [scope.projectId, phaseEvent, scope.runId]);
23
+ const anchor = context.db.get("SELECT json_extract(index_json,?) value FROM runs WHERE project_id=? AND id=?", [anchorPath, scope.projectId, scope.runId])?.value;
24
+ if (rows.length > 1 || phases.length > 2 || rows.some(row => Buffer.byteLength(row.payload_json) > 96 * 1024 * 1024)
25
+ || phases.some(row => Buffer.byteLength(row.payload_json) > 16384) || !!rows.length !== !!anchor || !rows.length && phases.length)
26
+ pending("integration_custody_lost");
27
+ if (!rows.length)
28
+ return { operation: null, phases: [] };
29
+ let operation, intent, value;
30
+ try {
31
+ operation = JSON.parse(rows[0].payload_json);
32
+ intent = phases.map(row => JSON.parse(row.payload_json));
33
+ value = JSON.parse(anchor);
34
+ }
35
+ catch {
36
+ return pending("integration_custody_invalid");
37
+ }
38
+ if (operation?.plan?.schema_id !== "dd-flow/interactive-git-operation@1" || operation.plan.run_id !== scope.runId
39
+ || operation.identity !== hybridDigest(operation.plan) || value.identity !== operation.identity || hybridDigest(value.phases) !== hybridDigest(intent)
40
+ || new Set(intent.map(item => item.phase)).size !== intent.length
41
+ || intent.some(item => item.run_id !== scope.runId || item.identity !== operation.identity || !["commit", "merge"].includes(item.phase)
42
+ || typeof item.baseline !== "string" || item.baseline !== "" && !/^[a-f0-9]{40,64}$/.test(item.baseline))
43
+ || intent.length === 2 && (intent[0].phase !== "commit" || intent[1].phase !== "merge"))
44
+ pending("integration_custody_changed");
45
+ return { operation, phases: intent };
46
+ }
47
+ function gitReader(context, plan) {
48
+ const env = { ...Object.fromEntries(Object.entries(context.env).filter(([name]) => !name.startsWith("GIT_"))), GIT_OPTIONAL_LOCKS: "0", GIT_LITERAL_PATHSPECS: "1" };
49
+ return (args, root = plan.reviewed.policy.scope.workspaceRoot) => {
50
+ const result = spawnSync(plan.reviewed.policy.git_executable, ["-c", "core.fsmonitor=false", "-C", root, ...args], { env, timeout: 10000, maxBuffer: 16 * 1024 * 1024, shell: false, windowsHide: true });
51
+ if (result.error || result.signal || result.status !== 0)
52
+ pending("original_git_readback_failed");
53
+ return args.includes("-z") ? result.stdout.toString("utf8") : result.stdout.toString("utf8").trim();
54
+ };
55
+ }
56
+ function assertOwnedHead(context, operation, phases, head) {
57
+ const { plan } = operation, git = gitReader(context, plan), commit = phases.find(item => item.phase === "commit");
58
+ if (!commit) {
59
+ if (head !== (plan.reviewed.policy.policy.initial_head ?? ""))
60
+ pending("source_head_not_owned");
61
+ }
62
+ else if (head !== commit.baseline) {
63
+ const message = `chore(${plan.run_id.toLowerCase()}): integrate reviewed project changes`;
64
+ const paths = [...plan.inventory.references, ...plan.inventory.removals].map(ref => ref.path);
65
+ if (git(["show", "-s", "--format=%P%n%s", head]) !== `${commit.baseline}\n${message}`.trim()
66
+ || git(["diff-tree", "--root", "--no-commit-id", "--no-renames", "--name-only", "-r", "-z", head]).split("\0").filter(Boolean).some(path => !paths.includes(path)))
67
+ pending("scoped_commit_not_proven");
68
+ }
69
+ }
70
+ function selection(plan) {
71
+ const policy = plan.reviewed.policy;
72
+ return { projectRoot: policy.scope.projectRoot, workspaceRoot: policy.scope.workspaceRoot,
73
+ integrationBranch: policy.policy.integration_branch, gitExecutable: policy.git_executable };
74
+ }
75
+ function assertTool(bundle, plan) {
76
+ const actual = readInteractiveWorkGit(bundle), selected = plan.reviewed.policy;
77
+ if (actual.git_executable !== selected.git_executable || actual.git_sha256 !== selected.git_sha256)
78
+ refuse("selected_git_changed");
79
+ }
80
+ function integrationCustody(context, input) {
81
+ if (context.db.isOwnedWriteTransaction?.())
82
+ refuse("preparation_outside_writer_required");
83
+ const selected = readInteractiveStageScope(context, { ...input, stage: "integration" }, true), reviewed = prepareInteractiveReviewedGitInputs(context, input);
84
+ const state = stored(context, selected.scope), operation = state.operation;
85
+ if (!operation || hybridDigest(operation.plan.reviewed) !== hybridDigest(reviewed.original))
86
+ refuse("original_integration_operation_required");
87
+ const assertCustody = (writer) => {
88
+ reviewed.assertCustody(writer);
89
+ if (hybridDigest(stored(writer, selected.scope)) !== hybridDigest(state))
90
+ pending("integration_custody_changed");
91
+ };
92
+ return { selected, reviewed, state, operation, assertCustody };
93
+ }
94
+ /** Historical operation custody, not current Git observation. Prepare outside
95
+ * the writer; the returned fence itself reads only retained SQL/bundle data. */
96
+ export function prepareInteractiveIntegrationCustody(context, input) {
97
+ const retained = integrationCustody(context, input);
98
+ return { binding: { operation_sha256: retained.operation.identity, phases_sha256: hybridDigest(retained.state.phases) },
99
+ reviewed: structuredClone(retained.operation.plan.reviewed), assertCustody: retained.assertCustody };
100
+ }
101
+ /** Readback plus SQL custody for terminal acceptance. No dispatch or fresh
102
+ * operation selection; usable when a process died after the original effect. */
103
+ export function prepareInteractiveFinishIntegration(context, input) {
104
+ const { selected, reviewed, state, operation, assertCustody } = integrationCustody(context, input), { plan } = operation;
105
+ const observe = () => {
106
+ assertTool(selected.bundle, plan);
107
+ assertCustody(context);
108
+ if (plan.reviewed.empty) {
109
+ if (state.phases.length)
110
+ pending("empty_run_has_git_intent");
111
+ reviewed.observeBeforeDispatch().revalidate();
112
+ return { schema_id: "dd-flow/interactive-finish-integration@1", operation_sha256: operation.identity, no_op: true, git: null };
113
+ }
114
+ const git = observeReviewedProjectGitIntegration(selection(plan), context.env, plan.inventory.references, plan.inventory.removals);
115
+ if (git.repository !== plan.reviewed.policy.policy.repository || git.workspace_branch !== plan.reviewed.policy.policy.workspace_branch)
116
+ refuse("policy_repository_or_branch_changed");
117
+ assertOwnedHead(context, operation, state.phases, git.workspace_head);
118
+ const merge = state.phases.find(item => item.phase === "merge");
119
+ if (merge && git.integration_head !== git.workspace_head && git.integration_head !== merge.baseline
120
+ && gitReader(context, plan)(["show", "-s", "--format=%P", git.integration_head], plan.reviewed.policy.scope.projectRoot) !== `${merge.baseline} ${git.workspace_head}`)
121
+ pending("scoped_merge_not_proven");
122
+ reviewed.assertReviewedSources();
123
+ assertCustody(context);
124
+ return { schema_id: "dd-flow/interactive-finish-integration@1", operation_sha256: operation.identity, no_op: false, git };
125
+ };
126
+ const observation = observe();
127
+ return { binding: { operation_sha256: operation.identity, phases_sha256: hybridDigest(state.phases) },
128
+ reviewed: structuredClone(plan.reviewed), value: () => structuredClone(observation), assertCustody, revalidate: () => {
129
+ if (context.db.isOwnedWriteTransaction?.())
130
+ refuse("physical_revalidation_outside_writer_required");
131
+ if (hybridDigest(observe()) !== hybridDigest(observation))
132
+ pending("integration_readback_changed");
133
+ } };
134
+ }
135
+ /** Existing scoped Git executor, with original selected index bytes retained
136
+ * before effects and each phase anchored separately. Never push/stash/reset,
137
+ * repeat an unresolved phase or rediscover a new agreement after commit. */
138
+ export function integrateInteractiveFinish(context, input) {
139
+ if (context.db.isOwnedWriteTransaction?.())
140
+ refuse("preparation_outside_writer_required");
141
+ const selected = readInteractiveStageScope(context, { ...input, stage: "integration" }, true), { scope, bundle } = selected;
142
+ const reviewed = prepareInteractiveReviewedGitInputs(context, input);
143
+ const authority = (writer) => {
144
+ assertRunMutationAllowed(writer, scope.projectId, scope.runId);
145
+ const run = writer.db.get("SELECT status,index_json FROM runs WHERE project_id=? AND id=?", [scope.projectId, scope.runId]);
146
+ const index = JSON.parse(run.index_json);
147
+ if (run.status !== "running" || index.current_stage !== "integration"
148
+ || !index.stage_runs.some((stage) => stage.stage === "integration" && stage.status === "running"))
149
+ refuse("active_integration_required");
150
+ const stage = readDescriptorStageInput(writer, scope, bundle), actor = readActiveDescriptorStageCoordinator(writer, scope, bundle);
151
+ if (stage.input.predecessor?.stage !== "review" || hybridDigest(stage.input.predecessor.result) !== hybridDigest(reviewed.original.review_result))
152
+ refuse("accepted_review_predecessor_required");
153
+ if (writer.db.get("SELECT 1 FROM works WHERE project_id=? AND run_id=? AND parent_work_id=? AND status IN ('created','running','paused') LIMIT 1", [scope.projectId, scope.runId, actor.work_id]))
154
+ refuse("active_child_work");
155
+ return { stage, actor };
156
+ };
157
+ let state = stored(context, scope);
158
+ const replayed = Boolean(state.operation);
159
+ if (!state.operation) {
160
+ const owner = authority(context), admission = reviewed.prepareOriginalInputsAgreement(input.agreement);
161
+ const inventory = { references: reviewed.original.inventory.references, removals: reviewed.original.inventory.removals.map((ref) => {
162
+ const head = admission.original_inputs.find(item => item.path === ref.path).head;
163
+ if (!head)
164
+ return ref;
165
+ if (head.git_mode !== "100644" && head.git_mode !== "100755")
166
+ refuse("regular_original_removal_required");
167
+ return { ...ref, sha256: head.sha256, git_mode: head.git_mode };
168
+ }) };
169
+ const plan = { schema_id: "dd-flow/interactive-git-operation@1", run_id: input.runId, reviewed: reviewed.original, ...owner, inventory,
170
+ admission: { agreement: admission.agreement, original_inputs: admission.original_inputs.map(({ workspace, ...item }) => {
171
+ const { base64: _retainedWorkspaceBytes, ...reference } = workspace;
172
+ void _retainedWorkspaceBytes;
173
+ return { ...item, workspace: reference };
174
+ }) } };
175
+ const operation = { identity: hybridDigest(plan), plan };
176
+ if (Buffer.byteLength(JSON.stringify(operation)) > 96 * 1024 * 1024)
177
+ refuse("integration_operation_exceeds_bound");
178
+ const sources = reviewed.observeBeforeDispatch();
179
+ sources.revalidate();
180
+ context.db.writeTransaction(() => {
181
+ reviewed.assertCustody(context);
182
+ sources.assertCustody(context);
183
+ if (hybridDigest(authority(context)) !== hybridDigest(owner))
184
+ refuse("integration_authority_changed");
185
+ const current = stored(context, scope);
186
+ if (current.operation) {
187
+ if (current.operation.identity !== operation.identity)
188
+ pending("integration_operation_changed");
189
+ return;
190
+ }
191
+ appendAudit(context, { projectId: scope.projectId, eventType: event, payload: operation });
192
+ context.db.run("UPDATE runs SET index_json=json_set(index_json,?,json(?)) WHERE project_id=? AND id=?", [anchorPath, JSON.stringify({ identity: operation.identity, phases: [] }), scope.projectId, scope.runId]);
193
+ });
194
+ state = stored(context, scope);
195
+ }
196
+ const operation = state.operation, { plan } = operation;
197
+ if (hybridDigest(plan.reviewed) !== hybridDigest(reviewed.original))
198
+ refuse("accepted_integration_inputs_changed");
199
+ if (input.agreement !== undefined) {
200
+ const retained = plan.admission.agreement;
201
+ if (!retained || hybridDigest(input.agreement) !== hybridDigest({ basis: retained.basis, reason: retained.reason, include_existing_paths: retained.include_existing_paths }))
202
+ refuse("original_git_agreement_changed");
203
+ }
204
+ try {
205
+ const proof = prepareInteractiveFinishIntegration(context, input);
206
+ proof.revalidate();
207
+ return { integrated: true, replayed, integration: proof.value() };
208
+ }
209
+ catch (error) {
210
+ if (!(error instanceof AppError) || !["project_git_integration_invalid", "memory_git_integration_invalid"].includes(error.code))
211
+ throw error;
212
+ }
213
+ if (hybridDigest(authority(context)) !== hybridDigest({ stage: plan.stage, actor: plan.actor }))
214
+ refuse("integration_authority_changed");
215
+ if (state.phases.length) {
216
+ if (state.phases.length !== 1 || state.phases[0].phase !== "commit"
217
+ || plan.reviewed.policy.scope.projectRoot === plan.reviewed.policy.scope.workspaceRoot)
218
+ pending("original_git_phase_unresolved");
219
+ const head = gitReader(context, plan)(["rev-parse", "HEAD"]);
220
+ if (head === state.phases[0].baseline) {
221
+ // A selected index-only cleanup need not create a commit. Prove its
222
+ // source postcondition, then permit only the still-unissued merge.
223
+ try {
224
+ observeReviewedProjectGitIntegration({ ...selection(plan), projectRoot: plan.reviewed.policy.scope.workspaceRoot,
225
+ integrationBranch: plan.reviewed.policy.policy.workspace_branch }, context.env, plan.inventory.references, plan.inventory.removals);
226
+ }
227
+ catch {
228
+ pending("original_commit_not_proven");
229
+ }
230
+ }
231
+ assertOwnedHead(context, operation, state.phases, head);
232
+ }
233
+ else
234
+ reviewed.observeBeforeDispatch().revalidate();
235
+ let dispatched = false;
236
+ try {
237
+ integrateReviewedProjectFiles(selection(plan), context.env, plan.inventory.references, input.runId, (phase, baseline, action) => {
238
+ assertTool(bundle, plan);
239
+ const prior = stored(context, scope);
240
+ if (prior.phases.some(item => item.phase === phase))
241
+ pending("original_git_phase_unresolved");
242
+ const before = phase === "commit" ? reviewed.observeBeforeDispatch() : null;
243
+ if (before)
244
+ before.revalidate();
245
+ else
246
+ assertOwnedHead(context, operation, prior.phases, gitReader(context, plan)(["rev-parse", "HEAD"]));
247
+ context.db.writeTransaction(() => {
248
+ reviewed.assertCustody(context);
249
+ before?.assertCustody(context);
250
+ if (hybridDigest(authority(context)) !== hybridDigest({ stage: plan.stage, actor: plan.actor }))
251
+ refuse("integration_authority_changed");
252
+ const current = stored(context, scope);
253
+ if (current.operation?.identity !== operation.identity || current.phases.some(item => item.phase === phase)
254
+ || phase === "commit" && current.phases.length)
255
+ pending("original_git_phase_unresolved");
256
+ const intent = { identity: operation.identity, run_id: input.runId, phase, baseline };
257
+ appendAudit(context, { projectId: scope.projectId, eventType: phaseEvent, payload: intent });
258
+ context.db.run("UPDATE runs SET index_json=json_set(index_json,?,json(?)) WHERE project_id=? AND id=?", [anchorPath, JSON.stringify({ identity: operation.identity, phases: [...current.phases, intent] }), scope.projectId, scope.runId]);
259
+ });
260
+ dispatched = true;
261
+ // Intent recording is not permission to overwrite an index or task that
262
+ // changed during that writer. Recheck immediately before the effect.
263
+ before?.revalidate();
264
+ reviewed.assertCustody(context);
265
+ if (hybridDigest(authority(context)) !== hybridDigest({ stage: plan.stage, actor: plan.actor }))
266
+ refuse("integration_authority_changed");
267
+ action();
268
+ }, plan.reviewed.policy.policy, plan.inventory.removals);
269
+ const proof = prepareInteractiveFinishIntegration(context, input);
270
+ proof.revalidate();
271
+ return { integrated: true, replayed: false, integration: proof.value() };
272
+ }
273
+ catch (error) {
274
+ if (dispatched)
275
+ pending(error instanceof AppError ? error.code : "git_operation_failed");
276
+ throw error;
277
+ }
278
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from "node:crypto";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { hybridDigest } from "./hybrid-contracts.js";
4
+ import { assertPreparedRunBundleSqlAuthority } from "./runs.js";
5
+ import { readInteractiveUserInputs, readInteractiveInputDelivery, retainInteractiveUserInput } from "./interactive-user-inputs.js";
6
+ import { prepareRunControllerAnswer, answerRunController } from "./run-controller.js";
7
+ function refuse(reason) {
8
+ throw new AppError("interactive_input_delivery_invalid", "Interactive delivery requires its original input and exact controller pause", 2, { reason, effect: "no_effect" });
9
+ }
10
+ function pending(input, delivery) {
11
+ throw new AppError("interactive_input_delivery_pending", "User input is retained; reconcile its original controller answer without retargeting", 1, { effect: "committed", recoverable: true, request_id: input.requestId, delivery, next_action: "retry_exact_interactive_input" });
12
+ }
13
+ /** No new dispatcher. Retain the user bytes, then queue the ordinary HITL answer.
14
+ * The original pause target survives loss between those two existing owners. */
15
+ export function deliverInteractiveUserInput(context, scope, bundle, input) {
16
+ if (context.db.isOwnedWriteTransaction?.())
17
+ refuse("preparation_outside_writer_required");
18
+ const before = readInteractiveUserInputs(context, scope, bundle), prior = before.turns.find(turn => turn.request_id === input.requestId);
19
+ // Validate exact raw replay through its existing owner before observing or
20
+ // queuing any controller operation. Changed retry bytes are still no-effect.
21
+ if (prior)
22
+ retainInteractiveUserInput(context, scope, bundle, input);
23
+ const select = () => {
24
+ const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle), index = JSON.parse(run.index_json);
25
+ if (bundle.execution.settings.execution !== "managed" || !context.db.get("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='run_controllers'"))
26
+ return null;
27
+ const stage = index.stage_runs?.filter((item) => item.stage === index.current_stage).at(-1);
28
+ if (index.current_stage !== "interactive" || !stage || !["paused", "waiting_for_user"].includes(stage.status) || !stage.pause?.id)
29
+ return null;
30
+ const controllers = context.db.all("SELECT controller_id FROM run_controllers WHERE project_id=? AND run_id=? AND status NOT IN ('completed','cancelled','superseded')", [scope.projectId, scope.runId]);
31
+ if (controllers.length > 1)
32
+ refuse("ambiguous_controller");
33
+ const controller = controllers[0];
34
+ if (!controller)
35
+ return null;
36
+ // An already queued answer will wake the same coordinator, which must read
37
+ // the complete retained input set. Do not queue a second answer to its pause.
38
+ if (context.db.get("SELECT 1 FROM run_controller_operations WHERE controller_id=? AND json_extract(input_json,'$.action')='user_answer' AND json_extract(input_json,'$.pause_id')=?", [controller.controller_id, stage.pause.id]))
39
+ return null;
40
+ return { controller_id: controller.controller_id, pause_id: stage.pause.id,
41
+ request_id: `interactive-${hybridDigest({ run_id: scope.runId, request_id: input.requestId })}` };
42
+ };
43
+ const target = prior ? readInteractiveInputDelivery(context, scope, bundle, input.requestId) : context.db.readSnapshot(select);
44
+ let prepared;
45
+ const answer = target ? { projectRoot: input.projectRoot, runId: scope.runId, requestId: target.request_id,
46
+ pauseId: target.pause_id, answerFile: input.intakeFile } : null;
47
+ if (target && answer) {
48
+ try {
49
+ prepared = prepareRunControllerAnswer(context, answer);
50
+ const operationId = `${target.controller_id}:answer:${target.request_id}`;
51
+ if (prepared.operationId !== operationId)
52
+ refuse("original_controller_changed");
53
+ if (prepared.kind === "create") {
54
+ if (!prepared.bytes.equals(Buffer.from(input.bytes)))
55
+ refuse("prepared_user_bytes_changed");
56
+ }
57
+ else {
58
+ const operation = context.db.get("SELECT input_json FROM run_controller_operations WHERE operation_id=?", [operationId]);
59
+ const value = operation ? JSON.parse(operation.input_json) : null;
60
+ if (!value || value.action !== "user_answer" || value.pause_id !== target.pause_id
61
+ || value.sha256 !== createHash("sha256").update(input.bytes).digest("hex"))
62
+ refuse("original_answer_changed");
63
+ }
64
+ }
65
+ catch (error) {
66
+ if (prior)
67
+ return pending(input, target);
68
+ throw error;
69
+ }
70
+ }
71
+ const retained = retainInteractiveUserInput(context, scope, bundle, { requestId: input.requestId, kind: input.kind,
72
+ bytes: input.bytes, expectedInputsSha256: input.expectedInputsSha256, delivery: target });
73
+ if (!target || !answer || !prepared)
74
+ return { ...retained, delivery: { kind: "retained_only" } };
75
+ try {
76
+ const queued = answerRunController(context, answer, prepared);
77
+ return { ...retained, delivery: { kind: "controller_answer", pause_id: target.pause_id, ...queued } };
78
+ }
79
+ catch {
80
+ return pending(input, target);
81
+ }
82
+ }
@@ -0,0 +1,231 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ import { appendAudit } from "./audit.js";
5
+ import { hybridDigest } from "./hybrid-contracts.js";
6
+ import { readPreparedInteractiveBeforeVersions, readPreparedInteractiveGitBaseline } from "./interactive-baseline-custody.js";
7
+ import { PreparedInteractiveBeforeVersions, restoreInteractiveBeforeVersions } from "./interactive-before-versions.js";
8
+ import { readInteractiveUserInputs } from "./interactive-user-inputs.js";
9
+ import { assertPreparedRunBundleSqlAuthority } from "./runs.js";
10
+ import { assertRunMutationAllowed } from "./run-recovery-guard.js";
11
+ import { readInteractiveWorkHistory, assertInteractiveCorrectionHistory } from "./interactive-work-history.js";
12
+ const eventType = "interactive.mutation_scope";
13
+ const digest = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
14
+ function refuse(reason) {
15
+ throw new AppError("interactive_mutation_scope_invalid", "Interactive scope requires delivered user input and original before-images", 2, { reason, effect: "no_effect", ...(reason === "current_scope_required" ? { phase: "prepare", recoverable: true } : {}) });
16
+ }
17
+ function pathsValid(paths) {
18
+ if (!Array.isArray(paths) || paths.length > 20_000 || paths.some(value => typeof value !== "string" || !value.trim())
19
+ || new Set(paths).size !== paths.length || hybridDigest(paths) !== hybridDigest([...paths].sort()))
20
+ refuse("ordered_unique_paths_required");
21
+ }
22
+ /** Delivered-input custody only; the agent still assesses what it authorizes. */
23
+ export function assertInteractiveUserBasis(basis, inputs) {
24
+ if (!basis || Object.keys(basis).sort().join() !== "event_id,role,sha256" || !Number.isSafeInteger(basis.event_id) || basis.event_id < 0 || !digest(basis.sha256))
25
+ refuse("delivered_user_basis_required");
26
+ const delivered = basis.role === "request" ? basis.event_id === 0 && basis.sha256 === inputs.initial_request.sha256
27
+ : inputs.messages.some(source => ["turn", "answer", "finish"].includes(source.role) && source.role === basis.role && source.event_id === basis.event_id && source.sha256 === basis.sha256);
28
+ if (!delivered)
29
+ refuse("delivered_user_basis_required");
30
+ }
31
+ /** SQL-only custody, not semantic approval. A later user message invalidates
32
+ * active scope until the coordinator explicitly reassesses the full input set. */
33
+ export function readInteractiveMutationScope(context, scope, bundle) {
34
+ return context.db.readSnapshot(() => {
35
+ const baseline = readPreparedInteractiveGitBaseline(context, scope, bundle);
36
+ const initial = readPreparedInteractiveBeforeVersions(context, scope, bundle);
37
+ const inputs = readInteractiveUserInputs(context, scope, bundle);
38
+ const versions = new Map(initial.value.entries.map(item => [item.path, item]));
39
+ const approved = new Set(), requests = new Set(), records = [];
40
+ let revision = hybridDigest({ bundle: bundle.sha256, baseline: baseline.sha256, before: initial.sha256 }), total = 0;
41
+ const rows = context.db.all("SELECT id,payload_json FROM audit_events WHERE project_id=? AND event_type=? AND json_extract(payload_json,'$.run_id')=? ORDER BY id LIMIT 129", [scope.projectId, eventType, scope.runId]);
42
+ if (rows.length > 128)
43
+ refuse("too_many_scope_decisions");
44
+ for (const row of rows) {
45
+ total += Buffer.byteLength(row.payload_json);
46
+ if (total > 128 * 1024 * 1024)
47
+ refuse("unbounded_scope_history");
48
+ let value;
49
+ try {
50
+ value = JSON.parse(row.payload_json);
51
+ }
52
+ catch {
53
+ return refuse("invalid_scope_custody");
54
+ }
55
+ if (!value || Object.keys(value).sort().join() !== "basis,before_sha256,before_versions,bundle_sha256,paths,previous_sha256,reason,request_id,run_id,schema_id,user_inputs_sha256"
56
+ || value.schema_id !== "dd-flow/interactive-mutation-scope@1" || value.run_id !== scope.runId || value.bundle_sha256 !== bundle.sha256
57
+ || typeof value.request_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.request_id) || requests.has(value.request_id)
58
+ || !digest(value.user_inputs_sha256) || typeof value.reason !== "string" || !value.reason.trim() || value.reason.length > 64 * 1024
59
+ || value.previous_sha256 !== revision)
60
+ refuse("invalid_scope_custody");
61
+ pathsValid(value.paths);
62
+ assertInteractiveUserBasis(value.basis, inputs);
63
+ if (value.basis.event_id >= row.id || value.basis.role === "finish" && value.paths.some(name => !approved.has(name)))
64
+ refuse("scope_not_supported_by_prior_user_input");
65
+ const original = restoreInteractiveBeforeVersions(Buffer.from(JSON.stringify(value.before_versions)), value.before_sha256, baseline.baseline);
66
+ const missing = value.paths.filter(name => !versions.has(name));
67
+ if (hybridDigest(original.entries.map(item => item.path)) !== hybridDigest(missing))
68
+ refuse("before_scope_changed");
69
+ for (const item of original.entries)
70
+ versions.set(item.path, item);
71
+ for (const name of value.paths) {
72
+ if (!versions.has(name))
73
+ refuse("before_version_required");
74
+ approved.add(name);
75
+ }
76
+ requests.add(value.request_id);
77
+ records.push(value);
78
+ revision = hybridDigest({ previous: revision, record: value });
79
+ }
80
+ const current = records.at(-1) ?? null, needsRefresh = !current || current.user_inputs_sha256 !== inputs.sha256;
81
+ return { baseline: baseline.baseline, inputs, records, revision, before_versions: [...versions.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
82
+ ever_approved_paths: [...approved].sort(), active_paths: needsRefresh ? [] : [...current.paths], needs_scope_refresh: needsRefresh };
83
+ });
84
+ }
85
+ /** A coordinator's bounded interpretation of actual user input, not an agent
86
+ * replacement spec or proof of applicability. Dispatch, current file state,
87
+ * hardening and independent review still belong to ordinary Stage/Work owners. */
88
+ export class PreparedInteractiveMutationScope {
89
+ #scope;
90
+ #bundle;
91
+ #record;
92
+ #before;
93
+ constructor(scope, bundle, record, before) {
94
+ this.#scope = { ...scope };
95
+ this.#bundle = bundle;
96
+ this.#record = structuredClone(record);
97
+ this.#before = before;
98
+ Object.freeze(this);
99
+ }
100
+ static prepare(context, scope, bundle, proposal) {
101
+ if (!proposal || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(proposal.requestId) || !digest(proposal.expectedInputsSha256)
102
+ || typeof proposal.reason !== "string" || !proposal.reason.trim() || proposal.reason.length > 64 * 1024)
103
+ refuse("invalid_scope_proposal");
104
+ pathsValid(proposal.paths);
105
+ const current = readInteractiveMutationScope(context, scope, bundle), prior = current.records.find(item => item.request_id === proposal.requestId);
106
+ if (prior) {
107
+ if (prior.user_inputs_sha256 !== proposal.expectedInputsSha256 || hybridDigest(prior.paths) !== hybridDigest(proposal.paths)
108
+ || hybridDigest(prior.basis) !== hybridDigest(proposal.basis) || prior.reason !== proposal.reason)
109
+ refuse("scope_request_conflict");
110
+ return new PreparedInteractiveMutationScope(scope, bundle, prior, null);
111
+ }
112
+ if (proposal.expectedInputsSha256 !== current.inputs.sha256)
113
+ refuse("user_inputs_changed");
114
+ assertInteractiveUserBasis(proposal.basis, current.inputs);
115
+ const known = new Set(current.before_versions.map(item => item.path));
116
+ const before = PreparedInteractiveBeforeVersions.capture(current.baseline, proposal.paths.filter(name => !known.has(name)));
117
+ const record = { schema_id: "dd-flow/interactive-mutation-scope@1", run_id: scope.runId, bundle_sha256: bundle.sha256,
118
+ request_id: proposal.requestId, user_inputs_sha256: proposal.expectedInputsSha256, basis: structuredClone(proposal.basis), reason: proposal.reason,
119
+ paths: [...proposal.paths], before_versions: before.snapshot(), before_sha256: before.sha256, previous_sha256: current.revision };
120
+ return new PreparedInteractiveMutationScope(scope, bundle, record, before);
121
+ }
122
+ snapshot() { return structuredClone(this.#record); }
123
+ revalidate() { this.#before?.revalidate(); }
124
+ retain(context) {
125
+ this.revalidate();
126
+ return context.db.writeTransaction(() => this.retainPreparedSql(context), "interactive.retainMutationScope");
127
+ }
128
+ /** Pair with an actual Stage/Work acceptance writer after outside-writer
129
+ * physical revalidation. No filesystem observation inside this method. */
130
+ retainPreparedSql(context) {
131
+ if (!context.db.isOwnedWriteTransaction?.())
132
+ refuse("owned_writer_required");
133
+ const current = readInteractiveMutationScope(context, this.#scope, this.#bundle), value = this.#record;
134
+ const prior = current.records.find(item => item.request_id === value.request_id);
135
+ if (prior) {
136
+ if (hybridDigest(prior) !== hybridDigest(value))
137
+ refuse("scope_request_conflict");
138
+ return { changed: false, scope: current };
139
+ }
140
+ assertRunMutationAllowed(context, this.#scope.projectId, this.#scope.runId);
141
+ if (current.revision !== value.previous_sha256 || current.inputs.sha256 !== value.user_inputs_sha256)
142
+ refuse("scope_changed_before_acceptance");
143
+ const run = assertPreparedRunBundleSqlAuthority(context, this.#scope, this.#bundle), index = JSON.parse(run.index_json);
144
+ const stage = index.stage_runs?.filter((item) => item.stage === index.current_stage).at(-1);
145
+ if (index.current_stage === "review")
146
+ assertInteractiveCorrectionHistory(readInteractiveWorkHistory(context, this.#scope, this.#bundle), stage?.attempt ?? "try-001");
147
+ if (!["running", "waiting_for_user"].includes(run.status) || !["preflight", "interactive", "consolidation", "hardening", "review"].includes(index.current_stage)
148
+ || !stage || !["running", "waiting_for_user", "paused"].includes(stage.status))
149
+ refuse("scope_stage_required");
150
+ if (current.inputs.finish_requested) {
151
+ // Hardening may add a file needed by the original delivered task without
152
+ // re-asking permission. Applicability is agent-owned and reviewed against
153
+ // the full current inputs; the finish action alone adds no new scope.
154
+ if (value.basis.role === "finish" && value.paths.some(name => !current.ever_approved_paths.includes(name)))
155
+ refuse("finish_does_not_expand_scope");
156
+ }
157
+ else if (!["preflight", "interactive"].includes(index.current_stage) || value.basis.role === "finish")
158
+ refuse("scope_stage_required");
159
+ appendAudit(context, { projectId: this.#scope.projectId, eventType, payload: value });
160
+ return { changed: true, scope: readInteractiveMutationScope(context, this.#scope, this.#bundle) };
161
+ }
162
+ }
163
+ /** Physical working-tree evidence before integration, not semantic approval or
164
+ * permission to stage/commit. Observe outside the writer, then fence the same
165
+ * user/scope revision inside the ordinary Stage/Work acceptance transaction. */
166
+ export class PreparedInteractiveWorkspaceChanges {
167
+ sha256;
168
+ #scope;
169
+ #bundle;
170
+ #selection;
171
+ #input;
172
+ #observed;
173
+ #retainedPaths;
174
+ constructor(scope, bundle, selection, input, retainedPaths) {
175
+ if (selection.needs_scope_refresh)
176
+ refuse("current_scope_required");
177
+ this.#scope = { ...scope };
178
+ this.#bundle = bundle;
179
+ this.#selection = selection;
180
+ this.#input = { ...input, env: { ...input.env } };
181
+ this.#retainedPaths = [...retainedPaths];
182
+ // Retain explicitly selected paths even when project ignore rules hide new
183
+ // files. Git visibility alone must not turn an approved edit into no delta.
184
+ this.#observed = selection.baseline.observeChanges(this.#input, [...selection.active_paths, ...this.#retainedPaths]);
185
+ const { delta } = this.#observed, allowed = new Set(selection.active_paths);
186
+ const originals = new Map(selection.before_versions.map(item => [item.path, item]));
187
+ if (delta.head_changed || delta.index_changed)
188
+ refuse("git_movement_requires_integration");
189
+ for (const change of delta.changes) {
190
+ if (!allowed.has(change.path))
191
+ refuse("working_change_outside_scope");
192
+ const original = originals.get(change.path);
193
+ if (!original || (change.before ? original.kind !== "file" || original.sha256 !== change.before.sha256 || original.mode !== change.before.mode
194
+ : original.kind !== "missing"))
195
+ refuse("working_change_before_version_missing");
196
+ if (change.after && change.after.kind !== "file")
197
+ refuse("regular_file_scope_required");
198
+ if (change.after && fs.lstatSync(path.join(this.#input.workspace, change.path)).nlink !== 1)
199
+ refuse("linked_working_change");
200
+ }
201
+ this.sha256 = hybridDigest(this.snapshot());
202
+ Object.freeze(this);
203
+ }
204
+ static prepare(context, scope, bundle, input, retainedPaths = []) {
205
+ if (context.db.isOwnedWriteTransaction?.())
206
+ refuse("physical_observation_outside_writer_required");
207
+ return new PreparedInteractiveWorkspaceChanges(scope, bundle, readInteractiveMutationScope(context, scope, bundle), input, retainedPaths);
208
+ }
209
+ snapshot() {
210
+ return { schema_id: "dd-flow/interactive-workspace-changes@1", run_id: this.#scope.runId,
211
+ bundle_sha256: this.#bundle.sha256, scope_revision: this.#selection.revision,
212
+ user_inputs_sha256: this.#selection.inputs.sha256, delta: structuredClone(this.#observed.delta) };
213
+ }
214
+ currentSnapshot() { return this.#observed.current.snapshot(); }
215
+ revalidate() {
216
+ const observed = this.#selection.baseline.observeChanges(this.#input, [...this.#selection.active_paths, ...this.#retainedPaths]);
217
+ if (observed.current.sha256 !== this.#observed.current.sha256)
218
+ refuse("working_changes_changed_before_acceptance");
219
+ for (const change of observed.delta.changes)
220
+ if (change.after && fs.lstatSync(path.join(this.#input.workspace, change.path)).nlink !== 1)
221
+ refuse("linked_working_change");
222
+ }
223
+ assertCustody(context) {
224
+ if (!context.db.isOwnedWriteTransaction?.())
225
+ refuse("owned_writer_required");
226
+ const current = readInteractiveMutationScope(context, this.#scope, this.#bundle);
227
+ if (current.needs_scope_refresh || current.revision !== this.#selection.revision
228
+ || current.inputs.sha256 !== this.#selection.inputs.sha256 || current.baseline.sha256 !== this.#selection.baseline.sha256)
229
+ refuse("working_changes_scope_changed");
230
+ }
231
+ }