/wrap close-out command: deterministic git/gh/CI/worktree checklist where unknown is never green.

/wrap is a close-out command for Claude Code. It runs your end-of-session checklist deterministically and prints one table row per check, each pass, fail or unknown with the reason, then a final line Ready to archive: yes|no.
The one rule: anything that could not be established (non-zero exit, missing git or gh, timeout, output it cannot parse) is unknown, and unknown is never a pass. Raw command output is never copied into the table, because git and gh can echo remote URLs with embedded credentials. Reasons use only parsed facts (counts, short SHAs, PR numbers, branch names).
| Row | Passes when |
|---|---|
| Working tree clean | git status --porcelain is empty |
| Fetched origin/<default> | one git fetch origin <default> succeeded (unknown (no origin) without an origin remote) |
| Commits pushed | the PR head equals local HEAD; with no PR, in sync with upstream (or nothing beyond origin/<default>) |
| PR | an open or merged PR exists for the branch without merge conflicts (or there is nothing to raise) |
| CI green on current head | the PR's headRefOid equals local HEAD and every check rollup entry is SUCCESS; an empty rollup is pending |
| Base fresh vs origin/<default> | origin/<default> is an ancestor of HEAD after the fresh fetch (n/a once merged) |
| Merged into origin/<default> | HEAD is contained in origin/<default>, or the PR is MERGED at this exact head |
| No stale worktrees | no other worktree is stale. A worktree counts as stale only when a merged PR (gh pr list --state merged --head <branch>, asked per sibling branch) has the worktree's branch as its head branch AND the worktree's HEAD as its head SHA, and git -C <path> status --porcelain shows its tree clean. git branch --merged is not used, because it also lists a live session's new branch sitting at the base tip. A worktree with uncommitted work is never stale. The main checkout and the session's own worktree are never counted. A detached sibling (no branch, so no PR to match) is never flagged; the row names how many it skipped. A sibling git marks prunable (its folder is gone) is never read and reads unknown, with git worktree prune named as the fix |
| No background tasks or wakeups | the last Stop event this session listed no background tasks AND no session crons (CronCreate, ScheduleWakeup, /loop: work that would wake this session later, which an archive would drop); unknown if no Stop has fired, or the last one carried no task list or no cron list | On a host that raises no Stop to mods (the desktop app, verified 2026-10-12), the main-loop turn end reads the running subagents instead: the row passes when none is running, with a note that shell tasks and wakeups are not listed there and the app refuses to archive live work.
The default branch comes from git symbolic-ref --short refs/remotes/origin/HEAD, falling back to main. All commands run argv-only (no shell) in $.session.cwd() with explicit timeouts: 60 s for network calls (git fetch, gh), 15 s for local git.
/wrap runs the checklist. When a model-only step is open (raise the PR, push, fix CI, merge on green, rebase) it queues the canonical close-out prompt, once per head SHA./wrap --dry never queues the prompt and never requests an archive./wrap --resend queues the close-out prompt again for a head it was already sent for, when it never arrived (see Known limits). Only your own /wrap can resend, never with --dry, and two quick resends still send once./wrap --archive additionally offers to archive, only if you issued the command yourself, the session draws on the desktop app, the app's archive tool is listed, AND every check passed (see Known limits).The close-out prompt restates the rules the mod cannot enforce itself: ship only if the brief authorized it, read CI for the PR's current head, merge only on green re-verified against current <default> right before the merge, never rely on auto-merge, delete this session's merged remote branch and prune, never remove another worktree or branch (one may be a live session's) but name flagged ones in the report, and report to the lead with the PR link. It is sent with asUser: true, tracked in $.state (wrap.submittedFor) by head SHA, and never re-triggered from turn.complete (there is no such hook), so /wrap cannot loop.
Only your own /wrap can send it. Other plugins ($.command.run), scheduled tasks, peer sessions and task notifications can run /wrap too. The prompt is sent, and the archive offered, only when the command's e.origin.kind is composer (you pressed Enter), bridge (Remote Control) or sdk (the desktop app hosts sessions over the SDK, so what you type there arrives as sdk; it is also the one surface the archive exists for). Any other origin (plugin, peer, task-notification, scheduled-trigger, channel, coordinator, observer, auto-continuation, unclassified and the rest), or no origin at all, gets the table plus a note saying the prompt was not sent. The prompt speaks as you, so it must never be set off by something that is not you. Note that sdk also covers a script driving Claude Code through the SDK (claude -p, an automated workflow): those runs are treated as yours.
| Hook | Why |
|---|---|
session.start | registers the /wrap command (not immediate, so it runs between turns) |
command.run { command: 'wrap' } | answers its own command and never calls next; returns { text } only (the row is already what the model reads, so the table is not repeated as context); reads e.origin to gate the prompt and archive; the whole handler is in try/catch and a failure prints only the error kind |
classic.Stop (with a fail-open .catch) | observes background_tasks and session_crons into $.state (wrap.backgroundTasks; task id, type and status, cron id and recurring flag only, never commands, descriptions, cron schedules or cron prompts) because no $ call lists either. A Stop without background_tasks writes null, and one without session_crons leaves crons out, so the row reads unknown |
State contract (types/index.d.ts): PluginState['wrap'] = { submittedFor: string | null; backgroundTasks: unknown | null }.
acceptSkippedChecks (boolean, default false): count SKIPPED and NEUTRAL check conclusions as green. Off, only SUCCESS counts.claude --plugin-dir /absolute/path/to/mods/wrap
or set CLAUDE_CODE_PLUGIN_DIRS=/absolute/path/to/mods/wrap. Check it with claude plugin validate mods/wrap and claude plugin test mods/wrap.
$.prompt.submit from inside a command.run hook (it would wait on the turn the hook is holding), so the close-out prompt is sent from a single $.clock.after(0) callback right after the table is returned. The SHA is claimed inside that callback (compare-and-set against the version /wrap read), so a timer lost to a hot reload claims nothing and the next /wrap retries, and two quick /wraps send once. A submit that rejects, or resolves as { drop } (a hook's refusal or a settings UserPromptSubmit block), releases the SHA so the next /wrap can retry; the release is a compare-and-set on the version the claim itself returned, not a fresh get (every get in one dispatch reads one moment, so a get after the claim in the same timer could miss it). A reload or lost hooks worker in the instant between the claim and the submit could still leave the SHA claimed with nothing sent: the engine has no primitive that makes the claim and the submit one step, and a timer's calls are not replayed after the hook that set it returned. Then /wrap says "already sent" for that head; a new commit clears it, and /wrap --resend sends it again at once. --resend while an earlier prompt is still waiting for the session to go idle queues a second copy.{ session_id: 'self', reason } (the literal self names this session; the reason shows in the app's approval prompt), so that is what /wrap sends; $.tool.list() carries no input schema to read them from. The archive is offered only when $.session.surfaces() includes desktop AND the tool is listed. The probe for mcp__ccd_session_mgmt__archive_session runs when --archive is passed, not once at session.start (a start-time probe would miss servers that connect later, and the state contract has no key to hold it). If the tool needs arguments, the call comes back as a tool error and /wrap shows a toast; use the app's own archive or its "Auto-archive on PR close" preference. The archive runs after the table row is drawn, behind $.ui.ask('Archive this session?', ['Archive', 'Not yet']). Whether $.mcp.call can reach the desktop host's ccd_* tools is undocumented.Stop still reads as running until the next turn ends; a task started after it is invisible.Ready to archive, as the spec has every check gate it, and no close-out step removes another session's worktree. Two sessions that finish together each flag the other until one is archived or its worktree removed outside the mod; this errs toward withholding the archive.gh cannot answer while other worktrees exist, the row reads unknown, not pass. Merged PRs are asked per sibling branch (--head <branch>, 30 per branch), for at most 20 siblings; a sibling past that cap, one whose query failed, one whose branch name is not allow-listed for argv, or one whose answer filled the page without a match reads unknown, never pass. At most 20 merged worktrees' trees are read (the rest read unknown). Each /wrap with siblings makes one gh call per sibling (up to 20, in parallel).$.process.run is documented as CLI only and inherits the Claude process environment, not your login PATH: if git or gh are not on that PATH every row reads unknown.hooks/register.tsx 248 lines1// /wrap: the close-out checklist as a slash command. It answers deterministically
2// from git/gh facts (see checks.ts), says "unknown" rather than guess, and hands
3// the model-only steps (raise PR, merge on green, report to lead) to ONE canonical
4// prompt per head SHA. Nothing here re-triggers itself (there is no turn.complete
5// hook), and only a /wrap the person typed (composer, Remote Control's bridge, or the
6// desktop app, which hosts sessions over the SDK so its typing arrives as `sdk`) may
7// queue that prompt or offer the archive: a plugin, peer, task notification or schedule
8// can also run /wrap, and its run must never turn into a user-voiced instruction to push
9// and merge.
10
11import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
12
13import { closeoutPrompt, evaluate, isReady, needsModel, parseArgs, render, show } from './checks'
14import type { Run } from './checks'
15import { collect } from './collect'
16
17type Dollar = EngineInterface
18
19const ARCHIVE_SERVER = 'ccd_session_mgmt'
20const ARCHIVE_TOOL_NAME = 'archive_session'
21const ARCHIVE_TOOL = `mcp__${ARCHIVE_SERVER}__${ARCHIVE_TOOL_NAME}`
22
23// Literal refs: `claude plugin validate` holds each key to types/index.d.ts.
24const submittedRef = { plugin: 'wrap', key: 'submittedFor' } as const
25const backgroundRef = { plugin: 'wrap', key: 'backgroundTasks' } as const
26
27/** An error's kind only: messages from git, gh or MCP can carry URLs or tokens. */
28const kindOf = (err: unknown): string => (err instanceof Error && err.name ? err.name : 'error')
29
30const makeRun =
31 ($: Dollar, cwd: string): Run =>
32 async (argv, timeoutMs) => {
33 try {
34 // cwd is the session's (a worktree), never repo().root, which is the main checkout.
35 const r = await $.process.run(argv, { cwd, timeoutMs })
36 return { kind: 'exit', code: r.exitCode, out: r.stdout, err: r.stderr }
37 } catch {
38 // Missing binary, spawn failure or timeout: all "could not establish".
39 return { kind: 'failed' }
40 }
41 }
42
43/**
44 * The origins that are the person's own typing (a binding lead decision); everything else
45 * (plugin, peer, task-notification, scheduled-trigger, channel, coordinator, observer,
46 * auto-continuation, unclassified, an unstamped origin) only gets the table. `sdk` is in
47 * because the desktop app hosts sessions over the SDK: a /wrap typed there arrives as `sdk`,
48 * and the desktop app is the one surface the archive exists for.
49 */
50const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
51
52/** The archive needs the app's tool AND a desktop surface; either missing means "use the app". */
53async function archiveAvailable($: Dollar): Promise<boolean> {
54 try {
55 const [tools, surfaces] = await Promise.all([$.tool.list(), $.session.surfaces()])
56 return surfaces.includes('desktop') && tools.some(t => t.name === ARCHIVE_TOOL)
57 } catch {
58 return false
59 }
60}
61
62/**
63 * Gives the SHA back so a later /wrap may send again, unless another write has moved it on.
64 * Compare-and-set on the claim's own version, never a fresh get: every get of one dispatch
65 * reads one moment, so a get after the claim in this same timer could miss the claim.
66 */
67async function release($: Dollar, claimedVersion: number): Promise<void> {
68 try {
69 await $.state.set(submittedRef, null, { ifVersion: claimedVersion })
70 } catch {
71 // nothing more to do
72 }
73}
74
75/**
76 * Runs from a one-shot timer after the command hook returned: the engine refuses
77 * $.prompt.submit inside command.run. The SHA is claimed here, not in the hook, so a
78 * timer lost to a hot reload leaves nothing claimed and the next /wrap retries; the
79 * compare-and-set on the version read in the hook keeps two quick /wraps to one send.
80 */
81async function sendCloseout($: Dollar, sha: string, version: number, text: string): Promise<void> {
82 let claimed: number | undefined
83 try {
84 const res = await $.state.set(submittedRef, sha, { ifVersion: version })
85 if (!res.isSet) return
86 claimed = res.version
87 const sent = await $.prompt.submit({ text, asUser: true })
88 // A hook's refusal or a settings block resolves as { drop }, not a rejection.
89 if (sent.drop !== undefined) await release($, claimed)
90 } catch {
91 if (claimed !== undefined) await release($, claimed)
92 }
93}
94
95/** Runs after the text row is drawn (scheduled with clock.after), since archiving ends this session. */
96async function archive($: Dollar): Promise<void> {
97 try {
98 const choice = await $.ui.ask('Archive this session?', ['Archive', 'Not yet'])
99 if (choice !== 'Archive') return
100 // The app's archive tool takes the literal "self" for this session and a reason it shows in its approval prompt.
101 const res = await $.mcp.call(ARCHIVE_SERVER, ARCHIVE_TOOL_NAME, { session_id: 'self', reason: '/wrap: every check passed' })
102 if (res.isError) $.ui.toast('/wrap: the app refused the archive request (tool error)')
103 } catch (err) {
104 $.ui.toast(`/wrap: archive not done (${kindOf(err)})`)
105 }
106}
107
108async function wrap($: Dollar, rawArgs: string, origin: PromptOrigin | undefined, acceptSkippedChecks: boolean) {
109 const args = parseArgs(rawArgs)
110 const cwd = await $.session.cwd()
111 const bg = await $.state.get(backgroundRef)
112 const facts = await collect(makeRun($, cwd), cwd, bg.value ?? null)
113 const rows = evaluate(facts, { acceptSkippedChecks })
114 const ready = isReady(rows)
115 const notes: string[] = []
116 // Fails closed: an origin the engine did not stamp is not the person's.
117 const byPerson = origin !== undefined && PERSON_ORIGINS.has(origin.kind)
118 const notPerson = `/wrap ran from ${show(origin?.kind ?? 'an unstamped origin', 30)}, not typed by you`
119
120 if (args.unknown.length > 0) notes.push(`Ignored unknown argument(s): ${args.unknown.join(' ')}`)
121
122 // Model-only work: once per head SHA, person-issued only, suppressed by --dry.
123 if (needsModel(rows)) {
124 if (args.dry) {
125 notes.push('--dry: close-out prompt not sent.')
126 } else if (!byPerson) {
127 notes.push(`Close-out prompt not sent: ${notPerson}. Type /wrap to queue it.`)
128 } else if (facts.head === undefined) {
129 notes.push('Close-out prompt not sent: HEAD unknown.')
130 } else {
131 const held = await $.state.get(submittedRef)
132 // --resend is the person's way out when a claimed send never arrived (a hooks worker
133 // lost between the claim and the submit leaves the SHA claimed with nothing sent, and
134 // no engine primitive makes the two atomic). It is explicit and person-only, so it
135 // cannot loop; the claim stays a compare-and-set, so two quick /wraps still send once.
136 if (held.value === facts.head && !args.resend) {
137 notes.push(
138 `Close-out prompt already sent for ${facts.head.slice(0, 7)}; not sending again until HEAD changes. If it never arrived, type /wrap --resend.`,
139 )
140 } else {
141 const sha = facts.head
142 const text = closeoutPrompt(facts.defaultBranch)
143 $.clock.after(0, () => {
144 void sendCloseout($, sha, held.version, text)
145 })
146 notes.push(
147 `${held.value === facts.head ? '--resend: queued the close-out prompt again' : 'Queued the close-out prompt for the model (once per commit)'}; it starts when the session is idle. If a hook refuses it, the next /wrap tries again.`,
148 )
149 }
150 }
151 } else if (args.resend) {
152 notes.push('--resend: nothing for the model to do, so no prompt was sent.')
153 }
154
155 // Archive: person-issued, capability-probed, explicit, confirmed, and only on a fully green table.
156 if (ready) {
157 if (args.archive && args.dry) {
158 notes.push('--dry: archive not requested; ready to archive (use the app).')
159 } else if (args.archive && !byPerson) {
160 notes.push(`Archive not offered: ${notPerson}. Ready to archive (use the app).`)
161 } else if (args.archive) {
162 if (await archiveAvailable($)) {
163 notes.push('Archive requested: a confirmation follows.')
164 $.clock.after(0, () => {
165 void archive($)
166 })
167 } else {
168 notes.push('Archive tool not available here: ready to archive (use the app).')
169 }
170 } else {
171 notes.push('ready to archive (use the app)')
172 }
173 } else if (args.archive) {
174 notes.push('Archive skipped: not every check passed.')
175 }
176
177 // `text` is already the row the model reads, so no `context` copy of the table.
178 return { text: render(facts, rows, notes) }
179}
180
181export const register: Register = (on, options) => {
182 const acceptSkippedChecks = options.acceptSkippedChecks === true
183
184 on('session.start', async ($, e, next) => {
185 await $.command.register({
186 name: 'wrap',
187 description:
188 'Close-out checklist: tree, push, PR, CI on head, base freshness, merged, worktrees, background tasks',
189 argumentHint: '[--archive] [--dry] [--resend]',
190 })
191 return next(e)
192 })
193
194 // Observer, fails open: caches what is still running or scheduled at each Stop, because no
195 // `$` call lists either. Ids, types, statuses and the recurring flag only: descriptions,
196 // commands and cron prompts may hold secrets or the person's own words.
197 on('classic.Stop', async ($, e, next) => {
198 try {
199 if (e.agent_id === undefined) {
200 // The field is optional: a Stop without it says nothing about tasks, so the row
201 // goes back to unknown rather than reading as "none running".
202 const listed = Array.isArray(e.background_tasks) ? e.background_tasks : undefined
203 const tasks = listed?.map(t => ({
204 id: String(t.id).slice(0, 64),
205 type: String(t.type).slice(0, 32),
206 status: String(t.status).slice(0, 32),
207 }))
208 // Crons (CronCreate, ScheduleWakeup, /loop) wake this session later: archiving drops them.
209 // Absent stays absent, so the row reads unknown rather than "none scheduled".
210 const crons = Array.isArray(e.session_crons)
211 ? e.session_crons.map(c => ({ id: String(c.id).slice(0, 64), recurring: c.recurring === true }))
212 : undefined
213 await $.state.set(backgroundRef, tasks ? (crons ? { tasks, crons } : { tasks }) : null)
214 }
215 } catch {
216 // never let bookkeeping disturb the stop
217 }
218 return next(e)
219 }).catch(($, e, next) => next(e)) // fails open: bookkeeping must never disturb a stop
220
221 // The desktop host raises no classic.Stop to mods (verified 2026-10-12): after a main-loop turn,
222 // the running subagents stand in for the task list. A Stop that does arrive overwrites this.
223 on('turn.complete', async ($, e, next) => {
224 const result = await next(e)
225 if (e.agentId === undefined) {
226 try {
227 const agents = await $.agent.list()
228 const tasks = agents
229 .filter(a => a.status === 'pending' || a.status === 'running' || a.status === 'waiting')
230 .map(a => ({ id: String(a.id).slice(0, 64), type: 'agent', status: String(a.status).slice(0, 32) }))
231 await $.state.set(backgroundRef, { tasks, source: 'agents' })
232 } catch {
233 // a list that fails leaves what was known
234 }
235 }
236 return result
237 }).catch(($, e, next) => next(e))
238
239 // Answers its own command (never calls next); a failure becomes the row, with the error kind only.
240 on('command.run', { command: 'wrap' }, async ($, e) => {
241 try {
242 return await wrap($, e.args, e.origin, acceptSkippedChecks)
243 } catch (err) {
244 return { text: `/wrap could not run (${kindOf(err)}). Nothing was checked, so nothing passed.\n\nReady to archive: no` }
245 }
246 })
247}
248hooks/checks.ts 469 lines1// Pure close-out logic for /wrap: no `$`, no I/O. collect.ts gathers Facts
2// through an injected runner and register.tsx wires the command; keeping the
3// judgement here lets the tests feed it mocked process results.
4//
5// One rule runs through every row: anything that could not be established
6// (non-zero exit, missing binary, timeout, unparseable output) is `unknown`,
7// and `unknown` is never a pass. No raw stderr or stdout is ever copied into a
8// reason: git/gh can echo remote URLs with embedded credentials, so reasons are
9// built only from parsed fields (counts, SHAs, PR numbers, branch names).
10
11export type Status = 'pass' | 'fail' | 'unknown'
12
13export type Row = { id: RowId; check: string; status: Status; reason: string }
14
15export type RowId =
16 | 'tree'
17 | 'fetch'
18 | 'pushed'
19 | 'pr'
20 | 'ci'
21 | 'base'
22 | 'merged'
23 | 'worktrees'
24 | 'background'
25
26/** The result of one argv run: it exited (any code) or it could not complete. */
27export type Proc =
28 | { kind: 'exit'; code: number; out: string; err: string }
29 | { kind: 'failed' }
30
31export type Run = (argv: readonly string[], timeoutMs: number) => Promise<Proc>
32
33export type PrFact =
34 | { kind: 'unknown' }
35 | { kind: 'none' }
36 | {
37 kind: 'pr'
38 number: number
39 state: string
40 headRefOid: string
41 baseRefName: string
42 mergedAt: string | null
43 mergeStateStatus: string
44 rollup: unknown[] | undefined
45 }
46
47/** `prunable`: git says the worktree's folder is gone (only `git worktree prune` clears the entry). */
48export type Worktree = { path: string; head: string; branch?: string; prunable?: boolean }
49
50export type Facts = {
51 cwd: string
52 head?: string
53 branch?: string
54 dirtyCount?: number
55 defaultBranch: string
56 /** undefined: could not tell; false: no `origin` remote. */
57 hasOrigin?: boolean
58 /** undefined: not attempted (no origin) or unknown. */
59 fetchOk?: boolean
60 ahead?: number
61 /** origin/<default> is an ancestor of HEAD (base is fresh). */
62 baseContained?: boolean
63 /** HEAD is an ancestor of origin/<default> (already merged). */
64 headInBase?: boolean
65 upstream?: { exists: false } | { exists: true; ahead: number | undefined }
66 pr: PrFact
67 worktrees?: Worktree[]
68 selfRoot?: string
69 /** Merged PRs gh listed for the sibling branches it was asked about; undefined when gh answered none. */
70 ghMerged?: { name: string; oid: string }[]
71 /**
72 * Sibling worktrees gh gave no complete answer for (query failed, a full page, an unsafe
73 * branch name, or past the query cap). Not knowing is never "not merged", so any reads unknown.
74 */
75 ghUnanswered?: number
76 /** Uncommitted-path count per sibling worktree path, read only for PR-merged ones; absent: unknown. */
77 siblingDirty?: Record<string, number>
78 background: unknown
79}
80
81export type Options = { acceptSkippedChecks: boolean }
82
83const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
84const SHA_RE = /^[0-9a-f]{40}$/
85
86/** Branch names go into argv and into text drawn in a terminal: allow-list them. */
87export function safeBranch(name: string | undefined, fallback: string): string {
88 const n = (name ?? '').trim()
89 return n.length > 0 && n.length <= 100 && BRANCH_RE.test(n) && !n.includes('..') ? n : fallback
90}
91
92/** A name read from git, made safe to draw (no control or escape characters). */
93export function show(text: string, max = 60): string {
94 const clean = text.replace(/[^\x20-\x7e]/g, '?')
95 return clean.length > max ? clean.slice(0, max - 1) + '~' : clean
96}
97
98const short = (sha: string | undefined) => (sha ? sha.slice(0, 7) : '?')
99
100export function parseWorktrees(out: string): Worktree[] {
101 const list: Worktree[] = []
102 let cur: Worktree | undefined
103 for (const line of out.split('\n')) {
104 if (line.startsWith('worktree ')) {
105 if (cur) list.push(cur)
106 cur = { path: line.slice('worktree '.length), head: '' }
107 } else if (cur && line.startsWith('HEAD ')) {
108 cur.head = line.slice('HEAD '.length).trim()
109 } else if (cur && line.startsWith('branch ')) {
110 cur.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '')
111 } else if (cur && (line === 'prunable' || line.startsWith('prunable '))) {
112 cur.prunable = true
113 }
114 }
115 if (cur) list.push(cur)
116 return list
117}
118
119/** gh's own words for "this branch has no PR": a known fact, not an outage. */
120export function isNoPr(err: string): boolean {
121 return /no pull requests found/i.test(err)
122}
123
124export function parsePr(out: string): PrFact {
125 try {
126 const j: unknown = JSON.parse(out)
127 if (typeof j !== 'object' || j === null) return { kind: 'unknown' }
128 const o = j as Record<string, unknown>
129 if (typeof o.number !== 'number' || typeof o.state !== 'string') return { kind: 'unknown' }
130 if (typeof o.headRefOid !== 'string' || !SHA_RE.test(o.headRefOid)) return { kind: 'unknown' }
131 return {
132 kind: 'pr',
133 number: o.number,
134 state: o.state,
135 headRefOid: o.headRefOid,
136 baseRefName: typeof o.baseRefName === 'string' ? o.baseRefName : '',
137 mergedAt: typeof o.mergedAt === 'string' && o.mergedAt !== '' ? o.mergedAt : null,
138 mergeStateStatus: typeof o.mergeStateStatus === 'string' ? o.mergeStateStatus : '',
139 rollup: Array.isArray(o.statusCheckRollup) ? o.statusCheckRollup : undefined,
140 }
141 } catch {
142 return { kind: 'unknown' }
143 }
144}
145
146/** Every worktree other than the main checkout and the session's own. */
147export function siblings(f: Facts): Worktree[] {
148 const wts = f.worktrees ?? []
149 return wts.slice(1).filter(w => {
150 if (f.selfRoot && w.path === f.selfRoot) return false
151 if (f.cwd === w.path || f.cwd.startsWith(w.path + '/')) return false
152 return true
153 })
154}
155
156/**
157 * The siblings that can be checked: on a named branch (stale needs a merged PR at the
158 * branch's head) at an absolute path. A detached sibling is never flagged, so the row
159 * names how many it skipped rather than claiming there are none.
160 */
161export function candidates(f: Facts): Worktree[] {
162 // Paths go into `git -C <path>` argv: absolute only, so nothing reads as an option.
163 return siblings(f).filter(w => w.branch !== undefined && w.branch !== '' && w.path.startsWith('/'))
164}
165
166export function classifyCi(
167 rollup: unknown[] | undefined,
168 acceptSkipped: boolean,
169): { status: Status; reason: string } {
170 if (!rollup) return { status: 'unknown', reason: 'check rollup missing from gh output' }
171 if (rollup.length === 0) return { status: 'fail', reason: 'pending: no checks reported yet' }
172 let ok = 0
173 let skipped = 0
174 let pending = 0
175 let failed = 0
176 let odd = 0
177 for (const entry of rollup) {
178 const e = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<string, unknown>
179 if (typeof e.status === 'string') {
180 // CheckRun
181 if (e.status !== 'COMPLETED') pending++
182 else if (e.conclusion === 'SUCCESS') ok++
183 else if (e.conclusion === 'NEUTRAL' || e.conclusion === 'SKIPPED') skipped++
184 else if (typeof e.conclusion === 'string' && e.conclusion !== '') failed++
185 else odd++
186 } else if (typeof e.state === 'string') {
187 // StatusContext
188 if (e.state === 'SUCCESS') ok++
189 else if (e.state === 'PENDING' || e.state === 'EXPECTED') pending++
190 else if (e.state === 'FAILURE' || e.state === 'ERROR') failed++
191 else odd++
192 } else odd++
193 }
194 const n = rollup.length
195 if (failed > 0) return { status: 'fail', reason: `${failed} of ${n} checks failing` }
196 if (pending > 0) return { status: 'fail', reason: `pending: ${pending} of ${n} checks not finished` }
197 if (odd > 0) return { status: 'unknown', reason: `${odd} of ${n} checks in an unrecognised shape` }
198 if (skipped > 0 && !acceptSkipped) {
199 return { status: 'fail', reason: `${skipped} of ${n} checks skipped or neutral, not success` }
200 }
201 return { status: 'pass', reason: `all ${n} checks green` }
202}
203
204/**
205 * The last Stop's in-flight work: background tasks and session crons (CronCreate,
206 * ScheduleWakeup, /loop) that will wake this session later. `crons` undefined: that
207 * Stop did not list them, which is not the same as "none scheduled".
208 */
209export function readBackground(value: unknown): { tasks: number; crons: number | undefined; source: 'stop' | 'agents' } | undefined {
210 if (typeof value !== 'object' || value === null) return undefined
211 const v = value as Record<string, unknown>
212 if (!Array.isArray(v.tasks)) return undefined
213 return { tasks: v.tasks.length, crons: Array.isArray(v.crons) ? v.crons.length : undefined, source: v.source === 'agents' ? 'agents' : 'stop' }
214}
215
216export function evaluate(f: Facts, opts: Options): Row[] {
217 const rows: Row[] = []
218 const add = (id: RowId, check: string, status: Status, reason: string) =>
219 rows.push({ id, check, status, reason })
220 const base = `origin/${f.defaultBranch}`
221 const pr = f.pr
222 const prMerged =
223 pr.kind === 'pr' && pr.mergedAt !== null && pr.state === 'MERGED' && pr.headRefOid === f.head
224 const nothingToShip = f.ahead === 0 && pr.kind === 'none'
225
226 // 1. tree
227 if (f.dirtyCount === undefined) add('tree', 'Working tree clean', 'unknown', 'git status did not complete')
228 else if (f.dirtyCount === 0) add('tree', 'Working tree clean', 'pass', 'no uncommitted changes')
229 else add('tree', 'Working tree clean', 'fail', `${f.dirtyCount} changed or untracked path(s)`)
230
231 // 2. fetch
232 if (f.hasOrigin === false) add('fetch', `Fetched ${base}`, 'unknown', 'unknown (no origin)')
233 else if (f.fetchOk === true) add('fetch', `Fetched ${base}`, 'pass', 'fetched just now')
234 else add('fetch', `Fetched ${base}`, 'unknown', 'fetch did not complete, so origin refs may be stale')
235
236 // 3. pushed
237 if (f.head === undefined) {
238 add('pushed', 'Commits pushed', 'unknown', 'could not read HEAD')
239 } else if (pr.kind === 'pr') {
240 if (pr.headRefOid === f.head) add('pushed', 'Commits pushed', 'pass', `PR head matches local HEAD ${short(f.head)}`)
241 else
242 add(
243 'pushed',
244 'Commits pushed',
245 'fail',
246 `local HEAD ${short(f.head)} differs from PR head ${short(pr.headRefOid)}`,
247 )
248 } else if (f.upstream?.exists === true && f.upstream.ahead !== undefined) {
249 if (f.upstream.ahead === 0) add('pushed', 'Commits pushed', 'pass', 'in sync with upstream (no PR yet)')
250 else add('pushed', 'Commits pushed', 'fail', `${f.upstream.ahead} commit(s) not on upstream`)
251 } else if (f.upstream?.exists === false) {
252 if (nothingToShip) add('pushed', 'Commits pushed', 'pass', `nothing beyond ${base}`)
253 else add('pushed', 'Commits pushed', 'fail', 'no upstream branch set')
254 } else {
255 add('pushed', 'Commits pushed', 'unknown', 'could not compare with upstream or PR')
256 }
257
258 // 4. PR
259 if (pr.kind === 'pr') {
260 const conflict = pr.mergeStateStatus === 'DIRTY'
261 if (pr.state === 'MERGED') add('pr', 'PR', 'pass', `#${pr.number} merged`)
262 else if (pr.state === 'CLOSED') add('pr', 'PR', 'fail', `#${pr.number} closed without merging`)
263 else if (conflict) add('pr', 'PR', 'fail', `#${pr.number} open but has merge conflicts`)
264 else add('pr', 'PR', 'pass', `#${pr.number} open${pr.baseRefName ? ` into ${show(pr.baseRefName)}` : ''}`)
265 } else if (pr.kind === 'none') {
266 if (f.ahead === 0) add('pr', 'PR', 'pass', `no commits beyond ${base}, nothing to raise`)
267 else if (f.ahead === undefined) add('pr', 'PR', 'unknown', 'no PR found and commits ahead of base not known')
268 else add('pr', 'PR', 'fail', `no PR for this branch (${f.ahead} commit(s) ahead of ${base})`)
269 } else {
270 add('pr', 'PR', 'unknown', 'gh pr view did not complete (missing, signed out, offline or timed out)')
271 }
272
273 // 5. CI on the current head
274 if (pr.kind === 'pr') {
275 if (f.head !== undefined && pr.headRefOid !== f.head) {
276 add('ci', 'CI green on current head', 'fail', `checks belong to ${short(pr.headRefOid)}, local HEAD is ${short(f.head)}`)
277 } else if (f.head === undefined) {
278 add('ci', 'CI green on current head', 'unknown', 'could not read HEAD to compare with the PR head')
279 } else if (prMerged) {
280 add('ci', 'CI green on current head', 'pass', 'PR merged; CI no longer gating')
281 } else {
282 const c = classifyCi(pr.rollup, opts.acceptSkippedChecks)
283 add('ci', 'CI green on current head', c.status, c.reason)
284 }
285 } else if (pr.kind === 'none') {
286 if (nothingToShip) add('ci', 'CI green on current head', 'pass', 'n/a: nothing to ship')
287 else add('ci', 'CI green on current head', 'fail', 'no PR, so no CI result for this head')
288 } else {
289 add('ci', 'CI green on current head', 'unknown', 'PR state unknown')
290 }
291
292 // 7 first (base depends on it): merged into the default branch
293 let mergedStatus: Status
294 let mergedReason: string
295 if (prMerged) {
296 mergedStatus = 'pass'
297 mergedReason = `PR #${(pr as { number: number }).number} merged at this head`
298 } else if (f.headInBase === true) {
299 mergedStatus = 'pass'
300 mergedReason = `HEAD is contained in ${base}`
301 } else if (pr.kind === 'pr' && pr.state === 'MERGED') {
302 mergedStatus = 'fail'
303 mergedReason = `PR #${pr.number} merged, but local HEAD ${short(f.head)} is not the merged head`
304 } else if (pr.kind === 'pr') {
305 mergedStatus = 'fail'
306 mergedReason = `PR #${pr.number} is ${pr.state.toLowerCase()}, not merged`
307 } else if (f.headInBase === false && pr.kind === 'none') {
308 mergedStatus = 'fail'
309 mergedReason = `HEAD is not in ${base} and no PR was found`
310 } else {
311 // headInBase false/undefined with PR unknown: a squash merge would look unmerged here.
312 mergedStatus = 'unknown'
313 mergedReason = 'could not establish (PR state unknown, or merge ancestry check did not complete)'
314 }
315
316 // 6. base freshness
317 if (mergedStatus === 'pass') {
318 add('base', `Base fresh vs ${base}`, 'pass', 'n/a: already merged')
319 } else if (f.hasOrigin === false) {
320 add('base', `Base fresh vs ${base}`, 'unknown', 'unknown (no origin)')
321 } else if (f.fetchOk !== true) {
322 add('base', `Base fresh vs ${base}`, 'unknown', 'fetch did not complete, so the local base ref may be stale')
323 } else if (f.baseContained === true) {
324 add('base', `Base fresh vs ${base}`, 'pass', `${base} is already in this branch`)
325 } else if (f.baseContained === false) {
326 add('base', `Base fresh vs ${base}`, 'fail', `${base} has commits this branch lacks (rebase or merge needed)`)
327 } else {
328 add('base', `Base fresh vs ${base}`, 'unknown', 'ancestry check did not complete')
329 }
330 rows.push({ id: 'merged', check: `Merged into ${base}`, status: mergedStatus, reason: mergedReason })
331
332 // 8. other stale worktrees: flagged only with a merged PR at the worktree's head and a
333 // clean tree; anything else may be a live session's and is never counted.
334 if (f.worktrees === undefined) {
335 add('worktrees', 'No stale worktrees', 'unknown', 'git worktree list did not complete')
336 } else {
337 const others = candidates(f)
338 const skipped = siblings(f).filter(w => !w.branch).length
339 const skippedNote = skipped > 0 ? `; ${skipped} detached worktree(s) not checked (no branch, so no PR to match)` : ''
340 if (others.length === 0) {
341 add(
342 'worktrees',
343 'No stale worktrees',
344 'pass',
345 skipped > 0
346 ? `no other worktree on a branch${skippedNote}`
347 : 'no other worktrees (this session\'s own is excluded)',
348 )
349 } else if (f.ghMerged === undefined) {
350 add('worktrees', 'No stale worktrees', 'unknown', `could not ask gh whether ${others.length} other worktree branch(es) are merged`)
351 } else {
352 const stale: string[] = []
353 const unanswered = f.ghUnanswered ?? 0
354 let undecided = 0
355 let gone = 0
356 for (const w of others) {
357 if (!prMergedAtHead(f, w)) continue
358 const dirty = f.siblingDirty?.[w.path]
359 if (dirty === undefined) {
360 undecided++
361 if (w.prunable === true) gone++
362 }
363 else if (dirty === 0) stale.push(show(w.branch as string))
364 // dirty > 0: uncommitted work lives there, so it is never stale.
365 }
366 if (stale.length > 0) {
367 const more = stale.length > 3 ? ` (+${stale.length - 3} more)` : ''
368 add('worktrees', 'No stale worktrees', 'fail', `PR merged, tree clean, still checked out: ${stale.slice(0, 3).join(', ')}${more}`)
369 } else if (undecided > 0) {
370 // A prunable entry stays unknown (its tree cannot be read), but the person is told the fix.
371 const hint = gone > 0 ? ` (${gone} of them marked prunable by git, its folder gone: \`git worktree prune\` clears it)` : ''
372 add('worktrees', 'No stale worktrees', 'unknown', `could not read the tree of ${undecided} other worktree(s) whose PR merged${hint}`)
373 } else if (unanswered > 0) {
374 add('worktrees', 'No stale worktrees', 'unknown', `gh gave no complete answer for ${unanswered} of ${others.length} other worktree branch(es)`)
375 } else {
376 add('worktrees', 'No stale worktrees', 'pass', `${others.length} other worktree(s), none with a merged PR at its head${skippedNote}`)
377 }
378 }
379 }
380
381 // 9. background tasks and scheduled wakeups: archiving would drop either silently.
382 const BG = 'No background tasks or wakeups'
383 const bg = readBackground(f.background)
384 if (bg === undefined) {
385 add('background', BG, 'unknown', 'unknown (no turn end with a task list observed this session)')
386 } else if (bg.source === 'agents') {
387 // A host that raises no classic.Stop (the desktop): running subagents are what can be seen;
388 // background shell tasks and wakeups are the app's to guard (its archive refuses live work).
389 if (bg.tasks > 0) add('background', BG, 'fail', `${bg.tasks} subagent(s) still running as of the last turn end`)
390 else add('background', BG, 'pass', 'no subagent running as of the last turn end (shell tasks and wakeups are not listed on this host; the app refuses to archive live work)')
391 } else if (bg.tasks > 0 || (bg.crons ?? 0) > 0) {
392 const parts: string[] = []
393 if (bg.tasks > 0) parts.push(`${bg.tasks} task(s) still running`)
394 if ((bg.crons ?? 0) > 0) parts.push(`${bg.crons} scheduled wakeup(s) pending (cron, ScheduleWakeup or /loop)`)
395 add('background', BG, 'fail', `${parts.join(', ')} as of the last turn end`)
396 } else if (bg.crons === undefined) {
397 add('background', BG, 'unknown', 'no tasks running, but the last Stop did not list scheduled wakeups')
398 } else {
399 add('background', BG, 'pass', 'none running or scheduled as of the last turn end')
400 }
401
402 return rows
403}
404
405export function isReady(rows: readonly Row[]): boolean {
406 return rows.length > 0 && rows.every(r => r.status === 'pass')
407}
408
409/**
410 * A sibling worktree is stale only on positive evidence: a merged PR whose head branch AND
411 * head SHA equal the worktree's. `git branch --merged` is not evidence, because it also lists a
412 * live session's brand-new branch that sits at the base tip with no commits of its own.
413 */
414export function prMergedAtHead(f: Facts, w: Worktree): boolean {
415 return mergedAtHead(f.ghMerged ?? [], w)
416}
417
418/** The one matching rule: head branch AND head SHA. A name alone may be an older PR's reused branch. */
419export function mergedAtHead(list: readonly { name: string; oid: string }[], w: Worktree): boolean {
420 return list.some(m => m.name === w.branch && m.oid === w.head)
421}
422
423/** Rows whose fix is model work: raise the PR, push, fix CI, merge on green, rebase. */
424const MODEL_ROWS: readonly RowId[] = ['pushed', 'pr', 'ci', 'base', 'merged']
425
426export function needsModel(rows: readonly Row[]): boolean {
427 return rows.some(r => r.status === 'fail' && MODEL_ROWS.includes(r.id))
428}
429
430export function render(f: Facts, rows: readonly Row[], notes: readonly string[]): string {
431 const width = Math.max(...rows.map(r => r.check.length))
432 const lines = [
433 `/wrap close-out: ${show(f.branch ?? 'detached HEAD')} @ ${short(f.head)}`,
434 ...rows.map(r => `${r.status.padEnd(8)}${r.check.padEnd(width + 2)}${r.reason}`),
435 ]
436 if (notes.length > 0) lines.push('', ...notes)
437 lines.push('', `Ready to archive: ${isReady(rows) ? 'yes' : 'no'}`)
438 return lines.join('\n')
439}
440
441/** The canonical close-out prompt. The merge rule is restated because nothing in the mod enforces it. */
442export function closeoutPrompt(defaultBranch: string): string {
443 const base = `origin/${defaultBranch}`
444 return [
445 `Close out this session's work (the /wrap checklist found items open). Only if this session's brief authorized shipping: a read-only session ends with a report, never a PR, branch or remote change.`,
446 `1. Re-check git status. If the tree is dirty, report what is dirty; do not commit files you did not change.`,
447 `2. If commits are unpushed, push the branch. If there is no PR, raise one against ${defaultBranch}.`,
448 `3. Read CI for the PR's CURRENT head: confirm the PR headRefOid equals git rev-parse HEAD. Red or stale CI is fixed or reported, never merged over.`,
449 `4. Merge only when CI is green on that head, and re-verify against current ${defaultBranch} right before merging: fetch ${defaultBranch}, confirm ${base} is already in the branch (rebase and re-run CI if not). Never rely on auto-merge to gate it.`,
450 `5. After the merge: delete this session's merged remote branch and run git fetch --prune. Do not remove any other worktree or branch, since one may belong to a live session; name any the checklist flagged as stale in your report instead.`,
451 `6. Report to the lead: what actually ran and passed, what is merged, what is still pending, and the PR link. Do not archive the session.`,
452 ].join('\n')
453}
454
455export function parseArgs(args: string): { dry: boolean; archive: boolean; resend: boolean; unknown: string[] } {
456 const tokens = args.trim().split(/\s+/).filter(Boolean)
457 const unknown: string[] = []
458 let dry = false
459 let archive = false
460 let resend = false
461 for (const t of tokens) {
462 if (t === '--dry') dry = true
463 else if (t === '--archive') archive = true
464 else if (t === '--resend') resend = true
465 else unknown.push(show(t, 30))
466 }
467 return { dry, archive, resend, unknown }
468}
469hooks/collect.ts 166 lines1// Gathers the facts /wrap judges, through an injected runner so tests can feed
2// canned process results. Every call is argv-only (no shell) with an explicit
3// timeout; network calls (fetch, gh) get 60 s, local git 15 s. A call that
4// cannot complete becomes `undefined`/'unknown' in Facts, never a pass.
5
6import { candidates, isNoPr, mergedAtHead, parsePr, parseWorktrees, prMergedAtHead, safeBranch } from './checks'
7import type { Facts, Proc, Run } from './checks'
8
9const LOCAL_MS = 15000
10const NET_MS = 60000
11const MAX_SIBLING_READS = 20
12/** Per-branch merged-PR queries per /wrap; siblings past it read unknown. */
13const MAX_GH_QUERIES = 20
14/** Page size per branch: a full page may have cut off the matching PR, so only a match settles it. */
15const MERGED_PAGE = 30
16
17const ok = (p: Proc): p is Extract<Proc, { kind: 'exit' }> => p.kind === 'exit' && p.code === 0
18const text = (p: Proc) => (p.kind === 'exit' ? p.out.trim() : '')
19
20const PR_FIELDS = 'number,state,headRefOid,baseRefName,mergedAt,mergeStateStatus,statusCheckRollup,url'
21
22export async function collect(run: Run, cwd: string, background: unknown): Promise<Facts> {
23 // Stage 1: independent reads, in parallel.
24 const [head, status, branch, symref, remotes, top, wtList, prView, upstreamName] = await Promise.all([
25 run(['git', 'rev-parse', 'HEAD'], LOCAL_MS),
26 run(['git', 'status', '--porcelain'], LOCAL_MS),
27 run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], LOCAL_MS),
28 run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], LOCAL_MS),
29 run(['git', 'remote'], LOCAL_MS),
30 run(['git', 'rev-parse', '--show-toplevel'], LOCAL_MS),
31 run(['git', 'worktree', 'list', '--porcelain'], LOCAL_MS),
32 run(['gh', 'pr', 'view', '--json', PR_FIELDS], NET_MS),
33 run(['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], LOCAL_MS),
34 ])
35
36 const defaultBranch = safeBranch(
37 ok(symref) ? text(symref).replace(/^origin\//, '') : undefined,
38 'main',
39 )
40 const base = `origin/${defaultBranch}`
41 const hasOrigin = remotes.kind === 'exit' && remotes.code === 0
42 ? remotes.out.split('\n').some(l => l.trim() === 'origin')
43 : undefined
44
45 const headSha = ok(head) && /^[0-9a-f]{40}$/.test(text(head)) ? text(head) : undefined
46
47 // The kind is tested first: after an `ok()` miss its type guard would also rule out
48 // a non-zero exit, which is exactly where gh says "no pull requests found".
49 let pr: Facts['pr']
50 if (prView.kind !== 'exit') pr = { kind: 'unknown' }
51 else if (prView.code === 0) pr = parsePr(prView.out)
52 else if (isNoPr(prView.err)) pr = { kind: 'none' }
53 else pr = { kind: 'unknown' }
54
55 // Stage 2: the one fetch (only when there is an origin to fetch from).
56 let fetchOk: boolean | undefined
57 if (hasOrigin === true) fetchOk = ok(await run(['git', 'fetch', 'origin', defaultBranch], NET_MS))
58
59 // Stage 3: ancestry reads against the (now fresher) origin ref.
60 const upstreamRef = ok(upstreamName) ? text(upstreamName) : ''
61 const [aheadP, baseInHead, headInBaseP, upAhead] = await Promise.all([
62 run(['git', 'rev-list', '--count', `${base}..HEAD`], LOCAL_MS),
63 run(['git', 'merge-base', '--is-ancestor', base, 'HEAD'], LOCAL_MS),
64 run(['git', 'merge-base', '--is-ancestor', 'HEAD', base], LOCAL_MS),
65 upstreamRef
66 ? run(['git', 'rev-list', '--count', '@{u}..HEAD'], LOCAL_MS)
67 : Promise.resolve<Proc>({ kind: 'failed' }),
68 ])
69
70 // is-ancestor: 0 yes, 1 no, anything else (128: bad ref) could not be told.
71 const ancestor = (p: Proc): boolean | undefined =>
72 p.kind === 'exit' && (p.code === 0 || p.code === 1) ? p.code === 0 : undefined
73 const count = (p: Proc): number | undefined =>
74 ok(p) && /^\d+$/.test(text(p)) ? Number(text(p)) : undefined
75
76 // "no upstream configured" is a known fact (exit 128, empty result) as long as the
77 // tree answered the other git calls; a failed run stays unknown.
78 let upstream: Facts['upstream']
79 if (upstreamRef) upstream = { exists: true, ahead: count(upAhead) }
80 else if (upstreamName.kind === 'exit' && headSha !== undefined) upstream = { exists: false }
81
82 const facts: Facts = {
83 cwd,
84 head: headSha,
85 branch: ok(branch) ? text(branch) : undefined,
86 dirtyCount: ok(status) ? status.out.split('\n').filter(l => l.length > 0).length : undefined,
87 defaultBranch,
88 hasOrigin,
89 fetchOk,
90 ahead: count(aheadP),
91 baseContained: ancestor(baseInHead),
92 headInBase: ancestor(headInBaseP),
93 upstream,
94 pr,
95 worktrees: ok(wtList) ? parseWorktrees(wtList.out) : undefined,
96 selfRoot: ok(top) ? text(top) : undefined,
97 background,
98 }
99
100 // Stage 4: which sibling worktrees sit at a merged PR's head, asked per head branch: one
101 // repo-wide list would silently miss PRs merged before its last page. Only asked when there
102 // are siblings. `git branch --merged` is deliberately not used: it also lists a live
103 // session's fresh branch at the base tip (see prMergedAtHead).
104 const others = candidates(facts)
105 if (others.length === 0) return facts
106 const asked = others.slice(0, MAX_GH_QUERIES)
107 const answers = await Promise.all(
108 asked.map(w =>
109 // The name goes into argv: only an allow-listed name that is exactly the branch is asked.
110 w.branch !== undefined && safeBranch(w.branch, '') === w.branch
111 ? run(
112 ['gh', 'pr', 'list', '--state', 'merged', '--head', w.branch, '--limit', String(MERGED_PAGE), '--json', 'headRefName,headRefOid'],
113 NET_MS,
114 )
115 : Promise.resolve<Proc>({ kind: 'failed' }),
116 ),
117 )
118 let unanswered = others.length - asked.length
119 let mergedPrs: { name: string; oid: string }[] | undefined
120 asked.forEach((w, i) => {
121 const p = answers[i]
122 const list = p !== undefined && ok(p) ? parseMerged(p.out) : undefined
123 if (list === undefined) {
124 unanswered++
125 return
126 }
127 mergedPrs = [...(mergedPrs ?? []), ...list]
128 if (list.length >= MERGED_PAGE && !mergedAtHead(list, w)) unanswered++
129 })
130 facts.ghMerged = mergedPrs
131 facts.ghUnanswered = unanswered
132
133 // Stage 5: a PR-merged sibling still holding uncommitted work is live, never stale, so
134 // read its tree. Capped: past the cap a sibling stays unread, which reads unknown.
135 // A prunable entry's folder is gone: `git -C` there could climb into an enclosing repo and
136 // read its tree instead, so it is never read and stays unknown (the row names the fix).
137 const merged = others.filter(w => prMergedAtHead(facts, w) && w.prunable !== true).slice(0, MAX_SIBLING_READS)
138 const trees = await Promise.all(
139 merged.map(w => run(['git', '-C', w.path, 'status', '--porcelain'], LOCAL_MS)),
140 )
141 const dirty: Record<string, number> = {}
142 merged.forEach((w, i) => {
143 const t = trees[i]
144 if (t !== undefined && ok(t)) dirty[w.path] = t.out.split('\n').filter(l => l.length > 0).length
145 })
146 facts.siblingDirty = dirty
147 return facts
148}
149
150function parseMerged(out: string): { name: string; oid: string }[] | undefined {
151 try {
152 const j: unknown = JSON.parse(out)
153 if (!Array.isArray(j)) return undefined
154 const res: { name: string; oid: string }[] = []
155 for (const x of j) {
156 const o = (typeof x === 'object' && x !== null ? x : {}) as Record<string, unknown>
157 if (typeof o.headRefName === 'string' && typeof o.headRefOid === 'string') {
158 res.push({ name: o.headRefName, oid: o.headRefOid })
159 }
160 }
161 return res
162 } catch {
163 return undefined
164 }
165}
166types/index.d.ts 11 lines1declare module 'claude-code' {
2 interface PluginState {
3 wrap: {
4 /** Head SHA the close-out prompt was last submitted for; null when none (or released after a rejected or dropped submit). */
5 submittedFor: string | null
6 /** { tasks: { id, type, status }[]; crons?: { id, recurring }[]; source?: 'stop' | 'agents' } cached by the classic.Stop observer, or by the main-loop turn.complete observer from $.agent.list() where the host raises no Stop (the desktop); null until either fires. */
7 backgroundTasks: unknown | null
8 }
9 }
10}
11