novahiz 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,61 @@
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.3] - 2026-10-11
7
+
8
+ ### Changed
9
+
10
+ - **CI verifies the suite and the installer dry-run on Windows and macOS**
11
+ — the matrix keeps the Ubuntu node grid (22 / 22.18.0 / 24) and adds one
12
+ node-24 job each on `windows-latest` and `macos-latest` (5 jobs).
13
+ Previously only Ubuntu ran in CI; Windows rested on local runs and macOS
14
+ on nothing at all.
15
+
16
+ ### Fixed
17
+
18
+ - **`install.sh` is executable in the package** — mode moved from 100644
19
+ to 100755, so `./install/install.sh` no longer fails with permission
20
+ denied on Linux/macOS.
21
+ - **macOS memory CLI test compares canonical paths** — `$TMPDIR` on macOS
22
+ is a symlink (`/var` → `/private/var`); the first-ever macOS CI run
23
+ caught `tests/memory-cli.test.ts` asserting the CLI's resolved root
24
+ against a raw string. Both sides now go through `realpathSync`.
25
+
26
+ ### Notes
27
+
28
+ - Cross-platform claims re-checked against primary sources: GitHub docs
29
+ (matrix `include` adds non-mergeable combinations → 5 jobs, confirmed by
30
+ the runs), npm docs (`--allow-scripts=novahiz` is the documented way to
31
+ approve a global install; a package cannot self-authorize), Playwright
32
+ docs (WebKit runs on all three OSes; Linux needs its system libraries).
33
+ The README now carries the Linux remedy: `npx playwright install-deps
34
+ webkit`.
35
+
36
+ ## [0.9.2] - 2026-10-11
37
+
38
+ ### Fixed
39
+
40
+ - **Memory root lock: no more silent marker loss under contention** — a
41
+ transient `EPERM`/`EBUSY` when reading `.lock` (Windows filter driver
42
+ under I/O load) was indistinguishable from "absent", so a waiter could
43
+ delete the lock of a **live** holder: two critical sections, one
44
+ read-modify-write clobbered, one marker lost while both writers exited 0.
45
+ `readLock` is now tri-state (`ok` / `absent` / `unreadable`); a steal
46
+ requires a confirmed stale lock (dead pid or age) plus an immediate
47
+ re-read (closes the TOCTOU window); a torn file left by a crash is
48
+ detected via `mtime > LOCK_STALE_MS` and only then removed.
49
+ - **`acquireRootLock` no longer crashes on transient create errors** — the
50
+ same `EPERM` on the `wx` create (reproduced: 2 crashes in 40 rounds
51
+ under 6 CPU burners) now retries with the existing deadline and ends in
52
+ a loud `E_LOCK` instead of killing the caller.
53
+
54
+ ### Notes
55
+
56
+ - Proven under load: 60/60 stress rounds clean after the fix (2/40 rounds
57
+ crashed before). Two new deterministic tests (torn + fresh mtime →
58
+ `E_LOCK` with the foreign lock intact; torn + old mtime → stolen).
59
+ Suite 613 tests / 612 pass / 0 fail; CI green on Windows and Linux.
60
+
6
61
  ## [0.9.1] - 2026-10-10
7
62
 
8
63
  ### Fixed
package/README.md CHANGED
@@ -63,6 +63,8 @@ npm antérieur à 11.16 exécute les scripts par défaut : `npm install -g novah
63
63
 
64
64
  > Piège : `npm install -g --allow-scripts=novahiz` **sans** le nom du paquet installe le dossier courant et échoue avec `ENOENT: package.json`. Le nom du paquet est obligatoire : `npm install -g --allow-scripts=novahiz novahiz`.
65
65
 
66
+ > Linux : le serveur Playwright MCP s'exécute sous WebKit (règle maison : Chromium interdit). WebKit exige ses dépendances système : si le navigateur ne démarre pas, `npx playwright install-deps webkit` (sudo requis sur Debian/Ubuntu).
67
+
66
68
  Puis vérifiez :
67
69
 
68
70
  ```bash
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.
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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/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
  }