hypomnema 1.10.0 → 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.
@@ -1,6 +1,6 @@
1
1
  # Hypomnema — Architecture
2
2
 
3
- > Internal reference for v1.1. For the user-facing pitch, see [README.md](../README.md).
3
+ > Internal reference for the current release. For the user-facing pitch, see [README.md](../README.md).
4
4
 
5
5
  ---
6
6
 
@@ -18,10 +18,12 @@ Hypomnema is a Claude Code extension with three runtime layers stacked on a plai
18
18
  │ ↓ writes / reads │
19
19
  │ Wiki vault (markdown + git) │
20
20
  │ ↑ reads / stages / commits │
21
- │ Lifecycle hooks (10) (background automation) │
21
+ │ Lifecycle hooks (background automation) │
22
22
  │ ├──► SessionStart, UserPromptSubmit, PreCompact, │
23
- │ │ PostToolUse, Stop, CwdChanged, FileChanged │
24
- │ └──► hypo-shared.mjs (deployed inline; no imports) │
23
+ │ │ PreToolUse, PostToolUse, Stop, SessionEnd, │
24
+ │ │ CwdChanged, FileChanged │
25
+ │ └──► hypo-shared.mjs + other (copied beside the hooks, │
26
+ │ shared modules no imports outside the dir) │
25
27
  └──────────────────────────────────────────────────────────────┘
26
28
  ```
27
29
 
@@ -34,36 +36,38 @@ The three layers are deliberately **decoupled by file artifacts**. Slash command
34
36
  ```
35
37
  hypomnema/
36
38
  ├── commands/ ← slash command definitions (.md prompts)
37
- ├── hooks/ ← lifecycle hooks deployed to ~/.claude/hooks/
38
- │ ├── hypo-*.mjs ← 10 hook files (see Hook registry below)
39
- │ ├── hypo-shared.mjs ← shared utilities, inlined at deploy
40
- │ └── hooks.json ← single source of truth for event ↔ hook mapping
39
+ ├── hooks/ ← lifecycle hooks (copied to ~/.claude/hooks/ on the npm channel)
40
+ │ ├── hypo-*.mjs ← hook entry points and modules (see Hook registry below)
41
+ │ ├── hypo-shared.mjs ← shared utilities, copied beside the hooks
42
+ │ ├── hooks.json ← source of truth for the event to hook map
43
+ │ └── shared.json ← source of truth for the shared module list
41
44
  ├── scripts/ ← Node.js implementations
42
45
  │ ├── init.mjs / upgrade.mjs / uninstall.mjs / doctor.mjs
43
46
  │ ├── ingest.mjs / query.mjs / lint.mjs / verify.mjs / graph.mjs / stats.mjs
44
- │ ├── crystallize.mjs / resume.mjs / feedback.mjs
45
- │ ├── bump-version.mjs ← release helper
46
- │ └── lib/ ← scripts-only helpers
47
- │ ├── frontmatter.mjs
48
- │ ├── hypo-root.mjs
49
- │ └── hypo-ignore.mjs
47
+ │ ├── crystallize.mjs / resume.mjs / feedback.mjs / feedback-sync.mjs
48
+ │ ├── capture.mjs / rename.mjs / proposal.mjs / session-entries.mjs
49
+ │ ├── session-audit.mjs / weekly-report.mjs
50
+ │ └── lib/ ← helpers that scripts import (shipped set: `files` in package.json; one hook, hypo-pre-commit.mjs, also imports from here)
50
51
  ├── skills/ ← directory-form Agent Skills (skills/<name>/SKILL.md)
51
52
  ├── templates/ ← baseline files copied on init
52
- │ ├── hypo-config.md, index.md, hot.md, log.md, SCHEMA.md, hypo-guide.md
53
+ │ ├── hypo-config.md, index.md, log.md, SCHEMA.md, hypo-guide.md
53
54
  │ ├── Home.md, Overview.md, hypo-automation.md, hypo-help.md
54
- │ ├── pages/_index.md
55
+ │ ├── .hypoignore, .hyposcanignore, gitignore, gitattributes
56
+ │ ├── pages/_index.md, pages/observability/_index.md
55
57
  │ └── projects/_template/
56
58
  ├── tests/
57
59
  │ ├── harness.mjs ← test()/testAsync()/suite(), selection, reporting
58
60
  │ ├── helpers.mjs ← fixtures shared by more than one area file
59
61
  │ ├── runner.mjs ← entry: imports every *.test.mjs, owns no tests
60
62
  │ ├── parallel.mjs ← runs the entry as N processes (`npm test`)
61
- │ └── <area>.test.mjs ← one file per production area (26)
63
+ │ └── <area>.test.mjs ← one file per production area
62
64
  ├── docs/ ← ARCHITECTURE.md, CONTRIBUTING.md
63
65
  ├── .claude-plugin/plugin.json← plugin manifest
64
66
  └── package.json ← npm metadata, no runtime deps
65
67
  ```
66
68
 
69
+ On the npm channel, which `scripts/` files ship is decided by the `files` list in `package.json`, which names each one. Anything under `scripts/` that is not on that list is maintainer tooling (release checks, the git-hook installer, and similar) and is absent from the npm package. A product script left off `files` is missing from the npm package only. A hook helper left off `hooks/shared.json` breaks in every install that copies the hooks and runs the copy (the npm channel, a checkout deployed with init or upgrade, and `--codex` copies in `~/.codex/hooks`). It still works where hooks run in place: the plugin channel and a checkout run directly. The plugin channel ships the whole tracked tree instead (see the "What ships" row under Install channels). The entry points above are the ones a user runs.
70
+
67
71
  The package has **zero npm runtime dependencies**. Everything is Node.js built-ins (`fs`, `path`, `os`, `child_process`, `crypto`).
68
72
 
69
73
  ---
@@ -77,7 +81,7 @@ Each user-facing operation is a pair: an LLM-facing prompt + a Node.js script.
77
81
  | `commands/<name>.md` | Prompt definition — instructions Claude follows when the command is invoked |
78
82
  | `scripts/<name>.mjs` | Script implementation — Node.js logic the prompt may call via Bash |
79
83
 
80
- **Commands shipped in v1.1:** `init`, `doctor`, `upgrade`, `uninstall`, `ingest`, `query`, `crystallize`, `resume`, `feedback`, `verify`, `lint`, `stats`, `graph`.
84
+ **Commands** (one file each under `commands/`): `init`, `doctor`, `upgrade`, `uninstall`, `ingest`, `query`, `crystallize`, `resume`, `feedback`, `verify`, `lint`, `stats`, `graph`, `audit`, `capture`, `rename`.
81
85
 
82
86
  Every one of those files is itself an Agent Skill; see below.
83
87
 
@@ -107,18 +111,22 @@ won every one of them. `scripts/smoke-plugin.mjs` now fails the build on a colli
107
111
 
108
112
  ## Lifecycle hooks
109
113
 
110
- Hooks run automatically at Claude Code lifecycle events. They are deployed to `~/.claude/hooks/` by `init` / `upgrade`, and registered in `~/.claude/settings.json`.
114
+ Hooks run automatically at Claude Code lifecycle events. On the plugin channel the harness runs them straight from the plugin directory. On the npm channel `init` / `upgrade` copy them to `~/.claude/hooks/` and register them in `~/.claude/settings.json` (see Install channels below).
111
115
 
112
116
  ### Hook registry (`hooks/hooks.json`)
113
117
 
114
- `hooks.json` is the **single source of truth** for hook ↔ event mapping. `init`, `upgrade`, and `doctor` all read from it; the previous v1.0 design that maintained three separate `HOOK_MAP` constants was deleted in v1.1. A `git post-commit` hook re-applies upgrades when this file changes.
118
+ `hooks.json` is the **single source of truth** for hook ↔ event mapping. `init`, `upgrade`, and `doctor` all read from it; the previous v1.0 design that maintained three separate `HOOK_MAP` constants was deleted in v1.1. A `git post-commit` hook in a checkout re-applies the upgrade, but only when the last commit changed something under `hooks/`.
119
+
120
+ Counts are left out on purpose, because they go stale: `hooks/hooks.json` lists the events and registered handlers, and `hooks/shared.json` lists the modules copied beside them. At the time of writing that is nine events and twelve handlers.
115
121
 
116
122
  | Event | Hooks (in order) |
117
123
  |---|---|
124
+ | `PreToolUse` | `hypo-close-guard.mjs` |
118
125
  | `SessionStart` | `hypo-session-start.mjs` |
126
+ | `SessionEnd` | `hypo-session-end.mjs` |
119
127
  | `UserPromptSubmit` | `hypo-first-prompt.mjs` → `hypo-lookup.mjs` → `hypo-compact-guard.mjs` |
120
128
  | `PreCompact` | `hypo-personal-check.mjs` |
121
- | `PostToolUse` (Write/Edit) | `hypo-auto-stage.mjs` |
129
+ | `PostToolUse` (no matcher in `hooks.json`; each hook filters on `tool_name` itself) | `hypo-auto-stage.mjs` (Write/Edit/MultiEdit) → `hypo-web-fetch-ingest.mjs` (WebFetch/WebSearch) |
122
130
  | `Stop` | `hypo-stop.mjs` (runs `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` → `hypo-auto-minimal-crystallize.mjs` as ordered stages, not separate registrations) |
123
131
  | `CwdChanged` | `hypo-cwd-change.mjs` |
124
132
  | `FileChanged` | `hypo-file-watch.mjs` |
@@ -127,12 +135,15 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
127
135
 
128
136
  | Hook | Responsibility |
129
137
  |---|---|
130
- | `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
+ | `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
+ | `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
+ | `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 |
131
142
  | `hypo-first-prompt` | Reads the marker left by `hypo-session-start` or `hypo-cwd-change` and forces a one-line resume on the first user prompt (10-min TTL). It does not re-read or re-inject `hot.md` |
132
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` |
133
144
  | `hypo-compact-guard` | Detect `/compact` or `/clear` typed in chat and, if session close is incomplete, tell Claude so. It never blocks `/compact` |
134
145
  | `hypo-personal-check` | PreCompact detection: lint blockers, uncommitted changes, missing session-log entries surface as a `systemMessage`; `/compact` is never blocked here |
135
- | `hypo-auto-stage` | After Write/Edit 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/`) |
136
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 |
137
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) |
138
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) |
@@ -141,12 +152,12 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
141
152
 
142
153
  ### Deployment constraint
143
154
 
144
- Hooks run in an isolated environment at `~/.claude/hooks/`. They **cannot import from relative paths** outside their own directory. Therefore:
155
+ On the npm channel hooks run from `~/.claude/hooks/`, an isolated directory. They **cannot import from anything outside their own directory**; sibling modules in that directory are fine. Therefore:
145
156
 
146
- - Shared hook logic lives in the modules `hooks/shared.json` declares, not only in `hypo-shared.mjs`. That file (a plain JSON array of `.mjs` basenames) is the source of truth for the list and is copied alongside hooks at deploy time. It used to be a `shared` field inside `hooks.json` itself; it moved to this sibling file because the harness started warning on that unknown top-level key once it began validating `hooks.json` against its own hook-registration schema.
147
- - Hook utilities use **only Node.js built-ins** — no relative imports, no npm dependencies.
157
+ - Shared hook logic lives in the modules `hooks/shared.json` declares, not only in `hypo-shared.mjs`. That file (a plain JSON array of `.mjs` basenames) is the source of truth for the list. Each listed file is copied next to the hooks at deploy time, not inlined into them. It used to be a `shared` field inside `hooks.json` itself; it moved to this sibling file because the harness started warning on that unknown top-level key once it began validating `hooks.json` against its own hook-registration schema.
158
+ - Hook utilities use **only Node.js built-ins** and sibling modules: no imports outside the hooks directory, no npm dependencies.
148
159
 
149
- Scripts in `scripts/` are not deployed — they run from the package install path — so they can import from `scripts/lib/`.
160
+ Scripts in `scripts/` are not copied anywhere. They run from the package install path, so they can import from `scripts/lib/`, and they may import from `hooks/`. The reverse is not allowed for any file that gets copied to `~/.claude/hooks` (anything in `hooks.json` or `shared.json`): a copied hook that imported from `scripts/` would resolve in a checkout and on the plugin channel, where hooks run in place, and fail with `ERR_MODULE_NOT_FOUND` wherever the hooks are copied (the npm channel, a checkout deployed with init or upgrade, and `--codex` copies in `~/.codex/hooks`). The one exception is `hooks/hypo-pre-commit.mjs`. It is a git hook worker that runs from the install root and is never copied, so it imports `../scripts/lib/hypo-ignore.mjs`.
150
161
 
151
162
  ---
152
163
 
@@ -156,26 +167,36 @@ Scripts in `scripts/` are not deployed — they run from the package install pat
156
167
  |---|---|
157
168
  | `HYPO_DIR` | Resolved wiki root path |
158
169
  | `PKG_ROOT` | Package install path, read from `~/.claude/hypo-pkg.json` |
159
- | `resolveHypoRoot()` | `HYPO_DIR` env → `hypo-config.md` scan (cwd → `$HOME`) → `~/hypomnema` fallback |
170
+ | `resolveHypoRoot()` | `HYPO_DIR` env → `hypo-config.md` in a fixed list of candidate directories (see Configuration resolution) → `~/hypomnema` fallback |
160
171
  | `loadHypoIgnore()` | Parse `.hypoignore` into a pattern list |
161
172
  | `isIgnored(path)` | Test a path against `.hypoignore` patterns |
162
173
  | `hypoIsClean()` | Check git status + unpushed commits |
163
174
  | `hotMdIsClean()` | Validate `hot.md` structure |
164
175
  | `isCompactCommand(prompt)` | Detect `/compact` invocations |
165
176
  | `buildOutput(hookEventName, context, extra)` | Build hook output that nests `additionalContext` under `hookSpecificOutput`, keeping control fields as top-level siblings. The event name is required: Claude Code drops a payload whose `hookEventName` does not match the firing event |
166
- | `SESSION_STATE_NEXT_HEADINGS` | Allowed headings for "next tasks" — `## 다음 이어받기` / `## 다음 작업`. Lint reuses this constant (DRY) |
177
+ | `SESSION_STATE_NEXT_HEADINGS` | Allowed headings for "next tasks": `## 다음 이어받기`, `## 다음 작업`, `## Next Up`, `## Next`. Lint reuses this constant (DRY) |
178
+
179
+ `hypo-shared.mjs` is one of the shared modules. The full list is `hooks/shared.json`; besides the session-close stores and the update-check modules it includes `session-entries.mjs`, which holds the session entry format and the renderers, and `session-views.mjs`, which loads the entries and writes the generated views.
167
180
 
168
181
  ---
169
182
 
170
183
  ## Scripts lib (`scripts/lib/`)
171
184
 
172
- Helpers used by command scripts only. Not deployed to hooks.
185
+ Helpers used by command scripts. They are not copied beside the hooks. The only hook that imports from here is `hypo-pre-commit.mjs`, a git hook worker that runs from the install root (see Deployment constraint). On the npm channel the shipped set is the `scripts/lib/` entries in `package.json`'s `files`. The main ones:
173
186
 
174
187
  | File | Purpose |
175
188
  |---|---|
176
189
  | `frontmatter.mjs` | Parse and serialize YAML frontmatter |
177
190
  | `hypo-root.mjs` | Wiki root resolution (mirrors hook logic, allows imports) |
178
191
  | `hypo-ignore.mjs` | `.hypoignore` parsing (mirrors hook logic, allows imports) |
192
+ | `project-create.mjs` | Creates a project: `index.md`, `prd.md`, `decisions/`, `session-log/` |
193
+ | `crystallize-close-*.mjs` | The session-close check, gate and apply behind `/hypo:crystallize` |
194
+ | `extensions.mjs` | Syncs the vault's `extensions/` into `~/.claude` |
195
+ | `plugin-detect.mjs` | Detects whether the plugin is also enabled, so the npm path does not register every hook twice |
196
+ | `wikilink.mjs`, `slug-resolver.mjs` | Wikilink parsing and slug resolution |
197
+ | `rename-marker.mjs` | The crash-recovery marker `rename.mjs --apply` writes and `doctor.mjs` reads |
198
+
199
+ The session entry format and the generated views are not here. `hooks/session-entries.mjs` and `hooks/session-views.mjs` own them, because hooks need them too and scripts may import from `hooks/` but not the other way round. `scripts/session-entries.mjs` is the command-line entry for them (`rollback` and `render`; the move to entries is `/hypo:upgrade --migrate-sessions`).
179
200
 
180
201
  ---
181
202
 
@@ -190,10 +211,20 @@ Helpers used by command scripts only. Not deployed to hooks.
190
211
  ├── SCHEMA.md ← type system reference
191
212
  ├── hypo-guide.md ← operations guide
192
213
  ├── .hypoignore ← glob patterns the hooks must ignore
214
+ ├── .hyposcanignore ← glob patterns excluded from catalog scans only (not a privacy boundary)
215
+ ├── .gitignore ← written by init (generated views, .cache/)
216
+ ├── .gitattributes ← written by init
217
+ ├── extensions/ ← your own agents/commands/hooks/skills, mirrored to ~/.claude
218
+ │ └── {agents,commands,hooks,skills}/
193
219
  ├── pages/ ← permanent knowledge pages
194
- │ └── feedback/ ← AI behavior corrections
220
+ │ ├── feedback/ ← AI behavior corrections
221
+ │ └── observability/ ← _index.md (the autonomy score definition)
195
222
  ├── projects/ ← per-project artifacts
223
+ │ ├── _template/ ← scaffold: index.md, prd.md, decisions/, session-log/
196
224
  │ └── <name>/
225
+ │ ├── index.md ← overview (working_dir: field)
226
+ │ ├── prd.md ← purpose / success criteria
227
+ │ ├── decisions/ ← ADRs (created with the project)
197
228
  │ ├── sessions/ ← one entry per session close (source of truth, never deleted)
198
229
  │ ├── notes.md ← notes every session shares (hand-written, optional)
199
230
  │ ├── hot.md ← recent session summaries (generated, not committed)
@@ -204,10 +235,11 @@ Helpers used by command scripts only. Not deployed to hooks.
204
235
  │ ├── daily/
205
236
  │ ├── weekly/
206
237
  │ └── monthly/
207
- └── sources/ ← raw ingested sources (append-only, never edit)
238
+ ├── sources/ ← raw ingested sources (append-only, never edit)
239
+ └── .cache/ ← runtime state (gitignored): session index, markers, backups
208
240
  ```
209
241
 
210
- `hypo-config.md` presence is the wiki root marker. `resolveHypoRoot()` scans for it from cwd up to `$HOME`.
242
+ `hypo-config.md` presence is the wiki root marker. `resolveHypoRoot()` checks a fixed list of candidate directories under `$HOME` for it (it does not walk up from the cwd).
211
243
 
212
244
  ---
213
245
 
@@ -216,36 +248,57 @@ Helpers used by command scripts only. Not deployed to hooks.
216
248
  The wiki path is resolved in this order:
217
249
 
218
250
  1. **`HYPO_DIR` environment variable** — explicit override
219
- 2. **`hypo-config.md` scan** — walk from current dir up to `$HOME`
220
- 3. **`~/hypomnema`** — default fallback
251
+ 2. **`hypo-config.md` scan**: the first of these directories that holds a `hypo-config.md` wins: `~/hypomnema`, `~/wiki`, `~/notes`, `~/knowledge`, `~/Documents/hypomnema`, `~/Documents/wiki`, and, in scripts only, `~/Documents/notes`
252
+ 3. **`~/hypomnema`**: default fallback
221
253
 
222
- Hooks inline this logic in `hypo-shared.mjs`. Scripts use `scripts/lib/hypo-root.mjs` (functionally identical, but importable).
254
+ Hooks carry this logic in `hypo-shared.mjs`. Scripts use `scripts/lib/hypo-root.mjs`, which is importable but not identical: its candidate list has the extra `~/Documents/notes` entry that the hook copy lacks.
223
255
 
224
256
  `PKG_ROOT` is stored in `~/.claude/hypo-pkg.json` by `init`. `hypo-personal-check.mjs` uses it to locate the lint script (`PKG_ROOT ?? HYPO_DIR`); `upgrade --apply` detects drift and refreshes it.
225
257
 
226
258
  ---
227
259
 
260
+ ## Install channels
261
+
262
+ Hypomnema reaches a machine through two channels, and they deploy hooks differently.
263
+
264
+ | | Plugin channel | npm channel |
265
+ |---|---|---|
266
+ | Who runs the hooks | The harness, straight from `${CLAUDE_PLUGIN_ROOT}/hooks` as `hooks/hooks.json` says | `~/.claude/hooks/`, registered in `~/.claude/settings.json` |
267
+ | What ships | The whole git-tracked tree of that commit. Observed 2026-10-15 in the plugin cache (`~/.claude/plugins/cache/hypomnema/hypo/1.10.0/`): it holds `scripts/bump-version.mjs`, `scripts/lib/check-tracker-ids.mjs` and `tests/` | The `package.json` `files` allowlist, which names each shipped `scripts/` file one by one. `npm run check:pack-surface` compares the tarball against a committed snapshot |
268
+ | Deploy step | None for the Claude hooks. `--codex` still copies hooks to `~/.codex/hooks` | `init` and `upgrade` copy the hooks and shared modules to `~/.claude/hooks`, then merge `settings.json` |
269
+ | How an update arrives | The installer caches each version in a directory named after `plugin.json`'s `version` string. If the version has not changed, the directory already exists and new commits never reach the installed copy | `npm install -g hypomnema` fetches the new package, then `upgrade --apply` copies again |
270
+
271
+ The consequence for releases: on the plugin channel a fix reaches nobody until the version string moves. `init` and `upgrade` detect an enabled plugin and skip the copy and the `settings.json` merge, because doing both would fire every hook twice. In the steps below, each step is marked `[both]`, `[npm only]`, or `[plugin: skipped]`.
272
+
228
273
  ## Init and upgrade flow
229
274
 
230
275
  ### `/hypo:init`
231
276
 
232
- 1. Wizard collects: wiki path, hook scope (`~/.claude` and/or `~/.codex`), git remote URL.
233
- 2. Creates the vault directory structure: `pages/`, `projects/`, `sources/`, `journal/{daily,weekly,monthly}/`.
234
- 3. Copies `templates/` files: 5 root files (`hypo-config.md`, `index.md`, `log.md`, `SCHEMA.md`, `hypo-guide.md`) and a generated root `hot.md` + 4 helpers (`Home.md`, `Overview.md`, `hypo-automation.md`, `hypo-help.md`) + `pages/_index.md` + `projects/_template/`.
235
- 4. Deploys 10 hooks to `~/.claude/hooks/` and merges entries into `~/.claude/settings.json` (idempotent; preserves non-hypo hooks).
236
- 5. Writes `~/.claude/hypo-pkg.json` with `pkgRoot` for upgrade tracking (skipped when the
277
+ 1. `[both]` Wizard collects: wiki path, hook scope (`~/.claude` and/or `~/.codex`), git remote URL.
278
+ 2. `[both]` Creates the vault directory structure: `pages/`, `pages/observability/`, `projects/`, `sources/`, `journal/{daily,weekly,monthly}/`, and `extensions/{hooks,commands,skills,agents}/` (each with a `.gitkeep`).
279
+ 3. `[both]` Copies `templates/` files: `index.md`, a generated root `hot.md`, `log.md`, `SCHEMA.md`, `hypo-guide.md`, the helpers `Home.md`, `Overview.md`, `hypo-help.md`, `hypo-automation.md`, `pages/_index.md`, `pages/observability/_index.md`, and `projects/_template/` (`index.md`, `prd.md`, `decisions/`, `session-log/`). It then writes `hypo-config.md`, `.hypoignore`, `.hyposcanignore`, `.gitignore` and `.gitattributes`.
280
+ 4. `[npm only]` Copies the hooks and shared modules to `~/.claude/hooks/`, installs the commands under `~/.claude/commands/hypo/`, and merges entries into `~/.claude/settings.json` (idempotent; preserves non-hypo hooks). `[plugin: skipped]` when the plugin is enabled.
281
+ 5. `[both]` Writes `~/.claude/hypo-pkg.json` with `pkgRoot` for upgrade tracking (skipped when the
237
282
  plugin channel judgment fails; see `init.mjs`'s `resolveDurableRoot`).
238
- 6. `git init` + first commit `init: hypomnema wiki`. Pushes to remote when one is provided.
283
+ 6. `[both]` Syncs the vault's `extensions/` into `~/.claude` (with hooks enabled), and into `~/.codex` for `--codex`. The codex mirror covers hooks and commands only; skills and agents are skipped.
284
+ 7. `[both]` Installs the `claude()` shell function into the shell rc file (unless shell setup is turned off).
285
+ 8. `[both]` With `--codex`, copies the hooks to `~/.codex/hooks` and merges `~/.codex/settings.json`. The plugin does not provide this surface.
286
+ 9. `[both]` `git init` for the vault.
287
+ 10. `[both]` Installs the vault's pre-commit hook, which guards `.hypoignore`. It is skipped when the channel judgment fails.
288
+ 11. `[both]` Installs a post-commit hook in the package checkout that re-syncs the hooks after a commit there that changed files under `hooks/`. It is skipped when the package is not a git checkout, which is the case for npm and plugin installs.
289
+ 12. `[both]` First commit `chore: init hypomnema (<date>)`. Pushes to remote when one is provided.
239
290
 
240
291
  `--dry-run` previews; `--no-hooks` and `--no-git-init` are also supported. Re-running `init` is idempotent — existing files are skipped, never overwritten.
241
292
 
242
293
  ### `/hypo:upgrade`
243
294
 
244
- 1. Reads `pkgRoot` from `~/.claude/hypo-pkg.json`.
245
- 2. Re-deploys hooks from the current package version.
246
- 3. Re-merges `settings.json` entries.
247
- 4. Detects drift (e.g., `pkgRoot` no longer exists) and refreshes the pointer.
248
- 5. `--json` output emits `{ schema, hooks, settings, applied }`. `--dry-run` previews; `--apply` commits changes.
295
+ 1. `[both]` Reads `pkgRoot` from `~/.claude/hypo-pkg.json`.
296
+ 2. `[npm only]` Re-deploys hooks from the current package version.
297
+ 3. `[npm only]` Re-merges `settings.json` entries.
298
+ 4. `[both]` Detects drift (e.g., `pkgRoot` no longer exists) and refreshes the pointer.
299
+ 5. `[both]` Re-copies hooks for `--codex` and syncs vault extensions. Only the Claude core surface is skipped on the plugin channel.
300
+ 6. `[both]` The check reports `hypo-guide.md` drift (a vault guide whose stamp is older than the package template's, or that has no stamp) as a warning and a non-zero exit, and `--apply` never overwrites the vault's guide. A stamp ahead of the package, or one that cannot be compared, is a warning without the non-zero exit. To clear drift, run the `diff` command the check prints against the package template, merge the changed parts by hand while keeping your own edits, raise `version:` in the vault guide's frontmatter to the package value, and run the check again. The stamp records that you reviewed the template. It does not prove the contents match.
301
+ 7. `--json` output emits `{ schema, hooks, settings, applied }`. `--dry-run` previews; `--apply` commits changes.
249
302
 
250
303
  The SessionStart hook (`hooks/hypo-session-start.mjs`) is a third writer of this file, outside init/upgrade entirely. `hooks/hypo-shared.mjs`'s `pkgRootDriftStatus` detects drift by comparing the canonicalized `pkgRoot` alone, not `pkgVersion`, so a file whose `pkgRoot` already matches self-location reads as `match` even when its `pkgVersion` is stale. Only a `pkgRoot` mismatch triggers the rewrite (temp file, then rename), and `pkgVersion` is carried along in that same write rather than checked or fixed on its own. The rewrite is guarded by a lockfile, so two sessions self-locating to different install roots at once cannot race each other's write, and it writes nothing on a downgrade (self-location resolves to an older version than the file already records), an incoming version that fails to parse as semver, a lock it could not acquire, or an opted-out session.
251
304
 
@@ -304,8 +357,12 @@ SessionStart
304
357
  │ ├─► hypo-lookup.mjs (BM25 inject)
305
358
  │ └─► hypo-compact-guard.mjs (report incomplete checklist; never blocks /compact)
306
359
  │
307
- ├─► PostToolUse(Write/Edit)
308
- │ └─► hypo-auto-stage.mjs (git add)
360
+ ├─► PreToolUse
361
+ │ └─► hypo-close-guard.mjs (denies a direct write to a generated view or a session entry)
362
+ │
363
+ ├─► PostToolUse (no matcher; each hook filters on tool_name)
364
+ │ ├─► hypo-auto-stage.mjs (Write/Edit/MultiEdit: git add)
365
+ │ └─► hypo-web-fetch-ingest.mjs (WebFetch/WebSearch: nudge toward /hypo:ingest, never ingests)
309
366
  │
310
367
  ├─► PreCompact
311
368
  │ └─► hypo-personal-check.mjs (lint + session-close gate)
@@ -313,12 +370,18 @@ SessionStart
313
370
  ├─► CwdChanged
314
371
  │ └─► hypo-cwd-change.mjs (builds a hot.md notice on systemMessage; the reference documents the terminal notification, tracing the installed binary as of Claude Code 2.1.276, checked 2026-09-18 and reconfirmed on 2.1.283, shows it does not reach the model, and no live-session observation exists either way; see the hook table above)
315
372
  │
316
- └─► Stop
317
- └─► hypo-stop.mjs, running four stages in order:
318
- ├─► hypo-hot-rebuild.mjs (regenerate the session views + growth cache; SessionStart also calls this generator)
319
- ├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
320
- ├─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
321
- └─► hypo-auto-minimal-crystallize.mjs (blocks Stop if session close never ran)
373
+ ├─► FileChanged
374
+ │ └─► hypo-file-watch.mjs (builds a notice for a changed wiki file; no trigger on a stock install, treated as not reaching the model; see the hook table above)
375
+ │
376
+ ├─► Stop
377
+ │ └─► hypo-stop.mjs, running four stages in order:
378
+ │ ├─► hypo-hot-rebuild.mjs (regenerate the session views + growth cache; SessionStart also calls this generator)
379
+ │ ├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
380
+ │ ├─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
381
+ │ └─► hypo-auto-minimal-crystallize.mjs (blocks Stop if session close never ran)
382
+ │
383
+ └─► SessionEnd
384
+ └─► hypo-session-end.mjs (on /clear only: writes .cache/clear-marker.json for the next SessionStart, and the close-gate resolution record .cache/close-gate/<session-id>.json)
322
385
  ```
323
386
 
324
387
  Root hot.md's frontmatter `updated:` is the max of every project row date, and a row's date is the
@@ -481,7 +544,7 @@ tests/
481
544
  ├── helpers.mjs ← fixtures used by more than one area file
482
545
  ├── runner.mjs ← the entry: imports every *.test.mjs, owns no tests itself
483
546
  ├── parallel.mjs ← runs the entry as N concurrent processes (`npm test`)
484
- └── <area>.test.mjs ← one file per production area (26 of them)
547
+ └── <area>.test.mjs ← one file per production area
485
548
  ```
486
549
 
487
550
  Tests live next to the thing they test: `extensions.test.mjs`, `lint.test.mjs`,
@@ -493,9 +556,10 @@ Everything uses Node built-ins only. Fixtures are built in code with scoped help
493
556
 
494
557
  **Selection is by suite, never by test.** `suite(label)` opens a selection unit, and
495
558
  `--shard=i/n` keeps a whole suite in one process, in order. So tests inside one suite may build
496
- on each other, and suites may not build on each other. `node tests/parallel.mjs --shards=237`
497
- runs every suite alone in a fresh process and is the proof of that invariant. Run it whenever
498
- you add or split a suite.
559
+ on each other, and suites may not build on each other. `node tests/parallel.mjs --shards=<n>`,
560
+ with `n` at or above the current `suite()` count, runs every suite alone in a fresh process and
561
+ is the proof of that invariant. A smaller `n` stops proving it. Run it whenever you add or split
562
+ a suite.
499
563
 
500
564
  | Command | What it does |
501
565
  |---|---|
@@ -504,11 +568,11 @@ you add or split a suite.
504
568
  | `node tests/runner.mjs --grep=<regex>` | just the matching tests, in seconds |
505
569
  | `node tests/runner.mjs --file=lint` | one area file |
506
570
  | `node tests/parallel.mjs --by-file` | one process per area file |
507
- | `node tests/parallel.mjs --shards=237` | every suite alone: the order-independence proof |
571
+ | `node tests/parallel.mjs --shards=<n>` | every suite alone, with `n` at or above the `suite()` count: the order-independence proof |
508
572
 
509
573
  Round-robin is the default rather than one-process-per-file, and the reason is measured, not
510
574
  assumed: on a 2026 laptop the whole suite runs in about 70s round-robined and about 100s
511
- `--by-file`. The areas differ by an order of magnitude in size, and `--by-file` starts all 26
575
+ `--by-file`. The areas differ by an order of magnitude in size, and `--by-file` starts every area
512
576
  at once, which oversubscribes the cores. The big files (`extensions`, `proposal-base`,
513
577
  `close-global`) then finish last under contention while the small ones exit in seconds and
514
578
  leave the tail of the run half idle. Reach for `--by-file` to read one area's output, not to go
@@ -572,7 +636,7 @@ Requires `NPM_TOKEN` secret.
572
636
  {
573
637
  "type": "command",
574
638
  "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/hypo-<name>.mjs",
575
- "timeout": <ms>
639
+ "timeout": <seconds>
576
640
  }
577
641
  ]
578
642
  }
@@ -587,7 +651,7 @@ The shared-file list (helpers a registered hook imports, such as `hypo-shared.mj
587
651
  ["hypo-shared.mjs", "version-check.mjs", "..."]
588
652
  ```
589
653
 
590
- `hooks/hooks.json` is still the source of truth for the event-to-hook map; `hooks/shared.json` is the source of truth for the shared-file list. A hook registered in the former, or a helper listed in the latter, is what init/upgrade deploy. Anything left off either file never reaches an installed copy.
654
+ `hooks/hooks.json` is still the source of truth for the event-to-hook map; `hooks/shared.json` is the source of truth for the shared-file list. A hook registered in the former, or a helper listed in the latter, is what init/upgrade copy on the npm channel. A hook left off the former never fires on the plugin channel either, because the harness reads that file. A helper left off the latter still resolves on the plugin channel, where the whole tree is present, and fails with `ERR_MODULE_NOT_FOUND` only in installs that copy the hooks (npm, a checkout deployed with init or upgrade, `--codex` copies) (see the "What ships" row under Install channels).
591
655
 
592
656
  `claude plugin validate .` is run as part of CI's plugin-snapshot checks.
593
657
 
@@ -60,13 +60,9 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown. Short version:
60
60
 
61
61
  ## Hook deployment constraint
62
62
 
63
- **Hooks run in `~/.claude/hooks/` in isolation. They cannot import from relative paths.**
63
+ Code that hooks and scripts share lives in `hooks/` and is listed in `hooks/shared.json`. Hooks do not import from `scripts/`, with one exception. The reasoning and the exception are in the [Deployment constraint](ARCHITECTURE.md#deployment-constraint) section of ARCHITECTURE.md.
64
64
 
65
- - Shared logic must live in one of the modules `hooks/shared.json` declares, not only in `hypo-shared.mjs`. That file (a plain JSON array of `.mjs` basenames) is the source of truth for the list and is copied alongside each hook at deploy time. It used to be a `shared` field inside `hooks.json` itself; that moved to this sibling file once the harness started warning on that unknown top-level key.
66
- - Hook utilities may use **only** Node.js built-ins (`fs`, `path`, `os`, `child_process`, `crypto`).
67
- - No relative imports, no npm dependencies.
68
-
69
- Scripts under `scripts/` are not deployed and may freely import from `scripts/lib/`. If you find yourself wanting to share logic between scripts and hooks, the canonical pattern is: implement it inline in `hypo-shared.mjs`, then have `scripts/lib/` mirror the function (or thin-wrap it via the deployed copy).
65
+ Hook code may use only Node.js built-ins and sibling modules in `hooks/`, with no npm dependencies.
70
66
 
71
67
  ---
72
68
 
@@ -86,14 +82,14 @@ Scripts under `scripts/` are not deployed and may freely import from `scripts/li
86
82
  2. If it's new, register it in `hooks/hooks.json` under the correct event key.
87
83
  3. Shared utilities go in a module listed in `hooks/shared.json`, usually `hypo-shared.mjs`.
88
84
  4. Add a contract test in the hook's area file, e.g. `tests/session-hooks.test.mjs` (input → expected `additionalContext` shape).
89
- 5. After your change, run `/hypo:upgrade` in a real Claude Code session and verify the hook fires.
85
+ 5. After your change, load the checkout with `claude --plugin-dir <path to your checkout>`, trigger the hook's event in that session, and confirm the hook path the session runs is under the checkout. `/hypo:upgrade` is not a check for this: it uses the installed package, and in plugin mode `--apply` skips the core hook deployment. If the installed plugin is enabled too, the two copies can compete, so disable it for the test.
90
86
 
91
87
  ### Modifying `hypo-shared.mjs`
92
88
 
93
- This file is deployed verbatim to `~/.claude/hooks/`. Constraints:
89
+ On the npm channel this file is copied verbatim to `~/.claude/hooks/`. On the plugin channel the harness runs it from the plugin directory. Constraints:
94
90
 
95
91
  - Only Node.js built-ins.
96
- - No relative imports.
92
+ - No imports outside `hooks/`.
97
93
  - No npm dependencies.
98
94
  - Keep exports minimal — the file ships in every wiki install.
99
95
 
@@ -231,8 +227,8 @@ For non-trivial changes, request a second review before merging — a peer revie
231
227
  Before opening:
232
228
 
233
229
  1. `npm test` and `npm run lint` both pass.
234
- 2. If you changed a hook, you ran the manual verification step in a real Claude Code session.
235
- 3. If you changed `hypo-shared.mjs`, the deployed copy works after `/hypo:upgrade`.
230
+ 2. If you changed a hook, you ran the manual verification step: a session started with `claude --plugin-dir <path to your checkout>` that triggers the event and runs the hook from the checkout.
231
+ 3. If you changed `hypo-shared.mjs`, the copied hook also works after `/hypo:upgrade` on the npm channel. On the plugin channel the installed plugin directory is what runs for users, and it picks up the change only after a release moves the version string.
236
232
  4. If you changed a template or `init` flow, a fresh `init` produces the expected vault structure.
237
233
  5. README / ARCHITECTURE / docs are updated to match.
238
234
 
@@ -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