novahiz 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,217 +1,226 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { dbPathFor, emit, type Parsed } from "./context.ts";
6
- import { loadSpec, NovahizHome } from "../spec.ts";
7
- import { openDb, SCHEMA_VERSION } from "../db.ts";
8
- import { loadInstalledSkills } from "../catalog.ts";
9
- import { evaluateGate } from "../gate.ts";
10
- import { DEFAULT_LIMIT_CHARS, DEFAULT_LIMIT_LINES, MEMORY_DIR } from "../memory.ts";
11
- import * as ui from "../render.ts";
12
-
13
- type DoctorCheck = { id: string; label: string; ok: boolean; detail: string; blocking: boolean };
14
-
15
- const SKILL_CLI: Record<string, string> = {};
16
-
17
- function hasCommand(name: string): boolean {
18
- const probe = process.platform === "win32" ? "where" : "which";
19
- const result = spawnSync(probe, [name], { encoding: "utf8", shell: false });
20
- return result.status === 0;
21
- }
22
-
23
- export function grantsQuestionIn(agentFile: string): boolean {
24
- return /^\s*question:\s*allow\s*$/m.test(agentFile);
25
- }
26
-
27
- function referencedSkills(spec: ReturnType<typeof loadSpec>): string[] {
28
- const ids = new Set<string>();
29
- for (const category of spec.categories) {
30
- for (const skill of category.defaultSkills ?? []) ids.add(skill);
31
- for (const step of category.roadmap?.steps ?? []) {
32
- for (const skill of step.requireSkills ?? []) ids.add(skill);
33
- }
34
- }
35
- for (const rule of spec.rules) for (const skill of rule.require) ids.add(skill);
36
- for (const provider of spec.providers) if (provider.kind === "skill") ids.add(provider.id);
37
- return [...ids].sort();
38
- }
39
-
40
- export function commandDoctor(parsed: Parsed): void {
41
- const root = NovahizHome();
42
- const spec = loadSpec(root);
43
- const checks: DoctorCheck[] = [];
44
-
45
- const parts = process.versions.node.split(".").map((value) => Number(value));
46
- const nodeOk = parts[0] > 22 || (parts[0] === 22 && parts[1] >= 18);
47
- checks.push({ id: "node", label: "Node 22.18+", ok: nodeOk, detail: `v${process.versions.node}`, blocking: true });
48
- checks.push({ id: "npx", label: "npx available", ok: hasCommand("npx"), detail: "required by MCP providers", blocking: true });
49
-
50
- const index = loadInstalledSkills(spec);
51
- checks.push({
52
- id: "index",
53
- label: "Skills index",
54
- ok: index.available,
55
- detail: index.available ? `${index.skills.size} skills` : "build/installed-skills.json unreadable, run novahiz sync",
56
- blocking: true
57
- });
58
-
59
- const referenced = referencedSkills(spec);
60
- const absent = index.available ? referenced.filter((id) => !index.skills.has(id)) : [];
61
- checks.push({
62
- id: "referenced",
63
- label: "Referenced skills",
64
- ok: absent.length === 0,
65
- detail: absent.length === 0 ? `${referenced.length} present` : `missing from index: ${absent.join(", ")}`,
66
- blocking: absent.length > 0
67
- });
68
-
69
- const missingCli = Object.entries(SKILL_CLI)
70
- .filter(([skill]) => referenced.includes(skill) && !hasCommand(skill))
71
- .map(([, cli]) => cli);
72
- checks.push({
73
- id: "cli",
74
- label: "Required external CLI",
75
- ok: missingCli.length === 0,
76
- detail: missingCli.length === 0 ? "all present" : `not found: ${missingCli.join(", ")}`,
77
- blocking: missingCli.length > 0
78
- });
79
-
80
- const probe = evaluateGate({
81
- tool: "edit",
82
- filePath: "README.md",
83
- content: "This README paragraph is long enough prose for the content rules to match and demand a loaded skill.",
84
- categories: [],
85
- loadedSkills: [],
86
- installedSkills: index.skills,
87
- installedIndexAvailable: index.available,
88
- spec
89
- });
90
- const gateOk = spec.rules.length === 0 || (probe.allow === false && probe.missingSkills.length > 0);
91
- checks.push({
92
- id: "gate",
93
- label: "Operational gate",
94
- ok: gateOk,
95
- detail: gateOk ? `blocks a write without a loaded skill (${probe.missingSkills.join(", ")})` : "did not block a write without a loaded skill",
96
- blocking: true
97
- });
98
-
99
- const dbFile = dbPathFor(root, spec);
100
- let dbOk = false;
101
- let dbDetail = "missing";
102
- try {
103
- const size = statSync(dbFile).size;
104
- const db = openDb(dbFile);
105
- db.prepare("SELECT COUNT(*) AS n FROM enforcement_log").get();
106
- db.close();
107
- dbOk = true;
108
- dbDetail = ui.bytes(size);
109
- } catch (error) {
110
- dbDetail = `unreadable: ${(error as Error).message}`;
111
- }
112
- checks.push({ id: "db", label: "Registry database", ok: dbOk, detail: dbDetail, blocking: false });
113
-
114
- // Informational only: openDb migrates on open, so the stored version always
115
- // matches by the time this reads it. The value is read so a future migration
116
- // has something to branch on, and so a database from an older build shows up.
117
- let schemaDetail = "database unreadable";
118
- try {
119
- const db = openDb(dbFile);
120
- const row = db.prepare("PRAGMA user_version").get() as { user_version?: number } | undefined;
121
- const version = Number(row?.user_version ?? 0);
122
- db.close();
123
- schemaDetail = `version ${version} (expected ${SCHEMA_VERSION})`;
124
- } catch (error) {
125
- schemaDetail = `unreadable: ${(error as Error).message}`;
126
- }
127
- checks.push({ id: "schema", label: "Schema version", ok: true, detail: schemaDetail, blocking: false });
128
-
129
- const adapterSource = join(root, "adapters", "opencode", "novahiz.ts");
130
- const opencodeDir =
131
- process.env.OPENCODE_CONFIG_DIR && process.env.OPENCODE_CONFIG_DIR.length > 0
132
- ? process.env.OPENCODE_CONFIG_DIR
133
- : join(homedir(), ".config", "opencode");
134
- const adapterInstalled = join(opencodeDir, "plugins", "novahiz.ts");
135
- let adapterOk = true;
136
- let adapterDetail = "no installed copy";
137
- if (existsSync(adapterSource) && existsSync(adapterInstalled)) {
138
- adapterOk = readFileSync(adapterSource, "utf8") === readFileSync(adapterInstalled, "utf8");
139
- adapterDetail = adapterOk ? "plugin harness copy up to date" : "plugin harness copy outdated, rerun installer then restart opencode";
140
- }
141
- checks.push({ id: "adapter", label: "Plugin harness copy", ok: adapterOk, detail: adapterDetail, blocking: false });
142
-
143
- const memoryModule = join(root, "src", "memory.ts");
144
- const memoryOk = existsSync(memoryModule) && DEFAULT_LIMIT_CHARS === 8000 && DEFAULT_LIMIT_LINES === 200;
145
- checks.push({
146
- id: "memory",
147
- label: "Memory module",
148
- ok: memoryOk,
149
- detail: memoryOk
150
- ? `${MEMORY_DIR}/ slots ${DEFAULT_LIMIT_CHARS} chars / ${DEFAULT_LIMIT_LINES} lines`
151
- : "src/memory.ts missing or limits changed",
152
- blocking: false
153
- });
154
-
155
- const mcpEntry = join(root, "mcp", "novahiz-tools", "index.mjs");
156
- let memoryToolsOk = false;
157
- let memoryToolsDetail = "mcp entry missing";
158
- if (existsSync(mcpEntry)) {
159
- const source = readFileSync(mcpEntry, "utf8");
160
- const tools = ["memory_write", "memory_list", "memory_get", "memory_init", "memory_rebuild"];
161
- const missing = tools.filter((name) => !source.includes(`"${name}"`));
162
- memoryToolsOk = missing.length === 0;
163
- memoryToolsDetail = memoryToolsOk ? "5 memory_* tools registered" : `missing: ${missing.join(", ")}`;
164
- }
165
- checks.push({ id: "memory-tools", label: "MCP memory tools", ok: memoryToolsOk, detail: memoryToolsDetail, blocking: false });
166
-
167
- const agentSource = join(root, "adapters", "opencode", "agent", "novahiz.md");
168
- const agentInstalled = join(opencodeDir, "agent", "novahiz.md");
169
- let agentOk = true;
170
- let agentDetail = "no installed copy";
171
- if (existsSync(agentSource) && existsSync(agentInstalled)) {
172
- const installed = readFileSync(agentInstalled, "utf8");
173
- const inSync = installed === readFileSync(agentSource, "utf8");
174
- const grantsQuestion = grantsQuestionIn(installed);
175
- agentOk = inSync && grantsQuestion;
176
- agentDetail = !inSync
177
- ? "agent harness copy outdated, rerun installer then restart opencode"
178
- : grantsQuestion
179
- ? "agent harness copy up to date, question allowed"
180
- : "agent harness copy does not allow question: pipeline cannot query";
181
- }
182
- checks.push({ id: "agent", label: "Agent harness copy", ok: agentOk, detail: agentDetail, blocking: false });
183
-
184
- const failing = checks.filter((check) => !check.ok);
185
- const blocking = failing.filter((check) => check.blocking);
186
- const value = {
187
- home: root,
188
- node: process.versions.node,
189
- platform: process.platform,
190
- checks,
191
- failing: failing.map((check) => check.id),
192
- blocking: blocking.map((check) => check.id)
193
- };
194
-
195
- emit(parsed, value, () =>
196
- [
197
- ui.heading("Novahiz doctor"),
198
- ui.kv([
199
- ["Maison", root],
200
- ["Plateforme", process.platform]
201
- ]),
202
- "",
203
- ui.table(
204
- ["check", "status", "detail"],
205
- checks.map((check) => [check.label, check.ok ? ui.status(true) : ui.status(false), check.detail])
206
- ),
207
- "",
208
- blocking.length > 0
209
- ? ui.style("red", `${blocking.length} blocking anomaly/anomalies: ${blocking.map((check) => check.id).join(", ")}`)
210
- : ui.style("green", "No blocking anomaly.")
211
- ].join("\n")
212
- );
213
-
214
- if (blocking.length > 0) process.exitCode = 1;
215
- }
216
-
217
-
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { dbPathFor, emit, type Parsed } from "./context.ts";
6
+ import { loadSpec, NovahizHome } from "../spec.ts";
7
+ import { openDb, SCHEMA_VERSION } from "../db.ts";
8
+ import { loadInstalledSkills } from "../catalog.ts";
9
+ import { evaluateGate } from "../gate.ts";
10
+ import { DEFAULT_LIMIT_CHARS, DEFAULT_LIMIT_LINES, MEMORY_DIR } from "../memory.ts";
11
+ import * as ui from "../render.ts";
12
+
13
+ type DoctorCheck = { id: string; label: string; ok: boolean; detail: string; blocking: boolean };
14
+
15
+ const SKILL_CLI: Record<string, string> = {};
16
+
17
+ function hasCommand(name: string): boolean {
18
+ const probe = process.platform === "win32" ? "where" : "which";
19
+ const result = spawnSync(probe, [name], { encoding: "utf8", shell: false });
20
+ return result.status === 0;
21
+ }
22
+
23
+ export function grantsQuestionIn(agentFile: string): boolean {
24
+ return /^\s*question:\s*allow\s*$/m.test(agentFile);
25
+ }
26
+
27
+ function referencedSkills(spec: ReturnType<typeof loadSpec>): string[] {
28
+ const ids = new Set<string>();
29
+ for (const category of spec.categories) {
30
+ for (const skill of category.defaultSkills ?? []) ids.add(skill);
31
+ for (const step of category.roadmap?.steps ?? []) {
32
+ for (const skill of step.requireSkills ?? []) ids.add(skill);
33
+ }
34
+ }
35
+ for (const rule of spec.rules) for (const skill of rule.require) ids.add(skill);
36
+ for (const provider of spec.providers) if (provider.kind === "skill") ids.add(provider.id);
37
+ return [...ids].sort();
38
+ }
39
+
40
+ export function commandDoctor(parsed: Parsed): void {
41
+ const root = NovahizHome();
42
+ const spec = loadSpec(root);
43
+ const checks: DoctorCheck[] = [];
44
+
45
+ const parts = process.versions.node.split(".").map((value) => Number(value));
46
+ const nodeOk = parts[0] > 22 || (parts[0] === 22 && parts[1] >= 18);
47
+ checks.push({ id: "node", label: "Node 22.18+", ok: nodeOk, detail: `v${process.versions.node}`, blocking: true });
48
+ checks.push({ id: "npx", label: "npx available", ok: hasCommand("npx"), detail: "required by MCP providers", blocking: true });
49
+
50
+ const index = loadInstalledSkills(spec);
51
+ checks.push({
52
+ id: "index",
53
+ label: "Skills index",
54
+ ok: index.available,
55
+ detail: index.available ? `${index.skills.size} skills` : "build/installed-skills.json unreadable, run novahiz sync",
56
+ blocking: true
57
+ });
58
+
59
+ const referenced = referencedSkills(spec);
60
+ const absent = index.available ? referenced.filter((id) => !index.skills.has(id)) : [];
61
+ checks.push({
62
+ id: "referenced",
63
+ label: "Referenced skills",
64
+ ok: absent.length === 0,
65
+ detail: absent.length === 0 ? `${referenced.length} present` : `missing from index: ${absent.join(", ")}`,
66
+ blocking: absent.length > 0
67
+ });
68
+
69
+ // SKILL_CLI is intentionally empty: novahiz-web-extract replaced the external
70
+ // defuddle CLI on the research roadmap (see MEMORY.md). Keep the check so a
71
+ // future external dependency is wired here again.
72
+ const cliEntries = Object.entries(SKILL_CLI);
73
+ const missingCli = cliEntries
74
+ .filter(([skill]) => referenced.includes(skill) && !hasCommand(skill))
75
+ .map(([, cli]) => cli);
76
+ checks.push({
77
+ id: "cli",
78
+ label: "Required external CLI",
79
+ ok: missingCli.length === 0,
80
+ detail:
81
+ missingCli.length > 0
82
+ ? `not found: ${missingCli.join(", ")}`
83
+ : cliEntries.length === 0
84
+ ? "none required (web-extract replaced defuddle)"
85
+ : "all present",
86
+ blocking: missingCli.length > 0
87
+ });
88
+
89
+ const probe = evaluateGate({
90
+ tool: "edit",
91
+ filePath: "README.md",
92
+ content: "This README paragraph is long enough prose for the content rules to match and demand a loaded skill.",
93
+ categories: [],
94
+ loadedSkills: [],
95
+ installedSkills: index.skills,
96
+ installedIndexAvailable: index.available,
97
+ spec
98
+ });
99
+ const gateOk = spec.rules.length === 0 || (probe.allow === false && probe.missingSkills.length > 0);
100
+ checks.push({
101
+ id: "gate",
102
+ label: "Operational gate",
103
+ ok: gateOk,
104
+ detail: gateOk ? `blocks a write without a loaded skill (${probe.missingSkills.join(", ")})` : "did not block a write without a loaded skill",
105
+ blocking: true
106
+ });
107
+
108
+ const dbFile = dbPathFor(root, spec);
109
+ let dbOk = false;
110
+ let dbDetail = "missing";
111
+ try {
112
+ const size = statSync(dbFile).size;
113
+ const db = openDb(dbFile);
114
+ db.prepare("SELECT COUNT(*) AS n FROM enforcement_log").get();
115
+ db.close();
116
+ dbOk = true;
117
+ dbDetail = ui.bytes(size);
118
+ } catch (error) {
119
+ dbDetail = `unreadable: ${(error as Error).message}`;
120
+ }
121
+ checks.push({ id: "db", label: "Registry database", ok: dbOk, detail: dbDetail, blocking: false });
122
+
123
+ // Informational only: openDb migrates on open, so the stored version always
124
+ // matches by the time this reads it. The value is read so a future migration
125
+ // has something to branch on, and so a database from an older build shows up.
126
+ let schemaDetail = "database unreadable";
127
+ try {
128
+ const db = openDb(dbFile);
129
+ const row = db.prepare("PRAGMA user_version").get() as { user_version?: number } | undefined;
130
+ const version = Number(row?.user_version ?? 0);
131
+ db.close();
132
+ schemaDetail = `version ${version} (expected ${SCHEMA_VERSION})`;
133
+ } catch (error) {
134
+ schemaDetail = `unreadable: ${(error as Error).message}`;
135
+ }
136
+ checks.push({ id: "schema", label: "Schema version", ok: true, detail: schemaDetail, blocking: false });
137
+
138
+ const adapterSource = join(root, "adapters", "opencode", "novahiz.ts");
139
+ const opencodeDir =
140
+ process.env.OPENCODE_CONFIG_DIR && process.env.OPENCODE_CONFIG_DIR.length > 0
141
+ ? process.env.OPENCODE_CONFIG_DIR
142
+ : join(homedir(), ".config", "opencode");
143
+ const adapterInstalled = join(opencodeDir, "plugins", "novahiz.ts");
144
+ let adapterOk = true;
145
+ let adapterDetail = "no installed copy";
146
+ if (existsSync(adapterSource) && existsSync(adapterInstalled)) {
147
+ adapterOk = readFileSync(adapterSource, "utf8") === readFileSync(adapterInstalled, "utf8");
148
+ adapterDetail = adapterOk ? "plugin harness copy up to date" : "plugin harness copy outdated, rerun installer then restart opencode";
149
+ }
150
+ checks.push({ id: "adapter", label: "Plugin harness copy", ok: adapterOk, detail: adapterDetail, blocking: false });
151
+
152
+ const memoryModule = join(root, "src", "memory.ts");
153
+ const memoryOk = existsSync(memoryModule) && DEFAULT_LIMIT_CHARS === 8000 && DEFAULT_LIMIT_LINES === 200;
154
+ checks.push({
155
+ id: "memory",
156
+ label: "Memory module",
157
+ ok: memoryOk,
158
+ detail: memoryOk
159
+ ? `${MEMORY_DIR}/ slots ${DEFAULT_LIMIT_CHARS} chars / ${DEFAULT_LIMIT_LINES} lines`
160
+ : "src/memory.ts missing or limits changed",
161
+ blocking: false
162
+ });
163
+
164
+ const mcpEntry = join(root, "mcp", "novahiz-tools", "index.mjs");
165
+ let memoryToolsOk = false;
166
+ let memoryToolsDetail = "mcp entry missing";
167
+ if (existsSync(mcpEntry)) {
168
+ const source = readFileSync(mcpEntry, "utf8");
169
+ const tools = ["memory_write", "memory_list", "memory_get", "memory_init", "memory_rebuild"];
170
+ const missing = tools.filter((name) => !source.includes(`"${name}"`));
171
+ memoryToolsOk = missing.length === 0;
172
+ memoryToolsDetail = memoryToolsOk ? "5 memory_* tools registered" : `missing: ${missing.join(", ")}`;
173
+ }
174
+ checks.push({ id: "memory-tools", label: "MCP memory tools", ok: memoryToolsOk, detail: memoryToolsDetail, blocking: false });
175
+
176
+ const agentSource = join(root, "adapters", "opencode", "agent", "novahiz.md");
177
+ const agentInstalled = join(opencodeDir, "agent", "novahiz.md");
178
+ let agentOk = true;
179
+ let agentDetail = "no installed copy";
180
+ if (existsSync(agentSource) && existsSync(agentInstalled)) {
181
+ const installed = readFileSync(agentInstalled, "utf8");
182
+ const inSync = installed === readFileSync(agentSource, "utf8");
183
+ const grantsQuestion = grantsQuestionIn(installed);
184
+ agentOk = inSync && grantsQuestion;
185
+ agentDetail = !inSync
186
+ ? "agent harness copy outdated, rerun installer then restart opencode"
187
+ : grantsQuestion
188
+ ? "agent harness copy up to date, question allowed"
189
+ : "agent harness copy does not allow question: pipeline cannot query";
190
+ }
191
+ checks.push({ id: "agent", label: "Agent harness copy", ok: agentOk, detail: agentDetail, blocking: false });
192
+
193
+ const failing = checks.filter((check) => !check.ok);
194
+ const blocking = failing.filter((check) => check.blocking);
195
+ const value = {
196
+ home: root,
197
+ node: process.versions.node,
198
+ platform: process.platform,
199
+ checks,
200
+ failing: failing.map((check) => check.id),
201
+ blocking: blocking.map((check) => check.id)
202
+ };
203
+
204
+ emit(parsed, value, () =>
205
+ [
206
+ ui.heading("Novahiz doctor"),
207
+ ui.kv([
208
+ ["Maison", root],
209
+ ["Plateforme", process.platform]
210
+ ]),
211
+ "",
212
+ ui.table(
213
+ ["check", "status", "detail"],
214
+ checks.map((check) => [check.label, check.ok ? ui.status(true) : ui.status(false), check.detail])
215
+ ),
216
+ "",
217
+ blocking.length > 0
218
+ ? ui.style("red", `${blocking.length} blocking anomaly/anomalies: ${blocking.map((check) => check.id).join(", ")}`)
219
+ : ui.style("green", "No blocking anomaly.")
220
+ ].join("\n")
221
+ );
222
+
223
+ if (blocking.length > 0) process.exitCode = 1;
224
+ }
225
+
226
+