Skip to content

Commit e8bd20f

Browse files
committed
Add claude init component for audience-tagged claude.md sections
`claude.md` claimed to deploy downstream through `repomatic init`, but no component ever wrote it. The six consuming repositories maintained their own copies by hand and drifted on roughly four in five of the sections they nominally shared; three of them carried almost none of it. The bundled agents made it worse, citing sections that were absent from the repositories they ship into. Each section now declares who it is written for, and `repomatic init claude` projects the matching ones into a repository while leaving untouched whatever that repository wrote for itself. `supersedes:` retires a renamed section the way `sync-labels` renames a label, since the merge keys on the heading title and a rename would otherwise strand the old copy beside its replacement. Generalizing plumage's hand-written sections surfaced two that had gone stale against the code: the workflow permissions contract is generated rather than hand-written, and PAT-gated `lint-repo` checks report as skipped rather than failing the job.
1 parent 584ad4b commit e8bd20f

11 files changed

Lines changed: 675 additions & 110 deletions

File tree

.claude/skills/repomatic-audit/SKILL.md

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,22 +116,30 @@ Compare these files against the upstream reference. **Before flagging absence as
116116
- Check `[tool.repomatic] agents.location` and `skills.location` for a sub-directory (e.g., `dotfiles/.claude/`); if those are set, look for `{location_parent}/CLAUDE.md`.
117117
- Try common alternates: `claude.md`, `CLAUDE.md`, `.claude/CLAUDE.md`, `dotfiles/.claude/CLAUDE.md`.
118118

119-
Fetch the upstream `claude.md` and identify universally applicable sections that the local file is missing. Focus on:
120-
121-
- Terminology and spelling rules.
122-
- Version formatting conventions.
123-
- File naming conventions (long-form extensions, lowercase, GitHub exceptions table).
124-
- Modern typing practices.
125-
- YAML scalar style (`>` vs `|`).
126-
- Markdown heading anchor rules.
127-
- Python version compatibility caveats.
128-
- Testing guidelines (e.g., "no test classes" rule, `@pytest.mark.once`).
129-
- Common maintenance pitfalls (CI URL, root-cause tracing, doc drift, type-check divergence, angle-bracket placeholders, route-through-existing-infra).
130-
- Command-line option conventions.
131-
132-
Do **not** flag upstream sections that are project-specific (e.g., CLI abstractions, knowledge placement table, workflow design rationale, release checklists, agent conventions, MyST docstring rules, `__init__.py` discipline, `TYPE_CHECKING` block patterns).
133-
134-
**Do not treat the local file as a downstream copy of upstream.** Many downstream `claude.md` files are personal-conventions documents with project-agnostic preferences (voice, commit policy, shell-command patterns, language preferences) that should not appear in upstream. Only flag missing content that is universally applicable.
119+
`repomatic init claude` only manages a root-level `claude.md`. When the file you find is somewhere else, the push direction below does not apply to it: audit the pull direction only, and note that the file sits outside what the sync can reach.
120+
121+
**Read the audience tags before judging anything.** Upstream marks every section with an HTML comment right under its heading, and that comment answers the question this audit used to answer by eye:
122+
123+
```markdown
124+
### Version formatting
125+
126+
<!-- audience: all -->
127+
```
128+
129+
`audience: all` and `audience: downstream` sections belong to upstream and are pushed down by `repomatic init claude`. `audience: upstream` never leaves `kdeldycke/repomatic`. A `; scope: package` qualifier narrows a section to repos that build a distributable, so a uv virtual project skipping one is correct, not missing. A section with **no tag at all** is the repository's own.
130+
131+
That splits the work into two directions, and they are not symmetric:
132+
133+
**Push (mechanical, do not hand-edit).** For each tagged local section, compare its body against upstream's. Any difference is stale, whichever side looks better: the fix is to run `repomatic init claude`, never to hand-patch the section or to propose the local wording upstream. Report the count and name the sections, but do not draft the diff. A tagged section upstream no longer sends here (retagged `upstream`, or scoped away) is an orphan the same command prunes.
134+
135+
**Pull (analytical, this is your job).** Untagged local sections are where repo-specific knowledge lives and are correct by default. Read them for two things:
136+
137+
1. **Content that generalizes.** A section describing something every repomatic consumer faces (how a workflow is regenerated, what a sync owns, how a pinned tool moves) is an upstream proposal. Say which audience it would carry, and check that no tagged section already covers it under a different title.
138+
2. **Content that upstream has since replaced.** A local section on a subject upstream now covers under a *different heading* is stale and will not be adopted, because the merge keys on the title. That is what upstream's `<!-- supersedes: {old title} -->` is for: propose adding one rather than asking the repo to delete its section.
139+
140+
**Degrade gracefully when the file carries no tags at all.** The `claude` component is opt-in, so a repository may never have run it. Say so once, treat the whole file as untagged repo-owned content, and audit only the pull direction. Do not hand-classify the file section by section against upstream: recommending `repomatic init claude` is both the smaller message and the durable fix.
141+
142+
**A downstream `claude.md` is not a copy of upstream, tags or no tags.** Personal or project conventions (voice, commit policy, shell patterns, language preferences) are deliberately absent upstream and must never be proposed for it, since upstream ships to repos with outside contributors where several such rules are wrong advice.
135143

136144
### 4. Upstream contribution opportunities (`upstream`)
137145

changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
- The bundled `repomatic-ship` skill now reads a green CI run as stale when supersession cancelled every run between it and `HEAD`, and ships a release's own reflection findings in that same release.
1010
- Bundled guidance now requires a duration baseline and per-job timestamps before calling a CI run hung, and `repomatic-ship` no longer holds a green release waiting for a binary build.
1111
- Fix stale guidance in the bundled agents and skills: the `update-docs` job credited to the wrong workflow, Furo logo and OpenGraph settings contradicting a working configuration, and a `repomatic` invocation that fails outside the canonical repository.
12+
- New `repomatic init claude` component projecting the audience-tagged sections of the bundled `claude.md` into a repository, leaving every section that repository wrote for itself untouched.
13+
- Bundled guidance now declares which repositories each `claude.md` section applies to, and documents what a repository consuming repomatic owns: its workflow content, its pin, and its configuration.
14+
- The bundled `repomatic-audit` skill now reads those tags to tell a synced section from a repository's own, instead of classifying each one by hand.
15+
- Fix two stale claims in the bundled guidance: the workflow permissions contract is generated rather than hand-written, and PAT-gated `lint-repo` checks report as skipped rather than failing the job.
1216

1317
## [`7.12.0` (2026-08-14)](https://github.com/kdeldycke/repomatic/compare/v7.11.0...v7.12.0)
1418

claude.md

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,78 @@ This repository is the **canonical reference** for conventions. Repos using the
1313

1414
**The push is not mechanical yet.** No `claude` component exists in {data}`~repomatic.registry.COMPONENTS`, so downstream copies are hand-maintained and have drifted badly: of the sections nominally shared with this file, roughly four in five have diverged, and three of the six downstream repos carry almost none of them. Until `repomatic init` owns the tagged sections, treat a downstream `claude.md` as stale rather than as evidence of what a repo was told.
1515

16+
## Consuming repomatic
17+
18+
<!-- audience: downstream -->
19+
20+
### Upstream conventions
21+
22+
<!-- audience: downstream -->
23+
24+
This repository takes its reusable workflows and much of its `pyproject.toml` configuration from [`kdeldycke/repomatic`](https://github.com/kdeldycke/repomatic), and follows the conventions established there. Every tagged section in this file is pushed from upstream and is the canonical form of a shared rule: edit it there, not here, or the next sync overwrites the edit. A section this repository wrote for itself carries no tag and survives every sync untouched.
25+
26+
**Contributing upstream:** propose a gap or improvement in the reusable workflows, the `repomatic` CLI, or a shared convention at [`kdeldycke/repomatic`](https://github.com/kdeldycke/repomatic/issues). Landing it upstream is what carries it to every other repository consuming it, instead of fixing it once here.
27+
28+
### Managed versus downstream-owned workflow content
29+
30+
<!-- audience: downstream -->
31+
32+
A generated workflow file has two parts. The first job is the **managed thin caller**, delegating to a reusable upstream workflow through a SHA-pinned `uses:`. It is rebuilt from scratch on every sync, so a hand edit to it is lost. Everything declared *after* it is **downstream-owned**: the sync slices the file at the end of the managed job body and carries the remaining text through verbatim, comments and blank lines included.
33+
34+
Position alone does not settle ownership: a multi-job caller declares canonical jobs after its first one, and those are regenerated too. Write a comment above your first downstream job saying so. The sync preserves it like any other extra content but never writes one itself, so its absence means the file has nothing in it that is yours to edit.
35+
36+
A fragment that is only comments and blank lines is still carried over, and deliberately does not count as a downstream job for the rule below.
37+
38+
### The permissions contract is generated, not hand-written
39+
40+
<!-- audience: downstream -->
41+
42+
<!-- supersedes: Known lint warning: top-level workflow permissions -->
43+
44+
When a workflow file carries downstream-owned jobs, the sync emits a top-level `permissions: {}` **and** the scopes the reusable workflow needs on the managed caller job. Both halves ship together, and neither is written by hand: a top-level `{}` on its own starves the managed call, which GitHub aborts at startup the moment a nested job asks for a scope the caller never granted.
45+
46+
So a `lint-repo` complaint about a missing top-level `permissions` key is a signal to re-run the sync, not to add the key yourself. Declare least privilege per job on the downstream-owned jobs instead, which is the half no sync rewrites.
47+
48+
### Bumping the repomatic pin
49+
50+
<!-- audience: downstream -->
51+
52+
Regenerate rather than search-and-replace, so codegen changes (new job permissions, reshaped triggers) arrive with the version bump instead of a release behind it:
53+
54+
```shell-session
55+
$ uvx --no-progress 'repomatic==X.Y.Z' init workflows/autofix.yaml workflows/lint.yaml
56+
```
57+
58+
Name the components explicitly. A bare `repomatic init` also materializes whatever else is in scope for the repository (labels config, a changelog), and an unqualified `workflows` selector bypasses scope gating.
59+
60+
Read the release notes for breaking changes needing a manual follow-up. A renamed autofix job is the recurring one: its old PR branch stays open, attached to a job that no longer exists.
61+
62+
### Tools called from workflows are version-pinned
63+
64+
<!-- audience: downstream -->
65+
66+
Every external tool a workflow invokes carries an exact version literal, in one of the shapes `sync-workflow-pins` recognizes: an action `uses:` ref, `uvx '{pkg}=={X.Y.Z}'` and the `--with` form for PyPI, `npm install {pkg}@{X.Y.Z}` for npm, and the `version:` input on `astral-sh/setup-uv`. That job resolves each to the newest release past `minimum-release-age` and opens a pull request. A tool invoked unpinned floats to the newest release on every run, outside the window entirely: see [§ Live registries with no cooldown knob](#live-registries-with-no-cooldown-knob).
67+
68+
A tool the runner image happens to provide is worse than an unpinned install, because it carries no version anywhere in the repository for anything to bump. Reach for a pinned dependency that already does the job instead of finding a way to pin the tool.
69+
70+
### PAT-gated checks degrade, they do not fail
71+
72+
<!-- audience: downstream -->
73+
74+
<!-- supersedes: `REPOMATIC_PAT` needs `Administration: Read-only` -->
75+
76+
Several `lint-repo` checks read GitHub API endpoints needing a scope on `REPOMATIC_PAT`. When the token lacks one, or the call fails for any other reason, the check returns an *indeterminate* result and is reported as skipped: it never fails the job. A missing scope costs coverage, not a red run, which is [§ Defensive workflow design](#defensive-workflow-design) applied to the audit lane.
77+
78+
Do not read a skipped check as a passing one. The setup-guide issue carries the pre-filled link for regenerating the token with the scopes a repository's own checks want.
79+
80+
### Configuration repomatic reads
81+
82+
<!-- audience: downstream -->
83+
84+
`[tool.repomatic]` in `pyproject.toml` belongs to the repository and is authoritative for every feature flag. The tool sections synced from repomatic's bundled templates are not: a local edit to a key the template owns is re-applied on the next sync, so put a deviation behind a `[tool.repomatic]` setting rather than editing the synced value and expecting it to hold.
85+
86+
The sync grafts rather than overwrites. A key the template does not define survives verbatim, a table present in both is merged so local sub-keys are kept, and an array gains its local-only items after the template's. Only a scalar the template also defines is overwritten, which is the point of an ongoing sync. Comments on a grafted node carry over; a comment beside a key the template owns does not, so record why a local entry exists somewhere the merge cannot reach.
87+
1688
## Cooldown on every install
1789

1890
<!-- audience: all -->
@@ -159,6 +231,18 @@ Two rules hold the tags together, both enforced by `tests/test_claude_md.py`:
159231

160232
A section a downstream repo wrote for itself carries no tag and is never touched: the sync owns the tagged sections and nothing else.
161233

234+
**Renaming a managed section is a migration, not an edit.** The merge keys on the heading title, so a rename strands the old section downstream, where it sits beside its own replacement contradicting it. Declare the old title on the section that replaces it, one comment per title:
235+
236+
```markdown
237+
### PAT-gated checks degrade, they do not fail
238+
239+
<!-- audience: downstream -->
240+
241+
<!-- supersedes: `REPOMATIC_PAT` needs `Administration: Read-only` -->
242+
```
243+
244+
The same reasoning as [§ Retiring a label is a migration, not a deletion](#retiring-a-label-is-a-migration-not-a-deletion), and the same one-way direction: a superseded title is claimed and dropped wherever it is found, so it must not also name a section that is still live. Adding a `supersedes:` for a title that never shipped is harmless, and cheaper than discovering the orphan in six repositories a year later.
245+
162246
### Keeping `claude.md` lean
163247

164248
<!-- audience: all -->
@@ -492,7 +576,8 @@ When releasing `kdeldycke/repomatic`, see [`docs/upstream-development.md` § Rel
492576
- **Write conformance tests when fixing a class of bugs.** For a bug that is a *category* (not a one-off), add a generic test locking in the invariant: iterate over every member of the set (registry entries, generators, exported symbols, data files) and assert the property uniformly via `@pytest.mark.parametrize` or a loop. Applies when the bug stems from a shared convention checkable from the codebase alone (no fixtures or mocks). Model: `tests/test_readme.py::test_docs_generator_matches_in_tree_state`. Shape: enumerate the population, assert on each, fail naming the violator. **Then prove it fails on the pre-fix state**, by running it against the old content rather than assuming: a conformance test written from the corrected text often only matches the corrected phrasing, so it passes on the very bug that motivated it and locks in nothing. When the invariant is genuinely narrower than the bug class (a rule keyed on one phrasing among several that state the same claim), keep the test and say so plainly, since a narrow guard is still worth having: what must not happen is reporting it as retroactive coverage it does not provide.
493577
- **The suite is hermetic against the host's own `repomatic` configuration.** The default config search derives from `click.get_app_dir`, so any config file in the developer's app folder is discovered by every in-process `CliRunner().invoke(repomatic, ...)`: a local setting can fail a test CI cannot reproduce. The `_isolate_user_config` autouse fixture in `tests/conftest.py` (aliasing click-extra's `isolated_app_dir`) repoints discovery at an empty per-test directory; tests exercising config loading pass an explicit path instead.
494578
- **Pass `encoding="UTF-8"` to `subprocess.run(..., text=True)` when output may contain non-ASCII bytes** (emoji in workflow `name:`, accented names). `text=True` alone uses the platform default (`cp1252` on Windows), raising `UnicodeDecodeError` only in Windows CI. Test helpers shelling out to `git show`/`git cat-file` are the usual offenders; production `read_text`/`write_text` already set it.
495-
- **Pass `encoding="utf-8"` to every text-mode `open()`, `read_text()`, and `write_text()` in tests, same as production.** The same Windows cp1252 default applies to file I/O, and the failure hides until content grows a non-ASCII character. Ruff's `PLW1514` (in the shared config) flags `open()` and receivers its inference can type, but misses unannotated `Path` locals (`doc = tmp_path / "page.md"`); when a change touches file I/O, run the suite once with `PYTHONWARNDEFAULTENCODING=1` (PEP 597) to surface every bare call at runtime, on any platform.
579+
- **Pass `encoding="UTF-8"` to every text-mode `open()`, `read_text()`, and `write_text()` in tests, same as production.** The same Windows cp1252 default applies to file I/O, and the failure hides until content grows a non-ASCII character. Ruff's `PLW1514` (in the shared config) flags `open()` and receivers its inference can type, but misses unannotated `Path` locals (`doc = tmp_path / "page.md"`); when a change touches file I/O, run the suite once with `PYTHONWARNDEFAULTENCODING=1` (PEP 597) to surface every bare call at runtime, on any platform.
580+
- **Spell it `UTF-8`, never `utf-8`, in both of the above.** Python normalizes either, so the difference carries no meaning and a mixed codebase only makes a reader stop to work that out. `tests/test_suite_hygiene.py::test_encoding_argument_spelling_is_uniform` pins the suite to the one spelling; production holds it by convention, with no exception at present.
496581

497582
### Choosing test-matrix targets
498583

docs/repomatic.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@
101101
:undoc-members:
102102
```
103103

104+
## `repomatic.claude_md` module
105+
106+
```{eval-rst}
107+
.. automodule:: repomatic.claude_md
108+
:members:
109+
:show-inheritance:
110+
:undoc-members:
111+
```
112+
104113
## `repomatic.cli` module
105114

106115
```{eval-rst}

0 commit comments

Comments
 (0)