@loucompanion/forge-bridge 0.1.3-dev.fbe15023cdfe → 0.1.4-dev.04a1e5b9e484

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 (165) hide show
  1. package/README.md +88 -2
  2. package/dist/bridge/bridge-chat.types.d.ts +111 -0
  3. package/dist/bridge/bridge-chat.types.js +401 -0
  4. package/dist/bridge/bridge.base.d.ts +4 -1
  5. package/dist/bridge/bridge.service.d.ts +72 -3
  6. package/dist/bridge/bridge.service.js +1563 -152
  7. package/dist/bridge/bridge.types.d.ts +46 -1
  8. package/dist/bridge/dispatch-result-outbox.d.ts +25 -0
  9. package/dist/bridge/dispatch-result-outbox.js +62 -0
  10. package/dist/bridge/dispatch-result-worker.d.ts +2 -0
  11. package/dist/bridge/dispatch-result-worker.js +122 -0
  12. package/dist/bridge/internals.d.ts +2 -0
  13. package/dist/bridge/internals.js +1 -0
  14. package/dist/bridge/readiness-result-outbox.d.ts +21 -0
  15. package/dist/bridge/readiness-result-outbox.js +54 -0
  16. package/dist/bridge/readiness-result-worker.d.ts +2 -0
  17. package/dist/bridge/readiness-result-worker.js +121 -0
  18. package/dist/bridge/runner-host-capabilities.d.ts +15 -0
  19. package/dist/bridge/runner-host-capabilities.js +93 -0
  20. package/dist/cleanup/cleanup.service.d.ts +1 -0
  21. package/dist/cleanup/cleanup.service.js +70 -9
  22. package/dist/cli/cli.help.js +2 -2
  23. package/dist/cli/cli.service.d.ts +14 -6
  24. package/dist/cli/cli.service.js +75 -16
  25. package/dist/cli/local-config.d.ts +1 -0
  26. package/dist/cli/local-config.js +4 -0
  27. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  28. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  29. package/dist/runtime/attempt-database-broker.d.ts +89 -0
  30. package/dist/runtime/attempt-database-broker.js +390 -0
  31. package/dist/runtime/attempt-node-runtime-launcher.d.ts +2 -1
  32. package/dist/runtime/attempt-node-runtime-launcher.js +57 -18
  33. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  34. package/dist/runtime/attempt-path-operation.js +94 -0
  35. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  36. package/dist/runtime/attempt-private-directory.js +54 -0
  37. package/dist/runtime/attempt-process-supervisor.d.ts +16 -0
  38. package/dist/runtime/attempt-process-supervisor.js +94 -23
  39. package/dist/runtime/canonical-source-file.d.ts +3 -0
  40. package/dist/runtime/canonical-source-file.js +54 -0
  41. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  42. package/dist/runtime/docker-engine-probe.js +333 -0
  43. package/dist/runtime/generated-output-reset.d.ts +10 -0
  44. package/dist/runtime/generated-output-reset.js +62 -8
  45. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  46. package/dist/runtime/host-database-capability-boundary.js +62 -0
  47. package/dist/runtime/host-diagnostics.d.ts +11 -0
  48. package/dist/runtime/host-diagnostics.js +192 -0
  49. package/dist/runtime/host-execution-root.d.ts +4 -0
  50. package/dist/runtime/host-execution-root.js +39 -0
  51. package/dist/runtime/host-listener-inspector.d.ts +8 -1
  52. package/dist/runtime/host-listener-inspector.js +121 -60
  53. package/dist/runtime/host-runtime-contract.d.ts +12 -1
  54. package/dist/runtime/host-runtime-contract.js +168 -55
  55. package/dist/runtime/host-runtime-lease.d.ts +92 -8
  56. package/dist/runtime/host-runtime-lease.js +1141 -114
  57. package/dist/runtime/host-runtime-promotion.d.ts +12 -3
  58. package/dist/runtime/host-runtime-promotion.js +59 -12
  59. package/dist/runtime/memory-capacity.d.ts +70 -0
  60. package/dist/runtime/memory-capacity.js +123 -0
  61. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  62. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  63. package/dist/runtime/node-host-runtime-adapter.d.ts +3 -0
  64. package/dist/runtime/node-host-runtime-adapter.js +29 -11
  65. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  66. package/dist/runtime/pinned-directory-process.js +61 -0
  67. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  68. package/dist/runtime/pinned-source-directory.js +94 -0
  69. package/dist/runtime/playwright-host-runtime-adapter.d.ts +1 -0
  70. package/dist/runtime/playwright-host-runtime-adapter.js +53 -5
  71. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  72. package/dist/runtime/prepared-runtime-identity.js +218 -0
  73. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  74. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  75. package/dist/runtime/runtime-isolation-lease.js +4 -1
  76. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  77. package/dist/runtime/workspace-resource-estimator.js +174 -0
  78. package/dist/sandbox/preflight.d.ts +31 -1
  79. package/dist/sandbox/preflight.js +237 -51
  80. package/dist/sandbox/repository-lock.d.ts +2 -1
  81. package/dist/sandbox/repository-lock.js +19 -9
  82. package/dist/sandbox/runtime.d.ts +6 -1
  83. package/dist/sandbox/runtime.js +137 -107
  84. package/dist/sandbox/sandbox.types.d.ts +7 -0
  85. package/dist/sandbox/workspace.service.js +2 -0
  86. package/dist/session/managed-attempt-tools.d.ts +17 -1
  87. package/dist/session/managed-attempt-tools.js +489 -34
  88. package/dist/session/managed-build-coordinator.d.ts +5 -0
  89. package/dist/session/managed-build-coordinator.js +106 -0
  90. package/dist/session/managed-build-receipt.d.ts +21 -0
  91. package/dist/session/managed-build-receipt.js +337 -0
  92. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  93. package/dist/session/managed-verification-timeouts.js +17 -0
  94. package/dist/session/phase-completion.artifacts.d.ts +17 -0
  95. package/dist/session/phase-completion.artifacts.js +224 -22
  96. package/dist/session/phase-completion.d.ts +11 -1
  97. package/dist/session/phase-completion.helpers.d.ts +5 -0
  98. package/dist/session/phase-completion.helpers.js +30 -7
  99. package/dist/session/phase-completion.js +14 -3
  100. package/dist/session/phase-completion.prompts.js +17 -18
  101. package/dist/session/phase-completion.runtime.d.ts +3 -1
  102. package/dist/session/phase-completion.runtime.js +126 -17
  103. package/dist/session/phase-completion.schemas.js +49 -2
  104. package/dist/session/phase-completion.validation.d.ts +1 -0
  105. package/dist/session/phase-completion.validation.js +111 -21
  106. package/dist/session/pre-publication-validation.d.ts +75 -0
  107. package/dist/session/pre-publication-validation.js +661 -0
  108. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  109. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  110. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  111. package/dist/session/provider-cli/ci-repair-readiness.js +23 -3
  112. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +4 -2
  113. package/dist/session/provider-cli/provider-cli.artifacts.js +47 -13
  114. package/dist/session/provider-cli/provider-cli.deterministic.js +34 -2
  115. package/dist/session/provider-cli/provider-cli.process.d.ts +51 -0
  116. package/dist/session/provider-cli/provider-cli.process.js +422 -22
  117. package/dist/session/provider-cli/provider-cli.prompts.d.ts +5 -0
  118. package/dist/session/provider-cli/provider-cli.prompts.js +136 -16
  119. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  120. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  121. package/dist/session/provider-cli/provider-cli.runtime.d.ts +14 -1
  122. package/dist/session/provider-cli/provider-cli.runtime.js +54 -3
  123. package/dist/session/provider-cli/provider-cli.service.d.ts +30 -5
  124. package/dist/session/provider-cli/provider-cli.service.js +773 -69
  125. package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
  126. package/dist/session/provider-cli/provider-output-state.d.ts +10 -0
  127. package/dist/session/provider-cli/provider-output-state.js +361 -17
  128. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  129. package/dist/session/provider-cli/provider-usage.js +28 -5
  130. package/dist/session/session-redaction.js +434 -9
  131. package/dist/session/session.service.js +145 -23
  132. package/dist/session/session.types.d.ts +147 -2
  133. package/dist/session/session.types.js +587 -7
  134. package/dist/session/unreferenced-exports.d.ts +12 -0
  135. package/dist/session/unreferenced-exports.js +59 -0
  136. package/dist/shared/format/format-bytes.d.ts +1 -0
  137. package/dist/shared/format/format-bytes.js +9 -0
  138. package/dist/shared/git/git.js +2 -1
  139. package/dist/shared/path/path.d.ts +1 -0
  140. package/dist/shared/path/path.js +18 -0
  141. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  142. package/dist/shared/process/dotnet-toolchain.js +307 -0
  143. package/dist/shared/process/host-process-recovery.js +3 -2
  144. package/dist/shared/process/host-process-supervisor.d.ts +2 -0
  145. package/dist/shared/process/host-process-supervisor.js +60 -11
  146. package/dist/shared/process/process.d.ts +2 -0
  147. package/dist/shared/process/process.js +12 -7
  148. package/dist/shared/provider-capabilities.d.ts +3 -0
  149. package/dist/shared/provider-capabilities.js +85 -0
  150. package/dist/shared/redaction/redaction.d.ts +1 -0
  151. package/dist/shared/redaction/redaction.js +39 -3
  152. package/dist/shared/time/monotonic.d.ts +1 -0
  153. package/dist/shared/time/monotonic.js +8 -0
  154. package/dist/version.d.ts +1 -1
  155. package/dist/version.js +1 -1
  156. package/dist/worktree/dependency-warmer.d.ts +41 -9
  157. package/dist/worktree/dependency-warmer.js +497 -22
  158. package/dist/worktree/index.d.ts +1 -1
  159. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  160. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  161. package/dist/worktree/worktree.service.d.ts +7 -0
  162. package/dist/worktree/worktree.service.js +253 -21
  163. package/dist/worktree/worktree.types.d.ts +7 -1
  164. package/package.json +7 -5
  165. package/runtime/windows-job-supervisor.ps1 +380 -14
@@ -0,0 +1,253 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { GitClient } from "../shared/git/git.js";
5
+ import { isSubPath } from "../shared/path/path.js";
6
+ import { stableStringify } from "../session/phase-completion.helpers.js";
7
+ const MAX_FILES = 128;
8
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
9
+ const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
10
+ const hash = (body) => createHash("sha256").update(body).digest("hex");
11
+ const fail = (reason) => new Error(`qa_handoff_invalid: ${reason}`);
12
+ const segment = (value) => {
13
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(value))
14
+ throw fail("invalid reference identity");
15
+ return value;
16
+ };
17
+ export function storyQaReferenceRoot(storyId, runId) {
18
+ return `.forge/work-items/${segment(storyId)}/qa-inputs/${segment(runId)}`;
19
+ }
20
+ // All inputs are reference-only. No phase verdict is created, promoted or replaced here.
21
+ export async function materializeStoryQaReferences(worktreePath, request, git = new GitClient()) {
22
+ const story = request.storyQa;
23
+ const bundle = story?.references;
24
+ if (!bundle)
25
+ return;
26
+ if (!request.referenceRunId || bundle.schemaVersion !== 1 || bundle.storyWorkItemId !== request.workItem.id
27
+ || bundle.cycleRunId !== request.referenceCycleRunId || bundle.storyWorkItemId !== request.ownerId
28
+ || bundle.storyWorkItemId !== story.storyWorkItemId || bundle.repoId !== request.repo.id
29
+ || bundle.projectId !== request.workItem.projectId || bundle.effectiveAmendmentHash !== request.effectivePlanAmendmentHash
30
+ || bundle.planId !== story.planId || bundle.planContentHash !== story.planContentHash || story.worktreeKey !== request.worktreeKey
31
+ || !/^[a-f0-9]{64}$/i.test(bundle.effectiveAmendmentHash))
32
+ throw fail("reference owner/plan identity mismatch");
33
+ const relativeRoot = storyQaReferenceRoot(bundle.storyWorkItemId, request.referenceRunId);
34
+ const root = path.resolve(worktreePath, relativeRoot);
35
+ await safePath(worktreePath, root);
36
+ const files = [...bundle.files];
37
+ validateFiles(files);
38
+ const checkpoint = await captureCheckpoint(worktreePath, bundle, git);
39
+ if (checkpoint)
40
+ files.push(checkpoint);
41
+ validateFiles(files);
42
+ const manifest = JSON.stringify({ ...bundle, files: files.map(({ body: _body, ...file }) => file),
43
+ notice: "Reference input only. Historical PASS and repair checkpoint status are unchanged. Fresh QA is still required." }, null, 2) + "\n";
44
+ // Prepare a sibling directory and publish by rename; never leave a partially written manifest.
45
+ await fs.mkdir(path.dirname(root), { recursive: true });
46
+ await safePath(worktreePath, path.dirname(root));
47
+ const existing = await fs.stat(root).catch(error => { if (error.code === "ENOENT")
48
+ return null; throw error; });
49
+ if (existing) {
50
+ // A resumed cycle may reuse only its exact immutable reference snapshot.
51
+ await safePath(worktreePath, path.join(root, "manifest.json"));
52
+ const previous = JSON.parse(await fs.readFile(path.join(root, "manifest.json"), "utf8"));
53
+ const current = JSON.parse(manifest);
54
+ delete previous.capturedAt;
55
+ delete current.capturedAt;
56
+ if (stableStringify(previous) !== stableStringify(current))
57
+ throw fail("reference snapshot changed for this run");
58
+ for (const file of files) {
59
+ await safePath(worktreePath, path.join(root, file.path));
60
+ if (hash(await fs.readFile(path.join(root, file.path))) !== file.contentHash.toLowerCase())
61
+ throw fail("existing reference hash mismatch");
62
+ }
63
+ return;
64
+ }
65
+ const staging = `${root}.tmp-${randomUUID()}`;
66
+ await fs.mkdir(staging);
67
+ try {
68
+ for (const file of files) {
69
+ const target = path.join(staging, file.path);
70
+ await safePath(worktreePath, target);
71
+ await fs.mkdir(path.dirname(target), { recursive: true });
72
+ await fs.writeFile(target, file.body, { encoding: "utf8", flag: "wx", mode: 0o600 });
73
+ }
74
+ await fs.writeFile(path.join(staging, "manifest.json"), manifest, { flag: "wx", mode: 0o600 });
75
+ await safePath(worktreePath, root);
76
+ await fs.rename(staging, root);
77
+ }
78
+ finally {
79
+ await fs.rm(staging, { recursive: true, force: true });
80
+ }
81
+ }
82
+ function validateFiles(files) {
83
+ if (files.length === 0 || files.length > MAX_FILES)
84
+ throw fail("reference file count exceeds bounds");
85
+ const paths = new Set();
86
+ let total = 0;
87
+ for (const file of files) {
88
+ if (!/^(original|effective|children|amendments|checkpoint)\/[a-zA-Z0-9_./-]+$/.test(file.path)
89
+ || file.path.split("/").some(part => !part || part === "." || part === ".." || part.endsWith(".")))
90
+ throw fail("unsafe reference path");
91
+ const key = file.path.toLowerCase();
92
+ if (paths.has(key))
93
+ throw fail("duplicate reference path");
94
+ paths.add(key);
95
+ const bytes = Buffer.byteLength(file.body, "utf8");
96
+ total += bytes;
97
+ if (bytes > MAX_FILE_BYTES || total > MAX_TOTAL_BYTES)
98
+ throw fail("reference size exceeds bounds");
99
+ if (!/^[a-f0-9]{64}$/i.test(file.contentHash) || hash(file.body) !== file.contentHash.toLowerCase())
100
+ throw fail("reference content hash mismatch");
101
+ if (!file.provenance.trim())
102
+ throw fail("reference provenance missing");
103
+ }
104
+ }
105
+ async function captureCheckpoint(worktreePath, bundle, git) {
106
+ const storyId = bundle.storyWorkItemId;
107
+ const originalPath = path.join(worktreePath, ".forge", "work-items", segment(storyId), "artifacts", "comment-repair.handoff.json");
108
+ await safePath(worktreePath, originalPath);
109
+ let sourcePath = originalPath;
110
+ let archivedManifestPath;
111
+ let stat = await fs.stat(sourcePath).catch(error => { if (error.code === "ENOENT")
112
+ return null; throw error; });
113
+ if (!stat) {
114
+ // A previous QA already cleared outputs. A preserved input is not a new output:
115
+ // validate its immutable snapshot before deciding whether it still describes this tree.
116
+ const historyRoot = path.join(worktreePath, ".forge", "work-items", segment(storyId), "qa-inputs");
117
+ await safePath(worktreePath, historyRoot);
118
+ const entries = await fs.readdir(historyRoot, { withFileTypes: true }).catch(error => { if (error.code === "ENOENT")
119
+ return []; throw error; });
120
+ const candidates = [];
121
+ if (entries.length > MAX_FILES)
122
+ throw fail("checkpoint reference history exceeds bounds");
123
+ for (const entry of entries) {
124
+ if (!entry.isDirectory() || entry.name.includes(".tmp-"))
125
+ continue;
126
+ const candidate = path.join(historyRoot, segment(entry.name), "checkpoint", "comment-repair.handoff.json");
127
+ await safePath(worktreePath, candidate);
128
+ const candidateStat = await fs.stat(candidate).catch(error => { if (error.code === "ENOENT")
129
+ return null; throw error; });
130
+ if (candidateStat)
131
+ candidates.push({ path: candidate, modified: candidateStat.mtimeMs });
132
+ }
133
+ candidates.sort((a, b) => b.modified - a.modified);
134
+ if (!candidates[0])
135
+ return null;
136
+ sourcePath = candidates[0].path;
137
+ archivedManifestPath = path.join(path.dirname(path.dirname(sourcePath)), "manifest.json");
138
+ stat = await fs.stat(sourcePath);
139
+ }
140
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
141
+ throw fail("checkpoint size exceeds bounds");
142
+ const body = await fs.readFile(sourcePath, "utf8");
143
+ const checkpoint = JSON.parse(body);
144
+ const recordedHash = checkpoint.content_hash;
145
+ const withoutHash = { ...checkpoint };
146
+ delete withoutHash.content_hash;
147
+ if (typeof recordedHash !== "string" || hash(stableStringify(withoutHash)) !== recordedHash.toLowerCase())
148
+ throw fail("checkpoint content hash mismatch");
149
+ const legacyOwner = checkpoint.owner_id === undefined && checkpoint.owner_type === undefined;
150
+ if (checkpoint.phase !== "comment_repair" || checkpoint.work_item_id !== storyId
151
+ || (!legacyOwner && (checkpoint.owner_id !== storyId || !["work_item", "story"].includes(String(checkpoint.owner_type))))
152
+ || !["pass", "needs_rework", "blocked"].includes(String(checkpoint.status))
153
+ || !Array.isArray(checkpoint.changed_files))
154
+ throw fail("checkpoint provenance mismatch");
155
+ // Ordinary repair artifacts describe paths/summaries but do not certify current bytes.
156
+ // Preserve them only as historical claims; extended checkpoints must pass every exact-tree fence.
157
+ const extended = checkpoint.attempt_dispatch_id !== undefined
158
+ || checkpoint.changed_files.some(value => value && typeof value === "object" && "sha256" in value);
159
+ const ordinaryProvenance = `historical-local-repair-claim:status=${checkpoint.status};not-current-tree-validation;not-authoritative-validation`;
160
+ const exactProvenance = `preserved-local-agent-claim:${checkpoint.attempt_dispatch_id}:HEAD=${checkpoint.repair_head_sha}:status=${checkpoint.status};not-authoritative-validation`;
161
+ const historicalProvenance = `historical-local-repair-claim:${checkpoint.attempt_dispatch_id}:recordedHEAD=${checkpoint.repair_head_sha}:status=${checkpoint.status};not-current-tree-validation;not-authoritative-validation`;
162
+ const archivedProvenance = archivedManifestPath
163
+ ? await validateArchivedCheckpoint(worktreePath, archivedManifestPath, bundle, body, extended ? [exactProvenance, historicalProvenance] : [ordinaryProvenance])
164
+ : undefined;
165
+ if (!extended)
166
+ return { path: "checkpoint/comment-repair.handoff.json", body, contentHash: hash(body), provenance: ordinaryProvenance };
167
+ if (typeof checkpoint.attempt_dispatch_id !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(checkpoint.attempt_dispatch_id))
168
+ throw fail("checkpoint attempt identity missing");
169
+ if (typeof checkpoint.repair_head_sha !== "string" || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(checkpoint.repair_head_sha))
170
+ throw fail("checkpoint HEAD invalid");
171
+ if (checkpoint.changed_files.length > 512)
172
+ throw fail("checkpoint file count exceeds bounds");
173
+ const recorded = new Set();
174
+ for (const value of checkpoint.changed_files) {
175
+ if (!value || typeof value !== "object")
176
+ throw fail("invalid checkpoint file");
177
+ const file = value;
178
+ if (typeof file.path !== "string" || file.path.includes("\\") || file.path.split("/").some(part => !part || part === "." || part === "..")
179
+ || path.isAbsolute(file.path) || file.path.includes(":") || [...file.path].some(character => character.charCodeAt(0) <= 31)
180
+ || typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256))
181
+ throw fail("unsafe checkpoint file");
182
+ const filePath = file.path;
183
+ if ([...recorded].some(value => value.toLowerCase() === filePath.toLowerCase()))
184
+ throw fail("duplicate checkpoint file");
185
+ recorded.add(filePath);
186
+ }
187
+ const head = (await git.run(worktreePath, ["rev-parse", "HEAD"])).trim();
188
+ if (archivedProvenance && (checkpoint.repair_head_sha !== head || archivedProvenance === historicalProvenance)) {
189
+ // Publication (and later repair) may legitimately advance the owned branch. This
190
+ // snapshot remains an unchanged historical claim, NEVER proof of the new tree.
191
+ // Do not apply this path to fresh outputs or infer a PASS/publication receipt.
192
+ await git.run(worktreePath, ["merge-base", "--is-ancestor", checkpoint.repair_head_sha, head])
193
+ .catch(() => { throw fail("checkpoint history is not an ancestor of the prepared HEAD"); });
194
+ if ((await git.run(worktreePath, ["rev-parse", "HEAD"])).trim() !== head)
195
+ throw fail("checkpoint HEAD changed during verification");
196
+ return { path: "checkpoint/comment-repair.handoff.json", body, contentHash: hash(body), provenance: historicalProvenance };
197
+ }
198
+ if (checkpoint.repair_head_sha !== head)
199
+ throw fail("checkpoint HEAD changed");
200
+ for (const value of checkpoint.changed_files) {
201
+ const file = value;
202
+ const target = path.resolve(worktreePath, file.path);
203
+ await safePath(worktreePath, target);
204
+ const fileStat = await fs.stat(target);
205
+ if (!fileStat.isFile() || fileStat.size > 32 * 1024 * 1024 || hash(await fs.readFile(target)) !== file.sha256.toLowerCase())
206
+ throw fail("checkpoint changed-file hash mismatch");
207
+ }
208
+ const tracked = (await git.run(worktreePath, ["diff", "--name-only", "-z", "HEAD"])).split("\0").filter(Boolean);
209
+ const untracked = (await git.run(worktreePath, ["ls-files", "--others", "--exclude-standard", "-z"])).split("\0")
210
+ .filter(file => file && !/^\.forge\/(work-items|stories)\//.test(file));
211
+ const changed = new Set([...tracked, ...untracked]);
212
+ if (recorded.size !== changed.size || [...changed].some(file => !recorded.has(file)))
213
+ throw fail("checkpoint does not cover the complete prepared diff");
214
+ if ((await git.run(worktreePath, ["rev-parse", "HEAD"])).trim() !== head)
215
+ throw fail("checkpoint HEAD changed during verification");
216
+ return { path: "checkpoint/comment-repair.handoff.json", body, contentHash: hash(body),
217
+ provenance: exactProvenance };
218
+ }
219
+ async function validateArchivedCheckpoint(worktreePath, manifestPath, bundle, body, allowedProvenance) {
220
+ await safePath(worktreePath, manifestPath);
221
+ const stat = await fs.stat(manifestPath).catch(() => { throw fail("checkpoint reference manifest missing"); });
222
+ if (!stat.isFile() || stat.size > MAX_TOTAL_BYTES)
223
+ throw fail("checkpoint reference manifest exceeds bounds");
224
+ const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8"));
225
+ if (manifest?.schemaVersion !== 1 || manifest.storyWorkItemId !== bundle.storyWorkItemId
226
+ || manifest.projectId !== bundle.projectId || manifest.repoId !== bundle.repoId
227
+ || manifest.planId !== bundle.planId || manifest.planContentHash !== bundle.planContentHash
228
+ || manifest.effectiveAmendmentHash !== bundle.effectiveAmendmentHash
229
+ || typeof manifest.cycleRunId !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(manifest.cycleRunId)
230
+ || !Array.isArray(manifest.files) || manifest.files.length > MAX_FILES)
231
+ throw fail("checkpoint reference identity mismatch");
232
+ const files = manifest.files.filter((file) => typeof file?.path === "string" && file.path.toLowerCase() === "checkpoint/comment-repair.handoff.json");
233
+ if (files.length !== 1 || files[0].path !== "checkpoint/comment-repair.handoff.json"
234
+ || files[0].contentHash !== hash(body) || !allowedProvenance.includes(files[0].provenance))
235
+ throw fail("checkpoint reference integrity mismatch");
236
+ return files[0].provenance;
237
+ }
238
+ async function safePath(root, target) {
239
+ if (!isSubPath(root, target))
240
+ throw fail("reference path escapes worktree");
241
+ let current = path.resolve(root);
242
+ for (const part of path.relative(current, target).split(path.sep)) {
243
+ if (!part)
244
+ continue;
245
+ current = path.join(current, part);
246
+ const stat = await fs.lstat(current).catch(error => { if (error.code === "ENOENT")
247
+ return null; throw error; });
248
+ if (!stat)
249
+ return;
250
+ if (stat.isSymbolicLink())
251
+ throw fail("reference path traverses symlink");
252
+ }
253
+ }
@@ -7,8 +7,10 @@ export declare class GitWorktreeService implements WorktreeService {
7
7
  constructor(git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
8
8
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
9
9
  private trackedForgePaths;
10
+ private registeredManagedWorktreePath;
10
11
  private repoCreationCoordinationPath;
11
12
  release(handle: WorktreeHandle): Promise<void>;
13
+ private restoreOrphanedWorktree;
12
14
  private resolveRepoPath;
13
15
  private managedRepoPath;
14
16
  private findExistingRepoPath;
@@ -19,5 +21,10 @@ export declare class GitWorktreeService implements WorktreeService {
19
21
  private currentBranch;
20
22
  private ensureGitIdentity;
21
23
  }
24
+ type WorktreeRename = (source: string, destination: string) => Promise<void>;
25
+ type WorktreeRenameDelay = (milliseconds: number) => Promise<void>;
26
+ export declare function renameWorktreeForRelease(source: string, destination: string, rename?: WorktreeRename, wait?: WorktreeRenameDelay, maxAttempts?: number): Promise<void>;
27
+ export declare function requiresMutableEnvironmentPreparation(request: Pick<WorktreePrepareRequest, "applicationRuntimeRequired">): boolean;
22
28
  export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
23
29
  export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
30
+ export {};
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { RepositoryCoordinatorLock } from "../sandbox/repository-lock.js";
@@ -5,6 +6,7 @@ import { GitClient } from "../shared/git/git.js";
5
6
  import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
6
7
  import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
7
8
  import { hydratePriorPhaseArtifacts } from "./prior-phase-artifact-hydrator.js";
9
+ import { materializeStoryQaReferences } from "./story-qa-reference-hydrator.js";
8
10
  import { warmWorktreeDependencies } from "./dependency-warmer.js";
9
11
  import { defaultWorktreeCoordinatorStateRoot, managedRepoRoot, resolveManagedWorktreeRoot, } from "./worktree-paths.js";
10
12
  export class GitWorktreeService {
@@ -36,37 +38,73 @@ export class GitWorktreeService {
36
38
  if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
37
39
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
38
40
  }
41
+ let selectedWorktreePath = worktreePath;
39
42
  if (!(await directoryExists(worktreePath))) {
40
- await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
41
- return await resolveExistingDirectoryPath(worktreePath);
43
+ // A prior runner can disappear after Git registers its worktree but before
44
+ // normal cleanup completes. Release that missing-directory registration
45
+ // immediately so retrying the same lease remains idempotent.
46
+ await this.git.run(repoPath, ["worktree", "prune", "--expire", "now"]);
47
+ const registeredWorktreePath = await this.registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments);
48
+ if (!registeredWorktreePath) {
49
+ const branchRef = `refs/heads/${branchName}`;
50
+ if (request.preserveExistingState && await this.gitRefExists(repoPath, branchRef)) {
51
+ // The checkout directory can disappear while its branch still
52
+ // contains completed agent work. Reattach that branch verbatim;
53
+ // `-B ... baseStartPoint` would silently reset it and lose progress.
54
+ await this.git.run(repoPath, ["worktree", "add", worktreePath, branchName]);
55
+ }
56
+ else {
57
+ await this.git.run(repoPath, ["worktree", "add", "-B", branchName, worktreePath, baseStartPoint]);
58
+ }
59
+ return await resolveExistingDirectoryPath(worktreePath);
60
+ }
61
+ // The selected checkout can change when a Bridge reconnects or a local
62
+ // runner takes over. Its managed root therefore changes even though the
63
+ // deterministic lease branch does not. Reattach to the registered,
64
+ // Forge-owned worktree instead of trying to check out that branch twice.
65
+ selectedWorktreePath = registeredWorktreePath;
42
66
  }
43
- const existingWorktreePath = await resolveExistingDirectoryPath(worktreePath);
67
+ const existingWorktreePath = await resolveExistingDirectoryPath(selectedWorktreePath);
44
68
  if (!isSubPath(allowedRoot, existingWorktreePath)) {
45
69
  throw new Error(`Worktree path must stay under ${allowedRoot}.`);
46
70
  }
71
+ if (!(await gitMetadataExists(existingWorktreePath, allowedRoot))) {
72
+ await this.restoreOrphanedWorktree({
73
+ repoPath,
74
+ worktreePath: existingWorktreePath,
75
+ branchName,
76
+ baseStartPoint,
77
+ allowedRoot,
78
+ });
79
+ }
47
80
  const currentBranch = await this.currentBranch(existingWorktreePath);
48
81
  if (currentBranch !== branchName) {
49
82
  throw new Error(`Existing worktree ${existingWorktreePath} is on branch '${currentBranch}', expected '${branchName}'.`);
50
83
  }
51
- if (shouldResetExistingWorktree(request)) {
84
+ if (!request.preserveExistingState && shouldResetExistingWorktree(request)) {
52
85
  await this.resetWorktreeToBase(existingWorktreePath, baseStartPoint);
53
86
  }
54
87
  return existingWorktreePath;
55
88
  });
56
- await this.ensureGitIdentity(resolvedWorktreePath);
57
- await clearRunArtifactRoots(resolvedWorktreePath, request);
89
+ // Reference inputs and an exact-tree local repair checkpoint must survive output cleanup.
90
+ await materializeStoryQaReferences(resolvedWorktreePath, request, this.git);
91
+ if (!request.preserveExistingState) {
92
+ await clearRunArtifactRoots(resolvedWorktreePath, request);
93
+ }
58
94
  const hydratedPriorPhaseArtifactCount = await hydratePriorPhaseArtifacts(resolvedWorktreePath, request.priorPhaseArtifacts);
59
95
  const qaReworkPacketPath = await materializeQaReworkPacket(resolvedWorktreePath, request);
60
96
  const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
61
97
  if (artifacts.length > 0) {
62
98
  await injectWorktreeArtifacts(resolvedWorktreePath, artifacts, await this.trackedForgePaths(resolvedWorktreePath));
63
99
  }
64
- const dependencyWarm = await warmWorktreeDependencies({
65
- baseCheckoutPath: repoPath,
66
- worktreePath: resolvedWorktreePath,
67
- allowedRoot,
68
- warmPathOverrides: request.warmPathOverrides
69
- });
100
+ const dependencyWarm = requiresMutableEnvironmentPreparation(request)
101
+ ? await warmWorktreeDependencies({
102
+ baseCheckoutPath: repoPath,
103
+ worktreePath: resolvedWorktreePath,
104
+ allowedRoot,
105
+ warmPathOverrides: request.warmPathOverrides
106
+ })
107
+ : undefined;
70
108
  return {
71
109
  repoPath,
72
110
  worktreePath: resolvedWorktreePath,
@@ -82,6 +120,19 @@ export class GitWorktreeService {
82
120
  const output = await this.git.run(worktreePath, ["ls-files", "-z", "--", ".forge"]);
83
121
  return new Set(output.split("\0").filter(Boolean).map(value => value.replaceAll("\\", "/")));
84
122
  }
123
+ async registeredManagedWorktreePath(repoPath, branchName, allowedRoot, worktreeSegments) {
124
+ const listing = await this.git.run(repoPath, ["worktree", "list", "--porcelain", "-z"]);
125
+ const registered = parseRegisteredWorktrees(repoPath, listing)
126
+ .find(entry => entry.branchName === `refs/heads/${branchName}`);
127
+ if (!registered) {
128
+ return null;
129
+ }
130
+ const resolved = await resolveExistingDirectoryPath(registered.worktreePath);
131
+ if (!isManagedLeaseWorktreePath(allowedRoot, resolved, worktreeSegments)) {
132
+ throw new Error(`Branch '${branchName}' is already checked out outside its Forge-managed lease path at ${resolved}.`);
133
+ }
134
+ return resolved;
135
+ }
85
136
  repoCreationCoordinationPath(repo, allowedRoot) {
86
137
  if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
87
138
  return path.resolve(repo.serverLocalPath);
@@ -93,18 +144,58 @@ export class GitWorktreeService {
93
144
  return;
94
145
  }
95
146
  await this.coordinator.withLock(handle.repoPath, async () => {
96
- if (!(await directoryExists(handle.worktreePath))) {
97
- return;
98
- }
99
- try {
100
- await this.git.run(handle.repoPath, ["worktree", "remove", "--force", handle.worktreePath]);
101
- await this.git.run(handle.repoPath, ["worktree", "prune"]);
102
- }
103
- catch {
104
- await fs.rm(handle.worktreePath, { recursive: true, force: true });
147
+ if (await directoryExists(handle.worktreePath)) {
148
+ // On Windows, `git worktree remove` can delete the `.git` link and then
149
+ // fail on a locked build output. That leaves a full source tree which is
150
+ // no longer a Git worktree and makes every retry fail during prepare.
151
+ // Move the tree out of its stable lease path first. If the rename fails,
152
+ // leave the valid worktree untouched rather than partially unregistering it.
153
+ const releasePath = `${handle.worktreePath}.forge-release-${randomUUID()}`;
154
+ await renameWorktreeForRelease(handle.worktreePath, releasePath);
155
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]);
156
+ await fs.rm(releasePath, {
157
+ recursive: true,
158
+ force: true,
159
+ maxRetries: 10,
160
+ retryDelay: 100,
161
+ }).catch(() => undefined);
105
162
  }
163
+ // The process can disappear after Git registers a worktree but before the
164
+ // directory is fully materialized (or after an external cleanup removes it).
165
+ // Always prune so those missing-directory registrations do not accumulate.
166
+ await this.git.run(handle.repoPath, ["worktree", "prune", "--expire", "now"]).catch(() => undefined);
106
167
  });
107
168
  }
169
+ async restoreOrphanedWorktree(input) {
170
+ const orphanStat = await fs.lstat(input.worktreePath);
171
+ if (orphanStat.isSymbolicLink() || !orphanStat.isDirectory()) {
172
+ throw new Error(`Refusing to recover redirected or non-directory worktree ${input.worktreePath}.`);
173
+ }
174
+ const quarantinePath = `${input.worktreePath}.forge-orphan-${randomUUID()}`;
175
+ if (!isSubPath(input.allowedRoot, quarantinePath)) {
176
+ throw new Error(`Worktree recovery path must stay under ${input.allowedRoot}.`);
177
+ }
178
+ await fs.rename(input.worktreePath, quarantinePath);
179
+ try {
180
+ await this.git.run(input.repoPath, ["worktree", "prune", "--expire", "now"]);
181
+ await this.git.run(input.repoPath, [
182
+ "worktree",
183
+ "add",
184
+ "-B",
185
+ input.branchName,
186
+ input.worktreePath,
187
+ input.baseStartPoint,
188
+ ]);
189
+ await overlayOrphanedWorktree(input.worktreePath, quarantinePath);
190
+ await fs.rm(quarantinePath, { recursive: true, force: true });
191
+ }
192
+ catch (error) {
193
+ await this.git.run(input.repoPath, ["worktree", "remove", "--force", input.worktreePath]).catch(() => undefined);
194
+ await fs.rm(input.worktreePath, { recursive: true, force: true }).catch(() => undefined);
195
+ await fs.rename(quarantinePath, input.worktreePath).catch(() => undefined);
196
+ throw new Error(`Failed to recover orphaned worktree ${input.worktreePath}.`, { cause: error });
197
+ }
198
+ }
108
199
  async resolveRepoPath(repo, allowedRoot, baseBranch) {
109
200
  if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
110
201
  const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
@@ -233,6 +324,32 @@ export class GitWorktreeService {
233
324
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
234
325
  }
235
326
  }
327
+ export async function renameWorktreeForRelease(source, destination, rename = fs.rename, wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), maxAttempts = 8) {
328
+ const attempts = Number.isFinite(maxAttempts) && maxAttempts > 0
329
+ ? Math.max(1, Math.floor(maxAttempts))
330
+ : 1;
331
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
332
+ try {
333
+ await rename(source, destination);
334
+ return;
335
+ }
336
+ catch (error) {
337
+ if (!isTransientWorktreeRenameError(error) || attempt === attempts)
338
+ throw error;
339
+ await wait(Math.min(1_000, 100 * (2 ** (attempt - 1))));
340
+ }
341
+ }
342
+ }
343
+ function isTransientWorktreeRenameError(error) {
344
+ if (!error || typeof error !== "object" || !("code" in error))
345
+ return false;
346
+ const code = String(error.code ?? "").toUpperCase();
347
+ return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "ENOTEMPTY";
348
+ }
349
+ export function requiresMutableEnvironmentPreparation(request) {
350
+ // Missing runtime intent is treated conservatively for older dispatch packets.
351
+ return request.applicationRuntimeRequired !== false;
352
+ }
236
353
  function isCloneableRemote(remoteUrl) {
237
354
  const trimmed = remoteUrl?.trim() ?? "";
238
355
  if (trimmed.length === 0 || trimmed.startsWith("local://")) {
@@ -317,6 +434,121 @@ function shouldResetExistingWorktree(request) {
317
434
  }
318
435
  return true;
319
436
  }
437
+ function parseRegisteredWorktrees(repoPath, output) {
438
+ const worktrees = [];
439
+ let current = null;
440
+ for (const field of output.split("\0")) {
441
+ if (field.startsWith("worktree ")) {
442
+ if (current)
443
+ worktrees.push(current);
444
+ const rawPath = field.slice("worktree ".length);
445
+ current = {
446
+ worktreePath: path.resolve(path.isAbsolute(rawPath) ? rawPath : path.join(repoPath, rawPath)),
447
+ branchName: null,
448
+ };
449
+ }
450
+ else if (field.startsWith("branch ") && current) {
451
+ current.branchName = field.slice("branch ".length).trim();
452
+ }
453
+ }
454
+ if (current)
455
+ worktrees.push(current);
456
+ return worktrees;
457
+ }
458
+ function isManagedLeaseWorktreePath(allowedRoot, candidate, worktreeSegments) {
459
+ if (!isSubPath(allowedRoot, candidate)) {
460
+ return false;
461
+ }
462
+ const relativeSegments = path.relative(path.resolve(allowedRoot), path.resolve(candidate))
463
+ .split(path.sep)
464
+ .filter(Boolean);
465
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
466
+ if (normalize(relativeSegments[0] ?? "") !== ".forge-worktrees"
467
+ || relativeSegments.length !== 2 + worktreeSegments.length) {
468
+ return false;
469
+ }
470
+ const suffix = relativeSegments.slice(-worktreeSegments.length).map(normalize);
471
+ return suffix.every((segment, index) => segment === normalize(worktreeSegments[index] ?? ""));
472
+ }
473
+ async function overlayOrphanedWorktree(worktreePath, orphanPath) {
474
+ // A failed Windows cleanup can remove only part of a worktree before its
475
+ // `.git` link disappears. Start from Git's clean checkout so missing tracked
476
+ // files are restored, then overlay every surviving agent file. Moving entries
477
+ // on the same volume preserves pnpm junctions without requiring symlink
478
+ // privileges, unlike fs.cp.
479
+ const backupRoot = `${worktreePath}.forge-base-${randomUUID()}`;
480
+ const moves = [];
481
+ try {
482
+ await fs.mkdir(backupRoot, { recursive: true });
483
+ const orphanEntries = await fs.readdir(orphanPath, { withFileTypes: true });
484
+ for (const entry of orphanEntries) {
485
+ if (entry.name === ".git") {
486
+ continue;
487
+ }
488
+ await overlayEntry({
489
+ sourcePath: path.join(orphanPath, entry.name),
490
+ destinationPath: path.join(worktreePath, entry.name),
491
+ backupPath: path.join(backupRoot, entry.name),
492
+ moves,
493
+ });
494
+ }
495
+ await fs.rm(backupRoot, { recursive: true, force: true });
496
+ }
497
+ catch (error) {
498
+ for (const move of moves.reverse()) {
499
+ await fs.mkdir(path.dirname(move.sourcePath), { recursive: true }).catch(() => undefined);
500
+ await fs.rename(move.destinationPath, move.sourcePath).catch(() => undefined);
501
+ if (move.backupPath) {
502
+ await fs.mkdir(path.dirname(move.destinationPath), { recursive: true }).catch(() => undefined);
503
+ await fs.rename(move.backupPath, move.destinationPath).catch(() => undefined);
504
+ }
505
+ }
506
+ await fs.rm(backupRoot, { recursive: true, force: true }).catch(() => undefined);
507
+ throw error;
508
+ }
509
+ }
510
+ async function overlayEntry(input) {
511
+ const sourceStat = await fs.lstat(input.sourcePath);
512
+ const destinationStat = await fs.lstat(input.destinationPath).catch(() => null);
513
+ if (sourceStat.isDirectory() &&
514
+ !sourceStat.isSymbolicLink() &&
515
+ destinationStat?.isDirectory() &&
516
+ !destinationStat.isSymbolicLink()) {
517
+ const entries = await fs.readdir(input.sourcePath, { withFileTypes: true });
518
+ for (const entry of entries) {
519
+ await overlayEntry({
520
+ sourcePath: path.join(input.sourcePath, entry.name),
521
+ destinationPath: path.join(input.destinationPath, entry.name),
522
+ backupPath: path.join(input.backupPath, entry.name),
523
+ moves: input.moves,
524
+ });
525
+ }
526
+ return;
527
+ }
528
+ let preservedBasePath;
529
+ if (destinationStat) {
530
+ await fs.mkdir(path.dirname(input.backupPath), { recursive: true });
531
+ await fs.rename(input.destinationPath, input.backupPath);
532
+ preservedBasePath = input.backupPath;
533
+ }
534
+ else {
535
+ await fs.mkdir(path.dirname(input.destinationPath), { recursive: true });
536
+ }
537
+ try {
538
+ await fs.rename(input.sourcePath, input.destinationPath);
539
+ }
540
+ catch (error) {
541
+ if (preservedBasePath) {
542
+ await fs.rename(preservedBasePath, input.destinationPath).catch(() => undefined);
543
+ }
544
+ throw error;
545
+ }
546
+ input.moves.push({
547
+ sourcePath: input.sourcePath,
548
+ destinationPath: input.destinationPath,
549
+ backupPath: preservedBasePath,
550
+ });
551
+ }
320
552
  function qaReworkPacketFileName(packetPath) {
321
553
  const normalized = packetPath.trim().replace(/\\/g, "/");
322
554
  const fileName = normalized.split("/").filter(Boolean).at(-1) ?? "";
@@ -1,4 +1,4 @@
1
- import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
1
+ import type { DispatchQaReworkContext, DispatchPriorPhaseArtifact, DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact, DispatchStoryQaContext } from "../session/session.types.js";
2
2
  import type { WarmDependenciesResult } from "./dependency-warmer.js";
3
3
  export interface WorktreePrepareRequest {
4
4
  readonly repo: DispatchRepo;
@@ -13,8 +13,14 @@ export interface WorktreePrepareRequest {
13
13
  readonly cleanupWorktrees: boolean;
14
14
  readonly artifacts: readonly DispatchResolvedArtifact[];
15
15
  readonly priorPhaseArtifacts?: readonly DispatchPriorPhaseArtifact[];
16
+ readonly storyQa?: DispatchStoryQaContext | null;
17
+ readonly referenceRunId?: string;
18
+ readonly referenceCycleRunId?: string;
19
+ readonly effectivePlanAmendmentHash?: string | null;
16
20
  readonly qaRework?: DispatchQaReworkContext | null;
17
21
  readonly warmPathOverrides?: readonly string[];
22
+ readonly applicationRuntimeRequired?: boolean;
23
+ readonly preserveExistingState?: boolean;
18
24
  }
19
25
  export interface WorktreeHandle {
20
26
  readonly repoPath: string;