brainclaw 0.29.2 → 1.5.4

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 (197) hide show
  1. package/LICENSE +21 -74
  2. package/README.md +199 -176
  3. package/dist/brainclaw-vscode.vsix +0 -0
  4. package/dist/cli.js +710 -25
  5. package/dist/commands/accept.js +3 -0
  6. package/dist/commands/add-step.js +11 -26
  7. package/dist/commands/agent-board.js +70 -3
  8. package/dist/commands/audit.js +19 -0
  9. package/dist/commands/check-policy.js +54 -0
  10. package/dist/commands/check-security-mcp.js +145 -0
  11. package/dist/commands/check-security.js +106 -0
  12. package/dist/commands/claim-resource.js +1 -0
  13. package/dist/commands/codev.js +672 -0
  14. package/dist/commands/compact.js +74 -0
  15. package/dist/commands/complete-step.js +16 -26
  16. package/dist/commands/constraint.js +8 -20
  17. package/dist/commands/decision.js +9 -20
  18. package/dist/commands/delete-plan.js +10 -12
  19. package/dist/commands/delete-step.js +16 -0
  20. package/dist/commands/dispatch.js +163 -0
  21. package/dist/commands/doctor.js +1122 -49
  22. package/dist/commands/enable-agent.js +1 -0
  23. package/dist/commands/export.js +280 -22
  24. package/dist/commands/handoff.js +33 -0
  25. package/dist/commands/harvest.js +189 -0
  26. package/dist/commands/hooks.js +82 -25
  27. package/dist/commands/inbox.js +169 -0
  28. package/dist/commands/init.js +38 -31
  29. package/dist/commands/install-hooks.js +71 -44
  30. package/dist/commands/link.js +89 -0
  31. package/dist/commands/list-claims.js +48 -3
  32. package/dist/commands/list-plans.js +129 -25
  33. package/dist/commands/loops-handlers.js +409 -0
  34. package/dist/commands/mcp-read-handlers.js +1628 -0
  35. package/dist/commands/mcp-schemas.generated.js +269 -0
  36. package/dist/commands/mcp.js +4224 -1501
  37. package/dist/commands/plan-resource.js +64 -0
  38. package/dist/commands/plan.js +12 -26
  39. package/dist/commands/prune.js +37 -2
  40. package/dist/commands/reflect.js +20 -7
  41. package/dist/commands/release-claim.js +11 -6
  42. package/dist/commands/release-notes.js +170 -0
  43. package/dist/commands/repair.js +210 -0
  44. package/dist/commands/run-profile.js +57 -0
  45. package/dist/commands/sequence.js +113 -0
  46. package/dist/commands/session-end.js +423 -14
  47. package/dist/commands/session-start.js +214 -41
  48. package/dist/commands/setup-security.js +103 -0
  49. package/dist/commands/setup.js +42 -4
  50. package/dist/commands/stale.js +109 -0
  51. package/dist/commands/switch.js +100 -2
  52. package/dist/commands/trap.js +14 -31
  53. package/dist/commands/update-handoff.js +63 -4
  54. package/dist/commands/update-plan.js +21 -28
  55. package/dist/commands/update-step.js +37 -0
  56. package/dist/commands/upgrade.js +313 -6
  57. package/dist/commands/usage.js +102 -0
  58. package/dist/commands/version.js +20 -0
  59. package/dist/commands/who.js +33 -5
  60. package/dist/commands/worktree.js +105 -0
  61. package/dist/core/actions.js +315 -0
  62. package/dist/core/agent-capability.js +610 -17
  63. package/dist/core/agent-context.js +7 -1
  64. package/dist/core/agent-files.js +1169 -85
  65. package/dist/core/agent-integrations.js +160 -5
  66. package/dist/core/agent-inventory.js +2 -0
  67. package/dist/core/agent-profiles.js +93 -0
  68. package/dist/core/agent-registry.js +162 -30
  69. package/dist/core/agentrun-reconciler.js +345 -0
  70. package/dist/core/agentruns.js +424 -0
  71. package/dist/core/ai-agent-detection.js +31 -10
  72. package/dist/core/archival.js +77 -0
  73. package/dist/core/assignment-sweeper.js +82 -0
  74. package/dist/core/assignments.js +367 -0
  75. package/dist/core/audit.js +30 -0
  76. package/dist/core/brainclaw-version.js +94 -2
  77. package/dist/core/candidates.js +93 -2
  78. package/dist/core/claims.js +419 -0
  79. package/dist/core/codev-metrics.js +77 -0
  80. package/dist/core/codev-personas.js +31 -0
  81. package/dist/core/codev-plan-gen.js +35 -0
  82. package/dist/core/codev-prompts.js +74 -0
  83. package/dist/core/codev-responses.js +62 -0
  84. package/dist/core/codev-rounds.js +218 -0
  85. package/dist/core/config.js +4 -0
  86. package/dist/core/context.js +381 -34
  87. package/dist/core/coordination.js +201 -6
  88. package/dist/core/cross-project.js +230 -16
  89. package/dist/core/default-profiles/doctor.yaml +11 -0
  90. package/dist/core/default-profiles/janitor.yaml +11 -0
  91. package/dist/core/default-profiles/onboarder.yaml +11 -0
  92. package/dist/core/default-profiles/reviewer.yaml +13 -0
  93. package/dist/core/dispatcher.js +1189 -0
  94. package/dist/core/duplicates.js +2 -2
  95. package/dist/core/entity-operations.js +450 -0
  96. package/dist/core/entity-registry.js +344 -0
  97. package/dist/core/events.js +106 -2
  98. package/dist/core/execution-adapters.js +154 -0
  99. package/dist/core/execution-context.js +63 -0
  100. package/dist/core/execution-profile.js +270 -0
  101. package/dist/core/execution.js +255 -0
  102. package/dist/core/facade-schema.js +81 -0
  103. package/dist/core/federation-cloud.js +99 -0
  104. package/dist/core/federation-message.js +52 -0
  105. package/dist/core/federation-transport.js +65 -0
  106. package/dist/core/gc-semantic.js +482 -0
  107. package/dist/core/governance.js +247 -0
  108. package/dist/core/guards.js +19 -0
  109. package/dist/core/ideation.js +72 -0
  110. package/dist/core/identity.js +110 -25
  111. package/dist/core/ids.js +6 -0
  112. package/dist/core/input-validation.js +2 -2
  113. package/dist/core/instruction-templates.js +344 -136
  114. package/dist/core/io.js +90 -11
  115. package/dist/core/lock.js +6 -2
  116. package/dist/core/loops/brief-assembly.js +213 -0
  117. package/dist/core/loops/facade-schema.js +148 -0
  118. package/dist/core/loops/index.js +7 -0
  119. package/dist/core/loops/iteration-engine.js +139 -0
  120. package/dist/core/loops/lock.js +385 -0
  121. package/dist/core/loops/store.js +201 -0
  122. package/dist/core/loops/types.js +403 -0
  123. package/dist/core/loops/verbs.js +534 -0
  124. package/dist/core/markdown.js +15 -3
  125. package/dist/core/memory-compactor.js +432 -0
  126. package/dist/core/memory-git.js +152 -8
  127. package/dist/core/messaging.js +278 -0
  128. package/dist/core/migration.js +32 -1
  129. package/dist/core/mutation-pipeline.js +4 -2
  130. package/dist/core/operations/memory-mutation.js +129 -0
  131. package/dist/core/operations/memory-write.js +78 -0
  132. package/dist/core/operations/plan.js +190 -0
  133. package/dist/core/policy.js +169 -0
  134. package/dist/core/reputation.js +9 -3
  135. package/dist/core/schema.js +491 -6
  136. package/dist/core/search.js +21 -2
  137. package/dist/core/security-cache.js +71 -0
  138. package/dist/core/security-guard.js +152 -0
  139. package/dist/core/security-scoring.js +86 -0
  140. package/dist/core/sequence.js +130 -0
  141. package/dist/core/socket-client.js +113 -0
  142. package/dist/core/staleness.js +246 -0
  143. package/dist/core/state.js +98 -22
  144. package/dist/core/store-resolution.js +43 -11
  145. package/dist/core/toml-writer.js +76 -0
  146. package/dist/core/upgrades/backup.js +232 -0
  147. package/dist/core/upgrades/health-check.js +169 -0
  148. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  149. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  150. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  151. package/dist/core/upgrades/schema-version.js +97 -0
  152. package/dist/core/worktree.js +606 -0
  153. package/dist/facts.js +114 -0
  154. package/dist/facts.json +111 -0
  155. package/docs/architecture/project-refs.md +5 -1
  156. package/docs/cli.md +690 -43
  157. package/docs/concepts/ideation-loop.md +317 -0
  158. package/docs/concepts/loop-engine.md +456 -0
  159. package/docs/concepts/mcp-governance.md +268 -0
  160. package/docs/concepts/memory-staleness.md +122 -0
  161. package/docs/concepts/multi-agent-workflows.md +166 -0
  162. package/docs/concepts/plans-and-claims.md +31 -6
  163. package/docs/concepts/project-md-convention.md +35 -0
  164. package/docs/concepts/troubleshooting.md +220 -0
  165. package/docs/concepts/upgrade-cli.md +202 -0
  166. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  167. package/docs/context-format-changelog.md +2 -2
  168. package/docs/context-format.md +2 -2
  169. package/docs/index.md +68 -0
  170. package/docs/integrations/agents.md +15 -16
  171. package/docs/integrations/cline.md +88 -0
  172. package/docs/integrations/codex.md +75 -23
  173. package/docs/integrations/continue.md +60 -0
  174. package/docs/integrations/copilot.md +67 -9
  175. package/docs/integrations/kilocode.md +72 -0
  176. package/docs/integrations/mcp.md +304 -21
  177. package/docs/integrations/mistral-vibe.md +122 -0
  178. package/docs/integrations/opencode.md +84 -0
  179. package/docs/integrations/overview.md +23 -8
  180. package/docs/integrations/roo.md +74 -0
  181. package/docs/integrations/windsurf.md +83 -0
  182. package/docs/mcp-schema-changelog.md +191 -1
  183. package/docs/playbooks/integration/index.md +121 -0
  184. package/docs/playbooks/productivity/index.md +102 -0
  185. package/docs/playbooks/team/index.md +122 -0
  186. package/docs/product/agent-first-model.md +184 -0
  187. package/docs/product/entity-model-audit.md +462 -0
  188. package/docs/product/positioning.md +10 -10
  189. package/docs/quickstart-existing-project.md +135 -0
  190. package/docs/quickstart.md +124 -37
  191. package/docs/release-maintenance.md +79 -0
  192. package/docs/review.md +2 -0
  193. package/docs/server-operations.md +118 -0
  194. package/package.json +21 -13
  195. package/dist/commands/claude-desktop-extension.js +0 -18
  196. package/dist/commands/diff.js +0 -99
  197. package/dist/core/claude-desktop-extension.js +0 -224
@@ -0,0 +1,606 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ /** Normalizes a path for use in git CLI arguments (forward slashes on Windows). */
7
+ function gitPath(p) {
8
+ return p.replace(/\\/g, '/');
9
+ }
10
+ /**
11
+ * Stack marker → shared directories mapping.
12
+ * Maven/Gradle/Cargo intentionally excluded — their dep caches live
13
+ * machine-globally (~/.m2, ~/.gradle/caches, ~/.cargo/registry).
14
+ */
15
+ const STACK_MARKERS = [
16
+ { markers: ['package.json'], paths: ['node_modules'] },
17
+ { markers: ['requirements.txt', 'pyproject.toml', 'Pipfile'], paths: ['venv', '.venv'] },
18
+ { markers: ['Gemfile'], paths: ['vendor/bundle'] },
19
+ { markers: ['go.mod'], paths: ['vendor'] },
20
+ { markers: ['composer.json'], paths: ['vendor'] },
21
+ { markers: ['mix.exs'], paths: ['deps'] },
22
+ ];
23
+ /**
24
+ * Detects which directories should be symlinked into worktrees based on
25
+ * stack markers found in `projectRoot`.
26
+ *
27
+ * Returns a deduplicated list of relative directory names.
28
+ */
29
+ export function detectStackSharedPaths(projectRoot) {
30
+ const result = new Set();
31
+ for (const { markers, paths } of STACK_MARKERS) {
32
+ const hasMarker = markers.some((m) => fs.existsSync(path.join(projectRoot, m)));
33
+ if (hasMarker) {
34
+ for (const p of paths)
35
+ result.add(p);
36
+ }
37
+ }
38
+ return [...result];
39
+ }
40
+ function canonicalizeScopePath(target) {
41
+ let resolved;
42
+ try {
43
+ resolved = fs.realpathSync.native(target);
44
+ }
45
+ catch {
46
+ resolved = path.resolve(target);
47
+ }
48
+ const normalized = path.normalize(resolved);
49
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
50
+ }
51
+ /**
52
+ * Returns the base directory where brainclaw-managed worktrees are placed.
53
+ * ~/.brainclaw/worktrees/<project-hash>/
54
+ *
55
+ * Using a hash of the main worktree path ensures distinct directories per
56
+ * project even when two projects share the same repo name.
57
+ */
58
+ export function worktreesBaseDir(mainWorktreePath) {
59
+ const hash = crypto.createHash('sha1').update(mainWorktreePath).digest('hex').slice(0, 12);
60
+ return path.join(os.homedir(), '.brainclaw', 'worktrees', hash);
61
+ }
62
+ /**
63
+ * Resolves the path where a new worktree will be placed.
64
+ * Pattern: ~/.brainclaw/worktrees/<project-hash>/<branchSlug>
65
+ */
66
+ export function resolveWorktreePath(mainWorktreePath, branchName) {
67
+ const slug = branchName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
68
+ return path.join(worktreesBaseDir(mainWorktreePath), slug);
69
+ }
70
+ function runGit(args, cwd) {
71
+ const result = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 15000 });
72
+ return {
73
+ ok: result.status === 0,
74
+ stdout: result.stdout ?? '',
75
+ stderr: result.stderr ?? '',
76
+ };
77
+ }
78
+ /**
79
+ * Returns true if the given path is a bare git repository.
80
+ * Bare repos have no working tree, so worktree add is not applicable.
81
+ */
82
+ export function isBareRepo(cwd) {
83
+ const result = runGit(['rev-parse', '--is-bare-repository'], cwd);
84
+ return result.ok && result.stdout.trim() === 'true';
85
+ }
86
+ /**
87
+ * Returns true if git has an index lock in this worktree
88
+ * (another git process is active — worktree operations would fail).
89
+ */
90
+ export function hasGitLock(cwd) {
91
+ const gitDir = runGit(['rev-parse', '--git-dir'], cwd);
92
+ if (!gitDir.ok)
93
+ return false;
94
+ const lockPath = path.join(gitDir.stdout.trim(), 'index.lock');
95
+ return fs.existsSync(lockPath);
96
+ }
97
+ /**
98
+ * Detects whether multiple distinct brainclaw sessions are using the same
99
+ * physical worktree directory (shared-checkout risk).
100
+ *
101
+ * Only worktrees with a `.brainclaw-worktree.json` sidecar are examined,
102
+ * since those are the ones brainclaw actively manages.
103
+ */
104
+ export function detectSharedCheckoutRisk(mainWorktreePath) {
105
+ const worktrees = listWorktrees(mainWorktreePath);
106
+ const sessionsByPath = new Map();
107
+ for (const wt of worktrees) {
108
+ if (!wt.session_id)
109
+ continue;
110
+ const existing = sessionsByPath.get(wt.path) ?? [];
111
+ existing.push(wt.session_id);
112
+ sessionsByPath.set(wt.path, existing);
113
+ }
114
+ const conflicting = [];
115
+ for (const [wtPath, sessions] of sessionsByPath) {
116
+ if (sessions.length > 1)
117
+ conflicting.push(wtPath);
118
+ }
119
+ return {
120
+ has_conflict: conflicting.length > 0,
121
+ conflicting_paths: conflicting,
122
+ };
123
+ }
124
+ export function findWorktreePathForBranch(worktrees, branchName) {
125
+ return worktrees.find((worktree) => worktree.branch === branchName)?.path;
126
+ }
127
+ /**
128
+ * Creates a git linked worktree at the computed placement path.
129
+ *
130
+ * - If `branchName` does not exist locally, creates it from HEAD.
131
+ * - If the target directory already exists, throws to avoid silent overwrites.
132
+ *
133
+ * Returns the absolute path to the newly created worktree.
134
+ */
135
+ export function createWorktree(mainWorktreePath, branchName, options = {}) {
136
+ const trySymlinkSharedPath = (entryName) => {
137
+ const sourcePath = path.join(mainWorktreePath, entryName);
138
+ const linkPath = path.join(targetPath, entryName);
139
+ if (!fs.existsSync(sourcePath) || fs.existsSync(linkPath)) {
140
+ return;
141
+ }
142
+ try {
143
+ // Ensure parent dir exists for nested paths like vendor/bundle
144
+ const parentDir = path.dirname(linkPath);
145
+ if (parentDir !== targetPath) {
146
+ fs.mkdirSync(parentDir, { recursive: true });
147
+ }
148
+ fs.symlinkSync(sourcePath, linkPath, 'junction');
149
+ }
150
+ catch {
151
+ // Non-fatal - shared paths are an optimization for agent worktrees
152
+ }
153
+ };
154
+ // Guard: bare repos have no working tree
155
+ if (isBareRepo(mainWorktreePath)) {
156
+ throw new Error('Cannot create a brainclaw worktree in a bare git repository.');
157
+ }
158
+ // Guard: active git operation lock
159
+ if (hasGitLock(mainWorktreePath)) {
160
+ throw new Error('Git index.lock detected — another git operation is in progress. Wait for it to complete before creating a worktree.');
161
+ }
162
+ const targetPath = resolveWorktreePath(mainWorktreePath, branchName);
163
+ if (fs.existsSync(targetPath)) {
164
+ throw new Error(`Worktree path already exists: ${targetPath}. Remove it first with 'brainclaw worktree remove'.`);
165
+ }
166
+ // Ensure parent directory exists
167
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
168
+ // Check if branch exists locally
169
+ const branchCheck = runGit(['rev-parse', '--verify', branchName], mainWorktreePath);
170
+ const branchExists = branchCheck.ok;
171
+ const baseRef = options.baseRef ?? 'HEAD';
172
+ if (branchExists && options.resetExistingBranch) {
173
+ const attachedWorktreePath = findWorktreePathForBranch(listWorktrees(mainWorktreePath), branchName);
174
+ if (attachedWorktreePath) {
175
+ throw new Error(`Cannot reset branch ${branchName}: it is checked out in worktree ${attachedWorktreePath}. Remove or merge that worktree first.`);
176
+ }
177
+ const reset = runGit(['branch', '--force', branchName, baseRef], mainWorktreePath);
178
+ if (!reset.ok) {
179
+ throw new Error(`git branch --force failed for ${branchName}: ${reset.stderr.trim()}`);
180
+ }
181
+ }
182
+ // Use forward-slash paths for git on Windows
183
+ const gitTargetPath = gitPath(targetPath);
184
+ const worktreeArgs = branchExists
185
+ ? ['worktree', 'add', gitTargetPath, branchName]
186
+ : ['worktree', 'add', '-b', branchName, gitTargetPath, baseRef];
187
+ const result = runGit(worktreeArgs, mainWorktreePath);
188
+ if (!result.ok) {
189
+ throw new Error(`git worktree add failed: ${result.stderr.trim()}`);
190
+ }
191
+ // After successful worktree creation, add to git safe.directory for cross-user agents (e.g. Codex)
192
+ try {
193
+ runGit(['config', '--global', '--add', 'safe.directory', gitPath(targetPath)], mainWorktreePath);
194
+ }
195
+ catch {
196
+ // Non-fatal - safe.directory may already be set or not needed
197
+ }
198
+ // pln#480: auto-detect shared paths from stack markers + config overrides.
199
+ // `dist` intentionally excluded — build outputs must be per-worktree
200
+ // (EBUSY during clean:dist when MCP/extension holds a handle on junction target).
201
+ const detected = detectStackSharedPaths(mainWorktreePath);
202
+ const extra = options.sharedPaths ?? [];
203
+ const excluded = new Set(options.excludeShared ?? []);
204
+ const sharedPaths = [...new Set([...detected, ...extra])].filter((p) => !excluded.has(p));
205
+ for (const entry of sharedPaths) {
206
+ trySymlinkSharedPath(entry);
207
+ }
208
+ // NOTE: .brainclaw/ is intentionally NOT symlinked.
209
+ // Symlinking .brainclaw/ causes hooks and session_start to trigger on the
210
+ // shared store, creating session conflicts and potentially blocking agents
211
+ // (especially Claude CLI which auto-detects .brainclaw/ presence).
212
+ const mainGitignorePath = path.join(mainWorktreePath, '.gitignore');
213
+ const targetGitignorePath = path.join(targetPath, '.gitignore');
214
+ if (fs.existsSync(mainGitignorePath)) {
215
+ fs.copyFileSync(mainGitignorePath, targetGitignorePath);
216
+ }
217
+ // Write brainclaw metadata sidecar inside the worktree
218
+ const meta = {
219
+ session_id: options.sessionId,
220
+ agent: options.agent,
221
+ user: process.env.USER || process.env.USERNAME || undefined,
222
+ created_at: new Date().toISOString(),
223
+ main_worktree_path: mainWorktreePath,
224
+ base_ref: baseRef,
225
+ reset_existing_branch: options.resetExistingBranch === true,
226
+ git_advice: 'git add ONLY specific files, NEVER git add -A.',
227
+ };
228
+ fs.writeFileSync(path.join(targetPath, '.brainclaw-worktree.json'), JSON.stringify(meta, null, 2));
229
+ return targetPath;
230
+ }
231
+ /**
232
+ * Lists all git worktrees for the given repo and enriches them with
233
+ * brainclaw metadata if available.
234
+ */
235
+ export function listWorktrees(mainWorktreePath) {
236
+ const result = runGit(['worktree', 'list', '--porcelain'], mainWorktreePath);
237
+ if (!result.ok)
238
+ return [];
239
+ const infos = [];
240
+ let current = {};
241
+ let isFirst = true;
242
+ for (const line of result.stdout.split('\n')) {
243
+ if (line.startsWith('worktree ')) {
244
+ if (current.path) {
245
+ infos.push(finaliseWorktree(current));
246
+ }
247
+ current = { path: line.slice('worktree '.length).trim(), is_first: isFirst };
248
+ isFirst = false;
249
+ }
250
+ else if (line.startsWith('HEAD ')) {
251
+ current.commit = line.slice('HEAD '.length).trim();
252
+ }
253
+ else if (line.startsWith('branch ')) {
254
+ // refs/heads/branchname → branchname
255
+ current.raw_branch = line.slice('branch '.length).trim();
256
+ current.branch = current.raw_branch.replace(/^refs\/heads\//, '');
257
+ }
258
+ else if (line.startsWith('bare')) {
259
+ current.branch = '(bare)';
260
+ }
261
+ else if (line === '') {
262
+ // blank line = end of stanza
263
+ }
264
+ }
265
+ if (current.path) {
266
+ infos.push(finaliseWorktree(current));
267
+ }
268
+ return infos;
269
+ }
270
+ function finaliseWorktree(raw) {
271
+ const wt = {
272
+ path: raw.path ?? '',
273
+ branch: raw.branch ?? '(detached)',
274
+ commit: raw.commit ?? '',
275
+ is_main: raw.is_first === true,
276
+ };
277
+ // Try to read brainclaw sidecar
278
+ const sidecarPath = path.join(wt.path, '.brainclaw-worktree.json');
279
+ if (fs.existsSync(sidecarPath)) {
280
+ try {
281
+ const meta = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8'));
282
+ wt.session_id = meta.session_id;
283
+ wt.agent = meta.agent;
284
+ wt.user = meta.user;
285
+ wt.is_main = false;
286
+ }
287
+ catch {
288
+ // ignore parse errors
289
+ }
290
+ }
291
+ return wt;
292
+ }
293
+ /**
294
+ * Removes a linked git worktree.
295
+ *
296
+ * Passes `--force` only if `force` is explicitly set, to avoid accidentally
297
+ * removing worktrees with uncommitted changes.
298
+ */
299
+ /**
300
+ * pln#477 — Path-prefix gate for the worktree GC.
301
+ *
302
+ * Worktree cleanup operations call `fs.rmSync(recursive: true)` which on
303
+ * Windows can follow directory junctions into the main repo and wipe
304
+ * `node_modules/` or `dist/` (trap_merge_wipes_node_modules). Defense
305
+ * in depth: refuse to operate on any path outside the brainclaw-managed
306
+ * scope. Resolves symlinks via `realpath` so a junction pointing OUT of
307
+ * scope is also caught.
308
+ *
309
+ * Allowed roots:
310
+ * - `<userHome>/.brainclaw/worktrees/**` — brainclaw-managed worktrees
311
+ * - `<projectRoot>/.brainclaw/coordination/runtime/**` — runtime artifacts
312
+ */
313
+ export function assertPathInWorktreesScope(target, projectRoot) {
314
+ const resolvedTarget = canonicalizeScopePath(target);
315
+ const worktreesRoot = canonicalizeScopePath(path.join(os.homedir(), '.brainclaw', 'worktrees'));
316
+ const runtimeRoot = canonicalizeScopePath(path.join(projectRoot, '.brainclaw', 'coordination', 'runtime'));
317
+ const isUnderWorktrees = resolvedTarget.startsWith(worktreesRoot + path.sep) || resolvedTarget === worktreesRoot;
318
+ const isUnderRuntime = resolvedTarget.startsWith(runtimeRoot + path.sep) || resolvedTarget === runtimeRoot;
319
+ if (!isUnderWorktrees && !isUnderRuntime) {
320
+ throw new Error(`Refusing to remove path outside brainclaw worktree scope: ${target} (resolves to ${resolvedTarget}). ` +
321
+ `Allowed roots: ${worktreesRoot}, ${runtimeRoot}`);
322
+ }
323
+ }
324
+ /**
325
+ * pln#477 — Safe recursive directory removal that does NOT follow symlinks
326
+ * or directory junctions. Required because brainclaw worktrees contain
327
+ * `node_modules` and `dist` as junctions to the main repo — a naive
328
+ * `fs.rmSync(recursive: true)` would wipe those targets.
329
+ *
330
+ * Walks via `lstat` so links are detached without descending into them.
331
+ */
332
+ export function safeRemoveWorktreeDir(dirPath) {
333
+ let stat;
334
+ try {
335
+ stat = fs.lstatSync(dirPath);
336
+ }
337
+ catch {
338
+ return; // Already gone
339
+ }
340
+ // Symlink (file or directory): unlink only, do not follow.
341
+ if (stat.isSymbolicLink()) {
342
+ try {
343
+ fs.unlinkSync(dirPath);
344
+ }
345
+ catch {
346
+ // Windows directory symlinks/junctions sometimes need rmdir
347
+ try {
348
+ fs.rmdirSync(dirPath);
349
+ }
350
+ catch { /* best effort */ }
351
+ }
352
+ return;
353
+ }
354
+ // Regular directory: recurse via readdir + lstat-based dispatch.
355
+ if (stat.isDirectory()) {
356
+ let entries;
357
+ try {
358
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
359
+ }
360
+ catch {
361
+ return;
362
+ }
363
+ for (const entry of entries) {
364
+ safeRemoveWorktreeDir(path.join(dirPath, entry.name));
365
+ }
366
+ try {
367
+ fs.rmdirSync(dirPath);
368
+ }
369
+ catch {
370
+ // Last-ditch: try unlink for stubborn junction parents.
371
+ try {
372
+ fs.unlinkSync(dirPath);
373
+ }
374
+ catch { /* best effort */ }
375
+ }
376
+ return;
377
+ }
378
+ // Regular file
379
+ try {
380
+ fs.unlinkSync(dirPath);
381
+ }
382
+ catch { /* best effort */ }
383
+ }
384
+ /**
385
+ * pln#498 — Detach top-level symlinks/junctions from a worktree before any
386
+ * recursive removal. On Windows, `git worktree remove` performs its own
387
+ * recursive rm and historically (git ≤ 2.38) followed NTFS junctions into
388
+ * the main repo, wiping `node_modules`. Unlinking the junction entries
389
+ * first leaves git only regular files/dirs to walk.
390
+ *
391
+ * Only top-level entries are inspected — that's where shared paths are
392
+ * symlinked at worktree birth (see createWorktree.trySymlinkSharedPath).
393
+ */
394
+ export function detachWorktreeJunctions(worktreePath) {
395
+ let entries;
396
+ try {
397
+ entries = fs.readdirSync(worktreePath, { withFileTypes: true });
398
+ }
399
+ catch {
400
+ return; // worktree already gone or unreadable
401
+ }
402
+ for (const entry of entries) {
403
+ const child = path.join(worktreePath, entry.name);
404
+ let stat;
405
+ try {
406
+ stat = fs.lstatSync(child);
407
+ }
408
+ catch {
409
+ continue;
410
+ }
411
+ if (!stat.isSymbolicLink())
412
+ continue;
413
+ try {
414
+ fs.unlinkSync(child);
415
+ }
416
+ catch {
417
+ try {
418
+ fs.rmdirSync(child);
419
+ }
420
+ catch { /* best effort */ }
421
+ }
422
+ }
423
+ }
424
+ export function removeWorktree(mainWorktreePath, worktreePath, options = {}) {
425
+ // pln#498: detach junctions BEFORE git's own recursive rm runs. On Windows
426
+ // (git ≤ 2.38) `git worktree remove` follows NTFS junctions into the main
427
+ // repo and wipes node_modules. Removing the symlink entries first means
428
+ // git only walks regular files and dirs.
429
+ if (fs.existsSync(worktreePath)) {
430
+ detachWorktreeJunctions(worktreePath);
431
+ }
432
+ const args = ['worktree', 'remove', worktreePath];
433
+ if (options.force)
434
+ args.push('--force');
435
+ const result = runGit(args, mainWorktreePath);
436
+ if (!result.ok) {
437
+ throw new Error(`git worktree remove failed: ${result.stderr.trim()}`);
438
+ }
439
+ // Remove brainclaw metadata directory if it sits under ~/.brainclaw/worktrees.
440
+ // pln#477: use safeRemoveWorktreeDir to avoid following junctions into the
441
+ // main repo (node_modules / dist symlinks created at worktree birth).
442
+ const base = path.join(os.homedir(), '.brainclaw', 'worktrees');
443
+ if (worktreePath.startsWith(base) && fs.existsSync(worktreePath)) {
444
+ assertPathInWorktreesScope(worktreePath, mainWorktreePath);
445
+ safeRemoveWorktreeDir(worktreePath);
446
+ }
447
+ }
448
+ /**
449
+ * Prunes stale worktree administrative files from `.git/worktrees/`.
450
+ * Equivalent to `git worktree prune`.
451
+ */
452
+ export function pruneWorktrees(mainWorktreePath) {
453
+ runGit(['worktree', 'prune'], mainWorktreePath);
454
+ }
455
+ /**
456
+ * Removes worktrees whose branch has been fully merged into the current branch
457
+ * (typically master/main after a merge). Also removes brainclaw-managed
458
+ * worktree directories that no longer have a corresponding git worktree entry
459
+ * (orphan dirs left behind by force-deleted branches).
460
+ *
461
+ * Safe by default: skips worktrees with uncommitted changes unless `force` is set.
462
+ */
463
+ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
464
+ const result = { removed: [], skipped: [], pruned: false };
465
+ // First prune stale git worktree admin entries
466
+ pruneWorktrees(mainWorktreePath);
467
+ result.pruned = true;
468
+ // Get branches already merged into HEAD
469
+ const mergedOutput = runGit(['branch', '--merged', 'HEAD'], mainWorktreePath);
470
+ const mergedBranches = new Set(mergedOutput.ok
471
+ ? mergedOutput.stdout
472
+ .split('\n')
473
+ .map((b) => b.replace(/^[*+]?\s+/, '').trim())
474
+ .filter(Boolean)
475
+ : []);
476
+ const worktrees = listWorktrees(mainWorktreePath);
477
+ for (const wt of worktrees) {
478
+ if (wt.is_main)
479
+ continue;
480
+ const isMerged = mergedBranches.has(wt.branch);
481
+ if (!isMerged) {
482
+ continue;
483
+ }
484
+ // Check for uncommitted changes
485
+ if (!options.force) {
486
+ const status = runGit(['status', '--porcelain'], wt.path);
487
+ if (status.ok && status.stdout.trim().length > 0) {
488
+ result.skipped.push({ path: wt.path, reason: 'uncommitted changes' });
489
+ continue;
490
+ }
491
+ }
492
+ if (options.dryRun) {
493
+ result.removed.push(wt.path);
494
+ continue;
495
+ }
496
+ try {
497
+ removeWorktree(mainWorktreePath, wt.path, { force: options.force });
498
+ result.removed.push(wt.path);
499
+ }
500
+ catch {
501
+ result.skipped.push({ path: wt.path, reason: 'removal failed' });
502
+ }
503
+ }
504
+ // Clean orphan brainclaw worktree directories (no matching git worktree)
505
+ cleanOrphanWorktreeDirs(mainWorktreePath, worktrees, result, options.dryRun);
506
+ return result;
507
+ }
508
+ /**
509
+ * Removes brainclaw-managed worktree directories under ~/.brainclaw/worktrees/
510
+ * that no longer have a corresponding git worktree entry.
511
+ */
512
+ function cleanOrphanWorktreeDirs(mainWorktreePath, activeWorktrees, result, dryRun) {
513
+ const base = worktreesBaseDir(mainWorktreePath);
514
+ if (!fs.existsSync(base))
515
+ return;
516
+ const activePaths = new Set(activeWorktrees.map((wt) => path.resolve(wt.path)));
517
+ let entries;
518
+ try {
519
+ entries = fs.readdirSync(base, { withFileTypes: true });
520
+ }
521
+ catch {
522
+ return;
523
+ }
524
+ for (const entry of entries) {
525
+ if (!entry.isDirectory())
526
+ continue;
527
+ const dirPath = path.resolve(path.join(base, entry.name));
528
+ if (activePaths.has(dirPath))
529
+ continue;
530
+ // This directory is not referenced by any git worktree — it's orphaned
531
+ if (dryRun) {
532
+ result.removed.push(dirPath);
533
+ }
534
+ else {
535
+ try {
536
+ // pln#477: scope gate + junction-safe walk avoid wiping the main
537
+ // repo's node_modules/dist via junction-following.
538
+ assertPathInWorktreesScope(dirPath, mainWorktreePath);
539
+ safeRemoveWorktreeDir(dirPath);
540
+ result.removed.push(dirPath);
541
+ }
542
+ catch {
543
+ result.skipped.push({ path: dirPath, reason: 'orphan dir removal failed' });
544
+ }
545
+ }
546
+ }
547
+ }
548
+ /**
549
+ * Merges a worktree branch into the current branch with automatic
550
+ * selective merge — detects and restores files that were deleted by
551
+ * worktree divergence (present on target, absent in worktree branch).
552
+ *
553
+ * This eliminates the manual --no-commit + checkout HEAD dance.
554
+ */
555
+ export function mergeWorktreeBranch(mainWorktreePath, branchName, options = {}) {
556
+ // Step 1: Get list of files on current HEAD before merge
557
+ const headFiles = runGit(['ls-tree', '-r', '--name-only', 'HEAD'], mainWorktreePath);
558
+ const currentFiles = new Set(headFiles.ok ? headFiles.stdout.trim().split('\n').filter(Boolean) : []);
559
+ // Step 2: Merge with --no-commit
560
+ const merge = runGit(['merge', branchName, '--no-ff', '--no-commit'], mainWorktreePath);
561
+ if (!merge.ok) {
562
+ // Check for conflicts
563
+ if (merge.stderr.includes('CONFLICT')) {
564
+ return { merged: false, filesChanged: 0, filesRestored: 0, error: 'Merge conflicts detected. Resolve manually.' };
565
+ }
566
+ return { merged: false, filesChanged: 0, filesRestored: 0, error: merge.stderr.trim() };
567
+ }
568
+ // Step 3: Detect parasitic deletions — files that exist on HEAD but are deleted by the merge
569
+ const staged = runGit(['diff', '--cached', '--name-status'], mainWorktreePath);
570
+ const deletions = staged.ok
571
+ ? staged.stdout.trim().split('\n')
572
+ .filter((line) => line.startsWith('D\t'))
573
+ .map((line) => line.slice(2))
574
+ .filter((file) => currentFiles.has(file))
575
+ : [];
576
+ // Step 4: Restore parasitic deletions
577
+ let filesRestored = 0;
578
+ for (const file of deletions) {
579
+ const restore = runGit(['checkout', 'HEAD', '--', file], mainWorktreePath);
580
+ if (restore.ok)
581
+ filesRestored++;
582
+ }
583
+ // Step 5: Count real changes
584
+ const realDiff = runGit(['diff', '--cached', '--stat'], mainWorktreePath);
585
+ const filesChanged = realDiff.ok
586
+ ? (realDiff.stdout.match(/\d+ file/)?.[0]?.match(/\d+/)?.[0] ?? '0')
587
+ : '0';
588
+ if (options.dryRun) {
589
+ runGit(['merge', '--abort'], mainWorktreePath);
590
+ return { merged: false, filesChanged: parseInt(filesChanged, 10), filesRestored, error: 'dry-run' };
591
+ }
592
+ // Step 6: Commit
593
+ const msg = options.message ?? `Merge branch '${branchName}'`;
594
+ const commit = runGit(['commit', '--no-edit', '-m', msg], mainWorktreePath);
595
+ if (!commit.ok) {
596
+ return { merged: false, filesChanged: parseInt(filesChanged, 10), filesRestored, error: commit.stderr.trim() };
597
+ }
598
+ const hash = runGit(['rev-parse', '--short', 'HEAD'], mainWorktreePath);
599
+ return {
600
+ merged: true,
601
+ filesChanged: parseInt(filesChanged, 10),
602
+ filesRestored,
603
+ commitHash: hash.ok ? hash.stdout.trim() : undefined,
604
+ };
605
+ }
606
+ //# sourceMappingURL=worktree.js.map