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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +31 -0
- package/README.ko.md +5 -5
- package/README.md +5 -5
- package/commands/crystallize.md +21 -15
- package/commands/upgrade.md +13 -2
- package/docs/ARCHITECTURE.md +127 -63
- package/docs/CONTRIBUTING.md +7 -11
- package/hooks/base-store.mjs +31 -17
- package/hooks/hypo-auto-minimal-crystallize.mjs +39 -6
- package/hooks/hypo-auto-stage.mjs +123 -63
- package/hooks/hypo-close-guard.mjs +3 -37
- package/hooks/hypo-compact-guard.mjs +9 -6
- package/hooks/hypo-file-watch.mjs +18 -10
- package/hooks/hypo-personal-check.mjs +137 -55
- package/hooks/hypo-session-start.mjs +17 -6
- package/hooks/hypo-shared.mjs +1507 -569
- package/hooks/hypo-stop.mjs +8 -0
- package/hooks/session-views.mjs +9 -3
- package/package.json +2 -1
- package/scripts/crystallize.mjs +1 -1
- package/scripts/doctor.mjs +160 -9
- package/scripts/init.mjs +60 -22
- package/scripts/lib/crystallize-close-apply.mjs +156 -22
- package/scripts/lib/crystallize-close-check.mjs +1 -1
- package/scripts/lib/extensions.mjs +2 -2
- package/scripts/lib/git-hooks-dir.mjs +228 -31
- package/scripts/lib/plugin-detect.mjs +57 -4
- package/scripts/lib/settings-write.mjs +254 -0
- package/scripts/lib/wikilink.mjs +10 -1
- package/scripts/uninstall.mjs +3 -2
- package/scripts/upgrade.mjs +94 -23
- package/templates/Home.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +60 -22
- package/templates/session-state.md +0 -12
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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;
|
|
508
|
-
//
|
|
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).
|
|
614
|
-
*
|
|
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 = (
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
1737
|
-
const repo =
|
|
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
|
|
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
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1900
|
-
? 'migrated'
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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),
|
|
2238
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2755
|
-
unstage
|
|
2756
|
-
|
|
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`
|
|
2802
|
-
*
|
|
2803
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2915
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
//
|
|
5414
|
-
//
|
|
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
|
|
5746
|
-
* recovered from here
|
|
5747
|
-
* (there is nothing salvageable to merge
|
|
5748
|
-
* `incoming` is the correct best-effort
|
|
5749
|
-
* where the same `null` MUST NOT be
|
|
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 (
|
|
5766
|
-
//
|
|
5767
|
-
// a
|
|
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
|
|
5801
|
-
*
|
|
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
|
-
|
|
5805
|
-
|
|
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
|
|
5847
|
-
*
|
|
5848
|
-
*
|
|
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(
|
|
5874
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 `[]`.
|
|
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
|
|
5965
|
-
*
|
|
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
|
-
* @
|
|
5972
|
-
*
|
|
5973
|
-
*
|
|
5974
|
-
*
|
|
5975
|
-
*
|
|
5976
|
-
*
|
|
5977
|
-
|
|
5978
|
-
|
|
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(
|
|
5983
|
-
|
|
5984
|
-
|
|
5985
|
-
|
|
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
|
|
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(
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
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 =
|
|
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
|
|
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
|
|
6155
|
-
if (
|
|
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.
|
|
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
|
|
6175
|
-
//
|
|
6176
|
-
const hashes =
|
|
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]
|
|
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
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
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(
|
|
6285
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
7953
|
-
*
|
|
7954
|
-
*
|
|
7955
|
-
*
|
|
7956
|
-
*
|
|
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
|
-
*
|
|
8238
|
-
* transcript
|
|
8239
|
-
*
|
|
8240
|
-
*
|
|
8241
|
-
*
|
|
8242
|
-
*
|
|
8243
|
-
*
|
|
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
|
-
*
|
|
8262
|
-
*
|
|
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
|
|
8297
|
-
//
|
|
8298
|
-
//
|
|
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
|
-
//
|
|
8312
|
-
//
|
|
8313
|
-
//
|
|
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
|
|
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
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
//
|
|
8346
|
-
//
|
|
8347
|
-
//
|
|
8348
|
-
//
|
|
8349
|
-
//
|
|
8350
|
-
//
|
|
8351
|
-
//
|
|
8352
|
-
//
|
|
8353
|
-
//
|
|
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 =
|
|
8371
|
-
//
|
|
8372
|
-
//
|
|
8373
|
-
//
|
|
8374
|
-
//
|
|
8375
|
-
//
|
|
8376
|
-
//
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
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 =
|
|
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",
|
|
8519
|
-
//
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
//
|
|
8523
|
-
//
|
|
8524
|
-
//
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
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: '
|
|
8601
|
-
|
|
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:
|
|
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
|
-
|
|
8642
|
-
|
|
8643
|
-
|
|
8644
|
-
|
|
8645
|
-
|
|
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
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
8833
|
-
|
|
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
|
|
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
|
-
|
|
8884
|
-
//
|
|
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
|
|
8887
|
-
// a
|
|
8888
|
-
if (
|
|
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
|
-
|
|
8949
|
-
//
|
|
8950
|
-
|
|
8951
|
-
|
|
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
|
|
8968
|
-
|
|
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'
|
|
9860
|
+
{ encoding: 'utf-8' },
|
|
8979
9861
|
);
|
|
8980
|
-
|
|
8981
|
-
|
|
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
|
|
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; //
|
|
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
|
-
|
|
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
|