hypomnema 1.6.2 → 1.7.1

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 (70) 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 +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. package/scripts/pre-commit-format.mjs +0 -198
@@ -16,10 +16,16 @@ import {
16
16
  rmSync,
17
17
  readdirSync,
18
18
  realpathSync,
19
+ openSync,
20
+ unlinkSync,
21
+ renameSync,
22
+ linkSync,
19
23
  } from 'fs';
20
- import { join, relative, basename } from 'path';
24
+ import { join, relative, basename, dirname, isAbsolute } from 'path';
21
25
  import { homedir, hostname, tmpdir } from 'os';
22
26
  import { spawnSync } from 'child_process';
27
+ import { randomBytes } from 'crypto';
28
+ import { fileURLToPath } from 'url';
23
29
 
24
30
  const HOME = homedir();
25
31
 
@@ -90,19 +96,196 @@ export const LOG_PATH = join(HYPO_DIR, 'log.md');
90
96
  export const HOT_PATH = join(HYPO_DIR, 'hot.md');
91
97
  export const GUIDE_PATH = join(HYPO_DIR, 'hypo-guide.md');
92
98
 
93
- // Package root: written by init/upgrade to ~/.claude/hypo-pkg.json
94
- function resolvePkgRoot() {
95
- const p = join(HOME, '.claude', 'hypo-pkg.json');
96
- if (!existsSync(p)) return null;
99
+ // Package root: written by init/upgrade to ~/.claude/hypo-pkg.json.
100
+ //
101
+ // The plugin channel is the primary distribution path, and
102
+ // Claude Code's own plugin manager updates hooks WITHOUT ever touching
103
+ // hypo-pkg.json — only our own init/upgrade write it. So after a plugin
104
+ // auto-update this file can point at a stale pkgRoot indefinitely with no
105
+ // signal to the user, and PKG_ROOT (resolved here) is what lint/feedback
106
+ // scripts get resolved through — a hook can run at the new version while the
107
+ // script it shells out to still runs from the old one.
108
+ //
109
+ // An earlier version of this cross-check queried ~/.claude/settings.json's
110
+ // `enabledPlugins` + the plugin registry to positively attribute an active
111
+ // install. That was wrong: Claude Code layers `enabledPlugins` across
112
+ // user/project/local/managed settings (project overrides user), and a plugin
113
+ // with no explicit key is enabled by default — so a single-file read of the
114
+ // user settings can neither prove "enabled" nor "disabled". Querying it was
115
+ // certain to misjudge some real layout.
116
+ //
117
+ // This code already knows the one thing that can't be wrong: where IT is
118
+ // running from. `import.meta.url`, walked up to the nearest package.json,
119
+ // names the actual root of the code executing right now — no
120
+ // settings/registry guessing needed. That's `selfLocationPkgRoot()` below.
121
+ //
122
+ // Never throw: this runs at hook-module load time (`export const PKG_ROOT =
123
+ // resolvePkgRoot()`), so a throw here takes the whole hook process down.
124
+ // Every read below is try/caught and every helper fails open to null.
125
+
126
+ /** Non-mutating read of the cached pointer, or null on any absence/corruption. */
127
+ function readCachedPkgRoot() {
97
128
  try {
129
+ const p = join(HOME, '.claude', 'hypo-pkg.json');
130
+ if (!existsSync(p)) return null;
98
131
  const v = JSON.parse(readFileSync(p, 'utf-8')).pkgRoot;
99
132
  return typeof v === 'string' && v ? v : null;
100
133
  } catch {
101
134
  return null;
102
135
  }
103
136
  }
137
+
138
+ // A pkgRoot is usable only as an ABSOLUTE path to a real package directory
139
+ // whose package.json carries a version. A relative path (e.g. ".") resolves
140
+ // against whatever cwd the reading process happens to have; a directory that
141
+ // merely EXISTS but carries no versioned package.json is a pointer nothing
142
+ // can actually resolve scripts through. Same contract for every source of a
143
+ // pkgRoot value in this file — the cache included (a cached `pkgRoot: "."`
144
+ // used to pass on directory-existence alone).
145
+ //
146
+ // NOT sufficient by itself for self-location (see selfLocationPkgRoot below):
147
+ // "absolute path + a package.json with SOME version" is true of any Node
148
+ // package anywhere on disk, including one that has nothing to do with
149
+ // Hypomnema — e.g. `$HOME/package.json` from an unrelated project. This
150
+ // contract answers "is this a real, resolvable directory", not "is this OUR
151
+ // package". Callers that need the latter also require self-containment.
152
+ function isUsablePkgRootLocal(pkgRoot) {
153
+ if (typeof pkgRoot !== 'string' || !pkgRoot || !isAbsolute(pkgRoot)) return false;
154
+ try {
155
+ const v = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
156
+ return typeof v === 'string' && v.length > 0;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ // True iff `candidateRoot` actually CONTAINS the module that is running right
163
+ // now — i.e. `<candidateRoot>/hooks/hypo-shared.mjs` is, on disk, the exact
164
+ // same file as `ownRealPath` (this module's own realpath). This is the
165
+ // decisive check self-location needs and isUsablePkgRootLocal alone cannot
166
+ // give it: a versioned package.json only proves SOME package lives at
167
+ // `candidateRoot`, not that it is the Hypomnema install this code is part of.
168
+ // Without this, walking up from an unrelated location (a standalone-copied
169
+ // hooks/ dir sitting under a directory that happens to have its own,
170
+ // unrelated package.json a few levels up — e.g. `$HOME/package.json`) would
171
+ // silently adopt that unrelated tree as PKG_ROOT, and every script path built
172
+ // from it (`join(PKG_ROOT, 'scripts', 'lint.mjs')`, the crystallize recovery
173
+ // command in hypo-auto-minimal-crystallize.mjs, etc.) would point at files
174
+ // that were never shipped there.
175
+ //
176
+ // Both paths are realpath'd before comparing so a symlinked candidate or a
177
+ // symlinked ancestor of the running module (macOS's /var → /private/var, a
178
+ // symlinked plugin cache dir) still compares correctly. Fails closed (false)
179
+ // on any read error — a candidate this can't positively confirm is never
180
+ // adopted.
181
+ function candidateContainsRunningModule(candidateRoot, ownRealPath) {
182
+ try {
183
+ return realpathSync(join(candidateRoot, 'hooks', 'hypo-shared.mjs')) === ownRealPath;
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ // Walk up from the PARENT of the directory this module (hooks/hypo-shared.mjs)
190
+ // is actually running from, looking for the nearest ancestor that is (a) a
191
+ // usable package root AND (b) actually contains this exact running module —
192
+ // that IS the package root of the code currently executing, regardless of
193
+ // which channel put it there (plugin, npm, dev checkout).
194
+ //
195
+ // Plugin mode runs hooks straight out of the plugin's own cache directory
196
+ // (`${CLAUDE_PLUGIN_ROOT}/hooks/...`), so this resolves directly to whatever
197
+ // the plugin loader most recently updated — exactly the channel that can
198
+ // silently drift ahead of hypo-pkg.json. A manual/npm install COPIES hooks
199
+ // standalone into ~/.claude/hooks/ with no package.json alongside (the "hooks
200
+ // import only Node built-ins, nothing outside the hooks dir" contract), so
201
+ // this correctly finds nothing self-containing there and resolvePkgRoot()
202
+ // falls through to the cache — which IS authoritative for that channel, since
203
+ // our own init/upgrade own writing it. And even if SOME unrelated ancestor
204
+ // happens to carry its own versioned package.json (a plain "usable" root),
205
+ // candidateContainsRunningModule rejects it: that ancestor's own hooks/
206
+ // subdirectory (if it even has one) is not this file.
207
+ //
208
+ // Bounded walk: up to 6 candidate ancestors above hooks/'s own parent (the
209
+ // ordinary root sits at the very first one; a few extra levels tolerate an
210
+ // unusual nesting depth). Never throws, fails open to null on any error.
211
+ function selfLocationPkgRoot() {
212
+ let hooksDir, ownRealPath;
213
+ try {
214
+ hooksDir = dirname(fileURLToPath(import.meta.url));
215
+ ownRealPath = realpathSync(join(hooksDir, 'hypo-shared.mjs'));
216
+ } catch {
217
+ return null; // can't even resolve our own path — nothing to self-contain against
218
+ }
219
+ try {
220
+ let dir = dirname(hooksDir); // first candidate: the ordinary root, one level above hooks/
221
+ for (let i = 0; i < 6; i++) {
222
+ if (isUsablePkgRootLocal(dir) && candidateContainsRunningModule(dir, ownRealPath)) {
223
+ return dir;
224
+ }
225
+ const parent = dirname(dir);
226
+ if (parent === dir) break; // reached filesystem root
227
+ dir = parent;
228
+ }
229
+ return null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ // Resolution order: self-location wins whenever it resolves — it is a direct,
236
+ // self-containment-verified fact about the code currently running, not an
237
+ // 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.
240
+ function resolvePkgRoot() {
241
+ const self = selfLocationPkgRoot();
242
+ if (self) return self;
243
+ const cached = readCachedPkgRoot();
244
+ return isUsablePkgRootLocal(cached) ? cached : null;
245
+ }
104
246
  export const PKG_ROOT = resolvePkgRoot();
105
247
 
248
+ // realpath a path for comparison, falling back to the raw value on any error
249
+ // (missing/unreadable/etc — the comparison then just degrades to a literal
250
+ // string compare, same as before this existed). Never throws.
251
+ function canonicalPath(p) {
252
+ if (typeof p !== 'string' || !p) return p;
253
+ try {
254
+ return realpathSync(p);
255
+ } catch {
256
+ return p;
257
+ }
258
+ }
259
+
260
+ // Tri-state comparison between the cache and the code's own resolved
261
+ // location, for the SURFACING decision only (resolvePkgRoot() above already
262
+ // self-corrects PKG_ROOT in memory regardless of this). Three outcomes:
263
+ // 'match' — cache and self-location agree (or both resolve to nothing
264
+ // comparable) → any earlier drift mark should be CLEARED.
265
+ // 'drift' — self-location resolves and DISAGREES with the cache → notify.
266
+ // 'unknown' — self-location could not be resolved at all → leave any
267
+ // existing mark untouched. This is the PERMANENT steady state
268
+ // for the npm/manual channel (no package.json ships next to the
269
+ // standalone-copied hooks), not a rare hiccup — collapsing it
270
+ // into 'match' would let that channel silently clear a mark it
271
+ // never had grounds to judge, and collapsing it into 'drift'
272
+ // would falsely warn a channel with nothing to compare against.
273
+ //
274
+ // The equality check canonicalizes BOTH sides first: the cache may record a
275
+ // symlink alias of the very same physical root selfLocationPkgRoot() just
276
+ // walked to (a symlinked plugin cache dir, or the same /var vs /private/var
277
+ // split candidateContainsRunningModule already has to handle) — a literal
278
+ // string compare would misreport that as drift.
279
+ //
280
+ // Never throws (every helper it calls already fails open).
281
+ export function pkgRootDriftStatus() {
282
+ const self = selfLocationPkgRoot();
283
+ if (!self) return { status: 'unknown' };
284
+ const cached = readCachedPkgRoot();
285
+ if (canonicalPath(self) === canonicalPath(cached)) return { status: 'match' };
286
+ return { status: 'drift', cached, self };
287
+ }
288
+
106
289
  // Optional H2 allowlist for hot.md validation.
107
290
  // Set HYPO_ALLOWED_HOT_H2=comma,separated,headings to enable.
108
291
  const _allowedH2Env = process.env.HYPO_ALLOWED_HOT_H2;
@@ -330,18 +513,30 @@ export function hasAnyTodayLogEntry(hypoDir) {
330
513
  }
331
514
 
332
515
  /**
333
- * Date strings that count as "today" for freshness checks. Both the local and
334
- * UTC dates are accepted: Claude writes file dates in the user's local zone,
335
- * while hypo-hot-rebuild stamps root hot.md with the UTC date. Accepting both
336
- * removes the ~timezone-offset window where a correctly closed session would
337
- * otherwise false-block.
516
+ * Local and UTC calendar-day strings for a given instant. Both matter because
517
+ * some writers stamp a date in the user's local zone (Claude writing file
518
+ * content) while others stamp UTC (hypo-hot-rebuild, the session-closed
519
+ * marker's `closed_at`) — comparing only one representation opens a
520
+ * ~timezone-offset window where a same-moment date-comparison reads as two
521
+ * different days. Shared by `freshDates()` (today) and any caller comparing
522
+ * against an arbitrary past timestamp (e.g. doctor correlating a marker's
523
+ * `closed_at` against an artifact's local-dated heading).
524
+ * @param {Date} [date]
525
+ * @returns {string[]} 1-2 ISO dates (YYYY-MM-DD), local first.
526
+ */
527
+ export function localAndUtcDates(date = new Date()) {
528
+ const local = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
529
+ const utc = date.toISOString().slice(0, 10);
530
+ return local === utc ? [local] : [local, utc];
531
+ }
532
+
533
+ /**
534
+ * Date strings that count as "today" for freshness checks. See
535
+ * {@link localAndUtcDates} for why both representations are accepted.
338
536
  * @returns {string[]} 1-2 ISO dates (YYYY-MM-DD), most-relevant first.
339
537
  */
340
538
  export function freshDates() {
341
- const d = new Date();
342
- const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
343
- const utc = d.toISOString().slice(0, 10);
344
- return local === utc ? [local] : [local, utc];
539
+ return localAndUtcDates(new Date());
345
540
  }
346
541
 
347
542
  // Parse a single frontmatter scalar (mirrors hypo-session-start.mjs /
@@ -418,7 +613,11 @@ export function pageUsageGuardCachePath(sessionId, hypoDir) {
418
613
  return join(tmpdir(), `hypo-pageusage-guard-${safe}-${h.toString(36)}.json`);
419
614
  }
420
615
 
421
- export function pageUsageLoggingAllowed(hypoDir, sessionId) {
616
+ // `probeFn` is test-only: tests inject a fake to control the "did git answer"
617
+ // outcome deterministically instead of racing a real subprocess under shard
618
+ // load. Production callers never pass it, so they always get `runGitCheckIgnore`
619
+ // below, byte-for-byte the same spawnSync call this file always made.
620
+ export function pageUsageLoggingAllowed(hypoDir, sessionId, probeFn) {
422
621
  // The load-bearing commit gate is .hypoignore: it is what hypo-auto-stage and
423
622
  // commitWikiChanges actually filter on. Re-check it FRESH on every call (it is
424
623
  // cheap, no subprocess) so that if coverage is removed mid-session the guard
@@ -435,38 +634,96 @@ export function pageUsageLoggingAllowed(hypoDir, sessionId) {
435
634
  }
436
635
  if (!hypoIgnored) return false;
437
636
 
438
- return gitIgnoresPageUsageCached(hypoDir, sessionId);
637
+ return gitIgnoresPageUsageCached(hypoDir, sessionId, probeFn);
439
638
  }
440
639
 
441
640
  // git check-ignore is the belt signal (defends a manual `git add`); it spawns a
442
641
  // subprocess, so cache its result per session. The verdict cached here is only
443
642
  // the git signal, never the composite allow decision, so the fresh .hypoignore
444
643
  // re-check above always still runs.
445
- function gitIgnoresPageUsageCached(hypoDir, sessionId) {
644
+ // How long an unanswered probe suppresses the next one. Long enough that a git
645
+ // that is genuinely wedged costs one 10s wait per half-minute instead of one per
646
+ // prompt, short enough that a blip clears on its own well inside a session.
647
+ const PROBE_BACKOFF_MS = 30000;
648
+
649
+ // The real probe: check-ignore answers with an exit code, 0 = ignored, 1 = not
650
+ // ignored. Split out to a named function so `gitIgnoresPageUsageCached` can take
651
+ // a substitute in tests without touching what production actually spawns.
652
+ function runGitCheckIgnore(hypoDir) {
653
+ return spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', '--', PAGE_USAGE_REL], {
654
+ timeout: 10000,
655
+ });
656
+ }
657
+
658
+ function gitIgnoresPageUsageCached(hypoDir, sessionId, probeFn = runGitCheckIgnore) {
659
+ // Without a session id every caller lands on the same `default` cache file, so
660
+ // one session's verdict would answer for the next one — and the file outlives
661
+ // both, sitting in tmpdir with nothing to expire it. A verdict that cannot be
662
+ // scoped to a session is not cached at all: re-probe every time instead of
663
+ // serving a stale `true` after .gitignore coverage has been removed.
664
+ const scoped = Boolean(sessionId);
446
665
  const cachePath = pageUsageGuardCachePath(sessionId, hypoDir);
447
- try {
448
- if (existsSync(cachePath)) {
449
- const cached = JSON.parse(readFileSync(cachePath, 'utf-8'));
450
- if (typeof cached.gitIgnored === 'boolean') return cached.gitIgnored;
666
+ if (scoped) {
667
+ try {
668
+ if (existsSync(cachePath)) {
669
+ const cached = JSON.parse(readFileSync(cachePath, 'utf-8'));
670
+ if (typeof cached.gitIgnored === 'boolean') return cached.gitIgnored;
671
+ // A recorded outage still stands until it expires. This is what keeps a
672
+ // wedged git off the UserPromptSubmit path: hypo-lookup calls this before
673
+ // it prints, so a 10s wait here is 10s of dead prompt, every prompt.
674
+ if (typeof cached.unavailableUntil === 'number' && Date.now() < cached.unavailableUntil) {
675
+ return false;
676
+ }
677
+ }
678
+ } catch {
679
+ // corrupt cache → recompute below
451
680
  }
452
- } catch {
453
- // corrupt cache → recompute below
454
681
  }
455
682
 
456
- let gitIgnored = false;
683
+ // check-ignore answers with an exit code: 0 = ignored, 1 = not ignored. Every
684
+ // other outcome means the probe never got to answer — 128 for a fatal git
685
+ // error (hypoDir not a repo yet), or a null status when the timeout below
686
+ // fired or the spawn itself failed, which is what a machine under heavy
687
+ // process load produces. Treating those as a plain `false` is the bug this guards
688
+ // against: it is indistinguishable from git actually saying "not ignored",
689
+ // and the verdict then gets cached, so one blip keeps logging disabled for
690
+ // the rest of the session even after the condition clears.
691
+ // The bound is here to survive a git that never returns (an index.lock held by
692
+ // a dead process, a corrupt repo), not to race a busy machine. It used to be
693
+ // 2s, which a loaded box clears by a hair: instrumenting a one-process-per-suite
694
+ // run produced eight ETIMEDOUT probes, every one of them landing between 2011ms
695
+ // and 2254ms. check-ignore on a real vault costs single-digit milliseconds, so
696
+ // the old bound was three orders of magnitude tighter than the work and was
697
+ // measuring scheduler latency instead of git. 10s still catches a true hang.
698
+ let probe = null;
457
699
  try {
458
- gitIgnored =
459
- spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', '--', PAGE_USAGE_REL], {
460
- timeout: 2000,
461
- }).status === 0;
700
+ probe = probeFn(hypoDir);
462
701
  } catch {
463
- gitIgnored = false;
702
+ probe = null;
464
703
  }
465
704
 
466
- try {
467
- writeFileSync(cachePath, JSON.stringify({ gitIgnored }));
468
- } catch {
469
- // cache write failure is non-fatal; the git probe just reruns next prompt
705
+ // Inconclusive → fail closed for this call. The verdict is never cached as an
706
+ // answer; what gets recorded is that the probe is down, and only until the
707
+ // backoff expires. So a scheduler blip self-heals within the session, while a
708
+ // wedged git is waited on once per backoff window rather than once per prompt.
709
+ if (!probe || (probe.status !== 0 && probe.status !== 1)) {
710
+ if (scoped) {
711
+ try {
712
+ writeFileSync(cachePath, JSON.stringify({ unavailableUntil: Date.now() + PROBE_BACKOFF_MS }));
713
+ } catch {
714
+ // non-fatal; the probe just runs again next prompt
715
+ }
716
+ }
717
+ return false;
718
+ }
719
+
720
+ const gitIgnored = probe.status === 0;
721
+ if (scoped) {
722
+ try {
723
+ writeFileSync(cachePath, JSON.stringify({ gitIgnored }));
724
+ } catch {
725
+ // cache write failure is non-fatal; the git probe just reruns next prompt
726
+ }
470
727
  }
471
728
  return gitIgnored;
472
729
  }
@@ -525,7 +782,12 @@ function _lastSeg(p) {
525
782
  // declines (null) so the caller falls back to recency. `projects` is the whole
526
783
  // universe (for the uniqueness gate); `eligible` restricts the answer.
527
784
  export function pickProjectByCwd(projects, cwd, opts = {}) {
528
- const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
785
+ const {
786
+ eligible = null,
787
+ realpathCwd = null,
788
+ caseInsensitive = isCaseInsensitiveFs(),
789
+ rejectAmbiguous = false,
790
+ } = opts;
529
791
  if (!cwd && !realpathCwd) return null;
530
792
  const eligibleSet = eligible ? new Set(eligible) : null;
531
793
  const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
@@ -545,20 +807,35 @@ export function pickProjectByCwd(projects, cwd, opts = {}) {
545
807
  if (n && !cwds.includes(n)) cwds.push(n);
546
808
  }
547
809
 
548
- // Tier 1: first cwd variant with any longest-prefix match wins.
810
+ // Tier 1: first cwd variant with any longest-prefix match wins. With
811
+ // rejectAmbiguous (session-cwd close check), two DISTINCT projects sharing the same
812
+ // longest matching working_dir (a monorepo config with no uniqueness invariant)
813
+ // is a genuine tie we must NOT break arbitrarily — silently picking the first
814
+ // would attribute a close to the wrong project and either mask a real failure
815
+ // (false-green) or block the wrong one (false-block). Decline the tie → null,
816
+ // so the caller degrades to the unresolved-cwd path instead of guessing.
549
817
  for (const c of cwds) {
550
818
  const cf = _fold(c, caseInsensitive);
551
819
  let bestSlug = null;
552
820
  let bestLen = -1;
821
+ let bestTied = false;
553
822
  for (const e of entries) {
554
823
  if (!isEligible(e.slug)) continue;
555
824
  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;
825
+ if (cf === pf || cf.startsWith(`${pf}/`)) {
826
+ if (e.path.length > bestLen) {
827
+ bestLen = e.path.length;
828
+ bestSlug = e.slug;
829
+ bestTied = false;
830
+ } else if (e.path.length === bestLen && e.slug !== bestSlug) {
831
+ bestTied = true;
832
+ }
559
833
  }
560
834
  }
561
- if (bestSlug) return bestSlug;
835
+ if (bestSlug) {
836
+ if (bestTied && rejectAmbiguous) return null;
837
+ return bestSlug;
838
+ }
562
839
  }
563
840
 
564
841
  // Tier 2: unique-basename ancestor, but only when the chain points at exactly
@@ -1130,7 +1407,10 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
1130
1407
 
1131
1408
  // primary = the recency project when it is itself today-active, else the first
1132
1409
  // 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.
1410
+ // single-slug alias for the message header and the flat `close.project` field.
1411
+ // It is NEVER an attribution source: the marker is stamped from close evidence
1412
+ // (explicit --project, transcript close-files, apply's payload.project), so this
1413
+ // recency-derived value cannot leak into a marker and back into the next gate.
1134
1414
  const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
1135
1415
  const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
1136
1416
 
@@ -1203,6 +1483,267 @@ export function rootLogEntry(slug, date, headingTail) {
1203
1483
  * @param {string} hypoDir
1204
1484
  * @returns {number} count of entries appended to log.md
1205
1485
  */
1486
+ // ── append-only file lock ───────────────────────────────────────────────────
1487
+ // Serializes the read → dedup → rebuild → temp+rename sequence on append-only
1488
+ // history files (session-log shards, log.md) so two concurrent session closes
1489
+ // never lose an entry. The lock does NOT replace the existing write-isolation:
1490
+ // each writer still rebuilds the full content and commits via atomicWrite
1491
+ // (temp write + rename), so a partial write lands on a throwaway temp and the
1492
+ // target is never torn. The lock only makes the read-modify-write exclusive, so
1493
+ // the second closer re-reads the first's committed bytes and appends onto them
1494
+ // (last-writer-wins can no longer drop the earlier entry), and exact-entry dedup
1495
+ // becomes precise rather than best-effort.
1496
+ //
1497
+ // Why not O_APPEND: an in-place append that short-writes (ENOSPC / EDQUOT /
1498
+ // RLIMIT_FSIZE / a split write() killed mid-loop) leaves a torn dated heading on
1499
+ // the real file that the freshness gate mis-reads as valid close evidence, and
1500
+ // it cannot be rolled back once a concurrent appender has written past it. That
1501
+ // is a normal-operation regression temp+rename does not have (a failed temp
1502
+ // write never runs the rename, so the target stays untouched). Confirmed against
1503
+ // Node/libuv write-loop behavior and POSIX write(2) partial-write semantics.
1504
+ //
1505
+ // Local-FS only: `openSync(lock, 'wx')` is not atomic on NFS — the same caveat
1506
+ // the vault already carries. Power-loss durability is unchanged from today
1507
+ // (atomicWrite never fsync'd), so it is out of scope here.
1508
+ function sleepSync(ms) {
1509
+ // Synchronous sleep with no busy-spin: block this thread on an Atomics.wait
1510
+ // against a private SharedArrayBuffer that is never signaled, so it always
1511
+ // times out after `ms`. Hooks run in a short-lived sync context.
1512
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms | 0));
1513
+ }
1514
+
1515
+ // Commit `content` via temp write + rename so a partial/failed write lands on a
1516
+ // throwaway temp and the target is never torn (mirrors crystallize.mjs's
1517
+ // atomicWrite). Rename atomicity swaps the directory entry; it is NOT power-loss
1518
+ // durable (no fsync) — same as everything else in the vault.
1519
+ function atomicWriteShared(path, content) {
1520
+ mkdirSync(dirname(path), { recursive: true });
1521
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
1522
+ writeFileSync(tmp, content);
1523
+ renameSync(tmp, path);
1524
+ }
1525
+
1526
+ // Read the pid the current holder recorded in its lockfile (see withFileLock).
1527
+ // Returns null for anything we can't trust as a pid: empty/missing content (a
1528
+ // lock written before this pid-recording existed, or already gone), or content
1529
+ // that doesn't parse as a positive integer. null means "unknown holder" and the
1530
+ // caller falls back to the pre-liveness, mtime-only steal rule.
1531
+ function readLockHolderPid(lockPath) {
1532
+ let raw;
1533
+ try {
1534
+ raw = readFileSync(lockPath, 'utf-8').trim();
1535
+ } catch {
1536
+ return null; // vanished/unreadable mid-check — let the caller's own catch handle it
1537
+ }
1538
+ // Canonical decimal only. `Number()` would accept '1e3' and '0x10' as pids,
1539
+ // which contradicts what this function promises its callers: anything we
1540
+ // can't trust is null, not a number we guessed at.
1541
+ if (!/^[1-9][0-9]*$/.test(raw)) return null;
1542
+ const pid = Number(raw);
1543
+ return Number.isSafeInteger(pid) ? pid : null;
1544
+ }
1545
+
1546
+ // Is `pid` still running? EPERM means it exists but we can't signal it (e.g. a
1547
+ // different user) — treat that as alive too, since "can't prove it's dead" must
1548
+ // not be steal-eligible.
1549
+ function isPidAlive(pid) {
1550
+ try {
1551
+ process.kill(pid, 0);
1552
+ return true;
1553
+ } catch (err) {
1554
+ return err.code === 'EPERM';
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Run `fn` while holding an exclusive lock on `<targetPath>.lock`.
1560
+ *
1561
+ * Acquire is a spin on `linkSync(tmp, lock)`, where `tmp` already holds this
1562
+ * process's pid in full: EEXIST means another writer holds it, so poll until it
1563
+ * frees. Linking a complete file is what makes the lock's content trustworthy —
1564
+ * a lock is never observable in a half-written state, so "no pid in there" always
1565
+ * means a genuine pre-liveness lockfile and never a holder mid-acquire. The lock
1566
+ * file's content is therefore the holder's own pid,
1567
+ * so a lock whose mtime is older than `staleMs` is only stolen once we can also
1568
+ * confirm the recorded holder is no longer running (`process.kill(pid, 0)`). A
1569
+ * LIVE holder preempted past `staleMs` is therefore left alone — its lock is not
1570
+ * stolen, so the second writer instead polls through to `timeoutMs` and throws,
1571
+ * falling back to the write=proposal gate instead of racing the live holder's
1572
+ * critical section (this was case (1) of the old mtime-only rule, and it's what
1573
+ * closes it). A lock with no readable/parseable pid (written before this
1574
+ * recording existed) falls back to the old mtime-only rule so pre-existing
1575
+ * lockfiles from a live process on disk still get treated as steal-eligible once
1576
+ * stale — see `readLockHolderPid`. Every steal, and every steal we refuse because
1577
+ * the holder is alive, is logged to stderr so a preemption or an actual steal is
1578
+ * never silent. One edge remains, unrelated to holder liveness: between the
1579
+ * stale `statSync` and the `unlinkSync`, the holder can release and a fresh
1580
+ * holder grab the same path, whose lock we then remove — `staleMs` is set well
1581
+ * above a normal close (seconds) to make that extreme-low-probability, and it is
1582
+ * out of scope here. Release is symmetric: we only unlink the lock while it still
1583
+ * records OUR pid, so a writer that WAS stolen from never removes the lock of the
1584
+ * holder that replaced it. If the lock cannot be acquired within `timeoutMs`,
1585
+ * throw so the caller can fall back to the write=proposal gate — for an append
1586
+ * that means blocking the close (proposal-pending) with no artifact; the next
1587
+ * close re-appends (architecturally consistent with the existing fail-safe).
1588
+ *
1589
+ * Note what refusing to steal from a live holder trades away: a holder that is
1590
+ * alive but permanently hung is now never stolen from, so every later close
1591
+ * times out too. That is availability loss, not a deadlock (`timeoutMs` still
1592
+ * bounds each attempt), but unlike an ordinary lock timeout it does NOT self-heal
1593
+ * on the next close — it needs the hung process to go away. Losing an append is
1594
+ * silent; blocking one is visible, so this is the direction to fail in.
1595
+ *
1596
+ * @param {string} targetPath file being guarded (lock is a sibling `.lock`)
1597
+ * @param {() => T} fn critical section
1598
+ * @param {{timeoutMs?: number, staleMs?: number, pollMs?: number}} [opts]
1599
+ * @returns {T} whatever `fn` returns
1600
+ * @template T
1601
+ */
1602
+ export function withFileLock(targetPath, fn, opts = {}) {
1603
+ const { timeoutMs = 5000, staleMs = 30000, pollMs = 50 } = opts;
1604
+ const lockPath = `${targetPath}.lock`;
1605
+ mkdirSync(dirname(lockPath), { recursive: true });
1606
+ const start = Date.now();
1607
+ // Publish the lock ATOMICALLY: write the whole pid into a private sibling
1608
+ // first, then `linkSync` it into place. `openSync(lockPath,'wx')` followed by
1609
+ // a write cannot do this — it publishes an EMPTY lock and fills it in after,
1610
+ // and a holder preempted inside that window looks exactly like a pid-less
1611
+ // legacy lock, so a second writer steals it and both run the critical section.
1612
+ // That is the very bug the liveness check exists to close, so the acquire has
1613
+ // to be atomic for the check to mean anything. `link` also gives us the same
1614
+ // EEXIST-if-taken primitive `wx` did, and it leaves nothing behind when the
1615
+ // write fails: no link, no lock.
1616
+ // The staging name is per-call random, and the staging write is exclusive.
1617
+ // A predictable `<lock>.<pid>.tmp` would be reachable again by a later run of
1618
+ // the same pid, and a crash between the link and the staging unlink leaves tmp
1619
+ // and lock as two names for ONE inode — writing the staging file would then
1620
+ // rewrite the live lock's bytes and mtime, resurrecting a dead lock under a
1621
+ // live pid that the liveness check would then protect. Random + `wx` removes
1622
+ // both the collision and the symlink it could otherwise be pointed through.
1623
+ const tmpPath = `${lockPath}.${randomBytes(8).toString('hex')}.tmp`;
1624
+ let staged = false;
1625
+ // The live-holder refusal is re-evaluated on every poll, but it is one event,
1626
+ // not `timeoutMs / pollMs` of them. Log each once per acquire or a single
1627
+ // preemption buries the hook's stderr under ~100 identical lines.
1628
+ let loggedLiveHolder = false;
1629
+ let loggedSteal = false;
1630
+ try {
1631
+ for (;;) {
1632
+ try {
1633
+ if (!staged) {
1634
+ 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' });
1645
+ }
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) {
1675
+ console.error(
1676
+ `[hypomnema] withFileLock: NOT stealing ${lockPath} — holder pid ${holderPid} is still alive past staleMs=${staleMs}`,
1677
+ );
1678
+ loggedLiveHolder = true;
1679
+ }
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
+ }
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.
1701
+ }
1702
+ }
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
+ }
1713
+ }
1714
+ } finally {
1715
+ // The sibling is only ever a staging file: once linked, the lock IS the
1716
+ // link, and on every failure path it must not survive as litter.
1717
+ try {
1718
+ unlinkSync(tmpPath);
1719
+ } catch {
1720
+ /* never created, or already gone */
1721
+ }
1722
+ }
1723
+ try {
1724
+ return fn();
1725
+ } finally {
1726
+ // Only remove the lock if it is still OURS. Recording the pid makes this
1727
+ // checkable: if we were stolen from (a legacy pid-less lock of ours, or the
1728
+ // stat/unlink window below), the path now holds a DIFFERENT holder's lock and
1729
+ // unlinking it unconditionally would hand a third writer the critical section
1730
+ // while that holder is still inside it. Leaving a foreign lock alone costs
1731
+ // nothing — its own holder releases it, or it goes stale.
1732
+ try {
1733
+ const stillOurs = readLockHolderPid(lockPath);
1734
+ if (stillOurs === process.pid) unlinkSync(lockPath);
1735
+ else if (existsSync(lockPath))
1736
+ console.error(
1737
+ `[hypomnema] withFileLock: not releasing ${lockPath} — it now holds ${
1738
+ stillOurs === null ? 'no readable pid' : `pid ${stillOurs}`
1739
+ }, so ours was stolen`,
1740
+ );
1741
+ } catch {
1742
+ /* lock already stolen/removed */
1743
+ }
1744
+ }
1745
+ }
1746
+
1206
1747
  export function deriveRootLogEntries(hypoDir) {
1207
1748
  const logPath = join(hypoDir, 'log.md');
1208
1749
  if (!existsSync(logPath)) return 0;
@@ -1259,19 +1800,44 @@ export function deriveRootLogEntries(hypoDir) {
1259
1800
  const { heading, block } = rootLogEntry(slug, date, m[1]);
1260
1801
  if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
1261
1802
  seenHeadings.add(heading);
1262
- additions.push(block);
1803
+ additions.push({ heading, block });
1263
1804
  }
1264
1805
  }
1265
1806
  }
1266
1807
 
1267
1808
  if (additions.length === 0) return 0;
1268
- const sep = logContent.endsWith('\n') ? '\n' : '\n\n';
1809
+
1810
+ // Serialize the read-modify-write on log.md: a concurrent session close (its
1811
+ // own crystallize apply, or another project's derive) may commit between the
1812
+ // read above and the write below. Under the lock we RE-READ the latest
1813
+ // committed log.md and re-run exact-heading dedup, so this derive appends onto
1814
+ // the other writer's entry instead of a full-file overwrite dropping it. The
1815
+ // same lock guards crystallize.mjs's per-close log.md append, so the two
1816
+ // paths never race. On lock-timeout, skip (best-effort backfill; the next
1817
+ // close re-derives) rather than risk a lost update.
1269
1818
  try {
1270
- writeFileSync(logPath, logContent + sep + additions.join('\n\n') + '\n');
1819
+ return withFileLock(logPath, () => {
1820
+ let current;
1821
+ try {
1822
+ current = readFileSync(logPath, 'utf-8');
1823
+ } catch {
1824
+ return 0;
1825
+ }
1826
+ const seen = new Set((current || '').split(/\r?\n/));
1827
+ const fresh = additions.filter(({ heading }) => {
1828
+ if (seen.has(heading)) return false;
1829
+ seen.add(heading);
1830
+ return true;
1831
+ });
1832
+ if (fresh.length === 0) return 0;
1833
+ const sep = current.endsWith('\n') ? '\n' : '\n\n';
1834
+ atomicWriteShared(logPath, current + sep + fresh.map((a) => a.block).join('\n\n') + '\n');
1835
+ return fresh.length;
1836
+ });
1271
1837
  } catch {
1838
+ // lock-timeout or unexpected lock error: skip this backfill pass.
1272
1839
  return 0;
1273
1840
  }
1274
- return additions.length;
1275
1841
  }
1276
1842
 
1277
1843
  // ── sync-state ────────────────────────────────────────────
@@ -1285,6 +1851,39 @@ function syncStatePath(hypoDir) {
1285
1851
  return join(hypoDir, '.cache', 'sync-state.json');
1286
1852
  }
1287
1853
 
1854
+ /**
1855
+ * Classify a sync-state `op` into the guidance branch it needs. Centralized
1856
+ * here — rather than each surface repeating its own string check — because
1857
+ * hypo-session-start.mjs's syncStateNotice and doctor.mjs's checkSyncState
1858
+ * used to each carry their own comparison, and drifted: session-start's
1859
+ * exact `=== 'conflict'` check silently missed 'conflict-unresolved' (the
1860
+ * MORE dangerous op, since the abort itself failed and the tree may still be
1861
+ * half-merged) and fell through to the generic "last sync failed" line,
1862
+ * while doctor's `startsWith('conflict')` already caught it. Both
1863
+ * callers now branch on this single function's return value, so they cannot
1864
+ * silently diverge on WHICH op gets which treatment again — only on the
1865
+ * prose each renders for a given branch.
1866
+ *
1867
+ * Both known ops are matched by EXACT string, not startsWith: a future
1868
+ * `conflict-*` value this function has never seen (e.g. a new syncRemote
1869
+ * failure mode added later) must not silently fall into either known
1870
+ * bucket. 'conflict' asserts the abort succeeded and local work is safely
1871
+ * committed; 'conflict-unresolved' asserts the abort itself failed. Neither
1872
+ * claim is known to hold for an op nobody has written a branch for yet, so
1873
+ * it gets its own conservative 'unknown-conflict' bucket instead of
1874
+ * inheriting either surface's reassurance by accident.
1875
+ *
1876
+ * @param {string} op
1877
+ * @returns {'conflict-unresolved'|'conflict'|'unknown-conflict'|'other'}
1878
+ */
1879
+ export function classifySyncOp(op) {
1880
+ const s = String(op || '');
1881
+ if (s === 'conflict-unresolved') return 'conflict-unresolved';
1882
+ if (s === 'conflict') return 'conflict';
1883
+ if (s.startsWith('conflict')) return 'unknown-conflict';
1884
+ return 'other';
1885
+ }
1886
+
1288
1887
  /**
1289
1888
  * Append a sync failure entry. Best-effort — never throws, since a failed
1290
1889
  * failure-log must not break the Stop hook that calls it.
@@ -1349,6 +1948,7 @@ export function syncRemote(hypoDir) {
1349
1948
  const pull = git('pull', '--no-rebase', '-q');
1350
1949
  if (pull.status === 0) {
1351
1950
  result.pulled = true;
1951
+ recordSyncSuccess(hypoDir, 'pull');
1352
1952
  } else {
1353
1953
  // A merge conflict leaves unmerged index entries; a network/auth failure
1354
1954
  // leaves none. Only the former must be aborted to keep the tree clean.
@@ -1373,67 +1973,480 @@ export function syncRemote(hypoDir) {
1373
1973
  appendSyncFailure(hypoDir, 'pull', pull.stderr || pull.stdout);
1374
1974
  }
1375
1975
  const push = git('push');
1376
- if (push.status === 0) result.pushed = true;
1377
- else appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
1976
+ if (push.status === 0) {
1977
+ result.pushed = true;
1978
+ recordSyncSuccess(hypoDir, 'push');
1979
+ } else appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
1378
1980
  } catch {
1379
1981
  // best-effort — never break the Stop hook
1380
1982
  }
1381
1983
  return result;
1382
1984
  }
1383
1985
 
1986
+ // ── touched-paths (scope the auto-commit to session-touched paths) ───────────
1987
+ //
1988
+ // The old commitWikiChanges swept the ENTIRE working tree: in a shared
1989
+ // multi-project vault with concurrent Claude Code sessions, another session's
1990
+ // staged/dirty files got committed and pushed by THIS session's Stop hook, and
1991
+ // the human-authored commit message was clobbered. The fix is to accumulate,
1992
+ // per session_id, the vault-relative paths this session actually touched, and
1993
+ // have commitWikiChanges commit only that scope.
1994
+ //
1995
+ // Sources that feed the accumulator:
1996
+ // - hypo-auto-stage.mjs (PostToolUse): every Write/Edit/MultiEdit to a file
1997
+ // under the vault.
1998
+ // - hypo-hot-rebuild.mjs (Stop, runs BEFORE auto-commit): hot.md and log.md,
1999
+ // which are hook-generated, not user Write/Edit — without this a scope
2000
+ // built from Write/Edit alone would drop them from the scoped commit.
2001
+ //
2002
+ // No session_id → never accumulate, and never fall back to a shared "default"
2003
+ // bucket: a path recorded under the wrong key could leak between sessions.
2004
+
2005
+ /** Directory holding one session's cache artifacts, incl. touched-paths.json. */
2006
+ function sessionCacheDir(hypoDir, sessionId) {
2007
+ return join(hypoDir, '.cache', 'sessions', sanitizeSessionId(sessionId));
2008
+ }
2009
+
2010
+ /** @returns {string} path to a session's accumulated touched-paths JSON array. */
2011
+ export function touchedPathsPath(hypoDir, sessionId) {
2012
+ return join(sessionCacheDir(hypoDir, sessionId), 'touched-paths.json');
2013
+ }
2014
+
2015
+ /**
2016
+ * Read the raw touched-paths JSON array off disk. Caller's responsibility to
2017
+ * hold the per-session lock first — this has no locking of its own.
2018
+ *
2019
+ * Absent and unreadable are different answers, and callers MUST branch on
2020
+ * the difference: a genuinely absent (or genuinely empty) file returns `[]`
2021
+ * — safe to treat as "nothing accumulated". A file that exists but fails to
2022
+ * read or parse returns `null` — NOT the same as empty. A caller that
2023
+ * conflates the two (codex FIX 1) and then does a set-difference clear on
2024
+ * `null`-as-`[]` will compute "nothing left" and delete the file outright,
2025
+ * silently losing every pending path to a transient I/O or parse error.
2026
+ *
2027
+ * @returns {string[]|null} the array, or `null` on a read/parse failure
2028
+ */
2029
+ function readTouchedPathsFile(path) {
2030
+ if (!existsSync(path)) return [];
2031
+ try {
2032
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
2033
+ return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string' && p) : null;
2034
+ } catch {
2035
+ return null; // corrupt/unreadable — NOT the same as empty; see above
2036
+ }
2037
+ }
2038
+
2039
+ /**
2040
+ * Accumulate vault-relative touched paths for `sessionId`. Best-effort,
2041
+ * dedup-on-insert, JSON array (not delimiter-joined — survives non-ASCII and
2042
+ * any byte a filename can legally hold). No-op without a session_id: never
2043
+ * accumulate into a shared bucket.
2044
+ *
2045
+ * Read-merge-write is guarded by the per-session file lock (the SAME lock
2046
+ * `drainTouchedPaths` takes), so a PostToolUse hook accumulating concurrently
2047
+ * with the Stop-chain drain can never lose a path to either a lost update
2048
+ * (two writers merging from the same stale read) or a drain racing between
2049
+ * this function's read and its write.
2050
+ *
2051
+ * @param {string} hypoDir
2052
+ * @param {string|null|undefined} sessionId
2053
+ * @param {string|string[]} relPaths one or more vault-relative paths
2054
+ */
2055
+ export function recordTouchedPaths(hypoDir, sessionId, relPaths) {
2056
+ if (!sessionId) return;
2057
+ const incoming = (Array.isArray(relPaths) ? relPaths : [relPaths]).filter(
2058
+ (p) => typeof p === 'string' && p.length > 0,
2059
+ );
2060
+ if (incoming.length === 0) return;
2061
+ const path = touchedPathsPath(hypoDir, sessionId);
2062
+ try {
2063
+ withFileLock(path, () => {
2064
+ const current = readTouchedPathsFile(path);
2065
+ // A corrupt/unreadable file (current === null) is recovered from here,
2066
+ // not preserved: an accumulate is additive by nature (there is nothing
2067
+ // salvageable to merge with), so starting fresh from `incoming` is the
2068
+ // correct best-effort behavior — unlike clearTouchedPaths, where the
2069
+ // same `null` MUST NOT be treated as empty (see readTouchedPathsFile).
2070
+ const merged = new Set(current === null ? [] : current);
2071
+ for (const p of incoming) merged.add(p);
2072
+ atomicWriteShared(path, JSON.stringify([...merged]));
2073
+ });
2074
+ } catch {
2075
+ // best-effort: a hook must never fail a tool call over a cache write
2076
+ // (includes a lock-timeout — a dropped accumulation here is the same
2077
+ // fail-safe shape as every other best-effort cache write in this file).
2078
+ }
2079
+ }
2080
+
2081
+ /**
2082
+ * Read and clear a session's accumulated touched paths in one step — "drain",
2083
+ * not "read", because the caller is expected to consume the WHOLE set
2084
+ * unconditionally. Kept for callers that genuinely want that (e.g. a
2085
+ * probe/inspection path); the Stop-chain auto-commit does NOT use this —
2086
+ * see commitTouchedPaths below, which holds ONE lock across a peek, the
2087
+ * commit itself, and a clear scoped to exactly what committed, so neither a
2088
+ * commit failure nor a same-path race loses anything.
2089
+ *
2090
+ * Guarded by the SAME per-session file lock every touched-paths mutation
2091
+ * takes: the read-then-remove here is mutually exclusive with a concurrent
2092
+ * accumulate or commitTouchedPaths call.
2093
+ *
2094
+ * A read/parse failure is NOT treated as an empty set to be cleared: like
2095
+ * clearTouchedPaths and commitTouchedPaths, a corrupt/unreadable file
2096
+ * (readTouchedPathsFile returns `null`) is left on disk untouched — never
2097
+ * deleted — so a transient I/O or parse error can't erase a set that a human
2098
+ * or a later pass could still recover. Drain returns `[]` in that case
2099
+ * (nothing safely consumable), but does not remove the file.
2100
+ *
2101
+ * @param {string} hypoDir
2102
+ * @param {string|null|undefined} sessionId
2103
+ * @returns {string[]} vault-relative paths, deduped; [] when absent, corrupt, or no session_id
2104
+ */
2105
+ export function drainTouchedPaths(hypoDir, sessionId) {
2106
+ if (!sessionId) return [];
2107
+ const path = touchedPathsPath(hypoDir, sessionId);
2108
+ try {
2109
+ return withFileLock(path, () => {
2110
+ const result = readTouchedPathsFile(path);
2111
+ if (result === null) return []; // corrupt/unreadable: leave the file for inspection, never delete
2112
+ try {
2113
+ rmSync(path, { force: true });
2114
+ } catch {
2115
+ // best-effort
2116
+ }
2117
+ return result;
2118
+ });
2119
+ } catch {
2120
+ // lock-timeout (or an unexpected lock error): fail closed to "nothing
2121
+ // drained" rather than risk reading concurrently with a writer. The set
2122
+ // stays on disk untouched, so it is still there for the next drain.
2123
+ return [];
2124
+ }
2125
+ }
2126
+
2127
+ /**
2128
+ * Read a session's accumulated touched paths WITHOUT clearing them. Kept as
2129
+ * a standalone probe for callers that just want to inspect the set; the
2130
+ * Stop-chain auto-commit does NOT call this on its own — see
2131
+ * commitTouchedPaths, which performs an equivalent internal read but holds
2132
+ * the lock through the commit and clear that follow it too.
2133
+ *
2134
+ * @param {string} hypoDir
2135
+ * @param {string|null|undefined} sessionId
2136
+ * @returns {string[]} vault-relative paths, deduped; [] when absent, corrupt,
2137
+ * no session_id, or a lock-timeout (fails closed to "nothing to commit" —
2138
+ * the set stays on disk, untouched, for the next Stop)
2139
+ */
2140
+ export function peekTouchedPaths(hypoDir, sessionId) {
2141
+ if (!sessionId) return [];
2142
+ const path = touchedPathsPath(hypoDir, sessionId);
2143
+ try {
2144
+ return withFileLock(path, () => {
2145
+ const result = readTouchedPathsFile(path);
2146
+ return result === null ? [] : result;
2147
+ });
2148
+ } catch {
2149
+ return [];
2150
+ }
2151
+ }
2152
+
2153
+ /**
2154
+ * Remove exactly `paths` from a session's touched-paths set — a set
2155
+ * difference, not a clear, and NOT a drain: any path a concurrent
2156
+ * PostToolUse (or Stop-chain generator) accumulated before this call is read
2157
+ * fresh under the SAME lock and therefore survives, because it was never in
2158
+ * `paths` to begin with.
2159
+ *
2160
+ * Called only after commitWikiChanges has ACTUALLY committed `paths` (or
2161
+ * confirmed there was nothing to commit) — never on a commit failure.
2162
+ *
2163
+ * Two failure modes this function must not turn into data loss (codex FIX 1
2164
+ * + FIX 2 review):
2165
+ *
2166
+ * - A read/parse failure on the touched-paths file (readTouchedPathsFile
2167
+ * returns `null`, NOT `[]`) must NOT be treated as "nothing left" — that
2168
+ * would make the set difference below compute an empty remainder and
2169
+ * DELETE the file outright on a transient I/O or parse error, losing
2170
+ * every pending path. On `null`, this function does nothing at all:
2171
+ * no write, no delete, the file is left exactly as it was.
2172
+ * - If this clear itself fails (lock-timeout, I/O) after a successful
2173
+ * read, the already-committed paths simply stay in the file: the next
2174
+ * Stop re-peeks them and re-runs commitWikiChanges, which is a clean
2175
+ * no-op (INTERSECT against a tree with no more changes at those paths
2176
+ * yields an empty scoped set). Over-retention is safe by construction —
2177
+ * it can never lose a path, only a commit failure (handled by leaving
2178
+ * the file alone entirely, see commitTouchedPaths) can.
2179
+ *
2180
+ * Standalone use of peekTouchedPaths + clearTouchedPaths as two SEPARATE
2181
+ * lock acquisitions still carries the same-path race codex FIX 2 describes
2182
+ * (a write between the two calls is indistinguishable from the one already
2183
+ * peeked, since the set only tracks path presence, not a generation/version
2184
+ * per path) — that is exactly why the Stop hook uses commitTouchedPaths
2185
+ * instead, which holds ONE lock across the whole peek→commit→clear window
2186
+ * so no write can land inside it at all.
2187
+ *
2188
+ * @param {string} hypoDir
2189
+ * @param {string|null|undefined} sessionId
2190
+ * @param {string[]} paths the exact paths that were just committed
2191
+ */
2192
+ export function clearTouchedPaths(hypoDir, sessionId, paths) {
2193
+ if (!sessionId) return;
2194
+ const toRemove = new Set((Array.isArray(paths) ? paths : []).filter(Boolean));
2195
+ if (toRemove.size === 0) return;
2196
+ const path = touchedPathsPath(hypoDir, sessionId);
2197
+ try {
2198
+ withFileLock(path, () => {
2199
+ const current = readTouchedPathsFile(path);
2200
+ if (current === null) return; // FIX 1: never delete/write on a read failure
2201
+ const remaining = current.filter((p) => !toRemove.has(p));
2202
+ if (remaining.length === 0) {
2203
+ try {
2204
+ rmSync(path, { force: true });
2205
+ } catch {
2206
+ // best-effort
2207
+ }
2208
+ } else {
2209
+ atomicWriteShared(path, JSON.stringify(remaining));
2210
+ }
2211
+ });
2212
+ } catch {
2213
+ // lock-timeout or unexpected error: leave the file as-is. Safe per the
2214
+ // over-retention argument above — the committed paths simply linger
2215
+ // until a later clear (or drain) removes them, at worst causing a
2216
+ // future no-op re-commit attempt, never a lost path.
2217
+ }
2218
+ }
2219
+
2220
+ /**
2221
+ * Peek a session's touched paths, run `commitFn(paths)` (expected to be
2222
+ * `commitWikiChanges` or an equivalent), and — only when it reports
2223
+ * `committed: true` — clear `paths` from the touched-paths set, ALL under
2224
+ * ONE hold of the per-session file lock (codex FIX 2). This is what the
2225
+ * Stop-chain auto-commit uses instead of calling peekTouchedPaths,
2226
+ * commitFn, and clearTouchedPaths as three separate steps.
2227
+ *
2228
+ * Holding one lock across peek → commit → clear (rather than acquiring and
2229
+ * releasing it three times, with the commit itself unlocked in between)
2230
+ * closes a same-path race: without it, a `recordTouchedPaths` call for a
2231
+ * path already in the just-peeked set could land in the window between the
2232
+ * commit and the clear. Since the touched-paths set only tracks path
2233
+ * PRESENCE (no per-path version/generation), that accumulate call is
2234
+ * indistinguishable from the one already peeked — the clear would remove it
2235
+ * anyway, silently orphaning a real edit that was never actually committed
2236
+ * (it landed on disk after `git commit` already ran, but the touched-paths
2237
+ * record of it just got wiped). With one continuous lock hold, that
2238
+ * accumulate call either fully precedes the peek (so it's included in THIS
2239
+ * commit, since commitWikiChanges reads live git status, not a cached
2240
+ * snapshot) or fully follows the clear (so it's untouched, waiting for the
2241
+ * next Stop) — there is no window where it can land in between.
2242
+ *
2243
+ * Lock order stays vault → per-session everywhere in this codebase (the
2244
+ * Stop hook takes the vault lock before calling this; accumulation only
2245
+ * ever takes the per-session lock, never the vault lock), so holding the
2246
+ * per-session lock for the whole commit here introduces no new ordering and
2247
+ * no deadlock risk.
2248
+ *
2249
+ * FIX 1 (read/parse failure) applies here too: a corrupt/unreadable
2250
+ * touched-paths file is never treated as empty. `commitFn` still runs (with
2251
+ * an empty scope — a safe no-op through commitWikiChanges), but nothing is
2252
+ * ever written to the corrupt file; it is left exactly as it was for a
2253
+ * human or a future recovery pass to look at.
2254
+ *
2255
+ * @param {string} hypoDir
2256
+ * @param {string|null|undefined} sessionId
2257
+ * @param {(paths: string[]) => {committed: boolean, [k: string]: unknown}} commitFn
2258
+ * @returns {{committed: boolean, [k: string]: unknown}} whatever `commitFn` returned,
2259
+ * or `{committed: false, reason: 'touched-paths-lock-timeout'}` if the lock
2260
+ * itself could not be acquired (commitFn never ran; the file is untouched)
2261
+ */
2262
+ export function commitTouchedPaths(hypoDir, sessionId, commitFn) {
2263
+ if (!sessionId) return commitFn([]);
2264
+ const path = touchedPathsPath(hypoDir, sessionId);
2265
+ try {
2266
+ return withFileLock(path, () => {
2267
+ const current = readTouchedPathsFile(path);
2268
+ if (current === null) {
2269
+ // FIX 1: a read/parse failure is not "empty" — commit nothing this
2270
+ // round (a safe no-op scope) and leave the corrupt file untouched,
2271
+ // rather than let a downstream clear compute "nothing left" and
2272
+ // delete it.
2273
+ return commitFn([]);
2274
+ }
2275
+ const result = commitFn(current);
2276
+ if (result && result.committed && current.length > 0) {
2277
+ // Still under the SAME lock acquired above: no recordTouchedPaths
2278
+ // call for this session can have landed since `current` was read
2279
+ // (it takes this exact lock too), so the set on disk right now is
2280
+ // EXACTLY `current` — clearing it needs no re-read or set
2281
+ // difference, just remove what we already know is the whole thing.
2282
+ try {
2283
+ rmSync(path, { force: true });
2284
+ } catch {
2285
+ // best-effort: the committed paths just linger on disk; the next
2286
+ // Stop re-peeks them and re-runs commitFn, a clean no-op.
2287
+ }
2288
+ }
2289
+ return result;
2290
+ });
2291
+ } catch {
2292
+ // Lock-timeout (or an unexpected lock error): never entered the
2293
+ // critical section, so commitFn never ran and nothing was peeked or
2294
+ // cleared. The touched-paths file is untouched on disk, and the next
2295
+ // Stop retries this session's commit from the same scope.
2296
+ return { committed: false, reason: 'touched-paths-lock-timeout' };
2297
+ }
2298
+ }
2299
+
2300
+ /**
2301
+ * The lock target both commit loci (hypo-auto-commit.mjs Stop hook,
2302
+ * crystallize.mjs --apply-session-close) hold while staging+committing (and,
2303
+ * for the Stop hook, syncing) the vault, so two concurrent sessions on a
2304
+ * shared vault never interleave `git add`/`git commit`/`git pull`/`git push`.
2305
+ * A stable, non-content path — withFileLock only ever touches its `.lock`
2306
+ * sibling, this file itself is never created.
2307
+ */
2308
+ export function vaultCommitLockTarget(hypoDir) {
2309
+ return join(hypoDir, '.cache', 'vault-commit');
2310
+ }
2311
+
2312
+ /** `projects/<slug>/...` → `<slug>`; anything else → its first path segment. */
2313
+ function projectOfPath(relPath) {
2314
+ const parts = relPath.split('/');
2315
+ if (parts[0] === 'projects' && parts.length > 1 && parts[1]) return parts[1];
2316
+ return parts[0] || relPath;
2317
+ }
2318
+
1384
2319
  /**
1385
- * Stage + commit every non-.hypoignore change in the wiki. Does NOT pull/push —
1386
- * remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
2320
+ * Stage + commit ONLY the caller-supplied `paths`, intersected with what is
2321
+ * actually changed on disk right now. Does NOT pull/push; remote
2322
+ * sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
1387
2323
  * network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
1388
2324
  * --apply-session-close path so the .hypoignore staging filter cannot diverge
1389
2325
  * between the two commit loci.
1390
2326
  *
1391
- * "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
1392
- * failure — the caller's tree is already in the committed state it wanted.
2327
+ * The caller supplies the scope; this function never re-derives it from the
2328
+ * whole tree. `paths` absent/empty is a clean no-op (`scoped: 0`), not an
2329
+ * error and not a whole-tree fallback — this is how a caller with nothing to
2330
+ * contribute (e.g. no session_id, so nothing was accumulated) skips cleanly.
2331
+ *
2332
+ * The authoritative scope is INTERSECT(paths, currently-changed): a stale
2333
+ * entry (already committed elsewhere, or never actually changed) is dropped
2334
+ * silently rather than erroring. `.hypoignore` filtering, the staged
2335
+ * re-derivation, the commit-message count, and the final commit all use this
2336
+ * SAME scoped set — no step here may widen back to whole-tree, or another
2337
+ * session's dirty/staged files could slip back in through any one of them.
2338
+ *
2339
+ * "Nothing to commit" (empty scope, or only .hypoignore'd changes) is
2340
+ * SUCCESS, not failure — the caller's tree is already in the state it wanted.
1393
2341
  *
1394
2342
  * @param {string} hypoDir
1395
- * @returns {{committed: boolean, reason?: string}} committed:true when a commit was
1396
- * created OR nothing needed committing; committed:false (with reason) on a real
1397
- * failure: not a git repo, or git status/add/commit erroring.
2343
+ * @param {string[]} [paths] vault-relative paths this caller wrote/owns this close
2344
+ * @returns {{committed: boolean, scoped?: number, reason?: string}} committed:true
2345
+ * when a commit was created OR nothing needed committing (scoped:0 in the
2346
+ * latter case); committed:false (with reason) on a real failure: not a git
2347
+ * repo, or git status/add/commit erroring.
1398
2348
  */
1399
- export function commitWikiChanges(hypoDir) {
2349
+ export function commitWikiChanges(hypoDir, paths) {
1400
2350
  const git = (...args) =>
1401
2351
  spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
1402
2352
  if (git('rev-parse', '--is-inside-work-tree').status !== 0)
1403
2353
  return { committed: false, reason: `not a git repository: ${hypoDir}` };
1404
- const porcelain = git('status', '--porcelain', '-uall');
2354
+
2355
+ const supplied = new Set(
2356
+ (Array.isArray(paths) ? paths : []).filter((p) => typeof p === 'string' && p.length > 0),
2357
+ );
2358
+ if (supplied.size === 0) return { committed: true, scoped: 0 };
2359
+
2360
+ // `-z`: NUL-separated records with verbatim paths — no surrounding quotes and
2361
+ // no octal escaping, so non-ASCII paths (Korean page names are normal input
2362
+ // here) survive intact. Without it, `core.quotepath=true` (the default) yields
2363
+ // `"pages/\355\225\234\352\270\200.md"` and the old quote-strip parser fed that
2364
+ // literal, non-existent path to `git add` — failing the whole commit.
2365
+ const porcelain = git('status', '--porcelain', '-uall', '-z');
1405
2366
  if (porcelain.status !== 0)
1406
2367
  return { committed: false, reason: `git status failed in ${hypoDir}` };
1407
2368
  // `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
1408
2369
  // enumerate changed paths, drop ignored ones, then stage explicitly.
1409
2370
  const ignorePatterns = loadHypoIgnore(hypoDir);
1410
- 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();
2371
+ const scoped = []; // pathspec for `git add -A` — worktree/index paths only
2372
+ const commitScope = []; // pathspec for the final diff/commit --only (superset)
2373
+ const records = (porcelain.stdout || '').split('\0');
2374
+ for (let i = 0; i < records.length; i++) {
2375
+ const rec = records[i];
2376
+ if (!rec) continue;
2377
+ const xy = rec.slice(0, 2);
2378
+ const file = rec.slice(3); // `XY <path>`; the destination path for a rename/copy
2379
+ const isRename = xy[0] === 'R' || xy[1] === 'R';
2380
+ // A rename OR copy emits two records (`to\0from`); consume the trailing
2381
+ // `from`. Copy `C` records only appear under `status.renames=copies`, but
2382
+ // when they do, missing this skip feeds the `from` path to `git add` as a
2383
+ // bogus pathspec — the exact auto-commit failure this parser fixes.
2384
+ let fromFile = null;
2385
+ if (isRename || xy[0] === 'C' || xy[1] === 'C') {
2386
+ i++;
2387
+ fromFile = records[i] || null;
2388
+ }
1414
2389
  if (!file) continue;
2390
+ if (!supplied.has(file)) continue; // out of this caller's scope
1415
2391
  if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
1416
2392
  continue;
1417
- paths.push(file);
2393
+ scoped.push(file);
2394
+ commitScope.push(file);
2395
+ // A rename's `from` path is the SAME change as its destination — without
2396
+ // it, `git commit --only` on the destination alone commits the addition
2397
+ // but leaves the source's deletion staged as residue (a git --only
2398
+ // quirk, verified). It is NOT added to `git add -A` (the deletion is
2399
+ // already staged by the rename itself, and its worktree entry is gone —
2400
+ // `git add -A -- <a gone-and-already-staged path>` errors "did not match
2401
+ // any files"), only to the commit's pathspec. A copy's `from` is
2402
+ // independently-owned, still-existing content, so it is NOT auto-pulled
2403
+ // in at all: the caller must name it explicitly if it wants it in scope.
2404
+ if (isRename && fromFile) commitScope.push(fromFile);
1418
2405
  }
1419
- if (paths.length > 0) {
1420
- const add = git('add', '--', ...paths);
2406
+ if (scoped.length === 0 && commitScope.length === 0) return { committed: true, scoped: 0 };
2407
+
2408
+ if (scoped.length > 0) {
2409
+ const add = git('add', '-A', '--', ...scoped);
1421
2410
  if (add.status !== 0)
1422
2411
  return {
1423
2412
  committed: false,
1424
2413
  reason: `git add failed: ${(add.stderr || '').trim() || 'unknown'}`,
1425
2414
  };
1426
2415
  }
1427
- const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
1428
- if (!staged) return { committed: true }; // nothing to commit = success (idempotent)
2416
+
2417
+ // Re-derive from what actually landed in the index, bounded by the SAME
2418
+ // pathspec (commitScope, the rename-aware superset of `scoped`) — this step
2419
+ // must not widen back to whole-tree either, or another session's already-
2420
+ // staged file would slip into the commit here.
2421
+ const staged = git('diff', '--cached', '--name-only', '-z', '--', ...commitScope);
2422
+ const stagedFiles = (staged.stdout || '').split('\0').filter(Boolean);
2423
+ if (stagedFiles.length === 0) return { committed: true, scoped: 0 };
2424
+
2425
+ const projects = new Set(stagedFiles.map(projectOfPath));
1429
2426
  const today = new Date().toISOString().slice(0, 10);
1430
- const commit = git('commit', '-m', `auto: ${today} wiki update`);
2427
+ const msg = `auto: ${today} wiki update (${stagedFiles.length} paths across ${projects.size} projects)`;
2428
+
2429
+ // `git commit --only -- <paths>` (first use of --only in this repo): commits
2430
+ // ONLY the staged changes under this pathspec, ignoring anything else
2431
+ // staged in the index — e.g. another session's own staged-but-uncommitted
2432
+ // work sharing this working tree. A bare `git commit -m` would sweep in
2433
+ // every staged path, defeating the whole point of the scoped set above.
2434
+ //
2435
+ // Pathspec is `commitScope`, NOT `stagedFiles`: `git diff --name-only`
2436
+ // collapses a rename to its destination alone (rename detection folds the
2437
+ // pair into one line), so re-deriving the commit's own pathspec from that
2438
+ // output would silently drop the `from` path again and reproduce the
2439
+ // exact `--only`-leaves-the-deletion-staged residue this function's rename
2440
+ // handling exists to avoid (verified). `stagedFiles` still governs the
2441
+ // empty-scope check and the N/M count above — both correct as "how many
2442
+ // logical changes", where a rename is rightly one.
2443
+ const commit = git('commit', '--only', '-m', msg, '--', ...commitScope);
1431
2444
  if (commit.status !== 0)
1432
2445
  return {
1433
2446
  committed: false,
1434
2447
  reason: `git commit failed: ${(commit.stderr || '').trim() || 'unknown'}`,
1435
2448
  };
1436
- return { committed: true };
2449
+ return { committed: true, scoped: stagedFiles.length };
1437
2450
  }
1438
2451
 
1439
2452
  /**
@@ -1464,6 +2477,134 @@ export function clearSyncState(hypoDir) {
1464
2477
  }
1465
2478
  }
1466
2479
 
2480
+ // ── sync-last-success ────────────────────────────────────────
2481
+ // `.cache/sync-last-success.json` is a single JSON object, PER-OPERATION:
2482
+ // { "pull": {"timestamp": "<ISO>", "host": "<os.hostname()>"},
2483
+ // "push": {"timestamp": "<ISO>", "host": "<...>"} }
2484
+ // Either key may be absent (pull-only or push-only history). Deliberately
2485
+ // separate from sync-state.json: that file is failure-only and gets wiped
2486
+ // wholesale on recovery (clearSyncState), so a success record living there
2487
+ // would be erased by the very thing it is meant to survive. syncRemote()
2488
+ // records here on a successful pull/push; session-start's independent
2489
+ // `git pull --ff-only` records a pull here too, so doctor never reports
2490
+ // "never synced" right after a healthy startup pull. doctor reads it via
2491
+ // readSyncLastSuccess; schema + parsing live here only.
2492
+ //
2493
+ // Scope: `.cache/` is gitignored (templates/gitignore), same as
2494
+ // sync-state.json — this file never syncs across machines, so there is no
2495
+ // cross-machine merge to protect. The lock below only has to cover the
2496
+ // same-machine case: two concurrent processes on one machine (a pull-writer
2497
+ // and a push-writer, or two overlapping sessions). `host` is kept per record
2498
+ // purely for provenance (which machine last recorded the op), not because a
2499
+ // remote write could ever land here.
2500
+
2501
+ /** @returns {string} path to the sync-last-success JSON file for a wiki root. */
2502
+ function syncLastSuccessPath(hypoDir) {
2503
+ return join(hypoDir, '.cache', 'sync-last-success.json');
2504
+ }
2505
+
2506
+ /**
2507
+ * A valid last-success record: `{timestamp: string, host: string}`. Anything
2508
+ * else (wrong type, missing field, non-object) is malformed.
2509
+ * @param {unknown} v
2510
+ * @returns {boolean}
2511
+ */
2512
+ function isValidSyncSuccessRecord(v) {
2513
+ return (
2514
+ v !== null &&
2515
+ typeof v === 'object' &&
2516
+ !Array.isArray(v) &&
2517
+ typeof v.timestamp === 'string' &&
2518
+ v.timestamp.trim().length > 0 &&
2519
+ typeof v.host === 'string' &&
2520
+ v.host.trim().length > 0
2521
+ );
2522
+ }
2523
+
2524
+ /**
2525
+ * Read last-success records. A missing file means "never synced" (not an
2526
+ * error) — the caller distinguishes that from a parse failure via
2527
+ * `parseError`. A present `pull`/`push` field that does not match the
2528
+ * `{timestamp, host}` shape (including an empty/whitespace-only timestamp or
2529
+ * host — a technically-typed but content-free record) is dropped from `data`
2530
+ * (never surfaced as if it were a real record) AND flags `parseError: true`,
2531
+ * so the caller warns ("cannot parse ... inspect manually") instead of
2532
+ * silently rendering `undefined` or an empty value — same corrupt-file
2533
+ * handling as sync-state.json. An unrecognized top-level key (anything other
2534
+ * than `pull`/`push`) likewise flags the whole file as corrupt: this schema
2535
+ * has exactly two legal keys, so a third one is evidence of a hand-edit or a
2536
+ * future/foreign writer, not a shape this reader should quietly tolerate.
2537
+ *
2538
+ * @param {string} hypoDir
2539
+ * @returns {{data: {pull?: {timestamp: string, host: string}, push?: {timestamp: string, host: string}}, parseError: boolean}}
2540
+ */
2541
+ export function readSyncLastSuccess(hypoDir) {
2542
+ const path = syncLastSuccessPath(hypoDir);
2543
+ if (!existsSync(path)) return { data: {}, parseError: false };
2544
+ try {
2545
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
2546
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
2547
+ return { data: {}, parseError: true };
2548
+ let malformed = Object.keys(parsed).some((k) => k !== 'pull' && k !== 'push');
2549
+ const data = {};
2550
+ for (const op of ['pull', 'push']) {
2551
+ if (parsed[op] === undefined) continue;
2552
+ if (isValidSyncSuccessRecord(parsed[op])) data[op] = parsed[op];
2553
+ else malformed = true; // drop the bad field; flag the file as corrupt
2554
+ }
2555
+ return { data, parseError: malformed };
2556
+ } catch {
2557
+ return { data: {}, parseError: true };
2558
+ }
2559
+ }
2560
+
2561
+ /**
2562
+ * Record a successful pull or push. Concurrency-safe on the same machine:
2563
+ * takes the vault file lock on the target path, re-reads the CURRENT file
2564
+ * under the lock, updates only the given op's field (the other op's existing
2565
+ * value survives — a same-machine concurrent pull-writer and push-writer must
2566
+ * not erase each other), then commits via temp-write + rename so a crash or a
2567
+ * concurrent read never sees a half-written file. `.cache/` is gitignored, so
2568
+ * this file never syncs across machines — there is no cross-machine case to
2569
+ * protect here. A pre-existing but unparseable file, or a sibling field that
2570
+ * does not match `{timestamp, host}` (including an empty/whitespace-only
2571
+ * value) or that carries an unrecognized key, is dropped rather than carried
2572
+ * forward (never preserve garbage into a freshly-written record) — the same
2573
+ * `isValidSyncSuccessRecord` predicate readSyncLastSuccess uses.
2574
+ *
2575
+ * Lock acquisition is best-effort — a timeout (or any other failure) is
2576
+ * swallowed, same as appendSyncFailure, since a failed success-log must never
2577
+ * break the caller (Stop hook / SessionStart).
2578
+ *
2579
+ * @param {string} hypoDir
2580
+ * @param {'pull'|'push'} op
2581
+ */
2582
+ export function recordSyncSuccess(hypoDir, op) {
2583
+ try {
2584
+ const path = syncLastSuccessPath(hypoDir);
2585
+ withFileLock(path, () => {
2586
+ let current = {};
2587
+ try {
2588
+ if (existsSync(path)) {
2589
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
2590
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2591
+ for (const k of ['pull', 'push']) {
2592
+ if (isValidSyncSuccessRecord(parsed[k])) current[k] = parsed[k];
2593
+ }
2594
+ }
2595
+ }
2596
+ } catch {
2597
+ // corrupt existing file: overwrite with a fresh object rather than
2598
+ // carry the corruption forward.
2599
+ }
2600
+ current[op] = { timestamp: new Date().toISOString(), host: hostname() };
2601
+ atomicWriteShared(path, JSON.stringify(current, null, 2) + '\n');
2602
+ });
2603
+ } catch {
2604
+ // best-effort: lock timeout or write failure must never break the caller
2605
+ }
2606
+ }
2607
+
1467
2608
  // ── auto-project suggestion ────────────────────────────────────────
1468
2609
  // `.cache/project-suggestions.json` is a single JSON object:
1469
2610
  // { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
@@ -1763,11 +2904,11 @@ export function clearClearMarker(hypoDir) {
1763
2904
  // Writer authority lives in crystallize, NOT this hook: the hook only checks
1764
2905
  // presence. See amendment 2026-05-19 Q2 for the split rationale.
1765
2906
 
1766
- const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
2907
+ export const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
1767
2908
 
1768
2909
  /** Sanitize session_id for filesystem use — Claude session_ids are UUIDs but
1769
2910
  * defend against accidental path traversal regardless. */
1770
- function sanitizeSessionId(sessionId) {
2911
+ export function sanitizeSessionId(sessionId) {
1771
2912
  return String(sessionId)
1772
2913
  .replace(/[^A-Za-z0-9._-]/g, '_')
1773
2914
  .slice(0, 128);
@@ -1799,9 +2940,22 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
1799
2940
  // attribution). Readers (precompactGateStatus / --check-session-close) key
1800
2941
  // the gate semantics on this field, so it must be recorded.
1801
2942
  const scope = info.scope === 'log-only' ? 'log-only' : 'project';
2943
+ // v4 attribution discriminator (session-close attribution). `projects` is the evidence-based
2944
+ // set this session actually closed — explicit --project ∪ transcript
2945
+ // close-file edits ∪ apply's authoritative payload.project — and NEVER the
2946
+ // recency primary. Its PRESENCE marks a v4 marker whose scope resolveCloseScope
2947
+ // trusts directly; a pre-v4 marker carries only `project` and is treated as an
2948
+ // uncorroborated legacy hint (see resolveCloseScope). `project` stays as
2949
+ // projects[0] for back-compat with the flat-field readers.
2950
+ const projects = Array.isArray(info.projects)
2951
+ ? [...new Set(info.projects.filter(Boolean))]
2952
+ : info.project
2953
+ ? [info.project]
2954
+ : [];
1802
2955
  const payload = {
1803
2956
  session_id: sessionId,
1804
- project: info.project || null,
2957
+ project: info.project || projects[0] || null,
2958
+ projects,
1805
2959
  scope,
1806
2960
  transcript_path: info.transcript_path || null,
1807
2961
  closed_at: new Date().toISOString(),
@@ -2170,6 +3324,64 @@ export function isUnderProjectDirs(file, slugs) {
2170
3324
  return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
2171
3325
  }
2172
3326
 
3327
+ /**
3328
+ * The session-close FILES of some project, as a path matcher. Used to attribute a
3329
+ * close to a session: a transcript that edited `projects/<slug>/session-state.md`
3330
+ * (or that project's hot.md / a session-log shard) is evidence THIS session was
3331
+ * closing <slug>. Any other file under `projects/<slug>/` is NOT evidence — merely
3332
+ * editing a page or an ADR there says nothing about whose close is whose, and
3333
+ * treating it as attribution would re-block a session for a project it only read
3334
+ * around in (codex design review).
3335
+ */
3336
+ const CLOSE_FILE_RE = /^projects\/([^/]+)\/(session-state\.md|hot\.md|session-log\/[^/]+\.md)$/;
3337
+
3338
+ /** Slugs whose close files this session edited directly (Write/Edit tool_use). */
3339
+ function projectsFromTouchedCloseFiles(transcriptPath, hypoDir) {
3340
+ const out = new Set();
3341
+ if (!transcriptPath) return out;
3342
+ for (const f of extractTouchedWikiFiles(transcriptPath, hypoDir)) {
3343
+ const m = posixPath(f).match(CLOSE_FILE_RE);
3344
+ if (m) out.add(m[1]);
3345
+ }
3346
+ return out;
3347
+ }
3348
+
3349
+ /**
3350
+ * closeScope: the projects THIS session is accountable for closing. The union of
3351
+ * three signals, because no single one covers every close path:
3352
+ *
3353
+ * 1. `opts.closeScope` — the caller states it outright. `--apply-session-close`
3354
+ * passes `payload.project` (it is the authority on what it just closed) and
3355
+ * `--mark-session-closed --project=<slug>` passes its attribution slug. This
3356
+ * signal is LOAD-BEARING, not a convenience: the documented close path writes
3357
+ * its files from inside a Bash call, so they never surface as Edit/Write
3358
+ * `file_path`s and signal 2 cannot see them (the same blind spot that forces
3359
+ * closeFileTargets() to seed the lint scope explicitly).
3360
+ * 2. close files the transcript shows this session editing — the hand-written
3361
+ * close, which bypasses the script entirely.
3362
+ * 3. `marker.project` — after a scripted close marked, this is how a later reader
3363
+ * (PreCompact) recovers signal 1. It is what keeps the marker == compact-ready
3364
+ * invariant: the writer scopes by `payload.project`, PreCompact re-scopes by
3365
+ * the `project` that same writer recorded, so the two can never disagree.
3366
+ */
3367
+ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
3368
+ const scope = new Set(opts.closeScope || []);
3369
+ for (const p of projectsFromTouchedCloseFiles(opts.transcriptPath, hypoDir)) scope.add(p);
3370
+ // Marker attribution (session-close attribution). A v4 marker carries `projects` — the
3371
+ // evidence-based set it closed (never recency) — trusted directly. A pre-v4
3372
+ // legacy marker carries only `project` with no provenance, and that value may be
3373
+ // recency-derived (the P1 bug). An uncorroborated legacy attribution must NOT
3374
+ // enable partitioning, or a stale/wrong slug could demote a real failure to
3375
+ // foreign debt. So a legacy `project` enters scope only when the direct signals
3376
+ // above (explicit close scope or transcript close-file edits) already corroborate
3377
+ // the SAME slug; otherwise it stays a display hint. This narrow gap self-heals as
3378
+ // pre-v4 markers expire (7-day TTL).
3379
+ if (Array.isArray(marker?.projects)) {
3380
+ for (const p of marker.projects) if (p) scope.add(p);
3381
+ }
3382
+ return scope;
3383
+ }
3384
+
2173
3385
  // ── PreCompact gate — single source of truth ────────────────────────────────
2174
3386
  /**
2175
3387
  * The full PreCompact gate decision as a READ-ONLY status. This is the single
@@ -2201,7 +3413,15 @@ export function isUnderProjectDirs(file, slugs) {
2201
3413
  * ignored.
2202
3414
  *
2203
3415
  * @param {string} hypoDir
2204
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
3416
+ * opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
3417
+ * gated (hook payload.cwd, or the CLI --session-cwd flag — never process.cwd(),
3418
+ * which is post-`cd` non-authoritative). When set and not log-only, the project
3419
+ * that owns this cwd is checked for close-completeness as an INDEPENDENT blocker,
3420
+ * catching a session whose own project close was never started. Unmatched/ambiguous
3421
+ * cwd yields a best-effort notice, not a block. apply never passes it (its launch
3422
+ * cwd may differ from the authoritative payload.project).
3423
+ *
3424
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
2205
3425
  * @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
3426
  */
2207
3427
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -2267,6 +3487,72 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2267
3487
  // projectOverride narrows the close status to one project (check-only); a
2268
3488
  // marker-writing caller never sets it, so the marker path stays global.
2269
3489
  close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
3490
+
3491
+ // Attribute the close debt before blocking on it. An incomplete close belongs
3492
+ // to whichever session performed it; charging it to an unrelated session is the
3493
+ // false block this partition exists to stop. The gate already draws exactly this
3494
+ // line for LINT debt (partitionLintScope: errors in files THIS session touched
3495
+ // block, pre-existing debt elsewhere is a notice) — close-file debt was the one
3496
+ // check still hard-blocking globally on another session's work.
3497
+ //
3498
+ // Two fail-closed guards keep the partition from eating a real blocker:
3499
+ // - close.fallback: no project closed today, so this is the "you have not
3500
+ // closed this session AT ALL" path. It must block unconditionally; demoting
3501
+ // it would gut the gate's whole purpose.
3502
+ // - empty scope: no positive attribution signal, so we cannot tell whose debt
3503
+ // it is. Never demote on a guess — fall back to today's global block.
3504
+ // - projectOverride: the caller asked "is THIS project close-complete?".
3505
+ // Demoting the very project it named would answer a question nobody asked.
3506
+ //
3507
+ // Bounded tradeoff (codex design review, accepted rather than closed). A
3508
+ // scripted close that CRASHES mid-write leaves no marker and no transcript trace
3509
+ // (it writes from inside a Bash call), so its own project carries no attribution.
3510
+ // If that same session had also hand-edited SOME OTHER project's close files, the
3511
+ // scope is non-empty but omits the torn project, and its debt is demoted. The
3512
+ // window is narrow and self-limiting: apply writes the project files, then the
3513
+ // session-log, then the root log entry, so the only torn state that reaches this
3514
+ // partition at all is a missing log.md entry — which deriveRootLogEntries
3515
+ // regenerates from the session-log heading. A crash before the session-log is not
3516
+ // detected as today-active by hasTodayCloseActivity in the first place (the
3517
+ // pre-existing tradeoff documented there). And the marker is never written, so the
3518
+ // Stop hook still refuses to end the session. Closing this properly needs a
3519
+ // durable close-attempt record; it is not worth a new state-file lifecycle here.
3520
+ const scopeSet = resolveCloseScope(hypoDir, opts, marker);
3521
+ const failed = (close.projects || []).filter((p) => !p.ok);
3522
+ const partition =
3523
+ !close.fallback && !opts.projectOverride && scopeSet.size > 0 && failed.length > 0;
3524
+
3525
+ close.scope = [...scopeSet];
3526
+ close.debt = [];
3527
+ // Re-project the flat aliases onto what actually BLOCKS, so `ok` and
3528
+ // `stale`/`missing` can never contradict each other (a reader that treats a
3529
+ // non-empty `missing` as failure stays correct). Demoted debt moves to its own
3530
+ // `debt` field instead of masquerading as this session's unfinished work.
3531
+ //
3532
+ // ONLY when partitioning. Deriving `ok` from the per-project rows unconditionally
3533
+ // would silently drop the failures that have NO project row to be derived from:
3534
+ // an unresolvable active project yields `projects: []` with
3535
+ // `missing: ['hot.md (no active project in pointer table)']`, so an empty `failed`
3536
+ // would read as "nothing failed" and flip a red gate green (codex pre-commit
3537
+ // BLOCKER, reproduced on a vault with no active-project row). Outside the
3538
+ // partition the close status stands exactly as sessionCloseGlobalStatus computed it.
3539
+ if (partition) {
3540
+ const mine = failed.filter((p) => scopeSet.has(p.project));
3541
+ const foreign = failed.filter((p) => !scopeSet.has(p.project));
3542
+ close.debt = foreign.map((p) => ({ project: p.project, stale: p.stale, missing: p.missing }));
3543
+ close.stale = [...new Set(mine.flatMap((p) => p.stale))];
3544
+ close.missing = [...new Set(mine.flatMap((p) => p.missing))];
3545
+ close.ok = mine.length === 0;
3546
+ // The flat `project` alias must follow the scope too: it names the project the
3547
+ // rest of this status describes, and every consumer that renders a per-file
3548
+ // checklist builds it from that name. Left as the global `primary` it can point
3549
+ // at a DEMOTED foreign project, and the checklist then reports ✓ for files the
3550
+ // debt list simultaneously calls missing (codex pre-commit CONCERN).
3551
+ if (!scopeSet.has(close.project)) {
3552
+ close.project = mine[0]?.project ?? [...scopeSet][0] ?? close.project;
3553
+ }
3554
+ }
3555
+
2270
3556
  if (!close.ok) {
2271
3557
  blockers.push({
2272
3558
  type: 'close',
@@ -2276,6 +3562,74 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2276
3562
  ].join(', ')}`,
2277
3563
  });
2278
3564
  }
3565
+ for (const p of close.debt) {
3566
+ notices.push({
3567
+ type: 'close-debt',
3568
+ project: p.project,
3569
+ reason: `${p.project}: incomplete session close from another session (${[
3570
+ ...p.missing.map((f) => `${f} (missing)`),
3571
+ ...p.stale.map((f) => `${f} (stale)`),
3572
+ ].join(', ')}) — not blocking; that project's next close will fix it`,
3573
+ });
3574
+ }
3575
+ }
3576
+
3577
+ // 3b. session-cwd close (session-cwd close check). The current session's cwd project is an
3578
+ // INDEPENDENT close responsibility. sessionCloseGlobalStatus above only sees
3579
+ // projects that left an authoritative close-activity trace (a session-log
3580
+ // heading / log.md entry), so a project whose close was NEVER STARTED is
3581
+ // invisible — and if the recency project was closed the same day, the gate would
3582
+ // go green while this session's real project stays unclosed (the false-green this
3583
+ // closes). Evaluate it here, AFTER the partition and OUTSIDE scopeSet /
3584
+ // close.projects, so it can neither be demoted to foreign debt (partition) nor
3585
+ // spawn a W8 design-history blocker (which derives from close.projects). log-only
3586
+ // sessions are exempt (no project to close). apply never passes sessionCwd (its
3587
+ // launch cwd may differ from the authoritative payload.project — a supported
3588
+ // cross-project close), so this runs only on the read / mark paths.
3589
+ if (!logOnly && opts.sessionCwd) {
3590
+ const cwdProject = pickProjectByCwd(collectProjectWorkingDirs(hypoDir), opts.sessionCwd, {
3591
+ rejectAmbiguous: true,
3592
+ });
3593
+ if (cwdProject) {
3594
+ const s = sessionCloseFileStatus(hypoDir, { projectOverride: cwdProject });
3595
+ if (!s.ok) {
3596
+ // ALWAYS emit the typed close-cwd blocker when the cwd project's close is
3597
+ // incomplete — never suppress it as a duplicate of the global `close`
3598
+ // blocker. The Stop hook keys its marker re-check on this exact type, so
3599
+ // hiding it (even when a `close` blocker names the same project) would let
3600
+ // Stop honor a stale marker and end the session green (codex pre-commit
3601
+ // BLOCKER). A second entry for the same slug is merely noisy, never wrong.
3602
+ blockers.push({
3603
+ type: 'close-cwd',
3604
+ project: cwdProject,
3605
+ reason: `session cwd project '${cwdProject}' has an incomplete session close: ${[
3606
+ ...s.missing.map((f) => `${f} (missing)`),
3607
+ ...s.stale.map((f) => `${f} (stale)`),
3608
+ ].join(', ')}`,
3609
+ });
3610
+ // If the partition demoted this project to foreign debt, it is NOT another
3611
+ // session's work — it is THIS session's, and we just BLOCKED on it. Remove
3612
+ // it from BOTH the debt notice and close.debt so the status cannot report
3613
+ // the same project as non-blocking debt and a blocker at once (codex
3614
+ // pre-commit CONCERN: crystallize renders close_debt from close.debt).
3615
+ for (let i = notices.length - 1; i >= 0; i--) {
3616
+ if (notices[i].type === 'close-debt' && notices[i].project === cwdProject)
3617
+ notices.splice(i, 1);
3618
+ }
3619
+ if (Array.isArray(close.debt))
3620
+ close.debt = close.debt.filter((d) => d.project !== cwdProject);
3621
+ }
3622
+ } else {
3623
+ // cwd is under no project working_dir, or ambiguously under several (a
3624
+ // monorepo tie pickProjectByCwd declined): we cannot attribute a close
3625
+ // responsibility, so we do NOT hard-block (nothing proves there is anything
3626
+ // to close). Surface a best-effort notice so the coverage gap is visible.
3627
+ notices.push({
3628
+ type: 'close-cwd-unresolved',
3629
+ reason:
3630
+ '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',
3631
+ });
3632
+ }
2279
3633
  }
2280
3634
 
2281
3635
  // 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
@@ -2425,8 +3779,55 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2425
3779
  .map(([n]) => n);
2426
3780
  const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
2427
3781
  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
3782
+ // A target whose file EXISTS but cannot be projected into (its
3783
+ // <learned_behaviors> container is gone) reports dirty:false with no
3784
+ // conflict flag — it cannot be built, so nothing "would change". That
3785
+ // shape used to fall into the non-actionable branch below and fail OPEN,
3786
+ // the worst possible reading: a projection that loads ZERO rules on this
3787
+ // machine was classified as "nothing to do" and waved through. It is a
3788
+ // blocker, not a shrug.
3789
+ //
3790
+ // ONLY kind 'build-failed'. A 'target-missing' buildError (no ~/.claude/
3791
+ // CLAUDE.md yet) is the ordinary first-run state and must keep failing
3792
+ // OPEN, or the gate blocks every new user on their first /compact.
3793
+ const buildErrT = entries
3794
+ .filter(([, t]) => t.buildError && t.buildErrorKind === 'build-failed')
3795
+ .map(([n]) => n);
3796
+ // Side-file I/O trouble (an unreadable feedback_<slug>.md under the
3797
+ // project memory dir) is a NOTICE, never a blocker: the primary
3798
+ // projection still loads every rule, and the only fix is a permission
3799
+ // bit on that path — `--ensure-container` cannot touch it. A gate that
3800
+ // blocks on a condition its own named remedy cannot clear is a gate that
3801
+ // gets bypassed.
3802
+ const sideWarnT = entries.filter(([, t]) => (t.sideWarnings || []).length);
3803
+ if (
3804
+ !report ||
3805
+ !(
3806
+ conflictedT.length ||
3807
+ overCapT.length ||
3808
+ driftedT.length ||
3809
+ buildErrT.length ||
3810
+ sideWarnT.length
3811
+ )
3812
+ ) {
3813
+ skipped.feedback = true; // unparseable / non-actionable → fail-open
3814
+ } else if (buildErrT.length) {
3815
+ // Name the EXACT target file and the remedy for THIS cause. "Restore
3816
+ // the managed container" with no path is a dead end, and so is naming
3817
+ // `--ensure-container` for a permission error or a dangling symlink,
3818
+ // which it cannot fix — a blocker with no way through gets bypassed
3819
+ // rather than obeyed. t.buildError carries the target's absolute path
3820
+ // and t.buildErrorRemedy the cause-specific way out; feedback-sync
3821
+ // makes that judgment once, at the branch that detects the cause.
3822
+ const failed = entries.filter(([n]) => buildErrT.includes(n));
3823
+ const details = failed.map(([n, t]) => `${n}: ${t.buildError}`).join('; ');
3824
+ const remedies = [
3825
+ ...new Set(failed.map(([, t]) => t.buildErrorRemedy).filter(Boolean)),
3826
+ ].join(' ');
3827
+ blockers.push({
3828
+ type: 'feedback',
3829
+ reason: `feedback projection cannot be built — ${details} — no rules are loaded from it. ${remedies}`,
3830
+ });
2430
3831
  } else if (conflictedT.length) {
2431
3832
  blockers.push({
2432
3833
  type: 'feedback',
@@ -2437,13 +3838,23 @@ export function precompactGateStatus(hypoDir, opts = {}) {
2437
3838
  type: 'feedback',
2438
3839
  reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
2439
3840
  });
2440
- } else {
3841
+ } else if (driftedT.length) {
2441
3842
  driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
2442
3843
  notices.push({
2443
3844
  type: 'feedback',
2444
3845
  reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
2445
3846
  });
2446
3847
  }
3848
+ // Additive, and deliberately outside the chain above: a side-file I/O
3849
+ // problem is orthogonal to the primary target's health, so it is a notice
3850
+ // whatever else is (or is not) going on. It names the path and the
3851
+ // permission fix, because that — not a command — is the way out.
3852
+ for (const [n, t] of sideWarnT) {
3853
+ notices.push({
3854
+ type: 'feedback',
3855
+ 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)`,
3856
+ });
3857
+ }
2447
3858
  }
2448
3859
  } catch {
2449
3860
  skipped.feedback = true;
@@ -2516,6 +3927,29 @@ export function isCompactOrClearCommand(prompt) {
2516
3927
  * @returns {string} newline-joined user text, or '' on any failure (fail-open)
2517
3928
  */
2518
3929
  export function extractUserMessages(transcriptPath, tailN = 30) {
3930
+ return extractUserMessageRecords(transcriptPath, tailN, { keepEmpty: true }).join('\n');
3931
+ }
3932
+
3933
+ /**
3934
+ * Same extraction as {@link extractUserMessages}, but ONE STRING PER TRANSCRIPT
3935
+ * RECORD instead of one flat blob. The message boundary is the point: a gate that
3936
+ * asks "did the user say exactly X" cannot ask it of text that has been joined
3937
+ * across turns, because any line of any message then looks like a whole message.
3938
+ * {@link hasTypedUserApproval} needs that boundary; the close-intent readers, which
3939
+ * only ever ask "does this text contain a close phrase", do not.
3940
+ *
3941
+ * @param {string} transcriptPath
3942
+ * @param {number} tailN how many trailing lines to scan (Infinity → whole file)
3943
+ * @param {{keepEmpty?: boolean}} [opts] keepEmpty preserves a '' per dropped record,
3944
+ * so the joined form stays byte-identical to what extractUserMessages always returned.
3945
+ * @returns {string[]} user-typed text per record, or [] on any failure (fail-open)
3946
+ */
3947
+ export function extractUserMessageRecords(transcriptPath, tailN = 30, { keepEmpty } = {}) {
3948
+ const records = extractUserRecordTexts(transcriptPath, tailN);
3949
+ return keepEmpty ? records : records.filter((t) => t !== '');
3950
+ }
3951
+
3952
+ function extractUserRecordTexts(transcriptPath, tailN) {
2519
3953
  try {
2520
3954
  const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
2521
3955
  // tailN === Infinity → whole transcript (the marker-write hard gate needs the
@@ -2523,51 +3957,49 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
2523
3957
  // checklist). The Stop hook keeps the 30-line default so a stale old close
2524
3958
  // signal doesn't re-trigger every turn.
2525
3959
  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 '';
3960
+ return tail.map((line) => {
3961
+ try {
3962
+ const obj = JSON.parse(line);
3963
+ // Skill-injection vector: drop system-injected role:user
3964
+ // messages before they pollute the close-intent signal.
3965
+ // • isMeta:true — slash-command bodies, skill bodies, local-command
3966
+ // caveats. Their text is docs/specs, often full of close vocabulary
3967
+ // (e.g. the /hypo:crystallize spec literally contains close phrases),
3968
+ // which would let the gate self-satisfy the moment the model invokes
3969
+ // a close command. Confirmed isMeta:true in the transcript.
3970
+ // • promptSource system|sdk — task-notifications (system) and
3971
+ // SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
3972
+ if (obj.isMeta === true) return '';
3973
+ if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
3974
+ const msg = obj.message ?? obj;
3975
+ const role = msg.role ?? obj.role ?? obj.type;
3976
+ if (role !== 'user') return '';
3977
+ const content = msg.content ?? obj.content;
3978
+ if (typeof content === 'string') {
3979
+ // Stop-hook block feedback is recorded as a role:user string. It is
3980
+ // the hook's OWN nudge ("[WIKI_AUTOCLOSE] … Run crystallize …"), not
3981
+ // user intent — counting it would be circular (the hook that prods the
3982
+ // model to close would become proof the user wanted to close).
3983
+ return content.startsWith('Stop hook feedback') ? '' : content;
2566
3984
  }
2567
- })
2568
- .join('\n');
3985
+ if (Array.isArray(content)) {
3986
+ // Only genuine user-typed text blocks. tool_result blocks are also
3987
+ // recorded with role:'user' in the Claude Code transcript; slurping
3988
+ // them via JSON.stringify let tool output (e.g. close-pattern example
3989
+ // strings read out of code/docs) trip the close-intent gate.
3990
+ // Do NOT recurse into tool_result.content, or the pollution returns.
3991
+ return content
3992
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
3993
+ .map((b) => b.text)
3994
+ .join('\n');
3995
+ }
3996
+ return '';
3997
+ } catch {
3998
+ return '';
3999
+ }
4000
+ });
2569
4001
  } catch {
2570
- return '';
4002
+ return [];
2571
4003
  }
2572
4004
  }
2573
4005
 
@@ -2632,6 +4064,114 @@ export function isClosePattern(text) {
2632
4064
  return [...krPatterns, ...enPatterns].some((re) => re.test(text));
2633
4065
  }
2634
4066
 
4067
+ // ── close ARTIFACTS, not the marker (issue: close gate lives on one writer) ──
4068
+ //
4069
+ // hasUserCloseSignal/isClosePattern answer "did the user ask to close" — the
4070
+ // input side of the gate. This answers a different question: "does this file
4071
+ // or commit message already READ as closed to a human", independent of
4072
+ // whether any marker writer ever ran. The 2026-07-28 incident produced a
4073
+ // closing session-state.md heading, a rewritten hot.md, and a close-worded
4074
+ // commit WITHOUT writing a session-closed marker at all — the hard gate on
4075
+ // the marker writer never fired because the model never called it. Closing
4076
+ // is a set of user-visible artifacts, not one internal file.
4077
+ //
4078
+ // Narrowed scope — this is a PARTIAL defense, not the completed artifact-set
4079
+ // gate: hot.md's REPLACEMENT is a diff against the prior version, which a
4080
+ // pure function taking only current content cannot see. This only catches a
4081
+ // hot.md that carries close vocabulary in its own bold heading — a hot.md
4082
+ // rewritten with a closing narrative that happens not to use 마감/종료
4083
+ // literally (as the incident's own hot.md rewrite did) will NOT trip this
4084
+ // signal. That is a real, known false-negative, not a corner this slice
4085
+ // closes: catching it needs a diff-aware check (comparing against the prior
4086
+ // committed hot.md, or a PreToolUse guard that sees the edit as it happens),
4087
+ // which is future work, not something reusing isClosePattern's heuristics
4088
+ // would fix either. The session-state heading and commit-message signals
4089
+ // still catch the one incident on record, so today's gap is documented, not
4090
+ // silent — but it is a gap, not a boundary this slice was designed to hold.
4091
+ //
4092
+ // A close word used as a NOUN-MODIFIER is not an announcement — "마감
4093
+ // 조건을/여부/로직/절차/정책" all describe something ABOUT closing, not a close
4094
+ // that happened. Enumerating the modifiers (blacklist, round 1) and then the
4095
+ // verb suffixes that ARE an announcement (whitelist, round 2) both kept
4096
+ // finding new words each review round — neither converges, because both are
4097
+ // open lexical classes. The convergent rule is a WORD-BOUNDARY, not a word
4098
+ // list: Korean verb conjugation attaches directly with no space (마감했다,
4099
+ // 마감되었습니다, 마감했습니다, 마감됨 — see the corpus in
4100
+ // tests/close-signals.test.mjs), while a noun-modifier is a SEPARATE word
4101
+ // after a space (마감 조건, 마감 정책, 종료 여부, 종료 절차). So: a close word
4102
+ // followed immediately (no space) by more Hangul is a conjugation and
4103
+ // announces a close; a close word followed by whitespace THEN Hangul is a
4104
+ // modifier and does not. `CLOSE_WORD_TAIL` consumes the (possibly empty)
4105
+ // directly-attached conjugation run, then requires the close word to be
4106
+ // effectively the LAST content in its heading: only a parenthetical, ":" +
4107
+ // trailing narrative, terminal punctuation, or the bold-heading's end may
4108
+ // follow — a bare space-separated Hangul word after it fails to match any of
4109
+ // those and rejects. Known accepted limit: a same-word-no-space compound like
4110
+ // "마감일" (deadline-date, a noun) would match — over the FN/FP asymmetry this
4111
+ // check is built on (a missed real close is the exact failure mode it exists
4112
+ // to catch; a stray warn is mild noise), that's the accepted direction.
4113
+ const CLOSE_WORD_TAIL = '[가-힣]*(?:\\s*:[^*\\n]*|(?:\\([^)]*\\))?[.,]?\\s*)';
4114
+ const SESSION_STATE_CLOSE_HEADING = new RegExp(
4115
+ `\\*\\*(\\d{4}-\\d{2}-\\d{2})[^*\\n]*?마감${CLOSE_WORD_TAIL}\\*\\*`,
4116
+ );
4117
+ const HOT_CLOSE_NARRATIVE = new RegExp(
4118
+ `\\*\\*(\\d{4}-\\d{2}-\\d{2})[^*\\n]*?(?:마감|세션\\s*종료)${CLOSE_WORD_TAIL}\\*\\*`,
4119
+ );
4120
+ // EN: requires the CLOSE's object to actually be "session" ("close the
4121
+ // session" / "close the Nth session" — an ORDINAL or digit-ordinal, the only
4122
+ // forms a real close commit uses), not any noun ("close the database
4123
+ // session", "close the browser session" are technical commits, not a
4124
+ // session-close). KR: same word-boundary rule as the heading patterns above,
4125
+ // applied as a lookahead only (a commit subject isn't bold-wrapped, so there
4126
+ // is no terminal structure to consume into) — reject only when a bare SPACE
4127
+ // separates the close word from a following Hangul word; a directly-attached
4128
+ // conjugation (세션을 종료했다) still matches. "을/를" is the object particle
4129
+ // Korean puts between "세션" and its verb.
4130
+ const ORDINAL =
4131
+ '(?:\\d+(?:st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|' +
4132
+ 'eleventh|twelfth|thirteenth|fourteenth|fifteenth|sixteenth|seventeenth|eighteenth|nineteenth|twentieth)';
4133
+ const CLOSE_COMMIT_MESSAGE = new RegExp(
4134
+ `\\b(?:session:\\s*)?close(?:s|d)?\\s+(?:the|this)\\s+(?:${ORDINAL}\\s+)?session\\b|` +
4135
+ `세션(?:을|를)?\\s*(?:마무리|마감|종료)(?!\\s+[가-힣])`,
4136
+ 'i',
4137
+ );
4138
+
4139
+ /**
4140
+ * Pure predicate: is this file content, or this commit message, a
4141
+ * session-close ARTIFACT (something a human would read as "this session was
4142
+ * closed")? Never reads the filesystem — callers own IO. `path`'s basename
4143
+ * selects which pattern applies (`session-state.md` vs `hot.md`); pass
4144
+ * `commitMessage` instead for a git log entry.
4145
+ *
4146
+ * Consumed today by doctor's post-hoc check: an artifact with no matching
4147
+ * session-closed marker for its date is a signature of a hand-made close
4148
+ * that never went through the gate. Meant to also back a future PreToolUse
4149
+ * guard on the same definition, so the two defenses can't drift apart on
4150
+ * what "close" means.
4151
+ *
4152
+ * @param {{path?: string|null, content?: string|null, commitMessage?: string|null}} input
4153
+ * @returns {{matched: boolean, kind: string|null, date: string|null}} `date`
4154
+ * is the YYYY-MM-DD captured from the artifact's own heading, or null for a
4155
+ * commit-message match (the caller already has the commit's date).
4156
+ */
4157
+ export function detectSessionCloseArtifact({ path = null, content = null, commitMessage = null } = {}) {
4158
+ if (typeof commitMessage === 'string' && CLOSE_COMMIT_MESSAGE.test(commitMessage)) {
4159
+ return { matched: true, kind: 'commit-message', date: null };
4160
+ }
4161
+ if (typeof path === 'string' && typeof content === 'string') {
4162
+ const base = path.split(/[\\/]/).pop();
4163
+ if (base === 'session-state.md') {
4164
+ const m = SESSION_STATE_CLOSE_HEADING.exec(content);
4165
+ if (m) return { matched: true, kind: 'session-state-heading', date: m[1] };
4166
+ }
4167
+ if (base === 'hot.md') {
4168
+ const m = HOT_CLOSE_NARRATIVE.exec(content);
4169
+ if (m) return { matched: true, kind: 'hot-narrative', date: m[1] };
4170
+ }
4171
+ }
4172
+ return { matched: false, kind: null, date: null };
4173
+ }
4174
+
2635
4175
  /**
2636
4176
  * Resolve a session's transcript path from its (globally-unique) session id by
2637
4177
  * globbing every Claude project dir: ~/.claude/projects/<slug>/<id>.jsonl.
@@ -2678,83 +4218,371 @@ export function resolveTranscriptBySessionId(
2678
4218
  }
2679
4219
 
2680
4220
  /**
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.
4221
+ * Returns true iff the transcript's LATEST live user decision is to close — the
4222
+ * hard gate for the session-closed marker writers. This is a state predicate, not
4223
+ * an existence one: "is close still approved right now", not "did a
4224
+ * close signal ever appear". Scans the FULL transcript in line order, classifies
4225
+ * each record, and tracks the approval as a LEASE.
4226
+ *
4227
+ * Classification (structural fields only — never content heuristics for producer):
4228
+ * • GRANT a genuine user close: an NL close phrase in user text that
4229
+ * survives {@link eventUserText}'s exclusions; a `/compact`
4230
+ * queue-op; a remove-path queued_command attachment carrying a
4231
+ * close with an audited human producer (origin.kind "human"); a
4232
+ * correlated, non-error AskUserQuestion answer naming a close.
4233
+ * • INVALIDATE a fresh user intent that expires the lease: any other genuine
4234
+ * user text, `/clear`, `popAll`, a non-close queued_command, a
4235
+ * non-close AskUserQuestion selection.
4236
+ * • NEUTRAL additionally: a typed `apply-proposals <nonce>` whose nonce this
4237
+ * transcript shows `proposal challenge` minting (see "the approval
4238
+ * line" below).
4239
+ * • NEUTRAL everything the model can produce or the harness injects: system/
4240
+ * sdk replay, isMeta bodies, sidechain, interruptedMessageId
4241
+ * companions, assistant, tool_result, task-notification.
4242
+ * • FATAL an unparseable line — the transcript is being appended to or is
4243
+ * corrupt, so refuse rather than read a half-written record.
4244
+ *
4245
+ * The last grant wins and a later invalidate expires it, so a stale close (Defect
4246
+ * B), a queued "keep working" after a close, and a non-close AskUserQuestion
4247
+ * selection all correctly read as NOT closed. Abandoned-branch staleness is a
4248
+ * known limit (no leaf pointer exists to resolve it — see the branch note on the
4249
+ * function), mitigated by the lease.
4250
+ *
4251
+ * THE APPROVAL LINE IS NOT A CHANGE OF MIND. The lease exists to catch a user who
4252
+ * changed their mind, and it also caught the user for doing what the close
4253
+ * procedure told them to do. `proposal challenge` instructs the user to type
4254
+ * `apply-proposals <nonce>`, which can never be a close phrase, so typing it
4255
+ * expired the close grant given a turn earlier (measured 2026-08-06: the close
4256
+ * needed three approval round trips because of it). Two gates read one transcript
4257
+ * under two rules, and passing one broke the other.
2685
4258
  *
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).
4259
+ * So one carve-out: a user message that IS the approval line, for a nonce this
4260
+ * transcript shows being minted, is NEUTRAL. It never grants; a lease that was not
4261
+ * already live stays dead. The mint has to be correlated, not merely present:
4262
+ * `collectMinted` reads only a tool_result correlated to a Bash tool_use, and only
4263
+ * when the surrounding challenge framing is there too, so a hex the model typed in
4264
+ * its own prose (or any other tool's output) is not a mint. Order is part of it:
4265
+ * the set fills as the walk goes, so a message can never mint the nonce it spends.
4266
+ * An unminted hex is ordinary text and still expires the lease.
2694
4267
  *
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.
4268
+ * What this does NOT cover: an AskUserQuestion answer during the close procedure
4269
+ * (the skill's Step 1a reflections) still expires the lease. A carve-out was
4270
+ * written for it and withdrawn, because "the answer does not read as a retraction"
4271
+ * is fail-open on an open class of phrasings, and this repo routes an unknown value
4272
+ * to the cautious branch. Recognizing a close-procedure answer POSITIVELY needs a
4273
+ * marker on the question, and the model authors the question. The round trip stays.
2698
4274
  *
2699
- * Fail-closed: any read error → false (caller refuses the marker).
4275
+ * Threat model: an honest-but-drifting model that does not forge the
4276
+ * transcript. This is a safety interlock, not an authorization boundary — a model
4277
+ * that forges records is out of scope and documented so. The carve-out does not
4278
+ * move that boundary: it only PRESERVES a grant the user already gave, so the model
4279
+ * still cannot manufacture one. The residual it does carry: a model that runs an
4280
+ * arbitrary Bash command can print the challenge framing itself and plant a mint.
4281
+ * That buys it nothing on its own, because the user still has to send the approval
4282
+ * line, and the result is neutrality rather than a grant.
4283
+ *
4284
+ * A command invocation is NOT a channel here. `/hypo:crystallize` typed by the user
4285
+ * was accepted as a grant for one review round and removed: the harness records a
4286
+ * model-issued Skill call as the same non-meta `<command-name>` user record
4287
+ * (verified against Claude Code 2.1.243's `processPromptSlashCommand`), so the
4288
+ * model could have closed the session by calling its own skill. The premise that a
4289
+ * command tag proves a human typed it is false.
4290
+ *
4291
+ * Fail-closed: any read/parse error, or an unresolvable branch, → false.
2700
4292
  */
4293
+ // Genuine user text of a record, or null when the record is on a channel the
4294
+ // honest-but-drifting model can reach (so it is never user intent — NEUTRAL).
4295
+ // role:user is the boundary the model cannot cross: it authors role:assistant,
4296
+ // so a user record that survives every exclusion below is genuine user text.
4297
+ // The exclusions are exactly the model-reachable channels:
4298
+ // • isMeta slash-command / skill bodies and local-command caveats
4299
+ // • promptSource system|sdk queue replay of injected text, SDK prompts
4300
+ // • isSidechain subagent context (a subagent must not close the session)
4301
+ // • interruptedMessageId the harness cut the model off; user-SHAPED, not a
4302
+ // user decision (e.g. the [Request interrupted by user]
4303
+ // companion of a delivered /compact)
4304
+ // • Stop-hook feedback the hook's own close nudge — circular
4305
+ // • tool_result blocks tool output, not typed text
4306
+ // No promptSource allowlist is required: requiring `typed` would drop the
4307
+ // legacy absent-promptSource close the older gate has always honoured, while the
4308
+ // dangerous replay/injection paths carry system|sdk|isMeta|isSidechain and are
4309
+ // excluded here anyway.
4310
+ function eventUserText(obj) {
4311
+ if (obj.isMeta === true) return null;
4312
+ if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return null;
4313
+ if (obj.isSidechain === true) return null;
4314
+ if (obj.interruptedMessageId) return null;
4315
+ const msg = obj.message ?? obj;
4316
+ const role = msg.role ?? obj.role ?? obj.type;
4317
+ if (role !== 'user') return null;
4318
+ const content = msg.content ?? obj.content;
4319
+ if (typeof content === 'string') {
4320
+ return content.startsWith('Stop hook feedback') ? null : content;
4321
+ }
4322
+ if (Array.isArray(content)) {
4323
+ const texts = content
4324
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4325
+ .map((b) => b.text);
4326
+ return texts.length ? texts.join('\n') : null;
4327
+ }
4328
+ return null;
4329
+ }
4330
+
4331
+ // A record on a channel the honest-but-drifting model can reach, so it can never
4332
+ // be a user decision. Used to keep injected / replayed / subagent records out of
4333
+ // BOTH the user-text and the AskUserQuestion-answer classifiers.
4334
+ function isModelReachableRecord(obj) {
4335
+ return (
4336
+ obj.isMeta === true ||
4337
+ obj.promptSource === 'system' ||
4338
+ obj.promptSource === 'sdk' ||
4339
+ obj.isSidechain === true
4340
+ );
4341
+ }
4342
+
2701
4343
  export function hasUserCloseSignal(transcriptPath) {
2702
4344
  if (!transcriptPath) return false;
2703
- let lines;
4345
+ let raw;
2704
4346
  try {
2705
- lines = readFileSync(transcriptPath, 'utf-8').split('\n');
4347
+ raw = readFileSync(transcriptPath, 'utf-8');
2706
4348
  } catch {
2707
4349
  return false;
2708
4350
  }
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) {
4351
+ // FATAL: a non-empty line that will not parse means the transcript is being
4352
+ // appended to (a half-written record) or is corrupt. Skipping it would let a
4353
+ // stale prior grant survive past an event we cannot read, so refuse. A line
4354
+ // that parses to a non-object (a bare null / string / number) is valid JSON but
4355
+ // not a record — noise, not corruption — so it is skipped, not fatal, and never
4356
+ // reaches the field reads below.
4357
+ const recs = [];
4358
+ for (const line of raw.split('\n')) {
2720
4359
  if (!line.trim()) continue;
2721
- let obj;
4360
+ let o;
2722
4361
  try {
2723
- obj = JSON.parse(line);
4362
+ o = JSON.parse(line);
2724
4363
  } catch {
4364
+ return false;
4365
+ }
4366
+ if (o === null || typeof o !== 'object') continue;
4367
+ recs.push(o);
4368
+ }
4369
+
4370
+ // The approval is a LEASE, not an existence fact: walk the transcript in line
4371
+ // order and track whether the LATEST user decision is a grant. Each grant sets
4372
+ // it true, each invalidate sets it false, neutral leaves it — so at the end
4373
+ // `granted` is "the most recent user decision was to close, and nothing has
4374
+ // expired it since", which is how a stale close and a queued change-of-mind
4375
+ // read as NOT closed.
4376
+ //
4377
+ // Branch note: line order mixes an abandoned branch's records with the live
4378
+ // ones. A leaf-pointer ancestry filter was tried and withdrawn — the transcript
4379
+ // carries no authoritative leaf pointer (measured: 0 leafUuid / summary
4380
+ // records), so a heuristic leaf can skip the real invalidator and PRESERVE a
4381
+ // stale grant (a fail-open, not a conservative filter). Until such a pointer
4382
+ // exists, a close on a branch abandoned under a neutral tail is a known
4383
+ // staleness limit, mitigated by the lease: any later live user intent, on any
4384
+ // branch, still expires it.
4385
+ const askIds = new Set();
4386
+ // Bash tool_use ids, so a mint can be tied to a command that actually ran rather
4387
+ // than to any string in the file. Filled in the same content scan as askIds.
4388
+ const bashIds = new Set();
4389
+ let granted = false;
4390
+ // The nonces this transcript shows `proposal challenge` minting. Producer, not
4391
+ // just presence: the hex has to arrive in a NON-error tool_result of a Bash
4392
+ // tool_use, wrapped in the challenge's own framing. Model prose carrying a
4393
+ // plausible hex, an isMeta/system body, and any other tool's output all fail
4394
+ // that, which matters because a mint neutralizes the user's next message. The
4395
+ // set fills as the walk goes, so a message cannot mint the nonce it spends.
4396
+ const mintedNonces = new Set();
4397
+ const challengeMint = new RegExp(
4398
+ `must type this line in the conversation:\\s*${APPROVAL_PHRASE} ([a-f0-9]{32,})\\s*Then run: hypomnema proposal resolve`,
4399
+ 'g',
4400
+ );
4401
+ const approvalLine = new RegExp(`^${APPROVAL_PHRASE}\\s+([a-f0-9]{32,})$`);
4402
+ const collectMinted = (o) => {
4403
+ const c = (o.message ?? o).content;
4404
+ if (!Array.isArray(c)) return;
4405
+ for (const b of c) {
4406
+ if (!b || typeof b !== 'object') continue;
4407
+ if (b.type !== 'tool_result' || !b.tool_use_id || !bashIds.has(b.tool_use_id)) continue;
4408
+ if (b.is_error === true) continue;
4409
+ const text = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4410
+ if (typeof text !== 'string' || !text.includes(APPROVAL_PHRASE)) continue;
4411
+ for (const m of text.matchAll(challengeMint)) mintedNonces.add(m[1]);
4412
+ }
4413
+ };
4414
+
4415
+ for (const o of recs) {
4416
+ // Genuine user text of this record, or null when the record is on a channel
4417
+ // the model can reach. Computed once here: the mint scan below is the exact
4418
+ // complement of it (mint from everything that is NOT the user's own text).
4419
+ const userText = eventUserText(o);
4420
+ if (userText == null) collectMinted(o);
4421
+
4422
+ // Queue operations. The queue carries no correlation key (measured), so the
4423
+ // ENQUEUE content is the decision — not a later contentless dequeue, which
4424
+ // would need pairing we cannot do. Reading the enqueue also keeps the live
4425
+ // PreCompact gate working (it sees the enqueue) and avoids double-counting the
4426
+ // replay companion of an already-decided item (the /compact replay is not a
4427
+ // fresh decision). popAll cancels the queue → invalidate. Delivery ops
4428
+ // (dequeue, remove) carry no fresh decision here — a content-bearing remove of
4429
+ // an NL queued command is handled by its queued_command attachment below.
4430
+ if (o.type === 'queue-operation') {
4431
+ if (o.operation === 'popAll') {
4432
+ granted = false;
4433
+ continue;
4434
+ }
4435
+ if (o.operation !== 'enqueue') continue;
4436
+ const c = typeof o.content === 'string' ? o.content.trim() : '';
4437
+ if (/^\/compact(?:\s|$)/.test(c))
4438
+ granted = true; // a user compaction preserves the work → grant
4439
+ else if (/^\/clear(?:\s|$)/.test(c))
4440
+ granted = false; // abandons context → invalidate
4441
+ else if (!c || c.startsWith('<task-notification>')) {
4442
+ /* model-caused / empty — neutral */
4443
+ } else if (isClosePattern(c)) {
4444
+ /* NL close via the queue — the open dequeue gap: the producer cannot be
4445
+ attributed (a peer/model enqueue wears the same shape), so no grant */
4446
+ } else granted = false; // a queued non-close user intent → invalidate (change of mind)
2725
4447
  continue;
2726
4448
  }
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;
4449
+
4450
+ // remove-path delivery of a queued natural-language command (measured: the
4451
+ // item leaves the queue as it is handed to the model, landing as an
4452
+ // `attachment` of type queued_command with the prompt verbatim). A close here
4453
+ // grants ONLY with an audited human producer — origin.kind "human", present
4454
+ // on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
4455
+ // cannot attest a producer, so it does not grant (fail-closed). A NON-close
4456
+ // queued command (e.g. "keep working") is a fresh user intent and INVALIDATES
4457
+ // a prior grant regardless of origin — that is what closes the re-close hole
4458
+ // where a queued "continue" after a close leaves the stale lease live.
4459
+ if (o.type === 'attachment' && o.attachment && o.attachment.type === 'queued_command') {
4460
+ const prompt = typeof o.attachment.prompt === 'string' ? o.attachment.prompt : '';
4461
+ const humanOrigin = !!(o.attachment.origin && o.attachment.origin.kind === 'human');
4462
+ if (isClosePattern(prompt)) {
4463
+ if (humanOrigin) granted = true;
4464
+ } else if (prompt) {
4465
+ granted = false;
4466
+ }
4467
+ continue;
2734
4468
  }
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);
4469
+
4470
+ // Record AskUserQuestion tool_use ids (assistant record, always precedes its
4471
+ // answer in line order). Bash ids ride along in the same scan, for the mint
4472
+ // correlation above.
4473
+ const content = (o.message ?? o).content;
4474
+ if (Array.isArray(content)) {
4475
+ for (const b of content) {
4476
+ if (!b || typeof b !== 'object' || b.type !== 'tool_use' || !b.id) continue;
4477
+ if (b.name === 'AskUserQuestion') askIds.add(b.id);
4478
+ else if (b.name === 'Bash') bashIds.add(b.id);
4479
+ }
4480
+ }
4481
+
4482
+ // Genuine user text → grant on a close phrase, invalidate on anything else,
4483
+ // minus the one carve-out for what the product itself asked the user to send.
4484
+ if (userText != null && userText !== '') {
4485
+ const spend = approvalLine.exec(userText.trim());
4486
+ if (spend && mintedNonces.has(spend[1])) {
4487
+ // The approval line `proposal challenge` tells the user to type, bound to a
4488
+ // nonce this transcript minted. Neutral, never a grant: an untouched
4489
+ // `granted` that was false stays false. An unminted hex is ordinary text and
4490
+ // falls through to the invalidating branch below.
2742
4491
  continue;
2743
4492
  }
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)) {
4493
+ granted = isClosePattern(userText);
4494
+ continue;
4495
+ }
4496
+
4497
+ // AskUserQuestion answer, correlated to a recorded AskUserQuestion, EXCLUDED
4498
+ // and HARDENED. isModelReachableRecord keeps an injected / sdk / sidechain
4499
+ // record from reaching the answer parser. is_error:false AND the host's
4500
+ // success marker are required because a malformed AskUserQuestion echoes the
4501
+ // raw input back in an is_error result, and the model authors the option
4502
+ // labels. A close selection grants; any other real selection invalidates.
4503
+ if (Array.isArray(content) && !isModelReachableRecord(o)) {
4504
+ for (const b of content) {
4505
+ if (!b || typeof b !== 'object') continue;
4506
+ if (b.type !== 'tool_result' || !b.tool_use_id || !askIds.has(b.tool_use_id)) continue;
4507
+ if (b.is_error === true) continue;
2750
4508
  const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
4509
+ if (!/have been answered/.test(s)) continue;
4510
+ let sawAnswer = false;
4511
+ let sawClose = false;
2751
4512
  for (const m of s.matchAll(/="([^"]*)"/g)) {
2752
- if (isClosePattern(m[1])) return true;
4513
+ sawAnswer = true;
4514
+ if (isClosePattern(m[1])) sawClose = true;
2753
4515
  }
4516
+ if (sawClose) granted = true;
4517
+ else if (sawAnswer) granted = false;
2754
4518
  }
2755
4519
  }
2756
4520
  }
2757
- return false;
4521
+ return granted;
4522
+ }
4523
+
4524
+ /** The literal the user must type to approve a parked-overwrite batch. */
4525
+ export const APPROVAL_PHRASE = 'apply-proposals';
4526
+
4527
+ /**
4528
+ * True iff the transcript carries a user's TYPED approval of the batch this nonce
4529
+ * was minted for — the authorization gate for a transcript-approved apply.
4530
+ *
4531
+ * Deliberately NOT hasUserCloseSignal. That function answers "did the user want to
4532
+ * end the session", and it accepts a correlated AskUserQuestion answer as evidence
4533
+ * (see above). Reusing it here would let a SESSION-CLOSE approval spend itself as an
4534
+ * OVERWRITE approval: different authority, different question. Two gates, two
4535
+ * matchers.
4536
+ *
4537
+ * Typed text only, and an AskUserQuestion click is refused ON PURPOSE. The model
4538
+ * authors the option labels, and it authors them AFTER it has seen the nonce — so
4539
+ * it could place `apply-proposals <nonce>` on a "no" option, on every option, or
4540
+ * under a question that asks something else entirely. A click would prove a click.
4541
+ * Typing the nonce proves the user produced THIS phrase.
4542
+ *
4543
+ * The nonce carries the freshness: it is minted (crypto-random) only once the diff
4544
+ * has been re-read from disk and shown, so a turn that predates the diff cannot
4545
+ * contain it, and `resolve` spends the challenge BEFORE it writes, so it cannot be
4546
+ * replayed.
4547
+ *
4548
+ * extractUserMessageRecords does the de-pollution: it drops `isMeta` bodies
4549
+ * (slash-command and skill text, so a doc that quotes this phrase cannot satisfy the
4550
+ * gate), `promptSource: system|sdk`, Stop-hook feedback, and `tool_result` blocks (so
4551
+ * neither a tool's output nor a Read of a file that contains the phrase counts).
4552
+ * The model's own words are role:assistant and never reach it.
4553
+ *
4554
+ * Fail-closed: no path, a nonce that is not the minted shape, or an unreadable
4555
+ * transcript all return false.
4556
+ *
4557
+ * @param {string} transcriptPath
4558
+ * @param {string} nonce hex, as minted by `proposal challenge`
4559
+ */
4560
+ export function hasTypedUserApproval(transcriptPath, nonce) {
4561
+ if (!transcriptPath || typeof nonce !== 'string') return false;
4562
+ // Pin the shape rather than accept any string: a caller that passed '' or a
4563
+ // regex-ish value would otherwise turn the match into a wildcard.
4564
+ if (!/^[a-f0-9]{32,}$/.test(nonce)) return false;
4565
+ // A MESSAGE that IS the phrase, not a message that has the phrase somewhere in it.
4566
+ // Line-exactness is not enough, because the user is TOLD to type this phrase and so
4567
+ // it is natural to quote it back while hesitating:
4568
+ //
4569
+ // I do not consent; I am only quoting the command:
4570
+ // apply-proposals <nonce>
4571
+ //
4572
+ // Every line-level matcher reads that as approval, and the user has authorized an
4573
+ // overwrite by refusing one. The whole eligible message must be the phrase and
4574
+ // nothing else, which is the bar the TTY channel has always held (`apply <id>`,
4575
+ // alone, on the prompt). The two channels must not disagree about what consent
4576
+ // looks like.
4577
+ //
4578
+ // Measured, not assumed: across 468 eligible user records in this project's
4579
+ // transcripts, none carried a second text block and none carried injected
4580
+ // system-reminder text, so a turn whose only content is the phrase survives
4581
+ // extraction as exactly the phrase. A false negative costs a retype; a false
4582
+ // positive costs the user's file.
4583
+ const want = `${APPROVAL_PHRASE} ${nonce}`;
4584
+ const records = extractUserMessageRecords(transcriptPath, Infinity);
4585
+ return records.some((msg) => msg.trim() === want);
2758
4586
  }
2759
4587
 
2760
4588
  /**
@@ -2876,7 +4704,8 @@ export function isCloseReconfirmDeclined(transcriptPath) {
2876
4704
  // system/sdk synthetic prompts, and (via the array-content branch below)
2877
4705
  // tool_result blocks, which are role:'user' in the transcript but are NOT
2878
4706
  // user-typed text.
2879
- const isInjected = obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
4707
+ const isInjected =
4708
+ obj.isMeta === true || obj.promptSource === 'system' || obj.promptSource === 'sdk';
2880
4709
  const role = msg.role ?? obj.role ?? obj.type;
2881
4710
  if (!isInjected && role === 'user') {
2882
4711
  if (typeof content === 'string') {
@@ -2962,7 +4791,15 @@ export function computeSessionGrowth(hypoDir) {
2962
4791
  // more expensive `git diff HEAD --unified=0` — Stop hook P95 win.
2963
4792
  // `-uall` expands untracked directories so a brand-new `pages/new.md`
2964
4793
  // isn't hidden behind a single `?? pages/` line.
2965
- const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], {
4794
+ // `-z`: NUL-separated, verbatim paths (see commitWikiChanges). The old
4795
+ // newline/quote-strip parser left octal escapes in place, so Korean page
4796
+ // names silently failed the `pages/`·`projects/` scope match and dropped
4797
+ // out of the growth count.
4798
+ // `-z`: NUL-separated, verbatim paths (see commitWikiChanges). The old
4799
+ // newline/quote-strip parser left octal escapes in place, so Korean page
4800
+ // names silently failed the `pages/`·`projects/` scope match and dropped
4801
+ // out of the growth count.
4802
+ const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall', '-z'], {
2966
4803
  encoding: 'utf-8',
2967
4804
  timeout: 5000,
2968
4805
  });
@@ -2976,10 +4813,14 @@ export function computeSessionGrowth(hypoDir) {
2976
4813
  // intentionally excluded — they're scaffolding, not page growth.
2977
4814
  const inPagesScope = (file) =>
2978
4815
  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();
4816
+ const records = (porcelain.stdout || '').split('\0');
4817
+ for (let i = 0; i < records.length; i++) {
4818
+ const rec = records[i];
4819
+ if (!rec) continue;
4820
+ const xy = rec.slice(0, 2);
4821
+ const file = rec.slice(3); // destination path for a rename/copy
4822
+ // A rename OR copy emits two records (`to\0from`); consume the trailing `from`.
4823
+ if (xy[0] === 'R' || xy[1] === 'R' || xy[0] === 'C' || xy[1] === 'C') i++;
2983
4824
  if (!inPagesScope(file)) continue;
2984
4825
  if (xy === '??') {
2985
4826
  untrackedMd.push(file);
@@ -2987,7 +4828,8 @@ export function computeSessionGrowth(hypoDir) {
2987
4828
  continue;
2988
4829
  }
2989
4830
  hasTrackedMdChange = true;
2990
- if (xy.includes('A')) addedPages++;
4831
+ // A copy's destination is a brand-new page, so it counts as added like `A`.
4832
+ if (xy.includes('A') || xy.includes('C')) addedPages++;
2991
4833
  else if (xy.includes('M') || xy.includes('R')) updatedPages++;
2992
4834
  }
2993
4835
  if (!hasTrackedMdChange && untrackedMd.length === 0) return empty;
@@ -3079,3 +4921,65 @@ export function isIgnored(filePath, hypoDir, patterns) {
3079
4921
  }
3080
4922
  return false;
3081
4923
  }
4924
+
4925
+ // ── visibility scope ─────────────────────────────────────────────────────────
4926
+ // The machine-scoped visibility namespace (`visibility_scope: machine:<device>`)
4927
+ // requires that the SAME device string is produced at write time (audit device
4928
+ // stamps) and at lookup time (the visibility filter) — otherwise a page never
4929
+ // matches its own machine. So this is the single source for both. NOT cached:
4930
+ // each call reads env fresh so in-process tests can override via HYPO_DEVICE
4931
+ // (os.hostname is not mockable). CR/LF are stripped so the value stays a single
4932
+ // frontmatter token.
4933
+ export function currentDevice() {
4934
+ // Strip CR/LF BEFORE the fallback chain, not after: a HYPO_DEVICE of only
4935
+ // CR/LF is truthy, so stripping after `||` would yield '' and make
4936
+ // scopeVisible('machine:', '') pass — the empty-owner page must hide
4937
+ // everywhere. Stripping first collapses such a value to '' so it falls through
4938
+ // to hostname, keeping the result non-empty on every path.
4939
+ const env = String(process.env.HYPO_DEVICE || '').replace(/[\r\n]/g, '');
4940
+ if (env) return env;
4941
+ const host = String(hostname() || '').replace(/[\r\n]/g, '');
4942
+ return host || 'unknown';
4943
+ }
4944
+
4945
+ // Extract the top-level `visibility_scope` from a page's raw content. Mirrors
4946
+ // scripts/lib/frontmatter.mjs normalization (top-level only, first-wins, strip a
4947
+ // whitespace-led trailing YAML comment, strip surrounding quotes) rather than
4948
+ // importing it: hooks deploy to ~/.claude/hooks/ with no external imports. The
4949
+ // five consumers must call this instead of a local last-wins/no-comment parser,
4950
+ // else a `machine:devA # note` value fails to match on its own machine. Returns
4951
+ // '' when absent (which scopeVisible treats as shared).
4952
+ export function readVisibilityScope(raw) {
4953
+ const m = String(raw || '').match(/^---\r?\n([\s\S]*?)\r?\n---/);
4954
+ if (!m) return '';
4955
+ for (const line of m[1].split(/\r?\n/)) {
4956
+ if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
4957
+ const idx = line.indexOf(':');
4958
+ if (idx < 0) continue;
4959
+ if (line.slice(0, idx).trim() !== 'visibility_scope') continue;
4960
+ return line
4961
+ .slice(idx + 1)
4962
+ .trim()
4963
+ .replace(/\s+#.*$/, '')
4964
+ .replace(/^["']|["']$/g, '');
4965
+ }
4966
+ return '';
4967
+ }
4968
+
4969
+ // The single visibility decision, shared by lookup / query / file-watch /
4970
+ // page-usage / crystallize. `scopeValue` is a readVisibilityScope() output,
4971
+ // `device` a currentDevice() output. Prefix dispatch, fail-open on anything
4972
+ // unrecognized so the field is purely additive:
4973
+ // ''/'shared' → visible (the implicit default of every pre-existing page)
4974
+ // 'machine:<owner>' → visible only on the owning machine. Empty owner
4975
+ // (`machine:`) hides everywhere: '' can never equal
4976
+ // currentDevice()'s non-empty fallback.
4977
+ // 'agent:<id>' → visible; value space reserved, no writer yet (forward-compat)
4978
+ // anything else → visible (fail-open)
4979
+ export function scopeVisible(scopeValue, device) {
4980
+ const v = String(scopeValue || '').trim();
4981
+ if (v === '' || v === 'shared') return true;
4982
+ if (v.startsWith('machine:')) return v.slice('machine:'.length) === device;
4983
+ if (v.startsWith('agent:')) return true;
4984
+ return true;
4985
+ }