hypomnema 1.8.2 → 1.8.4

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.
package/README.md CHANGED
@@ -265,7 +265,7 @@ Nine commands cover the full capture → retrieval → consolidation cycle.
265
265
 
266
266
  Both PostToolUse hooks are registered without a matcher and filter on `tool_name` themselves.
267
267
 
268
- All hooks resolve the wiki root via `HYPO_DIR` env → `hypo-config.md` scan → `~/hypomnema` default, and share `hypo-shared.mjs` (declared via `hooks.json`'s `shared` field).
268
+ All hooks resolve the wiki root via `HYPO_DIR` env → `hypo-config.md` scan → `~/hypomnema` default, and share `hypo-shared.mjs` (declared in `hooks/shared.json`, a JSON array of `.mjs` basenames).
269
269
 
270
270
  When the Stop hook's pull hits a merge conflict it aborts the merge, skips the push, and records the failure. Your local commits are safe, but the machines stop converging until you act: `git -C <your wiki> pull --no-rebase`, fix the conflicts, commit, push. `/hypo:doctor` surfaces a stuck sync.
271
271
 
@@ -386,7 +386,7 @@ Place a `hypo-config.md` at the wiki root to make it portable across machines wi
386
386
 
387
387
  `.hypoignore` controls which paths the hooks ignore (default: `*.pdf`, `*.zip`, `*.pem`, `.env*`, `*credentials*`, `*secret*`, `*token*`, `*password*`, `*passwd*`, …). Edit it directly; there is no privacy mode flag. One file, one source of truth.
388
388
 
389
- > `init` also installs a pre-commit hook into the wiki repo at `<wiki>/.git/hooks/pre-commit`, marked `# hypo-managed:pre-commit:*`. It refuses any commit that stages a path matching `.hypoignore`; unstage the file, or override with `git commit --no-verify`. The hook embeds the absolute path of the installed package, so if you move or reinstall Hypomnema, this path goes stale and every wiki commit fails. `hypomnema upgrade --apply` (or `/hypo:upgrade`, confirming the apply step, on a plugin install) repoints it in most cases, since a plugin-channel version bump is exactly the case it is built to detect; re-run `hypomnema init` when `upgrade --apply` cannot, which happens for a manual/npm install running alongside an enabled plugin whose active root it cannot positively resolve, or when the vault itself (not just the package) has moved, since `upgrade` only ever corrects the embedded root, never the embedded `--hypo-dir`. `uninstall --apply` removes this hook, resolving the vault the same way `init` does (`--hypo-dir=<path>` to override); it only removes a hook that still carries the marker and no content outside it, so a hook you have since edited by hand is left in place. Pass `--keep-wiki-hook` to skip this wiki hook cleanup (the rest of the uninstall still runs).
389
+ > `init` also installs a pre-commit hook into the wiki repo at `<wiki>/.git/hooks/pre-commit`, marked `# hypo-managed:pre-commit:*`. It refuses any commit that stages a path matching `.hypoignore`; unstage the file, or override with `git commit --no-verify`. The hook does not bake in an install path: it resolves the active Hypomnema install itself, at commit time, from `~/.claude/hypo-pkg.json`'s `pkgRoot` and, failing that, the plugin registry (`~/.claude/plugins/installed_plugins.json`, both the `hypo@hypomnema` and the legacy `hypomnema@hypomnema` entries, user-scope first). A candidate only counts when it is an absolute path whose `package.json` names `hypomnema` and carries a version, and whose script is actually there — anything looser and a `pkgRoot` you did not write could run in your vault. When nothing resolves, it refuses the commit and prints what it checked and how to fix it, rather than silently skip the `.hypoignore` guard. Resolving this way costs a second Node start per step, about 80ms, so a `--lint-strict` vault pays roughly 165ms per commit. A hook an older release wrote may still carry the version-pinned form (an absolute install path baked in at `init` time, which goes stale on every move or reinstall); `hypomnema upgrade --apply` (or `/hypo:upgrade`, confirming the apply step, on a plugin install) migrates it onto the self-resolving form, even when the baked-in path no longer exists on disk. A hook whose body cannot be recognized at all (hand-edited, corrupted) is different: `doctor` and `upgrade` both warn about it instead of touching it, and the fix is `hypomnema init --force-commands` (or the plugin's `/hypo:init --force-commands`) to reinstall the guard, not `upgrade --apply`. Moving the vault itself, not just the package, still needs a fresh `hypomnema init`: `upgrade` only ever fixes the install-root resolution, never a `--hypo-dir` a `--lint-strict` hook already baked in. `uninstall --apply` removes this hook, resolving the vault the same way `init` does (`--hypo-dir=<path>` to override); it only removes a hook that still carries the marker and no content outside it, so a hook you have since edited by hand is left in place. Pass `--keep-wiki-hook` to skip this wiki hook cleanup (the rest of the uninstall still runs).
390
390
 
391
391
  > The credential-style patterns above are substring globs, so an ordinary page such as `pages/oauth-token-refresh.md` is matched too: it is never injected by any hook, and the wiki pre-commit hook refuses to commit it. If you write about these topics, narrow the patterns or rename the page.
392
392
 
@@ -61,7 +61,9 @@ Field rules:
61
61
 
62
62
  Notes:
63
63
 
64
- - `sessionState` / `projectHot` / `rootHot` / `openQuestions` are **overwrite** (full-file content). `sessionLog` / `log` are **append** (entry-level idempotency — exact-entry dedup, safe to re-run).
64
+ - `sessionState` / `projectHot` / `rootHot` / `openQuestions` are **overwrite** (full-file content); `sessionLog` / `log` are **append** (entry-level idempotency, exact-entry dedup, safe to re-run).
65
+ - **Overwrite means overwrite: whatever you did not carry into the payload is gone.** A single session's close can legitimately touch only one thread of a file that tracks several (a project running two or three tracks in parallel is normal). Before composing an overwrite field, read the CURRENT on-disk file and carry forward every `##` section this session did not touch, verbatim; do not reconstruct a section from memory or drop it because this session had nothing new to add to it. This session's own edits go on top of that, not in place of it.
66
+ - Apply enforces the same rule mechanically: if an overwrite field's payload drops 2 or more of the file's existing `##` sections, apply withholds that field (parks it exactly like a base conflict, as a proposal artifact a human reviews and can force through) rather than writing it. If you are deliberately consolidating or retiring sections on purpose (the user asked for it, or the sections genuinely no longer apply), add `"restructure": true` next to that field's `"content"` to say so explicitly; it only waives the check for that one field, not the whole payload. A waiver that actually waived something, not just a `true` set on a field that never dropped anything, shows up in the result's `restructureWaivers[]` (see Step 4's close-result reporting).
65
67
  - Frontmatter `updated:` is NOT auto-fixed. If your payload's `updated:` is stale, the post-apply verification gate will fail with `stage='post-apply-verification'` and you must fix the payload and retry.
66
68
  - Write the payload to a **session-scoped** temp path, e.g. `/tmp/hypo-session-close-<session-id>.json`, using the same `<session-id>` you pass below. A date-based path (`<YYYY-MM-DD>`) collides when two sessions close on the same day: the second write silently overwrites the first, and the wrong payload gets applied. The `sessionId` field above is the second line of defense if a path is ever reused anyway.
67
69
 
@@ -163,6 +165,9 @@ The result JSON includes a `stage` field when `ok: false`. Branch on it:
163
165
  | `post-apply-verification` | A mandatory file's `updated:` frontmatter is stale (≠ today) after apply. | Edit the payload's stale `content` (or supply correct `date`), then re-run. Writes are idempotent — re-applying a corrected payload is safe. |
164
166
  | `post-apply-lint` | The payload introduced an error-level lint blocker in a payload file (malformed body / bad frontmatter), or lint crashed. | Fix the offending content in the payload, then re-run. (Broken wikilinks are W4 warnings — not gated.) |
165
167
  | `post-apply-verification+lint` | Both above. | Fix both; re-run. |
168
+ | `proposal-pending` (overwrite park) | At least one overwrite field drifted from this session's observed base, or its payload dropped 2 or more of an existing file's `##` sections without `restructure: true`. Each withheld field appears in `conflicts[]` with a `reason` code and a `why` (a ready-to-read sentence naming the cause, reuse it verbatim, don't reword it), and is parked as a `.cache/proposals/<id>.json` artifact; `proposals[]` lists the id, target, and path for every one. The target on disk is untouched. Other, non-conflicting fields in the same payload may already be on disk but uncommitted (`partialConflict: true`, `appliedUncommitted`). | Do not touch the parked file yourself, and do not tell the user to run `hypomnema proposal apply <id>`: that command reads from a TTY and is refused outright in this environment (no terminal attached). Use the transcript-approved path instead. Run `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs challenge --session-id=<current-session-id> --ids=<comma-separated ids from proposals[]>`. It prints a diff per target and mints a nonce; tell the user the exact line it prints (`apply-proposals <nonce>`, read the nonce off that output, never invent or reuse one), and once they have typed it back in the conversation, run `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs resolve --session-id=<current-session-id>` to apply the approved batch. `node ${CLAUDE_PLUGIN_ROOT}/scripts/proposal.mjs list` and `... discard <id>` (drop a proposal without applying it) both work with no TTY. Once resolved, re-run the same close; it is idempotent. |
169
+ | `proposal-pending` (append lock timeout) | An append field (`sessionLog` or `log`) could not take its file lock within the timeout (default 5s), usually a concurrent close holding the same target. Nothing is parked for it: it carries `kind: 'append'` in `conflicts[]` and never appears in `proposals[]`. | This is transient, not a review case. Do not run any `proposal` subcommand for it, there is nothing there to challenge or apply. Just re-run the same close; the next attempt normally clears once the other close releases the lock. |
170
+ | `proposal-store-failed` | A conflict was correctly withheld from disk, but writing its `.cache/proposals/` artifact also failed (see `proposalStoreFailures[]` and the loud stderr). The payload bytes for that field are on neither disk nor a proposal artifact right now. | Fix whatever is blocking `.cache/proposals/` (permissions, a file sitting where the directory should be, disk space), then re-run the exact same payload. Nothing was lost: the payload still holds the bytes, only the parking step failed. |
166
171
 
167
172
  Once `ok: true`, report from the result JSON's `applied` and `skipped` arrays together, not `applied` alone. `applied` lists only the fields this run actually wrote bytes for; `skipped` lists the fields that already matched what was on disk (a re-run of an already-applied payload). An idempotent re-run legitimately reports `applied: []`, and that is success, not a failure to report on: check `skipped` for the same 4-6 entries instead. Read `committed` the same way: `true` covers both a real commit and the case where nothing needed staging (a full no-op re-run); `false` means the commit itself ran and failed (see `markerSkipReason`); `null` means apply never reached the commit step at all, because `ok` was already false (an authority refusal before any write, or a verification/lint failure, or a withheld conflict, per `stage`). `null` does NOT mean nothing was written: `applied` can be non-empty (bytes landed on disk) while `committed` stays `null`, because those bytes were never staged into git.
168
173
 
@@ -172,6 +177,7 @@ Once `ok: true`, report from the result JSON's `applied` and `skipped` arrays to
172
177
  - ✓ open-questions applied (or skipped if unchanged)
173
178
  - ✓ log.md entry appended (or already present)
174
179
  - ✓ post-apply lint clean
180
+ - **`restructureWaivers[]`** (report it if non-empty): an overwrite field carried `"restructure": true`, and that flag actually waived a real section-loss trip (the payload dropped `##` sections the guard would otherwise have parked). Report each entry verbatim: the target file and the section names the waiver let through. The waiver is set by the same party (a model composing the payload) the section-loss guard exists to check, so it must never pass silently.
175
181
  - **marker written?** (required check): if `markerWritten: true`, report "session-close marker written"; if `markerWritten: false`, report "session-close marker NOT written (reason: `<markerSkipReason>`)" and do NOT declare the session "closed" or "complete". A missing marker means the Stop-chain is still open; recover per the `markerSkipReason` branch below.
176
182
 
177
183
  If `markerWritten: true`: ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
@@ -182,12 +188,17 @@ If `markerWritten: true`: ask: "Session closed. Would you like to also run knowl
182
188
  - `transcript-unresolved`: the id resolved no transcript, so it is almost certainly not the main conversation's (a background-task or Agent-thread uuid, most often). Get the right one and re-run.
183
189
  - `no-user-close-signal`: the transcript is this session's, but no close authority is in force. **Branch on `gateReason` before doing anything.** The three cases need three different responses, and treating them alike is what made this refusal look like it had a new cause every time it appeared.
184
190
  - `no-open`: the user genuinely never asked to close in wording the gate recognizes (e.g. "세션 마무리까지 진행해줘" falls outside the close-signal set). Re-running the same id changes nothing, because the transcript is unchanged. Confirm intent once with `AskUserQuestion`, header "세션", a single option labelled **세션 마무리** (설명: "이 세션을 마무리하고 close 마커를 기록"). If the user picks it, that answer lands in the transcript as a recognized close signal, so re-running the exact same command now applies **everything**: the writes, the commit, and the marker. If the user declines, the session stays open and nothing is written.
185
- - `no-new-open-since-resolution`: the user DID ask, and that request was already resolved by an earlier close. Asking again makes them answer a question they have already answered. Report that this session is already closed and stop; do not re-prompt.
191
+ - `no-new-open-since-resolution`: the user DID ask, and that request was already resolved by an earlier close. Asking again makes them answer a question they have already answered. Report that this session is already closed and stop; do not re-prompt. On the apply path, a resolution is recorded only once the session-close marker itself lands, so this reason cannot appear for a session whose apply was never marked closed. `/clear` is the one exception: it ends the session as surely as a close apply does, so `hypo-session-end.mjs` records the same resolution on a `/clear` directly, with no marker involved at all. So a session that never ran an apply, but was ended with `/clear`, can still carry a recorded resolution.
186
192
  - `transcript-rewrite-detected`: the transcript changed underneath the gate, so the earlier signal can no longer be attested. This is not a statement about what the user wants. Say what happened rather than asking them to repeat themselves, and let a human decide.
187
193
 
188
194
  In all three: do NOT touch the close-signal matcher itself, and do not hand-write the files to work around the refusal.
189
195
 
190
- If the apply succeeded but `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason` (`compact-gate-not-ok`, `commit-failed: …`, `marker-did-not-land`): surface the reason verbatim and address it (resolve the compact blocker, fix the git / disk issue) before re-running.
196
+ If the apply succeeded but `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`, which carries one of five values:
197
+
198
+ - `compact-gate-not-ok`, `commit-failed: …`, `marker-did-not-land`: surface the reason verbatim and address it (resolve the compact blocker, fix the git / disk issue) before re-running. **The re-run needs no fresh close phrase from the user for these three.** 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.
199
+ **A `commit-failed:` retry can need a hand first.** The payload writes land before the commit does, so after a failed commit those files already match what the retry would write. The retry skips them as already current, which leaves them out of the commit it makes, and they stay uncommitted and keep blocking the marker. If a retry reports `commit-failed:` or `compact-gate-not-ok` a second time on the same close, 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.
200
+ - `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.
201
+ - `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.
191
202
 
192
203
  If the user says stop, end here. Otherwise continue to Step 5.
193
204
 
@@ -143,8 +143,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
143
143
 
144
144
  Hooks run in an isolated environment at `~/.claude/hooks/`. They **cannot import from relative paths** outside their own directory. Therefore:
145
145
 
146
- - All shared hook logic lives in `hypo-shared.mjs`.
147
- - `hypo-shared.mjs` is declared via the `shared` field in `hooks.json` and copied alongside hooks at deploy time.
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.
148
147
  - Hook utilities use **only Node.js built-ins** — no relative imports, no npm dependencies.
149
148
 
150
149
  Scripts in `scripts/` are not deployed — they run from the package install path — so they can import from `scripts/lib/`.
@@ -246,6 +245,8 @@ Hooks inline this logic in `hypo-shared.mjs`. Scripts use `scripts/lib/hypo-root
246
245
  4. Detects drift (e.g., `pkgRoot` no longer exists) and refreshes the pointer.
247
246
  5. `--json` output emits `{ schema, hooks, settings, applied }`. `--dry-run` previews; `--apply` commits changes.
248
247
 
248
+ 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.
249
+
249
250
  ### `/hypo:uninstall`
250
251
 
251
252
  Removes hypo-prefixed hooks from `~/.claude/hooks/` and matching entries from `~/.claude/settings.json`. **Non-hypo hooks are preserved**. `--apply` also reaches past `~/.claude/`: it strips the marked `claude()` block from the shell rc file(s) init wrote to, and removes the marked pre-commit hook from the wiki's own git repo. Both removals are marker-gated the same way the hooks-dir cleanup is, so a hand-edited block or hook is left in place rather than guessed at.
@@ -501,7 +502,7 @@ Requires `NPM_TOKEN` secret.
501
502
 
502
503
  ## Plugin manifest
503
504
 
504
- `.claude-plugin/plugin.json` declares the plugin to Claude Code. `hooks/hooks.json` follows the standard plugin hooks schema:
505
+ `.claude-plugin/plugin.json` declares the plugin to Claude Code. `hooks/hooks.json` follows the standard plugin hooks schema, and carries only what that schema knows about: an event-to-hook map, nothing else.
505
506
 
506
507
  ```json
507
508
  {
@@ -517,11 +518,18 @@ Requires `NPM_TOKEN` secret.
517
518
  ]
518
519
  }
519
520
  ]
520
- },
521
- "shared": ["hypo-shared.mjs"]
521
+ }
522
522
  }
523
523
  ```
524
524
 
525
+ The shared-file list (helpers a registered hook imports, such as `hypo-shared.mjs`) is Hypomnema's own convention, not something the harness's schema recognizes, so it lives in a sibling file instead: `hooks/shared.json`, a plain JSON array of `.mjs` basenames.
526
+
527
+ ```json
528
+ ["hypo-shared.mjs", "version-check.mjs", "..."]
529
+ ```
530
+
531
+ `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.
532
+
525
533
  `claude plugin validate .` is run as part of CI's plugin-snapshot checks.
526
534
 
527
535
  ---
@@ -62,8 +62,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the full breakdown. Short version:
62
62
 
63
63
  **Hooks run in `~/.claude/hooks/` in isolation. They cannot import from relative paths.**
64
64
 
65
- - All shared logic must live in `hooks/hypo-shared.mjs`.
66
- - `hypo-shared.mjs` is declared via the `shared` field in `hooks/hooks.json` and copied alongside each hook at deploy time.
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.
67
66
  - Hook utilities may use **only** Node.js built-ins (`fs`, `path`, `os`, `child_process`, `crypto`).
68
67
  - No relative imports, no npm dependencies.
69
68
 
@@ -85,7 +84,7 @@ Scripts under `scripts/` are not deployed and may freely import from `scripts/li
85
84
 
86
85
  1. Edit the hook file in `hooks/`.
87
86
  2. If it's new, register it in `hooks/hooks.json` under the correct event key.
88
- 3. Shared utilities go in `hooks/hypo-shared.mjs`.
87
+ 3. Shared utilities go in a module listed in `hooks/shared.json`, usually `hypo-shared.mjs`.
89
88
  4. Add a contract test in the hook's area file, e.g. `tests/session-hooks.test.mjs` (input → expected `additionalContext` shape).
90
89
  5. After your change, run `/hypo:upgrade` in a real Claude Code session and verify the hook fires.
91
90
 
@@ -256,7 +255,7 @@ After both blocks, language-neutral:
256
255
 
257
256
  - **PR title**: Conventional Commits plus a scope, e.g. `feat(feedback): add failure_type enum`. The type drives the CHANGELOG section (see the classification table below).
258
257
  - **Merge commit**: the squash-merge subject carries the PR number (`#123`). That is where `#N` comes from, not the PR title. The two conventions stay separate.
259
- - Internal tracker ids (`FEAT-`, `IMPR-`, `ISSUE-`, `PRAC-`, `fix #N`) may appear in your local notes and in `tests/` (where they aid test-to-issue traceability and never reach an installed user), but not in shipped code or workflow comments, and never on the published changelog and release surface: not in the CHANGELOG body, not in the PR `## Changelog` block, not in a tag annotation, not in a GitHub Release. The only tracker identifier that ships in those is the PR number `#N`; the lone exception is the ADR carve-out noted below. `check-tracker-ids` gates the file, message, and tag surfaces (`--all`/`--staged` for files, `--commit-msg` for messages, `--tag` for the tag body), and `check-pr-surface` gates the PR title and body; the migration keeps the CHANGELOG body clean. `ADR NNNN` / `decisions/NNNN` are exempt on the changelog surfaces (the CHANGELOG body, the tag body, and the PR `## Changelog` block), where a release line legitimately cites the decision behind it.
258
+ - Internal tracker ids (`FEAT-`, `IMPR-`, `ISSUE-`, `PRAC-`, `fix #N`, `ADR NNNN`, `decisions/NNNN`) may appear in your local notes and in `tests/` (where they aid test-to-issue traceability and never reach an installed user), but not in shipped code or workflow comments, and never on the published changelog and release surface: not in the CHANGELOG body, not in the PR `## Changelog` block, not in a tag annotation, not in a GitHub Release. The only identifier that ships in those is the PR number `#N`. `ADR NNNN` / `decisions/NNNN` used to be exempt on the changelog surfaces, on the theory that a release line legitimately cites the decision behind it; that exemption is gone, because this repo ships no `decisions/` directory for anyone outside the maintainer's private wiki to open — an external reader hits the same dead end on `decisions/NNNN` as on `ISSUE-N`, so the exemption was blocking nothing but its own contradiction. `check-tracker-ids` gates the file, message, and tag surfaces (`--all`/`--staged` for files, `--commit-msg` for messages, `--tag` for the tag body), and `check-pr-surface` gates the PR title and body; the migration keeps the CHANGELOG body clean.
260
259
 
261
260
  ### The `## Changelog` block
262
261
 
@@ -372,6 +371,14 @@ node scripts/bump-version.mjs <new-semver> # e.g. 1.2.2 or 1.3.0-rc.1
372
371
  # version-consistency gate stay green (the lock carries the version twice).
373
372
  npm install --package-lock-only
374
373
 
374
+ # 1c. If this release also bumps templates/SCHEMA.md's `version:` frontmatter,
375
+ # add a matching line to SCHEMA_VERSION_DELTAS in
376
+ # scripts/lib/template-schema-version.mjs, naming what that version added.
377
+ # Skipping this is silent: upgrade.mjs's notice just falls back to "review
378
+ # manually" for that version, with no test failing at bump time except
379
+ # the one in tests/upgrade.test.mjs that pins the SHIPPED version has an
380
+ # entry.
381
+
375
382
  # 2. Draft the new CHANGELOG.md section from the merged PRs' `## Changelog`
376
383
  # blocks (see CHANGELOG conventions above). The collector prints a draft for
377
384
  # the range since the last tag; paste it, then finalize the wording by hand,
@@ -406,8 +413,10 @@ git commit -m "chore: release v<version>"
406
413
  # then a Korean summary block. The GitHub Release republishes this body
407
414
  # verbatim, so keep it on the same public surface as the CHANGELOG: PR
408
415
  # numbers (#N) only, no `FEAT-`/`IMPR-`/`ISSUE-`/`PRAC-`/`fix #N` tracker
409
- # ids (check-tracker-ids --tag gates it). Like the CHANGELOG, the tag body MAY cite
410
- # `ADR NNNN` / `decisions/NNNN` for the decision behind a release.
416
+ # ids (check-tracker-ids --tag gates it), and no `ADR NNNN` / `decisions/NNNN`
417
+ # either: this repo ships no `decisions/` directory, so a reader outside the
418
+ # maintainer's private wiki cannot open either one. Say what changed in
419
+ # plain prose, or cite the PR number.
411
420
  git tag -a v<version> -m "$(cat <<'EOF'
412
421
  Hypomnema v<version>: <one-line English summary>
413
422
 
@@ -421,7 +430,12 @@ Hypomnema v<version>: <한 줄 한글 요약>
421
430
  EOF
422
431
  )"
423
432
 
424
- # 6. Rehearse the release locally (same checks CI runs against the tag)
433
+ # 6. Rehearse the release locally (same checks CI runs against the tag).
434
+ # This is the ONLY gate that runs before the tag object is public. The
435
+ # release workflow re-runs these, but it starts when the push in step 7
436
+ # lands, so by then the annotated tag body is already on GitHub: CI can
437
+ # stop the npm publish and the Release, not the tag. Skip these three and
438
+ # nothing catches a leaked id until a reader finds it.
425
439
  node scripts/check-bilingual.mjs --tag v<version>
426
440
  node scripts/check-versions.mjs --tag v<version>
427
441
  node scripts/check-tracker-ids.mjs --tag v<version> # no tracker ids leak into the tag body
@@ -0,0 +1,128 @@
1
+ // hooks/close-journal.mjs — the close journal: which relative paths THIS
2
+ // session's close actually wrote, and the hash it wrote each one as.
3
+ //
4
+ // Lives in hooks/ for the same reason close-gate-store.mjs does: scripts/
5
+ // imports hooks/, never the reverse, and a hook copied standalone into
6
+ // ~/.claude/hooks/ cannot resolve a scripts/ import. Node built-ins only.
7
+ //
8
+ // Why this exists: applySessionClose treats a payload field as "already
9
+ // current" (an overwrite whose disk bytes already match the payload, or an
10
+ // append whose entry is already present) and skips writing it — the whole
11
+ // point of that skip is to make a same-payload retry cheap. But a close that
12
+ // partially failed (one field conflicted, the commit itself failed, the
13
+ // process was killed between write and commit) can leave EARLIER fields
14
+ // already written to disk and still uncommitted. The retry's skip then drops
15
+ // those paths out of the commit scope entirely, because "already current"
16
+ // and "already committed" look identical from the skip branch alone — and
17
+ // nothing ever re-adds them, so the close gate blocks on the same dirty
18
+ // files forever. The journal is what tells the retry apart: a path recorded
19
+ // here, whose disk hash still matches what was recorded, is this session's
20
+ // own uncommitted work and safe to restage. A path with no record, or one
21
+ // whose disk hash has since moved, is not — somebody else's bytes are
22
+ // sitting there, and the gate should keep blocking on them, not silently
23
+ // fold them into this close's commit.
24
+ //
25
+ // Mirrors close-gate-store.mjs's shape: same `.cache/<name>/<sessionId>.json`
26
+ // path convention, same atomic tmp+rename write, same "unreadable or corrupt
27
+ // reads as empty" polarity. A journal this module cannot trust must only ever
28
+ // NARROW what a retry is allowed to restage, never widen it — the same
29
+ // direction close-gate-store's NO_CONSTRAINT keeps for its own file.
30
+
31
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
32
+ import { dirname, join } from 'node:path';
33
+
34
+ /** `<hypoDir>/.cache/close-journal/<sessionId>.json`. */
35
+ export function closeJournalPath(hypoDir, sessionId) {
36
+ return join(hypoDir, '.cache', 'close-journal', `${sessionId}.json`);
37
+ }
38
+
39
+ /** Atomic overwrite via tmp+rename, mirroring close-gate-store's atomicWrite. */
40
+ function atomicWrite(path, content) {
41
+ mkdirSync(dirname(path), { recursive: true });
42
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
43
+ writeFileSync(tmp, content);
44
+ renameSync(tmp, path);
45
+ }
46
+
47
+ /**
48
+ * Read this session's journal. Absent, corrupt, or wrong-shaped reads as
49
+ * `{}` — never partially trusted. A caller that widened a retry off a
50
+ * malformed record here would be exactly the failure mode this file exists
51
+ * to avoid, so any doubt about the shape falls back to "nothing recorded".
52
+ * @param {string} hypoDir
53
+ * @param {string|null|undefined} sessionId
54
+ * @returns {Record<string, string>} relPath -> the hash this session wrote it as
55
+ */
56
+ export function readJournal(hypoDir, sessionId) {
57
+ if (!sessionId) return {};
58
+ const path = closeJournalPath(hypoDir, sessionId);
59
+ if (!existsSync(path)) return {};
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
62
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
63
+ if (parsed.v !== 1 || !parsed.paths || typeof parsed.paths !== 'object') return {};
64
+ const out = {};
65
+ for (const [relPath, hash] of Object.entries(parsed.paths)) {
66
+ if (typeof hash === 'string') out[relPath] = hash;
67
+ }
68
+ return out;
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Record that this session wrote `relPath` as `hash`. Callers must call this
76
+ * AFTER the byte write it describes, never before: the journal is read to
77
+ * decide whether a write already landed, and a record ahead of the write it
78
+ * names would let a crash between the two claim credit for bytes that were
79
+ * never actually put down.
80
+ *
81
+ * Read-modify-write, not append-only: one close touches several paths across
82
+ * several call sites in one process, and each call needs the earlier ones'
83
+ * entries still present when it re-saves the file. Best-effort like every
84
+ * other write in this store — a journal write failure must never fail a close
85
+ * over bytes that already landed; it only costs that one path its fast
86
+ * restage on a future retry, which falls back to the ordinary gate block, the
87
+ * safe direction to fail toward.
88
+ *
89
+ * @param {string} hypoDir
90
+ * @param {string|null|undefined} sessionId
91
+ * @param {string} relPath vault-relative path, exactly as it appears in
92
+ * `appliedPaths` / `commitWikiChanges`
93
+ * @param {string} hash sha256 hex of the bytes just written (hashContent)
94
+ */
95
+ export function recordJournalEntry(hypoDir, sessionId, relPath, hash) {
96
+ if (!sessionId) return;
97
+ try {
98
+ const paths = readJournal(hypoDir, sessionId);
99
+ paths[relPath] = hash;
100
+ atomicWrite(
101
+ closeJournalPath(hypoDir, sessionId),
102
+ JSON.stringify({ v: 1, sessionId: String(sessionId), paths }, null, 2),
103
+ );
104
+ } catch {
105
+ // best-effort — see doc comment above
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Clear this session's journal once its close has actually committed. A
111
+ * journal entry that outlives a successful commit would keep offering to
112
+ * restage bytes for a path this SAME session might legitimately touch again
113
+ * later in an unrelated close — the hash comparison alone cannot tell "still
114
+ * my uncommitted work" from "coincidentally the same bytes, much later" once
115
+ * the first close is done. Best-effort: a leftover file after a successful
116
+ * close is at worst a future no-op restore attempt (the hash still has to
117
+ * match), never a data-loss risk on its own.
118
+ * @param {string} hypoDir
119
+ * @param {string|null|undefined} sessionId
120
+ */
121
+ export function clearJournal(hypoDir, sessionId) {
122
+ if (!sessionId) return;
123
+ try {
124
+ rmSync(closeJournalPath(hypoDir, sessionId), { force: true });
125
+ } catch {
126
+ // best-effort — see doc comment above
127
+ }
128
+ }
package/hooks/hooks.json CHANGED
@@ -153,13 +153,5 @@
153
153
  ]
154
154
  }
155
155
  ]
156
- },
157
- "shared": [
158
- "hypo-shared.mjs",
159
- "version-check.mjs",
160
- "version-check-fetch.mjs",
161
- "base-store.mjs",
162
- "proposal-store.mjs",
163
- "close-gate-store.mjs"
164
- ]
156
+ }
165
157
  }
@@ -7,7 +7,7 @@
7
7
  * MISS → inject global hot.md pointer only (no fan-out to all projects)
8
8
  */
9
9
 
10
- import { readFileSync, writeFileSync, existsSync, realpathSync } from 'fs';
10
+ import { readFileSync, writeFileSync, existsSync, realpathSync, renameSync } from 'fs';
11
11
  import { homedir } from 'os';
12
12
  import { join, dirname } from 'path';
13
13
  import { fileURLToPath } from 'url';
@@ -39,6 +39,7 @@ import {
39
39
  readVisibilityScope,
40
40
  pkgRootDriftStatus,
41
41
  PKG_ROOT,
42
+ withFileLock,
42
43
  } from './hypo-shared.mjs';
43
44
  import {
44
45
  defaultCachePath,
@@ -52,6 +53,8 @@ import {
52
53
  computeSiblingNotice,
53
54
  siblingAlreadyNotified,
54
55
  markSiblingNotified,
56
+ classifyInstall,
57
+ parseSemver,
55
58
  pkgRootDriftAlreadyNotified,
56
59
  markPkgRootDriftNotified,
57
60
  clearPkgRootDriftNotified,
@@ -237,15 +240,177 @@ function buildSiblingNotice() {
237
240
  }
238
241
  }
239
242
 
243
+ // Full path to the file init/upgrade own writing (hypo-shared.mjs's
244
+ // readCachedPkgRoot reads it but does not export the path). Self-heal below
245
+ // reads and rewrites it directly rather than adding a write path into
246
+ // hypo-shared.mjs, which currently owns only the READ side of this file.
247
+ const HYPO_PKG_JSON_PATH = join(homedir(), '.claude', 'hypo-pkg.json');
248
+
249
+ // ── self-heal lock ───────────────────────────────────────────────────────────
250
+ // Two sessions can self-locate to two DIFFERENT install roots at once (a dual
251
+ // install, or one upgrade mid-flight while another session already started)
252
+ // and both hit this same homedir()-keyed hypo-pkg.json. Reading it once,
253
+ // comparing, then writing let whichever session finished SECOND clobber
254
+ // whatever the first had just written, even when its own incoming version
255
+ // was itself newer than the file's ORIGINAL value (Codex 3rd-round review,
256
+ // 2026-09-11: reproduced by running a 1.8.2 and a 1.8.1 install against the
257
+ // same HOME; both read the pre-existing 1.0.0, both judged themselves an
258
+ // improvement over it, and whichever wrote last won regardless of which
259
+ // incoming version was actually higher).
260
+ //
261
+ // This used to hand-roll its own `wx` lockfile (write-then-fill), which a
262
+ // Codex 3rd-round review caught publishing an empty lock before the pid was
263
+ // written: a holder preempted inside that window is indistinguishable from a
264
+ // pid-less legacy lock, so a second writer steals it and both run the
265
+ // critical section, which is the exact bug a liveness check is supposed to
266
+ // close. `withFileLock` (below, from hypo-shared.mjs) already exists in this
267
+ // repo, and its own docstring names that same failure mode as the reason it
268
+ // stages the pid into a private sibling first and `linkSync`s it into place
269
+ // atomically. Reuse it instead of repeating the mistake it was written to
270
+ // avoid. `timeoutMs: 150` keeps the bounded, best-effort wait this hook
271
+ // always had (long enough for a sibling session's heal, a handful of sync fs
272
+ // calls, to finish and release; short enough that SessionStart itself never
273
+ // stalls noticeably even under contention); `staleMs: 30000` keeps the same
274
+ // crashed-holder cutoff the old lock used.
275
+ //
276
+ // withFileLock throws ELOCKTIMEOUT when it cannot acquire in time, never a
277
+ // bare Error, so selfHealPkgRoot's caller can tell "lock contention" apart
278
+ // from "fn() itself failed". Either way this hook must fail OPEN: a
279
+ // SessionStart hook must never block or crash a session over a lock it
280
+ // couldn't get, so both outcomes collapse to the caller's `{ healed: false }`
281
+ // via the try/catch already wrapping the read-compare-write below.
282
+ //
283
+ // Known residue in withFileLock itself, inherited here rather than
284
+ // re-solved: a microscopic stat-then-unlink TOCTOU on the stale-steal path
285
+ // (a fresh holder can grab the path in that gap and have its lock removed by
286
+ // the stealer, bounded by staleMs being far above a normal close), a
287
+ // local-filesystem-only guarantee (no network FS atomicity), and a PID-reuse
288
+ // window where a live process happens to reuse a crashed holder's pid and
289
+ // gets treated as the still-alive original (an availability loss, the lock
290
+ // is never stolen from it, not a correctness bug, since two writers still
291
+ // never enter the critical section together).
292
+
293
+ /**
294
+ * Self-heal `pkgRoot`/`pkgVersion` in `hypo-pkg.json` in place, once a session
295
+ * has already detected they disagree with the code's own self-location
296
+ * (`status`, from pkgRootDriftStatus()). Until now the user's only remaining
297
+ * job was copying those two values into the file by hand; this does that copy
298
+ * for them.
299
+ *
300
+ * Preserves every other key untouched (`schemaVersion`, `extensions`,
301
+ * `commands`, ...): this file is shared with init/upgrade's own writes, so a
302
+ * narrower op than "read the whole object, patch two keys, write the whole
303
+ * object back" would silently drop state another lane depends on. Mirrors
304
+ * scripts/lib/pkg-json.mjs's writeDualSkipProvenance in spirit, but cannot
305
+ * import it — hooks never reach into scripts/.
306
+ *
307
+ * Fails closed to `{ healed: false }` on anything short of a verified,
308
+ * complete write: a lock that could not be acquired (timeout or a genuine
309
+ * fs error from `withFileLock`), an unreadable/corrupt existing file, a
310
+ * non-object parse result, no resolvable-and-parseable version at the new
311
+ * root, a DOWNGRADE (below), or the write itself throwing. The caller must
312
+ * fall back to the manual `/hypo:upgrade` guidance rather than claim a fix
313
+ * that never landed.
314
+ *
315
+ * Concurrency: the whole read-compare-write runs inside `withFileLock`
316
+ * (hypo-shared.mjs), which re-reads `hypo-pkg.json` AFTER acquiring the
317
+ * lock, not before, since a pre-lock snapshot is exactly what let two
318
+ * concurrent installs race. Losing the lock (ELOCKTIMEOUT, or any other
319
+ * throw from `withFileLock` or from `fn` itself) is caught by the outer
320
+ * try/catch and treated as fail-open: this session simply skips the heal,
321
+ * the same outcome as any other precondition miss below.
322
+ *
323
+ * The lock's reach is narrower than the sentence above about sharing this
324
+ * file with init/upgrade might suggest: it serializes SessionStart heals
325
+ * against each other, and nothing else. `writePkgJsonAtomic` (what init,
326
+ * upgrade and writeDualSkipProvenance go through) does not take it, so an
327
+ * `upgrade --apply` running while another session starts is still a lost
328
+ * update. The damage is bounded rather than absent: heal refuses a
329
+ * downgrade, so whichever write lands last leaves `pkgVersion` at the same
330
+ * value or higher. That bound is the whole argument for leaving it, and it
331
+ * dies the moment a heal path is allowed to lower the version.
332
+ *
333
+ * Downgrade guard: a dual install (e.g. a plugin-scope cache at 1.8.2 and a
334
+ * project-scope cache at 1.8.1, both reading the SAME `hypo-pkg.json` because
335
+ * that path is keyed on `homedir()`, not on scope) means a session that
336
+ * happens to self-locate to the OLDER sibling would otherwise overwrite this
337
+ * file's `pkgVersion` with a lower number — the one baseline
338
+ * `scripts/upgrade.mjs`'s downgrade guard and `hooks/version-check.mjs`'s
339
+ * `computeSiblingNotice` both compare against. `classifyInstall` is the same
340
+ * judgment those two already trust, but this only blocks the `'downgrade'`
341
+ * verdict: a comparison that resolves and comes out lower. On `'downgrade'`,
342
+ * this returns `{ healed: false }` before touching the file, so the normal
343
+ * failure fallback below fires the original manual-fix guidance instead of
344
+ * quietly recording the sibling's older version as truth.
345
+ *
346
+ * Direction matters for the `'unknown'` verdict (one side's semver failed to
347
+ * parse), which is why the INCOMING version is checked with `parseSemver`
348
+ * up front rather than folded into the same `'unknown'` escape hatch as an
349
+ * unparseable EXISTING value. An unparseable incoming version must never
350
+ * heal: that would overwrite a good `pkgVersion` with garbage read from a
351
+ * corrupt `package.json` (Codex 3rd-round review, 2026-09-11: reproduced with
352
+ * `package.json.version: "not-semver"` against an existing `1.8.2`, which
353
+ * used to heal to `"not-semver"`). An unparseable EXISTING value with a
354
+ * valid incoming one is the opposite case, a good value replacing a
355
+ * corrupted one, and is still allowed through by `classifyInstall`'s
356
+ * `'unknown'` verdict: there is no valid comparison to protect against
357
+ * there, and refusing it would leave a broken value in place forever with no
358
+ * path back to a good one.
359
+ */
360
+ function selfHealPkgRoot(status) {
361
+ try {
362
+ return withFileLock(
363
+ HYPO_PKG_JSON_PATH,
364
+ () => {
365
+ const meta = JSON.parse(readFileSync(HYPO_PKG_JSON_PATH, 'utf-8'));
366
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return { healed: false };
367
+ const newVersion = readInstalledVersion(status.self);
368
+ if (!newVersion || !parseSemver(newVersion)) return { healed: false };
369
+ const oldVersion = typeof meta.pkgVersion === 'string' ? meta.pkgVersion : null;
370
+ const incoming = { pkgRoot: status.self, version: newVersion };
371
+ const active = { pkgRoot: meta.pkgRoot, version: oldVersion };
372
+
373
+ if (classifyInstall(incoming, active) === 'downgrade') return { healed: false };
374
+ const updated = { ...meta, pkgRoot: status.self, pkgVersion: newVersion };
375
+ // temp + rename in the SAME directory: a crash mid-write leaves only a
376
+ // throwaway temp file behind, never a torn hypo-pkg.json that the next
377
+ // session's every hook read of this file would then choke on.
378
+ const dir = dirname(HYPO_PKG_JSON_PATH);
379
+ const tmp = join(
380
+ dir,
381
+ `.hypo-pkg.json.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`,
382
+ );
383
+ writeFileSync(tmp, `${JSON.stringify(updated, null, 2)}\n`);
384
+ renameSync(tmp, HYPO_PKG_JSON_PATH);
385
+ return { healed: true, oldVersion, newVersion };
386
+ },
387
+ { timeoutMs: 150, staleMs: 30_000, pollMs: 20 },
388
+ );
389
+ } catch {
390
+ // Fail open: a lock timeout (ELOCKTIMEOUT) or any other throw from
391
+ // withFileLock/fn must not block or crash SessionStart. The caller
392
+ // treats this exactly like any other heal precondition miss.
393
+ return { healed: false };
394
+ }
395
+ }
396
+
240
397
  /**
241
398
  * pkgRoot drift notice. hypo-shared.mjs's resolvePkgRoot() already
242
399
  * self-corrects PKG_ROOT in memory whenever the code's own resolved location
243
- * disagrees with the cached hypo-pkg.json — but silent self-correction is the
244
- * exact failure this closes: the user's own `upgrade` habit stops mattering
245
- * and nothing ever tells them hypo-pkg.json fell behind. Surfaced once per
246
- * (cached → self-location) pair via the same notify-once cache the sibling
247
- * notice above uses — a fresh drift (new self-location) re-notifies, but
248
- * staying on the same drifted state doesn't nag every session.
400
+ * disagrees with the cached hypo-pkg.json — but silent self-correction used
401
+ * to leave the FILE itself stale forever, with nothing but a "please copy
402
+ * these two values in yourself" notice to show for it. This now does that
403
+ * copy itself (selfHealPkgRoot above), on the spot, in the same session that
404
+ * detected the drift.
405
+ *
406
+ * Notify-once cache: a successful heal marks the (cached → self-location)
407
+ * pair notified, but next session's pkgRootDriftStatus() reads back 'match'
408
+ * from the now-fixed file regardless and clears it on its own — the mark here
409
+ * only prevents a duplicate "fixed" line within a session that re-checks
410
+ * drift more than once. A FAILED heal must NOT mark the pair: marking would
411
+ * suppress the only guidance the user has (the manual `/hypo:upgrade` line)
412
+ * for a drift that never actually got fixed, on every later session that
413
+ * hits the same stale pair.
249
414
  *
250
415
  * Tri-state (pkgRootDriftStatus): 'match' CLEARS any earlier mark (checked
251
416
  * FIRST, unconditionally — even under opt-out, so a drift that resolves while
@@ -253,8 +418,9 @@ function buildSiblingNotice() {
253
418
  * recurrence once opt-out is lifted); 'unknown' touches nothing (self-location
254
419
  * could not be resolved this session — the permanent steady state for the
255
420
  * npm/manual channel, not evidence either way); only 'drift' can produce a
256
- * banner, and opt-out is checked there so an opted-out session never marks a
257
- * pair as notified it never actually showed.
421
+ * banner (or attempt a heal), and opt-out is checked there so an opted-out
422
+ * session never writes a fix — or marks a pair notified — it never actually
423
+ * showed.
258
424
  */
259
425
  function buildPkgRootDriftNotice() {
260
426
  try {
@@ -269,7 +435,24 @@ function buildPkgRootDriftNotice() {
269
435
  const key = `${status.cached || '(none)'}->${status.self}`;
270
436
  const cache = readCache(cachePath);
271
437
  if (pkgRootDriftAlreadyNotified(cache, key)) return '';
272
- markPkgRootDriftNotified(cachePath, key);
438
+
439
+ const heal = selfHealPkgRoot(status);
440
+ if (heal.healed) {
441
+ markPkgRootDriftNotified(cachePath, key);
442
+ return (
443
+ `[Hypomnema] Package metadata drift fixed: hypo-pkg.json pointed at ` +
444
+ `\`${status.cached || '(none)'}\`` +
445
+ `${heal.oldVersion ? ` (version ${heal.oldVersion})` : ''}, now synced to ` +
446
+ `\`${status.self}\` (version ${heal.newVersion}).\n` +
447
+ ` This was corrected automatically for this session — no action needed.`
448
+ );
449
+ }
450
+
451
+ // Heal did not land (unreadable/corrupt hypo-pkg.json, no resolvable
452
+ // version at the new root, or the write itself failed). Do NOT mark this
453
+ // pair notified here — see the docstring above — and fall back to the
454
+ // original manual-fix guidance.
455
+ //
273
456
  // status.cached is null both for a genuinely fresh install (never ran
274
457
  // /hypo:init) AND for the channel-judgment-failure guard (init/upgrade
275
458
  // positively decided to leave pkgRoot unset; see scripts/init.mjs's
@@ -287,7 +470,7 @@ function buildPkgRootDriftNotice() {
287
470
  `\`${status.cached || '(none)'}\`, but the code actually running resolves to ` +
288
471
  `\`${status.self}\`.\n` +
289
472
  ` Hooks already resolved the correct root for this session — this is a ` +
290
- `heads-up, not a blocker.\n${recoveryLine}`
473
+ `heads-up, not a blocker (the automatic fix did not take — see below).\n${recoveryLine}`
291
474
  );
292
475
  } catch {
293
476
  return '';