SLOPSHOPPER

issue-forge

Takes one GitHub issue to a pull request test-first: red, green and refactor subagents, each phase checked by commands and a 95% diff-coverage gate; pushes…

newpaneguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issue-forge
│ ┃ Issue Forge ✕ › fix the failing auth test and add an audit log call │ ┃ I S S U E F O R G E │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ No run here. /issue-forge <issue number or ⏺ Update(src/auth.ts) │ ┃ URL> starts one, ⎿ Added 2 lines, removed 1 line │ ┃ /issue-forge demo shows every state. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ctrl+x tab: keys, again: next tab │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /issue-forge │ ⎿ issue-forge: opened. /issue-forge <issue number or URL> · status │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Issue Forge
I S S U E F O R G E ────────────────────────────────────────────────────── No run here. /issue-forge <issue number or URL> starts one, /issue-forge demo shows every state. ctrl+x tab: keys, again: next tab
README

Issue Forge

Hand it a GitHub issue number or URL and it comes back with a pull request, built test-first and checked by commands rather than by the model's word. Three subagents work in turn: red writes tests that fail, green makes them pass, refactor tidies. Between phases the mod checks the work: only test files changed in red, the tests failed and then passed, nobody edited the tests to make them pass, and at least 95% of the changed lines and branches are covered. Nothing is pushed until you say yes.

 I S S U E   F O R G E  bankielewicz/mod-builder#16 Mod Lab: Fix prompt a…
 ──────────────────────────────────────────────────────────────────────────
 ✓ red       3 test files, failing as intended · a1b2c3d             2m10s
 ● green     attempt 2/3 · gate G: diff branch 91.30% < 95           4m02s
 · refactor
 · publish
 ──────────────────────────────────────────────────────────────────────────
 coverage   changed lines 100.00% · branches 91.30% · total 99.36% / 83.69%
 branch     issue-16-mod-lab-fix-prompt-and-path-header-hardc → main
 also       #44 blocked at gateG · #52 refactor (another session)
 [ Hand off ]  [ Stop ]  [ Status ]

Several sessions can each run an issue in the same repository at once, each in its own worktree.

It fixes code; it doesn't build new plugins. An issue that asks for its plugin to be built with /mod-builder:build gets next steps that lead there, and nothing starts. A phase agent that tries to create a .claude-plugin/plugin.json outside the test folders is refused (a sample plugin under the profile's testGlobs is a test fixture, and is fine), and the run stops with the same kind of steps before the phase's work is committed. Such a stop is final: /issue-forge stop <N> ends it.

Try it

/issue-forge demo
  • /issue-forge <N or issue URL>: starts a run on issue N, or on the issue a pasted URL names (Which repository a run uses). If another run in the repository is active, or this session is driving one, it asks first (Parallel runs)
  • /issue-forge status [N]: where the run is (and its worktree), and the repository's other runs; with no run, whether .issue-forge.json validates
  • /issue-forge resume [N]: restarts a blocked or interrupted step
  • /issue-forge publish [N]: pushes the run's branch and opens the pull request
  • /issue-forge handoff [N]: ends this session's drive but keeps the step, so any session can resume it (with N, only when this session drives #N)
  • /issue-forge stop [N]: ends the run; its branch and commits are kept
  • /issue-forge demo [state]: every state of the pane, with nothing real running
  • /issue-forge: opens the pane, or closes it when it is shown. When it is open as a tab behind another pane, it comes to the front with the keyboard instead of closing; Esc returns the keyboard to the prompt

Without N, a command means the run the pane shows, even a finished one. If the pane shows none, it means the repository's only active run; with several, it lists them and asks you to name one. With no active run, status and publish mean the newest run started from this checkout, even a finished one.

Give the pane the keyboard with ctrl+x tab. Its keys are p publish, r resume, h hand off, s stop, t status, and in the demo n next and e end. A key acts at once, so the two that change a live run ask first: Stop ("Stop run #N? Its branch and commits are kept.") and Publish (the question that names the branch and base), whatever confirmPublish says. They ask only once the command would act, so a press it would refuse says why at once. A run another session drives is named in that one question (#N is driven by another session (for 2m05s). Stop run #N? …). Answer no, or dismiss it, and a toast says the run was left as it is. Typed as /issue-forge stop or /issue-forge publish, they are immediate. A start or a resume while the pane is behind another tab says so in its answer and leaves the keyboard alone.

Reaching the pane when other panes are open: ctrl+x tab shows the first tab and gives it the keyboard, each press after moves one tab right, and after the last tab the keyboard goes back to the prompt. Esc hands it back from any tab. A letter that is one of the pane's keys acts at once. To cycle without going through the first tab, bind pane:next and pane:previous (no default key) in ~/.claude/keybindings.json; it works while a pane holds the keyboard:

{"bindings":[{"context":"Pane","bindings":{"ctrl+x n":"pane:next","ctrl+x p":"pane:previous"}}]}

Set up a repository

Commit a .issue-forge.json at the repository root. Commands are argument vectors, and the coverage command must write LCOV:

{
  "testCommand": ["npx", "vitest", "run"],
  "coverageCommand": ["npx", "vitest", "run", "--coverage.enabled", "--coverage.provider=v8", "--coverage.reporter=lcov", "--coverage.reportsDirectory=/tmp/issue-forge/cov"],
  "lcovPath": "/tmp/issue-forge/cov/lcov.info",
  "sourceGlobs": ["src/**/*.ts"],
  "testGlobs": ["src/**/*.test.ts", "test/**"]
}

Every field, with Python and pilot examples, is in the design. This repository's own profile is .issue-forge.json at its root.

Where a run works

Two profile fields decide whether a run branches the checkout you start it from or gets a git worktree of its own. They are committed with the profile, so they hold for everyone, and they are read from origin/<base>'s copy before anything is switched or created:

FieldDefaultWhat it does
workspace"ask""here": branch the checkout you start from; if it's busy (uncommitted changes, not on the base, or another run working there), block. "ask": the same when it isn't busy; when it is, ask whether to use a new worktree (no answer blocks). "worktree": always add a new worktree from origin/<base> and never touch your checkout
worktreeDir"scratch/issue-forge"where new worktrees go, relative to the repository root. It must be gitignored

A worktree run lives in <worktreeDir>/<branch>/. /issue-forge status names it, with the git worktree remove <path> to run once the pull request merges. The mod never deletes a worktree's files or a branch. On resume it asks git worktree list about a kept worktree: one git has is reused, and one with nothing at its path is added again, after a git worktree remove that only clears git's entry for a folder deleted by hand. A folder at that path that git doesn't have as the worktree blocks the run, and nothing runs inside it, since git there would work on the checkout above. A relative lcovPath resolves against the run's folder, so runs in separate worktrees each write their own coverage file. Before it asks to publish, the run checks the result with git merge-tree against a freshly fetched origin/<base>, and the question names any files that would conflict.

This repository sets "workspace": "worktree": the installed plugins load from the main folder's branch, so a run must never switch it.

Which repository a run uses

A run works in the clone your session started in. Its issue and its pull request go to the repository gh repo view shows there, and every gh call of the run names it with --repo, so a stray GH_REPO or a second remote can't send the run elsewhere. The masthead and /issue-forge status name it (o/r#16; host/o/r#16 on GitHub Enterprise). A run started with a number is pinned to it as soon as origin agrees, so a later block keeps it across a resume; a run that origin blocks is pinned once the remotes are fixed and it resumes.

  • origin must be that repository, for fetching and for every push URL (git push sends to all of them), as the folder that fetches and pushes reads it (a run's worktree can have its own config). It is read from an https://, http:// or ssh remote (ssh://… or [user@]host:owner/repo); any other form (git://, file://, a path) blocks the run. An https or http remote must name the repository's host. An ssh remote may name an alias of it from ~/.ssh/config, so the same owner and repository is enough there (an ssh remote on another real host with the same owner and repository passes too: #71). A renamed or transferred repository passes: when the name differs on the same host, gh is asked what it calls it; gh is never asked about another host. A URL is never printed or passed on, since it can hold a token. GitHub Enterprise Server on a non-default port isn't supported (#71).
  • A fork clone, where origin is your fork and another remote (usually upstream) is the project gh names, runs against the project. The run reads its issue there, fetches and branches from that remote's base (upstream/main), pushes the branch to your fork, and opens the pull request on the project with your fork's owner as the head (gh pr create --repo <project> --head <you>:<branch>). Before anything is fetched, preflight checks that exactly one remote besides origin names the project (two block, naming both), that origin fetches from and pushes to one fork, that gh repo view calls it a fork of the project, and that its owner is a user: gh pr create can't name an organization's fork as the head (cli/cli#10093), so that blocks. An ssh host nickname from ~/.ssh/config (git@gh-personal:me/r.git) counts as the host ssh -G says it reaches, which never connects; a mirror with the project's name on another host isn't the project. The run stores the remote and the fork, checks the remote still names the project before each fetch from it, and publish blocks if origin stops pushing to that fork. Anything else that gh and origin disagree on still blocks the run before it fetches or switches. With the fork as the only remote, or after gh repo set-default <fork>, gh names the fork itself, so the run stays inside the fork as before. The whole flow is checked by the tests; no live pull request from a fork has been opened (the live probe is #102).
  • A pasted issue URL must name that same repository, or the command answers x/y#16 is in another repository: this checkout's GitHub repository is o/r. … and starts nothing. Only https://<host>/<owner>/<repo>/issues/<N> is read.
  • A pull request's number is refused (#24 is a pull request, not an issue), since gh issue view answers for those too.
  • A run started by 0.3 or earlier has no repository recorded: it resumes and publishes as before, with no --repo and no check of origin. A run started by 0.4 has no fork recorded: it fetches from and pushes to origin and names the branch alone as the head.

Parallel runs

Runs belong to the repository, not to the folder a session started in. Every worktree of a repository shares git's common folder, and that keys the runs. So every session in the repository sees every run: /issue-forge status lists them, and the pane's also line names the others. A session drives one run at a time. To work on two issues at once, open a second Claude Code session in the repository. With "workspace": "worktree", each run gets a worktree of its own.

/issue-forge <N> (or an issue URL) asks one question when it would meet another run:

SituationChoices (the first is the default)
Another session drives #44Start #N in a new worktree · Take over #44 here · Cancel
#44 is blocked, interrupted or ready, and no session drives itStart #N in a new worktree · Resume #44 here · Stop #44, then start #N · Cancel
This session drives #44Keep #44 (and start #N from another session) · Hand #44 off and start #N here · Cancel

Esc, or text typed under Other, cancels. Only where nobody can answer (claude -p) does a start go ahead with what you typed; in a session driving a run, it then explains how to start #N elsewhere and starts nothing. If #N has an active run already, you get its status instead.

A run records which session drives it. In any other session, Resume, Publish and Stop on that run ask If that session has ended, take it over? before they act (a press of the pane's Stop or Publish asks its own question instead, which names the other session). The mod can't tell a crashed session from a live one, so it asks rather than assumes. The driving session checks every 5 seconds and stops its drive when another session stopped or took over its run; before a commit, a stash, the push and the pull request it checks at once. Resume writes only if the run is still as it read it. A session that ends lets go of its run, but /clear and /resume don't, because the process and the drive go on.

Hand off (/issue-forge handoff or h) ends this session's drive at once, as Stop does, but keeps the step: the run reads interrupted everywhere, and /issue-forge resume <N> continues it in any session. A stopped run can't be resumed.

Parallel runs share git's refs. A git fetch that finds another process holding a lock is retried twice; a git stash or git commit waits a second and retries, five attempts in all (see "Limits worth knowing"). One coverage file can't serve two runs at once, so an absolute lcovPath (wherever it points) blocks a run's baseline and gates while another run is being driven: make it relative (and gitignored), as this repository's coverage/issue-forge.lcov is. When a run starts, finished runs beyond the repository's 20 newest are deleted from the store; their agents stay on the guard's list.

Runs stored by 0.2 or earlier, keyed by the checkout, move to the repository's keys when a session first starts in that checkout or in another worktree git lists.

A run needs an interactive session (claude -p exits before the agents finish), a checkout that isn't busy or a worktree run (above), gh signed in, <base> on origin, and origin is the repository gh repo view shows. If an issue names another base (a line Base branch: <name>), the run asks before using it, because the tests would run from that branch. A name git could read as an option or a forced refspec (starting with - or +, or holding :) is ignored. Globs use *, ** and ? only; braces, brackets and ! are refused.

Limits worth knowing:

  • With agent teams on (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1), Claude Code runs the mod's named agents as teammates. Inside tmux they default to pane teammates, which can't run them: the red phase blocks with a pane-based teammate cannot run it, and --settings '{"teammateMode":"in-process"}' lets it start. An in-process teammate hands back through session.receive, and the mod consumes the run's own idle notices, so the end of each phase leaves the main model idle (#27). Anything else a phase agent sends, such as a message it writes, still reaches the main model. With agent teams off, neither applies.
  • A new worktree has only what is committed: no node_modules or other untracked setup. Profile commands that need them fail in a worktree run (there's no setup command yet).
  • Agents ignore the folder they're spawned in, so in a worktree run the mod moves the live agent's Bash, Grep and Glob into the worktree, makes its relative Edit, Write and Read paths absolute there, and refuses an edit outside it. A path it can't place (\x, \\server\share, ~, D:x) is refused in every run. That catches honest mistakes; it isn't a sandbox. The check reads paths as text, so a symlink inside the worktree can still lead an edit outside it. A Bash command that cds elsewhere or names an absolute path still reaches it, and a here run's agents can't Write outside the repository either (/tmp included).
  • A red type that isn't the mod's own (redAgentType) can't run in a worktree: duet's Codex runs in the session's folder whatever the spawn says, so a worktree run with one blocks before it starts.
  • A run started in a linked worktree puts its new worktree under that checkout's worktreeDir, so the agents' writes stay under the session's folder.
  • The merge check at ready needs git 2.38 or later (merge-tree --write-tree); with an older git it reads merge check unavailable and the run goes on.
  • Resuming a kept worktree whose folder was deleted needs git 2.31 or later, which marks it prunable in git worktree list. An older git lists it as there, so the resume fails instead of adding it again.
  • Runs were checked with Claude Code's auto permission mode. In other modes a phase agent's Edit or Bash may wait on a permission prompt nobody sees; the pane then shows agent working until you /issue-forge stop. There's no agent timeout yet.
  • G6 checks that a behaviour test file (behaviourTestGlobs) is in the red commit, not what it tests.
  • Command lookup uses sh -c 'command -v …', so sh must be on PATH (Git Bash on Windows).
  • The profile that runs is the one on origin/<base>, read after the switch; the checkout you start from only names the base.
  • The run works in the session's own checkout (or its worktree). A git command in that checkout from any session while the run is between steps, a plain git status included, can take .git/index.lock just as the run writes the index; another run of the repository can hold a branch's lock or refs/stash's. The run's add, commit, stash, switch and merge retry for 4 s (five attempts, a second apart); if the lock is still there the run blocks with another git process is using <path> (5 attempts over 4 s), and /issue-forge resume continues once it is gone. A Stop during the wait ends it at once. The run's own git calls take no optional locks (GIT_OPTIONAL_LOCKS=0), so its git status doesn't hold the lock against yours. A lock in the main checkout doesn't affect a linked worktree, which has its own index. When other sessions share the checkout, start issue-forge in a dedicated worktree ("workspace": "worktree").
  • If two sessions start the same issue at the same moment, the second write wins, and the other session's run stops at its next step.
  • A crashed session leaves its run marked as driven until another session answers Take over.
  • After /clear and then a reload, your own interrupted run reads as if another session drove it.
  • Profile commands must leave the tree as they found it: a gate that sees files changed after staging (a formatter, a snapshot update) blocks rather than commit bytes it didn't test. The run's branch is cut from origin/<base>, so

the profile and anything its commands use must already be there.

Install

From the GitHub marketplace:

/plugin install issue-forge --marketplace bankielewicz/mod-builder

From a local clone (claude plugin marketplace add <folder> once):

/plugin install issue-forge@claudemod

While developing, from its folder:

claude --plugin-dir <path-to>/issue-forge

Changes load with /reload-plugins or a new session.

Options

OptionDefaultWhat it does
redModelopusmodel for the red agent
greenModelsonnetmodel for the green agent
refactorModelopusmodel for the refactor agent
redAgentTypeissue-forge:redanother type runs red as that agent, unnamed, so duet sends it to Codex if the type is in duet's codexAgents. Codex needs codexSandbox: workspace-write, which also makes duet wait for the run; in duet's read-only background mode the phase would end at once with nothing written
confirmPublishtrueask before pushing and opening the pull request; off publishes as soon as the run is ready

The mod's own agents are spawned with a name, so duet leaves them on Claude even with codexAgents: "*".

What it reaches

Mods are not sandboxed. They run with your access and skip Claude's permission prompts, so this table lists everything this one can touch. It is generated from claude plugin validate.

ReachDetail
Hookssession.start, session.end, agent.offer{agent=issue-forge:red}, agent.offer{agent=issue-forge:green}, agent.offer{agent=issue-forge:refactor}, turn.complete, tool.call, prompt.submit, session.receive, command.run{command=issue-forge}, ui.render{component=Pane, requestId=issue-forge}
Processes$.process.run and $.process.spawn, in the run's folder (the repository root, or its worktree): git, with GIT_OPTIONAL_LOCKS=0 (rev-parse, including --show-toplevel and --git-common-dir for each command and, once per folder before its first index or ref write, --git-path index.lock, and --git-path refs/stash.lock before a stash push, status, check-ignore, fetch, show <remote>/<base>:.issue-forge.json, for-each-ref, ls-remote (origin, and the project's remote in a fork clone), switch -c or worktree add, worktree list, worktree remove (only to clear git's entry for a kept worktree with nothing at its path), merge-base, merge --ff-only, add -A, diff, ls-tree (the plugin manifests at the merge base and the base branch's tip, before each phase agent starts and at each gate), commit, stash push/list/apply, merge-tree, remote (the list of remotes), remote get-url, push), gh (auth status, repo view, api --hostname <host> users/<owner> for .type in a fork clone, issue view: once at start, before anything is stored, and again in preflight, pr create), ssh -G <host> in a fork clone for an ssh remote on another host than the project's (reads ~/.ssh/config, never connects), sh -c 'command -v …', and the profile's testCommand, coverageCommand and checks. Publish pushes exactly the commit gate F checked (<sha>:refs/heads/<branch>)
Filesreads .issue-forge.json and the LCOV file; stats a .claude-plugin/plugin.json the live agent edits (to tell an edit from a new plugin); and looks for git's index.lock (and refs/stash.lock on a stash push) when a git call that writes the index or a ref fails; a worktree run creates <worktreeDir>/<branch>/ and leaves it; writes ~/.claude/issue-forge-data/<run>/pr.md; $.store keys issue-forge:repo-run:<git common folder>#<issue> and issue-forge:repo-agents:… (gets, sets, deletes and lists keys; moves 0.2 keys at session start; deletes finished runs beyond 20 per repository); reads $.session.id(); env reads HOME, USERPROFILE
Networkthrough git and gh only: fetch and, after your yes, push and pull request creation
Agentsissue-forge:red, :green, :refactor, each with Read, Grep, Glob, Edit, Write, Bash in the run's folder (the repository root, or its worktree), under your permission mode and not sandboxed (the agent API has no sandbox). Not listed by claude plugin validate, which doesn't print agent tool grants
Model callsthree subagents per run ($.agent.spawn), plus one per retry. attempts in the profile caps each phase's spawns, the first included (red 2, green 3, refactor 1 by default: at most 3 retries); a resume, or red's return for a behaviour test, starts that phase's count again
Asks$.ui.ask: the start menu when another run is in the way, Take over, a base branch the issue names, a new worktree for a busy checkout under "ask", publish when a run reaches ready (unless confirmPublish is off), and a press of the pane's Stop or Publish, whatever confirmPublish says (one question, which also names a run another session drives)

| Gating hooks | all with .catch: agent.offer ×3 (hides the mod's agents from the main model), tool.call (refuses tool calls from a stopped or orphaned agent of the run; refuses the live agent's Edit, Write and NotebookEdit outside the run's folder or to a path it can't place, or one that would create a new .claude-plugin/plugin.json, and in a wo

Source 3 files
hooks/register.tsx 2090 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import {
5  AGENT_DESCRIPTIONS,
6  DEFAULT_COMMAND_MINUTES,
7  DEMO_STATES,
8  NAME,
9  PHASES,
10  PROFILE_FILE,
11  STASH_LIST_ARGV,
12  TAKE_OVER,
13  USAGE,
14  advance,
15  afterBlock,
16  agentName,
17  agentsKey,
18  agentPrompt,
19  badgeOf,
20  baseOf,
21  baseRefOf,
22  baseRemoteOf,
23  baseRemoteOfRun,
24  branchLine,
25  branchName,
26  canRetry,
27  checkoutBaseOf,
28  checkoutState,
29  checkoutWorkspaceOf,
30  cleanupLine,
31  commitMessage,
32  commonDirOf,
33  coverageLine,
34  demoRun,
35  elsewhereLine,
36  elsewhereText,
37  failuresOf,
38  findStash,
39  foreignChange,
40  forkOfOrigin,
41  forkRemoteVerdict,
42  gateG,
43  gateR,
44  guardAction,
45  hasSourceRecord,
46  hasStash,
47  headArg,
48  hostOf,
49  isAbsolutePath,
50  isActive,
51  isDrivenElsewhere,
52  isForkOf,
53  isLockError,
54  isOwnHandback,
55  isOwnMailboxHandback,
56  isPluginManifest,
57  isPrunable,
58  isPullUrl,
59  isSameRepo,
60  isWorking,
61  isWorktreeRun,
62  issueLabel,
63  keepText,
64  keptForkBlock,
65  keptWorktreePlan,
66  layoutOf,
67  lcovFileOf,
68  legacyAgentsKey,
69  lineOf,
70  lockPathOf,
71  manifestOf,
72  mergeLine,
73  migrationPlan,
74  newModLine,
75  newModReply,
76  newPluginGate,
77  newPluginOf,
78  newPluginRecord,
79  newPluginRefusal,
80  newRun,
81  nextDemoState,
82  normalizePath,
83  othersLine,
84  othersOf,
85  outsideChecks,
86  ownerOf,
87  parseCommand,
88  parseLcov,
89  parseMergeTree,
90  parseProfile,
91  parseRunKey,
92  phasePrompt,
93  phaseRows,
94  pickRun,
95  prBody,
96  profileProblems,
97  profileStatusText,
98  pruneKeys,
99  publishAsk,
100  paneAction,
101  pressQuestion,
102  publishQuestion,
103  readdWorktreeCommand,
104  releasesOnEnd,
105  remoteVerdict,
106  repoLabel,
107  repoOfView,
108  repoAgentsKeys,
109  repoRunKeys,
110  routedRedBlock,
111  runKey,
112  severalText,
113  sharedLcovBlock,
114  spawnRefs,
115  splitZ,
116  sshHostToResolve,
117  sshHostname,
118  stashApplyArgv,
119  stashMessage,
120  startAnswer,
121  startChoices,
122  statusText,
123  tailLines,
124  takeOverQuestion,
125  totals,
126  truncate,
127  unstagedOf,
128  viewOf,
129  withRefusal,
130  workOf,
131  workspacePlan,
132  worktreeAddFailure,
133  worktreePath,
134  worktreeStateOf,
135} from './issue-forge-logic'
136import type { BaseRemote, CheckoutState, Command, CommandResult, DemoState, DemoView, Gate, Kept, KeptWorktreePlan, LegacyEntry, LineContext, MergeCheck, OtherRun, Phase, Profile, RemoteFound, RemoteVerdict, Run, RunEvent, RunPick, StashRef, Step, Tone } from './issue-forge-logic'
137
138/**
139 * Issue Forge: one GitHub issue to a pull request, test-first (SPEC.md "How it works").
140 * Every `$` call lives here; every rule lives in issue-forge-logic.ts.
141 */
142
143const PANE = 'issue-forge'
144const TITLE = 'Issue Forge'
145/** The pane as every open asks for it; bringing it to the front adds only `focus`. */
146const PANE_ARGS = { id: PANE, title: TITLE, columns: 76 }
147const SHOW_TEXT = 'bringing the pane to the front; it takes the keyboard if your prompt is empty (Esc gives it back).'
148const STILL_BEHIND_TOAST = `${TITLE} is still behind another tab: ctrl+x tab, then again until it shows.`
149/** After a start or a resume opened the pane behind another tab: where it is and how to reach it. */
150const BEHIND_TEXT = 'its pane is behind another tab (ctrl+x tab, then again until it shows)'
151/** How long after asking for the front the pane is read again (#31). */
152const FRONT_CHECK_MS = 500
153const GIT_TIMEOUT = 60_000
154/** The attempts a git call that writes the index or a ref makes against a held lock, a second apart (#28). */
155const LOCK_ATTEMPTS = 5
156const LOCK_WAIT_MS = 1000
157/** Why a phase agent's report is kept from the main model: shown for prompt.submit's drop, only logged for session.receive's consumed (d.ts). */
158const HANDBACK_DROP = 'issue-forge: a phase agent finished; the pane has the result.'
159const COLORS: Record<Tone, string | undefined> = { success: '#7FB685', accent: '#D97757', warn: '#E0B15A', error: '#E06C6C', dim: undefined }
160
161const runAtom = atom({ plugin: 'issue-forge', key: 'run' } as const, null)
162const activityAtom = atom({ plugin: 'issue-forge', key: 'activity' } as const, null)
163const nowAtom = atom({ plugin: 'issue-forge', key: 'now' } as const, 0)
164const demoAtom = atom({ plugin: 'issue-forge', key: 'demo' } as const, null)
165/** The issue whose run the pane shows: the one this session last started, resumed, published, stopped or asked about (Change 0.3.0). */
166const focusAtom = atom({ plugin: 'issue-forge', key: 'focus' } as const, null)
167/** The repository's other active runs: the pane's also line. */
168const othersAtom = atom({ plugin: 'issue-forge', key: 'others' } as const, [] as OtherRun[])
169
170type Options = { redModel: string; greenModel: string; refactorModel: string; redAgentType: string; confirmPublish: boolean }
171
172let options: Options = { redModel: 'opus', greenModel: 'sonnet', refactorModel: 'opus', redAgentType: 'issue-forge:red', confirmPublish: true }
173let sessionCwd = ''
174/** Whether a person is at the prompt: a dismissed start menu then means Cancel (PR #51 review). */
175let isInteractive = true
176let home = ''
177/** This session's id, read once at session start: what a run's driver names (Change 0.3.0). */
178let sessionId = ''
179/**
180 * A drive: its run, the run's store key and issue, whether it was stopped or handed off, and its generation. A new
181 * drive of the same run (a hand-off, then a resume) has a new one, so the old drive's step in flight fails every
182 * ownership check (PR #51 review).
183 */
184type Drive = { runId: string; key: string; issue: number; isStopped: boolean; gen: number }
185/** The run as a drive hands it to a step: stamped with the drive's generation (never stored). */
186type Stamped = Run & { driveGen: number }
187let drives = 0
188/** The run this module is driving (one per session); null after a reload (the run then reads as interrupted). */
189let driving: Drive | null = null
190/** The driven run as last written: the status line's badge. */
191let drivenLast: Run | null = null
192/** The run whose drive has its publish question open; a Publish or Stop press then asks no second one (PR #96 review). */
193let publishAsking: string | null = null
194/** Ends the long command running now (a stop, or its timeout). */
195let stopCommand: (() => void) | null = null
196/** The one agent of the run allowed to call tools; the run's other agents are refused (SPEC 9). */
197let liveAgent: string | null = null
198/** The live agent's run: where the guard keeps it (Change 0.2.0; an agent's cwd is ignored, P4). */
199let liveWork: { root: string; workRoot: string; testGlobs: readonly string[]; baseManifests: readonly string[] } | null = null
200/**
201 * Every agent id and name a run of this repository has started, in memory for the tool.call guard
202 * and the hand-back drop, and in the store so a new run or a reload never forgets one (an agent
203 * can't be ended, only refused).
204 */
205let knownRefs = new Set<string>()
206/** The last run write queued: updateRun chains each one after it. */
207let runWrites: Promise<void> = Promise.resolve()
208/** Where git keeps each folder's lock files, asked once per folder and file (PR #88 review). */
209const lockFiles: Record<string, string> = {}
210const waiting: Record<string, { resolve: (answer: string) => void; reject: (err: Error) => void }> = {}
211
212function readOptions(raw: unknown): Options {
213  const o = (raw ?? {}) as Record<string, unknown>
214  const text = (key: string, fallback: string) => (typeof o[key] === 'string' && (o[key] as string).trim() !== '' ? (o[key] as string).trim() : fallback)
215  return {
216    redModel: text('redModel', 'opus'),
217    greenModel: text('greenModel', 'sonnet'),
218    refactorModel: text('refactorModel', 'opus'),
219    redAgentType: text('redAgentType', 'issue-forge:red'),
220    confirmPublish: o.confirmPublish !== false && o.confirmPublish !== 'false',
221  }
222}
223
224const MAX_KNOWN = 500
225
226/** A run's store key; runs stored before 0.3.0 had no repo until session start moved them. */
227const keyOf = (run: Run) => runKey(run.repo ?? run.root, run.issue)
228
229function refsOf(run: Run | null): string[] {
230  return run ? run.agents.flatMap(a => (a.name ? [a.id, a.name] : [a.id])) : []
231}
232
233// ── Outside world ─────────────────────────────────────────────────────────
234
235/** git takes no optional locks, so the run's own `git status` doesn't refresh the index while an agent's or the user's git needs it (#28). */
236const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0' }
237
238async function git($: EngineInterface, root: string, args: string[], stdin?: string) {
239  return $.process.run(['git', ...args], { cwd: root, timeoutMs: GIT_TIMEOUT, env: GIT_ENV, ...(stdin === undefined ? {} : { stdin }) })
240}
241
242/**
243 * git that another process may hold a lock against (a parallel run's fetch): retried twice at once (Change 0.3.0).
244 * A fetch spends seconds on the network first, so the other is done. The index has gitWrite (#28).
245 */
246async function gitRetry($: EngineInterface, root: string, args: string[], stdin?: string) {
247  let r = await git($, root, args, stdin)
248  for (let i = 0; i < 2 && r.exitCode !== 0 && isLockError(`${r.stderr}\n${r.stdout}`); i++) r = await git($, root, args, stdin)
249  return r
250}
251
252/** A git call's output: a failure throws, naming the subcommand and the first line git printed. */
253function outputOf(args: string[], r: ProcessRunResult): string {
254  if (r.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${firstLine(r.stderr || r.stdout)}`)
255  return r.stdout
256}
257
258async function gitOut($: EngineInterface, root: string, args: string[]): Promise<string> {
259  return outputOf(args, await git($, root, args))
260}
261
262/** Thrown when a git lock (the index's or a ref's) is still held after the last attempt: the drive blocks with its reason and next step. */
263class Locked extends Error {
264  readonly next: string
265  constructor(lockPath: string) {
266    super(`another git process is using ${lockPath} (${LOCK_ATTEMPTS} attempts over ${((LOCK_ATTEMPTS - 1) * LOCK_WAIT_MS) / 1000} s)`)
267    this.next = `wait for the other git process to finish, or delete ${lockPath} if none is running`
268  }
269}
270
271/**
272 * git that writes the index or a ref (add, commit, stash, switch, merge): another process holding .git/index.lock, or a
273 * ref's lock, fails it at once, so a failure caused by a lock waits a second and tries again, five attempts in all (#28,
274 * PR #88 review). Stash prints nothing when it meets the index lock, so that file is looked for too. Any other failure
275 * comes back as it is.
276 */
277async function gitWrite($: EngineInterface, run: Run, root: string, args: string[], stdin?: string) {
278  // Known before the call: a lock file is a silent failure's only sign, and the other process may let it go soon after.
279  // stash push names no lock when refs/stash's is held (git 2.53: only "Cannot save the current status").
280  const files = [await lockFileOf($, root, 'index.lock'), ...(args[0] === 'stash' && args[1] === 'push' ? [await lockFileOf($, root, 'refs/stash.lock')] : [])]
281  for (let attempt = 1; ; attempt++) {
282    const r = await git($, root, args, stdin)
283    if (r.exitCode === 0) return r
284    const text = `${r.stderr}\n${r.stdout}`
285    const held = lockPathOf(text) ?? (await heldOf($, files))
286    if (!held && !isLockError(text)) return r
287    // A lock refusal that no lock file explains (a broken ref) comes back as git said it once the attempts are spent.
288    if (attempt === LOCK_ATTEMPTS) {
289      if (!held) return r
290      throw new Locked(held)
291    }
292    await lockWait($)
293    // The 5 s watch is too slow for a takeover during the waits: the stored run is read before each attempt.
294    await stillOwned($, run)
295  }
296}
297
298/**
299 * One of the folder's lock files: a linked worktree's index lock is under the main .git folder, and refs/stash is
300 * shared by every worktree, so git says where. Without `--path-format=absolute`, which git before 2.31 prints back
301 * instead of reading, so a relative answer is joined here.
302 */
303async function lockFileOf($: EngineInterface, root: string, name: 'index.lock' | 'refs/stash.lock'): Promise<string> {
304  const key = `${root}\0${name}`
305  const known = lockFiles[key]
306  if (known) return known
307  const r = await git($, root, ['rev-parse', '--git-path', name])
308  const path = r.exitCode === 0 ? r.stdout.trim() : ''
309  if (!path) return `${root}/.git/${name}`
310  return (lockFiles[key] = isAbsolutePath(path) ? path : `${root}/${path}`)
311}
312
313/** The first of `files` that exists, or null. */
314async function heldOf($: EngineInterface, files: string[]): Promise<string | null> {
315  for (const file of files) if (await $.fs.exists(file)) return file
316  return null
317}
318
319/**
320 * The second between lock attempts, as a timer: `$.clock.sleep` would count against a hook's budget. The timer may never
321 * fire (a hook refuses it, a reload cancels it), so a Stop, hand-off or takeover ends the wait itself, as it ends a command.
322 */
323async function lockWait($: EngineInterface): Promise<void> {
324  try {
325    await new Promise<void>((resolve, reject) => {
326      const timer = $.clock.after(LOCK_WAIT_MS, () => resolve())
327      stopCommand = () => {
328        timer.cancel()
329        reject(new Stopped())
330      }
331    })
332  } finally {
333    stopCommand = null
334  }
335}
336
337/** gitWrite for a call that must succeed: a failure throws, as gitOut's does. */
338async function gitWriteOut($: EngineInterface, run: Run, root: string, args: string[], stdin?: string): Promise<string> {
339  return outputOf(args, await gitWrite($, run, root, args, stdin))
340}
341
342function firstLine(text: string): string {
343  return text.trim().split('\n')[0] ?? ''
344}
345
346async function rootOf($: EngineInterface): Promise<string | null> {
347  const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: sessionCwd || undefined, timeoutMs: GIT_TIMEOUT, env: GIT_ENV }).catch(() => null)
348  return r && r.exitCode === 0 ? r.stdout.trim() : null
349}
350
351/** The session's checkout and git's common folder, which every worktree of the repository shares (Change 0.3.0). */
352type Repo = { root: string; repo: string }
353
354async function repoOf($: EngineInterface): Promise<Repo | null> {
355  const root = await rootOf($)
356  if (!root) return null
357  const r = await git($, root, ['rev-parse', '--git-common-dir']).catch(() => null)
358  const repo = r && r.exitCode === 0 ? commonDirOf(root, r.stdout) : null
359  return repo ? { root, repo } : null
360}
361
362async function loadRun($: EngineInterface, key: string): Promise<Run | null> {
363  return ((await $.store.get(key)) as Run | undefined) ?? null
364}
365
366/** The repository's runs, read from the store each time: other sessions write it too. */
367async function listRuns($: EngineInterface, repo: string): Promise<{ key: string; run: Run }[]> {
368  const out: { key: string; run: Run }[] = []
369  for (const key of repoRunKeys(await $.store.keys(), repo)) {
370    const run = await loadRun($, key)
371    if (run) out.push({ key, run })
372  }
373  return out
374}
375
376async function runsOf($: EngineInterface, repo: string): Promise<Run[]> {
377  return (await listRuns($, repo)).map(e => e.run)
378}
379
380/**
381 * Every write of a stored run, one at a time, so none is computed from a read another write has since made stale
382 * (PR #41 round 4). `change` gets the run as stored now and returns what to store, or null to write nothing.
383 * Resolves to the run written, or null.
384 */
385async function updateRun($: EngineInterface, key: string, change: (stored: Run | null) => Run | null): Promise<Run | null> {
386  const before = runWrites
387  let done = () => {}
388  runWrites = new Promise<void>(resolve => (done = resolve))
389  try {
390    await before
391    const next = change(await loadRun($, key))
392    if (!next) return null
393    await $.store.set(key, next)
394    await reflect($, next)
395    return next
396  } finally {
397    done()
398  }
399}
400
401/** A run just written: its agents are known, the pane redraws it when it is the one shown, the badge when it is driven. */
402async function reflect($: EngineInterface, run: Run): Promise<void> {
403  for (const ref of refsOf(run)) knownRefs.add(ref)
404  if (driving && run.id === driving.runId) drivenLast = run
405  if ((await read($, focusAtom)) === run.issue) await update($, runAtom, () => run)
406}
407
408/** Another session drives the stored run now: it took it over (Change 0.3.0). */
409function isTakenOver(stored: Run): boolean {
410  return !!stored.driver && stored.driver.session !== sessionId
411}
412
413/**
414 * A drive's write, by generation `gen`. Resolves to the run written, or null when it was dropped: the drive then has
415 * nothing left to drive. A stop wins every race, a write lands only on the same run, never on one another session
416 * took over (Change 0.3.0), and never from a drive that was stopped, handed off or replaced (PR #51 review). A refusal
417 * the guard stored meanwhile is kept (Codex 4290653342).
418 */
419async function saveRun($: EngineInterface, run: Run, gen: number): Promise<Run | null> {
420  return updateRun($, keyOf(run), stored =>
421    !stored || stored.id !== run.id || (run.step !== 'stopped' && stored.step === 'stopped') || isTakenOver(stored) || driving?.gen !== gen || driving.isStopped ? null : withRefusal(stored, run),
422  )
423}
424
425/**
426 * The branch and folder preflight just made, written onto the stored run at once, even over a stop, so a
427 * Stop pressed before preflight returns never loses them (Codex, PR #41 round 3). Never onto a run another
428 * session took over meanwhile: it makes its own (Codex 4278707576).
429 */
430async function recordMade($: EngineInterface, run: Run, made: Kept): Promise<void> {
431  const now = await $.clock.now()
432  await updateRun($, keyOf(run), stored => (stored?.id === run.id && !isTakenOver(stored) ? advance(stored, { type: 'made', keep: made }, now) : null))
433}
434
435/** A number start's repository, written onto the stored run at once, as recordMade writes the branch (PR #68 review 2). */
436async function recordPinned($: EngineInterface, run: Run, repo: string): Promise<void> {
437  const now = await $.clock.now()
438  await updateRun($, keyOf(run), stored => (stored?.id === run.id && !isTakenOver(stored) ? advance(stored, { type: 'pinned', repo }, now) : null))
439}
440
441/** Adds agents to the run's known list (memory and store); they are refused tools unless live. */
442async function rememberAgents($: EngineInterface, run: Run, refs: readonly string[]): Promise<void> {
443  for (const ref of refs) knownRefs.add(ref)
444  const key = agentsKey(run.repo ?? run.root, run.issue)
445  const stored = ((await $.store.get(key)) as string[] | undefined) ?? []
446  const merged = [...new Set([...stored, ...refs])].slice(-MAX_KNOWN)
447  await $.store.set(key, merged)
448}
449
450async function setActivity($: EngineInterface, label: string | null): Promise<void> {
451  const since = await $.clock.now()
452  await update($, activityAtom, () => (label ? { label, since } : null))
453}
454
455/**
456 * A long command (tests, coverage, checks) through `$.process.spawn`, killed at the profile's
457 * timeout by ending its stream (C0 P3). Where spawn can't start (always under `claude plugin
458 * test`), the same command runs through `$.process.run`, capped at ten minutes.
459 */
460async function runLong($: EngineInterface, argv: string[], root: string, minutes: number, env?: Record<string, string>): Promise<CommandResult> {
461  let text = ''
462  let isTimedOut = false
463  let isStopped = false
464  let hasStarted = false
465  const stream = $.process.spawn({ argv, cwd: root, ...(env ? { env } : {}) })
466  const end = () => void stream.return(undefined as never).catch(() => undefined)
467  const timer = $.clock.after(minutes * 60_000, () => {
468    isTimedOut = true
469    end()
470  })
471  stopCommand = () => {
472    isStopped = true
473    end()
474  }
475  try {
476    let result: CommandResult
477    try {
478      for await (const piece of stream) {
479        hasStarted = true
480        text = (text + piece.text).slice(-200_000)
481      }
482      if (isTimedOut || isStopped) result = { exit: null, isTimedOut, tail: tailLines(text) }
483      else result = { exit: (await stream.result).code, isTimedOut: false, tail: tailLines(text) }
484    } catch (err) {
485      result = hasStarted || isTimedOut || isStopped ? { exit: null, isTimedOut, tail: tailLines(text) } : await runShort($, argv, root, minutes, err, env)
486    }
487    if (isStopped) throw new Stopped()
488    return result
489  } finally {
490    timer.cancel()
491    stopCommand = null
492  }
493}
494
495/** Thrown when /issue-forge stop (or a takeover) ends the run mid-step: nothing after it may change the repository. */
496class Stopped extends Error {
497  constructor() {
498    super('stopped')
499  }
500}
501
502/** Whether this module is still driving `run`: false once it was stopped, or another drive (a later one included) took it over. */
503function isDriving(run: Run): boolean {
504  return driving !== null && driving.runId === run.id && !driving.isStopped && (run as Partial<Stamped>).driveGen === driving.gen
505}
506
507function stillDriving(run: Run): void {
508  if (!isDriving(run)) throw new Stopped()
509}
510
511/** Before a commit or a stash: a run another session stopped, took over or replaced is left alone (Change 0.3.0). */
512async function stillOwned($: EngineInterface, run: Run): Promise<void> {
513  stillDriving(run)
514  if (foreignChange(await loadRun($, keyOf(run)), run.id, sessionId)) {
515    endLocalDrive()
516    throw new Stopped()
517  }
518}
519
520/** Ends this session's drive at once, as a Stop does, writing nothing: the command is ended and the agent refused. */
521function endLocalDrive(): void {
522  if (driving) driving.isStopped = true
523  stopCommand?.()
524  liveAgent = null
525  liveWork = null
526  for (const [id, w] of Object.entries(waiting)) {
527    delete waiting[id]
528    w.reject(new Error('stopped'))
529  }
530}
531
532/** The drive under way in this session, if any (a stopped or handed-off one isn't). */
533function drivingNow(): Drive | null {
534  return driving && !driving.isStopped ? driving : null
535}
536
537async function hasBranch($: EngineInterface, root: string, branch: string): Promise<boolean> {
538  return (await git($, root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).exitCode === 0
539}
540
541/** A gate commits only on the run's own branch: an agent may have switched branches. */
542async function offBranch($: EngineInterface, run: Run): Promise<Blocked | null> {
543  const head = (await gitOut($, workOf(run), ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
544  return head === run.branch ? null : block(`HEAD is on ${head}, not ${run.branch}`, `git switch ${run.branch}`)
545}
546
547async function runShort($: EngineInterface, argv: string[], root: string, minutes: number, why: unknown, env?: Record<string, string>): Promise<CommandResult> {
548  try {
549    const r = await $.process.run(argv, { cwd: root, timeoutMs: Math.min(minutes * 60_000, 600_000), ...(env ? { env } : {}) })
550    return { exit: r.exitCode, isTimedOut: false, tail: tailLines(`${r.stdout}\n${r.stderr}`) }
551  } catch (err) {
552    return { exit: null, isTimedOut: /time/i.test(String(err)), tail: `could not run ${argv.join(' ')}: ${String(err)} (spawn: ${String(why)})` }
553  }
554}
555
556/** The LCOV file's text if the coverage command wrote it after `since`; a stale or missing file is null. */
557async function readLcov($: EngineInterface, path: string, since: number): Promise<string | null> {
558  try {
559    const st = await $.fs.stat(path)
560    if (st.mtimeMs < since - 2000) return null
561    return await $.fs.read(path)
562  } catch {
563    return null
564  }
565}
566
567async function commitAll($: EngineInterface, run: Run, message: string): Promise<string | undefined> {
568  await stillOwned($, run)
569  const root = workOf(run)
570  const staged = await git($, root, ['diff', '--cached', '--quiet'])
571  if (staged.exitCode === 0) return undefined
572  await gitWriteOut($, run, root, ['commit', '-q', '-F', '-'], message)
573  return (await gitOut($, root, ['rev-parse', 'HEAD'])).trim()
574}
575
576/** Stashes the tree under a unique message and records the entry's SHA (the list is shared by every worktree). */
577async function stashAll($: EngineInterface, run: Run, message: string): Promise<StashRef | undefined> {
578  await stillOwned($, run)
579  const root = workOf(run)
580  await gitWriteOut($, run, root, ['stash', 'push', '--include-untracked', '-m', message])
581  const sha = findStash(await gitOut($, root, STASH_LIST_ARGV.slice(1)), message)
582  return sha ? { sha, message } : undefined
583}
584
585// ── Steps ─────────────────────────────────────────────────────────────────
586
587type Blocked = Extract<RunEvent, { type: 'block' }>
588const block = (reason: string, next: string): Blocked => ({ type: 'block', reason, next })
589
590async function authCheck($: EngineInterface, run: Run): Promise<Blocked | null> {
591  const auth = await $.process.run(['gh', 'auth', 'status'], { cwd: run.root, timeoutMs: GIT_TIMEOUT }).catch(() => null)
592  return !auth || auth.exitCode !== 0 ? block('gh is not signed in', 'run gh auth login') : null
593}
594
595/** `gh issue view` of an issue, in `repo` when one is named, else in the repository gh resolves. */
596const issueViewArgv = (issue: number, repo: string | null | undefined): string[] => ['gh', 'issue', 'view', String(issue), ...(repo ? ['--repo', repo] : []), '--json', 'number,title,body,state,url']
597
598/** The issue as gh reads it from `repo` (the run's pinned repository, or gh's own choice for a run stored before #25). */
599async function issueCheck($: EngineInterface, run: Run, repo: string | null): Promise<Blocked | { title: string; body: string; url: string }> {
600  const view = await $.process.run(issueViewArgv(run.issue, repo), { cwd: run.root, timeoutMs: GIT_TIMEOUT })
601  if (view.exitCode !== 0) return block(`issue #${run.issue} not found: ${firstLine(view.stderr)}`, 'check the issue number or URL')
602  const issue = JSON.parse(view.stdout) as { title: string; body: string; state: string; url: string }
603  // gh issue view answers for a pull request's number too.
604  // Final: a pull request's number never becomes an issue's, so resume can't clear it (Change 0.7.0).
605  if (isPullUrl(issue.url)) return { ...block(`#${run.issue} is a pull request, not an issue`, 'pick another issue'), isFinal: true }
606  if (issue.state !== 'OPEN') return block(`issue #${run.issue} is ${issue.state.toLowerCase()}`, 'reopen it or pick another issue')
607  return { title: issue.title, body: issue.body ?? '', url: issue.url }
608}
609
610/** The GitHub repository gh resolves in `root` (`host/owner/repo`), or why it can't say. */
611async function ghRepoOf($: EngineInterface, root: string): Promise<Blocked | { repo: string }> {
612  const view = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner,url'], { cwd: root, timeoutMs: GIT_TIMEOUT }).catch(() => null)
613  if (!view || view.exitCode !== 0) return block(`could not tell which GitHub repository this checkout belongs to: ${firstLine(view?.stderr ?? 'gh repo view did not finish')}`, 'give the checkout a GitHub remote named origin')
614  const repo = repoOfView(view.stdout)
615  return repo ? { repo } : block("could not read gh repo view's output", 'check gh')
616}
617
618/**
619 * Whether a remote URL names `repo`. The URL itself is parsed here and never passed on: it can hold a token. A renamed or
620 * transferred repository differs from the URL, so gh is asked for the name it gives `host/owner/repo`.
621 */
622async function remoteRepo($: EngineInterface, root: string, url: string, repo: string, views: Views = new Map()): Promise<RemoteFound> {
623  return foundOf($, root, remoteVerdict(url, repo), repo, views)
624}
625
626/**
627 * `remoteRepo` for a fork clone's remotes (the fork, the base remote): an ssh host other than `repo`'s is resolved with
628 * `ssh -G`, never taken for a nickname (PR #87 review, the owner's choice).
629 */
630async function forkRemoteRepo($: EngineInterface, root: string, url: string, repo: string, views: Views = new Map()): Promise<RemoteFound> {
631  const host = sshHostToResolve(url, repo)
632  return foundOf($, root, forkRemoteVerdict(url, repo, host === null ? undefined : await sshHostOf($, root, host)), repo, views)
633}
634
635/** `ssh -G <host>`: the host ssh reaches for it, ~/.ssh/config applied, without connecting; null when ssh can't say. */
636async function sshHostOf($: EngineInterface, root: string, host: string): Promise<string | null> {
637  const shown = await $.process.run(['ssh', '-G', host], { cwd: root, timeoutMs: GIT_TIMEOUT }).catch(() => null)
638  return shown?.exitCode === 0 ? sshHostname(shown.stdout) : null
639}
640
641/** A verdict settled: another repository on `repo`'s host is asked about, since gh may call it `repo` renamed. */
642async function foundOf($: EngineInterface, root: string, verdict: RemoteVerdict, repo: string, views: Views): Promise<RemoteFound> {
643  if (verdict.kind === 'match') return 'match'
644  if (verdict.kind === 'unrecognised') return { other: null }
645  if (verdict.kind === 'other') return { other: verdict.repo }
646  const canonical = await canonicalOf($, root, verdict.repo, views)
647  return isSameRepo(canonical, repo) ? 'match' : { other: canonical ?? verdict.repo }
648}
649
650/** gh's names for `host/owner/repo`s, asked once each within one check (PR #87 review). */
651type Views = Map<string, Promise<string | null>>
652
653/** The name gh gives `host/owner/repo` (a renamed or transferred repository's new one), or null when gh can't say. */
654function canonicalOf($: EngineInterface, root: string, repo: string, views: Views): Promise<string | null> {
655  const known = views.get(repo)
656  if (known) return known
657  const asked = $.process
658    .run(['gh', 'repo', 'view', repo, '--json', 'nameWithOwner,url'], { cwd: root, timeoutMs: GIT_TIMEOUT })
659    .then(view => (view.exitCode === 0 ? repoOfView(view.stdout) : null))
660    .catch(() => null)
661  views.set(repo, asked)
662  return asked
663}
664
665/** The distinct, non-empty lines of a command's output, in order. */
666function linesOf(stdout: string): string[] {
667  return [...new Set(stdout.split('\n').map(line => line.trim()).filter(line => line !== ''))]
668}
669
670/** `git remote get-url --push --all origin`: every URL git pushes origin to, one line each, and git's exit code. */
671async function pushUrlsOf($: EngineInterface, root: string): Promise<{ exitCode: number; urls: string[] }> {
672  const pushed = await git($, root, ['remote', 'get-url', '--push', '--all', 'origin'])
673  return { exitCode: pushed.exitCode, urls: linesOf(pushed.stdout) }
674}
675
676/** Origin's fetch URL and every URL git pushes it to, or null without an origin. Kept in memory only: a URL can hold a token. */
677type OriginUrls = { fetch: string; pushes: string[] }
678
679async function originUrls($: EngineInterface, root: string): Promise<OriginUrls | null> {
680  const fetched = await git($, root, ['remote', 'get-url', 'origin'])
681  const pushed = await pushUrlsOf($, root)
682  return fetched.exitCode !== 0 || pushed.exitCode !== 0 ? null : { fetch: fetched.stdout.trim(), pushes: pushed.urls }
683}
684
685/**
686 * Origin fetches from and pushes to `repo`, the repository gh sends the issue and the pull request to. git pushes to every push
687 * URL, so each is checked (PR #68 review), and one that is the fetch URL only once. Preflight passes the URLs it read.
688 */
689async function originCheck($: EngineInterface, root: string, repo: string, read?: OriginUrls | null, views: Views = new Map()): Promise<Blocked | null> {
690  const urls = read === undefined ? await originUrls($, root) : read
691  if (!urls) return block('this checkout has no remote named origin', `add a remote named origin for ${repoLabel(repo)}`)
692  const fetchUrl = urls.fetch
693  const pushUrls = urls.pushes.filter(u => u !== fetchUrl)
694  for (const [url, isPush] of [[fetchUrl, false] as const, ...pushUrls.map(u => [u, true] as const)]) {
695    const found = await remoteRepo($, root, url, repo, views)
696    if (found === 'match') continue
697    const label = found.other === null ? 'an unrecognised URL' : repoLabel(found.other)
698    const next = found.other === null ? `give origin an https, http or ssh URL for ${repoLabel(repo)}` : `work from a clone whose origin is ${repoLabel(repo)}, or whose origin is your fork of ${repoLabel(repo)} with another remote for ${repoLabel(repo)}`
699    return block(`origin ${isPush ? 'pushes to' : 'fetches from'} ${label}, but gh sends issues and pull requests to ${repoLabel(repo)}`, next)
700  }
701  return null
702}
703
704/** A fork clone's remotes (#26): the one the base is fetched from, and the fork origin pushes to (`host/owner/repo`). */
705type Fork = { baseRemote: string; headRepo: string }
706
707/** The fork origin pushes to, from the URLs preflight read: each is resolved once, all at once (PR #87 review). */
708async function forkOf($: EngineInterface, root: string, repo: string, urls: OriginUrls | null, views: Views): Promise<string | null> {
709  if (!urls) return null
710  const unique = [...new Set([urls.fetch, ...urls.pushes])]
711  const found = new Map((await Promise.all(unique.map(url => forkRemoteRepo($, root, url, repo, views)))).map((f, i) => [unique[i], f]))
712  const of = (url: string) => found.get(url) ?? { other: null }
713  return forkOfOrigin(of(urls.fetch), urls.pushes.map(of), repo)
714}
715
716/** Whether remote `name`'s fetch URL names `repo`. */
717async function remoteNames($: EngineInterface, root: string, name: string, repo: string, views: Views): Promise<boolean> {
718  const url = await git($, root, ['remote', 'get-url', name])
719  return url.exitCode === 0 && (await forkRemoteRepo($, root, url.stdout.trim(), repo, views)) === 'match'
720}
721
722/** The remotes besides origin whose fetch URL names `repo`, looked up together: a fork clone fetches the base from the one there is. */
723async function baseRemoteFor($: EngineInterface, root: string, repo: string, views: Views): Promise<BaseRemote> {
724  const names = linesOf((await git($, root, ['remote'])).stdout).filter(n => n !== 'origin')
725  const named = await Promise.all(names.map(name => remoteNames($, root, name, repo, views)))
726  return baseRemoteOf(names.filter((_, i) => named[i]))
727}
728
729/**
730 * A clone whose origin isn't `repo` may be a fork clone (#26): origin pushes to a fork of `repo` and another remote is `repo`. The
731 * fork must be a user's, not an organization's, which `gh pr create --head` can't name. This runs before anything is fetched. Any
732 * other clone gets `stray`, the origin block.
733 */
734async function forkCheck($: EngineInterface, root: string, repo: string, stray: Blocked, urls: OriginUrls | null, views: Views): Promise<Blocked | Fork> {
735  const headRepo = await forkOf($, root, repo, urls, views)
736  if (!headRepo) return stray
737  const base = await baseRemoteFor($, root, repo, views)
738  if (base.kind === 'none') return stray
739  if (base.kind === 'several') return block(`remotes ${base.remotes.slice(0, -1).join(', ')} and ${base.remotes.slice(-1).join('')} both point at ${repoLabel(repo)}`, 'remove one of them')
740  const view = await $.process.run(['gh', 'repo', 'view', headRepo, '--json', 'isFork,parent'], { cwd: root, timeoutMs: GIT_TIMEOUT }).catch(() => null)
741  if (!view || view.exitCode !== 0) return block(`could not read ${repoLabel(headRepo)} from gh: ${firstLine(view?.stderr ?? 'gh repo view did not finish')}`, 'check gh and your access to the fork')
742  if (!isForkOf(view.stdout, repo)) return block(`origin ${repoLabel(headRepo)} isn't a fork of ${repoLabel(repo)}`, `point origin at your fork of ${repoLabel(repo)}`)
743  const owner = ownerOf(headRepo)
744  // gh api asks github.com unless told the host (Codex on PR #87).
745  const type = await $.process.run(['gh', 'api', '--hostname', hostOf(headRepo), `users/${owner}`, '--jq', '.type'], { cwd: root, timeoutMs: GIT_TIMEOUT }).catch(() => null)
746  if (!type || type.exitCode !== 0) return block(`could not tell whether ${owner} is a user or an organization: ${firstLine(type?.stderr ?? 'gh api did not finish')}`, 'check gh')
747  if (type.stdout.trim() === 'Organization') return block("gh pr create can't name an organization's fork as the head (cli/cli#10093)", `point origin at a fork in a personal account, not ${owner}'s`)
748  return { baseRemote: base.remote, headRepo }
749}
750
751/** A fork run's origin still pushes only to the fork it was preflighted for: git pushes to every push URL (#26). */
752async function forkPushCheck($: EngineInterface, root: string, headRepo: string): Promise<Blocked | null> {
753  const pushed = await pushUrlsOf($, root)
754  const moved = block(`origin no longer pushes to ${repoLabel(headRepo)}`, 'point origin back at it, or start a new run')
755  if (pushed.exitCode !== 0 || pushed.urls.length === 0) return moved
756  for (const url of pushed.urls) if ((await forkRemoteRepo($, root, url, headRepo)) !== 'match') return moved
757  return null
758}
759
760/** Origin still pushes where the run's branch goes: the fork it was preflighted for (#26), else `repo` itself (#25). */
761async function pushCheck($: EngineInterface, root: string, repo: string | undefined, headRepo: string | undefined): Promise<Blocked | null> {
762  if (headRepo) return forkPushCheck($, root, headRepo)
763  return repo ? originCheck($, root, repo) : null
764}
765
766/** Reads and validates the profile, the outside checks included (§4.2). */
767async function loadProfile($: EngineInterface, root: string): Promise<{ profile: Profile | null; errors: string[] }> {
768  const path = `${root}/${PROFILE_FILE}`
769  if (!(await $.fs.exists(path))) return { profile: null, errors: [] }
770  const parsed = parseProfile(await $.fs.read(path), home)
771  if (!parsed.profile) return { profile: null, errors: parsed.errors }
772  const p = parsed.profile
773  const outside = outsideChecks(p, root)
774  const isLcovIgnored = outside.lcovInside === null ? null : (await git($, root, ['check-ignore', '-q', '--', outside.lcovInside])).exitCode === 0
775  const missing: string[] = []
776  for (const c of outside.commands) {
777    const found =
778      c.kind === 'path'
779        ? await $.fs.exists(isAbsolutePath(c.argv0) ? c.argv0 : `${root}/${c.argv0}`)
780        : (await $.process.run(['sh', '-c', 'command -v "$1"', '_', c.argv0], { cwd: root, timeoutMs: GIT_TIMEOUT })).exitCode === 0
781    if (!found) missing.push(c.argv0)
782  }
783  const problems = profileProblems(p, { isLcovIgnored, missing })
784  return problems.length > 0 ? { profile: null, errors: problems } : { profile: p, errors: [] }
785}
786
787/**
788 * A fork run's base remote still fetches from `repo`, as the folder that fetches reads it: the run stores only its name, and a
789 * resume, a kept worktree's own config or the publish merge check would fetch whatever it points at now (Codex on PR #87).
790 * Origin is checked by `originCheck` and `pushCheck`.
791 */
792async function baseRemoteCheck($: EngineInterface, cwd: string, remote: string, repo: string | undefined): Promise<Blocked | null> {
793  if (remote === 'origin' || !repo) return null
794  const url = await git($, cwd, ['remote', 'get-url', remote])
795  if (url.exitCode === 0 && (await forkRemoteRepo($, cwd, url.stdout.trim(), repo)) === 'match') return null
796  return block(`${remote} no longer fetches from ${repoLabel(repo)}`, `point ${remote} back at ${repoLabel(repo)}, or start a new run`)
797}
798
799/** `git fetch <remote> <base>` once the remote is still `repo`'s, or the block naming why it can't be or failed. */
800async function fetchBase($: EngineInterface, cwd: string, remote: string, base: string, repo?: string): Promise<Blocked | null> {
801  const moved = await baseRemoteCheck($, cwd, remote, repo)
802  if (moved) return moved
803  await setActivity($, `git fetch ${remote} ${base}`)
804  const fetched = await gitRetry($, cwd, ['fetch', remote, base])
805  return fetched.exitCode === 0 ? null : block(`git fetch ${remote} ${base} failed: ${firstLine(fetched.stderr)}`, 'check the base branch and the network')
806}
807
808/** A branch with no commits of its own fast-forwards to <remote>/<base>, so a fix pushed there reaches it. */
809async function catchUp($: EngineInterface, run: Run, cwd: string, remote: string, base: string): Promise<Blocked | null> {
810  const hasNoOwnCommits = (await git($, cwd, ['merge-base', '--is-ancestor', 'HEAD', `${remote}/${base}`])).exitCode === 0
811  if (!hasNoOwnCommits) return null
812  stillDriving(run)
813  await setActivity($, `catching up with ${remote}/${base}`)
814  const ff = await gitWrite($, run, cwd, ['merge', '--ff-only', '-q', `${remote}/${base}`])
815  return ff.exitCode === 0 ? null : block(`could not fast-forward to ${remote}/${base}: ${firstLine(ff.stderr)}`, 'check the branch')
816}
817
818type Where = { kind: 'here' } | { kind: 'worktree'; worktreeDir: string; minutes: number }
819
820/** Whether the session's checkout has changes or isn't on the base: one `git status` and one `rev-parse`. */
821async function checkoutOf($: EngineInterface, root: string, base: string, heldBy?: number): Promise<CheckoutState> {
822  await setActivity($, 'checking the checkout')
823  const porcelain = await gitOut($, root, ['status', '--porcelain'])
824  const head = (await gitOut($, root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
825  return checkoutState({ porcelain, head, base, ...(heldBy === undefined ? {} : { heldBy }) })
826}
827
828/**
829 * Here or in a new worktree (Change 0.2.0). The base's profile decides, read from <remote>/<base> before
830 * anything is made or switched; a busy checkout is never branched in place.
831 */
832async function whereToWork($: EngineInterface, run: Run, root: string, remote: string, base: string): Promise<Blocked | Where> {
833  const issue = run.issue
834  await setActivity($, `reading ${PROFILE_FILE} from ${remote}/${base}`)
835  const shown = await git($, root, ['show', `${remote}/${base}:${PROFILE_FILE}`])
836  const atBase = shown.exitCode === 0 ? parseProfile(shown.stdout, home) : { profile: undefined, errors: [firstLine(shown.stderr) || 'missing'] }
837  if (!atBase.profile) return block(`${remote}/${base} has no valid ${PROFILE_FILE}: ${atBase.errors[0] ?? 'missing'}`, `commit ${PROFILE_FILE} to ${base}`)
838  const { workspace, worktreeDir, commandTimeoutMinutes } = atBase.profile
839  // `worktree` makes one whatever the checkout holds, so it isn't looked at (a status is slow on a big tree or a Windows mount).
840  // Another active run working in this checkout makes it busy (Change 0.3.0).
841  const holder = workspace === 'worktree' ? undefined : (await runsOf($, run.repo ?? root)).find(r => r.id !== run.id && isActive(r) && normalizePath(workOf(r)) === normalizePath(root))
842  const state = workspace === 'worktree' ? { isBusy: false, why: '' } : await checkoutOf($, root, base, holder?.issue)
843  // Start in the menu under `ask` was the answer to the worktree question: it isn't asked twice.
844  const plan = workspacePlan(run.chosenWorktree && workspace === 'ask' ? 'worktree' : workspace, state, base, `${remote}/${base}`)
845  if (plan.kind === 'block') return block(plan.reason, plan.next)
846  if (plan.kind === 'here') return { kind: 'here' }
847  // Everything a worktree needs is checked before anyone is asked for one.
848  const routed = routedRedBlock(options.redAgentType, true)
849  if (routed) return block(routed.reason, routed.next)
850  if ((await git($, root, ['check-ignore', '-q', '--', `${worktreeDir}/`])).exitCode !== 0) return block(`${worktreeDir} isn't gitignored`, `add ${worktreeDir}/ to .gitignore`)
851  if (plan.kind === 'ask' && !(await chooseWorktree($, issue, root, state.why, worktreeDir))) {
852    return block(`${root} is busy (${state.why}) and no new worktree was chosen`, `answer New worktree in an interactive session, set "workspace" to "worktree" in ${PROFILE_FILE} on ${base}, or clean the checkout`)
853  }
854  return { kind: 'worktree', worktreeDir, minutes: commandTimeoutMinutes }
855}
856
857/**
858 * Step 2: the base, where the run works, and the run's branch, cut here or in a new worktree from `remote`'s base. `repo` is
859 * the pinned repository: the base question can wait on the person, so the base remote is read again before the fetch.
860 */
861async function makeBranch($: EngineInterface, run: Run, issueBody: string, remote: string, repo: string, headRepo?: string): Promise<Blocked | Kept> {
862  const root = run.root
863  // This checkout's profile only names the base; the profile that is checked and runs is <remote>/<base>'s.
864  await setActivity($, `reading ${PROFILE_FILE}`)
865  const profileBase = checkoutBaseOf(await readLocalProfile($, root))
866  const asked = baseOf({ baseBranch: profileBase }, issueBody)
867  const base = asked === profileBase ? asked : await chooseBase($, run.issue, asked, profileBase)
868  if (!base) return block(`issue #${run.issue} asks for base ${asked}, not ${profileBase}, and nobody confirmed it`, "answer the question in an interactive session, or remove the issue's Base branch line")
869  stillDriving(run)
870  const unfetched = await fetchBase($, root, remote, base, repo)
871  if (unfetched) return unfetched
872  const where = await whereToWork($, run, root, remote, base)
873  if ('type' in where) return where
874  const local = splitZ((await gitOut($, root, ['for-each-ref', '--format=%(refname:short)%00', `refs/heads/issue-${run.issue}-*`, `refs/heads/issue-${run.issue}`])).replace(/\n/g, ''))
875  // A fork clone's branch name must be free on the fork it is pushed to and on the project it is cut from (#26).
876  const remoteHeads: string[] = []
877  for (const name of new Set(['origin', remote])) {
878    const listed = await gitOut($, root, ['ls-remote', '--heads', name, `issue-${run.issue}*`])
879    remoteHeads.push(...listed.split('\n').map(line => line.split('refs/heads/')[1] ?? '').filter(Boolean))
880  }
881  const branch = branchName(run.issue, run.title, [...local, ...remoteHeads])
882  // A fork clone's branch is kept with the fork its name was checked on, so a resume can't take it elsewhere (Codex on PR #87).
883  const forked = headRepo ? { baseRemote: remote, headRepo } : {}
884  stillDriving(run)
885  if (where.kind === 'here') {
886    await setActivity($, `git switch -c ${branch}`)
887    const switched = await gitWrite($, run, root, ['switch', '--no-track', '-c', branch, `${remote}/${base}`])
888    if (switched.exitCode !== 0) return block(`git switch failed: ${firstLine(switched.stderr)}`, 'check the repository state')
889    const here = { branch, workRoot: root, base, ...forked }
890    await recordMade($, run, here)
891    return here
892  }
893  const workRoot = worktreePath(root, where.worktreeDir, branch)
894  if (await $.fs.exists(workRoot)) return block(`${workRoot} already exists`, 'remove it, or set another worktreeDir')
895  await setActivity($, `git worktree add ${where.worktreeDir}/${branch}`)
896  // A full checkout can outlast git's 60 s on a slow disk: it runs like the profile's commands, on their timeout.
897  const made = { branch, workRoot, base, ...forked }
898  let added: CommandResult
899  try {
900    added = await runLong($, ['git', 'worktree', 'add', '--no-track', '-b', branch, workRoot, `${remote}/${base}`], root, where.minutes, GIT_ENV)
901  } catch (err) {
902    // A Stop during the add: the branch git already made is kept on the stopped run, like one made before it.
903    if (err instanceof Stopped && (await hasBranch($, root, branch))) await recordMade($, run, made)
904    throw err
905  }
906  if (added.exit !== 0) {
907    const failed = block(worktreeAddFailure(added, where.minutes), `check ${workRoot} (git worktree remove --force removes a half-made one)`)
908    // git creates the branch before it fills the folder: a branch left behind is kept, and resume adds the worktree for it.
909    if (!(await hasBranch($, root, branch))) return failed
910    await recordMade($, run, made)
911    return { ...failed, keep: made }
912  }
913  await recordMade($, run, made)
914  return made
915}
916
917/**
918 * What to do with a run's kept worktree, from git's own list: `fs.exists` can be wrong, and git run in a
919 * folder at that path that isn't the worktree works on the checkout above it (round 6).
920 */
921async function keptWorktree($: EngineInterface, root: string, workRoot: string): Promise<KeptWorktreePlan | Blocked> {
922  const listed = await git($, root, ['worktree', 'list', '--porcelain'])
923  if (listed.exitCode !== 0) return block(`git worktree list failed: ${firstLine(listed.stderr || listed.stdout) || `exit ${listed.exitCode}`}`, 'check git in this checkout')
924  return keptWorktreePlan(worktreeStateOf(listed.stdout, workRoot), await $.fs.exists(workRoot))
925}
926
927const strayFolder = (workRoot: string): Blocked => block(`${workRoot} is there but isn't the run's git worktree`, `move ${workRoot} aside`)
928
929/**
930 * Steps 1 and 2. A preflight that blocked after making its branch recorded it with the block, so a
931 * resume reuses that branch and folder instead of making another (Codex, PR #41).
932 */
933async function preflight($: EngineInterface, run: Run): Promise<RunEvent> {
934  await setActivity($, 'checking gh and the issue')
935  const unauthed = await authCheck($, run)
936  if (unauthed) return unauthed
937  // Every gh call of the run names this repository, and origin must be it (#25).
938  const resolved = run.ghRepo ? { repo: run.ghRepo } : await ghRepoOf($, run.root)
939  if ('type' in resolved) return resolved
940  const { repo } = resolved
941  // Origin is the project, or (#26) the contributor's fork with the project as another remote.
942  // Origin's URLs are read once and each repository named is asked about once (PR #87 review).
943  const urls = await originUrls($, run.root)
944  const views: Views = new Map()
945  const stray = await originCheck($, run.root, repo, urls, views)
946  const fork = stray ? await forkCheck($, run.root, repo, stray, urls, views) : null
947  if (fork && 'type' in fork) return fork
948  const remote = fork?.baseRemote ?? 'origin'
949  // Pinned once origin agrees (or is a fork of it), so a later block keeps it; an origin block leaves it open to a fix of the remotes.
950  if (!run.ghRepo) await recordPinned($, run, repo)
951  const issue = await issueCheck($, run, repo)
952  if ('type' in issue) return issue
953  let made: Kept
954  if (run.branch) {
955    made = { branch: run.branch, workRoot: workOf(run), base: run.base }
956    const moved = keptForkBlock(run.headRepo, fork?.headRepo, repo)
957    if (moved) return { ...block(moved.reason, moved.next), keep: made }
958    // A kept worktree that is gone (removed by hand, or never filled) is made again for the kept branch.
959    const kept = isWorktreeRun(run) ? await keptWorktree($, run.root, made.workRoot) : 'reuse'
960    if (typeof kept !== 'string') return { ...kept, keep: made }
961    if (kept === 'block') return { ...strayFolder(made.workRoot), keep: made }
962    if (kept === 'add') {
963      stillDriving(run)
964      // git refuses to add a folder deleted by hand while it is still registered: only this path's entry is cleared (no
965      // prune of others'). A folder git never registered just fails the remove.
966      await git($, run.root, ['worktree', 'remove', made.workRoot])
967      await setActivity($, `git worktree add ${made.workRoot}`)
968      const minutes = run.profile?.commandTimeoutMinutes ?? DEFAULT_COMMAND_MINUTES
969      const again = await runLong($, ['git', 'worktree', 'add', made.workRoot, made.branch], run.root, minutes, GIT_ENV)
970      if (again.exit !== 0) return { ...block(worktreeAddFailure(again, minutes), `check ${made.workRoot}`), keep: made }
971    }
972    // A kept worktree's own config can give origin other URLs (extensions.worktreeConfig): it is checked where it fetches.
973    const strayThere = made.workRoot === run.root ? null : await pushCheck($, made.workRoot, repo, fork?.headRepo)
974    if (strayThere) return { ...strayThere, keep: made }
975    const unfetched = await fetchBase($, made.workRoot, remote, made.base, repo)
976    if (unfetched) return { ...unfetched, keep: made }
977    // A fix pushed to the base (the profile, a missing command) reaches the kept branch before it is checked again.
978    const behind = await catchUp($, run, made.workRoot, remote, made.base)
979    if (behind) return { ...behind, keep: made }
980  } else {
981    const fresh = await makeBranch($, { ...run, title: issue.title }, issue.body, remote, repo, fork?.headRepo)
982    if ('type' in fresh) return fresh
983    made = fresh
984  }
985  try {
986    const mergeBase = (await gitOut($, made.workRoot, ['merge-base', 'HEAD', `${remote}/${made.base}`])).trim()
987    // The profile whose commands run is the base's, not the checkout the run started from (which anyone may have pushed).
988    await setActivity($, `reading ${PROFILE_FILE} from ${remote}/${made.base}`)
989    const trusted = await loadProfile($, made.workRoot)
990    if (!trusted.profile) return { ...block(`${remote}/${made.base} has no valid ${PROFILE_FILE}: ${trusted.errors[0] ?? 'missing'}`, `commit ${PROFILE_FILE} to ${made.base}`), keep: made }
991    return { type: 'preflighted', title: issue.title, body: issue.body, url: issue.url, base: made.base, branch: made.branch, mergeBase, profile: trusted.profile, workRoot: made.workRoot, repo, ...fork }
992  } catch (err) {
993    if (err instanceof Stopped) throw err
994    return { ...block(`unexpected: ${err instanceof Error ? err.message : String(err)}`, 'see /issue-forge status'), keep: made }
995  }
996}
997
998/**
999 * Before each step that runs coverage (baseline, gates G and F), on a fresh start or a resume: one LCOV file can't
1000 * serve two runs at once (Change 0.3.0; Codex 4278707573).
1001 */
1002async function lcovShared($: EngineInterface, run: Run, p: Profile): Promise<Blocked | null> {
1003  const shared = sharedLcovBlock(p, (await runsOf($, run.repo ?? run.root)).filter(r => r.id !== run.id))
1004  return shared ? block(shared.reason, shared.next) : null
1005}
1006
1007/**
1008 * Step 3. A branch with no commits of its own first catches up with the base (so "fix main, then resume"
1009 * works), and the merge base is computed afresh every time, so a blocked baseline never leaves a stale one.
1010 */
1011async function baseline($: EngineInterface, run: Run, p: Profile): Promise<RunEvent> {
1012  const shared = await lcovShared($, run, p)
1013  if (shared) return shared
1014  const root = workOf(run)
1015  const remote = baseRemoteOfRun(run)
1016  const unfetched = await fetchBase($, root, remote, run.base, run.ghRepo)
1017  if (unfetched) return unfetched
1018  const behind = await catchUp($, run, root, remote, run.base)
1019  if (behind) return behind
1020  const mergeBase = (await gitOut($, root, ['merge-base', 'HEAD', baseRefOf(run)])).trim()
1021  await setActivity($, 'running tests')
1022  const tests = await runLong($, p.testCommand, root, p.commandTimeoutMinutes)
1023  if (tests.exit !== 0) return block(tests.isTimedOut ? 'tests time out before any change' : 'tests fail before any change', `fix ${run.base}`)
1024  await setActivity($, 'running coverage')
1025  const since = await $.clock.now()
1026  const cov = await runLong($, p.coverageCommand, root, p.commandTimeoutMinutes)
1027  const text = cov.exit === 0 ? await readLcov($, lcovFileOf(p, root), since) : null
1028  const lcov = text === null ? null : parseLcov(text, root)
1029  if (!lcov || !hasSourceRecord(p, lcov)) return block('coverage produced no data', `check coverageCommand and lcovPath in ${PROFILE_FILE}`)
1030  return { type: 'baselined', baseline: totals(p, lcov), mergeBase }
1031}
1032
1033function waitForAnswer(agentId: string): Promise<string> {
1034  return new Promise((resolve, reject) => {
1035    waiting[agentId] = { resolve, reject }
1036  })
1037}
1038
1039/** A phase: spawn its agent (named, so duet leaves it on Claude) and wait for its turn.complete. */
1040async function phase($: EngineInterface, run: Run, which: Phase, p: Profile): Promise<RunEvent | null> {
1041  const last = run.lastGate
1042  // A retry is this phase's own gate having failed last (an attempt count resets on resume; the failure stays relevant).
1043  const isRetry = !!last && !last.passed && last.gate === (which === 'red' ? 'R' : which === 'green' ? 'G' : 'F')
1044  const redTestPaths = which === 'green' && run.redCommit ? splitZ(await gitOut($, workOf(run), ['diff', '--name-only', '-z', run.mergeBase, run.redCommit])) : undefined
1045  const prompt = phasePrompt(which, run, {
1046    failures: isRetry && last ? failuresOf(last) : undefined,
1047    behaviourFor: which === 'red' ? run.behaviourFor : undefined,
1048    redTestPaths,
1049    testTail: which === 'green' && !isRetry ? run.redTestTail : isRetry && last?.checks.some(c => (c.id === 'G2' || c.id === 'R3') && !c.passed) ? run.lastTestTail : undefined,
1050  })
1051  const subagentType = which === 'red' ? options.redAgentType : `issue-forge:${which}`
1052  const isOwn = subagentType.startsWith('issue-forge:')
1053  const routed = routedRedBlock(subagentType, isWorktreeRun(run))
1054  if (routed) return block(routed.reason, routed.next)
1055  const name = isOwn ? agentName(run, which) : undefined
1056  // Known before the spawn returns, so an agent that stops sooner still has its hand-back taken.
1057  if (name) knownRefs.add(name)
1058  const model = which === 'red' ? options.redModel : which === 'green' ? options.greenModel : options.refactorModel
1059  await setActivity($, `starting the ${which} agent`)
1060  const baseManifests = [...(await manifestsAt($, workOf(run), run.mergeBase)), ...(await manifestsAt($, workOf(run), baseRefOf(run)))]
1061  const spawned = await $.agent.spawn({ subagentType, prompt, description: `issue-forge #${run.issue} ${which}`, model, cwd: workOf(run), ...(name ? { name } : {}) })
1062  const agentId = spawned.agentId
1063  // An agent another plugin answered in-line (duet's Codex) has no loop and no turn.complete (C0).
1064  const isInline = !!agentId && (spawned.model.startsWith('codex') || agentId.startsWith('duet-'))
1065  // Wait before any other await, so a fast agent's turn.complete finds its waiter.
1066  const answer = agentId && !isInline ? waitForAnswer(agentId) : null
1067  void answer?.catch(() => undefined)
1068  // Steer the agent from its first tool call: set before any other await, like the waiter.
1069  if (agentId) {
1070    liveAgent = agentId
1071    liveWork = { root: run.root, workRoot: workOf(run), testGlobs: run.profile?.testGlobs ?? [], baseManifests }
1072  }
1073  // A stop (or another run) while the spawn was pending wins: the agent is only refused tools.
1074  const latest = await loadRun($, keyOf(run))
1075  if (!latest || latest.id !== run.id || latest.step !== run.step || !isDriving(run)) {
1076    if (agentId) {
1077      delete waiting[agentId]
1078      if (liveAgent === agentId) {
1079        liveAgent = null
1080        liveWork = null
1081      }
1082      await rememberAgents($, run, spawnRefs(agentId, name, spawned.teammateId))
1083    }
1084    return null
1085  }
1086  if (spawned.deny !== undefined || !agentId) return block(`the ${which} agent didn't start: ${spawned.deny ?? 'no agent id'}`, 'check the agent type and model options')
1087  await rememberAgents($, run, spawnRefs(agentId, name, spawned.teammateId))
1088  await saveRun($, advance(latest, { type: 'spawned', agent: { id: agentId, ...(name ? { name } : {}), phase: which } }, await $.clock.now()), (run as Stamped).driveGen)
1089  await setActivity($, 'agent working')
1090  try {
1091    return { type: 'agentDone', answer: answer ? await answer : '' }
1092  } finally {
1093    if (liveAgent === agentId) {
1094      liveAgent = null
1095      liveWork = null
1096    }
1097  }
1098}
1099
1100async function headOf($: EngineInterface, root: string): Promise<string> {
1101  return (await gitOut($, root, ['rev-parse', 'HEAD'])).trim()
1102}
1103
1104async function stagedSince($: EngineInterface, root: string, ref: string): Promise<string[]> {
1105  return splitZ(await gitOut($, root, ['diff', '--cached', '--name-only', '-z', ref]))
1106}
1107
1108/** The guard refused a manifest: the driven run records it, so the phase's gate blocks on it (Change 0.7.0); never a run another session took over. */
1109async function recordNewPlugin($: EngineInterface, path: string): Promise<void> {
1110  const d = drivingNow()
1111  if (!d) return
1112  const now = await $.clock.now()
1113  await updateRun($, d.key, stored => newPluginRecord(stored, d.runId, sessionId, path, now))
1114}
1115
1116/** The plugin manifests at a commit (the merge base, or the base branch's tip): none is new (Codex 4290574826; owner). None when git can't list them. */
1117async function manifestsAt($: EngineInterface, root: string, ref: string): Promise<string[]> {
1118  const listed = await git($, root, ['ls-tree', '-r', '--name-only', '-z', ref]).catch(() => null)
1119  return listed?.exitCode === 0 ? splitZ(listed.stdout).filter(isPluginManifest) : []
1120}
1121
1122/**
1123 * R4 and G8 (Change 0.7.0): a manifest added since the merge base (renames off: a moved one is added), or the one the guard
1124 * refused, unless the base branch's tip has it. Checked before any test runs.
1125 */
1126async function newPluginAt($: EngineInterface, run: Run, root: string) {
1127  const added = splitZ(await gitOut($, root, ['diff', '--cached', '--no-renames', '--name-only', '--diff-filter=A', '-z', run.mergeBase]))
1128  return newPluginOf(added, run.newPlugin, run.profile?.testGlobs ?? [], await manifestsAt($, root, baseRefOf(run)))
1129}
1130
1131/** Gate R (§3 step 4); on pass, the red commit, and after G6's return, green's work restored by its SHA. */
1132async function gateRStep($: EngineInterface, run: Run, p: Profile): Promise<RunEvent> {
1133  const root = workOf(run)
1134  const away = await offBranch($, run)
1135  if (away) return away
1136  stillDriving(run)
1137  await setActivity($, 'staging')
1138  await gitWriteOut($, run, root, ['add', '-A'])
1139  const madeR = await newPluginAt($, run, root)
1140  if (madeR) return { type: 'gated', gate: newPluginGate('R', madeR) }
1141  // The behaviour pass must add a test of its own: judge it against the red commit, not the merge base.
1142  const staged = await stagedSince($, root, run.behaviourFor && run.redCommit ? run.redCommit : run.mergeBase)
1143  await setActivity($, 'running tests')
1144  const tests = await runLong($, p.testCommand, root, p.commandTimeoutMinutes)
1145  const gate = gateR(p, { staged, tests })
1146  if (!gate.passed) return { type: 'gated', gate, testTail: tests.tail }
1147  const isBehaviour = !!run.behaviourFor
1148  // Nothing new to commit means a replay after a reload: the gated tests are HEAD already.
1149  const commit = (await commitAll($, run, commitMessage(isBehaviour ? 'behaviour' : 'red', run, run.behaviourFor ?? []))) ?? (await headOf($, root))
1150  if (isBehaviour && run.greenStash) {
1151    const stash = run.greenStash
1152    stillDriving(run)
1153    const restore = (reason: string) => ({ type: 'gated' as const, gate, commit, testTail: tests.tail, blockAfter: { reason, next: `restore green's work by hand (git stash apply ${stash.sha.slice(0, 7)})` } })
1154    if (!hasStash(await gitOut($, root, STASH_LIST_ARGV.slice(1)), stash.sha)) return restore(`stash ${stash.sha.slice(0, 7)} "${stash.message}" is gone`)
1155    // The commit is made: a lock that outlasts the apply blocks after it, naming the stash, as any failed restore does.
1156    let applied: ProcessRunResult
1157    try {
1158      applied = await gitWrite($, run, root, stashApplyArgv(stash).slice(1))
1159    } catch (err) {
1160      if (err instanceof Locked) return restore(`${err.message}; green's work is in stash ${stash.sha.slice(0, 7)} "${stash.message}"`)
1161      throw err
1162    }
1163    if (applied.exitCode !== 0) return restore(`could not restore green's work; it is in stash ${stash.sha.slice(0, 7)} "${stash.message}"`)
1164  }
1165  return { type: 'gated', gate, commit, testTail: tests.tail }
1166}
1167
1168/** Gates G and F (§3 steps 5–6): G1–G7 over facts gathered here, judged in the logic module. */
1169async function gateGStep($: EngineInterface, run: Run, p: Profile, which: 'G' | 'F'): Promise<RunEvent> {
1170  const shared = await lcovShared($, run, p)
1171  if (shared) return shared
1172  const root = workOf(run)
1173  const redCommit = run.redCommit ?? run.mergeBase
1174  const away = await offBranch($, run)
1175  if (away) return away
1176  stillDriving(run)
1177  await setActivity($, 'staging')
1178  await gitWriteOut($, run, root, ['add', '-A'])
1179  const madeG = await newPluginAt($, run, root)
1180  if (madeG) return { type: 'gated', gate: newPluginGate(which, madeG) }
1181  const staged = await stagedSince($, root, run.mergeBase)
1182  const changedSinceRed = await stagedSince($, root, redCommit)
1183  const redPaths = splitZ(await gitOut($, root, ['diff', '--name-only', '-z', run.mergeBase, redCommit]))
1184  await setActivity($, 'running tests')
1185  const tests = await runLong($, p.testCommand, root, p.commandTimeoutMinutes)
1186  await setActivity($, 'running coverage')
1187  const since = await $.clock.now()
1188  const coverage = await runLong($, p.coverageCommand, root, p.commandTimeoutMinutes)
1189  const lcov = coverage.exit === 0 ? await readLcov($, lcovFileOf(p, root), since) : null
1190  // Fixed prefixes and no external diff: diff.mnemonicPrefix or diff.noprefix would hide every changed line.
1191  const diff = await gitOut($, root, ['diff', '-U0', '--cached', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', run.mergeBase])
1192  const checks: { argv: string[]; result: CommandResult }[] = []
1193  for (const [i, argv] of p.checks.entries()) {
1194    await setActivity($, `checks ${i + 1}/${p.checks.length}`)
1195    checks.push({ argv, result: await runLong($, argv, root, p.commandTimeoutMinutes) })
1196  }
1197  // A command that rewrote files (a formatter, a snapshot update) means the tests ran on bytes that aren't staged.
1198  const rewritten = unstagedOf(await gitOut($, root, ['status', '--porcelain', '-z']))
1199  if (rewritten.length > 0) return block(`a command changed files after staging, so the tested bytes aren't the ones that would be committed: ${rewritten.slice(0, 5).join(', ')}${rewritten.length > 5 ? ` and ${rewritten.length - 5} more` : ''}`, 'make the profile commands leave the tree as they found it (or commit what they write)')
1200  const gate: Gate = gateG(p, run.baseline, { staged, changedSinceRed, redPaths, tests, coverage, lcov, diff, checks }, root, which)
hooks/issue-forge-logic.ts 2221 lines
1/**
2 * Issue Forge's rules, free of `$` so tests can call them (SPEC "How it works").
3 * Sections: profile, globs and paths, coverage, gates, state, prompts, view, demo.
4 * Section numbers like §4 refer to docs/specs/issue-forge.md.
5 */
6
7import type { Activity, AgentRef, Baseline, Check, Coverage, Driver, Gate, MergeCheck, NewPlugin, OtherRun, Pct, Phase, Profile, Run, StashRef, Step, Workspace } from '../types'
8
9export type { Activity, AgentRef, Baseline, Check, Coverage, Driver, Gate, MergeCheck, NewPlugin, OtherRun, Pct, Phase, Profile, Run, StashRef, Step, Workspace }
10
11export const NAME = 'issue-forge'
12export const PROFILE_FILE = '.issue-forge.json'
13export const PHASES: Phase[] = ['red', 'green', 'refactor']
14
15// ── Profile (§4) ──────────────────────────────────────────────────────────
16
17const DEFAULTS = {
18  baseBranch: 'main',
19  excludeGlobs: [] as string[],
20  unmeasuredGlobs: [] as string[],
21  behaviourTestGlobs: [] as string[],
22  checks: [] as string[][],
23  threshold: 95,
24  regressionTolerance: 0,
25  attempts: { red: 2, green: 3, refactor: 1 },
26  commandTimeoutMinutes: 15,
27  notes: '',
28  commitTrailers: [] as string[],
29  workspace: 'ask' as Workspace,
30  worktreeDir: 'scratch/issue-forge',
31}
32
33export const WORKSPACES: readonly Workspace[] = ['ask', 'worktree', 'here']
34/** The command timeout before a run has its profile (a preflight blocked before it was read). */
35export const DEFAULT_COMMAND_MINUTES = DEFAULTS.commandTimeoutMinutes
36
37/** `.git` as a case-insensitive filesystem and Windows read it: any case, trailing dots and spaces dropped. */
38const isGitDir = (segment: string): boolean => segment.toLowerCase().replace(/[. ]+$/, '') === '.git'
39
40/** A relative path with no `..` segment that names a folder inside the repository's working tree (`D:tmp` is on drive D; `.git` is git's own). */
41function isInsideDir(v: unknown): v is string {
42  if (typeof v !== 'string' || isAbsolutePath(v) || /^[\\/]/.test(v) || /^[A-Za-z]:/.test(v)) return false
43  const parts = normalizePath(v).split('/')
44  return parts[0] !== '' && !parts.includes('..') && !parts.some(isGitDir)
45}
46
47
48const isStrings = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => typeof x === 'string')
49
50/**
51 * A branch name safe to hand git as an argument: letters, digits, `.`, `_`, `-`, `/`; not starting
52 * with `-` (an option) or `+` (a forced refspec), no `..`, `//` or `@{`, no trailing `/`, `.` or `.lock`.
53 */
54export function isSafeBranch(name: string): boolean {
55  return (
56    /^[A-Za-z0-9._/-]+$/.test(name) &&
57    !/^[-+./]/.test(name) &&
58    !/\.\.|\/\/|@\{/.test(name) &&
59    !/[/.]$/.test(name) &&
60    !name.endsWith('.lock')
61  )
62}
63
64/** The glob syntax the design promises (§4.1): `*`, `**`, `?`. Braces, brackets and `!` are refused, not matched literally. */
65const isGlobs = (v: unknown): v is string[] => isStrings(v) && v.every(g => g.trim() !== '' && !/[{}[\]!]/.test(g))
66const isArgv = (v: unknown): v is string[] => isStrings(v) && v.length > 0 && v[0] !== ''
67
68/** `~` or `~/x` from `home`; anything else unchanged. */
69export function expandHome(path: string, home: string): string {
70  if (path === '~') return home
71  if (path.startsWith('~/')) return `${home.replace(/\/+$/, '')}/${path.slice(2)}`
72  return path
73}
74
75/**
76 * Parses and type-checks `.issue-forge.json` (§4.1, §4.2's first two rules). The rules that need
77 * the outside world (lcovPath ignored, commands on PATH) are `outsideChecks` + `profileProblems`.
78 */
79export function parseProfile(text: string, home: string): { profile?: Profile; errors: string[] } {
80  let raw: unknown
81  try {
82    raw = JSON.parse(text)
83  } catch (err) {
84    return { errors: [`${PROFILE_FILE} is not valid JSON: ${(err as Error).message}`] }
85  }
86  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { errors: [`${PROFILE_FILE} must hold a JSON object`] }
87  const o = raw as Record<string, unknown>
88  const errors: string[] = []
89  const need = (key: string, ok: (v: unknown) => boolean, what: string) => {
90    if (o[key] === undefined) errors.push(`${key} is required (${what})`)
91    else if (!ok(o[key])) errors.push(`${key} must be ${what}`)
92  }
93  const may = (key: string, ok: (v: unknown) => boolean, what: string) => {
94    if (o[key] !== undefined && !ok(o[key])) errors.push(`${key} must be ${what}`)
95  }
96  const nonEmptyStrings = (v: unknown) => isStrings(v) && v.length > 0
97  need('testCommand', isArgv, 'a non-empty array of strings (argv)')
98  need('coverageCommand', isArgv, 'a non-empty array of strings (argv)')
99  need('lcovPath', v => typeof v === 'string' && v !== '', 'a non-empty string')
100  const nonEmptyGlobs = (v: unknown) => isGlobs(v) && v.length > 0
101  const GLOBS = 'non-empty globs using only * ** ? (no braces, brackets or !)'
102  need('sourceGlobs', nonEmptyGlobs, `a non-empty array of ${GLOBS}`)
103  need('testGlobs', nonEmptyGlobs, `a non-empty array of ${GLOBS}`)
104  may('baseBranch', v => typeof v === 'string' && isSafeBranch(v), 'a branch name (letters, digits, . _ - /; not starting with - or +)')
105  for (const key of ['excludeGlobs', 'unmeasuredGlobs', 'behaviourTestGlobs']) may(key, isGlobs, `an array of ${GLOBS}`)
106  may('commitTrailers', isStrings, 'an array of strings')
107  may('checks', v => Array.isArray(v) && v.every(isArgv), 'an array of argv arrays')
108  may('threshold', v => typeof v === 'number' && v >= 0 && v <= 100, 'a number from 0 to 100')
109  may('regressionTolerance', v => typeof v === 'number' && v >= 0, 'a number ≥ 0')
110  may('commandTimeoutMinutes', v => typeof v === 'number' && v > 0, 'a number > 0')
111  may('notes', v => typeof v === 'string', 'a string')
112  may('workspace', v => WORKSPACES.includes(v as Workspace), 'one of "ask", "worktree", "here"')
113  may('worktreeDir', isInsideDir, 'a relative path inside the repository (no .. segment)')
114  const isCount = (v: unknown) => v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= 1)
115  may(
116    'attempts',
117    v => v !== null && typeof v === 'object' && !Array.isArray(v) && ['red', 'green', 'refactor'].every(k => isCount((v as Record<string, unknown>)[k])),
118    'an object { red, green, refactor } of whole numbers ≥ 1',
119  )
120  const nonEmpty = (v: unknown) => Array.isArray(v) && v.length > 0
121  if (nonEmpty(o.unmeasuredGlobs) && !nonEmpty(o.behaviourTestGlobs)) errors.push('behaviourTestGlobs must name at least one glob when unmeasuredGlobs does (G6 could never pass)')
122  if (errors.length > 0) return { errors }
123  const attempts = (o.attempts ?? {}) as Partial<Profile['attempts']>
124  const profile: Profile = {
125    baseBranch: (o.baseBranch as string | undefined) ?? DEFAULTS.baseBranch,
126    testCommand: o.testCommand as string[],
127    coverageCommand: o.coverageCommand as string[],
128    lcovPath: expandHome(o.lcovPath as string, home),
129    sourceGlobs: o.sourceGlobs as string[],
130    testGlobs: o.testGlobs as string[],
131    excludeGlobs: (o.excludeGlobs as string[] | undefined) ?? DEFAULTS.excludeGlobs,
132    unmeasuredGlobs: (o.unmeasuredGlobs as string[] | undefined) ?? DEFAULTS.unmeasuredGlobs,
133    behaviourTestGlobs: (o.behaviourTestGlobs as string[] | undefined) ?? DEFAULTS.behaviourTestGlobs,
134    checks: (o.checks as string[][] | undefined) ?? DEFAULTS.checks,
135    threshold: (o.threshold as number | undefined) ?? DEFAULTS.threshold,
136    regressionTolerance: (o.regressionTolerance as number | undefined) ?? DEFAULTS.regressionTolerance,
137    attempts: {
138      red: attempts.red ?? DEFAULTS.attempts.red,
139      green: attempts.green ?? DEFAULTS.attempts.green,
140      refactor: attempts.refactor ?? DEFAULTS.attempts.refactor,
141    },
142    commandTimeoutMinutes: (o.commandTimeoutMinutes as number | undefined) ?? DEFAULTS.commandTimeoutMinutes,
143    notes: (o.notes as string | undefined) ?? DEFAULTS.notes,
144    commitTrailers: (o.commitTrailers as string[] | undefined) ?? DEFAULTS.commitTrailers,
145    workspace: (o.workspace as Workspace | undefined) ?? DEFAULTS.workspace,
146    worktreeDir: normalizePath((o.worktreeDir as string | undefined) ?? DEFAULTS.worktreeDir),
147  }
148  return { profile, errors: [] }
149}
150
151/** What the module must look up before the profile counts as valid (§4.2's last two rules). */
152export type OutsideChecks = {
153  /** The LCOV path relative to the root when it is inside: `git check-ignore -q` must pass. */
154  lcovInside: string | null
155  /** Commands named by a path (must exist, relative to the root) or a name (must be on PATH). */
156  commands: { argv0: string; kind: 'path' | 'name' }[]
157}
158
159export function outsideChecks(p: Profile, root: string): OutsideChecks {
160  const abs = isAbsolutePath(p.lcovPath) ? normalizePath(p.lcovPath) : normalizePath(`${root}/${p.lcovPath}`)
161  const argv0s = [...new Set([p.testCommand[0] as string, p.coverageCommand[0] as string, ...p.checks.map(c => c[0] as string)])]
162  return {
163    lcovInside: relativeTo(root, abs),
164    commands: argv0s.map(argv0 => ({ argv0, kind: /[\\/]/.test(argv0) ? 'path' : 'name' })),
165  }
166}
167
168/** The §4.2 failures the outside checks found; empty when the profile holds. */
169export function profileProblems(p: Profile, facts: { isLcovIgnored: boolean | null; missing: string[] }): string[] {
170  const problems: string[] = []
171  if (facts.isLcovIgnored === false) problems.push(`lcovPath ${p.lcovPath} is inside the repository and not ignored by git; git add -A would commit it`)
172  for (const argv0 of facts.missing) problems.push(`command not found: ${argv0}`)
173  return problems
174}
175
176/**
177 * The base the starting checkout's profile names. That copy only picks the base: the run checks
178 * origin/<base>'s, so a stale, broken or missing one here reads as the default, and an unsafe name too.
179 */
180export function checkoutBaseOf(text: string | null): string {
181  try {
182    const o = text === null ? null : (JSON.parse(text) as unknown)
183    const named = o !== null && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>).baseBranch : undefined
184    return typeof named === 'string' && isSafeBranch(named) ? named : DEFAULTS.baseBranch
185  } catch {
186    return DEFAULTS.baseBranch
187  }
188}
189
190/**
191 * §2: the issue's first `Base branch: x` line, else the profile's. The name reaches git, and anyone who
192 * can file an issue writes it, so a name `isSafeBranch` refuses is ignored (the profile's base stands).
193 */
194export function baseOf(p: Pick<Profile, 'baseBranch'>, issueBody: string): string {
195  for (const line of issueBody.split(/\r?\n/)) {
196    const m = line.match(/^Base branch: (\S+)$/)
197    if (m?.[1]) return isSafeBranch(m[1]) ? m[1] : p.baseBranch
198  }
199  return p.baseBranch
200}
201
202// ── Globs and paths ───────────────────────────────────────────────────────
203
204/** `*` within a segment, `**` any depth (a leading `**\/` also matches none), `?` one character. */
205export function globToRegExp(glob: string): RegExp {
206  let out = ''
207  for (let i = 0; i < glob.length; i++) {
208    const c = glob.charAt(i)
209    if (c === '*' && glob.charAt(i + 1) === '*') {
210      const before = i === 0 || glob.charAt(i - 1) === '/'
211      const after = glob.charAt(i + 2)
212      if (before && after === '/') {
213        out += '(?:.*/)?'
214        i += 2
215      } else {
216        out += '.*'
217        i += 1
218      }
219    } else if (c === '*') out += '[^/]*'
220    else if (c === '?') out += '[^/]'
221    else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
222  }
223  return new RegExp(`^${out}$`)
224}
225
226export function matchesAny(path: string, globs: readonly string[]): boolean {
227  return globs.some(glob => globToRegExp(glob).test(path))
228}
229
230export type PathKind = 'test' | 'measured' | 'unmeasured' | 'other'
231
232/** §2: test files win; source is sourceGlobs minus testGlobs and excludeGlobs. */
233export function kindOf(p: Profile, path: string): PathKind {
234  if (matchesAny(path, p.testGlobs)) return 'test'
235  if (!matchesAny(path, p.sourceGlobs) || matchesAny(path, p.excludeGlobs)) return 'other'
236  return matchesAny(path, p.unmeasuredGlobs) ? 'unmeasured' : 'measured'
237}
238
239/** Where the coverage command writes LCOV: a relative `lcovPath` is relative to the repository root, as the command runs there. */
240export function lcovFileOf(p: Profile, root: string): string {
241  return isAbsolutePath(p.lcovPath) ? p.lcovPath : `${root.replace(/\/+$/, '')}/${p.lcovPath}`
242}
243
244/** `/x` or a Windows drive path (`C:\x`, `C:/x`). */
245export function isAbsolutePath(path: string): boolean {
246  return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
247}
248
249/** `/`-separated, `.` and `..` resolved, no trailing `/`; a UNC root's two leading slashes are kept. */
250export function normalizePath(path: string): string {
251  const isAbsolute = isAbsolutePath(path)
252  const parts: string[] = []
253  for (const part of path.replace(/\\/g, '/').split('/')) {
254    if (part === '' || part === '.') continue
255    if (part === '..') {
256      if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
257      else if (!isAbsolute) parts.push('..')
258    } else parts.push(part)
259  }
260  const joined = parts.join('/')
261  const lead = /^\/\/(?!\/)/.test(path) ? '//' : path.startsWith('/') ? '/' : ''
262  return `${lead}${joined}`
263}
264
265/** `abs` relative to `root`, or null when it is outside. */
266export function relativeTo(root: string, abs: string): string | null {
267  const r = normalizePath(root)
268  const a = normalizePath(abs)
269  if (a === r) return ''
270  const prefix = r.endsWith('/') ? r : `${r}/`
271  return a.startsWith(prefix) ? a.slice(prefix.length) : null
272}
273
274/**
275 * From `git status --porcelain -z`: paths whose working-tree bytes differ from the index (Y column) or
276 * that are untracked. After `git add -A` there should be none; any left were written by a command since.
277 */
278export function unstagedOf(porcelainZ: string): string[] {
279  const out: string[] = []
280  const entries = porcelainZ.split('\0')
281  for (let i = 0; i < entries.length; i++) {
282    const e = entries[i] as string
283    if (e.length < 4) continue
284    const [x, y, path] = [e.charAt(0), e.charAt(1), e.slice(3)]
285    if (x === 'R' || x === 'C') i++
286    if (y !== ' ') out.push(path)
287  }
288  return out
289}
290
291/** NUL-separated `git … -z` output as paths. */
292export function splitZ(out: string): string[] {
293  return out.split('\0').filter(path => path !== '')
294}
295
296// ── Branch name (§3 step 2) ───────────────────────────────────────────────
297
298export function slugOf(title: string): string {
299  return title
300    .toLowerCase()
301    .replace(/[^a-z0-9]+/g, '-')
302    .replace(/^-+|-+$/g, '')
303    .slice(0, 40)
304    .replace(/-+$/, '')
305}
306
307/** `issue-<N>-<slug>`, then `-2`, `-3`, … past every name in `taken`. */
308export function branchName(issue: number, title: string, taken: readonly string[]): string {
309  const slug = slugOf(title)
310  const first = slug ? `issue-${issue}-${slug}` : `issue-${issue}`
311  const used = new Set(taken)
312  if (!used.has(first)) return first
313  for (let k = 2; ; k++) if (!used.has(`${first}-${k}`)) return `${first}-${k}`
314}
315
316// ── Coverage (§5) ─────────────────────────────────────────────────────────
317
318/** Undoes git's C-style quoting of an unusual path (`"b/t\303\251st"`). */
319function unquote(path: string): string {
320  if (!path.startsWith('"') || !path.endsWith('"')) return path
321  const bytes: number[] = []
322  const body = path.slice(1, -1)
323  for (let i = 0; i < body.length; i++) {
324    const c = body.charAt(i)
325    if (c !== '\\') {
326      for (const b of new TextEncoder().encode(c)) bytes.push(b)
327      continue
328    }
329    const next = body.charAt(i + 1)
330    const octal = body.slice(i + 1, i + 4)
331    if (/^[0-7]{3}$/.test(octal)) {
332      bytes.push(parseInt(octal, 8))
333      i += 3
334    } else {
335      const map: Record<string, string> = { n: '\n', t: '\t', '"': '"', '\\': '\\' }
336      for (const b of new TextEncoder().encode(map[next] ?? next)) bytes.push(b)
337      i += 1
338    }
339  }
340  return new TextDecoder().decode(new Uint8Array(bytes))
341}
342
343/**
344 * Added line numbers per file from `git diff -U0` (§2 "Changed lines"): `@@ -a,b +c,d @@` adds
345 * `c … c+d−1`; `+c` alone is one line; `d = 0` adds none. A deleted file has none.
346 */
347export function parseDiff(text: string): Record<string, number[]> {
348  const changed: Record<string, number[]> = Object.create(null)
349  let current: string | null = null
350  for (const line of text.split('\n')) {
351    if (line.startsWith('diff --git ')) current = null
352    else if (line.startsWith('+++ ')) {
353      const target = line.slice(4).replace(/\t$/, '')
354      current = target === '/dev/null' ? null : unquote(target).replace(/^b\//, '')
355      if (current !== null) changed[current] = changed[current] ?? []
356    } else if (line.startsWith('@@') && current !== null) {
357      const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
358      if (!m?.[1]) continue
359      const start = Number(m[1])
360      const count = m[2] === undefined ? 1 : Number(m[2])
361      const lines = changed[current] as number[]
362      for (let n = start; n < start + count; n++) lines.push(n)
363      changed[current] = lines
364    }
365  }
366  return changed
367}
368
369/** A count from LCOV text; anything that isn't a number (`-` included) is 0. */
370const num = (text: string | undefined): number => Number(text) || 0
371
372export type LcovFile = {
373  path: string
374  lines: Record<number, number>
375  branches: { line: number; taken: number }[]
376  lf: number
377  lh: number
378  brf: number
379  brh: number
380}
381
382/**
383 * The §5.2 records, each `SF` made repository-relative (a path outside the repository is
384 * dropped). `BRDA` taken `-` counts as 0. Two records for one file are merged.
385 */
386export function parseLcov(text: string, root: string): Record<string, LcovFile> {
387  const files: Record<string, LcovFile> = Object.create(null)
388  let cur: LcovFile | null = null
389  let isDuplicate = false
390  const finish = () => {
391    if (!cur) return
392    const prev = files[cur.path]
393    if (prev) {
394      isDuplicate = true
395      for (const [line, hits] of Object.entries(cur.lines)) prev.lines[Number(line)] = (prev.lines[Number(line)] ?? 0) + hits
396      prev.branches.push(...cur.branches)
397    } else files[cur.path] = cur
398    cur = null
399  }
400  for (const raw of text.split(/\r?\n/)) {
401    const line = raw.trim()
402    if (line.startsWith('SF:')) {
403      finish()
404      const sf = line.slice(3)
405      const isAbs = isAbsolutePath(sf)
406      const rel = isAbs ? relativeTo(root, sf) : normalizePath(sf)
407      cur = rel === null || rel.startsWith('..') ? null : { path: rel, lines: {}, branches: [], lf: 0, lh: 0, brf: 0, brh: 0 }
408      if (cur === null) cur = { path: '\0dropped', lines: {}, branches: [], lf: 0, lh: 0, brf: 0, brh: 0 }
409    } else if (!cur) continue
410    else if (line.startsWith('DA:')) {
411      const [n, hits] = line.slice(3).split(',')
412      cur.lines[num(n)] = num(hits)
413    } else if (line.startsWith('BRDA:')) {
414      const parts = line.slice(5).split(',')
415      cur.branches.push({ line: num(parts[0]), taken: num(parts[3]) })
416    } else if (line.startsWith('LF:')) cur.lf = num(line.slice(3))
417    else if (line.startsWith('LH:')) cur.lh = num(line.slice(3))
418    else if (line.startsWith('BRF:')) cur.brf = num(line.slice(4))
419    else if (line.startsWith('BRH:')) cur.brh = num(line.slice(4))
420    else if (line === 'end_of_record') finish()
421  }
422  finish()
423  delete files['\0dropped']
424  if (isDuplicate) {
425    for (const f of Object.values(files)) {
426      const hits = Object.values(f.lines)
427      f.lf = hits.length
428      f.lh = hits.filter(h => h > 0).length
429      f.brf = f.branches.length
430      f.brh = f.branches.filter(b => b.taken > 0).length
431    }
432  }
433  return files
434}
435
436export function round2(x: number): number {
437  return Math.round(x * 100) / 100
438}
439
440export function pct(part: number, whole: number): Pct {
441  return whole === 0 ? null : round2((part / whole) * 100)
442}
443
444export function pctText(x: Pct): string {
445  return x === null ? 'n/a' : `${x.toFixed(2)}%`
446}
447
448/** `[3,4,5,9]` → `3-5, 9`. */
449export function ranges(lines: readonly number[]): string {
450  const sorted = [...new Set(lines)].sort((a, b) => a - b)
451  const out: string[] = []
452  for (let i = 0; i < sorted.length; ) {
453    let j = i
454    while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] as number) + 1) j++
455    out.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`)
456    i = j + 1
457  }
458  return out.join(', ')
459}
460
461export type DiffCoverage = {
462  lines: { covered: number; measured: number; pct: Pct }
463  branches: { taken: number; total: number; pct: Pct }
464  uncovered: string[]
465}
466
467/** §5.3 steps 2–4 and 6. A changed measured file missing from LCOV counts every changed line uncovered. */
468export function diffCoverage(p: Profile, changed: Record<string, number[]>, lcov: Record<string, LcovFile>): DiffCoverage {
469  let covered = 0
470  let measured = 0
471  let taken = 0
472  let total = 0
473  const uncovered: string[] = []
474  for (const [path, lines] of Object.entries(changed).sort(([a], [b]) => a.localeCompare(b))) {
475    if (kindOf(p, path) !== 'measured' || lines.length === 0) continue
476    const file = lcov[path]
477    const missed: number[] = []
478    if (!file) {
479      measured += lines.length
480      missed.push(...lines)
481    } else {
482      const set = new Set(lines)
483      for (const n of lines) {
484        const hits = file.lines[n]
485        if (hits === undefined) continue
486        measured++
487        if (hits > 0) covered++
488        else missed.push(n)
489      }
490      for (const b of file.branches) {
491        if (!set.has(b.line)) continue
492        total++
493        if (b.taken > 0) taken++
494      }
495    }
496    if (missed.length > 0) uncovered.push(`${path}: ${ranges(missed)}${file ? '' : ' (not in the coverage report)'}`)
497  }
498  return { lines: { covered, measured, pct: pct(covered, measured) }, branches: { taken, total, pct: pct(taken, total) }, uncovered }
499}
500
501/** §5.3 step 5: Σ LH / Σ LF and Σ BRH / Σ BRF over measured source files. */
502export function totals(p: Profile, lcov: Record<string, LcovFile>): Baseline {
503  let lf = 0
504  let lh = 0
505  let brf = 0
506  let brh = 0
507  for (const f of Object.values(lcov)) {
508    if (kindOf(p, f.path) !== 'measured') continue
509    lf += f.lf
510    lh += f.lh
511    brf += f.brf
512    brh += f.brh
513  }
514  return { lines: pct(lh, lf), branches: pct(brh, brf) }
515}
516
517/** §3 step 3: whether the report has any record for source the profile names. */
518export function hasSourceRecord(p: Profile, lcov: Record<string, LcovFile>): boolean {
519  return Object.keys(lcov).some(path => matchesAny(path, p.sourceGlobs))
520}
521
522// ── Gates (§3) ────────────────────────────────────────────────────────────
523
524/** How a command ended: its exit code (null when killed), whether the timeout killed it, its last lines. */
525export type CommandResult = { exit: number | null; isTimedOut: boolean; tail: string }
526
527export function tailLines(text: string, n = 200): string {
528  const lines = text.replace(/\s+$/, '').split('\n')
529  return lines.slice(Math.max(0, lines.length - n)).join('\n')
530}
531
532/** Why a git command failed: its first `fatal:` or `error:` line (git prints progress before it), else its last line. */
533export function gitFailure(text: string): string {
534  const lines = text.split('\n').map(line => line.trim()).filter(Boolean)
535  return lines.find(line => /^(fatal|error):/.test(line)) ?? lines[lines.length - 1] ?? ''
536}
537
538/** A failed `git worktree add` in words: its timeout, git's own reason, or that it said nothing. */
539export function worktreeAddFailure(r: CommandResult, minutes: number): string {
540  return `git worktree add failed: ${r.isTimedOut ? `timed out after ${minutes} min` : gitFailure(r.tail) || 'no output'}`
541}
542
543function ended(r: CommandResult, minutes: number): string {
544  return r.isTimedOut ? `timed out after ${minutes} min` : `exit ${r.exit ?? 'killed'}`
545}
546
547function list(paths: readonly string[], max = 5): string {
548  return paths.length <= max ? paths.join(', ') : `${paths.slice(0, max).join(', ')} and ${paths.length - max} more`
549}
550
551const check = (id: string, passed: boolean, detail: string): Check => ({ id, passed, detail })
552
553export type GateRFacts = { staged: string[]; tests: CommandResult }
554
555/** R1 only test files changed, R2 at least one, R3 the tests now fail. */
556export function gateR(p: Profile, f: GateRFacts): Gate {
557  const notTests = f.staged.filter(path => !matchesAny(path, p.testGlobs))
558  const checks = [
559    check('R1', notTests.length === 0, notTests.length === 0 ? `${f.staged.length} staged, all test files` : `not test files: ${list(notTests)}`),
560    check('R2', f.staged.length > 0, f.staged.length > 0 ? `${f.staged.length} test files` : 'nothing staged: red wrote no tests'),
561    check(
562      'R3',
563      !f.tests.isTimedOut && f.tests.exit !== null && f.tests.exit !== 0,
564      f.tests.isTimedOut
565        ? `tests ${ended(f.tests, p.commandTimeoutMinutes)}`
566        : f.tests.exit === null
567          ? "tests didn't run to the end (killed, or the command couldn't start)"
568          : f.tests.exit === 0
569            ? 'tests pass; the new tests must fail first'
570            : `tests fail as intended (exit ${f.tests.exit})`,
571    ),
572  ]
573  return { gate: 'R', passed: checks.every(c => c.passed), checks }
574}
575
576export type GateGFacts = {
577  /** Staged paths since the merge base. */
578  staged: string[]
579  /** Staged paths that differ from the red commit. */
580  changedSinceRed: string[]
581  /** Paths the red commit changed since the merge base. */
582  redPaths: string[]
583  tests: CommandResult
584  coverage: CommandResult
585  /** The LCOV file's text, null when it wasn't there. */
586  lcov: string | null
587  /** `git diff -U0 --cached <merge base>`. */
588  diff: string
589  checks: { argv: string[]; result: CommandResult }[]
590}
591
592/** G1–G7 (gate F is the same checks). */
593export function gateG(p: Profile, baseline: Baseline | null, f: GateGFacts, root: string, which: 'G' | 'F' = 'G'): Gate {
594  const min = p.commandTimeoutMinutes
595  const changedTests = f.changedSinceRed.filter(path => matchesAny(path, p.testGlobs))
596  const lcov = f.lcov === null ? null : parseLcov(f.lcov, root)
597  const hasCoverage = f.coverage.exit === 0 && !f.coverage.isTimedOut && lcov !== null && hasSourceRecord(p, lcov)
598  const cov = lcov ? diffCoverage(p, parseDiff(f.diff), lcov) : null
599  const total = lcov ? totals(p, lcov) : null
600  const below = (x: Pct) => x !== null && x < p.threshold
601  const unmeasured = f.staged.filter(path => kindOf(p, path) === 'unmeasured')
602  const behaviourTests = f.redPaths.filter(path => matchesAny(path, p.behaviourTestGlobs))
603  const tol = p.regressionTolerance
604  const was = baseline ?? { lines: null, branches: null }
605  const drop = (now: Pct, then: Pct) => now !== null && then !== null && now < round2(then - tol)
606
607  const g4: Check = !hasCoverage || !cov
608    ? check('G4', false, 'no coverage data')
609    : below(cov.lines.pct)
610      ? check('G4', false, `diff line ${pctText(cov.lines.pct)} < ${p.threshold}`)
611      : below(cov.branches.pct)
612        ? check('G4', false, `diff branch ${pctText(cov.branches.pct)} < ${p.threshold}`)
613        : check('G4', true, `diff lines ${pctText(cov.lines.pct)} · branches ${pctText(cov.branches.pct)}`)
614  const g5: Check = !hasCoverage || !total
615    ? check('G5', false, 'no coverage data')
616    : drop(total.lines, was.lines)
617      ? check('G5', false, `total lines ${pctText(total.lines)} < baseline ${pctText(was.lines)} − ${tol}`)
618      : drop(total.branches, was.branches)
619        ? check('G5', false, `total branches ${pctText(total.branches)} < baseline ${pctText(was.branches)} − ${tol}`)
620        : check('G5', true, `total ${pctText(total.lines)} / ${pctText(total.branches)}`)
621  const failedChecks = f.checks.filter(c => c.result.exit !== 0 || c.result.isTimedOut)
622  const checks: Check[] = [
623    check('G1', changedTests.length === 0, changedTests.length === 0 ? 'tests unchanged since the red commit' : `tests changed since the red commit: ${list(changedTests)}`),
624    check('G2', f.tests.exit === 0 && !f.tests.isTimedOut, f.tests.exit === 0 && !f.tests.isTimedOut ? 'tests pass' : `tests fail (${ended(f.tests, min)})`),
625    check(
626      'G3',
627      hasCoverage,
628      hasCoverage
629        ? 'coverage written'
630        : f.coverage.exit !== 0 || f.coverage.isTimedOut
631          ? `coverage command failed (${ended(f.coverage, min)})`
632          : lcov === null
633            ? `no LCOV at ${p.lcovPath}`
634            : 'the LCOV report has no source records',
635    ),
636    g4,
637    g5,
638    check(
639      'G6',
640      unmeasured.length === 0 || behaviourTests.length > 0,
641      unmeasured.length === 0
642        ? 'no unmeasured source changed'
643        : behaviourTests.length > 0
644          ? `behaviour test ${list(behaviourTests, 2)} covers ${list(unmeasured, 2)}`
645          : `no behaviour test in the red commit for ${list(unmeasured)}`,
646    ),
647    check('G7', failedChecks.length === 0, failedChecks.length === 0 ? `${f.checks.length} checks pass` : `check failed: ${failedChecks.map(c => `${c.argv.join(' ')} (${ended(c.result, min)})`).join('; ')}`),
648  ]
649  const failed = checks.filter(c => !c.passed)
650  return {
651    gate: which,
652    passed: failed.length === 0,
653    checks,
654    coverage: { diffLines: cov?.lines.pct ?? null, diffBranches: cov?.branches.pct ?? null, totalLines: total?.lines ?? null, totalBranches: total?.branches ?? null, uncovered: cov?.uncovered ?? [] },
655    isBehaviourOnly: failed.length === 1 && failed[0]?.id === 'G6',
656    unmeasured,
657  }
658}
659
660// ── New plugins (Change 0.7.0) ──────────────────────────────────────────
661
662const BUILD = /(?<![\w:/-])build\b/i
663const WITH = /\bwith\b/i
664const MOD_BUILDER = /\/mod-builder:build\b/i
665
666/**
667 * `build … with /mod-builder:build` on one line; not the `build` of `/mod-builder:build` itself. Each word is found
668 * after the one before it, in linear time: one regex with two lazy `.*?` backtracks cubically on issue text anyone
669 * can write (PR #103 /code-review). The earliest `build` and `with` leave the most line for the next word.
670 */
671function asksForModBuilder(line: string): boolean {
672  const build = line.search(BUILD)
673  if (build < 0) return false
674  const afterBuild = line.slice(build + 'build'.length)
675  const withAt = afterBuild.search(WITH)
676  return withAt >= 0 && MOD_BUILDER.test(afterBuild.slice(withAt + 'with'.length))
677}
678const LINE_CHARS = 1200
679
680/**
681 * The line of the issue that asks for its plugin to be built with mod-builder, cut at 1,200 characters (marked `…`);
682 * null when no line does. Only that line is shown, as the reason, never the lines after it: issue text is anyone's to
683 * write (the owner's decision after PR #103's review).
684 */
685export function newModLine(body: string): string | null {
686  const line = body.replace(/\r\n?/g, '\n').split('\n').find(asksForModBuilder)?.trimEnd()
687  if (line === undefined) return null
688  return line.length > LINE_CHARS ? `${line.slice(0, LINE_CHARS)}…` : line
689}
690
691/** A Claude Code plugin's manifest: `plugin.json` in a `.claude-plugin` folder, at any depth, in any case (a case-insensitive drive opens it). */
692export function isPluginManifest(path: string): boolean {
693  return /(^|\/)\.claude-plugin\/plugin\.json$/i.test(path.replace(/\\/g, '/'))
694}
695
696/**
697 * The new plugin a phase made: the first manifest added since the merge base, else the one the guard refused. A manifest
698 * under the test globs is a fixture (test data), not a new plugin; one on the base branch's tip (`onBase`) is someone
699 * else's, merged in (the owner's decision after PR #103's review).
700 */
701export function newPluginOf(added: readonly string[], refused?: string, testGlobs: readonly string[] = [], onBase: readonly string[] = []): NewPlugin | null {
702  const path = added.find(p => isPluginManifest(p) && !matchesAny(p, testGlobs) && !onBase.includes(p))
703  if (path) return { path, isCreated: true }
704  return refused && !onBase.includes(refused) ? { path: refused, isCreated: false } : null
705}
706
707/** R4 (gate R) or G8 (gates G and F), checked first: a new plugin fails the gate before any test runs. */
708export function newPluginGate(which: 'R' | 'G' | 'F', np: NewPlugin): Gate {
709  const id = which === 'R' ? 'R4' : 'G8'
710  return { gate: which, passed: false, checks: [check(id, false, `${np.isCreated ? 'a new' : 'a refused'} Claude Code plugin: ${np.path}`)], newPlugin: np }
711}
712
713/**
714 * The stored run with the guard's refusal on it, or null to write nothing: never onto a run another session took over,
715 * one that stopped, or another run (Codex 4290574833 on PR #103).
716 */
717export function newPluginRecord(stored: Run | null, runId: string, me: string, path: string, now: number): Run | null {
718  return stored && !foreignChange(stored, runId, me) && isActive(stored) ? advance(stored, { type: 'newPlugin', path }, now) : null
719}
720
721/**
722 * A drive's write, keeping a refusal the guard stored after the drive read the run: nothing clears `newPlugin`, and a
723 * write computed from the earlier read would drop it, so the gate would see neither the file nor the refusal (Codex 4290653342).
724 */
725export function withRefusal(stored: Run | null, next: Run): Run {
726  return stored?.newPlugin && !next.newPlugin ? { ...next, newPlugin: stored.newPlugin } : next
727}
728
729/** What the guard tells a phase agent that would create a manifest. */
730export function newPluginRefusal(path: string): string {
731  return `issue-forge: creating a new Claude Code plugin (${path}) is mod-builder's job, not this run's. End your turn now; the run stops with next steps for the person.`
732}
733
734export type StartIssue = { issue: number; title: string; url: string; ghRepo?: string }
735/** No case carries the issue's text: the start's reply shows its matched line apart, as the reason (owner, after PR #103's review). */
736export type NewPluginCase = { kind: 'start'; issue: StartIssue } | { kind: 'gate'; run: Run }
737
738/** The next steps, one item each, for a start the issue refused or a run a gate blocked; no repository-specific path. */
739export function newPluginSteps(c: NewPluginCase): string[] {
740  if (c.kind === 'start') {
741    const { issue, title, url, ghRepo } = c.issue
742    return [
743      `Run /mod-builder:build and ask for the plugin #${issue} describes: "${title}" (${url}). Read it first with gh issue view ${issue}${ghRepo ? ` --repo ${ghRepo}` : ''}.`,
744      'Approve the SPEC mod-builder writes; it then scaffolds, tests and verifies the plugin.',
745      `Open a pull request that closes #${issue}.`,
746    ]
747  }
748  const run = c.run
749  const isWorktree = isWorktreeRun(run)
750  // What was committed comes from the run's commits, not its gate: G6's detour returns to gate R after red's commit (Codex 4290574818).
751  const red = run.redCommit?.slice(0, 7)
752  const green = run.greenCommit?.slice(0, 7)
753  const stash = run.behaviourFor && run.greenStash ? ` Green's work is in stash ${run.greenStash.sha.slice(0, 7)} "${run.greenStash.message}" (git stash apply ${run.greenStash.sha.slice(0, 7)}).` : ''
754  const left = red
755    ? `Nothing past ${green ? "green's" : "red's"} commit was committed, and nothing was pushed. ${
756        isWorktree
757          ? `This run's worktree ${run.workRoot} holds red's tests at ${red}${green ? `, green's change at ${green},` : ''} and the phase's uncommitted files.`
758          : `This run's branch ${run.branch} holds red's tests at ${red}${green ? ` and green's change at ${green}` : ''}, and the checkout's uncommitted files (git status lists them) are the phase's.`
759      }${stash}`
760    : `Nothing was committed, and nothing was pushed. ${isWorktree ? `This run's worktree ${run.workRoot} holds red's uncommitted files.` : "The checkout's uncommitted files (git status lists them) are red's."}`
761  const cleanup = cleanupCommand(run)
762  return [
763    left,
764    `Stop this run: /issue-forge stop ${run.issue}`,
765    `Build the plugin with /mod-builder:build in a clean checkout of ${run.base} or a new worktree from it, not in this run's ${isWorktree ? 'worktree' : 'branch'}, whose files would be in the way (mod-builder's scaffold refuses a plugin folder that already holds files).`,
766    ...(red ? [`Red's tests can serve the build's logic-first step as reference: git show ${red}`] : []),
767    ...(cleanup ? [`When you no longer need this run's worktree, remove it from a plain shell: ${cleanup}`] : []),
768    `Open a pull request that closes #${run.issue}.`,
769  ]
770}
771
772/** Steps as the person reads them: `1. …`, one per line (a step's own lines follow it). */
773export function numberedSteps(steps: readonly string[]): string {
774  return steps.map((step, i) => `${i + 1}. ${step}`).join('\n')
775}
776
777/** The answer to a start whose issue asks for a new plugin: nothing was started; the matched line is the reason, in its author's words. */
778export function newModReply(issue: StartIssue, line: string): string {
779  return [
780    `#${issue.issue} asks for a new Claude Code plugin. issue-forge doesn't build new plugins: it takes an issue to a fix, test-first. New plugins are built with mod-builder, whose SPEC is the one approval. Nothing was started.`,
781    '',
782    "It matched this line of the issue (the author's words; issue-forge hasn't checked them). If it doesn't ask for a new plugin, reword it and start again:",
783    `  > ${line}`,
784    '',
785    'Next steps:',
786    numberedSteps(newPluginSteps({ kind: 'start', issue })),
787  ].join('\n')
788}
789
790/** A gate found a new plugin: the run blocks final, with the steps, and is never retried. */
791function newPluginBlocked(run: Run, at: Step, np: NewPlugin): Run {
792  const reason = `a phase agent ${np.isCreated ? 'created' : 'tried to create'} a new Claude Code plugin (${np.path}); issue-forge doesn't build new plugins`
793  const steps = newPluginSteps({ kind: 'gate', run: { ...run, step: at } })
794  return blockedAt(run, at, reason, 'build it with /mod-builder:build', { steps, isFinal: true })
795}
796
797/** The first failed check's detail, or '' when none failed. */
798export function firstFailure(g: Gate): string {
799  return g.checks.find(c => !c.passed)?.detail ?? ''
800}
801
802/** The failed checks as the next attempt's prompt lists them, uncovered lines included. */
803export function failuresOf(g: Gate): string[] {
804  const out = g.checks.filter(c => !c.passed).map(c => `${c.id}: ${c.detail}`)
805  if (g.checks.some(c => c.id === 'G4' && !c.passed)) for (const u of g.coverage?.uncovered ?? []) out.push(`  uncovered ${u}`)
806  return out
807}
808
809// ── Repository (#25) ──────────────────────────────────────────────────────
810
811/** `host/owner/repo` from `gh repo view --json nameWithOwner,url`: the host from the url (lowercased), the rest from nameWithOwner. */
812export function repoOfView(stdout: string): string | null {
813  let view: { nameWithOwner?: unknown; url?: unknown }
814  try {
815    view = JSON.parse(stdout) as typeof view
816  } catch {
817    return null
818  }
819  const host = typeof view?.url === 'string' ? /^https?:\/\/([^/@]+)\//.exec(view.url)?.[1] : undefined
820  return host && typeof view.nameWithOwner === 'string' && /^[^/]+\/[^/]+$/.test(view.nameWithOwner) ? `${host.toLowerCase()}/${view.nameWithOwner}` : null
821}
822
823// The scheme is group 1 and the host group 2 in a URL, the host group 3 in the scp-like form; then owner (4) and repository (5).
824const REMOTE_URL = /^(?:(https?|ssh):\/\/(?:[^/@]+@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::\d+)?\/|(?:[^/@:\s]+@)?([A-Za-z0-9][A-Za-z0-9.-]*):(?!\/))([^/\s?#]+)\/([^/\s?#]+?)(?:\.git)?\/?$/i
825
826/** A remote's `host/owner/repo`, and whether git reaches it over ssh (an ssh:// URL or the scp-like form). */
827function parseRemote(url: string): { repo: string; isSsh: boolean } | null {
828  const m = url.trim().match(REMOTE_URL)
829  return m ? { repo: `${String(m[2] ?? m[3]).toLowerCase()}/${m[4]}/${m[5]}`, isSsh: m[1] === undefined || m[1].toLowerCase() === 'ssh' } : null
830}
831
832/**
833 * `host/owner/repo` from a git remote URL: https, http and ssh URLs (the scheme in any case; userinfo, port, `.git` and a
834 * trailing slash dropped, the host lowercased) and scp-like `[user@]host:owner/repo`. Any other form (git://, file://, a
835 * path) is null.
836 */
837export function repoOfRemoteUrl(url: string): string | null {
838  return parseRemote(url)?.repo ?? null
839}
840
841export type RemoteVerdict = { kind: 'match' } | { kind: 'unrecognised' } | { kind: 'ask'; repo: string } | { kind: 'other'; repo: string }
842
843export const hostOf = (repo: string) => repo.slice(0, repo.indexOf('/')).toLowerCase()
844
845/**
846 * Whether a remote URL names `repo` (PR #68 review). An https host is the host; git hands an ssh remote's host to ssh, and
847 * ~/.ssh/config may alias it, so there the same owner and repository is enough. Another owner or repository on the pinned
848 * host may be `repo` renamed, which only gh can say (`ask`); gh is never asked about another host.
849 */
850export function remoteVerdict(url: string, repo: string): RemoteVerdict {
851  const remote = parseRemote(url)
852  if (!remote) return { kind: 'unrecognised' }
853  if (isSameRepo(remote.repo, repo) || (remote.isSsh && isSameOwnerRepo(remote.repo, repo))) return { kind: 'match' }
854  return hostOf(remote.repo) === hostOf(repo) ? { kind: 'ask', repo: remote.repo } : { kind: 'other', repo: remote.repo }
855}
856
857/** The host `ssh -G` is asked about for a fork clone's URL: an ssh URL's host when it isn't `repo`'s; any other needs none. */
858export function sshHostToResolve(url: string, repo: string): string | null {
859  const remote = parseRemote(url)
860  return remote?.isSsh && hostOf(remote.repo) !== hostOf(repo) ? hostOf(remote.repo) : null
861}
862
863/** `ssh -G <host>`'s `hostname` line: the host ssh connects to once ~/.ssh/config is applied (a nickname's HostName). */
864export function sshHostname(stdout: string): string | null {
865  const host = stdout
866    .split(/\r?\n/)
867    .find(line => /^hostname\s/i.test(line))
868    ?.trim()
869    .split(/\s+/)[1]
870  return host ? host.toLowerCase() : null
871}
872
873/**
874 * What a fork clone's remote URL names (PR #87 review, the owner's choice). Unlike `remoteVerdict`, an ssh host other than
875 * `repo`'s is never assumed to be its nickname: it counts only as ssh resolves it (`resolved`, from `ssh -G`; null or absent
876 * when ssh couldn't say), with GitHub's ssh-over-443 host taken for github.com. Origin's own check keeps #25's rule (#71).
877 */
878export function forkRemoteVerdict(url: string, repo: string, resolved?: string | null): RemoteVerdict {
879  const remote = parseRemote(url)
880  if (!remote) return { kind: 'unrecognised' }
881  let named = remote.repo
882  if (sshHostToResolve(url, repo) !== null) {
883    if (!resolved) return { kind: 'unrecognised' }
884    named = `${resolved === 'ssh.github.com' ? 'github.com' : resolved}${named.slice(named.indexOf('/'))}`
885  }
886  if (isSameRepo(named, repo)) return { kind: 'match' }
887  return hostOf(named) === hostOf(repo) ? { kind: 'ask', repo: named } : { kind: 'other', repo: named }
888}
889
890/** The same host, owner and repository, in any case; a null side never matches. */
891export function isSameRepo(a: string | null, b: string | null): boolean {
892  return a !== null && b !== null && a.toLowerCase() === b.toLowerCase()
893}
894
895/** The same owner and repository on any host: an SSH alias from ~/.ssh/config stands in for the host. */
896export function isSameOwnerRepo(a: string | null, b: string | null): boolean {
897  const ownerRepo = (repo: string) => repo.slice(repo.indexOf('/') + 1).toLowerCase()
898  return a !== null && b !== null && ownerRepo(a) === ownerRepo(b)
899}
900
901/** Whether an issue's url is a pull request's: `gh issue view` answers for both. */
902export function isPullUrl(url: string): boolean {
903  return /^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/\d+$/.test(url)
904}
905
906/** `owner/repo` on github.com, `host/owner/repo` anywhere else. */
907export function repoLabel(repo: string): string {
908  return repo.replace(/^github\.com\//i, '')
909}
910
911/** `#16`, or `o/r#16` for a run pinned to a repository. */
912export function issueLabel(run: Run): string {
913  return `${run.ghRepo ? repoLabel(run.ghRepo) : ''}#${run.issue}`
914}
915
916// ── Fork clones (#26) ─────────────────────────────────────────────────────
917
918/** What a remote URL names once resolved: the run's repository, another (gh's name for it), or nothing readable (null). */
919export type RemoteFound = 'match' | { other: string | null }
920
921/**
922 * The fork a fork clone's origin pushes to: every push URL names one repository on `repo`'s host that isn't `repo`, and the
923 * fetch URL is that fork too, so `git ls-remote origin`, which reads the fetch URL, lists the fork's branches (PR #87 review;
924 * Codex P1 4290274553: fetching from `repo` itself hid them).
925 */
926export function forkOfOrigin(fetch: RemoteFound, pushes: readonly RemoteFound[], repo: string): string | null {
927  let fork: string | null = null
928  for (const found of pushes) {
929    if (found === 'match' || found.other === null || hostOf(found.other) !== hostOf(repo)) return null
930    if (fork !== null && !isSameRepo(fork, found.other)) return null
931    fork ??= found.other
932  }
933  if (fork === null) return null
934  return fetch !== 'match' && isSameRepo(fetch.other, fork) ? fork : null
935}
936
937/** The remotes whose fetch URL names the project: none, one (the base remote), or several (a block). */
938export type BaseRemote = { kind: 'none' } | { kind: 'one'; remote: string } | { kind: 'several'; remotes: string[] }
939
940export function baseRemoteOf(matching: readonly string[]): BaseRemote {
941  const [first, ...rest] = matching
942  if (first === undefined) return { kind: 'none' }
943  return rest.length === 0 ? { kind: 'one', remote: first } : { kind: 'several', remotes: [...matching] }
944}
945
946/**
947 * Whether `gh repo view <fork> --json isFork,parent` says a fork of `repo`: the parent's owner login and name are
948 * `repo`'s, in any case. gh prints no host for the parent: it is the fork's own, which the caller has found to be
949 * `repo`'s, so `repo`'s host stands in for it.
950 */
951export function isForkOf(stdout: string, repo: string): boolean {
952  let view: { isFork?: unknown; parent?: { name?: unknown; owner?: { login?: unknown } | null } | null }
953  try {
954    view = JSON.parse(stdout) as typeof view
955  } catch {
956    return false
957  }
958  const login = view?.parent?.owner?.login
959  const name = view?.parent?.name
960  if (view?.isFork !== true || typeof login !== 'string' || typeof name !== 'string') return false
961  return isSameRepo(`${hostOf(repo)}/${login}/${name}`, repo)
962}
963
964/** The owner of a `host/owner/repo`. */
965export function ownerOf(repo: string): string {
966  return repo.slice(repo.indexOf('/') + 1, repo.lastIndexOf('/'))
967}
968
969/** `gh pr create --head`: `<owner>:<branch>` for a fork's branch (its owner is not the project's), the branch alone otherwise. */
970export function headArg(branch: string, headRepo?: string): string {
971  return headRepo === undefined ? branch : `${ownerOf(headRepo)}:${branch}`
972}
973
974/**
975 * A kept branch is reused only while origin pushes where its name was checked: the fork it was made for (`kept`), or the
976 * project itself when there was none; `now` is the fork preflight finds (Codex on PR #87).
977 */
978export function keptForkBlock(kept: string | undefined, now: string | undefined, repo: string): { reason: string; next: string } | null {
979  if (kept === undefined ? now === undefined : isSameRepo(kept, now ?? null)) return null
980  const was = repoLabel(kept ?? repo)
981  return { reason: `origin now pushes to ${repoLabel(now ?? repo)}, not ${was}, where the run's branch was named`, next: `point origin back at ${was}, or start a new run` }
982}
983
984/** The remote a run fetches its base from: the project's in a fork clone, origin in every other run. */
985export function baseRemoteOfRun(run: { baseRemote?: string }): string {
986  return run.baseRemote ?? 'origin'
987}
988
989/** The remote-tracking ref of the base, `<remote>/<base>`. */
990export function baseRefOf(run: { base: string; baseRemote?: string }): string {
991  return `${baseRemoteOfRun(run)}/${run.base}`
992}
993
994// ── State (§7.4) ──────────────────────────────────────────────────────────
995
996/**
997 * The run's branch, the folder it is checked out in, and its base, once preflight has made them; in a fork clone also the base
998 * remote and the fork the branch's name was checked on (Codex on PR #87).
999 */
1000export type Kept = { branch: string; workRoot: string; base: string; baseRemote?: string; headRepo?: string }
1001
1002export type RunEvent =
1003  | { type: 'preflighted'; title: string; body: string; url: string; base: string; branch: string; mergeBase: string; profile: Profile; workRoot?: string; repo: string; baseRemote?: string; headRepo?: string }
1004  | { type: 'baselined'; baseline: Baseline; mergeBase?: string }
1005  | { type: 'spawned'; agent: AgentRef }
1006  | { type: 'agentDone'; answer: string }
1007  | { type: 'gated'; gate: Gate; commit?: string; stash?: StashRef; testTail?: string; head?: string; blockAfter?: { reason: string; next: string } }
1008  | { type: 'block'; reason: string; next: string; keep?: Kept; isFinal?: boolean }
1009  | { type: 'newPlugin'; path: string }
1010  | { type: 'merged'; merge: MergeCheck }
1011  | { type: 'made'; keep: Kept }
1012  | { type: 'pinned'; repo: string }
1013  | { type: 'resume' }
1014  | { type: 'stop' }
1015  | { type: 'drive'; session: string }
1016  | { type: 'release'; session: string }
1017  | { type: 'publish' }
1018  | { type: 'published'; url: string; merge?: MergeCheck }
1019
1020export const WORKING: readonly Step[] = ['preflight', 'baseline', 'red', 'gateR', 'green', 'gateG', 'refactor', 'gateF', 'publishing']
1021
1022export function isWorking(step: Step): boolean {
1023  return WORKING.includes(step)
1024}
1025
1026/** Active: anything but done and stopped holds the repository (§7.4: a second start answers with its status). */
1027export function isActive(run: Run | null): boolean {
1028  return run !== null && run.step !== 'done' && run.step !== 'stopped'
1029}
1030
1031export function phaseOf(step: Step): Phase | 'publish' | 'setup' | null {
1032  if (step === 'preflight' || step === 'baseline') return 'setup'
1033  if (step === 'red' || step === 'gateR') return 'red'
1034  if (step === 'green' || step === 'gateG') return 'green'
1035  if (step === 'refactor' || step === 'gateF') return 'refactor'
1036  if (step === 'ready' || step === 'publishing' || step === 'done') return 'publish'
1037  return null
1038}
1039
1040/** `ghRepo` (`host/owner/repo`) pins a run started from an issue URL to that repository (#25). */
1041export function newRun(id: string, root: string, issue: number, now: number, ghRepo?: string): Run {
1042  return {
1043    id,
1044    root,
1045    ...(ghRepo === undefined ? {} : { ghRepo }),
1046    issue,
1047    title: '',
1048    body: '',
1049    url: '',
1050    branch: '',
1051    base: '',
1052    mergeBase: '',
1053    profile: null,
1054    step: 'preflight',
1055    used: { red: 0, green: 0, refactor: 0 },
1056    baseline: null,
1057    gates: {},
1058    times: {},
1059    startedAt: now,
1060    agents: [],
1061  }
1062}
1063
1064function attemptsOf(run: Run, phase: Phase): number {
1065  return run.profile?.attempts[phase] ?? DEFAULTS.attempts[phase]
1066}
1067
1068/** Whether a failed gate in `phase` gets another spawn. */
1069export function canRetry(run: Run, phase: Phase): boolean {
1070  return run.used[phase] < attemptsOf(run, phase)
1071}
1072
1073const start = (run: Run, phase: Phase | 'publish', now: number): Run['times'] => ({ ...run.times, [phase]: { startedAt: now } })
1074const end = (run: Run, phase: Phase | 'publish', now: number): Run['times'] => ({ ...run.times, [phase]: { startedAt: run.times[phase]?.startedAt ?? now, endedAt: now } })
1075
1076function blockedAt(run: Run, at: Step, reason: string, next: string, more: { steps?: string[]; isFinal?: boolean } = {}): Run {
1077  return { ...run, step: 'blocked', blocked: { at, reason, next, ...more } }
1078}
1079
1080/** The single transition function: every step change goes through here. */
1081export function advance(run: Run, ev: RunEvent, now: number): Run {
1082  if (ev.type === 'stop') return isActive(run) ? { ...run, step: 'stopped' } : run
1083  // Who drives the run (Change 0.3.0): set when a drive starts; only that session's release clears it.
1084  if (ev.type === 'drive') return { ...run, driver: { session: ev.session, since: now } }
1085  if (ev.type === 'release') {
1086    if (run.driver?.session !== ev.session) return run
1087    const { driver: _, ...rest } = run
1088    return rest
1089  }
1090  // The branch and folder preflight made land at any step, a stopped one too, so a Stop never loses them.
1091  if (ev.type === 'made') return { ...run, ...ev.keep }
1092  // A number start's repository, once origin agrees with it: kept over a block, so resume can't move the run (PR #68 review 2).
1093  if (ev.type === 'pinned') return run.ghRepo ? run : { ...run, ghRepo: ev.repo }
1094  // A preflight that made the branch before it blocked keeps it, so resume reuses it.
1095  if (ev.type === 'block') return isWorking(run.step) || run.step === 'ready' ? blockedAt({ ...run, ...ev.keep }, run.step, ev.reason, ev.next, ev.isFinal ? { isFinal: true } : {}) : run
1096  // The guard refused a manifest: the phase's gate blocks on it (Change 0.7.0).
1097  if (ev.type === 'newPlugin') return isActive(run) ? { ...run, newPlugin: ev.path } : run
1098  if (ev.type === 'resume') {
1099    if (isWorking(run.step)) {
1100      const phase = phaseOf(run.step)
1101      return phase === 'red' || phase === 'green' || phase === 'refactor' ? { ...run, used: { ...run.used, [phase]: 0 } } : run
1102    }
1103    if (run.step !== 'blocked' || !run.blocked || run.blocked.isFinal) return run
1104    const at = run.blocked.at === 'publishing' ? 'ready' : run.blocked.at
1105    const phase = phaseOf(at)
1106    const used = phase === 'red' || phase === 'green' || phase === 'refactor' ? { ...run.used, [phase]: 0 } : run.used
1107    const { blocked: _, ...rest } = run
1108    return { ...rest, step: at, used }
1109  }
1110  switch (run.step) {
1111    case 'preflight':
1112      if (ev.type !== 'preflighted') return run
1113      return { ...run, title: ev.title, body: ev.body, url: ev.url, base: ev.base, branch: ev.branch, mergeBase: ev.mergeBase, profile: ev.profile, ...(ev.workRoot ? { workRoot: ev.workRoot } : {}), ghRepo: ev.repo, ...(ev.baseRemote ? { baseRemote: ev.baseRemote } : {}), ...(ev.headRepo ? { headRepo: ev.headRepo } : {}), step: 'baseline' }
1114    case 'baseline':
1115      if (ev.type !== 'baselined') return run
1116      return { ...run, baseline: ev.baseline, mergeBase: ev.mergeBase ?? run.mergeBase, step: 'red', times: start(run, 'red', now) }
1117    case 'red':
1118    case 'green':
1119    case 'refactor': {
1120      const phase = run.step
1121      if (ev.type === 'spawned') return { ...run, used: { ...run.used, [phase]: run.used[phase] + 1 }, agents: [...run.agents, ev.agent] }
1122      if (ev.type !== 'agentDone') return run
1123      const gate: Step = phase === 'red' ? 'gateR' : phase === 'green' ? 'gateG' : 'gateF'
1124      const redAnswer = phase !== 'red' ? run.redAnswer : run.behaviourFor ? `${run.redAnswer ?? ''}\n\n${ev.answer}`.trim() : ev.answer
1125      return { ...run, step: gate, redAnswer }
1126    }
1127    case 'gateR': {
1128      if (ev.type !== 'gated') return run
1129      const gated = { ...run, lastGate: ev.gate, gates: { ...run.gates, R: ev.gate }, lastTestTail: ev.testTail }
1130      if (ev.gate.newPlugin) return newPluginBlocked(gated, 'gateR', ev.gate.newPlugin)
1131      if (ev.gate.passed) {
1132        if (run.behaviourFor) {
1133          const { behaviourFor: _, ...rest } = gated
1134          const done: Run = { ...rest, redCommit: ev.commit ?? run.redCommit, isBehaviourDone: true, step: 'gateG' }
1135          // The behaviour test is committed; if green's work couldn't be restored, block where resume picks up: gate G.
1136          return ev.blockAfter ? blockedAt(done, 'gateG', ev.blockAfter.reason, ev.blockAfter.next) : done
1137        }
1138        return { ...gated, redCommit: ev.commit, redTestTail: ev.testTail, step: 'green', times: { ...end(gated, 'red', now), green: { startedAt: now } } }
1139      }
1140      if (canRetry(run, 'red')) return { ...gated, step: 'red' }
1141      const kept = run.greenStash ? `; green's work is in stash ${run.greenStash.sha.slice(0, 7)} "${run.greenStash.message}" (git stash apply ${run.greenStash.sha.slice(0, 7)})` : ''
1142      return blockedAt(gated, 'gateR', `gate R failed after ${run.used.red} attempts: ${firstFailure(ev.gate)}`, `fix the tests by hand or clarify the issue${kept}`)
1143    }
1144    case 'gateG':
1145    case 'gateF': {
1146      if (ev.type !== 'gated') return run
1147      const isF = run.step === 'gateF'
1148      const gated = { ...run, lastGate: ev.gate, gates: { ...run.gates, [isF ? 'F' : 'G']: ev.gate }, lastTestTail: ev.testTail }
1149      if (ev.gate.newPlugin) return newPluginBlocked(gated, run.step, ev.gate.newPlugin)
1150      if (ev.gate.passed) {
1151        if (isF) return { ...gated, refactorCommit: ev.commit, gatedHead: ev.head, step: 'ready', times: end(gated, 'refactor', now) }
1152        const greenTimes = end(gated, 'green', now)
1153        return { ...gated, greenCommit: ev.commit, step: 'refactor', times: { ...greenTimes, refactor: { startedAt: now } } }
1154      }
1155      if (isF) {
1156        if (canRetry(run, 'refactor')) return { ...gated, step: 'refactor' }
1157        return { ...gated, step: 'ready', isRefactorRejected: true, refactorStash: ev.stash, gatedHead: ev.head, times: end(gated, 'refactor', now) }
1158      }
1159      if (ev.gate.isBehaviourOnly && !run.isBehaviourDone) {
1160        return { ...gated, step: 'red', behaviourFor: ev.gate.unmeasured ?? [], greenStash: ev.stash, used: { ...run.used, red: 0 } }
1161      }
1162      if (canRetry(run, 'green')) return { ...gated, step: 'green' }
1163      return blockedAt(gated, 'gateG', `gate G failed after ${run.used.green} attempts: ${firstFailure(ev.gate)}`, 'fix it by hand on the branch')
1164    }
1165    case 'ready':
1166      if (ev.type === 'merged') return { ...run, merge: ev.merge }
1167      return ev.type === 'publish' ? { ...run, step: 'publishing', times: start(run, 'publish', now) } : run
1168    case 'publishing':
1169      return ev.type === 'published' ? { ...run, step: 'done', prUrl: ev.url, merge: ev.merge ?? run.merge, times: end(run, 'publish', now) } : run
1170    default:
1171      return run
1172  }
1173}
1174
1175// ── Workspace (Change 0.2.0) ─────────────────────────────────────────────
1176
1177export type CheckoutFacts = { porcelain: string; head: string; base: string; heldBy?: number }
1178export type CheckoutState = { isBusy: boolean; why: string }
1179export type WorkspacePlan = { kind: 'here' } | { kind: 'worktree' } | { kind: 'ask' } | { kind: 'block'; reason: string; next: string }
1180
1181/** Whether the session's checkout may be branched in place. Busy: it has changes, or HEAD isn't the base (another branch, or detached). */
1182export function checkoutState(f: CheckoutFacts): CheckoutState {
1183  const changed = f.porcelain.split('\n').filter(line => line.trim() !== '').length
1184  const reasons = [
1185    changed > 0 ? `${changed} changed file${changed === 1 ? '' : 's'}` : '',
1186    f.head === f.base ? '' : f.head === 'HEAD' ? 'detached HEAD' : `on ${f.head}`,
1187    f.heldBy === undefined ? '' : `#${f.heldBy}'s run works here`,
1188  ].filter(Boolean)
1189  return { isBusy: reasons.length > 0, why: reasons.join(', ') }
1190}
1191
1192/**
1193 * `here` never branches a busy checkout, `ask` asks only when it's busy, `worktree` always makes one. `ref` is where the profile
1194 * was read: `upstream/<base>` in a fork clone (PR #87 review).
1195 */
1196export function workspacePlan(mode: Workspace, s: CheckoutState, base: string, ref = `origin/${base}`): WorkspacePlan {
1197  if (mode === 'worktree') return { kind: 'worktree' }
1198  if (!s.isBusy) return { kind: 'here' }
1199  if (mode === 'ask') return { kind: 'ask' }
1200  return { kind: 'block', reason: `the checkout is busy (${s.why})`, next: `set "workspace" to "worktree" or "ask" in ${PROFILE_FILE} on ${base} (the run reads ${ref}'s copy), or clean the checkout` }
types/index.d.ts 167 lines
1// Issue Forge's state contract. Self-contained: no imports.
2
3export type Phase = 'red' | 'green' | 'refactor'
4
5/** Where a run is (SPEC "How it works" 14). `blocked` and `stopped` hold no work. */
6export type Step =
7  | 'preflight'
8  | 'baseline'
9  | 'red'
10  | 'gateR'
11  | 'green'
12  | 'gateG'
13  | 'refactor'
14  | 'gateF'
15  | 'ready'
16  | 'publishing'
17  | 'done'
18  | 'blocked'
19  | 'stopped'
20
21/** `.issue-forge.json`, validated, defaults filled, `~` expanded (§4). */
22export type Profile = {
23  baseBranch: string
24  testCommand: string[]
25  coverageCommand: string[]
26  lcovPath: string
27  sourceGlobs: string[]
28  testGlobs: string[]
29  excludeGlobs: string[]
30  unmeasuredGlobs: string[]
31  behaviourTestGlobs: string[]
32  checks: string[][]
33  threshold: number
34  regressionTolerance: number
35  attempts: { red: number; green: number; refactor: number }
36  commandTimeoutMinutes: number
37  notes: string
38  commitTrailers: string[]
39  /** Where a run works: `here` (this checkout, never when busy), `worktree` (always a new one), `ask` (when busy). */
40  workspace: Workspace
41  /** Where new worktrees go, relative to the repository root (gitignored). */
42  worktreeDir: string
43}
44
45export type Workspace = 'ask' | 'worktree' | 'here'
46
47/** `git merge-tree` of the gated commit against the freshly fetched base, at ready. */
48export type MergeCheck = { isClean: boolean; files: string[]; error?: string }
49
50/** A percentage rounded to two decimals, or null for "n/a". */
51export type Pct = number | null
52
53export type Check = { id: string; passed: boolean; detail: string }
54
55export type Coverage = {
56  diffLines: Pct
57  diffBranches: Pct
58  totalLines: Pct
59  totalBranches: Pct
60  /** `path: 3-5, 9` for each changed measured file with uncovered lines. */
61  uncovered: string[]
62}
63
64export type Gate = {
65  gate: 'R' | 'G' | 'F'
66  passed: boolean
67  checks: Check[]
68  coverage?: Coverage
69  /** G6 failed and nothing else did: the run goes back to red once. */
70  isBehaviourOnly?: boolean
71  /** Unmeasured source files changed since the merge base. */
72  unmeasured?: string[]
73  /** R4 or G8 failed: a phase agent created a plugin manifest, or the guard refused one (Change 0.7.0). */
74  newPlugin?: NewPlugin
75}
76
77/** A new Claude Code plugin's manifest a phase agent made (`isCreated`) or the guard refused it (Change 0.7.0). */
78export type NewPlugin = { path: string; isCreated: boolean }
79
80export type Baseline = { lines: Pct; branches: Pct }
81
82export type AgentRef = { id: string; name?: string; phase: Phase }
83
84/** A stash entry the mod made, found by its unique message and kept by its commit. */
85export type StashRef = { sha: string; message: string }
86
87export type PhaseTime = { startedAt: number; endedAt?: number }
88
89/** The session whose drive is under way (Change 0.3.0): its `$.session.id()` at session start, and since when. */
90export type Driver = { session: string; since: number }
91
92/** One of the repository's other active runs, as the pane's `also` line shows it (Change 0.3.0). */
93export type OtherRun = { issue: number; step: Step; line: string; isElsewhere: boolean }
94
95export type Run = {
96  id: string
97  /** git's common folder: every worktree of the repository shares it, and it keys the store (Change 0.3.0). Absent in runs stored before. */
98  repo?: string
99  /** The GitHub repository (`host/owner/repo`) every gh call of the run names with `--repo` (#25). Absent in runs stored before. */
100  ghRepo?: string
101  /** The remote the base is fetched from, in a fork clone where origin is the contributor's fork (#26). Absent: origin. */
102  baseRemote?: string
103  /** The fork origin pushes to (`host/owner/repo`), whose owner heads the pull request (#26). Absent: the branch alone. */
104  headRepo?: string
105  /** Set while a session drives the run; cleared when the drive ends (Change 0.3.0). */
106  driver?: Driver
107  /** Started from the menu's Start under `ask`: it works in a new worktree, asked once (Change 0.3.0). */
108  chosenWorktree?: boolean
109  /** The checkout the run was started from (it keyed the store before 0.3.0). */
110  root: string
111  /** Where git, the profile's commands and the agents work: a new worktree, or `root`. Absent in runs stored before 0.2.0. */
112  workRoot?: string
113  issue: number
114  title: string
115  body: string
116  url: string
117  branch: string
118  base: string
119  mergeBase: string
120  profile: Profile | null
121  step: Step
122  /** Spawns used in each phase since it last started or was resumed. */
123  used: { red: number; green: number; refactor: number }
124  redCommit?: string
125  greenCommit?: string
126  refactorCommit?: string
127  baseline: Baseline | null
128  /** The latest result of each gate. */
129  gates: { R?: Gate; G?: Gate; F?: Gate }
130  lastGate?: Gate
131  /** Last 200 lines of the test output red's gate saw: green's first prompt gets them. */
132  redTestTail?: string
133  /** Last 200 lines of the latest gate's test output: a retry's prompt gets them. */
134  lastTestTail?: string
135  redAnswer?: string
136  /** Set while red adds a behaviour test for these files (G6's one return). */
137  behaviourFor?: string[]
138  isBehaviourDone?: boolean
139  /** The stash entry holding green's work during that return; applied by its SHA only. */
140  greenStash?: StashRef
141  isRefactorRejected?: boolean
142  refactorStash?: StashRef
143  times: { red?: PhaseTime; green?: PhaseTime; refactor?: PhaseTime; publish?: PhaseTime }
144  startedAt: number
145  agents: AgentRef[]
146  prUrl?: string
147  /** HEAD when the run reached ready (after gate F): publish pushes exactly this commit, and refuses if HEAD moved. */
148  gatedHead?: string
149  merge?: MergeCheck
150  /** The manifest the guard refused a phase agent, relative to the run's folder: the phase's gate blocks on it (Change 0.7.0). */
151  newPlugin?: string
152  /**
153   * `steps`: what the person does next, one item each; `isFinal`: resume can't clear it, so the hint says stop
154   * (Change 0.7.0). Both absent in runs stored before.
155   */
156  blocked?: { at: Step; reason: string; next: string; steps?: string[]; isFinal?: boolean }
157}
158
159/** What the module is doing right now inside a step: a label and since when. */
160export type Activity = { label: string; since: number }
161
162declare module 'claude-code' {
163  interface PluginState {
164    'issue-forge': { run: Run | null; activity: Activity | null; now: number; demo: string | null; focus: number | null; others: OtherRun[] }
165  }
166}
167