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.
@@ -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
- function isUsablePkgRootLocal(pkgRoot) {
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 hooks/'s own parent (the
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
- function selfLocationPkgRoot() {
212
- let hooksDir, ownRealPath;
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 our own path — nothing to self-contain against
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 cache get to answer, and only
239
- // after passing the same usable-root contract as everything else here.
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
- const cached = readCachedPkgRoot();
244
- return isUsablePkgRootLocal(cached) ? cached : null;
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(cachePath, JSON.stringify({ unavailableUntil: Date.now() + PROBE_BACKOFF_MS }));
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
- for (;;) {
1632
- try {
1633
- if (!staged) {
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
- writeFileSync(tmpPath, String(process.pid), { flag: 'wx' });
1636
- staged = true;
1637
- } catch (stageErr) {
1638
- // Staging fails for the same reason acquisition does — an unwritable
1639
- // directory — and `openSync(lock,'wx')` used to report exactly that as
1640
- // EEXIST whenever a lock was already sitting there, sending it down the
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
- // Kept separate from staging on purpose: EPERM/EMLINK from the link are
1648
- // real failures, not contention, and must not decay into ELOCKTIMEOUT.
1649
- linkSync(tmpPath, lockPath);
1650
- break;
1651
- } catch (err) {
1652
- if (err.code !== 'EEXIST') throw err;
1653
- // Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
1654
- // wait and eventually time out. The stat and the unlink are handled
1655
- // separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
1656
- // and a fresh lock must both fall through to the timeout check — never
1657
- // `continue` past it, or an un-unlinkable lock spins forever and violates
1658
- // the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
1659
- let stale = false;
1660
- try {
1661
- // Steal-eligible by age alone; liveness is checked separately below
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: NOT stealing ${lockPath} — holder pid ${holderPid} is still alive past staleMs=${staleMs}`,
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
- loggedLiveHolder = true;
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
- if (stale) {
1694
- try {
1695
- unlinkSync(lockPath);
1696
- continue; // stole it; retry the create immediately
1697
- } catch (unlinkErr) {
1698
- if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
1699
- // Cannot remove it: do NOT spin — fall through to timeout/sleep so
1700
- // acquisition eventually throws ELOCKTIMEOUT instead of hanging.
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({ path = null, content = null, commitMessage = null } = {}) {
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
  }
@@ -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.1",
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",
@@ -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
- 'git',
818
- ['-C', hypoDir, 'show', '--name-status', '-M', '--format=', hash],
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) {