brainclaw 1.13.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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Serve-count aging for stale_warnings + workflow_hints (pln#602).
3
+ *
4
+ * Empirical driver: fable-audit-2026-07 opened with three stale_warnings from
5
+ * 87 days ago (pln_0e4a848b, rtn_d5a940c3, rtn_5eb68c9e) rendered in full at
6
+ * every session, plus a workflow_hint "confirm or retire dec_426b3b00" served
7
+ * on repeat for months. Agents learn to skim past that noise — which is
8
+ * exactly the reflex we cannot afford.
9
+ *
10
+ * Contract: an entry is served in detail `k` times, then folded into a single
11
+ * aggregate line that carries the exact next_action (`bclaw_find …`) so the
12
+ * agent still has one clear pointer. The counter is a small JSON file, safe to
13
+ * lose — hitting it twice per stale item is worse than losing it once, and a
14
+ * missing file just resets counts to zero.
15
+ *
16
+ * @module
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { resolveEntityDir } from './io.js';
21
+ import { logger } from './logger.js';
22
+ import { loadHygienePolicy } from './hygiene-policy.js';
23
+ function makeEmptyRegistry() {
24
+ return { schema_version: 1, warnings: {}, hints: {} };
25
+ }
26
+ function registryFile(cwd) {
27
+ const dir = resolveEntityDir('coordination/hygiene', cwd, 'write');
28
+ return path.join(dir, 'serve-counter.json');
29
+ }
30
+ export function loadServeRegistry(cwd) {
31
+ const root = cwd ?? process.cwd();
32
+ const file = registryFile(root);
33
+ if (!fs.existsSync(file))
34
+ return makeEmptyRegistry();
35
+ try {
36
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
37
+ return {
38
+ schema_version: 1,
39
+ warnings: parsed.warnings ?? {},
40
+ hints: parsed.hints ?? {},
41
+ };
42
+ }
43
+ catch (err) {
44
+ logger.debug('hint-aging: failed to read serve counter, resetting', err);
45
+ return makeEmptyRegistry();
46
+ }
47
+ }
48
+ export function saveServeRegistry(registry, cwd) {
49
+ const root = cwd ?? process.cwd();
50
+ const file = registryFile(root);
51
+ try {
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ fs.writeFileSync(file, JSON.stringify(registry, null, 2), 'utf-8');
54
+ }
55
+ catch (err) {
56
+ logger.debug('hint-aging: failed to write serve counter', err);
57
+ }
58
+ }
59
+ function bump(counter, nowIso) {
60
+ if (!counter)
61
+ return { count: 1, first_at: nowIso, last_at: nowIso };
62
+ return { count: counter.count + 1, first_at: counter.first_at, last_at: nowIso };
63
+ }
64
+ /**
65
+ * Fold stale warnings served ≥ k times into a single aggregate line. The
66
+ * aggregate carries a bclaw_find pointer so the agent still has one exact
67
+ * next-action instead of guessing which filter to use.
68
+ *
69
+ * Idempotence: calling with recordServe=false is pure — the returned split is
70
+ * derived from the current registry alone. With recordServe=true the counter
71
+ * bumps by one per warning ID in `warnings`, and folded_ids may grow on the
72
+ * NEXT call as items cross the threshold.
73
+ */
74
+ export function ageStaleWarnings(warnings, cwd, options = {}) {
75
+ const policy = options.policy ?? loadHygienePolicy(cwd);
76
+ if (policy.disabled) {
77
+ return { warnings, served_ids: [], folded_ids: [] };
78
+ }
79
+ const registry = options.registry ?? loadServeRegistry(cwd);
80
+ const nowIso = new Date(options.nowMs ?? Date.now()).toISOString();
81
+ const k = policy.stale_warning_serve_k;
82
+ const detail = [];
83
+ const folded = [];
84
+ const served_ids = [];
85
+ const folded_ids = [];
86
+ for (const w of warnings) {
87
+ const existing = registry.warnings[w.id];
88
+ const seenCount = existing?.count ?? 0;
89
+ if (seenCount >= k) {
90
+ folded.push(w);
91
+ folded_ids.push(w.id);
92
+ }
93
+ else {
94
+ detail.push(w);
95
+ served_ids.push(w.id);
96
+ if (options.recordServe !== false) {
97
+ registry.warnings[w.id] = bump(existing, nowIso);
98
+ }
99
+ }
100
+ }
101
+ if (options.recordServe !== false && served_ids.length > 0 && cwd !== undefined) {
102
+ saveServeRegistry(registry, cwd);
103
+ }
104
+ let aggregate;
105
+ if (folded.length > 0) {
106
+ const byEntity = new Map();
107
+ for (const w of folded)
108
+ byEntity.set(w.entity, (byEntity.get(w.entity) ?? 0) + 1);
109
+ const parts = [...byEntity.entries()].map(([entity, n]) => `${n} ${entity}${n === 1 ? '' : 's'}`);
110
+ aggregate = `${folded.length} stale item${folded.length === 1 ? '' : 's'} you've already been offered (${parts.join(', ')}) — bclaw_find(status:'stale') to review, or bclaw_transition to retire.`;
111
+ }
112
+ return { warnings: detail, aggregate, served_ids, folded_ids };
113
+ }
114
+ /**
115
+ * Fold workflow hints served ≥ k times. Hints have no stable ID — we key on
116
+ * their normalised text so the same "confirm or retire dec_426b3b00" line
117
+ * folds even across sessions.
118
+ */
119
+ export function ageWorkflowHints(hints, cwd, options = {}) {
120
+ const policy = options.policy ?? loadHygienePolicy(cwd);
121
+ if (policy.disabled) {
122
+ return { hints, served_ids: [], folded_ids: [] };
123
+ }
124
+ const registry = options.registry ?? loadServeRegistry(cwd);
125
+ const nowIso = new Date(options.nowMs ?? Date.now()).toISOString();
126
+ const k = policy.workflow_hint_serve_k;
127
+ const detail = [];
128
+ const folded = [];
129
+ const served_ids = [];
130
+ const folded_ids = [];
131
+ for (const h of hints) {
132
+ const key = hintKey(h);
133
+ const existing = registry.hints[key];
134
+ const seenCount = existing?.count ?? 0;
135
+ if (seenCount >= k) {
136
+ folded.push(h);
137
+ folded_ids.push(key);
138
+ }
139
+ else {
140
+ detail.push(h);
141
+ served_ids.push(key);
142
+ if (options.recordServe !== false) {
143
+ registry.hints[key] = bump(existing, nowIso);
144
+ }
145
+ }
146
+ }
147
+ if (options.recordServe !== false && served_ids.length > 0 && cwd !== undefined) {
148
+ saveServeRegistry(registry, cwd);
149
+ }
150
+ let aggregate;
151
+ if (folded.length > 0) {
152
+ aggregate = `${folded.length} workflow hint${folded.length === 1 ? '' : 's'} you've already been offered — bclaw_context(kind:'workflow_hints') to review.`;
153
+ }
154
+ return { hints: detail, aggregate, served_ids, folded_ids };
155
+ }
156
+ /**
157
+ * Normalise a workflow-hint text into a stable key. The generator interpolates
158
+ * counts and IDs, so keying on the raw text would give every "3 in-progress
159
+ * plan(s)…" variant its own counter. Strip digits and known ID prefixes.
160
+ */
161
+ function hintKey(text) {
162
+ return text
163
+ .replace(/\d+/g, '#')
164
+ .replace(/\b(pln|rtn|dec|trp|clm|asgn|sess|cnd|hnd|con)_[a-f0-9]{4,}\b/g, '$1_#')
165
+ .replace(/\s+/g, ' ')
166
+ .trim()
167
+ .toLowerCase();
168
+ }
169
+ function median(nums) {
170
+ if (nums.length === 0)
171
+ return 0;
172
+ const sorted = [...nums].sort((a, b) => a - b);
173
+ const mid = Math.floor(sorted.length / 2);
174
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
175
+ }
176
+ export function computeServeStats(counters, threshold) {
177
+ const values = Object.values(counters);
178
+ const counts = values.map((c) => c.count);
179
+ const first_ats = values.map((c) => c.first_at).sort();
180
+ return {
181
+ total: values.length,
182
+ over_threshold: counts.filter((c) => c >= threshold).length,
183
+ median_count: median(counts),
184
+ oldest_first_at: first_ats[0],
185
+ };
186
+ }
187
+ export const __testing = { hintKey };
188
+ //# sourceMappingURL=hint-aging.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Coordination-hygiene policy (pln#602).
3
+ *
4
+ * Family-level TTL + serve-count thresholds that govern the lazy sweep at the
5
+ * bclaw_work read path and the full sweep at session-start. Keeping the policy
6
+ * in one place lets `brainclaw doctor --hygiene` describe the current governance
7
+ * to operators without chasing constants across five modules.
8
+ *
9
+ * Design contract:
10
+ * - **Park-don't-delete.** Every TTL crossing must transit through a canonical
11
+ * grammar (transitionAssignment expired/timed_out, handoff archive) or write
12
+ * a backup file before unlinking. No policy field toggles deletion.
13
+ * - **Serve-count K is UX budget, not memory GC.** Once a stale_warning or
14
+ * workflow_hint has been rendered `k` times we swap it for an aggregate
15
+ * counter so the agent gets ONE actionable pointer instead of the same
16
+ * three lines every session (fable-audit-2026-07 empirical evidence).
17
+ * - **Opt-out means literally opt-out.** `disabled: true` bypasses BOTH the
18
+ * sweep and the aging — archive stores that curate their own retention
19
+ * stay in charge.
20
+ *
21
+ * @module
22
+ */
23
+ import { loadConfig } from './config.js';
24
+ import { logger } from './logger.js';
25
+ const DAY_MS = 24 * 60 * 60 * 1000;
26
+ export const DEFAULT_HYGIENE_POLICY = {
27
+ disabled: false,
28
+ assignment_offered_ttl_ms: 3 * DAY_MS,
29
+ assignment_accepted_ttl_ms: 1 * DAY_MS,
30
+ assignment_started_ttl_ms: 1 * DAY_MS,
31
+ handoff_closed_ttl_ms: 30 * DAY_MS,
32
+ stale_warning_serve_k: 3,
33
+ workflow_hint_serve_k: 3,
34
+ read_path_sweep_budget: 25,
35
+ };
36
+ /** The override keys HygieneConfigSchema declares — used to detect typos. */
37
+ const HYGIENE_POLICY_KEYS = [
38
+ 'disabled',
39
+ 'assignment_offered_ttl_ms',
40
+ 'assignment_accepted_ttl_ms',
41
+ 'assignment_started_ttl_ms',
42
+ 'handoff_closed_ttl_ms',
43
+ 'stale_warning_serve_k',
44
+ 'workflow_hint_serve_k',
45
+ 'read_path_sweep_budget',
46
+ ];
47
+ /**
48
+ * Best-effort load: any policy override is merged on top of the defaults.
49
+ * Config parse errors fall back silently to defaults so a broken config.yaml
50
+ * cannot break every bclaw_work call.
51
+ *
52
+ * `config.hygiene` is now declared by ConfigSchema (HygieneConfigSchema), so
53
+ * valid overrides survive the zod parse instead of being stripped (Codex review
54
+ * of PR #48, HIGH — the previous `as unknown as { hygiene? }` cast read a key
55
+ * the schema had already discarded, so `disabled`/TTL overrides never applied).
56
+ * Undefined fields (partial override or an unknown/typo sub-key that the schema
57
+ * stripped) fall back to DEFAULT_HYGIENE_POLICY; a typo is logged so the drop is
58
+ * not fully silent, consistent with the store-wide strip convention.
59
+ */
60
+ export function loadHygienePolicy(cwd) {
61
+ try {
62
+ const config = loadConfig(cwd);
63
+ const overrides = config.hygiene ?? {};
64
+ for (const key of Object.keys(overrides)) {
65
+ if (!HYGIENE_POLICY_KEYS.includes(key)) {
66
+ logger.warn(`config.hygiene: unknown key "${key}" ignored (valid keys: ${HYGIENE_POLICY_KEYS.join(', ')})`);
67
+ }
68
+ }
69
+ // Drop undefined values so a partial override never overwrites a default with undefined.
70
+ const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined));
71
+ return { ...DEFAULT_HYGIENE_POLICY, ...defined };
72
+ }
73
+ catch {
74
+ return { ...DEFAULT_HYGIENE_POLICY };
75
+ }
76
+ }
77
+ //# sourceMappingURL=hygiene-policy.js.map
@@ -1367,6 +1367,27 @@ export const BrainclawLocalReleaseManifestSchema = z.object({
1367
1367
  release_notes: z.string().optional(),
1368
1368
  agent_release_notes: AgentReleaseNotesSchema.optional(),
1369
1369
  });
1370
+ /**
1371
+ * Coordination-hygiene overrides (pln#602). Declared here so ConfigSchema does
1372
+ * NOT strip the `hygiene` key at parse time — the earlier untyped read
1373
+ * (`loadConfig() as { hygiene? }`) was silently unreachable because zod strips
1374
+ * unknown keys by default (Codex review of PR #48, HIGH). Every field is an
1375
+ * optional override on DEFAULT_HYGIENE_POLICY; the numeric TTLs/budgets are
1376
+ * validated positive so a bad value fails loudly rather than disabling a sweep.
1377
+ * Unknown sub-keys still follow the store-wide strip convention (a typo is
1378
+ * ignored, consistent with reputation/governance/etc.) — the load path logs
1379
+ * them via loadHygienePolicy so the drop is not fully silent.
1380
+ */
1381
+ export const HygieneConfigSchema = z.object({
1382
+ disabled: z.boolean().optional(),
1383
+ assignment_offered_ttl_ms: z.number().int().positive().optional(),
1384
+ assignment_accepted_ttl_ms: z.number().int().positive().optional(),
1385
+ assignment_started_ttl_ms: z.number().int().positive().optional(),
1386
+ handoff_closed_ttl_ms: z.number().int().positive().optional(),
1387
+ stale_warning_serve_k: z.number().int().positive().optional(),
1388
+ workflow_hint_serve_k: z.number().int().positive().optional(),
1389
+ read_path_sweep_budget: z.number().int().positive().optional(),
1390
+ });
1370
1391
  export const ConfigSchema = z.object({
1371
1392
  schema_version: z.number().int().positive().optional(),
1372
1393
  version: z.literal(1),
@@ -1398,6 +1419,7 @@ export const ConfigSchema = z.object({
1398
1419
  reflective_memory: ReflectiveMemoryConfigSchema.optional(),
1399
1420
  governance: GovernanceConfigSchema.optional(),
1400
1421
  reputation: ReputationConfigSchema.optional(),
1422
+ hygiene: HygieneConfigSchema.optional(),
1401
1423
  agent_integrations: AgentIntegrationsConfigSchema.default({ declarations: [] }),
1402
1424
  cross_project_links: z.array(CrossProjectLinkSchema).optional().default([]),
1403
1425
  implicit_session_ttl: z.string().default('4h'),
@@ -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);
@@ -1048,6 +1093,10 @@ export function isBranchMergedByContent(mainWorktreePath, branchName, baseRef =
1048
1093
  */
1049
1094
  export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
1050
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);
1051
1100
  // First prune stale git worktree admin entries
1052
1101
  pruneWorktrees(mainWorktreePath);
1053
1102
  result.pruned = true;
@@ -1147,6 +1196,9 @@ export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options =
1147
1196
  });
1148
1197
  if (!worktreePath || !fs.existsSync(worktreePath))
1149
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);
1150
1202
  if (workerLooksAlive(worktreePath, options.livenessWindowMs ?? WORKTREE_GC_LIVENESS_WINDOW_MS)) {
1151
1203
  return out(false, 'worker still active (recent heartbeat)');
1152
1204
  }
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.13.0 on 2026-07-04T20:40:39.712Z
2
+ // Source: brainclaw v1.14.0 on 2026-07-05T13:38:40.276Z
3
3
  export const FACTS = {
4
- "version": "1.13.0",
5
- "generated_at": "2026-07-04T20:40:39.712Z",
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,
@@ -472,7 +472,7 @@ export const FACTS = {
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-07-04T20:40:37.627Z",
475
+ "generated_at": "2026-07-05T13:38:38.216Z",
476
476
  "node_version": "v24.18.0",
477
477
  "platform": "linux-x64",
478
478
  "repeats": 3,
@@ -481,7 +481,7 @@ export const FACTS = {
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": 78,
484
+ "duration_ms_median": 72,
485
485
  "payload_chars_median": 1650,
486
486
  "payload_tokens_est_median": 413
487
487
  },
@@ -489,7 +489,7 @@ export const FACTS = {
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": 130,
492
+ "duration_ms_median": 126,
493
493
  "payload_chars_median": 2626,
494
494
  "payload_tokens_est_median": 657
495
495
  },
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.13.0",
3
- "generated_at": "2026-07-04T20:40:39.712Z",
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,
@@ -470,7 +470,7 @@
470
470
  },
471
471
  "bench": {
472
472
  "schema": "brainclaw.bench.v1",
473
- "generated_at": "2026-07-04T20:40:37.627Z",
473
+ "generated_at": "2026-07-05T13:38:38.216Z",
474
474
  "node_version": "v24.18.0",
475
475
  "platform": "linux-x64",
476
476
  "repeats": 3,
@@ -479,7 +479,7 @@
479
479
  "name": "cold_onboard",
480
480
  "volume": "empty",
481
481
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
482
- "duration_ms_median": 78,
482
+ "duration_ms_median": 72,
483
483
  "payload_chars_median": 1650,
484
484
  "payload_tokens_est_median": 413
485
485
  },
@@ -487,7 +487,7 @@
487
487
  "name": "warm_work",
488
488
  "volume": "medium",
489
489
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
490
- "duration_ms_median": 130,
490
+ "duration_ms_median": 126,
491
491
  "payload_chars_median": 2626,
492
492
  "payload_tokens_est_median": 657
493
493
  },