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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1202 -0
- package/README.ko.md +4 -4
- package/README.md +5 -5
- package/commands/audit.md +1 -1
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +3 -3
- package/commands/doctor.md +15 -1
- package/commands/feedback.md +1 -1
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +2 -2
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +8 -3
- package/docs/CONTRIBUTING.md +2 -1
- package/hooks/base-store.mjs +198 -0
- package/hooks/close-gate-store.mjs +3 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
- package/hooks/hypo-compact-guard.mjs +5 -3
- package/hooks/hypo-cwd-change.mjs +8 -0
- package/hooks/hypo-personal-check.mjs +127 -67
- package/hooks/hypo-session-start.mjs +91 -10
- package/hooks/hypo-shared.mjs +241 -19
- package/hooks/version-check.mjs +44 -6
- package/package.json +9 -3
- package/scripts/capture.mjs +49 -17
- package/scripts/crystallize.mjs +20 -2092
- package/scripts/doctor.mjs +334 -14
- package/scripts/init.mjs +106 -81
- package/scripts/lib/crystallize-args.mjs +50 -0
- package/scripts/lib/crystallize-close-apply.mjs +1830 -0
- package/scripts/lib/crystallize-close-check.mjs +238 -0
- package/scripts/lib/crystallize-close-gate.mjs +58 -0
- package/scripts/lib/crystallize-helpers.mjs +55 -0
- package/scripts/lib/extensions.mjs +175 -49
- package/scripts/lib/git-hooks-dir.mjs +88 -3
- package/scripts/lib/plugin-detect.mjs +176 -21
- package/scripts/lint.mjs +12 -6
- package/scripts/proposal.mjs +3 -3
- package/scripts/upgrade.mjs +243 -20
- package/skills/crystallize/SKILL.md +15 -13
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +2 -2
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/hypo-automation.md +59 -17
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +12 -9
- 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
|
-
* ("
|
|
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
|
|
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
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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)
|
|
19
|
-
*
|
|
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
|
-
// ──
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
`[WIKI CHECK
|
|
76
|
-
`
|
|
77
|
-
`
|
|
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
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
];
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
.map((n) => n.project)
|
|
200
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
`${closeIntentNote}[WIKI CHECK
|
|
279
|
-
`
|
|
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 {
|
|
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
|
|
305
|
-
`with the current package
|
|
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
|
-
|
|
621
|
-
|
|
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
|
|
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));
|