hypomnema 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +74 -26
  4. package/README.md +57 -9
  5. package/commands/audit.md +2 -2
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +1 -1
  11. package/docs/CONTRIBUTING.md +1 -1
  12. package/hooks/hooks.json +30 -1
  13. package/hooks/hypo-auto-commit.mjs +10 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +4 -3
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +111 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +69 -23
  20. package/hooks/hypo-hot-rebuild.mjs +22 -10
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +209 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +11 -5
  26. package/hooks/hypo-session-start.mjs +302 -52
  27. package/hooks/hypo-shared.mjs +817 -37
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +730 -47
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +398 -113
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +16 -6
  49. package/scripts/session-audit.mjs +37 -27
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +363 -49
  53. package/scripts/upgrade.mjs +706 -202
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +59 -25
  56. package/skills/crystallize/SKILL.md +20 -7
  57. package/skills/ingest/SKILL.md +25 -5
  58. package/templates/.hypoignore +16 -2
  59. package/templates/Home.md +2 -0
  60. package/templates/SCHEMA.md +61 -6
  61. package/templates/extensions/agents/.gitkeep +0 -0
  62. package/templates/extensions/commands/.gitkeep +0 -0
  63. package/templates/extensions/hooks/.gitkeep +0 -0
  64. package/templates/extensions/skills/.gitkeep +0 -0
  65. package/templates/gitignore +5 -0
  66. package/templates/hot.md +2 -0
  67. package/templates/hypo-config.md +1 -1
  68. package/templates/hypo-guide.md +42 -2
  69. package/templates/hypo-help.md +1 -1
  70. package/templates/pages/observability/_index.md +77 -0
  71. package/templates/projects/_template/index.md +2 -2
  72. package/templates/projects/_template/prd.md +1 -1
@@ -6,13 +6,46 @@
6
6
  * Hooks are deployed to ~/.claude/hooks/ — no external imports allowed.
7
7
  */
8
8
 
9
- import { readFileSync, existsSync } from 'fs';
9
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, rmSync } from 'fs';
10
10
  import { join, relative, basename } from 'path';
11
- import { homedir } from 'os';
11
+ import { homedir, hostname, tmpdir } from 'os';
12
12
  import { spawnSync } from 'child_process';
13
13
 
14
14
  const HOME = homedir();
15
15
 
16
+ // ── session marker path ─────────────────────────────────────────────────────
17
+ // hypo-session-start / hypo-cwd-change WRITE this marker; hypo-first-prompt
18
+ // READS + unlinks it. The session_id comes from the Claude Code runtime (a
19
+ // UUID), but we sanitize defensively so a malformed id with path separators or
20
+ // `..` can never escape tmpdir or collide on an empty value (codex review
21
+ // 2026-05-21, fix #3/#13). Non-alphanumeric chars collapse to `_`.
22
+ export function sessionMarkerPath(sessionId) {
23
+ const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
24
+ return join(tmpdir(), `hypo-session-marker-${safe}.json`);
25
+ }
26
+
27
+ // ── project name sanitizer for prompt-facing interpolation ─────────────────
28
+ // marker.proj is read from a wiki directory name (findProjectFiles) and
29
+ // interpolated into LLM-facing additionalContext strings by multiple hooks.
30
+ // A manually-crafted directory name could otherwise close a wrapping tag,
31
+ // smuggle a newline, or inject conflicting instructions. Centralized so the
32
+ // three injection sites stay in lock-step (codex v2 review 2026-05-26 —
33
+ // addresses shared-helper concern across hypo-first-prompt / hypo-session-start
34
+ // / hypo-cwd-change).
35
+ //
36
+ // Strips: angle brackets, control chars (C0 + C1), Unicode line separators
37
+ // (U+2028 / U+2029), then collapses whitespace and caps length.
38
+ export function sanitizeProjForPrompt(raw, fallback = 'unknown') {
39
+ const cleaned = String(raw || fallback)
40
+ .replace(/[<>\[\]]/g, '_')
41
+ // eslint-disable-next-line no-control-regex
42
+ .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, ' ')
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ .slice(0, 80);
46
+ return cleaned || fallback;
47
+ }
48
+
16
49
  // ── wiki root resolution ────────────────────────────────────────────────────
17
50
 
18
51
  function expandHome(p) {
@@ -42,16 +75,21 @@ function resolveHypoRoot() {
42
75
  return join(HOME, 'hypomnema');
43
76
  }
44
77
 
45
- export const HYPO_DIR = resolveHypoRoot();
46
- export const LOG_PATH = join(HYPO_DIR, 'log.md');
47
- export const HOT_PATH = join(HYPO_DIR, 'hot.md');
78
+ export const HYPO_DIR = resolveHypoRoot();
79
+ export const LOG_PATH = join(HYPO_DIR, 'log.md');
80
+ export const HOT_PATH = join(HYPO_DIR, 'hot.md');
48
81
  export const GUIDE_PATH = join(HYPO_DIR, 'hypo-guide.md');
49
82
 
50
83
  // Package root: written by init/upgrade to ~/.claude/hypo-pkg.json
51
84
  function resolvePkgRoot() {
52
85
  const p = join(HOME, '.claude', 'hypo-pkg.json');
53
86
  if (!existsSync(p)) return null;
54
- try { const v = JSON.parse(readFileSync(p, 'utf-8')).pkgRoot; return typeof v === 'string' && v ? v : null; } catch { return null; }
87
+ try {
88
+ const v = JSON.parse(readFileSync(p, 'utf-8')).pkgRoot;
89
+ return typeof v === 'string' && v ? v : null;
90
+ } catch {
91
+ return null;
92
+ }
55
93
  }
56
94
  export const PKG_ROOT = resolvePkgRoot();
57
95
 
@@ -59,7 +97,7 @@ export const PKG_ROOT = resolvePkgRoot();
59
97
  // Set HYPO_ALLOWED_HOT_H2=comma,separated,headings to enable.
60
98
  const _allowedH2Env = process.env.HYPO_ALLOWED_HOT_H2;
61
99
  export const ALLOWED_HOT_H2 = _allowedH2Env
62
- ? new Set(_allowedH2Env.split(',').map(s => s.trim()))
100
+ ? new Set(_allowedH2Env.split(',').map((s) => s.trim()))
63
101
  : null;
64
102
 
65
103
  // ── skip-gate helper ───────────────────────────────────────────────────────
@@ -74,22 +112,29 @@ export function isGateSkipped() {
74
112
  export function lastSubstantialOpIsSession() {
75
113
  if (!existsSync(LOG_PATH)) return true;
76
114
  const log = readFileSync(LOG_PATH, 'utf-8');
77
- const substantial = log.split('\n')
78
- .filter(l => /^## \[\d{4}-\d{2}-\d{2}\] (session|ingest)/.test(l));
115
+ const substantial = log
116
+ .split('\n')
117
+ .filter((l) => /^## \[\d{4}-\d{2}-\d{2}\] (session|ingest)/.test(l));
79
118
  if (substantial.length === 0) return true;
80
119
  return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
81
120
  }
82
121
 
83
- export function hypoIsClean() {
122
+ export function hypoIsClean(dir = HYPO_DIR) {
84
123
  try {
85
- const porcelain = spawnSync('git', ['-C', HYPO_DIR, 'status', '--porcelain'], { encoding: 'utf-8' });
86
- if (porcelain.status !== 0) return { clean: false, reason: `git check failed in ${HYPO_DIR}` };
87
- if (porcelain.stdout.trim() !== '') return { clean: false, reason: `uncommitted changes in ${HYPO_DIR}` };
88
- const ahead = spawnSync('git', ['-C', HYPO_DIR, 'status', '--branch', '--porcelain'], { encoding: 'utf-8' });
89
- if (/\[ahead \d+\]/.test(ahead.stdout || '')) return { clean: false, reason: `unpushed commits in ${HYPO_DIR}` };
124
+ const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain'], {
125
+ encoding: 'utf-8',
126
+ });
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'], {
131
+ encoding: 'utf-8',
132
+ });
133
+ if (/\[ahead \d+\]/.test(ahead.stdout || ''))
134
+ return { clean: false, reason: `unpushed commits in ${dir}` };
90
135
  return { clean: true };
91
136
  } catch {
92
- return { clean: false, reason: `git check failed in ${HYPO_DIR}` };
137
+ return { clean: false, reason: `git check failed in ${dir}` };
93
138
  }
94
139
  }
95
140
 
@@ -100,8 +145,8 @@ export function hotMdIsClean() {
100
145
 
101
146
  // Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
102
147
  if (ALLOWED_HOT_H2) {
103
- const h2s = [...content.matchAll(/^## (.+)$/gm)].map(m => m[1].trim());
104
- const extra = h2s.filter(h => !ALLOWED_HOT_H2.has(h));
148
+ const h2s = [...content.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
149
+ const extra = h2s.filter((h) => !ALLOWED_HOT_H2.has(h));
105
150
  if (extra.length > 0) reasons.push(`hot.md has unexpected H2 sections: ${extra.join(', ')}`);
106
151
  }
107
152
 
@@ -114,6 +159,650 @@ export function hotMdIsClean() {
114
159
  return reasons.length === 0 ? { clean: true } : { clean: false, reason: reasons.join(' / ') };
115
160
  }
116
161
 
162
+ // ── strict session-close verification ────────────────────────────
163
+ // spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
164
+ // 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
165
+ // (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
166
+ // project hot.md, root hot.md, session-log/YYYY-MM.md, and log.md.
167
+ // pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
168
+ // cross-project queue, so a session that raises no questions should not be
169
+ // forced to touch it. Gating it would produce false-blocks; spec §5.2.7
170
+ // records this as the intended policy.
171
+ //
172
+ // Known limitation: freshness is date-based per spec §8.3 ("timestamp가 같음"),
173
+ // so a second session on the same day that skips updating a file still passes
174
+ // if an earlier close that day already stamped it. freshDates() accepting both
175
+ // 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.
177
+
178
+ /** Parse the frontmatter `updated:` field. Returns the trimmed value or null. */
179
+ function frontmatterUpdated(content) {
180
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
181
+ if (!m) return null;
182
+ const u = m[1].match(/^updated:\s*(.+)$/m);
183
+ return u ? u[1].trim().replace(/^["']|["']$/g, '') : null;
184
+ }
185
+
186
+ /** Escape a string for safe literal use inside a RegExp. */
187
+ function escapeRegExp(s) {
188
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
189
+ }
190
+
191
+ /**
192
+ * True if `content` carries an ATX heading dated `date` (YYYY-MM-DD), e.g.
193
+ * `## [2026-05-15] something`. Matches H1-H6.
194
+ * Shared by sessionCloseFileStatus() and the crystallize apply helper so both
195
+ * use the same definition of "session-log already updated for today".
196
+ */
197
+ export function hasSessionLogHeading(content, date) {
198
+ return new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]', 'm').test(content || '');
199
+ }
200
+
201
+ /**
202
+ * True if `content` carries a today-dated `## [date] session | <project>` entry
203
+ * in log.md.
204
+ *
205
+ * Bounded with an explicit `(?=\s|$)` lookahead, NOT `\b`: a regex word boundary
206
+ * matches between word and non-word chars, so `\b` after "foo" still matches in
207
+ * "foo-bar" (hyphen is non-word). The canonical log format always separates the
208
+ * project slug from anything that follows by whitespace or end-of-line, so the
209
+ * lookahead correctly rejects "session | foo-bar" when looking for "foo".
210
+ * (Reported by codex review of fix #38 — was a pre-existing bug in
211
+ * sessionCloseFileStatus that the helper extraction inherited.)
212
+ */
213
+ export function hasLogEntry(content, date, project) {
214
+ return new RegExp(
215
+ '^## \\[' + escapeRegExp(date) + '\\] session \\| ' + escapeRegExp(project) + '(?=\\s|$)',
216
+ 'm',
217
+ ).test(content || '');
218
+ }
219
+
220
+ /**
221
+ * Date strings that count as "today" for freshness checks. Both the local and
222
+ * UTC dates are accepted: Claude writes file dates in the user's local zone,
223
+ * while hypo-hot-rebuild stamps root hot.md with the UTC date. Accepting both
224
+ * removes the ~timezone-offset window where a correctly closed session would
225
+ * otherwise false-block.
226
+ * @returns {string[]} 1-2 ISO dates (YYYY-MM-DD), most-relevant first.
227
+ */
228
+ export function freshDates() {
229
+ const d = new Date();
230
+ const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
231
+ const utc = d.toISOString().slice(0, 10);
232
+ return local === utc ? [local] : [local, utc];
233
+ }
234
+
235
+ /**
236
+ * Resolve the most-recently-active project slug from root hot.md.
237
+ * Mirrors scripts/resume.mjs resolveActiveProject — kept in sync by hand.
238
+ * @param {string} hypoDir
239
+ * @returns {string|null}
240
+ */
241
+ export function resolveActiveProject(hypoDir) {
242
+ const hotPath = join(hypoDir, 'hot.md');
243
+ if (!existsSync(hotPath)) return null;
244
+ let content;
245
+ try {
246
+ // Strip HTML comments before parsing so the canonical-format example row
247
+ // in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
248
+ content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
249
+ } catch {
250
+ return null;
251
+ }
252
+ // Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
253
+ const wikiRows = [
254
+ ...content.matchAll(
255
+ /\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
256
+ ),
257
+ ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
258
+ if (wikiRows.length > 0) {
259
+ wikiRows.sort((a, b) => b.date.localeCompare(a.date));
260
+ return wikiRows[0].slug;
261
+ }
262
+ // Legacy markdown-link rows: | [name](projects/name/...) | ...
263
+ const mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
264
+ if (mdRow) return mdRow[2];
265
+ return null;
266
+ }
267
+
268
+ /**
269
+ * Strict session-close verification (fix #17, spec §5.2.7 / §8.3).
270
+ * Confirms the memory files a session close must touch were updated today:
271
+ * - projects/<project>/session-state.md — frontmatter `updated:` is today
272
+ * - projects/<project>/hot.md — frontmatter `updated:` is today
273
+ * - hot.md (root) — frontmatter `updated:` is today
274
+ * - projects/<project>/session-log/YYYY-MM.md — has a `## [today]` heading
275
+ * - log.md — has a `## [today] session | <project>` entry
276
+ * The log.md check is project-scoped so a session close left incomplete for
277
+ * project A can't be masked by a fresh close of project B (and vice versa).
278
+ * open-questions.md (file #5) is conditional and not gated.
279
+ *
280
+ * @param {string} hypoDir
281
+ * @returns {{ok: boolean, project: string|null, dates: string[], stale: string[], missing: string[]}}
282
+ */
283
+ export function sessionCloseFileStatus(hypoDir) {
284
+ const dates = freshDates();
285
+ const project = resolveActiveProject(hypoDir);
286
+ if (!project) {
287
+ return {
288
+ ok: false,
289
+ project: null,
290
+ dates,
291
+ stale: [],
292
+ missing: ['hot.md (no active project in pointer table)'],
293
+ };
294
+ }
295
+
296
+ const stale = []; // exists but not updated this session
297
+ const missing = []; // file does not exist
298
+
299
+ const checkUpdated = (relPath) => {
300
+ const full = join(hypoDir, relPath);
301
+ if (!existsSync(full)) {
302
+ missing.push(relPath);
303
+ return;
304
+ }
305
+ let content;
306
+ try {
307
+ content = readFileSync(full, 'utf-8');
308
+ } catch {
309
+ missing.push(relPath);
310
+ return;
311
+ }
312
+ if (!dates.includes(frontmatterUpdated(content))) stale.push(relPath);
313
+ };
314
+
315
+ checkUpdated(join('projects', project, 'session-state.md'));
316
+ checkUpdated(join('projects', project, 'hot.md'));
317
+ checkUpdated('hot.md');
318
+
319
+ // session-log: monthly append-only file — must carry a today-dated heading.
320
+ // Reported under the local date's month (dates[0]) when no match is found.
321
+ let sessionLogOk = false;
322
+ for (const date of dates) {
323
+ const full = join(hypoDir, 'projects', project, 'session-log', `${date.slice(0, 7)}.md`);
324
+ if (!existsSync(full)) continue;
325
+ let content = '';
326
+ try {
327
+ content = readFileSync(full, 'utf-8');
328
+ } catch {
329
+ continue;
330
+ }
331
+ if (hasSessionLogHeading(content, date)) {
332
+ sessionLogOk = true;
333
+ break;
334
+ }
335
+ }
336
+ if (!sessionLogOk) {
337
+ const logRel = join('projects', project, 'session-log', `${dates[0].slice(0, 7)}.md`);
338
+ (existsSync(join(hypoDir, logRel)) ? stale : missing).push(logRel);
339
+ }
340
+
341
+ // log.md: must carry a today-dated `session` entry for the resolved project.
342
+ const logFull = join(hypoDir, 'log.md');
343
+ if (!existsSync(logFull)) {
344
+ missing.push('log.md');
345
+ } else {
346
+ let content = '';
347
+ try {
348
+ content = readFileSync(logFull, 'utf-8');
349
+ } catch {
350
+ missing.push('log.md');
351
+ }
352
+ const logFresh = content && dates.some((d) => hasLogEntry(content, d, project));
353
+ if (content && !logFresh) stale.push('log.md');
354
+ }
355
+
356
+ return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
357
+ }
358
+
359
+ // ── sync-state ────────────────────────────────────────────
360
+ // `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
361
+ // line. hypo-auto-commit (#9) appends on pull/push failure; hypo-session-start
362
+ // (#10) surfaces open entries and clears them once sync is healthy again;
363
+ // doctor (#11) warns while entries remain. Keep the schema defined here only.
364
+
365
+ /** @returns {string} path to the sync-state JSONL file for a wiki root. */
366
+ function syncStatePath(hypoDir) {
367
+ return join(hypoDir, '.cache', 'sync-state.json');
368
+ }
369
+
370
+ /**
371
+ * Append a sync failure entry. Best-effort — never throws, since a failed
372
+ * failure-log must not break the Stop hook that calls it.
373
+ *
374
+ * @param {string} hypoDir
375
+ * @param {'pull'|'push'} op
376
+ * @param {string} error raw stderr/stdout; first non-empty line is kept
377
+ */
378
+ export function appendSyncFailure(hypoDir, op, error) {
379
+ try {
380
+ const cacheDir = join(hypoDir, '.cache');
381
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
382
+ const firstLine =
383
+ String(error || '')
384
+ .split('\n')
385
+ .map((l) => l.trim())
386
+ .find(Boolean) || 'unknown error';
387
+ const entry = {
388
+ timestamp: new Date().toISOString(),
389
+ op,
390
+ error: firstLine.slice(0, 200),
391
+ host: hostname(),
392
+ };
393
+ appendFileSync(syncStatePath(hypoDir), JSON.stringify(entry) + '\n');
394
+ } catch {
395
+ // best-effort
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Read sync-state entries.
401
+ * @param {string} hypoDir
402
+ * @returns {{entries: object[], parseError: boolean}}
403
+ */
404
+ export function readSyncState(hypoDir) {
405
+ const path = syncStatePath(hypoDir);
406
+ if (!existsSync(path)) return { entries: [], parseError: false };
407
+ try {
408
+ const entries = readFileSync(path, 'utf-8')
409
+ .split('\n')
410
+ .filter((l) => l.trim())
411
+ .map((l) => JSON.parse(l));
412
+ return { entries, parseError: false };
413
+ } catch {
414
+ return { entries: [], parseError: true };
415
+ }
416
+ }
417
+
418
+ /** Remove the sync-state file. Called once sync is healthy again. Best-effort. */
419
+ export function clearSyncState(hypoDir) {
420
+ try {
421
+ rmSync(syncStatePath(hypoDir), { force: true });
422
+ } catch {
423
+ // best-effort
424
+ }
425
+ }
426
+
427
+ // ── auto-project suggestion (ADR 0023) ────────────────────────────
428
+ // `.cache/project-suggestions.json` is a single JSON object:
429
+ // { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
430
+ // `skips` is written by the LLM (Layer-1 behavioral rule) when the user answers
431
+ // "N" to an auto-project offer — permanent per-cwd suppression (no TTL).
432
+ // `cooldowns` is written by the hook each time it emits an offer — a 5-minute
433
+ // same-cwd noise guard. The two live in one file so doctor validates a single
434
+ // schema. Both reads/writes are best-effort; a failure only loses the offer,
435
+ // never breaks SessionStart/CwdChanged.
436
+
437
+ const PROJECT_MARKERS = [
438
+ 'package.json',
439
+ 'Cargo.toml',
440
+ 'go.mod',
441
+ 'pyproject.toml',
442
+ 'pom.xml',
443
+ 'build.gradle',
444
+ 'composer.json',
445
+ 'Gemfile',
446
+ ];
447
+ const SUGGESTION_COOLDOWN_MS = 5 * 60 * 1000;
448
+
449
+ /** @returns {string} path to the project-suggestions file for a wiki root. */
450
+ export function projectSuggestionsPath(hypoDir) {
451
+ return join(hypoDir, '.cache', 'project-suggestions.json');
452
+ }
453
+
454
+ /**
455
+ * Read the project-suggestions store.
456
+ * @param {string} hypoDir
457
+ * @returns {{skips: object[], cooldowns: Record<string,string>, parseError: boolean}}
458
+ */
459
+ export function readProjectSuggestions(hypoDir) {
460
+ const path = projectSuggestionsPath(hypoDir);
461
+ if (!existsSync(path)) return { skips: [], cooldowns: {}, parseError: false };
462
+ try {
463
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
464
+ return {
465
+ skips: Array.isArray(data.skips) ? data.skips : [],
466
+ cooldowns: data.cooldowns && typeof data.cooldowns === 'object' ? data.cooldowns : {},
467
+ parseError: false,
468
+ };
469
+ } catch {
470
+ return { skips: [], cooldowns: {}, parseError: true };
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Record that an offer was just emitted for `cwd`, starting the cooldown.
476
+ * Preserves the existing skips array. Best-effort.
477
+ */
478
+ export function recordSuggestionCooldown(hypoDir, cwd, now = new Date()) {
479
+ try {
480
+ const cacheDir = join(hypoDir, '.cache');
481
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
482
+ const { skips, cooldowns } = readProjectSuggestions(hypoDir);
483
+ cooldowns[cwd] = now.toISOString();
484
+ writeFileSync(
485
+ projectSuggestionsPath(hypoDir),
486
+ JSON.stringify({ skips, cooldowns }, null, 2) + '\n',
487
+ );
488
+ } catch {
489
+ // best-effort
490
+ }
491
+ }
492
+
493
+ /** True when `cwd` carries one of the recognized project-root markers. */
494
+ export function cwdHasProjectMarker(cwd) {
495
+ return PROJECT_MARKERS.some((m) => existsSync(join(cwd, m)));
496
+ }
497
+
498
+ /**
499
+ * Decide whether SessionStart/CwdChanged should offer to create a project for
500
+ * `cwd`. The caller MUST have already confirmed `cwd` matches no project's
501
+ * `working_dir` (the hook's MISS branch); this evaluates the remaining ADR 0023
502
+ * trigger conditions: (a) cwd is a git repo, (b) carries a project marker
503
+ * (`.git` alone is a weak signal — §8.11), (c) not in cooldown, (d) not a cwd
504
+ * the user previously declined. A corrupt store stays silent (doctor surfaces).
505
+ *
506
+ * @param {string} cwd
507
+ * @param {string} [hypoDir]
508
+ * @param {number} [now] epoch ms, injectable for tests
509
+ * @returns {boolean}
510
+ */
511
+ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date.now()) {
512
+ if (!cwd) return false;
513
+ if (!existsSync(join(cwd, '.git'))) return false;
514
+ if (!cwdHasProjectMarker(cwd)) return false;
515
+ const { skips, cooldowns, parseError } = readProjectSuggestions(hypoDir);
516
+ if (parseError) return false;
517
+ if (skips.some((s) => s && s.cwd === cwd)) return false;
518
+ const ts = cooldowns[cwd];
519
+ if (ts) {
520
+ const t = Date.parse(ts);
521
+ if (Number.isFinite(t) && now - t < SUGGESTION_COOLDOWN_MS) return false;
522
+ }
523
+ return true;
524
+ }
525
+
526
+ /**
527
+ * Build the §8.11 auto-project offer line for a cwd. The display name is the
528
+ * cwd basename, which is attacker-influenced (a directory name can contain
529
+ * newlines/control chars on Unix). Strip control characters and length-cap it
530
+ * so a crafted dir name cannot spoof extra instructions in additionalContext
531
+ * (codex review 2026-05-22).
532
+ */
533
+ export function buildProjectSuggestionLine(cwd) {
534
+ // Replace any control char (code < 0x20 or === 0x7F) with a space so a
535
+ // crafted dir name cannot inject newlines/instructions into additionalContext
536
+ // (codex review 2026-05-22). Done by codepoint to keep control bytes out of
537
+ // this source file.
538
+ const sanitized = Array.from(basename(cwd))
539
+ .map((ch) => {
540
+ const code = ch.codePointAt(0);
541
+ return code < 0x20 || code === 0x7f ? ' ' : ch;
542
+ })
543
+ .join('');
544
+ const safe = sanitized.slice(0, 80).trim() || 'project';
545
+ return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
546
+ }
547
+
548
+ // ── clear-marker (ADR 0022 amendment 2026-05-14) ────────────
549
+ // `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
550
+ // point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
551
+ // SessionEnd writes `.cache/clear-marker.json` with the dying session's id +
552
+ // transcript path; SessionStart on `source=clear` reads, injects a recovery
553
+ // nudge into additionalContext, and unlinks (one-shot). A 7-day stale guard
554
+ // prevents an orphaned marker (SessionEnd fired but new session never began)
555
+ // from polluting an unrelated later session.
556
+
557
+ const CLEAR_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
558
+
559
+ /** @returns {string} path to the clear-marker file for a wiki root. */
560
+ function clearMarkerPath(hypoDir) {
561
+ return join(hypoDir, '.cache', 'clear-marker.json');
562
+ }
563
+
564
+ /**
565
+ * Persist the dying session's identity so the next SessionStart(source=clear)
566
+ * can issue a recovery nudge. Single-file by design (see ADR 0022 amendment):
567
+ * /clear is a single-client UX action, multi-marker disambiguation buys no
568
+ * safety and breaks the 1-of-1 read-and-unlink contract.
569
+ *
570
+ * Best-effort: a failure here only loses the recovery nudge, never the user's
571
+ * `/clear` itself.
572
+ *
573
+ * @param {string} hypoDir
574
+ * @param {{prev_session_id: string, prev_transcript_path: string, prev_cwd?: string}} info
575
+ */
576
+ export function writeClearMarker(hypoDir, info) {
577
+ try {
578
+ const cacheDir = join(hypoDir, '.cache');
579
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
580
+ const payload = {
581
+ prev_session_id: info.prev_session_id || null,
582
+ prev_transcript_path: info.prev_transcript_path || null,
583
+ prev_cwd: info.prev_cwd || null,
584
+ ts: new Date().toISOString(),
585
+ };
586
+ writeFileSync(clearMarkerPath(hypoDir), JSON.stringify(payload) + '\n');
587
+ } catch (err) {
588
+ process.stderr.write(`[hypo] clear-marker write failed: ${err?.message || err}\n`);
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Read the clear-marker if present and not stale (>7 days). Returns null when
594
+ * absent, unreadable, or expired. Stale markers are unlinked here so a single
595
+ * SessionStart cleans them up — no separate cron needed.
596
+ *
597
+ * @param {string} hypoDir
598
+ * @returns {object|null}
599
+ */
600
+ export function readClearMarker(hypoDir) {
601
+ const path = clearMarkerPath(hypoDir);
602
+ if (!existsSync(path)) return null;
603
+ try {
604
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
605
+ const ts = Date.parse(data?.ts || '');
606
+ if (!Number.isFinite(ts) || Date.now() - ts > CLEAR_MARKER_STALE_MS) {
607
+ rmSync(path, { force: true });
608
+ return null;
609
+ }
610
+ return data;
611
+ } catch (err) {
612
+ // Corrupt marker: emit a debug line AND unlink so the next SessionStart
613
+ // does not log the same parse error forever (self-cleanup invariant).
614
+ process.stderr.write(`[hypo] clear-marker read failed: ${err?.message || err}\n`);
615
+ try {
616
+ rmSync(path, { force: true });
617
+ } catch {
618
+ // best-effort
619
+ }
620
+ return null;
621
+ }
622
+ }
623
+
624
+ /** Delete the clear-marker. One-shot contract — caller is SessionStart. */
625
+ export function clearClearMarker(hypoDir) {
626
+ try {
627
+ rmSync(clearMarkerPath(hypoDir), { force: true });
628
+ } catch {
629
+ // best-effort
630
+ }
631
+ }
632
+
633
+ // ── session-closed marker (ADR 0022 amendment 2026-05-19) ────
634
+ // Per-session marker proving session-close completed. Stop hook
635
+ // (`hypo-auto-minimal-crystallize`) reads it; `scripts/crystallize.mjs` writes
636
+ // it after a verified close. Per-session (not per-day) precision resolves the
637
+ // codex BLOCKER from 2026-05-14: log.md date-level check false-passes when a
638
+ // later session reuses an earlier session's entry on the same day.
639
+ //
640
+ // Writer authority lives in crystallize, NOT this hook: the hook only checks
641
+ // presence. See ADR 0022 amendment 2026-05-19 Q2 for the split rationale.
642
+
643
+ const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
644
+
645
+ /** Sanitize session_id for filesystem use — Claude session_ids are UUIDs but
646
+ * defend against accidental path traversal regardless. */
647
+ function sanitizeSessionId(sessionId) {
648
+ return String(sessionId)
649
+ .replace(/[^A-Za-z0-9._-]/g, '_')
650
+ .slice(0, 128);
651
+ }
652
+
653
+ /** @returns {string} path to the session-closed marker for a given session_id. */
654
+ export function sessionClosedMarkerPath(hypoDir, sessionId) {
655
+ return join(hypoDir, '.cache', `session-closed-${sanitizeSessionId(sessionId)}.marker`);
656
+ }
657
+
658
+ /**
659
+ * Persist a per-session close proof. Caller MUST verify
660
+ * `sessionCloseFileStatus(hypoDir).ok` before invoking — this helper does NOT
661
+ * re-check; that's the writer's contract (crystallize.mjs).
662
+ *
663
+ * Best-effort: stderr debug line on failure, no exception propagation.
664
+ *
665
+ * @param {string} hypoDir
666
+ * @param {string} sessionId
667
+ * @param {{project?: string, transcript_path?: string}} info
668
+ */
669
+ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
670
+ if (!sessionId) return;
671
+ try {
672
+ const cacheDir = join(hypoDir, '.cache');
673
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
674
+ const payload = {
675
+ session_id: sessionId,
676
+ project: info.project || null,
677
+ transcript_path: info.transcript_path || null,
678
+ closed_at: new Date().toISOString(),
679
+ verification: 'session-close-file-status:ok',
680
+ };
681
+ writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
682
+ } catch (err) {
683
+ process.stderr.write(`[hypo] session-closed marker write failed: ${err?.message || err}\n`);
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Read the session-closed marker for `sessionId` if present and not stale
689
+ * (>7 days). Returns null when absent, unreadable, or expired. Stale/corrupt
690
+ * markers are unlinked here so a single Stop hook call cleans them up — no
691
+ * separate sweeper needed (mirrors clear-marker self-cleanup invariant).
692
+ *
693
+ * @param {string} hypoDir
694
+ * @param {string} sessionId
695
+ * @returns {object|null}
696
+ */
697
+ export function readSessionClosedMarker(hypoDir, sessionId) {
698
+ if (!sessionId) return null;
699
+ const path = sessionClosedMarkerPath(hypoDir, sessionId);
700
+ if (!existsSync(path)) return null;
701
+ try {
702
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
703
+ const ts = Date.parse(data?.closed_at || '');
704
+ if (!Number.isFinite(ts) || Date.now() - ts > SESSION_CLOSED_MARKER_STALE_MS) {
705
+ rmSync(path, { force: true });
706
+ return null;
707
+ }
708
+ return data;
709
+ } catch (err) {
710
+ process.stderr.write(`[hypo] session-closed marker read failed: ${err?.message || err}\n`);
711
+ try {
712
+ rmSync(path, { force: true });
713
+ } catch {
714
+ // best-effort
715
+ }
716
+ return null;
717
+ }
718
+ }
719
+
720
+ /** Delete a session-closed marker. Test/maintenance helper. */
721
+ export function clearSessionClosedMarker(hypoDir, sessionId) {
722
+ if (!sessionId) return;
723
+ try {
724
+ rmSync(sessionClosedMarkerPath(hypoDir, sessionId), { force: true });
725
+ } catch {
726
+ // best-effort
727
+ }
728
+ }
729
+
730
+ // ── transcript activity heuristic (ADR 0022 amendment 2026-05-19) ──
731
+ // Substantial-session gate for the Stop hook: a session that performed at least
732
+ // one mutation tool call (Edit / Write / MultiEdit / NotebookEdit) is "worth"
733
+ // blocking on for session-close. Pure Q&A / read-only sessions skip the block.
734
+ //
735
+ // Bash is intentionally excluded — running tests would otherwise trigger
736
+ // block. Future fix may broaden to read-heavy sessions (Grep ≥ N).
737
+
738
+ const MUTATING_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
739
+
740
+ /** Mirror of `scripts/session-audit.mjs` extractToolNames: handles both top-level
741
+ * `tool_use` entries (legacy fixtures) and nested `message.content[].tool_use`
742
+ * blocks (real Claude Code transcripts). */
743
+ function extractTranscriptToolNames(entry) {
744
+ const names = [];
745
+ if (!entry || typeof entry !== 'object') return names;
746
+ if (entry.type === 'tool_use') {
747
+ const n = entry.name || entry.tool_name;
748
+ if (n) names.push(n);
749
+ } else if (entry.tool_name || entry.name) {
750
+ if (entry.type === undefined || entry.type === 'tool_use') {
751
+ const n = entry.tool_name || entry.name;
752
+ if (n) names.push(n);
753
+ }
754
+ }
755
+ const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
756
+ if (Array.isArray(content)) {
757
+ for (const block of content) {
758
+ if (block && typeof block === 'object' && block.type === 'tool_use') {
759
+ const n = block.name || block.tool_name;
760
+ if (n) names.push(n);
761
+ }
762
+ }
763
+ }
764
+ return names;
765
+ }
766
+
767
+ /**
768
+ * True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
769
+ * tool_use (Edit/Write/MultiEdit/NotebookEdit).
770
+ *
771
+ * Granularity:
772
+ * • Whole-file unreadable / missing path → returns false (fail-open).
773
+ * • Per-line malformed JSON → that line is skipped, scan continues. Real
774
+ * transcripts occasionally carry truncated lines; one bad line must not
775
+ * hide a clearly-mutating session that follows. (Codex Worker-2 CONCERN
776
+ * resolved 2026-05-19: line-level skip is the intended contract.)
777
+ *
778
+ * @param {string|null|undefined} transcriptPath
779
+ * @returns {boolean}
780
+ */
781
+ export function hasMutatingTranscriptActivity(transcriptPath) {
782
+ if (!transcriptPath || typeof transcriptPath !== 'string') return false;
783
+ if (!existsSync(transcriptPath)) return false;
784
+ let raw;
785
+ try {
786
+ raw = readFileSync(transcriptPath, 'utf-8');
787
+ } catch {
788
+ return false;
789
+ }
790
+ for (const line of raw.split('\n')) {
791
+ const trimmed = line.trim();
792
+ if (!trimmed) continue;
793
+ let entry;
794
+ try {
795
+ entry = JSON.parse(trimmed);
796
+ } catch {
797
+ continue;
798
+ }
799
+ for (const name of extractTranscriptToolNames(entry)) {
800
+ if (MUTATING_TOOL_NAMES.has(name)) return true;
801
+ }
802
+ }
803
+ return false;
804
+ }
805
+
117
806
  // ── session-close checklist ────────────────────────────────────────────────
118
807
 
119
808
  /**
@@ -152,6 +841,86 @@ export function isCompactCommand(prompt) {
152
841
  return prompt === '/compact' || /^\/compact(\s|$)/.test(prompt);
153
842
  }
154
843
 
844
+ /** Returns true if the prompt is a /clear command invocation. */
845
+ export function isClearCommand(prompt) {
846
+ return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
847
+ }
848
+
849
+ /** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2, fix #25). */
850
+ export function isCompactOrClearCommand(prompt) {
851
+ return isCompactCommand(prompt) || isClearCommand(prompt);
852
+ }
853
+
854
+ /**
855
+ * Extract recent user-role message text from a JSONL transcript (last `tailN`
856
+ * lines). Promoted from hypo-personal-check.mjs (fix #27 PR-C) so both the
857
+ * PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
858
+ * signal source. Claude Code transcript format: each line is
859
+ * `{ type:"user", message:{ role:"user", content: ... } }`; the older
860
+ * top-level `{ role, content }` shape is also accepted.
861
+ *
862
+ * @param {string} transcriptPath
863
+ * @param {number} tailN how many trailing lines to scan (default 30)
864
+ * @returns {string} newline-joined user text, or '' on any failure (fail-open)
865
+ */
866
+ export function extractUserMessages(transcriptPath, tailN = 30) {
867
+ try {
868
+ const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
869
+ const tail = lines.slice(-tailN);
870
+ return tail
871
+ .map((line) => {
872
+ try {
873
+ const obj = JSON.parse(line);
874
+ const msg = obj.message ?? obj;
875
+ const role = msg.role ?? obj.role ?? obj.type;
876
+ if (role !== 'user') return '';
877
+ const content = msg.content ?? obj.content;
878
+ return typeof content === 'string' ? content : JSON.stringify(content);
879
+ } catch {
880
+ return '';
881
+ }
882
+ })
883
+ .join('\n');
884
+ } catch {
885
+ return '';
886
+ }
887
+ }
888
+
889
+ /**
890
+ * Returns true if the text contains a natural-language session-close signal.
891
+ * Scans Korean and English close patterns. Designed for transcript user-message
892
+ * text — favours low false-positive rate over recall.
893
+ *
894
+ * Examples that match: "세션 마무리하자", "오늘 여기까지", "wrap up", "signing off".
895
+ * Examples that do NOT match: "이 함수 마무리하자", "wrap up this PR".
896
+ */
897
+ export function isClosePattern(text) {
898
+ if (!text || typeof text !== 'string') return false;
899
+ const krPatterns = [
900
+ /세션\s*(끝|종료|마무리)/,
901
+ /오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
902
+ // 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
903
+ /여기(서)?까지(?!\s*(?:구현|작성|완성|수정|변경|추가|삭제|테스트|확인|검토|해줘|해야|하고|하면))/,
904
+ /이만\s*(마치|끝|종료|마무리)/,
905
+ // 작업 종료/마무리: requires verb ending, not noun modifier (e.g. 작업 종료 조건을 is not a close signal)
906
+ /작업\s*(?:마무리|종료)\s*(?:하자|할게|하겠|했어|임)/,
907
+ /오늘은?\s*여기/,
908
+ /그만\s*(하자|할게|하겠|합시다)/,
909
+ /슬슬\s*(마무리|종료)/,
910
+ /오늘은?\s*이만/,
911
+ ];
912
+ const enPatterns = [
913
+ // wrap up: requires session-level context or sentence-end, not code-level objects
914
+ /wrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test)\b)/i,
915
+ /done\s+for\s+(?:today|now|the\s+day)/i,
916
+ /that'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)/i,
917
+ /signing\s+off/i,
918
+ /end(?:ing)?\s+(?:the|this)\s+(?:session|work|day)/i,
919
+ /close\s+(?:the|this)\s+session/i,
920
+ ];
921
+ return [...krPatterns, ...enPatterns].some((re) => re.test(text));
922
+ }
923
+
155
924
  /**
156
925
  * Build hook output for Claude Code (additionalContext channel).
157
926
  * Codex hooks write systemMessage directly in their own files.
@@ -174,12 +943,12 @@ export function buildOutput(context, extra = {}) {
174
943
  * @returns {string}
175
944
  */
176
945
  export function formatGrowthMetrics(mode, stats) {
177
- const a = Number(stats?.addedPages) || 0;
946
+ const a = Number(stats?.addedPages) || 0;
178
947
  const u = Number(stats?.updatedPages) || 0;
179
948
  const w = Number(stats?.newWikilinks) || 0;
180
949
  if (a === 0 && u === 0 && w === 0) return '';
181
950
  const body = `+${a} pages, ~${u} updated, ${w} wikilinks`;
182
- if (mode === 'stop') return `[hypo] ${body}`;
951
+ if (mode === 'stop') return `[hypo] ${body}`;
183
952
  if (mode === 'start') return `[hypo] 직전 세션: ${body}. 이어서 볼까요?`;
184
953
  return '';
185
954
  }
@@ -201,9 +970,13 @@ export function computeSessionGrowth(hypoDir) {
201
970
  // more expensive `git diff HEAD --unified=0` — Stop hook P95 win.
202
971
  // `-uall` expands untracked directories so a brand-new `pages/new.md`
203
972
  // isn't hidden behind a single `?? pages/` line.
204
- const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], { encoding: 'utf-8', timeout: 5000 });
973
+ const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], {
974
+ encoding: 'utf-8',
975
+ timeout: 5000,
976
+ });
205
977
  if (porcelain.status !== 0) return empty;
206
- let addedPages = 0, updatedPages = 0;
978
+ let addedPages = 0,
979
+ updatedPages = 0;
207
980
  let hasTrackedMdChange = false;
208
981
  const untrackedMd = [];
209
982
  // Growth metrics describe wiki page activity, so restrict to the two
@@ -213,17 +986,22 @@ export function computeSessionGrowth(hypoDir) {
213
986
  file.endsWith('.md') && (file.startsWith('pages/') || file.startsWith('projects/'));
214
987
  for (const line of (porcelain.stdout || '').split('\n')) {
215
988
  if (!line) continue;
216
- const xy = line.slice(0, 2);
989
+ const xy = line.slice(0, 2);
217
990
  const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
218
991
  if (!inPagesScope(file)) continue;
219
- if (xy === '??') { untrackedMd.push(file); addedPages++; continue; }
992
+ if (xy === '??') {
993
+ untrackedMd.push(file);
994
+ addedPages++;
995
+ continue;
996
+ }
220
997
  hasTrackedMdChange = true;
221
998
  if (xy.includes('A')) addedPages++;
222
999
  else if (xy.includes('M') || xy.includes('R')) updatedPages++;
223
1000
  }
224
1001
  if (!hasTrackedMdChange && untrackedMd.length === 0) return empty;
225
1002
 
226
- let plus = 0, minus = 0;
1003
+ let plus = 0,
1004
+ minus = 0;
227
1005
  if (hasTrackedMdChange) {
228
1006
  // pathspec keeps non-Markdown / out-of-scope diffs from polluting the
229
1007
  // wikilink count. Without it, a `[[…]]` string in a script.js diff was
@@ -231,14 +1009,14 @@ export function computeSessionGrowth(hypoDir) {
231
1009
  const diff = spawnSync(
232
1010
  'git',
233
1011
  ['-C', hypoDir, 'diff', 'HEAD', '--unified=0', '--', 'pages/', 'projects/'],
234
- { encoding: 'utf-8', timeout: 10000 }
1012
+ { encoding: 'utf-8', timeout: 10000 },
235
1013
  );
236
1014
  if (diff.status === 0) {
237
1015
  for (const line of (diff.stdout || '').split('\n')) {
238
1016
  if (line.startsWith('+++') || line.startsWith('---')) continue;
239
1017
  const matches = line.match(/\[\[[^\]\n]+\]\]/g);
240
1018
  if (!matches) continue;
241
- if (line.startsWith('+')) plus += matches.length;
1019
+ if (line.startsWith('+')) plus += matches.length;
242
1020
  else if (line.startsWith('-')) minus += matches.length;
243
1021
  }
244
1022
  }
@@ -260,14 +1038,16 @@ export function computeSessionGrowth(hypoDir) {
260
1038
  // Inlined here so deployed hooks (~/.claude/hooks/) don't need scripts/lib/.
261
1039
 
262
1040
  function _globToRegex(glob) {
263
- return new RegExp('^' +
264
- glob
265
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
266
- .replace(/\*\*/g, '\x00')
267
- .replace(/\*/g, '[^/]*')
268
- .replace(/\?/g, '[^/]')
269
- .replace(/\x00/g, '.*')
270
- + '$');
1041
+ return new RegExp(
1042
+ '^' +
1043
+ glob
1044
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
1045
+ .replace(/\*\*/g, '\x00')
1046
+ .replace(/\*/g, '[^/]*')
1047
+ .replace(/\?/g, '[^/]')
1048
+ .replace(/\x00/g, '.*') +
1049
+ '$',
1050
+ );
271
1051
  }
272
1052
 
273
1053
  export function loadHypoIgnore(hypoDir) {
@@ -275,8 +1055,8 @@ export function loadHypoIgnore(hypoDir) {
275
1055
  if (!existsSync(ignorePath)) return [];
276
1056
  return readFileSync(ignorePath, 'utf-8')
277
1057
  .split('\n')
278
- .map(l => l.trim())
279
- .filter(l => l && !l.startsWith('#'));
1058
+ .map((l) => l.trim())
1059
+ .filter((l) => l && !l.startsWith('#'));
280
1060
  }
281
1061
 
282
1062
  export function isIgnored(filePath, hypoDir, patterns) {