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/upgrade.mjs
CHANGED
|
@@ -42,8 +42,9 @@ import { homedir } from 'os';
|
|
|
42
42
|
import { fileURLToPath } from 'url';
|
|
43
43
|
import { createHash } from 'crypto';
|
|
44
44
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
45
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
45
46
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
46
|
-
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
47
|
+
import { templateSchemaVersion, schemaVersionDeltas } from './lib/template-schema-version.mjs';
|
|
47
48
|
import {
|
|
48
49
|
readPkgJson as readPkgJsonSafe,
|
|
49
50
|
writePkgJsonAtomic,
|
|
@@ -54,13 +55,15 @@ import {
|
|
|
54
55
|
writeDualSkipProvenance,
|
|
55
56
|
} from './lib/pkg-json.mjs';
|
|
56
57
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
57
|
-
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
58
|
+
import { writeProvenanceSidecar, readProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
58
59
|
import { resolvePluginChannel } from './lib/plugin-detect.mjs';
|
|
59
60
|
import {
|
|
60
61
|
resolveGitHooksDir,
|
|
61
62
|
unsafeHookTargetReason,
|
|
62
63
|
parseWikiPreCommitRoot,
|
|
63
64
|
wikiPreCommitContent,
|
|
65
|
+
oldFormPreCommitContent,
|
|
66
|
+
uniqueBakPath,
|
|
64
67
|
} from './lib/git-hooks-dir.mjs';
|
|
65
68
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
66
69
|
|
|
@@ -132,94 +135,23 @@ function bumpType(installed, current) {
|
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
// ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
console.error(
|
|
147
|
-
process.exit(1);
|
|
148
|
-
}
|
|
149
|
-
if (
|
|
150
|
-
!_hookConfig.hooks ||
|
|
151
|
-
typeof _hookConfig.hooks !== 'object' ||
|
|
152
|
-
Array.isArray(_hookConfig.hooks)
|
|
153
|
-
) {
|
|
154
|
-
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
155
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
156
|
-
process.exit(1);
|
|
157
|
-
}
|
|
158
|
-
function _extractCommandFileName(command) {
|
|
159
|
-
if (typeof command !== 'string') return null;
|
|
160
|
-
const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
|
|
161
|
-
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
162
|
-
const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
|
|
163
|
-
return bare ? bare[1] : null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function _isHookFileName(file) {
|
|
167
|
-
return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function _isHookGroup(group) {
|
|
171
|
-
return (
|
|
172
|
-
group &&
|
|
173
|
-
typeof group === 'object' &&
|
|
174
|
-
!Array.isArray(group) &&
|
|
175
|
-
Array.isArray(group.hooks) &&
|
|
176
|
-
group.hooks.length > 0 &&
|
|
177
|
-
group.hooks.every(
|
|
178
|
-
(hook) =>
|
|
179
|
-
hook &&
|
|
180
|
-
typeof hook === 'object' &&
|
|
181
|
-
!Array.isArray(hook) &&
|
|
182
|
-
hook.type === 'command' &&
|
|
183
|
-
_extractCommandFileName(hook.command),
|
|
184
|
-
)
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
189
|
-
function _extractFileNames(groups) {
|
|
190
|
-
return groups.flatMap((group) => {
|
|
191
|
-
if (typeof group === 'string') return [group.trim()];
|
|
192
|
-
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
|
|
197
|
-
const valid =
|
|
198
|
-
Array.isArray(groups) &&
|
|
199
|
-
groups.length > 0 &&
|
|
200
|
-
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
201
|
-
_extractFileNames(groups).length > 0;
|
|
202
|
-
if (!valid) {
|
|
203
|
-
console.error(
|
|
204
|
-
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
205
|
-
);
|
|
206
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
207
|
-
process.exit(1);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (
|
|
211
|
-
_hookConfig.shared !== undefined &&
|
|
212
|
-
(!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
|
|
213
|
-
) {
|
|
214
|
-
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
138
|
+
//
|
|
139
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
|
|
140
|
+
// hooks.json/shared.json consumer reads through — smoke-plugin, init, doctor,
|
|
141
|
+
// and uninstall all call the same function, so a command shape one of them
|
|
142
|
+
// would accept and another would silently misread cannot exist anymore. It also
|
|
143
|
+
// existence-checks every named file against THIS package's own hooks/ before
|
|
144
|
+
// returning ok:true, so a corrupt or incomplete package (a hook file dropped
|
|
145
|
+
// from `package.json`'s `files` allowlist) fails here, at startup, instead of
|
|
146
|
+
// upgrade reporting some files "src-missing" while still applying the rest.
|
|
147
|
+
const _hookInventory = loadHookInventory(PKG_ROOT);
|
|
148
|
+
if (!_hookInventory.ok) {
|
|
149
|
+
console.error(`Error: ${_hookInventory.error}`);
|
|
215
150
|
console.error(PKG_INTEGRITY_HINT);
|
|
216
151
|
process.exit(1);
|
|
217
152
|
}
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
|
|
221
|
-
);
|
|
222
|
-
const SHARED_FILES = _hookConfig.shared ?? [];
|
|
153
|
+
const HOOK_MAP = _hookInventory.hookMap;
|
|
154
|
+
const SHARED_FILES = _hookInventory.shared;
|
|
223
155
|
|
|
224
156
|
// ── checks ───────────────────────────────────────────────────────────────────
|
|
225
157
|
|
|
@@ -555,17 +487,42 @@ function applyGitignoreMigration(result) {
|
|
|
555
487
|
return appended;
|
|
556
488
|
}
|
|
557
489
|
|
|
558
|
-
// ── wiki pre-commit hook:
|
|
490
|
+
// ── wiki pre-commit hook: migrate the version-pinned form to the runtime
|
|
491
|
+
// resolver ───────────────────────────────────────────────────────
|
|
492
|
+
//
|
|
493
|
+
// init.mjs used to bake an absolute install root into the vault's pre-commit
|
|
494
|
+
// hook (see the OLD form documented above PRE_COMMIT_WORKER_LINE in
|
|
495
|
+
// lib/git-hooks-dir.mjs). A plugin-channel upgrade moves PKG_ROOT to a new
|
|
496
|
+
// version directory every release, but nothing ever re-ran init afterward, so
|
|
497
|
+
// the hook kept calling whatever release happened to be current the day
|
|
498
|
+
// /hypo:init last ran — silently, since the entry-point file the hook calls is
|
|
499
|
+
// often byte-identical across versions while the module graph it pulls in
|
|
500
|
+
// from its own directory is not. wikiPreCommitContent() no longer bakes a
|
|
501
|
+
// root in at all: the hook resolves it itself, at commit time. So the fix
|
|
502
|
+
// here is not "repoint the baked root at the current install" (there is
|
|
503
|
+
// nothing left to repoint once a hook is on the new form) — it is "rewrite an
|
|
504
|
+
// OLD-form hook onto the new form", once, unconditionally. That migration
|
|
505
|
+
// does not need to know the active install root, which is why it is safe even
|
|
506
|
+
// when the registry cannot positively resolve one right now (see
|
|
507
|
+
// wikiPreCommitNeedsMigration below): the resolver embedded in the rewritten
|
|
508
|
+
// hook will look that up itself, fresh, at every future commit.
|
|
509
|
+
//
|
|
510
|
+
// Read-only: locates the hook (if any) and, only when it carries our marker in
|
|
511
|
+
// a shape isOwnedWikiPreCommitBody() recognizes (old OR new — see
|
|
512
|
+
// lib/git-hooks-dir.mjs), what parseWikiPreCommitRoot() read back from it.
|
|
559
513
|
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
514
|
+
// Returns `null` when there is nothing of ours to report (no hook, or a hook
|
|
515
|
+
// with no Hypomnema marker at all: some other tool's or the user's own).
|
|
516
|
+
// Returns `{ hookPath, unrecognized: true, root: null }` when our marker IS
|
|
517
|
+
// present but parseWikiPreCommitRoot could not read a body it recognizes: a
|
|
518
|
+
// hand-edited or corrupted hook, or (before this fix) a hook whose OLD-form
|
|
519
|
+
// worker line named an install root that no longer exists. That case used to
|
|
520
|
+
// collapse into the same `null` a genuinely foreign hook gets, which made
|
|
521
|
+
// upgrade.mjs's report skip the "Wiki pre-commit" line entirely and left
|
|
522
|
+
// doctor.mjs reporting the marker-present pass with no root warning beside
|
|
523
|
+
// it: a broken hook (MODULE_NOT_FOUND on every commit) reads as installed
|
|
524
|
+
// and healthy either way. `root: null` here means the same thing it does in
|
|
525
|
+
// applyWikiPreCommitRoot's caller: never attempt the migration write.
|
|
569
526
|
function checkWikiPreCommitRoot(hypoDir) {
|
|
570
527
|
const resolved = resolveGitHooksDir(hypoDir);
|
|
571
528
|
if (!resolved.ok || !resolved.owned) return null;
|
|
@@ -578,50 +535,105 @@ function checkWikiPreCommitRoot(hypoDir) {
|
|
|
578
535
|
return null;
|
|
579
536
|
}
|
|
580
537
|
const parsed = parseWikiPreCommitRoot(content);
|
|
581
|
-
if (!parsed.ok)
|
|
538
|
+
if (!parsed.ok) {
|
|
539
|
+
return parsed.hasMarker ? { hookPath, unrecognized: true, root: null } : null;
|
|
540
|
+
}
|
|
582
541
|
return { hookPath, root: parsed.root, lintStrict: parsed.lintStrict, hypoDir: parsed.hypoDir };
|
|
583
542
|
}
|
|
584
543
|
|
|
585
|
-
// Rewrites
|
|
586
|
-
// uses so the regenerated hook is byte-identical to
|
|
587
|
-
// produce — the --lint-strict step count
|
|
588
|
-
// exactly, never collapsed to one step, or the
|
|
589
|
-
// The --hypo-dir baked into a --lint-strict hook
|
|
590
|
-
// (what parseWikiPreCommitRoot read back), never
|
|
591
|
-
// --hypo-dir: upgrade can run against a vault with a
|
|
592
|
-
// the one init baked in, and substituting the
|
|
593
|
-
// silently repoint the lint gate at an unrelated
|
|
594
|
-
// embedded value is refused rather than trusted
|
|
595
|
-
// re-resolves it against upgrade's own cwd, which is
|
|
596
|
-
// value there was meant to mean), leaving the hook
|
|
544
|
+
// Rewrites an OLD-form hook onto the runtime-resolving form, reusing the exact
|
|
545
|
+
// writer init.mjs itself uses so the regenerated hook is byte-identical to
|
|
546
|
+
// what a fresh init would produce today — the --lint-strict step count
|
|
547
|
+
// (info.lintStrict) is preserved exactly, never collapsed to one step, or the
|
|
548
|
+
// lint gate goes silently dark. The --hypo-dir baked into a --lint-strict hook
|
|
549
|
+
// is preserved from `info` (what parseWikiPreCommitRoot read back), never
|
|
550
|
+
// taken from this run's own --hypo-dir: upgrade can run against a vault with a
|
|
551
|
+
// different --hypo-dir than the one init baked in, and substituting the
|
|
552
|
+
// current run's value would silently repoint the lint gate at an unrelated
|
|
553
|
+
// directory. A non-absolute embedded value is refused rather than trusted
|
|
554
|
+
// (wikiPreCommitContent() re-resolves it against upgrade's own cwd, which is
|
|
555
|
+
// never what a relative value there was meant to mean), leaving the hook
|
|
556
|
+
// untouched for that run.
|
|
597
557
|
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
558
|
+
// `wantRoot` is accepted for call-site compatibility (the caller still
|
|
559
|
+
// computes it for the drift report and JSON output) but is otherwise unused
|
|
560
|
+
// here: the new form never bakes a root, so there is nothing to substitute it
|
|
561
|
+
// into. This only migrates the FORM. If the vault itself has actually moved,
|
|
562
|
+
// the embedded --hypo-dir is wrong too and no amount of --apply fixes that —
|
|
563
|
+
// only re-running `hypomnema init` rewrites --hypo-dir.
|
|
601
564
|
//
|
|
602
565
|
// Returns `{ ok: true }` on a successful rewrite, or `{ ok: false, reason }`
|
|
603
566
|
// naming why the write was refused or failed, so the caller can surface that
|
|
604
567
|
// reason instead of silently reporting "unchanged".
|
|
605
|
-
function applyWikiPreCommitRoot(info
|
|
568
|
+
function applyWikiPreCommitRoot(info) {
|
|
606
569
|
const unsafe = unsafeHookTargetReason(info.hookPath);
|
|
607
570
|
if (unsafe) return { ok: false, reason: unsafe };
|
|
608
571
|
if (info.lintStrict && !isAbsolute(info.hypoDir)) {
|
|
609
572
|
return {
|
|
610
573
|
ok: false,
|
|
611
|
-
reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to
|
|
574
|
+
reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to migrate`,
|
|
612
575
|
};
|
|
613
576
|
}
|
|
614
577
|
// hypoDir only affects output when lintStrict is true (see
|
|
615
578
|
// wikiPreCommitContent); the fallback here never reaches the written file.
|
|
616
579
|
const hypoDir = info.lintStrict ? info.hypoDir : '.';
|
|
617
|
-
|
|
580
|
+
|
|
581
|
+
// Require the file ON DISK to already be, byte-for-byte, the exact OLD-form
|
|
582
|
+
// shape these SAME values (info.root, hypoDir, info.lintStrict) reconstruct.
|
|
583
|
+
// isOwnedWikiPreCommitBody upstream only validated the SPAN between the
|
|
584
|
+
// markers; it says nothing about content before the start marker or after
|
|
585
|
+
// the end one. A hand-crafted file pairing a forged-but-valid span (an
|
|
586
|
+
// old-form worker line naming a root that does not currently exist — exactly
|
|
587
|
+
// what isRewritableOldFormInstallRoot's "gone root" branch accepts) with real
|
|
588
|
+
// content outside that span would otherwise be silently discarded by the
|
|
589
|
+
// whole-file write below (codex reproduction, 2026-09-11). A legitimate
|
|
590
|
+
// install this codebase ever wrote IS exactly this reconstruction — nothing
|
|
591
|
+
// outside the span but the fixed shebang line — so this never refuses a real
|
|
592
|
+
// migration; see oldFormPreCommitContent's comment in lib/git-hooks-dir.mjs.
|
|
593
|
+
let currentContent;
|
|
594
|
+
try {
|
|
595
|
+
currentContent = readFileSync(info.hookPath, 'utf-8');
|
|
596
|
+
} catch (e) {
|
|
597
|
+
return {
|
|
598
|
+
ok: false,
|
|
599
|
+
reason: `cannot read hook to verify before migrating (${e.code || e.message})`,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const expectedOld = oldFormPreCommitContent(info.root, hypoDir, info.lintStrict);
|
|
603
|
+
if (currentContent !== expectedOld) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
reason:
|
|
607
|
+
'hook file has content outside the managed marker span, or does not exactly match ' +
|
|
608
|
+
'the expected legacy shape — refusing to overwrite the whole file. Re-run ' +
|
|
609
|
+
"`hypomnema init --force-commands` (or the plugin's `/hypo:init --force-commands`) " +
|
|
610
|
+
'to reinstall the guard by hand',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const newContent = wikiPreCommitContent(hypoDir, info.lintStrict);
|
|
615
|
+
|
|
616
|
+
// Safety-net backup of the exact bytes about to be replaced, mirroring
|
|
617
|
+
// init.mjs's --force-commands convention (a .bak beside the hook). Guarded
|
|
618
|
+
// through the same symlink/regular-file check as the hook itself: a
|
|
619
|
+
// pre-existing `.bak` (or `.bak.N`) that is a symlink or non-regular file
|
|
620
|
+
// must not be written through, matching init's own bak-write guard.
|
|
621
|
+
const bakPath = uniqueBakPath(info.hookPath);
|
|
622
|
+
if (!bakPath) {
|
|
623
|
+
return {
|
|
624
|
+
ok: false,
|
|
625
|
+
reason: `refusing to migrate without a safe backup path (a backup name is a symlink or not a regular file)`,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
writeFileSync(bakPath, currentContent);
|
|
629
|
+
|
|
618
630
|
// A REFUSAL (above) is a soft skip, matching init's contract that an unsafe
|
|
619
631
|
// or unresolvable target is logged and stepped over. A real I/O failure is
|
|
620
632
|
// not that: every other apply write in this file lets the exception through,
|
|
621
633
|
// and init does the same, so swallowing EACCES here would make this one
|
|
622
|
-
//
|
|
623
|
-
// the wrong thing: a chmod that fails AFTER the write says "still
|
|
624
|
-
//
|
|
634
|
+
// migration quieter than the rest of --apply for no reason. It also reported
|
|
635
|
+
// the wrong thing: a chmod that fails AFTER the write says "still on the old
|
|
636
|
+
// form" while the file already holds the new one.
|
|
625
637
|
writeFileSync(info.hookPath, newContent);
|
|
626
638
|
chmodSync(info.hookPath, 0o755);
|
|
627
639
|
return { ok: true };
|
|
@@ -1080,10 +1092,18 @@ const dualSkipWouldCorrect =
|
|
|
1080
1092
|
// dual-install notice tells the user to remove.
|
|
1081
1093
|
const wikiHookWantRoot = hypomnemaPluginEnabled ? pluginRegistryRoot : PKG_ROOT;
|
|
1082
1094
|
const wikiPreCommitInfo = checkWikiPreCommitRoot(args.hypoDir);
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1095
|
+
// `wikiPreCommitInfo.root` is non-null only for a hook still on the
|
|
1096
|
+
// OLD, version-pinned form (see parseWikiPreCommitRoot in
|
|
1097
|
+
// lib/git-hooks-dir.mjs) — the NEW, runtime-resolving form never bakes one in,
|
|
1098
|
+
// so `root === null` there means "nothing to migrate", not "unresolved".
|
|
1099
|
+
// Migrating does not need wikiHookWantRoot at all: unlike the old repoint
|
|
1100
|
+
// (which had to pick the CORRECT root to bake in, and had to stay silent when
|
|
1101
|
+
// it could not positively resolve one — see the dual-install preserve case
|
|
1102
|
+
// below), rewriting onto the resolver form is always a strict improvement,
|
|
1103
|
+
// even when this run cannot resolve the active install itself, because the
|
|
1104
|
+
// rewritten hook resolves it fresh at every future commit instead of baking
|
|
1105
|
+
// in a guess now.
|
|
1106
|
+
const wikiPreCommitDrift = wikiPreCommitInfo !== null && wikiPreCommitInfo.root !== null;
|
|
1087
1107
|
const commands = checkCommands();
|
|
1088
1108
|
const oldHookRefs = checkOldHookNames(claudeSettingsPath);
|
|
1089
1109
|
const hypoignore = checkHypoignore(args.hypoDir);
|
|
@@ -1209,28 +1229,79 @@ let appliedExtensionsCodex = null;
|
|
|
1209
1229
|
if (args.apply) {
|
|
1210
1230
|
// Downgrade guard: an `--apply` from an OLDER package than the
|
|
1211
1231
|
// active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
|
|
1212
|
-
// rewrite
|
|
1232
|
+
// rewrite the version record to the older one. Refuse before the first mutation.
|
|
1213
1233
|
// A dev workspace re-running its own --apply (incl. the post-commit sync hook)
|
|
1214
1234
|
// is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
|
|
1215
|
-
|
|
1216
|
-
|
|
1235
|
+
//
|
|
1236
|
+
// Scoped to the two branches below that actually write a version-bearing
|
|
1237
|
+
// artifact this run does not own the comparison baseline for, and each
|
|
1238
|
+
// reads the baseline THAT branch actually writes, not a shared one:
|
|
1239
|
+
// - managesClaudeCore: applyHookFiles + writeProvenanceSidecar write this
|
|
1240
|
+
// run's own hooks and pkgVersion over the Claude-core hooks dir, whose
|
|
1241
|
+
// version of record is hypo-pkg.json (pkgJsonPath()) — the artifact
|
|
1242
|
+
// applyCommands below stamps. An older incoming version there really
|
|
1243
|
+
// would overwrite newer hooks.
|
|
1244
|
+
// - args.codex: reached even when managesClaudeCore is false (it is a
|
|
1245
|
+
// sibling branch, not nested under it — see :1383 below), and inside it
|
|
1246
|
+
// applyHookFiles(hooksCodex, ...) + writeProvenanceSidecar(codexHooksDir,
|
|
1247
|
+
// ..., readVersionAtRoot(PKG_ROOT), ...) copy codex hooks and stamp a
|
|
1248
|
+
// version into codexHooksDir's OWN `.hypo-provenance.json` sidecar, a
|
|
1249
|
+
// file distinct from hypo-pkg.json. A prior fix compared the codex
|
|
1250
|
+
// branch against hypo-pkg.json anyway (the Claude plugin's pointer,
|
|
1251
|
+
// unrelated to what codex hooks record their own version in) and so
|
|
1252
|
+
// never observed a downgrade there: in a dual install hypo-pkg.json
|
|
1253
|
+
// stays plugin-owned and un-bumped by a codex apply, so the "downgrade"
|
|
1254
|
+
// that guard could see was really just the plugin's untouched pointer,
|
|
1255
|
+
// leaving every SECOND `--apply --codex` refused forever (the codex
|
|
1256
|
+
// sidecar it should have compared against had already moved on).
|
|
1257
|
+
// Reading each branch's own artifact is the fix.
|
|
1258
|
+
// A dualSkip run with neither condition true (the plain `--apply` repeat
|
|
1259
|
+
// in a dual install, no `--codex`) copies no hook file and never writes its
|
|
1260
|
+
// own version: writeDualSkipProvenance stamps the REGISTRY's version, not
|
|
1261
|
+
// PKG_ROOT's, so there is nothing here for the guard to protect.
|
|
1262
|
+
if ((managesClaudeCore || args.codex) && !args.allowDowngrade) {
|
|
1217
1263
|
let _incomingVersion = null;
|
|
1218
1264
|
try {
|
|
1219
1265
|
_incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
1220
1266
|
} catch {
|
|
1221
1267
|
/* unreadable own package.json — cannot prove a downgrade, allow */
|
|
1222
1268
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1269
|
+
// `active` is { pkgRoot, pkgVersion } from whichever baseline the branch
|
|
1270
|
+
// actually writes to. `label` only feeds the error message.
|
|
1271
|
+
const refuseIfDowngrade = (active) => {
|
|
1272
|
+
if (
|
|
1273
|
+
active &&
|
|
1274
|
+
active.pkgRoot &&
|
|
1275
|
+
active.pkgVersion &&
|
|
1276
|
+
_incomingVersion &&
|
|
1277
|
+
classifyInstall(
|
|
1278
|
+
{ pkgRoot: PKG_ROOT, version: _incomingVersion },
|
|
1279
|
+
{ pkgRoot: active.pkgRoot, version: active.pkgVersion },
|
|
1280
|
+
) === 'downgrade'
|
|
1281
|
+
) {
|
|
1282
|
+
console.error(
|
|
1283
|
+
downgradeGuardMessage(_incomingVersion, active.pkgVersion, 'upgrade --apply'),
|
|
1284
|
+
);
|
|
1285
|
+
process.exit(2);
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
if (managesClaudeCore) {
|
|
1289
|
+
refuseIfDowngrade(readPkgJsonSafe(pkgJsonPath()));
|
|
1290
|
+
}
|
|
1291
|
+
if (args.codex) {
|
|
1292
|
+
// readProvenanceSidecar returns null on both "no sidecar yet" (first-
|
|
1293
|
+
// ever `--apply --codex`, nothing to downgrade from) and "sidecar
|
|
1294
|
+
// exists but fails to parse" — deliberately treated the same as
|
|
1295
|
+
// absent, fail-open, rather than distinguished: a corrupt sidecar
|
|
1296
|
+
// carries no more of a trustworthy baseline than a missing one, and
|
|
1297
|
+
// the write immediately below this guard (writeProvenanceSidecar at
|
|
1298
|
+
// :~1419) heals it either way. Its {pkgRoot, pkgVersion} shape matches
|
|
1299
|
+
// what refuseIfDowngrade expects, unlike its `hypoSharedSha256`/
|
|
1300
|
+
// `copiedAt` fields, which classifyInstall never reads.
|
|
1301
|
+
const _codexActive = readProvenanceSidecar(codexHooksDir);
|
|
1302
|
+
refuseIfDowngrade(
|
|
1303
|
+
_codexActive && { pkgRoot: _codexActive.pkgRoot, pkgVersion: _codexActive.pkgVersion },
|
|
1304
|
+
);
|
|
1234
1305
|
}
|
|
1235
1306
|
}
|
|
1236
1307
|
// Migration report is vault-side (writes into the Hypomnema root) and applies
|
|
@@ -1350,7 +1421,7 @@ if (args.apply) {
|
|
|
1350
1421
|
// wikiHookWantRoot is null only when dualSkip cannot positively resolve the
|
|
1351
1422
|
// registry (preserve case), and wikiPreCommitDrift is already false then.
|
|
1352
1423
|
if (wikiPreCommitDrift) {
|
|
1353
|
-
const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo
|
|
1424
|
+
const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo);
|
|
1354
1425
|
appliedWikiPreCommitRoot = wikiPreCommitResult.ok;
|
|
1355
1426
|
if (!wikiPreCommitResult.ok) wikiPreCommitApplyFailReason = wikiPreCommitResult.reason;
|
|
1356
1427
|
}
|
|
@@ -1458,8 +1529,15 @@ if (args.json) {
|
|
|
1458
1529
|
wikiPreCommitRoot: wikiPreCommitInfo
|
|
1459
1530
|
? {
|
|
1460
1531
|
current: wikiPreCommitInfo.root,
|
|
1532
|
+
// Not "where the hook will point": the migrated body resolves the
|
|
1533
|
+
// install itself at commit time. This is what THIS run resolved the
|
|
1534
|
+
// active install to, kept as diagnostic output.
|
|
1461
1535
|
wantRoot: wikiHookWantRoot,
|
|
1462
1536
|
drift: wikiPreCommitDrift,
|
|
1537
|
+
// true only for "marker present, body unreadable" (see
|
|
1538
|
+
// checkWikiPreCommitRoot above). Absent/false for both a clean
|
|
1539
|
+
// new-form hook and an old-form one this run can still parse.
|
|
1540
|
+
unrecognized: wikiPreCommitInfo.unrecognized === true,
|
|
1463
1541
|
}
|
|
1464
1542
|
: null,
|
|
1465
1543
|
extensions: extCheck,
|
|
@@ -1596,6 +1674,12 @@ if (schema.bump === 'none') {
|
|
|
1596
1674
|
lines.push(
|
|
1597
1675
|
`⚠ SCHEMA version ${schema.installed} → ${schema.current} [minor update — review and update SCHEMA.md manually]`,
|
|
1598
1676
|
);
|
|
1677
|
+
// Name what actually changed in the versions between, when this map knows.
|
|
1678
|
+
// A version this map has no entry for (installed predates the map, or a
|
|
1679
|
+
// future bump forgot to add one) leaves the notice as it was above.
|
|
1680
|
+
for (const delta of schemaVersionDeltas(schema.installed, schema.current)) {
|
|
1681
|
+
lines.push(` - ${delta}`);
|
|
1682
|
+
}
|
|
1599
1683
|
}
|
|
1600
1684
|
|
|
1601
1685
|
// hypo-guide.md version stamp. Visibility only — a drift warning,
|
|
@@ -1617,7 +1701,7 @@ if (guide.bump === 'none') {
|
|
|
1617
1701
|
// (guideDrift) — the message must be actionable, not "cannot compare",
|
|
1618
1702
|
// since a pre-versioning copy silently reporting "up to date" was the bug.
|
|
1619
1703
|
lines.push(
|
|
1620
|
-
`⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current}) — review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it`,
|
|
1704
|
+
`⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current}) — no stamp means there is no base version to diff a changelog from, so review templates/hypo-guide.md and update your copy manually; --apply does not overwrite it`,
|
|
1621
1705
|
);
|
|
1622
1706
|
} else {
|
|
1623
1707
|
lines.push(
|
|
@@ -1735,23 +1819,37 @@ if (dualSkip && dualSkipCorrected) {
|
|
|
1735
1819
|
if (wikiPreCommitInfo && wikiPreCommitDrift) {
|
|
1736
1820
|
if (appliedWikiPreCommitRoot) {
|
|
1737
1821
|
lines.push(
|
|
1738
|
-
`✓ Wiki pre-commit hook
|
|
1822
|
+
`✓ Wiki pre-commit hook migrated off the baked-in root ${wikiPreCommitInfo.root}` +
|
|
1823
|
+
` — it now resolves the install at commit time and no longer goes stale on a release`,
|
|
1739
1824
|
);
|
|
1740
1825
|
} else if (args.apply) {
|
|
1741
1826
|
// --apply ran but the write was refused or failed — say why, rather than
|
|
1742
1827
|
// printing the same "run --apply" nudge that already ran and did nothing.
|
|
1743
1828
|
lines.push(
|
|
1744
|
-
`⚠ Wiki pre-commit could not
|
|
1745
|
-
`
|
|
1829
|
+
`⚠ Wiki pre-commit could not migrate hook (${wikiPreCommitApplyFailReason}) —` +
|
|
1830
|
+
` ${wikiPreCommitInfo.root} is still baked in and goes stale on the next release`,
|
|
1746
1831
|
);
|
|
1747
1832
|
} else {
|
|
1748
1833
|
lines.push(
|
|
1749
|
-
`⚠ Wiki pre-commit hook
|
|
1750
|
-
`
|
|
1834
|
+
`⚠ Wiki pre-commit hook has ${wikiPreCommitInfo.root} baked in, so it goes stale on` +
|
|
1835
|
+
` every release — run --apply to migrate it`,
|
|
1751
1836
|
);
|
|
1752
1837
|
}
|
|
1838
|
+
} else if (wikiPreCommitInfo && wikiPreCommitInfo.unrecognized) {
|
|
1839
|
+
// The marker is here but the body inside it is not one this codebase ever
|
|
1840
|
+
// wrote (hand-edited, corrupted, or an install root so far gone even the
|
|
1841
|
+
// rewrite-safe check above cannot read it). Nothing here is safe to
|
|
1842
|
+
// rewrite automatically, so name the break instead of staying silent about
|
|
1843
|
+
// it the way this branch used to (2026-09-11): a `--apply` that never
|
|
1844
|
+
// prints a Wiki pre-commit line at all reads as "nothing to do" when the
|
|
1845
|
+
// hook is actually failing every commit.
|
|
1846
|
+
lines.push(
|
|
1847
|
+
`⚠ Wiki pre-commit marker present but the hook body is not recognized (hand-edited,` +
|
|
1848
|
+
` corrupted, or too stale to read). Run \`hypomnema init --force-commands\` (or the` +
|
|
1849
|
+
` plugin's \`/hypo:init --force-commands\`) to reinstall the guard`,
|
|
1850
|
+
);
|
|
1753
1851
|
} else if (wikiPreCommitInfo) {
|
|
1754
|
-
lines.push(`✓ Wiki pre-commit hook
|
|
1852
|
+
lines.push(`✓ Wiki pre-commit hook resolves the install at commit time (nothing to migrate)`);
|
|
1755
1853
|
}
|
|
1756
1854
|
|
|
1757
1855
|
// Slash commands
|