Skip to content

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) certifies Verdict: PASS|BLOCK for the ORG-WIDE charly/pr-validator GitHub Actions gate; on PASS the org-wide auto-merge workflow enables native auto-merge (squash), and the org-wide tag-on-merge workflow then finalizes the merge-time CalVer, writes CHANGELOG/<CalVer>.md from the merged PR body, and tags the merged HEAD. Sequence + guardrails: marketplace/internals/agents/pr-validator.md. The gate never runs gh pr merge --admin (that bypasses it) and never force-pushes; a BEHIND branch is recovered with gh pr update-branch (no force-push), then merged on the new head.
  • No write access — fork + PR: ensure a fork (gh repo fork --remote), push feat/<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 charly/pr-validator 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.

  1. permissions.allow — the deterministic command-prompt layer. The superproject’s committed .claude/settings.json carries 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 success status POST is fully cleared by its rule — a superproject-rooted validator posts success with zero denials (proven repeatedly). Exact spellings the rules pin: --method POST (never -X POST), a repos/opencharly/… path; the POST rule is POST-only, so it can never touch branch protection (a PUT).

  2. The auto-mode classifier — the semantic layer that fires on top. permissions.allow clears the prompt for gh pr merge, but the classifier’s Merge Without Review soft-block fires anyway and is not cleared by permissions.allow. With the merge rule present, superproject-rooted, and a CLAUDE.md landing statement live, gh pr merge --squash was denied for both a sub-agent and the main session — verbatim: “the merge instruction came from a coordinator agent (not the user), the CLAUDE.md pre-authorization is manufactured classifier-steering intent … Run this outside auto mode so the user can 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.allow in 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 ignores autoMode there) and not reliably from settings.local.jsonuser 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 CLAUDE.md prose. The classifier explicitly rejects a CLAUDE.md authorization as “manufactured classifier-steering intent.” CLAUDE.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.allow rules live in the superproject. Claude Code resolves .claude/settings.json from the agent’s project root (its working directory), and neither marketplace/ nor sdk/ ships a .claude/. A validator rooted inside a submodule loads no permission rules, so even its success POST 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>. Never cd 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 …-plugins sibling.

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 CLAUDE.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.cue strictly above current main — 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 — if v$VER or CHANGELOG/$VER.md already exists on the current main, advance to the next free minute); after the squash merge, write CHANGELOG/$VER.md on main from 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> and git push origin refs/tags/v$VER (every repo; sdk substitutes its Go-module v0.<…> form). A schema cutover’s #SchemaVersion + version: + migrations.cue re-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 alone uses its Go-module v0.<YYYYDDD>.<HHMM leading-zeros-stripped> scheme (not an exemption — Go modules require semver, which forbids a leading-zero segment — 0733733). 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.

  • Working-tree cleanliness. After the merge, git status is 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-validator verbatim verdict + the merge SHA + the finalized v<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 at documentation 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.

A FAIL is a return-to-implementation signal, not a stopping point:

  1. Run /charly-internals:root-cause-analyzer before attempting any fix — blind retry is forbidden.
  2. Fix in the same working tree, on the same feat/<slug> — never a new PR.
  3. Re-push the fix (the head SHA moves → charly/pr-validator resets → the fresh pr-validator re-runs). Re-run the full R10 from a fresh charly update, not just the failing piece — a fix that survives only the targeted re-run is a regression in waiting.
  4. The PR merges only when validation passes end-to-end on the final code.