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
@@ -1,3 +1,9 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import * as childProcess from 'node:child_process';
5
+ import { reconcileAllOpenRuns } from '../core/agentrun-reconciler.js';
6
+ import { loadAgentRun } from '../core/agentruns.js';
1
7
  import { listAgentIdentities, resolveCurrentAgentIdentity } from '../core/agent-registry.js';
2
8
  import { listCapabilities as listRegistryCapabilities, listTools as listRegistryTools } from '../core/registries.js';
3
9
  import { buildReputationSummary } from '../core/reputation.js';
@@ -9,39 +15,649 @@ import { getVisibleMemoryVersion, readContextMarker } from '../core/freshness.js
9
15
  import { generateMarkdown } from '../core/markdown.js';
10
16
  import { loadProjectIdentity, projectIdentityExists } from '../core/project-registry.js';
11
17
  import { findInstructionConflicts, loadInstructions } from '../core/instructions.js';
12
- import { memoryExists, memoryPath, readFileSync } from '../core/io.js';
18
+ import { memoryExists, memoryPath, readFileSync, resolveEntityDir, memoryDir, REQUIRED_ENTITY_SUBDIRS } from '../core/io.js';
13
19
  import { logger } from '../core/logger.js';
14
- import { listCandidates, listArchivedCandidates } from '../core/candidates.js';
15
- import { listClaims } from '../core/claims.js';
20
+ import { cleanupStaleCandidates, listCandidates, listArchivedCandidates } from '../core/candidates.js';
21
+ import { listClaims, isClaimExpired, assessClaimLiveness } from '../core/claims.js';
16
22
  import { listRuntimeNotes } from '../core/runtime.js';
17
23
  import { isTrapExpired, listOperationalTraps } from '../core/traps.js';
18
24
  import { scanText } from '../core/security.js';
19
- import { listRuntimeEvents } from '../core/events.js';
25
+ import { isTaskLifecycleRuntimeEvent, listRuntimeEvents } from '../core/events.js';
20
26
  import { resolveEventSessionId } from '../core/identity.js';
21
27
  import { detectContradictions } from '../core/contradictions.js';
22
- import { scanMigrationStatus } from '../core/migration.js';
28
+ import { loadVersionedJsonFile, scanMigrationStatus } from '../core/migration.js';
23
29
  import { buildAgentToolingContext } from '../core/agent-context.js';
24
30
  import { assessAgentIntegrationReadiness } from '../core/agent-integrations.js';
25
- import { assessBrainclawVersion } from '../core/brainclaw-version.js';
31
+ import { assessBrainclawVersion, detectConcurrentInstallations } from '../core/brainclaw-version.js';
26
32
  import { resolveStoreChain } from '../core/store-resolution.js';
33
+ import { listWorktrees, detectSharedCheckoutRisk } from '../core/worktree.js';
27
34
  import { resolveCrossProjectLinks, detectCrossProjectCycles } from '../core/cross-project.js';
28
- import { auditLocalAgentWorkspaceFiles, ensureGitignoreEntries } from '../core/agent-files.js';
35
+ import { auditLocalAgentWorkspaceFiles, ensureGitignoreEntries, patchAllMcpConfigs } from '../core/agent-files.js';
29
36
  import { summarizeWorkspaceProjects } from '../core/workspace-projects.js';
37
+ import { detectStaleness, staleSummary } from '../core/staleness.js';
38
+ import { InboxMessageSchema } from '../core/schema.js';
39
+ import { resolvePrimaryStore } from '../core/store-resolution.js';
40
+ import { runPostMigrationHealthCheck } from '../core/upgrades/health-check.js';
30
41
  const BACKLOG_KEYWORDS = /\b(TODO|NEXT|backlog|next[\s-]step|action[\s-]item|prochaine?s?\s+étapes?|à\s+faire)\b/i;
42
+ const NON_MESSAGE_INBOX_SUBDIRS = new Set(['accepted', 'rejected', 'cross-project']);
43
+ export const MCP_RUNTIME_REPAIR_COMMAND = 'brainclaw doctor --repair';
44
+ export const MCP_WORKER_RELATIVE_PATH = 'dist/commands/mcp-worker.js';
45
+ const DIST_CLI_RELATIVE_PATH = 'dist/cli.js';
46
+ const DIST_BUILD_MANIFEST_RELATIVE_PATH = 'dist/.brainclaw-build.json';
47
+ const ACTIONABLE_BACKLOG_LINE_PATTERNS = [
48
+ { name: 'unchecked_task', re: /^\s*(?:[-*•]\s*)?\[\s*\]\s+.+$/i },
49
+ { name: 'todo_line', re: /^\s*(?:[-*•]\s*)?TODO\b.*$/i },
50
+ { name: 'backlog_line', re: /^\s*(?:[-*•]\s*)?backlog:\s*.+$/i },
51
+ { name: 'next_steps_line', re: /^\s*(?:[-*•]\s*)?next steps:\s*.+$/i },
52
+ { name: 'should_do_line', re: /^\s*(?:[-*•]\s*)?should do\b.*$/i },
53
+ { name: 'needs_to_be_done_line', re: /^\s*(?:[-*•]\s*)?needs to be done\b.*$/i },
54
+ ];
55
+ const FORMAL_PLAN_REFERENCE_RE = /\bpln_[a-z0-9]+\b/i;
31
56
  function hasBacklogPatterns(text) {
32
57
  const lines = text.split(/\r?\n/);
33
58
  const bulletOrNumbered = lines.some((l) => /^\s*[-*•]\s+\w/.test(l) || /^\s*\d+\.\s+\w/.test(l));
34
59
  return bulletOrNumbered || BACKLOG_KEYWORDS.test(text) || /\[[ x]\]/.test(text);
35
60
  }
61
+ function truncateDoctorSnippet(text, maxLength = 140) {
62
+ const compact = text.replace(/\s+/g, ' ').trim();
63
+ if (compact.length <= maxLength) {
64
+ return compact;
65
+ }
66
+ return `${compact.slice(0, Math.max(0, maxLength - 3))}...`;
67
+ }
68
+ function hasFormalPlanLink(handoff) {
69
+ return Boolean(handoff.plan_id)
70
+ || FORMAL_PLAN_REFERENCE_RE.test(handoff.text)
71
+ || Boolean(handoff.contract?.linked_plans?.length);
72
+ }
73
+ export function extractBacklogWithoutPlanFindings(handoff) {
74
+ if (hasFormalPlanLink(handoff)) {
75
+ return [];
76
+ }
77
+ const findings = [];
78
+ for (const line of handoff.text.split(/\r?\n/)) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed) {
81
+ continue;
82
+ }
83
+ for (const pattern of ACTIONABLE_BACKLOG_LINE_PATTERNS) {
84
+ if (!pattern.re.test(line)) {
85
+ continue;
86
+ }
87
+ findings.push({
88
+ handoff_id: handoff.id,
89
+ matched_pattern: pattern.name,
90
+ snippet: truncateDoctorSnippet(trimmed),
91
+ suggestion: 'Create a formal plan item with `brainclaw plan create "<text>"` and link the handoff to the resulting pln_xxx.',
92
+ });
93
+ break;
94
+ }
95
+ }
96
+ return findings;
97
+ }
98
+ function listJsonFilesRecursive(dirPath) {
99
+ if (!fs.existsSync(dirPath)) {
100
+ return [];
101
+ }
102
+ const files = [];
103
+ for (const entry of fs.readdirSync(dirPath).sort()) {
104
+ const fullPath = path.join(dirPath, entry);
105
+ let stat;
106
+ try {
107
+ stat = fs.statSync(fullPath);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ if (stat.isDirectory()) {
113
+ files.push(...listJsonFilesRecursive(fullPath));
114
+ continue;
115
+ }
116
+ if (entry.endsWith('.json')) {
117
+ files.push(fullPath);
118
+ }
119
+ }
120
+ return files;
121
+ }
122
+ function normalizeInboxAgentName(agent) {
123
+ return agent.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
124
+ }
125
+ function toRelativeDoctorPath(filepath, cwd) {
126
+ return path.relative(cwd ?? process.cwd(), filepath).replace(/\\/g, '/');
127
+ }
128
+ function resolveDoctorPath(relativePath, cwd) {
129
+ return path.resolve(cwd ?? process.cwd(), ...relativePath.split('/'));
130
+ }
131
+ function listFilesForHash(rootPath, includeFile) {
132
+ if (!fs.existsSync(rootPath)) {
133
+ return [];
134
+ }
135
+ const files = [];
136
+ const walk = (currentPath) => {
137
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true })
138
+ .sort((a, b) => a.name.localeCompare(b.name));
139
+ for (const entry of entries) {
140
+ const fullPath = path.join(currentPath, entry.name);
141
+ if (entry.isDirectory()) {
142
+ walk(fullPath);
143
+ continue;
144
+ }
145
+ if (entry.isFile() && includeFile(fullPath)) {
146
+ files.push(fullPath);
147
+ }
148
+ }
149
+ };
150
+ walk(rootPath);
151
+ return files;
152
+ }
153
+ function hashFiles(rootPath, files) {
154
+ if (files.length === 0) {
155
+ return undefined;
156
+ }
157
+ const hash = crypto.createHash('sha256');
158
+ for (const file of files) {
159
+ hash.update(path.relative(rootPath, file).replace(/\\/g, '/'));
160
+ hash.update('\0');
161
+ hash.update(fs.readFileSync(file));
162
+ hash.update('\0');
163
+ }
164
+ return hash.digest('hex');
165
+ }
166
+ function computeSourceTreeHash(cwd) {
167
+ const effectiveCwd = cwd ?? process.cwd();
168
+ const rootFiles = [path.join(effectiveCwd, 'tsconfig.json'), path.join(effectiveCwd, 'package.json')]
169
+ .filter((filepath) => fs.existsSync(filepath));
170
+ const srcFiles = listFilesForHash(path.join(effectiveCwd, 'src'), (filepath) => filepath.endsWith('.ts'));
171
+ const scriptFiles = [path.join(effectiveCwd, 'scripts', 'copy-default-profiles.mjs')]
172
+ .filter((filepath) => fs.existsSync(filepath));
173
+ return hashFiles(effectiveCwd, [...rootFiles, ...srcFiles, ...scriptFiles]);
174
+ }
175
+ function getLatestMtimeMs(files) {
176
+ return files.reduce((latest, filepath) => {
177
+ try {
178
+ return Math.max(latest, fs.statSync(filepath).mtimeMs);
179
+ }
180
+ catch {
181
+ return latest;
182
+ }
183
+ }, 0);
184
+ }
185
+ function collectSourceTreeFiles(cwd) {
186
+ const effectiveCwd = cwd ?? process.cwd();
187
+ const rootFiles = [path.join(effectiveCwd, 'tsconfig.json'), path.join(effectiveCwd, 'package.json')]
188
+ .filter((filepath) => fs.existsSync(filepath));
189
+ const srcFiles = listFilesForHash(path.join(effectiveCwd, 'src'), (filepath) => filepath.endsWith('.ts'));
190
+ const scriptFiles = [path.join(effectiveCwd, 'scripts', 'copy-default-profiles.mjs')]
191
+ .filter((filepath) => fs.existsSync(filepath));
192
+ return [...rootFiles, ...srcFiles, ...scriptFiles];
193
+ }
194
+ function computeDistTreeHash(cwd) {
195
+ const distRoot = path.join(cwd ?? process.cwd(), 'dist');
196
+ const distFiles = listFilesForHash(distRoot, (filepath) => {
197
+ const rel = path.relative(distRoot, filepath).replace(/\\/g, '/');
198
+ return !rel.startsWith('.')
199
+ && (filepath.endsWith('.js') || filepath.endsWith('.d.ts') || filepath.endsWith('.yaml'));
200
+ });
201
+ return hashFiles(distRoot, distFiles);
202
+ }
203
+ function collectDistTreeFiles(cwd) {
204
+ const distRoot = path.join(cwd ?? process.cwd(), 'dist');
205
+ return listFilesForHash(distRoot, (filepath) => {
206
+ const rel = path.relative(distRoot, filepath).replace(/\\/g, '/');
207
+ return !rel.startsWith('.')
208
+ && (filepath.endsWith('.js') || filepath.endsWith('.d.ts') || filepath.endsWith('.yaml'));
209
+ });
210
+ }
211
+ function readDistBuildManifest(cwd) {
212
+ const manifestPath = resolveDoctorPath(DIST_BUILD_MANIFEST_RELATIVE_PATH, cwd);
213
+ if (!fs.existsSync(manifestPath)) {
214
+ return undefined;
215
+ }
216
+ try {
217
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
218
+ if (parsed && parsed.schema_version === 1 && typeof parsed.src_hash === 'string' && typeof parsed.dist_hash === 'string') {
219
+ return parsed;
220
+ }
221
+ }
222
+ catch {
223
+ // ignored — invalid manifest means stale runtime and will be rebuilt
224
+ }
225
+ return undefined;
226
+ }
227
+ function writeDistBuildManifest(cwd, srcHash, distHash) {
228
+ const manifestPath = resolveDoctorPath(DIST_BUILD_MANIFEST_RELATIVE_PATH, cwd);
229
+ fs.writeFileSync(manifestPath, JSON.stringify({
230
+ schema_version: 1,
231
+ generated_at: new Date().toISOString(),
232
+ src_hash: srcHash,
233
+ dist_hash: distHash,
234
+ }, null, 2));
235
+ }
236
+ export function resolveMcpWorkerMissingPath(cwd) {
237
+ return resolveDoctorPath(MCP_WORKER_RELATIVE_PATH, cwd);
238
+ }
239
+ export function isBrainclawRepoCwd(cwd) {
240
+ // dist/ runtime checks resolve paths relative to cwd; that only makes sense
241
+ // when cwd is the brainclaw source/install root. For every other cwd (a
242
+ // user's project, a test workspace), dist/ does not and should not exist.
243
+ const effectiveCwd = cwd ?? process.cwd();
244
+ if (fs.existsSync(path.join(effectiveCwd, 'src', 'commands', 'mcp.ts'))) {
245
+ return true;
246
+ }
247
+ if (fs.existsSync(path.join(effectiveCwd, 'dist', 'commands', 'mcp.js'))) {
248
+ return true;
249
+ }
250
+ return false;
251
+ }
252
+ export function getMcpRuntimeHealth(cwd) {
253
+ const effectiveCwd = cwd ?? process.cwd();
254
+ const manifestPath = resolveDoctorPath(DIST_BUILD_MANIFEST_RELATIVE_PATH, effectiveCwd);
255
+ const cliPath = resolveDoctorPath(DIST_CLI_RELATIVE_PATH, effectiveCwd);
256
+ const workerPath = resolveMcpWorkerMissingPath(effectiveCwd);
257
+ const missingFiles = [cliPath, workerPath].filter((filepath) => !fs.existsSync(filepath));
258
+ const srcHash = computeSourceTreeHash(effectiveCwd);
259
+ const distHash = computeDistTreeHash(effectiveCwd);
260
+ const manifest = readDistBuildManifest(effectiveCwd);
261
+ const latestSourceMtime = getLatestMtimeMs(collectSourceTreeFiles(effectiveCwd));
262
+ const latestDistMtime = getLatestMtimeMs(collectDistTreeFiles(effectiveCwd));
263
+ if (missingFiles.length > 0 || !distHash) {
264
+ return {
265
+ ok: false,
266
+ status: 'missing',
267
+ message: `dist/ runtime is missing required artifacts. Run "${MCP_RUNTIME_REPAIR_COMMAND}" to rebuild dist/.`,
268
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
269
+ missing_path: missingFiles[0],
270
+ missing_files: missingFiles.map((filepath) => toRelativeDoctorPath(filepath, effectiveCwd)),
271
+ src_hash: srcHash,
272
+ dist_hash: distHash,
273
+ manifest_src_hash: manifest?.src_hash,
274
+ manifest_dist_hash: manifest?.dist_hash,
275
+ manifest_path: toRelativeDoctorPath(manifestPath, effectiveCwd),
276
+ };
277
+ }
278
+ if (!manifest) {
279
+ if (latestSourceMtime > latestDistMtime) {
280
+ return {
281
+ ok: false,
282
+ status: 'stale',
283
+ message: `dist/ appears older than src/. Run "${MCP_RUNTIME_REPAIR_COMMAND}" to rebuild dist/.`,
284
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
285
+ missing_files: [],
286
+ src_hash: srcHash,
287
+ dist_hash: distHash,
288
+ manifest_path: toRelativeDoctorPath(manifestPath, effectiveCwd),
289
+ };
290
+ }
291
+ return {
292
+ ok: true,
293
+ status: 'ok',
294
+ message: 'dist/ runtime is healthy (legacy build without hash manifest)',
295
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
296
+ missing_files: [],
297
+ src_hash: srcHash,
298
+ dist_hash: distHash,
299
+ manifest_path: toRelativeDoctorPath(manifestPath, effectiveCwd),
300
+ };
301
+ }
302
+ if (!srcHash || manifest.src_hash !== srcHash || manifest.dist_hash !== distHash) {
303
+ return {
304
+ ok: false,
305
+ status: 'stale',
306
+ message: `dist/ is stale relative to src/. Run "${MCP_RUNTIME_REPAIR_COMMAND}" to rebuild dist/.`,
307
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
308
+ missing_files: [],
309
+ src_hash: srcHash,
310
+ dist_hash: distHash,
311
+ manifest_src_hash: manifest.src_hash,
312
+ manifest_dist_hash: manifest.dist_hash,
313
+ manifest_path: toRelativeDoctorPath(manifestPath, effectiveCwd),
314
+ };
315
+ }
316
+ return {
317
+ ok: true,
318
+ status: 'ok',
319
+ message: 'dist/ runtime is healthy',
320
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
321
+ missing_files: [],
322
+ src_hash: srcHash,
323
+ dist_hash: distHash,
324
+ manifest_src_hash: manifest.src_hash,
325
+ manifest_dist_hash: manifest.dist_hash,
326
+ manifest_path: toRelativeDoctorPath(manifestPath, effectiveCwd),
327
+ };
328
+ }
329
+ function spawnRepairCommand(command, args, cwd, options) {
330
+ const result = childProcess.spawnSync(command, args, {
331
+ cwd,
332
+ encoding: 'utf-8',
333
+ stdio: options.json ? 'pipe' : 'inherit',
334
+ });
335
+ return {
336
+ ok: result.status === 0,
337
+ stdout: typeof result.stdout === 'string' ? result.stdout : '',
338
+ stderr: typeof result.stderr === 'string' ? result.stderr : '',
339
+ status: result.status,
340
+ errorCode: result.error?.code,
341
+ };
342
+ }
343
+ function readLocalPackageVersion(cwd) {
344
+ const packageJsonPath = path.join(cwd, 'package.json');
345
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
346
+ return parsed.version ?? 'unknown';
347
+ }
348
+ function repairDistRuntime(options = {}) {
349
+ const cwd = options.cwd ?? process.cwd();
350
+ const before = getMcpRuntimeHealth(cwd);
351
+ if (before.ok) {
352
+ const manifestPath = resolveDoctorPath(DIST_BUILD_MANIFEST_RELATIVE_PATH, cwd);
353
+ if (!fs.existsSync(manifestPath)) {
354
+ const srcHash = computeSourceTreeHash(cwd);
355
+ const distHash = computeDistTreeHash(cwd);
356
+ if (srcHash && distHash) {
357
+ writeDistBuildManifest(cwd, srcHash, distHash);
358
+ }
359
+ }
360
+ const versionResult = spawnRepairCommand(process.execPath, [DIST_CLI_RELATIVE_PATH, '--version'], cwd, { json: true });
361
+ if (!versionResult.ok && versionResult.errorCode !== 'EPERM') {
362
+ throw new Error(versionResult.stderr.trim() || versionResult.stdout.trim() || 'dist/cli.js --version failed');
363
+ }
364
+ const cliVersion = versionResult.ok ? versionResult.stdout.trim() : readLocalPackageVersion(cwd);
365
+ return {
366
+ ok: true,
367
+ repaired: false,
368
+ reason: 'ok',
369
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
370
+ missing_path: before.missing_path,
371
+ missing_files: before.missing_files,
372
+ manifest_path: before.manifest_path,
373
+ cli_version: cliVersion,
374
+ };
375
+ }
376
+ const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx';
377
+ const tscResult = spawnRepairCommand(npxCommand, ['tsc'], cwd, options);
378
+ if (!tscResult.ok) {
379
+ throw new Error(tscResult.stderr.trim() || tscResult.stdout.trim() || 'npx tsc failed');
380
+ }
381
+ const copyProfilesResult = spawnRepairCommand(process.execPath, ['scripts/copy-default-profiles.mjs'], cwd, options);
382
+ if (!copyProfilesResult.ok) {
383
+ throw new Error(copyProfilesResult.stderr.trim() || copyProfilesResult.stdout.trim() || 'copy-default-profiles.mjs failed');
384
+ }
385
+ const versionResult = spawnRepairCommand(process.execPath, [DIST_CLI_RELATIVE_PATH, '--version'], cwd, { json: true });
386
+ if (!versionResult.ok && versionResult.errorCode !== 'EPERM') {
387
+ throw new Error(versionResult.stderr.trim() || versionResult.stdout.trim() || 'dist/cli.js --version failed');
388
+ }
389
+ const cliVersion = versionResult.ok ? versionResult.stdout.trim() : readLocalPackageVersion(cwd);
390
+ const srcHash = computeSourceTreeHash(cwd);
391
+ const distHash = computeDistTreeHash(cwd);
392
+ if (!srcHash || !distHash) {
393
+ throw new Error('Rebuild completed but runtime hash could not be computed');
394
+ }
395
+ writeDistBuildManifest(cwd, srcHash, distHash);
396
+ const after = getMcpRuntimeHealth(cwd);
397
+ if (!after.ok) {
398
+ throw new Error(after.message);
399
+ }
400
+ return {
401
+ ok: true,
402
+ repaired: true,
403
+ reason: before.status,
404
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
405
+ missing_path: before.missing_path,
406
+ missing_files: before.missing_files,
407
+ manifest_path: after.manifest_path,
408
+ cli_version: cliVersion,
409
+ };
410
+ }
411
+ /**
412
+ * Return the absolute paths of entity subdirectories that should exist under
413
+ * `.brainclaw/` but don't. Source of truth is REQUIRED_ENTITY_SUBDIRS in
414
+ * core/io.ts (pln#397 stp_b5337e30).
415
+ */
416
+ function scanMissingEntitySubdirs(cwd) {
417
+ const root = memoryDir(cwd);
418
+ if (!fs.existsSync(root))
419
+ return [];
420
+ const missing = [];
421
+ for (const subdir of REQUIRED_ENTITY_SUBDIRS) {
422
+ const full = path.join(root, subdir);
423
+ if (!fs.existsSync(full))
424
+ missing.push(full);
425
+ }
426
+ return missing;
427
+ }
428
+ function auditInboxMessages(cwd) {
429
+ const effectiveCwd = cwd ?? process.cwd();
430
+ const inboxRoot = resolveEntityDir('inbox', effectiveCwd, 'read');
431
+ const result = {
432
+ checked: 0,
433
+ invalid: [],
434
+ orphaned: [],
435
+ };
436
+ if (!fs.existsSync(inboxRoot)) {
437
+ return result;
438
+ }
439
+ for (const entry of fs.readdirSync(inboxRoot).sort()) {
440
+ const fullPath = path.join(inboxRoot, entry);
441
+ let stat;
442
+ try {
443
+ stat = fs.statSync(fullPath);
444
+ }
445
+ catch {
446
+ continue;
447
+ }
448
+ if (stat.isFile() && entry.endsWith('.json')) {
449
+ try {
450
+ loadVersionedJsonFile('message', fullPath);
451
+ result.orphaned.push({
452
+ path: toRelativeDoctorPath(fullPath, effectiveCwd),
453
+ reason: 'message file is stored at inbox root instead of an agent subdirectory',
454
+ });
455
+ }
456
+ catch {
457
+ // Pending candidate files also live at inbox root; ignore non-message documents here.
458
+ }
459
+ continue;
460
+ }
461
+ if (!stat.isDirectory() || NON_MESSAGE_INBOX_SUBDIRS.has(entry)) {
462
+ continue;
463
+ }
464
+ for (const filepath of listJsonFilesRecursive(fullPath)) {
465
+ try {
466
+ const parsed = loadVersionedJsonFile('message', filepath);
467
+ const message = InboxMessageSchema.parse(parsed.document);
468
+ result.checked += 1;
469
+ const expectedDir = normalizeInboxAgentName(message.to);
470
+ if (expectedDir !== entry) {
471
+ result.orphaned.push({
472
+ path: toRelativeDoctorPath(filepath, effectiveCwd),
473
+ reason: `message targets '${message.to}' but is stored under '${entry}'`,
474
+ });
475
+ }
476
+ }
477
+ catch (error) {
478
+ result.invalid.push({
479
+ path: toRelativeDoctorPath(filepath, effectiveCwd),
480
+ error: error instanceof Error ? error.message : String(error),
481
+ });
482
+ }
483
+ }
484
+ }
485
+ return result;
486
+ }
487
+ export function runDispatchHealthCheck(options = {}) {
488
+ const results = reconcileAllOpenRuns(options.cwd);
489
+ const inferred_completed = [];
490
+ const health_check_unverified = [];
491
+ const inferred_failed = [];
492
+ let no_op_open = 0;
493
+ for (const result of results) {
494
+ const run = loadAgentRun(result.run_id, options.cwd);
495
+ if (!run) {
496
+ // Run was deleted between list and load — skip.
497
+ continue;
498
+ }
499
+ const summary = {
500
+ run_id: result.run_id,
501
+ agent: run.agent,
502
+ assignment_id: run.assignment_id,
503
+ claim_id: run.claim_id,
504
+ scope: run.scope,
505
+ age_ms: result.evidence.age_ms,
506
+ reason: result.reason,
507
+ previous_status: result.previous_status,
508
+ current_status: result.current_status,
509
+ };
510
+ switch (result.action) {
511
+ case 'inferred_completed':
512
+ inferred_completed.push(summary);
513
+ break;
514
+ case 'health_check_unverified':
515
+ health_check_unverified.push(summary);
516
+ break;
517
+ case 'inferred_failed':
518
+ inferred_failed.push(summary);
519
+ break;
520
+ case 'no_op':
521
+ no_op_open += 1;
522
+ break;
523
+ }
524
+ }
525
+ return {
526
+ generated_at: new Date().toISOString(),
527
+ total: results.length,
528
+ inferred_completed,
529
+ health_check_unverified,
530
+ inferred_failed,
531
+ no_op_open,
532
+ exit_code: inferred_failed.length > 0 ? 1 : 0,
533
+ };
534
+ }
535
+ function renderDispatchHealthHumanReport(report) {
536
+ const lines = [];
537
+ lines.push(`Dispatch health — ${report.total} open agent_run(s) examined at ${report.generated_at}`);
538
+ lines.push('');
539
+ if (report.inferred_failed.length > 0) {
540
+ lines.push(`✗ ${report.inferred_failed.length} silent failure(s) — process dead, no completion evidence:`);
541
+ for (const r of report.inferred_failed) {
542
+ lines.push(` - ${r.run_id} ${r.agent} (${r.scope}) — ${r.reason}`);
543
+ }
544
+ lines.push('');
545
+ }
546
+ if (report.inferred_completed.length > 0) {
547
+ lines.push(`⟳ ${report.inferred_completed.length} silent completion(s) recovered (run transitioned ${report.inferred_completed[0]?.previous_status ?? '?'} → completed):`);
548
+ for (const r of report.inferred_completed) {
549
+ lines.push(` - ${r.run_id} ${r.agent} (${r.scope}) — ${r.reason}`);
550
+ }
551
+ lines.push('');
552
+ }
553
+ if (report.health_check_unverified.length > 0) {
554
+ lines.push(`⏳ ${report.health_check_unverified.length} unverified spawn(s) past grace window (no life-sign yet):`);
555
+ for (const r of report.health_check_unverified) {
556
+ lines.push(` - ${r.run_id} ${r.agent} (${r.scope}) — age=${Math.round(r.age_ms / 1000)}s — ${r.reason}`);
557
+ }
558
+ lines.push('');
559
+ }
560
+ if (report.inferred_failed.length === 0 && report.inferred_completed.length === 0 && report.health_check_unverified.length === 0) {
561
+ lines.push(`✔ No dispatch issues detected (${report.no_op_open} open run(s) within grace window or already healthy).`);
562
+ }
563
+ else {
564
+ lines.push(`(${report.no_op_open} other open run(s) within grace window or already healthy.)`);
565
+ }
566
+ return lines.join('\n');
567
+ }
36
568
  export function runDoctor(options = {}) {
569
+ if (options.dispatch) {
570
+ const report = runDispatchHealthCheck(options);
571
+ if (options.json) {
572
+ console.log(JSON.stringify(report, null, 2));
573
+ }
574
+ else {
575
+ console.log(renderDispatchHealthHumanReport(report));
576
+ }
577
+ if (report.exit_code !== 0)
578
+ process.exit(report.exit_code);
579
+ return;
580
+ }
581
+ if (options.repair) {
582
+ try {
583
+ const result = repairDistRuntime(options);
584
+ if (options.json) {
585
+ console.log(JSON.stringify(result, null, 2));
586
+ }
587
+ else if (result.repaired) {
588
+ console.log(`✔ Rebuilt dist/ (${result.reason})`);
589
+ console.log(`✔ Verified runtime: ${result.cli_version ?? 'unknown version'}`);
590
+ console.log(`✔ Updated hash manifest: ${result.manifest_path}`);
591
+ }
592
+ else {
593
+ console.log(`✔ dist/ runtime already healthy (${result.cli_version ?? 'unknown version'})`);
594
+ }
595
+ return;
596
+ }
597
+ catch (error) {
598
+ const message = error instanceof Error ? error.message : String(error);
599
+ if (options.json) {
600
+ console.log(JSON.stringify({
601
+ ok: false,
602
+ repaired: false,
603
+ repair_command: MCP_RUNTIME_REPAIR_COMMAND,
604
+ error: message,
605
+ }, null, 2));
606
+ }
607
+ else {
608
+ console.error(`✗ Repair failed: ${message}`);
609
+ }
610
+ process.exit(1);
611
+ }
612
+ }
37
613
  if (!memoryExists(options.cwd)) {
38
614
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
39
615
  process.exit(1);
40
616
  }
617
+ if (options.afterMigration) {
618
+ runAfterMigrationCheck(options);
619
+ return;
620
+ }
41
621
  let hasIssues = false;
42
622
  const checks = [];
623
+ const repairCandidates = [];
43
624
  let migrationEntries = [];
44
625
  let agentGitHygieneFixed = [];
626
+ // pln#397 stp_b5337e30: scan entity-aligned subdirectories and emit safe
627
+ // mkdir repair candidates for any that are missing. Runs before other
628
+ // checks so downstream validators don't emit cascading "not found" noise.
629
+ const missingDirs = scanMissingEntitySubdirs(options.cwd);
630
+ if (missingDirs.length > 0) {
631
+ const rel = missingDirs.map((p) => toRelativeDoctorPath(p, options.cwd));
632
+ checks.push({
633
+ name: 'entity_subdirs',
634
+ status: 'warn',
635
+ message: `${missingDirs.length} required subdirectorie(s) missing from .brainclaw/`,
636
+ details: { missing: rel },
637
+ });
638
+ for (const dir of missingDirs) {
639
+ repairCandidates.push({
640
+ action: 'mkdir',
641
+ target: toRelativeDoctorPath(dir, options.cwd),
642
+ description: `Create missing entity subdirectory ${toRelativeDoctorPath(dir, options.cwd)}`,
643
+ safe: true,
644
+ related_check: 'entity_subdirs',
645
+ });
646
+ }
647
+ hasIssues = true;
648
+ if (!options.json) {
649
+ console.warn(`⚠ ${missingDirs.length} required subdirectorie(s) missing under .brainclaw/:`);
650
+ for (const p of rel) {
651
+ console.warn(` - ${p}`);
652
+ }
653
+ }
654
+ }
655
+ else {
656
+ checks.push({ name: 'entity_subdirs', status: 'ok', message: 'all entity subdirectories present' });
657
+ if (!options.json) {
658
+ console.log('✔ all entity subdirectories present');
659
+ }
660
+ }
45
661
  // Validate config
46
662
  let config;
47
663
  try {
@@ -71,6 +687,37 @@ export function runDoctor(options = {}) {
71
687
  }
72
688
  }
73
689
  }
690
+ if (isBrainclawRepoCwd(options.cwd)) {
691
+ const mcpRuntimeHealth = getMcpRuntimeHealth(options.cwd);
692
+ if (mcpRuntimeHealth.ok) {
693
+ checks.push({
694
+ name: 'mcp_runtime',
695
+ status: 'ok',
696
+ message: mcpRuntimeHealth.message,
697
+ details: mcpRuntimeHealth,
698
+ });
699
+ if (!options.json) {
700
+ console.log('✔ MCP runtime: dist/ is healthy');
701
+ }
702
+ }
703
+ else {
704
+ checks.push({
705
+ name: 'mcp_runtime',
706
+ status: mcpRuntimeHealth.status === 'missing' ? 'error' : 'warn',
707
+ message: mcpRuntimeHealth.message,
708
+ details: mcpRuntimeHealth,
709
+ });
710
+ if (!options.json) {
711
+ const glyph = mcpRuntimeHealth.status === 'missing' ? '✗' : '⚠';
712
+ console.warn(`${glyph} MCP runtime: ${mcpRuntimeHealth.message}`);
713
+ if (mcpRuntimeHealth.missing_path) {
714
+ console.warn(` Missing path: ${toRelativeDoctorPath(mcpRuntimeHealth.missing_path, options.cwd)}`);
715
+ }
716
+ console.warn(` Repair: ${mcpRuntimeHealth.repair_command}`);
717
+ }
718
+ hasIssues = true;
719
+ }
720
+ }
74
721
  // Validate state
75
722
  let state;
76
723
  try {
@@ -232,48 +879,29 @@ export function runDoctor(options = {}) {
232
879
  }
233
880
  try {
234
881
  const registeredAgents = listAgentIdentities(options.cwd);
235
- if (config.current_agent || config.current_agent_id) {
236
- const currentAgent = resolveCurrentAgentIdentity(options.cwd);
237
- if (!currentAgent) {
238
- checks.push({
239
- name: 'agent_identity',
240
- status: 'warn',
241
- message: `Current agent is configured (${config.current_agent ?? 'unknown'} / ${config.current_agent_id ?? 'unknown'}) but no matching registry entry was found.`,
242
- });
243
- if (!options.json) {
244
- console.warn(`⚠ Current agent is configured (${config.current_agent ?? 'unknown'} / ${config.current_agent_id ?? 'unknown'}) but no matching registry entry was found.`);
245
- }
246
- hasIssues = true;
247
- }
248
- else if ((config.current_agent && config.current_agent !== currentAgent.agent_name)
249
- || (config.current_agent_id && config.current_agent_id !== currentAgent.agent_id)) {
250
- checks.push({
251
- name: 'agent_identity',
252
- status: 'warn',
253
- message: `Current agent config does not match registry entry (${currentAgent.agent_name} / ${currentAgent.agent_id}).`,
254
- });
255
- if (!options.json) {
256
- console.warn(`⚠ Current agent config does not match registry entry (${currentAgent.agent_name} / ${currentAgent.agent_id}).`);
257
- }
258
- hasIssues = true;
259
- }
260
- else {
261
- checks.push({
262
- name: 'agent_identity',
263
- status: 'ok',
264
- message: `current_agent=${currentAgent.agent_name}, agent_id=${currentAgent.agent_id}, registered_agents=${registeredAgents.length}`,
265
- });
266
- if (!options.json) {
267
- console.log(`✔ current agent: ${currentAgent.agent_name} (${currentAgent.agent_id})`);
268
- }
882
+ // Agent identity check: verify the detected agent is registered (env vars + detectAiAgent).
883
+ // config.current_agent is NOT checked — it's a legacy singleton, not an identity source.
884
+ const detectedAgent = resolveCurrentAgentIdentity(options.cwd);
885
+ if (detectedAgent) {
886
+ checks.push({
887
+ name: 'agent_identity',
888
+ status: 'ok',
889
+ message: `detected_agent=${detectedAgent.agent_name}, agent_id=${detectedAgent.agent_id}, registered_agents=${registeredAgents.length}`,
890
+ });
891
+ if (!options.json) {
892
+ console.log(`✔ detected agent: ${detectedAgent.agent_name} (${detectedAgent.agent_id})`);
269
893
  }
270
894
  }
271
895
  else {
272
896
  checks.push({
273
897
  name: 'agent_identity',
274
- status: 'ok',
275
- message: `No current agent configured (${registeredAgents.length} registered agent(s))`,
898
+ status: 'warn',
899
+ message: `No agent detected from environment (${registeredAgents.length} registered agent(s)). Set BRAINCLAW_AGENT or run from an agent terminal.`,
276
900
  });
901
+ if (!options.json) {
902
+ console.warn(`⚠ No agent detected from environment (${registeredAgents.length} registered). Set BRAINCLAW_AGENT or run from an agent terminal.`);
903
+ }
904
+ hasIssues = true;
277
905
  }
278
906
  }
279
907
  catch (e) {
@@ -361,15 +989,41 @@ export function runDoctor(options = {}) {
361
989
  }
362
990
  const integrationReadiness = assessAgentIntegrationReadiness(config, options.cwd ?? process.cwd());
363
991
  const missingIntegrations = integrationReadiness.filter((entry) => !entry.ready);
992
+ if (options.fix && missingIntegrations.some(m => m.missing_surfaces.some(s => s.kind === 'mcp') || m.drifting_surfaces.length > 0)) {
993
+ const results = patchAllMcpConfigs(options.cwd ?? process.cwd());
994
+ // Re-evaluate readiness
995
+ const refreshedReadiness = assessAgentIntegrationReadiness(config, options.cwd ?? process.cwd());
996
+ const fixedAgents = missingIntegrations.filter(initial => {
997
+ const current = refreshedReadiness.find(r => r.agent_name === initial.agent_name);
998
+ return current?.ready;
999
+ }).map(r => r.agent_name);
1000
+ if (!options.json) {
1001
+ console.log(`\n✔ Applied --fix: Patched ${results.length} MCP config(s) automatically.`);
1002
+ if (fixedAgents.length > 0) {
1003
+ console.log(`✔ Successfully restored: ${fixedAgents.join(', ')}`);
1004
+ }
1005
+ }
1006
+ missingIntegrations.length = 0;
1007
+ missingIntegrations.push(...refreshedReadiness.filter((entry) => !entry.ready));
1008
+ }
364
1009
  if (missingIntegrations.length > 0) {
365
1010
  checks.push({
366
1011
  name: 'agent_integrations',
367
1012
  status: 'warn',
368
- message: `${missingIntegrations.length} declared agent integration(s) are not fully activated on this machine/workspace.`,
1013
+ message: `${missingIntegrations.length} declared agent integration(s) are not fully activated or are drifting.`,
369
1014
  details: missingIntegrations,
370
1015
  });
371
1016
  if (!options.json) {
372
- console.warn(`⚠ ${missingIntegrations.length} declared agent integration(s) are not fully activated on this machine/workspace.`);
1017
+ console.warn(`⚠ ${missingIntegrations.length} declared agent integration(s) are not fully activated or are drifting.`);
1018
+ for (const m of missingIntegrations) {
1019
+ console.warn(` - ${m.agent_name} -> Effective Tier: ${m.effective_tier}`);
1020
+ for (const drift of m.drifting_surfaces) {
1021
+ console.warn(` ↳ Drift: ${drift.drift_message}`);
1022
+ }
1023
+ for (const g of m.self_healing_guidance) {
1024
+ console.warn(` ↳ ${g}`);
1025
+ }
1026
+ }
373
1027
  }
374
1028
  hasIssues = true;
375
1029
  }
@@ -379,6 +1033,11 @@ export function runDoctor(options = {}) {
379
1033
  status: 'ok',
380
1034
  message: `${integrationReadiness.length} declared agent integration(s) are fully activated`,
381
1035
  });
1036
+ if (!options.json) {
1037
+ for (const r of integrationReadiness) {
1038
+ console.info(`✓ ${r.agent_name} is active -> Effective Tier: ${r.effective_tier}`);
1039
+ }
1040
+ }
382
1041
  }
383
1042
  const agentGitHygiene = auditLocalAgentWorkspaceFiles(options.cwd ?? process.cwd());
384
1043
  if (!agentGitHygiene.isGitRepo) {
@@ -483,6 +1142,40 @@ export function runDoctor(options = {}) {
483
1142
  console.log(`✔ ${brainclawVersion.message}`);
484
1143
  }
485
1144
  }
1145
+ // Check for concurrent brainclaw installations in PATH
1146
+ try {
1147
+ const installations = detectConcurrentInstallations();
1148
+ const uniqueVersions = new Set(installations.map(i => i.version));
1149
+ if (installations.length > 1 && uniqueVersions.size > 1) {
1150
+ const details = installations.map(i => `${i.path} (${i.version}${i.isCurrent ? ', active' : ''})`).join(', ');
1151
+ checks.push({
1152
+ name: 'brainclaw_path_conflicts',
1153
+ status: 'warn',
1154
+ message: `Multiple brainclaw versions in PATH: ${details}. The first in PATH will be used by CLI; MCP uses absolute path.`,
1155
+ details: { installations },
1156
+ });
1157
+ if (!options.json) {
1158
+ console.warn(`⚠ Multiple brainclaw versions in PATH:`);
1159
+ for (const inst of installations) {
1160
+ console.warn(` ${inst.isCurrent ? '→' : ' '} ${inst.path} (${inst.version})`);
1161
+ }
1162
+ }
1163
+ hasIssues = true;
1164
+ }
1165
+ else {
1166
+ checks.push({
1167
+ name: 'brainclaw_path_conflicts',
1168
+ status: 'ok',
1169
+ message: installations.length > 0
1170
+ ? `Single brainclaw in PATH: ${installations[0].path} (${installations[0].version})`
1171
+ : 'No brainclaw found in PATH (using direct invocation)',
1172
+ });
1173
+ }
1174
+ }
1175
+ catch {
1176
+ // Non-fatal — PATH scan failure should not block doctor
1177
+ checks.push({ name: 'brainclaw_path_conflicts', status: 'ok', message: 'PATH scan skipped' });
1178
+ }
486
1179
  // Check project.md consistency
487
1180
  try {
488
1181
  const currentMd = readFileSync(memoryPath('project.md', options.cwd));
@@ -602,7 +1295,7 @@ export function runDoctor(options = {}) {
602
1295
  });
603
1296
  }
604
1297
  // --- Reflective memory checks ---
605
- const pending = listCandidates('pending', options.cwd);
1298
+ let pending = listCandidates('pending', options.cwd);
606
1299
  const accepted = listArchivedCandidates('accepted', options.cwd);
607
1300
  const rejected = listArchivedCandidates('rejected', options.cwd);
608
1301
  if (!options.json) {
@@ -624,8 +1317,8 @@ export function runDoctor(options = {}) {
624
1317
  const promotionStarsThreshold = config.reflective_memory?.promotion_stars_threshold ?? 3;
625
1318
  const promotionUsesThreshold = config.reflective_memory?.promotion_uses_threshold ?? 2;
626
1319
  const reviewSlaHours = config.governance?.review_sla_hours ?? 24;
627
- const promotionReady = pending.filter((c) => (c.star_count ?? 0) >= promotionStarsThreshold || (c.usage_count ?? 0) >= promotionUsesThreshold);
628
- const pendingOverdue = pending.filter((c) => {
1320
+ let promotionReady = pending.filter((c) => (c.star_count ?? 0) >= promotionStarsThreshold || (c.usage_count ?? 0) >= promotionUsesThreshold);
1321
+ let pendingOverdue = pending.filter((c) => {
629
1322
  const ageHours = Math.floor((Date.now() - Date.parse(c.created_at)) / (1000 * 60 * 60));
630
1323
  return ageHours > reviewSlaHours;
631
1324
  });
@@ -643,6 +1336,57 @@ export function runDoctor(options = {}) {
643
1336
  console.log(`Governance review KPI: pending_overdue=${pendingOverdue.length}, avg_review_hours=${avgReviewHours.toFixed(1)}, review_sla_hours=${reviewSlaHours}`);
644
1337
  console.log(`Promotion signal: ${promotionReady.length} candidate(s) reached ${promotionStarsThreshold} star(s) or ${promotionUsesThreshold} use(s)`);
645
1338
  }
1339
+ const staleAutoCandidates = cleanupStaleCandidates({
1340
+ cwd: options.cwd,
1341
+ source: 'auto',
1342
+ maxAgeDays: 30,
1343
+ dryRun: !options.fix,
1344
+ });
1345
+ if (staleAutoCandidates.matched > 0) {
1346
+ const actionMessage = options.fix
1347
+ ? `Removed ${staleAutoCandidates.deleted} stale auto-generated candidate(s) older than 30 days.`
1348
+ : `${staleAutoCandidates.matched} stale auto-generated candidate(s) older than 30 days. Run \`brainclaw cleanup-candidates --max-age 30\` or \`brainclaw doctor --fix\`.`;
1349
+ checks.push({
1350
+ name: 'stale_auto_candidates',
1351
+ status: options.fix ? 'ok' : 'warn',
1352
+ message: actionMessage,
1353
+ details: staleAutoCandidates.candidates.map((candidate) => ({
1354
+ id: candidate.id,
1355
+ type: candidate.type,
1356
+ created_at: candidate.created_at,
1357
+ text: truncateDoctorSnippet(candidate.text),
1358
+ })),
1359
+ });
1360
+ if (!options.json) {
1361
+ if (options.fix) {
1362
+ console.log(`✔ ${actionMessage}`);
1363
+ }
1364
+ else {
1365
+ console.warn(`⚠ ${actionMessage}`);
1366
+ }
1367
+ }
1368
+ if (!options.fix) {
1369
+ hasIssues = true;
1370
+ }
1371
+ }
1372
+ else {
1373
+ checks.push({
1374
+ name: 'stale_auto_candidates',
1375
+ status: 'ok',
1376
+ message: 'No stale auto-generated candidates found',
1377
+ });
1378
+ if (!options.json) {
1379
+ console.log('✔ No stale auto-generated candidates found');
1380
+ }
1381
+ }
1382
+ if (options.fix && staleAutoCandidates.deleted > 0) {
1383
+ pending = listCandidates('pending', options.cwd);
1384
+ promotionReady = pending.filter((c) => (c.star_count ?? 0) >= promotionStarsThreshold || (c.usage_count ?? 0) >= promotionUsesThreshold);
1385
+ pendingOverdue = pending.filter((c) => {
1386
+ const ageHours = Math.floor((Date.now() - Date.parse(c.created_at)) / (1000 * 60 * 60));
1387
+ return ageHours > reviewSlaHours;
1388
+ });
1389
+ }
646
1390
  if (promotionReady.length > 0) {
647
1391
  checks.push({
648
1392
  name: 'promotion_signals',
@@ -768,6 +1512,105 @@ export function runDoctor(options = {}) {
768
1512
  else {
769
1513
  checks.push({ name: 'expired_items', status: 'ok', message: 'No expired items found' });
770
1514
  }
1515
+ // --- Stale memory check: age-based heuristics for plans, handoffs, candidates, runtime_notes ---
1516
+ try {
1517
+ const pendingCandidatesForStaleness = listCandidates('pending', options.cwd);
1518
+ const runtimeNotesForStaleness = listRuntimeNotes(undefined, options.cwd);
1519
+ const staleReport = detectStaleness(state.plan_items, state.known_traps, state.open_handoffs, pendingCandidatesForStaleness, Date.now(), runtimeNotesForStaleness);
1520
+ if (staleReport.warnings.length > 0) {
1521
+ const summary = staleSummary(staleReport);
1522
+ checks.push({
1523
+ name: 'stale_memory',
1524
+ status: 'warn',
1525
+ message: `${staleReport.warnings.length} stale item(s) detected: ${summary}`,
1526
+ details: staleReport.warnings.map((w) => ({
1527
+ id: w.id,
1528
+ entity: w.entity,
1529
+ age_days: w.age_days,
1530
+ reason: w.reason,
1531
+ suggested_action: w.suggested_action,
1532
+ })),
1533
+ });
1534
+ if (!options.json) {
1535
+ console.warn(`⚠ Stale memory: ${summary}`);
1536
+ for (const w of staleReport.warnings.slice(0, 5)) {
1537
+ console.warn(` [${w.entity}] ${w.text} — ${w.reason}`);
1538
+ console.warn(` → ${w.suggested_action}`);
1539
+ }
1540
+ if (staleReport.warnings.length > 5) {
1541
+ console.warn(` … and ${staleReport.warnings.length - 5} more. Run \`brainclaw doctor --json\` for the full list.`);
1542
+ }
1543
+ }
1544
+ hasIssues = true;
1545
+ }
1546
+ else {
1547
+ checks.push({ name: 'stale_memory', status: 'ok', message: 'No stale items detected' });
1548
+ if (!options.json) {
1549
+ console.log('✔ No stale items detected');
1550
+ }
1551
+ }
1552
+ }
1553
+ catch { /* non-fatal — staleness check should not block doctor */ }
1554
+ // --- Inbox message layout checks ---
1555
+ const inboxAudit = auditInboxMessages(options.cwd);
1556
+ const inboxIssueCount = inboxAudit.invalid.length + inboxAudit.orphaned.length;
1557
+ if (inboxIssueCount > 0) {
1558
+ const status = inboxAudit.invalid.length > 0 ? 'error' : 'warn';
1559
+ checks.push({
1560
+ name: 'inbox_messages',
1561
+ status,
1562
+ message: `${inboxIssueCount} inbox message issue(s): ${inboxAudit.invalid.length} invalid, ${inboxAudit.orphaned.length} orphaned.`,
1563
+ details: {
1564
+ checked: inboxAudit.checked,
1565
+ invalid: inboxAudit.invalid.slice(0, 10),
1566
+ orphaned: inboxAudit.orphaned.slice(0, 10),
1567
+ },
1568
+ });
1569
+ // pln#397 stp_b5337e30: orphaned messages (wrong-dir placement) can be
1570
+ // moved safely. Invalid JSON requires manual inspection — surface as
1571
+ // unsafe so the repair flow prompts.
1572
+ for (const orphaned of inboxAudit.orphaned) {
1573
+ repairCandidates.push({
1574
+ action: 'move_inbox_message',
1575
+ target: orphaned.path,
1576
+ description: `Move orphaned inbox message to the correct agent subdirectory (${orphaned.reason})`,
1577
+ safe: true,
1578
+ related_check: 'inbox_messages',
1579
+ });
1580
+ }
1581
+ for (const invalid of inboxAudit.invalid) {
1582
+ repairCandidates.push({
1583
+ action: 'quarantine_inbox_message',
1584
+ target: invalid.path,
1585
+ description: `Move malformed inbox message to inbox/.quarantine for later inspection (${invalid.error})`,
1586
+ safe: false,
1587
+ related_check: 'inbox_messages',
1588
+ });
1589
+ }
1590
+ if (!options.json) {
1591
+ console.warn(`⚠ Inbox messages: ${inboxAudit.invalid.length} invalid, ${inboxAudit.orphaned.length} orphaned.`);
1592
+ for (const invalid of inboxAudit.invalid.slice(0, 10)) {
1593
+ console.warn(` - invalid: ${invalid.path} (${invalid.error})`);
1594
+ }
1595
+ for (const orphaned of inboxAudit.orphaned.slice(0, 10)) {
1596
+ console.warn(` - orphaned: ${orphaned.path} (${orphaned.reason})`);
1597
+ }
1598
+ }
1599
+ hasIssues = true;
1600
+ }
1601
+ else {
1602
+ checks.push({
1603
+ name: 'inbox_messages',
1604
+ status: 'ok',
1605
+ message: inboxAudit.checked > 0
1606
+ ? `Inbox messages look valid (${inboxAudit.checked} agent message file(s) checked)`
1607
+ : 'No inbox message files to check',
1608
+ details: { checked: inboxAudit.checked },
1609
+ });
1610
+ if (!options.json && inboxAudit.checked > 0) {
1611
+ console.log(`✔ Inbox messages: ${inboxAudit.checked} agent message file(s) checked`);
1612
+ }
1613
+ }
771
1614
  // --- Claims checks ---
772
1615
  const claims = listClaims(options.cwd);
773
1616
  const activeClaims = claims.filter(c => c.status === 'active');
@@ -816,6 +1659,59 @@ export function runDoctor(options = {}) {
816
1659
  else {
817
1660
  checks.push({ name: 'claim_plan_link', status: 'ok', message: 'No active claims to check' });
818
1661
  }
1662
+ // Stale claims check — session-aware: a claim with a live session is never considered stale
1663
+ const staleThresholdHours = config?.claims?.auto_release_after_hours ?? 24;
1664
+ const livenessById = new Map(activeClaims.map(c => [c.id, assessClaimLiveness(c, { thresholdHours: staleThresholdHours, cwd: options.cwd })]));
1665
+ const staleClaims = activeClaims.filter(c => {
1666
+ const s = livenessById.get(c.id).status;
1667
+ return s === 'stale' || s === 'never-adopted';
1668
+ });
1669
+ const orphanedClaims = activeClaims.filter(c => livenessById.get(c.id).status === 'orphaned');
1670
+ if (staleClaims.length > 0) {
1671
+ hasIssues = true;
1672
+ const details = staleClaims.map(c => `${c.agent} → ${c.scope}`).join(', ');
1673
+ checks.push({ name: 'claims_stale', status: 'warn', message: `${staleClaims.length} stale claim(s) (no live session, >${staleThresholdHours}h): ${details}` });
1674
+ if (!options.json)
1675
+ console.warn(`⚠ ${staleClaims.length} stale claim(s) (no live session, >${staleThresholdHours}h): ${details}`);
1676
+ }
1677
+ else {
1678
+ checks.push({ name: 'claims_stale', status: 'ok', message: `No stale claims (threshold: ${staleThresholdHours}h)` });
1679
+ }
1680
+ if (orphanedClaims.length > 0) {
1681
+ hasIssues = true;
1682
+ const details = orphanedClaims.map(c => `${c.agent} → ${c.scope}`).join(', ');
1683
+ checks.push({ name: 'claims_orphaned', status: 'warn', message: `${orphanedClaims.length} orphaned claim(s) (session crashed): ${details}. Run 'brainclaw prune' or 'brainclaw claim release' to clean up.` });
1684
+ if (!options.json)
1685
+ console.warn(`⚠ ${orphanedClaims.length} orphaned claim(s) — session was adopted but died (crash recovery): ${details}`);
1686
+ }
1687
+ else if (activeClaims.some(c => c.adopted_at)) {
1688
+ checks.push({ name: 'claims_orphaned', status: 'ok', message: 'No orphaned claims' });
1689
+ }
1690
+ // Expired-but-still-active claims (TTL passed but prune not run)
1691
+ const expiredActive = activeClaims.filter((c) => isClaimExpired(c));
1692
+ if (expiredActive.length > 0) {
1693
+ hasIssues = true;
1694
+ const ids = expiredActive.map((c) => c.id).join(', ');
1695
+ checks.push({
1696
+ name: 'claim_ttl_expired',
1697
+ status: 'warn',
1698
+ message: `${expiredActive.length} active claim(s) past their TTL: ${ids}. Run 'brainclaw prune' to release them automatically.`,
1699
+ });
1700
+ if (!options.json) {
1701
+ console.warn(`⚠ ${expiredActive.length} active claim(s) have expired (TTL passed — run 'brainclaw prune')`);
1702
+ for (const c of expiredActive) {
1703
+ console.warn(` - [${c.id}] ${c.scope}: expires_at ${c.expires_at}`);
1704
+ }
1705
+ }
1706
+ }
1707
+ else if (activeClaims.some((c) => c.expires_at)) {
1708
+ checks.push({ name: 'claim_ttl_expired', status: 'ok', message: 'All TTL-bounded claims are within their expiry window' });
1709
+ if (!options.json)
1710
+ console.log('✔ Claim TTLs: all within bounds');
1711
+ }
1712
+ else {
1713
+ checks.push({ name: 'claim_ttl_expired', status: 'ok', message: 'No TTL-bounded claims' });
1714
+ }
819
1715
  // --- Runtime notes checks ---
820
1716
  const notes = listRuntimeNotes(undefined, options.cwd);
821
1717
  const localTraps = listOperationalTraps({}, options.cwd);
@@ -850,7 +1746,7 @@ export function runDoctor(options = {}) {
850
1746
  const events = listRuntimeEvents(options.cwd);
851
1747
  if (events.length > 0) {
852
1748
  const sessions = new Map();
853
- for (const event of events) {
1749
+ for (const event of events.filter(isTaskLifecycleRuntimeEvent)) {
854
1750
  const sessionValue = resolveEventSessionId(event);
855
1751
  if (!sessionValue)
856
1752
  continue;
@@ -893,6 +1789,8 @@ export function runDoctor(options = {}) {
893
1789
  active_plan_items: activePlans.length,
894
1790
  blocked_plan_items: blockedPlans.length,
895
1791
  promotion_ready_candidates: promotionReady.length,
1792
+ stale_auto_candidates: staleAutoCandidates.matched,
1793
+ stale_auto_candidates_deleted: staleAutoCandidates.deleted,
896
1794
  pending_candidates: pending.length,
897
1795
  accepted_candidates: accepted.length,
898
1796
  rejected_candidates: rejected.length,
@@ -1037,6 +1935,33 @@ export function runDoctor(options = {}) {
1037
1935
  // --- Backlog patterns in open handoffs ---
1038
1936
  try {
1039
1937
  const openHandoffs = state.open_handoffs.filter((h) => h.status === 'open');
1938
+ const backlogWithoutPlans = openHandoffs.flatMap((handoff) => extractBacklogWithoutPlanFindings(handoff));
1939
+ if (backlogWithoutPlans.length > 0) {
1940
+ const ids = [...new Set(backlogWithoutPlans.map((finding) => finding.handoff_id))].join(', ');
1941
+ checks.push({
1942
+ name: 'backlog_without_plans',
1943
+ status: 'warn',
1944
+ message: `${backlogWithoutPlans.length} actionable backlog item(s) in open handoff(s) lack a formal plan: ${ids}. Create a pln_xxx plan and link it.`,
1945
+ details: backlogWithoutPlans,
1946
+ });
1947
+ if (!options.json) {
1948
+ console.warn(`⚠ ${backlogWithoutPlans.length} actionable backlog item(s) in open handoff(s) lack a formal plan: ${ids}`);
1949
+ for (const finding of backlogWithoutPlans.slice(0, 10)) {
1950
+ console.warn(` - [${finding.handoff_id}] ${finding.snippet}`);
1951
+ console.warn(` ${finding.suggestion}`);
1952
+ }
1953
+ }
1954
+ hasIssues = true;
1955
+ }
1956
+ else {
1957
+ checks.push({
1958
+ name: 'backlog_without_plans',
1959
+ status: 'ok',
1960
+ message: openHandoffs.length > 0
1961
+ ? `${openHandoffs.length} open handoff(s) checked — no actionable backlog without plans detected`
1962
+ : 'No open handoffs to check',
1963
+ });
1964
+ }
1040
1965
  const handoffsWithBacklog = openHandoffs.filter((h) => !h.plan_id && hasBacklogPatterns(h.text));
1041
1966
  if (handoffsWithBacklog.length > 0) {
1042
1967
  const ids = handoffsWithBacklog.map((h) => h.id).join(', ');
@@ -1303,10 +2228,132 @@ export function runDoctor(options = {}) {
1303
2228
  catch { /* non-fatal */ }
1304
2229
  }
1305
2230
  catch { /* non-fatal */ }
2231
+ // Worktree stale-session and shared-checkout checks
2232
+ try {
2233
+ const activeClaims = listClaims(options.cwd);
2234
+ const worktrees = listWorktrees(options.cwd ?? process.cwd());
2235
+ const claimWorktrees = new Set(activeClaims.filter((c) => c.worktree_path && c.status === 'active').map((c) => c.worktree_path));
2236
+ const orphanWorktrees = worktrees.filter((wt) => !wt.is_main && wt.session_id && !claimWorktrees.has(wt.path));
2237
+ if (orphanWorktrees.length > 0) {
2238
+ hasIssues = true;
2239
+ checks.push({
2240
+ name: 'worktree_orphans',
2241
+ status: 'warn',
2242
+ message: `${orphanWorktrees.length} worktree(s) have no active claim: ${orphanWorktrees.map((w) => w.path).join(', ')}. Run 'brainclaw worktree prune' or remove them.`,
2243
+ });
2244
+ if (!options.json) {
2245
+ console.warn(`⚠ ${orphanWorktrees.length} orphan worktree(s) with no active claim`);
2246
+ for (const wt of orphanWorktrees) {
2247
+ console.warn(` - ${wt.path} (branch: ${wt.branch}, session: ${wt.session_id ?? 'unknown'})`);
2248
+ }
2249
+ }
2250
+ }
2251
+ else {
2252
+ checks.push({ name: 'worktree_orphans', status: 'ok', message: 'No orphan worktrees detected' });
2253
+ if (!options.json)
2254
+ console.log('✔ Worktrees: no orphans');
2255
+ }
2256
+ // Shared-checkout risk: multiple brainclaw sessions in the same working tree
2257
+ const risk = detectSharedCheckoutRisk(options.cwd ?? process.cwd());
2258
+ if (risk.has_conflict) {
2259
+ hasIssues = true;
2260
+ checks.push({
2261
+ name: 'worktree_shared_checkout',
2262
+ status: 'warn',
2263
+ message: `Shared-checkout risk: ${risk.conflicting_paths.length} worktree(s) have multiple active sessions. Each session should use a dedicated worktree.`,
2264
+ });
2265
+ if (!options.json) {
2266
+ console.warn('⚠ Shared-checkout risk detected — multiple sessions share a worktree');
2267
+ for (const p of risk.conflicting_paths) {
2268
+ console.warn(` - ${p}`);
2269
+ }
2270
+ }
2271
+ }
2272
+ else {
2273
+ checks.push({ name: 'worktree_shared_checkout', status: 'ok', message: 'No shared-checkout conflicts' });
2274
+ if (!options.json)
2275
+ console.log('✔ Worktrees: no shared-checkout conflicts');
2276
+ }
2277
+ }
2278
+ catch { /* non-fatal — git may not be available or no worktrees */ }
2279
+ // --- Documentation drift check ---
2280
+ try {
2281
+ const { execSync } = childProcess;
2282
+ const effectiveCwd = options.cwd ?? process.cwd();
2283
+ const srcCommitDate = execSync('git log -1 --format=%aI -- src/commands src/core', { encoding: 'utf-8', cwd: effectiveCwd }).trim();
2284
+ const docsCommitDate = execSync('git log -1 --format=%aI -- docs/', { encoding: 'utf-8', cwd: effectiveCwd }).trim();
2285
+ if (srcCommitDate && docsCommitDate && srcCommitDate > docsCommitDate) {
2286
+ checks.push({ name: 'doc_drift', status: 'warn', message: `Documentation may be outdated: src/ last changed ${srcCommitDate.slice(0, 10)}, docs/ last changed ${docsCommitDate.slice(0, 10)}` });
2287
+ if (!options.json) {
2288
+ console.warn(`⚠ Documentation drift: src/ updated ${srcCommitDate.slice(0, 10)} but docs/ last updated ${docsCommitDate.slice(0, 10)}`);
2289
+ }
2290
+ }
2291
+ else if (srcCommitDate && !docsCommitDate) {
2292
+ checks.push({ name: 'doc_drift', status: 'warn', message: 'No docs/ directory found in git history' });
2293
+ if (!options.json) {
2294
+ console.warn('⚠ No docs/ directory found in git history');
2295
+ }
2296
+ }
2297
+ else {
2298
+ checks.push({ name: 'doc_drift', status: 'ok', message: 'Documentation is up to date with source' });
2299
+ if (!options.json)
2300
+ console.log('✔ Documentation is up to date with source');
2301
+ }
2302
+ }
2303
+ catch { /* non-fatal — git may not be available */ }
2304
+ // --- Security preinstall gate check ---
2305
+ if (config.security?.preinstall?.enabled) {
2306
+ checks.push({ name: 'security_preinstall', status: 'ok', message: `Security preinstall gate is enabled (mode: ${config.security.preinstall.mode})` });
2307
+ if (!options.json)
2308
+ console.log(`✔ Security preinstall gate is enabled (mode: ${config.security.preinstall.mode})`);
2309
+ // Check if guard scripts exist
2310
+ try {
2311
+ const guardDir = path.join(memoryPath('security/bin', options.cwd), '.');
2312
+ const guardExists = fs.existsSync(path.dirname(guardDir));
2313
+ if (guardExists) {
2314
+ checks.push({ name: 'security_guard_scripts', status: 'ok', message: 'Guard wrapper scripts are generated' });
2315
+ if (!options.json)
2316
+ console.log('✔ Guard wrapper scripts are generated');
2317
+ }
2318
+ else {
2319
+ checks.push({ name: 'security_guard_scripts', status: 'warn', message: 'Guard wrapper scripts not found — run brainclaw setup-security' });
2320
+ if (!options.json)
2321
+ console.warn('⚠ Guard wrapper scripts not found — run brainclaw setup-security');
2322
+ }
2323
+ }
2324
+ catch { /* non-fatal */ }
2325
+ }
2326
+ else {
2327
+ checks.push({ name: 'security_preinstall', status: 'ok', message: 'Security preinstall gate is not enabled (optional)' });
2328
+ if (!options.json)
2329
+ console.log('ℹ Security preinstall gate is not enabled (optional — run brainclaw setup-security to activate)');
2330
+ }
2331
+ // VS Code extension check
2332
+ try {
2333
+ const codeResult = childProcess.spawnSync('code', ['--list-extensions'], { stdio: 'pipe', timeout: 5000 });
2334
+ if (codeResult.status === 0) {
2335
+ const extensions = codeResult.stdout.toString().split('\n').map(e => e.trim().toLowerCase());
2336
+ if (extensions.includes('brainclaw.brainclaw-vscode')) {
2337
+ checks.push({ name: 'vscode_extension', status: 'ok', message: 'Brainclaw VS Code extension is installed' });
2338
+ if (!options.json)
2339
+ console.log('✔ Brainclaw VS Code extension is installed');
2340
+ }
2341
+ else {
2342
+ checks.push({ name: 'vscode_extension', status: 'warn', message: 'VS Code detected but Brainclaw extension is not installed. Run `brainclaw setup` to install it.' });
2343
+ if (!options.json)
2344
+ console.log('⚠ VS Code detected but Brainclaw extension is not installed. Run `brainclaw setup` to install it.');
2345
+ }
2346
+ }
2347
+ // If `code` is not available, skip silently — VS Code not installed
2348
+ }
2349
+ catch {
2350
+ // Non-fatal
2351
+ }
1306
2352
  if (options.json) {
1307
2353
  console.log(JSON.stringify({
1308
2354
  ok: !hasIssues,
1309
2355
  checks,
2356
+ repair_candidates: repairCandidates,
1310
2357
  metrics: {
1311
2358
  ...metrics,
1312
2359
  migration_outdated_documents: migrationEntries.filter((entry) => entry.status === 'outdated').length,
@@ -1319,6 +2366,8 @@ export function runDoctor(options = {}) {
1319
2366
  circuit_breaker_threshold: circuitSnapshot.threshold,
1320
2367
  circuit_breaker_window_days: circuitSnapshot.window_days,
1321
2368
  agent_git_hygiene_fixed: agentGitHygieneFixed.length,
2369
+ repair_candidates_safe: repairCandidates.filter((c) => c.safe).length,
2370
+ repair_candidates_unsafe: repairCandidates.filter((c) => !c.safe).length,
1322
2371
  },
1323
2372
  migration: options.migrationCheck
1324
2373
  ? {
@@ -1335,4 +2384,28 @@ export function runDoctor(options = {}) {
1335
2384
  console.log('All checks passed.');
1336
2385
  }
1337
2386
  }
2387
+ function runAfterMigrationCheck(options) {
2388
+ const cwd = options.cwd ?? process.cwd();
2389
+ const store = resolvePrimaryStore(cwd);
2390
+ if (!store) {
2391
+ console.error(`Error: no .brainclaw/ store resolved from ${cwd}`);
2392
+ process.exit(1);
2393
+ }
2394
+ const report = runPostMigrationHealthCheck({ storePath: store.storePath });
2395
+ if (options.json) {
2396
+ console.log(JSON.stringify(report, null, 2));
2397
+ }
2398
+ else {
2399
+ console.log(`Post-migration health check on ${report.store_path}`);
2400
+ for (const finding of report.findings) {
2401
+ const glyph = finding.status === 'ok' ? '✔' : finding.status === 'warn' ? '⚠' : '✗';
2402
+ console.log(` ${glyph} [${finding.check}] ${finding.message}`);
2403
+ }
2404
+ console.log('');
2405
+ console.log(report.ok ? '✔ All post-migration invariants hold.' : '✗ Post-migration invariants failed. Inspect the findings above.');
2406
+ }
2407
+ if (!report.ok) {
2408
+ process.exit(1);
2409
+ }
2410
+ }
1338
2411
  //# sourceMappingURL=doctor.js.map