hypomnema 1.7.0 → 1.7.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/scripts/lint.mjs CHANGED
@@ -112,10 +112,19 @@ function parseTagsField(rawValue) {
112
112
  }
113
113
 
114
114
  // type-conditional required fields (spec §6.3, SCHEMA.md §2)
115
+ // `project-index` deliberately does NOT list `working_dir` here. An index
116
+ // existing at all is required (W12 above); the cwd anchor inside it is not —
117
+ // crystallize.mjs's auto-created index has no session cwd to fill it with, and
118
+ // requiring it would force a fake, non-empty placeholder into the field. A
119
+ // placeholder value is truthy, and hooks/hypo-shared.mjs's collector +
120
+ // findBackfillCandidate both read "has a truthy working_dir" as "already
121
+ // anchored, do not offer to backfill" — so a fake value permanently poisons
122
+ // the exact recovery path it should trigger. `status`/`started` stay required:
123
+ // they always have a real value to substitute (`active` / the close date).
115
124
  const TYPE_CONDITIONAL_FIELDS = {
116
125
  prd: ['status', 'started'],
117
126
  adr: ['source', 'status', 'date'],
118
- 'project-index': ['working_dir', 'status', 'started'],
127
+ 'project-index': ['status', 'started'],
119
128
  'tool-eval': ['status'],
120
129
  postmortem: ['outcome'],
121
130
  learning: ['source'],
@@ -397,6 +406,26 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
397
406
  }
398
407
  }
399
408
 
409
+ // W13: project-index with no working_dir anchor. Deliberately NOT in
410
+ // TYPE_CONDITIONAL_FIELDS (see the comment there) — an auto-created index has
411
+ // no cwd to put here, and that close must not block on its own output, so
412
+ // this stays warn-only and out of STRICT_PROMOTE_IDS, exactly like W12. The
413
+ // gap this closes: cwd-first backfill (hooks/hypo-shared.mjs's
414
+ // findBackfillCandidate) only fires when the cwd's leaf directory name
415
+ // matches the project's slug — a project whose real working dir has a
416
+ // DIFFERENT basename than its slug never becomes a backfill candidate, so an
417
+ // emptied/never-filled anchor can otherwise sit invisible except in
418
+ // `doctor`'s manual report.
419
+ if (fm.type === 'project-index' && !fm.working_dir) {
420
+ issue(
421
+ 'warn',
422
+ rel,
423
+ `project-index has no working_dir anchor (cwd-first resume can't match this project)`,
424
+ null,
425
+ 'W13',
426
+ );
427
+ }
428
+
400
429
  // type-conditional forbidden fields (W11): a field valid only on a
401
430
  // DIFFERENT type planted here by an unvalidated writer. Object.hasOwn
402
431
  // (not `fm[field]`) so a present-but-empty `working_dir:` still flags —
@@ -489,7 +518,16 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
489
518
  const args = parseArgs(process.argv);
490
519
  // Only validate the auto-resolved path (env/marker/default). An explicit
491
520
  // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
492
- if (args.hypoDirSource) checkVaultOrExit(args.hypoDir, args.hypoDirSource);
521
+ // `vaultMissing` stays on the CI-safe exit-0 path (source 'default'/stale-
522
+ // 'marker' — see checkVaultOrExit's doc comment and its pinned test coverage):
523
+ // this repo's own `npm run lint` runs with no vault at all and must keep
524
+ // passing. What was actually missing is that a vault-less run still printed
525
+ // "no lint issues found" — indistinguishable, on stdout or in --json, from a
526
+ // real empty vault that was actually scanned. Suppress that false-positive-
527
+ // looking output below instead of changing the exit code.
528
+ const vaultMissing = args.hypoDirSource
529
+ ? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
530
+ : false;
493
531
 
494
532
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
495
533
  const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
@@ -531,6 +569,40 @@ for (const s of findDesignHistoryStale(args.hypoDir)) {
531
569
  );
532
570
  }
533
571
 
572
+ // W12: project directory missing index.md. SCHEMA.md declares project-index
573
+ // at projects/*/index.md and templates/projects/_template/ ships one, so a
574
+ // project without it is a tooling/vault drift, not a legitimate shape — but
575
+ // warn-only: promoting this to an error would hard-block a close for every
576
+ // pre-existing project that predates the create-on-close path (crystallize.mjs
577
+ // A-1), and a missing index is never this close's fault to fix. `_`-prefixed
578
+ // directories are excluded (not just `_template`), matching the markdown
579
+ // collector's own convention (scripts/lib/wikilink.mjs's skipUnderscoreDir —
580
+ // `_scratch`, `_drafts`, etc. are scaffold, not a project).
581
+ if (existsSync(join(args.hypoDir, 'projects'))) {
582
+ for (const slug of readdirSync(join(args.hypoDir, 'projects'))) {
583
+ if (slug.startsWith('_')) continue;
584
+ const projectDir = join(args.hypoDir, 'projects', slug);
585
+ // A dangling symlink (or any other stat failure) must not crash the whole
586
+ // lint run — skip it exactly as doctor.mjs's own project-anchor scan does.
587
+ let st;
588
+ try {
589
+ st = statSync(projectDir);
590
+ } catch {
591
+ continue;
592
+ }
593
+ if (!st.isDirectory()) continue;
594
+ if (!existsSync(join(projectDir, 'index.md'))) {
595
+ issue(
596
+ 'warn',
597
+ `projects/${slug}/index.md`,
598
+ `Missing project index: projects/${slug}/ has no index.md (SCHEMA.md declares project-index at projects/*/index.md)`,
599
+ null,
600
+ 'W12',
601
+ );
602
+ }
603
+ }
604
+ }
605
+
534
606
  if (args.fix) {
535
607
  const today = new Date().toISOString().slice(0, 10);
536
608
  const fixed = new Set();
@@ -597,6 +669,11 @@ if (args.json) {
597
669
  JSON.stringify(
598
670
  {
599
671
  ok: errors.length === 0,
672
+ // A machine consumer piping `--json` (the exact CI-scripting case that
673
+ // motivated this field) can tell "scanned and clean" apart from
674
+ // "nothing to scan" without needing stderr. `ok` keeps its existing
675
+ // errors-only meaning so nothing that already reads it breaks.
676
+ vaultFound: !vaultMissing,
600
677
  errors: errors.map(toOut),
601
678
  warns: warns.map(toOut),
602
679
  total: issues.length,
@@ -605,6 +682,11 @@ if (args.json) {
605
682
  2,
606
683
  ),
607
684
  );
685
+ } else if (vaultMissing) {
686
+ // checkVaultOrExit already put the "No Hypomnema vault found" notice on
687
+ // stderr. Printing "✓ No lint issues found" here as well would claim a scan
688
+ // that never happened — the exact false-green shape a piped/redirected
689
+ // caller cannot tell apart from a real, clean vault.
608
690
  } else {
609
691
  if (issues.length === 0) {
610
692
  console.log('✓ No lint issues found');
@@ -39,18 +39,20 @@ import {
39
39
  existsSync,
40
40
  readFileSync,
41
41
  writeFileSync,
42
- rmSync,
43
42
  mkdirSync,
44
43
  readdirSync,
45
44
  renameSync,
46
45
  statSync,
47
46
  lstatSync,
48
47
  realpathSync,
48
+ chmodSync,
49
+ rmSync,
49
50
  } from 'fs';
50
51
  import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
51
52
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
52
53
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
53
54
  import { collectPagesRename, slugForms } from './lib/wikilink.mjs';
55
+ import { RENAME_MARKER_REL, renameMarkerPath, readRenameMarker } from './lib/rename-marker.mjs';
54
56
 
55
57
  // ── arg parsing ───────────────────────────────────────────────────────────────
56
58
 
@@ -227,6 +229,141 @@ function fail(args, msg) {
227
229
  process.exit(1);
228
230
  }
229
231
 
232
+ /** Atomic write via tmp+rename (same pattern as crystallize.mjs/proposal.mjs's
233
+ * atomicWrite): a crash or full disk mid-write leaves the ORIGINAL bytes at
234
+ * `path` untouched, never a truncated/partial file. Needed for every rewrite
235
+ * this script lands on a page that already has content on disk — including a
236
+ * page's own self-referential rewrite — because a plain writeFileSync
237
+ * truncates before writing and a crash in that gap destroys the one copy. */
238
+ function atomicWrite(path, content) {
239
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
240
+ // Swapping in a fresh inode also swaps in fresh permissions, so a 0444 or 0600
241
+ // page would come back 0644 and this rewrite would quietly widen it. Carry the
242
+ // existing mode over. Ownership, ACLs and hard-link identity are NOT carried;
243
+ // a vault page with any of those is outside what this script claims to handle.
244
+ let mode = null;
245
+ try {
246
+ mode = statSync(path).mode;
247
+ } catch {
248
+ mode = null; // new file, nothing to preserve
249
+ }
250
+ // `wx` so a collision is an error rather than a silent clobber of somebody
251
+ // else's temp, and drop the temp if anything after it fails (a full disk,
252
+ // a rename that cannot land) instead of leaving `.tmp` litter in the vault.
253
+ try {
254
+ writeFileSync(tmp, content, { flag: 'wx' });
255
+ if (mode !== null) chmodSync(tmp, mode);
256
+ renameSync(tmp, path);
257
+ } catch (err) {
258
+ try {
259
+ rmSync(tmp, { force: true });
260
+ } catch {
261
+ // best effort; the original at `path` is untouched either way
262
+ }
263
+ throw err;
264
+ }
265
+ }
266
+
267
+ // ── crash-recovery marker ────────────────────────────────────────────────────
268
+ // Written as the FIRST disk write of an --apply run (right after the last guard
269
+ // passes, before any inbound-link rewrite lands) and cleared as the LAST action
270
+ // (right after the terminal renameSync). If the process dies in between, this
271
+ // is the only trace left that the rename is incomplete: --from still resolves
272
+ // at that point (the move itself hasn't happened yet — see the module
273
+ // docstring's move-last invariant), so a re-run of the identical command
274
+ // converges on its own. Nothing else in the vault says "you need to re-run it".
275
+ //
276
+ // Fail-closed by construction: writeRenameMarker calls fail() (process.exit(1))
277
+ // on any write error, so --apply never proceeds without a marker on disk. A
278
+ // detector with no reliable marker is worse than no detector at all.
279
+ function writeRenameMarker(args, marker) {
280
+ const path = join(args.hypoDir, RENAME_MARKER_REL);
281
+ try {
282
+ mkdirSync(dirname(path), { recursive: true });
283
+ atomicWrite(path, JSON.stringify(marker, null, 2));
284
+ } catch (err) {
285
+ fail(
286
+ args,
287
+ `could not write the rename-in-progress marker (${err.message}) — refusing --apply without it: it is the only signal a crash mid-rewrite would leave behind.`,
288
+ );
289
+ }
290
+ }
291
+
292
+ // codex BLOCKER: writeRenameMarker above used to atomicWrite unconditionally,
293
+ // so a SECOND rename whose own --apply started after a FIRST one crashed mid-run
294
+ // would silently clobber the first rename's marker — permanently erasing the
295
+ // only trace that the first rename's move/rewrite is incomplete. Called right
296
+ // before writeRenameMarker in both modes, so the check runs before this run's
297
+ // marker (or anything else) touches disk.
298
+ //
299
+ // Three outcomes:
300
+ // - no marker on disk → nothing to conflict with, proceed.
301
+ // - marker names THIS exact command → this is the legitimate re-run path the
302
+ // whole mechanism exists to let converge (identical mode/from/to). Proceed;
303
+ // writeRenameMarker below simply overwrites it with a fresh started_at —
304
+ // mode/from/to don't change, only the timestamp does.
305
+ // - marker names a DIFFERENT command, or can't be parsed at all → refuse. A
306
+ // marker we can't parse might belong to this command or a different one;
307
+ // "can't tell" must resolve to refuse, not to a silent overwrite.
308
+ function guardExistingMarker(args, thisMarker) {
309
+ const path = renameMarkerPath(args.hypoDir);
310
+ if (!existsSync(path)) return;
311
+ const marker = readRenameMarker(args.hypoDir);
312
+ if (!marker || !marker.from || !marker.to) {
313
+ fail(
314
+ args,
315
+ `${path} exists but could not be parsed (or is missing from/to), so it cannot be told apart from this rename's own marker. Inspect it by hand, finish or discard the rename it describes, then delete the marker before retrying.`,
316
+ );
317
+ }
318
+ const same =
319
+ marker.mode === thisMarker.mode && marker.from === thisMarker.from && marker.to === thisMarker.to;
320
+ if (!same) {
321
+ fail(
322
+ args,
323
+ `a rename is already in progress (${marker.mode === 'directory' ? 'directory' : 'page'} '${marker.from}' → '${marker.to}', marker at ${path}) — finish that rename first (re-run the identical command), or confirm it is safe and delete the marker, before starting a different one.`,
324
+ );
325
+ }
326
+ }
327
+
328
+ // A failure here is not fatal — the move already succeeded — but a marker left
329
+ // behind would make doctor misreport a finished rename as incomplete, so it is
330
+ // surfaced (stderr) rather than swallowed.
331
+ function clearRenameMarker(args) {
332
+ const path = join(args.hypoDir, RENAME_MARKER_REL);
333
+ try {
334
+ rmSync(path, { force: true });
335
+ } catch (err) {
336
+ console.error(
337
+ `⚠ rename completed, but could not remove the in-progress marker (${err.message}) — remove ${path} by hand.`,
338
+ );
339
+ }
340
+ }
341
+
342
+ // Refuse an --apply that would have to cross a filesystem/mount boundary,
343
+ // BEFORE any rewrite is written to disk. renameSync cannot move across
344
+ // devices (throws EXDEV); falling back to write-then-remove in that case
345
+ // reintroduces the exact non-convergent "both paths exist, --to-already-exists
346
+ // forever" crash window a single renameSync exists to close. Call this first,
347
+ // so a device mismatch is refused with the vault untouched — not discovered
348
+ // mid-move with inbound links already rewritten.
349
+ function assertSameDevice(args, fromAbsPath, toAbsPath) {
350
+ let ancestor = dirname(toAbsPath);
351
+ while (!existsSync(ancestor)) ancestor = dirname(ancestor);
352
+ let fromDev, toDev;
353
+ try {
354
+ fromDev = statSync(fromAbsPath).dev;
355
+ toDev = statSync(ancestor).dev;
356
+ } catch {
357
+ return; // can't tell in advance — let the real move surface the real error
358
+ }
359
+ if (fromDev !== toDev) {
360
+ fail(
361
+ args,
362
+ `--from and --to are on different filesystems/mounts — an atomic move is not possible across that boundary. Move it by hand instead of --apply.`,
363
+ );
364
+ }
365
+ }
366
+
230
367
  // ── directory mode ─────────────────────────────────────────────────────────────
231
368
  // A directory rename relocates a whole subtree (`projects/old/**` → `projects/new/**`)
232
369
  // and rewrites inbound links across the vault. Key facts that shape the algorithm:
@@ -545,7 +682,7 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
545
682
 
546
683
  // Rewrite inbound references across the vault.
547
684
  const externalWrites = new Map(); // abs path → content (non-moved source files)
548
- const movedBodies = new Map(); // new rel → content (moved page bodies, written post-move)
685
+ const movedBodies = new Map(); // OLD rel → content (moved-page bodies, written pre-rename)
549
686
  const fileResults = [];
550
687
  const ambiguities = [];
551
688
  let totalRewrites = 0;
@@ -577,22 +714,55 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
577
714
  const landRel = inSubtree ? movedByRel.get(p.rel).toPage.rel : p.rel;
578
715
  fileResults.push({ file: landRel, rewrites });
579
716
  totalRewrites += rewrites.length;
580
- if (inSubtree) movedBodies.set(movedByRel.get(p.rel).toPage.rel, content);
717
+ // Keyed by the OLD rel: this gets written at the OLD path, before the
718
+ // subtree rename, so the content travels with the directory move instead
719
+ // of racing it (see the apply block below).
720
+ if (inSubtree) movedBodies.set(p.rel, content);
581
721
  else externalWrites.set(p.path, content);
582
722
  }
583
723
  if (ambiguous.length > 0) ambiguities.push({ file: p.rel, ambiguous });
584
724
  }
585
725
 
586
- // Apply: external rewrites in place → renameSync the whole subtree (carries
587
- // non-.md assets) → write rewritten moved bodies at their new paths.
726
+ // Apply: refuse a cross-device move up front (assertSameDevice) → external
727
+ // rewrites in place → moved-page bodies rewritten at their OLD paths → THEN
728
+ // renameSync the whole subtree in one syscall (carries non-.md assets and the
729
+ // just-rewritten bodies along with it).
730
+ //
731
+ // Writing moved bodies before the rename (not after, as this used to) matters
732
+ // for convergence: a crash after renameSync but before those writes used to
733
+ // leave `projects/new/*` links still pointing at `projects/old/*` forever —
734
+ // --from no longer resolves (the directory already moved), so a re-run failed
735
+ // with "did not resolve to a unique existing page" instead of finishing the
736
+ // job. Writing pre-rename means the content moves atomically with the
737
+ // directory: there is no window where the subtree has moved but its own
738
+ // internal links have not been rewritten yet.
588
739
  let moved = false;
589
740
  if (args.apply) {
590
- for (const [path, content] of externalWrites) writeFileSync(path, content);
741
+ assertSameDevice(args, fromAbs, toAbs);
742
+ const thisMarker = {
743
+ mode: 'directory',
744
+ from: fromDirRel,
745
+ to: toDirRel,
746
+ started_at: new Date().toISOString(),
747
+ };
748
+ guardExistingMarker(args, thisMarker);
749
+ writeRenameMarker(args, thisMarker);
750
+ for (const [path, content] of externalWrites) atomicWrite(path, content);
751
+ for (const [oldRel, content] of movedBodies) {
752
+ atomicWrite(join(args.hypoDir, oldRel), content);
753
+ }
591
754
  mkdirSync(dirname(toAbs), { recursive: true });
592
- renameSync(fromAbs, toAbs);
593
- for (const [newRel, content] of movedBodies) {
594
- writeFileSync(join(args.hypoDir, newRel), content);
755
+ try {
756
+ renameSync(fromAbs, toAbs);
757
+ } catch (err) {
758
+ // assertSameDevice above should already have refused this. A device
759
+ // change mid-run is the only way to reach EXDEV here — fail loudly
760
+ // instead of an unsafe write-then-remove fallback; every write above is
761
+ // already atomic, so a re-run picks up cleanly.
762
+ if (err.code !== 'EXDEV') throw err;
763
+ fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
595
764
  }
765
+ clearRenameMarker(args);
596
766
  moved = true;
597
767
  }
598
768
 
@@ -730,6 +900,22 @@ function run(args) {
730
900
  }
731
901
  }
732
902
 
903
+ // Refuse a cross-device --apply before anything below writes a single byte
904
+ // (see assertSameDevice) — the external rewrite loop right after this can
905
+ // otherwise land partial vault changes ahead of a move that turns out to be
906
+ // impossible.
907
+ if (args.apply) {
908
+ assertSameDevice(args, fromPage.path, toPath);
909
+ const thisMarker = {
910
+ mode: 'page',
911
+ from: fromPage.rel,
912
+ to: toRel,
913
+ started_at: new Date().toISOString(),
914
+ };
915
+ guardExistingMarker(args, thisMarker);
916
+ writeRenameMarker(args, thisMarker);
917
+ }
918
+
733
919
  // Rewrite inbound references across every NON-preserved page (skip the moved
734
920
  // page itself — self-references are rewritten on its own content separately).
735
921
  const fileResults = [];
@@ -748,12 +934,12 @@ function run(args) {
748
934
  fileResults.push({ file: p.rel, rewrites });
749
935
  totalRewrites += rewrites.length;
750
936
  if (args.apply && content !== raw) {
751
- // The from-page is about to move; write its rewritten body to the NEW
752
- // path below, not the old one.
937
+ // The from-page is about to move; stash its own rewritten body — it is
938
+ // written to the OLD path below, right before the rename, not here.
753
939
  if (p.rel === fromPage.rel) {
754
940
  fromPage._rewritten = content;
755
941
  } else {
756
- writeFileSync(p.path, content);
942
+ atomicWrite(p.path, content);
757
943
  }
758
944
  } else if (p.rel === fromPage.rel) {
759
945
  fromPage._rewritten = content;
@@ -764,15 +950,34 @@ function run(args) {
764
950
  }
765
951
  }
766
952
 
767
- // Move the file (--apply only): write the (possibly self-rewritten) body at the
768
- // new path, then drop the old one. Done as write-then-remove rather than a raw
769
- // rename so the carried-over self-reference rewrites are preserved.
953
+ // Move the file (--apply only): if the from-page carries a self-rewritten body
954
+ // (its own [[foo]]-style links needed retargeting), write it to the OLD path
955
+ // first (atomically — a crash mid-write must never corrupt the one copy),
956
+ // then renameSync the file into place. A rename is one syscall with no
957
+ // observable intermediate state, so a crash either lands before it (old path
958
+ // still has the rewritten body, re-run converges) or after it (new path exists,
959
+ // nothing to redo) — never both paths present at once, which is the state a
960
+ // prior write-then-remove could leave and that the --to-already-exists guard
961
+ // above then refuses to recover from.
770
962
  let moved = false;
771
963
  if (args.apply) {
964
+ if (fromPage._rewritten !== undefined) atomicWrite(fromPage.path, fromPage._rewritten);
772
965
  mkdirSync(dirname(toPath), { recursive: true });
773
- const body = fromPage._rewritten ?? readFileSync(fromPage.path, 'utf-8');
774
- writeFileSync(toPath, body);
775
- if (toPath !== fromPage.path) rmSync(fromPage.path, { force: true });
966
+ if (toPath !== fromPage.path) {
967
+ try {
968
+ renameSync(fromPage.path, toPath);
969
+ } catch (err) {
970
+ // assertSameDevice above should already have refused a cross-device
971
+ // move before any write happened. Reaching EXDEV here means the mount
972
+ // changed mid-run — fail loudly rather than silently falling back to
973
+ // write-then-remove, which is exactly the non-atomic state this fix
974
+ // removes. fromPage.path still holds the valid (possibly rewritten)
975
+ // body, so a re-run picks up cleanly once the device issue is gone.
976
+ if (err.code !== 'EXDEV') throw err;
977
+ fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
978
+ }
979
+ }
980
+ clearRenameMarker(args);
776
981
  moved = true;
777
982
  }
778
983
 
package/scripts/stats.mjs CHANGED
@@ -98,7 +98,12 @@ function getLastActivity(hypoDir) {
98
98
  const args = parseArgs(process.argv);
99
99
  // Only validate the auto-resolved path (env/marker/default). An explicit
100
100
  // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
101
- if (args.hypoDirSource) checkVaultOrExit(args.hypoDir, args.hypoDirSource);
101
+ // See lint.mjs's matching comment: source 'default'/stale-'marker' stays
102
+ // exit-0 (CI-safe), but the all-zero counts below must not be printed as if a
103
+ // real (empty) vault had been scanned.
104
+ const vaultMissing = args.hypoDirSource
105
+ ? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
106
+ : false;
102
107
 
103
108
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
104
109
  const pageFiles = collectMdFiles(join(args.hypoDir, 'pages'), [], args.hypoDir, ignorePatterns);
@@ -165,7 +170,14 @@ const stats = {
165
170
  };
166
171
 
167
172
  if (args.json) {
168
- console.log(JSON.stringify(stats, null, 2));
173
+ // vaultFound lets a machine consumer of `--json` tell "scanned, genuinely
174
+ // empty" apart from "nothing was scanned" without needing stderr — the
175
+ // same reasoning as lint.mjs's matching field.
176
+ console.log(JSON.stringify({ ...stats, vaultFound: !vaultMissing }, null, 2));
177
+ } else if (vaultMissing) {
178
+ // checkVaultOrExit already printed the "No Hypomnema vault found" notice on
179
+ // stderr. An all-zero stats block here would read as "scanned an empty
180
+ // vault", which never happened.
169
181
  } else {
170
182
  console.log(`Pages: ${pageFiles.length} total`);
171
183
  const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
@@ -47,6 +47,7 @@ import {
47
47
  hasSymlinkAncestor,
48
48
  buildHookCommand,
49
49
  } from './lib/extensions.mjs';
50
+ import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
50
51
 
51
52
  const HOME = homedir();
52
53
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -510,6 +511,17 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
510
511
  missing.push(p);
511
512
  }
512
513
  }
514
+ // .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
515
+ // this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
516
+ // hook files themselves, so it is removed in the same pass rather than left
517
+ // behind to describe a package root that no longer has any hooks pointing
518
+ // through it. Unlike hookFiles above, this is an optional file (only the
519
+ // manual/npm channel ever writes one) — so, mirroring the loop's own
520
+ // present/absent split, it is only added to `removed` (dry-run: "to remove")
521
+ // when it actually exists; a channel that never wrote one gets no line at
522
+ // all, not a spurious "already absent".
523
+ const sidecar = removeProvenanceSidecar(hooksDir, apply);
524
+ if (sidecar) removed.push(sidecar);
513
525
  return { removed, missing };
514
526
  }
515
527
 
@@ -53,6 +53,7 @@ import {
53
53
  writeDualSkipProvenance,
54
54
  } from './lib/pkg-json.mjs';
55
55
  import { syncExtensions } from './lib/extensions.mjs';
56
+ import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
56
57
  import { isHypomnemaPluginEnabled, resolveEnabledPluginRoot } from './lib/plugin-detect.mjs';
57
58
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
58
59
 
@@ -1135,6 +1136,12 @@ if (args.apply) {
1135
1136
  );
1136
1137
  }
1137
1138
  appliedHooks = applyHookFiles(hooks, claudeHooksDir);
1139
+ // Refresh every run managesClaudeCore is true, not only when a stale hook
1140
+ // was actually copied above: applyHookFiles OVERWRITES (never skips) a
1141
+ // stale hook, but even a run that finds every hook already up-to-date
1142
+ // must still keep the sidecar's pkgVersion truthful, same reasoning as
1143
+ // installHooks's own refresh-every-run comment.
1144
+ writeProvenanceSidecar(claudeHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
1138
1145
  appliedSettings = applySettingsJson(settings, claudeSettingsPath);
1139
1146
  // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
1140
1147
  appliedCommands = applyCommands(commands, args.forceCommands);
@@ -1196,6 +1203,7 @@ if (args.apply) {
1196
1203
  );
1197
1204
  }
1198
1205
  appliedHooksCodex = applyHookFiles(hooksCodex, codexHooksDir);
1206
+ writeProvenanceSidecar(codexHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
1199
1207
  appliedSettingsCodex = applySettingsJson(settingsCodex, codexSettingsPath);
1200
1208
  }
1201
1209
  // After applyCommands wrote hypo-pkg.json — merges extensions.<target> alongside.
@@ -8,3 +8,7 @@
8
8
  # writes (crystallize apply, deriveRootLogEntries). Normally deleted immediately;
9
9
  # a crashed holder can leave a stale one, which must never be committed.
10
10
  *.lock
11
+ # The lock is published by linking a staged sibling into place. `*.lock` does not
12
+ # match the staging name, and a crash between the link and its cleanup leaves one
13
+ # behind — where `git add -A` would happily pick it up.
14
+ *.lock.*.tmp
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.7.0"
4
+ version: "1.7.2"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7