UNPKG

paqad-ai

Version:

Spec-driven development framework — AI agents that think before they type

291 lines (260 loc) • 10.9 kB
import { slash } from './command-name'; import { formatStopwatch } from './cost'; import { STAGE_TIMELINE_KINDS, fixProofClaims } from './model'; import { shipRail } from './ship'; import type { TableLine, TableSegment } from './table'; import { NOTHING_TO_PROVE, OLD_RECORD_NOTE, STEP_MARKS, oldRecordGates, recordGapLines, repairLine, stageHasEvidence, trustStage, type TrustGateRow, type TrustStage, type WordTone, } from './trust'; import type { PaqadCiJob, PaqadClaim, PaqadHudSnapshot, PaqadTimelineEntry, PaqadTone, } from './types'; export const NOTHING_PUSHED = 'Nothing pushed yet.'; export const NO_CHANGE = 'No change is in progress.'; export const NO_PLAN = 'No plan recorded yet.'; export const CLAIMS_HEADING = 'Claims and the tests that prove them'; export const FLAGS_HEADING = 'Flags'; const TERMINAL_TONE: Readonly<Record<WordTone, PaqadTone | null>> = { pass: 'good', fail: 'bad', cau: 'warn', acc: 'working', att: 'warn', dim: null, }; function line(...segments: Array<TableSegment | string>): TableLine { return segments.map((segment) => (typeof segment === 'string' ? { text: segment } : segment)); } function heading(text: string): TableLine { return [{ text, dim: true }]; } function worded(text: string, tone: WordTone): TableSegment { const terminal = TERMINAL_TONE[tone]; return terminal ? { text, tone: terminal } : { text, dim: true }; } function stepLine(step: TrustStage['steps'][number]): TableLine { const mark = STEP_MARKS[step.mark]; return [ step.mark === 'failed' ? { text: ` ${mark} `, tone: 'bad' } : { text: ` ${mark} ` }, step.mark === 'not-yet' ? { text: step.words, dim: true } : { text: step.words }, ...(step.detail ? [{ text: ` ${step.detail}`, dim: true }] : []), ]; } function gateRowLine(gate: TrustGateRow): TableLine { return [{ text: ` ${gate.words} ` }, worded(gate.word, gate.tone)]; } function stageLines(stage: TrustStage): TableLine[] { return [ [ { text: `${stage.words} ` }, worded(stage.mark ? `${stage.mark} ${stage.word}` : stage.word, stage.tone), ], ...stage.steps.map(stepLine), ...stage.gates.map(gateRowLine), ]; } function claimLine(claim: PaqadClaim): TableLine { const passed = claim.verdict === 'pass'; const name = claim.kind === 'fix-proof' ? 'Fix proof' : claim.kind; return [ { text: ` ${name}: ${claim.label} ` }, { text: passed ? 'passed' : 'Inconclusive', tone: passed ? 'good' : 'warn' }, { text: claim.test_id ? ` proving test ${claim.test_id}` : ' no proving test recorded', dim: true, }, ]; } export function trustLines(snapshot: PaqadHudSnapshot | null): TableLine[] { if (!snapshot?.active) return [line(NOTHING_TO_PROVE)]; const proof = snapshot.proof ?? null; const stages = proof && proof.some(stageHasEvidence) ? proof.map(trustStage) : []; const gates = proof === null ? oldRecordGates(snapshot) : []; const repairs = repairLine(snapshot); const claims = fixProofClaims(snapshot); const lines: TableLine[] = [ ...stages.flatMap(stageLines), ...(gates.length > 0 ? [line({ text: OLD_RECORD_NOTE, dim: true })] : []), ...gates.map(gateRowLine), ...recordGapLines(snapshot).map((gap) => line(gap)), ...(repairs ? [line({ text: repairs, dim: true })] : []), ...(claims.length > 0 ? [heading(CLAIMS_HEADING), ...claims.map(claimLine)] : []), ...(snapshot.flags.length > 0 ? [ heading(FLAGS_HEADING), ...snapshot.flags.map((flag) => line({ text: ' flag ', tone: 'warn' }, flag.text)), ] : []), ]; return lines.length > 0 ? lines : [line(NOTHING_TO_PROVE)]; } function row(label: string, value: string, tone?: PaqadTone, dim = false): TableLine { return [ { text: `${label.padEnd(13)}`, dim: true }, { text: value, ...(tone ? { tone } : {}), ...(dim ? { dim: true } : {}) }, ]; } export const ON_THE_BRANCH = 'on the branch already'; const JOB_DONE = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']); const JOB_FAILED = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']); function jobWord(job: PaqadCiJob): { mark: string; word: string; tone?: PaqadTone } { const outcome = (job.conclusion ?? job.status ?? '').toUpperCase(); if (JOB_FAILED.has(outcome)) return { mark: '✗', word: 'failed', tone: 'bad' }; if (JOB_DONE.has(outcome)) return { mark: '●', word: 'passed', tone: 'good' }; return { mark: '▸', word: 'running', tone: 'working' }; } function jobTime(job: PaqadCiJob, now: number): string | null { const started = job.started_at ? Date.parse(job.started_at) : Number.NaN; if (!Number.isFinite(started)) return null; const ended = job.completed_at ? Date.parse(job.completed_at) : Number.NaN; return formatStopwatch((Number.isFinite(ended) ? ended : now) - started); } export function readAgo(at: string | null, now: number): string | null { const read = at ? Date.parse(at) : Number.NaN; return Number.isFinite(read) ? `read ${formatStopwatch(now - read)} ago` : null; } function editRow(snapshot: PaqadHudSnapshot): TableLine { const state = shipRail(snapshot).steps[0]!.state; if (state === 'done') return row('Edit', 'done', 'good'); if (state === 'current') return row('Edit', 'running', 'working'); return row('Edit', 'not yet', undefined, true); } function commitRows(ship: NonNullable<PaqadHudSnapshot['ship']>): TableLine[] { if (ship.commit_list.length === 0) { return [ ship.commits > 0 ? row('Commits', `${ship.commits} of yours, no list recorded`) : row('Commits', 'none yet', undefined, true), ]; } const own = ship.commit_list.filter((commit) => commit.own !== false).length; return [ row('Commits', `${own} of yours`), ...ship.commit_list.map((commit): TableLine => commit.own === false ? [ { text: ` ${commit.sha} ${commit.subject} `, dim: true }, { text: ON_THE_BRANCH, dim: true }, ] : [{ text: ` ${commit.sha} ${commit.subject}` }], ), ]; } function prRow(ship: NonNullable<PaqadHudSnapshot['ship']>): TableLine { if (ship.pr === null || ship.pr_number === null) { return row('Pull request', 'not yet', undefined, true); } const state = ship.pr_state === 'merged' ? null : ship.pr_state === null ? 'state unknown' : ship.pr_state; const tone: PaqadTone | undefined = ship.pr_state === 'closed' ? 'warn' : undefined; const words = [ship.pr, state, ship.pr_url].filter((part): part is string => Boolean(part)); return row('Pull request', words.join(' '), tone); } function ciRows(ship: NonNullable<PaqadHudSnapshot['ship']>, now: number): TableLine[] { const ci = ship.ci_detail ?? null; const ago = readAgo(ship.ci_read_at, now); const suffix = ago ? `, ${ago}` : ''; if (!ci) return [row('CI', 'unknown, never recorded', 'warn')]; const count = ci.total !== null ? `${ci.done ?? 0} of ${ci.total} done` : null; const head = ci.state === 'pass' ? row('CI', `passed${count ? `, ${count}` : ''}${suffix}`, 'good') : ci.state === 'fail' ? row('CI', `failed${count ? `, ${count}` : ''}${suffix}`, 'bad') : ci.state === 'pending' ? row('CI', `running${count ? `, ${count}` : ''}${suffix}`, 'working') : row('CI', `unknown${ci.reason ? `: ${ci.reason}` : ''}${suffix}`, 'warn'); return [ head, ...ci.jobs.map((job): TableLine => { const look = jobWord(job); const time = jobTime(job, now); return [ { text: ` ${look.mark} `, ...(look.tone ? { tone: look.tone } : {}) }, { text: `${job.name} ${look.word}${time ? ` ${time}` : ''}` }, ]; }), ]; } function openPrRows(ship: NonNullable<PaqadHudSnapshot['ship']>): TableLine[] { if (ship.pr_state !== 'open' || ship.pr_number === null || !ship.pr_url) return []; return [[{ text: ` Open PR #${ship.pr_number}`, href: ship.pr_url }]]; } export function shipsLines(snapshot: PaqadHudSnapshot | null, now = 0): TableLine[] { const ship = snapshot?.active ? snapshot.ship : null; if (!snapshot?.active || !ship) return [line(NOTHING_PUSHED)]; const lines: TableLine[] = [editRow(snapshot), ...commitRows(ship)]; if (ship.push === null) lines.push(row('Push', 'not yet', undefined, true)); else if (ship.push === 'failed') lines.push(row('Push', 'failed', 'bad')); else lines.push(row('Push', `${ship.push}${ship.push_branch ? ` to ${ship.push_branch}` : ''}`)); lines.push(prRow(ship), ...openPrRows(ship), ...ciRows(ship, now)); return lines; } const PLAN_FILE = 'plan.json'; export function recordLines(snapshot: PaqadHudSnapshot | null): TableLine[] { if (!snapshot?.active) return [line(snapshot ? NO_CHANGE : NO_PLAN)]; const planned = snapshot.bundle_files.includes(PLAN_FILE); const { pending, resolved } = snapshot.decisions; const lines: TableLine[] = [ planned ? row('Plan', `recorded in ${PLAN_FILE}`, 'good') : line(NO_PLAN), row( 'Decisions', pending.length > 0 ? `${pending.length} waiting, ${resolved} resolved` : `${resolved} resolved`, pending.length > 0 ? 'needs-you' : undefined, ), ...pending.map((decision) => line({ text: ` ${decision.id} (${decision.category}) `, dim: true }, decision.question), ), snapshot.rules.recorded ? row('Rules', `${snapshot.rules.applicable ?? 0} apply, loaded`, 'good') : row('Rules', 'not loaded yet', 'warn'), snapshot.receipt ? row('Receipt', `written, see ${slash('receipt')}`, 'good') : row('Receipt', 'not yet, written at the end of a turn', undefined, true), ]; const timeline = snapshot.timeline.filter((entry) => STAGE_TIMELINE_KINDS.has(entry.kind)); if (timeline.length > 0) { lines.push(heading('Timeline'), ...timeline.map(timelineLine)); } return lines; } const TIMELINE_STAGES: Readonly<Record<string, string>> = { ticket_intake: 'Intake', planning: 'Plan', specification: 'Spec', development: 'Build', review: 'Review', checks: 'Checks', documentation_sync: 'Docs', delivery: 'Deliver', }; export function timelineWords(entry: PaqadTimelineEntry): string { const stage = entry.stage ? (TIMELINE_STAGES[entry.stage] ?? entry.stage) : 'A stage'; const note = entry.note ? `: ${entry.note}` : ''; if (entry.kind === 'open') return entry.note ? `Reopened${note}` : 'Change opened'; if (entry.kind === 'close') return `Closed${note}`; if (entry.kind === 'stage_start') return `${stage} started`; return `${stage} ended${note}`; } function timelineLine(entry: PaqadTimelineEntry): TableLine { return [{ text: ` ${entry.at.slice(11, 16)} `, dim: true }, { text: timelineWords(entry) }]; } export function linesText(lines: readonly TableLine[]): string { return lines.map((entry) => entry.map((segment) => segment.text).join('')).join('\n'); }