hypomnema 1.10.0 → 1.10.2

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.
@@ -476,7 +476,7 @@ export function lastSubstantialOpIsSession() {
476
476
  // 0 — budget exhausted, or the deadline itself was not a safe
477
477
  // positive integer: do not spawn
478
478
  // >0 — a whole positive integer number of ms safe to pass as `timeout`
479
- function remainingSpawnTimeoutMs(deadline) {
479
+ export function remainingSpawnTimeoutMs(deadline) {
480
480
  if (!deadline) return undefined;
481
481
  const remaining = Math.floor(deadline.end - performance.now());
482
482
  return Number.isSafeInteger(remaining) && remaining > 0 ? remaining : 0;
@@ -504,8 +504,9 @@ function porcelainHasDirt(stdout) {
504
504
  // `opts.deadline` is optional (session-close-scope-boundary spec §5): when
505
505
  // omitted, both spawns run exactly as before, no timeout, no status/error
506
506
  // check on the second one. hypo-compact-guard.mjs is the only caller that
507
- // passes one; precompactGateStatus (`:3872`) and all six direct test call
508
- // sites still pass a bare `dir` and must see byte-identical behavior.
507
+ // passes one; the direct test call sites pass a bare `dir` and must see
508
+ // byte-identical behavior. precompactGateStatus does not call this: it reads
509
+ // dirt and ahead from one status run (gateGitState).
509
510
  export function hypoIsClean(dir = HYPO_DIR, opts = {}) {
510
511
  const { deadline } = opts;
511
512
  try {
@@ -610,8 +611,9 @@ export function hypoIsClean(dir = HYPO_DIR, opts = {}) {
610
611
  * `opts.deadline` (session-close-scope-boundary spec §5): the SAME shared
611
612
  * deadline object passed to hypoIsClean, so the two functions' spawns split
612
613
  * one budget instead of each getting their own (which would let the pair
613
- * together run twice as long as intended). Omitted, both spawns run exactly
614
- * as before — precompactGateStatus's own call (`:3874`) does not pass one.
614
+ * together run twice as long as intended). hypo-compact-guard.mjs passes one;
615
+ * omitted (the Stop hook's caller), both spawns run with no timeout.
616
+ * precompactGateStatus does not call this (gateGitState).
615
617
  */
616
618
  // Read-only status calls here pass --no-optional-locks: a plain `git status` may take
617
619
  // .git/index.lock to refresh the index, and outside the vault commit lock that collides
@@ -640,8 +642,15 @@ export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
640
642
  t2 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t2 },
641
643
  );
642
644
  if (porcelain.error || porcelain.status !== 0) return [];
645
+ return porcelainDirtyPaths(porcelain.stdout, prefix);
646
+ }
647
+
648
+ // The `dir`-relative dirty paths in `git status --porcelain -z` output with no branch header,
649
+ // for a vault whose `rev-parse --show-prefix` is `prefix`: the parser of gitDirtyFiles, shared
650
+ // with gateGitState.
651
+ function porcelainDirtyPaths(stdout, prefix) {
643
652
  const out = [];
644
- const records = (porcelain.stdout || '').split('\0');
653
+ const records = (stdout || '').split('\0');
645
654
  const toDirRelative = (f) => {
646
655
  if (!f) return null;
647
656
  if (!prefix) return f; // dir IS the repo top level, nothing to strip
@@ -666,12 +675,86 @@ export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
666
675
  return out;
667
676
  }
668
677
 
678
+ /**
679
+ * The close gate's git state from ONE `git status` run, so a big vault does not walk its tree
680
+ * three times inside the gate deadline (hypoIsClean's two runs, then gitDirtyFiles' third), and
681
+ * a first run that finished can no longer be followed by a later one that the deadline cut.
682
+ * `status --porcelain -b -uall -z` answers dirt and ahead together: its first record is the branch
683
+ * header (`## main`, or `## main...origin/main [ahead 1]`), never a file, and the rest is the
684
+ * record list the other two parse (porcelainHasDirt, porcelainDirtyPaths). `rev-parse
685
+ * --show-prefix` runs only when something is dirty, to make the paths vault-relative.
686
+ *
687
+ * @returns {{uncommitted: boolean, ahead: boolean, dirty: string[], timedOut: boolean, reason?: string}}
688
+ * `timedOut`: the deadline ran out before or during a spawn, so nothing is known (and
689
+ * `uncommitted` is true). A git failure is `uncommitted: true` with no dirty files and no time
690
+ * limit, which the gate blocks on. `dirty` is `dir`-relative, like gitDirtyFiles.
691
+ */
692
+ export function gateGitState(dir, { deadline } = {}) {
693
+ const spawn = (args) => {
694
+ const t = remainingSpawnTimeoutMs(deadline);
695
+ if (t === 0) return { timedOut: true };
696
+ const r = spawnSync(
697
+ 'git',
698
+ args,
699
+ t === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t },
700
+ );
701
+ if (r.error?.code === 'ETIMEDOUT') return { timedOut: true };
702
+ return r.error || r.status !== 0 ? { failed: true } : { stdout: r.stdout || '' };
703
+ };
704
+ const cutShort = {
705
+ uncommitted: true,
706
+ ahead: false,
707
+ dirty: [],
708
+ timedOut: true,
709
+ reason: `git check deadline exhausted in ${dir}`,
710
+ };
711
+ const failed = {
712
+ uncommitted: true,
713
+ ahead: false,
714
+ dirty: [],
715
+ timedOut: false,
716
+ reason: `git check failed in ${dir}`,
717
+ };
718
+ const status = spawn([
719
+ '--no-optional-locks',
720
+ '-C',
721
+ dir,
722
+ 'status',
723
+ '--porcelain',
724
+ '-b',
725
+ '-uall',
726
+ '-z',
727
+ ]);
728
+ if (status.timedOut) return cutShort;
729
+ if (status.failed) return failed;
730
+ const out = status.stdout;
731
+ const headerEnd = out.startsWith('## ') ? out.indexOf('\0') + 1 || out.length : 0;
732
+ // The same test as hypoIsClean's, so the two read one header the same way.
733
+ const ahead = /\[ahead \d+\]/.test(out.slice(0, headerEnd));
734
+ const body = out.slice(headerEnd);
735
+ const uncommitted = porcelainHasDirt(body);
736
+ const reasons = [];
737
+ if (uncommitted) reasons.push(`uncommitted changes in ${dir}`);
738
+ if (ahead) reasons.push(`unpushed commits in ${dir}`);
739
+ const reason = reasons.length ? reasons.join('; ') : undefined;
740
+ if (!uncommitted) return { uncommitted, ahead, dirty: [], timedOut: false, reason };
741
+ const prefixRes = spawn(['-C', dir, 'rev-parse', '--show-prefix']);
742
+ if (prefixRes.timedOut) return cutShort;
743
+ if (prefixRes.failed) return failed;
744
+ // Only the line ending is git's framing (see gitDirtyFiles).
745
+ const prefix = prefixRes.stdout.replace(/\r?\n$/, '');
746
+ return { uncommitted, ahead, dirty: porcelainDirtyPaths(body, prefix), timedOut: false, reason };
747
+ }
748
+
669
749
  // session-close-scope-boundary spec §2b/§5: the ONE path-prefix rule that
670
750
  // decides a dirty file structurally belongs to a DIFFERENT eligible project
671
751
  // than the one this call is scoped to. Extracted from precompactGateStatus's
672
752
  // former inline closure so hypo-compact-guard.mjs (spec §5) can call the
673
753
  // exact same predicate instead of duplicating it — a closure over the gate's
674
754
  // local variables cannot be called, or asserted against, from anywhere else.
755
+ // The gate itself no longer calls it: its git axis judges by the session's
756
+ // touched-paths record, so hypo-compact-guard.mjs is now the only caller (via
757
+ // classifyForeignOnlyDirty).
675
758
  //
676
759
  // Lexical, on the RAW git-porcelain path `f`, BEFORE posixPath()'s
677
760
  // unconditional `\` -> `/` conversion: a file whose actual NAME contains a
@@ -679,23 +762,12 @@ export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
679
762
  // subdirectory) must not be reinterpreted as living under `projects/other/`
680
763
  // just because posixPath() would rewrite it that way — testing the raw
681
764
  // string means it never matches the regex below and falls through to
682
- // "not foreign", the fail-closed default. `transcriptTouched` IS keyed by
683
- // posix paths (it comes from extractTouchedWikiFiles), so that one check
684
- // still normalizes `f` before the lookup.
765
+ // "not foreign", the fail-closed default.
685
766
  //
686
767
  // @param {string} f - a raw dirty path from gitDirtyFiles/git porcelain
687
- // @param {{eligibleSlugs: Set<string>|null, effectiveOverride: string|null,
688
- // transcriptTouched?: Set<string>}} ctx
768
+ // @param {{eligibleSlugs: Set<string>|null, effectiveOverride: string|null}} ctx
689
769
  // @returns {boolean}
690
- export function isForeignProjectFile(
691
- f,
692
- { eligibleSlugs, effectiveOverride, transcriptTouched = new Set() },
693
- ) {
694
- // Transcript evidence outranks the path-prefix heuristic below: it PROVES
695
- // this session edited f, whatever its path prefix says. The heuristic only
696
- // exists to cover files an untrusted (or absent) transcript could not
697
- // vouch for either way.
698
- if (transcriptTouched.has(posixPath(f))) return false;
770
+ export function isForeignProjectFile(f, { eligibleSlugs, effectiveOverride }) {
699
771
  if (!eligibleSlugs) return false;
700
772
  const m = /^projects\/([^/]+)\/.+$/.exec(f);
701
773
  if (!m) return false;
@@ -1718,6 +1790,21 @@ const vaultGit = (hypoDir, args, extra = {}) =>
1718
1790
  ...extra,
1719
1791
  });
1720
1792
 
1793
+ /**
1794
+ * Git's own words for a failed command: the first three non-empty lines of its stderr (stdout when
1795
+ * stderr is empty) on one line, at most 400 characters. A stale `.git/index.lock` is named in the
1796
+ * first of them, with the path.
1797
+ */
1798
+ function gitFailureText(run) {
1799
+ return String(run?.stderr || run?.stdout || '')
1800
+ .split('\n')
1801
+ .map((l) => l.trim())
1802
+ .filter(Boolean)
1803
+ .slice(0, 3)
1804
+ .join(' ')
1805
+ .slice(0, 400);
1806
+ }
1807
+
1721
1808
  /** `<rev>:./<relPath>`: a `git show` / `cat-file` argument resolved from the vault. */
1722
1809
  export function revPathArg(rev, relPath) {
1723
1810
  return `${rev}:./${relPath}`;
@@ -1729,12 +1816,18 @@ export function revPathArg(rev, relPath) {
1729
1816
  * repository, a broken one). A caller that would act on "absent" must not read an
1730
1817
  * error as one.
1731
1818
  */
1732
- export function headPathState(hypoDir, relPath) {
1733
- const r = vaultGit(hypoDir, ['ls-tree', '--name-only', 'HEAD', '--', relPath]);
1819
+ export function headPathState(hypoDir, relPath, { deadline } = {}) {
1820
+ // With a `deadline` every git call is cut to the time left, and no time left is an
1821
+ // `'error'` (git did not answer), never an `'absent'`.
1822
+ const git = (args) => {
1823
+ const timeout = remainingSpawnTimeoutMs(deadline);
1824
+ return timeout === 0 ? { error: true } : vaultGit(hypoDir, args, timeout ? { timeout } : {});
1825
+ };
1826
+ const r = git(['ls-tree', '--name-only', 'HEAD', '--', relPath]);
1734
1827
  if (!r.error && r.status === 0) return r.stdout.length > 0 ? 'present' : 'absent';
1735
1828
  // A repository with no commit yet (unborn HEAD) has no path in HEAD at all.
1736
- const head = vaultGit(hypoDir, ['rev-parse', '--verify', '-q', 'HEAD']);
1737
- const repo = vaultGit(hypoDir, ['rev-parse', '--git-dir']);
1829
+ const head = git(['rev-parse', '--verify', '-q', 'HEAD']);
1830
+ const repo = git(['rev-parse', '--git-dir']);
1738
1831
  return !head.error && head.status === 1 && repo.status === 0 ? 'absent' : 'error';
1739
1832
  }
1740
1833
 
@@ -1809,13 +1902,15 @@ export function listSessionProjects(hypoDir) {
1809
1902
  * `git ls-tree -r HEAD`. Filtered by `isGeneratedViewPath`, never by a pathspec (`*` crosses `/`).
1810
1903
  * Throws when git cannot answer: an empty list must always mean "nothing tracked".
1811
1904
  */
1812
- export function listTrackedGeneratedViews(hypoDir, { source = 'index' } = {}) {
1905
+ export function listTrackedGeneratedViews(hypoDir, { source = 'index', deadline } = {}) {
1813
1906
  if (source !== 'head' && source !== 'index') {
1814
1907
  throw new Error(`listTrackedGeneratedViews: source must be 'head' or 'index', got ${source}`);
1815
1908
  }
1816
1909
  const args =
1817
1910
  source === 'head' ? ['ls-tree', '-r', '-z', '--name-only', 'HEAD'] : ['ls-files', '-z'];
1818
- const r = vaultGit(hypoDir, args);
1911
+ const timeout = remainingSpawnTimeoutMs(deadline);
1912
+ if (timeout === 0) throw new Error(`git ${args[0]} not started: no time left`);
1913
+ const r = vaultGit(hypoDir, args, timeout ? { timeout } : {});
1819
1914
  if (r.status !== 0) throw new Error(`git ${args[0]} failed: ${String(r.stderr).trim()}`);
1820
1915
  return r.stdout.split('\0').filter((p) => isGeneratedViewPath(p));
1821
1916
  }
@@ -1885,22 +1980,46 @@ export function vaultHasNoGit(hypoDir) {
1885
1980
  * no `.git` entry in it or any parent (`hasGitEntry`) tracks nothing and counts as `migrated`: when
1886
1981
  * git cannot say whether the directory is a work tree (not installed, dubious ownership, a timeout,
1887
1982
  * a broken config) but the entry is there, the vault may well track views, so it is `not-migrated`.
1888
- */
1889
- export function migrationState(hypoDir) {
1890
- if (vaultGit(hypoDir, ['rev-parse', '--is-inside-work-tree']).status !== 0) {
1891
- return hasGitEntry(hypoDir) ? 'not-migrated' : 'migrated';
1892
- }
1893
- if (pathInHead(hypoDir, SESSION_ENTRIES_OFF_MARKER)) return 'opted-out';
1894
- const shown = vaultGit(hypoDir, ['show', revPathArg('HEAD', '.gitignore')]);
1895
- const missing = shown.status === 0 ? missingBlockLines(shown.stdout, GITIGNORE_BLOCK) : null;
1896
- if (missing === null) return 'not-migrated';
1897
- if (missing.length) return 'incomplete-block';
1983
+ *
1984
+ * `deadline` (`{end}` as `remainingSpawnTimeoutMs` reads it) bounds every git call by the time
1985
+ * left. A classification the deadline cut short is `'unknown'`, a fifth answer only a call with a
1986
+ * `deadline` can get: the calls that did not answer say nothing about the vault, so it is neither
1987
+ * `migrated` nor `not-migrated`.
1988
+ */
1989
+ export function migrationState(hypoDir, { deadline } = {}) {
1990
+ // With a `deadline` every git call is cut to the time left, and a call that has no time left
1991
+ // (or is cut by the timeout) is not an answer: the whole classification becomes 'unknown', never
1992
+ // a guess from a failed call. Without one, the 30 second default of `vaultGit` stands.
1993
+ const CUT = Symbol('cut');
1994
+ const git = (args) => {
1995
+ const timeout = remainingSpawnTimeoutMs(deadline);
1996
+ if (timeout === 0) throw CUT;
1997
+ const r = vaultGit(hypoDir, args, timeout ? { timeout } : {});
1998
+ if (timeout && r.error?.code === 'ETIMEDOUT') throw CUT;
1999
+ return r;
2000
+ };
1898
2001
  try {
1899
- return listTrackedGeneratedViews(hypoDir, { source: 'head' }).length === 0
1900
- ? 'migrated'
1901
- : 'not-migrated';
1902
- } catch {
1903
- return 'not-migrated';
2002
+ if (git(['rev-parse', '--is-inside-work-tree']).status !== 0) {
2003
+ return hasGitEntry(hypoDir) ? 'not-migrated' : 'migrated';
2004
+ }
2005
+ const head = headPathState(hypoDir, SESSION_ENTRIES_OFF_MARKER, { deadline });
2006
+ if (deadline && remainingSpawnTimeoutMs(deadline) === 0) throw CUT;
2007
+ if (head === 'present') return 'opted-out';
2008
+ const shown = git(['show', revPathArg('HEAD', '.gitignore')]);
2009
+ const missing = shown.status === 0 ? missingBlockLines(shown.stdout, GITIGNORE_BLOCK) : null;
2010
+ if (missing === null) return 'not-migrated';
2011
+ if (missing.length) return 'incomplete-block';
2012
+ try {
2013
+ return listTrackedGeneratedViews(hypoDir, { source: 'head', deadline }).length === 0
2014
+ ? 'migrated'
2015
+ : 'not-migrated';
2016
+ } catch (err) {
2017
+ if (deadline && remainingSpawnTimeoutMs(deadline) === 0) throw CUT;
2018
+ return 'not-migrated';
2019
+ }
2020
+ } catch (err) {
2021
+ if (err === CUT) return 'unknown';
2022
+ throw err;
1904
2023
  }
1905
2024
  }
1906
2025
 
@@ -2233,10 +2352,20 @@ function readPullArchive(hypoDir) {
2233
2352
 
2234
2353
  // Durable, and only ever called after every backup it names was written durably: nothing is
2235
2354
  // removed from the index or the working tree until this record is on disk.
2355
+ const pullArchiveText = (rec) => `${JSON.stringify(rec, null, 2)}\n`;
2236
2356
  const writePullArchive = (hypoDir, rec) =>
2237
- atomicWrite(join(hypoDir, PULL_ARCHIVE_REL), `${JSON.stringify(rec, null, 2)}\n`, {
2238
- durable: true,
2239
- });
2357
+ atomicWrite(join(hypoDir, PULL_ARCHIVE_REL), pullArchiveText(rec), { durable: true });
2358
+
2359
+ // Whether the record on disk is byte for byte `text` (null: whether there is no record). Null when
2360
+ // the record cannot be read. A write that threw says nothing about what was published (`atomicWrite`
2361
+ // renames first and syncs the directory after), so the caller asks the disk.
2362
+ function pullArchiveHolds(hypoDir, text) {
2363
+ try {
2364
+ return readTextOrNull(join(hypoDir, PULL_ARCHIVE_REL)) === text;
2365
+ } catch {
2366
+ return null;
2367
+ }
2368
+ }
2240
2369
 
2241
2370
  // Line endings as git stores them: `core.autocrlf=true` checks a file out with CRLF, so a working
2242
2371
  // tree text and the HEAD blob of the same file only match once both are read this way.
@@ -2250,18 +2379,50 @@ function mergeViews(...lists) {
2250
2379
  return [...seen.values()];
2251
2380
  }
2252
2381
 
2382
+ // The key of an entry in the set-aside lists: the path, the kind and the backup that holds it.
2383
+ const itemKey = (v) => `${v.relPath}\0${v.kind ?? 'worktree'}\0${v.backupPath}`;
2384
+
2253
2385
  // The same merge for the set-aside lists (`localOnly`, `unowned`, `cleared`, `gitignoreSaved`),
2254
2386
  // keyed by the backup, so a retry that finds an entry again does not list it twice and one that
2255
2387
  // does not find it any more (the file is already gone) does not lose it.
2256
2388
  function mergeItems(...lists) {
2257
2389
  const seen = new Map();
2258
- for (const v of lists.flat()) {
2259
- const key = `${v.relPath}\0${v.kind ?? 'worktree'}\0${v.backupPath}`;
2260
- if (!seen.has(key)) seen.set(key, v);
2261
- }
2390
+ for (const v of lists.flat()) if (!seen.has(itemKey(v))) seen.set(itemKey(v), v);
2262
2391
  return [...seen.values()];
2263
2392
  }
2264
2393
 
2394
+ // The record `rec` without what an undo took back: `undone` (the undo's own `all`, every entry it
2395
+ // put back) and `moved.unowned` (copies, never put back). An entry the earlier record
2396
+ // (`moved.priorRecord`) also holds under the same key stays: `backUpGeneratedPath` reuses a backup
2397
+ // that already holds the same bytes, so one backup path can belong to the earlier run and to this
2398
+ // one. The lines this run set aside leave, except those the earlier record carries too. `stage`
2399
+ // and `targetSha` return to the earlier record's values, so a `merged` record stays one. Null when
2400
+ // nothing is left.
2401
+ function pullArchiveWithout(rec, moved, undone) {
2402
+ const prior = moved.priorRecord ?? null;
2403
+ const without = (list, gone, earlier) => {
2404
+ const goneKeys = new Set(gone.map(itemKey));
2405
+ const earlierKeys = new Set((earlier ?? []).map(itemKey));
2406
+ return list.filter((v) => !goneKeys.has(itemKey(v)) || earlierKeys.has(itemKey(v)));
2407
+ };
2408
+ const earlierLines = prior?.gitignoreLines ?? [];
2409
+ const left = {
2410
+ v: 2,
2411
+ stage: moved.priorRecord?.stage ?? rec.stage,
2412
+ targetSha: moved.priorRecord?.targetSha ?? rec.targetSha,
2413
+ gitignoreLines: rec.gitignoreLines.filter(
2414
+ (l) => !(moved.gitignoreLines ?? []).includes(l) || earlierLines.includes(l),
2415
+ ),
2416
+ views: without(rec.views, undone, prior?.views),
2417
+ localOnly: without(rec.localOnly, undone, prior?.localOnly),
2418
+ unowned: without(rec.unowned, moved.unowned ?? [], prior?.unowned),
2419
+ cleared: without(rec.cleared, undone, prior?.cleared),
2420
+ gitignoreSaved: without(rec.gitignoreSaved, undone, prior?.gitignoreSaved),
2421
+ };
2422
+ const anything = ['gitignoreLines', 'views', 'localOnly', 'unowned', 'cleared', 'gitignoreSaved'];
2423
+ return anything.some((k) => left[k].length) ? left : null;
2424
+ }
2425
+
2265
2426
  /**
2266
2427
  * The notices for what the clearing step set aside besides the views: an ignored project's old
2267
2428
  * files (`localOnly`), a root `hot.md` nobody vouched for (`unowned`), an untracked file in a path
@@ -2334,9 +2495,16 @@ export function markPullArchiveMerged(hypoDir) {
2334
2495
  /**
2335
2496
  * The step before `merge --ff-only <targetRev>` or `pull --no-rebase` takes in a migration (or
2336
2497
  * rollback) commit: clear what would block it, without ever migrating this machine. Returns
2337
- * `{ok, deferred?, reason?, notice?, backups[], archived[], localOnly[], unowned[], cleared[], gitignoreLines[], gitignoreSaved[]}`.
2498
+ * `{ok, deferred?, reason?, notice?, backups[], archived[], localOnly[], unowned[], cleared[], gitignoreLines[], gitignoreSaved[], priorRecord, recordWritten}`.
2338
2499
  * A stopped run (`ok: false`) reports everything it had moved up to that point in the same lists,
2339
- * so the caller can take it back.
2500
+ * so the caller can take it back. A stop on a failed git command (`unstage-failed`,
2501
+ * `checkout-failed`) also carries `gitError`, the first lines of git's own message (a stale
2502
+ * `.git/index.lock` with its path), which `pullRemote` puts in its notice and the recorded failure.
2503
+ * `priorRecord` is the whole archive record this run read before it wrote its own (null when
2504
+ * there was none), and `recordWritten` is true only once this run's own write of the record returned (or threw after the disk already held its bytes): until then the
2505
+ * record on disk is the earlier run's, and the undo leaves it alone. `recordUncertain` is true when
2506
+ * that write threw and the disk holds neither its bytes nor the earlier ones; the undo then leaves
2507
+ * the record and every backup in place.
2340
2508
  *
2341
2509
  * The paths looked at are the ones `targetRev` changes since its merge-base with HEAD (so a
2342
2510
  * diverged history counts only what is coming in). A local change on any of them that is not a
@@ -2361,7 +2529,11 @@ export function markPullArchiveMerged(hypoDir) {
2361
2529
  * Before anything is removed or unstaged, everything set aside (`archived`, `localOnly`, `unowned`,
2362
2530
  * `cleared`, `gitignoreSaved`, the lines) is written to `.cache/pull-archive.json`, so a crash at
2363
2531
  * any later point leaves a record that names every backup. The lists also carry what an earlier
2364
- * run that stopped had recorded, merged by backup path.
2532
+ * run that stopped had recorded, merged by backup path. The record is touched only when this run
2533
+ * set something aside or the recovery of an earlier `archived` record leaves something for the
2534
+ * caller to take back (a record that held only `.gitignore` lines is not rewritten): the callers
2535
+ * undo only when a file was moved, so a rewrite for nothing would cost a failed attempt the
2536
+ * earlier record's `merged` stage.
2365
2537
  *
2366
2538
  * The root `hot.md` that `targetRev` deletes while it is clean is the one path nobody else
2367
2539
  * touches: its bytes are copied to `unowned[]` (`{relPath, kind, backupPath}`, not part of
@@ -2377,6 +2549,12 @@ export function markPullArchiveMerged(hypoDir) {
2377
2549
  * file with the bytes just backed up). A difference means another process wrote meanwhile: nothing
2378
2550
  * more is changed and `{ok: false, deferred: 'concurrent-change'}` comes back.
2379
2551
  *
2552
+ * Right after those bytes are read (the snapshot), a blocked path that step 3 saw unstaged but
2553
+ * whose index blob now differs from HEAD, or a blocked `.gitignore` whose bytes differ from the
2554
+ * ones step 4 judged, stops the run the same way: another process staged or edited it in the
2555
+ * window between the scan and the snapshot. `testHooks.beforeSnapshot` runs just before the
2556
+ * snapshot is read.
2557
+ *
2380
2558
  * `testHooks.beforeUnstage` runs before the first of those comparisons, `testHooks.afterArchive`
2381
2559
  * after the archive record is written and the stage is released, before the second (and before any
2382
2560
  * working-tree path is restored). `testHooks.beforeBackupWrite` goes to the backups.
@@ -2443,6 +2621,12 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2443
2621
  // Owned views removed without a backup (the generator wrote those bytes); a failed move writes
2444
2622
  // them back from here.
2445
2623
  const ownedRemoved = [];
2624
+ // True once this run's `writePullArchive` returned. Until then the record on disk is an earlier
2625
+ // run's, and the undo must not rewrite or delete it.
2626
+ let recordWritten = false;
2627
+ // True when this run's record write threw and the disk holds neither the bytes it meant nor the
2628
+ // earlier ones: whether the record was published cannot be judged.
2629
+ let recordUncertain = false;
2446
2630
  const stop = (extra) => ({
2447
2631
  ok: false,
2448
2632
  backups,
@@ -2453,6 +2637,9 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2453
2637
  gitignoreLines,
2454
2638
  gitignoreSaved,
2455
2639
  ownedRemoved,
2640
+ priorRecord: prior ?? null,
2641
+ recordWritten,
2642
+ recordUncertain,
2456
2643
  ...extra,
2457
2644
  });
2458
2645
 
@@ -2460,6 +2647,13 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2460
2647
  // archived bytes into this run. The `.gitignore` write is a restore, so it is safe before the
2461
2648
  // checks below.
2462
2649
  const prior = readPullArchive(hypoDir);
2650
+ // The earlier record's exact bytes (null: no file), what a throwing write is judged against.
2651
+ let priorRecordText;
2652
+ try {
2653
+ priorRecordText = readTextOrNull(join(hypoDir, PULL_ARCHIVE_REL));
2654
+ } catch {
2655
+ priorRecordText = undefined; // unreadable: no later read can match it
2656
+ }
2463
2657
  const recovered =
2464
2658
  prior && prior.stage === 'archived' && !isAncestorOfHead(hypoDir, prior.targetSha)
2465
2659
  ? prior
@@ -2537,13 +2731,29 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2537
2731
  if (blocked.some((p) => p !== '.gitignore' && !isGeneratedViewPath(p))) {
2538
2732
  return stop({ reason: 'other-dirty' });
2539
2733
  }
2734
+ // An intent-to-add entry (`git add -N`) reads as unstaged here (`diff --cached` leaves it out), but
2735
+ // the index already holds an empty blob for a path HEAD lacks. The late-stage check below would
2736
+ // read that as a concurrent stage on every attempt, with no writer involved, so it waits for the
2737
+ // person who made it. A real stage of a path is in `staged` and never matches.
2738
+ const intentToAdd = blocked.find(
2739
+ (p) => !staged.has(p) && !pathInHead(hypoDir, p) && gitShowText(hypoDir, ':0', p) === '',
2740
+ );
2741
+ if (intentToAdd) {
2742
+ return stop({
2743
+ deferred: 'intent-to-add',
2744
+ reason: 'intent-to-add',
2745
+ notice: `${intentToAdd}에 git add -N 으로 추가 의도만 등록해 둔 항목이 있어 이행 커밋을 받지 못했습니다. 그 파일을 커밋하거나 git restore --staged 로 등록을 풀면 다음 동기화에서 받습니다`,
2746
+ });
2747
+ }
2540
2748
 
2541
2749
  // 4. `.gitignore`: only a pure working-tree addition can be set aside. A staged one is not set
2542
2750
  // aside at all: the merge would take it out of the index and the lines come back to the working
2543
2751
  // tree only, so a person's stage boundary would be gone and the backup the notice names deleted.
2544
2752
  // It waits until they commit or unstage it. Everything below this check therefore sees a
2545
2753
  // `.gitignore` that was unstaged when step 3 looked, and keeps no stage copy of it. A stage made
2546
- // after step 3 and before the snapshot below is not caught here (a known, narrow window).
2754
+ // after step 3 and before the snapshot below is caught right after the snapshot (`lateStage`),
2755
+ // and so is an edit of the `.gitignore` after this step read it (`lateGitignoreEdit`).
2756
+ let judgedGitignoreText = null;
2547
2757
  if (staged.has('.gitignore') && blocked.includes('.gitignore')) {
2548
2758
  return stop({
2549
2759
  deferred: 'gitignore',
@@ -2555,7 +2765,8 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2555
2765
  if (blocked.includes('.gitignore')) {
2556
2766
  const headText = gitShowText(hypoDir, 'HEAD', '.gitignore') ?? '';
2557
2767
  const targetText = gitShowText(hypoDir, target, '.gitignore') ?? '';
2558
- const text = readTextOrNull(join(hypoDir, '.gitignore')) ?? '';
2768
+ judgedGitignoreText = readTextOrNull(join(hypoDir, '.gitignore'));
2769
+ const text = judgedGitignoreText ?? '';
2559
2770
  if (!mergeAdditiveGitignore(headText, text, targetText).ok) {
2560
2771
  return stop({
2561
2772
  deferred: 'gitignore',
@@ -2626,6 +2837,19 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2626
2837
  }
2627
2838
  }
2628
2839
 
2840
+ // The vault's own writers are shut out by the vault commit lock the caller holds, so a change
2841
+ // since the snapshot came from another Hypomnema process that does not take it, or a person.
2842
+ // Either way this run changes nothing more and the bytes stay where they are. ponytail: an
2843
+ // external editor can still save between this re-read and the git command right after it; that
2844
+ // window (milliseconds) is accepted, a compare-and-swap does not exist for a working tree.
2845
+ const changedSince = () => ({
2846
+ deferred: 'concurrent-change',
2847
+ reason: 'changed-during-clear',
2848
+ notice:
2849
+ '이행 커밋을 받는 도중 생성 파일이나 인덱스가 바뀌어 아무것도 더 바꾸지 않고 미뤘습니다. 다음 동기화에서 다시 시도합니다',
2850
+ });
2851
+ testHooks?.beforeSnapshot?.();
2852
+
2629
2853
  // 6. Blocked generated paths: the working-tree bytes, and the staged bytes of a staged path.
2630
2854
  // `snapshot` is what every blocked path (`.gitignore` too) held when its backup was taken: the
2631
2855
  // unstage and the restore below run only while the bytes still match it (`drifted`).
@@ -2635,6 +2859,17 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2635
2859
  { work: readTextOrNull(join(hypoDir, p)), index: gitShowText(hypoDir, ':0', p) },
2636
2860
  ]),
2637
2861
  );
2862
+ // Steps 3 and 4 judged these paths earlier. A path they saw unstaged that holds a different index
2863
+ // blob than HEAD now was staged in between, and a `.gitignore` whose bytes are not the ones step 4
2864
+ // judged was edited in between. No blocked path has been backed up or recorded yet, so stopping
2865
+ // here leaves the stage and the bytes as the other writer left them.
2866
+ const lateStage = blocked.some(
2867
+ (p) => !staged.has(p) && snapshot.get(p).index !== gitShowText(hypoDir, 'HEAD', p),
2868
+ );
2869
+ if (lateStage) return stop(changedSince());
2870
+ const lateGitignoreEdit =
2871
+ blocked.includes('.gitignore') && snapshot.get('.gitignore').work !== judgedGitignoreText;
2872
+ if (lateGitignoreEdit) return stop(changedSince());
2638
2873
  for (const path of blocked.filter(isGeneratedViewPath)) {
2639
2874
  const versions = [];
2640
2875
  const { work, index } = snapshot.get(path);
@@ -2691,29 +2926,50 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2691
2926
  }
2692
2927
  untracked.push({ path, text });
2693
2928
  }
2929
+ // What this run set aside itself, counted before the earlier run's entries are merged in.
2930
+ const thisRunSetAside = [fresh, localOnly, unowned, cleared, gitignoreSaved, gitignoreLines].some(
2931
+ (list) => list.length,
2932
+ );
2933
+ // What the recovery carries into the lists the caller counts. A record that only held
2934
+ // `.gitignore` lines was put back in step 1 and leaves nothing for the caller to undo.
2935
+ const recoveredCarries =
2936
+ recovered !== null &&
2937
+ (recovered.views.length > 0 ||
2938
+ recovered.localOnly.length > 0 ||
2939
+ recovered.unowned.length > 0 ||
2940
+ recovered.cleared.length > 0 ||
2941
+ carriedGitignore.length > 0);
2694
2942
  archived = mergeViews(recovered?.views ?? [], fresh);
2695
2943
  localOnly = mergeItems(recovered?.localOnly ?? [], localOnly);
2696
2944
  unowned = mergeItems(recovered?.unowned ?? [], unowned);
2697
2945
  cleared = mergeItems(recovered?.cleared ?? [], cleared);
2698
2946
  gitignoreSaved = mergeItems(carriedGitignore, gitignoreSaved);
2699
- const anySetAside = [archived, localOnly, unowned, cleared, gitignoreSaved, gitignoreLines].some(
2700
- (list) => list.length,
2701
- );
2702
- if (prior || anySetAside) {
2947
+ // The record is rewritten only when this run has something to set aside or to carry. The callers
2948
+ // undo only when a file was moved, so a rewrite for nothing would leave a failed attempt with a
2949
+ // record that lost the earlier run's `merged` stage.
2950
+ const touchesRecord = thisRunSetAside || recoveredCarries;
2951
+ if (touchesRecord) {
2703
2952
  cacheGate();
2953
+ const record = {
2954
+ v: 2,
2955
+ stage: 'archived',
2956
+ targetSha: target,
2957
+ gitignoreLines: [...new Set([...(prior?.gitignoreLines ?? []), ...gitignoreLines])],
2958
+ views: mergeViews(prior?.views ?? [], archived),
2959
+ localOnly: mergeItems(prior?.localOnly ?? [], localOnly),
2960
+ unowned: mergeItems(prior?.unowned ?? [], unowned),
2961
+ cleared: mergeItems(prior?.cleared ?? [], cleared),
2962
+ gitignoreSaved: mergeItems(prior?.gitignoreSaved ?? [], gitignoreSaved),
2963
+ };
2964
+ const intendedRecordText = pullArchiveText(record);
2704
2965
  try {
2705
- writePullArchive(hypoDir, {
2706
- v: 2,
2707
- stage: 'archived',
2708
- targetSha: target,
2709
- gitignoreLines: [...new Set([...(prior?.gitignoreLines ?? []), ...gitignoreLines])],
2710
- views: mergeViews(prior?.views ?? [], archived),
2711
- localOnly: mergeItems(prior?.localOnly ?? [], localOnly),
2712
- unowned: mergeItems(prior?.unowned ?? [], unowned),
2713
- cleared: mergeItems(prior?.cleared ?? [], cleared),
2714
- gitignoreSaved,
2715
- });
2966
+ writePullArchive(hypoDir, record);
2967
+ recordWritten = true;
2716
2968
  } catch {
2969
+ // A throw can come after the rename published the record (the directory sync): ask the disk.
2970
+ const heldIntended = pullArchiveHolds(hypoDir, intendedRecordText) === true;
2971
+ if (heldIntended) recordWritten = true;
2972
+ else if (pullArchiveHolds(hypoDir, priorRecordText) !== true) recordUncertain = true;
2717
2973
  return stop({ reason: 'archive-write-failed' });
2718
2974
  }
2719
2975
  // The notice naming every backup is on disk before anything is removed or unstaged: a stop at
@@ -2724,17 +2980,6 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2724
2980
  return stop({ reason: 'notice-write-failed' });
2725
2981
  }
2726
2982
  }
2727
- // The vault's own writers are shut out by the vault commit lock the caller holds, so a change
2728
- // since the snapshot came from another Hypomnema process that does not take it, or a person.
2729
- // Either way this run changes nothing more and the bytes stay where they are. ponytail: an
2730
- // external editor can still save between this re-read and the git command right after it; that
2731
- // window (milliseconds) is accepted, a compare-and-swap does not exist for a working tree.
2732
- const changedSince = () => ({
2733
- deferred: 'concurrent-change',
2734
- reason: 'changed-during-clear',
2735
- notice:
2736
- '이행 커밋을 받는 도중 생성 파일이나 인덱스가 바뀌어 아무것도 더 바꾸지 않고 미뤘습니다. 다음 동기화에서 다시 시도합니다',
2737
- });
2738
2983
  const read = (p) => {
2739
2984
  try {
2740
2985
  return readTextOrNull(join(hypoDir, p));
@@ -2751,11 +2996,11 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2751
2996
  testHooks?.beforeUnstage?.();
2752
2997
  if (drifted(false)) return stop(changedSince());
2753
2998
  const unstage = blocked.filter((p) => staged.has(p));
2754
- if (
2755
- unstage.length &&
2756
- vaultGit(hypoDir, ['reset', '-q', '--', ...literalSpecs(unstage)]).status !== 0
2757
- ) {
2758
- return stop({ reason: 'unstage-failed' });
2999
+ const unstageRun = unstage.length
3000
+ ? vaultGit(hypoDir, ['reset', '-q', '--', ...literalSpecs(unstage)])
3001
+ : null;
3002
+ if (unstageRun && unstageRun.status !== 0) {
3003
+ return stop({ reason: 'unstage-failed', gitError: gitFailureText(unstageRun) });
2759
3004
  }
2760
3005
  testHooks?.afterArchive?.();
2761
3006
  if (drifted(true)) return stop(changedSince());
@@ -2774,7 +3019,7 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2774
3019
  if (restore.length) {
2775
3020
  const r = vaultGit(hypoDir, ['checkout', 'HEAD', '--', ...literalSpecs(restore)]);
2776
3021
  if (r.status !== 0) {
2777
- return stop({ reason: 'checkout-failed' });
3022
+ return stop({ reason: 'checkout-failed', gitError: gitFailureText(r) });
2778
3023
  }
2779
3024
  }
2780
3025
  return {
@@ -2787,6 +3032,9 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2787
3032
  gitignoreLines,
2788
3033
  gitignoreSaved,
2789
3034
  ownedRemoved,
3035
+ priorRecord: prior ?? null,
3036
+ recordWritten,
3037
+ recordUncertain,
2790
3038
  };
2791
3039
  }
2792
3040
 
@@ -2798,9 +3046,20 @@ function clearBlockingPaths(hypoDir, targetRev, opts) {
2798
3046
  * staged one is put back in the index only when the index entry still equals HEAD's
2799
3047
  * (`hash-object -w`, then `update-index --cacheinfo`, with the vault's prefix in front). The
2800
3048
  * backup bytes are written as they are. When every path is back, the backup files made for them
2801
- * (and for `unowned[]`) and `.cache/pull-archive.json` (when anything was set aside) are deleted;
2802
- * otherwise those stay for the next catch-up to carry. `keepRecord` leaves the record in place (the
2803
- * clearing step uses it to take back a stopped run's `.gitignore` bytes while that record lives on).
3049
+ * (and for `unowned[]`) are deleted, and `.cache/pull-archive.json` loses the entries this run took
3050
+ * back (`pullArchiveWithout`; deleted when nothing is left); otherwise those stay for the next
3051
+ * catch-up to carry. The order is record, then the notice lines, then the backups: a stop in
3052
+ * between leaves a backup nobody names, never a record naming a backup that is gone. When the
3053
+ * record cannot be rewritten (the disk does not hold the bytes the rewrite meant, `pullArchiveHolds`;
3054
+ * a throw after the rename published them counts as written), the result is `ok: false` with a
3055
+ * notice naming the record, and the record, the notice lines and every backup stay. The callers
3056
+ * (`fastForwardTo`, `pullRemote`) read only `.notices`, so that sentence is the user's only signal;
3057
+ * `fastForwardTo` appends it to its `notice`. The record is
3058
+ * touched only when `moved.recordWritten` says this run wrote it; an earlier run's record is left
3059
+ * byte for byte. A backup the record on disk or `moved.priorRecord` still names is never deleted
3060
+ * (a run that finds the same bytes already backed up reuses that file, so the earlier run and this
3061
+ * one can share it). `keepRecord` leaves the record in place (the clearing step uses it to take
3062
+ * back a stopped run's `.gitignore` bytes while that record lives on).
2804
3063
  * `moved.gitignoreSaved[]` is the exact `.gitignore` bytes, put back with the same guards as a
2805
3064
  * view: a working tree that is absent or at HEAD, an index entry that is at HEAD. A `.gitignore`
2806
3065
  * written since is left alone, with a notice naming the backup.
@@ -2896,26 +3155,90 @@ export function undoClearedPaths(hypoDir, moved, { keepRecord = false } = {}) {
2896
3155
  );
2897
3156
  }
2898
3157
  }
3158
+ // The record may or may not be the one this run meant to write: it stays as it is, the notice
3159
+ // lines stay and no backup is deleted. The callers read only `.notices`, so this sentence is what
3160
+ // reaches the user.
3161
+ if (moved.recordUncertain) {
3162
+ return {
3163
+ ok: false,
3164
+ notices: [
3165
+ ...notices,
3166
+ `보관 기록(${PULL_ARCHIVE_REL})이 게시됐는지 판정하지 못해 기록과 백업 파일을 모두 그대로 남겼습니다. 기록이 가리키는 항목을 확인한 뒤 정리하세요.`,
3167
+ ],
3168
+ };
3169
+ }
2899
3170
  if (allBack) {
2900
- // Every path is back where it was, so the lines naming the backups are retracted before the
2901
- // backups go. A stop in between leaves a notice for a backup that is still there.
2902
- if (!keepRecord) {
2903
- retractRootHotHealthNotice(hypoDir, clearingNoticeLines(hypoDir, moved).join('\n'));
2904
- }
2905
3171
  // Each deletion is synced before the next step relies on it: a power loss that replays the
2906
- // backup removal but not the record removal would leave a record naming a backup that is gone.
3172
+ // backup removal but not the record change would leave a record naming a backup that is gone.
2907
3173
  const syncDir = (dir) => {
2908
3174
  if (existsSync(dir)) fsyncDir(dir);
2909
3175
  };
2910
3176
  // The record goes first: a stop in between leaves a backup nobody names, not a record that
2911
- // names a backup that is gone.
2912
- if (!keepRecord && all.length + (moved.unowned ?? []).length > 0) {
3177
+ // names a backup that is gone. Only a record this run wrote is touched; one written by an
3178
+ // earlier run stays as it is. What this run took back leaves the record, what an earlier run
3179
+ // left in it stays, and a record with nothing left is deleted.
3180
+ let rewriteFailed = false;
3181
+ if (moved.recordWritten && !keepRecord && all.length + (moved.unowned ?? []).length > 0) {
2913
3182
  const record = join(hypoDir, PULL_ARCHIVE_REL);
2914
- rmSync(record, { force: true });
2915
- syncDir(dirname(record));
3183
+ const rec = readPullArchive(hypoDir);
3184
+ const rest = rec && pullArchiveWithout(rec, moved, all);
3185
+ const intendedRecordText = rest ? pullArchiveText(rest) : null;
3186
+ // A record that is there but unreadable (read error, wrong shape) is not one with nothing left.
3187
+ rewriteFailed = rec === null && existsSync(record);
3188
+ try {
3189
+ if (rest) writePullArchive(hypoDir, rest);
3190
+ else if (!rewriteFailed) rmSync(record, { force: true });
3191
+ } catch {
3192
+ // A throw after the rename still left the intended bytes on disk: go on as written.
3193
+ const published = pullArchiveHolds(hypoDir, intendedRecordText) === true;
3194
+ rewriteFailed = !published;
3195
+ }
3196
+ // The disk showing the intended bytes is not durable: a power loss can replay the old record
3197
+ // while the backups below are gone. The change counts as done only once the directory sync
3198
+ // succeeded; a sync that throws keeps the record, the notice lines and every backup.
3199
+ if (!rewriteFailed) {
3200
+ try {
3201
+ syncDir(dirname(record));
3202
+ } catch {
3203
+ rewriteFailed = true;
3204
+ }
3205
+ }
3206
+ }
3207
+ // The record is in an unknown state (the disk holds neither the bytes this run meant to write
3208
+ // nor, necessarily, the earlier ones): it stays as it is, the notice lines stay (they name the
3209
+ // backups) and no backup is deleted. The callers read only `.notices` of this result, so this
3210
+ // sentence is what reaches the user.
3211
+ if (rewriteFailed) {
3212
+ return {
3213
+ ok: false,
3214
+ notices: [
3215
+ ...notices,
3216
+ `보관 기록(${PULL_ARCHIVE_REL})을 다시 쓰지 못해 기록과 백업 파일을 모두 그대로 남겼습니다. 기록이 가리키는 항목을 확인한 뒤 정리하세요.`,
3217
+ ],
3218
+ };
2916
3219
  }
3220
+ // Every path is back where it was, so the lines naming the backups are retracted before the
3221
+ // backups go. A stop in between leaves a notice for a backup that is still there.
3222
+ if (!keepRecord) {
3223
+ retractRootHotHealthNotice(hypoDir, clearingNoticeLines(hypoDir, moved).join('\n'));
3224
+ }
3225
+ // A backup a record still names is not deleted: the earlier run's record, or this run's own
3226
+ // when it reused that backup, or the record `keepRecord` leaves in place.
3227
+ const backupPathsOf = (rec) =>
3228
+ rec
3229
+ ? [rec.views, rec.localOnly, rec.unowned, rec.cleared, rec.gitignoreSaved].flatMap((list) =>
3230
+ (list ?? []).map((v) => v.backupPath),
3231
+ )
3232
+ : [];
3233
+ const referencedBackups = new Set([
3234
+ ...backupPathsOf(readPullArchive(hypoDir)),
3235
+ ...backupPathsOf(moved.priorRecord),
3236
+ ]);
2917
3237
  const backups = [...all, ...(moved.unowned ?? [])].map((v) => v.backupPath);
2918
- for (const backupPath of backups) rmSync(backupPath, { force: true });
3238
+ for (const backupPath of backups) {
3239
+ const deletable = !referencedBackups.has(backupPath);
3240
+ if (deletable) rmSync(backupPath, { force: true });
3241
+ }
2919
3242
  for (const dir of new Set(backups.map((b) => dirname(b)))) syncDir(dir);
2920
3243
  }
2921
3244
  return { ok: allBack, notices };
@@ -3519,7 +3842,7 @@ function entryMatchesPath(text, relPath) {
3519
3842
  // already in HEAD and since modified is not new, and git failing to answer is not
3520
3843
  // "absent": both stay blocking. The file must also be the entry its path names
3521
3844
  // (entryMatchesPath).
3522
- export function isForeignUncommittedEntry(hypoDir, relPath, sessionId) {
3845
+ export function isForeignUncommittedEntry(hypoDir, relPath, sessionId, { deadline } = {}) {
3523
3846
  if (!isValidSessionId(sessionId) || !isSessionEntryPath(relPath)) return false;
3524
3847
  const m = /^projects\/[^/]+\/sessions\/\d{4}-\d{2}-\d{2}-(.+-\d+)\.md$/.exec(relPath);
3525
3848
  if (!m) return false;
@@ -3532,7 +3855,7 @@ export function isForeignUncommittedEntry(hypoDir, relPath, sessionId) {
3532
3855
  return false;
3533
3856
  }
3534
3857
  if (!entryMatchesPath(text, relPath)) return false;
3535
- return headPathState(hypoDir, relPath) === 'absent';
3858
+ return headPathState(hypoDir, relPath, { deadline }) === 'absent';
3536
3859
  }
3537
3860
 
3538
3861
  const FOREIGN_ENTRY_NOTICE = (f) =>
@@ -3656,9 +3979,9 @@ function foreignShardAppends(hypoDir, relPath, sessionId) {
3656
3979
  // of those appends (a staged edit or deletion). A shard this session recorded counts
3657
3980
  // only when the bytes it recorded are the ones HEAD holds (its own write committed),
3658
3981
  // the same retirement commitTouchedPaths applies.
3659
- export function isForeignAppendOnlyShard(hypoDir, relPath, sessionId) {
3982
+ export function isForeignAppendOnlyShard(hypoDir, relPath, sessionId, { deadline } = {}) {
3660
3983
  if (!isValidSessionId(sessionId) || !CLOSE_FILE_RE.test(relPath)) return false;
3661
- const head = headBlob(hypoDir, relPath);
3984
+ const head = headBlob(hypoDir, relPath, { deadline });
3662
3985
  if (!head) return false;
3663
3986
  let disk;
3664
3987
  try {
@@ -3682,7 +4005,7 @@ export function isForeignAppendOnlyShard(hypoDir, relPath, sessionId) {
3682
4005
  pos += r.addedLen;
3683
4006
  ends.push(pos);
3684
4007
  }
3685
- const touched = readTouchedPathsStrict(hypoDir, sessionId);
4008
+ const touched = readTouchedPathsStrict(hypoDir, sessionId, touchedLockBudget(deadline));
3686
4009
  if (touched.state === 'locked' || touched.state === 'unreadable') return false;
3687
4010
  // The bytes this session recorded writing, when it wrote this shard. They must be
3688
4011
  // HEAD's, or HEAD's up to a line end: a pathspec commit (`--only`) reads each path
@@ -3691,7 +4014,7 @@ export function isForeignAppendOnlyShard(hypoDir, relPath, sessionId) {
3691
4014
  let own = null;
3692
4015
  if (touched.paths.map(posixPath).includes(relPath)) {
3693
4016
  const hashes = readTouchedHashesFile(touchedHashesPath(hypoDir, sessionId));
3694
- if (!Object.hasOwn(hashes, relPath)) return false;
4017
+ if (!hashes || !Object.hasOwn(hashes, relPath)) return false;
3695
4018
  own = hashes[relPath];
3696
4019
  let committed = sha256Hex(head) === own;
3697
4020
  // One hash per line end: a day's shard is a few hundred lines at most.
@@ -3702,7 +4025,7 @@ export function isForeignAppendOnlyShard(hypoDir, relPath, sessionId) {
3702
4025
  }
3703
4026
  // The index: HEAD, HEAD plus recorded appends another close is staging right now,
3704
4027
  // or (the same race) the shorter bytes this session's committed write left there.
3705
- const index = headBlob(hypoDir, relPath, { index: true });
4028
+ const index = headBlob(hypoDir, relPath, { index: true, deadline });
3706
4029
  if (!index) return false;
3707
4030
  if (ends.some((e) => e === index.length && disk.subarray(0, e).equals(index))) return true;
3708
4031
  return own !== null && sha256Hex(index) === own;
@@ -3767,6 +4090,9 @@ function checkCloseLogs(hypoDir, project, dates, stale, missing) {
3767
4090
  /** Notice for a project whose session-log shows a close today but whose `sessions/` has no entry for it. */
3768
4091
  export const LEGACY_CLOSE_NOTICE_TYPE = 'close-legacy';
3769
4092
 
4093
+ /** The same situation when the gate deadline ran out before the vault could be classified: not a legacy diagnosis, only a notice. */
4094
+ export const LEGACY_CLOSE_UNCLASSIFIED_NOTICE_TYPE = 'close-legacy-unclassified';
4095
+
3770
4096
  /**
3771
4097
  * Notice, on a session-scope check, that the project has no session-log heading or log.md line
3772
4098
  * under the entry's date. It is a notice and not a gap: the heading and line carry no close id,
@@ -3808,11 +4134,16 @@ export const CLOSE_LOGS_MISSING_NOTICE_TYPE = 'close-logs-missing';
3808
4134
  * `projectOverride`: the project to check; when omitted, the active project
3809
4135
  * resolved from root hot.md (recency).
3810
4136
  *
4137
+ * `deadline` (optional, `{end}` as `remainingSpawnTimeoutMs` reads it): the budget for the git
4138
+ * read of the entry's committed blob. When it runs out before git answers, the result carries
4139
+ * `incomplete: 'deadline'` and `ok: false`, with neither `stale` nor `missing` naming the entry:
4140
+ * the proof was not finished, which is not the same as the close having failed.
4141
+ *
3811
4142
  * @param {string} hypoDir
3812
- * @param {{scope: 'session'|'global', closeId?: string|null, projectOverride?: string|null}} opts
4143
+ * @param {{scope: 'session'|'global', closeId?: string|null, projectOverride?: string|null, deadline?: {end: number}}} opts
3813
4144
  * @returns {{ok: boolean, project: string|null, dates: string[], stale: string[], missing: string[],
3814
4145
  * sessionLogEvidence: {path: string, date: string}|null, entry: string|null,
3815
- * notices: {type: string, project: string, reason: string}[]}}
4146
+ * notices: {type: string, project: string, reason: string}[], incomplete?: 'deadline'}}
3816
4147
  */
3817
4148
  export function sessionCloseFileStatus(hypoDir, opts) {
3818
4149
  const scope = opts?.scope;
@@ -3842,6 +4173,7 @@ export function sessionCloseFileStatus(hypoDir, opts) {
3842
4173
  let dates = freshDates();
3843
4174
  let entry = null;
3844
4175
  let sessionLogEvidence = null;
4176
+ let incomplete = null;
3845
4177
 
3846
4178
  if (scope === 'session') {
3847
4179
  const closeId = opts.closeId;
@@ -3872,8 +4204,9 @@ export function sessionCloseFileStatus(hypoDir, opts) {
3872
4204
  stale.push(entry);
3873
4205
  }
3874
4206
  } else {
3875
- const blob = headBlob(hypoDir, entry);
3876
- if (!blob || !entryMatchesPath(blob.toString('utf-8'), entry)) stale.push(entry);
4207
+ const blob = headBlob(hypoDir, entry, { deadline: opts.deadline });
4208
+ if (!blob && remainingSpawnTimeoutMs(opts.deadline) === 0) incomplete = 'deadline';
4209
+ else if (!blob || !entryMatchesPath(blob.toString('utf-8'), entry)) stale.push(entry);
3877
4210
  }
3878
4211
  const logStale = [];
3879
4212
  const logMissing = [];
@@ -3897,7 +4230,8 @@ export function sessionCloseFileStatus(hypoDir, opts) {
3897
4230
  sessionLogEvidence = checkCloseLogs(hypoDir, project, dates, stale, missing);
3898
4231
  if (!entry) {
3899
4232
  if (sessionLogEvidence) {
3900
- if (migrationState(hypoDir) === 'migrated') {
4233
+ const migration = migrationState(hypoDir, { deadline: opts.deadline });
4234
+ if (migration === 'migrated') {
3901
4235
  notices.push({
3902
4236
  type: LEGACY_CLOSE_NOTICE_TYPE,
3903
4237
  project,
@@ -3905,6 +4239,15 @@ export function sessionCloseFileStatus(hypoDir, opts) {
3905
4239
  `${project}: today's session-log has a close heading but projects/${project}/sessions/ ` +
3906
4240
  `has no entry dated today. 구버전 Hypomnema나 손으로 쓴 close로 보입니다. 그 기계를 업그레이드하세요.`,
3907
4241
  });
4242
+ } else if (migration === 'unknown') {
4243
+ notices.push({
4244
+ type: LEGACY_CLOSE_UNCLASSIFIED_NOTICE_TYPE,
4245
+ project,
4246
+ reason:
4247
+ `${project}: today's session-log has a close heading but projects/${project}/sessions/ ` +
4248
+ `has no entry dated today, and whether this vault already uses session entries was not ` +
4249
+ `decided within the time limit (판정이 시간 안에 끝나지 않았다). 구버전 close인지는 다시 확인하면 됩니다.`,
4250
+ });
3908
4251
  }
3909
4252
  } else {
3910
4253
  missing.push(`projects/${project}/sessions/${dates[0]}-<close id>.md`);
@@ -3913,7 +4256,7 @@ export function sessionCloseFileStatus(hypoDir, opts) {
3913
4256
  }
3914
4257
 
3915
4258
  return {
3916
- ok: stale.length === 0 && missing.length === 0,
4259
+ ok: stale.length === 0 && missing.length === 0 && !incomplete,
3917
4260
  project,
3918
4261
  dates,
3919
4262
  stale,
@@ -3921,6 +4264,7 @@ export function sessionCloseFileStatus(hypoDir, opts) {
3921
4264
  sessionLogEvidence,
3922
4265
  entry,
3923
4266
  notices,
4267
+ ...(incomplete ? { incomplete } : {}),
3924
4268
  };
3925
4269
  }
3926
4270
 
@@ -4070,6 +4414,9 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
4070
4414
  * aliases need no project prefix and a single-project session is byte-identical
4071
4415
  * to sessionCloseFileStatus (back-compat for the flat-field readers).
4072
4416
  *
4417
+ * `opts.deadline` (`{end}`) bounds the migration classification a legacy-close notice needs: one
4418
+ * the deadline cut short is a `close-legacy-unclassified` notice, never a blocker.
4419
+ *
4073
4420
  * @param {string} hypoDir
4074
4421
  * @returns {{ok: boolean, projects: Array<{project:string, ok:boolean, stale:string[], missing:string[]}>,
4075
4422
  * dates: string[], fallback: boolean, primary: string|null,
@@ -4097,6 +4444,7 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
4097
4444
  const s = sessionCloseFileStatus(hypoDir, {
4098
4445
  scope: 'global',
4099
4446
  projectOverride: opts.projectOverride,
4447
+ deadline: opts.deadline,
4100
4448
  });
4101
4449
  return {
4102
4450
  ok: s.ok,
@@ -4145,7 +4493,7 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
4145
4493
  // `verified_scope` names the recency project instead of the requested one is
4146
4494
  // unreachable, not merely fail-safe — do not write code defending that state.
4147
4495
  if (activeCandidates.length === 0) {
4148
- const legacy = sessionCloseFileStatus(hypoDir, { scope: 'global' });
4496
+ const legacy = sessionCloseFileStatus(hypoDir, { scope: 'global', deadline: opts.deadline });
4149
4497
  return {
4150
4498
  ok: legacy.ok,
4151
4499
  projects: legacy.project
@@ -4174,7 +4522,11 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
4174
4522
 
4175
4523
  const notices = [];
4176
4524
  const projects = ordered.map((p) => {
4177
- const s = sessionCloseFileStatus(hypoDir, { scope: 'global', projectOverride: p });
4525
+ const s = sessionCloseFileStatus(hypoDir, {
4526
+ scope: 'global',
4527
+ projectOverride: p,
4528
+ deadline: opts.deadline,
4529
+ });
4178
4530
  notices.push(...s.notices);
4179
4531
  return { project: p, ok: s.ok, stale: s.stale, missing: s.missing };
4180
4532
  });
@@ -5361,11 +5713,12 @@ export function pullRemote(hypoDir, { testHooks, leaveNotices = true } = {}) {
5361
5713
  };
5362
5714
  // A deferral skips the pull on purpose; it is recorded like a failed pull so doctor shows it, and
5363
5715
  // so the last record names the cause even when the deferral came without a notice.
5364
- const defer = (why, note) => {
5365
- appendSyncFailure(hypoDir, 'pull', note || `pull deferred: ${why}`);
5716
+ const defer = (why, note, recorded) => {
5717
+ appendSyncFailure(hypoDir, 'pull', recorded || note || `pull deferred: ${why}`);
5366
5718
  return done({ pulled: false, conflict: false, deferred: why });
5367
5719
  };
5368
- // What the clearing step moved. A pull that does not land takes it back.
5720
+ // What the clearing step moved. A pull that does not land takes it back. Returns whether anything
5721
+ // was moved (so the take-back ran).
5369
5722
  let pre = null;
5370
5723
  const takeBack = () => {
5371
5724
  const moved = pre
@@ -5378,6 +5731,7 @@ export function pullRemote(hypoDir, { testHooks, leaveNotices = true } = {}) {
5378
5731
  : 0;
5379
5732
  if (moved > 0) notices.push(...undoClearedPaths(hypoDir, pre).notices);
5380
5733
  pre = null;
5734
+ return moved > 0;
5381
5735
  };
5382
5736
  try {
5383
5737
  const owned = finishOwnedMerge(hypoDir);
@@ -5404,14 +5758,29 @@ export function pullRemote(hypoDir, { testHooks, leaveNotices = true } = {}) {
5404
5758
  if (canCatchUp) {
5405
5759
  pre = clearGeneratedPathsBlockingPull(hypoDir, '@{u}', { testHooks });
5406
5760
  if (!pre.ok) {
5407
- const { deferred, notice } = pre;
5408
- takeBack();
5761
+ const { deferred, notice, reason, gitError } = pre;
5762
+ const tookBack = takeBack();
5409
5763
  if (deferred) {
5410
5764
  if (notice) notices.push(notice);
5411
5765
  return defer(deferred, notice);
5412
5766
  }
5413
- // Any other stop (another file is dirty, a revision git cannot read): pull anyway and let
5414
- // git report its own failure.
5767
+ // A stop after bytes were moved and put back (the record could not be written, an unstage
5768
+ // or checkout failed): the take-back may leave an ignored file that the merge would
5769
+ // overwrite without a word, with its backup and record gone. Merge nothing; the next pull
5770
+ // runs the clearing step again.
5771
+ if (tookBack) {
5772
+ const note =
5773
+ `이행 커밋을 받으려고 옛 세션 현황을 보관하던 중 멈춰(${reason}) 보관한 내용을 되돌리고 병합하지 않았습니다. 다음 동기화에서 다시 시도합니다` +
5774
+ (gitError
5775
+ ? `. git의 말: ${gitError}. git이 잠금 파일(.lock)을 가리키는데 실행 중인 git 프로세스가 없다면, 그 파일을 직접 지운 뒤 다음 동기화를 기다리면 됩니다`
5776
+ : '');
5777
+ notices.push(note);
5778
+ // The record keeps git's own line: retrying cannot fix a stale lock, and the one-line
5779
+ // summary of a long note would cut it off.
5780
+ return defer('clear-failed', note, gitError ? `${reason}: ${gitError}` : undefined);
5781
+ }
5782
+ // Any other stop (another file is dirty, a revision git cannot read) moved nothing: pull
5783
+ // anyway and let git report its own failure.
5415
5784
  } else if (pre.backups.length) {
5416
5785
  const where = [...new Set(pre.backups.map(backupPathForNotice))].join(', ');
5417
5786
  notices.push(`이행 커밋을 받으려고 이 기계의 옛 세션 현황 변경을 ${where}에 보관했습니다.`);
@@ -5685,6 +6054,262 @@ export function touchedPathsPath(hypoDir, sessionId) {
5685
6054
  return join(sessionCacheDir(hypoDir, sessionId), 'touched-paths.json');
5686
6055
  }
5687
6056
 
6057
+ // ── record stamp (which sessions began on a version that records every write) ────────────────
6058
+ // A session on an older version can keep running its old hooks, which fail to record a write
6059
+ // without leaving a flag, and it can reload into this version later. Its record is then empty or
6060
+ // short with nothing to say so, and a gate that trusts "the record is complete" would let its
6061
+ // dirty file through. Only a session that STARTED on a version that records completely is
6062
+ // trusted: SessionStart writes the stamp once per session id, and never for an id that already
6063
+ // has a base snapshot or record on disk (it began under an older version).
6064
+ const RECORD_STAMP_SCHEMA = 1;
6065
+
6066
+ /** @returns {string} path to a session's record stamp. */
6067
+ export function sessionRecordStampPath(hypoDir, sessionId) {
6068
+ return join(sessionCacheDir(hypoDir, sessionId), 'record-stamp.json');
6069
+ }
6070
+
6071
+ /** Whether the session began on a version that records every write (it has a record stamp). */
6072
+ export function sessionRecordStamped(hypoDir, sessionId) {
6073
+ return readRecordStamp(hypoDir, sessionId) === true;
6074
+ }
6075
+
6076
+ /** A session's stamp: `true` stamped, `false` no stamp (absent, or a schema this version does not
6077
+ * vouch for), `null` a stamp file that exists but cannot be read. */
6078
+ function readRecordStamp(hypoDir, sessionId) {
6079
+ let text;
6080
+ try {
6081
+ text = readFileSync(sessionRecordStampPath(hypoDir, sessionId), 'utf-8');
6082
+ } catch (err) {
6083
+ return err?.code === 'ENOENT' ? false : null;
6084
+ }
6085
+ try {
6086
+ const parsed = JSON.parse(text);
6087
+ return Number.isInteger(parsed?.schema) && parsed.schema >= RECORD_STAMP_SCHEMA;
6088
+ } catch {
6089
+ return null;
6090
+ }
6091
+ }
6092
+
6093
+ /**
6094
+ * Stamp a session as complete-record, from SessionStart before anything else of the session is
6095
+ * written. Not stamped when the id is invalid, or when the session already has a base snapshot, a
6096
+ * touched-paths set or hashes, or a failure flag: it began under an older version. Best-effort
6097
+ * (never throws); a session that fails to get its stamp only keeps the stricter gate.
6098
+ *
6099
+ * @returns {'stamped'|'already-stamped'|'older-session'|'invalid-session'|'failed'}
6100
+ */
6101
+ export function stampSessionRecord(hypoDir, sessionId) {
6102
+ if (!isValidSessionId(sessionId)) return 'invalid-session';
6103
+ try {
6104
+ if (sessionRecordStamped(hypoDir, sessionId)) return 'already-stamped';
6105
+ const dir = sessionCacheDir(hypoDir, sessionId);
6106
+ const earlier = [
6107
+ 'base.json',
6108
+ 'touched-paths.json',
6109
+ 'touched-hashes.json',
6110
+ 'touched-paths-failed.d',
6111
+ ];
6112
+ if (earlier.some((name) => existsSync(join(dir, name)))) return 'older-session';
6113
+ atomicWrite(
6114
+ sessionRecordStampPath(hypoDir, sessionId),
6115
+ JSON.stringify({ v: 1, schema: RECORD_STAMP_SCHEMA }),
6116
+ );
6117
+ return 'stamped';
6118
+ } catch {
6119
+ return 'failed';
6120
+ }
6121
+ }
6122
+
6123
+ // How much of a caller's time budget `recordTouchedPaths` leaves for writing the failure flag, so a
6124
+ // record that waited out its lock still has time to say it failed.
6125
+ const TOUCHED_FLAG_SHARE_MS = 1000;
6126
+
6127
+ /**
6128
+ * HEAD's state for one vault path, for the empty-index hint: `{state: 'present', sha256}`,
6129
+ * `{state: 'absent'}` (HEAD has no such path, or no commit yet) or `{state: 'error'}` when git could
6130
+ * not answer (including no time left on `deadline`, or a blob that will not read). Built on
6131
+ * `headPathState`, which keeps absent and error apart; `headBlob` alone returns `null` for both.
6132
+ */
6133
+ function headBlobState(hypoDir, relPath, { deadline } = {}) {
6134
+ const state = headPathState(hypoDir, relPath, { deadline });
6135
+ if (state !== 'present') return { state };
6136
+ const blob = headBlob(hypoDir, relPath, { deadline });
6137
+ return blob ? { state: 'present', sha256: sha256Hex(blob) } : { state: 'error' };
6138
+ }
6139
+
6140
+ /**
6141
+ * True for a zero-byte `projects/<slug>/index.md` that HEAD does not have: the shape a close leaves
6142
+ * when it dies between creating the project index and writing its seed. `headBlobState` answering
6143
+ * `error` gives no hint, since "not in HEAD" is then unknown.
6144
+ */
6145
+ function emptyNewIndexHint(hypoDir, relPath, { deadline } = {}) {
6146
+ if (!/^projects\/[^/]+\/index\.md$/.test(relPath)) return false;
6147
+ try {
6148
+ if (statSync(join(hypoDir, relPath)).size !== 0) return false;
6149
+ } catch {
6150
+ return false;
6151
+ }
6152
+ return headBlobState(hypoDir, relPath, { deadline }).state === 'absent';
6153
+ }
6154
+
6155
+ // The failure flag is a set of immutable attempt files, one per failed record, in
6156
+ // `touched-paths-failed.d/`. A single file updated by read-modify-replace lost a path when two
6157
+ // writers raced (each read the same prior flag, added its own path, and replaced the file), and
6158
+ // the flag exists exactly for the moments a lock does not hold, so it cannot take one. A writer
6159
+ // only ever adds a file with a unique name, and nothing in this module rewrites or removes one: the
6160
+ // directory goes only when a person removes it. The reader takes the union of the paths. Order
6161
+ // between attempt files means nothing (a clock set back, or two attempts in one millisecond, can
6162
+ // sort them either way), so nothing is judged by which attempt comes last.
6163
+ const FAILED_ATTEMPT_ORDER_WIDTH = 15;
6164
+ let failedAttemptSeq = 0;
6165
+
6166
+ /** @returns {string} directory of a session's failure-flag attempt files. */
6167
+ export function touchedPathsFailedDir(hypoDir, sessionId) {
6168
+ return join(sessionCacheDir(hypoDir, sessionId), 'touched-paths-failed.d');
6169
+ }
6170
+
6171
+ /** An attempt file's name: a fixed-width time first, so the names sort oldest to newest, then the
6172
+ * pid and a per-process counter, so two attempts in one millisecond never share a name. */
6173
+ function failedAttemptName() {
6174
+ const order = String(Date.now()).padStart(FAILED_ATTEMPT_ORDER_WIDTH, '0');
6175
+ failedAttemptSeq += 1;
6176
+ return `${order}-${process.pid}-${failedAttemptSeq}-${randomBytes(4).toString('hex')}.json`;
6177
+ }
6178
+
6179
+ /** One flag file: `{entries, pathsUnknown, reasons}`, `null` when it cannot be read as one, and
6180
+ * `undefined` when it is gone (a person removed it between the listing and the read). */
6181
+ function parseFailedFlagFile(path) {
6182
+ try {
6183
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
6184
+ const entries = parsed?.entries;
6185
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return null;
6186
+ return {
6187
+ entries,
6188
+ pathsUnknown: parsed.pathsUnknown === true,
6189
+ reasons: Array.isArray(parsed.reasons)
6190
+ ? parsed.reasons.filter((r) => typeof r === 'string')
6191
+ : [],
6192
+ };
6193
+ } catch (err) {
6194
+ return err?.code === 'ENOENT' ? undefined : null;
6195
+ }
6196
+ }
6197
+
6198
+ /** Every entry of a session's flag directory, sorted by name, as `{path, parsed}` (`path` null for
6199
+ * a directory that cannot be listed, which reads as an unreadable flag; `parsed` undefined for an
6200
+ * entry that is not a `.json` attempt). */
6201
+ function failedFlagSources(hypoDir, sessionId) {
6202
+ const sources = [];
6203
+ const dir = touchedPathsFailedDir(hypoDir, sessionId);
6204
+ let names = [];
6205
+ try {
6206
+ names = readdirSync(dir).sort();
6207
+ } catch (err) {
6208
+ if (err?.code !== 'ENOENT') sources.push({ path: null, parsed: null });
6209
+ }
6210
+ for (const name of names) {
6211
+ const path = join(dir, name);
6212
+ // A temp attempt (`<attempt>.json.<pid>.<random>.tmp`) is what a writer killed before its rename
6213
+ // leaves. It counts as a flag (`any`) but names no path and is not parsed.
6214
+ if (!name.endsWith('.json')) {
6215
+ sources.push({ path, parsed: undefined });
6216
+ continue;
6217
+ }
6218
+ const parsed = parseFailedFlagFile(path);
6219
+ if (parsed !== undefined) sources.push({ path, parsed });
6220
+ }
6221
+ return sources;
6222
+ }
6223
+
6224
+ /** The union of a session's flag files: `any` (an attempt file exists, readable or not), `paths`
6225
+ * (every path any readable one names, sorted), `pathsUnknown`, `unreadable` (some file or the
6226
+ * directory could not be read), `entries` and `reasons` (see readTouchedPathsFailed). */
6227
+ function mergeFailedFlags(hypoDir, sessionId) {
6228
+ const sources = failedFlagSources(hypoDir, sessionId);
6229
+ const paths = new Set();
6230
+ const out = { any: sources.length > 0, pathsUnknown: false, unreadable: false };
6231
+ out.entries = {};
6232
+ out.reasons = [];
6233
+ for (const { parsed } of sources) {
6234
+ if (parsed === undefined) continue; // a temp attempt: counted in `any` only
6235
+ if (!parsed) {
6236
+ out.unreadable = true;
6237
+ continue;
6238
+ }
6239
+ for (const p of Object.keys(parsed.entries)) paths.add(p);
6240
+ Object.assign(out.entries, parsed.entries);
6241
+ out.pathsUnknown ||= parsed.pathsUnknown;
6242
+ for (const r of parsed.reasons) if (!out.reasons.includes(r)) out.reasons.push(r);
6243
+ }
6244
+ out.paths = [...paths].sort();
6245
+ return out;
6246
+ }
6247
+
6248
+ /**
6249
+ * Read a session's failure flag as the union of its attempt files:
6250
+ * `{failed, any, paths, entries, pathsUnknown, reasons}`. `any` is true when an attempt file exists
6251
+ * at all, readable or not (`failed` is the same answer under its older name). `paths` is every path
6252
+ * an attempt names, sorted; the gate judges by that set and by `any` alone. `entries` holds the
6253
+ * hashes each attempt recorded (`{diskSha256?: string|null, expectedSha256?: string}`), for a person
6254
+ * reading the flag: where two attempts name one path the one last by file name is kept, and nothing
6255
+ * judges by it. A flag file that exists but cannot be read is a failure with the lost paths
6256
+ * unknown, never "no flag".
6257
+ *
6258
+ * @returns {{failed: boolean, any: boolean, paths: string[], entries: Record<string, {diskSha256?: string|null, expectedSha256?: string}>, pathsUnknown: boolean, reasons: string[]}}
6259
+ */
6260
+ export function readTouchedPathsFailed(hypoDir, sessionId) {
6261
+ const m = mergeFailedFlags(hypoDir, sessionId);
6262
+ return {
6263
+ failed: m.any,
6264
+ any: m.any,
6265
+ paths: m.paths,
6266
+ entries: m.entries,
6267
+ pathsUnknown: m.pathsUnknown || m.unreadable,
6268
+ reasons: m.reasons,
6269
+ };
6270
+ }
6271
+
6272
+ /**
6273
+ * Leave a mark that a touched-paths record did not land, so a gate that reads the set cannot take
6274
+ * the missing path for "not this session's". Best-effort and lock-free on purpose: a lock failure is
6275
+ * one of the causes it exists for. Writes one new attempt file and reads no other flag file, so
6276
+ * concurrent marks cannot erase each other, and it only ever adds. The gate blocks a named path
6277
+ * whenever it is dirty, whatever its bytes, and any flag at all widens the project folder rule (see
6278
+ * the gate). The hashes are kept only for a person reading the flag: each entry holds the sha256 of
6279
+ * the path's bytes on disk as the flag is written (`diskSha256`, `null` when the file does not
6280
+ * exist, no key when it cannot be read), and a path recorded before its bytes are written
6281
+ * (`expectedHashes`) also keeps the hash it was given (`expectedSha256`).
6282
+ *
6283
+ * @param {string[]} paths vault-relative paths whose record did not land
6284
+ * @param {string} reason why (an error code, or a fixed label)
6285
+ * @param {{pathsUnknown?: boolean, expectedHashes?: Record<string, string>}} [opts]
6286
+ * `pathsUnknown`: paths were lost that this call cannot name
6287
+ * @returns {boolean} whether the flag file landed
6288
+ */
6289
+ function markTouchedPathsFailed(hypoDir, sessionId, paths, reason, opts = {}) {
6290
+ const { pathsUnknown = false, expectedHashes } = opts;
6291
+ try {
6292
+ const entries = {};
6293
+ for (const p of paths) {
6294
+ const entry = {};
6295
+ const disk = vaultFileHash(hypoDir, p);
6296
+ if (disk !== undefined) entry.diskSha256 = disk;
6297
+ if (expectedHashes && typeof expectedHashes[p] === 'string') {
6298
+ entry.expectedSha256 = expectedHashes[p];
6299
+ }
6300
+ entries[p] = entry;
6301
+ }
6302
+ atomicWrite(
6303
+ join(touchedPathsFailedDir(hypoDir, sessionId), failedAttemptName()),
6304
+ JSON.stringify({ v: 1, entries, pathsUnknown, reasons: [reason] }),
6305
+ );
6306
+ return true;
6307
+ } catch {
6308
+ // Nothing more to fall back on here. The caller decides what a missing flag means.
6309
+ return false;
6310
+ }
6311
+ }
6312
+
5688
6313
  /**
5689
6314
  * Read the raw touched-paths JSON array off disk. Caller's responsibility to
5690
6315
  * hold the per-session lock first — this has no locking of its own.
@@ -5734,40 +6359,74 @@ function readTouchedPathsFile(path) {
5734
6359
  * @param {string} hypoDir
5735
6360
  * @param {string|null|undefined} sessionId
5736
6361
  * @param {string|string[]} relPaths one or more vault-relative paths
6362
+ * @param {{expectedHashes?: Record<string, string>, deadline?: {end: number}}} [opts]
6363
+ * `expectedHashes`: record this sha256 for a path instead of the bytes on disk (for a path
6364
+ * recorded before its bytes are written). `deadline`: a `{end}` budget (see
6365
+ * `remainingSpawnTimeoutMs`) that caps the lock wait, leaving time to write the failure flag.
5737
6366
  * @returns {boolean} true when the accumulation is durably recorded (or there
5738
6367
  * was nothing to record); false when a lock-timeout or write failure means
5739
6368
  * the caller cannot assume this path is in ANY session's touched-paths set.
6369
+ * A false also leaves a failure flag (`touched-paths-failed.d/`) naming the paths, whether or
6370
+ * not the caller looks at the return value.
5740
6371
  */
5741
6372
  /**
5742
6373
  * Shared read-merge-write body for accumulating touched paths. Runs OUTSIDE
5743
6374
  * any lock; every caller wraps it in `withFileLock(path, ...)`.
5744
6375
  *
5745
- * A corrupt/unreadable file (`readTouchedPathsFile` returns `null`) is
5746
- * recovered from here, not preserved: an accumulate is additive by nature
5747
- * (there is nothing salvageable to merge with), so starting fresh from
5748
- * `incoming` is the correct best-effort behavior, unlike `clearTouchedPaths`,
5749
- * where the same `null` MUST NOT be treated as empty (see
5750
- * `readTouchedPathsFile`).
6376
+ * A corrupt/unreadable set (`readTouchedPathsFile` returns `null`) is
6377
+ * recovered from here once a paths-unknown failure flag has landed: an
6378
+ * accumulate is additive by nature (there is nothing salvageable to merge
6379
+ * with), so starting fresh from `incoming` is the correct best-effort
6380
+ * behavior, unlike `clearTouchedPaths`, where the same `null` MUST NOT be
6381
+ * treated as empty (see `readTouchedPathsFile`). Without that flag, or with
6382
+ * an unreadable hash file, this throws and nothing is written.
5751
6383
  *
5752
6384
  * @returns {{added: boolean}} whether at least one of `incoming` was NOT
5753
6385
  * already present in the set before this call.
5754
6386
  */
5755
- function mergeIncomingTouchedPaths(hypoDir, sessionId, incoming, hashFor) {
6387
+ function mergeIncomingTouchedPaths(hypoDir, sessionId, incoming, hashFor, onlyIfUnclaimed = false) {
5756
6388
  const path = touchedPathsPath(hypoDir, sessionId);
6389
+ // An unreadable hash file is left as it is: replacing it with this call's hashes would drop the
6390
+ // claims it holds. The record fails instead, and the caller's catch flags `incoming`.
6391
+ const hashPath = touchedHashesPath(hypoDir, sessionId);
6392
+ const hashes = readTouchedHashesFile(hashPath);
6393
+ if (hashes === null) {
6394
+ throw Object.assign(new Error('touched-hashes.json cannot be read'), {
6395
+ code: 'hashes-unreadable',
6396
+ });
6397
+ }
5757
6398
  const current = readTouchedPathsFile(path);
6399
+ // Starting over from `incoming` drops whatever the unreadable file held, and a later read says
6400
+ // `ok`. The lost paths are not known, so the flag must say so before the file is replaced. A flag
6401
+ // that did not land leaves the corrupt file in place, which still reads as unreadable.
6402
+ if (
6403
+ current === null &&
6404
+ !markTouchedPathsFailed(hypoDir, sessionId, incoming, 'prior-record-unreadable', {
6405
+ pathsUnknown: true,
6406
+ })
6407
+ ) {
6408
+ throw Object.assign(new Error('the failure flag for an unreadable record did not land'), {
6409
+ code: 'prior-record-unreadable',
6410
+ });
6411
+ }
5758
6412
  const merged = new Set(current === null ? [] : current);
6413
+ // `onlyIfUnclaimed`: a path this session already claims (set entry or hash entry) keeps its
6414
+ // claim and its hash untouched. The decision is made here, under the session lock, so a record
6415
+ // that landed after the caller's own existence check cannot be overwritten by this one.
6416
+ if (onlyIfUnclaimed) {
6417
+ incoming = incoming.filter((p) => !merged.has(p) && !Object.hasOwn(hashes, p));
6418
+ if (incoming.length === 0) return { added: false };
6419
+ }
5759
6420
  let added = false;
5760
6421
  for (const p of incoming) {
5761
6422
  if (!merged.has(p)) added = true;
5762
6423
  merged.add(p);
5763
6424
  }
5764
6425
  // Hashes first, the set second. A failure between the two leaves a hash
5765
- // for a path the set may not hold (never read: only paths in the set are
5766
- // looked up) or the previous hash of a path the set already held (at worst
5767
- // a false drift, which keeps the path out of the commit and blocks, never
5768
- // one that sweeps foreign bytes in).
5769
- const hashPath = touchedHashesPath(hypoDir, sessionId);
5770
- const hashes = readTouchedHashesFile(hashPath);
6426
+ // for a path the set may not hold (readTouchedClaims reads it as a claim of
6427
+ // this session, so a kill here does not lose the write) or the previous hash
6428
+ // of a path the set already held (at worst a false drift, which keeps the
6429
+ // path out of the commit and blocks, never one that sweeps foreign bytes in).
5771
6430
  for (const p of incoming) {
5772
6431
  const h = hashFor(p);
5773
6432
  if (h === undefined) delete hashes[p];
@@ -5797,15 +6456,107 @@ export function touchedHashesPath(hypoDir, sessionId) {
5797
6456
  return join(sessionCacheDir(hypoDir, sessionId), 'touched-hashes.json');
5798
6457
  }
5799
6458
 
5800
- /** Absent, corrupt, or not an object all read as `{}`: no hash recorded for
5801
- * any path, which is exactly how a set from before hashes existed behaves. */
6459
+ /** Absent reads as `{}`: no hash recorded for any path, which is exactly how a set from before
6460
+ * hashes existed behaves. A file that exists but cannot be read, or is not an object, is `null`:
6461
+ * not the same as empty, so no caller overwrites, prunes or trusts it as "no drift". */
5802
6462
  function readTouchedHashesFile(path) {
6463
+ let text;
5803
6464
  try {
5804
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
5805
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
6465
+ text = readFileSync(path, 'utf-8');
6466
+ } catch (err) {
6467
+ return err?.code === 'ENOENT' ? {} : null;
6468
+ }
6469
+ try {
6470
+ const parsed = JSON.parse(text);
6471
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
5806
6472
  } catch {
5807
- return {};
6473
+ return null;
6474
+ }
6475
+ }
6476
+
6477
+ /**
6478
+ * The paths a session claims: the touched-paths set plus every path that has a hash entry but no
6479
+ * entry in the set. A record writes the hash file first and the set second, so a process killed
6480
+ * between the two (no catch runs, so no failure flag either) leaves a path with a hash and no set
6481
+ * entry. That hash is the only trace of the write, so it counts as a claim until the path is
6482
+ * committed or matches HEAD, retired by the same rules as a recorded path. `null` when the set or
6483
+ * the hash file exists but cannot be read (see `readRecordFiles`). The caller holds the per-session
6484
+ * lock.
6485
+ */
6486
+ function readTouchedClaims(hypoDir, sessionId) {
6487
+ const files = readRecordFiles(hypoDir, sessionId);
6488
+ return files.why.length === 0 ? files.paths : null;
6489
+ }
6490
+
6491
+ /**
6492
+ * The touched-paths set and the hash file of a session, read by the one rule every reader follows:
6493
+ * a file that is absent is empty, a file that exists but cannot be read is not. The caller holds
6494
+ * the per-session lock. `paths` is the claim list (see `readTouchedClaims`), `null` when the set
6495
+ * cannot be read, and the set alone when only the hash file cannot. `hashes` is `null` when the
6496
+ * hash file cannot be read. `why` names each unreadable file as `{source, cause}`.
6497
+ *
6498
+ * @returns {{paths: string[]|null, hashes: Record<string, string|null>|null, why: {source: 'set'|'hashes', cause: string}[]}}
6499
+ */
6500
+ function readRecordFiles(hypoDir, sessionId) {
6501
+ const set = readTouchedPathsFile(touchedPathsPath(hypoDir, sessionId));
6502
+ const hashes = readTouchedHashesFile(touchedHashesPath(hypoDir, sessionId));
6503
+ const why = [];
6504
+ if (set === null) why.push({ source: 'set', cause: 'corrupt' });
6505
+ if (hashes === null) why.push({ source: 'hashes', cause: 'corrupt' });
6506
+ let paths = set;
6507
+ if (set !== null && hashes !== null) {
6508
+ const known = new Set(set);
6509
+ const orphans = Object.keys(hashes).filter((p) => p && !known.has(p));
6510
+ if (orphans.length > 0) paths = [...set, ...orphans];
5808
6511
  }
6512
+ return { paths, hashes, why };
6513
+ }
6514
+
6515
+ /**
6516
+ * Everything the close gate reads about one session's record, read by one rule: a file that is
6517
+ * absent is empty (no set is an empty record, no stamp is `stamped: false`), a file that exists but
6518
+ * cannot be read (bad JSON, permissions, a lock that cannot be taken or timed out) makes the record
6519
+ * incomplete, with its source and cause in `incompleteWhy`. Nothing here reads "cannot read" as
6520
+ * "nothing there". Takes the per-session lock for the set and the hashes, under `timeoutMs` when
6521
+ * given, else under the time left on `deadline`. The failure flag and the stamp are lock-free.
6522
+ *
6523
+ * `paths` and `hashes` are the claims: each claimed path, and the hash recorded for it where one
6524
+ * was (`null` meaning the file did not exist when recorded). A path with no recorded hash is in
6525
+ * `paths` and not in `hashes`, which keeps it apart from one recorded as absent.
6526
+ *
6527
+ * @param {{deadline?: {end: number}, timeoutMs?: number}} [opts]
6528
+ * `flags` is the failure flag reduced to what the gate judges by: `any` (an attempt file exists,
6529
+ * readable or not), `paths` (the set the attempts name) and `pathsUnknown`.
6530
+ *
6531
+ * @returns {{paths: string[], hashes: Record<string, string|null>, flags: {any: boolean, paths: string[], pathsUnknown: boolean}, stamped: boolean, complete: boolean, incompleteWhy: {source: 'set'|'hashes'|'flags'|'stamp', cause: string}[]}}
6532
+ */
6533
+ export function readSessionRecord(hypoDir, sessionId, { deadline, timeoutMs } = {}) {
6534
+ const incompleteWhy = [];
6535
+ let files;
6536
+ try {
6537
+ files = withFileLock(
6538
+ touchedPathsPath(hypoDir, sessionId),
6539
+ () => readRecordFiles(hypoDir, sessionId),
6540
+ Number.isFinite(timeoutMs) ? lockWaitOpts({ timeoutMs }) : touchedLockBudget(deadline),
6541
+ );
6542
+ } catch (err) {
6543
+ const cause = err?.code === 'ELOCKTIMEOUT' ? 'locked' : 'lock-failed';
6544
+ files = { paths: null, hashes: null, why: [{ source: 'set', cause }] };
6545
+ }
6546
+ incompleteWhy.push(...files.why);
6547
+ const merged = mergeFailedFlags(hypoDir, sessionId);
6548
+ if (merged.unreadable) incompleteWhy.push({ source: 'flags', cause: 'unreadable' });
6549
+ const flags = { any: merged.any, paths: merged.paths, pathsUnknown: merged.pathsUnknown };
6550
+ const stamp = readRecordStamp(hypoDir, sessionId);
6551
+ if (stamp === null) incompleteWhy.push({ source: 'stamp', cause: 'unreadable' });
6552
+ return {
6553
+ paths: files.paths ?? [],
6554
+ hashes: files.hashes ?? {},
6555
+ flags,
6556
+ stamped: stamp === true,
6557
+ complete: incompleteWhy.length === 0,
6558
+ incompleteWhy,
6559
+ };
5809
6560
  }
5810
6561
 
5811
6562
  /** sha256 of a vault file's bytes, `null` when it does not exist, `undefined`
@@ -5827,11 +6578,14 @@ function headFileHash(hypoDir, relPath) {
5827
6578
  }
5828
6579
 
5829
6580
  /** A vault path's blob in HEAD (or, with `index`, in the index at stage 0) as a
5830
- * Buffer, `null` when git cannot give it. */
5831
- export function headBlob(hypoDir, relPath, { index = false } = {}) {
6581
+ * Buffer, `null` when git cannot give it (or `deadline` has run out). */
6582
+ export function headBlob(hypoDir, relPath, { index = false, deadline } = {}) {
5832
6583
  const spec = `${index ? ':0' : 'HEAD'}:./${relPath}`;
6584
+ // With a `deadline` the call is cut to the time left; none left means git is not asked.
6585
+ const left = remainingSpawnTimeoutMs(deadline);
6586
+ if (left === 0) return null;
5833
6587
  const r = spawnSync('git', ['-C', hypoDir, 'cat-file', 'blob', spec], {
5834
- timeout: 30000,
6588
+ timeout: left ?? 30000,
5835
6589
  maxBuffer: 64 * 1024 * 1024,
5836
6590
  });
5837
6591
  return r.status === 0 && r.stdout ? r.stdout : null;
@@ -5843,17 +6597,19 @@ function recordedHashDrifted(hypoDir, hashes, relPath) {
5843
6597
  return Object.hasOwn(hashes, relPath) && vaultFileHash(hypoDir, relPath) !== hashes[relPath];
5844
6598
  }
5845
6599
 
5846
- /** Drop hash entries for paths no longer in the set. Best-effort: a leftover
5847
- * entry is never read (lookups are by paths in the set) and is overwritten
5848
- * the next time that path is recorded. */
6600
+ /** Drop hash entries for paths no longer claimed (`remaining` is the claim list, set and hash-only
6601
+ * paths alike). Best-effort: a leftover entry reads as a claim (readTouchedClaims) until the path
6602
+ * is committed or matches HEAD, which retires it like a recorded path. */
5849
6603
  function pruneTouchedHashes(hypoDir, sessionId, remaining) {
5850
6604
  const hashPath = touchedHashesPath(hypoDir, sessionId);
5851
6605
  try {
6606
+ // An unreadable hash file is left exactly as it is: neither removed nor rewritten.
6607
+ const hashes = readTouchedHashesFile(hashPath);
6608
+ if (hashes === null) return;
5852
6609
  if (remaining.length === 0) {
5853
6610
  rmSync(hashPath, { force: true });
5854
6611
  return;
5855
6612
  }
5856
- const hashes = readTouchedHashesFile(hashPath);
5857
6613
  const kept = {};
5858
6614
  for (const p of remaining) if (Object.hasOwn(hashes, p)) kept[p] = hashes[p];
5859
6615
  atomicWrite(hashPath, JSON.stringify(kept));
@@ -5862,25 +6618,50 @@ function pruneTouchedHashes(hypoDir, sessionId, remaining) {
5862
6618
  }
5863
6619
  }
5864
6620
 
5865
- export function recordTouchedPaths(hypoDir, sessionId, relPaths) {
6621
+ export function recordTouchedPaths(hypoDir, sessionId, relPaths, opts = {}) {
5866
6622
  if (!sessionId) return true;
5867
6623
  const incoming = (Array.isArray(relPaths) ? relPaths : [relPaths]).filter(
5868
6624
  (p) => typeof p === 'string' && p.length > 0,
5869
6625
  );
5870
6626
  if (incoming.length === 0) return true;
6627
+ const { expectedHashes, deadline, onlyIfUnclaimed } = opts;
5871
6628
  const path = touchedPathsPath(hypoDir, sessionId);
6629
+ // With a `deadline` the lock wait leaves room to write the failure flag.
6630
+ const left = remainingSpawnTimeoutMs(deadline);
6631
+ const lockOpts =
6632
+ left === undefined
6633
+ ? {}
6634
+ : { timeoutMs: Math.max(0, Math.min(5000, left - TOUCHED_FLAG_SHARE_MS)) };
5872
6635
  try {
5873
- withFileLock(path, () =>
5874
- mergeIncomingTouchedPaths(hypoDir, sessionId, incoming, (p) => vaultFileHash(hypoDir, p)),
6636
+ withFileLock(
6637
+ path,
6638
+ () =>
6639
+ mergeIncomingTouchedPaths(
6640
+ hypoDir,
6641
+ sessionId,
6642
+ incoming,
6643
+ (p) =>
6644
+ expectedHashes && Object.hasOwn(expectedHashes, p)
6645
+ ? expectedHashes[p]
6646
+ : vaultFileHash(hypoDir, p),
6647
+ onlyIfUnclaimed === true,
6648
+ ),
6649
+ lockOpts,
5875
6650
  );
5876
- return true;
5877
- } catch {
6651
+ } catch (err) {
5878
6652
  // Never throws out to the caller (a hook must not fail a tool call over
5879
6653
  // a cache write, includes a lock-timeout), but no longer silent: the
5880
6654
  // false return is what lets a caller decide whether to trust this write
5881
- // as accounted for.
6655
+ // as accounted for, and the failure flag is what tells a gate that reads
6656
+ // the set later, whether or not the caller looked at the return.
6657
+ markTouchedPathsFailed(hypoDir, sessionId, incoming, err?.code || 'write-failed', {
6658
+ expectedHashes,
6659
+ });
5882
6660
  return false;
5883
6661
  }
6662
+ // An earlier flag entry for these paths stays: the flag only ever adds, and it keeps blocking
6663
+ // until a person removes the directory (see the gate).
6664
+ return true;
5884
6665
  }
5885
6666
 
5886
6667
  /**
@@ -5912,7 +6693,7 @@ export function drainTouchedPaths(hypoDir, sessionId) {
5912
6693
  const path = touchedPathsPath(hypoDir, sessionId);
5913
6694
  try {
5914
6695
  return withFileLock(path, () => {
5915
- const result = readTouchedPathsFile(path);
6696
+ const result = readTouchedClaims(hypoDir, sessionId);
5916
6697
  if (result === null) return []; // corrupt/unreadable: leave the file for inspection, never delete
5917
6698
  try {
5918
6699
  rmSync(path, { force: true });
@@ -5948,7 +6729,7 @@ export function peekTouchedPaths(hypoDir, sessionId) {
5948
6729
  const path = touchedPathsPath(hypoDir, sessionId);
5949
6730
  try {
5950
6731
  return withFileLock(path, () => {
5951
- const result = readTouchedPathsFile(path);
6732
+ const result = readTouchedClaims(hypoDir, sessionId);
5952
6733
  return result === null ? [] : result;
5953
6734
  });
5954
6735
  } catch {
@@ -5958,37 +6739,60 @@ export function peekTouchedPaths(hypoDir, sessionId) {
5958
6739
 
5959
6740
  /**
5960
6741
  * Like `peekTouchedPaths`, but does not collapse "corrupt", "lock timeout"
5961
- * and "empty" into the same silent `[]`. A `checkpointMode` gate treats
6742
+ * and "empty" into the same silent `[]`. The close gate (every caller) treats
5962
6743
  * the three differently: a lock timeout is transient (another writer holds
5963
6744
  * the lock right now) and blocks so the retry reads the real set; a corrupt
5964
- * file stays corrupt on every retry, so the gate reads it as "no record"
5965
- * and says so in a notice instead of refusing forever. `peekTouchedPaths`
6745
+ * file stays corrupt on every retry, so it is not a blocker of its own: it
6746
+ * widens the project folder being closed to every uncommitted file in it
6747
+ * (with the way out in the reason) instead of refusing every close for
6748
+ * good. `peekTouchedPaths`
5966
6749
  * itself is unchanged: its callers already treat its `[]` as
5967
6750
  * fail-safe-to-no-scope for a best-effort commit attempt.
5968
6751
  *
5969
6752
  * @param {string} hypoDir
5970
6753
  * @param {string|null|undefined} sessionId
5971
- * @returns {{state: 'ok'|'empty'|'unreadable'|'locked', paths: string[]}}
5972
- * 'ok': a non-empty set was read. 'empty': no session_id, or the file is
5973
- * absent/genuinely empty. 'unreadable': the file exists but is corrupt, or
5974
- * the lock could not be taken for a reason other than a timeout (an
5975
- * unwritable directory). 'locked': the per-session lock timed out. paths
5976
- * is always `[]` unless 'ok', since nothing safely known could be returned.
5977
- */
5978
- export function readTouchedPathsStrict(hypoDir, sessionId) {
6754
+ * @param {{timeoutMs?: number}} [opts] cap on the lock wait (default: the lock's own)
6755
+ * @returns {{state: 'ok'|'empty'|'unreadable'|'locked', paths: string[], cause?: 'corrupt'|'lock-failed'}}
6756
+ * 'ok': a non-empty set was read (the set plus any hash-only path). 'empty': no session_id, or the file is
6757
+ * absent/genuinely empty. 'unreadable': the file exists but is corrupt
6758
+ * (`cause: 'corrupt'`), or the lock could not be taken for a reason other
6759
+ * than a timeout, an unwritable directory (`cause: 'lock-failed'`). Deleting
6760
+ * the file mends the first and not the second. 'locked': the per-session
6761
+ * lock timed out. paths is always `[]` unless 'ok', since nothing safely
6762
+ * known could be returned.
6763
+ */
6764
+ export function readTouchedPathsStrict(hypoDir, sessionId, opts = {}) {
5979
6765
  if (!sessionId) return { state: 'empty', paths: [] };
5980
6766
  const path = touchedPathsPath(hypoDir, sessionId);
5981
6767
  try {
5982
- return withFileLock(path, () => {
5983
- const result = readTouchedPathsFile(path);
5984
- if (result === null) return { state: 'unreadable', paths: [] };
5985
- return result.length === 0 ? { state: 'empty', paths: [] } : { state: 'ok', paths: result };
5986
- });
6768
+ return withFileLock(
6769
+ path,
6770
+ () => {
6771
+ const result = readTouchedClaims(hypoDir, sessionId);
6772
+ if (result === null) return { state: 'unreadable', paths: [], cause: 'corrupt' };
6773
+ return result.length === 0 ? { state: 'empty', paths: [] } : { state: 'ok', paths: result };
6774
+ },
6775
+ lockWaitOpts(opts),
6776
+ );
5987
6777
  } catch (err) {
5988
- return { state: err?.code === 'ELOCKTIMEOUT' ? 'locked' : 'unreadable', paths: [] };
6778
+ return err?.code === 'ELOCKTIMEOUT'
6779
+ ? { state: 'locked', paths: [] }
6780
+ : { state: 'unreadable', paths: [], cause: 'lock-failed' };
5989
6781
  }
5990
6782
  }
5991
6783
 
6784
+ /** The touched-paths lock wait of a reader that runs under a gate `deadline`: the lock's own cap
6785
+ * (5000 ms) or the time left, whichever is smaller. `{}` (the lock's own default) without a deadline. */
6786
+ function touchedLockBudget(deadline) {
6787
+ return deadline ? { timeoutMs: Math.min(5000, remainingSpawnTimeoutMs(deadline)) } : {};
6788
+ }
6789
+
6790
+ /** `withFileLock` options for a reader's optional `timeoutMs` (the lock wait cap): unset keeps the
6791
+ * lock's own default. */
6792
+ function lockWaitOpts({ timeoutMs } = {}) {
6793
+ return Number.isFinite(timeoutMs) ? { timeoutMs: Math.max(0, timeoutMs) } : {};
6794
+ }
6795
+
5992
6796
  /**
5993
6797
  * The paths in a session's touched-paths set whose bytes on disk no longer
5994
6798
  * match the hash recorded for them (see touchedHashesPath): another write
@@ -6000,17 +6804,23 @@ export function readTouchedPathsStrict(hypoDir, sessionId) {
6000
6804
  *
6001
6805
  * @param {string} hypoDir
6002
6806
  * @param {string|null|undefined} sessionId
6807
+ * @param {{timeoutMs?: number}} [opts] cap on the lock wait (default: the lock's own)
6003
6808
  * @returns {string[]}
6004
6809
  */
6005
- export function readTouchedPathsDrifted(hypoDir, sessionId) {
6810
+ export function readTouchedPathsDrifted(hypoDir, sessionId, opts = {}) {
6006
6811
  if (!sessionId) return [];
6007
6812
  const path = touchedPathsPath(hypoDir, sessionId);
6008
6813
  try {
6009
- return withFileLock(path, () => {
6010
- const current = readTouchedPathsFile(path) || [];
6011
- const hashes = readTouchedHashesFile(touchedHashesPath(hypoDir, sessionId));
6012
- return current.filter((p) => recordedHashDrifted(hypoDir, hashes, p));
6013
- });
6814
+ return withFileLock(
6815
+ path,
6816
+ () => {
6817
+ const { paths, hashes } = readRecordFiles(hypoDir, sessionId);
6818
+ // No readable hashes: no drift can be shown, and the gate blocks on the unreadable file.
6819
+ if (!paths || !hashes) return [];
6820
+ return paths.filter((p) => recordedHashDrifted(hypoDir, hashes, p));
6821
+ },
6822
+ lockWaitOpts(opts),
6823
+ );
6014
6824
  } catch {
6015
6825
  return [];
6016
6826
  }
@@ -6062,7 +6872,7 @@ export function clearTouchedPaths(hypoDir, sessionId, paths) {
6062
6872
  const path = touchedPathsPath(hypoDir, sessionId);
6063
6873
  try {
6064
6874
  withFileLock(path, () => {
6065
- const current = readTouchedPathsFile(path);
6875
+ const current = readTouchedClaims(hypoDir, sessionId);
6066
6876
  if (current === null) return; // FIX 1: never delete/write on a read failure
6067
6877
  const remaining = current.filter((p) => !toRemove.has(p));
6068
6878
  if (remaining.length === 0) {
@@ -6132,7 +6942,10 @@ export function clearTouchedPaths(hypoDir, sessionId, paths) {
6132
6942
  * test-only stand-in for `commitWikiChanges`) keeps nothing, the plain
6133
6943
  * whole-scope clear.
6134
6944
  *
6135
- * `commitFn` does not always get the whole set. A path whose bytes on disk
6945
+ * `commitFn` does not always get the whole set. A path with nothing on disk
6946
+ * and nothing in HEAD is left out and kept: it may be claimed before its file
6947
+ * is created (ensureProjectIndex records first), so retiring it as clean
6948
+ * would let the create land unclaimed. A path whose bytes on disk
6136
6949
  * differ from the hash recorded for it (touchedHashesPath) is left out, and
6137
6950
  * is then either retired (HEAD already holds the recorded bytes, or the file
6138
6951
  * is clean) or kept alongside `ignoredPaths` (another write landed after the
@@ -6151,14 +6964,18 @@ export function commitTouchedPaths(hypoDir, sessionId, commitFn) {
6151
6964
  const path = touchedPathsPath(hypoDir, sessionId);
6152
6965
  try {
6153
6966
  return withFileLock(path, () => {
6154
- const current = readTouchedPathsFile(path);
6155
- if (current === null) {
6967
+ const files = readRecordFiles(hypoDir, sessionId);
6968
+ if (files.why.length > 0) {
6156
6969
  // FIX 1: a read/parse failure is not "empty" — commit nothing this
6157
6970
  // round (a safe no-op scope) and leave the corrupt file untouched,
6158
6971
  // rather than let a downstream clear compute "nothing left" and
6159
- // delete it.
6972
+ // delete it. An unreadable hash file is the same: no path can be
6973
+ // shown to still hold this session's bytes, so reading it as "no
6974
+ // drift" would commit whatever is on disk under this session's
6975
+ // claim. Every path stays recorded and out of this commit.
6160
6976
  return commitFn([]);
6161
6977
  }
6978
+ const current = files.paths;
6162
6979
  // Content check before the commit, for every path that has a recorded
6163
6980
  // hash (see touchedHashesPath). Bytes still equal to what this session
6164
6981
  // recorded go into the scope. Anything else is left out of THIS commit,
@@ -6166,23 +6983,37 @@ export function commitTouchedPaths(hypoDir, sessionId, commitFn) {
6166
6983
  // - HEAD already holds the recorded bytes: an old claim whose commit
6167
6984
  // landed but whose clear failed. Retired, so another session's
6168
6985
  // later edit of the path is never swept in under it.
6169
- // - HEAD already holds the bytes on disk: nothing is dirty. Retired.
6986
+ // - HEAD already holds the bytes on disk and the index does too: nothing is dirty.
6987
+ // Retired. An index that still holds other bytes keeps the claim.
6170
6988
  // - neither: some other write landed after this session recorded the
6171
6989
  // path. Kept, so the checkpoint gate still blocks on it and says
6172
6990
  // why (readTouchedPathsDrifted), instead of the path reading as
6173
6991
  // ownerless.
6174
- // A path with no recorded hash (a set from before hashes existed, an
6175
- // unreadable file) goes into the scope as before.
6176
- const hashes = readTouchedHashesFile(touchedHashesPath(hypoDir, sessionId));
6992
+ // A path with no recorded hash (a set from before hashes existed) goes
6993
+ // into the scope as before.
6994
+ const hashes = files.hashes;
6177
6995
  const scope = [];
6178
6996
  const driftKept = [];
6179
6997
  for (const p of current) {
6998
+ // Nothing on disk and nothing in HEAD: nothing to commit, and possibly a claim made before
6999
+ // its file exists. Kept, never retired as clean (a deletion of a HEAD file still commits).
7000
+ if (vaultFileHash(hypoDir, p) === null && headFileHash(hypoDir, p) === null) {
7001
+ driftKept.push(p);
7002
+ continue;
7003
+ }
6180
7004
  if (!recordedHashDrifted(hypoDir, hashes, p)) {
6181
7005
  scope.push(p);
6182
7006
  continue;
6183
7007
  }
6184
7008
  const head = headFileHash(hypoDir, p);
6185
- if (head === hashes[p] || head === vaultFileHash(hypoDir, p)) continue;
7009
+ if (head === hashes[p]) continue;
7010
+ // The worktree is back at HEAD, but the claim is spent only if the index agrees. An index
7011
+ // still holding this session's bytes would be committed under no claim at all. An index git
7012
+ // cannot read keeps the claim.
7013
+ if (head === vaultFileHash(hypoDir, p)) {
7014
+ const blobs = indexAndHeadBlobs(hypoDir, p);
7015
+ if (blobs && blobs.index === blobs.head) continue;
7016
+ }
6186
7017
  driftKept.push(p);
6187
7018
  }
6188
7019
  const result = commitFn(scope);
@@ -6259,16 +7090,22 @@ function readCacheRecord(path) {
6259
7090
  }
6260
7091
  }
6261
7092
 
6262
- function updateCacheRecord(path, mutate) {
6263
- withFileLock(path, () => {
6264
- // An unreadable record starts over: a lost entry only means one more backup later.
6265
- const record = readCacheRecord(path);
6266
- mutate(record);
6267
- // An empty record is no file, so a commit that leaves nothing to remember leaves no trace in a
6268
- // vault whose `.gitignore` has no `.cache/` line.
6269
- if (Object.keys(record).length === 0) rmSync(path, { force: true });
6270
- else atomicWrite(path, JSON.stringify(record));
6271
- });
7093
+ // `timeoutMs` caps the lock wait (a hook with a time budget passes what is left); without it the
7094
+ // wait is `withFileLock`'s default.
7095
+ function updateCacheRecord(path, mutate, { timeoutMs } = {}) {
7096
+ withFileLock(
7097
+ path,
7098
+ () => {
7099
+ // An unreadable record starts over: a lost entry only means one more backup later.
7100
+ const record = readCacheRecord(path);
7101
+ mutate(record);
7102
+ // An empty record is no file, so a commit that leaves nothing to remember leaves no trace in a
7103
+ // vault whose `.gitignore` has no `.cache/` line.
7104
+ if (Object.keys(record).length === 0) rmSync(path, { force: true });
7105
+ else atomicWrite(path, JSON.stringify(record));
7106
+ },
7107
+ timeoutMs === undefined ? {} : { timeoutMs },
7108
+ );
6272
7109
  }
6273
7110
 
6274
7111
  /** The recorded auto-stage blobs. A missing or unreadable record is `{}`: nothing is skipped. */
@@ -6280,10 +7117,14 @@ export function readAutoStagedRecord(hypoDir) {
6280
7117
  * Remember that hypo-auto-stage staged `blob` for `relPath` while HEAD held `headBlob` for it, so a
6281
7118
  * commit does not back it up.
6282
7119
  */
6283
- export function recordAutoStagedBlob(hypoDir, relPath, blob, headBlob) {
6284
- updateCacheRecord(autoStagedPath(hypoDir), (record) => {
6285
- record[relPath] = { blob, headBlob: headBlob ?? null };
6286
- });
7120
+ export function recordAutoStagedBlob(hypoDir, relPath, blob, headBlob, { timeoutMs } = {}) {
7121
+ updateCacheRecord(
7122
+ autoStagedPath(hypoDir),
7123
+ (record) => {
7124
+ record[relPath] = { blob, headBlob: headBlob ?? null };
7125
+ },
7126
+ { timeoutMs },
7127
+ );
6287
7128
  }
6288
7129
 
6289
7130
  /**
@@ -6316,13 +7157,19 @@ export function isStagedDeletion(headBlob, indexed) {
6316
7157
  /**
6317
7158
  * The stage-0 index blob and HEAD's blob for one vault path (`null` when absent; an unborn HEAD has
6318
7159
  * no blobs), and whether the index has an entry for it at any stage. `null` for the whole result
6319
- * when git cannot answer about the index.
7160
+ * when git cannot answer about the index (or `deadline` has run out).
6320
7161
  * @returns {{index: string|null, head: string|null, indexed: boolean}|null}
6321
7162
  */
6322
- export function indexAndHeadBlobs(hypoDir, relPath) {
6323
- const ls = vaultGit(hypoDir, ['ls-files', '-s', '--', relPath]);
7163
+ export function indexAndHeadBlobs(hypoDir, relPath, { deadline } = {}) {
7164
+ // With a `deadline` each git call is cut to the time left, and no time left is `null` (git did
7165
+ // not answer about the index), the answer callers already skip on.
7166
+ const git = (args) => {
7167
+ const timeout = remainingSpawnTimeoutMs(deadline);
7168
+ return timeout === 0 ? { error: true } : vaultGit(hypoDir, args, timeout ? { timeout } : {});
7169
+ };
7170
+ const ls = git(['ls-files', '-s', '--', relPath]);
6324
7171
  if (ls.error || ls.status !== 0) return null;
6325
- const tree = vaultGit(hypoDir, ['ls-tree', 'HEAD', '--', relPath]);
7172
+ const tree = git(['ls-tree', 'HEAD', '--', relPath]);
6326
7173
  return {
6327
7174
  index: /^\d+ ([0-9a-f]+) 0\t/m.exec(ls.stdout || '')?.[1] ?? null,
6328
7175
  head:
@@ -7949,11 +8796,11 @@ function toHypoRel(absPath, hypoDir) {
7949
8796
  * occasionally truncate mid-write). `extractTouchedWikiFiles` collapses all of
7950
8797
  * that to an empty/partial Set, indistinguishable from "this session touched
7951
8798
  * nothing", which is fine for lint's existing debt-partition (a false notice is not a
7952
- * false pass), but NOT fine for an attribution-sensitive caller like
7953
- * precompactGateStatus's git-scope check: treating an untrustworthy empty Set
7954
- * as "nothing to widen" would let a session's own dirty file, invisible only
7955
- * because its transcript could not be read, pass as someone else's foreign
7956
- * debt (codex pre-commit review BLOCKER 1).
8799
+ * false pass), but NOT fine for a caller that must not guess from a partial
8800
+ * walk, such as deriveTouchedProject: a truncated line could have named a
8801
+ * second project the walk never saw. The gate's git check does not read the
8802
+ * transcript at all (it uses the touched-paths record), so it is not such a
8803
+ * caller any more.
7957
8804
  *
7958
8805
  * @returns {{files: Set<string>, trusted: boolean}}
7959
8806
  */
@@ -8199,6 +9046,37 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
8199
9046
  }
8200
9047
 
8201
9048
  // ── PreCompact gate — single source of truth ────────────────────────────────
9049
+
9050
+ // One monotonic deadline for the whole gate, below the smallest caller limit (the Stop
9051
+ // stage's 10000 ms) minus the work a caller does before and after the gate. Every wait
9052
+ // the gate starts (git, the touched-paths lock, lint, feedback-sync) is cut to the time
9053
+ // left on it. No environment knob: a stale value in a user's environment would switch
9054
+ // the checks off or block every close. Tests inject `opts.deadline` instead. Exported only so
9055
+ // PreCompact can build the same deadline for its follow-up calls.
9056
+ export const GATE_DEADLINE_MS = 7000;
9057
+
9058
+ /**
9059
+ * How a child process ended: 'timeout' (the `timeout` the gate gave spawnSync killed it, Node
9060
+ * reports `error.code === 'ETIMEDOUT'`), 'spawn-error' (it never ran), 'signal' (killed from
9061
+ * outside, such as an OOM kill), 'exit' (a non-zero exit) or 'ok'. Same order as `stageFailure`
9062
+ * in hypo-stop.mjs, which cannot import this module (it uses Node built-ins only): keep the two
9063
+ * orders equal.
9064
+ *
9065
+ * @param {{error?: {code?: string}, status?: number|null, signal?: string|null}} res
9066
+ * @returns {'ok'|'timeout'|'spawn-error'|'signal'|'exit'}
9067
+ */
9068
+ function spawnEndKind(res) {
9069
+ if (res.error?.code === 'ETIMEDOUT') return 'timeout';
9070
+ if (res.error) return 'spawn-error';
9071
+ if (res.signal) return 'signal';
9072
+ return res.status === 0 ? 'ok' : 'exit';
9073
+ }
9074
+
9075
+ // The lint and feedback-sync runner of the gate. `opts.runCheck` replaces it in tests. Both run
9076
+ // under this process's own node: a spawn error is a `check-skipped` blocker, so a `node` that PATH
9077
+ // lacks (or resolves to another install) must not be the one asked.
9078
+ const defaultRunCheck = (_name, args, spawnOpts) => spawnSync(process.execPath, args, spawnOpts);
9079
+
8202
9080
  /**
8203
9081
  * The full PreCompact gate decision as a READ-ONLY status. This is the single
8204
9082
  * source of truth for "is the wiki compact-ready?": both hypo-personal-check.mjs
@@ -8234,13 +9112,15 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
8234
9112
  * spec §3). The writer is `scripts/lib/crystallize-close-apply.mjs`
8235
9113
  * (`runMarkSessionClosed` and `runMarkerPhase`); the reader is
8236
9114
  * `markerCoversArtifact` in `scripts/doctor.mjs`.
8237
- * Either key ALSO feeds the git-dirty partition (spec §2b): with an untrusted
8238
- * transcript (missing/corrupt), a dirty file structurally under a DIFFERENT
8239
- * eligible project's own directory is demoted to a notice, because the path
8240
- * alone proves it is not this session's; everything else keeps blocking.
8241
- * When a log-only marker governs the session, log-only mode wins for the
8242
- * close-status and lint scope (scope selection above); the git-dirty
8243
- * partition in spec §2b still applies regardless of projectOverride.
9115
+ * The git axis is the same for every caller, and does not read the
9116
+ * transcript. A dirty file blocks when this session's touched-paths record says
9117
+ * it wrote it, or a failure flag says a record of it was lost; the whole
9118
+ * project folder being closed blocks only when the record lost paths it cannot
9119
+ * name, or when the call has no session id to read a record of. Any other dirty
9120
+ * file is a notice (`unresolved`, `foreign-entry`, `foreign-append`,
9121
+ * `local-entry`). projectOverride and attributionScope only name the project
9122
+ * folder being closed. When a log-only marker governs the session, log-only
9123
+ * mode wins for the close-status and lint scope (scope selection above).
8244
9124
  *
8245
9125
  * @param {string} hypoDir
8246
9126
  * opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
@@ -8258,14 +9138,59 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
8258
9138
  * way it injects `opts.closeMarker`: close-gate-store.mjs imports this module.
8259
9139
  * A call that reaches the check without them throws (sessionProofCloseId).
8260
9140
  *
8261
- * @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[], closeOpen?: boolean, resolvedAtIndex?: number|null}} [opts]
8262
- * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
9141
+ * Internal opts, not set by an operating caller: `opts.deadline` (`{end}`, default one built
9142
+ * from GATE_DEADLINE_MS at entry; PreCompact passes its own so its follow-up calls share the
9143
+ * budget) and `opts.runCheck(name, args, spawnOpts)` (the lint / feedback-sync runner, tests
9144
+ * inject results). A wait that outlives the deadline is a time limit: the check is skipped
9145
+ * and said so in a notice, never a blocker. A check that ended by an error (a crash, an
9146
+ * outside kill, output that does not parse) is a `check-skipped` blocker for every caller,
9147
+ * `checkpointMode` or not. `skippedWhy` names the cause of each skip.
9148
+ *
9149
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, attributionScope?: string|null, sessionCwd?: string|null, sessionId?: string|null, checkpointMode?: boolean, closeMarker?: object|null, logOnly?: boolean, closeScope?: string[], closeOpen?: boolean, resolvedAtIndex?: number|null, deadline?: {end: number}, runCheck?: (name: 'lint'|'feedback', args: string[], spawnOpts: object) => object}} [opts]
9150
+ * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}, skippedWhy: {lint?: string, feedback?: string}}}
8263
9151
  */
8264
9152
  export function precompactGateStatus(hypoDir, opts = {}) {
8265
9153
  const blockers = [];
8266
9154
  const notices = [];
8267
9155
  const driftTargets = [];
8268
9156
  const skipped = { lint: false, feedback: false };
9157
+ const skippedWhy = {};
9158
+ const deadline = opts.deadline ?? { end: performance.now() + GATE_DEADLINE_MS };
9159
+ const cut = () => remainingSpawnTimeoutMs(deadline) === 0;
9160
+ const runCheck = opts.runCheck ?? defaultRunCheck;
9161
+ // Starts one lint / feedback-sync run under the time left, or reports that none is left.
9162
+ const startCheck = (name, args, spawnOpts) => {
9163
+ const left = remainingSpawnTimeoutMs(deadline);
9164
+ if (left === 0) return { kind: 'deadline' };
9165
+ const t0 = performance.now();
9166
+ const res = runCheck(name, args, { ...spawnOpts, timeout: Math.min(30000, left) });
9167
+ return { res, kind: spawnEndKind(res), ms: Math.round(performance.now() - t0) };
9168
+ };
9169
+ // A time limit (the child hit its timeout, or no time was left to start it): a notice.
9170
+ const checkTimeLimit = (check, run) => {
9171
+ skipped[check] = true;
9172
+ const why =
9173
+ run.kind === 'deadline'
9174
+ ? `the gate deadline, ${GATE_DEADLINE_MS} ms, was used up before it could start`
9175
+ : `still running after ${run.ms} ms, gate deadline ${GATE_DEADLINE_MS} ms`;
9176
+ skippedWhy[check] = `time limit: ${why}`;
9177
+ notices.push({ type: check, reason: `${check} skipped (time limit, fail-open): ${why}` });
9178
+ };
9179
+ // An error: the check did not run to a verdict, so this close has no verified lint /
9180
+ // feedback state. Blocks every caller, so no receipt is issued for it.
9181
+ const checkFailed = (check, msg) => {
9182
+ skipped[check] = true;
9183
+ skippedWhy[check] = `error: ${msg}`;
9184
+ blockers.push({
9185
+ type: 'check-skipped',
9186
+ check,
9187
+ reason: `${check} check could not run to a result: ${msg} (${
9188
+ check === 'lint'
9189
+ ? `/hypo:lint 또는 \`node ${join(PKG_ROOT || '<pkg>', 'scripts', 'lint.mjs')} --hypo-dir=${hypoDir}\` 로 lint`
9190
+ : '`feedback-sync --check`'
9191
+ } 를 직접 돌려 원인을 고친 뒤 close 를 다시 하면 된다. 영수증이 없으면 Stop 이 계속 close 를 요구한다)`,
9192
+ });
9193
+ };
8269
9194
 
8270
9195
  // log-only synthetic close mode. A non-project (tooling / wiki-only)
8271
9196
  // session has no project to close. Activated either by the explicit writer flag
@@ -8293,10 +9218,9 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8293
9218
  : null;
8294
9219
  const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
8295
9220
 
8296
- // Paths this session is accountable for at THIS close: the same signals the
8297
- // lint partition below already trusts for exactly this question ("is this
8298
- // file mine or pre-existing debt"), hoisted so the git check (step 1) can
8299
- // partition on it too. Base = the mandatory close-target files (opts.lintScope
9221
+ // Paths this session is accountable for at THIS close: the signals the lint
9222
+ // partition below trusts for the question "is this file mine or pre-existing
9223
+ // debt". Base = the mandatory close-target files (opts.lintScope
8300
9224
  // override, else the log-only shared root files, else one project's close
8301
9225
  // files under projectOverride, else every today-active project's); widened by
8302
9226
  // every file this session's transcript shows it editing directly.
@@ -8308,342 +9232,286 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8308
9232
  ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
8309
9233
  : closeFileTargetsGlobal(hypoDir)),
8310
9234
  );
8311
- // Trust signal for the git-scope check below (codex pre-commit review
8312
- // BLOCKER 1): closeAccountableScope's mandatory-files base is always
8313
- // reliable, but the transcript widening that catches an ad hoc page this
8314
- // session edited is only as good as the transcript. No opts.transcriptPath
8315
- // at all, an unreadable file, or a line that fails to parse all mean the
8316
- // widened scope may be under-inclusive, NOT that this session touched
8317
- // nothing extra. Only a fully-read, fully-parsed transcript earns
8318
- // sessionTouchTrusted:true. A single tool_use with no file_path does NOT
8319
- // lower trust: most tool_use blocks (Read, Bash, Grep, ...) never carry one
8320
- // and that is expected, not corruption, so treating it as untrustworthy
8321
- // would make almost every real transcript untrusted and defeat the scoping
8322
- // this fix exists to add.
8323
- let sessionTouchTrusted = false;
8324
- // Transcript-derived files ONLY (not closeAccountableScope's mandatory-files
8325
- // base, which comes from closeFileTargetsGlobal and would fold every close
8326
- // file of every eligible project into "this session touched it"). §2b's
8327
- // isForeign below needs exactly this narrower set: a file this session's own
8328
- // Edit/Write proves it touched, even on a PARTIALLY-parsed transcript (the
8329
- // trailing line of an append-in-progress JSONL truncates in the ordinary
8330
- // course of a live session, not as an exceptional failure), must never be
8331
- // classified foreign by path prefix alone. Widening this to
8332
- // closeAccountableScope would re-open the exact foreign-close-file leak
8333
- // (b) fixed above (a genuinely different project's close file would count
8334
- // as "touched" just because it shares the mandatory-files base).
8335
- let transcriptTouched = new Set();
9235
+ // The transcript widens the lint scope to the wiki files this session edited
9236
+ // directly. It plays no part in the git axis below, which judges by the
9237
+ // session's touched-paths record.
8336
9238
  if (opts.transcriptPath) {
8337
- const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
8338
- sessionTouchTrusted = widened.trusted;
8339
- for (const f of widened.files) {
9239
+ for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) {
8340
9240
  closeAccountableScope.add(f);
8341
- transcriptTouched.add(f);
8342
- }
8343
- }
8344
-
8345
- // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but a
8346
- // file this session cannot be held to still demotes to a notice: a
8347
- // session's own scoped auto-commit (commitWikiChanges, PR #222) can
8348
- // leave the working tree non-empty when a DIFFERENT session sharing
8349
- // this vault still has its own file dirty, the 2026-08-03 multi-session
8350
- // block. That dirty file is human-fixable by whoever owns it, not by
8351
- // this session, so it demotes to a notice (listed by path, never
8352
- // silently dropped) instead of refusing this session's marker. Which
8353
- // set decides "not mine" depends on what the caller told us: a
8354
- // caller-provided project (--project, or the cwd-derived
8355
- // attributionScope) demotes by PATH STRUCTURE alone
8356
- // (isForeignProjectFile below), independent of transcript trust; with
8357
- // no such scope, only a trusted transcript's closeAccountableScope can
8358
- // tell mine from foreign, so an untrusted,
8359
- // unscoped session falls back to the original unscoped blocker:
8360
- // "cannot attribute" is not "clean". A dirty file THIS session owns
8361
- // still blocks unconditionally either way, and so does the
8362
- // enumeration-failed case below (dirty.length === 0 despite
8363
- // git.uncommitted === true).
9241
+ }
9242
+ }
9243
+
9244
+ // 1. wiki git state. An uncommitted file blocks only when this session's
9245
+ // touched-paths record (or its failure flag) says the write is this
9246
+ // session's. A DIFFERENT session sharing this vault can leave the working
9247
+ // tree non-empty with its own dirty file (the 2026-08-03 multi-session
9248
+ // block). That file is human-fixable by whoever owns it, not by this
9249
+ // session, so it demotes to a notice (listed by path, never silently
9250
+ // dropped) instead of refusing this session. "Cannot attribute" is not
9251
+ // "clean" either: an enumeration failure (dirty.length === 0 despite
9252
+ // git.uncommitted === true) blocks, and so does every file of the project
9253
+ // folder being closed when the record lost paths it cannot name.
8364
9254
  // Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
8365
9255
  // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
8366
9256
  // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
8367
9257
  // here (the shared gate) keeps the marker == compact-ready invariant:
8368
9258
  // a committed-but-unpushed close marks AND compacts, instead of the close writer
8369
9259
  // committing its own payload and then being blocked by its own (unpushed) commit.
8370
- const git = hypoIsClean(hypoDir);
8371
- // checkpointMode: a marker-writing
8372
- // path's own gate call, replacing the whole git axis above with a narrower
8373
- // question: "is there an uncommitted write THIS session is known to still
8374
- // own, or any uncommitted file in the project folder being closed".
8375
- // Everything else (close files, cwd, hot structure, lint, W8,
8376
- // feedback) is untouched below; Stop, PreCompact and check never set this.
8377
- if (opts.checkpointMode) {
8378
- if (!isValidSessionId(opts.sessionId)) {
8379
- blockers.push({
8380
- type: 'checkpoint-session',
8381
- reason: 'checkpointMode requires a validated sessionId',
8382
- });
8383
- } else if (git.uncommitted) {
8384
- const dirty = gitDirtyFiles(hypoDir);
8385
- if (dirty.length === 0) {
8386
- // Enumeration itself failed (or nothing is actually dirty despite the
8387
- // porcelain status): fail closed exactly like the unscoped path below.
8388
- blockers.push({ type: 'git', reason: git.reason });
8389
- } else {
8390
- const touched = readTouchedPathsStrict(hypoDir, opts.sessionId);
8391
- if (touched.state === 'locked') {
8392
- // Another writer holds this session's touched-paths lock right now
8393
- // (usually this session's own Stop auto-commit). Transient, so
8394
- // block: the retry reads the real set instead of guessing.
8395
- blockers.push({
8396
- type: 'known-session-write',
8397
- reason: 'touched-paths lock timed out (다른 쓰기가 끝난 뒤 다시 시도하면 된다)',
8398
- });
8399
- } else {
8400
- // A corrupt set reads as "no record". It would read the same on
8401
- // every retry, and nothing clears it, so blocking on it refused
8402
- // every close for good. The project folder rule below does not
8403
- // depend on the record, so that folder stays protected; only the
8404
- // ownership of files outside it goes unjudged, and the notice
8405
- // says so.
8406
- if (touched.state === 'unreadable') {
8407
- notices.push({
8408
- type: 'touched-paths-unreadable',
8409
- reason:
8410
- 'touched-paths unreadable: ownership of dirty files outside the project folder being closed was not judged (touched-paths 기록을 읽지 못해 프로젝트 폴더 밖 파일의 소유를 판정하지 못했다)',
8411
- });
8412
- }
8413
- // The project folder this close is closing blocks on any dirty
8414
- // file, recorded or not, as the gate did before checkpointMode.
8415
- // The record is not complete enough to be the only guard there: a
8416
- // failed record (a hash sidecar or list write that did not land)
8417
- // leaves this session's own write unlisted, and a close that died
8418
- // between creating a new project's index.md and writing it leaves
8419
- // an empty file no session ever recorded. Root files and other
8420
- // projects' folders keep the record-only rule, since those are
8421
- // where another session's dirty file stopped a close for good.
8422
- // With no project named (a --mark without --project), whose folder
8423
- // it is cannot be told, so every projects/ file blocks. A log-only
8424
- // close names no project by design, so none does.
8425
- const closeSlug = opts.projectOverride || opts.attributionScope || null;
8426
- const inClosedProject = (f) =>
8427
- closeSlug
8428
- ? f.startsWith(`projects/${closeSlug}/`)
8429
- : !logOnly && f.startsWith('projects/');
8430
- const touchedSet = new Set((touched.paths || []).map(posixPath));
8431
- const driftedSet = new Set(
8432
- readTouchedPathsDrifted(hypoDir, opts.sessionId).map(posixPath),
8433
- );
8434
- const ignorePatterns = loadHypoIgnore(hypoDir);
8435
- // A session entry of a `.hypoignore` project is never committed by
8436
- // anything, so it is dirty for good and would refuse every close of
8437
- // that project. This session's own one is proven by its close pin's
8438
- // local proof instead (the receipt checks the same bytes); another
8439
- // session's is that session's to prove. Only an own entry whose bytes
8440
- // no longer match its proof still blocks, below.
8441
- const provenLocal = new Set(
8442
- Object.values(readClosePin(hypoDir, opts.sessionId).localProofs)
8443
- .filter((p) => fileSha256(join(hypoDir, p.entryRelPath)) === p.entrySha256)
8444
- .map((p) => posixPath(p.entryRelPath)),
8445
- );
8446
- for (const f of dirty) {
8447
- const pf = posixPath(f);
8448
- if (
8449
- ignorePatterns.length > 0 &&
8450
- isSessionEntryPath(pf) &&
8451
- isIgnored(join(hypoDir, f), hypoDir, ignorePatterns) &&
8452
- (provenLocal.has(pf) || !touchedSet.has(pf))
8453
- ) {
8454
- notices.push({
8455
- type: 'local-entry',
8456
- file: f,
8457
- reason: provenLocal.has(pf)
8458
- ? `${f} stays out of git (.hypoignore); this session's close pin proves its bytes`
8459
- : `${f} stays out of git (.hypoignore) and belongs to another session's close`,
8460
- });
8461
- continue;
8462
- }
8463
- if (isForeignAppendOnlyShard(hypoDir, pf, opts.sessionId)) {
8464
- // Ahead of the touched-set check: the shard this session's close
8465
- // committed stays in its set until Stop, and the later close of the
8466
- // same project appends to it before this marker gate is done.
8467
- notices.push({ type: 'foreign-append', file: f, reason: FOREIGN_APPEND_NOTICE(f) });
8468
- continue;
8469
- }
8470
- if (touchedSet.has(posixPath(f))) {
8471
- // A touched file .hypoignore keeps out of every commit stays
8472
- // dirty forever on its own: say how to get out. A file whose
8473
- // bytes changed after this session recorded it is kept out of
8474
- // the auto-commit (commitTouchedPaths), so say that too.
8475
- const ignoredHint =
8476
- ignorePatterns.length > 0 && isIgnored(join(hypoDir, f), hypoDir, ignorePatterns)
8477
- ? ' (.hypoignore 대상이라 자동 커밋되지 않는다. 손으로 커밋하거나 .hypoignore 에서 빼야 close 가 된다)'
8478
- : driftedSet.has(posixPath(f))
8479
- ? ' (이 세션이 기록한 뒤 다른 쓰기가 이 파일을 바꿔서 자동 커밋에서 빠진다. 지금 내용이 맞는지 확인하고 손으로 커밋해야 close 가 된다)'
8480
- : '';
8481
- blockers.push({
8482
- type: 'known-session-write',
8483
- file: f,
8484
- reason: `this session's own write is still uncommitted: ${f}${ignoredHint}`,
8485
- });
8486
- } else if (isForeignUncommittedEntry(hypoDir, pf, opts.sessionId)) {
8487
- // Two sessions closing one project at once: the other one's entry
8488
- // is published and its commit has not landed yet.
8489
- notices.push({ type: 'foreign-entry', file: f, reason: FOREIGN_ENTRY_NOTICE(f) });
8490
- } else if (inClosedProject(posixPath(f))) {
8491
- blockers.push({
8492
- type: 'git',
8493
- file: f,
8494
- reason: closeSlug
8495
- ? `uncommitted file in the project folder being closed: ${f} (close 하는 프로젝트 폴더 안의 미커밋 파일은 이 세션의 쓰기 기록에 없어도 막는다. 커밋하거나 되돌려야 close 가 된다)`
8496
- : `uncommitted file under projects/ and this close names no project: ${f} (close 하는 프로젝트를 알 수 없어 projects/ 아래 미커밋 파일은 쓰기 기록에 없어도 막는다. --project 로 프로젝트를 지정하거나, 커밋하거나 되돌려야 close 가 된다)`,
8497
- });
8498
- } else {
8499
- notices.push({
8500
- type: 'unresolved',
8501
- file: f,
8502
- reason: `uncommitted changes, ownership unknown: ${f}`,
8503
- });
8504
- }
8505
- }
8506
- }
8507
- }
8508
- } else if (git.ahead) {
8509
- notices.push({
8510
- type: 'git-sync',
8511
- reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
8512
- });
8513
- }
9260
+ const git = gateGitState(hypoDir, { deadline });
9261
+ // One git axis for every caller (Stop, PreCompact, check, --mark, apply): the
9262
+ // question is "is there an uncommitted write THIS session recorded (or failed
9263
+ // to record)". The project folder being closed blocks only what the record
9264
+ // says is this session's, and every file in it only when the record itself
9265
+ // lost paths it cannot name (or there is no session to read a record of).
9266
+ // Everything else is another session's or nobody's, and is listed as a
9267
+ // notice. The other axes (close files, cwd, hot structure, lint, W8,
9268
+ // feedback) are untouched below. checkpointMode, set by the two marker
9269
+ // writers, adds one thing: a session id that fails validation is a blocker of
9270
+ // its own instead of reading as "no session".
9271
+ if (opts.checkpointMode && !isValidSessionId(opts.sessionId)) {
9272
+ blockers.push({
9273
+ type: 'checkpoint-session',
9274
+ reason: 'checkpointMode requires a validated sessionId',
9275
+ });
8514
9276
  } else if (git.uncommitted) {
8515
- const dirty = gitDirtyFiles(hypoDir);
9277
+ const dirty = git.dirty;
8516
9278
  if (dirty.length === 0) {
8517
9279
  // Enumeration itself failed (or nothing is actually dirty despite the
8518
- // porcelain status): "cannot attribute" is not "clean", block exactly
8519
- // as before, override or not.
8520
- blockers.push({ type: 'git', reason: git.reason });
8521
- } else if (opts.projectOverride || opts.attributionScope) {
8522
- // session-close-scope-boundary spec §2b, revised 2026-09-11: a
8523
- // caller-provided scope (an explicit --project, or the cwd-derived
8524
- // attributionScope) proves which project is ours PATH-STRUCTURALLY,
8525
- // regardless of transcript trust. This used to run only when
8526
- // !sessionTouchTrusted, on the theory that a trusted transcript could
8527
- // fall back to closeAccountableScope instead. But closeAccountableScope
8528
- // is `closeFileTargetsGlobal` whenever opts.projectOverride is unset
8529
- // (every caller here passes attributionScope, never projectOverride;
8530
- // see crystallize-close-apply.mjs), which unions in every OTHER
8531
- // today-active project's own mandatory close files too. That let a
8532
- // different session's still-dirty close files (session-state.md,
8533
- // project hot.md, today's session-log shard) block THIS session's
8534
- // marker even with --project set. A dirty file that lives structurally
8535
- // under a DIFFERENT eligible project's own
8536
- // directory needs no attribution inference at all: the path alone
8537
- // proves it is not this session's file. Everything else (this
8538
- // session's own project, pages/, an unregistered or _template project
8539
- // dir) keeps the pre-existing fail-closed behavior; only a
8540
- // provably-foreign path is demoted.
8541
- const effectiveOverride = opts.projectOverride || opts.attributionScope;
8542
- // Computed once per gate call, never per file (collectProjectWorkingDirs
8543
- // walks the projects/ dir and reads every index.md). A throw here (a
8544
- // readdirSync failure it does not catch itself) must not escape as an
8545
- // exception, the PreCompact hook's outermost catch would turn that
8546
- // into a silent, fully-suppressed gate result, the opposite of "fail
8547
- // closed". Treat any failure as "no eligible projects known", which
8548
- // makes isForeignProjectFile() below always false and every dirty file
8549
- // falls through to the unconditional blocker.
8550
- let eligibleSlugs = null;
8551
- try {
8552
- eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
8553
- } catch {
8554
- eligibleSlugs = null;
8555
- }
8556
- // isForeignProjectFile is the extracted, exported predicate (spec §5):
8557
- // hypo-compact-guard.mjs calls the exact same function on its own git
8558
- // axis, instead of a second, silently-diverging copy of this rule.
8559
- const isForeign = (f) =>
8560
- isForeignProjectFile(f, { eligibleSlugs, effectiveOverride, transcriptTouched });
8561
- const foreign = dirty.filter(isForeign);
8562
- // A dirty file inside the scoped project's OWN directory blocks, even one
8563
- // this close does not write. An earlier revision demoted those to notices
8564
- // to escape a deadlock: a close whose commit fails leaves
8565
- // `projects/<p>/index.md` (seeded by ensureProjectIndex) uncommitted, the
8566
- // retry skips every payload field as already-current without re-staging
8567
- // it, and the marker can then never land again no matter how often the
8568
- // user retries.
8569
- //
8570
- // That demotion was far wider than the deadlock it answered. It waved
8571
- // through every unsaved file in the project, which is exactly the work a
8572
- // close is supposed to refuse to walk away from. The deadlock is fixed at
8573
- // its source instead: applyOverwrites re-stages index.md on the retry
8574
- // path (crystallize-close-apply.mjs), so the file this branch used to
8575
- // demote is now in the close's own commit scope and never reaches here.
8576
- // Another session's entry still waiting for its own commit, or its
8577
- // append to a shared session-log shard, is not this session's to commit,
8578
- // inside the scoped project or not (the checkpoint branch above demotes
8579
- // both the same way).
8580
- const foreignEntries = dirty.filter(
8581
- (f) => !isForeign(f) && isForeignUncommittedEntry(hypoDir, posixPath(f), opts.sessionId),
8582
- );
8583
- const foreignAppends = dirty.filter(
8584
- (f) =>
8585
- !isForeign(f) &&
8586
- !foreignEntries.includes(f) &&
8587
- isForeignAppendOnlyShard(hypoDir, posixPath(f), opts.sessionId),
8588
- );
8589
- const rest = dirty.filter(
8590
- (f) => !isForeign(f) && !foreignEntries.includes(f) && !foreignAppends.includes(f),
8591
- );
8592
- if (rest.length > 0) {
8593
- blockers.push({
8594
- type: 'git',
8595
- reason: `uncommitted changes in ${hypoDir}: ${rest.join(', ')}`,
8596
- });
8597
- }
8598
- for (const f of foreign) {
9280
+ // porcelain status): "cannot attribute" is not "clean", so fail closed.
9281
+ // One exception: the gate deadline ran out before the one status run
9282
+ // finished, so there is no file list to judge. That blocks only when this
9283
+ // session has a record of writing anything (touched-paths or a failure
9284
+ // flag), else it is a notice. A call with no valid session id has no
9285
+ // record to read, which the block-everything rule below treats as "cannot
9286
+ // tell", so the cut counts as recorded.
9287
+ const timedOut = git.timedOut;
9288
+ const rec = isValidSessionId(opts.sessionId)
9289
+ ? readSessionRecord(hypoDir, opts.sessionId, { deadline })
9290
+ : null;
9291
+ const recorded = !rec || !rec.complete || rec.paths.length > 0 || rec.flags.any;
9292
+ if (timedOut && !recorded) {
8599
9293
  notices.push({
8600
- type: 'git',
8601
- file: f,
8602
- reason: `uncommitted changes outside this session's scope: ${f}`,
9294
+ type: 'unresolved',
9295
+ reason: `could not list the uncommitted files within the gate deadline (${GATE_DEADLINE_MS} ms), and this session has no record of writing (마감 안에 미커밋 파일을 열거하지 못함)`,
8603
9296
  });
8604
- }
8605
- for (const f of foreignEntries) {
8606
- notices.push({ type: 'foreign-entry', file: f, reason: FOREIGN_ENTRY_NOTICE(f) });
8607
- }
8608
- for (const f of foreignAppends) {
8609
- notices.push({ type: 'foreign-append', file: f, reason: FOREIGN_APPEND_NOTICE(f) });
8610
- }
8611
- } else if (!sessionTouchTrusted) {
8612
- blockers.push({ type: 'git', reason: git.reason });
8613
- } else {
8614
- // closeAccountableScope holds every entry of today in the project, the
8615
- // other sessions' too, and the day's session-log shard they all append
8616
- // to, so a foreign entry still waiting for its commit and a foreign
8617
- // append are taken out here like in the scoped branch above.
8618
- const foreignEntries = dirty.filter((f) =>
8619
- isForeignUncommittedEntry(hypoDir, posixPath(f), opts.sessionId),
8620
- );
8621
- const foreignAppends = dirty.filter(
8622
- (f) =>
8623
- !foreignEntries.includes(f) &&
8624
- isForeignAppendOnlyShard(hypoDir, posixPath(f), opts.sessionId),
8625
- );
8626
- for (const f of foreignEntries) {
8627
- notices.push({ type: 'foreign-entry', file: f, reason: FOREIGN_ENTRY_NOTICE(f) });
8628
- }
8629
- for (const f of foreignAppends) {
8630
- notices.push({ type: 'foreign-append', file: f, reason: FOREIGN_APPEND_NOTICE(f) });
8631
- }
8632
- const demoted = (f) => foreignEntries.includes(f) || foreignAppends.includes(f);
8633
- const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)) && !demoted(f));
8634
- const foreign = dirty.filter((f) => !closeAccountableScope.has(posixPath(f)) && !demoted(f));
8635
- if (mine.length > 0) {
9297
+ } else {
8636
9298
  blockers.push({
8637
9299
  type: 'git',
8638
- reason: `uncommitted changes in ${hypoDir}: ${mine.join(', ')}`,
9300
+ reason: timedOut
9301
+ ? isValidSessionId(opts.sessionId)
9302
+ ? `could not list the uncommitted files within the gate deadline (${GATE_DEADLINE_MS} ms) and this session has a record of writing (마감 안에 미커밋 파일을 열거하지 못함, 이 세션의 기록이 있다. 다시 시도하면 된다)`
9303
+ : `could not list the uncommitted files within the gate deadline (${GATE_DEADLINE_MS} ms), and with no session id this session's files cannot be told apart: pass --session-id, or retry (마감 안에 미커밋 파일을 열거하지 못함, 세션 ID가 없어 이 세션의 파일을 가를 수 없다. --session-id를 주거나 다시 시도하면 된다)`
9304
+ : git.reason,
8639
9305
  });
8640
9306
  }
8641
- for (const f of foreign) {
8642
- notices.push({
8643
- type: 'git',
8644
- file: f,
8645
- reason: `uncommitted changes outside this session's scope: ${f}`,
9307
+ } else {
9308
+ // No valid session id (a check run without --session-id): there is no
9309
+ // record to read, so nothing can be told apart as this session's and
9310
+ // the project folder rule below applies to every file in it.
9311
+ const hasSession = isValidSessionId(opts.sessionId);
9312
+ // The whole record (set, hashes, failure flag, stamp) by one reading rule: a file that is
9313
+ // there but cannot be read makes it incomplete, never empty (readSessionRecord).
9314
+ const record = hasSession
9315
+ ? readSessionRecord(hypoDir, opts.sessionId, { deadline })
9316
+ : {
9317
+ paths: [],
9318
+ hashes: {},
9319
+ flags: { any: false, paths: [], pathsUnknown: false },
9320
+ stamped: false,
9321
+ complete: true,
9322
+ incompleteWhy: [],
9323
+ };
9324
+ const whyOf = (source, cause) =>
9325
+ record.incompleteWhy.some((w) => w.source === source && (!cause || w.cause === cause));
9326
+ if (whyOf('set', 'locked')) {
9327
+ // Another writer holds this session's touched-paths lock right now
9328
+ // (usually this session's own Stop auto-commit). Transient, so
9329
+ // block: the retry reads the real set instead of guessing.
9330
+ blockers.push({
9331
+ type: 'known-session-write',
9332
+ reason:
9333
+ "this session's write record lock timed out (다른 쓰기가 끝난 뒤 다시 시도하면 된다)",
8646
9334
  });
9335
+ } else {
9336
+ // An unreadable set reads as "no record" and would read the same on
9337
+ // every retry, so it is not a blocker of its own (that refused every
9338
+ // close for good). It widens the project folder rule instead
9339
+ // (folderWide below): the record can no longer say which files in the
9340
+ // folder are this session's, so all of them block, with the way out
9341
+ // in the reason.
9342
+ if (whyOf('set') || whyOf('hashes')) {
9343
+ notices.push({
9344
+ type: 'touched-paths-unreadable',
9345
+ reason:
9346
+ "this session's write record unreadable: ownership of dirty files outside the project folder being closed was not judged; the project-folder block above says how to clear it (이 세션의 쓰기 기록을 읽지 못해 프로젝트 폴더 밖 파일의 소유를 판정하지 못했다)",
9347
+ });
9348
+ }
9349
+ // A record that failed to land leaves a failure flag
9350
+ // (touched-paths-failed.d/, see markTouchedPathsFailed). The rule is
9351
+ // coarse on purpose: a flagged path blocks wherever it sits whenever
9352
+ // it is uncommitted, whatever its bytes, and any flag at all (like a
9353
+ // flag that lost paths it cannot name, or an unreadable record) makes
9354
+ // the whole project folder being closed block (folderWide) until a
9355
+ // person removes the flag or the session ends. Judging by the bytes a
9356
+ // flag recorded let a write through each time that evidence went
9357
+ // stale. Another session's dirty file there stays a notice only while
9358
+ // the record is complete and has no flag. With no project named (a
9359
+ // --mark without --project), whose folder it is cannot be told, so
9360
+ // every projects/ file counts. A log-only close names no project by
9361
+ // design, so none does.
9362
+ const failed = record.flags;
9363
+ const flaggedSet = new Set(failed.paths.map(posixPath));
9364
+ // A session that began on an older version has no stamp (see stampSessionRecord): its
9365
+ // record may be missing writes without a flag to say so, so it gets the rule main used
9366
+ // before records decided ownership: every uncommitted file in the project folder being
9367
+ // closed blocks. Only a stamped session's silence is trusted, and only while the record
9368
+ // is complete (every file of it read) and it has no failure flag.
9369
+ const legacySession = hasSession && !record.stamped;
9370
+ const folderWide =
9371
+ !hasSession || legacySession || !record.complete || failed.pathsUnknown || failed.any;
9372
+ // Absolute, so the way out can be followed from any working directory.
9373
+ const cacheDir = hasSession ? sessionCacheDir(hypoDir, opts.sessionId) : '';
9374
+ // How to clear an incomplete record, one sentence per cause that applies.
9375
+ const recordExit = [
9376
+ failed.any
9377
+ ? `git status 의 미커밋 파일 가운데 이 세션이 쓴 것이 남아 있지 않은지 확인한 뒤 ${join(cacheDir, 'touched-paths-failed.d')}/ 를 통째로 지운다`
9378
+ : null,
9379
+ whyOf('set', 'corrupt')
9380
+ ? `손상된 ${join(cacheDir, 'touched-paths.json')} 을 살펴보고 지우면 풀린다. 지우기 전에 이 세션이 쓴 파일이 이미 커밋됐는지 git status 로 확인한다`
9381
+ : null,
9382
+ whyOf('hashes')
9383
+ ? `손상된 ${join(cacheDir, 'touched-hashes.json')} 을 살펴보고 지우면 풀린다. 지우기 전에 이 세션이 쓴 파일이 이미 커밋됐는지 git status 로 확인한다`
9384
+ : null,
9385
+ whyOf('set', 'lock-failed')
9386
+ ? `${cacheDir}/ 디렉터리의 권한과 디스크 공간을 점검한다 (파일을 지워도 고쳐지지 않는다)`
9387
+ : null,
9388
+ ]
9389
+ .filter(Boolean)
9390
+ .join('. ');
9391
+ const closeSlug = opts.projectOverride || opts.attributionScope || null;
9392
+ // The folder rule's reason says one cause, the first that applies: no session id, then
9393
+ // no stamp, then an incomplete record, then a failure flag. Each has its own way out, and
9394
+ // only one is said.
9395
+ const folderReason = (f) => {
9396
+ const head = closeSlug
9397
+ ? `uncommitted file in the project folder being closed: ${f}`
9398
+ : `uncommitted file under projects/ and this close names no project: ${f}`;
9399
+ if (!hasSession) {
9400
+ return `${head} (이 호출에는 세션 id 가 없어 이 세션 파일을 가를 수 없다. --session-id 를 주면 이 세션이 쓴 파일만 막는다)`;
9401
+ }
9402
+ if (legacySession) {
9403
+ const stamp = whyOf('stamp')
9404
+ ? 'record-stamp.json 을 읽지 못함'
9405
+ : 'record-stamp.json 없음';
9406
+ return `${head} (이 세션은 쓰기를 빠짐없이 기록하는 버전에서 시작하지 않았다(${stamp}). 그래서 기록에 없어도 이 폴더의 미커밋 파일을 모두 막는다. 이 파일은 다른 세션의 작업일 수 있다. 다른 터미널의 작업이면 되돌리지 않는다. 이 세션 것임을 확인한 파일만 커밋하거나 되돌리고, 아니면 새 세션을 열어 거기서 close 한다)`;
9407
+ }
9408
+ if (!record.complete || failed.pathsUnknown) {
9409
+ return `${head} (이 세션의 쓰기 기록이 불완전해 잃은 경로를 모르므로 close 하는 프로젝트 폴더의 미커밋 파일을 모두 막는다. ${recordExit}. 이 세션 것임을 확인한 파일은 커밋하거나 되돌려도 풀린다)`;
9410
+ }
9411
+ return `${head} (이 세션의 쓰기 기록이 한 번이라도 실패했다(실패 표시 있음). 표시가 남아 있으면 세션이 끝날 때까지 close 하는 프로젝트 폴더의 미커밋 파일을 모두 막는다. 이 파일은 다른 세션의 작업일 수 있다. ${recordExit}. 아니면 새 세션을 열어 거기서 close 한다. 이 세션 것임을 확인한 파일은 커밋하거나 되돌려도 풀린다)`;
9412
+ };
9413
+ const inClosedProject = (f) =>
9414
+ closeSlug
9415
+ ? f.startsWith(`projects/${closeSlug}/`)
9416
+ : !logOnly && f.startsWith('projects/');
9417
+ const touchedSet = new Set(record.paths.map(posixPath));
9418
+ const driftedSet = new Set(
9419
+ record.paths.filter((p) => recordedHashDrifted(hypoDir, record.hashes, p)).map(posixPath),
9420
+ );
9421
+ const ignorePatterns = loadHypoIgnore(hypoDir);
9422
+ // A session entry of a `.hypoignore` project is never committed by
9423
+ // anything, so it is dirty for good and would refuse every close of
9424
+ // that project. This session's own one is proven by its close pin's
9425
+ // local proof instead (the receipt checks the same bytes); another
9426
+ // session's is that session's to prove. Only an own entry whose bytes
9427
+ // no longer match its proof still blocks, below.
9428
+ const provenLocal = new Set(
9429
+ Object.values(hasSession ? readClosePin(hypoDir, opts.sessionId).localProofs : {})
9430
+ .filter((p) => fileSha256(join(hypoDir, p.entryRelPath)) === p.entrySha256)
9431
+ .map((p) => posixPath(p.entryRelPath)),
9432
+ );
9433
+ for (const f of dirty) {
9434
+ const pf = posixPath(f);
9435
+ // First, ahead of the local-entry and foreign-append exceptions:
9436
+ // an active flag means a record of this path was lost, so who owns
9437
+ // the bytes is not left to those exceptions.
9438
+ if (flaggedSet.has(pf)) {
9439
+ blockers.push({
9440
+ type: 'known-session-write',
9441
+ file: f,
9442
+ reason: `uncommitted file this session recorded with a failure flag: ${f} (이 세션의 쓰기 기록이 실패했고, ${join(cacheDir, 'touched-paths-failed.d')}/ 의 표시가 이 경로를 가리킨다. 지금 내용과 무관하게 막는다. 이 세션 것이면 커밋하거나 되돌리면 풀린다. 다른 세션의 작업일 수 있으면 되돌리지 말고, git status 로 이 세션이 쓴 것이 남아 있지 않음을 확인한 뒤 ${join(cacheDir, 'touched-paths-failed.d')}/ 를 통째로 지우거나 새 세션에서 close 한다)`,
9443
+ });
9444
+ continue;
9445
+ }
9446
+ if (
9447
+ ignorePatterns.length > 0 &&
9448
+ isSessionEntryPath(pf) &&
9449
+ isIgnored(join(hypoDir, f), hypoDir, ignorePatterns) &&
9450
+ (provenLocal.has(pf) || !touchedSet.has(pf))
9451
+ ) {
9452
+ notices.push({
9453
+ type: 'local-entry',
9454
+ file: f,
9455
+ reason: provenLocal.has(pf)
9456
+ ? `${f} stays out of git (.hypoignore); this session's close pin proves its bytes`
9457
+ : `${f} stays out of git (.hypoignore) and belongs to another session's close`,
9458
+ });
9459
+ continue;
9460
+ }
9461
+ // Past the deadline the ownership exceptions below (another session's
9462
+ // append or entry) cannot be judged, so they are not applied: a file
9463
+ // this session recorded still blocks, any other is an unresolved notice.
9464
+ if (!cut() && isForeignAppendOnlyShard(hypoDir, pf, opts.sessionId, { deadline })) {
9465
+ // Ahead of the touched-set check: the shard this session's close
9466
+ // committed stays in its set until Stop, and the later close of the
9467
+ // same project appends to it before this marker gate is done.
9468
+ notices.push({ type: 'foreign-append', file: f, reason: FOREIGN_APPEND_NOTICE(f) });
9469
+ continue;
9470
+ }
9471
+ if (touchedSet.has(posixPath(f))) {
9472
+ // A touched file .hypoignore keeps out of every commit stays
9473
+ // dirty forever on its own: say how to get out. A file whose
9474
+ // bytes changed after this session recorded it is kept out of
9475
+ // the auto-commit (commitTouchedPaths), so say that too.
9476
+ const pathHint = emptyNewIndexHint(hypoDir, pf, { deadline })
9477
+ ? ' (빈 파일이다. 이 세션의 close 가 만들다 멈춘 것이니 지우고 close 를 다시 하면 된다)'
9478
+ : ignorePatterns.length > 0 && isIgnored(join(hypoDir, f), hypoDir, ignorePatterns)
9479
+ ? ' (.hypoignore 대상이라 자동 커밋되지 않는다. 손으로 커밋하거나 .hypoignore 에서 빼야 close 가 된다)'
9480
+ : driftedSet.has(posixPath(f))
9481
+ ? ' (이 세션이 기록한 뒤 다른 쓰기가 이 파일을 바꿔서 자동 커밋에서 빠진다. 지금 내용이 맞는지 확인하고 손으로 커밋해야 close 가 된다)'
9482
+ : '';
9483
+ blockers.push({
9484
+ type: 'known-session-write',
9485
+ file: f,
9486
+ reason: `this session's own write is still uncommitted: ${f}${pathHint}`,
9487
+ });
9488
+ } else if (inClosedProject(pf) && emptyNewIndexHint(hypoDir, pf, { deadline })) {
9489
+ // A zero-byte index.md HEAD does not have is a create cut between its open and its
9490
+ // write. Whose create it was is not told apart: it blocks with or without a record.
9491
+ blockers.push({
9492
+ type: 'git',
9493
+ file: f,
9494
+ reason: `empty index.md in the project folder being closed: ${f} (생성 도중 끊긴 파일이다. 이 세션 것이면 내용을 채우거나 지운다. 다른 세션이 만들다 만 것이면(그 세션이 죽었으면) 그 파일을 지운다. 내용이 있는 index.md는 막지 않는다)`,
9495
+ });
9496
+ } else if (
9497
+ !cut() &&
9498
+ isForeignUncommittedEntry(hypoDir, pf, opts.sessionId, { deadline })
9499
+ ) {
9500
+ // Two sessions closing one project at once: the other one's entry
9501
+ // is published and its commit has not landed yet.
9502
+ notices.push({ type: 'foreign-entry', file: f, reason: FOREIGN_ENTRY_NOTICE(f) });
9503
+ } else if (folderWide && inClosedProject(posixPath(f))) {
9504
+ blockers.push({ type: 'git', file: f, reason: folderReason(f) });
9505
+ } else {
9506
+ notices.push({
9507
+ type: 'unresolved',
9508
+ file: f,
9509
+ reason: cut()
9510
+ ? `uncommitted changes, ownership not judged within the gate deadline (${GATE_DEADLINE_MS} ms): ${f} (마감 안에 귀속을 정하지 못함)`
9511
+ : `uncommitted changes, ownership unknown: ${f}`,
9512
+ });
9513
+ }
9514
+ }
8647
9515
  }
8648
9516
  }
8649
9517
  } else if (git.ahead) {
@@ -8689,6 +9557,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8689
9557
  // looked at, which is the false-green this ordering exists to stop.
8690
9558
  const attributedScope = resolveCloseScope(hypoDir, opts, marker);
8691
9559
  close = sessionCloseGlobalStatus(hypoDir, {
9560
+ deadline,
8692
9561
  projectOverride: opts.projectOverride,
8693
9562
  // direct only: a past marker's projects must not become mandatory
8694
9563
  // evaluation targets (codex round 2). They stay in attributedScope so the
@@ -8812,6 +9681,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8812
9681
  project: cwdProject,
8813
9682
  }),
8814
9683
  projectOverride: cwdProject,
9684
+ deadline,
8815
9685
  });
8816
9686
  // The notices of the session-scope check (a project with no log lines under the
8817
9687
  // entry's date) belong to the gate result: a caller that reads only `notices[]` would
@@ -8827,10 +9697,16 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8827
9697
  blockers.push({
8828
9698
  type: 'close-cwd',
8829
9699
  project: cwdProject,
8830
- reason: `session cwd project '${cwdProject}' has an incomplete session close: ${[
8831
- ...s.missing.map((f) => `${f} (missing)`),
8832
- ...s.stale.map((f) => `${f} (stale)`),
8833
- ].join(', ')}`,
9700
+ // Set when the proof was cut by the gate deadline: the close did not fail, so
9701
+ // Stop must not read this blocker as an unfinished close (it only notes it).
9702
+ // Writers still refuse on it like any blocker.
9703
+ ...(s.incomplete ? { incomplete: s.incomplete } : {}),
9704
+ reason: s.incomplete
9705
+ ? `could not finish proving this session's close of '${cwdProject}' within the gate deadline (${GATE_DEADLINE_MS} ms, slow git). The close did not fail; try again (마감 안에 이 세션의 close 증명을 마치지 못함)`
9706
+ : `session cwd project '${cwdProject}' has an incomplete session close: ${[
9707
+ ...s.missing.map((f) => `${f} (missing)`),
9708
+ ...s.stale.map((f) => `${f} (stale)`),
9709
+ ].join(', ')}`,
8834
9710
  });
8835
9711
  // If the partition demoted this project to foreign debt, it is NOT another
8836
9712
  // session's work — it is THIS session's, and we just BLOCKED on it. Remove
@@ -8866,6 +9742,8 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8866
9742
  const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
8867
9743
  if (!lintPath || !existsSync(lintPath)) {
8868
9744
  skipped.lint = true; // no package → fail-open (never block on missing tooling)
9745
+ skippedWhy.lint =
9746
+ 'tool missing: scripts/lint.mjs not found, no package root or no such file; run `hypomnema init` or reinstall the plugin';
8869
9747
  } else {
8870
9748
  try {
8871
9749
  // Pass --hypo-dir explicitly: lint.mjs resolves the vault via HYPO_DIR /
@@ -8874,18 +9752,23 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8874
9752
  // maxBuffer matches crystallize's runLint (64 MiB): warn-heavy output on a
8875
9753
  // large wiki easily exceeds Node's 1 MiB default, which would truncate
8876
9754
  // stdout and (via the catch below) silently fail-open this gate.
8877
- const r = spawnSync('node', [lintPath, '--json', `--hypo-dir=${hypoDir}`], {
9755
+ const run = startCheck('lint', [lintPath, '--json', `--hypo-dir=${hypoDir}`], {
8878
9756
  encoding: 'utf-8',
8879
9757
  cwd: hypoDir,
8880
- timeout: 30000,
8881
9758
  maxBuffer: 64 * 1024 * 1024,
8882
9759
  });
8883
- // A spawn failure (ENOENT), a timeout/kill (status === null), or a crash
8884
- // that produced no stdout must NOT be parsed as `{}` and treated as a clean
9760
+ const r = run.res;
9761
+ // The time limit the gate gave (or none left to start): a notice, handled
9762
+ // in the catch below, never a blocker.
9763
+ if (run.kind === 'deadline' || run.kind === 'timeout') {
9764
+ throw Object.assign(new Error('lint hit the gate time limit'), { timeLimit: run });
9765
+ }
9766
+ // A spawn failure (ENOENT), an outside kill (a signal), or a crash that
9767
+ // produced no stdout must NOT be parsed as `{}` and treated as a clean
8885
9768
  // lint — that path leaves skipped.lint=false with no notice, an INVISIBLE
8886
- // fail-open. Throw instead so the catch below records skipped.lint=true WITH
8887
- // a reason notice.
8888
- if (r.error || r.status === null) {
9769
+ // fail-open. Throw instead so the catch below records skipped.lint=true and
9770
+ // a `check-skipped` blocker.
9771
+ if (run.kind === 'spawn-error' || run.kind === 'signal') {
8889
9772
  throw new Error(`lint spawn failed: ${r.error?.code || `signal ${r.signal}`}`);
8890
9773
  }
8891
9774
  if (!r.stdout || !r.stdout.trim()) {
@@ -8945,13 +9828,10 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8945
9828
  for (const w of w8Notice)
8946
9829
  notices.push({ type: 'design-history', file: w.file, reason: w.file });
8947
9830
  } catch (e) {
8948
- skipped.lint = true; // fail-open on tooling error
8949
- // Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
8950
- // instead of silently dropping the check — a fail-open is invisible otherwise.
8951
- notices.push({
8952
- type: 'lint',
8953
- reason: `lint skipped (fail-open): ${e.message || e.code || 'unknown error'}`,
8954
- });
9831
+ // Only a time limit fails open (a notice). Anything else (a crash, an
9832
+ // outside kill, no or broken stdout) blocks, with the cause in the reason.
9833
+ if (e.timeLimit) checkTimeLimit('lint', e.timeLimit);
9834
+ else checkFailed('lint', e.message || e.code || 'unknown error');
8955
9835
  }
8956
9836
  }
8957
9837
 
@@ -8962,10 +9842,12 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8962
9842
  const claudeHome = opts.claudeHome || join(HOME, '.claude');
8963
9843
  if (!feedbackPath || !existsSync(feedbackPath)) {
8964
9844
  skipped.feedback = true;
9845
+ skippedWhy.feedback =
9846
+ 'tool missing: scripts/feedback-sync.mjs not found, no package root or no such file; run `hypomnema init` or reinstall the plugin';
8965
9847
  } else {
8966
9848
  try {
8967
- const r = spawnSync(
8968
- process.execPath,
9849
+ const run = startCheck(
9850
+ 'feedback',
8969
9851
  [
8970
9852
  feedbackPath,
8971
9853
  '--check',
@@ -8975,10 +9857,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8975
9857
  `--hypo-dir=${hypoDir}`,
8976
9858
  `--claude-home=${claudeHome}`,
8977
9859
  ],
8978
- { encoding: 'utf-8', timeout: 30000 },
9860
+ { encoding: 'utf-8' },
8979
9861
  );
8980
- if (r.error || r.status === null) {
8981
- skipped.feedback = true; // spawn failure → fail-open
9862
+ const r = run.res;
9863
+ if (run.kind === 'deadline' || run.kind === 'timeout') {
9864
+ checkTimeLimit('feedback', run); // a time limit: notice, fail-open
9865
+ } else if (run.kind === 'spawn-error' || run.kind === 'signal') {
9866
+ throw new Error(`feedback-sync spawn failed: ${r.error?.code || `signal ${r.signal}`}`);
8982
9867
  } else {
8983
9868
  // A non-zero exit carries an actionable issue. A clean check exits 0 and is NOT
8984
9869
  // skipped, just nothing to do, except that its report can still carry
@@ -8988,7 +9873,18 @@ export function precompactGateStatus(hypoDir, opts = {}) {
8988
9873
  try {
8989
9874
  report = JSON.parse(r.stdout || '');
8990
9875
  } catch {
8991
- /* unparseable → fail-open below */
9876
+ /* unparseable: an error, thrown below */
9877
+ }
9878
+ // Output that does not parse, or a report that says `error`, is an error
9879
+ // whatever the exit code (a broken or empty report on exit 0 included).
9880
+ // A parsed report without `error` and without anything to act on stays
9881
+ // fail-open below, even on a non-zero (strict) exit.
9882
+ if (!report || typeof report !== 'object' || report.error) {
9883
+ throw new Error(
9884
+ report?.error
9885
+ ? `feedback-sync reported an error: ${report.error}`
9886
+ : `feedback-sync output did not parse (exit=${r.status})${r.stderr ? `: ${String(r.stderr).slice(-500)}` : ''}`,
9887
+ );
8992
9888
  }
8993
9889
  const entries = report ? Object.entries(report.targets || {}) : [];
8994
9890
  const conflictedT = entries
@@ -9035,7 +9931,8 @@ export function precompactGateStatus(hypoDir, opts = {}) {
9035
9931
  sideWarnT.length
9036
9932
  ))
9037
9933
  ) {
9038
- skipped.feedback = true; // unparseable / non-actionable → fail-open
9934
+ skipped.feedback = true; // parsed, no error, nothing actionable → fail-open
9935
+ skippedWhy.feedback = `not actionable: feedback-sync --strict exited ${r.status} with nothing to act on`;
9039
9936
  } else if (buildErrT.length) {
9040
9937
  // Name the EXACT target file and the remedy for THIS cause. "Restore
9041
9938
  // the managed container" with no path is a dead end, and so is naming
@@ -9114,12 +10011,12 @@ export function precompactGateStatus(hypoDir, opts = {}) {
9114
10011
  });
9115
10012
  }
9116
10013
  }
9117
- } catch {
9118
- skipped.feedback = true;
10014
+ } catch (e) {
10015
+ checkFailed('feedback', e.message || e.code || 'unknown error');
9119
10016
  }
9120
10017
  }
9121
10018
 
9122
- return { ok: blockers.length === 0, close, blockers, notices, driftTargets, skipped };
10019
+ return { ok: blockers.length === 0, close, blockers, notices, driftTargets, skipped, skippedWhy };
9123
10020
  }
9124
10021
 
9125
10022
  // ── session-close checklist ────────────────────────────────────────────────
@@ -10730,6 +11627,47 @@ export function isExcludedFromCommit(hypoDir, relPath, patterns = loadHypoIgnore
10730
11627
  return patterns.length > 0 && isIgnored(join(hypoDir, relPath), hypoDir, patterns);
10731
11628
  }
10732
11629
 
11630
+ // The vault-relative spellings of `filePath` that are inside `root`: the real-path spelling first
11631
+ // (when the paths resolve) and the path as given second (so a symlinked vault, or a file reached
11632
+ // through a symlinked directory inside it, is still found). The order is a contract: git reports
11633
+ // the real spelling, so a caller that records one path takes `[0]`; a caller that only asks
11634
+ // whether any spelling matches iterates all of them. A string prefix test is not used: a trailing
11635
+ // separator on HYPO_DIR or a symlink would switch it off.
11636
+ // Shared by the close guard, the auto-stage hook and the file-watch hook, which must agree on what
11637
+ // is in the vault.
11638
+ export function vaultRelatives(root, filePath) {
11639
+ const real = (p) => {
11640
+ try {
11641
+ return realpathSync(p);
11642
+ } catch {
11643
+ return null;
11644
+ }
11645
+ };
11646
+ // The real path of the nearest ancestor that exists, with the segments below it put back: a file
11647
+ // that does not exist yet, in a directory that does not either, still resolves. `dirname` reaches
11648
+ // a fixed point at the filesystem root, which ends the walk (`null` when not even that resolves).
11649
+ const realThroughAncestor = (p) => {
11650
+ const rest = [];
11651
+ for (let d = p; ; d = dirname(d)) {
11652
+ const r = real(d);
11653
+ if (r !== null) return join(r, ...rest);
11654
+ if (dirname(d) === d) return null;
11655
+ rest.unshift(basename(d));
11656
+ }
11657
+ };
11658
+ const pairs = [[root, filePath]];
11659
+ const realRoot = real(root);
11660
+ const realFile = realThroughAncestor(filePath);
11661
+ if (realRoot !== null && realFile !== null) pairs.unshift([realRoot, realFile]);
11662
+ return (
11663
+ pairs
11664
+ .map(([r, f]) => relative(r, f))
11665
+ .filter((rel) => rel !== '' && rel.split(sep)[0] !== '..' && !isAbsolute(rel))
11666
+ // Only the platform separator becomes `/`: on POSIX a backslash is part of a file name.
11667
+ .map((rel) => rel.split(sep).join('/'))
11668
+ );
11669
+ }
11670
+
10733
11671
  /**
10734
11672
  * Whether git's own ignore rules (`.gitignore`, `info/exclude`, `core.excludesFile`) keep this
10735
11673
  * vault-relative path out of every commit. `git check-ignore` leaves tracked files out of its