novahiz 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
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
+ * Parse the JSON printed by `novahiz gate` on exit 2. Returns null when the
19
+ * payload is not readable — the caller then falls back to the raw stdout.
20
+ */
21
+ export function parseGateFailure(stdout) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(stdout);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ if (!parsed || typeof parsed !== "object")
30
+ return null;
31
+ const record = parsed;
32
+ const asStringArray = (value) => Array.isArray(value)
33
+ ? value.filter((entry) => typeof entry === "string")
34
+ : [];
35
+ const tool = typeof record.tool === "string" && record.tool.length > 0 ? record.tool : "tool";
36
+ return {
37
+ tool,
38
+ missingSkills: asStringArray(record.missingSkills),
39
+ reasons: asStringArray(record.reasons),
40
+ error: typeof record.error === "string" ? record.error : null
41
+ };
42
+ }
43
+ /**
44
+ * Build the actionable denial for a gate failure.
45
+ *
46
+ * attempt 1 — missing skills: the repair protocol (load, retry, resume).
47
+ * attempt 2+ — the same skills are still missing: the loads did not take
48
+ * effect, so the directive escalates to diagnosis and stops
49
+ * the loop instead of repeating itself.
50
+ */
51
+ export function buildRepairDirective(failure, attempt) {
52
+ const head = `Novahiz gate blocked ${failure.tool}.`;
53
+ const missing = failure.missingSkills;
54
+ if (missing.length === 0) {
55
+ // Nothing the agent can repair by loading — state the reasons and stop.
56
+ const detail = failure.error ?? failure.reasons.join("; ") ?? "no reason reported";
57
+ return `${head}\nBlocked by rule, not by a missing skill: ${detail}\nResolve the listed rule, then retry once. Never bypass the gate.`;
58
+ }
59
+ if (attempt <= 1) {
60
+ const steps = missing.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
61
+ return [
62
+ `${head} Missing skills: ${missing.join(", ")}.`,
63
+ "AUTO-REPAIR — execute now, do not ask the user, do not stop:",
64
+ steps,
65
+ ` ${missing.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
66
+ "Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
67
+ ].join("\n");
68
+ }
69
+ return [
70
+ `${head} AUTO-REPAIR FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
71
+ "The loads did not register — diagnose instead of retrying:",
72
+ " 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
73
+ " 2. Realign the index (`novahiz sync`), then load the named skills again.",
74
+ "If the skill genuinely does not exist, report that honestly to the user and stop. Never bypass the gate."
75
+ ].join("\n");
76
+ }
package/dist/gate.js ADDED
@@ -0,0 +1,493 @@
1
+ import { hasPlaceholder, hasProse, hasStyle, isTrivial } from "./content.js";
2
+ import { determineTier } from "./complexity.js";
3
+ // Shared ledger enforcement (audit P1-D/M1): the CLI command and the MCP
4
+ // novahiz_gate tool both run these checks so their verdicts cannot diverge.
5
+ import { activeTask, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "./ledger.js";
6
+ import { autoCommit } from "./graft.js";
7
+ const CLASS_BY_EXTENSION = {
8
+ css: "design",
9
+ scss: "design",
10
+ sass: "design",
11
+ less: "design",
12
+ styl: "design",
13
+ html: "design",
14
+ htm: "design",
15
+ vue: "design",
16
+ svelte: "design",
17
+ astro: "design",
18
+ jsx: "design",
19
+ tsx: "design",
20
+ md: "text",
21
+ mdx: "text",
22
+ markdown: "text",
23
+ txt: "text",
24
+ rst: "text",
25
+ adoc: "text",
26
+ json: "data",
27
+ jsonc: "data",
28
+ yaml: "data",
29
+ yml: "data",
30
+ toml: "data",
31
+ csv: "data",
32
+ tsv: "data",
33
+ xml: "data",
34
+ env: "config",
35
+ ini: "config",
36
+ cfg: "config",
37
+ conf: "config",
38
+ gitignore: "config",
39
+ gitattributes: "config",
40
+ js: "code",
41
+ mjs: "code",
42
+ cjs: "code",
43
+ ts: "code",
44
+ mts: "code",
45
+ cts: "code",
46
+ py: "code",
47
+ rb: "code",
48
+ go: "code",
49
+ rs: "code",
50
+ java: "code",
51
+ kt: "code",
52
+ kts: "code",
53
+ swift: "code",
54
+ c: "code",
55
+ cc: "code",
56
+ cpp: "code",
57
+ h: "code",
58
+ hpp: "code",
59
+ cs: "code",
60
+ php: "code",
61
+ sh: "code",
62
+ bash: "code",
63
+ zsh: "code",
64
+ ps1: "code",
65
+ sql: "code",
66
+ lua: "code",
67
+ dart: "code",
68
+ ex: "code",
69
+ exs: "code",
70
+ erl: "code",
71
+ clj: "code",
72
+ scala: "code"
73
+ };
74
+ export function fileClass(filePath) {
75
+ const normalized = filePath.replace(/\\/g, "/");
76
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
77
+ if (base.length === 0)
78
+ return "other";
79
+ // M8: dotted dotfiles (.env.local, .eslintrc.json, .babelrc.js) carry a config
80
+ // basename before their extension — classify by basename first.
81
+ const CONFIG_BASENAMES = new Set([
82
+ "env",
83
+ "eslintrc",
84
+ "babelrc",
85
+ "prettierrc",
86
+ "stylelintrc",
87
+ "editorconfig",
88
+ "dockerignore",
89
+ "npmrc",
90
+ "yarnrc",
91
+ "nvmrc",
92
+ "terraformrc",
93
+ "gitignore",
94
+ "gitattributes"
95
+ ]);
96
+ if (base.startsWith(".")) {
97
+ const rest = base.replace(/^\.+/, "").toLowerCase();
98
+ if (rest.length === 0)
99
+ return "other";
100
+ const direct = CLASS_BY_EXTENSION[rest];
101
+ if (direct)
102
+ return direct;
103
+ const firstDot = rest.indexOf(".");
104
+ if (firstDot > 0 && CONFIG_BASENAMES.has(rest.slice(0, firstDot)))
105
+ return "config";
106
+ const lastDot = rest.lastIndexOf(".");
107
+ if (lastDot !== -1) {
108
+ const ext = rest.slice(lastDot + 1);
109
+ return CLASS_BY_EXTENSION[ext] ?? "config";
110
+ }
111
+ return "config";
112
+ }
113
+ const dot = base.lastIndexOf(".");
114
+ if (dot <= 0)
115
+ return "other";
116
+ const ext = base.slice(dot + 1).toLowerCase();
117
+ return CLASS_BY_EXTENSION[ext] ?? "other";
118
+ }
119
+ // M-Gate: cache glob→RegExp conversions (same glob pattern recompiled on every call)
120
+ const GLOB_CACHE_MAX = 512;
121
+ const _globCache = new Map();
122
+ export function globToRegExp(glob) {
123
+ let cached = _globCache.get(glob);
124
+ if (cached)
125
+ return cached;
126
+ const source = glob.replace(/\\/g, "/");
127
+ let output = "";
128
+ for (let index = 0; index < source.length; index += 1) {
129
+ const char = source[index];
130
+ if (char === "*") {
131
+ if (source[index + 1] === "*") {
132
+ index += 1;
133
+ if (source[index + 1] === "/") {
134
+ index += 1;
135
+ output += "(?:.+/)?";
136
+ }
137
+ else if (source[index + 1] === undefined) {
138
+ output += ".*";
139
+ }
140
+ else {
141
+ output += "(?:[^/]+/)*[^/]*";
142
+ }
143
+ }
144
+ else {
145
+ output += "[^/]*";
146
+ }
147
+ }
148
+ else if (char === "?") {
149
+ output += "[^/]";
150
+ }
151
+ else if (".+^${}()|[]\\".includes(char)) {
152
+ output += `\\${char}`;
153
+ }
154
+ else {
155
+ output += char;
156
+ }
157
+ }
158
+ cached = new RegExp(`^${output}$`, "i");
159
+ // H4: eviction — drop oldest entry when cache is full
160
+ if (_globCache.size >= GLOB_CACHE_MAX) {
161
+ const firstKey = _globCache.keys().next().value;
162
+ if (firstKey !== undefined)
163
+ _globCache.delete(firstKey);
164
+ }
165
+ _globCache.set(glob, cached);
166
+ return cached;
167
+ }
168
+ // C3: nested quantifiers — (a+)+, (x*)*, (a+)? — are the classic ReDoS shape.
169
+ // try/catch stops syntax errors but cannot stop catastrophic backtracking,
170
+ // so reject the shape before compiling.
171
+ const UNSAFE_NESTED_QUANTIFIER = /\([^()]*[+*][^()]*\)[+*?]/;
172
+ export function isSafeRegexPattern(pattern) {
173
+ return !UNSAFE_NESTED_QUANTIFIER.test(pattern);
174
+ }
175
+ // M3: cache compiled regex patterns to avoid recompilation on every gate check
176
+ const _regexCache = new Map();
177
+ const REGEX_CACHE_MAX = 256;
178
+ export function contentSatisfies(content, patterns) {
179
+ if (content.length === 0)
180
+ return false;
181
+ return patterns.some((pattern) => {
182
+ if (pattern === "prose")
183
+ return hasProse(content);
184
+ if (pattern === "style")
185
+ return hasStyle(content);
186
+ if (pattern.length > 1000)
187
+ return false;
188
+ if (!isSafeRegexPattern(pattern))
189
+ return false;
190
+ try {
191
+ let re = _regexCache.get(pattern);
192
+ if (re) {
193
+ // M4: LRU-style — move accessed entry to end (newest) so hot entries stay
194
+ _regexCache.delete(pattern);
195
+ _regexCache.set(pattern, re);
196
+ }
197
+ else {
198
+ // M-Gate: eviction — drop oldest entry when cache is full
199
+ if (_regexCache.size >= REGEX_CACHE_MAX) {
200
+ const firstKey = _regexCache.keys().next().value;
201
+ if (firstKey !== undefined)
202
+ _regexCache.delete(firstKey);
203
+ }
204
+ re = new RegExp(pattern, "i");
205
+ _regexCache.set(pattern, re);
206
+ }
207
+ return re.test(content);
208
+ }
209
+ catch {
210
+ // H1: fail-closed — propagate the error so the caller can decide.
211
+ // For contentMatches this blocks the edit; for contentExcludes the
212
+ // caller catches and treats it as "exclude matched" (also blocking).
213
+ throw new Error(`regex error in pattern: ${pattern.slice(0, 80)}`);
214
+ }
215
+ });
216
+ }
217
+ function selectorMatches(rule, classification, path, categories, pathless = false) {
218
+ const when = rule.when;
219
+ const checks = [];
220
+ if (pathless) {
221
+ // MAJEUR l.135: with no target path, only prompt-scoped selectors can
222
+ // apply. Path/file rules are skipped silently — there is no path to test
223
+ // and the absence is not the misconfiguration M1 warns about.
224
+ if (!when.promptCategories || when.promptCategories.length === 0)
225
+ return false;
226
+ checks.push(when.promptCategories.some((category) => categories.includes(category)));
227
+ }
228
+ else {
229
+ if (when.fileClasses && when.fileClasses.length > 0)
230
+ checks.push(when.fileClasses.includes(classification));
231
+ if (when.pathGlobs && when.pathGlobs.length > 0)
232
+ checks.push(when.pathGlobs.some((glob) => globToRegExp(glob).test(path)));
233
+ if (when.promptCategories && when.promptCategories.length > 0) {
234
+ checks.push(when.promptCategories.some((category) => categories.includes(category)));
235
+ }
236
+ // M1: a rule with no selectors must match nothing, not everything.
237
+ // An empty `when` is a misconfiguration — fail closed, not open.
238
+ if (checks.length === 0) {
239
+ console.error(`[Novahiz] rule "${rule.id}" has empty selectors — it will never match`);
240
+ return false;
241
+ }
242
+ }
243
+ // Default to "all" — all selectors must match. This is the safe default:
244
+ // a rule with fileClasses + promptCategories requires BOTH to match.
245
+ return when.match === "any" ? checks.some(Boolean) : checks.every(Boolean);
246
+ }
247
+ // C3: files whose content defines what the gate requires. They live under
248
+ // build/ but must never be treated as ignored build output.
249
+ const PROTECTED_INDEX_SUFFIXES = ["/build/installed-skills.json", "/build/catalog.json"];
250
+ function isProtectedIndexPath(path) {
251
+ return PROTECTED_INDEX_SUFFIXES.some((suffix) => path === suffix.slice(1) || path.endsWith(suffix));
252
+ }
253
+ export function evaluateGate(input) {
254
+ const classification = fileClass(input.filePath);
255
+ const categories = input.categories ?? [];
256
+ const path = input.filePath.replace(/\\/g, "/");
257
+ const content = input.content ?? "";
258
+ // MAJEUR l.135: a pathless call (bash/shell/cron without a target) evaluates
259
+ // prompt-scoped rules only and never consults path-based ignore globs.
260
+ const pathless = input.pathless === true;
261
+ try {
262
+ // C3: the skill index lives under build/ (matched by the default
263
+ // **/build/** ignore glob). Editing it must never bypass the gate —
264
+ // dropping a skill from the index would silently un-require it.
265
+ const ignored = !pathless &&
266
+ !isProtectedIndexPath(path) &&
267
+ input.spec.config.gate.ignoreFiles.some((glob) => globToRegExp(glob).test(path));
268
+ if (ignored) {
269
+ return {
270
+ allow: true,
271
+ ignored: true,
272
+ fileClass: classification,
273
+ roadmap: null,
274
+ tier: "trivial",
275
+ requiredSkills: [],
276
+ missingSkills: [],
277
+ unmatchedRequired: [],
278
+ matchedRules: [],
279
+ indexMissing: false,
280
+ placeholder: false,
281
+ reasons: []
282
+ };
283
+ }
284
+ // H3: Determine complexity tier BEFORE rule evaluation so that trivial
285
+ // prompts can skip Novahiz-specific rules entirely.
286
+ // C1: compute the tier from the user prompt when available; content is
287
+ // only a fallback for callers that do not carry a prompt (probes).
288
+ const prompt = (input.prompt ?? "").trim();
289
+ const tier = input.tier ?? determineTier(prompt.length > 0 ? prompt : content);
290
+ const requiredSkills = [];
291
+ const matchedRules = [];
292
+ for (const rule of input.spec.rules) {
293
+ if (!selectorMatches(rule, classification, path, categories, pathless))
294
+ continue;
295
+ // Tier gating for R6-Novahiz: trivial = skip entirely, lite = only implement+converge
296
+ if (rule.id === "R6-Novahiz") {
297
+ if (tier === "trivial")
298
+ continue;
299
+ if (tier === "lite") {
300
+ // Only add implement and converge from R6's require list
301
+ for (const skill of rule.require) {
302
+ if (skill === "novahiz-implement" || skill === "novahiz-converge") {
303
+ if (!requiredSkills.includes(skill))
304
+ requiredSkills.push(skill);
305
+ }
306
+ }
307
+ matchedRules.push(rule.id);
308
+ continue;
309
+ }
310
+ }
311
+ if (rule.when.minChange && isTrivial(content, rule.when.minChange))
312
+ continue;
313
+ // H1: contentSatisfies now throws on regex errors (fail-closed).
314
+ // For contentExcludes: regex error → treat as "exclude matched" → skip rule.
315
+ // For contentMatches: regex error → treat as "match succeeded" → apply rule.
316
+ if (rule.when.contentExcludes) {
317
+ try {
318
+ if (contentSatisfies(content, rule.when.contentExcludes))
319
+ continue;
320
+ }
321
+ catch {
322
+ // MINEUR#1: an invalid exclude pattern must never disable the rule
323
+ // (the old `continue` was a silent fail-open). Fail closed instead:
324
+ // apply the rule and surface the catalogue bug on stderr.
325
+ console.error(`[Novahiz] rule "${rule.id}": invalid contentExcludes pattern — applying the rule anyway`);
326
+ }
327
+ }
328
+ if (rule.when.contentMatches) {
329
+ try {
330
+ if (!contentSatisfies(content, rule.when.contentMatches))
331
+ continue;
332
+ }
333
+ catch { /* fail-closed: apply rule */ }
334
+ }
335
+ matchedRules.push(rule.id);
336
+ for (const skill of rule.require) {
337
+ if (!requiredSkills.includes(skill))
338
+ requiredSkills.push(skill);
339
+ }
340
+ }
341
+ const primary = categories[0];
342
+ let roadmap = null;
343
+ if (primary) {
344
+ const category = input.spec.categories.find((entry) => entry.id === primary);
345
+ if (category?.roadmap) {
346
+ roadmap = category.roadmap.id;
347
+ for (const step of category.roadmap.steps) {
348
+ if (step.kind !== "skill" || step.optional)
349
+ continue;
350
+ // Tier-based filtering
351
+ if (tier === "trivial") {
352
+ // Trivial: no skills from roadmap
353
+ continue;
354
+ }
355
+ if (tier === "lite") {
356
+ // Lite: only implement + converge skills (skip plan, clarify, etc.)
357
+ const allowed = (step.requireSkills ?? []).filter(s => s === "novahiz-implement" || s === "novahiz-converge");
358
+ if (allowed.length === 0)
359
+ continue;
360
+ for (const skill of allowed) {
361
+ if (!requiredSkills.includes(skill))
362
+ requiredSkills.push(skill);
363
+ }
364
+ continue;
365
+ }
366
+ for (const skill of step.requireSkills ?? []) {
367
+ if (!requiredSkills.includes(skill))
368
+ requiredSkills.push(skill);
369
+ }
370
+ }
371
+ }
372
+ }
373
+ const installed = input.installedSkills ?? null;
374
+ const indexAvailable = input.installedIndexAvailable !== false;
375
+ const effective = [];
376
+ const unmatchedRequired = [];
377
+ for (const skill of requiredSkills) {
378
+ if (indexAvailable && installed && !installed.has(skill))
379
+ unmatchedRequired.push(skill);
380
+ else
381
+ effective.push(skill);
382
+ }
383
+ const loaded = new Set(input.loadedSkills ?? []);
384
+ const missingSkills = effective.filter((skill) => !loaded.has(skill));
385
+ const placeholderEligible = input.spec.config.gate.placeholders && (classification === "code" || classification === "design");
386
+ const placeholder = placeholderEligible && hasPlaceholder(content);
387
+ const reasons = [];
388
+ for (const skill of missingSkills)
389
+ reasons.push(`missing skill: ${skill}`);
390
+ // C3: an index gap must be visible, not silent. The skill is not enforced
391
+ // (kept out of requiredSkills) — say so loudly so a sync gets triggered.
392
+ for (const skill of unmatchedRequired) {
393
+ reasons.push(`required skill not in index, not enforced: ${skill} — run "novahiz" sync to realign`);
394
+ }
395
+ if (placeholder)
396
+ reasons.push("placeholder marker found in content");
397
+ return {
398
+ allow: missingSkills.length === 0 && !placeholder,
399
+ ignored: false,
400
+ fileClass: classification,
401
+ roadmap,
402
+ tier,
403
+ requiredSkills: effective,
404
+ missingSkills,
405
+ unmatchedRequired,
406
+ matchedRules,
407
+ indexMissing: input.installedIndexAvailable === false,
408
+ placeholder,
409
+ reasons
410
+ };
411
+ }
412
+ catch (error) {
413
+ return {
414
+ allow: false,
415
+ ignored: false,
416
+ fileClass: classification,
417
+ roadmap: null,
418
+ tier: "trivial",
419
+ requiredSkills: [],
420
+ missingSkills: [],
421
+ unmatchedRequired: [],
422
+ matchedRules: [],
423
+ indexMissing: false,
424
+ placeholder: false,
425
+ reasons: [`gate error: ${String(error?.message ?? error)}`]
426
+ };
427
+ }
428
+ }
429
+ // Ledger + trace enforcement shared by the CLI gate command and the MCP
430
+ // novahiz_gate tool (audit P1-D/M1): same checks and the same log rows, so the
431
+ // two entry points cannot drift apart. Mutates results[].allow exactly like
432
+ // the original CLI block; returns aggregate reasons and the review warning.
433
+ export function enforceLedgerChecks(db, input) {
434
+ const { session, tool, paths, categories, results, spec, gateConfig } = input;
435
+ const reasons = [];
436
+ let reviewWarning = "";
437
+ const traceConfig = gateConfig.trace;
438
+ const traceRequired = traceConfig?.enabled === true &&
439
+ session.length > 0 &&
440
+ categories.some((category) => traceConfig.categories.includes(category));
441
+ if (traceRequired) {
442
+ for (const entry of results) {
443
+ // MAJEUR l.135: a pathless entry (bash/shell/cron with no target) has
444
+ // no file to trace — skip the per-path check, keep the log row below.
445
+ if (entry.path.length === 0)
446
+ continue;
447
+ const trace = traceCheck(db, { sessionId: session, filePath: entry.path, required: true });
448
+ if (!trace.ok) {
449
+ entry.allow = false;
450
+ reasons.push(trace.reason);
451
+ }
452
+ }
453
+ }
454
+ const ledgerConfig = spec.config.ledger;
455
+ if (ledgerConfig?.enabled !== false) {
456
+ const task = activeTask(db, session || undefined);
457
+ if (task) {
458
+ if (["edit", "write", "patch", "apply_patch"].includes(tool))
459
+ recordEdit(db, task.id);
460
+ // Targeted review: block only paths owned by an open todo with an owner
461
+ // pattern. A due review no longer freezes every target.
462
+ for (const entry of results) {
463
+ // MAJEUR l.135: no path means no todo owner can claim it — a
464
+ // pathless command must not be blocked by a file-ownership check.
465
+ if (entry.path.length === 0)
466
+ continue;
467
+ const reason = reviewBlockReason(db, task.id, entry.path, ledgerConfig.review);
468
+ if (reason && !entry.ignored) {
469
+ entry.allow = false;
470
+ entry.reasons.push(reason);
471
+ }
472
+ }
473
+ if (results.some((entry) => entry.reasons.some((reason) => reason.startsWith("plan review due")))) {
474
+ reasons.push(reviewDue(db, task.id, ledgerConfig.review).reason);
475
+ }
476
+ else if (reviewDue(db, task.id, ledgerConfig.review).due) {
477
+ reviewWarning = reviewDue(db, task.id, ledgerConfig.review).reason;
478
+ }
479
+ }
480
+ }
481
+ const currentAllow = results.every((entry) => entry.allow);
482
+ if (session.length > 0) {
483
+ const missing = [...new Set(results.flatMap((entry) => entry.missingSkills))];
484
+ db.prepare("INSERT INTO enforcement_log (session_id, tool, file_path, file_class, decision, missing, matched_rules, logged_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(session, tool, paths.join(","), results[0]?.fileClass ?? "other", currentAllow ? "allow" : gateConfig.mode === "block" ? "block" : gateConfig.mode, JSON.stringify(missing), JSON.stringify(results.flatMap((entry) => entry.matchedRules)), new Date().toISOString());
485
+ try {
486
+ autoCommit("enforcement", `${currentAllow ? "allow" : "block"} ${tool}`);
487
+ }
488
+ catch {
489
+ // autoCommit failures are non-critical; the enforcement is logged regardless
490
+ }
491
+ }
492
+ return { reasons, reviewWarning };
493
+ }