git-workflow — validator-and-calver
Detail page of the git-workflow recipe card.
B5 — the fresh evaluator (pr-validator) + the fork+PR path
Section titled “B5 — the fresh evaluator (pr-validator) + the fork+PR path”The PR path is the sole landing path for everyone — write-access holders and outside contributors alike. There is no direct-merge fast path.
Validator handoff is parent-owned and complete
Section titled “Validator handoff is parent-owned and complete”Before spawning every fresh pr-validator round, the parent/orchestrator supplies a
self-contained handoff as transient spawn context. It is never recorded as an
author-worktree artifact. It names the PR, literal
superproject and target paths, current target protected-base and PR-head SHAs,
protected-policy object SHA, complete repository/gitlink map, clean status, operator
constraints, required approval categories, and mutation limits. For a submodule PR, the
target protected base and superproject gitlink remain separate objects; do not validate
one by guessing from the other.
The validator begins in that exact worktree, loads protected policy and dispatched skills
before candidate actions, and verifies the handoff with read-only commands. It has a
fresh context and role but does not create another worktree, clone, alternate Git
directory, cache, home, or /tmp workspace. A missing protected object, unreadable
required skill, uninitialized declared gitlink, absent approval, or ambiguous handoff is
BLOCKED: post the precise reason as the validator PR comment and stop. Do
not bootstrap, run setup, retry around the boundary, or substitute candidate policy.
- Write access (the default): the author opens the PR (B1 step 1); the fresh
pr-validator(new context, not the author’s context, not a teammate that authored the code) certifiesVerdict: PASS|BLOCKfor the ORG-WIDEcharly/pr-validatorGitHub Actions gate; on PASS that same gate’s workflow enables native auto-merge (squash) inline (there is no separate auto-merge workflow), and the org-widetag-on-mergeworkflow then finalizes the merge-time CalVer, writesCHANGELOG/<CalVer>.mdfrom the merged PR body, and tags the merged HEAD. Sequence + guardrails:marketplace/internals/agents/pr-validator.md. The gate never runsgh pr merge --admin(that bypasses it) and never force-pushes; aBEHINDbranch is recovered withgh pr update-branch(no force-push), then merged on the new head. - No write access — fork + PR: ensure a fork (
gh repo fork --remote), pushfeat/<slug>to the fork,gh pr create --base main --head <fork>:feat/<slug>with the full template body. A maintainer’s gate then validates and merges exactly as above. Never force-push, never need upstream write.
Why a status, not a review approval — and what it does not buy. GitHub forbids a
PR’s author from approving their own PR, and a local sub-agent shares the author’s
identity. A commit status carries no such GitHub-side restriction, which is why
the validate / validate check run (produced by the org required workflow) is
the required check. Be precise about what that means:
the status is agent-attested validation, not two-party review. The fresh
pr-validator supplies context independence (a new context re-deriving the verdict
adversarially, trusting no author claim) — which demonstrably catches real defects —
but it cannot supply party independence: same principal, same token, and
required_approving_review_count is 0, so no second party exists anywhere in the
flow. Claude Code’s auto-mode classifier names this exactly — its Self-Approval
rule blocks “triggering a pipeline that marks the agent’s own PR’s required checks as
passed … regardless of whether the agent believes it verified its own code,” its
Merge Without Review rule blocks “merging before a human approved,” and a sub-agent
the session spawned is “an automation the agent controls.” So by those definitions an
agent posting the status is self-approval and an agent merging is merge-without-review.
The project accepts that posture deliberately. What branch protection still mechanically
enforces: PR-only landing, linear history, no force-push (the ruleset’s non_fast_forward), and that the
status exists — never gh pr merge --admin, never a force-push, never editing protection.
Two separate gates — landing must clear both, and they are not the same thing.
-
permissions.allow— the deterministic command-prompt layer. The superproject’s committed.claude/settings.jsoncarries these rules (a whole team inherits them):"permissions": { "allow": ["Bash(gh pr merge:*)","Bash(gh api --method POST repos/opencharly:*)"] }These clear the prompt for the commands. The
successstatus POST is fully cleared by its rule — a superproject-rooted validator postssuccesswith zero denials (proven repeatedly). Exact spellings the rules pin:--method POST(never-X POST), arepos/opencharly/…path; the POST rule is POST-only, so it can never touch branch protection (a PUT). -
The auto-mode classifier — the semantic layer that fires on top.
permissions.allowclears the prompt forgh pr merge, but the classifier’s Merge Without Review soft-block fires anyway and is not cleared bypermissions.allow. With the merge rule present, superproject-rooted, and an AGENTS.md landing statement live,gh pr merge --squashwas denied for both a sub-agent and the main session: the classifier held that the instruction came from a coordinator agent rather than the user, and that the rulebook pre-authorization was manufactured classifier-steering intent, directing the merge to run outside auto mode so the user could review. The merge gate is real, separate, and stricter than the status gate.
What clears the Merge-Without-Review gate — these, in descending durability:
autoMode.allowin USER (~/.claude/settings.json) or MANAGED settings — the classifier’s own designed soft-deny override, and the only durable config-based grant. It is re-read from the settings file on every classifier evaluation, so it is immune to context compaction. Scope it in the rule prose (“opencharly org PRs only”). It is not read from a committed.claude/settings.json(the classifier ignoresautoModethere) and not reliably fromsettings.local.json— user or managed scope only.- Genuine, in-context user intent that names the action (the main session merging right after the user says “merge it”). Works, but fragile across compaction: the consent lives in the transcript, and once the transcript is summarized the classifier stops seeing it — a merge that worked earlier in a session was denied after the session resumed from a compaction summary.
- Not AGENTS.md prose. The classifier explicitly rejects an AGENTS.md authorization as “manufactured classifier-steering intent.” AGENTS.md records the policy; it does not function as classifier consent.
An agent cannot apply the autoMode.allow grant itself. Writing one’s own
permission-weakening config trips the classifier’s Self-Modification block, and that
block does not clear even on explicit in-chat user authorization (proven: the autoMode
edit was denied immediately after the operator authorized it). This is the security
guarantee that makes autoMode trustworthy — merge authority can be granted only by a
human editing the settings file hands-on (or an admin via managed settings). The agent’s
job is to hand the operator the exact rule to paste.
Two rooting/poison consequences remain load-bearing (about the status POST):
- The
permissions.allowrules live in the superproject. Claude Code resolves.claude/settings.jsonfrom the agent’s project root (its working directory), and neithermarketplace/norsdk/ships a.claude/. A validator rooted inside a submodule loads no permission rules, so even itssuccessPOST is denied as Self-Approval (“the only authorization comes from a<teammate-message>”) — unless a user/managed-level grant covers the action (user settings resolve independently of project root; see the scope-of-validity note below). See the autonomous-landing contract below. - A prior hook/classifier block poisons everything after it. A PreToolUse block followed by a reshaped retry of the same command is flagged as a bypass attempt — after which later actions a rule would otherwise resolve are denied. Treat any hook or classifier block as a hard denial: never reshape the command and retry (not even toward a form this skill prescribes). Report the block and stop. This has cost a real landing.
Operational consequence for the autonomous loop. A validator (or the main session)
can always do everything up to the merge — validate, and post the success status
(cleared by permissions.allow). The merge lands autonomously only when the operator’s
autoMode.allow rule is in effect (or, non-durably, under fresh in-context user consent).
Absent the rule, the operator completes the merge (gh pr merge <n> --repo <r> --squash --delete-branch — the status is already green) or the main session does under fresh
consent. Posting a failure status never trips Self-Approval (it marks nothing passed),
so a FAIL verdict always goes through. Never --admin; never --auto (the classifier
exempts --auto only on repos with required-reviews protection, and these set
required_approving_review_count: 0). Only a fresh pr-validator posts the status
(never the author, never a code-authoring teammate) — a context-level discipline, not an
identity guarantee.
The autonomous-landing contract — spawn every pr-validator rooted in the
superproject. The status-post half of the loop depends on the standing
permissions.allow rules in the superproject’s .claude/settings.json — committed
there, so a whole team inherits them and the success POST is autonomous by default
(the merge half additionally needs the operator’s autoMode.allow rule — the two-gate
model above). Claude Code resolves .claude/settings.json from the agent’s project root,
which is its working directory. A validator told to work inside marketplace/ or sdk/
roots in that submodule — which ships no .claude/ — and therefore silently loads no
permission rules at all. Its success status POST is then denied as Self-Approval
(“the only authorization comes from a <teammate-message>”), because nothing ever
authorized it — unless a user/managed-level grant covers the action (those resolve
independently of project root; the scope-of-validity note below). So:
- Spawn the validator with its working directory at the superproject root, for a PR
in any repo (superproject,
sdk,plugins,docs,box/<distro>). - Drive the submodule with a literal absolute path:
git -C /abs/path/marketplace …,gh <cmd> --repo <owner>/<repo>. Nevercd plugins && …(B7 states the same rule for the commit gate; it is equally load-bearing for permissions). - Verify after the fact: the agent’s transcript must live under
~/.claude/projects/-<superproject-path-slug>/, not the…-pluginssibling.
Proven by controlled experiment (single variable): with the rule text unchanged, a
pr-validator rooted in marketplace/ was denied even the success POST; the same validator
rooted in the superproject posted success with zero denials. Scope was the entire cause
of the status-post denial (the merge is the separate Merge-Without-Review gate above).
Scope of validity: the denial reproduces only when no
user/managed-level grant covers the action — user-level settings (e.g. the operator’s
autoMode.allow rule) apply independently of project root, and a submodule-rooted
validator under that rule posted success and merged with zero denials. Superproject
rooting remains the rule (project-level rules, the AGENTS.md hierarchy, and transcript
determinism are root-dependent); diagnose a denial by checking both settings layers. Do
not “fix” a denial by editing the rule until you have confirmed the agent’s project root. See /charly-internals:agents “Sub-agent operational invariants”
for the durable-verdict-first protocol every validator must follow (a permission denial
ends the agent’s turn, so it records its verdict before attempting any gated action).
CalVer — generated at MERGE, by tag-on-merge
Section titled “CalVer — generated at MERGE, by tag-on-merge”The single CalVer stamp is <YYYY.DDD.HHMM> from the current UTC time. It is
generated at the moment of merge, by the org-wide tag-on-merge workflow — not by the author.
Author-time stamps do not survive concurrency: with multiple PRs open and approved
out of order, an author-time CalVer collides (same minute) and mis-orders (merge
order ≠ author order). tag-on-merge generates it at merge and applies it to both
the changelog and the tag (the “one stamp for both” invariant, applied at merge
time). This holds for every repo, plugins and docs included:
plugins and docs are no CHANGELOG-exceptions — every plugins AND every
docs landing gets a CHANGELOG/<YYYY.DDD.HHMM>.md entry written from the merged
PR body (the PR body IS the changelog) exactly like the
superproject and box/<distro>. Once tagged,
the same finalized CalVer names each repo’s changelog file and its v<…> tag.
Every component is fixed-width zero-padded so filenames and tags sort
chronologically under a plain alphanumeric sort.
- The author writes no CalVer and no CHANGELOG file. The PR body IS the
changelog (title + body are the release-notes source). For a schema cutover
the author still bumps
#SchemaVersion/migrations.cuestrictly above currentmain— but the final CalVer for tag and changelog filename is minted at merge, never by the author. - tag-on-merge, at merge:
VER=$(date -u +%Y.%j.%H%M)(guard uniqueness — ifv$VERorCHANGELOG/$VER.mdalready exists on the currentmain, advance to the next free minute); after the squash merge, writeCHANGELOG/$VER.mdonmainfrom the merged PR’s title + body (a bot token — GitHub App preferred, PAT fallback — authorizes the protected-main write; the App is the ruleset bypass actor for the single CHANGELOG path); then tag the merged HEAD —git tag -a v$VER -m "<subject>" <merged-HEAD>andgit push origin refs/tags/v$VER(every repo;sdk,spec, andplugin-ghsubstitute their Go-modulev0.<…>form). A schema cutover’s#SchemaVersion+version:+migrations.cuere-stamp stays strictly above the current HEAD.
One fresh stamp per merge, immutable (only ever added), independent of charly.yml
version: (the schema version, bumped only by a cutover raising #SchemaVersion).
Every repo (superproject, box/<distro>, plugins, docs) mints v$VER on its
merged HEAD; sdk, spec, and plugin-gh (the proxy-consumed root-module repos) use their Go-module v0.<YYYYDDD>.<HHMM leading-zeros-stripped> scheme (not an exemption — Go modules require semver, which
forbids a leading-zero segment — 0733→733). A YAML schema/format change does
both: the schema bump and the tag. See /charly-build:migrate.
A merged CHANGELOG/<CalVer>.md is immutable, exactly like the tag sharing its
CalVer. Once tag-on-merge writes the entry from a PR body at
CHANGELOG/$VER.md, that file is closed history — follow-up work in
the same theme, branch, or session never appends to it or edits its content, even to
add a directly-related narrative. It gets its own
CHANGELOG/<merge-time VER>.md entry written from its own PR body at its own merge.
Editing an already-merged entry re-dates
history out from under its own filename↔tag pairing — a permanent divergence the
instant it lands, not a convenience.
After landing — cleanliness + report
Section titled “After landing — cleanliness + report”- Working-tree cleanliness. After the merge,
git statusis clean in every repo (refresh worktrees per B7 step 6). Untracked files that aren’t part of the cutover (test artifacts, build outputs) belong in.gitignore; if they aren’t, that joins the next thematic batch cutover (the Cutover Sizing Law,/charly-internals:cutover-policy“Cutover sizing — the batch law”). - Report format. The final message states: what was committed (commit subject +
hash, per repo), the confidence tier with the proof that supports it, the PR + the
pr-validatorverbatim verdict + the merge SHA + the finalizedv<CalVer>tag, and the pasted R10 outputs (exploratory + fresh-rebuild). The tier must match the project rulebook “AI Attribution”, keyed to the change class (/charly-check:check“R10 gate by change class”) — a Documentation-only change class commit lands atdocumentation reviewed, runtime classes at a runtime tier. A worked commit message:
Fix: Add fuse-overlayfs for container startup
Tested via overlay session on LOCAL system.
Assisted-by: <Harness> <Provider Full Model Name> (fully tested and validated)For every harness, the enforced trailer form is exactly
Assisted-by: <Harness> <Provider Full Model Name> (<confidence>). A validator
composing a squash-merge trailer preserves the authoring harness, full provider
model name, and proof-supported confidence.
A model-free bot body is the <Harness> <Runtime> form (defined by the A1
amendment in opencharly/action-review:prompt/validator.md). A body emitted by a
fixed, model-free generator — the nightly sync.yml / refresh.yml bots, a
committed CI printf/echo block with no LLM in the loop — has no AI provider and
no AI model, so the AI form cannot be truthfully filled. Such a body MUST END with
the italicized *Assisted-by: <Harness> <Runtime> (<confidence>)* (e.g.
*Assisted-by: GitHub Actions ubuntu-latest (fully tested and validated)*),
naming the automation and the runner identity. N/A in the provider slot and a
fabricated AI model name are BOTH forbidden — AI-authored (AI form) or model-free
(this form), never a hybrid; a 100% human-authored body omits the line.
The canonical constructor is marketplace/scripts/squash_body.py in the
superproject. It receives prose on standard input plus the concrete trailer via
--trailer, inserts the required blank line, and refuses output unless
git interpret-trailers --parse returns exactly that trailer. The validator
holds the returned bytes in one shell variable, passes the same bytes through
--check, and streams those exact bytes to gh pr merge --body-file -. It
then parses the fetched merge object and requires the same exact trailer before
tagging or reporting completion. Same-line attribution and a trailer separated
from prose by only one newline are both invalid, even when a plain-text search
finds the expected words. The parser output and merge SHA are mandatory
validator evidence.
Commit-time checks are an advisory mechanical backstop only; the fresh PR validator independently verifies the trailer on every repository, including a submodule checkout or standalone clone.
If validation FAILS or R10 fails
Section titled “If validation FAILS or R10 fails”A FAIL is a return-to-implementation signal, not a stopping point:
- Run
/charly-internals:root-cause-analyzerbefore attempting any fix — blind retry is forbidden. - Fix in the same working tree, on the same
feat/<slug>— never a new PR. - Re-push the fix (the head SHA moves →
charly/pr-validatorresets → the freshpr-validatorre-runs). Re-run the full R10 from a freshcharly update, not just the failing piece — a fix that survives only the targeted re-run is a regression in waiting. - The PR merges only when validation passes end-to-end on the final code.
Replacing a PR or an ISSUE — always comment on the OLD one
Section titled “Replacing a PR or an ISSUE — always comment on the OLD one”Whenever you REPLACE a PR or an issue with a new one — an auto-closed PR picked up
again (the gate auto-closes after N consecutive BLOCK rounds, or the operator
closes it), a re-scoped or split issue, a superseded or re-filed issue/PR — you
MUST post a comment on the OLD one that references the new one (its
number/URL) and states what it supersedes. The old thread is a durable, public
record: another agent (or the next pr-validator running comment intake) arriving
at it must be able to follow the thread to where the work continued, and issue
comments are the coordination channel (B2b). Never silently abandon a closed or
replaced PR/issue; the reference comment is mandatory, not optional. (A closed PR
whose diff never landed also has no tag/CHANGELOG; the new PR is the landing
vehicle.) The canonical, four-surface form of this rule lives in B2b
(“PR closed, work continues → the successor protocol”); this section states the
rule for a hand-off, B2b owns the detail.
If the BLOCK is body-only (no code change), fix the body then re-run the
gate MANUALLY with gh run rerun <run-id> on the failed charly/pr-validator
run (find it with gh run list --repo <r> --json databaseId,headSha,conclusion).
A re-run reuses the SAME GITHUB_SHA and updates the SAME validate / validate
check run IN PLACE (no duplicate, clears POISON) and re-reads the corrected body.
An empty re-freeze commit ALSO re-fires the gate (a synchronize push) but mints
a NEW head SHA. (A body edit alone does NOT re-run the REQUIRED workflow —
MEASURED and confirmed by the GitHub docs: it ignores on.types; do not rely on
edited.) The org-wide rerun label + sweep was RETIRED — it could fire without
a body change. See the SKILL’s “THE BODY-BEFORE-PUSH RULE”.
The POISON state — a duplicate same-name check-run keeps a PASS PR BLOCKED
Section titled “The POISON state — a duplicate same-name check-run keeps a PASS PR BLOCKED”Symptom. The validator’s latest verdict is PASS (and a check-run of the
required name is SUCCESS), yet the PR reads mergeStateStatus=BLOCKED forever
and gh pr checks shows fail.
Mechanism. GitHub’s rollup collapses check-runs sharing the required
check’s name to the WORST conclusion. A body-only fix does not move the head,
so re-dispatching the validator on the SAME head APPENDS a second check-run of
the same name beside the earlier one instead of replacing it. The newer
SUCCESS never cancels the older FAILURE. Measured: opencharly/plugin-vm#39 head
c9457a9 carried two validate / validate runs — failure (auto pull_request
at 04:40:06) and success (manual --ref dispatch at 04:44:11) — and the PR
stayed BLOCKED with a PASS verdict.
Distinguish it from a verdict BLOCK. A verdict BLOCK’s NEWEST same-name
run is the failure; POISON’s newest is SUCCESS with an older failure beneath.
Use marketplace/scripts/pr_state_watch.sh, which classifies exactly this and
names the shape in its output.
Remedy — the capability-free gh run rerun. Re-run the FAILED run in place:
gh run rerun <run-id> (find it via gh run list --repo <r> --json databaseId,headSha,attempt). A workflow re-run reuses the SAME GITHUB_SHA/ref
and updates THAT run’s check run — it does NOT append a second same-name
check-run, so it clears the POISON without a new SHA or an empty commit
(GitHub “Re-running workflows and jobs”; MEASURED on opencharly/sdk#301: the head
carried exactly ONE validate / validate check run before and after the
re-run). Do NOT gh workflow run re-dispatch on the same head — that mints a
NEW run (a NEW duplicate check-run) and re-poisons.
The push dedupe (orthogonal). A re-dispatch/push cancels the in-flight run via
the ONE org required workflow’s per-PR concurrency group
(opencharly/.github/.github/workflows/org-wide-pr-validator-required.yml):
concurrency: group: pr-validator-${{ github.repository }}-${{ github.event.pull_request.number }} cancel-in-progress: truepermissions: actions: write # REQUIRED for cancel-in-progress to workThat workflow is the SOLE producer of the required check (no per-repo dispatcher exists since the org-ruleset cutover), so every repo inherits the dedupe by construction.
Never re-dispatch the same head “to see if it clears” (it cannot), and never treat POISON as a verdict BLOCK by rewriting a correct body to appease a check that is merely red because of the duplicate.