brainclaw 0.29.2 → 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 (195) hide show
  1. package/README.md +193 -170
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +673 -24
  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 +4221 -1501
  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 +100 -2
  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 +33 -5
  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/brainclaw-version.js +94 -2
  76. package/dist/core/candidates.js +93 -2
  77. package/dist/core/claims.js +419 -0
  78. package/dist/core/codev-metrics.js +77 -0
  79. package/dist/core/codev-personas.js +31 -0
  80. package/dist/core/codev-plan-gen.js +35 -0
  81. package/dist/core/codev-prompts.js +74 -0
  82. package/dist/core/codev-responses.js +62 -0
  83. package/dist/core/codev-rounds.js +218 -0
  84. package/dist/core/config.js +4 -0
  85. package/dist/core/context.js +381 -34
  86. package/dist/core/coordination.js +201 -6
  87. package/dist/core/cross-project.js +230 -16
  88. package/dist/core/default-profiles/doctor.yaml +11 -0
  89. package/dist/core/default-profiles/janitor.yaml +11 -0
  90. package/dist/core/default-profiles/onboarder.yaml +11 -0
  91. package/dist/core/default-profiles/reviewer.yaml +13 -0
  92. package/dist/core/dispatcher.js +1189 -0
  93. package/dist/core/duplicates.js +2 -2
  94. package/dist/core/entity-operations.js +450 -0
  95. package/dist/core/entity-registry.js +344 -0
  96. package/dist/core/events.js +106 -2
  97. package/dist/core/execution-adapters.js +154 -0
  98. package/dist/core/execution-context.js +63 -0
  99. package/dist/core/execution-profile.js +270 -0
  100. package/dist/core/execution.js +255 -0
  101. package/dist/core/facade-schema.js +81 -0
  102. package/dist/core/federation-cloud.js +99 -0
  103. package/dist/core/federation-message.js +52 -0
  104. package/dist/core/federation-transport.js +65 -0
  105. package/dist/core/gc-semantic.js +482 -0
  106. package/dist/core/governance.js +247 -0
  107. package/dist/core/guards.js +19 -0
  108. package/dist/core/ideation.js +72 -0
  109. package/dist/core/identity.js +110 -25
  110. package/dist/core/ids.js +6 -0
  111. package/dist/core/input-validation.js +2 -2
  112. package/dist/core/instruction-templates.js +344 -136
  113. package/dist/core/io.js +90 -11
  114. package/dist/core/lock.js +6 -2
  115. package/dist/core/loops/brief-assembly.js +213 -0
  116. package/dist/core/loops/facade-schema.js +148 -0
  117. package/dist/core/loops/index.js +7 -0
  118. package/dist/core/loops/iteration-engine.js +139 -0
  119. package/dist/core/loops/lock.js +385 -0
  120. package/dist/core/loops/store.js +201 -0
  121. package/dist/core/loops/types.js +403 -0
  122. package/dist/core/loops/verbs.js +534 -0
  123. package/dist/core/markdown.js +15 -3
  124. package/dist/core/memory-compactor.js +432 -0
  125. package/dist/core/memory-git.js +152 -8
  126. package/dist/core/messaging.js +278 -0
  127. package/dist/core/migration.js +32 -1
  128. package/dist/core/mutation-pipeline.js +4 -2
  129. package/dist/core/operations/memory-mutation.js +129 -0
  130. package/dist/core/operations/memory-write.js +78 -0
  131. package/dist/core/operations/plan.js +190 -0
  132. package/dist/core/policy.js +169 -0
  133. package/dist/core/reputation.js +9 -3
  134. package/dist/core/schema.js +491 -6
  135. package/dist/core/search.js +21 -2
  136. package/dist/core/security-cache.js +71 -0
  137. package/dist/core/security-guard.js +152 -0
  138. package/dist/core/security-scoring.js +86 -0
  139. package/dist/core/sequence.js +130 -0
  140. package/dist/core/socket-client.js +113 -0
  141. package/dist/core/staleness.js +246 -0
  142. package/dist/core/state.js +98 -22
  143. package/dist/core/store-resolution.js +43 -11
  144. package/dist/core/toml-writer.js +76 -0
  145. package/dist/core/upgrades/backup.js +232 -0
  146. package/dist/core/upgrades/health-check.js +169 -0
  147. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  148. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  149. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  150. package/dist/core/upgrades/schema-version.js +97 -0
  151. package/dist/core/worktree.js +606 -0
  152. package/dist/facts.js +114 -0
  153. package/dist/facts.json +111 -0
  154. package/docs/architecture/project-refs.md +5 -1
  155. package/docs/cli.md +690 -43
  156. package/docs/concepts/ideation-loop.md +317 -0
  157. package/docs/concepts/loop-engine.md +456 -0
  158. package/docs/concepts/mcp-governance.md +268 -0
  159. package/docs/concepts/memory-staleness.md +122 -0
  160. package/docs/concepts/multi-agent-workflows.md +166 -0
  161. package/docs/concepts/plans-and-claims.md +31 -6
  162. package/docs/concepts/project-md-convention.md +35 -0
  163. package/docs/concepts/troubleshooting.md +220 -0
  164. package/docs/concepts/upgrade-cli.md +202 -0
  165. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  166. package/docs/context-format-changelog.md +2 -2
  167. package/docs/context-format.md +2 -2
  168. package/docs/index.md +68 -0
  169. package/docs/integrations/agents.md +15 -16
  170. package/docs/integrations/cline.md +88 -0
  171. package/docs/integrations/codex.md +75 -23
  172. package/docs/integrations/continue.md +60 -0
  173. package/docs/integrations/copilot.md +67 -9
  174. package/docs/integrations/kilocode.md +72 -0
  175. package/docs/integrations/mcp.md +304 -21
  176. package/docs/integrations/mistral-vibe.md +122 -0
  177. package/docs/integrations/opencode.md +84 -0
  178. package/docs/integrations/overview.md +23 -8
  179. package/docs/integrations/roo.md +74 -0
  180. package/docs/integrations/windsurf.md +83 -0
  181. package/docs/mcp-schema-changelog.md +191 -1
  182. package/docs/playbooks/integration/index.md +121 -0
  183. package/docs/playbooks/productivity/index.md +102 -0
  184. package/docs/playbooks/team/index.md +122 -0
  185. package/docs/product/agent-first-model.md +184 -0
  186. package/docs/product/entity-model-audit.md +462 -0
  187. package/docs/quickstart-existing-project.md +135 -0
  188. package/docs/quickstart.md +124 -37
  189. package/docs/release-maintenance.md +79 -0
  190. package/docs/review.md +2 -0
  191. package/docs/server-operations.md +118 -0
  192. package/package.json +20 -12
  193. package/dist/commands/claude-desktop-extension.js +0 -18
  194. package/dist/commands/diff.js +0 -99
  195. 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
@@ -91,17 +91,24 @@ export function resolveTargetStore(cwd = process.cwd(), target = 'local', option
91
91
  *
92
92
  * Priority:
93
93
  * 1. explicitCwd (--cwd flag)
94
- * 2. BRAINCLAW_PROJECT env var → resolved by name/path from workspace
95
- * 3. Session-scoped active project (from .current-session)
96
- * 4. Global active-project.json in workspace root
97
- * 5. 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()
98
99
  */
99
100
  export function resolveEffectiveCwd(options = {}) {
100
101
  // 1. Explicit --cwd flag
101
102
  if (options.explicitCwd) {
102
103
  return path.resolve(options.explicitCwd);
103
104
  }
104
- // 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
105
112
  const envProject = process.env.BRAINCLAW_PROJECT;
106
113
  if (envProject) {
107
114
  const resolved = resolveProjectRef(envProject, process.cwd(), options.storeChainOptions);
@@ -143,7 +150,10 @@ export function resolveWorkspaceRoot(cwd = process.cwd(), options = {}) {
143
150
  * Returns undefined when the reference cannot be resolved to a valid brainclaw project.
144
151
  */
145
152
  export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
146
- 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);
147
157
  if (!wsRoot)
148
158
  return undefined;
149
159
  // Try as absolute path
@@ -155,27 +165,29 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
155
165
  if (fs.existsSync(path.join(asPath, MEMORY_DIR, 'config.yaml'))) {
156
166
  return asPath;
157
167
  }
158
- // Try by project name: scan child stores for matching project_name
168
+ // Try by project name or project ID: scan child stores
159
169
  const chain = resolveStoreChain(wsRoot, storeChainOptions);
160
170
  for (const store of chain) {
161
171
  if (store.cwd === wsRoot)
162
- continue; // skip workspace itself
172
+ continue;
163
173
  try {
164
174
  const config = loadConfig(store.cwd);
165
- if (config.project_name === ref)
175
+ if (config.project_name === ref || config.project_id === ref)
166
176
  return store.cwd;
167
177
  }
168
178
  catch {
169
179
  // skip unreadable configs
170
180
  }
171
181
  }
172
- // Try discovering child projects by scanning filesystem
182
+ // Try discovering child projects by scanning filesystem (deep scan for monorepos)
173
183
  try {
174
184
  const wsConfig = loadConfig(wsRoot);
175
185
  const summary = summarizeWorkspaceProjects(wsRoot, wsConfig);
176
186
  for (const project of summary.discovered_projects) {
177
187
  const projectPath = path.resolve(wsRoot, project.path);
178
- if (project.project_name === ref) {
188
+ if (project.project_name === ref
189
+ || project.project_id === ref
190
+ || path.basename(project.path) === ref) {
179
191
  if (fs.existsSync(path.join(projectPath, MEMORY_DIR, 'config.yaml'))) {
180
192
  return projectPath;
181
193
  }
@@ -187,6 +199,26 @@ export function resolveProjectRef(ref, cwd = process.cwd(), storeChainOptions) {
187
199
  }
188
200
  return undefined;
189
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
+ }
190
222
  /**
191
223
  * Resolve the most specific child store that should answer a context request.
192
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