brainclaw 1.12.0 → 1.13.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.
@@ -582,6 +582,16 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
582
582
  if (fs.existsSync(mainGitignorePath)) {
583
583
  fs.copyFileSync(mainGitignorePath, targetGitignorePath);
584
584
  }
585
+ // trp#926 — record the RESOLVED base ref SHA at creation time. base_ref is
586
+ // usually "HEAD" or a branch name, both of which drift after creation and
587
+ // are useless for later "how many commits did the worker add?" comparisons.
588
+ // The resolved SHA is the stable anchor: `${base_ref_sha}..HEAD` on the lane
589
+ // deterministically counts the worker's contribution even as master advances.
590
+ // Best-effort — an unresolvable base_ref simply omits the field.
591
+ const baseRefSha = (() => {
592
+ const rev = runGit(['rev-parse', baseRef], mainWorktreePath);
593
+ return rev.ok ? rev.stdout.trim() : undefined;
594
+ })();
585
595
  // Write brainclaw metadata sidecar inside the worktree
586
596
  const meta = {
587
597
  session_id: options.sessionId,
@@ -590,6 +600,7 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
590
600
  created_at: new Date().toISOString(),
591
601
  main_worktree_path: mainWorktreePath,
592
602
  base_ref: baseRef,
603
+ ...(baseRefSha ? { base_ref_sha: baseRefSha } : {}),
593
604
  reset_existing_branch: options.resetExistingBranch === true,
594
605
  git_advice: 'git add ONLY specific files, NEVER git add -A.',
595
606
  // pln#523: surface any shared-path link failures (e.g. node_modules junction
@@ -827,25 +838,63 @@ export function safeRemoveWorktreeDir(dirPath) {
827
838
  catch { /* best effort */ }
828
839
  }
829
840
  /**
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.
841
+ * Depth cap for detachWorktreeJunctions' recursive walk. 8 covers realistic
842
+ * monorepo trees (apps/<pkg>/packages/<pkg>/node_modules, pnpm nested links)
843
+ * while keeping the walk bounded on pathological structures. Hitting the cap
844
+ * is a hard failure: continuing to `git worktree remove` after an incomplete
845
+ * scan would re-open the junction-follow wipe class. .git is skipped outright
846
+ * — it never contains user junctions and can be very deep.
847
+ */
848
+ const JUNCTION_SCAN_MAX_DEPTH = 8;
849
+ /**
850
+ * pln#498 + trp#926 (2026-07-03 incident) — Detach ALL symlinks/junctions from
851
+ * a worktree before any recursive removal. On Windows, `git worktree remove`
852
+ * performs its own recursive rm and historically (git ≤ 2.38) followed NTFS
853
+ * junctions into the main repo, wiping `node_modules`. Unlinking every
854
+ * junction entry first leaves git only regular files/dirs to walk.
835
855
  *
836
- * Only top-level entries are inspected — that's where shared paths are
837
- * symlinked at worktree birth (see createWorktree.trySymlinkSharedPath).
856
+ * Historically this only inspected top-level entries — that covered the
857
+ * classic single-stack shared `node_modules` case but MISSED:
858
+ * - monorepo per-package junctions created by pln#523
859
+ * (apps/<pkg>/node_modules, packages/<pkg>/node_modules);
860
+ * - operator- or worker-created manual junctions at nested paths.
861
+ * The 2026-07-03 incident (node_modules racine rasé via the auto-junction)
862
+ * was a recurrence of the pln#498 class, extended one level of nesting.
863
+ *
864
+ * The recursion NEVER descends into a symlink (lstat + unlink at the entry
865
+ * itself), so it cannot follow a junction into the main repo. `.git/` is
866
+ * skipped entirely — git manages its own state and it never holds user
867
+ * junctions. Depth is capped defensively at JUNCTION_SCAN_MAX_DEPTH; hitting
868
+ * the cap aborts removal rather than silently leaving deeper links in place.
838
869
  */
839
870
  export function detachWorktreeJunctions(worktreePath) {
871
+ if (!fs.existsSync(worktreePath))
872
+ return;
873
+ const failures = [];
874
+ detachJunctionsRecursively(worktreePath, 0, failures);
875
+ if (failures.length > 0) {
876
+ throw new Error(`could not safely detach worktree junctions: ${failures.join('; ')}`);
877
+ }
878
+ }
879
+ function detachJunctionsRecursively(dir, depth, failures) {
880
+ if (depth > JUNCTION_SCAN_MAX_DEPTH) {
881
+ failures.push(`scan depth exceeded at ${dir}`);
882
+ return;
883
+ }
840
884
  let entries;
841
885
  try {
842
- entries = fs.readdirSync(worktreePath, { withFileTypes: true });
886
+ entries = fs.readdirSync(dir, { withFileTypes: true });
843
887
  }
844
- catch {
845
- return; // worktree already gone or unreadable
888
+ catch (err) {
889
+ failures.push(`could not read ${dir}: ${err.message}`);
890
+ return;
846
891
  }
847
892
  for (const entry of entries) {
848
- const child = path.join(worktreePath, entry.name);
893
+ // `.git` is a file (linked worktree pointer) at the worktree root or a
894
+ // real dir in the main repo — either way, do not walk it.
895
+ if (entry.name === '.git')
896
+ continue;
897
+ const child = path.join(dir, entry.name);
849
898
  let stat;
850
899
  try {
851
900
  stat = fs.lstatSync(child);
@@ -853,16 +902,24 @@ export function detachWorktreeJunctions(worktreePath) {
853
902
  catch {
854
903
  continue;
855
904
  }
856
- if (!stat.isSymbolicLink())
857
- continue;
858
- try {
859
- fs.unlinkSync(child);
860
- }
861
- catch {
905
+ if (stat.isSymbolicLink()) {
906
+ // Unlink the junction. Do NOT descend into it (that would follow the
907
+ // link back into the main repo — the exact class we're preventing).
862
908
  try {
863
- fs.rmdirSync(child);
909
+ fs.unlinkSync(child);
864
910
  }
865
- catch { /* best effort */ }
911
+ catch (unlinkErr) {
912
+ try {
913
+ fs.rmdirSync(child);
914
+ }
915
+ catch (rmdirErr) {
916
+ failures.push(`could not detach link ${child}: unlink=${unlinkErr.message}; rmdir=${rmdirErr.message}`);
917
+ }
918
+ }
919
+ continue;
920
+ }
921
+ if (stat.isDirectory()) {
922
+ detachJunctionsRecursively(child, depth + 1, failures);
866
923
  }
867
924
  }
868
925
  }
@@ -924,6 +981,57 @@ export function worktreeHasOnlyBirthNoise(statusZStdout) {
924
981
  || isSystemDirtyPath(norm);
925
982
  });
926
983
  }
984
+ /**
985
+ * trp#926 (squash-aware GC) — True when every commit on `branch` that is not
986
+ * an ancestor of `baseRef` has a patch-equivalent commit ALREADY on `baseRef`.
987
+ *
988
+ * `git branch --merged HEAD` and `merge-base --is-ancestor` are ancestry-only:
989
+ * a squash-merge on GitHub creates a NEW commit on master whose ancestry does
990
+ * not include the lane commits, so an ancestry probe returns "not merged" and
991
+ * the GC keeps the worktree forever. `git cherry <base> <branch>` uses
992
+ * patch-id equivalence — the same signal GitHub itself uses to say "this PR is
993
+ * merged". Output is one line per commit in `base..branch`:
994
+ * `+ <sha>` = patch NOT yet on base (a real un-integrated commit)
995
+ * `- <sha>` = patch already on base (via squash / cherry-pick / rebase)
996
+ * The branch is "merged by content" iff there are no `+` lines. An empty
997
+ * output (no commits ahead of base) is also merged.
998
+ *
999
+ * Returns `false` on any git failure — a probe that cannot prove merged must
1000
+ * NEVER lie "yes" and cause a keep-me worktree to be GC'd. Callers combine
1001
+ * this with ancestry ('git branch --merged') so both signals contribute.
1002
+ */
1003
+ export function isBranchMergedByContent(mainWorktreePath, branchName, baseRef = 'HEAD') {
1004
+ const cherry = runGit(['cherry', baseRef, branchName], mainWorktreePath);
1005
+ if (!cherry.ok)
1006
+ return false;
1007
+ const lines = cherry.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1008
+ if (lines.length === 0)
1009
+ return true; // no commits ahead of base → fully merged
1010
+ // A `+` line means "patch not on base" — one is enough to disqualify.
1011
+ if (!lines.some((l) => l.startsWith('+ ')))
1012
+ return true;
1013
+ // Multi-commit squash merges often do not produce per-commit patch-id
1014
+ // matches: the squash commit's aggregate patch differs from each individual
1015
+ // branch commit. As a second, still content-only signal, compare the final
1016
+ // content of files changed by the branch. If every branch-touched path now
1017
+ // matches baseRef, removing the worktree cannot drop unique file content.
1018
+ const mergeBase = runGit(['merge-base', baseRef, branchName], mainWorktreePath);
1019
+ if (!mergeBase.ok || !mergeBase.stdout.trim())
1020
+ return false;
1021
+ const changed = runGit(['diff', '--name-only', '-z', mergeBase.stdout.trim(), branchName], mainWorktreePath);
1022
+ if (!changed.ok)
1023
+ return false;
1024
+ const paths = changed.stdout.split('\0').filter(Boolean);
1025
+ if (paths.length === 0)
1026
+ return true;
1027
+ for (let i = 0; i < paths.length; i += 100) {
1028
+ const chunk = paths.slice(i, i + 100);
1029
+ const diff = runGit(['diff', '--quiet', branchName, baseRef, '--', ...chunk], mainWorktreePath);
1030
+ if (!diff.ok)
1031
+ return false;
1032
+ }
1033
+ return true;
1034
+ }
927
1035
  /**
928
1036
  * Removes worktrees whose branch has been fully merged into the current branch
929
1037
  * (typically master/main after a merge). Also removes brainclaw-managed
@@ -931,6 +1039,12 @@ export function worktreeHasOnlyBirthNoise(statusZStdout) {
931
1039
  * (orphan dirs left behind by force-deleted branches).
932
1040
  *
933
1041
  * Safe by default: skips worktrees with uncommitted changes unless `force` is set.
1042
+ *
1043
+ * trp#926 — Merged detection is a UNION of two signals:
1044
+ * - ancestry (`git branch --merged HEAD`): catches fast-forward / merge-commit;
1045
+ * - content (`git cherry HEAD <branch>`, patch-id): catches squash merges,
1046
+ * which is GitHub's default merge strategy on this repo and previously left
1047
+ * every squashed lane un-GC-able forever.
934
1048
  */
935
1049
  export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
936
1050
  const result = { removed: [], skipped: [], pruned: false };
@@ -949,7 +1063,12 @@ export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
949
1063
  for (const wt of worktrees) {
950
1064
  if (wt.is_main)
951
1065
  continue;
952
- const isMerged = mergedBranches.has(wt.branch);
1066
+ // trp#926 a lane's branch is "merged" if EITHER git says its commits are
1067
+ // ancestors of HEAD (fast-forward / merge-commit) OR every commit's patch
1068
+ // is already on HEAD (squash-merge, catching GitHub's default strategy).
1069
+ // Without the content probe, squashed lanes accumulate forever.
1070
+ const isMerged = mergedBranches.has(wt.branch)
1071
+ || isBranchMergedByContent(mainWorktreePath, wt.branch, 'HEAD');
953
1072
  if (!isMerged) {
954
1073
  continue;
955
1074
  }
@@ -1054,9 +1173,14 @@ export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options =
1054
1173
  }
1055
1174
  const ancestor = runGit(['merge-base', '--is-ancestor', laneHead.stdout.trim(), mainHead.stdout.trim()], mainWorktreePath);
1056
1175
  // 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)
1176
+ // mean "cannot prove integrated via ancestry". trp#926 — fall back to the
1177
+ // content probe (patch-id): a squash-merged lane is not an ancestor but is
1178
+ // fully integrated, and previously the GC kept it forever. Ancestry OR
1179
+ // content, either signal is enough; a failed content probe still keeps the
1180
+ // worktree (isBranchMergedByContent returns false on git failure).
1181
+ if (!ancestor.ok && branch && !isBranchMergedByContent(mainWorktreePath, branch, mainHead.stdout.trim())) {
1059
1182
  return out(false, 'lane branch has un-integrated commits (or unverifiable)', branch);
1183
+ }
1060
1184
  }
1061
1185
  try {
1062
1186
  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.13.0 on 2026-07-04T20:40:39.712Z
3
3
  export const FACTS = {
4
- "version": "1.12.0",
5
- "generated_at": "2026-06-27T13:47:04.254Z",
4
+ "version": "1.13.0",
5
+ "generated_at": "2026-07-04T20:40:39.712Z",
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-04T20:40:37.627Z",
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": 78,
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": 130,
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.13.0",
3
+ "generated_at": "2026-07-04T20:40:39.712Z",
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-04T20:40:37.627Z",
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": 78,
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": 130,
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
  }
@@ -122,8 +122,13 @@ will still succeed. A follow-up PR will strip the dead handler code.
122
122
  changelog records the published MCP surface fingerprint. When a tool
123
123
  name, tier, category, or input schema changes, the test fails until
124
124
  this section is updated.
125
- - MCP public surface fingerprint: `sha256:188d2eba8828e4fe`
126
- (updated 2026-06-24 for 1.11.0: `bclaw_move` added (pln#595) AND a `cascade`
125
+ - MCP public surface fingerprint: `sha256:2b0dfbd62acd71b7`
126
+ (updated 2026-07-04 for trp#928: explicit `coordinator_override` boolean added
127
+ to `bclaw_release_claim` and `bclaw_transition` input schemas — the coordinator
128
+ path to release/stale a non-owned claim is now opt-in and audited rather than
129
+ auto-derived from trust level. Additive: no tool added, removed, or renamed; no
130
+ required argument changed. Previous: `sha256:188d2eba8828e4fe`
131
+ updated 2026-06-24 for 1.11.0: `bclaw_move` added (pln#595) AND a `cascade`
127
132
  boolean added to `bclaw_code_refresh` / `bclaw_code_status` (DGX Finding 2).
128
133
  Additive: one new tool; nothing removed or renamed; no required argument changed.
129
134
  Supersedes the per-branch interim hashes sha256:dffcc868ae90e013 and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -42,8 +42,8 @@
42
42
  "clean:dist-test": "node scripts/clean-build.mjs dist-test",
43
43
  "build": "npm run build:cli && node scripts/build-vsix.mjs --optional",
44
44
  "build:cli": "npm run clean:dist && tsc && node scripts/copy-default-profiles.mjs && node scripts/copy-code-map-wasm.mjs",
45
- "build:release": "npm run build:cli && node scripts/emit-site-facts.mjs && node scripts/build-vsix.mjs",
46
- "emit:facts": "npm run build:cli && node scripts/emit-site-facts.mjs",
45
+ "build:release": "npm run emit:facts && node scripts/build-vsix.mjs",
46
+ "emit:facts": "npm run bench && node scripts/emit-site-facts.mjs",
47
47
  "dev": "npm run clean:dist && tsc && node scripts/copy-default-profiles.mjs && node dist/cli.js",
48
48
  "dev:mcp": "tsc && node scripts/copy-default-profiles.mjs",
49
49
  "build:mcp-schemas": "npm run clean:dist && tsc && node scripts/build-mcp-schemas.mjs",
@@ -60,6 +60,8 @@
60
60
  "test:cross": "npm run build:test && node scripts/test-cross.mjs default",
61
61
  "test:coverage": "npm run build:test && c8 --all --src dist-test/src --reporter=text-summary --reporter=lcov --exclude=dist-test/tests/** --exclude=dist-test/scripts/** node scripts/run-tests.mjs default",
62
62
  "test:coverage:check": "npm run build:test && c8 --all --check-coverage --lines 55 --functions 60 --branches 65 --statements 55 --src dist-test/src --reporter=text-summary --exclude=dist-test/tests/** --exclude=dist-test/scripts/** node scripts/run-tests.mjs default",
63
+ "bench": "npm run build:test && node scripts/bench.mjs",
64
+ "bench:check": "node scripts/bench-check.mjs",
63
65
  "prepublishOnly": "npm run build:release && npm run pack:check"
64
66
  },
65
67
  "keywords": [
@@ -79,7 +81,7 @@
79
81
  },
80
82
  "devDependencies": {
81
83
  "@eslint/js": "^10.0.1",
82
- "@types/node": "^25.9.3",
84
+ "@types/node": "^26.0.1",
83
85
  "ajv": "^8.20.0",
84
86
  "c8": "^11.0.0",
85
87
  "eslint": "^10.5.0",