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
23 changes: 18 additions & 5 deletions .claude/skills/issue-lifecycle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ Use this table to determine what to do next:
| `classified` | Read [references/planning.md](references/planning.md) |
| `plan_generated` | Read [references/adversarial-review.md](references/adversarial-review.md) |
| `approved` | Read [references/implementation.md](references/implementation.md) |
| `implementing` | Check PR status or call `complete` |
| `pr_open` | Check PR status or call `complete` |
| `implementing` | Link a PR with `link_pr` or call `complete` |
| `pr_open` | Wait 3 min, then check PR: `pr_merged` if merged, `pr_failed` if failed |
| `pr_failed` | Fix the issue, then `link_pr` (new PR) or `implement` (major rework) |
| `releasing` | Check release build: `ship` when done, or `complete` as fallback |
| `done` | Nothing to do — lifecycle is complete |

The canonical phase list lives in the `TRANSITIONS` constant in
Expand All @@ -140,11 +142,20 @@ When a PR has already merged and the lifecycle just needs to be marked done:
```
swamp data get issue-<N> state-main --json
```
2. If the phase is `implementing` and you have a PR URL, link it first:
2. If the phase is `implementing`, link the PR first:
```
swamp model method run issue-<N> link_pr --input url=<PR URL>
```
3. Call `complete` (accepts both `implementing` and `pr_open` as source phases):
3. If the phase is `pr_open`, record the merge:
```
swamp model method run issue-<N> pr_merged
```
4. If the phase is `releasing`, ship it:
```
swamp model method run issue-<N> ship
```
5. For quick close-out, `complete` still works from `implementing`, `pr_open`,
or `releasing`:
```
swamp model method run issue-<N> complete
```
Expand All @@ -162,7 +173,9 @@ When a PR has already merged and the lifecycle just needs to be marked done:
reference specific files, functions, and test paths.
6. **Follow the planning conventions for this repository.** Read
`agent-constraints/planning-conventions.md` if it exists.
7. **File unrelated issues immediately.** If you discover a bug, code smell, or
7. **Never open a PR without asking first.** Present the changes summary and
wait for the human to confirm before creating the pull request.
8. **File unrelated issues immediately.** If you discover a bug, code smell, or
problem during investigation that is NOT related to the current issue, file
it as a new swamp-club issue. Do not try to fix it in the current work span —
keep the scope focused.
55 changes: 51 additions & 4 deletions .claude/skills/issue-lifecycle/references/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ Report verification results to the human before creating the PR:

## 4. Create a PR

**Always ask the human before opening a PR.** Do not create the PR automatically
— present a summary of the changes and ask if they are ready to open it. Only
proceed after explicit confirmation.

Use the repository's normal PR tooling. Read
`agent-constraints/implementation-conventions.md` for repo-specific PR
conventions.
Expand Down Expand Up @@ -69,13 +73,56 @@ Calling `link_pr` is **encouraged but not enforced** — `complete` still accept
records. Prefer the `implementing → link_pr → complete` flow for all new work so
the lifecycle record carries the PR link.

## 5. Complete the Issue
## 4b. Wait for CI and Check PR Status

After linking the PR, wait at least 3 minutes for CI to run. The model enforces
a `pr-cooldown` check — calling `pr_merged` or `pr_failed` within 3 minutes of
`link_pr` will be rejected.

Check the PR status externally (e.g.,
`gh pr view <url> --json state,statusCheckRollup`):

- **PR merged**: call `pr_merged` to transition to `releasing`
```
swamp model method run issue-<N> pr_merged
```
- **PR failed** (CI red, changes requested): call `pr_failed` with the reason
```
swamp model method run issue-<N> pr_failed --input reason="CI failed: type check errors"
```
- **PR still open and passing**: wait and check again later.

## 4c. Handle PR Failure

When in `pr_failed`, diagnose and fix the issue. Then either:

- Push fixes and call `link_pr` again (same or new PR URL) to return to
`pr_open`:
```
swamp model method run issue-<N> link_pr --input url=<PR URL>
```
- Call `implement` to go back to the implementing phase for major rework:
```
swamp model method run issue-<N> implement
```

Once the PR has merged and the work is done, call `complete`:
## 5. Ship the Release

After `pr_merged` transitions to `releasing`, wait for the release build to
complete. Once the release is out, call `ship`:

```
swamp model method run issue-<N> ship
```

Optionally pass release metadata:

```
swamp model method run issue-<N> complete
swamp model method run issue-<N> ship --input releaseUrl=<URL> --input releaseNotes="Bug fix release"
```

This transitions the phase to `done`, transitions the swamp-club status to
`shipped`, and posts a `complete` lifecycle entry.
`shipped`, and posts a `shipped` lifecycle entry.

For quick close-out (e.g., the PR merged and you just want to wrap up),
`complete` still works from `implementing`, `pr_open`, or `releasing`.
29 changes: 26 additions & 3 deletions extensions/models/_lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,16 @@ export const Phase = z.enum([
"approved",
"implementing",
"pr_open",
"pr_failed",
"releasing",
"done",
]);

export type Phase = z.infer<typeof Phase>;

/** Minimum time (ms) between link_pr and pr_merged/pr_failed to allow CI to run. */
export const PR_COOLDOWN_MS = 3 * 60 * 1000;

/** Valid transitions: method name → allowed source phases */
export const TRANSITIONS: Record<string, Phase[]> = {
start: [
Expand All @@ -59,16 +64,21 @@ export const TRANSITIONS: Record<string, Phase[]> = {
"approved",
"implementing",
"pr_open",
"pr_failed",
"releasing",
],
triage: ["triaging"],
plan: ["classified"],
iterate: ["plan_generated"],
approve: ["plan_generated"],
implement: ["approved"],
implement: ["approved", "pr_failed"],
adversarial_review: ["plan_generated"],
resolve_findings: ["plan_generated"],
link_pr: ["implementing", "pr_open"],
complete: ["implementing", "pr_open"],
link_pr: ["implementing", "pr_open", "pr_failed"],
pr_merged: ["pr_open"],
pr_failed: ["pr_open"],
ship: ["releasing"],
complete: ["implementing", "pr_open", "releasing"],
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -170,10 +180,23 @@ export const PullRequestSchema = z.object({
"Canonical URL of the pull request. Opaque to the model — the agent " +
"supplies whatever URL their git host produced.",
),
attempt: z.number().describe(
"Sequential attempt number. Starts at 1 on the first link_pr call, " +
"incremented on each subsequent link_pr call after a pr_failed cycle.",
),
linkedAt: z.string().describe(
"ISO-8601 timestamp of when link_pr was called. Updated on every " +
"subsequent link_pr call so the record reflects the latest link.",
),
mergedAt: z.string().optional().describe(
"ISO-8601 timestamp of when pr_merged was called. Set once.",
),
failedAt: z.string().optional().describe(
"ISO-8601 timestamp of when pr_failed was called. Cleared on next link_pr.",
),
failureReason: z.string().optional().describe(
"Why the PR failed (CI failure, review rejection, etc.). Cleared on next link_pr.",
),
});

export type PullRequestData = z.infer<typeof PullRequestSchema>;
26 changes: 17 additions & 9 deletions extensions/models/_lib/schemas_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,35 @@
import { assertEquals } from "@std/assert";
import { Phase, PullRequestSchema, TRANSITIONS } from "./schemas.ts";

Deno.test("Phase: includes pr_open between implementing and done", () => {
Deno.test("Phase: includes pr_open, pr_failed, releasing between implementing and done", () => {
const phases = Phase.options;
const implementingIdx = phases.indexOf("implementing");
const prOpenIdx = phases.indexOf("pr_open");
const prFailedIdx = phases.indexOf("pr_failed");
const releasingIdx = phases.indexOf("releasing");
const doneIdx = phases.indexOf("done");

assertEquals(prOpenIdx, implementingIdx + 1);
assertEquals(doneIdx, prOpenIdx + 1);
assertEquals(prFailedIdx, prOpenIdx + 1);
assertEquals(releasingIdx, prFailedIdx + 1);
assertEquals(doneIdx, releasingIdx + 1);
});

Deno.test("TRANSITIONS: link_pr is idempotent from implementing and pr_open", () => {
assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open"]);
Deno.test("TRANSITIONS: link_pr accepts implementing, pr_open, and pr_failed", () => {
assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open", "pr_failed"]);
});

Deno.test("TRANSITIONS: complete accepts both implementing (legacy) and pr_open (new)", () => {
// Accepting both keeps existing records (created before this phase was
// introduced) able to finish without being forced through link_pr.
assertEquals(TRANSITIONS.complete, ["implementing", "pr_open"]);
Deno.test("TRANSITIONS: complete accepts implementing, pr_open, and releasing", () => {
// Accepting all three keeps backwards compatibility while allowing
// the new releasing phase to be closed out directly.
assertEquals(TRANSITIONS.complete, ["implementing", "pr_open", "releasing"]);
});

Deno.test("TRANSITIONS: start (resume) includes pr_open so in-flight issues can be picked up", () => {
Deno.test("TRANSITIONS: start (resume) includes pr_open, pr_failed, and releasing", () => {
const startPhases = TRANSITIONS.start;
assertEquals(startPhases.includes("pr_open"), true);
assertEquals(startPhases.includes("pr_failed"), true);
assertEquals(startPhases.includes("releasing"), true);
});

Deno.test("TRANSITIONS: link_pr is rejected from earlier lifecycle phases", () => {
Expand Down Expand Up @@ -73,6 +79,7 @@ Deno.test("PullRequestSchema: accepts any non-empty URL string", () => {
for (const url of samples) {
const parsed = PullRequestSchema.parse({
url,
attempt: 1,
linkedAt: "2026-04-08T15:00:00.000Z",
});
assertEquals(parsed.url, url);
Expand All @@ -82,6 +89,7 @@ Deno.test("PullRequestSchema: accepts any non-empty URL string", () => {
Deno.test("PullRequestSchema: rejects empty url string", () => {
const result = PullRequestSchema.safeParse({
url: "",
attempt: 1,
linkedAt: "2026-04-08T15:00:00.000Z",
});
assertEquals(result.success, false);
Expand Down
Loading
Loading