substrate-ai 0.20.148 → 0.20.150

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.
@@ -4,13 +4,16 @@ import { EventEmitter } from "node:events";
4
4
  import yaml, { dump, load } from "js-yaml";
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { exec, execFile, execSync, spawn, spawnSync } from "node:child_process";
7
+ import * as path$3 from "node:path";
8
+ import * as path$2 from "node:path";
9
+ import * as path$1 from "node:path";
7
10
  import path, { dirname as dirname$1, join as join$1, resolve as resolve$1 } from "node:path";
8
- import os, { freemem, platform } from "node:os";
11
+ import os, { freemem, homedir, platform } from "node:os";
9
12
  import { createHash, randomUUID } from "node:crypto";
10
13
  import { z } from "zod";
11
- import { access as access$1, mkdir as mkdir$1, readFile as readFile$1, writeFile as writeFile$1 } from "node:fs/promises";
14
+ import { access as access$1, copyFile, mkdir as mkdir$1, readFile as readFile$1, readdir as readdir$1, rm, writeFile as writeFile$1 } from "node:fs/promises";
12
15
  import { readFileSync as readFileSync$1, writeFileSync as writeFileSync$1 } from "fs";
13
- import { homedir } from "os";
16
+ import { homedir as homedir$1 } from "os";
14
17
  import { createServer } from "node:http";
15
18
  import { createGunzip, createInflate } from "node:zlib";
16
19
  import { promisify } from "node:util";
@@ -919,6 +922,26 @@ function createStderrLogger(prefix) {
919
922
 
920
923
  //#endregion
921
924
  //#region packages/core/dist/dispatch/dispatcher-impl.js
925
+ const WORKTREE_COUPLED_TASK_TYPES = new Set([
926
+ "create-story",
927
+ "dev-story",
928
+ "code-review",
929
+ "minor-fixes",
930
+ "major-rework",
931
+ "build-fix",
932
+ "test-plan",
933
+ "test-expansion",
934
+ "probe-author"
935
+ ]);
936
+ const GIT_STATE_ENV_KEYS = [
937
+ "PWD",
938
+ "OLDPWD",
939
+ "INIT_CWD",
940
+ "GIT_DIR",
941
+ "GIT_WORK_TREE",
942
+ "GIT_INDEX_FILE",
943
+ "GIT_COMMON_DIR"
944
+ ];
922
945
  const SHUTDOWN_GRACE_MS = 1e4;
923
946
  const SHUTDOWN_MAX_WAIT_MS = 3e4;
924
947
  const CHARS_PER_TOKEN$3 = 4;
@@ -1242,6 +1265,29 @@ var DispatcherImpl = class {
1242
1265
  });
1243
1266
  return;
1244
1267
  }
1268
+ if (workingDirectory === void 0 && WORKTREE_COUPLED_TASK_TYPES.has(taskType)) {
1269
+ this._logger.error({
1270
+ id,
1271
+ agent,
1272
+ taskType
1273
+ }, "dispatch rejected: workingDirectory is required for worktree-coupled task types (H4.1)");
1274
+ this._running.delete(id);
1275
+ this._drainQueue();
1276
+ resolve$2({
1277
+ id,
1278
+ status: "failed",
1279
+ exitCode: -1,
1280
+ output: "",
1281
+ parsed: null,
1282
+ parseError: `workingDirectory is required for taskType "${taskType}" — coding-pipeline dispatches must name their worktree explicitly (H4.1 AC2)`,
1283
+ durationMs: 0,
1284
+ tokenEstimate: {
1285
+ input: Math.ceil(prompt.length / CHARS_PER_TOKEN$3),
1286
+ output: 0
1287
+ }
1288
+ });
1289
+ return;
1290
+ }
1245
1291
  const worktreePath = workingDirectory ?? process.cwd();
1246
1292
  const resolvedMaxTurns = maxTurns ?? DEFAULT_MAX_TURNS[taskType];
1247
1293
  const capabilities = adapter.getCapabilities();
@@ -1257,7 +1303,8 @@ var DispatcherImpl = class {
1257
1303
  ...storyKey !== void 0 ? { storyKey } : {},
1258
1304
  ...optimizationDirectives !== void 0 ? { optimizationDirectives } : {},
1259
1305
  taskType,
1260
- dispatchId: id
1306
+ dispatchId: id,
1307
+ ...this._config.permissionProfile !== void 0 ? { permissionProfile: this._config.permissionProfile } : {}
1261
1308
  });
1262
1309
  const baseTimeoutMs = timeout ?? this._config.defaultTimeouts[taskType] ?? DEFAULT_TIMEOUTS[taskType] ?? 3e5;
1263
1310
  const timeoutMultiplier = capabilities.timeoutMultiplier ?? 1;
@@ -1265,8 +1312,33 @@ var DispatcherImpl = class {
1265
1312
  const env = { ...process.env };
1266
1313
  const parentNodeOpts = env["NODE_OPTIONS"] ?? "";
1267
1314
  if (!parentNodeOpts.includes("--max-old-space-size")) env["NODE_OPTIONS"] = `${parentNodeOpts} --max-old-space-size=512`.trim();
1315
+ for (const key of GIT_STATE_ENV_KEYS) delete env[key];
1316
+ env["GIT_CEILING_DIRECTORIES"] = dirname$1(worktreePath);
1268
1317
  if (cmd.env !== void 0) Object.assign(env, cmd.env);
1269
1318
  if (cmd.unsetEnvKeys !== void 0) for (const key of cmd.unsetEnvKeys) delete env[key];
1319
+ if (cmd.executionMode !== void 0 && cmd.executionMode !== "spawn") {
1320
+ this._logger.error({
1321
+ id,
1322
+ agent,
1323
+ executionMode: cmd.executionMode
1324
+ }, "dispatch rejected: execution mode not implemented");
1325
+ this._running.delete(id);
1326
+ this._drainQueue();
1327
+ resolve$2({
1328
+ id,
1329
+ status: "failed",
1330
+ exitCode: -1,
1331
+ output: "",
1332
+ parsed: null,
1333
+ parseError: `executionMode "${cmd.executionMode}" is not implemented — only 'spawn' is available (H4.4 seam)`,
1334
+ durationMs: 0,
1335
+ tokenEstimate: {
1336
+ input: Math.ceil(prompt.length / CHARS_PER_TOKEN$3),
1337
+ output: 0
1338
+ }
1339
+ });
1340
+ return;
1341
+ }
1270
1342
  const proc = spawn(cmd.binary, cmd.args, {
1271
1343
  cwd: cmd.cwd,
1272
1344
  env,
@@ -6233,7 +6305,10 @@ const TelemetryConfigSchema = z.object({
6233
6305
  meshUrl: z.string().url().optional(),
6234
6306
  projectId: z.string().optional()
6235
6307
  }).strict();
6236
- const WorktreeConfigSchema = z.object({ copy_files: z.array(z.string()).default([]) }).strict();
6308
+ const WorktreeConfigSchema = z.object({
6309
+ copy_files: z.array(z.string()).default([]),
6310
+ base: z.enum(["in-repo", "external"]).optional()
6311
+ }).strict();
6237
6312
  /** Current supported config format version */
6238
6313
  const CURRENT_CONFIG_FORMAT_VERSION = "1";
6239
6314
  /** Current supported task graph version */
@@ -6252,6 +6327,7 @@ const SubstrateConfigSchema = z.object({
6252
6327
  telemetry: TelemetryConfigSchema.optional(),
6253
6328
  worktree: WorktreeConfigSchema.optional(),
6254
6329
  trivialOutputThreshold: z.number().int().nonnegative().optional(),
6330
+ dispatch: z.object({ permission_profile: z.enum(["skip", "scoped"]).optional() }).strict().optional(),
6255
6331
  finalization: z.object({
6256
6332
  mode: z.enum([
6257
6333
  "merge",
@@ -6278,6 +6354,7 @@ const PartialSubstrateConfigSchema = z.object({
6278
6354
  telemetry: TelemetryConfigSchema.partial().optional(),
6279
6355
  worktree: WorktreeConfigSchema.partial().optional(),
6280
6356
  trivialOutputThreshold: z.number().int().nonnegative().optional(),
6357
+ dispatch: z.object({ permission_profile: z.enum(["skip", "scoped"]).optional() }).strict().optional(),
6281
6358
  finalization: z.object({
6282
6359
  mode: z.enum([
6283
6360
  "merge",
@@ -6618,8 +6695,8 @@ function readEnvOverrides(logger) {
6618
6695
  /**
6619
6696
  * Get a value from a nested object using dot-notation key.
6620
6697
  */
6621
- function getByPath(obj, path$1) {
6622
- const parts = path$1.split(".");
6698
+ function getByPath(obj, path$4) {
6699
+ const parts = path$4.split(".");
6623
6700
  let cursor = obj;
6624
6701
  for (const part of parts) {
6625
6702
  if (cursor === null || cursor === void 0 || typeof cursor !== "object") return void 0;
@@ -6631,8 +6708,8 @@ function getByPath(obj, path$1) {
6631
6708
  * Return a deep clone of `obj` with `path` set to `value`.
6632
6709
  * Creates intermediate objects as needed.
6633
6710
  */
6634
- function setByPath(obj, path$1, value) {
6635
- const parts = path$1.split(".");
6711
+ function setByPath(obj, path$4, value) {
6712
+ const parts = path$4.split(".");
6636
6713
  const result = { ...obj };
6637
6714
  let cursor = result;
6638
6715
  for (let i = 0; i < parts.length - 1; i++) {
@@ -6654,7 +6731,7 @@ var ConfigSystemImpl = class {
6654
6731
  _logger;
6655
6732
  constructor(options = {}) {
6656
6733
  this._projectConfigDir = options.projectConfigDir ? resolve(options.projectConfigDir) : resolve(process.cwd(), ".substrate");
6657
- this._globalConfigDir = options.globalConfigDir ? resolve(options.globalConfigDir) : resolve(homedir(), ".substrate");
6734
+ this._globalConfigDir = options.globalConfigDir ? resolve(options.globalConfigDir) : resolve(homedir$1(), ".substrate");
6658
6735
  this._cliOverrides = options.cliOverrides ?? {};
6659
6736
  this._logger = options.logger ?? createStderrLogger("config-system");
6660
6737
  }
@@ -9478,6 +9555,40 @@ function checkAdapterVersionCompat(adapterName, actualVersion, tested) {
9478
9555
  //#endregion
9479
9556
  //#region packages/core/dist/adapters/claude-adapter.js
9480
9557
  const execAsync$2 = promisify(exec);
9558
+ /**
9559
+ * H4.3: generate the per-worktree Claude Code settings file for the
9560
+ * 'scoped' permission profile. Written NEXT TO the worktree (its parent
9561
+ * directory — the worktree base, outside any repo after H4.2) so it can
9562
+ * never be committed by commit-first.
9563
+ *
9564
+ * Rules: reads/search/bash allowed everywhere (verification needs the
9565
+ * suite + git); file MUTATION allowed only under the worktree — anything
9566
+ * else falls to "ask", which headless -p mode denies visibly (there is no
9567
+ * interactive prompt to stall on).
9568
+ */
9569
+ function writeScopedPermissionSettings(worktreePath) {
9570
+ const wt = path$3.resolve(worktreePath);
9571
+ const settings = { permissions: {
9572
+ allow: [
9573
+ "Read",
9574
+ "Glob",
9575
+ "Grep",
9576
+ "Bash",
9577
+ "WebFetch",
9578
+ "WebSearch",
9579
+ "TodoWrite",
9580
+ "Task",
9581
+ `Edit(${wt}/**)`,
9582
+ `Write(${wt}/**)`,
9583
+ `NotebookEdit(${wt}/**)`
9584
+ ],
9585
+ deny: []
9586
+ } };
9587
+ const settingsPath = path$3.join(path$3.dirname(wt), `.agent-settings-${path$3.basename(wt)}.json`);
9588
+ mkdirSync(path$3.dirname(settingsPath), { recursive: true });
9589
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
9590
+ return settingsPath;
9591
+ }
9481
9592
  /** Default model used when none is specified */
9482
9593
  const DEFAULT_MODEL$1 = "claude-sonnet-4-6";
9483
9594
  /**
@@ -9600,9 +9711,12 @@ var ClaudeCodeAdapter = class ClaudeCodeAdapter {
9600
9711
  const args = [
9601
9712
  "-p",
9602
9713
  "--model",
9603
- model,
9604
- "--dangerously-skip-permissions"
9714
+ model
9605
9715
  ];
9716
+ if (options.permissionProfile === "scoped") {
9717
+ const settingsPath = writeScopedPermissionSettings(options.worktreePath);
9718
+ args.push("--permission-mode", "acceptEdits", "--settings", settingsPath);
9719
+ } else args.push("--dangerously-skip-permissions");
9606
9720
  args.push("--output-format", "stream-json", "--verbose");
9607
9721
  if (options.additionalFlags && options.additionalFlags.length > 0) args.push(...options.additionalFlags);
9608
9722
  const systemPromptParts = [BASE_SYSTEM_PROMPT];
@@ -10779,6 +10893,833 @@ const CompileResultSchema = z.object({
10779
10893
  truncated: z.boolean()
10780
10894
  });
10781
10895
 
10896
+ //#endregion
10897
+ //#region packages/core/dist/git/git-utils.js
10898
+ const MIN_GIT_MAJOR = 2;
10899
+ const MIN_GIT_MINOR = 20;
10900
+ /**
10901
+ * Spawn a git subprocess with the given args.
10902
+ *
10903
+ * @param args - Arguments to pass to git (e.g., ['worktree', 'add', ...])
10904
+ * @param options - Optional spawn options (cwd, env)
10905
+ * @returns - Object with stdout, stderr, and exit code
10906
+ */
10907
+ function spawnGit(args, options) {
10908
+ return new Promise((resolve$2) => {
10909
+ const proc = spawn("git", args, {
10910
+ cwd: options?.cwd,
10911
+ env: options?.env ?? process.env,
10912
+ stdio: [
10913
+ "ignore",
10914
+ "pipe",
10915
+ "pipe"
10916
+ ]
10917
+ });
10918
+ let stdout = "";
10919
+ let stderr = "";
10920
+ proc.stdout?.on("data", (chunk) => {
10921
+ stdout += chunk.toString();
10922
+ });
10923
+ proc.stderr?.on("data", (chunk) => {
10924
+ stderr += chunk.toString();
10925
+ });
10926
+ proc.on("close", (code) => {
10927
+ resolve$2({
10928
+ stdout: stdout.trim(),
10929
+ stderr: stderr.trim(),
10930
+ code: code ?? 1
10931
+ });
10932
+ });
10933
+ proc.on("error", (err) => {
10934
+ resolve$2({
10935
+ stdout: "",
10936
+ stderr: err.message,
10937
+ code: 1
10938
+ });
10939
+ });
10940
+ });
10941
+ }
10942
+ /**
10943
+ * Get the installed git version string.
10944
+ *
10945
+ * @returns Version string like "2.42.0"
10946
+ * @throws Error if git is not installed or version cannot be parsed
10947
+ */
10948
+ async function getGitVersion() {
10949
+ const result = await spawnGit(["--version"]);
10950
+ if (result.code !== 0) throw new Error(`git --version failed: ${result.stderr}`);
10951
+ const match = /git version\s+(\d+\.\d+(?:\.\d+)?)/.exec(result.stdout);
10952
+ if (match === null || match[1] === void 0) throw new Error(`Unable to parse git version from output: "${result.stdout}"`);
10953
+ return match[1];
10954
+ }
10955
+ /**
10956
+ * Parse a git version string into major/minor/patch components.
10957
+ *
10958
+ * @param versionString - Version string like "2.42.0" or "2.20"
10959
+ * @returns - Parsed version components
10960
+ */
10961
+ function parseGitVersion(versionString) {
10962
+ const parts = versionString.split(".").map(Number);
10963
+ return {
10964
+ major: parts[0] ?? 0,
10965
+ minor: parts[1] ?? 0,
10966
+ patch: parts[2] ?? 0
10967
+ };
10968
+ }
10969
+ /**
10970
+ * Check if the given git version string is >= 2.20.0.
10971
+ *
10972
+ * @param version - Version string like "2.42.0"
10973
+ * @returns - true if version >= 2.20.0
10974
+ */
10975
+ function isGitVersionSupported(version) {
10976
+ const { major, minor } = parseGitVersion(version);
10977
+ if (major > MIN_GIT_MAJOR) return true;
10978
+ if (major === MIN_GIT_MAJOR && minor >= MIN_GIT_MINOR) return true;
10979
+ return false;
10980
+ }
10981
+ /**
10982
+ * Verify that git is installed and version >= 2.20.
10983
+ *
10984
+ * @throws Error with clear message if git is not installed or too old
10985
+ */
10986
+ async function verifyGitVersion() {
10987
+ let version;
10988
+ try {
10989
+ version = await getGitVersion();
10990
+ } catch (err) {
10991
+ throw new Error(`git is not installed or could not be executed. Please install git 2.20 or newer. Details: ${String(err)}`);
10992
+ }
10993
+ if (!isGitVersionSupported(version)) {
10994
+ const { major, minor, patch } = parseGitVersion(version);
10995
+ throw new Error(`Git version ${major}.${minor}.${patch} is too old. Substrate requires git 2.20 or newer. Please upgrade git: https://git-scm.com/downloads`);
10996
+ }
10997
+ }
10998
+ /**
10999
+ * Decide whether a registered worktree from a prior dispatch can be reclaimed
11000
+ * for a fresh re-run. Safe only when there is nothing to lose: no uncommitted
11001
+ * changes AND no commits on the branch beyond the base branch. A negative
11002
+ * `commitsAhead` means the ahead-count could not be determined → not safe.
11003
+ *
11004
+ * Pure + exported for testing (the git I/O that produces the inputs lives in
11005
+ * createWorktree).
11006
+ */
11007
+ function decideWorktreeReclaim(hasUncommittedChanges, commitsAhead, baseBranch) {
11008
+ if (hasUncommittedChanges) return {
11009
+ safe: false,
11010
+ reason: "it has uncommitted changes that are NOT on the branch"
11011
+ };
11012
+ if (commitsAhead > 0) return {
11013
+ safe: false,
11014
+ reason: `its branch has ${String(commitsAhead)} commit(s) beyond ${baseBranch}`
11015
+ };
11016
+ if (commitsAhead < 0) return {
11017
+ safe: false,
11018
+ reason: "its state could not be verified as safe to discard"
11019
+ };
11020
+ return { safe: true };
11021
+ }
11022
+ /**
11023
+ * Decide whether removing a story worktree AND deleting its branch is safe
11024
+ * (H0.3, field findings #17/#19). Unsafe when:
11025
+ * - the worktree has uncommitted changes (removal destroys the only copy), or
11026
+ * - the branch carries commits not reachable from the project's current HEAD
11027
+ * (branch -D destroys them — this is where H0.1's wip checkpoints live), or
11028
+ * - either state could not be determined (negative unmergedCommits).
11029
+ *
11030
+ * Pure + exported for testing (mirrors decideWorktreeReclaim; the git I/O that
11031
+ * produces the inputs lives in inspectWorktreeRemovalSafety).
11032
+ */
11033
+ function decideWorktreeRemoval(hasUncommittedChanges, uncommittedFiles, unmergedCommits, branchName) {
11034
+ const reasons = [];
11035
+ if (hasUncommittedChanges) {
11036
+ const preview = uncommittedFiles.slice(0, 10).join(", ");
11037
+ const more = uncommittedFiles.length > 10 ? ` (+${String(uncommittedFiles.length - 10)} more)` : "";
11038
+ reasons.push(`the worktree has ${String(uncommittedFiles.length)} uncommitted change(s) that removal would destroy` + (preview.length > 0 ? `: ${preview}${more}` : ""));
11039
+ }
11040
+ if (unmergedCommits > 0) reasons.push(`branch ${branchName} carries ${String(unmergedCommits)} commit(s) not reachable from the current HEAD — deleting the branch would destroy them`);
11041
+ if (unmergedCommits < 0) reasons.push("the branch state could not be verified as safe to discard");
11042
+ return {
11043
+ safe: reasons.length === 0,
11044
+ reasons
11045
+ };
11046
+ }
11047
+ /**
11048
+ * Gather the inputs for `decideWorktreeRemoval` from git. Thin I/O wrapper:
11049
+ * - `git status --porcelain` inside the worktree (skipped when the directory
11050
+ * is missing — nothing on disk to lose)
11051
+ * - `git rev-list --count HEAD..<branch>` in the project root (commits the
11052
+ * branch has that the current checkout does not; 0 when the branch is
11053
+ * merged or absent, -1 when the count could not be determined)
11054
+ */
11055
+ async function inspectWorktreeRemovalSafety(worktreePath, projectRoot, branchName) {
11056
+ let hasUncommittedChanges = false;
11057
+ let uncommittedFiles = [];
11058
+ const worktreeOnDisk = await access$1(worktreePath).then(() => true).catch(() => false);
11059
+ if (worktreeOnDisk) {
11060
+ const statusResult = await spawnGit(["status", "--porcelain"], { cwd: worktreePath });
11061
+ if (statusResult.code === 0) {
11062
+ uncommittedFiles = statusResult.stdout.split("\n").filter((line) => line.trim().length > 0).map((line) => line.slice(3).trim());
11063
+ hasUncommittedChanges = uncommittedFiles.length > 0;
11064
+ } else return decideWorktreeRemoval(false, [], -1, branchName);
11065
+ }
11066
+ const branchExists = (await spawnGit([
11067
+ "rev-parse",
11068
+ "--verify",
11069
+ branchName
11070
+ ], { cwd: projectRoot })).code === 0;
11071
+ let unmergedCommits = 0;
11072
+ if (branchExists) {
11073
+ const aheadResult = await spawnGit([
11074
+ "rev-list",
11075
+ "--count",
11076
+ `HEAD..${branchName}`
11077
+ ], { cwd: projectRoot });
11078
+ unmergedCommits = aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : -1;
11079
+ }
11080
+ return decideWorktreeRemoval(hasUncommittedChanges, uncommittedFiles, unmergedCommits, branchName);
11081
+ }
11082
+ async function createWorktree(projectRoot, taskId, branchName, baseBranch, copyFiles = [], baseDirectory = ".substrate-worktrees") {
11083
+ const worktreePath = path$2.resolve(projectRoot, baseDirectory, taskId);
11084
+ await mkdir$1(path$2.dirname(worktreePath), { recursive: true });
11085
+ const worktreeExists = await access$1(worktreePath).then(() => true).catch((err) => {
11086
+ if (err.code === "ENOENT") return false;
11087
+ throw err;
11088
+ });
11089
+ if (worktreeExists) {
11090
+ const listResult = await spawnGit([
11091
+ "worktree",
11092
+ "list",
11093
+ "--porcelain"
11094
+ ], { cwd: projectRoot });
11095
+ const registeredPaths = listResult.stdout.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9).trim());
11096
+ const isRegistered = registeredPaths.includes(worktreePath);
11097
+ if (!isRegistered) await rm(worktreePath, {
11098
+ recursive: true,
11099
+ force: true
11100
+ });
11101
+ else {
11102
+ const statusResult = await spawnGit(["status", "--porcelain"], { cwd: worktreePath });
11103
+ const hasUncommittedChanges = statusResult.code === 0 && statusResult.stdout.trim().length > 0;
11104
+ const aheadResult = await spawnGit([
11105
+ "rev-list",
11106
+ "--count",
11107
+ `${baseBranch}..${branchName}`
11108
+ ], { cwd: projectRoot });
11109
+ const commitsAhead = aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : -1;
11110
+ const decision = decideWorktreeReclaim(hasUncommittedChanges, commitsAhead, baseBranch);
11111
+ if (!decision.safe) throw new Error(`Worktree at ${worktreePath} is already registered (branch: ${branchName}) and ${decision.reason}.\nIt was preserved from a prior dispatch for inspection — inspect before removing.\n\nTo remove and re-dispatch:\n substrate worktrees --cleanup ${taskId}\n\nTo remove all substrate worktrees:\n substrate worktrees --cleanup`);
11112
+ await spawnGit([
11113
+ "worktree",
11114
+ "remove",
11115
+ "--force",
11116
+ worktreePath
11117
+ ], { cwd: projectRoot });
11118
+ await spawnGit([
11119
+ "branch",
11120
+ "-D",
11121
+ branchName
11122
+ ], { cwd: projectRoot });
11123
+ }
11124
+ }
11125
+ const addResult = await spawnGit([
11126
+ "worktree",
11127
+ "add",
11128
+ worktreePath,
11129
+ "-b",
11130
+ branchName,
11131
+ baseBranch
11132
+ ], { cwd: projectRoot });
11133
+ if (addResult.code !== 0) throw new Error(`git worktree add failed for task "${taskId}": ${addResult.stderr || addResult.stdout}`);
11134
+ await copyFilesToWorktree(projectRoot, worktreePath, copyFiles);
11135
+ return { worktreePath };
11136
+ }
11137
+ /**
11138
+ * Copy files from a source directory into a target worktree.
11139
+ *
11140
+ * Skips missing files silently (intentional — config like `[".env",
11141
+ * ".env.local"]` should not blow up if `.env.local` doesn't exist in the
11142
+ * parent checkout). Creates parent directories for nested paths.
11143
+ *
11144
+ * Exported for testability; not part of the public worktree API.
11145
+ */
11146
+ async function copyFilesToWorktree(sourceRoot, worktreePath, files) {
11147
+ if (files.length === 0) return;
11148
+ for (const relativePath of files) {
11149
+ if (path$2.isAbsolute(relativePath) || relativePath.split(path$2.sep).includes("..")) continue;
11150
+ const sourcePath = path$2.join(sourceRoot, relativePath);
11151
+ const destPath = path$2.join(worktreePath, relativePath);
11152
+ try {
11153
+ await access$1(sourcePath);
11154
+ } catch {
11155
+ continue;
11156
+ }
11157
+ const destDir = path$2.dirname(destPath);
11158
+ if (destDir !== worktreePath) await mkdir$1(destDir, { recursive: true });
11159
+ await copyFile(sourcePath, destPath);
11160
+ }
11161
+ }
11162
+ /**
11163
+ * Remove a git worktree by path.
11164
+ *
11165
+ * Uses `git worktree remove --force` to handle unclean worktrees.
11166
+ *
11167
+ * @param worktreePath - Absolute path to the worktree directory
11168
+ * @param projectRoot - Absolute path to the git repository root
11169
+ * @throws - Error if git command fails
11170
+ */
11171
+ async function removeWorktree(worktreePath, projectRoot) {
11172
+ const spawnOpts = {};
11173
+ if (projectRoot !== void 0) spawnOpts.cwd = projectRoot;
11174
+ const result = await spawnGit([
11175
+ "worktree",
11176
+ "remove",
11177
+ "--force",
11178
+ worktreePath
11179
+ ], spawnOpts);
11180
+ if (result.code !== 0) throw new Error(`git worktree remove failed for "${worktreePath}": ${result.stderr || result.stdout}`);
11181
+ }
11182
+ /**
11183
+ * Delete a git branch using `git branch -D`.
11184
+ *
11185
+ * @param branchName - Branch name to delete (e.g., "substrate/story-abc123")
11186
+ * @param projectRoot - Absolute path to the git repository root
11187
+ * @returns - true if branch was successfully deleted, false otherwise
11188
+ */
11189
+ async function removeBranch(branchName, projectRoot) {
11190
+ const spawnOpts = {};
11191
+ if (projectRoot !== void 0) spawnOpts.cwd = projectRoot;
11192
+ const result = await spawnGit([
11193
+ "branch",
11194
+ "-D",
11195
+ branchName
11196
+ ], spawnOpts);
11197
+ if (result.code !== 0) return false;
11198
+ return true;
11199
+ }
11200
+ /**
11201
+ * Scan the worktrees base directory and return paths of all found worktree directories.
11202
+ *
11203
+ * @param projectRoot - Absolute path to the git repository root
11204
+ * @param baseDirectory - Relative directory name for worktrees (default: '.substrate-worktrees')
11205
+ * @returns - Array of absolute worktree directory paths
11206
+ */
11207
+ async function getOrphanedWorktrees(projectRoot, baseDirectory = ".substrate-worktrees") {
11208
+ const worktreesDir = path$2.resolve(projectRoot, baseDirectory);
11209
+ try {
11210
+ await access$1(worktreesDir);
11211
+ } catch {
11212
+ return [];
11213
+ }
11214
+ let entries;
11215
+ try {
11216
+ entries = await readdir$1(worktreesDir, { withFileTypes: true });
11217
+ } catch {
11218
+ return [];
11219
+ }
11220
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => path$2.join(worktreesDir, entry.name));
11221
+ }
11222
+ /**
11223
+ * Simulate a merge using git merge --no-commit --no-ff without committing.
11224
+ *
11225
+ * This runs in the target branch's working directory (the project root or
11226
+ * worktree path). The simulation must be aborted after checking conflicts
11227
+ * via abortMerge().
11228
+ *
11229
+ * @param branchName - The source branch to simulate merging
11230
+ * @param cwd - Working directory (must be on the target branch)
11231
+ * @returns - true if merge would be clean, false if there are conflicts
11232
+ */
11233
+ async function simulateMerge(branchName, cwd) {
11234
+ const result = await spawnGit([
11235
+ "merge",
11236
+ "--no-commit",
11237
+ "--no-ff",
11238
+ branchName
11239
+ ], { cwd });
11240
+ if (result.code === 0) return true;
11241
+ return false;
11242
+ }
11243
+ /**
11244
+ * Abort a merge in progress using git merge --abort.
11245
+ *
11246
+ * Should be called after simulateMerge() to clean up the merge state,
11247
+ * regardless of whether conflicts were found.
11248
+ *
11249
+ * @param cwd - Working directory (same as used for simulateMerge)
11250
+ */
11251
+ async function abortMerge(cwd) {
11252
+ await spawnGit(["merge", "--abort"], { cwd });
11253
+ }
11254
+ /**
11255
+ * Get a list of files with conflicts during a merge.
11256
+ *
11257
+ * Must be called while a merge is in progress (after simulateMerge() and
11258
+ * before abortMerge()).
11259
+ *
11260
+ * @param cwd - Working directory of the repository
11261
+ * @returns - Array of conflicting file paths
11262
+ */
11263
+ async function getConflictingFiles(cwd) {
11264
+ const result = await spawnGit([
11265
+ "diff",
11266
+ "--name-only",
11267
+ "--diff-filter=U"
11268
+ ], { cwd });
11269
+ if (result.code !== 0) return [];
11270
+ if (result.stdout.trim() === "") return [];
11271
+ return result.stdout.trim().split("\n").filter((f) => f.trim().length > 0);
11272
+ }
11273
+ /**
11274
+ * Perform an actual merge using git merge --no-ff.
11275
+ *
11276
+ * Should only be called after detectConflicts() confirms there are no conflicts.
11277
+ * Creates a merge commit even if fast-forward is possible (--no-ff ensures history).
11278
+ *
11279
+ * @param branchName - The source branch to merge
11280
+ * @param cwd - Working directory (must be on the target branch)
11281
+ * @returns - true if merge succeeded, false otherwise
11282
+ */
11283
+ async function performMerge(branchName, cwd) {
11284
+ const result = await spawnGit([
11285
+ "merge",
11286
+ "--no-ff",
11287
+ branchName
11288
+ ], { cwd });
11289
+ if (result.code !== 0) return false;
11290
+ return true;
11291
+ }
11292
+ /**
11293
+ * Get a list of files changed in the most recent merge.
11294
+ *
11295
+ * Uses git diff --name-only HEAD~1..HEAD to find files in the merge commit.
11296
+ * Falls back to empty array if commit history is insufficient.
11297
+ *
11298
+ * @param cwd - Working directory of the repository
11299
+ * @returns - Array of file paths that were merged
11300
+ */
11301
+ async function getMergedFiles(cwd) {
11302
+ const result = await spawnGit([
11303
+ "diff",
11304
+ "--name-only",
11305
+ "HEAD~1..HEAD"
11306
+ ], { cwd });
11307
+ if (result.code !== 0) {
11308
+ const altResult = await spawnGit([
11309
+ "show",
11310
+ "--name-only",
11311
+ "--format=",
11312
+ "HEAD"
11313
+ ], { cwd });
11314
+ if (altResult.code !== 0) return [];
11315
+ return altResult.stdout.trim().split("\n").filter((f) => f.trim().length > 0);
11316
+ }
11317
+ if (result.stdout.trim() === "") return [];
11318
+ return result.stdout.trim().split("\n").filter((f) => f.trim().length > 0);
11319
+ }
11320
+
11321
+ //#endregion
11322
+ //#region packages/core/dist/git/git-worktree-manager-impl.js
11323
+ /**
11324
+ * Branch name prefix for substrate per-story branches.
11325
+ *
11326
+ * Exported as the canonical source of truth so consumers (orchestrator,
11327
+ * integration tests, tooling) can compose branch names without
11328
+ * independently encoding the prefix. v0.20.82 production bug:
11329
+ * `orchestrator-impl.ts:4290` hardcoded `substrate/story-${storyKey}`
11330
+ * while this module created `substrate/task-${taskId}` — the resulting
11331
+ * merge-to-main looked at a non-existent branch. Recurrence prevention:
11332
+ * all branch-name construction MUST import this constant.
11333
+ */
11334
+ const BRANCH_PREFIX = "substrate/story-";
11335
+ const DEFAULT_WORKTREE_BASE = ".substrate-worktrees";
11336
+ /**
11337
+ * H4.2 (AC2): resolve the worktree base directory for a project.
11338
+ *
11339
+ * 'external' (the NEW DEFAULT) puts worktrees OUTSIDE the parent tree at
11340
+ * `~/.substrate/worktrees/<projectname>-<hash8>/` — an agent inside its
11341
+ * worktree has no parent repo above it to leak into (composes with H4.1's
11342
+ * GIT_CEILING_DIRECTORIES, which points at this base). 'in-repo' retains the
11343
+ * pre-H4.2 `<projectRoot>/.substrate-worktrees/` for tooling that assumed
11344
+ * the old path (MIGRATION NOTE: reconcile-from-disk, editor bookmarks, and
11345
+ * scripts that globbed `.substrate-worktrees/` should use
11346
+ * `substrate worktrees list` or set `worktree.base: in-repo`).
11347
+ *
11348
+ * When `baseOverride` is not given, reads `worktree.base` from
11349
+ * `.substrate/config.yaml` directly (same no-threading pattern as
11350
+ * `resolveEpicsPathOverride`) so EVERY construction site — orchestrator,
11351
+ * `substrate merge`, `substrate worktrees` — resolves identically.
11352
+ */
11353
+ function resolveWorktreeBaseDirectory(projectRoot, baseOverride) {
11354
+ let mode = baseOverride;
11355
+ if (mode === void 0) try {
11356
+ const raw = readFileSync(path$1.join(projectRoot, ".substrate", "config.yaml"), "utf-8");
11357
+ const parsed = yaml.load(raw);
11358
+ if (parsed?.worktree?.base === "in-repo" || parsed?.worktree?.base === "external") mode = parsed.worktree.base;
11359
+ } catch {}
11360
+ if ((mode ?? "external") === "in-repo") return DEFAULT_WORKTREE_BASE;
11361
+ const hash = createHash("sha256").update(path$1.resolve(projectRoot)).digest("hex").slice(0, 8);
11362
+ return path$1.join(homedir(), ".substrate", "worktrees", `${path$1.basename(projectRoot)}-${hash}`);
11363
+ }
11364
+ var GitWorktreeManagerImpl = class {
11365
+ _eventBus;
11366
+ _projectRoot;
11367
+ _baseDirectory;
11368
+ _db;
11369
+ _logger;
11370
+ /** v0.20.109: files to copy from project root into each new worktree (e.g. `.env`). */
11371
+ _copyFiles;
11372
+ /** Bound listener references for cleanup in shutdown() */
11373
+ _onTaskReady;
11374
+ _onTaskComplete;
11375
+ _onTaskFailed;
11376
+ constructor(eventBus, projectRoot, baseDirectory = DEFAULT_WORKTREE_BASE, db = null, logger, copyFiles = []) {
11377
+ this._eventBus = eventBus;
11378
+ this._projectRoot = projectRoot;
11379
+ this._baseDirectory = baseDirectory;
11380
+ this._db = db;
11381
+ this._logger = logger ?? createStderrLogger("git-worktree-manager");
11382
+ this._copyFiles = copyFiles;
11383
+ this._onTaskReady = ({ taskId }) => {
11384
+ this._handleTaskReady(taskId).catch((err) => {
11385
+ this._logger.error({
11386
+ taskId,
11387
+ err
11388
+ }, "Unhandled error in _handleTaskReady");
11389
+ });
11390
+ };
11391
+ this._onTaskComplete = ({ taskId }) => {
11392
+ this._handleTaskDone(taskId);
11393
+ };
11394
+ this._onTaskFailed = ({ taskId }) => {
11395
+ this._handleTaskDone(taskId);
11396
+ };
11397
+ }
11398
+ async initialize() {
11399
+ this._logger.info({ projectRoot: this._projectRoot }, "GitWorktreeManager.initialize()");
11400
+ await this.verifyGitVersion();
11401
+ const cleaned = await this.cleanupAllWorktrees();
11402
+ if (cleaned > 0) this._logger.info({ cleaned }, "Recovered orphaned worktrees on startup");
11403
+ this._eventBus.on("task:ready", this._onTaskReady);
11404
+ this._eventBus.on("task:complete", this._onTaskComplete);
11405
+ this._eventBus.on("task:failed", this._onTaskFailed);
11406
+ this._logger.info("GitWorktreeManager initialized");
11407
+ }
11408
+ async shutdown() {
11409
+ this._logger.info("GitWorktreeManager.shutdown()");
11410
+ this._eventBus.off("task:ready", this._onTaskReady);
11411
+ this._eventBus.off("task:complete", this._onTaskComplete);
11412
+ this._eventBus.off("task:failed", this._onTaskFailed);
11413
+ await this.cleanupAllWorktrees();
11414
+ this._logger.info("GitWorktreeManager shutdown complete");
11415
+ }
11416
+ async _handleTaskReady(taskId) {
11417
+ this._logger.debug({ taskId }, "task:ready — creating worktree");
11418
+ try {
11419
+ await this.createWorktree(taskId);
11420
+ } catch (err) {
11421
+ this._logger.error({
11422
+ taskId,
11423
+ err
11424
+ }, "Failed to create worktree for task");
11425
+ }
11426
+ }
11427
+ async _handleTaskDone(taskId) {
11428
+ this._logger.debug({ taskId }, "task done — cleaning up worktree");
11429
+ try {
11430
+ await this.cleanupWorktree(taskId);
11431
+ } catch (err) {
11432
+ this._logger.warn({
11433
+ taskId,
11434
+ err
11435
+ }, "Failed to cleanup worktree for task");
11436
+ }
11437
+ }
11438
+ async createWorktree(taskId, baseBranch) {
11439
+ if (!taskId || taskId.trim().length === 0) throw new Error("createWorktree: taskId must be a non-empty string");
11440
+ const resolvedBaseBranch = baseBranch ?? await this._detectCurrentBranch() ?? "main";
11441
+ const branchName = BRANCH_PREFIX + taskId;
11442
+ const worktreePath = this.getWorktreePath(taskId);
11443
+ this._logger.debug({
11444
+ taskId,
11445
+ branchName,
11446
+ worktreePath,
11447
+ baseBranch: resolvedBaseBranch,
11448
+ copyFiles: this._copyFiles
11449
+ }, "createWorktree");
11450
+ const copyFiles = this._copyFiles.includes(".substrate/project-profile.yaml") ? this._copyFiles : [...this._copyFiles, ".substrate/project-profile.yaml"];
11451
+ await createWorktree(this._projectRoot, taskId, branchName, resolvedBaseBranch, copyFiles, this._baseDirectory);
11452
+ const createdAt = new Date();
11453
+ this._eventBus.emit("worktree:created", {
11454
+ taskId,
11455
+ branchName,
11456
+ worktreePath,
11457
+ createdAt
11458
+ });
11459
+ const info = {
11460
+ taskId,
11461
+ branchName,
11462
+ worktreePath,
11463
+ createdAt
11464
+ };
11465
+ this._logger.info({
11466
+ taskId,
11467
+ branchName,
11468
+ worktreePath
11469
+ }, "Worktree created");
11470
+ return info;
11471
+ }
11472
+ async cleanupWorktree(taskId, opts) {
11473
+ const branchName = BRANCH_PREFIX + taskId;
11474
+ const worktreePath = this.getWorktreePath(taskId);
11475
+ this._logger.debug({
11476
+ taskId,
11477
+ branchName,
11478
+ worktreePath,
11479
+ force: opts?.force === true
11480
+ }, "cleanupWorktree");
11481
+ if (opts?.force !== true) {
11482
+ const decision = await inspectWorktreeRemovalSafety(worktreePath, this._projectRoot, branchName);
11483
+ const blockingReasons = opts?.keepBranch === true ? decision.reasons.filter((r) => r.includes("uncommitted")) : decision.reasons;
11484
+ if (blockingReasons.length > 0) throw new Error(`refusing to clean up worktree for "${taskId}": ${blockingReasons.join("; ")}.\nInspect first (git -C ${worktreePath} status; git log ${branchName} --oneline) or re-run with --force to discard.`);
11485
+ }
11486
+ let worktreeExists = false;
11487
+ try {
11488
+ await access$1(worktreePath);
11489
+ worktreeExists = true;
11490
+ } catch {
11491
+ this._logger.debug({
11492
+ taskId,
11493
+ worktreePath
11494
+ }, "cleanupWorktree: worktree does not exist, skipping removal");
11495
+ }
11496
+ if (worktreeExists) try {
11497
+ await removeWorktree(worktreePath, this._projectRoot);
11498
+ } catch (err) {
11499
+ this._logger.warn({
11500
+ taskId,
11501
+ worktreePath,
11502
+ err
11503
+ }, "removeWorktree failed during cleanup");
11504
+ }
11505
+ if (opts?.keepBranch !== true) try {
11506
+ await removeBranch(branchName, this._projectRoot);
11507
+ } catch (err) {
11508
+ this._logger.warn({
11509
+ taskId,
11510
+ branchName,
11511
+ err
11512
+ }, "removeBranch failed during cleanup");
11513
+ }
11514
+ this._eventBus.emit("worktree:removed", {
11515
+ taskId,
11516
+ branchName
11517
+ });
11518
+ this._logger.info({
11519
+ taskId,
11520
+ branchName
11521
+ }, "Worktree cleaned up");
11522
+ }
11523
+ async cleanupAllWorktrees(opts) {
11524
+ this._logger.debug({
11525
+ projectRoot: this._projectRoot,
11526
+ force: opts?.force === true
11527
+ }, "cleanupAllWorktrees");
11528
+ const orphanedPaths = await getOrphanedWorktrees(this._projectRoot, this._baseDirectory);
11529
+ let cleaned = 0;
11530
+ for (const worktreePath of orphanedPaths) {
11531
+ const taskId = path$1.basename(worktreePath);
11532
+ const branchGuardName = BRANCH_PREFIX + taskId;
11533
+ const decision = opts?.force === true ? {
11534
+ safe: true,
11535
+ reasons: []
11536
+ } : await inspectWorktreeRemovalSafety(worktreePath, this._projectRoot, branchGuardName);
11537
+ if (!decision.safe) {
11538
+ this._logger.warn({
11539
+ taskId,
11540
+ worktreePath,
11541
+ reasons: decision.reasons
11542
+ }, "cleanupAllWorktrees: preserving worktree — removal would destroy work (use `substrate worktrees cleanup --force` to discard)");
11543
+ continue;
11544
+ }
11545
+ let worktreeRemoved = false;
11546
+ try {
11547
+ await removeWorktree(worktreePath, this._projectRoot);
11548
+ worktreeRemoved = true;
11549
+ this._logger.debug({
11550
+ taskId,
11551
+ worktreePath
11552
+ }, "cleanupAllWorktrees: removed orphaned worktree");
11553
+ } catch (err) {
11554
+ this._logger.warn({
11555
+ taskId,
11556
+ worktreePath,
11557
+ err
11558
+ }, "cleanupAllWorktrees: failed to remove worktree");
11559
+ }
11560
+ const branchName = BRANCH_PREFIX + taskId;
11561
+ try {
11562
+ const branchRemoved = await removeBranch(branchName, this._projectRoot);
11563
+ if (branchRemoved) this._logger.debug({
11564
+ taskId,
11565
+ branchName
11566
+ }, "cleanupAllWorktrees: removed orphaned branch");
11567
+ } catch (err) {
11568
+ this._logger.warn({
11569
+ taskId,
11570
+ branchName,
11571
+ err
11572
+ }, "cleanupAllWorktrees: failed to remove branch");
11573
+ }
11574
+ if (worktreeRemoved) cleaned++;
11575
+ }
11576
+ if (cleaned > 0) this._logger.info({ cleaned }, "cleanupAllWorktrees: recovered orphaned worktrees");
11577
+ return cleaned;
11578
+ }
11579
+ async detectConflicts(taskId, targetBranch = "main") {
11580
+ if (!taskId || taskId.trim().length === 0) throw new Error("detectConflicts: taskId must be a non-empty string");
11581
+ const branchName = BRANCH_PREFIX + taskId;
11582
+ const worktreePath = this.getWorktreePath(taskId);
11583
+ this._logger.debug({
11584
+ taskId,
11585
+ branchName,
11586
+ targetBranch
11587
+ }, "detectConflicts");
11588
+ try {
11589
+ await access$1(worktreePath);
11590
+ } catch {
11591
+ throw new Error(`detectConflicts: Worktree for task "${taskId}" not found at "${worktreePath}". The worktree may have already been cleaned up.`);
11592
+ }
11593
+ const mergeClean = await simulateMerge(branchName, this._projectRoot);
11594
+ let conflictingFiles = [];
11595
+ try {
11596
+ if (!mergeClean) conflictingFiles = await getConflictingFiles(this._projectRoot);
11597
+ } finally {
11598
+ await abortMerge(this._projectRoot);
11599
+ }
11600
+ const report = {
11601
+ hasConflicts: !mergeClean || conflictingFiles.length > 0,
11602
+ conflictingFiles,
11603
+ taskId,
11604
+ targetBranch
11605
+ };
11606
+ if (report.hasConflicts) this._eventBus.emit("worktree:conflict", {
11607
+ taskId,
11608
+ branch: branchName,
11609
+ conflictingFiles: report.conflictingFiles
11610
+ });
11611
+ this._logger.info({
11612
+ taskId,
11613
+ hasConflicts: report.hasConflicts,
11614
+ conflictCount: conflictingFiles.length
11615
+ }, "Conflict detection complete");
11616
+ return report;
11617
+ }
11618
+ async mergeWorktree(taskId, targetBranch = "main") {
11619
+ if (!taskId || taskId.trim().length === 0) throw new Error("mergeWorktree: taskId must be a non-empty string");
11620
+ const branchName = BRANCH_PREFIX + taskId;
11621
+ this._logger.debug({
11622
+ taskId,
11623
+ branchName,
11624
+ targetBranch
11625
+ }, "mergeWorktree");
11626
+ const conflictReport = await this.detectConflicts(taskId, targetBranch);
11627
+ if (conflictReport.hasConflicts) {
11628
+ this._logger.info({
11629
+ taskId,
11630
+ conflictCount: conflictReport.conflictingFiles.length
11631
+ }, "Merge skipped due to conflicts");
11632
+ return {
11633
+ success: false,
11634
+ mergedFiles: [],
11635
+ conflicts: conflictReport
11636
+ };
11637
+ }
11638
+ const mergeSuccess = await performMerge(branchName, this._projectRoot);
11639
+ if (!mergeSuccess) throw new Error(`mergeWorktree: git merge --no-ff failed for task "${taskId}" branch "${branchName}"`);
11640
+ const mergedFiles = await getMergedFiles(this._projectRoot);
11641
+ this._eventBus.emit("worktree:merged", {
11642
+ taskId,
11643
+ branch: branchName,
11644
+ mergedFiles
11645
+ });
11646
+ const result = {
11647
+ success: true,
11648
+ mergedFiles
11649
+ };
11650
+ this._logger.info({
11651
+ taskId,
11652
+ branchName,
11653
+ mergedFileCount: mergedFiles.length
11654
+ }, "Worktree merged successfully");
11655
+ return result;
11656
+ }
11657
+ async listWorktrees() {
11658
+ this._logger.debug({
11659
+ projectRoot: this._projectRoot,
11660
+ baseDirectory: this._baseDirectory
11661
+ }, "listWorktrees");
11662
+ const worktreePaths = await getOrphanedWorktrees(this._projectRoot, this._baseDirectory);
11663
+ const results = [];
11664
+ for (const worktreePath of worktreePaths) {
11665
+ const taskId = path$1.basename(worktreePath);
11666
+ const branchName = BRANCH_PREFIX + taskId;
11667
+ let createdAt;
11668
+ try {
11669
+ const { stat: stat$2 } = await import("node:fs/promises");
11670
+ const stats = await stat$2(worktreePath);
11671
+ createdAt = stats.birthtime ?? stats.ctime;
11672
+ } catch {
11673
+ createdAt = new Date();
11674
+ }
11675
+ results.push({
11676
+ taskId,
11677
+ branchName,
11678
+ worktreePath,
11679
+ createdAt
11680
+ });
11681
+ }
11682
+ this._logger.debug({ count: results.length }, "listWorktrees: found worktrees");
11683
+ return results;
11684
+ }
11685
+ /** H4.2: current branch of the parent repo (undefined when detached/unreadable). */
11686
+ async _detectCurrentBranch() {
11687
+ try {
11688
+ const result = await spawnGit([
11689
+ "rev-parse",
11690
+ "--abbrev-ref",
11691
+ "HEAD"
11692
+ ], { cwd: this._projectRoot });
11693
+ const name = result.code === 0 ? result.stdout.trim() : "";
11694
+ return name !== "" && name !== "HEAD" ? name : void 0;
11695
+ } catch {
11696
+ return void 0;
11697
+ }
11698
+ }
11699
+ getWorktreePath(taskId) {
11700
+ return path$1.resolve(this._projectRoot, this._baseDirectory, taskId);
11701
+ }
11702
+ async verifyGitVersion() {
11703
+ try {
11704
+ await verifyGitVersion();
11705
+ } catch (err) {
11706
+ throw new Error(`GitWorktreeManager: git version check failed: ${String(err)}`);
11707
+ }
11708
+ }
11709
+ };
11710
+ function createGitWorktreeManager(options) {
11711
+ return new GitWorktreeManagerImpl(
11712
+ options.eventBus,
11713
+ options.projectRoot,
11714
+ // H4.2: single resolution point — explicit option, else `worktree.base`
11715
+ // from .substrate/config.yaml, else the external default.
11716
+ options.baseDirectory ?? resolveWorktreeBaseDirectory(options.projectRoot),
11717
+ options.db ?? null,
11718
+ options.logger,
11719
+ options.copyFiles
11720
+ );
11721
+ }
11722
+
10782
11723
  //#endregion
10783
11724
  //#region packages/core/dist/version-manager/update-checker.js
10784
11725
  /**
@@ -11097,9 +12038,9 @@ var VersionManagerImpl = class {
11097
12038
  try {
11098
12039
  const _require = createRequire(import.meta.url);
11099
12040
  const fs = _require("fs");
11100
- const path$1 = _require("path");
12041
+ const path$4 = _require("path");
11101
12042
  const yaml$1 = _require("js-yaml");
11102
- const configPath = path$1.join(process.cwd(), ".substrate", "config.yaml");
12043
+ const configPath = path$4.join(process.cwd(), ".substrate", "config.yaml");
11103
12044
  try {
11104
12045
  const raw = fs.readFileSync(configPath, "utf-8");
11105
12046
  configObj = yaml$1.load(raw) ?? {};
@@ -11341,7 +12282,7 @@ function createExperimenter(config, deps) {
11341
12282
  ], { cwd: config.projectRoot });
11342
12283
  return result.stdout.trim();
11343
12284
  }
11344
- async function createWorktree(worktreePath, branchName) {
12285
+ async function createWorktree$1(worktreePath, branchName) {
11345
12286
  const result = await git([
11346
12287
  "worktree",
11347
12288
  "add",
@@ -11351,7 +12292,7 @@ function createExperimenter(config, deps) {
11351
12292
  ], { cwd: config.projectRoot });
11352
12293
  if (result.code !== 0) throw new Error(`Failed to create worktree ${worktreePath}: ${result.stderr}`);
11353
12294
  }
11354
- async function removeWorktree(worktreePath) {
12295
+ async function removeWorktree$1(worktreePath) {
11355
12296
  const result = await git([
11356
12297
  "worktree",
11357
12298
  "remove",
@@ -11461,7 +12402,7 @@ function createExperimenter(config, deps) {
11461
12402
  try {
11462
12403
  currentPhase = "BRANCHING";
11463
12404
  log(`[experimenter] Creating worktree: ${worktreePath} on branch ${branchName}`);
11464
- await createWorktree(worktreePath, branchName);
12405
+ await createWorktree$1(worktreePath, branchName);
11465
12406
  worktreeCreated = true;
11466
12407
  currentPhase = "MODIFYING";
11467
12408
  const promptFile = resolvePromptFile(rec, worktreePath, config.pack);
@@ -11504,7 +12445,7 @@ function createExperimenter(config, deps) {
11504
12445
  log(`[experimenter] Error in phase ${currentPhase}: ${caughtError}`);
11505
12446
  } finally {
11506
12447
  if (worktreeCreated) try {
11507
- await removeWorktree(worktreePath);
12448
+ await removeWorktree$1(worktreePath);
11508
12449
  } catch {
11509
12450
  log(`[experimenter] Warning: could not remove worktree ${worktreePath}`);
11510
12451
  }
@@ -11653,5 +12594,5 @@ async function callLLM(params) {
11653
12594
  }
11654
12595
 
11655
12596
  //#endregion
11656
- export { ADVISORY_NOTES, AdapterRegistry, AdtError, BudgetConfigSchema, CLAUDE_AUTH_FAILURE_HINT, CODEX_SANDBOX_BLOCK_HINT, CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, Categorizer, ClaudeCodeAdapter, CodexCLIAdapter, ConfigError, ConfigIncompatibleFormatError, ConsumerAnalyzer, CostTrackerConfigSchema, DEFAULT_CONFIG, DEFAULT_GLOBAL_SETTINGS, DispatcherImpl, DoltClient, DoltNotInstalled, DoltQueryError, ESCALATION_DIAGNOSIS, EXPERIMENT_RESULT, EfficiencyScorer, GeminiCLIAdapter, GlobalSettingsSchema, InMemoryDatabaseAdapter, IngestionServer, LEARNING_FINDING, LogTurnAnalyzer, ModelRoutingConfigSchema, MonitorDatabaseImpl, OPERATIONAL_FINDING, PartialGlobalSettingsSchema, PartialProviderConfigSchema, ProviderPolicySchema, ProvidersSchema, Recommender, RoutingConfigError, RoutingRecommender, RoutingResolver, RoutingTelemetry, RoutingTokenAccumulator, RoutingTuner, STORY_METRICS, STORY_OUTCOME, SubstrateConfigSchema, TASK_TYPE_PHASE_MAP, TEST_EXPANSION_FINDING, TEST_PLAN, TelemetryConfigSchema, TelemetryNormalizer, TelemetryPipeline, TurnAnalyzer, VersionManagerImpl, addTokenUsage, aggregateTokenUsageForRun, aggregateTokenUsageForStory, buildAuditLogEntry, buildBranchName, buildModificationDirective, buildPRBody, buildWorktreePath, callLLM, checkDoltInstalled, classifyVersionGap, compareRunMetrics, createAmendmentRun, createConfigSystem, createDatabaseAdapter as createDatabaseAdapter$1, createDecision, createExperimenter, createPipelineRun, createRequirement, createStderrLogger, createVersionManager, detectClaudeAuthFailure, detectCodexSandboxBlock, detectInterfaceChanges, determineVerdict, getActiveDecisions, getAllCostEntriesFiltered, getArtifactByTypeForRun, getArtifactsByRun, getBaselineRunMetrics, getDecisionsByCategory, getDecisionsByPhase, getDecisionsByPhaseForRun, getLatestCompletedRun, getLatestRun, getModelTier, getPipelineRunById, getPlanningCostTotal, getRetryableEscalations, getRunMetrics, getRunningPipelineRuns, getSessionCostSummary, getSessionCostSummaryFiltered, getStoryMetricsForRun, getTokenUsageSummary, incrementRunRestarts, initSchema, initWorkGraphSchema, initializeDolt, listRequirements, listRunMetrics, loadModelRoutingConfig, loadParentRunDecisions, registerArtifact, resolvePromptFile, supersedeDecision, swallowDebug, tagRunAsBaseline, updateDecision, updatePipelineRun, updatePipelineRunConfig, upsertDecision, writeRunMetrics, writeStoryMetrics };
11657
- //# sourceMappingURL=dist-CZk5SeSt.js.map
12597
+ export { ADVISORY_NOTES, AdapterRegistry, AdtError, BRANCH_PREFIX, BudgetConfigSchema, CLAUDE_AUTH_FAILURE_HINT, CODEX_SANDBOX_BLOCK_HINT, CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, Categorizer, ClaudeCodeAdapter, CodexCLIAdapter, ConfigError, ConfigIncompatibleFormatError, ConsumerAnalyzer, CostTrackerConfigSchema, DEFAULT_CONFIG, DEFAULT_GLOBAL_SETTINGS, DispatcherImpl, DoltClient, DoltNotInstalled, DoltQueryError, ESCALATION_DIAGNOSIS, EXPERIMENT_RESULT, EfficiencyScorer, GeminiCLIAdapter, GlobalSettingsSchema, InMemoryDatabaseAdapter, IngestionServer, LEARNING_FINDING, LogTurnAnalyzer, ModelRoutingConfigSchema, MonitorDatabaseImpl, OPERATIONAL_FINDING, PartialGlobalSettingsSchema, PartialProviderConfigSchema, ProviderPolicySchema, ProvidersSchema, Recommender, RoutingConfigError, RoutingRecommender, RoutingResolver, RoutingTelemetry, RoutingTokenAccumulator, RoutingTuner, STORY_METRICS, STORY_OUTCOME, SubstrateConfigSchema, TASK_TYPE_PHASE_MAP, TEST_EXPANSION_FINDING, TEST_PLAN, TelemetryConfigSchema, TelemetryNormalizer, TelemetryPipeline, TurnAnalyzer, VersionManagerImpl, addTokenUsage, aggregateTokenUsageForRun, aggregateTokenUsageForStory, buildAuditLogEntry, buildBranchName, buildModificationDirective, buildPRBody, buildWorktreePath, callLLM, checkDoltInstalled, classifyVersionGap, compareRunMetrics, createAmendmentRun, createConfigSystem, createDatabaseAdapter as createDatabaseAdapter$1, createDecision, createExperimenter, createGitWorktreeManager, createPipelineRun, createRequirement, createStderrLogger, createVersionManager, detectClaudeAuthFailure, detectCodexSandboxBlock, detectInterfaceChanges, determineVerdict, getActiveDecisions, getAllCostEntriesFiltered, getArtifactByTypeForRun, getArtifactsByRun, getBaselineRunMetrics, getDecisionsByCategory, getDecisionsByPhase, getDecisionsByPhaseForRun, getLatestCompletedRun, getLatestRun, getModelTier, getPipelineRunById, getPlanningCostTotal, getRetryableEscalations, getRunMetrics, getRunningPipelineRuns, getSessionCostSummary, getSessionCostSummaryFiltered, getStoryMetricsForRun, getTokenUsageSummary, incrementRunRestarts, initSchema, initWorkGraphSchema, initializeDolt, listRequirements, listRunMetrics, loadModelRoutingConfig, loadParentRunDecisions, registerArtifact, resolvePromptFile, supersedeDecision, swallowDebug, tagRunAsBaseline, updateDecision, updatePipelineRun, updatePipelineRunConfig, upsertDecision, writeRunMetrics, writeStoryMetrics };
12598
+ //# sourceMappingURL=dist-C6pEdj12.js.map