substrate-ai 0.20.147 → 0.20.149
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/dist/adapter-registry-CauYNA1U.js +4 -0
- package/dist/cli/index.js +42 -26
- package/dist/{decisions-qFnqErZ5.js → decisions-De43bvYq.js} +1 -1
- package/dist/{dist-DT378h1M.js → dist-C2CAZDep.js} +912 -27
- package/dist/{errors-BGE3kaH8.js → errors-Db8EIM9w.js} +2 -2
- package/dist/{experimenter-COLnRT8i.js → experimenter-Dm5jUPO6.js} +1 -1
- package/dist/{health-nQ_Txd5T.js → health-ByQadq3J.js} +3 -3
- package/dist/{health-BJoVPIyR.js → health-WGcvcIYK.js} +3 -3
- package/dist/{index-DaBYDiWn.d.ts → index-DxeIRBBf.d.ts} +10 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +3 -3
- package/dist/{interactive-prompt-xJ3K-IUQ.js → interactive-prompt-CMBlj6UF.js} +2 -2
- package/dist/{manifest-read-DlWwZpfO.js → manifest-read-knD2uWkz.js} +2 -2
- package/dist/modules/interactive-prompt/index.js +3 -3
- package/dist/{routing-Dz4GQta5.js → routing-B3sgH5MZ.js} +1 -1
- package/dist/{run-BBVZdkEo.js → run-BAUlPom3.js} +210 -838
- package/dist/{run-D3mhgkIL.js → run-CtIc7_rK.js} +5 -5
- package/dist/src/modules/recovery-engine/index.d.ts +1 -1
- package/dist/{upgrade-V0xYGkQs.js → upgrade-B3iamwjO.js} +2 -2
- package/dist/{upgrade-BVHnUYUd.js → upgrade-DOmKCyOY.js} +2 -2
- package/dist/{version-manager-impl-BWqvme_f.js → version-manager-impl-DizCvYy8.js} +1 -1
- package/package.json +1 -1
- package/dist/adapter-registry-SL7-DtOW.js +0 -4
- /package/dist/{adapter-registry-DIcrxjH8.js → adapter-registry-DXLMTmfD.js} +0 -0
|
@@ -4,13 +4,15 @@ 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$2 from "node:path";
|
|
8
|
+
import * as path$1 from "node:path";
|
|
7
9
|
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";
|
|
10
|
+
import os, { freemem, homedir, platform } from "node:os";
|
|
9
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
12
|
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";
|
|
13
|
+
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
14
|
import { readFileSync as readFileSync$1, writeFileSync as writeFileSync$1 } from "fs";
|
|
13
|
-
import { homedir } from "os";
|
|
15
|
+
import { homedir as homedir$1 } from "os";
|
|
14
16
|
import { createServer } from "node:http";
|
|
15
17
|
import { createGunzip, createInflate } from "node:zlib";
|
|
16
18
|
import { promisify } from "node:util";
|
|
@@ -919,6 +921,26 @@ function createStderrLogger(prefix) {
|
|
|
919
921
|
|
|
920
922
|
//#endregion
|
|
921
923
|
//#region packages/core/dist/dispatch/dispatcher-impl.js
|
|
924
|
+
const WORKTREE_COUPLED_TASK_TYPES = new Set([
|
|
925
|
+
"create-story",
|
|
926
|
+
"dev-story",
|
|
927
|
+
"code-review",
|
|
928
|
+
"minor-fixes",
|
|
929
|
+
"major-rework",
|
|
930
|
+
"build-fix",
|
|
931
|
+
"test-plan",
|
|
932
|
+
"test-expansion",
|
|
933
|
+
"probe-author"
|
|
934
|
+
]);
|
|
935
|
+
const GIT_STATE_ENV_KEYS = [
|
|
936
|
+
"PWD",
|
|
937
|
+
"OLDPWD",
|
|
938
|
+
"INIT_CWD",
|
|
939
|
+
"GIT_DIR",
|
|
940
|
+
"GIT_WORK_TREE",
|
|
941
|
+
"GIT_INDEX_FILE",
|
|
942
|
+
"GIT_COMMON_DIR"
|
|
943
|
+
];
|
|
922
944
|
const SHUTDOWN_GRACE_MS = 1e4;
|
|
923
945
|
const SHUTDOWN_MAX_WAIT_MS = 3e4;
|
|
924
946
|
const CHARS_PER_TOKEN$3 = 4;
|
|
@@ -1242,6 +1264,29 @@ var DispatcherImpl = class {
|
|
|
1242
1264
|
});
|
|
1243
1265
|
return;
|
|
1244
1266
|
}
|
|
1267
|
+
if (workingDirectory === void 0 && WORKTREE_COUPLED_TASK_TYPES.has(taskType)) {
|
|
1268
|
+
this._logger.error({
|
|
1269
|
+
id,
|
|
1270
|
+
agent,
|
|
1271
|
+
taskType
|
|
1272
|
+
}, "dispatch rejected: workingDirectory is required for worktree-coupled task types (H4.1)");
|
|
1273
|
+
this._running.delete(id);
|
|
1274
|
+
this._drainQueue();
|
|
1275
|
+
resolve$2({
|
|
1276
|
+
id,
|
|
1277
|
+
status: "failed",
|
|
1278
|
+
exitCode: -1,
|
|
1279
|
+
output: "",
|
|
1280
|
+
parsed: null,
|
|
1281
|
+
parseError: `workingDirectory is required for taskType "${taskType}" — coding-pipeline dispatches must name their worktree explicitly (H4.1 AC2)`,
|
|
1282
|
+
durationMs: 0,
|
|
1283
|
+
tokenEstimate: {
|
|
1284
|
+
input: Math.ceil(prompt.length / CHARS_PER_TOKEN$3),
|
|
1285
|
+
output: 0
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1245
1290
|
const worktreePath = workingDirectory ?? process.cwd();
|
|
1246
1291
|
const resolvedMaxTurns = maxTurns ?? DEFAULT_MAX_TURNS[taskType];
|
|
1247
1292
|
const capabilities = adapter.getCapabilities();
|
|
@@ -1265,6 +1310,8 @@ var DispatcherImpl = class {
|
|
|
1265
1310
|
const env = { ...process.env };
|
|
1266
1311
|
const parentNodeOpts = env["NODE_OPTIONS"] ?? "";
|
|
1267
1312
|
if (!parentNodeOpts.includes("--max-old-space-size")) env["NODE_OPTIONS"] = `${parentNodeOpts} --max-old-space-size=512`.trim();
|
|
1313
|
+
for (const key of GIT_STATE_ENV_KEYS) delete env[key];
|
|
1314
|
+
env["GIT_CEILING_DIRECTORIES"] = dirname$1(worktreePath);
|
|
1268
1315
|
if (cmd.env !== void 0) Object.assign(env, cmd.env);
|
|
1269
1316
|
if (cmd.unsetEnvKeys !== void 0) for (const key of cmd.unsetEnvKeys) delete env[key];
|
|
1270
1317
|
const proc = spawn(cmd.binary, cmd.args, {
|
|
@@ -6233,7 +6280,10 @@ const TelemetryConfigSchema = z.object({
|
|
|
6233
6280
|
meshUrl: z.string().url().optional(),
|
|
6234
6281
|
projectId: z.string().optional()
|
|
6235
6282
|
}).strict();
|
|
6236
|
-
const WorktreeConfigSchema = z.object({
|
|
6283
|
+
const WorktreeConfigSchema = z.object({
|
|
6284
|
+
copy_files: z.array(z.string()).default([]),
|
|
6285
|
+
base: z.enum(["in-repo", "external"]).optional()
|
|
6286
|
+
}).strict();
|
|
6237
6287
|
/** Current supported config format version */
|
|
6238
6288
|
const CURRENT_CONFIG_FORMAT_VERSION = "1";
|
|
6239
6289
|
/** Current supported task graph version */
|
|
@@ -6252,11 +6302,15 @@ const SubstrateConfigSchema = z.object({
|
|
|
6252
6302
|
telemetry: TelemetryConfigSchema.optional(),
|
|
6253
6303
|
worktree: WorktreeConfigSchema.optional(),
|
|
6254
6304
|
trivialOutputThreshold: z.number().int().nonnegative().optional(),
|
|
6255
|
-
finalization: z.object({
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6305
|
+
finalization: z.object({
|
|
6306
|
+
mode: z.enum([
|
|
6307
|
+
"merge",
|
|
6308
|
+
"branch",
|
|
6309
|
+
"pr"
|
|
6310
|
+
]).optional(),
|
|
6311
|
+
merge_strategy: z.enum(["ff-only", "three-way"]).optional(),
|
|
6312
|
+
epic_gate_command: z.string().optional()
|
|
6313
|
+
}).strict().optional()
|
|
6260
6314
|
}).passthrough();
|
|
6261
6315
|
const PartialProviderConfigSchema = ProviderConfigSchema.partial();
|
|
6262
6316
|
const PartialGlobalSettingsSchema = GlobalSettingsSchema.partial();
|
|
@@ -6274,11 +6328,15 @@ const PartialSubstrateConfigSchema = z.object({
|
|
|
6274
6328
|
telemetry: TelemetryConfigSchema.partial().optional(),
|
|
6275
6329
|
worktree: WorktreeConfigSchema.partial().optional(),
|
|
6276
6330
|
trivialOutputThreshold: z.number().int().nonnegative().optional(),
|
|
6277
|
-
finalization: z.object({
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6331
|
+
finalization: z.object({
|
|
6332
|
+
mode: z.enum([
|
|
6333
|
+
"merge",
|
|
6334
|
+
"branch",
|
|
6335
|
+
"pr"
|
|
6336
|
+
]).optional(),
|
|
6337
|
+
merge_strategy: z.enum(["ff-only", "three-way"]).optional(),
|
|
6338
|
+
epic_gate_command: z.string().optional()
|
|
6339
|
+
}).strict().optional()
|
|
6282
6340
|
}).passthrough();
|
|
6283
6341
|
|
|
6284
6342
|
//#endregion
|
|
@@ -6610,8 +6668,8 @@ function readEnvOverrides(logger) {
|
|
|
6610
6668
|
/**
|
|
6611
6669
|
* Get a value from a nested object using dot-notation key.
|
|
6612
6670
|
*/
|
|
6613
|
-
function getByPath(obj, path$
|
|
6614
|
-
const parts = path$
|
|
6671
|
+
function getByPath(obj, path$3) {
|
|
6672
|
+
const parts = path$3.split(".");
|
|
6615
6673
|
let cursor = obj;
|
|
6616
6674
|
for (const part of parts) {
|
|
6617
6675
|
if (cursor === null || cursor === void 0 || typeof cursor !== "object") return void 0;
|
|
@@ -6623,8 +6681,8 @@ function getByPath(obj, path$1) {
|
|
|
6623
6681
|
* Return a deep clone of `obj` with `path` set to `value`.
|
|
6624
6682
|
* Creates intermediate objects as needed.
|
|
6625
6683
|
*/
|
|
6626
|
-
function setByPath(obj, path$
|
|
6627
|
-
const parts = path$
|
|
6684
|
+
function setByPath(obj, path$3, value) {
|
|
6685
|
+
const parts = path$3.split(".");
|
|
6628
6686
|
const result = { ...obj };
|
|
6629
6687
|
let cursor = result;
|
|
6630
6688
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
@@ -6646,7 +6704,7 @@ var ConfigSystemImpl = class {
|
|
|
6646
6704
|
_logger;
|
|
6647
6705
|
constructor(options = {}) {
|
|
6648
6706
|
this._projectConfigDir = options.projectConfigDir ? resolve(options.projectConfigDir) : resolve(process.cwd(), ".substrate");
|
|
6649
|
-
this._globalConfigDir = options.globalConfigDir ? resolve(options.globalConfigDir) : resolve(homedir(), ".substrate");
|
|
6707
|
+
this._globalConfigDir = options.globalConfigDir ? resolve(options.globalConfigDir) : resolve(homedir$1(), ".substrate");
|
|
6650
6708
|
this._cliOverrides = options.cliOverrides ?? {};
|
|
6651
6709
|
this._logger = options.logger ?? createStderrLogger("config-system");
|
|
6652
6710
|
}
|
|
@@ -10771,6 +10829,833 @@ const CompileResultSchema = z.object({
|
|
|
10771
10829
|
truncated: z.boolean()
|
|
10772
10830
|
});
|
|
10773
10831
|
|
|
10832
|
+
//#endregion
|
|
10833
|
+
//#region packages/core/dist/git/git-utils.js
|
|
10834
|
+
const MIN_GIT_MAJOR = 2;
|
|
10835
|
+
const MIN_GIT_MINOR = 20;
|
|
10836
|
+
/**
|
|
10837
|
+
* Spawn a git subprocess with the given args.
|
|
10838
|
+
*
|
|
10839
|
+
* @param args - Arguments to pass to git (e.g., ['worktree', 'add', ...])
|
|
10840
|
+
* @param options - Optional spawn options (cwd, env)
|
|
10841
|
+
* @returns - Object with stdout, stderr, and exit code
|
|
10842
|
+
*/
|
|
10843
|
+
function spawnGit(args, options) {
|
|
10844
|
+
return new Promise((resolve$2) => {
|
|
10845
|
+
const proc = spawn("git", args, {
|
|
10846
|
+
cwd: options?.cwd,
|
|
10847
|
+
env: options?.env ?? process.env,
|
|
10848
|
+
stdio: [
|
|
10849
|
+
"ignore",
|
|
10850
|
+
"pipe",
|
|
10851
|
+
"pipe"
|
|
10852
|
+
]
|
|
10853
|
+
});
|
|
10854
|
+
let stdout = "";
|
|
10855
|
+
let stderr = "";
|
|
10856
|
+
proc.stdout?.on("data", (chunk) => {
|
|
10857
|
+
stdout += chunk.toString();
|
|
10858
|
+
});
|
|
10859
|
+
proc.stderr?.on("data", (chunk) => {
|
|
10860
|
+
stderr += chunk.toString();
|
|
10861
|
+
});
|
|
10862
|
+
proc.on("close", (code) => {
|
|
10863
|
+
resolve$2({
|
|
10864
|
+
stdout: stdout.trim(),
|
|
10865
|
+
stderr: stderr.trim(),
|
|
10866
|
+
code: code ?? 1
|
|
10867
|
+
});
|
|
10868
|
+
});
|
|
10869
|
+
proc.on("error", (err) => {
|
|
10870
|
+
resolve$2({
|
|
10871
|
+
stdout: "",
|
|
10872
|
+
stderr: err.message,
|
|
10873
|
+
code: 1
|
|
10874
|
+
});
|
|
10875
|
+
});
|
|
10876
|
+
});
|
|
10877
|
+
}
|
|
10878
|
+
/**
|
|
10879
|
+
* Get the installed git version string.
|
|
10880
|
+
*
|
|
10881
|
+
* @returns Version string like "2.42.0"
|
|
10882
|
+
* @throws Error if git is not installed or version cannot be parsed
|
|
10883
|
+
*/
|
|
10884
|
+
async function getGitVersion() {
|
|
10885
|
+
const result = await spawnGit(["--version"]);
|
|
10886
|
+
if (result.code !== 0) throw new Error(`git --version failed: ${result.stderr}`);
|
|
10887
|
+
const match = /git version\s+(\d+\.\d+(?:\.\d+)?)/.exec(result.stdout);
|
|
10888
|
+
if (match === null || match[1] === void 0) throw new Error(`Unable to parse git version from output: "${result.stdout}"`);
|
|
10889
|
+
return match[1];
|
|
10890
|
+
}
|
|
10891
|
+
/**
|
|
10892
|
+
* Parse a git version string into major/minor/patch components.
|
|
10893
|
+
*
|
|
10894
|
+
* @param versionString - Version string like "2.42.0" or "2.20"
|
|
10895
|
+
* @returns - Parsed version components
|
|
10896
|
+
*/
|
|
10897
|
+
function parseGitVersion(versionString) {
|
|
10898
|
+
const parts = versionString.split(".").map(Number);
|
|
10899
|
+
return {
|
|
10900
|
+
major: parts[0] ?? 0,
|
|
10901
|
+
minor: parts[1] ?? 0,
|
|
10902
|
+
patch: parts[2] ?? 0
|
|
10903
|
+
};
|
|
10904
|
+
}
|
|
10905
|
+
/**
|
|
10906
|
+
* Check if the given git version string is >= 2.20.0.
|
|
10907
|
+
*
|
|
10908
|
+
* @param version - Version string like "2.42.0"
|
|
10909
|
+
* @returns - true if version >= 2.20.0
|
|
10910
|
+
*/
|
|
10911
|
+
function isGitVersionSupported(version) {
|
|
10912
|
+
const { major, minor } = parseGitVersion(version);
|
|
10913
|
+
if (major > MIN_GIT_MAJOR) return true;
|
|
10914
|
+
if (major === MIN_GIT_MAJOR && minor >= MIN_GIT_MINOR) return true;
|
|
10915
|
+
return false;
|
|
10916
|
+
}
|
|
10917
|
+
/**
|
|
10918
|
+
* Verify that git is installed and version >= 2.20.
|
|
10919
|
+
*
|
|
10920
|
+
* @throws Error with clear message if git is not installed or too old
|
|
10921
|
+
*/
|
|
10922
|
+
async function verifyGitVersion() {
|
|
10923
|
+
let version;
|
|
10924
|
+
try {
|
|
10925
|
+
version = await getGitVersion();
|
|
10926
|
+
} catch (err) {
|
|
10927
|
+
throw new Error(`git is not installed or could not be executed. Please install git 2.20 or newer. Details: ${String(err)}`);
|
|
10928
|
+
}
|
|
10929
|
+
if (!isGitVersionSupported(version)) {
|
|
10930
|
+
const { major, minor, patch } = parseGitVersion(version);
|
|
10931
|
+
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`);
|
|
10932
|
+
}
|
|
10933
|
+
}
|
|
10934
|
+
/**
|
|
10935
|
+
* Decide whether a registered worktree from a prior dispatch can be reclaimed
|
|
10936
|
+
* for a fresh re-run. Safe only when there is nothing to lose: no uncommitted
|
|
10937
|
+
* changes AND no commits on the branch beyond the base branch. A negative
|
|
10938
|
+
* `commitsAhead` means the ahead-count could not be determined → not safe.
|
|
10939
|
+
*
|
|
10940
|
+
* Pure + exported for testing (the git I/O that produces the inputs lives in
|
|
10941
|
+
* createWorktree).
|
|
10942
|
+
*/
|
|
10943
|
+
function decideWorktreeReclaim(hasUncommittedChanges, commitsAhead, baseBranch) {
|
|
10944
|
+
if (hasUncommittedChanges) return {
|
|
10945
|
+
safe: false,
|
|
10946
|
+
reason: "it has uncommitted changes that are NOT on the branch"
|
|
10947
|
+
};
|
|
10948
|
+
if (commitsAhead > 0) return {
|
|
10949
|
+
safe: false,
|
|
10950
|
+
reason: `its branch has ${String(commitsAhead)} commit(s) beyond ${baseBranch}`
|
|
10951
|
+
};
|
|
10952
|
+
if (commitsAhead < 0) return {
|
|
10953
|
+
safe: false,
|
|
10954
|
+
reason: "its state could not be verified as safe to discard"
|
|
10955
|
+
};
|
|
10956
|
+
return { safe: true };
|
|
10957
|
+
}
|
|
10958
|
+
/**
|
|
10959
|
+
* Decide whether removing a story worktree AND deleting its branch is safe
|
|
10960
|
+
* (H0.3, field findings #17/#19). Unsafe when:
|
|
10961
|
+
* - the worktree has uncommitted changes (removal destroys the only copy), or
|
|
10962
|
+
* - the branch carries commits not reachable from the project's current HEAD
|
|
10963
|
+
* (branch -D destroys them — this is where H0.1's wip checkpoints live), or
|
|
10964
|
+
* - either state could not be determined (negative unmergedCommits).
|
|
10965
|
+
*
|
|
10966
|
+
* Pure + exported for testing (mirrors decideWorktreeReclaim; the git I/O that
|
|
10967
|
+
* produces the inputs lives in inspectWorktreeRemovalSafety).
|
|
10968
|
+
*/
|
|
10969
|
+
function decideWorktreeRemoval(hasUncommittedChanges, uncommittedFiles, unmergedCommits, branchName) {
|
|
10970
|
+
const reasons = [];
|
|
10971
|
+
if (hasUncommittedChanges) {
|
|
10972
|
+
const preview = uncommittedFiles.slice(0, 10).join(", ");
|
|
10973
|
+
const more = uncommittedFiles.length > 10 ? ` (+${String(uncommittedFiles.length - 10)} more)` : "";
|
|
10974
|
+
reasons.push(`the worktree has ${String(uncommittedFiles.length)} uncommitted change(s) that removal would destroy` + (preview.length > 0 ? `: ${preview}${more}` : ""));
|
|
10975
|
+
}
|
|
10976
|
+
if (unmergedCommits > 0) reasons.push(`branch ${branchName} carries ${String(unmergedCommits)} commit(s) not reachable from the current HEAD — deleting the branch would destroy them`);
|
|
10977
|
+
if (unmergedCommits < 0) reasons.push("the branch state could not be verified as safe to discard");
|
|
10978
|
+
return {
|
|
10979
|
+
safe: reasons.length === 0,
|
|
10980
|
+
reasons
|
|
10981
|
+
};
|
|
10982
|
+
}
|
|
10983
|
+
/**
|
|
10984
|
+
* Gather the inputs for `decideWorktreeRemoval` from git. Thin I/O wrapper:
|
|
10985
|
+
* - `git status --porcelain` inside the worktree (skipped when the directory
|
|
10986
|
+
* is missing — nothing on disk to lose)
|
|
10987
|
+
* - `git rev-list --count HEAD..<branch>` in the project root (commits the
|
|
10988
|
+
* branch has that the current checkout does not; 0 when the branch is
|
|
10989
|
+
* merged or absent, -1 when the count could not be determined)
|
|
10990
|
+
*/
|
|
10991
|
+
async function inspectWorktreeRemovalSafety(worktreePath, projectRoot, branchName) {
|
|
10992
|
+
let hasUncommittedChanges = false;
|
|
10993
|
+
let uncommittedFiles = [];
|
|
10994
|
+
const worktreeOnDisk = await access$1(worktreePath).then(() => true).catch(() => false);
|
|
10995
|
+
if (worktreeOnDisk) {
|
|
10996
|
+
const statusResult = await spawnGit(["status", "--porcelain"], { cwd: worktreePath });
|
|
10997
|
+
if (statusResult.code === 0) {
|
|
10998
|
+
uncommittedFiles = statusResult.stdout.split("\n").filter((line) => line.trim().length > 0).map((line) => line.slice(3).trim());
|
|
10999
|
+
hasUncommittedChanges = uncommittedFiles.length > 0;
|
|
11000
|
+
} else return decideWorktreeRemoval(false, [], -1, branchName);
|
|
11001
|
+
}
|
|
11002
|
+
const branchExists = (await spawnGit([
|
|
11003
|
+
"rev-parse",
|
|
11004
|
+
"--verify",
|
|
11005
|
+
branchName
|
|
11006
|
+
], { cwd: projectRoot })).code === 0;
|
|
11007
|
+
let unmergedCommits = 0;
|
|
11008
|
+
if (branchExists) {
|
|
11009
|
+
const aheadResult = await spawnGit([
|
|
11010
|
+
"rev-list",
|
|
11011
|
+
"--count",
|
|
11012
|
+
`HEAD..${branchName}`
|
|
11013
|
+
], { cwd: projectRoot });
|
|
11014
|
+
unmergedCommits = aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : -1;
|
|
11015
|
+
}
|
|
11016
|
+
return decideWorktreeRemoval(hasUncommittedChanges, uncommittedFiles, unmergedCommits, branchName);
|
|
11017
|
+
}
|
|
11018
|
+
async function createWorktree(projectRoot, taskId, branchName, baseBranch, copyFiles = [], baseDirectory = ".substrate-worktrees") {
|
|
11019
|
+
const worktreePath = path$2.resolve(projectRoot, baseDirectory, taskId);
|
|
11020
|
+
await mkdir$1(path$2.dirname(worktreePath), { recursive: true });
|
|
11021
|
+
const worktreeExists = await access$1(worktreePath).then(() => true).catch((err) => {
|
|
11022
|
+
if (err.code === "ENOENT") return false;
|
|
11023
|
+
throw err;
|
|
11024
|
+
});
|
|
11025
|
+
if (worktreeExists) {
|
|
11026
|
+
const listResult = await spawnGit([
|
|
11027
|
+
"worktree",
|
|
11028
|
+
"list",
|
|
11029
|
+
"--porcelain"
|
|
11030
|
+
], { cwd: projectRoot });
|
|
11031
|
+
const registeredPaths = listResult.stdout.split("\n").filter((line) => line.startsWith("worktree ")).map((line) => line.slice(9).trim());
|
|
11032
|
+
const isRegistered = registeredPaths.includes(worktreePath);
|
|
11033
|
+
if (!isRegistered) await rm(worktreePath, {
|
|
11034
|
+
recursive: true,
|
|
11035
|
+
force: true
|
|
11036
|
+
});
|
|
11037
|
+
else {
|
|
11038
|
+
const statusResult = await spawnGit(["status", "--porcelain"], { cwd: worktreePath });
|
|
11039
|
+
const hasUncommittedChanges = statusResult.code === 0 && statusResult.stdout.trim().length > 0;
|
|
11040
|
+
const aheadResult = await spawnGit([
|
|
11041
|
+
"rev-list",
|
|
11042
|
+
"--count",
|
|
11043
|
+
`${baseBranch}..${branchName}`
|
|
11044
|
+
], { cwd: projectRoot });
|
|
11045
|
+
const commitsAhead = aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : -1;
|
|
11046
|
+
const decision = decideWorktreeReclaim(hasUncommittedChanges, commitsAhead, baseBranch);
|
|
11047
|
+
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`);
|
|
11048
|
+
await spawnGit([
|
|
11049
|
+
"worktree",
|
|
11050
|
+
"remove",
|
|
11051
|
+
"--force",
|
|
11052
|
+
worktreePath
|
|
11053
|
+
], { cwd: projectRoot });
|
|
11054
|
+
await spawnGit([
|
|
11055
|
+
"branch",
|
|
11056
|
+
"-D",
|
|
11057
|
+
branchName
|
|
11058
|
+
], { cwd: projectRoot });
|
|
11059
|
+
}
|
|
11060
|
+
}
|
|
11061
|
+
const addResult = await spawnGit([
|
|
11062
|
+
"worktree",
|
|
11063
|
+
"add",
|
|
11064
|
+
worktreePath,
|
|
11065
|
+
"-b",
|
|
11066
|
+
branchName,
|
|
11067
|
+
baseBranch
|
|
11068
|
+
], { cwd: projectRoot });
|
|
11069
|
+
if (addResult.code !== 0) throw new Error(`git worktree add failed for task "${taskId}": ${addResult.stderr || addResult.stdout}`);
|
|
11070
|
+
await copyFilesToWorktree(projectRoot, worktreePath, copyFiles);
|
|
11071
|
+
return { worktreePath };
|
|
11072
|
+
}
|
|
11073
|
+
/**
|
|
11074
|
+
* Copy files from a source directory into a target worktree.
|
|
11075
|
+
*
|
|
11076
|
+
* Skips missing files silently (intentional — config like `[".env",
|
|
11077
|
+
* ".env.local"]` should not blow up if `.env.local` doesn't exist in the
|
|
11078
|
+
* parent checkout). Creates parent directories for nested paths.
|
|
11079
|
+
*
|
|
11080
|
+
* Exported for testability; not part of the public worktree API.
|
|
11081
|
+
*/
|
|
11082
|
+
async function copyFilesToWorktree(sourceRoot, worktreePath, files) {
|
|
11083
|
+
if (files.length === 0) return;
|
|
11084
|
+
for (const relativePath of files) {
|
|
11085
|
+
if (path$2.isAbsolute(relativePath) || relativePath.split(path$2.sep).includes("..")) continue;
|
|
11086
|
+
const sourcePath = path$2.join(sourceRoot, relativePath);
|
|
11087
|
+
const destPath = path$2.join(worktreePath, relativePath);
|
|
11088
|
+
try {
|
|
11089
|
+
await access$1(sourcePath);
|
|
11090
|
+
} catch {
|
|
11091
|
+
continue;
|
|
11092
|
+
}
|
|
11093
|
+
const destDir = path$2.dirname(destPath);
|
|
11094
|
+
if (destDir !== worktreePath) await mkdir$1(destDir, { recursive: true });
|
|
11095
|
+
await copyFile(sourcePath, destPath);
|
|
11096
|
+
}
|
|
11097
|
+
}
|
|
11098
|
+
/**
|
|
11099
|
+
* Remove a git worktree by path.
|
|
11100
|
+
*
|
|
11101
|
+
* Uses `git worktree remove --force` to handle unclean worktrees.
|
|
11102
|
+
*
|
|
11103
|
+
* @param worktreePath - Absolute path to the worktree directory
|
|
11104
|
+
* @param projectRoot - Absolute path to the git repository root
|
|
11105
|
+
* @throws - Error if git command fails
|
|
11106
|
+
*/
|
|
11107
|
+
async function removeWorktree(worktreePath, projectRoot) {
|
|
11108
|
+
const spawnOpts = {};
|
|
11109
|
+
if (projectRoot !== void 0) spawnOpts.cwd = projectRoot;
|
|
11110
|
+
const result = await spawnGit([
|
|
11111
|
+
"worktree",
|
|
11112
|
+
"remove",
|
|
11113
|
+
"--force",
|
|
11114
|
+
worktreePath
|
|
11115
|
+
], spawnOpts);
|
|
11116
|
+
if (result.code !== 0) throw new Error(`git worktree remove failed for "${worktreePath}": ${result.stderr || result.stdout}`);
|
|
11117
|
+
}
|
|
11118
|
+
/**
|
|
11119
|
+
* Delete a git branch using `git branch -D`.
|
|
11120
|
+
*
|
|
11121
|
+
* @param branchName - Branch name to delete (e.g., "substrate/story-abc123")
|
|
11122
|
+
* @param projectRoot - Absolute path to the git repository root
|
|
11123
|
+
* @returns - true if branch was successfully deleted, false otherwise
|
|
11124
|
+
*/
|
|
11125
|
+
async function removeBranch(branchName, projectRoot) {
|
|
11126
|
+
const spawnOpts = {};
|
|
11127
|
+
if (projectRoot !== void 0) spawnOpts.cwd = projectRoot;
|
|
11128
|
+
const result = await spawnGit([
|
|
11129
|
+
"branch",
|
|
11130
|
+
"-D",
|
|
11131
|
+
branchName
|
|
11132
|
+
], spawnOpts);
|
|
11133
|
+
if (result.code !== 0) return false;
|
|
11134
|
+
return true;
|
|
11135
|
+
}
|
|
11136
|
+
/**
|
|
11137
|
+
* Scan the worktrees base directory and return paths of all found worktree directories.
|
|
11138
|
+
*
|
|
11139
|
+
* @param projectRoot - Absolute path to the git repository root
|
|
11140
|
+
* @param baseDirectory - Relative directory name for worktrees (default: '.substrate-worktrees')
|
|
11141
|
+
* @returns - Array of absolute worktree directory paths
|
|
11142
|
+
*/
|
|
11143
|
+
async function getOrphanedWorktrees(projectRoot, baseDirectory = ".substrate-worktrees") {
|
|
11144
|
+
const worktreesDir = path$2.resolve(projectRoot, baseDirectory);
|
|
11145
|
+
try {
|
|
11146
|
+
await access$1(worktreesDir);
|
|
11147
|
+
} catch {
|
|
11148
|
+
return [];
|
|
11149
|
+
}
|
|
11150
|
+
let entries;
|
|
11151
|
+
try {
|
|
11152
|
+
entries = await readdir$1(worktreesDir, { withFileTypes: true });
|
|
11153
|
+
} catch {
|
|
11154
|
+
return [];
|
|
11155
|
+
}
|
|
11156
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => path$2.join(worktreesDir, entry.name));
|
|
11157
|
+
}
|
|
11158
|
+
/**
|
|
11159
|
+
* Simulate a merge using git merge --no-commit --no-ff without committing.
|
|
11160
|
+
*
|
|
11161
|
+
* This runs in the target branch's working directory (the project root or
|
|
11162
|
+
* worktree path). The simulation must be aborted after checking conflicts
|
|
11163
|
+
* via abortMerge().
|
|
11164
|
+
*
|
|
11165
|
+
* @param branchName - The source branch to simulate merging
|
|
11166
|
+
* @param cwd - Working directory (must be on the target branch)
|
|
11167
|
+
* @returns - true if merge would be clean, false if there are conflicts
|
|
11168
|
+
*/
|
|
11169
|
+
async function simulateMerge(branchName, cwd) {
|
|
11170
|
+
const result = await spawnGit([
|
|
11171
|
+
"merge",
|
|
11172
|
+
"--no-commit",
|
|
11173
|
+
"--no-ff",
|
|
11174
|
+
branchName
|
|
11175
|
+
], { cwd });
|
|
11176
|
+
if (result.code === 0) return true;
|
|
11177
|
+
return false;
|
|
11178
|
+
}
|
|
11179
|
+
/**
|
|
11180
|
+
* Abort a merge in progress using git merge --abort.
|
|
11181
|
+
*
|
|
11182
|
+
* Should be called after simulateMerge() to clean up the merge state,
|
|
11183
|
+
* regardless of whether conflicts were found.
|
|
11184
|
+
*
|
|
11185
|
+
* @param cwd - Working directory (same as used for simulateMerge)
|
|
11186
|
+
*/
|
|
11187
|
+
async function abortMerge(cwd) {
|
|
11188
|
+
await spawnGit(["merge", "--abort"], { cwd });
|
|
11189
|
+
}
|
|
11190
|
+
/**
|
|
11191
|
+
* Get a list of files with conflicts during a merge.
|
|
11192
|
+
*
|
|
11193
|
+
* Must be called while a merge is in progress (after simulateMerge() and
|
|
11194
|
+
* before abortMerge()).
|
|
11195
|
+
*
|
|
11196
|
+
* @param cwd - Working directory of the repository
|
|
11197
|
+
* @returns - Array of conflicting file paths
|
|
11198
|
+
*/
|
|
11199
|
+
async function getConflictingFiles(cwd) {
|
|
11200
|
+
const result = await spawnGit([
|
|
11201
|
+
"diff",
|
|
11202
|
+
"--name-only",
|
|
11203
|
+
"--diff-filter=U"
|
|
11204
|
+
], { cwd });
|
|
11205
|
+
if (result.code !== 0) return [];
|
|
11206
|
+
if (result.stdout.trim() === "") return [];
|
|
11207
|
+
return result.stdout.trim().split("\n").filter((f) => f.trim().length > 0);
|
|
11208
|
+
}
|
|
11209
|
+
/**
|
|
11210
|
+
* Perform an actual merge using git merge --no-ff.
|
|
11211
|
+
*
|
|
11212
|
+
* Should only be called after detectConflicts() confirms there are no conflicts.
|
|
11213
|
+
* Creates a merge commit even if fast-forward is possible (--no-ff ensures history).
|
|
11214
|
+
*
|
|
11215
|
+
* @param branchName - The source branch to merge
|
|
11216
|
+
* @param cwd - Working directory (must be on the target branch)
|
|
11217
|
+
* @returns - true if merge succeeded, false otherwise
|
|
11218
|
+
*/
|
|
11219
|
+
async function performMerge(branchName, cwd) {
|
|
11220
|
+
const result = await spawnGit([
|
|
11221
|
+
"merge",
|
|
11222
|
+
"--no-ff",
|
|
11223
|
+
branchName
|
|
11224
|
+
], { cwd });
|
|
11225
|
+
if (result.code !== 0) return false;
|
|
11226
|
+
return true;
|
|
11227
|
+
}
|
|
11228
|
+
/**
|
|
11229
|
+
* Get a list of files changed in the most recent merge.
|
|
11230
|
+
*
|
|
11231
|
+
* Uses git diff --name-only HEAD~1..HEAD to find files in the merge commit.
|
|
11232
|
+
* Falls back to empty array if commit history is insufficient.
|
|
11233
|
+
*
|
|
11234
|
+
* @param cwd - Working directory of the repository
|
|
11235
|
+
* @returns - Array of file paths that were merged
|
|
11236
|
+
*/
|
|
11237
|
+
async function getMergedFiles(cwd) {
|
|
11238
|
+
const result = await spawnGit([
|
|
11239
|
+
"diff",
|
|
11240
|
+
"--name-only",
|
|
11241
|
+
"HEAD~1..HEAD"
|
|
11242
|
+
], { cwd });
|
|
11243
|
+
if (result.code !== 0) {
|
|
11244
|
+
const altResult = await spawnGit([
|
|
11245
|
+
"show",
|
|
11246
|
+
"--name-only",
|
|
11247
|
+
"--format=",
|
|
11248
|
+
"HEAD"
|
|
11249
|
+
], { cwd });
|
|
11250
|
+
if (altResult.code !== 0) return [];
|
|
11251
|
+
return altResult.stdout.trim().split("\n").filter((f) => f.trim().length > 0);
|
|
11252
|
+
}
|
|
11253
|
+
if (result.stdout.trim() === "") return [];
|
|
11254
|
+
return result.stdout.trim().split("\n").filter((f) => f.trim().length > 0);
|
|
11255
|
+
}
|
|
11256
|
+
|
|
11257
|
+
//#endregion
|
|
11258
|
+
//#region packages/core/dist/git/git-worktree-manager-impl.js
|
|
11259
|
+
/**
|
|
11260
|
+
* Branch name prefix for substrate per-story branches.
|
|
11261
|
+
*
|
|
11262
|
+
* Exported as the canonical source of truth so consumers (orchestrator,
|
|
11263
|
+
* integration tests, tooling) can compose branch names without
|
|
11264
|
+
* independently encoding the prefix. v0.20.82 production bug:
|
|
11265
|
+
* `orchestrator-impl.ts:4290` hardcoded `substrate/story-${storyKey}`
|
|
11266
|
+
* while this module created `substrate/task-${taskId}` — the resulting
|
|
11267
|
+
* merge-to-main looked at a non-existent branch. Recurrence prevention:
|
|
11268
|
+
* all branch-name construction MUST import this constant.
|
|
11269
|
+
*/
|
|
11270
|
+
const BRANCH_PREFIX = "substrate/story-";
|
|
11271
|
+
const DEFAULT_WORKTREE_BASE = ".substrate-worktrees";
|
|
11272
|
+
/**
|
|
11273
|
+
* H4.2 (AC2): resolve the worktree base directory for a project.
|
|
11274
|
+
*
|
|
11275
|
+
* 'external' (the NEW DEFAULT) puts worktrees OUTSIDE the parent tree at
|
|
11276
|
+
* `~/.substrate/worktrees/<projectname>-<hash8>/` — an agent inside its
|
|
11277
|
+
* worktree has no parent repo above it to leak into (composes with H4.1's
|
|
11278
|
+
* GIT_CEILING_DIRECTORIES, which points at this base). 'in-repo' retains the
|
|
11279
|
+
* pre-H4.2 `<projectRoot>/.substrate-worktrees/` for tooling that assumed
|
|
11280
|
+
* the old path (MIGRATION NOTE: reconcile-from-disk, editor bookmarks, and
|
|
11281
|
+
* scripts that globbed `.substrate-worktrees/` should use
|
|
11282
|
+
* `substrate worktrees list` or set `worktree.base: in-repo`).
|
|
11283
|
+
*
|
|
11284
|
+
* When `baseOverride` is not given, reads `worktree.base` from
|
|
11285
|
+
* `.substrate/config.yaml` directly (same no-threading pattern as
|
|
11286
|
+
* `resolveEpicsPathOverride`) so EVERY construction site — orchestrator,
|
|
11287
|
+
* `substrate merge`, `substrate worktrees` — resolves identically.
|
|
11288
|
+
*/
|
|
11289
|
+
function resolveWorktreeBaseDirectory(projectRoot, baseOverride) {
|
|
11290
|
+
let mode = baseOverride;
|
|
11291
|
+
if (mode === void 0) try {
|
|
11292
|
+
const raw = readFileSync(path$1.join(projectRoot, ".substrate", "config.yaml"), "utf-8");
|
|
11293
|
+
const parsed = yaml.load(raw);
|
|
11294
|
+
if (parsed?.worktree?.base === "in-repo" || parsed?.worktree?.base === "external") mode = parsed.worktree.base;
|
|
11295
|
+
} catch {}
|
|
11296
|
+
if ((mode ?? "external") === "in-repo") return DEFAULT_WORKTREE_BASE;
|
|
11297
|
+
const hash = createHash("sha256").update(path$1.resolve(projectRoot)).digest("hex").slice(0, 8);
|
|
11298
|
+
return path$1.join(homedir(), ".substrate", "worktrees", `${path$1.basename(projectRoot)}-${hash}`);
|
|
11299
|
+
}
|
|
11300
|
+
var GitWorktreeManagerImpl = class {
|
|
11301
|
+
_eventBus;
|
|
11302
|
+
_projectRoot;
|
|
11303
|
+
_baseDirectory;
|
|
11304
|
+
_db;
|
|
11305
|
+
_logger;
|
|
11306
|
+
/** v0.20.109: files to copy from project root into each new worktree (e.g. `.env`). */
|
|
11307
|
+
_copyFiles;
|
|
11308
|
+
/** Bound listener references for cleanup in shutdown() */
|
|
11309
|
+
_onTaskReady;
|
|
11310
|
+
_onTaskComplete;
|
|
11311
|
+
_onTaskFailed;
|
|
11312
|
+
constructor(eventBus, projectRoot, baseDirectory = DEFAULT_WORKTREE_BASE, db = null, logger, copyFiles = []) {
|
|
11313
|
+
this._eventBus = eventBus;
|
|
11314
|
+
this._projectRoot = projectRoot;
|
|
11315
|
+
this._baseDirectory = baseDirectory;
|
|
11316
|
+
this._db = db;
|
|
11317
|
+
this._logger = logger ?? createStderrLogger("git-worktree-manager");
|
|
11318
|
+
this._copyFiles = copyFiles;
|
|
11319
|
+
this._onTaskReady = ({ taskId }) => {
|
|
11320
|
+
this._handleTaskReady(taskId).catch((err) => {
|
|
11321
|
+
this._logger.error({
|
|
11322
|
+
taskId,
|
|
11323
|
+
err
|
|
11324
|
+
}, "Unhandled error in _handleTaskReady");
|
|
11325
|
+
});
|
|
11326
|
+
};
|
|
11327
|
+
this._onTaskComplete = ({ taskId }) => {
|
|
11328
|
+
this._handleTaskDone(taskId);
|
|
11329
|
+
};
|
|
11330
|
+
this._onTaskFailed = ({ taskId }) => {
|
|
11331
|
+
this._handleTaskDone(taskId);
|
|
11332
|
+
};
|
|
11333
|
+
}
|
|
11334
|
+
async initialize() {
|
|
11335
|
+
this._logger.info({ projectRoot: this._projectRoot }, "GitWorktreeManager.initialize()");
|
|
11336
|
+
await this.verifyGitVersion();
|
|
11337
|
+
const cleaned = await this.cleanupAllWorktrees();
|
|
11338
|
+
if (cleaned > 0) this._logger.info({ cleaned }, "Recovered orphaned worktrees on startup");
|
|
11339
|
+
this._eventBus.on("task:ready", this._onTaskReady);
|
|
11340
|
+
this._eventBus.on("task:complete", this._onTaskComplete);
|
|
11341
|
+
this._eventBus.on("task:failed", this._onTaskFailed);
|
|
11342
|
+
this._logger.info("GitWorktreeManager initialized");
|
|
11343
|
+
}
|
|
11344
|
+
async shutdown() {
|
|
11345
|
+
this._logger.info("GitWorktreeManager.shutdown()");
|
|
11346
|
+
this._eventBus.off("task:ready", this._onTaskReady);
|
|
11347
|
+
this._eventBus.off("task:complete", this._onTaskComplete);
|
|
11348
|
+
this._eventBus.off("task:failed", this._onTaskFailed);
|
|
11349
|
+
await this.cleanupAllWorktrees();
|
|
11350
|
+
this._logger.info("GitWorktreeManager shutdown complete");
|
|
11351
|
+
}
|
|
11352
|
+
async _handleTaskReady(taskId) {
|
|
11353
|
+
this._logger.debug({ taskId }, "task:ready — creating worktree");
|
|
11354
|
+
try {
|
|
11355
|
+
await this.createWorktree(taskId);
|
|
11356
|
+
} catch (err) {
|
|
11357
|
+
this._logger.error({
|
|
11358
|
+
taskId,
|
|
11359
|
+
err
|
|
11360
|
+
}, "Failed to create worktree for task");
|
|
11361
|
+
}
|
|
11362
|
+
}
|
|
11363
|
+
async _handleTaskDone(taskId) {
|
|
11364
|
+
this._logger.debug({ taskId }, "task done — cleaning up worktree");
|
|
11365
|
+
try {
|
|
11366
|
+
await this.cleanupWorktree(taskId);
|
|
11367
|
+
} catch (err) {
|
|
11368
|
+
this._logger.warn({
|
|
11369
|
+
taskId,
|
|
11370
|
+
err
|
|
11371
|
+
}, "Failed to cleanup worktree for task");
|
|
11372
|
+
}
|
|
11373
|
+
}
|
|
11374
|
+
async createWorktree(taskId, baseBranch) {
|
|
11375
|
+
if (!taskId || taskId.trim().length === 0) throw new Error("createWorktree: taskId must be a non-empty string");
|
|
11376
|
+
const resolvedBaseBranch = baseBranch ?? await this._detectCurrentBranch() ?? "main";
|
|
11377
|
+
const branchName = BRANCH_PREFIX + taskId;
|
|
11378
|
+
const worktreePath = this.getWorktreePath(taskId);
|
|
11379
|
+
this._logger.debug({
|
|
11380
|
+
taskId,
|
|
11381
|
+
branchName,
|
|
11382
|
+
worktreePath,
|
|
11383
|
+
baseBranch: resolvedBaseBranch,
|
|
11384
|
+
copyFiles: this._copyFiles
|
|
11385
|
+
}, "createWorktree");
|
|
11386
|
+
const copyFiles = this._copyFiles.includes(".substrate/project-profile.yaml") ? this._copyFiles : [...this._copyFiles, ".substrate/project-profile.yaml"];
|
|
11387
|
+
await createWorktree(this._projectRoot, taskId, branchName, resolvedBaseBranch, copyFiles, this._baseDirectory);
|
|
11388
|
+
const createdAt = new Date();
|
|
11389
|
+
this._eventBus.emit("worktree:created", {
|
|
11390
|
+
taskId,
|
|
11391
|
+
branchName,
|
|
11392
|
+
worktreePath,
|
|
11393
|
+
createdAt
|
|
11394
|
+
});
|
|
11395
|
+
const info = {
|
|
11396
|
+
taskId,
|
|
11397
|
+
branchName,
|
|
11398
|
+
worktreePath,
|
|
11399
|
+
createdAt
|
|
11400
|
+
};
|
|
11401
|
+
this._logger.info({
|
|
11402
|
+
taskId,
|
|
11403
|
+
branchName,
|
|
11404
|
+
worktreePath
|
|
11405
|
+
}, "Worktree created");
|
|
11406
|
+
return info;
|
|
11407
|
+
}
|
|
11408
|
+
async cleanupWorktree(taskId, opts) {
|
|
11409
|
+
const branchName = BRANCH_PREFIX + taskId;
|
|
11410
|
+
const worktreePath = this.getWorktreePath(taskId);
|
|
11411
|
+
this._logger.debug({
|
|
11412
|
+
taskId,
|
|
11413
|
+
branchName,
|
|
11414
|
+
worktreePath,
|
|
11415
|
+
force: opts?.force === true
|
|
11416
|
+
}, "cleanupWorktree");
|
|
11417
|
+
if (opts?.force !== true) {
|
|
11418
|
+
const decision = await inspectWorktreeRemovalSafety(worktreePath, this._projectRoot, branchName);
|
|
11419
|
+
const blockingReasons = opts?.keepBranch === true ? decision.reasons.filter((r) => r.includes("uncommitted")) : decision.reasons;
|
|
11420
|
+
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.`);
|
|
11421
|
+
}
|
|
11422
|
+
let worktreeExists = false;
|
|
11423
|
+
try {
|
|
11424
|
+
await access$1(worktreePath);
|
|
11425
|
+
worktreeExists = true;
|
|
11426
|
+
} catch {
|
|
11427
|
+
this._logger.debug({
|
|
11428
|
+
taskId,
|
|
11429
|
+
worktreePath
|
|
11430
|
+
}, "cleanupWorktree: worktree does not exist, skipping removal");
|
|
11431
|
+
}
|
|
11432
|
+
if (worktreeExists) try {
|
|
11433
|
+
await removeWorktree(worktreePath, this._projectRoot);
|
|
11434
|
+
} catch (err) {
|
|
11435
|
+
this._logger.warn({
|
|
11436
|
+
taskId,
|
|
11437
|
+
worktreePath,
|
|
11438
|
+
err
|
|
11439
|
+
}, "removeWorktree failed during cleanup");
|
|
11440
|
+
}
|
|
11441
|
+
if (opts?.keepBranch !== true) try {
|
|
11442
|
+
await removeBranch(branchName, this._projectRoot);
|
|
11443
|
+
} catch (err) {
|
|
11444
|
+
this._logger.warn({
|
|
11445
|
+
taskId,
|
|
11446
|
+
branchName,
|
|
11447
|
+
err
|
|
11448
|
+
}, "removeBranch failed during cleanup");
|
|
11449
|
+
}
|
|
11450
|
+
this._eventBus.emit("worktree:removed", {
|
|
11451
|
+
taskId,
|
|
11452
|
+
branchName
|
|
11453
|
+
});
|
|
11454
|
+
this._logger.info({
|
|
11455
|
+
taskId,
|
|
11456
|
+
branchName
|
|
11457
|
+
}, "Worktree cleaned up");
|
|
11458
|
+
}
|
|
11459
|
+
async cleanupAllWorktrees(opts) {
|
|
11460
|
+
this._logger.debug({
|
|
11461
|
+
projectRoot: this._projectRoot,
|
|
11462
|
+
force: opts?.force === true
|
|
11463
|
+
}, "cleanupAllWorktrees");
|
|
11464
|
+
const orphanedPaths = await getOrphanedWorktrees(this._projectRoot, this._baseDirectory);
|
|
11465
|
+
let cleaned = 0;
|
|
11466
|
+
for (const worktreePath of orphanedPaths) {
|
|
11467
|
+
const taskId = path$1.basename(worktreePath);
|
|
11468
|
+
const branchGuardName = BRANCH_PREFIX + taskId;
|
|
11469
|
+
const decision = opts?.force === true ? {
|
|
11470
|
+
safe: true,
|
|
11471
|
+
reasons: []
|
|
11472
|
+
} : await inspectWorktreeRemovalSafety(worktreePath, this._projectRoot, branchGuardName);
|
|
11473
|
+
if (!decision.safe) {
|
|
11474
|
+
this._logger.warn({
|
|
11475
|
+
taskId,
|
|
11476
|
+
worktreePath,
|
|
11477
|
+
reasons: decision.reasons
|
|
11478
|
+
}, "cleanupAllWorktrees: preserving worktree — removal would destroy work (use `substrate worktrees cleanup --force` to discard)");
|
|
11479
|
+
continue;
|
|
11480
|
+
}
|
|
11481
|
+
let worktreeRemoved = false;
|
|
11482
|
+
try {
|
|
11483
|
+
await removeWorktree(worktreePath, this._projectRoot);
|
|
11484
|
+
worktreeRemoved = true;
|
|
11485
|
+
this._logger.debug({
|
|
11486
|
+
taskId,
|
|
11487
|
+
worktreePath
|
|
11488
|
+
}, "cleanupAllWorktrees: removed orphaned worktree");
|
|
11489
|
+
} catch (err) {
|
|
11490
|
+
this._logger.warn({
|
|
11491
|
+
taskId,
|
|
11492
|
+
worktreePath,
|
|
11493
|
+
err
|
|
11494
|
+
}, "cleanupAllWorktrees: failed to remove worktree");
|
|
11495
|
+
}
|
|
11496
|
+
const branchName = BRANCH_PREFIX + taskId;
|
|
11497
|
+
try {
|
|
11498
|
+
const branchRemoved = await removeBranch(branchName, this._projectRoot);
|
|
11499
|
+
if (branchRemoved) this._logger.debug({
|
|
11500
|
+
taskId,
|
|
11501
|
+
branchName
|
|
11502
|
+
}, "cleanupAllWorktrees: removed orphaned branch");
|
|
11503
|
+
} catch (err) {
|
|
11504
|
+
this._logger.warn({
|
|
11505
|
+
taskId,
|
|
11506
|
+
branchName,
|
|
11507
|
+
err
|
|
11508
|
+
}, "cleanupAllWorktrees: failed to remove branch");
|
|
11509
|
+
}
|
|
11510
|
+
if (worktreeRemoved) cleaned++;
|
|
11511
|
+
}
|
|
11512
|
+
if (cleaned > 0) this._logger.info({ cleaned }, "cleanupAllWorktrees: recovered orphaned worktrees");
|
|
11513
|
+
return cleaned;
|
|
11514
|
+
}
|
|
11515
|
+
async detectConflicts(taskId, targetBranch = "main") {
|
|
11516
|
+
if (!taskId || taskId.trim().length === 0) throw new Error("detectConflicts: taskId must be a non-empty string");
|
|
11517
|
+
const branchName = BRANCH_PREFIX + taskId;
|
|
11518
|
+
const worktreePath = this.getWorktreePath(taskId);
|
|
11519
|
+
this._logger.debug({
|
|
11520
|
+
taskId,
|
|
11521
|
+
branchName,
|
|
11522
|
+
targetBranch
|
|
11523
|
+
}, "detectConflicts");
|
|
11524
|
+
try {
|
|
11525
|
+
await access$1(worktreePath);
|
|
11526
|
+
} catch {
|
|
11527
|
+
throw new Error(`detectConflicts: Worktree for task "${taskId}" not found at "${worktreePath}". The worktree may have already been cleaned up.`);
|
|
11528
|
+
}
|
|
11529
|
+
const mergeClean = await simulateMerge(branchName, this._projectRoot);
|
|
11530
|
+
let conflictingFiles = [];
|
|
11531
|
+
try {
|
|
11532
|
+
if (!mergeClean) conflictingFiles = await getConflictingFiles(this._projectRoot);
|
|
11533
|
+
} finally {
|
|
11534
|
+
await abortMerge(this._projectRoot);
|
|
11535
|
+
}
|
|
11536
|
+
const report = {
|
|
11537
|
+
hasConflicts: !mergeClean || conflictingFiles.length > 0,
|
|
11538
|
+
conflictingFiles,
|
|
11539
|
+
taskId,
|
|
11540
|
+
targetBranch
|
|
11541
|
+
};
|
|
11542
|
+
if (report.hasConflicts) this._eventBus.emit("worktree:conflict", {
|
|
11543
|
+
taskId,
|
|
11544
|
+
branch: branchName,
|
|
11545
|
+
conflictingFiles: report.conflictingFiles
|
|
11546
|
+
});
|
|
11547
|
+
this._logger.info({
|
|
11548
|
+
taskId,
|
|
11549
|
+
hasConflicts: report.hasConflicts,
|
|
11550
|
+
conflictCount: conflictingFiles.length
|
|
11551
|
+
}, "Conflict detection complete");
|
|
11552
|
+
return report;
|
|
11553
|
+
}
|
|
11554
|
+
async mergeWorktree(taskId, targetBranch = "main") {
|
|
11555
|
+
if (!taskId || taskId.trim().length === 0) throw new Error("mergeWorktree: taskId must be a non-empty string");
|
|
11556
|
+
const branchName = BRANCH_PREFIX + taskId;
|
|
11557
|
+
this._logger.debug({
|
|
11558
|
+
taskId,
|
|
11559
|
+
branchName,
|
|
11560
|
+
targetBranch
|
|
11561
|
+
}, "mergeWorktree");
|
|
11562
|
+
const conflictReport = await this.detectConflicts(taskId, targetBranch);
|
|
11563
|
+
if (conflictReport.hasConflicts) {
|
|
11564
|
+
this._logger.info({
|
|
11565
|
+
taskId,
|
|
11566
|
+
conflictCount: conflictReport.conflictingFiles.length
|
|
11567
|
+
}, "Merge skipped due to conflicts");
|
|
11568
|
+
return {
|
|
11569
|
+
success: false,
|
|
11570
|
+
mergedFiles: [],
|
|
11571
|
+
conflicts: conflictReport
|
|
11572
|
+
};
|
|
11573
|
+
}
|
|
11574
|
+
const mergeSuccess = await performMerge(branchName, this._projectRoot);
|
|
11575
|
+
if (!mergeSuccess) throw new Error(`mergeWorktree: git merge --no-ff failed for task "${taskId}" branch "${branchName}"`);
|
|
11576
|
+
const mergedFiles = await getMergedFiles(this._projectRoot);
|
|
11577
|
+
this._eventBus.emit("worktree:merged", {
|
|
11578
|
+
taskId,
|
|
11579
|
+
branch: branchName,
|
|
11580
|
+
mergedFiles
|
|
11581
|
+
});
|
|
11582
|
+
const result = {
|
|
11583
|
+
success: true,
|
|
11584
|
+
mergedFiles
|
|
11585
|
+
};
|
|
11586
|
+
this._logger.info({
|
|
11587
|
+
taskId,
|
|
11588
|
+
branchName,
|
|
11589
|
+
mergedFileCount: mergedFiles.length
|
|
11590
|
+
}, "Worktree merged successfully");
|
|
11591
|
+
return result;
|
|
11592
|
+
}
|
|
11593
|
+
async listWorktrees() {
|
|
11594
|
+
this._logger.debug({
|
|
11595
|
+
projectRoot: this._projectRoot,
|
|
11596
|
+
baseDirectory: this._baseDirectory
|
|
11597
|
+
}, "listWorktrees");
|
|
11598
|
+
const worktreePaths = await getOrphanedWorktrees(this._projectRoot, this._baseDirectory);
|
|
11599
|
+
const results = [];
|
|
11600
|
+
for (const worktreePath of worktreePaths) {
|
|
11601
|
+
const taskId = path$1.basename(worktreePath);
|
|
11602
|
+
const branchName = BRANCH_PREFIX + taskId;
|
|
11603
|
+
let createdAt;
|
|
11604
|
+
try {
|
|
11605
|
+
const { stat: stat$2 } = await import("node:fs/promises");
|
|
11606
|
+
const stats = await stat$2(worktreePath);
|
|
11607
|
+
createdAt = stats.birthtime ?? stats.ctime;
|
|
11608
|
+
} catch {
|
|
11609
|
+
createdAt = new Date();
|
|
11610
|
+
}
|
|
11611
|
+
results.push({
|
|
11612
|
+
taskId,
|
|
11613
|
+
branchName,
|
|
11614
|
+
worktreePath,
|
|
11615
|
+
createdAt
|
|
11616
|
+
});
|
|
11617
|
+
}
|
|
11618
|
+
this._logger.debug({ count: results.length }, "listWorktrees: found worktrees");
|
|
11619
|
+
return results;
|
|
11620
|
+
}
|
|
11621
|
+
/** H4.2: current branch of the parent repo (undefined when detached/unreadable). */
|
|
11622
|
+
async _detectCurrentBranch() {
|
|
11623
|
+
try {
|
|
11624
|
+
const result = await spawnGit([
|
|
11625
|
+
"rev-parse",
|
|
11626
|
+
"--abbrev-ref",
|
|
11627
|
+
"HEAD"
|
|
11628
|
+
], { cwd: this._projectRoot });
|
|
11629
|
+
const name = result.code === 0 ? result.stdout.trim() : "";
|
|
11630
|
+
return name !== "" && name !== "HEAD" ? name : void 0;
|
|
11631
|
+
} catch {
|
|
11632
|
+
return void 0;
|
|
11633
|
+
}
|
|
11634
|
+
}
|
|
11635
|
+
getWorktreePath(taskId) {
|
|
11636
|
+
return path$1.resolve(this._projectRoot, this._baseDirectory, taskId);
|
|
11637
|
+
}
|
|
11638
|
+
async verifyGitVersion() {
|
|
11639
|
+
try {
|
|
11640
|
+
await verifyGitVersion();
|
|
11641
|
+
} catch (err) {
|
|
11642
|
+
throw new Error(`GitWorktreeManager: git version check failed: ${String(err)}`);
|
|
11643
|
+
}
|
|
11644
|
+
}
|
|
11645
|
+
};
|
|
11646
|
+
function createGitWorktreeManager(options) {
|
|
11647
|
+
return new GitWorktreeManagerImpl(
|
|
11648
|
+
options.eventBus,
|
|
11649
|
+
options.projectRoot,
|
|
11650
|
+
// H4.2: single resolution point — explicit option, else `worktree.base`
|
|
11651
|
+
// from .substrate/config.yaml, else the external default.
|
|
11652
|
+
options.baseDirectory ?? resolveWorktreeBaseDirectory(options.projectRoot),
|
|
11653
|
+
options.db ?? null,
|
|
11654
|
+
options.logger,
|
|
11655
|
+
options.copyFiles
|
|
11656
|
+
);
|
|
11657
|
+
}
|
|
11658
|
+
|
|
10774
11659
|
//#endregion
|
|
10775
11660
|
//#region packages/core/dist/version-manager/update-checker.js
|
|
10776
11661
|
/**
|
|
@@ -11089,9 +11974,9 @@ var VersionManagerImpl = class {
|
|
|
11089
11974
|
try {
|
|
11090
11975
|
const _require = createRequire(import.meta.url);
|
|
11091
11976
|
const fs = _require("fs");
|
|
11092
|
-
const path$
|
|
11977
|
+
const path$3 = _require("path");
|
|
11093
11978
|
const yaml$1 = _require("js-yaml");
|
|
11094
|
-
const configPath = path$
|
|
11979
|
+
const configPath = path$3.join(process.cwd(), ".substrate", "config.yaml");
|
|
11095
11980
|
try {
|
|
11096
11981
|
const raw = fs.readFileSync(configPath, "utf-8");
|
|
11097
11982
|
configObj = yaml$1.load(raw) ?? {};
|
|
@@ -11333,7 +12218,7 @@ function createExperimenter(config, deps) {
|
|
|
11333
12218
|
], { cwd: config.projectRoot });
|
|
11334
12219
|
return result.stdout.trim();
|
|
11335
12220
|
}
|
|
11336
|
-
async function createWorktree(worktreePath, branchName) {
|
|
12221
|
+
async function createWorktree$1(worktreePath, branchName) {
|
|
11337
12222
|
const result = await git([
|
|
11338
12223
|
"worktree",
|
|
11339
12224
|
"add",
|
|
@@ -11343,7 +12228,7 @@ function createExperimenter(config, deps) {
|
|
|
11343
12228
|
], { cwd: config.projectRoot });
|
|
11344
12229
|
if (result.code !== 0) throw new Error(`Failed to create worktree ${worktreePath}: ${result.stderr}`);
|
|
11345
12230
|
}
|
|
11346
|
-
async function removeWorktree(worktreePath) {
|
|
12231
|
+
async function removeWorktree$1(worktreePath) {
|
|
11347
12232
|
const result = await git([
|
|
11348
12233
|
"worktree",
|
|
11349
12234
|
"remove",
|
|
@@ -11453,7 +12338,7 @@ function createExperimenter(config, deps) {
|
|
|
11453
12338
|
try {
|
|
11454
12339
|
currentPhase = "BRANCHING";
|
|
11455
12340
|
log(`[experimenter] Creating worktree: ${worktreePath} on branch ${branchName}`);
|
|
11456
|
-
await createWorktree(worktreePath, branchName);
|
|
12341
|
+
await createWorktree$1(worktreePath, branchName);
|
|
11457
12342
|
worktreeCreated = true;
|
|
11458
12343
|
currentPhase = "MODIFYING";
|
|
11459
12344
|
const promptFile = resolvePromptFile(rec, worktreePath, config.pack);
|
|
@@ -11496,7 +12381,7 @@ function createExperimenter(config, deps) {
|
|
|
11496
12381
|
log(`[experimenter] Error in phase ${currentPhase}: ${caughtError}`);
|
|
11497
12382
|
} finally {
|
|
11498
12383
|
if (worktreeCreated) try {
|
|
11499
|
-
await removeWorktree(worktreePath);
|
|
12384
|
+
await removeWorktree$1(worktreePath);
|
|
11500
12385
|
} catch {
|
|
11501
12386
|
log(`[experimenter] Warning: could not remove worktree ${worktreePath}`);
|
|
11502
12387
|
}
|
|
@@ -11645,5 +12530,5 @@ async function callLLM(params) {
|
|
|
11645
12530
|
}
|
|
11646
12531
|
|
|
11647
12532
|
//#endregion
|
|
11648
|
-
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 };
|
|
11649
|
-
//# sourceMappingURL=dist-
|
|
12533
|
+
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 };
|
|
12534
|
+
//# sourceMappingURL=dist-C2CAZDep.js.map
|