diff --git a/.claude/skills/deliver/SKILL.md b/.claude/skills/deliver/SKILL.md index 52ee1ff..b3b2919 100644 --- a/.claude/skills/deliver/SKILL.md +++ b/.claude/skills/deliver/SKILL.md @@ -104,7 +104,10 @@ The PR body must contain `Closes #` and nothing that closes any other issue; CI checks that the branch name and the closing reference agree. Beyond that: -- **task**: walk every line of Done when and say how each was checked. +- **task**: walk every line of Done when and say how each was checked. If + no other sub-issue of the parent is still open, also walk the parent's + Completion conditions: verification is part of delivery, and this PR is + the last one. - **bug**: name the regression test; it is the triage test, now passing. - **feature**: walk every Completion condition and say how each was checked. @@ -153,5 +156,13 @@ gh issue comment --body-file gh issue edit --remove-assignee @me ``` +If Blocked on is anything but "nothing", also move the issue out of the +ready state so the next agent does not hit the same wall. The maintainer +re-adds `:ready` once the blocker is gone: + +```bash +gh issue edit --add-label :blocked --remove-label :ready +``` + A handoff is state, never spec, and rule 12 applies: 150 words, plain words, conclusion first. Do not post progress updates at any other time. diff --git a/.claude/skills/triage-bug/SKILL.md b/.claude/skills/triage-bug/SKILL.md index e1f120c..1aada2e 100644 --- a/.claude/skills/triage-bug/SKILL.md +++ b/.claude/skills/triage-bug/SKILL.md @@ -176,3 +176,10 @@ waiting on the maintainer's answer about the slow lane — push whatever is on `bug/-repro`, leave one comment headed `## Handoff` with Done, Not done, Findings and Blocked on, and unassign yourself. Findings is where a partial root cause or a rejected hypothesis goes so the next agent does not redo it. +If you are waiting on the maintainer, also move the issue to `bug:blocked` +so no other agent starts the same triage; the maintainer re-adds +`bug:triage` when the answer is in: + +```bash +gh issue edit --add-label bug:blocked --remove-label bug:triage +``` diff --git a/.github/labels.json b/.github/labels.json index 18526bc..e1c9777 100644 --- a/.github/labels.json +++ b/.github/labels.json @@ -4,7 +4,11 @@ "color": "bfdadc", "description": "Inbox. Never picked up, body never edited." }, - { "name": "bug:new", "color": "d73a4a", "description": "Reported. Nobody has looked yet." }, + { + "name": "bug:new", + "color": "d73a4a", + "description": "Reported. Nobody has looked yet." + }, { "name": "bug:triage", "color": "d73a4a", @@ -15,7 +19,21 @@ "color": "d73a4a", "description": "Could not reproduce. Waiting on the reporter." }, - { "name": "bug:ready", "color": "d73a4a", "description": "An agent may fix it." }, + { + "name": "bug:triaged", + "color": "d73a4a", + "description": "Triage report posted. Waiting on the maintainer to accept or send back." + }, + { + "name": "bug:ready", + "color": "d73a4a", + "description": "An agent may fix it." + }, + { + "name": "bug:blocked", + "color": "d73a4a", + "description": "Agent stopped on a blocker named in its handoff. Maintainer clears it by re-adding the label it came from." + }, { "name": "feature:spec", "color": "0e8a16", @@ -31,12 +49,26 @@ "color": "0e8a16", "description": "Split into tasks. Never picked up itself." }, + { + "name": "feature:blocked", + "color": "0e8a16", + "description": "Agent stopped on a blocker named in its handoff. Maintainer clears it by re-adding feature:ready." + }, { "name": "task:draft", "color": "5319e7", "description": "Scope written; technical spec, approval, or deps missing." }, - { "name": "task:ready", "color": "5319e7", "description": "An agent may implement it." }, + { + "name": "task:ready", + "color": "5319e7", + "description": "An agent may implement it." + }, + { + "name": "task:blocked", + "color": "5319e7", + "description": "Agent stopped on a blocker named in its handoff. Maintainer clears it by re-adding task:ready." + }, { "name": "documentation", "color": "0075ca", diff --git a/.github/workflows/issue-state.yml b/.github/workflows/issue-state.yml index e9e38e1..8a3a8ef 100644 --- a/.github/workflows/issue-state.yml +++ b/.github/workflows/issue-state.yml @@ -1,12 +1,18 @@ name: Issue state -# The one place that enforces docs/agent-rules/delivery.md mechanically: +# The one place that enforces docs/internal/agent-rules/delivery.md +# mechanically: # - one : label per issue (adding the next label drops the old); -# - task:draft becomes task:ready when approved, specified, and unblocked; -# - a reporter's reply moves bug:needs-info back to bug:triage (agents post -# under a maintainer's account, so comments ending in the agent footer -# from docs/internal/agent-rules/delivery.md rule 12 do not count); -# - a feature whose last sub-issue closed gets a completion-conditions note; +# - task:draft becomes task:ready when approved, specified, and unblocked, and +# task:ready goes back to task:draft when that stops being true; +# - a reporter's reply moves bug:needs-info back to bug:triage, reopening the +# issue if the stale job closed it (agents post under a maintainer's +# account, so comments carrying the agent footer from rule 12 do not count); +# - a feature whose last sub-issue closed gets a note to check completion +# conditions and close; +# - a feature closed as completed closes the request it came from; +# - a PR closed without merge releases the claim on the issue it would close; +# - a claim with no activity for three days is released; # - bug:needs-info with no activity for two weeks closes as not planned; # - a PR from a / branch must close issue and no other. # Actions taken with the workflow token do not trigger this workflow again, @@ -14,11 +20,11 @@ name: Issue state on: issues: - types: [labeled, edited, closed] + types: [labeled, edited, closed, reopened] issue_comment: types: [created] pull_request: - types: [opened, edited, synchronize] + types: [opened, edited, synchronize, closed] schedule: - cron: "17 6 * * *" workflow_dispatch: @@ -31,6 +37,7 @@ concurrency: env: STATE_LABEL: "^(request|bug|feature|task):" + AGENT_FOOTER: "Written by an agent." jobs: exclusive-label: @@ -57,10 +64,10 @@ jobs: } task-ready: - name: Promote task:draft to task:ready + name: Promote or demote between task:draft and task:ready if: >- github.event_name == 'issues' && - (github.event.action == 'closed' || + (github.event.action == 'closed' || github.event.action == 'reopened' || (github.event.action == 'edited' && github.event.changes.body != null)) runs-on: ubuntu-24.04 permissions: @@ -89,14 +96,17 @@ jobs: const candidates = []; if (context.payload.action === "edited") { - if (hasLabel(context.payload.issue, "task:draft")) candidates.push(context.payload.issue); + const issue = context.payload.issue; + if (hasLabel(issue, "task:draft") || hasLabel(issue, "task:ready")) candidates.push(issue); } else { - const closed = context.payload.issue.number; - const drafts = await github.paginate(github.rest.issues.listForRepo, { - ...context.repo, state: "open", labels: "task:draft", per_page: 100, - }); - for (const issue of drafts) { - if (dependencies(issue.body ?? "").includes(closed)) candidates.push(issue); + const changed = context.payload.issue.number; + for (const labels of ["task:draft", "task:ready"]) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + ...context.repo, state: "open", labels, per_page: 100, + }); + for (const issue of issues) { + if (dependencies(issue.body ?? "").includes(changed)) candidates.push(issue); + } } } @@ -111,22 +121,33 @@ jobs: if (dep.data.state !== "closed") open.push(`#${n}`); } if (open.length) why.push(`waiting on ${open.join(", ")}`); - if (why.length) { - core.info(`#${issue.number} stays task:draft: ${why.join("; ")}`); - continue; + const ready = why.length === 0; + const isReady = hasLabel(issue, "task:ready"); + const issue_number = issue.number; + + if (ready && !isReady) { + await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["task:ready"] }); + await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "task:draft" }); + const deps = dependencies(body).map((n) => `#${n}`); + await github.rest.issues.createComment({ + ...context.repo, issue_number, + body: `Now \`task:ready\`: approved, technical spec present${deps.length ? `, ${deps.join(", ")} closed` : ""}.`, + }); + core.info(`#${issue_number}: task:draft -> task:ready`); + } else if (!ready && isReady) { + if (issue.assignees?.length) { + core.info(`#${issue_number} no longer ready (${why.join("; ")}) but is claimed; leaving it`); + continue; + } + await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["task:draft"] }); + await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "task:ready" }); + await github.rest.issues.createComment({ + ...context.repo, issue_number, body: `Back to \`task:draft\`: ${why.join("; ")}.`, + }); + core.info(`#${issue_number}: task:ready -> task:draft`); + } else { + core.info(`#${issue_number} unchanged${why.length ? ` (${why.join("; ")})` : ""}`); } - await github.rest.issues.addLabels({ - ...context.repo, issue_number: issue.number, labels: ["task:ready"], - }); - await github.rest.issues.removeLabel({ - ...context.repo, issue_number: issue.number, name: "task:draft", - }); - const deps = dependencies(body).map((n) => `#${n}`); - await github.rest.issues.createComment({ - ...context.repo, issue_number: issue.number, - body: `Now \`task:ready\`: approved, technical spec present${deps.length ? `, ${deps.join(", ")} closed` : ""}.`, - }); - core.info(`#${issue.number}: task:draft -> task:ready`); } needs-info-reply: @@ -146,11 +167,15 @@ jobs: with: script: | const issue_number = context.payload.issue.number; + if (context.payload.issue.state === "closed") { + await github.rest.issues.update({ ...context.repo, issue_number, state: "open" }); + core.info(`#${issue_number}: reopened`); + } await github.rest.issues.addLabels({ ...context.repo, issue_number, labels: ["bug:triage"] }); await github.rest.issues.removeLabel({ ...context.repo, issue_number, name: "bug:needs-info" }); core.info(`#${issue_number}: bug:needs-info -> bug:triage`); - feature-verify: + feature-complete: name: Last sub-issue closed if: github.event_name == 'issues' && github.event.action == 'closed' runs-on: ubuntu-24.04 @@ -173,9 +198,104 @@ jobs: if (completed < total) return; await github.rest.issues.createComment({ ...context.repo, issue_number: parent.number, - body: `All ${total} sub-issues are closed. Completion conditions are now due: an agent proves each one against \`main\` and reports here, then the maintainer closes this feature and flips its ADRs to *Accepted*.`, + body: `All ${total} sub-issues are closed. If the completion conditions hold on \`main\`, close this feature and flip its ADRs to *Accepted*.`, + }); + core.info(`#${parent.number}: all sub-issues closed`); + + request-close: + name: Feature completed, close its request + if: >- + github.event_name == 'issues' && github.event.action == 'closed' && + github.event.issue.state_reason == 'completed' && + (contains(github.event.issue.labels.*.name, 'feature:planned') || + contains(github.event.issue.labels.*.name, 'feature:ready') || + contains(github.event.issue.labels.*.name, 'feature:spec')) + runs-on: ubuntu-24.04 + permissions: + issues: write + timeout-minutes: 5 + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const feature = context.payload.issue; + const m = (feature.body ?? "").match(/^Request:\s*#(\d+)/im); + if (!m) return; + const issue_number = Number(m[1]); + const request = await github.rest.issues.get({ ...context.repo, issue_number }); + if (request.data.state !== "open") return; + await github.rest.issues.createComment({ + ...context.repo, issue_number, body: `Shipped as #${feature.number}.`, }); - core.info(`#${parent.number}: completion conditions due`); + await github.rest.issues.update({ + ...context.repo, issue_number, state: "closed", state_reason: "completed", + }); + core.info(`#${issue_number}: closed, shipped as #${feature.number}`); + + pr-closed-unmerged: + name: PR closed without merge releases the claim + if: >- + github.event_name == 'pull_request' && github.event.action == 'closed' && + github.event.pull_request.merged == false + runs-on: ubuntu-24.04 + permissions: + issues: write + timeout-minutes: 5 + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pr = context.payload.pull_request; + const closes = [...(pr.body ?? "").matchAll(/\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#(\d+)/gi)] + .map((x) => Number(x[1])); + for (const issue_number of new Set(closes)) { + const issue = await github.rest.issues.get({ ...context.repo, issue_number }); + if (issue.data.state !== "open") continue; + const assignees = issue.data.assignees.map((a) => a.login); + if (assignees.length) { + await github.rest.issues.removeAssignees({ ...context.repo, issue_number, assignees }); + } + await github.rest.issues.createComment({ + ...context.repo, issue_number, + body: `#${pr.number} was closed without merging. Claim released; the label is unchanged, so the next agent starts from the branch and the last handoff.`, + }); + core.info(`#${issue_number}: claim released after #${pr.number} closed unmerged`); + } + + stale-claims: + name: Release claims with no activity + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + timeout-minutes: 5 + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const days = 3; + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + const q = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open -no:assignee label:bug:ready,feature:ready,task:ready`; + const found = await github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 }); + for (const issue of found) { + let last = new Date(issue.updated_at).getTime(); + const kind = issue.labels.map((l) => l.name).find((n) => /:ready$/.test(n))?.split(":")[0]; + try { + const branch = await github.rest.repos.getBranch({ ...context.repo, branch: `${kind}/${issue.number}` }); + last = Math.max(last, new Date(branch.data.commit.commit.committer.date).getTime()); + } catch (e) { + if (e.status !== 404) throw e; + } + if (last > cutoff) continue; + const assignees = issue.assignees.map((a) => a.login); + await github.rest.issues.removeAssignees({ ...context.repo, issue_number: issue.number, assignees }); + await github.rest.issues.createComment({ + ...context.repo, issue_number: issue.number, + body: `Claim released: no activity for ${days} days. The label is unchanged; the next agent starts from the branch and the last handoff.`, + }); + core.info(`#${issue.number}: stale claim released`); + } stale-needs-info: name: Close silent bug:needs-info @@ -204,7 +324,7 @@ jobs: branch-matches-issue: name: Branch name closes its issue - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.action != 'closed' runs-on: ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/docs/internal/DELIVERY.md b/docs/internal/DELIVERY.md index 6014e7f..ef009f3 100644 --- a/docs/internal/DELIVERY.md +++ b/docs/internal/DELIVERY.md @@ -26,11 +26,14 @@ request, closed — not from a label. The branch for issue `` is always | `bug:triage` | An agent may reproduce it and write the triage report. | | `bug:needs-info` | Could not reproduce. Waiting on the reporter. | | `bug:ready` | An agent may fix it. | +| `bug:blocked` | Waiting on the maintainer to clear a blocker. | | `feature:spec` | Business or technical spec in progress. | | `feature:ready` | No sub-issues; one PR delivers the whole feature. | | `feature:planned` | Split into tasks. Never picked up itself. | +| `feature:blocked` | Waiting on the maintainer to clear a blocker. | | `task:draft` | Scope written; technical spec, approval or deps missing. | | `task:ready` | An agent may implement it. | +| `task:blocked` | Waiting on the maintainer to clear a blocker. | ## A bug, from report to fix @@ -100,10 +103,12 @@ of a Technical spec section on the feature, it produces sub-issues. under Depends on is closed. Nobody re-reads the dependency graph by hand. 7. Agents claim `task:ready` issues one PR each, on `task/`. As tasks close, the ones they unblocked become ready on their own. -8. When the last sub-issue closes, the automation comments on the feature - that completion conditions are due. An agent proves them against `main`, - usually with an end-to-end run, and reports in a comment. The maintainer - closes the feature. +8. Verification is part of delivery. Every task PR walks its Done when, and + the PR that closes the last open sub-issue also walks the feature's + Completion conditions. When that last sub-issue closes, the automation + comments on the feature, and the maintainer closes it. If the feature + came from a request, the request closes on its own with a pointer to the + feature. ## Handoffs between agents @@ -115,7 +120,11 @@ knows where the commits are without being told. A handoff records the state of the work, never a change to the spec; if the work showed the spec is wrong, the handoff says so and the maintainer runs a revise spec session. Agents do not post progress updates, only handoffs, so the one comment that -matters is easy to find. +matters is easy to find. A handoff that names a blocker also moves the issue +to `:blocked`, so nobody picks it up until the maintainer re-adds the +label it came from. A claim that goes silent — no comment, label change, or +commit for three days — or whose PR is closed without merging is released by +the automation, so a crashed agent cannot hold an issue forever. Everything an agent writes on an issue or a PR — report, handoff, spec, PR body — is short and plain: conclusion first, short sentences, common words, @@ -129,10 +138,13 @@ and readers and automation need to tell the two apart. The workflow in `.github/workflows/issue-state.yml` handles the transitions that are mechanical: adding a state label removes the previous one, so every transition is a single add; `task:draft` becomes `task:ready` when approved, -specified and unblocked; a reporter's reply moves `bug:needs-info` back to -`bug:triage`; a silent `bug:needs-info` closes after two weeks; a feature -whose last sub-issue closed gets the completion-conditions note; a PR from a -`/` branch must close `#` and nothing else. The repo's +specified and unblocked, and goes back when that stops being true; a +reporter's reply moves `bug:needs-info` back to `bug:triage` and reopens the +issue if needed; a silent `bug:needs-info` closes after two weeks; a feature +whose last sub-issue closed gets a note to close it; a completed feature +closes its request; a PR closed without merging or a claim silent for three +days releases the claim; a PR from a `/` branch must close `#` +and nothing else. The repo's skills — `spec-session`, `triage-bug`, `deliver` — handle the transitions an agent makes as part of its own procedure. diff --git a/docs/internal/agent-rules/delivery.md b/docs/internal/agent-rules/delivery.md index 433b727..090dc75 100644 --- a/docs/internal/agent-rules/delivery.md +++ b/docs/internal/agent-rules/delivery.md @@ -22,11 +22,14 @@ else happens to it. | `bug:triage` | maintainer | An agent may reproduce it and write the triage report. | | `bug:needs-info` | triage agent | Could not reproduce. Waiting on the reporter. | | `bug:ready` | maintainer, after the triage report | An agent may fix it. | +| `bug:blocked` | agent, on a blocked handoff | Waiting on the maintainer to clear a blocker. | | `feature:spec` | spec session, on creation | Business or technical spec in progress. | | `feature:ready` | maintainer | No sub-issues; one PR delivers the whole feature. | | `feature:planned` | spec session, on split | Split into tasks. Never picked up itself. | +| `feature:blocked` | agent, on a blocked handoff | Waiting on the maintainer to clear a blocker. | | `task:draft` | spec session, on creation | Scope written; technical spec, approval, or deps missing. | | `task:ready` | automation, or maintainer | An agent may implement it. | +| `task:blocked` | agent, on a blocked handoff | Waiting on the maintainer to clear a blocker. | Transitions per kind: @@ -35,7 +38,11 @@ Transitions per kind: - **bug**: `new` → `triage` → `ready`, with `needs-info` as a side-trip that returns to `triage` when the reporter answers. - **feature**: `spec` → `ready` or `planned`. Both end at closed. -- **task**: `draft` → `ready`. +- **task**: `draft` → `ready`, and back to `draft` if the approval, the + technical spec, or a closed dependency goes away. +- **any kind**: `triage` or `ready` → `blocked` when an agent stops on a + blocker it names in its handoff. The maintainer clears it by re-adding the + label it came from. Everything after `ready` is read from GitHub itself, not from a label: in progress means an assignee is set, in review means a linked pull request @@ -63,6 +70,9 @@ is open, done means closed as completed. wrong or incomplete, the handoff says "spec needs: ..." and the agent stops; it does not amend the spec in the comment. One handoff per stop, no progress log — an agent comments when it stops, not while it works. + If Blocked on is anything but "nothing", the agent also moves the issue + to `:blocked`, so the next agent does not walk into the same wall; + the maintainer clears it by re-adding the label it came from. 3. **The body is the spec. Comments are discussion.** Whoever implements an issue reads its body, the documents it links, the latest `## Handoff` @@ -122,9 +132,11 @@ is open, done means closed as completed. that closes it is merged; for a bug the failing test from triage is the regression test and must be in that PR. A `feature:ready` issue is done when its PR is merged and the PR body walks every completion condition. - A `feature:planned` issue is done when every sub-issue is closed *and* an - agent has proven the completion conditions against main, usually by an - end-to-end run reported in a comment; the maintainer closes it. + A `feature:planned` issue is done when every sub-issue is closed. + Verification is part of delivery, not a step after it: every task PR + walks its Done when, and the PR that closes the last open sub-issue also + walks the parent's Completion conditions. The maintainer closes the + feature. 10. **ADR status follows the feature.** A decision made during a spec session that constrains more than one task, or would be expensive to reverse, @@ -204,11 +216,21 @@ rules mechanically, so nobody has to remember them: is ticked, its Technical spec section is filled in, and every issue under Depends on is closed. It is re-evaluated whenever its body changes and whenever an issue it depends on closes. +- A `task:ready` issue with no assignee goes back to `task:draft` when the + approval box is unticked, the technical spec is emptied, or a dependency + is reopened. A claimed task is left alone. - A comment by the reporter on a `bug:needs-info` issue moves it back to - `bug:triage`. Two weeks of silence closes it as not planned; a later - comment does not reopen it automatically, the maintainer does. + `bug:triage`, reopening the issue if it had been closed. Two weeks of + silence closes it as not planned. - When the last sub-issue of a `feature:planned` issue closes, the workflow - comments that completion conditions are due. + comments that the feature can be closed once the completion conditions + hold. +- A feature closed as completed closes the `request:new` issue it names on + its `Request:` line, with a comment pointing at the feature. +- A pull request closed without merging releases the claim on every issue + its body closes, with a comment. The label is left as it was. +- A claim on a `*:ready` issue with no comment, label change, or commit on + `/` for three days is released, with a comment. - A pull request from a `/` branch fails its check unless its body closes `#` and closes nothing else.