hypomnema 1.2.0 → 1.3.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 (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +72 -26
  4. package/README.md +53 -7
  5. package/commands/crystallize.md +23 -6
  6. package/commands/feedback.md +1 -1
  7. package/docs/CONTRIBUTING.md +96 -11
  8. package/hooks/hypo-auto-commit.mjs +3 -3
  9. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  10. package/hooks/hypo-cwd-change.mjs +10 -6
  11. package/hooks/hypo-first-prompt.mjs +40 -8
  12. package/hooks/hypo-personal-check.mjs +60 -8
  13. package/hooks/hypo-session-start.mjs +60 -9
  14. package/hooks/hypo-shared.mjs +162 -13
  15. package/hooks/version-check.mjs +204 -6
  16. package/package.json +5 -2
  17. package/scripts/bump-version.mjs +9 -3
  18. package/scripts/check-bilingual.mjs +115 -0
  19. package/scripts/crystallize.mjs +124 -15
  20. package/scripts/doctor.mjs +45 -9
  21. package/scripts/feedback-sync.mjs +44 -15
  22. package/scripts/feedback.mjs +5 -5
  23. package/scripts/fix-status-verify.mjs +256 -0
  24. package/scripts/init.mjs +45 -4
  25. package/scripts/install-git-hooks.mjs +258 -0
  26. package/scripts/lib/adr-corpus.mjs +79 -0
  27. package/scripts/lib/check-bilingual.mjs +141 -0
  28. package/scripts/lib/extensions.mjs +3 -3
  29. package/scripts/lib/feedback-scope.mjs +21 -0
  30. package/scripts/lib/fix-manifest.mjs +109 -0
  31. package/scripts/lib/fix-status-verify.mjs +438 -0
  32. package/scripts/lib/pre-commit-format.mjs +251 -0
  33. package/scripts/lib/project-create.mjs +2 -2
  34. package/scripts/lint.mjs +48 -8
  35. package/scripts/pre-commit-format.mjs +198 -0
  36. package/scripts/resume.mjs +5 -1
  37. package/scripts/smoke-pack.mjs +16 -0
  38. package/scripts/upgrade.mjs +55 -23
  39. package/skills/crystallize/SKILL.md +13 -2
  40. package/templates/hot.md +1 -1
  41. package/templates/hypo-config.md +1 -1
  42. package/templates/hypo-guide.md +4 -0
@@ -43,9 +43,9 @@ if (staged) {
43
43
  }
44
44
 
45
45
  if (hasRemote()) {
46
- // fix #9: pull/push failures must not stop the session, but they can no
47
- // longer be swallowed silently — record each to .cache/sync-state.json so
48
- // session-start (#10) and doctor (#11) can surface them next session.
46
+ // pull/push failures must not stop the session, but they can no longer be
47
+ // swallowed silently — record each to .cache/sync-state.json so session-start
48
+ // and doctor can surface them next session.
49
49
  const pull = git('pull', '--no-rebase', '-q');
50
50
  if (pull.status !== 0) appendSyncFailure(HYPO_DIR, 'pull', pull.stderr || pull.stdout);
51
51
  const push = git('push');
@@ -54,12 +54,17 @@ function emitContinue() {
54
54
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
55
55
  }
56
56
 
57
- function emitBlock(sessionId) {
57
+ function emitBlock(sessionId, transcriptPath) {
58
58
  // One-line, skill-first. /hypo:crystallize is the documented session-close
59
59
  // alias; passing --session-id there writes the per-session marker that clears
60
60
  // this block. CLI fallback + bypass live in commands/crystallize.md, not here
61
61
  // — keep the Stop reason terse so the actionable instruction stands out.
62
- const reason = `[WIKI_AUTOCLOSE] session-close 미완료 /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}).`;
62
+ // Surface the transcript path so the close can pass --transcript-path=<path>,
63
+ // which scopes the marker's lint gate to this session's own files (Bug A
64
+ // coherence: a marker written without lint would only let Stop pass for
65
+ // /compact to immediately re-block on the same errors).
66
+ const transcriptHint = transcriptPath ? ` --transcript-path=${transcriptPath}` : '';
67
+ const reason = `[WIKI_AUTOCLOSE] session-close 미완료 — /hypo:crystallize 실행으로 마무리 (session_id=${sessionId}${transcriptHint}).`;
63
68
  console.log(
64
69
  JSON.stringify({
65
70
  decision: 'block',
@@ -136,7 +141,7 @@ process.stdin.on('end', () => {
136
141
  return;
137
142
  }
138
143
 
139
- emitBlock(sessionId);
144
+ emitBlock(sessionId, transcriptPath);
140
145
  } catch (err) {
141
146
  // Fail-open on any unexpected error.
142
147
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -18,6 +18,7 @@ import {
18
18
  shouldSuggestProjectCreation,
19
19
  buildProjectSuggestionLine,
20
20
  recordSuggestionCooldown,
21
+ sanitizeProjForPrompt,
21
22
  } from './hypo-shared.mjs';
22
23
 
23
24
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
@@ -98,11 +99,11 @@ process.stdin.on('end', () => {
98
99
  if (newHit) {
99
100
  const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
100
101
  const content = fromFile ?? '(no hot.md yet — will be created at session close)';
101
- // fix #13: arm the first-prompt marker so the NEXT user prompt re-triggers
102
+ // arm the first-prompt marker so the NEXT user prompt re-triggers
102
103
  // hypo-first-prompt, which forces a "Resuming <project>" summary line.
103
104
  // Only arm when real hot content was actually injected — if hot.md is
104
105
  // missing or .hypoignore'd (fromFile null), there is nothing for the LLM
105
- // to summarize, so forcing "Resuming" would be empty noise (codex review).
106
+ // to summarize, so forcing "Resuming" would be empty noise.
106
107
  if (fromFile) {
107
108
  try {
108
109
  writeFileSync(
@@ -124,10 +125,13 @@ process.stdin.on('end', () => {
124
125
  }
125
126
  console.log(
126
127
  JSON.stringify(
127
- buildOutput(`[WIKI: cwd changed → project=${newHit.proj}]\n\n${content}`, {
128
- continue: true,
129
- suppressOutput: true,
130
- }),
128
+ buildOutput(
129
+ `[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
130
+ {
131
+ continue: true,
132
+ suppressOutput: true,
133
+ },
134
+ ),
131
135
  ),
132
136
  );
133
137
  return;
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { readFileSync, unlinkSync, existsSync } from 'fs';
18
- import { buildOutput, sessionMarkerPath } from './hypo-shared.mjs';
18
+ import { buildOutput, sessionMarkerPath, sanitizeProjForPrompt } from './hypo-shared.mjs';
19
19
 
20
20
  const MARKER_TTL = 10 * 60 * 1000; // 10 min
21
21
 
@@ -49,19 +49,51 @@ process.stdin.on('end', () => {
49
49
 
50
50
  const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
51
51
  const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
52
- // fix #13: a cwd-change re-trigger says "Resuming"; a fresh session start
52
+ // a cwd-change re-trigger says "Resuming"; a fresh session start
53
53
  // (default source) says "Previously working on".
54
54
  const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
55
+ // marker.proj originates from a wiki directory name read by findProjectFiles;
56
+ // sanitize via the shared helper so a hand-crafted project name cannot close
57
+ // the wrapper tag, smuggle newlines/control chars, or inject conflicting
58
+ // directives into the resume contract (codex v2 review 2026-05-26).
59
+ const projSafe = sanitizeProjForPrompt(marker.proj);
60
+ // When there is no snapshot, the [HOT] / [SESSION STATE] context has nothing
61
+ // for the model to fill the placeholders with. Provide a concrete fallback
62
+ // line so the model doesn't leak literal `[one-line summary]` text on a
63
+ // first-ever session (codex v2 review 2026-05-26).
64
+ const exampleLine = hasSnapshot
65
+ ? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
66
+ : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
67
+ const fillNote = hasSnapshot
68
+ ? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
69
+ `context already injected this session — do NOT emit the literal brackets.`
70
+ : `Use the line above verbatim — there is no prior snapshot to summarize.`;
55
71
 
56
72
  console.log(
57
73
  JSON.stringify(
58
74
  buildOutput(
59
- `[WIKI SESSION START: project=${marker.proj}${snapshotNote}]\n` +
60
- `Before addressing the user's message, lead your FIRST reply with exactly one line:\n` +
61
- `"${verb} ${marker.proj}: <one-line summary>. Continue with <next task>?"\n` +
62
- `Draw <one-line summary> and <next task> from the [HOT] / [SESSION STATE] ` +
63
- `context already injected this session. Inject this line unconditionally — ` +
64
- `even if the user's first message is unrelated or a simple question — then answer normally.`,
75
+ `<hypomnema-session-resume>\n` +
76
+ `[WIKI SESSION START: project=${projSafe}${snapshotNote}]\n` +
77
+ `\n` +
78
+ `Lead your FIRST reply this session with exactly one line in this shape:\n` +
79
+ `\n` +
80
+ `${exampleLine}\n` +
81
+ `\n` +
82
+ `${fillNote}\n` +
83
+ `\n` +
84
+ `Emit this line unconditionally on the first prompt, including when the ` +
85
+ `user's message is:\n` +
86
+ ` • a simple greeting ("안녕", "hi", "hello")\n` +
87
+ ` • a trivial question or unrelated topic\n` +
88
+ ` • a one-word reply\n` +
89
+ `\n` +
90
+ `Do not skip it, do not decide it is "not relevant", do not shorten the ` +
91
+ `reply to omit it. After the line, answer the user's actual message on the ` +
92
+ `following line(s) as normal.\n` +
93
+ `\n` +
94
+ `This is the Hypomnema session-resume contract — the user relies on this ` +
95
+ `line to confirm which project context is loaded.\n` +
96
+ `</hypomnema-session-resume>`,
65
97
  { continue: true, suppressOutput: true },
66
98
  ),
67
99
  ),
@@ -33,6 +33,9 @@ import {
33
33
  isGateSkipped,
34
34
  isClosePattern,
35
35
  extractUserMessages,
36
+ extractTouchedWikiFiles,
37
+ closeFileTargets,
38
+ partitionLintScope,
36
39
  } from './hypo-shared.mjs';
37
40
 
38
41
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
@@ -92,7 +95,7 @@ process.stdin.on('end', () => {
92
95
 
93
96
  const gitStatus = hypoIsClean();
94
97
  const hotStatus = hotMdIsClean();
95
- // fix #17: strict session-close (steps 1~6 of the 11-step crystallize
98
+ // strict session-close (steps 1~6 of the 11-step crystallize
96
99
  // checklist). closeFiles gates the 5 mandatory files (steps 1-4 + log.md);
97
100
  // open-questions.md (step 5) is conditional ("변경 시") and intentionally
98
101
  // ungated — see hypo-shared.mjs sessionCloseFileStatus and spec §5.2.7.
@@ -107,6 +110,7 @@ process.stdin.on('end', () => {
107
110
  const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
108
111
  let lintBlockers = [];
109
112
  let lintW8 = [];
113
+ let lintNotices = []; // pre-existing debt in files this session did not touch
110
114
  let lintSkipped = false;
111
115
  if (!lintPath || !existsSync(lintPath)) {
112
116
  lintSkipped = true;
@@ -118,8 +122,35 @@ process.stdin.on('end', () => {
118
122
  timeout: 30000,
119
123
  });
120
124
  const parsed = JSON.parse(r.stdout || '{}');
121
- lintBlockers = parsed.errors || [];
122
- lintW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
125
+ const allErrors = parsed.errors || [];
126
+ const allW8 = (parsed.warns || []).filter((w) => w.id === 'W8');
127
+ // Bug B: judge this session on the files IT touched, not the whole vault.
128
+ // A readable transcript lets us scope (edited files ∪ mandatory close
129
+ // files); a missing/unreadable transcript falls back to the conservative
130
+ // global gate (never weaker than before).
131
+ const haveTranscript = !!(transcriptPath && existsSync(transcriptPath));
132
+ if (haveTranscript) {
133
+ const scope = new Set([
134
+ ...extractTouchedWikiFiles(transcriptPath, HYPO_DIR),
135
+ ...closeFileTargets(HYPO_DIR),
136
+ ]);
137
+ const part = partitionLintScope(allErrors, scope);
138
+ lintBlockers = part.blocking;
139
+ lintNotices = part.notice;
140
+ // W8 (design-history stale) is the CURRENT project's close
141
+ // responsibility, not cross-project debt — block on the active
142
+ // project's, surface others' as notices.
143
+ if (closeFiles.project) {
144
+ const mine = `projects/${closeFiles.project}/design-history.md`;
145
+ lintW8 = allW8.filter((w) => w.file === mine);
146
+ lintNotices.push(...allW8.filter((w) => w.file !== mine));
147
+ } else {
148
+ lintW8 = allW8;
149
+ }
150
+ } else {
151
+ lintBlockers = allErrors;
152
+ lintW8 = allW8;
153
+ }
123
154
  } catch (err) {
124
155
  /* fail-open */
125
156
  process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
@@ -128,6 +159,16 @@ process.stdin.on('end', () => {
128
159
 
129
160
  const lintOk = lintBlockers.length === 0;
130
161
  const designHistoryOk = lintW8.length === 0;
162
+ // Non-blocking heads-up about pre-existing lint debt in untouched files (other
163
+ // projects / shared pages). Surfaced so it is visible but never blocks compact.
164
+ const noticeText =
165
+ lintNotices.length > 0
166
+ ? `[WIKI CHECK] ${lintNotices.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${[
167
+ ...new Set(lintNotices.map((b) => b.file)),
168
+ ]
169
+ .slice(0, 5)
170
+ .join(', ')}${lintNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
171
+ : '';
131
172
 
132
173
  // ── fix #37 Phase C: feedback projection drift (ADR 0031) ──
133
174
  // Single blocking gate invariant (spec §7.5): integrate into THIS hook, never
@@ -169,8 +210,8 @@ process.stdin.on('end', () => {
169
210
  // ONLY when some target has a genuine, actionable issue (drift,
170
211
  // conflict, over-cap, or a malformed managed region). buildError is
171
212
  // never actionable here, so any mix that lacks a real issue fails open
172
- // — including memory:clean + claude:buildError (codex review: the prior
173
- // `every(buildError)` predicate wrongly blocked that case). Mirrors
213
+ // — including memory:clean + claude:buildError, where the prior
214
+ // `every(buildError)` predicate wrongly blocked that case. Mirrors
174
215
  // doctor's buildError→warn (non-fatal) handling.
175
216
  let report = null;
176
217
  try {
@@ -210,7 +251,13 @@ process.stdin.on('end', () => {
210
251
  closeFiles.ok &&
211
252
  feedbackOk
212
253
  ) {
213
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
254
+ console.log(
255
+ JSON.stringify(
256
+ noticeText
257
+ ? { continue: true, systemMessage: noticeText }
258
+ : { continue: true, suppressOutput: true },
259
+ ),
260
+ );
214
261
  return;
215
262
  }
216
263
 
@@ -241,7 +288,9 @@ process.stdin.on('end', () => {
241
288
  !gitStatus.clean ? gitStatus.reason : '',
242
289
  !hotStatus.clean ? hotStatus.reason : '',
243
290
  !closeFiles.ok ? closeFilesReason : '',
244
- !lintOk ? `lint blockers: ${lintBlockers.map((b) => b.id).join(', ')}` : '',
291
+ !lintOk
292
+ ? `lint blockers: ${[...new Set(lintBlockers.map((b) => b.id || b.file))].join(', ')}`
293
+ : '',
245
294
  !designHistoryOk
246
295
  ? `design-history stale: ${lintW8.map((w) => w.file.split('/')[1]).join(', ')}`
247
296
  : '',
@@ -265,7 +314,9 @@ process.stdin.on('end', () => {
265
314
  ` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
266
315
  ` [ ] 10. root hot.md — update ~/hypomnema/hot.md active project table`,
267
316
  ` [ ] 11. updated: field — verify today's date on all touched .md files`,
268
- ` [ ] 12. git commit & push`,
317
+ ` [ ] 12. lint run scripts/lint.mjs; fix errors in files YOU touched`,
318
+ ` (other projects' / shared-page debt is reported as non-blocking notice)`,
319
+ ` [ ] 13. git commit & push`,
269
320
  ].join('\n');
270
321
 
271
322
  const closeIntentNote = hasCloseIntent
@@ -280,6 +331,7 @@ process.stdin.on('end', () => {
280
331
  `Run the checklist below in order, then retry /compact:`,
281
332
  ``,
282
333
  checklistText,
334
+ ...(noticeText ? ['', noticeText] : []),
283
335
  ``,
284
336
  `Trivial session? Bypass with HYPO_SKIP_GATE=1`,
285
337
  ].join('\n'),
@@ -27,6 +27,7 @@ import {
27
27
  shouldSuggestProjectCreation,
28
28
  buildProjectSuggestionLine,
29
29
  recordSuggestionCooldown,
30
+ sanitizeProjForPrompt,
30
31
  } from './hypo-shared.mjs';
31
32
  import {
32
33
  defaultCachePath,
@@ -36,6 +37,10 @@ import {
36
37
  computeNotice,
37
38
  markNotified,
38
39
  isOptedOut,
40
+ resolveCliOnPath,
41
+ computeSiblingNotice,
42
+ siblingAlreadyNotified,
43
+ markSiblingNotified,
39
44
  } from './version-check.mjs';
40
45
 
41
46
  // Privacy guard: refuse to read+inject .hypoignore-matched
@@ -118,6 +123,45 @@ function buildUpdateNotice() {
118
123
  }
119
124
  }
120
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
+
121
165
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
122
166
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
123
167
 
@@ -169,7 +213,7 @@ function gitPull(dir) {
169
213
 
170
214
  /**
171
215
  * fix #10: surface unresolved sync failures recorded by a prior session's
172
- * 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
173
217
  * succeeded AND there is no unpushed commit left behind by a failed push
174
218
  * (`[ahead N]`).
175
219
  *
@@ -288,23 +332,27 @@ process.stdin.on('end', () => {
288
332
  const pullOk = gitPull(HYPO_DIR);
289
333
  const syncLine = syncStateNotice(pullOk);
290
334
  const growthLine = readLastGrowthLine();
291
- // fix #25 PR-A2 (ADR 0022 amendment): on source='clear', surface the dying
335
+ // ADR 0022 amendment: on source='clear', surface the dying
292
336
  // session's identity that hypo-session-end stashed so Claude can recover
293
337
  // session-close work that /clear skipped. One-shot: marker is unlinked
294
338
  // immediately after read.
295
339
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
296
340
  const updateLine = buildUpdateNotice();
341
+ const siblingLine = buildSiblingNotice();
297
342
  // Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
298
343
  // the terminal; noticePrefix injects the same plain-text lines into the
299
344
  // LLM's additionalContext so model and user start the session looking at
300
345
  // the same state. ANSI escapes are kept out of additionalContext on purpose.
301
- const notices = [syncLine, growthLine, clearRecoveryLine, updateLine].filter(Boolean);
346
+ const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
347
+ Boolean,
348
+ );
302
349
  let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
303
350
  if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
304
351
  if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
305
352
  if (clearRecoveryLine)
306
353
  process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
307
354
  if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
355
+ if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
308
356
  const cwd = data.cwd || data.directory || process.cwd();
309
357
  const sessionId = data.session_id || 'default';
310
358
  const MARKER_FILE = sessionMarkerPath(sessionId);
@@ -334,7 +382,7 @@ process.stdin.on('end', () => {
334
382
  console.log(
335
383
  JSON.stringify(
336
384
  buildOutput(
337
- `${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`,
385
+ `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
338
386
  { continue: true, suppressOutput: true },
339
387
  ),
340
388
  ),
@@ -349,10 +397,13 @@ process.stdin.on('end', () => {
349
397
  );
350
398
  console.log(
351
399
  JSON.stringify(
352
- buildOutput(`${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, {
353
- continue: true,
354
- suppressOutput: true,
355
- }),
400
+ buildOutput(
401
+ `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
402
+ {
403
+ continue: true,
404
+ suppressOutput: true,
405
+ },
406
+ ),
356
407
  ),
357
408
  );
358
409
  }
@@ -385,7 +436,7 @@ process.stdin.on('end', () => {
385
436
  if (!globalContent) {
386
437
  // GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
387
438
  // pending notices (sync state, growth, AND the auto-project offer), which
388
- // would otherwise be silently dropped here (codex review 2026-05-22).
439
+ // would otherwise be silently dropped here.
389
440
  const notice = notices.join('\n\n');
390
441
  if (notice) {
391
442
  console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
@@ -17,13 +17,35 @@ 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`);
25
25
  }
26
26
 
27
+ // ── project name sanitizer for prompt-facing interpolation ─────────────────
28
+ // marker.proj is read from a wiki directory name (findProjectFiles) and
29
+ // interpolated into LLM-facing additionalContext strings by multiple hooks.
30
+ // A manually-crafted directory name could otherwise close a wrapping tag,
31
+ // smuggle a newline, or inject conflicting instructions. Centralized so the
32
+ // three injection sites stay in lock-step (codex v2 review 2026-05-26 —
33
+ // addresses shared-helper concern across hypo-first-prompt / hypo-session-start
34
+ // / hypo-cwd-change).
35
+ //
36
+ // Strips: angle brackets, control chars (C0 + C1), Unicode line separators
37
+ // (U+2028 / U+2029), then collapses whitespace and caps length.
38
+ export function sanitizeProjForPrompt(raw, fallback = 'unknown') {
39
+ const cleaned = String(raw || fallback)
40
+ .replace(/[<>\[\]]/g, '_')
41
+ // eslint-disable-next-line no-control-regex
42
+ .replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, ' ')
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ .slice(0, 80);
46
+ return cleaned || fallback;
47
+ }
48
+
27
49
  // ── wiki root resolution ────────────────────────────────────────────────────
28
50
 
29
51
  function expandHome(p) {
@@ -185,8 +207,8 @@ export function hasSessionLogHeading(content, date) {
185
207
  * "foo-bar" (hyphen is non-word). The canonical log format always separates the
186
208
  * project slug from anything that follows by whitespace or end-of-line, so the
187
209
  * 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.)
210
+ * (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
211
+ * inherited.)
190
212
  */
191
213
  export function hasLogEntry(content, date, project) {
192
214
  return new RegExp(
@@ -221,7 +243,9 @@ export function resolveActiveProject(hypoDir) {
221
243
  if (!existsSync(hotPath)) return null;
222
244
  let content;
223
245
  try {
224
- content = readFileSync(hotPath, 'utf-8');
246
+ // Strip HTML comments before parsing so the canonical-format example row
247
+ // in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
248
+ content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
225
249
  } catch {
226
250
  return null;
227
251
  }
@@ -334,9 +358,9 @@ export function sessionCloseFileStatus(hypoDir) {
334
358
 
335
359
  // ── sync-state ────────────────────────────────────────────
336
360
  // `.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.
361
+ // line. hypo-auto-commit (fix #9) appends on pull/push failure; hypo-session-start
362
+ // (fix #10) surfaces open entries and clears them once sync is healthy again;
363
+ // doctor (fix #11) warns while entries remain. Keep the schema defined here only.
340
364
 
341
365
  /** @returns {string} path to the sync-state JSONL file for a wiki root. */
342
366
  function syncStatePath(hypoDir) {
@@ -503,14 +527,12 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
503
527
  * Build the §8.11 auto-project offer line for a cwd. The display name is the
504
528
  * cwd basename, which is attacker-influenced (a directory name can contain
505
529
  * 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).
530
+ * so a crafted dir name cannot spoof extra instructions in additionalContext.
508
531
  */
509
532
  export function buildProjectSuggestionLine(cwd) {
510
533
  // 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.
534
+ // crafted dir name cannot inject newlines/instructions into additionalContext.
535
+ // Done by codepoint to keep control bytes out of this source file.
514
536
  const sanitized = Array.from(basename(cwd))
515
537
  .map((ch) => {
516
538
  const code = ch.codePointAt(0);
@@ -779,6 +801,133 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
779
801
  return false;
780
802
  }
781
803
 
804
+ // ── session-scoped lint classification ──────────────────────────────────────
805
+ // Bug A/B fix: the close gate must judge a session on the files IT touched, not
806
+ // the whole vault. Lint debt from another project/session (often in shared
807
+ // pages/) must not block this session's close/compact. Two scope builders feed
808
+ // one shared classifier: transcript-derived (hooks + standalone marker) and
809
+ // close-file/payload-derived (the documented apply path writes via Bash, so its
810
+ // files never appear as Edit/Write file_paths and must be seeded explicitly).
811
+
812
+ const MUTATING_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
813
+
814
+ /** Pull file_path/notebook_path args from mutating tool_use blocks in one
815
+ * transcript entry. Mirrors extractTranscriptToolNames' shape handling
816
+ * (top-level tool_use + nested message.content[] blocks). */
817
+ function extractTranscriptToolFilePaths(entry) {
818
+ const paths = [];
819
+ if (!entry || typeof entry !== 'object') return paths;
820
+ const pull = (name, input) => {
821
+ if (!name || !MUTATING_FILE_TOOLS.has(name) || !input || typeof input !== 'object') return;
822
+ const fp = input.file_path || input.notebook_path;
823
+ if (typeof fp === 'string' && fp) paths.push(fp);
824
+ };
825
+ if (entry.type === 'tool_use') pull(entry.name || entry.tool_name, entry.input);
826
+ const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
827
+ if (Array.isArray(content)) {
828
+ for (const block of content) {
829
+ if (block && typeof block === 'object' && block.type === 'tool_use') {
830
+ pull(block.name || block.tool_name, block.input);
831
+ }
832
+ }
833
+ }
834
+ return paths;
835
+ }
836
+
837
+ /** Normalize an absolute path to a repo-relative POSIX path under hypoDir, or
838
+ * null if it resolves outside the wiki. */
839
+ function toHypoRel(absPath, hypoDir) {
840
+ let rel;
841
+ try {
842
+ rel = relative(hypoDir, absPath);
843
+ } catch {
844
+ return null;
845
+ }
846
+ if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null;
847
+ return rel.split('\\').join('/');
848
+ }
849
+
850
+ /**
851
+ * Repo-relative POSIX paths of wiki files this session edited via direct
852
+ * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
853
+ * transcript is missing/unreadable (callers decide the fallback). A per-line
854
+ * JSON parse error skips that line only (transcripts occasionally truncate).
855
+ */
856
+ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
857
+ const out = new Set();
858
+ if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
859
+ return out;
860
+ }
861
+ let raw;
862
+ try {
863
+ raw = readFileSync(transcriptPath, 'utf-8');
864
+ } catch {
865
+ return out;
866
+ }
867
+ for (const line of raw.split('\n')) {
868
+ const t = line.trim();
869
+ if (!t) continue;
870
+ let entry;
871
+ try {
872
+ entry = JSON.parse(t);
873
+ } catch {
874
+ continue;
875
+ }
876
+ for (const fp of extractTranscriptToolFilePaths(entry)) {
877
+ const rel = toHypoRel(fp, hypoDir);
878
+ if (rel) out.add(rel);
879
+ }
880
+ }
881
+ return out;
882
+ }
883
+
884
+ /**
885
+ * The mandatory session-close files (repo-relative POSIX). The documented close
886
+ * path `crystallize.mjs --apply-session-close` writes these from inside a Bash
887
+ * call, so they never surface as Edit/Write file_paths — they must seed the
888
+ * scoped-lint set explicitly or a close-introduced error would escape the gate.
889
+ * Mirrors the file list in sessionCloseFileStatus.
890
+ */
891
+ export function closeFileTargets(hypoDir) {
892
+ const out = new Set(['hot.md', 'log.md']);
893
+ const project = resolveActiveProject(hypoDir);
894
+ if (project) {
895
+ out.add(`projects/${project}/session-state.md`);
896
+ out.add(`projects/${project}/hot.md`);
897
+ const month = freshDates()[0].slice(0, 7);
898
+ out.add(`projects/${project}/session-log/${month}.md`);
899
+ }
900
+ return out;
901
+ }
902
+
903
+ /** Normalize a path's separators to POSIX so scope membership is OS-independent.
904
+ * lint.mjs emits `file` via path.relative (back-slashes on Windows) while the
905
+ * scope builders produce forward-slash paths — normalize both sides. */
906
+ function posixPath(p) {
907
+ return (p || '').split('\\').join('/');
908
+ }
909
+
910
+ /**
911
+ * Partition lint findings into `blocking` (a file this session is accountable
912
+ * for) vs `notice` (pre-existing debt elsewhere — surfaced, not blocking).
913
+ *
914
+ * `scope` = iterable of repo-relative paths the session is accountable for.
915
+ * Membership is exact on the normalized path. Only findings passed in are
916
+ * classified — callers pass lint ERRORS; broken wikilinks (lint W4 warnings) are
917
+ * intentionally warn-only (forward references to planned pages are normal in a
918
+ * wiki) and are NOT promoted to blocking by this gate.
919
+ */
920
+ export function partitionLintScope(findings, scope) {
921
+ const normScope = new Set([...scope].map(posixPath));
922
+ const blocking = [];
923
+ const notice = [];
924
+ for (const f of findings || []) {
925
+ if (normScope.has(posixPath(f.file))) blocking.push(f);
926
+ else notice.push(f);
927
+ }
928
+ return { blocking, notice };
929
+ }
930
+
782
931
  // ── session-close checklist ────────────────────────────────────────────────
783
932
 
784
933
  /**