hypomnema 1.8.2 → 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 +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- 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 +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- 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 +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- 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/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -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 +144 -66
- package/scripts/upgrade.mjs +523 -234
- 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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
|
|
11
|
+
import { atomicWrite } from './atomic-write.mjs';
|
|
11
12
|
import { homedir } from 'os';
|
|
12
13
|
import { join, dirname } from 'path';
|
|
13
14
|
import { fileURLToPath } from 'url';
|
|
@@ -39,6 +40,12 @@ import {
|
|
|
39
40
|
readVisibilityScope,
|
|
40
41
|
pkgRootDriftStatus,
|
|
41
42
|
PKG_ROOT,
|
|
43
|
+
withFileLock,
|
|
44
|
+
vaultCommitLockTarget,
|
|
45
|
+
claimAndWriteRootHotProjection,
|
|
46
|
+
recordTouchedPaths,
|
|
47
|
+
consumeRootHotHealthNotice,
|
|
48
|
+
rootHotBackupRecoveryNotice,
|
|
42
49
|
} from './hypo-shared.mjs';
|
|
43
50
|
import {
|
|
44
51
|
defaultCachePath,
|
|
@@ -52,6 +59,8 @@ import {
|
|
|
52
59
|
computeSiblingNotice,
|
|
53
60
|
siblingAlreadyNotified,
|
|
54
61
|
markSiblingNotified,
|
|
62
|
+
classifyInstall,
|
|
63
|
+
parseSemver,
|
|
55
64
|
pkgRootDriftAlreadyNotified,
|
|
56
65
|
markPkgRootDriftNotified,
|
|
57
66
|
clearPkgRootDriftNotified,
|
|
@@ -67,7 +76,7 @@ import {
|
|
|
67
76
|
recordObserved,
|
|
68
77
|
hashContent,
|
|
69
78
|
} from './base-store.mjs';
|
|
70
|
-
import {
|
|
79
|
+
import { listProposalsChecked } from './proposal-store.mjs';
|
|
71
80
|
|
|
72
81
|
// Privacy guard: refuse to read+inject .hypoignore-matched
|
|
73
82
|
// wiki files into additionalContext. Without this, a user who lists
|
|
@@ -237,15 +246,176 @@ function buildSiblingNotice() {
|
|
|
237
246
|
}
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
// Full path to the file init/upgrade own writing (hypo-shared.mjs's
|
|
250
|
+
// readCachedPkgRoot reads it but does not export the path). Self-heal below
|
|
251
|
+
// reads and rewrites it directly rather than adding a write path into
|
|
252
|
+
// hypo-shared.mjs, which currently owns only the READ side of this file.
|
|
253
|
+
const HYPO_PKG_JSON_PATH = join(homedir(), '.claude', 'hypo-pkg.json');
|
|
254
|
+
|
|
255
|
+
// ── self-heal lock ───────────────────────────────────────────────────────────
|
|
256
|
+
// Two sessions can self-locate to two DIFFERENT install roots at once (a dual
|
|
257
|
+
// install, or one upgrade mid-flight while another session already started)
|
|
258
|
+
// and both hit this same homedir()-keyed hypo-pkg.json. Reading it once,
|
|
259
|
+
// comparing, then writing let whichever session finished SECOND clobber
|
|
260
|
+
// whatever the first had just written, even when its own incoming version
|
|
261
|
+
// was itself newer than the file's ORIGINAL value (Codex 3rd-round review,
|
|
262
|
+
// 2026-09-11: reproduced by running a 1.8.2 and a 1.8.1 install against the
|
|
263
|
+
// same HOME; both read the pre-existing 1.0.0, both judged themselves an
|
|
264
|
+
// improvement over it, and whichever wrote last won regardless of which
|
|
265
|
+
// incoming version was actually higher).
|
|
266
|
+
//
|
|
267
|
+
// This used to hand-roll its own `wx` lockfile (write-then-fill), which a
|
|
268
|
+
// Codex 3rd-round review caught publishing an empty lock before the pid was
|
|
269
|
+
// written: a holder preempted inside that window is indistinguishable from a
|
|
270
|
+
// pid-less legacy lock, so a second writer steals it and both run the
|
|
271
|
+
// critical section, which is the exact bug a liveness check is supposed to
|
|
272
|
+
// close. `withFileLock` (below, from hypo-shared.mjs) already exists in this
|
|
273
|
+
// repo, and its own docstring names that same failure mode as the reason it
|
|
274
|
+
// stages the pid into a private sibling first and `linkSync`s it into place
|
|
275
|
+
// atomically. Reuse it instead of repeating the mistake it was written to
|
|
276
|
+
// avoid. `timeoutMs: 150` keeps the bounded, best-effort wait this hook
|
|
277
|
+
// always had (long enough for a sibling session's heal, a handful of sync fs
|
|
278
|
+
// calls, to finish and release; short enough that SessionStart itself never
|
|
279
|
+
// stalls noticeably even under contention); `staleMs: 30000` keeps the same
|
|
280
|
+
// crashed-holder cutoff the old lock used.
|
|
281
|
+
//
|
|
282
|
+
// withFileLock throws ELOCKTIMEOUT when it cannot acquire in time, never a
|
|
283
|
+
// bare Error, so selfHealPkgRoot's caller can tell "lock contention" apart
|
|
284
|
+
// from "fn() itself failed". Either way this hook must fail OPEN: a
|
|
285
|
+
// SessionStart hook must never block or crash a session over a lock it
|
|
286
|
+
// couldn't get, so both outcomes collapse to the caller's `{ healed: false }`
|
|
287
|
+
// via the try/catch already wrapping the read-compare-write below.
|
|
288
|
+
//
|
|
289
|
+
// Known residue in withFileLock itself, inherited here rather than
|
|
290
|
+
// re-solved: a microscopic stat-then-unlink TOCTOU on the stale-steal path
|
|
291
|
+
// (a fresh holder can grab the path in that gap and have its lock removed by
|
|
292
|
+
// the stealer, bounded by staleMs being far above a normal close), a
|
|
293
|
+
// local-filesystem-only guarantee (no network FS atomicity), and a PID-reuse
|
|
294
|
+
// window where a live process happens to reuse a crashed holder's pid and
|
|
295
|
+
// gets treated as the still-alive original (an availability loss, the lock
|
|
296
|
+
// is never stolen from it, not a correctness bug, since two writers still
|
|
297
|
+
// never enter the critical section together).
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Self-heal `pkgRoot`/`pkgVersion` in `hypo-pkg.json` in place, once a session
|
|
301
|
+
* has already detected they disagree with the code's own self-location
|
|
302
|
+
* (`status`, from pkgRootDriftStatus()). Until now the user's only remaining
|
|
303
|
+
* job was copying those two values into the file by hand; this does that copy
|
|
304
|
+
* for them.
|
|
305
|
+
*
|
|
306
|
+
* Preserves every other key untouched (`schemaVersion`, `extensions`,
|
|
307
|
+
* `commands`, ...): this file is shared with init/upgrade's own writes, so a
|
|
308
|
+
* narrower op than "read the whole object, patch two keys, write the whole
|
|
309
|
+
* object back" would silently drop state another lane depends on. Mirrors
|
|
310
|
+
* scripts/lib/pkg-json.mjs's writeDualSkipProvenance in spirit, but cannot
|
|
311
|
+
* import it — hooks never reach into scripts/.
|
|
312
|
+
*
|
|
313
|
+
* Fails closed to `{ healed: false }` on anything short of a verified,
|
|
314
|
+
* complete write: a lock that could not be acquired (timeout or a genuine
|
|
315
|
+
* fs error from `withFileLock`), an unreadable/corrupt existing file, a
|
|
316
|
+
* non-object parse result, no resolvable-and-parseable version at the new
|
|
317
|
+
* root, a DOWNGRADE (below), or the write itself throwing. The caller must
|
|
318
|
+
* fall back to the manual `/hypo:upgrade` guidance rather than claim a fix
|
|
319
|
+
* that never landed.
|
|
320
|
+
*
|
|
321
|
+
* Concurrency: the whole read-compare-write runs inside `withFileLock`
|
|
322
|
+
* (hypo-shared.mjs), which re-reads `hypo-pkg.json` AFTER acquiring the
|
|
323
|
+
* lock, not before, since a pre-lock snapshot is exactly what let two
|
|
324
|
+
* concurrent installs race. Losing the lock (ELOCKTIMEOUT, or any other
|
|
325
|
+
* throw from `withFileLock` or from `fn` itself) is caught by the outer
|
|
326
|
+
* try/catch and treated as fail-open: this session simply skips the heal,
|
|
327
|
+
* the same outcome as any other precondition miss below.
|
|
328
|
+
*
|
|
329
|
+
* The lock's reach is narrower than the sentence above about sharing this
|
|
330
|
+
* file with init/upgrade might suggest: it serializes SessionStart heals
|
|
331
|
+
* against each other, and nothing else. `writePkgJsonAtomic` (what init,
|
|
332
|
+
* upgrade and writeDualSkipProvenance go through) does not take it, so an
|
|
333
|
+
* `upgrade --apply` running while another session starts is still a lost
|
|
334
|
+
* update. The damage is bounded rather than absent: heal refuses a
|
|
335
|
+
* downgrade, so whichever write lands last leaves `pkgVersion` at the same
|
|
336
|
+
* value or higher. That bound is the whole argument for leaving it, and it
|
|
337
|
+
* dies the moment a heal path is allowed to lower the version.
|
|
338
|
+
*
|
|
339
|
+
* Downgrade guard: a dual install (e.g. a plugin-scope cache at 1.8.2 and a
|
|
340
|
+
* project-scope cache at 1.8.1, both reading the SAME `hypo-pkg.json` because
|
|
341
|
+
* that path is keyed on `homedir()`, not on scope) means a session that
|
|
342
|
+
* happens to self-locate to the OLDER sibling would otherwise overwrite this
|
|
343
|
+
* file's `pkgVersion` with a lower number — the one baseline
|
|
344
|
+
* `scripts/upgrade.mjs`'s downgrade guard and `hooks/version-check.mjs`'s
|
|
345
|
+
* `computeSiblingNotice` both compare against. `classifyInstall` is the same
|
|
346
|
+
* judgment those two already trust, but this only blocks the `'downgrade'`
|
|
347
|
+
* verdict: a comparison that resolves and comes out lower. On `'downgrade'`,
|
|
348
|
+
* this returns `{ healed: false }` before touching the file, so the normal
|
|
349
|
+
* failure fallback below fires the original manual-fix guidance instead of
|
|
350
|
+
* quietly recording the sibling's older version as truth.
|
|
351
|
+
*
|
|
352
|
+
* Direction matters for the `'unknown'` verdict (one side's semver failed to
|
|
353
|
+
* parse), which is why the INCOMING version is checked with `parseSemver`
|
|
354
|
+
* up front rather than folded into the same `'unknown'` escape hatch as an
|
|
355
|
+
* unparseable EXISTING value. An unparseable incoming version must never
|
|
356
|
+
* heal: that would overwrite a good `pkgVersion` with garbage read from a
|
|
357
|
+
* corrupt `package.json` (Codex 3rd-round review, 2026-09-11: reproduced with
|
|
358
|
+
* `package.json.version: "not-semver"` against an existing `1.8.2`, which
|
|
359
|
+
* used to heal to `"not-semver"`). An unparseable EXISTING value with a
|
|
360
|
+
* valid incoming one is the opposite case, a good value replacing a
|
|
361
|
+
* corrupted one, and is still allowed through by `classifyInstall`'s
|
|
362
|
+
* `'unknown'` verdict: there is no valid comparison to protect against
|
|
363
|
+
* there, and refusing it would leave a broken value in place forever with no
|
|
364
|
+
* path back to a good one.
|
|
365
|
+
*/
|
|
366
|
+
function selfHealPkgRoot(status) {
|
|
367
|
+
try {
|
|
368
|
+
return withFileLock(
|
|
369
|
+
HYPO_PKG_JSON_PATH,
|
|
370
|
+
() => {
|
|
371
|
+
const meta = JSON.parse(readFileSync(HYPO_PKG_JSON_PATH, 'utf-8'));
|
|
372
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return { healed: false };
|
|
373
|
+
const newVersion = readInstalledVersion(status.self);
|
|
374
|
+
if (!newVersion || !parseSemver(newVersion)) return { healed: false };
|
|
375
|
+
const oldVersion = typeof meta.pkgVersion === 'string' ? meta.pkgVersion : null;
|
|
376
|
+
const incoming = { pkgRoot: status.self, version: newVersion };
|
|
377
|
+
const active = { pkgRoot: meta.pkgRoot, version: oldVersion };
|
|
378
|
+
|
|
379
|
+
if (classifyInstall(incoming, active) === 'downgrade') return { healed: false };
|
|
380
|
+
const updated = { ...meta, pkgRoot: status.self, pkgVersion: newVersion };
|
|
381
|
+
// temp + rename in the SAME directory: a crash mid-write leaves only a
|
|
382
|
+
// throwaway temp file behind, never a torn hypo-pkg.json that the next
|
|
383
|
+
// session's every hook read of this file would then choke on.
|
|
384
|
+
// Shared writer rather than a local temp+rename: this one had no
|
|
385
|
+
// cleanup, so a failed rename left its temp behind for good. The temp
|
|
386
|
+
// name loses its leading dot in the trade, so a leaked one would now be
|
|
387
|
+
// visible in a directory the user reads; that is the point, since the
|
|
388
|
+
// shared writer is the one that does not leak.
|
|
389
|
+
atomicWrite(HYPO_PKG_JSON_PATH, `${JSON.stringify(updated, null, 2)}\n`);
|
|
390
|
+
return { healed: true, oldVersion, newVersion };
|
|
391
|
+
},
|
|
392
|
+
{ timeoutMs: 150, staleMs: 30_000, pollMs: 20 },
|
|
393
|
+
);
|
|
394
|
+
} catch {
|
|
395
|
+
// Fail open: a lock timeout (ELOCKTIMEOUT) or any other throw from
|
|
396
|
+
// withFileLock/fn must not block or crash SessionStart. The caller
|
|
397
|
+
// treats this exactly like any other heal precondition miss.
|
|
398
|
+
return { healed: false };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
240
402
|
/**
|
|
241
403
|
* pkgRoot drift notice. hypo-shared.mjs's resolvePkgRoot() already
|
|
242
404
|
* self-corrects PKG_ROOT in memory whenever the code's own resolved location
|
|
243
|
-
* disagrees with the cached hypo-pkg.json — but silent self-correction
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* (
|
|
247
|
-
*
|
|
248
|
-
*
|
|
405
|
+
* disagrees with the cached hypo-pkg.json — but silent self-correction used
|
|
406
|
+
* to leave the FILE itself stale forever, with nothing but a "please copy
|
|
407
|
+
* these two values in yourself" notice to show for it. This now does that
|
|
408
|
+
* copy itself (selfHealPkgRoot above), on the spot, in the same session that
|
|
409
|
+
* detected the drift.
|
|
410
|
+
*
|
|
411
|
+
* Notify-once cache: a successful heal marks the (cached → self-location)
|
|
412
|
+
* pair notified, but next session's pkgRootDriftStatus() reads back 'match'
|
|
413
|
+
* from the now-fixed file regardless and clears it on its own — the mark here
|
|
414
|
+
* only prevents a duplicate "fixed" line within a session that re-checks
|
|
415
|
+
* drift more than once. A FAILED heal must NOT mark the pair: marking would
|
|
416
|
+
* suppress the only guidance the user has (the manual `/hypo:upgrade` line)
|
|
417
|
+
* for a drift that never actually got fixed, on every later session that
|
|
418
|
+
* hits the same stale pair.
|
|
249
419
|
*
|
|
250
420
|
* Tri-state (pkgRootDriftStatus): 'match' CLEARS any earlier mark (checked
|
|
251
421
|
* FIRST, unconditionally — even under opt-out, so a drift that resolves while
|
|
@@ -253,8 +423,9 @@ function buildSiblingNotice() {
|
|
|
253
423
|
* recurrence once opt-out is lifted); 'unknown' touches nothing (self-location
|
|
254
424
|
* could not be resolved this session — the permanent steady state for the
|
|
255
425
|
* npm/manual channel, not evidence either way); only 'drift' can produce a
|
|
256
|
-
* banner, and opt-out is checked there so an opted-out
|
|
257
|
-
* pair
|
|
426
|
+
* banner (or attempt a heal), and opt-out is checked there so an opted-out
|
|
427
|
+
* session never writes a fix — or marks a pair notified — it never actually
|
|
428
|
+
* showed.
|
|
258
429
|
*/
|
|
259
430
|
function buildPkgRootDriftNotice() {
|
|
260
431
|
try {
|
|
@@ -269,7 +440,24 @@ function buildPkgRootDriftNotice() {
|
|
|
269
440
|
const key = `${status.cached || '(none)'}->${status.self}`;
|
|
270
441
|
const cache = readCache(cachePath);
|
|
271
442
|
if (pkgRootDriftAlreadyNotified(cache, key)) return '';
|
|
272
|
-
|
|
443
|
+
|
|
444
|
+
const heal = selfHealPkgRoot(status);
|
|
445
|
+
if (heal.healed) {
|
|
446
|
+
markPkgRootDriftNotified(cachePath, key);
|
|
447
|
+
return (
|
|
448
|
+
`[Hypomnema] Package metadata drift fixed: hypo-pkg.json pointed at ` +
|
|
449
|
+
`\`${status.cached || '(none)'}\`` +
|
|
450
|
+
`${heal.oldVersion ? ` (version ${heal.oldVersion})` : ''}, now synced to ` +
|
|
451
|
+
`\`${status.self}\` (version ${heal.newVersion}).\n` +
|
|
452
|
+
` This was corrected automatically for this session — no action needed.`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Heal did not land (unreadable/corrupt hypo-pkg.json, no resolvable
|
|
457
|
+
// version at the new root, or the write itself failed). Do NOT mark this
|
|
458
|
+
// pair notified here — see the docstring above — and fall back to the
|
|
459
|
+
// original manual-fix guidance.
|
|
460
|
+
//
|
|
273
461
|
// status.cached is null both for a genuinely fresh install (never ran
|
|
274
462
|
// /hypo:init) AND for the channel-judgment-failure guard (init/upgrade
|
|
275
463
|
// positively decided to leave pkgRoot unset; see scripts/init.mjs's
|
|
@@ -287,7 +475,7 @@ function buildPkgRootDriftNotice() {
|
|
|
287
475
|
`\`${status.cached || '(none)'}\`, but the code actually running resolves to ` +
|
|
288
476
|
`\`${status.self}\`.\n` +
|
|
289
477
|
` Hooks already resolved the correct root for this session — this is a ` +
|
|
290
|
-
`heads-up, not a blocker.\n${recoveryLine}`
|
|
478
|
+
`heads-up, not a blocker (the automatic fix did not take — see below).\n${recoveryLine}`
|
|
291
479
|
);
|
|
292
480
|
} catch {
|
|
293
481
|
return '';
|
|
@@ -471,15 +659,30 @@ function syncStateNotice(pullOk) {
|
|
|
471
659
|
* exactly like syncStateNotice: the line joins the `notices` array (→
|
|
472
660
|
* additionalContext) and is also written to stderr, so both the model and the
|
|
473
661
|
* user's transcript see it. NOT a systemMessage banner (that channel is
|
|
474
|
-
* reserved for the update/sibling notices). Pure read (
|
|
475
|
-
* mutates); best-effort so a store read failure never breaks
|
|
476
|
-
* there are no pending proposals
|
|
662
|
+
* reserved for the update/sibling notices). Pure read (listProposalsChecked
|
|
663
|
+
* never mutates); best-effort so a store read failure never breaks
|
|
664
|
+
* SessionStart. '' when there are no pending proposals AND nothing is wrong
|
|
665
|
+
* with the store, so nothing surfaces on the ordinary empty path.
|
|
666
|
+
*
|
|
667
|
+
* Uses listProposalsChecked rather than the plain listProposals this used to
|
|
668
|
+
* call: a store that could not be listed at all (`ok: false`, permissions or
|
|
669
|
+
* a file sitting where the directory should be) and a store with an
|
|
670
|
+
* unparseable `.json` candidate both used to look identical to zero here,
|
|
671
|
+
* silently, at the start of every session. Both get a short clause appended
|
|
672
|
+
* to the SAME line rather than a line of their own, so a clean vault's
|
|
673
|
+
* prompt budget does not grow by a byte.
|
|
477
674
|
*/
|
|
478
675
|
function pendingProposalNotice() {
|
|
479
676
|
try {
|
|
480
|
-
const
|
|
481
|
-
if (
|
|
482
|
-
|
|
677
|
+
const inventory = listProposalsChecked(HYPO_DIR);
|
|
678
|
+
if (!inventory.ok) return '[WIKI: 파킹 proposal 측정 불가 (hypomnema doctor 확인)]';
|
|
679
|
+
const n = inventory.proposals.length;
|
|
680
|
+
const u = inventory.unreadable.length;
|
|
681
|
+
if (n === 0 && u === 0) return '';
|
|
682
|
+
const parts = [];
|
|
683
|
+
if (n > 0) parts.push(`대기 proposal ${n}건 (검토: hypomnema proposal list)`);
|
|
684
|
+
if (u > 0) parts.push(`안 읽히는 후보 ${u}건 (hypomnema doctor 확인)`);
|
|
685
|
+
return `[WIKI: ${parts.join(', ')}]`;
|
|
483
686
|
} catch {
|
|
484
687
|
return '';
|
|
485
688
|
}
|
|
@@ -721,7 +924,32 @@ process.stdin.on('end', () => {
|
|
|
721
924
|
data = JSON.parse(raw);
|
|
722
925
|
} catch {}
|
|
723
926
|
|
|
724
|
-
|
|
927
|
+
// BLOCKER fix: serialized against every other writer of this vault
|
|
928
|
+
// (hypo-auto-commit.mjs's stage+commit+sync, crystallize.mjs's apply, and
|
|
929
|
+
// writeRootHotProjection below) on the SAME lock. A fast-forward pull
|
|
930
|
+
// rewrites hot.md as surely as the projection writer does, so locking the
|
|
931
|
+
// writer alone would just move the unguarded overwrite onto this line.
|
|
932
|
+
// Not reentrant: this hold is released before the projection call below
|
|
933
|
+
// takes the same lock for itself, and nothing between them holds it.
|
|
934
|
+
//
|
|
935
|
+
// A lock timeout leaves pullOk false, which is the same state a failed
|
|
936
|
+
// pull produces: the sync-state notice stays unresolved for one more
|
|
937
|
+
// session rather than being cleared on a pull that never ran.
|
|
938
|
+
//
|
|
939
|
+
// The `.git` check is gitPull's own precondition, hoisted out so the lock
|
|
940
|
+
// is never taken for a call that cannot pull: withFileLock creates the
|
|
941
|
+
// lock's parent directory, which on an install with no vault would mint
|
|
942
|
+
// `.cache/` under a wiki root that does not exist.
|
|
943
|
+
let pullOk = false;
|
|
944
|
+
if (existsSync(join(HYPO_DIR, '.git'))) {
|
|
945
|
+
try {
|
|
946
|
+
pullOk = withFileLock(vaultCommitLockTarget(HYPO_DIR), () => gitPull(HYPO_DIR), {
|
|
947
|
+
timeoutMs: Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000,
|
|
948
|
+
});
|
|
949
|
+
} catch {
|
|
950
|
+
pullOk = false;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
725
953
|
const syncLine = syncStateNotice(pullOk);
|
|
726
954
|
const proposalLine = pendingProposalNotice();
|
|
727
955
|
const growthLine = readLastGrowthLine();
|
|
@@ -788,6 +1016,197 @@ process.stdin.on('end', () => {
|
|
|
788
1016
|
process.stderr.write(`\n\x1b[33m${foreignNotice}\x1b[0m\n`);
|
|
789
1017
|
}
|
|
790
1018
|
|
|
1019
|
+
// Root hot.md projection. Must run AFTER gitPull (so it
|
|
1020
|
+
// reflects the merged tree) and BEFORE snapshotBase below (so this
|
|
1021
|
+
// session's base for 'hot.md' is the freshly generated bytes, not
|
|
1022
|
+
// whatever was on disk before this write). Also deliberately AFTER
|
|
1023
|
+
// foreignUncommittedNotice above: that check reads `git status` on the
|
|
1024
|
+
// wiki tree, and this session's OWN regeneration of hot.md is not a
|
|
1025
|
+
// foreign or unattributed change: running the write first would make the
|
|
1026
|
+
// projection's own necessary write show up in that scan and misreport as
|
|
1027
|
+
// one. resolveActiveProject, closeCandidateSlugs, and the HIT/MISS
|
|
1028
|
+
// injection later in this hook all read hot.md, so the write still has to
|
|
1029
|
+
// land before any of those.
|
|
1030
|
+
// recordTouchedPaths here is the ONLY thing that gets this write into a
|
|
1031
|
+
// scoped hypo-auto-commit at Stop. Without it: SessionStart writes fresh
|
|
1032
|
+
// bytes, Stop's own rebuild sees disk already equals the projection
|
|
1033
|
+
// (writeRootHotProjection's no-op branch) and records nothing touched, and
|
|
1034
|
+
// hypo-auto-commit only ever commits its touched-paths set, so the write
|
|
1035
|
+
// this hook just made would never be staged or committed by ANY path, and
|
|
1036
|
+
// would sit as an unattributable dirty file (closeFileTargetsGlobal scopes
|
|
1037
|
+
// hot.md as close-owned; classifyForeignOnlyDirty cannot demote it to
|
|
1038
|
+
// foreign) blocking both the close gate and PreCompact. TODO(wave 3): this
|
|
1039
|
+
// whole call disappears the moment hot.md comes out of overwriteTargets /
|
|
1040
|
+
// the commit path, since a projection target no longer needs a commit.
|
|
1041
|
+
//
|
|
1042
|
+
// The touched-paths record is claimed BEFORE the write, not after: it is
|
|
1043
|
+
// itself best-effort (a lock timeout can fail it), and recording it after
|
|
1044
|
+
// the fact means a failure there leaves a real disk write with nothing in
|
|
1045
|
+
// any session's touched-paths set, the exact orphan this comment already
|
|
1046
|
+
// described, just moved from "Stop's rebuild is a no-op" to "the record
|
|
1047
|
+
// call itself failed." Claiming it first and skipping the write on a
|
|
1048
|
+
// failed claim means hot.md is never rewritten without something able to
|
|
1049
|
+
// account for that rewrite at close time.
|
|
1050
|
+
//
|
|
1051
|
+
// The claim and the write share one hold of the vault lock
|
|
1052
|
+
// (claimAndWriteRootHotProjection). Claimed first and locked second, a
|
|
1053
|
+
// Stop for the same session_id could take the lock in between, retire
|
|
1054
|
+
// the still-unwritten claim, and leave the write that followed with no
|
|
1055
|
+
// claim at all.
|
|
1056
|
+
if (existsSync(HYPO_DIR)) {
|
|
1057
|
+
// A missing session_id reads as "not claimed" here (claimProjectionWrite
|
|
1058
|
+
// underneath), unlike every other best-effort accumulate call, or this
|
|
1059
|
+
// write proceeds with nothing able to ever bring it into a commit.
|
|
1060
|
+
let outcome;
|
|
1061
|
+
let writeError = null;
|
|
1062
|
+
try {
|
|
1063
|
+
// n1 fix: session_id goes along so this session's own digest lands in
|
|
1064
|
+
// ITS OWN receipt, not just the shared global ownership record (see
|
|
1065
|
+
// writeRootHotProjection's doc comment).
|
|
1066
|
+
outcome = claimAndWriteRootHotProjection(HYPO_DIR, data.session_id);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
writeError = err;
|
|
1069
|
+
}
|
|
1070
|
+
if (writeError) {
|
|
1071
|
+
// BLOCKER fix: writeRootHotProjection now throws instead of
|
|
1072
|
+
// silently treating an unreadable existing hot.md as absent. The
|
|
1073
|
+
// claim this call added was already dropped inside the lock (see
|
|
1074
|
+
// claimAndWriteRootHotProjection), so no stale 'hot.md' claim is
|
|
1075
|
+
// left to sweep in whatever another session or a person writes next.
|
|
1076
|
+
// catch-message-accuracy fix (r5-w1.md): the write can throw from
|
|
1077
|
+
// several different steps (reading the existing file, writing the
|
|
1078
|
+
// migration backup, updating .gitignore, or the final atomic write
|
|
1079
|
+
// itself), not only a read failure: wording this as "읽을 수 없어"
|
|
1080
|
+
// (could not read) misnamed a disk-full or backup-write failure as
|
|
1081
|
+
// something it was not. What stays true regardless of which step
|
|
1082
|
+
// failed is that the atomic write never ran, so the old file is
|
|
1083
|
+
// untouched.
|
|
1084
|
+
const err = writeError;
|
|
1085
|
+
const msg = `루트 hot.md 갱신에 실패해 이전 파일을 그대로 두었습니다: ${err?.message ?? String(err)}`;
|
|
1086
|
+
notices.push(msg);
|
|
1087
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1088
|
+
outExtra = {
|
|
1089
|
+
...outExtra,
|
|
1090
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1091
|
+
};
|
|
1092
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1093
|
+
} else if (outcome.claimFailed) {
|
|
1094
|
+
const msg =
|
|
1095
|
+
'루트 hot.md 갱신을 건너뛰었습니다: 이번 세션의 커밋 범위에 기록하지 못해 갱신 후 미커밋 파일로 남을 수 있었습니다.';
|
|
1096
|
+
notices.push(msg);
|
|
1097
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1098
|
+
outExtra = {
|
|
1099
|
+
...outExtra,
|
|
1100
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1101
|
+
};
|
|
1102
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1103
|
+
} else {
|
|
1104
|
+
const { result } = outcome;
|
|
1105
|
+
if (result.lockTimeout) {
|
|
1106
|
+
// BLOCKER fix, the other half: the projection writer holds the
|
|
1107
|
+
// vault lock, so a vault busy with another session's commit can
|
|
1108
|
+
// leave this session on the previous pointer table. Say so instead
|
|
1109
|
+
// of starting the session on a stale table with no explanation.
|
|
1110
|
+
// No claim to drop: it is taken inside the same lock, so a lock
|
|
1111
|
+
// timeout means this run never claimed anything, and a resumed
|
|
1112
|
+
// session_id's earlier, still-unresolved 'hot.md' claim is left
|
|
1113
|
+
// exactly as it was.
|
|
1114
|
+
const msg =
|
|
1115
|
+
'루트 hot.md 를 갱신하지 못했습니다: 다른 세션이 이 저장소를 쓰는 중이라 잠금을 얻지 못했습니다. 이번 세션은 이전 포인터 표를 그대로 읽습니다 (다음 세션 시작이나 종료 때 다시 시도합니다).';
|
|
1116
|
+
notices.push(msg);
|
|
1117
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1118
|
+
outExtra = {
|
|
1119
|
+
...outExtra,
|
|
1120
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1121
|
+
};
|
|
1122
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1123
|
+
} else if (result.scanError) {
|
|
1124
|
+
const msg =
|
|
1125
|
+
'루트 hot.md 갱신 실패: projects/ 디렉터리를 읽을 수 없어 이전 파일을 그대로 두었습니다.' +
|
|
1126
|
+
` 권한과 경로를 확인하고(\`ls -ld ${join(HYPO_DIR, 'projects')}\`), 고치면 다음 세션 시작이나 종료 때 자동으로 다시 시도합니다.`;
|
|
1127
|
+
notices.push(msg);
|
|
1128
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1129
|
+
outExtra = {
|
|
1130
|
+
...outExtra,
|
|
1131
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1132
|
+
};
|
|
1133
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1134
|
+
} else if (result.warnings.length > 0) {
|
|
1135
|
+
// major-1/3: a per-project read failure no longer disappears
|
|
1136
|
+
// silently: it kept its row (blank date) in the table already
|
|
1137
|
+
// written above, but the reason must still reach a human.
|
|
1138
|
+
const msg =
|
|
1139
|
+
`루트 hot.md 갱신 중 일부 프로젝트를 읽지 못했습니다:\n${result.warnings.join('\n')}\n` +
|
|
1140
|
+
'해당 파일의 권한과 경로를 확인하면 다음 세션 시작이나 종료 때 자동으로 다시 읽습니다.';
|
|
1141
|
+
notices.push(msg);
|
|
1142
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1143
|
+
outExtra = {
|
|
1144
|
+
...outExtra,
|
|
1145
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1146
|
+
};
|
|
1147
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1148
|
+
}
|
|
1149
|
+
if (result.gitignoreUpdated) {
|
|
1150
|
+
// Best-effort: the gitignore change itself already landed on
|
|
1151
|
+
// disk (writeRootHotProjection would have thrown otherwise); a
|
|
1152
|
+
// failure to also claim it only risks THIS one infra file
|
|
1153
|
+
// showing up as an unattributed dirty file at close, not the
|
|
1154
|
+
// backup it protects (which is gitignored either way).
|
|
1155
|
+
const gClaimed = recordTouchedPaths(HYPO_DIR, data.session_id, ['.gitignore']);
|
|
1156
|
+
if (!gClaimed) {
|
|
1157
|
+
process.stderr.write(
|
|
1158
|
+
'[hypo-session-start] .gitignore 갱신(백업 유출 방지 패턴)을 커밋 범위에 기록하지 못했습니다\n',
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (result.backedUp && result.backupPath) {
|
|
1163
|
+
// MAJOR fix: the backup itself was never in question (it lands
|
|
1164
|
+
// on disk and is gitignored either way), only whether a human
|
|
1165
|
+
// ever learns it exists. Before this, `backedUp`/`backupPath`
|
|
1166
|
+
// reached neither `notices` nor `systemMessage`: the backup file
|
|
1167
|
+
// is gitignored, so it does not even show up in `git status`, and
|
|
1168
|
+
// a person who had just hand-edited root hot.md would see their
|
|
1169
|
+
// edit silently replaced with no trace of where it went. Naming
|
|
1170
|
+
// only the file (basename), not its full path: the vault root is
|
|
1171
|
+
// already implied by the session, and nothing else here leaks a
|
|
1172
|
+
// full filesystem path into a notice. This branch should be rare
|
|
1173
|
+
// once rootHot leaves the close payload/overwrite targets: with
|
|
1174
|
+
// no scripted writer left that targets this file, a backup only
|
|
1175
|
+
// happens when a person edits it by hand, so this notice does
|
|
1176
|
+
// not become noise on every session.
|
|
1177
|
+
// Naming the file is not a recovery path on its own: copying the
|
|
1178
|
+
// backup back onto root hot.md just gets it replaced again on the
|
|
1179
|
+
// next rebuild. rootHotBackupRecoveryNotice (hooks/hypo-shared.mjs)
|
|
1180
|
+
// is the one sentence that says what actually recovers the
|
|
1181
|
+
// content, shared with the Stop-hook rebuild and project-create so
|
|
1182
|
+
// the three writers cannot drift into different wordings.
|
|
1183
|
+
const msg = rootHotBackupRecoveryNotice(result.backupPath);
|
|
1184
|
+
notices.push(msg);
|
|
1185
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1186
|
+
outExtra = {
|
|
1187
|
+
...outExtra,
|
|
1188
|
+
systemMessage: [outExtra.systemMessage, msg].filter(Boolean).join('\n\n'),
|
|
1189
|
+
};
|
|
1190
|
+
process.stderr.write(`\n\x1b[33m${msg}\x1b[0m\n`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// A prior Stop's rebuild may have failed invisibly (its own stdout is
|
|
1196
|
+
// suppressed) and left a note for the next SessionStart to show. One-shot:
|
|
1197
|
+
// consumed (and deleted) the moment it is read, so it surfaces exactly
|
|
1198
|
+
// once instead of repeating on every future session.
|
|
1199
|
+
const staleProjectionNotice = consumeRootHotHealthNotice(HYPO_DIR);
|
|
1200
|
+
if (staleProjectionNotice) {
|
|
1201
|
+
notices.push(staleProjectionNotice);
|
|
1202
|
+
noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
1203
|
+
outExtra = {
|
|
1204
|
+
...outExtra,
|
|
1205
|
+
systemMessage: [outExtra.systemMessage, staleProjectionNotice].filter(Boolean).join('\n\n'),
|
|
1206
|
+
};
|
|
1207
|
+
process.stderr.write(`\n\x1b[33m${staleProjectionNotice}\x1b[0m\n`);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
791
1210
|
// Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
|
|
792
1211
|
// describe the tree this session actually starts from, remote merges
|
|
793
1212
|
// included, or the first close would raise a proposal against content the
|
|
@@ -796,8 +1215,24 @@ process.stdin.on('end', () => {
|
|
|
796
1215
|
// alone. `data.session_id` is used raw rather than the 'default' fallback
|
|
797
1216
|
// above. A session with no id has no base and closes down the legacy
|
|
798
1217
|
// direct-write path.
|
|
1218
|
+
//
|
|
1219
|
+
// Only the root-only targets (hot.md, open-questions.md) are snapshotted
|
|
1220
|
+
// HERE, unconditionally. They carry no .hypoignore/visibility gate of
|
|
1221
|
+
// their own, so hashing them before any injection decision costs
|
|
1222
|
+
// nothing a later read would have refused anyway. The current project's
|
|
1223
|
+
// OWN two targets (hit.proj's hot.md / session-state.md) used to be
|
|
1224
|
+
// snapshotted right here too, blind to whether the HIT branch below was
|
|
1225
|
+
// about to actually show them (review r4-w4 major 3): that let a
|
|
1226
|
+
// .hypoignore'd or machine-scoped-out project target mint a "clean" base
|
|
1227
|
+
// from bytes this session was never shown a single line of, and for a
|
|
1228
|
+
// LATE HIT (this session's cwd only resolves to a project on a later
|
|
1229
|
+
// resume/compact, not its first SessionStart), that base came from
|
|
1230
|
+
// whatever was on disk at that arbitrary later moment, with no relation
|
|
1231
|
+
// to what injection actually revealed. Enrollment for those two moved
|
|
1232
|
+
// down to right after the HIT branch's own reads decide that question
|
|
1233
|
+
// (see the call there).
|
|
799
1234
|
if (data.session_id) {
|
|
800
|
-
snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(
|
|
1235
|
+
snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(null));
|
|
801
1236
|
// Bumps the observed generation on EVERY SessionStart (first run and
|
|
802
1237
|
// resume/compact alike), before the injections below record into it. A
|
|
803
1238
|
// resume that ends up injecting nothing (ignored, scoped out, absent)
|
|
@@ -823,6 +1258,109 @@ process.stdin.on('end', () => {
|
|
|
823
1258
|
const TODAY = new Date().toISOString().slice(0, 10);
|
|
824
1259
|
const hotRead = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
|
|
825
1260
|
const stateRead = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
|
|
1261
|
+
// Enroll THIS project's two overwrite targets now, right next to the
|
|
1262
|
+
// reads that just decided whether this session is actually about to be
|
|
1263
|
+
// shown them (review r4-w4 major 3: see the top-of-hook snapshotBase
|
|
1264
|
+
// call's comment for why this moved here instead of running blind
|
|
1265
|
+
// before this decision existed). A target `readIfNotIgnored` refused
|
|
1266
|
+
// (.hypoignore, machine-scoped-out) but that still exists on disk is
|
|
1267
|
+
// left OUT of `relPaths` on purpose: `snapshotBase` would otherwise
|
|
1268
|
+
// hash it anyway (it re-reads disk itself, not these `Read`s) and mint
|
|
1269
|
+
// a base from bytes this session was never shown, so the key stays
|
|
1270
|
+
// 'unknown' and this session's closes keep failing safe on it, same as
|
|
1271
|
+
// before late enrollment existed. A target genuinely absent on disk
|
|
1272
|
+
// (no file yet) is still safe to enroll as `null` (nothing was hidden
|
|
1273
|
+
// from anyone, there is simply nothing there yet), so `existsSync`
|
|
1274
|
+
// lets that case through even though `readIfNotIgnored` also returns
|
|
1275
|
+
// null for it.
|
|
1276
|
+
//
|
|
1277
|
+
// `knownHashes` (review r5-w3 blocker 1): the root-only snapshotBase
|
|
1278
|
+
// call above (line ~999) always creates base.json before this HIT
|
|
1279
|
+
// branch runs, so this call would otherwise ALWAYS take snapshotBase's
|
|
1280
|
+
// "enroll" branch and hash `hit.hotPath`/`hit.statePath` off disk again,
|
|
1281
|
+
// a SEPARATE read from `hotRead`/`stateRead` above. A writer landing in
|
|
1282
|
+
// that window mints a base from bytes this session was never shown, and
|
|
1283
|
+
// a later close built from `hotRead.raw`/`stateRead.raw` reads
|
|
1284
|
+
// disk-equals-base and overwrites that writer's change with no park.
|
|
1285
|
+
// Passing the hash of the bytes `hotRead`/`stateRead` ALREADY read pins
|
|
1286
|
+
// the base to what this session was actually shown, not to whatever a
|
|
1287
|
+
// second, independent read happens to find.
|
|
1288
|
+
if (data.session_id) {
|
|
1289
|
+
// Test-only barrier, the other half of the enroll probe below. The
|
|
1290
|
+
// dump alone catches a call that drops `knownHashes`, but not one
|
|
1291
|
+
// that re-reads disk here and hashes that: with nothing changing the
|
|
1292
|
+
// file, the re-read and the shown bytes agree. When
|
|
1293
|
+
// HYPO_TEST_ENROLL_BARRIER is a non-empty string, the hook says so on
|
|
1294
|
+
// stderr and waits until that path exists (10s at most), which gives
|
|
1295
|
+
// a test the gap between the reads above and the hashing below to
|
|
1296
|
+
// change the file in. It only waits and reads: it writes no file,
|
|
1297
|
+
// inside the vault or out, for the reason the dump comment gives.
|
|
1298
|
+
const barrier = process.env.HYPO_TEST_ENROLL_BARRIER;
|
|
1299
|
+
if (typeof barrier === 'string' && barrier.length > 0) {
|
|
1300
|
+
process.stderr.write('[hypo-test] enroll-barrier-ready\n');
|
|
1301
|
+
const deadline = Date.now() + 10000;
|
|
1302
|
+
while (!existsSync(barrier) && Date.now() < deadline) {
|
|
1303
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const relPaths = [];
|
|
1307
|
+
const knownHashes = {};
|
|
1308
|
+
const hotRelPath = join('projects', hit.proj, 'hot.md');
|
|
1309
|
+
const stateRelPath = join('projects', hit.proj, 'session-state.md');
|
|
1310
|
+
if (hotRead) {
|
|
1311
|
+
relPaths.push(hotRelPath);
|
|
1312
|
+
knownHashes[hotRelPath] = hashContent(hotRead.raw);
|
|
1313
|
+
} else if (!existsSync(hit.hotPath)) {
|
|
1314
|
+
relPaths.push(hotRelPath);
|
|
1315
|
+
knownHashes[hotRelPath] = null; // observed-absent, not unread
|
|
1316
|
+
}
|
|
1317
|
+
if (stateRead) {
|
|
1318
|
+
relPaths.push(stateRelPath);
|
|
1319
|
+
knownHashes[stateRelPath] = hashContent(stateRead.raw);
|
|
1320
|
+
} else if (!existsSync(hit.statePath)) {
|
|
1321
|
+
relPaths.push(stateRelPath);
|
|
1322
|
+
knownHashes[stateRelPath] = null;
|
|
1323
|
+
}
|
|
1324
|
+
// Test-only wiring probe (review r5-w4 blocker 1,
|
|
1325
|
+
// tests/proposal-base.test.mjs). It replaced a seam that wrote
|
|
1326
|
+
// `process.env.HYPO_TEST_RACE_HOT_WRITE` straight into `hit.hotPath`
|
|
1327
|
+
// to fake a concurrent writer: every file under hooks/ is copied into
|
|
1328
|
+
// every install, that path is a real person's project hot.md, and the
|
|
1329
|
+
// guard was `!== undefined`, so an empty value truncated the file to
|
|
1330
|
+
// zero bytes. Nothing here writes inside the vault any more.
|
|
1331
|
+
//
|
|
1332
|
+
// What is left is the half the seam was actually needed for. Whether
|
|
1333
|
+
// `snapshotBase` honors a hash it is told is a property of
|
|
1334
|
+
// `snapshotBase`, and a direct unit test covers it; whether THIS call
|
|
1335
|
+
// site hands it the hashes of the bytes it just showed is only
|
|
1336
|
+
// observable here, and dropping the argument leaves every other
|
|
1337
|
+
// real-hook test green (with no writer in the gap, a fresh disk read
|
|
1338
|
+
// and the shown bytes agree). So when HYPO_TEST_ENROLL_DUMP names a
|
|
1339
|
+
// path, the arguments this call actually passes are written there as
|
|
1340
|
+
// JSON, and a test reads them back. `args`, not the local
|
|
1341
|
+
// `knownHashes`: reading the local would report the map as passed even
|
|
1342
|
+
// when the call below stopped passing it, which is precisely the
|
|
1343
|
+
// regression this exists to catch. An argument list of three shows up
|
|
1344
|
+
// as `argc: 3` with no `knownHashes` key at all.
|
|
1345
|
+
//
|
|
1346
|
+
// Best-effort, like every other cache write in these hooks: a bad
|
|
1347
|
+
// dump path must not take down a SessionStart.
|
|
1348
|
+
const enroll = (...args) => {
|
|
1349
|
+
const dumpPath = process.env.HYPO_TEST_ENROLL_DUMP;
|
|
1350
|
+
if (dumpPath) {
|
|
1351
|
+
try {
|
|
1352
|
+
writeFileSync(
|
|
1353
|
+
dumpPath,
|
|
1354
|
+
JSON.stringify({ argc: args.length, relPaths: args[2], knownHashes: args[3] }),
|
|
1355
|
+
);
|
|
1356
|
+
} catch {
|
|
1357
|
+
// ignore: the probe is diagnostics, never a precondition
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return snapshotBase(...args);
|
|
1361
|
+
};
|
|
1362
|
+
if (relPaths.length) enroll(HYPO_DIR, data.session_id, relPaths, knownHashes);
|
|
1363
|
+
}
|
|
826
1364
|
let hotContent = hotRead ? hotRead.shown : null;
|
|
827
1365
|
let stateContent = stateRead ? stateRead.shown : null;
|
|
828
1366
|
const hotMarker = staleMarkerForPath(hit.hotPath, ignorePatterns, TODAY);
|
|
@@ -969,9 +1507,17 @@ process.stdin.on('end', () => {
|
|
|
969
1507
|
);
|
|
970
1508
|
// Observed-set record: the MISS branch's root hot.md injection is the one
|
|
971
1509
|
// place a project-less session observes anything at all. Recorded AFTER
|
|
972
|
-
// the console.log above
|
|
1510
|
+
// the console.log above. See the HIT branch's comment for why (fail-open
|
|
973
1511
|
// ordering). Hashes `.raw`, not a fresh disk read, for the same reason
|
|
974
1512
|
// given there.
|
|
1513
|
+
//
|
|
1514
|
+
// This call records nothing any more. overwriteTargets() stopped returning
|
|
1515
|
+
// 'hot.md' when the root pointer table became a hook-generated projection,
|
|
1516
|
+
// and recordObserved only writes for keys the base snapshot is tracking, so
|
|
1517
|
+
// the guard rejects this one every time. It is kept here, rather than
|
|
1518
|
+
// deleted, because this file is being rewritten in another lane at the same
|
|
1519
|
+
// time and a deletion would widen that merge. Delete it when the two lanes
|
|
1520
|
+
// land together.
|
|
975
1521
|
if (data.session_id) {
|
|
976
1522
|
recordObserved(
|
|
977
1523
|
HYPO_DIR,
|