hypomnema 1.7.4 → 1.8.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +1202 -0
  4. package/README.ko.md +4 -4
  5. package/README.md +5 -5
  6. package/commands/audit.md +1 -1
  7. package/commands/capture.md +1 -1
  8. package/commands/crystallize.md +3 -3
  9. package/commands/doctor.md +15 -1
  10. package/commands/feedback.md +1 -1
  11. package/commands/graph.md +1 -1
  12. package/commands/ingest.md +1 -1
  13. package/commands/init.md +2 -2
  14. package/commands/lint.md +1 -1
  15. package/commands/query.md +1 -1
  16. package/commands/rename.md +1 -1
  17. package/commands/resume.md +1 -1
  18. package/commands/stats.md +1 -1
  19. package/commands/uninstall.md +1 -1
  20. package/commands/upgrade.md +2 -2
  21. package/commands/verify.md +1 -1
  22. package/docs/ARCHITECTURE.md +8 -3
  23. package/docs/CONTRIBUTING.md +2 -1
  24. package/hooks/base-store.mjs +198 -0
  25. package/hooks/close-gate-store.mjs +3 -2
  26. package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
  27. package/hooks/hypo-compact-guard.mjs +5 -3
  28. package/hooks/hypo-cwd-change.mjs +8 -0
  29. package/hooks/hypo-personal-check.mjs +127 -67
  30. package/hooks/hypo-session-start.mjs +91 -10
  31. package/hooks/hypo-shared.mjs +241 -19
  32. package/hooks/version-check.mjs +44 -6
  33. package/package.json +9 -3
  34. package/scripts/capture.mjs +49 -17
  35. package/scripts/crystallize.mjs +20 -2092
  36. package/scripts/doctor.mjs +334 -14
  37. package/scripts/init.mjs +106 -81
  38. package/scripts/lib/crystallize-args.mjs +50 -0
  39. package/scripts/lib/crystallize-close-apply.mjs +1830 -0
  40. package/scripts/lib/crystallize-close-check.mjs +238 -0
  41. package/scripts/lib/crystallize-close-gate.mjs +58 -0
  42. package/scripts/lib/crystallize-helpers.mjs +55 -0
  43. package/scripts/lib/extensions.mjs +175 -49
  44. package/scripts/lib/git-hooks-dir.mjs +88 -3
  45. package/scripts/lib/plugin-detect.mjs +176 -21
  46. package/scripts/lint.mjs +12 -6
  47. package/scripts/proposal.mjs +3 -3
  48. package/scripts/upgrade.mjs +243 -20
  49. package/skills/crystallize/SKILL.md +15 -13
  50. package/skills/graph/SKILL.md +3 -3
  51. package/skills/ingest/SKILL.md +2 -2
  52. package/skills/lint/SKILL.md +3 -3
  53. package/skills/query/SKILL.md +3 -3
  54. package/skills/verify/SKILL.md +3 -3
  55. package/templates/hypo-automation.md +59 -17
  56. package/templates/hypo-config.md +1 -1
  57. package/templates/hypo-guide.md +12 -9
  58. package/templates/hypo-help.md +1 -1
@@ -367,10 +367,11 @@ export function readResolution(hypoDir, sessionId, rawTranscript) {
367
367
  * and a marker withheld by a commit failure could never be recovered
368
368
  * without a brand-new close phrase the user has no reason to type twice.
369
369
  * tests/close-hooks-gate.test.mjs's test C
370
- * ("crystallize.mjs:754 (runMarkSessionClosed) stays on isCloseGateOpen")
370
+ * ("runMarkSessionClosed stays on isCloseGateOpen")
371
371
  * pins exactly this: it withholds a marker via a real commit failure, fixes
372
372
  * the commit by hand with NO new close signal, and asserts the recovery run
373
- * still succeeds; swapping :754 to `closeGateStatus` turns that test red.
373
+ * still succeeds; swapping that call to `closeGateStatus` turns that test
374
+ * red.
374
375
  * As of this writing the two callers that gate a NEW request are
375
376
  * `verifyCloseAuthority` (crystallize.mjs, before any wiki byte is written)
376
377
  * and `hypo-close-guard.mjs`'s PreToolUse intercept (before a
@@ -69,6 +69,7 @@ import {
69
69
  hasPendingBackgroundWork,
70
70
  isCloseReconfirmDeclined,
71
71
  CLOSE_RECONFIRM_MARK,
72
+ resolveGateProjectOverride,
72
73
  } from './hypo-shared.mjs';
73
74
 
74
75
  function emitContinue() {
@@ -245,10 +246,19 @@ process.stdin.on('end', () => {
245
246
  let gate = null;
246
247
  const computeGate = () => {
247
248
  try {
249
+ // resolveGateProjectOverride (session-close-scope-boundary spec §2/§3): Stop
250
+ // gets no explicit project, only sessionCwd, same as PreCompact. Passed
251
+ // below as `attributionScope` (never `projectOverride`, which is the
252
+ // check-only diagnostic's key and would also disable the mine/foreign
253
+ // partition): this is what demotes a foreign project's dangling close to
254
+ // a notice instead of holding this session's Stop hostage on debt it
255
+ // never touched.
256
+ const attributionScope = resolveGateProjectOverride(HYPO_DIR, { sessionCwd });
248
257
  return precompactGateStatus(HYPO_DIR, {
249
258
  ...(transcriptPath ? { transcriptPath } : {}),
250
259
  ...(sessionCwd ? { sessionCwd } : {}),
251
260
  ...(sessionId ? { sessionId } : {}),
261
+ ...(attributionScope ? { attributionScope } : {}),
252
262
  });
253
263
  } catch {
254
264
  return null;
@@ -3,9 +3,11 @@
3
3
  * hypo-compact-guard.mjs — UserPromptSubmit hook
4
4
  *
5
5
  * Scope: detects "/compact" or "/clear" typed in chat only (Layer 2).
6
- * The CLI built-in /compact does NOT fire UserPromptSubmit — use personal-wiki-check.mjs
7
- * (PreCompact hook) as the hard gate for that path. /clear has no PreCompact event, so
8
- * this hook is the only chat-side gate that can prompt session-close before context wipe.
6
+ * The CLI built-in /compact does NOT fire UserPromptSubmit. The PreCompact hook
7
+ * (hypo-personal-check.mjs) covers that path, but it only REPORTS: since the
8
+ * session-close-scope-boundary change it never blocks /compact. /clear has no
9
+ * PreCompact event at all, so this hook is the only chat-side gate that can
10
+ * prompt session-close before a context wipe.
9
11
  *
10
12
  * Behavior: if session close is incomplete → instruct Claude to run session close
11
13
  * immediately before /compact or /clear.
@@ -110,6 +110,14 @@ process.stdin.on('end', () => {
110
110
 
111
111
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
112
112
 
113
+ // This injection is not a licensing surface for the observed-set gate
114
+ // (see base-store.mjs's recordObserved): a session's tracked
115
+ // `targets` are fixed at whichever project the OWNING SessionStart hit,
116
+ // and a cwd move can land here in a different project entirely, so
117
+ // recording an observation against this read could credit a path outside
118
+ // that fixed target set. Nothing here calls recordObserved; the guard
119
+ // stays exactly as conservative for a cwd-change injection as it was
120
+ // before the observed set existed.
113
121
  if (newHit) {
114
122
  const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
115
123
  const content =
@@ -2,21 +2,22 @@
2
2
  /**
3
3
  * hypo-personal-check.mjs — PreCompact hook
4
4
  *
5
- * Hard gate before /compact. Blocks if:
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
- * - wiki git repo has uncommitted/unpushed changes
9
- * - hot.md has forbidden structure
10
- * - lint blockers exist
11
- *
12
- * Bypass options (checked in order, per spec §7.5):
13
- * 1. HYPO_SKIP_GATE=1 env var
14
- * 2. HYPO_SKIP_GATE=1 in a recent *user-role* transcript message
15
- * (assistant/tool output is excluded to prevent self-triggering from block reason text)
5
+ * NEVER blocks /compact (session-close-scope-boundary spec §1). It used to be
6
+ * a hard gate here, but /compact was the only hard path that "compact-ready"
7
+ * marker's invariant leaned on, and a hard block here meant every check
8
+ * `precompactGateStatus` runs (git-clean, hot.md structure, session-close
9
+ * files, scoped lint, W8 design-history, feedback projection) could hold
10
+ * /compact hostage on a project this session never touched. That is the
11
+ * scope-boundary bug this hook no longer causes. Session close is still
12
+ * enforced, just not HERE: the Stop hook (hypo-auto-minimal-crystallize.mjs)
13
+ * still blocks on close-intent + incomplete close, and `--mark-session-closed`
14
+ * still refuses the marker on a red gate. What follows is now advisory: an
15
+ * incomplete close surfaces as a systemMessage, never as decision:'block'.
16
16
  *
17
17
  * NOTE: capacity bypass (wiki-context-critical.json ≥90%) was REMOVED
18
- * (amendment 2026-05-13). Spec §7.5: even at full context, minimal
19
- * session-close is mandatory — auto-bypass on capacity caused silent state loss.
18
+ * (amendment 2026-05-13), and HYPO_SKIP_GATE is now moot for THIS hook since
19
+ * nothing here blocks — it is kept only so an incomplete-close systemMessage
20
+ * doesn't repeat the recommendation once bypassed.
20
21
  */
21
22
 
22
23
  import { spawnSync } from 'child_process';
@@ -32,6 +33,7 @@ import {
32
33
  isClosePattern,
33
34
  extractUserMessages,
34
35
  isUnderProjectDirs,
36
+ resolveGateProjectOverride,
35
37
  } from './hypo-shared.mjs';
36
38
 
37
39
  const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
@@ -63,22 +65,18 @@ process.stdin.on('end', () => {
63
65
  // Even at full context, minimal session-close is mandatory (spec §7.5).
64
66
  // Bypass paths are now only: HYPO_SKIP_GATE env / HYPO_SKIP_GATE in transcript.
65
67
 
66
- // ── Block 1.5: context warning (≥70%) — request session-compact before compact ──
68
+ // ── Context warning (≥70%) — advisory nudge toward session-compact, never a block ──
67
69
  if (existsSync(WARNING_FILE)) {
68
70
  try {
69
71
  unlinkSync(WARNING_FILE);
70
72
  } catch {}
71
73
  console.log(
72
74
  JSON.stringify({
73
- decision: 'block',
74
- reason: [
75
- `[WIKI CHECK — BLOCKING] Context ≥70%: run /session-compact before compacting.`,
76
- `STOP. Do NOT compact yet.`,
77
- `1. If Skill tool is available: call it with skill="session-compact" immediately.`,
78
- `2. If Skill tool is unavailable: perform the full session-close checklist from hypo-guide.md.`,
79
- `After session close completes, compact will proceed normally.`,
80
- ``,
81
- `To skip: set HYPO_SKIP_GATE=1`,
75
+ continue: true,
76
+ systemMessage: [
77
+ `[WIKI CHECK] Context ≥70%: consider running /session-compact before compacting.`,
78
+ `1. If Skill tool is available: call it with skill="session-compact".`,
79
+ `2. If Skill tool is unavailable: perform the session-close checklist from hypo-guide.md.`,
82
80
  ].join('\n'),
83
81
  }),
84
82
  );
@@ -108,12 +106,23 @@ process.stdin.on('end', () => {
108
106
  // lint scope to this session's edited files; without one the scope
109
107
  // is the mandatory close files. Read-only: pure feedback drift comes back as
110
108
  // gate.driftTargets, a self-heal effect requirement we run as --write below.
109
+ // resolveGateProjectOverride (session-close-scope-boundary spec §2/§3): PreCompact
110
+ // gets no explicit --project, only the payload's sessionCwd, so this resolves
111
+ // to the one project that cwd unambiguously owns (or null, which leaves the
112
+ // gate global). Passed below as `attributionScope`, never `projectOverride`:
113
+ // that key is the check-only diagnostic's and would also narrow
114
+ // sessionCloseGlobalStatus to this one project, turning off the mine/foreign
115
+ // partition that is what actually turns a foreign project's dangling close
116
+ // into a notice instead of debt this session gets blamed for in the message.
117
+ const attributionScope = resolveGateProjectOverride(HYPO_DIR, { sessionCwd });
118
+
111
119
  let gate;
112
120
  try {
113
121
  gate = precompactGateStatus(HYPO_DIR, {
114
122
  transcriptPath,
115
123
  ...(sessionId ? { sessionId } : {}),
116
124
  ...(sessionCwd ? { sessionCwd } : {}),
125
+ ...(attributionScope ? { attributionScope } : {}),
117
126
  });
118
127
  } catch (err) {
119
128
  // Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
@@ -161,45 +170,100 @@ process.stdin.on('end', () => {
161
170
  }
162
171
  }
163
172
 
164
- // Non-blocking heads-up about pre-existing lint / out-of-scope design-history
165
- // debt in untouched files. Surfaced so it is visible but never blocks compact.
166
- // Scoped to the close-target (today-active) projects: debt under one of their
167
- // dirs stays listed by filename; debt elsewhere (other projects, shared pages,
168
- // root files) folds into a count so the same untouched-file debt does not
169
- // re-list its filenames on every compact. Non-file diagnostics (a fail-open
170
- // "lint skipped" notice carries no path) are preserved verbatim, never folded.
171
- const debtNotices = gate.notices.filter((n) => n.type === 'lint' || n.type === 'design-history');
172
- const activeSlugs = (gate.close?.projects || []).map((p) => p.project).filter(Boolean);
173
- const nonFileNotices = debtNotices.filter((n) => !n.file);
174
- const fileNotices = debtNotices.filter((n) => n.file);
175
- const inScopeNotices = fileNotices.filter((n) => isUnderProjectDirs(n.file, activeSlugs));
176
- const otherDebtCount = fileNotices.length - inScopeNotices.length;
177
- const listed = [
178
- ...nonFileNotices.map((n) => n.reason),
179
- ...new Set(inScopeNotices.map((n) => n.reason.replace(/ \([^)]*\)$/, ''))),
180
- ];
181
- let noticeText = '';
182
- if (listed.length > 0) {
183
- noticeText = `[WIKI CHECK] ${listed.length} pre-existing lint issue(s) in files this session did not touch (not blocking): ${listed
184
- .slice(0, 5)
185
- .join(', ')}${listed.length > 5 ? ', …' : ''} — clean up when convenient.`;
173
+ // Non-blocking heads-up, one line per notice TYPE (session-close-scope-boundary
174
+ // spec §4). gate.notices can carry any of seven types: git, git-sync,
175
+ // close-debt, close-cwd-unresolved, lint, design-history, feedback. Folding
176
+ // them all into one "pre-existing lint issue(s)" sentence (the old
177
+ // renderer) mislabeled git/git-sync/close-cwd-unresolved as lint debt and
178
+ // dropped close-cwd-unresolved plus most feedback notices outright: visible
179
+ // but lying about what kind of issue it was and what fixes it. A count that
180
+ // can grow unbounded (foreign git paths, close-debt projects) is capped and
181
+ // the rest folds into "+N more", mirroring the existing lint/design-history
182
+ // fold (a vault-wide lint sweep once produced 125 lines here).
183
+ const NOTICE_LIST_CAP = 5;
184
+ const foldNames = (items) =>
185
+ items.length > NOTICE_LIST_CAP
186
+ ? `${items.slice(0, NOTICE_LIST_CAP).join(', ')}, +${items.length - NOTICE_LIST_CAP} more`
187
+ : items.join(', ');
188
+ const byType = (t) => gate.notices.filter((n) => n.type === t);
189
+ const noticeLines = [];
190
+
191
+ // git: a dirty file outside this session's scope (§2b's structural demotion,
192
+ // or the trusted-transcript foreign-file demotion above it).
193
+ const foreignGit = byType('git');
194
+ if (foreignGit.length > 0) {
195
+ noticeLines.push(
196
+ `[WIKI CHECK] ${foreignGit.length} uncommitted file(s) outside this session's scope (not blocking): ${foldNames(foreignGit.map((n) => n.file || n.reason))}.`,
197
+ );
186
198
  }
187
- if (otherDebtCount > 0) {
188
- const fold = `+${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`/hypo:lint\` for the full list.`;
189
- noticeText = noticeText ? `${noticeText}\n${fold}` : `[WIKI CHECK] ${fold}`;
199
+ // git-sync / close-cwd-unresolved: single-fact notices, their own `reason`
200
+ // is already the full sentence.
201
+ for (const n of byType('git-sync')) noticeLines.push(`[WIKI CHECK] ${n.reason}.`);
202
+ // close-cwd-unresolved's own `reason` (hypo-shared.mjs) names --project /
203
+ // --log-only, crystallize CLI flags with no PreCompact-hook equivalent, so
204
+ // it is not echoed here. This session's own sentence names the one thing a
205
+ // /compact-time reader can actually do about it: nothing, this is
206
+ // best-effort and non-blocking.
207
+ for (const n of byType('close-cwd-unresolved')) {
208
+ noticeLines.push(
209
+ `[WIKI CHECK] session cwd did not resolve to a unique project (not blocking): close attribution for this session is best-effort here.`,
210
+ );
190
211
  }
191
- // A demoted close: some OTHER session left a project's close incomplete. It no
192
- // longer blocks this compact, but it must still be SEEN — a notice list that the
193
- // gate silently swallows (suppressOutput when nothing else surfaced) would turn the
194
- // demotion into a disappearance, and nobody would ever fix the dangling close
195
- // (codex design BLOCKER).
196
- const closeDebt = gate.notices.filter((n) => n.type === 'close-debt');
212
+
213
+ // close-debt: some OTHER session left a project's close incomplete. It no
214
+ // longer blocks this compact, but it must still be SEEN: a notice list that
215
+ // the gate silently swallows (suppressOutput when nothing else surfaced)
216
+ // would turn the demotion into a disappearance, and nobody would ever fix
217
+ // the dangling close (codex design BLOCKER).
218
+ const closeDebt = byType('close-debt');
197
219
  if (closeDebt.length > 0) {
198
- const line = `[WIKI CHECK] ${closeDebt.length} project(s) with an incomplete session close from another session (not blocking this compact): ${closeDebt
199
- .map((n) => n.project)
200
- .join(', ')} — each is fixed by that project's next close.`;
201
- noticeText = noticeText ? `${noticeText}\n${line}` : line;
220
+ noticeLines.push(
221
+ `[WIKI CHECK] ${closeDebt.length} project(s) with an incomplete session close from another session (not blocking this compact): ${foldNames(closeDebt.map((n) => n.project))}, each fixed by that project's next close.`,
222
+ );
202
223
  }
224
+
225
+ // lint / design-history: pre-existing debt in files this session did not
226
+ // touch. Scoped to the close-target (today-active) projects: debt under one
227
+ // of their dirs stays listed by filename; debt elsewhere (other projects,
228
+ // shared pages, root files) folds into a count so the same untouched-file
229
+ // debt does not re-list its filenames on every compact. Non-file
230
+ // diagnostics (a fail-open "lint skipped" notice carries no path) are
231
+ // preserved verbatim, never folded. lint and design-history each get their
232
+ // own line now instead of sharing one "lint issue(s)" sentence.
233
+ const activeSlugs = (gate.close?.projects || []).map((p) => p.project).filter(Boolean);
234
+ for (const type of ['lint', 'design-history']) {
235
+ const debtNotices = byType(type);
236
+ const nonFileNotices = debtNotices.filter((n) => !n.file);
237
+ const fileNotices = debtNotices.filter((n) => n.file);
238
+ const inScopeNotices = fileNotices.filter((n) => isUnderProjectDirs(n.file, activeSlugs));
239
+ const otherDebtCount = fileNotices.length - inScopeNotices.length;
240
+ const listed = [
241
+ ...nonFileNotices.map((n) => n.reason),
242
+ ...new Set(inScopeNotices.map((n) => n.reason.replace(/ \([^)]*\)$/, ''))),
243
+ ];
244
+ if (listed.length > 0) {
245
+ noticeLines.push(
246
+ `[WIKI CHECK] ${listed.length} pre-existing ${type} issue(s) in files this session did not touch (not blocking): ${foldNames(listed)}. Clean up when convenient.`,
247
+ );
248
+ }
249
+ if (otherDebtCount > 0) {
250
+ noticeLines.push(
251
+ `[WIKI CHECK] +${otherDebtCount} pre-existing ${type} issue(s) elsewhere in the vault (other projects / shared pages, not blocking); run \`/hypo:lint\` for the full list.`,
252
+ );
253
+ }
254
+ }
255
+
256
+ // feedback: side-file I/O warnings (a projection target's per-slug sidecar is
257
+ // unreadable). A drift notice is skipped ONLY when this run actually healed
258
+ // it (feedbackHealed non-empty, reported once below): when gate.ok is
259
+ // false (another blocker fired), the self-heal above never runs at all, so
260
+ // the drift itself was never reported anywhere and must not be dropped here.
261
+ for (const n of byType('feedback')) {
262
+ if (feedbackHealed && /^feedback projection drift/.test(n.reason)) continue;
263
+ noticeLines.push(`[WIKI CHECK] ${n.reason}.`);
264
+ }
265
+
266
+ let noticeText = noticeLines.join('\n');
203
267
  // Surface the self-heal so a re-synced projection is not a silent mutation of
204
268
  // the user's MEMORY.md / CLAUDE.md (transparency).
205
269
  if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
@@ -233,7 +297,7 @@ process.stdin.on('end', () => {
233
297
  return;
234
298
  }
235
299
 
236
- // ── Block ──
300
+ // ── Advisory (never blocks — spec §1) ──
237
301
  // gate.blockers already carry per-type reasons in the canonical order
238
302
  // (git, hot, close, lint, design-history, feedback) — same strings as before
239
303
  // Now sourced from the shared gate instead of inline checks.
@@ -273,18 +337,14 @@ process.stdin.on('end', () => {
273
337
 
274
338
  console.log(
275
339
  JSON.stringify({
276
- decision: 'block',
277
- reason: [
278
- `${closeIntentNote}[WIKI CHECK — BLOCKING] Session close incomplete. (${reasons.join(', ')})`,
279
- `Run the checklist below in order, then retry /compact:`,
340
+ continue: true,
341
+ systemMessage: [
342
+ `${closeIntentNote}[WIKI CHECK] Session close incomplete. (${reasons.join(', ')})`,
343
+ `Recommended before /compact — run the checklist below:`,
280
344
  ``,
281
345
  checklistText,
282
346
  ...(noticeText ? ['', noticeText] : []),
283
- ``,
284
- `Trivial session? Bypass with HYPO_SKIP_GATE=1`,
285
347
  ].join('\n'),
286
- continue: false,
287
- stopReason: `Session close incomplete: ${reasons.join(', ')}`,
288
348
  }),
289
349
  );
290
350
  });
@@ -58,8 +58,15 @@ import {
58
58
  pkgRootNullAlreadyNotified,
59
59
  markPkgRootNullNotified,
60
60
  clearPkgRootNullNotified,
61
+ UPGRADE_APPLY_EITHER,
61
62
  } from './version-check.mjs';
62
- import { snapshotBase, overwriteTargets } from './base-store.mjs';
63
+ import {
64
+ snapshotBase,
65
+ overwriteTargets,
66
+ beginObservedGeneration,
67
+ recordObserved,
68
+ hashContent,
69
+ } from './base-store.mjs';
63
70
  import { listProposals } from './proposal-store.mjs';
64
71
 
65
72
  // Privacy guard: refuse to read+inject .hypoignore-matched
@@ -76,12 +83,18 @@ import { listProposals } from './proposal-store.mjs';
76
83
  // slicing first could cut the frontmatter off and silently fail open.
77
84
  // The root hot.md is a frontmatter-less pointer table, so it reads as '' and
78
85
  // passes (shared) unchanged.
86
+ // Returns `{raw, shown}` rather than just the sliced string: the observed-set
87
+ // record must hash the FULL bytes this call just read, not a fresh re-read at
88
+ // record time, or a write that lands in the window between this read and the
89
+ // record call would be credited to this session's observation without ever
90
+ // having been shown to it. `shown` stays the maxChars-sliced display string
91
+ // every existing caller already expects.
79
92
  function readIfNotIgnored(path, maxChars, patterns) {
80
93
  if (!path) return null;
81
94
  if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
82
95
  const raw = readFileSync(path, 'utf-8');
83
96
  if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
84
- return raw.slice(0, maxChars);
97
+ return { raw, shown: raw.slice(0, maxChars) };
85
98
  }
86
99
 
87
100
  // Scoped-out is not the same as absent. Both make readIfNotIgnored return null,
@@ -257,13 +270,24 @@ function buildPkgRootDriftNotice() {
257
270
  const cache = readCache(cachePath);
258
271
  if (pkgRootDriftAlreadyNotified(cache, key)) return '';
259
272
  markPkgRootDriftNotified(cachePath, key);
273
+ // status.cached is null both for a genuinely fresh install (never ran
274
+ // /hypo:init) AND for the channel-judgment-failure guard (init/upgrade
275
+ // positively decided to leave pkgRoot unset; see scripts/init.mjs's
276
+ // resolveDurableRoot). "run /hypo:upgrade and confirm the apply step" is a
277
+ // dead end in the second case: apply skips the same write until the
278
+ // registry itself is fixed. Point at that fix directly rather than send the
279
+ // user in a loop.
280
+ const recoveryLine = status.cached
281
+ ? ' → run `/hypo:upgrade` and confirm the apply step to bring hypo-pkg.json back in sync.'
282
+ : ' → if `/hypo:upgrade` keeps leaving this unwritten, the plugin channel itself cannot be ' +
283
+ 'resolved: repair `~/.claude/plugins/installed_plugins.json` first (reinstall the plugin, or ' +
284
+ 'run `/plugin marketplace update hypomnema` then `/reload-plugins`), then re-run `/hypo:upgrade`.';
260
285
  return (
261
286
  `[Hypomnema] Package metadata drift: hypo-pkg.json still points at ` +
262
287
  `\`${status.cached || '(none)'}\`, but the code actually running resolves to ` +
263
288
  `\`${status.self}\`.\n` +
264
289
  ` Hooks already resolved the correct root for this session — this is a ` +
265
- `heads-up, not a blocker.\n` +
266
- ` → run \`/hypo:upgrade --apply\` to bring hypo-pkg.json back in sync.`
290
+ `heads-up, not a blocker.\n${recoveryLine}`
267
291
  );
268
292
  } catch {
269
293
  return '';
@@ -301,9 +325,8 @@ function buildPkgRootNullNotice() {
301
325
  `[Hypomnema] Package root unresolved: this install's hooks cannot locate ` +
302
326
  `their own package, so PreCompact's lint/feedback checks are silently ` +
303
327
  `skipped this session.\n` +
304
- ` → run \`hypomnema upgrade --apply\` to sync this install's hook copies ` +
305
- `with the current package (the \`/hypo:upgrade --apply\` slash command does ` +
306
- `the same thing, where slash commands were installed). \`/hypo:init\` will ` +
328
+ ` → run ${UPGRADE_APPLY_EITHER} to sync this install's hook copies ` +
329
+ `with the current package. \`/hypo:init\` will ` +
307
330
  `NOT fix this — it skips every hook file that already exists.\n` +
308
331
  ` → or run \`hypomnema doctor\` to see what's missing.`
309
332
  );
@@ -600,6 +623,12 @@ process.stdin.on('end', () => {
600
623
  // direct-write path.
601
624
  if (data.session_id) {
602
625
  snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(hit ? hit.proj : null));
626
+ // Bumps the observed generation on EVERY SessionStart (first run and
627
+ // resume/compact alike), before the injections below record into it. A
628
+ // resume that ends up injecting nothing (ignored, scoped out, absent)
629
+ // still bumps, which is what lets a stale observation from an earlier
630
+ // resume expire instead of staying licensed for the rest of the session.
631
+ beginObservedGeneration(HYPO_DIR, data.session_id);
603
632
  }
604
633
 
605
634
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
@@ -617,8 +646,10 @@ process.stdin.on('end', () => {
617
646
  // marker is computed on raw content (staleMarkerForPath), then prepended
618
647
  // onto the sliced display content; a no-op when there is no verify_by_date.
619
648
  const TODAY = new Date().toISOString().slice(0, 10);
620
- let hotContent = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
621
- let stateContent = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
649
+ const hotRead = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
650
+ const stateRead = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
651
+ let hotContent = hotRead ? hotRead.shown : null;
652
+ let stateContent = stateRead ? stateRead.shown : null;
622
653
  const hotMarker = staleMarkerForPath(hit.hotPath, ignorePatterns, TODAY);
623
654
  const stateMarker = staleMarkerForPath(hit.statePath, ignorePatterns, TODAY);
624
655
  if (hotContent && hotMarker) hotContent = `${hotMarker}\n${hotContent}`;
@@ -647,6 +678,41 @@ process.stdin.on('end', () => {
647
678
  ),
648
679
  ),
649
680
  );
681
+ // Observed-set record: this is the actual injection point, so this is
682
+ // where "this session was SHOWN these bytes" becomes true. Recorded
683
+ // AFTER the marker write and the console.log above (fail-open
684
+ // ordering): if this throws, the model has already been shown the
685
+ // bytes above but no observed-entry lands, so the guard just fails
686
+ // safe into a park later, rather than the reverse — an entry on disk
687
+ // claiming an observation the injection never actually emitted.
688
+ // Gated on `hotContent`/`stateContent` (the same predicate that put
689
+ // each into `parts` above), not on `hotRead`/`stateRead` alone: an
690
+ // empty-but-not-ignored file makes `hotRead` a truthy `{raw:'',
691
+ // shown:''}`, and recording against that would create an observed
692
+ // entry for bytes the model was never actually shown a line of.
693
+ // Hash `.raw` (the full file this call just read), never a fresh
694
+ // `readFileSync` here — that would credit a write landing between the
695
+ // read above and this line to an observation that never happened.
696
+ if (data.session_id) {
697
+ if (hotContent) {
698
+ recordObserved(
699
+ HYPO_DIR,
700
+ data.session_id,
701
+ join('projects', hit.proj, 'hot.md'),
702
+ hashContent(hotRead.raw),
703
+ hotRead.raw.length > HOT_CHARS,
704
+ );
705
+ }
706
+ if (stateContent) {
707
+ recordObserved(
708
+ HYPO_DIR,
709
+ data.session_id,
710
+ join('projects', hit.proj, 'session-state.md'),
711
+ hashContent(stateRead.raw),
712
+ stateRead.raw.length > STATE_CHARS,
713
+ );
714
+ }
715
+ }
650
716
  } else {
651
717
  // A snapshot that exists but is scoped to another machine must not be
652
718
  // reported as "no snapshot yet": the model would treat a resumed project
@@ -701,7 +767,8 @@ process.stdin.on('end', () => {
701
767
  return;
702
768
  }
703
769
 
704
- const globalContent = readIfNotIgnored(GLOBAL_HOT, HOT_CHARS, ignorePatterns);
770
+ const globalRead = readIfNotIgnored(GLOBAL_HOT, HOT_CHARS, ignorePatterns);
771
+ const globalContent = globalRead ? globalRead.shown : null;
705
772
  if (!globalContent) {
706
773
  // GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
707
774
  // pending notices (sync state, growth, AND the auto-project offer), which
@@ -722,6 +789,20 @@ process.stdin.on('end', () => {
722
789
  ),
723
790
  ),
724
791
  );
792
+ // Observed-set record: the MISS branch's root hot.md injection is the one
793
+ // place a project-less session observes anything at all. Recorded AFTER
794
+ // the console.log above — see the HIT branch's comment for why (fail-open
795
+ // ordering). Hashes `.raw`, not a fresh disk read, for the same reason
796
+ // given there.
797
+ if (data.session_id) {
798
+ recordObserved(
799
+ HYPO_DIR,
800
+ data.session_id,
801
+ 'hot.md',
802
+ hashContent(globalRead.raw),
803
+ globalRead.raw.length > HOT_CHARS,
804
+ );
805
+ }
725
806
  } catch (err) {
726
807
  process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
727
808
  console.log(JSON.stringify(outExtra));