hypomnema 1.8.2 → 1.8.4
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -51
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/crystallize.md +14 -3
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/CONTRIBUTING.md +21 -7
- package/hooks/close-journal.mjs +128 -0
- package/hooks/hooks.json +1 -9
- package/hooks/hypo-session-start.mjs +194 -11
- package/hooks/hypo-shared.mjs +117 -39
- package/hooks/proposal-store.mjs +35 -1
- package/hooks/shared.json +9 -0
- package/package.json +2 -1
- package/scripts/doctor.mjs +43 -91
- package/scripts/init.mjs +54 -106
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +569 -79
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +150 -0
- package/scripts/lib/pkg-provenance.mjs +11 -0
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/template-schema-version.mjs +47 -0
- package/scripts/uninstall.mjs +130 -66
- package/scripts/upgrade.mjs +243 -145
- package/templates/hypo-config.md +1 -1
package/scripts/init.mjs
CHANGED
|
@@ -48,8 +48,9 @@ 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 {
|
|
53
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
53
54
|
import {
|
|
54
55
|
readPkgJson as readPkgJsonSafe,
|
|
55
56
|
writePkgJsonAtomic,
|
|
@@ -61,7 +62,7 @@ import { syncExtensions } from './lib/extensions.mjs';
|
|
|
61
62
|
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
62
63
|
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
63
64
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
64
|
-
import { resolvePluginChannel,
|
|
65
|
+
import { resolvePluginChannel, isHypomnemaInstallRoot } from './lib/plugin-detect.mjs';
|
|
65
66
|
|
|
66
67
|
const HOME = homedir();
|
|
67
68
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -367,90 +368,19 @@ function writeGitignore(hypoDir, dryRun) {
|
|
|
367
368
|
// ── hook installation ────────────────────────────────────────────────────────
|
|
368
369
|
|
|
369
370
|
function loadHookMap() {
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
371
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) owns the grammar AND the
|
|
372
|
+
// existence check now: every consumer that installs, refreshes, or removes a
|
|
373
|
+
// hook file reads through the same parser, and a name on either list that does
|
|
374
|
+
// not resolve to a real file under hooks/ fails this call before init writes
|
|
375
|
+
// anything at all (major C — a hook file dropped from the shipped package used
|
|
376
|
+
// to install a broken settings.json registration with nothing behind it).
|
|
377
|
+
const res = loadHookInventory(PKG_ROOT);
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
console.error(`Error: ${res.error}`);
|
|
377
380
|
console.error(PKG_INTEGRITY_HINT);
|
|
378
381
|
process.exit(1);
|
|
379
382
|
}
|
|
380
|
-
|
|
381
|
-
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
382
|
-
console.error('Error: hooks/hooks.json must be a JSON object');
|
|
383
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
384
|
-
process.exit(1);
|
|
385
|
-
}
|
|
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
|
-
);
|
|
383
|
+
return res.hookMap;
|
|
454
384
|
}
|
|
455
385
|
|
|
456
386
|
function installHooks(targetDir, dryRun) {
|
|
@@ -578,8 +508,9 @@ function readPkgVersionAt(root) {
|
|
|
578
508
|
}
|
|
579
509
|
}
|
|
580
510
|
|
|
581
|
-
// usablePkgRoot now
|
|
582
|
-
// upgrade.mjs's dualSkip provenance correction so
|
|
511
|
+
// usablePkgRoot / isHypomnemaInstallRoot now live in ./lib/plugin-detect.mjs
|
|
512
|
+
// (imported above), shared with upgrade.mjs's dualSkip provenance correction so
|
|
513
|
+
// both agree on what is real.
|
|
583
514
|
|
|
584
515
|
function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
|
|
585
516
|
const dest = pkgJsonPath();
|
|
@@ -759,12 +690,14 @@ function installPkgGitHook(dryRun) {
|
|
|
759
690
|
// ── wiki pre-commit hook ─────────────────────────────────────────────────────
|
|
760
691
|
//
|
|
761
692
|
// shellSingleQuote and wikiPreCommitContent live in lib/git-hooks-dir.mjs, not
|
|
762
|
-
// here: upgrade.mjs's
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
693
|
+
// here: upgrade.mjs's old-to-new-form migration and doctor.mjs's report both
|
|
694
|
+
// need to build/parse the exact same body this writer produces, so one copy is
|
|
695
|
+
// shared rather than three independent copies silently drifting apart.
|
|
696
|
+
// wikiPreCommitContent no longer takes an install root: the body
|
|
697
|
+
// it generates resolves that itself, at commit time, so there is nothing here
|
|
698
|
+
// for a plugin-channel version bump to make stale.
|
|
699
|
+
|
|
700
|
+
function installWikiPreCommitHook(hypoDir, dryRun, force, lintStrict) {
|
|
768
701
|
const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
|
|
769
702
|
if (!hooksDir) {
|
|
770
703
|
// no git repo — silently skip, as before; anything else is worth surfacing
|
|
@@ -772,7 +705,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
772
705
|
return;
|
|
773
706
|
}
|
|
774
707
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
775
|
-
const newContent = wikiPreCommitContent(
|
|
708
|
+
const newContent = wikiPreCommitContent(hypoDir, lintStrict);
|
|
776
709
|
|
|
777
710
|
// Before every branch below, including --force: a symlink here would send the
|
|
778
711
|
// write through to an arbitrary external file.
|
|
@@ -789,20 +722,36 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
789
722
|
log('skipped', `${hookPath} (pre-commit up to date)`);
|
|
790
723
|
return;
|
|
791
724
|
}
|
|
725
|
+
// Back this up first. The marker's presence says our block is IN the
|
|
726
|
+
// file; it says nothing about what else is, and this branch replaces the
|
|
727
|
+
// whole file either way. upgrade.mjs refuses exactly this shape (content
|
|
728
|
+
// outside the marker span) and tells the user to run
|
|
729
|
+
// `init --force-commands` — so without a backup here, following our own
|
|
730
|
+
// recovery advice is what destroys their hook. Measured by a codex
|
|
731
|
+
// reviewer 2026-09-11; the `force` branch below already backed up, this
|
|
732
|
+
// one did not, and `force` is not even consulted here.
|
|
733
|
+
const bakPath = uniqueBakPath(hookPath);
|
|
734
|
+
if (!bakPath) {
|
|
735
|
+
log('skipped', `${hookPath} (unsafe backup path) — not overwriting without a safe backup`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
792
738
|
if (!dryRun) {
|
|
739
|
+
writeFileSync(bakPath, existing);
|
|
793
740
|
writeFileSync(hookPath, newContent);
|
|
794
741
|
chmodSync(hookPath, 0o755);
|
|
795
742
|
}
|
|
796
|
-
log('merged', `${hookPath} (pre-commit updated)`);
|
|
743
|
+
log('merged', `${hookPath} (pre-commit updated, backup at ${basename(bakPath)})`);
|
|
797
744
|
} else if (force) {
|
|
798
745
|
// The .bak is a SECOND write to a DIFFERENT path, so the guard on
|
|
799
746
|
// hookPath above says nothing about it. Left unchecked, a pre-commit.bak
|
|
800
747
|
// symlink turns --force-commands into an overwrite of whatever it points
|
|
801
748
|
// at — deterministically, not as a race.
|
|
802
|
-
const bakPath = hookPath
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
749
|
+
const bakPath = uniqueBakPath(hookPath);
|
|
750
|
+
if (!bakPath) {
|
|
751
|
+
log(
|
|
752
|
+
'skipped',
|
|
753
|
+
`${hookPath} (unsafe backup path) — not force-overwriting without a safe backup`,
|
|
754
|
+
);
|
|
806
755
|
return;
|
|
807
756
|
}
|
|
808
757
|
if (!dryRun) {
|
|
@@ -810,7 +759,7 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
810
759
|
writeFileSync(hookPath, newContent);
|
|
811
760
|
chmodSync(hookPath, 0o755);
|
|
812
761
|
}
|
|
813
|
-
log('merged', `${hookPath} (force-overwritten, backup at
|
|
762
|
+
log('merged', `${hookPath} (force-overwritten, backup at ${basename(bakPath)})`);
|
|
814
763
|
} else {
|
|
815
764
|
log(
|
|
816
765
|
'skipped',
|
|
@@ -1040,7 +989,12 @@ function resolveDurableRoot() {
|
|
|
1040
989
|
if (!hypomnemaPluginEnabled) return PKG_ROOT;
|
|
1041
990
|
if (pluginChannel.root) return pluginChannel.root;
|
|
1042
991
|
const recorded = recordedPkgRoot();
|
|
1043
|
-
|
|
992
|
+
// isHypomnemaInstallRoot, not the weak usablePkgRoot: this value is about to be
|
|
993
|
+
// adopted as the durable root and re-recorded (see writePkgJson's `root` param
|
|
994
|
+
// below), so a recorded pointer that merely resolves a readable version, without
|
|
995
|
+
// actually being a Hypomnema package, must not be trusted here (see
|
|
996
|
+
// lib/plugin-detect.mjs's comment on the two predicates).
|
|
997
|
+
if (isHypomnemaInstallRoot(recorded)) return recorded;
|
|
1044
998
|
// pluginChannel.root is null here because the channel JUDGMENT FAILED (reason
|
|
1045
999
|
// 'registry-unreadable' or 'unresolved'), not because no plugin is installed —
|
|
1046
1000
|
// hypomnemaPluginEnabled is already true, and that case already returned
|
|
@@ -1321,13 +1275,7 @@ if (args.hooks) {
|
|
|
1321
1275
|
// any) is left exactly as-is, and channelUnresolvedNotice() above says what to
|
|
1322
1276
|
// do next.
|
|
1323
1277
|
if (!channelUnresolved) {
|
|
1324
|
-
installWikiPreCommitHook(
|
|
1325
|
-
args.hypoDir,
|
|
1326
|
-
args.dryRun,
|
|
1327
|
-
args.forceCommands,
|
|
1328
|
-
durableRoot,
|
|
1329
|
-
args.lintStrict,
|
|
1330
|
-
);
|
|
1278
|
+
installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands, args.lintStrict);
|
|
1331
1279
|
} else {
|
|
1332
1280
|
log(
|
|
1333
1281
|
'skipped',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// core-hooks.mjs - read the packaged hooks/hooks.json
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
|
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.
|
|
34
|
-
* `cfg`
|
|
35
|
-
* so a caller can discriminate read/parse
|
|
36
|
-
*
|
|
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
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|