hypomnema 1.7.3 → 1.8.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 (65) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1202 -0
  4. package/README.ko.md +7 -7
  5. package/README.md +7 -7
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +2 -2
  8. package/commands/crystallize.md +4 -4
  9. package/commands/doctor.md +15 -1
  10. package/commands/feedback.md +1 -1
  11. package/commands/graph.md +1 -1
  12. package/commands/ingest.md +1 -1
  13. package/commands/init.md +2 -2
  14. package/commands/lint.md +1 -1
  15. package/commands/query.md +1 -1
  16. package/commands/rename.md +1 -1
  17. package/commands/resume.md +1 -1
  18. package/commands/stats.md +1 -1
  19. package/commands/uninstall.md +17 -5
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +9 -4
  23. package/docs/CONTRIBUTING.md +15 -5
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +436 -0
  26. package/hooks/hooks.json +2 -1
  27. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  28. package/hooks/hypo-close-guard.mjs +24 -4
  29. package/hooks/hypo-compact-guard.mjs +5 -3
  30. package/hooks/hypo-cwd-change.mjs +8 -0
  31. package/hooks/hypo-personal-check.mjs +128 -68
  32. package/hooks/hypo-session-end.mjs +21 -2
  33. package/hooks/hypo-session-start.mjs +91 -10
  34. package/hooks/hypo-shared.mjs +498 -180
  35. package/hooks/version-check.mjs +44 -6
  36. package/package.json +10 -3
  37. package/scripts/capture.mjs +64 -21
  38. package/scripts/crystallize.mjs +20 -2044
  39. package/scripts/doctor.mjs +334 -14
  40. package/scripts/init.mjs +132 -92
  41. package/scripts/lib/crystallize-args.mjs +50 -0
  42. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  43. package/scripts/lib/crystallize-close-check.mjs +238 -0
  44. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  45. package/scripts/lib/crystallize-helpers.mjs +55 -0
  46. package/scripts/lib/design-history-stale.mjs +26 -7
  47. package/scripts/lib/extensions.mjs +235 -65
  48. package/scripts/lib/git-hooks-dir.mjs +214 -2
  49. package/scripts/lib/plugin-detect.mjs +176 -21
  50. package/scripts/lib/slug-resolver.mjs +181 -0
  51. package/scripts/lint.mjs +33 -23
  52. package/scripts/proposal.mjs +3 -3
  53. package/scripts/rename.mjs +38 -141
  54. package/scripts/uninstall.mjs +155 -58
  55. package/scripts/upgrade.mjs +243 -20
  56. package/skills/crystallize/SKILL.md +16 -14
  57. package/skills/graph/SKILL.md +3 -3
  58. package/skills/ingest/SKILL.md +2 -2
  59. package/skills/lint/SKILL.md +3 -3
  60. package/skills/query/SKILL.md +3 -3
  61. package/skills/verify/SKILL.md +3 -3
  62. package/templates/hypo-automation.md +59 -17
  63. package/templates/hypo-config.md +1 -1
  64. package/templates/hypo-guide.md +12 -9
  65. package/templates/hypo-help.md +1 -1
@@ -156,16 +156,54 @@ export function selectPluginVersion(plugins) {
156
156
  return typeof v === 'string' ? v : null;
157
157
  }
158
158
 
159
- /** Channel-specific one-line update instruction. */
159
+ /** The command that runs an upgrade apply, in the form the caller's channel can
160
+ * actually run. A plugin-only install has no `hypomnema` on PATH: that binary is
161
+ * the npm package's bin, while the plugin manifest ships commands only. And the
162
+ * slash command takes no `--apply` argument, so naming the flag there describes
163
+ * a call it does not make: it runs the check and appends --apply only after the
164
+ * operator confirms.
165
+ *
166
+ * Lives here because both hooks and scripts need it and the dependency only
167
+ * runs one way (scripts import hooks, never the reverse). This file is already
168
+ * in hooks.json's `shared` list, so it reaches every standalone install.
169
+ *
170
+ * Use UPGRADE_APPLY_EITHER when the target channel is genuinely unknown, e.g.
171
+ * an instruction about a DIFFERENT machine. */
172
+ export function upgradeApplyHint(isPluginChannel) {
173
+ return isPluginChannel
174
+ ? '`/hypo:upgrade` (confirm the apply step)'
175
+ : '`hypomnema upgrade --apply`';
176
+ }
177
+
178
+ export const UPGRADE_APPLY_EITHER =
179
+ '`hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install)';
180
+
181
+ /** Channel-specific one-line update instruction.
182
+ *
183
+ * Every channel ends with the same second step. Installing a release does NOT
184
+ * repoint the vault's git pre-commit hook: that hook holds an absolute path
185
+ * baked in at init time, and only `upgrade --apply` rewrites it. No hook can
186
+ * do it either (a hook must never invoke an apply path). Without this line the
187
+ * user updates, sees the new version string, and keeps committing through the
188
+ * install they had when they first ran init. */
160
189
  export function buildUpdateLine(channel, current, latest) {
161
190
  const head = `[Hypomnema] Update available! ${current} → ${latest}`;
191
+ // The repoint step has to be RUNNABLE on the channel it is printed for. A
192
+ // plugin-only install has no `hypomnema` on PATH (that binary is the npm
193
+ // package's bin; the plugin manifest ships commands only), so its form is the
194
+ // slash command. And the slash command takes no --apply argument: that
195
+ // command runs the check first and only appends --apply after the operator
196
+ // confirms, so naming the flag there would describe a call the command does
197
+ // not make. Both mistakes are the same class as naming a flag no script parses.
198
+ const repoint = (cmd) => `\n → then: ${cmd} (repoints the vault's git hook at the new install)`;
162
199
  if (channel === 'plugin') {
163
- return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins`;
164
- }
165
- if (channel === 'npm') {
166
- return `${head}\n → run: npm install -g hypomnema`;
200
+ return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins${repoint(upgradeApplyHint(true))}`;
167
201
  }
168
- return `${head}\n → npm i -g hypomnema (or /plugin marketplace update hypomnema && /reload-plugins)`;
202
+ // The only remaining displayed channel is npm: computeNotice drops 'unknown'
203
+ // before anything renders, and a dual install reports the channel of the hook
204
+ // root actually running, never a third "mixed" value. A branch for one would
205
+ // be code no user reaches.
206
+ return `${head}\n → run: npm install -g hypomnema${repoint(upgradeApplyHint(false))}`;
169
207
  }
170
208
 
171
209
  // ── cache freshness + notice decision (pure) ─────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,11 @@
28
28
  "scripts/verify.mjs",
29
29
  "scripts/weekly-report.mjs",
30
30
  "scripts/lib/core-hooks.mjs",
31
+ "scripts/lib/crystallize-args.mjs",
32
+ "scripts/lib/crystallize-close-apply.mjs",
33
+ "scripts/lib/crystallize-close-check.mjs",
34
+ "scripts/lib/crystallize-close-gate.mjs",
35
+ "scripts/lib/crystallize-helpers.mjs",
31
36
  "scripts/lib/design-history-stale.mjs",
32
37
  "scripts/lib/extensions.mjs",
33
38
  "scripts/lib/failure-type.mjs",
@@ -43,6 +48,7 @@
43
48
  "scripts/lib/project-create.mjs",
44
49
  "scripts/lib/rename-marker.mjs",
45
50
  "scripts/lib/schema-vocab.mjs",
51
+ "scripts/lib/slug-resolver.mjs",
46
52
  "scripts/lib/template-schema-version.mjs",
47
53
  "scripts/lib/wd-match.mjs",
48
54
  "scripts/lib/wikilink.mjs",
@@ -52,7 +58,8 @@
52
58
  "templates/",
53
59
  "docs/",
54
60
  ".claude-plugin/",
55
- "README.md"
61
+ "README.md",
62
+ "CHANGELOG.md"
56
63
  ],
57
64
  "keywords": [
58
65
  "wiki",
@@ -91,6 +98,6 @@
91
98
  "prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && node scripts/check-pack-surface.mjs && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
92
99
  },
93
100
  "devDependencies": {
94
- "prettier": "^3.8.3"
101
+ "prettier": "3.8.3"
95
102
  }
96
103
  }
@@ -32,6 +32,7 @@
32
32
  * acquired through the normal sync path, never by injecting a SHA directly.
33
33
  */
34
34
 
35
+ import { UPGRADE_APPLY_EITHER } from '../hooks/version-check.mjs';
35
36
  import {
36
37
  existsSync,
37
38
  readFileSync,
@@ -45,7 +46,8 @@ import {
45
46
  openSync,
46
47
  closeSync,
47
48
  statSync,
48
- chmodSync,
49
+ fstatSync,
50
+ fchmodSync,
49
51
  } from 'fs';
50
52
  import { randomBytes } from 'crypto';
51
53
  import { join, dirname, relative, sep } from 'path';
@@ -75,6 +77,8 @@ import {
75
77
  scanSettingsHooks,
76
78
  parseCapturableHookCommand,
77
79
  HOOK_EVENT_ALLOWLIST,
80
+ HOOK_INTERPRETER_EXT,
81
+ DEFAULT_HOOK_INTERPRETER,
78
82
  SKILL_ROOT_FILE,
79
83
  EXT_PREFIX,
80
84
  withSrcExecBits,
@@ -89,8 +93,9 @@ const HOME = homedir();
89
93
  const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
90
94
 
91
95
  // Captured types and their singular manifest `type` value. commands/agents install
92
- // as top-level `.md` files; hooks install as `.mjs` and additionally register a
93
- // settings.json entry (reverse of forward-sync); skills install as a DIRECTORY.
96
+ // as top-level `.md` files; hooks install as an interpreter-specific executable
97
+ // (.mjs/.py/.sh) and additionally register a settings.json entry
98
+ // (reverse of forward-sync); skills install as a DIRECTORY.
94
99
  const CAPTURE_TYPES = ['commands', 'agents', 'hooks', 'skills'];
95
100
  // The flat, single-file types. `TYPE_EXT` has no `skills` entry on purpose: a skill
96
101
  // has no install extension, and letting it through a flat helper would silently
@@ -100,7 +105,20 @@ const FLAT_TYPES = ['commands', 'agents', 'hooks'];
100
105
  // registration instead, and skills have their own directory scanner.
101
106
  const READDIR_TYPES = ['commands', 'agents'];
102
107
  const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook', skills: 'skill' };
103
- const TYPE_EXT = { commands: '.md', agents: '.md', hooks: '.mjs' };
108
+ // `TYPE_EXT` has no `hooks` entry either, for the same reason as skills: a hook's
109
+ // extension depends on its interpreter (HOOK_INTERPRETER_EXT), not its type.
110
+ // captureFileExt() is the one place that resolves it; every hook-touching call
111
+ // site below goes through that, not this map.
112
+ const TYPE_EXT = { commands: '.md', agents: '.md' };
113
+
114
+ /** The install/wiki-storage extension for one capture candidate. hooks vary by
115
+ * interpreter (mirrors buildHookCommand/parseCapturableHookCommand in
116
+ * extensions.mjs); the other flat types have one fixed extension. */
117
+ function captureFileExt(c) {
118
+ return c.type === 'hooks'
119
+ ? HOOK_INTERPRETER_EXT[c.interpreter ?? DEFAULT_HOOK_INTERPRETER]
120
+ : TYPE_EXT[c.type];
121
+ }
104
122
 
105
123
  // Ceilings for a captured skill subtree (skills-capture design §3). A hand-authored
106
124
  // skill is a handful of files; a vendored one (gstack: 14,311 files / 1.1GB, mostly
@@ -123,9 +141,14 @@ function pkgJsonPath() {
123
141
  * hooks/commands + already-synced hypo-ext-* copies), anything already owned
124
142
  * (a recorded SHA for its install path means the wiki already manages it), and
125
143
  * non-.md files. Symlink/non-regular is checked by the caller (needs an fs stat).
144
+ * `type` must be a flat readdir type with a single fixed extension (TYPE_EXT);
145
+ * hooks have no entry there (extension depends on interpreter) and are never
146
+ * routed through this function — scanCandidates only calls it for READDIR_TYPES.
126
147
  */
127
148
  export function isCaptureCandidate(type, file, recorded) {
128
- if (!file.endsWith(TYPE_EXT[type])) return { ok: false, reason: `not a ${TYPE_EXT[type]} file` };
149
+ const ext = TYPE_EXT[type];
150
+ if (!ext) return { ok: false, reason: `capture-by-readdir does not cover ${type}` };
151
+ if (!file.endsWith(ext)) return { ok: false, reason: `not a ${ext} file` };
129
152
  const lower = file.toLowerCase();
130
153
  if (lower === 'hypo' || lower.startsWith('hypo-')) {
131
154
  return { ok: false, reason: 'reserved hypo namespace' };
@@ -295,10 +318,14 @@ export function planSkillCapture({
295
318
  /**
296
319
  * Reverse-generate the sidecar manifest for a capture candidate. commands/agents
297
320
  * carry only `{ type, installName }`; hooks additionally carry the settings
298
- * registration fields `{ event, matcher?, timeout? }`. matcher/timeout are omitted
299
- * when absent (an empty-string matcher is already normalized to undefined by the
300
- * scanner) via conditional spread, so the in-memory object never carries an
301
- * `undefined`-valued key that would break the planCapture deep-equality check.
321
+ * registration fields `{ event, matcher?, timeout?, interpreter? }`. Every
322
+ * optional field is omitted when it would equal its default (an empty-string
323
+ * matcher is already normalized to undefined by the scanner; `node` is the
324
+ * default interpreter) via conditional spread, so the in-memory object never
325
+ * carries an `undefined`-valued key or a redundant explicit default, either of
326
+ * which would break the planCapture deep-equality check against a manifest
327
+ * captured before interpreter support landed (a `.mjs` hook's manifest must stay byte-for-byte
328
+ * identical, or every prior capture becomes a false conflict).
302
329
  */
303
330
  function buildWantManifest(c) {
304
331
  const manifest = { type: TYPE_SINGULAR[c.type], installName: c.stem };
@@ -306,6 +333,9 @@ function buildWantManifest(c) {
306
333
  manifest.event = c.event;
307
334
  if (c.matcher !== undefined && c.matcher !== '') manifest.matcher = c.matcher;
308
335
  if (c.timeout !== undefined) manifest.timeout = c.timeout;
336
+ if (c.interpreter !== undefined && c.interpreter !== DEFAULT_HOOK_INTERPRETER) {
337
+ manifest.interpreter = c.interpreter;
338
+ }
309
339
  }
310
340
  return manifest;
311
341
  }
@@ -437,8 +467,9 @@ function subsetOf(keys, allowed) {
437
467
  * canonical.
438
468
  *
439
469
  * A candidate must satisfy ALL of: strict command grammar
440
- * (`parseCapturableHookCommand`) resolving under `~/.claude/hooks/`; the resolved
441
- * `.mjs` is a real regular file (no symlink / non-regular); `type === 'command'`;
470
+ * (`parseCapturableHookCommand`, which also recovers the interpreter — node,
471
+ * python3, or bash) resolving under `~/.claude/hooks/`; the resolved
472
+ * file is a real regular file (no symlink / non-regular); `type === 'command'`;
442
473
  * the event is in `HOOK_EVENT_ALLOWLIST`; a preservable shape (F3); the case-folded
443
474
  * basename appears exactly once across all settings hooks (F6); and the stem is not
444
475
  * a core hook, not the reserved hypo namespace, and not already wiki-owned.
@@ -478,7 +509,7 @@ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasen
478
509
  skipped.push({ type: 'hooks', command: rec.command, reason: parsed.reason });
479
510
  continue;
480
511
  }
481
- const { stem, basename } = parsed;
512
+ const { stem, basename, interpreter } = parsed;
482
513
  const label = `hooks/${basename}`;
483
514
 
484
515
  // Duplicate first: a basename registered 2+ times is uniformly skipped so both
@@ -546,6 +577,7 @@ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasen
546
577
  event: rec.event,
547
578
  matcher: rec.matcher,
548
579
  timeout: rec.timeout,
580
+ interpreter,
549
581
  label,
550
582
  });
551
583
  }
@@ -589,17 +621,27 @@ function log(msg) {
589
621
  // so this still has to be a separate chmod). Omitted for a manifest write: that
590
622
  // content is JSON we generated, not a copy of something with a mode worth
591
623
  // keeping.
624
+ //
625
+ // The mode is set on the open FD (`fchmodSync`), before the FD is closed, the
626
+ // same ordering the forward writer (extensions.mjs's writeFreshAtomic) already
627
+ // uses. A pathname-based `chmodSync(tmp, ...)` run after `closeSync` reopens
628
+ // `tmp` by name, and a competing process racing this write could have already
629
+ // deleted `tmp` and planted a symlink at that name in the gap between close
630
+ // and chmod — `wx` only protects the file's creation, not everything after it.
631
+ // Chmod-ing the FD closes that window: it always addresses the file this
632
+ // process itself just created, never whatever a symlink at the same name
633
+ // might point to by the time the pathname is looked up again.
592
634
  function writeAtomic(dest, buf, srcMode) {
593
635
  const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
594
636
  const fd = openSync(tmp, 'wx');
595
637
  try {
596
638
  writeFileSync(fd, buf);
639
+ if (srcMode != null) {
640
+ fchmodSync(fd, withSrcExecBits(fstatSync(fd).mode, srcMode));
641
+ }
597
642
  } finally {
598
643
  closeSync(fd);
599
644
  }
600
- if (srcMode != null) {
601
- chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
602
- }
603
645
  try {
604
646
  renameSync(tmp, dest);
605
647
  } catch (err) {
@@ -982,7 +1024,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
982
1024
  captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created });
983
1025
  continue;
984
1026
  }
985
- const fileExt = TYPE_EXT[c.type];
1027
+ const fileExt = captureFileExt(c);
986
1028
  const wikiStem = `${EXT_PREFIX}${c.stem}`;
987
1029
  const typeDir = join(extDir, c.type);
988
1030
  const filePath = join(typeDir, `${wikiStem}${fileExt}`);
@@ -1013,11 +1055,12 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
1013
1055
  const plan = planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw });
1014
1056
 
1015
1057
  // installFile is the ORIGINAL name the far machine installs under (installName
1016
- // adoption); for hooks that is `<stem>.mjs`, for commands/agents `<stem>.md`.
1058
+ // adoption); for hooks that is `<stem>` plus its interpreter's extension
1059
+ // (`.mjs`/`.py`/`.sh`), for commands/agents `<stem>.md`.
1017
1060
  const installFile = `${c.stem}${fileExt}`;
1018
1061
  // Ownership keys sync must record for the adoption to count. Hooks track BOTH
1019
- // the `.mjs` and its `.manifest.json` sidecar under the install path (success
1020
- // criterion d); commands/agents track only the single install file.
1062
+ // the main file and its `.manifest.json` sidecar under the install path
1063
+ // (success criterion d); commands/agents track only the single install file.
1021
1064
  const requiredKeys =
1022
1065
  c.type === 'hooks'
1023
1066
  ? [`hooks/${installFile}`, `hooks/${c.stem}.manifest.json`]
@@ -1162,7 +1205,7 @@ function report(captured, skipped, failed, dryRun) {
1162
1205
  const dest =
1163
1206
  c.type === 'skills'
1164
1207
  ? `extensions/skills/${EXT_PREFIX}${c.stem}/`
1165
- : `extensions/${c.type}/${EXT_PREFIX}${c.stem}${TYPE_EXT[c.type]}`;
1208
+ : `extensions/${c.type}/${EXT_PREFIX}${c.stem}${captureFileExt(c)}`;
1166
1209
  log(` ${c.type}/${c.file} → ${dest}`);
1167
1210
  }
1168
1211
  }
@@ -1171,7 +1214,7 @@ function report(captured, skipped, failed, dryRun) {
1171
1214
  log(`Failed to adopt ${failed.length} (see sync warnings; wiki files rolled back).`);
1172
1215
  if (!dryRun && captured.some((c) => c.status === 'captured')) {
1173
1216
  log('');
1174
- log('Next: commit + push the wiki, then run `hypomnema upgrade --apply` on another machine.');
1217
+ log(`Next: commit + push the wiki, then run ${UPGRADE_APPLY_EITHER} on another machine.`);
1175
1218
  }
1176
1219
  }
1177
1220