@loucompanion/forge-bridge 0.1.3-dev.d46a8e73d03d → 0.1.3-dev.eeefdb0d19c1

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 (118) hide show
  1. package/README.md +34 -0
  2. package/dist/bridge/bridge.base.d.ts +2 -1
  3. package/dist/bridge/bridge.service.d.ts +22 -3
  4. package/dist/bridge/bridge.service.js +416 -40
  5. package/dist/bridge/bridge.types.d.ts +59 -1
  6. package/dist/cleanup/cleanup.service.d.ts +13 -2
  7. package/dist/cleanup/cleanup.service.js +435 -3
  8. package/dist/cleanup/cleanup.types.d.ts +10 -0
  9. package/dist/cleanup/index.d.ts +1 -1
  10. package/dist/cleanup/index.js +1 -1
  11. package/dist/cleanup/internals.d.ts +2 -2
  12. package/dist/cleanup/internals.js +1 -1
  13. package/dist/cli/cli.help.js +37 -3
  14. package/dist/cli/cli.service.d.ts +30 -2
  15. package/dist/cli/cli.service.js +182 -23
  16. package/dist/cli/local-config.d.ts +4 -0
  17. package/dist/cli/local-config.js +41 -1
  18. package/dist/runtime/attempt-node-runtime-launcher.d.ts +17 -0
  19. package/dist/runtime/attempt-node-runtime-launcher.js +101 -0
  20. package/dist/runtime/attempt-process-supervisor-registry.d.ts +20 -0
  21. package/dist/runtime/attempt-process-supervisor-registry.js +64 -0
  22. package/dist/runtime/attempt-process-supervisor.d.ts +86 -0
  23. package/dist/runtime/attempt-process-supervisor.js +343 -0
  24. package/dist/runtime/generated-output-reset.d.ts +10 -0
  25. package/dist/runtime/generated-output-reset.js +103 -0
  26. package/dist/runtime/host-listener-inspector.d.ts +44 -0
  27. package/dist/runtime/host-listener-inspector.js +324 -0
  28. package/dist/runtime/host-port-broker.d.ts +76 -0
  29. package/dist/runtime/host-port-broker.js +333 -0
  30. package/dist/runtime/host-runtime-contract.d.ts +47 -0
  31. package/dist/runtime/host-runtime-contract.js +700 -0
  32. package/dist/runtime/host-runtime-lease.d.ts +133 -0
  33. package/dist/runtime/host-runtime-lease.js +1460 -0
  34. package/dist/runtime/host-runtime-promotion.d.ts +23 -0
  35. package/dist/runtime/host-runtime-promotion.js +159 -0
  36. package/dist/runtime/node-host-runtime-adapter.d.ts +51 -0
  37. package/dist/runtime/node-host-runtime-adapter.js +268 -0
  38. package/dist/runtime/playwright-host-runtime-adapter.d.ts +20 -0
  39. package/dist/runtime/playwright-host-runtime-adapter.js +220 -0
  40. package/dist/runtime/runtime-isolation-lease.d.ts +67 -0
  41. package/dist/runtime/runtime-isolation-lease.js +158 -0
  42. package/dist/sandbox/preflight.d.ts +14 -0
  43. package/dist/sandbox/preflight.js +91 -12
  44. package/dist/sandbox/process.js +125 -40
  45. package/dist/sandbox/repository-lock.js +37 -1
  46. package/dist/sandbox/runtime.d.ts +66 -3
  47. package/dist/sandbox/runtime.js +670 -105
  48. package/dist/sandbox/sandbox.types.d.ts +3 -0
  49. package/dist/sandbox/smoke.d.ts +76 -0
  50. package/dist/sandbox/smoke.js +380 -0
  51. package/dist/sandbox/worker-supervisor.js +1 -1
  52. package/dist/sandbox/worker.js +2 -5
  53. package/dist/sandbox/workspace.service.d.ts +1 -0
  54. package/dist/sandbox/workspace.service.js +28 -5
  55. package/dist/session/managed-attempt-tools.d.ts +12 -0
  56. package/dist/session/managed-attempt-tools.js +350 -0
  57. package/dist/session/phase-completion.artifacts.js +9 -2
  58. package/dist/session/phase-completion.d.ts +4 -2
  59. package/dist/session/phase-completion.helpers.js +27 -3
  60. package/dist/session/phase-completion.js +12 -3
  61. package/dist/session/phase-completion.prompts.d.ts +3 -3
  62. package/dist/session/phase-completion.prompts.js +19 -5
  63. package/dist/session/phase-completion.runtime.d.ts +12 -1
  64. package/dist/session/phase-completion.runtime.js +127 -7
  65. package/dist/session/phase-completion.schemas.js +108 -3
  66. package/dist/session/phase-completion.validation.d.ts +4 -1
  67. package/dist/session/phase-completion.validation.js +106 -2
  68. package/dist/session/provider-cli/ci-repair-readiness.d.ts +8 -0
  69. package/dist/session/provider-cli/ci-repair-readiness.js +90 -0
  70. package/dist/session/provider-cli/provider-cli.artifacts.d.ts +1 -1
  71. package/dist/session/provider-cli/provider-cli.artifacts.js +28 -4
  72. package/dist/session/provider-cli/provider-cli.deterministic.js +47 -1
  73. package/dist/session/provider-cli/provider-cli.process.d.ts +16 -0
  74. package/dist/session/provider-cli/provider-cli.process.js +212 -25
  75. package/dist/session/provider-cli/provider-cli.prompts.d.ts +4 -1
  76. package/dist/session/provider-cli/provider-cli.prompts.js +84 -17
  77. package/dist/session/provider-cli/provider-cli.qa-sidecars.js +1 -0
  78. package/dist/session/provider-cli/provider-cli.runtime.d.ts +1 -1
  79. package/dist/session/provider-cli/provider-cli.runtime.js +3 -2
  80. package/dist/session/provider-cli/provider-cli.service.d.ts +3 -2
  81. package/dist/session/provider-cli/provider-cli.service.js +254 -71
  82. package/dist/session/provider-cli/provider-cli.types.d.ts +5 -0
  83. package/dist/session/provider-cli/provider-output-state.d.ts +2 -0
  84. package/dist/session/provider-cli/provider-output-state.js +30 -2
  85. package/dist/session/session-redaction.d.ts +3 -0
  86. package/dist/session/session-redaction.js +23 -0
  87. package/dist/session/session.base.d.ts +6 -1
  88. package/dist/session/session.service.d.ts +8 -2
  89. package/dist/session/session.service.js +436 -41
  90. package/dist/session/session.types.d.ts +55 -0
  91. package/dist/session/session.types.js +69 -2
  92. package/dist/shared/path/path.js +3 -1
  93. package/dist/shared/process/host-process-recovery.d.ts +12 -0
  94. package/dist/shared/process/host-process-recovery.js +78 -0
  95. package/dist/shared/process/host-process-supervisor.d.ts +57 -0
  96. package/dist/shared/process/host-process-supervisor.js +282 -0
  97. package/dist/shared/process/process.d.ts +1 -0
  98. package/dist/shared/process/process.js +1 -0
  99. package/dist/shared/redaction/redaction.js +6 -1
  100. package/dist/version.d.ts +1 -1
  101. package/dist/version.js +1 -1
  102. package/dist/worktree/artifact-injector.d.ts +3 -2
  103. package/dist/worktree/artifact-injector.js +43 -4
  104. package/dist/worktree/dependency-warmer.d.ts +10 -1
  105. package/dist/worktree/dependency-warmer.js +94 -5
  106. package/dist/worktree/index.d.ts +1 -0
  107. package/dist/worktree/index.js +1 -0
  108. package/dist/worktree/prior-phase-artifact-hydrator.d.ts +2 -0
  109. package/dist/worktree/prior-phase-artifact-hydrator.js +136 -0
  110. package/dist/worktree/worktree-paths.d.ts +4 -0
  111. package/dist/worktree/worktree-paths.js +30 -0
  112. package/dist/worktree/worktree.base.d.ts +3 -0
  113. package/dist/worktree/worktree.service.d.ts +7 -2
  114. package/dist/worktree/worktree.service.js +72 -44
  115. package/dist/worktree/worktree.types.d.ts +3 -1
  116. package/package.json +3 -2
  117. package/runtime/windows-job-supervisor.ps1 +356 -0
  118. package/runtime/windows-listener-owner.ps1 +33 -0
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { spawn } from "node:child_process";
2
3
  import fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  const DEBUG_OPERATIONAL_SKILLS = new Set(["debug-repro", "debug-root-cause", "debug-fix", "debug-regression"]);
@@ -59,9 +60,9 @@ const DRAFT_WORK_ITEM_OPERATIONAL_HOOKS = new Set([
59
60
  const DRAFT_WORK_ORDER_OPERATIONAL_HOOKS = new Set([
60
61
  "draft-work-order-plan"
61
62
  ]);
62
- export async function injectWorktreeArtifacts(worktreePath, artifacts) {
63
+ export async function injectWorktreeArtifacts(worktreePath, artifacts, protectedRelativePaths = new Set()) {
63
64
  for (const artifact of artifacts) {
64
- await writeArtifact(worktreePath, artifact);
65
+ await writeArtifact(worktreePath, artifact, protectedRelativePaths);
65
66
  }
66
67
  }
67
68
  export function filterArtifactsForLane(artifacts, lane, phase) {
@@ -78,7 +79,7 @@ export function filterArtifactsForLane(artifacts, lane, phase) {
78
79
  }
79
80
  return artifacts;
80
81
  }
81
- export async function writeArtifact(worktreePath, artifact) {
82
+ export async function writeArtifact(worktreePath, artifact, protectedRelativePaths = new Set()) {
82
83
  const writtenHash = sha256(artifact.body);
83
84
  if (writtenHash !== artifact.contentHash.toLowerCase()) {
84
85
  throw new Error(`Injected artifact '${artifact.slug}' content_hash mismatch (expected ${artifact.contentHash}, got ${writtenHash}).`);
@@ -93,6 +94,11 @@ export async function writeArtifact(worktreePath, artifact) {
93
94
  ? path.resolve(scopeDir, slug, "SKILL.md")
94
95
  : path.resolve(scopeDir, `${slug}.md`);
95
96
  assertInside(forgeRoot, filePath, "Artifact file");
97
+ const relativePath = path.relative(worktreePath, filePath).replaceAll(path.sep, "/");
98
+ if (protectedRelativePaths.has(relativePath)) {
99
+ await restoreTrackedArtifactIfItMatchesManagedBody(worktreePath, filePath, relativePath, artifact);
100
+ return;
101
+ }
96
102
  await fs.mkdir(path.dirname(filePath), { recursive: true });
97
103
  await fs.writeFile(filePath, artifact.body, "utf8");
98
104
  }
@@ -120,6 +126,39 @@ export function resolveCommandSlug(lane, phase) {
120
126
  return "dev-prompt";
121
127
  }
122
128
  }
129
+ async function restoreTrackedArtifactIfItMatchesManagedBody(worktreePath, filePath, relativePath, artifact) {
130
+ const current = await fs.readFile(filePath, "utf8").catch(() => null);
131
+ if (current === null
132
+ || (current !== artifact.body && current !== artifactExecutionBody(artifact)))
133
+ return;
134
+ const result = await new Promise((resolve) => {
135
+ const child = spawn("git", ["show", `HEAD:${relativePath}`], {
136
+ cwd: worktreePath,
137
+ windowsHide: true,
138
+ stdio: ["ignore", "pipe", "ignore"],
139
+ });
140
+ let stdout = "";
141
+ child.stdout?.setEncoding("utf8");
142
+ child.stdout?.on("data", chunk => { stdout += chunk; });
143
+ child.on("error", () => resolve({ exitCode: 1, stdout: "" }));
144
+ child.on("close", code => resolve({ exitCode: code ?? 1, stdout }));
145
+ });
146
+ if (result.exitCode === 0)
147
+ await fs.writeFile(filePath, result.stdout, "utf8");
148
+ }
149
+ export function artifactExecutionBody(artifact) {
150
+ const content = artifact.body;
151
+ if (!content.startsWith("---"))
152
+ return content;
153
+ const end = content.indexOf("\n---", 3);
154
+ if (end < 3)
155
+ return content;
156
+ let bodyStart = end + 4;
157
+ while (bodyStart < content.length && (content[bodyStart] === "\n" || content[bodyStart] === "\r")) {
158
+ bodyStart += 1;
159
+ }
160
+ return content.slice(bodyStart);
161
+ }
123
162
  function shouldInjectDebugArtifact(artifact, commandSlug) {
124
163
  if (artifact.scope.trim().toLowerCase() !== "operational") {
125
164
  return false;
@@ -192,7 +231,7 @@ function normalizeDraftPhase(phase) {
192
231
  return phase.trim().toLowerCase().replaceAll("-", "_");
193
232
  }
194
233
  export function buildSpecBody(laneCommand, workItem, phase = "implementation") {
195
- const prompt = laneCommand.body.replaceAll("{workItemId}", workItem.id);
234
+ const prompt = artifactExecutionBody(laneCommand).replaceAll("{workItemId}", workItem.id);
196
235
  return `---
197
236
  work_item_id: ${workItem.id}
198
237
  status: approved
@@ -22,7 +22,10 @@ export interface WarmDependenciesResult {
22
22
  readonly fallbacks: readonly InstallFallbackResult[];
23
23
  }
24
24
  export interface CommandExecutor {
25
- run(cwd: string, command: string, args: readonly string[]): Promise<void>;
25
+ run(cwd: string, command: string, args: readonly string[], options?: CommandExecutionOptions): Promise<void>;
26
+ }
27
+ export interface CommandExecutionOptions {
28
+ readonly env?: NodeJS.ProcessEnv;
26
29
  }
27
30
  export declare function detectWarmPaths(repoRoot: string, warmPathOverrides?: readonly string[]): Promise<WarmPathEntry[]>;
28
31
  export declare function warmWorktreeDependencies(request: WarmDependenciesRequest, executor?: CommandExecutor): Promise<WarmDependenciesResult>;
@@ -41,3 +44,9 @@ export declare function resolveNpmLaunch(args: readonly string[], options?: NpmL
41
44
  command: string;
42
45
  args: readonly string[];
43
46
  };
47
+ export declare function resolvePnpmLaunch(args: readonly string[]): {
48
+ command: string;
49
+ args: readonly string[];
50
+ };
51
+ export declare function formatCommandFailureOutput(stdout: string, stderr: string): string;
52
+ export declare function unattendedPackageManagerEnvironment(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -23,6 +23,7 @@ const NATIVE_MODULE_HINTS = [
23
23
  "better-sqlite3",
24
24
  "@swc/core"
25
25
  ];
26
+ const MAX_COMMAND_FAILURE_OUTPUT_LENGTH = 8_000;
26
27
  export async function detectWarmPaths(repoRoot, warmPathOverrides = []) {
27
28
  const entries = new Map();
28
29
  await collectDetectedPaths(repoRoot, repoRoot, entries, 0);
@@ -50,10 +51,39 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
50
51
  const linked = [];
51
52
  const skipped = [];
52
53
  const fallbacks = [];
54
+ const restoredDotNetRoots = new Set();
55
+ const installedNodeRoots = new Set();
53
56
  for (const entry of warmPaths) {
54
57
  const sourcePath = path.join(baseCheckoutPath, entry.relativePath);
55
58
  const targetPath = path.join(worktreePath, entry.relativePath);
56
59
  assertWithinRoot(allowedRoot, targetPath, "Warm target");
60
+ if (entry.ecosystem === "node" && path.basename(entry.relativePath) === "node_modules") {
61
+ await removeLegacyDirectoryLink(targetPath, allowedRoot);
62
+ const installPackageRoot = await resolveNodeInstallPackageRoot(worktreePath, entry.packageRoot);
63
+ const packageRoot = path.resolve(worktreePath, installPackageRoot === "." ? "" : installPackageRoot);
64
+ const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
65
+ if (installedNodeRoots.has(packageKey)) {
66
+ skipped.push(entry.relativePath);
67
+ continue;
68
+ }
69
+ installedNodeRoots.add(packageKey);
70
+ const install = await runInstallFallback({ ...entry, packageRoot: installPackageRoot }, worktreePath, "Managed worktrees require isolated Node dependencies.", executor);
71
+ fallbacks.push(install);
72
+ continue;
73
+ }
74
+ if (entry.ecosystem === "dotnet") {
75
+ await removeLegacyDirectoryLink(targetPath, allowedRoot);
76
+ const packageRoot = path.resolve(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
77
+ const packageKey = process.platform === "win32" ? packageRoot.toLowerCase() : packageRoot;
78
+ if (restoredDotNetRoots.has(packageKey)) {
79
+ skipped.push(entry.relativePath);
80
+ continue;
81
+ }
82
+ restoredDotNetRoots.add(packageKey);
83
+ const install = await runInstallFallback(entry, worktreePath, "Managed worktrees require isolated .NET build outputs.", executor);
84
+ fallbacks.push(install);
85
+ continue;
86
+ }
57
87
  const sourceExists = await directoryExists(sourcePath);
58
88
  const fallbackReason = await resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
59
89
  if (fallbackReason) {
@@ -70,6 +100,16 @@ export async function warmWorktreeDependencies(request, executor = defaultComman
70
100
  }
71
101
  return { linked, skipped, fallbacks };
72
102
  }
103
+ async function removeLegacyDirectoryLink(targetPath, allowedRoot) {
104
+ const resolvedTarget = path.resolve(targetPath);
105
+ const resolvedRoot = path.resolve(allowedRoot);
106
+ assertWithinRoot(resolvedRoot, resolvedTarget, "Legacy warm target");
107
+ const stat = await fs.lstat(resolvedTarget).catch(() => null);
108
+ if (!stat?.isSymbolicLink()) {
109
+ return;
110
+ }
111
+ await fs.rm(resolvedTarget, { force: true });
112
+ }
73
113
  export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
74
114
  const resolvedSource = path.resolve(sourcePath);
75
115
  const resolvedTarget = path.resolve(targetPath);
@@ -81,7 +121,10 @@ export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
81
121
  if (!stat?.isDirectory()) {
82
122
  throw new Error(`Link source must be an existing directory: ${resolvedSource}`);
83
123
  }
84
- await fs.rm(resolvedTarget, { recursive: true, force: true });
124
+ const existingTarget = await fs.lstat(resolvedTarget).catch(() => null);
125
+ await fs.rm(resolvedTarget, existingTarget?.isSymbolicLink()
126
+ ? { force: true }
127
+ : { recursive: true, force: true });
85
128
  await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
86
129
  if (process.platform === "win32") {
87
130
  await fs.symlink(resolvedSource, resolvedTarget, "junction");
@@ -185,10 +228,17 @@ export async function lockfilesDiffer(baseCheckoutPath, worktreePath, packageRoo
185
228
  async function runInstallFallback(entry, worktreePath, reason, executor) {
186
229
  const cwd = path.join(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
187
230
  if (entry.ecosystem === "node") {
231
+ const pnpmLockPath = path.join(cwd, "pnpm-lock.yaml");
232
+ if (await pathExists(pnpmLockPath)) {
233
+ const args = ["install", "--frozen-lockfile"];
234
+ const launch = resolvePnpmLaunch(args);
235
+ await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
236
+ return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `pnpm ${args.join(" ")}` };
237
+ }
188
238
  const lockPath = path.join(cwd, "package-lock.json");
189
239
  const args = (await pathExists(lockPath)) ? ["ci"] : ["install"];
190
240
  const launch = resolveNpmLaunch(args);
191
- await executor.run(cwd, launch.command, launch.args);
241
+ await executor.run(cwd, launch.command, launch.args, { env: unattendedPackageManagerEnvironment() });
192
242
  return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `npm ${args.join(" ")}` };
193
243
  }
194
244
  if (entry.ecosystem === "python") {
@@ -219,6 +269,23 @@ async function runInstallFallback(entry, worktreePath, reason, executor) {
219
269
  command: "dotnet restore"
220
270
  };
221
271
  }
272
+ async function resolveNodeInstallPackageRoot(worktreePath, packageRoot) {
273
+ const resolvedWorktree = path.resolve(worktreePath);
274
+ let current = path.resolve(resolvedWorktree, packageRoot === "." ? "" : packageRoot);
275
+ assertWithinRoot(resolvedWorktree, current, "Node package root");
276
+ while (isSubPath(resolvedWorktree, current)) {
277
+ if (await pathExists(path.join(current, "pnpm-lock.yaml"))
278
+ || await pathExists(path.join(current, "pnpm-workspace.yaml"))
279
+ || await pathExists(path.join(current, "package-lock.json"))) {
280
+ const relative = normalizeRelativePath(path.relative(resolvedWorktree, current));
281
+ return relative || ".";
282
+ }
283
+ if (current === resolvedWorktree)
284
+ break;
285
+ current = path.dirname(current);
286
+ }
287
+ return packageRoot;
288
+ }
222
289
  export async function requiresInstallFallback(entry, baseCheckoutPath, worktreePath, sourceExists) {
223
290
  return resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
224
291
  }
@@ -310,6 +377,26 @@ export function resolveNpmLaunch(args, options = {}) {
310
377
  }
311
378
  return { command: "npm", args };
312
379
  }
380
+ export function resolvePnpmLaunch(args) {
381
+ if (process.platform === "win32") {
382
+ return buildWindowsCmdLaunch("pnpm.cmd", args, process.env.ComSpec);
383
+ }
384
+ return { command: "pnpm", args };
385
+ }
386
+ export function formatCommandFailureOutput(stdout, stderr) {
387
+ const sections = [
388
+ stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null,
389
+ stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null,
390
+ ].filter((section) => section !== null);
391
+ const output = sections.join("\n");
392
+ if (output.length <= MAX_COMMAND_FAILURE_OUTPUT_LENGTH) {
393
+ return output;
394
+ }
395
+ return `[last ${MAX_COMMAND_FAILURE_OUTPUT_LENGTH} characters]\n${output.slice(-MAX_COMMAND_FAILURE_OUTPUT_LENGTH)}`;
396
+ }
397
+ export function unattendedPackageManagerEnvironment(env = process.env) {
398
+ return { ...env, CI: "true" };
399
+ }
313
400
  function tryResolveNpmCandidate(candidate) {
314
401
  try {
315
402
  if (!existsSync(candidate)) {
@@ -331,10 +418,12 @@ function defaultNpmCliCandidates(execPath, homeDir) {
331
418
  return candidates;
332
419
  }
333
420
  const defaultCommandExecutor = {
334
- async run(cwd, command, args) {
335
- const result = await defaultProcessRunner.run({ command, cwd, args });
421
+ async run(cwd, command, args, options) {
422
+ const result = await defaultProcessRunner.run({ command, cwd, args, env: options?.env });
336
423
  if (result.exitCode !== 0) {
337
- throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}): ${result.stderr.trim()}`);
424
+ const output = formatCommandFailureOutput(result.stdout, result.stderr);
425
+ const details = output.length > 0 ? `\n${output}` : " no output was captured";
426
+ throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}) in ${cwd}:${details}`);
338
427
  }
339
428
  }
340
429
  };
@@ -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
4
  export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult } 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
+ 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,12 +1,16 @@
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 repoCreationCoordinationPath;
8
11
  release(handle: WorktreeHandle): Promise<void>;
9
12
  private resolveRepoPath;
13
+ private managedRepoPath;
10
14
  private findExistingRepoPath;
11
15
  private resolveMatchingRepo;
12
16
  private resolveBaseStartPoint;
@@ -15,4 +19,5 @@ export declare class GitWorktreeService implements WorktreeService {
15
19
  private currentBranch;
16
20
  private ensureGitIdentity;
17
21
  }
22
+ export declare function clearRunArtifactRoots(worktreePath: string, request: WorktreePrepareRequest): Promise<void>;
18
23
  export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;