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
package/src/commands/task.ts
CHANGED
|
@@ -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:
|
|
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.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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", "
|
|
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
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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 (
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 = "
|
|
31
|
-
return
|
|
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(
|
|
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
|
-
|
|
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",
|
|
200
|
-
return { success: true, message: `exported to ${
|
|
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
|
-
|
|
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");
|