hypomnema 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +10 -10
  4. package/README.md +10 -10
  5. package/commands/crystallize.md +8 -8
  6. package/commands/feedback.md +1 -1
  7. package/commands/resume.md +1 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/CONTRIBUTING.md +2 -2
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  11. package/hooks/hypo-compact-guard.mjs +1 -1
  12. package/hooks/hypo-cwd-change.mjs +1 -1
  13. package/hooks/hypo-first-prompt.mjs +2 -2
  14. package/hooks/hypo-hot-rebuild.mjs +14 -1
  15. package/hooks/hypo-personal-check.mjs +91 -179
  16. package/hooks/hypo-pre-commit.mjs +1 -1
  17. package/hooks/hypo-session-end.mjs +1 -1
  18. package/hooks/hypo-session-start.mjs +7 -7
  19. package/hooks/hypo-shared.mjs +788 -72
  20. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  21. package/hooks/version-check-fetch.mjs +2 -8
  22. package/hooks/version-check.mjs +18 -0
  23. package/package.json +3 -2
  24. package/scripts/check-tracker-ids.mjs +329 -0
  25. package/scripts/crystallize.mjs +244 -109
  26. package/scripts/doctor.mjs +6 -9
  27. package/scripts/feedback-sync.mjs +1 -1
  28. package/scripts/init.mjs +1 -1
  29. package/scripts/install-git-hooks.mjs +75 -40
  30. package/scripts/lib/check-tracker-ids.mjs +140 -0
  31. package/scripts/lib/design-history-stale.mjs +59 -14
  32. package/scripts/lib/extensions.mjs +4 -4
  33. package/scripts/lib/fix-manifest.mjs +2 -2
  34. package/scripts/lib/fix-status-verify.mjs +5 -4
  35. package/scripts/lib/plugin-detect.mjs +15 -6
  36. package/scripts/lib/project-create.mjs +1 -1
  37. package/scripts/lint.mjs +63 -8
  38. package/scripts/rename.mjs +373 -0
  39. package/scripts/resume.mjs +75 -19
  40. package/scripts/uninstall.mjs +1 -1
  41. package/scripts/upgrade.mjs +26 -25
  42. package/skills/crystallize/SKILL.md +10 -7
  43. package/templates/SCHEMA.md +2 -2
  44. package/templates/hypo-config.md +2 -2
  45. package/templates/hypo-guide.md +20 -1
  46. package/templates/projects/_template/index.md +1 -1
@@ -6,7 +6,15 @@
6
6
  * Hooks are deployed to ~/.claude/hooks/ — no external imports allowed.
7
7
  */
8
8
 
9
- import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, rmSync } from 'fs';
9
+ import {
10
+ readFileSync,
11
+ writeFileSync,
12
+ existsSync,
13
+ appendFileSync,
14
+ mkdirSync,
15
+ rmSync,
16
+ readdirSync,
17
+ } from 'fs';
10
18
  import { join, relative, basename } from 'path';
11
19
  import { homedir, hostname, tmpdir } from 'os';
12
20
  import { spawnSync } from 'child_process';
@@ -138,9 +146,10 @@ export function hypoIsClean(dir = HYPO_DIR) {
138
146
  }
139
147
  }
140
148
 
141
- export function hotMdIsClean() {
142
- if (!existsSync(HOT_PATH)) return { clean: true };
143
- const content = readFileSync(HOT_PATH, 'utf-8');
149
+ export function hotMdIsClean(dir = HYPO_DIR) {
150
+ const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
151
+ if (!existsSync(hotPath)) return { clean: true };
152
+ const content = readFileSync(hotPath, 'utf-8');
144
153
  const reasons = [];
145
154
 
146
155
  // Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
@@ -163,7 +172,7 @@ export function hotMdIsClean() {
163
172
  // spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
164
173
  // 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
165
174
  // (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
166
- // project hot.md, root hot.md, session-log/YYYY-MM.md, and log.md.
175
+ // project hot.md, root hot.md, session-log/YYYY-MM-DD.md, and log.md.
167
176
  // pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
168
177
  // cross-project queue, so a session that raises no questions should not be
169
178
  // forced to touch it. Gating it would produce false-blocks; spec §5.2.7
@@ -173,7 +182,7 @@ export function hotMdIsClean() {
173
182
  // so a second session on the same day that skips updating a file still passes
174
183
  // if an earlier close that day already stamped it. freshDates() accepting both
175
184
  // local and UTC dates widens that window by up to one UTC offset. A per-session
176
- // boundary is out of scope for fix #17.
185
+ // boundary is out of scope for the strict session-close check.
177
186
 
178
187
  /** Parse the frontmatter `updated:` field. Returns the trimmed value or null. */
179
188
  function frontmatterUpdated(content) {
@@ -198,6 +207,56 @@ export function hasSessionLogHeading(content, date) {
198
207
  return new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]', 'm').test(content || '');
199
208
  }
200
209
 
210
+ /**
211
+ * Canonical session-log shard path (repo-relative POSIX) for a single day.
212
+ * ADR 0050 / option D: the date IS the filename (`YYYY-MM-DD.md`), so no
213
+ * "current part" resolver is needed and the per-session write touches a small
214
+ * file instead of a multi-thousand-line monthly log. This is the WRITE target.
215
+ */
216
+ export function sessionLogShardPath(project, date) {
217
+ return `projects/${project}/session-log/${date}.md`;
218
+ }
219
+
220
+ /**
221
+ * Read candidates for a date's session-log entry, in priority order:
222
+ * 1. the daily shard `YYYY-MM-DD.md` (canonical, small)
223
+ * 2. the legacy monthly `YYYY-MM.md` (pre-shard history is never split)
224
+ * Callers iterate and short-circuit on the first file carrying the dated
225
+ * heading, so once a daily shard exists the large monthly file is never read —
226
+ * which is where the per-close token saving comes from. The monthly fallback
227
+ * keeps pre-cutover months (and a hybrid cutover month) resolving correctly
228
+ * without a retroactive split.
229
+ */
230
+ export function sessionLogReadCandidates(project, date) {
231
+ return [
232
+ sessionLogShardPath(project, date),
233
+ `projects/${project}/session-log/${date.slice(0, 7)}.md`,
234
+ ];
235
+ }
236
+
237
+ /**
238
+ * The session-log file the close gate should hold accountable for `date` — i.e.
239
+ * the read candidate that ACTUALLY carries a today-dated heading (daily shard
240
+ * preferred), or the daily write target when none does yet. The freshness gate
241
+ * accepts a today-heading found in the legacy monthly file via fallback; if the
242
+ * lint scope only ever named the daily shard, a close could pass on a *corrupt*
243
+ * monthly evidence file while that file's lint error was demoted to a
244
+ * non-blocking notice (out of scope). Scoping to the resolved evidence file
245
+ * closes that gap: whatever file the gate trusts as proof, lint judges too.
246
+ */
247
+ export function sessionLogScopePath(hypoDir, project, date) {
248
+ for (const rel of sessionLogReadCandidates(project, date)) {
249
+ const full = join(hypoDir, rel);
250
+ if (!existsSync(full)) continue;
251
+ try {
252
+ if (hasSessionLogHeading(readFileSync(full, 'utf-8'), date)) return rel;
253
+ } catch {
254
+ /* unreadable candidate — keep looking */
255
+ }
256
+ }
257
+ return sessionLogShardPath(project, date);
258
+ }
259
+
201
260
  /**
202
261
  * True if `content` carries a today-dated `## [date] session | <project>` entry
203
262
  * in log.md.
@@ -217,6 +276,31 @@ export function hasLogEntry(content, date, project) {
217
276
  ).test(content || '');
218
277
  }
219
278
 
279
+ /**
280
+ * True when log.md carries ANY `## [<freshDate>] session | <slug>` heading —
281
+ * project-agnostic. The minimum proof for a log-only close: a
282
+ * non-project (tooling/wiki-only) session has no project mandatory files, but it
283
+ * MUST still leave a today log.md trace so the close is not a content-free bypass.
284
+ * Uses the same canonical heading parser as hasLogEntry (not a loose substring —
285
+ * codex design review: keep the fresh-date/canonical-heading style) so a stray
286
+ * mention of the date elsewhere cannot satisfy it.
287
+ * @param {string} hypoDir
288
+ * @returns {boolean}
289
+ */
290
+ export function hasAnyTodayLogEntry(hypoDir) {
291
+ const logPath = join(hypoDir, 'log.md');
292
+ if (!existsSync(logPath)) return false;
293
+ let content = '';
294
+ try {
295
+ content = readFileSync(logPath, 'utf-8');
296
+ } catch {
297
+ return false;
298
+ }
299
+ return freshDates().some((d) =>
300
+ new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| \\S', 'm').test(content),
301
+ );
302
+ }
303
+
220
304
  /**
221
305
  * Date strings that count as "today" for freshness checks. Both the local and
222
306
  * UTC dates are accepted: Claude writes file dates in the user's local zone,
@@ -247,7 +331,8 @@ function parseFrontmatterField(content, key) {
247
331
 
248
332
  // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
249
333
  // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
250
- // when cwd is falsy or matches none. Used only as a same-date tie-breaker.
334
+ // when cwd is falsy or matches none. resume gives this authority OVER recency
335
+ // (ADR 0044); close callers never pass cwd, so it stays inert for them.
251
336
  function pickByCwd(hypoDir, slugs, cwd) {
252
337
  if (!cwd) return null;
253
338
  let best = null;
@@ -268,15 +353,18 @@ function pickByCwd(hypoDir, slugs, cwd) {
268
353
  }
269
354
 
270
355
  /**
271
- * Resolve the most-recently-active project slug from root hot.md.
272
- * The cwd helpers (parseFrontmatterField / pickByCwd) and the same-date
273
- * tie-break are kept in sync with scripts/resume.mjs by hand; the surrounding
274
- * wrapper intentionally differs (resume.mjs adds an mtime fallback, this does not).
275
- * `cwd` is an optional same-date tie-breaker (ISSUE-1): resume passes
276
- * process.cwd(); session-close callers (sessionCloseFileStatus /
277
- * closeFileTargets) intentionally pass null close has a different
278
- * authority (payload.project / freshness), tracked separately as ISSUE-7.
279
- * When cwd is omitted, behavior is identical to the legacy version.
356
+ * Resolve the active project slug from root hot.md. With a cwd, a project whose
357
+ * working_dir contains it wins (cwd-first, ADR 0044); otherwise the
358
+ * most-recently-active row is returned.
359
+ * The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
360
+ * are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
361
+ * intentionally differs (resume.mjs adds an mtime fallback, this does not).
362
+ * `cwd` is an optional cwd-first selector (ADR 0044): a cwd↔working_dir match
363
+ * wins over recency. resume passes process.cwd(); session-close callers
364
+ * (sessionCloseFileStatus / closeFileTargets) intentionally pass null close
365
+ * has a different authority (payload.project / freshness, the global invariant
366
+ * of ADR 0043), so it never picks by cwd. When cwd is omitted, behavior is
367
+ * identical to the legacy recency version.
280
368
  * @param {string} hypoDir
281
369
  * @param {string|null} [cwd]
282
370
  * @returns {string|null}
@@ -299,41 +387,48 @@ export function resolveActiveProject(hypoDir, cwd = null) {
299
387
  ),
300
388
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
301
389
  if (wikiRows.length > 0) {
302
- wikiRows.sort((a, b) => b.date.localeCompare(a.date));
303
- // Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
304
- // prefer the project whose working_dir contains cwd. No cwd / no match →
305
- // keep the stable-sort winner (the legacy "first table row" behavior).
306
- const topDate = wikiRows[0].date;
307
- const tied = wikiRows.filter((r) => r.date === topDate);
308
- if (cwd && tied.length > 1) {
390
+ // cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
391
+ // ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null
392
+ // recency path below (resume=cwd-positive / close=no-pick).
393
+ if (cwd) {
309
394
  const picked = pickByCwd(
310
395
  hypoDir,
311
- tied.map((r) => r.slug),
396
+ wikiRows.map((r) => r.slug),
312
397
  cwd,
313
398
  );
314
399
  if (picked) return picked;
315
400
  }
401
+ // No cwd match → most recent by date (stable-sort keeps the first table row
402
+ // on a tie, the legacy behavior).
403
+ wikiRows.sort((a, b) => b.date.localeCompare(a.date));
316
404
  return wikiRows[0].slug;
317
405
  }
318
406
  // Legacy markdown-link rows: | [name](projects/name/...) | ...
319
- const mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
320
- if (mdRow) return mdRow[2];
407
+ const mdSlugs = [...content.matchAll(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/g)].map((m) => m[2]);
408
+ if (mdSlugs.length > 0) {
409
+ if (cwd) {
410
+ const picked = pickByCwd(hypoDir, mdSlugs, cwd);
411
+ if (picked) return picked;
412
+ }
413
+ return mdSlugs[0]; // legacy: first table row
414
+ }
321
415
  return null;
322
416
  }
323
417
 
324
418
  /**
325
- * Strict session-close verification (fix #17, spec §5.2.7 / §8.3).
419
+ * Strict session-close verification (spec §5.2.7 / §8.3).
326
420
  * Confirms the memory files a session close must touch were updated today:
327
421
  * - projects/<project>/session-state.md — frontmatter `updated:` is today
328
422
  * - projects/<project>/hot.md — frontmatter `updated:` is today
329
423
  * - hot.md (root) — frontmatter `updated:` is today
330
- * - projects/<project>/session-log/YYYY-MM.md — has a `## [today]` heading
424
+ * - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
425
+ * (daily shard, ADR 0050; legacy YYYY-MM.md is still accepted as fallback)
331
426
  * - log.md — has a `## [today] session | <project>` entry
332
427
  * The log.md check is project-scoped so a session close left incomplete for
333
428
  * project A can't be masked by a fresh close of project B (and vice versa).
334
429
  * open-questions.md (file #5) is conditional and not gated.
335
430
  *
336
- * `projectOverride` (ISSUE-7 Part A): when the caller already holds the
431
+ * `projectOverride` (same-date-tie fix): when the caller already holds the
337
432
  * authoritative project being closed (e.g. crystallize apply derives it from
338
433
  * `payload.project`), it passes that slug so verification checks the SAME
339
434
  * project it just wrote — instead of re-deriving via resolveActiveProject(),
@@ -381,25 +476,30 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
381
476
  checkUpdated(join('projects', project, 'hot.md'));
382
477
  checkUpdated('hot.md');
383
478
 
384
- // session-log: monthly append-only file must carry a today-dated heading.
385
- // Reported under the local date's month (dates[0]) when no match is found.
479
+ // session-log: daily shard (ADR 0050), with legacy monthly fallback — must
480
+ // carry a today-dated heading in whichever file holds it. Daily-first read
481
+ // order short-circuits on the small shard. When no match is found, the gap is
482
+ // reported under the canonical daily shard for the local date (dates[0]).
386
483
  let sessionLogOk = false;
387
484
  for (const date of dates) {
388
- const full = join(hypoDir, 'projects', project, 'session-log', `${date.slice(0, 7)}.md`);
389
- if (!existsSync(full)) continue;
390
- let content = '';
391
- try {
392
- content = readFileSync(full, 'utf-8');
393
- } catch {
394
- continue;
395
- }
396
- if (hasSessionLogHeading(content, date)) {
397
- sessionLogOk = true;
398
- break;
485
+ for (const rel of sessionLogReadCandidates(project, date)) {
486
+ const full = join(hypoDir, rel);
487
+ if (!existsSync(full)) continue;
488
+ let content = '';
489
+ try {
490
+ content = readFileSync(full, 'utf-8');
491
+ } catch {
492
+ continue;
493
+ }
494
+ if (hasSessionLogHeading(content, date)) {
495
+ sessionLogOk = true;
496
+ break;
497
+ }
399
498
  }
499
+ if (sessionLogOk) break;
400
500
  }
401
501
  if (!sessionLogOk) {
402
- const logRel = join('projects', project, 'session-log', `${dates[0].slice(0, 7)}.md`);
502
+ const logRel = sessionLogShardPath(project, dates[0]);
403
503
  (existsSync(join(hypoDir, logRel)) ? stale : missing).push(logRel);
404
504
  }
405
505
 
@@ -421,11 +521,292 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
421
521
  return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
422
522
  }
423
523
 
524
+ // ── global session-close gate (ADR 0043) ────
525
+ // The no-payload close paths must NOT pick one project (recency / cwd) and check
526
+ // it — that re-derivation is the prior session-close false-block, and a cwd
527
+ // tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
528
+ // close. Instead the gate enforces a global invariant: no project may end a
529
+ // session with a partial close. resume stays cwd-positive (ADR 0044); close
530
+ // never picks. The two copies of resolveActiveProject share the cwd-first body
531
+ // but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
532
+
533
+ // Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
534
+ // project-scoped (unlike the shared frontmatter `updated:`), so a today-dated
535
+ // row is a legitimate close-activity signal. Mirrors resolveActiveProject's regex.
536
+ function rootHotRows(hypoDir) {
537
+ const hotPath = join(hypoDir, 'hot.md');
538
+ if (!existsSync(hotPath)) return [];
539
+ let content;
540
+ try {
541
+ content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
542
+ } catch {
543
+ return [];
544
+ }
545
+ return [
546
+ ...content.matchAll(
547
+ /\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
548
+ ),
549
+ ].map((m) => ({ slug: m[3], date: m[2] || '' }));
550
+ }
551
+
552
+ // Candidate slugs the global gate must consider: real project dirs (with a
553
+ // session-state.md, skip _template) ∪ slugs in a today-dated `## [today] session
554
+ // | P` log.md entry ∪ slugs in a today-dated root hot.md row. The log/row unions
555
+ // catch a dangling close whose own project files are missing —
556
+ // sessionCloseFileStatus(projectOverride) reports those as `missing` correctly.
557
+ function closeCandidateSlugs(hypoDir, dates) {
558
+ const slugs = new Set();
559
+ const projectsDir = join(hypoDir, 'projects');
560
+ if (existsSync(projectsDir)) {
561
+ let entries = [];
562
+ try {
563
+ entries = readdirSync(projectsDir);
564
+ } catch {
565
+ entries = [];
566
+ }
567
+ for (const p of entries) {
568
+ if (p === '_template') continue;
569
+ if (existsSync(join(projectsDir, p, 'session-state.md'))) slugs.add(p);
570
+ }
571
+ }
572
+ for (const r of rootHotRows(hypoDir)) {
573
+ if (r.date && dates.includes(r.date)) slugs.add(r.slug);
574
+ }
575
+ const logPath = join(hypoDir, 'log.md');
576
+ if (existsSync(logPath)) {
577
+ let content = '';
578
+ try {
579
+ content = readFileSync(logPath, 'utf-8');
580
+ } catch {
581
+ content = '';
582
+ }
583
+ for (const d of dates) {
584
+ const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
585
+ for (const m of content.matchAll(re)) slugs.add(m[1]);
586
+ }
587
+ }
588
+ return slugs;
589
+ }
590
+
591
+ // True when project P shows ANY today close-activity signal: session-state or
592
+ // project hot.md frontmatter `updated:` today, a today-dated session-log heading,
593
+ // a today log.md `session | P` entry, or a today-dated root hot.md row for P.
594
+ // (Root hot.md *frontmatter* is shared and is NOT a signal; the per-project ROW
595
+ // date is.)
596
+ function hasTodayCloseActivity(hypoDir, project, dates) {
597
+ const fresh = (rel) => {
598
+ const full = join(hypoDir, rel);
599
+ if (!existsSync(full)) return false;
600
+ try {
601
+ return dates.includes(frontmatterUpdated(readFileSync(full, 'utf-8')));
602
+ } catch {
603
+ return false;
604
+ }
605
+ };
606
+ if (fresh(join('projects', project, 'session-state.md'))) return true;
607
+ if (fresh(join('projects', project, 'hot.md'))) return true;
608
+ for (const d of dates) {
609
+ for (const rel of sessionLogReadCandidates(project, d)) {
610
+ const sl = join(hypoDir, rel);
611
+ if (!existsSync(sl)) continue;
612
+ try {
613
+ if (hasSessionLogHeading(readFileSync(sl, 'utf-8'), d)) return true;
614
+ } catch {
615
+ /* skip */
616
+ }
617
+ }
618
+ }
619
+ const logPath = join(hypoDir, 'log.md');
620
+ if (existsSync(logPath)) {
621
+ try {
622
+ const c = readFileSync(logPath, 'utf-8');
623
+ if (dates.some((d) => hasLogEntry(c, d, project))) return true;
624
+ } catch {
625
+ /* skip */
626
+ }
627
+ }
628
+ for (const r of rootHotRows(hypoDir)) {
629
+ if (r.slug === project && r.date && dates.includes(r.date)) return true;
630
+ }
631
+ return false;
632
+ }
633
+
634
+ /**
635
+ * Global session-close status for the no-payload close paths (ADR 0043).
636
+ * Checks EVERY project with today close-activity; ok only when all are complete.
637
+ * When no project has today activity, falls back to the legacy single recency
638
+ * project (preserves "force the initial close" behavior, byte-identical gate).
639
+ *
640
+ * stale/missing entries are self-describing paths — project-specific files carry
641
+ * `projects/<slug>/…`, root files (`hot.md`/`log.md`) are shared — so the flat
642
+ * aliases need no project prefix and a single-project session is byte-identical
643
+ * to sessionCloseFileStatus (back-compat for the flat-field readers).
644
+ *
645
+ * @param {string} hypoDir
646
+ * @returns {{ok: boolean, projects: Array<{project:string, ok:boolean, stale:string[], missing:string[]}>,
647
+ * dates: string[], fallback: boolean, primary: string|null,
648
+ * project: string|null, stale: string[], missing: string[]}}
649
+ */
650
+ export function sessionCloseGlobalStatus(hypoDir) {
651
+ const dates = freshDates();
652
+ const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
653
+ const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
654
+ hasTodayCloseActivity(hypoDir, p, dates),
655
+ );
656
+
657
+ if (todayActive.length === 0) {
658
+ const legacy = sessionCloseFileStatus(hypoDir);
659
+ return {
660
+ ok: legacy.ok,
661
+ projects: legacy.project
662
+ ? [{ project: legacy.project, ok: legacy.ok, stale: legacy.stale, missing: legacy.missing }]
663
+ : [],
664
+ dates,
665
+ fallback: true,
666
+ primary: legacy.project,
667
+ project: legacy.project,
668
+ stale: legacy.stale,
669
+ missing: legacy.missing,
670
+ };
671
+ }
672
+
673
+ // primary = the recency project when it is itself today-active, else the first
674
+ // today-active slug (stable order from the candidate set). Used only as the
675
+ // single-slug alias (marker `project` field, message header) — never to gate.
676
+ const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
677
+ const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
678
+
679
+ const projects = ordered.map((p) => {
680
+ const s = sessionCloseFileStatus(hypoDir, { projectOverride: p });
681
+ return { project: p, ok: s.ok, stale: s.stale, missing: s.missing };
682
+ });
683
+ const ok = projects.every((x) => x.ok);
684
+ const stale = [...new Set(projects.flatMap((x) => x.stale))];
685
+ const missing = [...new Set(projects.flatMap((x) => x.missing))];
686
+ return { ok, projects, dates, fallback: false, primary, project: primary, stale, missing };
687
+ }
688
+
689
+ // ── derivable-artifact auto-derive: root log.md session entry ──────────────────
690
+ // The root log.md `## [date] session | <slug>` entry is a DERIVABLE artifact —
691
+ // it restates a project's session-log heading, which the close already authored.
692
+ // root hot.md is already auto-derived here (rebuild() above); log.md was the only
693
+ // derivable still left as a manual checklist step, so a hand-edited close that
694
+ // skipped it left the global gate (sessionCloseGlobalStatus) hard-blocking /compact
695
+ // for EVERY today-active project — cross-session, looking like a fresh bug each
696
+ // time. This derives the missing entry from the session-log heading so the gate
697
+ // never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
698
+ // here: it is a proof artifact the close gate actually ran (ADR 0022 invariant).
699
+ //
700
+ // Safety guard (codex design review): derive ONLY for a project whose close is
701
+ // otherwise complete — i.e. its sole gate problem is log.md. If session-state /
702
+ // project hot / session-log are also stale/missing, the authored close is genuinely
703
+ // incomplete and MUST keep blocking; deriving log.md then would mask it.
704
+
705
+ // Build the canonical root log.md heading from a raw session-log heading tail.
706
+ // The gate's session-log freshness check accepts ANY `## [date] ...` heading, but
707
+ // the log.md check requires `## [date] session | <slug>` — so normalise to that
708
+ // shape rather than copying a heading that might not carry `session | <slug>`.
709
+ function deriveLogTitle(tail) {
710
+ let t = (tail || '').trim();
711
+ t = t.replace(/^session\b\s*/i, ''); // drop a leading "session" token
712
+ if (t.startsWith('|')) {
713
+ // Drop a leading "| <old label/slug>" segment up to the first em-dash, so a
714
+ // renamed-project heading (`| oldslug — title`) does not leak its old slug
715
+ // into the derived title. A pipe segment with no separator is a bare label
716
+ // (e.g. `| slug`) → no title.
717
+ const dash = t.indexOf('—');
718
+ t = dash === -1 ? '' : t.slice(dash);
719
+ }
720
+ return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
721
+ }
722
+
723
+ /**
724
+ * Append any missing root log.md `## [date] session | <slug>` entries derived from
725
+ * each today-active project's session-log heading(s). Idempotent: dedups on the
726
+ * exact generated heading line, so re-running (or a same-day apply that already
727
+ * wrote the entry) is a no-op, and multiple same-day sessions each get their own
728
+ * entry. Best-effort and read-mostly: returns the number of entries appended.
729
+ *
730
+ * @param {string} hypoDir
731
+ * @returns {number} count of entries appended to log.md
732
+ */
733
+ export function deriveRootLogEntries(hypoDir) {
734
+ const logPath = join(hypoDir, 'log.md');
735
+ if (!existsSync(logPath)) return 0;
736
+ const dates = freshDates();
737
+ const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
738
+ hasTodayCloseActivity(hypoDir, p, dates),
739
+ );
740
+ if (todayActive.length === 0) return 0;
741
+
742
+ let logContent;
743
+ try {
744
+ logContent = readFileSync(logPath, 'utf-8');
745
+ } catch {
746
+ return 0;
747
+ }
748
+
749
+ // Exact-LINE dedup: a titleless heading (`## [d] session | a`) is a substring/
750
+ // prefix of a titled one (`## [d] session | a — first`), so substring checks
751
+ // would wrongly drop a distinct same-day session. Track whole heading lines.
752
+ const seenHeadings = new Set((logContent || '').split(/\r?\n/));
753
+ const additions = [];
754
+ for (const slug of todayActive) {
755
+ // Guard: only the log.md entry may be the gap; an otherwise-incomplete close
756
+ // must keep blocking (do not mask missing authored files).
757
+ const st = sessionCloseFileStatus(hypoDir, { projectOverride: slug });
758
+ const problems = [...st.stale, ...st.missing];
759
+ if (!(problems.length === 1 && problems[0] === 'log.md')) continue;
760
+
761
+ for (const date of dates) {
762
+ // Pick the candidate that actually CARRIES this date's heading (daily shard
763
+ // preferred), mirroring the freshness gate's resolution. Selecting merely
764
+ // the first *existing* file would diverge from freshness: a header-only
765
+ // daily shard (e.g. a seeded-but-not-yet-appended file) would be chosen and
766
+ // the real heading in the legacy monthly fallback missed — freshness would
767
+ // pass while derive failed to recover the log.md entry.
768
+ let slog = null;
769
+ for (const rel of sessionLogReadCandidates(slug, date)) {
770
+ const slogPath = join(hypoDir, rel);
771
+ if (!existsSync(slogPath)) continue;
772
+ let content;
773
+ try {
774
+ content = readFileSync(slogPath, 'utf-8');
775
+ } catch {
776
+ continue;
777
+ }
778
+ if (!hasSessionLogHeading(content, date)) continue;
779
+ slog = content;
780
+ break;
781
+ }
782
+ if (slog === null) continue;
783
+ const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
784
+ let m;
785
+ while ((m = headingRe.exec(slog)) !== null) {
786
+ const title = deriveLogTitle(m[1]);
787
+ const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
788
+ if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
789
+ seenHeadings.add(heading);
790
+ additions.push(`${heading}\n→ [[projects/${slug}/hot]]`);
791
+ }
792
+ }
793
+ }
794
+
795
+ if (additions.length === 0) return 0;
796
+ const sep = logContent.endsWith('\n') ? '\n' : '\n\n';
797
+ try {
798
+ writeFileSync(logPath, logContent + sep + additions.join('\n\n') + '\n');
799
+ } catch {
800
+ return 0;
801
+ }
802
+ return additions.length;
803
+ }
804
+
424
805
  // ── sync-state ────────────────────────────────────────────
425
806
  // `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
426
- // line. hypo-auto-commit (fix #9) appends on pull/push failure; hypo-session-start
427
- // (fix #10) surfaces open entries and clears them once sync is healthy again;
428
- // doctor (fix #11) warns while entries remain. Keep the schema defined here only.
807
+ // line. hypo-auto-commit appends on pull/push failure; hypo-session-start
808
+ // surfaces open entries and clears them once sync is healthy again;
809
+ // doctor warns while entries remain. Keep the schema defined here only.
429
810
 
430
811
  /** @returns {string} path to the sync-state JSONL file for a wiki root. */
431
812
  function syncStatePath(hypoDir) {
@@ -727,19 +1108,25 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
727
1108
  *
728
1109
  * @param {string} hypoDir
729
1110
  * @param {string} sessionId
730
- * @param {{project?: string, transcript_path?: string}} info
1111
+ * @param {{project?: string, scope?: string, transcript_path?: string}} info
731
1112
  */
732
1113
  export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
733
1114
  if (!sessionId) return;
734
1115
  try {
735
1116
  const cacheDir = join(hypoDir, '.cache');
736
1117
  if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
1118
+ // scope distinguishes a project close (the 5 mandatory files were verified
1119
+ // fresh) from a log-only close (a non-project session, no project
1120
+ // attribution). Readers (precompactGateStatus / --check-session-close) key
1121
+ // the gate semantics on this field, so it must be recorded.
1122
+ const scope = info.scope === 'log-only' ? 'log-only' : 'project';
737
1123
  const payload = {
738
1124
  session_id: sessionId,
739
1125
  project: info.project || null,
1126
+ scope,
740
1127
  transcript_path: info.transcript_path || null,
741
1128
  closed_at: new Date().toISOString(),
742
- verification: 'session-close-file-status:ok',
1129
+ verification: scope === 'log-only' ? 'log-only-close:ok' : 'session-close-file-status:ok',
743
1130
  };
744
1131
  writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
745
1132
  } catch (err) {
@@ -790,15 +1177,33 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
790
1177
  }
791
1178
  }
792
1179
 
793
- // ── transcript activity heuristic (ADR 0022 amendment 2026-05-19) ──
794
- // Substantial-session gate for the Stop hook: a session that performed at least
795
- // one mutation tool call (Edit / Write / MultiEdit / NotebookEdit) is "worth"
796
- // blocking on for session-close. Pure Q&A / read-only sessions skip the block.
1180
+ // ── transcript activity heuristic (ADR 0022 amendment 2026-05-19; 6a 2026-06-14) ──
1181
+ // Substantial-session gate for the Stop hook: a session "worth" blocking on for
1182
+ // session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
1183
+ // or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
1184
+ // Read/Grep/Glob/Bash calls). Pure Q&A / incidental lookups skip the block.
797
1185
  //
798
- // Bash is intentionally excluded running tests would otherwise trigger
799
- // block. Future fix may broaden to read-heavy sessions (Grep N).
1186
+ // 6a rationale: code-review / debugging sessions reach real conclusions worth
1187
+ // crystallizing while touching only read-only tools. The original gate keyed
1188
+ // purely on mutation tools, so those sessions were never nudged to close. The
1189
+ // investigation threshold (5) mirrors session-audit.mjs's "search-many" cutoff
1190
+ // (scripts/session-audit.mjs:206) and sits in the empirical gap between
1191
+ // incidental lookups (0–3 calls) and real investigation (16–22 calls). Bash is
1192
+ // now counted (it is the dominant signal in read-only sessions); over-firing is
1193
+ // bounded by the close-intent gate (the Stop hook only blocks when the user also
1194
+ // signalled wrap-up) — see hypo-auto-minimal-crystallize.mjs.
1195
+ //
1196
+ // NOTE: counting a Bash call here does NOT widen the lint scope. Lint scoping
1197
+ // (precompactGateStatus) still seeds only Edit/Write-touched files plus the
1198
+ // mandatory close files — a Bash-written wiki file is not auto-scoped.
800
1199
 
801
1200
  const MUTATING_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
1201
+ // Read-only investigation tools. Bash included: read-only sessions are
1202
+ // Bash-dominant (git/grep/cat), so excluding it would leave most of them
1203
+ // undetectable (a real session had read=0, bash=16).
1204
+ const INVESTIGATION_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'Bash']);
1205
+ // Investigation-volume cutoff for a read-only session to count as substantial.
1206
+ const READONLY_SUBSTANTIAL_THRESHOLD = 5;
802
1207
 
803
1208
  /** Mirror of `scripts/session-audit.mjs` extractToolNames: handles both top-level
804
1209
  * `tool_use` entries (legacy fixtures) and nested `message.content[].tool_use`
@@ -828,27 +1233,30 @@ function extractTranscriptToolNames(entry) {
828
1233
  }
829
1234
 
830
1235
  /**
831
- * True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
832
- * tool_use (Edit/Write/MultiEdit/NotebookEdit).
1236
+ * Single-pass tool-use census over a JSONL transcript. Both public predicates
1237
+ * (`hasMutatingTranscriptActivity`, `isSubstantialSession`) derive from this one
1238
+ * census so the mutation leg stays identical between them. (They still diverge
1239
+ * on read-only ≥ threshold sessions, where only `isSubstantialSession` is true.)
833
1240
  *
834
- * Granularity:
835
- * • Whole-file unreadable / missing path → returns false (fail-open).
1241
+ * Granularity (shared contract):
1242
+ * • Whole-file unreadable / missing path → all counts 0 (fail-open).
836
1243
  * • Per-line malformed JSON → that line is skipped, scan continues. Real
837
1244
  * transcripts occasionally carry truncated lines; one bad line must not
838
- * hide a clearly-mutating session that follows. (Codex Worker-2 CONCERN
1245
+ * hide a clearly-active session that follows. (Codex Worker-2 CONCERN
839
1246
  * resolved 2026-05-19: line-level skip is the intended contract.)
840
1247
  *
841
1248
  * @param {string|null|undefined} transcriptPath
842
- * @returns {boolean}
1249
+ * @returns {{ mutationCount: number, investigationCount: number }}
843
1250
  */
844
- export function hasMutatingTranscriptActivity(transcriptPath) {
845
- if (!transcriptPath || typeof transcriptPath !== 'string') return false;
846
- if (!existsSync(transcriptPath)) return false;
1251
+ function transcriptActivityStats(transcriptPath) {
1252
+ const stats = { mutationCount: 0, investigationCount: 0 };
1253
+ if (!transcriptPath || typeof transcriptPath !== 'string') return stats;
1254
+ if (!existsSync(transcriptPath)) return stats;
847
1255
  let raw;
848
1256
  try {
849
1257
  raw = readFileSync(transcriptPath, 'utf-8');
850
1258
  } catch {
851
- return false;
1259
+ return stats;
852
1260
  }
853
1261
  for (const line of raw.split('\n')) {
854
1262
  const trimmed = line.trim();
@@ -860,10 +1268,41 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
860
1268
  continue;
861
1269
  }
862
1270
  for (const name of extractTranscriptToolNames(entry)) {
863
- if (MUTATING_TOOL_NAMES.has(name)) return true;
1271
+ if (MUTATING_TOOL_NAMES.has(name)) stats.mutationCount++;
1272
+ else if (INVESTIGATION_TOOL_NAMES.has(name)) stats.investigationCount++;
864
1273
  }
865
1274
  }
866
- return false;
1275
+ return stats;
1276
+ }
1277
+
1278
+ /**
1279
+ * True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
1280
+ * tool_use (Edit/Write/MultiEdit/NotebookEdit). Kept as a precise, standalone
1281
+ * helper (and regression oracle) even though the Stop hook now gates on the
1282
+ * broader `isSubstantialSession`.
1283
+ *
1284
+ * @param {string|null|undefined} transcriptPath
1285
+ * @returns {boolean}
1286
+ */
1287
+ export function hasMutatingTranscriptActivity(transcriptPath) {
1288
+ return transcriptActivityStats(transcriptPath).mutationCount > 0;
1289
+ }
1290
+
1291
+ /**
1292
+ * True if the session is "substantial" enough to nudge a session-close:
1293
+ * any mutation, OR a read-only investigation of at least
1294
+ * READONLY_SUBSTANTIAL_THRESHOLD Read/Grep/Glob/Bash calls (6a). The mutation
1295
+ * leg is identical to `hasMutatingTranscriptActivity`, so mutating sessions
1296
+ * behave exactly as before; only high-volume read-only sessions are newly
1297
+ * caught. Over-firing on read-only sessions is bounded downstream by the
1298
+ * close-intent gate in hypo-auto-minimal-crystallize.mjs.
1299
+ *
1300
+ * @param {string|null|undefined} transcriptPath
1301
+ * @returns {boolean}
1302
+ */
1303
+ export function isSubstantialSession(transcriptPath) {
1304
+ const { mutationCount, investigationCount } = transcriptActivityStats(transcriptPath);
1305
+ return mutationCount > 0 || investigationCount >= READONLY_SUBSTANTIAL_THRESHOLD;
867
1306
  }
868
1307
 
869
1308
  // ── session-scoped lint classification ──────────────────────────────────────
@@ -959,8 +1398,35 @@ export function closeFileTargets(hypoDir) {
959
1398
  if (project) {
960
1399
  out.add(`projects/${project}/session-state.md`);
961
1400
  out.add(`projects/${project}/hot.md`);
962
- const month = freshDates()[0].slice(0, 7);
963
- out.add(`projects/${project}/session-log/${month}.md`);
1401
+ out.add(sessionLogScopePath(hypoDir, project, freshDates()[0]));
1402
+ }
1403
+ return out;
1404
+ }
1405
+
1406
+ /**
1407
+ * Global variant of closeFileTargets for the no-payload lint-scope callers
1408
+ * (ADR 0043). Union of the close files over every today-active project
1409
+ * (fallback: the recency project when none is active). Includes the session-log
1410
+ * evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
1411
+ * what sessionCloseFileStatus actually checks across a local/UTC date boundary.
1412
+ */
1413
+ export function closeFileTargetsGlobal(hypoDir) {
1414
+ const dates = freshDates();
1415
+ const out = new Set(['hot.md', 'log.md']);
1416
+ let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1417
+ hasTodayCloseActivity(hypoDir, p, dates),
1418
+ );
1419
+ if (active.length === 0) {
1420
+ const recency = resolveActiveProject(hypoDir);
1421
+ if (recency) active = [recency];
1422
+ }
1423
+ for (const p of active) {
1424
+ out.add(`projects/${p}/session-state.md`);
1425
+ out.add(`projects/${p}/hot.md`);
1426
+ // Scope to the file each date's freshness is PROVEN by (daily shard or, via
1427
+ // fallback, the legacy monthly), so a corrupt evidence file can't pass the
1428
+ // gate while its lint error is demoted to an out-of-scope notice.
1429
+ for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
964
1430
  }
965
1431
  return out;
966
1432
  }
@@ -993,6 +1459,253 @@ export function partitionLintScope(findings, scope) {
993
1459
  return { blocking, notice };
994
1460
  }
995
1461
 
1462
+ // ── PreCompact gate — single source of truth ────────────────────────────────
1463
+ /**
1464
+ * The full PreCompact gate decision as a READ-ONLY status. This is the single
1465
+ * source of truth for "is the wiki compact-ready?": both hypo-personal-check.mjs
1466
+ * (the PreCompact hook) and `crystallize --check-session-close` call it, so a
1467
+ * green status means /compact will not block on a human-fixable issue.
1468
+ *
1469
+ * Read-only: feedback projection PURE drift is reported as a non-blocking notice
1470
+ * with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
1471
+ * the hook self-heals it with `feedback-sync --write` before continuing (ADR
1472
+ * 0045) and a verify caller needs no human action for it. over-cap and conflict
1473
+ * DO block (ADR 0031 rules 3 & 6 — human demote/import required).
1474
+ *
1475
+ * Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
1476
+ * has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
1477
+ * bypass, and a fail-closed if the self-heal `--write` itself errors. And without
1478
+ * a transcript the lint scope is the mandatory close files only; pass
1479
+ * opts.transcriptPath to widen it to the session's edited files exactly as the
1480
+ * hook does.
1481
+ *
1482
+ * @param {string} hypoDir
1483
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
1484
+ * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
1485
+ */
1486
+ export function precompactGateStatus(hypoDir, opts = {}) {
1487
+ const blockers = [];
1488
+ const notices = [];
1489
+ const driftTargets = [];
1490
+ const skipped = { lint: false, feedback: false };
1491
+
1492
+ // log-only synthetic close mode. A non-project (tooling / wiki-only)
1493
+ // session has no project to close. Activated either by the explicit writer flag
1494
+ // (opts.logOnly, from `--mark-session-closed --log-only`) or by a log-only marker
1495
+ // for opts.sessionId (the PreCompact / --check-session-close readers). In this
1496
+ // mode the project-close invariant is replaced by a today log.md entry (minimum
1497
+ // proof), and — critically — the active/phantom project is NEVER put in
1498
+ // close.projects, because that set ALSO drives the lint scope and W8 ownership
1499
+ // below. Exempting only the close blocker would still let an unrelated project's
1500
+ // stale design-history / lint block the non-project session (codex design
1501
+ // BLOCKER). git / hot / lint(self) / feedback all still apply — not a bypass.
1502
+ const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
1503
+ const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
1504
+
1505
+ // 1. wiki git clean
1506
+ const git = hypoIsClean(hypoDir);
1507
+ if (!git.clean) blockers.push({ type: 'git', reason: git.reason });
1508
+
1509
+ // 2. root hot.md structure
1510
+ const hot = hotMdIsClean(hypoDir);
1511
+ if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
1512
+
1513
+ // 3. session-close files (global invariant, ADR 0043) — or, in log-only mode,
1514
+ // the minimum proof (a today log.md entry) with NO project attribution.
1515
+ let close;
1516
+ if (logOnly) {
1517
+ const hasLog = hasAnyTodayLogEntry(hypoDir);
1518
+ close = {
1519
+ ok: hasLog,
1520
+ projects: [],
1521
+ dates: freshDates(),
1522
+ fallback: false,
1523
+ primary: null,
1524
+ project: null,
1525
+ stale: [],
1526
+ missing: hasLog ? [] : ['log.md (no today session entry)'],
1527
+ };
1528
+ if (!hasLog) {
1529
+ blockers.push({
1530
+ type: 'close',
1531
+ reason: 'log-only close: log.md has no today session entry (minimum proof)',
1532
+ });
1533
+ }
1534
+ } else {
1535
+ close = sessionCloseGlobalStatus(hypoDir);
1536
+ if (!close.ok) {
1537
+ blockers.push({
1538
+ type: 'close',
1539
+ reason: `memory files not updated this session: ${[
1540
+ ...close.missing.map((f) => `${f} (missing)`),
1541
+ ...close.stale.map((f) => `${f} (stale)`),
1542
+ ].join(', ')}`,
1543
+ });
1544
+ }
1545
+ }
1546
+
1547
+ // 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
1548
+ const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
1549
+ if (!lintPath || !existsSync(lintPath)) {
1550
+ skipped.lint = true; // no package → fail-open (never block on missing tooling)
1551
+ } else {
1552
+ try {
1553
+ // Pass --hypo-dir explicitly: lint.mjs resolves the vault via HYPO_DIR /
1554
+ // home dirs and ignores cwd, so a --hypo-dir caller (crystallize, tests)
1555
+ // would otherwise lint the ambient wiki, not the one under test.
1556
+ // maxBuffer matches crystallize's runLint (64 MiB): warn-heavy output on a
1557
+ // large wiki easily exceeds Node's 1 MiB default, which would truncate
1558
+ // stdout and (via the catch below) silently fail-open this gate.
1559
+ const r = spawnSync('node', [lintPath, '--json', `--hypo-dir=${hypoDir}`], {
1560
+ encoding: 'utf-8',
1561
+ cwd: hypoDir,
1562
+ timeout: 30000,
1563
+ maxBuffer: 64 * 1024 * 1024,
1564
+ });
1565
+ // A spawn failure (ENOENT), a timeout/kill (status === null), or a crash
1566
+ // that produced no stdout must NOT be parsed as `{}` and treated as a clean
1567
+ // lint — that path leaves skipped.lint=false with no notice, an INVISIBLE
1568
+ // fail-open. Throw instead so the catch below records skipped.lint=true WITH
1569
+ // a reason notice.
1570
+ if (r.error || r.status === null) {
1571
+ throw new Error(`lint spawn failed: ${r.error?.code || `signal ${r.signal}`}`);
1572
+ }
1573
+ if (!r.stdout || !r.stdout.trim()) {
1574
+ throw new Error(
1575
+ `lint produced no stdout (exit=${r.status})${r.stderr ? `: ${r.stderr.slice(-500)}` : ''}`,
1576
+ );
1577
+ }
1578
+ const parsed = JSON.parse(r.stdout);
1579
+ const allErrors = parsed.errors || [];
1580
+ const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
1581
+ // log-only base scope = the shared root files only (hot.md / log.md) — NOT
1582
+ // closeFileTargetsGlobal, which would fold the active/phantom project's
1583
+ // mandatory files in and re-introduce the cross-project attribution. The
1584
+ // session's own transcript-touched files are still added below (a log-only
1585
+ // session is accountable for the wiki files it actually edited).
1586
+ const scope = new Set(
1587
+ opts.lintScope || (logOnly ? ['hot.md', 'log.md'] : closeFileTargetsGlobal(hypoDir)),
1588
+ );
1589
+ if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
1590
+ for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
1591
+ }
1592
+ const part = partitionLintScope(allErrors, scope);
1593
+ if (part.blocking.length > 0) {
1594
+ blockers.push({
1595
+ type: 'lint',
1596
+ reason: `lint blockers: ${[...new Set(part.blocking.map((b) => b.id || b.file))].join(', ')}`,
1597
+ });
1598
+ }
1599
+ for (const n of part.notice)
1600
+ notices.push({ type: 'lint', reason: `${n.file}${n.id ? ` (${n.id})` : ''}` });
1601
+ // W8 (design-history stale) is each today-active project's own close
1602
+ // responsibility; others' are non-blocking notices (ADR 0043). In log-only
1603
+ // mode there is NO project this session is accountable for, so every W8 is a
1604
+ // notice — a non-project session must never be blocked by some project's
1605
+ // stale design-history (codex design BLOCKER: the attribution leak this fix
1606
+ // closes).
1607
+ const activeSlugs = logOnly
1608
+ ? []
1609
+ : (close.projects || []).map((p) => p.project).filter(Boolean);
1610
+ const mine = new Set(activeSlugs.map((s) => `projects/${s}/design-history.md`));
1611
+ const w8Blocking = logOnly
1612
+ ? []
1613
+ : activeSlugs.length > 0
1614
+ ? allW8.filter((w) => mine.has(w.file))
1615
+ : allW8;
1616
+ const w8Notice = logOnly
1617
+ ? allW8
1618
+ : activeSlugs.length > 0
1619
+ ? allW8.filter((w) => !mine.has(w.file))
1620
+ : [];
1621
+ if (w8Blocking.length > 0) {
1622
+ blockers.push({
1623
+ type: 'design-history',
1624
+ reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
1625
+ });
1626
+ }
1627
+ for (const w of w8Notice) notices.push({ type: 'design-history', reason: w.file });
1628
+ } catch (e) {
1629
+ skipped.lint = true; // fail-open on tooling error
1630
+ // Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
1631
+ // instead of silently dropping the check — a fail-open is invisible otherwise.
1632
+ notices.push({
1633
+ type: 'lint',
1634
+ reason: `lint skipped (fail-open): ${e.message || e.code || 'unknown error'}`,
1635
+ });
1636
+ }
1637
+ }
1638
+
1639
+ // 5. feedback projection (ADR 0031 / 0045). over-cap/conflict block; pure
1640
+ // drift is a self-healable notice (driftTargets = effect requirement the
1641
+ // hook runs as --write). Classification mirrors hypo-personal-check exactly.
1642
+ const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
1643
+ const claudeHome = opts.claudeHome || join(HOME, '.claude');
1644
+ if (!feedbackPath || !existsSync(feedbackPath)) {
1645
+ skipped.feedback = true;
1646
+ } else {
1647
+ try {
1648
+ const r = spawnSync(
1649
+ process.execPath,
1650
+ [
1651
+ feedbackPath,
1652
+ '--check',
1653
+ '--strict',
1654
+ '--no-input',
1655
+ '--json',
1656
+ `--hypo-dir=${hypoDir}`,
1657
+ `--claude-home=${claudeHome}`,
1658
+ ],
1659
+ { encoding: 'utf-8', timeout: 30000 },
1660
+ );
1661
+ if (r.error || r.status === null) {
1662
+ skipped.feedback = true; // spawn failure → fail-open
1663
+ } else if (r.status !== 0) {
1664
+ // Only a non-zero exit carries an actionable issue. A clean check exits 0
1665
+ // (the implicit else below) — that is NOT skipped, just nothing to do.
1666
+ let report = null;
1667
+ try {
1668
+ report = JSON.parse(r.stdout || '');
1669
+ } catch {
1670
+ /* unparseable → fail-open below */
1671
+ }
1672
+ const entries = report ? Object.entries(report.targets || {}) : [];
1673
+ const conflictedT = entries
1674
+ .filter(
1675
+ ([, t]) =>
1676
+ t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
1677
+ )
1678
+ .map(([n]) => n);
1679
+ const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
1680
+ const driftedT = entries.filter(([, t]) => t.dirty).map(([n]) => n);
1681
+ if (!report || !(conflictedT.length || overCapT.length || driftedT.length)) {
1682
+ skipped.feedback = true; // buildError / unparseable / non-actionable → fail-open
1683
+ } else if (conflictedT.length) {
1684
+ blockers.push({
1685
+ type: 'feedback',
1686
+ reason: `feedback projection conflict (manual edit of ${conflictedT.join(', ')}) — run \`hypomnema feedback-sync --import-target-change --from=<memory|claude>\``,
1687
+ });
1688
+ } else if (overCapT.length) {
1689
+ blockers.push({
1690
+ type: 'feedback',
1691
+ reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
1692
+ });
1693
+ } else {
1694
+ driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
1695
+ notices.push({
1696
+ type: 'feedback',
1697
+ reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
1698
+ });
1699
+ }
1700
+ }
1701
+ } catch {
1702
+ skipped.feedback = true;
1703
+ }
1704
+ }
1705
+
1706
+ return { ok: blockers.length === 0, close, blockers, notices, driftTargets, skipped };
1707
+ }
1708
+
996
1709
  // ── session-close checklist ────────────────────────────────────────────────
997
1710
 
998
1711
  /**
@@ -1036,14 +1749,14 @@ export function isClearCommand(prompt) {
1036
1749
  return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
1037
1750
  }
1038
1751
 
1039
- /** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2, fix #25). */
1752
+ /** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2). */
1040
1753
  export function isCompactOrClearCommand(prompt) {
1041
1754
  return isCompactCommand(prompt) || isClearCommand(prompt);
1042
1755
  }
1043
1756
 
1044
1757
  /**
1045
1758
  * Extract recent user-role message text from a JSONL transcript (last `tailN`
1046
- * lines). Promoted from hypo-personal-check.mjs (fix #27 PR-C) so both the
1759
+ * lines). Promoted from hypo-personal-check.mjs so both the
1047
1760
  * PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
1048
1761
  * signal source. Claude Code transcript format: each line is
1049
1762
  * `{ type:"user", message:{ role:"user", content: ... } }`; the older
@@ -1100,8 +1813,11 @@ export function isClosePattern(text) {
1100
1813
  /오늘은?\s*이만/,
1101
1814
  ];
1102
1815
  const enPatterns = [
1103
- // wrap up: requires session-level context or sentence-end, not code-level objects
1104
- /wrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test)\b)/i,
1816
+ // wrap up: requires session-level context or sentence-end, not code-level
1817
+ // objects. The review/analysis/debug/audit/investigation nouns were added
1818
+ // for 6a — read-only review sessions are now "substantial", so "wrap up the
1819
+ // review" must read as a task-level signal, not a session-close one.
1820
+ /wrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
1105
1821
  /done\s+for\s+(?:today|now|the\s+day)/i,
1106
1822
  /that'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)/i,
1107
1823
  /signing\s+off/i,