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.
- package/README.md +355 -355
- package/adapters/opencode/commands/novahiz-init.md +10 -0
- package/adapters/opencode/novahiz.ts +181 -3
- package/docs/CONFIGURATION.md +2 -2
- package/docs/PLUGIN.md +24 -6
- package/docs/RULES.md +1 -1
- package/install/install.mjs +2 -0
- package/mcp/novahiz-tools/index.mjs +35 -6
- package/package.json +6 -6
- package/skills/novahiz-gate/SKILL.md +1 -1
- package/skills/novahiz-init/SKILL.md +112 -0
- package/src/autodocs.ts +192 -0
- package/src/cli.ts +8 -1
- package/src/commands/autodocs.ts +250 -0
- package/src/commands/doctor.ts +226 -217
- package/src/commands/init.ts +356 -0
- package/src/commands/task.ts +20 -7
- package/src/ledger.ts +23 -0
- package/src/targets.ts +43 -7
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
),
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
|