novahiz 0.2.5 → 0.3.2
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/NOTICE.md +18 -6
- package/README.md +12 -7
- package/adapters/README.md +1 -1
- package/adapters/opencode/agent/novahiz.md +2 -2
- package/adapters/opencode/instructions.md +2 -1
- package/adapters/opencode/novahiz.ts +336 -40
- package/catalog/categories.json +282 -9
- package/catalog/overrides.json +727 -0
- package/catalog/providers.json +63 -10
- package/catalog/rules.json +42 -9
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CATALOG.md +4 -4
- package/docs/CLASSIFICATION.md +3 -1
- package/docs/CONFIGURATION.md +5 -5
- package/docs/CONVERGENCE-2026-09-25.md +5 -0
- package/docs/GATE.md +26 -15
- package/docs/HARNESSES.md +10 -6
- package/docs/INSTALL.md +1 -0
- package/docs/PROVIDERS.md +21 -6
- package/docs/ROADMAPS.md +29 -0
- package/docs/RULES.md +7 -4
- package/install/bootstrap.mjs +29 -24
- package/install/install.mjs +56 -19
- package/install/lib.mjs +20 -4
- package/mcp/novahiz-tools/README.md +5 -0
- package/mcp/novahiz-tools/index.mjs +78 -7
- package/novahiz.config.example.json +4 -3
- package/package.json +3 -2
- package/skills/gate/SKILL.md +16 -1
- package/skills/novahiz-analyse/SKILL.md +2 -2
- package/skills/novahiz-audit/SKILL.md +1 -1
- package/skills/novahiz-code-review/SKILL.md +1 -1
- package/skills/novahiz-converge/SKILL.md +1 -1
- package/skills/novahiz-gate/SKILL.md +2 -1
- package/skills/novahiz-implement/SKILL.md +2 -2
- package/skills/novahiz-plan/SKILL.md +2 -2
- package/skills/novahiz-planner/SKILL.md +5 -3
- package/skills/novahiz-task/SKILL.md +1 -1
- package/skills/prompt-rewriter/SKILL.md +18 -13
- package/src/cli.ts +24 -4
- package/src/commands/context.ts +43 -4
- package/src/commands/doctor.ts +26 -8
- package/src/commands/gate.ts +66 -85
- package/src/commands/graft.ts +14 -9
- package/src/commands/inspect.ts +28 -2
- package/src/commands/task.ts +30 -1
- package/src/exec.ts +38 -10
- package/src/gate-repair.ts +87 -0
- package/src/gate.ts +156 -14
- package/src/graft.ts +56 -23
- package/src/ledger.ts +12 -2
- package/src/memory.ts +14 -1
- package/src/prompt-rewriter.ts +357 -24
- package/src/spec.ts +19 -6
|
@@ -74,7 +74,7 @@ Split again when the work runs past one focused sitting, when the criteria overf
|
|
|
74
74
|
|
|
75
75
|
## Categories that require this step
|
|
76
76
|
|
|
77
|
-
`code`, `browser`, `design-ui`, and `planning`.
|
|
77
|
+
`code`, `flutter`, `expo`, `browser`, `design-ui`, and `planning`.
|
|
78
78
|
|
|
79
79
|
## Discipline
|
|
80
80
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: prompt-rewriter
|
|
3
3
|
description: |
|
|
4
|
-
prompt-rewriter
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
prompt-rewriter detects the source language of every prompt (AR, FR, ES, DE, PT, EN),
|
|
5
|
+
translates it into optimized English before classification where term maps exist
|
|
6
|
+
(Arabic, French), and forces the reply back into the user's language. No external
|
|
7
|
+
dependencies. Built into the plugin adapter.
|
|
7
8
|
license: Apache-2.0
|
|
8
9
|
compatibility: |
|
|
9
10
|
Implemented in src/prompt-rewriter.ts and inlined in adapters/opencode/novahiz.ts;
|
|
@@ -11,7 +12,7 @@ compatibility: |
|
|
|
11
12
|
metadata:
|
|
12
13
|
author: Novahiz
|
|
13
14
|
organization: Novahiz
|
|
14
|
-
version: "2.
|
|
15
|
+
version: "2.1.0"
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
# prompt-rewriter
|
|
@@ -20,8 +21,8 @@ metadata:
|
|
|
20
21
|
|
|
21
22
|
Every user message is intercepted before classification and rewritten into optimized English:
|
|
22
23
|
|
|
23
|
-
1. Detect the source language (FR, AR, ES, DE, PT, or EN).
|
|
24
|
-
2. Translate task terms into English equivalents.
|
|
24
|
+
1. Detect the source language (FR, AR, ES, DE, PT, or EN) with weighted pattern scoring.
|
|
25
|
+
2. Translate task terms into English equivalents where a term map exists (AR, FR).
|
|
25
26
|
3. Tighten the English prompt into imperative mood and drop filler.
|
|
26
27
|
4. Classify the English version for tier and skill assignment.
|
|
27
28
|
5. Respond in the user's original language.
|
|
@@ -36,11 +37,11 @@ The rewriter sits in `src/prompt-rewriter.ts` and is wired into the plugin adapt
|
|
|
36
37
|
|
|
37
38
|
### Language detection
|
|
38
39
|
|
|
39
|
-
Heuristic scoring: each language has weighted patterns of common words, technical terms, and multi-word phrases.
|
|
40
|
+
Heuristic scoring over a folded (lowercase, accent-stripped) copy of the prompt: each language in `LANG_PATTERNS` (FR, ES, DE, PT) has weighted patterns of common words, technical terms, and multi-word phrases. A language needs a minimum score to qualify; a weight-3 marker (task verbs, question words) beats a pile of weight-1 function words. Arabic is detected by its script range. Ties fall back to a fixed order (FR, ES, DE, PT). English cognates (test, design, page, interface, code...) are deliberately excluded from the marker lists so clean English stays `en`. No external dependency is involved.
|
|
40
41
|
|
|
41
42
|
### Term translation
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
Arabic and French have dedicated term maps of 55 and 86 patterns respectively. Examples:
|
|
44
45
|
|
|
45
46
|
| FR term | EN equivalent |
|
|
46
47
|
|---------|---------------|
|
|
@@ -51,6 +52,8 @@ French and Arabic have dedicated term maps of forty and thirty patterns. Example
|
|
|
51
52
|
| migrer | migrate |
|
|
52
53
|
| optimiser | optimize |
|
|
53
54
|
|
|
55
|
+
**Known limitation:** Spanish, German, and Portuguese prompts are detected but not translated — classification on the raw text relies on the shared technical vocabulary (refactor, login, servidor, Datenbank...). Add an `XX_EN` map below to extend coverage.
|
|
56
|
+
|
|
54
57
|
### English optimization
|
|
55
58
|
|
|
56
59
|
Politeness markers are stripped and the prompt becomes imperative:
|
|
@@ -61,18 +64,20 @@ Politeness markers are stripped and the prompt becomes imperative:
|
|
|
61
64
|
|
|
62
65
|
## Integration
|
|
63
66
|
|
|
64
|
-
The plugin adapter calls `rewritePrompt(text)` before `classify
|
|
67
|
+
The plugin adapter calls `rewritePrompt(text)` before `classify`:
|
|
68
|
+
|
|
69
|
+
1. Classification runs on the rewritten English version.
|
|
70
|
+
2. When the detected source language is not English, the enforcement block carries the response-language instruction (see below).
|
|
71
|
+
3. When the text actually changed (`wasRewritten`), a log entry records the rewrite.
|
|
65
72
|
|
|
66
|
-
|
|
67
|
-
2. A log entry records the rewrite.
|
|
68
|
-
3. Classification runs on the English version.
|
|
73
|
+
Covered by `tests/prompt.rewriter.test.ts`.
|
|
69
74
|
|
|
70
75
|
## Response language
|
|
71
76
|
|
|
72
77
|
The agent MUST respond in the user's original language. The enforcement block includes:
|
|
73
78
|
|
|
74
79
|
```
|
|
75
|
-
User language: fr
|
|
80
|
+
User language: fr — respond in this language, not English.
|
|
76
81
|
```
|
|
77
82
|
|
|
78
83
|
That line overrides any default to English.
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { join, dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { parse, print } from "./commands/context.ts";
|
|
5
|
+
import { parse, print, flagOn, type Parsed } from "./commands/context.ts";
|
|
6
6
|
import { expandHome } from "./spec.ts";
|
|
7
7
|
|
|
8
8
|
import { commandCheck, commandSync, commandClassify, commandSkills, commandCategories, commandRules, commandSessionLoad, commandSessionState, commandCatalog, commandRoadmap, commandStep, commandProviders, commandDeps, commandDispatch } from "./commands/inspect.ts";
|
|
@@ -105,15 +105,35 @@ function runInit(): void {
|
|
|
105
105
|
process.stdout.write("\nDone! Restart opencode to activate Novahiz.\n");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function runUpgrade(): void {
|
|
108
|
+
function runUpgrade(parsed: Parsed): void {
|
|
109
109
|
const home = homeDir();
|
|
110
110
|
if (!existsSync(join(home, ".git"))) {
|
|
111
111
|
process.stderr.write("Not a git repository. Install from source first.\n");
|
|
112
112
|
process.exitCode = 1;
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
+
// P2-C (LOW): `git pull` rewrote the running code (hooks included) with no
|
|
116
|
+
// preview. Default to a dry run; `--apply` is required to actually pull.
|
|
117
|
+
if (!flagOn(parsed, "apply")) {
|
|
118
|
+
const dry = spawnSync("git", ["pull", "--dry-run"], { cwd: home, encoding: "utf8" });
|
|
119
|
+
const preview = `${dry.stdout ?? ""}${dry.stderr ?? ""}`.trim();
|
|
120
|
+
if (preview) process.stdout.write(preview + "\n");
|
|
121
|
+
if (dry.status !== 0) {
|
|
122
|
+
process.stderr.write("Dry run failed; fix git first.\n");
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
process.stdout.write("Dry run only. Re-run `novahiz upgrade --apply` to pull and rebuild.\n");
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
115
130
|
process.stdout.write("Pulling latest changes...\n");
|
|
116
|
-
spawnSync("git", ["pull"], { cwd: home, stdio: "inherit" });
|
|
131
|
+
const pull = spawnSync("git", ["pull", "--ff-only"], { cwd: home, stdio: "inherit" });
|
|
132
|
+
if (pull.status !== 0) {
|
|
133
|
+
process.stderr.write("Pull failed; nothing rebuilt.\n");
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
117
137
|
process.stdout.write("\nRebuilding skill catalog...\n");
|
|
118
138
|
runSync();
|
|
119
139
|
process.stdout.write("\nUpgraded! Restart opencode to apply changes.\n");
|
|
@@ -151,7 +171,7 @@ function main(argv: string[]): void {
|
|
|
151
171
|
return commandClean(parsed);
|
|
152
172
|
case "upgrade":
|
|
153
173
|
case "update":
|
|
154
|
-
return runUpgrade();
|
|
174
|
+
return runUpgrade(parsed);
|
|
155
175
|
case "version":
|
|
156
176
|
case "-v":
|
|
157
177
|
case "--version":
|
package/src/commands/context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, chmodSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, chmodSync, readSync } from "node:fs";
|
|
2
2
|
import { resolve as resolvePath, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -78,10 +78,37 @@ export function emit(parsed: Parsed, value: unknown, textFn: () => string): void
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// P2-C (LOW): stdin.read() returns null on a TTY (stream not in flowing
|
|
82
|
+
// mode), which made every confirmation silently answer "no". Read a line
|
|
83
|
+
// from fd 0 directly instead; parse stays pure so tests cover it without a
|
|
84
|
+
// terminal.
|
|
85
|
+
export function parseAnswer(line: string): boolean {
|
|
86
|
+
const answer = line.trim().toLowerCase();
|
|
87
|
+
return answer === "y" || answer === "yes" || answer === "o" || answer === "oui";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MAX_ANSWER_BYTES = 1024;
|
|
91
|
+
|
|
92
|
+
function readAnswerLine(): string {
|
|
93
|
+
const buffer = Buffer.alloc(MAX_ANSWER_BYTES);
|
|
94
|
+
let total = 0;
|
|
95
|
+
try {
|
|
96
|
+
while (total < MAX_ANSWER_BYTES) {
|
|
97
|
+
const read = readSync(0, buffer, total, 1, null);
|
|
98
|
+
if (read <= 0) break;
|
|
99
|
+
const byte = buffer[total];
|
|
100
|
+
total += 1;
|
|
101
|
+
if (byte === 0x0a) break; // \n — consume the whole line, stop at newline
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// stdin unavailable (closed pipe, no console): treat as empty answer
|
|
105
|
+
}
|
|
106
|
+
return buffer.toString("utf8", 0, total);
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
export function confirm(question: string): boolean {
|
|
82
110
|
process.stdout.write(question + " [y/N] ");
|
|
83
|
-
|
|
84
|
-
return answer === "y" || answer === "yes" || answer === "o";
|
|
111
|
+
return parseAnswer(readAnswerLine());
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
// The name promises safety: malformed input returns [] instead of throwing.
|
|
@@ -98,11 +125,23 @@ export function dbPathFor(root: string, spec: Spec): string {
|
|
|
98
125
|
return resolvePath(root, spec.config.dbPath);
|
|
99
126
|
}
|
|
100
127
|
|
|
128
|
+
const MAX_STDIN_BYTES = 1024 * 1024; // 1 MiB: JSON payloads never need more
|
|
129
|
+
|
|
101
130
|
export function readStdin(): string {
|
|
102
131
|
// A single synchronous stdin read races with pipe delivery and can return null even when input was provided.
|
|
103
132
|
if (process.stdin.isTTY) return "";
|
|
104
133
|
try {
|
|
105
|
-
|
|
134
|
+
// P2-C (LOW): bounded read — readFileSync(0) had no size limit, so a huge
|
|
135
|
+
// pipe reached JSON.parse in full. Stop at the cap; oversized input gets
|
|
136
|
+
// truncated (and then fails to parse) instead of being loaded whole.
|
|
137
|
+
const buffer = Buffer.alloc(MAX_STDIN_BYTES);
|
|
138
|
+
let total = 0;
|
|
139
|
+
while (total < MAX_STDIN_BYTES) {
|
|
140
|
+
const read = readSync(0, buffer, total, MAX_STDIN_BYTES - total, null);
|
|
141
|
+
if (read <= 0) break;
|
|
142
|
+
total += read;
|
|
143
|
+
}
|
|
144
|
+
return buffer.toString("utf8", 0, total);
|
|
106
145
|
} catch {
|
|
107
146
|
return "";
|
|
108
147
|
}
|
package/src/commands/doctor.ts
CHANGED
|
@@ -33,7 +33,10 @@ function referencedSkills(spec: ReturnType<typeof loadSpec>): string[] {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
for (const rule of spec.rules) for (const skill of rule.require) ids.add(skill);
|
|
36
|
-
|
|
36
|
+
// Skill packs (kind: "skill") are delivery mechanisms, not index entries:
|
|
37
|
+
// their id ("flutter-skills", "expo-skills") never matches a SKILL.md name,
|
|
38
|
+
// so adding it made this check permanently red. Pack skills land in the
|
|
39
|
+
// index individually and are referenced through categories and rules above.
|
|
37
40
|
return [...ids].sort();
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -86,22 +89,37 @@ export function commandDoctor(parsed: Parsed): void {
|
|
|
86
89
|
blocking: missingCli.length > 0
|
|
87
90
|
});
|
|
88
91
|
|
|
89
|
-
|
|
92
|
+
// Two probes, one per live enforcement layer, both deterministic against the
|
|
93
|
+
// current ruleset (the old single probe fed README.md with empty categories,
|
|
94
|
+
// which matches no rule since the prose content rules were removed):
|
|
95
|
+
// - path probe: R13/R14 match a style file through pathGlobs alone.
|
|
96
|
+
// - category probe: R6 matches a classified code prompt; tier is set
|
|
97
|
+
// explicitly because probe text is file content, not a prompt, and
|
|
98
|
+
// determineTier would call it trivial and skip R6.
|
|
99
|
+
const probeInput = {
|
|
90
100
|
tool: "edit",
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
loadedSkills: [],
|
|
101
|
+
content: "",
|
|
102
|
+
categories: [] as string[],
|
|
103
|
+
loadedSkills: [] as string[],
|
|
95
104
|
installedSkills: index.skills,
|
|
96
105
|
installedIndexAvailable: index.available,
|
|
97
106
|
spec
|
|
107
|
+
};
|
|
108
|
+
const pathProbe = evaluateGate({ ...probeInput, filePath: "src/hero.css" });
|
|
109
|
+
const classProbe = evaluateGate({
|
|
110
|
+
...probeInput,
|
|
111
|
+
filePath: "README.md",
|
|
112
|
+
categories: ["code"],
|
|
113
|
+
tier: "full"
|
|
98
114
|
});
|
|
99
|
-
const
|
|
115
|
+
const blocked = (r: typeof pathProbe) => r.allow === false && r.missingSkills.length > 0;
|
|
116
|
+
const gateOk = spec.rules.length === 0 || (blocked(pathProbe) && blocked(classProbe));
|
|
117
|
+
const probeSkills = [...new Set([...pathProbe.missingSkills, ...classProbe.missingSkills])];
|
|
100
118
|
checks.push({
|
|
101
119
|
id: "gate",
|
|
102
120
|
label: "Operational gate",
|
|
103
121
|
ok: gateOk,
|
|
104
|
-
detail: gateOk ? `blocks a write without a loaded skill (${
|
|
122
|
+
detail: gateOk ? `blocks a write without a loaded skill (${probeSkills.join(", ")})` : "did not block a write without a loaded skill",
|
|
105
123
|
blocking: true
|
|
106
124
|
});
|
|
107
125
|
|
package/src/commands/gate.ts
CHANGED
|
@@ -4,10 +4,8 @@ import { loadSpec, NovahizHome } from "../spec.ts";
|
|
|
4
4
|
import { openDb } from "../db.ts";
|
|
5
5
|
import { loadInstalledSkills } from "../catalog.ts";
|
|
6
6
|
import { changeText } from "../content.ts";
|
|
7
|
-
import { evaluateGate } from "../gate.ts";
|
|
7
|
+
import { enforceLedgerChecks, evaluateGate } from "../gate.ts";
|
|
8
8
|
import { extractTargetPaths } from "../targets.ts";
|
|
9
|
-
import { activeTask, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "../ledger.ts";
|
|
10
|
-
import { autoCommit } from "../graft.ts";
|
|
11
9
|
|
|
12
10
|
export function commandGate(parsed: Parsed): void {
|
|
13
11
|
const root = NovahizHome();
|
|
@@ -25,7 +23,10 @@ export function commandGate(parsed: Parsed): void {
|
|
|
25
23
|
return;
|
|
26
24
|
}
|
|
27
25
|
const gateConfig = spec.config.gate;
|
|
28
|
-
|
|
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();
|
|
29
30
|
const categories = splitList(parsed.flags.categories);
|
|
30
31
|
let loaded = splitList(parsed.flags.loaded);
|
|
31
32
|
const session = asString(parsed.flags.session);
|
|
@@ -45,9 +46,13 @@ export function commandGate(parsed: Parsed): void {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// C2: a project-writable config must not silently switch enforcement off.
|
|
50
|
+
// enabled:false is ignored on purpose; the only kill-switch is the external
|
|
51
|
+
// env var NOVAHIZ_GATE (same philosophy as the hardcoded envEscape, H3/C5).
|
|
48
52
|
if (gateConfig.enabled === false) {
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
'novahiz: gate.enabled=false in novahiz.config.json is ignored; enforcement stays active. Use NOVAHIZ_GATE=off to disable the gate.\n'
|
|
55
|
+
);
|
|
51
56
|
}
|
|
52
57
|
// H3: always use NOVAHIZ_GATE (legacy: NOVAHIZ_GATE) — ignore configurable
|
|
53
58
|
// envEscape to prevent a writable config from redirecting the kill-switch.
|
|
@@ -65,6 +70,8 @@ export function commandGate(parsed: Parsed): void {
|
|
|
65
70
|
|
|
66
71
|
const single = asString(parsed.flags.file);
|
|
67
72
|
let content = asString(parsed.flags.content);
|
|
73
|
+
// C1: the prompt that triggered the edit drives the complexity tier.
|
|
74
|
+
const prompt = asString(parsed.flags.prompt);
|
|
68
75
|
let paths: string[];
|
|
69
76
|
if (single.length > 0) {
|
|
70
77
|
paths = [single];
|
|
@@ -88,38 +95,45 @@ export function commandGate(parsed: Parsed): void {
|
|
|
88
95
|
return;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
|
-
|
|
98
|
+
const pathless = paths.length === 0;
|
|
99
|
+
if (pathless) {
|
|
92
100
|
const writeTools = ["edit", "write", "patch", "apply_patch"];
|
|
93
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";
|
|
94
105
|
print({
|
|
95
|
-
allow:
|
|
106
|
+
allow: !enforced,
|
|
107
|
+
...(enforced ? {} : { wouldBlock: true }),
|
|
96
108
|
tool,
|
|
97
109
|
targets: [],
|
|
98
110
|
requiredSkills: [],
|
|
99
111
|
missingSkills: [],
|
|
100
112
|
reasons: ["no target path for a write tool"]
|
|
101
113
|
});
|
|
102
|
-
if (
|
|
114
|
+
if (enforced) process.exitCode = 2;
|
|
103
115
|
return;
|
|
104
116
|
}
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
// MAJEUR l.135: bash/shell/cron with no target path no longer pass
|
|
118
|
+
// unconditionally — they fall through to a pathless evaluation so
|
|
119
|
+
// prompt-scoped rules (supabase, browser, security, ...) still apply
|
|
120
|
+
// to commands like `python -e`, `curl -o` or `git apply`.
|
|
107
121
|
}
|
|
108
122
|
|
|
109
123
|
const index = loadInstalledSkills(spec);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
})
|
|
122
|
-
|
|
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 }) }));
|
|
123
137
|
|
|
124
138
|
const reasons: string[] = [];
|
|
125
139
|
let reviewWarning = "";
|
|
@@ -130,77 +144,37 @@ export function commandGate(parsed: Parsed): void {
|
|
|
130
144
|
|
|
131
145
|
// H4: single DB connection for trace, ledger checks, AND enforcement logging
|
|
132
146
|
let db: ReturnType<typeof openDb> | null = null;
|
|
147
|
+
let dbFailure = "";
|
|
133
148
|
try {
|
|
134
149
|
db = openDb(dbPathFor(root, spec));
|
|
135
150
|
} catch (error) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
dbFailure = String(error).slice(0, 200);
|
|
152
|
+
process.stderr.write(`novahiz: DB open failed: ${dbFailure}\n`);
|
|
153
|
+
if (gateConfig.mode === "block") {
|
|
154
|
+
// block mode keeps fail-closed behaviour: no enforcement trace, no edits.
|
|
155
|
+
print({ allow: false, error: `DB open failed`, tool, missingSkills: [], reasons: [`DB open failed`] });
|
|
156
|
+
process.exitCode = 2;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// MINEUR#7: warn/audit are advisory — degrade gracefully instead of
|
|
160
|
+
// locking every gated tool out; ledger checks are skipped and reported.
|
|
140
161
|
}
|
|
141
162
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
session
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
for (const entry of results) {
|
|
149
|
-
const trace = traceCheck(db, { sessionId: session, filePath: entry.path, required: true });
|
|
150
|
-
if (!trace.ok) {
|
|
151
|
-
entry.allow = false;
|
|
152
|
-
reasons.push(trace.reason);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const ledgerConfig = spec.config.ledger;
|
|
158
|
-
if (ledgerConfig?.enabled !== false) {
|
|
159
|
-
const task = activeTask(db, session || undefined);
|
|
160
|
-
if (task) {
|
|
161
|
-
if (["edit", "write", "patch", "apply_patch"].includes(tool)) recordEdit(db, task.id);
|
|
162
|
-
// Targeted review: block only paths owned by an open todo with an owner
|
|
163
|
-
// pattern. A due review no longer freezes every target.
|
|
164
|
-
for (const entry of results) {
|
|
165
|
-
const reason = reviewBlockReason(db, task.id, entry.path, ledgerConfig.review);
|
|
166
|
-
if (reason && !entry.ignored) {
|
|
167
|
-
entry.allow = false;
|
|
168
|
-
entry.reasons.push(reason);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (results.some((entry) => entry.reasons.some((reason) => reason.startsWith("plan review due")))) {
|
|
172
|
-
reasons.push(reviewDue(db, task.id, ledgerConfig.review).reason);
|
|
173
|
-
} else if (reviewDue(db, task.id, ledgerConfig.review).due) {
|
|
174
|
-
reviewWarning = reviewDue(db, task.id, ledgerConfig.review).reason;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const currentAllow = results.every((entry) => entry.allow);
|
|
180
|
-
if (session.length > 0) {
|
|
181
|
-
db.prepare(
|
|
182
|
-
"INSERT INTO enforcement_log (session_id, tool, file_path, file_class, decision, missing, matched_rules, logged_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
183
|
-
).run(
|
|
184
|
-
session,
|
|
185
|
-
tool,
|
|
186
|
-
paths.join(","),
|
|
187
|
-
results[0]?.fileClass ?? "other",
|
|
188
|
-
currentAllow ? "allow" : gateConfig.mode === "block" ? "block" : gateConfig.mode,
|
|
189
|
-
JSON.stringify(missingSkills),
|
|
190
|
-
JSON.stringify(results.flatMap((entry) => entry.matchedRules)),
|
|
191
|
-
new Date().toISOString()
|
|
192
|
-
);
|
|
193
|
-
try {
|
|
194
|
-
autoCommit("enforcement", `${currentAllow ? "allow" : "block"} ${tool}`);
|
|
195
|
-
} catch {
|
|
196
|
-
// autoCommit failures are non-critical; the enforcement is logged regardless
|
|
197
|
-
}
|
|
163
|
+
if (db) {
|
|
164
|
+
// Shared with the MCP novahiz_gate tool (src/gate.ts enforceLedgerChecks)
|
|
165
|
+
// so the two entry points enforce trace + ledger + the log identically.
|
|
166
|
+
const enforced = enforceLedgerChecks(db, { session, tool, paths, categories, results, spec, gateConfig });
|
|
167
|
+
reasons.push(...enforced.reasons);
|
|
168
|
+
reviewWarning = enforced.reviewWarning;
|
|
198
169
|
}
|
|
199
170
|
} finally {
|
|
200
171
|
db?.close();
|
|
201
172
|
}
|
|
202
173
|
|
|
203
174
|
const warnings: string[] = [];
|
|
175
|
+
if (dbFailure.length > 0 && gateConfig.mode !== "block") {
|
|
176
|
+
warnings.push(`enforcement trace unavailable (DB open failed): continuing without ledger checks in ${gateConfig.mode} mode.`);
|
|
177
|
+
}
|
|
204
178
|
if (reviewWarning.length > 0) warnings.push(reviewWarning);
|
|
205
179
|
if (unmatchedRequired.length > 0) {
|
|
206
180
|
warnings.push(
|
|
@@ -211,9 +185,15 @@ export function commandGate(parsed: Parsed): void {
|
|
|
211
185
|
warnings.push("skills index unreadable: all required skills are enforced.");
|
|
212
186
|
}
|
|
213
187
|
|
|
214
|
-
const
|
|
188
|
+
const blocked = results.some((entry) => !entry.allow);
|
|
189
|
+
const enforced = gateConfig.mode === "block";
|
|
190
|
+
// MINEUR#6: allow mirrors the exit contract — warn/audit never block, so a
|
|
191
|
+
// failing evaluation is reported as allow:true + wouldBlock:true (exit 0)
|
|
192
|
+
// instead of a contradictory allow:false + exit 0.
|
|
193
|
+
const allow = !blocked || !enforced;
|
|
215
194
|
print({
|
|
216
195
|
allow,
|
|
196
|
+
...(blocked && !enforced ? { wouldBlock: true } : {}),
|
|
217
197
|
tool,
|
|
218
198
|
mode: gateConfig.mode,
|
|
219
199
|
indexMissing,
|
|
@@ -225,6 +205,7 @@ export function commandGate(parsed: Parsed): void {
|
|
|
225
205
|
targets: results.map((entry) => ({
|
|
226
206
|
path: entry.path,
|
|
227
207
|
fileClass: entry.fileClass,
|
|
208
|
+
tier: entry.tier,
|
|
228
209
|
ignored: entry.ignored,
|
|
229
210
|
roadmap: entry.roadmap,
|
|
230
211
|
requiredSkills: entry.requiredSkills,
|
package/src/commands/graft.ts
CHANGED
|
@@ -26,8 +26,8 @@ Usage:
|
|
|
26
26
|
novahiz graft log [N] Show last N commits (default: 20)
|
|
27
27
|
novahiz graft diff Diff current ledger vs last commit
|
|
28
28
|
novahiz graft status Show graft status
|
|
29
|
-
novahiz graft restore <hash>
|
|
30
|
-
novahiz graft export <hash> <path> Export snapshot as .sqlite
|
|
29
|
+
novahiz graft restore <hash> [--force] Restore ledger to a revision (--force required; a backup is written first)
|
|
30
|
+
novahiz graft export <hash> <path> [--force] Export snapshot as .sqlite (path must stay in the workspace; --force to overwrite)
|
|
31
31
|
novahiz graft commit -m MSG Manual commit with message
|
|
32
32
|
novahiz graft help Show this help
|
|
33
33
|
`;
|
|
@@ -99,11 +99,13 @@ export function graftCommand(argv: string[]): void {
|
|
|
99
99
|
if (!isGraftInitialized()) {
|
|
100
100
|
fail("graft not initialized. Run: novahiz graft init");
|
|
101
101
|
}
|
|
102
|
-
const
|
|
102
|
+
const force = argv.slice(1).includes("--force");
|
|
103
|
+
const rev = argv.slice(1).find((a) => a !== "--force");
|
|
103
104
|
if (!rev) {
|
|
104
|
-
fail("usage: novahiz graft restore <revision>");
|
|
105
|
+
fail("usage: novahiz graft restore <revision> [--force]");
|
|
106
|
+
return;
|
|
105
107
|
}
|
|
106
|
-
const result = restoreGraft(rev);
|
|
108
|
+
const result = restoreGraft(rev, { force });
|
|
107
109
|
if (result.success) {
|
|
108
110
|
console.log(`ok: ${result.message}`);
|
|
109
111
|
} else {
|
|
@@ -116,12 +118,15 @@ export function graftCommand(argv: string[]): void {
|
|
|
116
118
|
if (!isGraftInitialized()) {
|
|
117
119
|
fail("graft not initialized. Run: novahiz graft init");
|
|
118
120
|
}
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
+
const rest = argv.slice(1).filter((a) => a !== "--force");
|
|
122
|
+
const force = argv.slice(1).includes("--force");
|
|
123
|
+
const rev = rest[0];
|
|
124
|
+
const outPath = rest[1];
|
|
121
125
|
if (!rev || !outPath) {
|
|
122
|
-
fail("usage: novahiz graft export <revision> <output-path>");
|
|
126
|
+
fail("usage: novahiz graft export <revision> <output-path> [--force]");
|
|
127
|
+
return;
|
|
123
128
|
}
|
|
124
|
-
const result = exportGraft(rev, outPath);
|
|
129
|
+
const result = exportGraft(rev, outPath, { force });
|
|
125
130
|
if (result.success) {
|
|
126
131
|
console.log(`ok: ${result.message}`);
|
|
127
132
|
} else {
|
package/src/commands/inspect.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { asString, dbPathFor, numberFlag, print, safeJsonArray, type Parsed } from "./context.ts";
|
|
2
|
+
import { asString, dbPathFor, flagOn, numberFlag, print, readStdin, safeJsonArray, type Parsed } from "./context.ts";
|
|
3
3
|
import { loadSpec, NovahizHome } from "../spec.ts";
|
|
4
4
|
import { getMeta, openDb, setMeta } from "../db.ts";
|
|
5
5
|
import { loadCatalog, loadInstalledSkills, persistCatalog, scanSkills, writeCatalog, writeSkillIndex } from "../catalog.ts";
|
|
@@ -48,7 +48,13 @@ export function commandSync(): void {
|
|
|
48
48
|
export function commandClassify(parsed: Parsed): void {
|
|
49
49
|
const root = NovahizHome();
|
|
50
50
|
const spec = loadSpec(root);
|
|
51
|
-
|
|
51
|
+
// P0-B: --stdin keeps the prompt off argv — argv allowed option injection
|
|
52
|
+
// (--home), broke past the Windows 32k limit, and leaked the prompt into
|
|
53
|
+
// the process list. The slice bounds a hostile stdin (full fix in P2-C).
|
|
54
|
+
const raw = flagOn(parsed, "stdin")
|
|
55
|
+
? readStdin().slice(0, 200_000)
|
|
56
|
+
: parsed.positionals.slice(1).join(" ") || asString(parsed.flags.text);
|
|
57
|
+
const text = raw.trim();
|
|
52
58
|
if (text.length === 0) {
|
|
53
59
|
throw new Error("classify needs a prompt: pass it as an argument or with --text");
|
|
54
60
|
}
|
|
@@ -111,6 +117,22 @@ export function commandSessionLoad(parsed: Parsed): void {
|
|
|
111
117
|
process.exitCode = 1;
|
|
112
118
|
return;
|
|
113
119
|
}
|
|
120
|
+
// P0-B: the plugin joins recorded names with commas and the gate re-splits
|
|
121
|
+
// them — a loose name could inject extra "loaded" skills.
|
|
122
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(skill)) {
|
|
123
|
+
print({ error: `invalid skill name format: "${skill.slice(0, 64)}"` });
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// P0-B: only skills present in the installed index may count as loaded.
|
|
128
|
+
// An unreadable index cannot verify anything — accept the record (the gate
|
|
129
|
+
// degrades on its own side via indexMissing).
|
|
130
|
+
const installed = loadInstalledSkills(spec);
|
|
131
|
+
if (installed.available && !installed.skills.has(skill)) {
|
|
132
|
+
print({ error: `unknown skill "${skill}" — not in the installed index. Run "novahiz sync" to rebuild it.` });
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
114
136
|
const db = openDb(dbPathFor(root, spec));
|
|
115
137
|
// H5: ensure session exists for FK compliance
|
|
116
138
|
const ts = new Date().toISOString();
|
|
@@ -197,6 +219,10 @@ export function commandStep(parsed: Parsed): void {
|
|
|
197
219
|
}
|
|
198
220
|
const db = openDb(dbPathFor(root, spec));
|
|
199
221
|
if (done.length > 0) {
|
|
222
|
+
const ts = new Date().toISOString();
|
|
223
|
+
// roadmap_progress.session_id has a foreign key to sessions(id):
|
|
224
|
+
// ensure the session row exists before recording a step.
|
|
225
|
+
db.prepare("INSERT OR IGNORE INTO sessions (id, categories, required_skills, updated_at) VALUES (?, '[]', '[]', ?)").run(session, ts);
|
|
200
226
|
db.prepare(
|
|
201
227
|
"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"
|
|
202
228
|
).run(session, done, new Date().toISOString());
|