medsci-skills 5.25.0 → 5.26.1

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 (66) hide show
  1. package/README.md +22 -1
  2. package/installers/install.py +245 -17
  3. package/installers/medsci_txn.py +29 -7
  4. package/metadata/distribution_files.json +104 -89
  5. package/metadata/distribution_manifest.json +1 -1
  6. package/package.json +1 -1
  7. package/skills/check-reporting/SKILL.md +5 -5
  8. package/skills/check-reporting/references/step4d_prisma_figure_audit.md +0 -1
  9. package/skills/fill-icmje-coi/SKILL.md +2 -2
  10. package/skills/intake-project/SKILL.md +1 -1
  11. package/skills/manage-refs/references/check_xref_symptoms.md +4 -4
  12. package/skills/manage-refs/scripts/check_xref.py +5 -5
  13. package/skills/manage-refs/scripts/render_pandoc.sh +4 -1
  14. package/skills/manage-refs/scripts/strip_source_metadata.lua +7 -0
  15. package/skills/manage-refs/tests/test_csl_render.sh +24 -0
  16. package/skills/meta-analysis/SKILL.md +10 -10
  17. package/skills/meta-analysis/references/phase3_screening_detail.md +9 -10
  18. package/skills/meta-analysis/references/phase4_extraction_detail.md +1 -1
  19. package/skills/peer-review/references/reviewer_profiles/EURE.md +3 -4
  20. package/skills/peer-review/references/reviewer_profiles/INSI.md +1 -1
  21. package/skills/peer-review/references/reviewer_profiles/README.md +14 -11
  22. package/skills/peer-review/references/reviewer_profiles/RYAI.md +4 -5
  23. package/skills/peer-review/scripts/check_review_boxes.py +5 -5
  24. package/skills/present-paper/SKILL.md +11 -0
  25. package/skills/present-paper/references/generated_illustrations.md +133 -0
  26. package/skills/present-paper/references/spoken_notes_and_bilingual.md +156 -0
  27. package/skills/revise/SKILL.md +5 -5
  28. package/skills/revise/scripts/check_density_complaint.py +14 -14
  29. package/skills/revise/scripts/density_complaint_challenge/verify.sh +5 -6
  30. package/skills/self-review/references/exemplar_findings/estimand_drift_posthoc_primary.md +1 -1
  31. package/skills/self-review/references/phases/confounding_completeness.md +4 -4
  32. package/skills/self-review/references/phases/phase2_5a2_design_power.md +4 -4
  33. package/skills/self-review/references/phases/phase2_5a_source_fidelity.md +1 -1
  34. package/skills/self-review/references/phases/phase2_5b_screening_counts.md +8 -9
  35. package/skills/self-review/references/phases/phase2_5d_xref_qc.md +5 -4
  36. package/skills/self-review/references/phases/phase2_5f_claim_artifact.md +5 -5
  37. package/skills/self-review/scripts/_frontmatter.py +3 -3
  38. package/skills/self-review/scripts/check_citation_order.py +1 -1
  39. package/skills/self-review/scripts/check_effect_stability.py +2 -2
  40. package/skills/self-review/scripts/check_effect_stability_challenge/expected/bad.txt +2 -2
  41. package/skills/self-review/scripts/check_effect_stability_challenge/fixture/effect_bad.md +2 -2
  42. package/skills/self-review/scripts/check_effect_stability_challenge/fixture/effect_ok.md +1 -1
  43. package/skills/self-review/scripts/check_effect_stability_challenge/problem.md +5 -5
  44. package/skills/self-review/scripts/check_effect_stability_challenge/verify.sh +3 -3
  45. package/skills/self-review/scripts/check_incorporation_bias.py +4 -8
  46. package/skills/self-review/scripts/check_incorporation_bias_challenge/fixture/incorp_bad.md +1 -1
  47. package/skills/self-review/scripts/check_incorporation_bias_challenge/problem.md +2 -2
  48. package/skills/self-review/scripts/check_incorporation_bias_challenge/verify.sh +1 -1
  49. package/skills/self-review/scripts/check_paren_spans.py +2 -2
  50. package/skills/self-review/tests/fixtures/citation_order_bad.md +9 -12
  51. package/skills/self-review/tests/fixtures/claim_manuscript.md +1 -1
  52. package/skills/self-review/tests/fixtures/claim_manuscript_single_primary.md +1 -1
  53. package/skills/self-review/tests/fixtures/paren_clean.md +1 -1
  54. package/skills/self-review/tests/fixtures/paren_corrupt.md +1 -1
  55. package/skills/self-review/tests/test_citation_order.sh +2 -2
  56. package/skills/self-review/tests/test_claim_artifact.sh +4 -4
  57. package/skills/sync-submission/SKILL.md +5 -5
  58. package/skills/sync-submission/scripts/check_asset_anonymization.py +15 -12
  59. package/skills/sync-submission/scripts/cover_letter_drift_check.py +4 -8
  60. package/skills/sync-submission/tests/test_asset_anonymization.sh +22 -0
  61. package/skills/verify-refs/SKILL.md +5 -8
  62. package/skills/verify-refs/scripts/verify_refs.py +3 -5
  63. package/skills/verify-refs/tests/test_fabricated_author.sh +2 -3
  64. package/skills/write-paper/references/journal_profiles/KJR.md +1 -1
  65. package/skills/write-paper/references/phase7_integrity_audits.md +5 -5
  66. package/skills/write-paper/references/phase7_polish_detail.md +3 -3
package/README.md CHANGED
@@ -137,6 +137,8 @@ Install a single category and invoke its skills under that namespace:
137
137
 
138
138
  All nine plugins share the same repository source, so this groups and enables skills by category — it is not a partial download. The marketplace tracks `main`, so a plugin's version is its git commit.
139
139
 
140
+ **Note the name.** A skill installed as a plugin is invoked under its plugin's namespace (`/medsci-analysis:analyze-stats`); the same skill installed into the skills folder by the `npx`, `gh skill`, classroom, or manual paths is invoked bare (`/analyze-stats`). Both run the same skill — press `/` and use Tab completion rather than typing the long form.
141
+
140
142
  **Want just one capability?** Two skills are also published as focused standalone repos (generated mirrors; this repo stays the source of truth), each installable on its own with `/plugin marketplace add Aperivue/<repo>`:
141
143
 
142
144
  - [`Aperivue/verify-refs`](https://github.com/Aperivue/verify-refs) — catch fabricated/mismatched citations (PubMed + CrossRef).
@@ -681,12 +683,31 @@ gh skill install --all Aperivue/medsci-skills # or install all of the
681
683
 
682
684
  - Claude Code: skills are copied to `~/.claude/skills/` (also read by GitHub Copilot and Cursor).
683
685
  - Codex: skills are copied to `~/.agents/skills/` (also read by Cursor and GitHub Copilot).
684
- - Cursor: no separate step needed — Cursor reads `~/.claude/skills/` and `~/.agents/skills/` directly. The installer can still write an optional `.cursor/rules/` steering rule with `--cursor-project`.
686
+ - Cursor: no separate step needed — Cursor reads `~/.claude/skills/` and `~/.agents/skills/` directly. The installer can still write an optional `.cursor/rules/` steering rule with `--cursor-project`; it carries no machine-specific path, so it is safe to commit.
685
687
  - See [`docs/host_compatibility.md`](docs/host_compatibility.md) for the verified per-host install paths and their official sources.
686
688
  - Windows users do not need WSL for the basic classroom workflow. Use WSL only for advanced reproducible Linux toolchains.
687
689
 
688
690
  See [docs/classroom_distribution_plan.md](docs/classroom_distribution_plan.md) and [docs/classroom_materials.md](docs/classroom_materials.md) for instructor distribution, email templates, and first-class exercises.
689
691
 
692
+ ### Optional: let plain-language requests find the skills
693
+
694
+ Typing `/orchestrate` is the reliable way in. If you would rather just describe what you want, the
695
+ installer can write a short routing table into a project's `CLAUDE.md` so Claude Code reaches for
696
+ the right skill on its own whenever you work in that folder:
697
+
698
+ ```bash
699
+ python3 installers/install.py --claude-project ~/research/my-study # this project only
700
+ python3 installers/install.py --claude-user # every project (larger footprint)
701
+ python3 installers/install.py --claude-project ~/research/my-study --remove-routing
702
+ ```
703
+
704
+ Both are **off by default**. The block is ~25 lines between `<!-- BEGIN/END medsci-skills routing -->`
705
+ markers and carries no machine-specific paths. It is spliced in, not written over: an existing
706
+ `CLAUDE.md` keeps its bytes outside the markers — line endings and file permissions included — the
707
+ write is atomic so an interrupted run cannot leave a truncated file, re-running updates the block in
708
+ place, and `--remove-routing` takes it back out. Prefer `--claude-project` — `--claude-user` loads in every project you ever open,
709
+ including work unrelated to research.
710
+
690
711
  > **Tip:** Not sure which skill to use? Start with `/orchestrate` -- it will classify your request and route you to the right tool.
691
712
 
692
713
  ## Updating
@@ -86,41 +86,229 @@ def copy_skills(target: str, dest: Path, log_lines: list[str], dry_run: bool) ->
86
86
 
87
87
 
88
88
  def install_cursor_rule(project: Path, log_lines: list[str], dry_run: bool) -> None:
89
- rules_dir = project / ".cursor" / "rules"
90
- rule_path = rules_dir / "medsci-skills.mdc"
91
- body = f"""---
89
+ """Write the optional Cursor steering rule into <project>/.cursor/rules/medsci-skills.mdc.
90
+
91
+ Two things this deliberately does NOT do any more:
92
+
93
+ * It does not embed the repository path. `.cursor/rules/` is a directory people commit, and the
94
+ absolute path of a home directory is the installer's name, so it travelled into shared repos.
95
+ It was also pointing at the wrong place: per docs/host_compatibility.md this rule is legacy
96
+ for discovery -- Cursor reads `~/.claude/skills/` and `~/.agents/skills/` directly -- so the
97
+ rule steers, and the skills are found where they are installed rather than in a checkout.
98
+ * It does not call `Path.write_text`, which opens in "w" mode and truncates before writing. This
99
+ file is ours to replace, but a half-written one after an interrupt is nobody's.
100
+ """
101
+ rule_path = project / ".cursor" / "rules" / "medsci-skills.mdc"
102
+ body = """---
92
103
  description: Use MedSci Skills for medical research writing, literature search, statistics, figures, and submission workflows.
93
104
  alwaysApply: false
94
105
  ---
95
106
 
96
107
  # MedSci Skills
97
108
 
98
- When the user asks for medical research workflows, inspect the relevant
99
- `skills/<skill-name>/SKILL.md` file in this repository before acting.
109
+ This file is written by the MedSci Skills installer and is replaced whenever it runs.
100
110
 
101
- Start with these entry points:
111
+ When the user asks for a medical research workflow, use the installed MedSci skill rather than
112
+ answering from scratch. Cursor reads them from `~/.claude/skills/` and `~/.agents/skills/`; open the
113
+ relevant `<skill-name>/SKILL.md` before acting.
102
114
 
103
- - `skills/search-lit/SKILL.md` for literature search and verified citations
104
- - `skills/analyze-stats/SKILL.md` for statistical tables and analysis code
105
- - `skills/make-figures/SKILL.md` for publication figures
106
- - `skills/write-paper/SKILL.md` for manuscript sections
107
- - `skills/check-reporting/SKILL.md` for reporting guideline audits
115
+ Start with these entry points:
108
116
 
109
- Use small single-skill tasks first. Avoid running the full end-to-end pipeline
110
- unless the user explicitly asks and provides the required project files.
117
+ - `orchestrate` — unsure which one fits; it classifies the request and routes
118
+ - `search-lit` — literature search and verified citations
119
+ - `analyze-stats` — statistical tables and analysis code
120
+ - `make-figures` — publication figures
121
+ - `write-paper` — manuscript sections
122
+ - `check-reporting` — reporting-guideline audits
111
123
 
112
- Repository path:
113
- `{REPO_ROOT}`
124
+ Prefer small single-skill tasks. Do not run the full end-to-end pipeline unless the user asks for it
125
+ and provides the required project files.
114
126
  """
115
127
  log(f"\n[cursor] writing project rule to {rule_path}", log_lines)
116
128
  if dry_run:
117
129
  log(" DRY RUN write Cursor rule", log_lines)
118
130
  return
119
- rules_dir.mkdir(parents=True, exist_ok=True)
120
- rule_path.write_text(body, encoding="utf-8")
131
+ prev_mode = os.stat(rule_path).st_mode & 0o777 if rule_path.is_file() else None
132
+ medsci_txn.atomic_write_bytes(rule_path, body.encode("utf-8"))
133
+ if prev_mode is not None:
134
+ try:
135
+ os.chmod(rule_path, prev_mode)
136
+ except OSError:
137
+ pass
121
138
  log(" installed Cursor project rule", log_lines)
122
139
 
123
140
 
141
+ ROUTING_BEGIN = "<!-- BEGIN medsci-skills routing -->"
142
+ ROUTING_END = "<!-- END medsci-skills routing -->"
143
+
144
+ # Deliberately carries no local path. The Cursor rule above embeds REPO_ROOT, which is fine for a
145
+ # file nobody shares — but a CLAUDE.md sits in a project folder and gets committed, and the absolute
146
+ # path of a home directory is the installer's name. Nothing here is machine-specific.
147
+ ROUTING_BLOCK = f"""{ROUTING_BEGIN}
148
+ ## MedSci Skills
149
+
150
+ MedSci Skills is installed. When a request matches a row below, invoke that skill rather than
151
+ answering from scratch. When the right one is not obvious, invoke `orchestrate` — it classifies the
152
+ request and routes to the rest.
153
+
154
+ | Request | Skill |
155
+ |---|---|
156
+ | Find papers; check that a citation is real; build a reference list | `search-lit`, `verify-refs`, `manage-refs` |
157
+ | Plan a study; sample size; define variables; de-identify data | `design-study`, `calc-sample-size`, `define-variables`, `deidentify` |
158
+ | Run statistics; draw a publication figure | `analyze-stats`, `make-figures` |
159
+ | Draft or revise a manuscript; write an IRB protocol | `write-paper`, `revise`, `write-protocol` |
160
+ | Audit against a reporting guideline (STROBE, PRISMA, CONSORT, STARD, TRIPOD) or a risk-of-bias tool | `check-reporting` |
161
+ | Self-review before submitting; answer reviewers; review someone else's paper | `self-review`, `revise`, `peer-review` |
162
+ | Choose a journal; assemble a submission package | `find-journal`, `sync-submission` |
163
+ | Medical-research work that is not obviously one of the above | `orchestrate` |
164
+
165
+ How a skill is invoked depends on how it was installed: bare (`/write-paper`) for a skills-folder
166
+ install, namespaced (`/medsci-writing:write-paper`) for a plugin install. Skip any skill that is not
167
+ installed, and never invent one.
168
+
169
+ These skills draft and audit. They do not replace authors, statisticians, reviewers, or an IRB, and
170
+ every output needs human-expert verification.
171
+
172
+ Everything between the two markers above and below is managed by the MedSci Skills installer and is
173
+ replaced or removed wholesale when it runs. Put your own notes outside them.
174
+ {ROUTING_END}
175
+ """
176
+
177
+
178
+ LF = "\n"
179
+ CRLF = "\r\n"
180
+
181
+
182
+ def _dominant_newline(text: str) -> str:
183
+ """Which line ending this file already uses, so the block matches it instead of mixing."""
184
+ crlf = text.count(CRLF)
185
+ return CRLF if crlf > text.count(LF) - crlf else LF
186
+
187
+
188
+ def _write_preserving_mode(path: Path, text: str, newline: str) -> None:
189
+ """Atomically replace `path`, keeping the permissions it already had.
190
+
191
+ The mode step mirrors update._write_settings: a user who ran `chmod 600` on their CLAUDE.md
192
+ should not have it widened to the umask default as a side effect of an install.
193
+ """
194
+ prev_mode = os.stat(path).st_mode & 0o777 if path.is_file() else None
195
+ medsci_txn.atomic_write_bytes(path, text.encode("utf-8"))
196
+ if prev_mode is not None:
197
+ try:
198
+ os.chmod(path, prev_mode)
199
+ except OSError:
200
+ pass
201
+
202
+
203
+ def apply_routing(md_path: Path, remove: bool, dry_run: bool, log_lines: list[str]) -> str:
204
+ """Splice the routing block into a CLAUDE.md. Returns: added | updated | unchanged | removed | absent.
205
+
206
+ What is actually guaranteed, because the first version of this docstring claimed more than the
207
+ code delivered and an external review said so:
208
+
209
+ * **The write is atomic.** Content goes to a temp file beside the target and is `os.replace`d
210
+ into position (medsci_txn.atomic_write_bytes), so an interrupted run leaves the previous file
211
+ intact. The previous version called `Path.write_text`, which opens in "w" mode and therefore
212
+ truncates before writing -- an interrupt there left a zero-byte CLAUDE.md.
213
+ * **Bytes outside the two markers are preserved, line endings included.** The file is read with
214
+ `read_bytes` and decoded here, so nothing on the path performs universal-newline translation;
215
+ a CRLF file stays CRLF, and the block is emitted with the file's own line ending rather than
216
+ mixing one in.
217
+ * **Permissions are preserved.**
218
+
219
+ The one thing it does NOT preserve, stated rather than hidden: a file with no trailing newline
220
+ gains one, and removal does not take it back. The newline is needed to put the block on its own
221
+ line, and nothing in the file records that it was ours.
222
+
223
+ A CLAUDE.md is where a user keeps their own standing instructions. Anything less than the above
224
+ is a data-loss bug, not an install step.
225
+ """
226
+ # A symlinked CLAUDE.md must be edited through to its target: `os.replace` onto the link would
227
+ # replace the link itself with a regular file, and unlinking it would delete the user's link
228
+ # while leaving the block sitting in the file it pointed at.
229
+ linked = md_path.is_symlink()
230
+ target = md_path.resolve() if linked else md_path
231
+
232
+ raw = target.read_bytes() if target.is_file() else None
233
+ head = raw.decode("utf-8") if raw is not None else ""
234
+ nl = _dominant_newline(head)
235
+ block = ROUTING_BLOCK if nl == LF else ROUTING_BLOCK.replace(LF, nl)
236
+ region = block[: -len(nl)] # the block without its own trailing newline
237
+
238
+ # Count every marker rather than finding the first of each. Reasoning from `find()` alone
239
+ # gets three cases wrong, and all three were reachable: markers in reverse order spliced a
240
+ # duplicate of the user's text and left a dangling fence; two blocks in one file never
241
+ # converged; and `--remove-routing` on two blocks reported "removed" while one survived --
242
+ # a success message that was not true. Anything other than one clean fence is refused, which
243
+ # is what the half-marker case already did.
244
+ n_begin, n_end = head.count(ROUTING_BEGIN), head.count(ROUTING_END)
245
+ if n_begin != n_end:
246
+ raise RuntimeError(
247
+ f"{md_path} has {n_begin} '{ROUTING_BEGIN}' and {n_end} '{ROUTING_END}'; refusing to "
248
+ f"guess where the block ends. Fix the markers by hand, then re-run."
249
+ )
250
+ if n_begin > 1:
251
+ raise RuntimeError(
252
+ f"{md_path} contains {n_begin} routing blocks. Refusing to touch it: removing one "
253
+ f"would leave the rest while reporting success. Delete the extra blocks by hand, "
254
+ f"then re-run."
255
+ )
256
+ begin, end = head.find(ROUTING_BEGIN), head.find(ROUTING_END)
257
+ if begin != -1 and begin > end:
258
+ raise RuntimeError(
259
+ f"{md_path} has the routing markers in reverse order (END before BEGIN). Splicing "
260
+ f"that would duplicate the text between them. Fix the markers by hand, then re-run."
261
+ )
262
+
263
+ if remove:
264
+ if begin == -1:
265
+ return "absent"
266
+ # Exact slice. The previous version ran rstrip("\n") + "\n" over the head, which silently
267
+ # collapsed the user's own blank lines: "KEEP\n\n" came back as "KEEP\n".
268
+ tail = head[end + len(ROUTING_END):]
269
+ if tail.startswith(nl):
270
+ tail = tail[len(nl):]
271
+ rest = head[:begin] + tail
272
+ if dry_run:
273
+ return "removed"
274
+ if rest.strip() or linked or not target.is_file():
275
+ _write_preserving_mode(target, rest, nl)
276
+ else:
277
+ target.unlink()
278
+ return "removed"
279
+
280
+ if begin != -1:
281
+ if head[begin:end + len(ROUTING_END)] == region:
282
+ return "unchanged"
283
+ merged = head[:begin] + region + head[end + len(ROUTING_END):]
284
+ outcome = "updated"
285
+ else:
286
+ merged = (head if not head or head.endswith(nl) else head + nl) + block
287
+ outcome = "added"
288
+
289
+ if not dry_run:
290
+ _write_preserving_mode(target, merged, nl)
291
+ return outcome
292
+
293
+
294
+ def install_claude_routing(md_path: Path, remove: bool, log_lines: list[str], dry_run: bool) -> None:
295
+ """Add or remove the routing block in one CLAUDE.md, reporting exactly what happened."""
296
+ verb = "removing" if remove else "writing"
297
+ log(f"\n[routing] {verb} the routing block in {md_path}", log_lines)
298
+ if dry_run:
299
+ outcome = apply_routing(md_path, remove, True, log_lines)
300
+ log(f" DRY RUN would report: {outcome}", log_lines)
301
+ return
302
+ outcome = apply_routing(md_path, remove, False, log_lines)
303
+ log({
304
+ "added": " added the routing block (the rest of the file was left as it was)",
305
+ "updated": " updated the existing routing block (nothing outside the markers changed)",
306
+ "unchanged": " already present and identical; no write",
307
+ "removed": " removed the routing block",
308
+ "absent": " no routing block was there; nothing to remove",
309
+ }[outcome], log_lines)
310
+
311
+
124
312
  def run_self_test() -> int:
125
313
  """Simulate installs into throwaway temp dirs, assert every skill is discoverable, and
126
314
  prove no real host directory is touched. Returns 0 on pass, 1 on failure. Writes nothing
@@ -215,6 +403,27 @@ def parse_args() -> argparse.Namespace:
215
403
  default=None,
216
404
  help="Project folder where a .cursor/rules/medsci-skills.mdc rule should be written.",
217
405
  )
406
+ parser.add_argument(
407
+ "--claude-project",
408
+ type=Path,
409
+ default=None,
410
+ help="Opt in: add a short skill-routing block to <folder>/CLAUDE.md so plain-language "
411
+ "requests in that project reach the right skill. Scoped to that folder; nothing else "
412
+ "in the file is touched.",
413
+ )
414
+ parser.add_argument(
415
+ "--claude-user",
416
+ action="store_true",
417
+ help="Opt in: add the same routing block to ~/.claude/CLAUDE.md, which Claude Code loads in "
418
+ "EVERY project. Larger footprint than --claude-project; prefer that one unless you "
419
+ "want it everywhere.",
420
+ )
421
+ parser.add_argument(
422
+ "--remove-routing",
423
+ action="store_true",
424
+ help="Remove the routing block from whichever target you name with --claude-project / "
425
+ "--claude-user. Leaves the rest of the file alone.",
426
+ )
218
427
  parser.add_argument("--dry-run", action="store_true", help="Print actions without changing files.")
219
428
  parser.add_argument(
220
429
  "--self-test",
@@ -343,6 +552,25 @@ def main() -> int:
343
552
  failures.append("cursor")
344
553
  log(f"\n[cursor] FAILED: {exc}", log_lines)
345
554
 
555
+ try:
556
+ if args.claude_project:
557
+ install_claude_routing(
558
+ args.claude_project.expanduser().resolve() / "CLAUDE.md",
559
+ args.remove_routing, log_lines, args.dry_run,
560
+ )
561
+ if args.claude_user:
562
+ install_claude_routing(
563
+ Path.home() / ".claude" / "CLAUDE.md",
564
+ args.remove_routing, log_lines, args.dry_run,
565
+ )
566
+ if args.remove_routing and not (args.claude_project or args.claude_user):
567
+ log("\n[routing] --remove-routing needs a target: pass --claude-project <folder> "
568
+ "and/or --claude-user.", log_lines)
569
+ except Exception as exc: # noqa: BLE001
570
+ failures.append("routing")
571
+ log(f"\n[routing] FAILED: {exc}", log_lines)
572
+ log(" the CLAUDE.md was left exactly as it was.", log_lines)
573
+
346
574
  # Place the one-click updater under ~/.medsci-skills/updater/ so a future update needs no
347
575
  # GitHub/terminal even if this download folder is deleted (best-effort; never fatal).
348
576
  if not args.dry_run:
@@ -84,15 +84,33 @@ def assert_contained(path: Path, container: Path) -> None:
84
84
 
85
85
  # ---------------------------------------------------------------- json io
86
86
 
87
- def atomic_write_json(path: Path, obj) -> None:
87
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
88
+ """Replace `path` with `data`, atomically: a reader sees either the old file or the whole new
89
+ one, never a half-written or truncated file.
90
+
91
+ Extracted from `atomic_write_json` so there is one fsync implementation in the repo rather than
92
+ two. The caller that forced the split writes a user's CLAUDE.md, where the alternative --
93
+ `Path.write_text`, which opens in "w" mode and truncates before writing -- turns an interrupted
94
+ install into a lost file.
95
+
96
+ Bytes, not text: the caller decides the encoding and the line endings, so nothing here can
97
+ translate a CRLF file into an LF one on the way through.
98
+ """
88
99
  path.parent.mkdir(parents=True, exist_ok=True)
89
100
  tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
90
- data = (json.dumps(obj, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
91
- with open(tmp, "wb") as f:
92
- f.write(data)
93
- f.flush()
94
- os.fsync(f.fileno())
95
- os.replace(tmp, path)
101
+ try:
102
+ with open(tmp, "wb") as f:
103
+ f.write(data)
104
+ f.flush()
105
+ os.fsync(f.fileno())
106
+ os.replace(tmp, path)
107
+ except BaseException:
108
+ # Leave nothing behind: the destination is still whatever it was before this call.
109
+ try:
110
+ os.unlink(tmp)
111
+ except OSError:
112
+ pass
113
+ raise
96
114
  # fsync the directory so the rename is durable.
97
115
  try:
98
116
  dfd = os.open(str(path.parent), os.O_RDONLY)
@@ -104,6 +122,10 @@ def atomic_write_json(path: Path, obj) -> None:
104
122
  pass # directory fsync unsupported (e.g. Windows) — os.replace is still atomic.
105
123
 
106
124
 
125
+ def atomic_write_json(path: Path, obj) -> None:
126
+ atomic_write_bytes(path, (json.dumps(obj, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
127
+
128
+
107
129
  def read_json_strict(path: Path):
108
130
  """Read JSON; raise TxnError on a corrupt/unreadable file (callers fail closed)."""
109
131
  try: