hypomnema 1.8.4 → 1.9.0

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.
Files changed (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +36 -0
  4. package/README.ko.md +16 -15
  5. package/README.md +14 -13
  6. package/commands/crystallize.md +84 -30
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +32 -9
  9. package/docs/CONTRIBUTING.md +5 -1
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +141 -19
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +2 -29
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +381 -18
  27. package/hooks/hypo-shared.mjs +2151 -185
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +166 -34
  30. package/hooks/shared.json +7 -1
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +2 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +506 -32
  35. package/scripts/init.mjs +32 -3
  36. package/scripts/lib/crystallize-close-apply.mjs +1949 -230
  37. package/scripts/lib/crystallize-close-check.mjs +43 -23
  38. package/scripts/lib/design-history-stale.mjs +36 -9
  39. package/scripts/lib/frontmatter.mjs +39 -0
  40. package/scripts/lib/hook-inventory.mjs +31 -1
  41. package/scripts/lib/page-usage.mjs +1 -1
  42. package/scripts/lib/pkg-provenance.mjs +7 -1
  43. package/scripts/lib/project-create.mjs +40 -44
  44. package/scripts/lib/schema-vocab.mjs +22 -11
  45. package/scripts/lib/template-schema-version.mjs +1 -0
  46. package/scripts/lib/time.mjs +46 -0
  47. package/scripts/lint.mjs +66 -18
  48. package/scripts/proposal.mjs +423 -18
  49. package/scripts/resume.mjs +3 -2
  50. package/scripts/session-audit.mjs +2 -1
  51. package/scripts/uninstall.mjs +15 -1
  52. package/scripts/upgrade.mjs +281 -90
  53. package/scripts/verify.mjs +2 -1
  54. package/scripts/weekly-report.mjs +2 -1
  55. package/templates/SCHEMA.md +27 -7
  56. package/templates/gitignore +11 -0
  57. package/templates/hypo-automation.md +10 -13
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +53 -25
  60. package/templates/hot.md +0 -24
@@ -0,0 +1,559 @@
1
+ // hooks/close-receipt.mjs, commit-based close receipt: read, write, verify.
2
+ //
3
+ // Design lives in the maintainer's private wiki (a close-marker redesign
4
+ // spec: a name/shape contract plus its follow-up rounds). Not reproduced
5
+ // here since this file ships publicly and the spec does not.
6
+ //
7
+ // What this replaces: a session-close marker used to infer "this session's
8
+ // work is saved" from git cleanliness at close time (a clean tree was read
9
+ // as nothing left to save). Cleanliness is a property of the whole tree at
10
+ // one instant, so another session's commit or edit changes the answer
11
+ // without this session's own bytes being saved or lost. This module
12
+ // certifies a narrower, provable claim instead: "the file versions named in this receipt are all present, byte-for-byte,
13
+ // in commit C, which is reachable from the current branch history". It says
14
+ // nothing about files it does not name, and nothing about whether the rest
15
+ // of the session's work was ever saved at all, callers (Stop, PreCompact,
16
+ // doctor) must keep surfacing unresolved changes separately, never read a
17
+ // valid receipt as "the vault is clean".
18
+ //
19
+ // Node built-ins only, per the hooks/ convention (this file is copied
20
+ // standalone into `~/.claude/hooks/` on install; `scripts/` may import it,
21
+ // never the reverse). Listed in `hooks/shared.json`.
22
+ //
23
+ // Session id validation reuses `isValidSessionId` (hooks/proposal-store.mjs)
24
+ // rather than re-deriving a shape check: a session id reaches this module the
25
+ // same way it reaches the proposal store, straight off `--session-id`, and it
26
+ // becomes a filename component here exactly as it does there. The receipt
27
+ // directory name (`.cache/sessions/<id>/`) follows the same rule as
28
+ // hypo-shared.mjs's per-session cache directory: `sanitizeSessionId`, which
29
+ // caps the id at 128 characters. `isValidSessionId` has no length cap, so an
30
+ // id longer than 128 maps to a truncated directory name; two such ids that
31
+ // share their first 128 characters share a directory, and
32
+ // `readReceiptStrict`'s session id comparison rejects the other session's
33
+ // receipt.
34
+
35
+ import { existsSync, readFileSync, renameSync } from 'node:fs';
36
+ import { join, isAbsolute } from 'node:path';
37
+ import { spawnSync } from 'node:child_process';
38
+ import { createHash } from 'node:crypto';
39
+ import { atomicWrite } from './atomic-write.mjs';
40
+ import {
41
+ sessionClosedMarkerPath,
42
+ sanitizeSessionId,
43
+ readSessionClosedMarker,
44
+ } from './hypo-shared.mjs';
45
+ import { isValidSessionId } from './proposal-store.mjs';
46
+
47
+ export const RECEIPT_SCHEMA_VERSION = 1;
48
+ /** Certification value an apply-path (full close, expected-bytes verified) issues. */
49
+ export const CERT_CHECKPOINT = 'committed-close-checkpoint';
50
+ /** Certification value `--mark-session-closed` (no payload, weaker proof) issues. */
51
+ export const CERT_CLOSE_FILES = 'committed-close-files';
52
+
53
+ // A full object id: 40 hex (sha1) or 64 hex (sha256 repositories). One
54
+ // pattern for the receipt's commit field and for a tree entry's blob oid.
55
+ const OID_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
56
+
57
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
58
+ // A git tree entry mode as ls-tree prints it (100644, 100755, 120000, ...).
59
+ const TREE_MODE_RE = /^[0-7]{6}$/;
60
+
61
+ // The proof kinds a receipt WRITER actually emits (scripts/lib/
62
+ // crystallize-close-apply.mjs: the apply path's write phases and
63
+ // buildMarkCloseProof). verifyEntriesInCommit also understands 'absent', but no
64
+ // writer files one, so a stored receipt that names it was not written by us.
65
+ const RECEIPT_ENTRY_KINDS = new Set(['overwrite', 'create', 'append', 'schema-pending']);
66
+
67
+ const GIT_TIMEOUT_MS = 30000;
68
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
69
+
70
+ /** Run a git subcommand rooted at `hypoDir`, text mode. */
71
+ function gitText(hypoDir, args) {
72
+ return spawnSync('git', ['-C', hypoDir, ...args], {
73
+ encoding: 'utf-8',
74
+ timeout: GIT_TIMEOUT_MS,
75
+ maxBuffer: GIT_MAX_BUFFER,
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Run `git cat-file -p <oid>` and return its RAW bytes as a Buffer, no
81
+ * `encoding` option, so spawnSync hands back stdout undecoded. A blob is
82
+ * compared and hashed as bytes, never as a UTF-8 string: decoding first would
83
+ * let two blobs that differ only in an invalid UTF-8 sequence collapse to the
84
+ * same JS string (or throw away the offending bytes), hiding exactly the kind
85
+ * of corruption a close receipt exists to catch.
86
+ * @returns {Buffer|null} null on any git failure (timeout, bad oid, non-repo)
87
+ */
88
+ function catFileBuffer(hypoDir, oid) {
89
+ const res = spawnSync('git', ['-C', hypoDir, 'cat-file', '-p', oid], {
90
+ timeout: GIT_TIMEOUT_MS,
91
+ maxBuffer: GIT_MAX_BUFFER,
92
+ });
93
+ if (res.error || res.status !== 0 || !Buffer.isBuffer(res.stdout)) return null;
94
+ return res.stdout;
95
+ }
96
+
97
+ /**
98
+ * Look up one path's tree entry inside `commit`. `-r` so a nested path
99
+ * resolves in one call without walking subtrees by hand. `--full-tree` is
100
+ * deliberately absent: a receipt path is relative to the vault, and the vault
101
+ * may be a subdirectory of the repository, so git must resolve the pathspec
102
+ * against `-C hypoDir` (the vault), not the repository root. `-z` keeps a
103
+ * non-ASCII path byte-exact (no quotepath escaping) and splits records on NUL.
104
+ * The exact matching path is picked out of the (possibly multi-record, if the
105
+ * pathspec were ambiguous) output rather than trusting "first record".
106
+ * @returns {{ok: true, present: false}|{ok: true, present: true, mode: string, oid: string}|{ok: false, reason: string}}
107
+ */
108
+ function lsTreeEntry(hypoDir, commit, path) {
109
+ const res = gitText(hypoDir, ['ls-tree', '-r', '-z', commit, '--', path]);
110
+ if (res.error) return { ok: false, reason: `git-error: ${res.error.message}` };
111
+ if (res.status !== 0) {
112
+ return { ok: false, reason: `ls-tree-failed: ${(res.stderr || '').trim() || 'unknown'}` };
113
+ }
114
+ const records = (res.stdout || '').split('\0').filter(Boolean);
115
+ if (records.length === 0) return { ok: true, present: false };
116
+ // `<mode> <type> <oid>\t<path>`, tab-separated path field, exact match only
117
+ // (a pathspec that happened to match a sibling would otherwise silently pass).
118
+ const record = records.find((r) => r.slice(r.indexOf('\t') + 1) === path);
119
+ if (!record) return { ok: false, reason: 'ls-tree-path-mismatch' };
120
+ const head = record.slice(0, record.indexOf('\t'));
121
+ const m = /^(\d+) \S+ (\S+)$/.exec(head);
122
+ if (!m || !OID_RE.test(m[2])) return { ok: false, reason: 'ls-tree-parse-failed' };
123
+ return { ok: true, present: true, mode: m[1], oid: m[2] };
124
+ }
125
+
126
+ // Copy of `scripts/lib/schema-vocab.mjs`'s section bounds for the
127
+ // `**Pending**:` data line. `scripts/` cannot be imported from `hooks/`
128
+ // (dependency direction is one-way the other way), so the four patterns below
129
+ // are a separately-maintained duplicate and must be kept in step with it. The
130
+ // walk is the same: the "## Tag Vocabulary" H2 section (ended by the next
131
+ // H2), inside it the "### Pending" subsection (ended by the next H3), inside
132
+ // that the first `**Pending**:` data line. A `**Pending**:` line anywhere
133
+ // outside that subsection is not pending vocabulary and is ignored, so a
134
+ // receipt can never be satisfied by a line the real parser would not read.
135
+ const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
136
+ const NEXT_H2_RE = /^##\s+/m;
137
+ const PENDING_HEADING_RE = /^###[ \t]+Pending\b.*$/im;
138
+ const NEXT_H3_RE = /^###[ \t]+/m;
139
+ const PENDING_DATA_RE = /^\*\*Pending[^*]*\*\*:.*$/im;
140
+ const BACKTICK_TOKEN_RE = /`([^`]+)`/g;
141
+
142
+ function parsePendingTags(content) {
143
+ const tags = new Set();
144
+ const text = content || '';
145
+ const header = VOCAB_HEADER_RE.exec(text);
146
+ if (!header) return tags;
147
+ const rest = text.slice(header.index + header[0].length);
148
+ const nextH2 = NEXT_H2_RE.exec(rest);
149
+ const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
150
+ const heading = PENDING_HEADING_RE.exec(section);
151
+ if (!heading) return tags;
152
+ const afterHeading = section.slice(heading.index + heading[0].length);
153
+ const nextH3 = NEXT_H3_RE.exec(afterHeading);
154
+ const sub = nextH3 ? afterHeading.slice(0, nextH3.index) : afterHeading;
155
+ const m = PENDING_DATA_RE.exec(sub);
156
+ if (!m) return tags;
157
+ for (const tok of m[0].matchAll(BACKTICK_TOKEN_RE)) {
158
+ const t = tok[1].trim();
159
+ if (t && !t.includes(' ')) tags.add(t);
160
+ }
161
+ return tags;
162
+ }
163
+
164
+ /**
165
+ * `<hypoDir>/.cache/sessions/<sessionId>/close-receipt.json`. Returns `null`
166
+ * for a session id `isValidSessionId` rejects, never falls back to a shared
167
+ * path, since a receipt filed under a bad id would otherwise collide with
168
+ * every other bad id.
169
+ * @returns {string|null}
170
+ */
171
+ export function receiptPath(hypoDir, sessionId) {
172
+ if (!isValidSessionId(sessionId)) return null;
173
+ return join(hypoDir, '.cache', 'sessions', sanitizeSessionId(sessionId), 'close-receipt.json');
174
+ }
175
+
176
+ function isPlainObject(v) {
177
+ return !!v && typeof v === 'object' && !Array.isArray(v);
178
+ }
179
+
180
+ function isNonEmptyStringArray(v) {
181
+ return Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'string' && x);
182
+ }
183
+
184
+ /**
185
+ * One receipt entry's shape: a vault-relative path that cannot climb out of
186
+ * the vault, a kind a writer emits, and an `expected` that gives
187
+ * verifyEntriesInCommit something to compare. An overwrite/create whose
188
+ * `expected` names neither a sha256 nor a blob oid would verify as a match
189
+ * against any committed bytes, so it is rejected here too.
190
+ */
191
+ function isWellFormedEntry(entry) {
192
+ if (!isPlainObject(entry)) return false;
193
+ const { path, kind, expected } = entry;
194
+ if (typeof path !== 'string' || !path || isAbsolute(path) || /^[\\/]/.test(path)) return false;
195
+ if (path.split(/[\\/]/).includes('..')) return false;
196
+ if (!RECEIPT_ENTRY_KINDS.has(kind) || !isPlainObject(expected)) return false;
197
+ if (kind === 'overwrite' || kind === 'create') {
198
+ const { bytesSha256, blob, mode } = expected;
199
+ if (
200
+ bytesSha256 !== undefined &&
201
+ !(typeof bytesSha256 === 'string' && SHA256_HEX_RE.test(bytesSha256))
202
+ )
203
+ return false;
204
+ if (blob !== undefined && !(typeof blob === 'string' && OID_RE.test(blob))) return false;
205
+ if (mode !== undefined && !(typeof mode === 'string' && TREE_MODE_RE.test(mode))) return false;
206
+ return bytesSha256 !== undefined || blob !== undefined;
207
+ }
208
+ if (kind === 'append') return isNonEmptyStringArray(expected.entryBlocks);
209
+ return isNonEmptyStringArray(expected.tags); // schema-pending
210
+ }
211
+
212
+ /**
213
+ * Read and strictly validate a session's close receipt: parse, schema
214
+ * version, certification value, session id match, entry shape (see
215
+ * isWellFormedEntry), repo identity, and commit reachability from the CURRENT
216
+ * branch history. Any failure at any step is
217
+ * `invalid`, never silently treated as `missing`, a caller must be able to
218
+ * tell "no receipt was ever written" from "a receipt exists but cannot be
219
+ * trusted", since the two call for different user-facing advice.
220
+ *
221
+ * @returns {{status: 'valid', receipt: object}
222
+ * | {status: 'missing'}
223
+ * | {status: 'invalid', reason: string}}
224
+ */
225
+ export function readReceiptStrict(hypoDir, sessionId) {
226
+ const path = receiptPath(hypoDir, sessionId);
227
+ if (!path) return { status: 'invalid', reason: 'invalid-session-id' };
228
+ if (!existsSync(path)) return { status: 'missing' };
229
+
230
+ let receipt;
231
+ try {
232
+ receipt = JSON.parse(readFileSync(path, 'utf-8'));
233
+ } catch {
234
+ return { status: 'invalid', reason: 'parse-error' };
235
+ }
236
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) {
237
+ return { status: 'invalid', reason: 'malformed' };
238
+ }
239
+ if (receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) {
240
+ // Includes an unknown/future version, never assumed forward-compatible.
241
+ return { status: 'invalid', reason: 'schema-version-mismatch' };
242
+ }
243
+ if (receipt.certification !== CERT_CHECKPOINT && receipt.certification !== CERT_CLOSE_FILES) {
244
+ return { status: 'invalid', reason: 'unknown-certification' };
245
+ }
246
+ if (String(receipt.sessionId) !== String(sessionId)) {
247
+ return { status: 'invalid', reason: 'session-id-mismatch' };
248
+ }
249
+ if (typeof receipt.commit !== 'string' || !OID_RE.test(receipt.commit)) {
250
+ return { status: 'invalid', reason: 'malformed-commit' };
251
+ }
252
+ if (!Array.isArray(receipt.entries)) {
253
+ return { status: 'invalid', reason: 'malformed-entries' };
254
+ }
255
+ // Shape only, no git objects: Stop reads this on every close-intent turn, and
256
+ // the byte-level check belongs to verifyEntriesInCommit at write time. What
257
+ // this rules out is a receipt that certifies nothing (no entries) or an entry
258
+ // verifyEntriesInCommit would have waved through without checking anything.
259
+ if (receipt.entries.length === 0) {
260
+ return { status: 'invalid', reason: 'empty-entries' };
261
+ }
262
+ if (receipt.entries.some((e) => !isWellFormedEntry(e))) {
263
+ return { status: 'invalid', reason: 'malformed-entry' };
264
+ }
265
+ if (
266
+ !receipt.repo ||
267
+ typeof receipt.repo !== 'object' ||
268
+ typeof receipt.repo.toplevel !== 'string' ||
269
+ !receipt.repo.toplevel
270
+ ) {
271
+ return { status: 'invalid', reason: 'malformed-repo' };
272
+ }
273
+
274
+ const toplevelRes = gitText(hypoDir, ['rev-parse', '--show-toplevel']);
275
+ if (toplevelRes.error || toplevelRes.status !== 0) {
276
+ return { status: 'invalid', reason: 'not-a-git-repository' };
277
+ }
278
+ const actualToplevel = (toplevelRes.stdout || '').trim();
279
+ if (actualToplevel !== receipt.repo.toplevel) {
280
+ return { status: 'invalid', reason: 'repo-mismatch' };
281
+ }
282
+
283
+ // Reachability: a commit that reset/rebase dropped from history must not
284
+ // keep certifying a close forever. `--is-ancestor` exits 0 only when
285
+ // `commit` is an ancestor of (or equal to) HEAD; any other exit code
286
+ // (not found, not an ancestor, git error) is "cannot stand behind this".
287
+ const ancestry = gitText(hypoDir, ['merge-base', '--is-ancestor', receipt.commit, 'HEAD']);
288
+ if (ancestry.error || ancestry.status !== 0) {
289
+ return { status: 'invalid', reason: 'commit-unreachable' };
290
+ }
291
+
292
+ return { status: 'valid', receipt };
293
+ }
294
+
295
+ /**
296
+ * Verify every proof entry against the actual git objects in `commit`. This
297
+ * is the one place bytes/mode/append/pending claims get checked against
298
+ * reality, a caller must never accept `appliedPaths.length === 0` or a
299
+ * commit helper's bare success as content proof; this function is what
300
+ * closes that gap by reading the committed tree itself.
301
+ *
302
+ * @param {string} hypoDir
303
+ * @param {string} commit a commit-ish (full sha expected in practice)
304
+ * @param {Array<{path: string, kind: 'overwrite'|'create'|'append'|'schema-pending'|'absent',
305
+ * expected?: {blob?: string, mode?: string, bytesSha256?: string, entryBlocks?: string[], tags?: string[]}}>} entries
306
+ * @returns {{ok: boolean, mismatches: Array<{path: string, reason: string}>}}
307
+ */
308
+ export function verifyEntriesInCommit(hypoDir, commit, entries) {
309
+ const mismatches = [];
310
+ for (const entry of entries || []) {
311
+ const path = entry && entry.path;
312
+ const kind = entry && entry.kind;
313
+ const expected = (entry && entry.expected) || {};
314
+ if (typeof path !== 'string' || !path) {
315
+ mismatches.push({
316
+ path: typeof path === 'string' ? path : '(missing path)',
317
+ reason: 'invalid-entry',
318
+ });
319
+ continue;
320
+ }
321
+
322
+ const lookup = lsTreeEntry(hypoDir, commit, path);
323
+ if (!lookup.ok) {
324
+ mismatches.push({ path, reason: lookup.reason });
325
+ continue;
326
+ }
327
+
328
+ if (kind === 'absent') {
329
+ if (lookup.present) mismatches.push({ path, reason: 'expected-absent-but-present' });
330
+ continue;
331
+ }
332
+
333
+ if (!lookup.present) {
334
+ mismatches.push({ path, reason: 'missing-entry' });
335
+ continue;
336
+ }
337
+
338
+ if (kind === 'overwrite' || kind === 'create') {
339
+ if (typeof expected.mode === 'string' && expected.mode && lookup.mode !== expected.mode) {
340
+ mismatches.push({ path, reason: 'mode-mismatch' });
341
+ continue;
342
+ }
343
+ if (typeof expected.blob === 'string' && expected.blob && lookup.oid !== expected.blob) {
344
+ mismatches.push({ path, reason: 'blob-mismatch' });
345
+ continue;
346
+ }
347
+ if (typeof expected.bytesSha256 === 'string' && expected.bytesSha256) {
348
+ const buf = catFileBuffer(hypoDir, lookup.oid);
349
+ if (buf === null) {
350
+ mismatches.push({ path, reason: 'object-unreadable' });
351
+ continue;
352
+ }
353
+ const actual = createHash('sha256').update(buf).digest('hex');
354
+ if (actual !== expected.bytesSha256) {
355
+ mismatches.push({ path, reason: 'blob-mismatch' });
356
+ continue;
357
+ }
358
+ }
359
+ continue;
360
+ }
361
+
362
+ if (kind === 'append') {
363
+ const blocks = Array.isArray(expected.entryBlocks) ? expected.entryBlocks : [];
364
+ if (blocks.length === 0) {
365
+ mismatches.push({ path, reason: 'append-incomplete' });
366
+ continue;
367
+ }
368
+ const buf = catFileBuffer(hypoDir, lookup.oid);
369
+ if (buf === null) {
370
+ mismatches.push({ path, reason: 'object-unreadable' });
371
+ continue;
372
+ }
373
+ // Search as text: an append block is prose (a log/session-log entry),
374
+ // not a byte-exact whole-file claim, so containment is checked against
375
+ // the decoded string. This does not weaken the raw-byte rule above ,
376
+ // overwrite/create still hash the untouched Buffer, it only means an
377
+ // append entry cannot detect a change OUTSIDE the block it names, which
378
+ // is the documented limit of "append" as a proof kind (containment of
379
+ // the named blocks, not equality of the whole file).
380
+ const text = buf.toString('utf-8');
381
+ const missing = blocks.some((b) => typeof b !== 'string' || !b || !text.includes(b));
382
+ if (missing) mismatches.push({ path, reason: 'append-incomplete' });
383
+ continue;
384
+ }
385
+
386
+ if (kind === 'schema-pending') {
387
+ const tags = Array.isArray(expected.tags) ? expected.tags : [];
388
+ if (tags.length === 0) {
389
+ mismatches.push({ path, reason: 'schema-pending-missing' });
390
+ continue;
391
+ }
392
+ const buf = catFileBuffer(hypoDir, lookup.oid);
393
+ if (buf === null) {
394
+ mismatches.push({ path, reason: 'object-unreadable' });
395
+ continue;
396
+ }
397
+ const pending = parsePendingTags(buf.toString('utf-8'));
398
+ const missing = tags.some((t) => !pending.has(t));
399
+ if (missing) mismatches.push({ path, reason: 'schema-pending-missing' });
400
+ continue;
401
+ }
402
+
403
+ mismatches.push({ path, reason: `unknown-kind: ${String(kind)}` });
404
+ }
405
+ return { ok: mismatches.length === 0, mismatches };
406
+ }
407
+
408
+ /**
409
+ * Write a receipt via temp+rename, then re-read and re-parse it to confirm
410
+ * the bytes that landed are the bytes intended, `atomicWrite`'s rename
411
+ * already rules out a torn file, but not a filesystem that silently
412
+ * truncates/rejects a write the process itself did not detect as an error.
413
+ * A caller (the close-authority holder) must not proceed as if a receipt
414
+ * exists when this returns `ok: false`; the write may or may not have
415
+ * landed, and only the caller's own retry/backoff policy decides what to
416
+ * do next.
417
+ * @returns {{ok: true, path: string} | {ok: false, path?: string, reason: string}}
418
+ */
419
+ export function writeReceiptAtomic(hypoDir, sessionId, receipt) {
420
+ const path = receiptPath(hypoDir, sessionId);
421
+ if (!path) return { ok: false, reason: 'invalid-session-id' };
422
+ const body = JSON.stringify(receipt, null, 2);
423
+ try {
424
+ atomicWrite(path, body);
425
+ } catch (e) {
426
+ return { ok: false, path, reason: `write-failed: ${e && e.message}` };
427
+ }
428
+ try {
429
+ const reread = readFileSync(path, 'utf-8');
430
+ if (reread !== body) return { ok: false, path, reason: 'reread-mismatch' };
431
+ JSON.parse(reread); // re-parse: catch anything a byte-equal check would miss (belt only)
432
+ } catch (e) {
433
+ return { ok: false, path, reason: `reread-failed: ${e && e.message}` };
434
+ }
435
+ return { ok: true, path };
436
+ }
437
+
438
+ /** Rename `path` to `<path>.invalidated-<ts>`. Absent is success: there was
439
+ * nothing to invalidate under this name. */
440
+ function invalidateOne(path) {
441
+ if (!existsSync(path)) return { ok: true };
442
+ const dest = `${path}.invalidated-${Date.now()}`;
443
+ try {
444
+ renameSync(path, dest);
445
+ return { ok: true };
446
+ } catch (e) {
447
+ return { ok: false, reason: `rename-failed: ${e && e.message}` };
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Invalidate this session's prior close artifacts before a new close
453
+ * request is accepted: the compat MARKER first, the RECEIPT second. If the process dies between the two renames, only the
454
+ * receipt is left, never a marker with no backing receipt, which would let
455
+ * an old-Stop reader treat a stale marker as fresh. `<name>.invalidated-<ts>`
456
+ * (not `.marker` at the end) so doctor's marker-file scan and the
457
+ * legacy-closed acceptance path (which only look at names ending `.marker`)
458
+ * never pick an invalidated file back up.
459
+ *
460
+ * `testHooks.afterFirstRename` runs between the two renames. It exists so a
461
+ * test can stop the process at that exact point and see which artifact is
462
+ * already gone; production callers never pass it.
463
+ * @param {{afterFirstRename?: () => void}} [testHooks]
464
+ * @returns {{ok: boolean, reason?: string}}
465
+ */
466
+ export function invalidateCloseArtifacts(hypoDir, sessionId, testHooks = {}) {
467
+ const markerResult = invalidateOne(sessionClosedMarkerPath(hypoDir, sessionId));
468
+ if (!markerResult.ok) return markerResult;
469
+ testHooks.afterFirstRename?.();
470
+ const receipt = receiptPath(hypoDir, sessionId);
471
+ if (!receipt) return { ok: true }; // invalid session id: nothing filed under it to invalidate
472
+ return invalidateOne(receipt);
473
+ }
474
+
475
+ /** The two states that count as "this session's close is done". */
476
+ const CLOSE_COMPLETE_STATES = new Set(['closed', 'legacy-closed']);
477
+
478
+ /** @returns {boolean} true when `checkpoint` (a closeCheckpointState result) is a finished close. */
479
+ export function isCloseComplete(checkpoint) {
480
+ return !!checkpoint && CLOSE_COMPLETE_STATES.has(checkpoint.state);
481
+ }
482
+
483
+ /**
484
+ * The one verdict on "is this session's close done", shared by Stop, the
485
+ * `--check-session-close` diagnostic, doctor, and the PreCompact gate so the
486
+ * four cannot drift apart. It pairs the receipt with the compat marker that
487
+ * projects it:
488
+ *
489
+ * closed the receipt is valid, and a marker names that same receipt
490
+ * generation.
491
+ * legacy-closed no receipt, and a marker that names no receipt generation
492
+ * (an older writer; the marker's own 7-day TTL still applies).
493
+ * broken the two disagree: a marker promises a receipt that is
494
+ * missing, invalid, or a different generation, or a valid
495
+ * receipt has no marker or a mismatched one (a close that
496
+ * stopped between its two writes, or an invalidation that
497
+ * moved only one of them). An invalid receipt with no marker
498
+ * is broken too: something was filed and cannot be trusted.
499
+ * open neither a receipt nor a marker.
500
+ *
501
+ * Only `closed` and `legacy-closed` are a finished close (isCloseComplete).
502
+ * `reason` is one human-readable line for `broken` and `open`, else null.
503
+ *
504
+ * A session id that isValidSessionId rejects has no receipt path at all, so
505
+ * its receipt reads as missing rather than invalid: no receipt can ever be
506
+ * filed under it, and a legacy marker for it stays legacy-closed.
507
+ *
508
+ * `opts.marker`, when the key is present, is used instead of reading the
509
+ * marker: readSessionClosedMarker unlinks an expired or corrupt marker as it
510
+ * reads, and doctor (a read-only health check) must not mutate the vault, so
511
+ * it reads the raw file itself and passes the parsed object (or null) here.
512
+ *
513
+ * @param {string} hypoDir
514
+ * @param {string} sessionId
515
+ * @param {{marker?: object|null}} [opts]
516
+ * @returns {{state: 'closed'|'legacy-closed'|'broken'|'open', reason: string|null,
517
+ * receipt: object|null, marker: object|null}}
518
+ */
519
+ export function closeCheckpointState(hypoDir, sessionId, opts = {}) {
520
+ const marker =
521
+ 'marker' in opts ? opts.marker || null : readSessionClosedMarker(hypoDir, sessionId);
522
+ const read = receiptPath(hypoDir, sessionId)
523
+ ? readReceiptStrict(hypoDir, sessionId)
524
+ : { status: 'missing' };
525
+ const receipt = read.status === 'valid' ? read.receipt : null;
526
+ const markerGen = (marker && marker.receipt_generation) || null;
527
+ const result = (state, reason = null) => ({ state, reason, receipt, marker });
528
+
529
+ if (receipt) {
530
+ if (!marker) {
531
+ return result(
532
+ 'broken',
533
+ 'a valid close receipt has no session-closed marker (the close stopped before the marker landed, or only the marker was invalidated)',
534
+ );
535
+ }
536
+ if (
537
+ typeof receipt.generation !== 'string' ||
538
+ !receipt.generation ||
539
+ markerGen !== receipt.generation
540
+ ) {
541
+ return result(
542
+ 'broken',
543
+ `the session-closed marker names receipt generation ${markerGen || '(none)'}, but the close receipt is generation ${receipt.generation || '(none)'}`,
544
+ );
545
+ }
546
+ return result('closed');
547
+ }
548
+ if (read.status === 'invalid') {
549
+ return result('broken', `the close receipt is invalid (${read.reason})`);
550
+ }
551
+ if (markerGen) {
552
+ return result(
553
+ 'broken',
554
+ `the session-closed marker names receipt generation ${markerGen}, but no close receipt exists`,
555
+ );
556
+ }
557
+ if (marker) return result('legacy-closed');
558
+ return result('open', 'no close receipt and no session-closed marker for this session');
559
+ }
package/hooks/hooks.json CHANGED
@@ -98,35 +98,8 @@
98
98
  "hooks": [
99
99
  {
100
100
  "type": "command",
101
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-hot-rebuild.mjs",
102
- "timeout": 30
103
- }
104
- ]
105
- },
106
- {
107
- "hooks": [
108
- {
109
- "type": "command",
110
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-session-record.mjs",
111
- "timeout": 10
112
- }
113
- ]
114
- },
115
- {
116
- "hooks": [
117
- {
118
- "type": "command",
119
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-auto-commit.mjs",
120
- "timeout": 60
121
- }
122
- ]
123
- },
124
- {
125
- "hooks": [
126
- {
127
- "type": "command",
128
- "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-auto-minimal-crystallize.mjs",
129
- "timeout": 10
101
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-stop.mjs",
102
+ "timeout": 120
130
103
  }
131
104
  ]
132
105
  }