novahiz 0.3.2 → 0.3.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.
@@ -0,0 +1,314 @@
1
+ import { join } from "node:path";
2
+ import { asString, dbPathFor, flagOn, numberFlag, print, readStdin, safeJsonArray } from "./context.js";
3
+ import { loadSpec, NovahizHome } from "../spec.js";
4
+ import { getMeta, openDb, setMeta } from "../db.js";
5
+ import { loadCatalog, loadInstalledSkills, persistCatalog, scanSkills, writeCatalog, writeSkillIndex } from "../catalog.js";
6
+ import { rankSkills } from "../relevance.js";
7
+ import { buildMcpEntries, enabledProviders, installCommands } from "../providers.js";
8
+ import { bootstrapFor, checkDependencies, missingPrerequisites } from "../deps.js";
9
+ import { classify } from "../classify.js";
10
+ import { runCommand, runScript } from "../exec.js";
11
+ import { activeTask, buildWorkPackets, getTask } from "../ledger.js";
12
+ export function commandCheck() {
13
+ const root = NovahizHome();
14
+ const spec = loadSpec(root);
15
+ const index = loadInstalledSkills(spec);
16
+ const db = openDb(dbPathFor(root, spec));
17
+ const lastSync = getMeta(db, "last_sync");
18
+ db.close();
19
+ print({
20
+ home: root,
21
+ categories: spec.categories.length,
22
+ rules: spec.rules.length,
23
+ skillRoots: spec.config.skillRoots.length,
24
+ installedSkills: index.skills.size,
25
+ indexAvailable: index.available,
26
+ lastSync,
27
+ gate: spec.config.gate,
28
+ classify: spec.config.classify
29
+ });
30
+ }
31
+ export function commandSync() {
32
+ const root = NovahizHome();
33
+ const spec = loadSpec(root);
34
+ const scanErrors = [];
35
+ const skills = scanSkills(spec, scanErrors);
36
+ const indexFile = writeSkillIndex(spec, skills);
37
+ const catalogFile = writeCatalog(spec, skills);
38
+ const db = openDb(dbPathFor(root, spec));
39
+ persistCatalog(db, spec, skills);
40
+ const lastSync = new Date().toISOString();
41
+ setMeta(db, "last_sync", lastSync);
42
+ db.close();
43
+ print({ root, scanned: skills.length, index: indexFile, catalog: catalogFile, lastSync, scanErrors });
44
+ }
45
+ export function commandClassify(parsed) {
46
+ const root = NovahizHome();
47
+ const spec = loadSpec(root);
48
+ // P0-B: --stdin keeps the prompt off argv — argv allowed option injection
49
+ // (--home), broke past the Windows 32k limit, and leaked the prompt into
50
+ // the process list. The slice bounds a hostile stdin (full fix in P2-C).
51
+ const raw = flagOn(parsed, "stdin")
52
+ ? readStdin().slice(0, 200_000)
53
+ : parsed.positionals.slice(1).join(" ") || asString(parsed.flags.text);
54
+ const text = raw.trim();
55
+ if (text.length === 0) {
56
+ throw new Error("classify needs a prompt: pass it as an argument or with --text");
57
+ }
58
+ const result = classify(spec, text, {
59
+ minScore: numberFlag(parsed, "min-score", { min: 0 }),
60
+ maxCategories: numberFlag(parsed, "max-categories", { min: 1, integer: true })
61
+ });
62
+ print({ prompt: text, ...result });
63
+ }
64
+ export function commandSkills(parsed) {
65
+ const root = NovahizHome();
66
+ const spec = loadSpec(root);
67
+ const db = openDb(dbPathFor(root, spec));
68
+ const rows = db.prepare("SELECT id, name, power, stars, tags, categories FROM skills ORDER BY power DESC, id ASC").all();
69
+ db.close();
70
+ const category = asString(parsed.flags.category);
71
+ const filtered = category.length > 0 ? rows.filter((row) => safeJsonArray(row.categories).includes(category)) : rows;
72
+ print(filtered.map((row) => ({
73
+ id: row.id,
74
+ name: row.name,
75
+ power: row.power,
76
+ stars: row.stars,
77
+ tags: safeJsonArray(row.tags),
78
+ categories: safeJsonArray(row.categories)
79
+ })));
80
+ }
81
+ export function commandCategories() {
82
+ print(loadSpec().categories);
83
+ }
84
+ export function commandRules() {
85
+ print(loadSpec().rules);
86
+ }
87
+ export function commandSessionLoad(parsed) {
88
+ const root = NovahizHome();
89
+ const spec = loadSpec(root);
90
+ const session = asString(parsed.flags.session);
91
+ const skill = asString(parsed.flags.skill);
92
+ if (session.length === 0 || skill.length === 0) {
93
+ print({ error: "session-load requires --session and --skill" });
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ // M12: basic session ID format validation (alphanumeric, hyphens, underscores)
98
+ if (!/^[a-zA-Z0-9_-]+$/.test(session)) {
99
+ print({ error: `invalid session ID format: "${session}" (expected alphanumeric, hyphens, underscores)` });
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ // P0-B: the plugin joins recorded names with commas and the gate re-splits
104
+ // them — a loose name could inject extra "loaded" skills.
105
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(skill)) {
106
+ print({ error: `invalid skill name format: "${skill.slice(0, 64)}"` });
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ // P0-B: only skills present in the installed index may count as loaded.
111
+ // An unreadable index cannot verify anything — accept the record (the gate
112
+ // degrades on its own side via indexMissing).
113
+ const installed = loadInstalledSkills(spec);
114
+ if (installed.available && !installed.skills.has(skill)) {
115
+ print({ error: `unknown skill "${skill}" — not in the installed index. Run "novahiz sync" to rebuild it.` });
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ const db = openDb(dbPathFor(root, spec));
120
+ // H5: ensure session exists for FK compliance
121
+ const ts = new Date().toISOString();
122
+ db.prepare("INSERT OR IGNORE INTO sessions (id, categories, required_skills, updated_at) VALUES (?, '[]', '[]', ?)").run(session, ts);
123
+ db.prepare("INSERT OR IGNORE INTO skill_invocations (session_id, skill, invoked_at) VALUES (?, ?, ?)").run(session, skill, new Date().toISOString());
124
+ db.close();
125
+ print({ session, loaded: skill });
126
+ }
127
+ export function commandSessionState(parsed) {
128
+ const root = NovahizHome();
129
+ const spec = loadSpec(root);
130
+ const session = asString(parsed.flags.session);
131
+ if (session.length === 0) {
132
+ print({ error: "session-state requires --session <id>" });
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ // H6: validate session ID format
137
+ if (!/^[a-zA-Z0-9_-]+$/.test(session)) {
138
+ print({ error: `invalid session ID format: "${session}" (expected alphanumeric, hyphens, underscores)` });
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ const db = openDb(dbPathFor(root, spec));
143
+ const loaded = db.prepare("SELECT skill FROM skill_invocations WHERE session_id = ?").all(session).map((row) => row.skill);
144
+ const state = db.prepare("SELECT categories, required_skills FROM sessions WHERE id = ?").get(session);
145
+ db.close();
146
+ print({ session, loaded, categories: state ? safeJsonArray(state.categories) : [], requiredSkills: state ? safeJsonArray(state.required_skills) : [] });
147
+ }
148
+ export function commandCatalog(parsed) {
149
+ const spec = loadSpec();
150
+ const query = parsed.positionals.slice(1).join(" ") || asString(parsed.flags.query);
151
+ const limitRaw = numberFlag(parsed, "limit", { min: 1, integer: true });
152
+ const limit = limitRaw !== undefined && limitRaw > 0 ? limitRaw : 10;
153
+ const catalog = loadCatalog(spec);
154
+ const results = rankSkills(catalog, query, limit);
155
+ const hint = catalog.length === 0 ? "catalog is empty; run `novahiz sync` to build it" : undefined;
156
+ print({ query, total: catalog.length, results, ...(hint ? { hint } : {}) });
157
+ }
158
+ export function commandRoadmap(parsed) {
159
+ const spec = loadSpec();
160
+ const categoryId = asString(parsed.flags.category);
161
+ const query = parsed.positionals.slice(1).join(" ") || asString(parsed.flags.query);
162
+ let category = categoryId ? spec.categories.find((entry) => entry.id === categoryId) : undefined;
163
+ if (!category && query.length > 0) {
164
+ const result = classify(spec, query);
165
+ category = spec.categories.find((entry) => entry.id === result.primary);
166
+ }
167
+ if (!category) {
168
+ print({ error: "roadmap requires --category <id> or a query" });
169
+ process.exitCode = 1;
170
+ return;
171
+ }
172
+ print({ category: category.id, roadmap: category.roadmap ?? null });
173
+ }
174
+ export function commandStep(parsed) {
175
+ const root = NovahizHome();
176
+ const spec = loadSpec(root);
177
+ const session = asString(parsed.flags.session);
178
+ const done = asString(parsed.flags.done);
179
+ if (session.length === 0) {
180
+ print({ error: "step requires --session <id>" });
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ // H6: validate session ID format
185
+ if (!/^[a-zA-Z0-9_-]+$/.test(session)) {
186
+ print({ error: `invalid session ID format: "${session}" (expected alphanumeric, hyphens, underscores)` });
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ const db = openDb(dbPathFor(root, spec));
191
+ if (done.length > 0) {
192
+ const ts = new Date().toISOString();
193
+ // roadmap_progress.session_id has a foreign key to sessions(id):
194
+ // ensure the session row exists before recording a step.
195
+ db.prepare("INSERT OR IGNORE INTO sessions (id, categories, required_skills, updated_at) VALUES (?, '[]', '[]', ?)").run(session, ts);
196
+ db.prepare("INSERT INTO roadmap_progress (session_id, step_id, status, updated_at) VALUES (?, ?, 'done', ?) ON CONFLICT(session_id, step_id) DO UPDATE SET status = 'done', updated_at = excluded.updated_at").run(session, done, new Date().toISOString());
197
+ }
198
+ const steps = db.prepare("SELECT step_id, status, updated_at FROM roadmap_progress WHERE session_id = ? ORDER BY updated_at").all(session);
199
+ db.close();
200
+ print({ session, steps });
201
+ }
202
+ export function commandProviders(parsed) {
203
+ const spec = loadSpec();
204
+ if (parsed.flags["mcp-json"]) {
205
+ print(buildMcpEntries(spec));
206
+ return;
207
+ }
208
+ if (parsed.flags.install) {
209
+ const results = [];
210
+ for (const entry of installCommands(spec)) {
211
+ const [command, ...args] = entry.command;
212
+ const result = runCommand(command, args);
213
+ process.stdout.write(`${result.ok ? "ok " : "fail"} ${entry.id} (${entry.kind}) ${entry.command.join(" ")}\n`);
214
+ if (!result.ok && result.stderr)
215
+ process.stderr.write(result.stderr);
216
+ results.push({ id: entry.id, kind: entry.kind, source: entry.source, command: entry.command.join(" "), ok: result.ok, error: result.error ?? null });
217
+ }
218
+ print(results);
219
+ return;
220
+ }
221
+ const category = asString(parsed.flags.category);
222
+ const query = parsed.positionals.slice(1).join(" ") || asString(parsed.flags.query);
223
+ let list = spec.providers;
224
+ if (category.length > 0) {
225
+ list = list.filter((provider) => (provider.categories ?? []).includes(category));
226
+ }
227
+ else if (query.length > 0) {
228
+ const ids = new Set(classify(spec, query).providers);
229
+ list = list.filter((provider) => ids.has(provider.id));
230
+ }
231
+ const enabled = new Set(enabledProviders(spec).map((provider) => provider.id));
232
+ print(list.map((provider) => ({
233
+ id: provider.id,
234
+ label: provider.label,
235
+ kind: provider.kind,
236
+ transport: provider.transport ?? null,
237
+ purpose: provider.purpose ?? "",
238
+ categories: provider.categories ?? [],
239
+ source: provider.source ?? "",
240
+ enabled: enabled.has(provider.id)
241
+ })));
242
+ }
243
+ export function commandDeps(parsed) {
244
+ const spec = loadSpec();
245
+ const status = checkDependencies(spec);
246
+ if (!parsed.flags.install) {
247
+ print({ node: process.version, platform: process.platform, dependencies: status });
248
+ return;
249
+ }
250
+ const run = (command, label) => {
251
+ const [bin, ...args] = command;
252
+ const result = runCommand(bin, args);
253
+ process.stdout.write(`${result.ok ? "ok " : "fail"} ${label}\n`);
254
+ if (!result.ok && result.stderr)
255
+ process.stderr.write(result.stderr);
256
+ if (result.error)
257
+ process.stderr.write(`${result.error}\n`);
258
+ return result.ok;
259
+ };
260
+ const runBootstrap = (argv, label) => {
261
+ const result = runScript(argv);
262
+ process.stdout.write(`${result.ok ? "ok " : "fail"} ${label}\n`);
263
+ if (!result.ok && result.stderr)
264
+ process.stderr.write(result.stderr);
265
+ if (result.error)
266
+ process.stderr.write(`${result.error}\n`);
267
+ return result.ok;
268
+ };
269
+ const results = [];
270
+ for (const entry of missingPrerequisites(spec)) {
271
+ const bootstrap = bootstrapFor(entry.provider);
272
+ if (!bootstrap || bootstrap.length === 0) {
273
+ results.push({ provider: entry.provider.id, step: "bootstrap", ok: false, note: `missing ${entry.missing.join(", ")}` });
274
+ continue;
275
+ }
276
+ results.push({ provider: entry.provider.id, step: "bootstrap", command: bootstrap.join(" "), ok: runBootstrap(bootstrap, `bootstrap ${entry.provider.id}`) });
277
+ }
278
+ for (const entry of installCommands(spec)) {
279
+ results.push({ provider: entry.id, step: "install", command: entry.command.join(" "), ok: run(entry.command, `install ${entry.id}`) });
280
+ }
281
+ print(results);
282
+ }
283
+ export function commandDispatch(parsed) {
284
+ const root = NovahizHome();
285
+ const spec = loadSpec(root);
286
+ const session = asString(parsed.flags.session);
287
+ const db = openDb(dbPathFor(root, spec));
288
+ try {
289
+ const taskId = asString(parsed.flags.task) || activeTask(db, session || undefined)?.id;
290
+ if (!taskId) {
291
+ print({ error: "dispatch requires --task or an active task" });
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ const packets = buildWorkPackets(db, taskId);
296
+ const ownership = new Map();
297
+ for (const packet of packets) {
298
+ for (const file of packet.files) {
299
+ ownership.set(file, [...(ownership.get(file) ?? []), packet.todo]);
300
+ }
301
+ }
302
+ const conflicts = [...ownership.entries()]
303
+ .filter(([, owners]) => owners.length > 1)
304
+ .map(([file, owners]) => ({ file, todos: owners }));
305
+ print({ task: getTask(db, taskId), packets, conflicts });
306
+ }
307
+ catch (error) {
308
+ print({ error: String(error?.message ?? error) });
309
+ process.exitCode = 1;
310
+ }
311
+ finally {
312
+ db.close();
313
+ }
314
+ }
@@ -0,0 +1,87 @@
1
+ import { join } from "node:path";
2
+ import { asString, dbPathFor, emit, parse } from "./context.js";
3
+ import { loadSpec, NovahizHome } from "../spec.js";
4
+ import { openDb } from "../db.js";
5
+ import * as ui from "../render.js";
6
+ export function commandReport(parsed) {
7
+ const root = NovahizHome();
8
+ const spec = loadSpec(root);
9
+ const db = openDb(dbPathFor(root, spec));
10
+ const total = db.prepare("SELECT COUNT(*) AS n FROM enforcement_log").get().n;
11
+ const decisions = db.prepare("SELECT decision, COUNT(*) AS n FROM enforcement_log GROUP BY decision").all();
12
+ const byTool = db.prepare("SELECT tool, COUNT(*) AS n FROM enforcement_log GROUP BY tool ORDER BY n DESC").all();
13
+ const byClass = db.prepare("SELECT file_class, COUNT(*) AS n FROM enforcement_log GROUP BY file_class ORDER BY n DESC").all();
14
+ const missingRows = db.prepare("SELECT missing FROM enforcement_log").all();
15
+ const invocations = db.prepare("SELECT COUNT(*) AS n FROM skill_invocations").get().n;
16
+ const topSkills = db.prepare("SELECT skill, COUNT(*) AS n FROM skill_invocations GROUP BY skill ORDER BY n DESC LIMIT 10").all();
17
+ const roadmapDone = db.prepare("SELECT COUNT(*) AS n FROM roadmap_progress").get().n;
18
+ const roadmapBySession = db.prepare("SELECT session_id, COUNT(*) AS n FROM roadmap_progress GROUP BY session_id ORDER BY n DESC LIMIT 10").all();
19
+ const taskCount = db.prepare("SELECT COUNT(*) AS n FROM tasks").get().n;
20
+ const activeTasks = db.prepare("SELECT COUNT(*) AS n FROM tasks WHERE status = 'active'").get().n;
21
+ const todoCount = db.prepare("SELECT COUNT(*) AS n FROM todos").get().n;
22
+ const completedTodos = db.prepare("SELECT COUNT(*) AS n FROM todos WHERE status = 'done'").get().n;
23
+ db.close();
24
+ const counts = {};
25
+ for (const row of missingRows) {
26
+ let list = [];
27
+ try {
28
+ list = JSON.parse(row.missing);
29
+ }
30
+ catch {
31
+ list = [];
32
+ }
33
+ for (const skill of list)
34
+ counts[skill] = (counts[skill] ?? 0) + 1;
35
+ }
36
+ const topMissing = Object.entries(counts)
37
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
38
+ .slice(0, 10)
39
+ .map(([skill, n]) => ({ skill, n }));
40
+ const report = { total, invocations, roadmapDone, tasks: taskCount, activeTasks, todos: todoCount, completedTodos, providers: spec.providers.map((provider) => provider.id), decisions, byTool, byClass, topMissing, topSkills, roadmapBySession };
41
+ if (asString(parsed.flags.format) === "markdown") {
42
+ const lines = [
43
+ "# novahiz report",
44
+ "",
45
+ `Enforcement entries: ${total}`,
46
+ `Skill invocations: ${invocations}`,
47
+ `Roadmap steps done: ${roadmapDone}`,
48
+ `Tasks: ${taskCount} (${activeTasks} active)`,
49
+ `Todos: ${todoCount} (${completedTodos} done)`,
50
+ "",
51
+ "## Decisions",
52
+ ...decisions.map((row) => `- ${row.decision}: ${row.n}`),
53
+ "",
54
+ "## Top missing skills",
55
+ ...topMissing.map((entry) => `- ${entry.skill}: ${entry.n}`),
56
+ "",
57
+ "## Top loaded skills",
58
+ ...topSkills.map((row) => `- ${row.skill}: ${row.n}`),
59
+ ""
60
+ ];
61
+ process.stdout.write(`${lines.join("\n")}\n`);
62
+ return;
63
+ }
64
+ emit(parsed, report, () => [
65
+ ui.heading("novahiz report"),
66
+ ui.kv([
67
+ ["Enforcement log", String(total)],
68
+ ["Skill invocations", String(invocations)],
69
+ ["Roadmap steps completed", String(roadmapDone)],
70
+ ["Tasks", `${taskCount} (${activeTasks} active)`],
71
+ ["Todos", `${todoCount} (${completedTodos} done)`],
72
+ ["Providers", String(spec.providers.length)]
73
+ ]),
74
+ "",
75
+ ui.heading("Decisions"),
76
+ ui.table(["decision", "n"], decisions.map((row) => [row.decision, String(row.n)])),
77
+ "",
78
+ ui.heading("Tools"),
79
+ ui.table(["tool", "n"], byTool.map((row) => [String(row.tool), String(row.n)])),
80
+ "",
81
+ ui.heading("Most expected skills"),
82
+ ui.table(["skill", "n"], topMissing.map((entry) => [entry.skill, String(entry.n)])),
83
+ "",
84
+ ui.heading("Most loaded skills"),
85
+ ui.table(["skill", "n"], topSkills.map((row) => [row.skill, String(row.n)]))
86
+ ].join("\n"));
87
+ }