hypomnema 1.7.2 → 1.7.4

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,435 @@
1
+ // hooks/close-gate-store.mjs — the close-gate resolution record.
2
+ //
3
+ // Lives in hooks/ because scripts/ already imports from hooks/, never the
4
+ // reverse (a hook copied standalone into ~/.claude/hooks/ cannot resolve a
5
+ // scripts/ import). Node built-ins only.
6
+ //
7
+ // This file can only ever make close harder to invoke, never easier. Whether
8
+ // the gate is open is decided entirely from the session transcript, which the
9
+ // model cannot forge without also forging a human-authored role:user record.
10
+ // This store adds the one fact a transcript alone cannot carry: "the close
11
+ // this session opened has already gone through". Writing that fact CLOSES the
12
+ // gate. Nothing this file reads can OPEN it: `readResolution` never returns a
13
+ // value that widens a caller's decision, and it does not even look at keys
14
+ // like `open`, `granted`, `humanTurnAt`, or `fresh`. A forged file containing
15
+ // any of them falls back to the same "no constraint" answer an absent file
16
+ // gives, because none of those keys is ever read.
17
+ //
18
+ // `resolutionStamp` is the one place that decides what counts as a "record" in
19
+ // a raw transcript, for both the writer (close time) and the reader
20
+ // (verification time). Definition: split on newlines, skip a blank line, fail
21
+ // the whole computation on the first non-blank line that does not parse (a
22
+ // half-written or corrupt transcript looks like this), and skip a line that
23
+ // parses to something other than a non-null object (bare `null`, a string, a
24
+ // number are valid JSON but not a record). `prefixSha` hashes the raw BYTES
25
+ // from the start of the transcript through the end of the line that produced
26
+ // the target record, so an append after that point never changes it (those
27
+ // bytes are untouched) while a rewrite before it always does. Hashing bytes,
28
+ // not a decoded string, matters: two different invalid-UTF-8 byte sequences
29
+ // can decode to the identical JS string (both fold to U+FFFD), which would
30
+ // hide a rewrite from a string-based hash. `resolutionStamp` therefore walks
31
+ // a `Buffer`, never a decoded string, when it wants that guarantee — see its
32
+ // own doc comment for the caller contract.
33
+
34
+ import { createHash } from 'node:crypto';
35
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
36
+ import { dirname, join } from 'node:path';
37
+ import { walkCloseGate } from './hypo-shared.mjs';
38
+
39
+ /** `<hypoDir>/.cache/close-gate/<session-id>.json`. */
40
+ export function closeGatePath(hypoDir, sessionId) {
41
+ return join(hypoDir, '.cache', 'close-gate', `${sessionId}.json`);
42
+ }
43
+
44
+ // Only a `Buffer` is byte-faithful. A `string` was accepted here once, but a
45
+ // string has already been decoded by SOMEONE — this function has no way to
46
+ // tell an honest string apart from one a lossy decode already quietly
47
+ // rewrote to look like something else (two distinct invalid-UTF-8 byte
48
+ // sequences can both fold to U+FFFD and re-encode identically, which is
49
+ // exactly the collision this file exists to catch). So accepting a string at
50
+ // all just moves that hole one call deeper instead of closing it: whichever
51
+ // caller builds the stamp from a decoded string bakes the loss into the
52
+ // stamp itself, and every later comparison inherits it. Anything that is not
53
+ // a `Buffer` returns `null` here, the same "not a fatal transcript, an
54
+ // invalid call" answer a caller must already treat as unusable.
55
+ function toBuffer(rawTranscript) {
56
+ return Buffer.isBuffer(rawTranscript) ? rawTranscript : null;
57
+ }
58
+
59
+ /**
60
+ * Walk a raw transcript and compute `{ index, prefixSha }`. `index` is the
61
+ * count of object records found (see the file header for what counts as one).
62
+ * `prefixSha` is the sha256 (hex) of the raw BYTES through the end of the line
63
+ * that produced the `upToIndex`-th record (default: the last one found).
64
+ *
65
+ * Passing a smaller `upToIndex` is how a reader re-derives the SAME prefix a
66
+ * writer once hashed, out of a transcript that may have grown since: the walk
67
+ * stops counting at that record instead of hashing whatever came after, so an
68
+ * append never changes the answer.
69
+ *
70
+ * Returns `null` on a fatal parse: a non-blank line that does not parse as
71
+ * JSON at all, which is what a transcript being appended to mid-write looks
72
+ * like. A caller must not read a `null` stamp as "no records". Also `null`
73
+ * when `rawTranscript` is not a `Buffer` — a decoded `string` included; see
74
+ * `toBuffer`'s doc comment above for why that path was removed rather than
75
+ * merely documented against.
76
+ *
77
+ * @param {Buffer} rawTranscript the un-decoded result of
78
+ * `readFileSync(transcriptPath)`. Anything else (a `string` included)
79
+ * returns `null`.
80
+ * @param {number} [upToIndex]
81
+ * @returns {{index: number, prefixSha: string}|null}
82
+ */
83
+ export function resolutionStamp(rawTranscript, upToIndex = Infinity) {
84
+ const buf = toBuffer(rawTranscript);
85
+ if (buf === null) return null;
86
+ let index = 0;
87
+ let prefixEnd = 0;
88
+ let pos = 0;
89
+ const len = buf.length;
90
+ while (pos <= len) {
91
+ const nl = buf.indexOf(0x0a, pos); // '\n' byte — a single ASCII byte under any encoding
92
+ const lineEnd = nl === -1 ? len : nl;
93
+ const consumedThrough = nl === -1 ? len : nl + 1;
94
+ // Decoding the line to a string here is safe: it is used only to decide
95
+ // blank/non-blank and to JSON.parse it, never to compute the hash below
96
+ // (that reads straight off `buf`). A decode artifact (U+FFFD folding two
97
+ // distinct invalid byte sequences together) can at most affect record
98
+ // CLASSIFICATION, which this file already treats no differently for any
99
+ // other reason a line might parse one way or another — it can never
100
+ // affect the hash itself.
101
+ const line = buf.toString('utf-8', pos, lineEnd);
102
+ if (line.trim() !== '') {
103
+ let obj;
104
+ try {
105
+ obj = JSON.parse(line);
106
+ } catch {
107
+ return null; // fatal: unparseable non-blank line
108
+ }
109
+ // A record is a non-null object. A bare `null`, a string, or a number
110
+ // parses fine but is noise, not a record, and does not move the prefix.
111
+ if (obj !== null && typeof obj === 'object') {
112
+ index += 1;
113
+ prefixEnd = consumedThrough;
114
+ if (index >= upToIndex) break;
115
+ }
116
+ }
117
+ if (nl === -1) break;
118
+ pos = nl + 1;
119
+ }
120
+ const prefixSha = createHash('sha256').update(buf.subarray(0, prefixEnd)).digest('hex');
121
+ return { index, prefixSha };
122
+ }
123
+
124
+ /** Atomic overwrite via tmp+rename, mirroring base-store's atomicWrite. */
125
+ function atomicWrite(path, content) {
126
+ mkdirSync(dirname(path), { recursive: true });
127
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
128
+ writeFileSync(tmp, content);
129
+ renameSync(tmp, path);
130
+ }
131
+
132
+ /**
133
+ * Record that this session's close gate is resolved, at the point `stamp`
134
+ * describes. Best-effort: a hook must never fail a close over a cache write,
135
+ * so any error here is swallowed.
136
+ *
137
+ * @param {string} hypoDir
138
+ * @param {string} sessionId
139
+ * @param {{index: number, prefixSha: string}|null} stamp from `resolutionStamp`
140
+ * @returns {boolean} true when the file was written
141
+ */
142
+ export function recordGateClosed(hypoDir, sessionId, stamp) {
143
+ if (
144
+ !sessionId ||
145
+ !stamp ||
146
+ typeof stamp.index !== 'number' ||
147
+ typeof stamp.prefixSha !== 'string'
148
+ ) {
149
+ return false;
150
+ }
151
+ const body = JSON.stringify(
152
+ {
153
+ v: 1,
154
+ sessionId: String(sessionId),
155
+ closedAt: new Date().toISOString(),
156
+ closedAtIndex: stamp.index,
157
+ closedPrefixSha: stamp.prefixSha,
158
+ },
159
+ null,
160
+ 2,
161
+ );
162
+ try {
163
+ atomicWrite(closeGatePath(hypoDir, sessionId), body);
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ // The single "no constraint" answer, shared so an absent file and an unusable
171
+ // one are byte-for-byte the same return value. That sameness is the polarity
172
+ // guarantee this file exists to keep: nothing written to the resolution file
173
+ // can ever read back as MORE permissive than the file not existing at all.
174
+ const NO_CONSTRAINT = Object.freeze({ closedAtIndex: null, prefixMatches: null });
175
+
176
+ // The "rejected" sentinel for closedAtIndex — see readResolution's doc table.
177
+ // `Infinity` reads clean in memory (no real openedAtIndex is ever `>=
178
+ // Infinity`) but does not survive a JSON round trip: `JSON.stringify` turns
179
+ // `Infinity` into the literal `null`, which is the EXACT value this module
180
+ // uses for "no constraint" — so a hook or script that reads this value back
181
+ // out of its own stdout JSON silently flips a rejection into no constraint at
182
+ // all. `Number.MAX_SAFE_INTEGER` (2**53 - 1) survives JSON untouched and
183
+ // keeps the same arithmetic property for any value an honest caller can ever
184
+ // produce: `openedAtIndex` is an index into an in-memory array built by
185
+ // reading a transcript one line at a time (walkCloseGate in hypo-shared.mjs),
186
+ // so reaching this many entries needs a running process holding more than
187
+ // 2**53 parsed record objects in memory at once — past any real machine's
188
+ // RAM by many orders of magnitude, not merely a large transcript. It is
189
+ // unreachable because the walk that produces `openedAtIndex` cannot survive
190
+ // long enough to get there, not because the number is merely "big enough".
191
+ const REJECTED_INDEX = Number.MAX_SAFE_INTEGER;
192
+
193
+ /**
194
+ * Answer only "does a recorded resolution constrain this session's gate",
195
+ * never "is the gate open". Three possible shapes come back, and the table
196
+ * below is the full contract a consumer (the closeGateStatus gate to be
197
+ * built on top of this) needs — including what happens if it reads ONLY
198
+ * `closedAtIndex` and never looks at `prefixMatches` at all, because a
199
+ * consumer that only checks `openedAtIndex >= closedAtIndex` is a shape this
200
+ * file has to stay safe under, not a shape it gets to assume away.
201
+ *
202
+ * | state | shape | survives `JSON.parse(JSON.stringify(...))` | what it means | an index-only consumer (`openedAtIndex >= closedAtIndex`) does |
203
+ * |--------------------|-----------------------------------------------|:---:|-----------------------------------------------------------------------------|-----------------------------------------------------------------|
204
+ * | no constraint | `{closedAtIndex: null, prefixMatches: null}` | yes | no valid resolution record exists at all (absent file, unparseable, wrong `v`, wrong `sessionId`, or a `closedAtIndex` that fails its own shape check) | must special-case `null` and treat it as "unconstrained" |
205
+ * | verified | `{closedAtIndex: N, prefixMatches: true}` (N a finite positive integer) | yes | a resolution WAS recorded at record N, and this transcript still carries the exact same bytes through record N | compares correctly: passes only once a later open reaches index N |
206
+ * | rejected | `{closedAtIndex: REJECTED_INDEX (Number.MAX_SAFE_INTEGER), prefixMatches: false}` | yes | a resolution claims to exist but this transcript cannot be trusted against it: wrong input type, a hash mismatch, or an index mismatch (the walk never actually reached record N) | rejects WITHOUT reading `prefixMatches` at all, because no real record index is ever `>= Number.MAX_SAFE_INTEGER` |
207
+ *
208
+ * The "survives JSON round trip" column is load-bearing, not incidental: this
209
+ * value crosses a JSON boundary on the way out of a hook's stdout and out of
210
+ * `crystallize`'s `--check-session-close` JSON output, so a state that only
211
+ * holds up in memory is not actually held. An earlier version of the
212
+ * "rejected" row used `Infinity` for the same arithmetic reasoning, and
213
+ * `Infinity` DOES make an in-memory `>=` comparison fail on its own — but
214
+ * `JSON.stringify(Infinity)` is the literal `null`, which is the EXACT value
215
+ * this module uses for "no constraint". One JSON round trip silently flipped
216
+ * a rejection into "unconstrained". See `REJECTED_INDEX`'s own comment above
217
+ * for why `Number.MAX_SAFE_INTEGER` keeps the same guarantee without that
218
+ * failure mode.
219
+ *
220
+ * The "rejected" row is what makes the index-only consumer safe even across
221
+ * that boundary: this function never has to trust that some future caller
222
+ * remembers to check `prefixMatches`, because setting `closedAtIndex` to
223
+ * `REJECTED_INDEX` on every unverifiable input makes the plain `>=`
224
+ * comparison fail on its own, by arithmetic, not by convention. This
225
+ * replaces an earlier version of this function that returned the real
226
+ * recorded `closedAtIndex` alongside `prefixMatches: false` for an
227
+ * unverifiable input — correct for a caller that reads both fields, but
228
+ * silently permissive for one that reads only the index, since the real N
229
+ * can still satisfy `openedAtIndex >= N` for a later, unrelated open.
230
+ *
231
+ * Keys other than `v`, `sessionId`, `closedAtIndex`, `closedPrefixSha` are
232
+ * never read, on purpose: `open`, `granted`, `humanTurnAt`, `fresh` sitting in
233
+ * the file have zero effect here.
234
+ *
235
+ * `closedAtIndex` must be a positive safe integer (`Number.isSafeInteger` and
236
+ * `>= 1`) before anything else runs — a record index of 0 or below names no
237
+ * real record, and is exactly the shape a forged file would carry to
238
+ * trivially satisfy a downstream `openedAtIndex >= closedAtIndex`
239
+ * comparison. A `closedAtIndex` that fails this shape check falls into "no
240
+ * constraint", the same bucket as every other malformed field above: it
241
+ * never had a value this file could act on, so falling back to "as if the
242
+ * file were absent" cannot widen anything.
243
+ *
244
+ * Everything that PASSES the shape check but cannot be positively verified
245
+ * lands in "rejected", not "no constraint" — undecidable must not fold into
246
+ * permissive. That covers three different reasons, deliberately treated the
247
+ * same way: (1) `rawTranscript` is not a `Buffer` (a `string` has already
248
+ * been decoded by the caller, and re-encoding it here cannot recover
249
+ * whatever an invalid-UTF-8 rewrite destroyed on the way through that
250
+ * decode); (2) the recomputed `stamp.index` does not come back EQUAL to
251
+ * `parsed.closedAtIndex` (walking with `upToIndex = closedAtIndex` can stop
252
+ * EARLY, at whatever the transcript's actual last record is, if the
253
+ * transcript never reaches that many records — so a forged `closedAtIndex`
254
+ * larger than the real record count could otherwise land on a prefix that
255
+ * happens to hash-match a shorter, genuine one); (3) the hash itself does
256
+ * not match (a genuine rewrite).
257
+ *
258
+ * @param {string} hypoDir
259
+ * @param {string} sessionId
260
+ * @param {Buffer} rawTranscript the current transcript's raw bytes. Callers
261
+ * MUST pass what `readFileSync(transcriptPath)` returns with NO encoding
262
+ * argument. Anything else (a decoded string included) lands in "rejected"
263
+ * above.
264
+ * @returns {{closedAtIndex: number|null, prefixMatches: boolean|null}}
265
+ */
266
+ export function readResolution(hypoDir, sessionId, rawTranscript) {
267
+ if (!sessionId) return NO_CONSTRAINT;
268
+ const path = closeGatePath(hypoDir, sessionId);
269
+ if (!existsSync(path)) return NO_CONSTRAINT;
270
+
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
274
+ } catch {
275
+ return NO_CONSTRAINT;
276
+ }
277
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return NO_CONSTRAINT;
278
+ if (parsed.v !== 1) return NO_CONSTRAINT;
279
+ if (typeof parsed.sessionId !== 'string' || parsed.sessionId !== String(sessionId)) {
280
+ return NO_CONSTRAINT;
281
+ }
282
+ if (
283
+ typeof parsed.closedAtIndex !== 'number' ||
284
+ !Number.isSafeInteger(parsed.closedAtIndex) ||
285
+ parsed.closedAtIndex < 1 ||
286
+ typeof parsed.closedPrefixSha !== 'string'
287
+ ) {
288
+ return NO_CONSTRAINT;
289
+ }
290
+
291
+ // `resolutionStamp` itself now returns `null` for anything that is not a
292
+ // Buffer (a string included), so a wrong-typed `rawTranscript` and a
293
+ // genuine hash/index mismatch both land here without a separate type
294
+ // check: `verified` is false either way, and "rejected" (REJECTED_INDEX,
295
+ // never the real recorded index) is the one answer every failure mode
296
+ // gets. See the doc comment's table above for why that shape, not the
297
+ // real index, is what makes an index-only consumer safe, and why it has
298
+ // to be a value that survives a JSON round trip.
299
+ const stamp = resolutionStamp(rawTranscript, parsed.closedAtIndex);
300
+ const verified =
301
+ stamp !== null &&
302
+ stamp.index === parsed.closedAtIndex &&
303
+ stamp.prefixSha === parsed.closedPrefixSha;
304
+ return verified
305
+ ? { closedAtIndex: parsed.closedAtIndex, prefixMatches: true }
306
+ : { closedAtIndex: REJECTED_INDEX, prefixMatches: false };
307
+ }
308
+
309
+ /**
310
+ * The one gate a consumer needs when the caller is about to WRITE the wiki
311
+ * or WRITE the marker: does this session's transcript, taken together with
312
+ * any recorded resolution, currently authorize a close? This is the
313
+ * composite `walkCloseGate` + `readResolution` such a caller should use
314
+ * instead of wiring the two together itself.
315
+ *
316
+ * Three rules, in order:
317
+ * 1. No open in the transcript at all (`walkCloseGate(...).open` is
318
+ * false) → reject. There is nothing to check a resolution against.
319
+ * 2. A recorded resolution exists and its prefix hash still matches this
320
+ * transcript's bytes through the resolved record → pass only when the
321
+ * open found in step 1 happened STRICTLY AFTER that record
322
+ * (`openedAtIndex >= closedAtIndex`). The two sides of that comparison
323
+ * use DIFFERENT bases on purpose, not by accident: `openedAtIndex` is
324
+ * `walkCloseGate`'s 0-based position in its record array, while
325
+ * `closedAtIndex` is `resolutionStamp`'s 1-based COUNT of records
326
+ * resolved. A count of N records resolved (positions 0..N-1 spent)
327
+ * means position N is the first UNRESOLVED one — so `openedAtIndex
328
+ * (N) >= closedAtIndex (N)` lands exactly on the next fresh record,
329
+ * not on the one the resolution already consumed. Reusing this
330
+ * comparison anywhere else requires reusing this base mismatch too;
331
+ * the new-open tests in the test file below pin this exact boundary,
332
+ * so DO NOT "fix" the operator to `>` — that would move the boundary
333
+ * off by one in the other direction. An open that does not clear the
334
+ * boundary is the signal already spent; it does not authorize a
335
+ * second apply.
336
+ * 3. The recorded resolution's prefix hash does NOT match (the
337
+ * transcript was rewritten before the resolved record) → reject,
338
+ * distinctly from rule 2, because the fix is different: rule 2 asks
339
+ * for a fresh close phrase, rule 3 says the evidence itself cannot be
340
+ * trusted.
341
+ * (No resolution record at all — `readResolution`'s `NO_CONSTRAINT` — is
342
+ * not a fourth rule: it is the absence of rule 2's and rule 3's
343
+ * precondition, so an open from step 1 passes with nothing further to
344
+ * check.)
345
+ *
346
+ * `open` in the return value is `walkCloseGate`'s own verdict (rule 1),
347
+ * unfiltered by the resolution check — a consumer that wants to know
348
+ * "did the user say close at all, resolution aside" reads this field.
349
+ * `ok` is the full three-rule verdict.
350
+ *
351
+ * NOT every `isCloseGateOpen` caller should switch to this. Writing bytes is
352
+ * not the test: `--mark-session-closed` writes a file too (the marker), and
353
+ * it still belongs on `isCloseGateOpen`. The real criterion is which close
354
+ * event a call is transacting FOR. `verifyCloseAuthority` and
355
+ * `hypo-close-guard.mjs` each gate a NEW authorization request: a write
356
+ * that has not happened yet, standing on whatever close signal the
357
+ * transcript currently carries — so a resolution recorded by an EARLIER
358
+ * close must retire that old signal before a new one is trusted again.
359
+ * `runMarkSessionClosed` (crystallize.mjs's standalone
360
+ * `--mark-session-closed`) is different in kind: it is the FOLLOW-UP
361
+ * RECOVERY of a close that, per the resolution file itself, has ALREADY
362
+ * happened (a successful apply is what wrote that resolution in the first
363
+ * place). Gating that recovery on `closeGateStatus` would make an apply's
364
+ * own success permanently block its one legitimate repair path — its
365
+ * `openedAtIndex` comes from the same transcript snapshot the resolution
366
+ * was just stamped FROM, so it can never clear the boundary rule 2 needs,
367
+ * and a marker withheld by a commit failure could never be recovered
368
+ * without a brand-new close phrase the user has no reason to type twice.
369
+ * tests/close-hooks-gate.test.mjs's test C
370
+ * ("crystallize.mjs:754 (runMarkSessionClosed) stays on isCloseGateOpen")
371
+ * pins exactly this: it withholds a marker via a real commit failure, fixes
372
+ * the commit by hand with NO new close signal, and asserts the recovery run
373
+ * still succeeds; swapping :754 to `closeGateStatus` turns that test red.
374
+ * As of this writing the two callers that gate a NEW request are
375
+ * `verifyCloseAuthority` (crystallize.mjs, before any wiki byte is written)
376
+ * and `hypo-close-guard.mjs`'s PreToolUse intercept (before a
377
+ * Write/Edit/MultiEdit on a close-artifact file lands); every path that
378
+ * recovers or reports on an ALREADY-recorded close (`runMarkSessionClosed`,
379
+ * and the post-apply diagnostic that reports whether the transcript carried
380
+ * a signal) reads `isCloseGateOpen` directly, on purpose, and should keep
381
+ * doing so.
382
+ *
383
+ * @param {{transcriptPath: string|null, hypoDir: string, sessionId: string|null}} args
384
+ * @returns {{ok: boolean, open: boolean, reason: string|null}}
385
+ */
386
+ export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
387
+ const { open, openedAtIndex } = walkCloseGate(transcriptPath ?? null);
388
+ if (!open) {
389
+ return {
390
+ ok: false,
391
+ open: false,
392
+ reason:
393
+ 'no-open: this session carries no close signal in its transcript yet — ' +
394
+ 'ask the user whether they actually want to close before treating this as one.',
395
+ };
396
+ }
397
+
398
+ let rawTranscript = null;
399
+ try {
400
+ rawTranscript = transcriptPath ? readFileSync(transcriptPath) : null;
401
+ } catch {
402
+ rawTranscript = null; // unreadable at the moment of the check; readResolution treats this as unverifiable, not absent
403
+ }
404
+ const { closedAtIndex, prefixMatches } = readResolution(hypoDir, sessionId, rawTranscript);
405
+
406
+ if (closedAtIndex === null) {
407
+ // NO_CONSTRAINT: no valid resolution record exists for this session, so
408
+ // the open found above is unconstrained.
409
+ return { ok: true, open: true, reason: null };
410
+ }
411
+ if (prefixMatches === false) {
412
+ // Checked ahead of the index comparison on purpose, even though
413
+ // REJECTED_INDEX already makes `openedAtIndex >= closedAtIndex` fail on
414
+ // its own arithmetic: the point here is the DISTINCT reason string, not
415
+ // the pass/fail outcome.
416
+ return {
417
+ ok: false,
418
+ open: true,
419
+ reason:
420
+ 'transcript-rewrite-detected: the recorded resolution no longer matches this ' +
421
+ "transcript's history — treat this session's prior resolution as untrustworthy " +
422
+ 'and confirm the close with the user again.',
423
+ };
424
+ }
425
+ if (openedAtIndex >= closedAtIndex) {
426
+ return { ok: true, open: true, reason: null };
427
+ }
428
+ return {
429
+ ok: false,
430
+ open: true,
431
+ reason:
432
+ 'no-new-open-since-resolution: this session already resolved its last close signal — ' +
433
+ 'a fresh close phrase from the user is needed before this can pass again.',
434
+ };
435
+ }
package/hooks/hooks.json CHANGED
@@ -159,6 +159,7 @@
159
159
  "version-check.mjs",
160
160
  "version-check-fetch.mjs",
161
161
  "base-store.mjs",
162
- "proposal-store.mjs"
162
+ "proposal-store.mjs",
163
+ "close-gate-store.mjs"
163
164
  ]
164
165
  }
@@ -103,11 +103,11 @@ import { relative } from 'path';
103
103
  import {
104
104
  HYPO_DIR,
105
105
  detectSessionCloseArtifact,
106
- hasUserCloseSignal,
107
106
  isGateSkipped,
108
107
  touchedPathsPath,
109
108
  withFileLock,
110
109
  } from './hypo-shared.mjs';
110
+ import { closeGateStatus } from './close-gate-store.mjs';
111
111
 
112
112
  const CLOSE_ARTIFACT_BASENAMES = new Set(['session-state.md', 'hot.md']);
113
113
  // Mirrors hypo-auto-stage.mjs's WRITE_TOOLS: the tools that replace file bytes.
@@ -215,7 +215,22 @@ try {
215
215
  process.exit(0);
216
216
  }
217
217
 
218
- if (hasUserCloseSignal(input.transcript_path ?? null)) {
218
+ const gateStatus = closeGateStatus({
219
+ transcriptPath: input.transcript_path ?? null,
220
+ hypoDir: HYPO_DIR,
221
+ sessionId: input.session_id ?? null,
222
+ });
223
+ // F4: without session_id, closeGateStatus's own readResolution can never
224
+ // look up THIS session's close-gate/<id>.json, so a PRIOR close this exact
225
+ // session already resolved would silently read back as unconstrained (rule
226
+ // 1's raw transcript open, none of the resolution check rules 2/3 add) —
227
+ // the same T6-before behavior this whole file exists to close. Real
228
+ // PreToolUse input always carries session_id (measured), so this branch is
229
+ // a defensive fail-closed for a shape that should not occur, not the
230
+ // everyday path: an absent session_id forces an ask on top of whatever
231
+ // closeGateStatus itself found, rather than letting "cannot verify" read
232
+ // as "verified clean" the way an unchecked `gateStatus.ok` would.
233
+ if (gateStatus.ok && input.session_id) {
219
234
  process.exit(0);
220
235
  }
221
236
 
@@ -224,6 +239,11 @@ try {
224
239
  : structuralHit
225
240
  ? `both session-state.md and hot.md are being rewritten this session`
226
241
  : `this write reads as a close announcement (마감/종료 wording)`;
242
+ // gateStatus.reason is null when gateStatus.ok is true — the missing-
243
+ // session_id branch above is the only way to reach here with ok:true, so
244
+ // name that gap explicitly instead of printing a literal "(null)".
245
+ const gateReason =
246
+ gateStatus.reason ?? 'no session_id — cannot verify against a recorded resolution';
227
247
 
228
248
  console.log(
229
249
  JSON.stringify({
@@ -233,8 +253,8 @@ try {
233
253
  permissionDecision: 'ask',
234
254
  permissionDecisionReason:
235
255
  `[WIKI CLOSE GUARD] ${rel} — ${why}, but no user close signal was seen ` +
236
- `in this session. Confirm with the user before writing: did they actually ` +
237
- `ask to close the session?\n` +
256
+ `in this session (${gateReason}). Confirm with the user before writing: did they ` +
257
+ `actually ask to close the session?\n` +
238
258
  `To bypass: set HYPO_SKIP_GATE=1`,
239
259
  },
240
260
  }),
@@ -19,6 +19,7 @@ import {
19
19
  deriveRootLogEntries,
20
20
  recordTouchedPaths,
21
21
  } from './hypo-shared.mjs';
22
+ import { advanceBase, hashContent } from './base-store.mjs';
22
23
 
23
24
  const HOT_PATH = join(HYPO_DIR, 'hot.md');
24
25
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
@@ -73,7 +74,7 @@ function parsePointerRows(content) {
73
74
  }
74
75
 
75
76
  /** @returns {boolean} true when hot.md was actually rewritten. */
76
- function rebuild() {
77
+ function rebuild(sessionId) {
77
78
  if (!existsSync(HOT_PATH)) return false;
78
79
 
79
80
  const current = readFileSync(HOT_PATH, 'utf-8');
@@ -116,6 +117,25 @@ ${tableRows}
116
117
 
117
118
  if (canonical !== current) {
118
119
  writeFileSync(HOT_PATH, canonical);
120
+ // This write bypasses the Write/Edit tool, so hypo-auto-stage's
121
+ // PostToolUse-based advanceBaseForWrite never sees it (see the file-header
122
+ // comment above). Without advancing the base here, this session's own
123
+ // rewrite of hot.md looks -- at close time -- exactly like a DIFFERENT
124
+ // session having edited it, and the observed-base guard in crystallize.mjs
125
+ // parks a false conflict against this session's own work. Mirrors
126
+ // crystallize.mjs's overwrite(): advanceBase right after the write that
127
+ // made it true.
128
+ //
129
+ // No session_id on stdin (empty/malformed payload): advanceBase is a
130
+ // no-op without a snapshot anyway, so this stays silent-safe like before
131
+ // this fix -- just observable on stderr instead of a guess.
132
+ if (sessionId) {
133
+ advanceBase(HYPO_DIR, sessionId, 'hot.md', hashContent(canonical));
134
+ } else {
135
+ process.stderr.write(
136
+ '[hypo-hot-rebuild] no session_id on stdin; base not advanced for hot.md\n',
137
+ );
138
+ }
119
139
  return true;
120
140
  }
121
141
  return false;
@@ -134,7 +154,7 @@ function emitGrowth() {
134
154
 
135
155
  let hotWritten = false;
136
156
  try {
137
- hotWritten = rebuild();
157
+ hotWritten = rebuild(sessionId);
138
158
  } catch (err) {
139
159
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
140
160
  }
@@ -249,7 +249,7 @@ process.stdin.on('end', () => {
249
249
  ` [ ] 0. Read SCHEMA.md + hypo-guide.md (required before wiki work)`,
250
250
  ` [ ] 1. PRD — create projects/<name>/prd.md if missing`,
251
251
  ` [ ] 2. ADR — decide yes/no on 5 types. Design change → append to projects/<name>/design-history.md.`,
252
- ` If none, note the literal marker "ADR 없음 — reason: <why>" in the session-log entry`,
252
+ ` If none, note the literal marker "ADR 없음: <reason>" in the session-log entry`,
253
253
  ` (machine-readable; suppresses the W8 design-history gate for no-design sessions).`,
254
254
  ` [ ] 3. Ingest — if new external knowledge, save to sources/ and ingest`,
255
255
  ` [ ] 4. Pages — extract new concepts/patterns to pages/`,
@@ -16,8 +16,9 @@
16
16
  * debug lines only.
17
17
  */
18
18
 
19
- import { HYPO_DIR, writeClearMarker } from './hypo-shared.mjs';
20
- import { existsSync } from 'fs';
19
+ import { HYPO_DIR, writeClearMarker, resolveTranscriptBySessionId } from './hypo-shared.mjs';
20
+ import { recordGateClosed, resolutionStamp } from './close-gate-store.mjs';
21
+ import { existsSync, readFileSync } from 'fs';
21
22
 
22
23
  function emitContinue() {
23
24
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -50,6 +51,24 @@ process.stdin.on('end', () => {
50
51
  prev_transcript_path: payload.transcript_path || payload.transcriptPath || null,
51
52
  prev_cwd: payload.cwd || null,
52
53
  });
54
+
55
+ // Close-gate resolution, the second writer (decision 5): `/clear` ends
56
+ // the session as surely as a close apply does, so it closes the gate
57
+ // the same way. `sessionId` is the SAME payload field already read two
58
+ // lines above for the clear marker, not a new one. The transcript path
59
+ // is independently re-resolved by session id (glob under
60
+ // ~/.claude/projects), never trusted straight off the payload, matching
61
+ // every other resolver this store's callers use. Best-effort: a missing
62
+ // session id, an unresolvable transcript, or any read/write failure
63
+ // here is caught by this function's own outer try/catch below and never
64
+ // blocks `/clear` itself.
65
+ const sessionId = payload.session_id || payload.sessionId || null;
66
+ if (sessionId) {
67
+ const transcriptPath = resolveTranscriptBySessionId(sessionId);
68
+ if (transcriptPath) {
69
+ recordGateClosed(HYPO_DIR, sessionId, resolutionStamp(readFileSync(transcriptPath)));
70
+ }
71
+ }
53
72
  } catch (err) {
54
73
  // Best-effort: a marker failure must not break /clear itself.
55
74
  process.stderr.write(`[hypo-session-end] error: ${err?.message ?? String(err)}\n`);