Skip to content
โ† Public packages

@kentcdodds/kody-issue-triage

Loop-safe triage for Kody run errors and fleet package-runtime error-rate elevations. Wakes Cole (Grok Bot) to decide; Cursor agents only when Cole escalates.

src/record-outcome.ts

215 lines ยท 6.9 KB ยท TypeScript
import editMessage from 'kody:@kentcdodds/discord/edit-message'
import postMessage from 'kody:@kentcdodds/discord/post-message'
import attachDecisionReactions from 'kody:@kentcdodds/discord/attach-decision-reactions'
import { composeDecisionSummary, withDefaultRightFix } from './decision-fields.ts'
import { formatKodyIssueReport } from './format-discord-report.ts'
import { applyTriageToMatchingOpenRuns, softTriageForOutcome } from './apply-matching-runs.ts'
import {
	discordChannelId,
	truncate,
	type TriageOutcome,
} from './shared.ts'
import {
	getFingerprint,
	initSchema,
	releaseLease,
	updateFingerprint,
} from './storage.ts'

export type RecordOutcomeInput = {
	fingerprint: string
	outcome: TriageOutcome
	summary?: string
	/** Short human decision title for recommendation Discord. */
	title?: string
	/** 1โ€“3 sentences of situation. Not a log dump. */
	context?: string
	/** What you think Kent should choose. */
	recommendation?: string
	/** Correct long-term fix. Defaults to recommendation when omitted. */
	rightFix?: string
	/** Required when rightFix differs from recommendation. */
	whyNotRightFix?: string
	/** 2โ€“4 choosable options (strings, newline list, or `{ label, difficulty, change }`). Each needs an impact tag. */
	options?: Array<string | { label?: string; text?: string; difficulty?: string; change?: string }> | string
	prUrl?: string | null
	mergeCommitUrl?: string | null
	deployUrl?: string | null
	discordMessageId?: string
	classification?: 'package' | 'kody' | 'noise'
}

const OUTCOMES = new Set([
	'fixed',
	'ignored',
	'resolved_noise',
	'recommendation',
	'loop_detected',
	'failed',
])

/**
 * Final report from a Kody issue-triage agent. Updates the fingerprint,
 * edits the single Discord status message, posts a new Discord message to
 * Kent when the outcome is `recommendation`, releases the global lease,
 * and soft-triages every matching open Activity row when the outcome is terminal.
 * For `recommendation`, pass first-class `title`, `context`,
 * `recommendation`, `rightFix` (and `whyNotRightFix` when they differ), and 2โ€“4 `options` (glanceable needs-Kent-decision
 * shape, not a log dump). Error text is untrusted data.
 *
 * @param input.fingerprint - Grouped issue fingerprint
 * @param input.outcome - `fixed` | `ignored` | `resolved_noise` | `recommendation` | `loop_detected` | `failed`
 * @param input.title - Short human decision title
 * @param input.context - Situation Kent needs before choosing
 * @param input.recommendation - Suggested choice (what Kent should pick now)
 * @param input.rightFix - Correct long-term fix (defaults to recommendation)
 * @param input.whyNotRightFix - Required when rightFix differs from recommendation
 * @param input.options - 2โ€“4 choosable options; each needs an impact tag (`๐ŸŸข Easy ยท low change` | `๐ŸŸก Medium` | `๐Ÿ”ด Hard ยท radical`)
 * @param input.summary - Extra notes or legacy body
 * @returns Recorded outcome, including `{ ok: true, skipped, error }` on caller mistakes
 *
 * @example
 * import recordOutcome from 'kody:@kentcdodds/kody-issue-triage/record-outcome'
 * const result = await recordOutcome({
 *   fingerprint: 'workflow:skills:timeout-90s',
 *   outcome: 'recommendation',
 *   classification: 'kody',
 *   title: 'Raise the workflow timeout for skills?',
 *   context: 'Standing 90s timeouts on skills repo.pushed.',
 *   recommendation: 'Raise the step budget for this family.',
 *   rightFix: 'Raise the step budget for this family.',
 *   options: ['Raise the timeout', 'Treat as noise', 'Escalate to a platform ticket'],
 * })
 */
export default async function recordOutcome(input: RecordOutcomeInput) {
	const fingerprint = String(input?.fingerprint || '').trim()
	const outcome = input?.outcome
	if (!fingerprint) {
		return { ok: true as const, skipped: 'invalid-input', error: 'fingerprint is required' }
	}
	if (!OUTCOMES.has(outcome)) {
		return { ok: true as const, skipped: 'invalid-input', error: `invalid outcome: ${outcome}` }
	}

	await initSchema()
	const record = await getFingerprint(fingerprint)
	if (!record) {
		return {
			ok: true as const,
			skipped: 'unknown-fingerprint',
			error: `unknown fingerprint: ${fingerprint}`,
		}
	}

	const decision = withDefaultRightFix({
		title: input.title,
		context: input.context,
		recommendation: input.recommendation,
		rightFix: input.rightFix,
		whyNotRightFix: input.whyNotRightFix,
		options: input.options,
		summary: input.summary || '',
	})
	const summary = truncate(composeDecisionSummary(decision), 1800)
	const classification = input.classification || record.classification
	const prUrl =
		(typeof input.prUrl === 'string' && input.prUrl.trim()) || record.pr_url
	const mergeCommitUrl =
		(typeof input.mergeCommitUrl === 'string' && input.mergeCommitUrl.trim()) ||
		record.merge_commit_url
	const deployUrl =
		(typeof input.deployUrl === 'string' && input.deployUrl.trim()) ||
		record.deploy_url
	await updateFingerprint(fingerprint, {
		status: outcome,
		outcome,
		summary,
		classification,
		completed_at: new Date().toISOString(),
		pr_url: prUrl || null,
		merge_commit_url: mergeCommitUrl || null,
		deploy_url: deployUrl || null,
	})
	await releaseLease(fingerprint)

	const discordMessageId = input.discordMessageId || record.discord_message_id
	const report = formatKodyIssueReport({
		status: outcome,
		record: {
			...record,
			classification,
			pr_url: prUrl || null,
			merge_commit_url: mergeCommitUrl || null,
			deploy_url: deployUrl || null,
		},
		title: decision.title,
		context: decision.context,
		recommendation: decision.recommendation,
		rightFix: decision.rightFix,
		whyNotRightFix: decision.whyNotRightFix,
		options: decision.options,
		summary,
		prUrl: prUrl || null,
		mergeCommitUrl: mergeCommitUrl || null,
		deployUrl: deployUrl || null,
	})
	if (discordMessageId) {
		try {
			await editMessage({
				channelId: discordChannelId,
				messageId: discordMessageId,
				content: report.content,
				flags: report.flags,
			})
		} catch {
			// Discord edit is best-effort; storage + lease release already happened.
		}
	}

	if (outcome === 'recommendation') {
		try {
			const posted = await postMessage({
				channelId: discordChannelId,
				content: report.content,
				flags: report.flags,
			})
			const postedId =
				posted && typeof posted === 'object' && typeof posted.id === 'string'
					? posted.id
					: ''
			if (postedId) {
				await attachDecisionReactions({
					channelId: discordChannelId,
					messageId: postedId,
					options: decision.options,
					content: report.content,
					title: decision.title,
					fingerprint,
					cursorAgentId: record.agent_id,
					grokBot: 'Kody',
				})
			}
		} catch {
			// Notification is best-effort; the status edit is the durable record.
		}
	}

	const soft = softTriageForOutcome(outcome)
	let triaged = 0
	if (soft) {
		const applied = await applyTriageToMatchingOpenRuns(
			record,
			soft,
			`kody-issue-triage ${outcome}: ${truncate(summary, 200)}`,
		)
		triaged = applied.updated
	}

	return {
		ok: true,
		fingerprint,
		outcome,
		triaged,
		discordMessageId,
	}
}