paqad-ai
Version:
Spec-driven development framework — AI agents that think before they type
235 lines (210 loc) • 8.85 kB
text/typescript
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) };
}