novahiz 0.9.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -3,6 +3,49 @@
3
3
  All notable changes to Novahiz are documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.9.2] - 2026-10-11
7
+
8
+ ### Fixed
9
+
10
+ - **Memory root lock: no more silent marker loss under contention** — a
11
+ transient `EPERM`/`EBUSY` when reading `.lock` (Windows filter driver
12
+ under I/O load) was indistinguishable from "absent", so a waiter could
13
+ delete the lock of a **live** holder: two critical sections, one
14
+ read-modify-write clobbered, one marker lost while both writers exited 0.
15
+ `readLock` is now tri-state (`ok` / `absent` / `unreadable`); a steal
16
+ requires a confirmed stale lock (dead pid or age) plus an immediate
17
+ re-read (closes the TOCTOU window); a torn file left by a crash is
18
+ detected via `mtime > LOCK_STALE_MS` and only then removed.
19
+ - **`acquireRootLock` no longer crashes on transient create errors** — the
20
+ same `EPERM` on the `wx` create (reproduced: 2 crashes in 40 rounds
21
+ under 6 CPU burners) now retries with the existing deadline and ends in
22
+ a loud `E_LOCK` instead of killing the caller.
23
+
24
+ ### Notes
25
+
26
+ - Proven under load: 60/60 stress rounds clean after the fix (2/40 rounds
27
+ crashed before). Two new deterministic tests (torn + fresh mtime →
28
+ `E_LOCK` with the foreign lock intact; torn + old mtime → stolen).
29
+ Suite 613 tests / 612 pass / 0 fail; CI green on Windows and Linux.
30
+
31
+ ## [0.9.1] - 2026-10-10
32
+
33
+ ### Fixed
34
+
35
+ - **Fresh-install scan stops reporting missing skill roots** — the default
36
+ `skillRoots` include `~/.agents/skills`, which most profiles do not have.
37
+ The first `sync` (run by the installer) used to surface it as a scan error
38
+ on every new machine; a declared root that does not exist yet now simply
39
+ contributes nothing (`scanErrors: []`).
40
+ - **README install banner aligned with the shipped version** (0.8.0 → 0.9.0).
41
+
42
+ ### Notes
43
+
44
+ - The npm installer path is verified end-to-end on npm 12.2: install with the
45
+ `postinstall` blocked by `allowScripts`, first `novahiz` run finishes the
46
+ setup itself (auto-install, `NOVAHIZ_AUTOINSTALL=0` to opt out), second run
47
+ clean. `novahiz doctor` exits 0 on the fresh sandbox.
48
+
6
49
  ## [0.9.0] - 2026-10-10
7
50
 
8
51
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  17 catégories, 94 skills, 13 gate rules, 8 MCP providers — tout déterministe, tout local, tout JSON.
6
6
 
7
- > **Version 0.8.0** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
7
+ > **Version 0.9.0** — publiée sur npm (`npm install -g --allow-scripts=novahiz novahiz` installe et configure tout : core, plugin, agent, skills packs et Playwright MCP). `CHANGELOG.md` et `scripts/` sont dans le tarball.
8
8
 
9
9
  ```
10
10
  ┌──────────────────────────────────────────────────────────────────────────┐
package/dist/catalog.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, isAbsolute, join } from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { expandHome } from "./spec.js";
@@ -56,6 +56,12 @@ export function parseFrontmatter(content) {
56
56
  return result;
57
57
  }
58
58
  function walkForSkillFiles(root, found, visited, errors) {
59
+ // A declared skill root that does not exist yet is normal on a fresh machine:
60
+ // the default skillRoots include ~/.agents/skills, which most profiles do not
61
+ // have. It contributes nothing and must not surface as a scan error in every
62
+ // first sync (the installer prints that JSON).
63
+ if (!existsSync(root))
64
+ return;
59
65
  let real;
60
66
  try {
61
67
  real = realpathSync(root);
package/dist/memory.js CHANGED
@@ -203,25 +203,57 @@ function isProcessAlive(pid) {
203
203
  return error.code === "EPERM";
204
204
  }
205
205
  }
206
- function readLockInfo(root) {
206
+ function readLock(root) {
207
+ let raw;
207
208
  try {
208
- const raw = readFileSync(lockPath(root), "utf8");
209
- const [pidRaw, atRaw] = raw.split("\n");
210
- const pid = Number(pidRaw);
211
- const at = Number(atRaw);
212
- if (!Number.isInteger(pid) || !Number.isFinite(at))
213
- return null;
214
- return { pid, at };
209
+ raw = readFileSync(lockPath(root), "utf8");
215
210
  }
216
- catch {
217
- // Absent ou illisible: traite comme un verrou perime.
218
- return null;
211
+ catch (error) {
212
+ // ENOENT = certitude: pas de verrou. Tout le reste (EPERM/EBUSY/EACCES:
213
+ // filtre antivirus sous tempete I/O sur Windows, reproduit en stress)
214
+ // est incertain — une lecture qui echoue ne prouve pas que le detenteur
215
+ // est mort, et traiter l'incertain comme un verrou perime volait le
216
+ // verrou d'un processus vivant: deux sections critiques, une ecriture
217
+ // ecrasee, un marqueur perdu sans erreur.
218
+ const code = error.code;
219
+ return code === "ENOENT" ? { state: "absent" } : { state: "unreadable" };
220
+ }
221
+ const [pidRaw, atRaw] = raw.split("\n");
222
+ const pid = Number(pidRaw);
223
+ const at = Number(atRaw);
224
+ if (!Number.isInteger(pid) || !Number.isFinite(at))
225
+ return { state: "unreadable" };
226
+ return { state: "ok", info: { pid, at } };
227
+ }
228
+ function tryStealLock(root) {
229
+ const read = readLock(root);
230
+ if (read.state === "absent")
231
+ return true;
232
+ if (read.state === "unreadable") {
233
+ // Lecture incertaine ou contenu dechire: jamais de vol sur une lecture
234
+ // qui a echoue. Un fichier dechire abandonne par un crash (write non
235
+ // atomique) ne se detecte que par son age: mtime au-dela de
236
+ // LOCK_STALE_MS = detenteur disparu, on peut le retirer.
237
+ try {
238
+ if (Date.now() - statSync(lockPath(root)).mtimeMs <= LOCK_STALE_MS)
239
+ return false;
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ }
245
+ else {
246
+ const { info } = read;
247
+ if (isProcessAlive(info.pid) && Date.now() - info.at <= LOCK_STALE_MS)
248
+ return false;
249
+ // Relecture avant unlink: si le verrou a change de main entre la
250
+ // decision de vol et la suppression (fenetre TOCTOU), on recule.
251
+ const again = readLock(root);
252
+ if (again.state === "unreadable")
253
+ return false;
254
+ if (again.state === "ok" && (again.info.pid !== info.pid || again.info.at !== info.at))
255
+ return false;
219
256
  }
220
- }
221
- function tryStealLock(root, info) {
222
- const stale = info === null || !isProcessAlive(info.pid) || Date.now() - info.at > LOCK_STALE_MS;
223
- if (!stale)
224
- return false;
225
257
  try {
226
258
  unlinkSync(lockPath(root));
227
259
  }
@@ -252,20 +284,25 @@ export function acquireRootLock(root, waitMs = LOCK_WAIT_DEFAULT_MS) {
252
284
  return () => releaseRootLock(root);
253
285
  }
254
286
  catch (error) {
255
- if (error.code !== "EEXIST")
287
+ const code = error.code;
288
+ // EEXIST = verrou pris; EPERM/EBUSY/EACCES = transitoire (filtre
289
+ // antivirus sous charge, EPERM reproduit en stress sur .lock): on
290
+ // reessaie dans la boucle au lieu de planter l'appelant. Le delai
291
+ // couvre un blocage durable (alors E_LOCK, bruyant, non corrupteur).
292
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES")
256
293
  throw error;
257
294
  }
258
- const info = readLockInfo(root);
259
- if (info !== null && info.pid === process.pid && Date.now() - info.at <= LOCK_STALE_MS) {
295
+ const read = readLock(root);
296
+ if (read.state === "ok" && read.info.pid === process.pid && Date.now() - read.info.at <= LOCK_STALE_MS) {
260
297
  // Notre propre verrou (section interrompue par un crash precedent au
261
298
  // meme pid): on le reprend en reentrant.
262
299
  lockDepth.set(root, 1);
263
300
  return () => releaseRootLock(root);
264
301
  }
265
- if (tryStealLock(root, info))
302
+ if (tryStealLock(root))
266
303
  continue;
267
304
  if (Date.now() >= deadline) {
268
- const holder = info === null ? "illisible" : String(info.pid);
305
+ const holder = read.state === "ok" ? String(read.info.pid) : read.state;
269
306
  throw memError("E_LOCK", `verrou memoire occupe (pid ${holder}) sous ${root}`);
270
307
  }
271
308
  sleepSync(LOCK_RETRY_MS);
@@ -279,11 +316,12 @@ function releaseRootLock(root) {
279
316
  }
280
317
  lockDepth.delete(root);
281
318
  try {
282
- const info = readLockInfo(root);
283
- if (info === null || info.pid === process.pid)
319
+ const read = readLock(root);
320
+ // Lecture incertaine ou absente: on est le detenteur, on libere quand
321
+ // meme. Un verrou lisible d'un autre pid n'est pas le notre (course
322
+ // apres peremption): on ne touche pas au fichier d'un tiers.
323
+ if (read.state !== "ok" || read.info.pid === process.pid)
284
324
  unlinkSync(lockPath(root));
285
- // Verrou repris par un autre (course apres peremption): on ne touche pas
286
- // au fichier d'un tiers.
287
325
  }
288
326
  catch {
289
327
  // Deja libere (ENOENT) ou lecture echouee: rien a faire.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",
package/src/catalog.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, isAbsolute, join } from "node:path";
3
3
  import { DatabaseSync } from "node:sqlite";
4
4
  import { expandHome, type Spec } from "./spec.ts";
@@ -68,6 +68,11 @@ export function parseFrontmatter(content: string): Record<string, string> {
68
68
  }
69
69
 
70
70
  function walkForSkillFiles(root: string, found: string[], visited: Set<string>, errors: string[]): void {
71
+ // A declared skill root that does not exist yet is normal on a fresh machine:
72
+ // the default skillRoots include ~/.agents/skills, which most profiles do not
73
+ // have. It contributes nothing and must not surface as a scan error in every
74
+ // first sync (the installer prints that JSON).
75
+ if (!existsSync(root)) return;
71
76
  let real: string;
72
77
  try {
73
78
  real = realpathSync(root);
package/src/memory.ts CHANGED
@@ -295,24 +295,52 @@ function isProcessAlive(pid: number): boolean {
295
295
  }
296
296
  }
297
297
 
298
- function readLockInfo(root: string): { pid: number; at: number } | null {
298
+ type LockInfo = { pid: number; at: number };
299
+ type LockRead = { state: "ok"; info: LockInfo } | { state: "absent" } | { state: "unreadable" };
300
+
301
+ function readLock(root: string): LockRead {
302
+ let raw: string;
299
303
  try {
300
- const raw = readFileSync(lockPath(root), "utf8");
301
- const [pidRaw, atRaw] = raw.split("\n");
302
- const pid = Number(pidRaw);
303
- const at = Number(atRaw);
304
- if (!Number.isInteger(pid) || !Number.isFinite(at)) return null;
305
- return { pid, at };
306
- } catch {
307
- // Absent ou illisible: traite comme un verrou perime.
308
- return null;
304
+ raw = readFileSync(lockPath(root), "utf8");
305
+ } catch (error) {
306
+ // ENOENT = certitude: pas de verrou. Tout le reste (EPERM/EBUSY/EACCES:
307
+ // filtre antivirus sous tempete I/O sur Windows, reproduit en stress)
308
+ // est incertain — une lecture qui echoue ne prouve pas que le detenteur
309
+ // est mort, et traiter l'incertain comme un verrou perime volait le
310
+ // verrou d'un processus vivant: deux sections critiques, une ecriture
311
+ // ecrasee, un marqueur perdu sans erreur.
312
+ const code = (error as NodeJS.ErrnoException).code;
313
+ return code === "ENOENT" ? { state: "absent" } : { state: "unreadable" };
314
+ }
315
+ const [pidRaw, atRaw] = raw.split("\n");
316
+ const pid = Number(pidRaw);
317
+ const at = Number(atRaw);
318
+ if (!Number.isInteger(pid) || !Number.isFinite(at)) return { state: "unreadable" };
319
+ return { state: "ok", info: { pid, at } };
320
+ }
321
+
322
+ function tryStealLock(root: string): boolean {
323
+ const read = readLock(root);
324
+ if (read.state === "absent") return true;
325
+ if (read.state === "unreadable") {
326
+ // Lecture incertaine ou contenu dechire: jamais de vol sur une lecture
327
+ // qui a echoue. Un fichier dechire abandonne par un crash (write non
328
+ // atomique) ne se detecte que par son age: mtime au-dela de
329
+ // LOCK_STALE_MS = detenteur disparu, on peut le retirer.
330
+ try {
331
+ if (Date.now() - statSync(lockPath(root)).mtimeMs <= LOCK_STALE_MS) return false;
332
+ } catch {
333
+ return false;
334
+ }
335
+ } else {
336
+ const { info } = read;
337
+ if (isProcessAlive(info.pid) && Date.now() - info.at <= LOCK_STALE_MS) return false;
338
+ // Relecture avant unlink: si le verrou a change de main entre la
339
+ // decision de vol et la suppression (fenetre TOCTOU), on recule.
340
+ const again = readLock(root);
341
+ if (again.state === "unreadable") return false;
342
+ if (again.state === "ok" && (again.info.pid !== info.pid || again.info.at !== info.at)) return false;
309
343
  }
310
- }
311
-
312
- function tryStealLock(root: string, info: { pid: number; at: number } | null): boolean {
313
- const stale =
314
- info === null || !isProcessAlive(info.pid) || Date.now() - info.at > LOCK_STALE_MS;
315
- if (!stale) return false;
316
344
  try {
317
345
  unlinkSync(lockPath(root));
318
346
  } catch (error) {
@@ -342,18 +370,23 @@ export function acquireRootLock(root: string, waitMs = LOCK_WAIT_DEFAULT_MS): ()
342
370
  lockDepth.set(root, 1);
343
371
  return () => releaseRootLock(root);
344
372
  } catch (error) {
345
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
373
+ const code = (error as NodeJS.ErrnoException).code;
374
+ // EEXIST = verrou pris; EPERM/EBUSY/EACCES = transitoire (filtre
375
+ // antivirus sous charge, EPERM reproduit en stress sur .lock): on
376
+ // reessaie dans la boucle au lieu de planter l'appelant. Le delai
377
+ // couvre un blocage durable (alors E_LOCK, bruyant, non corrupteur).
378
+ if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw error;
346
379
  }
347
- const info = readLockInfo(root);
348
- if (info !== null && info.pid === process.pid && Date.now() - info.at <= LOCK_STALE_MS) {
380
+ const read = readLock(root);
381
+ if (read.state === "ok" && read.info.pid === process.pid && Date.now() - read.info.at <= LOCK_STALE_MS) {
349
382
  // Notre propre verrou (section interrompue par un crash precedent au
350
383
  // meme pid): on le reprend en reentrant.
351
384
  lockDepth.set(root, 1);
352
385
  return () => releaseRootLock(root);
353
386
  }
354
- if (tryStealLock(root, info)) continue;
387
+ if (tryStealLock(root)) continue;
355
388
  if (Date.now() >= deadline) {
356
- const holder = info === null ? "illisible" : String(info.pid);
389
+ const holder = read.state === "ok" ? String(read.info.pid) : read.state;
357
390
  throw memError("E_LOCK", `verrou memoire occupe (pid ${holder}) sous ${root}`);
358
391
  }
359
392
  sleepSync(LOCK_RETRY_MS);
@@ -368,10 +401,11 @@ function releaseRootLock(root: string): void {
368
401
  }
369
402
  lockDepth.delete(root);
370
403
  try {
371
- const info = readLockInfo(root);
372
- if (info === null || info.pid === process.pid) unlinkSync(lockPath(root));
373
- // Verrou repris par un autre (course apres peremption): on ne touche pas
374
- // au fichier d'un tiers.
404
+ const read = readLock(root);
405
+ // Lecture incertaine ou absente: on est le detenteur, on libere quand
406
+ // meme. Un verrou lisible d'un autre pid n'est pas le notre (course
407
+ // apres peremption): on ne touche pas au fichier d'un tiers.
408
+ if (read.state !== "ok" || read.info.pid === process.pid) unlinkSync(lockPath(root));
375
409
  } catch {
376
410
  // Deja libere (ENOENT) ou lecture echouee: rien a faire.
377
411
  }