hypomnema 1.10.1 → 1.10.2

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.10.1",
14
+ "version": "1.10.2",
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.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "LLM-native personal wiki system — session-aware knowledge base for Claude Code",
5
5
  "author": {
6
6
  "name": "sk-lim19f",
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to Hypomnema are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.10.2] - 2026-10-29
9
+
10
+ ### Bug Fixes
11
+ #### English
12
+ - Session close judges uncommitted files by this session's own write record, so another terminal's edit no longer blocks a close. When the record cannot be trusted (a session opened before this version, an unreadable record, a write that could not be recorded, a call without a session id) the whole project folder being closed blocks instead. A check that fails by a real error withholds the receipt and names the command to run; `hypomnema doctor` verifies receipt evidence against the commit it names; the gate runs one `git status`; a session that was already open keeps the older rule until a new session starts. ([#322](https://github.com/sk-lim19f/Hypomnema/pull/322))
13
+ #### 한국어
14
+ - 세션 close가 미커밋 파일을 이 세션의 쓰기 기록으로 판정해, 다른 터미널의 편집이 더는 close를 막지 않습니다. 기록을 믿을 수 없을 때(이 버전 전에 연 세션, 읽지 못하는 기록, 기록하지 못한 쓰기, session id 없는 호출)는 닫는 프로젝트 폴더 전체를 막습니다. 진짜 오류로 끝난 검사는 영수증을 보류하고 돌릴 명령을 안내하며, `hypomnema doctor`가 영수증 근거를 그 커밋과 대조하고, 게이트는 `git status`를 한 번만 돌립니다. 이미 열려 있던 세션은 새 세션을 열기 전까지 옛 규칙을 씁니다. ([#322](https://github.com/sk-lim19f/Hypomnema/pull/322))
15
+
16
+ ### Changelog
17
+ - [#322](https://github.com/sk-lim19f/Hypomnema/pull/322) fix(close-gate): judge uncommitted files by the session's own write record
18
+ Contributors: @sk-lim19f
19
+
8
20
  ## [1.10.1] - 2026-10-27
9
21
 
10
22
  ### Bug Fixes
package/README.ko.md CHANGED
@@ -266,7 +266,7 @@ Hypomnema는 청크가 아니라 페이지를 지식 단위로 봅니다. 새
266
266
  | `hypo-cwd-change.mjs` | `CwdChanged` | cwd에 맞는 프로젝트 `hot.md`로 알림을 만든다(아래 전송 안내 참조) |
267
267
  | `hypo-file-watch.mjs` | `FileChanged` | 바뀐 위키 파일로 알림을 만든다(`.hypoignore` 준수). 이 패키지는 감시 경로를 등록하지 않아 기본 설치에서는 이벤트가 발생할 계기 자체가 없다(아래 전송 안내 참조) |
268
268
  | `hypo-auto-stage.mjs` | `PostToolUse(Write/Edit/MultiEdit)` | 위키 파일 자동 stage |
269
- | `hypo-stop.mjs` | `Stop` | 등록된 유일한 `Stop` 훅이다. 네 단계를 병렬이 아니라 순서대로 돌린다: 세션 현황 파일 재생성(`SessionStart`도 같은 생성기를 부른다), observability 점수와 auto-resume 신호용 세션 메타데이터 기록, commit + pull + push, 그리고 사용자가 마무리를 신호한 뒤 의미 있는 작업을 했는데 마무리가 확인되지 않으면 Stop을 막고 `crystallize.mjs --mark-session-closed` 명령을 돌려준다(미커밋 변경이나 진행 중인 작업이 있으면 명령 대신 지금 닫을지 되묻는다) |
269
+ | `hypo-stop.mjs` | `Stop` | 등록된 유일한 `Stop` 훅이다. 네 단계를 병렬이 아니라 순서대로 돌린다: 세션 현황 파일 재생성(`SessionStart`도 같은 생성기를 부른다), observability 점수와 auto-resume 신호용 세션 메타데이터 기록, commit + pull + push, 그리고 사용자가 마무리를 신호한 뒤 의미 있는 작업을 했는데 마무리가 확인되지 않으면 Stop을 막고 `crystallize.mjs --mark-session-closed` 명령을 돌려준다(이 세션이 쓴 미커밋 파일이나 진행 중인 작업이 있으면 명령 대신 지금 닫을지 되묻는다. 다른 세션의 미커밋 파일로는 묻지 않는다. 업그레이드 전에 연 세션이나, 쓰기 기록이 한 번이라도 실패했거나 경로를 잃은 세션은 예외다) |
270
270
  | `hypo-personal-check.mjs` | `PreCompact` | session-close 미완, 위키 커밋/푸시 누락, hot.md 구조 위반, lint 블로커를 `systemMessage`로 알린다. `/compact`는 여기서 막지 않는다. 세션 마무리 강제는 다른 자리에 남아 있다: `hypo-stop.mjs`의 crystallize 단계가 마무리 확인 없는 의미 있는 세션을 막고, `--mark-session-closed`는 여전히 red 게이트에서 마커를 거부한다 |
271
271
  | `hypo-session-end.mjs` | `SessionEnd` | SessionEnd 마커를 기록해 다음 SessionStart가 `source=clear` 복구를 감지하게 한다 |
272
272
  | `hypo-web-fetch-ingest.mjs` | `PostToolUse(WebFetch/WebSearch)` | WebFetch/WebSearch 뒤 `additionalContext`에 `/hypo:ingest` 권유를 주입한다(URL의 query/hash/userinfo는 제거) |
@@ -426,8 +426,8 @@ E. 멈춘 프로젝트 재개.
426
426
 
427
427
  | 설치 경로 | 슬래시 커맨드 위치 |
428
428
  |---|---|
429
- | 플러그인 (Path A) | Claude Code 플러그인 캐시. `/plugin marketplace update hypomnema` 후 `/reload-plugins`로 갱신. 캐시 디렉터리 이름이 매니페스트 버전이라, 그 버전이 바뀌어야 갱신이 반영된다. 버전을 그대로 둔 채 머지한 커밋은 이미 설치된 곳에 닿지 않는다. |
430
- | npm CLI (Path B) | `~/.claude/commands/hypo/`. `hypomnema upgrade --apply`로 갱신, 파일별 SHA 추적. 사용자 수정본까지 덮어쓰려면 `--force-commands`(원본은 `.bak`으로 보존) |
429
+ | 플러그인 (Path A) | Claude Code 플러그인 캐시. `/plugin marketplace update hypomnema` 후 `/reload-plugins`로 갱신. 캐시 디렉터리 이름이 매니페스트 버전이라, 그 버전이 바뀌어야 갱신이 반영된다. 버전을 그대로 둔 채 머지한 커밋은 이미 설치된 곳에 닿지 않는다. 업데이트 전에 열려 있던 세션은 reload 뒤에도 이전 마무리 규칙(닫는 프로젝트 폴더에 미커밋 파일이 있으면 마무리를 막는다)을 그대로 쓴다. 세션마다 자기가 쓴 파일만 따지는 새 규칙은 새 세션을 시작해야 적용된다. |
430
+ | npm CLI (Path B) | `~/.claude/commands/hypo/`. `hypomnema upgrade --apply`로 갱신, 파일별 SHA 추적. 사용자 수정본까지 덮어쓰려면 `--force-commands`(원본은 `.bak`으로 보존). 업그레이드 전에 열려 있던 세션은 여기서도 이전 마무리 규칙을 그대로 쓴다. 새 세션이 받는 기록 스탬프는 설치 경로와 상관이 없다. 세션마다 자기가 쓴 파일만 따지는 새 규칙은 새 세션을 시작해야 적용된다. |
431
431
 
432
432
  ---
433
433
 
package/README.md CHANGED
@@ -262,7 +262,7 @@ Nine commands cover the full capture → retrieval → consolidation cycle.
262
262
  | `hypo-cwd-change.mjs` | `CwdChanged` | Build a notice from the matching project's `hot.md` (see the transmission note below) |
263
263
  | `hypo-file-watch.mjs` | `FileChanged` | Build a notice for a changed wiki file (honors `.hypoignore`); nothing in this package registers watch paths, so on a stock install the event has no trigger here at all (see the transmission note below) |
264
264
  | `hypo-auto-stage.mjs` | `PostToolUse(Write/Edit/MultiEdit)` | Auto-stage wiki-file edits |
265
- | `hypo-stop.mjs` | `Stop` | The only registered `Stop` hook. Runs four stages in order, not in parallel: rebuild the generated session views (`SessionStart` calls the same generator), record session metadata for the observability score and auto-resume signaling, commit + pull + push, then, after the user signals wrap-up, block `Stop` on a substantial session with no verified close and hand back the `crystallize.mjs --mark-session-closed` command (with uncommitted changes or work still in flight it asks whether to close now instead) |
265
+ | `hypo-stop.mjs` | `Stop` | The only registered `Stop` hook. Runs four stages in order, not in parallel: rebuild the generated session views (`SessionStart` calls the same generator), record session metadata for the observability score and auto-resume signaling, commit + pull + push, then, after the user signals wrap-up, block `Stop` on a substantial session with no verified close and hand back the `crystallize.mjs --mark-session-closed` command (with an uncommitted file this session wrote, or work still in flight, it asks whether to close now instead; another session's uncommitted file does not trigger that question, except in a session opened before the upgrade or one whose record of a write failed or lost paths) |
266
266
  | `hypo-personal-check.mjs` | `PreCompact` | Surfaces an unfinished session-close, an uncommitted or unpushed wiki, malformed `hot.md`, or lint blockers as a `systemMessage`; it never blocks `/compact`. Session close is still enforced elsewhere: `hypo-stop.mjs`'s crystallize stage blocks on a substantial session with no verified close, and `--mark-session-closed` still refuses the marker on a red gate |
267
267
  | `hypo-session-end.mjs` | `SessionEnd` | Write a SessionEnd marker so SessionStart can detect `source=clear` recovery |
268
268
  | `hypo-web-fetch-ingest.mjs` | `PostToolUse(WebFetch/WebSearch)` | Inject a `/hypo:ingest` nudge into `additionalContext` after a WebFetch (URL redacted of query/hash/userinfo) or a WebSearch |
@@ -426,8 +426,8 @@ Place a `hypo-config.md` at the wiki root to make it portable across machines wi
426
426
 
427
427
  | Install path | Slash commands served from |
428
428
  |---|---|
429
- | Plugin (Path A) | Claude Code's plugin cache; updated via `/plugin marketplace update hypomnema` then `/reload-plugins`. The cache directory is named after the manifest version, so an update lands only when that version has changed. A commit merged under an unchanged version does not reach an existing install. |
430
- | npm CLI (Path B) | `~/.claude/commands/hypo/`; updated via `hypomnema upgrade --apply` with per-file SHA tracking. Pass `--force-commands` to overwrite hand-edits (creates `.bak`). |
429
+ | Plugin (Path A) | Claude Code's plugin cache; updated via `/plugin marketplace update hypomnema` then `/reload-plugins`. The cache directory is named after the manifest version, so an update lands only when that version has changed. A commit merged under an unchanged version does not reach an existing install. A session that was already open before the update keeps the older close rule (an uncommitted file in the project folder being closed blocks) even after the reload; start a new session to get the rule that follows each session's own record of its writes. |
430
+ | npm CLI (Path B) | `~/.claude/commands/hypo/`; updated via `hypomnema upgrade --apply` with per-file SHA tracking. Pass `--force-commands` to overwrite hand-edits (creates `.bak`). A session that was already open before the upgrade keeps the older close rule here too, since the record stamp a new session gets does not depend on the install path; start a new session to get the rule that follows each session's own record of its writes. |
431
431
 
432
432
  ---
433
433
 
@@ -115,19 +115,24 @@ and nothing on disk, in three cases:
115
115
  A refusal is not a failure to route around. It means the close should not happen: ask
116
116
  the user, and re-run only after they say so.
117
117
 
118
- On a verified close (`ok: true`, no uncommitted file in the project folder being closed, and no uncommitted file elsewhere that this session wrote through Write or Edit since its last auto-commit)
118
+ On a verified close (`ok: true`, and no uncommitted file this session recorded writing)
119
119
  the apply first files a commit-backed close checkpoint receipt at
120
120
  `HYPO_DIR/.cache/sessions/<id>/close-receipt.json`, then writes the per-session compat marker
121
121
  `HYPO_DIR/.cache/session-closed-<id>.marker` as a projection of it. If the marker does not land,
122
122
  the receipt is withdrawn again, so a run that reports the marker as failed never leaves a valid
123
- receipt behind. The checkpoint gate reads git more narrowly than the PreCompact gate and `--check-session-close` do. An uncommitted
124
- file inside the project folder being closed (`projects/<project>/`) blocks whether or not this
125
- session has a record of writing it. An uncommitted file at the vault root or in another project's
126
- folder blocks only when this session wrote it through Write or Edit since its last auto-commit;
127
- otherwise it is reported as a notice. If the record of what this session wrote cannot be read, the
128
- gate does not block on it: it treats the session as having no record and adds a notice that it could
129
- not determine who owns those files. So the checkpoint can land while `--check-session-close` stays red on
130
- someone else's dirty file. Neither one stops `/compact`: the PreCompact hook only shows a notice. The checkpoint receipt is the thing the
123
+ receipt behind. One git check runs for every caller: this checkpoint gate, `--mark-session-closed`, `--check-session-close`, PreCompact and the Stop hook. An uncommitted file blocks only when this session's record of its writes names it (the record the Write and Edit hook fills, and the close's own writes), or when a record-failure flag names it, whatever its bytes are now. If any record of this session failed (a failure flag exists at all, even one that names only a path outside the project) or the record lost paths it cannot name, every uncommitted file in the project folder being closed (`projects/<project>/`) blocks until the flag is removed or the session ends, because the record can no longer be trusted to say which of them are this session's. An empty `index.md` that HEAD does not have, in the project folder being closed, blocks whatever the record says: it is a project index whose create was cut between creating the file and writing it, and whose create it was cannot be told. When a close finds that another session already created the project's `index.md`, this session's claim on that file stays, so it blocks this session's close as its own write until it is committed. A session that began before 1.10.2 is treated the same way: SessionStart leaves a record stamp (`record-stamp.json`, in `.cache/sessions/<id>/`) for a session that starts on 1.10.2 or later, and a session without one may have missed writes with no flag to say so, so it keeps the older rule (every uncommitted file in the project folder being closed blocks). Reloading plugins does not stamp it; only a new session does. A call with no `--session-id` has no record to read, so it is treated the same way: with `--project` the whole project folder blocks, without it every uncommitted file under `projects/` does. Any other uncommitted file is a notice (type `unresolved`), not a blocker: another session's, one of unknown ownership, or one this session wrote through a shell command (Bash leaves no record, so such a file shows up only as a notice). The transcript is not ownership evidence here; it only widens the lint scope. Neither a blocker nor a notice stops `/compact`: the PreCompact hook only shows a notice.
124
+
125
+ How to get out of a project-folder block:
126
+
127
+ - The session has a failure flag (a record failed, or lost paths): check that no uncommitted file `git status` lists in the vault is one this session wrote, then delete the whole `<vault>/.cache/sessions/<id>/touched-paths-failed.d/` directory, or open a new session and close from there. The blocker's reason names that directory by its absolute path. The flag never clears on its own.
128
+ - The session has no record stamp (`record-stamp.json`; it began before 1.10.2), or has one that cannot be read: the blocked files may be another session's, so do not revert one that is another terminal's work. Commit or revert only the files you have confirmed are this session's; otherwise open a new session and close from there. There is no flag file to remove.
129
+ - The record is corrupt: look at the file the reason names (`.cache/sessions/<id>/touched-paths.json`, or `touched-hashes.json` beside it), check with `git status` that what this session wrote is committed, then delete it.
130
+ - The record's lock could not be created (permissions or disk space on `.cache/sessions/<id>/`): deleting a file does not help. Fix the permissions or free disk space, then re-run.
131
+ - A file that the failure flag names blocks whenever it is uncommitted, whatever its bytes. If it is this session's, commit or revert it. If it may be another session's, do not revert it: clear the flag as above.
132
+ - An empty `index.md` in the project folder being closed: if this session's close made it, fill it in or delete it and close again. If another session left it half made (that session died), delete the file. A new session sees the same empty file and blocks again, so starting one does not help. An `index.md` with content is not blocked.
133
+ - A call with no `--session-id`: pass the id.
134
+
135
+ The checkpoint receipt is the thing the
131
136
  Stop-chain Layer 3 hook (`hypo-auto-minimal-crystallize`) actually checks: it proves only that
132
137
  the file versions it names are in a specific commit, never that every change this session made
133
138
  is saved. (It has nothing to do with the `close-receipt-failed` result of `proposal resolve`
@@ -199,7 +204,7 @@ Both gates judge only the **payload files**: this close's session entry, the ses
199
204
  >
200
205
  > Three refusals come before the receipt proof and carry no `reason`. A gate refusal (`blockers[]` plus `missing` and `stale`, with `error: 'session-close gate not satisfied'`) means the same close-file, lint or feedback checks as `--check-session-close` found something this session owns: fix it and re-run. A `skipReason` of `no-user-close-signal` (with `gateReason` when the gate has one) or `no-attribution-evidence` means the transcript shows no close request, or nothing ties this session to a project (pass `--project=<slug>` or `--log-only`). A bare `error` with none of these fields means the prior receipt or marker could not be set aside; fix the permission or disk problem under `.cache/` it names.
201
206
  >
202
- > The close checkpoint is recorded once the receipt is issued: `ok: true` from this command, and `close_state: closed` from `--check-session-close --session-id=<id>`. The Stop hook still checks this session's own project folder and has the final say. The final "Close check" line of `--check-session-close` covers the whole vault, so another session's uncommitted file can keep it red after your close has landed. A red line never stops `/compact`: PreCompact only shows a notice.
207
+ > The close checkpoint is recorded once the receipt is issued: `ok: true` from this command, and `close_state: closed` from `--check-session-close --session-id=<id>`. The Stop hook runs the same git check and has the final say. The final "Close check" line of `--check-session-close` uses that check too, but a call without `--session-id` has no record to read and blocks the whole project folder (or every uncommitted file under `projects/` when no project is named), so another session's uncommitted file can keep a check without the id red after your close has landed. A red line never stops `/compact`: PreCompact only shows a notice.
203
208
 
204
209
  ---
205
210
 
@@ -268,7 +273,8 @@ If `markerWritten: true`: ask: "The close checkpoint is recorded. Would you like
268
273
 
269
274
  If the apply succeeded but `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`, which carries one of three values here. Four more (`marker-did-not-land`, `receipt-proof-mismatch`, `receipt-write-failed`, `close-resolution-failed`) no longer reach this branch: when every other precondition cleared and the close still could not certify itself, that is a failure and not a policy withhold, so the result comes back `ok: false` with that value as `stage` and exit 1. Each has its own row in the stage table above.
270
275
 
271
- - `compact-gate-not-ok`: surface the reason verbatim and address it (resolve the gate blocker) before re-running. The JSON's `gateBlockers[]` names what the checkpoint gate refused on; read the cause there, not from `--check-session-close`, which judges the whole vault on a wider git axis and also lists other sessions' files that did not block this close. One `compact-gate-not-ok` blocker is an uncommitted file in the project folder being closed, which blocks even when this session has no record of writing it: commit it or revert it. **Never commit or revert another session's `projects/<p>/sessions/` entry, nor a session-log shard another session is appending to.** The entry is the only copy of that session's close summary, the appended heading is that session's log line, and that session's own close commits both. Neither blocks this close: a new entry waiting for its commit shows up as a `foreign-entry` notice, and a committed session-log shard whose only change is what other sessions' closes appended after its committed bytes (each close records its append) shows up as a `foreign-append` notice. If one stays uncommitted (that session's commit failed), it stays unsynced until that session runs its close again. A shard whose committed part was changed, one that gained any line no other close recorded (a hand edit, even a dated heading), one with a staged edit or deletion, or one this session itself appended to and has not committed still blocks. **The re-run needs no fresh close phrase from the user for these two.** The resolution that spends a close signal is written only once the marker lands, so a run denied its marker at this stage leaves that signal unspent and the retry is authorized by the same one.
276
+ - `compact-gate-not-ok`: surface the reason verbatim and address it (resolve the gate blocker) before re-running. The JSON's `gateBlockers[]` names what the checkpoint gate refused on; read the cause there, not from `--check-session-close` run without `--session-id`, which cannot read this session's record and blocks the whole project folder. A `compact-gate-not-ok` blocker of type `known-session-write` is an uncommitted file this session recorded writing (or one whose record failed to land): save it in a vault commit or revert it. A blocker of type `git` that names a file is a project-folder block because the record lost paths, the session has a failure flag, the session has no record stamp, or the call has no session id, or it is an empty `index.md` in the project folder being closed (a call without `--session-id` blocks the whole project folder too); its reason carries the way out (see "How to get out of a project-folder block" in Step 3). **Never commit or revert another session's `projects/<p>/sessions/` entry, nor a session-log shard another session is appending to.** The entry is the only copy of that session's close summary, the appended heading is that session's log line, and that session's own close commits both. Neither blocks this close: a new entry waiting for its commit shows up as a `foreign-entry` notice, and a committed session-log shard whose only change is what other sessions' closes appended after its committed bytes (each close records its append) shows up as a `foreign-append` notice. If one stays uncommitted (that session's commit failed), it stays unsynced until that session runs its close again. A shard this session itself appended to and has not committed still blocks, because the record names it. A shard whose committed part was changed, one that gained any line no other close recorded (a hand edit, even a dated heading), or one with a staged edit or deletion blocks only under the rule in Step 3: the record names it, a failure flag names it, or the session is under the project-folder rule (no record stamp, a failure flag, a record that lost paths, or a call with no `--session-id`). With a complete record that does not name the shard, it is an `unresolved` notice. **The re-run needs no fresh close phrase from the user for these two.** The resolution that spends a close signal is written only once the marker lands, so a run denied its marker at this stage leaves that signal unspent and the retry is authorized by the same one.
277
+ A `compact-gate-not-ok` blocker of type `check-skipped` (with `check: 'lint'` or `'feedback'`) means that check died by an error and has no verdict: lint crashed, a spawn failed, a child process was killed from outside, or its output did not parse (or carried an `error` field). It comes from all five callers (apply, `--mark-session-closed`, `--check-session-close`, PreCompact, Stop), so no receipt is filed while it stands. `reason` names the cause, and the way out is to run `lint` or `feedback-sync --check` by hand, fix what it reports, and run the close again. A check that only ran out of time is different: it adds a notice (the `skippedWhy` text says the time limit) and does not block the receipt, so on a large vault a close can finish with lint unconfirmed. The same goes for the migration classification inside the global close check: when the gate deadline cuts it short, a project whose session-log has a close heading but no entry dated today gets a `close-legacy-unclassified` notice instead of a `close-legacy` one. It never blocks; run the check again to have the vault classified.
272
278
  **A commit failure is `ok: false` with `stage: 'commit-failed'` (see the stage table), not a withheld marker. Re-run the apply, not `--mark-session-closed`.** Nothing of this close is committed yet, so `--mark-session-closed` has nothing to certify. The failed attempt took its session-log and `log.md` appends back, and `appliedUncommitted` lists only what is still on disk (the entry and its index side effects). The retry finds the entry by its close id and appends again, then commits both. An append file that another write changed since then (another session appended to the same shard, say) is left alone and keeps blocking the marker. If a retry reports `commit-failed:` or `compact-gate-not-ok` a second time on the same close, or comes back with `stage: 'receipt-proof-mismatch'` naming those close files, stop retrying and look at `git status` in the vault: stage and commit the close files yourself, or ask the user to, then run the close again.
273
279
  - `transcript-unresolved`: the marker writer could not resolve any transcript for this `--session-id` at all, so it never got as far as checking for a close signal. Passing the exact same id again changes nothing; get the main conversation's real session id (not a background-task or Agent-thread uuid) and re-run.
274
280
  - `no-user-close-signal`: a transcript was found, but it carries no close signal the gate recognizes. This is the one branch where the re-run DOES need a fresh signal: confirm intent once with `AskUserQuestion` (the same 세션 마무리 flow described under Step 3's `no-user-close-signal` table above), then re-run only after the user confirms. **Read `markerGateReason` first.** It carries the same three values as the authority refusal's `gateReason` (`no-open`, `transcript-rewrite-detected`, `no-new-open-since-resolution`), and only `no-open` means the user never asked. It also names a queued item shaped like an unregistered host tag when one retracted the close, which is a stale allowlist rather than a change of mind: report that name to the user instead of asking them to close again.
@@ -368,7 +374,7 @@ otherwise omit it.
368
374
 
369
375
  An unrecognized flag exits 2 instead of being ignored.
370
376
 
371
- Its verdict covers the whole vault (with `--session-id` for a session whose close was log-only, the project-close checks are skipped), so it can stay red on another session's uncommitted file after your own close has landed. Whether this session is closed is decided by the close checkpoint receipt: pass `--session-id=<id>` and read `close_state` (`closed` once the receipt is valid, or `legacy-closed` for an older marker written before receipts existed), not the vault-wide verdict. The Stop hook still checks this session's own project folder and has the final say.
377
+ Its git check is the one every close caller runs. With `--session-id` it blocks only a file this session recorded writing (or a project folder whose record lost paths or has a failure flag, or whose session began before 1.10.2 and has no record stamp, and an empty `index.md` in the project folder being closed). Without the id it has no record to read and blocks the whole project folder, so it can stay red on another session's uncommitted file after your own close has landed. With `--session-id` for a session whose close was log-only, the project-close checks are skipped. Whether this session is closed is decided by the close checkpoint receipt: pass `--session-id=<id>` and read `close_state` (`closed` once the receipt is valid, or `legacy-closed` for an older marker written before receipts existed), not the vault-wide verdict. The Stop hook runs the same git check and has the final say.
372
378
 
373
379
  Its required lines are this close's session entry (`projects/<project>/sessions/<date>-<close id>.md`), the session-log shard and `log.md`; the generated `session-state.md` and `hot.md` are not among them. It reports any file as `missing` or `stale`. For an actual close, prefer `--apply-session-close --payload=<path>` (Step 3): it bundles freshness and lint into one gate and is the documented dogfood path. `parseArgs` only accepts the `--payload=<path|->` spelling (a path, or `-` for stdin); a space-separated `--payload <path>` is rejected outright with exit 2, not silently dropped.
374
380
 
@@ -32,7 +32,7 @@ node ${CLAUDE_PLUGIN_ROOT}/scripts/upgrade.mjs [--hypo-dir="<path>"]
32
32
 
33
33
  Show the output verbatim.
34
34
 
35
- > **Plugin installs**: if the output begins with `ℹ Plugin install detected`, the core hooks, slash commands, and `settings.json` wiring are managed by the Claude Code plugin loader — **not** by `/hypo:upgrade`. Do **not** run `--apply` expecting it to update them (it intentionally skips those to avoid double-registering every hook). To upgrade the plugin itself: `/plugin marketplace update hypomnema` then `/reload-plugins`. **Then run `--apply` anyway**: hooks written by 1.8.4 through 1.10.0 read the sidecar before the enabled plugin, and older ones bake an install path, so reloading the plugin does not move them. `--apply` rewrites both onto the current resolver. `--apply` in plugin mode applies vault-side migrations (SCHEMA, `.hypoignore`), refreshes package metadata, and still syncs any vault extensions — but does **not** install the core hooks/commands/settings (the plugin provides those).
35
+ > **Plugin installs**: if the output begins with `ℹ Plugin install detected`, the core hooks, slash commands, and `settings.json` wiring are managed by the Claude Code plugin loader, **not** by `/hypo:upgrade`. Do **not** run `--apply` expecting it to update them (it intentionally skips those to avoid double-registering every hook). To upgrade the plugin itself: `/plugin marketplace update hypomnema`, then `/reload-plugins` in **every session that is already open**, not only the one where you ran the update. A session that has not reloaded keeps running the old hooks with the new commands, and in that mix a failed write record leaves no trace. If a session's close is then blocked under the old project-folder rule (every uncommitted file in the project folder being closed blocks), end that session and open a new one: a session that was open before the upgrade has no record stamp (`record-stamp.json`), reloading does not give it one, and only a session started after the upgrade gets the rule that follows each session's own record of its writes. `--apply` alone never changes the plugin-managed hooks. **Then run `--apply` anyway**: hooks written by 1.8.4 through 1.10.0 read the sidecar before the enabled plugin, and older ones bake an install path, so reloading the plugin does not move them. `--apply` rewrites both onto the current resolver. `--apply` in plugin mode applies vault-side migrations (SCHEMA, `.hypoignore`), refreshes package metadata, and still syncs any vault extensions, but does **not** install the core hooks/commands/settings (the plugin provides those).
36
36
 
37
37
  > **Note**: A major SCHEMA bump is only **detected** in this step. The informational `MIGRATION-vX.Y.md` file is written later by `--apply` (Step 4) and only on a major bump. `SCHEMA.md` is never auto-overwritten.
38
38
 
@@ -77,6 +77,8 @@ If there is anything to update, ask the user:
77
77
 
78
78
  After applying, recommend running `/hypo:doctor` to verify the installation is healthy.
79
79
 
80
+ On an npm install the same holds for a session that was already open before the upgrade: it has no record stamp (`record-stamp.json`), so its close keeps the old project-folder rule. If that blocks its close, end the session and open a new one.
81
+
80
82
  ---
81
83
 
82
84
  ## `--migrate-sessions` (a vault with a git remote)
@@ -91,7 +93,7 @@ This moves the vault from the tracked `hot.md` and `session-state.md` files to p
91
93
 
92
94
  1. loses the session view in what it injects at session start,
93
95
  2. stalls its whole sync if it holds an unpushed commit that edits `hot.md` or `session-state.md`, and
94
- 3. if it uses the plugin channel, is not refreshed on its own (run `/plugin marketplace update hypomnema`, then `/reload-plugins`).
96
+ 3. if it uses the plugin channel, is not refreshed on its own (run `/plugin marketplace update hypomnema`, then `/reload-plugins` in every session that is already open; Step 2 says what to do when a session opened before the upgrade is still blocked at close).
95
97
 
96
98
  **When to ask.** If the user typed `/hypo:upgrade --migrate-sessions` directly, run it without asking again: typing the flag is their statement that all machines are upgraded. Only when you are the one proposing this flag, or about to run it during another flow, first ask the user whether every machine that uses the same vault has been upgraded, because the command pushes the migration commit at once.
97
99
 
@@ -135,7 +135,7 @@ Counts are left out on purpose, because they go stale: `hooks/hooks.json` lists
135
135
 
136
136
  | Hook | Responsibility |
137
137
  |---|---|
138
- | `hypo-session-start` | Run `git pull --ff-only` (silent fail on missing remote), rebuild the generated session views from `projects/*/sessions/` (not on a vault with a remote that has not moved yet, which gets a notice instead), inject `index.md`, root `hot.md`, project `hot.md`/`session-state.md` |
138
+ | `hypo-session-start` | Run `git pull --ff-only` (silent fail on missing remote), rebuild the generated session views from `projects/*/sessions/` (not on a vault with a remote that has not moved yet, which gets a notice instead), inject `index.md`, root `hot.md`, project `hot.md`/`session-state.md`; leave a record stamp (`record-stamp.json`, in `.cache/sessions/<id>/`) for a session that starts on 1.10.2 or later, which is what lets the close gate trust that session's record of its writes |
139
139
  | `hypo-close-guard` | `PreToolUse`: denies a direct Write/Edit/MultiEdit to a generated session view (root `hot.md`, a project's `hot.md` or `session-state.md`) or to a session entry under `projects/<p>/sessions/`. Writes made through Bash are out of its scope, and the regular close apply runs through Bash |
140
140
  | `hypo-session-end` | `SessionEnd`: on `reason=clear` only, writes `.cache/clear-marker.json` so the next `SessionStart` (`source=clear`) can issue a recovery nudge. It also records the close-gate resolution in `.cache/close-gate/<session-id>.json` (the close gate is the check that a substantial session has been closed; `/clear` ends the session, so it counts as resolving the gate). That record is best effort: it is written only when the session's transcript resolves, and a failure never blocks `/clear`. Other exit reasons write nothing |
141
141
  | `hypo-web-fetch-ingest` | `PostToolUse`: after a WebFetch or WebSearch, injects a nudge to run `/hypo:ingest` (URL reduced to origin and path). It is a signal only and never ingests anything itself |
@@ -143,7 +143,7 @@ Counts are left out on purpose, because they go stale: `hooks/hooks.json` lists
143
143
  | `hypo-lookup` | BM25 search over the wiki on every prompt. **HIT** → inject top-3 page snippets (≤2000 chars each; a page whose `verify_by_date` is overdue gets a `[STALE verify_by_date=…]` marker prepended). **MISS** → emit closest-slug signal that prompts Claude to research + `/hypo:ingest` |
144
144
  | `hypo-compact-guard` | Detect `/compact` or `/clear` typed in chat and, if session close is incomplete, tell Claude so. It never blocks `/compact` |
145
145
  | `hypo-personal-check` | PreCompact detection: lint blockers, uncommitted changes, missing session-log entries surface as a `systemMessage`; `/compact` is never blocked here |
146
- | `hypo-auto-stage` | After Write/Edit/MultiEdit on a wiki path, run `git add` (skips paths matching `.hypoignore`, and every path while a merge is open) |
146
+ | `hypo-auto-stage` | After Write/Edit/MultiEdit on a wiki path, run `git add` (skips paths matching `.hypoignore`, and every path while a merge is open), and record the path in the session's touched-paths (the close gate's record of what this session wrote; a record that fails to land leaves a failure flag under `touched-paths-failed.d/`) |
147
147
  | `hypo-hot-rebuild` | At session stop, regenerate the generated session views (root `hot.md`, project `hot.md` and `session-state.md`; the same generator `hypo-session-start` also calls); emit growth metrics + cache for next SessionStart. A scan failure (not just an empty `projects/`) skips the write and leaves a health notice for the next `SessionStart` to surface |
148
148
  | `hypo-session-record` | At session stop, append `{session_id, transcript_path, recorded_at, cwd, device}` to `.cache/sessions/index.jsonl` (primary source for the observability audit) |
149
149
  | `hypo-auto-commit` | At session stop, filter changed paths through `.hypoignore`, finish a merge Hypomnema left open, commit non-ignored changes, `git pull --no-rebase` + `git push` (silent fail on missing remote; the push is skipped after a pull that did not land, and when the tree tracks generated views under a migrated upstream) |
@@ -314,28 +314,42 @@ export function advanceBase(hypoDir, sessionId, relPath, hash) {
314
314
  * rule (spec line 40): only the session's OWN writes advance, so a concurrent
315
315
  * writer's change to the same target is still observed as drift.
316
316
  *
317
+ * `timeoutMs` caps the lock wait for a caller with a time budget (the auto-stage hook); a lock that
318
+ * is not free in time returns false like any other failure here.
319
+ *
317
320
  * @returns {boolean} true when the base file was updated
318
321
  */
319
- export function advanceBaseForWrite(hypoDir, sessionId, relPath, absPath, knownHash = null) {
322
+ export function advanceBaseForWrite(
323
+ hypoDir,
324
+ sessionId,
325
+ relPath,
326
+ absPath,
327
+ knownHash = null,
328
+ { timeoutMs } = {},
329
+ ) {
320
330
  if (!sessionId) return false;
321
331
  const path = basePath(hypoDir, sessionId);
322
332
  try {
323
- return withFileLock(path, () => {
324
- const parsed = readBaseFile(hypoDir, sessionId);
325
- if (!parsed) return false;
326
- // Only a tracked target advances. hasOwnProperty, not truthiness: an
327
- // observed-absent entry is `null` but still a legitimate key to advance from.
328
- if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
329
- const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
330
- if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
331
- parsed.targets[relPath] = hash;
332
- try {
333
- atomicWrite(path, JSON.stringify(parsed, null, 2));
334
- return true;
335
- } catch {
336
- return false;
337
- }
338
- });
333
+ return withFileLock(
334
+ path,
335
+ () => {
336
+ const parsed = readBaseFile(hypoDir, sessionId);
337
+ if (!parsed) return false;
338
+ // Only a tracked target advances. hasOwnProperty, not truthiness: an
339
+ // observed-absent entry is `null` but still a legitimate key to advance from.
340
+ if (!Object.prototype.hasOwnProperty.call(parsed.targets, relPath)) return false;
341
+ const hash = typeof knownHash === 'string' ? knownHash : hashFile(absPath);
342
+ if (typeof hash !== 'string') return false; // absent/unreadable → leave base as-is
343
+ parsed.targets[relPath] = hash;
344
+ try {
345
+ atomicWrite(path, JSON.stringify(parsed, null, 2));
346
+ return true;
347
+ } catch {
348
+ return false;
349
+ }
350
+ },
351
+ timeoutMs === undefined ? {} : { timeoutMs },
352
+ );
339
353
  } catch {
340
354
  return false;
341
355
  }
@@ -84,6 +84,7 @@ import {
84
84
  isCloseReconfirmDeclined,
85
85
  CLOSE_RECONFIRM_MARK,
86
86
  resolveGateProjectOverride,
87
+ GATE_DEADLINE_MS,
87
88
  gitDirtyFiles,
88
89
  isForeignAppendOnlyShard,
89
90
  isForeignUncommittedEntry,
@@ -92,6 +93,19 @@ import { closeCheckpointState, isCloseComplete, receiptPath } from './close-rece
92
93
  import { closeGateStatus } from './close-gate-store.mjs';
93
94
  import { atomicWrite } from './atomic-write.mjs';
94
95
 
96
+ // This stage's own share of the Stop budget: the `timeoutMs` of this file in the STAGES
97
+ // table of hypo-stop.mjs, which passes it down as HYPO_STAGE_TIMEOUT_MS (hypo-stop runs on
98
+ // import, so it cannot be imported for it). 10000 is the fallback when the variable is
99
+ // missing or not a positive integer, such as a run of this file on its own.
100
+ // The gate deadline is capped at that budget minus a reserve for the work after the gate,
101
+ // measured from this process's start (performance.now() counts from there), so a slow
102
+ // start leaves the gate less time instead of letting the stage be killed mid-gate.
103
+ const STAGE_BUDGET_MS = (() => {
104
+ const n = Number(process.env.HYPO_STAGE_TIMEOUT_MS);
105
+ return Number.isInteger(n) && n > 0 ? n : 10000;
106
+ })();
107
+ const STAGE_RESERVE_MS = 1000;
108
+
95
109
  // Carrying a systemMessage does not turn this into a block: the session is
96
110
  // still free to end, the message just rides along on the same continue reply
97
111
  // (hooks/hypo-stop.mjs merges every stage's systemMessage into one).
@@ -365,6 +379,9 @@ process.stdin.on('end', () => {
365
379
  ? { closeMarker: isCloseComplete(checkpoint) ? checkpoint.marker : null }
366
380
  : {}),
367
381
  ...(attributionScope ? { attributionScope } : {}),
382
+ deadline: {
383
+ end: Math.min(performance.now() + GATE_DEADLINE_MS, STAGE_BUDGET_MS - STAGE_RESERVE_MS),
384
+ },
368
385
  });
369
386
  } catch {
370
387
  return null;
@@ -394,9 +411,17 @@ process.stdin.on('end', () => {
394
411
  return;
395
412
  }
396
413
  gate = computeGate();
397
- const cwdBlocked = !!gate?.blockers?.some((b) => b.type === 'close-cwd');
414
+ // A close-cwd blocker with `incomplete` means the gate deadline cut the proof:
415
+ // the close did not fail, and a transient timeout must not become a block (see
416
+ // hypo-stop.mjs), so it is said and checked again on the next turn.
417
+ const cwdBlockers = (gate?.blockers || []).filter((b) => b.type === 'close-cwd');
418
+ const cwdBlocked = cwdBlockers.some((b) => !b.incomplete);
398
419
  if (!cwdBlocked) {
399
- emitContinue(notice());
420
+ const cut = cwdBlockers.length
421
+ ? 'hypo: the close of this session was recorded, but the check of the session cwd project did not finish within the time limit. It will be checked again on the next turn (마감 안에 cwd 프로젝트 close 증명을 마치지 못함, 다음 턴에 다시 확인함).'
422
+ : null;
423
+ const n = notice();
424
+ emitContinue([n, cut].filter(Boolean).join('\n\n') || undefined);
400
425
  return;
401
426
  }
402
427
  // finished close, but the session's cwd project close is incomplete:
@@ -420,15 +445,23 @@ process.stdin.on('end', () => {
420
445
  // "work-incomplete" together are exactly the case where the transcript's
421
446
  // NL close phrase can't tell "close now" from "close once X is done" —
422
447
  // regex can't disambiguate this (see spec background). Narrowed to the
423
- // work-incomplete signals only: an uncommitted wiki (`git` blocker; real
424
- // unsaved work) OR pending background work — a non-terminal background task
448
+ // work-incomplete signals only: an uncommitted file this session wrote (a
449
+ // `known-session-write` blocker that names a file, or a `git` blocker for a
450
+ // project folder the write record lost track of; real unsaved work) OR
451
+ // pending background work — a non-terminal background task
425
452
  // (delegated subagent OR shell, e.g. a deferred push/CI wait) or a
426
453
  // scheduled cron wake (hasPendingBackgroundWork). `hot` / `lint` / `close`
427
454
  // / `design-history` / `feedback` blockers are real close blockers but not
428
455
  // evidence of "later" intent, so they keep the existing wording (unchanged
429
- // from before this feature).
456
+ // from before this feature). The touched-paths lock timeout is a
457
+ // `known-session-write` blocker with no `file`: it says the record could not
458
+ // be read right now, not that work is unfinished.
430
459
  const workIncomplete =
431
- (gate && gate.blockers && gate.blockers.some((b) => b.type === 'git')) ||
460
+ (gate &&
461
+ gate.blockers &&
462
+ gate.blockers.some(
463
+ (b) => (b.type === 'git' || b.type === 'known-session-write') && b.file,
464
+ )) ||
432
465
  hasPendingBackgroundWork(payload);
433
466
  if (workIncomplete && isCloseReconfirmDeclined(transcriptPath)) {
434
467
  // The user already answered "아직" to the ambiguous close signal that