@holmes-lab/holmes-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.STRIPPED_FOR_PROBE = void 0;
|
|
37
|
+
exports.probeEnv = probeEnv;
|
|
38
|
+
exports.runDoctor = runDoctor;
|
|
39
|
+
exports.formatChecks = formatChecks;
|
|
40
|
+
// @implements A-SPEC-100.2
|
|
41
|
+
const fs = __importStar(require("node:fs"));
|
|
42
|
+
const path = __importStar(require("node:path"));
|
|
43
|
+
const role_policy_1 = require("../governance/role-policy");
|
|
44
|
+
const blind_spots_1 = require("../guardrail/blind-spots");
|
|
45
|
+
const node_child_process_1 = require("node:child_process");
|
|
46
|
+
const os = __importStar(require("node:os"));
|
|
47
|
+
const settings_merge_1 = require("./settings-merge");
|
|
48
|
+
const playbook_skills_1 = require("./playbook-skills");
|
|
49
|
+
const init_1 = require("./init");
|
|
50
|
+
const GRAMMARS = [
|
|
51
|
+
'tree-sitter-typescript', 'tree-sitter-python', 'tree-sitter-c-sharp', 'tree-sitter-java',
|
|
52
|
+
'tree-sitter-go', 'tree-sitter-rust', 'tree-sitter-cpp',
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* Environment variables removed before spawning a probe.
|
|
56
|
+
*
|
|
57
|
+
* @implements A-SPEC-143
|
|
58
|
+
* `HOLMES_APPROVAL` grants permission, so a probe that inherits one measures the GRANT rather than
|
|
59
|
+
* the gate. `HOLMES_SPECS` redirects the spec root, so a probe reading another project's specs
|
|
60
|
+
* answers a different question than the one asked. `HOLMES_LEDGER_KEY` is deliberately NOT here: it
|
|
61
|
+
* changes how the ledger is signed, not whether the gate enforces, and stripping it could mask a
|
|
62
|
+
* real key-related failure.
|
|
63
|
+
*/
|
|
64
|
+
exports.STRIPPED_FOR_PROBE = ['HOLMES_APPROVAL', 'HOLMES_SPECS'];
|
|
65
|
+
/** The parent environment minus the variables that legitimately change a gate decision. Pure. */
|
|
66
|
+
function probeEnv(parent) {
|
|
67
|
+
const out = { ...parent };
|
|
68
|
+
for (const k of exports.STRIPPED_FOR_PROBE)
|
|
69
|
+
delete out[k];
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The settings file this target is actually wired through.
|
|
74
|
+
*
|
|
75
|
+
* @implements A-SPEC-190 §14 (round 12) — round 11 replaced a hardcoded `settings.local.json` with
|
|
76
|
+
* "local if that file EXISTS", which is a different wrong answer: Claude Code writes its own
|
|
77
|
+
* `settings.local.json` for unrelated reasons, and the moment it appears a target wired with
|
|
78
|
+
* `--settings project` is declared unwired again — with the sibling `hook matcher` check, reading
|
|
79
|
+
* the same way, reporting PASS. Presence is not wiring. Ask which file carries holmes commands, and
|
|
80
|
+
* fall back to the layout only when neither does.
|
|
81
|
+
*/
|
|
82
|
+
function wiredSettingsPath(target) {
|
|
83
|
+
const carries = (which) => {
|
|
84
|
+
try {
|
|
85
|
+
const s = JSON.parse(fs.readFileSync((0, init_1.settingsPathOf)(target, which), 'utf8'));
|
|
86
|
+
return Object.values(s.hooks ?? {}).some((groups) => (groups ?? []).some((g) => (g.hooks ?? []).some((h) => (0, settings_merge_1.isHolmesCommand)(h.command))));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
if (carries('local'))
|
|
93
|
+
return (0, init_1.settingsPathOf)(target, 'local');
|
|
94
|
+
if (carries('project'))
|
|
95
|
+
return (0, init_1.settingsPathOf)(target, 'project');
|
|
96
|
+
return (0, init_1.settingsPathOf)(target, fs.existsSync((0, init_1.settingsPathOf)(target, 'local')) ? 'local' : 'project');
|
|
97
|
+
}
|
|
98
|
+
async function runDoctor(packageRoot, target, opts) {
|
|
99
|
+
const checks = [];
|
|
100
|
+
const add = (name, level, detail, fix) => checks.push({ name, level, detail, fix });
|
|
101
|
+
// 1. Build output — also the "prepare didn't run" detector.
|
|
102
|
+
const serverJs = path.join(packageRoot, 'dist', 'holmes', 'mcp', 'server.js');
|
|
103
|
+
const hookJs = path.join(packageRoot, 'dist', 'holmes', 'hooks', 'pre-tool-use.js');
|
|
104
|
+
const stopJs = path.join(packageRoot, 'dist', 'holmes', 'hooks', 'stop.js');
|
|
105
|
+
const built = fs.existsSync(serverJs) && fs.existsSync(hookJs) && fs.existsSync(stopJs);
|
|
106
|
+
add('build output', built ? 'PASS' : 'FAIL', built ? `compiled output present under ${path.join(packageRoot, 'dist')}` : `missing ${serverJs}`,
|
|
107
|
+
// @implements A-SPEC-190 §16 (round 12) — the old remedy named a `prepare` script package.json
|
|
108
|
+
// never declared (build/mcp/release/test/typecheck only); dist/ ships because `files` lists it.
|
|
109
|
+
built ? undefined : '이 패키지는 컴파일된 dist/ 를 함께 배포합니다 — 저장소에서 쓰는 중이라면 `npm run build`, 설치본이라면 재설치하십시오.');
|
|
110
|
+
// 2. Node version vs better-sqlite3's declared range (WARN only — a mismatch still often works).
|
|
111
|
+
let sqliteRange = '(unknown)';
|
|
112
|
+
try {
|
|
113
|
+
sqliteRange = JSON.parse(fs.readFileSync(path.join(packageRoot, 'node_modules', 'better-sqlite3', 'package.json'), 'utf8')).engines?.node ?? '(none)';
|
|
114
|
+
}
|
|
115
|
+
catch { /* not resolvable from here */ }
|
|
116
|
+
add('node version', 'PASS', `node ${process.version}; better-sqlite3 declares engines.node=${sqliteRange}`, 'If a native module fails to load after a Node major upgrade, reinstall holmes-kit.');
|
|
117
|
+
// 3. tree-sitter + EACH grammar. language-parser.ts require.resolve's all of them at module scope,
|
|
118
|
+
// so ONE missing grammar kills MCP server startup entirely — report them individually.
|
|
119
|
+
try {
|
|
120
|
+
require('tree-sitter');
|
|
121
|
+
const missing = GRAMMARS.filter((g) => { try {
|
|
122
|
+
require.resolve(g);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return true;
|
|
127
|
+
} });
|
|
128
|
+
if (missing.length === 0)
|
|
129
|
+
add('tree-sitter grammars', 'PASS', `tree-sitter + ${GRAMMARS.length} grammars resolve`);
|
|
130
|
+
else
|
|
131
|
+
add('tree-sitter grammars', 'FAIL', `missing: ${missing.join(', ')}`, 'A single missing grammar prevents the MCP server from starting. Reinstall; if a native build failed, ensure a C++ toolchain is available.');
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
add('tree-sitter grammars', 'FAIL', `tree-sitter failed to load: ${e.message}`, 'Native module build failed. Install Xcode Command Line Tools (macOS) or build-essential, then reinstall.');
|
|
135
|
+
}
|
|
136
|
+
// 4. better-sqlite3 — ABI-locked (not N-API), the most fragile dependency.
|
|
137
|
+
try {
|
|
138
|
+
const Database = require('better-sqlite3');
|
|
139
|
+
const db = new Database(':memory:');
|
|
140
|
+
db.prepare('SELECT 1 AS ok').get();
|
|
141
|
+
db.close();
|
|
142
|
+
add('better-sqlite3', 'PASS', 'loads and executes against :memory:');
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
add('better-sqlite3', 'FAIL', e.message, 'better-sqlite3 is ABI-locked to the Node version. Reinstall holmes-kit after any Node major change; a source build needs a C++ toolchain.');
|
|
146
|
+
}
|
|
147
|
+
// 5. Hooks actually gate — prove it with a live allow AND a live deny (exit 2).
|
|
148
|
+
if (built) {
|
|
149
|
+
// @implements A-SPEC-143
|
|
150
|
+
// HERMETIC. The probe asks "does this INSTALLATION enforce?", so it must not inherit variables
|
|
151
|
+
// that legitimately change a gate decision. Measured on a real tarball install: with a valid
|
|
152
|
+
// HOLMES_APPROVAL exported — the very thing `init` prints as the way to run governed mode — the
|
|
153
|
+
// catastrophic command was correctly ALLOWED, and this check read that as "the guardrail is not
|
|
154
|
+
// enforcing" and told the operator to reinstall a working install.
|
|
155
|
+
const env = probeEnv(process.env);
|
|
156
|
+
// @implements A-SPEC-143
|
|
157
|
+
// Injectable so the WIRING is verified deterministically. Calling the real spawn from a unit test
|
|
158
|
+
// made it load-sensitive — measured, it failed roughly 1 full-suite run in 8 by exceeding the
|
|
159
|
+
// 20s timeout, and a verification harness whose green is probabilistic is not verification.
|
|
160
|
+
// The realistic path stays covered by the external check (pack → install → doctor).
|
|
161
|
+
const runner = opts?.run ?? ((script, payload, childEnv) =>
|
|
162
|
+
// ISOLATED cwd (round-5 HIGH): the probed hooks write REAL governance state relative to
|
|
163
|
+
// their cwd — every doctor run appended a synthetic 'rm -rf /' gate-deny to the PROJECT's
|
|
164
|
+
// tamper-evident ledger (measured: 8,393 forged entries) and minted .ax state in empty dirs.
|
|
165
|
+
// A probe must observe the gate, never feed its audit trail.
|
|
166
|
+
(() => {
|
|
167
|
+
const probeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-probe-'));
|
|
168
|
+
cleanupOnSignal(probeCwd); // round-9: 인터럽트 사망에서도 남지 않는다
|
|
169
|
+
try {
|
|
170
|
+
return (0, node_child_process_1.spawnSync)(process.execPath, [script], { input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000, env: childEnv, cwd: probeCwd });
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
// The probe minted synthetic gate state in there — leaving it accumulated 1,665 dirs on
|
|
174
|
+
// one machine (round-6), each holding a forged gate-deny ledger.
|
|
175
|
+
try {
|
|
176
|
+
fs.rmSync(probeCwd, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
catch { /* tmp cleaner's job */ }
|
|
179
|
+
}
|
|
180
|
+
})());
|
|
181
|
+
const run = (script, payload) => runner(script, payload, env);
|
|
182
|
+
const allow = run(hookJs, { tool_name: 'Bash', tool_input: { command: 'ls' } });
|
|
183
|
+
const denyCmd = ['rm', '-rf', '/'].join(' ');
|
|
184
|
+
const deny = run(hookJs, { tool_name: 'Bash', tool_input: { command: denyCmd } });
|
|
185
|
+
const ok = allow.status === 0 && deny.status === 2 && /\[Holmes-Kit\]/.test(`${deny.stderr ?? ''}${deny.stdout ?? ''}`);
|
|
186
|
+
// Say so when we excluded something the operator deliberately set: a diagnostic that silently
|
|
187
|
+
// disagrees with someone's mental model is how a correct answer still gets distrusted.
|
|
188
|
+
const excluded = exports.STRIPPED_FOR_PROBE.filter((k) => process.env[k] !== undefined);
|
|
189
|
+
const note = excluded.length ? ` (probe excluded ${excluded.join(', ')} — an approval legitimately allows what this check expects denied)` : '';
|
|
190
|
+
add('PreToolUse gate', ok ? 'PASS' : 'FAIL', ok ? `allows a benign command; denies a catastrophic delete (exit 2)${note}` : `allow exit=${allow.status}, deny exit=${deny.status}${note}`, ok ? undefined : 'The guardrail is not enforcing. Re-check the build output and reinstall.');
|
|
191
|
+
const stop = run(stopJs, { session_id: 'doctor' });
|
|
192
|
+
add('Stop gate', stop.status === 0 ? 'PASS' : 'WARN', `exit=${stop.status}${stop.stderr ? ` — ${String(stop.stderr).slice(0, 160)}` : ''}`);
|
|
193
|
+
}
|
|
194
|
+
// 6. MCP server handshake — initialize, THEN tools/list. This must be async: the protocol is a
|
|
195
|
+
// round-trip, and a spawnSync that writes everything and closes stdin makes the server exit
|
|
196
|
+
// before it answers (a false FAIL this check produced on its first run).
|
|
197
|
+
if (built) {
|
|
198
|
+
checks.push(await mcpHandshakeCheck(packageRoot));
|
|
199
|
+
}
|
|
200
|
+
// 7. Target wiring — do the configured commands point at THIS installed package and exist?
|
|
201
|
+
if (target) {
|
|
202
|
+
// @implements A-SPEC-190 §13 (round 11) — `init --settings project` wires `.claude/settings.json`,
|
|
203
|
+
// and this check read only `settings.local.json`: a correctly wired target got WARN plus "Run
|
|
204
|
+
// `holmes-kit init` in the target", while the `hook matcher` check ten lines below — asking the
|
|
205
|
+
// same file the same way through `settingsPathOf` — reported PASS. One diagnosis contradicting
|
|
206
|
+
// itself is worse than either verdict alone, because the remedy it prints re-runs a completed
|
|
207
|
+
// install. Both checks now read the file the target actually uses.
|
|
208
|
+
const sp = wiredSettingsPath(target);
|
|
209
|
+
try {
|
|
210
|
+
const settings = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
211
|
+
const cmds = [];
|
|
212
|
+
for (const groups of Object.values(settings.hooks ?? {})) {
|
|
213
|
+
for (const g of groups ?? [])
|
|
214
|
+
for (const h of g.hooks ?? [])
|
|
215
|
+
if ((0, settings_merge_1.isHolmesCommand)(h.command))
|
|
216
|
+
cmds.push(h.command);
|
|
217
|
+
}
|
|
218
|
+
if (cmds.length === 0)
|
|
219
|
+
add('target wiring', 'WARN', `no holmes hooks found in ${sp}`, 'Run `holmes-kit init` in the target.');
|
|
220
|
+
else {
|
|
221
|
+
const stale = cmds.filter((c) => {
|
|
222
|
+
const p = (0, settings_merge_1.hookScriptPath)(c);
|
|
223
|
+
return !p || !fs.existsSync(p) || !path.resolve(p).startsWith(path.resolve(packageRoot) + path.sep);
|
|
224
|
+
});
|
|
225
|
+
add('target wiring', stale.length === 0 ? 'PASS' : 'FAIL', stale.length === 0 ? `${cmds.length} holmes hook(s) resolve to this install` : `stale/foreign command(s): ${stale.join(' | ')}`, stale.length === 0 ? undefined : 'Re-run `holmes-kit init --force` in the target to refresh the absolute paths.');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
add('target wiring', 'WARN', `could not read ${sp}`, 'Run `holmes-kit init` in the target.');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// @implements A-SPEC-142
|
|
233
|
+
// Recovery layer. WARN and never FAIL: no gate decision reads a playbook, so their absence changes
|
|
234
|
+
// no enforcement — and a check that FAILs on something harmless trains an operator to ignore
|
|
235
|
+
// doctor output, which is how the checks that DO matter stop being read.
|
|
236
|
+
if (target) {
|
|
237
|
+
// @implements A-SPEC-190
|
|
238
|
+
// Judged by CONTENT, not count: an installed skill whose bytes differ from what install would
|
|
239
|
+
// write today still counted as installed, so a target teaching abolished conventions reported
|
|
240
|
+
// "3 of 3 … PASS" (measured in this repository — all three had drifted). Foreign files break
|
|
241
|
+
// the all-current PASS too (the count check never counted them either), but the wording keeps
|
|
242
|
+
// ownership distinct and never promises that init will fix what init deliberately skips.
|
|
243
|
+
const skillsDir = path.join(target, '.claude', 'skills');
|
|
244
|
+
const states = (0, playbook_skills_1.playbookSkillStates)(packageRoot, target);
|
|
245
|
+
const of = (s) => states.filter((x) => x.state === s).map((x) => (0, playbook_skills_1.invocableSkillName)(x.name));
|
|
246
|
+
// Orphan rows already carry the RAW directory name (round-6: re-prefixing a case-variant
|
|
247
|
+
// produced the CURRENT skill's name in a delete instruction).
|
|
248
|
+
const orphaned = states.filter((x) => x.state === 'orphaned').map((x) => x.name);
|
|
249
|
+
const [drifted, missing, foreign] = [of('drifted'), of('missing'), of('foreign')];
|
|
250
|
+
// @implements A-SPEC-190 (round 7) — the PACKAGE's own damage, distinguished from the target's:
|
|
251
|
+
// an unreadable shipped playbook used to disappear from this report entirely.
|
|
252
|
+
const sourceUnreadable = of('source-unreadable');
|
|
253
|
+
const aliased = of('aliased');
|
|
254
|
+
// Counted EXPLICITLY, not by subtraction: a future fifth state must land in the WARN branch,
|
|
255
|
+
// not silently pass as current (round-1 finding).
|
|
256
|
+
const current = states.filter((x) => x.state === 'current').length;
|
|
257
|
+
if (states.length === 0) {
|
|
258
|
+
// No playbooks shipped. The first number stays TRUE to the target — a hardcoded "0 of 0"
|
|
259
|
+
// lied about targets that still hold installed skills (round-1 finding).
|
|
260
|
+
// round-8: the first number came from installedPlaybookCount, which counts marker-owned
|
|
261
|
+
// installs of SHIPPED names — provably 0 whenever nothing is shipped. It read as data.
|
|
262
|
+
add('recovery skills', 'WARN', `this package ships no playbooks; nothing to compare under ${skillsDir}`,
|
|
263
|
+
// No playbooks shipped means the PACKAGE is broken — init/refresh would install nothing
|
|
264
|
+
// (round-3: the old init hint was both a refusal loop on wired targets and a no-op here).
|
|
265
|
+
'This holmes-kit package ships no playbooks — reinstall or upgrade holmes-kit itself.');
|
|
266
|
+
}
|
|
267
|
+
else if (current === states.length) {
|
|
268
|
+
add('recovery skills', 'PASS', `${states.length} recovery skill(s) current under ${skillsDir}`);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const KNOWN_STATES = new Set(['current', 'drifted', 'missing', 'foreign', 'orphaned', 'source-unreadable', 'aliased']);
|
|
272
|
+
const unknown = states.filter((x) => !KNOWN_STATES.has(x.state)).map((x) => `${(0, playbook_skills_1.invocableSkillName)(x.name)} (${x.state})`);
|
|
273
|
+
const parts = [
|
|
274
|
+
drifted.length ? `drifted: ${drifted.join(', ')}` : '',
|
|
275
|
+
missing.length ? `missing: ${missing.join(', ')}` : '',
|
|
276
|
+
foreign.length ? `not provably ours (untouched): ${foreign.join(', ')}` : '',
|
|
277
|
+
orphaned.length ? `orphaned (no longer shipped): ${orphaned.join(', ')}` : '',
|
|
278
|
+
sourceUnreadable.length ? `unreadable in this package: ${sourceUnreadable.join(', ')}` : '',
|
|
279
|
+
aliased.length ? `two skills resolve to one file: ${aliased.join(', ')}` : '',
|
|
280
|
+
// A state this doctor does not know still NAMES the skill — a dangling separator and an
|
|
281
|
+
// empty fix threw away the one fact the operator needed (round-3).
|
|
282
|
+
unknown.length ? `unrecognized state: ${unknown.join(', ')}` : '',
|
|
283
|
+
].filter(Boolean).join('; ');
|
|
284
|
+
// The remedy must actually WORK: `init --target` on a wired target exits 2 (self-disarm
|
|
285
|
+
// guard) and --force demands governance approval — a refusal loop for three advisory files
|
|
286
|
+
// (round-1 HIGH). `skills refresh` touches only marker-owned skills, never wiring.
|
|
287
|
+
const fixes = [
|
|
288
|
+
drifted.length + missing.length > 0
|
|
289
|
+
? `Run \`holmes-kit skills refresh --target ${target}\` to refresh the skills holmes-kit owns.` : '',
|
|
290
|
+
foreign.length > 0 && drifted.length + missing.length === 0
|
|
291
|
+
? `A file holmes-kit cannot prove it owns occupies it (no readable marker); holmes-kit leaves it untouched — if it is not yours, restore permissions or remove it and re-run \`holmes-kit skills refresh --target ${target}\`.` : '',
|
|
292
|
+
orphaned.length > 0
|
|
293
|
+
? 'holmes-kit no longer ships it; delete the directory if you no longer want it.' : '',
|
|
294
|
+
sourceUnreadable.length > 0
|
|
295
|
+
? 'holmes-kit cannot read its own shipped playbook — reinstall or upgrade holmes-kit itself; `skills refresh` will fail on the same file.' : '',
|
|
296
|
+
aliased.length > 0
|
|
297
|
+
? 'A link makes two skill files the same file; refresh refuses rather than let one body overwrite the other — remove the link, then refresh.' : '',
|
|
298
|
+
unknown.length > 0
|
|
299
|
+
? 'A state this doctor does not recognize — upgrade holmes-kit so doctor and skills agree.' : '',
|
|
300
|
+
].filter(Boolean).join(' ');
|
|
301
|
+
add('recovery skills', 'WARN', `${current} of ${states.length} recovery skill(s) current under ${skillsDir} — ${parts}`, fixes);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// @implements A-SPEC-190 (round 7) — without --target this check simply did not appear, so the
|
|
306
|
+
// whole drift report was invisible to the operator who typed the shorter command and read a
|
|
307
|
+
// clean doctor as 'my recovery layer is fine'. Absence of a check is now itself reported.
|
|
308
|
+
add('recovery skills', 'WARN', 'not checked — this check is per-project and needs a target', `Run \`holmes-kit doctor --target ${process.cwd()}\` (or another project) to see recovery-skill drift.`);
|
|
309
|
+
}
|
|
310
|
+
// 8. Environment — presence only, never values (these hold out-of-band secrets).
|
|
311
|
+
const present = ['HOLMES_SPECS', 'HOLMES_APPROVAL', 'HOLMES_LEDGER_KEY', 'HOLMES_MCP_PROFILE']
|
|
312
|
+
.map((k) => `${k}=${process.env[k] ? 'set' : 'unset'}`).join(' ');
|
|
313
|
+
// @implements A-SPEC-153
|
|
314
|
+
// Reports, never judges. Not having roles on is a choice, not a defect; a FAIL here would teach
|
|
315
|
+
// operators that doctor's failures are noise, which costs more than this check is worth.
|
|
316
|
+
if (target) {
|
|
317
|
+
const rs = (0, role_policy_1.rolePolicyStatus)(target);
|
|
318
|
+
add('role policy', 'PASS', rs.state === 'absent' ? 'not configured (optional)'
|
|
319
|
+
: rs.state === 'inactive' ? 'guidance present, no policy — roles are off (see .ax/roles/README.md)'
|
|
320
|
+
: `active — ${rs.roles.length} role(s): ${rs.roles.join(', ')}; default=${rs.default ?? '(none — unspecified is refused)'}`);
|
|
321
|
+
}
|
|
322
|
+
// @implements A-SPEC-165
|
|
323
|
+
// The boundary, told to the user rather than left in a document nobody opens. PASS, not FAIL: a
|
|
324
|
+
// limit is not a defect, and marking harmless facts as failures teaches operators to skip doctor
|
|
325
|
+
// entirely — the judgement REQ-153 already made for the role-policy report.
|
|
326
|
+
// @implements A-SPEC-168
|
|
327
|
+
// Existing children under unapproved parents are NOT violations — the obligation is on the act of
|
|
328
|
+
// approving, so this repo does not brick. But they must stay visible: what nobody can see, nobody
|
|
329
|
+
// fixes, and the state becomes permanent.
|
|
330
|
+
if (target) {
|
|
331
|
+
try {
|
|
332
|
+
const { LocalMarkdownRepository } = require('../spec/spec-store');
|
|
333
|
+
const all = await new LocalMarkdownRepository(path.join(target, '.ax', 'specs')).list();
|
|
334
|
+
const byId = new Map(all.map((s) => [s.id, s]));
|
|
335
|
+
const orphaned = all.filter((s) => s.status === 'approved'
|
|
336
|
+
&& (s.dependsOn ?? []).some((p) => byId.get(p) && byId.get(p).status !== 'approved'));
|
|
337
|
+
// @implements A-SPEC-177
|
|
338
|
+
// Measured 2026-08-12: a duplicated id flips an anchored write to deny — correctly, the gate
|
|
339
|
+
// takes the unapproved side either way, so there is no forgery route. But the refusal says the
|
|
340
|
+
// spec "is not approved" while the author is looking at a file that says it is, and `spec chain`
|
|
341
|
+
// answered PASS. `rtm_check` reported it; nothing the author was looking at did.
|
|
342
|
+
// The detection is QUOTED, not re-implemented: two answers to "is this a duplicate" drift, and
|
|
343
|
+
// on the day they disagree the user cannot tell which tool to believe.
|
|
344
|
+
const { rtmCheck } = require('../rtm/rtm-check');
|
|
345
|
+
const dups = rtmCheck(all).filter((i) => i.kind === 'duplicate-id');
|
|
346
|
+
add('duplicate spec ids', dups.length === 0 ? 'PASS' : 'WARN', dups.length === 0
|
|
347
|
+
? '같은 id를 가진 스펙 파일이 없습니다'
|
|
348
|
+
: `같은 id의 스펙 파일이 둘 이상: ${dups.map((d) => d.id).join(', ')}`, dups.length === 0 ? undefined
|
|
349
|
+
: '게이트는 승인되지 않은 쪽으로 판정합니다 — 남길 파일 하나만 두고 사본을 지우세요.');
|
|
350
|
+
add('spec chain', 'PASS', orphaned.length === 0
|
|
351
|
+
? '승인된 스펙의 상위가 모두 approved입니다'
|
|
352
|
+
: `상위가 approved가 아닌 승인 스펙 ${orphaned.length}건: ${orphaned.map((s) => s.id).slice(0, 5).join(', ')}`);
|
|
353
|
+
}
|
|
354
|
+
catch { /* an unreadable corpus is doctor's other checks' business, not this one's */ }
|
|
355
|
+
}
|
|
356
|
+
// @implements A-SPEC-176
|
|
357
|
+
// `list()` drops any file it cannot parse, which is correct for a gate but leaves the author
|
|
358
|
+
// reading "that spec does not exist" while looking at it — measured with a CRLF-saved A-SPEC, whose
|
|
359
|
+
// approved anchor then denied every write. WARN, not FAIL: this changes no enforcement, and a FAIL
|
|
360
|
+
// on something that changes nothing teaches an operator to stop reading doctor at all.
|
|
361
|
+
if (target) {
|
|
362
|
+
const { unreadableSpecFiles } = require('../spec/spec-store');
|
|
363
|
+
const unreadable = unreadableSpecFiles(path.join(target, '.ax', 'specs'));
|
|
364
|
+
add('spec readability', unreadable.length === 0 ? 'PASS' : 'WARN', unreadable.length === 0
|
|
365
|
+
? '스펙 디렉터리의 모든 .md가 파싱됩니다'
|
|
366
|
+
: `파싱되지 않아 스펙으로 취급되지 않는 파일 ${unreadable.length}건: ${unreadable.slice(0, 5).join(', ')}`, unreadable.length === 0 ? undefined : '해당 파일의 프론트매터를 확인하세요 — 존재하지만 게이트에는 보이지 않습니다.');
|
|
367
|
+
}
|
|
368
|
+
// @implements A-SPEC-178
|
|
369
|
+
// Two checks that report the wiring a USER will meet, not what this process happens to see.
|
|
370
|
+
//
|
|
371
|
+
// Measured 2026-08-13 in holmes-kit's own workspace: `environment` said `HOLMES_APPROVAL=set`
|
|
372
|
+
// (true of doctor's process) while `spec_approve` over MCP refused fail-closed, because the
|
|
373
|
+
// variable lives in `.claude/settings.local.json` and the server is spawned from `.mcp.json`.
|
|
374
|
+
// And `target wiring` said PASS while the matcher was `Bash|Write|Edit` against a shipped default
|
|
375
|
+
// of `.*` — the matcher decides whether the hook is CALLED, so a tool whose name does not match
|
|
376
|
+
// never reaches the correct decision inside it.
|
|
377
|
+
//
|
|
378
|
+
// Deliberately NOT probed by spawning a server: that child would inherit doctor's environment and
|
|
379
|
+
// answer "reachable" no matter what Claude Code does. An inference dressed as a measurement is the
|
|
380
|
+
// defect being fixed; a better-looking one is not the cure. Only decidable facts are stated.
|
|
381
|
+
if (target) {
|
|
382
|
+
let mcpApproval = false;
|
|
383
|
+
try {
|
|
384
|
+
const raw = fs.readFileSync(path.join(target, '.mcp.json'), 'utf8');
|
|
385
|
+
const env = JSON.parse(raw)
|
|
386
|
+
.mcpServers?.[init_1.SERVER_NAME]?.env;
|
|
387
|
+
mcpApproval = !!env && 'HOLMES_APPROVAL' in env;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
mcpApproval = false;
|
|
391
|
+
} // absent or unreadable is "not guaranteed", which is the point
|
|
392
|
+
const procApproval = !!process.env.HOLMES_APPROVAL;
|
|
393
|
+
if (mcpApproval) {
|
|
394
|
+
add('approval channel', 'PASS', '승인 변수가 .mcp.json의 서버 환경에 있습니다 — spec_approve가 도달합니다');
|
|
395
|
+
}
|
|
396
|
+
else if (!procApproval) {
|
|
397
|
+
// A project that has not started approving is healthy. Warning here would put a permanent line
|
|
398
|
+
// on every install, and a line that is always there is one nobody reads (REQ-153's judgement).
|
|
399
|
+
add('approval channel', 'PASS', '승인 변수가 설정되어 있지 않습니다 — 승인이 필요할 때 설정하세요');
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
add('approval channel', 'WARN', '이 프로세스에는 HOLMES_APPROVAL이 있으나 .mcp.json의 서버 환경에는 없습니다 — spec_approve는 서버 환경을 읽습니다', 'settings.local.json의 env가 MCP 서버까지 전달된다는 보장은 없습니다. 그 변수를 설정한 셸에서 Claude Code를 띄우면 자식 서버가 상속합니다.');
|
|
403
|
+
}
|
|
404
|
+
// @implements A-SPEC-193 — 배선된 하네스마다 그 하네스의 배선을 검사한다. 배선되지 않은
|
|
405
|
+
// 하네스는 진단하지 않는다(없는 것을 결함이라 부르면 doctor 가 소음이 된다).
|
|
406
|
+
const agyHooks = path.join(target, '.agents', 'hooks.json');
|
|
407
|
+
if (fs.existsSync(agyHooks)) {
|
|
408
|
+
try {
|
|
409
|
+
const h = JSON.parse(fs.readFileSync(agyHooks, 'utf8'));
|
|
410
|
+
const entry = h['holmes-kit'];
|
|
411
|
+
const pre = entry?.PreToolUse?.[0];
|
|
412
|
+
const preCmd = pre?.hooks?.[0]?.command ?? '';
|
|
413
|
+
const stopCmd = entry?.Stop?.[0]?.command ?? '';
|
|
414
|
+
const stale = [preCmd, stopCmd].filter((c) => {
|
|
415
|
+
const p2 = (0, settings_merge_1.hookScriptPath)(c);
|
|
416
|
+
return !p2 || !fs.existsSync(p2) || !path.resolve(p2).startsWith(path.resolve(packageRoot) + path.sep);
|
|
417
|
+
});
|
|
418
|
+
if (entry === undefined) {
|
|
419
|
+
add('antigravity wiring', 'WARN', `${agyHooks} 에 holmes-kit 항목이 없습니다`, 'holmes-kit init --target <dir> --agent antigravity');
|
|
420
|
+
}
|
|
421
|
+
else if (stale.length > 0) {
|
|
422
|
+
add('antigravity wiring', 'FAIL', `이 설치본을 가리키지 않는 명령: ${stale.join(' | ')}`, 'holmes-kit init --target <dir> --agent antigravity --force 로 절대 경로를 갱신하십시오.');
|
|
423
|
+
}
|
|
424
|
+
else if ((pre?.matcher ?? '') !== '*') {
|
|
425
|
+
// 매처가 좁으면 그 밖의 도구가 게이트를 지나간다 — 조용한 구멍이므로 말한다.
|
|
426
|
+
add('antigravity wiring', 'WARN', `PreToolUse 매처가 '${pre?.matcher ?? ''}' 입니다 — '*' 가 아니면 그 밖의 도구는 게이트에 닿지 않습니다`, "matcher 를 '*' 로 되돌리십시오.");
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
add('antigravity wiring', 'PASS', 'PreToolUse·Stop 이 이 설치본을 가리킵니다');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
add('antigravity wiring', 'FAIL', `${agyHooks} 를 읽을 수 없습니다(JSON 아님)`, '파일을 고치거나 지우고 다시 배선하십시오.');
|
|
434
|
+
}
|
|
435
|
+
// 스킬이 그 하네스에 보이는가 — 링크가 끊기면 스킬은 조용히 사라진다.
|
|
436
|
+
const agySkills = path.join(target, '.agents', 'skills');
|
|
437
|
+
add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills} 가 스킬을 가리킵니다` : `${agySkills} 가 없습니다 — 이 하네스는 스킬을 보지 못합니다`, fs.existsSync(agySkills) ? undefined : `${path.join('..', '.claude', 'skills')} 로 링크하거나 복사하십시오.`);
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const sp = wiredSettingsPath(target);
|
|
441
|
+
const st = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
442
|
+
const wired = (st.hooks?.PreToolUse ?? [])
|
|
443
|
+
.filter((g) => (g.hooks ?? []).some((h) => (0, settings_merge_1.isHolmesCommand)(h.command ?? '')))
|
|
444
|
+
.map((g) => g.matcher ?? '');
|
|
445
|
+
const drifted = wired.filter((m) => m !== init_1.MATCHERS.guardrail);
|
|
446
|
+
add('hook matcher', drifted.length === 0 ? 'PASS' : 'WARN', drifted.length === 0
|
|
447
|
+
? `배선된 매처가 출하 기본값과 같습니다 (${init_1.MATCHERS.guardrail})`
|
|
448
|
+
: `배선 ${drifted.join(' | ')} / 출하 기본값 ${init_1.MATCHERS.guardrail} — 매처에 걸리지 않는 도구는 훅에 도달하지 않습니다`, drifted.length === 0 ? undefined : 'holmes-kit init --target <dir> --force 로 갱신하고 Claude Code를 재시작하세요.');
|
|
449
|
+
}
|
|
450
|
+
catch { /* an unreadable settings file is `target wiring`'s business, not this check's */ }
|
|
451
|
+
}
|
|
452
|
+
add('gate blind spots', 'PASS', (0, blind_spots_1.blindSpotSummary)());
|
|
453
|
+
add('environment', 'PASS', present);
|
|
454
|
+
return checks;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* @implements A-SPEC-192 (round 9)
|
|
458
|
+
* Temp cwds doctor creates must not outlive doctor, including on Ctrl-C. `finish()` is unreachable
|
|
459
|
+
* on signal death, so an interrupted run stranded one directory per run — measured 14 of 14
|
|
460
|
+
* interrupted runs, at BOTH mkdtemp sites (the gate probe's and the handshake's).
|
|
461
|
+
*/
|
|
462
|
+
const DOCTOR_TEMP_DIRS = new Set();
|
|
463
|
+
let signalHooked = false;
|
|
464
|
+
function cleanupOnSignal(dir) {
|
|
465
|
+
DOCTOR_TEMP_DIRS.add(dir);
|
|
466
|
+
if (signalHooked)
|
|
467
|
+
return;
|
|
468
|
+
signalHooked = true;
|
|
469
|
+
const sweep = () => {
|
|
470
|
+
for (const d of DOCTOR_TEMP_DIRS) {
|
|
471
|
+
try {
|
|
472
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
473
|
+
}
|
|
474
|
+
catch { /* tmp cleaner's job */ }
|
|
475
|
+
}
|
|
476
|
+
DOCTOR_TEMP_DIRS.clear();
|
|
477
|
+
};
|
|
478
|
+
process.on('exit', sweep);
|
|
479
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
480
|
+
process.on(sig, () => { sweep(); process.exit(130); });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Drive a real MCP stdio handshake: initialize -> initialized -> tools/list, with a timeout.
|
|
485
|
+
* Async by necessity — the protocol is a ROUND TRIP, so a spawnSync that writes everything at once
|
|
486
|
+
* and closes stdin makes the server exit before answering (a false FAIL this check produced on its
|
|
487
|
+
* very first run against a healthy server).
|
|
488
|
+
*/
|
|
489
|
+
function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
|
|
490
|
+
return new Promise((resolve) => {
|
|
491
|
+
const mcpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-mcp-'));
|
|
492
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [path.join(packageRoot, 'bin', 'holmes-mcp.js')], { stdio: ['pipe', 'pipe', 'pipe'], cwd: mcpCwd });
|
|
493
|
+
let pending = ''; // the unterminated tail of the stdout stream, never the whole history
|
|
494
|
+
let err = '';
|
|
495
|
+
let done = false;
|
|
496
|
+
let initialized = false; // LATCH (round-9): the second leg was re-sent once per stdout CHUNK,
|
|
497
|
+
// because the trigger tested the CUMULATIVE buffer. A strict server
|
|
498
|
+
// answered the duplicate tools/list with an error and doctor reported
|
|
499
|
+
// FAIL — with 'reinstall' guidance — on a perfectly healthy install.
|
|
500
|
+
const finish = (level, detail, fix) => {
|
|
501
|
+
if (done)
|
|
502
|
+
return;
|
|
503
|
+
done = true;
|
|
504
|
+
clearTimeout(timer);
|
|
505
|
+
// Escalate (round-9): SIGTERM alone leaves a wedged server running with doctor's pipes open.
|
|
506
|
+
child.kill();
|
|
507
|
+
// round-10: an unref'd timer never fires in a doctor run that ends in ~0.4s, so the escalation
|
|
508
|
+
// was decoration. The timer holds the loop for its 500ms and is cleared the moment the child
|
|
509
|
+
// actually exits, so a healthy run pays nothing.
|
|
510
|
+
const hard = setTimeout(() => { try {
|
|
511
|
+
child.kill('SIGKILL');
|
|
512
|
+
}
|
|
513
|
+
catch { /* already gone */ } }, 500);
|
|
514
|
+
child.once('exit', () => clearTimeout(hard));
|
|
515
|
+
child.stdout.destroy();
|
|
516
|
+
child.stderr.destroy();
|
|
517
|
+
child.stdin.destroy();
|
|
518
|
+
child.unref?.();
|
|
519
|
+
// Cleanup HERE, not on 'close' — doctor's process can exit before a close handler runs,
|
|
520
|
+
// stranding the isolated cwd (round-6 §7e measured the leak). A signal death still strands it;
|
|
521
|
+
// `cleanupOnSignal` below covers that (round-9).
|
|
522
|
+
try {
|
|
523
|
+
fs.rmSync(mcpCwd, { recursive: true, force: true });
|
|
524
|
+
}
|
|
525
|
+
catch { /* tmp cleaner's job */ }
|
|
526
|
+
resolve({ name: 'MCP server', level, detail, fix });
|
|
527
|
+
};
|
|
528
|
+
cleanupOnSignal(mcpCwd);
|
|
529
|
+
// EPIPE arrives as an ASYNC 'error' event, not a synchronous throw — the try/catch below never
|
|
530
|
+
// sees it, and an unhandled stream error would crash doctor itself (round-1 REQ-192).
|
|
531
|
+
child.stdin.on('error', () => { });
|
|
532
|
+
// Fast path (round-4): a dead-on-arrival server made doctor (and its tests) sit out the full
|
|
533
|
+
// 15s timeout — the child's stdio closing IS the answer.
|
|
534
|
+
child.on('close', () => {
|
|
535
|
+
// round-10: a server whose last line lacked a trailing newline was reported as 'died before
|
|
536
|
+
// answering' although it had answered. Flush what is pending before judging.
|
|
537
|
+
const tail = pending.trim();
|
|
538
|
+
pending = '';
|
|
539
|
+
if (tail.startsWith('{')) {
|
|
540
|
+
try {
|
|
541
|
+
const msg = JSON.parse(tail);
|
|
542
|
+
const tools = msg.result?.tools;
|
|
543
|
+
if (Array.isArray(tools) && tools.length > 0) {
|
|
544
|
+
finish('PASS', `handshake ok, ${tools.length} tools advertised`);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch { /* not a complete message after all */ }
|
|
549
|
+
}
|
|
550
|
+
finish('FAIL', `MCP server exited before answering (stderr: ${err.slice(0, 160)})`, 'The MCP server did not start. A missing tree-sitter grammar is the usual cause (see above).');
|
|
551
|
+
});
|
|
552
|
+
const send = (o) => { try {
|
|
553
|
+
child.stdin.write(JSON.stringify(o) + '\n');
|
|
554
|
+
}
|
|
555
|
+
catch { /* server gone */ } };
|
|
556
|
+
const timer = setTimeout(() => finish('FAIL', `no tools/list response within ${timeoutMs}ms (stderr: ${err.slice(0, 160)})`, 'The MCP server did not start. A missing tree-sitter grammar is the usual cause (see above).'), timeoutMs);
|
|
557
|
+
// Line-oriented and stateful. The old shape re-scanned the whole accumulated buffer on every
|
|
558
|
+
// chunk (quadratic, and it re-fired past triggers) and judged `tools` by
|
|
559
|
+
// `typeof result.tools.length === 'number'` — which a NON-ARRAY or an EMPTY list satisfies, so a
|
|
560
|
+
// server advertising nothing passed (round-9).
|
|
561
|
+
child.stdout.on('data', (d) => {
|
|
562
|
+
pending += String(d);
|
|
563
|
+
// round-10: slicing from the FRONT cut a large tools/list answer mid-JSON, so a healthy server
|
|
564
|
+
// with many tools parsed as garbage and waited out the full timeout with a tree-sitter
|
|
565
|
+
// misdiagnosis. A protocol line is dropped WHOLE (and said so), never trimmed into nonsense.
|
|
566
|
+
if (pending.length > 4_000_000 && !pending.includes('\n')) {
|
|
567
|
+
pending = '';
|
|
568
|
+
finish('FAIL', 'MCP server sent a single line larger than 4MB without a newline', 'The server is not speaking line-delimited JSON-RPC — check the installed version.');
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const lines = pending.split('\n');
|
|
572
|
+
pending = lines.pop() ?? '';
|
|
573
|
+
for (const line of lines) {
|
|
574
|
+
if (!line.trim())
|
|
575
|
+
continue;
|
|
576
|
+
let msg;
|
|
577
|
+
try {
|
|
578
|
+
msg = JSON.parse(line);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
continue;
|
|
582
|
+
} // 서버의 로그 줄은 프로토콜이 아니다
|
|
583
|
+
if (msg.error && (msg.id === 1 || msg.id === 2)) {
|
|
584
|
+
finish('FAIL', `MCP server answered ${msg.id === 1 ? 'initialize' : 'tools/list'} with an error: ${String(msg.error.message ?? msg.error.code)}`, 'The server started but refused the handshake — check the installed version against this package.');
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// A server may SEND requests of its own; one that happens to use id 2 is not our answer.
|
|
588
|
+
// Only a message carrying `result` (or `error`, handled above) is a response.
|
|
589
|
+
if (msg.result === undefined)
|
|
590
|
+
continue;
|
|
591
|
+
if (msg.id === 2 || msg.result?.tools !== undefined) {
|
|
592
|
+
const tools = msg.result?.tools;
|
|
593
|
+
if (!Array.isArray(tools)) {
|
|
594
|
+
finish('FAIL', `tools/list did not answer with a list (got ${typeof tools})`, 'The server answered, but not with a tool list — check the installed version against this package.');
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (tools.length === 0) {
|
|
598
|
+
finish('FAIL', 'handshake ok but the server advertises ZERO tools', 'A server with no tools cannot serve this project — reinstall or upgrade holmes-kit.');
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
finish('PASS', `handshake ok, ${tools.length} tools advertised`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (!initialized && msg.result?.serverInfo !== undefined) {
|
|
605
|
+
initialized = true;
|
|
606
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
607
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
child.stderr.on('data', (d) => { err = (err + String(d)).slice(-4096); });
|
|
612
|
+
child.on('error', (e) => finish('FAIL', `could not spawn the MCP server: ${e.message}`, 'Check the install — the bin shim must resolve to this package.'));
|
|
613
|
+
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'holmes-doctor', version: '1' } } });
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function formatChecks(checks) {
|
|
617
|
+
const lines = checks.map((c) => {
|
|
618
|
+
const head = `${c.level.padEnd(4)} ${c.name} — ${c.detail}`;
|
|
619
|
+
return c.fix && c.level !== 'PASS' ? `${head}\n fix: ${c.fix}` : head;
|
|
620
|
+
});
|
|
621
|
+
const fails = checks.filter((c) => c.level === 'FAIL').length;
|
|
622
|
+
const warns = checks.filter((c) => c.level === 'WARN').length;
|
|
623
|
+
lines.push(`\n${checks.length - fails - warns} pass, ${warns} warn, ${fails} fail`);
|
|
624
|
+
return lines.join('\n');
|
|
625
|
+
}
|