@loucompanion/forge-bridge 0.1.1-dev.242bb53ef13f → 0.1.1-dev.8dcbc6245e51

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.
package/README.md CHANGED
@@ -31,6 +31,7 @@ The bridge can also read connection settings from environment variables:
31
31
  - `FORGE_DEVICE_NAME`
32
32
  - `FORGE_REPO_ROOT`
33
33
  - `FORGE_CLI_PROVIDERS`
34
+ - `FORGE_CODEX_MODELS` / `FORGE_CURSOR_MODELS` (optional comma-separated model ids advertised in runner capabilities)
34
35
 
35
36
  ## Repo-local development
36
37
 
@@ -168,7 +168,7 @@ export class ForgeBridgeService {
168
168
  }
169
169
  capabilities() {
170
170
  return {
171
- schemaVersion: 1,
171
+ schemaVersion: 2,
172
172
  activeSessionCount: this.activeSessions.size,
173
173
  cliProviders: this.config.cliProviders,
174
174
  providerStatuses: this.config.providerStatuses,
@@ -264,7 +264,7 @@ export function defaultBridgeServiceConfig(overrides = {}) {
264
264
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? defaults.defaultRepoRoot,
265
265
  cleanupWorktrees: process.env.FORGE_CLEANUP_WORKTREES !== "false" && defaults.cleanupWorktrees,
266
266
  cliProviders,
267
- providerStatuses: Object.fromEntries(cliProviders.map((provider) => [provider, { signedIn: true, version: "" }])),
267
+ providerStatuses: providerStatusesFor(cliProviders),
268
268
  projectLabels: splitCsv(process.env.FORGE_PROJECT_LABELS ?? ""),
269
269
  repoMappings: [],
270
270
  os: process.platform,
@@ -294,6 +294,13 @@ function normalizeServerUrl(serverUrl) {
294
294
  function positiveFiniteNumber(value, fallback) {
295
295
  return Number.isFinite(value) && value > 0 ? value : fallback;
296
296
  }
297
+ function providerStatusesFor(providers) {
298
+ return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
299
+ }
300
+ function configuredModelsFor(provider) {
301
+ const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
302
+ return splitCsv(process.env[envName] ?? "");
303
+ }
297
304
  function splitCsv(value) {
298
305
  return value
299
306
  .split(",")
@@ -28,6 +28,7 @@ export interface BridgeRepoMapping {
28
28
  export interface BridgeProviderStatus {
29
29
  readonly signedIn?: boolean | null;
30
30
  readonly version?: string | null;
31
+ readonly models?: readonly string[] | null;
31
32
  }
32
33
  export interface BridgeCapabilities {
33
34
  readonly schemaVersion?: number | null;
@@ -1,6 +1,6 @@
1
1
  import type { BridgeProviderStatus, BridgeRepoMapping, BridgeServiceConfig } from "../bridge/bridge.types.js";
2
2
  export interface BridgeLocalConfig {
3
- readonly schemaVersion: 1;
3
+ readonly schemaVersion: 1 | 2;
4
4
  readonly serverUrl?: string;
5
5
  readonly runnerId?: string;
6
6
  readonly deviceToken?: string;
@@ -46,7 +46,7 @@ export function applyLocalConfigDefaults(config, local) {
46
46
  defaultRepoRoot: process.env.FORGE_REPO_ROOT ?? local.defaultRepoRoot ?? config.defaultRepoRoot,
47
47
  cleanupWorktrees: local.cleanupWorktrees ?? config.cleanupWorktrees,
48
48
  cliProviders,
49
- providerStatuses: local.providerStatuses ?? providerStatusesFor(cliProviders),
49
+ providerStatuses: normalizeProviderStatuses(local.providerStatuses) ?? providerStatusesFor(cliProviders),
50
50
  projectLabels: local.projectLabels ?? config.projectLabels,
51
51
  repoMappings: local.repoMappings ?? config.repoMappings,
52
52
  sandboxProfiles: local.sandboxProfiles ?? config.sandboxProfiles,
@@ -83,7 +83,7 @@ export async function removeRepoMapping(label) {
83
83
  return { config: next, removed: after.length !== before.length };
84
84
  }
85
85
  export function providerStatusesFor(providers) {
86
- return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "" }]));
86
+ return Object.fromEntries(providers.map((provider) => [provider, { signedIn: true, version: "", models: configuredModelsFor(provider) }]));
87
87
  }
88
88
  function parseLocalConfig(raw) {
89
89
  if (!raw.trim()) {
@@ -93,7 +93,7 @@ function parseLocalConfig(raw) {
93
93
  }
94
94
  function normalizeLocalConfig(config) {
95
95
  return {
96
- schemaVersion: 1,
96
+ schemaVersion: 2,
97
97
  serverUrl: cleanString(config.serverUrl),
98
98
  runnerId: cleanString(config.runnerId),
99
99
  deviceToken: cleanString(config.deviceToken),
@@ -101,7 +101,7 @@ function normalizeLocalConfig(config) {
101
101
  defaultRepoRoot: cleanString(config.defaultRepoRoot),
102
102
  cleanupWorktrees: config.cleanupWorktrees,
103
103
  cliProviders: cleanList(config.cliProviders),
104
- providerStatuses: config.providerStatuses,
104
+ providerStatuses: normalizeProviderStatuses(config.providerStatuses),
105
105
  projectLabels: cleanList(config.projectLabels),
106
106
  repoMappings: normalizeRepoMappings(config.repoMappings),
107
107
  sandboxProfiles: cleanList(config.sandboxProfiles),
@@ -110,10 +110,39 @@ function normalizeLocalConfig(config) {
110
110
  }
111
111
  function emptyConfig() {
112
112
  return {
113
- schemaVersion: 1,
113
+ schemaVersion: 2,
114
114
  repoMappings: []
115
115
  };
116
116
  }
117
+ function normalizeProviderStatuses(values) {
118
+ if (!values)
119
+ return undefined;
120
+ const entries = [];
121
+ for (const [provider, status] of Object.entries(values)) {
122
+ const key = provider.trim();
123
+ if (!key)
124
+ continue;
125
+ entries.push([
126
+ key,
127
+ {
128
+ signedIn: status?.signedIn ?? true,
129
+ version: cleanString(status?.version),
130
+ models: cleanList(status?.models) ?? configuredModelsFor(key)
131
+ }
132
+ ]);
133
+ }
134
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
135
+ }
136
+ function configuredModelsFor(provider) {
137
+ const envName = `FORGE_${provider.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_MODELS`;
138
+ return splitCsv(process.env[envName] ?? "");
139
+ }
140
+ function splitCsv(value) {
141
+ return value
142
+ .split(",")
143
+ .map((entry) => entry.trim())
144
+ .filter((entry) => entry.length > 0);
145
+ }
117
146
  function normalizeRepoMappings(values) {
118
147
  return (values ?? [])
119
148
  .filter((mapping) => !!mapping.label?.trim() && !!mapping.localPath?.trim())
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { GitClient } from "../../shared/git/git.js";
5
+ import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
5
6
  export class DeterministicProviderCliService {
6
7
  git;
7
8
  constructor(git = new GitClient()) {
@@ -11,13 +12,23 @@ export class DeterministicProviderCliService {
11
12
  const { dispatch, worktree } = context;
12
13
  const providerCommand = buildProviderCliCommand(dispatch);
13
14
  const providerCommandText = formatProviderCliCommand(providerCommand);
14
- const metadataRoot = path.join(worktree.repoPath, ".forge", "work-items", dispatch.workItem.id);
15
+ const metadataRoot = path.join(worktree.worktreePath, ".forge", "work-items", dispatch.workItem.id);
15
16
  const artifactRoot = path.join(metadataRoot, "artifacts");
16
17
  await fs.mkdir(artifactRoot, { recursive: true });
17
- const specBody = `---\nwork_item_id: ${dispatch.workItem.id}\nstatus: approved\nlane: ${dispatch.execution.lane}\nprovider: ${yamlQuotedScalar(dispatch.execution.provider)}\nmodel: ${yamlQuotedScalar(dispatch.execution.model)}\nprovider_command: ${yamlQuotedScalar(providerCommandText)}\n---\n\n# Forge bridge implementation spec\n\nImplement **${dispatch.workItem.title}**.\n\nAcceptance:\n- prepare a task-scoped git worktree\n- emit newline JSON runner events\n- produce Forge phase artifacts\n`;
18
+ const laneCommand = selectLaneCommand(dispatch.artifacts, dispatch.execution.lane, dispatch.cycleRun.phase);
19
+ if (!laneCommand) {
20
+ throw new Error(`Lane command for lane '${dispatch.execution.lane}' phase '${dispatch.cycleRun.phase}' was not resolved.`);
21
+ }
22
+ const specBody = `${buildSpecBody(laneCommand, dispatch.workItem)}
23
+ ## Execution
24
+
25
+ - Provider: ${singleLine(dispatch.execution.provider)}
26
+ - Model: ${singleLine(dispatch.execution.model)}
27
+ - Command: ${providerCommandText}
28
+ `;
18
29
  const specPath = path.join(metadataRoot, "spec.md");
19
30
  await fs.writeFile(specPath, specBody, "utf8");
20
- await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-bridge" }) + "\n", "utf8");
31
+ await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-lane-command" }) + "\n", "utf8");
21
32
  const changesetPath = path.join(worktree.worktreePath, "FORGE_PHASE5_CHANGESET.md");
22
33
  const changesetBody = `# Forge Phase 5 bridge changeset\n\nWork item: ${dispatch.workItem.id}\nTitle: ${dispatch.workItem.title}\nBranch: ${worktree.branchName}\nProvider: ${singleLine(dispatch.execution.provider)}\nModel: ${singleLine(dispatch.execution.model)}\nCommand: ${providerCommandText}\n\nThis deterministic file is written by forge-bridge run-once through the shared session/worktree service path.\n`;
23
34
  await fs.writeFile(changesetPath, changesetBody, "utf8");
@@ -51,14 +62,14 @@ export class DeterministicProviderCliService {
51
62
  return {
52
63
  summary: `forge-bridge completed ${worktree.branchName}.`,
53
64
  spec: {
54
- repoPath: worktree.repoPath,
65
+ repoPath: worktree.worktreePath,
55
66
  path: specPath,
56
67
  body: specBody,
57
- producedBy: "forge-bridge",
68
+ producedBy: "forge-lane-command",
58
69
  status: "approved"
59
70
  },
60
71
  artifact: {
61
- repoPath: worktree.repoPath,
72
+ repoPath: worktree.worktreePath,
62
73
  path: artifactPath,
63
74
  verdictJson,
64
75
  report,
@@ -30,7 +30,9 @@ export class BridgeSessionService {
30
30
  workItem: dispatch.workItem,
31
31
  baseBranch: dispatch.cycleRun.baseBranch || dispatch.cycleRun.requestedBase,
32
32
  allowedRepoRoot: dispatch.serverLocal.repoRoot,
33
- cleanupWorktrees: dispatch.serverLocal.cleanupWorktrees
33
+ cleanupWorktrees: dispatch.serverLocal.cleanupWorktrees,
34
+ artifacts: dispatch.artifacts,
35
+ warmPathOverrides: dispatch.warmPathOverrides
34
36
  });
35
37
  throwIfAborted(signal);
36
38
  await emit({
@@ -39,6 +41,9 @@ export class BridgeSessionService {
39
41
  content: `Prepared worktree ${worktree.branchName}.`,
40
42
  eventType: "worktree_prepared"
41
43
  });
44
+ if (worktree.dependencyWarm) {
45
+ await emitDependencyWarmEvents(worktree.dependencyWarm, emit);
46
+ }
42
47
  try {
43
48
  throwIfAborted(signal);
44
49
  const isQa = dispatch.execution.role.toLowerCase() === "qa" || dispatch.execution.lane.toLowerCase() === "qa";
@@ -90,3 +95,29 @@ function validateApiKeySecretGrant(dispatch) {
90
95
  function exactSecrets(dispatch) {
91
96
  return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
92
97
  }
98
+ async function emitDependencyWarmEvents(warm, emit) {
99
+ if (warm.linked.length > 0) {
100
+ await emit({
101
+ role: "runner",
102
+ kind: "checkpoint",
103
+ content: `Warmed dependencies via link: ${warm.linked.join(", ")}.`,
104
+ eventType: "dependencies_warmed"
105
+ });
106
+ }
107
+ for (const fallback of warm.fallbacks) {
108
+ await emit({
109
+ role: "runner",
110
+ kind: "checkpoint",
111
+ content: `Dependency install fallback (${fallback.ecosystem}): ${fallback.reason} Ran \`${fallback.command}\` in ${fallback.packageRoot}.`,
112
+ eventType: "dependencies_install_fallback"
113
+ });
114
+ }
115
+ if (warm.skipped.length > 0) {
116
+ await emit({
117
+ role: "runner",
118
+ kind: "checkpoint",
119
+ content: `Skipped warming missing dependency paths: ${warm.skipped.join(", ")}.`,
120
+ eventType: "dependencies_warm_skipped"
121
+ });
122
+ }
123
+ }
@@ -9,6 +9,17 @@ export interface RunOnceDispatchPacket {
9
9
  readonly execution: DispatchExecution;
10
10
  readonly serverLocal: DispatchServerLocalConfig;
11
11
  readonly apiKeySecretGrant?: ApiKeySecretGrant | null;
12
+ readonly artifacts: readonly DispatchResolvedArtifact[];
13
+ readonly warmPathOverrides?: readonly string[];
14
+ }
15
+ export interface DispatchResolvedArtifact {
16
+ readonly kind: string;
17
+ readonly slug: string;
18
+ readonly scope: string;
19
+ readonly body: string;
20
+ readonly contentHash: string;
21
+ readonly lane?: string | null;
22
+ readonly event?: string | null;
12
23
  }
13
24
  export interface DispatchWorkItem {
14
25
  readonly id: string;
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  export function parseRunOnceDispatchPacket(value) {
2
3
  const packet = asObject(value, "dispatch packet");
3
4
  const schemaVersion = requiredNumber(packet, "schemaVersion");
@@ -14,7 +15,9 @@ export function parseRunOnceDispatchPacket(value) {
14
15
  lease: parseLease(packet.lease),
15
16
  execution: parseExecution(packet.execution),
16
17
  serverLocal: parseServerLocal(packet.serverLocal),
17
- apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant)
18
+ apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant),
19
+ artifacts: parseArtifacts(packet.artifacts),
20
+ warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides)
18
21
  };
19
22
  }
20
23
  export function failureResult(summary, events = []) {
@@ -112,6 +115,49 @@ function parseServerLocal(value) {
112
115
  cleanupWorktrees: requiredBoolean(serverLocal, "cleanupWorktrees")
113
116
  };
114
117
  }
118
+ function parseWarmPathOverrides(value) {
119
+ if (value === undefined || value === null) {
120
+ return [];
121
+ }
122
+ if (!Array.isArray(value)) {
123
+ throw new Error("warmPathOverrides must be an array when provided.");
124
+ }
125
+ return value.map((entry, index) => parseWarmPathOverride(entry, index));
126
+ }
127
+ function parseWarmPathOverride(entry, index) {
128
+ if (typeof entry !== "string" || entry.trim().length === 0) {
129
+ throw new Error(`warmPathOverrides[${index}] must be a non-empty string.`);
130
+ }
131
+ const trimmed = entry.trim();
132
+ if (path.isAbsolute(trimmed) || path.posix.isAbsolute(trimmed) || path.win32.isAbsolute(trimmed)) {
133
+ throw new Error(`warmPathOverrides[${index}] must be a relative path.`);
134
+ }
135
+ const segments = trimmed.split(/[\\/]+/).filter(Boolean);
136
+ if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) {
137
+ throw new Error(`warmPathOverrides[${index}] must not contain path traversal.`);
138
+ }
139
+ return segments.join("/");
140
+ }
141
+ function parseArtifacts(value) {
142
+ if (value === undefined || value === null) {
143
+ return [];
144
+ }
145
+ if (!Array.isArray(value)) {
146
+ throw new Error("artifacts must be an array when provided.");
147
+ }
148
+ return value.map((entry, index) => {
149
+ const artifact = asObject(entry, `artifacts[${index}]`);
150
+ return {
151
+ kind: requiredString(artifact, "kind"),
152
+ slug: requiredString(artifact, "slug"),
153
+ scope: requiredString(artifact, "scope"),
154
+ body: optionalString(artifact, "body") ?? "",
155
+ contentHash: requiredString(artifact, "contentHash"),
156
+ lane: optionalString(artifact, "lane"),
157
+ event: optionalString(artifact, "event")
158
+ };
159
+ });
160
+ }
115
161
  function asObject(value, label) {
116
162
  if (!value || typeof value !== "object" || Array.isArray(value)) {
117
163
  throw new Error(`${label} must be an object.`);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const forgeBridgeVersion = "0.1.0";
1
+ export declare const forgeBridgeVersion = "0.1.1-dev.8dcbc6245e51";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const forgeBridgeVersion = "0.1.0";
1
+ export const forgeBridgeVersion = "0.1.1-dev.8dcbc6245e51";
@@ -0,0 +1,11 @@
1
+ import type { DispatchResolvedArtifact } from "../session/session.types.js";
2
+ export declare function injectWorktreeArtifacts(worktreePath: string, artifacts: readonly DispatchResolvedArtifact[]): Promise<void>;
3
+ export declare function writeArtifact(worktreePath: string, artifact: DispatchResolvedArtifact): Promise<void>;
4
+ export declare function selectLaneCommand(artifacts: readonly DispatchResolvedArtifact[], lane: string, phase: string): DispatchResolvedArtifact | undefined;
5
+ export declare function resolveCommandSlug(lane: string, phase: string): string;
6
+ export declare function buildSpecBody(laneCommand: DispatchResolvedArtifact, workItem: {
7
+ readonly id: string;
8
+ readonly title: string;
9
+ readonly description: string;
10
+ readonly acceptanceCriteria: string;
11
+ }): string;
@@ -0,0 +1,104 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ export async function injectWorktreeArtifacts(worktreePath, artifacts) {
5
+ for (const artifact of artifacts) {
6
+ await writeArtifact(worktreePath, artifact);
7
+ }
8
+ }
9
+ export async function writeArtifact(worktreePath, artifact) {
10
+ const writtenHash = sha256(artifact.body);
11
+ if (writtenHash !== artifact.contentHash.toLowerCase()) {
12
+ throw new Error(`Injected artifact '${artifact.slug}' content_hash mismatch (expected ${artifact.contentHash}, got ${writtenHash}).`);
13
+ }
14
+ const kindFolder = kindToFolder(artifact.kind);
15
+ const scope = safeArtifactSegment("scope", artifact.scope);
16
+ const slug = safeArtifactSegment("slug", artifact.slug);
17
+ const forgeRoot = path.resolve(worktreePath, ".forge");
18
+ const targetDir = path.resolve(forgeRoot, kindFolder, scope);
19
+ assertInside(forgeRoot, targetDir, "Artifact directory");
20
+ await fs.mkdir(targetDir, { recursive: true });
21
+ const filePath = path.resolve(targetDir, `${slug}.md`);
22
+ assertInside(forgeRoot, filePath, "Artifact file");
23
+ await fs.writeFile(filePath, artifact.body, "utf8");
24
+ }
25
+ export function selectLaneCommand(artifacts, lane, phase) {
26
+ const slug = resolveCommandSlug(lane, phase).toLowerCase();
27
+ return artifacts.find((artifact) => normalizedArtifactKind(artifact.kind) === "command" && artifact.slug.trim().toLowerCase() === slug);
28
+ }
29
+ export function resolveCommandSlug(lane, phase) {
30
+ const normalizedPhase = phase.trim().toLowerCase();
31
+ if (normalizedPhase === "self_review") {
32
+ return "self-review-prompt";
33
+ }
34
+ if (normalizedPhase === "rework") {
35
+ return "rework-prompt";
36
+ }
37
+ switch (lane.trim().toLowerCase()) {
38
+ case "architect":
39
+ return "architect-prompt";
40
+ case "qa":
41
+ return "qa-prompt";
42
+ default:
43
+ return "dev-prompt";
44
+ }
45
+ }
46
+ export function buildSpecBody(laneCommand, workItem) {
47
+ const prompt = laneCommand.body.replaceAll("{workItemId}", workItem.id);
48
+ return `---
49
+ work_item_id: ${workItem.id}
50
+ status: approved
51
+ lane: ${laneCommand.lane ?? "develop"}
52
+ command_slug: ${laneCommand.slug}
53
+ ---
54
+
55
+ ${prompt}
56
+
57
+ ---
58
+
59
+ # Work item: ${workItem.title}
60
+
61
+ ${workItem.description}
62
+
63
+ ## Acceptance criteria
64
+
65
+ ${workItem.acceptanceCriteria}
66
+ `;
67
+ }
68
+ function kindToFolder(kind) {
69
+ switch (normalizedArtifactKind(kind)) {
70
+ case "command":
71
+ return "commands";
72
+ case "hook":
73
+ return "hooks";
74
+ case "skill":
75
+ return "skills";
76
+ default:
77
+ throw new Error(`Unsupported artifact kind '${kind}'.`);
78
+ }
79
+ }
80
+ function normalizedArtifactKind(kind) {
81
+ return kind.trim().toLowerCase();
82
+ }
83
+ function safeArtifactSegment(label, value) {
84
+ const trimmed = value.trim();
85
+ if (!trimmed
86
+ || trimmed === "."
87
+ || trimmed === ".."
88
+ || /[\\/]/.test(trimmed)
89
+ || path.isAbsolute(trimmed)
90
+ || !/^[A-Za-z0-9._-]+$/.test(trimmed)) {
91
+ throw new Error(`Artifact ${label} must be a safe path segment.`);
92
+ }
93
+ return trimmed;
94
+ }
95
+ function assertInside(root, candidate, label) {
96
+ const relative = path.relative(root, candidate);
97
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
98
+ return;
99
+ }
100
+ throw new Error(`${label} must stay under ${root}.`);
101
+ }
102
+ function sha256(content) {
103
+ return crypto.createHash("sha256").update(content).digest("hex");
104
+ }
@@ -0,0 +1,32 @@
1
+ export type DependencyEcosystem = "node" | "dotnet" | "python";
2
+ export interface WarmPathEntry {
3
+ readonly relativePath: string;
4
+ readonly ecosystem: DependencyEcosystem;
5
+ readonly packageRoot: string;
6
+ }
7
+ export interface WarmDependenciesRequest {
8
+ readonly baseCheckoutPath: string;
9
+ readonly worktreePath: string;
10
+ readonly allowedRoot: string;
11
+ readonly warmPathOverrides?: readonly string[];
12
+ }
13
+ export interface InstallFallbackResult {
14
+ readonly ecosystem: DependencyEcosystem;
15
+ readonly packageRoot: string;
16
+ readonly reason: string;
17
+ readonly command: string;
18
+ }
19
+ export interface WarmDependenciesResult {
20
+ readonly linked: readonly string[];
21
+ readonly skipped: readonly string[];
22
+ readonly fallbacks: readonly InstallFallbackResult[];
23
+ }
24
+ export interface CommandExecutor {
25
+ run(cwd: string, command: string, args: readonly string[]): Promise<void>;
26
+ }
27
+ export declare function detectWarmPaths(repoRoot: string, warmPathOverrides?: readonly string[]): Promise<WarmPathEntry[]>;
28
+ export declare function warmWorktreeDependencies(request: WarmDependenciesRequest, executor?: CommandExecutor): Promise<WarmDependenciesResult>;
29
+ export declare function createDirectoryLink(sourcePath: string, targetPath: string, allowedRoot: string): Promise<void>;
30
+ export declare function lockfilesDiffer(baseCheckoutPath: string, worktreePath: string, packageRootRelative: string): Promise<boolean>;
31
+ export declare function requiresInstallFallback(entry: WarmPathEntry, baseCheckoutPath: string, worktreePath: string, sourceExists: boolean): Promise<string | null>;
32
+ export declare function declaresNativeModules(packageRootPath: string): Promise<boolean>;
@@ -0,0 +1,297 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { NodeProcessRunner } from "../shared/process/process.js";
4
+ import { isSubPath } from "../shared/path/path.js";
5
+ const SKIP_DIR_NAMES = new Set([
6
+ ".git",
7
+ "node_modules",
8
+ ".venv",
9
+ "bin",
10
+ "obj",
11
+ ".forge-worktrees",
12
+ ".forge"
13
+ ]);
14
+ const NATIVE_MODULE_HINTS = [
15
+ "node-gyp",
16
+ "bcrypt",
17
+ "sharp",
18
+ "esbuild",
19
+ "sqlite3",
20
+ "better-sqlite3",
21
+ "@swc/core"
22
+ ];
23
+ export async function detectWarmPaths(repoRoot, warmPathOverrides = []) {
24
+ const entries = new Map();
25
+ await collectDetectedPaths(repoRoot, repoRoot, entries, 0);
26
+ for (const override of warmPathOverrides) {
27
+ const normalized = normalizeRelativePath(override);
28
+ if (!normalized) {
29
+ continue;
30
+ }
31
+ const packageRoot = await resolvePackageRoot(repoRoot, normalized);
32
+ entries.set(normalized, {
33
+ relativePath: normalized,
34
+ ecosystem: inferEcosystemFromPath(normalized),
35
+ packageRoot
36
+ });
37
+ }
38
+ return [...entries.values()];
39
+ }
40
+ export async function warmWorktreeDependencies(request, executor = defaultCommandExecutor) {
41
+ const baseCheckoutPath = path.resolve(request.baseCheckoutPath);
42
+ const worktreePath = path.resolve(request.worktreePath);
43
+ const allowedRoot = path.resolve(request.allowedRoot);
44
+ assertWithinRoot(allowedRoot, baseCheckoutPath, "Base checkout");
45
+ assertWithinRoot(allowedRoot, worktreePath, "Worktree");
46
+ const warmPaths = await detectWarmPaths(baseCheckoutPath, request.warmPathOverrides ?? []);
47
+ const linked = [];
48
+ const skipped = [];
49
+ const fallbacks = [];
50
+ for (const entry of warmPaths) {
51
+ const sourcePath = path.join(baseCheckoutPath, entry.relativePath);
52
+ const targetPath = path.join(worktreePath, entry.relativePath);
53
+ assertWithinRoot(allowedRoot, targetPath, "Warm target");
54
+ const sourceExists = await directoryExists(sourcePath);
55
+ const fallbackReason = await resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
56
+ if (fallbackReason) {
57
+ const install = await runInstallFallback(entry, worktreePath, fallbackReason, executor);
58
+ fallbacks.push(install);
59
+ continue;
60
+ }
61
+ if (!sourceExists) {
62
+ skipped.push(entry.relativePath);
63
+ continue;
64
+ }
65
+ await createDirectoryLink(sourcePath, targetPath, allowedRoot);
66
+ linked.push(entry.relativePath);
67
+ }
68
+ return { linked, skipped, fallbacks };
69
+ }
70
+ export async function createDirectoryLink(sourcePath, targetPath, allowedRoot) {
71
+ const resolvedSource = path.resolve(sourcePath);
72
+ const resolvedTarget = path.resolve(targetPath);
73
+ const resolvedRoot = path.resolve(allowedRoot);
74
+ assertWithinRoot(resolvedRoot, resolvedSource, "Link source");
75
+ assertWithinRoot(resolvedRoot, resolvedTarget, "Link target");
76
+ assertWithinRoot(resolvedRoot, path.dirname(resolvedTarget), "Link target parent");
77
+ const stat = await fs.stat(resolvedSource).catch(() => null);
78
+ if (!stat?.isDirectory()) {
79
+ throw new Error(`Link source must be an existing directory: ${resolvedSource}`);
80
+ }
81
+ await fs.rm(resolvedTarget, { recursive: true, force: true });
82
+ await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
83
+ if (process.platform === "win32") {
84
+ await fs.symlink(resolvedSource, resolvedTarget, "junction");
85
+ return;
86
+ }
87
+ await fs.symlink(resolvedSource, resolvedTarget, "dir");
88
+ }
89
+ async function collectDetectedPaths(repoRoot, currentDir, entries, depth) {
90
+ if (depth > 6) {
91
+ return;
92
+ }
93
+ const relativeDir = path.relative(repoRoot, currentDir);
94
+ const normalizedDir = relativeDir.length === 0 ? "" : normalizeRelativePath(relativeDir);
95
+ if (normalizedDir) {
96
+ const packageJsonPath = path.join(currentDir, "package.json");
97
+ if (await pathExists(packageJsonPath)) {
98
+ addEntry(entries, repoRoot, normalizedDir, "node_modules", "node");
99
+ }
100
+ if (await hasPythonMarker(currentDir)) {
101
+ addEntry(entries, repoRoot, normalizedDir, ".venv", "python");
102
+ }
103
+ if (await hasDotNetMarker(currentDir)) {
104
+ const binRelative = joinRelative(normalizedDir, "bin");
105
+ const objRelative = joinRelative(normalizedDir, "obj");
106
+ if (await directoryExists(path.join(repoRoot, binRelative))) {
107
+ addEntry(entries, repoRoot, normalizedDir, "bin", "dotnet", binRelative);
108
+ }
109
+ if (await directoryExists(path.join(repoRoot, objRelative))) {
110
+ addEntry(entries, repoRoot, normalizedDir, "obj", "dotnet", objRelative);
111
+ }
112
+ }
113
+ }
114
+ else {
115
+ if (await pathExists(path.join(currentDir, "package.json"))) {
116
+ addEntry(entries, repoRoot, "", "node_modules", "node");
117
+ }
118
+ if (await hasPythonMarker(currentDir)) {
119
+ addEntry(entries, repoRoot, "", ".venv", "python");
120
+ }
121
+ if (await hasDotNetMarker(currentDir)) {
122
+ if (await directoryExists(path.join(repoRoot, "bin"))) {
123
+ addEntry(entries, repoRoot, "", "bin", "dotnet", "bin");
124
+ }
125
+ if (await directoryExists(path.join(repoRoot, "obj"))) {
126
+ addEntry(entries, repoRoot, "", "obj", "dotnet", "obj");
127
+ }
128
+ }
129
+ }
130
+ const children = await fs.readdir(currentDir, { withFileTypes: true }).catch(() => []);
131
+ for (const child of children) {
132
+ if (!child.isDirectory() || SKIP_DIR_NAMES.has(child.name) || child.name.startsWith(".")) {
133
+ continue;
134
+ }
135
+ await collectDetectedPaths(repoRoot, path.join(currentDir, child.name), entries, depth + 1);
136
+ }
137
+ }
138
+ function addEntry(entries, repoRoot, packageRootRelative, folderName, ecosystem, explicitRelativePath) {
139
+ const relativePath = explicitRelativePath ?? joinRelative(packageRootRelative, folderName);
140
+ entries.set(relativePath, {
141
+ relativePath,
142
+ ecosystem,
143
+ packageRoot: packageRootRelative.length > 0 ? packageRootRelative : "."
144
+ });
145
+ }
146
+ async function resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists) {
147
+ if (entry.ecosystem === "node") {
148
+ if (!sourceExists) {
149
+ return "Base checkout is missing node_modules.";
150
+ }
151
+ if (await lockfilesDiffer(baseCheckoutPath, worktreePath, entry.packageRoot)) {
152
+ return "Lockfile differs between base checkout and worktree.";
153
+ }
154
+ }
155
+ if (entry.ecosystem === "python" && !sourceExists) {
156
+ return "Base checkout is missing .venv.";
157
+ }
158
+ return null;
159
+ }
160
+ export async function lockfilesDiffer(baseCheckoutPath, worktreePath, packageRootRelative) {
161
+ const lockfileNames = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "npm-shrinkwrap.json"];
162
+ const packageRoot = packageRootRelative === "." ? "" : packageRootRelative;
163
+ for (const lockfile of lockfileNames) {
164
+ const baseLock = path.join(baseCheckoutPath, packageRoot, lockfile);
165
+ const worktreeLock = path.join(worktreePath, packageRoot, lockfile);
166
+ const baseExists = await pathExists(baseLock);
167
+ const worktreeExists = await pathExists(worktreeLock);
168
+ if (baseExists !== worktreeExists) {
169
+ return true;
170
+ }
171
+ if (!baseExists) {
172
+ continue;
173
+ }
174
+ const baseHash = await sha256File(baseLock);
175
+ const worktreeHash = await sha256File(worktreeLock);
176
+ if (baseHash !== worktreeHash) {
177
+ return true;
178
+ }
179
+ }
180
+ return false;
181
+ }
182
+ async function runInstallFallback(entry, worktreePath, reason, executor) {
183
+ const cwd = path.join(worktreePath, entry.packageRoot === "." ? "" : entry.packageRoot);
184
+ if (entry.ecosystem === "node") {
185
+ const lockPath = path.join(cwd, "package-lock.json");
186
+ const args = (await pathExists(lockPath)) ? ["ci"] : ["install"];
187
+ await executor.run(cwd, "npm", args);
188
+ return { ecosystem: entry.ecosystem, packageRoot: entry.packageRoot, reason, command: `npm ${args.join(" ")}` };
189
+ }
190
+ if (entry.ecosystem === "python") {
191
+ await executor.run(cwd, "python", ["-m", "venv", ".venv"]);
192
+ const requirements = path.join(cwd, "requirements.txt");
193
+ if (await pathExists(requirements)) {
194
+ const pip = process.platform === "win32" ? path.join(".venv", "Scripts", "pip") : path.join(".venv", "bin", "pip");
195
+ await executor.run(cwd, pip, ["install", "-r", "requirements.txt"]);
196
+ return {
197
+ ecosystem: entry.ecosystem,
198
+ packageRoot: entry.packageRoot,
199
+ reason,
200
+ command: "python -m venv .venv && pip install -r requirements.txt"
201
+ };
202
+ }
203
+ return {
204
+ ecosystem: entry.ecosystem,
205
+ packageRoot: entry.packageRoot,
206
+ reason,
207
+ command: "python -m venv .venv"
208
+ };
209
+ }
210
+ await executor.run(cwd, "dotnet", ["restore"]);
211
+ return {
212
+ ecosystem: entry.ecosystem,
213
+ packageRoot: entry.packageRoot,
214
+ reason,
215
+ command: "dotnet restore"
216
+ };
217
+ }
218
+ export async function requiresInstallFallback(entry, baseCheckoutPath, worktreePath, sourceExists) {
219
+ return resolveFallbackReason(entry, baseCheckoutPath, worktreePath, sourceExists);
220
+ }
221
+ export async function declaresNativeModules(packageRootPath) {
222
+ const packageJsonPath = path.join(packageRootPath, "package.json");
223
+ if (!(await pathExists(packageJsonPath))) {
224
+ return false;
225
+ }
226
+ const raw = await fs.readFile(packageJsonPath, "utf8");
227
+ const lower = raw.toLowerCase();
228
+ return NATIVE_MODULE_HINTS.some((hint) => lower.includes(`"${hint}"`));
229
+ }
230
+ async function hasPythonMarker(dir) {
231
+ return (await pathExists(path.join(dir, "pyproject.toml")))
232
+ || (await pathExists(path.join(dir, "requirements.txt")));
233
+ }
234
+ async function hasDotNetMarker(dir) {
235
+ const entries = await fs.readdir(dir).catch(() => []);
236
+ return entries.some((name) => name.endsWith(".csproj") || name.endsWith(".sln"));
237
+ }
238
+ async function resolvePackageRoot(repoRoot, relativePath) {
239
+ const segments = relativePath.split("/").filter(Boolean);
240
+ if (segments.length === 0) {
241
+ return ".";
242
+ }
243
+ const last = segments[segments.length - 1];
244
+ if (last === "node_modules" || last === ".venv" || last === "bin" || last === "obj") {
245
+ const parent = segments.slice(0, -1).join("/");
246
+ return parent.length > 0 ? parent : ".";
247
+ }
248
+ const candidate = path.join(repoRoot, ...segments);
249
+ if (await pathExists(path.join(candidate, "package.json"))) {
250
+ return segments.join("/");
251
+ }
252
+ return segments.slice(0, -1).join("/") || ".";
253
+ }
254
+ function inferEcosystemFromPath(relativePath) {
255
+ const base = path.basename(relativePath);
256
+ if (base === ".venv") {
257
+ return "python";
258
+ }
259
+ if (base === "bin" || base === "obj") {
260
+ return "dotnet";
261
+ }
262
+ return "node";
263
+ }
264
+ function joinRelative(packageRootRelative, folderName) {
265
+ return packageRootRelative.length > 0
266
+ ? `${packageRootRelative}/${folderName}`.replace(/\\/g, "/")
267
+ : folderName;
268
+ }
269
+ function normalizeRelativePath(value) {
270
+ return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
271
+ }
272
+ function assertWithinRoot(root, candidate, label) {
273
+ if (!isSubPath(root, candidate)) {
274
+ throw new Error(`${label} must stay under ${root}.`);
275
+ }
276
+ }
277
+ async function directoryExists(inputPath) {
278
+ const stat = await fs.stat(inputPath).catch(() => null);
279
+ return stat?.isDirectory() ?? false;
280
+ }
281
+ async function pathExists(inputPath) {
282
+ return (await fs.stat(inputPath).catch(() => null)) !== null;
283
+ }
284
+ async function sha256File(filePath) {
285
+ const crypto = await import("node:crypto");
286
+ const content = await fs.readFile(filePath);
287
+ return crypto.createHash("sha256").update(content).digest("hex");
288
+ }
289
+ const defaultCommandExecutor = {
290
+ async run(cwd, command, args) {
291
+ const result = await defaultProcessRunner.run({ command, cwd, args });
292
+ if (result.exitCode !== 0) {
293
+ throw new Error(`${command} ${args.join(" ")} failed (${result.exitCode}): ${result.stderr.trim()}`);
294
+ }
295
+ }
296
+ };
297
+ const defaultProcessRunner = new NodeProcessRunner();
@@ -1,2 +1,4 @@
1
1
  export { GitWorktreeService } from "./worktree.service.js";
2
+ export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
3
+ export type { WarmDependenciesResult, WarmPathEntry, InstallFallbackResult } from "./dependency-warmer.js";
2
4
  export type { WorktreeService } from "./worktree.base.js";
@@ -1 +1,2 @@
1
1
  export { GitWorktreeService } from "./worktree.service.js";
2
+ export { detectWarmPaths, warmWorktreeDependencies, createDirectoryLink, lockfilesDiffer, requiresInstallFallback } from "./dependency-warmer.js";
@@ -7,6 +7,8 @@ export declare class GitWorktreeService implements WorktreeService {
7
7
  prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
8
8
  release(handle: WorktreeHandle): Promise<void>;
9
9
  private resolveRepoPath;
10
+ private ensureBaseBranch;
11
+ private gitRefExists;
10
12
  private ensureGitIdentity;
11
13
  }
12
14
  export declare function normalizeBranchName(value: string | null | undefined, fallback: string): string;
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { GitClient } from "../shared/git/git.js";
4
4
  import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
5
+ import { injectWorktreeArtifacts } from "./artifact-injector.js";
6
+ import { warmWorktreeDependencies } from "./dependency-warmer.js";
5
7
  export class GitWorktreeService {
6
8
  git;
7
9
  constructor(git = new GitClient()) {
@@ -9,9 +11,10 @@ export class GitWorktreeService {
9
11
  }
10
12
  async prepare(request) {
11
13
  const allowedRoot = await ensureDirectory(request.allowedRepoRoot);
12
- const repoPath = await this.resolveRepoPath(request.repo, allowedRoot);
13
14
  const baseBranch = normalizeBranchName(request.baseBranch, "develop");
15
+ const repoPath = await this.resolveRepoPath(request.repo, allowedRoot, baseBranch);
14
16
  await this.ensureGitIdentity(repoPath);
17
+ await this.ensureBaseBranch(repoPath, baseBranch);
15
18
  const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
16
19
  const kind = safeSegment(request.workItem.kind, "work-item");
17
20
  const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
@@ -34,12 +37,22 @@ export class GitWorktreeService {
34
37
  }
35
38
  }
36
39
  await this.ensureGitIdentity(resolvedWorktreePath);
40
+ if (request.artifacts.length > 0) {
41
+ await injectWorktreeArtifacts(resolvedWorktreePath, request.artifacts);
42
+ }
43
+ const dependencyWarm = await warmWorktreeDependencies({
44
+ baseCheckoutPath: repoPath,
45
+ worktreePath: resolvedWorktreePath,
46
+ allowedRoot,
47
+ warmPathOverrides: request.warmPathOverrides
48
+ });
37
49
  return {
38
50
  repoPath,
39
51
  worktreePath: resolvedWorktreePath,
40
52
  branchName,
41
53
  baseBranch,
42
- cleanupWorktrees: request.cleanupWorktrees
54
+ cleanupWorktrees: request.cleanupWorktrees,
55
+ dependencyWarm
43
56
  };
44
57
  }
45
58
  async release(handle) {
@@ -54,7 +67,7 @@ export class GitWorktreeService {
54
67
  await fs.rm(handle.worktreePath, { recursive: true, force: true });
55
68
  }
56
69
  }
57
- async resolveRepoPath(repo, allowedRoot) {
70
+ async resolveRepoPath(repo, allowedRoot, baseBranch) {
58
71
  if (repo.serverLocalPath && repo.serverLocalPath.trim().length > 0) {
59
72
  const requestedPath = await resolveExistingDirectoryPath(repo.serverLocalPath);
60
73
  if (!isSubPath(allowedRoot, requestedPath) || !(await gitMetadataExists(requestedPath, allowedRoot))) {
@@ -67,12 +80,28 @@ export class GitWorktreeService {
67
80
  }
68
81
  const repoPath = path.join(allowedRoot, `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
69
82
  if (!(await gitMetadataExists(repoPath, allowedRoot))) {
70
- await fs.mkdir(repoPath, { recursive: true });
71
- await this.git.run(repoPath, ["init", "-b", normalizeBranchName(repo.defaultBranch, "develop")]);
72
- await this.ensureGitIdentity(repoPath);
73
- await fs.writeFile(path.join(repoPath, "README.md"), `# ${repo.name}\n`, "utf8");
74
- await this.git.run(repoPath, ["add", "README.md"]);
75
- await this.git.run(repoPath, ["commit", "-m", "seed forge bridge repo"]);
83
+ const repoPathExists = await pathExists(repoPath);
84
+ if (repoPathExists) {
85
+ throw new Error(`Repository path ${repoPath} exists but is not a git checkout.`);
86
+ }
87
+ if (isCloneableRemote(repo.remoteUrl)) {
88
+ await fs.mkdir(path.dirname(repoPath), { recursive: true });
89
+ try {
90
+ await this.git.run(path.dirname(repoPath), ["clone", "--origin", "origin", repo.remoteUrl.trim(), repoPath]);
91
+ }
92
+ catch (err) {
93
+ await fs.rm(repoPath, { recursive: true, force: true });
94
+ throw err;
95
+ }
96
+ }
97
+ else {
98
+ await fs.mkdir(repoPath, { recursive: true });
99
+ await this.git.run(repoPath, ["init", "-b", baseBranch]);
100
+ await this.ensureGitIdentity(repoPath);
101
+ await fs.writeFile(path.join(repoPath, "README.md"), `# ${repo.name}\n`, "utf8");
102
+ await this.git.run(repoPath, ["add", "README.md"]);
103
+ await this.git.run(repoPath, ["commit", "-m", "seed forge bridge repo"]);
104
+ }
76
105
  }
77
106
  const resolved = await resolveExistingDirectoryPath(repoPath);
78
107
  if (!isSubPath(allowedRoot, resolved)) {
@@ -80,11 +109,36 @@ export class GitWorktreeService {
80
109
  }
81
110
  return resolved;
82
111
  }
112
+ async ensureBaseBranch(repoPath, baseBranch) {
113
+ if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
114
+ return;
115
+ }
116
+ const origin = await this.git.run(repoPath, ["remote", "get-url", "origin"]).catch(() => "");
117
+ if (origin.trim().length === 0) {
118
+ throw new Error(`Base branch '${baseBranch}' was not found in the local checkout.`);
119
+ }
120
+ await this.git.run(repoPath, ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`]).catch(() => "");
121
+ if (await this.gitRefExists(repoPath, `refs/remotes/origin/${baseBranch}`)) {
122
+ await this.git.run(repoPath, ["branch", baseBranch, `refs/remotes/origin/${baseBranch}`]);
123
+ return;
124
+ }
125
+ throw new Error(`Base branch '${baseBranch}' was not found locally or on origin.`);
126
+ }
127
+ async gitRefExists(repoPath, gitRef) {
128
+ return this.git.run(repoPath, ["show-ref", "--verify", "--quiet", gitRef]).then(() => true).catch(() => false);
129
+ }
83
130
  async ensureGitIdentity(repoPath) {
84
131
  await this.git.run(repoPath, ["config", "user.email", "forge-bridge@forge.local"]);
85
132
  await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
86
133
  }
87
134
  }
135
+ function isCloneableRemote(remoteUrl) {
136
+ const trimmed = remoteUrl?.trim() ?? "";
137
+ if (trimmed.length === 0 || trimmed.startsWith("local://")) {
138
+ return false;
139
+ }
140
+ return trimmed.startsWith("git@") || trimmed.includes("://") || path.isAbsolute(trimmed);
141
+ }
88
142
  export function normalizeBranchName(value, fallback) {
89
143
  const branch = (value?.trim() || fallback).trim();
90
144
  if (branch.length === 0 ||
@@ -133,6 +187,9 @@ async function gitMetadataExists(repoPath, allowedRoot) {
133
187
  const resolvedGitDir = await resolveExistingDirectoryPath(gitDirPath);
134
188
  return isSubPath(allowedRoot, resolvedGitDir);
135
189
  }
190
+ async function pathExists(inputPath) {
191
+ return fs.stat(inputPath).then(() => true).catch(() => false);
192
+ }
136
193
  async function directoryExists(inputPath) {
137
194
  const stat = await fs.stat(inputPath).catch(() => null);
138
195
  return stat?.isDirectory() ?? false;
@@ -1,10 +1,13 @@
1
- import type { DispatchRepo, DispatchWorkItem } from "../session/session.types.js";
1
+ import type { DispatchRepo, DispatchWorkItem, DispatchResolvedArtifact } from "../session/session.types.js";
2
+ import type { WarmDependenciesResult } from "./dependency-warmer.js";
2
3
  export interface WorktreePrepareRequest {
3
4
  readonly repo: DispatchRepo;
4
5
  readonly workItem: DispatchWorkItem;
5
6
  readonly baseBranch: string;
6
7
  readonly allowedRepoRoot: string;
7
8
  readonly cleanupWorktrees: boolean;
9
+ readonly artifacts: readonly DispatchResolvedArtifact[];
10
+ readonly warmPathOverrides?: readonly string[];
8
11
  }
9
12
  export interface WorktreeHandle {
10
13
  readonly repoPath: string;
@@ -12,4 +15,5 @@ export interface WorktreeHandle {
12
15
  readonly branchName: string;
13
16
  readonly baseBranch: string;
14
17
  readonly cleanupWorktrees: boolean;
18
+ readonly dependencyWarm?: WarmDependenciesResult;
15
19
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@loucompanion/forge-bridge",
3
- "version": "0.1.1-dev.242bb53ef13f",
3
+ "version": "0.1.1-dev.8dcbc6245e51",
4
4
  "description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
7
7
  "bin": {
8
- "forge-bridge": "./dist/cli/bin.js"
8
+ "forge-bridge": "dist/cli/bin.js"
9
9
  },
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",