hypomnema 1.5.0 → 1.5.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.
@@ -284,17 +284,22 @@ export function sessionLogScopePath(hypoDir, project, date) {
284
284
  * True if `content` carries a today-dated `## [date] session | <project>` entry
285
285
  * in log.md.
286
286
  *
287
- * Bounded with an explicit `(?=\s|$)` lookahead, NOT `\b`: a regex word boundary
288
- * matches between word and non-word chars, so `\b` after "foo" still matches in
289
- * "foo-bar" (hyphen is non-word). The canonical log format always separates the
290
- * project slug from anything that follows by whitespace or end-of-line, so the
291
- * lookahead correctly rejects "session | foo-bar" when looking for "foo".
292
- * (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
293
- * inherited.)
287
+ * Bounded with an explicit `(?=[\s:]|$)` lookahead, NOT `\b`: a regex word
288
+ * boundary matches between word and non-word chars, so `\b` after "foo" still
289
+ * matches in "foo-bar" (hyphen is non-word). The canonical log format separates
290
+ * the project slug from anything that follows by whitespace, a colon, or
291
+ * end-of-line, so the lookahead correctly rejects "session | foo-bar" when
292
+ * looking for "foo". Both delimiters are canonical: the derive path
293
+ * (rootLogEntry) emits `<project> — <title>` (space), while the dominant
294
+ * hand-written convention is `<project>: <title>` (colon, since the tone rule
295
+ * banned the em dash). The colon must be accepted: without it, a close whose
296
+ * only log.md evidence used the colon form was misread as "stale" and blocked
297
+ * non-deterministically. (Was a pre-existing boundary bug in
298
+ * sessionCloseFileStatus that the helper extraction inherited.)
294
299
  */
295
300
  export function hasLogEntry(content, date, project) {
296
301
  return new RegExp(
297
- '^## \\[' + escapeRegExp(date) + '\\] session \\| ' + escapeRegExp(project) + '(?=\\s|$)',
302
+ '^## \\[' + escapeRegExp(date) + '\\] session \\| ' + escapeRegExp(project) + '(?=[\\s:]|$)',
298
303
  'm',
299
304
  ).test(content || '');
300
305
  }
@@ -352,6 +357,139 @@ function parseFrontmatterField(content, key) {
352
357
  .replace(/^['"]|['"]$/g, '');
353
358
  }
354
359
 
360
+ // ── freshness: overdue verify_by_date predicate + STALE injection marker ─────
361
+ // Authority is doctor.mjs:487-491 (D1). Same characters, same meaning: only a
362
+ // well-formed ISO date strictly before `today` is overdue. verify_by (the
363
+ // natural-language question) is never a date and is never consulted here.
364
+ // today is caller-supplied and must use the UTC convention (new Date()
365
+ // .toISOString().slice(0,10)) so this set matches doctor's.
366
+ export function isOverdueDate(dateStr, today) {
367
+ return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && dateStr < today;
368
+ }
369
+
370
+ // Compute the injection marker for a page's raw content. Returns
371
+ // `[STALE verify_by_date=YYYY-MM-DD]` when overdue, otherwise an empty string
372
+ // (so non-targets pass through unchanged). Callers prepend the non-empty result
373
+ // at injection time.
374
+ //
375
+ // The value read here must be normalized exactly as scripts/lib/frontmatter.mjs
376
+ // does (doctor's parser), so the overdue set stays char-identical to doctor.mjs.
377
+ // In particular it strips a trailing YAML inline comment (`2020-01-01 # note`):
378
+ // the plain parseFrontmatterField does not, which would silently miss dates
379
+ // doctor flags overdue. Top-level line only (skip indented / list entries),
380
+ // first-wins, strip comment, then strip surrounding quotes.
381
+ export function staleMarkerFor(rawContent, today) {
382
+ const m = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
383
+ if (!m) return '';
384
+ let value = null;
385
+ for (const line of m[1].split(/\r?\n/)) {
386
+ if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
387
+ const idx = line.indexOf(':');
388
+ if (idx < 0) continue;
389
+ if (line.slice(0, idx).trim() !== 'verify_by_date') continue; // key match (idx: tolerates `key : v`)
390
+ value = line
391
+ .slice(idx + 1)
392
+ .trim()
393
+ .replace(/\s+#.*$/, '')
394
+ .replace(/^["']|["']$/g, '');
395
+ break; // first-wins
396
+ }
397
+ return value && isOverdueDate(value, today) ? `[STALE verify_by_date=${value}]` : '';
398
+ }
399
+
400
+ // ── page-usage logging: commit-coverage guard + append (B, D6) ───────────────
401
+ // Logging which pages lookup injects is observability, never an injection path.
402
+ // The log lives at .cache/page-usage.jsonl and must never be committed. Before
403
+ // any append, prove that file cannot be staged/committed: it must be covered by
404
+ // BOTH git's ignore rules (check-ignore) AND .hypoignore (which is what gates
405
+ // hypo-auto-stage and commitWikiChanges). Missing either signal, or any error
406
+ // (no git, timeout, non-repo), returns false so nothing is written (fail-closed
407
+ // logging). Injection stays fail-open and is unaffected. The verdict is cached
408
+ // per session (keyed by session_id plus a vault-path hash so two vaults in one
409
+ // session can't cross-contaminate) so git runs at most once per session.
410
+ export const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
411
+
412
+ export function pageUsageGuardCachePath(sessionId, hypoDir) {
413
+ const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
414
+ // djb2 over the vault path so the cache key is per-(session, vault).
415
+ let h = 5381;
416
+ const s = String(hypoDir || '');
417
+ for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
418
+ return join(tmpdir(), `hypo-pageusage-guard-${safe}-${h.toString(36)}.json`);
419
+ }
420
+
421
+ export function pageUsageLoggingAllowed(hypoDir, sessionId) {
422
+ // The load-bearing commit gate is .hypoignore: it is what hypo-auto-stage and
423
+ // commitWikiChanges actually filter on. Re-check it FRESH on every call (it is
424
+ // cheap, no subprocess) so that if coverage is removed mid-session the guard
425
+ // flips closed immediately and can never leave logging armed against a
426
+ // now-committable file. Only the expensive git check-ignore probe is cached
427
+ // per session. Any error on either signal fails closed.
428
+ let hypoIgnored = false;
429
+ try {
430
+ const target = join(hypoDir, PAGE_USAGE_REL);
431
+ const patterns = loadHypoIgnore(hypoDir);
432
+ hypoIgnored = patterns.length > 0 && isIgnored(target, hypoDir, patterns);
433
+ } catch {
434
+ hypoIgnored = false;
435
+ }
436
+ if (!hypoIgnored) return false;
437
+
438
+ return gitIgnoresPageUsageCached(hypoDir, sessionId);
439
+ }
440
+
441
+ // git check-ignore is the belt signal (defends a manual `git add`); it spawns a
442
+ // subprocess, so cache its result per session. The verdict cached here is only
443
+ // the git signal, never the composite allow decision, so the fresh .hypoignore
444
+ // re-check above always still runs.
445
+ function gitIgnoresPageUsageCached(hypoDir, sessionId) {
446
+ const cachePath = pageUsageGuardCachePath(sessionId, hypoDir);
447
+ try {
448
+ if (existsSync(cachePath)) {
449
+ const cached = JSON.parse(readFileSync(cachePath, 'utf-8'));
450
+ if (typeof cached.gitIgnored === 'boolean') return cached.gitIgnored;
451
+ }
452
+ } catch {
453
+ // corrupt cache → recompute below
454
+ }
455
+
456
+ let gitIgnored = false;
457
+ try {
458
+ gitIgnored =
459
+ spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', '--', PAGE_USAGE_REL], {
460
+ timeout: 2000,
461
+ }).status === 0;
462
+ } catch {
463
+ gitIgnored = false;
464
+ }
465
+
466
+ try {
467
+ writeFileSync(cachePath, JSON.stringify({ gitIgnored }));
468
+ } catch {
469
+ // cache write failure is non-fatal; the git probe just reruns next prompt
470
+ }
471
+ return gitIgnored;
472
+ }
473
+
474
+ // Append one JSONL record per injected slug to .cache/page-usage.jsonl. Callers
475
+ // MUST gate this behind pageUsageLoggingAllowed first. Fully fail-open: any error
476
+ // (mkdir, disk, serialization) is swallowed so a logging failure never disturbs
477
+ // the lookup injection that already happened (mirrors hypo-session-record).
478
+ export function recordLookupUsage(hypoDir, { sessionId = null, slugs = [] } = {}) {
479
+ try {
480
+ if (!Array.isArray(slugs) || slugs.length === 0) return;
481
+ const target = join(hypoDir, PAGE_USAGE_REL);
482
+ mkdirSync(join(hypoDir, '.cache'), { recursive: true });
483
+ const ts = new Date().toISOString();
484
+ const lines = slugs
485
+ .map((slug) => JSON.stringify({ ts, session_id: sessionId ?? null, slug, source: 'lookup' }))
486
+ .join('\n');
487
+ appendFileSync(target, lines + '\n');
488
+ } catch {
489
+ // logging is observability; never let it break injection (fail-open)
490
+ }
491
+ }
492
+
355
493
  // ── cwd ↔ project matcher ────────────────────────────────────────────────────
356
494
  // Hand-synced with scripts/lib/wd-match.mjs: hooks deploy to ~/.claude/hooks/
357
495
  // without scripts/, so this cannot import the lib and must mirror it. Keep the
@@ -787,13 +925,20 @@ function closeCandidateSlugs(hypoDir, dates) {
787
925
  content = '';
788
926
  }
789
927
  for (const d of dates) {
790
- const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
928
+ // Capture the slug up to the first whitespace OR colon so both canonical
929
+ // log delimiters resolve to the same bare slug: `session | beta — t` and
930
+ // `session | beta: t` both yield `beta`. This parser shares the
931
+ // colon-delimiter contract with hasLogEntry, so a colon-form entry for a
932
+ // real project is a close candidate, not a ghost. `[^\s:]+` cannot span
933
+ // the colon, so a stale/typo heading still yields a token that fails the
934
+ // on-disk directory gate below.
935
+ const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| ([^\\s:]+)', 'gm');
791
936
  for (const m of content.matchAll(re)) {
792
- // B-1: only real projects are close candidates. A malformed log heading
793
- // (`## [d] session | hypomnema:`) or a stale slug yields a ghost token
794
- // that no longer maps to a `projects/<slug>/` directory gating on disk
795
- // keeps it out of the dangling-close set. Directory (not bare-exists)
796
- // mirrors the apply-path project check (crystallize.mjs:193).
937
+ // B-1: only real projects are close candidates. A stale or misspelled
938
+ // slug yields a token that no longer maps to a `projects/<slug>/`
939
+ // directory gating on disk keeps it out of the dangling-close set.
940
+ // Directory (not bare-exists) mirrors the apply-path project check
941
+ // (crystallize.mjs:193).
797
942
  const dir = join(projectsDir, m[1]);
798
943
  if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
799
944
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -73,6 +73,7 @@ import { fileURLToPath } from 'url';
73
73
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
74
74
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
75
75
  import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
76
+ import { aggregateColdCandidates } from './lib/page-usage.mjs';
76
77
  import { isValidProjectName } from './lib/project-create.mjs';
77
78
  import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
78
79
  import {
@@ -88,6 +89,8 @@ import {
88
89
  sessionLogReadCandidates,
89
90
  sessionLogScopePath,
90
91
  rootLogEntry,
92
+ hasSessionLogHeading,
93
+ hasLogEntry,
91
94
  resolveTranscriptBySessionId,
92
95
  hasUserCloseSignal,
93
96
  commitWikiChanges,
@@ -753,21 +756,49 @@ function applySessionClose(args) {
753
756
  }
754
757
  const date = payload.date || todayLocal();
755
758
 
756
- // B-1 derive precondition: when `log` is omitted, apply reconstructs the root
757
- // log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
758
- // `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
759
- // close the date-level freshness verifier would still pass on the earlier
760
- // close's entry, so the no-write would slip through as ok:true. Fail loud here,
761
- // before any writes (codex pre-commit review).
762
- if (
763
- !payload.log &&
764
- !new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
765
- ) {
766
- const msg =
767
- `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
768
- `entry from. Give it a dated heading, or supply payload.log explicitly.`;
769
- console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
759
+ // Pre-apply freshness-contract gate: the post-apply verification holds
760
+ // sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
761
+ // definition of "closed today". Enforce that SAME contract on the payload
762
+ // BEFORE writing a byte, so a heading the gate won't recognize is rejected
763
+ // here as a format mismatch not written and then misdiagnosed downstream as
764
+ // "stale" (the "not updated" vs "format mismatch" conflation). All checks
765
+ // exit 1 with stage='pre-apply-verification' and leave the tree untouched.
766
+ const failPreApply = (msg) => {
767
+ console.log(
768
+ args.json
769
+ ? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
770
+ : `✗ ${msg}`,
771
+ );
770
772
  process.exit(1);
773
+ };
774
+ // (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
775
+ // post-apply gate checks the session-log file for exactly this heading, so a
776
+ // headingless entry would write then false-fail as "stale". This also doubles
777
+ // as the B-1 derive precondition: when `log` is omitted the root log.md entry
778
+ // is reconstructed from THIS heading, and on a same-day SECOND close the
779
+ // date-level verifier would still pass on the earlier entry, so a no-derive
780
+ // would slip through as ok:true. The `!payload.log` branch keeps the original
781
+ // derive-specific wording (a test asserts it).
782
+ if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
783
+ failPreApply(
784
+ !payload.log
785
+ ? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
786
+ `entry from. Give it a dated heading, or supply payload.log explicitly.`
787
+ : `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
788
+ `identifies a session-log by its dated ATX heading; give the entry a ` +
789
+ `"## [${date}] <title>" heading (the brackets are required).`,
790
+ );
791
+ }
792
+ // (b) An explicit payload.log entry must match the canonical
793
+ // `## [<date>] session | <project>` line the gate looks for (colon or space
794
+ // delimiter after the slug). Otherwise the write lands but post-apply
795
+ // verification reports log.md as stale. When `log` is omitted the line is
796
+ // derived canonically (rootLogEntry) so this cannot mismatch.
797
+ if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
798
+ failPreApply(
799
+ `payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
800
+ `close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
801
+ );
771
802
  }
772
803
 
773
804
  // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
@@ -1315,8 +1346,12 @@ const synthesisGroups = Object.entries(tagGroups)
1315
1346
  .sort((a, b) => b[1].length - a[1].length)
1316
1347
  .map(([tag, pages]) => ({ tag, pages }));
1317
1348
 
1349
+ // Lookup-cold candidates (B): pages with inbound wikilinks that lookup has not
1350
+ // injected within the recency window. Advisory only, never gates, never mutates.
1351
+ const coldCandidates = aggregateColdCandidates(args.hypoDir, { ignorePatterns });
1352
+
1318
1353
  if (args.json) {
1319
- console.log(JSON.stringify({ synthesisGroups, unlinked, drafts }, null, 2));
1354
+ console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
1320
1355
  process.exit(0);
1321
1356
  }
1322
1357
 
@@ -1346,6 +1381,25 @@ if (drafts.length > 0) {
1346
1381
  console.log('');
1347
1382
  }
1348
1383
 
1384
+ // Advisory (non-gating): pages the graph treats as live but lookup has not
1385
+ // injected recently. Held until enough page-usage history accrues.
1386
+ if (coldCandidates.status === 'ok' && coldCandidates.candidates.length > 0) {
1387
+ found = true;
1388
+ console.log(
1389
+ `Lookup-cold pages (${coldCandidates.candidates.length}), inbound links but not injected recently:`,
1390
+ );
1391
+ for (const p of coldCandidates.candidates) console.log(` [[${p.slug}]] (${p.title})`);
1392
+ console.log('');
1393
+ } else if (
1394
+ coldCandidates.status === 'insufficient-data' &&
1395
+ coldCandidates.reason === 'span-too-short'
1396
+ ) {
1397
+ // Only surface the "held" notice once a log is actually accruing (span under
1398
+ // the cold-start window). A vault with no log at all stays silent so this
1399
+ // advisory never becomes permanent noise on every crystallize run.
1400
+ console.log('Lookup-cold scan held: not enough page-usage history yet (advisory).\n');
1401
+ }
1402
+
1349
1403
  if (!found) {
1350
1404
  console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
1351
1405
  }
@@ -0,0 +1,141 @@
1
+ // page-usage.mjs: read-only aggregation over .cache/page-usage.jsonl (B3)
2
+ //
3
+ // The lookup hook appends one JSONL record per injected page slug (see
4
+ // hooks/hypo-shared.mjs recordLookupUsage). This lib reads that log and derives
5
+ // "lookup-cold candidates": pages that have inbound wikilinks (so the graph
6
+ // treats them as live) but have not been injected by lookup within a recency
7
+ // window. It writes nothing and is only invoked from scripts (crystallize), so
8
+ // it never touches the per-prompt hook hot path.
9
+
10
+ import { readFileSync, existsSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { collectPagesCrystallize, extractWikilinks, slugForms } from './wikilink.mjs';
13
+ import { parseFrontmatter } from './frontmatter.mjs';
14
+
15
+ const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
16
+ const DAY_MS = 86400000;
17
+
18
+ // The distinct link forms a slug may appear as in a [[wikilink]] or in the log:
19
+ // its full path, its basename, and (when nested) the path minus its first
20
+ // segment. Matching by form-set intersection bridges the gap between the log's
21
+ // index-matched slug form and the reverse index's [[wikilink]] form.
22
+ function formSet(slug) {
23
+ const f = slugForms(slug);
24
+ const s = new Set([f.full, f.bare]);
25
+ if (f.dirRel) s.add(f.dirRel);
26
+ return s;
27
+ }
28
+
29
+ function intersects(a, b) {
30
+ for (const x of a) if (b.has(x)) return true;
31
+ return false;
32
+ }
33
+
34
+ // Read every record from the usage log, skipping malformed lines. Returns [] if
35
+ // the log is absent or unreadable (fail-soft: aggregation is advisory).
36
+ export function readPageUsage(hypoDir) {
37
+ const path = join(hypoDir, PAGE_USAGE_REL);
38
+ if (!existsSync(path)) return [];
39
+ let text;
40
+ try {
41
+ text = readFileSync(path, 'utf-8');
42
+ } catch {
43
+ return [];
44
+ }
45
+ const records = [];
46
+ for (const line of text.split('\n')) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed) continue;
49
+ try {
50
+ const parsed = JSON.parse(trimmed);
51
+ // Only keep plain objects. A bare `null`, number, string, or array is
52
+ // valid JSON but not a record; keeping it would crash the field access in
53
+ // aggregateColdCandidates (and crystallize calls that outside a try).
54
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
55
+ records.push(parsed);
56
+ }
57
+ } catch {
58
+ // malformed line → skip (log is append-only and may be partially written)
59
+ }
60
+ }
61
+ return records;
62
+ }
63
+
64
+ // Derive lookup-cold candidates. Guards against cold-start: if there are no
65
+ // records, or the observed log span is shorter than minLogSpanDays, returns
66
+ // { status: 'insufficient-data' } so a fresh log doesn't flag the whole vault.
67
+ // Otherwise returns { status: 'ok', candidates: [{ slug, title }] } for pages
68
+ // that have >= 1 inbound wikilink from another page yet have not been logged
69
+ // within the last thresholdDays.
70
+ export function aggregateColdCandidates(
71
+ hypoDir,
72
+ { thresholdDays = 90, minLogSpanDays = 14, now = Date.now(), ignorePatterns = [] } = {},
73
+ ) {
74
+ const records = readPageUsage(hypoDir);
75
+ if (records.length === 0) return { status: 'insufficient-data', reason: 'no-records' };
76
+
77
+ const times = records.map((r) => Date.parse(r.ts)).filter((t) => Number.isFinite(t));
78
+ if (times.length === 0) return { status: 'insufficient-data', reason: 'no-timestamps' };
79
+ const span = Math.max(...times) - Math.min(...times);
80
+ if (span < minLogSpanDays * DAY_MS) {
81
+ return { status: 'insufficient-data', reason: 'span-too-short' };
82
+ }
83
+
84
+ // Forms of every slug logged within the recency window.
85
+ const recentCutoff = now - thresholdDays * DAY_MS;
86
+ const recentForms = new Set();
87
+ for (const r of records) {
88
+ const t = Date.parse(r.ts);
89
+ if (!Number.isFinite(t) || t < recentCutoff) continue;
90
+ if (typeof r.slug !== 'string' || !r.slug) continue;
91
+ for (const form of formSet(r.slug)) recentForms.add(form);
92
+ }
93
+
94
+ // Build the page list with slug, title, forms, and outbound links.
95
+ const pagesDir = join(hypoDir, 'pages');
96
+ const pageList = [];
97
+ for (const { path, rel } of collectPagesCrystallize(pagesDir, hypoDir, ignorePatterns)) {
98
+ let content;
99
+ try {
100
+ content = readFileSync(path, 'utf-8');
101
+ } catch {
102
+ continue;
103
+ }
104
+ const fm = parseFrontmatter(content);
105
+ if (!fm) continue;
106
+ const slug = rel.replace(/\.md$/, '');
107
+ const body = content.replace(/^---[\s\S]*?---/, '');
108
+ pageList.push({
109
+ slug,
110
+ title: fm.title || slug,
111
+ forms: formSet(slug),
112
+ links: extractWikilinks(body),
113
+ });
114
+ }
115
+
116
+ // Reverse index: which page slugs receive >= 1 inbound link from another page.
117
+ const formOwners = new Map(); // form → Set<page slug>
118
+ for (const p of pageList) {
119
+ for (const form of p.forms) {
120
+ if (!formOwners.has(form)) formOwners.set(form, new Set());
121
+ formOwners.get(form).add(p.slug);
122
+ }
123
+ }
124
+ const hasInbound = new Set();
125
+ for (const p of pageList) {
126
+ for (const link of p.links) {
127
+ const targets = new Set();
128
+ for (const form of formSet(link)) {
129
+ const owners = formOwners.get(form);
130
+ if (owners) for (const o of owners) targets.add(o);
131
+ }
132
+ for (const t of targets) if (t !== p.slug) hasInbound.add(t);
133
+ }
134
+ }
135
+
136
+ const candidates = pageList
137
+ .filter((p) => hasInbound.has(p.slug) && !intersects(p.forms, recentForms))
138
+ .map((p) => ({ slug: p.slug, title: p.title }));
139
+
140
+ return { status: 'ok', candidates };
141
+ }
@@ -465,6 +465,49 @@ function applyHypoignoreMigration(result) {
465
465
  return appended;
466
466
  }
467
467
 
468
+ // ── .gitignore migration: mirror .cache/ into .gitignore (page-usage privacy) ─
469
+ //
470
+ // Legacy vaults may have a .gitignore that predates .cache/ (init now scaffolds
471
+ // it, but old vaults were migrated only in .hypoignore). Without .cache/ in
472
+ // .gitignore, the page-usage log could be committed. This mirrors the hypoignore
473
+ // migration: idempotent append of the missing entry, no-op when already present
474
+ // or when there is no .gitignore (the runtime logging guard fails closed for the
475
+ // no-file case, so nothing leaks in the meantime).
476
+
477
+ const GITIGNORE_REQUIRED_ENTRIES = [
478
+ {
479
+ pattern: '.cache/',
480
+ comment: '# Hypomnema runtime cache (page-usage log, session growth metrics)',
481
+ },
482
+ ];
483
+
484
+ function checkGitignore(hypoDir) {
485
+ const path = join(hypoDir, '.gitignore');
486
+ if (!existsSync(path)) return { status: 'no-file', missing: [], path };
487
+ const content = readFileSync(path, 'utf-8');
488
+ const entries = new Set(
489
+ content
490
+ .split('\n')
491
+ .map((l) => l.trim())
492
+ .filter((l) => l && !l.startsWith('#')),
493
+ );
494
+ const missing = GITIGNORE_REQUIRED_ENTRIES.filter((e) => !entries.has(e.pattern));
495
+ return { status: missing.length === 0 ? 'up-to-date' : 'needs-migration', missing, path };
496
+ }
497
+
498
+ function applyGitignoreMigration(result) {
499
+ if (result.status !== 'needs-migration') return [];
500
+ let content = readFileSync(result.path, 'utf-8');
501
+ if (!content.endsWith('\n')) content += '\n';
502
+ const appended = [];
503
+ for (const entry of result.missing) {
504
+ content += `\n${entry.comment}\n${entry.pattern}\n`;
505
+ appended.push(entry.pattern);
506
+ }
507
+ writeFileSync(result.path, content);
508
+ return appended;
509
+ }
510
+
468
511
  function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = false } = {}) {
469
512
  const today = new Date().toISOString().slice(0, 10);
470
513
  const filename = `MIGRATION-v${toVersion}.md`;
@@ -849,6 +892,7 @@ const pkgJson = checkPkgJson();
849
892
  const commands = checkCommands();
850
893
  const oldHookRefs = checkOldHookNames(claudeSettingsPath);
851
894
  const hypoignore = checkHypoignore(args.hypoDir);
895
+ const gitignore = checkGitignore(args.hypoDir);
852
896
 
853
897
  // when --codex is set, mirror the same core-hook checks against ~/.codex/
854
898
  // so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
@@ -930,6 +974,7 @@ let appliedSettingsCodex = [];
930
974
  let appliedHookNameRenamesCodex = [];
931
975
  let appliedCommands = [];
932
976
  let appliedHypoignore = [];
977
+ let appliedGitignore = [];
933
978
  let appliedExtensions = null;
934
979
  let appliedExtensionsCodex = null;
935
980
 
@@ -1014,6 +1059,7 @@ if (args.apply) {
1014
1059
  }
1015
1060
  }
1016
1061
  appliedHypoignore = applyHypoignoreMigration(hypoignore);
1062
+ appliedGitignore = applyGitignoreMigration(gitignore);
1017
1063
  // codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
1018
1064
  // as the claude side (rename first so subsequent hook copy can find renamed targets).
1019
1065
  if (args.codex) {
@@ -1089,6 +1135,7 @@ const hasDrift =
1089
1135
  pkgJsonDrift ||
1090
1136
  schemaDrift ||
1091
1137
  hypoignore.status === 'needs-migration' ||
1138
+ gitignore.status === 'needs-migration' ||
1092
1139
  extDrift ||
1093
1140
  codexCoreDrift;
1094
1141
 
@@ -1109,6 +1156,7 @@ if (args.json) {
1109
1156
  commands,
1110
1157
  oldHookRefs,
1111
1158
  hypoignore,
1159
+ gitignore,
1112
1160
  extensions: extCheck,
1113
1161
  extensionsCodex: extCheckCodex,
1114
1162
  // codex core mirror (null when --codex absent).
@@ -1122,6 +1170,7 @@ if (args.json) {
1122
1170
  hookNameRenames: appliedHookNameRenames,
1123
1171
  commands: appliedCommands,
1124
1172
  hypoignore: appliedHypoignore,
1173
+ gitignore: appliedGitignore,
1125
1174
  extensions: appliedExtensions,
1126
1175
  extensionsCodex: appliedExtensionsCodex,
1127
1176
  hooksCodex: appliedHooksCodex,
@@ -1369,6 +1418,18 @@ if (hypoignore.status === 'no-file') {
1369
1418
  for (const e of hypoignore.missing) lines.push(` + ${e.pattern}`);
1370
1419
  }
1371
1420
 
1421
+ // .gitignore migration (page-usage privacy: mirror .cache/ into .gitignore)
1422
+ if (gitignore.status === 'no-file') {
1423
+ lines.push(`⚠ .gitignore not found at ${gitignore.path} (init.mjs scaffolds this)`);
1424
+ } else if (gitignore.status === 'up-to-date') {
1425
+ lines.push(`✓ .gitignore required entries present`);
1426
+ } else {
1427
+ lines.push(
1428
+ `⚠ .gitignore ${gitignore.missing.length} missing entry(s), run --apply to append:`,
1429
+ );
1430
+ for (const e of gitignore.missing) lines.push(` + ${e.pattern}`);
1431
+ }
1432
+
1372
1433
  // Extensions companion (conflict/drift gating E3, #31). Shared by the
1373
1434
  // claude target and, under --codex, the codex target (E4, #32) — the label keeps
1374
1435
  // the two blocks distinguishable in the report.
@@ -1424,6 +1485,7 @@ if (
1424
1485
  appliedHookNameRenames.length > 0 ||
1425
1486
  appliedCommands.length > 0 ||
1426
1487
  appliedHypoignore.length > 0 ||
1488
+ appliedGitignore.length > 0 ||
1427
1489
  appliedHooksCodex.length > 0 ||
1428
1490
  appliedSettingsCodex.length > 0 ||
1429
1491
  appliedHookNameRenamesCodex.length > 0
@@ -1452,6 +1514,10 @@ if (
1452
1514
  lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
1453
1515
  for (const e of appliedHypoignore) lines.push(` → ${e}`);
1454
1516
  }
1517
+ if (appliedGitignore.length > 0) {
1518
+ lines.push(`✓ Appended .gitignore entries (${appliedGitignore.length}):`);
1519
+ for (const e of appliedGitignore) lines.push(` → ${e}`);
1520
+ }
1455
1521
  // codex-target applied actions (mirrors claude blocks above).
1456
1522
  if (appliedHookNameRenamesCodex.length > 0) {
1457
1523
  lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
@@ -1507,6 +1573,7 @@ const totalDrift =
1507
1573
  (pkgJsonDrift ? 1 : 0) +
1508
1574
  (schemaDrift ? 1 : 0) +
1509
1575
  (hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
1576
+ (gitignore.status === 'needs-migration' ? gitignore.missing.length : 0) +
1510
1577
  extCheck.actions.filter(
1511
1578
  (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1512
1579
  ).length +
@@ -1544,6 +1611,7 @@ if (totalDrift === 0) {
1544
1611
  (appliedPkgJson ? 1 : 0) +
1545
1612
  appliedHookNameRenames.length +
1546
1613
  appliedHypoignore.length +
1614
+ appliedGitignore.length +
1547
1615
  appliedExtCount +
1548
1616
  appliedHooksCodex.length +
1549
1617
  appliedSettingsCodex.length +
@@ -92,7 +92,9 @@ main conversation id. Passing the wrong id causes `markerWritten: false` with
92
92
  `markerSkipReason: "transcript-unresolved"` or `"no-user-close-signal"`: the
93
93
  5 mandatory files are written (ok: true) but the Stop-chain marker is withheld,
94
94
  so the session is not actually closed and the Stop hook re-prompts. The correct
95
- id comes from the `[WIKI_AUTOCLOSE]` block reason or `$CLAUDE_SESSION_ID`.
95
+ id comes from the `[WIKI_AUTOCLOSE]` block reason or the injected
96
+ `$CLAUDE_CODE_SESSION_ID` (accept the legacy spelling via
97
+ `${CLAUDE_CODE_SESSION_ID:-$CLAUDE_SESSION_ID}`).
96
98
 
97
99
  Once it passes, report each item with ✓:
98
100
  - ✓ session-state.md
@@ -100,11 +102,15 @@ Once it passes, report each item with ✓:
100
102
  - ✓ session-log entry
101
103
  - ✓ open-questions (or skipped if unchanged)
102
104
  - ✓ log.md entry
103
- - **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete. Instead report: "Files applied and verified (ok: true), but the session-close marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
105
+ - **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete; recover per the `markerSkipReason` branch below.
104
106
 
105
107
  If `markerWritten: true` (or `--session-id` was not passed), ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
106
108
 
107
- If `markerWritten: false`, do NOT say "session closed." Surface the skip reason and instruct a re-run with the correct main-conversation `--session-id` (see the bullet above) before treating the session as closed.
109
+ If `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`:
110
+
111
+ - `no-user-close-signal`: files applied cleanly and the transcript resolved, but it carries no close phrase the gate recognizes (the user's close wording fell outside the close-signal set, e.g. "세션 마무리까지 진행해줘" / "세션 마무리 진행"). Re-running the same id will not help. Confirm intent once with `AskUserQuestion` (header "세션", one option **세션 마무리**); if the user picks it, that answer is a recognized close signal, so re-run the same `--apply-session-close … --session-id` command (idempotent no-op writes; the marker lands). If the user declines, leave the session unmarked. Do NOT change the close-signal matcher.
112
+ - `transcript-unresolved` (wrong / background id): report "Files applied and verified (ok: true), but the marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
113
+ - any other reason (`compact-gate-not-ok`, `commit-failed: …`, `marker-did-not-land`): surface it verbatim and resolve the underlying blocker before re-running.
108
114
 
109
115
  ---
110
116
 
@@ -141,4 +147,4 @@ evidence_strength: inferred
141
147
 
142
148
  ---
143
149
 
144
- > **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]`. The observability audit counts citations toward the autonomy score see [[pages/observability/_index]] (run `/hypo:audit` to inspect).
150
+ > **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]` so it stays connected in the graph. The observability audit scores sessions on search / ingest / feedback activity (recorded by `hypo-session-record`), not on these inline citations; run `/hypo:audit` to inspect and see [[pages/observability/_index]].
@@ -55,4 +55,4 @@ If the graph has 0 edges, note that no `[[wikilinks]]` were found between pages.
55
55
 
56
56
  ---
57
57
 
58
- > **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]`. The observability audit counts citations toward the autonomy score see [[pages/observability/_index]] (run `/hypo:audit` to inspect).
58
+ > **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]` so it stays connected in the graph. The observability audit scores sessions on search / ingest / feedback activity (recorded by `hypo-session-record`), not on these inline citations; run `/hypo:audit` to inspect and see [[pages/observability/_index]].