From 6bf9199c33f2f80d4526f87f055d228fbaf0446b Mon Sep 17 00:00:00 2001 From: stack72 Date: Wed, 8 Apr 2026 01:14:29 +0100 Subject: [PATCH 1/3] refactor: drop GitHub integration from issue-lifecycle, use swamp-club directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the changes in systeminit/swamp#1139 — the @swamp/issue-lifecycle extension and skill now operate directly on swamp-club lab issues. Issues must already exist in swamp-club; the model fetches them by sequential lab number via GET /api/v1/lab/issues/{number} and PATCHes the type during triage to reflect the classification back. - Delete extensions/models/_lib/issue_tracker.ts and every gh CLI call. - Swap GlobalArgs: drop repo, issueNumber now refers to a swamp-club lab issue. Version bumped to 2026.04.08.1 with an upgrade that strips repo. - Rework SwampClubClient: operate on the lab number directly, add fetchIssue() and updateType(). Drop the /ensure round-trip. - Reconcile classification types to swamp-club's bug/feature/security. Regressions become type=bug with an isRegression flag on the classification record. unclear is gone — use confidence=low + clarifyingQuestions instead. - Remove ci_status, record_pr, fix methods and ciResults/fixDirective resources. The ci_review phase is gone; implementing transitions straight to done. - Update schemas_test.ts state machine tests to the new topology: happy path ends implementing→done, new completion gate test, old fix loop and ci_review tests removed. - Rewrite skill SKILL.md, triage.md, implementation.md, extension README.md, and manifest.yaml description/method list for the swamp-club-first workflow. Depends on swamp-club PATCH-type support in systeminit/swamp-club#374 (merged). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../.claude/skills/issue-lifecycle/SKILL.md | 56 +- .../references/implementation.md | 100 +-- .../issue-lifecycle/references/triage.md | 39 +- issue-lifecycle/README.md | 230 +++--- .../extensions/models/_lib/issue_tracker.ts | 398 --------- .../extensions/models/_lib/schemas.ts | 72 +- .../extensions/models/_lib/schemas_test.ts | 18 +- .../extensions/models/_lib/swamp_club.ts | 145 ++-- .../extensions/models/issue_lifecycle.ts | 772 +++--------------- issue-lifecycle/manifest.yaml | 41 +- 10 files changed, 402 insertions(+), 1469 deletions(-) delete mode 100644 issue-lifecycle/extensions/models/_lib/issue_tracker.ts 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..2e7681a2 100644 --- a/issue-lifecycle/extensions/models/_lib/swamp_club.ts +++ b/issue-lifecycle/extensions/models/_lib/swamp_club.ts @@ -11,14 +11,12 @@ // 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 . - // --------------------------------------------------------------------------- // Swamp Club Lifecycle API Client // --------------------------------------------------------------------------- import { join } from "@std/path"; +import type { IssueType } from "./schemas.ts"; export interface LifecycleEntryParams { step: string; @@ -30,25 +28,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 +58,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 +162,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 +238,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 +252,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 +267,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 +283,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 +299,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..16048686 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,39 +31,17 @@ 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. - */ +/** Get or create the swamp-club client (lazily, from globalArgs). */ async function getSwampClub( globalArgs: GlobalArgs, logger?: { @@ -79,29 +56,6 @@ async function getSwampClub( } 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 +83,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 +100,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 +121,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 +150,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 +336,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 +357,31 @@ 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 getSwampClub(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 +391,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 +419,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 +448,7 @@ export const model = { ) => Promise<{ name: string }>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; handles.push( @@ -514,6 +459,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 +470,40 @@ 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( - context.globalArgs, - context.logger, - ); - if (scClassify) { - await scClassify.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + 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 +545,7 @@ export const model = { ) => Promise<{ name: string }>; }, ) => { - const { repo, issueNumber } = context.globalArgs; + const { issueNumber } = context.globalArgs; const handles = []; handles.push( @@ -657,7 +565,6 @@ export const model = { await context.writeResource("state", "state-main", { phase: "plan_generated", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); @@ -666,24 +573,8 @@ 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( - context.globalArgs, - context.logger, - ); - await scPlan?.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + await sc?.postLifecycleEntry({ step: "plan_generated", targetStatus: "triaged", summary: `Implementation plan generated (v1) \u2014 ${args.summary}`, @@ -698,7 +589,6 @@ export const model = { }, isVerbose: true, }); - await tracker.setPhaseLabel(repo, issueNumber, "plan_generated"); return { dataHandles: handles }; }, @@ -801,7 +691,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 +750,6 @@ export const model = { await context.writeResource("state", "state-main", { phase: "plan_generated", issueNumber, - repo, updatedAt: new Date().toISOString(), }), ); @@ -870,24 +759,8 @@ 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( - context.globalArgs, - context.logger, - ); - await scIterate?.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + await sc?.postLifecycleEntry({ step: "plan_revised", targetStatus: "triaged", summary: @@ -904,7 +777,6 @@ export const model = { }, isVerbose: true, }); - await tracker.setPhaseLabel(repo, issueNumber, "plan_generated"); return { dataHandles: handles }; }, @@ -944,7 +816,6 @@ export const model = { ) => Promise | null>; }, ) => { - const { repo, issueNumber } = context.globalArgs; const handles = []; const plan = await context.readResource!("plan-main") as @@ -980,44 +851,8 @@ 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( - context.globalArgs, - context.logger, - ); - await scReview?.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + await sc?.postLifecycleEntry({ step: "adversarial_review", targetStatus: "triaged", summary: @@ -1071,7 +906,6 @@ export const model = { ) => Promise | null>; }, ) => { - const { repo, issueNumber } = context.globalArgs; const handles = []; const current = await context.readResource!( @@ -1119,34 +953,8 @@ 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( - context.globalArgs, - context.logger, - ); - await scResolve?.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + await sc?.postLifecycleEntry({ step: "findings_resolved", targetStatus: "triaged", summary: @@ -1165,7 +973,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 +994,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 +1008,16 @@ 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( - context.globalArgs, - context.logger, - ); - if (scApprove && plan) { - await scApprove.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + 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 +1026,7 @@ export const model = { }, isVerbose: true, }); - await scApprove.transitionStatus("in_progress"); + await sc.transitionStatus("in_progress"); } return { dataHandles: handles }; @@ -1297,74 +1034,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 +1051,27 @@ 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}`, - ); + context.logger.info("Implementation started", {}); - 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 }; - }, - }, - - 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( - context.globalArgs, - context.logger, - ); - await scFix?.postLifecycleEntry({ - step: "fixing", + const sc = await getSwampClub(context.globalArgs, context.logger); + 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 +1091,30 @@ 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( - context.globalArgs, - context.logger, - ); - if (scDone) { - await scDone.postLifecycleEntry({ + const sc = await getSwampClub(context.globalArgs, context.logger); + 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" From 1d491228cd3b78c7ceac2d8d0e1dea637a278792 Mon Sep 17 00:00:00 2001 From: stack72 Date: Wed, 8 Apr 2026 01:22:30 +0100 Subject: [PATCH 2/3] fix: remove module-level SwampClubClient cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _swampClub module-level singleton was keyed on nothing — once created for issue #N, every subsequent getSwampClub() call returned the same client regardless of the new issueNumber in globalArgs. Since user models are loaded via dynamic import() in the same process, the module stays cached across method calls, so running start for issue #10 and then issue #20 in the same session silently sent #20's lifecycle entries, type updates, and status transitions to #10. Drop the cache entirely and call createSwampClubClient directly at each use. The reachability check is a single 5s-timeout HTTP GET and runs once per method invocation — negligible next to the lifecycle POST already happening on the same code path. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/models/issue_lifecycle.ts | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/issue-lifecycle/extensions/models/issue_lifecycle.ts b/issue-lifecycle/extensions/models/issue_lifecycle.ts index 16048686..181c35e6 100644 --- a/issue-lifecycle/extensions/models/issue_lifecycle.ts +++ b/issue-lifecycle/extensions/models/issue_lifecycle.ts @@ -32,7 +32,6 @@ import { TRANSITIONS, } from "./_lib/schemas.ts"; import { createSwampClubClient } from "./_lib/swamp_club.ts"; -import type { SwampClubClient } from "./_lib/swamp_club.ts"; /** Global args type for the issue-lifecycle model. */ type GlobalArgs = { @@ -41,21 +40,6 @@ type GlobalArgs = { swampClubApiKey?: string; }; -/** Get or create the swamp-club client (lazily, from globalArgs). */ -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; - /** Read the current state from data repository (for checks). */ async function readState( dataRepository: { @@ -360,7 +344,10 @@ export const model = { const { issueNumber } = context.globalArgs; const handles = []; - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); if (!sc) { throw new Error( "swamp-club is not reachable or credentials are missing. " + @@ -485,7 +472,10 @@ export const model = { }, ); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); if (sc) { await sc.updateType(args.type); await sc.postLifecycleEntry({ @@ -573,7 +563,10 @@ export const model = { summary: args.summary, }); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); await sc?.postLifecycleEntry({ step: "plan_generated", targetStatus: "triaged", @@ -759,7 +752,10 @@ export const model = { { version: newVersion, round: feedbackRound }, ); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); await sc?.postLifecycleEntry({ step: "plan_revised", targetStatus: "triaged", @@ -851,7 +847,10 @@ export const model = { { planVersion, critical, high, medium, low }, ); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); await sc?.postLifecycleEntry({ step: "adversarial_review", targetStatus: "triaged", @@ -953,7 +952,10 @@ export const model = { { resolved, remaining }, ); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); await sc?.postLifecycleEntry({ step: "findings_resolved", targetStatus: "triaged", @@ -1011,7 +1013,10 @@ export const model = { context.logger.info("Plan approved", {}); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); if (sc && plan) { await sc.postLifecycleEntry({ step: "plan_approved", @@ -1061,7 +1066,10 @@ export const model = { context.logger.info("Implementation started", {}); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); await sc?.postLifecycleEntry({ step: "implementation_started", targetStatus: "in_progress", @@ -1103,7 +1111,10 @@ export const model = { context.logger.info("Issue lifecycle complete", {}); - const sc = await getSwampClub(context.globalArgs, context.logger); + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); if (sc) { await sc.postLifecycleEntry({ step: "complete", From 4c342196942473a51fd70cffa350d5c52b666a93 Mon Sep 17 00:00:00 2001 From: stack72 Date: Wed, 8 Apr 2026 01:33:19 +0100 Subject: [PATCH 3/3] fix: restore truncated AGPLv3 header in swamp_club.ts The swamp-club refactor accidentally dropped the final two lines of the required copyright header ("You should have received a copy..." and "with Swamp. If not, see..."). Per CLAUDE.md, every .ts file must carry the full AGPLv3 header from FILE-LICENSE-TEMPLATE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- issue-lifecycle/extensions/models/_lib/swamp_club.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/issue-lifecycle/extensions/models/_lib/swamp_club.ts b/issue-lifecycle/extensions/models/_lib/swamp_club.ts index 2e7681a2..624e2470 100644 --- a/issue-lifecycle/extensions/models/_lib/swamp_club.ts +++ b/issue-lifecycle/extensions/models/_lib/swamp_club.ts @@ -11,6 +11,9 @@ // 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 . + // --------------------------------------------------------------------------- // Swamp Club Lifecycle API Client // ---------------------------------------------------------------------------