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
@@ -51,7 +51,7 @@ import {
51
51
  statSync,
52
52
  realpathSync,
53
53
  } from 'fs';
54
- import { join } from 'path';
54
+ import { join, resolve, sep } from 'path';
55
55
  import { homedir } from 'os';
56
56
  import { fileURLToPath } from 'url';
57
57
  import {
@@ -74,7 +74,12 @@ import {
74
74
  hasSymlinkAncestor,
75
75
  buildHookCommand,
76
76
  } from './lib/extensions.mjs';
77
- import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
77
+ import {
78
+ removeProvenanceSidecar,
79
+ readProvenanceSidecar,
80
+ provenancePath,
81
+ } from './lib/pkg-provenance.mjs';
82
+ import { loadHookInventory } from './lib/hook-inventory.mjs';
78
83
  import {
79
84
  hooksDirForInstall,
80
85
  unsafeHookTargetReason,
@@ -94,12 +99,6 @@ const HOME = homedir();
94
99
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
95
100
  const PKG_ROOT = join(SCRIPT_DIR, '..');
96
101
 
97
- // Shown after every fatal package-integrity error. These conditions mean the
98
- // shipped hooks/hooks.json is missing or malformed — never a user mistake —
99
- // so the only useful next step is a re-install of the package.
100
- const PKG_INTEGRITY_HINT =
101
- '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
102
-
103
102
  function removeCommands(apply, force) {
104
103
  const targetDir = join(HOME, '.claude', 'commands', 'hypo');
105
104
  const pkgPath = join(HOME, '.claude', 'hypo-pkg.json');
@@ -725,53 +724,74 @@ function parseArgs(argv) {
725
724
  return args;
726
725
  }
727
726
 
728
- // ── hook map (single source of truth) ───────────────────────────────────────
729
-
730
- function loadHookFiles() {
731
- let cfg;
732
- try {
733
- cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
734
- } catch {
735
- console.error('Error: cannot read hooks/hooks.json');
736
- console.error(PKG_INTEGRITY_HINT);
737
- process.exit(1);
738
- }
739
- if (!cfg?.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
740
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
741
- console.error(PKG_INTEGRITY_HINT);
742
- process.exit(1);
743
- }
744
-
745
- const hookFiles = new Set();
746
- const normalizedHookMap = {};
747
-
748
- for (const [event, groups] of Object.entries(cfg.hooks)) {
749
- const filenames = [];
750
- for (const entry of groups) {
751
- if (typeof entry === 'string') {
752
- // legacy flat format: entry is a filename
753
- hookFiles.add(entry);
754
- filenames.push(entry);
755
- } else if (entry && Array.isArray(entry.hooks)) {
756
- // current group format: extract filename from command string
757
- for (const h of entry.hooks) {
758
- if (h.type === 'command' && typeof h.command === 'string') {
759
- const m = h.command.match(/\/hooks\/([^/\s]+\.mjs)$/);
760
- if (m) {
761
- hookFiles.add(m[1]);
762
- filenames.push(m[1]);
763
- }
764
- }
765
- }
766
- }
767
- }
768
- normalizedHookMap[event] = filenames;
727
+ // ── hook file set (single source of truth, with a provenance fallback) ──────
728
+ //
729
+ // The normal path reads PKG_ROOT's own hooks.json/shared.json through
730
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) — the same parser every
731
+ // install/uninstall consumer shares, so a command shape one of them would
732
+ // accept (or a trailing-argument command this file's own old `/hooks/(...)$`
733
+ // anchor silently ignored) cannot read differently here.
734
+ //
735
+ // But uninstall is the one command whose whole point is letting someone leave
736
+ // even when the package itself is broken. The other four consumers fail closed
737
+ // on a malformed hooks.json; refusing to run here instead would trade "a few
738
+ // files linger" for "you cannot remove this at all". So when the package list
739
+ // cannot be read, this falls back to the provenance sidecar THIS hooksDir's own
740
+ // last install/upgrade wrote: writeProvenanceSidecar (lib/pkg-provenance.mjs)
741
+ // records `managedFiles`, the exact basename set that run copied, so a hooks.json
742
+ // broken in the CURRENTLY-INSTALLED package does not strand the files a PAST,
743
+ // working package put there.
744
+ //
745
+ // Only when neither the package list nor a provenance record is available does
746
+ // this give up on identifying anything here as ours — `source: 'none'` below
747
+ // never guesses at deletion; the caller names what remains and reports failure
748
+ // via its exit code rather than silently succeeding at nothing.
749
+ function resolveHookFileSet(pkgRoot, hooksDir) {
750
+ const inv = loadHookInventory(pkgRoot);
751
+ if (inv.ok) {
752
+ // Reverse of the install order, so removal mirrors it: entry hooks go
753
+ // first, the shared modules they import go last. An uninstall interrupted
754
+ // halfway then leaves entry hooks that are already gone rather than entry
755
+ // hooks still registered against modules that are, which is the shape that
756
+ // breaks the next session instead of merely being incomplete.
757
+ // A Set keeps insertion order, and removeHookFiles iterates it in that order.
758
+ //
759
+ // LEGACY_INSTALLED is not part of the install set and never should have
760
+ // been: init used to copy every `.mjs` in hooks/, which put
761
+ // hypo-pre-commit.mjs into installs that never invoke it (the vault's
762
+ // pre-commit wrapper runs the package-root copy). init no longer does that,
763
+ // so the only copies left are the ones already on disk, and this is the one
764
+ // command that can take them away.
765
+ const LEGACY_INSTALLED = ['hypo-pre-commit.mjs'];
766
+ const files = new Set([...[...inv.ordered].reverse(), ...LEGACY_INSTALLED]);
767
+ return { files, source: 'package', warning: null };
769
768
  }
770
-
771
- if (Array.isArray(cfg.shared)) {
772
- for (const f of cfg.shared) hookFiles.add(f);
769
+ const sidecar = readProvenanceSidecar(hooksDir);
770
+ if (sidecar && Array.isArray(sidecar.managedFiles) && sidecar.managedFiles.length > 0) {
771
+ return {
772
+ files: new Set(sidecar.managedFiles),
773
+ source: 'provenance',
774
+ warning:
775
+ `hooks/hooks.json could not be read (${inv.error}). Recovered the managed file list for ` +
776
+ `${hooksDir} from ${provenancePath(hooksDir)} (recorded at the last install/upgrade) instead.`,
777
+ };
773
778
  }
774
- return { hookMap: normalizedHookMap, hookFiles };
779
+ const present = existsSync(hooksDir)
780
+ ? readdirSync(hooksDir).filter((f) => f.endsWith('.mjs'))
781
+ : [];
782
+ return {
783
+ files: new Set(),
784
+ source: 'none',
785
+ warning:
786
+ `hooks/hooks.json could not be read (${inv.error}) and no provenance record exists at ` +
787
+ `${provenancePath(hooksDir)} to recover a managed file list from (an install made before ` +
788
+ `this sidecar field existed never wrote one). Hook file removal for ${hooksDir} is skipped ` +
789
+ 'entirely.' +
790
+ (present.length
791
+ ? ` The following .mjs files are still there and were not evaluated — remove them by ` +
792
+ `hand after checking what they are: ${present.join(', ')}`
793
+ : ' No .mjs files were found there either.'),
794
+ };
775
795
  }
776
796
 
777
797
  // ── hook file removal ────────────────────────────────────────────────────────
@@ -779,8 +799,22 @@ function loadHookFiles() {
779
799
  function removeHookFiles(hooksDir, hookFiles, apply) {
780
800
  const removed = [],
781
801
  missing = [];
802
+ const skipped = [];
803
+ // Every name here comes out of a file on disk: hooks.json's event map and
804
+ // hooks/shared.json. A name is supposed to be a bare basename, but nothing
805
+ // upstream forces that, and join() resolves `../x.mjs` straight out of the
806
+ // hooks directory. This function DELETES what it is handed, so it confirms
807
+ // the containment itself rather than trusting the list it was given. An
808
+ // out-of-tree name is skipped and named, never removed: a corrupt list is a
809
+ // reason to leave files alone, not to delete somewhere else.
810
+ const root = resolve(hooksDir);
811
+ const inside = (p) => p === root || p.startsWith(root + sep);
782
812
  for (const file of hookFiles) {
783
813
  const p = join(hooksDir, file);
814
+ if (!inside(resolve(p))) {
815
+ skipped.push(file);
816
+ continue;
817
+ }
784
818
  if (existsSync(p)) {
785
819
  if (apply) rmSync(p);
786
820
  removed.push(p);
@@ -788,6 +822,12 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
788
822
  missing.push(p);
789
823
  }
790
824
  }
825
+ if (skipped.length > 0) {
826
+ console.warn(
827
+ `Warning: ${skipped.length} hook name(s) resolve outside ${hooksDir} and were left alone: ` +
828
+ `${skipped.join(', ')}. Remove them by hand after checking what they are.`,
829
+ );
830
+ }
791
831
  // .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
792
832
  // this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
793
833
  // hook files themselves, so it is removed in the same pass rather than left
@@ -804,7 +844,13 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
804
844
 
805
845
  // ── settings.json cleanup ────────────────────────────────────────────────────
806
846
 
807
- function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
847
+ // `hookFiles` is a flat Set of basenames (not a per-event map): a command
848
+ // string already names the exact hook file (`node <hooksDir>/<file>`), so
849
+ // scoping "ours" by which event a settings.json group happens to sit under adds
850
+ // no protection a flat membership check does not already give — and a flat set
851
+ // is what both the normal (package) and provenance-fallback paths in
852
+ // resolveHookFileSet actually have on hand.
853
+ function stripSettingsJson(settingsPath, hooksDir, hookFiles, apply) {
808
854
  if (!existsSync(settingsPath)) return { stripped: [], kept: 0 };
809
855
 
810
856
  let settings;
@@ -816,18 +862,18 @@ function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
816
862
 
817
863
  if (!settings.hooks || typeof settings.hooks !== 'object') return { stripped: [], kept: 0 };
818
864
 
865
+ const expectedCmds = new Set(
866
+ [...hookFiles].map((file) => `node ${hooksDir.replace(HOME, '$HOME')}/${file}`),
867
+ );
868
+ const isHypoHook = (h) =>
869
+ h.type === 'command' && typeof h.command === 'string' && expectedCmds.has(h.command);
870
+
819
871
  const stripped = [];
820
872
  let changed = false;
821
873
 
822
874
  for (const [event, groups] of Object.entries(settings.hooks)) {
823
875
  if (!Array.isArray(groups)) continue;
824
876
 
825
- const managed = hookMap[event] ?? [];
826
- const isHypoHook = (h) =>
827
- h.type === 'command' &&
828
- typeof h.command === 'string' &&
829
- managed.some((file) => h.command === `node ${hooksDir.replace(HOME, '$HOME')}/${file}`);
830
-
831
877
  const filtered = groups.flatMap((group) => {
832
878
  if (!Array.isArray(group.hooks)) return [group];
833
879
 
@@ -873,13 +919,31 @@ if (args.hooksDir && !args.keepShell && !args.keepWikiHook) {
873
919
  );
874
920
  }
875
921
 
876
- const { hookMap, hookFiles } = loadHookFiles();
877
-
878
922
  const claudeHooksDir = args.hooksDir ?? join(HOME, '.claude', 'hooks');
879
923
  const claudeSettings = join(HOME, '.claude', 'settings.json');
880
924
 
881
- const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
882
- const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
925
+ // A `source: 'none'` resolution (package unreadable AND no provenance record)
926
+ // means hook-file removal for that target was skipped entirely rather than
927
+ // guessed at — this run must not report plain success in that case. Collected
928
+ // across both targets and applied to the exit code once the whole report is
929
+ // printed, so the caller still sees everything else this run did (or would do).
930
+ let hardFailure = false;
931
+ function reportHookSetWarning(set) {
932
+ if (!set.warning) return;
933
+ console.error(`Warning: ${set.warning}`);
934
+ if (set.source === 'none') hardFailure = true;
935
+ }
936
+
937
+ const claudeHookSet = resolveHookFileSet(PKG_ROOT, claudeHooksDir);
938
+ reportHookSetWarning(claudeHookSet);
939
+
940
+ const hookResult = removeHookFiles(claudeHooksDir, claudeHookSet.files, args.apply);
941
+ const settingsResult = stripSettingsJson(
942
+ claudeSettings,
943
+ claudeHooksDir,
944
+ claudeHookSet.files,
945
+ args.apply,
946
+ );
883
947
  const commandResult = removeCommands(args.apply, args.forceCommands);
884
948
 
885
949
  // Wiki-side cleanup: the git pre-commit hook and the shell rc block init.mjs
@@ -946,8 +1010,15 @@ let codexExtSettings = { stripped: [] };
946
1010
  if (args.codex) {
947
1011
  const codexHooksDir = join(HOME, '.codex', 'hooks');
948
1012
  const codexSettings = join(HOME, '.codex', 'settings.json');
949
- codexHookResult = removeHookFiles(codexHooksDir, hookFiles, args.apply);
950
- codexSettingsResult = stripSettingsJson(codexSettings, codexHooksDir, hookMap, args.apply);
1013
+ const codexHookSet = resolveHookFileSet(PKG_ROOT, codexHooksDir);
1014
+ reportHookSetWarning(codexHookSet);
1015
+ codexHookResult = removeHookFiles(codexHooksDir, codexHookSet.files, args.apply);
1016
+ codexSettingsResult = stripSettingsJson(
1017
+ codexSettings,
1018
+ codexHooksDir,
1019
+ codexHookSet.files,
1020
+ args.apply,
1021
+ );
951
1022
  codexExtResult = removeExtensions('codex', args.apply, args.forceExtensions);
952
1023
  codexExtSettings = stripExtensionSettings(
953
1024
  codexSettings,
@@ -1105,3 +1176,10 @@ if (
1105
1176
  }
1106
1177
 
1107
1178
  console.log(lines.join('\n\n'));
1179
+
1180
+ // See resolveHookFileSet / reportHookSetWarning above (major D): neither the
1181
+ // package's own hooks.json nor a provenance record could identify what to
1182
+ // remove for at least one target. The report above already named what remains;
1183
+ // this is what tells an automated caller (or a script chaining onto this one)
1184
+ // that the run did not fully succeed, rather than exiting 0 on a silent skip.
1185
+ if (hardFailure) process.exit(1);