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.
- package/bin/novahiz.mjs +30 -2
- package/dist/autodocs.js +170 -0
- package/dist/catalog.js +245 -0
- package/dist/classify.js +181 -0
- package/dist/cli.js +218 -0
- package/dist/commands/autodocs.js +218 -0
- package/dist/commands/clean.js +106 -0
- package/dist/commands/context.js +167 -0
- package/dist/commands/doctor.js +221 -0
- package/dist/commands/gate.js +218 -0
- package/dist/commands/graft.js +146 -0
- package/dist/commands/init.js +325 -0
- package/dist/commands/inspect.js +314 -0
- package/dist/commands/report.js +87 -0
- package/dist/commands/task.js +354 -0
- package/dist/commands/tokens.js +69 -0
- package/dist/complexity.js +265 -0
- package/dist/content.js +99 -0
- package/dist/db.js +176 -0
- package/dist/deps.js +24 -0
- package/dist/exec.js +108 -0
- package/dist/gate-repair.js +76 -0
- package/dist/gate.js +493 -0
- package/dist/graft.js +257 -0
- package/dist/ledger.js +431 -0
- package/dist/memory.js +441 -0
- package/dist/prompt-rewriter.js +481 -0
- package/dist/providers.js +47 -0
- package/dist/relevance.js +58 -0
- package/dist/render.js +90 -0
- package/dist/spec.js +208 -0
- package/dist/targets.js +375 -0
- package/install/bootstrap.mjs +25 -6
- package/install/install.mjs +32 -44
- package/install/lib.mjs +41 -0
- package/package.json +4 -3
- package/src/exec.ts +6 -1
- package/src/spec.ts +4 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, chmodSync, readSync } from "node:fs";
|
|
2
|
+
import { resolve as resolvePath, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
export function parse(argv) {
|
|
6
|
+
const flags = {};
|
|
7
|
+
const positionals = [];
|
|
8
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
9
|
+
const arg = argv[index];
|
|
10
|
+
if (!arg.startsWith("--")) {
|
|
11
|
+
positionals.push(arg);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const equals = arg.indexOf("=");
|
|
15
|
+
if (equals !== -1) {
|
|
16
|
+
flags[arg.slice(2, equals)] = arg.slice(equals + 1);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const next = argv[index + 1];
|
|
20
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
21
|
+
flags[arg.slice(2)] = next;
|
|
22
|
+
index += 1;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
flags[arg.slice(2)] = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { positionals, flags };
|
|
29
|
+
}
|
|
30
|
+
export function asString(value) {
|
|
31
|
+
if (value === undefined || value === null)
|
|
32
|
+
return "";
|
|
33
|
+
if (typeof value === "string")
|
|
34
|
+
return value;
|
|
35
|
+
return String(value);
|
|
36
|
+
}
|
|
37
|
+
export function splitList(value) {
|
|
38
|
+
if (typeof value !== "string" || value.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
return value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
export function print(output) {
|
|
43
|
+
const record = (typeof output === "object" && output !== null ? output : {});
|
|
44
|
+
if (typeof record.name === "string" && Array.isArray(record.commands)) {
|
|
45
|
+
console.log(record.name);
|
|
46
|
+
for (const cmd of record.commands)
|
|
47
|
+
console.log(" " + cmd);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function flagOn(parsed, name) {
|
|
54
|
+
const value = parsed.flags[name];
|
|
55
|
+
if (value === true)
|
|
56
|
+
return true;
|
|
57
|
+
if (value === false)
|
|
58
|
+
return false;
|
|
59
|
+
if (typeof value === "string" && value.length > 0)
|
|
60
|
+
return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
export function humanMode(parsed) {
|
|
64
|
+
process.stdout.write("Plan mode: read-only. No application files will be written.\n");
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
export function emit(parsed, value, textFn) {
|
|
68
|
+
const format = String(parsed.flags.format ?? "").toLowerCase();
|
|
69
|
+
const asJson = format === "json" || parsed.flags.json === true;
|
|
70
|
+
if (asJson) {
|
|
71
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
process.stdout.write(textFn() + "\n");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// P2-C (LOW): stdin.read() returns null on a TTY (stream not in flowing
|
|
78
|
+
// mode), which made every confirmation silently answer "no". Read a line
|
|
79
|
+
// from fd 0 directly instead; parse stays pure so tests cover it without a
|
|
80
|
+
// terminal.
|
|
81
|
+
export function parseAnswer(line) {
|
|
82
|
+
const answer = line.trim().toLowerCase();
|
|
83
|
+
return answer === "y" || answer === "yes" || answer === "o" || answer === "oui";
|
|
84
|
+
}
|
|
85
|
+
const MAX_ANSWER_BYTES = 1024;
|
|
86
|
+
function readAnswerLine() {
|
|
87
|
+
const buffer = Buffer.alloc(MAX_ANSWER_BYTES);
|
|
88
|
+
let total = 0;
|
|
89
|
+
try {
|
|
90
|
+
while (total < MAX_ANSWER_BYTES) {
|
|
91
|
+
const read = readSync(0, buffer, total, 1, null);
|
|
92
|
+
if (read <= 0)
|
|
93
|
+
break;
|
|
94
|
+
const byte = buffer[total];
|
|
95
|
+
total += 1;
|
|
96
|
+
if (byte === 0x0a)
|
|
97
|
+
break; // \n — consume the whole line, stop at newline
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// stdin unavailable (closed pipe, no console): treat as empty answer
|
|
102
|
+
}
|
|
103
|
+
return buffer.toString("utf8", 0, total);
|
|
104
|
+
}
|
|
105
|
+
export function confirm(question) {
|
|
106
|
+
process.stdout.write(question + " [y/N] ");
|
|
107
|
+
return parseAnswer(readAnswerLine());
|
|
108
|
+
}
|
|
109
|
+
// The name promises safety: malformed input returns [] instead of throwing.
|
|
110
|
+
export function safeJsonArray(value) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(value);
|
|
113
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function dbPathFor(root, spec) {
|
|
120
|
+
return resolvePath(root, spec.config.dbPath);
|
|
121
|
+
}
|
|
122
|
+
const MAX_STDIN_BYTES = 1024 * 1024; // 1 MiB: JSON payloads never need more
|
|
123
|
+
export function readStdin() {
|
|
124
|
+
// A single synchronous stdin read races with pipe delivery and can return null even when input was provided.
|
|
125
|
+
if (process.stdin.isTTY)
|
|
126
|
+
return "";
|
|
127
|
+
try {
|
|
128
|
+
// P2-C (LOW): bounded read — readFileSync(0) had no size limit, so a huge
|
|
129
|
+
// pipe reached JSON.parse in full. Stop at the cap; oversized input gets
|
|
130
|
+
// truncated (and then fails to parse) instead of being loaded whole.
|
|
131
|
+
const buffer = Buffer.alloc(MAX_STDIN_BYTES);
|
|
132
|
+
let total = 0;
|
|
133
|
+
while (total < MAX_STDIN_BYTES) {
|
|
134
|
+
const read = readSync(0, buffer, total, MAX_STDIN_BYTES - total, null);
|
|
135
|
+
if (read <= 0)
|
|
136
|
+
break;
|
|
137
|
+
total += read;
|
|
138
|
+
}
|
|
139
|
+
return buffer.toString("utf8", 0, total);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export function numberFlag(parsed, name, opts = {}) {
|
|
146
|
+
const raw = parsed.flags[name];
|
|
147
|
+
if (raw === undefined || raw === false)
|
|
148
|
+
return undefined;
|
|
149
|
+
const label = opts.label ?? name;
|
|
150
|
+
const num = Number(raw);
|
|
151
|
+
if (Number.isNaN(num)) {
|
|
152
|
+
process.stderr.write(`Invalid value for --${label}: "${raw}" is not a number.\n`);
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
if (opts.integer && !Number.isInteger(num)) {
|
|
157
|
+
process.stderr.write(`Invalid value for --${label}: an integer is required, not "${raw}".\n`);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
if (opts.min !== undefined && num < opts.min) {
|
|
162
|
+
process.stderr.write(`Invalid value for --${label}: ${num} is below the minimum limit of ${opts.min}.\n`);
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return num;
|
|
167
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
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 } from "./context.js";
|
|
6
|
+
import { loadSpec, NovahizHome } from "../spec.js";
|
|
7
|
+
import { openDb, SCHEMA_VERSION } from "../db.js";
|
|
8
|
+
import { loadInstalledSkills } from "../catalog.js";
|
|
9
|
+
import { evaluateGate } from "../gate.js";
|
|
10
|
+
import { DEFAULT_LIMIT_CHARS, DEFAULT_LIMIT_LINES, MEMORY_DIR } from "../memory.js";
|
|
11
|
+
import * as ui from "../render.js";
|
|
12
|
+
const SKILL_CLI = {};
|
|
13
|
+
function hasCommand(name) {
|
|
14
|
+
const probe = process.platform === "win32" ? "where" : "which";
|
|
15
|
+
const result = spawnSync(probe, [name], { encoding: "utf8", shell: false });
|
|
16
|
+
return result.status === 0;
|
|
17
|
+
}
|
|
18
|
+
export function grantsQuestionIn(agentFile) {
|
|
19
|
+
return /^\s*question:\s*allow\s*$/m.test(agentFile);
|
|
20
|
+
}
|
|
21
|
+
function referencedSkills(spec) {
|
|
22
|
+
const ids = new Set();
|
|
23
|
+
for (const category of spec.categories) {
|
|
24
|
+
for (const skill of category.defaultSkills ?? [])
|
|
25
|
+
ids.add(skill);
|
|
26
|
+
for (const step of category.roadmap?.steps ?? []) {
|
|
27
|
+
for (const skill of step.requireSkills ?? [])
|
|
28
|
+
ids.add(skill);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (const rule of spec.rules)
|
|
32
|
+
for (const skill of rule.require)
|
|
33
|
+
ids.add(skill);
|
|
34
|
+
// Skill packs (kind: "skill") are delivery mechanisms, not index entries:
|
|
35
|
+
// their id ("flutter-skills", "expo-skills") never matches a SKILL.md name,
|
|
36
|
+
// so adding it made this check permanently red. Pack skills land in the
|
|
37
|
+
// index individually and are referenced through categories and rules above.
|
|
38
|
+
return [...ids].sort();
|
|
39
|
+
}
|
|
40
|
+
export function commandDoctor(parsed) {
|
|
41
|
+
const root = NovahizHome();
|
|
42
|
+
const spec = loadSpec(root);
|
|
43
|
+
const checks = [];
|
|
44
|
+
const parts = process.versions.node.split(".").map((value) => Number(value));
|
|
45
|
+
const nodeOk = parts[0] > 22 || (parts[0] === 22 && parts[1] >= 18);
|
|
46
|
+
checks.push({ id: "node", label: "Node 22.18+", ok: nodeOk, detail: `v${process.versions.node}`, blocking: true });
|
|
47
|
+
checks.push({ id: "npx", label: "npx available", ok: hasCommand("npx"), detail: "required by MCP providers", blocking: true });
|
|
48
|
+
const index = loadInstalledSkills(spec);
|
|
49
|
+
checks.push({
|
|
50
|
+
id: "index",
|
|
51
|
+
label: "Skills index",
|
|
52
|
+
ok: index.available,
|
|
53
|
+
detail: index.available ? `${index.skills.size} skills` : "build/installed-skills.json unreadable, run novahiz sync",
|
|
54
|
+
blocking: true
|
|
55
|
+
});
|
|
56
|
+
const referenced = referencedSkills(spec);
|
|
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
|
+
});
|
|
65
|
+
// SKILL_CLI is intentionally empty: novahiz-web-extract replaced the external
|
|
66
|
+
// defuddle CLI on the research roadmap (see MEMORY.md). Keep the check so a
|
|
67
|
+
// future external dependency is wired here again.
|
|
68
|
+
const cliEntries = Object.entries(SKILL_CLI);
|
|
69
|
+
const missingCli = cliEntries
|
|
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
|
|
77
|
+
? `not found: ${missingCli.join(", ")}`
|
|
78
|
+
: cliEntries.length === 0
|
|
79
|
+
? "none required (web-extract replaced defuddle)"
|
|
80
|
+
: "all present",
|
|
81
|
+
blocking: missingCli.length > 0
|
|
82
|
+
});
|
|
83
|
+
// Two probes, one per live enforcement layer, both deterministic against the
|
|
84
|
+
// current ruleset (the old single probe fed README.md with empty categories,
|
|
85
|
+
// which matches no rule since the prose content rules were removed):
|
|
86
|
+
// - path probe: R13/R14 match a style file through pathGlobs alone.
|
|
87
|
+
// - category probe: R6 matches a classified code prompt; tier is set
|
|
88
|
+
// explicitly because probe text is file content, not a prompt, and
|
|
89
|
+
// determineTier would call it trivial and skip R6.
|
|
90
|
+
const probeInput = {
|
|
91
|
+
tool: "edit",
|
|
92
|
+
content: "",
|
|
93
|
+
categories: [],
|
|
94
|
+
loadedSkills: [],
|
|
95
|
+
installedSkills: index.skills,
|
|
96
|
+
installedIndexAvailable: index.available,
|
|
97
|
+
spec
|
|
98
|
+
};
|
|
99
|
+
const pathProbe = evaluateGate({ ...probeInput, filePath: "src/hero.css" });
|
|
100
|
+
const classProbe = evaluateGate({
|
|
101
|
+
...probeInput,
|
|
102
|
+
filePath: "README.md",
|
|
103
|
+
categories: ["code"],
|
|
104
|
+
tier: "full"
|
|
105
|
+
});
|
|
106
|
+
const blocked = (r) => r.allow === false && r.missingSkills.length > 0;
|
|
107
|
+
const gateOk = spec.rules.length === 0 || (blocked(pathProbe) && blocked(classProbe));
|
|
108
|
+
const probeSkills = [...new Set([...pathProbe.missingSkills, ...classProbe.missingSkills])];
|
|
109
|
+
checks.push({
|
|
110
|
+
id: "gate",
|
|
111
|
+
label: "Operational gate",
|
|
112
|
+
ok: gateOk,
|
|
113
|
+
detail: gateOk ? `blocks a write without a loaded skill (${probeSkills.join(", ")})` : "did not block a write without a loaded skill",
|
|
114
|
+
blocking: true
|
|
115
|
+
});
|
|
116
|
+
const dbFile = dbPathFor(root, spec);
|
|
117
|
+
let dbOk = false;
|
|
118
|
+
let dbDetail = "missing";
|
|
119
|
+
try {
|
|
120
|
+
const size = statSync(dbFile).size;
|
|
121
|
+
const db = openDb(dbFile);
|
|
122
|
+
db.prepare("SELECT COUNT(*) AS n FROM enforcement_log").get();
|
|
123
|
+
db.close();
|
|
124
|
+
dbOk = true;
|
|
125
|
+
dbDetail = ui.bytes(size);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
dbDetail = `unreadable: ${error.message}`;
|
|
129
|
+
}
|
|
130
|
+
checks.push({ id: "db", label: "Registry database", ok: dbOk, detail: dbDetail, blocking: false });
|
|
131
|
+
// Informational only: openDb migrates on open, so the stored version always
|
|
132
|
+
// matches by the time this reads it. The value is read so a future migration
|
|
133
|
+
// has something to branch on, and so a database from an older build shows up.
|
|
134
|
+
let schemaDetail = "database unreadable";
|
|
135
|
+
try {
|
|
136
|
+
const db = openDb(dbFile);
|
|
137
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
138
|
+
const version = Number(row?.user_version ?? 0);
|
|
139
|
+
db.close();
|
|
140
|
+
schemaDetail = `version ${version} (expected ${SCHEMA_VERSION})`;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
schemaDetail = `unreadable: ${error.message}`;
|
|
144
|
+
}
|
|
145
|
+
checks.push({ id: "schema", label: "Schema version", ok: true, detail: schemaDetail, blocking: false });
|
|
146
|
+
const adapterSource = join(root, "adapters", "opencode", "novahiz.ts");
|
|
147
|
+
const opencodeDir = process.env.OPENCODE_CONFIG_DIR && process.env.OPENCODE_CONFIG_DIR.length > 0
|
|
148
|
+
? process.env.OPENCODE_CONFIG_DIR
|
|
149
|
+
: join(homedir(), ".config", "opencode");
|
|
150
|
+
const adapterInstalled = join(opencodeDir, "plugins", "novahiz.ts");
|
|
151
|
+
let adapterOk = true;
|
|
152
|
+
let adapterDetail = "no installed copy";
|
|
153
|
+
if (existsSync(adapterSource) && existsSync(adapterInstalled)) {
|
|
154
|
+
adapterOk = readFileSync(adapterSource, "utf8") === readFileSync(adapterInstalled, "utf8");
|
|
155
|
+
adapterDetail = adapterOk ? "plugin harness copy up to date" : "plugin harness copy outdated, rerun installer then restart opencode";
|
|
156
|
+
}
|
|
157
|
+
checks.push({ id: "adapter", label: "Plugin harness copy", ok: adapterOk, detail: adapterDetail, blocking: false });
|
|
158
|
+
const memoryModule = join(root, "src", "memory.ts");
|
|
159
|
+
const memoryOk = existsSync(memoryModule) && DEFAULT_LIMIT_CHARS === 8000 && DEFAULT_LIMIT_LINES === 200;
|
|
160
|
+
checks.push({
|
|
161
|
+
id: "memory",
|
|
162
|
+
label: "Memory module",
|
|
163
|
+
ok: memoryOk,
|
|
164
|
+
detail: memoryOk
|
|
165
|
+
? `${MEMORY_DIR}/ slots ${DEFAULT_LIMIT_CHARS} chars / ${DEFAULT_LIMIT_LINES} lines`
|
|
166
|
+
: "src/memory.ts missing or limits changed",
|
|
167
|
+
blocking: false
|
|
168
|
+
});
|
|
169
|
+
const mcpEntry = join(root, "mcp", "novahiz-tools", "index.mjs");
|
|
170
|
+
let memoryToolsOk = false;
|
|
171
|
+
let memoryToolsDetail = "mcp entry missing";
|
|
172
|
+
if (existsSync(mcpEntry)) {
|
|
173
|
+
const source = readFileSync(mcpEntry, "utf8");
|
|
174
|
+
const tools = ["memory_write", "memory_list", "memory_get", "memory_init", "memory_rebuild"];
|
|
175
|
+
const missing = tools.filter((name) => !source.includes(`"${name}"`));
|
|
176
|
+
memoryToolsOk = missing.length === 0;
|
|
177
|
+
memoryToolsDetail = memoryToolsOk ? "5 memory_* tools registered" : `missing: ${missing.join(", ")}`;
|
|
178
|
+
}
|
|
179
|
+
checks.push({ id: "memory-tools", label: "MCP memory tools", ok: memoryToolsOk, detail: memoryToolsDetail, blocking: false });
|
|
180
|
+
const agentSource = join(root, "adapters", "opencode", "agent", "novahiz.md");
|
|
181
|
+
const agentInstalled = join(opencodeDir, "agent", "novahiz.md");
|
|
182
|
+
let agentOk = true;
|
|
183
|
+
let agentDetail = "no installed copy";
|
|
184
|
+
if (existsSync(agentSource) && existsSync(agentInstalled)) {
|
|
185
|
+
const installed = readFileSync(agentInstalled, "utf8");
|
|
186
|
+
const inSync = installed === readFileSync(agentSource, "utf8");
|
|
187
|
+
const grantsQuestion = grantsQuestionIn(installed);
|
|
188
|
+
agentOk = inSync && grantsQuestion;
|
|
189
|
+
agentDetail = !inSync
|
|
190
|
+
? "agent harness copy outdated, rerun installer then restart opencode"
|
|
191
|
+
: grantsQuestion
|
|
192
|
+
? "agent harness copy up to date, question allowed"
|
|
193
|
+
: "agent harness copy does not allow question: pipeline cannot query";
|
|
194
|
+
}
|
|
195
|
+
checks.push({ id: "agent", label: "Agent harness copy", ok: agentOk, detail: agentDetail, blocking: false });
|
|
196
|
+
const failing = checks.filter((check) => !check.ok);
|
|
197
|
+
const blocking = failing.filter((check) => check.blocking);
|
|
198
|
+
const value = {
|
|
199
|
+
home: root,
|
|
200
|
+
node: process.versions.node,
|
|
201
|
+
platform: process.platform,
|
|
202
|
+
checks,
|
|
203
|
+
failing: failing.map((check) => check.id),
|
|
204
|
+
blocking: blocking.map((check) => check.id)
|
|
205
|
+
};
|
|
206
|
+
emit(parsed, value, () => [
|
|
207
|
+
ui.heading("Novahiz doctor"),
|
|
208
|
+
ui.kv([
|
|
209
|
+
["Maison", root],
|
|
210
|
+
["Plateforme", process.platform]
|
|
211
|
+
]),
|
|
212
|
+
"",
|
|
213
|
+
ui.table(["check", "status", "detail"], checks.map((check) => [check.label, check.ok ? ui.status(true) : ui.status(false), check.detail])),
|
|
214
|
+
"",
|
|
215
|
+
blocking.length > 0
|
|
216
|
+
? ui.style("red", `${blocking.length} blocking anomaly/anomalies: ${blocking.map((check) => check.id).join(", ")}`)
|
|
217
|
+
: ui.style("green", "No blocking anomaly.")
|
|
218
|
+
].join("\n"));
|
|
219
|
+
if (blocking.length > 0)
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { asString, dbPathFor, parse, print, readStdin, splitList } from "./context.js";
|
|
3
|
+
import { loadSpec, NovahizHome } from "../spec.js";
|
|
4
|
+
import { openDb } from "../db.js";
|
|
5
|
+
import { loadInstalledSkills } from "../catalog.js";
|
|
6
|
+
import { changeText } from "../content.js";
|
|
7
|
+
import { enforceLedgerChecks, evaluateGate } from "../gate.js";
|
|
8
|
+
import { extractTargetPaths } from "../targets.js";
|
|
9
|
+
export function commandGate(parsed) {
|
|
10
|
+
const root = NovahizHome();
|
|
11
|
+
let spec;
|
|
12
|
+
try {
|
|
13
|
+
spec = loadSpec(root);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
// C-Gate: fail-closed when spec is corrupt — returning allow:true is a
|
|
17
|
+
// security boundary violation. Structured JSON on stderr for callers that
|
|
18
|
+
// need to distinguish corruption from normal block.
|
|
19
|
+
const msg = `loadSpec failed: ${String(error).slice(0, 200)}`;
|
|
20
|
+
process.stderr.write(`novahiz: ${msg}\n`);
|
|
21
|
+
print({ allow: false, error: msg, tool: asString(parsed.flags.tool) || "edit", missingSkills: [], reasons: [msg] });
|
|
22
|
+
process.exitCode = 2;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const gateConfig = spec.config.gate;
|
|
26
|
+
// MINEUR#5: normalize case — the plugin lowercases tool names (adapters
|
|
27
|
+
// novahiz.ts), so a case-sensitive check here would treat "EDIT" as
|
|
28
|
+
// un-gated and silently allow the call.
|
|
29
|
+
const tool = (asString(parsed.flags.tool) || "edit").toLowerCase();
|
|
30
|
+
const categories = splitList(parsed.flags.categories);
|
|
31
|
+
let loaded = splitList(parsed.flags.loaded);
|
|
32
|
+
const session = asString(parsed.flags.session);
|
|
33
|
+
if (loaded.length === 0 && session.length > 0) {
|
|
34
|
+
let db = null;
|
|
35
|
+
try {
|
|
36
|
+
db = openDb(dbPathFor(root, spec));
|
|
37
|
+
const rows = db.prepare("SELECT skill FROM skill_invocations WHERE session_id = ?").all(session);
|
|
38
|
+
loaded = rows.map((row) => row.skill);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
process.stderr.write(`novahiz: session DB open failed: ${String(error).slice(0, 200)}\n`);
|
|
42
|
+
print({ allow: false, error: `session DB error`, tool, missingSkills: [], reasons: [`session DB open failed`] });
|
|
43
|
+
process.exitCode = 2;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db?.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// C2: a project-writable config must not silently switch enforcement off.
|
|
51
|
+
// enabled:false is ignored on purpose; the only kill-switch is the external
|
|
52
|
+
// env var NOVAHIZ_GATE (same philosophy as the hardcoded envEscape, H3/C5).
|
|
53
|
+
if (gateConfig.enabled === false) {
|
|
54
|
+
process.stderr.write('novahiz: gate.enabled=false in novahiz.config.json is ignored; enforcement stays active. Use NOVAHIZ_GATE=off to disable the gate.\n');
|
|
55
|
+
}
|
|
56
|
+
// H3: always use NOVAHIZ_GATE (legacy: NOVAHIZ_GATE) — ignore configurable
|
|
57
|
+
// envEscape to prevent a writable config from redirecting the kill-switch.
|
|
58
|
+
const escapeValue = (process.env.NOVAHIZ_GATE || "").toLowerCase();
|
|
59
|
+
if (["off", "0", "false", "no", "disabled"].includes(escapeValue)) {
|
|
60
|
+
print({ allow: true, disabled: true });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const gated = gateConfig.tools.includes(tool);
|
|
64
|
+
if (!gated) {
|
|
65
|
+
print({ allow: true, tool, reason: "tool is not gated" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const single = asString(parsed.flags.file);
|
|
69
|
+
let content = asString(parsed.flags.content);
|
|
70
|
+
// C1: the prompt that triggered the edit drives the complexity tier.
|
|
71
|
+
const prompt = asString(parsed.flags.prompt);
|
|
72
|
+
let paths;
|
|
73
|
+
if (single.length > 0) {
|
|
74
|
+
paths = [single];
|
|
75
|
+
}
|
|
76
|
+
else if (parsed.flags["args-stdin"]) {
|
|
77
|
+
let args = {};
|
|
78
|
+
const raw = readStdin().trim();
|
|
79
|
+
if (raw.length > 0) {
|
|
80
|
+
try {
|
|
81
|
+
args = JSON.parse(raw);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
process.stderr.write("novahiz: invalid JSON on stdin for --args-stdin\n");
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
paths = extractTargetPaths(tool, args);
|
|
90
|
+
if (content.length === 0)
|
|
91
|
+
content = changeText(tool, args);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
process.stderr.write("novahiz: gate requires --file <path> or --args-stdin\n");
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const pathless = paths.length === 0;
|
|
99
|
+
if (pathless) {
|
|
100
|
+
const writeTools = ["edit", "write", "patch", "apply_patch"];
|
|
101
|
+
if (writeTools.includes(tool)) {
|
|
102
|
+
// MINEUR#6: warn/audit never exit non-zero — report the pass with a
|
|
103
|
+
// wouldBlock marker instead of a contradictory allow:false + exit 0.
|
|
104
|
+
const enforced = gateConfig.mode === "block";
|
|
105
|
+
print({
|
|
106
|
+
allow: !enforced,
|
|
107
|
+
...(enforced ? {} : { wouldBlock: true }),
|
|
108
|
+
tool,
|
|
109
|
+
targets: [],
|
|
110
|
+
requiredSkills: [],
|
|
111
|
+
missingSkills: [],
|
|
112
|
+
reasons: ["no target path for a write tool"]
|
|
113
|
+
});
|
|
114
|
+
if (enforced)
|
|
115
|
+
process.exitCode = 2;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// MAJEUR l.135: bash/shell/cron with no target path no longer pass
|
|
119
|
+
// unconditionally — they fall through to a pathless evaluation so
|
|
120
|
+
// prompt-scoped rules (supabase, browser, security, ...) still apply
|
|
121
|
+
// to commands like `python -e`, `curl -o` or `git apply`.
|
|
122
|
+
}
|
|
123
|
+
const index = loadInstalledSkills(spec);
|
|
124
|
+
const evalInput = {
|
|
125
|
+
tool,
|
|
126
|
+
content,
|
|
127
|
+
prompt,
|
|
128
|
+
categories,
|
|
129
|
+
loadedSkills: loaded,
|
|
130
|
+
installedSkills: index.skills,
|
|
131
|
+
installedIndexAvailable: index.available,
|
|
132
|
+
spec
|
|
133
|
+
};
|
|
134
|
+
const results = pathless
|
|
135
|
+
? [{ path: "", ...evaluateGate({ ...evalInput, filePath: "", pathless: true }) }]
|
|
136
|
+
: paths.map((filePath) => ({ path: filePath, ...evaluateGate({ ...evalInput, filePath }) }));
|
|
137
|
+
const reasons = [];
|
|
138
|
+
let reviewWarning = "";
|
|
139
|
+
const requiredSkills = [...new Set(results.flatMap((entry) => entry.requiredSkills))];
|
|
140
|
+
const missingSkills = [...new Set(results.flatMap((entry) => entry.missingSkills))];
|
|
141
|
+
const unmatchedRequired = [...new Set(results.flatMap((entry) => entry.unmatchedRequired))];
|
|
142
|
+
const indexMissing = results.some((entry) => entry.indexMissing);
|
|
143
|
+
// H4: single DB connection for trace, ledger checks, AND enforcement logging
|
|
144
|
+
let db = null;
|
|
145
|
+
let dbFailure = "";
|
|
146
|
+
try {
|
|
147
|
+
db = openDb(dbPathFor(root, spec));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
dbFailure = String(error).slice(0, 200);
|
|
151
|
+
process.stderr.write(`novahiz: DB open failed: ${dbFailure}\n`);
|
|
152
|
+
if (gateConfig.mode === "block") {
|
|
153
|
+
// block mode keeps fail-closed behaviour: no enforcement trace, no edits.
|
|
154
|
+
print({ allow: false, error: `DB open failed`, tool, missingSkills: [], reasons: [`DB open failed`] });
|
|
155
|
+
process.exitCode = 2;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// MINEUR#7: warn/audit are advisory — degrade gracefully instead of
|
|
159
|
+
// locking every gated tool out; ledger checks are skipped and reported.
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
if (db) {
|
|
163
|
+
// Shared with the MCP novahiz_gate tool (src/gate.ts enforceLedgerChecks)
|
|
164
|
+
// so the two entry points enforce trace + ledger + the log identically.
|
|
165
|
+
const enforced = enforceLedgerChecks(db, { session, tool, paths, categories, results, spec, gateConfig });
|
|
166
|
+
reasons.push(...enforced.reasons);
|
|
167
|
+
reviewWarning = enforced.reviewWarning;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
db?.close();
|
|
172
|
+
}
|
|
173
|
+
const warnings = [];
|
|
174
|
+
if (dbFailure.length > 0 && gateConfig.mode !== "block") {
|
|
175
|
+
warnings.push(`enforcement trace unavailable (DB open failed): continuing without ledger checks in ${gateConfig.mode} mode.`);
|
|
176
|
+
}
|
|
177
|
+
if (reviewWarning.length > 0)
|
|
178
|
+
warnings.push(reviewWarning);
|
|
179
|
+
if (unmatchedRequired.length > 0) {
|
|
180
|
+
warnings.push(`required skills missing from index, therefore not applied: ` + unmatchedRequired.join(", ") + `. Restart novahiz sync to realign the index.`);
|
|
181
|
+
}
|
|
182
|
+
if (indexMissing) {
|
|
183
|
+
warnings.push("skills index unreadable: all required skills are enforced.");
|
|
184
|
+
}
|
|
185
|
+
const blocked = results.some((entry) => !entry.allow);
|
|
186
|
+
const enforced = gateConfig.mode === "block";
|
|
187
|
+
// MINEUR#6: allow mirrors the exit contract — warn/audit never block, so a
|
|
188
|
+
// failing evaluation is reported as allow:true + wouldBlock:true (exit 0)
|
|
189
|
+
// instead of a contradictory allow:false + exit 0.
|
|
190
|
+
const allow = !blocked || !enforced;
|
|
191
|
+
print({
|
|
192
|
+
allow,
|
|
193
|
+
...(blocked && !enforced ? { wouldBlock: true } : {}),
|
|
194
|
+
tool,
|
|
195
|
+
mode: gateConfig.mode,
|
|
196
|
+
indexMissing,
|
|
197
|
+
requiredSkills,
|
|
198
|
+
missingSkills,
|
|
199
|
+
unmatchedRequired,
|
|
200
|
+
warnings,
|
|
201
|
+
reasons,
|
|
202
|
+
targets: results.map((entry) => ({
|
|
203
|
+
path: entry.path,
|
|
204
|
+
fileClass: entry.fileClass,
|
|
205
|
+
tier: entry.tier,
|
|
206
|
+
ignored: entry.ignored,
|
|
207
|
+
roadmap: entry.roadmap,
|
|
208
|
+
requiredSkills: entry.requiredSkills,
|
|
209
|
+
missingSkills: entry.missingSkills,
|
|
210
|
+
unmatchedRequired: entry.unmatchedRequired,
|
|
211
|
+
placeholder: entry.placeholder,
|
|
212
|
+
reasons: entry.reasons,
|
|
213
|
+
matchedRules: entry.matchedRules
|
|
214
|
+
}))
|
|
215
|
+
});
|
|
216
|
+
if (!allow && gateConfig.mode === "block")
|
|
217
|
+
process.exitCode = 2;
|
|
218
|
+
}
|