brainclaw 0.28.0 → 1.5.3
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 +193 -170
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +683 -23
- package/dist/commands/accept.js +3 -0
- package/dist/commands/add-step.js +11 -26
- package/dist/commands/agent-board.js +70 -3
- package/dist/commands/audit.js +19 -0
- package/dist/commands/check-policy.js +54 -0
- package/dist/commands/check-security-mcp.js +145 -0
- package/dist/commands/check-security.js +106 -0
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/codev.js +672 -0
- package/dist/commands/compact.js +74 -0
- package/dist/commands/complete-step.js +16 -26
- package/dist/commands/constraint.js +8 -20
- package/dist/commands/decision.js +9 -20
- package/dist/commands/delete-plan.js +10 -12
- package/dist/commands/delete-step.js +16 -0
- package/dist/commands/dispatch.js +163 -0
- package/dist/commands/doctor.js +1122 -49
- package/dist/commands/enable-agent.js +1 -0
- package/dist/commands/export.js +280 -22
- package/dist/commands/handoff.js +33 -0
- package/dist/commands/harvest.js +189 -0
- package/dist/commands/hooks.js +82 -25
- package/dist/commands/inbox.js +169 -0
- package/dist/commands/init.js +38 -31
- package/dist/commands/install-hooks.js +71 -44
- package/dist/commands/link.js +89 -0
- package/dist/commands/list-claims.js +48 -3
- package/dist/commands/list-plans.js +129 -25
- package/dist/commands/loops-handlers.js +409 -0
- package/dist/commands/mcp-read-handlers.js +1628 -0
- package/dist/commands/mcp-schemas.generated.js +74 -0
- package/dist/commands/mcp.js +4244 -1475
- package/dist/commands/plan-resource.js +64 -0
- package/dist/commands/plan.js +12 -26
- package/dist/commands/prune.js +37 -2
- package/dist/commands/reflect.js +20 -7
- package/dist/commands/release-claim.js +11 -6
- package/dist/commands/release-notes.js +170 -0
- package/dist/commands/repair.js +210 -0
- package/dist/commands/run-profile.js +57 -0
- package/dist/commands/sequence.js +113 -0
- package/dist/commands/session-end.js +423 -14
- package/dist/commands/session-start.js +214 -41
- package/dist/commands/setup-security.js +103 -0
- package/dist/commands/setup.js +42 -4
- package/dist/commands/stale.js +109 -0
- package/dist/commands/switch.js +131 -10
- package/dist/commands/trap.js +14 -31
- package/dist/commands/update-handoff.js +63 -4
- package/dist/commands/update-plan.js +21 -28
- package/dist/commands/update-step.js +37 -0
- package/dist/commands/upgrade.js +313 -6
- package/dist/commands/usage.js +102 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/who.js +124 -0
- package/dist/commands/worktree.js +105 -0
- package/dist/core/actions.js +315 -0
- package/dist/core/agent-capability.js +610 -17
- package/dist/core/agent-context.js +7 -1
- package/dist/core/agent-files.js +1169 -85
- package/dist/core/agent-integrations.js +160 -5
- package/dist/core/agent-inventory.js +2 -0
- package/dist/core/agent-profiles.js +93 -0
- package/dist/core/agent-registry.js +162 -30
- package/dist/core/agentrun-reconciler.js +345 -0
- package/dist/core/agentruns.js +424 -0
- package/dist/core/ai-agent-detection.js +31 -10
- package/dist/core/archival.js +77 -0
- package/dist/core/assignment-sweeper.js +82 -0
- package/dist/core/assignments.js +367 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/bootstrap.js +61 -10
- package/dist/core/brainclaw-version.js +94 -2
- package/dist/core/candidates.js +93 -2
- package/dist/core/claims.js +419 -0
- package/dist/core/codev-metrics.js +77 -0
- package/dist/core/codev-personas.js +31 -0
- package/dist/core/codev-plan-gen.js +35 -0
- package/dist/core/codev-prompts.js +74 -0
- package/dist/core/codev-responses.js +62 -0
- package/dist/core/codev-rounds.js +218 -0
- package/dist/core/config.js +4 -0
- package/dist/core/context.js +454 -34
- package/dist/core/coordination.js +201 -6
- package/dist/core/cross-project.js +230 -16
- package/dist/core/default-profiles/doctor.yaml +11 -0
- package/dist/core/default-profiles/janitor.yaml +11 -0
- package/dist/core/default-profiles/onboarder.yaml +11 -0
- package/dist/core/default-profiles/reviewer.yaml +13 -0
- package/dist/core/dispatcher.js +1189 -0
- package/dist/core/duplicates.js +2 -2
- package/dist/core/entity-operations.js +450 -0
- package/dist/core/entity-registry.js +344 -0
- package/dist/core/event-log.js +1 -0
- package/dist/core/events.js +106 -2
- package/dist/core/execution-adapters.js +154 -0
- package/dist/core/execution-context.js +63 -0
- package/dist/core/execution-profile.js +270 -0
- package/dist/core/execution.js +255 -0
- package/dist/core/facade-schema.js +81 -0
- package/dist/core/federation-cloud.js +99 -0
- package/dist/core/federation-message.js +52 -0
- package/dist/core/federation-transport.js +65 -0
- package/dist/core/gc-semantic.js +482 -0
- package/dist/core/governance.js +247 -0
- package/dist/core/guards.js +19 -0
- package/dist/core/ideation.js +72 -0
- package/dist/core/identity.js +252 -28
- package/dist/core/ids.js +6 -0
- package/dist/core/input-validation.js +2 -2
- package/dist/core/instruction-templates.js +344 -136
- package/dist/core/io.js +90 -11
- package/dist/core/lock.js +6 -2
- package/dist/core/loops/brief-assembly.js +213 -0
- package/dist/core/loops/facade-schema.js +148 -0
- package/dist/core/loops/index.js +7 -0
- package/dist/core/loops/iteration-engine.js +139 -0
- package/dist/core/loops/lock.js +385 -0
- package/dist/core/loops/store.js +201 -0
- package/dist/core/loops/types.js +403 -0
- package/dist/core/loops/verbs.js +534 -0
- package/dist/core/markdown.js +15 -3
- package/dist/core/memory-compactor.js +432 -0
- package/dist/core/memory-git.js +152 -8
- package/dist/core/messaging.js +278 -0
- package/dist/core/migration.js +32 -1
- package/dist/core/mutation-pipeline.js +4 -2
- package/dist/core/operations/memory-mutation.js +129 -0
- package/dist/core/operations/memory-write.js +78 -0
- package/dist/core/operations/plan.js +190 -0
- package/dist/core/policy.js +169 -0
- package/dist/core/repo-analysis.js +67 -0
- package/dist/core/reputation.js +9 -3
- package/dist/core/schema.js +546 -21
- package/dist/core/search.js +21 -2
- package/dist/core/security-cache.js +71 -0
- package/dist/core/security-guard.js +152 -0
- package/dist/core/security-scoring.js +86 -0
- package/dist/core/sequence.js +130 -0
- package/dist/core/socket-client.js +113 -0
- package/dist/core/staleness.js +246 -0
- package/dist/core/state.js +98 -22
- package/dist/core/store-resolution.js +54 -12
- package/dist/core/toml-writer.js +76 -0
- package/dist/core/upgrades/backup.js +232 -0
- package/dist/core/upgrades/health-check.js +169 -0
- package/dist/core/upgrades/patches/candidate-archive.js +145 -0
- package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
- package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
- package/dist/core/upgrades/schema-version.js +97 -0
- package/dist/core/worktree.js +606 -0
- package/dist/facts.js +114 -0
- package/dist/facts.json +111 -0
- package/docs/architecture/project-refs.md +5 -1
- package/docs/cli.md +690 -43
- package/docs/concepts/ideation-loop.md +317 -0
- package/docs/concepts/loop-engine.md +456 -0
- package/docs/concepts/mcp-governance.md +268 -0
- package/docs/concepts/memory-staleness.md +122 -0
- package/docs/concepts/multi-agent-workflows.md +166 -0
- package/docs/concepts/plans-and-claims.md +31 -6
- package/docs/concepts/project-md-convention.md +35 -0
- package/docs/concepts/troubleshooting.md +220 -0
- package/docs/concepts/upgrade-cli.md +202 -0
- package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
- package/docs/context-format-changelog.md +2 -2
- package/docs/context-format.md +2 -2
- package/docs/index.md +68 -0
- package/docs/integrations/agents.md +15 -16
- package/docs/integrations/cline.md +88 -0
- package/docs/integrations/codex.md +75 -23
- package/docs/integrations/continue.md +60 -0
- package/docs/integrations/copilot.md +67 -9
- package/docs/integrations/kilocode.md +72 -0
- package/docs/integrations/mcp.md +304 -21
- package/docs/integrations/mistral-vibe.md +122 -0
- package/docs/integrations/opencode.md +84 -0
- package/docs/integrations/overview.md +23 -8
- package/docs/integrations/roo.md +74 -0
- package/docs/integrations/windsurf.md +83 -0
- package/docs/mcp-schema-changelog.md +191 -1
- package/docs/playbooks/integration/index.md +121 -0
- package/docs/playbooks/productivity/index.md +102 -0
- package/docs/playbooks/team/index.md +122 -0
- package/docs/product/agent-first-model.md +184 -0
- package/docs/product/entity-model-audit.md +462 -0
- package/docs/quickstart-existing-project.md +135 -0
- package/docs/quickstart.md +124 -37
- package/docs/release-maintenance.md +79 -0
- package/docs/review.md +2 -0
- package/docs/server-operations.md +118 -0
- package/package.json +20 -12
- package/dist/commands/claude-desktop-extension.js +0 -18
- package/dist/commands/diff.js +0 -99
- package/dist/core/claude-desktop-extension.js +0 -224
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight staleness detection for brainclaw memory entities.
|
|
3
|
+
*
|
|
4
|
+
* Staleness is a soft signal — items are warned, not auto-archived.
|
|
5
|
+
* Users choose to dismiss, resolve, or archive via explicit commands.
|
|
6
|
+
*/
|
|
7
|
+
import { resolvedSource } from './candidates.js';
|
|
8
|
+
/** Thresholds in days. Adjust via config in the future. */
|
|
9
|
+
export const STALENESS_THRESHOLDS = {
|
|
10
|
+
/** in_progress plan with no update in N days */
|
|
11
|
+
plan_in_progress_days: 7,
|
|
12
|
+
/** todo/blocked plan not started in N days */
|
|
13
|
+
plan_idle_days: 30,
|
|
14
|
+
/** open handoff older than N days */
|
|
15
|
+
handoff_open_days: 14,
|
|
16
|
+
/** pending candidate older than N days */
|
|
17
|
+
candidate_pending_days: 21,
|
|
18
|
+
/** auto-generated pending candidate older than N days */
|
|
19
|
+
candidate_auto_pending_days: 30,
|
|
20
|
+
/**
|
|
21
|
+
* Observation runtime_note older than N days without explicit
|
|
22
|
+
* expiry. Session start/end notes are transient by nature and
|
|
23
|
+
* never flagged regardless of age.
|
|
24
|
+
*/
|
|
25
|
+
runtime_note_observation_days: 30,
|
|
26
|
+
};
|
|
27
|
+
function ageDays(isoDate, nowMs) {
|
|
28
|
+
return Math.floor((nowMs - Date.parse(isoDate)) / 86_400_000);
|
|
29
|
+
}
|
|
30
|
+
function truncate(text, max = 80) {
|
|
31
|
+
return text.length > max ? text.slice(0, max - 3) + '...' : text;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Detect stale plans based on status and last-update age.
|
|
35
|
+
* Returns one warning per stale plan.
|
|
36
|
+
*/
|
|
37
|
+
export function detectStalePlans(plans, nowMs = Date.now(), thresholds = STALENESS_THRESHOLDS) {
|
|
38
|
+
const warnings = [];
|
|
39
|
+
for (const plan of plans) {
|
|
40
|
+
if (plan.status === 'done' || plan.status === 'dropped')
|
|
41
|
+
continue;
|
|
42
|
+
// Use most recent step update, fall back to plan updated_at, then created_at
|
|
43
|
+
const lastActivity = getLastPlanActivity(plan);
|
|
44
|
+
const age = ageDays(lastActivity, nowMs);
|
|
45
|
+
if (plan.status === 'in_progress' && age >= thresholds.plan_in_progress_days) {
|
|
46
|
+
warnings.push({
|
|
47
|
+
id: plan.id,
|
|
48
|
+
entity: 'plan',
|
|
49
|
+
text: truncate(plan.text),
|
|
50
|
+
age_days: age,
|
|
51
|
+
reason: `Plan in_progress for ${age} day${age === 1 ? '' : 's'} without recent activity`,
|
|
52
|
+
suggested_action: `brainclaw plan update ${plan.short_label ?? plan.id} --status done # or --status dropped`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else if ((plan.status === 'todo' || plan.status === 'blocked') && age >= thresholds.plan_idle_days) {
|
|
56
|
+
warnings.push({
|
|
57
|
+
id: plan.id,
|
|
58
|
+
entity: 'plan',
|
|
59
|
+
text: truncate(plan.text),
|
|
60
|
+
age_days: age,
|
|
61
|
+
reason: `Plan ${plan.status} for ${age} day${age === 1 ? '' : 's'} without progress`,
|
|
62
|
+
suggested_action: `brainclaw plan update ${plan.short_label ?? plan.id} --status in_progress # or --status dropped`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return warnings;
|
|
67
|
+
}
|
|
68
|
+
function getLastPlanActivity(plan) {
|
|
69
|
+
// If steps exist, find the most recently updated step
|
|
70
|
+
if (plan.steps && plan.steps.length > 0) {
|
|
71
|
+
const stepDates = plan.steps.map((s) => s.updated_at).filter(Boolean);
|
|
72
|
+
if (stepDates.length > 0) {
|
|
73
|
+
const latest = stepDates.reduce((a, b) => (a > b ? a : b));
|
|
74
|
+
// Only use step date if it's more recent than plan.updated_at
|
|
75
|
+
if (latest > plan.updated_at)
|
|
76
|
+
return latest;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return plan.updated_at;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Detect traps that have passed their expiry date but are still marked active.
|
|
83
|
+
*/
|
|
84
|
+
export function detectExpiredTraps(traps, nowIso = new Date().toISOString(), nowMs = Date.now()) {
|
|
85
|
+
const warnings = [];
|
|
86
|
+
for (const trap of traps) {
|
|
87
|
+
if (trap.status !== 'active')
|
|
88
|
+
continue;
|
|
89
|
+
if (!trap.expires_at || trap.expires_at > nowIso)
|
|
90
|
+
continue;
|
|
91
|
+
const age = ageDays(trap.expires_at, nowMs);
|
|
92
|
+
warnings.push({
|
|
93
|
+
id: trap.id,
|
|
94
|
+
entity: 'trap',
|
|
95
|
+
text: truncate(trap.text),
|
|
96
|
+
age_days: age,
|
|
97
|
+
reason: `Trap expired ${age} day${age === 1 ? '' : 's'} ago (expires_at: ${trap.expires_at.slice(0, 10)})`,
|
|
98
|
+
suggested_action: `brainclaw trap resolve ${trap.short_label ?? trap.id}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return warnings;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Detect open handoffs that have not been acted on for a long time.
|
|
105
|
+
*/
|
|
106
|
+
export function detectStaleHandoffs(handoffs, nowMs = Date.now(), thresholds = STALENESS_THRESHOLDS) {
|
|
107
|
+
const warnings = [];
|
|
108
|
+
for (const handoff of handoffs) {
|
|
109
|
+
if (handoff.status !== 'open')
|
|
110
|
+
continue;
|
|
111
|
+
const age = ageDays(handoff.created_at, nowMs);
|
|
112
|
+
if (age >= thresholds.handoff_open_days) {
|
|
113
|
+
warnings.push({
|
|
114
|
+
id: handoff.id,
|
|
115
|
+
entity: 'handoff',
|
|
116
|
+
text: truncate(handoff.text),
|
|
117
|
+
age_days: age,
|
|
118
|
+
reason: `Open handoff from ${handoff.from} → ${handoff.to} has been open for ${age} day${age === 1 ? '' : 's'}`,
|
|
119
|
+
suggested_action: `brainclaw update-handoff ${handoff.short_label ?? handoff.id} --status closed # or accept the handoff`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return warnings;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Detect candidates that have been pending without a decision for a long time.
|
|
127
|
+
*/
|
|
128
|
+
export function detectStaleCandidates(candidates, nowMs = Date.now(), thresholds = STALENESS_THRESHOLDS) {
|
|
129
|
+
const warnings = [];
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
if (candidate.status !== 'pending')
|
|
132
|
+
continue;
|
|
133
|
+
const age = ageDays(candidate.created_at, nowMs);
|
|
134
|
+
const source = resolvedSource(candidate);
|
|
135
|
+
const threshold = source === 'auto'
|
|
136
|
+
? thresholds.candidate_auto_pending_days
|
|
137
|
+
: thresholds.candidate_pending_days;
|
|
138
|
+
if (age >= threshold) {
|
|
139
|
+
const sourceLabel = source === 'auto' ? 'Auto-generated' : 'Pending';
|
|
140
|
+
warnings.push({
|
|
141
|
+
id: candidate.id,
|
|
142
|
+
entity: 'candidate',
|
|
143
|
+
text: truncate(candidate.text),
|
|
144
|
+
age_days: age,
|
|
145
|
+
reason: `${sourceLabel} ${candidate.type} candidate for ${age} day${age === 1 ? '' : 's'} — no accept/reject decision`,
|
|
146
|
+
suggested_action: `brainclaw accept ${candidate.short_label ?? candidate.id} # or: brainclaw reject ${candidate.short_label ?? candidate.id}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return warnings;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Detect observation runtime_notes older than the threshold that
|
|
154
|
+
* lack an explicit `expires_at`. Session start/end notes are
|
|
155
|
+
* transient markers and never flagged.
|
|
156
|
+
*
|
|
157
|
+
* Notes with `expires_at` in the future are treated as operator-managed
|
|
158
|
+
* and skipped. Notes that have already expired are flagged separately
|
|
159
|
+
* with a short age relative to the expiry (matches the trap pattern).
|
|
160
|
+
*/
|
|
161
|
+
export function detectStaleRuntimeNotes(notes, nowMs = Date.now(), thresholds = STALENESS_THRESHOLDS) {
|
|
162
|
+
const warnings = [];
|
|
163
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
164
|
+
for (const note of notes) {
|
|
165
|
+
if (note.note_type !== 'observation')
|
|
166
|
+
continue;
|
|
167
|
+
// Honour operator-set expiries: expired → flag with the expiry age.
|
|
168
|
+
if (note.expires_at) {
|
|
169
|
+
if (note.expires_at > nowIso)
|
|
170
|
+
continue; // not yet expired
|
|
171
|
+
const age = ageDays(note.expires_at, nowMs);
|
|
172
|
+
warnings.push({
|
|
173
|
+
id: note.id,
|
|
174
|
+
entity: 'runtime_note',
|
|
175
|
+
text: truncate(note.text),
|
|
176
|
+
age_days: age,
|
|
177
|
+
reason: `Runtime note expired ${age} day${age === 1 ? '' : 's'} ago (expires_at: ${note.expires_at.slice(0, 10)})`,
|
|
178
|
+
suggested_action: `bclaw_remove(entity: "runtime_note", id: "${note.id}")`,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const age = ageDays(note.created_at, nowMs);
|
|
183
|
+
if (age >= thresholds.runtime_note_observation_days) {
|
|
184
|
+
warnings.push({
|
|
185
|
+
id: note.id,
|
|
186
|
+
entity: 'runtime_note',
|
|
187
|
+
text: truncate(note.text),
|
|
188
|
+
age_days: age,
|
|
189
|
+
reason: `Observation runtime note from ${note.agent} is ${age} day${age === 1 ? '' : 's'} old with no expiry set`,
|
|
190
|
+
suggested_action: `bclaw_remove(entity: "runtime_note", id: "${note.id}") # or bclaw_update to set expires_at`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return warnings;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Run all staleness detectors and return a combined report.
|
|
198
|
+
* Warnings are sorted by age (oldest first) so the most urgent surface first.
|
|
199
|
+
*
|
|
200
|
+
* @param plans Active (non-done/non-dropped) plans
|
|
201
|
+
* @param traps All known traps (active)
|
|
202
|
+
* @param handoffs Open handoffs
|
|
203
|
+
* @param candidates Pending candidates
|
|
204
|
+
* @param nowMs Optional timestamp override (for testing)
|
|
205
|
+
*/
|
|
206
|
+
export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date.now(), runtimeNotes = []) {
|
|
207
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
208
|
+
const planWarnings = detectStalePlans(plans, nowMs);
|
|
209
|
+
const trapWarnings = detectExpiredTraps(traps, nowIso, nowMs);
|
|
210
|
+
const handoffWarnings = detectStaleHandoffs(handoffs, nowMs);
|
|
211
|
+
const candidateWarnings = detectStaleCandidates(candidates, nowMs);
|
|
212
|
+
const noteWarnings = detectStaleRuntimeNotes(runtimeNotes, nowMs);
|
|
213
|
+
const warnings = [
|
|
214
|
+
...planWarnings,
|
|
215
|
+
...trapWarnings,
|
|
216
|
+
...handoffWarnings,
|
|
217
|
+
...candidateWarnings,
|
|
218
|
+
...noteWarnings,
|
|
219
|
+
].sort((a, b) => b.age_days - a.age_days);
|
|
220
|
+
return {
|
|
221
|
+
warnings,
|
|
222
|
+
plan_count: planWarnings.length,
|
|
223
|
+
trap_count: trapWarnings.length,
|
|
224
|
+
handoff_count: handoffWarnings.length,
|
|
225
|
+
candidate_count: candidateWarnings.length,
|
|
226
|
+
runtime_note_count: noteWarnings.length,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Total warning count across all entity types. */
|
|
230
|
+
export function staleSummary(report) {
|
|
231
|
+
if (report.warnings.length === 0)
|
|
232
|
+
return 'No stale items detected';
|
|
233
|
+
const parts = [];
|
|
234
|
+
if (report.plan_count > 0)
|
|
235
|
+
parts.push(`${report.plan_count} plan${report.plan_count > 1 ? 's' : ''}`);
|
|
236
|
+
if (report.trap_count > 0)
|
|
237
|
+
parts.push(`${report.trap_count} expired trap${report.trap_count > 1 ? 's' : ''}`);
|
|
238
|
+
if (report.handoff_count > 0)
|
|
239
|
+
parts.push(`${report.handoff_count} open handoff${report.handoff_count > 1 ? 's' : ''}`);
|
|
240
|
+
if (report.candidate_count > 0)
|
|
241
|
+
parts.push(`${report.candidate_count} pending candidate${report.candidate_count > 1 ? 's' : ''}`);
|
|
242
|
+
if (report.runtime_note_count > 0)
|
|
243
|
+
parts.push(`${report.runtime_note_count} stale runtime note${report.runtime_note_count > 1 ? 's' : ''}`);
|
|
244
|
+
return parts.join(', ');
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=staleness.js.map
|
package/dist/core/state.js
CHANGED
|
@@ -7,6 +7,8 @@ import { commitMemoryChange } from './memory-git.js';
|
|
|
7
7
|
import { appendEvent } from './event-log.js';
|
|
8
8
|
import { loadVersionedJsonFile, saveVersionedJsonFile } from './migration.js';
|
|
9
9
|
import { rebuildProjectMd } from './markdown.js';
|
|
10
|
+
import { refreshLiveCompanions } from '../commands/export.js';
|
|
11
|
+
import { logger } from './logger.js';
|
|
10
12
|
export function emptyState() {
|
|
11
13
|
return {
|
|
12
14
|
version: 1,
|
|
@@ -27,8 +29,10 @@ function loadDirectoryItems(dirPath, schema, documentType) {
|
|
|
27
29
|
try {
|
|
28
30
|
items.push(schema.parse(loadVersionedJsonFile(documentType, path.join(dirPath, file)).document));
|
|
29
31
|
}
|
|
30
|
-
catch {
|
|
31
|
-
//
|
|
32
|
+
catch (error) {
|
|
33
|
+
// Record-level schema failure. We preserve the file on disk (see syncDirectory)
|
|
34
|
+
// so nothing is silently lost, but surface the drift so operators can repair.
|
|
35
|
+
logger.warn(`Invalid ${documentType} file ${file} in ${dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
return items;
|
|
@@ -50,7 +54,7 @@ export function loadState(cwd) {
|
|
|
50
54
|
state.plan_items.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
51
55
|
return state;
|
|
52
56
|
}
|
|
53
|
-
function syncDirectory(dirPath, items, documentType) {
|
|
57
|
+
function syncDirectory(dirPath, items, documentType, schema) {
|
|
54
58
|
if (!fs.existsSync(dirPath)) {
|
|
55
59
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
56
60
|
}
|
|
@@ -61,41 +65,113 @@ function syncDirectory(dirPath, items, documentType) {
|
|
|
61
65
|
const filepath = path.join(dirPath, `${item.id}.json`);
|
|
62
66
|
saveVersionedJsonFile(documentType, filepath, item);
|
|
63
67
|
}
|
|
64
|
-
// Remove files that are no longer in the state (e.g. if deleted/pruned)
|
|
68
|
+
// Remove files that are no longer in the state (e.g. if deleted/pruned).
|
|
69
|
+
// CRITICAL: we must distinguish "file dropped from state intentionally" from
|
|
70
|
+
// "file silently dropped by loadDirectoryItems because its schema.parse threw".
|
|
71
|
+
// Deleting the second kind corrupts data (see trap: silent-data-loss via
|
|
72
|
+
// load-swallow + write-sync-GC). So before unlinking, we re-validate the file
|
|
73
|
+
// against the schema. Parseable + not in state = intentional remove → unlink.
|
|
74
|
+
// Unparseable = preserved, operator can inspect/repair.
|
|
65
75
|
const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.json'));
|
|
66
76
|
for (const file of files) {
|
|
67
77
|
const id = file.replace('.json', '');
|
|
68
|
-
if (
|
|
69
|
-
|
|
78
|
+
if (currentIds.has(id))
|
|
79
|
+
continue;
|
|
80
|
+
const filepath = path.join(dirPath, file);
|
|
81
|
+
let parseable = false;
|
|
82
|
+
try {
|
|
83
|
+
schema.parse(loadVersionedJsonFile(documentType, filepath).document);
|
|
84
|
+
parseable = true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Already logged by loadDirectoryItems — leave the file in place.
|
|
88
|
+
}
|
|
89
|
+
if (parseable) {
|
|
90
|
+
fs.unlinkSync(filepath);
|
|
70
91
|
}
|
|
71
92
|
}
|
|
72
93
|
}
|
|
73
94
|
export function saveState(state, cwd) {
|
|
74
95
|
persistState(state, cwd, { writeProjectMarkdown: false });
|
|
75
96
|
}
|
|
97
|
+
function persistStateUnlocked(state, cwd, options = {}) {
|
|
98
|
+
writeStateDirectories(state, cwd);
|
|
99
|
+
if (options.writeProjectMarkdown ?? true) {
|
|
100
|
+
rebuildProjectMd(state, cwd);
|
|
101
|
+
}
|
|
102
|
+
appendEvent({
|
|
103
|
+
action: options.eventAction ?? 'update',
|
|
104
|
+
item_type: 'state',
|
|
105
|
+
agent: 'system',
|
|
106
|
+
summary: options.eventSummary,
|
|
107
|
+
}, cwd);
|
|
108
|
+
commitMemoryChange(options.commitMessage ?? 'state update', cwd);
|
|
109
|
+
// Auto-refresh live companion files (Tier B/C agents) after state mutations.
|
|
110
|
+
// Non-fatal: failures are logged but don't break the mutation.
|
|
111
|
+
try {
|
|
112
|
+
refreshLiveCompanions(cwd);
|
|
113
|
+
}
|
|
114
|
+
catch { /* best-effort */ }
|
|
115
|
+
}
|
|
116
|
+
function cleanupLegacyDir(entityName, currentIds, cwd, documentType, schema) {
|
|
117
|
+
const writeDir = resolveEntityDir(entityName, cwd, 'write');
|
|
118
|
+
const readDir = resolveEntityDir(entityName, cwd, 'read');
|
|
119
|
+
// If read resolves to a different (legacy) directory, clean orphans there too.
|
|
120
|
+
// Match syncDirectory's safety condition: only delete parseable records that
|
|
121
|
+
// are absent from the current state. Schema-invalid legacy files may be drifted
|
|
122
|
+
// data that operators still need to inspect or repair.
|
|
123
|
+
if (readDir !== writeDir && fs.existsSync(readDir)) {
|
|
124
|
+
const files = fs.readdirSync(readDir).filter(f => f.endsWith('.json'));
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const id = file.replace('.json', '');
|
|
127
|
+
if (currentIds.has(id))
|
|
128
|
+
continue;
|
|
129
|
+
const filepath = path.join(readDir, file);
|
|
130
|
+
let parseable = false;
|
|
131
|
+
try {
|
|
132
|
+
schema.parse(loadVersionedJsonFile(documentType, filepath).document);
|
|
133
|
+
parseable = true;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
logger.warn(`Preserving unparseable legacy ${entityName} file ${file}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (parseable) {
|
|
140
|
+
fs.unlinkSync(filepath);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
76
145
|
function writeStateDirectories(state, cwd) {
|
|
77
146
|
ensureMemoryDir(cwd);
|
|
78
147
|
const effectiveCwd = cwd ?? process.cwd();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
148
|
+
const entities = [
|
|
149
|
+
{ name: 'constraints', items: state.active_constraints, docType: 'constraint', schema: ConstraintSchema },
|
|
150
|
+
{ name: 'decisions', items: state.recent_decisions, docType: 'decision', schema: DecisionSchema },
|
|
151
|
+
{ name: 'traps', items: state.known_traps, docType: 'trap', schema: TrapSchema },
|
|
152
|
+
{ name: 'handoffs', items: state.open_handoffs, docType: 'handoff', schema: HandoffSchema },
|
|
153
|
+
{ name: 'plans', items: state.plan_items, docType: 'plan', schema: PlanItemSchema },
|
|
154
|
+
];
|
|
155
|
+
for (const { name, items, docType, schema } of entities) {
|
|
156
|
+
const writeDir = resolveEntityDir(name, effectiveCwd, 'write');
|
|
157
|
+
syncDirectory(writeDir, items, docType, schema);
|
|
158
|
+
const currentIds = new Set(items.map(item => item.id));
|
|
159
|
+
cleanupLegacyDir(name, currentIds, effectiveCwd, docType, schema);
|
|
160
|
+
}
|
|
84
161
|
}
|
|
85
162
|
export function persistState(state, cwd, options = {}) {
|
|
86
163
|
const effectiveCwd = cwd ?? process.cwd();
|
|
87
164
|
mutate({ cwd: effectiveCwd }, () => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
commitMemoryChange(options.commitMessage ?? 'state update', effectiveCwd);
|
|
165
|
+
persistStateUnlocked(state, effectiveCwd, options);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
export function mutateState(mutateFn, cwd, options = {}) {
|
|
169
|
+
const effectiveCwd = cwd ?? process.cwd();
|
|
170
|
+
return mutate({ cwd: effectiveCwd }, () => {
|
|
171
|
+
const state = loadState(effectiveCwd);
|
|
172
|
+
const result = mutateFn(state);
|
|
173
|
+
persistStateUnlocked(state, effectiveCwd, options);
|
|
174
|
+
return result;
|
|
99
175
|
});
|
|
100
176
|
}
|
|
101
177
|
//# sourceMappingURL=state.js.map
|
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { loadActiveProject } from './active-project.js';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
|
+
import { loadCurrentSession } from './identity.js';
|
|
6
7
|
import { MEMORY_DIR } from './io.js';
|
|
7
8
|
import { summarizeWorkspaceProjects } from './workspace-projects.js';
|
|
8
9
|
/**
|
|
@@ -90,23 +91,39 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
|
|
|
90
91
|
*
|
|
91
92
|
* Priority:
|
|
92
93
|
* 1. explicitCwd (--cwd flag)
|
|
93
|
-
* 2.
|
|
94
|
-
* 3.
|
|
95
|
-
* 4.
|
|
94
|
+
* 2. BRAINCLAW_CWD env var → absolute workspace path injected by MCP configs
|
|
95
|
+
* 3. BRAINCLAW_PROJECT env var → resolved by name/path from workspace
|
|
96
|
+
* 4. Session-scoped active project (from .current-session)
|
|
97
|
+
* 5. Global active-project.json in workspace root
|
|
98
|
+
* 6. process.cwd()
|
|
96
99
|
*/
|
|
97
100
|
export function resolveEffectiveCwd(options = {}) {
|
|
98
101
|
// 1. Explicit --cwd flag
|
|
99
102
|
if (options.explicitCwd) {
|
|
100
103
|
return path.resolve(options.explicitCwd);
|
|
101
104
|
}
|
|
102
|
-
// 2.
|
|
105
|
+
// 2. BRAINCLAW_CWD env var — set by MCP configs to bind to the workspace
|
|
106
|
+
// regardless of the IDE's process.cwd() at launch time
|
|
107
|
+
const envCwd = process.env.BRAINCLAW_CWD?.trim();
|
|
108
|
+
if (envCwd && fs.existsSync(path.join(path.resolve(envCwd), MEMORY_DIR, 'config.yaml'))) {
|
|
109
|
+
return path.resolve(envCwd);
|
|
110
|
+
}
|
|
111
|
+
// 3. BRAINCLAW_PROJECT env var
|
|
103
112
|
const envProject = process.env.BRAINCLAW_PROJECT;
|
|
104
113
|
if (envProject) {
|
|
105
114
|
const resolved = resolveProjectRef(envProject, process.cwd(), options.storeChainOptions);
|
|
106
115
|
if (resolved)
|
|
107
116
|
return resolved;
|
|
108
117
|
}
|
|
109
|
-
// 3. active
|
|
118
|
+
// 3. Session-scoped active project (per-agent, no cross-agent interference)
|
|
119
|
+
const session = loadCurrentSession(process.cwd());
|
|
120
|
+
if (session?.active_project) {
|
|
121
|
+
const sp = session.active_project;
|
|
122
|
+
if (fs.existsSync(path.join(sp.path, MEMORY_DIR, 'config.yaml'))) {
|
|
123
|
+
return sp.path;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 4. Global active-project.json from workspace root
|
|
110
127
|
const wsRoot = resolveWorkspaceRoot(process.cwd(), options.storeChainOptions);
|
|
111
128
|
if (wsRoot) {
|
|
112
129
|
const active = loadActiveProject(wsRoot);
|
|
@@ -114,7 +131,7 @@ export function resolveEffectiveCwd(options = {}) {
|
|
|
114
131
|
return active.path;
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
|
-
//
|
|
134
|
+
// 5. Default
|
|
118
135
|
return process.cwd();
|
|
119
136
|
}
|
|
120
137
|
/**
|
|
@@ -133,7 +150,10 @@ export function resolveWorkspaceRoot(cwd = process.cwd(), options = {}) {
|
|
|
133
150
|
* Returns undefined when the reference cannot be resolved to a valid brainclaw project.
|
|
134
151
|
*/
|
|
135
152
|
export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
|
|
136
|
-
|
|
153
|
+
// Walk UP from real cwd to find the outermost .brainclaw/ — this avoids
|
|
154
|
+
// circular resolution when an active project narrows the workspace view.
|
|
155
|
+
const wsRoot = findOutermostBrainclawRoot(process.cwd())
|
|
156
|
+
?? resolveWorkspaceRoot(cwd, storeChainOptions);
|
|
137
157
|
if (!wsRoot)
|
|
138
158
|
return undefined;
|
|
139
159
|
// Try as absolute path
|
|
@@ -145,27 +165,29 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
|
|
|
145
165
|
if (fs.existsSync(path.join(asPath, MEMORY_DIR, 'config.yaml'))) {
|
|
146
166
|
return asPath;
|
|
147
167
|
}
|
|
148
|
-
// Try by project name: scan child stores
|
|
168
|
+
// Try by project name or project ID: scan child stores
|
|
149
169
|
const chain = resolveStoreChain(wsRoot, storeChainOptions);
|
|
150
170
|
for (const store of chain) {
|
|
151
171
|
if (store.cwd === wsRoot)
|
|
152
|
-
continue;
|
|
172
|
+
continue;
|
|
153
173
|
try {
|
|
154
174
|
const config = loadConfig(store.cwd);
|
|
155
|
-
if (config.project_name === ref)
|
|
175
|
+
if (config.project_name === ref || config.project_id === ref)
|
|
156
176
|
return store.cwd;
|
|
157
177
|
}
|
|
158
178
|
catch {
|
|
159
179
|
// skip unreadable configs
|
|
160
180
|
}
|
|
161
181
|
}
|
|
162
|
-
// Try discovering child projects by scanning filesystem
|
|
182
|
+
// Try discovering child projects by scanning filesystem (deep scan for monorepos)
|
|
163
183
|
try {
|
|
164
184
|
const wsConfig = loadConfig(wsRoot);
|
|
165
185
|
const summary = summarizeWorkspaceProjects(wsRoot, wsConfig);
|
|
166
186
|
for (const project of summary.discovered_projects) {
|
|
167
187
|
const projectPath = path.resolve(wsRoot, project.path);
|
|
168
|
-
if (project.project_name === ref
|
|
188
|
+
if (project.project_name === ref
|
|
189
|
+
|| project.project_id === ref
|
|
190
|
+
|| path.basename(project.path) === ref) {
|
|
169
191
|
if (fs.existsSync(path.join(projectPath, MEMORY_DIR, 'config.yaml'))) {
|
|
170
192
|
return projectPath;
|
|
171
193
|
}
|
|
@@ -177,6 +199,26 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
|
|
|
177
199
|
}
|
|
178
200
|
return undefined;
|
|
179
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Walk UP from a directory and return the outermost .brainclaw/ root found.
|
|
204
|
+
* This bypasses resolveEffectiveCwd / active project to find the true workspace root.
|
|
205
|
+
*/
|
|
206
|
+
export function findOutermostBrainclawRoot(startDir) {
|
|
207
|
+
let dir = path.resolve(startDir);
|
|
208
|
+
const root = path.parse(dir).root;
|
|
209
|
+
const home = os.homedir();
|
|
210
|
+
let outermost;
|
|
211
|
+
while (dir !== root && dir !== home) {
|
|
212
|
+
if (fs.existsSync(path.join(dir, MEMORY_DIR, 'config.yaml'))) {
|
|
213
|
+
outermost = dir;
|
|
214
|
+
}
|
|
215
|
+
const parent = path.dirname(dir);
|
|
216
|
+
if (parent === dir)
|
|
217
|
+
break;
|
|
218
|
+
dir = parent;
|
|
219
|
+
}
|
|
220
|
+
return outermost;
|
|
221
|
+
}
|
|
180
222
|
/**
|
|
181
223
|
* Resolve the most specific child store that should answer a context request.
|
|
182
224
|
*
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TOML writer for the small subset brainclaw needs (Mistral Vibe MCP
|
|
3
|
+
* config). Zero runtime dependency by policy. Supports:
|
|
4
|
+
* - inline tables `[name]`
|
|
5
|
+
* - array-of-tables `[[name]]`
|
|
6
|
+
* - string keys
|
|
7
|
+
* - string and string-array values
|
|
8
|
+
* - basic escaping for strings (`\` and `"` and control chars)
|
|
9
|
+
*
|
|
10
|
+
* Does NOT support: numbers, booleans, dates, nested tables, mixed-type arrays,
|
|
11
|
+
* multi-line strings, comments. If you need any of those, reach for `@iarna/toml`
|
|
12
|
+
* — but every brainclaw call site so far fits this subset.
|
|
13
|
+
*/
|
|
14
|
+
/** Escape a string for a TOML basic string literal. */
|
|
15
|
+
export function escapeTomlString(value) {
|
|
16
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
|
17
|
+
}
|
|
18
|
+
function renderValue(value) {
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
const items = value.map((v) => `"${escapeTomlString(v)}"`).join(', ');
|
|
21
|
+
return `[${items}]`;
|
|
22
|
+
}
|
|
23
|
+
return `"${escapeTomlString(value)}"`;
|
|
24
|
+
}
|
|
25
|
+
function renderTable(name, entries, header) {
|
|
26
|
+
const close = header === '[[' ? ']]' : ']';
|
|
27
|
+
const lines = [`${header}${name}${close}`];
|
|
28
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
29
|
+
lines.push(`${key} = ${renderValue(value)}`);
|
|
30
|
+
}
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
33
|
+
/** Serialize a TomlDocument to a string. Tables come first, then array-of-tables. */
|
|
34
|
+
export function renderToml(doc) {
|
|
35
|
+
const blocks = [];
|
|
36
|
+
for (const table of doc.tables ?? []) {
|
|
37
|
+
blocks.push(renderTable(table.name, table.entries, '['));
|
|
38
|
+
}
|
|
39
|
+
for (const arrayTable of doc.arrayTables ?? []) {
|
|
40
|
+
for (const entry of arrayTable.entries) {
|
|
41
|
+
blocks.push(renderTable(arrayTable.name, entry, '[['));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return blocks.join('\n\n') + (blocks.length > 0 ? '\n' : '');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Heuristic line-based check for "does this TOML already declare a
|
|
48
|
+
* [[<sectionName>]] block whose `name = "<entryName>"` field matches?".
|
|
49
|
+
* Used by writers to remain idempotent without a full TOML parser.
|
|
50
|
+
*
|
|
51
|
+
* Limitations: assumes the `name = "..."` field appears in the first ~10 lines
|
|
52
|
+
* after the `[[sectionName]]` header (true for our writer's output and for
|
|
53
|
+
* hand-written files that follow the convention `name` first).
|
|
54
|
+
*/
|
|
55
|
+
export function tomlArrayTableHasEntry(source, sectionName, entryNameValue) {
|
|
56
|
+
const headerPattern = new RegExp(String.raw `^\[\[\s*${escapeRegex(sectionName)}\s*\]\]\s*$`);
|
|
57
|
+
const namePattern = new RegExp(String.raw `^name\s*=\s*"${escapeRegex(entryNameValue)}"\s*$`);
|
|
58
|
+
const lines = source.split(/\r?\n/);
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
if (headerPattern.test(lines[i])) {
|
|
61
|
+
// Look in the next ~10 lines (until next blank or next header) for `name = "<value>"`
|
|
62
|
+
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
|
|
63
|
+
const line = lines[j].trim();
|
|
64
|
+
if (line.startsWith('['))
|
|
65
|
+
break; // next section
|
|
66
|
+
if (namePattern.test(line))
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
function escapeRegex(s) {
|
|
74
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=toml-writer.js.map
|