hypomnema 1.7.4 → 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 (58) 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 +4 -4
  5. package/README.md +5 -5
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +1 -1
  8. package/commands/crystallize.md +3 -3
  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 +1 -1
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +8 -3
  23. package/docs/CONTRIBUTING.md +2 -1
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +3 -2
  26. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  27. package/hooks/hypo-compact-guard.mjs +5 -3
  28. package/hooks/hypo-cwd-change.mjs +8 -0
  29. package/hooks/hypo-personal-check.mjs +127 -67
  30. package/hooks/hypo-session-start.mjs +91 -10
  31. package/hooks/hypo-shared.mjs +241 -19
  32. package/hooks/version-check.mjs +44 -6
  33. package/package.json +9 -3
  34. package/scripts/capture.mjs +49 -17
  35. package/scripts/crystallize.mjs +20 -2092
  36. package/scripts/doctor.mjs +334 -14
  37. package/scripts/init.mjs +106 -81
  38. package/scripts/lib/crystallize-args.mjs +50 -0
  39. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  40. package/scripts/lib/crystallize-close-check.mjs +238 -0
  41. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  42. package/scripts/lib/crystallize-helpers.mjs +55 -0
  43. package/scripts/lib/extensions.mjs +175 -49
  44. package/scripts/lib/git-hooks-dir.mjs +88 -3
  45. package/scripts/lib/plugin-detect.mjs +176 -21
  46. package/scripts/lint.mjs +12 -6
  47. package/scripts/proposal.mjs +3 -3
  48. package/scripts/upgrade.mjs +243 -20
  49. package/skills/crystallize/SKILL.md +15 -13
  50. package/skills/graph/SKILL.md +3 -3
  51. package/skills/ingest/SKILL.md +2 -2
  52. package/skills/lint/SKILL.md +3 -3
  53. package/skills/query/SKILL.md +3 -3
  54. package/skills/verify/SKILL.md +3 -3
  55. package/templates/hypo-automation.md +59 -17
  56. package/templates/hypo-config.md +1 -1
  57. package/templates/hypo-guide.md +12 -9
  58. package/templates/hypo-help.md +1 -1
package/scripts/init.mjs CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  renameSync,
33
33
  unlinkSync,
34
34
  } from 'fs';
35
- import { join, basename, dirname, resolve } from 'path';
35
+ import { join, basename, dirname } from 'path';
36
36
  import { homedir } from 'os';
37
37
  import { execSync, spawnSync } from 'child_process';
38
38
  import { fileURLToPath } from 'url';
@@ -47,6 +47,7 @@ import {
47
47
  SHELL_MARKER_START,
48
48
  SHELL_MARKER_END,
49
49
  SHELL_FUNCTION_BODY,
50
+ wikiPreCommitContent,
50
51
  } from './lib/git-hooks-dir.mjs';
51
52
  import { readCoreHooksConfig } from './lib/core-hooks.mjs';
52
53
  import {
@@ -60,11 +61,7 @@ import { syncExtensions } from './lib/extensions.mjs';
60
61
  import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
61
62
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
62
63
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
63
- import {
64
- isHypomnemaPluginEnabled,
65
- resolveEnabledPluginRoot as resolveEnabledPluginRootShared,
66
- usablePkgRoot,
67
- } from './lib/plugin-detect.mjs';
64
+ import { resolvePluginChannel, usablePkgRoot } from './lib/plugin-detect.mjs';
68
65
 
69
66
  const HOME = homedir();
70
67
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -756,50 +753,12 @@ function installPkgGitHook(dryRun) {
756
753
  }
757
754
 
758
755
  // ── wiki pre-commit hook ─────────────────────────────────────────────────────
759
-
760
- // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
761
- function shellSingleQuote(p) {
762
- return `'${p.replace(/'/g, "'\\''")}'`;
763
- }
764
-
765
- // `root` is the DURABLE install root the hook should resolve hypo-pre-commit.mjs
766
- // (and, when opted in, lint.mjs) through — not necessarily PKG_ROOT. In a dual
767
- // install (a manual/npm init while the plugin is enabled) PKG_ROOT is the
768
- // manual/npm checkout the dual-install notice tells the user to uninstall;
769
- // embedding it here would leave the vault's git hook dangling the moment they
770
- // do, breaking every wiki commit. The caller passes the preserved plugin-owned
771
- // root instead (see `durableRoot`), so the hook points at the install that
772
- // actually persists.
773
756
  //
774
- // The block runs its steps sequentially rather than tail-calling `exit $?` on
775
- // the first one, so a second step (the opt-in --lint-strict gate below) can
776
- // run after the .hypoignore guard instead of being unreachable dead code.
777
- // `lintStrict` is baked into the generated shim at install time — the same
778
- // pattern already used for HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR in
779
- // install-git-hooks.mjs — so toggling it means re-running init with/without
780
- // `--lint-strict`, not editing the hook by hand.
781
- //
782
- // `hypoDir` MUST be absolutized before it is baked in. Git runs a pre-commit
783
- // hook with cwd set to the wiki's own working-tree root, so a relative
784
- // `--hypo-dir` (e.g. from `hypo init --hypo-dir=wiki` run from its parent) is
785
- // re-resolved AT COMMIT TIME against that root instead of the directory the
786
- // caller meant — `wiki` becomes `<wiki-root>/wiki`, a path that doesn't exist,
787
- // and lint.mjs falls through to its own default resolution (HYPO_DIR/
788
- // hypo-config.md scan) and may silently lint an unrelated vault. `worker`
789
- // needs no such treatment: `root` is already realpath'd upstream (see
790
- // `durableRoot` / PKG_ROOT resolution), so it's absolute at every call site.
791
- function wikiPreCommitContent(root, hypoDir, lintStrict) {
792
- const absHypoDir = resolve(hypoDir);
793
- const worker = join(root, 'hooks', 'hypo-pre-commit.mjs');
794
- const steps = [`node ${shellSingleQuote(worker)} || exit 1`];
795
- if (lintStrict) {
796
- const lintScript = join(root, 'scripts', 'lint.mjs');
797
- steps.push(
798
- `node ${shellSingleQuote(lintScript)} --hypo-dir=${shellSingleQuote(absHypoDir)} --strict || exit 1`,
799
- );
800
- }
801
- return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
802
- }
757
+ // shellSingleQuote and wikiPreCommitContent live in lib/git-hooks-dir.mjs, not
758
+ // here: upgrade.mjs's self-heal (repointing an existing hook at the current
759
+ // durable root) and doctor.mjs's drift check both need to build/parse the
760
+ // exact same body this writer produces, so one copy is shared rather than
761
+ // three independent copies silently drifting apart.
803
762
 
804
763
  function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
805
764
  const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
@@ -995,11 +954,16 @@ function gitSetup(hypoDir, remote, dryRun) {
995
954
 
996
955
  // ── first commit + push ──────────────────────────────────────────────────────
997
956
 
957
+ // Returns true only when THIS run actually created a commit. The
958
+ // channel-unresolved notice keys its "the commit this run just made was
959
+ // unguarded" sentence off that, so a repo that already had commits, a dry run,
960
+ // and a failed commit must all read as false — none of them produced an
961
+ // unguarded commit to warn about.
998
962
  function firstCommit(hypoDir, remote, dryRun) {
999
963
  const logR = git(hypoDir, ['log', '--oneline', '-1']);
1000
964
  if (logR.status === 0 && logR.stdout.trim()) {
1001
965
  log('skipped', 'first commit (repo already has commits)');
1002
- return;
966
+ return false;
1003
967
  }
1004
968
  const today = new Date().toISOString().slice(0, 10);
1005
969
  if (!dryRun) {
@@ -1007,7 +971,7 @@ function firstCommit(hypoDir, remote, dryRun) {
1007
971
  const commitR = git(hypoDir, ['commit', '-m', `chore: init hypomnema (${today})`]);
1008
972
  if (commitR.status !== 0) {
1009
973
  log('errors', 'first commit failed');
1010
- return;
974
+ return false;
1011
975
  }
1012
976
  if (remote) {
1013
977
  const actualOrigin = git(hypoDir, ['remote', 'get-url', 'origin']);
@@ -1018,6 +982,7 @@ function firstCommit(hypoDir, remote, dryRun) {
1018
982
  }
1019
983
  }
1020
984
  log('created', `first commit (${today})`);
985
+ return !dryRun;
1021
986
  }
1022
987
 
1023
988
  // ── main ─────────────────────────────────────────────────────────────────────
@@ -1026,7 +991,7 @@ const args = parseArgs(process.argv);
1026
991
 
1027
992
  // ── install channel ───────────────────────────────────────────────────────────
1028
993
  //
1029
- // A plugin-channel install already provides the 14 core hooks + slash commands
994
+ // A plugin-channel install already provides the core hooks in hooks/hooks.json plus the slash commands
1030
995
  // through the package's own hooks/hooks.json + commands/ (Claude Code auto-wires
1031
996
  // them). Copying the same hooks into ~/.claude/hooks and registering the same
1032
997
  // events in settings.json on top makes every core hook fire TWICE, the same
@@ -1035,22 +1000,15 @@ const args = parseArgs(process.argv);
1035
1000
  // `.claude/plugins/…` root); `hypomnemaPluginEnabled` catches the dual-install
1036
1001
  // variant (a manual/npm init.mjs run while the plugin is ALSO enabled — see
1037
1002
  // lib/plugin-detect.mjs, fail-open on any uncertainty). Either signal means
1038
- // Claude's core hook/command surface is plugin-managed already.
1039
- const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
1040
- const hypomnemaPluginEnabled =
1041
- !pluginMode && isHypomnemaPluginEnabled(join(HOME, '.claude', 'settings.json'));
1042
- const coreManagedByPlugin = pluginMode || hypomnemaPluginEnabled;
1043
-
1044
- // resolveEnabledPluginRoot lives in ./lib/plugin-detect.mjs (shared with
1045
- // upgrade.mjs's dualSkip provenance correction, imported above as
1046
- // resolveEnabledPluginRootShared). This wrapper binds it to init's own HOME-derived
1047
- // settings/registry paths so callers below read the same as before the move.
1048
- function resolveEnabledPluginRoot(settingsPath) {
1049
- return resolveEnabledPluginRootShared(
1050
- settingsPath,
1051
- join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
1052
- );
1053
- }
1003
+ // Claude's core hook/command surface is plugin-managed already. resolvePluginChannel
1004
+ // is the single place this judgment is made — doctor.mjs and upgrade.mjs call the
1005
+ // same function with their own PKG_ROOT so all three tools agree.
1006
+ const pluginChannel = resolvePluginChannel({
1007
+ pkgRoot: PKG_ROOT,
1008
+ settingsPath: join(HOME, '.claude', 'settings.json'),
1009
+ registryPath: join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
1010
+ });
1011
+ const { pluginMode, hypomnemaPluginEnabled, coreManagedByPlugin } = pluginChannel;
1054
1012
 
1055
1013
  // Non-mutating read of the recorded pkgRoot. resolveDurableRoot runs before init's
1056
1014
  // package validation and its "no writes before validation" guarantee, so it must
@@ -1076,13 +1034,55 @@ function recordedPkgRoot() {
1076
1034
  // only install.
1077
1035
  function resolveDurableRoot() {
1078
1036
  if (!hypomnemaPluginEnabled) return PKG_ROOT;
1079
- const pluginRoot = resolveEnabledPluginRoot(join(HOME, '.claude', 'settings.json'));
1080
- if (pluginRoot) return pluginRoot;
1037
+ if (pluginChannel.root) return pluginChannel.root;
1081
1038
  const recorded = recordedPkgRoot();
1082
1039
  if (usablePkgRoot(recorded)) return recorded;
1083
- return PKG_ROOT;
1040
+ // pluginChannel.root is null here because the channel JUDGMENT FAILED (reason
1041
+ // 'registry-unreadable' or 'unresolved'), not because no plugin is installed —
1042
+ // hypomnemaPluginEnabled is already true, and that case already returned
1043
+ // above. Falling back to PKG_ROOT would persist a GUESS as if it were the
1044
+ // confirmed durable root; in a dual install PKG_ROOT is the manual/npm
1045
+ // checkout the dual-install notice tells the user to remove, so that guess
1046
+ // dangles the moment they do. A judge returning null here means "cannot
1047
+ // positively resolve", never "resolved to nothing usable exists" — treating
1048
+ // it as the latter is exactly the failure this fix closes.
1049
+ // With no existing usable pointer to preserve either, leave the durable root
1050
+ // UNRESOLVED (null) instead of guessing — the caller below skips writing the
1051
+ // pointer and the pre-commit hook and prints recovery guidance rather than
1052
+ // confirm a guess.
1053
+ return null;
1084
1054
  }
1085
1055
  const durableRoot = resolveDurableRoot();
1056
+ // True only when the channel judgment itself failed and there was nothing usable
1057
+ // to fall back on (see resolveDurableRoot above) — never true for a genuinely
1058
+ // disabled/absent plugin, which resolves to PKG_ROOT and never reaches here.
1059
+ const channelUnresolved = durableRoot === null;
1060
+ let madeFirstCommit = false;
1061
+
1062
+ function channelUnresolvedNotice(madeFirstCommit) {
1063
+ // Step 9 (firstCommit) runs AFTER step 8b skipped the pre-commit hook install,
1064
+ // so a commit this run created went out unguarded, not just future ones. Key
1065
+ // this off what firstCommit actually did, never off the flags that merely make
1066
+ // it eligible: --dry-run writes nothing, a repo with existing commits skips,
1067
+ // and a failed commit produced none. Asserting "just made" in those cases
1068
+ // sends the reader looking for a commit that does not exist.
1069
+ const firstCommitCaveat = madeFirstCommit
1070
+ ? ' The initial commit this run just made in the vault was not checked by that guard ' +
1071
+ 'either: it landed before any pre-commit hook existed to check it.'
1072
+ : '';
1073
+ return (
1074
+ `⚠ Cannot positively resolve the enabled Hypomnema plugin's install root ` +
1075
+ `(${pluginChannel.rootReason} — checked ~/.claude/plugins/installed_plugins.json). ` +
1076
+ `Leaving the durable pointer UNSET rather than guessing the npm path at ${PKG_ROOT}: ` +
1077
+ `that path is what a dual install's own notice tells you to remove, and baking it in ` +
1078
+ `now would break every wiki commit the moment you do. hypo-pkg.json's pkgRoot and the ` +
1079
+ `vault's git pre-commit hook are both skipped this run, so this wiki has no pre-commit ` +
1080
+ `.hypoignore guard until this resolves.${firstCommitCaveat}\n` +
1081
+ ` → Fix: repair or refresh ~/.claude/plugins/installed_plugins.json (reinstall the ` +
1082
+ 'plugin, or run `/plugin marketplace update hypomnema` then `/reload-plugins`), then ' +
1083
+ 're-run `hypomnema init` to record the confirmed root.'
1084
+ );
1085
+ }
1086
1086
 
1087
1087
  // Validate hooks.json before any file writes so a bad package leaves no partial state
1088
1088
  const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
@@ -1206,7 +1206,20 @@ if (args.hooks) {
1206
1206
  // so this never clobbers a commands/extensions map written elsewhere.
1207
1207
  // Record hypo-pkg.json against the durable root (the plugin's real cache root in a
1208
1208
  // dual install, PKG_ROOT otherwise), the same root the wiki pre-commit hook uses.
1209
- writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {}, durableRoot);
1209
+ // A null durableRoot means the channel judgment failed with nothing usable to
1210
+ // preserve (see resolveDurableRoot) — skip the write rather than stamp a guess;
1211
+ // channelUnresolvedNotice() above explains why and what closes it. The
1212
+ // extensions sync below (step 4b) still runs unconditionally and can leave a
1213
+ // pkgRoot-less hypo-pkg.json on disk in this case; that is doctor.mjs's own
1214
+ // distinct reported state ("no pkgRoot field"), never a confirmed PKG_ROOT.
1215
+ if (!channelUnresolved) {
1216
+ writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {}, durableRoot);
1217
+ } else {
1218
+ log(
1219
+ 'skipped',
1220
+ 'hypo-pkg.json pkgRoot (plugin channel judgment failed — see notice above; re-run after fixing the registry)',
1221
+ );
1222
+ }
1210
1223
 
1211
1224
  // 4b. user extensions companion sync. Runs after
1212
1225
  // writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
@@ -1298,23 +1311,35 @@ if (args.hooks) {
1298
1311
 
1299
1312
  // 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8).
1300
1313
  // Point it at the durable install root, not PKG_ROOT, so a dual install does not
1301
- // embed a manual/npm path that dangles once the user uninstalls it.
1302
- installWikiPreCommitHook(
1303
- args.hypoDir,
1304
- args.dryRun,
1305
- args.forceCommands,
1306
- durableRoot,
1307
- args.lintStrict,
1308
- );
1314
+ // embed a manual/npm path that dangles once the user uninstalls it. A null
1315
+ // durableRoot means the channel judgment failed with nothing usable to fall
1316
+ // back on — skip installing the hook with a guessed root; an existing hook (if
1317
+ // any) is left exactly as-is, and channelUnresolvedNotice() above says what to
1318
+ // do next.
1319
+ if (!channelUnresolved) {
1320
+ installWikiPreCommitHook(
1321
+ args.hypoDir,
1322
+ args.dryRun,
1323
+ args.forceCommands,
1324
+ durableRoot,
1325
+ args.lintStrict,
1326
+ );
1327
+ } else {
1328
+ log(
1329
+ 'skipped',
1330
+ `${args.hypoDir} pre-commit hook (plugin channel judgment failed — see notice above)`,
1331
+ );
1332
+ }
1309
1333
 
1310
1334
  // 9. first commit + push (skip when cloned from remote — already has commits)
1311
1335
  if (args.gitInit && !args.fromRemote) {
1312
- firstCommit(args.hypoDir, args.gitRemote, args.dryRun);
1336
+ madeFirstCommit = firstCommit(args.hypoDir, args.gitRemote, args.dryRun);
1313
1337
  }
1314
1338
 
1315
1339
  // ── report ───────────────────────────────────────────────────────────────────
1316
1340
 
1317
1341
  const lines = [];
1342
+ if (channelUnresolved) lines.push(channelUnresolvedNotice(madeFirstCommit), '');
1318
1343
  if (results.created.length)
1319
1344
  lines.push(
1320
1345
  `✓ Created (${results.created.length}):\n${results.created.map((p) => ` ${p}`).join('\n')}`,
@@ -0,0 +1,50 @@
1
+ import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
2
+ import { isValidProjectName } from './project-create.mjs';
3
+
4
+ // ── arg parsing ──────────────────────────────────────────────────────────────
5
+
6
+ export function parseArgs(argv) {
7
+ const args = {
8
+ hypoDir: null,
9
+ minGroup: 2,
10
+ json: false,
11
+ checkSessionClose: false,
12
+ applySessionClose: false,
13
+ markSessionClosed: false,
14
+ logOnly: false,
15
+ sessionId: null,
16
+ payload: null,
17
+ force: false,
18
+ transcriptPath: null,
19
+ project: null,
20
+ };
21
+ for (const arg of argv.slice(2)) {
22
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
23
+ else if (arg.startsWith('--min-group=')) args.minGroup = parseInt(arg.slice(12), 10) || 2;
24
+ else if (arg === '--check-session-close') args.checkSessionClose = true;
25
+ else if (arg === '--apply-session-close') args.applySessionClose = true;
26
+ else if (arg === '--mark-session-closed') args.markSessionClosed = true;
27
+ else if (arg === '--log-only') args.logOnly = true;
28
+ else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
29
+ else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
30
+ else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
31
+ else if (arg.startsWith('--session-cwd=')) args.sessionCwd = expandHome(arg.slice(14));
32
+ else if (arg.startsWith('--project=')) args.project = arg.slice(10);
33
+ else if (arg === '--force') args.force = true;
34
+ else if (arg === '--json') args.json = true;
35
+ }
36
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
37
+ // --project=<slug> override (check/mark only). Validate the SYNTAX here so a
38
+ // traversal/charset attack (`--project=../x`) is rejected before any path is
39
+ // built from it — sessionCloseFileStatus(projectOverride) joins it directly.
40
+ // isValidProjectName is the SHARED validator (project-create.mjs), so the
41
+ // override accepts exactly the namespace createProject can scaffold. Existence
42
+ // (a real projects/<slug>/ directory) is checked in the run functions, where
43
+ // hypoDir is resolved and only the check/mark paths consume --project.
44
+ if (args.project != null && !isValidProjectName(args.project)) {
45
+ const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
46
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
47
+ process.exit(1);
48
+ }
49
+ return args;
50
+ }