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.
- package/README.md +40 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +11 -3
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/estimation-report.js +1 -1
- package/dist/commands/harvest.js +38 -24
- package/dist/commands/mcp.js +328 -46
- package/dist/commands/release-claim.js +21 -1
- package/dist/commands/session-start.js +16 -1
- package/dist/core/agent-capability.js +15 -4
- package/dist/core/agent-registry.js +7 -1
- package/dist/core/assignment-sweeper.js +92 -11
- package/dist/core/claims.js +160 -1
- package/dist/core/context.js +11 -4
- package/dist/core/dispatch-status.js +113 -7
- package/dist/core/entity-operations.js +51 -3
- 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/loops/store.js +33 -0
- package/dist/core/reputation.js +18 -0
- package/dist/core/schema.js +29 -0
- package/dist/core/worktree.js +198 -22
- package/dist/facts.js +36 -3
- package/dist/facts.json +35 -2
- package/docs/concepts/dispatch-supervisor.md +393 -0
- package/docs/mcp-schema-changelog.md +7 -2
- package/package.json +6 -4
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { loadState, mutateState } from './state.js';
|
|
16
16
|
import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
|
|
17
17
|
import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
|
|
18
|
-
import { listClaims, loadClaim, saveClaim } from './claims.js';
|
|
18
|
+
import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
|
|
19
19
|
import { listActionRequired } from './actions.js';
|
|
20
20
|
import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
|
|
21
21
|
import { listAgentRuns } from './agentruns.js';
|
|
@@ -599,8 +599,7 @@ export function removeEntity(name, id, cwd, purge = false) {
|
|
|
599
599
|
throw new EntityOperationUnsupportedError(name, 'remove');
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
|
-
|
|
603
|
-
export function transitionEntity(name, id, to, cwd, _reason) {
|
|
602
|
+
export function transitionEntity(name, id, to, cwd, _reason, auth) {
|
|
604
603
|
const spec = ENTITY_REGISTRY[name];
|
|
605
604
|
if (!spec.statusField) {
|
|
606
605
|
throw new Error(`${name} has no lifecycle (statusField is undefined)`);
|
|
@@ -619,6 +618,32 @@ export function transitionEntity(name, id, to, cwd, _reason) {
|
|
|
619
618
|
switch (name) {
|
|
620
619
|
case 'plan': {
|
|
621
620
|
updatePlan({ id, status: to }, cwd);
|
|
621
|
+
// trp#928 — implement the `release_linked_claims_if_last` cascade tag.
|
|
622
|
+
// Before this landing the tag was advertised by the entity registry but
|
|
623
|
+
// the imperative cascade never ran, so a plan closed while its worker
|
|
624
|
+
// claims stayed active (ghost claims). The cascade now:
|
|
625
|
+
// - runs only on plan → done (the tag's actual trigger)
|
|
626
|
+
// - releases each active claim linked via plan_id
|
|
627
|
+
// - LOGS per claim (released / skipped+reason / error) via the runtime
|
|
628
|
+
// event journal so `bclaw_find(entity=agent_run)` and dashboards can
|
|
629
|
+
// observe silent ownership failures instead of guessing at them.
|
|
630
|
+
// Ownership check: this path runs from bclaw_transition, so it inherits
|
|
631
|
+
// the caller's TransitionAuth (populated for entity='claim' but not for
|
|
632
|
+
// entity='plan'). auth undefined = system convergence (bypass ownership),
|
|
633
|
+
// matching the historical implicit contract for plan cascades.
|
|
634
|
+
if (to === 'done') {
|
|
635
|
+
const linked = findActiveClaimsForPlan(id, cwd);
|
|
636
|
+
if (linked.length > 0) {
|
|
637
|
+
const cascade = releaseClaimsCascade(linked.map((c) => c.id), { cwd });
|
|
638
|
+
logCascadeReleaseResult({
|
|
639
|
+
actor: 'system',
|
|
640
|
+
trigger: 'plan_done',
|
|
641
|
+
plan_id: id,
|
|
642
|
+
cascade,
|
|
643
|
+
cwd,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
622
647
|
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
623
648
|
}
|
|
624
649
|
case 'decision':
|
|
@@ -656,6 +681,29 @@ export function transitionEntity(name, id, to, cwd, _reason) {
|
|
|
656
681
|
updateSequence({ id, status: to }, cwd);
|
|
657
682
|
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
658
683
|
}
|
|
684
|
+
case 'claim': {
|
|
685
|
+
// trp#928 — the entity registry advertised `active → released|stale` but
|
|
686
|
+
// transitionEntity never routed for entity=claim. The isValidTransition
|
|
687
|
+
// check above passed for anyone calling `bclaw_transition(entity='claim',
|
|
688
|
+
// to='released')`, but the transition then fell through to the
|
|
689
|
+
// EntityOperationUnsupportedError default. Now: released hits the same
|
|
690
|
+
// cascade path bclaw_release_claim uses (audit + plan-done cascade); stale
|
|
691
|
+
// uses markClaimStale (audit + `stale` terminal status). Reuses ReleaseClaimAuth
|
|
692
|
+
// so a trusted+ coordinator can release across ownership with override.
|
|
693
|
+
const releaseAuth = auth
|
|
694
|
+
? { agent: auth.agent, agent_id: auth.agent_id, session_id: auth.session_id, override: auth.override }
|
|
695
|
+
: undefined;
|
|
696
|
+
if (to === 'released') {
|
|
697
|
+
releaseClaimWithCascade(id, { planStatus: _reason === 'done' ? 'done' : undefined, cwd, auth: releaseAuth });
|
|
698
|
+
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
699
|
+
}
|
|
700
|
+
if (to === 'stale') {
|
|
701
|
+
markClaimStale(id, cwd, releaseAuth);
|
|
702
|
+
return { entity: name, id, from, to, side_effects: sideEffects };
|
|
703
|
+
}
|
|
704
|
+
// isValidTransition already excluded every other target — belt-and-braces:
|
|
705
|
+
throw new InvalidTransitionError(name, from, to);
|
|
706
|
+
}
|
|
659
707
|
default:
|
|
660
708
|
throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
|
|
661
709
|
}
|
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/loops/store.js
CHANGED
|
@@ -5,6 +5,7 @@ import { memoryDir, writeFileAtomic } from '../io.js';
|
|
|
5
5
|
import { nowISO } from '../ids.js';
|
|
6
6
|
import { logger } from '../logger.js';
|
|
7
7
|
import { convergeAssignmentToTerminal, loadAssignment } from '../assignments.js';
|
|
8
|
+
import { logCascadeReleaseResult, releaseClaimsCascade } from '../claims.js';
|
|
8
9
|
import { gcWorktreeIfHarvested } from '../worktree.js';
|
|
9
10
|
import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
|
|
10
11
|
import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
|
|
@@ -435,6 +436,38 @@ export function closeLoop(input, cwd) {
|
|
|
435
436
|
}
|
|
436
437
|
catch { /* never block loop close on assignment convergence */ }
|
|
437
438
|
}
|
|
439
|
+
// trp#928 — cascade-release claims linked to slots + slot-linked assignments.
|
|
440
|
+
// Before this landing, loop close converged the assignment lifecycle but left
|
|
441
|
+
// reviewer claims active indefinitely (dogfooding 2026-07: 23 ghost claims,
|
|
442
|
+
// most from closed review loops). System-actor release: no auth → the
|
|
443
|
+
// ownership check is skipped, matching convergeAssignmentToTerminal's contract
|
|
444
|
+
// above (loop close is a system action, not a user-driven release).
|
|
445
|
+
const claimIdsFromSlots = [];
|
|
446
|
+
for (const slot of next.slots) {
|
|
447
|
+
if (slot.claim_id)
|
|
448
|
+
claimIdsFromSlots.push(slot.claim_id);
|
|
449
|
+
if (slot.assignment_id) {
|
|
450
|
+
try {
|
|
451
|
+
const assignment = loadAssignment(slot.assignment_id, cwd);
|
|
452
|
+
if (assignment?.claim_id)
|
|
453
|
+
claimIdsFromSlots.push(assignment.claim_id);
|
|
454
|
+
}
|
|
455
|
+
catch { /* assignment gone — skip */ }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (claimIdsFromSlots.length > 0) {
|
|
459
|
+
try {
|
|
460
|
+
const cascade = releaseClaimsCascade(claimIdsFromSlots, { cwd });
|
|
461
|
+
logCascadeReleaseResult({
|
|
462
|
+
actor: input.actor,
|
|
463
|
+
trigger: 'loop_close',
|
|
464
|
+
loop_id: input.id,
|
|
465
|
+
cascade,
|
|
466
|
+
cwd,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
catch { /* never block loop close on cascade release */ }
|
|
470
|
+
}
|
|
438
471
|
// pln#594: GC the dispatched sub-agent worktrees now the loop is done, so
|
|
439
472
|
// review/dispatch worktrees stop accumulating under ~/.brainclaw/worktrees/.
|
|
440
473
|
// Only on a COMPLETED close — cancelled/blocked keep their worktree (+ run
|
package/dist/core/reputation.js
CHANGED
|
@@ -281,6 +281,24 @@ export function buildReputationSnapshot(cwd) {
|
|
|
281
281
|
resume_weight: 0.35,
|
|
282
282
|
mcp_exposure: false,
|
|
283
283
|
};
|
|
284
|
+
// pln#578 — disabled reputation (the default) must not pay for the sweep.
|
|
285
|
+
// Every consumer already treats a disabled snapshot as empty: agents is []
|
|
286
|
+
// (line below gates on enabled), so ranking bonuses are 0 and the resume
|
|
287
|
+
// summary is undefined. Yet the full signal sweep (pending + archived
|
|
288
|
+
// candidates, all runtime notes, all claims, a complete loadState) was still
|
|
289
|
+
// running — two of the four full-store read passes a single buildContext
|
|
290
|
+
// performed on a large store. Exit before any store read when disabled.
|
|
291
|
+
if (!reputationConfig.enabled) {
|
|
292
|
+
return {
|
|
293
|
+
enabled: false,
|
|
294
|
+
visibility: reputationConfig.visibility,
|
|
295
|
+
window_days: reputationConfig.decay_days,
|
|
296
|
+
generated_at: nowISO(),
|
|
297
|
+
project_id: config.project_id,
|
|
298
|
+
current_agent_id: resolveCurrentAgentIdentity(cwd)?.agent_id,
|
|
299
|
+
agents: [],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
284
302
|
const registered = listAgentIdentities(cwd);
|
|
285
303
|
const currentAgent = resolveCurrentAgentIdentity(cwd);
|
|
286
304
|
const resolvers = buildIdentityResolvers(registered);
|
package/dist/core/schema.js
CHANGED
|
@@ -1122,6 +1122,13 @@ export const CurrentSessionStateSchema = z.object({
|
|
|
1122
1122
|
branch: z.string().optional(),
|
|
1123
1123
|
/** Isolation mode: shared-checkout (default) or dedicated-worktree. */
|
|
1124
1124
|
isolation_mode: IsolationModeSchema.optional(),
|
|
1125
|
+
/**
|
|
1126
|
+
* True when the session was materialized by an auto-repair path (e.g. a
|
|
1127
|
+
* canonical write arriving without a prior session). Tag lets aggressive
|
|
1128
|
+
* harvesting distinguish worker-authored sessions from operator sessions
|
|
1129
|
+
* (pln#602 lesson on the pln#578 887-file blowup).
|
|
1130
|
+
*/
|
|
1131
|
+
auto_created: z.boolean().optional(),
|
|
1125
1132
|
});
|
|
1126
1133
|
export const MemorySeedKindSchema = z.enum([
|
|
1127
1134
|
'command',
|
|
@@ -1360,6 +1367,27 @@ export const BrainclawLocalReleaseManifestSchema = z.object({
|
|
|
1360
1367
|
release_notes: z.string().optional(),
|
|
1361
1368
|
agent_release_notes: AgentReleaseNotesSchema.optional(),
|
|
1362
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
|
+
});
|
|
1363
1391
|
export const ConfigSchema = z.object({
|
|
1364
1392
|
schema_version: z.number().int().positive().optional(),
|
|
1365
1393
|
version: z.literal(1),
|
|
@@ -1391,6 +1419,7 @@ export const ConfigSchema = z.object({
|
|
|
1391
1419
|
reflective_memory: ReflectiveMemoryConfigSchema.optional(),
|
|
1392
1420
|
governance: GovernanceConfigSchema.optional(),
|
|
1393
1421
|
reputation: ReputationConfigSchema.optional(),
|
|
1422
|
+
hygiene: HygieneConfigSchema.optional(),
|
|
1394
1423
|
agent_integrations: AgentIntegrationsConfigSchema.default({ declarations: [] }),
|
|
1395
1424
|
cross_project_links: z.array(CrossProjectLinkSchema).optional().default([]),
|
|
1396
1425
|
implicit_session_ttl: z.string().default('4h'),
|