hypomnema 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@
11
11
  "name": "hypo",
12
12
  "source": "./",
13
13
  "description": "LLM-native personal wiki — session-aware knowledge base for Claude Code",
14
- "version": "1.7.0",
14
+ "version": "1.7.1",
15
15
  "homepage": "https://github.com/sk-lim19f/Hypomnema"
16
16
  }
17
17
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypo",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "LLM-native personal wiki system — session-aware knowledge base for Claude Code",
5
5
  "author": {
6
6
  "name": "sk-lim19f",
package/hooks/hooks.json CHANGED
@@ -1,5 +1,16 @@
1
1
  {
2
2
  "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-close-guard.mjs",
9
+ "timeout": 10
10
+ }
11
+ ]
12
+ }
13
+ ],
3
14
  "SessionStart": [
4
15
  {
5
16
  "hooks": [
@@ -2,32 +2,109 @@
2
2
  /**
3
3
  * hypo-auto-commit.mjs — Stop hook
4
4
  *
5
- * At session end: stage all changes, commit if any, then pull+push to sync remote.
5
+ * At session end: stage this session's touched paths, commit if any, then
6
+ * pull+push to sync remote.
7
+ *
8
+ * Scoped, not whole-tree: this no longer sweeps the entire working tree. The
9
+ * scope is this session's accumulated touched-paths set (hypo-auto-stage.mjs
10
+ * writes, plus whatever the earlier Stop-chain generators, hot-rebuild and
11
+ * session-record, appended for the same session_id). No session_id means
12
+ * nothing was ever accumulated, so the scoped commit is skipped cleanly;
13
+ * never a whole-tree fallback.
14
+ *
15
+ * PEEK, don't drain, and hold ONE lock across peek+commit+clear
16
+ * (commitTouchedPaths, hypo-shared.mjs): a drain-then-requeue-on-failure
17
+ * design was tried and dropped — the requeue write is itself a fallible
18
+ * operation (lock-timeout, I/O), so a commit failure could still lose the
19
+ * scope in the narrow window between the drain and the requeue. A peek
20
+ * that released its lock before the commit, then a SEPARATE clear
21
+ * afterward, was also tried and dropped — a `recordTouchedPaths` for a
22
+ * path already in the just-peeked set could land in the window between the
23
+ * commit and the clear and be silently wiped out by it (the set only
24
+ * tracks path presence, not a version, so that write is indistinguishable
25
+ * from the one already peeked). commitTouchedPaths holds ONE per-session
26
+ * lock across the whole peek → commit → clear window, so neither loss mode
27
+ * is possible: nothing is deleted until the commit has actually succeeded,
28
+ * and no accumulate can land inside the window at all.
6
29
  */
7
30
 
8
31
  import { spawnSync } from 'child_process';
9
- import { HYPO_DIR, syncRemote, commitWikiChanges } from './hypo-shared.mjs';
32
+ import {
33
+ HYPO_DIR,
34
+ syncRemote,
35
+ commitWikiChanges,
36
+ commitTouchedPaths,
37
+ vaultCommitLockTarget,
38
+ withFileLock,
39
+ } from './hypo-shared.mjs';
10
40
 
11
41
  function hasRemote() {
12
42
  const r = spawnSync('git', ['-C', HYPO_DIR, 'remote'], { encoding: 'utf-8', timeout: 30000 });
13
43
  return (r.stdout || '').trim().length > 0;
14
44
  }
15
45
 
16
- // Stage + commit via the shared helper (same .hypoignore filter the apply path
17
- // uses). A real commit failure short-circuits before sync, exactly as
18
- // the inline logic did; "nothing to commit" is success and falls through to sync.
19
- const result = commitWikiChanges(HYPO_DIR);
20
- if (!result.committed) {
21
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
22
- process.exit(0);
46
+ // Overridable so a test can force a fast lock-timeout instead of waiting out
47
+ // the real default (mirrors crystallize.mjs's HYPO_APPEND_LOCK_TIMEOUT_MS).
48
+ const VAULT_LOCK_TIMEOUT_MS = Number(process.env.HYPO_VAULT_LOCK_TIMEOUT_MS) || 5000;
49
+
50
+ let input = {};
51
+ try {
52
+ const raw = await new Promise((r) => {
53
+ let d = '';
54
+ process.stdin.on('data', (c) => (d += c));
55
+ process.stdin.on('end', () => r(d));
56
+ });
57
+ input = JSON.parse(raw || '{}') || {};
58
+ } catch {
59
+ input = {};
23
60
  }
61
+ const sessionId = input.session_id || input.sessionId || null;
62
+
63
+ // Stage + commit + sync as one critical section, serialized against every
64
+ // other writer of this vault (the crystallize.mjs --apply-session-close path
65
+ // holds the SAME lock around its own stage+commit). Without this, two
66
+ // concurrent sessions on a shared vault could interleave `git add`/`git
67
+ // commit`/`git pull`/`git push`. This does NOT gate pushes on whole-tree
68
+ // cleanliness: a scoped commit may legitimately leave other sessions' dirty
69
+ // files behind, and a `git pull --no-rebase` failure from that residual is
70
+ // already logged via appendSyncFailure and surfaced by doctor/session-start.
71
+ // Full cross-session isolation is out of scope (it needs separate worktrees).
72
+ //
73
+ // The vault lock (shared with crystallize.mjs's apply commit) serializes
74
+ // git operations across concurrent sessions on this vault; the per-session
75
+ // touched-paths lock commitTouchedPaths takes internally is a DIFFERENT
76
+ // lock file, so the two nest without any ordering conflict (vault lock is
77
+ // always acquired first here; accumulation elsewhere only ever takes the
78
+ // per-session lock, never the vault lock).
79
+ try {
80
+ withFileLock(
81
+ vaultCommitLockTarget(HYPO_DIR),
82
+ () => {
83
+ // Peek this session's scope, run the scoped commit, and — only on
84
+ // success — clear exactly what committed, ALL under one hold of the
85
+ // per-session lock. See commitTouchedPaths's docstring for why a
86
+ // commit failure or a same-path race can't lose anything under this.
87
+ const result = commitTouchedPaths(HYPO_DIR, sessionId, (paths) =>
88
+ commitWikiChanges(HYPO_DIR, paths),
89
+ );
90
+ if (!result.committed) return;
24
91
 
25
- if (hasRemote()) {
26
- // pull/push failures must not stop the session, but they can no longer be
27
- // swallowed silently — syncRemote records each to .cache/sync-state.json and,
28
- // on a merge conflict, aborts the merge so the tree is never left half-merged
29
- // (part of the v1.4 sync hardening). session-start + doctor surface the result next session.
30
- syncRemote(HYPO_DIR);
92
+ if (hasRemote()) {
93
+ // pull/push failures must not stop the session, but they can no longer be
94
+ // swallowed silently — syncRemote records each to .cache/sync-state.json and,
95
+ // on a merge conflict, aborts the merge so the tree is never left half-merged
96
+ // (part of the v1.4 sync hardening). session-start + doctor surface the result next session.
97
+ syncRemote(HYPO_DIR);
98
+ }
99
+ },
100
+ { timeoutMs: VAULT_LOCK_TIMEOUT_MS },
101
+ );
102
+ } catch {
103
+ // Lock-timeout (or an unexpected lock error) on the OUTER vault lock: we
104
+ // never entered the critical section, so commitTouchedPaths never ran —
105
+ // the touched-paths file is untouched on disk, and the next Stop retries
106
+ // this session's commit from the same scope. Best-effort, like every
107
+ // other step in this hook.
31
108
  }
32
109
 
33
110
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
9
  import { relative } from 'path';
10
- import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
10
+ import { HYPO_DIR, loadHypoIgnore, isIgnored, recordTouchedPaths } from './hypo-shared.mjs';
11
11
  import { advanceBaseForWrite, hashContent } from './base-store.mjs';
12
12
 
13
13
  // Tools that REPLACE file bytes. The base advance below must fire only for these:
@@ -40,24 +40,34 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
40
40
  spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
41
41
  }
42
42
 
43
- // Write=proposal gate provenance: when this session's own write lands on one of
44
- // the overwrite targets it snapshotted at start, advance that target's base so
45
- // the close guard reads the change as "I wrote this", not "someone else did"
46
- // (which would fail safe into a false proposal against the session's own edit).
47
- // Self-scoping — a no-op unless the path is a tracked base key — so it runs
48
- // regardless of .hypoignore (provenance is independent of privacy). Best-effort.
49
- //
50
- // The Write tool carries its full `content`, so advance to the bytes THIS
51
- // session wrote (race-safe: a concurrent write landing between the tool and
52
- // this hook cannot be adopted as our base). Edit/MultiEdit have no full content
53
- // in the payload, so they fall back to a post-write disk read.
54
- if (WRITE_TOOLS.has(input.tool_name) && input.session_id) {
43
+ if (WRITE_TOOLS.has(input.tool_name)) {
55
44
  const rel = relative(HYPO_DIR, filePath);
56
- const known =
57
- input.tool_name === 'Write' && typeof input.tool_input?.content === 'string'
58
- ? hashContent(input.tool_input.content)
59
- : null;
60
- advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known);
45
+
46
+ // Accumulate this write into the session's scoped auto-commit
47
+ // set, keyed by session_id (no-op without one; never a shared bucket).
48
+ // hypo-auto-commit.mjs drains this at Stop instead of sweeping the whole
49
+ // working tree, so another session's concurrent writes to this vault
50
+ // never land in THIS session's commit.
51
+ recordTouchedPaths(HYPO_DIR, input.session_id, rel);
52
+
53
+ // Write=proposal gate provenance: when this session's own write lands on one of
54
+ // the overwrite targets it snapshotted at start, advance that target's base so
55
+ // the close guard reads the change as "I wrote this", not "someone else did"
56
+ // (which would fail safe into a false proposal against the session's own edit).
57
+ // Self-scoping — a no-op unless the path is a tracked base key — so it runs
58
+ // regardless of .hypoignore (provenance is independent of privacy). Best-effort.
59
+ //
60
+ // The Write tool carries its full `content`, so advance to the bytes THIS
61
+ // session wrote (race-safe: a concurrent write landing between the tool and
62
+ // this hook cannot be adopted as our base). Edit/MultiEdit have no full content
63
+ // in the payload, so they fall back to a post-write disk read.
64
+ if (input.session_id) {
65
+ const known =
66
+ input.tool_name === 'Write' && typeof input.tool_input?.content === 'string'
67
+ ? hashContent(input.tool_input.content)
68
+ : null;
69
+ advanceBaseForWrite(HYPO_DIR, input.session_id, rel, filePath, known);
70
+ }
61
71
  }
62
72
  }
63
73
 
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-close-guard.mjs — PreToolUse hook
4
+ *
5
+ * SCOPE: this guard closes the direct Write/Edit/MultiEdit bypass only — it is
6
+ * not a general unauthorized-close catcher. A write executed via Bash (shell
7
+ * redirection, sed, a script) never reaches PreToolUse's tool_input inspection
8
+ * and is out of scope. The regular `/hypo:crystallize --apply-session-close`
9
+ * path runs via Bash and already validates the transcript's close signal
10
+ * before it writes, so it neither trips this guard nor needs to.
11
+ *
12
+ * Intercepts a Write/Edit/MultiEdit BEFORE it lands, when it targets one of the
13
+ * two close-artifact files (session-state.md, hot.md). doctor's
14
+ * detectSessionCloseArtifact (hypo-shared.mjs, post-hoc) only ever sees a file
15
+ * AFTER the write, and only fires on 마감/종료 vocabulary — a wordless full
16
+ * rewrite (the 2026-07-28 hot.md incident) reads clean to it, because there is
17
+ * no prior version to diff against.
18
+ *
19
+ * Here there is no such blind spot: the PRIMARY signal is structural, not
20
+ * lexical. recordTouchedPaths (populated by hypo-auto-stage's PostToolUse,
21
+ * which has already run for every earlier write this session) already tracks
22
+ * which close-artifact file(s) this session wrote. If the write in front of us
23
+ * targets one of a project's pair (projects/<slug>/session-state.md,
24
+ * projects/<slug>/hot.md) and the OTHER one is already in that set, this
25
+ * session is rewriting both — regardless of what either file's text says. The
26
+ * pair is scoped to the SAME project directory on purpose: pairing by basename
27
+ * alone would fire on the root hot.md (which every session's Stop-chain
28
+ * hypo-hot-rebuild.mjs legitimately rewrites) against an unrelated project's
29
+ * session-state.md — a false positive, not a close. Root hot.md is therefore
30
+ * never a structural pair member; it can still trip the lexical signal below
31
+ * if it is literally rewritten with 마감/종료 wording.
32
+ *
33
+ * KNOWN WINDOW: the structural signal is only alive for one turn. Stop's
34
+ * auto-commit chain (hypo-auto-commit.mjs → commitTouchedPaths) commits and
35
+ * then CLEARS a session's touched-paths set every time Stop runs. Write
36
+ * session-state.md in turn 1 and hot.md in turn 2 (Stop runs in between) and
37
+ * the touched-paths file no longer has the first path — structuralHit reads
38
+ * false. This is accepted, not fixed: a real close writes both files in the
39
+ * same turn (see the JSDoc coverage note in hypo-shared.mjs's
40
+ * detectSessionCloseArtifact), so the main path is still caught; a fresh
41
+ * cross-turn persistence store is out of this guard's scope. Once the window
42
+ * closes, only the lexical signal (detectSessionCloseArtifact on the write's
43
+ * own new text) can still catch a close. See the test that pins this window
44
+ * (using the real commitTouchedPaths path, not a bare drain) in
45
+ * tests/close-hooks-gate.test.mjs.
46
+ *
47
+ * CASE FOLDING: the basename gate and the structural pairing comparison below
48
+ * are lowercase-folded. macOS's default volume is case-insensitive, so a write
49
+ * to `projects/foo/HOT.md` targets the same file `hot.md` does, and comparing
50
+ * basenames verbatim would read it as "not a close-artifact file" and skip the
51
+ * structural check entirely. This folding covers only OUR OWN comparisons;
52
+ * detectSessionCloseArtifact (hypo-shared.mjs, untouched here) does its own
53
+ * case-sensitive basename check internally, so a case-varied write can still
54
+ * dodge the LEXICAL signal — but the structural signal does not depend on file
55
+ * content at all, so it still catches it.
56
+ *
57
+ * detectSessionCloseArtifact runs as a SECONDARY trigger regardless of the
58
+ * structural outcome, so a lone wordy close is caught before its pair even
59
+ * lands, and the two defenses share one definition of "close" instead of
60
+ * drifting apart.
61
+ *
62
+ * UNDECIDABLE vs BROKEN: the structural signal reads the session's
63
+ * touched-paths cache directly (readTouchedPathsOrUndecidable below), not via
64
+ * hypo-shared's peekTouchedPaths, because peekTouchedPaths collapses a lock
65
+ * timeout, a corrupt cache file, AND a genuinely-empty session into the exact
66
+ * same `[]` — indistinguishable from the caller's side. Folding all three into
67
+ * "no structural signal, allow" would let a wordless close slip through
68
+ * exactly when this guard's own bookkeeping is unreliable, which is the worst
69
+ * moment for it to go quiet. So an undecidable read (lock timeout / corrupt
70
+ * cache / no session_id) is instead treated as a HIT — it folds into the same
71
+ * `ask` branch as a genuine structural match. This is deliberately distinct
72
+ * from the hook ITSELF breaking (unparseable stdin, an unexpected exception):
73
+ * that still exits silently below, because a broken guard must never block
74
+ * the user's actual work. readTouchedPathsOrUndecidable is built from the same
75
+ * exported primitives peekTouchedPaths itself uses (withFileLock,
76
+ * touchedPathsPath) — no new read-only API was added to hypo-shared.mjs for
77
+ * this.
78
+ *
79
+ * NO EXPLICIT ALLOW: `permissionDecision: "allow"` is not "stay quiet" — it
80
+ * tells Claude Code to bypass the user's NORMAL permission prompt for this
81
+ * tool call outright. This hook has no matcher (see NO MATCHER below), so it
82
+ * runs in front of every tool call, Bash included; printing an explicit allow
83
+ * anywhere would auto-approve permission prompts this guard has no business
84
+ * touching, which is the opposite of what a "confirm before a close" guard is
85
+ * for. So every pass-through path below prints NOTHING and exits 0, leaving
86
+ * Claude Code's normal permission policy exactly as it was. Only a genuine
87
+ * `ask` hit ever writes to stdout.
88
+ *
89
+ * A hit is never a deny. The hook only ASKS
90
+ * (hookSpecificOutput.permissionDecision = "ask") — the harness turns that into
91
+ * a confirmation in front of the write; approval stays with the human.
92
+ *
93
+ * NO MATCHER: this hook is registered under PreToolUse with no matcher (this
94
+ * repo's installer does not carry matchers through to settings — see
95
+ * scripts/init.mjs's `_extractFileNames` — and no other hook here uses one
96
+ * either), so it runs on every tool call. The early-return order below exists
97
+ * for exactly that: a non-write tool, or a write outside HYPO_DIR, returns
98
+ * before anything else runs.
99
+ */
100
+
101
+ import { existsSync, readFileSync } from 'fs';
102
+ import { relative } from 'path';
103
+ import {
104
+ HYPO_DIR,
105
+ detectSessionCloseArtifact,
106
+ hasUserCloseSignal,
107
+ isGateSkipped,
108
+ touchedPathsPath,
109
+ withFileLock,
110
+ } from './hypo-shared.mjs';
111
+
112
+ const CLOSE_ARTIFACT_BASENAMES = new Set(['session-state.md', 'hot.md']);
113
+ // Mirrors hypo-auto-stage.mjs's WRITE_TOOLS: the tools that replace file bytes.
114
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
115
+
116
+ // The write's own new text. Write carries the whole file; Edit/MultiEdit carry
117
+ // only the replaced snippet(s) — good enough for detectSessionCloseArtifact,
118
+ // which matches a single bold heading line, not the whole document.
119
+ function newTextOf(toolName, toolInput) {
120
+ if (toolName === 'Write') {
121
+ return typeof toolInput?.content === 'string' ? toolInput.content : '';
122
+ }
123
+ if (toolName === 'Edit') {
124
+ return typeof toolInput?.new_string === 'string' ? toolInput.new_string : '';
125
+ }
126
+ if (toolName === 'MultiEdit' && Array.isArray(toolInput?.edits)) {
127
+ return toolInput.edits
128
+ .map((e) => (typeof e?.new_string === 'string' ? e.new_string : ''))
129
+ .join('\n');
130
+ }
131
+ return '';
132
+ }
133
+
134
+ // See "UNDECIDABLE vs BROKEN" above. `{ok: true, paths}` on a clean read
135
+ // (including a genuinely absent file — never touched this session, not an
136
+ // error); `{ok: false}` when the read cannot be trusted (no session_id, a
137
+ // corrupt/non-array cache file, or a lock timeout).
138
+ function readTouchedPathsOrUndecidable(hypoDir, sessionId) {
139
+ if (!sessionId) return { ok: false };
140
+ const path = touchedPathsPath(hypoDir, sessionId);
141
+ try {
142
+ return withFileLock(path, () => {
143
+ if (!existsSync(path)) return { ok: true, paths: [] };
144
+ try {
145
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
146
+ if (!Array.isArray(parsed)) return { ok: false }; // corrupt shape
147
+ return { ok: true, paths: parsed.filter((p) => typeof p === 'string' && p) };
148
+ } catch {
149
+ return { ok: false }; // corrupt/unreadable JSON
150
+ }
151
+ });
152
+ } catch {
153
+ return { ok: false }; // lock timeout
154
+ }
155
+ }
156
+
157
+ let input = {};
158
+ try {
159
+ const raw = await new Promise((r) => {
160
+ let d = '';
161
+ process.stdin.on('data', (c) => (d += c));
162
+ process.stdin.on('end', () => r(d));
163
+ });
164
+ input = JSON.parse(raw);
165
+ } catch (err) {
166
+ // The hook ITSELF failed to read its own input — stay silent (see NO
167
+ // EXPLICIT ALLOW above); never write a permission decision over garbage.
168
+ process.stderr.write(`[hypo-close-guard] error: ${err?.message ?? String(err)}\n`);
169
+ process.exit(0);
170
+ }
171
+
172
+ try {
173
+ if (isGateSkipped() || !WRITE_TOOLS.has(input.tool_name)) {
174
+ process.exit(0);
175
+ }
176
+
177
+ const filePath = input.tool_input?.file_path ?? '';
178
+ if (!filePath || !(filePath === HYPO_DIR || filePath.startsWith(HYPO_DIR + '/'))) {
179
+ process.exit(0);
180
+ }
181
+
182
+ const rel = relative(HYPO_DIR, filePath);
183
+ const relParts = rel.split(/[\\/]/);
184
+ const base = relParts[relParts.length - 1];
185
+ const baseLower = base.toLowerCase();
186
+ if (!CLOSE_ARTIFACT_BASENAMES.has(baseLower)) {
187
+ process.exit(0);
188
+ }
189
+
190
+ // Structural signal (primary): the OTHER close-artifact file of the SAME
191
+ // project already written this session — projects/<slug>/session-state.md
192
+ // paired with projects/<slug>/hot.md ONLY (case-folded). A root hot.md
193
+ // (relParts.length !== 3, or not under "projects/") is never a pair member.
194
+ const otherBaseLower = baseLower === 'hot.md' ? 'session-state.md' : 'hot.md';
195
+ let structuralHit = false;
196
+ let structuralUndecidable = false;
197
+ if (relParts.length === 3 && relParts[0].toLowerCase() === 'projects') {
198
+ const otherPathLower = `${relParts[0].toLowerCase()}/${relParts[1].toLowerCase()}/${otherBaseLower}`;
199
+ const touchedResult = readTouchedPathsOrUndecidable(HYPO_DIR, input.session_id);
200
+ if (!touchedResult.ok) {
201
+ structuralUndecidable = true; // see "UNDECIDABLE vs BROKEN" above
202
+ } else {
203
+ structuralHit = touchedResult.paths.some((p) => p.toLowerCase() === otherPathLower);
204
+ }
205
+ }
206
+
207
+ // Lexical signal (secondary): same predicate doctor uses post-hoc, run here
208
+ // on the write's own new text.
209
+ const lexicalHit = detectSessionCloseArtifact({
210
+ path: filePath,
211
+ content: newTextOf(input.tool_name, input.tool_input),
212
+ }).matched;
213
+
214
+ if (!structuralHit && !structuralUndecidable && !lexicalHit) {
215
+ process.exit(0);
216
+ }
217
+
218
+ if (hasUserCloseSignal(input.transcript_path ?? null)) {
219
+ process.exit(0);
220
+ }
221
+
222
+ const why = structuralUndecidable
223
+ ? `whether session-state.md and hot.md are both being rewritten this session could not be determined (touched-paths cache unreadable or no session_id)`
224
+ : structuralHit
225
+ ? `both session-state.md and hot.md are being rewritten this session`
226
+ : `this write reads as a close announcement (마감/종료 wording)`;
227
+
228
+ console.log(
229
+ JSON.stringify({
230
+ continue: true,
231
+ hookSpecificOutput: {
232
+ hookEventName: 'PreToolUse',
233
+ permissionDecision: 'ask',
234
+ permissionDecisionReason:
235
+ `[WIKI CLOSE GUARD] ${rel} — ${why}, but no user close signal was seen ` +
236
+ `in this session. Confirm with the user before writing: did they actually ` +
237
+ `ask to close the session?\n` +
238
+ `To bypass: set HYPO_SKIP_GATE=1`,
239
+ },
240
+ }),
241
+ );
242
+ } catch (err) {
243
+ // The hook ITSELF broke (unexpected exception) — stay silent, same as the
244
+ // stdin-parse failure above: a broken guard must never block real work.
245
+ process.stderr.write(`[hypo-close-guard] error: ${err?.message ?? String(err)}\n`);
246
+ }
@@ -17,11 +17,31 @@ import {
17
17
  computeSessionGrowth,
18
18
  formatGrowthMetrics,
19
19
  deriveRootLogEntries,
20
+ recordTouchedPaths,
20
21
  } from './hypo-shared.mjs';
21
22
 
22
23
  const HOT_PATH = join(HYPO_DIR, 'hot.md');
23
24
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
24
25
 
26
+ // This Stop hook runs BEFORE hypo-auto-commit and can write hot.md
27
+ // (rebuild) and log.md (deriveRootLogEntries), both hook-generated, not user
28
+ // Write/Edit, so hypo-auto-stage never sees them. Read session_id off stdin so
29
+ // whatever this hook writes still lands in the scoped commit's set; without
30
+ // this, a scope built from Write/Edit alone would silently drop these files
31
+ // from every session's auto-commit.
32
+ let sessionId = null;
33
+ try {
34
+ const raw = await new Promise((r) => {
35
+ let d = '';
36
+ process.stdin.on('data', (c) => (d += c));
37
+ process.stdin.on('end', () => r(d));
38
+ });
39
+ const payload = JSON.parse(raw || '{}') || {};
40
+ sessionId = payload.session_id || payload.sessionId || null;
41
+ } catch {
42
+ sessionId = null;
43
+ }
44
+
25
45
  function parseFrontmatter(content) {
26
46
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
27
47
  if (!m) return {};
@@ -52,12 +72,13 @@ function parsePointerRows(content) {
52
72
  return rows;
53
73
  }
54
74
 
75
+ /** @returns {boolean} true when hot.md was actually rewritten. */
55
76
  function rebuild() {
56
- if (!existsSync(HOT_PATH)) return;
77
+ if (!existsSync(HOT_PATH)) return false;
57
78
 
58
79
  const current = readFileSync(HOT_PATH, 'utf-8');
59
80
  const rows = parsePointerRows(current);
60
- if (rows.length === 0) return;
81
+ if (rows.length === 0) return false;
61
82
 
62
83
  const today = new Date().toISOString().slice(0, 10);
63
84
 
@@ -93,7 +114,11 @@ ${tableRows}
93
114
  3. Read \`projects/<name>/hot.md\` for project background
94
115
  `;
95
116
 
96
- if (canonical !== current) writeFileSync(HOT_PATH, canonical);
117
+ if (canonical !== current) {
118
+ writeFileSync(HOT_PATH, canonical);
119
+ return true;
120
+ }
121
+ return false;
97
122
  }
98
123
 
99
124
  function emitGrowth() {
@@ -107,16 +132,18 @@ function emitGrowth() {
107
132
  } catch {}
108
133
  }
109
134
 
135
+ let hotWritten = false;
110
136
  try {
111
- rebuild();
137
+ hotWritten = rebuild();
112
138
  } catch (err) {
113
139
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
114
140
  }
115
141
  // Auto-derive the root log.md session entry from each project's session-log
116
142
  // heading (runs AFTER rebuild() so root hot.md is already fresh and isn't itself
117
143
  // counted as the project's open gate problem). Best-effort: own try/catch.
144
+ let logEntriesAdded = 0;
118
145
  try {
119
- deriveRootLogEntries(HYPO_DIR);
146
+ logEntriesAdded = deriveRootLogEntries(HYPO_DIR);
120
147
  } catch (err) {
121
148
  process.stderr.write(`[hypo-hot-rebuild] log-derive error: ${err?.message ?? String(err)}\n`);
122
149
  }
@@ -126,6 +153,17 @@ try {
126
153
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
127
154
  }
128
155
 
156
+ // Feed this hook's own writes into the session's scoped auto-commit
157
+ // set (see the sessionId comment above). No-op without a session_id.
158
+ try {
159
+ const touched = [];
160
+ if (hotWritten) touched.push('hot.md');
161
+ if (logEntriesAdded > 0) touched.push('log.md');
162
+ if (touched.length > 0) recordTouchedPaths(HYPO_DIR, sessionId, touched);
163
+ } catch (err) {
164
+ process.stderr.write(`[hypo-hot-rebuild] touched-paths error: ${err?.message ?? String(err)}\n`);
165
+ }
166
+
129
167
  try {
130
168
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
131
169
  } catch {}