hypomnema 1.7.3 → 1.8.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 +1202 -0
- package/README.ko.md +7 -7
- package/README.md +7 -7
- package/commands/audit.md +1 -1
- package/commands/capture.md +2 -2
- package/commands/crystallize.md +4 -4
- package/commands/doctor.md +15 -1
- package/commands/feedback.md +1 -1
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +17 -5
- package/commands/upgrade.md +2 -2
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +9 -4
- package/docs/CONTRIBUTING.md +15 -5
- package/hooks/base-store.mjs +198 -0
- package/hooks/close-gate-store.mjs +436 -0
- package/hooks/hooks.json +2 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
- package/hooks/hypo-close-guard.mjs +24 -4
- package/hooks/hypo-compact-guard.mjs +5 -3
- package/hooks/hypo-cwd-change.mjs +8 -0
- package/hooks/hypo-personal-check.mjs +128 -68
- package/hooks/hypo-session-end.mjs +21 -2
- package/hooks/hypo-session-start.mjs +91 -10
- package/hooks/hypo-shared.mjs +498 -180
- package/hooks/version-check.mjs +44 -6
- package/package.json +10 -3
- package/scripts/capture.mjs +64 -21
- package/scripts/crystallize.mjs +20 -2044
- package/scripts/doctor.mjs +334 -14
- package/scripts/init.mjs +132 -92
- package/scripts/lib/crystallize-args.mjs +50 -0
- package/scripts/lib/crystallize-close-apply.mjs +1830 -0
- package/scripts/lib/crystallize-close-check.mjs +238 -0
- package/scripts/lib/crystallize-close-gate.mjs +58 -0
- package/scripts/lib/crystallize-helpers.mjs +55 -0
- package/scripts/lib/design-history-stale.mjs +26 -7
- package/scripts/lib/extensions.mjs +235 -65
- package/scripts/lib/git-hooks-dir.mjs +214 -2
- package/scripts/lib/plugin-detect.mjs +176 -21
- package/scripts/lib/slug-resolver.mjs +181 -0
- package/scripts/lint.mjs +33 -23
- package/scripts/proposal.mjs +3 -3
- package/scripts/rename.mjs +38 -141
- package/scripts/uninstall.mjs +155 -58
- package/scripts/upgrade.mjs +243 -20
- package/skills/crystallize/SKILL.md +16 -14
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +2 -2
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/hypo-automation.md +59 -17
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +12 -9
- package/templates/hypo-help.md +1 -1
package/hooks/base-store.mjs
CHANGED
|
@@ -250,6 +250,204 @@ export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownH
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
// ── observed set ───────────────────────────────────────────────────────────
|
|
254
|
+
//
|
|
255
|
+
// `targets` never moves except through the two invariants above, so a session
|
|
256
|
+
// that outlives its first snapshot by days sees every intervening legitimate
|
|
257
|
+
// write from OTHER sessions as drift and parks all four overwrite targets.
|
|
258
|
+
// The observed set is a second, additive record: what this session was
|
|
259
|
+
// actually SHOWN by a later SessionStart (resume/compact), kept separate from
|
|
260
|
+
// `targets` so it can only ever widen what a close may write, never narrow or
|
|
261
|
+
// replace the original base. `readBaseEntry`'s shape and `targets`' meaning
|
|
262
|
+
// are unchanged; a consumer that never calls the functions below sees
|
|
263
|
+
// identical behavior to before this section existed.
|
|
264
|
+
//
|
|
265
|
+
// Two guards keep the widening bounded to "what this session was just shown":
|
|
266
|
+
//
|
|
267
|
+
// - Generation. `observedGeneration` is a per-session counter, bumped once
|
|
268
|
+
// per SessionStart by `beginObservedGeneration` (BEFORE the first read of
|
|
269
|
+
// that SessionStart, so it covers everything that SessionStart injects).
|
|
270
|
+
// `recordObserved` stamps each entry with the CURRENT generation but never
|
|
271
|
+
// advances it — advancing on every record would put the two files a HIT
|
|
272
|
+
// SessionStart injects (hot.md, session-state.md) into different
|
|
273
|
+
// generations, since they are recorded one call apart, and the second call
|
|
274
|
+
// would expire the first. `readObservedHash` only returns a hash whose
|
|
275
|
+
// `generation` equals the CURRENT `observedGeneration`; a SessionStart
|
|
276
|
+
// that bumps the generation and then injects nothing (ignored, scoped out,
|
|
277
|
+
// absent, no session_id) leaves every existing entry one generation stale,
|
|
278
|
+
// so it reads back as null everywhere. This is what makes "only the most
|
|
279
|
+
// recent SessionStart's injection licenses a write" true without an
|
|
280
|
+
// explicit expiry pass: staleness falls out of the generation compare.
|
|
281
|
+
// - Tracked-key scoping. Exactly like `advanceBaseForWrite`, `recordObserved`
|
|
282
|
+
// is a no-op for a key that is not already in `targets` — it cannot mint a
|
|
283
|
+
// new guarded target, only add provenance to one that was already
|
|
284
|
+
// snapshotted for this session.
|
|
285
|
+
//
|
|
286
|
+
// One entry per path, not an array: a later observation of the SAME path
|
|
287
|
+
// simply overwrites the old `{generation, hash}` pair, so there is no
|
|
288
|
+
// unbounded growth to cap or dedup.
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Bump this session's observed generation. Call once per SessionStart
|
|
292
|
+
* invocation, before the first `recordObserved` of that invocation — this is
|
|
293
|
+
* what makes an injection-free SessionStart (resume that hit no target, a
|
|
294
|
+
* scoped-out file, .hypoignore) expire every prior observation instead of
|
|
295
|
+
* leaving it licensed forever.
|
|
296
|
+
*
|
|
297
|
+
* No-op when the session has no snapshot yet: there is nothing to bump.
|
|
298
|
+
*
|
|
299
|
+
* @returns {boolean} true when base.json was updated
|
|
300
|
+
*/
|
|
301
|
+
export function beginObservedGeneration(hypoDir, sessionId) {
|
|
302
|
+
if (!sessionId) return false;
|
|
303
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
304
|
+
if (!parsed) return false;
|
|
305
|
+
const current = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
|
|
306
|
+
parsed.observedGeneration = current + 1;
|
|
307
|
+
try {
|
|
308
|
+
atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
|
|
309
|
+
return true;
|
|
310
|
+
} catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Record that this session was just SHOWN `hash` for `relPath` (the exact
|
|
317
|
+
* bytes a SessionStart injection read, not a fresh disk re-read — the caller
|
|
318
|
+
* must pass the hash of the bytes it actually displayed).
|
|
319
|
+
*
|
|
320
|
+
* `truncated`: true when the injection sliced the file (2000-char HOT_CHARS /
|
|
321
|
+
* STATE_CHARS) before showing it, i.e. the caller only passed `hash` of a
|
|
322
|
+
* prefix's worth of trust even though `hash` itself is the FULL file's hash.
|
|
323
|
+
* A truncated observation is stored, not dropped, so a parked close can name
|
|
324
|
+
* the reason (`base-mismatch-truncated-observation`) instead of the plain
|
|
325
|
+
* `base-mismatch` a bare no-op would produce — but `readObservedHash` below
|
|
326
|
+
* refuses to hand it out as a licence: seeing 5% of a file is not seeing it.
|
|
327
|
+
*
|
|
328
|
+
* No-op, in order: no snapshot for this session; `relPath` is not one of the
|
|
329
|
+
* four tracked overwrite targets (mirrors `advanceBaseForWrite`'s scoping —
|
|
330
|
+
* this must not be able to mint a new guarded key). Stamped with the CURRENT
|
|
331
|
+
* `observedGeneration`, never advancing it: the caller advances once via
|
|
332
|
+
* `beginObservedGeneration`, not once per recorded target.
|
|
333
|
+
*
|
|
334
|
+
* @returns {boolean} true when base.json was updated
|
|
335
|
+
*/
|
|
336
|
+
/**
|
|
337
|
+
* A safe integer, or null. base.json is on disk and another writer (an older
|
|
338
|
+
* release, a half-finished write, a hand edit) can leave any shape in it, so a
|
|
339
|
+
* generation counter is only trusted when it is exactly that: `NaN`, `Infinity`,
|
|
340
|
+
* `1.5` and `"1"` all read as absent rather than as a value to compare against.
|
|
341
|
+
*/
|
|
342
|
+
function safeGeneration(v) {
|
|
343
|
+
return Number.isSafeInteger(v) ? v : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function recordObserved(hypoDir, sessionId, relPath, hash, truncated = false) {
|
|
347
|
+
if (!sessionId) return false;
|
|
348
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
349
|
+
if (!parsed) return false;
|
|
350
|
+
if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
|
|
351
|
+
if (typeof hash !== 'string') return false;
|
|
352
|
+
const generation = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
|
|
353
|
+
if (!parsed.observed || typeof parsed.observed !== 'object' || Array.isArray(parsed.observed)) {
|
|
354
|
+
parsed.observed = {};
|
|
355
|
+
}
|
|
356
|
+
parsed.observed[relPath] = { generation, hash, truncated: !!truncated };
|
|
357
|
+
try {
|
|
358
|
+
atomicWrite(basePath(hypoDir, sessionId), JSON.stringify(parsed, null, 2));
|
|
359
|
+
return true;
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The hash this session was shown for `relPath`, but ONLY when it was shown
|
|
367
|
+
* IN FULL during the CURRENT observed generation — the actual enforcement
|
|
368
|
+
* point of "only the most recent SessionStart's injection licenses a write".
|
|
369
|
+
* Returns null for: no snapshot, a session whose observed-generation counter
|
|
370
|
+
* was never created (see below), no observed entry, a malformed entry, a
|
|
371
|
+
* generation that does not match (stale — superseded by a later SessionStart,
|
|
372
|
+
* or never refreshed by one that injected nothing), or an entry the injection
|
|
373
|
+
* itself marked `truncated`.
|
|
374
|
+
*
|
|
375
|
+
* The `observedGeneration` check is deliberately ASYMMETRIC with how
|
|
376
|
+
* `recordObserved` reads the same field: that function normalizes a missing
|
|
377
|
+
* counter to `0` only to pick a generation to STAMP an entry with. Doing the
|
|
378
|
+
* same here — treating "no counter" as "generation 0" — would make a session
|
|
379
|
+
* whose `beginObservedGeneration` call never ran (never bumped past 0) match
|
|
380
|
+
* an entry `recordObserved` also stamped at 0, and the observed set would
|
|
381
|
+
* license writes despite the expiry mechanism that is supposed to gate it
|
|
382
|
+
* never having run at all. So here, "no counter" reads as "no current
|
|
383
|
+
* generation for anything to match" — null, not 0.
|
|
384
|
+
*
|
|
385
|
+
* @returns {string|null}
|
|
386
|
+
*/
|
|
387
|
+
export function readObservedHash(hypoDir, sessionId, relPath) {
|
|
388
|
+
if (!sessionId) return null;
|
|
389
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
390
|
+
if (!parsed) return null;
|
|
391
|
+
const current = safeGeneration(parsed.observedGeneration);
|
|
392
|
+
if (current === null) return null;
|
|
393
|
+
const observed = parsed.observed;
|
|
394
|
+
if (!observed || typeof observed !== 'object' || Array.isArray(observed)) return null;
|
|
395
|
+
const entry = observed[relPath];
|
|
396
|
+
if (!entry || typeof entry !== 'object' || typeof entry.hash !== 'string' || !entry.hash) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
if (safeGeneration(entry.generation) !== current) return null;
|
|
400
|
+
// `truncated` is checked for a STRICT boolean, and any other shape refuses the
|
|
401
|
+
// licence rather than falling through to `=== true` being false. A corrupt
|
|
402
|
+
// `"true"` string used to pass that comparison and hand out a licence for a
|
|
403
|
+
// sliced observation — the one thing this field exists to deny. Corruption
|
|
404
|
+
// parks; it never widens.
|
|
405
|
+
if (entry.truncated !== false) return null;
|
|
406
|
+
return entry.hash;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Whether this session has a CURRENT-generation observed entry for `relPath`
|
|
411
|
+
* that exists but was marked `truncated` by `recordObserved` — the one bit
|
|
412
|
+
* `readObservedHash`'s null collapses away. Consulted only to pick a park
|
|
413
|
+
* reason (`base-mismatch-truncated-observation` vs plain `base-mismatch`),
|
|
414
|
+
* never to license a write; a caller must keep treating `readObservedHash`'s
|
|
415
|
+
* null as "no licence" regardless of what this returns.
|
|
416
|
+
*
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
export function wasObservedTruncated(hypoDir, sessionId, relPath) {
|
|
420
|
+
if (!sessionId) return false;
|
|
421
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
422
|
+
if (!parsed) return false;
|
|
423
|
+
const current = safeGeneration(parsed.observedGeneration);
|
|
424
|
+
if (current === null) return false;
|
|
425
|
+
const observed = parsed.observed;
|
|
426
|
+
if (!observed || typeof observed !== 'object' || Array.isArray(observed)) return false;
|
|
427
|
+
const entry = observed[relPath];
|
|
428
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
429
|
+
if (safeGeneration(entry.generation) !== current) return false;
|
|
430
|
+
// Mirrors readObservedHash's strict check: anything that is not exactly `false`
|
|
431
|
+
// counts as truncated here. This only picks the park REASON (readObservedHash
|
|
432
|
+
// has already refused the licence), so erring toward "truncated" names a
|
|
433
|
+
// narrower cause than the generic mismatch and never unblocks a write.
|
|
434
|
+
return entry.truncated !== false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The four whole-file overwrite targets are prose-and-table markdown documents, and
|
|
438
|
+
// a base-mismatch on one of them always parks. Five predicates lived here that tried
|
|
439
|
+
// to skip the park when a payload "provably" lost nothing, and four rounds of review
|
|
440
|
+
// broke all five against the real vault. The last one accepted an insertion between a
|
|
441
|
+
// table's header and its separator, which keeps every byte and stops the table from
|
|
442
|
+
// being a table. They all failed the same way: a markdown document's meaning comes
|
|
443
|
+
// from block context that begins far above the line under judgement, and a hook that
|
|
444
|
+
// may use Node built-ins only is not the place to own a block parser.
|
|
445
|
+
//
|
|
446
|
+
// The pointer table this was built for should stop being a shared whole-file
|
|
447
|
+
// overwrite target and become a locally generated projection of the per-project files
|
|
448
|
+
// that already own those facts. Then two machines never contend over it, and nothing
|
|
449
|
+
// here needs to prove anything.
|
|
450
|
+
|
|
253
451
|
/**
|
|
254
452
|
* The four overwrite targets crystallize replaces wholesale. `project` may be
|
|
255
453
|
* null when cwd resolves to no project; the two project-scoped paths are then
|
|
@@ -0,0 +1,436 @@
|
|
|
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
|
+
* ("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 that call to `closeGateStatus` turns that test
|
|
374
|
+
* red.
|
|
375
|
+
* As of this writing the two callers that gate a NEW request are
|
|
376
|
+
* `verifyCloseAuthority` (crystallize.mjs, before any wiki byte is written)
|
|
377
|
+
* and `hypo-close-guard.mjs`'s PreToolUse intercept (before a
|
|
378
|
+
* Write/Edit/MultiEdit on a close-artifact file lands); every path that
|
|
379
|
+
* recovers or reports on an ALREADY-recorded close (`runMarkSessionClosed`,
|
|
380
|
+
* and the post-apply diagnostic that reports whether the transcript carried
|
|
381
|
+
* a signal) reads `isCloseGateOpen` directly, on purpose, and should keep
|
|
382
|
+
* doing so.
|
|
383
|
+
*
|
|
384
|
+
* @param {{transcriptPath: string|null, hypoDir: string, sessionId: string|null}} args
|
|
385
|
+
* @returns {{ok: boolean, open: boolean, reason: string|null}}
|
|
386
|
+
*/
|
|
387
|
+
export function closeGateStatus({ transcriptPath, hypoDir, sessionId }) {
|
|
388
|
+
const { open, openedAtIndex } = walkCloseGate(transcriptPath ?? null);
|
|
389
|
+
if (!open) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
open: false,
|
|
393
|
+
reason:
|
|
394
|
+
'no-open: this session carries no close signal in its transcript yet — ' +
|
|
395
|
+
'ask the user whether they actually want to close before treating this as one.',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let rawTranscript = null;
|
|
400
|
+
try {
|
|
401
|
+
rawTranscript = transcriptPath ? readFileSync(transcriptPath) : null;
|
|
402
|
+
} catch {
|
|
403
|
+
rawTranscript = null; // unreadable at the moment of the check; readResolution treats this as unverifiable, not absent
|
|
404
|
+
}
|
|
405
|
+
const { closedAtIndex, prefixMatches } = readResolution(hypoDir, sessionId, rawTranscript);
|
|
406
|
+
|
|
407
|
+
if (closedAtIndex === null) {
|
|
408
|
+
// NO_CONSTRAINT: no valid resolution record exists for this session, so
|
|
409
|
+
// the open found above is unconstrained.
|
|
410
|
+
return { ok: true, open: true, reason: null };
|
|
411
|
+
}
|
|
412
|
+
if (prefixMatches === false) {
|
|
413
|
+
// Checked ahead of the index comparison on purpose, even though
|
|
414
|
+
// REJECTED_INDEX already makes `openedAtIndex >= closedAtIndex` fail on
|
|
415
|
+
// its own arithmetic: the point here is the DISTINCT reason string, not
|
|
416
|
+
// the pass/fail outcome.
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
open: true,
|
|
420
|
+
reason:
|
|
421
|
+
'transcript-rewrite-detected: the recorded resolution no longer matches this ' +
|
|
422
|
+
"transcript's history — treat this session's prior resolution as untrustworthy " +
|
|
423
|
+
'and confirm the close with the user again.',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (openedAtIndex >= closedAtIndex) {
|
|
427
|
+
return { ok: true, open: true, reason: null };
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
open: true,
|
|
432
|
+
reason:
|
|
433
|
+
'no-new-open-since-resolution: this session already resolved its last close signal — ' +
|
|
434
|
+
'a fresh close phrase from the user is needed before this can pass again.',
|
|
435
|
+
};
|
|
436
|
+
}
|