brainclaw 1.7.5 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -0,0 +1,36 @@
1
+ import { runBootstrapProfile } from '../../bootstrap.js';
2
+ const DEFAULT_MAX_SEEDS = 20;
3
+ const DEFAULT_MAX_BYTES = 3800;
4
+ export function buildSurveySignalsBaseline(cwd, opts = {}) {
5
+ const maxSeeds = opts.maxSeeds ?? DEFAULT_MAX_SEEDS;
6
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
7
+ const result = runBootstrapProfile({ cwd });
8
+ const baseline = {
9
+ generated_at: new Date().toISOString(),
10
+ source: 'deterministic_scanner',
11
+ summary: result.profile.summary,
12
+ workspace_kind: result.profile.workspace_kind,
13
+ onboarding_mode: result.profile.onboarding_mode,
14
+ confidence: result.profile.confidence,
15
+ sources_scanned: result.profile.sources_scanned,
16
+ native_instruction_files: result.profile.native_instruction_files,
17
+ gaps: result.profile.gaps,
18
+ seed_count: result.seeds.length,
19
+ seeds: result.seeds.slice(0, maxSeeds).map((seed) => ({
20
+ kind: seed.seed_kind,
21
+ text: seed.text,
22
+ source_kind: seed.source_kind,
23
+ source_ref: seed.source_ref,
24
+ confidence: seed.confidence,
25
+ })),
26
+ seeds_truncated: result.seeds.length > maxSeeds,
27
+ };
28
+ // Fit the byte budget by shedding seeds from the tail; the histogram-level
29
+ // fields are small and always kept.
30
+ while (baseline.seeds.length > 0 && Buffer.byteLength(JSON.stringify(baseline), 'utf8') > maxBytes) {
31
+ baseline.seeds.pop();
32
+ baseline.seeds_truncated = true;
33
+ }
34
+ return baseline;
35
+ }
36
+ //# sourceMappingURL=survey-signals-baseline.js.map
@@ -8,6 +8,7 @@ export { BOOTSTRAP_PRESET } from './presets/bootstrap.js';
8
8
  export { writeProjectMdSafe, } from './hooks/bootstrap-write.js';
9
9
  export { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
10
10
  export { readSurveySources, } from './hooks/survey-source-reader.js';
11
+ export { buildSurveySignalsBaseline, } from './hooks/survey-signals-baseline.js';
11
12
  export { acquireLock, hashRequest, recordConflict, withLoopLock, DEFAULT_MAX_MUTATION_DURATION_MS, IDEMPOTENCY_TTL_MS, LEASE_GRACE_MS, LEASE_WINDOW_MS, IdempotencyKeyReusedError, IdempotencyOwnerMismatchError, LockLostError, LockTimeoutError, VersionConflictError, } from './lock.js';
12
13
  export { acquireBootstrapLoop, findExistingBootstrapLoop, BootstrapCoordinationInProgressError, } from './bootstrap-acquire.js';
13
14
  //# sourceMappingURL=index.js.map
@@ -22,6 +22,13 @@ export const BOOTSTRAP_PRESET = {
22
22
  // is unchanged — see types.ts). Empirical motivation: TranslaVox cold-start
23
23
  // missed the actual GCP Speech+Translate pipeline because survey scanned only
24
24
  // topology + manifests (can_0160d6c4).
25
+ //
26
+ // pln#557 step 4 — the deterministic scanner (runBootstrapProfile) now
27
+ // seeds this phase: acquireBootstrapLoop attaches a `signals_baseline`
28
+ // artifact (toolchain, topology, native rules, top seeds) at open time.
29
+ // The champion enriches that baseline into its signals_report — survey
30
+ // quality stops depending on per-agent re-discovery, and bclaw_bootstrap
31
+ // becomes an internal helper of the loop rather than a competing door.
25
32
  {
26
33
  name: 'survey',
27
34
  context_filter: ['project_vision', 'decisions', 'plans', 'feedback'],
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { memoryDir, writeFileAtomic } from '../io.js';
5
5
  import { nowISO } from '../ids.js';
6
+ import { convergeAssignmentToTerminal } from '../assignments.js';
6
7
  import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
7
8
  import { notifyOperatorOnInputRequested } from './hooks/notify-operator.js';
8
9
  import { DEFAULT_PROTOCOLS, LoopArtifactSchema, LoopEventSchema, LoopThreadSchema, } from './types.js';
@@ -416,6 +417,22 @@ export function closeLoop(input, cwd) {
416
417
  reason: input.reason,
417
418
  }, cwd);
418
419
  writeThreadFile(next, cwd);
420
+ // pln#563: converge slot assignments so they don't fossilize in `offered`.
421
+ // A review-loop assignment exists only to drive the turn; closing the loop is
422
+ // the authoritative end of that work. File-based / sandboxed workers never
423
+ // report a terminal status themselves, and the coordinator can't cross-update
424
+ // a worker-owned assignment (trp#291) — so the loop close (a system action)
425
+ // is the right place. Best-effort: a missing or already-terminal assignment
426
+ // must never block the close.
427
+ const assignmentTerminal = input.final_status === 'completed' ? 'completed' : 'cancelled';
428
+ for (const slot of next.slots) {
429
+ if (!slot.assignment_id)
430
+ continue;
431
+ try {
432
+ convergeAssignmentToTerminal(slot.assignment_id, assignmentTerminal, `loop ${input.id} closed (${input.final_status})`, cwd);
433
+ }
434
+ catch { /* never block loop close on assignment convergence */ }
435
+ }
419
436
  return next;
420
437
  }
421
438
  //# sourceMappingURL=store.js.map
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { nowISO } from '../ids.js';
3
+ import { convergeAssignmentToTerminal } from '../assignments.js';
3
4
  import { writeProjectMdSafe } from './hooks/bootstrap-write.js';
4
5
  import { appendEvent, closeLoop, generateMutationId, getLoop, listLoopEvents, writeThreadFile, } from './store.js';
5
6
  import { LoopArtifactSchema, PAUSE_REASONS, } from './types.js';
@@ -19,6 +20,17 @@ function loadLoopOrThrow(id, cwd) {
19
20
  throw new Error(`unknown loop_id ${id}`);
20
21
  return loop;
21
22
  }
23
+ function convergeSlotAssignmentsForClosedLoop(thread, finalStatus, cwd) {
24
+ const assignmentTerminal = finalStatus === 'completed' ? 'completed' : 'cancelled';
25
+ for (const slot of thread.slots) {
26
+ if (!slot.assignment_id)
27
+ continue;
28
+ try {
29
+ convergeAssignmentToTerminal(slot.assignment_id, assignmentTerminal, `loop ${thread.id} closed (${finalStatus})`, cwd);
30
+ }
31
+ catch { /* never block loop close on assignment convergence */ }
32
+ }
33
+ }
22
34
  /* ========================= Stop-condition evaluator ======================= */
23
35
  function isVerdictAccepted(artifact) {
24
36
  if (artifact.type !== 'verdict')
@@ -369,6 +381,7 @@ function commitClosedTransition(thread, final_status, actor, reason, cwd) {
369
381
  reason,
370
382
  }, cwd);
371
383
  writeThreadFile(next, cwd);
384
+ convergeSlotAssignmentsForClosedLoop(next, final_status, cwd);
372
385
  return next;
373
386
  }
374
387
  function resolveTurnSlot(thread, input) {
@@ -402,6 +415,7 @@ export function turn(input, cwd) {
402
415
  status: 'assigned',
403
416
  phase: current.current_phase,
404
417
  assignment_id: input.assignment_id ?? slot.assignment_id,
418
+ claim_id: input.claim_id ?? slot.claim_id,
405
419
  }
406
420
  : slot);
407
421
  const next = {
@@ -435,7 +449,13 @@ export function complete_turn(input, cwd) {
435
449
  throw new Error(`complete_turn: slot_id "${input.slot_id}" not in loop`);
436
450
  // Slot-bound auth. Only enforced when caller_agent_id is supplied (MCP entry path).
437
451
  if (input.caller_agent_id !== undefined && !input.admin_override) {
438
- const ownerMatches = slot.agent_id !== undefined && slot.agent_id === input.caller_agent_id;
452
+ // Instance binding (pln#562 step 4): a claim-bound slot is owned by the
453
+ // INSTANCE holding that claim. When both sides carry claim info, claim
454
+ // equality decides; otherwise fall back to the legacy agent_id check.
455
+ const claimBound = slot.claim_id !== undefined && input.caller_claim_id !== undefined;
456
+ const ownerMatches = claimBound
457
+ ? slot.claim_id === input.caller_claim_id
458
+ : slot.agent_id !== undefined && slot.agent_id === input.caller_agent_id;
439
459
  const creatorMatches = current.created_by === input.caller_agent_id;
440
460
  if (!ownerMatches && !creatorMatches) {
441
461
  throw new Error('unauthorized_slot_write');
@@ -998,6 +1018,9 @@ export function provideInput(input, cwd) {
998
1018
  }, cwd);
999
1019
  }
1000
1020
  writeThreadFile(next, cwd);
1021
+ if (fileApplyResolution !== undefined) {
1022
+ convergeSlotAssignmentsForClosedLoop(next, 'completed', cwd);
1023
+ }
1001
1024
  assertOpenQuestionsInvariant(next, 'provide_input');
1002
1025
  return { thread: next, artifact_id: newArtifact.artifact_id, duplicate: false };
1003
1026
  }
@@ -1,12 +1,17 @@
1
- import { listClaims } from './claims.js';
2
1
  import { loadInstructions } from './instructions.js';
3
2
  import { memoryPath, writeFileAtomic } from './io.js';
4
3
  import { isTrapActive } from './traps.js';
5
4
  import { logger } from './logger.js';
6
5
  export function generateMarkdown(state, cwd) {
7
- const lines = ['# Project Memory', ''];
6
+ const lines = [
7
+ '# Project Memory',
8
+ '',
9
+ '> Legacy derived summary generated from canonical Brainclaw memory.',
10
+ '> PROJECT.md at the workspace root is the durable project vision.',
11
+ '> For active claims, plans, handoffs, and agent state, use `brainclaw agent-board` or MCP board context.',
12
+ '',
13
+ ];
8
14
  const instructions = loadInstructions(cwd).filter((entry) => entry.active);
9
- const claims = listClaims(cwd).filter((claim) => claim.status === 'active');
10
15
  lines.push('## Shared instructions');
11
16
  if (instructions.length === 0) {
12
17
  lines.push('- (none)');
@@ -19,50 +24,6 @@ export function generateMarkdown(state, cwd) {
19
24
  }
20
25
  }
21
26
  lines.push('');
22
- lines.push('## Active claims');
23
- if (claims.length === 0) {
24
- lines.push('- (none)');
25
- }
26
- else {
27
- for (const claim of claims) {
28
- const meta = [claim.scope];
29
- if (claim.plan_id)
30
- meta.push(`plan: ${claim.plan_id}`);
31
- if (claim.project)
32
- meta.push(`project: ${claim.project}`);
33
- lines.push(`- **[${claim.id}]** ${claim.agent} → ${claim.description} _(${meta.join(', ')})_`);
34
- }
35
- }
36
- lines.push('');
37
- lines.push('## Shared plan');
38
- const activePlans = state.plan_items.filter((plan) => plan.status !== 'done' && plan.status !== 'dropped');
39
- if (activePlans.length === 0) {
40
- lines.push('- (none)');
41
- }
42
- else {
43
- for (const plan of activePlans) {
44
- const meta = [plan.status, plan.priority];
45
- if (plan.assignee)
46
- meta.push(`assignee: ${plan.assignee}`);
47
- if (plan.project)
48
- meta.push(`project: ${plan.project}`);
49
- if (plan.steps && plan.steps.length > 0) {
50
- const done = plan.steps.filter((s) => s.status === 'done').length;
51
- meta.push(`${done}/${plan.steps.length} steps`);
52
- }
53
- const tags = plan.tags.length ? ` [${plan.tags.join(', ')}]` : '';
54
- const paths = plan.related_paths?.length ? ` → ${plan.related_paths.join(', ')}` : '';
55
- lines.push(`- **[${plan.id}]** ${plan.text} _(${meta.join(', ')})_${paths}${tags}`);
56
- if (plan.steps && plan.steps.length > 0) {
57
- for (const step of plan.steps) {
58
- const check = step.status === 'done' ? 'x' : ' ';
59
- const assign = step.assignee ? ` _(${step.assignee})_` : '';
60
- lines.push(` - [${check}] [${step.id}] ${step.text}${assign}`);
61
- }
62
- }
63
- }
64
- }
65
- lines.push('');
66
27
  lines.push('## Active constraints');
67
28
  if (state.active_constraints.length === 0) {
68
29
  lines.push('- (none)');
@@ -100,35 +61,6 @@ export function generateMarkdown(state, cwd) {
100
61
  }
101
62
  }
102
63
  lines.push('');
103
- lines.push('## Open handoffs');
104
- const MAX_HANDOFFS = 10;
105
- const MAX_HANDOFF_TEXT = 500;
106
- const activeHandoffs = state.open_handoffs
107
- .filter((h) => h.status === 'open')
108
- .sort((a, b) => b.created_at.localeCompare(a.created_at))
109
- .slice(0, MAX_HANDOFFS);
110
- const totalOpen = state.open_handoffs.filter((h) => h.status === 'open').length;
111
- if (activeHandoffs.length === 0) {
112
- lines.push('- (none)');
113
- }
114
- else {
115
- for (const h of activeHandoffs) {
116
- const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : '';
117
- const paths = h.related_paths?.length ? ` → ${h.related_paths.join(', ')}` : '';
118
- const meta = [h.status];
119
- if (h.plan_id)
120
- meta.push(`plan: ${h.plan_id}`);
121
- if (h.project)
122
- meta.push(`project: ${h.project}`);
123
- const text = h.text.length > MAX_HANDOFF_TEXT ? h.text.slice(0, MAX_HANDOFF_TEXT) + '...' : h.text;
124
- lines.push(`- **[${h.id}]** ${h.from} → ${h.to}: ${text} _(${meta.join(', ')})_${paths}${tags}`);
125
- }
126
- const omitted = totalOpen - activeHandoffs.length;
127
- if (omitted > 0) {
128
- lines.push(`- _(${omitted} older handoffs omitted — use \`bclaw_read_handoff\` to inspect)_`);
129
- }
130
- }
131
- lines.push('');
132
64
  return lines.join('\n');
133
65
  }
134
66
  /**
@@ -0,0 +1,245 @@
1
+ /**
2
+ * MCP command resolution + shared writer plumbing (pln#546 step 3 extraction).
3
+ *
4
+ * Extracted from agent-files.ts so the binary-resolution / shim-tracing logic
5
+ * can evolve independently of the per-agent writers. Three concerns live here:
6
+ *
7
+ * 1. Resolving the brainclaw MCP server invocation from any host shell
8
+ * (which/where → cli.js shim → absolute node + cli.js pair). Falls back
9
+ * to `npx brainclaw mcp` when nothing else resolves.
10
+ * 2. Building the `brainclawMcpEntry` JSON shape that every MCP writer emits
11
+ * under `mcpServers.brainclaw` (or its agent-specific equivalent).
12
+ * 3. Hook-command rendering — `getBclawCliParts` + `buildHookCommand` for
13
+ * writers that emit session-start / Stop / context-diff entries.
14
+ *
15
+ * The `_forceResolve` module flag is private to this file; callers toggle it
16
+ * via `withForcedResolve(cb)` so the next pair of `brainclawMcpEntry` calls
17
+ * overwrites existing absolute paths (used by `patchAllMcpConfigs` post-upgrade).
18
+ */
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ export function isJsonObject(value) {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+ /** Cached MCP command — resolved once per process. */
28
+ let cachedMcpCommand;
29
+ /** Module-level flag: when true, brainclawMcpEntry overwrites existing paths. */
30
+ let _forceResolve = false;
31
+ /**
32
+ * Resolve the brainclaw command for MCP configs.
33
+ * Returns `{ command: "<node>", args: ["<cli.js>", "mcp"] }` so the config
34
+ * works in non-login shells (VS Code Server, MCP subprocesses) on all OSes.
35
+ *
36
+ * Strategy:
37
+ * 1. Find the brainclaw bin via which/where
38
+ * 2. Trace from the bin/shim to the actual cli.js entry point
39
+ * 3. Pair it with the absolute node path
40
+ * Falls back to 'npx brainclaw mcp' if resolution fails.
41
+ */
42
+ function resolveBrainclawMcpCommand() {
43
+ const nodeBin = process.execPath;
44
+ // 1. Try to resolve the cli.js from the installed brainclaw binary
45
+ const cliJs = resolveBrainclawCliJs();
46
+ if (cliJs) {
47
+ return { command: nodeBin, args: [cliJs, 'mcp'] };
48
+ }
49
+ // 2. Fallback: npx (relies on PATH, may resolve wrong version)
50
+ return { command: 'npx', args: ['brainclaw', 'mcp'] };
51
+ }
52
+ /**
53
+ * Trace from the brainclaw bin/shim to the actual dist/cli.js file.
54
+ * Works on Windows (.cmd shim), macOS/Linux (symlink to bin stub).
55
+ */
56
+ function resolveBrainclawCliJs() {
57
+ // Strategy A: find via which/where and trace to cli.js
58
+ const whichCmd = os.platform() === 'win32' ? 'where' : 'which';
59
+ try {
60
+ const result = spawnSync(whichCmd, ['brainclaw'], { encoding: 'utf-8', timeout: 3000 });
61
+ if (result.status === 0) {
62
+ const resolved = result.stdout.trim().split(/\r?\n/)[0]?.trim();
63
+ if (resolved) {
64
+ const cliJs = traceToCliJs(resolved);
65
+ if (cliJs)
66
+ return cliJs;
67
+ }
68
+ }
69
+ }
70
+ catch {
71
+ // Non-fatal — try next strategy
72
+ }
73
+ // Strategy B: resolve from this file's own package (we ARE brainclaw)
74
+ try {
75
+ const ownCliJs = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
76
+ if (fs.existsSync(ownCliJs))
77
+ return ownCliJs;
78
+ }
79
+ catch {
80
+ // Non-fatal
81
+ }
82
+ return undefined;
83
+ }
84
+ /**
85
+ * Given a bin path (shim or symlink), trace to the dist/cli.js entry point.
86
+ *
87
+ * Windows: .cmd shim contains a line like `"%_prog%" "%dp0%\node_modules\brainclaw\dist\cli.js" %*`
88
+ * Unix: bin is a symlink → resolve to real path → go up to package root → dist/cli.js
89
+ */
90
+ function traceToCliJs(binPath) {
91
+ const isWindows = os.platform() === 'win32';
92
+ if (isWindows) {
93
+ // Read the .cmd shim and extract the cli.js path
94
+ const cmdPath = binPath.endsWith('.cmd') ? binPath : `${binPath}.cmd`;
95
+ try {
96
+ const content = fs.readFileSync(cmdPath, 'utf-8');
97
+ // Match patterns like: "%dp0%\node_modules\brainclaw\dist\cli.js"
98
+ const match = content.match(/%dp0%\\([^\s"]+cli\.js)/);
99
+ if (match) {
100
+ const shimDir = path.dirname(cmdPath);
101
+ const cliJs = path.resolve(shimDir, match[1]);
102
+ if (fs.existsSync(cliJs))
103
+ return cliJs;
104
+ }
105
+ }
106
+ catch {
107
+ // Fall through
108
+ }
109
+ }
110
+ else {
111
+ // Unix: follow symlink chain to the real bin, then find cli.js
112
+ try {
113
+ const realBin = fs.realpathSync(binPath);
114
+ // Typical layout: .../node_modules/.bin/brainclaw → ../brainclaw/dist/cli.js
115
+ // Or: .../node_modules/brainclaw/dist/cli.js (direct)
116
+ if (realBin.endsWith('cli.js') && fs.existsSync(realBin))
117
+ return realBin;
118
+ // The bin stub typically lives at node_modules/brainclaw/dist/cli.js
119
+ // or node_modules/.bin/brainclaw → ../brainclaw/dist/cli.js
120
+ const packageRoot = findPackageRoot(realBin);
121
+ if (packageRoot) {
122
+ const cliJs = path.join(packageRoot, 'dist', 'cli.js');
123
+ if (fs.existsSync(cliJs))
124
+ return cliJs;
125
+ }
126
+ }
127
+ catch {
128
+ // Fall through
129
+ }
130
+ }
131
+ return undefined;
132
+ }
133
+ /** Walk up from a file to find the nearest directory containing package.json with name "brainclaw". */
134
+ function findPackageRoot(from) {
135
+ let dir = path.dirname(from);
136
+ for (let i = 0; i < 10; i++) {
137
+ const pkgPath = path.join(dir, 'package.json');
138
+ try {
139
+ if (fs.existsSync(pkgPath)) {
140
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
141
+ if (pkg.name === 'brainclaw')
142
+ return dir;
143
+ }
144
+ }
145
+ catch { /* continue */ }
146
+ const parent = path.dirname(dir);
147
+ if (parent === dir)
148
+ break;
149
+ dir = parent;
150
+ }
151
+ return undefined;
152
+ }
153
+ export function getBrainclawMcpCommand() {
154
+ if (!cachedMcpCommand) {
155
+ cachedMcpCommand = resolveBrainclawMcpCommand();
156
+ }
157
+ return cachedMcpCommand;
158
+ }
159
+ /** Reset the cached MCP command so it gets re-resolved on next access. */
160
+ export function resetMcpCommandCache() {
161
+ cachedMcpCommand = undefined;
162
+ }
163
+ /** Test/internal helper — read the current force-resolve state. */
164
+ export function isForceResolveEnabled() {
165
+ return _forceResolve;
166
+ }
167
+ /**
168
+ * Run `cb` with force-resolve mode enabled, so `brainclawMcpEntry` overwrites
169
+ * any existing absolute paths in user MCP configs. Always restores the prior
170
+ * state, even when `cb` throws.
171
+ */
172
+ export function withForcedResolve(cb) {
173
+ const previous = _forceResolve;
174
+ _forceResolve = true;
175
+ try {
176
+ return cb();
177
+ }
178
+ finally {
179
+ _forceResolve = previous;
180
+ }
181
+ }
182
+ /**
183
+ * Build a complete MCP server entry with relay model env injection.
184
+ * Merges with the existing entry to preserve manual edits (e.g. custom command
185
+ * path, additional env vars, extra args). Only sets defaults for missing fields.
186
+ *
187
+ * When `workspacePath` is provided, injects BRAINCLAW_CWD into the env so
188
+ * the MCP server resolves the correct workspace root regardless of the IDE's
189
+ * process.cwd() at launch time.
190
+ */
191
+ export function brainclawMcpEntry(agentName, existing, workspacePath) {
192
+ const defaults = getBrainclawMcpCommand();
193
+ const ex = isJsonObject(existing) ? existing : {};
194
+ const exEnv = isJsonObject(ex.env) ? ex.env : {};
195
+ // When _forceResolve is true (post-upgrade), always use newly resolved paths.
196
+ // Otherwise preserve existing command if it's an absolute path (manual edit).
197
+ // CRITICAL: once we decide to preserve the command, we MUST also preserve
198
+ // the args. Previously args was always overwritten, which silently clobbered
199
+ // manual customizations (--cwd, --debug, etc.) and broke setups on DGX.
200
+ // See trp#12 + pln#450.
201
+ const useExisting = !_forceResolve && typeof ex.command === 'string' && ex.command !== 'npx';
202
+ const existingArgs = Array.isArray(ex.args) ? ex.args : undefined;
203
+ return {
204
+ command: useExisting ? ex.command : defaults.command,
205
+ args: useExisting && existingArgs ? existingArgs : defaults.args,
206
+ // Merge env: preserve user-added vars, ensure BRAINCLAW_AGENT is set
207
+ env: {
208
+ ...exEnv,
209
+ BRAINCLAW_AGENT: agentName,
210
+ ...(workspacePath ? { BRAINCLAW_CWD: workspacePath } : {}),
211
+ },
212
+ // Preserve timeout if set
213
+ ...(typeof ex.timeout === 'number' ? { timeout: ex.timeout } : {}),
214
+ };
215
+ }
216
+ export function quoteShellArg(arg) {
217
+ if (/^[A-Za-z0-9_./:=+-]+$/.test(arg))
218
+ return arg;
219
+ return `"${arg.replace(/"/g, '\\"')}"`;
220
+ }
221
+ /**
222
+ * Resolve the brainclaw CLI invocation for hook configs.
223
+ * Returns shell-safe parts like `["<node>", "<cli.js>"]` or `["npx", "brainclaw"]`.
224
+ */
225
+ export function getBclawCliParts() {
226
+ const mcpCmd = getBrainclawMcpCommand();
227
+ if (mcpCmd.command === 'npx')
228
+ return ['npx', 'brainclaw'];
229
+ const argsWithoutMcp = [...mcpCmd.args];
230
+ if (argsWithoutMcp[argsWithoutMcp.length - 1] === 'mcp') {
231
+ argsWithoutMcp.pop();
232
+ }
233
+ return [
234
+ mcpCmd.command.replace(/\\/g, '/'),
235
+ ...argsWithoutMcp.map((arg) => arg.replace(/\\/g, '/')),
236
+ ];
237
+ }
238
+ export function buildHookCommand(args, shell = os.platform() === 'win32' ? 'powershell' : 'bash') {
239
+ const rendered = [...getBclawCliParts(), ...args].map(quoteShellArg).join(' ');
240
+ if (shell === 'powershell') {
241
+ return `& ${rendered} 2>$null`;
242
+ }
243
+ return `${rendered} 2>/dev/null`;
244
+ }
245
+ //# sourceMappingURL=mcp-command-resolution.js.map
@@ -17,7 +17,7 @@ import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { normalise, similarity } from './duplicates.js';
19
19
  import { resolveEntityDir } from './io.js';
20
- import { loadState, saveState } from './state.js';
20
+ import { loadState, persistState } from './state.js';
21
21
  import { mutate } from './mutation-pipeline.js';
22
22
  import { logger } from './logger.js';
23
23
  const DEFAULT_SIMILARITY_THRESHOLD = 0.55;
@@ -118,7 +118,9 @@ export function analyzeAndApply(options = {}) {
118
118
  archivedCount = applied.archivedCount;
119
119
  mergedClusters = applied.mergedClusters;
120
120
  staleArchived = applied.staleArchived;
121
- saveState(state, cwd);
121
+ // deleteMissing: archived items must have their live files unlinked; the RMW
122
+ // is atomic (loadState above runs under this mutate() lock).
123
+ persistState(state, cwd, { writeProjectMarkdown: false, deleteMissing: true });
122
124
  });
123
125
  return { report, result: { archivedCount, mergedClusters, staleArchived } };
124
126
  }
@@ -138,7 +140,7 @@ export function applyCompaction(report, options = {}) {
138
140
  archivedCount = applied.archivedCount;
139
141
  mergedClusters = applied.mergedClusters;
140
142
  staleArchived = applied.staleArchived;
141
- saveState(state, cwd);
143
+ persistState(state, cwd, { writeProjectMarkdown: false, deleteMissing: true });
142
144
  });
143
145
  return { archivedCount, mergedClusters, staleArchived };
144
146
  }