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
|
@@ -8,9 +8,119 @@ import { join, relative, resolve } from "node:path";
|
|
|
8
8
|
// ~/.config/opencode/plugins/ and cannot resolve ../../src/*.
|
|
9
9
|
type RewriteResult = { original: string; rewritten: string; sourceLanguage: string; wasRewritten: boolean };
|
|
10
10
|
|
|
11
|
+
function fold(value: string): string {
|
|
12
|
+
return value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function rx(words: string, flags: string): RegExp {
|
|
16
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])(?:${words})(?![\\p{L}\\p{N}])`, flags);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function marker(words: string): RegExp {
|
|
20
|
+
return rx(words, "iu");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function word(words: string): RegExp {
|
|
24
|
+
return rx(words, "giu");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DETECT_MIN_SCORE = 3;
|
|
28
|
+
const LANG_ORDER = ["fr", "es", "de", "pt"];
|
|
29
|
+
|
|
30
|
+
const LANG_PATTERNS: Record<string, Array<[RegExp, number]>> = {
|
|
31
|
+
fr: [
|
|
32
|
+
[marker("corriger|corrige"), 3], [marker("ajouter|ajoute|ajoutez"), 3],
|
|
33
|
+
[marker("supprimer|supprime|supprimez"), 3], [marker("creer|creez|creation"), 3],
|
|
34
|
+
[marker("developper|developpe"), 3], [marker("implementer|implemente"), 3],
|
|
35
|
+
[marker("refactoriser|refactorise"), 3], [marker("configurer"), 3],
|
|
36
|
+
[marker("installer"), 3], [marker("ameliorer|amelioration"), 3],
|
|
37
|
+
[marker("optimiser"), 3], [marker("verifier|verifie"), 3],
|
|
38
|
+
[marker("expliquer|explique"), 3], [marker("pourquoi"), 3],
|
|
39
|
+
[marker("comment faire"), 3],
|
|
40
|
+
[marker("dans"), 2], [marker("avec"), 2], [marker("pour"), 2], [marker("vous"), 2],
|
|
41
|
+
[marker("nous"), 2], [marker("votre|notre"), 2], [marker("fonction|fonctions"), 2],
|
|
42
|
+
[marker("classe|classes"), 2], [marker("fichier|fichiers"), 2],
|
|
43
|
+
[marker("probleme|problemes"), 2], [marker("erreur|erreurs"), 2],
|
|
44
|
+
[marker("serveur"), 2], [marker("donnees"), 2], [marker("utilisateur|utilisateurs"), 2],
|
|
45
|
+
[marker("ecran"), 2], [marker("bouton"), 2], [marker("formulaire"), 2],
|
|
46
|
+
[marker("securite"), 2], [marker("reseau"), 2], [marker("requete"), 2],
|
|
47
|
+
[marker("besoin"), 2], [marker("faites|fait"), 2],
|
|
48
|
+
[marker("le"), 1], [marker("la"), 1], [marker("les"), 1], [marker("des"), 1],
|
|
49
|
+
[marker("du"), 1], [marker("de"), 1], [marker("un"), 1], [marker("une"), 1],
|
|
50
|
+
[marker("est"), 1], [marker("ne"), 1], [marker("pas"), 1],
|
|
51
|
+
],
|
|
52
|
+
es: [
|
|
53
|
+
[marker("hacer"), 3], [marker("crear|cree"), 3], [marker("anadir"), 3],
|
|
54
|
+
[marker("borrar"), 3], [marker("arreglar"), 3], [marker("corregir"), 3],
|
|
55
|
+
[marker("explicar"), 3], [marker("necesito"), 3], [marker("quiero"), 3],
|
|
56
|
+
[marker("tengo"), 3], [marker("base de datos"), 3], [marker("inicio de sesion"), 3],
|
|
57
|
+
[marker("para"), 2], [marker("con"), 2], [marker("pero"), 2], [marker("muy"), 2],
|
|
58
|
+
[marker("tambien"), 2], [marker("pagina|paginas"), 2], [marker("servidor"), 2],
|
|
59
|
+
[marker("usuario|usuarios"), 2], [marker("archivo|archivos"), 2], [marker("codigo"), 2],
|
|
60
|
+
[marker("pantalla"), 2], [marker("mensaje"), 2], [marker("conexion"), 2],
|
|
61
|
+
[marker("datos"), 2], [marker("diseno"), 2], [marker("aplicacion"), 2],
|
|
62
|
+
[marker("de"), 1], [marker("la"), 1], [marker("el"), 1], [marker("los"), 1],
|
|
63
|
+
[marker("las"), 1], [marker("un"), 1], [marker("una"), 1], [marker("es"), 1],
|
|
64
|
+
[marker("en"), 1], [marker("por"), 1], [marker("que"), 1], [marker("se"), 1],
|
|
65
|
+
[marker("al"), 1], [marker("lo"), 1],
|
|
66
|
+
],
|
|
67
|
+
de: [
|
|
68
|
+
[marker("bitte"), 3], [marker("machen"), 3], [marker("erstellen|erstelle"), 3],
|
|
69
|
+
[marker("hinzufugen"), 3], [marker("loschen"), 3], [marker("beheben|behebt"), 3],
|
|
70
|
+
[marker("debuggen"), 3], [marker("testen"), 3], [marker("erklaren|erklare"), 3],
|
|
71
|
+
[marker("warum"), 3], [marker("nicht"), 3], [marker("muss"), 3], [marker("soll"), 3],
|
|
72
|
+
[marker("funktion|funktionen"), 2], [marker("klasse"), 2], [marker("datei|dateien"), 2],
|
|
73
|
+
[marker("fehler"), 2], [marker("seite|seiten"), 2], [marker("datenbank"), 2],
|
|
74
|
+
[marker("anwendung|anwendungen"), 2], [marker("benutzer"), 2], [marker("anmelden"), 2],
|
|
75
|
+
[marker("einloggen"), 2], [marker("variablen"), 2], [marker("speichern"), 2],
|
|
76
|
+
[marker("anzeigen"), 2], [marker("verbessern"), 2], [marker("optimieren"), 2],
|
|
77
|
+
[marker("implementieren"), 2], [marker("schreiben"), 2], [marker("suchen"), 2],
|
|
78
|
+
[marker("starten"), 2],
|
|
79
|
+
[marker("der"), 1], [marker("die"), 1], [marker("das"), 1], [marker("den"), 1],
|
|
80
|
+
[marker("dem"), 1], [marker("ein"), 1], [marker("eine"), 1], [marker("ist"), 1],
|
|
81
|
+
[marker("mit"), 1], [marker("fur"), 1], [marker("und"), 1], [marker("auf"), 1],
|
|
82
|
+
[marker("zu"), 1],
|
|
83
|
+
],
|
|
84
|
+
pt: [
|
|
85
|
+
[marker("criar|crie"), 3], [marker("fazer"), 3], [marker("adicionar"), 3],
|
|
86
|
+
[marker("corrigir"), 3], [marker("preciso"), 3], [marker("quero"), 3],
|
|
87
|
+
[marker("banco de dados"), 3],
|
|
88
|
+
[marker("pagina|paginas"), 2], [marker("usuario|usuarios"), 2],
|
|
89
|
+
[marker("arquivo|arquivos"), 2], [marker("codigo"), 2], [marker("mensagem"), 2],
|
|
90
|
+
[marker("tela"), 2], [marker("configurar"), 2], [marker("instalar"), 2],
|
|
91
|
+
[marker("melhorar"), 2], [marker("conectar"), 2], [marker("senha"), 2],
|
|
92
|
+
[marker("rede"), 2], [marker("funcao"), 2], [marker("nao"), 2], [marker("tambem"), 2],
|
|
93
|
+
[marker("uma"), 1], [marker("um"), 1], [marker("para"), 1], [marker("com"), 1],
|
|
94
|
+
[marker("que"), 1], [marker("na"), 1], [marker("os"), 1], [marker("as"), 1],
|
|
95
|
+
[marker("ao"), 1], [marker("mais"), 1],
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
|
|
11
99
|
function detectLanguage(prompt: string): string {
|
|
12
100
|
if (/[\u0600-\u06FF]/.test(prompt)) return "ar";
|
|
13
|
-
|
|
101
|
+
const folded = fold(prompt);
|
|
102
|
+
let bestLang = "en";
|
|
103
|
+
let bestScore = 0;
|
|
104
|
+
let bestStrong = false;
|
|
105
|
+
for (const lang of LANG_ORDER) {
|
|
106
|
+
let score = 0;
|
|
107
|
+
let strong = false;
|
|
108
|
+
for (const [pattern, weight] of LANG_PATTERNS[lang]) {
|
|
109
|
+
if (pattern.test(folded)) {
|
|
110
|
+
score += weight;
|
|
111
|
+
if (weight >= 3) strong = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (score < DETECT_MIN_SCORE) continue;
|
|
115
|
+
const wins =
|
|
116
|
+
bestScore === 0 ? true : strong !== bestStrong ? strong : score > bestScore;
|
|
117
|
+
if (wins) {
|
|
118
|
+
bestLang = lang;
|
|
119
|
+
bestScore = score;
|
|
120
|
+
bestStrong = strong;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return bestLang;
|
|
14
124
|
}
|
|
15
125
|
|
|
16
126
|
const AR_EN: Array<[RegExp, string]> = [
|
|
@@ -33,22 +143,75 @@ const AR_EN: Array<[RegExp, string]> = [
|
|
|
33
143
|
[/تسجيل الدخول/g, "login"], [/خطأ/g, "bug"], [/اداء/g, "performance"], [/امان/g, "security"],
|
|
34
144
|
];
|
|
35
145
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
146
|
+
const FR_EN: Array<[RegExp, string]> = [
|
|
147
|
+
[word("base de donnees|base de données"), "database"],
|
|
148
|
+
[word("page d'accueil|page daccueil|page d accueil"), "homepage"],
|
|
149
|
+
[word("mot de passe"), "password"],
|
|
150
|
+
[word("site web"), "website"],
|
|
151
|
+
[word("mise a jour|mise à jour"), "update"],
|
|
152
|
+
[word("tableau de bord"), "dashboard"],
|
|
153
|
+
[word("code source"), "source code"],
|
|
154
|
+
[word("en temps reel|en temps réel"), "realtime"],
|
|
155
|
+
[word("formulaire de contact"), "contact form"],
|
|
156
|
+
[word("point d'entree|point d entree|point d'entrée"), "entry point"],
|
|
157
|
+
[word("se deconnecter|se déconnecter"), "logout"],
|
|
158
|
+
[word("se connecter"), "login"],
|
|
159
|
+
[word("corriger|corrige"), "fix"], [word("ajouter|ajoute|ajoutez"), "add"],
|
|
160
|
+
[word("supprimer|supprime|supprimez|effacer|enlever"), "remove"],
|
|
161
|
+
[word("creer|créer|creez|créez"), "create"], [word("developper|développer"), "develop"],
|
|
162
|
+
[word("implementer|implémenter"), "implement"], [word("refactoriser|refactorer"), "refactor"],
|
|
163
|
+
[word("configurer"), "configure"], [word("installer"), "install"], [word("migrer"), "migrate"],
|
|
164
|
+
[word("ameliorer|améliorer"), "improve"], [word("amelioration|amélioration"), "improvement"],
|
|
165
|
+
[word("optimiser"), "optimize"], [word("verifier|vérifier|verifie"), "verify"],
|
|
166
|
+
[word("expliquer|explique"), "explain"], [word("afficher|affiche|montrer|montre"), "display"],
|
|
167
|
+
[word("masquer|cacher"), "hide"], [word("activer"), "enable"],
|
|
168
|
+
[word("desactiver|désactiver"), "disable"], [word("ecrire|écrire"), "write"],
|
|
169
|
+
[word("sauvegarder|enregistrer"), "save"], [word("utiliser|utilise"), "use"],
|
|
170
|
+
[word("remplacer"), "replace"], [word("simplifier"), "simplify"],
|
|
171
|
+
[word("nettoyer"), "clean up"], [word("tester|teste|testez"), "test"],
|
|
172
|
+
[word("deboguer|déboguer"), "debug"], [word("compiler"), "compile"],
|
|
173
|
+
[word("deployer|déployer"), "deploy"], [word("rechercher|chercher"), "search"],
|
|
174
|
+
[word("telecharger|télécharger"), "download"], [word("envoyer"), "send"],
|
|
175
|
+
[word("ouvrir"), "open"], [word("fermer"), "close"],
|
|
176
|
+
[word("fonction|fonctions"), "function"], [word("classe|classes"), "class"],
|
|
177
|
+
[word("methode|méthode"), "method"], [word("fichier|fichiers"), "file"],
|
|
178
|
+
[word("probleme|problème|problemes"), "issue"], [word("erreur|erreurs"), "error"],
|
|
179
|
+
[word("serveur|serveurs"), "server"], [word("donnees|données"), "data"],
|
|
180
|
+
[word("ecran|écran"), "screen"], [word("bouton|boutons"), "button"],
|
|
181
|
+
[word("formulaire|formulaires"), "form"], [word("champ|champs"), "field"],
|
|
182
|
+
[word("lien|liens"), "link"], [word("modele|modèle"), "model"],
|
|
183
|
+
[word("schema|schéma"), "schema"], [word("reseau|réseau"), "network"],
|
|
184
|
+
[word("securite|sécurité"), "security"], [word("utilisateur|utilisateurs"), "user"],
|
|
185
|
+
[word("requete|requête"), "request"], [word("reponse|réponse"), "response"],
|
|
186
|
+
[word("performances"), "performance"], [word("tableau"), "table"],
|
|
187
|
+
[word("liste"), "list"], [word("boucle"), "loop"], [word("authentification"), "auth"],
|
|
188
|
+
[word("inscription"), "register"], [word("connexion"), "login"],
|
|
189
|
+
[word("dans"), "in"], [word("avec"), "with"], [word("pour"), "for"], [word("sur"), "on"],
|
|
190
|
+
[word("sans"), "without"], [word("aussi"), "also"], [word("mais"), "but"],
|
|
191
|
+
[word("pas"), "not"], [word("plus"), "more"], [word("tres|très"), "very"],
|
|
192
|
+
[word("comment"), "how to"], [word("pourquoi"), "why"], [word("besoin"), "need"],
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
function translateTerms(prompt: string, lang: string): string {
|
|
196
|
+
const map = lang === "ar" ? AR_EN : lang === "fr" ? FR_EN : null;
|
|
197
|
+
if (!map) return prompt;
|
|
45
198
|
let result = prompt;
|
|
46
|
-
for (const [pattern, replacement] of
|
|
47
|
-
|
|
199
|
+
for (const [pattern, replacement] of map) result = result.replace(pattern, replacement);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function optimizeEnglish(prompt: string): string {
|
|
204
|
+
return prompt.trim()
|
|
205
|
+
.replace(/^I\s+want\s+to\s+/i, "").replace(/^I\s+need\s+to\s+/i, "")
|
|
48
206
|
.replace(/^Can\s+you\s+/i, "").replace(/^Could\s+you\s+/i, "").replace(/^Please\s+/i, "")
|
|
49
207
|
.replace(/^I\s+would\s+like\s+to\s+/i, "").replace(/^It\s+would\s+be\s+great\s+if\s+you\s+could\s+/i, "")
|
|
50
208
|
.replace(/[.!?]+$/, "").trim();
|
|
51
|
-
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function rewritePrompt(prompt: string): RewriteResult {
|
|
212
|
+
const sourceLanguage = detectLanguage(prompt);
|
|
213
|
+
const rewritten = optimizeEnglish(translateTerms(prompt, sourceLanguage));
|
|
214
|
+
return { original: prompt, rewritten, sourceLanguage, wasRewritten: rewritten !== prompt };
|
|
52
215
|
}
|
|
53
216
|
|
|
54
217
|
// Inlined from src/autodocs.ts — same reason as prompt-rewriter: relative
|
|
@@ -229,12 +392,18 @@ const GATE = CONFIG.gate ?? {};
|
|
|
229
392
|
// redirect the kill-switch to an unrelated variable (e.g. CI=false).
|
|
230
393
|
// Single escape hatch name after brand rename.
|
|
231
394
|
const ESCAPE = (process.env.NOVAHIZ_GATE ?? "").toLowerCase();
|
|
232
|
-
// gate.enabled=false
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
395
|
+
// P0-B: gate.enabled=false is no longer honored — a writable config must not
|
|
396
|
+
// silently disable enforcement (same rule as the CLI and the MCP tool).
|
|
397
|
+
// Only the env escape turns the gate off; gate.mode stays owned by the CLI.
|
|
398
|
+
const DISABLED = ["off", "0", "false", "no", "disabled"].includes(ESCAPE);
|
|
236
399
|
const GATE_TOOLS = new Set(
|
|
237
|
-
(Array.isArray(GATE.tools) && GATE.tools.length > 0
|
|
400
|
+
(Array.isArray(GATE.tools) && GATE.tools.length > 0
|
|
401
|
+
? GATE.tools
|
|
402
|
+
// MINEUR#8: cron tools that carry/execute shell commands are gated too —
|
|
403
|
+
// cron_add_command_task was a bash-gate bypass. Keep in sync with
|
|
404
|
+
// DEFAULT_CONFIG.gate.tools (src/spec.ts) and novahiz.config.json.
|
|
405
|
+
: ["edit", "write", "patch", "apply_patch", "bash", "shell", "cron_add_command_task", "cron_update_command_task", "cron_update_task", "cron_run_task_now"]
|
|
406
|
+
).map((tool) => tool.toLowerCase())
|
|
238
407
|
);
|
|
239
408
|
|
|
240
409
|
type RunResult = { status: number; stdout: string; stderr: string; spawnError?: string };
|
|
@@ -282,11 +451,78 @@ function isValidSessionId(id: unknown): id is string {
|
|
|
282
451
|
return typeof id === "string" && id.trim().length > 0;
|
|
283
452
|
}
|
|
284
453
|
|
|
454
|
+
// Inlined from src/gate-repair.ts — the installed plugin lives in
|
|
455
|
+
// ~/.config/opencode/plugins/ and cannot resolve ../../src/*.
|
|
456
|
+
type GateFailure = {
|
|
457
|
+
tool: string;
|
|
458
|
+
missingSkills: string[];
|
|
459
|
+
reasons: string[];
|
|
460
|
+
error: string | null;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
function parseGateFailure(stdout: string): GateFailure | null {
|
|
464
|
+
let parsed: unknown;
|
|
465
|
+
try {
|
|
466
|
+
parsed = JSON.parse(stdout);
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
471
|
+
const record = parsed as Record<string, unknown>;
|
|
472
|
+
const asStringArray = (value: unknown): string[] =>
|
|
473
|
+
Array.isArray(value)
|
|
474
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
475
|
+
: [];
|
|
476
|
+
const tool = typeof record.tool === "string" && record.tool.length > 0 ? record.tool : "tool";
|
|
477
|
+
return {
|
|
478
|
+
tool,
|
|
479
|
+
missingSkills: asStringArray(record.missingSkills),
|
|
480
|
+
reasons: asStringArray(record.reasons),
|
|
481
|
+
error: typeof record.error === "string" ? record.error : null
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildRepairDirective(failure: GateFailure, attempt: number): string {
|
|
486
|
+
const head = `Novahiz gate blocked ${failure.tool}.`;
|
|
487
|
+
const missing = failure.missingSkills;
|
|
488
|
+
|
|
489
|
+
if (missing.length === 0) {
|
|
490
|
+
const detail = failure.error ?? failure.reasons.join("; ") ?? "no reason reported";
|
|
491
|
+
return `${head}\nBlocked by rule, not by a missing skill: ${detail}\nResolve the listed rule, then retry once. Never bypass the gate.`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (attempt <= 1) {
|
|
495
|
+
const steps = missing.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
|
|
496
|
+
return [
|
|
497
|
+
`${head} Missing skills: ${missing.join(", ")}.`,
|
|
498
|
+
"AUTO-REPAIR — execute now, do not ask the user, do not stop:",
|
|
499
|
+
steps,
|
|
500
|
+
` ${missing.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
|
|
501
|
+
"Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
|
|
502
|
+
].join("\n");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return [
|
|
506
|
+
`${head} AUTO-REPAIR FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
|
|
507
|
+
"The loads did not register — diagnose instead of retrying:",
|
|
508
|
+
" 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
|
|
509
|
+
" 2. Realign the index (`novahiz sync`), then load the named skills again.",
|
|
510
|
+
"If the skill genuinely does not exist, report that honestly to the user and stop. Never bypass the gate."
|
|
511
|
+
].join("\n");
|
|
512
|
+
}
|
|
513
|
+
|
|
285
514
|
export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
286
515
|
const loadedBySession = new Map<string, Set<string>>();
|
|
287
516
|
const categoriesBySession = new Map<string, string[]>();
|
|
288
517
|
const enforcementBySession = new Map<string, string>();
|
|
289
518
|
const lastSeenBySession = new Map<string, number>();
|
|
519
|
+
// P0-B: reason of the last failed classify per session — gate tool calls are
|
|
520
|
+
// refused while set, instead of running with empty categories (fail-open).
|
|
521
|
+
const classifyFailedBySession = new Map<string, string>();
|
|
522
|
+
// Auto-repair: denial count per `session|tool|missing set`. A first denial
|
|
523
|
+
// carries the repair protocol; an identical repeat escalates to diagnosis
|
|
524
|
+
// instead of looping. Cleared on a successful call of the same tool.
|
|
525
|
+
const repairAttemptsBySession = new Map<string, number>();
|
|
290
526
|
const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
291
527
|
|
|
292
528
|
const log = async (level: "info" | "warn", message: string): Promise<void> => {
|
|
@@ -302,6 +538,11 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
302
538
|
categoriesBySession.delete(sessionID);
|
|
303
539
|
enforcementBySession.delete(sessionID);
|
|
304
540
|
lastSeenBySession.delete(sessionID);
|
|
541
|
+
classifyFailedBySession.delete(sessionID);
|
|
542
|
+
// Auto-repair keys are prefixed with the session ID — drop them too.
|
|
543
|
+
for (const key of repairAttemptsBySession.keys()) {
|
|
544
|
+
if (key.startsWith(`${sessionID}|`)) repairAttemptsBySession.delete(key);
|
|
545
|
+
}
|
|
305
546
|
};
|
|
306
547
|
|
|
307
548
|
// Sessions only vanish from memory on session.deleted, which may never arrive.
|
|
@@ -314,8 +555,9 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
314
555
|
}
|
|
315
556
|
};
|
|
316
557
|
|
|
317
|
-
if (
|
|
318
|
-
await log("
|
|
558
|
+
if (GATE.enabled === false)
|
|
559
|
+
await log("warn", "gate.enabled=false in config is ignored; enforcement stays active. Use NOVAHIZ_GATE=off to disable the gate.");
|
|
560
|
+
if (DISABLED) await log("info", "Gate disabled via environment escape");
|
|
319
561
|
|
|
320
562
|
return {
|
|
321
563
|
config: async (input) => {
|
|
@@ -397,9 +639,13 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
397
639
|
await log("info", `Prompt rewritten: ${rewrite.sourceLanguage} → English ("${classifyText.slice(0, 80)}")`);
|
|
398
640
|
}
|
|
399
641
|
|
|
400
|
-
|
|
642
|
+
// P0-B: the prompt travels on stdin. On argv it allowed option
|
|
643
|
+
// injection (--home), broke past the Windows 32k limit, and was
|
|
644
|
+
// readable in the process list.
|
|
645
|
+
const result = run(["classify", "--stdin"], classifyText);
|
|
401
646
|
if (result.status !== 0) {
|
|
402
|
-
|
|
647
|
+
classifyFailedBySession.set(input.sessionID, `classify exit ${result.status}`);
|
|
648
|
+
await log("warn", `Classify failed (exit ${result.status}), gate tool calls refused for this session: ${result.stderr.trim().slice(0, 200)}`);
|
|
403
649
|
return;
|
|
404
650
|
}
|
|
405
651
|
let parsed: {
|
|
@@ -413,15 +659,18 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
413
659
|
try {
|
|
414
660
|
parsed = JSON.parse(result.stdout) as typeof parsed;
|
|
415
661
|
} catch {
|
|
416
|
-
|
|
662
|
+
classifyFailedBySession.set(input.sessionID, "classify returned invalid JSON");
|
|
663
|
+
await log("warn", "Classify returned invalid JSON, gate tool calls refused for this session");
|
|
417
664
|
return;
|
|
418
665
|
}
|
|
419
666
|
// M5: the `as` cast is compile-time only — validate the shape at runtime
|
|
420
667
|
// so a malformed classify response cannot silently disable enforcement.
|
|
421
668
|
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.categories)) {
|
|
422
|
-
|
|
669
|
+
classifyFailedBySession.set(input.sessionID, "classify returned an unexpected structure");
|
|
670
|
+
await log("warn", "Classify returned unexpected structure, gate tool calls refused for this session");
|
|
423
671
|
return;
|
|
424
672
|
}
|
|
673
|
+
classifyFailedBySession.delete(input.sessionID);
|
|
425
674
|
const categories = (parsed.categories ?? []).map((entry) => entry.id);
|
|
426
675
|
categoriesBySession.set(input.sessionID, categories);
|
|
427
676
|
const primary = parsed.primary ?? categories[0] ?? null;
|
|
@@ -434,7 +683,7 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
434
683
|
"[Novahiz enforcement]",
|
|
435
684
|
`Categories detected: ${categories.join(", ") || "none"}${primary ? ` (primary: ${primary})` : ""}`
|
|
436
685
|
];
|
|
437
|
-
if (rewrite.
|
|
686
|
+
if (rewrite.sourceLanguage !== "en") {
|
|
438
687
|
lines.push(`User language: ${rewrite.sourceLanguage} — respond in this language, not English.`);
|
|
439
688
|
}
|
|
440
689
|
if (roadmap) {
|
|
@@ -457,12 +706,13 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
457
706
|
}
|
|
458
707
|
}
|
|
459
708
|
lines.push("The gate blocks edit/write/patch/apply_patch/bash/shell until the required skills are loaded via skill({name:\"...\"}).");
|
|
460
|
-
lines.push("The gate is content-aware: novahiz-humanizer, ui-slop-remover and ui-craft-rules are required only on frontend design tasks (R13).");
|
|
709
|
+
lines.push("The gate is content-aware: novahiz-humanizer, ui-slop-remover and ui-craft-rules are required only on frontend design tasks (R13), and impeccable on the same design selectors (R14).");
|
|
461
710
|
lines.push("Config edited = opencode restart required (config read at import).");
|
|
462
711
|
lines.push("Memory lives in project-memory/ under the project root (cwd): index.json + fixed-size slots (8000 chars / 200 lines) with compact → archive → new-slot rotation. Use the MCP memory_* tools to read and append.");
|
|
463
712
|
enforcementBySession.set(input.sessionID, lines.join("\n"));
|
|
464
713
|
} catch (error) {
|
|
465
|
-
|
|
714
|
+
classifyFailedBySession.set(input.sessionID, "chat.message hook error");
|
|
715
|
+
await log("warn", `chat.message hook failed, gate tool calls refused for this session: ${String(error).slice(0, 200)}`);
|
|
466
716
|
return;
|
|
467
717
|
}
|
|
468
718
|
},
|
|
@@ -481,29 +731,57 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
481
731
|
|
|
482
732
|
"tool.execute.before": async (input, output) => {
|
|
483
733
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
734
|
+
const tool = input.tool.toLowerCase();
|
|
735
|
+
const gated = !DISABLED && GATE_TOOLS.has(tool);
|
|
736
|
+
// P0-B: an invalid session ID must not bypass the gate. Gate tools and
|
|
737
|
+
// skill loads are refused; tools that need no enforcement still pass.
|
|
738
|
+
if (!isValidSessionId(input.sessionID)) {
|
|
739
|
+
if (gated || (!DISABLED && tool === "skill")) {
|
|
740
|
+
throw new Error(
|
|
741
|
+
`Novahiz gate blocked ${input.tool}: invalid session ID — loaded skills cannot be tracked. Fix the session or set NOVAHIZ_GATE=off to disable.`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
487
746
|
touch(input.sessionID);
|
|
488
747
|
if (!loadedBySession.has(input.sessionID)) loadedBySession.set(input.sessionID, new Set());
|
|
489
748
|
const loaded = loadedBySession.get(input.sessionID)!;
|
|
490
749
|
|
|
491
|
-
if (
|
|
492
|
-
const args = output.args as { name?:
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
750
|
+
if (tool === "skill") {
|
|
751
|
+
const args = output.args as { name?: unknown; skill?: unknown } | undefined;
|
|
752
|
+
const raw = args?.name ?? args?.skill;
|
|
753
|
+
if (raw !== undefined && raw !== null && typeof raw !== "string") {
|
|
754
|
+
throw new Error("Novahiz gate blocked the skill load: the skill name must be a string.");
|
|
755
|
+
}
|
|
756
|
+
const name = typeof raw === "string" ? raw.trim() : "";
|
|
757
|
+
// P0-B: no commas or spaces — the gate re-splits --loaded on commas,
|
|
758
|
+
// so a loose name could inject extra "loaded" skills.
|
|
759
|
+
if (name.length > 0 && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) {
|
|
760
|
+
throw new Error(`Novahiz gate blocked the skill load: invalid skill name "${name.slice(0, 64)}".`);
|
|
761
|
+
}
|
|
762
|
+
if (name.length > 0) {
|
|
763
|
+
// P0-B: record only after session-load validated the name against
|
|
764
|
+
// the installed index — an unverified name never counts as loaded.
|
|
765
|
+
// H3 stays: failures are surfaced in the log instead of vanishing.
|
|
766
|
+
const loadResult = run(["session-load", "--session", input.sessionID, "--skill", name]);
|
|
499
767
|
if (loadResult.status !== 0) {
|
|
500
|
-
await log("warn", `session-load failed for skill ${name}: ${(loadResult.stderr || "").trim().slice(0, 200)}`);
|
|
768
|
+
await log("warn", `session-load failed for skill ${name}, not recorded: ${(loadResult.stderr || loadResult.stdout || "").trim().slice(0, 200)}`);
|
|
769
|
+
} else {
|
|
770
|
+
loaded.add(name);
|
|
501
771
|
}
|
|
502
772
|
}
|
|
503
773
|
return;
|
|
504
774
|
}
|
|
505
775
|
|
|
506
|
-
if (
|
|
776
|
+
if (!gated) return;
|
|
777
|
+
// P0-B: a failed classify would empty the session categories and
|
|
778
|
+
// neutralize the prompt-scoped rules — refuse instead of failing open.
|
|
779
|
+
const classifyFailure = classifyFailedBySession.get(input.sessionID);
|
|
780
|
+
if (classifyFailure) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`Novahiz gate blocked ${input.tool}: prompt classification failed (${classifyFailure}). Fix the install (run "novahiz sync", check catalog/) and send a new message, or set NOVAHIZ_GATE=off to disable.`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
507
785
|
|
|
508
786
|
const categories = categoriesBySession.get(input.sessionID) ?? [];
|
|
509
787
|
const result = run(
|
|
@@ -537,6 +815,18 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
537
815
|
);
|
|
538
816
|
}
|
|
539
817
|
if (result.status === 2) {
|
|
818
|
+
// Auto-repair: a structured denial becomes an executable directive
|
|
819
|
+
// (load the named skills, retry the same call, resume the task).
|
|
820
|
+
// Counting identical denials turns a failed repair into a diagnosis
|
|
821
|
+
// instead of an infinite retry loop. The denial itself still stands
|
|
822
|
+
// until the gate CLI sees the skills — nothing is granted here.
|
|
823
|
+
const failure = parseGateFailure(result.stdout);
|
|
824
|
+
if (failure) {
|
|
825
|
+
const key = `${input.sessionID}|${failure.tool}|${[...failure.missingSkills].sort().join(",")}`;
|
|
826
|
+
const attempt = (repairAttemptsBySession.get(key) ?? 0) + 1;
|
|
827
|
+
repairAttemptsBySession.set(key, attempt);
|
|
828
|
+
throw new Error(buildRepairDirective(failure, attempt));
|
|
829
|
+
}
|
|
540
830
|
throw new Error(`Novahiz gate blocked ${input.tool}.\n${result.stdout}`);
|
|
541
831
|
}
|
|
542
832
|
if (result.status !== 0) {
|
|
@@ -544,6 +834,12 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
544
834
|
`Novahiz gate unavailable (exit ${result.status}). Fix the install (run sync, check catalog/) or set the escape variable to disable.\n${result.stderr}`
|
|
545
835
|
);
|
|
546
836
|
}
|
|
837
|
+
// The call is allowed: the repair converged — drop this session's
|
|
838
|
+
// denial counters so the next task starts a fresh cycle.
|
|
839
|
+
const allowedPrefix = `${input.sessionID}|${input.tool.toLowerCase()}|`;
|
|
840
|
+
for (const key of repairAttemptsBySession.keys()) {
|
|
841
|
+
if (key.startsWith(allowedPrefix)) repairAttemptsBySession.delete(key);
|
|
842
|
+
}
|
|
547
843
|
} catch (error) {
|
|
548
844
|
if (error instanceof Error && error.message.startsWith("Novahiz gate")) throw error;
|
|
549
845
|
// H2: fail-closed — unknown gate errors deny, they never bypass.
|