hypomnema 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +144 -66
- package/scripts/upgrade.mjs +523 -234
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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
|
|
716
|
-
// project hot.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
|
|
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
|
|
1376
|
-
*
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
* a
|
|
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
|
|
1465
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
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
|
|
1949
|
-
//
|
|
1950
|
-
//
|
|
1951
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
2354
|
-
*
|
|
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
|
-
*
|
|
2358
|
-
*
|
|
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
|
|
2362
|
-
* fast-forward, so the push would only
|
|
2363
|
-
*
|
|
2364
|
-
*
|
|
2365
|
-
*
|
|
2366
|
-
* `.conflict-*`
|
|
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'
|
|
2369
|
-
*
|
|
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
|
|
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,
|
|
3386
|
+
* @returns {{pulled: boolean, conflict: boolean}}
|
|
2375
3387
|
*/
|
|
2376
|
-
export function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3540
|
+
return { pushed: true };
|
|
3541
|
+
}
|
|
3542
|
+
appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
|
|
3543
|
+
return { pushed: false };
|
|
2413
3544
|
} catch {
|
|
2414
|
-
// best-effort
|
|
3545
|
+
// best-effort: never break the Stop hook
|
|
3546
|
+
return { pushed: false };
|
|
2415
3547
|
}
|
|
2416
|
-
|
|
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
|
|
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
|
-
*
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
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
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
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}}
|
|
2778
|
-
* when a commit was created OR nothing needed committing
|
|
2779
|
-
* latter case); committed:false (with reason) on a real
|
|
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)
|
|
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
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4028
|
-
//
|
|
4029
|
-
//
|
|
4030
|
-
//
|
|
4031
|
-
//
|
|
4032
|
-
//
|
|
4033
|
-
//
|
|
4034
|
-
//
|
|
4035
|
-
//
|
|
4036
|
-
//
|
|
4037
|
-
//
|
|
4038
|
-
//
|
|
4039
|
-
//
|
|
4040
|
-
//
|
|
4041
|
-
//
|
|
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
|
-
|
|
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 (
|
|
4057
|
-
// session-close-scope-boundary spec §2b: a
|
|
4058
|
-
//
|
|
4059
|
-
//
|
|
4060
|
-
//
|
|
4061
|
-
//
|
|
4062
|
-
//
|
|
4063
|
-
//
|
|
4064
|
-
//
|
|
4065
|
-
//
|
|
4066
|
-
//
|
|
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
|
|
4988
|
-
* position, in the FATAL-filtered record array this
|
|
4989
|
-
* (blank lines dropped, non-object JSON values
|
|
4990
|
-
* returns early), of the most recent event
|
|
4991
|
-
* none ever did. `close-gate-store.mjs`'s
|
|
4992
|
-
* by the same definition, so the two
|
|
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
|
|
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
|
|
5103
|
-
// the gate
|
|
5104
|
-
//
|
|
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 ||
|
|
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
|
|
6987
|
+
if (!transcriptPath) return closedGateResult();
|
|
5112
6988
|
let raw;
|
|
5113
6989
|
try {
|
|
5114
6990
|
raw = readFileSync(transcriptPath, 'utf-8');
|
|
5115
6991
|
} catch {
|
|
5116
|
-
return
|
|
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
|
|
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
|
-
|
|
5191
|
-
|
|
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
|
|
5194
|
-
attributed (a peer/model enqueue wears the same shape), so no open
|
|
5195
|
-
|
|
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).
|
|
5205
|
-
// queued command (e.g. "keep working") is a
|
|
5206
|
-
//
|
|
5207
|
-
//
|
|
5208
|
-
//
|
|
5209
|
-
//
|
|
5210
|
-
//
|
|
5211
|
-
//
|
|
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
|
|
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.
|
|
5252
|
-
//
|
|
5253
|
-
//
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
//
|
|
5257
|
-
//
|
|
5258
|
-
//
|
|
5259
|
-
//
|
|
5260
|
-
//
|
|
5261
|
-
//
|
|
5262
|
-
//
|
|
5263
|
-
//
|
|
5264
|
-
//
|
|
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
|
-
//
|
|
5267
|
-
//
|
|
5268
|
-
//
|
|
5269
|
-
//
|
|
5270
|
-
//
|
|
5271
|
-
//
|
|
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
|
-
/*
|
|
5274
|
-
|
|
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
|
-
|
|
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
|
/**
|