hypomnema 1.7.1 → 1.7.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/README.ko.md +79 -50
- package/README.md +63 -34
- package/hooks/hypo-session-start.mjs +60 -4
- package/hooks/hypo-shared.mjs +187 -87
- package/hooks/version-check.mjs +47 -0
- package/package.json +2 -1
- package/scripts/doctor.mjs +149 -5
- package/scripts/init.mjs +9 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/hypo-config.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { join, relative, basename, dirname, isAbsolute } from 'path';
|
|
25
25
|
import { homedir, hostname, tmpdir } from 'os';
|
|
26
26
|
import { spawnSync } from 'child_process';
|
|
27
|
-
import { randomBytes } from 'crypto';
|
|
27
|
+
import { randomBytes, createHash } from 'crypto';
|
|
28
28
|
import { fileURLToPath } from 'url';
|
|
29
29
|
|
|
30
30
|
const HOME = homedir();
|
|
@@ -149,7 +149,14 @@ function readCachedPkgRoot() {
|
|
|
149
149
|
// Hypomnema — e.g. `$HOME/package.json` from an unrelated project. This
|
|
150
150
|
// contract answers "is this a real, resolvable directory", not "is this OUR
|
|
151
151
|
// package". Callers that need the latter also require self-containment.
|
|
152
|
-
|
|
152
|
+
//
|
|
153
|
+
// Exported so scripts/doctor.mjs can apply the SAME predicate to a sidecar's
|
|
154
|
+
// recorded pkgRoot that readVerifiedProvenancePkgRoot() below applies at
|
|
155
|
+
// runtime — doctor used to hand-roll a thinner name+hash check that a
|
|
156
|
+
// version-less "name":"hypomnema" root would pass while the runtime resolver
|
|
157
|
+
// rejects it, so doctor could PASS a sidecar the runtime treats as null. The
|
|
158
|
+
// import direction stays scripts/ → hooks/, never the reverse.
|
|
159
|
+
export function isUsablePkgRootLocal(pkgRoot) {
|
|
153
160
|
if (typeof pkgRoot !== 'string' || !pkgRoot || !isAbsolute(pkgRoot)) return false;
|
|
154
161
|
try {
|
|
155
162
|
const v = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
|
|
@@ -205,16 +212,25 @@ function candidateContainsRunningModule(candidateRoot, ownRealPath) {
|
|
|
205
212
|
// candidateContainsRunningModule rejects it: that ancestor's own hooks/
|
|
206
213
|
// subdirectory (if it even has one) is not this file.
|
|
207
214
|
//
|
|
208
|
-
// Bounded walk: up to 6 candidate ancestors above
|
|
215
|
+
// Bounded walk: up to 6 candidate ancestors above hooksDir's own parent (the
|
|
209
216
|
// ordinary root sits at the very first one; a few extra levels tolerate an
|
|
210
217
|
// unusual nesting depth). Never throws, fails open to null on any error.
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
//
|
|
219
|
+
// Split out from selfLocationPkgRoot() (below) so scripts/doctor.mjs can ask
|
|
220
|
+
// the same question about an ARBITRARY installed hooks directory
|
|
221
|
+
// (~/.claude/hooks, ~/.codex/hooks) instead of only about wherever THIS
|
|
222
|
+
// running module happens to live. doctor needs that to tell "self-location
|
|
223
|
+
// genuinely can't resolve for this install" (the standalone-copy steady
|
|
224
|
+
// state, silence is correct) apart from "self-location would resolve but the
|
|
225
|
+
// provenance sidecar is missing/broken" (a real gap — see CONCERN 4's
|
|
226
|
+
// PKG_ROOT-null-and-silent fix in checkProvenanceSidecar). Exported;
|
|
227
|
+
// scripts/ → hooks/ stays the only allowed import direction.
|
|
228
|
+
export function selfLocationPkgRootFrom(hooksDir) {
|
|
229
|
+
let ownRealPath;
|
|
213
230
|
try {
|
|
214
|
-
hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
215
231
|
ownRealPath = realpathSync(join(hooksDir, 'hypo-shared.mjs'));
|
|
216
232
|
} catch {
|
|
217
|
-
return null; // can't even resolve
|
|
233
|
+
return null; // can't even resolve the module at hooksDir — nothing to self-contain against
|
|
218
234
|
}
|
|
219
235
|
try {
|
|
220
236
|
let dir = dirname(hooksDir); // first candidate: the ordinary root, one level above hooks/
|
|
@@ -232,16 +248,93 @@ function selfLocationPkgRoot() {
|
|
|
232
248
|
}
|
|
233
249
|
}
|
|
234
250
|
|
|
251
|
+
function selfLocationPkgRoot() {
|
|
252
|
+
let hooksDir;
|
|
253
|
+
try {
|
|
254
|
+
hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
255
|
+
} catch {
|
|
256
|
+
return null; // can't even resolve our own path
|
|
257
|
+
}
|
|
258
|
+
return selfLocationPkgRootFrom(hooksDir);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Copy-time provenance sidecar (`.hypo-provenance.json`, written next to a
|
|
262
|
+
// standalone-copied hooks/ dir by installHooks/applyHookFiles — see
|
|
263
|
+
// scripts/lib/pkg-provenance.mjs, the writer half of this contract) is the
|
|
264
|
+
// ONLY fallback resolvePkgRoot() gets when self-location can't resolve. The
|
|
265
|
+
// filename and the "hypomnema" name check below must stay byte-identical
|
|
266
|
+
// with scripts/lib/pkg-provenance.mjs — hooks can't import scripts/ (no
|
|
267
|
+
// reaching outside hooks/), so the contract is duplicated, not shared.
|
|
268
|
+
//
|
|
269
|
+
// This is accidental-staleness protection, not a security boundary: any
|
|
270
|
+
// process running as this OS user can edit this JSON file (or hypo-shared.mjs
|
|
271
|
+
// itself) freely. It only catches what installHooks/applyHookFiles left
|
|
272
|
+
// unguarded before this fix — a manual-install hooks/ copy left pointing at
|
|
273
|
+
// an old pkgVersion because a skip-then-refresh-the-cache-anyway sequence
|
|
274
|
+
// (init re-run against an unchanged hooks/ dir) recorded a version the copy
|
|
275
|
+
// on disk never actually became.
|
|
276
|
+
//
|
|
277
|
+
// Producer proof, BOTH required before this pkgRoot is trusted:
|
|
278
|
+
// - the recorded pkgRoot's package.json "name" must be "hypomnema" (not
|
|
279
|
+
// merely "some usable versioned package.json" — isUsablePkgRootLocal
|
|
280
|
+
// alone would accept $HOME/package.json from an unrelated project)
|
|
281
|
+
// - the recorded hypoSharedSha256 must match the SHA-256 of THIS running
|
|
282
|
+
// hypo-shared.mjs file — ties the sidecar to the exact copy it was
|
|
283
|
+
// written next to, so a sidecar surviving a partial re-install (new
|
|
284
|
+
// hooks/*.mjs dropped in, old sidecar left behind) is rejected rather
|
|
285
|
+
// than silently trusted.
|
|
286
|
+
//
|
|
287
|
+
// Scope, stated honestly: hypoSharedSha256 pins ONE file — this file. A
|
|
288
|
+
// sidecar surviving a re-install that touched some OTHER hook (e.g.
|
|
289
|
+
// hypo-personal-check.mjs got a new version, hypo-shared.mjs itself didn't
|
|
290
|
+
// change a byte) still passes this check and PKG_ROOT still resolves,
|
|
291
|
+
// because the running module — the one thing this function can verify
|
|
292
|
+
// without cost — never changed. Catching that wider drift needs hashing
|
|
293
|
+
// every file hooks.json wires up, which is too expensive to do on every
|
|
294
|
+
// hook load (this function runs on every single hook invocation); that
|
|
295
|
+
// broader, once-per-`doctor`-run check is `hooksDigest`
|
|
296
|
+
// (scripts/lib/pkg-provenance.mjs's computeHooksDigest, verified by
|
|
297
|
+
// scripts/doctor.mjs), deliberately NOT read here.
|
|
298
|
+
const PROVENANCE_FILENAME = '.hypo-provenance.json';
|
|
299
|
+
const EXPECTED_PKG_NAME = 'hypomnema';
|
|
300
|
+
|
|
301
|
+
function readVerifiedProvenancePkgRoot(hooksDir) {
|
|
302
|
+
try {
|
|
303
|
+
const raw = JSON.parse(readFileSync(join(hooksDir, PROVENANCE_FILENAME), 'utf-8'));
|
|
304
|
+
const { pkgRoot, hypoSharedSha256 } = raw || {};
|
|
305
|
+
if (!isUsablePkgRootLocal(pkgRoot)) return null;
|
|
306
|
+
const producerPkgJson = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
|
|
307
|
+
if (producerPkgJson.name !== EXPECTED_PKG_NAME) return null;
|
|
308
|
+
if (typeof hypoSharedSha256 !== 'string' || !hypoSharedSha256) return null;
|
|
309
|
+
const ownHash = createHash('sha256')
|
|
310
|
+
.update(readFileSync(join(hooksDir, 'hypo-shared.mjs')))
|
|
311
|
+
.digest('hex');
|
|
312
|
+
if (ownHash !== hypoSharedSha256) return null;
|
|
313
|
+
return pkgRoot;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
235
319
|
// Resolution order: self-location wins whenever it resolves — it is a direct,
|
|
236
320
|
// self-containment-verified fact about the code currently running, not an
|
|
237
321
|
// inference. Only when it cannot resolve (the standalone-copied hooks case
|
|
238
|
-
// above, or a genuine read failure) does the
|
|
239
|
-
//
|
|
322
|
+
// above, or a genuine read failure) does the verified provenance sidecar get
|
|
323
|
+
// to answer, checked against the same directory this module is actually
|
|
324
|
+
// running from. The cache (readCachedPkgRoot/hypo-pkg.json) is deliberately
|
|
325
|
+
// NOT a resolution fallback here — a disagreeing provenance sidecar means
|
|
326
|
+
// "stop", not "ask the cache", because the cache is exactly what can be
|
|
327
|
+
// stale (that staleness is this fix's whole premise). readCachedPkgRoot stays
|
|
328
|
+
// in this file only for pkgRootDriftStatus()'s surfacing comparison below.
|
|
240
329
|
function resolvePkgRoot() {
|
|
241
330
|
const self = selfLocationPkgRoot();
|
|
242
331
|
if (self) return self;
|
|
243
|
-
|
|
244
|
-
|
|
332
|
+
try {
|
|
333
|
+
const hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
334
|
+
return readVerifiedProvenancePkgRoot(hooksDir);
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
245
338
|
}
|
|
246
339
|
export const PKG_ROOT = resolvePkgRoot();
|
|
247
340
|
|
|
@@ -709,7 +802,10 @@ function gitIgnoresPageUsageCached(hypoDir, sessionId, probeFn = runGitCheckIgno
|
|
|
709
802
|
if (!probe || (probe.status !== 0 && probe.status !== 1)) {
|
|
710
803
|
if (scoped) {
|
|
711
804
|
try {
|
|
712
|
-
writeFileSync(
|
|
805
|
+
writeFileSync(
|
|
806
|
+
cachePath,
|
|
807
|
+
JSON.stringify({ unavailableUntil: Date.now() + PROBE_BACKOFF_MS }),
|
|
808
|
+
);
|
|
713
809
|
} catch {
|
|
714
810
|
// non-fatal; the probe just runs again next prompt
|
|
715
811
|
}
|
|
@@ -1628,89 +1724,89 @@ export function withFileLock(targetPath, fn, opts = {}) {
|
|
|
1628
1724
|
let loggedLiveHolder = false;
|
|
1629
1725
|
let loggedSteal = false;
|
|
1630
1726
|
try {
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1727
|
+
for (;;) {
|
|
1728
|
+
try {
|
|
1729
|
+
if (!staged) {
|
|
1730
|
+
try {
|
|
1731
|
+
writeFileSync(tmpPath, String(process.pid), { flag: 'wx' });
|
|
1732
|
+
staged = true;
|
|
1733
|
+
} catch (stageErr) {
|
|
1734
|
+
// Staging fails for the same reason acquisition does — an unwritable
|
|
1735
|
+
// directory — and `openSync(lock,'wx')` used to report exactly that as
|
|
1736
|
+
// EEXIST whenever a lock was already sitting there, sending it down the
|
|
1737
|
+
// contention path. Preserve that: contend if a lock exists, and surface
|
|
1738
|
+
// a genuine write failure otherwise rather than masking it as a timeout.
|
|
1739
|
+
if (!existsSync(lockPath)) throw stageErr;
|
|
1740
|
+
throw Object.assign(new Error('lock-contended'), { code: 'EEXIST' });
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
// Kept separate from staging on purpose: EPERM/EMLINK from the link are
|
|
1744
|
+
// real failures, not contention, and must not decay into ELOCKTIMEOUT.
|
|
1745
|
+
linkSync(tmpPath, lockPath);
|
|
1746
|
+
break;
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
if (err.code !== 'EEXIST') throw err;
|
|
1749
|
+
// Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
|
|
1750
|
+
// wait and eventually time out. The stat and the unlink are handled
|
|
1751
|
+
// separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
|
|
1752
|
+
// and a fresh lock must both fall through to the timeout check — never
|
|
1753
|
+
// `continue` past it, or an un-unlinkable lock spins forever and violates
|
|
1754
|
+
// the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
|
|
1755
|
+
let stale = false;
|
|
1634
1756
|
try {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
// contention path. Preserve that: contend if a lock exists, and surface
|
|
1642
|
-
// a genuine write failure otherwise rather than masking it as a timeout.
|
|
1643
|
-
if (!existsSync(lockPath)) throw stageErr;
|
|
1644
|
-
throw Object.assign(new Error('lock-contended'), { code: 'EEXIST' });
|
|
1757
|
+
// Steal-eligible by age alone; liveness is checked separately below
|
|
1758
|
+
// before we actually act on it.
|
|
1759
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
1760
|
+
} catch (statErr) {
|
|
1761
|
+
if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
|
|
1762
|
+
throw statErr; // unexpected stat failure — surface it, don't mask
|
|
1645
1763
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
// before we actually act on it.
|
|
1663
|
-
stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
1664
|
-
} catch (statErr) {
|
|
1665
|
-
if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
|
|
1666
|
-
throw statErr; // unexpected stat failure — surface it, don't mask
|
|
1667
|
-
}
|
|
1668
|
-
if (stale) {
|
|
1669
|
-
const holderPid = readLockHolderPid(lockPath);
|
|
1670
|
-
if (holderPid !== null && isPidAlive(holderPid)) {
|
|
1671
|
-
// LIVE holder preempted past staleMs: do NOT steal. Surface it so the
|
|
1672
|
-
// preemption is visible, then fall through to the poll/timeout path
|
|
1673
|
-
// below instead of racing a second writer into the critical section.
|
|
1674
|
-
if (!loggedLiveHolder) {
|
|
1764
|
+
if (stale) {
|
|
1765
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
1766
|
+
if (holderPid !== null && isPidAlive(holderPid)) {
|
|
1767
|
+
// LIVE holder preempted past staleMs: do NOT steal. Surface it so the
|
|
1768
|
+
// preemption is visible, then fall through to the poll/timeout path
|
|
1769
|
+
// below instead of racing a second writer into the critical section.
|
|
1770
|
+
if (!loggedLiveHolder) {
|
|
1771
|
+
console.error(
|
|
1772
|
+
`[hypomnema] withFileLock: NOT stealing ${lockPath} — holder pid ${holderPid} is still alive past staleMs=${staleMs}`,
|
|
1773
|
+
);
|
|
1774
|
+
loggedLiveHolder = true;
|
|
1775
|
+
}
|
|
1776
|
+
stale = false;
|
|
1777
|
+
} else if (!loggedSteal) {
|
|
1778
|
+
// Also once per acquire: an un-removable stale lock re-enters this
|
|
1779
|
+
// branch on every poll, and the steal is one event either way.
|
|
1675
1780
|
console.error(
|
|
1676
|
-
`[hypomnema] withFileLock:
|
|
1781
|
+
`[hypomnema] withFileLock: stealing stale lock ${lockPath}` +
|
|
1782
|
+
(holderPid !== null
|
|
1783
|
+
? ` (holder pid ${holderPid} is no longer running)`
|
|
1784
|
+
: ' (no readable holder pid — pre-liveness lockfile)'),
|
|
1677
1785
|
);
|
|
1678
|
-
|
|
1786
|
+
loggedSteal = true;
|
|
1679
1787
|
}
|
|
1680
|
-
stale = false;
|
|
1681
|
-
} else if (!loggedSteal) {
|
|
1682
|
-
// Also once per acquire: an un-removable stale lock re-enters this
|
|
1683
|
-
// branch on every poll, and the steal is one event either way.
|
|
1684
|
-
console.error(
|
|
1685
|
-
`[hypomnema] withFileLock: stealing stale lock ${lockPath}` +
|
|
1686
|
-
(holderPid !== null
|
|
1687
|
-
? ` (holder pid ${holderPid} is no longer running)`
|
|
1688
|
-
: ' (no readable holder pid — pre-liveness lockfile)'),
|
|
1689
|
-
);
|
|
1690
|
-
loggedSteal = true;
|
|
1691
1788
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1789
|
+
if (stale) {
|
|
1790
|
+
try {
|
|
1791
|
+
unlinkSync(lockPath);
|
|
1792
|
+
continue; // stole it; retry the create immediately
|
|
1793
|
+
} catch (unlinkErr) {
|
|
1794
|
+
if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
|
|
1795
|
+
// Cannot remove it: do NOT spin — fall through to timeout/sleep so
|
|
1796
|
+
// acquisition eventually throws ELOCKTIMEOUT instead of hanging.
|
|
1797
|
+
}
|
|
1701
1798
|
}
|
|
1799
|
+
if (Date.now() - start > timeoutMs) {
|
|
1800
|
+
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1801
|
+
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1802
|
+
// which must NOT be masked as a timeout.
|
|
1803
|
+
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1804
|
+
e.code = 'ELOCKTIMEOUT';
|
|
1805
|
+
throw e;
|
|
1806
|
+
}
|
|
1807
|
+
sleepSync(pollMs);
|
|
1702
1808
|
}
|
|
1703
|
-
if (Date.now() - start > timeoutMs) {
|
|
1704
|
-
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1705
|
-
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1706
|
-
// which must NOT be masked as a timeout.
|
|
1707
|
-
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1708
|
-
e.code = 'ELOCKTIMEOUT';
|
|
1709
|
-
throw e;
|
|
1710
|
-
}
|
|
1711
|
-
sleepSync(pollMs);
|
|
1712
1809
|
}
|
|
1713
|
-
}
|
|
1714
1810
|
} finally {
|
|
1715
1811
|
// The sibling is only ever a staging file: once linked, the lock IS the
|
|
1716
1812
|
// link, and on every failure path it must not survive as litter.
|
|
@@ -4154,7 +4250,11 @@ const CLOSE_COMMIT_MESSAGE = new RegExp(
|
|
|
4154
4250
|
* is the YYYY-MM-DD captured from the artifact's own heading, or null for a
|
|
4155
4251
|
* commit-message match (the caller already has the commit's date).
|
|
4156
4252
|
*/
|
|
4157
|
-
export function detectSessionCloseArtifact({
|
|
4253
|
+
export function detectSessionCloseArtifact({
|
|
4254
|
+
path = null,
|
|
4255
|
+
content = null,
|
|
4256
|
+
commitMessage = null,
|
|
4257
|
+
} = {}) {
|
|
4158
4258
|
if (typeof commitMessage === 'string' && CLOSE_COMMIT_MESSAGE.test(commitMessage)) {
|
|
4159
4259
|
return { matched: true, kind: 'commit-message', date: null };
|
|
4160
4260
|
}
|
package/hooks/version-check.mjs
CHANGED
|
@@ -430,6 +430,53 @@ export function clearPkgRootDriftNotified(path) {
|
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// ── pkgRoot-null notice ─────────────────────────────────────────────────────
|
|
434
|
+
// A DIFFERENT failure than drift above: drift only ever fires when
|
|
435
|
+
// self-location resolved (PKG_ROOT is non-null, just disagreeing with the
|
|
436
|
+
// cache). This one fires when PKG_ROOT itself resolved to null — self-location
|
|
437
|
+
// failed AND no verified provenance sidecar covered it — the case where
|
|
438
|
+
// PreCompact's lint/feedback calls silently no-op because they have no root to
|
|
439
|
+
// shell scripts through. The two conditions can never both hold in the same
|
|
440
|
+
// session (drift requires a non-null self-location), so there is no "which
|
|
441
|
+
// wins" question in practice, but the two use separate cache fields regardless
|
|
442
|
+
// so neither implementation depends on that being true forever.
|
|
443
|
+
// Boolean (not a pair-key like drift) — the notified state is just "already
|
|
444
|
+
// told them this session's install has no resolvable pkgRoot".
|
|
445
|
+
|
|
446
|
+
/** Has the PKG_ROOT-null state already been surfaced since it last cleared? */
|
|
447
|
+
export function pkgRootNullAlreadyNotified(cache) {
|
|
448
|
+
return Boolean(cache && cache.pkgRootNullNotified === true);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Record that the PKG_ROOT-null banner was shown (read-merge-write). */
|
|
452
|
+
export function markPkgRootNullNotified(path) {
|
|
453
|
+
try {
|
|
454
|
+
const cache = readCache(path) || {};
|
|
455
|
+
cache.pkgRootNullNotified = true;
|
|
456
|
+
writeCacheAtomic(path, cache);
|
|
457
|
+
} catch {
|
|
458
|
+
/* best-effort */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Clear a previously-recorded PKG_ROOT-null mark once PKG_ROOT resolves again
|
|
464
|
+
* (self-location or a verified provenance sidecar). Without this, a null state
|
|
465
|
+
* that resolves and then recurs later (e.g. the provenance sidecar's hash
|
|
466
|
+
* binding breaks again after a partial re-install) would stay silently
|
|
467
|
+
* suppressed forever. Read-merge-write, best-effort.
|
|
468
|
+
*/
|
|
469
|
+
export function clearPkgRootNullNotified(path) {
|
|
470
|
+
try {
|
|
471
|
+
const cache = readCache(path);
|
|
472
|
+
if (!cache || !('pkgRootNullNotified' in cache)) return;
|
|
473
|
+
delete cache.pkgRootNullNotified;
|
|
474
|
+
writeCacheAtomic(path, cache);
|
|
475
|
+
} catch {
|
|
476
|
+
/* best-effort */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
433
480
|
/**
|
|
434
481
|
* Shared one-line message for the init/upgrade downgrade guard (P). `op` is
|
|
435
482
|
* 'init' or 'upgrade'. Kept here so guard text stays identical across both CLIs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"scripts/lib/hypo-root.mjs",
|
|
39
39
|
"scripts/lib/page-usage.mjs",
|
|
40
40
|
"scripts/lib/pkg-json.mjs",
|
|
41
|
+
"scripts/lib/pkg-provenance.mjs",
|
|
41
42
|
"scripts/lib/plugin-detect.mjs",
|
|
42
43
|
"scripts/lib/project-create.mjs",
|
|
43
44
|
"scripts/lib/rename-marker.mjs",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
detectSessionCloseArtifact,
|
|
32
32
|
localAndUtcDates,
|
|
33
33
|
SESSION_CLOSED_MARKER_STALE_MS,
|
|
34
|
+
isUsablePkgRootLocal,
|
|
35
|
+
selfLocationPkgRootFrom,
|
|
34
36
|
} from '../hooks/hypo-shared.mjs';
|
|
35
37
|
import { listProposals } from '../hooks/proposal-store.mjs';
|
|
36
38
|
import {
|
|
@@ -50,12 +52,19 @@ import {
|
|
|
50
52
|
CODEX_TYPES,
|
|
51
53
|
} from './lib/extensions.mjs';
|
|
52
54
|
import { sha256, readFileIfRegular, readPkgJson } from './lib/pkg-json.mjs';
|
|
55
|
+
import {
|
|
56
|
+
provenancePath,
|
|
57
|
+
EXPECTED_PKG_NAME,
|
|
58
|
+
HOOKS_DIGEST_FIELD,
|
|
59
|
+
computeHooksDigest,
|
|
60
|
+
} from './lib/pkg-provenance.mjs';
|
|
53
61
|
import { resolveCliOnPath, classifyInstall } from '../hooks/version-check.mjs';
|
|
54
62
|
import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
|
|
55
63
|
|
|
56
64
|
const HOME = homedir();
|
|
57
65
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
58
66
|
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
67
|
+
const HOOKS_SRC = join(PKG_ROOT, 'hooks');
|
|
59
68
|
|
|
60
69
|
// ── install channel ───────────────────────────────────────────────────────────
|
|
61
70
|
//
|
|
@@ -320,6 +329,8 @@ function checkHooks(coreManagedByPlugin) {
|
|
|
320
329
|
} else {
|
|
321
330
|
fail('Hook files installed', `No hook files found in ${claudeHooks} — run /hypo:init`);
|
|
322
331
|
}
|
|
332
|
+
|
|
333
|
+
checkProvenanceSidecar(claudeHooks, 'hooks/.hypo-provenance.json');
|
|
323
334
|
}
|
|
324
335
|
|
|
325
336
|
function checkSettingsJson(coreManagedByPlugin) {
|
|
@@ -813,11 +824,9 @@ function deriveCommitProjects(hypoDir, hash) {
|
|
|
813
824
|
// projects/a/hot.md → projects/b/hot.md rename would silently drop project
|
|
814
825
|
// a from scope — a marker naming only "b" would then wrongly cover the
|
|
815
826
|
// whole commit. -M's tab-separated `status\told\tnew` line carries both.
|
|
816
|
-
const show = spawnSync(
|
|
817
|
-
'
|
|
818
|
-
|
|
819
|
-
{ encoding: 'utf-8' },
|
|
820
|
-
);
|
|
827
|
+
const show = spawnSync('git', ['-C', hypoDir, 'show', '--name-status', '-M', '--format=', hash], {
|
|
828
|
+
encoding: 'utf-8',
|
|
829
|
+
});
|
|
821
830
|
if (show.status !== 0 || !show.stdout) return [];
|
|
822
831
|
const projects = new Set();
|
|
823
832
|
for (const line of show.stdout.split('\n')) {
|
|
@@ -1230,6 +1239,8 @@ function checkCodexPaths() {
|
|
|
1230
1239
|
);
|
|
1231
1240
|
}
|
|
1232
1241
|
|
|
1242
|
+
checkProvenanceSidecar(codexHooks, 'Codex hooks/.hypo-provenance.json');
|
|
1243
|
+
|
|
1233
1244
|
const settingsPath = join(HOME, '.codex', 'settings.json');
|
|
1234
1245
|
if (!existsSync(settingsPath)) {
|
|
1235
1246
|
warn(
|
|
@@ -1828,6 +1839,139 @@ function checkStaleSibling() {
|
|
|
1828
1839
|
}
|
|
1829
1840
|
}
|
|
1830
1841
|
|
|
1842
|
+
// ── provenance sidecar (manual/npm channel) ────────────────────────────────────
|
|
1843
|
+
//
|
|
1844
|
+
// `.hypo-provenance.json` lives next to a standalone-copied hooks/ dir
|
|
1845
|
+
// (installHooks/applyHookFiles — scripts/lib/pkg-provenance.mjs) and is what
|
|
1846
|
+
// hooks/hypo-shared.mjs's resolvePkgRoot() falls back to when self-location
|
|
1847
|
+
// can't resolve. Absent is a normal state whenever there is no INSTALLED
|
|
1848
|
+
// hooks/hypo-shared.mjs at `hooksDir` to worry about at all (plugin channel:
|
|
1849
|
+
// hooks run straight from CLAUDE_PLUGIN_ROOT, nothing is ever copied here;
|
|
1850
|
+
// a truly fresh, never-inited home) — that case stays silent. It is also
|
|
1851
|
+
// normal, and stays silent, when hypo-shared.mjs IS installed here but
|
|
1852
|
+
// self-locates on its own (a dev checkout whose "hooksDir" happens to be the
|
|
1853
|
+
// package's own hooks/, not a copy). The one PRESENT-but-broken and the one
|
|
1854
|
+
// ABSENT-but-should-not-be-silent case are both actionable (CONCERN 4):
|
|
1855
|
+
// - present sidecar that fails to verify → WARN, same three-way shape as
|
|
1856
|
+
// checkPkgIntegrity below, never FAIL (a broken sidecar just degrades
|
|
1857
|
+
// PKG_ROOT to null, surfaced live by hypo-session-start.mjs's
|
|
1858
|
+
// PKG_ROOT-null banner, not corrupting anything on disk)
|
|
1859
|
+
// - installed hypo-shared.mjs, self-location fails for it, AND no sidecar
|
|
1860
|
+
// at all → that combination IS "PKG_ROOT is null for every hook running
|
|
1861
|
+
// from here" (resolvePkgRoot() has nothing left to try), so silence here
|
|
1862
|
+
// would hide exactly the state the live banner already warns about
|
|
1863
|
+
function checkProvenanceSidecar(hooksDir, label) {
|
|
1864
|
+
const sidecarPath = provenancePath(hooksDir);
|
|
1865
|
+
if (!existsSync(sidecarPath)) {
|
|
1866
|
+
if (existsSync(join(hooksDir, 'hypo-shared.mjs')) && !selfLocationPkgRootFrom(hooksDir)) {
|
|
1867
|
+
warn(
|
|
1868
|
+
label,
|
|
1869
|
+
`no provenance sidecar, and this install's hooks cannot self-locate their ` +
|
|
1870
|
+
`package — PKG_ROOT resolves to null for every hook running from ${hooksDir}; ` +
|
|
1871
|
+
`run \`hypomnema upgrade --apply\` to write one`,
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
let sidecar;
|
|
1878
|
+
try {
|
|
1879
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8'));
|
|
1880
|
+
} catch {
|
|
1881
|
+
warn(
|
|
1882
|
+
label,
|
|
1883
|
+
`${sidecarPath} is not valid JSON — run \`hypomnema upgrade --apply\` to rewrite it`,
|
|
1884
|
+
);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const { pkgRoot, hypoSharedSha256, [HOOKS_DIGEST_FIELD]: hooksDigest } = sidecar || {};
|
|
1889
|
+
|
|
1890
|
+
// Same predicate the runtime resolver requires (isUsablePkgRootLocal, used
|
|
1891
|
+
// by readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) — imported,
|
|
1892
|
+
// not hand-rolled again here, so a version-less "name":"hypomnema" root can
|
|
1893
|
+
// no longer PASS this check while resolvePkgRoot() treats it as null.
|
|
1894
|
+
const usableOk = isUsablePkgRootLocal(pkgRoot);
|
|
1895
|
+
|
|
1896
|
+
let producerName = null;
|
|
1897
|
+
try {
|
|
1898
|
+
producerName = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).name;
|
|
1899
|
+
} catch {
|
|
1900
|
+
/* pkgRoot missing/unreadable — nameOk stays false below */
|
|
1901
|
+
}
|
|
1902
|
+
const nameOk = producerName === EXPECTED_PKG_NAME;
|
|
1903
|
+
|
|
1904
|
+
let hashOk = false;
|
|
1905
|
+
try {
|
|
1906
|
+
hashOk = sha256(readFileSync(join(hooksDir, 'hypo-shared.mjs'))) === hypoSharedSha256;
|
|
1907
|
+
} catch {
|
|
1908
|
+
/* hooks/hypo-shared.mjs unreadable in its own hooks dir — hashOk stays false */
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (usableOk && nameOk && hashOk) {
|
|
1912
|
+
// hooksDigest (BLOCKER A item 3) can still turn this into a warn: it is
|
|
1913
|
+
// folded into the SAME label/check rather than pushed as a second entry,
|
|
1914
|
+
// so a caller reading checks by label (every other check in this file,
|
|
1915
|
+
// and every test asserting on one) sees exactly one verdict per sidecar.
|
|
1916
|
+
const digestIssue = hooksDigestMismatch(hooksDir, hooksDigest);
|
|
1917
|
+
if (!digestIssue) {
|
|
1918
|
+
pass(label, `verified — resolves to ${pkgRoot}`);
|
|
1919
|
+
} else {
|
|
1920
|
+
warn(label, `verified (pkgRoot/hash), but ${digestIssue}`);
|
|
1921
|
+
}
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const reasons = [];
|
|
1925
|
+
if (!usableOk) {
|
|
1926
|
+
reasons.push(
|
|
1927
|
+
`recorded pkgRoot (${pkgRoot}) is not a usable package root (must be an absolute ` +
|
|
1928
|
+
`path to a directory whose package.json carries a version)`,
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
if (!nameOk) reasons.push(`recorded pkgRoot (${pkgRoot}) is not this package`);
|
|
1932
|
+
if (!hashOk)
|
|
1933
|
+
reasons.push('recorded hypoSharedSha256 does not match the installed hypo-shared.mjs');
|
|
1934
|
+
warn(
|
|
1935
|
+
label,
|
|
1936
|
+
`${sidecarPath} does not verify (${reasons.join('; ')}) — resolvePkgRoot() will treat ` +
|
|
1937
|
+
`PKG_ROOT as unresolved this session; run \`hypomnema upgrade --apply\` to refresh it`,
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// BLOCKER A item 3: the runtime SHA check above (hashOk) pins ONE file,
|
|
1942
|
+
// hypo-shared.mjs — see that function's own hooks/hypo-shared.mjs-side
|
|
1943
|
+
// comment for why. This pins every OTHER file hooks.json wires up too, once
|
|
1944
|
+
// per `hypomnema doctor` run rather than once per hook load (too expensive
|
|
1945
|
+
// to do there — see computeHooksDigest's doc comment in
|
|
1946
|
+
// scripts/lib/pkg-provenance.mjs). Skips silently when the sidecar predates
|
|
1947
|
+
// this field (an older init/upgrade wrote it before BLOCKER A existed) —
|
|
1948
|
+
// there is nothing recorded to compare against, and the runtime never reads
|
|
1949
|
+
// this field either, so staying quiet hides nothing the runtime already
|
|
1950
|
+
// trusts.
|
|
1951
|
+
//
|
|
1952
|
+
// Returns null when there is nothing to report (field absent, or digests
|
|
1953
|
+
// match), or a ready-to-append reason string naming a few diverged files.
|
|
1954
|
+
function hooksDigestMismatch(hooksDir, recordedDigest) {
|
|
1955
|
+
if (typeof recordedDigest !== 'string' || !recordedDigest) return null;
|
|
1956
|
+
const actualDigest = computeHooksDigest(PKG_ROOT, hooksDir);
|
|
1957
|
+
if (actualDigest === null || actualDigest === recordedDigest) return null;
|
|
1958
|
+
|
|
1959
|
+
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
1960
|
+
const diverged = allFiles.filter((file) => {
|
|
1961
|
+
try {
|
|
1962
|
+
return !readFileSync(join(hooksDir, file)).equals(readFileSync(join(HOOKS_SRC, file)));
|
|
1963
|
+
} catch {
|
|
1964
|
+
return true; // unreadable on either side counts as diverged
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
const examples = diverged.slice(0, 3).join(', ');
|
|
1968
|
+
return (
|
|
1969
|
+
`hooksDigest mismatch: ${diverged.length}/${allFiles.length} hook file(s) differ from ` +
|
|
1970
|
+
`the current package source${examples ? ` (e.g. ${examples})` : ''} — run ` +
|
|
1971
|
+
`\`hypomnema upgrade --apply\``
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1831
1975
|
// ── package integrity ─────────────────────────────────────────────────────────
|
|
1832
1976
|
//
|
|
1833
1977
|
// ~/.claude/hypo-pkg.json is a snapshot written once by init/upgrade and never
|
package/scripts/init.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
readFileIfRegular,
|
|
49
49
|
} from './lib/pkg-json.mjs';
|
|
50
50
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
51
|
+
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
51
52
|
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
52
53
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
53
54
|
import {
|
|
@@ -458,6 +459,14 @@ function installHooks(targetDir, dryRun) {
|
|
|
458
459
|
if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
|
|
459
460
|
log('created', dest);
|
|
460
461
|
}
|
|
462
|
+
// Refresh the provenance sidecar every run, even when every .mjs above was
|
|
463
|
+
// skipped as already-present: this is the standalone (manual/npm) channel
|
|
464
|
+
// only (the plugin channel never calls installHooks), and resolvePkgRoot()'s
|
|
465
|
+
// provenance fallback needs it to track the truth of what is actually on
|
|
466
|
+
// disk here, not just what a fresh copy left behind. See
|
|
467
|
+
// hooks/hypo-shared.mjs's readVerifiedProvenancePkgRoot() for the reader.
|
|
468
|
+
const sidecar = writeProvenanceSidecar(targetDir, PKG_ROOT, PKG_VERSION, HOOKS_SRC, dryRun);
|
|
469
|
+
if (sidecar) log('created', sidecar);
|
|
461
470
|
}
|
|
462
471
|
|
|
463
472
|
function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
|