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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -51
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/crystallize.md +14 -3
- package/docs/ARCHITECTURE.md +13 -5
- package/docs/CONTRIBUTING.md +21 -7
- package/hooks/close-journal.mjs +128 -0
- package/hooks/hooks.json +1 -9
- package/hooks/hypo-session-start.mjs +194 -11
- package/hooks/hypo-shared.mjs +117 -39
- package/hooks/proposal-store.mjs +35 -1
- package/hooks/shared.json +9 -0
- package/package.json +2 -1
- package/scripts/doctor.mjs +43 -91
- package/scripts/init.mjs +54 -106
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +569 -79
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +150 -0
- package/scripts/lib/pkg-provenance.mjs +11 -0
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/template-schema-version.mjs +47 -0
- package/scripts/uninstall.mjs +130 -66
- package/scripts/upgrade.mjs +243 -145
- package/templates/hypo-config.md +1 -1
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
|
|
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
|
|
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
|
|
package/commands/crystallize.md
CHANGED
|
@@ -61,7 +61,9 @@ Field rules:
|
|
|
61
61
|
|
|
62
62
|
Notes:
|
|
63
63
|
|
|
64
|
-
- `sessionState` / `projectHot` / `rootHot` / `openQuestions` are **overwrite** (full-file content)
|
|
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
|
|
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
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
---
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
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)
|
|
410
|
-
#
|
|
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
|
@@ -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
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* (
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
|
257
|
-
* pair
|
|
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
|
-
|
|
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 '';
|