novahiz 0.9.5 → 0.9.6

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/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.6] - 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - **`PRODUCT.md` and `DESIGN.md`** - the product context and the visual-system
13
+ record `impeccable` asks for, both written from sources in the repository
14
+ (`README.md`, `docs/CONSTITUTION.md`, `src/render.ts`) with anything they do
15
+ not settle marked "not determined" instead of guessed. `novahiz doctor` runs
16
+ from a project now finds both instead of two `fail` rows; when doctor runs
17
+ from the Novahiz home both rows are skipped by design
18
+ (`impeccableChecks` returns early when `cwd === home`).
19
+
20
+ - **`when.pathExcludes`** - a rule-level *negative* selector, the counterpart of
21
+ `pathGlobs`. A rule carrying it never applies to a matching path, whatever the
22
+ other selectors say. With `match: "any"` a prompt category alone fired on any
23
+ file, so `design-ui` demanded the frontend design skills on unit tests, and
24
+ inclusion-only selectors could not express "never on tests" (`**/*.tsx` also
25
+ matches `tests/Foo.test.tsx`). Applied to the five design-scoped rules
26
+ (R11-accessibility, R13-design-craft, R14-impeccable, R16-stitch-fidelity,
27
+ R17-wireframe), which now skip `**/tests/**`, `**/__tests__/**`,
28
+ `**/*.test.*` and `**/*.spec.*`. R9-code-review and R10-security are
29
+ untouched — a test is still code — and R15-token-economy keeps its own
30
+ `**/tests/token-economy.test.ts` target.
31
+
32
+ ### Changed
33
+
34
+ - **The installer states its own limit** - `install.mjs` now prints that
35
+ `deps --install` is an install-only pass: packs already present are left
36
+ untouched, and `/novahiz-skills-update` (`deps --install --update`) is what
37
+ refreshes them. Installation and update stay separate commands by choice — a
38
+ reinstall must not overwrite local skill edits — but the choice was previously
39
+ only written down in `docs/PROVIDERS.md`, not shown to the person installing.
40
+
41
+ ### Fixed
42
+
43
+ - **`doctor` no longer advises a command that cannot help** - the "Referenced
44
+ skills" row counted every absent non-shipped skill as a pack skill and
45
+ answered "run /novahiz-skills-update, or novahiz deps --install". Measured on
46
+ the 0.9.5 install: the 7 skills it named are `eas-app-stores`, `eas-hosting`,
47
+ `eas-observe`, `eas-simulator`, `eas-update`, `eas-update-insights`,
48
+ `eas-workflows`, referenced as `defaultSkills` by the `test` and `devops`
49
+ categories, while `catalog/providers.json` ships four skill packs only
50
+ (`flutter-skills`, `dart-skills`, `expo-skills`, `impeccable`) and the expo
51
+ command names 20 `expo-*` skills, no `eas-*`. `deps --install` accordingly
52
+ returned `install: []` and exited 0 - nothing to install. The row now reads
53
+ each pack's `install` argv (`--skill` list, or `--skill *` resolved to the
54
+ repo's leading segment, so `dart-lang/skills` covers `dart-*`), and skills no
55
+ pack can deliver are named as such instead of being sent after a command that
56
+ installs nothing. New `tests/doctor-referenced-skills.test.ts` pins the
57
+ contract - eight cases, none existed before.
58
+
8
59
  ## [0.9.5] - 2026-10-15
9
60
 
10
61
  > 0.9.4 was prepared on 2026-10-14 but never tagged nor published to npm
@@ -94,7 +94,8 @@
94
94
  "description": "Load novahiz-wcag-audit on design-ui work and on audit prompts, where accessibility is in scope.",
95
95
  "when": {
96
96
  "match": "any",
97
- "promptCategories": ["design-ui", "audit"]
97
+ "promptCategories": ["design-ui", "audit"],
98
+ "pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
98
99
  },
99
100
  "require": ["novahiz-wcag-audit"]
100
101
  },
@@ -121,7 +122,8 @@
121
122
  "**/*.html",
122
123
  "**/*.htm"
123
124
  ],
124
- "minChange": 200
125
+ "minChange": 200,
126
+ "pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
125
127
  },
126
128
  "require": ["novahiz-humanizer", "ui-slop-remover", "ui-craft-rules"]
127
129
  },
@@ -139,7 +141,8 @@
139
141
  "**/*.html",
140
142
  "**/*.htm"
141
143
  ],
142
- "minChange": 200
144
+ "minChange": 200,
145
+ "pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
143
146
  },
144
147
  "require": ["impeccable"]
145
148
  },
@@ -172,7 +175,8 @@
172
175
  "**/*.scss",
173
176
  "**/*.html"
174
177
  ],
175
- "projectGlobs": ["stitch/**"]
178
+ "projectGlobs": ["stitch/**"],
179
+ "pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
176
180
  },
177
181
  "require": ["novahiz-stitch-fidelity"]
178
182
  },
@@ -192,7 +196,8 @@
192
196
  "**/components/**"
193
197
  ],
194
198
  "tiers": ["lite", "full"],
195
- "minChange": 800
199
+ "minChange": 800,
200
+ "pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
196
201
  },
197
202
  "require": ["novahiz-wireframe"]
198
203
  },
@@ -460,19 +460,72 @@ export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
460
460
  }
461
461
  }
462
462
  const absentShipped = absent.filter((id) => shipped.has(id));
463
- const absentPack = absent.length - absentShipped.length;
463
+ const absentPack = absent.filter((id) => !shipped.has(id));
464
+ // Only promise the update command when a pack can actually deliver what is
465
+ // missing: see packDelivery below.
466
+ const { exact, prefixes } = packDelivery(spec);
467
+ const deliverable = (id) => exact.has(id) || prefixes.some((p) => id.startsWith(p));
468
+ const undeliverable = absentPack.filter((id) => !deliverable(id));
469
+ const present = referenced.length - absent.length;
470
+ let detail;
471
+ if (absent.length === 0)
472
+ detail = `${present} present`;
473
+ else if (absentShipped.length > 0)
474
+ detail = `missing from index: ${absent.join(", ")}`;
475
+ else {
476
+ detail = `${present} present, ${absentPack.length} pack skills not installed`;
477
+ // A skill the catalog references but no pack ships can never be installed —
478
+ // say so instead of sending the reader after a command that returns nothing.
479
+ detail +=
480
+ undeliverable.length > 0
481
+ ? `; ${undeliverable.join(", ")} referenced by no pack (deps cannot install them)`
482
+ : " (run /novahiz-skills-update, or novahiz deps --install)";
483
+ }
464
484
  return {
465
485
  id: "referenced",
466
486
  label: "Referenced skills",
467
487
  ok: absentShipped.length === 0,
468
- detail: absent.length === 0
469
- ? `${referenced.length} present`
470
- : absentShipped.length > 0
471
- ? `missing from index: ${absent.join(", ")}`
472
- : `${referenced.length - absent.length} present, ${absentPack} pack skills not installed (run /novahiz-skills-update, or novahiz deps --install)`,
488
+ detail,
473
489
  blocking: absentShipped.length > 0
474
490
  };
475
491
  }
492
+ /** Skills a provider pack could really install, read off each pack's `install`
493
+ * argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
494
+ * whole repo, whose ids start with the repo's leading segment with any hyphen
495
+ * suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
496
+ * `dart-`). Measured 2026-10-15: the 7 skills doctor reported as "pack skills
497
+ * not installed" are `eas-*`, referenced as `defaultSkills` by the `test` and
498
+ * `devops` categories while no provider ships them, so
499
+ * "run /novahiz-skills-update, or novahiz deps --install" could not have
500
+ * installed a single one. Advice no command can honour is a defect. */
501
+ function packDelivery(spec) {
502
+ const exact = new Set();
503
+ const prefixes = [];
504
+ const disabled = new Set(spec.config.providers.disabled);
505
+ for (const provider of spec.providers) {
506
+ if (disabled.has(provider.id))
507
+ continue;
508
+ const argv = provider.install;
509
+ if (!Array.isArray(argv) || argv.length === 0)
510
+ continue;
511
+ const at = argv.indexOf("--skill");
512
+ if (at === -1)
513
+ continue;
514
+ const args = argv.slice(at + 1).filter((arg) => !arg.startsWith("-"));
515
+ if (args.length === 0)
516
+ continue;
517
+ if (args.includes("*")) {
518
+ const repo = String(argv.find((arg) => arg.includes("/")) ?? "");
519
+ const head = repo.split("/")[0].split("-")[0];
520
+ if (head.length > 0)
521
+ prefixes.push(`${head}-`);
522
+ }
523
+ for (const id of args)
524
+ if (id !== "*")
525
+ exact.add(id);
526
+ }
527
+ return { exact, prefixes };
528
+ }
476
529
  /** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
477
530
  * (root -> .agents/context -> docs/). Rows appear only when the impeccable
478
531
  * skill is installed (opt-in) and are never blocking: a missing record is a
package/dist/gate.js CHANGED
@@ -301,6 +301,15 @@ function selectorMatches(rule, classification, path, categories, pathless = fals
301
301
  checks.push(when.promptCategories.some((category) => categories.includes(category)));
302
302
  }
303
303
  else {
304
+ // E1: pathExcludes is the negative counterpart of pathGlobs. A rule that
305
+ // carries it never applies to an excluded path, whatever the other
306
+ // selectors say — the only way to express "never on tests" when match is
307
+ // "any", where a prompt category alone would otherwise fire on any file.
308
+ if (when.pathExcludes &&
309
+ when.pathExcludes.length > 0 &&
310
+ when.pathExcludes.some((glob) => globToRegExp(glob).test(path))) {
311
+ return false;
312
+ }
304
313
  if (when.fileClasses && when.fileClasses.length > 0)
305
314
  checks.push(when.fileClasses.includes(classification));
306
315
  if (when.pathGlobs && when.pathGlobs.length > 0)
@@ -459,6 +459,14 @@ async function main() {
459
459
  });
460
460
  if (result.stdout) ui.raw(result.stdout);
461
461
  if (result.status !== 0 && result.stderr) process.stderr.write(result.stderr);
462
+ // Arbitrage assumé: installation et mise à jour restent deux commandes.
463
+ // `deps --install` est idempotent — un pack déjà présent n'est jamais
464
+ // rafraîchi ici, sinon une réinstallation écraserait les retouches
465
+ // locales. C'est documenté dans docs/PROVIDERS.md; on le dit aussi à
466
+ // l'écran plutôt que de laisser le paquet sembler à jour sans l'être.
467
+ if (autoInstall && result.status === 0) {
468
+ note("Installs only: skill packs already present are left untouched. Refresh them with /novahiz-skills-update (deps --install --update).");
469
+ }
462
470
  }
463
471
  }
464
472
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -459,21 +459,65 @@ export function referencedSkillsCheck(
459
459
  }
460
460
  }
461
461
  const absentShipped = absent.filter((id) => shipped.has(id));
462
- const absentPack = absent.length - absentShipped.length;
462
+ const absentPack = absent.filter((id) => !shipped.has(id));
463
+ // Only promise the update command when a pack can actually deliver what is
464
+ // missing: see packDelivery below.
465
+ const { exact, prefixes } = packDelivery(spec);
466
+ const deliverable = (id: string): boolean => exact.has(id) || prefixes.some((p) => id.startsWith(p));
467
+ const undeliverable = absentPack.filter((id) => !deliverable(id));
468
+ const present = referenced.length - absent.length;
469
+ let detail: string;
470
+ if (absent.length === 0) detail = `${present} present`;
471
+ else if (absentShipped.length > 0) detail = `missing from index: ${absent.join(", ")}`;
472
+ else {
473
+ detail = `${present} present, ${absentPack.length} pack skills not installed`;
474
+ // A skill the catalog references but no pack ships can never be installed —
475
+ // say so instead of sending the reader after a command that returns nothing.
476
+ detail +=
477
+ undeliverable.length > 0
478
+ ? `; ${undeliverable.join(", ")} referenced by no pack (deps cannot install them)`
479
+ : " (run /novahiz-skills-update, or novahiz deps --install)";
480
+ }
463
481
  return {
464
482
  id: "referenced",
465
483
  label: "Referenced skills",
466
484
  ok: absentShipped.length === 0,
467
- detail:
468
- absent.length === 0
469
- ? `${referenced.length} present`
470
- : absentShipped.length > 0
471
- ? `missing from index: ${absent.join(", ")}`
472
- : `${referenced.length - absent.length} present, ${absentPack} pack skills not installed (run /novahiz-skills-update, or novahiz deps --install)`,
485
+ detail,
473
486
  blocking: absentShipped.length > 0
474
487
  };
475
488
  }
476
489
 
490
+ /** Skills a provider pack could really install, read off each pack's `install`
491
+ * argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
492
+ * whole repo, whose ids start with the repo's leading segment with any hyphen
493
+ * suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
494
+ * `dart-`). Measured 2026-10-15: the 7 skills doctor reported as "pack skills
495
+ * not installed" are `eas-*`, referenced as `defaultSkills` by the `test` and
496
+ * `devops` categories while no provider ships them, so
497
+ * "run /novahiz-skills-update, or novahiz deps --install" could not have
498
+ * installed a single one. Advice no command can honour is a defect. */
499
+ function packDelivery(spec: ReturnType<typeof loadSpec>): { exact: Set<string>; prefixes: string[] } {
500
+ const exact = new Set<string>();
501
+ const prefixes: string[] = [];
502
+ const disabled = new Set(spec.config.providers.disabled);
503
+ for (const provider of spec.providers) {
504
+ if (disabled.has(provider.id)) continue;
505
+ const argv = provider.install;
506
+ if (!Array.isArray(argv) || argv.length === 0) continue;
507
+ const at = argv.indexOf("--skill");
508
+ if (at === -1) continue;
509
+ const args = argv.slice(at + 1).filter((arg) => !arg.startsWith("-"));
510
+ if (args.length === 0) continue;
511
+ if (args.includes("*")) {
512
+ const repo = String(argv.find((arg) => arg.includes("/")) ?? "");
513
+ const head = repo.split("/")[0].split("-")[0];
514
+ if (head.length > 0) prefixes.push(`${head}-`);
515
+ }
516
+ for (const id of args) if (id !== "*") exact.add(id);
517
+ }
518
+ return { exact, prefixes };
519
+ }
520
+
477
521
  /** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
478
522
  * (root -> .agents/context -> docs/). Rows appear only when the impeccable
479
523
  * skill is installed (opt-in) and are never blocking: a missing record is a
package/src/gate.ts CHANGED
@@ -291,6 +291,17 @@ function selectorMatches(rule: Rule, classification: FileClass, path: string, ca
291
291
  if (!when.promptCategories || when.promptCategories.length === 0) return false;
292
292
  checks.push(when.promptCategories.some((category) => categories.includes(category)));
293
293
  } else {
294
+ // E1: pathExcludes is the negative counterpart of pathGlobs. A rule that
295
+ // carries it never applies to an excluded path, whatever the other
296
+ // selectors say — the only way to express "never on tests" when match is
297
+ // "any", where a prompt category alone would otherwise fire on any file.
298
+ if (
299
+ when.pathExcludes &&
300
+ when.pathExcludes.length > 0 &&
301
+ when.pathExcludes.some((glob) => globToRegExp(glob).test(path))
302
+ ) {
303
+ return false;
304
+ }
294
305
  if (when.fileClasses && when.fileClasses.length > 0) checks.push(when.fileClasses.includes(classification));
295
306
  if (when.pathGlobs && when.pathGlobs.length > 0) checks.push(when.pathGlobs.some((glob) => globToRegExp(glob).test(path)));
296
307
  if (when.promptCategories && when.promptCategories.length > 0) {
package/src/spec.ts CHANGED
@@ -35,6 +35,12 @@ type RuleWhen = {
35
35
  match?: "any" | "all";
36
36
  fileClasses?: string[];
37
37
  pathGlobs?: string[];
38
+ /** Negative counterpart of pathGlobs: the rule never applies to a path
39
+ * matching one of these globs, whatever the other selectors say. This is the
40
+ * only way to express "never on tests" when match is "any", where a prompt
41
+ * category on its own would otherwise fire on any file, unit tests included.
42
+ * Omitted = no exclusion, exactly as before. */
43
+ pathExcludes?: string[];
38
44
  /** Project-scoped selector: the rule applies only when at least one file under
39
45
  * the project root (nearest ancestor of the edited file holding .git,
40
46
  * package.json or pubspec.yaml) matches one of these globs, written from that