SLOPSHOPPER

wrap

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

newcommandtoastprocesstimer
v0.1.1MITupdated 2026-10-12Joseph-Ayoub/claude-mods/wrap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wrap
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /wrap ⎿ wrap: /wrap close-out: feat/auth-refresh @ a1b2c3d ⎿ wrap: fail Working tree clean 2 changed or untracked path(s) ⎿ wrap: unknown Fetched origin/feat/auth-refresh unknown (no origin) ⎿ wrap: unknown Commits pushed could not compare with upstream or PR ⎿ wrap: unknown PR gh pr view did not complete (missing, signed out, offline or t ⎿ wrap: unknown CI green on current head PR state unknown ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

wrap

/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).

The checks

RowPasses when
Working tree cleangit status --porcelain is empty
Fetched origin/<default>one git fetch origin <default> succeeded (unknown (no origin) without an origin remote)
Commits pushedthe PR head equals local HEAD; with no PR, in sync with upstream (or nothing beyond origin/<default>)
PRan open or merged PR exists for the branch without merge conflicts (or there is nothing to raise)
CI green on current headthe 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 worktreesno 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.

Arguments

  • /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).
  • Unknown arguments are ignored and named in the output.

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.

Hooks registered

HookWhy
session.startregisters 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 }.

Options

  • acceptSkippedChecks (boolean, default false): count SKIPPED and NEUTRAL check conclusions as green. Off, only SUCCESS counts.

Load it

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.

Known limits

  • Prompt submit is deferred by one timer tick. The engine refuses $.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.
  • Archive arguments are fixed. The app's tool takes { 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.
  • Background tasks are as of the last turn end. A task that finished after the last Stop still reads as running until the next turn ends; a task started after it is invisible.
  • Another session's stale worktree blocks this session's readiness. The row is part of 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.
  • Stale worktrees need a merged PR. A branch merged locally with no PR is never flagged (a miss, not a false alarm: ancestry alone cannot tell it from a live session's fresh branch). If 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.
  • Output stops at what the checklist can see: it does not verify that a merge was authorized, and the model-side steps depend on the canonical prompt being followed.
Source 4 files
hooks/register.tsx 248 lines
1// /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}
248
hooks/checks.ts 469 lines
1// 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}
469
hooks/collect.ts 166 lines
1// 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}
166
types/index.d.ts 11 lines
1declare 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