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
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { execFileSync } from 'child_process';
|
|
38
|
-
import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
|
|
38
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs';
|
|
39
39
|
import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
|
|
40
40
|
|
|
41
41
|
// ── shared install/uninstall markers ────────────────────────────────────────
|
|
@@ -100,13 +100,34 @@ export function findMarkerSpan(content, startMarker, endMarker) {
|
|
|
100
100
|
//
|
|
101
101
|
// The shell block is fully static — init never bakes a path into it — so its
|
|
102
102
|
// body can be matched byte-for-byte against SHELL_FUNCTION_BODY below. The
|
|
103
|
-
// pre-commit body
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
103
|
+
// pre-commit body has TWO recognized shapes now:
|
|
104
|
+
//
|
|
105
|
+
// - the OLD, version-pinned shape: `node '<absolute-root>/hooks/
|
|
106
|
+
// hypo-pre-commit.mjs' || exit 1`. An install root baked in like this goes
|
|
107
|
+
// stale every release — the plugin channel moves PKG_ROOT to a new
|
|
108
|
+
// version directory on every upgrade, and nothing ever re-writes an
|
|
109
|
+
// already-installed hook, so it keeps calling whatever release happened
|
|
110
|
+
// to be current the day the hook was written.
|
|
111
|
+
// - the NEW, runtime-resolving shape this issue introduces: `node -e
|
|
112
|
+
// '<resolver script>' || exit 1`, where the resolver script (built by
|
|
113
|
+
// buildPreCommitResolverJs below) looks up the install root itself, at
|
|
114
|
+
// COMMIT TIME, from ~/.claude/hypo-pkg.json or the plugin registry. No
|
|
115
|
+
// root is ever baked in, so there is nothing here for a release to make
|
|
116
|
+
// stale.
|
|
117
|
+
//
|
|
118
|
+
// Both shapes are matched structurally, not byte-for-byte: the OLD shape by
|
|
119
|
+
// checking the referenced worker script ends in `/hooks/hypo-pre-commit.mjs`
|
|
120
|
+
// (not which root it lives under, subject to isValidOldFormRoot below) and,
|
|
121
|
+
// when a lint step is present, that its path is EXACTLY that same root's
|
|
122
|
+
// `/scripts/lint.mjs` — not merely a path ending in that suffix under some
|
|
123
|
+
// OTHER root, which no writer here has ever produced (codex reproduction,
|
|
124
|
+
// 2026-09-11 — see isOwnedWikiPreCommitBody); the NEW shape by
|
|
125
|
+
// reconstructing the exact resolver script buildPreCommitResolverJs would
|
|
126
|
+
// have produced and comparing it byte-for-byte against what is embedded (see
|
|
127
|
+
// isNewFormWorkerStep/isNewFormLintStep below) — loose substring matching on
|
|
128
|
+
// a resolver script would let a forged marker pair around arbitrary content
|
|
129
|
+
// through the same way a forged marker pair around a plain path once did
|
|
130
|
+
// (codex reproduction, 2026-08-27).
|
|
110
131
|
//
|
|
111
132
|
// Both directions of a mismatch here are unequal: failing to recognize a
|
|
112
133
|
// hook init actually wrote costs a re-run with --force-*; deleting a file
|
|
@@ -123,20 +144,280 @@ export const PRE_COMMIT_WORKER_LINE = /^node '(.+)' \|\| exit 1$/;
|
|
|
123
144
|
// repoints the lint gate at the wrong vault.
|
|
124
145
|
export const PRE_COMMIT_LINT_LINE = /^node '(.+)' --hypo-dir='(.+)' --strict \|\| exit 1$/;
|
|
125
146
|
|
|
147
|
+
// The NEW, runtime-resolving shape: `node -e '<script>' || exit 1`, where
|
|
148
|
+
// `<script>` is shell-single-quoted the same way PRE_COMMIT_WORKER_LINE's
|
|
149
|
+
// path is — via shellSingleQuote() — so unescapeShellSingleQuoted() below
|
|
150
|
+
// recovers it the same way.
|
|
151
|
+
export const PRE_COMMIT_RESOLVER_LINE = /^node -e '(.+)' \|\| exit 1$/;
|
|
152
|
+
|
|
126
153
|
// Reverses shellSingleQuote()'s escaping (a literal `'` becomes `'\''`) so the
|
|
127
154
|
// captured path can be compared against the suffix it must end in.
|
|
128
155
|
export function unescapeShellSingleQuoted(s) {
|
|
129
156
|
return s.split("'\\''").join("'");
|
|
130
157
|
}
|
|
131
158
|
|
|
159
|
+
// The two scripts a vault's pre-commit hook ever resolves through, relative to
|
|
160
|
+
// the install root. Shared between the OLD form's suffix checks and the NEW
|
|
161
|
+
// form's resolver script (buildPreCommitResolverJs), so the two can never name
|
|
162
|
+
// a different worker/lint path without this file itself failing to load.
|
|
163
|
+
const WORKER_SUFFIX = '/hooks/hypo-pre-commit.mjs';
|
|
164
|
+
const LINT_SUFFIX = '/scripts/lint.mjs';
|
|
165
|
+
|
|
166
|
+
// Builds the body of a `node -e '<this>'` step that resolves the Hypomnema
|
|
167
|
+
// install root AT COMMIT TIME instead of embedding one — the fix for the root
|
|
168
|
+
// this issue removes. No single quotes appear anywhere in this JS: the whole
|
|
169
|
+
// result is wrapped in shell single quotes by the caller (shellSingleQuote,
|
|
170
|
+
// same as the OLD form's path), and sh single quotes have no escape mechanism
|
|
171
|
+
// of their own, so a stray `'` in the FIXED part of this script would corrupt
|
|
172
|
+
// the surrounding shell line no matter how it were JS-escaped. Values that are
|
|
173
|
+
// NOT fixed (the --hypo-dir path for the lint step) are instead embedded via
|
|
174
|
+
// JSON.stringify and passed in through `extraArgvJs`; shellSingleQuote() is
|
|
175
|
+
// applied to the finished script as a whole, so any single quote inside such a
|
|
176
|
+
// value is escaped once, at the one place that can actually do it safely.
|
|
177
|
+
//
|
|
178
|
+
// Resolution order (must match the resolution chain commands/*.md already
|
|
179
|
+
// documents for resolving CLAUDE_PLUGIN_ROOT by hand):
|
|
180
|
+
// 1. ~/.claude/hypo-pkg.json's `pkgRoot`, but only when it is usable (see
|
|
181
|
+
// below). A stale/wrong/relative pkgRoot must not be trusted just
|
|
182
|
+
// because the file parses.
|
|
183
|
+
// 2. ~/.claude/plugins/installed_plugins.json's `hypo@hypomnema` AND the
|
|
184
|
+
// legacy pre-rename `hypomnema@hypomnema` entries, searched TOGETHER as
|
|
185
|
+
// one combined list: every user-scope row first, then any usable row.
|
|
186
|
+
// A prior version searched `hypo@hypomnema` alone and only fell back to
|
|
187
|
+
// the legacy key when that key was entirely absent, so a `hypo@hypomnema`
|
|
188
|
+
// array present but full of unusable rows (a half-migrated registry)
|
|
189
|
+
// hid a perfectly good `hypomnema@hypomnema` row forever (codex
|
|
190
|
+
// reproduction, 2026-09-11: exit 1 with a real legacy row on disk).
|
|
191
|
+
// 3. Neither resolves: print what was checked and how to fix it, then exit 1.
|
|
192
|
+
// A commit that silently skips the .hypoignore guard is worse than one
|
|
193
|
+
// that refuses outright.
|
|
194
|
+
//
|
|
195
|
+
// A candidate root is "usable" only when it is an ABSOLUTE string, its
|
|
196
|
+
// `package.json` parses with a non-empty string `version`, AND
|
|
197
|
+
// `<root><targetSuffix>` exists. Checking existence of the target alone (the
|
|
198
|
+
// prior behavior) let a RELATIVE pkgRoot such as `"."` pass by resolving
|
|
199
|
+
// against whatever the hook's cwd happened to be at commit time (the
|
|
200
|
+
// vault's own working-tree root), so a vault carrying its own
|
|
201
|
+
// `hooks/hypo-pre-commit.mjs` was silently accepted as "the install" and its
|
|
202
|
+
// (attacker-controlled) contents ran instead of the real .hypoignore guard,
|
|
203
|
+
// letting an ignored file commit clean (codex reproduction, 2026-09-11:
|
|
204
|
+
// `pkgRoot: "."` + a forged `hooks/hypo-pre-commit.mjs` inside the vault
|
|
205
|
+
// exited 0 with `.hypoignore`'d `.env` staged). Requiring an absolute path
|
|
206
|
+
// closes that: a relative value can never satisfy `path.isAbsolute`, so it
|
|
207
|
+
// is skipped in favor of the registry, and if nothing else resolves the
|
|
208
|
+
// commit is refused (exit 1) rather than silently let through.
|
|
209
|
+
function buildPreCommitResolverJs(targetSuffix, extraArgvJs = '') {
|
|
210
|
+
return (
|
|
211
|
+
'var fs=require("fs"),os=require("os"),cp=require("child_process"),path=require("path");' +
|
|
212
|
+
'function usable(r){' +
|
|
213
|
+
'if(typeof r!=="string"||!r||!path.isAbsolute(r))return false;' +
|
|
214
|
+
// name AND version. This one runs at commit time in the user's vault and
|
|
215
|
+
// EXECUTES whatever it adopts, so version alone was not enough: any
|
|
216
|
+
// absolute directory carrying a package.json and the target path was
|
|
217
|
+
// accepted, and its script ran. The name check closes that.
|
|
218
|
+
//
|
|
219
|
+
// Not every root judgment here checks the name, and the difference is
|
|
220
|
+
// what each one licenses. isRealOldFormInstallRoot and
|
|
221
|
+
// isRewritableOldFormInstallRoot (below) and hypo-shared.mjs's sidecar
|
|
222
|
+
// proof do check it — they gate deleting, rewriting, executing.
|
|
223
|
+
// plugin-detect.mjs's usablePkgRoot does NOT (it answers the narrower
|
|
224
|
+
// "can scripts be resolved through this pointer at all"), which is why
|
|
225
|
+
// that file now also exports isHypomnemaInstallRoot, the strong form that
|
|
226
|
+
// DOES check the name. Its callers split accordingly: init.mjs's
|
|
227
|
+
// resolveDurableRoot and selectEntry (the registry-row picker behind
|
|
228
|
+
// resolveEnabledPluginEntry) RECORD what they accept, so both moved onto
|
|
229
|
+
// the strong predicate; doctor.mjs's per-row leaf-drift scan only reads
|
|
230
|
+
// for display and deliberately stays on the weak one (narrowing it would
|
|
231
|
+
// silence a foreign registry row doctor exists to surface). Do not cite
|
|
232
|
+
// this comment as proof the name is checked everywhere; check the
|
|
233
|
+
// specific predicate.
|
|
234
|
+
'try{var p=JSON.parse(fs.readFileSync(r+"/package.json","utf-8"));' +
|
|
235
|
+
'if(p.name!=="hypomnema")return false;' +
|
|
236
|
+
'if(typeof p.version!=="string"||!p.version)return false}catch(e){return false}' +
|
|
237
|
+
'try{return fs.existsSync(r+"' +
|
|
238
|
+
targetSuffix +
|
|
239
|
+
'")}catch(e){return false}' +
|
|
240
|
+
'}' +
|
|
241
|
+
'function readJson(p){try{return JSON.parse(fs.readFileSync(p,"utf-8"))}catch(e){return null}}' +
|
|
242
|
+
'var home=os.homedir();var root=null;' +
|
|
243
|
+
'var pkg=readJson(home+"/.claude/hypo-pkg.json");' +
|
|
244
|
+
'if(pkg&&usable(pkg.pkgRoot)){root=pkg.pkgRoot}' +
|
|
245
|
+
'if(!root){' +
|
|
246
|
+
'var reg=readJson(home+"/.claude/plugins/installed_plugins.json");' +
|
|
247
|
+
'var plugins=reg&®.plugins;' +
|
|
248
|
+
'var arr=[];' +
|
|
249
|
+
'if(plugins&&Array.isArray(plugins["hypo@hypomnema"]))arr=arr.concat(plugins["hypo@hypomnema"]);' +
|
|
250
|
+
'if(plugins&&Array.isArray(plugins["hypomnema@hypomnema"]))arr=arr.concat(plugins["hypomnema@hypomnema"]);' +
|
|
251
|
+
'var entry=arr.find(function(e){return e&&e.scope==="user"&&usable(e.installPath)})||' +
|
|
252
|
+
'arr.find(function(e){return e&&usable(e.installPath)});' +
|
|
253
|
+
'if(entry){root=entry.installPath}' +
|
|
254
|
+
'}' +
|
|
255
|
+
'if(!root){' +
|
|
256
|
+
'console.error("hypomnema: could not resolve the install root for ' +
|
|
257
|
+
targetSuffix +
|
|
258
|
+
'.");' +
|
|
259
|
+
'console.error("Checked ~/.claude/hypo-pkg.json (pkgRoot) and ~/.claude/plugins/installed_plugins.json (hypo@hypomnema installPath, legacy hypomnema@hypomnema).");' +
|
|
260
|
+
'console.error("Fix: run `hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install), or reinstall Hypomnema.");' +
|
|
261
|
+
'process.exit(1)' +
|
|
262
|
+
'}' +
|
|
263
|
+
'var r=cp.spawnSync(process.execPath,[root+"' +
|
|
264
|
+
targetSuffix +
|
|
265
|
+
'"' +
|
|
266
|
+
extraArgvJs +
|
|
267
|
+
'],{stdio:"inherit"});' +
|
|
268
|
+
'if(r.error){console.error(String(r.error.message||r.error))}' +
|
|
269
|
+
'process.exit(r.status===null?1:r.status)'
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The lint step's `--hypo-dir` value is the only dynamic input to the resolver
|
|
274
|
+
// script. Embedding it via JSON.stringify (rather than string-concatenating it
|
|
275
|
+
// raw into the script) means it round-trips exactly through extractLintHypoDir
|
|
276
|
+
// below, including any character that would otherwise need JS escaping.
|
|
277
|
+
function lintExtraArgvJs(hypoDir) {
|
|
278
|
+
return `,${JSON.stringify(`--hypo-dir=${hypoDir}`)},"--strict"`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Recovers the `--hypo-dir` value baked into a NEW-form lint step's resolver
|
|
282
|
+
// script, or null when the script does not have this exact shape. Matched
|
|
283
|
+
// against the literal argv array text buildPreCommitResolverJs(LINT_SUFFIX, …)
|
|
284
|
+
// produces — `[root+"/scripts/lint.mjs",<json-string>,"--strict"]` — so a
|
|
285
|
+
// script that merely CONTAINS these substrings somewhere else does not count.
|
|
286
|
+
function extractLintHypoDir(js) {
|
|
287
|
+
const m = /\[root\+"\/scripts\/lint\.mjs",("(?:[^"\\]|\\.)*"),"--strict"\]/.exec(js);
|
|
288
|
+
if (!m) return null;
|
|
289
|
+
let arg;
|
|
290
|
+
try {
|
|
291
|
+
arg = JSON.parse(m[1]);
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const prefix = '--hypo-dir=';
|
|
296
|
+
return typeof arg === 'string' && arg.startsWith(prefix) ? arg.slice(prefix.length) : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Is `step` a NEW-form worker line whose resolver script is EXACTLY the one
|
|
300
|
+
// buildPreCommitResolverJs(WORKER_SUFFIX) would generate? Exact comparison
|
|
301
|
+
// (not a substring/suffix check) because the worker step has no dynamic
|
|
302
|
+
// input at all — anything less than byte-identical is not a script this
|
|
303
|
+
// writer could have produced, and per the module comment above an
|
|
304
|
+
// unrecognized shape must never be treated as "close enough".
|
|
305
|
+
function isNewFormWorkerStep(step) {
|
|
306
|
+
const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
|
|
307
|
+
if (!m) return false;
|
|
308
|
+
return unescapeShellSingleQuoted(m[1]) === buildPreCommitResolverJs(WORKER_SUFFIX);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Is `root` a real, on-disk Hypomnema install that could actually have
|
|
312
|
+
// produced an OLD-form worker line naming it? The suffix check on the worker
|
|
313
|
+
// path alone (endsWith(WORKER_SUFFIX)) proves nothing about the path BEFORE
|
|
314
|
+
// the suffix: any root, including one no writer in this codebase ever
|
|
315
|
+
// touched, satisfies it. codex reproduced this (2026-09-11): a marker pair
|
|
316
|
+
// wrapping `node '<arbitrary-root>/hooks/hypo-pre-commit.mjs' || exit 1`,
|
|
317
|
+
// where `<arbitrary-root>` was a user's own project, was accepted as "ours"
|
|
318
|
+
// and became eligible for upgrade.mjs's rewrite and uninstall.mjs's delete,
|
|
319
|
+
// neither of which this repo ever wrote. This requires the referenced root
|
|
320
|
+
// to be an absolute path to a package literally named "hypomnema" (the same
|
|
321
|
+
// producer-identity check hooks/hypo-shared.mjs applies to a cached pkgRoot)
|
|
322
|
+
// that still contains the worker script the line names.
|
|
323
|
+
//
|
|
324
|
+
// This is the STRICT form: a root that no longer exists on disk cannot be
|
|
325
|
+
// verified this way and reads as NOT ours. That is the right call for a
|
|
326
|
+
// DESTRUCTIVE consumer (uninstall.mjs's delete, the only caller left on this
|
|
327
|
+
// predicate, see isRewritableOldFormInstallRoot below for the non-destructive
|
|
328
|
+
// one, because deleting a hook this check cannot actually verify would be
|
|
329
|
+
// deleting on a guess. It is the wrong call for a REWRITE consumer: an install
|
|
330
|
+
// that moved or was reinstalled leaves its OLD root gone by construction, and
|
|
331
|
+
// refusing to rewrite there left every vault git commit failing
|
|
332
|
+
// `MODULE_NOT_FOUND` with no recovery path in the product (2026-09-11).
|
|
333
|
+
function isRealOldFormInstallRoot(root) {
|
|
334
|
+
if (!isAbsolute(root) || !existsSync(join(root, WORKER_SUFFIX))) return false;
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).name === 'hypomnema';
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The REWRITE-safe counterpart to isRealOldFormInstallRoot above. Rewriting an
|
|
343
|
+
// OLD-form hook onto the new, runtime-resolving form is non-destructive (it
|
|
344
|
+
// only changes what the hook calls next commit, never deletes anything), so a
|
|
345
|
+
// root that no longer exists is not treated as suspicious the way it is for
|
|
346
|
+
// deletion: it is exactly what a moved-or-reinstalled Hypomnema looks like,
|
|
347
|
+
// and there is nothing left at that path to impersonate. A root that DOES
|
|
348
|
+
// exist is still checked against package.json's name, so the forged case
|
|
349
|
+
// codex reproduced (a real path, but someone else's project) is rejected the
|
|
350
|
+
// same as above; only the "root is gone" branch differs between the two
|
|
351
|
+
// predicates. Keep both in sync: a change to one's package.json/name check
|
|
352
|
+
// almost certainly belongs in the other too.
|
|
353
|
+
// The first free `<hookPath>.bak`, `<hookPath>.bak.1`, `<hookPath>.bak.2`, ...
|
|
354
|
+
// Never overwrites an EARLIER backup: a collision there means something has
|
|
355
|
+
// already been preserved once, and clobbering it would defeat the entire point
|
|
356
|
+
// of taking a backup at all. Shared by upgrade's migration and init's
|
|
357
|
+
// force/marker overwrite so the two cannot disagree about what a backup is.
|
|
358
|
+
export function uniqueBakPath(hookPath) {
|
|
359
|
+
let candidate = `${hookPath}.bak`;
|
|
360
|
+
for (let n = 1; existsSync(candidate); n += 1) {
|
|
361
|
+
// A symlink (or anything that is not a regular file) sitting on a backup
|
|
362
|
+
// name is not a collision to route around — it is the attack the caller's
|
|
363
|
+
// symlink guard exists to stop, and stepping to `.bak.1` would walk past it
|
|
364
|
+
// silently while the guard then inspects the wrong path. Refuse, and let
|
|
365
|
+
// the caller decline the whole write rather than overwrite the hook with
|
|
366
|
+
// its backup gone somewhere unverified.
|
|
367
|
+
if (unsafeHookTargetReason(candidate)) return null;
|
|
368
|
+
candidate = `${hookPath}.bak.${n}`;
|
|
369
|
+
}
|
|
370
|
+
return unsafeHookTargetReason(candidate) ? null : candidate;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function isRewritableOldFormInstallRoot(root) {
|
|
374
|
+
if (!isAbsolute(root)) return false;
|
|
375
|
+
if (!existsSync(root)) return true;
|
|
376
|
+
try {
|
|
377
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).name === 'hypomnema';
|
|
378
|
+
} catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Is `step` a NEW-form lint line whose resolver script is EXACTLY the one
|
|
384
|
+
// buildPreCommitResolverJs(LINT_SUFFIX, …) would generate for SOME --hypo-dir?
|
|
385
|
+
// Extracts the embedded --hypo-dir, rebuilds the expected script around it,
|
|
386
|
+
// and compares byte-for-byte — the only way to validate a script with one
|
|
387
|
+
// dynamic input without loosening the check into a substring match.
|
|
388
|
+
function isNewFormLintStep(step) {
|
|
389
|
+
const m = PRE_COMMIT_RESOLVER_LINE.exec(step);
|
|
390
|
+
if (!m) return false;
|
|
391
|
+
const js = unescapeShellSingleQuoted(m[1]);
|
|
392
|
+
const hypoDir = extractLintHypoDir(js);
|
|
393
|
+
if (hypoDir === null) return false;
|
|
394
|
+
return js === buildPreCommitResolverJs(LINT_SUFFIX, lintExtraArgvJs(hypoDir));
|
|
395
|
+
}
|
|
396
|
+
|
|
132
397
|
/**
|
|
133
398
|
* @param {string} content full pre-commit hook file content
|
|
134
399
|
* @param {{startIdx: number, endIdx: number}} span a `findMarkerSpan` result
|
|
135
400
|
* already confirmed `ok: true` for WIKI_PRE_COMMIT_MARKER_START/END
|
|
401
|
+
* @param {(root: string) => boolean} [isValidOldFormRoot] which predicate
|
|
402
|
+
* decides an OLD-form worker line's root is really ours. Defaults to the
|
|
403
|
+
* STRICT one (isRealOldFormInstallRoot), the right default for the only
|
|
404
|
+
* direct caller besides parseWikiPreCommitRoot below, uninstall.mjs's
|
|
405
|
+
* delete path, where a root this check cannot verify must not be trusted.
|
|
406
|
+
* parseWikiPreCommitRoot passes the REWRITE-safe one instead, since its
|
|
407
|
+
* consumers (upgrade.mjs's migration, doctor.mjs's report) only ever read
|
|
408
|
+
* or rewrite, never delete.
|
|
136
409
|
* @returns {boolean} true when the text between the markers is recognizable
|
|
137
|
-
* as a body
|
|
410
|
+
* as a body wikiPreCommitContent() writes NOW (the runtime-resolving form)
|
|
411
|
+
* or COULD HAVE WRITTEN in an older release (the version-pinned form) — see
|
|
412
|
+
* the module comment above PRE_COMMIT_WORKER_LINE for both shapes. A body
|
|
413
|
+
* 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.
|
|
138
415
|
*/
|
|
139
|
-
export function isOwnedWikiPreCommitBody(
|
|
416
|
+
export function isOwnedWikiPreCommitBody(
|
|
417
|
+
content,
|
|
418
|
+
span,
|
|
419
|
+
isValidOldFormRoot = isRealOldFormInstallRoot,
|
|
420
|
+
) {
|
|
140
421
|
const body = content.slice(span.startIdx + WIKI_PRE_COMMIT_MARKER_START.length, span.endIdx);
|
|
141
422
|
const lines = body.split('\n');
|
|
142
423
|
// wikiPreCommitContent() always places a bare "\n" right after START and
|
|
@@ -147,13 +428,36 @@ export function isOwnedWikiPreCommitBody(content, span) {
|
|
|
147
428
|
return false;
|
|
148
429
|
}
|
|
149
430
|
const steps = middle.slice(0, -1);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
431
|
+
|
|
432
|
+
const oldWorker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
|
|
433
|
+
const oldWorkerPath = oldWorker ? unescapeShellSingleQuoted(oldWorker[1]) : null;
|
|
434
|
+
const oldWorkerRoot =
|
|
435
|
+
oldWorkerPath && oldWorkerPath.endsWith(WORKER_SUFFIX)
|
|
436
|
+
? oldWorkerPath.slice(0, -WORKER_SUFFIX.length)
|
|
437
|
+
: null;
|
|
438
|
+
const workerIsOld = oldWorkerRoot !== null && isValidOldFormRoot(oldWorkerRoot);
|
|
439
|
+
const workerIsNew = isNewFormWorkerStep(steps[0]);
|
|
440
|
+
if (!workerIsOld && !workerIsNew) return false;
|
|
441
|
+
|
|
154
442
|
if (steps.length === 2) {
|
|
155
|
-
|
|
156
|
-
|
|
443
|
+
if (workerIsOld) {
|
|
444
|
+
const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
|
|
445
|
+
// The lint path must be EXACTLY this same root's lint script, not merely
|
|
446
|
+
// END in LINT_SUFFIX — codex reproduction (2026-09-11): a marker hook
|
|
447
|
+
// mixing a real Hypomnema worker path with an arbitrary user lint path
|
|
448
|
+
// (also ending in /scripts/lint.mjs, just under a different root) passed
|
|
449
|
+
// the old suffix-only check and was accepted as "ours", making it
|
|
450
|
+
// eligible for uninstall.mjs's delete and upgrade.mjs's rewrite —
|
|
451
|
+
// neither of which any writer in this codebase could have produced: the
|
|
452
|
+
// legacy writer always built both lines from the SAME root, so this
|
|
453
|
+
// mixed shape is not a form init.mjs's own history could leave behind,
|
|
454
|
+
// and rejecting it costs nothing a real install ever had.
|
|
455
|
+
if (!lint || unescapeShellSingleQuoted(lint[1]) !== oldWorkerRoot + LINT_SUFFIX) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
} else if (!isNewFormLintStep(steps[1])) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
157
461
|
}
|
|
158
462
|
return true;
|
|
159
463
|
}
|
|
@@ -166,19 +470,24 @@ export function shellSingleQuote(p) {
|
|
|
166
470
|
return `'${p.replace(/'/g, "'\\''")}'`;
|
|
167
471
|
}
|
|
168
472
|
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
473
|
+
// No install root is passed in here anymore: the generated body
|
|
474
|
+
// resolves it itself, AT COMMIT TIME, via buildPreCommitResolverJs's lookup
|
|
475
|
+
// chain (~/.claude/hypo-pkg.json, then the plugin registry). That is what
|
|
476
|
+
// makes this immune to the failure the old, root-baking version of this
|
|
477
|
+
// function had — a plugin-channel upgrade moves PKG_ROOT to a new version
|
|
478
|
+
// directory on every release, and nothing ever re-writes an already-installed
|
|
479
|
+
// hook, so a baked root goes stale by construction, on a schedule the vault
|
|
480
|
+
// owner does not control. Dual installs (a manual/npm init while the plugin
|
|
481
|
+
// is enabled) no longer need special handling here either: there is no root
|
|
482
|
+
// to pick between "the plugin's real cache root" and "the manual/npm
|
|
483
|
+
// checkout" at write time, because the hook's own copy of the resolver picks
|
|
484
|
+
// between them itself, every time it runs.
|
|
176
485
|
//
|
|
177
486
|
// The block runs its steps sequentially rather than tail-calling `exit $?` on
|
|
178
487
|
// the first one, so a second step (the opt-in --lint-strict gate below) can
|
|
179
488
|
// run after the .hypoignore guard instead of being unreachable dead code.
|
|
180
489
|
// `lintStrict` is baked into the generated shim at install time, so toggling it
|
|
181
|
-
// means re-running init with/without `--lint-strict` (or upgrade
|
|
490
|
+
// means re-running init with/without `--lint-strict` (or upgrade migrating an
|
|
182
491
|
// existing install), not editing the hook by hand.
|
|
183
492
|
//
|
|
184
493
|
// `hypoDir` MUST be absolutized before it is baked in. Git runs a pre-commit
|
|
@@ -187,53 +496,119 @@ export function shellSingleQuote(p) {
|
|
|
187
496
|
// re-resolved AT COMMIT TIME against that root instead of the directory the
|
|
188
497
|
// caller meant — `wiki` becomes `<wiki-root>/wiki`, a path that doesn't exist,
|
|
189
498
|
// and lint.mjs falls through to its own default resolution (HYPO_DIR/
|
|
190
|
-
// hypo-config.md scan) and may silently lint an unrelated vault.
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
export function wikiPreCommitContent(
|
|
499
|
+
// hypo-config.md scan) and may silently lint an unrelated vault. That is a
|
|
500
|
+
// vault path, not an install path, so unlike the root above it never goes
|
|
501
|
+
// stale across a release — it only needs resolving once, here.
|
|
502
|
+
export function wikiPreCommitContent(hypoDir, lintStrict) {
|
|
194
503
|
const absHypoDir = resolve(hypoDir);
|
|
195
|
-
const
|
|
196
|
-
|
|
504
|
+
const steps = [`node -e ${shellSingleQuote(buildPreCommitResolverJs(WORKER_SUFFIX))} || exit 1`];
|
|
505
|
+
if (lintStrict) {
|
|
506
|
+
const lintJs = buildPreCommitResolverJs(LINT_SUFFIX, lintExtraArgvJs(absHypoDir));
|
|
507
|
+
steps.push(`node -e ${shellSingleQuote(lintJs)} || exit 1`);
|
|
508
|
+
}
|
|
509
|
+
return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Rebuilds the exact bytes an OLD-form (version-pinned) pre-commit hook holds
|
|
513
|
+
// for a given (root, hypoDir, lintStrict) — the shape a pre-issue release of
|
|
514
|
+
// this writer would have produced, and the shape parseWikiPreCommitRoot reads
|
|
515
|
+
// an OLD-form body back INTO. `hypoDir` is used verbatim (never re-resolved):
|
|
516
|
+
// callers pass the exact string parseWikiPreCommitRoot already extracted from
|
|
517
|
+
// the file being reconstructed, so re-resolving it here could silently paper
|
|
518
|
+
// over a relative value that was never valid in the first place.
|
|
519
|
+
//
|
|
520
|
+
// Exported so upgrade.mjs's applyWikiPreCommitRoot can compare a file ON DISK
|
|
521
|
+
// against this byte-for-byte before overwriting the WHOLE FILE with the
|
|
522
|
+
// migrated (new-form) content. That check exists because isOwnedWikiPreCommitBody
|
|
523
|
+
// only validates the SPAN between the markers, never what sits before the start
|
|
524
|
+
// marker or after the end one — a hand-crafted file combining a forged-but-valid
|
|
525
|
+
// marker span with real content outside it would pass every check up to that
|
|
526
|
+
// point, and an unconditional whole-file write would silently discard the
|
|
527
|
+
// outside content (codex reproduction, 2026-09-11). Requiring the CURRENT file
|
|
528
|
+
// to already equal this reconstruction proves there is nothing outside the span
|
|
529
|
+
// to lose before the overwrite is allowed to happen.
|
|
530
|
+
export function oldFormPreCommitContent(root, hypoDir, lintStrict) {
|
|
531
|
+
const steps = [`node ${shellSingleQuote(root + WORKER_SUFFIX)} || exit 1`];
|
|
197
532
|
if (lintStrict) {
|
|
198
|
-
const lintScript = join(root, 'scripts', 'lint.mjs');
|
|
199
533
|
steps.push(
|
|
200
|
-
`node ${shellSingleQuote(
|
|
534
|
+
`node ${shellSingleQuote(root + LINT_SUFFIX)} --hypo-dir=${shellSingleQuote(hypoDir)} --strict || exit 1`,
|
|
201
535
|
);
|
|
202
536
|
}
|
|
203
537
|
return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
|
|
204
538
|
}
|
|
205
539
|
|
|
206
|
-
// Read the install root, --lint-strict shape, and
|
|
207
|
-
// --hypo-dir currently baked into a wiki's
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
540
|
+
// Read the install root (OLD form only — see below), --lint-strict shape, and
|
|
541
|
+
// (when present) the embedded --hypo-dir currently baked into a wiki's
|
|
542
|
+
// pre-commit hook, so upgrade.mjs can migrate it and doctor.mjs can report on
|
|
543
|
+
// it — without either duplicating the body-shape rules isOwnedWikiPreCommitBody
|
|
544
|
+
// already enforces. Returns `{ ok: false }` for anything that isn't a body
|
|
545
|
+
// wikiPreCommitContent() writes now or wrote in an older release (see the
|
|
546
|
+
// module comment above PRE_COMMIT_WORKER_LINE for both shapes): a
|
|
212
547
|
// missing/duplicated marker pair, a user's own hook, or one too malformed to
|
|
213
|
-
// trust.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
// --hypo-dir value when `lintStrict` is true, else `null`
|
|
217
|
-
// bakes one in
|
|
548
|
+
// trust.
|
|
549
|
+
//
|
|
550
|
+
// `ok: true` results always carry a `lintStrict` flag and `hypoDir` (the
|
|
551
|
+
// embedded --hypo-dir value when `lintStrict` is true, else `null` — a plain
|
|
552
|
+
// hook never bakes one in, in either form). `root` is where the two forms
|
|
553
|
+
// diverge: for an OLD-form body it is the absolute install root baked into the
|
|
554
|
+
// worker line (validated by the WORKER_SUFFIX check below, mirroring
|
|
555
|
+
// isOwnedWikiPreCommitBody). For a NEW-form body — the runtime-resolving
|
|
556
|
+
// shape this issue introduces — there is no baked root to return: the hook
|
|
557
|
+
// resolves it itself at commit time, so `root` is `null`. A caller must treat
|
|
558
|
+
// `root === null` as "already on the form that never goes stale", never as
|
|
559
|
+
// "root could not be determined" (that failure is `ok: false`).
|
|
560
|
+
//
|
|
561
|
+
// An OLD-form worker line is accepted here even when its root no longer
|
|
562
|
+
// exists on disk (isRewritableOldFormInstallRoot, not the strict
|
|
563
|
+
// isRealOldFormInstallRoot uninstall.mjs's delete path still uses): every
|
|
564
|
+
// caller of this function only reads or rewrites the hook, never deletes it,
|
|
565
|
+
// and a moved-or-reinstalled Hypomnema is exactly what a gone root looks like
|
|
566
|
+
// (2026-09-11: refusing to migrate there left every vault commit failing
|
|
567
|
+
// MODULE_NOT_FOUND with no in-product recovery).
|
|
568
|
+
//
|
|
569
|
+
// `ok: false` results still carry `hasMarker`: true when the content has our
|
|
570
|
+
// start marker at all (whatever is inside it failed to parse: a corrupted or
|
|
571
|
+
// hand-edited body), false when there is no marker here to begin with (not
|
|
572
|
+
// our hook). Callers that only care about migration can ignore it; doctor.mjs
|
|
573
|
+
// and upgrade.mjs use it to tell "nothing installed" apart from "installed
|
|
574
|
+
// but unreadable", which used to collapse into the same silent `ok: false`.
|
|
575
|
+
//
|
|
576
|
+
// The caller that migrates an OLD-form hook to the new one must reuse this
|
|
218
577
|
// `hypoDir` verbatim rather than the CURRENT run's --hypo-dir — the two are
|
|
219
578
|
// not guaranteed to be the same directory.
|
|
220
579
|
export function parseWikiPreCommitRoot(content) {
|
|
580
|
+
const hasMarker = content.includes(WIKI_PRE_COMMIT_MARKER_START);
|
|
221
581
|
const span = findMarkerSpan(content, WIKI_PRE_COMMIT_MARKER_START, WIKI_PRE_COMMIT_MARKER_END);
|
|
222
|
-
if (!span.ok || !isOwnedWikiPreCommitBody(content, span))
|
|
582
|
+
if (!span.ok || !isOwnedWikiPreCommitBody(content, span, isRewritableOldFormInstallRoot)) {
|
|
583
|
+
return { ok: false, hasMarker };
|
|
584
|
+
}
|
|
223
585
|
const body = content.slice(span.startIdx + WIKI_PRE_COMMIT_MARKER_START.length, span.endIdx);
|
|
224
586
|
const steps = body.split('\n').slice(1, -2); // drop leading '', trailing 'exit 0' + ''
|
|
225
|
-
const worker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
|
|
226
|
-
const workerPath = unescapeShellSingleQuoted(worker[1]);
|
|
227
|
-
const root = workerPath.slice(0, -'/hooks/hypo-pre-commit.mjs'.length);
|
|
228
587
|
const lintStrict = steps.length === 2;
|
|
588
|
+
|
|
589
|
+
const oldWorker = PRE_COMMIT_WORKER_LINE.exec(steps[0]);
|
|
590
|
+
if (oldWorker) {
|
|
591
|
+
const workerPath = unescapeShellSingleQuoted(oldWorker[1]);
|
|
592
|
+
const root = workerPath.slice(0, -WORKER_SUFFIX.length);
|
|
593
|
+
let hypoDir = null;
|
|
594
|
+
if (lintStrict) {
|
|
595
|
+
// isOwnedWikiPreCommitBody already confirmed steps[1] matches this shape,
|
|
596
|
+
// so the exec here cannot fail.
|
|
597
|
+
const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
|
|
598
|
+
hypoDir = unescapeShellSingleQuoted(lint[2]);
|
|
599
|
+
}
|
|
600
|
+
return { ok: true, hasMarker: true, root, lintStrict, hypoDir };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// NEW form: isOwnedWikiPreCommitBody already confirmed steps[0] is an exact
|
|
604
|
+
// resolver script and, when lintStrict, that steps[1] embeds a --hypo-dir
|
|
605
|
+
// extractLintHypoDir can recover — so neither exec below needs a guard.
|
|
229
606
|
let hypoDir = null;
|
|
230
607
|
if (lintStrict) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const lint = PRE_COMMIT_LINT_LINE.exec(steps[1]);
|
|
234
|
-
hypoDir = unescapeShellSingleQuoted(lint[2]);
|
|
608
|
+
const js = unescapeShellSingleQuoted(PRE_COMMIT_RESOLVER_LINE.exec(steps[1])[1]);
|
|
609
|
+
hypoDir = extractLintHypoDir(js);
|
|
235
610
|
}
|
|
236
|
-
return { ok: true, root, lintStrict, hypoDir };
|
|
611
|
+
return { ok: true, hasMarker: true, root: null, lintStrict, hypoDir };
|
|
237
612
|
}
|
|
238
613
|
|
|
239
614
|
// The exact text init.mjs's shellFunctionBlock() writes between the shell
|