hypomnema 1.7.1 → 1.7.3

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.
@@ -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.3",
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",
@@ -42,9 +42,10 @@ import {
42
42
  unlinkSync,
43
43
  renameSync,
44
44
  realpathSync,
45
- lstatSync,
46
45
  openSync,
47
46
  closeSync,
47
+ statSync,
48
+ chmodSync,
48
49
  } from 'fs';
49
50
  import { randomBytes } from 'crypto';
50
51
  import { join, dirname, relative, sep } from 'path';
@@ -76,6 +77,7 @@ import {
76
77
  HOOK_EVENT_ALLOWLIST,
77
78
  SKILL_ROOT_FILE,
78
79
  EXT_PREFIX,
80
+ withSrcExecBits,
79
81
  } from './lib/extensions.mjs';
80
82
  import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
81
83
 
@@ -582,7 +584,12 @@ function log(msg) {
582
584
  // `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
583
585
  // already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
584
586
  // on an existing path of any kind, symlink included.
585
- function writeAtomic(dest, buf) {
587
+ // `srcMode`, when given, carries the source file's execute bit onto the wiki
588
+ // copy (openSync's own mode argument is clipped by umask same as writeFileSync,
589
+ // so this still has to be a separate chmod). Omitted for a manifest write: that
590
+ // content is JSON we generated, not a copy of something with a mode worth
591
+ // keeping.
592
+ function writeAtomic(dest, buf, srcMode) {
586
593
  const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
587
594
  const fd = openSync(tmp, 'wx');
588
595
  try {
@@ -590,6 +597,9 @@ function writeAtomic(dest, buf) {
590
597
  } finally {
591
598
  closeSync(fd);
592
599
  }
600
+ if (srcMode != null) {
601
+ chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
602
+ }
593
603
  try {
594
604
  renameSync(tmp, dest);
595
605
  } catch (err) {
@@ -719,7 +729,8 @@ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wiki
719
729
  madeDirs.add(cur);
720
730
  }
721
731
  }
722
- writeAtomic(destPath, readFileSync(f.srcPath));
732
+ const buf = readFileSync(f.srcPath);
733
+ writeAtomic(destPath, buf, statSync(f.srcPath).mode);
723
734
  rec.createdFiles.push(destPath);
724
735
  }
725
736
  }
@@ -814,22 +825,6 @@ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped,
814
825
  return;
815
826
  }
816
827
 
817
- // Content round-trips; the executable bit does not (forward-sync writes with the
818
- // default mode). Say so rather than let a captured `scripts/run.sh` arrive
819
- // non-executable on the far machine without a word.
820
- const execFiles = c.files.filter((f) => {
821
- try {
822
- return (lstatSync(f.srcPath).mode & 0o111) !== 0;
823
- } catch {
824
- return false;
825
- }
826
- });
827
- if (execFiles.length > 0) {
828
- log(
829
- `! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
830
- );
831
- }
832
-
833
828
  if (!args.dryRun) {
834
829
  // The ledger is owned by the caller so a throw MID-write is still recoverable: the
835
830
  // paths created before the failure are already recorded in it.
@@ -1055,7 +1050,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
1055
1050
  manifestPrevBuf: existingManifestBuf,
1056
1051
  };
1057
1052
  writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
1058
- writeAtomic(filePath, srcBuf);
1053
+ writeAtomic(filePath, srcBuf, statSync(c.srcPath).mode);
1059
1054
  created.push(rec);
1060
1055
  }
1061
1056
  captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
@@ -109,6 +109,7 @@ import {
109
109
  scopeVisible,
110
110
  readVisibilityScope,
111
111
  withFileLock,
112
+ extractTouchedWikiFilesWithTrust,
112
113
  } from '../hooks/hypo-shared.mjs';
113
114
  import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
114
115
  import { writeProposal } from '../hooks/proposal-store.mjs';
@@ -225,6 +226,35 @@ function requireProjectDir(args, slug) {
225
226
  }
226
227
  }
227
228
 
229
+ // When the global gate's own discovery (hot.md pointer table + today
230
+ // close-activity scan, both in hypo-shared.mjs) comes back with NO project at
231
+ // all, a real apply never hits that dead end: it is handed `payload.project`
232
+ // directly and never infers. --check-session-close has no payload, so its one
233
+ // remaining authoritative signal is the same session's own transcript — which
234
+ // project's files did THIS session actually touch. Reusing the exact
235
+ // evidence-resolution helper the widened-lint-scope path already trusts here
236
+ // keeps this a single inference vocabulary (touched wiki files), not a second
237
+ // one: the difference is only which project-shaped question gets asked of it.
238
+ // Never guessed: a transcript touching zero or more than one project's files
239
+ // leaves the check exactly as unresolved as it was before this fallback.
240
+ function deriveTouchedProject(hypoDir, transcriptPath) {
241
+ if (!transcriptPath) return null;
242
+ const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
243
+ // `trusted:false` means the walk itself may be incomplete (a missing/unreadable
244
+ // transcript, or a line that failed to parse). A truncated line could have named
245
+ // a SECOND project the walk never saw, so treating this Set as "the whole
246
+ // truth" would resolve a single-project reading off a scope that is only
247
+ // single-project because part of it is missing, exactly the ambiguity this
248
+ // fallback exists to refuse rather than guess through.
249
+ if (!trusted) return null;
250
+ const slugs = new Set();
251
+ for (const f of files) {
252
+ const m = /^projects\/([^/]+)\//.exec(f);
253
+ if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
254
+ }
255
+ return slugs.size === 1 ? [...slugs][0] : null;
256
+ }
257
+
228
258
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
229
259
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
230
260
  // flow can self-verify before /compact triggers PreCompact.
@@ -261,7 +291,7 @@ function runSessionCloseCheck(args) {
261
291
  args.transcriptPath ||
262
292
  (args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
263
293
  null;
264
- const status = precompactGateStatus(args.hypoDir, {
294
+ let status = precompactGateStatus(args.hypoDir, {
265
295
  ...(args.project
266
296
  ? { projectOverride: args.project }
267
297
  : checkTranscript
@@ -276,7 +306,30 @@ function runSessionCloseCheck(args) {
276
306
  // enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
277
307
  ...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
278
308
  });
309
+
310
+ // check/apply divergence (2026-08-25 QA): a real apply never hits discovery
311
+ // dead-ends because payload.project is required input, not an inference. This
312
+ // check has no payload, so when discovery finds NO project at all (not even
313
+ // the recency fallback), it retries scoped to whatever single project this
314
+ // session's own transcript shows it touching. This is a diagnostic estimate,
315
+ // not a preview of what a real apply will do: a payload's `project` field is
316
+ // whatever the caller puts there and can legitimately name a project the
317
+ // transcript never mentions. Only fires on a fully unresolved global result,
318
+ // and only on a TRUSTED single-project reading (see deriveTouchedProject): an
319
+ // already-successful discovery, an ambiguous/empty transcript, or one the walk
320
+ // could not fully read is left untouched rather than guessed at.
321
+ let inferredProject = null;
322
+ if (!args.project && !status.close.project) {
323
+ inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
324
+ if (inferredProject) {
325
+ status = precompactGateStatus(args.hypoDir, {
326
+ projectOverride: inferredProject,
327
+ ...(args.sessionId ? { sessionId: args.sessionId } : {}),
328
+ });
329
+ }
330
+ }
279
331
  const close = status.close;
332
+ const scopedProject = args.project || inferredProject;
280
333
 
281
334
  // When a --session-id is supplied, report whether THIS session's
282
335
  // per-session marker (the Stop-chain completion signal) exists. This is a
@@ -301,8 +354,8 @@ function runSessionCloseCheck(args) {
301
354
  // log-only marker governs the session, the gate runs in log-only mode and the
302
355
  // --project override is IGNORED — surface that rather than implying X was
303
356
  // checked (it was not).
304
- const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
305
- const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
357
+ const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
358
+ const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
306
359
 
307
360
  if (args.json) {
308
361
  console.log(
@@ -325,9 +378,13 @@ function runSessionCloseCheck(args) {
325
378
  skipped: status.skipped,
326
379
  // scope is additive; `global` keeps prior semantics for existing readers
327
380
  scope,
328
- ...(args.project
381
+ ...(scopedProject
329
382
  ? {
330
- scoped_project: args.project,
383
+ scoped_project: scopedProject,
384
+ // Distinguishes a user-typed --project from this check picking one
385
+ // for itself off the transcript — a reader should not mistake the
386
+ // latter for an explicit ask (see deriveTouchedProject above).
387
+ ...(inferredProject ? { project_inferred_from_transcript: true } : {}),
331
388
  ...(logOnlyWon ? { project_override_ignored: true } : {}),
332
389
  }
333
390
  : {}),
@@ -340,13 +397,20 @@ function runSessionCloseCheck(args) {
340
397
  process.exit(status.ok ? 0 : 1);
341
398
  }
342
399
 
400
+ // Label the scoped project by how it was chosen — an explicit --project reads
401
+ // as a flag the caller typed; an inferred one reads as this check's own guess
402
+ // off the transcript, so a reader does not credit the caller with an ask
403
+ // nobody made.
404
+ const scopedProjectLabel = args.project
405
+ ? `--project=${args.project}`
406
+ : `project=${scopedProject} (inferred from the session transcript, no --project given)`;
343
407
  if (logOnlyWon) {
344
408
  console.log(
345
- `Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and --project=${args.project} was IGNORED (no project was checked).\n`,
409
+ `Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and ${scopedProjectLabel} was IGNORED (no project was checked).\n`,
346
410
  );
347
411
  } else if (scope === 'project') {
348
412
  console.log(
349
- `Note: --project=${args.project} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${args.project} is close-complete; another project can still block /compact.\n`,
413
+ `Note: ${scopedProjectLabel} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${scopedProject} is close-complete; another project can still block /compact.\n`,
350
414
  );
351
415
  }
352
416
 
@@ -398,8 +462,8 @@ function runSessionCloseCheck(args) {
398
462
  // Do NOT claim global compact-readiness (the whole point of the narrow).
399
463
  console.log(
400
464
  status.ok
401
- ? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
402
- : `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
465
+ ? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
466
+ : `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
403
467
  );
404
468
  } else {
405
469
  console.log(
@@ -1043,7 +1107,10 @@ function applySessionClose(args) {
1043
1107
  stage: 'no-user-close-signal',
1044
1108
  reason: closeAuth.reason,
1045
1109
  applied: [],
1046
- committed: false,
1110
+ // `null`, not `false`: this refusal fires before the commit step is ever
1111
+ // reached (see the general result's own `committed` contract below).
1112
+ // `false` is reserved for a commit that actually ran and failed.
1113
+ committed: null,
1047
1114
  error: closeAuth.error,
1048
1115
  };
1049
1116
  console.log(
@@ -1103,7 +1170,9 @@ function applySessionClose(args) {
1103
1170
  stage: 'session-id-mismatch',
1104
1171
  error: msg,
1105
1172
  applied: [],
1106
- committed: false,
1173
+ // `null`, not `false` — refused before the commit step, same contract as
1174
+ // the `no-user-close-signal` refusal above.
1175
+ committed: null,
1107
1176
  };
1108
1177
  console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
1109
1178
  process.exit(1);
@@ -1734,6 +1803,10 @@ function applySessionClose(args) {
1734
1803
  // but silently.
1735
1804
  let markerWritten = false;
1736
1805
  let markerSkipReason = null;
1806
+ // Hoisted so the result JSON below can report it: `null` when this apply never
1807
+ // reached the commit step at all (ok:false before the writes were even
1808
+ // verified), distinct from a commit that ran and reported `committed:false`.
1809
+ let commitOutcome = null;
1737
1810
  if (ok && args.sessionId) {
1738
1811
  // IO stays lazy so this preserves the exact side-effect order (codex design
1739
1812
  // review): commit first (the only mutation), then resolve the
@@ -1749,7 +1822,6 @@ function applySessionClose(args) {
1749
1822
  // apply's stage+commit. A lock-timeout is treated exactly like any other
1750
1823
  // commit failure below (skip the marker, surface the reason) rather than
1751
1824
  // crashing the apply.
1752
- let commitOutcome;
1753
1825
  try {
1754
1826
  commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
1755
1827
  commitWikiChanges(args.hypoDir, appliedPaths),
@@ -1843,6 +1915,19 @@ function applySessionClose(args) {
1843
1915
  date,
1844
1916
  applied,
1845
1917
  skipped,
1918
+ // Was the general-shape sibling of the two early-refusal `committed:null`
1919
+ // fields (no-user-close-signal / session-id-mismatch), which this path never
1920
+ // carried before: a reader of `applied:[]` on a no-op re-run had no
1921
+ // `committed` value to check against and no way to tell it apart from a run
1922
+ // that never reached the commit step. `null` here means exactly that: `ok`
1923
+ // came back false before the commit ever ran (see `stage` for which check
1924
+ // failed: post-apply-verification, post-apply-lint, or proposal-pending). It
1925
+ // does NOT mean nothing was written — an overwrite/append can already be on
1926
+ // disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
1927
+ // `true` covers both an actual commit and the legitimate "nothing to stage"
1928
+ // no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
1929
+ // is a real commit failure, surfaced together with markerSkipReason below.
1930
+ committed: commitOutcome ? commitOutcome.committed : null,
1846
1931
  // Targets withheld: an overwrite drifted from this session's observed base, or
1847
1932
  // an append could not take the file lock in time (`kind: 'append'`). Two
1848
1933
  // channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
21
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
23
- import { resolveGitHooksDir } from './lib/git-hooks-dir.mjs';
23
+ import { resolveGitHooksDir, WIKI_PRE_COMMIT_MARKER_START } from './lib/git-hooks-dir.mjs';
24
24
  import { parseFrontmatter } from './lib/frontmatter.mjs';
25
25
  import {
26
26
  readSyncState,
@@ -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) {
@@ -495,7 +506,7 @@ function checkGit(hypoDir) {
495
506
  ? 'Not installed — run /hypo:init to install .hypoignore guard'
496
507
  : 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
497
508
  );
498
- } else if (content.includes('# hypo-managed:pre-commit:start')) {
509
+ } else if (content.includes(WIKI_PRE_COMMIT_MARKER_START)) {
499
510
  pass(label, 'Hypomnema .hypoignore guard installed');
500
511
  } else {
501
512
  warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
@@ -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
@@ -38,7 +38,14 @@ import { execSync, spawnSync } from 'child_process';
38
38
  import { fileURLToPath } from 'url';
39
39
  import { createHash } from 'crypto';
40
40
  import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
41
- import { hooksDirForInstall, unsafeHookTargetReason } from './lib/git-hooks-dir.mjs';
41
+ import {
42
+ hooksDirForInstall,
43
+ unsafeHookTargetReason,
44
+ WIKI_PRE_COMMIT_MARKER_START,
45
+ WIKI_PRE_COMMIT_MARKER_END,
46
+ SHELL_MARKER_START,
47
+ SHELL_MARKER_END,
48
+ } from './lib/git-hooks-dir.mjs';
42
49
  import { readCoreHooksConfig } from './lib/core-hooks.mjs';
43
50
  import {
44
51
  readPkgJson as readPkgJsonSafe,
@@ -48,6 +55,7 @@ import {
48
55
  readFileIfRegular,
49
56
  } from './lib/pkg-json.mjs';
50
57
  import { syncExtensions } from './lib/extensions.mjs';
58
+ import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
51
59
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
52
60
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
53
61
  import {
@@ -458,6 +466,14 @@ function installHooks(targetDir, dryRun) {
458
466
  if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
459
467
  log('created', dest);
460
468
  }
469
+ // Refresh the provenance sidecar every run, even when every .mjs above was
470
+ // skipped as already-present: this is the standalone (manual/npm) channel
471
+ // only (the plugin channel never calls installHooks), and resolvePkgRoot()'s
472
+ // provenance fallback needs it to track the truth of what is actually on
473
+ // disk here, not just what a fresh copy left behind. See
474
+ // hooks/hypo-shared.mjs's readVerifiedProvenancePkgRoot() for the reader.
475
+ const sidecar = writeProvenanceSidecar(targetDir, PKG_ROOT, PKG_VERSION, HOOKS_SRC, dryRun);
476
+ if (sidecar) log('created', sidecar);
461
477
  }
462
478
 
463
479
  function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
@@ -739,9 +755,6 @@ function installPkgGitHook(dryRun) {
739
755
 
740
756
  // ── wiki pre-commit hook ─────────────────────────────────────────────────────
741
757
 
742
- const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
743
- const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
744
-
745
758
  // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
746
759
  function shellSingleQuote(p) {
747
760
  return `'${p.replace(/'/g, "'\\''")}'`;
@@ -850,9 +863,6 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
850
863
 
851
864
  // ── shell function setup ─────────────────────────────────────────────────────
852
865
 
853
- const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
854
- const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
855
-
856
866
  function shellFunctionBlock() {
857
867
  return `${SHELL_MARKER_START}
858
868
  function claude() {