Skip to content

core: new-repo skill and doctor --profile — a repo standard from a profile - #36

Merged
allenhutchison merged 14 commits into
mainfrom
feat/new-repo-and-doctor-profile
Sep 9, 2026
Merged

allenhutchison merged 14 commits into
mainfrom
feat/new-repo-and-doctor-profile

Conversation

@allenhutchison

@allenhutchison allenhutchison commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #35.

A repo profile is the standard a fleet of repos is held to, as one versioned JSON file. This PR adds the mechanism that applies it and the mechanism that reports drift from it. Maintainerd ships no profile and names no org — the profile is an argument.

What's here

  • plugins/core/references/profile-schema.md — the contract: shape, resolution, what the profile deliberately does not govern, the versioning rules, and the weekly drift-issue cadence it is built for. Worked example at references/example-profile.json.
  • plugins/core/scripts/profile-resolve.sh — validates a profile and resolves one repo's effective settings (defaults + language + override, key by key).
  • plugins/core/scripts/settings-diff.sh — diffs those against captured gh api output and prints the exact call that fixes each difference.
  • plugins/core/skills/new-repo/SKILL.md — create or --adopt a repo against a profile.
  • doctor --profile <path> — checks 14 (files), 15 (GitHub settings), 16 (required-check producers).
  • scripts/test-profile.sh — 77 assertions, wired into validate.yml.

The decisions worth reviewing

Report-only is a boundary, not a TODO. doctor --profile prints gh api calls and runs none of them, and --fix does not change that. new-repo's mutating half refuses outright — not degrades — when CI/GITHUB_ACTIONS is set, when GH_TOKEN is in the environment (an app token rather than a person), or when the session cannot ask a question. It still scaffolds and still prints the calls; what it won't do is leave a standard half-applied by nobody in particular.

Branch protection is fixed by one consolidated PUT. The endpoint replaces the whole object, so a call sending only the diverging key clears every key it omits — including the required checks. Per-key differences are printed as that call's reasons. The helper also warns when the PUT would drop a check the branch requires and the profile doesn't name.

A read that never happened is "couldn't verify", not a difference. Protection and rulesets need admin on most repos, and a drift report that turns a read-only token into six fabricated diffs is how a weekly drift issue gets muted.

A required check with no producer is a stop. It blocks every merge in the repo forever, looks identical to a working one, and fails on somebody else's PR rather than on the run that created it. Matching is on the check-run name, which is per job and not per workflow.

settings-diff.sh reads files, never the network — which is what makes the whole GitHub-settings diff testable offline, and means the code computing what should change can never be the thing that changes it.

Two corrections to the shape the issue sketched

Both because a check was otherwise unimplementable, not because the shape was ugly:

  • defaults.claudeSettings (optional) — the file table requires a settings.json declaring the expected marketplaces and plugins, and nothing in the profile said which. Absent, check 14 reports "not specified by the profile" rather than inventing an expected plugin set.
  • protection.strictRequiredChecks (optional, default false) — the protection PUT must send strict one way or the other. False by default: with a merge queue, strict is redundant and a good way to make a busy repo unmergeable.

And one key documented as deliberately unchecked: protection.requiredReviews.countsBotApproval has no GitHub setting behind it. It stays in the profile as the fleet's intent for the review skills, and every report names it as unchecked rather than passing it silently.

One scaffolded CI job, not one per required check. commands describes exactly one pipeline, so scaffolding a second job named after a second check would mean inventing what it runs — and a required check whose job does nothing is worse than one that doesn't exist. Every other effective check names a workflow the repo already carries; check 16 is what stops an unproduced name from reaching protection.

What review changed

Five rounds, all on settings-diff.sh, and all one family: a body built from the profile switches off everything the profile is silent about. Branch protection is a PUT that replaces the whole object, and the call this prints is one a human pastes, so every omission is applied by hand with full confidence.

The body is now built from the branch as read, with the profile's opinions laid over it. Silence inherits; only an explicit value changes anything. Specifically, over the five rounds:

  1. Unmodeled protections (locked branch, blocked creations, conversation resolution) were being dropped — now carried through.
  2. A failed protection read was rendered as an unprotected branch. GitHub answers "you may not read this" and "this branch is not protected" with the same JSON shape; only the latter message means the branch is open. A failed read now reports couldn't-verify and prints no replacement call at all.
  3. requiredReviews.count: 0 nulled the whole review object, taking code-owner review and last-push approval with it. Generalized: // was the trap throughout, since jq treats false as empty and would have promoted every explicitly-disabled setting to its fallback.
  4. Bypass allowances and app-pinned checks were warned about rather than preserved — but a warning above a call that still loses the thing is a warning read after the paste. Both turned out to be expressible in the PUT, so both are preserved. Follow-on: contexts is deprecated and still required by the request schema, and app_id is an optional integer there (null is the response's spelling).
  5. dismissal_restrictions is the third actor list, alongside restrictions and bypass allowances. One actors helper now translates all three, and required_pull_request_reviews is covered completely — all six fields.

The last round produced no findings.

Verification

sync-references.sh --check, check-links.py, every validate.yml step, test-coverage.sh, test-profile.sh (77/77), and claude plugin validate . all pass locally.

Resolution was also exercised against a real-world profile from outside this repo — seven repos across four language keys — and it validated unchanged and resolved as its design doc describes: additive override checks, a language-level exemption, and a repo-level explicit null beating its language's ratchet.

https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge.

Summary

  • Adds profile schema documentation, a worked example, validation, and layered resolution.
  • Adds report-only settings comparison with complete branch-protection replacement commands.
  • Adds new-repo and extends doctor with profile-based file, settings, and required-check validation.
  • Adds focused profile tests to the validation workflow.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  P[Versioned repo profile] --> R[profile-resolve.sh]
  R --> E[Effective repository settings]
  E --> N[new-repo]
  E --> D[doctor --profile]
  N --> F[Scaffold repository files]
  N --> G[Interactively apply GitHub settings]
  D --> C[Compare files, settings, and check producers]
  C --> O[Report drift and print repair commands]
Loading

A profile is the standard a fleet of repos is held to, as one versioned JSON file.
It is an argument, never a location: maintainerd ships no profile and names no org.

The document that mattered to get right is resolution, because two of its rules are
easy to state and easy to implement wrongly. An explicit `null` in a layer overrides
the layer beneath it while an absent key inherits — that distinction is the only thing
letting one repo be coverage-exempt while every other repo of its language ratchets,
so a resolver that treats missing and null alike is wrong in precisely the case
somebody wrote an override for. And `requiredChecks` is additive with no way to
subtract, because a standard whose required checks can be removed per repo is a
suggestion.

Two corrections to the shape the design sketched, both because a check was otherwise
unimplementable rather than because the shape was ugly:

- `defaults.claudeSettings` — the file table requires a settings.json declaring the
  expected marketplaces and plugins, and nothing in the profile said which. It is
  optional, and its absence is reported as "not specified" rather than guessed at.
- `protection.strictRequiredChecks` — the protection PUT has to send `strict` one way
  or the other. Default false: with a merge queue, strict is redundant and a good way
  to make a busy repo unmergeable.

And one key documented as deliberately unchecked: `requiredReviews.countsBotApproval`
has no GitHub setting behind it. It stays in the profile as the fleet's intent for the
review skills, and the settings diff names it rather than passing it silently, because
an unchecked key that reads as a pass is how a report loses its meaning.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Two skills read a profile, so the merge rules live in a script rather than in prose
twice. `profile-resolve.sh` validates the shape and prints one repo's effective
settings; `settings-diff.sh` compares those against GitHub and prints the exact call
that fixes each difference.

`settings-diff.sh` reads FILES, never the network. The caller captures the `gh api`
responses and hands them over. That is what lets the same diff be produced in a dry
run, in a test, and in a report a human reads before pasting anything — and it means
the code that computes what should change can never be the thing that changes it.

Three behaviours the tests exist to pin, each one a way a wrong standard could look
applied:

- A read that never happened is "couldn't verify", not a difference. Protection and
  rulesets need admin on most repos, and a drift report that turns a read-only token
  into six fabricated diffs is how a weekly drift issue gets muted.
- Branch protection is fixed by ONE consolidated PUT carrying the whole desired state.
  The endpoint replaces the object, so a call sending only the diverging key clears
  every key it omits — including the required checks. The per-key differences are
  printed as that call's reasons, not as calls of their own.
- The merge queue is a ruleset rule, not a protection key, so its fix is a ruleset
  POST and its read needs `includes_parents=true` — an org-level parent can be what
  supplies it.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Scaffolds every file the profile requires — settings.json, the CI workflow with the
coverage-ratchet steps, PR template, CODEOWNERS, dependabot config, review rules —
runs `bootstrap` for the repo config, creates the labels, and applies the GitHub
settings. Idempotent: a no-op on a conformant repo, and on a partial one it reports
what would change and asks, per section, so somebody adopting a repo they don't own
can take the files and decline the settings.

The skill has a hard seam through the middle. Files are reversible with `git checkout`
and can be written anywhere. Branch protection, merge methods and a merge queue are
org configuration with the blast radius of a production write, so that half runs only
in an interactive session, with the operator's own token, after they have read every
call. It refuses — not degrades — when `CI`/`GITHUB_ACTIONS` is set, when `GH_TOKEN`
is in the environment (an app token rather than a person), or when the session cannot
ask a question at all. A refusal still does the scaffold and still prints the calls;
what it will not do is leave a standard half-applied by nobody in particular.

Two stops worth naming. It will not write a required check into branch protection
without finding a producer for it first: a required check nothing produces blocks
every merge in the repo forever, looks identical to a working one, and fails on
someone else's PR rather than on this run. And it scaffolds one CI job, not one per
required check — `commands` describes exactly one pipeline, so a second job named
after a second check would mean inventing what it runs, and a required check whose job
does nothing is worse than one that doesn't exist.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
`--profile <path>` adds three checks after the coverage ratchet: the files the profile
requires (14), its GitHub settings diffed key by key with the fixing `gh api` call
(15), and a producing job or check run for every required check (16).

Check 15 is report-only, and that is not a missing `--fix` waiting to be written. The
skill already draws its mutation line at creating labels; settings writes sit on the
far side of it permanently, and `new-repo` — interactive, human token, every call read
first — is where they belong. `--fix` does not change what check 15 does.

Check 14 checks existence, not content, for every file but CODEOWNERS. A PR template
and a review-rules file are the repo's own prose; a check that diffed their text would
report every improvement as drift. CODEOWNERS is the exception because an ownership
rule that varies per repo isn't one. And a `files.*` key the profile doesn't carry is
reported as not specified rather than as a pass — silence should not read as approval.

Check 16 matches on the check-run name, which is per job and not per workflow: the
same rule check 13b already uses, and the same mistake it exists to catch. A workflow
file called ci.yml produces no check called `ci` unless a job in it is called that.

The report block is shaped to be an issue body, because the intended consumer is a
weekly drift issue per repo — one issue, titled and labeled the same way each week,
updated in place and closed on conformance. Maintainerd ships the check; the repo list
and the credentials live with the fleet, so the scheduler does too.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
A new skill and two new scripts reach nobody until the version moves — the install
cache is keyed by it. Minor, since `new-repo` and `doctor --profile` are additive.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Both skills assumed every repo scaffolds and carries `.github/workflows/ci.yml`. A
`none`-language repo — tracked but not built here, which is exactly how a profile
describes a tooling repo — has an empty `requiredChecks` and no commands, so there is
no `ci` job to look for and its absence is not drift. Reporting one would make the
first repo anybody adopts fail a check for conforming.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Comment thread plugins/core/scripts/settings-diff.sh Outdated
Comment thread plugins/core/scripts/settings-diff.sh Outdated
Two review findings, both about the same trap: this script prints a call a human will
paste, so anything it gets wrong is applied by hand with full confidence.

**The replacement dropped safeguards the profile has no opinion on.** Branch protection
has more keys than the seven a profile governs, and the endpoint is a PUT. A body built
from the profile alone would switch off required conversation resolution, a locked
branch, blocked creations or push restrictions as a side effect of fixing a merge
method — silently, and in the direction of less protection. The body now carries those
through from the branch as read. Two things the GET cannot be round-tripped into a PUT
are warned about rather than dropped quietly: review bypass allowances, whose object
shape the PUT will not accept, and app-pinned required checks, which the profile's
context-only list would unpin.

**A failed read read as an unprotected branch.** GitHub answers "you may not read this"
and "this branch has no protection" with the same JSON shape. Only the not-protected
message means the branch is open; a permissions error, a 404 or a rate limit means the
current state was never established. Those now report couldn't-verify and print no
replacement call at all — the alternative is fabricated drift in a weekly issue, and a
blind write pasted from it.

Seven new assertions cover both, including that the preserved keys actually survive
into the printed body.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Comment thread plugins/core/scripts/settings-diff.sh Outdated
…the profile

Third review finding, and the general form of the first two: a body whose keys default
to the profile's opinion switches off everything the profile is silent about. Requiring
zero approvals nulled `required_pull_request_reviews` outright, taking code-owner review
and last-push approval with it — those share the object with the approval count and say
nothing about it.

So the body is now built from the branch as read, with the profile's opinions laid over
it. Silence inherits; only an explicit value changes anything. That fixes the reported
case and the same bug in `enforce_admins`, `allow_deletions`, `allow_force_pushes`,
`required_linear_history` and `strict`, which were all one `//` away from it — and `//`
was the trap, since jq treats `false` as empty and would have promoted every disabled
setting to the fallback.

Also adds `bash -n` over both scripts as the first assertion in the suite. Both embed a
long single-quoted jq program where one unescaped apostrophe in a comment ends the quote
and turns the rest into shell; the symptom is twenty unrelated assertion failures, and
the check costs a millisecond. Learned the hard way, twice, writing this commit.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Comment thread plugins/core/scripts/settings-diff.sh
… about

Fourth review finding, and the right one: a warning above a call that still loses the
thing is a warning read after the paste. The gate on printing the replacement ignored
the warnings, so an operator following the report would have dropped review bypass
allowances or unpinned app-scoped required checks anyway.

The premise the warnings rested on turned out to be wrong, which is the better fix.
Bypass allowances round-trip exactly like `restrictions` — objects in, logins and slugs
out — so they are a translation, not a guess. And the PUT accepts
`required_status_checks.checks[{context, app_id}]`, not only a bare `contexts` list, so
a pinned check keeps its pin: the profile's context list is written in that form on a
branch that pins, with each observed app_id carried across.

One warning survives, because it is a real widening nothing can avoid: a check the
PROFILE ADDS to a branch whose existing checks are pinned has no pin to carry, so any
app could satisfy it. It names the added checks.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
… about

Follow-on to the previous commit: doctor's check 15, new-repo's step 9 and the script's
own header still described bypass allowances and app pins as unpreservable. They are
preserved now, and the only protection warning left is the one nothing can avoid — a
check the profile adds to a branch whose existing checks are pinned.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
…ot the response

Two shape corrections to the call this prints, both of which would have made the repair
fail rather than misapply:

- `required_status_checks.contexts` is deprecated but still **required** by the PUT
  schema, so it is sent whether or not `checks` is. Sending `checks` alone reads as the
  modern form and is rejected.
- `app_id` is an optional integer in the *request*; `null` is the *response* shape. An
  unpinned check now omits the key instead of sending a null the schema doesn't take.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
…had spliced

The "what the replacement preserves" passage had been rewritten twice in place and the
second edit landed mid-sentence, leaving the old warned-about-and-dropped text stitched
onto the new preserved-and-translated text. It now says one thing: the body is built
from the branch as read, silence in the profile inherits, and the values whose GET shape
differs from their PUT shape are translated — with the single unavoidable widening named
at the end.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
Comment thread plugins/core/scripts/settings-diff.sh Outdated
…and the last

Fifth finding in this class, and the one that made the pattern obvious: GitHub returns
an actor list as user/team/app objects in three places — top-level `restrictions`,
`dismissal_restrictions` and `bypass_pull_request_allowances` — and accepts all three as
logins and slugs. I had translated two of them and open-coded each, so the third read as
a key nobody had thought about, which is exactly what it was. Dropping it broadens who
may dismiss a review, every time an unrelated protection key is fixed.

One `actors` helper now does the translation, used by all three. And
`required_pull_request_reviews` is covered completely: all six of its fields are carried
— the two the profile has an opinion on, and the four it does not — which is stated in a
comment above the function so the next reader can check the claim rather than infer it
from the code.

Claude-Session: https://claude.ai/code/session_01HE7kFvQacjDaUYSwM1cbbc
@allenhutchison
allenhutchison merged commit 16dde8f into main Sep 9, 2026
2 checks passed
@allenhutchison
allenhutchison deleted the feat/new-repo-and-doctor-profile branch September 9, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: new-repo skill and doctor --profile (repo standard from a profile; drift report-only)

1 participant