hypomnema 1.6.2 → 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.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. package/scripts/pre-commit-format.mjs +0 -198
@@ -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 }));
@@ -123,10 +123,20 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
123
123
  // an install/transcript path contains spaces (display text only — never exec'd
124
124
  // here).
125
125
  const transcriptHint = transcriptPath ? ` --transcript-path="${transcriptPath}"` : '';
126
+ // Recovery command carries EVIDENCE, never the recency-derived close.project.
127
+ // The evidence-backed attribution is a singleton close scope (transcript
128
+ // close-files ∪ this marker's projects); when it is unambiguous, embed it as
129
+ // --project so the re-run is one-shot instead of failing closed for lack of
130
+ // evidence. The session cwd (from the payload) rides along so the re-run's own
131
+ // session-cwd close check runs against the same project this Stop evaluated.
132
+ const scope = gate?.close?.scope || [];
133
+ const evidenceProject = scope.length === 1 ? scope[0] : null;
134
+ const projectHint = evidenceProject ? ` --project=${evidenceProject}` : '';
135
+ const cwdHint = opts.sessionCwd ? ` --session-cwd="${opts.sessionCwd}"` : '';
126
136
  const cliBase = PKG_ROOT ? `node "${join(PKG_ROOT, 'scripts', 'crystallize.mjs')}"` : null;
127
137
  const markCmd = cliBase
128
- ? `${cliBase} --mark-session-closed --session-id=${sessionId}${transcriptHint}`
129
- : `/hypo:crystallize (session_id=${sessionId}${transcriptHint})`;
138
+ ? `${cliBase} --mark-session-closed --session-id=${sessionId}${projectHint}${transcriptHint}${cwdHint}`
139
+ : `/hypo:crystallize (session_id=${sessionId}${projectHint}${transcriptHint})`;
130
140
  // The log-only escape hatch for a non-project (wiki/tooling-only)
131
141
  // session. Offered ONLY as an explicit alternative when a close blocker is
132
142
  // present — never as the default recovery, so a real project session is not
@@ -150,7 +160,9 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
150
160
  // Only when a project-close blocker is what's holding the session: a
151
161
  // non-project session has nothing to close, so offer log-only as the way out
152
162
  // (Claude decides whether this session is project-scoped — no auto-attribution).
153
- if (gate.blockers.some((b) => b.type === 'close')) {
163
+ // A session-cwd close blocker (the session's own cwd project is unstarted) is
164
+ // the same kind of project-close hold, so it gets the same escape.
165
+ if (gate.blockers.some((b) => b.type === 'close' || b.type === 'close-cwd')) {
154
166
  reason += ` If this was a non-project (wiki/tooling-only) session with no project to close, run \`${logOnlyCmd}\` instead (log-only close, no project attribution).`;
155
167
  }
156
168
  } else {
@@ -202,6 +214,9 @@ process.stdin.on('end', () => {
202
214
 
203
215
  const sessionId = payload.session_id || payload.sessionId || null;
204
216
  const transcriptPath = payload.transcript_path || payload.transcriptPath || null;
217
+ // Authoritative session cwd (the one verified cwd source) for the session-cwd
218
+ // close check below. Absent on older Claude Code payloads → the check is skipped.
219
+ const sessionCwd = payload.cwd || null;
205
220
 
206
221
  // 3. substantial-session gate. Pure Q&A / incidental-lookup sessions skip
207
222
  // the block; mutating sessions AND high-volume read-only investigations
@@ -220,10 +235,49 @@ process.stdin.on('end', () => {
220
235
  return;
221
236
  }
222
237
 
223
- // 5. close already verified for this session_id.
224
- if (sessionId && readSessionClosedMarker(HYPO_DIR, sessionId)) {
225
- emitContinue();
226
- return;
238
+ // Read-only /compact gate (same precompactGateStatus the real PreCompact hook
239
+ // uses) sharpens the block message and, with sessionCwd, backs the session-cwd
240
+ // close check below. The hook NEVER writes the marker here (file-header
241
+ // invariant); this is read-only. Any error → null → emitBlock falls back to the
242
+ // generic message (fail-open). Computed lazily: the common closed-session path
243
+ // (a project marker whose cwd project is complete) still short-circuits without
244
+ // paying the gate cost, unless a cwd signal makes the cwd check meaningful.
245
+ let gate = null;
246
+ const computeGate = () => {
247
+ try {
248
+ return precompactGateStatus(HYPO_DIR, {
249
+ ...(transcriptPath ? { transcriptPath } : {}),
250
+ ...(sessionCwd ? { sessionCwd } : {}),
251
+ ...(sessionId ? { sessionId } : {}),
252
+ });
253
+ } catch {
254
+ return null;
255
+ }
256
+ };
257
+
258
+ // 5. close already verified for this session_id — but a project marker only
259
+ // attests the project(s) it recorded. If THIS session's cwd project still has
260
+ // an unstarted close, honoring the marker would end the session green while
261
+ // that project stays open (the session-cwd false-green). So re-check the cwd
262
+ // project before accepting a project marker. A log-only marker (non-project
263
+ // session) is exempt, and without a cwd signal we accept the marker as before
264
+ // (back-compat with payloads that carry no cwd).
265
+ if (sessionId) {
266
+ const marker = readSessionClosedMarker(HYPO_DIR, sessionId);
267
+ if (marker) {
268
+ if (marker.scope === 'log-only' || !sessionCwd) {
269
+ emitContinue();
270
+ return;
271
+ }
272
+ gate = computeGate();
273
+ const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
274
+ if (!cwdBlocked) {
275
+ emitContinue();
276
+ return;
277
+ }
278
+ // marker present but the session's cwd project close is incomplete: fall
279
+ // through to block, reusing the gate computed above.
280
+ }
227
281
  }
228
282
 
229
283
  // 6. block — but only when we have a session_id to address the recovery
@@ -234,18 +288,7 @@ process.stdin.on('end', () => {
234
288
  return;
235
289
  }
236
290
 
237
- // Read-only /compact gate (same precompactGateStatus the real
238
- // PreCompact hook uses) sharpens the block message — distinguishes "close
239
- // is compact-ready, only the marker is missing" from "there are real
240
- // blockers". The hook NEVER writes the marker here (file-header invariant);
241
- // this is read-only. Any error → null → emitBlock falls back to the generic
242
- // message (fail-open).
243
- let gate = null;
244
- try {
245
- gate = precompactGateStatus(HYPO_DIR, transcriptPath ? { transcriptPath } : {});
246
- } catch {
247
- gate = null;
248
- }
291
+ if (!gate) gate = computeGate();
249
292
 
250
293
  // Reconfirm decision (conditional-close-reconfirm): "close" and
251
294
  // "work-incomplete" together are exactly the case where the transcript's
@@ -269,7 +312,7 @@ process.stdin.on('end', () => {
269
312
  return;
270
313
  }
271
314
 
272
- emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete });
315
+ emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete, sessionCwd });
273
316
  } catch (err) {
274
317
  // Fail-open on any unexpected error.
275
318
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -6,7 +6,17 @@
6
6
  */
7
7
 
8
8
  import { spawnSync } from 'child_process';
9
- import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
9
+ import { relative } from 'path';
10
+ import { HYPO_DIR, loadHypoIgnore, isIgnored, recordTouchedPaths } from './hypo-shared.mjs';
11
+ import { advanceBaseForWrite, hashContent } from './base-store.mjs';
12
+
13
+ // Tools that REPLACE file bytes. The base advance below must fire only for these:
14
+ // this hook has no matcher in hooks.json (it runs on every PostToolUse), and a
15
+ // read-only tool like Read also carries `tool_input.file_path`. Without this
16
+ // allowlist, merely Reading a target another session had drifted would advance
17
+ // the base to that other session's bytes — silently defeating the write=proposal
18
+ // guard at close. tool_name, not file_path, is the write signal.
19
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
10
20
 
11
21
  let input = {};
12
22
  try {
@@ -29,6 +39,36 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
29
39
  if (patterns.length === 0 || !isIgnored(filePath, HYPO_DIR, patterns)) {
30
40
  spawnSync('git', ['-C', HYPO_DIR, 'add', filePath], { stdio: 'ignore' });
31
41
  }
42
+
43
+ if (WRITE_TOOLS.has(input.tool_name)) {
44
+ const rel = relative(HYPO_DIR, filePath);
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
+ }
71
+ }
32
72
  }
33
73
 
34
74
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -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
+ }
@@ -24,6 +24,9 @@ import {
24
24
  pickProjectByCwd,
25
25
  collectProjectWorkingDirs,
26
26
  buildVaultOrientation,
27
+ currentDevice,
28
+ scopeVisible,
29
+ readVisibilityScope,
27
30
  } from './hypo-shared.mjs';
28
31
 
29
32
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
@@ -32,10 +35,32 @@ const MAX_CHARS = 3000;
32
35
 
33
36
  // Privacy guard: a .hypoignore-matched hot.md must not be
34
37
  // re-emitted into additionalContext on cwd change.
38
+ //
39
+ // Visibility guard: same contract as hypo-file-watch and hypo-session-start. A
40
+ // machine-scoped hot.md stays off every machine but its owner. Scope is read
41
+ // from the RAW content before the MAX_CHARS slice, since slicing first could cut
42
+ // the frontmatter off and fail open. The root hot.md carries no frontmatter, so
43
+ // it reads as '' and passes as shared.
35
44
  function readIfNotIgnored(path, patterns) {
36
45
  if (!path) return null;
37
46
  if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
38
- return readFileSync(path, 'utf-8').slice(0, MAX_CHARS);
47
+ const raw = readFileSync(path, 'utf-8');
48
+ if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
49
+ return raw.slice(0, MAX_CHARS);
50
+ }
51
+
52
+ // Scoped-out is not absent. Both make readIfNotIgnored return null, but the
53
+ // absent-case placeholder tells the model the file "will be created at session
54
+ // close". On a foreign machine that invites it to author over a hot.md that
55
+ // exists and belongs elsewhere. Report the fact, never the withheld body.
56
+ function isScopedOut(path, patterns) {
57
+ try {
58
+ if (!path || !existsSync(path)) return false;
59
+ if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return false;
60
+ return !scopeVisible(readVisibilityScope(readFileSync(path, 'utf-8')), currentDevice());
61
+ } catch {
62
+ return false;
63
+ }
39
64
  }
40
65
 
41
66
  function findProjectHot(cwd) {
@@ -87,7 +112,11 @@ process.stdin.on('end', () => {
87
112
 
88
113
  if (newHit) {
89
114
  const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
90
- const content = fromFile ?? '(no hot.md yet — will be created at session close)';
115
+ const content =
116
+ fromFile ??
117
+ (isScopedOut(newHit.hotPath, ignorePatterns)
118
+ ? '(hot.md for this project is scoped to another machine and is not visible here)'
119
+ : '(no hot.md yet — will be created at session close)');
91
120
  // arm the first-prompt marker so the NEXT user prompt re-triggers
92
121
  // hypo-first-prompt, which forces a "Resuming <project>" summary line.
93
122
  // Only arm when real hot content was actually injected — if hot.md is
@@ -8,7 +8,14 @@
8
8
 
9
9
  import { readFileSync, existsSync } from 'fs';
10
10
  import { join } from 'path';
11
- import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
11
+ import {
12
+ HYPO_DIR,
13
+ loadHypoIgnore,
14
+ isIgnored,
15
+ currentDevice,
16
+ scopeVisible,
17
+ readVisibilityScope,
18
+ } from './hypo-shared.mjs';
12
19
 
13
20
  const MAX_CHARS = 2000;
14
21
 
@@ -43,7 +50,19 @@ process.stdin.on('end', () => {
43
50
  return;
44
51
  }
45
52
 
46
- const content = readFileSync(filePath, 'utf-8').slice(0, MAX_CHARS);
53
+ const fileRaw = readFileSync(filePath, 'utf-8');
54
+
55
+ // Visibility guard: a machine-scoped page (visibility_scope: machine:<owner>)
56
+ // must not be re-injected on a machine other than its owner. Read the scope
57
+ // from the raw, unsliced content — slicing to MAX_CHARS first could cut the
58
+ // frontmatter off and silently make every scoped page pass fail-open.
59
+ const scope = readVisibilityScope(fileRaw);
60
+ if (!scopeVisible(scope, currentDevice())) {
61
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
62
+ return;
63
+ }
64
+
65
+ const content = fileRaw.slice(0, MAX_CHARS);
47
66
  const relPath = filePath.replace(HYPO_DIR + '/', '');
48
67
 
49
68
  console.log(