hypomnema 1.7.1 → 1.7.3
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/README.ko.md +79 -50
- package/README.md +63 -34
- package/commands/crystallize.md +5 -5
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-session-start.mjs +60 -4
- package/hooks/hypo-shared.mjs +364 -118
- package/hooks/version-check.mjs +47 -0
- package/package.json +2 -1
- package/scripts/capture.mjs +15 -20
- package/scripts/crystallize.mjs +97 -12
- package/scripts/doctor.mjs +151 -7
- package/scripts/init.mjs +17 -7
- package/scripts/lib/extensions.mjs +43 -4
- package/scripts/lib/git-hooks-dir.mjs +10 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lint.mjs +45 -1
- package/scripts/uninstall.mjs +266 -2
- package/scripts/upgrade.mjs +8 -0
- package/templates/hypo-config.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { join, relative, basename, dirname, isAbsolute } from 'path';
|
|
25
25
|
import { homedir, hostname, tmpdir } from 'os';
|
|
26
26
|
import { spawnSync } from 'child_process';
|
|
27
|
-
import { randomBytes } from 'crypto';
|
|
27
|
+
import { randomBytes, createHash } from 'crypto';
|
|
28
28
|
import { fileURLToPath } from 'url';
|
|
29
29
|
|
|
30
30
|
const HOME = homedir();
|
|
@@ -149,7 +149,14 @@ function readCachedPkgRoot() {
|
|
|
149
149
|
// Hypomnema — e.g. `$HOME/package.json` from an unrelated project. This
|
|
150
150
|
// contract answers "is this a real, resolvable directory", not "is this OUR
|
|
151
151
|
// package". Callers that need the latter also require self-containment.
|
|
152
|
-
|
|
152
|
+
//
|
|
153
|
+
// Exported so scripts/doctor.mjs can apply the SAME predicate to a sidecar's
|
|
154
|
+
// recorded pkgRoot that readVerifiedProvenancePkgRoot() below applies at
|
|
155
|
+
// runtime — doctor used to hand-roll a thinner name+hash check that a
|
|
156
|
+
// version-less "name":"hypomnema" root would pass while the runtime resolver
|
|
157
|
+
// rejects it, so doctor could PASS a sidecar the runtime treats as null. The
|
|
158
|
+
// import direction stays scripts/ → hooks/, never the reverse.
|
|
159
|
+
export function isUsablePkgRootLocal(pkgRoot) {
|
|
153
160
|
if (typeof pkgRoot !== 'string' || !pkgRoot || !isAbsolute(pkgRoot)) return false;
|
|
154
161
|
try {
|
|
155
162
|
const v = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
|
|
@@ -205,16 +212,25 @@ function candidateContainsRunningModule(candidateRoot, ownRealPath) {
|
|
|
205
212
|
// candidateContainsRunningModule rejects it: that ancestor's own hooks/
|
|
206
213
|
// subdirectory (if it even has one) is not this file.
|
|
207
214
|
//
|
|
208
|
-
// Bounded walk: up to 6 candidate ancestors above
|
|
215
|
+
// Bounded walk: up to 6 candidate ancestors above hooksDir's own parent (the
|
|
209
216
|
// ordinary root sits at the very first one; a few extra levels tolerate an
|
|
210
217
|
// unusual nesting depth). Never throws, fails open to null on any error.
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
//
|
|
219
|
+
// Split out from selfLocationPkgRoot() (below) so scripts/doctor.mjs can ask
|
|
220
|
+
// the same question about an ARBITRARY installed hooks directory
|
|
221
|
+
// (~/.claude/hooks, ~/.codex/hooks) instead of only about wherever THIS
|
|
222
|
+
// running module happens to live. doctor needs that to tell "self-location
|
|
223
|
+
// genuinely can't resolve for this install" (the standalone-copy steady
|
|
224
|
+
// state, silence is correct) apart from "self-location would resolve but the
|
|
225
|
+
// provenance sidecar is missing/broken" (a real gap — see CONCERN 4's
|
|
226
|
+
// PKG_ROOT-null-and-silent fix in checkProvenanceSidecar). Exported;
|
|
227
|
+
// scripts/ → hooks/ stays the only allowed import direction.
|
|
228
|
+
export function selfLocationPkgRootFrom(hooksDir) {
|
|
229
|
+
let ownRealPath;
|
|
213
230
|
try {
|
|
214
|
-
hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
215
231
|
ownRealPath = realpathSync(join(hooksDir, 'hypo-shared.mjs'));
|
|
216
232
|
} catch {
|
|
217
|
-
return null; // can't even resolve
|
|
233
|
+
return null; // can't even resolve the module at hooksDir — nothing to self-contain against
|
|
218
234
|
}
|
|
219
235
|
try {
|
|
220
236
|
let dir = dirname(hooksDir); // first candidate: the ordinary root, one level above hooks/
|
|
@@ -232,16 +248,93 @@ function selfLocationPkgRoot() {
|
|
|
232
248
|
}
|
|
233
249
|
}
|
|
234
250
|
|
|
251
|
+
function selfLocationPkgRoot() {
|
|
252
|
+
let hooksDir;
|
|
253
|
+
try {
|
|
254
|
+
hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
255
|
+
} catch {
|
|
256
|
+
return null; // can't even resolve our own path
|
|
257
|
+
}
|
|
258
|
+
return selfLocationPkgRootFrom(hooksDir);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Copy-time provenance sidecar (`.hypo-provenance.json`, written next to a
|
|
262
|
+
// standalone-copied hooks/ dir by installHooks/applyHookFiles — see
|
|
263
|
+
// scripts/lib/pkg-provenance.mjs, the writer half of this contract) is the
|
|
264
|
+
// ONLY fallback resolvePkgRoot() gets when self-location can't resolve. The
|
|
265
|
+
// filename and the "hypomnema" name check below must stay byte-identical
|
|
266
|
+
// with scripts/lib/pkg-provenance.mjs — hooks can't import scripts/ (no
|
|
267
|
+
// reaching outside hooks/), so the contract is duplicated, not shared.
|
|
268
|
+
//
|
|
269
|
+
// This is accidental-staleness protection, not a security boundary: any
|
|
270
|
+
// process running as this OS user can edit this JSON file (or hypo-shared.mjs
|
|
271
|
+
// itself) freely. It only catches what installHooks/applyHookFiles left
|
|
272
|
+
// unguarded before this fix — a manual-install hooks/ copy left pointing at
|
|
273
|
+
// an old pkgVersion because a skip-then-refresh-the-cache-anyway sequence
|
|
274
|
+
// (init re-run against an unchanged hooks/ dir) recorded a version the copy
|
|
275
|
+
// on disk never actually became.
|
|
276
|
+
//
|
|
277
|
+
// Producer proof, BOTH required before this pkgRoot is trusted:
|
|
278
|
+
// - the recorded pkgRoot's package.json "name" must be "hypomnema" (not
|
|
279
|
+
// merely "some usable versioned package.json" — isUsablePkgRootLocal
|
|
280
|
+
// alone would accept $HOME/package.json from an unrelated project)
|
|
281
|
+
// - the recorded hypoSharedSha256 must match the SHA-256 of THIS running
|
|
282
|
+
// hypo-shared.mjs file — ties the sidecar to the exact copy it was
|
|
283
|
+
// written next to, so a sidecar surviving a partial re-install (new
|
|
284
|
+
// hooks/*.mjs dropped in, old sidecar left behind) is rejected rather
|
|
285
|
+
// than silently trusted.
|
|
286
|
+
//
|
|
287
|
+
// Scope, stated honestly: hypoSharedSha256 pins ONE file — this file. A
|
|
288
|
+
// sidecar surviving a re-install that touched some OTHER hook (e.g.
|
|
289
|
+
// hypo-personal-check.mjs got a new version, hypo-shared.mjs itself didn't
|
|
290
|
+
// change a byte) still passes this check and PKG_ROOT still resolves,
|
|
291
|
+
// because the running module — the one thing this function can verify
|
|
292
|
+
// without cost — never changed. Catching that wider drift needs hashing
|
|
293
|
+
// every file hooks.json wires up, which is too expensive to do on every
|
|
294
|
+
// hook load (this function runs on every single hook invocation); that
|
|
295
|
+
// broader, once-per-`doctor`-run check is `hooksDigest`
|
|
296
|
+
// (scripts/lib/pkg-provenance.mjs's computeHooksDigest, verified by
|
|
297
|
+
// scripts/doctor.mjs), deliberately NOT read here.
|
|
298
|
+
const PROVENANCE_FILENAME = '.hypo-provenance.json';
|
|
299
|
+
const EXPECTED_PKG_NAME = 'hypomnema';
|
|
300
|
+
|
|
301
|
+
function readVerifiedProvenancePkgRoot(hooksDir) {
|
|
302
|
+
try {
|
|
303
|
+
const raw = JSON.parse(readFileSync(join(hooksDir, PROVENANCE_FILENAME), 'utf-8'));
|
|
304
|
+
const { pkgRoot, hypoSharedSha256 } = raw || {};
|
|
305
|
+
if (!isUsablePkgRootLocal(pkgRoot)) return null;
|
|
306
|
+
const producerPkgJson = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8'));
|
|
307
|
+
if (producerPkgJson.name !== EXPECTED_PKG_NAME) return null;
|
|
308
|
+
if (typeof hypoSharedSha256 !== 'string' || !hypoSharedSha256) return null;
|
|
309
|
+
const ownHash = createHash('sha256')
|
|
310
|
+
.update(readFileSync(join(hooksDir, 'hypo-shared.mjs')))
|
|
311
|
+
.digest('hex');
|
|
312
|
+
if (ownHash !== hypoSharedSha256) return null;
|
|
313
|
+
return pkgRoot;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
235
319
|
// Resolution order: self-location wins whenever it resolves — it is a direct,
|
|
236
320
|
// self-containment-verified fact about the code currently running, not an
|
|
237
321
|
// inference. Only when it cannot resolve (the standalone-copied hooks case
|
|
238
|
-
// above, or a genuine read failure) does the
|
|
239
|
-
//
|
|
322
|
+
// above, or a genuine read failure) does the verified provenance sidecar get
|
|
323
|
+
// to answer, checked against the same directory this module is actually
|
|
324
|
+
// running from. The cache (readCachedPkgRoot/hypo-pkg.json) is deliberately
|
|
325
|
+
// NOT a resolution fallback here — a disagreeing provenance sidecar means
|
|
326
|
+
// "stop", not "ask the cache", because the cache is exactly what can be
|
|
327
|
+
// stale (that staleness is this fix's whole premise). readCachedPkgRoot stays
|
|
328
|
+
// in this file only for pkgRootDriftStatus()'s surfacing comparison below.
|
|
240
329
|
function resolvePkgRoot() {
|
|
241
330
|
const self = selfLocationPkgRoot();
|
|
242
331
|
if (self) return self;
|
|
243
|
-
|
|
244
|
-
|
|
332
|
+
try {
|
|
333
|
+
const hooksDir = dirname(fileURLToPath(import.meta.url));
|
|
334
|
+
return readVerifiedProvenancePkgRoot(hooksDir);
|
|
335
|
+
} catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
245
338
|
}
|
|
246
339
|
export const PKG_ROOT = resolvePkgRoot();
|
|
247
340
|
|
|
@@ -352,6 +445,72 @@ export function hypoIsClean(dir = HYPO_DIR) {
|
|
|
352
445
|
}
|
|
353
446
|
}
|
|
354
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Repo-relative POSIX paths with uncommitted changes (tracked or untracked) in
|
|
450
|
+
* `dir`. Reuses the SAME `-z` rename-aware porcelain parsing as
|
|
451
|
+
* commitWikiChanges (PR #222): a rename or copy emits `to\0from`, and both
|
|
452
|
+
* sides count as dirty (a rename's source still shows staged until the rename
|
|
453
|
+
* itself lands). This is the read-only half of that fix's pathspec boundary:
|
|
454
|
+
* commitWikiChanges narrows what gets COMMITTED to a caller-supplied scope;
|
|
455
|
+
* this narrows what counts as a BLOCKER the same way, for precompactGateStatus
|
|
456
|
+
* to attribute dirty files to the session that owns them instead of the whole
|
|
457
|
+
* shared working tree.
|
|
458
|
+
*
|
|
459
|
+
* Paths are normalized to be relative to `dir` itself, NOT to the git
|
|
460
|
+
* repository's top level: `git -C <dir> status --porcelain` prints paths
|
|
461
|
+
* relative to the repo TOP LEVEL even under `-C` (verified empirically: a
|
|
462
|
+
* vault nested under `<repo>/vault/` reports `vault/hot.md`, not `hot.md`).
|
|
463
|
+
* Every other path this file compares against (closeAccountableScope,
|
|
464
|
+
* closeFileTargetsGlobal, extractTouchedWikiFiles) is `dir`-relative, so
|
|
465
|
+
* without this normalization a nested vault's OWN files would never match
|
|
466
|
+
* its own scope and every one of them would look foreign (codex pre-commit
|
|
467
|
+
* review BLOCKER 2). `git rev-parse --show-prefix` gives exactly the prefix
|
|
468
|
+
* to strip; a dirty path that does not start with it lives outside the
|
|
469
|
+
* vault entirely (the rest of a bigger host repo) and is dropped, never
|
|
470
|
+
* "mine".
|
|
471
|
+
*
|
|
472
|
+
* @returns {string[]} dirty paths relative to `dir`, or `[]` on any git
|
|
473
|
+
* failure (the caller already has its own git-status result via
|
|
474
|
+
* hypoIsClean and treats that failure as an unconditional blocker; an
|
|
475
|
+
* empty return here just means "cannot attribute", not "clean").
|
|
476
|
+
*/
|
|
477
|
+
function gitDirtyFiles(dir) {
|
|
478
|
+
const prefixRes = spawnSync('git', ['-C', dir, 'rev-parse', '--show-prefix'], {
|
|
479
|
+
encoding: 'utf-8',
|
|
480
|
+
});
|
|
481
|
+
if (prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
|
|
482
|
+
const prefix = (prefixRes.stdout || '').trim();
|
|
483
|
+
|
|
484
|
+
const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain', '-uall', '-z'], {
|
|
485
|
+
encoding: 'utf-8',
|
|
486
|
+
});
|
|
487
|
+
if (porcelain.status !== 0) return [];
|
|
488
|
+
const out = [];
|
|
489
|
+
const records = (porcelain.stdout || '').split('\0');
|
|
490
|
+
const toDirRelative = (f) => {
|
|
491
|
+
if (!f) return null;
|
|
492
|
+
if (!prefix) return f; // dir IS the repo top level, nothing to strip
|
|
493
|
+
return f.startsWith(prefix) ? f.slice(prefix.length) : null; // outside the vault
|
|
494
|
+
};
|
|
495
|
+
for (let i = 0; i < records.length; i++) {
|
|
496
|
+
const rec = records[i];
|
|
497
|
+
if (!rec) continue;
|
|
498
|
+
const xy = rec.slice(0, 2);
|
|
499
|
+
const file = rec.slice(3); // destination path for a rename/copy
|
|
500
|
+
const isRenameOrCopy = xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C';
|
|
501
|
+
let fromFile = null;
|
|
502
|
+
if (isRenameOrCopy) {
|
|
503
|
+
i++;
|
|
504
|
+
fromFile = records[i] || null;
|
|
505
|
+
}
|
|
506
|
+
const rel = toDirRelative(file);
|
|
507
|
+
if (rel) out.push(rel);
|
|
508
|
+
const relFrom = toDirRelative(fromFile);
|
|
509
|
+
if (relFrom) out.push(relFrom);
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
355
514
|
export function hotMdIsClean(dir = HYPO_DIR) {
|
|
356
515
|
const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
|
|
357
516
|
if (!existsSync(hotPath)) return { clean: true };
|
|
@@ -709,7 +868,10 @@ function gitIgnoresPageUsageCached(hypoDir, sessionId, probeFn = runGitCheckIgno
|
|
|
709
868
|
if (!probe || (probe.status !== 0 && probe.status !== 1)) {
|
|
710
869
|
if (scoped) {
|
|
711
870
|
try {
|
|
712
|
-
writeFileSync(
|
|
871
|
+
writeFileSync(
|
|
872
|
+
cachePath,
|
|
873
|
+
JSON.stringify({ unavailableUntil: Date.now() + PROBE_BACKOFF_MS }),
|
|
874
|
+
);
|
|
713
875
|
} catch {
|
|
714
876
|
// non-fatal; the probe just runs again next prompt
|
|
715
877
|
}
|
|
@@ -1628,89 +1790,89 @@ export function withFileLock(targetPath, fn, opts = {}) {
|
|
|
1628
1790
|
let loggedLiveHolder = false;
|
|
1629
1791
|
let loggedSteal = false;
|
|
1630
1792
|
try {
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1793
|
+
for (;;) {
|
|
1794
|
+
try {
|
|
1795
|
+
if (!staged) {
|
|
1796
|
+
try {
|
|
1797
|
+
writeFileSync(tmpPath, String(process.pid), { flag: 'wx' });
|
|
1798
|
+
staged = true;
|
|
1799
|
+
} catch (stageErr) {
|
|
1800
|
+
// Staging fails for the same reason acquisition does — an unwritable
|
|
1801
|
+
// directory — and `openSync(lock,'wx')` used to report exactly that as
|
|
1802
|
+
// EEXIST whenever a lock was already sitting there, sending it down the
|
|
1803
|
+
// contention path. Preserve that: contend if a lock exists, and surface
|
|
1804
|
+
// a genuine write failure otherwise rather than masking it as a timeout.
|
|
1805
|
+
if (!existsSync(lockPath)) throw stageErr;
|
|
1806
|
+
throw Object.assign(new Error('lock-contended'), { code: 'EEXIST' });
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
// Kept separate from staging on purpose: EPERM/EMLINK from the link are
|
|
1810
|
+
// real failures, not contention, and must not decay into ELOCKTIMEOUT.
|
|
1811
|
+
linkSync(tmpPath, lockPath);
|
|
1812
|
+
break;
|
|
1813
|
+
} catch (err) {
|
|
1814
|
+
if (err.code !== 'EEXIST') throw err;
|
|
1815
|
+
// Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
|
|
1816
|
+
// wait and eventually time out. The stat and the unlink are handled
|
|
1817
|
+
// separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
|
|
1818
|
+
// and a fresh lock must both fall through to the timeout check — never
|
|
1819
|
+
// `continue` past it, or an un-unlinkable lock spins forever and violates
|
|
1820
|
+
// the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
|
|
1821
|
+
let stale = false;
|
|
1634
1822
|
try {
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
// contention path. Preserve that: contend if a lock exists, and surface
|
|
1642
|
-
// a genuine write failure otherwise rather than masking it as a timeout.
|
|
1643
|
-
if (!existsSync(lockPath)) throw stageErr;
|
|
1644
|
-
throw Object.assign(new Error('lock-contended'), { code: 'EEXIST' });
|
|
1823
|
+
// Steal-eligible by age alone; liveness is checked separately below
|
|
1824
|
+
// before we actually act on it.
|
|
1825
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
1826
|
+
} catch (statErr) {
|
|
1827
|
+
if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
|
|
1828
|
+
throw statErr; // unexpected stat failure — surface it, don't mask
|
|
1645
1829
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
// before we actually act on it.
|
|
1663
|
-
stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
1664
|
-
} catch (statErr) {
|
|
1665
|
-
if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
|
|
1666
|
-
throw statErr; // unexpected stat failure — surface it, don't mask
|
|
1667
|
-
}
|
|
1668
|
-
if (stale) {
|
|
1669
|
-
const holderPid = readLockHolderPid(lockPath);
|
|
1670
|
-
if (holderPid !== null && isPidAlive(holderPid)) {
|
|
1671
|
-
// LIVE holder preempted past staleMs: do NOT steal. Surface it so the
|
|
1672
|
-
// preemption is visible, then fall through to the poll/timeout path
|
|
1673
|
-
// below instead of racing a second writer into the critical section.
|
|
1674
|
-
if (!loggedLiveHolder) {
|
|
1830
|
+
if (stale) {
|
|
1831
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
1832
|
+
if (holderPid !== null && isPidAlive(holderPid)) {
|
|
1833
|
+
// LIVE holder preempted past staleMs: do NOT steal. Surface it so the
|
|
1834
|
+
// preemption is visible, then fall through to the poll/timeout path
|
|
1835
|
+
// below instead of racing a second writer into the critical section.
|
|
1836
|
+
if (!loggedLiveHolder) {
|
|
1837
|
+
console.error(
|
|
1838
|
+
`[hypomnema] withFileLock: NOT stealing ${lockPath} — holder pid ${holderPid} is still alive past staleMs=${staleMs}`,
|
|
1839
|
+
);
|
|
1840
|
+
loggedLiveHolder = true;
|
|
1841
|
+
}
|
|
1842
|
+
stale = false;
|
|
1843
|
+
} else if (!loggedSteal) {
|
|
1844
|
+
// Also once per acquire: an un-removable stale lock re-enters this
|
|
1845
|
+
// branch on every poll, and the steal is one event either way.
|
|
1675
1846
|
console.error(
|
|
1676
|
-
`[hypomnema] withFileLock:
|
|
1847
|
+
`[hypomnema] withFileLock: stealing stale lock ${lockPath}` +
|
|
1848
|
+
(holderPid !== null
|
|
1849
|
+
? ` (holder pid ${holderPid} is no longer running)`
|
|
1850
|
+
: ' (no readable holder pid — pre-liveness lockfile)'),
|
|
1677
1851
|
);
|
|
1678
|
-
|
|
1852
|
+
loggedSteal = true;
|
|
1679
1853
|
}
|
|
1680
|
-
stale = false;
|
|
1681
|
-
} else if (!loggedSteal) {
|
|
1682
|
-
// Also once per acquire: an un-removable stale lock re-enters this
|
|
1683
|
-
// branch on every poll, and the steal is one event either way.
|
|
1684
|
-
console.error(
|
|
1685
|
-
`[hypomnema] withFileLock: stealing stale lock ${lockPath}` +
|
|
1686
|
-
(holderPid !== null
|
|
1687
|
-
? ` (holder pid ${holderPid} is no longer running)`
|
|
1688
|
-
: ' (no readable holder pid — pre-liveness lockfile)'),
|
|
1689
|
-
);
|
|
1690
|
-
loggedSteal = true;
|
|
1691
1854
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1855
|
+
if (stale) {
|
|
1856
|
+
try {
|
|
1857
|
+
unlinkSync(lockPath);
|
|
1858
|
+
continue; // stole it; retry the create immediately
|
|
1859
|
+
} catch (unlinkErr) {
|
|
1860
|
+
if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
|
|
1861
|
+
// Cannot remove it: do NOT spin — fall through to timeout/sleep so
|
|
1862
|
+
// acquisition eventually throws ELOCKTIMEOUT instead of hanging.
|
|
1863
|
+
}
|
|
1701
1864
|
}
|
|
1865
|
+
if (Date.now() - start > timeoutMs) {
|
|
1866
|
+
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1867
|
+
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1868
|
+
// which must NOT be masked as a timeout.
|
|
1869
|
+
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1870
|
+
e.code = 'ELOCKTIMEOUT';
|
|
1871
|
+
throw e;
|
|
1872
|
+
}
|
|
1873
|
+
sleepSync(pollMs);
|
|
1702
1874
|
}
|
|
1703
|
-
if (Date.now() - start > timeoutMs) {
|
|
1704
|
-
// Tagged so callers can distinguish "could not get the lock" (fall to the
|
|
1705
|
-
// proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
|
|
1706
|
-
// which must NOT be masked as a timeout.
|
|
1707
|
-
const e = new Error(`lock-timeout: ${lockPath}`);
|
|
1708
|
-
e.code = 'ELOCKTIMEOUT';
|
|
1709
|
-
throw e;
|
|
1710
|
-
}
|
|
1711
|
-
sleepSync(pollMs);
|
|
1712
1875
|
}
|
|
1713
|
-
}
|
|
1714
1876
|
} finally {
|
|
1715
1877
|
// The sibling is only ever a staging file: once linked, the lock IS the
|
|
1716
1878
|
// link, and on every failure path it must not survive as litter.
|
|
@@ -3185,22 +3347,32 @@ function toHypoRel(absPath, hypoDir) {
|
|
|
3185
3347
|
}
|
|
3186
3348
|
|
|
3187
3349
|
/**
|
|
3188
|
-
*
|
|
3189
|
-
*
|
|
3190
|
-
*
|
|
3191
|
-
*
|
|
3350
|
+
* Same walk as extractTouchedWikiFiles, but also reports whether the walk can
|
|
3351
|
+
* be TRUSTED as a complete enumeration: `trusted: false` when the transcript
|
|
3352
|
+
* is missing, unreadable, or contains a line that failed to parse (transcripts
|
|
3353
|
+
* occasionally truncate mid-write). `extractTouchedWikiFiles` collapses all of
|
|
3354
|
+
* that to an empty/partial Set, indistinguishable from "this session touched
|
|
3355
|
+
* nothing", which is fine for lint's existing debt-partition (a false notice is not a
|
|
3356
|
+
* false pass), but NOT fine for an attribution-sensitive caller like
|
|
3357
|
+
* precompactGateStatus's git-scope check: treating an untrustworthy empty Set
|
|
3358
|
+
* as "nothing to widen" would let a session's own dirty file, invisible only
|
|
3359
|
+
* because its transcript could not be read, pass as someone else's foreign
|
|
3360
|
+
* debt (codex pre-commit review BLOCKER 1).
|
|
3361
|
+
*
|
|
3362
|
+
* @returns {{files: Set<string>, trusted: boolean}}
|
|
3192
3363
|
*/
|
|
3193
|
-
export function
|
|
3364
|
+
export function extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir) {
|
|
3194
3365
|
const out = new Set();
|
|
3195
3366
|
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
3196
|
-
return out;
|
|
3367
|
+
return { files: out, trusted: false };
|
|
3197
3368
|
}
|
|
3198
3369
|
let raw;
|
|
3199
3370
|
try {
|
|
3200
3371
|
raw = readFileSync(transcriptPath, 'utf-8');
|
|
3201
3372
|
} catch {
|
|
3202
|
-
return out;
|
|
3373
|
+
return { files: out, trusted: false };
|
|
3203
3374
|
}
|
|
3375
|
+
let trusted = true;
|
|
3204
3376
|
for (const line of raw.split('\n')) {
|
|
3205
3377
|
const t = line.trim();
|
|
3206
3378
|
if (!t) continue;
|
|
@@ -3208,6 +3380,7 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
|
3208
3380
|
try {
|
|
3209
3381
|
entry = JSON.parse(t);
|
|
3210
3382
|
} catch {
|
|
3383
|
+
trusted = false; // a truncated/corrupt line: the walk may be incomplete
|
|
3211
3384
|
continue;
|
|
3212
3385
|
}
|
|
3213
3386
|
for (const fp of extractTranscriptToolFilePaths(entry)) {
|
|
@@ -3215,7 +3388,19 @@ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
|
3215
3388
|
if (rel) out.add(rel);
|
|
3216
3389
|
}
|
|
3217
3390
|
}
|
|
3218
|
-
return out;
|
|
3391
|
+
return { files: out, trusted };
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/**
|
|
3395
|
+
* Repo-relative POSIX paths of wiki files this session edited via direct
|
|
3396
|
+
* Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
|
|
3397
|
+
* transcript is missing/unreadable (callers decide the fallback). A per-line
|
|
3398
|
+
* JSON parse error skips that line only (transcripts occasionally truncate).
|
|
3399
|
+
* A caller that needs to tell "genuinely empty" apart from "could not fully
|
|
3400
|
+
* enumerate" wants extractTouchedWikiFilesWithTrust instead.
|
|
3401
|
+
*/
|
|
3402
|
+
export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
|
|
3403
|
+
return extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir).files;
|
|
3219
3404
|
}
|
|
3220
3405
|
|
|
3221
3406
|
/**
|
|
@@ -3443,20 +3628,89 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3443
3628
|
const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
|
|
3444
3629
|
const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
|
|
3445
3630
|
|
|
3446
|
-
//
|
|
3447
|
-
//
|
|
3631
|
+
// Paths this session is accountable for at THIS close: the same signals the
|
|
3632
|
+
// lint partition below already trusts for exactly this question ("is this
|
|
3633
|
+
// file mine or pre-existing debt"), hoisted so the git check (step 1) can
|
|
3634
|
+
// partition on it too. Base = the mandatory close-target files (opts.lintScope
|
|
3635
|
+
// override, else the log-only shared root files, else one project's close
|
|
3636
|
+
// files under projectOverride, else every today-active project's); widened by
|
|
3637
|
+
// every file this session's transcript shows it editing directly.
|
|
3638
|
+
const closeAccountableScope = new Set(
|
|
3639
|
+
opts.lintScope ||
|
|
3640
|
+
(logOnly
|
|
3641
|
+
? ['hot.md', 'log.md']
|
|
3642
|
+
: opts.projectOverride
|
|
3643
|
+
? closeFileTargetsForProject(hypoDir, opts.projectOverride)
|
|
3644
|
+
: closeFileTargetsGlobal(hypoDir)),
|
|
3645
|
+
);
|
|
3646
|
+
// Trust signal for the git-scope check below (codex pre-commit review
|
|
3647
|
+
// BLOCKER 1): closeAccountableScope's mandatory-files base is always
|
|
3648
|
+
// reliable, but the transcript widening that catches an ad hoc page this
|
|
3649
|
+
// session edited is only as good as the transcript. No opts.transcriptPath
|
|
3650
|
+
// at all, an unreadable file, or a line that fails to parse all mean the
|
|
3651
|
+
// widened scope may be under-inclusive, NOT that this session touched
|
|
3652
|
+
// nothing extra. Only a fully-read, fully-parsed transcript earns
|
|
3653
|
+
// sessionTouchTrusted:true. A single tool_use with no file_path does NOT
|
|
3654
|
+
// lower trust: most tool_use blocks (Read, Bash, Grep, ...) never carry one
|
|
3655
|
+
// and that is expected, not corruption, so treating it as untrustworthy
|
|
3656
|
+
// would make almost every real transcript untrusted and defeat the scoping
|
|
3657
|
+
// this fix exists to add.
|
|
3658
|
+
let sessionTouchTrusted = false;
|
|
3659
|
+
if (opts.transcriptPath) {
|
|
3660
|
+
const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
|
|
3661
|
+
sessionTouchTrusted = widened.trusted;
|
|
3662
|
+
for (const f of widened.files) closeAccountableScope.add(f);
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
|
|
3666
|
+
// the ones inside closeAccountableScope, and only when sessionTouchTrusted
|
|
3667
|
+
// (above) says that scope can actually be trusted: a session's own scoped
|
|
3668
|
+
// auto-commit (commitWikiChanges, PR #222) can leave the working tree
|
|
3669
|
+
// non-empty when a DIFFERENT session sharing this vault still has its own
|
|
3670
|
+
// file dirty, the 2026-08-03 multi-session block. That dirty file is
|
|
3671
|
+
// human-fixable by whoever owns it, not by this session, so it demotes to
|
|
3672
|
+
// a notice (listed by path, never silently dropped) instead of refusing
|
|
3673
|
+
// this session's marker. A dirty file THIS session owns still blocks
|
|
3674
|
+
// unconditionally (fail-closed is unchanged for scope this session
|
|
3675
|
+
// actually touched), and so does an unattributable state: a git failure
|
|
3676
|
+
// gitDirtyFiles can't enumerate (dirty.length === 0 despite
|
|
3677
|
+
// git.uncommitted === true), OR a scope we cannot trust
|
|
3678
|
+
// (!sessionTouchTrusted) both fall back to the original unscoped blocker:
|
|
3679
|
+
// "cannot attribute" is not "clean".
|
|
3680
|
+
// Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
|
|
3448
3681
|
// automatic (auto-commit Stop hook) and its failures are already non-fatal, so
|
|
3449
3682
|
// "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
|
|
3450
3683
|
// here (the shared gate) keeps the marker == compact-ready invariant:
|
|
3451
3684
|
// a committed-but-unpushed close marks AND compacts, instead of the close writer
|
|
3452
3685
|
// committing its own payload and then being blocked by its own (unpushed) commit.
|
|
3453
3686
|
const git = hypoIsClean(hypoDir);
|
|
3454
|
-
if (git.uncommitted)
|
|
3455
|
-
|
|
3687
|
+
if (git.uncommitted) {
|
|
3688
|
+
const dirty = gitDirtyFiles(hypoDir);
|
|
3689
|
+
if (dirty.length === 0 || !sessionTouchTrusted) {
|
|
3690
|
+
blockers.push({ type: 'git', reason: git.reason });
|
|
3691
|
+
} else {
|
|
3692
|
+
const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
|
|
3693
|
+
const foreign = dirty.filter((f) => !closeAccountableScope.has(posixPath(f)));
|
|
3694
|
+
if (mine.length > 0) {
|
|
3695
|
+
blockers.push({
|
|
3696
|
+
type: 'git',
|
|
3697
|
+
reason: `uncommitted changes in ${hypoDir}: ${mine.join(', ')}`,
|
|
3698
|
+
});
|
|
3699
|
+
}
|
|
3700
|
+
for (const f of foreign) {
|
|
3701
|
+
notices.push({
|
|
3702
|
+
type: 'git',
|
|
3703
|
+
file: f,
|
|
3704
|
+
reason: `uncommitted changes outside this session's scope: ${f}`,
|
|
3705
|
+
});
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
} else if (git.ahead) {
|
|
3456
3709
|
notices.push({
|
|
3457
3710
|
type: 'git-sync',
|
|
3458
3711
|
reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
|
|
3459
3712
|
});
|
|
3713
|
+
}
|
|
3460
3714
|
|
|
3461
3715
|
// 2. root hot.md structure
|
|
3462
3716
|
const hot = hotMdIsClean(hypoDir);
|
|
@@ -3666,25 +3920,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3666
3920
|
const parsed = JSON.parse(r.stdout);
|
|
3667
3921
|
const allErrors = parsed.errors || [];
|
|
3668
3922
|
const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
|
|
3669
|
-
//
|
|
3670
|
-
//
|
|
3671
|
-
//
|
|
3672
|
-
//
|
|
3673
|
-
//
|
|
3674
|
-
//
|
|
3675
|
-
|
|
3676
|
-
// files (matching the narrowed close status above); else the global set.
|
|
3677
|
-
const scope = new Set(
|
|
3678
|
-
opts.lintScope ||
|
|
3679
|
-
(logOnly
|
|
3680
|
-
? ['hot.md', 'log.md']
|
|
3681
|
-
: opts.projectOverride
|
|
3682
|
-
? closeFileTargetsForProject(hypoDir, opts.projectOverride)
|
|
3683
|
-
: closeFileTargetsGlobal(hypoDir)),
|
|
3684
|
-
);
|
|
3685
|
-
if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
|
|
3686
|
-
for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
|
|
3687
|
-
}
|
|
3923
|
+
// Lint scope = closeAccountableScope, hoisted above step 1 so the git
|
|
3924
|
+
// check can partition on the same "is this mine" answer. See that
|
|
3925
|
+
// hoisted comment for what feeds it (opts.lintScope override, else
|
|
3926
|
+
// log-only's shared root files, else one project's close files under
|
|
3927
|
+
// projectOverride, else the global set, widened by transcript-touched
|
|
3928
|
+
// files).
|
|
3929
|
+
const scope = closeAccountableScope;
|
|
3688
3930
|
const part = partitionLintScope(allErrors, scope);
|
|
3689
3931
|
if (part.blocking.length > 0) {
|
|
3690
3932
|
blockers.push({
|
|
@@ -4154,7 +4396,11 @@ const CLOSE_COMMIT_MESSAGE = new RegExp(
|
|
|
4154
4396
|
* is the YYYY-MM-DD captured from the artifact's own heading, or null for a
|
|
4155
4397
|
* commit-message match (the caller already has the commit's date).
|
|
4156
4398
|
*/
|
|
4157
|
-
export function detectSessionCloseArtifact({
|
|
4399
|
+
export function detectSessionCloseArtifact({
|
|
4400
|
+
path = null,
|
|
4401
|
+
content = null,
|
|
4402
|
+
commitMessage = null,
|
|
4403
|
+
} = {}) {
|
|
4158
4404
|
if (typeof commitMessage === 'string' && CLOSE_COMMIT_MESSAGE.test(commitMessage)) {
|
|
4159
4405
|
return { matched: true, kind: 'commit-message', date: null };
|
|
4160
4406
|
}
|