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
|
@@ -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
|
+
}
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -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
|
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -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.
|