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
@@ -4,6 +4,8 @@ import { loadSpec, NovahizHome } from "../spec.ts";
4
4
  import { openDb } from "../db.ts";
5
5
  import { activeTask, addTodos, amendTodo, blockTodo, completeTodo, createTask, dropTask, dropTodo, getTask, getTodo, insertTodo, ledgerSummary, recordTodoDone, reorderTodos, resume, reviewDue, reviewTask, revisionSignals, startTodo, type ReviewDiff, type TodoAmendment, type TodoInput, type TodoKind } from "../ledger.ts";
6
6
 
7
+ const TODO_KINDS: readonly string[] = ["read", "edit", "verify", "delegate"];
8
+
7
9
  export function normalizeTodoInput(item: unknown): TodoInput {
8
10
  const record = (item ?? {}) as Record<string, unknown>;
9
11
  const owners = Array.isArray(record.owner)
@@ -14,9 +16,13 @@ export function normalizeTodoInput(item: unknown): TodoInput {
14
16
  const dependsRaw = record.dependsOn ?? record.depends_on;
15
17
  const dependsOn = Array.isArray(dependsRaw) ? dependsRaw.map(String) : [];
16
18
  const maxIterations = Number(record.maxIterations ?? record.max_iterations);
19
+ const kind = record.kind ? String(record.kind) : "edit";
20
+ if (!TODO_KINDS.includes(kind)) {
21
+ throw new Error(`invalid todo kind: ${kind} (expected one of ${TODO_KINDS.join(", ")})`);
22
+ }
17
23
  return {
18
24
  label: String(record.label ?? record.title ?? "").trim(),
19
- kind: (record.kind ? String(record.kind) : "edit") as TodoKind,
25
+ kind: kind as TodoKind,
20
26
  acceptance: record.acceptance ? String(record.acceptance) : undefined,
21
27
  owner: owners.length > 0 ? owners.join(",") : undefined,
22
28
  dependsOn,
@@ -62,6 +68,8 @@ export function commandTask(parsed: Parsed): void {
62
68
 
63
69
  if (action === "read") return taskRead(parsed, db, session, spec);
64
70
  if (action === "status") return taskRead(parsed, db, session, spec);
71
+ if (action === "resume") return taskResume(db, session);
72
+ if (action === "current") return taskCurrent(db, session);
65
73
 
66
74
  print({ error: `unknown task action: ${action}`, actions: ["new", "plan", "todo", "start", "done", "block", "review", "amend", "insert", "drop", "reorder", "signals", "status", "resume", "current"] });
67
75
  process.exitCode = 1;
@@ -90,6 +98,22 @@ function taskRead(parsed: Parsed, db: ReturnType<typeof openDb>, session: string
90
98
  print({ ...state, summary, review, signals });
91
99
  }
92
100
 
101
+ function taskResume(db: ReturnType<typeof openDb>, session: string): void {
102
+ const state = resume(db, session || undefined);
103
+ const summary = ledgerSummary(state);
104
+ const next = state.todos.find((todo) => todo.status === "pending");
105
+ print({
106
+ ...state,
107
+ summary,
108
+ next: next ? { id: next.id, seq: next.seq, label: next.label, kind: next.kind } : null
109
+ });
110
+ }
111
+
112
+ function taskCurrent(db: ReturnType<typeof openDb>, session: string): void {
113
+ const state = resume(db, session || undefined);
114
+ print({ task: state.task, current: state.current, todos: state.todos.length });
115
+ }
116
+
93
117
  function taskReorder(parsed: Parsed, db: ReturnType<typeof openDb>, session: string, spec: ReturnType<typeof loadSpec>): void {
94
118
  const taskId = asString(parsed.flags.task) || activeTask(db, session || undefined)?.id;
95
119
  if (!taskId) {
@@ -166,6 +190,11 @@ function taskAmend(parsed: Parsed, db: ReturnType<typeof openDb>, session: strin
166
190
  const label = asString(parsed.flags.label);
167
191
  if (label) patch.label = label;
168
192
  const kind = asString(parsed.flags.kind);
193
+ if (kind && !TODO_KINDS.includes(kind)) {
194
+ print({ error: `invalid todo kind: ${kind} (expected one of ${TODO_KINDS.join(", ")})` });
195
+ process.exitCode = 1;
196
+ return;
197
+ }
169
198
  if (kind) patch.kind = kind as TodoKind;
170
199
  if (parsed.flags.acceptance !== undefined) patch.acceptance = asString(parsed.flags.acceptance);
171
200
  const owners = splitList(parsed.flags.owner);
package/src/exec.ts CHANGED
@@ -47,11 +47,12 @@ function resolveSpawn(bin: string, args: string[]): [string, string[]] {
47
47
  return [shell, ["/d", "/s", "/c", [bin, ...args].join(" ")]];
48
48
  }
49
49
 
50
- // Bootstrap may only start a known interpreter or packager. Arguments stay
51
- // unvalidated on purpose (a legitimate argument is sometimes a whole shell
52
- // command line), so the binary itself is what gets bounded here.
50
+ // Bootstrap may only start a known interpreter or packager. Shell binaries
51
+ // (sh, bash, pwsh, powershell, cmd) are deliberately absent: arguments are
52
+ // parsed by the executable itself, so a shell would turn this allowlist into
53
+ // a formality by reinterpreting anything it is handed.
53
54
  const SAFE_BOOTSTRAP_BINS = new Set([
54
- "node", "npm", "npx", "uv", "uvx", "sh", "bash", "pwsh", "powershell", "cmd", "cmd.exe", "python", "py"
55
+ "node", "npm", "npx", "uv", "uvx", "python", "py"
55
56
  ]);
56
57
 
57
58
  function normalizeBin(bin: string): string {
@@ -69,17 +70,44 @@ function refusedBootstrap(bin: string): CommandResult {
69
70
  return { ok: false, status: null, stdout: "", stderr: "", error: `refused disallowed bootstrap binary: ${bin}` };
70
71
  }
71
72
 
72
- // Bootstrap argv is trusted: it comes from catalog/providers.json, which ships
73
- // inside the repository. Arguments stay unvalidated on purpose, because a
74
- // legitimate argument can be a whole shell command line (for example
75
- // `sh -c "curl ... | sh"`) that must reach its interpreter as one unparsed
76
- // token. The binary itself is bounded to SAFE_BOOTSTRAP_BINS, so editing the
77
- // registry to point `bin` at an arbitrary executable is refused.
73
+ // Bootstrap argv comes from catalog/providers.json. Every token is bounded:
74
+ // the bin must be in SAFE_BOOTSTRAP_BINS, each argument must pass SAFE_TOKEN
75
+ // (no whitespace, no shell metacharacter), and interpreters that execute an
76
+ // inline string must not receive one — `python -c` and `node -e` are the
77
+ // shell -c equivalents that would otherwise nullify the allowlist.
78
+ const CODE_RUNNER_FLAGS: Record<string, string[]> = {
79
+ python: ["-c", "--command"],
80
+ py: ["-c", "--command"],
81
+ node: ["-e", "--eval", "-p", "--print", "--repl"]
82
+ };
83
+
84
+ export function isCodeRunnerFlag(bin: string, token: string): boolean {
85
+ const name = normalizeBin(bin).replace(/\.(exe|cmd|bat)$/i, "");
86
+ const flags = CODE_RUNNER_FLAGS[name];
87
+ if (!flags) return false;
88
+ if (token.startsWith("--")) {
89
+ const eq = token.indexOf("=");
90
+ return flags.includes(eq > 0 ? token.slice(0, eq) : token);
91
+ }
92
+ // A short option may carry its value attached: -ccode, -ecode.
93
+ if (token.startsWith("-") && token.length > 2) return flags.includes(token.slice(0, 2));
94
+ return flags.includes(token);
95
+ }
96
+
97
+ function refusedCodeRunner(token: string): CommandResult {
98
+ return { ok: false, status: null, stdout: "", stderr: "", error: `refused code-runner flag: ${token}` };
99
+ }
100
+
78
101
  export function runScript(argv: string[]): CommandResult {
79
102
  if (argv.length === 0) return refused("");
80
103
  const [bin, ...args] = argv;
81
104
  if (!SAFE_TOKEN.test(bin)) return refused(bin);
82
105
  if (!bootstrapBinAllowed(bin)) return refusedBootstrap(bin);
106
+ for (const arg of args) {
107
+ if (arg.length === 0) return refused("");
108
+ if (isCodeRunnerFlag(bin, arg)) return refusedCodeRunner(arg);
109
+ if (!SAFE_TOKEN.test(arg)) return refused(arg);
110
+ }
83
111
  const result = spawnSync(bin, args, { encoding: "utf8", windowsHide: true, shell: false });
84
112
  return resultFrom(result.status, result.stdout ?? "", result.stderr ?? "", result.error?.message);
85
113
  }
@@ -0,0 +1,87 @@
1
+ // Auto-repair for gate FAIL results.
2
+ //
3
+ // When the gate denies a tool call because required skills are not loaded,
4
+ // the denial must be actionable: parse the structured FAIL payload and build
5
+ // a directive the agent executes immediately (load the named skills, retry
6
+ // the exact same call, resume the user's task) without asking the user and
7
+ // without bypassing the gate.
8
+ //
9
+ // Design guardrails:
10
+ // - Fail-closed: this only rewrites the denial message. It never grants the
11
+ // call. The retry passes only when the gate CLI itself sees the skills.
12
+ // - Attempt tracking: repeating the identical failure means the skill()
13
+ // loads did not register (skill not installed, index stale) — the message
14
+ // escalates to a diagnosis instead of looping forever.
15
+ // - No bypass: the directive never mentions NOVAHIZ_GATE or an alternate
16
+ // tool as a remedy.
17
+
18
+ export type GateFailure = {
19
+ tool: string;
20
+ missingSkills: string[];
21
+ reasons: string[];
22
+ error: string | null;
23
+ };
24
+
25
+ /**
26
+ * Parse the JSON printed by `novahiz gate` on exit 2. Returns null when the
27
+ * payload is not readable — the caller then falls back to the raw stdout.
28
+ */
29
+ export function parseGateFailure(stdout: string): GateFailure | null {
30
+ let parsed: unknown;
31
+ try {
32
+ parsed = JSON.parse(stdout);
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (!parsed || typeof parsed !== "object") return null;
37
+ const record = parsed as Record<string, unknown>;
38
+ const asStringArray = (value: unknown): string[] =>
39
+ Array.isArray(value)
40
+ ? value.filter((entry): entry is string => typeof entry === "string")
41
+ : [];
42
+ const tool = typeof record.tool === "string" && record.tool.length > 0 ? record.tool : "tool";
43
+ return {
44
+ tool,
45
+ missingSkills: asStringArray(record.missingSkills),
46
+ reasons: asStringArray(record.reasons),
47
+ error: typeof record.error === "string" ? record.error : null
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the actionable denial for a gate failure.
53
+ *
54
+ * attempt 1 — missing skills: the repair protocol (load, retry, resume).
55
+ * attempt 2+ — the same skills are still missing: the loads did not take
56
+ * effect, so the directive escalates to diagnosis and stops
57
+ * the loop instead of repeating itself.
58
+ */
59
+ export function buildRepairDirective(failure: GateFailure, attempt: number): string {
60
+ const head = `Novahiz gate blocked ${failure.tool}.`;
61
+ const missing = failure.missingSkills;
62
+
63
+ if (missing.length === 0) {
64
+ // Nothing the agent can repair by loading — state the reasons and stop.
65
+ const detail = failure.error ?? failure.reasons.join("; ") ?? "no reason reported";
66
+ return `${head}\nBlocked by rule, not by a missing skill: ${detail}\nResolve the listed rule, then retry once. Never bypass the gate.`;
67
+ }
68
+
69
+ if (attempt <= 1) {
70
+ const steps = missing.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
71
+ return [
72
+ `${head} Missing skills: ${missing.join(", ")}.`,
73
+ "AUTO-REPAIR — execute now, do not ask the user, do not stop:",
74
+ steps,
75
+ ` ${missing.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
76
+ "Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
77
+ ].join("\n");
78
+ }
79
+
80
+ return [
81
+ `${head} AUTO-REPAIR FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
82
+ "The loads did not register — diagnose instead of retrying:",
83
+ " 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
84
+ " 2. Realign the index (`novahiz sync`), then load the named skills again.",
85
+ "If the skill genuinely does not exist, report that honestly to the user and stop. Never bypass the gate."
86
+ ].join("\n");
87
+ }
package/src/gate.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import type { Rule, Spec } from "./spec.ts";
2
2
  import { hasPlaceholder, hasProse, hasStyle, isTrivial } from "./content.ts";
3
3
  import { determineTier, type ComplexityTier } from "./complexity.ts";
4
+ // Shared ledger enforcement (audit P1-D/M1): the CLI command and the MCP
5
+ // novahiz_gate tool both run these checks so their verdicts cannot diverge.
6
+ import { activeTask, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "./ledger.ts";
7
+ import { autoCommit } from "./graft.ts";
8
+ import type { openDb } from "./db.ts";
9
+
10
+ type GateDb = ReturnType<typeof openDb>;
4
11
 
5
12
  type FileClass = "code" | "text" | "design" | "data" | "config" | "other";
6
13
 
@@ -204,34 +211,57 @@ export function contentSatisfies(content: string, patterns: string[]): boolean {
204
211
  });
205
212
  }
206
213
 
207
- function selectorMatches(rule: Rule, classification: FileClass, path: string, categories: string[]): boolean {
214
+ function selectorMatches(rule: Rule, classification: FileClass, path: string, categories: string[], pathless = false): boolean {
208
215
  const when = rule.when;
209
216
  const checks: boolean[] = [];
210
- if (when.fileClasses && when.fileClasses.length > 0) checks.push(when.fileClasses.includes(classification));
211
- if (when.pathGlobs && when.pathGlobs.length > 0) checks.push(when.pathGlobs.some((glob) => globToRegExp(glob).test(path)));
212
- if (when.promptCategories && when.promptCategories.length > 0) {
217
+ if (pathless) {
218
+ // MAJEUR l.135: with no target path, only prompt-scoped selectors can
219
+ // apply. Path/file rules are skipped silently — there is no path to test
220
+ // and the absence is not the misconfiguration M1 warns about.
221
+ if (!when.promptCategories || when.promptCategories.length === 0) return false;
213
222
  checks.push(when.promptCategories.some((category) => categories.includes(category)));
214
- }
215
- // M1: a rule with no selectors must match nothing, not everything.
216
- // An empty `when` is a misconfiguration — fail closed, not open.
217
- if (checks.length === 0) {
218
- console.error(`[Novahiz] rule "${rule.id}" has empty selectors — it will never match`);
219
- return false;
223
+ } else {
224
+ if (when.fileClasses && when.fileClasses.length > 0) checks.push(when.fileClasses.includes(classification));
225
+ if (when.pathGlobs && when.pathGlobs.length > 0) checks.push(when.pathGlobs.some((glob) => globToRegExp(glob).test(path)));
226
+ if (when.promptCategories && when.promptCategories.length > 0) {
227
+ checks.push(when.promptCategories.some((category) => categories.includes(category)));
228
+ }
229
+ // M1: a rule with no selectors must match nothing, not everything.
230
+ // An empty `when` is a misconfiguration — fail closed, not open.
231
+ if (checks.length === 0) {
232
+ console.error(`[Novahiz] rule "${rule.id}" has empty selectors — it will never match`);
233
+ return false;
234
+ }
220
235
  }
221
236
  // Default to "all" — all selectors must match. This is the safe default:
222
237
  // a rule with fileClasses + promptCategories requires BOTH to match.
223
238
  return when.match === "any" ? checks.some(Boolean) : checks.every(Boolean);
224
239
  }
225
240
 
241
+ // C3: files whose content defines what the gate requires. They live under
242
+ // build/ but must never be treated as ignored build output.
243
+ const PROTECTED_INDEX_SUFFIXES = ["/build/installed-skills.json", "/build/catalog.json"];
244
+
245
+ function isProtectedIndexPath(path: string): boolean {
246
+ return PROTECTED_INDEX_SUFFIXES.some((suffix) => path === suffix.slice(1) || path.endsWith(suffix));
247
+ }
226
248
  type GateInput = {
227
249
  tool: string;
228
250
  filePath: string;
229
251
  content?: string;
252
+ /** C1: user prompt that triggered the edit. The complexity tier is
253
+ * computed from it when present — the edited content alone under-reports
254
+ * how much pipeline a short edit of a complex request needs. */
255
+ prompt?: string;
230
256
  categories?: string[];
231
257
  loadedSkills?: string[];
232
258
  installedSkills?: ReadonlySet<string> | null;
233
259
  installedIndexAvailable?: boolean;
234
260
  tier?: ComplexityTier;
261
+ /** MAJEUR l.135: true when the caller has no target path (bash/shell/cron
262
+ * with no detectable file). Only prompt-scoped selectors are evaluated;
263
+ * path/file rules cannot match and are skipped without the M1 warning. */
264
+ pathless?: boolean;
235
265
  spec: Spec;
236
266
  };
237
267
 
@@ -255,9 +285,18 @@ export function evaluateGate(input: GateInput): GateResult {
255
285
  const categories = input.categories ?? [];
256
286
  const path = input.filePath.replace(/\\/g, "/");
257
287
  const content = input.content ?? "";
288
+ // MAJEUR l.135: a pathless call (bash/shell/cron without a target) evaluates
289
+ // prompt-scoped rules only and never consults path-based ignore globs.
290
+ const pathless = input.pathless === true;
258
291
 
259
292
  try {
260
- const ignored = input.spec.config.gate.ignoreFiles.some((glob) => globToRegExp(glob).test(path));
293
+ // C3: the skill index lives under build/ (matched by the default
294
+ // **/build/** ignore glob). Editing it must never bypass the gate —
295
+ // dropping a skill from the index would silently un-require it.
296
+ const ignored =
297
+ !pathless &&
298
+ !isProtectedIndexPath(path) &&
299
+ input.spec.config.gate.ignoreFiles.some((glob) => globToRegExp(glob).test(path));
261
300
  if (ignored) {
262
301
  return {
263
302
  allow: true,
@@ -277,13 +316,16 @@ export function evaluateGate(input: GateInput): GateResult {
277
316
 
278
317
  // H3: Determine complexity tier BEFORE rule evaluation so that trivial
279
318
  // prompts can skip Novahiz-specific rules entirely.
280
- const tier = input.tier ?? determineTier(content);
319
+ // C1: compute the tier from the user prompt when available; content is
320
+ // only a fallback for callers that do not carry a prompt (probes).
321
+ const prompt = (input.prompt ?? "").trim();
322
+ const tier = input.tier ?? determineTier(prompt.length > 0 ? prompt : content);
281
323
 
282
324
  const requiredSkills: string[] = [];
283
325
  const matchedRules: string[] = [];
284
326
 
285
327
  for (const rule of input.spec.rules) {
286
- if (!selectorMatches(rule, classification, path, categories)) continue;
328
+ if (!selectorMatches(rule, classification, path, categories, pathless)) continue;
287
329
  // Tier gating for R6-Novahiz: trivial = skip entirely, lite = only implement+converge
288
330
  if (rule.id === "R6-Novahiz") {
289
331
  if (tier === "trivial") continue;
@@ -303,7 +345,14 @@ export function evaluateGate(input: GateInput): GateResult {
303
345
  // For contentExcludes: regex error → treat as "exclude matched" → skip rule.
304
346
  // For contentMatches: regex error → treat as "match succeeded" → apply rule.
305
347
  if (rule.when.contentExcludes) {
306
- try { if (contentSatisfies(content, rule.when.contentExcludes)) continue; } catch { continue; }
348
+ try {
349
+ if (contentSatisfies(content, rule.when.contentExcludes)) continue;
350
+ } catch {
351
+ // MINEUR#1: an invalid exclude pattern must never disable the rule
352
+ // (the old `continue` was a silent fail-open). Fail closed instead:
353
+ // apply the rule and surface the catalogue bug on stderr.
354
+ console.error(`[Novahiz] rule "${rule.id}": invalid contentExcludes pattern — applying the rule anyway`);
355
+ }
307
356
  }
308
357
  if (rule.when.contentMatches) {
309
358
  try { if (!contentSatisfies(content, rule.when.contentMatches)) continue; } catch { /* fail-closed: apply rule */ }
@@ -366,6 +415,11 @@ export function evaluateGate(input: GateInput): GateResult {
366
415
 
367
416
  const reasons: string[] = [];
368
417
  for (const skill of missingSkills) reasons.push(`missing skill: ${skill}`);
418
+ // C3: an index gap must be visible, not silent. The skill is not enforced
419
+ // (kept out of requiredSkills) — say so loudly so a sync gets triggered.
420
+ for (const skill of unmatchedRequired) {
421
+ reasons.push(`required skill not in index, not enforced: ${skill} — run "novahiz" sync to realign`);
422
+ }
369
423
  if (placeholder) reasons.push("placeholder marker found in content");
370
424
 
371
425
  return {
@@ -399,3 +453,91 @@ export function evaluateGate(input: GateInput): GateResult {
399
453
  };
400
454
  }
401
455
  }
456
+
457
+ // Ledger + trace enforcement shared by the CLI gate command and the MCP
458
+ // novahiz_gate tool (audit P1-D/M1): same checks and the same log rows, so the
459
+ // two entry points cannot drift apart. Mutates results[].allow exactly like
460
+ // the original CLI block; returns aggregate reasons and the review warning.
461
+ export function enforceLedgerChecks(
462
+ db: GateDb,
463
+ input: {
464
+ session: string;
465
+ tool: string;
466
+ paths: string[];
467
+ categories: string[];
468
+ results: (GateResult & { path: string })[];
469
+ spec: Spec;
470
+ gateConfig: Spec["config"]["gate"];
471
+ }
472
+ ): { reasons: string[]; reviewWarning: string } {
473
+ const { session, tool, paths, categories, results, spec, gateConfig } = input;
474
+ const reasons: string[] = [];
475
+ let reviewWarning = "";
476
+
477
+ const traceConfig = gateConfig.trace;
478
+ const traceRequired =
479
+ traceConfig?.enabled === true &&
480
+ session.length > 0 &&
481
+ categories.some((category) => traceConfig.categories.includes(category));
482
+ if (traceRequired) {
483
+ for (const entry of results) {
484
+ // MAJEUR l.135: a pathless entry (bash/shell/cron with no target) has
485
+ // no file to trace — skip the per-path check, keep the log row below.
486
+ if (entry.path.length === 0) continue;
487
+ const trace = traceCheck(db, { sessionId: session, filePath: entry.path, required: true });
488
+ if (!trace.ok) {
489
+ entry.allow = false;
490
+ reasons.push(trace.reason);
491
+ }
492
+ }
493
+ }
494
+
495
+ const ledgerConfig = spec.config.ledger;
496
+ if (ledgerConfig?.enabled !== false) {
497
+ const task = activeTask(db, session || undefined);
498
+ if (task) {
499
+ if (["edit", "write", "patch", "apply_patch"].includes(tool)) recordEdit(db, task.id);
500
+ // Targeted review: block only paths owned by an open todo with an owner
501
+ // pattern. A due review no longer freezes every target.
502
+ for (const entry of results) {
503
+ // MAJEUR l.135: no path means no todo owner can claim it — a
504
+ // pathless command must not be blocked by a file-ownership check.
505
+ if (entry.path.length === 0) continue;
506
+ const reason = reviewBlockReason(db, task.id, entry.path, ledgerConfig.review);
507
+ if (reason && !entry.ignored) {
508
+ entry.allow = false;
509
+ entry.reasons.push(reason);
510
+ }
511
+ }
512
+ if (results.some((entry) => entry.reasons.some((reason) => reason.startsWith("plan review due")))) {
513
+ reasons.push(reviewDue(db, task.id, ledgerConfig.review).reason);
514
+ } else if (reviewDue(db, task.id, ledgerConfig.review).due) {
515
+ reviewWarning = reviewDue(db, task.id, ledgerConfig.review).reason;
516
+ }
517
+ }
518
+ }
519
+
520
+ const currentAllow = results.every((entry) => entry.allow);
521
+ if (session.length > 0) {
522
+ const missing = [...new Set(results.flatMap((entry) => entry.missingSkills))];
523
+ db.prepare(
524
+ "INSERT INTO enforcement_log (session_id, tool, file_path, file_class, decision, missing, matched_rules, logged_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
525
+ ).run(
526
+ session,
527
+ tool,
528
+ paths.join(","),
529
+ results[0]?.fileClass ?? "other",
530
+ currentAllow ? "allow" : gateConfig.mode === "block" ? "block" : gateConfig.mode,
531
+ JSON.stringify(missing),
532
+ JSON.stringify(results.flatMap((entry) => entry.matchedRules)),
533
+ new Date().toISOString()
534
+ );
535
+ try {
536
+ autoCommit("enforcement", `${currentAllow ? "allow" : "block"} ${tool}`);
537
+ } catch {
538
+ // autoCommit failures are non-critical; the enforcement is logged regardless
539
+ }
540
+ }
541
+
542
+ return { reasons, reviewWarning };
543
+ }
package/src/graft.ts CHANGED
@@ -5,30 +5,41 @@
5
5
  * Auto-commit is handled by calling `commitGraft()` from ledger operations.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
- import { existsSync } from "node:fs";
9
- import { resolve } from "node:path";
8
+ import { copyFileSync, existsSync } from "node:fs";
9
+ import { isAbsolute, resolve, sep } from "node:path";
10
10
  import { NovahizHome } from "./spec.ts";
11
11
 
12
12
  // ── Graft binary resolution ──────────────────────────────────────────────────
13
13
  // Graft is expected on PATH. If not found, we degrade gracefully (no-op).
14
14
 
15
- let _graftBinary: string | null = null;
15
+ let _graftBinary: string | null = null; // null = not resolved yet, "" = resolved but absent
16
16
 
17
- function graftBinary(): string {
18
- if (_graftBinary !== null) return _graftBinary;
19
- // Try common locations on Windows where npm global bin lives
20
- const candidates = ["graft", "graft.exe"];
21
- for (const c of candidates) {
22
- try {
23
- execFileSync(c, ["--version"], { stdio: "pipe", timeout: 5000 });
24
- _graftBinary = c;
25
- return _graftBinary;
26
- } catch {
27
- // not found, try next
17
+ function graftBinary(): string | null {
18
+ if (_graftBinary !== null) return _graftBinary || null;
19
+ const override = process.env.NOVAHIZ_GRAFT_BIN;
20
+ if (override) {
21
+ const abs = resolve(override);
22
+ if (existsSync(abs)) {
23
+ _graftBinary = abs;
24
+ return abs;
25
+ }
26
+ }
27
+ const isWin = process.platform === "win32";
28
+ const names = isWin ? ["graft.cmd", "graft.exe", "graft"] : ["graft"];
29
+ const delimiter = isWin ? ";" : ":";
30
+ for (const raw of (process.env.PATH ?? "").split(delimiter)) {
31
+ const dir = raw.trim();
32
+ if (dir === "" || !isAbsolute(dir)) continue;
33
+ for (const name of names) {
34
+ const candidate = resolve(dir, name);
35
+ if (existsSync(candidate)) {
36
+ _graftBinary = candidate;
37
+ return candidate;
38
+ }
28
39
  }
29
40
  }
30
- _graftBinary = "graft"; // fallback — will fail with clear error
31
- return _graftBinary;
41
+ _graftBinary = "";
42
+ return null;
32
43
  }
33
44
 
34
45
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -46,6 +57,7 @@ function ledgerPath(): string {
46
57
 
47
58
  function runGraft(args: string[], opts?: { timeout?: number }): string {
48
59
  const bin = graftBinary();
60
+ if (!bin) throw new Error("graft not found on PATH (install @eidos.space/graft or set NOVAHIZ_GRAFT_BIN)");
49
61
  const db = ledgerPath();
50
62
  const fullArgs = ["--db", db, ...args];
51
63
  try {
@@ -67,8 +79,10 @@ function runGraft(args: string[], opts?: { timeout?: number }): string {
67
79
 
68
80
  /** Check if graft CLI is available on PATH. */
69
81
  export function isGraftAvailable(): boolean {
82
+ const bin = graftBinary();
83
+ if (!bin) return false;
70
84
  try {
71
- execFileSync(graftBinary(), ["--version"], { stdio: "pipe", timeout: 5000 });
85
+ execFileSync(bin, ["--version"], { stdio: "pipe", timeout: 5000 });
72
86
  return true;
73
87
  } catch {
74
88
  return false;
@@ -90,7 +104,9 @@ export function initGraft(): { success: boolean; message: string } {
90
104
  }
91
105
  try {
92
106
  // graft init creates .graft/ in cwd
93
- execFileSync(graftBinary(), ["init"], {
107
+ const bin = graftBinary();
108
+ if (!bin) return { success: false, message: "graft CLI not found on PATH" };
109
+ execFileSync(bin, ["init"], {
94
110
  stdio: "pipe",
95
111
  timeout: 10_000,
96
112
  cwd: NovahizHome(),
@@ -177,13 +193,22 @@ export function getGraftStatus(): string {
177
193
  }
178
194
 
179
195
  /** Restore the ledger to a specific revision. */
180
- export function restoreGraft(revision: string): { success: boolean; message: string } {
196
+ export function restoreGraft(revision: string, opts?: { force?: boolean }): { success: boolean; message: string } {
181
197
  if (!isGraftAvailable() || !isGraftInitialized()) {
182
198
  return { success: false, message: "graft not initialized" };
183
199
  }
200
+ if (!opts?.force) {
201
+ return {
202
+ success: false,
203
+ message:
204
+ "refuses to overwrite the ledger without --force; re-run as `novahiz graft restore <hash> --force` (a backup is written first)",
205
+ };
206
+ }
184
207
  try {
208
+ const db = ledgerPath();
209
+ if (existsSync(db)) copyFileSync(db, `${db}.backup-${Date.now()}`);
185
210
  runGraft(["checkout", revision]);
186
- return { success: true, message: `restored to ${revision}` };
211
+ return { success: true, message: `restored to ${revision} (backup kept next to the ledger)` };
187
212
  } catch (err: unknown) {
188
213
  const message = err instanceof Error ? err.message : String(err);
189
214
  return { success: false, message: `restore failed: ${message}` };
@@ -191,13 +216,21 @@ export function restoreGraft(revision: string): { success: boolean; message: str
191
216
  }
192
217
 
193
218
  /** Export a snapshot of the ledger as a physical .sqlite file. */
194
- export function exportGraft(revision: string, outputPath: string): { success: boolean; message: string } {
219
+ export function exportGraft(revision: string, outputPath: string, opts?: { force?: boolean }): { success: boolean; message: string } {
195
220
  if (!isGraftAvailable() || !isGraftInitialized()) {
196
221
  return { success: false, message: "graft not initialized" };
197
222
  }
223
+ const target = resolve(outputPath);
224
+ const allowed = [resolve(NovahizHome()), resolve(process.cwd())];
225
+ if (!allowed.some((r) => target === r || target.startsWith(r + sep))) {
226
+ return { success: false, message: `export path outside the workspace refused: ${outputPath}` };
227
+ }
228
+ if (existsSync(target) && !opts?.force) {
229
+ return { success: false, message: `export path already exists: ${target} (pass --force to overwrite)` };
230
+ }
198
231
  try {
199
- runGraft(["export", revision, "--output", outputPath]);
200
- return { success: true, message: `exported to ${outputPath}` };
232
+ runGraft(["export", revision, "--output", target]);
233
+ return { success: true, message: `exported to ${target}` };
201
234
  } catch (err: unknown) {
202
235
  const message = err instanceof Error ? err.message : String(err);
203
236
  return { success: false, message: `export failed: ${message}` };
package/src/ledger.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
- import { globToRegExp } from "./gate.ts";
2
+ import { globToRegExp, isSafeRegexPattern } from "./gate.ts";
3
3
  import { autoCommit } from "./graft.ts";
4
4
 
5
5
  export type TodoKind = "read" | "edit" | "verify" | "delegate";
@@ -311,7 +311,9 @@ export function ownedBy(todo: TodoRow, filePath: string): boolean {
311
311
  .split(",")
312
312
  .map((part) => part.trim())
313
313
  .filter(Boolean)
314
- .some((pattern) => globToRegExp(pattern).test(normalized));
314
+ // P2-C (LOW): owner globs come from todo labels; reject nested-quantifier
315
+ // shapes before compiling so a crafted owner cannot ReDoS every edit check.
316
+ .some((pattern) => isSafeRegexPattern(pattern) && globToRegExp(pattern).test(normalized));
315
317
  }
316
318
 
317
319
  export function traceCheck(db: DatabaseSync, options: { sessionId?: string; filePath: string; required: boolean }): TraceResult {
@@ -531,10 +533,18 @@ export function reviewTask(db: DatabaseSync, options: { taskId: string } & Revie
531
533
  for (const item of removals) {
532
534
  const id = typeof item === "string" ? item : item.id;
533
535
  const reason = typeof item === "string" ? "" : item.reason ?? "";
536
+ const target = getTodo(db, id);
537
+ if (!target || target.task_id !== options.taskId) {
538
+ throw new Error(`review removal targets a todo outside this task: ${id}`);
539
+ }
534
540
  dropTodo(db, id, reason);
535
541
  }
536
542
  for (const change of amendments) {
537
543
  const { id, ...patch } = change;
544
+ const target = getTodo(db, id);
545
+ if (!target || target.task_id !== options.taskId) {
546
+ throw new Error(`review amendment targets a todo outside this task: ${id}`);
547
+ }
538
548
  amendTodo(db, id, patch);
539
549
  }
540
550
  for (const item of additions) insertTodo(db, options.taskId, item, "end");