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…

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.
/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 promptWithout 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"}}]}
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.
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:
| Field | Default | What 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.
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).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).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.#24 is a pull request, not an issue), since gh issue view answers for those too.--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.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:
| Situation | Choices (the first is the default) |
|---|---|
| Another session drives #44 | Start #N in a new worktree · Take over #44 here · Cancel |
| #44 is blocked, interrupted or ready, and no session drives it | Start #N in a new worktree · Resume #44 here · Stop #44, then start #N · Cancel |
| This session drives #44 | Keep #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:
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.node_modules or other untracked setup. Profile commands that need them fail in a worktree run (there's no setup command yet).\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).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.worktreeDir, so the agents' writes stay under the session's folder.merge-tree --write-tree); with an older git it reads merge check unavailable and the run goes on.prunable in git worktree list. An older git lists it as there, so the resume fails instead of adding it again.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.behaviourTestGlobs) is in the red commit, not what it tests.sh -c 'command -v …', so sh must be on PATH (Git Bash on Windows).origin/<base>, read after the switch; the checkout you start from only names the base.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")./clear and then a reload, your own interrupted run reads as if another session drove it.origin/<base>, sothe profile and anything its commands use must already be there.
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.
| Option | Default | What it does |
|---|---|---|
redModel | opus | model for the red agent |
greenModel | sonnet | model for the green agent |
refactorModel | opus | model for the refactor agent |
redAgentType | issue-forge:red | another 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 |
confirmPublish | true | ask 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: "*".
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.
| Reach | Detail |
|---|---|
| Hooks | session.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>) |
| Files | reads .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 |
| Network | through git and gh only: fetch and, after your yes, push and pull request creation |
| Agents | issue-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 calls | three 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
hooks/register.tsx 2090 lines1import { 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 lines1/**
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 lines1// 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