hypomnema 1.2.1 → 1.3.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/commands/crystallize.md +23 -6
  6. package/commands/feedback.md +1 -1
  7. package/commands/upgrade.md +2 -0
  8. package/docs/CONTRIBUTING.md +96 -11
  9. package/hooks/hypo-auto-commit.mjs +3 -3
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  11. package/hooks/hypo-cwd-change.mjs +2 -2
  12. package/hooks/hypo-first-prompt.mjs +1 -1
  13. package/hooks/hypo-personal-check.mjs +57 -7
  14. package/hooks/hypo-session-start.mjs +73 -19
  15. package/hooks/hypo-shared.mjs +206 -16
  16. package/hooks/version-check.mjs +204 -6
  17. package/package.json +5 -2
  18. package/scripts/bump-version.mjs +9 -3
  19. package/scripts/check-bilingual.mjs +115 -0
  20. package/scripts/crystallize.mjs +130 -16
  21. package/scripts/doctor.mjs +45 -9
  22. package/scripts/feedback-sync.mjs +44 -15
  23. package/scripts/feedback.mjs +5 -5
  24. package/scripts/fix-status-verify.mjs +256 -0
  25. package/scripts/init.mjs +45 -4
  26. package/scripts/install-git-hooks.mjs +258 -0
  27. package/scripts/lib/adr-corpus.mjs +79 -0
  28. package/scripts/lib/check-bilingual.mjs +141 -0
  29. package/scripts/lib/extensions.mjs +3 -3
  30. package/scripts/lib/feedback-scope.mjs +21 -0
  31. package/scripts/lib/fix-manifest.mjs +109 -0
  32. package/scripts/lib/fix-status-verify.mjs +438 -0
  33. package/scripts/lib/plugin-detect.mjs +51 -0
  34. package/scripts/lib/pre-commit-format.mjs +251 -0
  35. package/scripts/lib/project-create.mjs +2 -2
  36. package/scripts/lint.mjs +48 -8
  37. package/scripts/pre-commit-format.mjs +198 -0
  38. package/scripts/resume.mjs +61 -3
  39. package/scripts/smoke-pack.mjs +39 -2
  40. package/scripts/upgrade.mjs +308 -58
  41. package/skills/crystallize/SKILL.md +13 -2
  42. package/templates/hypo-config.md +1 -1
  43. package/templates/hypo-guide.md +4 -0
@@ -37,6 +37,10 @@ import {
37
37
  computeNotice,
38
38
  markNotified,
39
39
  isOptedOut,
40
+ resolveCliOnPath,
41
+ computeSiblingNotice,
42
+ siblingAlreadyNotified,
43
+ markSiblingNotified,
40
44
  } from './version-check.mjs';
41
45
 
42
46
  // Privacy guard: refuse to read+inject .hypoignore-matched
@@ -119,6 +123,45 @@ function buildUpdateNotice() {
119
123
  }
120
124
  }
121
125
 
126
+ /**
127
+ * Stale-sibling notice (ADR 0038, D3). The update-notifier above only knows
128
+ * whether the ACTIVE install is behind latest — it is blind to an OLDER sibling
129
+ * that owns the `hypomnema` bin on PATH. That sibling is the live footgun:
130
+ * running `hypomnema init`/`upgrade` through it downgrades the active hooks.
131
+ *
132
+ * This is the ONLY surface that reaches a user already in that state, because it
133
+ * runs from the (newer) active hook — `doctor` invoked via the stale CLI would
134
+ * run the stale doctor. fs-only (no npm/which spawn). Throttled via the cache so
135
+ * it nags once per (cliPath@cliVersion → activeVersion) tuple. Best-effort.
136
+ */
137
+ function buildSiblingNotice() {
138
+ try {
139
+ if (isOptedOut()) return '';
140
+ // Active install identity = hypo-pkg.json (what init/upgrade write). This is
141
+ // the authoritative pkgRoot+version; ACTIVE_ROOT (~/.claude) has no package.json.
142
+ let active = null;
143
+ try {
144
+ active = JSON.parse(readFileSync(join(homedir(), '.claude', 'hypo-pkg.json'), 'utf-8'));
145
+ } catch {
146
+ return ''; // no active metadata → nothing to compare a sibling against
147
+ }
148
+ if (!active || !active.pkgVersion) return '';
149
+ const cli = resolveCliOnPath('hypomnema');
150
+ const notice = computeSiblingNotice(cli, {
151
+ pkgRoot: active.pkgRoot,
152
+ version: active.pkgVersion,
153
+ });
154
+ if (!notice) return '';
155
+ const cachePath = defaultCachePath();
156
+ const cache = readCache(cachePath);
157
+ if (siblingAlreadyNotified(cache, notice.key)) return '';
158
+ markSiblingNotified(cachePath, notice.key);
159
+ return notice.line;
160
+ } catch {
161
+ return '';
162
+ }
163
+ }
164
+
122
165
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
123
166
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
124
167
 
@@ -170,7 +213,7 @@ function gitPull(dir) {
170
213
 
171
214
  /**
172
215
  * fix #10: surface unresolved sync failures recorded by a prior session's
173
- * Stop hook (#9). The entry is cleared only once this session's pull has
216
+ * Stop hook (fix #9). The entry is cleared only once this session's pull has
174
217
  * succeeded AND there is no unpushed commit left behind by a failed push
175
218
  * (`[ahead N]`).
176
219
  *
@@ -280,6 +323,10 @@ let raw = '';
280
323
  process.stdin.setEncoding('utf-8');
281
324
  process.stdin.on('data', (chunk) => (raw += chunk));
282
325
  process.stdin.on('end', () => {
326
+ // ISSUE-5: declared before the try so every emit branch — including the outer
327
+ // catch — carries the same `systemMessage` (the user-visible update/sibling
328
+ // banner). Reassigned once below after the notices are computed.
329
+ let outExtra = { continue: true, suppressOutput: true };
283
330
  try {
284
331
  let data = {};
285
332
  try {
@@ -289,23 +336,33 @@ process.stdin.on('end', () => {
289
336
  const pullOk = gitPull(HYPO_DIR);
290
337
  const syncLine = syncStateNotice(pullOk);
291
338
  const growthLine = readLastGrowthLine();
292
- // fix #25 PR-A2 (ADR 0022 amendment): on source='clear', surface the dying
339
+ // ADR 0022 amendment: on source='clear', surface the dying
293
340
  // session's identity that hypo-session-end stashed so Claude can recover
294
341
  // session-close work that /clear skipped. One-shot: marker is unlinked
295
342
  // immediately after read.
296
343
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
297
344
  const updateLine = buildUpdateNotice();
298
- // Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
299
- // the terminal; noticePrefix injects the same plain-text lines into the
300
- // LLM's additionalContext so model and user start the session looking at
301
- // the same state. ANSI escapes are kept out of additionalContext on purpose.
302
- const notices = [syncLine, growthLine, clearRecoveryLine, updateLine].filter(Boolean);
345
+ const siblingLine = buildSiblingNotice();
346
+ // ISSUE-5: the update + stale-sibling banners must reach the USER. On a
347
+ // SessionStart hook that exits 0, stderr is invisible in the normal TUI
348
+ // (only shown on exit 2 / --verbose) and additionalContext is model-only —
349
+ // `systemMessage` is the documented user-visible channel. Route those two
350
+ // banners there. They ALSO stay in noticePrefix → additionalContext below,
351
+ // so the model and the user start the session looking at the same state.
352
+ // (The other stderr notices — sync/growth/clear/suggest — are intentionally
353
+ // transcript/--verbose only and out of ISSUE-5's scope.)
354
+ const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
355
+ if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
356
+ const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
357
+ Boolean,
358
+ );
303
359
  let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
304
360
  if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
305
361
  if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
306
362
  if (clearRecoveryLine)
307
363
  process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
308
364
  if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
365
+ if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
309
366
  const cwd = data.cwd || data.directory || process.cwd();
310
367
  const sessionId = data.session_id || 'default';
311
368
  const MARKER_FILE = sessionMarkerPath(sessionId);
@@ -336,7 +393,7 @@ process.stdin.on('end', () => {
336
393
  JSON.stringify(
337
394
  buildOutput(
338
395
  `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
339
- { continue: true, suppressOutput: true },
396
+ outExtra,
340
397
  ),
341
398
  ),
342
399
  );
@@ -352,10 +409,7 @@ process.stdin.on('end', () => {
352
409
  JSON.stringify(
353
410
  buildOutput(
354
411
  `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
355
- {
356
- continue: true,
357
- suppressOutput: true,
358
- },
412
+ outExtra,
359
413
  ),
360
414
  ),
361
415
  );
@@ -378,9 +432,9 @@ process.stdin.on('end', () => {
378
432
  if (!existsSync(GLOBAL_HOT)) {
379
433
  const notice = notices.join('\n\n');
380
434
  if (notice) {
381
- console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
435
+ console.log(JSON.stringify(buildOutput(notice, outExtra)));
382
436
  } else {
383
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
437
+ console.log(JSON.stringify(outExtra));
384
438
  }
385
439
  return;
386
440
  }
@@ -389,12 +443,12 @@ process.stdin.on('end', () => {
389
443
  if (!globalContent) {
390
444
  // GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
391
445
  // pending notices (sync state, growth, AND the auto-project offer), which
392
- // would otherwise be silently dropped here (codex review 2026-05-22).
446
+ // would otherwise be silently dropped here.
393
447
  const notice = notices.join('\n\n');
394
448
  if (notice) {
395
- console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
449
+ console.log(JSON.stringify(buildOutput(notice, outExtra)));
396
450
  } else {
397
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
451
+ console.log(JSON.stringify(outExtra));
398
452
  }
399
453
  return;
400
454
  }
@@ -402,12 +456,12 @@ process.stdin.on('end', () => {
402
456
  JSON.stringify(
403
457
  buildOutput(
404
458
  `${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
405
- { continue: true, suppressOutput: true },
459
+ outExtra,
406
460
  ),
407
461
  ),
408
462
  );
409
463
  } catch (err) {
410
464
  process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
411
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
465
+ console.log(JSON.stringify(outExtra));
412
466
  }
413
467
  });
@@ -17,8 +17,8 @@ const HOME = homedir();
17
17
  // hypo-session-start / hypo-cwd-change WRITE this marker; hypo-first-prompt
18
18
  // READS + unlinks it. The session_id comes from the Claude Code runtime (a
19
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 `_`.
20
+ // `..` can never escape tmpdir or collide on an empty value. Non-alphanumeric
21
+ // chars collapse to `_`.
22
22
  export function sessionMarkerPath(sessionId) {
23
23
  const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
24
24
  return join(tmpdir(), `hypo-session-marker-${safe}.json`);
@@ -207,8 +207,8 @@ export function hasSessionLogHeading(content, date) {
207
207
  * "foo-bar" (hyphen is non-word). The canonical log format always separates the
208
208
  * project slug from anything that follows by whitespace or end-of-line, so the
209
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.)
210
+ * (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
211
+ * inherited.)
212
212
  */
213
213
  export function hasLogEntry(content, date, project) {
214
214
  return new RegExp(
@@ -232,13 +232,56 @@ export function freshDates() {
232
232
  return local === utc ? [local] : [local, utc];
233
233
  }
234
234
 
235
+ // Parse a single frontmatter scalar (mirrors hypo-session-start.mjs /
236
+ // hypo-cwd-change.mjs; local copy per the hook self-contained convention).
237
+ function parseFrontmatterField(content, key) {
238
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
239
+ if (!m) return null;
240
+ const line = m[1].split('\n').find((l) => l.startsWith(`${key}:`));
241
+ if (!line) return null;
242
+ return line
243
+ .slice(key.length + 1)
244
+ .trim()
245
+ .replace(/^['"]|['"]$/g, '');
246
+ }
247
+
248
+ // Among `slugs`, return the one whose projects/<slug>/index.md `working_dir`
249
+ // is the LONGEST prefix of cwd (so /repo/sub wins over /repo). Returns null
250
+ // when cwd is falsy or matches none. Used only as a same-date tie-breaker.
251
+ function pickByCwd(hypoDir, slugs, cwd) {
252
+ if (!cwd) return null;
253
+ let best = null;
254
+ let bestLen = -1;
255
+ for (const slug of slugs) {
256
+ const indexPath = join(hypoDir, 'projects', slug, 'index.md');
257
+ if (!existsSync(indexPath)) continue;
258
+ const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
259
+ if (!wd) continue;
260
+ let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
261
+ resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
262
+ if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
263
+ bestLen = resolved.length;
264
+ best = slug;
265
+ }
266
+ }
267
+ return best;
268
+ }
269
+
235
270
  /**
236
271
  * Resolve the most-recently-active project slug from root hot.md.
237
- * Mirrors scripts/resume.mjs resolveActiveProject kept in sync by hand.
272
+ * The cwd helpers (parseFrontmatterField / pickByCwd) and the same-date
273
+ * tie-break are kept in sync with scripts/resume.mjs by hand; the surrounding
274
+ * wrapper intentionally differs (resume.mjs adds an mtime fallback, this does not).
275
+ * `cwd` is an optional same-date tie-breaker (ISSUE-1): resume passes
276
+ * process.cwd(); session-close callers (sessionCloseFileStatus /
277
+ * closeFileTargets) intentionally pass null — close has a different
278
+ * authority (payload.project / freshness), tracked separately as ISSUE-7.
279
+ * When cwd is omitted, behavior is identical to the legacy version.
238
280
  * @param {string} hypoDir
281
+ * @param {string|null} [cwd]
239
282
  * @returns {string|null}
240
283
  */
241
- export function resolveActiveProject(hypoDir) {
284
+ export function resolveActiveProject(hypoDir, cwd = null) {
242
285
  const hotPath = join(hypoDir, 'hot.md');
243
286
  if (!existsSync(hotPath)) return null;
244
287
  let content;
@@ -257,6 +300,19 @@ export function resolveActiveProject(hypoDir) {
257
300
  ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
258
301
  if (wikiRows.length > 0) {
259
302
  wikiRows.sort((a, b) => b.date.localeCompare(a.date));
303
+ // Same-date tie-break (ISSUE-1): when the top date is shared by >1 row,
304
+ // prefer the project whose working_dir contains cwd. No cwd / no match →
305
+ // keep the stable-sort winner (the legacy "first table row" behavior).
306
+ const topDate = wikiRows[0].date;
307
+ const tied = wikiRows.filter((r) => r.date === topDate);
308
+ if (cwd && tied.length > 1) {
309
+ const picked = pickByCwd(
310
+ hypoDir,
311
+ tied.map((r) => r.slug),
312
+ cwd,
313
+ );
314
+ if (picked) return picked;
315
+ }
260
316
  return wikiRows[0].slug;
261
317
  }
262
318
  // Legacy markdown-link rows: | [name](projects/name/...) | ...
@@ -277,12 +333,21 @@ export function resolveActiveProject(hypoDir) {
277
333
  * project A can't be masked by a fresh close of project B (and vice versa).
278
334
  * open-questions.md (file #5) is conditional and not gated.
279
335
  *
336
+ * `projectOverride` (ISSUE-7 Part A): when the caller already holds the
337
+ * authoritative project being closed (e.g. crystallize apply derives it from
338
+ * `payload.project`), it passes that slug so verification checks the SAME
339
+ * project it just wrote — instead of re-deriving via resolveActiveProject(),
340
+ * which on a same-date root-hot.md tie can resolve a DIFFERENT project and
341
+ * false-fail a completed close. When omitted, behavior is byte-identical to the
342
+ * legacy single-arg version (resolve from root hot.md).
343
+ *
280
344
  * @param {string} hypoDir
345
+ * @param {{projectOverride?: string|null}} [opts]
281
346
  * @returns {{ok: boolean, project: string|null, dates: string[], stale: string[], missing: string[]}}
282
347
  */
283
- export function sessionCloseFileStatus(hypoDir) {
348
+ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {}) {
284
349
  const dates = freshDates();
285
- const project = resolveActiveProject(hypoDir);
350
+ const project = projectOverride || resolveActiveProject(hypoDir);
286
351
  if (!project) {
287
352
  return {
288
353
  ok: false,
@@ -358,9 +423,9 @@ export function sessionCloseFileStatus(hypoDir) {
358
423
 
359
424
  // ── sync-state ────────────────────────────────────────────
360
425
  // `.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.
426
+ // line. hypo-auto-commit (fix #9) appends on pull/push failure; hypo-session-start
427
+ // (fix #10) surfaces open entries and clears them once sync is healthy again;
428
+ // doctor (fix #11) warns while entries remain. Keep the schema defined here only.
364
429
 
365
430
  /** @returns {string} path to the sync-state JSONL file for a wiki root. */
366
431
  function syncStatePath(hypoDir) {
@@ -527,14 +592,12 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
527
592
  * Build the §8.11 auto-project offer line for a cwd. The display name is the
528
593
  * cwd basename, which is attacker-influenced (a directory name can contain
529
594
  * 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).
595
+ * so a crafted dir name cannot spoof extra instructions in additionalContext.
532
596
  */
533
597
  export function buildProjectSuggestionLine(cwd) {
534
598
  // 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.
599
+ // crafted dir name cannot inject newlines/instructions into additionalContext.
600
+ // Done by codepoint to keep control bytes out of this source file.
538
601
  const sanitized = Array.from(basename(cwd))
539
602
  .map((ch) => {
540
603
  const code = ch.codePointAt(0);
@@ -803,6 +866,133 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
803
866
  return false;
804
867
  }
805
868
 
869
+ // ── session-scoped lint classification ──────────────────────────────────────
870
+ // Bug A/B fix: the close gate must judge a session on the files IT touched, not
871
+ // the whole vault. Lint debt from another project/session (often in shared
872
+ // pages/) must not block this session's close/compact. Two scope builders feed
873
+ // one shared classifier: transcript-derived (hooks + standalone marker) and
874
+ // close-file/payload-derived (the documented apply path writes via Bash, so its
875
+ // files never appear as Edit/Write file_paths and must be seeded explicitly).
876
+
877
+ const MUTATING_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
878
+
879
+ /** Pull file_path/notebook_path args from mutating tool_use blocks in one
880
+ * transcript entry. Mirrors extractTranscriptToolNames' shape handling
881
+ * (top-level tool_use + nested message.content[] blocks). */
882
+ function extractTranscriptToolFilePaths(entry) {
883
+ const paths = [];
884
+ if (!entry || typeof entry !== 'object') return paths;
885
+ const pull = (name, input) => {
886
+ if (!name || !MUTATING_FILE_TOOLS.has(name) || !input || typeof input !== 'object') return;
887
+ const fp = input.file_path || input.notebook_path;
888
+ if (typeof fp === 'string' && fp) paths.push(fp);
889
+ };
890
+ if (entry.type === 'tool_use') pull(entry.name || entry.tool_name, entry.input);
891
+ const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
892
+ if (Array.isArray(content)) {
893
+ for (const block of content) {
894
+ if (block && typeof block === 'object' && block.type === 'tool_use') {
895
+ pull(block.name || block.tool_name, block.input);
896
+ }
897
+ }
898
+ }
899
+ return paths;
900
+ }
901
+
902
+ /** Normalize an absolute path to a repo-relative POSIX path under hypoDir, or
903
+ * null if it resolves outside the wiki. */
904
+ function toHypoRel(absPath, hypoDir) {
905
+ let rel;
906
+ try {
907
+ rel = relative(hypoDir, absPath);
908
+ } catch {
909
+ return null;
910
+ }
911
+ if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null;
912
+ return rel.split('\\').join('/');
913
+ }
914
+
915
+ /**
916
+ * Repo-relative POSIX paths of wiki files this session edited via direct
917
+ * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
918
+ * transcript is missing/unreadable (callers decide the fallback). A per-line
919
+ * JSON parse error skips that line only (transcripts occasionally truncate).
920
+ */
921
+ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
922
+ const out = new Set();
923
+ if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
924
+ return out;
925
+ }
926
+ let raw;
927
+ try {
928
+ raw = readFileSync(transcriptPath, 'utf-8');
929
+ } catch {
930
+ return out;
931
+ }
932
+ for (const line of raw.split('\n')) {
933
+ const t = line.trim();
934
+ if (!t) continue;
935
+ let entry;
936
+ try {
937
+ entry = JSON.parse(t);
938
+ } catch {
939
+ continue;
940
+ }
941
+ for (const fp of extractTranscriptToolFilePaths(entry)) {
942
+ const rel = toHypoRel(fp, hypoDir);
943
+ if (rel) out.add(rel);
944
+ }
945
+ }
946
+ return out;
947
+ }
948
+
949
+ /**
950
+ * The mandatory session-close files (repo-relative POSIX). The documented close
951
+ * path `crystallize.mjs --apply-session-close` writes these from inside a Bash
952
+ * call, so they never surface as Edit/Write file_paths — they must seed the
953
+ * scoped-lint set explicitly or a close-introduced error would escape the gate.
954
+ * Mirrors the file list in sessionCloseFileStatus.
955
+ */
956
+ export function closeFileTargets(hypoDir) {
957
+ const out = new Set(['hot.md', 'log.md']);
958
+ const project = resolveActiveProject(hypoDir);
959
+ if (project) {
960
+ out.add(`projects/${project}/session-state.md`);
961
+ out.add(`projects/${project}/hot.md`);
962
+ const month = freshDates()[0].slice(0, 7);
963
+ out.add(`projects/${project}/session-log/${month}.md`);
964
+ }
965
+ return out;
966
+ }
967
+
968
+ /** Normalize a path's separators to POSIX so scope membership is OS-independent.
969
+ * lint.mjs emits `file` via path.relative (back-slashes on Windows) while the
970
+ * scope builders produce forward-slash paths — normalize both sides. */
971
+ function posixPath(p) {
972
+ return (p || '').split('\\').join('/');
973
+ }
974
+
975
+ /**
976
+ * Partition lint findings into `blocking` (a file this session is accountable
977
+ * for) vs `notice` (pre-existing debt elsewhere — surfaced, not blocking).
978
+ *
979
+ * `scope` = iterable of repo-relative paths the session is accountable for.
980
+ * Membership is exact on the normalized path. Only findings passed in are
981
+ * classified — callers pass lint ERRORS; broken wikilinks (lint W4 warnings) are
982
+ * intentionally warn-only (forward references to planned pages are normal in a
983
+ * wiki) and are NOT promoted to blocking by this gate.
984
+ */
985
+ export function partitionLintScope(findings, scope) {
986
+ const normScope = new Set([...scope].map(posixPath));
987
+ const blocking = [];
988
+ const notice = [];
989
+ for (const f of findings || []) {
990
+ if (normScope.has(posixPath(f.file))) blocking.push(f);
991
+ else notice.push(f);
992
+ }
993
+ return { blocking, notice };
994
+ }
995
+
806
996
  // ── session-close checklist ────────────────────────────────────────────────
807
997
 
808
998
  /**