hypomnema 1.8.4 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/README.ko.md +16 -15
- package/README.md +14 -13
- package/commands/crystallize.md +84 -30
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +32 -9
- package/docs/CONTRIBUTING.md +5 -1
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +141 -19
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +2 -29
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +381 -18
- package/hooks/hypo-shared.mjs +2151 -185
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +166 -34
- package/hooks/shared.json +7 -1
- package/hooks/version-check.mjs +9 -7
- package/package.json +2 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +506 -32
- package/scripts/init.mjs +32 -3
- package/scripts/lib/crystallize-close-apply.mjs +1949 -230
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/hook-inventory.mjs +31 -1
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +7 -1
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +1 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +15 -1
- package/scripts/upgrade.mjs +281 -90
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
package/hooks/base-store.mjs
CHANGED
|
@@ -29,19 +29,26 @@
|
|
|
29
29
|
// Everything here is best-effort: a hook must never fail a session start because
|
|
30
30
|
// a cache write did not land. Read failures degrade to "base unknown", which the
|
|
31
31
|
// caller treats as fail-safe (proposal), never as "no conflict".
|
|
32
|
+
//
|
|
33
|
+
// Every mutator below (snapshotBase, advanceBase, advanceBaseForWrite,
|
|
34
|
+
// beginObservedGeneration, recordObserved, recordAppliedHash, clearAppliedHash,
|
|
35
|
+
// advanceBaseAndRecordApplied) wraps its read-modify-write in `withFileLock`
|
|
36
|
+
// (review r5-w3 major 1): SessionStart, a PostToolUse hand-edit hook, and
|
|
37
|
+
// crystallize's own apply can all touch the SAME session's base.json around the
|
|
38
|
+
// same time, and a plain `readBaseFile` → mutate → `atomicWrite` sequence with
|
|
39
|
+
// no lock lets a later writer's read start before an earlier writer's write
|
|
40
|
+
// lands, so the later one then republishes the whole file from its stale copy and
|
|
41
|
+
// silently reverts the earlier writer's field. `atomicWrite`'s rename already
|
|
42
|
+
// guarantees a reader never sees a torn/partial file, so this lock exists for
|
|
43
|
+
// lost-update races between writers, not for read tearing. A lock timeout
|
|
44
|
+
// degrades to the same fail-safe as any other write failure here: the field
|
|
45
|
+
// that could not be recorded is simply left as it was.
|
|
32
46
|
|
|
33
47
|
import { createHash } from 'node:crypto';
|
|
34
|
-
import {
|
|
35
|
-
existsSync,
|
|
36
|
-
mkdirSync,
|
|
37
|
-
readFileSync,
|
|
38
|
-
writeFileSync,
|
|
39
|
-
renameSync,
|
|
40
|
-
closeSync,
|
|
41
|
-
openSync,
|
|
42
|
-
writeSync,
|
|
43
|
-
} from 'node:fs';
|
|
48
|
+
import { existsSync, mkdirSync, readFileSync, closeSync, openSync, writeSync } from 'node:fs';
|
|
44
49
|
import { join, dirname } from 'node:path';
|
|
50
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
51
|
+
import { withFileLock } from './hypo-shared.mjs';
|
|
45
52
|
|
|
46
53
|
/** sha256 of a UTF-8 string, hex. */
|
|
47
54
|
export function hashContent(content) {
|
|
@@ -68,14 +75,6 @@ export function basePath(hypoDir, sessionId) {
|
|
|
68
75
|
return join(hypoDir, '.cache', 'sessions', String(sessionId), 'base.json');
|
|
69
76
|
}
|
|
70
77
|
|
|
71
|
-
/** Atomic overwrite via tmp+rename, mirroring crystallize's atomicWrite. */
|
|
72
|
-
function atomicWrite(path, content) {
|
|
73
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
-
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
75
|
-
writeFileSync(tmp, content);
|
|
76
|
-
renameSync(tmp, path);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
78
|
/** Read and parse base.json. Returns null when absent, unreadable, or malformed. */
|
|
80
79
|
function readBaseFile(hypoDir, sessionId) {
|
|
81
80
|
const path = basePath(hypoDir, sessionId);
|
|
@@ -91,55 +90,133 @@ function readBaseFile(hypoDir, sessionId) {
|
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
/**
|
|
94
|
-
* Snapshot the observed base hashes for `relPaths`, ONCE per session.
|
|
93
|
+
* Snapshot the observed base hashes for `relPaths`, ONCE PER KEY per session.
|
|
95
94
|
*
|
|
96
95
|
* Existence-check (invariant 1): when base.json already exists for this session,
|
|
97
|
-
*
|
|
96
|
+
* an already-tracked key never moves: resume and compact must not move the
|
|
97
|
+
* base for a target this session has already seen.
|
|
98
|
+
*
|
|
99
|
+
* Late enrollment: `relPaths` can differ across calls in the SAME session. The
|
|
100
|
+
* caller that motivated this (hypo-session-start.mjs) resolves the project from
|
|
101
|
+
* `cwd`, which can miss on the session's first SessionStart and hit on a later
|
|
102
|
+
* resume or compact with the SAME session_id. `overwriteTargets(project)`
|
|
103
|
+
* then names two more paths than the first call did. Without this, those two
|
|
104
|
+
* targets would never get a key at all for the rest of the session (not even
|
|
105
|
+
* an 'unknown' one): `advanceBaseForWrite` and `recordObserved` are both
|
|
106
|
+
* scoped to `hasOwnProperty`, so an untracked key can never accept a write's
|
|
107
|
+
* provenance, and close falls back to `base-unknown` forever. So a key this
|
|
108
|
+
* call names that the session has NOT already seen is added now, hashed off
|
|
109
|
+
* disk exactly like a fresh snapshot's targets are; a key already tracked,
|
|
110
|
+
* present or 'absent', is left exactly as it was.
|
|
98
111
|
*
|
|
99
112
|
* A target that does not exist on disk is recorded as `null` (observed-absent),
|
|
100
113
|
* which is distinct from having no entry at all (observed-nothing). Close treats
|
|
101
114
|
* the first as "I saw no file, creating it is safe" and the second as fail-safe.
|
|
102
115
|
*
|
|
116
|
+
* `knownHashes` (review r5-w3 blocker 1): for a `relPath` this map covers, its
|
|
117
|
+
* value is written as the base AS-IS (a hex hash, or `null` for observed-absent)
|
|
118
|
+
* instead of a fresh `hashFile` disk read. Late enrollment (see above) is the
|
|
119
|
+
* caller that needs this: hypo-session-start.mjs's HIT branch reads
|
|
120
|
+
* `hit.hotPath`/`hit.statePath` to decide what to SHOW the model, then calls
|
|
121
|
+
* this function to enroll those same two targets. Root targets are always
|
|
122
|
+
* snapshotted first (line ~999 there), so `path` already exists by the time the
|
|
123
|
+
* HIT branch's own call lands here, which used to always take the "enroll"
|
|
124
|
+
* branch below and re-read disk fresh: a SEPARATE read from the one whose
|
|
125
|
+
* bytes the model was just shown. Between those two reads, another writer can
|
|
126
|
+
* change the file: the base would then describe bytes the model never saw, and
|
|
127
|
+
* a close that reapplies a payload built from the shown bytes reads
|
|
128
|
+
* disk-equals-base and overwrites the intervening write with no park. Passing
|
|
129
|
+
* the hash of the exact bytes just shown closes that window: the base can only
|
|
130
|
+
* ever describe what this session was actually shown, never a bystander read.
|
|
131
|
+
* A `relPath` NOT in `knownHashes` still gets the old fresh-disk-read behavior
|
|
132
|
+
* (the root-only call above has no "shown bytes" to pin to, since it runs
|
|
133
|
+
* before any injection decision).
|
|
134
|
+
*
|
|
103
135
|
* @param {string} hypoDir
|
|
104
136
|
* @param {string} sessionId
|
|
105
137
|
* @param {string[]} relPaths vault-relative target paths
|
|
106
|
-
* @
|
|
138
|
+
* @param {Record<string, string|null>|null} [knownHashes] exact-bytes hash (or
|
|
139
|
+
* `null` for observed-absent) to record for a `relPath`, bypassing the disk
|
|
140
|
+
* re-read that would otherwise risk racing a concurrent writer
|
|
141
|
+
* @returns {{created: boolean, reason?: string, enrolled?: string[]}}
|
|
107
142
|
*/
|
|
108
|
-
export function snapshotBase(hypoDir, sessionId, relPaths) {
|
|
143
|
+
export function snapshotBase(hypoDir, sessionId, relPaths, knownHashes = null) {
|
|
109
144
|
if (!sessionId) return { created: false, reason: 'no-session-id' };
|
|
110
145
|
const path = basePath(hypoDir, sessionId);
|
|
111
|
-
|
|
146
|
+
// A hash this call was TOLD to use always wins over a disk read: `undefined`
|
|
147
|
+
// (unreadable) is the disk-read fallback's own "leave this key out" signal, so
|
|
148
|
+
// it must never mask an explicit known value.
|
|
149
|
+
const resolveHash = (rel) =>
|
|
150
|
+
knownHashes && Object.prototype.hasOwnProperty.call(knownHashes, rel)
|
|
151
|
+
? knownHashes[rel]
|
|
152
|
+
: hashFile(join(hypoDir, rel));
|
|
112
153
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
try {
|
|
155
|
+
// Whole function under one lock (major 1, review r5-w3): the "does path
|
|
156
|
+
// exist" branch below and the write that follows it must not interleave
|
|
157
|
+
// with another RMW on this same session's base.json (resume, compact, and
|
|
158
|
+
// this session's own close can all touch it concurrently).
|
|
159
|
+
return withFileLock(path, () => {
|
|
160
|
+
if (existsSync(path)) {
|
|
161
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
162
|
+
// Unreadable/malformed base.json: nothing safe to enroll into, same
|
|
163
|
+
// fallback the pre-enrollment code always gave for an existing file.
|
|
164
|
+
if (!parsed) return { created: false, reason: 'already-snapshotted' };
|
|
165
|
+
const missing = (relPaths || []).filter(
|
|
166
|
+
(rel) => rel && !Object.prototype.hasOwnProperty.call(parsed.targets, rel),
|
|
167
|
+
);
|
|
168
|
+
if (missing.length === 0) return { created: false, reason: 'already-snapshotted' };
|
|
169
|
+
for (const rel of missing) {
|
|
170
|
+
const h = resolveHash(rel);
|
|
171
|
+
if (h !== undefined) parsed.targets[rel] = h;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
175
|
+
return { created: false, reason: 'enrolled', enrolled: missing };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return { created: false, reason: `write-failed: ${e && e.message}` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
122
180
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
181
|
+
const targets = {};
|
|
182
|
+
for (const rel of relPaths) {
|
|
183
|
+
if (!rel) continue;
|
|
184
|
+
const h = resolveHash(rel);
|
|
185
|
+
// `undefined` (unreadable) is left OUT of the map on purpose: no entry
|
|
186
|
+
// means "unknown", and close fails safe into a proposal rather than
|
|
187
|
+
// assuming a file it could not read was unchanged.
|
|
188
|
+
if (h !== undefined) targets[rel] = h;
|
|
189
|
+
}
|
|
128
190
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
191
|
+
const body = JSON.stringify(
|
|
192
|
+
{ session_id: String(sessionId), created_at: new Date().toISOString(), targets },
|
|
193
|
+
null,
|
|
194
|
+
2,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
199
|
+
// Exclusive create IS the existence-check, closing the gap between the
|
|
200
|
+
// existsSync above and the write below when two hooks race on one
|
|
201
|
+
// session. Redundant with the outer lock now, kept as a second,
|
|
202
|
+
// cheaper guard: it costs nothing when the lock already serialized us.
|
|
203
|
+
const fd = openSync(path, 'wx');
|
|
204
|
+
try {
|
|
205
|
+
writeSync(fd, body);
|
|
206
|
+
} finally {
|
|
207
|
+
closeSync(fd);
|
|
208
|
+
}
|
|
209
|
+
return { created: true };
|
|
210
|
+
} catch (e) {
|
|
211
|
+
if (e && e.code === 'EEXIST') return { created: false, reason: 'already-snapshotted' };
|
|
212
|
+
// best-effort: a hook must never break a session start over a cache write
|
|
213
|
+
return { created: false, reason: `write-failed: ${e && e.message}` };
|
|
214
|
+
}
|
|
215
|
+
});
|
|
140
216
|
} catch (e) {
|
|
141
|
-
|
|
142
|
-
//
|
|
217
|
+
// withFileLock throws on timeout/failure to acquire: best-effort, same as
|
|
218
|
+
// every other outcome here: a hook must never break a session start over
|
|
219
|
+
// a cache write.
|
|
143
220
|
return { created: false, reason: `write-failed: ${e && e.message}` };
|
|
144
221
|
}
|
|
145
222
|
}
|
|
@@ -182,12 +259,19 @@ export function readBaseEntry(hypoDir, sessionId, relPath) {
|
|
|
182
259
|
*/
|
|
183
260
|
export function advanceBase(hypoDir, sessionId, relPath, hash) {
|
|
184
261
|
if (!sessionId) return false;
|
|
185
|
-
const
|
|
186
|
-
if (!parsed) return false;
|
|
187
|
-
parsed.targets[relPath] = hash;
|
|
262
|
+
const path = basePath(hypoDir, sessionId);
|
|
188
263
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
264
|
+
return withFileLock(path, () => {
|
|
265
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
266
|
+
if (!parsed) return false;
|
|
267
|
+
parsed.targets[relPath] = hash;
|
|
268
|
+
try {
|
|
269
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
270
|
+
return true;
|
|
271
|
+
} catch {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
191
275
|
} catch {
|
|
192
276
|
return false;
|
|
193
277
|
}
|
|
@@ -234,17 +318,24 @@ export function advanceBase(hypoDir, sessionId, relPath, hash) {
|
|
|
234
318
|
*/
|
|
235
319
|
export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownHash = null) {
|
|
236
320
|
if (!sessionId) return false;
|
|
237
|
-
const
|
|
238
|
-
if (!parsed) return false;
|
|
239
|
-
// Only a tracked target advances. hasOwnProperty, not truthiness: an
|
|
240
|
-
// observed-absent entry is `null` but still a legitimate key to advance from.
|
|
241
|
-
if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
|
|
242
|
-
const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
|
|
243
|
-
if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
|
|
244
|
-
parsed.targets[relPath] = hash;
|
|
321
|
+
const path = basePath(hypoDir, sessionId);
|
|
245
322
|
try {
|
|
246
|
-
|
|
247
|
-
|
|
323
|
+
return withFileLock(path, () => {
|
|
324
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
325
|
+
if (!parsed) return false;
|
|
326
|
+
// Only a tracked target advances. hasOwnProperty, not truthiness: an
|
|
327
|
+
// observed-absent entry is `null` but still a legitimate key to advance from.
|
|
328
|
+
if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
|
|
329
|
+
const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
|
|
330
|
+
if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
|
|
331
|
+
parsed.targets[relPath] = hash;
|
|
332
|
+
try {
|
|
333
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
334
|
+
return true;
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
248
339
|
} catch {
|
|
249
340
|
return false;
|
|
250
341
|
}
|
|
@@ -300,13 +391,20 @@ export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownH
|
|
|
300
391
|
*/
|
|
301
392
|
export function beginObservedGeneration(hypoDir, sessionId) {
|
|
302
393
|
if (!sessionId) return false;
|
|
303
|
-
const
|
|
304
|
-
if (!parsed) return false;
|
|
305
|
-
const current = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
|
|
306
|
-
parsed.observedGeneration = current + 1;
|
|
394
|
+
const path = basePath(hypoDir, sessionId);
|
|
307
395
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
396
|
+
return withFileLock(path, () => {
|
|
397
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
398
|
+
if (!parsed) return false;
|
|
399
|
+
const current = typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
|
|
400
|
+
parsed.observedGeneration = current + 1;
|
|
401
|
+
try {
|
|
402
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
403
|
+
return true;
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
});
|
|
310
408
|
} catch {
|
|
311
409
|
return false;
|
|
312
410
|
}
|
|
@@ -326,7 +424,7 @@ export function beginObservedGeneration(hypoDir, sessionId) {
|
|
|
326
424
|
* refuses to hand it out as a licence: seeing 5% of a file is not seeing it.
|
|
327
425
|
*
|
|
328
426
|
* No-op, in order: no snapshot for this session; `relPath` is not one of the
|
|
329
|
-
*
|
|
427
|
+
* tracked overwrite targets (mirrors `advanceBaseForWrite`'s scoping --
|
|
330
428
|
* this must not be able to mint a new guarded key). Stamped with the CURRENT
|
|
331
429
|
* `observedGeneration`, never advancing it: the caller advances once via
|
|
332
430
|
* `beginObservedGeneration`, not once per recorded target.
|
|
@@ -345,18 +443,30 @@ function safeGeneration(v) {
|
|
|
345
443
|
|
|
346
444
|
export function recordObserved(hypoDir, sessionId, relPath, hash, truncated = false) {
|
|
347
445
|
if (!sessionId) return false;
|
|
348
|
-
const
|
|
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 };
|
|
446
|
+
const path = basePath(hypoDir, sessionId);
|
|
357
447
|
try {
|
|
358
|
-
|
|
359
|
-
|
|
448
|
+
return withFileLock(path, () => {
|
|
449
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
450
|
+
if (!parsed) return false;
|
|
451
|
+
if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
|
|
452
|
+
if (typeof hash !== 'string') return false;
|
|
453
|
+
const generation =
|
|
454
|
+
typeof parsed.observedGeneration === 'number' ? parsed.observedGeneration : 0;
|
|
455
|
+
if (
|
|
456
|
+
!parsed.observed ||
|
|
457
|
+
typeof parsed.observed !== 'object' ||
|
|
458
|
+
Array.isArray(parsed.observed)
|
|
459
|
+
) {
|
|
460
|
+
parsed.observed = {};
|
|
461
|
+
}
|
|
462
|
+
parsed.observed[relPath] = { generation, hash, truncated: !!truncated };
|
|
463
|
+
try {
|
|
464
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
465
|
+
return true;
|
|
466
|
+
} catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
360
470
|
} catch {
|
|
361
471
|
return false;
|
|
362
472
|
}
|
|
@@ -434,7 +544,208 @@ export function wasObservedTruncated(hypoDir, sessionId, relPath) {
|
|
|
434
544
|
return entry.truncated !== false;
|
|
435
545
|
}
|
|
436
546
|
|
|
437
|
-
//
|
|
547
|
+
// ── applied set ──────────────────────────────────────────────────────────────
|
|
548
|
+
//
|
|
549
|
+
// `targets` moves on TWO kinds of legitimate write: crystallize's own overwrite
|
|
550
|
+
// (invariant 2, `advanceBase`) and a direct hand edit through the session's own
|
|
551
|
+
// Write/Edit tool (`advanceBaseForWrite`, wired from hypo-auto-stage.mjs). Both
|
|
552
|
+
// are correct for the guard's original question, "did somebody ELSE change this
|
|
553
|
+
// since I last looked": a hand edit is this session's own work, not a foreign
|
|
554
|
+
// write, and must not park a close that folds it in.
|
|
555
|
+
//
|
|
556
|
+
// But the two are NOT the same fact, and `targets` alone cannot tell them
|
|
557
|
+
// apart after the fact: once a hand edit advances `targets[relPath]` to match
|
|
558
|
+
// its own new bytes, a stale close-apply payload from BEFORE that edit reads
|
|
559
|
+
// disk-equals-base and rewrites over the edit with no record it ever
|
|
560
|
+
// existed: the payload never touched the edit, and the edit was never
|
|
561
|
+
// re-checked against it. `applied` is the narrower, second fact this closes:
|
|
562
|
+
// not "what did any write show as the target's provenance", but "what did
|
|
563
|
+
// THIS OVERWRITE CALL itself last put down for this key, this session": a
|
|
564
|
+
// value only `recordAppliedHash` ever sets, so a hand edit through any other
|
|
565
|
+
// path leaves it exactly where the last successful apply left it. When disk no
|
|
566
|
+
// longer matches it, something moved the target after that apply without
|
|
567
|
+
// going through it again, and the next apply of the same (or same-shaped)
|
|
568
|
+
// payload must not silently bury that move.
|
|
569
|
+
//
|
|
570
|
+
// One entry per path, unconditionally overwritten by the next apply: there is
|
|
571
|
+
// no generation to expire here, only ever "what did I last apply", so the
|
|
572
|
+
// newest apply is always the only one that matters.
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Record that THIS session's own overwrite() just wrote `hash` for `relPath`.
|
|
576
|
+
* Scoped by tracked-ness like `advanceBaseForWrite`/`recordObserved`: a no-op
|
|
577
|
+
* for a key outside `targets`, so this can never mint a new guarded target.
|
|
578
|
+
*
|
|
579
|
+
* @returns {boolean} true when base.json was updated
|
|
580
|
+
*/
|
|
581
|
+
export function recordAppliedHash(hypoDir, sessionId, relPath, hash) {
|
|
582
|
+
if (!sessionId) return false;
|
|
583
|
+
const path = basePath(hypoDir, sessionId);
|
|
584
|
+
try {
|
|
585
|
+
return withFileLock(path, () => {
|
|
586
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
587
|
+
if (!parsed) return false;
|
|
588
|
+
if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
|
|
589
|
+
if (typeof hash !== 'string') return false;
|
|
590
|
+
if (
|
|
591
|
+
!parsed.appliedHashes ||
|
|
592
|
+
typeof parsed.appliedHashes !== 'object' ||
|
|
593
|
+
Array.isArray(parsed.appliedHashes)
|
|
594
|
+
) {
|
|
595
|
+
parsed.appliedHashes = {};
|
|
596
|
+
}
|
|
597
|
+
parsed.appliedHashes[relPath] = hash;
|
|
598
|
+
try {
|
|
599
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
600
|
+
return true;
|
|
601
|
+
} catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
} catch {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Clear THIS session's recorded applied-hash for `relPath`, without touching
|
|
612
|
+
* `targets`. For a writer that legitimately moves `targets[relPath]` (via
|
|
613
|
+
* `advanceBase`) but has no "applied by an overwrite() call" fact of its own to
|
|
614
|
+
* record.
|
|
615
|
+
*
|
|
616
|
+
* **No production caller right now.** The only one was
|
|
617
|
+
* `hooks/hypo-hot-rebuild.mjs`'s canonical rebuild of the root `hot.md`, and
|
|
618
|
+
* that file left `overwriteTargets` when it became a hook-generated projection,
|
|
619
|
+
* taking the whole base-vs-rebuild contention with it. Kept, with its unit
|
|
620
|
+
* coverage, for the next generated-file target that does get snapshotted; the
|
|
621
|
+
* scenario below is what it is for. Left stale, such a rebuild's `advanceBase`
|
|
622
|
+
* moves `targets`
|
|
623
|
+
* to the rebuilt bytes while `appliedHashes` still names whatever crystallize
|
|
624
|
+
* last applied; a retry of that same stale close payload then reads disk (the
|
|
625
|
+
* rebuilt bytes) as diverged from `appliedHash`, and blocker 2's "any drift
|
|
626
|
+
* from appliedHash parks" guard (`overwriteConflictReason`) mistakes the
|
|
627
|
+
* hook's own routine rebuild for a hand edit and parks it. Clearing removes the
|
|
628
|
+
* stale fact instead of leaving it to misfire: a retry after this sees no
|
|
629
|
+
* `appliedHash` for the target at all, and falls back to the plain base-vs-disk
|
|
630
|
+
* compare, same as a target crystallize has never applied to this session.
|
|
631
|
+
*
|
|
632
|
+
* The cost this accepts: a stale close payload that WOULD have been caught by
|
|
633
|
+
* the applied-hash guard can now overwrite the rebuild's canonical bytes
|
|
634
|
+
* outright, same as it could before blocker 2 existed. Rebuilt content is
|
|
635
|
+
* regenerated on the next Stop hook regardless, so this only trades one park
|
|
636
|
+
* for a page that self-heals a run later, not a real loss.
|
|
637
|
+
*
|
|
638
|
+
* @returns {boolean} true when base.json was updated
|
|
639
|
+
*/
|
|
640
|
+
export function clearAppliedHash(hypoDir, sessionId, relPath) {
|
|
641
|
+
if (!sessionId) return false;
|
|
642
|
+
const path = basePath(hypoDir, sessionId);
|
|
643
|
+
try {
|
|
644
|
+
return withFileLock(path, () => {
|
|
645
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
646
|
+
if (!parsed) return false;
|
|
647
|
+
if (
|
|
648
|
+
!parsed.appliedHashes ||
|
|
649
|
+
typeof parsed.appliedHashes !== 'object' ||
|
|
650
|
+
Array.isArray(parsed.appliedHashes) ||
|
|
651
|
+
!Object.prototype.hasOwnProperty.call(parsed.appliedHashes, relPath)
|
|
652
|
+
) {
|
|
653
|
+
return false; // nothing recorded for this target: a no-op, not a failure
|
|
654
|
+
}
|
|
655
|
+
delete parsed.appliedHashes[relPath];
|
|
656
|
+
try {
|
|
657
|
+
atomicWrite(path, JSON.stringify(parsed, null, 2));
|
|
658
|
+
return true;
|
|
659
|
+
} catch {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
} catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* `advanceBase` and `recordAppliedHash`, done as ONE read-modify-write instead
|
|
670
|
+
* of two. crystallize's own overwrite() used to call them back to back: if the
|
|
671
|
+
* first write landed and the second then failed (disk full, a permissions
|
|
672
|
+
* change mid-session, any `atomicWrite` failure), `targets[relPath]` had
|
|
673
|
+
* already moved to the new bytes while `appliedHashes[relPath]` silently kept
|
|
674
|
+
* its OLD value (the exact partial state `overwriteConflictReason`'s
|
|
675
|
+
* `appliedHash` check exists to catch, except now self-inflicted): the next
|
|
676
|
+
* apply would read disk-equals-base (base moved), see a stale `appliedHash`
|
|
677
|
+
* that does not match either, and park a WRITE that should have gone through,
|
|
678
|
+
* or (the direction review r4-w4 major 2 flagged) silently clear a
|
|
679
|
+
* genuinely stale reapply's park because the failed record made `appliedHash`
|
|
680
|
+
* read as if this call had never run at all. A `false` return here leaves
|
|
681
|
+
* BOTH fields exactly where they were (the single `atomicWrite` either lands
|
|
682
|
+
* whole or not at all): base does not move, so the next look sees the same
|
|
683
|
+
* `base-mismatch`/`base-hash-target-missing` fail-safe a foreign write would
|
|
684
|
+
* get, never a silent clean read. This is the only caller-visible difference
|
|
685
|
+
* from calling `advanceBase` then `recordAppliedHash` separately: a failure
|
|
686
|
+
* here now costs the base advance too, on purpose, because "recorded the
|
|
687
|
+
* write but forgot who wrote it" is worse than "didn't record it at all".
|
|
688
|
+
*
|
|
689
|
+
* Not a replacement for either function: `advanceBase` alone is the right call
|
|
690
|
+
* for a writer that moves a tracked target but has no "applied by THIS
|
|
691
|
+
* overwrite call" fact to record and must not manufacture one. Its last such
|
|
692
|
+
* caller was hypo-hot-rebuild.mjs, which stopped touching the base when the
|
|
693
|
+
* root `hot.md` left `overwriteTargets`.
|
|
694
|
+
*
|
|
695
|
+
* @param {{ write?: (path: string, content: string) => void }} [testHooks]
|
|
696
|
+
* `write` is test-only: it stands in for `atomicWrite`, so a test can let the
|
|
697
|
+
* first save land and fail a second one. A regression back to two saves then
|
|
698
|
+
* shows up as a moved base with a stale applied hash. Production callers
|
|
699
|
+
* never pass this.
|
|
700
|
+
* @returns {boolean} true when base.json was updated (both fields together)
|
|
701
|
+
*/
|
|
702
|
+
export function advanceBaseAndRecordApplied(hypoDir, sessionId, relPath, hash, testHooks) {
|
|
703
|
+
const write = testHooks?.write ?? atomicWrite;
|
|
704
|
+
if (!sessionId) return false;
|
|
705
|
+
const path = basePath(hypoDir, sessionId);
|
|
706
|
+
try {
|
|
707
|
+
return withFileLock(path, () => {
|
|
708
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
709
|
+
if (!parsed) return false;
|
|
710
|
+
if (typeof hash !== 'string') return false;
|
|
711
|
+
parsed.targets[relPath] = hash;
|
|
712
|
+
if (
|
|
713
|
+
!parsed.appliedHashes ||
|
|
714
|
+
typeof parsed.appliedHashes !== 'object' ||
|
|
715
|
+
Array.isArray(parsed.appliedHashes)
|
|
716
|
+
) {
|
|
717
|
+
parsed.appliedHashes = {};
|
|
718
|
+
}
|
|
719
|
+
parsed.appliedHashes[relPath] = hash;
|
|
720
|
+
try {
|
|
721
|
+
write(path, JSON.stringify(parsed, null, 2));
|
|
722
|
+
return true;
|
|
723
|
+
} catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
} catch {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The hash THIS session's own overwrite() last wrote for `relPath`, or null
|
|
734
|
+
* when it never has (no snapshot, no session, or no recorded entry).
|
|
735
|
+
*
|
|
736
|
+
* @returns {string|null}
|
|
737
|
+
*/
|
|
738
|
+
export function readAppliedHash(hypoDir, sessionId, relPath) {
|
|
739
|
+
if (!sessionId) return null;
|
|
740
|
+
const parsed = readBaseFile(hypoDir, sessionId);
|
|
741
|
+
if (!parsed) return null;
|
|
742
|
+
const applied = parsed.appliedHashes;
|
|
743
|
+
if (!applied || typeof applied !== 'object' || Array.isArray(applied)) return null;
|
|
744
|
+
const hash = applied[relPath];
|
|
745
|
+
return typeof hash === 'string' && hash ? hash : null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// The whole-file overwrite targets are prose-and-table markdown documents, and
|
|
438
749
|
// a base-mismatch on one of them always parks. Five predicates lived here that tried
|
|
439
750
|
// to skip the park when a payload "provably" lost nothing, and four rounds of review
|
|
440
751
|
// broke all five against the real vault. The last one accepted an insertion between a
|
|
@@ -443,18 +754,26 @@ export function wasObservedTruncated(hypoDir, sessionId, relPath) {
|
|
|
443
754
|
// from block context that begins far above the line under judgement, and a hook that
|
|
444
755
|
// may use Node built-ins only is not the place to own a block parser.
|
|
445
756
|
//
|
|
446
|
-
// The pointer table
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
757
|
+
// The pointer table those predicates were built for is gone from this list, which
|
|
758
|
+
// is the fix they were reaching for: the root `hot.md` is now a projection the
|
|
759
|
+
// SessionStart and Stop hooks regenerate from `projects/*/hot.md`, not a shared
|
|
760
|
+
// whole-file overwrite a close composes by hand. Two machines never contend over
|
|
761
|
+
// it, and nothing here needs to prove anything about it.
|
|
450
762
|
|
|
451
763
|
/**
|
|
452
|
-
* The
|
|
764
|
+
* The overwrite targets crystallize replaces wholesale. `project` may be
|
|
453
765
|
* null when cwd resolves to no project; the two project-scoped paths are then
|
|
454
766
|
* omitted and close falls back to proposal for them.
|
|
767
|
+
*
|
|
768
|
+
* The root `hot.md` is deliberately NOT here. Snapshotting it made the hooks'
|
|
769
|
+
* own regeneration look like a foreign edit, so a close parked a `base-mismatch`
|
|
770
|
+
* on a file no human had touched. Off the list, there is no base for the guard
|
|
771
|
+
* to compare and no park to false-raise; the projection self-heals on the next
|
|
772
|
+
* Stop hook regardless. Do not add it back without first taking the regeneration
|
|
773
|
+
* out of the hooks.
|
|
455
774
|
*/
|
|
456
775
|
export function overwriteTargets(project) {
|
|
457
|
-
const targets = [
|
|
776
|
+
const targets = [join('pages', 'open-questions.md')];
|
|
458
777
|
if (project) {
|
|
459
778
|
targets.unshift(join('projects', project, 'session-state.md'));
|
|
460
779
|
targets.unshift(join('projects', project, 'hot.md'));
|