hypomnema 1.0.1 → 1.2.0

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