novahiz 0.9.8 → 0.10.0

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 (44) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +6 -6
  3. package/adapters/opencode/agent/novahiz.md +1 -0
  4. package/adapters/opencode/commands/novahiz-upgrade.md +1 -1
  5. package/adapters/opencode/novahiz-plugin.ts +11 -3
  6. package/catalog/categories.json +209 -2
  7. package/catalog/rules.json +2 -35
  8. package/dist/classify.js +5 -1
  9. package/dist/commands/doctor.js +3 -1
  10. package/dist/commands/gate.js +36 -1
  11. package/dist/commands/inspect.js +43 -2
  12. package/dist/commands/upgrade.js +47 -3
  13. package/dist/complexity.js +12 -6
  14. package/dist/db.js +8 -2
  15. package/dist/gate.js +16 -18
  16. package/dist/prompt-rewriter.js +1 -0
  17. package/dist/readonly.js +119 -0
  18. package/dist/session.js +63 -0
  19. package/docs/ARCHITECTURE.md +2 -2
  20. package/docs/CATALOG.md +1 -1
  21. package/docs/CLASSIFICATION.md +16 -3
  22. package/docs/GATE.md +7 -15
  23. package/docs/ROADMAPS.md +2 -2
  24. package/docs/RULES.md +5 -5
  25. package/install/install.mjs +53 -2
  26. package/install/lib.d.mts +36 -0
  27. package/install/lib.mjs +106 -2
  28. package/install/uninstall.mjs +5 -8
  29. package/package.json +1 -1
  30. package/skills/novahiz-capture/SKILL.md +123 -0
  31. package/skills/novahiz-capture/scripts/shot-spec.mjs +113 -0
  32. package/skills/novahiz-gate/SKILL.md +2 -2
  33. package/skills/novahiz-planner/SKILL.md +1 -1
  34. package/src/classify.ts +5 -1
  35. package/src/commands/doctor.ts +3 -1
  36. package/src/commands/gate.ts +36 -1
  37. package/src/commands/inspect.ts +44 -3
  38. package/src/commands/upgrade.ts +50 -3
  39. package/src/complexity.ts +12 -6
  40. package/src/db.ts +8 -2
  41. package/src/gate.ts +16 -16
  42. package/src/prompt-rewriter.ts +1 -0
  43. package/src/readonly.ts +123 -0
  44. package/src/session.ts +99 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,103 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.10.0] - 2026-10-22
9
+
10
+ ### Fixed
11
+
12
+ - **The gate and the injected block now agree on the pipeline** - `R6-Novahiz`
13
+ required plan/clarify/analyse/implement/converge on every workflow prompt,
14
+ while the roadmap loop only enforced `kind: "skill"`. A lite or Flutter task
15
+ could therefore announce nothing (or five skills) and be blocked for seven.
16
+ `R6` is removed and every non-optional roadmap step that carries
17
+ `requireSkills` is enforced, whatever its kind (`skill`, `edit`, `verify`,
18
+ `advisory`). `R10-security` is removed too: the audit roadmap already names
19
+ `novahiz-security`. `R11-accessibility` is narrowed to `design-ui`. The
20
+ catalogue now holds 13 rules instead of 15.
21
+ - **A trivial follow-up no longer disarms the gate** - the plugin kept the
22
+ classification in memory only. `continue` / `ok`, or a plugin restart, fell
23
+ back to `general/trivial` and the gate allowed every edit with `missing: []`.
24
+ The last non-trivial classification is now persisted in the `sessions` table
25
+ (schema v3 adds the `prompt` and `tier` columns), `classify --session`
26
+ returns the effective classification, and the gate CLI reads that state from
27
+ the database when categories and prompt are empty.
28
+ - **`doctor` reports a schema mismatch** - the schema check hardcoded
29
+ `ok: true`; it now compares `PRAGMA user_version` with `SCHEMA_VERSION`.
30
+ - **The gate no longer blocks provably read-only shell commands** -
31
+ `git status`, `git log`, `git diff`, `ls`, `Get-Content` and their
32
+ equivalents are exempt (`src/readonly.ts`). The gate used to ask for the
33
+ design skills before it would let an agent read a file.
34
+ - **French UI prompts are no longer fail-open** - `ajoute` / `ajouter` now
35
+ score as lite and `implémente` as full, so a French additive request reaches
36
+ the pipeline instead of falling through to trivial. `design-ui` recognises
37
+ `bouton`, `écran`, `connexion`, `formulaire`, `menu`, `champ`, `icône`,
38
+ `carte`, `modal`, `header`, `sidebar` and their English forms. Numeric
39
+ micro-changes (`ajoute 8px de marge`) stay trivial, and debug words
40
+ (`bug`, `error`, `crash`, ...) subtract from `design-ui` so
41
+ `fix the login bug` stays in `debug` instead of dragging the design pipeline
42
+ into a fix.
43
+
44
+ ### Changed
45
+
46
+ - The gate CLI falls back to the persisted session state when the caller
47
+ passes no categories and no prompt, instead of letting the decision rest on
48
+ an empty classification.
49
+ - The plugin asks `classify --session` and, for a trivial continuation,
50
+ inherits the previous task's prompt so the tier does not drop back to
51
+ trivial mid-task.
52
+
53
+ ### Added
54
+
55
+ - **Session state persistence** - `src/session.ts` stores the categories, tier
56
+ and prompt of the last non-trivial task, with a TTL that keeps a stale
57
+ session from being mistaken for a continuation.
58
+ - **A read-only allowlist for the gate** - `src/readonly.ts` lists the shell
59
+ commands the gate can prove do not write.
60
+ - **The `novahiz-capture` skill** - reliable screenshot capture for the
61
+ physical Playwright browser, when a plain capture comes back stale, blank
62
+ or cropped.
63
+ - **Three test files** - `tests/session-state.test.ts`, `tests/readonly.test.ts`
64
+ and `tests/gate-alignment.test.ts` pin the session state, the read-only
65
+ exemption and the alignment between the classifier and the gate.
66
+
67
+ ## [0.9.9] - 2026-10-16
68
+
69
+ ### Changed
70
+
71
+ - **`novahiz upgrade --apply` now finishes the job it started** - it stopped at
72
+ `npm install -g novahiz@latest`, which refreshes the global package and
73
+ nothing else: `~/.config/novahiz`, the plugin copy and the MCP entrypoints
74
+ stayed on the previous version. It now resyncs the whole installation from
75
+ the fresh package (`install.mjs --yes --clean`) on both channels, so
76
+ `novahiz update` behaves exactly the same. If the update or the resync
77
+ fails, the command stops and reports the error instead of announcing a
78
+ completed upgrade.
79
+
80
+ ### Added
81
+
82
+ - **A cleanup of the previous installation, opt-in** - an update used to leave
83
+ behind whatever it no longer shipped: a renamed skill stayed loadable, a
84
+ removed plugin stayed registered. `install.mjs --clean` now removes the files
85
+ a previous install wrote whose source has disappeared. The only proof of
86
+ ownership is the install manifest (`.novahiz-install.json`, field `created`):
87
+ a skill or a command placed by a pack is not in it, so it cannot be removed.
88
+ The rule "absent from the source" was the tempting one and it is wrong here —
89
+ it would have deleted `impeccable`, which the pack keeps in
90
+ `~/.agents/skills`. Three guards hold: only files under the four installed
91
+ roots, never after a failed step, never without `--clean`. A `.novahiz-bak`
92
+ goes with the file it backed up and never alone. `novahiz.config.json`,
93
+ `project-memory/` and `novahiz.sqlite` sit outside those roots, untouched by
94
+ construction.
95
+
96
+ ### Fixed
97
+
98
+ - **`pruneEmptyDirs` never removed anything** - it called
99
+ `rmSync(dir, { recursive: false })`, which throws `ERR_FS_EISDIR` on a
100
+ directory, and the surrounding `catch` swallowed the error. The function
101
+ looked like it worked and silently left every emptied directory behind, for
102
+ `novahiz uninstall` as well. It now uses `recursive: true`, which is safe
103
+ because the directory is already known to be empty.
104
+
8
105
  ## [0.9.8] - 2026-10-15
9
106
 
10
107
  ### Removed
package/README.md CHANGED
@@ -125,12 +125,10 @@ flowchart TD
125
125
  C --> D[R13 : prompt design-ui ou fichier style ?<br/>exiger humanizer + ui-slop + ui-craft]
126
126
  C --> F[R3 : le prompt était Supabase ?<br/>exiger novahiz-supabase + postgres]
127
127
  C --> G[R4 : le prompt était navigateur ?<br/>exiger novahiz-browser]
128
- C --> H[R6 : prompt de workflow ?<br/>exiger plan/clarify/analyse/implement/converge]
129
128
 
130
- D --> I{Application de la roadmap}
129
+ D --> I{Application de la roadmap<br/>étapes non optionnelles}
131
130
  F --> I
132
131
  G --> I
133
- H --> I
134
132
 
135
133
  I --> J{Détection de placeholder<br/>tokens TODO / FIXME / placeholder}
136
134
 
@@ -160,18 +158,20 @@ flowchart TD
160
158
  |-------|------------------|-------|
161
159
  | R3-supabase | Un chemin Supabase ou un prompt Supabase | novahiz-supabase, novahiz-postgres |
162
160
  | R4-playwright | Une catégorie prompt navigateur, ou un chemin de test navigateur (`**/*.spec.ts`, `**/e2e/**`, `**/playwright/**`, …) | novahiz-browser |
163
- | R6-Novahiz | Un prompt dans une catégorie de workflow | novahiz-plan, -clarify, -analyse, -implement, -converge |
164
161
  | R7-assessment | Un prompt d'assessment | novahiz-assess-intake, -research, -define, -shape, -decide |
165
162
  | R8-docs | Une édition sous `novahiz-docs/**/*.md` | novahiz-docs |
166
163
  | R9-code-review | Un prompt de review ou un fichier de code en review | novahiz-code-review |
167
- | R10-security | Un prompt d'audit ou de sécurité | novahiz-security |
168
- | R11-accessibility | Un prompt design-ui ou audit | novahiz-wcag-audit |
164
+ | R11-accessibility | Un prompt design-ui | novahiz-wcag-audit |
169
165
  | R12-web-extract | Un prompt de recherche | novahiz-web-extract |
170
166
  | R13-design-craft | Un prompt design-ui ou un fichier style (css/scss/less/html) | novahiz-humanizer, ui-slop-remover, ui-craft-rules |
171
167
  | R14-impeccable | Un prompt design-ui ou un fichier style (css/scss/less/html) | impeccable |
172
168
 
173
169
  `novahiz-humanizer` et `ui-slop-remover` ne sont exigées que sur les tâches de design frontend (R13) ; `impeccable` se charge de la même façon (R14) pour garder les playbooks shape, critique, audit, harden et polish accessibles, et la roadmap design-ui porte une étape de vérification `impeccable detect` déterministe avant ship. `novahiz init` et `novahiz doctor` affichent les fichiers de contexte `PRODUCT.md` / `DESIGN.md` d'impeccable en lignes advisory.
174
170
 
171
+ Le pipeline (plan, clarify, analyse, implement, converge) n'est plus une règle : il vient des étapes non optionnelles de la roadmap de la catégorie principale, quel que soit leur `kind` (`skill`, `edit`, `verify`, `advisory`). Une seule source de vérité, donc le bloc affiché et le gate ne peuvent plus diverger sur ce point.
172
+
173
+ Les commandes shell prouvées en lecture seule (`git status`, `git log`, `git diff`, `ls`, `Get-ChildItem`, `Get-Content`, `rg`, `Select-String`, …) sont exemptées avant toute règle ou roadmap. Dès qu'une commande chaîne, redirige, substitue ou porte un flag d'écriture, l'exemption tombe et le gate s'applique normalement.
174
+
175
175
  ### Gate reload
176
176
 
177
177
  Un blocage n'est jamais une impasse. Le refus embarque une recette GATE RELOAD : charger chaque skill nommée via le loader, puis rejouer l'appel exact une fois — aucun outil alternatif, aucune écriture shell, aucun contournement d'édition. Si les mêmes skills sont de nouveau signalées manquantes, le chargement n'a pas été enregistré : exécuter `novahiz doctor`, signaler honnêtement, et s'arrêter. La seule dérogation sanctionnée est `NOVAHIZ_GATE=off` (voir Configuration), déclarée à voix haute.
@@ -2,6 +2,7 @@
2
2
  description: Novahiz primary agent. Classifies the request, loads the required skills, then works under the Novahiz gate.
3
3
  mode: primary
4
4
  temperature: 0.2
5
+ color: "#22c55e"
5
6
  # opencode denies `question` by default. The built-in build and plan agents
6
7
  # re-allow it; a custom agent inherits the denial unless it asks. Without this
7
8
  # block the pipeline cannot ask a clarifying question or validate a plan.
@@ -8,6 +8,6 @@ description: Check for a new Novahiz version and update it via npm. Arguments: $
8
8
 
9
9
  3. If an update is available, report the installed -> latest versions, then ask the user for explicit approval. Never run the update without that approval (skip this confirmation only when `$ARGUMENTS` contains `--yes`).
10
10
 
11
- 4. After approval, run `novahiz upgrade --apply` (same CLI used in step 1). This runs `npm install -g novahiz@latest` for npm installs, or `git pull --ff-only` plus a catalog rebuild for source checkouts. Report the exact result, including any npm/git error verbatim — never claim success you did not observe.
11
+ 4. After approval, run `novahiz upgrade --apply` (same CLI used in step 1). It installs `novahiz@latest` globally for npm installs, or pulls with `--ff-only` for source checkouts, then resyncs the Novahiz home and the opencode config from the fresh package and cleans what the previous version left behind. The cleanup only removes files the previous install manifest proves Novahiz wrote and whose source no longer exists: pack skills, `novahiz.config.json`, `project-memory/` and the vault are never touched. If the update or the resync fails, the command stops before the cleanup. Report the exact result, including any npm/git error verbatim — never claim success you did not observe.
12
12
 
13
13
  5. Finish by telling the user to restart OpenCode completely so the new version loads. Offer `novahiz doctor` as the follow-up check.
@@ -1633,7 +1633,7 @@ async function setup(ctx: Context): Promise<Cleanup> {
1633
1633
  // P0-B: the prompt travels on stdin. On argv it allowed option
1634
1634
  // injection (--home), broke past the Windows 32k limit, and was
1635
1635
  // readable in the process list.
1636
- const result = await run(["classify", "--stdin"], classifyText);
1636
+ const result = await run(["classify", "--stdin", "--session", sessionID], classifyText);
1637
1637
  if (result.status !== 0) {
1638
1638
  classifyFailedBySession.set(sessionID, `classify exit ${result.status}`);
1639
1639
  await log("warn", `Classify failed (exit ${result.status}), gate tool calls refused for this session: ${result.stderr.trim().slice(0, 200)}`);
@@ -1645,6 +1645,7 @@ async function setup(ctx: Context): Promise<Cleanup> {
1645
1645
  requiredSkills?: string[];
1646
1646
  enforcedSkills?: string[];
1647
1647
  providers?: string[];
1648
+ continuedFrom?: string;
1648
1649
  roadmaps?: { id: string; steps: { label: string; kind: string; requireSkills?: string[] }[] }[];
1649
1650
  };
1650
1651
  try {
@@ -1666,7 +1667,11 @@ async function setup(ctx: Context): Promise<Cleanup> {
1666
1667
  categoriesBySession.set(sessionID, categories);
1667
1668
  // P1-4: the gate takes the last prompt as its tier seed via --prompt even
1668
1669
  // when memory auto is off — set unconditionally, not inside MEMORY_AUTO.
1669
- lastPromptBySession.set(sessionID, text.slice(0, 300));
1670
+ // A trivial follow-up ("continue") inherits the previous task's prompt:
1671
+ // using the literal "continue" here would retier the session as trivial
1672
+ // and skip the roadmap the classify call just restored.
1673
+ const effectivePrompt = parsed.continuedFrom ?? text;
1674
+ lastPromptBySession.set(sessionID, effectivePrompt.slice(0, 300));
1670
1675
  const primary = parsed.primary ?? categories[0] ?? null;
1671
1676
  const enforced = parsed.enforcedSkills ?? [];
1672
1677
  const required = parsed.requiredSkills ?? [];
@@ -1677,6 +1682,9 @@ async function setup(ctx: Context): Promise<Cleanup> {
1677
1682
  "[Novahiz enforcement]",
1678
1683
  `Categories detected: ${categories.join(", ") || "none"}${primary ? ` (primary: ${primary})` : ""}`
1679
1684
  ];
1685
+ if (parsed.continuedFrom) {
1686
+ lines.push(`Continuing the previous task: ${parsed.continuedFrom.slice(0, 160)}`);
1687
+ }
1680
1688
  if (rewrite.sourceLanguage !== "en") {
1681
1689
  lines.push(`User language: ${rewrite.sourceLanguage} — respond in this language, not English.`);
1682
1690
  }
@@ -1699,7 +1707,7 @@ async function setup(ctx: Context): Promise<Cleanup> {
1699
1707
  await log("warn", "Ledger state is invalid JSON, enforcement injected without the task summary");
1700
1708
  }
1701
1709
  }
1702
- lines.push("The gate blocks edit/write/patch/apply_patch/bash/shell until the required skills are loaded via skill({id:\"...\"}).");
1710
+ lines.push("The gate blocks edit/write/patch/apply_patch/bash/shell until the required skills are loaded via skill({id:\"...\"}). Read-only shell commands (status/log/diff/ls/Get-Content/...) are exempt.");
1703
1711
  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).");
1704
1712
  lines.push("Config edited = opencode restart required (config read at import).");
1705
1713
  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: memory_search to find relevant slots first, then memory_get to read, memory_write to append, memory_update to correct, memory_archive to retire.");
@@ -756,7 +756,206 @@
756
756
  "accessibility",
757
757
  "a11y",
758
758
  "dashboard",
759
- "form",
759
+ {
760
+ "term": "form",
761
+ "weight": 2
762
+ },
763
+ {
764
+ "term": "forms",
765
+ "weight": 2
766
+ },
767
+ {
768
+ "term": "formulaire",
769
+ "weight": 2
770
+ },
771
+ {
772
+ "term": "formulaires",
773
+ "weight": 2
774
+ },
775
+ {
776
+ "term": "bouton",
777
+ "weight": 2
778
+ },
779
+ {
780
+ "term": "boutons",
781
+ "weight": 2
782
+ },
783
+ {
784
+ "term": "button",
785
+ "weight": 2
786
+ },
787
+ {
788
+ "term": "buttons",
789
+ "weight": 2
790
+ },
791
+ {
792
+ "term": "écran",
793
+ "weight": 2
794
+ },
795
+ {
796
+ "term": "screen",
797
+ "weight": 2
798
+ },
799
+ {
800
+ "term": "screens",
801
+ "weight": 2
802
+ },
803
+ {
804
+ "term": "connexion",
805
+ "weight": 1
806
+ },
807
+ {
808
+ "term": "login",
809
+ "weight": 1
810
+ },
811
+ {
812
+ "term": "sign in",
813
+ "weight": 1
814
+ },
815
+ {
816
+ "term": "signin",
817
+ "weight": 1
818
+ },
819
+ {
820
+ "term": "sign up",
821
+ "weight": 1
822
+ },
823
+ {
824
+ "term": "signup",
825
+ "weight": 1
826
+ },
827
+ {
828
+ "term": "inscription",
829
+ "weight": 1
830
+ },
831
+ {
832
+ "term": "mot de passe",
833
+ "weight": 1
834
+ },
835
+ {
836
+ "term": "password",
837
+ "weight": 1
838
+ },
839
+ {
840
+ "term": "champ",
841
+ "weight": 1
842
+ },
843
+ {
844
+ "term": "field",
845
+ "weight": 1
846
+ },
847
+ {
848
+ "term": "input",
849
+ "weight": 1
850
+ },
851
+ {
852
+ "term": "menu",
853
+ "weight": 1
854
+ },
855
+ {
856
+ "term": "icône",
857
+ "weight": 1
858
+ },
859
+ {
860
+ "term": "icon",
861
+ "weight": 1
862
+ },
863
+ {
864
+ "term": "carte",
865
+ "weight": 1
866
+ },
867
+ {
868
+ "term": "card",
869
+ "weight": 1
870
+ },
871
+ {
872
+ "term": "modal",
873
+ "weight": 1
874
+ },
875
+ {
876
+ "term": "dialog",
877
+ "weight": 1
878
+ },
879
+ {
880
+ "term": "dialogue",
881
+ "weight": 1
882
+ },
883
+ {
884
+ "term": "header",
885
+ "weight": 1
886
+ },
887
+ {
888
+ "term": "footer",
889
+ "weight": 1
890
+ },
891
+ {
892
+ "term": "sidebar",
893
+ "weight": 1
894
+ },
895
+ {
896
+ "term": "composant",
897
+ "weight": 1
898
+ },
899
+ {
900
+ "term": "component",
901
+ "weight": 1
902
+ },
903
+ {
904
+ "term": "onglet",
905
+ "weight": 1
906
+ },
907
+ {
908
+ "term": "tab",
909
+ "weight": 1
910
+ },
911
+ {
912
+ "term": "avatar",
913
+ "weight": 1
914
+ },
915
+ {
916
+ "term": "badge",
917
+ "weight": 1
918
+ },
919
+ {
920
+ "term": "tooltip",
921
+ "weight": 1
922
+ },
923
+ {
924
+ "term": "toast",
925
+ "weight": 1
926
+ },
927
+ {
928
+ "term": "snackbar",
929
+ "weight": 1
930
+ },
931
+ {
932
+ "term": "drawer",
933
+ "weight": 1
934
+ },
935
+ {
936
+ "term": "panneau",
937
+ "weight": 1
938
+ },
939
+ {
940
+ "term": "panel",
941
+ "weight": 1
942
+ },
943
+ {
944
+ "term": "accueil",
945
+ "weight": 1
946
+ },
947
+ {
948
+ "term": "welcome",
949
+ "weight": 1
950
+ },
951
+ {
952
+ "term": "home",
953
+ "weight": 1
954
+ },
955
+ {
956
+ "term": "homepage",
957
+ "weight": 1
958
+ },
760
959
  "onboarding",
761
960
  "frontend design",
762
961
  "ui component",
@@ -773,7 +972,15 @@
773
972
  "negativeKeywords": [
774
973
  "migration",
775
974
  "rls",
776
- "edge function"
975
+ "edge function",
976
+ "bug",
977
+ "error",
978
+ "crash",
979
+ "exception",
980
+ "regression",
981
+ "stacktrace",
982
+ "panic",
983
+ "broken"
777
984
  ],
778
985
  "defaultSkills": [
779
986
  "novahiz-humanizer",
@@ -26,30 +26,6 @@
26
26
  },
27
27
  "require": ["novahiz-browser"]
28
28
  },
29
- {
30
- "id": "R6-Novahiz",
31
- "description": "Load Novahiz pipeline skills for any task triggering the workflow.",
32
- "when": {
33
- "promptCategories": [
34
- "code",
35
- "debug",
36
- "review",
37
- "audit",
38
- "browser",
39
- "design-ui",
40
- "database-supabase",
41
- "docs-writing",
42
- "planning",
43
- "devops",
44
- "data",
45
- "general",
46
- "flutter",
47
- "expo",
48
- "test"
49
- ]
50
- },
51
- "require": ["novahiz-plan", "novahiz-clarify", "novahiz-analyse", "novahiz-implement", "novahiz-converge"]
52
- },
53
29
  {
54
30
  "id": "R7-assessment",
55
31
  "description": "Load Idea Assessment pipeline skills.",
@@ -80,21 +56,12 @@
80
56
  },
81
57
  "require": ["novahiz-code-review"]
82
58
  },
83
- {
84
- "id": "R10-security",
85
- "description": "Load novahiz-security on any security audit or hardening task.",
86
- "when": {
87
- "match": "any",
88
- "promptCategories": ["audit"]
89
- },
90
- "require": ["novahiz-security"]
91
- },
92
59
  {
93
60
  "id": "R11-accessibility",
94
- "description": "Load novahiz-wcag-audit on design-ui work and on audit prompts, where accessibility is in scope.",
61
+ "description": "Load novahiz-wcag-audit on design-ui work, where accessibility is in scope.",
95
62
  "when": {
96
63
  "match": "any",
97
- "promptCategories": ["design-ui", "audit"],
64
+ "promptCategories": ["design-ui"],
98
65
  "pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
99
66
  },
100
67
  "require": ["novahiz-wcag-audit"]
package/dist/classify.js CHANGED
@@ -58,7 +58,11 @@ function skillsOfCategory(category) {
58
58
  function enforcedOfCategory(category) {
59
59
  const skills = [];
60
60
  for (const step of category.roadmap?.steps ?? []) {
61
- if (step.kind !== "skill" || step.optional)
61
+ // Every non-optional step counts, whatever its kind: `edit:implement` and
62
+ // `verify:converge` carry skills too. Restricting to kind "skill" made
63
+ // lite/debug roadmaps advertise nothing while the gate demanded R6's
64
+ // implement/converge — the block and the gate disagreed.
65
+ if (step.optional)
62
66
  continue;
63
67
  for (const skill of step.requireSkills ?? []) {
64
68
  if (!skills.includes(skill))
@@ -691,17 +691,19 @@ export async function commandDoctor(parsed) {
691
691
  // matches by the time this reads it. The value is read so a future migration
692
692
  // has something to branch on, and so a database from an older build shows up.
693
693
  let schemaDetail = "database unreadable";
694
+ let schemaOk = false;
694
695
  try {
695
696
  const db = openDb(dbFile);
696
697
  const row = db.prepare("PRAGMA user_version").get();
697
698
  const version = Number(row?.user_version ?? 0);
698
699
  db.close();
699
700
  schemaDetail = `version ${version} (expected ${SCHEMA_VERSION})`;
701
+ schemaOk = version === SCHEMA_VERSION;
700
702
  }
701
703
  catch (error) {
702
704
  schemaDetail = `unreadable: ${error.message}`;
703
705
  }
704
- checks.push({ id: "schema", label: "Schema version", ok: true, detail: schemaDetail, blocking: false });
706
+ checks.push({ id: "schema", label: "Schema version", ok: schemaOk, detail: schemaDetail, blocking: false });
705
707
  const adapterSource = join(root, "adapters", "opencode", "novahiz-plugin.ts");
706
708
  const opencodeDir = process.env.OPENCODE_CONFIG_DIR && process.env.OPENCODE_CONFIG_DIR.length > 0
707
709
  ? process.env.OPENCODE_CONFIG_DIR
@@ -10,6 +10,8 @@ import { changeText } from "../content.js";
10
10
  import { classify } from "../classify.js";
11
11
  import { enforceLedgerChecks, evaluateGate } from "../gate.js";
12
12
  import { extractTargetPaths } from "../targets.js";
13
+ import { isReadOnlyCommand } from "../readonly.js";
14
+ import { isValidSessionId, readSessionClassification } from "../session.js";
13
15
  export function commandGate(parsed) {
14
16
  const root = NovahizHome();
15
17
  let spec;
@@ -91,7 +93,7 @@ export function commandGate(parsed) {
91
93
  const single = asString(parsed.flags.file);
92
94
  let content = asString(parsed.flags.content);
93
95
  // C1: the prompt that triggered the edit drives the complexity tier.
94
- const prompt = asString(parsed.flags.prompt);
96
+ let prompt = asString(parsed.flags.prompt);
95
97
  let paths;
96
98
  if (single.length > 0) {
97
99
  paths = [single];
@@ -109,6 +111,18 @@ export function commandGate(parsed) {
109
111
  return;
110
112
  }
111
113
  }
114
+ // P0: a read-only shell command (status/log/diff/ls/...) is not an edit.
115
+ // It is exempted before any prompt rule, category or ledger check runs —
116
+ // the gate's job is to guard writes, not to make `git status` cost five
117
+ // skill loads. Unrecognised or chained commands fall through unchanged.
118
+ if ((tool === "bash" || tool === "shell") && args !== null && typeof args === "object") {
119
+ const record = args;
120
+ const command = typeof record.command === "string" ? record.command : typeof record.cmd === "string" ? record.cmd : "";
121
+ if (isReadOnlyCommand(command)) {
122
+ print({ allow: true, tool, readonly: true, reason: "read-only shell command" });
123
+ return;
124
+ }
125
+ }
112
126
  paths = extractTargetPaths(tool, args);
113
127
  if (content.length === 0)
114
128
  content = changeText(tool, args);
@@ -118,6 +132,27 @@ export function commandGate(parsed) {
118
132
  process.exitCode = 1;
119
133
  return;
120
134
  }
135
+ // P0: after a plugin restart the in-memory categories are gone and the
136
+ // caller sends neither categories nor prompt. Read the last non-trivial
137
+ // classification back from the session instead of classifying an empty seed
138
+ // into general/trivial — which allowed every edit with missing: [].
139
+ if (categories.length === 0 && prompt.length === 0 && session.length > 0 && isValidSessionId(session)) {
140
+ let stateDb = null;
141
+ try {
142
+ stateDb = openDb(dbPathFor(root, spec));
143
+ const state = readSessionClassification(stateDb, session);
144
+ if (state) {
145
+ categories = state.categories;
146
+ prompt = state.prompt;
147
+ }
148
+ }
149
+ catch (error) {
150
+ process.stderr.write(`novahiz: session state read skipped: ${String(error).slice(0, 200)}\n`);
151
+ }
152
+ finally {
153
+ stateDb?.close();
154
+ }
155
+ }
121
156
  // Audit 2026-09-25 (P1-3): empty categories no longer fall through to an
122
157
  // unscoped evaluation — the CLI classifies the seed itself (same contract as
123
158
  // the MCP gate) and fails closed only when classification itself is broken.
@@ -8,6 +8,7 @@ import { buildMcpEntries, enabledProviders, installCommands, pendingInstallComma
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { bootstrapFor, coreBootstrap, dependencyReport, missingPrerequisites, reloadPathFromRegistry, toolchainStatus } from "../deps.js";
10
10
  import { classify } from "../classify.js";
11
+ import { isContinuation, isValidSessionId, readSessionClassification, writeSessionClassification } from "../session.js";
11
12
  import { runScript } from "../exec.js";
12
13
  import { activeTask, buildWorkPackets, getTask } from "../ledger.js";
13
14
  export function commandCheck() {
@@ -60,6 +61,38 @@ export function commandClassify(parsed) {
60
61
  minScore: numberFlag(parsed, "min-score", { min: 0 }),
61
62
  maxCategories: numberFlag(parsed, "max-categories", { min: 1, integer: true })
62
63
  });
64
+ const session = asString(parsed.flags.session);
65
+ if (!isValidSessionId(session)) {
66
+ print({ prompt: text, ...result });
67
+ return;
68
+ }
69
+ const db = openDb(dbPathFor(root, spec));
70
+ try {
71
+ const stored = readSessionClassification(db, session);
72
+ // A trivial follow-up ("continue", "ok") inherits the last non-trivial
73
+ // task of this session. Without this the plugin overwrote its categories
74
+ // with general/trivial and the gate allowed every edit (missing: []).
75
+ if (result.tier === "trivial" && stored && isContinuation(stored)) {
76
+ const continued = classify(spec, stored.prompt, {
77
+ minScore: numberFlag(parsed, "min-score", { min: 0 }),
78
+ maxCategories: numberFlag(parsed, "max-categories", { min: 1, integer: true })
79
+ });
80
+ print({ prompt: text, ...continued, continuedFrom: stored.prompt });
81
+ return;
82
+ }
83
+ if (result.tier !== "trivial") {
84
+ writeSessionClassification(db, {
85
+ session,
86
+ categories: result.categories.map((entry) => entry.id),
87
+ requiredSkills: result.enforcedSkills,
88
+ tier: result.tier,
89
+ prompt: text
90
+ });
91
+ }
92
+ }
93
+ finally {
94
+ db.close();
95
+ }
63
96
  print({ prompt: text, ...result });
64
97
  }
65
98
  export function commandSkills(parsed) {
@@ -142,9 +175,17 @@ export function commandSessionState(parsed) {
142
175
  }
143
176
  const db = openDb(dbPathFor(root, spec));
144
177
  const loaded = db.prepare("SELECT skill FROM skill_invocations WHERE session_id = ?").all(session).map((row) => row.skill);
145
- const state = db.prepare("SELECT categories, required_skills FROM sessions WHERE id = ?").get(session);
178
+ const state = db.prepare("SELECT categories, required_skills, prompt, tier, updated_at FROM sessions WHERE id = ?").get(session);
146
179
  db.close();
147
- print({ session, loaded, categories: state ? safeJsonArray(state.categories) : [], requiredSkills: state ? safeJsonArray(state.required_skills) : [] });
180
+ print({
181
+ session,
182
+ loaded,
183
+ categories: state ? safeJsonArray(state.categories) : [],
184
+ requiredSkills: state ? safeJsonArray(state.required_skills) : [],
185
+ prompt: state?.prompt ?? "",
186
+ tier: state?.tier ?? null,
187
+ updatedAt: state?.updated_at ?? null
188
+ });
148
189
  }
149
190
  export function commandCatalog(parsed) {
150
191
  const spec = loadSpec();