hypomnema 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +11 -10
  4. package/README.md +11 -10
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +11 -11
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +1 -1
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +79 -0
  15. package/commands/resume.md +2 -2
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +1 -1
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +3 -3
  21. package/docs/CONTRIBUTING.md +2 -2
  22. package/hooks/hypo-auto-commit.mjs +8 -24
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  24. package/hooks/hypo-compact-guard.mjs +6 -3
  25. package/hooks/hypo-cwd-change.mjs +1 -1
  26. package/hooks/hypo-first-prompt.mjs +2 -2
  27. package/hooks/hypo-hot-rebuild.mjs +14 -1
  28. package/hooks/hypo-personal-check.mjs +91 -179
  29. package/hooks/hypo-pre-commit.mjs +1 -1
  30. package/hooks/hypo-session-end.mjs +1 -1
  31. package/hooks/hypo-session-start.mjs +7 -7
  32. package/hooks/hypo-shared.mjs +1082 -89
  33. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  34. package/hooks/version-check-fetch.mjs +2 -8
  35. package/hooks/version-check.mjs +18 -0
  36. package/package.json +3 -2
  37. package/scripts/check-tracker-ids.mjs +329 -0
  38. package/scripts/crystallize.mjs +322 -110
  39. package/scripts/doctor.mjs +6 -9
  40. package/scripts/feedback-sync.mjs +1 -1
  41. package/scripts/init.mjs +1 -1
  42. package/scripts/install-git-hooks.mjs +75 -40
  43. package/scripts/lib/check-tracker-ids.mjs +140 -0
  44. package/scripts/lib/design-history-stale.mjs +59 -14
  45. package/scripts/lib/extensions.mjs +4 -4
  46. package/scripts/lib/fix-manifest.mjs +2 -2
  47. package/scripts/lib/fix-status-verify.mjs +5 -4
  48. package/scripts/lib/plugin-detect.mjs +15 -6
  49. package/scripts/lib/project-create.mjs +1 -1
  50. package/scripts/lint.mjs +63 -8
  51. package/scripts/rename.mjs +836 -0
  52. package/scripts/resume.mjs +75 -19
  53. package/scripts/uninstall.mjs +1 -1
  54. package/scripts/upgrade.mjs +26 -25
  55. package/skills/crystallize/SKILL.md +12 -9
  56. package/skills/graph/SKILL.md +1 -1
  57. package/skills/ingest/SKILL.md +1 -1
  58. package/skills/lint/SKILL.md +1 -1
  59. package/skills/query/SKILL.md +1 -1
  60. package/skills/verify/SKILL.md +1 -1
  61. package/templates/SCHEMA.md +2 -2
  62. package/templates/hypo-config.md +2 -2
  63. package/templates/hypo-guide.md +22 -1
  64. package/templates/hypo-help.md +1 -0
  65. package/templates/projects/_template/index.md +1 -1
@@ -6,7 +6,16 @@
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
+ realpathSync,
18
+ } from 'fs';
10
19
  import { join, relative, basename } from 'path';
11
20
  import { homedir, hostname, tmpdir } from 'os';
12
21
  import { spawnSync } from 'child_process';
@@ -119,28 +128,50 @@ export function lastSubstantialOpIsSession() {
119
128
  return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
120
129
  }
121
130
 
131
+ // Returns the wiki's git state split into its two independent axes (ADR 0056):
132
+ // uncommitted — working-tree changes (real unsaved work; a human-fixable blocker)
133
+ // ahead — committed-but-unpushed commits (a soft, auto-synced state: the
134
+ // auto-commit Stop hook pushes, and push failures are non-fatal —
135
+ // see hypo-auto-commit.mjs / appendSyncFailure)
136
+ // `clean` stays `!uncommitted && !ahead` for back-compat with callers that only
137
+ // read it. Callers that gate session-close / compact distinguish the two: they
138
+ // block on `uncommitted` and demote `ahead` to a notice (precompactGateStatus,
139
+ // hypo-compact-guard) so a committed-but-unpushed close is still "compact-ready".
122
140
  export function hypoIsClean(dir = HYPO_DIR) {
123
141
  try {
124
142
  const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain'], {
125
143
  encoding: 'utf-8',
126
144
  });
127
- if (porcelain.status !== 0) return { clean: false, reason: `git check failed in ${dir}` };
128
- if (porcelain.stdout.trim() !== '')
129
- return { clean: false, reason: `uncommitted changes in ${dir}` };
130
- const ahead = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
145
+ if (porcelain.status !== 0)
146
+ return {
147
+ clean: false,
148
+ uncommitted: true,
149
+ ahead: false,
150
+ reason: `git check failed in ${dir}`,
151
+ };
152
+ const uncommitted = porcelain.stdout.trim() !== '';
153
+ const aheadRes = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
131
154
  encoding: 'utf-8',
132
155
  });
133
- if (/\[ahead \d+\]/.test(ahead.stdout || ''))
134
- return { clean: false, reason: `unpushed commits in ${dir}` };
135
- return { clean: true };
156
+ const ahead = /\[ahead \d+\]/.test(aheadRes.stdout || '');
157
+ const reasons = [];
158
+ if (uncommitted) reasons.push(`uncommitted changes in ${dir}`);
159
+ if (ahead) reasons.push(`unpushed commits in ${dir}`);
160
+ return {
161
+ clean: !uncommitted && !ahead,
162
+ uncommitted,
163
+ ahead,
164
+ reason: reasons.length ? reasons.join('; ') : undefined,
165
+ };
136
166
  } catch {
137
- return { clean: false, reason: `git check failed in ${dir}` };
167
+ return { clean: false, uncommitted: true, ahead: false, reason: `git check failed in ${dir}` };
138
168
  }
139
169
  }
140
170
 
141
- export function hotMdIsClean() {
142
- if (!existsSync(HOT_PATH)) return { clean: true };
143
- const content = readFileSync(HOT_PATH, 'utf-8');
171
+ export function hotMdIsClean(dir = HYPO_DIR) {
172
+ const hotPath = dir === HYPO_DIR ? HOT_PATH : join(dir, 'hot.md');
173
+ if (!existsSync(hotPath)) return { clean: true };
174
+ const content = readFileSync(hotPath, 'utf-8');
144
175
  const reasons = [];
145
176
 
146
177
  // Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
@@ -163,7 +194,7 @@ export function hotMdIsClean() {
163
194
  // spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
164
195
  // 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
165
196
  // (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
166
- // project hot.md, root hot.md, session-log/YYYY-MM.md, and log.md.
197
+ // project hot.md, root hot.md, session-log/YYYY-MM-DD.md, and log.md.
167
198
  // pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
168
199
  // cross-project queue, so a session that raises no questions should not be
169
200
  // forced to touch it. Gating it would produce false-blocks; spec §5.2.7
@@ -173,7 +204,7 @@ export function hotMdIsClean() {
173
204
  // so a second session on the same day that skips updating a file still passes
174
205
  // if an earlier close that day already stamped it. freshDates() accepting both
175
206
  // 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.
207
+ // boundary is out of scope for the strict session-close check.
177
208
 
178
209
  /** Parse the frontmatter `updated:` field. Returns the trimmed value or null. */
179
210
  function frontmatterUpdated(content) {
@@ -198,6 +229,56 @@ export function hasSessionLogHeading(content, date) {
198
229
  return new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]', 'm').test(content || '');
199
230
  }
200
231
 
232
+ /**
233
+ * Canonical session-log shard path (repo-relative POSIX) for a single day.
234
+ * ADR 0050 / option D: the date IS the filename (`YYYY-MM-DD.md`), so no
235
+ * "current part" resolver is needed and the per-session write touches a small
236
+ * file instead of a multi-thousand-line monthly log. This is the WRITE target.
237
+ */
238
+ export function sessionLogShardPath(project, date) {
239
+ return `projects/${project}/session-log/${date}.md`;
240
+ }
241
+
242
+ /**
243
+ * Read candidates for a date's session-log entry, in priority order:
244
+ * 1. the daily shard `YYYY-MM-DD.md` (canonical, small)
245
+ * 2. the legacy monthly `YYYY-MM.md` (pre-shard history is never split)
246
+ * Callers iterate and short-circuit on the first file carrying the dated
247
+ * heading, so once a daily shard exists the large monthly file is never read —
248
+ * which is where the per-close token saving comes from. The monthly fallback
249
+ * keeps pre-cutover months (and a hybrid cutover month) resolving correctly
250
+ * without a retroactive split.
251
+ */
252
+ export function sessionLogReadCandidates(project, date) {
253
+ return [
254
+ sessionLogShardPath(project, date),
255
+ `projects/${project}/session-log/${date.slice(0, 7)}.md`,
256
+ ];
257
+ }
258
+
259
+ /**
260
+ * The session-log file the close gate should hold accountable for `date` — i.e.
261
+ * the read candidate that ACTUALLY carries a today-dated heading (daily shard
262
+ * preferred), or the daily write target when none does yet. The freshness gate
263
+ * accepts a today-heading found in the legacy monthly file via fallback; if the
264
+ * lint scope only ever named the daily shard, a close could pass on a *corrupt*
265
+ * monthly evidence file while that file's lint error was demoted to a
266
+ * non-blocking notice (out of scope). Scoping to the resolved evidence file
267
+ * closes that gap: whatever file the gate trusts as proof, lint judges too.
268
+ */
269
+ export function sessionLogScopePath(hypoDir, project, date) {
270
+ for (const rel of sessionLogReadCandidates(project, date)) {
271
+ const full = join(hypoDir, rel);
272
+ if (!existsSync(full)) continue;
273
+ try {
274
+ if (hasSessionLogHeading(readFileSync(full, 'utf-8'), date)) return rel;
275
+ } catch {
276
+ /* unreadable candidate — keep looking */
277
+ }
278
+ }
279
+ return sessionLogShardPath(project, date);
280
+ }
281
+
201
282
  /**
202
283
  * True if `content` carries a today-dated `## [date] session | <project>` entry
203
284
  * in log.md.
@@ -217,6 +298,31 @@ export function hasLogEntry(content, date, project) {
217
298
  ).test(content || '');
218
299
  }
219
300
 
301
+ /**
302
+ * True when log.md carries ANY `## [<freshDate>] session | <slug>` heading —
303
+ * project-agnostic. The minimum proof for a log-only close: a
304
+ * non-project (tooling/wiki-only) session has no project mandatory files, but it
305
+ * MUST still leave a today log.md trace so the close is not a content-free bypass.
306
+ * Uses the same canonical heading parser as hasLogEntry (not a loose substring —
307
+ * codex design review: keep the fresh-date/canonical-heading style) so a stray
308
+ * mention of the date elsewhere cannot satisfy it.
309
+ * @param {string} hypoDir
310
+ * @returns {boolean}
311
+ */
312
+ export function hasAnyTodayLogEntry(hypoDir) {
313
+ const logPath = join(hypoDir, 'log.md');
314
+ if (!existsSync(logPath)) return false;
315
+ let content = '';
316
+ try {
317
+ content = readFileSync(logPath, 'utf-8');
318
+ } catch {
319
+ return false;
320
+ }
321
+ return freshDates().some((d) =>
322
+ new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| \\S', 'm').test(content),
323
+ );
324
+ }
325
+
220
326
  /**
221
327
  * Date strings that count as "today" for freshness checks. Both the local and
222
328
  * UTC dates are accepted: Claude writes file dates in the user's local zone,
@@ -247,7 +353,8 @@ function parseFrontmatterField(content, key) {
247
353
 
248
354
  // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
249
355
  // 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.
356
+ // when cwd is falsy or matches none. resume gives this authority OVER recency
357
+ // (ADR 0044); close callers never pass cwd, so it stays inert for them.
251
358
  function pickByCwd(hypoDir, slugs, cwd) {
252
359
  if (!cwd) return null;
253
360
  let best = null;
@@ -268,15 +375,18 @@ function pickByCwd(hypoDir, slugs, cwd) {
268
375
  }
269
376
 
270
377
  /**
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.
378
+ * Resolve the active project slug from root hot.md. With a cwd, a project whose
379
+ * working_dir contains it wins (cwd-first, ADR 0044); otherwise the
380
+ * most-recently-active row is returned.
381
+ * The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
382
+ * are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
383
+ * intentionally differs (resume.mjs adds an mtime fallback, this does not).
384
+ * `cwd` is an optional cwd-first selector (ADR 0044): a cwd↔working_dir match
385
+ * wins over recency. resume passes process.cwd(); session-close callers
386
+ * (sessionCloseFileStatus / closeFileTargets) intentionally pass null close
387
+ * has a different authority (payload.project / freshness, the global invariant
388
+ * of ADR 0043), so it never picks by cwd. When cwd is omitted, behavior is
389
+ * identical to the legacy recency version.
280
390
  * @param {string} hypoDir
281
391
  * @param {string|null} [cwd]
282
392
  * @returns {string|null}
@@ -299,41 +409,48 @@ export function resolveActiveProject(hypoDir, cwd = null) {
299
409
  ),
300
410
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
301
411
  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) {
412
+ // cwd-first (ADR 0044): a cwd↔working_dir match wins over recency, across
413
+ // ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null
414
+ // recency path below (resume=cwd-positive / close=no-pick).
415
+ if (cwd) {
309
416
  const picked = pickByCwd(
310
417
  hypoDir,
311
- tied.map((r) => r.slug),
418
+ wikiRows.map((r) => r.slug),
312
419
  cwd,
313
420
  );
314
421
  if (picked) return picked;
315
422
  }
423
+ // No cwd match → most recent by date (stable-sort keeps the first table row
424
+ // on a tie, the legacy behavior).
425
+ wikiRows.sort((a, b) => b.date.localeCompare(a.date));
316
426
  return wikiRows[0].slug;
317
427
  }
318
428
  // Legacy markdown-link rows: | [name](projects/name/...) | ...
319
- const mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
320
- if (mdRow) return mdRow[2];
429
+ const mdSlugs = [...content.matchAll(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/g)].map((m) => m[2]);
430
+ if (mdSlugs.length > 0) {
431
+ if (cwd) {
432
+ const picked = pickByCwd(hypoDir, mdSlugs, cwd);
433
+ if (picked) return picked;
434
+ }
435
+ return mdSlugs[0]; // legacy: first table row
436
+ }
321
437
  return null;
322
438
  }
323
439
 
324
440
  /**
325
- * Strict session-close verification (fix #17, spec §5.2.7 / §8.3).
441
+ * Strict session-close verification (spec §5.2.7 / §8.3).
326
442
  * Confirms the memory files a session close must touch were updated today:
327
443
  * - projects/<project>/session-state.md — frontmatter `updated:` is today
328
444
  * - projects/<project>/hot.md — frontmatter `updated:` is today
329
445
  * - hot.md (root) — frontmatter `updated:` is today
330
- * - projects/<project>/session-log/YYYY-MM.md — has a `## [today]` heading
446
+ * - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
447
+ * (daily shard, ADR 0050; legacy YYYY-MM.md is still accepted as fallback)
331
448
  * - log.md — has a `## [today] session | <project>` entry
332
449
  * The log.md check is project-scoped so a session close left incomplete for
333
450
  * project A can't be masked by a fresh close of project B (and vice versa).
334
451
  * open-questions.md (file #5) is conditional and not gated.
335
452
  *
336
- * `projectOverride` (ISSUE-7 Part A): when the caller already holds the
453
+ * `projectOverride` (same-date-tie fix): when the caller already holds the
337
454
  * authoritative project being closed (e.g. crystallize apply derives it from
338
455
  * `payload.project`), it passes that slug so verification checks the SAME
339
456
  * project it just wrote — instead of re-deriving via resolveActiveProject(),
@@ -381,25 +498,30 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
381
498
  checkUpdated(join('projects', project, 'hot.md'));
382
499
  checkUpdated('hot.md');
383
500
 
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.
501
+ // session-log: daily shard (ADR 0050), with legacy monthly fallback — must
502
+ // carry a today-dated heading in whichever file holds it. Daily-first read
503
+ // order short-circuits on the small shard. When no match is found, the gap is
504
+ // reported under the canonical daily shard for the local date (dates[0]).
386
505
  let sessionLogOk = false;
387
506
  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;
507
+ for (const rel of sessionLogReadCandidates(project, date)) {
508
+ const full = join(hypoDir, rel);
509
+ if (!existsSync(full)) continue;
510
+ let content = '';
511
+ try {
512
+ content = readFileSync(full, 'utf-8');
513
+ } catch {
514
+ continue;
515
+ }
516
+ if (hasSessionLogHeading(content, date)) {
517
+ sessionLogOk = true;
518
+ break;
519
+ }
399
520
  }
521
+ if (sessionLogOk) break;
400
522
  }
401
523
  if (!sessionLogOk) {
402
- const logRel = join('projects', project, 'session-log', `${dates[0].slice(0, 7)}.md`);
524
+ const logRel = sessionLogShardPath(project, dates[0]);
403
525
  (existsSync(join(hypoDir, logRel)) ? stale : missing).push(logRel);
404
526
  }
405
527
 
@@ -421,11 +543,298 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
421
543
  return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
422
544
  }
423
545
 
546
+ // ── global session-close gate (ADR 0043) ────
547
+ // The no-payload close paths must NOT pick one project (recency / cwd) and check
548
+ // it — that re-derivation is the prior session-close false-block, and a cwd
549
+ // tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
550
+ // close. Instead the gate enforces a global invariant: no project may end a
551
+ // session with a partial close. resume stays cwd-positive (ADR 0044); close
552
+ // never picks. The two copies of resolveActiveProject share the cwd-first body
553
+ // but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
554
+
555
+ // Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
556
+ // project-scoped (unlike the shared frontmatter `updated:`). Used for candidate
557
+ // DISCOVERY in closeCandidateSlugs — NOT as close-activity evidence: ADR 0057
558
+ // dropped the row date as an activity signal because project-create and
559
+ // hypo-hot-rebuild both stamp rows today without a real session. Mirrors
560
+ // resolveActiveProject's regex.
561
+ function rootHotRows(hypoDir) {
562
+ const hotPath = join(hypoDir, 'hot.md');
563
+ if (!existsSync(hotPath)) return [];
564
+ let content;
565
+ try {
566
+ content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
567
+ } catch {
568
+ return [];
569
+ }
570
+ return [
571
+ ...content.matchAll(
572
+ /\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
573
+ ),
574
+ ].map((m) => ({ slug: m[3], date: m[2] || '' }));
575
+ }
576
+
577
+ // Candidate slugs the global gate must consider: real project dirs (with a
578
+ // session-state.md, skip _template) ∪ slugs in a today-dated `## [today] session
579
+ // | P` log.md entry ∪ slugs in a today-dated root hot.md row. The log/row unions
580
+ // catch a dangling close whose own project files are missing —
581
+ // sessionCloseFileStatus(projectOverride) reports those as `missing` correctly.
582
+ function closeCandidateSlugs(hypoDir, dates) {
583
+ const slugs = new Set();
584
+ const projectsDir = join(hypoDir, 'projects');
585
+ if (existsSync(projectsDir)) {
586
+ let entries = [];
587
+ try {
588
+ entries = readdirSync(projectsDir);
589
+ } catch {
590
+ entries = [];
591
+ }
592
+ for (const p of entries) {
593
+ if (p === '_template') continue;
594
+ if (existsSync(join(projectsDir, p, 'session-state.md'))) slugs.add(p);
595
+ }
596
+ }
597
+ for (const r of rootHotRows(hypoDir)) {
598
+ if (r.date && dates.includes(r.date)) slugs.add(r.slug);
599
+ }
600
+ const logPath = join(hypoDir, 'log.md');
601
+ if (existsSync(logPath)) {
602
+ let content = '';
603
+ try {
604
+ content = readFileSync(logPath, 'utf-8');
605
+ } catch {
606
+ content = '';
607
+ }
608
+ for (const d of dates) {
609
+ const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
610
+ for (const m of content.matchAll(re)) slugs.add(m[1]);
611
+ }
612
+ }
613
+ return slugs;
614
+ }
615
+
616
+ // True when project P shows an AUTHORITATIVE today close-activity signal: a
617
+ // today-dated session-log heading, or a today-dated `## [today] session | P`
618
+ // log.md entry. These are written ONLY by a real session close (apply, or its
619
+ // auto-derived root log — ADR 0049).
620
+ //
621
+ // ADR 0057: soft state files are EXCLUDED, because each is bumped to today by
622
+ // non-session tooling and is therefore indistinguishable from a real close:
623
+ // - session-state.md `updated:` — tracker bookkeeping mirrors a new item into
624
+ // the "next tasks" section (a cross-block incident: editing one project's
625
+ // tracker bumped session-state and blocked an unrelated project's /compact).
626
+ // - project hot.md `updated:` — project-create stamps the template `updated: today`.
627
+ // - root hot.md ROW date — project-create inserts a today-dated row, and
628
+ // hypo-hot-rebuild defaults a row to today when a project hot.md is missing.
629
+ // (Root hot.md *frontmatter* was already never a signal — it is shared and
630
+ // hypo-hot-rebuild stamps it today every session.)
631
+ //
632
+ // Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
633
+ // project files, then the session-log + log entry. A process crash before the
634
+ // session-log write leaves a torn close that this gate no longer flags. Accepted:
635
+ // a torn close never reached apply's ok=true so it wrote no marker (ADR 0055/0056);
636
+ // the surviving session-state is the resume pointer the next session overwrites;
637
+ // what is lost is a narrative log entry, not continuity.
638
+ function hasTodayCloseActivity(hypoDir, project, dates) {
639
+ for (const d of dates) {
640
+ for (const rel of sessionLogReadCandidates(project, d)) {
641
+ const sl = join(hypoDir, rel);
642
+ if (!existsSync(sl)) continue;
643
+ try {
644
+ if (hasSessionLogHeading(readFileSync(sl, 'utf-8'), d)) return true;
645
+ } catch {
646
+ /* skip */
647
+ }
648
+ }
649
+ }
650
+ const logPath = join(hypoDir, 'log.md');
651
+ if (existsSync(logPath)) {
652
+ try {
653
+ const c = readFileSync(logPath, 'utf-8');
654
+ if (dates.some((d) => hasLogEntry(c, d, project))) return true;
655
+ } catch {
656
+ /* skip */
657
+ }
658
+ }
659
+ return false;
660
+ }
661
+
662
+ /**
663
+ * Global session-close status for the no-payload close paths (ADR 0043).
664
+ * Checks EVERY project with today close-activity; ok only when all are complete.
665
+ * When no project has today activity, falls back to the legacy single recency
666
+ * project (preserves "force the initial close" behavior, byte-identical gate).
667
+ *
668
+ * stale/missing entries are self-describing paths — project-specific files carry
669
+ * `projects/<slug>/…`, root files (`hot.md`/`log.md`) are shared — so the flat
670
+ * aliases need no project prefix and a single-project session is byte-identical
671
+ * to sessionCloseFileStatus (back-compat for the flat-field readers).
672
+ *
673
+ * @param {string} hypoDir
674
+ * @returns {{ok: boolean, projects: Array<{project:string, ok:boolean, stale:string[], missing:string[]}>,
675
+ * dates: string[], fallback: boolean, primary: string|null,
676
+ * project: string|null, stale: string[], missing: string[]}}
677
+ */
678
+ export function sessionCloseGlobalStatus(hypoDir) {
679
+ const dates = freshDates();
680
+ const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
681
+ const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
682
+ hasTodayCloseActivity(hypoDir, p, dates),
683
+ );
684
+
685
+ if (todayActive.length === 0) {
686
+ const legacy = sessionCloseFileStatus(hypoDir);
687
+ return {
688
+ ok: legacy.ok,
689
+ projects: legacy.project
690
+ ? [{ project: legacy.project, ok: legacy.ok, stale: legacy.stale, missing: legacy.missing }]
691
+ : [],
692
+ dates,
693
+ fallback: true,
694
+ primary: legacy.project,
695
+ project: legacy.project,
696
+ stale: legacy.stale,
697
+ missing: legacy.missing,
698
+ };
699
+ }
700
+
701
+ // primary = the recency project when it is itself today-active, else the first
702
+ // today-active slug (stable order from the candidate set). Used only as the
703
+ // single-slug alias (marker `project` field, message header) — never to gate.
704
+ const primary = recency && todayActive.includes(recency) ? recency : todayActive[0];
705
+ const ordered = [primary, ...todayActive.filter((p) => p !== primary)];
706
+
707
+ const projects = ordered.map((p) => {
708
+ const s = sessionCloseFileStatus(hypoDir, { projectOverride: p });
709
+ return { project: p, ok: s.ok, stale: s.stale, missing: s.missing };
710
+ });
711
+ const ok = projects.every((x) => x.ok);
712
+ const stale = [...new Set(projects.flatMap((x) => x.stale))];
713
+ const missing = [...new Set(projects.flatMap((x) => x.missing))];
714
+ return { ok, projects, dates, fallback: false, primary, project: primary, stale, missing };
715
+ }
716
+
717
+ // ── derivable-artifact auto-derive: root log.md session entry ──────────────────
718
+ // The root log.md `## [date] session | <slug>` entry is a DERIVABLE artifact —
719
+ // it restates a project's session-log heading, which the close already authored.
720
+ // root hot.md is already auto-derived here (rebuild() above); log.md was the only
721
+ // derivable still left as a manual checklist step, so a hand-edited close that
722
+ // skipped it left the global gate (sessionCloseGlobalStatus) hard-blocking /compact
723
+ // for EVERY today-active project — cross-session, looking like a fresh bug each
724
+ // time. This derives the missing entry from the session-log heading so the gate
725
+ // never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
726
+ // here: it is a proof artifact the close gate actually ran (ADR 0022 invariant).
727
+ //
728
+ // Safety guard (codex design review): derive ONLY for a project whose close is
729
+ // otherwise complete — i.e. its sole gate problem is log.md. If session-state /
730
+ // project hot / session-log are also stale/missing, the authored close is genuinely
731
+ // incomplete and MUST keep blocking; deriving log.md then would mask it.
732
+
733
+ // Build the canonical root log.md heading from a raw session-log heading tail.
734
+ // The gate's session-log freshness check accepts ANY `## [date] ...` heading, but
735
+ // the log.md check requires `## [date] session | <slug>` — so normalise to that
736
+ // shape rather than copying a heading that might not carry `session | <slug>`.
737
+ function deriveLogTitle(tail) {
738
+ let t = (tail || '').trim();
739
+ t = t.replace(/^session\b\s*/i, ''); // drop a leading "session" token
740
+ if (t.startsWith('|')) {
741
+ // Drop a leading "| <old label/slug>" segment up to the first em-dash, so a
742
+ // renamed-project heading (`| oldslug — title`) does not leak its old slug
743
+ // into the derived title. A pipe segment with no separator is a bare label
744
+ // (e.g. `| slug`) → no title.
745
+ const dash = t.indexOf('—');
746
+ t = dash === -1 ? '' : t.slice(dash);
747
+ }
748
+ return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
749
+ }
750
+
751
+ /**
752
+ * Append any missing root log.md `## [date] session | <slug>` entries derived from
753
+ * each today-active project's session-log heading(s). Idempotent: dedups on the
754
+ * exact generated heading line, so re-running (or a same-day apply that already
755
+ * wrote the entry) is a no-op, and multiple same-day sessions each get their own
756
+ * entry. Best-effort and read-mostly: returns the number of entries appended.
757
+ *
758
+ * @param {string} hypoDir
759
+ * @returns {number} count of entries appended to log.md
760
+ */
761
+ export function deriveRootLogEntries(hypoDir) {
762
+ const logPath = join(hypoDir, 'log.md');
763
+ if (!existsSync(logPath)) return 0;
764
+ const dates = freshDates();
765
+ const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
766
+ hasTodayCloseActivity(hypoDir, p, dates),
767
+ );
768
+ if (todayActive.length === 0) return 0;
769
+
770
+ let logContent;
771
+ try {
772
+ logContent = readFileSync(logPath, 'utf-8');
773
+ } catch {
774
+ return 0;
775
+ }
776
+
777
+ // Exact-LINE dedup: a titleless heading (`## [d] session | a`) is a substring/
778
+ // prefix of a titled one (`## [d] session | a — first`), so substring checks
779
+ // would wrongly drop a distinct same-day session. Track whole heading lines.
780
+ const seenHeadings = new Set((logContent || '').split(/\r?\n/));
781
+ const additions = [];
782
+ for (const slug of todayActive) {
783
+ // Guard: only the log.md entry may be the gap; an otherwise-incomplete close
784
+ // must keep blocking (do not mask missing authored files).
785
+ const st = sessionCloseFileStatus(hypoDir, { projectOverride: slug });
786
+ const problems = [...st.stale, ...st.missing];
787
+ if (!(problems.length === 1 && problems[0] === 'log.md')) continue;
788
+
789
+ for (const date of dates) {
790
+ // Pick the candidate that actually CARRIES this date's heading (daily shard
791
+ // preferred), mirroring the freshness gate's resolution. Selecting merely
792
+ // the first *existing* file would diverge from freshness: a header-only
793
+ // daily shard (e.g. a seeded-but-not-yet-appended file) would be chosen and
794
+ // the real heading in the legacy monthly fallback missed — freshness would
795
+ // pass while derive failed to recover the log.md entry.
796
+ let slog = null;
797
+ for (const rel of sessionLogReadCandidates(slug, date)) {
798
+ const slogPath = join(hypoDir, rel);
799
+ if (!existsSync(slogPath)) continue;
800
+ let content;
801
+ try {
802
+ content = readFileSync(slogPath, 'utf-8');
803
+ } catch {
804
+ continue;
805
+ }
806
+ if (!hasSessionLogHeading(content, date)) continue;
807
+ slog = content;
808
+ break;
809
+ }
810
+ if (slog === null) continue;
811
+ const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
812
+ let m;
813
+ while ((m = headingRe.exec(slog)) !== null) {
814
+ const title = deriveLogTitle(m[1]);
815
+ const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
816
+ if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
817
+ seenHeadings.add(heading);
818
+ additions.push(`${heading}\n→ [[projects/${slug}/hot]]`);
819
+ }
820
+ }
821
+ }
822
+
823
+ if (additions.length === 0) return 0;
824
+ const sep = logContent.endsWith('\n') ? '\n' : '\n\n';
825
+ try {
826
+ writeFileSync(logPath, logContent + sep + additions.join('\n\n') + '\n');
827
+ } catch {
828
+ return 0;
829
+ }
830
+ return additions.length;
831
+ }
832
+
424
833
  // ── sync-state ────────────────────────────────────────────
425
834
  // `.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.
835
+ // line. hypo-auto-commit appends on pull/push failure; hypo-session-start
836
+ // surfaces open entries and clears them once sync is healthy again;
837
+ // doctor warns while entries remain. Keep the schema defined here only.
429
838
 
430
839
  /** @returns {string} path to the sync-state JSONL file for a wiki root. */
431
840
  function syncStatePath(hypoDir) {
@@ -461,6 +870,61 @@ export function appendSyncFailure(hypoDir, op, error) {
461
870
  }
462
871
  }
463
872
 
873
+ /**
874
+ * Stage + commit every non-.hypoignore change in the wiki. Does NOT pull/push —
875
+ * remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
876
+ * network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
877
+ * --apply-session-close path so the .hypoignore staging filter cannot diverge
878
+ * between the two commit loci (ADR 0056).
879
+ *
880
+ * "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
881
+ * failure — the caller's tree is already in the committed state it wanted.
882
+ *
883
+ * @param {string} hypoDir
884
+ * @returns {{committed: boolean, reason?: string}} committed:true when a commit was
885
+ * created OR nothing needed committing; committed:false (with reason) on a real
886
+ * failure: not a git repo, or git status/add/commit erroring.
887
+ */
888
+ export function commitWikiChanges(hypoDir) {
889
+ const git = (...args) =>
890
+ spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
891
+ if (git('rev-parse', '--is-inside-work-tree').status !== 0)
892
+ return { committed: false, reason: `not a git repository: ${hypoDir}` };
893
+ const porcelain = git('status', '--porcelain', '-uall');
894
+ if (porcelain.status !== 0)
895
+ return { committed: false, reason: `git status failed in ${hypoDir}` };
896
+ // `.hypoignore` is the project privacy boundary. `git add -A` ignores it, so
897
+ // enumerate changed paths, drop ignored ones, then stage explicitly.
898
+ const ignorePatterns = loadHypoIgnore(hypoDir);
899
+ const paths = [];
900
+ for (const line of (porcelain.stdout || '').split('\n')) {
901
+ if (!line) continue;
902
+ const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
903
+ if (!file) continue;
904
+ if (ignorePatterns.length > 0 && isIgnored(join(hypoDir, file), hypoDir, ignorePatterns))
905
+ continue;
906
+ paths.push(file);
907
+ }
908
+ if (paths.length > 0) {
909
+ const add = git('add', '--', ...paths);
910
+ if (add.status !== 0)
911
+ return {
912
+ committed: false,
913
+ reason: `git add failed: ${(add.stderr || '').trim() || 'unknown'}`,
914
+ };
915
+ }
916
+ const staged = git('diff', '--cached', '--name-only').stdout?.trim() || '';
917
+ if (!staged) return { committed: true }; // nothing to commit = success (idempotent)
918
+ const today = new Date().toISOString().slice(0, 10);
919
+ const commit = git('commit', '-m', `auto: ${today} wiki update`);
920
+ if (commit.status !== 0)
921
+ return {
922
+ committed: false,
923
+ reason: `git commit failed: ${(commit.stderr || '').trim() || 'unknown'}`,
924
+ };
925
+ return { committed: true };
926
+ }
927
+
464
928
  /**
465
929
  * Read sync-state entries.
466
930
  * @param {string} hypoDir
@@ -727,19 +1191,25 @@ export function sessionClosedMarkerPath(hypoDir, sessionId) {
727
1191
  *
728
1192
  * @param {string} hypoDir
729
1193
  * @param {string} sessionId
730
- * @param {{project?: string, transcript_path?: string}} info
1194
+ * @param {{project?: string, scope?: string, transcript_path?: string}} info
731
1195
  */
732
1196
  export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
733
1197
  if (!sessionId) return;
734
1198
  try {
735
1199
  const cacheDir = join(hypoDir, '.cache');
736
1200
  if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
1201
+ // scope distinguishes a project close (the 5 mandatory files were verified
1202
+ // fresh) from a log-only close (a non-project session, no project
1203
+ // attribution). Readers (precompactGateStatus / --check-session-close) key
1204
+ // the gate semantics on this field, so it must be recorded.
1205
+ const scope = info.scope === 'log-only' ? 'log-only' : 'project';
737
1206
  const payload = {
738
1207
  session_id: sessionId,
739
1208
  project: info.project || null,
1209
+ scope,
740
1210
  transcript_path: info.transcript_path || null,
741
1211
  closed_at: new Date().toISOString(),
742
- verification: 'session-close-file-status:ok',
1212
+ verification: scope === 'log-only' ? 'log-only-close:ok' : 'session-close-file-status:ok',
743
1213
  };
744
1214
  writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
745
1215
  } catch (err) {
@@ -790,15 +1260,33 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
790
1260
  }
791
1261
  }
792
1262
 
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.
1263
+ // ── transcript activity heuristic (ADR 0022 amendment 2026-05-19; 6a 2026-06-14) ──
1264
+ // Substantial-session gate for the Stop hook: a session "worth" blocking on for
1265
+ // session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
1266
+ // or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
1267
+ // Read/Grep/Glob/Bash calls). Pure Q&A / incidental lookups skip the block.
1268
+ //
1269
+ // 6a rationale: code-review / debugging sessions reach real conclusions worth
1270
+ // crystallizing while touching only read-only tools. The original gate keyed
1271
+ // purely on mutation tools, so those sessions were never nudged to close. The
1272
+ // investigation threshold (5) mirrors session-audit.mjs's "search-many" cutoff
1273
+ // (scripts/session-audit.mjs:206) and sits in the empirical gap between
1274
+ // incidental lookups (0–3 calls) and real investigation (16–22 calls). Bash is
1275
+ // now counted (it is the dominant signal in read-only sessions); over-firing is
1276
+ // bounded by the close-intent gate (the Stop hook only blocks when the user also
1277
+ // signalled wrap-up) — see hypo-auto-minimal-crystallize.mjs.
797
1278
  //
798
- // Bash is intentionally excluded running tests would otherwise trigger
799
- // block. Future fix may broaden to read-heavy sessions (Grep ≥ N).
1279
+ // NOTE: counting a Bash call here does NOT widen the lint scope. Lint scoping
1280
+ // (precompactGateStatus) still seeds only Edit/Write-touched files plus the
1281
+ // mandatory close files — a Bash-written wiki file is not auto-scoped.
800
1282
 
801
1283
  const MUTATING_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
1284
+ // Read-only investigation tools. Bash included: read-only sessions are
1285
+ // Bash-dominant (git/grep/cat), so excluding it would leave most of them
1286
+ // undetectable (a real session had read=0, bash=16).
1287
+ const INVESTIGATION_TOOL_NAMES = new Set(['Read', 'Grep', 'Glob', 'Bash']);
1288
+ // Investigation-volume cutoff for a read-only session to count as substantial.
1289
+ const READONLY_SUBSTANTIAL_THRESHOLD = 5;
802
1290
 
803
1291
  /** Mirror of `scripts/session-audit.mjs` extractToolNames: handles both top-level
804
1292
  * `tool_use` entries (legacy fixtures) and nested `message.content[].tool_use`
@@ -828,27 +1316,30 @@ function extractTranscriptToolNames(entry) {
828
1316
  }
829
1317
 
830
1318
  /**
831
- * True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
832
- * tool_use (Edit/Write/MultiEdit/NotebookEdit).
1319
+ * Single-pass tool-use census over a JSONL transcript. Both public predicates
1320
+ * (`hasMutatingTranscriptActivity`, `isSubstantialSession`) derive from this one
1321
+ * census so the mutation leg stays identical between them. (They still diverge
1322
+ * on read-only ≥ threshold sessions, where only `isSubstantialSession` is true.)
833
1323
  *
834
- * Granularity:
835
- * • Whole-file unreadable / missing path → returns false (fail-open).
1324
+ * Granularity (shared contract):
1325
+ * • Whole-file unreadable / missing path → all counts 0 (fail-open).
836
1326
  * • Per-line malformed JSON → that line is skipped, scan continues. Real
837
1327
  * transcripts occasionally carry truncated lines; one bad line must not
838
- * hide a clearly-mutating session that follows. (Codex Worker-2 CONCERN
1328
+ * hide a clearly-active session that follows. (Codex Worker-2 CONCERN
839
1329
  * resolved 2026-05-19: line-level skip is the intended contract.)
840
1330
  *
841
1331
  * @param {string|null|undefined} transcriptPath
842
- * @returns {boolean}
1332
+ * @returns {{ mutationCount: number, investigationCount: number }}
843
1333
  */
844
- export function hasMutatingTranscriptActivity(transcriptPath) {
845
- if (!transcriptPath || typeof transcriptPath !== 'string') return false;
846
- if (!existsSync(transcriptPath)) return false;
1334
+ function transcriptActivityStats(transcriptPath) {
1335
+ const stats = { mutationCount: 0, investigationCount: 0 };
1336
+ if (!transcriptPath || typeof transcriptPath !== 'string') return stats;
1337
+ if (!existsSync(transcriptPath)) return stats;
847
1338
  let raw;
848
1339
  try {
849
1340
  raw = readFileSync(transcriptPath, 'utf-8');
850
1341
  } catch {
851
- return false;
1342
+ return stats;
852
1343
  }
853
1344
  for (const line of raw.split('\n')) {
854
1345
  const trimmed = line.trim();
@@ -860,10 +1351,41 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
860
1351
  continue;
861
1352
  }
862
1353
  for (const name of extractTranscriptToolNames(entry)) {
863
- if (MUTATING_TOOL_NAMES.has(name)) return true;
1354
+ if (MUTATING_TOOL_NAMES.has(name)) stats.mutationCount++;
1355
+ else if (INVESTIGATION_TOOL_NAMES.has(name)) stats.investigationCount++;
864
1356
  }
865
1357
  }
866
- return false;
1358
+ return stats;
1359
+ }
1360
+
1361
+ /**
1362
+ * True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
1363
+ * tool_use (Edit/Write/MultiEdit/NotebookEdit). Kept as a precise, standalone
1364
+ * helper (and regression oracle) even though the Stop hook now gates on the
1365
+ * broader `isSubstantialSession`.
1366
+ *
1367
+ * @param {string|null|undefined} transcriptPath
1368
+ * @returns {boolean}
1369
+ */
1370
+ export function hasMutatingTranscriptActivity(transcriptPath) {
1371
+ return transcriptActivityStats(transcriptPath).mutationCount > 0;
1372
+ }
1373
+
1374
+ /**
1375
+ * True if the session is "substantial" enough to nudge a session-close:
1376
+ * any mutation, OR a read-only investigation of at least
1377
+ * READONLY_SUBSTANTIAL_THRESHOLD Read/Grep/Glob/Bash calls (6a). The mutation
1378
+ * leg is identical to `hasMutatingTranscriptActivity`, so mutating sessions
1379
+ * behave exactly as before; only high-volume read-only sessions are newly
1380
+ * caught. Over-firing on read-only sessions is bounded downstream by the
1381
+ * close-intent gate in hypo-auto-minimal-crystallize.mjs.
1382
+ *
1383
+ * @param {string|null|undefined} transcriptPath
1384
+ * @returns {boolean}
1385
+ */
1386
+ export function isSubstantialSession(transcriptPath) {
1387
+ const { mutationCount, investigationCount } = transcriptActivityStats(transcriptPath);
1388
+ return mutationCount > 0 || investigationCount >= READONLY_SUBSTANTIAL_THRESHOLD;
867
1389
  }
868
1390
 
869
1391
  // ── session-scoped lint classification ──────────────────────────────────────
@@ -959,8 +1481,35 @@ export function closeFileTargets(hypoDir) {
959
1481
  if (project) {
960
1482
  out.add(`projects/${project}/session-state.md`);
961
1483
  out.add(`projects/${project}/hot.md`);
962
- const month = freshDates()[0].slice(0, 7);
963
- out.add(`projects/${project}/session-log/${month}.md`);
1484
+ out.add(sessionLogScopePath(hypoDir, project, freshDates()[0]));
1485
+ }
1486
+ return out;
1487
+ }
1488
+
1489
+ /**
1490
+ * Global variant of closeFileTargets for the no-payload lint-scope callers
1491
+ * (ADR 0043). Union of the close files over every today-active project
1492
+ * (fallback: the recency project when none is active). Includes the session-log
1493
+ * evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
1494
+ * what sessionCloseFileStatus actually checks across a local/UTC date boundary.
1495
+ */
1496
+ export function closeFileTargetsGlobal(hypoDir) {
1497
+ const dates = freshDates();
1498
+ const out = new Set(['hot.md', 'log.md']);
1499
+ let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1500
+ hasTodayCloseActivity(hypoDir, p, dates),
1501
+ );
1502
+ if (active.length === 0) {
1503
+ const recency = resolveActiveProject(hypoDir);
1504
+ if (recency) active = [recency];
1505
+ }
1506
+ for (const p of active) {
1507
+ out.add(`projects/${p}/session-state.md`);
1508
+ out.add(`projects/${p}/hot.md`);
1509
+ // Scope to the file each date's freshness is PROVEN by (daily shard or, via
1510
+ // fallback, the legacy monthly), so a corrupt evidence file can't pass the
1511
+ // gate while its lint error is demoted to an out-of-scope notice.
1512
+ for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
964
1513
  }
965
1514
  return out;
966
1515
  }
@@ -993,6 +1542,264 @@ export function partitionLintScope(findings, scope) {
993
1542
  return { blocking, notice };
994
1543
  }
995
1544
 
1545
+ // ── PreCompact gate — single source of truth ────────────────────────────────
1546
+ /**
1547
+ * The full PreCompact gate decision as a READ-ONLY status. This is the single
1548
+ * source of truth for "is the wiki compact-ready?": both hypo-personal-check.mjs
1549
+ * (the PreCompact hook) and `crystallize --check-session-close` call it, so a
1550
+ * green status means /compact will not block on a human-fixable issue.
1551
+ *
1552
+ * Read-only: feedback projection PURE drift is reported as a non-blocking notice
1553
+ * with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
1554
+ * the hook self-heals it with `feedback-sync --write` before continuing (ADR
1555
+ * 0045) and a verify caller needs no human action for it. over-cap and conflict
1556
+ * DO block (ADR 0031 rules 3 & 6 — human demote/import required).
1557
+ *
1558
+ * Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
1559
+ * has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
1560
+ * bypass, and a fail-closed if the self-heal `--write` itself errors. And without
1561
+ * a transcript the lint scope is the mandatory close files only; pass
1562
+ * opts.transcriptPath to widen it to the session's edited files exactly as the
1563
+ * hook does.
1564
+ *
1565
+ * @param {string} hypoDir
1566
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
1567
+ * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
1568
+ */
1569
+ export function precompactGateStatus(hypoDir, opts = {}) {
1570
+ const blockers = [];
1571
+ const notices = [];
1572
+ const driftTargets = [];
1573
+ const skipped = { lint: false, feedback: false };
1574
+
1575
+ // log-only synthetic close mode. A non-project (tooling / wiki-only)
1576
+ // session has no project to close. Activated either by the explicit writer flag
1577
+ // (opts.logOnly, from `--mark-session-closed --log-only`) or by a log-only marker
1578
+ // for opts.sessionId (the PreCompact / --check-session-close readers). In this
1579
+ // mode the project-close invariant is replaced by a today log.md entry (minimum
1580
+ // proof), and — critically — the active/phantom project is NEVER put in
1581
+ // close.projects, because that set ALSO drives the lint scope and W8 ownership
1582
+ // below. Exempting only the close blocker would still let an unrelated project's
1583
+ // stale design-history / lint block the non-project session (codex design
1584
+ // BLOCKER). git / hot / lint(self) / feedback all still apply — not a bypass.
1585
+ const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
1586
+ const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
1587
+
1588
+ // 1. wiki git state (ADR 0056). Uncommitted changes (real unsaved work) BLOCK —
1589
+ // they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
1590
+ // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
1591
+ // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
1592
+ // here (the shared gate) keeps the marker == compact-ready invariant (ADR 0047):
1593
+ // a committed-but-unpushed close marks AND compacts, instead of the close writer
1594
+ // committing its own payload and then being blocked by its own (unpushed) commit.
1595
+ const git = hypoIsClean(hypoDir);
1596
+ if (git.uncommitted) blockers.push({ type: 'git', reason: git.reason });
1597
+ else if (git.ahead)
1598
+ notices.push({
1599
+ type: 'git-sync',
1600
+ reason: `unpushed commits in ${hypoDir} (push deferred to Stop hook)`,
1601
+ });
1602
+
1603
+ // 2. root hot.md structure
1604
+ const hot = hotMdIsClean(hypoDir);
1605
+ if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
1606
+
1607
+ // 3. session-close files (global invariant, ADR 0043) — or, in log-only mode,
1608
+ // the minimum proof (a today log.md entry) with NO project attribution.
1609
+ let close;
1610
+ if (logOnly) {
1611
+ const hasLog = hasAnyTodayLogEntry(hypoDir);
1612
+ close = {
1613
+ ok: hasLog,
1614
+ projects: [],
1615
+ dates: freshDates(),
1616
+ fallback: false,
1617
+ primary: null,
1618
+ project: null,
1619
+ stale: [],
1620
+ missing: hasLog ? [] : ['log.md (no today session entry)'],
1621
+ };
1622
+ if (!hasLog) {
1623
+ blockers.push({
1624
+ type: 'close',
1625
+ reason: 'log-only close: log.md has no today session entry (minimum proof)',
1626
+ });
1627
+ }
1628
+ } else {
1629
+ close = sessionCloseGlobalStatus(hypoDir);
1630
+ if (!close.ok) {
1631
+ blockers.push({
1632
+ type: 'close',
1633
+ reason: `memory files not updated this session: ${[
1634
+ ...close.missing.map((f) => `${f} (missing)`),
1635
+ ...close.stale.map((f) => `${f} (stale)`),
1636
+ ].join(', ')}`,
1637
+ });
1638
+ }
1639
+ }
1640
+
1641
+ // 4. lint blockers + W8 design-history (scoped). Mirrors hypo-personal-check.
1642
+ const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
1643
+ if (!lintPath || !existsSync(lintPath)) {
1644
+ skipped.lint = true; // no package → fail-open (never block on missing tooling)
1645
+ } else {
1646
+ try {
1647
+ // Pass --hypo-dir explicitly: lint.mjs resolves the vault via HYPO_DIR /
1648
+ // home dirs and ignores cwd, so a --hypo-dir caller (crystallize, tests)
1649
+ // would otherwise lint the ambient wiki, not the one under test.
1650
+ // maxBuffer matches crystallize's runLint (64 MiB): warn-heavy output on a
1651
+ // large wiki easily exceeds Node's 1 MiB default, which would truncate
1652
+ // stdout and (via the catch below) silently fail-open this gate.
1653
+ const r = spawnSync('node', [lintPath, '--json', `--hypo-dir=${hypoDir}`], {
1654
+ encoding: 'utf-8',
1655
+ cwd: hypoDir,
1656
+ timeout: 30000,
1657
+ maxBuffer: 64 * 1024 * 1024,
1658
+ });
1659
+ // A spawn failure (ENOENT), a timeout/kill (status === null), or a crash
1660
+ // that produced no stdout must NOT be parsed as `{}` and treated as a clean
1661
+ // lint — that path leaves skipped.lint=false with no notice, an INVISIBLE
1662
+ // fail-open. Throw instead so the catch below records skipped.lint=true WITH
1663
+ // a reason notice.
1664
+ if (r.error || r.status === null) {
1665
+ throw new Error(`lint spawn failed: ${r.error?.code || `signal ${r.signal}`}`);
1666
+ }
1667
+ if (!r.stdout || !r.stdout.trim()) {
1668
+ throw new Error(
1669
+ `lint produced no stdout (exit=${r.status})${r.stderr ? `: ${r.stderr.slice(-500)}` : ''}`,
1670
+ );
1671
+ }
1672
+ const parsed = JSON.parse(r.stdout);
1673
+ const allErrors = parsed.errors || [];
1674
+ const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
1675
+ // log-only base scope = the shared root files only (hot.md / log.md) — NOT
1676
+ // closeFileTargetsGlobal, which would fold the active/phantom project's
1677
+ // mandatory files in and re-introduce the cross-project attribution. The
1678
+ // session's own transcript-touched files are still added below (a log-only
1679
+ // session is accountable for the wiki files it actually edited).
1680
+ const scope = new Set(
1681
+ opts.lintScope || (logOnly ? ['hot.md', 'log.md'] : closeFileTargetsGlobal(hypoDir)),
1682
+ );
1683
+ if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
1684
+ for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
1685
+ }
1686
+ const part = partitionLintScope(allErrors, scope);
1687
+ if (part.blocking.length > 0) {
1688
+ blockers.push({
1689
+ type: 'lint',
1690
+ reason: `lint blockers: ${[...new Set(part.blocking.map((b) => b.id || b.file))].join(', ')}`,
1691
+ });
1692
+ }
1693
+ for (const n of part.notice)
1694
+ notices.push({ type: 'lint', reason: `${n.file}${n.id ? ` (${n.id})` : ''}` });
1695
+ // W8 (design-history stale) is each today-active project's own close
1696
+ // responsibility; others' are non-blocking notices (ADR 0043). In log-only
1697
+ // mode there is NO project this session is accountable for, so every W8 is a
1698
+ // notice — a non-project session must never be blocked by some project's
1699
+ // stale design-history (codex design BLOCKER: the attribution leak this fix
1700
+ // closes).
1701
+ const activeSlugs = logOnly
1702
+ ? []
1703
+ : (close.projects || []).map((p) => p.project).filter(Boolean);
1704
+ const mine = new Set(activeSlugs.map((s) => `projects/${s}/design-history.md`));
1705
+ const w8Blocking = logOnly
1706
+ ? []
1707
+ : activeSlugs.length > 0
1708
+ ? allW8.filter((w) => mine.has(w.file))
1709
+ : allW8;
1710
+ const w8Notice = logOnly
1711
+ ? allW8
1712
+ : activeSlugs.length > 0
1713
+ ? allW8.filter((w) => !mine.has(w.file))
1714
+ : [];
1715
+ if (w8Blocking.length > 0) {
1716
+ blockers.push({
1717
+ type: 'design-history',
1718
+ reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
1719
+ });
1720
+ }
1721
+ for (const w of w8Notice) notices.push({ type: 'design-history', reason: w.file });
1722
+ } catch (e) {
1723
+ skipped.lint = true; // fail-open on tooling error
1724
+ // Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
1725
+ // instead of silently dropping the check — a fail-open is invisible otherwise.
1726
+ notices.push({
1727
+ type: 'lint',
1728
+ reason: `lint skipped (fail-open): ${e.message || e.code || 'unknown error'}`,
1729
+ });
1730
+ }
1731
+ }
1732
+
1733
+ // 5. feedback projection (ADR 0031 / 0045). over-cap/conflict block; pure
1734
+ // drift is a self-healable notice (driftTargets = effect requirement the
1735
+ // hook runs as --write). Classification mirrors hypo-personal-check exactly.
1736
+ const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
1737
+ const claudeHome = opts.claudeHome || join(HOME, '.claude');
1738
+ if (!feedbackPath || !existsSync(feedbackPath)) {
1739
+ skipped.feedback = true;
1740
+ } else {
1741
+ try {
1742
+ const r = spawnSync(
1743
+ process.execPath,
1744
+ [
1745
+ feedbackPath,
1746
+ '--check',
1747
+ '--strict',
1748
+ '--no-input',
1749
+ '--json',
1750
+ `--hypo-dir=${hypoDir}`,
1751
+ `--claude-home=${claudeHome}`,
1752
+ ],
1753
+ { encoding: 'utf-8', timeout: 30000 },
1754
+ );
1755
+ if (r.error || r.status === null) {
1756
+ skipped.feedback = true; // spawn failure → fail-open
1757
+ } else if (r.status !== 0) {
1758
+ // Only a non-zero exit carries an actionable issue. A clean check exits 0
1759
+ // (the implicit else below) — that is NOT skipped, just nothing to do.
1760
+ let report = null;
1761
+ try {
1762
+ report = JSON.parse(r.stdout || '');
1763
+ } catch {
1764
+ /* unparseable → fail-open below */
1765
+ }
1766
+ const entries = report ? Object.entries(report.targets || {}) : [];
1767
+ const conflictedT = entries
1768
+ .filter(
1769
+ ([, t]) =>
1770
+ t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
1771
+ )
1772
+ .map(([n]) => n);
1773
+ const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
1774
+ const driftedT = entries.filter(([, t]) => t.dirty).map(([n]) => n);
1775
+ if (!report || !(conflictedT.length || overCapT.length || driftedT.length)) {
1776
+ skipped.feedback = true; // buildError / unparseable / non-actionable → fail-open
1777
+ } else if (conflictedT.length) {
1778
+ blockers.push({
1779
+ type: 'feedback',
1780
+ reason: `feedback projection conflict (manual edit of ${conflictedT.join(', ')}) — run \`hypomnema feedback-sync --import-target-change --from=<memory|claude>\``,
1781
+ });
1782
+ } else if (overCapT.length) {
1783
+ blockers.push({
1784
+ type: 'feedback',
1785
+ reason: `feedback projection over cap (${overCapT.join(', ')}) — demote/archive feedback pages`,
1786
+ });
1787
+ } else {
1788
+ driftTargets.push(...driftedT); // pure drift → self-healable, not a blocker
1789
+ notices.push({
1790
+ type: 'feedback',
1791
+ reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
1792
+ });
1793
+ }
1794
+ }
1795
+ } catch {
1796
+ skipped.feedback = true;
1797
+ }
1798
+ }
1799
+
1800
+ return { ok: blockers.length === 0, close, blockers, notices, driftTargets, skipped };
1801
+ }
1802
+
996
1803
  // ── session-close checklist ────────────────────────────────────────────────
997
1804
 
998
1805
  /**
@@ -1036,18 +1843,20 @@ export function isClearCommand(prompt) {
1036
1843
  return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
1037
1844
  }
1038
1845
 
1039
- /** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2, fix #25). */
1846
+ /** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2). */
1040
1847
  export function isCompactOrClearCommand(prompt) {
1041
1848
  return isCompactCommand(prompt) || isClearCommand(prompt);
1042
1849
  }
1043
1850
 
1044
1851
  /**
1045
1852
  * 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
1853
+ * lines). Promoted from hypo-personal-check.mjs so both the
1047
1854
  * PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
1048
1855
  * signal source. Claude Code transcript format: each line is
1049
1856
  * `{ type:"user", message:{ role:"user", content: ... } }`; the older
1050
- * top-level `{ role, content }` shape is also accepted.
1857
+ * top-level `{ role, content }` shape is also accepted. tool_result blocks
1858
+ * (also role:'user') are excluded so tool output never feeds the close-intent
1859
+ * gate — only top-level `type:'text'` blocks and string content count.
1051
1860
  *
1052
1861
  * @param {string} transcriptPath
1053
1862
  * @param {number} tailN how many trailing lines to scan (default 30)
@@ -1056,16 +1865,49 @@ export function isCompactOrClearCommand(prompt) {
1056
1865
  export function extractUserMessages(transcriptPath, tailN = 30) {
1057
1866
  try {
1058
1867
  const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
1059
- const tail = lines.slice(-tailN);
1868
+ // tailN === Infinity → whole transcript (the marker-write hard gate needs the
1869
+ // full prefix; a close request can precede the marker by the entire close
1870
+ // checklist). The Stop hook keeps the 30-line default so a stale old close
1871
+ // signal doesn't re-trigger every turn.
1872
+ const tail = Number.isFinite(tailN) ? lines.slice(-tailN) : lines;
1060
1873
  return tail
1061
1874
  .map((line) => {
1062
1875
  try {
1063
1876
  const obj = JSON.parse(line);
1877
+ // Skill-injection vector (ADR 0055): drop system-injected role:user
1878
+ // messages before they pollute the close-intent signal.
1879
+ // • isMeta:true — slash-command bodies, skill bodies, local-command
1880
+ // caveats. Their text is docs/specs, often full of close vocabulary
1881
+ // (e.g. the /hypo:crystallize spec literally contains close phrases),
1882
+ // which would let the gate self-satisfy the moment the model invokes
1883
+ // a close command. Confirmed isMeta:true in the transcript.
1884
+ // • promptSource system|sdk — task-notifications (system) and
1885
+ // SDK / QA-harness synthetic prompts (sdk). Neither is user-typed.
1886
+ if (obj.isMeta === true) return '';
1887
+ if (obj.promptSource === 'system' || obj.promptSource === 'sdk') return '';
1064
1888
  const msg = obj.message ?? obj;
1065
1889
  const role = msg.role ?? obj.role ?? obj.type;
1066
1890
  if (role !== 'user') return '';
1067
1891
  const content = msg.content ?? obj.content;
1068
- return typeof content === 'string' ? content : JSON.stringify(content);
1892
+ if (typeof content === 'string') {
1893
+ // Stop-hook block feedback is recorded as a role:user string. It is
1894
+ // the hook's OWN nudge ("[WIKI_AUTOCLOSE] … Run crystallize …"), not
1895
+ // user intent — counting it would be circular (the hook that prods the
1896
+ // model to close would become proof the user wanted to close).
1897
+ return content.startsWith('Stop hook feedback') ? '' : content;
1898
+ }
1899
+ if (Array.isArray(content)) {
1900
+ // Only genuine user-typed text blocks. tool_result blocks are also
1901
+ // recorded with role:'user' in the Claude Code transcript; slurping
1902
+ // them via JSON.stringify let tool output (e.g. close-pattern example
1903
+ // strings read out of code/docs) trip the close-intent gate.
1904
+ // Do NOT recurse into tool_result.content, or the pollution returns.
1905
+ return content
1906
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
1907
+ .map((b) => b.text)
1908
+ .join('\n');
1909
+ }
1910
+ return '';
1069
1911
  } catch {
1070
1912
  return '';
1071
1913
  }
@@ -1087,7 +1929,28 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
1087
1929
  export function isClosePattern(text) {
1088
1930
  if (!text || typeof text !== 'string') return false;
1089
1931
  const krPatterns = [
1090
- /세션\s*(끝|종료|마무리)/,
1932
+ // 끝: bare terminal noun, boundary-guarded so "세션 끝내는 방법" /
1933
+ // "세션 끝나면" don't trip.
1934
+ /세션\s*끝(?![가-힣])/,
1935
+ // 세션 마무리/종료 (ADR 0055): the OLD pattern required a fixed verb suffix
1936
+ // (하자/할게/했어) and missed the most common real phrasings — "세션 마무리
1937
+ // 해줘" (imperative), bare "세션 마무리", "세션마무리" (no space). A
1938
+ // blacklist lookahead (excluding 조건/로직/여부/…) is whack-a-mole because
1939
+ // noun-modifiers are an open class. WHITELIST instead: match only when
1940
+ // 마무리/종료 is followed by a close-intent verb suffix OR sentence-end. This
1941
+ // structurally rejects noun-modifiers (세션 종료 여부/로직/작업 정리) and
1942
+ // negations (세션 종료 안 해도 돼, 세션 마무리하지 않아도) without enumerating.
1943
+ // The suffixes are COMPLETE terminal forms followed by a non-Hangul boundary
1944
+ // (?![가-힣]), so connective continuations die: 해주는/해주기 (해 alone, then
1945
+ // 주 follows), 해야 하는 / 해도 되는지 (해 alone, then 야/도 follows). 하고 is
1946
+ // deliberately dropped — "마무리하고 블로그"(close) and "마무리하고 싶은지"(not)
1947
+ // are structurally identical, and over-close is the worse failure, so we accept
1948
+ // the rare FN over the FP. The residual: connective forms that happen to put a
1949
+ // space after a complete terminal can't be separated by regex without a
1950
+ // morphological parser — that's bounded by the compound gate (precompact-green
1951
+ // + signal) and the unambiguous /compact and AskUserQuestion channels (ADR 0055
1952
+ // threat boundary), not chased further.
1953
+ /세션\s*(?:마무리|종료)(?:\s*(?:해줘|해주세요|해요|해|하자|하죠|했어|했다|했음|했지|합시다|합니다|할게|할께|할래|할까|할까요|한\s?거(?:지|야|니)?|함)(?![가-힣])|\s*[)\].,!?~。…]|\s*$)/m,
1091
1954
  /오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
1092
1955
  // 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
1093
1956
  /여기(서)?까지(?!\s*(?:구현|작성|완성|수정|변경|추가|삭제|테스트|확인|검토|해줘|해야|하고|하면))/,
@@ -1100,17 +1963,147 @@ export function isClosePattern(text) {
1100
1963
  /오늘은?\s*이만/,
1101
1964
  ];
1102
1965
  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,
1105
- /done\s+for\s+(?:today|now|the\s+day)/i,
1106
- /that'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)/i,
1107
- /signing\s+off/i,
1108
- /end(?:ing)?\s+(?:the|this)\s+(?:session|work|day)/i,
1109
- /close\s+(?:the|this)\s+session/i,
1966
+ // wrap up: requires session-level context or sentence-end, not code-level
1967
+ // objects. The review/analysis/debug/audit/investigation nouns were added
1968
+ // for 6a — read-only review sessions are now "substantial", so "wrap up the
1969
+ // review" must read as a task-level signal, not a session-close one.
1970
+ // Leading \b on each pattern (ADR 0055) so an EN close phrase embedded as a
1971
+ // substring of a longer token can't trip the gate (e.g. "designing off…").
1972
+ /\bwrap(?: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,
1973
+ /\bdone\s+for\s+(?:today|now|the\s+day)\b/i,
1974
+ /\bthat'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)\b/i,
1975
+ /\bsigning\s+off\b/i,
1976
+ /\bend(?:ing)?\s+(?:the|this)\s+(?:session|work|day)\b/i,
1977
+ /\bclose\s+(?:the|this)\s+session\b/i,
1110
1978
  ];
1111
1979
  return [...krPatterns, ...enPatterns].some((re) => re.test(text));
1112
1980
  }
1113
1981
 
1982
+ /**
1983
+ * Resolve a session's transcript path from its (globally-unique) session id by
1984
+ * globbing every Claude project dir: ~/.claude/projects/<slug>/<id>.jsonl.
1985
+ *
1986
+ * Why glob, not cwd-derive: the transcript lives under a slug built from the cwd
1987
+ * AT SESSION START, but the marker-writer subprocess can run from a DIFFERENT cwd
1988
+ * (the real over-close ran `cd ~/hypomnema && crystallize …`), so a cwd-derived
1989
+ * slug would miss the file. The session id is a UUID, so the glob disambiguates
1990
+ * without needing the slug — verified globally unique across all project dirs.
1991
+ *
1992
+ * Fail-closed on ambiguity (ADR 0055, codex review): returns the single resolved
1993
+ * path, or null when ZERO or MORE-THAN-ONE distinct files match (realpath-deduped
1994
+ * so a symlink to the same file is not "multiple"). The caller treats null as
1995
+ * "refuse the marker".
1996
+ *
1997
+ * `projectsRoot` defaults to the real ~/.claude/projects; tests pass a controlled
1998
+ * root so they exercise the real resolver instead of a forgeable path override.
1999
+ */
2000
+ export function resolveTranscriptBySessionId(
2001
+ sessionId,
2002
+ projectsRoot = join(HOME, '.claude', 'projects'),
2003
+ ) {
2004
+ if (!sessionId || typeof sessionId !== 'string') return null;
2005
+ // Session ids are UUID-shaped; reject anything with path separators / glob
2006
+ // chars so a crafted id can't escape the projects root or match siblings.
2007
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
2008
+ try {
2009
+ const base = projectsRoot;
2010
+ const seen = new Set();
2011
+ for (const ent of readdirSync(base, { withFileTypes: true })) {
2012
+ if (!ent.isDirectory()) continue;
2013
+ const p = join(base, ent.name, `${sessionId}.jsonl`);
2014
+ if (!existsSync(p)) continue;
2015
+ try {
2016
+ seen.add(realpathSync(p));
2017
+ } catch {
2018
+ seen.add(p);
2019
+ }
2020
+ }
2021
+ return seen.size === 1 ? [...seen][0] : null;
2022
+ } catch {
2023
+ return null;
2024
+ }
2025
+ }
2026
+
2027
+ /**
2028
+ * Returns true iff the transcript carries a genuine USER session-close signal —
2029
+ * the hard gate for the session-closed marker writers (ADR 0055). Scans the FULL
2030
+ * transcript: a close request can precede the marker write by the entire close
2031
+ * checklist, so the Stop hook's 30-line tail would miss it.
2032
+ *
2033
+ * Evidence (any one is sufficient):
2034
+ * 1. a de-polluted NL close phrase — isClosePattern over extractUserMessages,
2035
+ * which already drops injected / tool / hook-feedback text (ADR 0055);
2036
+ * 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
2037
+ * counted: it abandons context, whereas a session-close PRESERVES the work
2038
+ * to the wiki — a different intent;
2039
+ * 3. an AskUserQuestion answer whose SELECTED value names a close action (the
2040
+ * canonical "offer [세션 마무리] → user picks it" flow).
2041
+ *
2042
+ * A Stop-hook block is NOT evidence: it is the hook's own nudge to close, so
2043
+ * counting it would be circular (the incident's block told the model to write the
2044
+ * marker). extractUserMessages already strips it.
2045
+ *
2046
+ * Fail-closed: any read error → false (caller refuses the marker).
2047
+ */
2048
+ export function hasUserCloseSignal(transcriptPath) {
2049
+ if (!transcriptPath) return false;
2050
+ let lines;
2051
+ try {
2052
+ lines = readFileSync(transcriptPath, 'utf-8').split('\n');
2053
+ } catch {
2054
+ return false;
2055
+ }
2056
+ // (1) NL close over the full, de-polluted transcript.
2057
+ if (isClosePattern(extractUserMessages(transcriptPath, Infinity))) return true;
2058
+ // AskUserQuestion answers (3) must be correlated to a real AskUserQuestion
2059
+ // tool_use by id — otherwise ANY tool_result string containing "have been
2060
+ // answered" (e.g. a Read/Grep of this very file, or of a transcript) would
2061
+ // satisfy the gate, reintroducing the tool_result pollution the de-pollution
2062
+ // layer closes. First pass collects the genuine AskUserQuestion tool_use ids;
2063
+ // the tool_use (assistant) always precedes its tool_result (user) in the log,
2064
+ // so a single forward scan suffices.
2065
+ const askIds = new Set();
2066
+ for (const line of lines) {
2067
+ if (!line.trim()) continue;
2068
+ let obj;
2069
+ try {
2070
+ obj = JSON.parse(line);
2071
+ } catch {
2072
+ continue;
2073
+ }
2074
+ // (2) /compact (queue-operation). Not /clear — see doc above.
2075
+ if (
2076
+ obj.type === 'queue-operation' &&
2077
+ typeof obj.content === 'string' &&
2078
+ /^\/compact(?:\s|$)/.test(obj.content.trim())
2079
+ ) {
2080
+ return true;
2081
+ }
2082
+ const content = (obj.message ?? obj).content;
2083
+ if (!Array.isArray(content)) continue;
2084
+ for (const b of content) {
2085
+ if (!b || typeof b !== 'object') continue;
2086
+ // record AskUserQuestion tool_use ids
2087
+ if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && b.id) {
2088
+ askIds.add(b.id);
2089
+ continue;
2090
+ }
2091
+ // (3) AskUserQuestion answer naming a close action — only when this
2092
+ // tool_result actually answers a recorded AskUserQuestion. The answer lands
2093
+ // in a role:user tool_result string: `… have been answered: "Q"="A". …`.
2094
+ // Match the answer value(s) (the `="…"` side), never the question text, and
2095
+ // run the SAME isClosePattern as the NL path so the two channels agree.
2096
+ if (b.type === 'tool_result' && b.tool_use_id && askIds.has(b.tool_use_id)) {
2097
+ const s = typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
2098
+ for (const m of s.matchAll(/="([^"]*)"/g)) {
2099
+ if (isClosePattern(m[1])) return true;
2100
+ }
2101
+ }
2102
+ }
2103
+ }
2104
+ return false;
2105
+ }
2106
+
1114
2107
  /**
1115
2108
  * Build hook output for Claude Code (additionalContext channel).
1116
2109
  * Codex hooks write systemMessage directly in their own files.