hypomnema 1.8.2 → 1.9.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 (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
package/scripts/init.mjs CHANGED
@@ -48,8 +48,10 @@ import {
48
48
  SHELL_MARKER_END,
49
49
  SHELL_FUNCTION_BODY,
50
50
  wikiPreCommitContent,
51
+ uniqueBakPath,
51
52
  } from './lib/git-hooks-dir.mjs';
52
- import { readCoreHooksConfig } from './lib/core-hooks.mjs';
53
+ import { loadHookInventory } from './lib/hook-inventory.mjs';
54
+ import { formatRootHotProjection } from '../hooks/hypo-shared.mjs';
53
55
  import {
54
56
  readPkgJson as readPkgJsonSafe,
55
57
  writePkgJsonAtomic,
@@ -61,7 +63,7 @@ import { syncExtensions } from './lib/extensions.mjs';
61
63
  import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
62
64
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
63
65
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
64
- import { resolvePluginChannel, usablePkgRoot } from './lib/plugin-detect.mjs';
66
+ import { resolvePluginChannel, isHypomnemaInstallRoot } from './lib/plugin-detect.mjs';
65
67
 
66
68
  const HOME = homedir();
67
69
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -302,6 +304,22 @@ function copyTemplate(srcName, destPath, dryRun, transform) {
302
304
  log('created', destPath);
303
305
  }
304
306
 
307
+ // Root hot.md is generated, not copied. The hooks rebuild it from
308
+ // projects/*/hot.md with formatRootHotProjection, and the first rebuild in a new
309
+ // vault treats any bytes it did not produce as a hand edit (backup file plus a
310
+ // notice). A templates/hot.md kept equal to the generator by hand was a second
311
+ // copy of that body, and every edit to the generator had to be mirrored into it.
312
+ // Writing the generator's own empty-vault output here leaves one body.
313
+ function writeRootHot(hypoDir, dryRun) {
314
+ const dest = join(hypoDir, 'hot.md');
315
+ if (existsSync(dest)) {
316
+ log('skipped', dest);
317
+ return;
318
+ }
319
+ if (!dryRun) writeFileSync(dest, formatRootHotProjection([]));
320
+ log('created', dest);
321
+ }
322
+
305
323
  // ── hypo-config.md ───────────────────────────────────────────────────────────
306
324
 
307
325
  function writeHypoConfig(hypoDir, dryRun) {
@@ -367,90 +385,19 @@ function writeGitignore(hypoDir, dryRun) {
367
385
  // ── hook installation ────────────────────────────────────────────────────────
368
386
 
369
387
  function loadHookMap() {
370
- // Read + parse via the exit-free shared helper; keep init's own validation and
371
- // exit(1) behavior below. The helper omits `cfg` only on read/parse failure
372
- // (JSON.parse never yields undefined), so key presence discriminates a
373
- // read/parse failure from a parsed-but-malformed shape.
374
- const res = readCoreHooksConfig(PKG_ROOT);
375
- if (!('cfg' in res)) {
376
- console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
377
- console.error(PKG_INTEGRITY_HINT);
378
- process.exit(1);
379
- }
380
- const cfg = res.cfg;
381
- if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
382
- console.error('Error: hooks/hooks.json must be a JSON object');
388
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) owns the grammar AND the
389
+ // existence check now: every consumer that installs, refreshes, or removes a
390
+ // hook file reads through the same parser, and a name on either list that does
391
+ // not resolve to a real file under hooks/ fails this call before init writes
392
+ // anything at all (major C — a hook file dropped from the shipped package used
393
+ // to install a broken settings.json registration with nothing behind it).
394
+ const res = loadHookInventory(PKG_ROOT);
395
+ if (!res.ok) {
396
+ console.error(`Error: ${res.error}`);
383
397
  console.error(PKG_INTEGRITY_HINT);
384
398
  process.exit(1);
385
399
  }
386
- if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
387
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
388
- console.error(PKG_INTEGRITY_HINT);
389
- process.exit(1);
390
- }
391
- function _extractCommandFileName(command) {
392
- if (typeof command !== 'string') return null;
393
- const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
394
- if (matches.length > 0) return matches[matches.length - 1][1];
395
- const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
396
- return bare ? bare[1] : null;
397
- }
398
-
399
- function _isHookFileName(file) {
400
- return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
401
- }
402
-
403
- function _isHookGroup(group) {
404
- return (
405
- group &&
406
- typeof group === 'object' &&
407
- !Array.isArray(group) &&
408
- Array.isArray(group.hooks) &&
409
- group.hooks.length > 0 &&
410
- group.hooks.every(
411
- (hook) =>
412
- hook &&
413
- typeof hook === 'object' &&
414
- !Array.isArray(hook) &&
415
- hook.type === 'command' &&
416
- _extractCommandFileName(hook.command),
417
- )
418
- );
419
- }
420
-
421
- // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
422
- function _extractFileNames(groups) {
423
- return groups.flatMap((group) => {
424
- if (typeof group === 'string') return [group.trim()];
425
- return group.hooks.map((hook) => _extractCommandFileName(hook.command));
426
- });
427
- }
428
-
429
- for (const [event, groups] of Object.entries(cfg.hooks)) {
430
- const valid =
431
- Array.isArray(groups) &&
432
- groups.length > 0 &&
433
- groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
434
- _extractFileNames(groups).length > 0;
435
- if (!valid) {
436
- console.error(
437
- `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
438
- );
439
- console.error(PKG_INTEGRITY_HINT);
440
- process.exit(1);
441
- }
442
- }
443
- if (
444
- cfg.shared !== undefined &&
445
- (!Array.isArray(cfg.shared) || !cfg.shared.every((f) => _isHookFileName(f)))
446
- ) {
447
- console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
448
- console.error(PKG_INTEGRITY_HINT);
449
- process.exit(1);
450
- }
451
- return Object.fromEntries(
452
- Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]),
453
- );
400
+ return res.hookMap;
454
401
  }
455
402
 
456
403
  function installHooks(targetDir, dryRun) {
@@ -459,8 +406,20 @@ function installHooks(targetDir, dryRun) {
459
406
  return;
460
407
  }
461
408
  if (!dryRun) mkdirSync(targetDir, { recursive: true });
462
- for (const file of readdirSync(HOOKS_SRC)) {
463
- if (!file.endsWith('.mjs')) continue;
409
+ // The canonical install order, not a directory listing. Reading the directory
410
+ // installed whatever happened to sit in hooks/, which is how every install
411
+ // ended up with a copy of hypo-pre-commit.mjs: it is invoked from the package
412
+ // root, never from the install, so upgrade would not refresh that copy,
413
+ // doctor would not report it, and uninstall would not take it away.
414
+ // installOrder also puts shared modules ahead of the entry hooks that import
415
+ // them, so an interrupted run cannot leave an entry hook live against a module
416
+ // that is not there yet.
417
+ const inventory = loadHookInventory(PKG_ROOT);
418
+ if (!inventory.ok) {
419
+ log('errors', `cannot read the hook inventory: ${inventory.error}`);
420
+ return;
421
+ }
422
+ for (const file of inventory.ordered) {
464
423
  const dest = join(targetDir, file);
465
424
  if (existsSync(dest)) {
466
425
  log('skipped', dest);
@@ -578,8 +537,9 @@ function readPkgVersionAt(root) {
578
537
  }
579
538
  }
580
539
 
581
- // usablePkgRoot now lives in ./lib/plugin-detect.mjs (imported above), shared with
582
- // upgrade.mjs's dualSkip provenance correction so both agree on what is real.
540
+ // usablePkgRoot / isHypomnemaInstallRoot now live in ./lib/plugin-detect.mjs
541
+ // (imported above), shared with upgrade.mjs's dualSkip provenance correction so
542
+ // both agree on what is real.
583
543
 
584
544
  function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
585
545
  const dest = pkgJsonPath();
@@ -759,12 +719,14 @@ function installPkgGitHook(dryRun) {
759
719
  // ── wiki pre-commit hook ─────────────────────────────────────────────────────
760
720
  //
761
721
  // shellSingleQuote and wikiPreCommitContent live in lib/git-hooks-dir.mjs, not
762
- // here: upgrade.mjs's self-heal (repointing an existing hook at the current
763
- // durable root) and doctor.mjs's drift check both need to build/parse the
764
- // exact same body this writer produces, so one copy is shared rather than
765
- // three independent copies silently drifting apart.
766
-
767
- function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
722
+ // here: upgrade.mjs's old-to-new-form migration and doctor.mjs's report both
723
+ // need to build/parse the exact same body this writer produces, so one copy is
724
+ // shared rather than three independent copies silently drifting apart.
725
+ // wikiPreCommitContent no longer takes an install root: the body
726
+ // it generates resolves that itself, at commit time, so there is nothing here
727
+ // for a plugin-channel version bump to make stale.
728
+
729
+ function installWikiPreCommitHook(hypoDir, dryRun, force, lintStrict) {
768
730
  const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
769
731
  if (!hooksDir) {
770
732
  // no git repo — silently skip, as before; anything else is worth surfacing
@@ -772,7 +734,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
772
734
  return;
773
735
  }
774
736
  const hookPath = join(hooksDir, 'pre-commit');
775
- const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
737
+ const newContent = wikiPreCommitContent(hypoDir, lintStrict);
776
738
 
777
739
  // Before every branch below, including --force: a symlink here would send the
778
740
  // write through to an arbitrary external file.
@@ -789,20 +751,36 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
789
751
  log('skipped', `${hookPath} (pre-commit up to date)`);
790
752
  return;
791
753
  }
754
+ // Back this up first. The marker's presence says our block is IN the
755
+ // file; it says nothing about what else is, and this branch replaces the
756
+ // whole file either way. upgrade.mjs refuses exactly this shape (content
757
+ // outside the marker span) and tells the user to run
758
+ // `init --force-commands` — so without a backup here, following our own
759
+ // recovery advice is what destroys their hook. Measured by a codex
760
+ // reviewer 2026-09-11; the `force` branch below already backed up, this
761
+ // one did not, and `force` is not even consulted here.
762
+ const bakPath = uniqueBakPath(hookPath);
763
+ if (!bakPath) {
764
+ log('skipped', `${hookPath} (unsafe backup path) — not overwriting without a safe backup`);
765
+ return;
766
+ }
792
767
  if (!dryRun) {
768
+ writeFileSync(bakPath, existing);
793
769
  writeFileSync(hookPath, newContent);
794
770
  chmodSync(hookPath, 0o755);
795
771
  }
796
- log('merged', `${hookPath} (pre-commit updated)`);
772
+ log('merged', `${hookPath} (pre-commit updated, backup at ${basename(bakPath)})`);
797
773
  } else if (force) {
798
774
  // The .bak is a SECOND write to a DIFFERENT path, so the guard on
799
775
  // hookPath above says nothing about it. Left unchecked, a pre-commit.bak
800
776
  // symlink turns --force-commands into an overwrite of whatever it points
801
777
  // at — deterministically, not as a race.
802
- const bakPath = hookPath + '.bak';
803
- const unsafeBak = unsafeHookTargetReason(bakPath);
804
- if (unsafeBak) {
805
- log('skipped', `${bakPath} (${unsafeBak}) — not force-overwriting without a safe backup`);
778
+ const bakPath = uniqueBakPath(hookPath);
779
+ if (!bakPath) {
780
+ log(
781
+ 'skipped',
782
+ `${hookPath} (unsafe backup path) — not force-overwriting without a safe backup`,
783
+ );
806
784
  return;
807
785
  }
808
786
  if (!dryRun) {
@@ -810,7 +788,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
810
788
  writeFileSync(hookPath, newContent);
811
789
  chmodSync(hookPath, 0o755);
812
790
  }
813
- log('merged', `${hookPath} (force-overwritten, backup at pre-commit.bak)`);
791
+ log('merged', `${hookPath} (force-overwritten, backup at ${basename(bakPath)})`);
814
792
  } else {
815
793
  log(
816
794
  'skipped',
@@ -1040,7 +1018,12 @@ function resolveDurableRoot() {
1040
1018
  if (!hypomnemaPluginEnabled) return PKG_ROOT;
1041
1019
  if (pluginChannel.root) return pluginChannel.root;
1042
1020
  const recorded = recordedPkgRoot();
1043
- if (usablePkgRoot(recorded)) return recorded;
1021
+ // isHypomnemaInstallRoot, not the weak usablePkgRoot: this value is about to be
1022
+ // adopted as the durable root and re-recorded (see writePkgJson's `root` param
1023
+ // below), so a recorded pointer that merely resolves a readable version, without
1024
+ // actually being a Hypomnema package, must not be trusted here (see
1025
+ // lib/plugin-detect.mjs's comment on the two predicates).
1026
+ if (isHypomnemaInstallRoot(recorded)) return recorded;
1044
1027
  // pluginChannel.root is null here because the channel JUDGMENT FAILED (reason
1045
1028
  // 'registry-unreadable' or 'unresolved'), not because no plugin is installed —
1046
1029
  // hypomnemaPluginEnabled is already true, and that case already returned
@@ -1127,7 +1110,7 @@ if (args.fromRemote) {
1127
1110
 
1128
1111
  // 2. template files
1129
1112
  copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
1130
- copyTemplate('hot.md', join(args.hypoDir, 'hot.md'), args.dryRun);
1113
+ writeRootHot(args.hypoDir, args.dryRun);
1131
1114
  copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
1132
1115
  copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
1133
1116
  copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
@@ -1321,13 +1304,7 @@ if (args.hooks) {
1321
1304
  // any) is left exactly as-is, and channelUnresolvedNotice() above says what to
1322
1305
  // do next.
1323
1306
  if (!channelUnresolved) {
1324
- installWikiPreCommitHook(
1325
- args.hypoDir,
1326
- args.dryRun,
1327
- args.forceCommands,
1328
- durableRoot,
1329
- args.lintStrict,
1330
- );
1307
+ installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands, args.lintStrict);
1331
1308
  } else {
1332
1309
  log(
1333
1310
  'skipped',
@@ -1,4 +1,5 @@
1
- // core-hooks.mjs - read the packaged hooks/hooks.json without side effects.
1
+ // core-hooks.mjs - read the packaged hooks/hooks.json and hooks/shared.json
2
+ // without side effects.
2
3
  //
3
4
  // The init installer (init.mjs loadHookMap) reads hooks.json, validates it, and
4
5
  // calls process.exit(1) on any malformation. That exit-on-error behavior is
@@ -12,28 +13,39 @@
12
13
  // hooks type when the result is not ok (better to capture nothing than to
13
14
  // capture a core hook).
14
15
  //
15
- // fail-closed: a result is ok only when the file reads, parses, AND has the
16
- // expected shape (a hooks registration map plus a shared array). A parsed but
17
- // oddly shaped hooks.json would yield a thin basename set, which is exactly the
18
- // gap through which a core hook could leak into capture. When the shape is off
19
- // we still attach the parsed cfg so init can run its own validation and emit its
16
+ // The shared-file list used to live at hooks.json's own top-level "shared" key.
17
+ // It moved to a sibling file, hooks/shared.json: the harness started
18
+ // warning on hooks.json's unknown "shared" key once it began validating that
19
+ // file against its own hook-registration schema, and "shared" was never part of
20
+ // that schema, it was a Hypomnema-only convention the harness never asked for.
21
+ // This module still folds shared.json's contents onto `cfg.shared` after
22
+ // reading it, so every existing caller of deriveCoreHookBasenames keeps working
23
+ // against the same shape without a signature change.
24
+ //
25
+ // fail-closed: a result is ok only when both files read, parse, AND have the
26
+ // expected shape (a hooks registration map, and a shared array). A parsed but
27
+ // oddly shaped input would yield a thin basename set, which is exactly the gap
28
+ // through which a core hook could leak into capture. When the shape is off we
29
+ // still attach the parsed cfg so init can run its own validation and emit its
20
30
  // own specific error, but ok is false so capture stays conservative.
21
31
 
22
32
  import { readFileSync } from 'node:fs';
23
33
  import { join } from 'node:path';
24
34
 
25
35
  /**
26
- * Read and JSON-parse hooks/hooks.json from a package root. No process.exit, no
27
- * top-level side effects.
36
+ * Read and JSON-parse hooks/hooks.json and hooks/shared.json from a package
37
+ * root. No process.exit, no top-level side effects.
28
38
  *
29
39
  * @param {string} pkgRoot absolute path to the package root (contains hooks/)
30
40
  * @returns {{ ok: true, cfg: object }
31
41
  * | { ok: false, error: string }
32
42
  * | { ok: false, error: string, cfg: * }}
33
- * On read or parse failure the `cfg` key is absent. On a successful parse the
34
- * `cfg` key is always present (even for null/array/scalar/shape-off inputs),
35
- * so a caller can discriminate read/parse failure from shape failure by the
36
- * presence of the `cfg` key. `ok` is true only when the shape is as expected.
43
+ * On read or parse failure of hooks.json the `cfg` key is absent. Once
44
+ * hooks.json parses, `cfg` is always present (even for null/array/scalar/
45
+ * shape-off inputs), so a caller can discriminate a hooks.json read/parse
46
+ * failure from a shape failure (including a shared.json failure) by the
47
+ * presence of the `cfg` key. `ok` is true only when both files' shapes are
48
+ * as expected, and when true `cfg.shared` is the parsed shared.json array.
37
49
  */
38
50
  export function readCoreHooksConfig(pkgRoot) {
39
51
  let raw;
@@ -55,15 +67,12 @@ export function readCoreHooksConfig(pkgRoot) {
55
67
  if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
56
68
  return { ok: false, error: 'hooks/hooks.json must contain a "hooks" object', cfg };
57
69
  }
58
- if (!Array.isArray(cfg.shared)) {
59
- return { ok: false, error: 'hooks/hooks.json must contain a "shared" array', cfg };
60
- }
61
70
  // Nested shape: a structurally odd rung (event not an array, a group without a
62
- // hooks array, a hook entry with no string command, a non-string shared element)
63
- // is silently skipped by deriveCoreHookBasenames, yielding a THIN reserved set.
64
- // That is the gap through which a core hook could leak into reverse-capture, so
65
- // validate every rung and fail closed. cfg is still attached so init can run its
66
- // own detailed validation and emit its own specific error.
71
+ // hooks array, a hook entry with no string command) is silently skipped by
72
+ // deriveCoreHookBasenames, yielding a THIN reserved set. That is the gap
73
+ // through which a core hook could leak into reverse-capture, so validate
74
+ // every rung and fail closed. cfg is still attached so init can run its own
75
+ // detailed validation and emit its own specific error.
67
76
  for (const groups of Object.values(cfg.hooks)) {
68
77
  if (!Array.isArray(groups)) {
69
78
  return { ok: false, error: 'each hooks event must map to an array of groups', cfg };
@@ -86,11 +95,28 @@ export function readCoreHooksConfig(pkgRoot) {
86
95
  }
87
96
  }
88
97
  }
89
- for (const file of cfg.shared) {
98
+
99
+ let sharedRaw;
100
+ try {
101
+ sharedRaw = readFileSync(join(pkgRoot, 'hooks', 'shared.json'), 'utf-8');
102
+ } catch (err) {
103
+ return { ok: false, error: `cannot read hooks/shared.json: ${err.message}`, cfg };
104
+ }
105
+ let shared;
106
+ try {
107
+ shared = JSON.parse(sharedRaw);
108
+ } catch (err) {
109
+ return { ok: false, error: `hooks/shared.json is not valid JSON: ${err.message}`, cfg };
110
+ }
111
+ if (!Array.isArray(shared)) {
112
+ return { ok: false, error: 'hooks/shared.json must be a JSON array', cfg };
113
+ }
114
+ for (const file of shared) {
90
115
  if (typeof file !== 'string') {
91
- return { ok: false, error: 'each "shared" entry must be a string', cfg };
116
+ return { ok: false, error: 'each hooks/shared.json entry must be a string', cfg };
92
117
  }
93
118
  }
119
+ cfg.shared = shared;
94
120
  return { ok: true, cfg };
95
121
  }
96
122