Skip to content

src/status.ts

1285 lines · 55.6 KB · TypeScript
Raw
/**
 * 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<StepStatus>(['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<StepId>
	/** 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<StepId, string> = {
	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<Record<StepId, number>> = {
	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<Decision>, kind: Decision['kind'], ...targets: Array<string>) {
	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<string> {
	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<CiTrigger | null> = [
		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<Decision>, now: number) {
	const ci = snapshot.checks.filter((check) => !aiReviewerOfCheck(check))
	const hiddenDecided: Array<{ name: string; disposition: string; reason: string }> = []
	const visible: Array<NormalizedCheck> = []
	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<ClassifiableComment> {
	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<Decision>) {
	const mustAddress: Array<ThreadItem> = []
	const alreadyAddressed: Array<ThreadItem> = []
	const invalid: Array<ThreadItem> = []
	const otherThreads: Array<ThreadItem> = []
	const findings: Array<ReviewFinding> = []
	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<Decision>, reviewer: AiReviewerKind, checkName?: string) {
	return decisionFor(decisions, 'check', reviewer, checkName ?? '')
}

export function analyzeBugbot(
	snapshot: PrSnapshot,
	decisions: Array<Decision>,
	events: Array<ShipEvent>,
	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(/<!--[\s\S]*?-->/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<Decision>,
	kind: 'coderabbit' | 'devin' | 'seer',
	now: number,
): ReviewerState & { latestReview?: Record<string, unknown> } {
	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(/<!--[\s\S]*?-->/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<WorkflowRun>) {
	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<string> {
	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 <merge 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<string, unknown>): Record<string, unknown> {
	const out: Record<string, unknown> = {}
	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<string, unknown>).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<string, unknown>).map(([k, v]) => {
					const r = (v ?? {}) as Record<string, unknown>
					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<string, unknown>
			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<string, unknown>
	exampleInvokes?: Array<Invoke>
}

/**
 * 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<Decision>,
	events: Array<ShipEvent>,
	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<StepId, StepReport>()
	const detailBuilders = new Map<StepId, () => { details: Record<string, unknown>; exampleInvokes: Array<Invoke> }>()
	const automations: Array<Automation> = []
	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 ?? '<check 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', '<why this failure does not block, e.g. known flake tracked in #123>', '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<string> = []
		const attention: Array<string> = []
		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 <sha>", 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 <sha>: <what changed>.', resolve: true },
								'Reply on a thread as kody-bot (and optionally resolve it).',
							),
						]),
				decideInvoke(
					'thread',
					String(first?.decideTarget ?? first?.commentId ?? '<root comment id or cr-comment:v1:…>'),
					'wontfix',
					'<why this finding does not apply>',
					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": "<text>" to post different text).',
				),
				...(bugbot.state === 'running' || bugbot.state === 'triggered'
					? []
					: [decideInvoke('check', 'bugbot', 'skipped', '<why Bugbot is not needed here>', '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', '<why parking, e.g. high risk without merge authority>', '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', '<how you verified, or why nothing deploys>', '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: '<what hurt>', whatHappened: '…', whatYouWanted: '…', howToReproduce: '…', cost: '…', preliminaryInvestigation: '<what you already looked at>' }],
						dryRun: true,
					},
					'File leftovers (drop dryRun to post).',
					'@kentcdodds/friction-log/file',
				),
				decideInvoke('step', 'friction', 'accepted', '<filed #123, #124>', '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 ?? '<bc-…>',
						...(r.model ? { model: r.model } : {}),
						title: r.title ?? '<human headline of the change>',
						difficulty: r.difficulty ?? '<Easy|Medium|Hard>',
						...(r.door ? { door: r.door } : {}),
						...(r.cleanup ? { cleanup: r.cleanup } : {}),
						...(r.cleanupNote ? { cleanupNote: r.cleanupNote } : {}),
						status: reportStatus,
						summary: r.summary ?? '<one-screen what shipped and why it is done>',
						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<Invoke> = []
	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 },
	}
}