/** * Pure ship-pr brain: turn a PR snapshot + decisions + events into a * progressive-disclosure report. No kody imports so it stays unit-testable. * * Output describes state and options. It never orders the agent around; the * agent decides what to fix, reply to, ignore, or merge. */ import { classifyReviewBotComment, findingHasAddressingReply, findingTitle, hasInvalidReply, invalidReplyBody, isAddressingReviewAuthor, isAddressingReviewReply, isShipPrBlocker, normalizeBotLogin, reviewBotKind, severityOf, truncate, type ClassifiableComment, type ReviewBotKind, type ReviewFinding, type ReviewVerdict, } from './classify.ts' import { collectOutsideDiffFindings } from './outside-diff.ts' import { exampleInvoke } from './input.ts' import { actionsIds, aiReviewerOfCheck, type AiReviewerKind, type NormalizedCheck, type PrSnapshot, type WorkflowRun, } from './snapshot.ts' import type { Decision, ShipEvent } from './storage.ts' export const risks = ['low', 'medium', 'high'] as const export type Risk = (typeof risks)[number] export const difficulties = ['Easy', 'Medium', 'Hard'] as const export const dispositions = ['ignored', 'skipped', 'wontfix', 'accepted'] as const export type Disposition = (typeof dispositions)[number] export const doors = ['two-way', 'one-way'] as const export const cleanups = ['none', 'needed', 'done'] as const /** Discord send-summary status values (Pending is tick-only until merge/park). */ export const reportStatuses = ['Shipped', 'Parked', 'Blocked'] as const export type Difficulty = (typeof difficulties)[number] export const stepIds = ['ready', 'ci', 'ai-review', 'merge', 'deploy', 'friction', 'report'] as const export type StepId = (typeof stepIds)[number] export type StepStatus = | 'done' | 'skipped' | 'not_applicable' | 'optional' | 'waiting' | 'needs_attention' | 'todo' | 'locked' const settled = new Set(['done', 'skipped', 'not_applicable', 'optional']) export type ReportFields = { agentId?: string model?: string title?: string difficulty?: Difficulty /** Door direction for Discord; omit when unknown. Never invent. */ door?: (typeof doors)[number] /** Cleanup status for Discord; omit when unknown. Never invent. */ cleanup?: (typeof cleanups)[number] cleanupNote?: string summary?: string } export type ComputeOptions = { risk: Risk mergeAuthority: boolean now: number report: ReportFields expand: 'all' | Array /** Compact output: drop the checklist, prose notes/about text, and hidden-item lists (counts only). */ brief?: boolean } export type Invoke = { purpose: string; command: string } export type Automation = { id: 'mark_ready' | 'trigger_bugbot' | 'reply_invalid' description: string commentId?: number body?: string } export type ThreadItem = { threadId: string commentId: number author: string bot: ReviewBotKind | null verdict?: ReviewVerdict reason?: string severity: string | null title: string path: string | null line: number | null outdated: boolean url: string | null replies: number lastReplyBy: string | null addressed: boolean invalidReplied?: boolean /** `outside_diff` = finding lives only in a review body (no inline thread). */ source?: 'review_thread' | 'outside_diff' /** Prefer this for decide() when set (cr-comment:v1:… / outside-diff:…). */ decideTarget?: string /** True when reply-review cannot be used (body-only findings). */ cannotReplyInline?: boolean /** Actionable excerpt for outside-diff findings. */ body?: string } export type ReviewerState = { state: | 'not_triggered' | 'triggered' | 'stalled' | 'running' | 'completed' | 'errored' | 'unavailable' | 'absent' | 'ignored' note: string checkConclusion?: string | null url?: string | null } const STEP_TITLES: Record = { ready: 'PR open and ready for review', ci: 'CI green on the head SHA', 'ai-review': 'AI review feedback handled', merge: 'Merge (or park) per risk policy', deploy: 'Deploy verified on the merge SHA', friction: 'Leftover friction filed (or none)', report: 'Discord ship summary sent', } const POLL_SECONDS: Partial> = { ready: 15, ci: 60, 'ai-review': 90, merge: 20, deploy: 60, } /** * Floor for the timer fallback when `exit.status` is `waiting`. CI branch * subscriptions can miss a delivery; a bounded one-shot timer re-ticks so a * green PR cannot sit unmerged for hours (#3750 / #3746). */ export const WAKE_TIMER_FLOOR_SECONDS = 120 export type WakeHints = { /** Always true when waiting — agents must arm this, not only a CI subscription. */ requireTimerFallback: true delaySeconds: number once: true /** Deduped timer name for Cursor `subscribe_timer`. */ name: string /** Prompt body for the timer wake. */ prompt: string reason: string } export function wakeHintsForWaiting(input: { prUrl: string prNumber: number risk: Risk focus: StepId pollAfterSeconds: number }): WakeHints { const delaySeconds = Math.max(input.pollAfterSeconds, WAKE_TIMER_FLOOR_SECONDS) return { requireTimerFallback: true, delaySeconds, once: true, name: `ship-pr-recheck-${input.prNumber}`, reason: 'CI / bot subscriptions can miss a delivery. A one-shot timer re-ticks so a green PR cannot stall forever (#3750).', prompt: [ `Ship-pr wake fallback for ${input.prUrl} (focus was ${input.focus}).`, `Re-tick with risk=${input.risk}, applySafeAutomations=true, brief=true.`, 'If exit.status is still waiting, arm another one-shot timer from exit.wakeHints (do not tight-loop).', 'If focus is merge / needs_attention / done, continue the ship-pr loop — do not wait for another CI event alone.', ].join(' '), } } function ageSeconds(now: number, iso: string | null | undefined) { const t = iso ? Date.parse(iso) : NaN return Number.isFinite(t) ? Math.max(0, Math.round((now - t) / 1000)) : null } function decisionFor(decisions: Array, kind: Decision['kind'], ...targets: Array) { const wanted = targets.filter(Boolean).map((t) => t.toLowerCase()) return decisions.find((d) => d.kind === kind && wanted.includes(d.target.toLowerCase())) ?? null } export function checklist(risk: Risk): Array { const ai = risk === 'low' ? 'optional at low risk — Bugbot not required; glance at findings, nits are ignorable.' : risk === 'medium' ? 'Bugbot on the head SHA; handle valid/unsure sorted findings plus valid CodeRabbit/other threads. Do not wait on CodeRabbit/Devin.' : 'Bugbot plus CodeRabbit/Devin when they run (wait on them at high risk); handle valid findings.' const merge = risk === 'high' ? 'high risk parks ready-for-review unless the user granted merge authority (pass mergeAuthority: true).' : "squash-merge via ./merge once green (Kent's standing policy: auto-ship unless high risk)." return [ `1. ready — ${STEP_TITLES.ready} (safe automation can mark it ready).`, '2. ci — head-SHA checks green. Failures are yours to fix, or decide ignored/skipped with a reason.', `3. ai-review — ${ai}`, `4. merge — ${merge}`, '5. deploy — watch workflow runs on the merge SHA (decide skipped when nothing deploys).', '6. friction — file leftover out-of-scope papercuts (incl. brittle/flaky low-value tests, even if worked around) with kody:@kentcdodds/friction-log/file, then decide step friction.', '7. report — Discord summary via ./send-summary (agentId, title, difficulty, summary; optional door/cleanup; model only from deterministic metadata).', 'Loop: invoke ./tick, read the focused step, act (fix / reply / decide), invoke ./tick again until exit.done.', ] } // ---------------------------------------------------------------- CI /** * How long after a CI trigger (head push, PR opened, ready-for-review) tick * waits for workflow runs to appear before trusting the head-SHA check list. */ const CI_START_GRACE_SECONDS = 180 type CiTrigger = { kind: 'push' | 'opened' | 'ready_for_review'; at: string } /** The newest event that (re)starts CI for the head SHA. */ function latestCiTrigger(snapshot: PrSnapshot): CiTrigger | null { const candidates: Array = [ snapshot.headCommittedDate ? { kind: 'push', at: snapshot.headCommittedDate } : null, snapshot.createdAt ? { kind: 'opened', at: snapshot.createdAt } : null, snapshot.readyForReviewAt ? { kind: 'ready_for_review', at: snapshot.readyForReviewAt } : null, ] let latest: CiTrigger | null = null for (const c of candidates) { if (c && Number.isFinite(Date.parse(c.at)) && (!latest || Date.parse(c.at) > Date.parse(latest.at))) latest = c } return latest } /** * CI gate for the head SHA. `done` only when every check run, commit status, * and workflow run on the head SHA is terminal and CI has started since the * latest trigger. Draft-gated jobs report `skipped`; after the PR is marked * ready those skips are stale until the ready-for-review runs report, so a * check list that predates the trigger is not trusted inside the grace window. */ export function analyzeCi(snapshot: PrSnapshot, decisions: Array, now: number) { const ci = snapshot.checks.filter((check) => !aiReviewerOfCheck(check)) const hiddenDecided: Array<{ name: string; disposition: string; reason: string }> = [] const visible: Array = [] for (const check of ci) { const decision = decisionFor(decisions, 'check', check.name) if (decision) hiddenDecided.push({ name: check.name, disposition: decision.disposition, reason: decision.reason }) else visible.push(check) } const failing = visible.filter((c) => c.state === 'failure') const pending = visible.filter((c) => c.state === 'pending') const passing = visible.filter((c) => c.state === 'success') const skipped = visible.filter((c) => c.state === 'skipped') // Queued / in-progress workflow runs may not have created their check runs yet. const pendingRuns = snapshot.headRuns.filter((run) => run.status.toLowerCase() !== 'completed') const sha = snapshot.headSha.slice(0, 9) const trigger = latestCiTrigger(snapshot) const triggerAge = trigger ? ageSeconds(now, trigger.at) : null const activityTimes = [ ...snapshot.headRuns.map((run) => run.createdAt), ...ci.map((check) => check.startedAt ?? check.completedAt), ] .map((t) => (t ? Date.parse(t) : NaN)) .filter(Number.isFinite) const startedSinceTrigger = trigger ? activityTimes.some((t) => t >= Date.parse(trigger.at)) : true const awaitingStart = !startedSinceTrigger && triggerAge != null && triggerAge < CI_START_GRACE_SECONDS // Jobs skipped while the PR was a draft that never re-ran after it went ready. const staleDraftSkips = !startedSinceTrigger && trigger?.kind === 'ready_for_review' ? skipped : [] const runsNote = pendingRuns.length ? `, ${pendingRuns.length} workflow run${pendingRuns.length === 1 ? '' : 's'} unfinished` : '' let status: StepStatus let summary: string if (failing.length) { status = 'needs_attention' summary = `${failing.length} failing, ${pending.length} pending, ${passing.length} passing on ${sha}${runsNote}` } else if (pending.length || pendingRuns.length) { status = 'waiting' summary = `${pending.length} pending, ${passing.length} passing on ${sha}${runsNote}` } else if (awaitingStart) { status = 'waiting' summary = trigger!.kind === 'ready_for_review' ? `marked ready ${triggerAge}s ago; waiting for ready-for-review CI to start on ${sha}` : trigger!.kind === 'opened' ? `PR opened ${triggerAge}s ago; waiting for CI to start on ${sha}` : `no CI reported yet for the new head commit ${sha}` } else if (staleDraftSkips.length) { status = 'needs_attention' summary = `${staleDraftSkips.length} skipped while draft and no CI ran after ready on ${sha}; re-run or push` } else if (visible.length === 0) { // After the start-grace window, an empty non-AI check list is not success — // Validate never enqueued (#2473). Agents cannot workflow_dispatch; escalate. status = 'needs_attention' summary = `no Validate-class CI on ${sha} after grace; dispatch via kody-bot or push` } else { status = 'done' summary = `${passing.length} passing${skipped.length ? `, ${skipped.length} skipped` : ''}${hiddenDecided.length ? `, ${hiddenDecided.length} decided` : ''}` } return { status, summary, failing, pending, pendingRuns, passing, skipped, staleDraftSkips, hiddenDecided, trigger } } function checkView(check: NormalizedCheck, repoPath: string) { const ids = actionsIds(check.url) return { name: check.name, state: check.state, conclusion: check.conclusion, url: check.url, ...(check.description ? { description: check.description } : {}), ...(check.conclusion === 'CANCELLED' ? { note: 'cancelled — often superseded by a newer run or a concurrency group; re-run or decide.' } : {}), ...(ids?.jobId ? { logs: `GET /repos/${repoPath}/actions/jobs/${ids.jobId}/logs (kody:@kentcdodds/github/request) or gh run view ${ids.runId} --log-failed` } : {}), } } // ---------------------------------------------------------------- reviews function toClassifiable(thread: PrSnapshot['threads'][number]): Array { const rootId = thread.comments[0]?.id return thread.comments.map((c, index) => ({ id: c.id, user: { login: c.author }, body: c.body, html_url: c.url ?? undefined, in_reply_to_id: index === 0 ? null : (c.replyToId ?? rootId ?? null), })) } export function analyzeThreads(snapshot: PrSnapshot, decisions: Array) { const mustAddress: Array = [] const alreadyAddressed: Array = [] const invalid: Array = [] const otherThreads: Array = [] const findings: Array = [] let resolved = 0 const decided: Array<{ threadId: string; commentId: number; disposition: string; reason: string }> = [] for (const thread of snapshot.threads) { const root = thread.comments[0] if (!root) continue if (thread.isResolved) { resolved += 1 continue } const decision = decisionFor(decisions, 'thread', thread.id, String(root.id)) if (decision) { decided.push({ threadId: thread.id, commentId: root.id, disposition: decision.disposition, reason: decision.reason }) continue } const comments = toClassifiable(thread) const replies = thread.comments.slice(1) const base = { threadId: thread.id, commentId: root.id, author: root.author, severity: severityOf(root.body), title: findingTitle(root.body), path: thread.path, line: thread.line, outdated: thread.isOutdated, url: root.url, replies: replies.length, lastReplyBy: replies.length ? replies[replies.length - 1].author : null, } const bot = reviewBotKind(root.author) if (bot) { const finding = classifyReviewBotComment(comments[0]) if (!finding) continue findings.push(finding) const addressed = findingHasAddressingReply(finding, comments) const item: ThreadItem = { ...base, bot, verdict: finding.verdict, reason: finding.reason, addressed, source: 'review_thread', decideTarget: String(root.id), } if (finding.verdict === 'invalid') { invalid.push({ ...item, invalidReplied: hasInvalidReply(comments, root.id) }) } else if (isShipPrBlocker(finding) && addressed) { alreadyAddressed.push(item) } else { mustAddress.push(item) } } else { const addressed = comments.slice(1).some((c) => isAddressingReviewReply(c)) otherThreads.push({ ...base, bot: null, addressed, source: 'review_thread' }) } } // CodeRabbit (and similar) outside-diff findings live only in review bodies. for (const finding of collectOutsideDiffFindings(snapshot.reviews)) { const decision = decisionFor(decisions, 'thread', finding.decideTarget) if (decision) { decided.push({ threadId: finding.decideTarget, commentId: 0, disposition: decision.disposition, reason: decision.reason, }) continue } mustAddress.push({ threadId: finding.decideTarget, commentId: 0, author: finding.author, bot: null, verdict: 'valid', reason: 'outside diff range comment in review body (not an inline thread)', severity: finding.severity, title: finding.title, path: finding.path, line: finding.line, outdated: false, url: finding.reviewUrl, replies: 0, lastReplyBy: null, addressed: false, source: 'outside_diff', decideTarget: finding.decideTarget, cannotReplyInline: true, body: finding.body, }) } return { mustAddress, alreadyAddressed, invalid, otherThreads, otherUnaddressed: otherThreads.filter((t) => !t.addressed), /** Unaddressed threads opened by humans (not bots, not kody-bot): never optional, even at low risk. */ humanUnaddressed: otherThreads.filter( (t) => !t.addressed && isAddressingReviewAuthor(t.author) && normalizeBotLogin(t.author) !== 'kody-bot', ), findings, resolved, decided, } } const triggerRe = /^\s*(?:bugbot run|@cursor review|cursor review)\b/i const rateLimitRe = /rate[- ]limit|review limit|reviews? (?:paused|skipped)|usage limit|out of credits|quota/i function reviewerDecision(decisions: Array, reviewer: AiReviewerKind, checkName?: string) { return decisionFor(decisions, 'check', reviewer, checkName ?? '') } export function analyzeBugbot( snapshot: PrSnapshot, decisions: Array, events: Array, now: number, ): ReviewerState & { triggeredAt?: string | null } { const check = snapshot.checks.find((c) => aiReviewerOfCheck(c) === 'bugbot') const decision = reviewerDecision(decisions, 'bugbot', check?.name ?? 'Cursor Bugbot') if (decision) return { state: 'ignored', note: `decided ${decision.disposition}: ${decision.reason}` } if (check) { if (check.state === 'pending') return { state: 'running', note: 'Cursor Bugbot check is running on the head SHA.', url: check.url } return { state: 'completed', note: 'Cursor Bugbot finished on the head SHA. Its findings are review threads authored by cursor[bot].', checkConclusion: check.conclusion, url: check.url, } } const headReview = snapshot.reviews.find( (r) => reviewBotKind(r.author) === 'bugbot' && r.commitSha === snapshot.headSha, ) if (headReview) { return { state: 'completed', note: truncate(headReview.body.replace(//g, '').trim(), 160), url: headReview.url } } if (normalizeBotLogin(snapshot.author) !== 'kentcdodds') { return { state: 'unavailable', note: `PR author is ${snapshot.author}; Bugbot only runs when the author matches the Cursor-linked account (kentcdodds). Do not wait for it — rely on CI and other reviewers.`, } } const event = events.find((e) => e.kind === 'bugbot_trigger' && e.ref === snapshot.headSha) const headTime = snapshot.headCommittedDate ? Date.parse(snapshot.headCommittedDate) : 0 const comment = snapshot.issueComments .filter((c) => normalizeBotLogin(c.author) === 'kentcdodds' && triggerRe.test(c.body) && Date.parse(c.createdAt) >= headTime) .pop() const triggeredAt = event?.createdAt ?? comment?.createdAt ?? null if (triggeredAt) { const age = ageSeconds(now, triggeredAt) ?? 0 if (age > 20 * 60) { return { state: 'stalled', note: `Triggered ${Math.round(age / 60)}m ago with no Cursor Bugbot check on the head SHA. Do not auto re-trigger (that resets the stall clock). Decide check "bugbot" skipped|ignored with a reason, or trigger once manually if Bugbot is back.`, triggeredAt, } } return { state: 'triggered', note: `Triggered ${Math.round(age / 60)}m ago; waiting for the Cursor Bugbot check.`, triggeredAt } } return { state: 'not_triggered', note: 'No Bugbot run for the head SHA yet. Trigger with applySafeAutomations: true (or kody:@kentcdodds/bugbot).' } } /** A reviewer status left pending this long after its last review counts as stuck, not running. */ export const STUCK_PENDING_SECONDS = 15 * 60 function analyzeOtherReviewer( snapshot: PrSnapshot, decisions: Array, kind: 'coderabbit' | 'devin' | 'seer', now: number, ): ReviewerState & { latestReview?: Record } { const check = snapshot.checks.find((c) => aiReviewerOfCheck(c) === kind) const decision = reviewerDecision(decisions, kind, check?.name) if (decision) return { state: 'ignored', note: `decided ${decision.disposition}: ${decision.reason}` } const authored = (login: string) => { const n = normalizeBotLogin(login) return kind === 'coderabbit' ? n.startsWith('coderabbit') : reviewBotKind(login) === kind } const reviews = snapshot.reviews.filter((r) => authored(r.author)) // CodeRabbit thread-reply reviews often land as empty-body REVIEW objects on the // new head SHA while the real incremental review is still running. Only a // non-empty body counts as having reviewed a commit. const substantive = reviews.filter((r) => r.body.trim().length > 0) const latest = substantive[substantive.length - 1] const latestComment = snapshot.issueComments.filter((c) => authored(c.author)).pop() const latestReview = latest ? { url: latest.url, state: latest.state, onHead: latest.commitSha === snapshot.headSha, ...(kind === 'coderabbit' ? { actionableComments: Number(latest.body.match(/Actionable comments posted:\s*(\d+)/i)?.[1] ?? NaN) || 0, hasOutsideDiffComments: /outside diff range/i.test(latest.body), hasNitpicks: /nitpick comments/i.test(latest.body), } : {}), preview: truncate(latest.body.replace(//g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(), 200), } : undefined const limited = [latestComment?.body, latest?.body, check?.description].some((b) => b && rateLimitRe.test(b)) if (limited) { return { state: 'unavailable', note: `${kind} looks rate-limited/paused. Do not wait on it below high risk.`, latestReview } } if (check) { if (check.state === 'pending') { // Reviewers (CodeRabbit especially) can post their review and leave the // status context pending. A non-empty-body review on the head SHA means it // finished; empty-body thread-reply reviews must not count. if (latest && latest.commitSha === snapshot.headSha) { return { state: 'completed', note: `${kind} posted its review on the head SHA (${latest.state.toLowerCase()}); "${check.name}" still says pending, which is a stale status. Not waiting on it.`, url: latest.url ?? check.url, latestReview, } } // Reviewed an earlier head, then never settled the status on the new one // (common after a nit-fix push). Stop waiting once it has sat long enough. const pendingAge = ageSeconds(now, check.startedAt ?? snapshot.headCommittedDate) if (latest && pendingAge != null && pendingAge >= STUCK_PENDING_SECONDS) { return { state: 'stalled', note: `"${check.name}" has been pending ${Math.round(pendingAge / 60)}m on the head SHA; ${kind}'s latest review is on an earlier commit. Treating it as settled (not waiting). Confirm that review's findings and nitpicks were handled.`, url: latest.url ?? check.url, latestReview, } } const hint = !latest && pendingAge != null && pendingAge >= 2 * STUCK_PENDING_SECONDS ? ` (pending ${Math.round(pendingAge / 60)}m with no review yet; if it never posts, decide check "${kind}" skipped with a reason)` : '' return { state: 'running', note: `${check.name}: ${check.description ?? 'running'}${hint}`, url: check.url, latestReview } } if (check.state === 'failure') return { state: 'errored', note: `${check.name}: ${check.description ?? check.conclusion}`, url: check.url, latestReview } return { state: 'completed', note: `${check.name}: ${check.description ?? 'completed'}`, url: check.url, latestReview } } if (latest) return { state: 'completed', note: `${kind} reviewed (latest review ${latest.state.toLowerCase()}).`, latestReview } return { state: 'absent', note: `${kind} has not reported on this PR.` } } // ---------------------------------------------------------------- deploy const deployFailure = new Set(['failure', 'timed_out', 'startup_failure', 'action_required']) /** Waiting this long for production to pick up a superseded merge before asking for attention. */ const SUPERSEDED_WAIT_SECONDS = 30 * 60 export function analyzeDeploy(snapshot: PrSnapshot, now: number) { const runs = (snapshot.deployRuns ?? []).filter( (r) => !['issue_comment', 'pull_request_review', 'pull_request_review_comment', 'pull_request_target'].includes(r.event), ) const meaningful = runs.filter((r) => r.conclusion !== 'skipped' || r.status !== 'completed') const pending = runs.filter((r) => r.status !== 'completed') const failed = runs.filter((r) => r.conclusion && deployFailure.has(r.conclusion)) // A newer push in the same concurrency group cancels runs on this SHA; that // is superseded, not failed. Production health decides whether it shipped. const cancelled = runs.filter((r) => r.status === 'completed' && r.conclusion === 'cancelled') const production = snapshot.production ?? null const shortSha = (sha: string | null | undefined) => (sha ? sha.slice(0, 9) : '?') const servedNote = production?.commitSha ? `production (${production.url}) serves ${shortSha(production.commitSha)}` : null const mergedAge = ageSeconds(now, snapshot.mergedAt) ?? 0 // kentcdodds/kody: an all-skipped Deploy can appear while Validate still runs. const validateRunning = runs.some((r) => /validate/i.test(r.name) && r.status !== 'completed') let status: StepStatus let summary: string if (failed.length) { status = 'needs_attention' summary = `${failed.length} workflow run(s) failed on the merge SHA: ${runList(failed)}` } else if (production?.containsMerge) { status = 'done' summary = production.commitSha === snapshot.mergeCommitSha ? `${servedNote}, the merge SHA` : `${servedNote}, a descendant of the merge SHA ${shortSha(snapshot.mergeCommitSha)}` + (cancelled.length ? ` (superseded: ${cancelled.length} cancelled run(s) on the merge SHA)` : '') } else if (pending.length || validateRunning) { status = 'waiting' summary = pending.length ? `${pending.length} workflow run(s) still running on the merge SHA: ${runList(pending)}` : 'waiting for Validate to finish on the merge SHA' } else if (cancelled.length) { const servedTail = production ? servedNote ? `; ${servedNote}, which does not contain it yet` : `; production check failed: ${production.error ?? 'unknown'}` : '' const base = `${cancelled.length} run(s) cancelled on the merge SHA (superseded by a newer push): ${runList(cancelled)}${servedTail}` if (production && mergedAge < SUPERSEDED_WAIT_SECONDS) { status = 'waiting' summary = `${base}. Waiting for a later deploy to include the merge.` } else { status = 'needs_attention' summary = production ? `${base}. Still not in production after ${Math.round(mergedAge / 60)}m; check the newer runs.` : `${base}. Verify a later deploy includes the merge SHA, then decide step deploy.` } } else if (!meaningful.length && mergedAge < 300) { status = 'waiting' summary = 'no workflow runs on the merge SHA yet' } else if (production && production.containsMerge === false) { const tail = `workflow runs finished on the merge SHA but ${servedNote}, which does not contain it` ;[status, summary] = mergedAge < SUPERSEDED_WAIT_SECONDS ? ['waiting', `${tail} yet`] : ['needs_attention', `${tail} after ${Math.round(mergedAge / 60)}m`] } else { status = 'done' summary = meaningful.length ? `${meaningful.length} workflow run(s) succeeded on the merge SHA` : 'no workflows ran on the merge SHA' } return { status, summary, runs, failed, pending, cancelled, production } } function runList(runs: Array) { const shown = runs.slice(0, 3).map((r) => (r.url ? `${r.name} (${r.url})` : r.name)) return shown.join('; ') + (runs.length > 3 ? `; +${runs.length - 3} more` : '') } function runView(run: WorkflowRun) { return { name: run.name, event: run.event, status: run.status, conclusion: run.conclusion, url: run.url } } export function repoHints(snapshot: PrSnapshot, step: StepId): Array { const repo = `${snapshot.locator.owner}/${snapshot.locator.repo}`.toLowerCase() if (repo !== 'kentcdodds/kody') { if (step !== 'deploy') return [] return [ `No repo-specific deploy hints for ${snapshot.locator.owner}/${snapshot.locator.repo}. Pick the deploy workflow from runs (name + url above), wait for it to succeed, then smoke the production URL if you know one (repo homepage, README, or the deploy job's environment URL). Never invent a URL; decide step deploy with how you verified.`, ] } if (step === 'deploy') { return [ 'kentcdodds/kody: an all-skipped "🚀 Deploy (production)" can appear while "✅ Validate" is still running on the merge SHA. Wait for Validate success, then watch the following Deploy run. Do not poll /health against the previous SHA.', 'kentcdodds/kody: a newer main push can cancel Validate/Deploy on the merge SHA (concurrency group). Cancelled is superseded, not failed; tick checks https://kody.codes/health and marks deploy done once the served commitSha is the merge SHA or a descendant (compare API).', 'After deploy: npm run control-kody -- health --origin https://kody.codes --sha .', ] } if (step === 'ai-review' || step === 'merge') { return [ 'kentcdodds/kody medium+: run npm run control-kody -- preview (or npm run preview:manual-test) as the seeded user with data for this change before merge (see the control-kody / preview-manual-test skills).', 'Immediately before merge, recheck mergeability. If it flipped to CONFLICTING after green checks: fetch origin/main, rebase once, push, wait for CI again. Still conflicting after one rebase → stop and report.', ] } return [] } // ---------------------------------------------------------------- brief const proseKeys = new Set(['notes', 'about', 'rules', 'bar', 'policy']) /** * Brief mode: drop prose (notes/about/rules/bar/policy), collapse hidden-item * arrays to counts, and reduce reviewer blocks to `{ state, note }`. Keeps * everything actionable (mustAddress, otherThreads items, failing checks, runs). */ export function compactDetails(details: Record): Record { const out: Record = {} for (const [key, value] of Object.entries(details)) { if (proseKeys.has(key) || value === undefined) continue if (key === 'hidden' && value && typeof value === 'object') { out.hidden = Object.fromEntries( Object.entries(value as Record).map(([k, v]) => [k, Array.isArray(v) ? v.length : v]), ) } else if (key === 'reviewers' && value && typeof value === 'object') { out.reviewers = Object.fromEntries( Object.entries(value as Record).map(([k, v]) => { const r = (v ?? {}) as Record return [k, { state: r.state, ...(r.note ? { note: r.note } : {}), ...(r.url ? { url: r.url } : {}) }] }), ) } else if (key === 'sorted' || key === 'otherThreads') { const v = (value ?? {}) as Record out[key] = Object.fromEntries( Object.entries(v) .filter(([k]) => !proseKeys.has(k)) .map(([k, items]) => [k, k === 'alreadyAddressed' || k === 'invalid' ? (Array.isArray(items) ? items.length : items) : items]), ) } else if (key === 'passing' || key === 'skipped' || key === 'hiddenDecided' || key === 'duplicateChecksHidden') { if (Array.isArray(value) ? value.length : value) out[key] = Array.isArray(value) ? value.length : value } else out[key] = value } return out } // ---------------------------------------------------------------- compute export type StepReport = { id: StepId title: string status: StepStatus summary: string details?: Record exampleInvokes?: Array } /** * Discord report status. `Pending` until the run merges, parks (decide step * merge skipped), or the PR closes, so nothing reads as parked prematurely. */ export function inferReportStatus(snapshot: PrSnapshot, mergeStep: StepStatus): 'Shipped' | 'Parked' | 'Blocked' | 'Pending' { if (snapshot.merged) return 'Shipped' if (snapshot.state === 'CLOSED') return 'Blocked' return mergeStep === 'skipped' ? 'Parked' : 'Pending' } export function computeShip( snapshot: PrSnapshot, decisions: Array, events: Array, options: ComputeOptions, ) { const { risk, now } = options const prUrl = snapshot.locator.prUrl // Re-tick examples ask for brief output: the first tick carries the checklist and prose once. const tickParams = { prUrl, risk, applySafeAutomations: true, brief: true } const steps = new Map() const detailBuilders = new Map { details: Record; exampleInvokes: Array }>() const automations: Array = [] const isOpen = snapshot.state === 'OPEN' const stepDecision = (id: StepId) => decisionFor(decisions, 'step', id) const decideInvoke = (kind: string, target: string, disposition: string, reason: string, purpose: string) => exampleInvoke('decide', { prUrl, kind, target, disposition, reason }, purpose) // ready { const d = stepDecision('ready') let status: StepStatus let summary: string if (snapshot.merged) [status, summary] = ['done', 'merged'] else if (!isOpen) [status, summary] = ['not_applicable', 'PR closed without merging'] else if (d) [status, summary] = ['skipped', `decided ${d.disposition}: ${d.reason}`] else if (snapshot.isDraft) { ;[status, summary] = ['todo', 'PR is a draft'] automations.push({ id: 'mark_ready', description: 'Mark the PR ready for review (kody-bot).' }) } else [status, summary] = ['done', 'open and ready for review'] steps.set('ready', { id: 'ready', title: STEP_TITLES.ready, status, summary }) detailBuilders.set('ready', () => ({ details: { draft: snapshot.isDraft, state: snapshot.state, title: snapshot.title, author: snapshot.author }, exampleInvokes: [ exampleInvoke('tick', tickParams, 'Re-tick with safe automations (marks the PR ready).'), exampleInvoke('', { prUrl, status: 'ready' }, 'Or mark ready directly.', '@kentcdodds/github/pr/set-review-status'), ], })) } // ci const ci = analyzeCi(snapshot, decisions, now) { const d = stepDecision('ci') let status: StepStatus = ci.status let summary = ci.summary if (snapshot.merged) [status, summary] = ['done', 'merged'] else if (!isOpen) [status, summary] = ['not_applicable', 'PR closed'] else if (d) [status, summary] = ['skipped', `decided ${d.disposition}: ${d.reason}`] steps.set('ci', { id: 'ci', title: STEP_TITLES.ci, status, summary }) const firstFailing = ci.failing[0]?.name ?? '' detailBuilders.set('ci', () => ({ details: { headSha: snapshot.headSha, failing: ci.failing.map((c) => checkView(c, `${snapshot.locator.owner}/${snapshot.locator.repo}`)), pending: ci.pending.map((c) => checkView(c, `${snapshot.locator.owner}/${snapshot.locator.repo}`)), ...(ci.pendingRuns.length ? { pendingRuns: ci.pendingRuns.map((run) => ({ name: run.name, event: run.event, status: run.status, url: run.url })) } : {}), passing: ci.passing.length, skipped: ci.skipped.length, ...(ci.staleDraftSkips.length ? { skippedWhileDraft: ci.staleDraftSkips.map((c) => c.name) } : {}), ...(ci.trigger ? { ciTrigger: ci.trigger } : {}), hiddenDecided: ci.hiddenDecided, duplicateChecksHidden: snapshot.duplicateChecksHidden, notes: [ 'Only checks on the current head SHA are listed; failures on abandoned commits are not a red PR.', `Done needs every check, status, and workflow run on the head SHA terminal, with CI started since the latest push / open / ready-for-review (waits up to ${CI_START_GRACE_SECONDS}s for runs to appear). Jobs skipped while the PR was a draft are stale once it is marked ready; if nothing re-runs after ready, CI needs attention (re-run, push, or decide).`, 'You decide: fix and push, re-run a flaky job, or record a decision (ignored | skipped | wontfix | accepted) with a reason so it stops showing for 7 days.', ], }, exampleInvokes: [ decideInvoke('check', firstFailing, 'ignored', '', 'Hide a check you judged non-blocking (7 days).'), exampleInvoke('tick', tickParams, 'Re-tick after pushing a fix or when checks finish.'), ], })) } // ai-review const threads = analyzeThreads(snapshot, decisions) const bugbot = analyzeBugbot(snapshot, decisions, events, now) const coderabbit = analyzeOtherReviewer(snapshot, decisions, 'coderabbit', now) const devin = analyzeOtherReviewer(snapshot, decisions, 'devin', now) const seer = analyzeOtherReviewer(snapshot, decisions, 'seer', now) { const d = stepDecision('ai-review') const waitingOn: Array = [] const attention: Array = [] if (risk !== 'low') { if (bugbot.state === 'running' || bugbot.state === 'triggered') waitingOn.push('bugbot') if (bugbot.state === 'stalled') attention.push('Bugbot trigger stalled') } if (risk === 'high') { if (coderabbit.state === 'running') waitingOn.push('coderabbit') if (devin.state === 'running') waitingOn.push('devin') } if (threads.mustAddress.length) attention.push(`${threads.mustAddress.length} sorted finding(s) to address`) if (threads.otherUnaddressed.length) attention.push(`${threads.otherUnaddressed.length} other unresolved thread(s)`) // Trigger at most once per head via applySafeAutomations. When stalled, // do not re-trigger — that resets the stall clock (#3598). Escalate to decide. const needsTrigger = risk !== 'low' && bugbot.state === 'not_triggered' if (isOpen && needsTrigger) { automations.push({ id: 'trigger_bugbot', description: 'Trigger Cursor Bugbot as kentcdodds via kody:@kentcdodds/bugbot.' }) } if (isOpen) { for (const item of threads.invalid.filter((t) => !t.invalidReplied)) { automations.push({ id: 'reply_invalid', description: `Reply "invalid" (kody-bot) on ${item.bot} finding ${item.commentId}: ${item.title}`, commentId: item.commentId, body: invalidReplyBody({ bot: item.bot as ReviewBotKind, authorLogin: item.author, commentId: item.commentId, bodyPreview: item.title, verdict: 'invalid', reason: item.reason ?? 'invalid', }), }) } } let status: StepStatus let summary: string if (snapshot.merged) [status, summary] = ['done', 'merged'] else if (!isOpen) [status, summary] = ['not_applicable', 'PR closed'] else if (d) [status, summary] = ['skipped', `decided ${d.disposition}: ${d.reason}`] else if (risk === 'low') { if (threads.humanUnaddressed.length) { status = 'needs_attention' summary = `${threads.humanUnaddressed.length} unresolved human review thread(s) (bot nits stay optional at low risk)` } else { status = attention.length ? 'optional' : 'done' summary = attention.length ? `optional at low risk: ${attention.join('; ')}` : 'nothing outstanding' } } else if (attention.length) { status = 'needs_attention' summary = attention.join('; ') + (waitingOn.length ? ` (still waiting on ${waitingOn.join(', ')})` : '') } else if (needsTrigger) { status = 'todo' summary = 'Bugbot not triggered for the head SHA' } else if (waitingOn.length) { status = 'waiting' summary = `waiting on ${waitingOn.join(', ')}` } else { status = 'done' const stale = ( [ ['coderabbit', coderabbit], ['devin', devin], ] as const ) .filter(([, r]) => r.state === 'stalled') .map(([name]) => name) summary = `bugbot ${bugbot.state}; no outstanding findings` + (stale.length ? ` (${stale.join(', ')} status stuck pending after an earlier review; not waiting)` : '') } steps.set('ai-review', { id: 'ai-review', title: STEP_TITLES['ai-review'], status, summary }) const first = threads.mustAddress[0] ?? threads.otherUnaddressed[0] detailBuilders.set('ai-review', () => ({ details: { risk, reviewers: { bugbot, coderabbit, devin, ...(seer.state !== 'absent' ? { seer } : {}) }, sorted: { about: 'Bugbot / Devin / Seer root comments, classified by the review-bot sort, plus CodeRabbit outside-diff body findings (source: outside_diff). unsure counts as valid; never auto-dismissed. A human/kody-bot reply citing a fixing commit ("fixed in abc1234") or a wontfix moves a thread to alreadyAddressed. Outside-diff items have no inline thread — fix in code and/or decide() with decideTarget (cr-comment:v1:…).', mustAddress: threads.mustAddress, alreadyAddressed: threads.alreadyAddressed, invalid: threads.invalid, }, otherThreads: { about: 'CodeRabbit / humans / other bots — not auto-classified. You judge validity.', items: threads.otherThreads, }, hidden: { resolvedThreads: threads.resolved, decidedThreads: threads.decided, ...(snapshot.threadsTruncated ? { truncated: 'more than 100 review threads; only the first 100 were read' } : {}), }, notes: [ 'You decide per thread: fix + push and reply "Fixed in ", reply wontfix with reasoning, or record a decision so status hides it for 7 days.', 'Outside-diff findings (source: outside_diff) cannot use reply-review — decide with decideTarget (cr-comment:v1:… or outside-diff:…).', 'Resolved threads are hidden. Outdated threads are often already fixed — confirm before replying.', ...(risk === 'medium' ? ['Medium risk: do not wait on CodeRabbit/Devin; handle their feedback if it already landed.'] : []), ...repoHints(snapshot, 'ai-review'), ], }, exampleInvokes: [ ...(first?.cannotReplyInline || first?.source === 'outside_diff' ? [] : [ exampleInvoke( 'reply-review', { prUrl, commentId: first?.commentId ?? 0, body: 'Fixed in : .', resolve: true }, 'Reply on a thread as kody-bot (and optionally resolve it).', ), ]), decideInvoke( 'thread', String(first?.decideTarget ?? first?.commentId ?? ''), 'wontfix', '', first?.cannotReplyInline || first?.source === 'outside_diff' ? 'Hide an outside-diff body finding (7 days). No reply-review — there is no inline thread.' : 'Hide a thread you judged noise (7 days). Add "reply": true to also post the reason on GitHub (or "reply": "" to post different text).', ), ...(bugbot.state === 'running' || bugbot.state === 'triggered' ? [] : [decideInvoke('check', 'bugbot', 'skipped', '', 'Stop waiting on Bugbot.')]), exampleInvoke('review-sort', { prUrl }, 'Full review-bot sort (classification only unless applyInvalidReplies: true).'), exampleInvoke('tick', tickParams, 'Re-tick (safe automations trigger Bugbot / reply on invalid findings).'), ], })) } // merge const preMergeSettled = (['ready', 'ci', 'ai-review'] as const).every((id) => settled.has(steps.get(id)!.status)) { const d = stepDecision('merge') let status: StepStatus let summary: string const ms = snapshot.mergeStateStatus if (snapshot.merged) [status, summary] = ['done', `merged ${snapshot.mergeCommitSha?.slice(0, 9) ?? ''}`.trim()] else if (!isOpen) [status, summary] = ['not_applicable', 'PR closed without merging'] else if (d) [status, summary] = ['skipped', `parked — ${d.disposition}: ${d.reason}`] else if (!preMergeSettled) [status, summary] = ['locked', 'after ready + ci + ai-review settle'] else if (risk === 'high' && !options.mergeAuthority) [status, summary] = ['needs_attention', 'high risk: park unless the user granted merge authority'] else if (snapshot.mergeable === 'CONFLICTING' || ms === 'DIRTY') [status, summary] = ['needs_attention', 'merge conflicts with the base branch'] else if (snapshot.mergeable === 'UNKNOWN' || ms === 'UNKNOWN') [status, summary] = ['waiting', 'GitHub is still computing mergeability'] else if (ms === 'BLOCKED') [status, summary] = ['needs_attention', 'blocked by branch protection (required reviews/checks)'] else if (ms === 'BEHIND') [status, summary] = ['needs_attention', 'branch is behind base and protection requires it up to date'] else if (ms === 'UNSTABLE') [status, summary] = ['todo', 'ready to merge (unstable: a non-required status is pending or failing, often a stale AI reviewer status; GitHub still allows the merge)'] else [status, summary] = ['todo', `ready to merge (${ms.toLowerCase()})`] steps.set('merge', { id: 'merge', title: STEP_TITLES.merge, status, summary }) detailBuilders.set('merge', () => ({ details: { risk, mergeAuthority: options.mergeAuthority, mergeable: snapshot.mergeable, mergeStateStatus: ms, reviewDecision: snapshot.reviewDecision, headSha: snapshot.headSha, policy: risk === 'high' ? 'High risk: leave ready-for-review unless the user explicitly granted merge authority.' : "Kent's standing policy (2026-08-08): auto-ship (squash-merge + verify deploy) once AI feedback is handled and CI is green, unless high risk.", notes: [ './merge re-runs this status as a preflight and pins the merge to the head SHA. It refuses on open CI failures / unhandled findings — decide them first if you judged them non-blocking.', 'Parking instead? Record decide step merge (skipped) with the reason; the run then moves to friction + report (status Parked).', ...repoHints(snapshot, 'merge'), ], }, exampleInvokes: [ exampleInvoke('merge', { prUrl, risk, dryRun: true, ...(risk === 'high' ? { mergeAuthority: true } : {}) }, 'Preflight only.'), exampleInvoke('merge', { prUrl, risk, ...(risk === 'high' ? { mergeAuthority: true } : {}) }, 'Squash-merge as kody-bot (pinned to head SHA).'), decideInvoke('step', 'merge', 'skipped', '', 'Park instead of merging.'), ], })) } // deploy const deploy = analyzeDeploy(snapshot, now) { const d = stepDecision('deploy') const mergeStatus = steps.get('merge')!.status let status: StepStatus let summary: string if (d) [status, summary] = ['skipped', `decided ${d.disposition}: ${d.reason}`] else if (snapshot.merged) [status, summary] = [deploy.status, deploy.summary] else if (mergeStatus === 'skipped' || mergeStatus === 'not_applicable') [status, summary] = ['not_applicable', 'not merged'] else [status, summary] = ['locked', 'after merge'] steps.set('deploy', { id: 'deploy', title: STEP_TITLES.deploy, status, summary }) detailBuilders.set('deploy', () => ({ details: { mergeSha: snapshot.mergeCommitSha, mergedAt: snapshot.mergedAt, runs: deploy.runs.map(runView), ...(deploy.production ? { production: deploy.production } : {}), notes: [ 'You decide when deploy is verified. Decide step deploy (skipped/accepted) when nothing deploys or you verified another way.', ...repoHints(snapshot, 'deploy'), ], }, exampleInvokes: [ decideInvoke('step', 'deploy', 'accepted', '', 'Mark deploy handled.'), exampleInvoke('tick', tickParams, 'Re-tick to watch runs.'), ], })) } // friction { const d = stepDecision('friction') const mergeStatus = steps.get('merge')!.status const unlocked = snapshot.merged || mergeStatus === 'skipped' || mergeStatus === 'not_applicable' let status: StepStatus let summary: string if (d) [status, summary] = ['done', `${d.disposition}: ${d.reason}`] else if (!unlocked) [status, summary] = ['locked', 'after the merge or park decision'] else [status, summary] = ['todo', 'scan the session for leftover out-of-scope papercuts'] steps.set('friction', { id: 'friction', title: STEP_TITLES.friction, status, summary }) const isKody = `${snapshot.locator.owner}/${snapshot.locator.repo}`.toLowerCase() === 'kentcdodds/kody' detailBuilders.set('friction', () => ({ details: { bar: 'Durable, recurring pain with a clear owner. Prefer one clear contract over aliases/compat layers. Do not invent papercuts or root causes. File brittle/flaky tests that were not valuable to begin with (even if you worked around them). In-scope fixes belong in the PR while it is open; never push onto a merged PR.', routing: { platform: isKody ? "{ host: 'github', repo: 'kentcdodds/kody' }" : `{ host: 'github', repo: '${snapshot.locator.owner}/${snapshot.locator.repo}' } (or kentcdodds/kody for platform pain)`, package: "{ host: 'kody', repo: '@scope/package' } (wakes Patch; also statically import kody:@kentcdodds/grok-bot/wake when using --code)", }, itemFields: ['title', 'whatHappened', 'whatYouWanted', 'howToReproduce', 'cost', 'preliminaryInvestigation', 'relevantFiles (only when known)'], // Structured settle contract survives brief mode (not under proseKeys). settle: { via: 'decide', kind: 'step', target: 'friction', dispositions: [...dispositions], meaning: { accepted: 'friction pass done (filed; reason lists them)', skipped: 'nothing met the bar', }, }, allowed: { disposition: dispositions }, policy: isKody ? 'docs/contributing/friction-log.md' : undefined, }, exampleInvokes: [ exampleInvoke( '', { target: { host: 'github', repo: isKody ? 'kentcdodds/kody' : `${snapshot.locator.owner}/${snapshot.locator.repo}` }, items: [{ title: '', whatHappened: '…', whatYouWanted: '…', howToReproduce: '…', cost: '…', preliminaryInvestigation: '' }], dryRun: true, }, 'File leftovers (drop dryRun to post).', '@kentcdodds/friction-log/file', ), decideInvoke('step', 'friction', 'accepted', '', 'Record that the friction pass is done.'), decideInvoke('step', 'friction', 'skipped', 'nothing met the friction bar', 'Or record that nothing qualified.'), ], })) } // report const reportStatus = inferReportStatus(snapshot, steps.get('merge')!.status) { const d = stepDecision('report') const sent = events.find((e) => e.kind === 'summary_sent') const deployStatus = steps.get('deploy')!.status const frictionDone = settled.has(steps.get('friction')!.status) const deploySettled = settled.has(deployStatus) let status: StepStatus let summary: string if (sent) [status, summary] = ['done', `sent ${String(sent.data.status ?? '')} summary ${sent.createdAt}`.trim()] else if (d) [status, summary] = ['skipped', `decided ${d.disposition}: ${d.reason}`] else if (!frictionDone || !deploySettled) [status, summary] = ['locked', 'after deploy + friction settle'] else [status, summary] = ['todo', `send the ${reportStatus} Discord summary`] steps.set('report', { id: 'report', title: STEP_TITLES.report, status, summary }) const r = options.report const missing = (['agentId', 'title', 'difficulty', 'summary'] as const).filter((k) => !r[k]) detailBuilders.set('report', () => ({ details: { status: reportStatus, fields: { agentId: r.agentId ?? null, model: r.model ?? null, title: r.title ?? null, difficulty: r.difficulty ?? null, door: r.door ?? null, cleanup: r.cleanup ?? null, cleanupNote: r.cleanupNote ?? null, summary: r.summary ?? null, }, missing, allowed: { difficulty: difficulties, door: doors, cleanup: cleanups, status: reportStatuses }, rules: [ 'title: human headline of the change itself (not "ship owner/repo#N").', "difficulty: 'Easy' | 'Medium' | 'Hard' — work difficulty, distinct from risk.", "door: 'two-way' | 'one-way' — omit when unknown; never invent. Renders Door: ↔️/➡️.", "cleanup: 'none' | 'needed' | 'done' (+ optional cleanupNote when needed) — omit when unknown; never invent.", 'agentId: Cursor Cloud Agent VM → curl -fsS --unix-socket "${CURSOR_AGENT_SOCKET:-/run/cursor/api.sock}" http://cursor-agent/v1/meta-data/agent/id; otherwise the bc- id from your launch URL.', 'model: deterministic only — VM metadata .../v1/meta-data/turn/model, or the model.id from launch. Missing → omit (posts "Model pending"). Never infer.', 'extras: clickable links to user-visible pages that actually deployed; never invent URLs.', ], }, exampleInvokes: [ exampleInvoke( 'send-summary', { prUrl, agentId: r.agentId ?? '', ...(r.model ? { model: r.model } : {}), title: r.title ?? '', difficulty: r.difficulty ?? '', ...(r.door ? { door: r.door } : {}), ...(r.cleanup ? { cleanup: r.cleanup } : {}), ...(r.cleanupNote ? { cleanupNote: r.cleanupNote } : {}), status: reportStatus, summary: r.summary ?? '', extras: ['CI green'], dryRun: true, }, 'Preview the Discord post (drop dryRun to send).', ), ], })) } // ---- focus, progress, exit const ordered = stepIds.map((id) => steps.get(id)!) const actionable = ordered.find((s) => s.status === 'needs_attention' || s.status === 'todo') const waiting = ordered.find((s) => s.status === 'waiting') const focus = actionable ?? waiting ?? null const expandSet = options.expand === 'all' ? new Set(stepIds) : new Set(options.expand) let focusInvokes: Array = [] for (const step of ordered) { const isFocus = Boolean(focus && step.id === focus.id) if (isFocus || expandSet.has(step.id)) { const built = detailBuilders.get(step.id)!() step.details = options.brief ? compactDetails(built.details) : built.details // Focus invokes live once at the top level; expanded steps keep their own. if (isFocus) focusInvokes = built.exampleInvokes else step.exampleInvokes = built.exampleInvokes } } const done = ordered .filter((s) => settled.has(s.status)) .map((s) => ({ id: s.id, status: s.status, summary: s.summary, ...(s.details ? { details: s.details } : {}), ...(s.exampleInvokes ? { exampleInvokes: s.exampleInvokes } : {}), })) const remaining = ordered.filter((s) => !settled.has(s.status)) const allDone = remaining.length === 0 let exitStatus: 'done' | 'needs_attention' | 'action_available' | 'ready_to_merge' | 'waiting' if (allDone) exitStatus = 'done' else if (!focus) exitStatus = 'waiting' else if (focus.status === 'needs_attention') exitStatus = 'needs_attention' else if (focus.status === 'todo') exitStatus = focus.id === 'merge' ? 'ready_to_merge' : 'action_available' else exitStatus = 'waiting' const phase: 'iterate' | 'merge' | 'deploy' | 'wrap-up' | 'done' = allDone ? 'done' : !focus ? 'iterate' : focus.id === 'merge' ? 'merge' : focus.id === 'deploy' ? 'deploy' : focus.id === 'friction' || focus.id === 'report' ? 'wrap-up' : 'iterate' const pollAfterSeconds = exitStatus === 'waiting' && focus ? (POLL_SECONDS[focus.id] ?? 60) : undefined const wakeHints = exitStatus === 'waiting' && focus && pollAfterSeconds != null ? wakeHintsForWaiting({ prUrl, prNumber: snapshot.locator.prNumber, risk, focus: focus.id, pollAfterSeconds, }) : undefined return { pr: { url: prUrl, title: snapshot.title, state: snapshot.state, draft: snapshot.isDraft, author: snapshot.author, headSha: snapshot.headSha, base: snapshot.baseRef, diff: `+${snapshot.additions}/-${snapshot.deletions}`, }, risk, phase, focus: focus?.id ?? null, exit: { done: allDone, status: exitStatus, ...(pollAfterSeconds != null ? { pollAfterSeconds } : {}), ...(wakeHints ? { wakeHints } : {}), reportStatus, }, ...(options.brief ? {} : { checklist: checklist(risk) }), done, remaining, exampleInvokes: [ ...focusInvokes, ...(wakeHints ? [ { purpose: 'REQUIRED when waiting: arm a one-shot timer fallback (CI subscriptions can miss). Re-arm from exit.wakeHints if still waiting after the wake.', command: `subscribe_timer name=${wakeHints.name} delaySeconds=${wakeHints.delaySeconds} once=true prompt=${JSON.stringify(wakeHints.prompt)}`, }, ] : []), ...(focusInvokes.some((i) => i.command.includes('ship-pr/tick')) ? [] : [exampleInvoke('tick', tickParams, 'Re-tick.')]), ], automations, activeDecisions: decisions.map((d) => ({ kind: d.kind, target: d.target, disposition: d.disposition, reason: d.reason, expiresAt: d.expiresAt, })), _internal: { ci, threads, bugbot }, } }