@ikarmic/forge-core 5.2.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/agent/agent.js +324 -122
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/enhance-prompt.js +2 -1
  5. package/dist/agent/history-adapt.d.ts +23 -0
  6. package/dist/agent/history-adapt.js +114 -0
  7. package/dist/agent/prompts.d.ts +19 -2
  8. package/dist/agent/prompts.js +44 -2
  9. package/dist/agent/retry.d.ts +50 -0
  10. package/dist/agent/retry.js +192 -0
  11. package/dist/agent/shell-command.d.ts +23 -0
  12. package/dist/agent/shell-command.js +93 -0
  13. package/dist/agent/tool-repair.d.ts +8 -0
  14. package/dist/agent/tool-repair.js +16 -0
  15. package/dist/catalog/models-dev-snapshot.d.ts +1 -0
  16. package/dist/catalog/models-dev-snapshot.js +3 -0
  17. package/dist/catalog/models-dev.d.ts +45 -0
  18. package/dist/catalog/models-dev.js +155 -0
  19. package/dist/checkpoints/store.js +3 -1
  20. package/dist/context/compaction.d.ts +21 -6
  21. package/dist/context/compaction.js +94 -28
  22. package/dist/context/mask.d.ts +9 -9
  23. package/dist/context/mask.js +30 -1
  24. package/dist/format/auto-format.d.ts +22 -0
  25. package/dist/format/auto-format.js +39 -0
  26. package/dist/git/auto-commit.d.ts +4 -2
  27. package/dist/git/auto-commit.js +16 -5
  28. package/dist/github/github-tools.d.ts +1 -0
  29. package/dist/github/github-tools.js +9 -9
  30. package/dist/github/pr.d.ts +34 -0
  31. package/dist/github/pr.js +70 -0
  32. package/dist/human-text.d.ts +6 -0
  33. package/dist/human-text.js +37 -0
  34. package/dist/index.d.ts +19 -9
  35. package/dist/index.js +14 -6
  36. package/dist/journey/answers-block.d.ts +2 -4
  37. package/dist/lsp/diagnostics.d.ts +4 -0
  38. package/dist/lsp/diagnostics.js +8 -2
  39. package/dist/lsp/index.d.ts +1 -1
  40. package/dist/lsp/index.js +1 -1
  41. package/dist/mcp/index.d.ts +1 -1
  42. package/dist/mcp/index.js +1 -1
  43. package/dist/mcp/json-schema-to-zod.js +2 -4
  44. package/dist/mcp/mcp-client.d.ts +14 -0
  45. package/dist/mcp/mcp-client.js +102 -18
  46. package/dist/memory/session-digest.js +2 -1
  47. package/dist/orchestrator/specialists-registry.js +14 -1
  48. package/dist/orchestrator/specialists.js +3 -3
  49. package/dist/pricing.js +12 -0
  50. package/dist/semantic/embedder.d.ts +11 -0
  51. package/dist/semantic/embedder.js +16 -0
  52. package/dist/semantic/semantic-index.d.ts +39 -1
  53. package/dist/semantic/semantic-index.js +43 -13
  54. package/dist/semantic/vector-store.d.ts +2 -0
  55. package/dist/semantic/vector-store.js +13 -1
  56. package/dist/sessions/index.d.ts +2 -2
  57. package/dist/sessions/index.js +1 -1
  58. package/dist/sessions/search.js +2 -2
  59. package/dist/sessions/session-export.d.ts +1 -1
  60. package/dist/sessions/session-export.js +41 -33
  61. package/dist/sessions/session-store.d.ts +20 -1
  62. package/dist/sessions/session-store.js +7 -0
  63. package/dist/sessions/watch.js +4 -4
  64. package/dist/skills/activator.d.ts +6 -0
  65. package/dist/skills/activator.js +17 -0
  66. package/dist/skills/skill-tool.d.ts +12 -0
  67. package/dist/skills/skill-tool.js +28 -0
  68. package/dist/tools/background-process.d.ts +63 -0
  69. package/dist/tools/background-process.js +150 -0
  70. package/dist/tools/command-sandbox.d.ts +24 -0
  71. package/dist/tools/command-sandbox.js +73 -0
  72. package/dist/tools/edit-file.d.ts +8 -0
  73. package/dist/tools/edit-file.js +39 -16
  74. package/dist/tools/edit-match.d.ts +16 -0
  75. package/dist/tools/edit-match.js +115 -0
  76. package/dist/tools/fetch-url.d.ts +15 -1
  77. package/dist/tools/fetch-url.js +7 -47
  78. package/dist/tools/get-diagnostics.d.ts +14 -0
  79. package/dist/tools/get-diagnostics.js +44 -0
  80. package/dist/tools/glob-filter.d.ts +7 -0
  81. package/dist/tools/glob-filter.js +12 -0
  82. package/dist/tools/html-to-markdown.d.ts +13 -0
  83. package/dist/tools/html-to-markdown.js +507 -0
  84. package/dist/tools/index.d.ts +5 -1
  85. package/dist/tools/index.js +11 -1
  86. package/dist/tools/list-files.d.ts +1 -0
  87. package/dist/tools/list-files.js +17 -5
  88. package/dist/tools/list-symbols.js +2 -2
  89. package/dist/tools/network-policy.d.ts +5 -0
  90. package/dist/tools/network-policy.js +77 -0
  91. package/dist/tools/office-docs.d.ts +6 -0
  92. package/dist/tools/office-docs.js +393 -0
  93. package/dist/tools/permission-rules.js +55 -5
  94. package/dist/tools/read-file.d.ts +2 -0
  95. package/dist/tools/read-file.js +47 -10
  96. package/dist/tools/read-image.js +2 -2
  97. package/dist/tools/read-only.d.ts +1 -1
  98. package/dist/tools/read-only.js +4 -0
  99. package/dist/tools/repo-clone.d.ts +40 -0
  100. package/dist/tools/repo-clone.js +218 -0
  101. package/dist/tools/result-cap.d.ts +8 -0
  102. package/dist/tools/result-cap.js +44 -0
  103. package/dist/tools/run-command.d.ts +18 -1
  104. package/dist/tools/run-command.js +44 -9
  105. package/dist/tools/search-files.js +12 -53
  106. package/dist/tools/search-worker.d.ts +23 -0
  107. package/dist/tools/search-worker.js +86 -0
  108. package/dist/tools/shared.d.ts +14 -1
  109. package/dist/tools/shared.js +76 -4
  110. package/dist/tools/shell.d.ts +27 -0
  111. package/dist/tools/shell.js +80 -0
  112. package/dist/tools/sub-agent.d.ts +1 -1
  113. package/dist/tools/sub-agent.js +4 -1
  114. package/dist/tools/suggest-plan.d.ts +18 -0
  115. package/dist/tools/suggest-plan.js +28 -0
  116. package/dist/types.d.ts +132 -1
  117. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  118. package/dist/wakeup/wakeup-tools.js +34 -0
  119. package/dist/wakeup/wakeup.d.ts +75 -0
  120. package/dist/wakeup/wakeup.js +151 -0
  121. package/dist/worktree/health.d.ts +51 -0
  122. package/dist/worktree/health.js +186 -0
  123. package/dist/worktree/index.d.ts +4 -0
  124. package/dist/worktree/index.js +2 -0
  125. package/dist/worktree/setup.d.ts +51 -0
  126. package/dist/worktree/setup.js +173 -0
  127. package/dist/worktree/worktree.d.ts +7 -0
  128. package/dist/worktree/worktree.js +9 -4
  129. package/package.json +1 -1
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Worktree health for the Agent Manager (F9): what git knows against what is on disk, a doctor that names what is wrong
3
+ * in words, ahead/behind and changed-file counts for a poller, and the two fixes.
4
+ *
5
+ * The readers change nothing and take no git lock (`--no-optional-locks`), so a poller can run beside a person's own git.
6
+ * The fixes change repository metadata, so they go through the repo queue like create and remove (RB-25).
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
10
+ import { readdir, rm } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { promisify } from 'node:util';
13
+ import { canonicalPath, onRepoQueue } from './worktree.js';
14
+ const execFileAsync = promisify(execFile);
15
+ async function git(cwd, args) {
16
+ const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
17
+ return stdout;
18
+ }
19
+ /** `git worktree list --porcelain` into entries. */
20
+ export function parseWorktreeList(text) {
21
+ const out = [];
22
+ for (const block of text.split(/\r?\n\r?\n/)) {
23
+ const lines = block.split(/\r?\n/).filter(Boolean);
24
+ const pathLine = lines.find((l) => l.startsWith('worktree '));
25
+ if (!pathLine)
26
+ continue;
27
+ const branchLine = lines.find((l) => l.startsWith('branch '));
28
+ const lock = lines.find((l) => l === 'locked' || l.startsWith('locked '));
29
+ out.push({
30
+ path: pathLine.slice('worktree '.length).trim(),
31
+ head: (lines.find((l) => l.startsWith('HEAD ')) ?? '').slice('HEAD '.length).trim(),
32
+ ...(branchLine ? { branch: branchLine.slice('branch '.length).trim().replace(/^refs\/heads\//, '') } : {}),
33
+ detached: lines.includes('detached'),
34
+ locked: lock !== undefined,
35
+ ...(lock && lock.length > 'locked'.length ? { lockReason: lock.slice('locked '.length).trim() } : {}),
36
+ prunable: lines.some((l) => l === 'prunable' || l.startsWith('prunable ')),
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+ const same = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
42
+ const normal = (p) => path.resolve(p);
43
+ function worktreesRootOf(repoRoot) {
44
+ return path.join(canonicalPath(repoRoot), '.forge', 'worktrees');
45
+ }
46
+ function isUnder(root, p) {
47
+ const rel = path.relative(root, p);
48
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
49
+ }
50
+ export async function inspectWorktrees(repoRoot) {
51
+ const root = canonicalPath(repoRoot);
52
+ const wtRoot = worktreesRootOf(root);
53
+ let listed = [];
54
+ try {
55
+ listed = parseWorktreeList(await git(root, ['--no-optional-locks', 'worktree', 'list', '--porcelain']));
56
+ }
57
+ catch {
58
+ // not a repository, or git is missing: nothing to report
59
+ }
60
+ const registered = listed.map((w) => ({ ...w, path: normal(w.path) })).filter((w) => isUnder(wtRoot, w.path));
61
+ let strayFolders = [];
62
+ try {
63
+ const entries = await readdir(wtRoot, { withFileTypes: true });
64
+ strayFolders = entries
65
+ .filter((e) => e.isDirectory())
66
+ .map((e) => path.join(wtRoot, e.name))
67
+ .filter((dir) => !registered.some((w) => same(w.path, dir)));
68
+ }
69
+ catch {
70
+ // no folder yet
71
+ }
72
+ const missing = registered.filter((w) => !existsSync(w.path));
73
+ return { registered, strayFolders, missing };
74
+ }
75
+ async function branchExists(repoRoot, branch) {
76
+ try {
77
+ await git(repoRoot, ['--no-optional-locks', 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** Read-only checks of every Agent Manager worktree. An empty list means all is well. */
85
+ export async function doctorWorktrees(repoRoot) {
86
+ const root = canonicalPath(repoRoot);
87
+ const { registered, strayFolders, missing } = await inspectWorktrees(root);
88
+ const found = [];
89
+ for (const w of registered) {
90
+ if (missing.includes(w)) {
91
+ found.push({ path: w.path, problem: 'Its folder is gone, but git still lists it.', advice: 'Prune it to forget the listing.', fix: 'prune' });
92
+ continue;
93
+ }
94
+ if (w.detached || !w.branch) {
95
+ found.push({ path: w.path, problem: 'It is on a detached HEAD, so commits made here belong to no branch.', advice: 'Create a branch before you leave it, or the commits can be lost.' });
96
+ }
97
+ else if (!(await branchExists(root, w.branch))) {
98
+ found.push({ path: w.path, problem: `Its branch "${w.branch}" no longer exists.`, advice: 'Create the branch again from this folder, or discard the session.' });
99
+ }
100
+ if (w.locked)
101
+ found.push({ path: w.path, problem: `Git has it locked${w.lockReason ? ` (${w.lockReason})` : ''}.`, advice: 'Unlock it with git worktree unlock if nothing is using it.' });
102
+ try {
103
+ const gitDir = (await git(w.path, ['--no-optional-locks', 'rev-parse', '--absolute-git-dir'])).trim();
104
+ for (const name of ['index.lock', 'HEAD.lock']) {
105
+ if (existsSync(path.join(gitDir, name))) {
106
+ found.push({ path: w.path, problem: `A leftover lock file (${name}) stops git commands here.`, advice: 'If no git command is running, delete that file.' });
107
+ }
108
+ }
109
+ }
110
+ catch {
111
+ // not readable as a git folder: another finding already covers a broken listing
112
+ }
113
+ }
114
+ for (const dir of strayFolders) {
115
+ found.push({ path: dir, problem: 'This folder is not a worktree that git knows.', advice: 'Look inside, and delete it if nothing there matters.', fix: 'delete-folder' });
116
+ }
117
+ return found;
118
+ }
119
+ const ZERO = { ahead: 0, behind: 0, changed: 0, added: 0, removed: 0 };
120
+ /** Numbers for one session's row. Never throws; a folder or branch that cannot be read gives zeros. */
121
+ export async function worktreeStats(repoRoot, worktreePath, branch, base) {
122
+ if (!existsSync(worktreePath))
123
+ return ZERO;
124
+ const root = canonicalPath(repoRoot);
125
+ try {
126
+ const counts = (await git(root, ['--no-optional-locks', 'rev-list', '--left-right', '--count', `${base}...${branch}`])).trim().split(/\s+/).map(Number);
127
+ const files = new Set();
128
+ const committed = await git(root, ['--no-optional-locks', 'diff', '--name-only', `${base}...${branch}`]);
129
+ for (const l of committed.split(/\r?\n/))
130
+ if (l.trim())
131
+ files.add(l.trim());
132
+ const status = await git(worktreePath, ['--no-optional-locks', 'status', '--porcelain=v1']);
133
+ for (const l of status.split(/\r?\n/))
134
+ if (l.trim())
135
+ files.add(l.slice(3).trim());
136
+ // One diff of the working tree against where the branch left the base, so a line edited twice is one change. A binary file shows "-" and adds no lines.
137
+ const mergeBase = (await git(worktreePath, ['merge-base', base, 'HEAD'])).trim();
138
+ let added = 0;
139
+ let removed = 0;
140
+ for (const l of (await git(worktreePath, ['--no-optional-locks', 'diff', '--numstat', mergeBase])).split(/\r?\n/)) {
141
+ const [a, r] = l.split('\t');
142
+ if (a && r && /^\d+$/.test(a) && /^\d+$/.test(r)) {
143
+ added += Number(a);
144
+ removed += Number(r);
145
+ }
146
+ }
147
+ return { behind: counts[0] ?? 0, ahead: counts[1] ?? 0, changed: files.size, added, removed };
148
+ }
149
+ catch {
150
+ return ZERO;
151
+ }
152
+ }
153
+ /** Forget listings whose folder is gone. Returns how many of the Agent Manager's were forgotten. */
154
+ export function pruneMissingWorktrees(repoRoot) {
155
+ const root = canonicalPath(repoRoot);
156
+ return onRepoQueue(root, async () => {
157
+ const before = (await inspectWorktrees(root)).missing.length;
158
+ if (before === 0)
159
+ return 0;
160
+ await git(root, ['worktree', 'prune']);
161
+ return before - (await inspectWorktrees(root)).missing.length;
162
+ });
163
+ }
164
+ /** Delete a folder under `.forge/worktrees` that git does not know. Refuses anything else. */
165
+ export function removeStrayFolder(repoRoot, dir) {
166
+ const root = canonicalPath(repoRoot);
167
+ return onRepoQueue(root, async () => {
168
+ const wtRoot = worktreesRootOf(root);
169
+ const target = path.resolve(dir);
170
+ if (same(target, wtRoot))
171
+ throw new Error('That is the worktrees folder itself; only a folder inside it can be removed.');
172
+ if (!isUnder(wtRoot, target))
173
+ throw new Error('Only a folder inside .forge/worktrees can be removed; this one is outside it.');
174
+ const { registered } = await inspectWorktrees(root);
175
+ if (registered.some((w) => same(w.path, target)))
176
+ throw new Error('That folder is a worktree git knows. Discard its session instead.');
177
+ if (existsSync(target)) {
178
+ if (lstatSync(target).isSymbolicLink())
179
+ throw new Error('That is a link, not a folder, so it was left alone.');
180
+ // Where the folder really is, after any link on the way (RB-20).
181
+ if (!isUnder(realpathSync.native(wtRoot), realpathSync.native(target)))
182
+ throw new Error('Only a folder inside .forge/worktrees can be removed; this one leads outside it.');
183
+ }
184
+ await rm(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 150 });
185
+ });
186
+ }
@@ -15,3 +15,7 @@ export { compareWorktreeContexts, snapshotWorktreeContext, WorktreeManager, Work
15
15
  export type { CreateOptions, RemoveOptions, RemoveResult, WorktreeContextSnapshot, WorktreeInfo, WorktreeMismatchKind, WorktreeMismatchResult, } from './worktree.js';
16
16
  export { branchChangedFiles, branchDiff, commitAllInWorktree, deleteBranch, mergeBranch, } from './branch-ops.js';
17
17
  export type { MergeResult, WorktreeCommit } from './branch-ops.js';
18
+ export { doctorWorktrees, inspectWorktrees, parseWorktreeList, pruneMissingWorktrees, removeStrayFolder, worktreeStats } from './health.js';
19
+ export type { HealthFinding, ListedWorktree, WorktreeInspection, WorktreeStats } from './health.js';
20
+ export { copyIntoWorktree, runSetupScript, setupWorktree } from './setup.js';
21
+ export type { CopyOptions, CopyResult, ScriptOptions, ScriptResult, SetupOptions, SetupOutcome } from './setup.js';
@@ -13,3 +13,5 @@
13
13
  */
14
14
  export { compareWorktreeContexts, snapshotWorktreeContext, WorktreeManager, WorktreeError, } from './worktree.js';
15
15
  export { branchChangedFiles, branchDiff, commitAllInWorktree, deleteBranch, mergeBranch, } from './branch-ops.js';
16
+ export { doctorWorktrees, inspectWorktrees, parseWorktreeList, pruneMissingWorktrees, removeStrayFolder, worktreeStats } from './health.js';
17
+ export { copyIntoWorktree, runSetupScript, setupWorktree } from './setup.js';
@@ -0,0 +1,51 @@
1
+ import { type ShellPreference } from '../tools/shell.js';
2
+ export interface CopyResult {
3
+ /** Paths copied, relative to the repository, with `/`. */
4
+ copied: string[];
5
+ skipped: {
6
+ path: string;
7
+ reason: string;
8
+ }[];
9
+ }
10
+ export interface CopyOptions {
11
+ maxFiles?: number;
12
+ maxFileBytes?: number;
13
+ }
14
+ /**
15
+ * Copy the files matching `patterns` (a name such as `.env` matches at any depth, `config/*.json` is a path) from the
16
+ * repository into the worktree. Never overwrites, never follows a link, never enters `.git` or a build folder, and stops
17
+ * at a count and a size.
18
+ */
19
+ export declare function copyIntoWorktree(repoRoot: string, worktreePath: string, patterns: readonly string[], options?: CopyOptions): Promise<CopyResult>;
20
+ export interface ScriptOptions {
21
+ timeoutMs?: number;
22
+ tailChars?: number;
23
+ shell?: ShellPreference;
24
+ }
25
+ export interface ScriptResult {
26
+ ok: boolean;
27
+ exitCode: number | null;
28
+ timedOut: boolean;
29
+ /** The end of what the script printed (both streams). */
30
+ output: string;
31
+ }
32
+ /** Run `script` in a shell, in the worktree. Never throws: a script that cannot start is a failed result. */
33
+ export declare function runSetupScript(worktreePath: string, script: string, options?: ScriptOptions): Promise<ScriptResult>;
34
+ export interface SetupOptions extends CopyOptions {
35
+ copyFiles?: readonly string[];
36
+ setupScript?: string;
37
+ scriptTimeoutMs?: number;
38
+ shell?: ShellPreference;
39
+ }
40
+ export interface SetupOutcome {
41
+ ok: boolean;
42
+ copied: string[];
43
+ skipped: {
44
+ path: string;
45
+ reason: string;
46
+ }[];
47
+ /** Why it stopped, in words, with the end of the script's output. */
48
+ message?: string;
49
+ }
50
+ /** Copy, then run the script. The first failure ends it. */
51
+ export declare function setupWorktree(repoRoot: string, worktreePath: string, options: SetupOptions): Promise<SetupOutcome>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Getting a new worktree ready before an agent works in it (F7): copy a few files from the repository (an `.env`), then run
3
+ * a setup script (`npm ci`). Both are the person's own settings, never read from a repository (RB-12). A failed step ends
4
+ * the session before the agent runs in a half-made tree.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { constants } from 'node:fs';
8
+ import { copyFile, lstat, mkdir, readdir } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { fileGlobMatcher } from '../tools/glob-filter.js';
11
+ import { killTree } from '../tools/run-command.js';
12
+ import { runnerShell, shellInvocation } from '../tools/shell.js';
13
+ import { shellEnv } from '../tools/shell-env.js';
14
+ import { DEFAULT_IGNORED_DIRS } from '../tools/shared.js';
15
+ const DEFAULT_MAX_FILES = 200;
16
+ const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
17
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000;
18
+ const DEFAULT_TAIL_CHARS = 2000;
19
+ /** Output kept while a script runs; only the end is ever shown. */
20
+ const KEEP_CHARS = 64 * 1024;
21
+ function checkPattern(p) {
22
+ if (!p.trim() || path.isAbsolute(p) || /^[A-Za-z]:/.test(p) || p.split(/[\\/]/).includes('..')) {
23
+ throw new Error(`Copy pattern "${p}" must be relative to the repository and stay inside it.`);
24
+ }
25
+ }
26
+ /**
27
+ * Copy the files matching `patterns` (a name such as `.env` matches at any depth, `config/*.json` is a path) from the
28
+ * repository into the worktree. Never overwrites, never follows a link, never enters `.git` or a build folder, and stops
29
+ * at a count and a size.
30
+ */
31
+ export async function copyIntoWorktree(repoRoot, worktreePath, patterns, options = {}) {
32
+ for (const p of patterns)
33
+ checkPattern(p);
34
+ const matchers = patterns.map((p) => fileGlobMatcher(p.replace(/\\/g, '/')));
35
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
36
+ const maxBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
37
+ const result = { copied: [], skipped: [] };
38
+ async function walk(dir) {
39
+ let entries;
40
+ try {
41
+ entries = await readdir(dir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ for (const entry of entries) {
47
+ // A link could lead anywhere on disk (RB-20): it is not followed and not copied.
48
+ if (entry.isSymbolicLink())
49
+ continue;
50
+ const abs = path.join(dir, entry.name);
51
+ if (entry.isDirectory()) {
52
+ if (entry.name === '.git' || DEFAULT_IGNORED_DIRS.has(entry.name))
53
+ continue;
54
+ await walk(abs);
55
+ continue;
56
+ }
57
+ if (!entry.isFile())
58
+ continue;
59
+ const rel = path.relative(repoRoot, abs).split(path.sep).join('/');
60
+ if (!matchers.some((m) => m(rel, entry.name)))
61
+ continue;
62
+ if (result.copied.length >= maxFiles) {
63
+ result.skipped.push({ path: rel, reason: 'file limit' });
64
+ continue;
65
+ }
66
+ const size = (await lstat(abs)).size;
67
+ if (size > maxBytes) {
68
+ result.skipped.push({ path: rel, reason: 'too large' });
69
+ continue;
70
+ }
71
+ const dest = path.join(worktreePath, ...rel.split('/'));
72
+ // The destination must stay inside the worktree whatever the name was.
73
+ if (path.relative(worktreePath, dest).startsWith('..'))
74
+ continue;
75
+ await mkdir(path.dirname(dest), { recursive: true });
76
+ try {
77
+ await copyFile(abs, dest, constants.COPYFILE_EXCL);
78
+ result.copied.push(rel);
79
+ }
80
+ catch (err) {
81
+ if (err.code === 'EEXIST')
82
+ result.skipped.push({ path: rel, reason: 'already exists' });
83
+ else
84
+ throw err;
85
+ }
86
+ }
87
+ }
88
+ await walk(repoRoot);
89
+ return result;
90
+ }
91
+ /** Run `script` in a shell, in the worktree. Never throws: a script that cannot start is a failed result. */
92
+ export function runSetupScript(worktreePath, script, options = {}) {
93
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
94
+ const tail = options.tailChars ?? DEFAULT_TAIL_CHARS;
95
+ return new Promise((resolve) => {
96
+ let output = '';
97
+ let timedOut = false;
98
+ const take = (chunk) => {
99
+ output += chunk;
100
+ if (output.length > KEEP_CHARS)
101
+ output = output.slice(output.length - KEEP_CHARS);
102
+ };
103
+ const inv = shellInvocation(runnerShell(options.shell), script);
104
+ const spawnOptions = {
105
+ cwd: worktreePath,
106
+ // Windows: never run a same-named program the repository ships in its root (RB-24).
107
+ env: shellEnv(process.env),
108
+ stdio: ['ignore', 'pipe', 'pipe'],
109
+ windowsHide: true,
110
+ detached: process.platform !== 'win32',
111
+ };
112
+ let child;
113
+ try {
114
+ child = inv.file ? spawn(inv.file, inv.args, spawnOptions) : spawn(script, { ...spawnOptions, shell: true });
115
+ }
116
+ catch (err) {
117
+ resolve({ ok: false, exitCode: null, timedOut: false, output: err.message });
118
+ return;
119
+ }
120
+ child.stdout.setEncoding('utf8').on('data', take);
121
+ child.stderr.setEncoding('utf8').on('data', take);
122
+ const timer = setTimeout(() => {
123
+ timedOut = true;
124
+ killTree(child);
125
+ }, timeoutMs);
126
+ let finished = false;
127
+ const finish = (code) => {
128
+ if (finished)
129
+ return;
130
+ finished = true;
131
+ clearTimeout(timer);
132
+ resolve({ ok: code === 0 && !timedOut, exitCode: code, timedOut, output: output.trim().slice(-tail) });
133
+ };
134
+ child.on('error', (err) => {
135
+ take(`${err.message}\n`);
136
+ finish(null);
137
+ });
138
+ // A grandchild that escaped the tree kill must not hold the pipes open and hang the session (RB-70).
139
+ child.on('exit', (code) => {
140
+ setTimeout(() => {
141
+ child.stdout.destroy();
142
+ child.stderr.destroy();
143
+ finish(code);
144
+ }, 500).unref();
145
+ });
146
+ child.on('close', (code) => finish(code));
147
+ });
148
+ }
149
+ /** Copy, then run the script. The first failure ends it. */
150
+ export async function setupWorktree(repoRoot, worktreePath, options) {
151
+ let copied = [];
152
+ let skipped = [];
153
+ const patterns = options.copyFiles ?? [];
154
+ if (patterns.length > 0) {
155
+ try {
156
+ const r = await copyIntoWorktree(repoRoot, worktreePath, patterns, options);
157
+ copied = r.copied;
158
+ skipped = r.skipped;
159
+ }
160
+ catch (err) {
161
+ return { ok: false, copied, skipped, message: `Copying files into the worktree failed: ${err.message}` };
162
+ }
163
+ }
164
+ const script = options.setupScript?.trim();
165
+ if (script) {
166
+ const r = await runSetupScript(worktreePath, script, { ...(options.scriptTimeoutMs ? { timeoutMs: options.scriptTimeoutMs } : {}), ...(options.shell ? { shell: options.shell } : {}) });
167
+ if (!r.ok) {
168
+ const why = r.timedOut ? 'ran too long and was stopped' : `failed${r.exitCode === null ? '' : ` (exit code ${r.exitCode})`}`;
169
+ return { ok: false, copied, skipped, message: `The setup script ${why}.${r.output ? `\n${r.output}` : ''}` };
170
+ }
171
+ }
172
+ return { ok: true, copied, skipped };
173
+ }
@@ -335,3 +335,10 @@ export declare class WorktreeManager {
335
335
  * canonical too. Falls back to `path.resolve` when the path does not exist.
336
336
  */
337
337
  export declare function canonicalPath(p: string): string;
338
+ /**
339
+ * Run `work` after every earlier queued worktree change of the same repository has finished. Parallel steps create
340
+ * and remove worktrees at the same moment, and two `git worktree add` / `remove` calls on one repository fight over
341
+ * git's lock files; one of them then fails at random, mostly under load. The agents still run in parallel: only these
342
+ * short git changes take turns.
343
+ */
344
+ export declare function onRepoQueue<T>(repoRoot: string, work: () => Promise<T>): Promise<T>;
@@ -296,9 +296,14 @@ export class WorktreeManager {
296
296
  throw new WorktreeError(`Worktree path already exists: ${worktreePath}. ` +
297
297
  `Pick a unique label or remove the existing worktree.`);
298
298
  }
299
- const args = ['worktree', 'add', '--no-track'];
300
- if (!opts.detached) {
301
- args.push('-b', branch);
299
+ const args = ['worktree', 'add'];
300
+ if (opts.detached) {
301
+ // Without --detach git makes a branch named after the folder, so the worktree was never detached (RB-74).
302
+ // --no-track is only allowed when a branch is created.
303
+ args.push('--detach');
304
+ }
305
+ else {
306
+ args.push('--no-track', '-b', branch);
302
307
  }
303
308
  if (opts.baseRef) {
304
309
  args.push(worktreePath, opts.baseRef);
@@ -642,7 +647,7 @@ const repoQueues = new Map();
642
647
  * git's lock files; one of them then fails at random, mostly under load. The agents still run in parallel: only these
643
648
  * short git changes take turns.
644
649
  */
645
- function onRepoQueue(repoRoot, work) {
650
+ export function onRepoQueue(repoRoot, work) {
646
651
  const key = canonicalPath(repoRoot);
647
652
  const next = (repoQueues.get(key) ?? Promise.resolve()).then(work, work);
648
653
  repoQueues.set(key, next.catch(() => undefined));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikarmic/forge-core",
3
- "version": "5.2.0",
3
+ "version": "5.4.0",
4
4
  "description": "Agent engine for IKarmic AI Forge. Model loop, tools, MCP, sandbox, memory, compaction, plan mode, skills, hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",