feat(issue-lifecycle): add pr_open phase and link_pr method#60
Conversation
Backport of systeminit/swamp#1143. Restores the PR-linkage capability dropped in #57 (drop GitHub integration) without re-introducing any git-host coupling. The refactor correctly removed the `gh` CLI dependency, but it also removed the concept of PR linkage — an audit-trail concept that isn't coupled to GitHub. This brings back the minimal piece. ## New domain concepts - `pr_open` phase — the PR is linked and we're awaiting CI/review/merge. No new swamp-club status; maps to `in_progress` like `implementing`. - `pullRequest-main` resource — single-instance value object holding the opaque PR URL and a `linkedAt` timestamp. Git-host agnostic. - `link_pr` method — idempotent (valid from both `implementing` and `pr_open`); each call overwrites the resource so re-pushes, URL corrections, or replacement PRs don't need a new phase. ## Backwards compatibility `complete` now accepts both `implementing` and `pr_open` as source phases. Existing records that didn't go through `link_pr` can still reach `done` via the legacy path. New work should flow `implementing → link_pr → pr_open → complete → done`. ## Version bump manifest.yaml: `2026.04.08.1 → 2026.04.08.2`, with the state machine diagram and methods list updated to reflect the new phase and method. Model upgrade entry added; no global-args migration needed. ## Skill doc hardening While updating `implementation.md` for the new `link_pr` step, also scoped all hardcoded tmp filename examples in `planning.md` and `adversarial-review.md`: - `/tmp/plan.yaml` → `/tmp/plan-issue-<N>.yaml` - `/tmp/findings.yaml` → `/tmp/findings-issue-<N>.yaml` - `/tmp/resolutions.yaml` → `/tmp/resolutions-issue-<N>.yaml` Generic filenames collide with stale content from previous lifecycle sessions and can silently leak unrelated data into the current run. ## Test coverage Existing `schemas_test.ts` updated to reflect the new state machine: - Happy-path walk now routes `implementing → link_pr → pr_open → complete`. - New `legacy path` test ensures `complete` still accepts `implementing`. - `completion gate` assertion expanded to `["implementing", "pr_open"]`. New tests added: - `Phase: pr_open sits between implementing and done` - `TRANSITIONS: link_pr is idempotent from implementing and pr_open` - `TRANSITIONS: link_pr is rejected from earlier lifecycle phases` - `PullRequestSchema` positive, empty-rejection, required-field tests New `issue_lifecycle_test.ts` with 7 method-level tests covering the `link_pr` happy path, state transition to `pr_open`, idempotency, zod schema rejection, and model registration smoke tests. Full verification gate (in the issue-lifecycle package): - `deno task check` — clean - `deno task lint` — clean - `deno task fmt:check` — clean - `deno task test` — **23 passed, 0 failed** (16 schema + 7 method) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Blocking Issues
None.
Suggestions
-
Minor: write ordering in
link_pr— The method writespullRequestbeforestate. If the state write fails after the pullRequest write succeeds, the resource and state are momentarily out of sync. This is the same pattern used elsewhere in the model and the framework presumably handles it, but it's worth being aware of. -
Minor:
linkedAtformat not validated —PullRequestSchemaaccepts any string forlinkedAt(no ISO-8601 enforcement). The model always generates it vianew Date().toISOString(), so this can't go wrong in practice, but az.string().datetime()would make the contract explicit.
Everything else looks solid:
- State machine:
pr_opensits correctly betweenimplementinganddone.link_pris allowed from bothimplementingandpr_open(enabling the idempotent re-link pattern).completeaccepts both source phases for backwards compatibility. All consistent with the TRANSITIONS map and tested. - Testing: Comprehensive — 6 new schema tests and 7 method tests. All tests using env var manipulation correctly call
restore()infinallyblocks. No live cloud services (credentials cleared, HOME redirected to a temp dir). No connection pools, so nosanitizeResourcesneeded. - Security: The PR URL logged via
logger.infois not a secret. No credential exposure. - CLAUDE.md compliance: No
anytypes, named exports only, nomodel/files touched, version bump applied consistently in bothissue_lifecycle.tsandmanifest.yaml. - Upgrade entry: Correct no-op upgrade (
upgradeAttributes: (old) => old) since there are no schema migrations needed for existing records. - Manifest: State machine diagram, methods list, and data section all updated to reflect the new phase and resource.
There was a problem hiding this comment.
Code Review
Blocking Issues
None.
Suggestions
-
Minor: write ordering in
link_pr— The method writespullRequestbeforestate. If the state write fails after the pullRequest write succeeds, the resource and state would be momentarily out of sync. Same pattern used elsewhere in the model so likely fine, but worth being aware of. -
Minor:
linkedAtformat not validated —PullRequestSchemaaccepts any string forlinkedAt(no ISO-8601 enforcement). The model always generates it vianew Date().toISOString()so this can't go wrong in practice, butz.string().datetime()would make the contract explicit.
Everything else looks solid:
- State machine:
pr_opensits correctly betweenimplementinganddone.link_praccepts bothimplementingandpr_openas source phases (enabling the idempotent re-link pattern).completeaccepts both for backwards compatibility. All consistent with TRANSITIONS and fully tested. - Testing: 6 new schema tests + 7 method tests. All tests using env var manipulation call
restore()infinallyblocks. No live cloud services (credentials cleared, HOME redirected to temp dir). No connection pools so nosanitizeResourcesneeded. - Security: The PR URL logged via
logger.infois not a credential. No secret exposure. - CLAUDE.md compliance: No
anytypes, named exports only, nomodel/files touched, version bump applied consistently in bothissue_lifecycle.tsandmanifest.yaml. - Upgrade entry: Correct no-op upgrade (
upgradeAttributes: (old) => old) — no schema migration needed for existing records. - Manifest: State machine diagram, methods list, and data section all updated correctly.
There was a problem hiding this comment.
Code Review
Blocking Issues
None.
Suggestions
-
Minor: write ordering in link_pr - The method writes pullRequest before state. If the state write fails after the pullRequest write succeeds, the resource and state would be momentarily out of sync. Same pattern used elsewhere in the model so likely fine, but worth noting.
-
Minor: linkedAt format not validated - PullRequestSchema accepts any string for linkedAt (no ISO-8601 enforcement). The model always generates it via new Date().toISOString() so this cannot go wrong in practice, but z.string().datetime() would make the contract explicit.
Everything else looks solid: state machine transitions are correct and fully tested, all env-mutating tests use finally+restore(), no live cloud services, no credential exposure, no any types, named exports only, no model/ files touched, version bump applied consistently in both issue_lifecycle.ts and manifest.yaml, upgrade entry is a correct no-op, and the manifest description is accurate.
There was a problem hiding this comment.
Code Review
Blocking Issues
None.
Suggestions
-
Minor: write ordering in link_pr - The method writes pullRequest before state. If the state write fails after the pullRequest write succeeds, the resource and state would be momentarily out of sync. Same pattern used elsewhere in the model so likely fine, but worth noting.
-
Minor: linkedAt format not validated - PullRequestSchema accepts any string for linkedAt (no ISO-8601 enforcement). The model always generates it via new Date().toISOString() so this cannot go wrong in practice, but z.string().datetime() would make the contract explicit.
Everything else looks solid: state machine transitions are correct and fully tested, all env-mutating tests use finally+restore(), no live cloud services, no credential exposure, no any types, named exports only, no model/ files touched, version bump applied consistently in both issue_lifecycle.ts and manifest.yaml, upgrade entry is a correct no-op, and the manifest description is accurate.
There was a problem hiding this comment.
Adversarial Review
Reviewed all non-model changed files: schemas.ts, schemas_test.ts, issue_lifecycle.ts, issue_lifecycle_test.ts, manifest.yaml, and the three skill reference docs.
Critical / High
None found.
Medium
issue_lifecycle.ts:1105-1110—link_prdescription says "Transitions the phase to pr_open if currently implementing" but always writesphase: "pr_open"unconditionally.
When called frompr_open(the idempotent re-link case), the description implies it might not transition, but it always does. Functionally harmless (writingpr_openwhen already inpr_openis a no-op state change with a timestamp refresh), but the description's "if currently implementing" qualifier could mislead a reader into thinking there's conditional logic. Theimplementation.mddoc correctly describes the behavior. Consider aligning the method description: "Transitions the phase to pr_open" (drop the conditional).
Low
-
schemas.ts:169-177—PullRequestSchema.linkedAtusesz.string()for an ISO-8601 timestamp rather thanz.string().datetime().
Every other timestamp in this file (updatedAt,fetchedAt,classifiedAt,generatedAt,submittedAt,reviewedAt) also uses plainz.string(), so this is consistent with the codebase. And the value is always set internally bynew Date().toISOString(), never by user input. Mentioning only because a schema-leveldatetime()constraint would catch future bugs if someone refactors the method to accept external timestamps. -
issue_lifecycle.ts:1134-1147— Sequential writes without transactional guarantee:pullRequest-maincould succeed whilestate-mainfails. This would leave the PR URL recorded but the phase stuck atimplementing. However, every other method in the model has the identical pattern (sequentialwriteResourcecalls without rollback), and a retry oflink_prfromimplementingwould self-heal. Not unique to this PR; mentioning for completeness. -
issue_lifecycle_test.ts:37-87—buildTestContextmutates globalDeno.env. Tests usetry/finallyto restore, which is correct. TheCLAUDE.mdtesting rules explicitly require "Restore all env vars in afinallyblock" — this is followed. No issue; just confirming the pattern holds.
Verdict
PASS — Clean, well-structured addition. The state machine extension is backwards compatible (complete still accepts implementing), the link_pr method follows established patterns for resource writes and swamp-club posting, test coverage is thorough (16 schema + 7 method tests), and the upgrade entry correctly maps old => old since no global args changed. The skill doc hardening (issue-scoped temp filenames) is a welcome defensive improvement.
Summary
Backport of systeminit/swamp#1143.
Restores the PR-linkage capability that was dropped in #57 (drop GitHub
integration) without re-introducing any git-host coupling. That refactor
correctly removed the
ghCLI dependency, but it also removed theconcept of PR linkage — an audit-trail concept that isn't coupled to
GitHub. This PR brings back the minimal piece: a way to attach a PR URL
to the lifecycle record so future sessions reading
swamp model get issue-<N>can see where the fix lives.Changes
New domain concepts
pr_openphase — the PR is linked and we're awaiting CI/review/merge.No new swamp-club status; maps to
in_progresslikeimplementing.pullRequest-mainresource — single-instance value object holdingthe opaque PR URL and a
linkedAttimestamp. Git-host agnostic.link_prmethod — idempotent (valid from bothimplementingandpr_open); each call overwrites the resource so re-pushes, URLcorrections, or replacement PRs don't need a new phase.
Backwards compatibility
completenow accepts bothimplementingandpr_openas valid sourcephases. Existing records that didn't go through
link_prcan stillreach
donevia the legacy path. New work should flowimplementing → link_pr → pr_open → complete → done.Version bump:
2026.04.08.1 → 2026.04.08.2in both the model andmanifest.yaml. The state machine diagram and methods list in themanifest have been updated to reflect the new phase and method. Upgrade
entry added; no global-args migration needed.
What this deliberately does NOT do
The agent/human observes CI passing and calls
complete. The modeldoesn't need to know what "passing" means.
fixmethod revival — post-merge follow-ups belong to new issues.ghCLI or any git-host coupling. URLsare opaque strings.
Bonus: skill doc hardening
While editing
implementation.mdfor the newlink_prstep, alsoscoped all hardcoded tmp filename examples in
planning.mdandadversarial-review.md:/tmp/plan.yaml→/tmp/plan-issue-<N>.yaml/tmp/findings.yaml→/tmp/findings-issue-<N>.yaml/tmp/resolutions.yaml→/tmp/resolutions-issue-<N>.yamlGeneric filenames collide with stale content from previous lifecycle
sessions and can silently leak unrelated data into the current run.
Test Plan
Existing `schemas_test.ts` updated:
New tests added to `schemas_test.ts` (6):
New `issue_lifecycle_test.ts` (7):
Full verification gate (in the `issue-lifecycle/` package):
🤖 Generated with Claude Code