hypomnema 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +141 -51
  4. package/README.ko.md +18 -17
  5. package/README.md +16 -15
  6. package/commands/crystallize.md +89 -24
  7. package/commands/doctor.md +3 -1
  8. package/docs/ARCHITECTURE.md +45 -14
  9. package/docs/CONTRIBUTING.md +26 -8
  10. package/hooks/atomic-write.mjs +44 -0
  11. package/hooks/base-store.mjs +409 -90
  12. package/hooks/close-gate-store.mjs +131 -16
  13. package/hooks/close-journal.mjs +250 -0
  14. package/hooks/close-receipt.mjs +559 -0
  15. package/hooks/hooks.json +3 -38
  16. package/hooks/hypo-auto-commit.mjs +80 -10
  17. package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
  18. package/hooks/hypo-auto-stage.mjs +13 -1
  19. package/hooks/hypo-close-guard.mjs +2 -1
  20. package/hooks/hypo-cwd-change.mjs +45 -20
  21. package/hooks/hypo-file-watch.mjs +44 -6
  22. package/hooks/hypo-first-prompt.mjs +15 -9
  23. package/hooks/hypo-hot-rebuild.mjs +169 -112
  24. package/hooks/hypo-personal-check.mjs +9 -1
  25. package/hooks/hypo-session-record.mjs +1 -1
  26. package/hooks/hypo-session-start.mjs +566 -20
  27. package/hooks/hypo-shared.mjs +2250 -206
  28. package/hooks/hypo-stop.mjs +207 -0
  29. package/hooks/proposal-store.mjs +201 -35
  30. package/hooks/shared.json +15 -0
  31. package/hooks/version-check.mjs +9 -7
  32. package/package.json +3 -1
  33. package/scripts/crystallize.mjs +3 -4
  34. package/scripts/doctor.mjs +549 -123
  35. package/scripts/init.mjs +86 -109
  36. package/scripts/lib/core-hooks.mjs +48 -22
  37. package/scripts/lib/crystallize-close-apply.mjs +2397 -188
  38. package/scripts/lib/crystallize-close-check.mjs +43 -23
  39. package/scripts/lib/design-history-stale.mjs +36 -9
  40. package/scripts/lib/frontmatter.mjs +39 -0
  41. package/scripts/lib/git-hooks-dir.mjs +427 -52
  42. package/scripts/lib/hook-inventory.mjs +180 -0
  43. package/scripts/lib/page-usage.mjs +1 -1
  44. package/scripts/lib/pkg-provenance.mjs +18 -1
  45. package/scripts/lib/plugin-detect.mjs +43 -9
  46. package/scripts/lib/project-create.mjs +40 -44
  47. package/scripts/lib/schema-vocab.mjs +22 -11
  48. package/scripts/lib/template-schema-version.mjs +48 -0
  49. package/scripts/lib/time.mjs +46 -0
  50. package/scripts/lint.mjs +66 -18
  51. package/scripts/proposal.mjs +423 -18
  52. package/scripts/resume.mjs +3 -2
  53. package/scripts/session-audit.mjs +2 -1
  54. package/scripts/uninstall.mjs +144 -66
  55. package/scripts/upgrade.mjs +523 -234
  56. package/scripts/verify.mjs +2 -1
  57. package/scripts/weekly-report.mjs +2 -1
  58. package/templates/SCHEMA.md +27 -7
  59. package/templates/gitignore +11 -0
  60. package/templates/hypo-automation.md +10 -13
  61. package/templates/hypo-config.md +1 -1
  62. package/templates/hypo-guide.md +53 -25
  63. package/templates/hot.md +0 -24
@@ -119,7 +119,7 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
119
119
  | `UserPromptSubmit` | `hypo-first-prompt.mjs` → `hypo-lookup.mjs` → `hypo-compact-guard.mjs` |
120
120
  | `PreCompact` | `hypo-personal-check.mjs` |
121
121
  | `PostToolUse` (Write/Edit) | `hypo-auto-stage.mjs` |
122
- | `Stop` | `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` |
122
+ | `Stop` | `hypo-stop.mjs` (runs `hypo-hot-rebuild.mjs` → `hypo-session-record.mjs` → `hypo-auto-commit.mjs` → `hypo-auto-minimal-crystallize.mjs` as ordered stages, not separate registrations) |
123
123
  | `CwdChanged` | `hypo-cwd-change.mjs` |
124
124
  | `FileChanged` | `hypo-file-watch.mjs` |
125
125
 
@@ -127,24 +127,23 @@ Hooks run automatically at Claude Code lifecycle events. They are deployed to `~
127
127
 
128
128
  | Hook | Responsibility |
129
129
  |---|---|
130
- | `hypo-session-start` | Inject `index.md`, root `hot.md`, project `hot.md`/`session-state.md`. Run `git pull --ff-only` (silent fail on missing remote) |
130
+ | `hypo-session-start` | Run `git pull --ff-only` (silent fail on missing remote), rebuild the root `hot.md` projection, inject `index.md`, root `hot.md`, project `hot.md`/`session-state.md` |
131
131
  | `hypo-first-prompt` | Reads the marker left by `hypo-session-start` or `hypo-cwd-change` and forces a one-line resume on the first user prompt (10-min TTL). It does not re-read or re-inject `hot.md` |
132
132
  | `hypo-lookup` | BM25 search over the wiki on every prompt. **HIT** → inject top-3 page snippets (≤2000 chars each; a page whose `verify_by_date` is overdue gets a `[STALE verify_by_date=…]` marker prepended). **MISS** → emit closest-slug signal that prompts Claude to research + `/hypo:ingest` |
133
133
  | `hypo-compact-guard` | Detect `/compact` or `/clear` typed in chat and, if session close is incomplete, tell Claude so. It never blocks `/compact` |
134
134
  | `hypo-personal-check` | PreCompact detection: lint blockers, uncommitted changes, missing session-log entries surface as a `systemMessage`; `/compact` is never blocked here |
135
135
  | `hypo-auto-stage` | After Write/Edit on a wiki path, run `git add` (skips paths matching `.hypoignore`) |
136
- | `hypo-hot-rebuild` | At session stop, regenerate root `hot.md` from recent activity; emit growth metrics + cache for next SessionStart |
136
+ | `hypo-hot-rebuild` | At session stop, regenerate the root `hot.md` projection (the same generator `hypo-session-start` also calls); emit growth metrics + cache for next SessionStart. A scan failure (not just an empty `projects/`) skips the write and leaves a health notice for the next `SessionStart` to surface |
137
137
  | `hypo-session-record` | At session stop, append `{session_id, transcript_path, recorded_at, cwd, device}` to `.cache/sessions/index.jsonl` (primary source for the observability audit) |
138
138
  | `hypo-auto-commit` | At session stop, filter changed paths through `.hypoignore`, commit non-ignored changes, `git pull --no-rebase` + `git push` (silent fail on missing remote) |
139
- | `hypo-cwd-change` | When working directory changes, re-resolve the active project and build `additionalContext` with its `hot.md`; `CwdChanged` has no field Claude Code forwards to the model, so this does not currently reach Claude |
140
- | `hypo-file-watch` | Build `additionalContext` for a changed wiki file. Two things keep this inert: nothing in this package returns `watchPaths`, so the event has no registered trigger, and `FileChanged` output does not reach the model. The event also fires regardless of who changed the file, so it is not an external-edit signal |
139
+ | `hypo-cwd-change` | When working directory changes, re-resolve the active project and build a notice carrying its `hot.md`. The hook reference documents `watchPaths` and `systemMessage` as `CwdChanged`'s output fields and says only that "it shows the `systemMessage` as a brief terminal notification" and "the message doesn't reach the SDK message stream"; it does not say whether `systemMessage` reaches the model on this event either way. As of Claude Code 2.1.276 (checked 2026-09-18) and reconfirmed on 2.1.283, tracing the installed binary shows the only consumer of that `systemMessage` is the terminal notification queue, a low-priority toast that a same-keyed message replaces and that disappears after five seconds, with no branch that forwards it to the model; outside the interactive REPL the injecting function is null. No live-session observation of this path exists either way. Until the documentation says otherwise, this event is treated as not reaching the model |
140
+ | `hypo-file-watch` | Build a `systemMessage` notice for a changed wiki file, over the same field as `hypo-cwd-change` whose reach to the model the reference leaves undocumented; as of Claude Code 2.1.276 (checked 2026-09-18) and reconfirmed on 2.1.283 it is likewise treated as not reaching the model, for the same shared-consumer reason (see the `hypo-cwd-change` row). On a stock install the event still has no registered trigger: its matcher key is omitted (not blank), which the reference says matches every watched file while adding nothing to the watch list, and nothing in this package returns `watchPaths` to seed that list. The watch list is session-global, not scoped to this plugin, so another hook or plugin that does seed it would make this matcher-less group fire on every watched file; the hook still returns immediately for any path that does not start with `HYPO_DIR` (a string-prefix check, not a symlink-resolving one). That check only screens the path string: a symlink living inside the wiki still starts with `HYPO_DIR` and passes it, and the hook's `readFileSync` follows the link, so such a symlink can still surface content from wherever it points. The event also fires regardless of who changed the file, so it is not an external-edit signal |
141
141
 
142
142
  ### Deployment constraint
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.
@@ -292,6 +293,7 @@ SessionStart
292
293
  │
293
294
  ├─► hypo-session-start.mjs
294
295
  │ ├─► git pull --ff-only (if remote)
296
+ │ ├─► rebuild root hot.md (projection of projects/*/hot.md)
295
297
  │ ├─► inject index.md / hot.md / project session-state.md
296
298
  │ └─► (Claude resumes work)
297
299
  │
@@ -307,14 +309,36 @@ SessionStart
307
309
  │ └─► hypo-personal-check.mjs (lint + session-close gate)
308
310
  │
309
311
  ├─► CwdChanged
310
- │ └─► hypo-cwd-change.mjs (builds hot.md context; does not currently reach Claude)
312
+ │ └─► hypo-cwd-change.mjs (builds a hot.md notice on systemMessage; the reference documents the terminal notification, tracing the installed binary as of Claude Code 2.1.276, checked 2026-09-18 and reconfirmed on 2.1.283, shows it does not reach the model, and no live-session observation exists either way; see the hook table above)
311
313
  │
312
314
  └─► Stop
313
- ├─► hypo-hot-rebuild.mjs (regenerate root hot.md + growth cache)
314
- ├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
315
- └─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
315
+ └─► hypo-stop.mjs, running four stages in order:
316
+ ├─► hypo-hot-rebuild.mjs (regenerate root hot.md projection + growth cache; SessionStart also calls this generator)
317
+ ├─► hypo-session-record.mjs (append .cache/sessions/index.jsonl)
318
+ ├─► hypo-auto-commit.mjs (.hypoignore-filtered stage + commit + pull + push)
319
+ └─► hypo-auto-minimal-crystallize.mjs (blocks Stop if session close never ran)
316
320
  ```
317
321
 
322
+ Root hot.md's frontmatter `updated:` is the max of every `projects/*/hot.md` row date, not the
323
+ date the projection itself was last rewritten. A vault whose newest project row is a week old
324
+ keeps that same week-old date across every SessionStart and Stop until a project's own `hot.md`
325
+ moves forward, even though the root file's bytes get recomputed and checked far more often.
326
+
327
+ Before overwriting content it does not recognize as its own, the projection backs it up next to
328
+ `hot.md` as `hot.md.pre-projection-backup.md` (or a numbered variant, `-2.md`, `-3.md`, ...). These
329
+ backups are never cleaned up automatically. Delete the ones you no longer need by hand.
330
+
331
+ The write itself goes through `writeRootHotProjection` in `hooks/hypo-shared.mjs`, which holds
332
+ the vault commit lock (`vaultCommitLockTarget`, the same lock `hypo-auto-commit.mjs` and
333
+ `crystallize.mjs`'s apply take around stage and commit) across the whole projection write: the
334
+ first read of `hot.md`, the ownership check, the backup, and the replacing rename. `hypo-session-start.mjs`'s
335
+ own `git pull` takes and releases the same lock before calling into the projection, so the two
336
+ never overlap; they still serialize against each other and against a concurrent `auto-commit` or
337
+ `crystallize` apply. The lock is not reentrant: nothing inside the projection's write path may
338
+ take it a second time, and a caller that already holds it has to call the unlocked form instead.
339
+ When the lock cannot be acquired within its timeout, the write is skipped rather than blocked on
340
+ indefinitely, and the caller is told the pointer table was left as-is.
341
+
318
342
  ---
319
343
 
320
344
  ## Feedback → Behavior pipeline
@@ -501,7 +525,7 @@ Requires `NPM_TOKEN` secret.
501
525
 
502
526
  ## Plugin manifest
503
527
 
504
- `.claude-plugin/plugin.json` declares the plugin to Claude Code. `hooks/hooks.json` follows the standard plugin hooks schema:
528
+ `.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
529
 
506
530
  ```json
507
531
  {
@@ -517,11 +541,18 @@ Requires `NPM_TOKEN` secret.
517
541
  ]
518
542
  }
519
543
  ]
520
- },
521
- "shared": ["hypo-shared.mjs"]
544
+ }
522
545
  }
523
546
  ```
524
547
 
548
+ 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.
549
+
550
+ ```json
551
+ ["hypo-shared.mjs", "version-check.mjs", "..."]
552
+ ```
553
+
554
+ `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.
555
+
525
556
  `claude plugin validate .` is run as part of CI's plugin-snapshot checks.
526
557
 
527
558
  ---
@@ -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
 
@@ -130,7 +129,11 @@ npm test # tests/*.test.mjs, sharded across processes — unit + smoke
130
129
  npm run lint # scripts/lint.mjs — frontmatter + wikilink validation + W8 (design-history stale
131
130
  # vs session-log) + W14 (design-history missing but session-log implies one)
132
131
  # + W15 (synthesis older than the newest page in its sources_consulted)
133
- # + W16 (sources_consulted names W15 could not resolve)
132
+ # + W16 (sources_consulted content problems: a source name W15 could not
133
+ # resolve, a source or the synthesis's own updated that fails a strict
134
+ # YYYY-MM-DD parse, or sources_consulted written as a YAML block list
135
+ # instead of the required flow list; a single page can carry more than one
136
+ # W16 at once)
134
137
  npm run fix:verify # Phase 1 of learned_behavior #6 — verifies fix #N status claims in
135
138
  # a wiki spec against `// @fix #N: <test-name>` anchors, read as a
136
139
  # union across every tests/*.mjs. Maintainer dogfood; needs a wiki at
@@ -256,7 +259,7 @@ After both blocks, language-neutral:
256
259
 
257
260
  - **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
261
  - **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.
262
+ - 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
263
 
261
264
  ### The `## Changelog` block
262
265
 
@@ -372,6 +375,14 @@ node scripts/bump-version.mjs <new-semver> # e.g. 1.2.2 or 1.3.0-rc.1
372
375
  # version-consistency gate stay green (the lock carries the version twice).
373
376
  npm install --package-lock-only
374
377
 
378
+ # 1c. If this release also bumps templates/SCHEMA.md's `version:` frontmatter,
379
+ # add a matching line to SCHEMA_VERSION_DELTAS in
380
+ # scripts/lib/template-schema-version.mjs, naming what that version added.
381
+ # Skipping this is silent: upgrade.mjs's notice just falls back to "review
382
+ # manually" for that version, with no test failing at bump time except
383
+ # the one in tests/upgrade.test.mjs that pins the SHIPPED version has an
384
+ # entry.
385
+
375
386
  # 2. Draft the new CHANGELOG.md section from the merged PRs' `## Changelog`
376
387
  # blocks (see CHANGELOG conventions above). The collector prints a draft for
377
388
  # the range since the last tag; paste it, then finalize the wording by hand,
@@ -406,8 +417,10 @@ git commit -m "chore: release v<version>"
406
417
  # then a Korean summary block. The GitHub Release republishes this body
407
418
  # verbatim, so keep it on the same public surface as the CHANGELOG: PR
408
419
  # 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.
420
+ # ids (check-tracker-ids --tag gates it), and no `ADR NNNN` / `decisions/NNNN`
421
+ # either: this repo ships no `decisions/` directory, so a reader outside the
422
+ # maintainer's private wiki cannot open either one. Say what changed in
423
+ # plain prose, or cite the PR number.
411
424
  git tag -a v<version> -m "$(cat <<'EOF'
412
425
  Hypomnema v<version>: <one-line English summary>
413
426
 
@@ -421,7 +434,12 @@ Hypomnema v<version>: <한 줄 한글 요약>
421
434
  EOF
422
435
  )"
423
436
 
424
- # 6. Rehearse the release locally (same checks CI runs against the tag)
437
+ # 6. Rehearse the release locally (same checks CI runs against the tag).
438
+ # This is the ONLY gate that runs before the tag object is public. The
439
+ # release workflow re-runs these, but it starts when the push in step 7
440
+ # lands, so by then the annotated tag body is already on GitHub: CI can
441
+ # stop the npm publish and the Release, not the tag. Skip these three and
442
+ # nothing catches a leaked id until a reader finds it.
425
443
  node scripts/check-bilingual.mjs --tag v<version>
426
444
  node scripts/check-versions.mjs --tag v<version>
427
445
  node scripts/check-tracker-ids.mjs --tag v<version> # no tracker ids leak into the tag body
@@ -0,0 +1,44 @@
1
+ // The one temp+rename writer. Six byte-identical copies of this function used to
2
+ // live in hooks/ and scripts/lib/, and a fix landed in two of them (#296) while
3
+ // the other four kept the bug. That is the whole reason this file exists: a
4
+ // defense that lives in six places is a defense you can only half-apply.
5
+ //
6
+ // Hooks are copied standalone into ~/.claude/hooks/, so this has to live in
7
+ // hooks/ and be listed in hooks/shared.json. scripts/ may import it; the reverse
8
+ // direction is what breaks for every installed copy.
9
+ //
10
+ // Node built-ins only, per the hooks rule.
11
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+
14
+ /**
15
+ * Commit `content` to `path` via a temp file and a rename, so a partial or
16
+ * failed write lands on a throwaway name and the target is never torn.
17
+ *
18
+ * The temp name carries this pid and a fresh random rather than a shared
19
+ * `<path>.tmp` slot, so concurrent writers never fight over it. That is also
20
+ * why a leaked temp is permanent: nothing ever picks the same name again, so
21
+ * the file sits in the vault forever, close after close. Both failure points
22
+ * leak one: the rename (the target stays untouched, which is the point) and
23
+ * the write itself (ENOSPC or EDQUOT partway through). Clean up on either, and
24
+ * never let the cleanup hide the error that got us here.
25
+ *
26
+ * Rename atomicity swaps the directory entry. It is NOT power-loss durable.
27
+ * there is no fsync, same as everything else in the vault.
28
+ *
29
+ * @param {string} path absolute path to write
30
+ * @param {string|Buffer} content bytes to commit
31
+ */
32
+ export function atomicWrite(path, content) {
33
+ mkdirSync(dirname(path), { recursive: true });
34
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
35
+ try {
36
+ writeFileSync(tmp, content);
37
+ renameSync(tmp, path);
38
+ } catch (err) {
39
+ try {
40
+ rmSync(tmp, { force: true });
41
+ } catch {}
42
+ throw err;
43
+ }
44
+ }