hypomnema 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-auto-commit.mjs — Stop hook
3
+ * hypo-auto-commit.mjs: Stop chain stage 3 (spawned by hypo-stop.mjs)
4
4
  *
5
5
  * At session end: stage this session's touched paths, commit if any, then
6
6
  * pull+push to sync remote.
@@ -31,11 +31,15 @@
31
31
  import { spawnSync } from 'child_process';
32
32
  import {
33
33
  HYPO_DIR,
34
- syncRemote,
34
+ pullRemote,
35
+ pushRemote,
36
+ resolvePushTarget,
35
37
  commitWikiChanges,
36
38
  commitTouchedPaths,
37
39
  vaultCommitLockTarget,
38
40
  withFileLock,
41
+ rootHotProjectionIsCurrent,
42
+ writeRootHotHealthNotice,
39
43
  } from './hypo-shared.mjs';
40
44
 
41
45
  function hasRemote() {
@@ -76,6 +80,27 @@ const sessionId = input.session_id || input.sessionId || null;
76
80
  // lock file, so the two nest without any ordering conflict (vault lock is
77
81
  // always acquired first here; accumulation elsewhere only ever takes the
78
82
  // per-session lock, never the vault lock).
83
+ //
84
+ // The PUSH is deliberately not in here (review r5-w4 major 2). `git pull
85
+ // --no-rebase` rewrites working-tree files, so it stays under the lock with
86
+ // the commit. `git push` changes nothing locally, and it is a network round
87
+ // trip with a 30s spawn timeout: holding the vault lock across it let one
88
+ // session's Stop own the vault for up to a minute, while a sibling
89
+ // SessionStart needs that same lock TWICE (its own `git pull` and the root
90
+ // hot.md projection write) at 5s each, out of a 30s hook budget. The pushable
91
+ // decision is made inside the lock and acted on after it is released.
92
+ //
93
+ // "The push changes nothing locally" was the whole justification for that
94
+ // split, and it was only half true (codex major). What the push sends is
95
+ // decided at push time from HEAD, so a sibling session that commits in the
96
+ // window between the unlock and the push moves HEAD, and a bare `git push`
97
+ // sends THEIR commit instead of the one this hook just made and verified.
98
+ // So the target is pinned inside the lock, not just the decision to push:
99
+ // `pushTarget` names one commit object and one fully-qualified remote ref, and
100
+ // pushRemote sends exactly that or declines. See pushRemote's own comment for
101
+ // the half this does NOT close: a client-side pre-push hook still runs
102
+ // arbitrary code, from outside the lock, on this working tree.
103
+ let pushTarget = null;
79
104
  try {
80
105
  withFileLock(
81
106
  vaultCommitLockTarget(HYPO_DIR),
@@ -84,17 +109,55 @@ try {
84
109
  // success — clear exactly what committed, ALL under one hold of the
85
110
  // per-session lock. See commitTouchedPaths's docstring for why a
86
111
  // 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
- );
112
+ const result = commitTouchedPaths(HYPO_DIR, sessionId, (paths) => {
113
+ // BLOCKER fix (r5-w1.md): 'hot.md' being IN this session's
114
+ // touched-paths set only ever proves this session once intended to
115
+ // write it, never that the bytes on disk right now are what it
116
+ // actually wrote: a scan error at SessionStart, a lock-timeout on
117
+ // the post-write record, or a claim simply outliving a later
118
+ // mid-session edit can all leave the claim standing over content
119
+ // this session never produced. Re-verify at this last possible
120
+ // moment, right before staging, instead of trying to revoke the
121
+ // claim on every branch that can go wrong upstream: a mismatch means
122
+ // whatever is on disk did not come from this session's own write, so
123
+ // it is dropped from THIS commit rather than swept in. It is not
124
+ // lost: the next SessionStart/Stop that manages a successful scan
125
+ // backs it up through the same ownership check before overwriting.
126
+ // n1 fix: sessionId, not just HYPO_DIR, since a sibling session's write
127
+ // updates the global ownership hash too, so this session's own
128
+ // receipt must independently confirm it produced the current bytes.
129
+ const scoped = rootHotProjectionIsCurrent(HYPO_DIR, sessionId)
130
+ ? paths
131
+ : paths.filter((p) => p !== 'hot.md');
132
+ if (scoped.length !== paths.length) {
133
+ writeRootHotHealthNotice(
134
+ HYPO_DIR,
135
+ '루트 hot.md이 이번 세션이 실제로 쓴 내용과 달라 이번 커밋에서 제외했습니다. 다음 세션 시작/종료 시 다시 확인됩니다.',
136
+ );
137
+ }
138
+ return commitWikiChanges(HYPO_DIR, scoped);
139
+ });
90
140
  if (!result.committed) return;
91
141
 
92
142
  if (hasRemote()) {
93
143
  // 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);
144
+ // swallowed silently: pullRemote/pushRemote record each to
145
+ // .cache/sync-state.json and, on a merge conflict, the pull aborts the merge
146
+ // so the tree is never left half-merged (part of the v1.4 sync hardening).
147
+ // session-start + doctor surface the result next session.
148
+ //
149
+ // A conflicting pull leaves this branch diverged, so the push is
150
+ // suppressed rather than deferred: the same ordering guarantee
151
+ // syncRemote has always had, now carried across the lock boundary by
152
+ // this target instead of by a `return`.
153
+ //
154
+ // Resolved AFTER the pull, not from `result.sha`: a clean
155
+ // `git pull --no-rebase` can land a merge commit on top of ours, and
156
+ // pushing the pre-merge sha would be rejected as a non-fast-forward.
157
+ // HEAD here is still under the lock, so it is this session's commit
158
+ // (plus whatever the pull brought down) and nothing a sibling added
159
+ // after the unlock.
160
+ if (!pullRemote(HYPO_DIR).conflict) pushTarget = resolvePushTarget(HYPO_DIR);
98
161
  }
99
162
  },
100
163
  { timeoutMs: VAULT_LOCK_TIMEOUT_MS },
@@ -104,7 +167,14 @@ try {
104
167
  // never entered the critical section, so commitTouchedPaths never ran —
105
168
  // the touched-paths file is untouched on disk, and the next Stop retries
106
169
  // this session's commit from the same scope. Best-effort, like every
107
- // other step in this hook.
170
+ // other step in this hook. `pushTarget` is still null here, so a lock
171
+ // we never took can never turn into a push.
108
172
  }
109
173
 
174
+ // Outside the lock on purpose: see the note above the try block. Guarded by a
175
+ // target resolved only after a successful commit and a non-conflicting pull,
176
+ // so the set of cases that push is exactly the set that pushed before this
177
+ // split, and what each one sends is fixed before the lock goes.
178
+ if (pushTarget) pushRemote(HYPO_DIR, pushTarget);
179
+
110
180
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-auto-minimal-crystallize.mjs — Stop hook (Layer 3)
3
+ * hypo-auto-minimal-crystallize.mjs: Stop chain stage 4, last (Layer 3)
4
+ *
5
+ * Spawned by hypo-stop.mjs, which is the only Stop registration. This stage runs
6
+ * last on purpose: it is the only one that can emit `decision: "block"`, and its
7
+ * close gate reads the committed state stage 3 (hypo-auto-commit) produced.
4
8
  *
5
9
  * Last hook in the Stop chain: a final-line defense that blocks `Stop` when
6
10
  * the current session did substantial work (mutation, or a high-volume
@@ -17,8 +21,19 @@
17
21
  * calls (Read/Grep/Glob/Bash) — 6a, so read-only review/debug sessions
18
22
  * are also nudged to close. Pure Q&A / incidental lookups still skip.
19
23
  * 4. no recent user close-intent → continue (close-intent gate, see below)
20
- * 5. readSessionClosedMarker(session_id) valid
21
- * → continue (close already verified)
24
+ * 5. close verdict for session_id, closeCheckpointState (close-receipt.mjs):
25
+ * a. closed: valid receipt + a marker naming its generation
26
+ * → continue (cwd/log-only exemption unchanged)
27
+ * b. legacy-closed: no receipt, marker with NO receiptGeneration
28
+ * → continue (marker's own 7-day TTL)
29
+ * c. broken: receipt and marker disagree (a marker promising a missing,
30
+ * invalid or different receipt, or a valid receipt with no marker or
31
+ * a mismatched one) → fall through to block, naming why
32
+ * d. open: neither → fall through to block
33
+ * A valid receipt only certifies the files it names (the checkpoint
34
+ * contract), so branch (a) also fires an unresolved-changes
35
+ * systemMessage once per receipt generation (see notifyUnresolved).
36
+ * This is never a block, only a notice riding along on the continue reply.
22
37
  * 6. otherwise → decision:block
23
38
  *
24
39
  * Close-intent gate (added after PR-C dogfooding revealed every-turn block —
@@ -55,13 +70,12 @@
55
70
  * mode the per-session marker was introduced to prevent.
56
71
  */
57
72
 
58
- import { existsSync } from 'fs';
59
- import { join } from 'path';
73
+ import { existsSync, readFileSync } from 'fs';
74
+ import { join, dirname } from 'path';
60
75
  import {
61
76
  HYPO_DIR,
62
77
  PKG_ROOT,
63
78
  isSubstantialSession,
64
- readSessionClosedMarker,
65
79
  extractUserMessages,
66
80
  isClosePattern,
67
81
  isGateSkipped,
@@ -70,10 +84,72 @@ import {
70
84
  isCloseReconfirmDeclined,
71
85
  CLOSE_RECONFIRM_MARK,
72
86
  resolveGateProjectOverride,
87
+ gitDirtyFiles,
73
88
  } from './hypo-shared.mjs';
89
+ import { closeCheckpointState, isCloseComplete, receiptPath } from './close-receipt.mjs';
90
+ import { atomicWrite } from './atomic-write.mjs';
91
+
92
+ // Carrying a systemMessage does not turn this into a block: the session is
93
+ // still free to end, the message just rides along on the same continue reply
94
+ // (hooks/hypo-stop.mjs merges every stage's systemMessage into one).
95
+ function emitContinue(systemMessage) {
96
+ const out = { continue: true, suppressOutput: !systemMessage };
97
+ if (systemMessage) out.systemMessage = systemMessage;
98
+ console.log(JSON.stringify(out));
99
+ }
74
100
 
75
- function emitContinue() {
76
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
101
+ // The checkpoint contract: a valid receipt proves only the files it names were
102
+ // committed at the receipt's commit, never that the rest of the session's work
103
+ // was saved, and never that a named file still has those bytes. Every dirty
104
+ // path is unresolved: a path outside the receipt's entries was never certified,
105
+ // and a path inside them differs from HEAD now, so the bytes on disk are
106
+ // committed nowhere (the receipt's commit is an ancestor of HEAD). The second
107
+ // kind is listed apart as changed after the close checkpoint. The notice fires
108
+ // once per receipt generation, or again once the dirty list itself changes,
109
+ // never on every Stop turn a long session produces. State lives next to the
110
+ // receipt it is keyed to (`.cache/sessions/<sid>/`), so an invalidated or
111
+ // replaced receipt (a fresh close attempt) starts this notice fresh too.
112
+ function notifyUnresolved(hypoDir, sessionId, receipt) {
113
+ const certified = new Set((receipt.entries || []).map((e) => e && e.path));
114
+ const dirty = gitDirtyFiles(hypoDir).sort();
115
+ if (dirty.length === 0) return null;
116
+ const rp = receiptPath(hypoDir, sessionId);
117
+ if (!rp) return null;
118
+ const notifyPath = join(dirname(rp), 'unresolved-notified.json');
119
+ let prior = null;
120
+ try {
121
+ prior = JSON.parse(readFileSync(notifyPath, 'utf-8'));
122
+ } catch {
123
+ prior = null;
124
+ }
125
+ const sameList =
126
+ prior &&
127
+ prior.generation === receipt.generation &&
128
+ Array.isArray(prior.files) &&
129
+ prior.files.length === dirty.length &&
130
+ prior.files.every((f, i) => f === dirty[i]);
131
+ if (sameList) return null;
132
+ try {
133
+ atomicWrite(notifyPath, JSON.stringify({ generation: receipt.generation, files: dirty }));
134
+ } catch {
135
+ // Best-effort: a failed write only means this same notice repeats next
136
+ // turn, never that it silently stops appearing.
137
+ }
138
+ const uncertified = dirty.filter((f) => !certified.has(f));
139
+ const changedAfter = dirty.filter((f) => certified.has(f));
140
+ const parts = [];
141
+ if (uncertified.length > 0) parts.push(uncertified.join(', '));
142
+ if (changedAfter.length > 0) {
143
+ // No ownership test here: a shared append target such as log.md is in
144
+ // nearly every receipt, and another session's append makes it dirty too.
145
+ parts.push(
146
+ `[close 체크포인트 뒤 바뀜(이 세션 또는 다른 세션), 지금 내용은 커밋되지 않음: ${changedAfter.join(', ')}]`,
147
+ );
148
+ }
149
+ return (
150
+ `[WIKI_AUTOCLOSE] close checkpoint 확인됨 (session_id=${sessionId}). 다만 이 체크포인트가 ` +
151
+ `증명하지 않는 미해결 변경이 있습니다: ${parts.join(' ')}`
152
+ );
77
153
  }
78
154
 
79
155
  function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
@@ -92,6 +168,12 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
92
168
  // label can't drift out of sync silently. The decline label ("아직, 계속")
93
169
  // stays a literal here — it is matched by the tolerant DECLINE regex in
94
170
  // isCloseReconfirmDeclined, not by an exact-mark check.
171
+ // A broken checkpoint (receipt and marker disagree) is not "never closed":
172
+ // say so, whichever wording the block takes, so the rerun is understood as
173
+ // re-issuing both artifacts rather than as a first close.
174
+ const brokenNote = opts.brokenReason
175
+ ? ` 이전 close 체크포인트가 깨져 있어 완료로 보지 않았습니다: ${opts.brokenReason}. close 를 다시 실행하면 영수증과 마커를 함께 새로 씁니다. --mark-session-closed 가 prior-checkpoint-rewritten 으로 거부되면 이전 close 가 증명한 커밋이 히스토리에서 사라진 것이니, 그 커밋을 되살리거나 사용자에게 다시 close 를 요청받아 /hypo:crystallize 로 새 close 를 적용하세요.`
176
+ : '';
95
177
  if (opts.reconfirm) {
96
178
  console.log(
97
179
  JSON.stringify({
@@ -102,7 +184,8 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
102
184
  `임의로 닫지 말고 AskUserQuestion으로 사용자에게 지금 세션을 닫을지 물어보세요. ` +
103
185
  `선택지는 "${CLOSE_RECONFIRM_MARK}"와 "아직, 계속"으로 제시합니다. 사용자가 ` +
104
186
  `"${CLOSE_RECONFIRM_MARK}"를 고른 뒤에만 세션 마무리를 진행하세요. 그전에는 ` +
105
- `마커를 쓰거나 종료 명령을 실행하지 마세요.`,
187
+ `마커를 쓰거나 종료 명령을 실행하지 마세요.` +
188
+ brokenNote,
106
189
  stopReason: 'session-close incomplete (Layer 3, reconfirm)',
107
190
  }),
108
191
  );
@@ -172,7 +255,7 @@ function emitBlock(sessionId, transcriptPath, gate = null, opts = {}) {
172
255
  console.log(
173
256
  JSON.stringify({
174
257
  decision: 'block',
175
- reason,
258
+ reason: reason + brokenNote,
176
259
  stopReason: 'session-close incomplete (Layer 3)',
177
260
  }),
178
261
  );
@@ -244,6 +327,10 @@ process.stdin.on('end', () => {
244
327
  // (a project marker whose cwd project is complete) still short-circuits without
245
328
  // paying the gate cost, unless a cwd signal makes the cwd check meaningful.
246
329
  let gate = null;
330
+ // This session's close verdict (step 5), read once and shared with the
331
+ // gate so its log-only and attribution reading uses the same marker Stop
332
+ // judged, never one the verdict rejected.
333
+ let checkpoint = null;
247
334
  const computeGate = () => {
248
335
  try {
249
336
  // resolveGateProjectOverride (session-close-scope-boundary spec §2/§3): Stop
@@ -258,6 +345,9 @@ process.stdin.on('end', () => {
258
345
  ...(transcriptPath ? { transcriptPath } : {}),
259
346
  ...(sessionCwd ? { sessionCwd } : {}),
260
347
  ...(sessionId ? { sessionId } : {}),
348
+ ...(checkpoint
349
+ ? { closeMarker: isCloseComplete(checkpoint) ? checkpoint.marker : null }
350
+ : {}),
261
351
  ...(attributionScope ? { attributionScope } : {}),
262
352
  });
263
353
  } catch {
@@ -265,28 +355,38 @@ process.stdin.on('end', () => {
265
355
  }
266
356
  };
267
357
 
268
- // 5. close already verified for this session_id — but a project marker only
269
- // attests the project(s) it recorded. If THIS session's cwd project still has
270
- // an unstarted close, honoring the marker would end the session green while
271
- // that project stays open (the session-cwd false-green). So re-check the cwd
272
- // project before accepting a project marker. A log-only marker (non-project
273
- // session) is exempt, and without a cwd signal we accept the marker as before
274
- // (back-compat with payloads that carry no cwd).
358
+ // 5. close verdict for this session_id (order in the file header), from
359
+ // closeCheckpointState, the same verdict check, doctor and PreCompact use.
360
+ // A finished close only attests the project(s)/scope it recorded: if THIS
361
+ // session's cwd project still has an unstarted close, honoring it would end
362
+ // the session green while that project stays open (the session-cwd
363
+ // false-green), so the cwd re-check below still runs. A broken checkpoint
364
+ // (receipt and marker disagree) falls through to block and says why.
365
+ let brokenReason = null;
275
366
  if (sessionId) {
276
- const marker = readSessionClosedMarker(HYPO_DIR, sessionId);
277
- if (marker) {
278
- if (marker.scope === 'log-only' || !sessionCwd) {
279
- emitContinue();
367
+ checkpoint = closeCheckpointState(HYPO_DIR, sessionId);
368
+ if (isCloseComplete(checkpoint)) {
369
+ const { receipt, marker } = checkpoint;
370
+ // A legacy close has no receipt, so no certified file list to compare
371
+ // the dirty tree against: no unresolved-changes notice, same as before.
372
+ const notice = () => (receipt ? notifyUnresolved(HYPO_DIR, sessionId, receipt) : undefined);
373
+ const isLogOnly = receipt
374
+ ? receipt.scope?.mode === 'log-only'
375
+ : marker.scope === 'log-only';
376
+ if (isLogOnly || !sessionCwd) {
377
+ emitContinue(notice());
280
378
  return;
281
379
  }
282
380
  gate = computeGate();
283
381
  const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
284
382
  if (!cwdBlocked) {
285
- emitContinue();
383
+ emitContinue(notice());
286
384
  return;
287
385
  }
288
- // marker present but the session's cwd project close is incomplete: fall
289
- // through to block, reusing the gate computed above.
386
+ // finished close, but the session's cwd project close is incomplete:
387
+ // fall through to block, reusing the gate computed above.
388
+ } else if (checkpoint.state === 'broken') {
389
+ brokenReason = checkpoint.reason;
290
390
  }
291
391
  }
292
392
 
@@ -322,7 +422,11 @@ process.stdin.on('end', () => {
322
422
  return;
323
423
  }
324
424
 
325
- emitBlock(sessionId, transcriptPath, gate, { reconfirm: workIncomplete, sessionCwd });
425
+ emitBlock(sessionId, transcriptPath, gate, {
426
+ reconfirm: workIncomplete,
427
+ sessionCwd,
428
+ brokenReason,
429
+ });
326
430
  } catch (err) {
327
431
  // Fail-open on any unexpected error.
328
432
  process.stderr.write(`[hypo-auto-minimal-crystallize] error: ${err?.message ?? String(err)}\n`);
@@ -48,7 +48,19 @@ if (filePath.startsWith(HYPO_DIR + '/') || filePath === HYPO_DIR) {
48
48
  // hypo-auto-commit.mjs drains this at Stop instead of sweeping the whole
49
49
  // working tree, so another session's concurrent writes to this vault
50
50
  // never land in THIS session's commit.
51
- recordTouchedPaths(HYPO_DIR, input.session_id, rel);
51
+ const recorded = recordTouchedPaths(HYPO_DIR, input.session_id, rel);
52
+ if (!recorded) {
53
+ // A lock-timeout or write failure here leaves the file just written
54
+ // with nothing in ANY session's touched-paths set to bring it into an
55
+ // auto-commit: it sits dirty until an unrelated later write to the
56
+ // same path happens to succeed. This hook's stdout is not suppressed
57
+ // wholesale like the Stop hooks, but its own output is a fixed
58
+ // continue/suppressOutput payload below, so stderr is the only channel
59
+ // available to surface this.
60
+ process.stderr.write(
61
+ `[hypo-auto-stage] ${rel} 를 이번 세션의 커밋 범위에 기록하지 못했습니다\n`,
62
+ );
63
+ }
52
64
 
53
65
  // Write=proposal gate provenance: when this session's own write lands on one of
54
66
  // the overwrite targets it snapshotted at start, advance that target's base so
@@ -32,7 +32,8 @@
32
32
  *
33
33
  * KNOWN WINDOW: the structural signal is only alive for one turn. Stop's
34
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
35
+ * then CLEARS a session's touched-paths set every time Stop runs (only a
36
+ * path `.hypoignore` keeps out of the commit stays). Write
36
37
  * session-state.md in turn 1 and hot.md in turn 2 (Stop runs in between) and
37
38
  * the touched-paths file no longer has the first path — structuralHit reads
38
39
  * false. This is accepted, not fixed: a real close writes both files in the
@@ -2,8 +2,12 @@
2
2
  /**
3
3
  * hypo-cwd-change.mjs — CwdChanged hook
4
4
  *
5
- * When the working directory changes mid-session, re-inject the matching
6
- * project hot.md. Skips if still within the same project subtree.
5
+ * When the working directory changes mid-session, display a notification for
6
+ * the matching project hot.md. Tracing the installed binary (Claude Code
7
+ * 2.1.276, reconfirmed on 2.1.283) shows the `systemMessage` this hook emits
8
+ * does not reach the model, only a 5-second terminal toast, so "re-inject"
9
+ * overstates what this hook can actually do. Skips if still within the same
10
+ * project subtree.
7
11
  */
8
12
 
9
13
  import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
@@ -32,8 +36,8 @@ const PROJECTS_DIR = join(HYPO_DIR, 'projects');
32
36
  const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
33
37
  const MAX_CHARS = 3000;
34
38
 
35
- // Privacy guard: a .hypoignore-matched hot.md must not be
36
- // re-emitted into additionalContext on cwd change.
39
+ // Privacy guard: a .hypoignore-matched hot.md must not be re-emitted in this
40
+ // hook's output on cwd change, whichever field carries it.
37
41
  //
38
42
  // Visibility guard: same contract as hypo-file-watch and hypo-session-start. A
39
43
  // machine-scoped hot.md stays off every machine but its owner. Scope is read
@@ -98,7 +102,7 @@ process.stdin.on('end', () => {
98
102
  const oldCwd = data.old_cwd || data.old_directory || data.previous_cwd || '';
99
103
  const sessionId = data.session_id || 'default';
100
104
 
101
- // Skip re-injection if still in the same project
105
+ // Skip re-notifying if still in the same project
102
106
  const oldHit = oldCwd ? findProjectHot(oldCwd) : null;
103
107
  const newHit = findProjectHot(newCwd);
104
108
 
@@ -109,13 +113,13 @@ process.stdin.on('end', () => {
109
113
 
110
114
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
111
115
 
112
- // This injection is not a licensing surface for the observed-set gate
113
- // (see base-store.mjs's recordObserved): a session's tracked
116
+ // Reading this hot.md is not a licensing surface for the observed-set
117
+ // gate (see base-store.mjs's recordObserved): a session's tracked
114
118
  // `targets` are fixed at whichever project the OWNING SessionStart hit,
115
119
  // and a cwd move can land here in a different project entirely, so
116
120
  // recording an observation against this read could credit a path outside
117
121
  // that fixed target set. Nothing here calls recordObserved; the guard
118
- // stays exactly as conservative for a cwd-change injection as it was
122
+ // stays exactly as conservative for a cwd-change notice as it was
119
123
  // before the observed set existed.
120
124
  if (newHit) {
121
125
  const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
@@ -125,10 +129,12 @@ process.stdin.on('end', () => {
125
129
  ? '(hot.md for this project is scoped to another machine and is not visible here)'
126
130
  : '(no hot.md yet — will be created at session close)');
127
131
  // arm the first-prompt marker so the NEXT user prompt re-triggers
128
- // hypo-first-prompt, which forces a "Resuming <project>" summary line.
129
- // Only arm when real hot content was actually injected — if hot.md is
130
- // missing or .hypoignore'd (fromFile null), there is nothing for the LLM
131
- // to summarize, so forcing "Resuming" would be empty noise.
132
+ // hypo-first-prompt, which forces Claude's reply to lead with a
133
+ // "Resuming <project>" line, verbatim rather than a content summary
134
+ // (see hypo-first-prompt.mjs's cwdMove branch). Only arm when real hot
135
+ // content was actually found; if hot.md is missing or .hypoignore'd
136
+ // (fromFile null), there is no snapshot to resume from, so claiming
137
+ // "Resuming" would be false.
132
138
  if (fromFile) {
133
139
  try {
134
140
  writeFileSync(
@@ -152,16 +158,35 @@ process.stdin.on('end', () => {
152
158
  // working_dir distinct from the vault, surface where wiki files live.
153
159
  const vaultOrientation = buildVaultOrientation(newCwd);
154
160
  const orientPrefix = vaultOrientation ? `${vaultOrientation}\n\n` : '';
155
- // Built inline rather than through buildOutput(): CwdChanged has no
156
- // documented context-injection path, so the nested hookSpecificOutput
157
- // shape buildOutput() now emits would be wrong for this event. The
158
- // follow-up that moves this hook to systemMessage removes these three
159
- // literals; until then they keep today's behaviour unchanged.
161
+ // Built inline rather than through buildOutput(): the hook reference's
162
+ // CwdChanged section documents watchPaths and systemMessage as the
163
+ // fields Claude Code reads from that event's output, and neither is an
164
+ // additionalContext path, so there is no additionalContext path here.
165
+ // CwdChanged and FileChanged each get their own section and each repeats
166
+ // this sentence, so the two are not sharing one paragraph (checked
167
+ // against the published reference 2026-09-21). The notice rides
168
+ // systemMessage instead. The reference says systemMessage "shows the
169
+ // systemMessage as a brief terminal notification" and "doesn't reach
170
+ // the SDK message stream," but it does not say whether systemMessage
171
+ // reaches the model on this event either way, so treat that as
172
+ // undocumented rather than denied. As of Claude Code 2.1.276 (checked
173
+ // 2026-09-18) and reconfirmed on 2.1.283, tracing the installed binary
174
+ // shows the only consumer of this systemMessage is the terminal
175
+ // notification queue: a low-priority toast (priority 'low', timeoutMs
176
+ // 5000, key 'env-hook') that a later same-keyed message replaces and
177
+ // that fades after five seconds, with no branch that forwards it to the
178
+ // model; outside the interactive REPL (SDK, headless) the injecting
179
+ // function is null, so the message is simply dropped. No live-session
180
+ // observation of this path exists either way. Until the documentation
181
+ // says otherwise, treat this event as not reaching the model.
182
+ // Whatever the documentation eventually says, the .hypoignore and
183
+ // visibility guards above run first, so a withheld page never reaches
184
+ // this line.
160
185
  console.log(
161
186
  JSON.stringify({
162
187
  continue: true,
163
188
  suppressOutput: true,
164
- additionalContext: `${orientPrefix}[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
189
+ systemMessage: `${orientPrefix}[WIKI: cwd changed → project=${sanitizeProjForPrompt(newHit.proj)}]\n\n${content}`,
165
190
  }),
166
191
  );
167
192
  return;
@@ -202,7 +227,7 @@ process.stdin.on('end', () => {
202
227
  JSON.stringify({
203
228
  continue: true,
204
229
  suppressOutput: true,
205
- additionalContext: suggestPrefix.trimEnd(),
230
+ systemMessage: suggestPrefix.trimEnd(),
206
231
  }),
207
232
  );
208
233
  } else {
@@ -214,7 +239,7 @@ process.stdin.on('end', () => {
214
239
  JSON.stringify({
215
240
  continue: true,
216
241
  suppressOutput: true,
217
- additionalContext: `${suggestPrefix}[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`,
242
+ systemMessage: `${suggestPrefix}[WIKI: cwd changed → no project match, global hot notice]\n\n${globalContent}`,
218
243
  }),
219
244
  );
220
245
  } catch (err) {
@@ -2,8 +2,13 @@
2
2
  /**
3
3
  * hypo-file-watch.mjs — FileChanged hook
4
4
  *
5
- * When a hot.md inside the wiki is modified externally (e.g. by a remote
6
- * agent or another Claude Code session), re-inject its contents.
5
+ * When any file under HYPO_DIR changes, build a notification of its contents.
6
+ * FileChanged fires for edits from any source: Claude's own Write/Edit/Bash
7
+ * tools as well as an external process (a remote agent, another Claude Code
8
+ * session), not only external ones. Tracing the installed binary (Claude Code
9
+ * 2.1.276, reconfirmed on 2.1.283) shows that notification is a five-second
10
+ * terminal toast that does not reach the model, so this hook does not put
11
+ * anything back in front of the model, only the terminal.
7
12
  */
8
13
 
9
14
  import { readFileSync, existsSync } from 'fs';
@@ -36,9 +41,12 @@ process.stdin.on('end', () => {
36
41
  return;
37
42
  }
38
43
 
39
- // Privacy guard: refuse to inject
40
- // .hypoignore-matched paths. Without this, `.env*` or other secrets under
41
- // HYPO_DIR are re-emitted as additionalContext to the Claude provider.
44
+ // Privacy guard: refuse to emit .hypoignore-matched paths. Without this,
45
+ // `.env*` or other secrets under HYPO_DIR are re-emitted in this hook's
46
+ // output. The guard does not depend on which field carries that output:
47
+ // whether or not the systemMessage terminal notification documented for
48
+ // FileChanged actually appears on a given machine is unmeasured, so the
49
+ // only safe assumption is that a matched path must never be put in it.
42
50
  const patterns = loadHypoIgnore(HYPO_DIR);
43
51
  if (patterns.length > 0 && isIgnored(filePath, HYPO_DIR, patterns)) {
44
52
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -65,11 +73,41 @@ process.stdin.on('end', () => {
65
73
  const content = fileRaw.slice(0, MAX_CHARS);
66
74
  const relPath = filePath.replace(HYPO_DIR + '/', '');
67
75
 
76
+ // Built inline rather than through buildOutput(): the hook reference's
77
+ // FileChanged section documents the same two fields as its CwdChanged
78
+ // section (watchPaths, systemMessage), and neither is an additionalContext
79
+ // path, so the notice rides systemMessage. The two events are documented
80
+ // separately, each repeating the sentence for itself (checked against the
81
+ // published reference 2026-09-21). The reference says systemMessage
82
+ // "shows the systemMessage as a brief terminal notification" and
83
+ // "doesn't reach the SDK message stream," but it does not say whether
84
+ // systemMessage reaches the model on this event either way. As of Claude
85
+ // Code 2.1.276 (checked 2026-09-18) and reconfirmed on 2.1.283, tracing
86
+ // the installed binary shows the same shared consumer as
87
+ // hypo-cwd-change.mjs: a low-priority terminal toast with no branch to
88
+ // the model, dropped entirely outside the interactive REPL. No
89
+ // live-session observation of this path exists either way. Until the
90
+ // documentation says otherwise, treat this event as not reaching the
91
+ // model.
92
+ // On a stock install this hook still has no registered trigger: its
93
+ // matcher is omitted (not blank), which the reference says matches every
94
+ // watched file while adding nothing to the watch list, and nothing in
95
+ // this package returns watchPaths to seed that list. The watch list is
96
+ // session-global though, not scoped to this plugin: if another hook or
97
+ // plugin seeds it, this matcher-less group fires on every watched file,
98
+ // not just wiki ones. The HYPO_DIR check above (`filePath.startsWith(HYPO_DIR
99
+ // + '/')`, a string-prefix test that does not resolve symlinks) still
100
+ // returns immediately for any path that does not start with HYPO_DIR, so
101
+ // a watch seeded elsewhere cannot point this hook at a path outside the
102
+ // wiki. A symlink living inside the wiki is a separate case: its path
103
+ // still starts with HYPO_DIR and passes the check above, but
104
+ // readFileSync follows it, so whatever that link points to, inside the
105
+ // wiki or not, is what actually gets emitted.
68
106
  console.log(
69
107
  JSON.stringify({
70
108
  continue: true,
71
109
  suppressOutput: true,
72
- additionalContext: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
110
+ systemMessage: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
73
111
  }),
74
112
  );
75
113
  } catch (err) {