git-workflow — multi-repo-coordination
Detail page of the git-workflow recipe card.
B2 — multi-repo / multi-worktree coordination
Section titled “B2 — multi-repo / multi-worktree coordination”One logical change spanning several repos uses the same feat/<slug> in each
(main, sdk, plugins, docs, box/<distro>), so the branches correlate. R10 runs
against the assembled superproject (submodule pointers at the feat/ commits)
— the whole change is verified before any PR is opened. Then land in dependency
order, each repo as its OWN two-step PR (author opens; fresh pr-validator
merges + tags):
- the sdk contract repo (
github.com/opencharly/sdk— NOT a submodule; this repo consumes it from the module proxy at the pinned require version, so only a touched CONTRACT lands there) — PR → native auto-merge + tag-on-merge → tagv0.<YYYYDDD>.<HHMM leading-zeros-stripped>(its Go-module tag scheme; the superprojectvYYYY.DDD.HHMMform is not a valid Go module version — e.g. superprojectv2026.185.0751⇄ sdkv0.2026185.751) — whenever the cutover touched sdk content; - each
box/<distro>submodule — PR → auto-merge + tag-on-merge tags (it hascharly.yml); plugins— PR → auto-merge + tag-on-merge tagsv<YYYY.DDD.HHMM>(nocharly.yml, so no schemaversion:bump — but the tag marks the merge, same as every repo);- the superproject — stage the now-MERGED submodule pointers (a touched sdk:
adopt the tagged sdk release as the new shared pinned require in every
module —
charly task mods-tidyre-syncs go.sum and the canonical-go.mod gate asserts the ONE shared pin; there is no gitlink and noreplace) → PR → native auto-merge + tag-on-merge tagsmain.
A producer PR must be merged (not merely green) before the consumer’s pointer
bump — the superproject pointer must reference a commit that is on the submodule’s
real main, which only the merge produces.
A valid base is an ASSEMBLED PAIR, not a lone submodule advance. A new consumer
cutover branches from a base that is valid only once BOTH halves have merged: an sdk
main advance is a valid consumer base ONLY after its superproject adaptation (the
tagged sdk release adopted as the new shared pinned require — there is no gitlink) has
ALSO merged. Branch a consumer off a bare
sdk main advance whose super side is still open and you pin a superproject state no
main records — the consumer’s R10 builds against a half-assembled base and its pointer
bump references a commit main has never seen. Wait for the pair before treating a
producer advance as a base.
Submodule-pointer-bump safety (step 3) — bump AFTER the session worktree exists,
then stage AND verify. A git switch / git checkout — and equally the
git worktree add that creates a session’s landing branch — re-materializes each
submodule at the gitlink the target records, silently discarding an unstaged
working-tree pointer bump (it happens even with submodule.recurse unset — an
unstaged gitlink is not carried across the switch / worktree-add). So bumping the
pointer before creating the session worktree (git worktree add .claude/worktrees/<slug> -b feat/<slug> origin/main, B1 step 0 — the feat branch
is created there at session start, never by git switch -c in the shared main
tree) — or merely git -C <sub> checkout <new> without git add — drops it from
the commit, and a git add <sub>; git commit afterward stages nothing because the
working tree was reset to the old pointer. Always, in order: (a) create the
session worktree + feat branch FIRST (B1 step 0); (b) THEN git -C <sub> checkout <new-commit> + git add <sub>; (c) VERIFY it is staged — git diff --cached --submodule=short <sub> must print <old>...<new>; (d) after committing,
confirm the commit records it — git show --stat lists <sub> and git ls-tree HEAD <sub> shows <new>. A pointer-bump commit whose --stat omits the submodule
is the silent-drop failure.
Attribution of the pointer-bump commit — derived from what it points at. When
the bumped submodule commit is itself all-documentation (a skill / *.md edit),
the superproject pointer-bump commit IS the Documentation-only change class and
lands at documentation reviewed: the fresh validator inspects the submodule’s
own old..new diff to certify it. A bump that integrates submodule CODE is a
code class and takes a runtime tier, the docs riding along. So a docs-only skill
cutover lands plugins (the *.md) at documentation reviewed, then the
superproject pointer bump at documentation reviewed too — both halves honest.
The derivation stops at the gitlink, and the reason is a general rule: a change
class must be decidable FROM THE DIFF. A gitlink is a pointer git itself
resolves, so “what it points at” is reachable from the PR — the validator reads
the submodule’s own old..new in the repo the PR is against, and the derivation
above is certifiable without leaving it. A sha inside a candy var: is not: it
is a string, and following it means cloning a remote repository at a named
commit. Were “the referenced content is documentation” the test, the class would
be a property of a tree the PR does not contain, and no validator could rule on
it from the PR — which is why re-pinning DOCS_REF after a docs merge is a
CONFIG change carrying its own bed gate (B6a step 4), no matter how documentary
the commit it names. Any pin the diff cannot resolve behaves the same way. When
the diff cannot decide the class, the class is the heavier one.
For the full multi-worktree end-to-end — the doc-tier git -C literal-path
rule, and the mandatory post-landing worktree refresh — see B7.
Per-module verification — verify by MODULE CLASS, and prove the fix is in the BINARY
Section titled “Per-module verification — verify by MODULE CLASS, and prove the fix is in the BINARY”Two mechanics that bite every cross-repo landing:
Verify each Go module by its CLASS, always with GOWORK=off (the repo is a Go
workspace, so module-level checks must disable it or they pull the workspace’s transitive
requires):
sdkis a STANDALONE-CONSUMED contract module (out-of-tree consumers import it directly), so it earns the full standalone battery:GOWORK=off go mod tidy && go mod verify && go build ./... && go test ./.... It MUST tidy and build cleanly on its own.charlyis a WORKSPACE MEMBER, not a standalone module: verify it withGOWORK=off go mod verify+ the WORKSPACE build. A full standalonego mod tidyoncharlyPOLLUTES itsgo.modwithcandy/plugin-*pseudo-requires (they resolve through the workspace, not the module graph), and a standalonego buildthat fails ONLY oncandy/plugin-*imports is an ARCHITECTURAL fact (the plugins are workspace siblings), never a defect to “fix” by hand-editinggo.mod.pluginscandies tidy PER-MODULE — each candy is its own module; tidy/verify them individually, never as one tree.
Prove a fix is in the BUILT BINARY by a content marker, NOT by the version stamp.
scripts/calver.sh derives the CalVer from the HEAD commit’s UTC time (TZ=UTC0 git log -1 --format=%cd --date='format-local:%Y %j %H %M' — the TZ=UTC0 is what makes
the bare %cd UTC; without it %cd uses the commit’s own TZ offset), so the stamp
identifies the SOURCE COMMIT, never the build moment — a
scripts/bootstrap-charly.sh on a DIRTY working tree reports the IDENTICAL version as the clean
commit under it. So charly version matching the expected CalVer does NOT prove your
uncommitted fix compiled in. Prove fix-presence by a content marker instead: strings bin/charly | grep '<a string unique to the fix>' (a new error message, flag name, or
symbol). The stamp answers “which commit”; the strings marker answers “is my change
actually in this binary”.
B2b — cross-session coordination: the PR comment is the channel
Section titled “B2b — cross-session coordination: the PR comment is the channel”Sessions are independent and OWN their artifacts (umbrella rule 9): a branch, worktree, file, or PR you did not create is another session’s, and you never edit, revert, reformat, stage, or commit it — not even to “clean up” or unblock yourself.
When another session’s PR blocks you (a projection lands before the source
that pins it; a consumer pin needs the producer merged; a shared file is
mid-flight on their branch), the ONE sanctioned channel is a PR comment on the
PR that owns the blocking file (or a new issue naming it). The comment must be
actionable: name your session slug, the exact file/gitlink/pin you need, what
change unblocks you, and the evidence. Then STOP; if it stays blocked, ask the
operator. Never work around it (R4), never edit their artifact, never
force-land, and never -D/reset their branch.
A fresh pr-validator runs comment intake: every comment on the PR is
investigated independently and weighed in the verdict (see
marketplace/internals/agents/pr-validator.md “Comment intake”). So a
coordination comment is not noise — it is the durable record the next reviewer
reads. Reply on the same thread; do not open a duplicate PR for scope already in
flight (the universal PR-gate audit).
Search first; file and OWN an issue. Before starting any non-trivial work
— and before filing anything — search the whole org for an EXISTING issue or PR
covering it (gh search issues <terms>, gh search prs <terms>,
gh issue list -S <terms>) and ADD to that thread (a comment with your
evidence/plan) rather than creating a duplicate. If none exists, create ONE
proper issue (a specific title, the problem, the evidence, the intended scope)
and reference it from every PR (Closes #N / relates to #N). Never open a
duplicate issue or PR.
The issue is the coordination point — claim it before you branch. To stop
two sessions working the same issue at once: check the issue for an owner
(assignee, a claim comment, a status label); CLAIM it by commenting (and
assigning yourself) BEFORE you create a branch, and state what slice you are
taking. If another session owns it, coordinate on the thread — offer to take a
slice, ask for status, or hand off — instead of opening a competing PR. Use
issue comments for every cross-session move (claim, block, hand-off, duplicate,
supersede); they are the durable record the next agent reads. Replacing a thread? When
you replace a PR or an issue with a new one, comment on the OLD one referencing
the new one (see “Replacing a PR or an ISSUE” in references/validator-and-calver.md).
Close the issue when its PR merges. A PR that resolves an issue references it
(Closes #N / relates to #N), and the author (or an agent) MUST ensure the issue
is CLOSED once the resolving PR merges — never leave a resolved issue open. If the
merge did not auto-close it (no Closes keyword, a squash that dropped it, or a
manual merge), close it explicitly and comment the resolving PR/commit. An issue
with no merged resolving PR is not resolved; do not close it as done.
Before EVERY push, read the NEW comments on the PR AND on every related issue. The pre-update-push read covers issues too: check the PR’s comments + checks AND the latest comments/state of each issue the PR closes or relates to, and ACT on each (answer, claim, hand off, or satisfy it in the pushed state). An unread issue reply can mean another session has claimed or changed the work since you branched.
The commenting session MUST follow up. A coordination comment is not
fire-and-forget: after posting it, the blocked session re-checks that PR’s thread
for a reply at every natural step — before its own next push/commit, when it
resumes, and at a BOUNDED cadence (a bounded poll, never a sleep loop — R4). When
the owning session answers, react accordingly: proceed if unblocked, refine or
answer if clarification is asked, or escalate to the operator if it stays blocked.
A one-shot comment with no follow-up leaves the block unresolved and the record
one-sided.
The PR-owning session MUST read AND act on every comment/reply before its next
push. The pre-update-push read (the “BEFORE ANY UPDATE PUSH” invariant) requires
reading the thread; it equally requires ACTING on it — each comment, including a
blocked session’s reply, is answered in-thread or addressed in the pushed state.
Pushing while an unanswered comment stands is a violation: the fresh pr-validator
weighs the whole thread (comment intake), so an unaddressed comment is a real
finding, not noise. The loop is: blocked session comments → owning session
answers/acts → blocked session re-checks and reacts.
B2b.1 — agent identity and the coordination verb grammar
Section titled “B2b.1 — agent identity and the coordination verb grammar”Identity is in the footers; authority is in the verb. An agent
identifies itself with TWO italic lines at the END of a comment or PR
body — the Agent: line naming the WORK and the session, then the
Assisted-by trailer. One canonical order on BOTH surfaces: Agent:
FIRST, Assisted-by: LAST (in a PR body this also satisfies the
validator, which requires the Assisted-by trailer to be the FINAL line).
The slug is NEVER appended to Assisted-by:
*Agent: `<slug>` · session `<ses_…>`**Assisted-by: <Harness> <Provider Model> (<confidence>)*- agent slug — a stable, human-readable, kebab-case name the session
chooses for the WORK (
c7-plugin-adoption), never the harness or the GitHub account. It is the authority key. - session — the harness session id.
Optional by default; MANDATORY on the trigger. Neither the Agent:
line nor the verb grammar is required of a solo agent on an uncontended PR
— never tax every comment. They become MANDATORY the moment EITHER holds:
- two or more agents work the same issue or PR, or
- the scope is a blocking dependency (any
BLOCKS/UNBLOCKSis in play).
On a triggered scope, EVERY agent-authored comment and PR body carries the two-line footer, and coordination comments open with the verb.
The first non-blank line of a coordination comment is ONE label from a CLOSED set, uppercase, then sharp prose in proper GitHub Markdown (headings, bold, tables, inline code):
| Verb | Means | Required fields |
|---|---|---|
CLAIM |
taking a scope before branching | scope ref, your slug, the slice |
OWNING |
the standing owner of a live scope | scope ref, your slug |
HANDING OVER |
the owner passes the scope to a named slug | scope ref, from-slug, to-slug, state + next step |
TAKING OVER |
a slug assumes an unowned or window-expired scope | scope ref, your slug, authority: |
BLOCKS |
this scope is blocked | blocked ref, blocking ref, what unblocks |
UNBLOCKS |
the block is cleared | blocked ref, who cleared it |
STATUS |
progress, no authority change | scope ref, your slug, state |
RESOLVED |
the scope is done | merged PR ref + v<CalVer> tag |
Rendered example — a claim on a PR:
**CLAIM** — `opencharly/layer-charly-internals#41`
Claiming the `git-workflow` B2b identity/grammar slice; will push `feat/agent-coord-grammar`.
*Agent: `agent-coord-grammar` · session `ses_f1ca67065ffe71KOr87cfv8aNw`**Assisted-by: OpenCode opencode-go/deepseek-v4.1-flash (analysed on a live system)*Same-account authority — the slug is authoritative, not the GitHub
author. Sessions on one host share ONE account, so the comment author
field cannot tell two agents apart; across accounts it still cannot name
the work. For a scope, the LATEST OWNING (or TAKING OVER) comment
wins. An agent MUST NOT push to a branch/PR another slug has claimed
unless (a) a HANDING OVER addressed it, (b) it posts TAKING OVER
naming its authority, or (c) the operator authorizes it. A coordinator
relaying another leg’s status is NOT a claim — a claim requires the verb.
The progress signal is a COMPLETED VALIDATOR RUN — never session
activity. Coordination progress on a scope is a charly/pr-validator
run COMPLETING on a new head (a new commit or a new comment also
count). A looping agent never falls quiet, so activity is a false
positive; and a peer waiting on a RUNNING validator looks quiet — it is
working, not stalled. Detect a stall/loop ONLY as no new completed
verdict within the window while the scope is open and unmerged — never
by silence. A monitor/coordinator MUST watch the scopes actually in
flight: the repo set changes as work moves, and a stale watch list
produces false stalls (a real failure today).
Window-based takeover — comment-FIRST. A TAKING OVER comment MUST
cite authority: hand-off | operator | window-expired and MUST be posted
BEFORE the first push to the taken scope. A takeover may proceed ONLY
after ALL of:
- A coordination comment posted on the scope FIRST — an ownership
board, or a
BLOCKS/STATUSaddressed to the owner, asking them to replyOWNING — ETAorHANDING OVER — <reason>. No comment, no takeover, ever. - The window elapsing with no answer from the original session AND
no progress (the progress signal above). The window is 60 minutes
— a FLOOR, measured from the timestamp of that comment: it may be
EXTENDED, never shortened without operator sign-off, and any answer
from the owner RESETS it. A scope with no progress for the window
while open+unmerged is
window-expired. TAKING OVER — authority: window-expiredposted BEFORE any push. The takeover is WITHDRAWABLE if the owner replies.
Auto-close carry-forward — continue on a CLEAN thread, cross-referenced
on FOUR surfaces. The validator auto-closes a PR after its BLOCK
threshold (AI_REVIEW_AUTO_CLOSE_AFTER, default 5). When a PR closes
(auto-close, superseded, or withdrawn) and the work continues in a NEW PR
for the same issue/scope, ALL FOUR are mandatory:
- On the CLOSED PR:
RESOLVED — superseded by #<n>, naming the successor, the closure reason, and that the closed thread is no longer acted on. - On the SUCCESSOR PR: its body names the predecessor (
Supersedes #<n>- closure reason), and the diff/body shows the predecessor’s findings were all fixed in ONE commit (not re-argued).
- On the ISSUE the work closes/relates to: a
STATUSnaming the successor, so anyone following the issue lands on the live PR. - Ownership transfer: a slug that claimed the predecessor but not the
successor must
HANDING OVER(or be named in the successor’s body); a silent drop is not allowed.
Never push to a PR at the block limit — a push that yields another verdict at the limit auto-closes it. Land ALL findings in ONE commit; never re-argue on a closed thread.
Sign-offs are ACCOUNT-gated, never prose-gated. A maintainer/operator
sign-off is valid ONLY when the comment is posted by a GitHub account in this
project’s maintainer set (atrawog, aitrawog) — the posting ACCOUNT is the
entire gate, verified via the author label (by @<login>), NEVER the prose
of a comment. Authorship is irrelevant: it makes no difference whether the
operator wrote the comment directly or an agent wrote it on the operator’s
behalf — account in the set → valid; out of the set → NOT a sign-off, however
worded. A self-asserted VALID — operator-DELEGATED / “posted at the
operator’s direction” label is forgeable and is NOT a sign-off. When a
rule requires a maintainer sign-off, cite it in the PR body with the author
label of the sign-off comment. An agent NEVER impersonates the operator. (The
validator’s rulebook AI_REVIEW_PROMPT owns the canonical statement; this
restates the mandate and references it.)
No R10 class exemption (current project state). A plugin-library or
schema change runs the full assembled disposable: true bed — there
is no “library module” waiver and no routing the bed to a consumer leg. A
bed-exemption sign-off is NOT an accepted route, from ANY account — a hard R10
rule is not sign-off-waivable. See the R10 change-class matrix
(/charly-check:check).
Amend an agent’s own PR by CONTINUING ITS SESSION — one editor per change. Continue that agent’s session by ID (a subagent conversation can be continued once idle); do NOT spawn a second editor on the same files.
State the dependency chain and the unblock order on the thread. When a scope is gated by another leg, name the exact chain (producer legs → consumer leg → corpus) and the unblock order, and comment the new tag on the waiting issue the moment it lands. No “a sibling session” / “deferred” framing.
Subagent accountability + identification. A session is FULLY RESPONSIBLE
for its subagents: it owns each dispatched subagent’s guidance, results, and
EVERY action the subagent takes (pushes, PRs, comments, takeovers, sign-offs);
responsibility does NOT delegate away with the task — the parent cannot disclaim
a subagent’s error. An action taken by a subagent MUST identify itself and name
its parent, so the chain of responsibility is followable — the footer extends to
*Agent: <slug> (subagent of <parent-slug>) · session <ses_…>* + the
Assisted-by: trailer (Agent: FIRST, Assisted-by: LAST). CLAIM /
TAKING OVER / HANDING OVER / BLOCKS are addressed to the accountable PARENT,
not merely a subagent. A CLAIM carries a PRODUCE-OR-HAND-OVER duty: produce an
artifact or HANDING OVER with the reason — “still investigating” past the
window is a silent block, not progress.
The parent’s MONITORING DUTY. Responsibility implies a duty to monitor: a
session MUST NOT dispatch-and-forget. The parent MUST actively watch each
subagent’s artifacts and INTERVENE when the subagent STALLS (no artifact
progress — no push, PR, or completed charly/pr-validator run — within the
window; the SILENCE/STALL ALARM) or receives ONE BLOCK after another (not
converging toward the 5/5 auto-close) — re-brief it with the exact findings, take
over the fix, or take over the PR under the comment-first hand-off rule, BEFORE
the auto-close. Liveness ≠ progress: judge progress by ARTIFACTS (a pushed
branch, a new commit, an opened PR, a merged tag), never a session heartbeat or
“still investigating”. BEFORE dispatch: complete, correct guidance (the owning
skills, the binding rules, the EXACT deliverable) and a defined completion
(merged, with the tag). AFTER dispatch: independently VERIFY the artifacts (PR
state, merge, tag, gate output) — never trust the subagent’s own report. AT
session end: no orphaned scopes — complete them, explicitly HANDING OVER, or
report blocked. Monitor the PRs blocking your own work too: fire on merged
(unblocked), closed (find its successor per the carry-forward rule), and
stall (takeover candidate under the comment-first 60-min rule). Monitoring a
subagent is how the parent discharges its accountability; a parent that does not
watch its subagents is NOT compliant.
A sign-off is never impersonation. An agent NEVER impersonates the
operator: no comment, sign-off, or approval may claim to BE them. A sign-off
is valid ONLY when the posting account is in the maintainer set (the
ACCOUNT-gated rule above) — never on the prose of a label. The validator’s
rulebook (AI_REVIEW_PROMPT) and the skill must agree on this form.
Grep the grammar (the label is the first non-blank uppercase line):
gh pr view <n> --repo <r> --json comments --jq '.comments[].body' \ | grep -E '^(CLAIM|OWNING|HANDING OVER|TAKING OVER|BLOCKS|UNBLOCKS|STATUS|RESOLVED)\b'gh pr view <n> --repo <r> --json comments --jq '.comments[].body' \ | grep -oE 'Agent: `[^`]+`' # who has a voice on this PRLifecycle: search → CLAIM (comment + assign where possible) → work →
HANDING OVER → RESOLVED (link the merged PR + its CalVer tag, and close
the resolved issue). The SAME protocol applies across accounts and
harnesses — the footer carries identity regardless of who owns the GitHub
account; there is no per-account case.
B2c — the PR backlog sweep (org-wide triage)
Section titled “B2c — the PR backlog sweep (org-wide triage)”When the ask is to clear the backlog — “check all open PRs, close the superseded, take over the stalled” — triage from LIVE state, never from a thread’s claim: a “blocked” PR’s blocker may already have landed, and a “superseded” PR may never have been closed.
- Enumerate authoritatively.
gh search prs --owner <org> --state open --limit 200is the snapshot, but search is index-backed; reconcile its total against per-repogh pr list --state openbefore declaring the set complete. - Classify each PR by its artifacts (
gh pr view <n> --json state,comments,statusCheckRollup,commits,mergeable, or arm the watcher):- superseded — a successor carries the same work (successor MERGED, or an
existing
Superseded by #<n>comment). Close it with the carry-forward comment if the thread lacks one — seereferences/validator-and-calver.md“Replacing a PR”. - actively worked — a commit, comment, or completed
charly/pr-validatorrun on the current head within the working window. Leave it. - stalled — none of the above. Start the formal takeover (B2b).
- superseded — a successor carries the same work (successor MERGED, or an
existing
- Act, then arm — never hand-poll. Close the superseded. For each stalled PR
post the ownership-board comment (
OWNING — ETA <when>/HANDING OVER — <reason>) and armgh_watch.sh --events comment,stall <item>so the reply OR the window’s end wakes you (references/watch-and-wake.md); after the window with no reply,TAKING OVER — authority: window-expired(B2b). Treat a cross-leg block as a dependency to verify, not a verdict to accept: name the chain and the unblock order on the thread.
B3 — agent teams in per-teammate worktrees
Section titled “B3 — agent teams in per-teammate worktrees”When an agent team parallelizes work, each teammate works in its OWN worktree
under .claude/worktrees/<slug>/ (B1 step 0) — the same worktree-per-session
model every session uses, never a shared checkout. A team is N worktrees, each
with its own bin/charly and its own freshness-guard scope; there is no
shared-tree alternative. Within a worktree, the check bed is the unit of
isolation: each teammate owns a disjoint check bed’s SOURCE files; distinct
beds get distinct charly-<bed> container/VM/domain names, and a bed run tags
every fixture IMAGE it builds with a per-run <bed-root>-<runCalver> tag (#75)
so two beds building the SAME fixture image name never race the store-global tag
namespace; host-port disjointness is not statically guaranteed, so every bed
uses port auto-allocation — never a hardcoded host port (the loader checks no
ports, so a collision surfaces only at deploy; manual port-picking is
forbidden), and a bed pins an
image → layers → files, so bed-ownership already isolates the source files each
teammate edits. Teammates edit; a PERSISTENT owner runs every full charly check run <bed> as a run_in_background task — the lead’s persistent
session, a background agent, or (interactive tmux) a split-pane teammate; an
in-process teammate CANNOT (its bg dies on yield).
- Teammates edit their bed-scoped files + run short foreground checks (
charly check box) — never the fullcharly check run, and never commit, push, or open a PR. The lead runs the full beds and, on R10 PASS, opens the SINGLE PR for the cutover (B1 step 1); a FRESHpr-validator(never a teammate that authored code) merges it. - Schedule longest-pole-first.
charly check runhas no bed-level concurrency and nocharlycap — the limit is host CPU/RAM/podman. Run ALL full beds as concurrent background tasks; order by expected DURATION, not bed count: launch the slow VM/desktop beds first and overlap the cheap pod beds, so wall-clock ≈ the slowest single bed, not the sum. - No shared freeze barrier — per-worktree self-freeze instead. Because each
worktree carries its own
bin/charlyand its own freshness-guard scope, a teammate editingcharly/*.goin its own worktree never trips another teammate’s bed run — there is no shared binary to freeze. The only freeze that applies is the WITHIN-worktree self-freeze: freeze your own worktree’scharly/*.gofor the duration of your own bed run (the per-tree freshness guard compares the invoked binary against the cwd’s sources at every heavy verb, mid-run), and keep one binary-build owner per worktree at a time. Full discipline:/charly-internals:agents“The charly binary in a multi-teammate / multi-worktree setup” + “Within-worktree self-freeze”.
B6 — cross-repo landing when a change is referenced via @github
Section titled “B6 — cross-repo landing when a change is referenced via @github”The resolver (EnsureRepoDownloaded) fetches a producer repo from the REMOTE at
the pinned ref, so a producer change on a local feat/ branch — or an OPEN,
unmerged PR — is invisible to a consumer’s R10. The producer PR must be MERGED
first. Staged landing:
- Develop producer (A) + consumer (B) on the same
feat/<slug>. - Land the producer FIRST: A’s own R10 PASS → open A’s PR → fresh
pr-validatorvalidates, merges, and tags Av<CalVer_A>— now an immutable, fetchable remote tag on A’s realmain. - Repoint the consumer:
charly box reconcilerewrites B’s@github.../A:...pins tov<CalVer_A>(see/charly-build:reconcile). - Authoritative consumer R10 against the real tag: B’s R10 now fetches A from
the pushed
v<CalVer_A>— verified against exactly what shipped. - Land the consumer: open B’s PR → fresh
pr-validatorvalidates, merges, tags. - New candy: a new candy has no standalone R10 — its gate is the consuming
image’s build. A lands a provisional
v<CalVer_A>(layer +go test/charly box generatesmoke); step 4 (B’s image R10 against that tag) is the real gate. On failure, fix A, land a new tag (immutable + accumulate — never move the old one), re-reconcile, re-run step 4.
Each repo gets ONE R10 against ITS final code; repos land producer→consumer. Multi-level chains (A→B→C) recurse the same way.
B6a — the documentation landing is the same chain
Section titled “B6a — the documentation landing is the same chain”This project publishes opencharly.ai from GENERATED trees, so a prose edit is a
producer→consumer landing exactly like a code one, and the same “producer must be
merged first” rule applies. /charly-build:docs owns the projection model; the
load-bearing part of it is that the two chains have DIFFERENT lengths — candy and
box prose is a ONE-hop projection read straight off each repo’s charly.yml,
while skill prose is a TWO-hop projection that charly docs generate reads from
the marketplace/ tree, never from candy/*/charly.yml.
The commands below are maintenance THIS REPOSITORY performs on itself. They are not steps an charly user runs, and they never belong on a reader-facing page.
- Edit the source, never a generated file — the candy’s
description:/plan:, or the owning candy’sskill:content. - For a skill edit, project and land
pluginsFIRST (charly marketplace generate, then thepluginsPR). Locally the docs generator reads the dirtymarketplace/tree, so a local regeneration picks the edit up immediately; every other reader gets it only once the gitlink advances. Advancing that gitlink WITHOUT regeneratingdocsin the same landing is precisely what leaves the published site stale, and only the drift gate will say so. Superproject-facing drift is EXPECTED while the projection PR is in flight — the superproject necessarily still pins the old gitlink until the projection merges — so that redness is not the tagging bar for the projection PR itself: its gate is thepluginstree, and the superproject catches up at step 5. - Regenerate and land
docs— a docs PR that bumps the charly pin indocs/.gitmodulesand carries the regenerated pages; the docs repo’s deploy workflow is the gate (it regenerates on every run and fails on any diff). Regeneration rewrites the generated trees WHOLESALE, so a mirror already behind cannot be brought forward selectively: every pending page lands with the next commit or none of them do. Budget for carrying someone else’s backlog when the mirror has drifted. - Re-pin the new
docsmerge incandy/docs-site/charly.yml;task docs:pinis the gate (it comparesDOCS_REFagainst the docs repo’s currentmainhead) and asserts more than one occurrence (/charly-tools:docs-siteowns the pin contract). This step edits candy CONFIG rather than prose, so its own gate is thecheck-docsbed — a documentation-only change class does NOT cover it, and the commit that carries it cannot claim thedocumentation reviewedtier. Positively: its gate ischarly box validatePLUScheck-docs— thedisposable: truebed that composes the changed entity, throughdocs-site-app— atfully tested and validated. Watch for it on a landing whose only “extra” is this re-pin: the step silently upgrades the WHOLE cutover’s gate, becauseDOCS_REFis emitted as an ENV line above the clone step, so re-pinning it changes the emitted Containerfile and therefore the image. The general rule the boundary rests on is in B2, “the derivation stops at the gitlink”. - Bump the superproject gitlinks for whichever submodules moved.
Why that order and not any other — the two-directional pin rule. A superproject gitlink and a mirror’s own pin are DIFFERENT relations, and conflating them is what produces an unpinnable mirror:
- Source-covers. The superproject pins a
pluginssha that must CONTAIN every source edit producing what the mirror shows. It is a coverage claim: “everything rendered downstream is generated from something at or before this sha.” - Mirror-reflects. The
docsmirror is a PROJECTION of one specific source state. It is an identity claim: “these pages are what that state renders to.”
The two shas are not the same and are not required to be — but the second is only meaningful relative to the first. A mirror leg that accumulates a SECOND merge before its superproject leg pins the first makes the mirror unpinnable by any state the superproject can reach: the mirror becomes a composite of projections from several submodule commits, and no single pin reproduces a composite.
Landing the mirror ahead is not the hazard — it is REQUIRED, because a superproject cannot pin a commit that does not exist yet. The hazard is the GAP, and specifically other legs’ projections landing inside it.
The mechanism, stated because a reader without it reconstructs the unqualified rule
and then finds it forbids the procedure below: several legs share ONE submodule
pointer. While your mirror merge sits unpinned, every other leg that lands projects
into the same tree. The superproject then has no sha that reproduces only your
change — the mirror is a composite, and the last leg to pin inherits every
projection beneath it. That is not hypothetical: it is how a four-cutover forced
union — charly#278 — came to carry four cutovers’ candy sources in one commit: the
check-verb resolver, the git-workflow landing lessons, the merge-tree guard, atop
the R4a sweep. None of those authors chose to couple their work; no intermediate
self-consistent superproject state existed for them to land against.
So: land each mirror leg immediately before its superproject leg, keeping at most one
outstanding merge per mirror — not one across all of them. B6 legitimately has
plugins and docs both outstanding between steps 2/3 and step 5; that is two
mirrors with one merge each, which is fine. What is not fine is two merges in ONE
mirror awaiting a single pin. Then nothing intervenes, each mirror contains only
your own projection, and the shas coincide naturally with no reconciliation to
perform.
Proof that this is a live invariant rather than an aspiration: regenerate at
main’s OWN pins and count the pages that move. Zero means every published page is
reproducible from the state the superproject currently points at. A non-zero count is
the composite above, and it names its own repair — the pages listed are exactly those
whose source landed out of order.
B7 — Multi-worktree landing + refresh (the canonical end-to-end)
Section titled “B7 — Multi-worktree landing + refresh (the canonical end-to-end)”When this project is driven from multiple git worktrees sharing one .git, only
ONE worktree can have main checked out at a time. The main tree stays on
main; every feature session works in its OWN worktree under
.claude/worktrees/<slug>/ — one worktree per session, created at session
start and removed after landing (B1 step 0 + B4 “Worktree hygiene”). Every
“land + update all worktrees” follows this EXACT ordered sequence. It composes
B1 (branch loop), B2 (per-repo order + pointer-bump safety), B4 (sync/prune).
0. Pre-flight (worktree safety). git worktree list → note which worktree
holds main. Pin ONE worktree for the whole edit→commit→push sequence; drive every
step with a literal absolute path git -C /abs/path …. NEVER a leading
cd+\-continued chain (it scopes every later command into the submodule) and
NEVER a shell variable for a path — shell variables do NOT persist between Bash
tool calls, so a WT=… set in an earlier call is EMPTY later and git -C "$WT/plugins" silently becomes git -C /plugins (this was a real failure). Verify:
git -C /abs rev-parse --show-toplevel == the path you edited AND git -C /abs status --short lists your edits.
1. Sync-before-start. git fetch origin --prune --tags; ff local main to
origin/main (B4).
2. Open the PRs in dependency order, same feat/<slug> in every repo (sdk when
touched → box submodules → plugins → superproject). Per-repo mechanics = B2 + B1
step 1; pointer-bump safety = B2 step 3.
Two proven additions:
- plugins docs commit at
documentation reviewed:git -C <LITERAL-abs-plugins> commit …. The literal path keeps repository selection explicit and lets the fresh validator inspect the plugins diff independently. Do NOT use a shell variable that may be unset or an in-command directory change. - box/
re-stamp (schema-HEAD bump): edit on the submodule’s own feat branch; gate =charly box validatestandalone (a version-stamp change has no build behavior — building proves nothing); commit, open PR, native auto-merge + tag-on-merge tags.
3. Land main via the PR — NEVER git push origin main (blocked) and NEVER git switch main in another worktree (git fatals “already used by worktree”). The
org-wide native auto-merge performs the server-side squash merge (it advances
origin/main remotely); then advance the LOCAL main ref where it lives:
git -C <main-wt> merge --ff-only origin/main. A local main now only ever
fast-forwards to what was merged remotely.
4. Tags: annotated only (git tag -a v<…> -m "<desc>" <merged-HEAD>), applied
by tag-on-merge on the merged main HEAD and pushed as refs/tags/… (allowed by
the pre-push-gate; the user token triggers the release-binary workflow). Verify git cat-file -t <tag> == tag AND git ls-remote --tags origin <tag> is non-empty.
5. Reconcile (when box submodules were re-stamped). Bump the superproject
GITLINKS +1 to the re-stamped box mains (a separate superproject PR; B2 step-3
safety) — do NOT bump the @github build pins: they lag deliberately, charly box reconcile reports “already reconciled”, and bumping them pulls multi-cutover
producer drift (a separate version-adoption cutover, NOT reconciliation).
6. Refresh EVERY worktree — PART of landing, NEVER a follow-up (R2). For each
worktree: the one on main → git -C <wt> merge --ff-only origin/main; each other
→ git -C <wt> checkout --detach origin/main; THEN refresh only already-initialized
submodules with git -C <wt> submodule update --recursive (no --init). Initialize
only the paths the next task needs: a root R10 worktree needs sdk, so
use git -C <wt> submodule update --init --recursive sdk; box-submodule work
initializes its own declared path. Never blanket-initialize every submodule merely to
refresh a worktree: it creates unnecessary per-worktree clone state. The Skill tool
serves skills from the MAIN worktree — a stale main worktree silently serves STALE SKILLS
to sessions, so refreshing it is mandatory. (A M <sub> in a worktree used only for
the ff-merge is this drift, not lost work.)
A disposable bed that builds from a submodule builds from the submodule’s
ON-DISK WORKING-TREE checkout, NOT from the committed gitlink alone. After a
CLEAN gitlink auto-merge (no conflict), git ls-tree HEAD <sub> can already
show the correct new pin while the submodule’s on-disk HEAD is still the OLD
commit — a gitlink merge does not itself check out the new submodule content;
that needs its own git -C <wt> submodule update --checkout <path> (or
--recursive over the initialized set), same as any other post-merge refresh
in this step. Skipping it means the bed silently builds STALE source even
though the committed pointer is correct.
Landing gotchas (each cost real time): git merge-base --is-ancestor A B ERRORS if B’s object isn’t
fetched (common for a sibling-worktree submodule) → git fetch first; cross-check
git ls-tree origin/main <sub> before concluding “DIVERGED”. A git grep -- <submodule-path> from the superproject is a FALSE ZERO (git grep does not cross a
gitlink) → git -C <sub> grep for the R5 sweep. The authoritative feat-branch head
SHA comes from git ls-remote origin refs/heads/<branch> — gh pr view --json headRefOid LAGS a fresh push and will post the status on a stale SHA. Deriving the
merge-time CalVer from a COMMIT’s recorded date (git show --date=format:'<fmt>' <sha>, git log -1 --format=%cd) uses that commit’s own author/committer TZ
offset, not UTC, and can mis-stamp the tag/changelog by hours — $VER always comes
from the LIVE clock at the moment of merge (date -u +%Y.%j.%H%M; the CalVer contract
lives in references/validator-and-calver.md, not in this file), never from a
commit’s stored timestamp. A metric or grep verification
command (a LOC count, a git grep sweep, a file-count claim) run from an
ambient, cd-inherited working directory silently measures the WRONG tree the
moment more than one worktree is in play — anchor every such command to an
explicit repo root (git -C /abs/path grep …, or a git rev-parse --show-toplevel cross-check first), never a bare relative command trusting
the shell’s current directory. A stronger, WRITE-side form of the same
footgun: a MUTATING command (anything that writes files or runs git submodule update as a side effect — a codegen task such as
charly task cue-gen, or the old task cue:gen it replaced, are canonical
offenders) run against a stale, ambient cwd doesn’t just misreport, it
MUTATES the wrong tree. Failure mode: a fresh evaluator ran a cue-codegen
task with a persisted shell cwd that had drifted to the main session
worktree — the task’s own git submodule update chain rewound 5 submodule
checkouts there before the mistake was caught (fully restored,
disclosed). So every mutating task/command invocation in an isolated-worktree
workflow (a validator run, a teammate’s branch work, a spike) carries an
explicit cd <worktree> && anchor IN THE SAME compound command — never a
bare mutating task (or any command with submodule/file-write side effects)
trusting a cwd set by an earlier, unrelated step.