@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. 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
+ }