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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -18,7 +18,6 @@ import {
18
18
  realpathSync,
19
19
  openSync,
20
20
  unlinkSync,
21
- renameSync,
22
21
  linkSync,
23
22
  } from 'fs';
24
23
  import { join, relative, basename, dirname, isAbsolute } from 'path';
@@ -26,6 +25,8 @@ import { homedir, hostname, tmpdir } from 'os';
26
25
  import { spawnSync } from 'child_process';
27
26
  import { randomBytes, createHash } from 'crypto';
28
27
  import { fileURLToPath } from 'url';
28
+ import { atomicWrite } from './atomic-write.mjs';
29
+ import { isValidSessionId } from './proposal-store.mjs';
29
30
 
30
31
  const HOME = homedir();
31
32
 
@@ -712,8 +713,12 @@ export function hotMdIsClean(dir = HYPO_DIR) {
712
713
  // ── strict session-close verification ────────────────────────────
713
714
  // spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
714
715
  // 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
715
- // (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
716
- // project hot.md, root hot.md, session-log/YYYY-MM-DD.md, and log.md.
716
+ // (sessionCloseFileStatus) confirms the 4 mandatory files: session-state.md,
717
+ // project hot.md, session-log/YYYY-MM-DD.md, and log.md. Root hot.md is NOT
718
+ // one of them: it became a hook-generated projection, so the close no longer
719
+ // writes it and the gate no longer checks it (see the note at the
720
+ // checkUpdated calls in sessionCloseFileStatus for why its `updated:` field
721
+ // cannot be read as this project's own date).
717
722
  // pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
718
723
  // cross-project queue, so a session that raises no questions should not be
719
724
  // forced to touch it. Gating it would produce false-blocks; spec §5.2.7
@@ -1209,8 +1214,25 @@ export function pickProjectByCwd(projects, cwd, opts = {}) {
1209
1214
  export function collectProjectWorkingDirs(hypoDir) {
1210
1215
  const projectsDir = join(hypoDir, 'projects');
1211
1216
  if (!existsSync(projectsDir)) return [];
1217
+ // existsSync is true for a regular file too, and it says nothing about
1218
+ // permissions, so readdirSync here can still throw ENOTDIR or EACCES. It
1219
+ // used to, and the caller that matters is SessionStart's cwd-to-project
1220
+ // resolution, which runs long before the projection writer. An unguarded
1221
+ // throw there took the whole hook to its top-level catch, which meant the
1222
+ // projection's own scanError branch (the one that tells a person WHICH
1223
+ // directory to fix) could never be reached on the real hook path: it was
1224
+ // dead code for exactly the inputs it existed to report. Degrade to "no
1225
+ // projects" instead. Resolving cwd to a project is a convenience, and the
1226
+ // projection scan re-reads this same directory moments later and surfaces
1227
+ // the real failure with its own notice.
1228
+ let slugs;
1229
+ try {
1230
+ slugs = readdirSync(projectsDir);
1231
+ } catch {
1232
+ return [];
1233
+ }
1212
1234
  const out = [];
1213
- for (const slug of readdirSync(projectsDir)) {
1235
+ for (const slug of slugs) {
1214
1236
  if (slug === '_template') continue;
1215
1237
  const dir = join(projectsDir, slug);
1216
1238
  try {
@@ -1372,11 +1394,12 @@ export function findBackfillCandidate(cwd, hypoDir = HYPO_DIR) {
1372
1394
 
1373
1395
  /**
1374
1396
  * When the session cwd is a project working_dir distinct from the vault root,
1375
- * the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart and
1376
- * CwdChanged inject hot.md/session-state content but never the vault's absolute
1377
- * path, so the AI re-discovers it each session and can wrongly conclude a wiki
1378
- * file is missing after checking only the code repo (a real misjudgment seen in
1379
- * a dev-repo session, 2026-06-23).
1397
+ * the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart puts
1398
+ * hot.md/session-state content in the model's context, and CwdChanged surfaces
1399
+ * the same content as a terminal notification the model does not see, but
1400
+ * neither carries the vault's absolute path, so the AI re-discovers it each
1401
+ * session and can wrongly conclude a wiki file is missing after checking only
1402
+ * the code repo (a real misjudgment seen in a dev-repo session, 2026-06-23).
1380
1403
  *
1381
1404
  * Returns a one-line "look in the vault, not here" orientation carrying the
1382
1405
  * absolute vault path, or '' when cwd is anywhere inside the vault tree (the
@@ -1439,6 +1462,953 @@ function pickByCwd(hypoDir, slugs, cwd) {
1439
1462
  });
1440
1463
  }
1441
1464
 
1465
+ // ── root hot.md projection ──────────────────────────────────────────────────
1466
+ // Root hot.md stops being a file Claude edits and becomes a deterministic
1467
+ // projection of `projects/<slug>/hot.md`: whoever reads it (resolveActiveProject,
1468
+ // closeCandidateSlugs, SessionStart's injection) gets a table built fresh off
1469
+ // disk, never a hand-maintained one that can drop a row or go stale. The row
1470
+ // set comes ONLY from a directory scan, never from parsing the previous root
1471
+ // file: that old parse (hypo-hot-rebuild.mjs's retired parsePointerRows) is
1472
+ // exactly what let a manually deleted row stay deleted forever.
1473
+ //
1474
+ // Three functions, split on purpose: scanRootHotProjectionSources does the
1475
+ // one directory walk and reports what it saw, INCLUDING whether the walk
1476
+ // itself failed; formatRootHotProjection turns scanned rows into the
1477
+ // canonical markdown, pure and disk-free; renderRootHotProjection composes
1478
+ // the two into the same pure string generator every existing caller already
1479
+ // expects, so a scan error there still resolves to "no rows" exactly like
1480
+ // before (out of scope for this pure entry point: see writeRootHotProjection
1481
+ // for the one caller that DOES act on scanError). writeRootHotProjection is
1482
+ // the only thing that ever touches disk, skipping the write entirely when the
1483
+ // projection is byte-identical to what is already there (so a no-op run
1484
+ // never dirties the file's mtime or trips a git diff), and refusing to write
1485
+ // at all when the scan itself failed (a transient read error must not commit
1486
+ // an empty/partial table over real content).
1487
+
1488
+ const ROOT_HOT_PROJECTS_TEMPLATE_SLUG = '_template';
1489
+
1490
+ // This exact sentence has been part of every generated root hot.md since the
1491
+ // projection shipped (separately pinned by 'generated body text is pinned' in
1492
+ // tests/session-hooks.test.mjs), so its presence on disk is proof the file is
1493
+ // already projection-owned and safe to overwrite without a backup. Its
1494
+ // absence means a hand-authored file, or one from before this projection
1495
+ // existed. See writeRootHotProjection's migration-backup step below.
1496
+ const ROOT_HOT_PROJECTION_MARKER = 'generated projection of `projects/*/hot.md`';
1497
+
1498
+ // Sits next to hot.md itself so a person who opens the vault root finds it
1499
+ // without hunting: `hot.md.pre-projection-backup.md`. Written every time
1500
+ // writeRootHotProjection sees content it does not own (readRootHotProjectionOwnership's
1501
+ // hash comparison, not a one-shot marker check), and there is no cap: each
1502
+ // unowned write that finds the plain suffix already taken gets its own
1503
+ // numbered path from nextRootHotBackupPath, without limit.
1504
+ export const ROOT_HOT_BACKUP_SUFFIX = '.pre-projection-backup.md';
1505
+
1506
+ // Root-anchored (leading `/`) so this only ever matches the backup sitting
1507
+ // next to hot.md itself, never an unrelated file elsewhere in the vault that
1508
+ // happens to share the tail of the name. The trailing `*` covers every
1509
+ // variant nextRootHotBackupPath can produce: the plain suffix, a `-2`/`-3`/...
1510
+ // counter, and the timestamp fallback.
1511
+ const ROOT_HOT_BACKUP_GITIGNORE_PATTERN = '/hot.md.pre-projection-backup*.md';
1512
+
1513
+ // minor fix: atomicWrite's own temp file for a backup write
1514
+ // (`<backupPath>.<pid>.<random>.tmp`, see atomic-write.mjs) carries the exact
1515
+ // same hand-authored bytes the backup above protects, but the pattern above
1516
+ // only matches the FINAL `.md` name. A kill or power loss between that temp
1517
+ // write and its rename leaves the temp sitting unignored: no cleanup runs
1518
+ // on the next start, and nothing reads or removes it, so `git add -A`
1519
+ // exposes it. The glob's `*` matches the `.md` in the middle too (gitignore
1520
+ // globs don't stop at `.`, only at `/`), so this one pattern covers the temp
1521
+ // name for every backupPath variant nextRootHotBackupPath can produce.
1522
+ const ROOT_HOT_BACKUP_TMP_GITIGNORE_PATTERN = '/hot.md.pre-projection-backup*.tmp';
1523
+
1524
+ /**
1525
+ * The one recovery sentence for a hand-authored root hot.md that just got
1526
+ * backed up before a projection write replaced it (major fix, codex 3rd-tier
1527
+ * finding 6). Naming the backup file alone is not a recovery path: copying
1528
+ * it back onto root hot.md just gets it replaced again on the next rebuild.
1529
+ * Three writers can trigger this backup: SessionStart's own direct write,
1530
+ * the Stop-hook rebuild (hypo-hot-rebuild.mjs), and project-create's own
1531
+ * `writeRootHotProjection` call. Before this fix only SessionStart's
1532
+ * own write site carried the guidance half of the message; the Stop-hook's
1533
+ * health notice and project-create's warnings named the filename alone,
1534
+ * with no route back to the content. One formatter, used by all three, so
1535
+ * they cannot drift into three different wordings of the same advice (and
1536
+ * so a future fourth call site gets the full message by construction,
1537
+ * not by remembering to copy it). Matches the longer version already
1538
+ * carried in both READMEs; do not change the wording without updating those
1539
+ * too.
1540
+ * @param {string} backupPath absolute path to the migration backup
1541
+ * @returns {string}
1542
+ */
1543
+ export function rootHotBackupRecoveryNotice(backupPath) {
1544
+ return (
1545
+ `루트 hot.md 의 이전 내용을 ${basename(backupPath)} 로 백업했습니다 (손으로 편집한 내용이 있었습니다). ` +
1546
+ `루트로 되돌리지 마세요. 다음 훅 실행이 다시 덮어씁니다. 열어 본 뒤 남길 내용을 projects/<slug>/hot.md 나 별도 페이지로 옮기고 백업은 지우세요.`
1547
+ );
1548
+ }
1549
+
1550
+ const ROOT_HOT_PROJECTION_STATE_FILENAME = 'root-hot-projection-state.json';
1551
+
1552
+ function sha256Hex(content) {
1553
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
1554
+ }
1555
+
1556
+ /**
1557
+ * Durable "did WE write the bytes currently on disk" state, read from
1558
+ * `.cache/`. Replaces a plain `ROOT_HOT_PROJECTION_MARKER` substring check: a
1559
+ * hand-authored hot.md that happens to quote that exact sentence (copied out
1560
+ * of documentation, for instance) used to read as "already ours" under the
1561
+ * substring check and skip the backup step outright, destroying real content
1562
+ * with nothing left to recover it from. A hash of the exact bytes this
1563
+ * function itself last wrote cannot collide by accident the way a fixed
1564
+ * sentence can. Missing or corrupt state resolves to "no prior hash": the
1565
+ * safe direction, since the only cost is one extra (harmless, since it is
1566
+ * never lost) backup on the next write, never a silently skipped one.
1567
+ * @returns {{lastHash: string|null}}
1568
+ */
1569
+ function readRootHotProjectionOwnership(hypoDir) {
1570
+ const path = join(hypoDir, '.cache', ROOT_HOT_PROJECTION_STATE_FILENAME);
1571
+ if (!existsSync(path)) return { lastHash: null };
1572
+ try {
1573
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
1574
+ return { lastHash: typeof parsed?.lastHash === 'string' ? parsed.lastHash : null };
1575
+ } catch {
1576
+ return { lastHash: null };
1577
+ }
1578
+ }
1579
+
1580
+ /** Best-effort: a failure here only costs one extra backup on the NEXT
1581
+ * write (ownership falls back to "no prior hash"), never a lost one. */
1582
+ function writeRootHotProjectionOwnership(hypoDir, hash) {
1583
+ try {
1584
+ atomicWrite(
1585
+ join(hypoDir, '.cache', ROOT_HOT_PROJECTION_STATE_FILENAME),
1586
+ JSON.stringify({ lastHash: hash }),
1587
+ );
1588
+ } catch {
1589
+ // see doc comment above
1590
+ }
1591
+ }
1592
+
1593
+ // n1 fix (3rd-round codex review): a per-SESSION companion to the global
1594
+ // ownership state above. The global hash only proves "SOME session's write
1595
+ // is what is on disk right now": any session's writeRootHotProjection call
1596
+ // updates it, including one that runs AFTER this session claimed 'hot.md'
1597
+ // but BEFORE this session's own Stop. A vault has exactly one root hot.md,
1598
+ // so two sessions across DIFFERENT projects race it constantly (measured
1599
+ // against 349 real sessions: 2081 overlapping pairs). Without this,
1600
+ // hypo-auto-commit.mjs's rootHotProjectionIsCurrent check read "disk matches
1601
+ // the last write" as true and staged a SIBLING session's bytes into this
1602
+ // session's commit, content this session never produced. Each session's own
1603
+ // receipt records the exact digest ITS OWN write last produced, so the
1604
+ // commit-time check below can require BOTH the global hash and this
1605
+ // session's own receipt to agree with the current disk bytes.
1606
+ const ROOT_HOT_PROJECTION_RECEIPT_FILENAME = 'root-hot-projection-receipt.json';
1607
+
1608
+ function rootHotProjectionReceiptPath(hypoDir, sessionId) {
1609
+ return join(sessionCacheDir(hypoDir, sessionId), ROOT_HOT_PROJECTION_RECEIPT_FILENAME);
1610
+ }
1611
+
1612
+ /**
1613
+ * Read the digest THIS session's own write last recorded. Missing, corrupt,
1614
+ * or no session_id all resolve to `null`: the safe direction, since the
1615
+ * only cost of a false `null` is this session's legitimate write sitting
1616
+ * uncommitted for one extra Stop (see rootHotProjectionIsCurrent below),
1617
+ * never a wrongly staged write.
1618
+ * @returns {{digest: string|null}}
1619
+ */
1620
+ function readRootHotProjectionReceipt(hypoDir, sessionId) {
1621
+ if (!sessionId) return { digest: null };
1622
+ const path = rootHotProjectionReceiptPath(hypoDir, sessionId);
1623
+ if (!existsSync(path)) return { digest: null };
1624
+ try {
1625
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
1626
+ return { digest: typeof parsed?.digest === 'string' ? parsed.digest : null };
1627
+ } catch {
1628
+ return { digest: null };
1629
+ }
1630
+ }
1631
+
1632
+ /**
1633
+ * Best-effort, same shape as writeRootHotProjectionOwnership: a failure here
1634
+ * only costs THIS session's own write not being recognized as current at ITS
1635
+ * OWN Stop (a missing/stale receipt reads as "not proven mine" and excludes
1636
+ * hot.md from this round's commit), never a lost write. The bytes are
1637
+ * already durable on disk either way, and the next SessionStart/Stop that
1638
+ * runs a write re-establishes both records. No-op without a session_id:
1639
+ * there is no per-session file to write one to.
1640
+ */
1641
+ function writeRootHotProjectionReceipt(hypoDir, sessionId, digest) {
1642
+ if (!sessionId) return;
1643
+ try {
1644
+ atomicWrite(rootHotProjectionReceiptPath(hypoDir, sessionId), JSON.stringify({ digest }));
1645
+ } catch {
1646
+ // see doc comment above
1647
+ }
1648
+ }
1649
+
1650
+ /** Does the CURRENT on-disk hot.md match the bytes this function itself last
1651
+ * wrote? See readRootHotProjectionOwnership for why this replaced a
1652
+ * substring check on ROOT_HOT_PROJECTION_MARKER. Exported: hypo-auto-commit.mjs's
1653
+ * scoped-commit wrapper re-runs this at commit time (see
1654
+ * rootHotProjectionIsCurrent below) rather than trusting a session's
1655
+ * touched-paths claim alone. */
1656
+ export function ownershipMatches(hypoDir, current) {
1657
+ const { lastHash } = readRootHotProjectionOwnership(hypoDir);
1658
+ return !!lastHash && sha256Hex(current) === lastHash;
1659
+ }
1660
+
1661
+ /**
1662
+ * Whether the bytes CURRENTLY on disk at the vault's root hot.md are exactly
1663
+ * what writeRootHotProjection itself last wrote or confirmed. This is the
1664
+ * commit-time half of the blocker fix in r5-w1.md: 'hot.md' can sit in a
1665
+ * session's touched-paths set without this session's own write having
1666
+ * actually landed there (a scanError that never clears the pre-write claim,
1667
+ * a lock-timeout on the post-write record, or simply a claim made before a
1668
+ * human's own edit lands mid-session): the claim only ever proves intent,
1669
+ * never the bytes on disk at commit time. hypo-auto-commit.mjs's scoped-commit
1670
+ * wrapper calls this immediately before staging, so a stale claim over
1671
+ * content this session did not actually write is excluded there no matter
1672
+ * how or why it went stale, instead of enumerating every write-outcome
1673
+ * branch and remembering to revoke the claim on each one. A staged content
1674
+ * this rejects is not lost: the next SessionStart or Stop that manages a
1675
+ * successful scan runs writeRootHotProjection again, which backs up
1676
+ * non-owned content through this SAME check before overwriting it, exactly
1677
+ * like any other external edit.
1678
+ *
1679
+ * n1 fix (3rd-round codex review): the global ownership check alone only
1680
+ * proves SOME session's write produced the current bytes, never that THIS
1681
+ * session's did: a sibling session's write between this session's claim and
1682
+ * its own Stop updates the global hash to the SIBLING's digest, and the old
1683
+ * global-only check read that as "current" and staged the sibling's bytes
1684
+ * into this session's commit. `sessionId` is now required to also prove
1685
+ * OWNERSHIP: this session's own receipt (see writeRootHotProjectionReceipt)
1686
+ * must independently match the current disk digest, not just the global
1687
+ * record every session's write shares.
1688
+ * @param {string} hypoDir
1689
+ * @param {string|null|undefined} sessionId the session about to stage hot.md
1690
+ * @returns {boolean} true when there is no root hot.md at all (nothing to
1691
+ * protect against) or its bytes hash-match both the global ownership hash
1692
+ * AND this session's own last-recorded receipt; false when it exists, is
1693
+ * readable, and either does not match, is unreadable, or `sessionId` never
1694
+ * recorded a matching receipt, none of which can prove THIS session
1695
+ * produced what is on disk right now.
1696
+ */
1697
+ export function rootHotProjectionIsCurrent(hypoDir, sessionId) {
1698
+ const hotPath = join(hypoDir, 'hot.md');
1699
+ if (!existsSync(hotPath)) return true;
1700
+ let content;
1701
+ try {
1702
+ content = readFileSync(hotPath, 'utf-8');
1703
+ } catch {
1704
+ return false; // unreadable: cannot prove it is ours, so do not stage it
1705
+ }
1706
+ if (!ownershipMatches(hypoDir, content)) return false;
1707
+ const { digest } = readRootHotProjectionReceipt(hypoDir, sessionId);
1708
+ return !!digest && digest === sha256Hex(content);
1709
+ }
1710
+
1711
+ /**
1712
+ * First unused backup path for `hotPath`: the plain suffix if free, else a
1713
+ * numbered variant. Before this, a SECOND external overwrite arriving after
1714
+ * the first migration backup already existed was silently destroyed --
1715
+ * writeRootHotProjection saw the fixed backup filename already taken, skipped
1716
+ * the backup step entirely, and overwrote hot.md anyway. Every later
1717
+ * non-owned overwrite now gets its own place to land instead.
1718
+ */
1719
+ function nextRootHotBackupPath(hotPath) {
1720
+ const plain = `${hotPath}${ROOT_HOT_BACKUP_SUFFIX}`;
1721
+ if (!existsSync(plain)) return plain;
1722
+ const stem = ROOT_HOT_BACKUP_SUFFIX.slice(0, -'.md'.length); // '.pre-projection-backup'
1723
+ for (let n = 2; n <= 9999; n++) {
1724
+ const candidate = `${hotPath}${stem}-${n}.md`;
1725
+ if (!existsSync(candidate)) return candidate;
1726
+ }
1727
+ // Practically unreachable (9998 prior collisions), but never reuse a path
1728
+ // silently: a millisecond timestamp is guaranteed distinct from every
1729
+ // numbered candidate already checked above.
1730
+ return `${hotPath}${stem}-${Date.now()}.md`;
1731
+ }
1732
+
1733
+ /**
1734
+ * Back `content` up next to `hotPath`, unless a backup already holds exactly
1735
+ * these bytes.
1736
+ *
1737
+ * minor fix: the backup and the `.gitignore` update run BEFORE the write that
1738
+ * replaces hot.md, so a write that fails afterwards (disk full, a rename
1739
+ * error) leaves the original in place and the next run sees the same
1740
+ * non-owned file again. Without the content comparison that run made a
1741
+ * SECOND, numbered copy of bytes already sitting in the first one, and a
1742
+ * loop that keeps failing kept minting them. Comparing what is already there
1743
+ * makes the retry converge instead: one backup per distinct content, however
1744
+ * many times the write after it fails.
1745
+ *
1746
+ * Reuses nextRootHotBackupPath's candidate order, so the paths checked here
1747
+ * are exactly the ones a fresh backup could occupy, and an unreadable
1748
+ * candidate counts as "not a match" (the safe direction: one extra copy, not
1749
+ * a skipped one).
1750
+ * `testHooks.beforeBackupWrite` is test-only: called with the fresh backup
1751
+ * path right before its write, so a test can fail the backup alone while the
1752
+ * projection write after it would still succeed. Failing the whole directory
1753
+ * cannot tell "the backup ran and failed" from "the backup never ran", since
1754
+ * the projection write fails there too.
1755
+ * @returns {string} the backup path now holding `content`, fresh or reused
1756
+ */
1757
+ function backUpOnce(hotPath, content, testHooks) {
1758
+ const stem = ROOT_HOT_BACKUP_SUFFIX.slice(0, -'.md'.length);
1759
+ const holdsContent = (candidate) => {
1760
+ try {
1761
+ return readFileSync(candidate, 'utf-8') === content;
1762
+ } catch {
1763
+ return false; // unreadable: cannot prove it already holds these bytes
1764
+ }
1765
+ };
1766
+ const plain = `${hotPath}${ROOT_HOT_BACKUP_SUFFIX}`;
1767
+ // The plain name is always taken first, so its absence means no backup
1768
+ // exists yet and the numbered scan below has nothing to find.
1769
+ if (existsSync(plain)) {
1770
+ if (holdsContent(plain)) return plain;
1771
+ for (let n = 2; n <= 9999; n++) {
1772
+ const candidate = `${hotPath}${stem}-${n}.md`;
1773
+ if (!existsSync(candidate)) break;
1774
+ if (holdsContent(candidate)) return candidate;
1775
+ }
1776
+ }
1777
+ const fresh = nextRootHotBackupPath(hotPath);
1778
+ testHooks?.beforeBackupWrite?.(fresh);
1779
+ atomicWrite(fresh, content);
1780
+ return fresh;
1781
+ }
1782
+
1783
+ /**
1784
+ * Append `pattern` to the vault's own `.gitignore` when it is not already
1785
+ * there. NOT best-effort: called right before the FIRST backup write of a
1786
+ * migration (see writeRootHotProjection), and a failure here propagates like
1787
+ * the backup write's own failure does: a backup file git can see and
1788
+ * auto-commit is not a backup, it is a leak, so the write that would create
1789
+ * one must never proceed once this step has been skipped by a thrown error.
1790
+ * @returns {boolean} true when the file was actually changed
1791
+ */
1792
+ function ensureVaultGitignorePattern(hypoDir, pattern) {
1793
+ const path = join(hypoDir, '.gitignore');
1794
+ const content = existsSync(path) ? readFileSync(path, 'utf-8') : '';
1795
+ if (content.split('\n').some((l) => l.trim() === pattern)) return false;
1796
+ const next =
1797
+ content.length > 0 && !content.endsWith('\n')
1798
+ ? `${content}\n${pattern}\n`
1799
+ : `${content}${pattern}\n`;
1800
+ atomicWrite(path, next);
1801
+ return true;
1802
+ }
1803
+
1804
+ /**
1805
+ * Scan `projects/*\/hot.md` and report both the rows and whether the scan
1806
+ * itself succeeded. Rows come only from directories that carry a `hot.md`: a
1807
+ * project without one yet gets no row, since the table's link column would
1808
+ * otherwise point at a file that does not exist (a broken wikilink under
1809
+ * lint). `_template` is never a row.
1810
+ *
1811
+ * `projects/` itself not existing is NOT a scan error: a fresh vault has no
1812
+ * projects dir yet, and an empty row set is the honest answer for that case.
1813
+ * `scanError` is reserved for `projects/` existing but `readdirSync` failing
1814
+ * (permission error, transient I/O): the one case that used to collapse into
1815
+ * the same empty array as "no projects yet," letting a transient read failure
1816
+ * render (and, through writeRootHotProjection, WRITE) an empty table over a
1817
+ * populated one. A per-project `hot.md` that exists but fails to read is
1818
+ * narrower: the directory walk itself succeeded, so the row is kept with a
1819
+ * blank date (same as before) plus a warning instead of a silently blanked
1820
+ * date.
1821
+ *
1822
+ * Per row: display name is always the slug, never that project's frontmatter
1823
+ * `title`: a project's `title` is that page's own heading text (things like
1824
+ * `hot: hypomnema` or `security-backoffice: Hot Cache`), not a name a human
1825
+ * chose to read in this table's Project column. An earlier version of this
1826
+ * function read `title` here on the mistaken belief that it would agree with
1827
+ * the slug; measured against a real vault it rewrote the Project column on 15
1828
+ * of 38 rows for no actual information gain. Date is that project's
1829
+ * frontmatter `updated`, left BLANK (never defaulted to today) when absent,
1830
+ * unreadable, OR not a well-formed `YYYY-MM-DD` string: a malformed value
1831
+ * (`stale`, `2026-9-1`, a YAML-parsed non-string) is left blank rather than
1832
+ * passed through, because it would otherwise flow unvalidated into a raw
1833
+ * string compare (`r.date > max` below) where a token like `"stale"` sorts
1834
+ * ahead of every real ISO date, and into the root file's own frontmatter
1835
+ * `updated:` (formatRootHotProjection takes the max of every row's date),
1836
+ * leaving a token like `stale` sitting in that field for as long as this
1837
+ * project's row stays malformed. Root hot.md's `updated:` is the max of the
1838
+ * project rows, not the date the file itself was last rewritten. That is why
1839
+ * sessionCloseFileStatus no longer checks this field against today's date:
1840
+ * a sibling project's later row would otherwise fail a close that did
1841
+ * everything right. The test 'n1: root hot.md must not be reported stale by a
1842
+ * sibling project whose row date is the max' pins that exemption.
1843
+ * A malformed value surfacing to a human at all is left to
1844
+ * `hypo:lint`'s wave-3 project-file checks, out of this wave's scope; this
1845
+ * function's job is only to keep the bad token from leaking into ANYTHING it
1846
+ * generates. Rows sort by date
1847
+ * descending, blank dates last, ties broken by slug ascending, so two
1848
+ * machines scanning the same tree produce byte-identical output.
1849
+ * @param {string} hypoDir
1850
+ * @returns {{rows: Array<{slug: string, date: string}>, scanError: boolean, warnings: string[]}}
1851
+ */
1852
+ export function scanRootHotProjectionSources(hypoDir) {
1853
+ const projectsDir = join(hypoDir, 'projects');
1854
+ const rows = [];
1855
+ const warnings = [];
1856
+ let scanError = false;
1857
+ if (existsSync(projectsDir)) {
1858
+ let entries = [];
1859
+ try {
1860
+ entries = readdirSync(projectsDir);
1861
+ } catch (err) {
1862
+ scanError = true;
1863
+ warnings.push(`projects/ 디렉터리를 읽지 못했습니다: ${err?.message ?? String(err)}`);
1864
+ entries = [];
1865
+ }
1866
+ for (const slug of entries) {
1867
+ if (slug === ROOT_HOT_PROJECTS_TEMPLATE_SLUG) continue;
1868
+ const hotPath = join(projectsDir, slug, 'hot.md');
1869
+ // Read directly instead of gating on existsSync first: existsSync folds
1870
+ // EVERY stat failure (a permission error, not just "genuinely absent")
1871
+ // into `false`, which used to delete this project's row from the table
1872
+ // exactly like a real ENOENT would, with no warning at all. Only ENOENT
1873
+ // (and ENOTDIR, a parent segment turning out not to be a directory)
1874
+ // means "no hot.md here yet". Every other error (EACCES, EIO, ...)
1875
+ // keeps the row with a blank date and a warning, same as a read failure
1876
+ // that happens after a successful existsSync always did.
1877
+ let content = '';
1878
+ let present = true;
1879
+ try {
1880
+ content = readFileSync(hotPath, 'utf-8');
1881
+ } catch (err) {
1882
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') {
1883
+ present = false;
1884
+ } else {
1885
+ warnings.push(
1886
+ `projects/${slug}/hot.md 를 읽지 못해 날짜를 비웠습니다: ${err?.message ?? String(err)}`,
1887
+ );
1888
+ content = '';
1889
+ }
1890
+ }
1891
+ if (!present) continue;
1892
+ const rawDate = parseFrontmatterField(content, 'updated');
1893
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(rawDate || '') ? rawDate : '';
1894
+ rows.push({ slug, date });
1895
+ }
1896
+ }
1897
+ return { rows: sortRootHotRows(rows), scanError, warnings };
1898
+ }
1899
+
1900
+ /**
1901
+ * Sort scanned rows date descending, blank dates last, ties broken by slug
1902
+ * ascending, so two directory scans of the same tree agree on row order
1903
+ * regardless of what order the filesystem itself handed the entries back in.
1904
+ * Mutates and returns `rows`.
1905
+ *
1906
+ * The slug tie-break is defence in depth, and a mutation test could not pin
1907
+ * it while this lived inline inside the scan loop above: on this machine
1908
+ * (macOS, APFS), readdirSync returns entries already sorted even when the
1909
+ * filesystem itself does not (measured 2026-09-17: same directory, raw order
1910
+ * "bb zz aa mm", readdirSync order "aa bb mm zz"), so a real directory scan
1911
+ * on this machine always hands this function slug-ascending input, and a
1912
+ * stable sort keeps that order with or without the tie-break. That ordering
1913
+ * is not guaranteed elsewhere: a filesystem like ext4 with `dir_index`
1914
+ * enabled returns entries in hash order, not name order, so the tie-break
1915
+ * is load-bearing there even though it never fires on this machine's tests.
1916
+ * Extracted into its own function so a test can
1917
+ * call it directly against a fixed adversarial input order instead of going
1918
+ * through a live scan. The date comparator's two other axes were always
1919
+ * reachable through a scan (flipping descending to ascending, or swapping the
1920
+ * blank-date placement, each reddens `row order` regardless of input order).
1921
+ * @param {Array<{slug: string, date: string}>} rows
1922
+ * @returns {Array<{slug: string, date: string}>}
1923
+ */
1924
+ export function sortRootHotRows(rows) {
1925
+ return rows.sort((a, b) => {
1926
+ if (a.date !== b.date) {
1927
+ if (!a.date) return 1;
1928
+ if (!b.date) return -1;
1929
+ return b.date.localeCompare(a.date);
1930
+ }
1931
+ return a.slug.localeCompare(b.slug);
1932
+ });
1933
+ }
1934
+
1935
+ /**
1936
+ * Format already-scanned rows into the canonical root hot.md markdown. Pure:
1937
+ * never touches disk. Frontmatter `updated:` is the max row date (or '' when
1938
+ * every row is dateless), never today: a today value would make the same
1939
+ * tree produce different bytes on different days, which is the one property
1940
+ * this projection exists to have. That also means this field is not the date
1941
+ * the file itself was last rewritten, which is why the session-close gate
1942
+ * exempts it. See the note where sessionCloseFileStatus stops short of
1943
+ * checking the root file, and the test that pins that exemption.
1944
+ *
1945
+ * Exported for one reason beyond `renderRootHotProjection`: `scripts/init.mjs`
1946
+ * writes `formatRootHotProjection([])` straight into a brand-new vault as its
1947
+ * root hot.md. There is no template copy to drift from it any more. If init
1948
+ * wrote anything else, the first SessionStart would see bytes it did not
1949
+ * write, back them up, and tell the person their hand edits were preserved,
1950
+ * in a vault nobody has edited yet. tests/init.test.mjs pins that init's
1951
+ * output is exactly this function's.
1952
+ * @param {Array<{slug: string, date: string}>} rows
1953
+ * @returns {string}
1954
+ */
1955
+ export function formatRootHotProjection(rows) {
1956
+ const tableRows = rows
1957
+ .map((r) => `| ${r.slug} | ${r.date} | [[projects/${r.slug}/hot]] |`)
1958
+ .join('\n');
1959
+ const updated = rows.reduce((max, r) => (r.date && r.date > max ? r.date : max), '');
1960
+
1961
+ return `---
1962
+ title: "Hot Cache: Pointer"
1963
+ type: reference
1964
+ updated: ${updated}
1965
+ tags: [wiki, operations]
1966
+ ---
1967
+
1968
+ # Hot Cache
1969
+
1970
+ > Read at session start → navigate to the relevant project session-state.md and hot.md.
1971
+ > This "Active Projects" table is a ${ROOT_HOT_PROJECTION_MARKER}, rebuilt at every session start and stop. Update session-state.md and hot.md in the relevant project instead: a hand edit to this table is overwritten by the next session.
1972
+
1973
+ ## Active Projects
1974
+
1975
+ | Project | Last Session | Hot Cache |
1976
+ |---|---|---|
1977
+ ${tableRows}
1978
+
1979
+ ## Session Start Checklist
1980
+
1981
+ 1. Check this file for the relevant project link
1982
+ 2. Read \`projects/<name>/session-state.md\` for next tasks if it exists
1983
+ 3. Read \`projects/<name>/hot.md\` for project background
1984
+ `;
1985
+ }
1986
+
1987
+ /**
1988
+ * Build the canonical root hot.md content by scanning `projects/*\/hot.md`.
1989
+ * Pure: reads disk, never writes, and stays a plain string return so every
1990
+ * existing caller/test keeps working unchanged. A scan error (see
1991
+ * scanRootHotProjectionSources) resolves to an empty row set here, same as
1992
+ * before this split: this entry point has no way to signal "the scan
1993
+ * failed" to a caller that only wants a string. writeRootHotProjection below
1994
+ * is the one caller that needs that signal and gets it directly from
1995
+ * scanRootHotProjectionSources instead of through this function.
1996
+ * @param {string} hypoDir
1997
+ * @returns {string}
1998
+ */
1999
+ export function renderRootHotProjection(hypoDir) {
2000
+ const { rows } = scanRootHotProjectionSources(hypoDir);
2001
+ return formatRootHotProjection(rows);
2002
+ }
2003
+
2004
+ /**
2005
+ * Regenerate root hot.md and write it only when the bytes actually changed.
2006
+ *
2007
+ * The lock-free body. Every caller goes through writeRootHotProjection below,
2008
+ * which holds the vault commit lock around this whole function; see there for
2009
+ * why the lock has to cover the read as well as the write.
2010
+ *
2011
+ * Called from both hypo-session-start.mjs (SessionStart, before the file is
2012
+ * read by resolveActiveProject/closeCandidateSlugs/the injection below) and
2013
+ * hypo-hot-rebuild.mjs (Stop). Both hooks calling the same function is the
2014
+ * point: there is exactly one generator, not two copies that can drift.
2015
+ *
2016
+ * A scan error (projects/ directory exists but cannot be read) skips the
2017
+ * write entirely and leaves whatever is already on disk untouched: see
2018
+ * scanRootHotProjectionSources.
2019
+ *
2020
+ * The FIRST time this overwrites a file whose bytes do not match what this
2021
+ * function itself last wrote (a hand-authored hot.md, one written before
2022
+ * this projection existed, or ANY later external overwrite, per
2023
+ * ownershipMatches), the existing bytes are backed up next to it
2024
+ * (nextRootHotBackupPath: the plain `hot.md` + ROOT_HOT_BACKUP_SUFFIX name if
2025
+ * free, else a numbered variant, so a second external overwrite after the
2026
+ * first migration backup still lands somewhere instead of being silently
2027
+ * destroyed) before the projection is written. Immediately before that FIRST
2028
+ * backup write, the vault's own `.gitignore` is made to cover the backup
2029
+ * filename pattern if it does not already (ensureVaultGitignorePattern): a
2030
+ * backup file that a scoped auto-commit can see and push is a leak of
2031
+ * whatever secret a person's uncommitted manual notes held, not a backup.
2032
+ * Neither the gitignore update nor the backup write is best-effort: a
2033
+ * failure in either throws out of this function (both call sites already
2034
+ * wrap this in try/catch), which aborts the write, so a person's unsaved
2035
+ * content is never replaced without somewhere ignored-and-recoverable to
2036
+ * find it. Every write after the first sees the ownership hash already
2037
+ * match and skips this step, and an existing backup file is never
2038
+ * overwritten by a later one.
2039
+ * major fix (TOCTOU): the ownership check above reads `hotPath` once, then
2040
+ * this function still has to run ensureVaultGitignorePattern and the backup
2041
+ * write (both real I/O against OTHER files) before it ever gets to the
2042
+ * rename that actually replaces `hotPath`. A human's own save can land in
2043
+ * that gap: it would be read as "ours" by the stale `current` and vanish
2044
+ * with no backup, since atomic rename only ever protects against a reader
2045
+ * seeing a torn write, never against this compare-then-replace race. This
2046
+ * function re-reads `hotPath` a second time, immediately before the
2047
+ * replacing write, and folds ANY bytes that appeared in that gap into the
2048
+ * backup too. That shrinks the race to the two lines directly around the
2049
+ * final `atomicWrite` call below (a read and a rename with nothing else in
2050
+ * between), which is as far as this can be narrowed without an advisory
2051
+ * lock that a person's own editor or shell would not honor anyway (see the
2052
+ * major finding in r5-w1.md for why that is not pursued here).
2053
+ * @param {string} hypoDir
2054
+ * @param {{ beforeFinalWrite?: (hotPath: string) => void, beforeBackupWrite?: (backupPath: string) => void }} [testHooks]
2055
+ * `beforeBackupWrite` is test-only too: see backUpOnce.
2056
+ * `beforeFinalWrite` is test-only: called with `hotPath` right after the
2057
+ * second read below and before the final `atomicWrite`, so a test can
2058
+ * write NEW bytes to `hotPath` at exactly the point a real concurrent
2059
+ * writer would need to land in to be missed: genuine two-process timing
2060
+ * inside one synchronous call cannot be reproduced deterministically
2061
+ * otherwise. Production callers never pass this.
2062
+ * @param {string|null} [sessionId] n1 fix: when given, this call's own
2063
+ * digest is recorded as THIS session's receipt (see
2064
+ * writeRootHotProjectionReceipt), so rootHotProjectionIsCurrent can later
2065
+ * tell this session's own write apart from a sibling session's. Omitted by
2066
+ * every caller that does not need commit-time attribution (most existing
2067
+ * tests, a direct `--json` CLI run).
2068
+ * @returns {{written: boolean, scanError: boolean, lockTimeout: boolean, backedUp: boolean, backupPath: string|null, gitignoreUpdated: boolean, warnings: string[], content: string|null}}
2069
+ * content is the bytes now on disk (whether this call wrote them or they
2070
+ * were already there), null only on a scanError. Callers that need to hash
2071
+ * what actually landed (hypo-hot-rebuild.mjs's advanceBase) use this
2072
+ * instead of a second, separate scan that could observe different bytes if
2073
+ * a sibling session writes a project's hot.md in between the two scans.
2074
+ * `lockTimeout` is true only on the wrapper's own refusal (see
2075
+ * writeRootHotProjection below): nothing was read, written, or recorded.
2076
+ */
2077
+ function writeRootHotProjectionUnlocked(hypoDir, testHooks, sessionId) {
2078
+ const { rows, scanError, warnings } = scanRootHotProjectionSources(hypoDir);
2079
+ if (scanError) {
2080
+ return {
2081
+ written: false,
2082
+ scanError: true,
2083
+ lockTimeout: false,
2084
+ backedUp: false,
2085
+ backupPath: null,
2086
+ gitignoreUpdated: false,
2087
+ warnings,
2088
+ content: null,
2089
+ };
2090
+ }
2091
+ const projection = formatRootHotProjection(rows);
2092
+ const hotPath = join(hypoDir, 'hot.md');
2093
+ // BLOCKER fix: only a genuinely absent file (ENOENT) may be treated as "no
2094
+ // prior content to protect". Any other read failure (permissions,
2095
+ // transient I/O) used to fold into the same `null` and skip the backup
2096
+ // step below outright, reproducing the exact data-loss the backup exists
2097
+ // to prevent. Throwing here (rather than returning a sentinel) reuses the
2098
+ // try/catch both call sites already wrap this function in.
2099
+ let current = null;
2100
+ try {
2101
+ current = readFileSync(hotPath, 'utf-8');
2102
+ } catch (err) {
2103
+ if (err?.code !== 'ENOENT') throw err;
2104
+ current = null;
2105
+ }
2106
+ if (current === projection) {
2107
+ // Establish/refresh ownership even on a no-op write: a vault upgrading
2108
+ // onto this fix has a genuinely-owned hot.md but no prior recorded hash
2109
+ // yet, and this is the cheap place to record one without risking a
2110
+ // spurious backup on the NEXT real write. Best-effort by design (see
2111
+ // writeRootHotProjectionOwnership): a failure here just costs one
2112
+ // extra harmless backup later, never a lost one.
2113
+ writeRootHotProjectionOwnership(hypoDir, sha256Hex(projection));
2114
+ // n1 fix: a no-op still confirms this session's OWN projection algorithm
2115
+ // produced exactly what is on disk right now, so it is legitimate for
2116
+ // this session to also record a receipt for it: the bytes are not
2117
+ // borrowed from whichever session wrote them first, they are identical
2118
+ // regardless of who computed them.
2119
+ writeRootHotProjectionReceipt(hypoDir, sessionId, sha256Hex(projection));
2120
+ return {
2121
+ written: false,
2122
+ scanError: false,
2123
+ lockTimeout: false,
2124
+ backedUp: false,
2125
+ backupPath: null,
2126
+ gitignoreUpdated: false,
2127
+ warnings,
2128
+ content: projection,
2129
+ };
2130
+ }
2131
+ let backedUp = false;
2132
+ let backupPath = null;
2133
+ let gitignoreUpdated = false;
2134
+ const ensureBackupIgnored = () => {
2135
+ // Both patterns, not best-effort either: see the doc comment above --
2136
+ // the second covers atomicWrite's own temp file for the backup write
2137
+ // itself (minor fix, r5-w1.md), which would otherwise carry the same
2138
+ // hand-authored bytes unignored if this process is killed between that
2139
+ // temp write and its rename.
2140
+ const a = ensureVaultGitignorePattern(hypoDir, ROOT_HOT_BACKUP_GITIGNORE_PATTERN);
2141
+ const b = ensureVaultGitignorePattern(hypoDir, ROOT_HOT_BACKUP_TMP_GITIGNORE_PATTERN);
2142
+ return a || b;
2143
+ };
2144
+ if (current !== null && !ownershipMatches(hypoDir, current)) {
2145
+ gitignoreUpdated = ensureBackupIgnored();
2146
+ backupPath = backUpOnce(hotPath, current, testHooks);
2147
+ backedUp = true;
2148
+ }
2149
+ // major fix (TOCTOU narrowing): re-read right before the write that
2150
+ // actually replaces `hotPath`, rather than trusting the `current` read
2151
+ // from above (see this function's own doc comment for why). Anything
2152
+ // that differs from `current` gets its own backup here too, so a save
2153
+ // landing during ensureBackupIgnored/the backup write above is still
2154
+ // caught instead of silently discarded by the rename below. `testHooks`
2155
+ // fires BEFORE this read (a no-op in production) so a test can write new
2156
+ // bytes at exactly the point this read needs to observe them; in
2157
+ // production nothing runs between `current`'s read above and this one.
2158
+ //
2159
+ // What this re-read is NOT: a safety boundary. It is best-effort salvage,
2160
+ // and calling it "a race narrowed to microseconds" overstates it. Work
2161
+ // still happens after this read: the `.gitignore` update and the backup
2162
+ // write below both run against other files, and atomicWrite itself writes a
2163
+ // whole temp file before the rename. A person's save landing anywhere in
2164
+ // there is still replaced with no copy kept, and an editor that saves in
2165
+ // place (truncate, then write) can leave a partial file for this read to
2166
+ // back up, so even a salvaged backup is not guaranteed to hold a complete
2167
+ // document. Nothing here can close that: POSIX rename has no general
2168
+ // compare-and-swap ("replace only if the bytes are still X"), and an
2169
+ // editor honors no advisory lock this file could take. The real fix is to
2170
+ // stop sharing one pathname between a generated file and a hand-edited
2171
+ // one, which this change does not do.
2172
+ testHooks?.beforeFinalWrite?.(hotPath);
2173
+ let latest = current;
2174
+ try {
2175
+ latest = readFileSync(hotPath, 'utf-8');
2176
+ } catch (err) {
2177
+ if (err?.code !== 'ENOENT') throw err;
2178
+ latest = null;
2179
+ }
2180
+ if (latest !== current && latest !== null && !ownershipMatches(hypoDir, latest)) {
2181
+ if (!gitignoreUpdated) gitignoreUpdated = ensureBackupIgnored();
2182
+ backupPath = backUpOnce(hotPath, latest, testHooks);
2183
+ backedUp = true;
2184
+ }
2185
+ // atomicWrite, not writeFileSync: this write now happens at BOTH
2186
+ // SessionStart and Stop (previously Stop-only), so two sessions starting
2187
+ // around the same time can race this write against each other. A plain
2188
+ // writeFileSync truncates before writing its new bytes, so a reader
2189
+ // (another SessionStart's read of hot.md, or hypo-file-watch, if it ever
2190
+ // gains a trigger) could observe a torn/empty file mid-write; atomicWrite
2191
+ // (temp file + rename) never exposes that state. log.md's append path
2192
+ // (deriveRootLogEntries below) already uses atomicWrite for the same
2193
+ // reason.
2194
+ atomicWrite(hotPath, projection);
2195
+ writeRootHotProjectionOwnership(hypoDir, sha256Hex(projection));
2196
+ writeRootHotProjectionReceipt(hypoDir, sessionId, sha256Hex(projection)); // n1 fix
2197
+ return {
2198
+ written: true,
2199
+ scanError: false,
2200
+ lockTimeout: false,
2201
+ backedUp,
2202
+ backupPath,
2203
+ gitignoreUpdated,
2204
+ warnings,
2205
+ content: projection,
2206
+ };
2207
+ }
2208
+
2209
+ /**
2210
+ * The locked entry point every caller uses. Holds the vault commit lock
2211
+ * (vaultCommitLockTarget, the same one hypo-auto-commit.mjs and
2212
+ * crystallize.mjs's apply take around stage+commit) across the WHOLE
2213
+ * projection write: the first read of hot.md, the ownership check, the
2214
+ * backup, the replacing rename, and the ownership/receipt records.
2215
+ *
2216
+ * BLOCKER fix: without this the writer took no lock at all, so a sibling
2217
+ * session's rename could land between hypo-auto-commit.mjs's ownership and
2218
+ * receipt check and the `git add` that follows it, inside the auto-commit's
2219
+ * own lock hold. The sibling's bytes then went into THIS session's commit
2220
+ * looking like a normal update, and the touched record that would have
2221
+ * flagged it was gone. One lock shared by the writer and the commit fence is
2222
+ * what makes that check mean anything at commit time. The receipt check
2223
+ * stays regardless: it is the fail-closed half for everything this lock
2224
+ * cannot cover (a lock we failed to take, a person's editor, another tool).
2225
+ *
2226
+ * The lock is NOT reentrant, so nothing inside `writeRootHotProjectionUnlocked`
2227
+ * may take it again, and a caller that already holds it must call the
2228
+ * unlocked form instead, as claimAndWriteRootHotProjection (below) does.
2229
+ * hypo-session-start.mjs takes it for its own `git pull` and releases it
2230
+ * before calling claimAndWriteRootHotProjection,
2231
+ * hypo-hot-rebuild.mjs (Stop) runs before hypo-auto-commit.mjs rather than
2232
+ * inside it, and scripts/lib/project-create.mjs holds no lock.
2233
+ *
2234
+ * A lock timeout is NOT an error here: it returns `lockTimeout: true` with
2235
+ * `written: false` and a warning row, so the caller can say the pointer table
2236
+ * was left as-is and move on. Throwing would turn a busy vault into a failed
2237
+ * `project-create`, and waiting longer would eat a SessionStart's 30s budget
2238
+ * for a file the session can still read the previous version of.
2239
+ * @param {string} hypoDir
2240
+ * @param {{ beforeFinalWrite?: (hotPath: string) => void, beforeBackupWrite?: (backupPath: string) => void }} [testHooks]
2241
+ * @param {string|null} [sessionId]
2242
+ * @returns {{written: boolean, scanError: boolean, lockTimeout: boolean, backedUp: boolean, backupPath: string|null, gitignoreUpdated: boolean, warnings: string[], content: string|null}}
2243
+ */
2244
+ export function writeRootHotProjection(hypoDir, testHooks, sessionId) {
2245
+ try {
2246
+ return withFileLock(
2247
+ vaultCommitLockTarget(hypoDir),
2248
+ () => writeRootHotProjectionUnlocked(hypoDir, testHooks, sessionId),
2249
+ // Read per call, not at module load, so a test can force a fast
2250
+ // timeout on a lock it holds itself (same env var hypo-auto-commit.mjs
2251
+ // reads for the same lock).
2252
+ { timeoutMs: Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000 },
2253
+ );
2254
+ } catch (err) {
2255
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
2256
+ return rootHotLockTimeoutResult();
2257
+ }
2258
+ }
2259
+
2260
+ function rootHotLockTimeoutResult() {
2261
+ return {
2262
+ written: false,
2263
+ scanError: false,
2264
+ lockTimeout: true,
2265
+ backedUp: false,
2266
+ backupPath: null,
2267
+ gitignoreUpdated: false,
2268
+ warnings: [
2269
+ 'root hot.md: 다른 세션이 저장소 잠금을 쥐고 있어 이번에는 갱신하지 않았습니다 (이전 내용을 그대로 둡니다).',
2270
+ ],
2271
+ content: null,
2272
+ };
2273
+ }
2274
+
2275
+ /**
2276
+ * SessionStart's root hot.md write: claim 'hot.md' in this session's
2277
+ * touched-paths set and write the projection inside ONE hold of the vault
2278
+ * lock. Lock order is vault, then per-session (the claim), the same order
2279
+ * hypo-auto-commit.mjs takes them in.
2280
+ *
2281
+ * The claim used to be taken before the lock. A Stop for the same session_id
2282
+ * (the same session run by two processes) could take the vault lock in that
2283
+ * gap, find hot.md claimed but still clean, commit nothing, and retire the
2284
+ * claim. The write that followed then left hot.md dirty with no claim, so the
2285
+ * checkpoint gate read it as ownerless and let the close through. Inside the
2286
+ * lock, a Stop either runs entirely before the claim or waits until the write
2287
+ * has landed.
2288
+ *
2289
+ * Kept from the unlocked sequence: a failed claim skips the write
2290
+ * (`claimFailed: true`, `result: null`), and a write that throws drops the
2291
+ * claim only when this call is the one that added it, before the lock is
2292
+ * released, then rethrows. A vault lock timeout now returns before any claim
2293
+ * is made, so there is nothing to drop.
2294
+ *
2295
+ * `testHooks.afterClaim` runs right after a successful claim, still inside
2296
+ * the lock (a no-op in production); the rest of `testHooks` goes to the
2297
+ * writer as with writeRootHotProjection.
2298
+ *
2299
+ * @param {string} hypoDir
2300
+ * @param {string|null|undefined} sessionId
2301
+ * @param {{ afterClaim?: () => void, beforeFinalWrite?: (hotPath: string) => void, beforeBackupWrite?: (backupPath: string) => void }} [testHooks]
2302
+ * @returns {{claimFailed: boolean, result: ReturnType<typeof writeRootHotProjection>|null}}
2303
+ */
2304
+ export function claimAndWriteRootHotProjection(hypoDir, sessionId, testHooks) {
2305
+ // Checked before the lock: without a session_id the claim can only fail,
2306
+ // and a busy vault must not turn that into a lock-timeout notice instead.
2307
+ if (!sessionId) return { claimFailed: true, result: null };
2308
+ try {
2309
+ return withFileLock(
2310
+ vaultCommitLockTarget(hypoDir),
2311
+ () => {
2312
+ const claim = claimProjectionWrite(hypoDir, sessionId, ['hot.md']);
2313
+ if (!claim.ok) return { claimFailed: true, result: null };
2314
+ testHooks?.afterClaim?.();
2315
+ try {
2316
+ return {
2317
+ claimFailed: false,
2318
+ result: writeRootHotProjectionUnlocked(hypoDir, testHooks, sessionId),
2319
+ };
2320
+ } catch (err) {
2321
+ // No write happened, so a claim this call added would let whatever
2322
+ // another session or a person writes to hot.md next be committed
2323
+ // as this session's change. An earlier invocation's claim on the
2324
+ // same (resumed) session_id is left alone.
2325
+ if (claim.added) clearTouchedPaths(hypoDir, sessionId, ['hot.md']);
2326
+ throw err;
2327
+ }
2328
+ },
2329
+ { timeoutMs: Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000 },
2330
+ );
2331
+ } catch (err) {
2332
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
2333
+ return { claimFailed: false, result: rootHotLockTimeoutResult() };
2334
+ }
2335
+ }
2336
+
2337
+ const ROOT_HOT_HEALTH_NOTICE_FILENAME = 'root-hot-projection-notice.json';
2338
+
2339
+ /**
2340
+ * Leave a durable note for the NEXT SessionStart when the Stop-hook rebuild
2341
+ * (hypo-hot-rebuild.mjs) fails or hits a scan error: Stop's own stdout is
2342
+ * suppressed (`{ suppressOutput: true }`), so a failure there is otherwise
2343
+ * invisible to the user until they go looking at stderr. Best-effort like
2344
+ * every other cache write in this file: a hook must not fail Stop over a
2345
+ * notice file.
2346
+ * @param {string} hypoDir
2347
+ * @param {string} message
2348
+ */
2349
+ export function writeRootHotHealthNotice(hypoDir, message) {
2350
+ const path = join(hypoDir, '.cache', ROOT_HOT_HEALTH_NOTICE_FILENAME);
2351
+ try {
2352
+ // Same lock consumeRootHotHealthNotice takes, so this write can never
2353
+ // land inside a concurrent reader's read-then-unlink window (which would
2354
+ // otherwise let this brand-new notice be deleted by a reader that read
2355
+ // the OLD one, or read half-written bytes from atomicWrite's rename).
2356
+ withFileLock(path, () => {
2357
+ atomicWrite(path, JSON.stringify({ message, ts: Date.now() }));
2358
+ });
2359
+ } catch {
2360
+ // best-effort: losing the notice is strictly better than crashing Stop
2361
+ // over it.
2362
+ }
2363
+ }
2364
+
2365
+ /**
2366
+ * Read and delete a pending root-hot-projection health notice left by a
2367
+ * prior Stop. One-shot (unlinked on read) so it surfaces exactly once, at
2368
+ * the next SessionStart. Returns null when nothing is pending or the file is
2369
+ * unreadable/corrupt.
2370
+ *
2371
+ * Guarded by the same per-target file lock every other read-then-mutate in
2372
+ * this file uses (withFileLock), so two SessionStarts racing this call
2373
+ * (a shared vault, two machines) cannot both read the same notice and both
2374
+ * display it: only the lock holder sees a non-null message, the other finds
2375
+ * the file already gone. This does not make delivery exactly-once across a
2376
+ * crash (a process that dies between the unlink and actually showing the
2377
+ * message still loses it, same as before this lock existed). Only the
2378
+ * concurrent double-read is closed here; a true crash-safe exactly-once
2379
+ * would need a durable ack, which this file does not keep.
2380
+ * @param {string} hypoDir
2381
+ * @returns {string|null}
2382
+ */
2383
+ export function consumeRootHotHealthNotice(hypoDir) {
2384
+ const path = join(hypoDir, '.cache', ROOT_HOT_HEALTH_NOTICE_FILENAME);
2385
+ if (!existsSync(path)) return null;
2386
+ try {
2387
+ return withFileLock(path, () => {
2388
+ if (!existsSync(path)) return null; // a concurrent reader already claimed it
2389
+ let message = null;
2390
+ try {
2391
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
2392
+ message = parsed && typeof parsed.message === 'string' ? parsed.message : null;
2393
+ } catch {
2394
+ message = null;
2395
+ }
2396
+ try {
2397
+ unlinkSync(path);
2398
+ } catch {
2399
+ // best-effort cleanup: a leftover file just gets read (and re-shown)
2400
+ // again next time, rather than losing anything.
2401
+ }
2402
+ return message;
2403
+ });
2404
+ } catch {
2405
+ // lock-timeout: leave the file exactly as-is, same as every other
2406
+ // lock-timeout path in this file: the notice just waits for the next
2407
+ // reader instead of being lost.
2408
+ return null;
2409
+ }
2410
+ }
2411
+
1442
2412
  /**
1443
2413
  * Resolve the active project slug from root hot.md. With a cwd, a project whose
1444
2414
  * working_dir contains it wins (cwd-first); otherwise the
@@ -1461,8 +2431,9 @@ export function resolveActiveProject(hypoDir, cwd = null) {
1461
2431
  if (!existsSync(hotPath)) return null;
1462
2432
  let content;
1463
2433
  try {
1464
- // Strip HTML comments before parsing so the canonical-format example row
1465
- // in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
2434
+ // Strip HTML comments before parsing so a commented example row, which
2435
+ // vaults created before the root table became generated still carry
2436
+ // (`<!-- Row format: ... -->`), is not picked up as data.
1466
2437
  content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
1467
2438
  } catch {
1468
2439
  return null;
@@ -1537,6 +2508,7 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
1537
2508
  dates,
1538
2509
  stale: [],
1539
2510
  missing: ['hot.md (no active project in pointer table)'],
2511
+ sessionLogEvidence: null,
1540
2512
  };
1541
2513
  }
1542
2514
 
@@ -1561,13 +2533,26 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
1561
2533
 
1562
2534
  checkUpdated(join('projects', project, 'session-state.md'));
1563
2535
  checkUpdated(join('projects', project, 'hot.md'));
1564
- checkUpdated('hot.md');
2536
+ // Root hot.md is deliberately not checked here. Its `updated:` is a max
2537
+ // across every project row (see formatRootHotProjection), not this project's
2538
+ // own date, so a sibling project's later row would push the field past today
2539
+ // and stale-flag a session that did everything right. The pointer table is a
2540
+ // projection the hooks regenerate; it is not a file this close writes.
2541
+ // Pinned by 'n1: root hot.md must not be reported stale by a sibling project
2542
+ // whose row date is the max' in tests/session-hooks.test.mjs.
1565
2543
 
1566
2544
  // session-log: daily shard, with legacy monthly fallback: must
1567
2545
  // carry a today-dated heading in whichever file holds it. Daily-first read
1568
2546
  // order short-circuits on the small shard. When no match is found, the gap is
1569
2547
  // reported under the canonical daily shard for the local date (dates[0]).
1570
2548
  let sessionLogOk = false;
2549
+ // The candidate that actually satisfied freshness, captured so a caller
2550
+ // that must certify a SPECIFIC committed version (a close receipt) knows
2551
+ // which of the (up to) four local/UTC × daily/monthly candidates is the
2552
+ // one this check actually trusted, rather than re-deriving (and possibly
2553
+ // picking a DIFFERENT one, since `closeFileTargetsForProject` recomputes
2554
+ // both dates independently) which file to prove.
2555
+ let sessionLogEvidence = null;
1571
2556
  for (const date of dates) {
1572
2557
  for (const rel of sessionLogReadCandidates(project, date)) {
1573
2558
  const full = join(hypoDir, rel);
@@ -1580,6 +2565,7 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
1580
2565
  }
1581
2566
  if (hasSessionLogHeading(content, date)) {
1582
2567
  sessionLogOk = true;
2568
+ sessionLogEvidence = { path: rel, date };
1583
2569
  break;
1584
2570
  }
1585
2571
  }
@@ -1605,7 +2591,14 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
1605
2591
  if (content && !logFresh) stale.push('log.md');
1606
2592
  }
1607
2593
 
1608
- return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
2594
+ return {
2595
+ ok: stale.length === 0 && missing.length === 0,
2596
+ project,
2597
+ dates,
2598
+ stale,
2599
+ missing,
2600
+ sessionLogEvidence,
2601
+ };
1609
2602
  }
1610
2603
 
1611
2604
  // ── global session-close gate ────────────────
@@ -1652,6 +2645,17 @@ function closeCandidateSlugs(hypoDir, dates) {
1652
2645
  try {
1653
2646
  entries = readdirSync(projectsDir);
1654
2647
  } catch {
2648
+ // Swallowing this is the same shape as the fail-open hasTornCloseIntent
2649
+ // was hardened out of (a judgment that could not be made reading as
2650
+ // "nothing found"), and it is deliberate only because this is one of
2651
+ // THREE unioned sources, not the whole answer: an unreadable projects/
2652
+ // still leaves today's log.md entries and today's root hot.md rows to
2653
+ // name the same slug. A close goes unnoticed only when all three miss it
2654
+ // at once. Signalling the uncertainty instead would mean carrying a
2655
+ // scanError out through sessionCloseGlobalStatus, whose result is read
2656
+ // in five places, so it is a separate change rather than a line here.
2657
+ // If you are adding a FOURTH source or removing one of the other two,
2658
+ // this comment stops being true and the gate needs the scanError.
1655
2659
  entries = [];
1656
2660
  }
1657
2661
  for (const p of entries) {
@@ -1704,10 +2708,12 @@ function closeCandidateSlugs(hypoDir, dates) {
1704
2708
  // the "next tasks" section (a cross-block incident: editing one project's
1705
2709
  // tracker bumped session-state and blocked an unrelated project's /compact).
1706
2710
  // - project hot.md `updated:` — project-create stamps the template `updated: today`.
1707
- // - root hot.md ROW date — project-create inserts a today-dated row, and
1708
- // hypo-hot-rebuild defaults a row to today when a project hot.md is missing.
1709
- // (Root hot.md *frontmatter* was already never a signal — it is shared and
1710
- // hypo-hot-rebuild stamps it today every session.)
2711
+ // - root hot.md ROW date: project-create inserts a today-dated row. Root
2712
+ // hot.md's row set is now a directory-scan projection (renderRootHotProjection):
2713
+ // a row's date is that project's own hot.md `updated:`, left BLANK rather than
2714
+ // defaulted to today when absent or malformed.
2715
+ // (Root hot.md *frontmatter* was already never a signal: it is shared, and it is
2716
+ // now the MAX row date across the projection, never today.)
1711
2717
  //
1712
2718
  // Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
1713
2719
  // project files, then the session-log + log entry. A process crash before the
@@ -1945,16 +2951,10 @@ function sleepSync(ms) {
1945
2951
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms | 0));
1946
2952
  }
1947
2953
 
1948
- // Commit `content` via temp write + rename so a partial/failed write lands on a
1949
- // throwaway temp and the target is never torn (mirrors crystallize.mjs's
1950
- // atomicWrite). Rename atomicity swaps the directory entry; it is NOT power-loss
1951
- // durable (no fsync) — same as everything else in the vault.
1952
- function atomicWriteShared(path, content) {
1953
- mkdirSync(dirname(path), { recursive: true });
1954
- const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
1955
- writeFileSync(tmp, content);
1956
- renameSync(tmp, path);
1957
- }
2954
+ // Commit via temp write + rename now lives in ./atomic-write.mjs, imported
2955
+ // above as `atomicWrite`. It used to be a local copy here (`atomicWriteShared`)
2956
+ // that only cleaned up the tmp file on a rename failure; the shared version
2957
+ // also cleans up on a write failure. See that file's own doc comment.
1958
2958
 
1959
2959
  // Read the pid the current holder recorded in its lockfile (see withFileLock).
1960
2960
  // Returns null for anything we can't trust as a pid: empty/missing content (a
@@ -2264,7 +3264,7 @@ export function deriveRootLogEntries(hypoDir) {
2264
3264
  });
2265
3265
  if (fresh.length === 0) return 0;
2266
3266
  const sep = current.endsWith('\n') ? '\n' : '\n\n';
2267
- atomicWriteShared(logPath, current + sep + fresh.map((a) => a.block).join('\n\n') + '\n');
3267
+ atomicWrite(logPath, current + sep + fresh.map((a) => a.block).join('\n\n') + '\n');
2268
3268
  return fresh.length;
2269
3269
  });
2270
3270
  } catch {
@@ -2349,71 +3349,228 @@ export function appendSyncFailure(hypoDir, op, error) {
2349
3349
  }
2350
3350
  }
2351
3351
 
3352
+ const syncGit = (hypoDir, ...args) =>
3353
+ spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
3354
+
2352
3355
  /**
2353
- * Pull + push the wiki against its remote, guaranteeing the working tree is
2354
- * never left half-merged. Called by the auto-commit Stop hook after a local
2355
- * commit succeeds.
3356
+ * The tree-mutating half of a sync: `git pull --no-rebase`, plus the abort
3357
+ * that keeps the working tree from being left half-merged.
2356
3358
  *
2357
- * Failure policy (v1.4 "sync hardening"):
2358
- * - clean fast-forward / conflict-free merge → push.
3359
+ * Split out of syncRemote (review r5-w4 major 2) so a caller can hold the
3360
+ * vault commit lock across this and NOT across the push. This half has to
3361
+ * stay inside the lock: it rewrites files another session could be reading,
3362
+ * staging, or committing at the same moment. See hypo-auto-commit.mjs for the
3363
+ * other side of that split.
3364
+ *
3365
+ * Failure policy (v1.4 "sync hardening"), unchanged by the split:
3366
+ * - clean fast-forward / conflict-free merge → `{pulled: true}`, caller pushes.
2359
3367
  * - MERGE CONFLICT (`git pull --no-rebase` leaves unmerged paths): abort the
2360
3368
  * merge so the tree returns to the just-committed local state ("ours"),
2361
- * record op='conflict', and do NOT push (a diverged branch cannot
2362
- * fast-forward, so the push would only add a noisy second failure). No data
2363
- * is lost: ours stays committed locally, "theirs" stays on the remote, and
2364
- * the divergence is surfaced by session-start + doctor until the user merges
2365
- * manually. Inline auto-resolution (preserving the losing version as a
2366
- * `.conflict-*` sibling) is deferred.
3369
+ * record op='conflict', and report `{conflict: true}` so the caller does
3370
+ * NOT push (a diverged branch cannot fast-forward, so the push would only
3371
+ * add a noisy second failure). No data is lost: ours stays committed
3372
+ * locally, "theirs" stays on the remote, and the divergence is surfaced by
3373
+ * session-start + doctor until the user merges manually. Inline
3374
+ * auto-resolution (preserving the losing version as a `.conflict-*`
3375
+ * sibling) is deferred.
2367
3376
  * - non-conflict pull failure (network/auth: no unmerged paths) → record
2368
- * op='pull', then still attempt push (a transient pull blip should not block
2369
- * an otherwise-pushable commit); record op='push' if that also fails.
3377
+ * op='pull' and report `{pulled: false, conflict: false}`; the caller still
3378
+ * pushes, since a transient pull blip should not block an otherwise
3379
+ * pushable commit.
2370
3380
  *
2371
- * Best-effort: never throws — a sync failure must not break the Stop hook.
3381
+ * Best-effort: never throws. A sync failure must not break the Stop hook. A
3382
+ * throw is reported as `{pulled: false, conflict: false}`, which lets the
3383
+ * caller attempt the push, exactly as the combined function did.
2372
3384
  *
2373
3385
  * @param {string} hypoDir
2374
- * @returns {{pulled: boolean, pushed: boolean, conflict: boolean}}
3386
+ * @returns {{pulled: boolean, conflict: boolean}}
2375
3387
  */
2376
- export function syncRemote(hypoDir) {
2377
- const git = (...args) =>
2378
- spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
2379
- const result = { pulled: false, pushed: false, conflict: false };
3388
+ export function pullRemote(hypoDir) {
3389
+ const git = (...args) => syncGit(hypoDir, ...args);
2380
3390
  try {
2381
3391
  const pull = git('pull', '--no-rebase', '-q');
2382
3392
  if (pull.status === 0) {
2383
- result.pulled = true;
2384
3393
  recordSyncSuccess(hypoDir, 'pull');
2385
- } else {
2386
- // A merge conflict leaves unmerged index entries; a network/auth failure
2387
- // leaves none. Only the former must be aborted to keep the tree clean.
2388
- const unmerged = git('ls-files', '-u');
2389
- const hasConflict = unmerged.status === 0 && (unmerged.stdout || '').trim().length > 0;
2390
- if (hasConflict) {
2391
- // Abort to return the tree to the just-committed local state. Verify the
2392
- // abort actually cleaned up: if it fails (filesystem/concurrent-mutation
2393
- // edge), the tree may still be half-merged, so record that distinctly
2394
- // ('conflict-unresolved') rather than masking it as a clean abort.
2395
- const abort = git('merge', '--abort');
2396
- const stillUnmerged = git('ls-files', '-u');
2397
- const aborted = abort.status === 0 && (stillUnmerged.stdout || '').trim().length === 0;
2398
- appendSyncFailure(
2399
- hypoDir,
2400
- aborted ? 'conflict' : 'conflict-unresolved',
2401
- pull.stderr || pull.stdout,
2402
- );
2403
- result.conflict = true;
2404
- return result; // do not push from a diverged branch
2405
- }
2406
- appendSyncFailure(hypoDir, 'pull', pull.stderr || pull.stdout);
3394
+ return { pulled: true, conflict: false };
2407
3395
  }
2408
- const push = git('push');
3396
+ // A merge conflict leaves unmerged index entries; a network/auth failure
3397
+ // leaves none. Only the former must be aborted to keep the tree clean.
3398
+ const unmerged = git('ls-files', '-u');
3399
+ const hasConflict = unmerged.status === 0 && (unmerged.stdout || '').trim().length > 0;
3400
+ if (hasConflict) {
3401
+ // Abort to return the tree to the just-committed local state. Verify the
3402
+ // abort actually cleaned up: if it fails (filesystem/concurrent-mutation
3403
+ // edge), the tree may still be half-merged, so record that distinctly
3404
+ // ('conflict-unresolved') rather than masking it as a clean abort.
3405
+ const abort = git('merge', '--abort');
3406
+ const stillUnmerged = git('ls-files', '-u');
3407
+ const aborted = abort.status === 0 && (stillUnmerged.stdout || '').trim().length === 0;
3408
+ appendSyncFailure(
3409
+ hypoDir,
3410
+ aborted ? 'conflict' : 'conflict-unresolved',
3411
+ pull.stderr || pull.stdout,
3412
+ );
3413
+ return { pulled: false, conflict: true };
3414
+ }
3415
+ appendSyncFailure(hypoDir, 'pull', pull.stderr || pull.stdout);
3416
+ return { pulled: false, conflict: false };
3417
+ } catch {
3418
+ // best-effort: never break the Stop hook
3419
+ return { pulled: false, conflict: false };
3420
+ }
3421
+ }
3422
+
3423
+ /**
3424
+ * Pin WHAT the push will send and WHERE, so the push itself carries no
3425
+ * ambiguity about either.
3426
+ *
3427
+ * Meant to be called from inside the vault commit lock, right after the
3428
+ * commit and the pull that this session performed. A bare `git push` resolves
3429
+ * its source at push time, from whatever HEAD is then: release the lock, let a
3430
+ * sibling session commit, and the push sends the SIBLING's newer commit rather
3431
+ * than the one this session just made and verified. Reading the OID here, still
3432
+ * under the lock, makes that impossible: the push sends this exact object or it
3433
+ * sends nothing.
3434
+ *
3435
+ * `oid` defaults to HEAD, which is correct for the Stop hook's use even though
3436
+ * the commit it made has its own sha: a non-conflicting `git pull --no-rebase`
3437
+ * can add a merge commit on top, and pushing the pre-merge sha would be
3438
+ * rejected as a non-fast-forward. HEAD under the lock, after the pull,
3439
+ * contains this session's commit and nothing a sibling added afterwards.
3440
+ *
3441
+ * Destination comes from the current branch's configured upstream, read as a
3442
+ * full `refs/heads/<name>` so no `push.default` mode, no `remote.<n>.push`
3443
+ * refspec, and no branch-name-to-remote-name guess gets to reinterpret it.
3444
+ * Returns null when the branch is detached or has no upstream: a null target
3445
+ * makes pushRemote decline to push at all, which is the safe end of the trade.
3446
+ * Pushing SOMETHING under an unresolvable destination is how a vault ends up
3447
+ * with a branch nobody meant to publish.
3448
+ *
3449
+ * Best-effort: never throws.
3450
+ *
3451
+ * @param {string} hypoDir
3452
+ * @param {string} [oid] the exact commit to send; defaults to current HEAD
3453
+ * @returns {{remote: string, remoteRef: string, oid: string}|null}
3454
+ */
3455
+ export function resolvePushTarget(hypoDir, oid) {
3456
+ try {
3457
+ const branch = syncGit(hypoDir, 'symbolic-ref', '--quiet', '--short', 'HEAD');
3458
+ if (branch.status !== 0) return null; // detached HEAD: no branch, no upstream
3459
+ const name = (branch.stdout || '').trim();
3460
+ if (!name) return null;
3461
+ // for-each-ref, not `rev-parse @{upstream}`: this yields the remote NAME and
3462
+ // the remote-side ref SEPARATELY and already fully qualified
3463
+ // (`refs/heads/main`), where `@{upstream}` yields one `origin/main` string a
3464
+ // caller has to split on the first slash and then guess whether the rest is
3465
+ // a branch (it is not, for a remote whose name contains a slash, or a ref
3466
+ // outside refs/heads).
3467
+ const up = syncGit(
3468
+ hypoDir,
3469
+ 'for-each-ref',
3470
+ '--format=%(upstream:remotename)%09%(upstream:remoteref)',
3471
+ `refs/heads/${name}`,
3472
+ );
3473
+ if (up.status !== 0) return null;
3474
+ const [remote, remoteRef] = (up.stdout || '').trim().split('\t');
3475
+ if (!remote || !remoteRef) return null; // no upstream configured
3476
+ const head = syncGit(hypoDir, 'rev-parse', oid || 'HEAD');
3477
+ if (head.status !== 0) return null;
3478
+ const resolved = (head.stdout || '').trim();
3479
+ if (!/^[0-9a-f]{40}$/.test(resolved)) return null;
3480
+ return { remote, remoteRef, oid: resolved };
3481
+ } catch {
3482
+ return null;
3483
+ }
3484
+ }
3485
+
3486
+ /**
3487
+ * The network half of a sync: push one pinned commit to one pinned
3488
+ * destination.
3489
+ *
3490
+ * Runs OUTSIDE the vault commit lock (review r5-w4 major 2). Holding the lock
3491
+ * across a network round trip made one session's Stop own the vault for up to
3492
+ * a minute, and a sibling SessionStart needs that same lock twice (its own
3493
+ * `git pull` and the root hot.md projection write), each with a 5s timeout,
3494
+ * out of a 30s hook budget.
3495
+ *
3496
+ * The earlier justification for putting the push outside the lock was that
3497
+ * a push changes nothing locally. Half of that is closed here and half is not:
3498
+ *
3499
+ * - CLOSED: "a push sends whatever HEAD is at push time". It sends `target`
3500
+ * and only `target`, decided under the lock by resolvePushTarget.
3501
+ * - OPEN, and it stays open: a client-side `pre-push` hook is an arbitrary
3502
+ * program that git runs inside this `git push`, with the working tree and
3503
+ * the index right there. A user whose pre-push builds, formats, or stages
3504
+ * anything is mutating the vault from outside the lock, racing the
3505
+ * projection write and the commit fence exactly as a pull would. This is
3506
+ * not theoretical: tests/session-hooks.test.mjs observes the lock boundary
3507
+ * THROUGH a pre-push hook, so the mechanism is exercised on every run.
3508
+ * `--no-verify` would close it and is deliberately not used: it overrides
3509
+ * the user's own push policy, which is not this hook's call to make, and it
3510
+ * would blind the one observation point the boundary has. Closing it for
3511
+ * real means either pushing from a scratch clone or taking the lock back
3512
+ * across the network, both bigger than this fix.
3513
+ *
3514
+ * Callers must not call this after a pullRemote that reported `conflict`:
3515
+ * pushing from a diverged branch only records a second, redundant failure.
3516
+ *
3517
+ * Best-effort: never throws.
3518
+ *
3519
+ * @param {string} hypoDir
3520
+ * @param {{remote: string, remoteRef: string, oid: string}|null} target
3521
+ * @returns {{pushed: boolean}}
3522
+ */
3523
+ export function pushRemote(hypoDir, target) {
3524
+ try {
3525
+ if (!target || !target.remote || !target.remoteRef || !target.oid) {
3526
+ // No destination this function is willing to name. Not pushing is the
3527
+ // conservative outcome and it is recorded like any other push failure, so
3528
+ // session-start and doctor surface it instead of letting a vault look
3529
+ // synced while it silently never pushes again.
3530
+ appendSyncFailure(
3531
+ hypoDir,
3532
+ 'push',
3533
+ 'push destination could not be resolved (detached HEAD, or the current branch has no upstream); nothing was pushed',
3534
+ );
3535
+ return { pushed: false };
3536
+ }
3537
+ const push = syncGit(hypoDir, 'push', target.remote, `${target.oid}:${target.remoteRef}`);
2409
3538
  if (push.status === 0) {
2410
- result.pushed = true;
2411
3539
  recordSyncSuccess(hypoDir, 'push');
2412
- } else appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
3540
+ return { pushed: true };
3541
+ }
3542
+ appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
3543
+ return { pushed: false };
2413
3544
  } catch {
2414
- // best-effort — never break the Stop hook
3545
+ // best-effort: never break the Stop hook
3546
+ return { pushed: false };
2415
3547
  }
2416
- return result;
3548
+ }
3549
+
3550
+ /**
3551
+ * Pull + push the wiki against its remote, guaranteeing the working tree is
3552
+ * never left half-merged.
3553
+ *
3554
+ * The two halves in their original order, for every caller that does not hold
3555
+ * the vault commit lock and so has no reason to care where the boundary is.
3556
+ * hypo-auto-commit.mjs does hold it and calls pullRemote/pushRemote itself;
3557
+ * see there. The ordering guarantee lives here as well as there: a conflicting
3558
+ * pull returns before the push, so a diverged branch is never pushed from.
3559
+ *
3560
+ * Best-effort: never throws. A sync failure must not break the Stop hook.
3561
+ *
3562
+ * @param {string} hypoDir
3563
+ * @returns {{pulled: boolean, pushed: boolean, conflict: boolean}}
3564
+ */
3565
+ export function syncRemote(hypoDir) {
3566
+ const { pulled, conflict } = pullRemote(hypoDir);
3567
+ if (conflict) return { pulled: false, pushed: false, conflict: true };
3568
+ // Resolved here rather than left to the push: the target is what makes the
3569
+ // push unambiguous, and a caller of this combined form has no other place to
3570
+ // decide it. HEAD right after the pull, which is what a bare `git push` would
3571
+ // have sent anyway, minus the window between this line and the push itself.
3572
+ const { pushed } = pushRemote(hypoDir, resolvePushTarget(hypoDir));
3573
+ return { pulled, pushed, conflict: false };
2417
3574
  }
2418
3575
 
2419
3576
  // ── touched-paths (scope the auto-commit to session-touched paths) ───────────
@@ -2435,7 +3592,12 @@ export function syncRemote(hypoDir) {
2435
3592
  // No session_id → never accumulate, and never fall back to a shared "default"
2436
3593
  // bucket: a path recorded under the wrong key could leak between sessions.
2437
3594
 
2438
- /** Directory holding one session's cache artifacts, incl. touched-paths.json. */
3595
+ /** Directory holding one session's cache artifacts: touched-paths.json and,
3596
+ * since the n1 fix above, root-hot-projection-receipt.json. Neither is
3597
+ * cleaned up when a session ends normally or dies mid-session, same as
3598
+ * every other file already living here before this fix. An abandoned
3599
+ * receipt from a crashed session is inert: nothing ever reads it again,
3600
+ * since no other session shares that same session_id to look it up under. */
2439
3601
  function sessionCacheDir(hypoDir, sessionId) {
2440
3602
  return join(hypoDir, '.cache', 'sessions', sanitizeSessionId(sessionId));
2441
3603
  }
@@ -2470,10 +3632,20 @@ function readTouchedPathsFile(path) {
2470
3632
  }
2471
3633
 
2472
3634
  /**
2473
- * Accumulate vault-relative touched paths for `sessionId`. Best-effort,
2474
- * dedup-on-insert, JSON array (not delimiter-joined — survives non-ASCII and
2475
- * any byte a filename can legally hold). No-op without a session_id: never
2476
- * accumulate into a shared bucket.
3635
+ * Accumulate vault-relative touched paths for `sessionId`. Best-effort in the
3636
+ * sense that it never throws, but no longer silent about it: it now reports
3637
+ * whether the accumulation actually landed, because a caller that treats a
3638
+ * write as accounted-for the moment it calls this (root hot.md's projection
3639
+ * writers, see hypo-session-start.mjs / hypo-hot-rebuild.mjs) needs to know
3640
+ * when that assumption was wrong. A lock-timeout or write failure used to be
3641
+ * indistinguishable from success: the write would go through, this would
3642
+ * swallow the error, and the file would sit dirty with nothing in ANY
3643
+ * session's touched-paths set to bring it into an auto-commit, orphaned
3644
+ * until some unrelated write happens to touch the same path again. Dedup-on-
3645
+ * insert, JSON array (not delimiter-joined, survives non-ASCII and any byte
3646
+ * a filename can legally hold). No-op (and reports true, nothing to fail)
3647
+ * without a session_id or with an empty path list: never accumulate into a
3648
+ * shared bucket, and an empty call isn't a failure.
2477
3649
  *
2478
3650
  * Read-merge-write is guarded by the per-session file lock (the SAME lock
2479
3651
  * `drainTouchedPaths` takes), so a PostToolUse hook accumulating concurrently
@@ -2484,30 +3656,199 @@ function readTouchedPathsFile(path) {
2484
3656
  * @param {string} hypoDir
2485
3657
  * @param {string|null|undefined} sessionId
2486
3658
  * @param {string|string[]} relPaths one or more vault-relative paths
3659
+ * @returns {boolean} true when the accumulation is durably recorded (or there
3660
+ * was nothing to record); false when a lock-timeout or write failure means
3661
+ * the caller cannot assume this path is in ANY session's touched-paths set.
2487
3662
  */
3663
+ /**
3664
+ * Shared read-merge-write body for accumulating touched paths, factored out
3665
+ * so `recordTouchedPaths` and `claimProjectionWrite` cannot drift on the
3666
+ * merge itself, only on what each reports about the outcome. Runs OUTSIDE
3667
+ * any lock; every caller wraps it in `withFileLock(path, ...)`.
3668
+ *
3669
+ * A corrupt/unreadable file (`readTouchedPathsFile` returns `null`) is
3670
+ * recovered from here, not preserved: an accumulate is additive by nature
3671
+ * (there is nothing salvageable to merge with), so starting fresh from
3672
+ * `incoming` is the correct best-effort behavior, unlike `clearTouchedPaths`,
3673
+ * where the same `null` MUST NOT be treated as empty (see
3674
+ * `readTouchedPathsFile`).
3675
+ *
3676
+ * @returns {{added: boolean}} whether at least one of `incoming` was NOT
3677
+ * already present in the set before this call, the bit a pre-write claim
3678
+ * needs to tell "I just claimed this" from "this was already claimed",
3679
+ * e.g. by an earlier SessionStart invocation in the same resumed session.
3680
+ */
3681
+ function mergeIncomingTouchedPaths(hypoDir, sessionId, incoming, hashFor) {
3682
+ const path = touchedPathsPath(hypoDir, sessionId);
3683
+ const current = readTouchedPathsFile(path);
3684
+ const merged = new Set(current === null ? [] : current);
3685
+ let added = false;
3686
+ for (const p of incoming) {
3687
+ if (!merged.has(p)) added = true;
3688
+ merged.add(p);
3689
+ }
3690
+ // Hashes first, the set second. A failure between the two leaves a hash
3691
+ // for a path the set may not hold (never read: only paths in the set are
3692
+ // looked up) or the previous hash of a path the set already held (at worst
3693
+ // a false drift, which keeps the path out of the commit and blocks, never
3694
+ // one that sweeps foreign bytes in).
3695
+ const hashPath = touchedHashesPath(hypoDir, sessionId);
3696
+ const hashes = readTouchedHashesFile(hashPath);
3697
+ for (const p of incoming) {
3698
+ const h = hashFor(p);
3699
+ if (h === undefined) delete hashes[p];
3700
+ else hashes[p] = h;
3701
+ }
3702
+ atomicWrite(hashPath, JSON.stringify(hashes));
3703
+ atomicWrite(path, JSON.stringify([...merged]));
3704
+ return { added };
3705
+ }
3706
+
3707
+ /**
3708
+ * Where the per-path content hashes recorded alongside a session's
3709
+ * touched-paths set live: `{ "<vault-relative path>": "<sha256 hex>" | null }`,
3710
+ * `null` meaning the file did not exist when it was recorded. A separate file
3711
+ * rather than a new shape for touched-paths.json, so every reader of that
3712
+ * array (hypo-close-guard.mjs parses it itself) keeps working unchanged, and a
3713
+ * set written before hashes existed simply has no entry here.
3714
+ *
3715
+ * What the hash is for: the set records that this session wrote a path, not
3716
+ * which bytes. When a clear after a successful commit fails, the stale claim
3717
+ * outlives its commit, and a later edit by ANOTHER session to that same path
3718
+ * would be swept into this session's next commit. Comparing the bytes on disk
3719
+ * with the bytes recorded here at commit time is what tells the two apart
3720
+ * (see commitTouchedPaths).
3721
+ */
3722
+ export function touchedHashesPath(hypoDir, sessionId) {
3723
+ return join(sessionCacheDir(hypoDir, sessionId), 'touched-hashes.json');
3724
+ }
3725
+
3726
+ /** Absent, corrupt, or not an object all read as `{}`: no hash recorded for
3727
+ * any path, which is exactly how a set from before hashes existed behaves. */
3728
+ function readTouchedHashesFile(path) {
3729
+ try {
3730
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
3731
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
3732
+ } catch {
3733
+ return {};
3734
+ }
3735
+ }
3736
+
3737
+ /** sha256 of a vault file's bytes, `null` when it does not exist, `undefined`
3738
+ * when it exists but cannot be read (nothing trustworthy to record). */
3739
+ function vaultFileHash(hypoDir, relPath) {
3740
+ try {
3741
+ return sha256Hex(readFileSync(join(hypoDir, relPath)));
3742
+ } catch (err) {
3743
+ return err?.code === 'ENOENT' ? null : undefined;
3744
+ }
3745
+ }
3746
+
3747
+ /** sha256 of a vault path's blob in HEAD, `null` when HEAD has no such path
3748
+ * (or there is no HEAD to read). `./` makes git resolve the path from the
3749
+ * vault, which may sit below the repository root. */
3750
+ function headFileHash(hypoDir, relPath) {
3751
+ const r = spawnSync('git', ['-C', hypoDir, 'cat-file', 'blob', `HEAD:./${relPath}`], {
3752
+ timeout: 30000,
3753
+ maxBuffer: 64 * 1024 * 1024,
3754
+ });
3755
+ return r.status === 0 && r.stdout ? sha256Hex(r.stdout) : null;
3756
+ }
3757
+
3758
+ /** Whether a hash was recorded for `relPath` and the bytes on disk no longer
3759
+ * match it. A path with no recorded hash never counts as drifted. */
3760
+ function recordedHashDrifted(hypoDir, hashes, relPath) {
3761
+ return Object.hasOwn(hashes, relPath) && vaultFileHash(hypoDir, relPath) !== hashes[relPath];
3762
+ }
3763
+
3764
+ /** Drop hash entries for paths no longer in the set. Best-effort: a leftover
3765
+ * entry is never read (lookups are by paths in the set) and is overwritten
3766
+ * the next time that path is recorded. */
3767
+ function pruneTouchedHashes(hypoDir, sessionId, remaining) {
3768
+ const hashPath = touchedHashesPath(hypoDir, sessionId);
3769
+ try {
3770
+ if (remaining.length === 0) {
3771
+ rmSync(hashPath, { force: true });
3772
+ return;
3773
+ }
3774
+ const hashes = readTouchedHashesFile(hashPath);
3775
+ const kept = {};
3776
+ for (const p of remaining) if (Object.hasOwn(hashes, p)) kept[p] = hashes[p];
3777
+ atomicWrite(hashPath, JSON.stringify(kept));
3778
+ } catch {
3779
+ // best-effort, see above
3780
+ }
3781
+ }
3782
+
2488
3783
  export function recordTouchedPaths(hypoDir, sessionId, relPaths) {
2489
- if (!sessionId) return;
3784
+ if (!sessionId) return true;
2490
3785
  const incoming = (Array.isArray(relPaths) ? relPaths : [relPaths]).filter(
2491
3786
  (p) => typeof p === 'string' && p.length > 0,
2492
3787
  );
2493
- if (incoming.length === 0) return;
3788
+ if (incoming.length === 0) return true;
2494
3789
  const path = touchedPathsPath(hypoDir, sessionId);
2495
3790
  try {
2496
- withFileLock(path, () => {
2497
- const current = readTouchedPathsFile(path);
2498
- // A corrupt/unreadable file (current === null) is recovered from here,
2499
- // not preserved: an accumulate is additive by nature (there is nothing
2500
- // salvageable to merge with), so starting fresh from `incoming` is the
2501
- // correct best-effort behavior — unlike clearTouchedPaths, where the
2502
- // same `null` MUST NOT be treated as empty (see readTouchedPathsFile).
2503
- const merged = new Set(current === null ? [] : current);
2504
- for (const p of incoming) merged.add(p);
2505
- atomicWriteShared(path, JSON.stringify([...merged]));
2506
- });
3791
+ withFileLock(path, () =>
3792
+ mergeIncomingTouchedPaths(hypoDir, sessionId, incoming, (p) => vaultFileHash(hypoDir, p)),
3793
+ );
3794
+ return true;
3795
+ } catch {
3796
+ // Never throws out to the caller (a hook must not fail a tool call over
3797
+ // a cache write, includes a lock-timeout), but no longer silent: the
3798
+ // false return is what lets a caller decide whether to trust this write
3799
+ // as accounted for.
3800
+ return false;
3801
+ }
3802
+ }
3803
+
3804
+ /**
3805
+ * Fail-closed pre-claim for a write that MUST have a session to be
3806
+ * accounted for, unlike recordTouchedPaths' every other caller. The generic
3807
+ * function above returns `true` on a missing session_id, correct for a
3808
+ * best-effort accumulate (there is nothing to accumulate, so nothing failed),
3809
+ * but the root-hot-projection pre-claim (hypo-session-start.mjs /
3810
+ * hypo-hot-rebuild.mjs) uses that same `true` to decide whether to go ahead
3811
+ * and write hot.md at all. A missing session_id there must not read as
3812
+ * "claimed": it would let hot.md be rewritten with no session's
3813
+ * touched-paths set able to ever bring the change into a commit, an orphan
3814
+ * write no different from a genuine lock-timeout, just for a different
3815
+ * reason. Every other property (locking, dedup, atomic merge) is identical;
3816
+ * this only tightens the missing-session_id case to `false`.
3817
+ *
3818
+ * Returns `added` alongside the old boolean (now `ok`) so a caller that must
3819
+ * later UNDO a failed write (hypo-session-start.mjs's SessionStart, on a
3820
+ * `writeRootHotProjection` lock-timeout or thrown failure) can tell "this
3821
+ * call is the one that put the claim there" from "the claim was already
3822
+ * sitting in this session's touched-paths set before this call ran", the
3823
+ * second case is a SECOND SessionStart on the same (resumed) session_id,
3824
+ * where an earlier invocation's still-unresolved claim must survive this
3825
+ * invocation's own failure, not be swept away by it.
3826
+ *
3827
+ * @param {string} hypoDir
3828
+ * @param {string|null|undefined} sessionId
3829
+ * @param {string|string[]} relPaths
3830
+ * @returns {{ok: boolean, added: boolean}}
3831
+ */
3832
+ export function claimProjectionWrite(hypoDir, sessionId, relPaths) {
3833
+ if (!sessionId) return { ok: false, added: false };
3834
+ const incoming = (Array.isArray(relPaths) ? relPaths : [relPaths]).filter(
3835
+ (p) => typeof p === 'string' && p.length > 0,
3836
+ );
3837
+ if (incoming.length === 0) return { ok: true, added: false };
3838
+ const path = touchedPathsPath(hypoDir, sessionId);
3839
+ try {
3840
+ // No hash for a projection claim: it is taken BEFORE the write, so the
3841
+ // bytes it will cover are not known yet, and a hash refreshed after the
3842
+ // write could itself fail and leave the claim looking drifted over this
3843
+ // session's own write. Root hot.md's ownership is checked per session by
3844
+ // its receipt at Stop instead (rootHotProjectionIsCurrent). Deleting the
3845
+ // entry also drops a hash an earlier Edit of the same path left behind.
3846
+ const { added } = withFileLock(path, () =>
3847
+ mergeIncomingTouchedPaths(hypoDir, sessionId, incoming, () => undefined),
3848
+ );
3849
+ return { ok: true, added };
2507
3850
  } catch {
2508
- // best-effort: a hook must never fail a tool call over a cache write
2509
- // (includes a lock-timeout — a dropped accumulation here is the same
2510
- // fail-safe shape as every other best-effort cache write in this file).
3851
+ return { ok: false, added: false };
2511
3852
  }
2512
3853
  }
2513
3854
 
@@ -2544,6 +3885,7 @@ export function drainTouchedPaths(hypoDir, sessionId) {
2544
3885
  if (result === null) return []; // corrupt/unreadable: leave the file for inspection, never delete
2545
3886
  try {
2546
3887
  rmSync(path, { force: true });
3888
+ pruneTouchedHashes(hypoDir, sessionId, []);
2547
3889
  } catch {
2548
3890
  // best-effort
2549
3891
  }
@@ -2583,6 +3925,66 @@ export function peekTouchedPaths(hypoDir, sessionId) {
2583
3925
  }
2584
3926
  }
2585
3927
 
3928
+ /**
3929
+ * Like `peekTouchedPaths`, but does not collapse "corrupt", "lock timeout"
3930
+ * and "empty" into the same silent `[]`. A `checkpointMode` gate treats
3931
+ * the three differently: a lock timeout is transient (another writer holds
3932
+ * the lock right now) and blocks so the retry reads the real set; a corrupt
3933
+ * file stays corrupt on every retry, so the gate reads it as "no record"
3934
+ * and says so in a notice instead of refusing forever. `peekTouchedPaths`
3935
+ * itself is unchanged: its callers already treat its `[]` as
3936
+ * fail-safe-to-no-scope for a best-effort commit attempt.
3937
+ *
3938
+ * @param {string} hypoDir
3939
+ * @param {string|null|undefined} sessionId
3940
+ * @returns {{state: 'ok'|'empty'|'unreadable'|'locked', paths: string[]}}
3941
+ * 'ok': a non-empty set was read. 'empty': no session_id, or the file is
3942
+ * absent/genuinely empty. 'unreadable': the file exists but is corrupt, or
3943
+ * the lock could not be taken for a reason other than a timeout (an
3944
+ * unwritable directory). 'locked': the per-session lock timed out. paths
3945
+ * is always `[]` unless 'ok', since nothing safely known could be returned.
3946
+ */
3947
+ export function readTouchedPathsStrict(hypoDir, sessionId) {
3948
+ if (!sessionId) return { state: 'empty', paths: [] };
3949
+ const path = touchedPathsPath(hypoDir, sessionId);
3950
+ try {
3951
+ return withFileLock(path, () => {
3952
+ const result = readTouchedPathsFile(path);
3953
+ if (result === null) return { state: 'unreadable', paths: [] };
3954
+ return result.length === 0 ? { state: 'empty', paths: [] } : { state: 'ok', paths: result };
3955
+ });
3956
+ } catch (err) {
3957
+ return { state: err?.code === 'ELOCKTIMEOUT' ? 'locked' : 'unreadable', paths: [] };
3958
+ }
3959
+ }
3960
+
3961
+ /**
3962
+ * The paths in a session's touched-paths set whose bytes on disk no longer
3963
+ * match the hash recorded for them (see touchedHashesPath): another write
3964
+ * landed after this session recorded the path, so Stop keeps it out of the
3965
+ * auto-commit. A separate reader, not a new field on readTouchedPathsStrict,
3966
+ * so that function's `{state, paths}` shape stays exactly what its callers
3967
+ * compare against. Only for wording a blocker; `[]` on no session_id or a
3968
+ * lock timeout, since readTouchedPathsStrict already decides whether to block.
3969
+ *
3970
+ * @param {string} hypoDir
3971
+ * @param {string|null|undefined} sessionId
3972
+ * @returns {string[]}
3973
+ */
3974
+ export function readTouchedPathsDrifted(hypoDir, sessionId) {
3975
+ if (!sessionId) return [];
3976
+ const path = touchedPathsPath(hypoDir, sessionId);
3977
+ try {
3978
+ return withFileLock(path, () => {
3979
+ const current = readTouchedPathsFile(path) || [];
3980
+ const hashes = readTouchedHashesFile(touchedHashesPath(hypoDir, sessionId));
3981
+ return current.filter((p) => recordedHashDrifted(hypoDir, hashes, p));
3982
+ });
3983
+ } catch {
3984
+ return [];
3985
+ }
3986
+ }
3987
+
2586
3988
  /**
2587
3989
  * Remove exactly `paths` from a session's touched-paths set — a set
2588
3990
  * difference, not a clear, and NOT a drain: any path a concurrent
@@ -2639,8 +4041,9 @@ export function clearTouchedPaths(hypoDir, sessionId, paths) {
2639
4041
  // best-effort
2640
4042
  }
2641
4043
  } else {
2642
- atomicWriteShared(path, JSON.stringify(remaining));
4044
+ atomicWrite(path, JSON.stringify(remaining));
2643
4045
  }
4046
+ pruneTouchedHashes(hypoDir, sessionId, remaining);
2644
4047
  });
2645
4048
  } catch {
2646
4049
  // lock-timeout or unexpected error: leave the file as-is. Safe per the
@@ -2685,9 +4088,29 @@ export function clearTouchedPaths(hypoDir, sessionId, paths) {
2685
4088
  * ever written to the corrupt file; it is left exactly as it was for a
2686
4089
  * human or a future recovery pass to look at.
2687
4090
  *
4091
+ * A `committed: true` result clears the whole peeked scope EXCEPT
4092
+ * `result.ignoredPaths`: the paths `commitFn` reports as still dirty but kept
4093
+ * out of the commit by `.hypoignore`. Those stay in the touched-paths set,
4094
+ * still blocking as a known, unresolved session write, instead of reading as
4095
+ * "resolved" because their siblings committed. Everything else is retired,
4096
+ * including a claim that was already clean at Stop time or that the caller
4097
+ * deliberately left out of the scope (the root hot.md projection claim when
4098
+ * its bytes are not this session's own): keeping those would let a path
4099
+ * another session later edits be blocked on, or swept into, this session's
4100
+ * next commit. A `commitFn` that reports no `ignoredPaths` (an older or
4101
+ * test-only stand-in for `commitWikiChanges`) keeps nothing, the plain
4102
+ * whole-scope clear.
4103
+ *
4104
+ * `commitFn` does not always get the whole set. A path whose bytes on disk
4105
+ * differ from the hash recorded for it (touchedHashesPath) is left out, and
4106
+ * is then either retired (HEAD already holds the recorded bytes, or the file
4107
+ * is clean) or kept alongside `ignoredPaths` (another write landed after the
4108
+ * record). The same-session case this also catches, a Bash rewrite of a
4109
+ * path after its last Edit, is left out too: the conservative direction.
4110
+ *
2688
4111
  * @param {string} hypoDir
2689
4112
  * @param {string|null|undefined} sessionId
2690
- * @param {(paths: string[]) => {committed: boolean, [k: string]: unknown}} commitFn
4113
+ * @param {(paths: string[]) => {committed: boolean, ignoredPaths?: string[], [k: string]: unknown}} commitFn
2691
4114
  * @returns {{committed: boolean, [k: string]: unknown}} whatever `commitFn` returned,
2692
4115
  * or `{committed: false, reason: 'touched-paths-lock-timeout'}` if the lock
2693
4116
  * itself could not be acquired (commitFn never ran; the file is untouched)
@@ -2705,18 +4128,55 @@ export function commitTouchedPaths(hypoDir, sessionId, commitFn) {
2705
4128
  // delete it.
2706
4129
  return commitFn([]);
2707
4130
  }
2708
- const result = commitFn(current);
4131
+ // Content check before the commit, for every path that has a recorded
4132
+ // hash (see touchedHashesPath). Bytes still equal to what this session
4133
+ // recorded go into the scope. Anything else is left out of THIS commit,
4134
+ // because the bytes on disk are no longer the ones this session wrote:
4135
+ // - HEAD already holds the recorded bytes: an old claim whose commit
4136
+ // landed but whose clear failed. Retired, so another session's
4137
+ // later edit of the path is never swept in under it.
4138
+ // - HEAD already holds the bytes on disk: nothing is dirty. Retired.
4139
+ // - neither: some other write landed after this session recorded the
4140
+ // path. Kept, so the checkpoint gate still blocks on it and says
4141
+ // why (readTouchedPathsDrifted), instead of the path reading as
4142
+ // ownerless.
4143
+ // A path with no recorded hash (a set from before hashes existed, a
4144
+ // projection claim, an unreadable file) goes into the scope as before.
4145
+ const hashes = readTouchedHashesFile(touchedHashesPath(hypoDir, sessionId));
4146
+ const scope = [];
4147
+ const driftKept = [];
4148
+ for (const p of current) {
4149
+ if (!recordedHashDrifted(hypoDir, hashes, p)) {
4150
+ scope.push(p);
4151
+ continue;
4152
+ }
4153
+ const head = headFileHash(hypoDir, p);
4154
+ if (head === hashes[p] || head === vaultFileHash(hypoDir, p)) continue;
4155
+ driftKept.push(p);
4156
+ }
4157
+ const result = commitFn(scope);
2709
4158
  if (result && result.committed && current.length > 0) {
2710
4159
  // Still under the SAME lock acquired above: no recordTouchedPaths
2711
4160
  // call for this session can have landed since `current` was read
2712
4161
  // (it takes this exact lock too), so the set on disk right now is
2713
- // EXACTLY `current` — clearing it needs no re-read or set
2714
- // difference, just remove what we already know is the whole thing.
2715
- try {
2716
- rmSync(path, { force: true });
2717
- } catch {
2718
- // best-effort: the committed paths just linger on disk; the next
2719
- // Stop re-peeks them and re-runs commitFn, a clean no-op.
4162
+ // EXACTLY `current`, the removal below needs no re-read.
4163
+ const keep = new Set([
4164
+ ...(Array.isArray(result.ignoredPaths) ? result.ignoredPaths : []),
4165
+ ...driftKept,
4166
+ ]);
4167
+ const remaining = current.filter((p) => keep.has(p));
4168
+ if (remaining.length < current.length) {
4169
+ try {
4170
+ if (remaining.length === 0) {
4171
+ rmSync(path, { force: true });
4172
+ } else {
4173
+ atomicWrite(path, JSON.stringify(remaining));
4174
+ }
4175
+ pruneTouchedHashes(hypoDir, sessionId, remaining);
4176
+ } catch {
4177
+ // best-effort: the retired paths just linger on disk; the next
4178
+ // Stop re-peeks them and re-runs commitFn, a clean no-op.
4179
+ }
2720
4180
  }
2721
4181
  }
2722
4182
  return result;
@@ -2774,10 +4234,23 @@ function projectOfPath(relPath) {
2774
4234
  *
2775
4235
  * @param {string} hypoDir
2776
4236
  * @param {string[]} [paths] vault-relative paths this caller wrote/owns this close
2777
- * @returns {{committed: boolean, scoped?: number, reason?: string}} committed:true
2778
- * when a commit was created OR nothing needed committing (scoped:0 in the
2779
- * latter case); committed:false (with reason) on a real failure: not a git
2780
- * repo, or git status/add/commit erroring.
4237
+ * @returns {{committed: boolean, scoped?: number, sha?: string|null, committedPaths?: string[], ignoredPaths?: string[], reason?: string}}
4238
+ * committed:true when a commit was created OR nothing needed committing
4239
+ * (scoped:0 in the latter case); committed:false (with reason) on a real
4240
+ * failure: not a git repo, or git status/add/commit erroring. `sha` is the
4241
+ * commit this call created, and is present ONLY on that branch: a
4242
+ * `scoped: 0` success created no commit, so it carries no sha rather than
4243
+ * an unrelated HEAD a caller could tell someone to revert. It is `null`
4244
+ * when the commit succeeded but reading its hash did not. `committedPaths`
4245
+ * is present on every `committed: true` outcome, `[]` for a no-op, and
4246
+ * names exactly the paths THIS call actually staged into the commit (the
4247
+ * rename-collapsed destination set `git diff --cached --name-only` itself
4248
+ * reports). `ignoredPaths` (also on every `committed: true` outcome) names
4249
+ * the supplied paths that ARE dirty but were kept out of the commit only
4250
+ * because `.hypoignore` matches them, so a caller clearing a per-session
4251
+ * pending-write set (Stop's `commitTouchedPaths`) can keep exactly those as
4252
+ * unresolved. A stale or already-clean supplied path is never listed:
4253
+ * nothing about it is left unresolved.
2781
4254
  */
2782
4255
  export function commitWikiChanges(hypoDir, paths) {
2783
4256
  const git = (...args) =>
@@ -2788,7 +4261,8 @@ export function commitWikiChanges(hypoDir, paths) {
2788
4261
  const supplied = new Set(
2789
4262
  (Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p.length > 0),
2790
4263
  );
2791
- if (supplied.size === 0) return { committed: true, scoped: 0 };
4264
+ if (supplied.size === 0)
4265
+ return { committed: true, scoped: 0, committedPaths: [], ignoredPaths: [] };
2792
4266
 
2793
4267
  // `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
2794
4268
  // no octal escaping, so non-ASCII paths (Korean page names are normal input
@@ -2798,17 +4272,31 @@ export function commitWikiChanges(hypoDir, paths) {
2798
4272
  const porcelain = git('status', '--porcelain', '-uall', '-z');
2799
4273
  if (porcelain.status !== 0)
2800
4274
  return { committed: false, reason: `git status failed in ${hypoDir}` };
4275
+ // porcelain paths are repository-root relative, while `supplied` and every
4276
+ // pathspec below are vault relative (git resolves pathspecs against -C). A
4277
+ // vault nested inside a larger repository therefore needs its prefix
4278
+ // stripped before matching, the same rule gitDirtyFiles uses; a record
4279
+ // outside the vault is out of scope.
4280
+ const prefixRes = git('rev-parse', '--show-prefix');
4281
+ if (prefixRes.status !== 0)
4282
+ return { committed: false, reason: `git rev-parse --show-prefix failed in ${hypoDir}` };
4283
+ const prefix = (prefixRes.stdout || '').replace(/\n$/, '');
4284
+ const toVaultRelative = (f) => {
4285
+ if (!f || !prefix) return f || null;
4286
+ return f.startsWith(prefix) ? f.slice(prefix.length) : null;
4287
+ };
2801
4288
  // `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
2802
4289
  // enumerate changed paths, drop ignored ones, then stage explicitly.
2803
4290
  const ignorePatterns = loadHypoIgnore(hypoDir);
2804
4291
  const scoped = []; // pathspec for `git add -A` — worktree/index paths only
2805
4292
  const commitScope = []; // pathspec for the final diff/commit --only (superset)
4293
+ const ignoredPaths = []; // supplied, dirty, but excluded by .hypoignore
2806
4294
  const records = (porcelain.stdout || '').split('\0');
2807
4295
  for (let i = 0; i < records.length; i++) {
2808
4296
  const rec = records[i];
2809
4297
  if (!rec) continue;
2810
4298
  const xy = rec.slice(0, 2);
2811
- const file = rec.slice(3); // `XY <path>`; the destination path for a rename/copy
4299
+ const file = toVaultRelative(rec.slice(3)); // `XY <path>`; the destination for a rename/copy
2812
4300
  const isRename = xy[0] === 'R' || xy[1] === 'R';
2813
4301
  // A rename OR copy emits two records (`to\0from`); consume the trailing
2814
4302
  // `from`. Copy `C` records only appear under `status.renames=copies`, but
@@ -2817,12 +4305,14 @@ export function commitWikiChanges(hypoDir, paths) {
2817
4305
  let fromFile = null;
2818
4306
  if (isRename || xy[0] === 'C' || xy[1] === 'C') {
2819
4307
  i++;
2820
- fromFile = records[i] || null;
4308
+ fromFile = toVaultRelative(records[i] || null);
2821
4309
  }
2822
4310
  if (!file) continue;
2823
4311
  if (!supplied.has(file)) continue; // out of this caller's scope
2824
- if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
4312
+ if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns)) {
4313
+ ignoredPaths.push(file);
2825
4314
  continue;
4315
+ }
2826
4316
  scoped.push(file);
2827
4317
  commitScope.push(file);
2828
4318
  // A rename's `from` path is the SAME change as its destination — without
@@ -2836,7 +4326,8 @@ export function commitWikiChanges(hypoDir, paths) {
2836
4326
  // in at all: the caller must name it explicitly if it wants it in scope.
2837
4327
  if (isRename && fromFile) commitScope.push(fromFile);
2838
4328
  }
2839
- if (scoped.length === 0 && commitScope.length === 0) return { committed: true, scoped: 0 };
4329
+ if (scoped.length === 0 && commitScope.length === 0)
4330
+ return { committed: true, scoped: 0, committedPaths: [], ignoredPaths };
2840
4331
 
2841
4332
  if (scoped.length > 0) {
2842
4333
  const add = git('add', '-A', '--', ...scoped);
@@ -2851,9 +4342,11 @@ export function commitWikiChanges(hypoDir, paths) {
2851
4342
  // pathspec (commitScope, the rename-aware superset of `scoped`) — this step
2852
4343
  // must not widen back to whole-tree either, or another session's already-
2853
4344
  // staged file would slip into the commit here.
2854
- const staged = git('diff', '--cached', '--name-only', '-z', '--', ...commitScope);
4345
+ // `--relative`: name the staged paths from the vault, like everything above.
4346
+ const staged = git('diff', '--cached', '--name-only', '--relative', '-z', '--', ...commitScope);
2855
4347
  const stagedFiles = (staged.stdout || '').split('\0').filter(Boolean);
2856
- if (stagedFiles.length === 0) return { committed: true, scoped: 0 };
4348
+ if (stagedFiles.length === 0)
4349
+ return { committed: true, scoped: 0, committedPaths: [], ignoredPaths };
2857
4350
 
2858
4351
  const projects = new Set(stagedFiles.map(projectOfPath));
2859
4352
  const today = new Date().toISOString().slice(0, 10);
@@ -2879,7 +4372,23 @@ export function commitWikiChanges(hypoDir, paths) {
2879
4372
  committed: false,
2880
4373
  reason: `git commit failed: ${(commit.stderr || '').trim() || 'unknown'}`,
2881
4374
  };
2882
- return { committed: true, scoped: stagedFiles.length };
4375
+ // MAJOR fix: name the commit this call just created. A caller that has to
4376
+ // tell a user how to take it back (close-gate-store.mjs's
4377
+ // hostTagWarningWithUndo) cannot derive it afterwards: on a shared vault
4378
+ // HEAD by then may be a concurrent session's commit, and reverting that
4379
+ // undoes someone else's work. Read right here, still inside the caller's
4380
+ // lock hold. `null` when rev-parse somehow fails: an undo instruction with
4381
+ // no target is a smaller failure than one with the wrong target, and the
4382
+ // commit itself did succeed, so this must not turn into `committed: false`.
4383
+ const head = git('rev-parse', 'HEAD');
4384
+ const sha = head.status === 0 ? (head.stdout || '').trim() || null : null;
4385
+ return {
4386
+ committed: true,
4387
+ scoped: stagedFiles.length,
4388
+ sha,
4389
+ committedPaths: stagedFiles,
4390
+ ignoredPaths,
4391
+ };
2883
4392
  }
2884
4393
 
2885
4394
  /**
@@ -3031,7 +4540,7 @@ export function recordSyncSuccess(hypoDir, op) {
3031
4540
  // carry the corruption forward.
3032
4541
  }
3033
4542
  current[op] = { timestamp: new Date().toISOString(), host: hostname() };
3034
- atomicWriteShared(path, JSON.stringify(current, null, 2) + '\n');
4543
+ atomicWrite(path, JSON.stringify(current, null, 2) + '\n');
3035
4544
  });
3036
4545
  } catch {
3037
4546
  // best-effort: lock timeout or write failure must never break the caller
@@ -3388,14 +4897,25 @@ export function normalizeVerifiedScope(verifiedScope) {
3388
4897
  *
3389
4898
  * @param {string} hypoDir
3390
4899
  * @param {string} sessionId
3391
- * @param {{project?: string, scope?: string, transcript_path?: string, verifiedScope?: {kind: 'log-only'|'project'|'global', projects?: string[]}}} info
4900
+ * @param {{project?: string, scope?: string, transcript_path?: string, verifiedScope?: {kind: 'log-only'|'project'|'global', projects?: string[]}, hostTagWarning?: string}} info
4901
+ * `hostTagWarning` is optional and, when present, is persisted as
4902
+ * `host_tag_warning` (see the payload below): the close-time residual a
4903
+ * reader of this file after the fact would otherwise have no record of.
4904
+ * `receiptGeneration`: when a caller wrote a close
4905
+ * receipt (`hooks/close-receipt.mjs`) for this session BEFORE calling this
4906
+ * function, it passes that receipt's `generation` here. A new Stop keys its
4907
+ * `legacy-closed` acceptance on this field's ABSENCE: a marker written by
4908
+ * an old build (or a caller that skipped the receipt) has no generation and
4909
+ * is accepted as a weaker, time-limited proof; a marker that carries one
4910
+ * promises a receipt exists, and Stop refuses to trust the marker alone
4911
+ * when that receipt is missing, invalid, or unreachable.
3392
4912
  */
3393
4913
  export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3394
- if (!sessionId) return;
4914
+ if (!sessionId) return false;
3395
4915
  try {
3396
4916
  const cacheDir = join(hypoDir, '.cache');
3397
4917
  if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
3398
- // scope distinguishes a project close (the 5 mandatory files were verified
4918
+ // scope distinguishes a project close (the 4 mandatory files were verified
3399
4919
  // fresh) from a log-only close (a non-project session, no project
3400
4920
  // attribution). Readers (precompactGateStatus / --check-session-close) key
3401
4921
  // the gate semantics on this field, so it must be recorded.
@@ -3426,10 +4946,36 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3426
4946
  // adopted this field reads back byte-identical to before it existed —
3427
4947
  // doctor's reader treats "field absent" as "no additional scope check".
3428
4948
  ...(verifiedScope ? { verified_scope: verifiedScope } : {}),
4949
+ // Same omit-when-absent contract. Set only when this close survived a
4950
+ // HOST_TAG_NAMES-shaped queue item reading as neutral rather than a
4951
+ // retraction (see walkCloseGate's neutralizedHostTagName and
4952
+ // closeGateStatus's hostTagWarning): a caller inspecting the marker
4953
+ // after the fact can then see the same residual the close-time console
4954
+ // output already warned about once.
4955
+ ...(info.hostTagWarning ? { host_tag_warning: info.hostTagWarning } : {}),
4956
+ // Omit-when-absent, same contract as the two fields above: a marker
4957
+ // written with no receipt behind it (or by a caller that predates the
4958
+ // receipt writer) must read back exactly as it always has.
4959
+ ...(info.receiptGeneration ? { receipt_generation: info.receiptGeneration } : {}),
3429
4960
  };
3430
- writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
4961
+ // Atomic, and it reports. Two reasons, and the caller needs both.
4962
+ //
4963
+ // A plain writeFileSync can leave a truncated file behind, and the reader
4964
+ // below drops a marker it cannot parse. That combination turns one failed
4965
+ // write into a session that can never close: the caller sees a file, calls
4966
+ // the close signal spent, and the next Stop deletes the unparseable marker.
4967
+ // temp+rename means the target is either the old bytes or the whole new
4968
+ // ones, never half.
4969
+ //
4970
+ // And existsSync cannot answer the question the caller is really asking.
4971
+ // "A marker file is there" is not "this run put it there" — a corrupt
4972
+ // marker from an earlier attempt satisfies it just as well. So say whether
4973
+ // THIS write landed and let the caller key on that.
4974
+ atomicWrite(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
4975
+ return true;
3431
4976
  } catch (err) {
3432
4977
  process.stderr.write(`[hypo] session-closed marker write failed: ${err?.message || err}\n`);
4978
+ return false;
3433
4979
  }
3434
4980
  }
3435
4981
 
@@ -3953,7 +5499,7 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3953
5499
  * cwd yields a best-effort notice, not a block. apply never passes it (its launch
3954
5500
  * cwd may differ from the authoritative payload.project).
3955
5501
  *
3956
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, attributionScope?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
5502
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, attributionScope?: string|null, sessionCwd?: string|null, sessionId?: string|null, closeMarker?: object|null, logOnly?: boolean, closeScope?: string[]}} [opts]
3957
5503
  * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
3958
5504
  */
3959
5505
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -3972,7 +5518,20 @@ export function precompactGateStatus(hypoDir, opts = {}) {
3972
5518
  // below. Exempting only the close blocker would still let an unrelated project's
3973
5519
  // stale design-history / lint block the non-project session (codex design
3974
5520
  // BLOCKER). git / hot / lint(self) / feedback all still apply — not a bypass.
3975
- const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
5521
+ // opts.closeMarker, when the key is set, is the marker the caller already
5522
+ // judged with closeCheckpointState (hooks/close-receipt.mjs): the marker of a
5523
+ // finished close, or null when the close is broken or open. Stop, PreCompact
5524
+ // and --check-session-close pass it, so a marker whose receipt is missing or
5525
+ // invalid never sets log-only mode or attribution here. It is injected rather
5526
+ // than computed because close-receipt.mjs imports this module. Without it the
5527
+ // raw marker is read as before (the apply paths, which invalidate this
5528
+ // session's marker before they reach this gate).
5529
+ const marker =
5530
+ opts.closeMarker !== undefined
5531
+ ? opts.closeMarker
5532
+ : opts.sessionId
5533
+ ? readSessionClosedMarker(hypoDir, opts.sessionId)
5534
+ : null;
3976
5535
  const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
3977
5536
 
3978
5537
  // Paths this session is accountable for at THIS close: the same signals the
@@ -4024,21 +5583,25 @@ export function precompactGateStatus(hypoDir, opts = {}) {
4024
5583
  }
4025
5584
  }
4026
5585
 
4027
- // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
4028
- // the ones inside closeAccountableScope, and only when sessionTouchTrusted
4029
- // (above) says that scope can actually be trusted: a session's own scoped
4030
- // auto-commit (commitWikiChanges, PR #222) can leave the working tree
4031
- // non-empty when a DIFFERENT session sharing this vault still has its own
4032
- // file dirty, the 2026-08-03 multi-session block. That dirty file is
4033
- // human-fixable by whoever owns it, not by this session, so it demotes to
4034
- // a notice (listed by path, never silently dropped) instead of refusing
4035
- // this session's marker. A dirty file THIS session owns still blocks
4036
- // unconditionally (fail-closed is unchanged for scope this session
4037
- // actually touched), and so does an unattributable state: a git failure
4038
- // gitDirtyFiles can't enumerate (dirty.length === 0 despite
4039
- // git.uncommitted === true), OR a scope we cannot trust
4040
- // (!sessionTouchTrusted) both fall back to the original unscoped blocker:
4041
- // "cannot attribute" is not "clean".
5586
+ // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but a
5587
+ // file this session cannot be held to still demotes to a notice: a
5588
+ // session's own scoped auto-commit (commitWikiChanges, PR #222) can
5589
+ // leave the working tree non-empty when a DIFFERENT session sharing
5590
+ // this vault still has its own file dirty, the 2026-08-03 multi-session
5591
+ // block. That dirty file is human-fixable by whoever owns it, not by
5592
+ // this session, so it demotes to a notice (listed by path, never
5593
+ // silently dropped) instead of refusing this session's marker. Which
5594
+ // set decides "not mine" depends on what the caller told us: a
5595
+ // caller-provided project (--project, or the cwd-derived
5596
+ // attributionScope) demotes by PATH STRUCTURE alone
5597
+ // (isForeignProjectFile below), independent of transcript trust; with
5598
+ // no such scope, only a trusted transcript's closeAccountableScope can
5599
+ // tell mine from foreign, so an untrusted,
5600
+ // unscoped session falls back to the original unscoped blocker:
5601
+ // "cannot attribute" is not "clean". A dirty file THIS session owns
5602
+ // still blocks unconditionally either way, and so does the
5603
+ // enumeration-failed case below (dirty.length === 0 despite
5604
+ // git.uncommitted === true).
4042
5605
  // Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
4043
5606
  // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
4044
5607
  // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
@@ -4046,24 +5609,138 @@ export function precompactGateStatus(hypoDir, opts = {}) {
4046
5609
  // a committed-but-unpushed close marks AND compacts, instead of the close writer
4047
5610
  // committing its own payload and then being blocked by its own (unpushed) commit.
4048
5611
  const git = hypoIsClean(hypoDir);
4049
- if (git.uncommitted) {
5612
+ // checkpointMode: a marker-writing
5613
+ // path's own gate call, replacing the whole git axis above with a narrower
5614
+ // question: "is there an uncommitted write THIS session is known to still
5615
+ // own, or any uncommitted file in the project folder being closed".
5616
+ // Everything else (close files, cwd, hot structure, lint, W8,
5617
+ // feedback) is untouched below; Stop, PreCompact and check never set this.
5618
+ if (opts.checkpointMode) {
5619
+ if (!isValidSessionId(opts.sessionId)) {
5620
+ blockers.push({
5621
+ type: 'checkpoint-session',
5622
+ reason: 'checkpointMode requires a validated sessionId',
5623
+ });
5624
+ } else if (git.uncommitted) {
5625
+ const dirty = gitDirtyFiles(hypoDir);
5626
+ if (dirty.length === 0) {
5627
+ // Enumeration itself failed (or nothing is actually dirty despite the
5628
+ // porcelain status): fail closed exactly like the unscoped path below.
5629
+ blockers.push({ type: 'git', reason: git.reason });
5630
+ } else {
5631
+ const touched = readTouchedPathsStrict(hypoDir, opts.sessionId);
5632
+ if (touched.state === 'locked') {
5633
+ // Another writer holds this session's touched-paths lock right now
5634
+ // (usually this session's own Stop auto-commit). Transient, so
5635
+ // block: the retry reads the real set instead of guessing.
5636
+ blockers.push({
5637
+ type: 'known-session-write',
5638
+ reason: 'touched-paths lock timed out (다른 쓰기가 끝난 뒤 다시 시도하면 된다)',
5639
+ });
5640
+ } else {
5641
+ // A corrupt set reads as "no record". It would read the same on
5642
+ // every retry, and nothing clears it, so blocking on it refused
5643
+ // every close for good. The project folder rule below does not
5644
+ // depend on the record, so that folder stays protected; only the
5645
+ // ownership of files outside it goes unjudged, and the notice
5646
+ // says so.
5647
+ if (touched.state === 'unreadable') {
5648
+ notices.push({
5649
+ type: 'touched-paths-unreadable',
5650
+ reason:
5651
+ 'touched-paths unreadable: ownership of dirty files outside the project folder being closed was not judged (touched-paths 기록을 읽지 못해 프로젝트 폴더 밖 파일의 소유를 판정하지 못했다)',
5652
+ });
5653
+ }
5654
+ // The project folder this close is closing blocks on any dirty
5655
+ // file, recorded or not, as the gate did before checkpointMode.
5656
+ // The record is not complete enough to be the only guard there: a
5657
+ // failed record (a hash sidecar or list write that did not land)
5658
+ // leaves this session's own write unlisted, and a close that died
5659
+ // between creating a new project's index.md and writing it leaves
5660
+ // an empty file no session ever recorded. Root files and other
5661
+ // projects' folders keep the record-only rule, since those are
5662
+ // where another session's dirty file stopped a close for good.
5663
+ // With no project named (a --mark without --project), whose folder
5664
+ // it is cannot be told, so every projects/ file blocks. A log-only
5665
+ // close names no project by design, so none does.
5666
+ const closeSlug = opts.projectOverride || opts.attributionScope || null;
5667
+ const inClosedProject = (f) =>
5668
+ closeSlug
5669
+ ? f.startsWith(`projects/${closeSlug}/`)
5670
+ : !logOnly && f.startsWith('projects/');
5671
+ const touchedSet = new Set((touched.paths || []).map(posixPath));
5672
+ const driftedSet = new Set(
5673
+ readTouchedPathsDrifted(hypoDir, opts.sessionId).map(posixPath),
5674
+ );
5675
+ const ignorePatterns = loadHypoIgnore(hypoDir);
5676
+ for (const f of dirty) {
5677
+ if (touchedSet.has(posixPath(f))) {
5678
+ // A touched file .hypoignore keeps out of every commit stays
5679
+ // dirty forever on its own: say how to get out. A file whose
5680
+ // bytes changed after this session recorded it is kept out of
5681
+ // the auto-commit (commitTouchedPaths), so say that too.
5682
+ const ignoredHint =
5683
+ ignorePatterns.length > 0 && isIgnored(join(hypoDir, f), hypoDir, ignorePatterns)
5684
+ ? ' (.hypoignore 대상이라 자동 커밋되지 않는다. 손으로 커밋하거나 .hypoignore 에서 빼야 close 가 된다)'
5685
+ : driftedSet.has(posixPath(f))
5686
+ ? ' (이 세션이 기록한 뒤 다른 쓰기가 이 파일을 바꿔서 자동 커밋에서 빠진다. 지금 내용이 맞는지 확인하고 손으로 커밋해야 close 가 된다)'
5687
+ : '';
5688
+ blockers.push({
5689
+ type: 'known-session-write',
5690
+ file: f,
5691
+ reason: `this session's own write is still uncommitted: ${f}${ignoredHint}`,
5692
+ });
5693
+ } else if (inClosedProject(posixPath(f))) {
5694
+ blockers.push({
5695
+ type: 'git',
5696
+ file: f,
5697
+ reason: closeSlug
5698
+ ? `uncommitted file in the project folder being closed: ${f} (close 하는 프로젝트 폴더 안의 미커밋 파일은 이 세션의 쓰기 기록에 없어도 막는다. 커밋하거나 되돌려야 close 가 된다)`
5699
+ : `uncommitted file under projects/ and this close names no project: ${f} (close 하는 프로젝트를 알 수 없어 projects/ 아래 미커밋 파일은 쓰기 기록에 없어도 막는다. --project 로 프로젝트를 지정하거나, 커밋하거나 되돌려야 close 가 된다)`,
5700
+ });
5701
+ } else {
5702
+ notices.push({
5703
+ type: 'unresolved',
5704
+ file: f,
5705
+ reason: `uncommitted changes, ownership unknown: ${f}`,
5706
+ });
5707
+ }
5708
+ }
5709
+ }
5710
+ }
5711
+ } else if (git.ahead) {
5712
+ notices.push({
5713
+ type: 'git-sync',
5714
+ reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
5715
+ });
5716
+ }
5717
+ } else if (git.uncommitted) {
4050
5718
  const dirty = gitDirtyFiles(hypoDir);
4051
5719
  if (dirty.length === 0) {
4052
5720
  // Enumeration itself failed (or nothing is actually dirty despite the
4053
5721
  // porcelain status): "cannot attribute" is not "clean", block exactly
4054
5722
  // as before, override or not.
4055
5723
  blockers.push({ type: 'git', reason: git.reason });
4056
- } else if (!sessionTouchTrusted && (opts.projectOverride || opts.attributionScope)) {
4057
- // session-close-scope-boundary spec §2b: a broken/missing transcript
4058
- // normally means "cannot attribute, fail closed" (the branch below).
4059
- // But when the CALLER told us which project is ours (an explicit
4060
- // --project, or the cwd-derived attributionScope), a dirty file that
4061
- // lives structurally under a DIFFERENT eligible project's own directory
4062
- // needs no attribution inference at all: the path alone proves it is
4063
- // not this session's file. Everything else (this session's own
4064
- // project, pages/, an unregistered or _template project dir) keeps the
4065
- // pre-existing fail-closed behavior; only a provably-foreign path is
4066
- // demoted.
5724
+ } else if (opts.projectOverride || opts.attributionScope) {
5725
+ // session-close-scope-boundary spec §2b, revised 2026-09-11: a
5726
+ // caller-provided scope (an explicit --project, or the cwd-derived
5727
+ // attributionScope) proves which project is ours PATH-STRUCTURALLY,
5728
+ // regardless of transcript trust. This used to run only when
5729
+ // !sessionTouchTrusted, on the theory that a trusted transcript could
5730
+ // fall back to closeAccountableScope instead. But closeAccountableScope
5731
+ // is `closeFileTargetsGlobal` whenever opts.projectOverride is unset
5732
+ // (every caller here passes attributionScope, never projectOverride;
5733
+ // see crystallize-close-apply.mjs), which unions in every OTHER
5734
+ // today-active project's own mandatory close files too. That let a
5735
+ // different session's still-dirty close files (session-state.md,
5736
+ // project hot.md, today's session-log shard) block THIS session's
5737
+ // marker even with --project set. A dirty file that lives structurally
5738
+ // under a DIFFERENT eligible project's own
5739
+ // directory needs no attribution inference at all: the path alone
5740
+ // proves it is not this session's file. Everything else (this
5741
+ // session's own project, pages/, an unregistered or _template project
5742
+ // dir) keeps the pre-existing fail-closed behavior; only a
5743
+ // provably-foreign path is demoted.
4067
5744
  const effectiveOverride = opts.projectOverride || opts.attributionScope;
4068
5745
  // Computed once per gate call, never per file (collectProjectWorkingDirs
4069
5746
  // walks the projects/ dir and reads every index.md). A throw here (a
@@ -4085,6 +5762,20 @@ export function precompactGateStatus(hypoDir, opts = {}) {
4085
5762
  const isForeign = (f) =>
4086
5763
  isForeignProjectFile(f, { eligibleSlugs, effectiveOverride, transcriptTouched });
4087
5764
  const foreign = dirty.filter(isForeign);
5765
+ // A dirty file inside the scoped project's OWN directory blocks, even one
5766
+ // this close does not write. An earlier revision demoted those to notices
5767
+ // to escape a deadlock: a close whose commit fails leaves
5768
+ // `projects/<p>/index.md` (seeded by ensureProjectIndex) uncommitted, the
5769
+ // retry skips every payload field as already-current without re-staging
5770
+ // it, and the marker can then never land again no matter how often the
5771
+ // user retries.
5772
+ //
5773
+ // That demotion was far wider than the deadlock it answered. It waved
5774
+ // through every unsaved file in the project, which is exactly the work a
5775
+ // close is supposed to refuse to walk away from. The deadlock is fixed at
5776
+ // its source instead: applyOverwrites re-stages index.md on the retry
5777
+ // path (crystallize-close-apply.mjs), so the file this branch used to
5778
+ // demote is now in the close's own commit scope and never reaches here.
4088
5779
  const rest = dirty.filter((f) => !isForeign(f));
4089
5780
  if (rest.length > 0) {
4090
5781
  blockers.push({
@@ -4984,12 +6675,28 @@ export function resolveTranscriptBySessionId(
4984
6675
  *
4985
6676
  * Fail-closed: any read/parse error → not open.
4986
6677
  *
4987
- * @returns {{open: boolean, openedAtIndex: number}} `openedAtIndex` is the
4988
- * position, in the FATAL-filtered record array this walk builds internally
4989
- * (blank lines dropped, non-object JSON values dropped, first parse failure
4990
- * returns early), of the most recent event that opened the gate, or -1 if
4991
- * none ever did. `close-gate-store.mjs`'s `resolutionStamp` counts records
4992
- * by the same definition, so the two indices are comparable.
6678
+ * @returns {{open: boolean, openedAtIndex: number, retractedByUnknownTagName: string|null, neutralizedHostTagName: string|null}}
6679
+ * `openedAtIndex` is the position, in the FATAL-filtered record array this
6680
+ * walk builds internally (blank lines dropped, non-object JSON values
6681
+ * dropped, first parse failure returns early), of the most recent event
6682
+ * that opened the gate, or -1 if none ever did. `close-gate-store.mjs`'s
6683
+ * `resolutionStamp` counts records by the same definition, so the two
6684
+ * indices are comparable. `retractedByUnknownTagName` names the tag (e.g.
6685
+ * `"any-new-host-tag"`) when the CURRENT `open: false` came from a
6686
+ * tag-shaped queue enqueue that is not one of `HOST_TAG_NAMES`, so a caller
6687
+ * can tell a maintainer which name to add; null whenever the current state
6688
+ * has another explanation or the gate is open. `neutralizedHostTagName`
6689
+ * names a `HOST_TAG_NAMES` tag (e.g. `"agent-message"`) that most recently
6690
+ * read as neutral, rather than a retraction, since the LAST event that
6691
+ * opened the gate, so a caller can warn that the close now in effect
6692
+ * survived a shape a person could also have pasted verbatim. Reset to null
6693
+ * by every event that (re)opens the gate, so it never survives past the
6694
+ * open it describes; null when nothing neutral has happened since that
6695
+ * open. Do NOT read it when `open` is false: the events that CLOSE the
6696
+ * gate (popAll, `/clear`, an unregistered tag, an attachment retraction, a
6697
+ * declined reconfirm) leave this field untouched, so on a closed gate it
6698
+ * can still name a tag from the open that was already retracted. Every
6699
+ * consumer today reads it only on an open gate.
4993
6700
  */
4994
6701
  // Genuine user text of a record, or null when the record is on a channel the
4995
6702
  // honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
@@ -5097,23 +6804,192 @@ function isModelReachableRecord(obj) {
5097
6804
  // notification the host injects on the model's behalf. Shared by the enqueue
5098
6805
  // branch and the remove-path (queued_command attachment) branch below so a
5099
6806
  // task notification reads as neutral on BOTH delivery shapes of the same host
5100
- // event. Before this was extracted, only the enqueue branch filtered it — the
6807
+ // event. Before this was extracted, only the enqueue branch filtered it: the
5101
6808
  // attachment branch treated any non-close prompt, including a task
5102
- // notification, as a change-of-mind close: a close typed by the user opened
5103
- // the gate, then the notification for an unrelated background task landed
5104
- // via this path and flipped it shut again.
6809
+ // notification, as a change-of-mind close, so a close typed by the user
6810
+ // opened the gate and the notification for an unrelated background task then
6811
+ // flipped it shut again.
6812
+ //
6813
+ // The prefix check used to be a literal `<task-notification>` string, which
6814
+ // missed two other host-minted tags this file had never been taught by name:
6815
+ // a subagent hand-back (`<agent-message from="...">`) and a cross-session
6816
+ // message (`<cross-session-message from="...">`). Measured 2026-09-17
6817
+ // against ~4.6K enqueue records across 431 transcripts (the corpus is live
6818
+ // and grows every session, so these counts are a snapshot, not a
6819
+ // reproducible figure; the ratios are what hold): task-notification (no
6820
+ // attributes) ~3890, agent-message (attributed) ~166, cross-session-message
6821
+ // (attributed) ~27, plain user prose ~518, slash commands ~53. Of the
6822
+ // records that start with `<` (~4.1K), exactly these three distinct tag
6823
+ // names occur, all host-minted.
6824
+ //
6825
+ // A first attempt widened the match to ANY leading tag name (`<name ...>` or
6826
+ // `<name>`, attributes optional), on the reasoning that a fourth host tag
6827
+ // would then read as neutral without a code change. That widening was
6828
+ // reverted: a queue-operation enqueue has no `origin` or `commandMode` field
6829
+ // to fall back on, so a leading-tag check is the ONLY signal this branch has,
6830
+ // and it cannot tell a host tag from a person's own bracketed text. Two
6831
+ // shapes a person can plausibly type or paste both matched the wide version:
6832
+ // a label like `<TODO> 계속 작업해줘`, and an unterminated prefix such as
6833
+ // `<please keep working` (the regex needed only a following space, not a
6834
+ // closing `>`). Both would have read as model-caused and neutral, silently
6835
+ // dropping a real change of mind instead of closing the gate. So the match
6836
+ // stays a closed allowlist of the three names this corpus has actually
6837
+ // produced (HOST_TAG_NAMES below), not a name-shaped pattern. A fourth host
6838
+ // tag this file has not been taught by name still falls through to the
6839
+ // enqueue branch's `else open = false` and retracts a close; that is the same
6840
+ // residual the enqueue branch's own comment already carries for any
6841
+ // unrecognized shape, not a new one this allowlist introduces.
6842
+ //
6843
+ // The slash-command ~53 does NOT mean "handled above, never reaches here".
6844
+ // Only `/clear` (~3) is caught by the branch above this one. The other ~50
6845
+ // (`/hypo:resume` ~40, `/reload-plugins` ~8, `/resume` ~1, `/operations…`
6846
+ // ~1) DO reach this filter, read as not-machine (no leading `<`), and fall
6847
+ // through to the enqueue branch's `else open = false`, same as any other
6848
+ // unrecognized non-close text. That classification is not obviously wrong
6849
+ // for `/hypo:resume` (typed by a human resuming work), but a queued
6850
+ // `/reload-plugins` right after a close retracts that close the same way,
6851
+ // and this file has never separately reasoned about whether that one
6852
+ // should. Confirmed against `walkCloseGate` directly: `close, then enqueue
6853
+ // "/reload-plugins"` reads false (closed), same as `/hypo:resume` and
6854
+ // `/clear`; only `/compact` reads true.
6855
+ //
6856
+ // None of the ~518 plain-prose records carry any of six known
6857
+ // machine-marker substrings (`[Subagent`, `hand-back`, `SYSTEM
6858
+ // NOTIFICATION`, `task-notification`, `Caveat:`, `automated`) in their first
6859
+ // 200 characters; some are as short as "reply with ok". So on the ENQUEUE
6860
+ // channel, in this corpus, one of these three leading tags separates
6861
+ // machine-caused content from user prose with zero false positives and zero
6862
+ // false negatives at this sample, but that separation is scoped to the
6863
+ // three names it checks, by design: it says nothing about a tag this corpus
6864
+ // has not produced.
6865
+ //
6866
+ // This predicate must not be reused on the typed (eventUserText) channel
6867
+ // either. 61 records that channel accepts as genuine user prose start with a
6868
+ // host tag too (`<bash-input>` ~24, `<bash-stdout>` ~24,
6869
+ // `<local-command-stdout>` ~13); `<bash-input>` is the user's own `!`
6870
+ // command. Those shapes do not occur on the queue channel today, so the two
6871
+ // channels read the same prefix in opposite directions and must stay on
6872
+ // separate predicates.
6873
+ //
6874
+ // Attachment-side note: this same function backstops the queued_command
6875
+ // branch below, but that branch now only reaches it when it has no producer
6876
+ // signal at all. `origin.kind` "human" overrides this body check there
6877
+ // entirely (see that branch's comment) rather than being weighed against it,
6878
+ // because a human-authored prompt can start with one of these three strings
6879
+ // too, by coincidence or by paste, and a producer field the host stamps is
6880
+ // stronger evidence than a string the host also mints. Measured: of 1796
6881
+ // queued_command attachments, 0 carry both a host-tag-shaped prefix and
6882
+ // origin.kind "human", so this ordering changes 0 outcomes at this sample;
6883
+ // it is a defense against a shape the corpus has not produced yet.
6884
+ //
6885
+ // ENQUEUE-BRANCH REVERSION (2026-09-18): a prior pass (codex cross-review
6886
+ // round 5, blocker) changed this branch to close on every non-empty,
6887
+ // non-close body, tag-shaped or not. Its reasoning still holds as a fact:
6888
+ // `queue-operation enqueue` carries no `origin` or `commandMode` field, so a
6889
+ // HOST_TAG_NAMES match here was never PROOF of a host producer, only a
6890
+ // corpus fact that no other producer had used the shape yet. What moved is
6891
+ // the judgment about which mistake costs more, once the corpus this file
6892
+ // actually runs against was measured. Of 433 sessions carrying a close
6893
+ // signal, 95 (21.9%, snapshot of a live and growing corpus, so the count
6894
+ // drifts but the ratio held at measurement time) hit that retraction, and 66
6895
+ // of those repeated the reconfirm five times or more, a friction this
6896
+ // file's own users hit daily. The event that retraction was defending
6897
+ // against, a person typing or pasting a HOST_TAG_NAMES shape verbatim, has
6898
+ // not been observed in this corpus at all; the trade was a confirmed daily
6899
+ // cost against an unmeasured one. A codex design consultation split the two
6900
+ // failure directions by recoverability instead of by which reading is more
6901
+ // often right: a commit opened by mistake is auditable and revertable in
6902
+ // git, while a permission closed by mistake just keeps asking the same user
6903
+ // to reconfirm a decision they already made. So the enqueue branch below
6904
+ // calls this function again and reads a HOST_TAG_NAMES match as neutral, the
6905
+ // same shape it read before the amendment. The residual that reopens, a
6906
+ // pasted tag keeping a spent close alive, is not guessed against on every
6907
+ // gate read; it is named once, via `neutralizedHostTagName` below, and
6908
+ // surfaced once at commit time by crystallize-close-apply.mjs rather than on
6909
+ // every read that would otherwise repeat the same nag the measurement above
6910
+ // found costly. This function's host-tag branch was already authoritative
6911
+ // for the queued_command attachment branch below (it never adopted the
6912
+ // amendment), which has `origin.kind` and `commandMode` to weigh a tag match
6913
+ // against, signals the enqueue shape does not carry.
6914
+ //
6915
+ // Exported so close-gate-store.mjs's `closeGateStatus` can tell a KNOWN host
6916
+ // tag apart from an unrecognized one when it names the culprit in its
6917
+ // `no-open` reason string: the advice differs (a known tag closing the gate
6918
+ // is the enqueue-branch fix above working as intended; an unrecognized one is
6919
+ // the "which tag to add" diagnostic that already existed).
6920
+ export const HOST_TAG_NAMES = ['task-notification', 'agent-message', 'cross-session-message'];
6921
+ // Capturing, not `(?:`, so hostTagNameOf below reads the matched name off the
6922
+ // same regex that decides neutrality. Two places in walkCloseGate need that
6923
+ // name, and giving either one its own pattern is how HOST_TAG_NAMES grows in
6924
+ // one of them and not the other.
6925
+ const HOST_TAG_PREFIX = new RegExp(`^<(${HOST_TAG_NAMES.join('|')})(?=[\\s>])`);
5105
6926
  function isModelCausedQueueContent(text) {
5106
6927
  const c = typeof text === 'string' ? text.trim() : '';
5107
- return !c || c.startsWith('<task-notification>');
6928
+ return !c || HOST_TAG_PREFIX.test(c);
6929
+ }
6930
+
6931
+ // The allowlisted tag name a queued body starts with, or null when it starts
6932
+ // with anything else. Diagnostic only, exactly like TAG_SHAPE_PREFIX below:
6933
+ // it never decides the gate, it only lets walkCloseGate say WHICH tag read as
6934
+ // neutral. Both neutral enqueue branches call this rather than re-deriving
6935
+ // the name, so adding a fourth name to HOST_TAG_NAMES moves both at once.
6936
+ function hostTagNameOf(text) {
6937
+ const m = HOST_TAG_PREFIX.exec(typeof text === 'string' ? text.trim() : '');
6938
+ return m ? m[1] : null;
6939
+ }
6940
+
6941
+ // Diagnostic only, never a gate decision on its own. HOST_TAG_PREFIX above is
6942
+ // the closed allowlist that actually decides neutrality; this is deliberately
6943
+ // wider, so it also matches a name the allowlist has not been taught
6944
+ // (`<any-new-host-tag ...>`) and a human's own bracketed paste (`<TODO>`,
6945
+ // `<please keep working`). It exists so the enqueue branch below can NAME a
6946
+ // retraction's shape in closeGateStatus's `no-open` reason string, closing a
6947
+ // gap a codex cross-review named: nothing today tells a maintainer the
6948
+ // allowlist has gone stale, so a fourth host tag silently retracts real
6949
+ // closes until someone reads this corpus by hand and notices.
6950
+ const TAG_SHAPE_PREFIX = /^<([A-Za-z][\w-]*)(?:[\s>]|$)/;
6951
+
6952
+ /**
6953
+ * The tag name of a tag-shaped body whose name is NOT in HOST_TAG_NAMES, or
6954
+ * null (no tag shape at all, or a registered one). This is the fail-closed
6955
+ * judgment the enqueue branch runs FIRST, ahead of the close-phrase check:
6956
+ * see that branch for why an unregistered tag must retract even when its body
6957
+ * reads like a close.
6958
+ *
6959
+ * Deliberately not the negation of hostTagNameOf: that one requires `[\s>]`
6960
+ * after the name, so a bare `<task-notification` (no bracket, nothing after)
6961
+ * misses it. Matching TAG_SHAPE_PREFIX and then checking the NAME against the
6962
+ * allowlist keeps such a body out of this retraction, leaving it to the same
6963
+ * final `else` that already closed on it before this function existed.
6964
+ */
6965
+ function unregisteredTagNameOf(text) {
6966
+ const m = TAG_SHAPE_PREFIX.exec(typeof text === 'string' ? text.trim() : '');
6967
+ if (!m) return null;
6968
+ return HOST_TAG_NAMES.includes(m[1]) ? null : m[1];
6969
+ }
6970
+
6971
+ // The fail-closed shape of this walk's return, built fresh on every call.
6972
+ // A shared module-level constant would hand every caller the same object, so
6973
+ // one caller stashing or mutating what it got back would reach into the next
6974
+ // call's answer. The three early returns below all use this so a caller
6975
+ // destructuring the two diagnostic fields gets `null`, not `undefined`, on
6976
+ // the paths that never walk a record (see this function's @returns).
6977
+ function closedGateResult() {
6978
+ return {
6979
+ open: false,
6980
+ openedAtIndex: -1,
6981
+ retractedByUnknownTagName: null,
6982
+ neutralizedHostTagName: null,
6983
+ };
5108
6984
  }
5109
6985
 
5110
6986
  export function walkCloseGate(transcriptPath) {
5111
- if (!transcriptPath) return { open: false, openedAtIndex: -1 };
6987
+ if (!transcriptPath) return closedGateResult();
5112
6988
  let raw;
5113
6989
  try {
5114
6990
  raw = readFileSync(transcriptPath, 'utf-8');
5115
6991
  } catch {
5116
- return { open: false, openedAtIndex: -1 };
6992
+ return closedGateResult();
5117
6993
  }
5118
6994
  // FATAL: a non-empty line that will not parse means the transcript is being
5119
6995
  // appended to (a half-written record) or is corrupt. Skipping it would let a
@@ -5128,7 +7004,7 @@ export function walkCloseGate(transcriptPath) {
5128
7004
  try {
5129
7005
  o = JSON.parse(line);
5130
7006
  } catch {
5131
- return { open: false, openedAtIndex: -1 };
7007
+ return closedGateResult();
5132
7008
  }
5133
7009
  if (o === null || typeof o !== 'object') continue;
5134
7010
  recs.push(o);
@@ -5160,6 +7036,21 @@ export function walkCloseGate(transcriptPath) {
5160
7036
  const markedAskIds = new Set();
5161
7037
  let open = false;
5162
7038
  let openedAtIndex = -1;
7039
+ // Names the tag-shaped queue item that most recently retracted a close (one
7040
+ // NOT in HOST_TAG_NAMES, the only shape that still retracts), so
7041
+ // closeGateStatus's `no-open` reason can surface it instead of leaving it as
7042
+ // a fact only a transcript dump reveals. Cleared at every OTHER state change
7043
+ // so it never survives past the retraction it explains; see the enqueue
7044
+ // branch's final `else` for where it is set.
7045
+ let retractedByUnknownTagName = null;
7046
+ // Names a HOST_TAG_NAMES tag that most recently read as neutral, rather
7047
+ // than a retraction, since the last event that opened the gate: the
7048
+ // residual the enqueue-branch reversion above accepts. Reset to null by
7049
+ // every `open = true` assignment below so it only ever describes the close
7050
+ // currently in effect, never one already resolved by a later close phrase.
7051
+ // Read once, at commit time, by crystallize-close-apply.mjs's warn-once
7052
+ // path; see closeGateStatus for where it turns into a message.
7053
+ let neutralizedHostTagName = null;
5163
7054
 
5164
7055
  for (let i = 0; i < recs.length; i++) {
5165
7056
  const o = recs[i];
@@ -5177,22 +7068,125 @@ export function walkCloseGate(transcriptPath) {
5177
7068
  // an NL queued command is handled by its queued_command attachment below.
5178
7069
  if (o.type === 'queue-operation') {
5179
7070
  if (o.operation === 'popAll') {
7071
+ // Unconditional close, and that is deliberate. A 2026-09-17 attempt to
7072
+ // read this branch's content the way the enqueue branch below does was
7073
+ // reverted: the measurement behind it was truncated and wrong.
7074
+ //
7075
+ // What the corpus actually holds (~66 popAll records, live corpus so a
7076
+ // snapshot): 0 have missing or empty content, 63 carry content identical
7077
+ // to the preceding enqueue, 2 follow an enqueue with different content,
7078
+ // 1 follows a `remove`. None start with a host tag, so a content check
7079
+ // would change the outcome of exactly 0 of them while newly letting a
7080
+ // user's own `<TODO> ...` paste through as neutral.
7081
+ //
7082
+ // The one record that looked machine-authored is worse than useless as
7083
+ // evidence. Read untruncated it is 465 chars: this hook's own relayed
7084
+ // notice with the USER'S question appended by the host after a blank
7085
+ // line ("...back in sync.\n\n업데이트했는데 이렇게 에러 뜨는 이유는?").
7086
+ // All 4 records carrying that relay prefix are such composites. Treating
7087
+ // the prefix as proof of machine authorship reads a real retraction as
7088
+ // neutral, which keeps a spent close alive and lets crystallize apply
7089
+ // writes the user already took back. Condition-hit accuracy was 0 of 5.
7090
+ //
7091
+ // This channel cannot do better: a queue-operation record's keys are
7092
+ // content/operation/sessionId/timestamp/type, with no origin and no
7093
+ // promptSource, so there is nothing here that attests a producer. Until
7094
+ // the host stamps one, popAll closes and the user re-confirms.
5180
7095
  open = false;
7096
+ retractedByUnknownTagName = null; // a fully-understood close, no diagnostic needed
5181
7097
  continue;
5182
7098
  }
5183
7099
  if (o.operation !== 'enqueue') continue;
5184
7100
  const c = typeof o.content === 'string' ? o.content.trim() : '';
7101
+ // Judged ahead of the branch chain so the fail-closed branch below can
7102
+ // read it without running the regex twice.
7103
+ const unregisteredTag = unregisteredTagNameOf(c);
5185
7104
  if (/^\/compact(?:\s|$)/.test(c)) {
5186
7105
  open = true; // a user compaction preserves the work → open
5187
7106
  openedAtIndex = i;
7107
+ retractedByUnknownTagName = null;
7108
+ neutralizedHostTagName = null;
5188
7109
  } else if (/^\/clear(?:\s|$)/.test(c)) {
5189
7110
  open = false; // abandons context → close
5190
- } else if (isModelCausedQueueContent(c)) {
5191
- /* model-caused / empty — neutral */
7111
+ retractedByUnknownTagName = null;
7112
+ } else if (!c) {
7113
+ /* empty: neutral. Empty content names no producer in either
7114
+ direction: there is no text to be a host tag, a close phrase, or a
7115
+ person's own words, so this is one of the two cases the enqueue
7116
+ branch grants for free; see the HOST_TAG_NAMES branch below for the
7117
+ other. */
7118
+ } else if (unregisteredTag) {
7119
+ /* MAJOR fix (fail-closed): a tag-shaped body whose name is NOT in
7120
+ HOST_TAG_NAMES retracts the close, and that judgment now runs
7121
+ BEFORE the close-phrase branch below. It used to run after, in the
7122
+ final `else`, which meant an unregistered tag whose body happened
7123
+ to carry close wording (`<future-host>오늘은 여기까지 하자</future-host>`)
7124
+ fell into the isClosePattern branch instead and did neither: it
7125
+ left the previous close standing AND named nothing, so
7126
+ closeGateStatus returned no warning on any of its three surfaces
7127
+ and the apply that followed looked ordinary.
7128
+
7129
+ Fail-closed means the unknown shape is treated as the user's own
7130
+ retraction until someone proves otherwise by registering the tag.
7131
+ What that opens: a genuine host notification from a tag this build
7132
+ has never heard of now costs the user one re-confirmation of their
7133
+ close. The reason string names the tag so the fix (add it to
7134
+ HOST_TAG_NAMES) is one edit away; see closeGateStatus's `no-open`
7135
+ wording. */
7136
+ retractedByUnknownTagName = unregisteredTag;
7137
+ open = false;
5192
7138
  } else if (isClosePattern(c)) {
5193
- /* NL close via the queue — the open dequeue gap: the producer cannot be
5194
- attributed (a peer/model enqueue wears the same shape), so no open */
5195
- } else open = false; // a queued non-close user intent → close (change of mind)
7139
+ /* NL close via the queue: the open dequeue gap. The producer cannot be
7140
+ attributed (a peer/model enqueue wears the same shape), so no open.
7141
+
7142
+ Ordered AHEAD of the host-tag branch below on purpose. A body that
7143
+ is both host-tag shaped and close worded must not open the gate off
7144
+ a shape a person can paste, and this is the branch that refuses to
7145
+ open; putting the host-tag branch first would change nothing about
7146
+ that outcome but would spread the no-open reasoning over two
7147
+ places. What the order did cost is a warning that never fired:
7148
+ isClosePattern is an unanchored substring match, so a genuine host
7149
+ notification whose body happens to carry close wording
7150
+ (`<agent-message from="worker">오늘은 여기까지 진행했습니다</agent-message>`)
7151
+ lands here rather than below, and left neutralizedHostTagName unset.
7152
+ So name the tag here too. The gate decision is untouched (both
7153
+ branches are no-ops on `open`); only the naming widens.
7154
+
7155
+ What that accepts: a real host notification with close-worded prose
7156
+ now also raises the commit-time warning, which is a false alarm.
7157
+ That is one line per apply, not the per-read nag the enqueue-branch
7158
+ reversion note above was measured against, so the two costs are not
7159
+ the same size, and the alternative (an anchored close match here)
7160
+ would trade a false alarm for a missed one. */
7161
+ const hostTag = hostTagNameOf(c);
7162
+ if (hostTag) neutralizedHostTagName = hostTag;
7163
+ } else if (isModelCausedQueueContent(c)) {
7164
+ /* a body shaped like one of HOST_TAG_NAMES: neutral, the same filter
7165
+ the queued_command attachment branch below applies to the same
7166
+ host event on its other delivery shape. This branch has no
7167
+ `origin` field, so a host tag's own shape is not PROOF of a host
7168
+ producer, it only reflects what this corpus has actually produced
7169
+ on this channel (see the doc comment above
7170
+ isModelCausedQueueContent, and the enqueue-branch reversion note
7171
+ right above it for why this reads neutral again rather than
7172
+ closing). Name the tag so a caller can warn once, at commit time,
7173
+ that this close survived a shape a person could also have pasted;
7174
+ see neutralizedHostTagName's own doc comment above. */
7175
+ // isModelCausedQueueContent above matched HOST_TAG_PREFIX on a body
7176
+ // the empty-content branch has already excluded, so this is never
7177
+ // null here; the guard only avoids clobbering a live value on a
7178
+ // mismatch that should not occur.
7179
+ const hostTag = hostTagNameOf(c);
7180
+ if (hostTag) neutralizedHostTagName = hostTag;
7181
+ } else {
7182
+ // Every other non-empty, non-close, non-host-tag-shaped queued item
7183
+ // → close (change of mind). Name the tag here, if there is one, so
7184
+ // closeGateStatus's `no-open` reason can tell a maintainer which
7185
+ // shape closed it: see this field's own doc comment above.
7186
+ const tagShape = TAG_SHAPE_PREFIX.exec(c);
7187
+ retractedByUnknownTagName = tagShape ? tagShape[1] : null;
7188
+ open = false;
7189
+ }
5196
7190
  continue;
5197
7191
  }
5198
7192
 
@@ -5201,14 +7195,25 @@ export function walkCloseGate(transcriptPath) {
5201
7195
  // `attachment` of type queued_command with the prompt verbatim). This opens
5202
7196
  // the gate ONLY with an audited human producer — origin.kind "human", present
5203
7197
  // on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
5204
- // cannot attest a producer, so it does not open (fail-closed). A NON-close
5205
- // queued command (e.g. "keep working") is a fresh user intent and CLOSES
5206
- // a prior open regardless of origin — that is what closes the re-close hole
5207
- // where a queued "continue" after a close leaves the stale open live. A
5208
- // task notification is not that: `modelCaused` below filters it out before
5209
- // this reaches the change-of-mind close, because the model, not the user,
5210
- // produced it. This delivery path used to skip that filter and let an
5211
- // unrelated background-task notification flip a just-opened gate shut.
7198
+ // cannot attest a producer, so it does not open (fail-closed). Closing asks
7199
+ // for the same proof: a NON-close queued command (e.g. "keep working") is a
7200
+ // fresh user intent and retracts a prior open, but only with that same
7201
+ // audited human producer. This branch used to close by default instead,
7202
+ // on the theory that anything the model-caused list did not recognise had
7203
+ // to be the user. That default is what let an unrelated background-task
7204
+ // notification flip a just-opened gate shut, and every new machine-minted
7205
+ // event would have needed its own line in that list to stay safe. Requiring
7206
+ // a positive producer means an event this filter has never seen is neutral
7207
+ // rather than hostile. `modelCaused` below still runs first, so a
7208
+ // recognised notification never reaches either decision.
7209
+ //
7210
+ // The enqueue branch above does NOT share this rule: those records carry no
7211
+ // `origin` at all, so requiring one would stop them closing anything. It
7212
+ // keeps the old enumerate-the-machine default, and the re-close hole (a
7213
+ // queued "continue" after a close leaving the stale open live) is closed
7214
+ // there rather than here. The two delivery shapes of one user action are
7215
+ // therefore judged differently on purpose; see the residual noted on the
7216
+ // queue-operation branch.
5212
7217
  //
5213
7218
  // Scope of "both shapes classify alike": it holds for task notifications and
5214
7219
  // for empty content, which is what this fix is about. It does not hold for
@@ -5243,43 +7248,76 @@ export function walkCloseGate(transcriptPath) {
5243
7248
  ? (contentBlocksText(rawPrompt) ?? '')
5244
7249
  : '';
5245
7250
  const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
5246
- // The host labels these deliveries on the record itself, and that label is
5247
- // the authoritative one: every measured task-notification attachment
7251
+ // The host labels these deliveries on the record itself, and that label
7252
+ // is the authoritative one: every measured task-notification attachment
5248
7253
  // (1040 of 1040) carries commandMode 'task-notification'. Keying only on
5249
7254
  // the '<task-notification>' body prefix would leave the whole filter
5250
7255
  // resting on a string the host owns and can restyle, and a body that
5251
- // merely gains a line before the tag would slip past it. The queue-op
5252
- // branch above has no such field to read, so it keeps the body check
5253
- // alone; here both are available and both are used.
5254
- const modelCaused =
5255
- o.attachment.commandMode === 'task-notification' || isModelCausedQueueContent(prompt);
5256
- // The filter runs FIRST here, exactly as it does in the enqueue branch
5257
- // above. Order is load-bearing, not cosmetic: a notification body carries
5258
- // whatever text the finished task was named after, and isClosePattern
5259
- // matches on a substring ("wrap up", "오늘 여기까지" inside a <summary>
5260
- // both measure true). Testing isClosePattern first therefore lets a
5261
- // model-produced event reach the opener, where a human-origin delivery
5262
- // would set `open` and push `openedAtIndex` forward. That manufactures a
5263
- // close signal the user never gave, and moves the index the resolution
5264
- // comparison reads.
7256
+ // merely gains a line before the tag would slip past it. It is checked
7257
+ // first, ahead of `humanOrigin` too: it is the strongest signal this
7258
+ // record has, host-stamped rather than read out of prose either side
7259
+ // could shape.
7260
+ const modelCaused = o.attachment.commandMode === 'task-notification';
7261
+ // A codex cross-review on this exact branch found that a prior pass
7262
+ // (meant to close a DIFFERENT major finding, an over-wide tag regex)
7263
+ // left `humanOrigin` never winning here at all: `isModelCausedQueueContent`
7264
+ // ran first for every case, so an audited human prompt that merely
7265
+ // STARTS WITH one of the three known tag names (`<agent-message ...>`,
7266
+ // `<cross-session-message ...>`) still read as neutral and a real
7267
+ // change of mind was silently dropped. The old "0 of 1796 carry both"
7268
+ // count was an observed-corpus fact, not a schema guarantee: a user
7269
+ // can paste one of these three strings verbatim without any host
7270
+ // change, so "it hasn't happened yet" was never a structural argument.
5265
7271
  //
5266
- // Reachability, so the next reader does not have to re-measure it: no
5267
- // recorded delivery hits that path. All 1040 task-notification
5268
- // attachments in the corpus arrive with no `origin`, so `humanOrigin` is
5269
- // false and the opener is skipped whichever order the two checks run in.
5270
- // This is a defensive pin against a host that starts stamping origin on
5271
- // them, not a repair of an observed failure.
7272
+ // This branch closes that gap for the half of it that carries no new
7273
+ // risk: a human origin paired with text that is NOT a close phrase
7274
+ // always retracts, whatever the body looks like, because retracting is
7275
+ // the safe direction (worst case: the user re-confirms a close they
7276
+ // still want).
7277
+ //
7278
+ // It deliberately does NOT extend the same reordering to a close-phrase
7279
+ // body: `isModelCausedQueueContent(prompt)` still runs, further down,
7280
+ // before `isClosePattern` gets to open anything, exactly as the enqueue
7281
+ // branch above does. A body that both matches a known tag shape AND a
7282
+ // close phrase (a task-notification's own `<summary>` can legitimately
7283
+ // say "wrap up") stays neutral there even with a stray human origin.
7284
+ // That ordering is what `close-signals.test.mjs`'s "task-notification
7285
+ // whose body matches a close pattern opens nothing" fixture pins, and
7286
+ // it exists to stop a mislabeled host notification from manufacturing
7287
+ // an open nobody asked for. Reordering that direction too would trade a
7288
+ // same-severity gap (a dropped close) for a worse one (a granted close
7289
+ // nobody typed), so it stays as it was.
5272
7290
  if (modelCaused) {
5273
- /* model-caused / empty: neutral, the same filter the enqueue branch
5274
- above applies to the same host event on its other delivery shape */
7291
+ /* strongest signal, host-stamped: decided before either heuristic
7292
+ below, and before humanOrigin gets a say */
7293
+ } else if (humanOrigin && !isClosePattern(prompt)) {
7294
+ open = false; // an audited human change of mind → close, whatever the body looks like
7295
+ retractedByUnknownTagName = null;
7296
+ } else if (isModelCausedQueueContent(prompt)) {
7297
+ /* model-caused / empty, OR a human-origin close-phrase body that still
7298
+ reads as one of the three known tag shapes (see the comment above):
7299
+ neutral, the same filter the enqueue branch above applies to the
7300
+ same host event on its other delivery shape */
5275
7301
  } else if (isClosePattern(prompt)) {
5276
7302
  if (humanOrigin) {
5277
7303
  open = true;
5278
7304
  openedAtIndex = i;
7305
+ retractedByUnknownTagName = null;
7306
+ neutralizedHostTagName = null;
5279
7307
  }
5280
- } else {
5281
- open = false;
7308
+ /* else: no open, producer cannot be attributed */
5282
7309
  }
7310
+ // else: not caught by the known-machine shapes above, not a close
7311
+ // phrase, and no audited human producer either — a host event this
7312
+ // filter does not yet recognize. The old rule closed here by default
7313
+ // (anything not on the model-caused list must be the user), which is
7314
+ // exactly the shape of bug #289 fixed for <task-notification>: a new
7315
+ // kind of unattributed host event would silently retract a close the
7316
+ // user already granted, and every future one would need its own line
7317
+ // in isModelCausedQueueContent to avoid repeating that. Requiring a
7318
+ // POSITIVE human producer to close, instead of enumerating every way to
7319
+ // recognize a machine one, means an event this filter has never seen
7320
+ // still cannot close the gate on its own.
5283
7321
  continue;
5284
7322
  }
5285
7323
 
@@ -5304,8 +7342,11 @@ export function walkCloseGate(transcriptPath) {
5304
7342
  if (isClosePattern(userText)) {
5305
7343
  open = true;
5306
7344
  openedAtIndex = i;
7345
+ retractedByUnknownTagName = null;
7346
+ neutralizedHostTagName = null;
5307
7347
  } else if (isCloseRetractionPattern(userText)) {
5308
7348
  open = false;
7349
+ retractedByUnknownTagName = null;
5309
7350
  }
5310
7351
  continue;
5311
7352
  }
@@ -5356,14 +7397,17 @@ export function walkCloseGate(transcriptPath) {
5356
7397
  // rejection.
5357
7398
  if (sawDecline && markedAskIds.has(b.tool_use_id)) {
5358
7399
  open = false;
7400
+ retractedByUnknownTagName = null;
5359
7401
  } else if (sawClose) {
5360
7402
  open = true;
5361
7403
  openedAtIndex = i;
7404
+ retractedByUnknownTagName = null;
7405
+ neutralizedHostTagName = null;
5362
7406
  }
5363
7407
  }
5364
7408
  }
5365
7409
  }
5366
- return { open, openedAtIndex };
7410
+ return { open, openedAtIndex, retractedByUnknownTagName, neutralizedHostTagName };
5367
7411
  }
5368
7412
 
5369
7413
  /**