import { normalizeFrictionTitle, normalizeRelevantFiles, } from './shared.ts' export type FrictionHost = 'github' | 'kody' export type FrictionTarget = { host: FrictionHost /** github: "owner/repo"; kody: "@owner/leaf" or "leaf" */ repo: string } export type QualifyFrictionInput = { title?: string body?: string whatHappened?: string whatYouWanted?: string howToReproduce?: string cost?: string /** Short notes on what was already looked at. Not a root cause. */ preliminaryInvestigation?: string /** Optional paths that look relevant; omit when none are known. */ relevantFiles?: Array target?: FrictionTarget | null } export type QualifyFrictionResult = | { ok: true } | { ok: false; reason: string } const MIN_STRUCTURED_CHARS = 40 const SHORT_WHAT_HAPPENED = 40 /** * Soft qualification bar for friction papercuts. * * Prefer durable, recurring, clear-owner, reproducible contract gaps. * Soft-skip (reason string, never throw) when the report looks session-bound, * one-off agent confusion, or missing a reproducible gap — not via a deny-list * of titles. */ export function parseGithubRepo( repo: string, ): { owner: string; name: string } | null { const trimmed = String(repo ?? '').trim() if (!trimmed || trimmed.includes('@')) return null const parts = trimmed.split('/').filter(Boolean) if (parts.length !== 2) return null const [owner, name] = parts if (!owner || !name) return null return { owner, name } } export function isFrictionHost(value: unknown): value is FrictionHost { return value === 'github' || value === 'kody' } export function qualifyTarget( target: FrictionTarget | null | undefined, ): QualifyFrictionResult { if (!target || typeof target !== 'object') { return { ok: false, reason: 'target is required ({ host: "github"|"kody", repo })', } } if (!isFrictionHost(target.host)) { return { ok: false, reason: 'target.host must be "github" or "kody"' } } const repo = String(target.repo ?? '').trim() if (!repo) { return { ok: false, reason: 'target.repo is required' } } if (target.host === 'github' && !parseGithubRepo(repo)) { return { ok: false, reason: 'target.repo must be "owner/repo" for host github', } } return { ok: true } } function structuredText(input: QualifyFrictionInput) { return { body: String(input.body ?? '').trim(), whatHappened: String(input.whatHappened ?? '').trim(), whatYouWanted: String(input.whatYouWanted ?? '').trim(), howToReproduce: String(input.howToReproduce ?? '').trim(), cost: String(input.cost ?? '').trim(), preliminaryInvestigation: String( input.preliminaryInvestigation ?? '', ).trim(), relevantFiles: normalizeRelevantFiles(input.relevantFiles), } } /** * Soft-skip when the papercut fails the durable-gap bar. * Callers should return `{ ok: true, skipped: true, reason }` — do not throw. */ export function qualifyFriction( input: QualifyFrictionInput = {}, ): QualifyFrictionResult { const targetCheck = qualifyTarget(input.target) if (!targetCheck.ok) return targetCheck const host = input.target!.host const rawTitle = String(input.title ?? '').trim() if (host === 'github') { const title = normalizeFrictionTitle(rawTitle) if (!title.replace(/^Friction:\s*/i, '').trim()) { return { ok: false, reason: 'title is required' } } } else if (!rawTitle) { return { ok: false, reason: 'title is required' } } const fields = structuredText(input) const structuredLen = fields.whatHappened.length + fields.whatYouWanted.length + fields.howToReproduce.length + fields.cost.length // Structured filings need a short preliminary investigation (files optional) if (!fields.body && !fields.preliminaryInvestigation) { return { ok: false, reason: 'preliminaryInvestigation is required: short notes on what was already looked at (relevantFiles optional; do not invent a root cause)', } } // Missing reproducible gap: no howToReproduce, no body, structured too thin if ( !fields.howToReproduce && !fields.body && structuredLen < MIN_STRUCTURED_CHARS ) { return { ok: false, reason: 'missing reproducible gap: provide howToReproduce, body, or richer structured fields (durable contract/API/docs gap)', } } // Transient / session-bound heuristic (not a title deny-list): // missing howToReproduce + cost empty + whatHappened under ~40 chars if ( !fields.howToReproduce && !fields.cost && fields.whatHappened.length > 0 && fields.whatHappened.length < SHORT_WHAT_HAPPENED && !fields.body ) { return { ok: false, reason: 'looks transient/session-bound: add howToReproduce and cost, or describe a clear recurring contract gap', } } return { ok: true } } export function buildPatchWakePrompt(input: { title: string target: FrictionTarget body?: string whatHappened?: string whatYouWanted?: string howToReproduce?: string cost?: string preliminaryInvestigation?: string relevantFiles?: Array }) { const fields = structuredText(input) const lines = [ 'Package friction routed to Patch — triage/fix the Kody package; escalate to Cole only if it\'s platform.', '', `Title: ${String(input.title ?? '').trim()}`, `Package: ${String(input.target.repo ?? '').trim()}`, `Host: kody`, ] if (fields.body) { lines.push('', '## Body', '', fields.body) } if (fields.whatHappened) { lines.push('', '## What happened', '', fields.whatHappened) } if (fields.whatYouWanted) { lines.push('', '## What you wanted', '', fields.whatYouWanted) } if (fields.howToReproduce) { lines.push('', '## How to reproduce', '', fields.howToReproduce) } if (fields.cost) { lines.push('', '## Cost', '', fields.cost) } if (fields.preliminaryInvestigation || fields.relevantFiles.length > 0) { lines.push('', '## Preliminary investigation', '') if (fields.preliminaryInvestigation) { lines.push(fields.preliminaryInvestigation) } if (fields.relevantFiles.length > 0) { if (fields.preliminaryInvestigation) lines.push('') lines.push('Relevant files:') for (const file of fields.relevantFiles) { lines.push(`- \`${file}\``) } } } return lines.join('\n') }