← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/status.ts
1285 lines · 55.6 KB · TypeScript/**
* 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 },
}
}