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
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ For each plan step, **read the actual code**:

## Step 3: Record Findings

Write findings to a YAML file (e.g. `/tmp/findings.yaml`) as a YAML object with
a `findings` key:
Write findings to a YAML file as a YAML object with a `findings` key. **Use an
issue-scoped filename** like `/tmp/findings-issue-<N>.yaml` — a generic
`/tmp/findings.yaml` collides with stale content from previous lifecycle
sessions and can leak unrelated findings into the current review.

```yaml
# /tmp/findings.yaml
# /tmp/findings-issue-<N>.yaml
findings:
- id: ADV-1
severity: high
Expand All @@ -66,7 +68,7 @@ Then record them:

```
swamp model method run issue-<N> adversarial_review \
--input-file /tmp/findings.yaml
--input-file /tmp/findings-issue-<N>.yaml
```

Each finding must have:
Expand Down Expand Up @@ -104,22 +106,24 @@ If no blocking findings, say:
When the human gives feedback OR adversarial findings need addressing:

1. **Call iterate** with both the feedback text and your revised plan. Write the
revised `steps` and `potentialChallenges` to a YAML file (same format as the
`plan` step), then run:
revised `steps` and `potentialChallenges` to an issue-scoped YAML file (e.g.
`/tmp/plan-issue-<N>-v2.yaml` — the `-v2` suffix keeps each revision distinct
if you want to diff between iterations), then run:

```
swamp model method run issue-<N> iterate \
--input feedback="<human's feedback or adversarial findings>" \
--input summary="..." \
--input dddAnalysis="..." \
--input testingStrategy="..." \
--input-file /tmp/plan.yaml
--input-file /tmp/plan-issue-<N>-v2.yaml
```

2. **Resolve addressed findings**. Write resolutions to a YAML file:
2. **Resolve addressed findings**. Write resolutions to an issue-scoped YAML
file:

```yaml
# /tmp/resolutions.yaml
# /tmp/resolutions-issue-<N>.yaml
resolutions:
- findingId: ADV-1
resolutionNote: "Added domain boundary definition in step 2"
Expand All @@ -129,7 +133,7 @@ When the human gives feedback OR adversarial findings need addressing:

```
swamp model method run issue-<N> resolve_findings \
--input-file /tmp/resolutions.yaml
--input-file /tmp/resolutions-issue-<N>.yaml
```

3. **Re-run adversarial review** on the new plan version. The review must be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,33 @@ Use the repository's normal PR tooling. Read
`agent-constraints/implementation-conventions.md` for repo-specific PR
conventions.

The issue-lifecycle model does not track PRs directly — your PR creation is
outside the model's scope. The swamp-club issue status stays at `in_progress`
until you call `complete`.
## 4a. Link the PR

After the PR is open, record its URL on the lifecycle so the swamp-club record
points to where the fix lives:

```
swamp model method run issue-<N> link_pr --input url=<PR URL>
```

This writes a `pullRequest-main` resource, transitions the phase to `pr_open`,
and posts a `pr_linked` lifecycle entry on the swamp-club issue. The swamp-club
status stays at `in_progress` — there is no new status for `pr_open`; the PR
link is additional evidence attached to the in-progress state.

`link_pr` is **idempotent** — call it again with a new URL if:

- CI fails and you force-push a different PR
- The first PR is closed and a replacement is opened
- You recorded the wrong URL the first time

Each call overwrites `pullRequest-main` with the latest URL and refreshes
`linkedAt`. A new `pr_linked` entry is posted on every call.

Calling `link_pr` is **encouraged but not enforced** — `complete` still accepts
`implementing` as a valid source phase for backwards compatibility with legacy
records. Prefer the `implementing → link_pr → complete` flow for all new work so
the lifecycle record carries the PR link.

## 5. Complete the Issue

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ ready to generate an implementation plan.

## 6. Generate an Implementation Plan

Write a single YAML file (e.g. `/tmp/plan.yaml`) containing both `steps` and
`potentialChallenges` as top-level keys. The CLI only supports one
`--input-file` flag per invocation, and the file must be a YAML object (not a
bare array).
Write a single YAML file containing both `steps` and `potentialChallenges` as
top-level keys. The CLI only supports one `--input-file` flag per invocation,
and the file must be a YAML object (not a bare array).

**Use an issue-scoped filename** like `/tmp/plan-issue-<N>.yaml` rather than a
generic `/tmp/plan.yaml`. Generic filenames collide with stale content left
behind by previous lifecycle sessions — the old content usually fails to `Read`
for you to overwrite, and worse, content from an unrelated issue can silently
leak into the current plan if you forget to check. Issue-scoped filenames make
resuming sessions safe and auditable.

```yaml
# /tmp/plan.yaml
# /tmp/plan-issue-<N>.yaml
steps:
- order: 1
description: "Add the new schema types"
Expand All @@ -36,7 +42,7 @@ swamp model method run issue-<N> plan \
--input summary="..." \
--input dddAnalysis="..." \
--input testingStrategy="..." \
--input-file /tmp/plan.yaml
--input-file /tmp/plan-issue-<N>.yaml
```

## 7. Apply Repo-Specific Planning Conventions
Expand Down
18 changes: 17 additions & 1 deletion issue-lifecycle/extensions/models/_lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const Phase = z.enum([
"plan_generated",
"approved",
"implementing",
"pr_open",
"done",
]);

Expand All @@ -57,6 +58,7 @@ export const TRANSITIONS: Record<string, Phase[]> = {
"plan_generated",
"approved",
"implementing",
"pr_open",
],
triage: ["triaging"],
plan: ["classified"],
Expand All @@ -65,7 +67,8 @@ export const TRANSITIONS: Record<string, Phase[]> = {
implement: ["approved"],
adversarial_review: ["plan_generated"],
resolve_findings: ["plan_generated"],
complete: ["implementing"],
link_pr: ["implementing", "pr_open"],
complete: ["implementing", "pr_open"],
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -161,3 +164,16 @@ export const AdversarialReviewSchema = z.object({
});

export type AdversarialReviewData = z.infer<typeof AdversarialReviewSchema>;

export const PullRequestSchema = z.object({
url: z.string().min(1).describe(
"Canonical URL of the pull request. Opaque to the model — the agent " +
"supplies whatever URL their git host produced.",
),
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.",
),
});

export type PullRequestData = z.infer<typeof PullRequestSchema>;
102 changes: 95 additions & 7 deletions issue-lifecycle/extensions/models/_lib/schemas_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { assert, assertEquals } from "@std/assert";
import { Phase, TRANSITIONS } from "./schemas.ts";
import { Phase, PullRequestSchema, TRANSITIONS } from "./schemas.ts";

const validPhases = new Set<string>(Phase.options);

Expand Down Expand Up @@ -64,7 +64,7 @@ Deno.test("start is allowed from every non-terminal phase (restart invariant)",
}
});

Deno.test("happy path: created → triaging → classified → plan_generated → approved → implementing → done", () => {
Deno.test("happy path: created → triaging → classified → plan_generated → approved → implementing → pr_open → done", () => {
// Walk the linear happy path one method at a time and verify each method's
// required source phase is in its TRANSITIONS entry. This catches the case
// where someone reorders the state machine and forgets to update an edge.
Expand All @@ -74,7 +74,8 @@ Deno.test("happy path: created → triaging → classified → plan_generated
{ method: "plan", from: "classified" },
{ method: "approve", from: "plan_generated" },
{ method: "implement", from: "approved" },
{ method: "complete", from: "implementing" },
{ method: "link_pr", from: "implementing" },
{ method: "complete", from: "pr_open" },
];
for (const { method, from } of happyPath) {
const allowed = TRANSITIONS[method];
Expand All @@ -88,6 +89,17 @@ Deno.test("happy path: created → triaging → classified → plan_generated
}
});

Deno.test("legacy path: complete still accepts implementing for records that never linked a PR", () => {
// Records created before link_pr existed (and records where the human
// skips link_pr for docs-only or trivial changes) must still be able to
// reach done directly from implementing. Removing the implementing entry
// from complete's allowed list would orphan those records.
assert(
TRANSITIONS.complete.includes("implementing"),
"complete must accept implementing as a source phase for backwards compatibility",
);
});

Deno.test("plan iteration loop: iterate is allowed from plan_generated only", () => {
// The iterate loop is the heart of the plan-revision feature. If iterate
// accepted any other phase, a user could revise an already-approved plan,
Expand All @@ -103,8 +115,84 @@ Deno.test("implementation gate: implement only allowed from approved", () => {
assertEquals(TRANSITIONS.implement, ["approved"]);
});

Deno.test("completion gate: complete only allowed from implementing", () => {
// complete is the single exit path out of implementing — there is no
// ci_review phase or fix loop in the swamp-club workflow.
assertEquals(TRANSITIONS.complete, ["implementing"]);
Deno.test("completion gate: complete allowed from implementing and pr_open", () => {
// complete is the exit path from the implementation span. It accepts
// both 'implementing' (legacy/no-PR path) and 'pr_open' (the new
// link_pr → pr_open → complete path). No ci_review phase or fix loop.
assertEquals(TRANSITIONS.complete, ["implementing", "pr_open"]);
});

// ---------------------------------------------------------------------------
// PR linkage additions (v2026.04.08.2)
// ---------------------------------------------------------------------------

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

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

Deno.test("TRANSITIONS: link_pr is idempotent from implementing and pr_open", () => {
// Accepting both source phases is what makes link_pr re-callable: the
// first call moves implementing → pr_open, subsequent calls overwrite
// the pullRequest resource while staying in pr_open. This supports URL
// corrections, replacement PRs, and force-push workflows without a
// separate phase.
assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open"]);
});

Deno.test("TRANSITIONS: link_pr is rejected from earlier lifecycle phases", () => {
// link_pr must not be callable before implementation has begun — calling
// it from any earlier phase is a sequencing bug in the agent and must be
// blocked by the valid-transition pre-flight check.
const earlierPhases: ReadonlyArray<typeof Phase.options[number]> = [
"created",
"triaging",
"classified",
"plan_generated",
"approved",
];
for (const phase of earlierPhases) {
assertEquals(
TRANSITIONS.link_pr.includes(phase),
false,
`link_pr must not be allowed from phase '${phase}'`,
);
}
});

Deno.test("PullRequestSchema: accepts any non-empty URL string", () => {
// URLs are opaque to the model — GitHub, GitLab, Gitea, Forgejo, etc.
const samples = [
"https://github.com/systeminit/swamp/pull/1141",
"https://gitlab.com/group/project/-/merge_requests/42",
"https://codeberg.org/user/repo/pulls/7",
"https://git.internal/project/+/123",
];
for (const url of samples) {
const parsed = PullRequestSchema.parse({
url,
linkedAt: "2026-04-08T15:00:00.000Z",
});
assertEquals(parsed.url, url);
}
});

Deno.test("PullRequestSchema: rejects empty url string", () => {
const result = PullRequestSchema.safeParse({
url: "",
linkedAt: "2026-04-08T15:00:00.000Z",
});
assertEquals(result.success, false);
});

Deno.test("PullRequestSchema: requires linkedAt", () => {
const result = PullRequestSchema.safeParse({
url: "https://github.com/systeminit/swamp/pull/1",
});
assertEquals(result.success, false);
});
Loading
Loading