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.
Files changed (198) hide show
  1. package/README.md +193 -170
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +683 -23
  4. package/dist/commands/accept.js +3 -0
  5. package/dist/commands/add-step.js +11 -26
  6. package/dist/commands/agent-board.js +70 -3
  7. package/dist/commands/audit.js +19 -0
  8. package/dist/commands/check-policy.js +54 -0
  9. package/dist/commands/check-security-mcp.js +145 -0
  10. package/dist/commands/check-security.js +106 -0
  11. package/dist/commands/claim-resource.js +1 -0
  12. package/dist/commands/codev.js +672 -0
  13. package/dist/commands/compact.js +74 -0
  14. package/dist/commands/complete-step.js +16 -26
  15. package/dist/commands/constraint.js +8 -20
  16. package/dist/commands/decision.js +9 -20
  17. package/dist/commands/delete-plan.js +10 -12
  18. package/dist/commands/delete-step.js +16 -0
  19. package/dist/commands/dispatch.js +163 -0
  20. package/dist/commands/doctor.js +1122 -49
  21. package/dist/commands/enable-agent.js +1 -0
  22. package/dist/commands/export.js +280 -22
  23. package/dist/commands/handoff.js +33 -0
  24. package/dist/commands/harvest.js +189 -0
  25. package/dist/commands/hooks.js +82 -25
  26. package/dist/commands/inbox.js +169 -0
  27. package/dist/commands/init.js +38 -31
  28. package/dist/commands/install-hooks.js +71 -44
  29. package/dist/commands/link.js +89 -0
  30. package/dist/commands/list-claims.js +48 -3
  31. package/dist/commands/list-plans.js +129 -25
  32. package/dist/commands/loops-handlers.js +409 -0
  33. package/dist/commands/mcp-read-handlers.js +1628 -0
  34. package/dist/commands/mcp-schemas.generated.js +74 -0
  35. package/dist/commands/mcp.js +4244 -1475
  36. package/dist/commands/plan-resource.js +64 -0
  37. package/dist/commands/plan.js +12 -26
  38. package/dist/commands/prune.js +37 -2
  39. package/dist/commands/reflect.js +20 -7
  40. package/dist/commands/release-claim.js +11 -6
  41. package/dist/commands/release-notes.js +170 -0
  42. package/dist/commands/repair.js +210 -0
  43. package/dist/commands/run-profile.js +57 -0
  44. package/dist/commands/sequence.js +113 -0
  45. package/dist/commands/session-end.js +423 -14
  46. package/dist/commands/session-start.js +214 -41
  47. package/dist/commands/setup-security.js +103 -0
  48. package/dist/commands/setup.js +42 -4
  49. package/dist/commands/stale.js +109 -0
  50. package/dist/commands/switch.js +131 -10
  51. package/dist/commands/trap.js +14 -31
  52. package/dist/commands/update-handoff.js +63 -4
  53. package/dist/commands/update-plan.js +21 -28
  54. package/dist/commands/update-step.js +37 -0
  55. package/dist/commands/upgrade.js +313 -6
  56. package/dist/commands/usage.js +102 -0
  57. package/dist/commands/version.js +20 -0
  58. package/dist/commands/who.js +124 -0
  59. package/dist/commands/worktree.js +105 -0
  60. package/dist/core/actions.js +315 -0
  61. package/dist/core/agent-capability.js +610 -17
  62. package/dist/core/agent-context.js +7 -1
  63. package/dist/core/agent-files.js +1169 -85
  64. package/dist/core/agent-integrations.js +160 -5
  65. package/dist/core/agent-inventory.js +2 -0
  66. package/dist/core/agent-profiles.js +93 -0
  67. package/dist/core/agent-registry.js +162 -30
  68. package/dist/core/agentrun-reconciler.js +345 -0
  69. package/dist/core/agentruns.js +424 -0
  70. package/dist/core/ai-agent-detection.js +31 -10
  71. package/dist/core/archival.js +77 -0
  72. package/dist/core/assignment-sweeper.js +82 -0
  73. package/dist/core/assignments.js +367 -0
  74. package/dist/core/audit.js +30 -0
  75. package/dist/core/bootstrap.js +61 -10
  76. package/dist/core/brainclaw-version.js +94 -2
  77. package/dist/core/candidates.js +93 -2
  78. package/dist/core/claims.js +419 -0
  79. package/dist/core/codev-metrics.js +77 -0
  80. package/dist/core/codev-personas.js +31 -0
  81. package/dist/core/codev-plan-gen.js +35 -0
  82. package/dist/core/codev-prompts.js +74 -0
  83. package/dist/core/codev-responses.js +62 -0
  84. package/dist/core/codev-rounds.js +218 -0
  85. package/dist/core/config.js +4 -0
  86. package/dist/core/context.js +454 -34
  87. package/dist/core/coordination.js +201 -6
  88. package/dist/core/cross-project.js +230 -16
  89. package/dist/core/default-profiles/doctor.yaml +11 -0
  90. package/dist/core/default-profiles/janitor.yaml +11 -0
  91. package/dist/core/default-profiles/onboarder.yaml +11 -0
  92. package/dist/core/default-profiles/reviewer.yaml +13 -0
  93. package/dist/core/dispatcher.js +1189 -0
  94. package/dist/core/duplicates.js +2 -2
  95. package/dist/core/entity-operations.js +450 -0
  96. package/dist/core/entity-registry.js +344 -0
  97. package/dist/core/event-log.js +1 -0
  98. package/dist/core/events.js +106 -2
  99. package/dist/core/execution-adapters.js +154 -0
  100. package/dist/core/execution-context.js +63 -0
  101. package/dist/core/execution-profile.js +270 -0
  102. package/dist/core/execution.js +255 -0
  103. package/dist/core/facade-schema.js +81 -0
  104. package/dist/core/federation-cloud.js +99 -0
  105. package/dist/core/federation-message.js +52 -0
  106. package/dist/core/federation-transport.js +65 -0
  107. package/dist/core/gc-semantic.js +482 -0
  108. package/dist/core/governance.js +247 -0
  109. package/dist/core/guards.js +19 -0
  110. package/dist/core/ideation.js +72 -0
  111. package/dist/core/identity.js +252 -28
  112. package/dist/core/ids.js +6 -0
  113. package/dist/core/input-validation.js +2 -2
  114. package/dist/core/instruction-templates.js +344 -136
  115. package/dist/core/io.js +90 -11
  116. package/dist/core/lock.js +6 -2
  117. package/dist/core/loops/brief-assembly.js +213 -0
  118. package/dist/core/loops/facade-schema.js +148 -0
  119. package/dist/core/loops/index.js +7 -0
  120. package/dist/core/loops/iteration-engine.js +139 -0
  121. package/dist/core/loops/lock.js +385 -0
  122. package/dist/core/loops/store.js +201 -0
  123. package/dist/core/loops/types.js +403 -0
  124. package/dist/core/loops/verbs.js +534 -0
  125. package/dist/core/markdown.js +15 -3
  126. package/dist/core/memory-compactor.js +432 -0
  127. package/dist/core/memory-git.js +152 -8
  128. package/dist/core/messaging.js +278 -0
  129. package/dist/core/migration.js +32 -1
  130. package/dist/core/mutation-pipeline.js +4 -2
  131. package/dist/core/operations/memory-mutation.js +129 -0
  132. package/dist/core/operations/memory-write.js +78 -0
  133. package/dist/core/operations/plan.js +190 -0
  134. package/dist/core/policy.js +169 -0
  135. package/dist/core/repo-analysis.js +67 -0
  136. package/dist/core/reputation.js +9 -3
  137. package/dist/core/schema.js +546 -21
  138. package/dist/core/search.js +21 -2
  139. package/dist/core/security-cache.js +71 -0
  140. package/dist/core/security-guard.js +152 -0
  141. package/dist/core/security-scoring.js +86 -0
  142. package/dist/core/sequence.js +130 -0
  143. package/dist/core/socket-client.js +113 -0
  144. package/dist/core/staleness.js +246 -0
  145. package/dist/core/state.js +98 -22
  146. package/dist/core/store-resolution.js +54 -12
  147. package/dist/core/toml-writer.js +76 -0
  148. package/dist/core/upgrades/backup.js +232 -0
  149. package/dist/core/upgrades/health-check.js +169 -0
  150. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  151. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  152. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  153. package/dist/core/upgrades/schema-version.js +97 -0
  154. package/dist/core/worktree.js +606 -0
  155. package/dist/facts.js +114 -0
  156. package/dist/facts.json +111 -0
  157. package/docs/architecture/project-refs.md +5 -1
  158. package/docs/cli.md +690 -43
  159. package/docs/concepts/ideation-loop.md +317 -0
  160. package/docs/concepts/loop-engine.md +456 -0
  161. package/docs/concepts/mcp-governance.md +268 -0
  162. package/docs/concepts/memory-staleness.md +122 -0
  163. package/docs/concepts/multi-agent-workflows.md +166 -0
  164. package/docs/concepts/plans-and-claims.md +31 -6
  165. package/docs/concepts/project-md-convention.md +35 -0
  166. package/docs/concepts/troubleshooting.md +220 -0
  167. package/docs/concepts/upgrade-cli.md +202 -0
  168. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  169. package/docs/context-format-changelog.md +2 -2
  170. package/docs/context-format.md +2 -2
  171. package/docs/index.md +68 -0
  172. package/docs/integrations/agents.md +15 -16
  173. package/docs/integrations/cline.md +88 -0
  174. package/docs/integrations/codex.md +75 -23
  175. package/docs/integrations/continue.md +60 -0
  176. package/docs/integrations/copilot.md +67 -9
  177. package/docs/integrations/kilocode.md +72 -0
  178. package/docs/integrations/mcp.md +304 -21
  179. package/docs/integrations/mistral-vibe.md +122 -0
  180. package/docs/integrations/opencode.md +84 -0
  181. package/docs/integrations/overview.md +23 -8
  182. package/docs/integrations/roo.md +74 -0
  183. package/docs/integrations/windsurf.md +83 -0
  184. package/docs/mcp-schema-changelog.md +191 -1
  185. package/docs/playbooks/integration/index.md +121 -0
  186. package/docs/playbooks/productivity/index.md +102 -0
  187. package/docs/playbooks/team/index.md +122 -0
  188. package/docs/product/agent-first-model.md +184 -0
  189. package/docs/product/entity-model-audit.md +462 -0
  190. package/docs/quickstart-existing-project.md +135 -0
  191. package/docs/quickstart.md +124 -37
  192. package/docs/release-maintenance.md +79 -0
  193. package/docs/review.md +2 -0
  194. package/docs/server-operations.md +118 -0
  195. package/package.json +20 -12
  196. package/dist/commands/claude-desktop-extension.js +0 -18
  197. package/dist/commands/diff.js +0 -99
  198. 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
@@ -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
- // skip invalid files
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 (!currentIds.has(id)) {
69
- fs.unlinkSync(path.join(dirPath, file));
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
- syncDirectory(resolveEntityDir('constraints', effectiveCwd, 'write'), state.active_constraints, 'constraint');
80
- syncDirectory(resolveEntityDir('decisions', effectiveCwd, 'write'), state.recent_decisions, 'decision');
81
- syncDirectory(resolveEntityDir('traps', effectiveCwd, 'write'), state.known_traps, 'trap');
82
- syncDirectory(resolveEntityDir('handoffs', effectiveCwd, 'write'), state.open_handoffs, 'handoff');
83
- syncDirectory(resolveEntityDir('plans', effectiveCwd, 'write'), state.plan_items, 'plan');
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
- writeStateDirectories(state, effectiveCwd);
89
- if (options.writeProjectMarkdown ?? true) {
90
- rebuildProjectMd(state, effectiveCwd);
91
- }
92
- appendEvent({
93
- action: options.eventAction ?? 'update',
94
- item_type: 'state',
95
- agent: 'system',
96
- summary: options.eventSummary,
97
- }, effectiveCwd);
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. BRAINCLAW_PROJECT env var → resolved by name/path from workspace
94
- * 3. active-project.json in workspace root
95
- * 4. process.cwd()
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. BRAINCLAW_PROJECT env var
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-project.json from workspace root
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
- // 4. Default
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
- const wsRoot = resolveWorkspaceRoot(cwd, storeChainOptions);
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 for matching project_name
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; // skip workspace itself
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