Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/autonomy/work-resolve-issue-issue-7469-c2f85a17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- `work-classes.md` names the babysit merge gate's rungs `merge` (`C2`) and `merge-reviewed` (`C3`) where it maps each rung to its promotion cells ([#7469](https://github.com/melodic-software/claude-code-plugins/issues/7469)).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Changed

- `merge.rung` takes names for what each rung needs before the merge authority merges: `off`, `merge` and `merge-reviewed`. `C2` and `C3` stay accepted as aliases for `merge` and `merge-reviewed`, and each conditional rung requirement applies to both spellings. The example and this repository's config use `merge`, and the README names the rung first with its code in parentheses ([#7469](https://github.com/melodic-software/claude-code-plugins/issues/7469)).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- The babysit merge gate reads the `merge.rung` names `merge` and `merge-reviewed` as the rungs `C2` and `C3`, at the same rank and with the same promotion cells, in the config, `CLAUDE_LANES_MERGE_RUNG_CAP` and `--lane-rung-cap`. `C2` and `C3` stay accepted as aliases, and the gate still reports the rung by its code ([#7469](https://github.com/melodic-software/claude-code-plugins/issues/7469)).
18 changes: 18 additions & 0 deletions .github/actions/resolve-config/merge-authority.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ test("rung C2 with authority local-babysit fails on both the missing diff-check
);
});

// Each rung above off has a name and a code kept as its alias; both spellings
// carry the same conditional requirements.
for (const rung of ["merge", "merge-reviewed", "C2", "C3"]) {
test(`rung ${rung} validates and carries the requirements of a rung above off`, () => {
assert.doesNotThrow(() => run(withMerge({ rung })));
assert.doesNotThrow(() => run(withMerge({ ...LANE, rung, "require-verifier-verdict": true })));
rejectedFor(withMerge({ ...LANE, rung }), /require-verifier-verdict/);
rejectedFor(
withMerge({ rung, authority: "local-babysit", "agent-identity": IDENTITY }),
/required property 'diff-check'/,
);
});
}

test("a rung outside off, its names and its codes fails the schema", () => {
rejectedFor(withMerge({ rung: "C4" }), /\/merge\/rung must be equal to one of the allowed values/);
});

test("an agent-identity without the [bot] suffix fails the schema: it must be a GitHub App", () => {
rejectedFor(
withMerge({ authority: "local-babysit", "agent-identity": "some-user" }),
Expand Down
9 changes: 9 additions & 0 deletions docs/contracts/loop-lane/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ topology, the escalation contract, the capability-tier vocabulary, or any loop-l
major bump, and additive guidance is a minor bump. A new model release re-audits the capability-tier
table (§3); drift found by that audit is recorded here.

## [13.1.1] - 2026-11-01

Patch. Wording only; no topology, escalation-contract mechanism, capability-tier or §4 invariant
changed.

- **Rung names (§1).** The unattended merge gate's rungs are named `merge` (`C2`) and
`merge-reviewed` (`C3`) where the contract maps each rung to its promotion cells, matching the
`merge.rung` names in the PR pipeline convention (#7469).

## [13.1.0] - 2026-10-31

Minor. Guidance only: confirms an interim §8 mechanism and adds how to read it; no topology,
Expand Down
4 changes: 2 additions & 2 deletions docs/contracts/loop-lane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ too ([the pr-merge lane](../../conventions/pr-pipeline/README.md#the-pr-merge-la
[`work-classes.md`](../../../plugins/autonomy/reference/guardrails/work-classes.md#merge-authorities-that-read-no-class)).
The unattended merge gate (`/source-control:babysit-prs`, `--unattended`) reads no class either,
by [owner decision](https://github.com/melodic-software/claude-code-plugins/issues/9668#issuecomment-6386299330):
no class caps its rung, its promotion evidence is read by rung, not class (C2 needs
`C2-auto-merge`, C3 every C3 cell), and its one class-named
no class caps its rung, its promotion evidence is read by rung, not class (`merge` (`C2`) needs
`C2-auto-merge`, `merge-reviewed` (`C3`) every C3 cell), and its one class-named
check is the trust hold, which refuses a pull request whose linked issue carries `work-class:
untrusted-provenance` or had it removed by a non-human. Its rung and rung cap remain pacing caps.
Higher rungs are opt-in per repository, up to full autonomy, where frontier-tier subagents resolve
Expand Down
10 changes: 6 additions & 4 deletions docs/conventions/pr-pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1029,10 +1029,12 @@ branch-cleanup:
max-deletes-per-run: 25

merge:
# The lane treats every PR the lanes App opened for one closing issue as
# class C2 (plugins/autonomy/reference/guardrails/work-classes.md) and reads
# no `work-class:` label. Its merge criterion is the verifier verdict below.
rung: C2
# The rung is a pacing cap: off merges nothing without a human; merge lets
# the merge authority merge; merge-reviewed also needs AI review to be
# blocking on the local babysit lane. C2 and C3 are accepted aliases for
# merge and merge-reviewed. The lane reads no `work-class:` label: its merge
# criterion is the verifier verdict below.
rung: merge
authority: pr-merge
agent-identity: melodic-automation-lanes[bot]
# The Apps whose touch holds a trust-gate path change for a person in
Expand Down
15 changes: 10 additions & 5 deletions docs/conventions/pr-pipeline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -991,10 +991,13 @@ run by the local babysit lane (loop-lane), or `pr-merge`. When it is absent the
reads the authority the wrapper runs as: `local-babysit` by default, `pr-merge` under
`--authority pr-merge` (below). `merge.report-only: true` makes that authority report each
decision and write nothing. `merge.rung` is a pacing cap: `off` merges nothing without a human,
and `C2` or `C3` lets the authority merge. Neither authority reads a work class
and `merge` or `merge-reviewed` lets the authority merge. On the pr-merge lane both merge on the
verifier verdict; on the local babysit lane `merge-reviewed` also needs AI review to be blocking
(its promotion cells, below). `C2` and `C3` are accepted aliases for `merge` and `merge-reviewed`.
Neither authority reads a work class
([`work-classes.md`](../../../plugins/autonomy/reference/guardrails/work-classes.md#merge-authorities-that-read-no-class)).

- `off` is the default. The first rung a repository adopts is `C2`, together with
- `off` is the default. The first rung a repository adopts is `merge` (`C2`), together with
`merge.diff-check`: the diff touches no denied path and stays under the size cap.
- No work class is read, and no class, missing class or diff shape caps the rung. The only
class-named check is the untrusted-provenance hold: a pull request whose linked issue carries
Expand Down Expand Up @@ -1065,7 +1068,8 @@ the rules that only tighten in
key, or either file the wrapper cannot read or trust, means `off`; a YAML without it leaves the
legacy value, and with no legacy file the rung is `off`, so the YAML alone never raises it), the
cap variable, and `off` while any demotion issue is open.
- **Cap variable.** `CLAUDE_LANES_MERGE_RUNG_CAP` (`off`, `C2` or `C3`) is a repository variable
- **Cap variable.** `CLAUDE_LANES_MERGE_RUNG_CAP` (`off`, `merge` or `merge-reviewed`, or the
aliases `C2` and `C3`) is a repository variable
only a human sets, and it can only lower the rung. The wrapper never writes it, and the identity
can read variables but not write them. A 404 after the variables listing proved readable means no
cap; any other failure means `off`.
Expand Down Expand Up @@ -1616,7 +1620,8 @@ except six:
stops the lane. The value is read once per run, and a stopped run cannot dequeue: to stop at
once, cancel the running pr-merge run and dequeue the lane's pull request by hand.
- **Cap variable.** For the same reason the workflow passes `CLAUDE_LANES_MERGE_RUNG_CAP` from its
`vars` context as `--lane-rung-cap`. Empty (unset) is no cap; `off`, `C2` or `C3` caps the rung;
`vars` context as `--lane-rung-cap`. Empty (unset) is no cap; `off`, `merge` or `merge-reviewed`
(or the aliases `C2` and `C3`) caps the rung;
a missing flag or any other value holds the rung at `off`.
- **Post-merge check and merges in flight.** Only merges the agent identity enqueued count. A
merge by a person or by another App, such as a release pull request, is not the lane's.
Expand Down Expand Up @@ -2121,7 +2126,7 @@ the same rules (`babysit_dependabot.py` here, `dependency-classify.mjs` in pr-un
always refuses, verdict or not.
- **Gate.** Either tier merges only once `ci-status` succeeded on its head, through the same gate
as the lane's own: mergeable, no `do-not-merge` label, no body hold, no blocking comment from a
human with write access, the rung (C2) and its cap, the kill switches, the post-merge check and
human with write access, the rung (`merge` (`C2`) or above) and its cap, the kill switches, the post-merge check and
the merges in flight. No closing issue, denied-path rule or diff risk score applies: `ci-status`
(and, for tier `review`, the review verdict) is the gate, and a workflow pin bump touches
`.github/**` by definition.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ models:
claude: review

merge:
rung: C2
# off, merge or merge-reviewed (README.md, Merge authority).
rung: merge
# The merge authority and, for local-babysit, the login the lane acts as
# (README.md, Merge authority). local-babysit requires agent-identity.
# authority: local-babysit
Expand Down
8 changes: 4 additions & 4 deletions docs/conventions/pr-pipeline/pr-pipeline.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,7 @@
"if": {
"required": ["rung", "authority"],
"properties": {
"rung": { "enum": ["C2", "C3"] },
"rung": { "enum": ["merge", "merge-reviewed", "C2", "C3"] },
"authority": { "const": "local-babysit" }
}
},
Expand All @@ -1018,7 +1018,7 @@
"if": {
"required": ["rung", "agent-identity"],
"properties": {
"rung": { "enum": ["C2", "C3"] },
"rung": { "enum": ["merge", "merge-reviewed", "C2", "C3"] },
"authority": { "const": "pr-merge" }
}
},
Expand All @@ -1030,9 +1030,9 @@
],
"properties": {
"rung": {
"enum": ["off", "C2", "C3"],
"enum": ["off", "merge", "merge-reviewed", "C2", "C3"],
"default": "off",
"description": "Pacing cap for the merge authority: off merges nothing without a human; C2 or C3 lets it merge. No work class is read; the only class-named check is the local babysit lane's untrusted-provenance hold (README.md, Merge authority)."
"description": "Pacing cap for the merge authority: off merges nothing without a human; merge or merge-reviewed lets it merge, and on the local babysit lane merge-reviewed also needs AI review to be blocking. C2 and C3 are accepted aliases for merge and merge-reviewed. No work class is read; the only class-named check is the local babysit lane's untrusted-provenance hold (README.md, Merge authority)."
},
"authority": {
"enum": ["local-babysit", "pr-merge"],
Expand Down
6 changes: 3 additions & 3 deletions lib/parse-concern-value.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ assert_exit "no args exits 2" 2 "$rc"
# --- Dotted keys and lists, read from the pipeline's canonical example ---
# Expected values are the literals on the cited lines of that example file.
EX="$SCRIPT_DIR/../docs/conventions/pr-pipeline/examples/claude-code-plugins.yaml"
assert_eq 'nested key merge.rung (example :173)' "C2" "$(bash "$SCRIPT" "$EX" merge.rung)"
assert_eq '--list prints a flow list one item per line, quotes removed (example :179)' \
assert_eq 'nested key merge.rung (example :201)' "merge" "$(bash "$SCRIPT" "$EX" merge.rung)"
assert_eq '--list prints a flow list one item per line, quotes removed (example :207)' \
$'.github/**\n.claude/**\ndocs/conventions/pr-pipeline.yaml' \
"$(bash "$SCRIPT" "$EX" merge.diff-check.denied-paths --list)"
assert_eq 'a block sequence of mappings is indexed (example :143)' "merge" \
Expand Down Expand Up @@ -215,7 +215,7 @@ assert_eq 'duplicate key: --strict names the second line' "parse-concern-value:
rc=0
out=$(bash "$SCRIPT" --strict "$EX" merge.rung) || rc=$?
assert_exit '--strict on a clean file exits 0' 0 "$rc"
assert_eq '--strict on a clean file prints the value' "C2" "$out"
assert_eq '--strict on a clean file prints the value' "merge" "$out"
assert_eq '--strict on a missing file prints the fallback' ".x" \
"$(bash "$SCRIPT" --strict "$TEST_TMPDIR/nope.yaml" memory_dir .x)"

Expand Down
4 changes: 2 additions & 2 deletions plugins/autonomy/reference/guardrails/work-classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ authority refuses every fork change request and every change request its automat
the dependency manager did not open.

The babysit unattended merge gate (`/source-control:babysit-prs`, its `--unattended` path) is
another. It reads no class; it needs its effective rung's promotion cells (rung C2 the `C2-auto-merge`
cell, rung C3 every C3 cell), read by rung, never by class. The one label it
another. It reads no class; it needs its effective rung's promotion cells (rung `merge` (`C2`) the
`C2-auto-merge` cell, rung `merge-reviewed` (`C3`) every C3 cell), read by rung, never by class. The one label it
reads by a class name is a trust hold: it refuses a change request whose linked item carries
`work-class: untrusted-provenance`, or had that label removed by a non-human, and it needs the
linked item vouched for by a human (written by one with write access, or given the admission
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1081,8 +1081,8 @@ any refusal is the blocker `unattended merge refused: <reason>`:
promotion evidence (`unattended.promoted`, from the babysit-loop resolver, which reads its four
options from the operator's managed and user settings, never from arguments or the
environment), and eligibility. The cells are the effective rung's, never the pull request's
work class: rung C2 needs `C2-auto-merge`, rung C3 every C3 cell
(`promotion-unqualified:<rung>` otherwise); an unreadable or untrusted resolver refuses as
work class: rung `merge` (`C2`) needs `C2-auto-merge`, rung `merge-reviewed` (`C3`) every C3
cell (`promotion-unqualified:<rung>` otherwise, naming the rung by its code); an unreadable or untrusted resolver refuses as
`promotion-unqualified:<reason>`. A settings file, or a binding, probe root, evidence source or
checker it names, that the lane user can write, or replace through any directory above it,
refuses; every worktree of the checkout is passed to the resolver as a `--worktree-root`, so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@
PARSER = Path(__file__).resolve().parents[3] / "lib" / "parse-concern-value.sh"

RUNG_RANK = {"off": 0, "C2": 2, "C3": 3}
# Each `merge.rung` name and the code it stands for. The codes stay accepted
# as aliases and are the form the gate compares and reports.
RUNG_NAMES = {"merge": "C2", "merge-reviewed": "C3"}
# `babysit_loop_merge` values as a rung.
LOOP_MERGE_RUNG = {
"human-only": "off",
Expand Down Expand Up @@ -1013,9 +1016,11 @@ def scalar(key: str) -> str | None:
found = values[key]
return found[0] if found else None

rung = scalar("merge.rung") or "off"
rung = rung_code(scalar("merge.rung")) or "off"
if rung not in RUNG_RANK:
notes.append(f"merge.rung {rung!r} is not off, C2 or C3")
notes.append(
f"merge.rung {rung!r} is not off, merge (C2) or merge-reviewed (C3)"
)
rung = "off"
lines = scalar("merge.diff-check.max-changed-lines")
if lines is not None and not re.fullmatch(r"[0-9]+", lines):
Expand Down Expand Up @@ -1432,6 +1437,12 @@ def lane_identity(repo: str, base_sha: str) -> tuple[str | None, str]:
# --- Effective rung ---------------------------------------------------------------


def rung_code(value: str | None) -> str | None:
"""`value` with a rung name (`RUNG_NAMES`) read as its code; any other
value, a code or an unknown one, unchanged."""
return RUNG_NAMES.get(value or "", value)


@dataclass(frozen=True)
class EffectiveRung:
rung: str
Expand All @@ -1444,6 +1455,7 @@ def effective_rung(
) -> EffectiveRung:
"""min(merge.rung, babysit_loop_merge, rung cap, demotion); an unknown input
is off. `cap` None means no cap (a proven 404); `demoted` holds it off."""
config_rung, cap = rung_code(config_rung), rung_code(cap)
inputs = {
"merge.rung": config_rung if config_rung in RUNG_RANK else "off",
LOOP_MERGE_KEY: LOOP_MERGE_RUNG.get(loop_merge or "", "off"),
Expand Down Expand Up @@ -1544,6 +1556,7 @@ def read_rung_cap(repo: str) -> RungCap:
status, value = _variable(owner, name, RUNG_CAP_VARIABLE)
if status == 404:
return RungCap(None, "no-cap")
value = rung_code(value)
if status == 200 and value in RUNG_RANK:
return RungCap(value, "cap-set")
return RungCap("off", "cap-unreadable" if status != 200 else "cap-invalid")
Expand All @@ -1561,6 +1574,7 @@ def rung_cap(repo: str, *, authority: str, lane_value: str | None = None) -> Run
return RungCap("off", "lane-value-missing")
if lane_value == "":
return RungCap(None, "no-cap")
lane_value = rung_code(lane_value)
if lane_value in RUNG_RANK:
return RungCap(lane_value, "cap-set")
return RungCap("off", "cap-invalid")
Expand Down Expand Up @@ -5757,7 +5771,7 @@ class Promotion:
reason: str | None

def promoted(self, rung: str | None) -> bool:
needed = RUNG_CELLS.get(rung or "")
needed = RUNG_CELLS.get(rung_code(rung) or "")
return bool(
self.cells
and needed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4605,7 +4605,9 @@ def _evidence_report(args: argparse.Namespace, reader: Reader) -> dict[str, Any]
rung = merge_policy.effective_rung(
config_rung=config.rung,
loop_merge=config.loop_merge,
cap=args.rung if args.rung in merge_policy.RUNG_RANK else "off",
cap=args.rung
if merge_policy.rung_code(args.rung) in merge_policy.RUNG_RANK
else "off",
demoted=False,
).rung
held = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,14 @@ def test_the_line_says_whether_a_whole_review_clears_the_risk_hold(self) -> None
self.assertIs(report["wholeReviewWanted"], whole)
self.assertIs(self.cli(passed, None, head="d" * 40)["wholeReviewWanted"], False)

def test_a_rung_name_from_the_room_scores_as_its_code(self) -> None:
for rung in ("merge", "merge-reviewed"):
with self.subTest(rung=rung):
report = self.cli(
ve.Evidence(None, HEAD, (), 0), "unsafe-path: docs/link", rung=rung
)
self.assertEqual(report["reviews"], "unsafe-path: docs/link")

def test_no_rung_from_the_room_holds(self) -> None:
for rung in (None, "off", "C9"):
with self.subTest(rung=rung):
Expand Down
Loading
Loading