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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +7 -7
- package/commands/uninstall.md +16 -4
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CONTRIBUTING.md +13 -4
- package/hooks/close-gate-store.mjs +435 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-personal-check.mjs +1 -1
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-shared.mjs +434 -192
- package/package.json +2 -1
- package/scripts/capture.mjs +26 -20
- package/scripts/crystallize.mjs +153 -20
- package/scripts/doctor.mjs +2 -2
- package/scripts/init.mjs +34 -18
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +89 -6
- package/scripts/lib/git-hooks-dir.mjs +139 -2
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +72 -24
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +351 -2
- package/skills/crystallize/SKILL.md +2 -2
- package/templates/hypo-config.md +1 -1
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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`);
|