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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/README.ko.md +16 -15
- package/README.md +14 -13
- package/commands/crystallize.md +84 -30
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +32 -9
- package/docs/CONTRIBUTING.md +5 -1
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +141 -19
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +2 -29
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +381 -18
- package/hooks/hypo-shared.mjs +2151 -185
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +166 -34
- package/hooks/shared.json +7 -1
- package/hooks/version-check.mjs +9 -7
- package/package.json +2 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +506 -32
- package/scripts/init.mjs +32 -3
- package/scripts/lib/crystallize-close-apply.mjs +1949 -230
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/hook-inventory.mjs +31 -1
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +7 -1
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +1 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +15 -1
- package/scripts/upgrade.mjs +281 -90
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- 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-
|
|
102
|
-
"timeout":
|
|
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
|
}
|