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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `.hypo-provenance.json` sidecar: copy-time producer proof for the
3
+ * standalone (manual/npm) hooks channel.
4
+ *
5
+ * hooks/hypo-shared.mjs's resolvePkgRoot() falls back to this file, verified
6
+ * against its own package name + a SHA-256 of the running hypo-shared.mjs,
7
+ * whenever self-location can't confirm a package root — the standalone hooks
8
+ * copy has no package.json alongside it to walk up to, by design (hooks
9
+ * import only Node built-ins, nothing outside the hooks dir).
10
+ *
11
+ * This is accidental-staleness protection, not a security boundary: any
12
+ * process running as this OS user can edit this JSON file freely, same as it
13
+ * could edit hooks/hypo-shared.mjs itself. It only guards against the
14
+ * accidental drift init/upgrade left unguarded before this fix — a hook
15
+ * file skip (already-present) that still let hypo-pkg.json's cached pointer
16
+ * move on to a newer version, silently mismatching the code actually copied.
17
+ *
18
+ * The filename and the producer-name check ("hypomnema") here must stay
19
+ * byte-identical with hooks/hypo-shared.mjs's own copy of this contract —
20
+ * hooks can't import scripts/, so the two sides are duplicated, not shared.
21
+ */
22
+
23
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { sha256 } from './pkg-json.mjs';
26
+ import { readCoreHooksConfig, deriveCoreHookBasenames } from './core-hooks.mjs';
27
+
28
+ export const PROVENANCE_FILENAME = '.hypo-provenance.json';
29
+ export const EXPECTED_PKG_NAME = 'hypomnema';
30
+
31
+ // Field name for the aggregate hook-set hash (BLOCKER A). Exported so
32
+ // scripts/doctor.mjs reads/writes the same key instead of duplicating the
33
+ // string literal — doctor already imports this module for
34
+ // PROVENANCE_FILENAME/EXPECTED_PKG_NAME, so importing one more constant costs
35
+ // nothing and closes the duplication CONCERN 1 flagged for the filename/name
36
+ // pair (those two stay duplicated only because hooks/hypo-shared.mjs can't
37
+ // import this file at all).
38
+ export const HOOKS_DIGEST_FIELD = 'hooksDigest';
39
+
40
+ /**
41
+ * Deterministic aggregate hash over every hook file hooks/hooks.json wires up
42
+ * (every event handler's file plus every `shared` entry) — the exact set
43
+ * installHooks/applyHookFiles copy, derived from hooks.json itself rather
44
+ * than hand-listed here so this can never drift from what actually gets
45
+ * copied. `dir` is read for CONTENT: pass hooksSrcDir at write time (the
46
+ * package's own hooks/, immediately after files were copied out of it — see
47
+ * writeProvenanceSidecar) and the installed hooksDir at doctor-check time
48
+ * (mirrors how hypoSharedSha256 below is written from source but verified
49
+ * against the installed copy).
50
+ *
51
+ * Deliberately NOT what the runtime resolver reads: hooks/hypo-shared.mjs's
52
+ * readVerifiedProvenancePkgRoot() only ever compares hypoSharedSha256, one
53
+ * file, because hook modules load on every single hook event and hashing an
54
+ * entire directory on every load would tax the hot path for a check only
55
+ * `hypomnema doctor` needs to run once per invocation. This digest is that
56
+ * doctor-only, directory-wide check: it catches a hooks/ dir where some OTHER
57
+ * file (e.g. hypo-personal-check.mjs) went stale while hypo-shared.mjs itself
58
+ * stayed byte-identical, which the single-file SHA the runtime trusts cannot
59
+ * see at all.
60
+ *
61
+ * Sorted basenames, `<file>\n<sha256-of-file-hex>\n` per file concatenated,
62
+ * then SHA-256 of that string — order and content only, never mtime/size.
63
+ * Returns null if hooks.json can't be read/parsed or any listed file can't be
64
+ * read, so a caller can treat "couldn't compute" the same as "nothing to
65
+ * compare" rather than writing/trusting a bogus digest.
66
+ */
67
+ export function computeHooksDigest(pkgRoot, dir) {
68
+ const cfgRes = readCoreHooksConfig(pkgRoot);
69
+ if (!cfgRes.ok) return null;
70
+ const files = [...deriveCoreHookBasenames(cfgRes.cfg)].sort();
71
+ try {
72
+ let acc = '';
73
+ for (const file of files) {
74
+ acc += `${file}\n${sha256(readFileSync(join(dir, file)))}\n`;
75
+ }
76
+ return sha256(acc);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function provenancePath(hooksDir) {
83
+ return join(hooksDir, PROVENANCE_FILENAME);
84
+ }
85
+
86
+ /**
87
+ * Write/refresh the sidecar next to a standalone hooks copy at `hooksDir`.
88
+ * `hooksSrcDir` is the package's own hooks/ (the source hypo-shared.mjs was
89
+ * just copied FROM) — its content is what gets hashed, since that hash is
90
+ * what the copy left at `hooksDir` will actually match.
91
+ *
92
+ * Callers must invoke this every run that touches — or even just re-verifies
93
+ * — the hook file set, EVEN when every individual .mjs file was skipped as
94
+ * already-present. A skipped hook copy that still lets hypo-pkg.json's
95
+ * pkgVersion move on to a newer release is exactly the bug this sidecar
96
+ * exists to catch; refreshing it only when files actually changed would
97
+ * reintroduce that gap under a new name.
98
+ *
99
+ * Returns the sidecar path written, or null if the source couldn't be
100
+ * hashed (leaves any pre-existing sidecar untouched rather than write a
101
+ * broken one).
102
+ *
103
+ * `hooksDigest` (BLOCKER A) is best-effort: computed from the same
104
+ * `hooksSrcDir`, over the file set hooks.json actually wires up. Unlike
105
+ * hypoSharedSha256 above, a failure to compute it does not abort the write —
106
+ * it is a doctor-only freshness signal, not part of the runtime contract
107
+ * (hooks/hypo-shared.mjs never reads this field), so a sidecar the runtime
108
+ * can verify should not be withheld just because the digest step failed.
109
+ */
110
+ export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDir, dryRun) {
111
+ let hypoSharedSha256;
112
+ try {
113
+ hypoSharedSha256 = sha256(readFileSync(join(hooksSrcDir, 'hypo-shared.mjs')));
114
+ } catch {
115
+ return null;
116
+ }
117
+ const hooksDigest = computeHooksDigest(pkgRoot, hooksSrcDir);
118
+ const dest = provenancePath(hooksDir);
119
+ const data = {
120
+ pkgRoot,
121
+ pkgVersion,
122
+ hypoSharedSha256,
123
+ ...(hooksDigest ? { [HOOKS_DIGEST_FIELD]: hooksDigest } : {}),
124
+ copiedAt: new Date().toISOString(),
125
+ };
126
+ if (!dryRun) atomicWriteJson(dest, data);
127
+ return dest;
128
+ }
129
+
130
+ // Tmp file in the SAME directory as `dest`, then rename over it (CONCERN 2):
131
+ // a reader (readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) that
132
+ // races a plain truncate-then-write can observe a half-written file and
133
+ // JSON.parse throws, degrading PKG_ROOT to null for that hook invocation.
134
+ // Same directory is required for the rename to be atomic — crossing a
135
+ // filesystem boundary would fall back to a non-atomic copy+delete.
136
+ function atomicWriteJson(dest, data) {
137
+ const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
138
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
139
+ try {
140
+ renameSync(tmp, dest);
141
+ } catch (err) {
142
+ try {
143
+ unlinkSync(tmp);
144
+ } catch {
145
+ /* best-effort cleanup */
146
+ }
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /** Remove the sidecar at `hooksDir`, if present. Returns the path removed, or null. */
152
+ export function removeProvenanceSidecar(hooksDir, apply) {
153
+ const dest = provenancePath(hooksDir);
154
+ if (!existsSync(dest)) return null;
155
+ if (apply) unlinkSync(dest);
156
+ return dest;
157
+ }
158
+
159
+ /** Non-mutating read. Returns the parsed sidecar, or null on absence/corruption. */
160
+ export function readProvenanceSidecar(hooksDir) {
161
+ try {
162
+ return JSON.parse(readFileSync(provenancePath(hooksDir), 'utf-8'));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
@@ -47,6 +47,7 @@ import {
47
47
  hasSymlinkAncestor,
48
48
  buildHookCommand,
49
49
  } from './lib/extensions.mjs';
50
+ import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
50
51
 
51
52
  const HOME = homedir();
52
53
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -510,6 +511,17 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
510
511
  missing.push(p);
511
512
  }
512
513
  }
514
+ // .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
515
+ // this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
516
+ // hook files themselves, so it is removed in the same pass rather than left
517
+ // behind to describe a package root that no longer has any hooks pointing
518
+ // through it. Unlike hookFiles above, this is an optional file (only the
519
+ // manual/npm channel ever writes one) — so, mirroring the loop's own
520
+ // present/absent split, it is only added to `removed` (dry-run: "to remove")
521
+ // when it actually exists; a channel that never wrote one gets no line at
522
+ // all, not a spurious "already absent".
523
+ const sidecar = removeProvenanceSidecar(hooksDir, apply);
524
+ if (sidecar) removed.push(sidecar);
513
525
  return { removed, missing };
514
526
  }
515
527
 
@@ -53,6 +53,7 @@ import {
53
53
  writeDualSkipProvenance,
54
54
  } from './lib/pkg-json.mjs';
55
55
  import { syncExtensions } from './lib/extensions.mjs';
56
+ import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
56
57
  import { isHypomnemaPluginEnabled, resolveEnabledPluginRoot } from './lib/plugin-detect.mjs';
57
58
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
58
59
 
@@ -1135,6 +1136,12 @@ if (args.apply) {
1135
1136
  );
1136
1137
  }
1137
1138
  appliedHooks = applyHookFiles(hooks, claudeHooksDir);
1139
+ // Refresh every run managesClaudeCore is true, not only when a stale hook
1140
+ // was actually copied above: applyHookFiles OVERWRITES (never skips) a
1141
+ // stale hook, but even a run that finds every hook already up-to-date
1142
+ // must still keep the sidecar's pkgVersion truthful, same reasoning as
1143
+ // installHooks's own refresh-every-run comment.
1144
+ writeProvenanceSidecar(claudeHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
1138
1145
  appliedSettings = applySettingsJson(settings, claudeSettingsPath);
1139
1146
  // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
1140
1147
  appliedCommands = applyCommands(commands, args.forceCommands);
@@ -1196,6 +1203,7 @@ if (args.apply) {
1196
1203
  );
1197
1204
  }
1198
1205
  appliedHooksCodex = applyHookFiles(hooksCodex, codexHooksDir);
1206
+ writeProvenanceSidecar(codexHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
1199
1207
  appliedSettingsCodex = applySettingsJson(settingsCodex, codexSettingsPath);
1200
1208
  }
1201
1209
  // After applyCommands wrote hypo-pkg.json — merges extensions.<target> alongside.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.7.1"
4
+ version: "1.7.2"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7