novahiz 0.3.4 → 0.3.6
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/README.md +98 -64
- package/adapters/claude/agent/novahiz.md +23 -0
- package/adapters/opencode/instructions.md +3 -3
- package/adapters/opencode/novahiz.ts +29 -8
- package/catalog/categories.json +60 -1
- package/catalog/providers.json +2 -2
- package/dist/cli.js +9 -3
- package/dist/commands/doctor.js +457 -15
- package/dist/commands/hook.js +204 -0
- package/dist/commands/init.js +53 -2
- package/dist/gate-repair.js +5 -2
- package/dist/hook.js +176 -0
- package/dist/impeccable.js +17 -0
- package/dist/memory.js +17 -1
- package/dist/spec.js +39 -7
- package/docs/CONFIGURATION.md +2 -2
- package/docs/HARNESSES.md +25 -6
- package/docs/INSTALL.md +3 -3
- package/docs/PLUGIN.md +9 -1
- package/docs/PROVIDERS.md +1 -1
- package/docs/ROADMAPS.md +6 -4
- package/install/bootstrap.mjs +4 -3
- package/install/hooks.mjs +171 -0
- package/install/install.mjs +174 -29
- package/install/lib.mjs +7 -1
- package/install/prompt.mjs +30 -0
- package/install/uninstall.mjs +1 -0
- package/novahiz.config.example.json +1 -1
- package/package.json +3 -1
- package/skills/memory/SKILL.md +1 -1
- package/skills/novahiz-converge/SKILL.md +10 -0
- package/skills/novahiz-init/SKILL.md +6 -0
- package/skills/novahiz-memory/SKILL.md +1 -1
- package/skills/novahiz-release/SKILL.md +59 -0
- package/src/cli.ts +9 -3
- package/src/commands/doctor.ts +473 -15
- package/src/commands/hook.ts +238 -0
- package/src/commands/init.ts +56 -2
- package/src/gate-repair.ts +5 -2
- package/src/gate.ts +1 -1
- package/src/hook.ts +203 -0
- package/src/impeccable.ts +18 -0
- package/src/memory.ts +19 -1
- package/src/spec.ts +38 -7
package/dist/commands/doctor.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { dbPathFor, emit } from "./context.js";
|
|
6
|
-
import { loadSpec, NovahizHome } from "../spec.js";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { dbPathFor, emit, flagOn } from "./context.js";
|
|
6
|
+
import { expandHome, loadSpec, NovahizHome, packageRoot } from "../spec.js";
|
|
7
7
|
import { openDb, SCHEMA_VERSION } from "../db.js";
|
|
8
8
|
import { loadInstalledSkills } from "../catalog.js";
|
|
9
|
+
import { findContextFile } from "../impeccable.js";
|
|
9
10
|
import { evaluateGate } from "../gate.js";
|
|
10
11
|
import { DEFAULT_LIMIT_CHARS, DEFAULT_LIMIT_LINES, MEMORY_DIR } from "../memory.js";
|
|
11
12
|
import * as ui from "../render.js";
|
|
@@ -18,6 +19,251 @@ function hasCommand(name) {
|
|
|
18
19
|
export function grantsQuestionIn(agentFile) {
|
|
19
20
|
return /^\s*question:\s*allow\s*$/m.test(agentFile);
|
|
20
21
|
}
|
|
22
|
+
// ── MCP configuration health (0.3.6) ───────────────────────────────────────
|
|
23
|
+
// Three real incidents motivated this: a purged context7 npx shim, a broken
|
|
24
|
+
// graft install, and a security-mcp running without its auth secret. All were
|
|
25
|
+
// only discovered mid-session. These checks are read-only and non-blocking —
|
|
26
|
+
// the CLI must stay usable on machines without an OpenCode install.
|
|
27
|
+
/** JSONC → JSON: strips // and block comments plus trailing commas,
|
|
28
|
+
* string-aware so URLs and globs inside values survive. */
|
|
29
|
+
export function parseJsonc(text) {
|
|
30
|
+
let out = "";
|
|
31
|
+
let inString = false;
|
|
32
|
+
let escaped = false;
|
|
33
|
+
for (let i = 0; i < text.length; i++) {
|
|
34
|
+
const ch = text[i];
|
|
35
|
+
if (inString) {
|
|
36
|
+
out += ch;
|
|
37
|
+
if (escaped)
|
|
38
|
+
escaped = false;
|
|
39
|
+
else if (ch === "\\")
|
|
40
|
+
escaped = true;
|
|
41
|
+
else if (ch === '"')
|
|
42
|
+
inString = false;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === '"') {
|
|
46
|
+
inString = true;
|
|
47
|
+
out += ch;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
51
|
+
while (i < text.length && text[i] !== "\n")
|
|
52
|
+
i++;
|
|
53
|
+
out += "\n";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
57
|
+
i += 2;
|
|
58
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
59
|
+
i++;
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out += ch;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(out);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Trailing comma pass, only when the strict parse already failed.
|
|
70
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Resolve a command word or path to a full spawnable path, else null.
|
|
74
|
+
* PATH lookups must return the real file: CreateProcess appends .exe for a
|
|
75
|
+
* bare name but refuses .cmd/.bat (npx), so the probe needs the full path. */
|
|
76
|
+
function resolveExecutable(cmd) {
|
|
77
|
+
const looksPath = cmd.includes("/") || cmd.includes("\\") || cmd.startsWith("~");
|
|
78
|
+
if (looksPath) {
|
|
79
|
+
const full = expandHome(cmd);
|
|
80
|
+
return existsSync(full) ? full : null;
|
|
81
|
+
}
|
|
82
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
83
|
+
const result = spawnSync(probe, [cmd], { encoding: "utf8", shell: false });
|
|
84
|
+
if (result.status !== 0)
|
|
85
|
+
return null;
|
|
86
|
+
const lines = String(result.stdout ?? "")
|
|
87
|
+
.split(/\r?\n/)
|
|
88
|
+
.map((line) => line.trim())
|
|
89
|
+
.filter((line) => line.length > 0);
|
|
90
|
+
if (lines.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
if (process.platform === "win32") {
|
|
93
|
+
// `where` also matches extensionless Git-Bash scripts (nodejs\npx,
|
|
94
|
+
// flutter\bin\dart) that CreateProcess refuses — prefer a real binary.
|
|
95
|
+
return lines.find((line) => /\.(exe|cmd|bat|com)$/i.test(line)) ?? null;
|
|
96
|
+
}
|
|
97
|
+
return lines[0];
|
|
98
|
+
}
|
|
99
|
+
/** Pure per-server config problems (exported for tests). */
|
|
100
|
+
export function mcpEntryProblems(name, entry, catalogPackages, env) {
|
|
101
|
+
const problems = [];
|
|
102
|
+
const notes = [];
|
|
103
|
+
if (entry.enabled === false)
|
|
104
|
+
return { problems, notes };
|
|
105
|
+
const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
|
|
106
|
+
if (command.length === 0) {
|
|
107
|
+
problems.push(`${name}: no command`);
|
|
108
|
+
return { problems, notes };
|
|
109
|
+
}
|
|
110
|
+
if (!resolveExecutable(command[0]))
|
|
111
|
+
problems.push(`${name}: executable not found (${command[0]})`);
|
|
112
|
+
// ${VAR} references must resolve in the environment the CLI runs with.
|
|
113
|
+
for (const match of JSON.stringify(entry).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
114
|
+
if (!env[match[1]])
|
|
115
|
+
problems.push(`${name}: env ${match[1]} not set`);
|
|
116
|
+
}
|
|
117
|
+
const configPkg = command.find((arg) => /@\d/.test(arg) && !arg.startsWith("-"));
|
|
118
|
+
const catalogPkg = catalogPackages.get(name);
|
|
119
|
+
if (catalogPkg && configPkg && configPkg !== catalogPkg) {
|
|
120
|
+
problems.push(`${name}: pinned to ${configPkg}, catalog pins ${catalogPkg}`);
|
|
121
|
+
}
|
|
122
|
+
else if (catalogPkg && !configPkg) {
|
|
123
|
+
// A bare shim can drift from the pinned catalog version — surface, don't fail.
|
|
124
|
+
const catalogBase = catalogPkg.slice(0, catalogPkg.lastIndexOf("@")) || catalogPkg;
|
|
125
|
+
if (command[0] !== "npx" && command[0] !== catalogBase) {
|
|
126
|
+
problems.push(`${name}: runs ${command[0]}, catalog expects ${catalogBase}`);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
notes.push(`${name}: unpinned (catalog: ${catalogPkg})`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (command[0] === "npx") {
|
|
133
|
+
if (!command.includes("-y"))
|
|
134
|
+
notes.push(`${name}: npx without -y`);
|
|
135
|
+
const pkg = command.slice(1).find((arg) => !arg.startsWith("-"));
|
|
136
|
+
if (pkg && !/@\d/.test(pkg))
|
|
137
|
+
notes.push(`${name}: npx package unpinned (${pkg})`);
|
|
138
|
+
}
|
|
139
|
+
return { problems, notes };
|
|
140
|
+
}
|
|
141
|
+
/** Kill a probed server and its whole tree (cmd.exe → npx → node). */
|
|
142
|
+
function killTree(child) {
|
|
143
|
+
if (child.pid === undefined) {
|
|
144
|
+
child.kill();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (process.platform === "win32") {
|
|
148
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { encoding: "utf8" });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
child.kill("SIGTERM");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** One live JSON-RPC `initialize` probe per server. Opt-in via --deep, up to
|
|
155
|
+
* 10 s per server, all servers probed in parallel. stdin is written but kept
|
|
156
|
+
* OPEN: mcp-cron reads stdin asynchronously and exits cleanly (status 0,
|
|
157
|
+
* no output) if the pipe closes before it starts reading. */
|
|
158
|
+
export function probeMcpServer(entry) {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const command = Array.isArray(entry.command) ? entry.command.map(String) : [];
|
|
161
|
+
if (command.length === 0) {
|
|
162
|
+
resolve({ ok: false, detail: "no command" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const resolved = resolveExecutable(command[0]);
|
|
166
|
+
if (!resolved) {
|
|
167
|
+
resolve({ ok: false, detail: `executable not found (${command[0]})` });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const args = command.slice(1);
|
|
171
|
+
const init = `${JSON.stringify({
|
|
172
|
+
jsonrpc: "2.0",
|
|
173
|
+
id: 1,
|
|
174
|
+
method: "initialize",
|
|
175
|
+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "novahiz-doctor", version: "0" } }
|
|
176
|
+
})}\n`;
|
|
177
|
+
const env = { ...process.env, ...(entry.environment ?? {}) };
|
|
178
|
+
const started = Date.now();
|
|
179
|
+
// Node refuses to exec .cmd/.bat directly (EINVAL). Go through ComSpec
|
|
180
|
+
// verbatim: the whole `"path" args` line gets one extra quote pair, which
|
|
181
|
+
// `cmd /d /s /c` strips back off. No shell flag — tests/exec.test.ts bans
|
|
182
|
+
// the shell option repo-wide — and no Node re-quoting layer
|
|
183
|
+
// (windowsVerbatimArguments).
|
|
184
|
+
const shellRequired = process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved);
|
|
185
|
+
const quote = (arg) => (/\s|"/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg);
|
|
186
|
+
const inner = [`"${resolved}"`, ...args.map(quote)].join(" ");
|
|
187
|
+
let child;
|
|
188
|
+
try {
|
|
189
|
+
child = shellRequired
|
|
190
|
+
? spawn(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `"${inner}"`], {
|
|
191
|
+
env,
|
|
192
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
193
|
+
windowsVerbatimArguments: true
|
|
194
|
+
})
|
|
195
|
+
: spawn(resolved, args, { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
resolve({ ok: false, detail: `spawn failed: ${error.message}` });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let settled = false;
|
|
202
|
+
let buffer = "";
|
|
203
|
+
let errBuffer = "";
|
|
204
|
+
const settle = (ok, detail) => {
|
|
205
|
+
if (settled)
|
|
206
|
+
return;
|
|
207
|
+
settled = true;
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
killTree(child);
|
|
210
|
+
resolve({ ok, detail });
|
|
211
|
+
};
|
|
212
|
+
const timer = setTimeout(() => {
|
|
213
|
+
settle(false, `no initialize response in ${Date.now() - started} ms (timeout)`);
|
|
214
|
+
}, 10_000);
|
|
215
|
+
child.stdout?.on("data", (chunk) => {
|
|
216
|
+
buffer += String(chunk);
|
|
217
|
+
for (const line of buffer.split(/\r?\n/)) {
|
|
218
|
+
const trimmed = line.trim();
|
|
219
|
+
if (!trimmed.startsWith("{"))
|
|
220
|
+
continue;
|
|
221
|
+
try {
|
|
222
|
+
const message = JSON.parse(trimmed);
|
|
223
|
+
const elapsed = Date.now() - started;
|
|
224
|
+
if (message.result?.serverInfo) {
|
|
225
|
+
const info = message.result.serverInfo;
|
|
226
|
+
settle(true, `${info.name ?? "?"}${info.version ? `@${info.version}` : ""} (${elapsed} ms)`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (message.id === 1) {
|
|
230
|
+
settle(true, `initialize acknowledged (${elapsed} ms)`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (message.error) {
|
|
234
|
+
settle(false, `initialize error: ${JSON.stringify(message.error).slice(0, 120)}`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// stdout noise (logs) — keep scanning.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Keep memory bounded on chatty servers.
|
|
243
|
+
if (buffer.length > 262_144)
|
|
244
|
+
buffer = buffer.slice(-65_536);
|
|
245
|
+
});
|
|
246
|
+
child.stderr?.on("data", (chunk) => {
|
|
247
|
+
if (errBuffer.length < 16_384)
|
|
248
|
+
errBuffer += String(chunk);
|
|
249
|
+
});
|
|
250
|
+
child.on("error", (error) => {
|
|
251
|
+
settle(false, `spawn failed: ${error.message}`);
|
|
252
|
+
});
|
|
253
|
+
child.on("close", (code) => {
|
|
254
|
+
if (settled)
|
|
255
|
+
return;
|
|
256
|
+
const elapsed = Date.now() - started;
|
|
257
|
+
const tail = errBuffer.trim() ? ` — ${errBuffer.trim().slice(0, 160)}` : "";
|
|
258
|
+
settle(false, `exited (code ${code}) after ${elapsed} ms without responding${tail}`);
|
|
259
|
+
});
|
|
260
|
+
child.stdin?.on("error", () => {
|
|
261
|
+
// Server closed its side first — the close handler reports it.
|
|
262
|
+
});
|
|
263
|
+
child.stdin?.write(init);
|
|
264
|
+
// Intentionally no stdin.end(): keep the pipe open until we have a reply.
|
|
265
|
+
});
|
|
266
|
+
}
|
|
21
267
|
function referencedSkills(spec) {
|
|
22
268
|
const ids = new Set();
|
|
23
269
|
for (const category of spec.categories) {
|
|
@@ -37,7 +283,128 @@ function referencedSkills(spec) {
|
|
|
37
283
|
// index individually and are referenced through categories and rules above.
|
|
38
284
|
return [...ids].sort();
|
|
39
285
|
}
|
|
40
|
-
|
|
286
|
+
/** Referenced-skills row. Exported for tests.
|
|
287
|
+
* `indexSkills` is null when the index is unreadable — the index check already
|
|
288
|
+
* reports that, so the row stays quiet instead of double-failing.
|
|
289
|
+
* Only skills the package ships (packageRoot/skills) can make a fresh install
|
|
290
|
+
* broken. The remaining references — the dart/eas/expo/flutter packs and
|
|
291
|
+
* impeccable — are optional delivery downloaded on request and are simply not
|
|
292
|
+
* present on a machine that never asked for them; the gate already degrades
|
|
293
|
+
* them to "not enforced". Blocking on them turned every healthy fresh install
|
|
294
|
+
* red (`missing from index: dart-...` right after novahiz-install --yes). */
|
|
295
|
+
export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
|
|
296
|
+
const referenced = referencedSkills(spec);
|
|
297
|
+
const absent = indexSkills ? referenced.filter((id) => !indexSkills.has(id)) : [];
|
|
298
|
+
const shipped = new Set();
|
|
299
|
+
const shippedDir = join(packageRootDir, "skills");
|
|
300
|
+
if (existsSync(shippedDir)) {
|
|
301
|
+
for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
|
|
302
|
+
if (entry.isDirectory())
|
|
303
|
+
shipped.add(entry.name);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const absentShipped = absent.filter((id) => shipped.has(id));
|
|
307
|
+
const absentPack = absent.length - absentShipped.length;
|
|
308
|
+
return {
|
|
309
|
+
id: "referenced",
|
|
310
|
+
label: "Referenced skills",
|
|
311
|
+
ok: absentShipped.length === 0,
|
|
312
|
+
detail: absent.length === 0
|
|
313
|
+
? `${referenced.length} present`
|
|
314
|
+
: absentShipped.length > 0
|
|
315
|
+
? `missing from index: ${absent.join(", ")}`
|
|
316
|
+
: `${referenced.length - absent.length} present, ${absentPack} pack skills not installed (novahiz-install --dart-skills --flutter-skills)`,
|
|
317
|
+
blocking: absentShipped.length > 0
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
|
|
321
|
+
* (root -> .agents/context -> docs/). Rows appear only when the impeccable
|
|
322
|
+
* skill is installed — the same opt-in visibility as the claude-* rows — and
|
|
323
|
+
* are never blocking: a missing record is a command to run
|
|
324
|
+
* (`/impeccable init`, `/impeccable document`), not an anomaly. Exported for tests. */
|
|
325
|
+
export function impeccableChecks(cwd, installed) {
|
|
326
|
+
if (!installed)
|
|
327
|
+
return [];
|
|
328
|
+
const rel = (path) => relative(cwd, path).split("\\").join("/");
|
|
329
|
+
const product = findContextFile(cwd, "PRODUCT.md");
|
|
330
|
+
const design = findContextFile(cwd, "DESIGN.md");
|
|
331
|
+
return [
|
|
332
|
+
{
|
|
333
|
+
id: "impeccable-context",
|
|
334
|
+
label: "Impeccable product context",
|
|
335
|
+
ok: product !== null,
|
|
336
|
+
detail: product ? `${rel(product)} present` : "no PRODUCT.md - run /impeccable init to capture audience, goals and constraints",
|
|
337
|
+
blocking: false
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "impeccable-design",
|
|
341
|
+
label: "Impeccable design record",
|
|
342
|
+
ok: design !== null,
|
|
343
|
+
detail: design ? `${rel(design)} present` : "no DESIGN.md - run /impeccable document to record an existing visual system",
|
|
344
|
+
blocking: false
|
|
345
|
+
}
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
/** Claude Code harness checks: hooks wiring, agent copy, skills and commands.
|
|
349
|
+
* Returns [] when no Claude config dir exists, so a machine without Claude
|
|
350
|
+
* Code never carries these rows. Exported for tests. */
|
|
351
|
+
export function claudeHarnessChecks(root, claudeDir) {
|
|
352
|
+
if (!existsSync(claudeDir))
|
|
353
|
+
return [];
|
|
354
|
+
const checks = [];
|
|
355
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
356
|
+
let hooksOk = false;
|
|
357
|
+
let hooksDetail = `${settingsPath} missing`;
|
|
358
|
+
if (existsSync(settingsPath)) {
|
|
359
|
+
try {
|
|
360
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
361
|
+
const groups = settings.hooks?.PreToolUse ?? [];
|
|
362
|
+
hooksOk = groups.some((group) => (group.hooks ?? []).some((handler) => {
|
|
363
|
+
const command = handler.command ?? "";
|
|
364
|
+
// Mirrors install/hooks.mjs isNovahizHandler: our handler points at
|
|
365
|
+
// the novahiz CLI and runs its hook subcommand. Do not test the path
|
|
366
|
+
// for "novahiz" alone — a custom NOVAHIZ_HOME (n4\home, /opt/gov)
|
|
367
|
+
// contains no such segment, and the doctor then reported a healthy
|
|
368
|
+
// install as unwired.
|
|
369
|
+
return command.includes("hook --harness") && (command.includes("novahiz") || command.includes("cli.ts"));
|
|
370
|
+
}));
|
|
371
|
+
hooksDetail = hooksOk ? "novahiz PreToolUse handler wired" : "no novahiz PreToolUse handler - rerun installer";
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
hooksDetail = `settings.json unreadable: ${error.message}`;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
checks.push({ id: "claude-hooks", label: "Claude hooks", ok: hooksOk, detail: hooksDetail, blocking: false });
|
|
378
|
+
const agentSource = join(root, "adapters", "claude", "agent", "novahiz.md");
|
|
379
|
+
const agentInstalled = join(claudeDir, "agents", "novahiz.md");
|
|
380
|
+
let agentOk = true;
|
|
381
|
+
let agentDetail = "no installed copy";
|
|
382
|
+
if (existsSync(agentSource) && existsSync(agentInstalled)) {
|
|
383
|
+
agentOk = readFileSync(agentSource, "utf8") === readFileSync(agentInstalled, "utf8");
|
|
384
|
+
agentDetail = agentOk ? "agent copy up to date" : "agent copy outdated, rerun installer then restart Claude Code";
|
|
385
|
+
}
|
|
386
|
+
checks.push({ id: "claude-agent", label: "Claude agent copy", ok: agentOk, detail: agentDetail, blocking: false });
|
|
387
|
+
const skillsDir = join(claudeDir, "skills");
|
|
388
|
+
let skillsOk = false;
|
|
389
|
+
let skillsDetail = `${skillsDir} missing`;
|
|
390
|
+
if (existsSync(skillsDir)) {
|
|
391
|
+
const count = readdirSync(skillsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(skillsDir, entry.name, "SKILL.md"))).length;
|
|
392
|
+
skillsOk = count > 0;
|
|
393
|
+
skillsDetail = `${count} skills with SKILL.md`;
|
|
394
|
+
}
|
|
395
|
+
checks.push({ id: "claude-skills", label: "Claude skills", ok: skillsOk, detail: skillsDetail, blocking: false });
|
|
396
|
+
const commandsDir = join(claudeDir, "commands");
|
|
397
|
+
let commandsOk = false;
|
|
398
|
+
let commandsDetail = `${commandsDir} missing`;
|
|
399
|
+
if (existsSync(commandsDir)) {
|
|
400
|
+
const count = readdirSync(commandsDir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).length;
|
|
401
|
+
commandsOk = count > 0;
|
|
402
|
+
commandsDetail = `${count} commands`;
|
|
403
|
+
}
|
|
404
|
+
checks.push({ id: "claude-commands", label: "Claude commands", ok: commandsOk, detail: commandsDetail, blocking: false });
|
|
405
|
+
return checks;
|
|
406
|
+
}
|
|
407
|
+
export async function commandDoctor(parsed) {
|
|
41
408
|
const root = NovahizHome();
|
|
42
409
|
const spec = loadSpec(root);
|
|
43
410
|
const checks = [];
|
|
@@ -53,18 +420,11 @@ export function commandDoctor(parsed) {
|
|
|
53
420
|
detail: index.available ? `${index.skills.size} skills` : "build/installed-skills.json unreadable, run novahiz sync",
|
|
54
421
|
blocking: true
|
|
55
422
|
});
|
|
56
|
-
|
|
57
|
-
const absent = index.available ? referenced.filter((id) => !index.skills.has(id)) : [];
|
|
58
|
-
checks.push({
|
|
59
|
-
id: "referenced",
|
|
60
|
-
label: "Referenced skills",
|
|
61
|
-
ok: absent.length === 0,
|
|
62
|
-
detail: absent.length === 0 ? `${referenced.length} present` : `missing from index: ${absent.join(", ")}`,
|
|
63
|
-
blocking: absent.length > 0
|
|
64
|
-
});
|
|
423
|
+
checks.push(referencedSkillsCheck(spec, index.available ? index.skills : null, packageRoot() ?? root));
|
|
65
424
|
// SKILL_CLI is intentionally empty: novahiz-web-extract replaced the external
|
|
66
425
|
// defuddle CLI on the research roadmap (see MEMORY.md). Keep the check so a
|
|
67
426
|
// future external dependency is wired here again.
|
|
427
|
+
const referenced = referencedSkills(spec);
|
|
68
428
|
const cliEntries = Object.entries(SKILL_CLI);
|
|
69
429
|
const missingCli = cliEntries
|
|
70
430
|
.filter(([skill]) => referenced.includes(skill) && !hasCommand(skill))
|
|
@@ -193,6 +553,88 @@ export function commandDoctor(parsed) {
|
|
|
193
553
|
: "agent harness copy does not allow question: pipeline cannot query";
|
|
194
554
|
}
|
|
195
555
|
checks.push({ id: "agent", label: "Agent harness copy", ok: agentOk, detail: agentDetail, blocking: false });
|
|
556
|
+
// ── MCP configuration health (0.3.6) ────────────────────────────────────
|
|
557
|
+
// Claude Code harness rows appear only when a Claude config dir exists.
|
|
558
|
+
const claudeDir = process.env.CLAUDE_CONFIG_DIR && process.env.CLAUDE_CONFIG_DIR.length > 0
|
|
559
|
+
? process.env.CLAUDE_CONFIG_DIR
|
|
560
|
+
: join(homedir(), ".claude");
|
|
561
|
+
checks.push(...claudeHarnessChecks(root, claudeDir));
|
|
562
|
+
// Impeccable rows appear only when the skill is installed (opt-in, like the
|
|
563
|
+
// claude-* rows) and only reflect this project's context files.
|
|
564
|
+
checks.push(...impeccableChecks(process.cwd(), index.available && index.skills.has("impeccable")));
|
|
565
|
+
const opencodeConfigPath = join(opencodeDir, "opencode.jsonc");
|
|
566
|
+
const catalogPackages = new Map();
|
|
567
|
+
for (const provider of spec.providers) {
|
|
568
|
+
if (provider.kind !== "mcp" || !provider.command)
|
|
569
|
+
continue;
|
|
570
|
+
const pkg = provider.command.find((arg) => /@\d/.test(arg) && !arg.startsWith("-"));
|
|
571
|
+
if (pkg)
|
|
572
|
+
catalogPackages.set(provider.id, pkg);
|
|
573
|
+
}
|
|
574
|
+
const disabledProviders = spec.config.providers?.disabled ?? [];
|
|
575
|
+
let mcpEntries = [];
|
|
576
|
+
const mcpProblems = [];
|
|
577
|
+
const mcpNotes = [];
|
|
578
|
+
if (!existsSync(opencodeConfigPath)) {
|
|
579
|
+
mcpNotes.push(`no ${opencodeConfigPath}`);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
try {
|
|
583
|
+
const document = parseJsonc(readFileSync(opencodeConfigPath, "utf8"));
|
|
584
|
+
const servers = document.mcp ?? document.mcpServers ?? {};
|
|
585
|
+
mcpEntries = Object.entries(servers).map(([name, entry]) => ({ name, entry: (entry ?? {}) }));
|
|
586
|
+
for (const { name, entry } of mcpEntries) {
|
|
587
|
+
const result = mcpEntryProblems(name, entry, catalogPackages, process.env);
|
|
588
|
+
mcpProblems.push(...result.problems);
|
|
589
|
+
mcpNotes.push(...result.notes);
|
|
590
|
+
}
|
|
591
|
+
const configured = new Set(mcpEntries.map(({ name }) => name));
|
|
592
|
+
for (const provider of spec.providers) {
|
|
593
|
+
if (provider.kind !== "mcp" || disabledProviders.includes(provider.id) || configured.has(provider.id))
|
|
594
|
+
continue;
|
|
595
|
+
mcpNotes.push(`${provider.id}: not in opencode.jsonc (registered by the plugin at startup)`);
|
|
596
|
+
}
|
|
597
|
+
if (!disabledProviders.includes("security") && !process.env.SECURITY_MCP_SHARED_SECRET) {
|
|
598
|
+
mcpProblems.push("security: SECURITY_MCP_SHARED_SECRET not set — security-mcp runs unauthenticated");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
mcpProblems.push(`opencode.jsonc unreadable: ${error.message}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
checks.push({
|
|
606
|
+
id: "mcp-config",
|
|
607
|
+
label: "MCP config",
|
|
608
|
+
ok: mcpProblems.length === 0,
|
|
609
|
+
detail: mcpProblems.length > 0
|
|
610
|
+
? mcpProblems.join("; ")
|
|
611
|
+
: mcpEntries.length === 0
|
|
612
|
+
? "no server entries"
|
|
613
|
+
: `${mcpEntries.length} servers${mcpNotes.length > 0 ? ` — ${mcpNotes.join("; ")}` : ""}`,
|
|
614
|
+
blocking: false
|
|
615
|
+
});
|
|
616
|
+
// Live probe: explicit opt-in, because npx cold caches can exceed the 10 s
|
|
617
|
+
// per-server budget and a false negative would be worse than no probe.
|
|
618
|
+
if (flagOn(parsed, "deep")) {
|
|
619
|
+
const targets = mcpEntries.filter(({ entry }) => entry.enabled !== false);
|
|
620
|
+
const results = await Promise.all(targets.map(async ({ name, entry }) => ({ name, probe: await probeMcpServer(entry) })));
|
|
621
|
+
const probes = [];
|
|
622
|
+
const probeFailures = [];
|
|
623
|
+
for (const { name, probe } of results) {
|
|
624
|
+
(probe.ok ? probes : probeFailures).push(`${name}: ${probe.detail}`);
|
|
625
|
+
}
|
|
626
|
+
checks.push({
|
|
627
|
+
id: "mcp-probe",
|
|
628
|
+
label: "MCP live probe",
|
|
629
|
+
ok: probeFailures.length === 0 && probes.length > 0,
|
|
630
|
+
detail: probeFailures.length > 0
|
|
631
|
+
? probeFailures.join("; ")
|
|
632
|
+
: probes.length > 0
|
|
633
|
+
? probes.join(", ")
|
|
634
|
+
: "no enabled servers to probe",
|
|
635
|
+
blocking: false
|
|
636
|
+
});
|
|
637
|
+
}
|
|
196
638
|
const failing = checks.filter((check) => !check.ok);
|
|
197
639
|
const blocking = failing.filter((check) => check.blocking);
|
|
198
640
|
const value = {
|