brainclaw 1.20.3 → 1.20.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.
Binary file
@@ -12,8 +12,10 @@
12
12
  *
13
13
  * @module
14
14
  */
15
+ import { spawnSync } from 'node:child_process';
15
16
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
16
17
  import { buildContext } from '../core/context.js';
18
+ import { detectCommitsBehindMainDetailed } from '../core/execution-context.js';
17
19
  import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
18
20
  import { loadConfig } from '../core/config.js';
19
21
  import { generateClaimId, loadClaim, saveClaim, adoptClaimSession, releaseClaimWithCascade, claimBaselineFields } from '../core/claims.js';
@@ -44,6 +46,22 @@ export function parseTtl(ttl) {
44
46
  const ms = unit === 'm' ? value * 60_000 : unit === 'h' ? value * 3_600_000 : value * 86_400_000;
45
47
  return new Date(Date.now() + ms).toISOString();
46
48
  }
49
+ /**
50
+ * Current git branch via an argv invocation — never a shell string (pln#618).
51
+ * Returns undefined when git is unavailable, cwd is not a repo, or HEAD is
52
+ * detached (`--show-current` prints nothing there).
53
+ */
54
+ function currentGitBranch(cwd) {
55
+ const result = spawnSync('git', ['branch', '--show-current'], {
56
+ cwd,
57
+ encoding: 'utf-8',
58
+ timeout: 5000,
59
+ windowsHide: true,
60
+ });
61
+ if (result.error || result.status !== 0)
62
+ return undefined;
63
+ return result.stdout.trim() || undefined;
64
+ }
47
65
  export async function handleBclawClaim(payload, ctx) {
48
66
  const { name, args, cwd, connectionSessionId } = payload;
49
67
  // project=X naming a workspace sibling auto-localizes (session+switch then
@@ -156,37 +174,21 @@ export async function handleBclawClaim(payload, ctx) {
156
174
  : '';
157
175
  // Branch guardrail: warn if on master/main without a worktree
158
176
  let branchWarn = '';
159
- if (!worktreePath) {
160
- try {
161
- const { execSync } = await import('node:child_process');
162
- const branch = execSync('git branch --show-current', { cwd: claimCwd, encoding: 'utf-8' }).trim();
163
- if (branch === 'master' || branch === 'main') {
164
- const branchSlug = sanitizeBranchComponent(claimScope);
165
- branchWarn = `\n⚠️ You are on ${branch}. Create a feature branch before editing: git checkout -b feat/${branchSlug}`;
166
- }
167
- }
168
- catch { /* git not available, skip warning */ }
177
+ const currentBranch = currentGitBranch(claimCwd);
178
+ if (!worktreePath && (currentBranch === 'master' || currentBranch === 'main')) {
179
+ const branchSlug = sanitizeBranchComponent(claimScope);
180
+ branchWarn = `\n⚠️ You are on ${currentBranch}. Create a feature branch before editing: git checkout -b feat/${branchSlug}`;
169
181
  }
170
- // Stale-branch detection: warn if behind master
182
+ // Stale-branch detection: warn if behind master/main. Branch names may
183
+ // legally contain shell metacharacters — the revspec goes through an argv
184
+ // invocation, never a shell string (pln#618).
171
185
  let staleBranchWarn = '';
172
- try {
173
- const { execSync: execSyncSB } = await import('node:child_process');
174
- const currentBranch = execSyncSB('git branch --show-current', { cwd: claimCwd, encoding: 'utf-8' }).trim();
175
- if (currentBranch && currentBranch !== 'master' && currentBranch !== 'main') {
176
- for (const mainBranch of ['master', 'main']) {
177
- try {
178
- const behind = execSyncSB(`git rev-list --count ${currentBranch}..${mainBranch}`, { cwd: claimCwd, encoding: 'utf-8' }).trim();
179
- const count = parseInt(behind, 10);
180
- if (count > 0) {
181
- staleBranchWarn = `\n⚠ Branch is ${count} commit(s) behind ${mainBranch}. Consider rebasing before editing.`;
182
- }
183
- break;
184
- }
185
- catch { /* branch doesn't exist, try next */ }
186
- }
186
+ if (currentBranch && currentBranch !== 'master' && currentBranch !== 'main') {
187
+ const behind = detectCommitsBehindMainDetailed(claimCwd, currentBranch);
188
+ if (behind && behind.count > 0) {
189
+ staleBranchWarn = `\n⚠ Branch is ${behind.count} commit(s) behind ${behind.branch}. Consider rebasing before editing.`;
187
190
  }
188
191
  }
189
- catch { /* git not available */ }
190
192
  const worktreeNote = worktreePath ? `\n Worktree: ${worktreePath}` : '';
191
193
  const expiryNote = claimExpiresAt ? `\n Expires: ${claimExpiresAt.slice(0, 16).replace('T', ' ')} UTC` : '';
192
194
  const handoffNote = handoffMode ? `\n Handoff: ${handoffMode} (another agent will review and merge)` : '';
@@ -150,27 +150,34 @@ function detectGitRemote(cwd, runner) {
150
150
  return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).length > 0;
151
151
  }
152
152
  /**
153
- * Detect how many commits the current branch is behind the main branch.
154
- * Tries master then main as the reference branch.
153
+ * Detect how many commits the current branch is behind the main branch,
154
+ * reporting which reference branch (master/main) produced the count.
155
+ * Tries both and keeps the highest — handles repos where both branches
156
+ * exist but only one is the real reference.
155
157
  * Returns undefined if not in a git repo or on the main branch itself.
158
+ *
159
+ * Branch names may legally contain shell metacharacters (`;`, `&`, `$()`,
160
+ * backticks…), so the revspec MUST stay a single argv element — never
161
+ * assemble it into a shell string (pln#618).
156
162
  */
157
- function detectCommitsBehindMain(cwd, currentBranch, runner) {
163
+ export function detectCommitsBehindMainDetailed(cwd, currentBranch, runner = defaultRunner) {
158
164
  // Don't check if already on main branch
159
165
  if (currentBranch === 'master' || currentBranch === 'main')
160
166
  return undefined;
161
- // Try both master and main, return the highest count found.
162
- // This handles repos where both branches exist but only one is the real reference.
163
- let maxBehind;
167
+ let best;
164
168
  for (const mainBranch of ['master', 'main']) {
165
169
  const result = runner('git', ['rev-list', '--count', `${currentBranch}..${mainBranch}`], cwd);
166
170
  if (result.status === 0) {
167
171
  const count = parseInt(result.stdout.trim(), 10);
168
- if (!isNaN(count) && (maxBehind === undefined || count > maxBehind)) {
169
- maxBehind = count;
172
+ if (!isNaN(count) && (best === undefined || count > best.count)) {
173
+ best = { branch: mainBranch, count };
170
174
  }
171
175
  }
172
176
  }
173
- return maxBehind;
177
+ return best;
178
+ }
179
+ function detectCommitsBehindMain(cwd, currentBranch, runner) {
180
+ return detectCommitsBehindMainDetailed(cwd, currentBranch, runner)?.count;
174
181
  }
175
182
  function detectToolchains(cwd, runner) {
176
183
  if (runner === defaultRunner && cachedToolchains) {
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.20.3 on 2026-08-03T12:57:46.375Z
2
+ // Source: brainclaw v1.20.4 on 2026-08-03T17:19:03.147Z
3
3
  export const FACTS = {
4
- "version": "1.20.3",
5
- "generated_at": "2026-08-03T12:57:46.375Z",
4
+ "version": "1.20.4",
5
+ "generated_at": "2026-08-03T17:19:03.147Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 65,
@@ -474,7 +474,7 @@ export const FACTS = {
474
474
  },
475
475
  "bench": {
476
476
  "schema": "brainclaw.bench.v1",
477
- "generated_at": "2026-08-03T12:57:44.250Z",
477
+ "generated_at": "2026-08-03T17:19:00.995Z",
478
478
  "node_version": "v24.18.0",
479
479
  "platform": "linux-x64",
480
480
  "repeats": 3,
@@ -483,7 +483,7 @@ export const FACTS = {
483
483
  "name": "cold_onboard",
484
484
  "volume": "empty",
485
485
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
486
- "duration_ms_median": 79,
486
+ "duration_ms_median": 77,
487
487
  "payload_chars_median": 1640,
488
488
  "payload_tokens_est_median": 410
489
489
  },
@@ -491,7 +491,7 @@ export const FACTS = {
491
491
  "name": "warm_work",
492
492
  "volume": "medium",
493
493
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
494
- "duration_ms_median": 129,
494
+ "duration_ms_median": 130,
495
495
  "payload_chars_median": 2626,
496
496
  "payload_tokens_est_median": 657
497
497
  },
@@ -499,7 +499,7 @@ export const FACTS = {
499
499
  "name": "first_edit",
500
500
  "volume": "medium",
501
501
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
502
- "duration_ms_median": 13,
502
+ "duration_ms_median": 12,
503
503
  "payload_chars_median": 499,
504
504
  "payload_tokens_est_median": 125
505
505
  }
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.20.3",
3
- "generated_at": "2026-08-03T12:57:46.375Z",
2
+ "version": "1.20.4",
3
+ "generated_at": "2026-08-03T17:19:03.147Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 65,
@@ -472,7 +472,7 @@
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-08-03T12:57:44.250Z",
475
+ "generated_at": "2026-08-03T17:19:00.995Z",
476
476
  "node_version": "v24.18.0",
477
477
  "platform": "linux-x64",
478
478
  "repeats": 3,
@@ -481,7 +481,7 @@
481
481
  "name": "cold_onboard",
482
482
  "volume": "empty",
483
483
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
484
- "duration_ms_median": 79,
484
+ "duration_ms_median": 77,
485
485
  "payload_chars_median": 1640,
486
486
  "payload_tokens_est_median": 410
487
487
  },
@@ -489,7 +489,7 @@
489
489
  "name": "warm_work",
490
490
  "volume": "medium",
491
491
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
- "duration_ms_median": 129,
492
+ "duration_ms_median": 130,
493
493
  "payload_chars_median": 2626,
494
494
  "payload_tokens_est_median": 657
495
495
  },
@@ -497,7 +497,7 @@
497
497
  "name": "first_edit",
498
498
  "volume": "medium",
499
499
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
500
- "duration_ms_median": 13,
500
+ "duration_ms_median": 12,
501
501
  "payload_chars_median": 499,
502
502
  "payload_tokens_est_median": 125
503
503
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brainclaw",
3
- "version": "1.20.3",
3
+ "version": "1.20.4",
4
4
  "description": "Shared project memory for humans and coding agents.",
5
5
  "type": "module",
6
6
  "repository": {