From 8388b731699bdca7fcda3c1dceae3ec8efe1c64d Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:15:39 +0200 Subject: [PATCH] feat(loop): formal issue approval via backlog -> planned labels (owner-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the implicit 'module:* label = approved work' convention with an explicit two-label workflow: - backlog: filed, not approved — default for every new issue, including issues agents file themselves. The loop never touches it. - planned: the owner's formal approval; assigned ONLY by the owner, never by agents/bots. ADVANCE now requires planned AND module:* (module stays the routing/boundary signal, not approval). Wired through: docs/USAGE.md (canonical description + historical note), pr-loop.md + pr-loop-self.md (STEP 0 cadence counts and ADVANCE filter, plus an explicit 'never assign planned' guard in the armed prompt), seed-issues.sh (bootstraps both labels; seeded tickets default to backlog), setup SKILL (consumer onboarding creates the labels and explains the gate). Labels already created in this repo; cockpit Phase 3 (#68) restructured as a backlog tracker with a Blocked-by chain (#69 -> #70 -> #71) as the first consumer of the new workflow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NTZEsfFT4mrZpv8CY67Frd --- .claude/commands/pr-loop-self.md | 6 +++--- .claude/commands/pr-loop.md | 6 +++--- .claude/scripts/seed-issues.sh | 9 ++++++++- .claude/skills/setup/SKILL.md | 10 +++++++--- docs/USAGE.md | 33 ++++++++++++++++++++++---------- 5 files changed, 44 insertions(+), 20 deletions(-) diff --git a/.claude/commands/pr-loop-self.md b/.claude/commands/pr-loop-self.md index cf92ec7..2c649c3 100644 --- a/.claude/commands/pr-loop-self.md +++ b/.claude/commands/pr-loop-self.md @@ -17,13 +17,13 @@ Prompt to use (the tick logic, with adaptive STEP 0): > Run one tick of the self-hosted PR loop. Resolve the repo with `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh repo view --json nameWithOwner -q .nameWithOwner`. Export `GATES_FILE=.claude/self/gates.json` for every gate/orchestration step, and instruct every spawned agent (orchestrator, implementers, reviewers) to read `.claude/self/gates.json` — NOT the placeholder root `.claude/gates.json` — as its adapter (module map, gates, review lenses). Follow docs/USAGE.md and .claude/agents/* for mechanics; reviewer lenses + consensus per `.claude/self/gates.json` (`correctness`, `tests`; consensus `all`). Every `gate.sh` invocation MUST be run as `GATES_FILE=.claude/self/gates.json bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/gate.sh `. ALL `gh` interaction (yours and every agent's) MUST run as the bot via `.claude/scripts/bot-gh.sh` — never bare `gh`; only `git` commits/pushes stay as the owner. > -> STEP 0 — adaptive cadence (every tick is a fresh full-context session, so cadence is the loop's dominant token cost — fire fast ONLY when the loop can act): count open PRs (base = `.claude/self/gates.json` merge.baseBranch, default main), open issues labelled `module:docs`, `module:harness`, `module:examples`, or `module:ci` (the self modules), and bot PRs with unaddressed CHANGES_REQUESTED (per pr-feedback.sh). Desired cadence = FAST "* * * * *" only if the loop has something to DO right now: ≥1 PR with unaddressed feedback, OR zero open PRs AND ≥1 open self module:* issue (ready to advance). WATCH "*/5 * * * *" if PRs are open but merely waiting on human review or CI — the loop can't hurry a human. Else IDLE "*/15 * * * *". If this job's current schedule != desired, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. +> STEP 0 — adaptive cadence (every tick is a fresh full-context session, so cadence is the loop's dominant token cost — fire fast ONLY when the loop can act): count open PRs (base = `.claude/self/gates.json` merge.baseBranch, default main), open issues labelled **`planned` AND** one of `module:docs`, `module:harness`, `module:examples`, `module:ci` (the self modules) — `backlog` issues are NOT work, and only the repo OWNER may add the `planned` label; you must never add it — and bot PRs with unaddressed CHANGES_REQUESTED (per pr-feedback.sh). Desired cadence = FAST "* * * * *" only if the loop has something to DO right now: ≥1 PR with unaddressed feedback, OR zero open PRs AND ≥1 open `planned` self module:* issue (ready to advance). WATCH "*/5 * * * *" if PRs are open but merely waiting on human review or CI — the loop can't hurry a human. Else IDLE "*/15 * * * *". If this job's current schedule != desired, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. > > Then, in order: > 1. POLL: run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/notify-poll.sh`; summarize new issues / PR comments / reviews and the open-PR status section. > 2. MERGE: run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/merge-ready.sh`; report each PR merged or why skipped. (It only merges PRs the owner APPROVED that are CI-green & mergeable; never approves.) After merging, it fast-forwards the LOCAL checkout to `main` when it is clean and already on `main` — so your terminal/IDE shows the latest without a manual pull. It never switches branches or clobbers uncommitted work; anything else is skipped (`local_sync` line reports the reason). > 3. ADDRESS FEEDBACK: run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/pr-feedback.sh`; for each PR it lists (bot-authored, with unaddressed CHANGES_REQUESTED), run orchestrator→worktree implementer→reviewer-lenses (self adapter: `GATES_FILE=.claude/self/gates.json`, lenses `correctness`/`tests`, consensus `all`) on the SAME branch, push to update the PR in place, and post the `` marker comment via bot-gh.sh. Do NOT merge here. -> 4. ADVANCE: ONLY when there are ZERO open PRs — pick the lowest-numbered open self `module:*` issue (`module:docs`, `module:harness`, `module:examples`, `module:ci`) with no feat/issue--* branch; drive it through the orchestrator using `.claude/self/gates.json` as the adapter (scope → worktree implementer → `GATES_FILE=.claude/self/gates.json gate.sh` gates → reviewer lenses `correctness`/`tests` consensus `all` → bot PR). One issue in flight at a time. +> 4. ADVANCE: ONLY when there are ZERO open PRs — pick the lowest-numbered open issue labelled **`planned`** AND a self `module:*` label (`module:docs`, `module:harness`, `module:examples`, `module:ci`) with no feat/issue--* branch; drive it through the orchestrator using `.claude/self/gates.json` as the adapter (scope → worktree implementer → `GATES_FILE=.claude/self/gates.json gate.sh` gates → reviewer lenses `correctness`/`tests` consensus `all` → bot PR). One issue in flight at a time. `backlog` issues are owner-unapproved: never pick them, and if you file an issue yourself, label it `backlog` — NEVER `planned` (that label is the owner's formal approval and is assigned by the owner alone; see docs/USAGE.md → "Autonomous loop & the issue queue"). > 5. If nothing actionable, reply exactly one line: "No actionable activity." > > Token discipline: only read docs/USAGE.md and .claude/agents/* when a step actually orchestrates agents (3–4); poll/merge-only ticks need only the script outputs. Keep the tick report to a few lines — it is telemetry, not documentation. @@ -31,4 +31,4 @@ Prompt to use (the tick logic, with adaptive STEP 0): ## 2. Run one tick now Execute steps 1–5 above immediately so the loop doesn't wait for the next cron fire. Report what happened (polled items, merges, feedback addressed, issue advanced — or "no actionable activity"). -Notes: requires the bot machine account set up per docs/USAGE.md (`GH_BOT_TOKEN` in `.env`, bot is a write collaborator) so PRs are bot-authored and the owner can formally Approve them. Cadence is adaptive and biased toward cheap ticks (each tick is a fresh full-context session): FAST (every minute) only while the loop has actionable work — unaddressed PR feedback, or a self `module:*` issue ready to advance with no PR in flight; WATCH (every 5 minutes) while PRs wait on human review/CI; IDLE (every 15 minutes) otherwise. New work is picked up within one WATCH/IDLE interval. For a tighter in-session cadence you can also run `/loop 5m /pr-loop-self`. +Notes: requires the bot machine account set up per docs/USAGE.md (`GH_BOT_TOKEN` in `.env`, bot is a write collaborator) so PRs are bot-authored and the owner can formally Approve them. Cadence is adaptive and biased toward cheap ticks (each tick is a fresh full-context session): FAST (every minute) only while the loop has actionable work — unaddressed PR feedback, or a `planned` self `module:*` issue ready to advance with no PR in flight; WATCH (every 5 minutes) while PRs wait on human review/CI; IDLE (every 15 minutes) otherwise. New work is picked up within one WATCH/IDLE interval. For a tighter in-session cadence you can also run `/loop 5m /pr-loop-self`. diff --git a/.claude/commands/pr-loop.md b/.claude/commands/pr-loop.md index 5bff835..0b2bf42 100644 --- a/.claude/commands/pr-loop.md +++ b/.claude/commands/pr-loop.md @@ -14,13 +14,13 @@ Prompt to use (the tick logic, with adaptive STEP 0): > Run one tick of the autonomous PR loop. Resolve the repo with `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh repo view --json nameWithOwner -q .nameWithOwner`. Follow docs/USAGE.md and .claude/agents/*; reviewer lenses + consensus per .claude/gates.json. ALL `gh` interaction (yours and every agent's) MUST run as the bot via `.claude/scripts/bot-gh.sh` — never bare `gh`; only `git` commits/pushes stay as the owner. > -> STEP 0 — adaptive cadence (every tick is a fresh full-context session, so cadence is the loop's dominant token cost — fire fast ONLY when the loop can act): count open PRs (base = gates.json merge.baseBranch, default main), open issues labelled module:*, and bot PRs with unaddressed CHANGES_REQUESTED (per pr-feedback.sh). Desired cadence = FAST "* * * * *" only if the loop has something to DO right now: ≥1 PR with unaddressed feedback, OR zero open PRs AND ≥1 open module:* issue (ready to advance). WATCH "*/5 * * * *" if PRs are open but merely waiting on human review or CI — the loop can't hurry a human. Else IDLE "*/15 * * * *". If this job's current schedule != desired, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. +> STEP 0 — adaptive cadence (every tick is a fresh full-context session, so cadence is the loop's dominant token cost — fire fast ONLY when the loop can act): count open PRs (base = gates.json merge.baseBranch, default main), open issues labelled **`planned` AND** module:* — `backlog` issues are NOT work, and only the repo OWNER may add the `planned` label; you must never add it — and bot PRs with unaddressed CHANGES_REQUESTED (per pr-feedback.sh). Desired cadence = FAST "* * * * *" only if the loop has something to DO right now: ≥1 PR with unaddressed feedback, OR zero open PRs AND ≥1 open `planned` module:* issue (ready to advance). WATCH "*/5 * * * *" if PRs are open but merely waiting on human review or CI — the loop can't hurry a human. Else IDLE "*/15 * * * *". If this job's current schedule != desired, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. > > Then, in order: > 1. POLL: run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/notify-poll.sh`; summarize new issues / PR comments / reviews and the open-PR status section. > 2. MERGE: run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/merge-ready.sh`; report each PR merged or why skipped. (It only merges PRs the owner APPROVED that are CI-green & mergeable; never approves.) After merging, it fast-forwards the LOCAL checkout to `main` when it is clean and already on `main` — so your terminal/IDE shows the latest without a manual pull. It never switches branches or clobbers uncommitted work; anything else is skipped (`local_sync` line reports the reason). > 3. ADDRESS FEEDBACK: run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/pr-feedback.sh`; for each PR it lists (bot-authored, with unaddressed CHANGES_REQUESTED), run orchestrator→worktree implementer→reviewer-lenses on the SAME branch, push to update the PR in place, and post the `` marker comment via bot-gh.sh. Do NOT merge here. -> 4. ADVANCE: ONLY when there are ZERO open PRs — pick the lowest-numbered open module:* issue with no feat/issue--* branch; drive it through the orchestrator (scope → worktree implementer → gate.sh gates → reviewer lenses → bot PR). One issue in flight at a time. +> 4. ADVANCE: ONLY when there are ZERO open PRs — pick the lowest-numbered open issue labelled **`planned`** AND module:* with no feat/issue--* branch; drive it through the orchestrator (scope → worktree implementer → gate.sh gates → reviewer lenses → bot PR). One issue in flight at a time. `backlog` issues are owner-unapproved: never pick them, and if you file an issue yourself, label it `backlog` — NEVER `planned` (that label is the owner's formal approval and is assigned by the owner alone; see docs/USAGE.md → "Autonomous loop & the issue queue"). > 5. If nothing actionable, reply exactly one line: "No actionable activity." > > Token discipline: only read docs/USAGE.md and .claude/agents/* when a step actually orchestrates agents (3–4); poll/merge-only ticks need only the script outputs. Keep the tick report to a few lines — it is telemetry, not documentation. @@ -28,4 +28,4 @@ Prompt to use (the tick logic, with adaptive STEP 0): ## 2. Run one tick now Execute steps 1–5 above immediately so the loop doesn't wait for the next cron fire. Report what happened (polled items, merges, feedback addressed, issue advanced — or "no actionable activity"). -Notes: requires the bot machine account set up per docs/USAGE.md (`GH_BOT_TOKEN` in `.env`, bot is a write collaborator) so PRs are bot-authored and the owner can formally Approve them. Cadence is adaptive and biased toward cheap ticks (each tick is a fresh full-context session): FAST (every minute) only while the loop has actionable work — unaddressed PR feedback, or an issue ready to advance with no PR in flight; WATCH (every 5 minutes) while PRs wait on human review/CI; IDLE (every 15 minutes) otherwise. New work is picked up within one WATCH/IDLE interval. For a tighter in-session cadence you can also run `/loop 5m /pr-loop`. +Notes: requires the bot machine account set up per docs/USAGE.md (`GH_BOT_TOKEN` in `.env`, bot is a write collaborator) so PRs are bot-authored and the owner can formally Approve them. Cadence is adaptive and biased toward cheap ticks (each tick is a fresh full-context session): FAST (every minute) only while the loop has actionable work — unaddressed PR feedback, or a `planned` module:* issue ready to advance with no PR in flight; WATCH (every 5 minutes) while PRs wait on human review/CI; IDLE (every 15 minutes) otherwise. New work is picked up within one WATCH/IDLE interval. For a tighter in-session cadence you can also run `/loop 5m /pr-loop`. diff --git a/.claude/scripts/seed-issues.sh b/.claude/scripts/seed-issues.sh index ccdfc6b..945096b 100755 --- a/.claude/scripts/seed-issues.sh +++ b/.claude/scripts/seed-issues.sh @@ -48,6 +48,11 @@ for m in "${modules[@]}"; do done gh label create "type:feature" --color 0052cc --description "Feature work scoped to one module" --force >/dev/null 2>&1 && echo " label ✓ type:feature" || true gh label create "type:infra" --color b60205 --description "Repo-wide tooling/infra (touches root config)" --force >/dev/null 2>&1 && echo " label ✓ type:infra" || true +# Approval workflow labels (docs/USAGE.md → "Autonomous loop & the issue queue"): +# every issue starts `backlog`; ONLY the repo owner moves it to `planned`, which +# is what makes it loop-eligible (together with a module:* label). +gh label create "backlog" --color bfd4f2 --description "Filed, not yet approved by the owner — the loop must NOT pick it up" --force >/dev/null 2>&1 && echo " label ✓ backlog" || true +gh label create "planned" --color 0e8a16 --description "Owner-approved for the autonomous loop (assigned ONLY by the owner)" --force >/dev/null 2>&1 && echo " label ✓ planned" || true # --- helper: create an issue unless an exact-title match already exists ------- existing="$(gh issue list --state all --limit 500 --json title -q '.[].title' 2>/dev/null)" @@ -64,9 +69,11 @@ BOUND="**Module boundary:** stay within this module's path; do not edit other mo # Replace the lines below with your real backlog. One mkissue per ticket: # mkissue "" "<comma,separated,labels>" "<body>" # Keep each ticket scoped to ONE module so workers get non-overlapping boundaries. +# Seeded tickets are labelled `backlog` — the OWNER promotes each to `planned` +# when it is approved for the loop (the loop ignores backlog issues). if [ "${#modules[@]}" -gt 0 ] && [ -n "${modules[0]}" ]; then - mkissue "[${modules[0]}] EXAMPLE — replace with a real ticket" "module:${modules[0]},type:feature" \ + mkissue "[${modules[0]}] EXAMPLE — replace with a real ticket" "module:${modules[0]},type:feature,backlog" \ "This is a placeholder created by seed-issues.sh to show the pattern. Delete it and add your own. $BOUND diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index 0acddf0..ab1d50a 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -35,7 +35,7 @@ Do these in order. Stop and report if a step genuinely can't proceed. ## 2. Explain the model up front (so answers are informed) Briefly tell the user how the loop decides what to build: -- The loop only builds issues **labelled `module:<name>`**, one at a time, when no PRs are open — an explicit opt-in queue. +- The loop only builds issues labelled **`planned` + `module:<name>`**, one at a time, when no PRs are open — an explicit, owner-gated queue. Every issue starts as `backlog`; **only the owner** promotes it to `planned` (agents never assign `planned`). - Each `module` maps to exactly one filesystem `path`, the **hard boundary** a worker may edit within. So the module list you define here is both the isolation boundary and the set of labels the loop understands. Include any non-code area you want automatable (e.g. `docs`, `.claude`, `examples`). @@ -98,9 +98,13 @@ before writing. Do **not** touch `.claude/settings.json` or any generic agent/sc `.env`, `.claude/settings.local.json`, and `.claude/state/` to confirm they actually resolve as ignored (e.g. a repo-level override elsewhere in `.gitignore` could still un-ignore one). -## 6. Create the module labels +## 6. Create the module + approval labels For every module `name`: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/bot-gh.sh label create "module:<name>" --description "<desc>" --force`. -Report created vs already-existing. Remind: **an issue is only loop-eligible once it carries a `module:*` label.** +Also create the approval-workflow pair (if `gh label create` is unavailable in the installed gh, use `bot-gh.sh api repos/<owner>/<repo>/labels -f name=... -f color=... -f description=...`): +- `backlog` (color `bfd4f2`) — "Filed, not yet approved by the owner — the loop must NOT pick it up" +- `planned` (color `0e8a16`) — "Owner-approved for the autonomous loop (assigned ONLY by the owner)" + +Report created vs already-existing. Remind: **an issue is only loop-eligible once the OWNER labels it `planned` and it carries a `module:*` label**; issues agents file must be labelled `backlog`. ## 7. Verify the bot account - Confirm `.env` has `GH_BOT_TOKEN` and the bot can see the repo: diff --git a/docs/USAGE.md b/docs/USAGE.md index 95de507..24e8bd8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -137,20 +137,33 @@ per-tick order, canonically defined in `.claude/commands/pr-loop.md`, is authori merge scripts described above are the mechanism it runs. Two human control points decide what the loop actually touches: -- **The `module:*` label is an explicit opt-in work queue.** The ADVANCE step only picks up **open issues - labelled `module:<name>`** — lowest-numbered first, one at a time, and only when there are zero open PRs. - An unlabelled issue is never touched, no matter what its title or body say. Two reasons: - - **Intent gate** — most issues are discussions, questions, or half-scoped bugs; a bot shouldn't - auto-implement them. The label is you saying "this is scoped and ready for an autonomous worker." - - **Mechanism** — the label maps issue → module → the worker's `path` boundary (`gates.json.modules[]`). - No module ⇒ no boundary ⇒ nothing safe to hand a worker. +- **Issue approval is a two-label workflow: `backlog` → `planned`.** An issue enters the loop's work + queue only when it carries **both** the `planned` label **and** a `module:<name>` label: + - **`backlog`** — filed but *not approved*. This is the default state for every new issue, including + issues the bot/agents file themselves (agents MUST label their own issues `backlog`, never + `planned`). The loop never touches a `backlog` issue. + - **`planned`** — the owner's formal approval: "scoped, reviewed, do it." **Only the repo owner + assigns `planned`** — no agent, subagent, or bot may ever add this label, to an issue it filed or + to anyone else's. Removing `planned` (or closing) is the owner's way to pull work back out of the + queue. + - **`module:<name>`** — routing, not approval. It maps issue → module → the worker's `path` boundary + (`gates.json.modules[]`). No module ⇒ no boundary ⇒ nothing safe to hand a worker, `planned` or not. + + The ADVANCE step picks the **lowest-numbered open issue labelled `planned` + `module:*`** with no + existing `feat/issue-<n>-*` branch — one at a time, and only when there are zero open PRs. Tracking + issues (plans split into `Blocked by` sub-issue chains) stay `backlog` forever so the loop works the + chain, never the tracker. - **Owner-approval merge gate.** Workers author PRs as the **bot** (`bot-gh.sh`); the MERGE step (above) only merges PRs the repo **owner** has Approved on GitHub that are CI-green and mergeable. It never approves on the owner's behalf. -**Corollary:** non-module (docs/infra) work is not loop-eligible until (a) its area exists as a module in -`gates.json.modules[]`, and (b) the issue carries the matching `module:*` label. Commenting "approved" on an -issue does nothing — nothing watches issue text. +**Corollary:** work is not loop-eligible until (a) its area exists as a module in +`gates.json.modules[]`, (b) the issue carries the matching `module:*` label, and (c) the **owner** has +labelled it `planned`. Commenting "approved" on an issue does nothing — nothing watches issue text; the +`planned` label is the only approval signal. + +> Historical note: before the `planned` label existed, the `module:*` label alone was the opt-in queue. +> If a repo predates the split, treat `module:*`-only issues as `backlog` until the owner adds `planned`. **Self-hosting this repo's own backlog?** **`/orchestrator:pr-loop-self`** (`.claude/commands/pr-loop-self.md`) runs the same loop mechanics self-hosted, against this repo's own `.claude`/`docs`/`examples`/`.github` backlog, using