brainclaw 0.29.2 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -170
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +673 -24
- package/dist/commands/accept.js +3 -0
- package/dist/commands/add-step.js +11 -26
- package/dist/commands/agent-board.js +70 -3
- package/dist/commands/audit.js +19 -0
- package/dist/commands/check-policy.js +54 -0
- package/dist/commands/check-security-mcp.js +145 -0
- package/dist/commands/check-security.js +106 -0
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/codev.js +672 -0
- package/dist/commands/compact.js +74 -0
- package/dist/commands/complete-step.js +16 -26
- package/dist/commands/constraint.js +8 -20
- package/dist/commands/decision.js +9 -20
- package/dist/commands/delete-plan.js +10 -12
- package/dist/commands/delete-step.js +16 -0
- package/dist/commands/dispatch.js +163 -0
- package/dist/commands/doctor.js +1122 -49
- package/dist/commands/enable-agent.js +1 -0
- package/dist/commands/export.js +280 -22
- package/dist/commands/handoff.js +33 -0
- package/dist/commands/harvest.js +189 -0
- package/dist/commands/hooks.js +82 -25
- package/dist/commands/inbox.js +169 -0
- package/dist/commands/init.js +38 -31
- package/dist/commands/install-hooks.js +71 -44
- package/dist/commands/link.js +89 -0
- package/dist/commands/list-claims.js +48 -3
- package/dist/commands/list-plans.js +129 -25
- package/dist/commands/loops-handlers.js +409 -0
- package/dist/commands/mcp-read-handlers.js +1628 -0
- package/dist/commands/mcp-schemas.generated.js +74 -0
- package/dist/commands/mcp.js +4221 -1501
- package/dist/commands/plan-resource.js +64 -0
- package/dist/commands/plan.js +12 -26
- package/dist/commands/prune.js +37 -2
- package/dist/commands/reflect.js +20 -7
- package/dist/commands/release-claim.js +11 -6
- package/dist/commands/release-notes.js +170 -0
- package/dist/commands/repair.js +210 -0
- package/dist/commands/run-profile.js +57 -0
- package/dist/commands/sequence.js +113 -0
- package/dist/commands/session-end.js +423 -14
- package/dist/commands/session-start.js +214 -41
- package/dist/commands/setup-security.js +103 -0
- package/dist/commands/setup.js +42 -4
- package/dist/commands/stale.js +109 -0
- package/dist/commands/switch.js +100 -2
- package/dist/commands/trap.js +14 -31
- package/dist/commands/update-handoff.js +63 -4
- package/dist/commands/update-plan.js +21 -28
- package/dist/commands/update-step.js +37 -0
- package/dist/commands/upgrade.js +313 -6
- package/dist/commands/usage.js +102 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/who.js +33 -5
- package/dist/commands/worktree.js +105 -0
- package/dist/core/actions.js +315 -0
- package/dist/core/agent-capability.js +610 -17
- package/dist/core/agent-context.js +7 -1
- package/dist/core/agent-files.js +1169 -85
- package/dist/core/agent-integrations.js +160 -5
- package/dist/core/agent-inventory.js +2 -0
- package/dist/core/agent-profiles.js +93 -0
- package/dist/core/agent-registry.js +162 -30
- package/dist/core/agentrun-reconciler.js +345 -0
- package/dist/core/agentruns.js +424 -0
- package/dist/core/ai-agent-detection.js +31 -10
- package/dist/core/archival.js +77 -0
- package/dist/core/assignment-sweeper.js +82 -0
- package/dist/core/assignments.js +367 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/brainclaw-version.js +94 -2
- package/dist/core/candidates.js +93 -2
- package/dist/core/claims.js +419 -0
- package/dist/core/codev-metrics.js +77 -0
- package/dist/core/codev-personas.js +31 -0
- package/dist/core/codev-plan-gen.js +35 -0
- package/dist/core/codev-prompts.js +74 -0
- package/dist/core/codev-responses.js +62 -0
- package/dist/core/codev-rounds.js +218 -0
- package/dist/core/config.js +4 -0
- package/dist/core/context.js +381 -34
- package/dist/core/coordination.js +201 -6
- package/dist/core/cross-project.js +230 -16
- package/dist/core/default-profiles/doctor.yaml +11 -0
- package/dist/core/default-profiles/janitor.yaml +11 -0
- package/dist/core/default-profiles/onboarder.yaml +11 -0
- package/dist/core/default-profiles/reviewer.yaml +13 -0
- package/dist/core/dispatcher.js +1189 -0
- package/dist/core/duplicates.js +2 -2
- package/dist/core/entity-operations.js +450 -0
- package/dist/core/entity-registry.js +344 -0
- package/dist/core/events.js +106 -2
- package/dist/core/execution-adapters.js +154 -0
- package/dist/core/execution-context.js +63 -0
- package/dist/core/execution-profile.js +270 -0
- package/dist/core/execution.js +255 -0
- package/dist/core/facade-schema.js +81 -0
- package/dist/core/federation-cloud.js +99 -0
- package/dist/core/federation-message.js +52 -0
- package/dist/core/federation-transport.js +65 -0
- package/dist/core/gc-semantic.js +482 -0
- package/dist/core/governance.js +247 -0
- package/dist/core/guards.js +19 -0
- package/dist/core/ideation.js +72 -0
- package/dist/core/identity.js +110 -25
- package/dist/core/ids.js +6 -0
- package/dist/core/input-validation.js +2 -2
- package/dist/core/instruction-templates.js +344 -136
- package/dist/core/io.js +90 -11
- package/dist/core/lock.js +6 -2
- package/dist/core/loops/brief-assembly.js +213 -0
- package/dist/core/loops/facade-schema.js +148 -0
- package/dist/core/loops/index.js +7 -0
- package/dist/core/loops/iteration-engine.js +139 -0
- package/dist/core/loops/lock.js +385 -0
- package/dist/core/loops/store.js +201 -0
- package/dist/core/loops/types.js +403 -0
- package/dist/core/loops/verbs.js +534 -0
- package/dist/core/markdown.js +15 -3
- package/dist/core/memory-compactor.js +432 -0
- package/dist/core/memory-git.js +152 -8
- package/dist/core/messaging.js +278 -0
- package/dist/core/migration.js +32 -1
- package/dist/core/mutation-pipeline.js +4 -2
- package/dist/core/operations/memory-mutation.js +129 -0
- package/dist/core/operations/memory-write.js +78 -0
- package/dist/core/operations/plan.js +190 -0
- package/dist/core/policy.js +169 -0
- package/dist/core/reputation.js +9 -3
- package/dist/core/schema.js +491 -6
- package/dist/core/search.js +21 -2
- package/dist/core/security-cache.js +71 -0
- package/dist/core/security-guard.js +152 -0
- package/dist/core/security-scoring.js +86 -0
- package/dist/core/sequence.js +130 -0
- package/dist/core/socket-client.js +113 -0
- package/dist/core/staleness.js +246 -0
- package/dist/core/state.js +98 -22
- package/dist/core/store-resolution.js +43 -11
- package/dist/core/toml-writer.js +76 -0
- package/dist/core/upgrades/backup.js +232 -0
- package/dist/core/upgrades/health-check.js +169 -0
- package/dist/core/upgrades/patches/candidate-archive.js +145 -0
- package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
- package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
- package/dist/core/upgrades/schema-version.js +97 -0
- package/dist/core/worktree.js +606 -0
- package/dist/facts.js +114 -0
- package/dist/facts.json +111 -0
- package/docs/architecture/project-refs.md +5 -1
- package/docs/cli.md +690 -43
- package/docs/concepts/ideation-loop.md +317 -0
- package/docs/concepts/loop-engine.md +456 -0
- package/docs/concepts/mcp-governance.md +268 -0
- package/docs/concepts/memory-staleness.md +122 -0
- package/docs/concepts/multi-agent-workflows.md +166 -0
- package/docs/concepts/plans-and-claims.md +31 -6
- package/docs/concepts/project-md-convention.md +35 -0
- package/docs/concepts/troubleshooting.md +220 -0
- package/docs/concepts/upgrade-cli.md +202 -0
- package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
- package/docs/context-format-changelog.md +2 -2
- package/docs/context-format.md +2 -2
- package/docs/index.md +68 -0
- package/docs/integrations/agents.md +15 -16
- package/docs/integrations/cline.md +88 -0
- package/docs/integrations/codex.md +75 -23
- package/docs/integrations/continue.md +60 -0
- package/docs/integrations/copilot.md +67 -9
- package/docs/integrations/kilocode.md +72 -0
- package/docs/integrations/mcp.md +304 -21
- package/docs/integrations/mistral-vibe.md +122 -0
- package/docs/integrations/opencode.md +84 -0
- package/docs/integrations/overview.md +23 -8
- package/docs/integrations/roo.md +74 -0
- package/docs/integrations/windsurf.md +83 -0
- package/docs/mcp-schema-changelog.md +191 -1
- package/docs/playbooks/integration/index.md +121 -0
- package/docs/playbooks/productivity/index.md +102 -0
- package/docs/playbooks/team/index.md +122 -0
- package/docs/product/agent-first-model.md +184 -0
- package/docs/product/entity-model-audit.md +462 -0
- package/docs/quickstart-existing-project.md +135 -0
- package/docs/quickstart.md +124 -37
- package/docs/release-maintenance.md +79 -0
- package/docs/review.md +2 -0
- package/docs/server-operations.md +118 -0
- package/package.json +20 -12
- package/dist/commands/claude-desktop-extension.js +0 -18
- package/dist/commands/diff.js +0 -99
- 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
|