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.
Files changed (54) hide show
  1. package/NOTICE.md +18 -6
  2. package/README.md +12 -7
  3. package/adapters/README.md +1 -1
  4. package/adapters/opencode/agent/novahiz.md +2 -2
  5. package/adapters/opencode/instructions.md +2 -1
  6. package/adapters/opencode/novahiz.ts +336 -40
  7. package/catalog/categories.json +282 -9
  8. package/catalog/overrides.json +727 -0
  9. package/catalog/providers.json +63 -10
  10. package/catalog/rules.json +42 -9
  11. package/docs/ARCHITECTURE.md +2 -2
  12. package/docs/CATALOG.md +4 -4
  13. package/docs/CLASSIFICATION.md +3 -1
  14. package/docs/CONFIGURATION.md +5 -5
  15. package/docs/CONVERGENCE-2026-09-25.md +5 -0
  16. package/docs/GATE.md +26 -15
  17. package/docs/HARNESSES.md +10 -6
  18. package/docs/INSTALL.md +1 -0
  19. package/docs/PROVIDERS.md +21 -6
  20. package/docs/ROADMAPS.md +29 -0
  21. package/docs/RULES.md +7 -4
  22. package/install/bootstrap.mjs +29 -24
  23. package/install/install.mjs +56 -19
  24. package/install/lib.mjs +20 -4
  25. package/mcp/novahiz-tools/README.md +5 -0
  26. package/mcp/novahiz-tools/index.mjs +78 -7
  27. package/novahiz.config.example.json +4 -3
  28. package/package.json +3 -2
  29. package/skills/gate/SKILL.md +16 -1
  30. package/skills/novahiz-analyse/SKILL.md +2 -2
  31. package/skills/novahiz-audit/SKILL.md +1 -1
  32. package/skills/novahiz-code-review/SKILL.md +1 -1
  33. package/skills/novahiz-converge/SKILL.md +1 -1
  34. package/skills/novahiz-gate/SKILL.md +2 -1
  35. package/skills/novahiz-implement/SKILL.md +2 -2
  36. package/skills/novahiz-plan/SKILL.md +2 -2
  37. package/skills/novahiz-planner/SKILL.md +5 -3
  38. package/skills/novahiz-task/SKILL.md +1 -1
  39. package/skills/prompt-rewriter/SKILL.md +18 -13
  40. package/src/cli.ts +24 -4
  41. package/src/commands/context.ts +43 -4
  42. package/src/commands/doctor.ts +26 -8
  43. package/src/commands/gate.ts +66 -85
  44. package/src/commands/graft.ts +14 -9
  45. package/src/commands/inspect.ts +28 -2
  46. package/src/commands/task.ts +30 -1
  47. package/src/exec.ts +38 -10
  48. package/src/gate-repair.ts +87 -0
  49. package/src/gate.ts +156 -14
  50. package/src/graft.ts +56 -23
  51. package/src/ledger.ts +12 -2
  52. package/src/memory.ts +14 -1
  53. package/src/prompt-rewriter.ts +357 -24
  54. 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 translates non-English prompts into optimized English before classification.
5
- The agent always responds in the user's original language. No external dependencies.
6
- Built into the plugin adapter.
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.0.0"
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. The highest score wins. No external dependency is involved.
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
- French and Arabic have dedicated term maps of forty and thirty patterns. Examples:
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`. When a rewrite happens:
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
- 1. The enforcement block carries `User language: XX, respond in this language`.
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, respond in this language, not English.
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":
@@ -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
- const answer = process.stdin.read()?.toString().trim().toLowerCase();
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
- return readFileSync(0, 'utf8');
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
  }
@@ -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
- for (const provider of spec.providers) if (provider.kind === "skill") ids.add(provider.id);
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
- const probe = evaluateGate({
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
- 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: [],
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 gateOk = spec.rules.length === 0 || (probe.allow === false && probe.missingSkills.length > 0);
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 (${probe.missingSkills.join(", ")})` : "did not block 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
 
@@ -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
- const tool = asString(parsed.flags.tool) || "edit";
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
- print({ allow: true, disabled: true, tool });
50
- return;
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
- if (paths.length === 0) {
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: false,
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 (gateConfig.mode === "block") process.exitCode = 2;
114
+ if (enforced) process.exitCode = 2;
103
115
  return;
104
116
  }
105
- print({ allow: true, tool, targets: [], requiredSkills: [], missingSkills: [], reason: "no target path" });
106
- return;
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 results = paths.map((filePath) => ({
111
- path: filePath,
112
- ...evaluateGate({
113
- tool,
114
- filePath,
115
- content,
116
- categories,
117
- loadedSkills: loaded,
118
- installedSkills: index.skills,
119
- installedIndexAvailable: index.available,
120
- spec
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
- process.stderr.write(`novahiz: DB open failed: ${String(error).slice(0, 200)}\n`);
137
- print({ allow: false, error: `DB open failed`, tool, missingSkills: [], reasons: [`DB open failed`] });
138
- process.exitCode = 2;
139
- return;
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
- const traceConfig = gateConfig.trace;
143
- const traceRequired =
144
- traceConfig?.enabled === true &&
145
- session.length > 0 &&
146
- categories.some((category) => traceConfig.categories.includes(category));
147
- if (traceRequired) {
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 allow = results.every((entry) => entry.allow);
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,
@@ -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> Restore ledger to a revision
30
- novahiz graft export <hash> <path> Export snapshot as .sqlite file
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 rev = argv[1];
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 rev = argv[1];
120
- const outPath = argv[2];
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 {
@@ -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
- const text = (parsed.positionals.slice(1).join(" ") || asString(parsed.flags.text)).trim();
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());