diff --git a/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md b/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md index bd32a6a1..2a742504 100644 --- a/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md +++ b/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md @@ -2,19 +2,23 @@ name: issue-lifecycle description: > Drive the @swamp/issue-lifecycle model for interactive issue triage and - plan iteration. Use when the user wants to triage a GitHub issue, - generate an implementation plan, or iterate on a plan with feedback. - Triggers on "triage issue", "triage #", "issue plan", "review plan", - "iterate plan", "approve plan", "issue lifecycle", "fix review issues", - "check CI", "ci status". + plan iteration against swamp-club lab issues. Use when the user wants to + triage a swamp-club issue, generate an implementation plan, or iterate on + a plan with feedback. Triggers on "triage issue", "triage #", "issue plan", + "review plan", "iterate plan", "approve plan", "issue lifecycle". --- # Issue Lifecycle Skill -Interactive triage and implementation planning for GitHub issues using the -`@swamp/issue-lifecycle` extension model. This skill drives the model +Interactive triage and implementation planning for swamp-club lab issues using +the `@swamp/issue-lifecycle` extension model. This skill drives the model conversationally — the human steers, you execute. +The model operates on swamp-club lab issue numbers. Every step records a +structured lifecycle entry against the issue in swamp-club and transitions its +status as the work progresses. There is no GitHub integration — the issue must +already exist in swamp-club before you start. + ## Core Principle **Never auto-approve.** Always stop and show the plan to the human. Always ask @@ -45,8 +49,8 @@ reference you need for the current phase. Read [references/triage.md](references/triage.md) when starting a new triage or resuming an issue in the `triaging` phase. Covers: creating the model instance, -fetching issue context, reading the codebase, classifying the issue, and -reproducing bugs. +fetching issue context from swamp-club, reading the codebase, classifying the +issue, and reproducing bugs. ### Phase 2: Planning (steps 6–9) @@ -63,11 +67,30 @@ running the review first. Covers: challenging the plan across repo-specific dimensions, verifying against the codebase, recording findings, presenting to the human, and the iteration loop until approval. -### Phase 4: Implementation & CI +### Phase 4: Implementation Read [references/implementation.md](references/implementation.md) after plan -approval. Covers: doing the work, verifying fixes against the reproduction, -creating the PR, CI polling loop, handling failures, and completing the issue. +approval. Covers: signalling implementation start, doing the work, verifying +fixes against the reproduction, creating the PR, and completing the issue. + +## Classification Types + +The `triage` method classifies issues into one of three types (matching +swamp-club): + +- `bug` — something is broken or behaving incorrectly +- `feature` — a request for new functionality or enhancement +- `security` — security vulnerability or hardening work + +Two additional classification details are captured in the classification record +but do NOT map to separate swamp-club types: + +- `isRegression` — set to `true` when the bug previously worked. Implies + `type: bug`. Look for signals like "this used to work", "stopped working + after", or git history showing recent changes to the affected code. +- Low-confidence classifications — if you cannot classify the issue confidently, + use `confidence: low` and populate `clarifyingQuestions`. Do not guess the + type — ask the human before calling `triage`. ## Reviewing Plan History @@ -100,13 +123,14 @@ there. 2. **Never call approve without explicit human approval.** 3. **Persist everything through the model.** Don't just have a conversation — call the model methods so state survives context compression and sessions. -4. **GitHub comments are automatic.** Every state transition posts to the issue. - You don't need to manually post comments. +4. **swamp-club is the source of truth.** Every state transition posts a + lifecycle entry and transitions the issue status in swamp-club automatically. + You don't need to manually update the issue. 5. **Read the codebase thoroughly** before generating the plan. The plan should 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 problem during investigation that is NOT related to the current issue, file - it as a new GitHub issue in the current repository. Do not try to fix it in - the current work span — keep the scope focused. + it as a new swamp-club issue. Do not try to fix it in the current work span — + keep the scope focused. diff --git a/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md b/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md index f0ff902e..0f9a5a5c 100644 --- a/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md +++ b/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md @@ -1,7 +1,7 @@ -# Implementation & CI Flow +# Implementation Flow Read this after the plan is approved and the human says to implement. The -`approve` method already transitioned the issue to "in progress" in swamp-club. +`approve` method already transitioned the swamp-club issue to `in_progress`. ## 1. Signal Implementation Started @@ -11,8 +11,9 @@ Before writing any code, signal that implementation has begun: swamp model method run issue- implement ``` -This transitions the phase to `implementing`, posts a GitHub comment, and -notifies swamp-club that work is underway. Do this **before** touching any code. +This transitions the phase to `implementing` and posts an +`implementation_started` lifecycle entry on the swamp-club issue. Do this +**before** touching any code. ## 2. Do the Implementation Work @@ -20,7 +21,7 @@ Follow the approved plan step by step. ## 3. Verify the Fix Against the Reproduction -**Bugs and regressions only — skip for features.** +**Bugs and regressions only — skip for features and security issues.** If a reproduction was created during triage, reuse it to confirm the fix works. @@ -36,90 +37,21 @@ Report verification results to the human before creating the PR: ## 4. Create a PR -Read `agent-constraints/implementation-conventions.md` for PR creation guidance. -If it does not exist, create a PR using `gh pr create`. +Use the repository's normal PR tooling. Read +`agent-constraints/implementation-conventions.md` for repo-specific PR +conventions. -## 5. Record the PR Number +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`. -After the PR is created, link it to the issue so `ci_status` can find it: +## 5. Complete the Issue -``` -swamp model method run issue- record_pr \ - --input prNumber= -``` - -## 6. Wait for CI to Start - -Use `sleep 180` — CI takes at least 3 minutes, so there's no point polling -earlier. - -## 7. Poll for CI Results - -You MUST implement an explicit polling loop — do not check once and assume done: - -``` -repeat: - call ci_status - parse the output — look at EVERY check's status - if ANY check is "pending", "queued", or "in_progress": - tell the human: "CI still running — N of M checks complete. Waiting 60s..." - sleep 60 - go back to repeat - else: - all checks are conclusive (passed/failed) — exit the loop -``` - -The ci_status command: - -``` -swamp model method run issue- ci_status -``` - -**Do NOT exit this loop early.** A single ci_status call that shows some checks -passed does not mean CI is done — other checks may still be running. You must -confirm that **every** check has a conclusive status (passed or failed) before -proceeding. The human can always interrupt the loop (e.g. "stop", "pause") — -respect that immediately. But the agent must never exit the loop on its own. - -## 8. Show CI Results - -Show results to the human, grouped by reviewer and severity: - -- Which checks passed/failed -- Review comments grouped by reviewer -- Comments sorted by severity (critical first) - -## 9. If Everything is Green and Approved - -The PR will auto-merge. Call `complete` immediately — no need to ask the human: +Once the PR has merged and the work is done, call `complete`: ``` swamp model method run issue- complete ``` -## 10. If There Are Failures or Review Comments - -Present them and wait for the human's direction. Parse their instruction: - -- "fix the CRITICAL issues from adversarial review" → - `targetReview: "claude-adversarial-review"`, `targetSeverity: "critical"` -- "address all the review comments" → no filters -- "fix the test failures" → `targetReview: "test"` - -``` -swamp model method run issue- fix \ - --input directive="" \ - --input targetReview="" \ - --input targetSeverity="" -``` - -## 11. After Pushing Fixes - -Loop back to step 6. Wait 3 minutes, poll for CI, show results. Repeat until -clean or the human says to stop. - -**IMPORTANT:** Do not break out of this loop voluntarily. The human should never -have to manually check CI or come back to ask "what happened?" — the skill stays -in the conversation and drives through to completion. If the human wants to -break out (e.g. "I'll come back to this later", "pause", "stop"), respect that -immediately — but it must be their decision, never yours. +This transitions the phase to `done`, transitions the swamp-club status to +`shipped`, and posts a `complete` lifecycle entry. diff --git a/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md b/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md index df6313f8..2b5deaf2 100644 --- a/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md +++ b/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md @@ -5,12 +5,12 @@ resuming an issue in the `triaging` phase. ## 1. Create the Model Instance -If it doesn't already exist: +The swamp-club issue must already exist — create it in the swamp-club UI first, +then note its sequential number (e.g. `42`). ``` swamp model create @swamp/issue-lifecycle issue- \ - --global-arg issueNumber= \ - --global-arg repo= --json + --global-arg issueNumber= --json ``` **Worktree note:** If you are in a Claude Code worktree (`.claude/worktrees/`), @@ -25,6 +25,11 @@ commands in this skill also need `--repo-dir`. swamp model method run issue- start ``` +This fetches the issue from swamp-club via `GET /api/v1/lab/issues/` and +writes the title, body, type, status, and comments to the `context` resource. If +the issue doesn't exist in swamp-club, `start` fails loudly — create the issue +there first. + ## 3. Read the Issue Context and Codebase Read the model output, then explore the codebase. @@ -38,7 +43,7 @@ on affected files to see if they were recently changed. ``` swamp model method run issue- triage \ - --input type= \ + --input type= \ --input confidence= \ --input reasoning="" ``` @@ -47,16 +52,28 @@ swamp model method run issue- triage \ - `bug` — something is broken or behaving incorrectly - `feature` — a request for new functionality or enhancement -- `regression` — a bug where something **previously worked** but is now broken. - Look for signals like: "this used to work", "stopped working after", "worked - in version X", references to recent changes that broke existing behavior, or - git history showing the affected code was recently modified. Regressions get - both `bug` and `regression` labels. -- `unclear` — not enough information to classify confidently +- `security` — security vulnerability, hardening, or compliance work + +Add `--input isRegression=true` when the bug previously worked. Look for signals +like: "this used to work", "stopped working after", "worked in version X", +references to recent changes that broke existing behavior, or git history +showing the affected code was recently modified. A regression is still +classified as `type: bug` — `isRegression` is a detail on the classification +record. + +**If you cannot classify confidently**, do NOT guess. Ask the human first, or +call `triage` with `confidence=low` and `clarifyingQuestions` populated, then +wait for the human's response before moving on. + +Running `triage` automatically: + +- Updates the swamp-club issue's `type` field via PATCH +- Transitions the swamp-club status to `triaged` +- Posts a `classified` lifecycle entry with the full classification payload ## 5. Reproduce the Bug -**Bugs and regressions only — skip for features.** +**Bugs and regressions only — skip for features and security issues.** Before planning a fix, reproduce the issue to confirm the failure mode. diff --git a/issue-lifecycle/README.md b/issue-lifecycle/README.md index 7e1fb203..4b5b35a7 100644 --- a/issue-lifecycle/README.md +++ b/issue-lifecycle/README.md @@ -1,21 +1,17 @@ # Issue Lifecycle Extension Model -The `@swamp/issue-lifecycle` extension model moves GitHub issue triage and -implementation planning out of GitHub Actions and into a local, interactive -workflow. Every plan revision, feedback round, and CI result is stored as -versioned, immutable data — the issue thread on GitHub becomes a live status -dashboard. +The `@swamp/issue-lifecycle` extension model drives interactive issue triage and +implementation planning against swamp-club lab issues. Every plan revision, +feedback round, and adversarial finding is stored as versioned, immutable data, +and every state transition is mirrored to swamp-club as a structured lifecycle +entry. ## Why -The old flow: a `/triage` comment in GitHub triggers a CI action, Claude posts a -plan as a comment, you push back in the comment thread, re-triage, wait for CI, -repeat. Every round-trip goes through GitHub's API and Actions queue. - -The new flow: triage happens locally in a conversation with Claude. You push -back directly. Plans are revised in seconds, not minutes. State is persisted in -swamp's data store so you can walk away and resume later. GitHub gets a comment -on every state change so the issue thread stays current. +Plans are revised in seconds. State is persisted in swamp's data store so you +can walk away and resume later. swamp-club is the single source of truth for the +issue itself — the model fetches the issue by number, classifies it, plans it, +and reports progress back to swamp-club on every transition. ## State Machine @@ -26,79 +22,27 @@ classified ──[plan]──> plan_generated plan_generated ──[iterate]──> plan_generated (feedback loop) plan_generated ──[approve]──> approved approved ──[implement]──> implementing -implementing ──[ci_status]──> ci_review -ci_review ──[fix]──> implementing (fix loop) -ci_review ──[complete]──> done +implementing ──[complete]──> done ``` -Two iteration loops: - -1. **Plan loop** — generate a plan, get feedback, revise, repeat until approved -2. **Fix loop** — check CI, direct fixes, push, check CI again until clean - Pre-flight checks enforce valid transitions. You cannot approve without a plan. -You cannot implement without approval. +You cannot approve with unresolved critical/high adversarial findings. You +cannot implement without approval. ## Using with Claude Code (recommended) Tell Claude: ``` -triage issue #850 +triage swamp-club issue #42 ``` The `issue-lifecycle` skill takes over. Claude creates the model instance, -fetches the issue from GitHub, reads the codebase, classifies the issue, +fetches the issue from swamp-club, reads the codebase, classifies the issue, generates a plan, and shows it to you. -From there, it's a conversation: - -``` -You: The step ordering is wrong — CLI should come before parser changes. - What about regressions in the tokenizer? - -Claude: [revises plan, persists feedback] - Revised plan (v2): - 1. Update CLI command ... - 2. Add parser validation ... - 3. Regression tests for tokenizer ... - Feedback? - -You: Looks good. Approve it. - -Claude: [approves, posts full plan to GitHub issue] - Plan approved. Ready to implement? - -You: Go. - -Claude: [implements, creates PR, records PR number] - PR #851 created. Checking CI... - - CI Results: - - test: PASSED - - claude-review: CHANGES_REQUESTED (1 critical) - - adversarial-review: APPROVED - -You: Fix the critical issue from claude-review. - -Claude: [persists fix directive, makes fix, pushes, checks CI again] - All checks passed. Ship it? - -You: Done. - -Claude: [marks complete, posts to GitHub] -``` - -Every step posts a comment to the GitHub issue. Anyone watching sees: - -- "Triage started" -- "Classified as bug (high confidence)" -- "Plan revised (v2) — incorporated feedback round 1" -- "Plan approved (v2)" with the full plan -- "Implementation started — PR #851" -- "CI results: 4 passed, 1 failed" -- "Fixing: critical issue from claude-review" -- "Complete — all checks passed" +From there, it's a conversation: revise the plan, run adversarial review, +iterate until you approve, then implement and complete. ## Using via CLI (manual mode) @@ -107,103 +51,95 @@ If you want to drive the model directly without Claude: ### Setup ```bash -# Create an instance for issue #850 -swamp model create @swamp/issue-lifecycle issue-850 \ - --global-arg issueNumber=850 \ - --global-arg repo=owner/repo --json +# Create an instance for swamp-club issue #42 +swamp model create @swamp/issue-lifecycle issue-42 \ + --global-arg issueNumber=42 --json ``` ### Triage ```bash -# Fetch issue context from GitHub -swamp model method run issue-850 start --json +# Fetch issue context from swamp-club +swamp model method run issue-42 start --json -# Classify the issue -swamp model method run issue-850 triage \ +# Classify the issue (updates the swamp-club type) +swamp model method run issue-42 triage \ --input type=bug \ --input confidence=high \ - --input reasoning="Parser fails on nested brackets" --json + --input isRegression=true \ + --input reasoning="Parser fails on nested brackets — regression from #38" --json # Generate a plan -swamp model method run issue-850 plan \ +swamp model method run issue-42 plan \ --input summary="Fix bracket parsing in tokenizer" \ --input dddAnalysis="Touches the Parser entity..." \ - --input-file steps.json \ --input testingStrategy="Unit tests for tokenizer edge cases" \ - --input-file potentialChallenges.json --json + --input-file /tmp/plan.yaml --json ``` ### Plan iteration ```bash # Review the current plan -swamp model method run issue-850 review --json +swamp model method run issue-42 review --json # Review a specific version -swamp model method run issue-850 review --input version=1 --json +swamp model method run issue-42 review --input version=1 --json # Submit feedback and a revised plan -swamp model method run issue-850 iterate \ +swamp model method run issue-42 iterate \ --input feedback="Step ordering is wrong" \ --input summary="..." \ - --input-file steps.json \ + --input dddAnalysis="..." \ --input testingStrategy="..." \ - --input-file potentialChallenges.json --json - -# Approve when satisfied -swamp model method run issue-850 approve --json -``` + --input-file /tmp/plan.yaml --json -### Implementation & CI +# Record adversarial findings (required before approve) +swamp model method run issue-42 adversarial_review \ + --input-file /tmp/findings.yaml --json -```bash -# Record PR number (approve already marked work as in-progress) -swamp model method run issue-850 implement --input prNumber=851 --json +# Mark findings resolved after revising the plan +swamp model method run issue-42 resolve_findings \ + --input-file /tmp/resolutions.yaml --json -# Fetch CI results -swamp model method run issue-850 ci_status --json +# Approve when all critical/high findings are resolved +swamp model method run issue-42 approve --json +``` -# Direct fixes -swamp model method run issue-850 fix \ - --input directive="Fix the CRITICAL issues from adversarial review" \ - --input targetReview=claude-adversarial-review \ - --input targetSeverity=critical --json +### Implementation -# Check CI again -swamp model method run issue-850 ci_status --json +```bash +# Signal implementation has started +swamp model method run issue-42 implement --json -# Mark done -swamp model method run issue-850 complete --json +# Mark done once the PR is merged +swamp model method run issue-42 complete --json ``` ### Inspection ```bash # See current state -swamp model get issue-850 --json +swamp model get issue-42 --json -# See all stored data (plans, feedback, CI results) -swamp model output search issue-850 --json +# See all stored data (plans, feedback, adversarial findings) +swamp model output search issue-42 --json ``` ## Methods | Method | Description | State Transition | | -------------------- | ----------------------------------------------- | -------------------------------- | -| `start` | Fetch issue from GitHub | \* -> triaging | -| `triage` | Classify as bug/feature/unclear | triaging -> classified | +| `start` | Fetch issue from swamp-club | \* -> triaging | +| `triage` | Classify as bug/feature/security | triaging -> classified | | `plan` | Generate implementation plan | classified -> plan_generated | | `review` | Display current plan (read-only) | no change | | `iterate` | Revise plan with feedback | plan_generated -> plan_generated | | `adversarial_review` | Record adversarial review findings for the plan | no change | | `resolve_findings` | Mark adversarial findings as resolved | no change | -| `approve` | Lock the plan, post to GitHub, start work | plan_generated -> approved | +| `approve` | Lock the plan and transition to in_progress | plan_generated -> approved | | `implement` | Signal implementation started | approved -> implementing | -| `record_pr` | Record PR number for CI tracking | implementing -> implementing | -| `ci_status` | Fetch CI check results and review comments | implementing -> ci_review | -| `fix` | Direct specific fixes from review feedback | ci_review -> implementing | -| `complete` | Mark lifecycle done | ci_review -> done | +| `complete` | Mark lifecycle done | implementing -> done | ## Data stored @@ -212,29 +148,42 @@ and a new feedback version. You can review any prior version. | Resource | What it stores | | ------------------- | --------------------------------------------- | -| `state` | Current phase, repo, issue number, PR number | -| `context` | Issue title, body, labels, comments | -| `classification` | Bug/feature/unclear + reasoning | +| `state` | Current phase and swamp-club issue number | +| `context` | Issue title, body, type, status, comments | +| `classification` | bug/feature/security + reasoning + regression | | `plan` | Implementation plan (versioned per iteration) | | `feedback` | Human feedback (versioned per round) | | `adversarialReview` | Adversarial review findings for current plan | -| `ciResults` | CI check runs + review comments | -| `fixDirective` | Human-directed fix instructions | ## Swamp Club Integration -The issue-lifecycle model can optionally push structured lifecycle data to -[swamp.club](https://swamp.club), giving you a dashboard view of issue progress -across your team. +The issue-lifecycle model operates directly on swamp-club lab issues. There is +no GitHub integration — create the issue in swamp-club first, then pass the +sequential lab number (e.g. `42`) as the `issueNumber` global arg. ### How it works Every state transition (triage, plan, approve, implement, complete) posts a -structured lifecycle entry to the swamp-club API. The issue is created in -swamp-club on first contact via the `/ensure` endpoint, which matches by GitHub -repo + issue number. +structured lifecycle entry to the swamp-club API and transitions the issue's +status. The `triage` method additionally PATCHes the issue's `type` field to +match the classification. + +Each lab issue has a sequential, human-friendly number (`#1`, `#2`, ...) used in +every lab URL — both the dashboard and the API. You can find an issue at +`https://swamp.club/lab/`. -Key status transitions in swamp-club: +Classification types (`triage`) match swamp-club's issue types: + +| Classification | swamp-club type | +| -------------- | --------------- | +| `bug` | bug | +| `feature` | feature | +| `security` | security | + +Regressions are classified as `type: bug` with `isRegression: true` on the +classification record — swamp-club does not have a separate `regression` type. + +Status transitions in swamp-club: | Method | swamp-club status | | ---------- | ----------------- | @@ -245,7 +194,7 @@ Key status transitions in swamp-club: ### Setup -The integration requires a swamp-club API key. The URL defaults to +The model requires a swamp-club API key. The URL defaults to `https://swamp.club`. **Option 1: Environment variable (recommended)** @@ -254,7 +203,7 @@ The integration requires a swamp-club API key. The URL defaults to export SWAMP_API_KEY=swamp_your_key_here ``` -That's it. The model reads `SWAMP_API_KEY` automatically and connects to +The model reads `SWAMP_API_KEY` automatically and connects to `https://swamp.club`. **Option 2: Point at a local dev server** @@ -264,8 +213,17 @@ export SWAMP_API_KEY=swamp_your_key_here export SWAMP_CLUB_URL=http://localhost:8000 ``` -If no API key is set, the swamp-club integration is silently disabled — all -GitHub comment posting and local data storage still works normally. +**Option 3: Global args on `swamp model create`** + +```bash +swamp model create @swamp/issue-lifecycle issue-42 \ + --global-arg issueNumber=42 \ + --global-arg swampClubUrl=http://localhost:8000 \ + --global-arg swampClubApiKey=swamp_your_key_here --json +``` + +If no credentials are available, `start` fails immediately — swamp-club is +mandatory. ## Repository Configuration @@ -285,6 +243,6 @@ Create only the files you want to customize. ## Prerequisites -- `gh` CLI installed and authenticated (`gh auth login`) - swamp initialized in the repository (`swamp init`) -- (Optional) `SWAMP_API_KEY` env var for swamp-club integration +- `SWAMP_API_KEY` env var (or `swamp auth login`) for swamp-club access +- The target lab issue must already exist in swamp-club diff --git a/issue-lifecycle/extensions/models/_lib/issue_tracker.ts b/issue-lifecycle/extensions/models/_lib/issue_tracker.ts deleted file mode 100644 index 6c4c3483..00000000 --- a/issue-lifecycle/extensions/models/_lib/issue_tracker.ts +++ /dev/null @@ -1,398 +0,0 @@ -// Swamp, an Automation Framework Copyright (C) 2026 System Initiative, Inc. -// -// This file is part of Swamp. -// -// Swamp is free software: you can redistribute it and/or modify it under the terms -// of the GNU Affero General Public License version 3 as published by the Free -// Software Foundation, with the Swamp Extension and Definition Exception (found in -// the "COPYING-EXCEPTION" file). -// -// Swamp is distributed in the hope that it will be useful, but WITHOUT ANY -// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A -// PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License along -// with Swamp. If not, see . - -import type { Phase } from "./schemas.ts"; - -// --------------------------------------------------------------------------- -// Issue Tracker Interface -// --------------------------------------------------------------------------- - -/** Abstract interface for issue tracker operations. */ -export interface IssueTracker { - /** Fetch issue context (title, body, labels, comments). */ - fetchIssue( - repo: string, - issueNumber: number, - ): Promise<{ - title: string; - body: string; - labels: string[]; - comments: { author: string; body: string; createdAt: string }[]; - }>; - - /** Post a comment on the issue. Best-effort — should not throw. */ - postComment( - repo: string, - issueNumber: number, - body: string, - ): Promise; - - /** Set the lifecycle phase label, removing all other lifecycle labels. */ - setPhaseLabel( - repo: string, - issueNumber: number, - phase: Phase, - ): Promise; - - /** Add labels to the issue. Best-effort. */ - addLabels( - repo: string, - issueNumber: number, - labels: string[], - ): Promise; - - /** Remove labels from the issue. Best-effort. */ - removeLabels( - repo: string, - issueNumber: number, - labels: string[], - ): Promise; - - /** Fetch PR check runs. */ - fetchPrChecks( - repo: string, - prNumber: number, - ): Promise< - { - name: string; - status: "passed" | "failed" | "pending"; - }[] - >; - - /** Fetch PR reviews and their inline comments. */ - fetchPrReviews( - repo: string, - prNumber: number, - ): Promise< - { - reviewer: string; - state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "PENDING"; - body: string; - comments: { - path: string; - line?: number; - body: string; - severity?: string; - }[]; - }[] - >; - - /** Check that the tracker backend is available and authenticated. */ - checkAvailability(): Promise< - { available: true } | { available: false; error: string } - >; -} - -// --------------------------------------------------------------------------- -// GitHub Issue Tracker Implementation -// --------------------------------------------------------------------------- - -const PHASE_LABELS: Record = { - created: "", - triaging: "lifecycle/triaging", - classified: "lifecycle/classified", - plan_generated: "lifecycle/plan-review", - approved: "lifecycle/approved", - implementing: "lifecycle/implementing", - ci_review: "lifecycle/ci-review", - done: "lifecycle/done", -}; - -const ALL_LIFECYCLE_LABELS = Object.values(PHASE_LABELS).filter(Boolean); - -/** Run a gh CLI command and return parsed JSON output. */ -async function ghJson(args: string[]): Promise { - const cmd = new Deno.Command("gh", { - args, - stdout: "piped", - stderr: "piped", - }); - const output = await cmd.output(); - if (!output.success) { - throw new Error( - `gh ${args.join(" ")} failed: ${new TextDecoder().decode(output.stderr)}`, - ); - } - return JSON.parse(new TextDecoder().decode(output.stdout)); -} - -/** Best-effort run a gh command. Swallows errors. */ -async function ghBestEffort(args: string[]): Promise { - try { - const cmd = new Deno.Command("gh", { - args, - stdout: "null", - stderr: "piped", - }); - const output = await cmd.output(); - return output.success; - } catch { - return false; - } -} - -export class GitHubIssueTracker implements IssueTracker { - async fetchIssue(repo: string, issueNumber: number) { - const issue = (await ghJson([ - "issue", - "view", - String(issueNumber), - "--repo", - repo, - "--json", - "title,body,labels,comments", - ])) as { - title: string; - body: string; - labels: { name: string }[]; - comments: { - author: { login: string }; - body: string; - createdAt: string; - }[]; - }; - - return { - title: issue.title, - body: issue.body, - labels: issue.labels.map((l) => l.name), - comments: issue.comments.map((c) => ({ - author: c.author.login, - body: c.body, - createdAt: c.createdAt, - })), - }; - } - - async postComment( - repo: string, - issueNumber: number, - body: string, - ): Promise { - await ghBestEffort([ - "issue", - "comment", - String(issueNumber), - "--repo", - repo, - "--body", - body, - ]); - } - - async setPhaseLabel( - repo: string, - issueNumber: number, - phase: Phase, - ): Promise { - const targetLabel = PHASE_LABELS[phase]; - const removeLabels = ALL_LIFECYCLE_LABELS.filter((l) => l !== targetLabel); - removeLabels.push("lifecycle/needs-info"); - - await this.removeLabels(repo, issueNumber, removeLabels); - if (targetLabel) { - await this.addLabels(repo, issueNumber, [targetLabel]); - } - } - - async addLabels( - repo: string, - issueNumber: number, - labels: string[], - ): Promise { - for (const label of labels) { - await ghBestEffort([ - "issue", - "edit", - String(issueNumber), - "--repo", - repo, - "--add-label", - label, - ]); - } - } - - async removeLabels( - repo: string, - issueNumber: number, - labels: string[], - ): Promise { - for (const label of labels) { - await ghBestEffort([ - "issue", - "edit", - String(issueNumber), - "--repo", - repo, - "--remove-label", - label, - ]); - } - } - - async fetchPrChecks(repo: string, prNumber: number) { - const checksData = (await ghJson([ - "pr", - "checks", - String(prNumber), - "--repo", - repo, - "--json", - "name,state,bucket", - ])) as { name: string; state: string; bucket: string }[]; - - return (Array.isArray(checksData) ? checksData : []).map((c) => ({ - name: c.name, - status: c.bucket === "pass" - ? "passed" as const - : c.bucket === "fail" - ? "failed" as const - : "pending" as const, - })); - } - - async fetchPrReviews(repo: string, prNumber: number) { - const reviewsRaw = (await ghJson([ - "api", - `repos/${repo}/pulls/${prNumber}/reviews`, - "--jq", - ".", - ])) as { - user: { login: string }; - state: string; - body: string; - }[]; - - const commentsRaw = (await ghJson([ - "api", - `repos/${repo}/pulls/${prNumber}/comments`, - "--jq", - ".", - ])) as { - user: { login: string }; - path: string; - line: number | null; - body: string; - pull_request_review_id: number; - }[]; - - // Reviews are keyed by `user.login` so the LATEST review per reviewer - // wins. The GitHub API returns reviews in chronological order, and this - // matches GitHub's own PR UI semantics: a reviewer who first requested - // changes and then approved is considered to have approved, and we - // deliberately do NOT surface withdrawn feedback into the lifecycle's - // `fix` loop — it would treat resolved feedback as still actionable. - // - // Known narrow gap: a `COMMENTED` review submitted after a decisive - // state will overwrite that state, even though GitHub itself does not - // dismiss prior approvals/change-requests via a comment. Tracked in - // systeminit/swamp-extensions#53. - const reviewMap = new Map< - string, - { - state: string; - body: string; - comments: { - path: string; - line?: number; - body: string; - severity?: string; - }[]; - } - >(); - - for (const review of (Array.isArray(reviewsRaw) ? reviewsRaw : [])) { - reviewMap.set(review.user.login, { - state: review.state, - body: review.body, - comments: [], - }); - } - - for (const comment of (Array.isArray(commentsRaw) ? commentsRaw : [])) { - const reviewer = comment.user.login; - if (!reviewMap.has(reviewer)) { - reviewMap.set(reviewer, { - state: "COMMENTED", - body: "", - comments: [], - }); - } - - const severityMatch = comment.body.match( - /\*\*(?:severity|Severity)\s*:\s*(critical|high|medium|low|info)\*\*/i, - ); - - reviewMap.get(reviewer)!.comments.push({ - path: comment.path, - line: comment.line ?? undefined, - body: comment.body, - severity: severityMatch ? severityMatch[1].toLowerCase() : undefined, - }); - } - - return Array.from(reviewMap.entries()).map(([reviewer, data]) => ({ - reviewer, - state: data.state as - | "APPROVED" - | "CHANGES_REQUESTED" - | "COMMENTED" - | "PENDING", - body: data.body, - comments: data.comments, - })); - } - - async checkAvailability(): Promise< - { available: true } | { available: false; error: string } - > { - try { - const which = new Deno.Command("which", { - args: ["gh"], - stdout: "null", - stderr: "null", - }); - const { success } = await which.output(); - if (!success) { - return { available: false, error: "gh CLI is not installed" }; - } - const auth = new Deno.Command("gh", { - args: ["auth", "status"], - stdout: "null", - stderr: "null", - }); - const authResult = await auth.output(); - if (!authResult.success) { - return { - available: false, - error: "gh CLI is not authenticated. Run 'gh auth login'.", - }; - } - return { available: true }; - } catch { - return { - available: false, - error: "Failed to check gh CLI availability", - }; - } - } -} - -/** Create the default issue tracker instance. */ -export function createTracker(): IssueTracker { - return new GitHubIssueTracker(); -} diff --git a/issue-lifecycle/extensions/models/_lib/schemas.ts b/issue-lifecycle/extensions/models/_lib/schemas.ts index c7d9e43d..376bcb51 100644 --- a/issue-lifecycle/extensions/models/_lib/schemas.ts +++ b/issue-lifecycle/extensions/models/_lib/schemas.ts @@ -21,10 +21,9 @@ import { z } from "zod"; // --------------------------------------------------------------------------- export const GlobalArgsSchema = z.object({ - repo: z.string().describe( - "GitHub repository in owner/repo format", + issueNumber: z.number().describe( + "Swamp Club lab issue number (the issue must already exist in swamp-club)", ), - issueNumber: z.number().describe("GitHub issue number"), swampClubUrl: z.string().optional().describe( "Swamp Club API base URL (defaults to https://swamp.club)", ), @@ -44,7 +43,6 @@ export const Phase = z.enum([ "plan_generated", "approved", "implementing", - "ci_review", "done", ]); @@ -59,21 +57,25 @@ export const TRANSITIONS: Record = { "plan_generated", "approved", "implementing", - "ci_review", ], triage: ["triaging"], plan: ["classified"], iterate: ["plan_generated"], approve: ["plan_generated"], implement: ["approved"], - record_pr: ["implementing"], - ci_status: ["implementing"], adversarial_review: ["plan_generated"], resolve_findings: ["plan_generated"], - fix: ["ci_review"], - complete: ["ci_review"], + complete: ["implementing"], }; +// --------------------------------------------------------------------------- +// Issue Classification Types +// --------------------------------------------------------------------------- + +/** Issue types supported by swamp-club. */ +export const IssueType = z.enum(["bug", "feature", "security"]); +export type IssueType = z.infer; + // --------------------------------------------------------------------------- // Resource Schemas // --------------------------------------------------------------------------- @@ -81,8 +83,6 @@ export const TRANSITIONS: Record = { export const StateSchema = z.object({ phase: Phase, issueNumber: z.number(), - repo: z.string(), - prNumber: z.number().optional(), updatedAt: z.string(), }); @@ -91,7 +91,8 @@ export type StateData = z.infer; export const ContextSchema = z.object({ title: z.string(), body: z.string(), - labels: z.array(z.string()), + type: IssueType, + status: z.string(), comments: z.array( z.object({ author: z.string(), @@ -103,9 +104,12 @@ export const ContextSchema = z.object({ }); export const ClassificationSchema = z.object({ - type: z.enum(["bug", "feature", "regression", "unclear"]), + type: IssueType, confidence: z.enum(["high", "medium", "low"]), reasoning: z.string(), + isRegression: z.boolean().optional().describe( + "True if this is a regression (something that previously worked). Implies type=bug.", + ), clarifyingQuestions: z.array(z.string()).optional(), classifiedAt: z.string(), }); @@ -137,39 +141,6 @@ export const FeedbackSchema = z.object({ submittedAt: z.string(), }); -export const CiResultsSchema = z.object({ - prNumber: z.number(), - checkRuns: z.array( - z.object({ - name: z.string(), - status: z.enum(["passed", "failed", "pending"]), - }), - ), - reviews: z.array( - z.object({ - reviewer: z.string(), - state: z.enum([ - "APPROVED", - "CHANGES_REQUESTED", - "COMMENTED", - "PENDING", - ]), - body: z.string(), - comments: z.array( - z.object({ - path: z.string(), - line: z.number().optional(), - body: z.string(), - severity: z - .enum(["critical", "high", "medium", "low", "info"]) - .optional(), - }), - ), - }), - ), - fetchedAt: z.string(), -}); - export const AdversarialFindingSchema = z.object({ id: z.string().describe("Unique finding identifier, e.g. ADV-1"), severity: z.enum(["critical", "high", "medium", "low"]), @@ -190,12 +161,3 @@ export const AdversarialReviewSchema = z.object({ }); export type AdversarialReviewData = z.infer; - -export const FixDirectiveSchema = z.object({ - round: z.number(), - directive: z.string(), - targetReview: z.string().optional(), - targetSeverity: z.string().optional(), - ciResultsVersion: z.number(), - submittedAt: z.string(), -}); diff --git a/issue-lifecycle/extensions/models/_lib/schemas_test.ts b/issue-lifecycle/extensions/models/_lib/schemas_test.ts index 5852194d..07687abe 100644 --- a/issue-lifecycle/extensions/models/_lib/schemas_test.ts +++ b/issue-lifecycle/extensions/models/_lib/schemas_test.ts @@ -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 → ci_review → done", () => { +Deno.test("happy path: created → triaging → classified → plan_generated → approved → implementing → 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. @@ -74,8 +74,7 @@ Deno.test("happy path: created → triaging → classified → plan_generated { method: "plan", from: "classified" }, { method: "approve", from: "plan_generated" }, { method: "implement", from: "approved" }, - { method: "ci_status", from: "implementing" }, - { method: "complete", from: "ci_review" }, + { method: "complete", from: "implementing" }, ]; for (const { method, from } of happyPath) { const allowed = TRANSITIONS[method]; @@ -96,13 +95,6 @@ Deno.test("plan iteration loop: iterate is allowed from plan_generated only", () assertEquals(TRANSITIONS.iterate, ["plan_generated"]); }); -Deno.test("fix loop: fix returns from ci_review to implementing only", () => { - // The fix loop must originate from ci_review (you fixed something based on - // CI feedback). Allowing fix from any other phase would let a user post a - // fix directive without ever having seen CI results. - assertEquals(TRANSITIONS.fix, ["ci_review"]); -}); - Deno.test("approval gate: approve only allowed from plan_generated", () => { assertEquals(TRANSITIONS.approve, ["plan_generated"]); }); @@ -110,3 +102,9 @@ Deno.test("approval gate: approve only allowed from plan_generated", () => { 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"]); +}); diff --git a/issue-lifecycle/extensions/models/_lib/swamp_club.ts b/issue-lifecycle/extensions/models/_lib/swamp_club.ts index 398040fb..624e2470 100644 --- a/issue-lifecycle/extensions/models/_lib/swamp_club.ts +++ b/issue-lifecycle/extensions/models/_lib/swamp_club.ts @@ -19,6 +19,7 @@ // --------------------------------------------------------------------------- import { join } from "@std/path"; +import type { IssueType } from "./schemas.ts"; export interface LifecycleEntryParams { step: string; @@ -30,25 +31,28 @@ export interface LifecycleEntryParams { isVerbose?: boolean; } +export interface FetchedIssue { + number: number; + type: IssueType; + status: string; + title: string; + body: string; + comments: { author: string; body: string; createdAt: string }[]; +} + /** - * HTTP client for posting structured lifecycle data to the swamp-club API. - * Resolves the swamp-club issue ID lazily from the GitHub repo + issue number - * using the /ensure endpoint. All operations are best-effort. + * HTTP client for the swamp-club lab issues API. Operates directly on a + * sequential lab issue number — the issue must already exist in swamp-club. */ export class SwampClubClient { private baseUrl: string; readonly #apiKey: string; - private repo: string; private issueNumber: number; private log: (msg: string, props: Record) => void; - /** Cached swamp-club issue ID, resolved on first call. */ - private issueId: string | null = null; - constructor( baseUrl: string, apiKey: string, - repo: string, issueNumber: number, logger?: { info: (msg: string, props: Record) => void; @@ -57,81 +61,79 @@ export class SwampClubClient { ) { this.baseUrl = baseUrl.replace(/\/+$/, ""); this.#apiKey = apiKey; - this.repo = repo; this.issueNumber = issueNumber; this.log = logger?.warning.bind(logger) ?? (() => {}); } + /** Build the public lab URL for this issue. */ + labUrl(): string { + return `${this.baseUrl}/lab/${this.issueNumber}`; + } + /** - * Ensure the issue exists in swamp-club by GitHub repo + issue number. - * Creates it if needed. Caches the issue ID for subsequent calls. - * Must be called with the issue data (title, body, type) the first time. + * Fetch the issue from swamp-club. Returns null if the issue does not + * exist or the request fails. */ - async ensureIssue(params: { - title: string; - body: string; - type?: string; - githubAuthorLogin?: string; - }): Promise { - if (this.issueId) return this.issueId; - + async fetchIssue(): Promise { try { - const url = `${this.baseUrl}/api/v1/lab/issues/ensure`; + const url = `${this.baseUrl}/api/v1/lab/issues/${this.issueNumber}`; const res = await fetch(url, { - method: "POST", + method: "GET", headers: { - "Content-Type": "application/json", "Authorization": `Bearer ${this.#apiKey}`, }, - body: JSON.stringify({ - githubRepoFullName: this.repo, - githubIssueNumber: this.issueNumber, - title: params.title, - body: params.body, - type: params.type ?? "feature", - githubAuthorLogin: params.githubAuthorLogin, - }), signal: AbortSignal.timeout(15_000), }); if (!res.ok) { const text = await res.text().catch(() => ""); - this.log("swamp-club ensure issue failed: {status} {text}", { + this.log("swamp-club fetch issue failed: {status} {text}", { status: res.status, text, }); return null; } const data = await res.json() as { - issue: { id: string }; - created: boolean; + issue?: { + number?: number; + type?: IssueType; + status?: string; + title?: string; + body?: string; + comments?: { + authorUsername?: string; + author?: string; + body?: string; + createdAt?: string; + }[]; + }; + }; + const issue = data?.issue; + if (!issue || typeof issue.number !== "number") return null; + return { + number: issue.number, + type: (issue.type ?? "feature") as IssueType, + status: issue.status ?? "open", + title: issue.title ?? "", + body: issue.body ?? "", + comments: (issue.comments ?? []).map((c) => ({ + author: c.authorUsername ?? c.author ?? "unknown", + body: c.body ?? "", + createdAt: c.createdAt ?? "", + })), }; - const resolvedId = data.issue.id; - // Validate UUID format to prevent path traversal - if ( - !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - resolvedId, - ) - ) { - this.log("swamp-club returned invalid issue ID: {id}", { - id: resolvedId, - }); - return null; - } - this.issueId = resolvedId; - return this.issueId; } catch (err) { - this.log("swamp-club ensure issue error: {error}", { + this.log("swamp-club fetch issue error: {error}", { error: String(err), }); return null; } } - /** Post a structured lifecycle entry. Best-effort. Requires ensureIssue() first. */ + /** Post a structured lifecycle entry. Best-effort. */ async postLifecycleEntry(params: LifecycleEntryParams): Promise { - if (!this.issueId) return; try { - const url = `${this.baseUrl}/api/v1/lab/issues/${this.issueId}/lifecycle`; + const url = + `${this.baseUrl}/api/v1/lab/issues/${this.issueNumber}/lifecycle`; const res = await fetch(url, { method: "POST", headers: { @@ -163,29 +165,40 @@ export class SwampClubClient { } } - /** Transition the issue status. Best-effort. Requires ensureIssue() first. */ + /** Transition the issue status. Best-effort. */ async transitionStatus(status: string): Promise { - if (!this.issueId) return; + await this.patchIssue({ status }); + } + + /** Update the issue type. Best-effort. */ + async updateType(type: IssueType): Promise { + await this.patchIssue({ type }); + } + + /** PATCH the issue with a partial set of fields. Best-effort. */ + private async patchIssue( + patch: Record, + ): Promise { try { - const url = `${this.baseUrl}/api/v1/lab/issues/${this.issueId}`; + const url = `${this.baseUrl}/api/v1/lab/issues/${this.issueNumber}`; const res = await fetch(url, { method: "PATCH", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.#apiKey}`, }, - body: JSON.stringify({ status }), + body: JSON.stringify(patch), signal: AbortSignal.timeout(15_000), }); if (!res.ok) { const text = await res.text().catch(() => ""); - this.log("swamp-club status transition failed: {status} {text}", { + this.log("swamp-club patch failed: {status} {text}", { status: res.status, text, }); } } catch (err) { - this.log("swamp-club status transition error: {error}", { + this.log("swamp-club patch error: {error}", { error: String(err), }); } @@ -228,13 +241,11 @@ async function loadAuthFile(): Promise< } /** - * Create a SwampClubClient if URL and API key are available. + * Create a SwampClubClient if a URL and API key are available. * Precedence: explicit global args > SWAMP_API_KEY env var > auth.json file. - * The issue ID is resolved lazily — no swampClubIssueId arg needed. */ export async function createSwampClubClient( globalArgs: { - repo: string; issueNumber: number; swampClubUrl?: string; swampClubApiKey?: string; @@ -244,11 +255,9 @@ export async function createSwampClubClient( warning: (msg: string, props: Record) => void; }, ): Promise { - // 1. Explicit args or env var let url = globalArgs.swampClubUrl ?? Deno.env.get("SWAMP_CLUB_URL"); let apiKey = globalArgs.swampClubApiKey ?? Deno.env.get("SWAMP_API_KEY"); - // 2. Fall back to auth.json from `swamp auth login` if (!apiKey) { const fileCreds = await loadAuthFile(); if (fileCreds) { @@ -261,7 +270,7 @@ export async function createSwampClubClient( if (!apiKey) { logger?.warning( - "No swamp-club credentials found (set SWAMP_API_KEY or run `swamp auth login`) — lifecycle entries will be skipped", + "No swamp-club credentials found (set SWAMP_API_KEY or run `swamp auth login`)", {}, ); return null; @@ -277,14 +286,14 @@ export async function createSwampClubClient( await res.body?.cancel(); if (!res.ok) { logger?.warning( - "swamp-club at {url} returned HTTP {status} — lifecycle entries will be skipped", + "swamp-club at {url} returned HTTP {status}", { url, status: res.status }, ); return null; } } catch (err) { logger?.warning( - "swamp-club at {url} is not reachable ({error}) — lifecycle entries will be skipped", + "swamp-club at {url} is not reachable ({error})", { url, error: String(err) }, ); return null; @@ -293,7 +302,6 @@ export async function createSwampClubClient( return new SwampClubClient( url, apiKey, - globalArgs.repo, globalArgs.issueNumber, logger, ); diff --git a/issue-lifecycle/extensions/models/issue_lifecycle.ts b/issue-lifecycle/extensions/models/issue_lifecycle.ts index 3cea9734..181c35e6 100644 --- a/issue-lifecycle/extensions/models/issue_lifecycle.ts +++ b/issue-lifecycle/extensions/models/issue_lifecycle.ts @@ -19,12 +19,11 @@ import { AdversarialFindingSchema, type AdversarialReviewData, AdversarialReviewSchema, - CiResultsSchema, ClassificationSchema, ContextSchema, FeedbackSchema, - FixDirectiveSchema, GlobalArgsSchema, + IssueType, type PlanData, PlanSchema, PlanStepSchema, @@ -32,76 +31,15 @@ import { StateSchema, TRANSITIONS, } from "./_lib/schemas.ts"; -import { createTracker } from "./_lib/issue_tracker.ts"; import { createSwampClubClient } from "./_lib/swamp_club.ts"; -import type { SwampClubClient } from "./_lib/swamp_club.ts"; -const tracker = createTracker(); - -/** Extended global args type including optional swamp-club fields. */ +/** Global args type for the issue-lifecycle model. */ type GlobalArgs = { - repo: string; issueNumber: number; swampClubUrl?: string; swampClubApiKey?: string; }; -/** - * Get or create the swamp-club client (lazily, from globalArgs). - * - * `_swampClub` is a module-level cache. This is safe because each - * `swamp model method run` invocation runs in its own process — module - * state starts fresh per run and never crosses method boundaries. The - * cache exists to avoid re-running `createSwampClubClient`'s reachability - * check (a 5s-timeout HTTP fetch to `swamp.club/api/health`) every time - * `ensureSwampClub` is called within a single `execute()` call. Most - * methods call it once, but the cache also covers the cases where it is - * called more than once. - * - * If swamp ever changes its execution model so a single Deno process - * handles multiple method runs, this cache must be either removed (so the - * health check runs per call) or scoped to the `execute()` context (e.g., - * stashed on the context object) — otherwise a `null` cached on the first - * call (no `SWAMP_API_KEY`) would persist across runs even if the user - * exported the env var between them. - */ -async function getSwampClub( - globalArgs: GlobalArgs, - logger?: { - info: (msg: string, props: Record) => void; - warning: (msg: string, props: Record) => void; - }, -): Promise { - if (_swampClub === undefined) { - _swampClub = await createSwampClubClient(globalArgs, logger); - } - return _swampClub; -} -let _swampClub: SwampClubClient | null | undefined; - -/** - * Get the swamp-club client and ensure the issue exists. - * Each method run is a separate process, so the issueId cache is lost. - * This helper must be called before postLifecycleEntry/transitionStatus. - */ -async function ensureSwampClub( - globalArgs: GlobalArgs, - logger: { - info: (msg: string, props: Record) => void; - warning: (msg: string, props: Record) => void; - }, -): Promise { - const sc = await getSwampClub(globalArgs, logger); - if (!sc) return null; - const id = await sc.ensureIssue({ - title: `Issue #${globalArgs.issueNumber}`, - body: `GitHub issue #${globalArgs.issueNumber} in ${globalArgs.repo}`, - type: "feature", - }); - if (!id) return null; - return sc; -} - /** Read the current state from data repository (for checks). */ async function readState( dataRepository: { @@ -129,7 +67,7 @@ async function readState( export const model = { type: "@swamp/issue-lifecycle", - version: "2026.04.07.1", + version: "2026.04.08.1", globalArguments: GlobalArgsSchema, upgrades: [ @@ -146,9 +84,16 @@ export const model = { upgradeAttributes: (old: Record) => old, }, { - toVersion: "2026.04.07.1", - description: "No changes - just moving source", - upgradeAttributes: (old: Record) => old, + toVersion: "2026.04.08.1", + description: + "Drop GitHub integration — swamp-club is now the source of truth. " + + "Global args replaced (repo removed, issueNumber now refers to swamp-club lab issue). " + + "Removed ci_status, record_pr, fix methods and ciResults/fixDirective resources.", + upgradeAttributes: (old: Record) => { + const next = { ...old }; + delete next.repo; + return next; + }, }, ], @@ -160,7 +105,7 @@ export const model = { garbageCollection: 10, }, "context": { - description: "Issue context fetched from GitHub", + description: "Issue context fetched from swamp-club", schema: ContextSchema, lifetime: "infinite" as const, garbageCollection: 5, @@ -189,18 +134,6 @@ export const model = { lifetime: "infinite" as const, garbageCollection: 20, }, - "ciResults": { - description: "CI check results and review comments", - schema: CiResultsSchema, - lifetime: "infinite" as const, - garbageCollection: 10, - }, - "fixDirective": { - description: "Human-directed fix instructions", - schema: FixDirectiveSchema, - lifetime: "infinite" as const, - garbageCollection: 20, - }, }, checks: { @@ -387,22 +320,11 @@ export const model = { return { pass: true }; }, }, - - "tracker-available": { - description: - "Checks that the issue tracker backend is available and authenticated", - labels: ["live"], - execute: async () => { - const result = await tracker.checkAvailability(); - if (result.available) return { pass: true }; - return { pass: false, errors: [result.error] }; - }, - }, }, methods: { start: { - description: "Fetch issue context and begin lifecycle", + description: "Ensure the swamp-club issue exists and begin the lifecycle", arguments: z.object({}), execute: async ( _args: Record, @@ -419,16 +341,34 @@ export const model = { ) => Promise<{ name: string }>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; - const issue = await tracker.fetchIssue(repo, issueNumber); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); + if (!sc) { + throw new Error( + "swamp-club is not reachable or credentials are missing. " + + "Set SWAMP_API_KEY or run `swamp auth login`.", + ); + } + + const issue = await sc.fetchIssue(); + if (!issue) { + throw new Error( + `swamp-club issue #${issueNumber} was not found. ` + + `Create the issue in swamp-club first, then run 'start'.`, + ); + } handles.push( await context.writeResource("context", "context-main", { title: issue.title, body: issue.body, - labels: issue.labels, + type: issue.type, + status: issue.status, comments: issue.comments, fetchedAt: new Date().toISOString(), }), @@ -438,38 +378,26 @@ export const model = { await context.writeResource("state", "state-main", { phase: "triaging", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); - context.logger.info("Fetched issue #{issueNumber}: {title}", { - issueNumber, - title: issue.title, - }); - - await tracker.postComment( - repo, - issueNumber, - `\u{1F50D} **Triage started** \u2014 fetching issue context`, - ); - const sc = await getSwampClub(context.globalArgs, context.logger); - if (sc) { - await sc.ensureIssue({ + context.logger.info( + "Fetched swamp-club issue #{issueNumber}: {title}", + { + issueNumber, title: issue.title, - body: issue.body, - type: "feature", - }); - await sc.postLifecycleEntry({ - step: "triage_started", - targetStatus: "open", - summary: "Triage started", - emoji: "\u{1F50D}", - payload: { issueNumber, repo }, - isVerbose: false, - }); - } - await tracker.setPhaseLabel(repo, issueNumber, "triaging"); + }, + ); + + await sc.postLifecycleEntry({ + step: "triage_started", + targetStatus: "open", + summary: "Triage started", + emoji: "\u{1F50D}", + payload: { issueNumber }, + isVerbose: false, + }); return { dataHandles: handles }; }, @@ -478,16 +406,20 @@ export const model = { triage: { description: "Classify the issue based on context", arguments: z.object({ - type: z.enum(["bug", "feature", "regression", "unclear"]), + type: IssueType, confidence: z.enum(["high", "medium", "low"]), reasoning: z.string(), + isRegression: z.boolean().optional().describe( + "True if this is a regression (something that previously worked). Implies type=bug.", + ), clarifyingQuestions: z.array(z.string()).optional(), }), execute: async ( args: { - type: "bug" | "feature" | "regression" | "unclear"; + type: "bug" | "feature" | "security"; confidence: "high" | "medium" | "low"; reasoning: string; + isRegression?: boolean; clarifyingQuestions?: string[]; }, context: { @@ -503,7 +435,7 @@ export const model = { ) => Promise<{ name: string }>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; handles.push( @@ -514,6 +446,7 @@ export const model = { type: args.type, confidence: args.confidence, reasoning: args.reasoning, + isRegression: args.isRegression, clarifyingQuestions: args.clarifyingQuestions, classifiedAt: new Date().toISOString(), }, @@ -524,78 +457,43 @@ export const model = { await context.writeResource("state", "state-main", { phase: "classified", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); + const regressionLabel = args.isRegression ? " (regression)" : ""; context.logger.info( - "Classified as {type} ({confidence}): {reasoning}", + "Classified as {type}{regression} ({confidence}): {reasoning}", { type: args.type, + regression: regressionLabel, confidence: args.confidence, reasoning: args.reasoning, }, ); - await tracker.postComment( - repo, - issueNumber, - [ - `\u{1F4CB} **Classified as ${args.type}** (${args.confidence})`, - "", - "
", - "Classification details", - "", - args.reasoning, - "
", - ].join("\n"), - ); - const scClassify = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - if (scClassify) { - await scClassify.postLifecycleEntry({ + if (sc) { + await sc.updateType(args.type); + await sc.postLifecycleEntry({ step: "classified", targetStatus: "triaged", - summary: `Classified as ${args.type} (${args.confidence})`, + summary: + `Classified as ${args.type}${regressionLabel} (${args.confidence})`, emoji: "\u{1F4CB}", payload: { type: args.type, confidence: args.confidence, reasoning: args.reasoning, + isRegression: args.isRegression ?? false, clarifyingQuestions: args.clarifyingQuestions, }, isVerbose: false, }); - await scClassify.transitionStatus("triaged"); - } - await tracker.setPhaseLabel(repo, issueNumber, "classified"); - - const typeLabels: Record = - { - bug: { - add: ["bug"], - remove: ["feature", "regression", "needs-triage"], - }, - feature: { - add: ["feature"], - remove: ["bug", "regression", "needs-triage"], - }, - regression: { - add: ["bug", "regression"], - remove: ["feature", "needs-triage"], - }, - unclear: { - add: ["lifecycle/needs-info"], - remove: ["bug", "regression", "needs-triage"], - }, - }; - const labelOps = typeLabels[args.type]; - if (labelOps) { - await tracker.removeLabels(repo, issueNumber, labelOps.remove); - await tracker.addLabels(repo, issueNumber, labelOps.add); + await sc.transitionStatus("triaged"); } return { dataHandles: handles }; @@ -637,7 +535,7 @@ export const model = { ) => Promise<{ name: string }>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; handles.push( @@ -657,7 +555,6 @@ export const model = { await context.writeResource("state", "state-main", { phase: "plan_generated", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); @@ -666,24 +563,11 @@ export const model = { summary: args.summary, }); - await tracker.postComment( - repo, - issueNumber, - [ - `\u{1F4DD} **Implementation plan generated** (v1)`, - "", - "
", - "Plan summary", - "", - args.summary, - "
", - ].join("\n"), - ); - const scPlan = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - await scPlan?.postLifecycleEntry({ + await sc?.postLifecycleEntry({ step: "plan_generated", targetStatus: "triaged", summary: `Implementation plan generated (v1) \u2014 ${args.summary}`, @@ -698,7 +582,6 @@ export const model = { }, isVerbose: true, }); - await tracker.setPhaseLabel(repo, issueNumber, "plan_generated"); return { dataHandles: handles }; }, @@ -801,7 +684,7 @@ export const model = { modelId: string; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; const currentPlan = await context.readResource!("plan-main") as @@ -860,7 +743,6 @@ export const model = { await context.writeResource("state", "state-main", { phase: "plan_generated", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); @@ -870,24 +752,11 @@ export const model = { { version: newVersion, round: feedbackRound }, ); - await tracker.postComment( - repo, - issueNumber, - [ - `\u{1F504} **Plan revised** (v${newVersion}) \u2014 incorporated feedback round ${feedbackRound}`, - "", - "
", - "Updated plan summary", - "", - args.summary, - "
", - ].join("\n"), - ); - const scIterate = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - await scIterate?.postLifecycleEntry({ + await sc?.postLifecycleEntry({ step: "plan_revised", targetStatus: "triaged", summary: @@ -904,7 +773,6 @@ export const model = { }, isVerbose: true, }); - await tracker.setPhaseLabel(repo, issueNumber, "plan_generated"); return { dataHandles: handles }; }, @@ -944,7 +812,6 @@ export const model = { ) => Promise | null>; }, ) => { - const { repo, issueNumber } = context.globalArgs; const handles = []; const plan = await context.readResource!("plan-main") as @@ -980,44 +847,11 @@ export const model = { { planVersion, critical, high, medium, low }, ); - const findingsText = findings - .map((f) => - `- **${f.id}** [${f.severity}/${f.category}]: ${f.description}` - ) - .join("\n"); - - const blockers = critical + high; - const status = blockers > 0 - ? `\u{1F6D1} **${blockers} blocking finding(s)** must be resolved before approval` - : "\u{2705} No blocking findings — ready for approval"; - - const severitySummary = [ - critical > 0 ? `${critical} critical` : "", - high > 0 ? `${high} high` : "", - medium > 0 ? `${medium} medium` : "", - low > 0 ? `${low} low` : "", - ].filter(Boolean).join(", "); - - await tracker.postComment( - repo, - issueNumber, - [ - `\u{1F50D} **Adversarial review** (plan v${planVersion})`, - "", - status, - "", - "
", - `Findings (${severitySummary})`, - "", - findingsText, - "
", - ].join("\n"), - ); - const scReview = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - await scReview?.postLifecycleEntry({ + await sc?.postLifecycleEntry({ step: "adversarial_review", targetStatus: "triaged", summary: @@ -1071,7 +905,6 @@ export const model = { ) => Promise | null>; }, ) => { - const { repo, issueNumber } = context.globalArgs; const handles = []; const current = await context.readResource!( @@ -1119,34 +952,11 @@ export const model = { { resolved, remaining }, ); - const resolvedText = args.resolutions - .map((r) => `- **${r.findingId}**: ${r.resolutionNote}`) - .join("\n"); - - const remainingStatus = remaining > 0 - ? `\u{1F6D1} ${remaining} blocking finding(s) remain` - : "\u{2705} All blocking findings resolved — ready for approval"; - - await tracker.postComment( - repo, - issueNumber, - [ - `\u{2705} **Findings resolved** (${resolved})`, - "", - remainingStatus, - "", - "
", - "Resolution details", - "", - resolvedText, - "
", - ].join("\n"), - ); - const scResolve = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - await scResolve?.postLifecycleEntry({ + await sc?.postLifecycleEntry({ step: "findings_resolved", targetStatus: "triaged", summary: @@ -1165,7 +975,7 @@ export const model = { }, approve: { - description: "Approve the current plan and post it to the issue", + description: "Approve the current plan", arguments: z.object({}), execute: async ( _args: Record, @@ -1186,14 +996,13 @@ export const model = { ) => Promise | null>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; handles.push( await context.writeResource("state", "state-main", { phase: "approved", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); @@ -1201,86 +1010,19 @@ export const model = { const plan = await context.readResource!("plan-main") as | PlanData | null; - if (plan) { - const steps = plan.steps - .map((s) => - `${s.order}. ${s.description}${ - s.risks ? ` _(risk: ${s.risks})_` : "" - }` - ) - .join("\n"); - const files = plan.steps - .flatMap((s) => s.files) - .filter((f, i, a) => a.indexOf(f) === i) - .map((f) => `- \`${f}\``) - .join("\n"); - const challenges = plan.potentialChallenges.map((c) => `- ${c}`).join( - "\n", - ); - - const fileCount = plan.steps - .flatMap((s) => s.files) - .filter((f, i, a) => a.indexOf(f) === i) - .length; - - await tracker.postComment( - repo, - issueNumber, - [ - "", - `## Approved Implementation Plan (v${plan.version})`, - "", - `**Summary:** ${plan.summary}`, - "", - "
", - "DDD Analysis", - "", - plan.dddAnalysis, - "
", - "", - "### Implementation Steps", - steps, - "", - "
", - `Files (${fileCount})`, - "", - files, - "
", - "", - "
", - "Testing Strategy", - "", - plan.testingStrategy, - "
", - "", - "
", - "Potential Challenges", - "", - challenges, - "
", - "", - plan.feedbackIncorporated.length > 0 - ? `_Incorporated ${plan.feedbackIncorporated.length} round(s) of feedback._` - : "", - "", - "_Approved via swamp @swamp/issue-lifecycle_", - ].join("\n"), - ); - } context.logger.info("Plan approved", {}); - await tracker.setPhaseLabel(repo, issueNumber, "approved"); - const scApprove = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - if (scApprove && plan) { - await scApprove.postLifecycleEntry({ + if (sc && plan) { + await sc.postLifecycleEntry({ step: "plan_approved", targetStatus: "in_progress", summary: - `Plan approved (v${plan.version}) — implementation starting`, + `Plan approved (v${plan.version}) \u2014 implementation starting`, emoji: "\u{2705}", payload: { version: plan.version, @@ -1289,7 +1031,7 @@ export const model = { }, isVerbose: true, }); - await scApprove.transitionStatus("in_progress"); + await sc.transitionStatus("in_progress"); } return { dataHandles: handles }; @@ -1297,74 +1039,10 @@ export const model = { }, implement: { - description: "Signal that implementation has started and track the PR", - arguments: z.object({ - prNumber: z.number().optional().describe( - "PR number if already created", - ), - }), - execute: async ( - args: { prNumber?: number }, - context: { - globalArgs: GlobalArgs; - logger: { - info: (msg: string, props: Record) => void; - warning: (msg: string, props: Record) => void; - }; - writeResource: ( - specName: string, - instanceName: string, - data: Record, - ) => Promise<{ name: string }>; - }, - ) => { - const { repo, issueNumber } = context.globalArgs; - - const stateHandle = await context.writeResource("state", "state-main", { - phase: "implementing", - issueNumber, - repo, - prNumber: args.prNumber, - updatedAt: new Date().toISOString(), - }); - - const prText = args.prNumber ? ` \u2014 PR #${args.prNumber}` : ""; - context.logger.info("Implementation started{prText}", { prText }); - - await tracker.postComment( - repo, - issueNumber, - `\u{1F680} **Implementation started**${prText}`, - ); - const scImpl = await ensureSwampClub( - context.globalArgs, - context.logger, - ); - if (scImpl) { - await scImpl.postLifecycleEntry({ - step: "implementation_started", - targetStatus: "in_progress", - summary: `Implementation started${prText}`, - emoji: "\u{1F680}", - payload: { - prNumber: args.prNumber ?? null, - }, - isVerbose: false, - }); - } - await tracker.setPhaseLabel(repo, issueNumber, "implementing"); - - return { dataHandles: [stateHandle] }; - }, - }, - - record_pr: { - description: "Record the PR number after implementation has started", - arguments: z.object({ - prNumber: z.number().describe("Pull request number"), - }), + description: "Signal that implementation has started", + arguments: z.object({}), execute: async ( - args: { prNumber: number }, + _args: Record, context: { globalArgs: GlobalArgs; logger: { @@ -1378,245 +1056,30 @@ export const model = { ) => Promise<{ name: string }>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const stateHandle = await context.writeResource("state", "state-main", { phase: "implementing", issueNumber, - repo, - prNumber: args.prNumber, updatedAt: new Date().toISOString(), }); - context.logger.info("Recorded PR #{prNumber}", { - prNumber: args.prNumber, - }); - - await tracker.postComment( - repo, - issueNumber, - `\u{1F517} **PR linked:** #${args.prNumber}`, - ); - - const sc = await ensureSwampClub(context.globalArgs, context.logger); - if (sc) { - await sc.postLifecycleEntry({ - step: "pr_linked", - targetStatus: "in_progress", - summary: `PR #${args.prNumber} linked to implementation`, - emoji: "\u{1F517}", - payload: { prNumber: args.prNumber }, - isVerbose: false, - }); - } - - return { dataHandles: [stateHandle] }; - }, - }, - - ci_status: { - description: "Fetch CI results and review comments for the PR", - arguments: z.object({}), - execute: async ( - _args: Record, - context: { - globalArgs: GlobalArgs; - logger: { - info: (msg: string, props: Record) => void; - warning: (msg: string, props: Record) => void; - }; - writeResource: ( - specName: string, - instanceName: string, - data: Record, - ) => Promise<{ name: string }>; - readResource: ( - instanceName: string, - version?: number, - ) => Promise | null>; - }, - ) => { - const { repo, issueNumber } = context.globalArgs; - const handles = []; - - const state = await context.readResource!("state-main") as - | StateData - | null; - if (!state?.prNumber) { - throw new Error( - "No PR number recorded. Run 'record_pr' with --input prNumber= first.", - ); - } - const prNumber = state.prNumber; - - const checkRuns = await tracker.fetchPrChecks(repo, prNumber); - const reviews = await tracker.fetchPrReviews(repo, prNumber); - - handles.push( - await context.writeResource("ciResults", "ciResults-main", { - prNumber, - checkRuns, - reviews, - fetchedAt: new Date().toISOString(), - }), - ); - - handles.push( - await context.writeResource("state", "state-main", { - phase: "ci_review", - issueNumber, - repo, - prNumber, - updatedAt: new Date().toISOString(), - }), - ); - - const passed = checkRuns.filter((c) => c.status === "passed").length; - const failed = checkRuns.filter((c) => c.status === "failed").length; - context.logger.info("CI results: {passed} passed, {failed} failed", { - passed, - failed, - }); - - await tracker.postComment( - repo, - issueNumber, - `\u{1F52C} **CI results**: ${passed} passed, ${failed} failed`, - ); - const scCi = await ensureSwampClub( - context.globalArgs, - context.logger, - ); - await scCi?.postLifecycleEntry({ - step: "ci_results", - targetStatus: "in_progress", - summary: `CI results: ${passed} passed, ${failed} failed`, - emoji: "\u{1F52C}", - payload: { - passed, - failed, - checks: checkRuns, - reviews, - }, - isVerbose: true, - }); - await tracker.setPhaseLabel(repo, issueNumber, "ci_review"); - - return { dataHandles: handles }; - }, - }, + context.logger.info("Implementation started", {}); - fix: { - description: "Direct specific fixes based on CI/review feedback", - arguments: z.object({ - directive: z.string().describe( - 'What to fix, e.g. "fix the CRITICAL issues from adversarial review"', - ), - targetReview: z.string().optional().describe( - "Filter to a specific reviewer", - ), - targetSeverity: z.string().optional().describe( - "Filter to a specific severity level", - ), - }), - execute: async ( - args: { - directive: string; - targetReview?: string; - targetSeverity?: string; - }, - context: { - globalArgs: GlobalArgs; - logger: { - info: (msg: string, props: Record) => void; - warning: (msg: string, props: Record) => void; - }; - writeResource: ( - specName: string, - instanceName: string, - data: Record, - ) => Promise<{ name: string }>; - readResource: ( - instanceName: string, - version?: number, - ) => Promise | null>; - dataRepository: { - findAllForModel: ( - type: string, - modelId: string, - ) => Promise<{ name: string; version: number }[]>; - }; - modelType: string; - modelId: string; - }, - ) => { - const { repo, issueNumber } = context.globalArgs; - const handles = []; - - const state = await context.readResource!("state-main") as - | StateData - | null; - - const allData = await context.dataRepository.findAllForModel( - context.modelType, - context.modelId, - ); - const fixRound = - allData.filter((d) => d.name === "fixDirective-main").length + 1; - const ciEntries = allData.filter((d) => d.name === "ciResults-main"); - const latestCiVersion = ciEntries.length > 0 - ? Math.max(...ciEntries.map((e) => e.version)) - : 0; - - handles.push( - await context.writeResource("fixDirective", "fixDirective-main", { - round: fixRound, - directive: args.directive, - targetReview: args.targetReview, - targetSeverity: args.targetSeverity, - ciResultsVersion: latestCiVersion, - submittedAt: new Date().toISOString(), - }), - ); - - handles.push( - await context.writeResource("state", "state-main", { - phase: "implementing", - issueNumber, - repo, - prNumber: state?.prNumber, - updatedAt: new Date().toISOString(), - }), - ); - - context.logger.info("Fix directive recorded: {directive}", { - directive: args.directive, - }); - - await tracker.postComment( - repo, - issueNumber, - `\u{1F527} **Fixing**: ${args.directive}`, - ); - const scFix = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - await scFix?.postLifecycleEntry({ - step: "fixing", + await sc?.postLifecycleEntry({ + step: "implementation_started", targetStatus: "in_progress", - summary: `Fixing: ${args.directive}`, - emoji: "\u{1F527}", - payload: { - directive: args.directive, - targetReview: args.targetReview, - targetSeverity: args.targetSeverity, - }, + summary: "Implementation started", + emoji: "\u{1F680}", + payload: {}, isVerbose: false, }); - await tracker.setPhaseLabel(repo, issueNumber, "implementing"); - return { dataHandles: handles }; + return { dataHandles: [stateHandle] }; }, }, @@ -1636,49 +1099,33 @@ export const model = { instanceName: string, data: Record, ) => Promise<{ name: string }>; - readResource: ( - instanceName: string, - version?: number, - ) => Promise | null>; }, ) => { - const { repo, issueNumber } = context.globalArgs; - - const state = await context.readResource!("state-main") as - | StateData - | null; + const { issueNumber } = context.globalArgs; const stateHandle = await context.writeResource("state", "state-main", { phase: "done", issueNumber, - repo, - prNumber: state?.prNumber, updatedAt: new Date().toISOString(), }); context.logger.info("Issue lifecycle complete", {}); - await tracker.postComment( - repo, - issueNumber, - `\u{2705} **Complete** \u2014 all checks passed`, - ); - const scDone = await ensureSwampClub( + const sc = await createSwampClubClient( context.globalArgs, context.logger, ); - if (scDone) { - await scDone.postLifecycleEntry({ + if (sc) { + await sc.postLifecycleEntry({ step: "complete", targetStatus: "shipped", - summary: "Complete \u2014 all checks passed", + summary: "Complete", emoji: "\u{2705}", - payload: { summary: "all checks passed" }, + payload: {}, isVerbose: false, }); - await scDone.transitionStatus("shipped"); + await sc.transitionStatus("shipped"); } - await tracker.setPhaseLabel(repo, issueNumber, "done"); return { dataHandles: [stateHandle] }; }, diff --git a/issue-lifecycle/manifest.yaml b/issue-lifecycle/manifest.yaml index 06dd63f6..5bab9ae6 100644 --- a/issue-lifecycle/manifest.yaml +++ b/issue-lifecycle/manifest.yaml @@ -1,11 +1,15 @@ manifestVersion: 1 name: "@swamp/issue-lifecycle" -version: "2026.04.07.1" +version: "2026.04.08.1" description: | - Interactive GitHub issue triage and implementation planning. Moves the - triage/plan/iterate/approve/implement loop out of GitHub Actions and into a - local conversation with Claude, while keeping the GitHub issue thread up to - date as a live status dashboard. + Interactive triage and implementation planning for swamp-club lab issues. + Drives the triage/plan/iterate/approve/implement loop as a local + conversation with Claude, while posting structured lifecycle entries and + status transitions back to the swamp-club issue on every step. + + The model operates directly on swamp-club lab issue numbers — the issue + must already exist in swamp-club before you start. There is no GitHub + integration. ## State Machine @@ -16,43 +20,38 @@ description: | plan_generated ──[iterate]──> plan_generated (feedback loop) plan_generated ──[approve]──> approved approved ──[implement]──> implementing - implementing ──[ci_status]──> ci_review - ci_review ──[fix]──> implementing (fix loop) - ci_review ──[complete]──> done + implementing ──[complete]──> done ``` Pre-flight checks enforce valid transitions: you cannot approve without a - plan, cannot implement without approval, and cannot approve while critical or - high adversarial findings remain unresolved. + plan, cannot implement without approval, and cannot approve while critical + or high adversarial findings remain unresolved. ## Methods - - `start` — fetch issue context from GitHub - - `triage` — classify as bug/feature/unclear + - `start` — fetch issue context from swamp-club (fails if the issue does + not exist) + - `triage` — classify as bug/feature/security and PATCH the swamp-club + issue type - `plan` — generate an implementation plan - `review` — display the current plan (read-only) - `iterate` — revise the plan with feedback (versioned) - `adversarial_review` — record adversarial review findings - `resolve_findings` — mark adversarial findings as resolved - - `approve` — lock the plan and post it to the issue + - `approve` — lock the plan and transition to in_progress - `implement` — signal implementation has started - - `record_pr` — record the PR number for CI tracking - - `ci_status` — fetch CI check results and review comments - - `fix` — direct specific fixes from review feedback - `complete` — mark the lifecycle done ## Data All resources are versioned and immutable: `state`, `context`, - `classification`, `plan`, `feedback`, `adversarialReview`, `ciResults`, - `fixDirective`. + `classification`, `plan`, `feedback`, `adversarialReview`. ## Prerequisites - - `gh` CLI installed and authenticated (`gh auth login`) - swamp initialized in the repository (`swamp init`) - - (Optional) `SWAMP_API_KEY` env var to mirror lifecycle entries to - [swamp.club](https://swamp.club) + - `SWAMP_API_KEY` env var (or `swamp auth login`) for swamp-club access + - The target lab issue must already exist in swamp-club repository: "https://github.com/systeminit/swamp-extensions"