hypomnema 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -16,8 +16,12 @@ import {
16
16
  rmSync,
17
17
  readdirSync,
18
18
  realpathSync,
19
+ openSync,
20
+ closeSync,
21
+ unlinkSync,
22
+ renameSync,
19
23
  } from 'fs';
20
- import { join, relative, basename } from 'path';
24
+ import { join, relative, basename, dirname } from 'path';
21
25
  import { homedir, hostname, tmpdir } from 'os';
22
26
  import { spawnSync } from 'child_process';
23
27
 
@@ -525,7 +529,12 @@ function _lastSeg(p) {
525
529
  // declines (null) so the caller falls back to recency. `projects` is the whole
526
530
  // universe (for the uniqueness gate); `eligible` restricts the answer.
527
531
  export function pickProjectByCwd(projects, cwd, opts = {}) {
528
- const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
532
+ const {
533
+ eligible = null,
534
+ realpathCwd = null,
535
+ caseInsensitive = isCaseInsensitiveFs(),
536
+ rejectAmbiguous = false,
537
+ } = opts;
529
538
  if (!cwd && !realpathCwd) return null;
530
539
  const eligibleSet = eligible ? new Set(eligible) : null;
531
540
  const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
@@ -545,20 +554,35 @@ export function pickProjectByCwd(projects, cwd, opts = {}) {
545
554
  if (n && !cwds.includes(n)) cwds.push(n);
546
555
  }
547
556
 
548
- // Tier 1: first cwd variant with any longest-prefix match wins.
557
+ // Tier 1: first cwd variant with any longest-prefix match wins. With
558
+ // rejectAmbiguous (session-cwd close check), two DISTINCT projects sharing the same
559
+ // longest matching working_dir (a monorepo config with no uniqueness invariant)
560
+ // is a genuine tie we must NOT break arbitrarily — silently picking the first
561
+ // would attribute a close to the wrong project and either mask a real failure
562
+ // (false-green) or block the wrong one (false-block). Decline the tie → null,
563
+ // so the caller degrades to the unresolved-cwd path instead of guessing.
549
564
  for (const c of cwds) {
550
565
  const cf = _fold(c, caseInsensitive);
551
566
  let bestSlug = null;
552
567
  let bestLen = -1;
568
+ let bestTied = false;
553
569
  for (const e of entries) {
554
570
  if (!isEligible(e.slug)) continue;
555
571
  const pf = _fold(e.path, caseInsensitive);
556
- if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
557
- bestLen = e.path.length;
558
- bestSlug = e.slug;
572
+ if (cf === pf || cf.startsWith(`${pf}/`)) {
573
+ if (e.path.length > bestLen) {
574
+ bestLen = e.path.length;
575
+ bestSlug = e.slug;
576
+ bestTied = false;
577
+ } else if (e.path.length === bestLen && e.slug !== bestSlug) {
578
+ bestTied = true;
579
+ }
559
580
  }
560
581
  }
561
- if (bestSlug) return bestSlug;
582
+ if (bestSlug) {
583
+ if (bestTied && rejectAmbiguous) return null;
584
+ return bestSlug;
585
+ }
562
586
  }
563
587
 
564
588
  // Tier 2: unique-basename ancestor, but only when the chain points at exactly
@@ -1130,7 +1154,10 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1130
1154
 
1131
1155
  // primary = the recency project when it is itself today-active, else the first
1132
1156
  // today-active slug (stable order from the candidate set). Used only as the
1133
- // single-slug alias (marker `project` field, message header) never to gate.
1157
+ // single-slug alias for the message header and the flat `close.project` field.
1158
+ // It is NEVER an attribution source: the marker is stamped from close evidence
1159
+ // (explicit --project, transcript close-files, apply's payload.project), so this
1160
+ // recency-derived value cannot leak into a marker and back into the next gate.
1134
1161
  const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
1135
1162
  const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
1136
1163
 
@@ -1203,6 +1230,133 @@ export function rootLogEntry(slug, date, headingTail) {
1203
1230
  * @param {string} hypoDir
1204
1231
  * @returns {number} count of entries appended to log.md
1205
1232
  */
1233
+ // ── append-only file lock ───────────────────────────────────────────────────
1234
+ // Serializes the read → dedup → rebuild → temp+rename sequence on append-only
1235
+ // history files (session-log shards, log.md) so two concurrent session closes
1236
+ // never lose an entry. The lock does NOT replace the existing write-isolation:
1237
+ // each writer still rebuilds the full content and commits via atomicWrite
1238
+ // (temp write + rename), so a partial write lands on a throwaway temp and the
1239
+ // target is never torn. The lock only makes the read-modify-write exclusive, so
1240
+ // the second closer re-reads the first's committed bytes and appends onto them
1241
+ // (last-writer-wins can no longer drop the earlier entry), and exact-entry dedup
1242
+ // becomes precise rather than best-effort.
1243
+ //
1244
+ // Why not O_APPEND: an in-place append that short-writes (ENOSPC / EDQUOT /
1245
+ // RLIMIT_FSIZE / a split write() killed mid-loop) leaves a torn dated heading on
1246
+ // the real file that the freshness gate mis-reads as valid close evidence, and
1247
+ // it cannot be rolled back once a concurrent appender has written past it. That
1248
+ // is a normal-operation regression temp+rename does not have (a failed temp
1249
+ // write never runs the rename, so the target stays untouched). Confirmed against
1250
+ // Node/libuv write-loop behavior and POSIX write(2) partial-write semantics.
1251
+ //
1252
+ // Local-FS only: `openSync(lock, 'wx')` is not atomic on NFS — the same caveat
1253
+ // the vault already carries. Power-loss durability is unchanged from today
1254
+ // (atomicWrite never fsync'd), so it is out of scope here.
1255
+ function sleepSync(ms) {
1256
+ // Synchronous sleep with no busy-spin: block this thread on an Atomics.wait
1257
+ // against a private SharedArrayBuffer that is never signaled, so it always
1258
+ // times out after `ms`. Hooks run in a short-lived sync context.
1259
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms | 0));
1260
+ }
1261
+
1262
+ // Commit `content` via temp write + rename so a partial/failed write lands on a
1263
+ // throwaway temp and the target is never torn (mirrors crystallize.mjs's
1264
+ // atomicWrite). Rename atomicity swaps the directory entry; it is NOT power-loss
1265
+ // durable (no fsync) — same as everything else in the vault.
1266
+ function atomicWriteShared(path, content) {
1267
+ mkdirSync(dirname(path), { recursive: true });
1268
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
1269
+ writeFileSync(tmp, content);
1270
+ renameSync(tmp, path);
1271
+ }
1272
+
1273
+ /**
1274
+ * Run `fn` while holding an exclusive lock on `<targetPath>.lock`.
1275
+ *
1276
+ * Acquire is a spin on `openSync(lock, 'wx')`: EEXIST means another writer holds
1277
+ * it, so poll until it frees. A lock whose holder looks dead (mtime older than
1278
+ * `staleMs`) is stolen. Steal is recoverable friction in the normal case (the
1279
+ * stealer re-reads the committed bytes before writing), but NOT loss-free in two
1280
+ * edge cases, both requiring the lock to sit untouched for `staleMs`: (1) a LIVE
1281
+ * holder preempted past `staleMs` gets stolen from, so two writers run the
1282
+ * critical section and one update is lost; (2) between the stale `statSync` and
1283
+ * the `unlinkSync`, the holder can release and a fresh holder grab the same path,
1284
+ * whose lock we then remove. `staleMs` is set well above a normal close (seconds)
1285
+ * to make both extreme-low-probability. If the lock cannot be acquired within
1286
+ * `timeoutMs`, throw so the caller can fall back to the write=proposal gate — for
1287
+ * an append that means blocking the close (proposal-pending) with no artifact; the
1288
+ * next close re-appends (architecturally consistent with the existing fail-safe).
1289
+ *
1290
+ * @param {string} targetPath file being guarded (lock is a sibling `.lock`)
1291
+ * @param {() => T} fn critical section
1292
+ * @param {{timeoutMs?: number, staleMs?: number, pollMs?: number}} [opts]
1293
+ * @returns {T} whatever `fn` returns
1294
+ * @template T
1295
+ */
1296
+ export function withFileLock(targetPath, fn, opts = {}) {
1297
+ const { timeoutMs = 5000, staleMs = 30000, pollMs = 50 } = opts;
1298
+ const lockPath = `${targetPath}.lock`;
1299
+ mkdirSync(dirname(lockPath), { recursive: true });
1300
+ const start = Date.now();
1301
+ let fd;
1302
+ for (;;) {
1303
+ try {
1304
+ fd = openSync(lockPath, 'wx');
1305
+ break;
1306
+ } catch (err) {
1307
+ if (err.code !== 'EEXIST') throw err;
1308
+ // Held by another writer. Steal ONLY a demonstrably stale lock; otherwise
1309
+ // wait and eventually time out. The stat and the unlink are handled
1310
+ // separately on purpose: an un-removable stale lock (EACCES/EPERM/EBUSY)
1311
+ // and a fresh lock must both fall through to the timeout check — never
1312
+ // `continue` past it, or an un-unlinkable lock spins forever and violates
1313
+ // the timeoutMs → ELOCKTIMEOUT contract (caller falls to the proposal gate).
1314
+ let stale = false;
1315
+ try {
1316
+ // Steal a lock whose holder looks dead. Loss-free unless the holder is
1317
+ // actually live-but-preempted past staleMs (see JSDoc edge cases).
1318
+ stale = Date.now() - statSync(lockPath).mtimeMs > staleMs;
1319
+ } catch (statErr) {
1320
+ if (statErr.code === 'ENOENT') continue; // lock vanished; retry create now
1321
+ throw statErr; // unexpected stat failure — surface it, don't mask
1322
+ }
1323
+ if (stale) {
1324
+ try {
1325
+ unlinkSync(lockPath);
1326
+ continue; // stole it; retry the create immediately
1327
+ } catch (unlinkErr) {
1328
+ if (unlinkErr.code === 'ENOENT') continue; // another stealer won; retry
1329
+ // Cannot remove it: do NOT spin — fall through to timeout/sleep so
1330
+ // acquisition eventually throws ELOCKTIMEOUT instead of hanging.
1331
+ }
1332
+ }
1333
+ if (Date.now() - start > timeoutMs) {
1334
+ // Tagged so callers can distinguish "could not get the lock" (fall to the
1335
+ // proposal gate) from a real fn() write error (mkdir/openSync/disk-full),
1336
+ // which must NOT be masked as a timeout.
1337
+ const e = new Error(`lock-timeout: ${lockPath}`);
1338
+ e.code = 'ELOCKTIMEOUT';
1339
+ throw e;
1340
+ }
1341
+ sleepSync(pollMs);
1342
+ }
1343
+ }
1344
+ try {
1345
+ return fn();
1346
+ } finally {
1347
+ try {
1348
+ closeSync(fd);
1349
+ } catch {
1350
+ /* fd already gone */
1351
+ }
1352
+ try {
1353
+ unlinkSync(lockPath);
1354
+ } catch {
1355
+ /* lock already stolen/removed */
1356
+ }
1357
+ }
1358
+ }
1359
+
1206
1360
  export function deriveRootLogEntries(hypoDir) {
1207
1361
  const logPath = join(hypoDir, 'log.md');
1208
1362
  if (!existsSync(logPath)) return 0;
@@ -1259,19 +1413,44 @@ export function deriveRootLogEntries(hypoDir) {
1259
1413
  const { heading, block } = rootLogEntry(slug, date, m[1]);
1260
1414
  if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
1261
1415
  seenHeadings.add(heading);
1262
- additions.push(block);
1416
+ additions.push({ heading, block });
1263
1417
  }
1264
1418
  }
1265
1419
  }
1266
1420
 
1267
1421
  if (additions.length === 0) return 0;
1268
- const sep = logContent.endsWith('\n') ? '\n' : '\n\n';
1422
+
1423
+ // Serialize the read-modify-write on log.md: a concurrent session close (its
1424
+ // own crystallize apply, or another project's derive) may commit between the
1425
+ // read above and the write below. Under the lock we RE-READ the latest
1426
+ // committed log.md and re-run exact-heading dedup, so this derive appends onto
1427
+ // the other writer's entry instead of a full-file overwrite dropping it. The
1428
+ // same lock guards crystallize.mjs's per-close log.md append, so the two
1429
+ // paths never race. On lock-timeout, skip (best-effort backfill; the next
1430
+ // close re-derives) rather than risk a lost update.
1269
1431
  try {
1270
- writeFileSync(logPath, logContent + sep + additions.join('\n\n') + '\n');
1432
+ return withFileLock(logPath, () => {
1433
+ let current;
1434
+ try {
1435
+ current = readFileSync(logPath, 'utf-8');
1436
+ } catch {
1437
+ return 0;
1438
+ }
1439
+ const seen = new Set((current || '').split(/\r?\n/));
1440
+ const fresh = additions.filter(({ heading }) => {
1441
+ if (seen.has(heading)) return false;
1442
+ seen.add(heading);
1443
+ return true;
1444
+ });
1445
+ if (fresh.length === 0) return 0;
1446
+ const sep = current.endsWith('\n') ? '\n' : '\n\n';
1447
+ atomicWriteShared(logPath, current + sep + fresh.map((a) => a.block).join('\n\n') + '\n');
1448
+ return fresh.length;
1449
+ });
1271
1450
  } catch {
1451
+ // lock-timeout or unexpected lock error: skip this backfill pass.
1272
1452
  return 0;
1273
1453
  }
1274
- return additions.length;
1275
1454
  }
1276
1455
 
1277
1456
  // ── sync-state ────────────────────────────────────────────
@@ -1401,16 +1580,34 @@ export function commitWikiChanges(hypoDir) {
1401
1580
  spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
1402
1581
  if (git('rev-parse', '--is-inside-work-tree').status !== 0)
1403
1582
  return { committed: false, reason: `not a git repository: ${hypoDir}` };
1404
- const porcelain = git('status', '--porcelain', '-uall');
1583
+ // `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
1584
+ // no octal escaping, so non-ASCII paths (Korean page names are normal input
1585
+ // here) survive intact. Without it, `core.quotepath=true` (the default) yields
1586
+ // `"pages/\355\225\234\352\270\200.md"` and the old quote-strip parser fed that
1587
+ // literal, non-existent path to `git add` — failing the whole commit.
1588
+ // `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
1589
+ // no octal escaping, so non-ASCII paths (Korean page names are normal input
1590
+ // here) survive intact. Without it, `core.quotepath=true` (the default) yields
1591
+ // `"pages/\355\225\234\352\270\200.md"` and the old quote-strip parser fed that
1592
+ // literal, non-existent path to `git add` — failing the whole commit.
1593
+ const porcelain = git('status', '--porcelain', '-uall', '-z');
1405
1594
  if (porcelain.status !== 0)
1406
1595
  return { committed: false, reason: `git status failed in ${hypoDir}` };
1407
1596
  // `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
1408
1597
  // enumerate changed paths, drop ignored ones, then stage explicitly.
1409
1598
  const ignorePatterns = loadHypoIgnore(hypoDir);
1410
1599
  const paths = [];
1411
- for (const line of (porcelain.stdout || '').split('\n')) {
1412
- if (!line) continue;
1413
- const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
1600
+ const records = (porcelain.stdout || '').split('\0');
1601
+ for (let i = 0; i < records.length; i++) {
1602
+ const rec = records[i];
1603
+ if (!rec) continue;
1604
+ const xy = rec.slice(0, 2);
1605
+ const file = rec.slice(3); // `XY <path>`; the destination path for a rename/copy
1606
+ // A rename OR copy emits two records (`to\0from`); consume the trailing
1607
+ // `from`. Copy `C` records only appear under `status.renames=copies`, but
1608
+ // when they do, missing this skip feeds the `from` path to `git add` as a
1609
+ // bogus pathspec — the exact auto-commit failure this parser fixes.
1610
+ if (xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C') i++;
1414
1611
  if (!file) continue;
1415
1612
  if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
1416
1613
  continue;
@@ -1799,9 +1996,22 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
1799
1996
  // attribution). Readers (precompactGateStatus / --check-session-close) key
1800
1997
  // the gate semantics on this field, so it must be recorded.
1801
1998
  const scope = info.scope === 'log-only' ? 'log-only' : 'project';
1999
+ // v4 attribution discriminator (session-close attribution). `projects` is the evidence-based
2000
+ // set this session actually closed — explicit --project ∪ transcript
2001
+ // close-file edits ∪ apply's authoritative payload.project — and NEVER the
2002
+ // recency primary. Its PRESENCE marks a v4 marker whose scope resolveCloseScope
2003
+ // trusts directly; a pre-v4 marker carries only `project` and is treated as an
2004
+ // uncorroborated legacy hint (see resolveCloseScope). `project` stays as
2005
+ // projects[0] for back-compat with the flat-field readers.
2006
+ const projects = Array.isArray(info.projects)
2007
+ ? [...new Set(info.projects.filter(Boolean))]
2008
+ : info.project
2009
+ ? [info.project]
2010
+ : [];
1802
2011
  const payload = {
1803
2012
  session_id: sessionId,
1804
- project: info.project || null,
2013
+ project: info.project || projects[0] || null,
2014
+ projects,
1805
2015
  scope,
1806
2016
  transcript_path: info.transcript_path || null,
1807
2017
  closed_at: new Date().toISOString(),
@@ -2170,6 +2380,64 @@ export function isUnderProjectDirs(file, slugs) {
2170
2380
  return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
2171
2381
  }
2172
2382
 
2383
+ /**
2384
+ * The session-close FILES of some project, as a path matcher. Used to attribute a
2385
+ * close to a session: a transcript that edited `projects/<slug>/session-state.md`
2386
+ * (or that project's hot.md / a session-log shard) is evidence THIS session was
2387
+ * closing <slug>. Any other file under `projects/<slug>/` is NOT evidence — merely
2388
+ * editing a page or an ADR there says nothing about whose close is whose, and
2389
+ * treating it as attribution would re-block a session for a project it only read
2390
+ * around in (codex design review).
2391
+ */
2392
+ const CLOSE_FILE_RE = /^projects\/([^/]+)\/(session-state\.md|hot\.md|session-log\/[^/]+\.md)$/;
2393
+
2394
+ /** Slugs whose close files this session edited directly (Write/Edit tool_use). */
2395
+ function projectsFromTouchedCloseFiles(transcriptPath, hypoDir) {
2396
+ const out = new Set();
2397
+ if (!transcriptPath) return out;
2398
+ for (const f of extractTouchedWikiFiles(transcriptPath, hypoDir)) {
2399
+ const m = posixPath(f).match(CLOSE_FILE_RE);
2400
+ if (m) out.add(m[1]);
2401
+ }
2402
+ return out;
2403
+ }
2404
+
2405
+ /**
2406
+ * closeScope: the projects THIS session is accountable for closing. The union of
2407
+ * three signals, because no single one covers every close path:
2408
+ *
2409
+ * 1. `opts.closeScope` — the caller states it outright. `--apply-session-close`
2410
+ * passes `payload.project` (it is the authority on what it just closed) and
2411
+ * `--mark-session-closed --project=<slug>` passes its attribution slug. This
2412
+ * signal is LOAD-BEARING, not a convenience: the documented close path writes
2413
+ * its files from inside a Bash call, so they never surface as Edit/Write
2414
+ * `file_path`s and signal 2 cannot see them (the same blind spot that forces
2415
+ * closeFileTargets() to seed the lint scope explicitly).
2416
+ * 2. close files the transcript shows this session editing — the hand-written
2417
+ * close, which bypasses the script entirely.
2418
+ * 3. `marker.project` — after a scripted close marked, this is how a later reader
2419
+ * (PreCompact) recovers signal 1. It is what keeps the marker == compact-ready
2420
+ * invariant: the writer scopes by `payload.project`, PreCompact re-scopes by
2421
+ * the `project` that same writer recorded, so the two can never disagree.
2422
+ */
2423
+ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
2424
+ const scope = new Set(opts.closeScope || []);
2425
+ for (const p of projectsFromTouchedCloseFiles(opts.transcriptPath, hypoDir)) scope.add(p);
2426
+ // Marker attribution (session-close attribution). A v4 marker carries `projects` — the
2427
+ // evidence-based set it closed (never recency) — trusted directly. A pre-v4
2428
+ // legacy marker carries only `project` with no provenance, and that value may be
2429
+ // recency-derived (the P1 bug). An uncorroborated legacy attribution must NOT
2430
+ // enable partitioning, or a stale/wrong slug could demote a real failure to
2431
+ // foreign debt. So a legacy `project` enters scope only when the direct signals
2432
+ // above (explicit close scope or transcript close-file edits) already corroborate
2433
+ // the SAME slug; otherwise it stays a display hint. This narrow gap self-heals as
2434
+ // pre-v4 markers expire (7-day TTL).
2435
+ if (Array.isArray(marker?.projects)) {
2436
+ for (const p of marker.projects) if (p) scope.add(p);
2437
+ }
2438
+ return scope;
2439
+ }
2440
+
2173
2441
  // ── PreCompact gate — single source of truth ────────────────────────────────
2174
2442
  /**
2175
2443
  * The full PreCompact gate decision as a READ-ONLY status. This is the single
@@ -2201,7 +2469,15 @@ export function isUnderProjectDirs(file, slugs) {
2201
2469
  * ignored.
2202
2470
  *
2203
2471
  * @param {string} hypoDir
2204
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
2472
+ * opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
2473
+ * gated (hook payload.cwd, or the CLI --session-cwd flag — never process.cwd(),
2474
+ * which is post-`cd` non-authoritative). When set and not log-only, the project
2475
+ * that owns this cwd is checked for close-completeness as an INDEPENDENT blocker,
2476
+ * catching a session whose own project close was never started. Unmatched/ambiguous
2477
+ * cwd yields a best-effort notice, not a block. apply never passes it (its launch
2478
+ * cwd may differ from the authoritative payload.project).
2479
+ *
2480
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
2205
2481
  * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
2206
2482
  */
2207
2483
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -2267,6 +2543,72 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2267
2543
  // projectOverride narrows the close status to one project (check-only); a
2268
2544
  // marker-writing caller never sets it, so the marker path stays global.
2269
2545
  close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
2546
+
2547
+ // Attribute the close debt before blocking on it. An incomplete close belongs
2548
+ // to whichever session performed it; charging it to an unrelated session is the
2549
+ // false block this partition exists to stop. The gate already draws exactly this
2550
+ // line for LINT debt (partitionLintScope: errors in files THIS session touched
2551
+ // block, pre-existing debt elsewhere is a notice) — close-file debt was the one
2552
+ // check still hard-blocking globally on another session's work.
2553
+ //
2554
+ // Two fail-closed guards keep the partition from eating a real blocker:
2555
+ // - close.fallback: no project closed today, so this is the "you have not
2556
+ // closed this session AT ALL" path. It must block unconditionally; demoting
2557
+ // it would gut the gate's whole purpose.
2558
+ // - empty scope: no positive attribution signal, so we cannot tell whose debt
2559
+ // it is. Never demote on a guess — fall back to today's global block.
2560
+ // - projectOverride: the caller asked "is THIS project close-complete?".
2561
+ // Demoting the very project it named would answer a question nobody asked.
2562
+ //
2563
+ // Bounded tradeoff (codex design review, accepted rather than closed). A
2564
+ // scripted close that CRASHES mid-write leaves no marker and no transcript trace
2565
+ // (it writes from inside a Bash call), so its own project carries no attribution.
2566
+ // If that same session had also hand-edited SOME OTHER project's close files, the
2567
+ // scope is non-empty but omits the torn project, and its debt is demoted. The
2568
+ // window is narrow and self-limiting: apply writes the project files, then the
2569
+ // session-log, then the root log entry, so the only torn state that reaches this
2570
+ // partition at all is a missing log.md entry — which deriveRootLogEntries
2571
+ // regenerates from the session-log heading. A crash before the session-log is not
2572
+ // detected as today-active by hasTodayCloseActivity in the first place (the
2573
+ // pre-existing tradeoff documented there). And the marker is never written, so the
2574
+ // Stop hook still refuses to end the session. Closing this properly needs a
2575
+ // durable close-attempt record; it is not worth a new state-file lifecycle here.
2576
+ const scopeSet = resolveCloseScope(hypoDir, opts, marker);
2577
+ const failed = (close.projects || []).filter((p) => !p.ok);
2578
+ const partition =
2579
+ !close.fallback && !opts.projectOverride && scopeSet.size > 0 && failed.length > 0;
2580
+
2581
+ close.scope = [...scopeSet];
2582
+ close.debt = [];
2583
+ // Re-project the flat aliases onto what actually BLOCKS, so `ok` and
2584
+ // `stale`/`missing` can never contradict each other (a reader that treats a
2585
+ // non-empty `missing` as failure stays correct). Demoted debt moves to its own
2586
+ // `debt` field instead of masquerading as this session's unfinished work.
2587
+ //
2588
+ // ONLY when partitioning. Deriving `ok` from the per-project rows unconditionally
2589
+ // would silently drop the failures that have NO project row to be derived from:
2590
+ // an unresolvable active project yields `projects: []` with
2591
+ // `missing: ['hot.md (no active project in pointer table)']`, so an empty `failed`
2592
+ // would read as "nothing failed" and flip a red gate green (codex pre-commit
2593
+ // BLOCKER, reproduced on a vault with no active-project row). Outside the
2594
+ // partition the close status stands exactly as sessionCloseGlobalStatus computed it.
2595
+ if (partition) {
2596
+ const mine = failed.filter((p) => scopeSet.has(p.project));
2597
+ const foreign = failed.filter((p) => !scopeSet.has(p.project));
2598
+ close.debt = foreign.map((p) => ({ project: p.project, stale: p.stale, missing: p.missing }));
2599
+ close.stale = [...new Set(mine.flatMap((p) => p.stale))];
2600
+ close.missing = [...new Set(mine.flatMap((p) => p.missing))];
2601
+ close.ok = mine.length === 0;
2602
+ // The flat `project` alias must follow the scope too: it names the project the
2603
+ // rest of this status describes, and every consumer that renders a per-file
2604
+ // checklist builds it from that name. Left as the global `primary` it can point
2605
+ // at a DEMOTED foreign project, and the checklist then reports ✓ for files the
2606
+ // debt list simultaneously calls missing (codex pre-commit CONCERN).
2607
+ if (!scopeSet.has(close.project)) {
2608
+ close.project = mine[0]?.project ?? [...scopeSet][0] ?? close.project;
2609
+ }
2610
+ }
2611
+
2270
2612
  if (!close.ok) {
2271
2613
  blockers.push({
2272
2614
  type: 'close',
@@ -2276,6 +2618,74 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2276
2618
  ].join(', ')}`,
2277
2619
  });
2278
2620
  }
2621
+ for (const p of close.debt) {
2622
+ notices.push({
2623
+ type: 'close-debt',
2624
+ project: p.project,
2625
+ reason: `${p.project}: incomplete session close from another session (${[
2626
+ ...p.missing.map((f) => `${f} (missing)`),
2627
+ ...p.stale.map((f) => `${f} (stale)`),
2628
+ ].join(', ')}) — not blocking; that project's next close will fix it`,
2629
+ });
2630
+ }
2631
+ }
2632
+
2633
+ // 3b. session-cwd close (session-cwd close check). The current session's cwd project is an
2634
+ // INDEPENDENT close responsibility. sessionCloseGlobalStatus above only sees
2635
+ // projects that left an authoritative close-activity trace (a session-log
2636
+ // heading / log.md entry), so a project whose close was NEVER STARTED is
2637
+ // invisible — and if the recency project was closed the same day, the gate would
2638
+ // go green while this session's real project stays unclosed (the false-green this
2639
+ // closes). Evaluate it here, AFTER the partition and OUTSIDE scopeSet /
2640
+ // close.projects, so it can neither be demoted to foreign debt (partition) nor
2641
+ // spawn a W8 design-history blocker (which derives from close.projects). log-only
2642
+ // sessions are exempt (no project to close). apply never passes sessionCwd (its
2643
+ // launch cwd may differ from the authoritative payload.project — a supported
2644
+ // cross-project close), so this runs only on the read / mark paths.
2645
+ if (!logOnly && opts.sessionCwd) {
2646
+ const cwdProject = pickProjectByCwd(collectProjectWorkingDirs(hypoDir), opts.sessionCwd, {
2647
+ rejectAmbiguous: true,
2648
+ });
2649
+ if (cwdProject) {
2650
+ const s = sessionCloseFileStatus(hypoDir, { projectOverride: cwdProject });
2651
+ if (!s.ok) {
2652
+ // ALWAYS emit the typed close-cwd blocker when the cwd project's close is
2653
+ // incomplete — never suppress it as a duplicate of the global `close`
2654
+ // blocker. The Stop hook keys its marker re-check on this exact type, so
2655
+ // hiding it (even when a `close` blocker names the same project) would let
2656
+ // Stop honor a stale marker and end the session green (codex pre-commit
2657
+ // BLOCKER). A second entry for the same slug is merely noisy, never wrong.
2658
+ blockers.push({
2659
+ type: 'close-cwd',
2660
+ project: cwdProject,
2661
+ reason: `session cwd project '${cwdProject}' has an incomplete session close: ${[
2662
+ ...s.missing.map((f) => `${f} (missing)`),
2663
+ ...s.stale.map((f) => `${f} (stale)`),
2664
+ ].join(', ')}`,
2665
+ });
2666
+ // If the partition demoted this project to foreign debt, it is NOT another
2667
+ // session's work — it is THIS session's, and we just BLOCKED on it. Remove
2668
+ // it from BOTH the debt notice and close.debt so the status cannot report
2669
+ // the same project as non-blocking debt and a blocker at once (codex
2670
+ // pre-commit CONCERN: crystallize renders close_debt from close.debt).
2671
+ for (let i = notices.length - 1; i >= 0; i--) {
2672
+ if (notices[i].type === 'close-debt' && notices[i].project === cwdProject)
2673
+ notices.splice(i, 1);
2674
+ }
2675
+ if (Array.isArray(close.debt))
2676
+ close.debt = close.debt.filter((d) => d.project !== cwdProject);
2677
+ }
2678
+ } else {
2679
+ // cwd is under no project working_dir, or ambiguously under several (a
2680
+ // monorepo tie pickProjectByCwd declined): we cannot attribute a close
2681
+ // responsibility, so we do NOT hard-block (nothing proves there is anything
2682
+ // to close). Surface a best-effort notice so the coverage gap is visible.
2683
+ notices.push({
2684
+ type: 'close-cwd-unresolved',
2685
+ reason:
2686
+ 'session cwd did not resolve to a unique project — the P2 cwd close check is best-effort here; pass --project or --log-only to be explicit',
2687
+ });
2688
+ }
2279
2689
  }
2280
2690
 
2281
2691
  // 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
@@ -2425,8 +2835,55 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2425
2835
  .map(([n]) => n);
2426
2836
  const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
2427
2837
  const driftedT = entries.filter(([, t]) => t.dirty).map(([n]) => n);
2428
- if (!report || !(conflictedT.length || overCapT.length || driftedT.length)) {
2429
- skipped.feedback = true; // buildError / unparseable / non-actionable → fail-open
2838
+ // A target whose file EXISTS but cannot be projected into (its
2839
+ // <learned_behaviors> container is gone) reports dirty:false with no
2840
+ // conflict flag — it cannot be built, so nothing "would change". That
2841
+ // shape used to fall into the non-actionable branch below and fail OPEN,
2842
+ // the worst possible reading: a projection that loads ZERO rules on this
2843
+ // machine was classified as "nothing to do" and waved through. It is a
2844
+ // blocker, not a shrug.
2845
+ //
2846
+ // ONLY kind 'build-failed'. A 'target-missing' buildError (no ~/.claude/
2847
+ // CLAUDE.md yet) is the ordinary first-run state and must keep failing
2848
+ // OPEN, or the gate blocks every new user on their first /compact.
2849
+ const buildErrT = entries
2850
+ .filter(([, t]) => t.buildError && t.buildErrorKind === 'build-failed')
2851
+ .map(([n]) => n);
2852
+ // Side-file I/O trouble (an unreadable feedback_<slug>.md under the
2853
+ // project memory dir) is a NOTICE, never a blocker: the primary
2854
+ // projection still loads every rule, and the only fix is a permission
2855
+ // bit on that path — `--ensure-container` cannot touch it. A gate that
2856
+ // blocks on a condition its own named remedy cannot clear is a gate that
2857
+ // gets bypassed.
2858
+ const sideWarnT = entries.filter(([, t]) => (t.sideWarnings || []).length);
2859
+ if (
2860
+ !report ||
2861
+ !(
2862
+ conflictedT.length ||
2863
+ overCapT.length ||
2864
+ driftedT.length ||
2865
+ buildErrT.length ||
2866
+ sideWarnT.length
2867
+ )
2868
+ ) {
2869
+ skipped.feedback = true; // unparseable / non-actionable → fail-open
2870
+ } else if (buildErrT.length) {
2871
+ // Name the EXACT target file and the remedy for THIS cause. "Restore
2872
+ // the managed container" with no path is a dead end, and so is naming
2873
+ // `--ensure-container` for a permission error or a dangling symlink,
2874
+ // which it cannot fix — a blocker with no way through gets bypassed
2875
+ // rather than obeyed. t.buildError carries the target's absolute path
2876
+ // and t.buildErrorRemedy the cause-specific way out; feedback-sync
2877
+ // makes that judgment once, at the branch that detects the cause.
2878
+ const failed = entries.filter(([n]) => buildErrT.includes(n));
2879
+ const details = failed.map(([n, t]) => `${n}: ${t.buildError}`).join('; ');
2880
+ const remedies = [
2881
+ ...new Set(failed.map(([, t]) => t.buildErrorRemedy).filter(Boolean)),
2882
+ ].join(' ');
2883
+ blockers.push({
2884
+ type: 'feedback',
2885
+ reason: `feedback projection cannot be built — ${details} — no rules are loaded from it. ${remedies}`,
2886
+ });
2430
2887
  } else if (conflictedT.length) {
2431
2888
  blockers.push({
2432
2889
  type: 'feedback',
@@ -2437,13 +2894,23 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2437
2894
  type: 'feedback',
2438
2895
  reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
2439
2896
  });
2440
- } else {
2897
+ } else if (driftedT.length) {
2441
2898
  driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
2442
2899
  notices.push({
2443
2900
  type: 'feedback',
2444
2901
  reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
2445
2902
  });
2446
2903
  }
2904
+ // Additive, and deliberately outside the chain above: a side-file I/O
2905
+ // problem is orthogonal to the primary target's health, so it is a notice
2906
+ // whatever else is (or is not) going on. It names the path and the
2907
+ // permission fix, because that — not a command — is the way out.
2908
+ for (const [n, t] of sideWarnT) {
2909
+ notices.push({
2910
+ type: 'feedback',
2911
+ reason: `feedback projection side file unreadable (${n}): ${t.sideWarnings.join('; ')} — fix the permissions on that path; the primary projection still loads every rule (\`--ensure-container\` does not fix this)`,
2912
+ });
2913
+ }
2447
2914
  }
2448
2915
  } catch {
2449
2916
  skipped.feedback = true;
@@ -2516,6 +2983,29 @@ export function isCompactOrClearCommand(prompt) {
2516
2983
  * @returns {string} newline-joined user text, or '' on any failure (fail-open)
2517
2984
  */
2518
2985
  export function extractUserMessages(transcriptPath, tailN = 30) {
2986
+ return extractUserMessageRecords(transcriptPath, tailN, { keepEmpty: true }).join('\n');
2987
+ }
2988
+
2989
+ /**
2990
+ * Same extraction as {@link extractUserMessages}, but ONE STRING PER TRANSCRIPT
2991
+ * RECORD instead of one flat blob. The message boundary is the point: a gate that
2992
+ * asks "did the user say exactly X" cannot ask it of text that has been joined
2993
+ * across turns, because any line of any message then looks like a whole message.
2994
+ * {@link hasTypedUserApproval} needs that boundary; the close-intent readers, which
2995
+ * only ever ask "does this text contain a close phrase", do not.
2996
+ *
2997
+ * @param {string} transcriptPath
2998
+ * @param {number} tailN how many trailing lines to scan (Infinity → whole file)
2999
+ * @param {{keepEmpty?: boolean}} [opts] keepEmpty preserves a '' per dropped record,
3000
+ * so the joined form stays byte-identical to what extractUserMessages always returned.
3001
+ * @returns {string[]} user-typed text per record, or [] on any failure (fail-open)
3002
+ */
3003
+ export function extractUserMessageRecords(transcriptPath, tailN = 30, { keepEmpty } = {}) {
3004
+ const records = extractUserRecordTexts(transcriptPath, tailN);
3005
+ return keepEmpty ? records : records.filter((t) => t !== '');
3006
+ }
3007
+
3008
+ function extractUserRecordTexts(transcriptPath, tailN) {
2519
3009
  try {
2520
3010
  const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
2521
3011
  // tailN === Infinity → whole transcript (the marker-write hard gate needs the
@@ -2523,51 +3013,49 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
2523
3013
  // checklist). The Stop hook keeps the 30-line default so a stale old close
2524
3014
  // signal doesn't re-trigger every turn.
2525
3015
  const tail = Number.isFinite(tailN) ? lines.slice(-tailN) : lines;
2526
- return tail
2527
- .map((line) => {
2528
- try {
2529
- const obj = JSON.parse(line);
2530
- // Skill-injection vector: drop system-injected role:user
2531
- // messages before they pollute the close-intent signal.
2532
- // isMeta:true — slash-command bodies, skill bodies, local-command
2533
- // caveats. Their text is docs/specs, often full of close vocabulary
2534
- // (e.g. the /hypo:crystallize spec literally contains close phrases),
2535
- // which would let the gate self-satisfy the moment the model invokes
2536
- // a close command. Confirmed isMeta:true in the transcript.
2537
- // promptSource system|sdk task-notifications (system) and
2538
- // SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
2539
- if (obj.isMeta === true) return '';
2540
- if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
2541
- const msg = obj.message ?? obj;
2542
- const role = msg.role ?? obj.role ?? obj.type;
2543
- if (role !== 'user') return '';
2544
- const content = msg.content ?? obj.content;
2545
- if (typeof content === 'string') {
2546
- // Stop-hook block feedback is recorded as a role:user string. It is
2547
- // the hook's OWN nudge ("[WIKI_AUTOCLOSE] Run crystallize …"), not
2548
- // user intent counting it would be circular (the hook that prods the
2549
- // model to close would become proof the user wanted to close).
2550
- return content.startsWith('Stop hook feedback') ? '' : content;
2551
- }
2552
- if (Array.isArray(content)) {
2553
- // Only genuine user-typed text blocks. tool_result blocks are also
2554
- // recorded with role:'user' in the Claude Code transcript; slurping
2555
- // them via JSON.stringify let tool output (e.g. close-pattern example
2556
- // strings read out of code/docs) trip the close-intent gate.
2557
- // Do NOT recurse into tool_result.content, or the pollution returns.
2558
- return content
2559
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
2560
- .map((b) => b.text)
2561
- .join('\n');
2562
- }
2563
- return '';
2564
- } catch {
2565
- return '';
3016
+ return tail.map((line) => {
3017
+ try {
3018
+ const obj = JSON.parse(line);
3019
+ // Skill-injection vector: drop system-injected role:user
3020
+ // messages before they pollute the close-intent signal.
3021
+ // isMeta:true — slash-command bodies, skill bodies, local-command
3022
+ // caveats. Their text is docs/specs, often full of close vocabulary
3023
+ // (e.g. the /hypo:crystallize spec literally contains close phrases),
3024
+ // which would let the gate self-satisfy the moment the model invokes
3025
+ // a close command. Confirmed isMeta:true in the transcript.
3026
+ // promptSource system|sdk task-notifications (system) and
3027
+ // SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
3028
+ if (obj.isMeta === true) return '';
3029
+ if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
3030
+ const msg = obj.message ?? obj;
3031
+ const role = msg.role ?? obj.role ?? obj.type;
3032
+ if (role !== 'user') return '';
3033
+ const content = msg.content ?? obj.content;
3034
+ if (typeof content === 'string') {
3035
+ // Stop-hook block feedback is recorded as a role:user string. It is
3036
+ // the hook's OWN nudge ("[WIKI_AUTOCLOSE] Run crystallize …"), not
3037
+ // user intent counting it would be circular (the hook that prods the
3038
+ // model to close would become proof the user wanted to close).
3039
+ return content.startsWith('Stop hook feedback') ? '' : content;
2566
3040
  }
2567
- })
2568
- .join('\n');
3041
+ if (Array.isArray(content)) {
3042
+ // Only genuine user-typed text blocks. tool_result blocks are also
3043
+ // recorded with role:'user' in the Claude Code transcript; slurping
3044
+ // them via JSON.stringify let tool output (e.g. close-pattern example
3045
+ // strings read out of code/docs) trip the close-intent gate.
3046
+ // Do NOT recurse into tool_result.content, or the pollution returns.
3047
+ return content
3048
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
3049
+ .map((b) => b.text)
3050
+ .join('\n');
3051
+ }
3052
+ return '';
3053
+ } catch {
3054
+ return '';
3055
+ }
3056
+ });
2569
3057
  } catch {
2570
- return '';
3058
+ return [];
2571
3059
  }
2572
3060
  }
2573
3061
 
@@ -2678,83 +3166,295 @@ export function resolveTranscriptBySessionId(
2678
3166
  }
2679
3167
 
2680
3168
  /**
2681
- * Returns true iff the transcript carries a genuine USER session-close signal
2682
- * the hard gate for the session-closed marker writers. Scans the FULL
2683
- * transcript: a close request can precede the marker write by the entire close
2684
- * checklist, so the Stop hook's 30-line tail would miss it.
2685
- *
2686
- * Evidence (any one is sufficient):
2687
- * 1. a de-polluted NL close phrase isClosePattern over extractUserMessages,
2688
- * which already drops injected / tool / hook-feedback text;
2689
- * 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
2690
- * counted: it abandons context, whereas a session-close PRESERVES the work
2691
- * to the wiki a different intent;
2692
- * 3. an AskUserQuestion answer whose SELECTED value names a close action (the
2693
- * canonical "offer [세션 마무리] user picks it" flow).
2694
- *
2695
- * A Stop-hook block is NOT evidence: it is the hook's own nudge to close, so
2696
- * counting it would be circular (the incident's block told the model to write the
2697
- * marker). extractUserMessages already strips it.
2698
- *
2699
- * Fail-closed: any read error false (caller refuses the marker).
3169
+ * Returns true iff the transcript's LATEST live user decision is to close the
3170
+ * hard gate for the session-closed marker writers. This is a state predicate, not
3171
+ * an existence one: "is close still approved right now", not "did a
3172
+ * close signal ever appear". Scans the FULL transcript in line order, classifies
3173
+ * each record, and tracks the approval as a LEASE.
3174
+ *
3175
+ * Classification (structural fields onlynever content heuristics for producer):
3176
+ * • GRANT a genuine user close: an NL close phrase in user text that
3177
+ * survives {@link eventUserText}'s exclusions; a `/compact`
3178
+ * queue-op; a remove-path queued_command attachment carrying a
3179
+ * close with an audited human producer (origin.kind "human"); a
3180
+ * correlated, non-error AskUserQuestion answer naming a close.
3181
+ * INVALIDATE a fresh user intent that expires the lease: any other genuine
3182
+ * user text, `/clear`, `popAll`, a non-close queued_command, a
3183
+ * non-close AskUserQuestion selection.
3184
+ * NEUTRAL everything the model can produce or the harness injects: system/
3185
+ * sdk replay, isMeta bodies, sidechain, interruptedMessageId
3186
+ * companions, assistant, tool_result, task-notification.
3187
+ * FATAL an unparseable line the transcript is being appended to or is
3188
+ * corrupt, so refuse rather than read a half-written record.
3189
+ *
3190
+ * The last grant wins and a later invalidate expires it, so a stale close (Defect
3191
+ * B), a queued "keep working" after a close, and a non-close AskUserQuestion
3192
+ * selection all correctly read as NOT closed. Abandoned-branch staleness is a
3193
+ * known limit (no leaf pointer exists to resolve it — see the branch note on the
3194
+ * function), mitigated by the lease.
3195
+ *
3196
+ * Threat model: an honest-but-drifting model that does not forge the
3197
+ * transcript. This is a safety interlock, not an authorization boundary — a model
3198
+ * that forges records is out of scope and documented so.
3199
+ *
3200
+ * Fail-closed: any read/parse error, or an unresolvable branch, → false.
2700
3201
  */
3202
+ // Genuine user text of a record, or null when the record is on a channel the
3203
+ // honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
3204
+ // role:user is the boundary the model cannot cross: it authors role:assistant,
3205
+ // so a user record that survives every exclusion below is genuine user text.
3206
+ // The exclusions are exactly the model-reachable channels:
3207
+ // • isMeta slash-command / skill bodies and local-command caveats
3208
+ // • promptSource system|sdk queue replay of injected text, SDK prompts
3209
+ // • isSidechain subagent context (a subagent must not close the session)
3210
+ // • interruptedMessageId the harness cut the model off; user-SHAPED, not a
3211
+ // user decision (e.g. the [Request interrupted by user]
3212
+ // companion of a delivered /compact)
3213
+ // • Stop-hook feedback the hook's own close nudge — circular
3214
+ // • tool_result blocks tool output, not typed text
3215
+ // No promptSource allowlist is required: requiring `typed` would drop the
3216
+ // legacy absent-promptSource close the older gate has always honoured, while the
3217
+ // dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
3218
+ // excluded here anyway.
3219
+ function eventUserText(obj) {
3220
+ if (obj.isMeta === true) return null;
3221
+ if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
3222
+ if (obj.isSidechain === true) return null;
3223
+ if (obj.interruptedMessageId) return null;
3224
+ const msg = obj.message ?? obj;
3225
+ const role = msg.role ?? obj.role ?? obj.type;
3226
+ if (role !== 'user') return null;
3227
+ const content = msg.content ?? obj.content;
3228
+ if (typeof content === 'string') {
3229
+ return content.startsWith('Stop hook feedback') ? null : content;
3230
+ }
3231
+ if (Array.isArray(content)) {
3232
+ const texts = content
3233
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
3234
+ .map((b) => b.text);
3235
+ return texts.length ? texts.join('\n') : null;
3236
+ }
3237
+ return null;
3238
+ }
3239
+
3240
+ // A record on a channel the honest-but-drifting model can reach, so it can never
3241
+ // be a user decision. Used to keep injected / replayed / subagent records out of
3242
+ // BOTH the user-text and the AskUserQuestion-answer classifiers.
3243
+ function isModelReachableRecord(obj) {
3244
+ return (
3245
+ obj.isMeta === true ||
3246
+ obj.promptSource === 'system' ||
3247
+ obj.promptSource === 'sdk' ||
3248
+ obj.isSidechain === true
3249
+ );
3250
+ }
3251
+
2701
3252
  export function hasUserCloseSignal(transcriptPath) {
2702
3253
  if (!transcriptPath) return false;
2703
- let lines;
3254
+ let raw;
2704
3255
  try {
2705
- lines = readFileSync(transcriptPath, 'utf-8').split('\n');
3256
+ raw = readFileSync(transcriptPath, 'utf-8');
2706
3257
  } catch {
2707
3258
  return false;
2708
3259
  }
2709
- // (1) NL close over the full, de-polluted transcript.
2710
- if (isClosePattern(extractUserMessages(transcriptPath, Infinity))) return true;
2711
- // AskUserQuestion answers (3) must be correlated to a real AskUserQuestion
2712
- // tool_use by id otherwise ANY tool_result string containing "have been
2713
- // answered" (e.g. a Read/Grep of this very file, or of a transcript) would
2714
- // satisfy the gate, reintroducing the tool_result pollution the de-pollution
2715
- // layer closes. First pass collects the genuine AskUserQuestion tool_use ids;
2716
- // the tool_use (assistant) always precedes its tool_result (user) in the log,
2717
- // so a single forward scan suffices.
2718
- const askIds = new Set();
2719
- for (const line of lines) {
3260
+ // FATAL: a non-empty line that will not parse means the transcript is being
3261
+ // appended to (a half-written record) or is corrupt. Skipping it would let a
3262
+ // stale prior grant survive past an event we cannot read, so refuse. A line
3263
+ // that parses to a non-object (a bare null / string / number) is valid JSON but
3264
+ // not a record noise, not corruption — so it is skipped, not fatal, and never
3265
+ // reaches the field reads below.
3266
+ const recs = [];
3267
+ for (const line of raw.split('\n')) {
2720
3268
  if (!line.trim()) continue;
2721
- let obj;
3269
+ let o;
2722
3270
  try {
2723
- obj = JSON.parse(line);
3271
+ o = JSON.parse(line);
2724
3272
  } catch {
3273
+ return false;
3274
+ }
3275
+ if (o === null || typeof o !== 'object') continue;
3276
+ recs.push(o);
3277
+ }
3278
+
3279
+ // The approval is a LEASE, not an existence fact: walk the transcript in line
3280
+ // order and track whether the LATEST user decision is a grant. Each grant sets
3281
+ // it true, each invalidate sets it false, neutral leaves it — so at the end
3282
+ // `granted` is "the most recent user decision was to close, and nothing has
3283
+ // expired it since", which is how a stale close and a queued change-of-mind
3284
+ // read as NOT closed.
3285
+ //
3286
+ // Branch note: line order mixes an abandoned branch's records with the live
3287
+ // ones. A leaf-pointer ancestry filter was tried and withdrawn — the transcript
3288
+ // carries no authoritative leaf pointer (measured: 0 leafUuid / summary
3289
+ // records), so a heuristic leaf can skip the real invalidator and PRESERVE a
3290
+ // stale grant (a fail-open, not a conservative filter). Until such a pointer
3291
+ // exists, a close on a branch abandoned under a neutral tail is a known
3292
+ // staleness limit, mitigated by the lease: any later live user intent, on any
3293
+ // branch, still expires it.
3294
+ const askIds = new Set();
3295
+ let granted = false;
3296
+
3297
+ for (const o of recs) {
3298
+ // Queue operations. The queue carries no correlation key (measured), so the
3299
+ // ENQUEUE content is the decision — not a later contentless dequeue, which
3300
+ // would need pairing we cannot do. Reading the enqueue also keeps the live
3301
+ // PreCompact gate working (it sees the enqueue) and avoids double-counting the
3302
+ // replay companion of an already-decided item (the /compact replay is not a
3303
+ // fresh decision). popAll cancels the queue → invalidate. Delivery ops
3304
+ // (dequeue, remove) carry no fresh decision here — a content-bearing remove of
3305
+ // an NL queued command is handled by its queued_command attachment below.
3306
+ if (o.type === 'queue-operation') {
3307
+ if (o.operation === 'popAll') {
3308
+ granted = false;
3309
+ continue;
3310
+ }
3311
+ if (o.operation !== 'enqueue') continue;
3312
+ const c = typeof o.content === 'string' ? o.content.trim() : '';
3313
+ if (/^\/compact(?:\s|$)/.test(c))
3314
+ granted = true; // a user compaction preserves the work → grant
3315
+ else if (/^\/clear(?:\s|$)/.test(c))
3316
+ granted = false; // abandons context → invalidate
3317
+ else if (!c || c.startsWith('<task-notification>')) {
3318
+ /* model-caused / empty — neutral */
3319
+ } else if (isClosePattern(c)) {
3320
+ /* NL close via the queue — the open dequeue gap: the producer cannot be
3321
+ attributed (a peer/model enqueue wears the same shape), so no grant */
3322
+ } else granted = false; // a queued non-close user intent → invalidate (change of mind)
2725
3323
  continue;
2726
3324
  }
2727
- // (2) /compact (queue-operation). Not /clear — see doc above.
2728
- if (
2729
- obj.type === 'queue-operation' &&
2730
- typeof obj.content === 'string' &&
2731
- /^\/compact(?:\s|$)/.test(obj.content.trim())
2732
- ) {
2733
- return true;
3325
+
3326
+ // remove-path delivery of a queued natural-language command (measured: the
3327
+ // item leaves the queue as it is handed to the model, landing as an
3328
+ // `attachment` of type queued_command with the prompt verbatim). A close here
3329
+ // grants ONLY with an audited human producer — origin.kind "human", present
3330
+ // on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
3331
+ // cannot attest a producer, so it does not grant (fail-closed). A NON-close
3332
+ // queued command (e.g. "keep working") is a fresh user intent and INVALIDATES
3333
+ // a prior grant regardless of origin — that is what closes the re-close hole
3334
+ // where a queued "continue" after a close leaves the stale lease live.
3335
+ if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
3336
+ const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
3337
+ const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
3338
+ if (isClosePattern(prompt)) {
3339
+ if (humanOrigin) granted = true;
3340
+ } else if (prompt) {
3341
+ granted = false;
3342
+ }
3343
+ continue;
2734
3344
  }
2735
- const content = (obj.message ?? obj).content;
2736
- if (!Array.isArray(content)) continue;
2737
- for (const b of content) {
2738
- if (!b || typeof b !== 'object') continue;
2739
- // record AskUserQuestion tool_use ids
2740
- if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && b.id) {
2741
- askIds.add(b.id);
2742
- continue;
3345
+
3346
+ // Record AskUserQuestion tool_use ids (assistant record, always precedes its
3347
+ // answer in line order).
3348
+ const content = (o.message ?? o).content;
3349
+ if (Array.isArray(content)) {
3350
+ for (const b of content) {
3351
+ if (
3352
+ b &&
3353
+ typeof b === 'object' &&
3354
+ b.type === 'tool_use' &&
3355
+ b.name === 'AskUserQuestion' &&
3356
+ b.id
3357
+ )
3358
+ askIds.add(b.id);
2743
3359
  }
2744
- // (3) AskUserQuestion answer naming a close action — only when this
2745
- // tool_result actually answers a recorded AskUserQuestion. The answer lands
2746
- // in a role:user tool_result string: `… have been answered: "Q"="A". …`.
2747
- // Match the answer value(s) (the `="…"` side), never the question text, and
2748
- // run the SAME isClosePattern as the NL path so the two channels agree.
2749
- if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
3360
+ }
3361
+
3362
+ // Genuine user text grant on a close phrase, invalidate on anything else.
3363
+ const text = eventUserText(o);
3364
+ if (text != null && text !== '') {
3365
+ granted = isClosePattern(text);
3366
+ continue;
3367
+ }
3368
+
3369
+ // AskUserQuestion answer, correlated to a recorded AskUserQuestion, EXCLUDED
3370
+ // and HARDENED. isModelReachableRecord keeps an injected / sdk / sidechain
3371
+ // record from reaching the answer parser. is_error:false AND the host's
3372
+ // success marker are required because a malformed AskUserQuestion echoes the
3373
+ // raw input back in an is_error result, and the model authors the option
3374
+ // labels. A close selection grants; any other real selection invalidates.
3375
+ if (Array.isArray(content) && !isModelReachableRecord(o)) {
3376
+ for (const b of content) {
3377
+ if (!b || typeof b !== 'object') continue;
3378
+ if (b.type !== 'tool_result' || !b.tool_use_id || !askIds.has(b.tool_use_id)) continue;
3379
+ if (b.is_error === true) continue;
2750
3380
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
3381
+ if (!/have been answered/.test(s)) continue;
3382
+ let sawAnswer = false;
3383
+ let sawClose = false;
2751
3384
  for (const m of s.matchAll(/="([^"]*)"/g)) {
2752
- if (isClosePattern(m[1])) return true;
3385
+ sawAnswer = true;
3386
+ if (isClosePattern(m[1])) sawClose = true;
2753
3387
  }
3388
+ if (sawClose) granted = true;
3389
+ else if (sawAnswer) granted = false;
2754
3390
  }
2755
3391
  }
2756
3392
  }
2757
- return false;
3393
+ return granted;
3394
+ }
3395
+
3396
+ /** The literal the user must type to approve a parked-overwrite batch. */
3397
+ export const APPROVAL_PHRASE = 'apply-proposals';
3398
+
3399
+ /**
3400
+ * True iff the transcript carries a user's TYPED approval of the batch this nonce
3401
+ * was minted for — the authorization gate for a transcript-approved apply.
3402
+ *
3403
+ * Deliberately NOT hasUserCloseSignal. That function answers "did the user want to
3404
+ * end the session", and it accepts a correlated AskUserQuestion answer as evidence
3405
+ * (see above). Reusing it here would let a SESSION-CLOSE approval spend itself as an
3406
+ * OVERWRITE approval: different authority, different question. Two gates, two
3407
+ * matchers.
3408
+ *
3409
+ * Typed text only, and an AskUserQuestion click is refused ON PURPOSE. The model
3410
+ * authors the option labels, and it authors them AFTER it has seen the nonce — so
3411
+ * it could place `apply-proposals <nonce>` on a "no" option, on every option, or
3412
+ * under a question that asks something else entirely. A click would prove a click.
3413
+ * Typing the nonce proves the user produced THIS phrase.
3414
+ *
3415
+ * The nonce carries the freshness: it is minted (crypto-random) only once the diff
3416
+ * has been re-read from disk and shown, so a turn that predates the diff cannot
3417
+ * contain it, and `resolve` spends the challenge BEFORE it writes, so it cannot be
3418
+ * replayed.
3419
+ *
3420
+ * extractUserMessageRecords does the de-pollution: it drops `isMeta` bodies
3421
+ * (slash-command and skill text, so a doc that quotes this phrase cannot satisfy the
3422
+ * gate), `promptSource: system|sdk`, Stop-hook feedback, and `tool_result` blocks (so
3423
+ * neither a tool's output nor a Read of a file that contains the phrase counts).
3424
+ * The model's own words are role:assistant and never reach it.
3425
+ *
3426
+ * Fail-closed: no path, a nonce that is not the minted shape, or an unreadable
3427
+ * transcript all return false.
3428
+ *
3429
+ * @param {string} transcriptPath
3430
+ * @param {string} nonce hex, as minted by `proposal challenge`
3431
+ */
3432
+ export function hasTypedUserApproval(transcriptPath, nonce) {
3433
+ if (!transcriptPath || typeof nonce !== 'string') return false;
3434
+ // Pin the shape rather than accept any string: a caller that passed '' or a
3435
+ // regex-ish value would otherwise turn the match into a wildcard.
3436
+ if (!/^[a-f0-9]{32,}$/.test(nonce)) return false;
3437
+ // A MESSAGE that IS the phrase, not a message that has the phrase somewhere in it.
3438
+ // Line-exactness is not enough, because the user is TOLD to type this phrase and so
3439
+ // it is natural to quote it back while hesitating:
3440
+ //
3441
+ // I do not consent; I am only quoting the command:
3442
+ // apply-proposals <nonce>
3443
+ //
3444
+ // Every line-level matcher reads that as approval, and the user has authorized an
3445
+ // overwrite by refusing one. The whole eligible message must be the phrase and
3446
+ // nothing else, which is the bar the TTY channel has always held (`apply <id>`,
3447
+ // alone, on the prompt). The two channels must not disagree about what consent
3448
+ // looks like.
3449
+ //
3450
+ // Measured, not assumed: across 468 eligible user records in this project's
3451
+ // transcripts, none carried a second text block and none carried injected
3452
+ // system-reminder text, so a turn whose only content is the phrase survives
3453
+ // extraction as exactly the phrase. A false negative costs a retype; a false
3454
+ // positive costs the user's file.
3455
+ const want = `${APPROVAL_PHRASE} ${nonce}`;
3456
+ const records = extractUserMessageRecords(transcriptPath, Infinity);
3457
+ return records.some((msg) => msg.trim() === want);
2758
3458
  }
2759
3459
 
2760
3460
  /**
@@ -2876,7 +3576,8 @@ export function isCloseReconfirmDeclined(transcriptPath) {
2876
3576
  // system/sdk synthetic prompts, and (via the array-content branch below)
2877
3577
  // tool_result blocks, which are role:'user' in the transcript but are NOT
2878
3578
  // user-typed text.
2879
- const isInjected = obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
3579
+ const isInjected =
3580
+ obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
2880
3581
  const role = msg.role ?? obj.role ?? obj.type;
2881
3582
  if (!isInjected && role === 'user') {
2882
3583
  if (typeof content === 'string') {
@@ -2962,7 +3663,15 @@ export function computeSessionGrowth(hypoDir) {
2962
3663
  // more expensive `git diff HEAD --unified=0` — Stop hook P95 win.
2963
3664
  // `-uall` expands untracked directories so a brand-new `pages/new.md`
2964
3665
  // isn't hidden behind a single `?? pages/` line.
2965
- const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], {
3666
+ // `-z`: NUL-separated, verbatim paths (see commitWikiChanges). The old
3667
+ // newline/quote-strip parser left octal escapes in place, so Korean page
3668
+ // names silently failed the `pages/`·`projects/` scope match and dropped
3669
+ // out of the growth count.
3670
+ // `-z`: NUL-separated, verbatim paths (see commitWikiChanges). The old
3671
+ // newline/quote-strip parser left octal escapes in place, so Korean page
3672
+ // names silently failed the `pages/`·`projects/` scope match and dropped
3673
+ // out of the growth count.
3674
+ const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall', '-z'], {
2966
3675
  encoding: 'utf-8',
2967
3676
  timeout: 5000,
2968
3677
  });
@@ -2976,10 +3685,14 @@ export function computeSessionGrowth(hypoDir) {
2976
3685
  // intentionally excluded — they're scaffolding, not page growth.
2977
3686
  const inPagesScope = (file) =>
2978
3687
  file.endsWith('.md') && (file.startsWith('pages/') || file.startsWith('projects/'));
2979
- for (const line of (porcelain.stdout || '').split('\n')) {
2980
- if (!line) continue;
2981
- const xy = line.slice(0, 2);
2982
- const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
3688
+ const records = (porcelain.stdout || '').split('\0');
3689
+ for (let i = 0; i < records.length; i++) {
3690
+ const rec = records[i];
3691
+ if (!rec) continue;
3692
+ const xy = rec.slice(0, 2);
3693
+ const file = rec.slice(3); // destination path for a rename/copy
3694
+ // A rename OR copy emits two records (`to\0from`); consume the trailing `from`.
3695
+ if (xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C') i++;
2983
3696
  if (!inPagesScope(file)) continue;
2984
3697
  if (xy === '??') {
2985
3698
  untrackedMd.push(file);
@@ -2987,7 +3700,8 @@ export function computeSessionGrowth(hypoDir) {
2987
3700
  continue;
2988
3701
  }
2989
3702
  hasTrackedMdChange = true;
2990
- if (xy.includes('A')) addedPages++;
3703
+ // A copy's destination is a brand-new page, so it counts as added like `A`.
3704
+ if (xy.includes('A') || xy.includes('C')) addedPages++;
2991
3705
  else if (xy.includes('M') || xy.includes('R')) updatedPages++;
2992
3706
  }
2993
3707
  if (!hasTrackedMdChange && untrackedMd.length === 0) return empty;
@@ -3079,3 +3793,65 @@ export function isIgnored(filePath, hypoDir, patterns) {
3079
3793
  }
3080
3794
  return false;
3081
3795
  }
3796
+
3797
+ // ── visibility scope ─────────────────────────────────────────────────────────
3798
+ // The machine-scoped visibility namespace (`visibility_scope: machine:<device>`)
3799
+ // requires that the SAME device string is produced at write time (audit device
3800
+ // stamps) and at lookup time (the visibility filter) — otherwise a page never
3801
+ // matches its own machine. So this is the single source for both. NOT cached:
3802
+ // each call reads env fresh so in-process tests can override via HYPO_DEVICE
3803
+ // (os.hostname is not mockable). CR/LF are stripped so the value stays a single
3804
+ // frontmatter token.
3805
+ export function currentDevice() {
3806
+ // Strip CR/LF BEFORE the fallback chain, not after: a HYPO_DEVICE of only
3807
+ // CR/LF is truthy, so stripping after `||` would yield '' and make
3808
+ // scopeVisible('machine:', '') pass — the empty-owner page must hide
3809
+ // everywhere. Stripping first collapses such a value to '' so it falls through
3810
+ // to hostname, keeping the result non-empty on every path.
3811
+ const env = String(process.env.HYPO_DEVICE || '').replace(/[\r\n]/g, '');
3812
+ if (env) return env;
3813
+ const host = String(hostname() || '').replace(/[\r\n]/g, '');
3814
+ return host || 'unknown';
3815
+ }
3816
+
3817
+ // Extract the top-level `visibility_scope` from a page's raw content. Mirrors
3818
+ // scripts/lib/frontmatter.mjs normalization (top-level only, first-wins, strip a
3819
+ // whitespace-led trailing YAML comment, strip surrounding quotes) rather than
3820
+ // importing it: hooks deploy to ~/.claude/hooks/ with no external imports. The
3821
+ // five consumers must call this instead of a local last-wins/no-comment parser,
3822
+ // else a `machine:devA # note` value fails to match on its own machine. Returns
3823
+ // '' when absent (which scopeVisible treats as shared).
3824
+ export function readVisibilityScope(raw) {
3825
+ const m = String(raw || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
3826
+ if (!m) return '';
3827
+ for (const line of m[1].split(/\r?\n/)) {
3828
+ if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
3829
+ const idx = line.indexOf(':');
3830
+ if (idx < 0) continue;
3831
+ if (line.slice(0, idx).trim() !== 'visibility_scope') continue;
3832
+ return line
3833
+ .slice(idx + 1)
3834
+ .trim()
3835
+ .replace(/\s+#.*$/, '')
3836
+ .replace(/^["']|["']$/g, '');
3837
+ }
3838
+ return '';
3839
+ }
3840
+
3841
+ // The single visibility decision, shared by lookup / query / file-watch /
3842
+ // page-usage / crystallize. `scopeValue` is a readVisibilityScope() output,
3843
+ // `device` a currentDevice() output. Prefix dispatch, fail-open on anything
3844
+ // unrecognized so the field is purely additive:
3845
+ // ''/'shared' → visible (the implicit default of every pre-existing page)
3846
+ // 'machine:<owner>' → visible only on the owning machine. Empty owner
3847
+ // (`machine:`) hides everywhere: '' can never equal
3848
+ // currentDevice()'s non-empty fallback.
3849
+ // 'agent:<id>' → visible; value space reserved, no writer yet (forward-compat)
3850
+ // anything else → visible (fail-open)
3851
+ export function scopeVisible(scopeValue, device) {
3852
+ const v = String(scopeValue || '').trim();
3853
+ if (v === '' || v === 'shared') return true;
3854
+ if (v.startsWith('machine:')) return v.slice('machine:'.length) === device;
3855
+ if (v.startsWith('agent:')) return true;
3856
+ return true;
3857
+ }