hypomnema 1.3.0 → 1.3.2

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 (48) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +12 -10
  4. package/README.md +12 -10
  5. package/commands/crystallize.md +8 -8
  6. package/commands/feedback.md +1 -1
  7. package/commands/resume.md +1 -1
  8. package/commands/upgrade.md +2 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/CONTRIBUTING.md +2 -2
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  12. package/hooks/hypo-compact-guard.mjs +1 -1
  13. package/hooks/hypo-cwd-change.mjs +1 -1
  14. package/hooks/hypo-first-prompt.mjs +2 -2
  15. package/hooks/hypo-hot-rebuild.mjs +14 -1
  16. package/hooks/hypo-personal-check.mjs +91 -179
  17. package/hooks/hypo-pre-commit.mjs +1 -1
  18. package/hooks/hypo-session-end.mjs +1 -1
  19. package/hooks/hypo-session-start.mjs +26 -19
  20. package/hooks/hypo-shared.mjs +839 -58
  21. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  22. package/hooks/version-check-fetch.mjs +2 -8
  23. package/hooks/version-check.mjs +18 -0
  24. package/package.json +3 -2
  25. package/scripts/check-tracker-ids.mjs +329 -0
  26. package/scripts/crystallize.mjs +249 -109
  27. package/scripts/doctor.mjs +6 -9
  28. package/scripts/feedback-sync.mjs +1 -1
  29. package/scripts/init.mjs +1 -1
  30. package/scripts/install-git-hooks.mjs +75 -40
  31. package/scripts/lib/check-tracker-ids.mjs +140 -0
  32. package/scripts/lib/design-history-stale.mjs +59 -14
  33. package/scripts/lib/extensions.mjs +4 -4
  34. package/scripts/lib/fix-manifest.mjs +2 -2
  35. package/scripts/lib/fix-status-verify.mjs +5 -4
  36. package/scripts/lib/plugin-detect.mjs +60 -0
  37. package/scripts/lib/project-create.mjs +1 -1
  38. package/scripts/lint.mjs +63 -8
  39. package/scripts/rename.mjs +373 -0
  40. package/scripts/resume.mjs +127 -13
  41. package/scripts/smoke-pack.mjs +23 -2
  42. package/scripts/uninstall.mjs +1 -1
  43. package/scripts/upgrade.mjs +266 -47
  44. package/skills/crystallize/SKILL.md +10 -7
  45. package/templates/SCHEMA.md +2 -2
  46. package/templates/hypo-config.md +2 -2
  47. package/templates/hypo-guide.md +20 -1
  48. package/templates/projects/_template/index.md +1 -1
@@ -3,8 +3,8 @@
3
3
  * hypo-personal-check.mjs — PreCompact hook
4
4
  *
5
5
  * Hard gate before /compact. Blocks if:
6
- * - the session-close memory files were not updated this session (fix #17:
7
- * session-state.md, project hot.md, root hot.md, session-log, log.md)
6
+ * - the session-close memory files were not updated this session
7
+ * (session-state.md, project hot.md, root hot.md, session-log, log.md)
8
8
  * - wiki git repo has uncommitted/unpushed changes
9
9
  * - hot.md has forbidden structure
10
10
  * - lint blockers exist
@@ -14,7 +14,7 @@
14
14
  * 2. HYPO_SKIP_GATE=1 in a recent *user-role* transcript message
15
15
  * (assistant/tool output is excluded to prevent self-triggering from block reason text)
16
16
  *
17
- * NOTE: capacity bypass (wiki-context-critical.json ≥90%) was REMOVED by fix #26
17
+ * NOTE: capacity bypass (wiki-context-critical.json ≥90%) was REMOVED
18
18
  * (ADR 0022 amendment 2026-05-13). Spec §7.5: even at full context, minimal
19
19
  * session-close is mandatory — auto-bypass on capacity caused silent state loss.
20
20
  */
@@ -26,16 +26,11 @@ import { homedir } from 'os';
26
26
  import {
27
27
  HYPO_DIR,
28
28
  PKG_ROOT,
29
- hypoIsClean,
30
- hotMdIsClean,
31
- sessionCloseFileStatus,
29
+ precompactGateStatus,
32
30
  readChecklist,
33
31
  isGateSkipped,
34
32
  isClosePattern,
35
33
  extractUserMessages,
36
- extractTouchedWikiFiles,
37
- closeFileTargets,
38
- partitionLintScope,
39
34
  } from './hypo-shared.mjs';
40
35
 
41
36
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
@@ -45,14 +40,19 @@ process.stdin.setEncoding('utf-8');
45
40
  process.stdin.on('data', (chunk) => (raw += chunk));
46
41
  process.stdin.on('end', () => {
47
42
  let transcriptPath = null;
43
+ let sessionId = null;
48
44
  try {
49
45
  const input = JSON.parse(raw || '{}');
50
46
  transcriptPath = input.transcript_path ?? null;
47
+ // A log-only marker for this session activates log-only gate
48
+ // semantics (no project attribution) so /compact does not block a closed
49
+ // non-project session on the active/phantom project's files.
50
+ sessionId = input.session_id ?? input.sessionId ?? null;
51
51
  } catch {
52
52
  /* fail-open */
53
53
  }
54
54
 
55
- // ── Capacity bypass (≥90%) REMOVED — fix #26, ADR 0022 amendment 2026-05-13.
55
+ // ── Capacity bypass (≥90%) REMOVED — ADR 0022 amendment 2026-05-13.
56
56
  // Even at full context, minimal session-close is mandatory (spec §7.5).
57
57
  // Bypass paths are now only: HYPO_SKIP_GATE env / HYPO_SKIP_GATE in transcript.
58
58
 
@@ -93,164 +93,82 @@ process.stdin.on('end', () => {
93
93
  // ── Heavy checks ──
94
94
  const today = new Date().toISOString().slice(0, 10);
95
95
 
96
- const gitStatus = hypoIsClean();
97
- const hotStatus = hotMdIsClean();
98
- // strict session-close (steps 1~6 of the 11-step crystallize
99
- // checklist). closeFiles gates the 5 mandatory files (steps 1-4 + log.md);
100
- // open-questions.md (step 5) is conditional ("변경 시") and intentionally
101
- // ungated see hypo-shared.mjs sessionCloseFileStatus and spec §5.2.7.
102
- const closeFiles = sessionCloseFileStatus(HYPO_DIR);
103
- const closeFilesReason = closeFiles.ok
104
- ? ''
105
- : `memory files not updated this session: ${[
106
- ...closeFiles.missing.map((f) => `${f} (missing)`),
107
- ...closeFiles.stale.map((f) => `${f} (stale)`),
108
- ].join(', ')}`;
96
+ // The full PreCompact gate decision, single-sourced (ADR 0046). The SAME
97
+ // function backs `crystallize --check-session-close`, so a green self-check
98
+ // there means this hook will not block. precompactGateStatus runs git-clean +
99
+ // hot.md structure + session-close files (ADR 0043 global invariant) + scoped
100
+ // lint + W8 design-history + feedback projection. The transcript widens the
101
+ // lint scope to this session's edited files (ADR 0041); without one the scope
102
+ // is the mandatory close files. Read-only: pure feedback drift comes back as
103
+ // gate.driftTargets, a self-heal effect requirement we run as --write below.
104
+ let gate;
105
+ try {
106
+ gate = precompactGateStatus(HYPO_DIR, {
107
+ transcriptPath,
108
+ ...(sessionId ? { sessionId } : {}),
109
+ });
110
+ } catch (err) {
111
+ // Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
112
+ // throws, never crash the PreCompact hook — fail open (continue) so a tooling
113
+ // fault can't wedge /compact.
114
+ process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
115
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
116
+ return;
117
+ }
109
118
 
110
- const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
111
- let lintBlockers = [];
112
- let lintW8 = [];
113
- let lintNotices = []; // pre-existing debt in files this session did not touch
114
- let lintSkipped = false;
115
- if (!lintPath || !existsSync(lintPath)) {
116
- lintSkipped = true;
117
- } else {
118
- try {
119
- const r = spawnSync('node', [lintPath, '--json'], {
120
- encoding: 'utf-8',
121
- cwd: HYPO_DIR,
122
- timeout: 30000,
119
+ // Self-heal pure feedback projection drift (ADR 0045): the one mutation the
120
+ // read-only gate leaves to the caller. Fails CLOSED — if the --write errors we
121
+ // turn the (otherwise non-blocking) drift into a blocker, since real drift is
122
+ // confirmed and silently passing it would defeat the gate. --write only applies
123
+ // when no target conflicts/over-caps (code===0 across ALL targets), so a late
124
+ // race exits non-zero and blocks here. It is semantic-preflight atomic but not
125
+ // filesystem-atomic (concurrent edit / mid-write I/O fault is best-effort) —
126
+ // pre-existing engine behavior, not introduced by the self-heal.
127
+ let feedbackHealed = '';
128
+ if (gate.ok && gate.driftTargets.length > 0) {
129
+ const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
130
+ const w = feedbackPath
131
+ ? spawnSync(
132
+ process.execPath,
133
+ [
134
+ feedbackPath,
135
+ '--write',
136
+ '--no-input',
137
+ `--hypo-dir=${HYPO_DIR}`,
138
+ `--claude-home=${join(homedir(), '.claude')}`,
139
+ ],
140
+ { encoding: 'utf-8', timeout: 30000 },
141
+ )
142
+ : { status: 1 };
143
+ if (w.error || w.status === null || w.status !== 0) {
144
+ gate.ok = false;
145
+ gate.blockers.push({
146
+ type: 'feedback',
147
+ reason: `feedback projection drift (${gate.driftTargets.join(', ')}) — auto-sync failed; run \`hypomnema feedback-sync --write\` manually`,
123
148
  });
124
- const parsed = JSON.parse(r.stdout || '{}');
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
- }
154
- } catch (err) {
155
- /* fail-open */
156
- process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
149
+ } else {
150
+ feedbackHealed = `[WIKI CHECK] feedback projection re-synced (${gate.driftTargets.join(', ')}); MEMORY.md body may be unchanged — drift was in the managed block / side-files.`;
157
151
  }
158
152
  }
159
153
 
160
- const lintOk = lintBlockers.length === 0;
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)),
154
+ // Non-blocking heads-up about pre-existing lint / out-of-scope design-history
155
+ // debt in untouched files (other projects / shared pages). Surfaced so it is
156
+ // visible but never blocks compact. (Mirrors the pre-ADR-0046 inline behavior,
157
+ // which lumped out-of-scope W8 into the same lint-notice list.)
158
+ const debtNotices = gate.notices.filter((n) => n.type === 'lint' || n.type === 'design-history');
159
+ let noticeText =
160
+ debtNotices.length > 0
161
+ ? `[WIKI CHECK] ${debtNotices.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${[
162
+ ...new Set(debtNotices.map((n) => n.reason.replace(/ \([^)]*\)$/, ''))),
168
163
  ]
169
164
  .slice(0, 5)
170
- .join(', ')}${lintNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
165
+ .join(', ')}${debtNotices.length > 5 ? ', …' : ''} — clean up when convenient.`
171
166
  : '';
167
+ // Surface the self-heal so a re-synced projection is not a silent mutation of
168
+ // the user's MEMORY.md / CLAUDE.md (ADR 0045 transparency).
169
+ if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
172
170
 
173
- // ── fix #37 Phase C: feedback projection drift (ADR 0031) ──
174
- // Single blocking gate invariant (spec §7.5): integrate into THIS hook, never
175
- // add a separate PreCompact hook. `feedback-sync --check --strict` reports
176
- // projection drift (wiki feedback SoT vs MEMORY / CLAUDE.md learned-behaviors
177
- // projection). `--no-input` keeps this non-TTY hook from ever blocking on a
178
- // prompt, and the engine's skip-MEMORY warning is *soft* (never escalated by
179
- // --strict) so a fresh / external user whose ~/.claude/projects/<id> dir does
180
- // not exist yet is never gated (contract §5 step 4). Fail-open on any spawn
181
- // error, exactly like the lint check above.
182
- const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
183
- let feedbackOk = true;
184
- let feedbackReason = '';
185
- let feedbackSkipped = false;
186
- if (!feedbackPath || !existsSync(feedbackPath)) {
187
- feedbackSkipped = true;
188
- } else {
189
- try {
190
- const r = spawnSync(
191
- process.execPath,
192
- [
193
- feedbackPath,
194
- '--check',
195
- '--strict',
196
- '--no-input',
197
- '--json',
198
- `--hypo-dir=${HYPO_DIR}`,
199
- `--claude-home=${join(homedir(), '.claude')}`,
200
- ],
201
- { encoding: 'utf-8', timeout: 30000 },
202
- );
203
- if (r.error || r.status === null) {
204
- feedbackSkipped = true; // spawn failure → fail-open (never block on tooling)
205
- } else if (r.status !== 0) {
206
- // exit≠0 alone is ambiguous. A *missing* target file (e.g. a system
207
- // whose ~/.claude/CLAUDE.md was never created) reports buildError +
208
- // exit 1, which is benign — there is nothing to gate. Decide from the
209
- // JSON report's per-target state instead of the raw exit code: block
210
- // ONLY when some target has a genuine, actionable issue (drift,
211
- // conflict, over-cap, or a malformed managed region). buildError is
212
- // never actionable here, so any mix that lacks a real issue fails open
213
- // — including memory:clean + claude:buildError, where the prior
214
- // `every(buildError)` predicate wrongly blocked that case. Mirrors
215
- // doctor's buildError→warn (non-fatal) handling.
216
- let report = null;
217
- try {
218
- report = JSON.parse(r.stdout || '');
219
- } catch {
220
- /* unparseable → fail-open below */
221
- }
222
- const targets = report ? Object.values(report.targets || {}) : [];
223
- const conflicted = targets.some(
224
- (t) =>
225
- t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
226
- );
227
- const overCap = targets.some((t) => t.overCap);
228
- const drifted = targets.some((t) => t.dirty);
229
- if (!report || !(conflicted || overCap || drifted)) {
230
- feedbackSkipped = true; // missing target / pure warning / unparseable → fail-open
231
- } else {
232
- feedbackOk = false;
233
- feedbackReason = conflicted
234
- ? 'feedback projection conflict (manual edit) — run `hypomnema feedback-sync --import-target-change --from=<memory|claude>`'
235
- : overCap
236
- ? 'feedback projection over cap — demote/archive feedback pages'
237
- : 'feedback projection drift — run `hypomnema feedback-sync --write`';
238
- }
239
- }
240
- } catch (err) {
241
- feedbackSkipped = true;
242
- process.stderr.write(`[hypo-personal-check] error: ${err?.message ?? String(err)}\n`);
243
- }
244
- }
245
-
246
- if (
247
- gitStatus.clean &&
248
- hotStatus.clean &&
249
- lintOk &&
250
- designHistoryOk &&
251
- closeFiles.ok &&
252
- feedbackOk
253
- ) {
171
+ if (gate.ok) {
254
172
  console.log(
255
173
  JSON.stringify(
256
174
  noticeText
@@ -264,13 +182,9 @@ process.stdin.on('end', () => {
264
182
  // ── Bypass 3: HYPO_SKIP_GATE ──
265
183
  if (isGateSkipped()) {
266
184
  const skipped = [
267
- !gitStatus.clean ? gitStatus.reason : '',
268
- !hotStatus.clean ? hotStatus.reason : '',
269
- !closeFiles.ok ? closeFilesReason : '',
270
- !designHistoryOk ? `design-history stale (${lintW8.length})` : '',
271
- !feedbackOk ? feedbackReason : '',
272
- lintSkipped ? 'lint skipped (hypo-pkg.json missing)' : '',
273
- feedbackSkipped ? 'feedback-sync skipped (hypo-pkg.json missing)' : '',
185
+ ...gate.blockers.map((b) => b.reason),
186
+ gate.skipped.lint ? 'lint skipped (hypo-pkg.json missing)' : '',
187
+ gate.skipped.feedback ? 'feedback-sync skipped (hypo-pkg.json missing)' : '',
274
188
  ]
275
189
  .filter(Boolean)
276
190
  .join(', ');
@@ -284,18 +198,12 @@ process.stdin.on('end', () => {
284
198
  }
285
199
 
286
200
  // ── Block ──
201
+ // gate.blockers already carry per-type reasons in the canonical order
202
+ // (git, hot, close, lint, design-history, feedback) — same strings as before
203
+ // ADR 0046, now sourced from the shared gate instead of inline checks.
287
204
  const reasons = [
288
- !gitStatus.clean ? gitStatus.reason : '',
289
- !hotStatus.clean ? hotStatus.reason : '',
290
- !closeFiles.ok ? closeFilesReason : '',
291
- !lintOk
292
- ? `lint blockers: ${[...new Set(lintBlockers.map((b) => b.id || b.file))].join(', ')}`
293
- : '',
294
- !designHistoryOk
295
- ? `design-history stale: ${lintW8.map((w) => w.file.split('/')[1]).join(', ')}`
296
- : '',
297
- !feedbackOk ? feedbackReason : '',
298
- lintSkipped ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
205
+ ...gate.blockers.map((b) => b.reason),
206
+ gate.skipped.lint ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
299
207
  ].filter(Boolean);
300
208
 
301
209
  const checklist = readChecklist(today);
@@ -304,11 +212,13 @@ process.stdin.on('end', () => {
304
212
  [
305
213
  ` [ ] 0. Read SCHEMA.md + hypo-guide.md (required before wiki work)`,
306
214
  ` [ ] 1. PRD — create projects/<name>/prd.md if missing`,
307
- ` [ ] 2. ADR — decide yes/no on 5 types; if all N, note "no ADR — reason: <why>"`,
215
+ ` [ ] 2. ADR — decide yes/no on 5 types. Design change append to projects/<name>/design-history.md.`,
216
+ ` If none, note the literal marker "ADR 없음 — reason: <why>" in the session-log entry`,
217
+ ` (machine-readable; suppresses the W8 design-history gate for no-design sessions).`,
308
218
  ` [ ] 3. Ingest — if new external knowledge, save to sources/ and ingest`,
309
219
  ` [ ] 4. Pages — extract new concepts/patterns to pages/`,
310
220
  ` [ ] 5. Synthesis — if 3+ cross-page analysis results, save to pages/syntheses/`,
311
- ` [ ] 6. session-log — append to projects/<name>/session-log/YYYY-MM.md`,
221
+ ` [ ] 6. session-log — append to projects/<name>/session-log/YYYY-MM-DD.md (daily shard)`,
312
222
  ` [ ] 7. index.md — update Projects section if needed`,
313
223
  ` [ ] 8. log.md — append ## [${today}] session | <project-name>`,
314
224
  ` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
@@ -317,6 +227,8 @@ process.stdin.on('end', () => {
317
227
  ` [ ] 12. lint — run scripts/lint.mjs; fix errors in files YOU touched`,
318
228
  ` (other projects' / shared-page debt is reported as non-blocking notice)`,
319
229
  ` [ ] 13. git commit & push`,
230
+ ` [ ] 14. verify — run \`crystallize.mjs --check-session-close\`; only declare`,
231
+ ` the session closed once it prints "Compact-ready" (= this gate passes).`,
320
232
  ].join('\n');
321
233
 
322
234
  const closeIntentNote = hasCloseIntent
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-pre-commit.mjs — wiki git pre-commit hook worker (§6.8 fix #24)
3
+ * hypo-pre-commit.mjs — wiki git pre-commit hook worker (§6.8)
4
4
  *
5
5
  * Blocks staged files that match .hypoignore patterns.
6
6
  * Installed by `hypo init` to <wiki>/.git/hooks/pre-commit.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-session-end.mjs — SessionEnd hook (fix #25 PR-A2, ADR 0022 Layer 2)
3
+ * hypo-session-end.mjs — SessionEnd hook (ADR 0022 Layer 2)
4
4
  *
5
5
  * `/clear` cannot be blocked: it never fires UserPromptSubmit (Stage 0 PoC,
6
6
  * 2026-05-14). The only intervention point is the SessionEnd(reason='clear')
@@ -176,7 +176,7 @@ function readLastGrowthLine() {
176
176
  }
177
177
 
178
178
  /**
179
- * fix #25 PR-A2 (ADR 0022 amendment 2026-05-14): if the prior session ended
179
+ * ADR 0022 amendment 2026-05-14: if the prior session ended
180
180
  * via `/clear`, hypo-session-end stashed its identity in `.cache/clear-marker.json`.
181
181
  * Read it (with 7-day stale guard), unlink it (one-shot), and return a
182
182
  * `[WIKI_AUTOCLOSE]` recovery line for additionalContext + stderr.
@@ -212,8 +212,8 @@ function gitPull(dir) {
212
212
  }
213
213
 
214
214
  /**
215
- * fix #10: surface unresolved sync failures recorded by a prior session's
216
- * Stop hook (fix #9). The entry is cleared only once this session's pull has
215
+ * Surface unresolved sync failures recorded by a prior session's
216
+ * Stop hook. The entry is cleared only once this session's pull has
217
217
  * succeeded AND there is no unpushed commit left behind by a failed push
218
218
  * (`[ahead N]`).
219
219
  *
@@ -323,6 +323,10 @@ let raw = '';
323
323
  process.stdin.setEncoding('utf-8');
324
324
  process.stdin.on('data', (chunk) => (raw += chunk));
325
325
  process.stdin.on('end', () => {
326
+ // 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 };
326
330
  try {
327
331
  let data = {};
328
332
  try {
@@ -339,10 +343,16 @@ process.stdin.on('end', () => {
339
343
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
340
344
  const updateLine = buildUpdateNotice();
341
345
  const siblingLine = buildSiblingNotice();
342
- // Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
343
- // the terminal; noticePrefix injects the same plain-text lines into the
344
- // LLM's additionalContext so model and user start the session looking at
345
- // the same state. ANSI escapes are kept out of additionalContext on purpose.
346
+ // 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 this banner's scope.)
354
+ const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
355
+ if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
346
356
  const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
347
357
  Boolean,
348
358
  );
@@ -383,7 +393,7 @@ process.stdin.on('end', () => {
383
393
  JSON.stringify(
384
394
  buildOutput(
385
395
  `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}]\n\n${parts.join('\n\n')}`,
386
- { continue: true, suppressOutput: true },
396
+ outExtra,
387
397
  ),
388
398
  ),
389
399
  );
@@ -399,10 +409,7 @@ process.stdin.on('end', () => {
399
409
  JSON.stringify(
400
410
  buildOutput(
401
411
  `${noticePrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
402
- {
403
- continue: true,
404
- suppressOutput: true,
405
- },
412
+ outExtra,
406
413
  ),
407
414
  ),
408
415
  );
@@ -410,7 +417,7 @@ process.stdin.on('end', () => {
410
417
  return;
411
418
  }
412
419
 
413
- // MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
420
+ // MISS: cwd matches no project. ADR 0023 — offer to create one
414
421
  // when the ADR trigger conditions hold (git repo + project marker + no
415
422
  // cooldown + not previously declined). The actual scaffold is the LLM's
416
423
  // job on a "Y" reply (scripts/lib/project-create.mjs); the hook only nudges.
@@ -425,9 +432,9 @@ process.stdin.on('end', () => {
425
432
  if (!existsSync(GLOBAL_HOT)) {
426
433
  const notice = notices.join('\n\n');
427
434
  if (notice) {
428
- console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
435
+ console.log(JSON.stringify(buildOutput(notice, outExtra)));
429
436
  } else {
430
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
437
+ console.log(JSON.stringify(outExtra));
431
438
  }
432
439
  return;
433
440
  }
@@ -439,9 +446,9 @@ process.stdin.on('end', () => {
439
446
  // would otherwise be silently dropped here.
440
447
  const notice = notices.join('\n\n');
441
448
  if (notice) {
442
- console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
449
+ console.log(JSON.stringify(buildOutput(notice, outExtra)));
443
450
  } else {
444
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
451
+ console.log(JSON.stringify(outExtra));
445
452
  }
446
453
  return;
447
454
  }
@@ -449,12 +456,12 @@ process.stdin.on('end', () => {
449
456
  JSON.stringify(
450
457
  buildOutput(
451
458
  `${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
452
- { continue: true, suppressOutput: true },
459
+ outExtra,
453
460
  ),
454
461
  ),
455
462
  );
456
463
  } catch (err) {
457
464
  process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
458
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
465
+ console.log(JSON.stringify(outExtra));
459
466
  }
460
467
  });