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.
@@ -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
- function isUsablePkgRootLocal(pkgRoot) {
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 hooks/'s own parent (the
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
- function selfLocationPkgRoot() {
212
- let hooksDir, ownRealPath;
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 our own path — nothing to self-contain against
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 cache get to answer, and only
239
- // after passing the same usable-root contract as everything else here.
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
- const cached = readCachedPkgRoot();
244
- return isUsablePkgRootLocal(cached) ? cached : null;
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(cachePath, JSON.stringify({ unavailableUntil: Date.now() + PROBE_BACKOFF_MS }));
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
- for (;;) {
1632
- try {
1633
- if (!staged) {
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
- writeFileSync(tmpPath, String(process.pid), { flag: 'wx' });
1636
- staged = true;
1637
- } catch (stageErr) {
1638
- // Staging fails for the same reason acquisition does — an unwritable
1639
- // directory — and `openSync(lock,'wx')` used to report exactly that as
1640
- // EEXIST whenever a lock was already sitting there, sending it down the
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
- // Kept separate from staging on purpose: EPERM/EMLINK from the link are
1648
- // real failures, not contention, and must not decay into ELOCKTIMEOUT.
1649
- linkSync(tmpPath, lockPath);
1650
- break;
1651
- } catch (err) {
1652
- if (err.code !== 'EEXIST') throw err;
1653
- // Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
1654
- // wait and eventually time out. The stat and the unlink are handled
1655
- // separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
1656
- // and a fresh lock must both fall through to the timeout check — never
1657
- // `continue` past it, or an un-unlinkable lock spins forever and violates
1658
- // the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
1659
- let stale = false;
1660
- try {
1661
- // Steal-eligible by age alone; liveness is checked separately below
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: NOT stealing ${lockPath} — holder pid ${holderPid} is still alive past staleMs=${staleMs}`,
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
- loggedLiveHolder = true;
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
- if (stale) {
1694
- try {
1695
- unlinkSync(lockPath);
1696
- continue; // stole it; retry the create immediately
1697
- } catch (unlinkErr) {
1698
- if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
1699
- // Cannot remove it: do NOT spin — fall through to timeout/sleep so
1700
- // acquisition eventually throws ELOCKTIMEOUT instead of hanging.
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
- * Repo-relative POSIX paths of wiki files this session edited via direct
3189
- * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
3190
- * transcript is missing/unreadable (callers decide the fallback). A per-line
3191
- * JSON parse error skips that line only (transcripts occasionally truncate).
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 extractTouchedWikiFiles(transcriptPath, hypoDir) {
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
- // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK:
3447
- // they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
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) blockers.push({ type: 'git', reason: git.reason });
3455
- else if (git.ahead)
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
- // log-only base scope = the shared root files only (hot.md / log.md) — NOT
3670
- // closeFileTargetsGlobal, which would fold the active/phantom project's
3671
- // mandatory files in and re-introduce the cross-project attribution. The
3672
- // session's own transcript-touched files are still added below (a log-only
3673
- // session is accountable for the wiki files it actually edited).
3674
- // Lint scope: explicit opts.lintScope wins; else log-only uses the shared
3675
- // root files only; else projectOverride narrows to that one project's close
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({ path = null, content = null, commitMessage = null } = {}) {
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
  }