hypomnema 1.10.0 → 1.10.2
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 +31 -0
- package/README.ko.md +5 -5
- package/README.md +5 -5
- package/commands/crystallize.md +21 -15
- package/commands/upgrade.md +13 -2
- package/docs/ARCHITECTURE.md +127 -63
- package/docs/CONTRIBUTING.md +7 -11
- package/hooks/base-store.mjs +31 -17
- package/hooks/hypo-auto-minimal-crystallize.mjs +39 -6
- package/hooks/hypo-auto-stage.mjs +123 -63
- package/hooks/hypo-close-guard.mjs +3 -37
- package/hooks/hypo-compact-guard.mjs +9 -6
- package/hooks/hypo-file-watch.mjs +18 -10
- package/hooks/hypo-personal-check.mjs +137 -55
- package/hooks/hypo-session-start.mjs +17 -6
- package/hooks/hypo-shared.mjs +1507 -569
- package/hooks/hypo-stop.mjs +8 -0
- package/hooks/session-views.mjs +9 -3
- package/package.json +2 -1
- package/scripts/crystallize.mjs +1 -1
- package/scripts/doctor.mjs +160 -9
- package/scripts/init.mjs +60 -22
- package/scripts/lib/crystallize-close-apply.mjs +156 -22
- package/scripts/lib/crystallize-close-check.mjs +1 -1
- package/scripts/lib/extensions.mjs +2 -2
- package/scripts/lib/git-hooks-dir.mjs +228 -31
- package/scripts/lib/plugin-detect.mjs +57 -4
- package/scripts/lib/settings-write.mjs +254 -0
- package/scripts/lib/wikilink.mjs +10 -1
- package/scripts/uninstall.mjs +3 -2
- package/scripts/upgrade.mjs +94 -23
- package/templates/Home.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +60 -22
- package/templates/session-state.md +0 -12
|
@@ -35,7 +35,18 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { execFileSync } from 'child_process';
|
|
38
|
-
import {
|
|
38
|
+
import { randomBytes } from 'crypto';
|
|
39
|
+
import {
|
|
40
|
+
chmodSync,
|
|
41
|
+
existsSync,
|
|
42
|
+
lstatSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
realpathSync,
|
|
45
|
+
renameSync,
|
|
46
|
+
rmSync,
|
|
47
|
+
statSync,
|
|
48
|
+
writeFileSync,
|
|
49
|
+
} from 'fs';
|
|
39
50
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
|
|
40
51
|
|
|
41
52
|
// ── shared install/uninstall markers ────────────────────────────────────────
|
|
@@ -175,20 +186,36 @@ const LINT_SUFFIX = '/scripts/lint.mjs';
|
|
|
175
186
|
// applied to the finished script as a whole, so any single quote inside such a
|
|
176
187
|
// value is escaped once, at the one place that can actually do it safely.
|
|
177
188
|
//
|
|
178
|
-
// Resolution order
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
189
|
+
// Resolution order. commands/*.md read ${CLAUDE_PLUGIN_ROOT} first and the
|
|
190
|
+
// sidecar second, but CLAUDE_PLUGIN_ROOT does not exist at commit time, so this
|
|
191
|
+
// script asks the settings and the plugin registry for the enabled plugin
|
|
192
|
+
// instead (the same choice resolveCommitTimeRoot in plugin-detect.mjs makes):
|
|
193
|
+
// 1. The enabled plugin's root: the first `enabledPlugins` key in
|
|
194
|
+
// ~/.claude/settings.json that is exactly `true`, shaped `name@marketplace`
|
|
195
|
+
// and named `hypo` or `hypomnema`; then that key's row in
|
|
196
|
+
// ~/.claude/plugins/installed_plugins.json (a user-scope row first, then
|
|
197
|
+
// any usable row).
|
|
198
|
+
// 2. ~/.claude/hypo-pkg.json's `pkgRoot` (the sidecar), but only when it is
|
|
199
|
+
// usable (see below). A stale/wrong/relative pkgRoot must not be trusted
|
|
200
|
+
// just because the file parses.
|
|
201
|
+
// When both 1 and 2 resolve, the higher package.json version wins and the
|
|
202
|
+
// plugin root wins a tie. The sidecar can be the newer one: SessionStart
|
|
203
|
+
// rewrites it to the running install, and a second profile that shares the
|
|
204
|
+
// settings but not the registry may sit on a newer release than the default
|
|
205
|
+
// profile's registry row. The comparison is the one hooks/version-check.mjs
|
|
206
|
+
// compareSemver makes (leading `v` allowed, `+build` ignored, prerelease
|
|
207
|
+
// ordered per SemVer section 11), copied below because this script runs
|
|
208
|
+
// standalone. A root whose version does not parse is not a candidate.
|
|
209
|
+
// 3. Neither resolved: ~/.claude/plugins/installed_plugins.json's
|
|
210
|
+
// `hypo@hypomnema` AND the legacy pre-rename `hypomnema@hypomnema`
|
|
211
|
+
// entries, searched TOGETHER as one combined list: every user-scope row
|
|
212
|
+
// first, then any usable row. A prior version searched `hypo@hypomnema`
|
|
213
|
+
// alone and only fell back to the legacy key when that key was entirely
|
|
214
|
+
// absent, so a `hypo@hypomnema` array present but full of unusable rows (a
|
|
215
|
+
// half-migrated registry) hid a perfectly good `hypomnema@hypomnema` row
|
|
216
|
+
// forever (codex reproduction, 2026-09-11: exit 1 with a real legacy row
|
|
217
|
+
// on disk).
|
|
218
|
+
// 4. Nothing resolves: print what was checked and how to fix it, then exit 1.
|
|
192
219
|
// A commit that silently skips the .hypoignore guard is worse than one
|
|
193
220
|
// that refuses outright.
|
|
194
221
|
//
|
|
@@ -207,6 +234,108 @@ const LINT_SUFFIX = '/scripts/lint.mjs';
|
|
|
207
234
|
// is skipped in favor of the registry, and if nothing else resolves the
|
|
208
235
|
// commit is refused (exit 1) rather than silently let through.
|
|
209
236
|
function buildPreCommitResolverJs(targetSuffix, extraArgvJs = '') {
|
|
237
|
+
return (
|
|
238
|
+
'var fs=require("fs"),os=require("os"),cp=require("child_process"),path=require("path");' +
|
|
239
|
+
'function usable(r){' +
|
|
240
|
+
'if(typeof r!=="string"||!r||!path.isAbsolute(r))return false;' +
|
|
241
|
+
// name AND version. This one runs at commit time in the user's vault and
|
|
242
|
+
// EXECUTES whatever it adopts, so version alone was not enough: any
|
|
243
|
+
// absolute directory carrying a package.json and the target path was
|
|
244
|
+
// accepted, and its script ran. The name check closes that.
|
|
245
|
+
//
|
|
246
|
+
// Not every root judgment here checks the name, and the difference is
|
|
247
|
+
// what each one licenses. isRealOldFormInstallRoot and
|
|
248
|
+
// isRewritableOldFormInstallRoot (below) and hypo-shared.mjs's sidecar
|
|
249
|
+
// proof do check it — they gate deleting, rewriting, executing.
|
|
250
|
+
// plugin-detect.mjs's usablePkgRoot does NOT (it answers the narrower
|
|
251
|
+
// "can scripts be resolved through this pointer at all"), which is why
|
|
252
|
+
// that file now also exports isHypomnemaInstallRoot, the strong form that
|
|
253
|
+
// DOES check the name. Its callers split accordingly: init.mjs's
|
|
254
|
+
// resolveDurableRoot and selectEntry (the registry-row picker behind
|
|
255
|
+
// resolveEnabledPluginEntry) RECORD what they accept, so both moved onto
|
|
256
|
+
// the strong predicate; doctor.mjs's per-row leaf-drift scan only reads
|
|
257
|
+
// for display and deliberately stays on the weak one (narrowing it would
|
|
258
|
+
// silence a foreign registry row doctor exists to surface). Do not cite
|
|
259
|
+
// this comment as proof the name is checked everywhere; check the
|
|
260
|
+
// specific predicate.
|
|
261
|
+
'try{var p=JSON.parse(fs.readFileSync(r+"/package.json","utf-8"));' +
|
|
262
|
+
'if(p.name!=="hypomnema")return false;' +
|
|
263
|
+
'if(typeof p.version!=="string"||!p.version)return false}catch(e){return false}' +
|
|
264
|
+
'try{return fs.existsSync(r+"' +
|
|
265
|
+
targetSuffix +
|
|
266
|
+
'")}catch(e){return false}' +
|
|
267
|
+
'}' +
|
|
268
|
+
'function readJson(p){try{return JSON.parse(fs.readFileSync(p,"utf-8"))}catch(e){return null}}' +
|
|
269
|
+
// Version helpers: a copy of hooks/version-check.mjs parseSemver and
|
|
270
|
+
// compareSemver (see the resolution order above). Only the regex and the
|
|
271
|
+
// comparison are copied, no comments, because a comment here could carry a
|
|
272
|
+
// single quote.
|
|
273
|
+
'function sv(v){var m=typeof v==="string"?v.trim().replace(/^v/,"").match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?(?:\\+[0-9A-Za-z.-]+)?$/):null;return m?{c:[m[1],m[2],m[3]],pre:m[4]||""}:null}' +
|
|
274
|
+
'function nid(x,y){if(x.length!==y.length)return x.length<y.length?-1:1;return x===y?0:(x<y?-1:1)}' +
|
|
275
|
+
'function cpre(a,b){var ai=a.split("."),bi=b.split(".");var n=Math.max(ai.length,bi.length);' +
|
|
276
|
+
'for(var i=0;i<n;i++){if(i>=ai.length)return -1;if(i>=bi.length)return 1;' +
|
|
277
|
+
'var x=ai[i],y=bi[i],xn=/^\\d+$/.test(x),yn=/^\\d+$/.test(y);' +
|
|
278
|
+
'if(xn&&yn){var c=nid(x,y);if(c!==0)return c}' +
|
|
279
|
+
'else if(xn!==yn){return xn?-1:1}' +
|
|
280
|
+
'else if(x!==y){return x<y?-1:1}}return 0}' +
|
|
281
|
+
'function cmp(a,b){var pa=sv(a),pb=sv(b);if(!pa||!pb)return null;' +
|
|
282
|
+
'for(var k=0;k<3;k++){var c=nid(pa.c[k],pb.c[k]);if(c!==0)return c}' +
|
|
283
|
+
'if(pa.pre===pb.pre)return 0;if(!pa.pre)return 1;if(!pb.pre)return -1;return cpre(pa.pre,pb.pre)}' +
|
|
284
|
+
'function ver(r){return readJson(r+"/package.json").version}' +
|
|
285
|
+
'var home=os.homedir();var root=null;' +
|
|
286
|
+
'var reg=readJson(home+"/.claude/plugins/installed_plugins.json");' +
|
|
287
|
+
'var plugins=reg&®.plugins;' +
|
|
288
|
+
'var set=readJson(home+"/.claude/settings.json");var ep=set&&set.enabledPlugins;var key=null;' +
|
|
289
|
+
'if(ep&&typeof ep==="object"&&!Array.isArray(ep)){var ks=Object.keys(ep);' +
|
|
290
|
+
'for(var i=0;i<ks.length;i++){var k=ks[i],at=k.indexOf("@");' +
|
|
291
|
+
'if(ep[k]!==true||at<=0||at===k.length-1)continue;' +
|
|
292
|
+
'var nm=k.slice(0,at);if(nm==="hypo"||nm==="hypomnema"){key=k;break}}}' +
|
|
293
|
+
'var pr=null;' +
|
|
294
|
+
'if(key&&plugins&&Array.isArray(plugins[key])){var rows=plugins[key];' +
|
|
295
|
+
'var row=rows.find(function(e){return e&&e.scope==="user"&&usable(e.installPath)})||' +
|
|
296
|
+
'rows.find(function(e){return e&&usable(e.installPath)});' +
|
|
297
|
+
'if(row&&sv(ver(row.installPath)))pr=row.installPath}' +
|
|
298
|
+
'var pkg=readJson(home+"/.claude/hypo-pkg.json");' +
|
|
299
|
+
'var sc=pkg&&usable(pkg.pkgRoot)&&sv(ver(pkg.pkgRoot))?pkg.pkgRoot:null;' +
|
|
300
|
+
'if(pr&&sc){var sidecarNewer=cmp(ver(pr),ver(sc))<0;root=sidecarNewer?sc:pr}else{root=pr||sc}' +
|
|
301
|
+
'if(!root){' +
|
|
302
|
+
'var arr=[];' +
|
|
303
|
+
'if(plugins&&Array.isArray(plugins["hypo@hypomnema"]))arr=arr.concat(plugins["hypo@hypomnema"]);' +
|
|
304
|
+
'if(plugins&&Array.isArray(plugins["hypomnema@hypomnema"]))arr=arr.concat(plugins["hypomnema@hypomnema"]);' +
|
|
305
|
+
'var entry=arr.find(function(e){return e&&e.scope==="user"&&usable(e.installPath)})||' +
|
|
306
|
+
'arr.find(function(e){return e&&usable(e.installPath)});' +
|
|
307
|
+
'if(entry){root=entry.installPath}' +
|
|
308
|
+
'}' +
|
|
309
|
+
'if(!root){' +
|
|
310
|
+
'console.error("hypomnema: could not resolve the install root for ' +
|
|
311
|
+
targetSuffix +
|
|
312
|
+
'.");' +
|
|
313
|
+
'console.error("Checked ~/.claude/settings.json (enabledPlugins), ~/.claude/hypo-pkg.json (pkgRoot) and ~/.claude/plugins/installed_plugins.json (hypo@hypomnema installPath, legacy hypomnema@hypomnema).");' +
|
|
314
|
+
'console.error("Fix: run `hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install), or reinstall Hypomnema.");' +
|
|
315
|
+
// A 1.10.0 uninstaller does not recognize this hook body and keeps it, so once the
|
|
316
|
+
// plugin is gone every commit lands here. Name the file and say how to remove it.
|
|
317
|
+
// git is asked for the path because the hook may live under core.hooksPath; if that
|
|
318
|
+
// fails the text says where to look instead. No single quote may appear in this text.
|
|
319
|
+
'var hp="";try{var g=cp.spawnSync("git",["rev-parse","--git-path","hooks/pre-commit"],{encoding:"utf-8"});' +
|
|
320
|
+
'if(g.status===0&&g.stdout.trim())hp=path.resolve(g.stdout.trim())}catch(e){}' +
|
|
321
|
+
'console.error("If Hypomnema was uninstalled, this hook can be removed by deleting "+(hp||"the pre-commit file in this repository hooks directory")+" (or by running uninstall from Hypomnema 1.10.1 or newer).");' +
|
|
322
|
+
'process.exit(1)' +
|
|
323
|
+
'}' +
|
|
324
|
+
'var r=cp.spawnSync(process.execPath,[root+"' +
|
|
325
|
+
targetSuffix +
|
|
326
|
+
'"' +
|
|
327
|
+
extraArgvJs +
|
|
328
|
+
'],{stdio:"inherit"});' +
|
|
329
|
+
'if(r.error){console.error(String(r.error.message||r.error))}' +
|
|
330
|
+
'process.exit(r.status===null?1:r.status)'
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Frozen, byte-for-byte copy of buildPreCommitResolverJs as it shipped before the
|
|
335
|
+
// enabled plugin root was consulted ahead of the sidecar. Hooks already
|
|
336
|
+
// installed in vaults embed this exact script, so ownership recognition has to
|
|
337
|
+
// keep reproducing it after the live builder moves on. Do not edit the body.
|
|
338
|
+
function buildPreviousPreCommitResolverJs(targetSuffix, extraArgvJs = '') {
|
|
210
339
|
return (
|
|
211
340
|
'var fs=require("fs"),os=require("os"),cp=require("child_process"),path=require("path");' +
|
|
212
341
|
'function usable(r){' +
|
|
@@ -301,11 +430,12 @@ function extractLintHypoDir(js) {
|
|
|
301
430
|
// (not a substring/suffix check) because the worker step has no dynamic
|
|
302
431
|
// input at all — anything less than byte-identical is not a script this
|
|
303
432
|
// writer could have produced, and per the module comment above an
|
|
304
|
-
// unrecognized shape must never be treated as "close enough".
|
|
305
|
-
|
|
433
|
+
// unrecognized shape must never be treated as "close enough". `build` picks the
|
|
434
|
+
// resolver shape: the live one, or the frozen one an earlier release shipped.
|
|
435
|
+
function isNewFormWorkerStep(step, build = buildPreCommitResolverJs) {
|
|
306
436
|
const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
|
|
307
437
|
if (!m) return false;
|
|
308
|
-
return unescapeShellSingleQuoted(m[1]) ===
|
|
438
|
+
return unescapeShellSingleQuoted(m[1]) === build(WORKER_SUFFIX);
|
|
309
439
|
}
|
|
310
440
|
|
|
311
441
|
// Is `root` a real, on-disk Hypomnema install that could actually have
|
|
@@ -385,13 +515,13 @@ export function isRewritableOldFormInstallRoot(root) {
|
|
|
385
515
|
// Extracts the embedded --hypo-dir, rebuilds the expected script around it,
|
|
386
516
|
// and compares byte-for-byte — the only way to validate a script with one
|
|
387
517
|
// dynamic input without loosening the check into a substring match.
|
|
388
|
-
function isNewFormLintStep(step) {
|
|
518
|
+
function isNewFormLintStep(step, build = buildPreCommitResolverJs) {
|
|
389
519
|
const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
|
|
390
520
|
if (!m) return false;
|
|
391
521
|
const js = unescapeShellSingleQuoted(m[1]);
|
|
392
522
|
const hypoDir = extractLintHypoDir(js);
|
|
393
523
|
if (hypoDir === null) return false;
|
|
394
|
-
return js ===
|
|
524
|
+
return js === build(LINT_SUFFIX, lintExtraArgvJs(hypoDir));
|
|
395
525
|
}
|
|
396
526
|
|
|
397
527
|
/**
|
|
@@ -411,7 +541,8 @@ function isNewFormLintStep(step) {
|
|
|
411
541
|
* or COULD HAVE WRITTEN in an older release (the version-pinned form) — see
|
|
412
542
|
* the module comment above PRE_COMMIT_WORKER_LINE for both shapes. A body
|
|
413
543
|
* that mixes the two (one step old-form, the other new-form) is never
|
|
414
|
-
* ours: no writer this codebase has ever shipped produces that.
|
|
544
|
+
* ours: no writer this codebase has ever shipped produces that. The same
|
|
545
|
+
* goes for one step on the previous resolver and the other on the current.
|
|
415
546
|
*/
|
|
416
547
|
export function isOwnedWikiPreCommitBody(
|
|
417
548
|
content,
|
|
@@ -437,7 +568,8 @@ export function isOwnedWikiPreCommitBody(
|
|
|
437
568
|
: null;
|
|
438
569
|
const workerIsOld = oldWorkerRoot !== null && isValidOldFormRoot(oldWorkerRoot);
|
|
439
570
|
const workerIsNew = isNewFormWorkerStep(steps[0]);
|
|
440
|
-
|
|
571
|
+
const workerIsPrevious = isNewFormWorkerStep(steps[0], buildPreviousPreCommitResolverJs);
|
|
572
|
+
if (!workerIsOld && !workerIsNew && !workerIsPrevious) return false;
|
|
441
573
|
|
|
442
574
|
if (steps.length === 2) {
|
|
443
575
|
if (workerIsOld) {
|
|
@@ -455,7 +587,12 @@ export function isOwnedWikiPreCommitBody(
|
|
|
455
587
|
if (!lint || unescapeShellSingleQuoted(lint[1]) !== oldWorkerRoot + LINT_SUFFIX) {
|
|
456
588
|
return false;
|
|
457
589
|
}
|
|
458
|
-
} else if (
|
|
590
|
+
} else if (
|
|
591
|
+
!isNewFormLintStep(
|
|
592
|
+
steps[1],
|
|
593
|
+
workerIsPrevious ? buildPreviousPreCommitResolverJs : buildPreCommitResolverJs,
|
|
594
|
+
)
|
|
595
|
+
) {
|
|
459
596
|
return false;
|
|
460
597
|
}
|
|
461
598
|
}
|
|
@@ -499,16 +636,55 @@ export function shellSingleQuote(p) {
|
|
|
499
636
|
// hypo-config.md scan) and may silently lint an unrelated vault. That is a
|
|
500
637
|
// vault path, not an install path, so unlike the root above it never goes
|
|
501
638
|
// stale across a release — it only needs resolving once, here.
|
|
502
|
-
export function wikiPreCommitContent(hypoDir, lintStrict) {
|
|
639
|
+
export function wikiPreCommitContent(hypoDir, lintStrict, build = buildPreCommitResolverJs) {
|
|
503
640
|
const absHypoDir = resolve(hypoDir);
|
|
504
|
-
const steps = [`node -e ${shellSingleQuote(
|
|
641
|
+
const steps = [`node -e ${shellSingleQuote(build(WORKER_SUFFIX))} || exit 1`];
|
|
505
642
|
if (lintStrict) {
|
|
506
|
-
const lintJs =
|
|
643
|
+
const lintJs = build(LINT_SUFFIX, lintExtraArgvJs(absHypoDir));
|
|
507
644
|
steps.push(`node -e ${shellSingleQuote(lintJs)} || exit 1`);
|
|
508
645
|
}
|
|
509
646
|
return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
|
|
510
647
|
}
|
|
511
648
|
|
|
649
|
+
// The exact bytes a hook written with the previous commit-time resolver holds
|
|
650
|
+
// (what v1.10.0 shipped). upgrade compares a file on disk against this before
|
|
651
|
+
// it rewrites the whole file, for the same reason oldFormPreCommitContent
|
|
652
|
+
// exists: the marker span says nothing about what sits around it.
|
|
653
|
+
export function previousFormPreCommitContent(hypoDir, lintStrict) {
|
|
654
|
+
return wikiPreCommitContent(hypoDir, lintStrict, buildPreviousPreCommitResolverJs);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Publishes `content` as the executable hook at `hookPath` through a complete
|
|
658
|
+
// candidate file: write the candidate beside the hook, mark it executable, then
|
|
659
|
+
// rename it over the hook. Git runs the hook at every commit, so a write torn
|
|
660
|
+
// halfway through the live file would leave a hook that fails or half-runs
|
|
661
|
+
// until someone repairs it by hand. With the rename as the only step that
|
|
662
|
+
// touches the live path, a failure anywhere leaves the previous hook whole.
|
|
663
|
+
// Ownership checks and the .bak copy are the caller's job.
|
|
664
|
+
//
|
|
665
|
+
// Returns `{ ok: true }`, or `{ ok: false, error, candidatePath, cleaned }`.
|
|
666
|
+
// `cleaned` is false when the candidate could not be removed after a failure; a
|
|
667
|
+
// leftover `pre-commit.<pid>.<rand>.tmp` is never run by git, so the caller
|
|
668
|
+
// reports its path rather than treating it as a second failure.
|
|
669
|
+
export function publishHookFile(hookPath, content) {
|
|
670
|
+
const candidatePath = `${hookPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
671
|
+
try {
|
|
672
|
+
writeFileSync(candidatePath, content, { flag: 'wx' });
|
|
673
|
+
chmodSync(candidatePath, 0o755);
|
|
674
|
+
renameSync(candidatePath, hookPath);
|
|
675
|
+
return { ok: true };
|
|
676
|
+
} catch (error) {
|
|
677
|
+
let cleaned = false;
|
|
678
|
+
try {
|
|
679
|
+
rmSync(candidatePath, { force: true });
|
|
680
|
+
cleaned = true;
|
|
681
|
+
} catch {
|
|
682
|
+
// Reported through `cleaned: false`, not thrown over the original error.
|
|
683
|
+
}
|
|
684
|
+
return { ok: false, error, candidatePath, cleaned };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
512
688
|
// Rebuilds the exact bytes an OLD-form (version-pinned) pre-commit hook holds
|
|
513
689
|
// for a given (root, hypoDir, lintStrict) — the shape a pre-issue release of
|
|
514
690
|
// this writer would have produced, and the shape parseWikiPreCommitRoot reads
|
|
@@ -608,6 +784,11 @@ export function parseWikiPreCommitRoot(content) {
|
|
|
608
784
|
const js = unescapeShellSingleQuoted(PRE_COMMIT_RESOLVER_LINE.exec(steps[1])[1]);
|
|
609
785
|
hypoDir = extractLintHypoDir(js);
|
|
610
786
|
}
|
|
787
|
+
// A body on the previous resolver also has no baked root, but it still needs
|
|
788
|
+
// upgrade to rewrite it, so it says so instead of looking like the live form.
|
|
789
|
+
if (isNewFormWorkerStep(steps[0], buildPreviousPreCommitResolverJs)) {
|
|
790
|
+
return { ok: true, hasMarker: true, root: null, previousResolver: true, lintStrict, hypoDir };
|
|
791
|
+
}
|
|
611
792
|
return { ok: true, hasMarker: true, root: null, lintStrict, hypoDir };
|
|
612
793
|
}
|
|
613
794
|
|
|
@@ -787,22 +968,37 @@ export function resolveGitHooksDir(repoRoot, { timeoutMs = 5000 } = {}) {
|
|
|
787
968
|
* @returns {null | string} null when writing is safe, else a reason to log
|
|
788
969
|
*/
|
|
789
970
|
export function unsafeHookTargetReason(hookPath) {
|
|
971
|
+
return unsafeHookTarget(hookPath)?.reason ?? null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Same probe as unsafeHookTargetReason, with `operational` telling a refusal on purpose
|
|
976
|
+
* (a symlink, a non-regular file) from a probe that failed with an errno (EACCES, EIO, ...)
|
|
977
|
+
* and so says nothing about whether the entry is safe. Callers that must not carry on past a
|
|
978
|
+
* failed probe read this field instead of parsing the message.
|
|
979
|
+
*
|
|
980
|
+
* @returns {null | {reason: string, operational: boolean}}
|
|
981
|
+
*/
|
|
982
|
+
export function unsafeHookTarget(hookPath) {
|
|
790
983
|
let st;
|
|
791
984
|
try {
|
|
792
985
|
st = lstatSync(hookPath);
|
|
793
986
|
} catch (e) {
|
|
794
987
|
if (e && e.code === 'ENOENT') return null; // genuinely absent — safe to create
|
|
795
|
-
return `cannot stat (${e.code || e.message})
|
|
988
|
+
return { reason: `cannot stat (${e.code || e.message})`, operational: true };
|
|
989
|
+
}
|
|
990
|
+
if (st.isSymbolicLink()) {
|
|
991
|
+
return { reason: 'is a symlink — refusing to write through it', operational: false };
|
|
796
992
|
}
|
|
797
|
-
if (st.
|
|
798
|
-
if (!st.isFile()) return 'exists but is not a regular file';
|
|
993
|
+
if (!st.isFile()) return { reason: 'exists but is not a regular file', operational: false };
|
|
799
994
|
return null;
|
|
800
995
|
}
|
|
801
996
|
|
|
802
997
|
/**
|
|
803
998
|
* Write-side wrapper: the hooks directory only if it is safe to install into.
|
|
804
999
|
* Returns `{dir}` when installing is allowed, otherwise `{skip}` carrying a
|
|
805
|
-
* human-readable reason for the caller to log.
|
|
1000
|
+
* human-readable reason for the caller to log. `operational: true` marks a skip that is a
|
|
1001
|
+
* failed probe (git could not resolve the hooks dir), not a deliberate refusal.
|
|
806
1002
|
*/
|
|
807
1003
|
export function hooksDirForInstall(repoRoot, opts) {
|
|
808
1004
|
const r = resolveGitHooksDir(repoRoot, opts);
|
|
@@ -815,7 +1011,8 @@ export function hooksDirForInstall(repoRoot, opts) {
|
|
|
815
1011
|
return { skip: `hooks path is not a directory, so git runs no hooks (${r.path})` };
|
|
816
1012
|
}
|
|
817
1013
|
if (r.reason === 'git-unavailable') return { skip: 'git not available on PATH' };
|
|
818
|
-
|
|
1014
|
+
// operational: git or the filesystem failed, which is not a decision to leave the hook alone
|
|
1015
|
+
return { skip: `could not resolve hooks dir (${r.detail || r.reason})`, operational: true };
|
|
819
1016
|
}
|
|
820
1017
|
if (!r.owned) {
|
|
821
1018
|
return {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { readFileSync, existsSync } from 'node:fs';
|
|
19
19
|
import { isAbsolute, join } from 'node:path';
|
|
20
|
+
import { compareSemver, parseSemver } from '../../hooks/version-check.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
|
|
@@ -218,11 +219,14 @@ export function leafVersionDrift(pkgRoot) {
|
|
|
218
219
|
// being a real Hypomnema package, must not be selected here. codex reproduction
|
|
219
220
|
// (2026-09-11): a registry row's package.json carried no `name` field at all and
|
|
220
221
|
// was still adopted as the durable root under the old, name-blind check.
|
|
221
|
-
function selectEntry(entries, scope) {
|
|
222
|
+
function selectEntry(entries, scope, requireFile) {
|
|
222
223
|
return (
|
|
223
224
|
entries.find(
|
|
224
225
|
(e) =>
|
|
225
|
-
e &&
|
|
226
|
+
e &&
|
|
227
|
+
(scope === undefined || e.scope === scope) &&
|
|
228
|
+
isHypomnemaInstallRoot(e.installPath) &&
|
|
229
|
+
(requireFile === undefined || existsSync(join(e.installPath, requireFile))),
|
|
226
230
|
) ?? null
|
|
227
231
|
);
|
|
228
232
|
}
|
|
@@ -260,7 +264,10 @@ function selectEntry(entries, scope) {
|
|
|
260
264
|
// comment exists to prevent). It also silences upgrade's dualSkipWouldCorrect
|
|
261
265
|
// trigger, disabling the very self-heal that fixes a stale pointer. Drift is
|
|
262
266
|
// surfaced by doctor's checkPluginLeafVersion instead.
|
|
263
|
-
|
|
267
|
+
//
|
|
268
|
+
// `requireFile` (a path relative to the install root, default none) makes a row
|
|
269
|
+
// eligible only when that file exists. Only resolveCommitTimeRoot passes it.
|
|
270
|
+
export function resolveEnabledPluginEntry(settingsPath, registryPath, requireFile) {
|
|
264
271
|
const key = enabledHypomnemaPluginKey(settingsPath);
|
|
265
272
|
if (!key) return { key: null, reason: 'not-enabled', entry: null, root: null };
|
|
266
273
|
let reg;
|
|
@@ -273,7 +280,9 @@ export function resolveEnabledPluginEntry(settingsPath, registryPath) {
|
|
|
273
280
|
reg && typeof reg.plugins === 'object' && !Array.isArray(reg.plugins) ? reg.plugins : null;
|
|
274
281
|
const entries = plugins && Array.isArray(plugins[key]) ? plugins[key] : null;
|
|
275
282
|
// Prefer the user-scope install, then any usable entry for this exact key.
|
|
276
|
-
const entry = entries
|
|
283
|
+
const entry = entries
|
|
284
|
+
? (selectEntry(entries, 'user', requireFile) ?? selectEntry(entries, undefined, requireFile))
|
|
285
|
+
: null;
|
|
277
286
|
if (!entry) return { key, reason: 'unresolved', entry: null, root: null };
|
|
278
287
|
return { key, reason: 'resolved', entry, root: entry.installPath };
|
|
279
288
|
}
|
|
@@ -286,6 +295,50 @@ export function resolveEnabledPluginRoot(settingsPath, registryPath) {
|
|
|
286
295
|
return resolveEnabledPluginEntry(settingsPath, registryPath).root;
|
|
287
296
|
}
|
|
288
297
|
|
|
298
|
+
// The install root the vault's pre-commit hook should run when a vault
|
|
299
|
+
// operation fires it: the enabled plugin's root (resolveEnabledPluginEntry) and
|
|
300
|
+
// the sidecar `pkgRoot` from hypo-pkg.json (`pkgJsonPath`), whichever carries the
|
|
301
|
+
// higher package.json version, the plugin root on a tie. The sidecar is only a
|
|
302
|
+
// candidate when isHypomnemaInstallRoot accepts it. A root whose version
|
|
303
|
+
// parseSemver cannot read is dropped as a candidate, and null means neither
|
|
304
|
+
// root qualified. The comparison is hooks/version-check.mjs compareSemver, the
|
|
305
|
+
// same judgment SessionStart's downgrade guard makes: one question, one rule.
|
|
306
|
+
//
|
|
307
|
+
// A root is only eligible when `script` (relative to the root, default the
|
|
308
|
+
// pre-commit worker) exists in it, as in the generated hook: a plugin root
|
|
309
|
+
// without the worker must not win over a complete sidecar. For the plugin the
|
|
310
|
+
// check applies per registry row, so a later row that has the script is still
|
|
311
|
+
// found.
|
|
312
|
+
//
|
|
313
|
+
// The generated hook (scripts/lib/git-hooks-dir.mjs) runs standalone and carries
|
|
314
|
+
// its own copy of this choice, so this function is the reference its tests hold
|
|
315
|
+
// the script against.
|
|
316
|
+
export function resolveCommitTimeRoot(
|
|
317
|
+
settingsPath,
|
|
318
|
+
registryPath,
|
|
319
|
+
pkgJsonPath,
|
|
320
|
+
script = 'hooks/hypo-pre-commit.mjs',
|
|
321
|
+
) {
|
|
322
|
+
const candidate = (root) => (root && parseSemver(readPkgVersionAt(root)) ? root : null);
|
|
323
|
+
const plugin = candidate(resolveEnabledPluginEntry(settingsPath, registryPath, script).root);
|
|
324
|
+
let sidecar = null;
|
|
325
|
+
try {
|
|
326
|
+
const pkgRoot = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'))?.pkgRoot;
|
|
327
|
+
sidecar =
|
|
328
|
+
isHypomnemaInstallRoot(pkgRoot) && existsSync(join(pkgRoot, script))
|
|
329
|
+
? candidate(pkgRoot)
|
|
330
|
+
: null;
|
|
331
|
+
} catch {
|
|
332
|
+
// unreadable or corrupt sidecar: no sidecar candidate
|
|
333
|
+
}
|
|
334
|
+
if (plugin && sidecar) {
|
|
335
|
+
return compareSemver(readPkgVersionAt(plugin), readPkgVersionAt(sidecar)) < 0
|
|
336
|
+
? sidecar
|
|
337
|
+
: plugin;
|
|
338
|
+
}
|
|
339
|
+
return plugin ?? sidecar;
|
|
340
|
+
}
|
|
341
|
+
|
|
289
342
|
// Single-computation answer to "is Claude's core hook/command surface
|
|
290
343
|
// plugin-managed for THIS process, and if that requires a registry lookup, what
|
|
291
344
|
// did it find?" doctor.mjs, init.mjs and upgrade.mjs each used to compute
|