UNPKG

paqad-ai

Version:

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

235 lines (210 loc) • 8.85 kB
import { slash } from './command-name'; import { costLines } from './cost'; import { buildBandView, flagPill, headlineAfterState, stageLabel, stageSegments, type BandView, } from './model'; import { CHANGE_CLOSED, DELIVERY_MISSING, NEW_COMMITS_AFTER_CLOSE, NO_VERDICT_WORDS, } from './desktop-view'; import { shipRail, STEP_WORDS } from './ship'; import { hiddenReply, SHOWN_AGAIN } from './visibility'; import { linesText, recordLines, shipsLines, trustLines } from './tabs'; import type { PaqadFindingsScan, PaqadHudSnapshot, PaqadStandDown } from './types'; export const SUBCOMMANDS = [ 'status', 'decisions', 'rules', 'receipt', 'cost', 'trust', 'ships', 'delivery', 'record', 'show', 'hide', ] as const; const ALIASES: Readonly<Record<string, string>> = { delivery: 'ships' }; export const COMMAND_DESCRIPTION = 'Where the paqad change stands: status, decisions, rules, receipt, cost, trust, delivery, record, or show or hide the band'; export const ARGUMENT_HINT = `[${SUBCOMMANDS.join('|')}]`; export function subcommandOf(args: string): string { const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''; return ALIASES[word] ?? word; } export interface CommandInput { args: string; standDown: PaqadStandDown | null; snapshot: PaqadHudSnapshot | null; receipt: string | null; now: number; repoDecisions?: readonly RepoDecision[]; } function help(): string { const listed = SUBCOMMANDS.filter((sub) => ALIASES[sub] === undefined).map((sub) => slash(sub)); return `Try ${listed.slice(0, -1).join(', ')} or ${listed.at(-1)}.`; } const STAND_DOWN_WORDS: Record<PaqadStandDown, string> = { 'view-off': 'the paqad HUD is turned off in the plugin settings', 'not-onboarded': 'this project was never onboarded', remote: 'this session runs in the cloud', disabled: 'paqad is turned off for this project', 'old-engine': 'this Claude Code is older than 2.1.301', }; export function notActiveText(reason: PaqadStandDown): string { return `paqad is not active here: ${STAND_DOWN_WORDS[reason]}.`; } const NO_RECORD = `Not yet. paqad has no record for this session.`; function stageLine(view: BandView): string { return `Stage: ${view.stages .map((segment) => segment.state === 'running' && view.clock ? `${segment.label} running ${view.clock}` : `${segment.label} ${segment.state}`, ) .join(', ')}.`; } function shipLine(snapshot: PaqadHudSnapshot): string { return `Ship: ${shipRail(snapshot) .steps.map((step) => `${step.label} ${STEP_WORDS[step.state]}`) .join(', ')}.`; } function stateLine(view: BandView): string { const pill = flagPill(view.flags); const rest = headlineAfterState(view); return `${view.state}${pill ? ` (${pill})` : ''}.${rest ? ` ${rest}` : ''}`; } function overview(snapshot: PaqadHudSnapshot, now: number): string { const view = buildBandView(snapshot, now); return [stateLine(view), stageLine(view), shipLine(snapshot), view.cost, help()].join('\n'); } function closedStatusLine( snapshot: PaqadHudSnapshot, closed: NonNullable<PaqadHudSnapshot['closed_change']>, ): string { const number = snapshot.ship?.pr_number ?? null; const parts = [`${CHANGE_CLOSED}: ${closed.word ?? NO_VERDICT_WORDS}`]; parts.push(number !== null && snapshot.ship?.pr_url ? `PR #${number}` : DELIVERY_MISSING); if (closed.new_commits > 0) parts.push(NEW_COMMITS_AFTER_CLOSE); return `${parts.join(', ')}.`; } function status(snapshot: PaqadHudSnapshot, now: number): string { const view = buildBandView(snapshot, now); const lines = [stateLine(view)]; if (snapshot.active && view.note) lines.push(`Note: ${view.note}.`); if (snapshot.active && view.gap) lines.push(`${view.gap}.`); const change = snapshot.change; const closed = !snapshot.active ? snapshot.closed_change : undefined; if ((snapshot.active || closed) && change) { const name = [change.issue ? `#${change.issue}` : null, change.title].filter(Boolean).join(' '); lines.push(`Change: ${name || change.id || 'untitled'}.`); if (change.branch) lines.push(`Branch: ${change.branch}.`); } if (closed) lines.push(closedStatusLine(snapshot, closed)); if (snapshot.lane) { lines.push(`Lane: ${snapshot.lane}${snapshot.lane_reason ? `, ${snapshot.lane_reason}` : ''}.`); } if (snapshot.paused.length > 0) { lines.push( `Paused: ${snapshot.paused.length} change${snapshot.paused.length === 1 ? '' : 's'}.`, ); } for (const segment of stageSegments(snapshot)) { const clock = segment.state === 'running' && view.clock ? ` ${view.clock}` : ''; lines.push(` ${segment.label}: ${segment.state}${clock}`); } for (const flag of snapshot.flags) lines.push(`Flag: ${flag.text}`); if (snapshot.verdict) { lines.push(`Verdict: ${snapshot.verdict.word}.`); for (const reason of snapshot.verdict.reasons) lines.push(` ${reason}`); } return lines.join('\n'); } export interface RepoDecision { id: string; category: string; question: string; } function decisions(snapshot: PaqadHudSnapshot, repo: readonly RepoDecision[] | undefined): string { const pending = repo ?? snapshot.decisions.pending; const where = repo ? ' in this repo' : ''; const done = `${snapshot.decisions.resolved} resolved for this change.`; if (pending.length === 0) return `No decision is waiting${where}. ${done}`; return [ `${pending.length} decision${pending.length === 1 ? '' : 's'} waiting${where}. ${done}`, ...pending.map((d) => ` ${d.id} (${d.category}): ${d.question || 'no question recorded'}`), ].join('\n'); } export function readRepoDecision(name: string, text: string | null): RepoDecision { const fallback = name.replace(/\.json$/, ''); let packet: Record<string, unknown> = {}; try { const parsed: unknown = text === null ? null : JSON.parse(text); if (typeof parsed === 'object' && parsed !== null) packet = parsed as Record<string, unknown>; } catch { packet = {}; } const word = (key: string) => (typeof packet[key] === 'string' ? (packet[key] as string) : null); return { id: word('id') ?? word('decision_id') ?? fallback, category: word('category') ?? 'unknown', question: word('question') ?? word('title') ?? '', }; } function scanLine(label: string, scan: PaqadFindingsScan | undefined): string { if (!scan || scan.status === 'did not run') return `${label}: did not run.`; if (scan.status === 'clean') return `${label}: none open.`; return `${label}: ${scan.total ?? 0} open${scan.blocking ? ', blocking' : ''}.`; } function rules(snapshot: PaqadHudSnapshot): string { if (!snapshot.active) return 'No change is in progress, so no rules are loaded for one.'; const lines = [ snapshot.rules.recorded ? `Rules loaded for this change: ${snapshot.rules.applicable ?? 0} apply${ snapshot.rules.loaded_at ? `, loaded ${snapshot.rules.loaded_at}` : '' }.` : 'The rules for this change are not loaded yet.', scanLine('Rule findings', snapshot.findings?.rules), scanLine('Near-copies', snapshot.findings?.near_copies), ]; return lines.join('\n'); } function cost(snapshot: PaqadHudSnapshot): string { if (!snapshot.active) return 'No change is in progress, so there is no cost to show.'; if (!snapshot.cost) { return 'Cost: not yet. paqad counts it from the transcripts at the end of each turn.'; } return costLines(snapshot.cost, { lane: snapshot.lane, stageLabel }).join('\n'); } export const NO_RECEIPT = 'No receipt yet. paqad writes one at the end of each turn.'; function receipt(text: string | null): string { const body = text?.trim(); return body ? body : NO_RECEIPT; } export function runPaqadCommand(input: CommandInput): { text: string } { if (input.standDown) return { text: notActiveText(input.standDown) }; const sub = subcommandOf(input.args); if (sub === 'receipt') return { text: receipt(input.receipt) }; if (sub === 'show') return { text: SHOWN_AGAIN }; if (sub === 'hide') return { text: hiddenReply() }; if (sub !== '' && !(SUBCOMMANDS as readonly string[]).includes(sub)) { return { text: `There is no ${slash(sub)}. ${help()}` }; } const snapshot = input.snapshot; if (!snapshot) return { text: `${NO_RECORD} ${help()}` }; if (sub === 'status') return { text: status(snapshot, input.now) }; if (sub === 'decisions') return { text: decisions(snapshot, input.repoDecisions) }; if (sub === 'rules') return { text: rules(snapshot) }; if (sub === 'cost') return { text: cost(snapshot) }; if (sub === 'trust') return { text: linesText(trustLines(snapshot)) }; if (sub === 'ships') return { text: linesText(shipsLines(snapshot, input.now)) }; if (sub === 'record') return { text: linesText(recordLines(snapshot)) }; return { text: overview(snapshot, input.now) }; }