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

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 (226) hide show
  1. package/CHANGELOG.md +23 -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 +273 -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 +114 -16
  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 +1 -0
  52. package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -3
  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-execution.js +4 -1
  84. package/dist/services/controller-fanout.js +9 -4
  85. package/dist/services/controller-memory-worker.js +6 -4
  86. package/dist/services/controller-result-authority.js +122 -0
  87. package/dist/services/dashboard.js +4 -2
  88. package/dist/services/delivery-access.js +123 -0
  89. package/dist/services/delivery-approval.js +68 -0
  90. package/dist/services/delivery-artifact.js +86 -0
  91. package/dist/services/delivery-checks.js +178 -0
  92. package/dist/services/delivery-execution.js +181 -0
  93. package/dist/services/delivery-intents.js +212 -0
  94. package/dist/services/delivery-operation.js +81 -0
  95. package/dist/services/delivery-policy.js +91 -0
  96. package/dist/services/delivery-procedures.js +43 -0
  97. package/dist/services/delivery-request.js +99 -0
  98. package/dist/services/delivery-result.js +144 -0
  99. package/dist/services/delivery-stage-commands.js +26 -0
  100. package/dist/services/delivery-stage-entry.js +56 -0
  101. package/dist/services/delivery-stage-handlers.js +135 -0
  102. package/dist/services/delivery-stage-results.js +94 -0
  103. package/dist/services/descriptor-coordinator-session.js +3 -1
  104. package/dist/services/descriptor-run-start.js +24 -4
  105. package/dist/services/descriptor-stage-coordinator.js +78 -10
  106. package/dist/services/descriptor-stage-settlement.js +59 -14
  107. package/dist/services/diagnostic-repair-intake.js +123 -0
  108. package/dist/services/diagnostic-repair-source-intake.js +102 -0
  109. package/dist/services/diagnostic-report.js +30 -0
  110. package/dist/services/diagnostic-run-bundle.js +48 -9
  111. package/dist/services/diagnostic-snapshot.js +2 -2
  112. package/dist/services/diagnostic-stage-handlers.js +3 -2
  113. package/dist/services/diagnostic-stage-results.js +26 -0
  114. package/dist/services/diagnostic-start.js +56 -6
  115. package/dist/services/diagnostic-work-collection.js +3 -3
  116. package/dist/services/distill-document-publication.js +280 -0
  117. package/dist/services/distill-promotion.js +197 -0
  118. package/dist/services/distill-publication-custody.js +162 -0
  119. package/dist/services/distill-stage-handlers.js +249 -0
  120. package/dist/services/distill-stage-results.js +105 -0
  121. package/dist/services/distill-user-inputs.js +4 -0
  122. package/dist/services/distill-work-batch.js +42 -0
  123. package/dist/services/distill-work-collection.js +74 -0
  124. package/dist/services/distill-work-registration.js +93 -0
  125. package/dist/services/distill-work-results.js +98 -0
  126. package/dist/services/eval-snapshots.js +17 -7
  127. package/dist/services/external-work-launch.js +8 -2
  128. package/dist/services/flow-catalog.js +24 -0
  129. package/dist/services/frozen-engine.js +19 -2
  130. package/dist/services/generic-execution.js +4 -3
  131. package/dist/services/harness-adapter.js +2 -14
  132. package/dist/services/hook-memory-routing.js +1 -1
  133. package/dist/services/hooks.js +31 -6
  134. package/dist/services/hybrid-contracts.js +6 -1
  135. package/dist/services/installed-flow-source.js +37 -2
  136. package/dist/services/interactive-attached-session.js +215 -0
  137. package/dist/services/interactive-baseline-custody.js +54 -0
  138. package/dist/services/interactive-before-versions.js +148 -0
  139. package/dist/services/interactive-bundle-data.js +85 -0
  140. package/dist/services/interactive-checks.js +179 -0
  141. package/dist/services/interactive-execution.js +33 -0
  142. package/dist/services/interactive-finish-integration.js +236 -0
  143. package/dist/services/interactive-git-baseline.js +230 -0
  144. package/dist/services/interactive-git-dispatch.js +278 -0
  145. package/dist/services/interactive-input-delivery.js +82 -0
  146. package/dist/services/interactive-mutation-scope.js +231 -0
  147. package/dist/services/interactive-request.js +287 -0
  148. package/dist/services/interactive-run-bundle.js +109 -0
  149. package/dist/services/interactive-stage-entry.js +41 -0
  150. package/dist/services/interactive-stage-handlers.js +289 -0
  151. package/dist/services/interactive-stage-observation.js +136 -0
  152. package/dist/services/interactive-stage-results.js +111 -0
  153. package/dist/services/interactive-user-inputs.js +142 -0
  154. package/dist/services/interactive-work-batch.js +43 -0
  155. package/dist/services/interactive-work-collection.js +119 -0
  156. package/dist/services/interactive-work-completion.js +180 -0
  157. package/dist/services/interactive-work-context.js +249 -0
  158. package/dist/services/interactive-work-history.js +112 -0
  159. package/dist/services/interactive-work-registration.js +124 -0
  160. package/dist/services/interactive-work-results.js +115 -0
  161. package/dist/services/interactive-work-start.js +155 -0
  162. package/dist/services/lifecycle-invocations.js +214 -24
  163. package/dist/services/managed-daemon-binding.js +52 -1
  164. package/dist/services/memory-compose-handler.js +2 -22
  165. package/dist/services/memory-finish-integration.js +58 -17
  166. package/dist/services/memory-fix-finish-integration.js +129 -0
  167. package/dist/services/memory-fix-git-dispatch.js +139 -0
  168. package/dist/services/memory-fix-intake-custody.js +11 -0
  169. package/dist/services/memory-fix-intake-settlement.js +127 -0
  170. package/dist/services/memory-fix-publication.js +196 -0
  171. package/dist/services/memory-fix-repair-admission.js +116 -0
  172. package/dist/services/memory-fix-request.js +141 -0
  173. package/dist/services/memory-fix-run-bundle.js +83 -0
  174. package/dist/services/memory-fix-scope.js +78 -0
  175. package/dist/services/memory-fix-sources.js +85 -0
  176. package/dist/services/memory-fix-stage-entry.js +24 -0
  177. package/dist/services/memory-fix-stage-handlers.js +282 -0
  178. package/dist/services/memory-fix-stage-results.js +106 -0
  179. package/dist/services/memory-fix-workspace.js +127 -0
  180. package/dist/services/memory-flow-user-inputs.js +88 -0
  181. package/dist/services/memory-init-contexts.js +55 -5
  182. package/dist/services/memory-init-controller-start.js +20 -4
  183. package/dist/services/memory-layer.js +7 -3
  184. package/dist/services/memory-native-review-copy.js +27 -3
  185. package/dist/services/memory-repair-document-delta.js +1 -0
  186. package/dist/services/project-flow-source.js +195 -0
  187. package/dist/services/project-git-integration.js +92 -0
  188. package/dist/services/repair-intents.js +2 -7
  189. package/dist/services/retained-native-hook.js +245 -0
  190. package/dist/services/review-fix-handoff.js +82 -0
  191. package/dist/services/review-fix-intake-custody.js +17 -0
  192. package/dist/services/review-fix-stage-entry.js +43 -0
  193. package/dist/services/review-fix-stage-handlers.js +176 -0
  194. package/dist/services/review-fix-stage-results.js +86 -0
  195. package/dist/services/run-control-worker.js +42 -1
  196. package/dist/services/run-control.js +40 -5
  197. package/dist/services/run-controller-adapter.js +37 -3
  198. package/dist/services/run-controller-process.js +12 -4
  199. package/dist/services/run-controller-recovery.js +46 -6
  200. package/dist/services/run-controller-state.js +3 -2
  201. package/dist/services/run-controller.js +209 -43
  202. package/dist/services/run-recovery.js +31 -2
  203. package/dist/services/run-timeline.js +59 -0
  204. package/dist/services/runs.js +18 -31
  205. package/dist/services/sessions.js +2 -1
  206. package/dist/services/stage-handlers.js +55 -3
  207. package/dist/services/stage-lifecycle.js +4 -3
  208. package/dist/services/stage-pause.js +26 -9
  209. package/dist/services/stage-report-renderer.js +2 -6
  210. package/dist/services/technical-notes.js +31 -0
  211. package/dist/services/verification-matrix.js +1 -1
  212. package/dist/services/version-status.js +1 -1
  213. package/dist/services/vnext-code-review.js +74 -5
  214. package/dist/services/vnext-code.js +8 -32
  215. package/dist/services/vnext-fanout.js +53 -8
  216. package/dist/services/vnext-merge.js +10 -5
  217. package/dist/services/vnext-plan-review.js +1 -1
  218. package/dist/services/vnext-plan.js +1 -1
  219. package/dist/services/vnext-protocolize.js +1 -1
  220. package/dist/services/vnext-specify.js +10 -5
  221. package/dist/services/work-registry.js +252 -66
  222. package/dist/services/worktrees.js +7 -4
  223. package/dist/storage/database.js +2 -1
  224. package/dist/verification-components.json +82 -0
  225. package/package.json +3 -2
  226. package/tools/verification-jobs.mjs +35 -10
@@ -0,0 +1,236 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { hybridDigest } from "./hybrid-contracts.js";
6
+ import { assertPreparedRunBundleSqlAuthority } from "./runs.js";
7
+ import { readInteractiveStageScope } from "./interactive-stage-entry.js";
8
+ import { readAcceptedInteractiveStage } from "./interactive-stage-handlers.js";
9
+ import { readCompletedInteractiveWork } from "./interactive-work-completion.js";
10
+ import { PreparedInteractiveWorkspaceChanges, assertInteractiveUserBasis } from "./interactive-mutation-scope.js";
11
+ import { readInteractiveWorkGit } from "./interactive-work-start.js";
12
+ import { assertReviewedProjectGitInventory } from "./project-git-integration.js";
13
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
14
+ function refuse(reason) {
15
+ throw new AppError("interactive_finish_integration_invalid", "Integration requires the accepted interactive task, review and exact original Git inputs", 2, { reason, effect: "no_effect" });
16
+ }
17
+ /** Frozen SQL/bundle policy, never ambient branch inference or Git authority. */
18
+ export function readInteractiveGitPolicy(context, scope, bundle) {
19
+ if (bundle.manifest.flow_id !== "interactive")
20
+ refuse("interactive_bundle_required");
21
+ const run = assertPreparedRunBundleSqlAuthority(context, scope, bundle), logical = "runtime/git-policy.json";
22
+ const references = bundle.manifest.inventory.filter(ref => ref.path === logical), bytes = bundle.blob(logical);
23
+ if (references.length !== 1 || !bytes.length || bytes.length > 32768 || references[0].sha256 !== hash(bytes))
24
+ refuse("frozen_git_policy_required");
25
+ let value;
26
+ try {
27
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
28
+ }
29
+ catch {
30
+ return refuse("invalid_git_policy");
31
+ }
32
+ const frozen = bundle.execution.snapshot().scope;
33
+ if (value?.schema_id !== "dd-flow/interactive-git-policy@1" || Object.keys(value).sort().join() !== "git_executable,git_sha256,policy,schema_id,scope"
34
+ || Object.keys(value.scope ?? {}).sort().join() !== "projectRoot,requestId,workspaceRoot"
35
+ || value.scope?.projectRoot !== run.project_root || value.scope?.workspaceRoot !== run.workspace_root
36
+ || value.scope.projectRoot !== frozen.projectRoot || value.scope.workspaceRoot !== frozen.workspace || value.scope.requestId !== frozen.requestId
37
+ || typeof value.git_executable !== "string" || !path.isAbsolute(value.git_executable) || !/^[a-f0-9]{64}$/.test(value.git_sha256)
38
+ || value.policy?.source !== "explicit_request" || value.policy.push_authorized !== false
39
+ || !["feature_worktree", "integration_branch_direct"].includes(value.policy.route)
40
+ || typeof value.policy.repository !== "string" || !path.isAbsolute(value.policy.repository)
41
+ || !value.policy.workspace_branch || !value.policy.integration_branch
42
+ || value.policy.initial_head !== null && !/^[a-f0-9]{40,64}$/.test(value.policy.initial_head))
43
+ refuse("git_policy_scope_changed");
44
+ return structuredClone(value);
45
+ }
46
+ /** Read-only early inspection; no accepted review, Git write or inclusion
47
+ * approval is required. Resolve any user decision before freezing review. */
48
+ export function observeInteractiveOriginalGitInputs(context, input) {
49
+ if (context.db.isOwnedWriteTransaction?.())
50
+ refuse("physical_observation_outside_writer_required");
51
+ const selected = readInteractiveStageScope(context, input), policy = readInteractiveGitPolicy(context, selected.scope, selected.bundle);
52
+ const git = readInteractiveWorkGit(selected.bundle);
53
+ if (git.git_executable !== policy.git_executable || git.git_sha256 !== policy.git_sha256)
54
+ refuse("selected_git_changed");
55
+ const baseline = selected.selected.baseline.snapshot();
56
+ if (policy.policy.repository !== baseline.repository || policy.policy.workspace_branch !== baseline.branch
57
+ || policy.policy.initial_head !== baseline.head)
58
+ refuse("reviewed_git_baseline_changed");
59
+ const changes = PreparedInteractiveWorkspaceChanges.prepare(context, selected.scope, selected.bundle, {
60
+ projectRoot: policy.scope.projectRoot, workspace: policy.scope.workspaceRoot, gitExecutable: git.git_executable, env: context.env
61
+ });
62
+ const originalInputs = readOriginalGitInputs(context, policy, selected.selected.active_paths, selected.selected.before_versions);
63
+ changes.revalidate();
64
+ selected.assertCustody(context);
65
+ if (hybridDigest(readInteractiveGitPolicy(context, selected.scope, selected.bundle)) !== hybridDigest(policy))
66
+ refuse("git_policy_scope_changed");
67
+ return { baseline_sha256: selected.selected.baseline.sha256, user_inputs_sha256: selected.selected.inputs.sha256,
68
+ scope_revision: selected.selected.revision, original_inputs: originalInputs };
69
+ }
70
+ function readOriginalGitInputs(context, policy, paths, beforeVersions) {
71
+ if (paths.length > 4096)
72
+ refuse("reviewed_inventory_exceeds_bound");
73
+ const before = new Map(beforeVersions.map(item => [item.path, item]));
74
+ const env = { ...Object.fromEntries(Object.entries(context.env).filter(([key]) => !key.startsWith("GIT_"))), GIT_OPTIONAL_LOCKS: "0", GIT_LITERAL_PATHSPECS: "1" };
75
+ const git = (args) => {
76
+ const result = spawnSync(policy.git_executable, ["-c", "core.fsmonitor=false", "-C", policy.scope.workspaceRoot, ...args], { env, timeout: 10000, maxBuffer: 16 * 1024 * 1024, shell: false, windowsHide: true });
77
+ if (result.error || result.signal || result.status !== 0)
78
+ refuse("original_git_inputs_unavailable");
79
+ return result.stdout;
80
+ };
81
+ let total = 0;
82
+ const version = (header, retain) => {
83
+ const match = /^([0-7]{6}) (?:blob )?([a-f0-9]{40,64})(?: 0)?$/.exec(header);
84
+ if (!match || !["100644", "100755", "120000"].includes(match[1]))
85
+ refuse("regular_original_git_input_required");
86
+ const bytes = git(["cat-file", "blob", match[2]]);
87
+ total += bytes.length;
88
+ if (total > 64 * 1024 * 1024)
89
+ refuse("original_git_inputs_exceed_bound");
90
+ return { sha256: hash(bytes), git_mode: match[1], ...(retain ? { base64: bytes.toString("base64") } : {}) };
91
+ };
92
+ const entries = (bytes, retain) => {
93
+ let text;
94
+ try {
95
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
96
+ }
97
+ catch {
98
+ return refuse("original_git_paths_invalid");
99
+ }
100
+ const values = new Map();
101
+ for (const entry of text.split("\0").filter(Boolean)) {
102
+ const tab = entry.indexOf("\t"), logical = entry.slice(tab + 1);
103
+ if (tab < 0 || !paths.includes(logical) || values.has(logical))
104
+ refuse("original_git_paths_invalid");
105
+ values.set(logical, version(entry.slice(0, tab), retain));
106
+ }
107
+ return values;
108
+ };
109
+ const heads = entries(paths.length && policy.policy.initial_head ? git(["ls-tree", "-z", policy.policy.initial_head, "--", ...paths]) : Buffer.alloc(0), false);
110
+ const indexes = entries(paths.length ? git(["ls-files", "--stage", "-z", "--", ...paths]) : Buffer.alloc(0), true);
111
+ const same = (left, right) => left?.sha256 === right?.sha256 && left?.git_mode === right?.git_mode;
112
+ return paths.map(logical => {
113
+ const workspace = before.get(logical);
114
+ if (!workspace)
115
+ refuse("working_change_before_version_missing");
116
+ const head = heads.get(logical) ?? null, index = indexes.get(logical) ?? null;
117
+ const source = workspace.kind === "file" ? { sha256: workspace.sha256, git_mode: workspace.mode & 0o111 ? "100755" : "100644" } : null;
118
+ return { path: logical, workspace: structuredClone(workspace), head, index,
119
+ preexisting_changes: !same(source, head) || !same(index, head) };
120
+ });
121
+ }
122
+ /** Select the exact accepted review and its entry-relative file delta from
123
+ * retained SQL. Works after Git effects: no fresh workspace/HEAD is used to
124
+ * manufacture a replacement reviewed inventory. Dispatch still needs original
125
+ * dirty-input admission, a retained operation intent and physical revalidation. */
126
+ export function prepareInteractiveReviewedGitInputs(context, input) {
127
+ if (context.db.isOwnedWriteTransaction?.())
128
+ refuse("preparation_outside_writer_required");
129
+ const selected = readInteractiveStageScope(context, { ...input, stage: "integration" }), { scope, bundle } = selected;
130
+ const review = readAcceptedInteractiveStage(context, { ...input, stage: "review" });
131
+ const read = (writer) => {
132
+ const policy = readInteractiveGitPolicy(writer, scope, bundle), state = selected.selected;
133
+ const result = review.result, collection = review.proof.collection;
134
+ if (!collection || result.details.review?.verdict !== "accept" || state.needs_scope_refresh
135
+ || result.baseline_sha256 !== state.baseline.sha256 || result.scope_revision !== state.revision
136
+ || result.user_inputs_sha256 !== state.inputs.sha256 || !state.inputs.finish_requested)
137
+ refuse("current_accepted_review_required");
138
+ const work = readCompletedInteractiveWork(writer, { ...scope, workId: collection.work_id }, bundle), packet = work.start.assignment.packet;
139
+ if (packet.stage !== "review" || packet.owner_stage !== "review" || work.result.verdict !== "ready" || work.receipt.changes.length
140
+ || packet.input_sha256 !== result.input_sha256 || packet.baseline_sha256 !== result.baseline_sha256
141
+ || packet.scope_revision !== result.scope_revision || packet.user_inputs_sha256 !== result.user_inputs_sha256
142
+ || packet.file_changes_sha256 !== result.file_changes_sha256)
143
+ refuse("accepted_readonly_critic_required");
144
+ const baseline = state.baseline.snapshot(), delta = state.baseline.changes(work.start.baseline);
145
+ if (delta.head_changed || delta.index_changed || hybridDigest(delta.changes) !== result.file_changes_sha256
146
+ || work.receipt.observed_snapshot_sha256 !== work.start.baseline.sha256
147
+ || policy.policy.repository !== baseline.repository || policy.policy.workspace_branch !== baseline.branch
148
+ || policy.policy.initial_head !== baseline.head)
149
+ refuse("reviewed_git_baseline_changed");
150
+ if (delta.changes.length > 4096)
151
+ refuse("reviewed_inventory_exceeds_bound");
152
+ const originals = new Map(state.before_versions.map(item => [item.path, item]));
153
+ const allowed = new Set(state.active_paths);
154
+ const references = [], removals = [];
155
+ for (const change of delta.changes) {
156
+ const before = originals.get(change.path);
157
+ if (!allowed.has(change.path) || !before
158
+ || change.before && (change.before.kind !== "file" || before.sha256 !== change.before.sha256 || before.mode !== change.before.mode)
159
+ || !change.before && before.kind !== "missing" || change.after && change.after.kind !== "file")
160
+ refuse("reviewed_file_scope_changed");
161
+ const entry = change.after ?? change.before;
162
+ if (!entry?.sha256 || entry.mode === null)
163
+ refuse("reviewed_regular_file_required");
164
+ (change.after ? references : removals).push({ root: "workspace", path: change.path, sha256: entry.sha256,
165
+ git_mode: entry.mode & 0o111 ? "100755" : "100644" });
166
+ }
167
+ // An honest empty patch is a terminal no-op, not permission to merge a
168
+ // pre-existing feature branch or commit unrelated staged/dirty files.
169
+ if (delta.changes.length)
170
+ assertReviewedProjectGitInventory(references, removals);
171
+ return { policy, review_sha256: review.proof.result_sha256, review_result: review.reference, review_work_id: collection.work_id,
172
+ reviewed_snapshot_sha256: work.receipt.observed_snapshot_sha256, reviewed_sources_sha256: hybridDigest(work.start.baseline.snapshot().entries), baseline_sha256: state.baseline.sha256,
173
+ user_inputs_sha256: state.inputs.sha256, scope_revision: state.revision, file_changes_sha256: result.file_changes_sha256,
174
+ changes: delta.changes, inventory: { references, removals }, empty: delta.changes.length === 0 };
175
+ };
176
+ const original = context.db.readSnapshot(() => read(context));
177
+ const assertCustody = (writer) => {
178
+ selected.assertCustody(writer);
179
+ review.assertCustody(writer);
180
+ if (hybridDigest(read(writer)) !== hybridDigest(original))
181
+ refuse("accepted_integration_custody_changed");
182
+ };
183
+ const observeBeforeDispatch = () => {
184
+ if (context.db.isOwnedWriteTransaction?.())
185
+ refuse("physical_observation_outside_writer_required");
186
+ const git = readInteractiveWorkGit(bundle);
187
+ if (git.git_executable !== original.policy.git_executable || git.git_sha256 !== original.policy.git_sha256)
188
+ refuse("selected_git_changed");
189
+ const changes = PreparedInteractiveWorkspaceChanges.prepare(context, scope, bundle, { projectRoot: original.policy.scope.projectRoot,
190
+ workspace: original.policy.scope.workspaceRoot, gitExecutable: git.git_executable, env: context.env });
191
+ if (hash(Buffer.from(JSON.stringify(changes.currentSnapshot()))) !== original.reviewed_snapshot_sha256)
192
+ refuse("reviewed_sources_changed");
193
+ assertCustody(context);
194
+ return changes;
195
+ };
196
+ const assertReviewedSources = () => {
197
+ if (context.db.isOwnedWriteTransaction?.())
198
+ refuse("physical_observation_outside_writer_required");
199
+ // Admit the owned HEAD/index effects, not a different working source tree.
200
+ // An unselected dependency changing after checks cannot inherit readiness.
201
+ const observed = selected.selected.baseline.observeChanges({ projectRoot: original.policy.scope.projectRoot,
202
+ workspace: original.policy.scope.workspaceRoot, gitExecutable: original.policy.git_executable, env: context.env }, selected.selected.active_paths);
203
+ if (hybridDigest(observed.current.snapshot().entries) !== original.reviewed_sources_sha256)
204
+ refuse("reviewed_source_footprint_changed");
205
+ assertCustody(context);
206
+ };
207
+ const observeOriginalInputs = () => {
208
+ const changes = observeBeforeDispatch(), policy = original.policy;
209
+ const inputs = readOriginalGitInputs(context, policy, original.changes.map(change => change.path), selected.selected.before_versions);
210
+ changes.revalidate();
211
+ assertCustody(context);
212
+ return inputs;
213
+ };
214
+ /** Semantic interpretation stays with the agent. The CLI verifies that a
215
+ * declared inclusion decision cites genuine delivered user input and covers
216
+ * exactly the pre-existing selected changes. Finish alone is not approval.
217
+ * This value must be retained with the future Git operation's intent before
218
+ * effects; calling this helper itself performs no acceptance or Git writes. */
219
+ const prepareOriginalInputsAgreement = (agreement) => {
220
+ const originals = observeOriginalInputs(), paths = originals.filter(item => item.preexisting_changes).map(item => item.path).sort();
221
+ if (!paths.length) {
222
+ if (agreement)
223
+ throw new AppError("interactive_finish_integration_invalid", "Selected files have no pre-RUN changes; retry integration without --result-file", 2, { reason: "unexpected_existing_input_agreement", phase: "prepare", effect: "no_effect", recoverable: true });
224
+ return { original_inputs: originals, agreement: null };
225
+ }
226
+ if (!agreement)
227
+ throw new AppError("interactive_git_agreement_required", "Selected files contain pre-RUN changes; preserve them or explicitly agree their inclusion before commit", 2, { reason: "preexisting_selected_changes", effect: "no_effect", recoverable: true, paths, continuation: { kind: "existing_input_decision_required" } });
228
+ if (typeof agreement.reason !== "string" || !agreement.reason.trim() || agreement.reason.length > 64 * 1024
229
+ || hybridDigest(agreement.include_existing_paths) !== hybridDigest(paths))
230
+ refuse("exact_existing_input_agreement_required");
231
+ assertInteractiveUserBasis(agreement.basis, selected.selected.inputs);
232
+ assertCustody(context);
233
+ return { original_inputs: originals, agreement: { ...structuredClone(agreement), user_inputs_sha256: original.user_inputs_sha256 } };
234
+ };
235
+ return { original: structuredClone(original), assertCustody, observeBeforeDispatch, assertReviewedSources, observeOriginalInputs, prepareOriginalInputsAgreement };
236
+ }
@@ -0,0 +1,230 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import { AppError } from "../shared/errors.js";
6
+ import { hybridDigest } from "./hybrid-contracts.js";
7
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
8
+ function refuse(reason) {
9
+ throw new AppError("interactive_git_baseline_invalid", "Interactive needs its actual project/workspace Git baseline", 2, { reason, effect: "no_effect" });
10
+ }
11
+ function logical(value) {
12
+ return typeof value === "string" && value.length > 0 && value.length <= 4096 && !value.includes("\\") && !value.includes("\0")
13
+ && !path.isAbsolute(value) && value.split("/").every(part => part && part !== "." && part !== ".." && part !== ".git");
14
+ }
15
+ /** Hash regular files incrementally: large assets do not require a second full
16
+ * in-memory copy. Do not follow symlinks, read FIFOs or invoke Git clean filters. */
17
+ function entry(root, name) {
18
+ const file = path.join(root, name);
19
+ let parent = root;
20
+ for (const part of name.split("/").slice(0, -1)) {
21
+ parent = path.join(parent, part);
22
+ try {
23
+ if (!fs.lstatSync(parent).isDirectory())
24
+ return { path: name, kind: "missing", mode: null, sha256: null };
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT")
28
+ return { path: name, kind: "missing", mode: null, sha256: null };
29
+ throw error;
30
+ }
31
+ }
32
+ let stat;
33
+ try {
34
+ stat = fs.lstatSync(file);
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return { path: name, kind: "missing", mode: null, sha256: null };
39
+ throw error;
40
+ }
41
+ const mode = stat.mode & 0o7777;
42
+ if (stat.isSymbolicLink())
43
+ return { path: name, kind: "link", mode, sha256: hash(fs.readlinkSync(file, { encoding: "buffer" })) };
44
+ // Gitlinks/submodules are opaque. Their nested working tree is not included
45
+ // in this parent repository's git-visible scope or a writable-file delta.
46
+ if (stat.isDirectory())
47
+ return { path: name, kind: "directory", mode, sha256: null };
48
+ if (!stat.isFile())
49
+ refuse("special_file");
50
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
51
+ try {
52
+ const before = fs.fstatSync(fd, { bigint: true }), digest = createHash("sha256"), buffer = Buffer.allocUnsafe(256 * 1024);
53
+ if (before.dev !== BigInt(stat.dev) || before.ino !== BigInt(stat.ino) || !before.isFile())
54
+ refuse("file_changed_during_capture");
55
+ let length = 0n;
56
+ for (;;) {
57
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
58
+ if (!count)
59
+ break;
60
+ digest.update(buffer.subarray(0, count));
61
+ length += BigInt(count);
62
+ }
63
+ const after = fs.fstatSync(fd, { bigint: true });
64
+ if (length !== before.size || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs)
65
+ refuse("file_changed_during_capture");
66
+ return { path: name, kind: "file", mode, sha256: digest.digest("hex") };
67
+ }
68
+ finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ }
72
+ function stamp(root, name) {
73
+ try {
74
+ let parent = root;
75
+ for (const part of name.split("/").slice(0, -1)) {
76
+ parent = path.join(parent, part);
77
+ if (!fs.lstatSync(parent).isDirectory())
78
+ return null;
79
+ }
80
+ const value = fs.lstatSync(path.join(root, name), { bigint: true });
81
+ return [value.dev, value.ino, value.mode, value.size, value.mtimeNs, value.ctimeNs].map(String);
82
+ }
83
+ catch (error) {
84
+ if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
85
+ return null;
86
+ throw error;
87
+ }
88
+ }
89
+ function observe(input, retainedPaths = []) {
90
+ if (!path.isAbsolute(input.gitExecutable))
91
+ refuse("absolute_git_executable_required");
92
+ for (const root of [input.projectRoot, input.workspace]) {
93
+ if (!path.isAbsolute(root) || fs.realpathSync(root) !== root || !fs.lstatSync(root).isDirectory())
94
+ refuse("canonical_workspace_required");
95
+ }
96
+ const env = { ...Object.fromEntries(Object.entries(input.env).filter(([key]) => !key.startsWith("GIT_"))), GIT_OPTIONAL_LOCKS: "0" };
97
+ const git = (root, args, absent = false) => {
98
+ const result = spawnSync(input.gitExecutable, ["-c", "core.fsmonitor=false", "-C", root, ...args], { env, timeout: 15000, maxBuffer: 32 * 1024 * 1024 });
99
+ if (result.error || result.signal || result.status !== 0 && !(absent && [1, 128].includes(result.status ?? -1)))
100
+ refuse("git_observation_failed");
101
+ return result.status === 0 ? result.stdout : null;
102
+ };
103
+ const text = (root, args, absent = false) => git(root, args, absent)?.toString("utf8").trim() ?? null;
104
+ const repository = fs.realpathSync(text(input.projectRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
105
+ if (fs.realpathSync(text(input.workspace, ["rev-parse", "--path-format=absolute", "--git-common-dir"])) !== repository
106
+ || text(input.projectRoot, ["rev-parse", "--show-toplevel"]) !== input.projectRoot
107
+ || text(input.workspace, ["rev-parse", "--show-toplevel"]) !== input.workspace)
108
+ refuse("foreign_workspace_repository");
109
+ if (git(input.workspace, ["ls-files", "--unmerged", "-z"]).length)
110
+ refuse("unmerged_index");
111
+ const identity = fs.statSync(input.workspace);
112
+ const meta = () => {
113
+ const index = text(input.workspace, ["rev-parse", "--path-format=absolute", "--git-path", "index"]);
114
+ let indexHash = null;
115
+ try {
116
+ indexHash = hash(fs.readFileSync(index));
117
+ }
118
+ catch (error) {
119
+ if (error.code !== "ENOENT")
120
+ throw error;
121
+ }
122
+ return { head: text(input.workspace, ["rev-parse", "--verify", "HEAD"], true), branch: text(input.workspace, ["symbolic-ref", "--quiet", "--short", "HEAD"], true),
123
+ index_sha256: indexHash, status_base64: git(input.workspace, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"]).toString("base64") };
124
+ };
125
+ const names = () => {
126
+ const bytes = git(input.workspace, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]);
127
+ let value;
128
+ try {
129
+ value = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
130
+ }
131
+ catch {
132
+ return refuse("non_utf8_file_name");
133
+ }
134
+ const items = [...new Set(value.split("\0").filter(Boolean))].sort();
135
+ if (items.length > 200_000 || items.some(name => !logical(name)))
136
+ refuse("unsafe_or_unbounded_inventory");
137
+ return items;
138
+ };
139
+ const before = meta(), visible = names(), paths = [...new Set([...visible, ...retainedPaths])].sort();
140
+ if (paths.length > 200_000 || paths.some(name => !logical(name)))
141
+ refuse("unsafe_or_unbounded_inventory");
142
+ const stamps = paths.map(name => stamp(input.workspace, name)), entries = paths.map(name => entry(input.workspace, name));
143
+ const currentRoot = fs.statSync(input.workspace);
144
+ if (identity.dev !== currentRoot.dev || identity.ino !== currentRoot.ino || hybridDigest(stamps) !== hybridDigest(paths.map(name => stamp(input.workspace, name)))
145
+ || hybridDigest(before) !== hybridDigest(meta()) || hybridDigest(visible) !== hybridDigest(names()))
146
+ refuse("workspace_changed_during_capture");
147
+ return { schema_id: "dd-flow/interactive-git-baseline@1", project_root: input.projectRoot, workspace: input.workspace,
148
+ repository, workspace_identity: { dev: identity.dev, ino: identity.ino }, ...before, visibility: "git-visible-and-retained@1", entries };
149
+ }
150
+ /** Read-only physical baseline, not permission to edit/commit existing dirty
151
+ * files. The RUN owner must retain this in its admitted bundle before work.
152
+ * Ignored files and opaque submodule contents are explicitly outside visibility. */
153
+ export class PreparedInteractiveGitBaseline {
154
+ sha256;
155
+ #value;
156
+ constructor(value) {
157
+ if (Buffer.byteLength(JSON.stringify(value)) > 32 * 1024 * 1024)
158
+ refuse("unbounded_baseline");
159
+ this.#value = structuredClone(value);
160
+ this.sha256 = hash(Buffer.from(JSON.stringify(value)));
161
+ Object.freeze(this);
162
+ }
163
+ static capture(input) { return new PreparedInteractiveGitBaseline(observe(input)); }
164
+ /** Parsing only. Expected bytes/hash must come from the RUN's admitted bundle,
165
+ * not a caller's self-hashed replacement or a current project observation. */
166
+ static restore(bytes, expectedSha256) {
167
+ if (!bytes.length || bytes.length > 32 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(expectedSha256) || hash(bytes) !== expectedSha256)
168
+ refuse("retained_baseline_changed");
169
+ let value;
170
+ try {
171
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
172
+ }
173
+ catch {
174
+ return refuse("invalid_retained_baseline");
175
+ }
176
+ const checksum = (item) => typeof item === "string" && /^[a-f0-9]{64}$/.test(item);
177
+ if (!value || Object.keys(value).sort().join() !== "branch,entries,head,index_sha256,project_root,repository,schema_id,status_base64,visibility,workspace,workspace_identity"
178
+ || value.schema_id !== "dd-flow/interactive-git-baseline@1" || value.visibility !== "git-visible-and-retained@1"
179
+ || [value.project_root, value.workspace, value.repository].some(item => typeof item !== "string" || !path.isAbsolute(item))
180
+ || !value.workspace_identity || Object.keys(value.workspace_identity).sort().join() !== "dev,ino"
181
+ || Object.values(value.workspace_identity).some(item => !Number.isSafeInteger(item) || item < 0)
182
+ || value.branch !== null && (typeof value.branch !== "string" || !value.branch)
183
+ || value.head !== null && (typeof value.head !== "string" || !/^[a-f0-9]{40,64}$/.test(value.head))
184
+ || value.index_sha256 !== null && !checksum(value.index_sha256)
185
+ || typeof value.status_base64 !== "string" || Buffer.from(value.status_base64, "base64").toString("base64") !== value.status_base64
186
+ || !Array.isArray(value.entries) || value.entries.length > 200_000)
187
+ refuse("invalid_retained_baseline");
188
+ let previous = "";
189
+ for (const item of value.entries) {
190
+ if (!item || Object.keys(item).sort().join() !== "kind,mode,path,sha256" || !logical(item.path) || item.path <= previous
191
+ || !["file", "link", "directory", "missing"].includes(item.kind)
192
+ || (item.kind === "missing" ? item.mode !== null : !Number.isInteger(item.mode) || item.mode < 0 || item.mode > 0o7777)
193
+ || (["file", "link"].includes(item.kind) ? !checksum(item.sha256) : item.sha256 !== null))
194
+ refuse("invalid_retained_inventory");
195
+ previous = item.path;
196
+ }
197
+ const restored = new PreparedInteractiveGitBaseline(value);
198
+ if (restored.sha256 !== expectedSha256)
199
+ refuse("retained_baseline_changed");
200
+ return restored;
201
+ }
202
+ snapshot() { return structuredClone(this.#value); }
203
+ bytes() { return Buffer.from(JSON.stringify(this.#value)); }
204
+ revalidate(input) {
205
+ if (PreparedInteractiveGitBaseline.capture(input).sha256 !== this.sha256)
206
+ refuse("baseline_changed_before_retention");
207
+ }
208
+ observeChanges(input, retainedPaths = []) {
209
+ const current = new PreparedInteractiveGitBaseline(observe(input, [...this.#value.entries.map(item => item.path), ...retainedPaths]));
210
+ return { current, delta: this.changes(current) };
211
+ }
212
+ /** Exact working-file delta relative to entry, not HEAD. Pre-existing dirty
213
+ * content is not automatically attributed to this interactive RUN. Git/index
214
+ * movement is reported separately, never silently approved as integration. */
215
+ changes(current) {
216
+ const before = this.#value, after = current.#value;
217
+ if (before.project_root !== after.project_root || before.workspace !== after.workspace || before.repository !== after.repository
218
+ || hybridDigest(before.workspace_identity) !== hybridDigest(after.workspace_identity) || before.branch !== after.branch)
219
+ refuse("workspace_identity_changed");
220
+ const left = new Map(before.entries.map(item => [item.path, item])), right = new Map(after.entries.map(item => [item.path, item]));
221
+ if ([...left.keys()].some(name => !right.has(name)))
222
+ refuse("baseline_paths_not_observed");
223
+ const changes = [...new Set([...left.keys(), ...right.keys()])].sort().flatMap(name => {
224
+ const old = left.get(name)?.kind === "missing" ? null : left.get(name) ?? null, next = right.get(name)?.kind === "missing" ? null : right.get(name) ?? null;
225
+ return hybridDigest(old) === hybridDigest(next) ? [] : [{ path: name, before: old, after: next }];
226
+ });
227
+ return { baseline_sha256: this.sha256, current_sha256: current.sha256, changes,
228
+ head_changed: before.head !== after.head, index_changed: before.index_sha256 !== after.index_sha256 };
229
+ }
230
+ }