brainclaw 0.29.2 → 1.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -74
- package/README.md +199 -176
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +710 -25
- package/dist/commands/accept.js +3 -0
- package/dist/commands/add-step.js +11 -26
- package/dist/commands/agent-board.js +70 -3
- package/dist/commands/audit.js +19 -0
- package/dist/commands/check-policy.js +54 -0
- package/dist/commands/check-security-mcp.js +145 -0
- package/dist/commands/check-security.js +106 -0
- package/dist/commands/claim-resource.js +1 -0
- package/dist/commands/codev.js +672 -0
- package/dist/commands/compact.js +74 -0
- package/dist/commands/complete-step.js +16 -26
- package/dist/commands/constraint.js +8 -20
- package/dist/commands/decision.js +9 -20
- package/dist/commands/delete-plan.js +10 -12
- package/dist/commands/delete-step.js +16 -0
- package/dist/commands/dispatch.js +163 -0
- package/dist/commands/doctor.js +1122 -49
- package/dist/commands/enable-agent.js +1 -0
- package/dist/commands/export.js +280 -22
- package/dist/commands/handoff.js +33 -0
- package/dist/commands/harvest.js +189 -0
- package/dist/commands/hooks.js +82 -25
- package/dist/commands/inbox.js +169 -0
- package/dist/commands/init.js +38 -31
- package/dist/commands/install-hooks.js +71 -44
- package/dist/commands/link.js +89 -0
- package/dist/commands/list-claims.js +48 -3
- package/dist/commands/list-plans.js +129 -25
- package/dist/commands/loops-handlers.js +409 -0
- package/dist/commands/mcp-read-handlers.js +1628 -0
- package/dist/commands/mcp-schemas.generated.js +269 -0
- package/dist/commands/mcp.js +4224 -1501
- package/dist/commands/plan-resource.js +64 -0
- package/dist/commands/plan.js +12 -26
- package/dist/commands/prune.js +37 -2
- package/dist/commands/reflect.js +20 -7
- package/dist/commands/release-claim.js +11 -6
- package/dist/commands/release-notes.js +170 -0
- package/dist/commands/repair.js +210 -0
- package/dist/commands/run-profile.js +57 -0
- package/dist/commands/sequence.js +113 -0
- package/dist/commands/session-end.js +423 -14
- package/dist/commands/session-start.js +214 -41
- package/dist/commands/setup-security.js +103 -0
- package/dist/commands/setup.js +42 -4
- package/dist/commands/stale.js +109 -0
- package/dist/commands/switch.js +100 -2
- package/dist/commands/trap.js +14 -31
- package/dist/commands/update-handoff.js +63 -4
- package/dist/commands/update-plan.js +21 -28
- package/dist/commands/update-step.js +37 -0
- package/dist/commands/upgrade.js +313 -6
- package/dist/commands/usage.js +102 -0
- package/dist/commands/version.js +20 -0
- package/dist/commands/who.js +33 -5
- package/dist/commands/worktree.js +105 -0
- package/dist/core/actions.js +315 -0
- package/dist/core/agent-capability.js +610 -17
- package/dist/core/agent-context.js +7 -1
- package/dist/core/agent-files.js +1169 -85
- package/dist/core/agent-integrations.js +160 -5
- package/dist/core/agent-inventory.js +2 -0
- package/dist/core/agent-profiles.js +93 -0
- package/dist/core/agent-registry.js +162 -30
- package/dist/core/agentrun-reconciler.js +345 -0
- package/dist/core/agentruns.js +424 -0
- package/dist/core/ai-agent-detection.js +31 -10
- package/dist/core/archival.js +77 -0
- package/dist/core/assignment-sweeper.js +82 -0
- package/dist/core/assignments.js +367 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/brainclaw-version.js +94 -2
- package/dist/core/candidates.js +93 -2
- package/dist/core/claims.js +419 -0
- package/dist/core/codev-metrics.js +77 -0
- package/dist/core/codev-personas.js +31 -0
- package/dist/core/codev-plan-gen.js +35 -0
- package/dist/core/codev-prompts.js +74 -0
- package/dist/core/codev-responses.js +62 -0
- package/dist/core/codev-rounds.js +218 -0
- package/dist/core/config.js +4 -0
- package/dist/core/context.js +381 -34
- package/dist/core/coordination.js +201 -6
- package/dist/core/cross-project.js +230 -16
- package/dist/core/default-profiles/doctor.yaml +11 -0
- package/dist/core/default-profiles/janitor.yaml +11 -0
- package/dist/core/default-profiles/onboarder.yaml +11 -0
- package/dist/core/default-profiles/reviewer.yaml +13 -0
- package/dist/core/dispatcher.js +1189 -0
- package/dist/core/duplicates.js +2 -2
- package/dist/core/entity-operations.js +450 -0
- package/dist/core/entity-registry.js +344 -0
- package/dist/core/events.js +106 -2
- package/dist/core/execution-adapters.js +154 -0
- package/dist/core/execution-context.js +63 -0
- package/dist/core/execution-profile.js +270 -0
- package/dist/core/execution.js +255 -0
- package/dist/core/facade-schema.js +81 -0
- package/dist/core/federation-cloud.js +99 -0
- package/dist/core/federation-message.js +52 -0
- package/dist/core/federation-transport.js +65 -0
- package/dist/core/gc-semantic.js +482 -0
- package/dist/core/governance.js +247 -0
- package/dist/core/guards.js +19 -0
- package/dist/core/ideation.js +72 -0
- package/dist/core/identity.js +110 -25
- package/dist/core/ids.js +6 -0
- package/dist/core/input-validation.js +2 -2
- package/dist/core/instruction-templates.js +344 -136
- package/dist/core/io.js +90 -11
- package/dist/core/lock.js +6 -2
- package/dist/core/loops/brief-assembly.js +213 -0
- package/dist/core/loops/facade-schema.js +148 -0
- package/dist/core/loops/index.js +7 -0
- package/dist/core/loops/iteration-engine.js +139 -0
- package/dist/core/loops/lock.js +385 -0
- package/dist/core/loops/store.js +201 -0
- package/dist/core/loops/types.js +403 -0
- package/dist/core/loops/verbs.js +534 -0
- package/dist/core/markdown.js +15 -3
- package/dist/core/memory-compactor.js +432 -0
- package/dist/core/memory-git.js +152 -8
- package/dist/core/messaging.js +278 -0
- package/dist/core/migration.js +32 -1
- package/dist/core/mutation-pipeline.js +4 -2
- package/dist/core/operations/memory-mutation.js +129 -0
- package/dist/core/operations/memory-write.js +78 -0
- package/dist/core/operations/plan.js +190 -0
- package/dist/core/policy.js +169 -0
- package/dist/core/reputation.js +9 -3
- package/dist/core/schema.js +491 -6
- package/dist/core/search.js +21 -2
- package/dist/core/security-cache.js +71 -0
- package/dist/core/security-guard.js +152 -0
- package/dist/core/security-scoring.js +86 -0
- package/dist/core/sequence.js +130 -0
- package/dist/core/socket-client.js +113 -0
- package/dist/core/staleness.js +246 -0
- package/dist/core/state.js +98 -22
- package/dist/core/store-resolution.js +43 -11
- package/dist/core/toml-writer.js +76 -0
- package/dist/core/upgrades/backup.js +232 -0
- package/dist/core/upgrades/health-check.js +169 -0
- package/dist/core/upgrades/patches/candidate-archive.js +145 -0
- package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
- package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
- package/dist/core/upgrades/schema-version.js +97 -0
- package/dist/core/worktree.js +606 -0
- package/dist/facts.js +114 -0
- package/dist/facts.json +111 -0
- package/docs/architecture/project-refs.md +5 -1
- package/docs/cli.md +690 -43
- package/docs/concepts/ideation-loop.md +317 -0
- package/docs/concepts/loop-engine.md +456 -0
- package/docs/concepts/mcp-governance.md +268 -0
- package/docs/concepts/memory-staleness.md +122 -0
- package/docs/concepts/multi-agent-workflows.md +166 -0
- package/docs/concepts/plans-and-claims.md +31 -6
- package/docs/concepts/project-md-convention.md +35 -0
- package/docs/concepts/troubleshooting.md +220 -0
- package/docs/concepts/upgrade-cli.md +202 -0
- package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
- package/docs/context-format-changelog.md +2 -2
- package/docs/context-format.md +2 -2
- package/docs/index.md +68 -0
- package/docs/integrations/agents.md +15 -16
- package/docs/integrations/cline.md +88 -0
- package/docs/integrations/codex.md +75 -23
- package/docs/integrations/continue.md +60 -0
- package/docs/integrations/copilot.md +67 -9
- package/docs/integrations/kilocode.md +72 -0
- package/docs/integrations/mcp.md +304 -21
- package/docs/integrations/mistral-vibe.md +122 -0
- package/docs/integrations/opencode.md +84 -0
- package/docs/integrations/overview.md +23 -8
- package/docs/integrations/roo.md +74 -0
- package/docs/integrations/windsurf.md +83 -0
- package/docs/mcp-schema-changelog.md +191 -1
- package/docs/playbooks/integration/index.md +121 -0
- package/docs/playbooks/productivity/index.md +102 -0
- package/docs/playbooks/team/index.md +122 -0
- package/docs/product/agent-first-model.md +184 -0
- package/docs/product/entity-model-audit.md +462 -0
- package/docs/product/positioning.md +10 -10
- package/docs/quickstart-existing-project.md +135 -0
- package/docs/quickstart.md +124 -37
- package/docs/release-maintenance.md +79 -0
- package/docs/review.md +2 -0
- package/docs/server-operations.md +118 -0
- package/package.json +21 -13
- package/dist/commands/claude-desktop-extension.js +0 -18
- package/dist/commands/diff.js +0 -99
- package/dist/core/claude-desktop-extension.js +0 -224
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
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: '
|
|
275
|
-
message: `No
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|