@loucompanion/forge-bridge 0.1.3-dev.f66395c3ac21 → 0.1.4-dev.1ff37038366e

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 (221) hide show
  1. package/README.md +165 -1
  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 +9 -1
  5. package/dist/bridge/bridge.service.d.ts +129 -3
  6. package/dist/bridge/bridge.service.js +2427 -70
  7. package/dist/bridge/bridge.types.d.ts +218 -2
  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 +14 -2
  21. package/dist/cleanup/cleanup.service.js +496 -3
  22. package/dist/cleanup/cleanup.types.d.ts +10 -0
  23. package/dist/cleanup/index.d.ts +1 -1
  24. package/dist/cleanup/index.js +1 -1
  25. package/dist/cleanup/internals.d.ts +2 -2
  26. package/dist/cleanup/internals.js +1 -1
  27. package/dist/cli/cli.help.d.ts +2 -0
  28. package/dist/cli/cli.help.js +217 -0
  29. package/dist/cli/cli.service.d.ts +55 -7
  30. package/dist/cli/cli.service.js +429 -219
  31. package/dist/cli/local-config.d.ts +7 -0
  32. package/dist/cli/local-config.js +51 -3
  33. package/dist/runtime/aspnet-runtime-prebuild.d.ts +36 -0
  34. package/dist/runtime/aspnet-runtime-prebuild.js +341 -0
  35. package/dist/runtime/attempt-database-broker.d.ts +89 -0
  36. package/dist/runtime/attempt-database-broker.js +390 -0
  37. package/dist/runtime/attempt-node-runtime-launcher.d.ts +18 -0
  38. package/dist/runtime/attempt-node-runtime-launcher.js +140 -0
  39. package/dist/runtime/attempt-path-operation.d.ts +21 -0
  40. package/dist/runtime/attempt-path-operation.js +94 -0
  41. package/dist/runtime/attempt-private-directory.d.ts +6 -0
  42. package/dist/runtime/attempt-private-directory.js +54 -0
  43. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  44. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  45. package/dist/runtime/attempt-process-supervisor.d.ts +97 -0
  46. package/dist/runtime/attempt-process-supervisor.js +397 -0
  47. package/dist/runtime/canonical-source-file.d.ts +3 -0
  48. package/dist/runtime/canonical-source-file.js +54 -0
  49. package/dist/runtime/docker-engine-probe.d.ts +90 -0
  50. package/dist/runtime/docker-engine-probe.js +333 -0
  51. package/dist/runtime/generated-output-reset.d.ts +20 -0
  52. package/dist/runtime/generated-output-reset.js +157 -0
  53. package/dist/runtime/host-database-capability-boundary.d.ts +16 -0
  54. package/dist/runtime/host-database-capability-boundary.js +62 -0
  55. package/dist/runtime/host-diagnostics.d.ts +11 -0
  56. package/dist/runtime/host-diagnostics.js +191 -0
  57. package/dist/runtime/host-execution-root.d.ts +4 -0
  58. package/dist/runtime/host-execution-root.js +39 -0
  59. package/dist/runtime/host-listener-inspector.d.ts +51 -0
  60. package/dist/runtime/host-listener-inspector.js +385 -0
  61. package/dist/runtime/host-port-broker.d.ts +76 -0
  62. package/dist/runtime/host-port-broker.js +333 -0
  63. package/dist/runtime/host-runtime-contract.d.ts +58 -0
  64. package/dist/runtime/host-runtime-contract.js +813 -0
  65. package/dist/runtime/host-runtime-lease.d.ts +217 -0
  66. package/dist/runtime/host-runtime-lease.js +2487 -0
  67. package/dist/runtime/host-runtime-promotion.d.ts +32 -0
  68. package/dist/runtime/host-runtime-promotion.js +206 -0
  69. package/dist/runtime/memory-capacity.d.ts +70 -0
  70. package/dist/runtime/memory-capacity.js +123 -0
  71. package/dist/runtime/native-test-postgres-fallback.d.ts +25 -0
  72. package/dist/runtime/native-test-postgres-fallback.js +91 -0
  73. package/dist/runtime/node-host-runtime-adapter.d.ts +54 -0
  74. package/dist/runtime/node-host-runtime-adapter.js +285 -0
  75. package/dist/runtime/pinned-directory-process.d.ts +9 -0
  76. package/dist/runtime/pinned-directory-process.js +61 -0
  77. package/dist/runtime/pinned-source-directory.d.ts +17 -0
  78. package/dist/runtime/pinned-source-directory.js +94 -0
  79. package/dist/runtime/playwright-host-runtime-adapter.d.ts +21 -0
  80. package/dist/runtime/playwright-host-runtime-adapter.js +268 -0
  81. package/dist/runtime/prepared-runtime-identity.d.ts +6 -0
  82. package/dist/runtime/prepared-runtime-identity.js +218 -0
  83. package/dist/runtime/prepared-runtime-readiness.d.ts +30 -0
  84. package/dist/runtime/prepared-runtime-readiness.js +285 -0
  85. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  86. package/dist/runtime/runtime-isolation-lease.js +161 -0
  87. package/dist/runtime/workspace-resource-estimator.d.ts +17 -0
  88. package/dist/runtime/workspace-resource-estimator.js +174 -0
  89. package/dist/sandbox/command-runner.d.ts +4 -0
  90. package/dist/sandbox/command-runner.js +47 -0
  91. package/dist/sandbox/contract.d.ts +5 -0
  92. package/dist/sandbox/contract.js +276 -0
  93. package/dist/sandbox/event-budget.d.ts +11 -0
  94. package/dist/sandbox/event-budget.js +27 -0
  95. package/dist/sandbox/image-policy.d.ts +35 -0
  96. package/dist/sandbox/image-policy.js +247 -0
  97. package/dist/sandbox/index.d.ts +5 -0
  98. package/dist/sandbox/index.js +5 -0
  99. package/dist/sandbox/lease-watchdog.d.ts +18 -0
  100. package/dist/sandbox/lease-watchdog.js +67 -0
  101. package/dist/sandbox/preflight.d.ts +84 -0
  102. package/dist/sandbox/preflight.js +521 -0
  103. package/dist/sandbox/process.d.ts +20 -0
  104. package/dist/sandbox/process.js +209 -0
  105. package/dist/sandbox/provider-auth/codex.d.ts +26 -0
  106. package/dist/sandbox/provider-auth/codex.js +212 -0
  107. package/dist/sandbox/repository-lock.d.ts +6 -0
  108. package/dist/sandbox/repository-lock.js +128 -0
  109. package/dist/sandbox/runtime.d.ts +302 -0
  110. package/dist/sandbox/runtime.js +2367 -0
  111. package/dist/sandbox/sandbox.types.d.ts +93 -0
  112. package/dist/sandbox/sandbox.types.js +1 -0
  113. package/dist/sandbox/smoke.d.ts +76 -0
  114. package/dist/sandbox/smoke.js +380 -0
  115. package/dist/sandbox/worker-supervisor.d.ts +13 -0
  116. package/dist/sandbox/worker-supervisor.js +36 -0
  117. package/dist/sandbox/worker.d.ts +1 -0
  118. package/dist/sandbox/worker.js +225 -0
  119. package/dist/sandbox/workspace.service.d.ts +13 -0
  120. package/dist/sandbox/workspace.service.js +74 -0
  121. package/dist/session/managed-attempt-tools.d.ts +28 -0
  122. package/dist/session/managed-attempt-tools.js +1015 -0
  123. package/dist/session/managed-build-coordinator.d.ts +5 -0
  124. package/dist/session/managed-build-coordinator.js +105 -0
  125. package/dist/session/managed-build-receipt.d.ts +21 -0
  126. package/dist/session/managed-build-receipt.js +337 -0
  127. package/dist/session/managed-verification-timeouts.d.ts +8 -0
  128. package/dist/session/managed-verification-timeouts.js +17 -0
  129. package/dist/session/phase-completion.artifacts.d.ts +27 -0
  130. package/dist/session/phase-completion.artifacts.js +829 -0
  131. package/dist/session/phase-completion.d.ts +53 -42
  132. package/dist/session/phase-completion.helpers.d.ts +117 -0
  133. package/dist/session/phase-completion.helpers.js +707 -0
  134. package/dist/session/phase-completion.js +125 -3007
  135. package/dist/session/phase-completion.prompts.d.ts +14 -0
  136. package/dist/session/phase-completion.prompts.js +233 -0
  137. package/dist/session/phase-completion.runtime.d.ts +49 -0
  138. package/dist/session/phase-completion.runtime.js +840 -0
  139. package/dist/session/phase-completion.schemas.d.ts +3 -0
  140. package/dist/session/phase-completion.schemas.js +859 -0
  141. package/dist/session/phase-completion.validation.d.ts +38 -0
  142. package/dist/session/phase-completion.validation.js +918 -0
  143. package/dist/session/pre-publication-validation.d.ts +75 -0
  144. package/dist/session/pre-publication-validation.js +661 -0
  145. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.d.ts +72 -0
  146. package/dist/session/provider-cli/bridge-cli.conformance-fixtures.js +462 -0
  147. package/dist/session/provider-cli/ci-repair-readiness.d.ts +16 -0
  148. package/dist/session/provider-cli/ci-repair-readiness.js +110 -0
  149. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +95 -0
  150. package/dist/session/provider-cli/provider-cli.artifacts.js +812 -0
  151. package/dist/session/provider-cli/provider-cli.base.d.ts +1 -0
  152. package/dist/session/provider-cli/provider-cli.deterministic-payloads.d.ts +12 -0
  153. package/dist/session/provider-cli/provider-cli.deterministic-payloads.js +279 -0
  154. package/dist/session/provider-cli/provider-cli.deterministic.d.ts +14 -0
  155. package/dist/session/provider-cli/provider-cli.deterministic.js +407 -0
  156. package/dist/session/provider-cli/provider-cli.process.d.ts +99 -0
  157. package/dist/session/provider-cli/provider-cli.process.js +874 -0
  158. package/dist/session/provider-cli/provider-cli.prompts.d.ts +52 -0
  159. package/dist/session/provider-cli/provider-cli.prompts.js +715 -0
  160. package/dist/session/provider-cli/provider-cli.qa-sidecars.d.ts +11 -0
  161. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +66 -0
  162. package/dist/session/provider-cli/provider-cli.repair-epoch.d.ts +6 -0
  163. package/dist/session/provider-cli/provider-cli.repair-epoch.js +11 -0
  164. package/dist/session/provider-cli/provider-cli.runtime.d.ts +16 -0
  165. package/dist/session/provider-cli/provider-cli.runtime.js +81 -0
  166. package/dist/session/provider-cli/provider-cli.service.d.ts +47 -53
  167. package/dist/session/provider-cli/provider-cli.service.js +1257 -2497
  168. package/dist/session/provider-cli/provider-cli.types.d.ts +8 -0
  169. package/dist/session/provider-cli/provider-output-state.d.ts +32 -0
  170. package/dist/session/provider-cli/provider-output-state.js +817 -0
  171. package/dist/session/provider-cli/provider-usage.d.ts +7 -0
  172. package/dist/session/provider-cli/provider-usage.js +28 -5
  173. package/dist/session/session-redaction.d.ts +3 -0
  174. package/dist/session/session-redaction.js +448 -0
  175. package/dist/session/session.base.d.ts +6 -1
  176. package/dist/session/session.service.d.ts +8 -2
  177. package/dist/session/session.service.js +586 -59
  178. package/dist/session/session.types.d.ts +324 -3
  179. package/dist/session/session.types.js +939 -10
  180. package/dist/session/unreferenced-exports.d.ts +12 -0
  181. package/dist/session/unreferenced-exports.js +59 -0
  182. package/dist/shared/format/format-bytes.d.ts +1 -0
  183. package/dist/shared/format/format-bytes.js +9 -0
  184. package/dist/shared/git/git.js +2 -1
  185. package/dist/shared/path/path.d.ts +1 -0
  186. package/dist/shared/path/path.js +21 -1
  187. package/dist/shared/process/dotnet-toolchain.d.ts +17 -0
  188. package/dist/shared/process/dotnet-toolchain.js +307 -0
  189. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  190. package/dist/shared/process/host-process-recovery.js +78 -0
  191. package/dist/shared/process/host-process-supervisor.d.ts +59 -0
  192. package/dist/shared/process/host-process-supervisor.js +330 -0
  193. package/dist/shared/process/process.d.ts +3 -0
  194. package/dist/shared/process/process.js +13 -7
  195. package/dist/shared/process/windows-launch.d.ts +8 -0
  196. package/dist/shared/process/windows-launch.js +41 -0
  197. package/dist/shared/provider-capabilities.d.ts +4 -0
  198. package/dist/shared/provider-capabilities.js +88 -0
  199. package/dist/shared/redaction/redaction.d.ts +1 -0
  200. package/dist/shared/redaction/redaction.js +47 -4
  201. package/dist/version.d.ts +1 -1
  202. package/dist/version.js +1 -1
  203. package/dist/worktree/artifact-injector.d.ts +3 -2
  204. package/dist/worktree/artifact-injector.js +82 -10
  205. package/dist/worktree/dependency-warmer.d.ts +53 -1
  206. package/dist/worktree/dependency-warmer.js +619 -11
  207. package/dist/worktree/index.d.ts +2 -1
  208. package/dist/worktree/index.js +1 -0
  209. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  210. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  211. package/dist/worktree/story-qa-reference-hydrator.d.ts +4 -0
  212. package/dist/worktree/story-qa-reference-hydrator.js +253 -0
  213. package/dist/worktree/worktree-paths.d.ts +4 -0
  214. package/dist/worktree/worktree-paths.js +30 -0
  215. package/dist/worktree/worktree.base.d.ts +3 -0
  216. package/dist/worktree/worktree.service.d.ts +16 -3
  217. package/dist/worktree/worktree.service.js +334 -54
  218. package/dist/worktree/worktree.types.d.ts +9 -1
  219. package/package.json +12 -7
  220. package/runtime/windows-job-supervisor.ps1 +722 -0
  221. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,4 +1,5 @@
1
1
  export { GitWorktreeService } from "./worktree.service.js";
2
+ export { managedRepoRoot, resolveManagedWorktreeRoot, resolveManagedWorktreeRootForRepo, } from "./worktree-paths.js";
2
3
  export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
3
- export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult } from "./dependency-warmer.js";
4
+ export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult, DependencyPreparationPolicy, DependencyPreparationReceiptResult, } from "./dependency-warmer.js";
4
5
  export type { WorktreeService } from "./worktree.base.js";
@@ -1,2 +1,3 @@
1
1
  export { GitWorktreeService } from "./worktree.service.js";
2
+ export { managedRepoRoot, resolveManagedWorktreeRoot, resolveManagedWorktreeRootForRepo, } from "./worktree-paths.js";
2
3
  export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
@@ -0,0 +1,2 @@
1
+ import type { DispatchPriorPhaseArtifact } from "../session/session.types.js";
2
+ export declare function hydratePriorPhaseArtifacts(worktreePath: string, artifacts?: readonly DispatchPriorPhaseArtifact[]): Promise<number>;
@@ -0,0 +1,136 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { isSubPath } from "../shared/path/path.js";
5
+ const MAX_ARTIFACT_COUNT = 128;
6
+ const MAX_CONTENT_BYTES = 2 * 1024 * 1024;
7
+ const MAX_TOTAL_BYTES = 12 * 1024 * 1024;
8
+ const phaseFiles = {
9
+ implementation: {
10
+ verdict: "implementation.completeness.json",
11
+ report: "implementation.report.md"
12
+ },
13
+ self_review: {
14
+ verdict: "self-review.report.json",
15
+ report: "self-review.summary.md"
16
+ },
17
+ validation: {
18
+ verdict: "validation.verdict.json",
19
+ report: "validation.report.md"
20
+ }
21
+ };
22
+ export async function hydratePriorPhaseArtifacts(worktreePath, artifacts = []) {
23
+ if (artifacts.length === 0)
24
+ return 0;
25
+ if (artifacts.length > MAX_ARTIFACT_COUNT) {
26
+ throw new Error(`Prior phase artifact handoff exceeds ${MAX_ARTIFACT_COUNT} artifacts.`);
27
+ }
28
+ const targets = new Set();
29
+ let totalBytes = 0;
30
+ for (const artifact of artifacts) {
31
+ const workItemId = safeArtifactSegment(artifact.workItemId, "work item id");
32
+ const files = phaseFiles[artifact.phase];
33
+ if (!files) {
34
+ throw new Error(`Unsupported prior phase artifact phase '${artifact.phase}'.`);
35
+ }
36
+ const verdictBytes = Buffer.byteLength(artifact.verdictJson, "utf8");
37
+ const reportBytes = Buffer.byteLength(artifact.report, "utf8");
38
+ if (verdictBytes > MAX_CONTENT_BYTES || reportBytes > MAX_CONTENT_BYTES) {
39
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' exceeds the per-file hydration limit.`);
40
+ }
41
+ totalBytes += verdictBytes + reportBytes;
42
+ if (totalBytes > MAX_TOTAL_BYTES) {
43
+ throw new Error("Prior phase artifact handoff exceeds the total hydration limit.");
44
+ }
45
+ verifyContentHash(artifact.verdictJson, artifact.verdictContentHash, artifact.artifactId, "verdict");
46
+ verifyContentHash(artifact.report, artifact.reportContentHash, artifact.artifactId, "report");
47
+ validateVerdict(artifact);
48
+ const artifactRoot = path.resolve(worktreePath, ".forge", "work-items", workItemId, "artifacts");
49
+ if (!isSubPath(worktreePath, artifactRoot)) {
50
+ throw new Error("Prior phase artifact target must stay under the worktree.");
51
+ }
52
+ await ensureNoSymlinkPathSegments(worktreePath, artifactRoot);
53
+ await fs.mkdir(artifactRoot, { recursive: true });
54
+ await ensureNoSymlinkPathSegments(worktreePath, artifactRoot);
55
+ const verdictPath = path.join(artifactRoot, files.verdict);
56
+ const reportPath = path.join(artifactRoot, files.report);
57
+ const targetKey = verdictPath.toLowerCase();
58
+ if (targets.has(targetKey)) {
59
+ throw new Error(`Duplicate prior phase artifact target for work item '${workItemId}' phase '${artifact.phase}'.`);
60
+ }
61
+ targets.add(targetKey);
62
+ await writeAtomic(verdictPath, artifact.verdictJson);
63
+ await writeAtomic(reportPath, artifact.report);
64
+ }
65
+ return artifacts.length;
66
+ }
67
+ function validateVerdict(artifact) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(artifact.verdictJson);
71
+ }
72
+ catch {
73
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' verdict is not valid JSON.`);
74
+ }
75
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
76
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' verdict must be a JSON object.`);
77
+ }
78
+ const recordedPhase = parsed["phase"];
79
+ const normalizedPhase = typeof recordedPhase === "string"
80
+ ? recordedPhase.trim().toLowerCase().replace(/-/g, "_")
81
+ : "";
82
+ if (normalizedPhase !== artifact.phase) {
83
+ throw new Error(`Prior phase artifact '${artifact.artifactId}' phase does not match its verdict.`);
84
+ }
85
+ }
86
+ function verifyContentHash(content, expected, artifactId, label) {
87
+ const normalizedExpected = expected.trim().toLowerCase().replace(/^sha256:/, "");
88
+ if (!/^[0-9a-f]{64}$/.test(normalizedExpected)) {
89
+ throw new Error(`Prior phase artifact '${artifactId}' ${label} hash is invalid.`);
90
+ }
91
+ const actual = createHash("sha256").update(content, "utf8").digest("hex");
92
+ if (actual !== normalizedExpected) {
93
+ throw new Error(`Prior phase artifact '${artifactId}' ${label} content_hash mismatch.`);
94
+ }
95
+ }
96
+ function safeArtifactSegment(value, label) {
97
+ const trimmed = value.trim();
98
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(trimmed)) {
99
+ throw new Error(`Prior phase artifact ${label} is not a safe path segment.`);
100
+ }
101
+ return trimmed;
102
+ }
103
+ async function writeAtomic(targetPath, content) {
104
+ const temporaryPath = `${targetPath}.tmp-${randomUUID()}`;
105
+ try {
106
+ await fs.writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
107
+ await fs.rename(temporaryPath, targetPath);
108
+ }
109
+ finally {
110
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
111
+ }
112
+ }
113
+ async function ensureNoSymlinkPathSegments(root, targetPath) {
114
+ const resolvedRoot = path.resolve(root);
115
+ const resolvedTarget = path.resolve(targetPath);
116
+ const relative = path.relative(resolvedRoot, resolvedTarget);
117
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
118
+ throw new Error("Prior phase artifact path must stay under the worktree.");
119
+ }
120
+ let current = resolvedRoot;
121
+ for (const segment of relative.split(path.sep)) {
122
+ if (!segment)
123
+ continue;
124
+ current = path.join(current, segment);
125
+ const stat = await fs.lstat(current).catch((error) => {
126
+ if (error.code === "ENOENT")
127
+ return null;
128
+ throw error;
129
+ });
130
+ if (!stat)
131
+ return;
132
+ if (stat.isSymbolicLink()) {
133
+ throw new Error("Prior phase artifact path must not traverse symlinked path segments.");
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,4 @@
1
+ import { GitClient } from "../shared/git/git.js";
2
+ import type { WorktreePrepareRequest } from "./worktree.types.js";
3
+ export declare function storyQaReferenceRoot(storyId: string, runId: string): string;
4
+ export declare function materializeStoryQaReferences(worktreePath: string, request: WorktreePrepareRequest, git?: GitClient): Promise<void>;
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export declare function managedRepoRoot(allowedRoot: string): string;
2
+ export declare function resolveManagedWorktreeRoot(allowedRoot: string, repoPath: string): string;
3
+ export declare function resolveManagedWorktreeRootForRepo(repoPath: string, allowedRoot?: string): string;
4
+ export declare function defaultWorktreeCoordinatorStateRoot(): string;
@@ -0,0 +1,30 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { isSubPath } from "../shared/path/path.js";
4
+ export function managedRepoRoot(allowedRoot) {
5
+ return path.join(path.resolve(allowedRoot), ".forge-worktrees", "_repos");
6
+ }
7
+ export function resolveManagedWorktreeRoot(allowedRoot, repoPath) {
8
+ const resolvedAllowedRoot = path.resolve(allowedRoot);
9
+ const resolvedRepoPath = path.resolve(repoPath);
10
+ const baseName = path.basename(resolvedRepoPath);
11
+ return isSubPath(managedRepoRoot(resolvedAllowedRoot), resolvedRepoPath)
12
+ ? path.join(resolvedAllowedRoot, ".forge-worktrees", baseName)
13
+ : path.join(path.dirname(resolvedRepoPath), ".forge-worktrees", baseName);
14
+ }
15
+ export function resolveManagedWorktreeRootForRepo(repoPath, allowedRoot) {
16
+ const resolvedRepoPath = path.resolve(repoPath);
17
+ if (allowedRoot && isSubPath(path.resolve(allowedRoot), resolvedRepoPath)) {
18
+ return resolveManagedWorktreeRoot(allowedRoot, resolvedRepoPath);
19
+ }
20
+ const repoParent = path.dirname(resolvedRepoPath);
21
+ const managedRoot = path.dirname(repoParent);
22
+ if (path.basename(repoParent) === "_repos" && path.basename(managedRoot) === ".forge-worktrees") {
23
+ return path.join(managedRoot, path.basename(resolvedRepoPath));
24
+ }
25
+ return path.join(repoParent, ".forge-worktrees", path.basename(resolvedRepoPath));
26
+ }
27
+ export function defaultWorktreeCoordinatorStateRoot() {
28
+ return process.env.FORGE_SANDBOX_STATE_ROOT
29
+ ?? path.join(os.homedir(), ".forge", "bridge", "sandboxes");
30
+ }
@@ -3,3 +3,6 @@ export interface WorktreeService {
3
3
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
4
4
  release(handle: WorktreeHandle): Promise<void>;
5
5
  }
6
+ export interface WorktreeLifecycleCoordinator {
7
+ withLock<T>(repoPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
8
+ }
@@ -1,17 +1,30 @@
1
1
  import { GitClient } from "../shared/git/git.js";
2
- import type { WorktreeService } from "./worktree.base.js";
2
+ import type { WorktreeLifecycleCoordinator, WorktreeService } from "./worktree.base.js";
3
3
  import type { WorktreeHandle, WorktreePrepareRequest } from "./worktree.types.js";
4
4
  export declare class GitWorktreeService implements WorktreeService {
5
5
  private readonly git;
6
- constructor(git?: GitClient);
6
+ private readonly coordinator;
7
+ constructor(git?: GitClient, coordinator?: WorktreeLifecycleCoordinator);
7
8
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
9
+ private trackedForgePaths;
10
+ private registeredManagedWorktreePath;
11
+ private repoCreationCoordinationPath;
8
12
  release(handle: WorktreeHandle): Promise<void>;
13
+ private restoreOrphanedWorktree;
9
14
  private resolveRepoPath;
15
+ private managedRepoPath;
10
16
  private findExistingRepoPath;
11
17
  private resolveMatchingRepo;
12
- private ensureBaseBranch;
18
+ private resolveBaseStartPoint;
19
+ private resetWorktreeToBase;
13
20
  private gitRefExists;
14
21
  private currentBranch;
15
22
  private ensureGitIdentity;
16
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;
28
+ export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
17
29
  export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
30
+ export {};