brainclaw 1.12.0 → 1.14.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.
@@ -229,6 +229,45 @@ function runGit(args, cwd, timeoutMs = GIT_QUERY_TIMEOUT_MS) {
229
229
  stderr: result.stderr ?? '',
230
230
  };
231
231
  }
232
+ /**
233
+ * Resolve the real git worktree root for `cwd` (pln#614). On an IN-TREE project
234
+ * — a project dir that sits inside a larger repo (monorepo), where the git root
235
+ * is an ancestor, not the project dir itself — `git worktree add` MUST run from
236
+ * the true toplevel, and the per-project worktree hash MUST be derived from it.
237
+ *
238
+ * The bug (trp_28025248, cross-machine dogfooding 1.13.0): the assign/review
239
+ * claim path passed the project cwd straight to createWorktree, so `git worktree
240
+ * add` ran from the project dir and — with an empty `.git` left by the embedded
241
+ * init — failed with "not a git repository", while the ideation path (which
242
+ * resolved the toplevel) worked. resolveGitToplevel makes both paths agree.
243
+ *
244
+ * Falls back to the input cwd when `git rev-parse` cannot resolve a toplevel
245
+ * (not a repo, git absent) so non-git callers and tests keep their behaviour.
246
+ */
247
+ export function resolveGitToplevel(cwd) {
248
+ // Codex review of PR #49 (HIGH): a stale/empty `.git` INSIDE the project dir
249
+ // (left by the embedded init — the exact leazzy case) makes `git rev-parse
250
+ // --show-toplevel` FAIL at that level instead of discovering the parent repo:
251
+ // git stops at the invalid gitdir. A plain fallback-to-cwd would then still
252
+ // run from the project dir and hash the subdir — the bug unfixed. So on
253
+ // failure we walk UP and retry from each ancestor, skipping past the invalid
254
+ // nested gitdir until a real toplevel is found; only a truly non-git tree
255
+ // falls back to the input cwd.
256
+ let dir = path.resolve(cwd);
257
+ for (let depth = 0; depth < 64; depth += 1) {
258
+ const result = runGit(['rev-parse', '--show-toplevel'], dir);
259
+ if (result.ok) {
260
+ const top = result.stdout.trim();
261
+ if (top)
262
+ return path.resolve(top);
263
+ }
264
+ const parent = path.dirname(dir);
265
+ if (parent === dir)
266
+ break; // filesystem root — not inside any repo
267
+ dir = parent;
268
+ }
269
+ return cwd;
270
+ }
232
271
  /**
233
272
  * Returns true if the given path is a bare git repository.
234
273
  * Bare repos have no working tree, so worktree add is not applicable.
@@ -444,6 +483,12 @@ export function findWorktreePathForBranch(worktrees, branchName) {
444
483
  * Returns the absolute path to the newly created worktree.
445
484
  */
446
485
  export function createWorktree(mainWorktreePath, branchName, options = {}) {
486
+ // pln#614: resolve the true git toplevel first, so an in-tree project (project
487
+ // dir ≠ git root) creates its worktree from the real repo root — `git worktree
488
+ // add` runs there, and the per-project worktree hash (resolveWorktreePath) is
489
+ // derived from it, matching the ideation path. All git commands + the hash
490
+ // below use this resolved root rather than the raw project cwd.
491
+ mainWorktreePath = resolveGitToplevel(mainWorktreePath);
447
492
  const symlinkWarnings = [];
448
493
  const trySymlinkSharedPath = (entryName) => {
449
494
  const sourcePath = path.join(mainWorktreePath, entryName);
@@ -582,6 +627,16 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
582
627
  if (fs.existsSync(mainGitignorePath)) {
583
628
  fs.copyFileSync(mainGitignorePath, targetGitignorePath);
584
629
  }
630
+ // trp#926 — record the RESOLVED base ref SHA at creation time. base_ref is
631
+ // usually "HEAD" or a branch name, both of which drift after creation and
632
+ // are useless for later "how many commits did the worker add?" comparisons.
633
+ // The resolved SHA is the stable anchor: `${base_ref_sha}..HEAD` on the lane
634
+ // deterministically counts the worker's contribution even as master advances.
635
+ // Best-effort — an unresolvable base_ref simply omits the field.
636
+ const baseRefSha = (() => {
637
+ const rev = runGit(['rev-parse', baseRef], mainWorktreePath);
638
+ return rev.ok ? rev.stdout.trim() : undefined;
639
+ })();
585
640
  // Write brainclaw metadata sidecar inside the worktree
586
641
  const meta = {
587
642
  session_id: options.sessionId,
@@ -590,6 +645,7 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
590
645
  created_at: new Date().toISOString(),
591
646
  main_worktree_path: mainWorktreePath,
592
647
  base_ref: baseRef,
648
+ ...(baseRefSha ? { base_ref_sha: baseRefSha } : {}),
593
649
  reset_existing_branch: options.resetExistingBranch === true,
594
650
  git_advice: 'git add ONLY specific files, NEVER git add -A.',
595
651
  // pln#523: surface any shared-path link failures (e.g. node_modules junction
@@ -827,25 +883,63 @@ export function safeRemoveWorktreeDir(dirPath) {
827
883
  catch { /* best effort */ }
828
884
  }
829
885
  /**
830
- * pln#498 Detach top-level symlinks/junctions from a worktree before any
831
- * recursive removal. On Windows, `git worktree remove` performs its own
832
- * recursive rm and historically (git 2.38) followed NTFS junctions into
833
- * the main repo, wiping `node_modules`. Unlinking the junction entries
834
- * first leaves git only regular files/dirs to walk.
886
+ * Depth cap for detachWorktreeJunctions' recursive walk. 8 covers realistic
887
+ * monorepo trees (apps/<pkg>/packages/<pkg>/node_modules, pnpm nested links)
888
+ * while keeping the walk bounded on pathological structures. Hitting the cap
889
+ * is a hard failure: continuing to `git worktree remove` after an incomplete
890
+ * scan would re-open the junction-follow wipe class. .git is skipped outright
891
+ * — it never contains user junctions and can be very deep.
892
+ */
893
+ const JUNCTION_SCAN_MAX_DEPTH = 8;
894
+ /**
895
+ * pln#498 + trp#926 (2026-07-03 incident) — Detach ALL symlinks/junctions from
896
+ * a worktree before any recursive removal. On Windows, `git worktree remove`
897
+ * performs its own recursive rm and historically (git ≤ 2.38) followed NTFS
898
+ * junctions into the main repo, wiping `node_modules`. Unlinking every
899
+ * junction entry first leaves git only regular files/dirs to walk.
900
+ *
901
+ * Historically this only inspected top-level entries — that covered the
902
+ * classic single-stack shared `node_modules` case but MISSED:
903
+ * - monorepo per-package junctions created by pln#523
904
+ * (apps/<pkg>/node_modules, packages/<pkg>/node_modules);
905
+ * - operator- or worker-created manual junctions at nested paths.
906
+ * The 2026-07-03 incident (node_modules racine rasé via the auto-junction)
907
+ * was a recurrence of the pln#498 class, extended one level of nesting.
835
908
  *
836
- * Only top-level entries are inspected that's where shared paths are
837
- * symlinked at worktree birth (see createWorktree.trySymlinkSharedPath).
909
+ * The recursion NEVER descends into a symlink (lstat + unlink at the entry
910
+ * itself), so it cannot follow a junction into the main repo. `.git/` is
911
+ * skipped entirely — git manages its own state and it never holds user
912
+ * junctions. Depth is capped defensively at JUNCTION_SCAN_MAX_DEPTH; hitting
913
+ * the cap aborts removal rather than silently leaving deeper links in place.
838
914
  */
839
915
  export function detachWorktreeJunctions(worktreePath) {
916
+ if (!fs.existsSync(worktreePath))
917
+ return;
918
+ const failures = [];
919
+ detachJunctionsRecursively(worktreePath, 0, failures);
920
+ if (failures.length > 0) {
921
+ throw new Error(`could not safely detach worktree junctions: ${failures.join('; ')}`);
922
+ }
923
+ }
924
+ function detachJunctionsRecursively(dir, depth, failures) {
925
+ if (depth > JUNCTION_SCAN_MAX_DEPTH) {
926
+ failures.push(`scan depth exceeded at ${dir}`);
927
+ return;
928
+ }
840
929
  let entries;
841
930
  try {
842
- entries = fs.readdirSync(worktreePath, { withFileTypes: true });
931
+ entries = fs.readdirSync(dir, { withFileTypes: true });
843
932
  }
844
- catch {
845
- return; // worktree already gone or unreadable
933
+ catch (err) {
934
+ failures.push(`could not read ${dir}: ${err.message}`);
935
+ return;
846
936
  }
847
937
  for (const entry of entries) {
848
- const child = path.join(worktreePath, entry.name);
938
+ // `.git` is a file (linked worktree pointer) at the worktree root or a
939
+ // real dir in the main repo — either way, do not walk it.
940
+ if (entry.name === '.git')
941
+ continue;
942
+ const child = path.join(dir, entry.name);
849
943
  let stat;
850
944
  try {
851
945
  stat = fs.lstatSync(child);
@@ -853,16 +947,24 @@ export function detachWorktreeJunctions(worktreePath) {
853
947
  catch {
854
948
  continue;
855
949
  }
856
- if (!stat.isSymbolicLink())
857
- continue;
858
- try {
859
- fs.unlinkSync(child);
860
- }
861
- catch {
950
+ if (stat.isSymbolicLink()) {
951
+ // Unlink the junction. Do NOT descend into it (that would follow the
952
+ // link back into the main repo — the exact class we're preventing).
862
953
  try {
863
- fs.rmdirSync(child);
954
+ fs.unlinkSync(child);
864
955
  }
865
- catch { /* best effort */ }
956
+ catch (unlinkErr) {
957
+ try {
958
+ fs.rmdirSync(child);
959
+ }
960
+ catch (rmdirErr) {
961
+ failures.push(`could not detach link ${child}: unlink=${unlinkErr.message}; rmdir=${rmdirErr.message}`);
962
+ }
963
+ }
964
+ continue;
965
+ }
966
+ if (stat.isDirectory()) {
967
+ detachJunctionsRecursively(child, depth + 1, failures);
866
968
  }
867
969
  }
868
970
  }
@@ -924,6 +1026,57 @@ export function worktreeHasOnlyBirthNoise(statusZStdout) {
924
1026
  || isSystemDirtyPath(norm);
925
1027
  });
926
1028
  }
1029
+ /**
1030
+ * trp#926 (squash-aware GC) — True when every commit on `branch` that is not
1031
+ * an ancestor of `baseRef` has a patch-equivalent commit ALREADY on `baseRef`.
1032
+ *
1033
+ * `git branch --merged HEAD` and `merge-base --is-ancestor` are ancestry-only:
1034
+ * a squash-merge on GitHub creates a NEW commit on master whose ancestry does
1035
+ * not include the lane commits, so an ancestry probe returns "not merged" and
1036
+ * the GC keeps the worktree forever. `git cherry <base> <branch>` uses
1037
+ * patch-id equivalence — the same signal GitHub itself uses to say "this PR is
1038
+ * merged". Output is one line per commit in `base..branch`:
1039
+ * `+ <sha>` = patch NOT yet on base (a real un-integrated commit)
1040
+ * `- <sha>` = patch already on base (via squash / cherry-pick / rebase)
1041
+ * The branch is "merged by content" iff there are no `+` lines. An empty
1042
+ * output (no commits ahead of base) is also merged.
1043
+ *
1044
+ * Returns `false` on any git failure — a probe that cannot prove merged must
1045
+ * NEVER lie "yes" and cause a keep-me worktree to be GC'd. Callers combine
1046
+ * this with ancestry ('git branch --merged') so both signals contribute.
1047
+ */
1048
+ export function isBranchMergedByContent(mainWorktreePath, branchName, baseRef = 'HEAD') {
1049
+ const cherry = runGit(['cherry', baseRef, branchName], mainWorktreePath);
1050
+ if (!cherry.ok)
1051
+ return false;
1052
+ const lines = cherry.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1053
+ if (lines.length === 0)
1054
+ return true; // no commits ahead of base → fully merged
1055
+ // A `+` line means "patch not on base" — one is enough to disqualify.
1056
+ if (!lines.some((l) => l.startsWith('+ ')))
1057
+ return true;
1058
+ // Multi-commit squash merges often do not produce per-commit patch-id
1059
+ // matches: the squash commit's aggregate patch differs from each individual
1060
+ // branch commit. As a second, still content-only signal, compare the final
1061
+ // content of files changed by the branch. If every branch-touched path now
1062
+ // matches baseRef, removing the worktree cannot drop unique file content.
1063
+ const mergeBase = runGit(['merge-base', baseRef, branchName], mainWorktreePath);
1064
+ if (!mergeBase.ok || !mergeBase.stdout.trim())
1065
+ return false;
1066
+ const changed = runGit(['diff', '--name-only', '-z', mergeBase.stdout.trim(), branchName], mainWorktreePath);
1067
+ if (!changed.ok)
1068
+ return false;
1069
+ const paths = changed.stdout.split('\0').filter(Boolean);
1070
+ if (paths.length === 0)
1071
+ return true;
1072
+ for (let i = 0; i < paths.length; i += 100) {
1073
+ const chunk = paths.slice(i, i + 100);
1074
+ const diff = runGit(['diff', '--quiet', branchName, baseRef, '--', ...chunk], mainWorktreePath);
1075
+ if (!diff.ok)
1076
+ return false;
1077
+ }
1078
+ return true;
1079
+ }
927
1080
  /**
928
1081
  * Removes worktrees whose branch has been fully merged into the current branch
929
1082
  * (typically master/main after a merge). Also removes brainclaw-managed
@@ -931,9 +1084,19 @@ export function worktreeHasOnlyBirthNoise(statusZStdout) {
931
1084
  * (orphan dirs left behind by force-deleted branches).
932
1085
  *
933
1086
  * Safe by default: skips worktrees with uncommitted changes unless `force` is set.
1087
+ *
1088
+ * trp#926 — Merged detection is a UNION of two signals:
1089
+ * - ancestry (`git branch --merged HEAD`): catches fast-forward / merge-commit;
1090
+ * - content (`git cherry HEAD <branch>`, patch-id): catches squash merges,
1091
+ * which is GitHub's default merge strategy on this repo and previously left
1092
+ * every squashed lane un-GC-able forever.
934
1093
  */
935
1094
  export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
936
1095
  const result = { removed: [], skipped: [], pruned: false };
1096
+ // pln#614: resolve the toplevel so an in-tree project scans the same
1097
+ // per-project worktree hash createWorktree wrote under (and runs git from the
1098
+ // real repo root).
1099
+ mainWorktreePath = resolveGitToplevel(mainWorktreePath);
937
1100
  // First prune stale git worktree admin entries
938
1101
  pruneWorktrees(mainWorktreePath);
939
1102
  result.pruned = true;
@@ -949,7 +1112,12 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
949
1112
  for (const wt of worktrees) {
950
1113
  if (wt.is_main)
951
1114
  continue;
952
- const isMerged = mergedBranches.has(wt.branch);
1115
+ // trp#926 a lane's branch is "merged" if EITHER git says its commits are
1116
+ // ancestors of HEAD (fast-forward / merge-commit) OR every commit's patch
1117
+ // is already on HEAD (squash-merge, catching GitHub's default strategy).
1118
+ // Without the content probe, squashed lanes accumulate forever.
1119
+ const isMerged = mergedBranches.has(wt.branch)
1120
+ || isBranchMergedByContent(mainWorktreePath, wt.branch, 'HEAD');
953
1121
  if (!isMerged) {
954
1122
  continue;
955
1123
  }
@@ -1028,6 +1196,9 @@ export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options =
1028
1196
  });
1029
1197
  if (!worktreePath || !fs.existsSync(worktreePath))
1030
1198
  return out(false, 'already gone');
1199
+ // pln#614: the merge-base / patch-id probes below run from the main repo — an
1200
+ // in-tree project dir (empty .git) would fail them; resolve the real toplevel.
1201
+ mainWorktreePath = resolveGitToplevel(mainWorktreePath);
1031
1202
  if (workerLooksAlive(worktreePath, options.livenessWindowMs ?? WORKTREE_GC_LIVENESS_WINDOW_MS)) {
1032
1203
  return out(false, 'worker still active (recent heartbeat)');
1033
1204
  }
@@ -1054,9 +1225,14 @@ export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options =
1054
1225
  }
1055
1226
  const ancestor = runGit(['merge-base', '--is-ancestor', laneHead.stdout.trim(), mainHead.stdout.trim()], mainWorktreePath);
1056
1227
  // exit 0 = ancestor (safe). Non-zero = not an ancestor OR a git error — both
1057
- // mean "cannot prove integrated", so keep.
1058
- if (!ancestor.ok)
1228
+ // mean "cannot prove integrated via ancestry". trp#926 — fall back to the
1229
+ // content probe (patch-id): a squash-merged lane is not an ancestor but is
1230
+ // fully integrated, and previously the GC kept it forever. Ancestry OR
1231
+ // content, either signal is enough; a failed content probe still keeps the
1232
+ // worktree (isBranchMergedByContent returns false on git failure).
1233
+ if (!ancestor.ok && branch && !isBranchMergedByContent(mainWorktreePath, branch, mainHead.stdout.trim())) {
1059
1234
  return out(false, 'lane branch has un-integrated commits (or unverifiable)', branch);
1235
+ }
1060
1236
  }
1061
1237
  try {
1062
1238
  removeWorktree(mainWorktreePath, worktreePath, { force: true });
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.12.0 on 2026-06-27T13:47:04.254Z
2
+ // Source: brainclaw v1.14.0 on 2026-07-05T13:38:40.276Z
3
3
  export const FACTS = {
4
- "version": "1.12.0",
5
- "generated_at": "2026-06-27T13:47:04.254Z",
4
+ "version": "1.14.0",
5
+ "generated_at": "2026-07-05T13:38:40.276Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 66,
@@ -469,6 +469,39 @@ export const FACTS = {
469
469
  "max_concurrent_tasks": 1
470
470
  }
471
471
  ]
472
+ },
473
+ "bench": {
474
+ "schema": "brainclaw.bench.v1",
475
+ "generated_at": "2026-07-05T13:38:38.216Z",
476
+ "node_version": "v24.18.0",
477
+ "platform": "linux-x64",
478
+ "repeats": 3,
479
+ "scenarios": [
480
+ {
481
+ "name": "cold_onboard",
482
+ "volume": "empty",
483
+ "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
484
+ "duration_ms_median": 72,
485
+ "payload_chars_median": 1650,
486
+ "payload_tokens_est_median": 413
487
+ },
488
+ {
489
+ "name": "warm_work",
490
+ "volume": "medium",
491
+ "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
+ "duration_ms_median": 126,
493
+ "payload_chars_median": 2626,
494
+ "payload_tokens_est_median": 657
495
+ },
496
+ {
497
+ "name": "first_edit",
498
+ "volume": "medium",
499
+ "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
+ "duration_ms_median": 8,
501
+ "payload_chars_median": 442,
502
+ "payload_tokens_est_median": 111
503
+ }
504
+ ]
472
505
  }
473
506
  }
474
507
  export default FACTS
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.12.0",
3
- "generated_at": "2026-06-27T13:47:04.254Z",
2
+ "version": "1.14.0",
3
+ "generated_at": "2026-07-05T13:38:40.276Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 66,
@@ -467,5 +467,38 @@
467
467
  "max_concurrent_tasks": 1
468
468
  }
469
469
  ]
470
+ },
471
+ "bench": {
472
+ "schema": "brainclaw.bench.v1",
473
+ "generated_at": "2026-07-05T13:38:38.216Z",
474
+ "node_version": "v24.18.0",
475
+ "platform": "linux-x64",
476
+ "repeats": 3,
477
+ "scenarios": [
478
+ {
479
+ "name": "cold_onboard",
480
+ "volume": "empty",
481
+ "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
482
+ "duration_ms_median": 72,
483
+ "payload_chars_median": 1650,
484
+ "payload_tokens_est_median": 413
485
+ },
486
+ {
487
+ "name": "warm_work",
488
+ "volume": "medium",
489
+ "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
490
+ "duration_ms_median": 126,
491
+ "payload_chars_median": 2626,
492
+ "payload_tokens_est_median": 657
493
+ },
494
+ {
495
+ "name": "first_edit",
496
+ "volume": "medium",
497
+ "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
498
+ "duration_ms_median": 8,
499
+ "payload_chars_median": 442,
500
+ "payload_tokens_est_median": 111
501
+ }
502
+ ]
470
503
  }
471
504
  }