brainclaw 1.13.0 → 1.15.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.
- package/README.md +6 -252
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +184 -15
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/harvest.js +8 -2
- package/dist/commands/mcp.js +67 -6
- package/dist/commands/session-start.js +16 -1
- package/dist/core/agent-registry.js +51 -3
- package/dist/core/assignment-sweeper.js +92 -11
- package/dist/core/claims.js +18 -0
- package/dist/core/facade-schema.js +12 -0
- package/dist/core/federation-cloud.js +142 -11
- package/dist/core/federation-outbox.js +292 -0
- package/dist/core/federation-signing.js +115 -0
- package/dist/core/gc-semantic.js +79 -0
- package/dist/core/hint-aging.js +188 -0
- package/dist/core/hygiene-policy.js +77 -0
- package/dist/core/io.js +6 -0
- package/dist/core/schema.js +33 -0
- package/dist/core/worktree.js +77 -4
- package/dist/facts.js +6 -6
- package/dist/facts.json +5 -5
- package/docs/concepts/dispatch-supervisor.md +393 -0
- package/docs/mcp-schema-changelog.md +18 -2
- package/package.json +1 -1
package/dist/core/gc-semantic.js
CHANGED
|
@@ -543,6 +543,85 @@ export function enforceRuntimeNoteRetention(options = {}) {
|
|
|
543
543
|
result.backup_path = backupPath;
|
|
544
544
|
return result;
|
|
545
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* pln#602 — park closed auto-generated handoffs older than the cutoff.
|
|
548
|
+
*
|
|
549
|
+
* The stale-warning surface counts every open handoff older than 14d as
|
|
550
|
+
* something the agent should act on. Once a handoff is `closed` (accepted +
|
|
551
|
+
* fulfilled OR explicitly retired), it stops being a workflow signal and
|
|
552
|
+
* starts being noise: the fable-audit trace repeatedly rendered fully-served
|
|
553
|
+
* handoffs from months prior. Park them next to the released-claims archive
|
|
554
|
+
* (same JSONL + backup pattern) so `bclaw_find` still surfaces them on demand
|
|
555
|
+
* but the compact bclaw_work context can move on.
|
|
556
|
+
*
|
|
557
|
+
* Auto-generated detection matches the same "Session sess_ … auto-generated
|
|
558
|
+
* handoff" prefix that `dedupAutoHandoffs` uses — human-authored handoffs
|
|
559
|
+
* stay put regardless of age (they may carry decisions the agent needs).
|
|
560
|
+
*/
|
|
561
|
+
export function parkClosedAutoHandoffs(cwd, minAgeDays = DEFAULT_MIN_AGE_DAYS, dryRun = false) {
|
|
562
|
+
const cutoff = new Date(Date.now() - minAgeDays * 24 * 60 * 60 * 1000).toISOString();
|
|
563
|
+
const handoffsDir = path.join(cwd, '.brainclaw', 'coordination', 'handoffs');
|
|
564
|
+
if (!fs.existsSync(handoffsDir))
|
|
565
|
+
return { candidates: 0, parked: 0 };
|
|
566
|
+
const eligible = [];
|
|
567
|
+
const files = fs.readdirSync(handoffsDir).filter((f) => f.endsWith('.json'));
|
|
568
|
+
for (const file of files) {
|
|
569
|
+
const filePath = path.join(handoffsDir, file);
|
|
570
|
+
try {
|
|
571
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
572
|
+
const parsed = JSON.parse(content);
|
|
573
|
+
const status = typeof parsed.status === 'string' ? parsed.status : '';
|
|
574
|
+
if (status !== 'closed')
|
|
575
|
+
continue;
|
|
576
|
+
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
|
577
|
+
const isAutoGenerated = text.startsWith('Session sess_') && text.includes('auto-generated handoff');
|
|
578
|
+
if (!isAutoGenerated)
|
|
579
|
+
continue;
|
|
580
|
+
const updatedAt = typeof parsed.updated_at === 'string' ? parsed.updated_at
|
|
581
|
+
: typeof parsed.created_at === 'string' ? parsed.created_at : '';
|
|
582
|
+
if (!updatedAt || updatedAt > cutoff)
|
|
583
|
+
continue;
|
|
584
|
+
eligible.push({ filePath, content });
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// Skip unparseable — a separate check (loadDirectoryItems) will surface it.
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (dryRun || eligible.length === 0) {
|
|
591
|
+
return { candidates: eligible.length, parked: 0 };
|
|
592
|
+
}
|
|
593
|
+
const archivePath = path.join(handoffsDir, 'compacted.jsonl');
|
|
594
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
595
|
+
const backupPath = path.join(cwd, '.brainclaw', 'gc-backups', `compact-handoffs-closed-${timestamp}.jsonl`);
|
|
596
|
+
fs.mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
597
|
+
let parked = 0;
|
|
598
|
+
for (const { filePath, content } of eligible) {
|
|
599
|
+
// Codex PR#48 finding 4: order the steps so a partial failure can never
|
|
600
|
+
// leave the source on disk AND its record already in the compaction log
|
|
601
|
+
// (which produced a duplicate compacted record on the next pass). The safe
|
|
602
|
+
// order is backup → unlink → archive:
|
|
603
|
+
// 1. backup first — park-don't-delete safety net is written before any
|
|
604
|
+
// removal, so the raw handoff is always recoverable.
|
|
605
|
+
// 2. unlink next — if this throws, we do NOT archive, so no compacted
|
|
606
|
+
// record exists for a source that is still present → no duplicate.
|
|
607
|
+
// 3. archive last — if this throws after a successful unlink, the source
|
|
608
|
+
// is gone (in the backup) and simply absent from compacted.jsonl; the
|
|
609
|
+
// next pass cannot re-see it, so still no duplicate.
|
|
610
|
+
try {
|
|
611
|
+
const parsed = JSON.parse(content);
|
|
612
|
+
parsed._compacted_at = new Date().toISOString();
|
|
613
|
+
parsed._compaction_type = 'closed-auto-handoff';
|
|
614
|
+
fs.appendFileSync(backupPath, content.trim() + '\n', 'utf-8');
|
|
615
|
+
fs.unlinkSync(filePath);
|
|
616
|
+
fs.appendFileSync(archivePath, JSON.stringify(parsed) + '\n', 'utf-8');
|
|
617
|
+
parked += 1;
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
logger.debug('parkClosedAutoHandoffs: failed to park', err);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return { candidates: eligible.length, parked, backup_path: backupPath };
|
|
624
|
+
}
|
|
546
625
|
/**
|
|
547
626
|
* Deduplicate auto-generated session-end handoffs. These carry the same
|
|
548
627
|
* commits list when several sessions close on the same project state, so the
|
|
@@ -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
|
package/dist/core/io.js
CHANGED
|
@@ -34,6 +34,12 @@ const ENTITY_DIR_MAP = {
|
|
|
34
34
|
'runtime': 'coordination/runtime',
|
|
35
35
|
'runtime-hosts': 'coordination/runtime-hosts',
|
|
36
36
|
'runtime-private': 'coordination/runtime-private',
|
|
37
|
+
// federation/ — outbound cloud sync queue (pln#101 Phase 2): durable outbox,
|
|
38
|
+
// archived 'sent' markers, and 'parked' dead-letters.
|
|
39
|
+
'federation': 'coordination/federation',
|
|
40
|
+
'federation/outbox': 'coordination/federation/outbox',
|
|
41
|
+
'federation/sent': 'coordination/federation/sent',
|
|
42
|
+
'federation/parked': 'coordination/federation/parked',
|
|
37
43
|
'surface-tasks': 'coordination/surface-tasks',
|
|
38
44
|
'assignments': 'coordination/assignments',
|
|
39
45
|
'runs': 'coordination/runs',
|
package/dist/core/schema.js
CHANGED
|
@@ -1079,6 +1079,17 @@ export const CloudSyncConfigSchema = z.object({
|
|
|
1079
1079
|
enabled: z.boolean().default(false),
|
|
1080
1080
|
endpoint: z.string().default('https://app.brainclaw.dev'),
|
|
1081
1081
|
api_key: z.string().optional(),
|
|
1082
|
+
/** Remote project this bridge federates into (scopes signed runtime writes). */
|
|
1083
|
+
project_id: z.string().optional(),
|
|
1084
|
+
/** Approved remote agent identity used to sign runtime writes (pln#100). */
|
|
1085
|
+
agent_id: z.string().optional(),
|
|
1086
|
+
agent_name: z.string().optional(),
|
|
1087
|
+
/**
|
|
1088
|
+
* Fail-closed toggle: when true, the bridge refuses to push a runtime write
|
|
1089
|
+
* unless it can sign it with an approved agent's Ed25519 key. Absent/false
|
|
1090
|
+
* keeps existing API-key-only setups working (signing is additive).
|
|
1091
|
+
*/
|
|
1092
|
+
require_signed: z.boolean().optional(),
|
|
1082
1093
|
});
|
|
1083
1094
|
export const SessionSnapshotSchema = z.object({
|
|
1084
1095
|
schema_version: z.number().int().positive().optional(),
|
|
@@ -1367,6 +1378,27 @@ export const BrainclawLocalReleaseManifestSchema = z.object({
|
|
|
1367
1378
|
release_notes: z.string().optional(),
|
|
1368
1379
|
agent_release_notes: AgentReleaseNotesSchema.optional(),
|
|
1369
1380
|
});
|
|
1381
|
+
/**
|
|
1382
|
+
* Coordination-hygiene overrides (pln#602). Declared here so ConfigSchema does
|
|
1383
|
+
* NOT strip the `hygiene` key at parse time — the earlier untyped read
|
|
1384
|
+
* (`loadConfig() as { hygiene? }`) was silently unreachable because zod strips
|
|
1385
|
+
* unknown keys by default (Codex review of PR #48, HIGH). Every field is an
|
|
1386
|
+
* optional override on DEFAULT_HYGIENE_POLICY; the numeric TTLs/budgets are
|
|
1387
|
+
* validated positive so a bad value fails loudly rather than disabling a sweep.
|
|
1388
|
+
* Unknown sub-keys still follow the store-wide strip convention (a typo is
|
|
1389
|
+
* ignored, consistent with reputation/governance/etc.) — the load path logs
|
|
1390
|
+
* them via loadHygienePolicy so the drop is not fully silent.
|
|
1391
|
+
*/
|
|
1392
|
+
export const HygieneConfigSchema = z.object({
|
|
1393
|
+
disabled: z.boolean().optional(),
|
|
1394
|
+
assignment_offered_ttl_ms: z.number().int().positive().optional(),
|
|
1395
|
+
assignment_accepted_ttl_ms: z.number().int().positive().optional(),
|
|
1396
|
+
assignment_started_ttl_ms: z.number().int().positive().optional(),
|
|
1397
|
+
handoff_closed_ttl_ms: z.number().int().positive().optional(),
|
|
1398
|
+
stale_warning_serve_k: z.number().int().positive().optional(),
|
|
1399
|
+
workflow_hint_serve_k: z.number().int().positive().optional(),
|
|
1400
|
+
read_path_sweep_budget: z.number().int().positive().optional(),
|
|
1401
|
+
});
|
|
1370
1402
|
export const ConfigSchema = z.object({
|
|
1371
1403
|
schema_version: z.number().int().positive().optional(),
|
|
1372
1404
|
version: z.literal(1),
|
|
@@ -1398,6 +1430,7 @@ export const ConfigSchema = z.object({
|
|
|
1398
1430
|
reflective_memory: ReflectiveMemoryConfigSchema.optional(),
|
|
1399
1431
|
governance: GovernanceConfigSchema.optional(),
|
|
1400
1432
|
reputation: ReputationConfigSchema.optional(),
|
|
1433
|
+
hygiene: HygieneConfigSchema.optional(),
|
|
1401
1434
|
agent_integrations: AgentIntegrationsConfigSchema.default({ declarations: [] }),
|
|
1402
1435
|
cross_project_links: z.array(CrossProjectLinkSchema).optional().default([]),
|
|
1403
1436
|
implicit_session_ttl: z.string().default('4h'),
|
package/dist/core/worktree.js
CHANGED
|
@@ -25,17 +25,38 @@ function gitPath(p) {
|
|
|
25
25
|
* landed on the dot before `astro`, yielding `…IntegrationHubPage.` — a trailing
|
|
26
26
|
* dot git rejects (`fatal: not a valid branch name`). Truncating first, then
|
|
27
27
|
* stripping, guarantees the cap can never re-introduce an invalid ref.
|
|
28
|
+
*
|
|
29
|
+
* trp#950 (dogfood 2026-07-15): a plain truncation makes two DISTINCT scopes
|
|
30
|
+
* that share a >48-char prefix collapse to the SAME branch → same worktree path
|
|
31
|
+
* → the second claim/assign is refused. When (and only when) the cleaned slug
|
|
32
|
+
* exceeds the cap, a deterministic 8-char digest of the FULL cleaned slug is
|
|
33
|
+
* appended so distinct scopes diverge, while the same scope stays stable
|
|
34
|
+
* (resume/re-assign still resolves its worktree). Short scopes are unchanged.
|
|
35
|
+
* 8 hex chars = 32 bits: comfortably collision-safe for the realistic case
|
|
36
|
+
* (a handful of scopes sharing a deep directory prefix) while keeping a
|
|
37
|
+
* 39-char readable head.
|
|
28
38
|
*/
|
|
39
|
+
const BRANCH_COMPONENT_CAP = 48;
|
|
29
40
|
export function sanitizeBranchComponent(raw, fallback = 'scope') {
|
|
30
|
-
|
|
41
|
+
const cleaned = raw
|
|
31
42
|
.replace(/[\s~^:?*[\]\\]/g, '-') // chars forbidden by check-ref-format
|
|
32
43
|
.replace(/@\{/g, '-') // reflog syntax
|
|
33
44
|
.replace(/\.\.+/g, '.') // no double dots
|
|
34
45
|
.replace(/[^a-zA-Z0-9._-]/g, '-') // conservative whitelist for the rest
|
|
35
46
|
.replace(/-+/g, '-') // collapse dashes
|
|
36
|
-
.replace(/^[.-]+/, '') // no leading dot/dash
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
.replace(/^[.-]+/, ''); // no leading dot/dash
|
|
48
|
+
let slug;
|
|
49
|
+
if (cleaned.length <= BRANCH_COMPONENT_CAP) {
|
|
50
|
+
slug = cleaned.replace(/[.-]+$/, ''); // no trailing dot/dash
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// Truncation drops characters → reserve room for a collision-resistant
|
|
54
|
+
// suffix derived from the full cleaned slug (trp#950). The digest is hex, so
|
|
55
|
+
// it can never re-introduce a trailing dot/dash or a `.lock` suffix.
|
|
56
|
+
const suffix = crypto.createHash('sha1').update(cleaned).digest('hex').slice(0, 8);
|
|
57
|
+
const head = cleaned.slice(0, BRANCH_COMPONENT_CAP - suffix.length - 1).replace(/[.-]+$/, '');
|
|
58
|
+
slug = `${head}-${suffix}`;
|
|
59
|
+
}
|
|
39
60
|
if (/\.lock$/i.test(slug))
|
|
40
61
|
slug = slug.slice(0, -'.lock'.length).replace(/[.-]+$/, '');
|
|
41
62
|
if (!slug)
|
|
@@ -229,6 +250,45 @@ function runGit(args, cwd, timeoutMs = GIT_QUERY_TIMEOUT_MS) {
|
|
|
229
250
|
stderr: result.stderr ?? '',
|
|
230
251
|
};
|
|
231
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Resolve the real git worktree root for `cwd` (pln#614). On an IN-TREE project
|
|
255
|
+
* — a project dir that sits inside a larger repo (monorepo), where the git root
|
|
256
|
+
* is an ancestor, not the project dir itself — `git worktree add` MUST run from
|
|
257
|
+
* the true toplevel, and the per-project worktree hash MUST be derived from it.
|
|
258
|
+
*
|
|
259
|
+
* The bug (trp_28025248, cross-machine dogfooding 1.13.0): the assign/review
|
|
260
|
+
* claim path passed the project cwd straight to createWorktree, so `git worktree
|
|
261
|
+
* add` ran from the project dir and — with an empty `.git` left by the embedded
|
|
262
|
+
* init — failed with "not a git repository", while the ideation path (which
|
|
263
|
+
* resolved the toplevel) worked. resolveGitToplevel makes both paths agree.
|
|
264
|
+
*
|
|
265
|
+
* Falls back to the input cwd when `git rev-parse` cannot resolve a toplevel
|
|
266
|
+
* (not a repo, git absent) so non-git callers and tests keep their behaviour.
|
|
267
|
+
*/
|
|
268
|
+
export function resolveGitToplevel(cwd) {
|
|
269
|
+
// Codex review of PR #49 (HIGH): a stale/empty `.git` INSIDE the project dir
|
|
270
|
+
// (left by the embedded init — the exact leazzy case) makes `git rev-parse
|
|
271
|
+
// --show-toplevel` FAIL at that level instead of discovering the parent repo:
|
|
272
|
+
// git stops at the invalid gitdir. A plain fallback-to-cwd would then still
|
|
273
|
+
// run from the project dir and hash the subdir — the bug unfixed. So on
|
|
274
|
+
// failure we walk UP and retry from each ancestor, skipping past the invalid
|
|
275
|
+
// nested gitdir until a real toplevel is found; only a truly non-git tree
|
|
276
|
+
// falls back to the input cwd.
|
|
277
|
+
let dir = path.resolve(cwd);
|
|
278
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
279
|
+
const result = runGit(['rev-parse', '--show-toplevel'], dir);
|
|
280
|
+
if (result.ok) {
|
|
281
|
+
const top = result.stdout.trim();
|
|
282
|
+
if (top)
|
|
283
|
+
return path.resolve(top);
|
|
284
|
+
}
|
|
285
|
+
const parent = path.dirname(dir);
|
|
286
|
+
if (parent === dir)
|
|
287
|
+
break; // filesystem root — not inside any repo
|
|
288
|
+
dir = parent;
|
|
289
|
+
}
|
|
290
|
+
return cwd;
|
|
291
|
+
}
|
|
232
292
|
/**
|
|
233
293
|
* Returns true if the given path is a bare git repository.
|
|
234
294
|
* Bare repos have no working tree, so worktree add is not applicable.
|
|
@@ -444,6 +504,12 @@ export function findWorktreePathForBranch(worktrees, branchName) {
|
|
|
444
504
|
* Returns the absolute path to the newly created worktree.
|
|
445
505
|
*/
|
|
446
506
|
export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
507
|
+
// pln#614: resolve the true git toplevel first, so an in-tree project (project
|
|
508
|
+
// dir ≠ git root) creates its worktree from the real repo root — `git worktree
|
|
509
|
+
// add` runs there, and the per-project worktree hash (resolveWorktreePath) is
|
|
510
|
+
// derived from it, matching the ideation path. All git commands + the hash
|
|
511
|
+
// below use this resolved root rather than the raw project cwd.
|
|
512
|
+
mainWorktreePath = resolveGitToplevel(mainWorktreePath);
|
|
447
513
|
const symlinkWarnings = [];
|
|
448
514
|
const trySymlinkSharedPath = (entryName) => {
|
|
449
515
|
const sourcePath = path.join(mainWorktreePath, entryName);
|
|
@@ -1048,6 +1114,10 @@ export function isBranchMergedByContent(mainWorktreePath, branchName, baseRef =
|
|
|
1048
1114
|
*/
|
|
1049
1115
|
export function cleanMergedWorktrees(mainWorktreePath, options = {}) {
|
|
1050
1116
|
const result = { removed: [], skipped: [], pruned: false };
|
|
1117
|
+
// pln#614: resolve the toplevel so an in-tree project scans the same
|
|
1118
|
+
// per-project worktree hash createWorktree wrote under (and runs git from the
|
|
1119
|
+
// real repo root).
|
|
1120
|
+
mainWorktreePath = resolveGitToplevel(mainWorktreePath);
|
|
1051
1121
|
// First prune stale git worktree admin entries
|
|
1052
1122
|
pruneWorktrees(mainWorktreePath);
|
|
1053
1123
|
result.pruned = true;
|
|
@@ -1147,6 +1217,9 @@ export function gcWorktreeIfHarvested(mainWorktreePath, worktreePath, options =
|
|
|
1147
1217
|
});
|
|
1148
1218
|
if (!worktreePath || !fs.existsSync(worktreePath))
|
|
1149
1219
|
return out(false, 'already gone');
|
|
1220
|
+
// pln#614: the merge-base / patch-id probes below run from the main repo — an
|
|
1221
|
+
// in-tree project dir (empty .git) would fail them; resolve the real toplevel.
|
|
1222
|
+
mainWorktreePath = resolveGitToplevel(mainWorktreePath);
|
|
1150
1223
|
if (workerLooksAlive(worktreePath, options.livenessWindowMs ?? WORKTREE_GC_LIVENESS_WINDOW_MS)) {
|
|
1151
1224
|
return out(false, 'worker still active (recent heartbeat)');
|
|
1152
1225
|
}
|
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.
|
|
2
|
+
// Source: brainclaw v1.15.0 on 2026-07-15T15:24:46.074Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-07-
|
|
4
|
+
"version": "1.15.0",
|
|
5
|
+
"generated_at": "2026-07-15T15:24:46.074Z",
|
|
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-
|
|
475
|
+
"generated_at": "2026-07-15T15:24:43.956Z",
|
|
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":
|
|
484
|
+
"duration_ms_median": 76,
|
|
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":
|
|
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.
|
|
3
|
-
"generated_at": "2026-07-
|
|
2
|
+
"version": "1.15.0",
|
|
3
|
+
"generated_at": "2026-07-15T15:24:46.074Z",
|
|
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-
|
|
473
|
+
"generated_at": "2026-07-15T15:24:43.956Z",
|
|
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":
|
|
482
|
+
"duration_ms_median": 76,
|
|
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":
|
|
490
|
+
"duration_ms_median": 126,
|
|
491
491
|
"payload_chars_median": 2626,
|
|
492
492
|
"payload_tokens_est_median": 657
|
|
493
493
|
},
|