/** * ui-architect.extension.ts — Pi adapter for the portable `ui-architect` skill (ADR-0091 C5). * * Registers `/ui-architect ` and dispatches isolated planning / composition / QA * subtasks as real child `pi` processes — the same technique the officially shipped * `examples/extensions/subagent/` extension in `@earendil-works/pi-coding-agent` uses * (verified against the installed package, v0.84.3): `child_process.spawn` of a nested * `pi --mode json -p --no-session --append-system-prompt --tools `, * parsing the newline-delimited `message_end` JSON event stream for the child's final * assistant text. This file is self-contained — it does not depend on that example * extension being installed, it only reuses its proven invocation shape. * * It never copies the workflow's prose: `readSkillBody()` reads * `../skills/ui-architect/SKILL.md` off disk at COMMAND-INVOCATION time (not at build * time, not baked into this file), so the skill body stays the single authored copy * (SPEC REQ-001's single-source-of-truth requirement) and this file only adds the * Pi-specific dispatch mechanics: which CLI flags perform an isolated pass, and this * command's own tool-wall-per-phase. * * Isolation and tool walls per phase mirror this plugin's Claude seats exactly * (`agents/app-planning-agent.md`, `agents/screen-composition-agent.md`, * `agents/surface-qa-agent.md` `tools:` frontmatter — SPEC REQ-009's parity intent, * applied here to Pi's own `--tools` allowlist rather than Claude's tool-wall field): * planning: read, grep, find, ls, bash (no write/edit — orient, never build) * composition: read, grep, find, ls, edit, write, bash (the one phase that may mutate) * qa: read, grep, find, ls, bash (no write/edit — verify fresh, never self-certify) * * Contract gates: every phase transition runs the shared, portable linters * (`scripts/record-lint`, `scripts/build-result-lint`, `scripts/verify-proof-lint` — * SPEC REQ-007, "every adapter... calls the same linter script, never a runtime-specific * re-implementation"). A phase whose output fails its linter is retried once per the * SKILL.md's own re-plan-not-reblind rule; a wave's four-lap cap (SKILL.md "Gear * selection") is enforced by `MAX_LAPS_PER_WAVE` below. */ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { spawn, spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; // This file lives at /pi/ui-architect.extension.ts. const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = path.join(EXTENSION_DIR, ".."); const SKILL_MD_PATH = path.join(PLUGIN_ROOT, "skills", "ui-architect", "SKILL.md"); const LINTERS = { orientation: path.join(PLUGIN_ROOT, "scripts", "record-lint"), build: path.join(PLUGIN_ROOT, "scripts", "build-result-lint"), verify: path.join(PLUGIN_ROOT, "scripts", "verify-proof-lint"), } as const; const TOOLS = { planning: ["read", "grep", "find", "ls", "bash"], composition: ["read", "grep", "find", "ls", "edit", "write", "bash"], qa: ["read", "grep", "find", "ls", "bash"], } as const; const MAX_LAPS_PER_WAVE = 4; interface MessagePart { type: string; text?: string; } interface PiMessage { role: string; content: MessagePart[]; } /** Strip YAML frontmatter, return the SKILL.md body — read fresh on every invocation. */ function readSkillBody(): string { const raw = fs.readFileSync(SKILL_MD_PATH, "utf-8"); const match = raw.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); return (match ? match[1] : raw).trim(); } /** * Resolve how to re-invoke `pi` as a child process. Mirrors * `examples/extensions/subagent/index.ts`'s `getPiInvocation` (same installed package) — * handles the bun-compiled-binary, bare-node-script, and `pi`-on-PATH cases identically. */ function getPiInvocation(args: string[]): { command: string; args: string[] } { const currentScript = process.argv[1]; const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) { return { command: process.execPath, args: [currentScript, ...args] }; } const execName = path.basename(process.execPath).toLowerCase(); const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); if (!isGenericRuntime) return { command: process.execPath, args }; return { command: "pi", args }; } function getFinalOutput(messages: PiMessage[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text" && part.text) return part.text; } } } return ""; } interface PhaseResult { exitCode: number; output: string; stderr: string; } /** * Run one isolated phase as a real, separate `pi` child process — its own context window, * its own tool wall, no shared state with the dispatching session or any other phase. */ async function runPhase( cwd: string, systemPrompt: string, task: string, tools: readonly string[], signal: AbortSignal | undefined, ): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ui-architect-")); const promptPath = path.join(tmpDir, "system-prompt.md"); try { fs.writeFileSync(promptPath, systemPrompt, { encoding: "utf-8", mode: 0o600 }); const args = ["--mode", "json", "-p", "--no-session", "--tools", tools.join(","), "--append-system-prompt", promptPath, task]; return await new Promise((resolve) => { const invocation = getPiInvocation(args); const proc = spawn(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], }); const messages: PiMessage[] = []; let stderr = ""; let buffer = ""; const processLine = (line: string) => { if (!line.trim()) return; let event: any; try { event = JSON.parse(line); } catch { return; } if (event.type === "message_end" && event.message) messages.push(event.message as PiMessage); }; proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) processLine(line); }); proc.stderr.on("data", (data) => { stderr += data.toString(); }); proc.on("close", (code) => { if (buffer.trim()) processLine(buffer); resolve({ exitCode: code ?? 0, output: getFinalOutput(messages), stderr }); }); proc.on("error", (err) => resolve({ exitCode: 1, output: "", stderr: String(err) })); if (signal) { const killProc = () => { proc.kill("SIGTERM"); setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000); }; if (signal.aborted) killProc(); else signal.addEventListener("abort", killProc, { once: true }); } }); } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } } } interface LintResult { ok: boolean; output: string; } /** Every phase transition calls the SAME shared linter every runtime uses (SPEC REQ-007). */ function runLinter(linterPath: string, text: string): LintResult { const res = spawnSync(linterPath, ["-"], { input: text, encoding: "utf-8" }); const output = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim(); return { ok: res.status === 0, output }; } // Literal templates, copied verbatim (not paraphrased) from each contract's own SKILL.md so // the isolated pass has no room to substitute prose headings for the linter's required flat // `key: value` lines (record-lint / build-result-lint / verify-proof-lint all match these // literally — see each script's own header for the exact required-line list). const ORIENTATION_RECORD_TEMPLATE = [ "Rendering mode: SPA | SSR | hybrid — signal: ", "Project shape: single-surface | rollup | shared-foundation — signal: <…>", "Shell: admin | chat | editor | simple | embed | none — signal: <…>", "Task: — signal: ", "→ Route: , per the task table", "Verify target: ", "Open questions: ", ].join("\n"); const BUILD_RESULT_TEMPLATE = [ "BuildResult", "surface: ", "orientationRef: ", "filesChanged: ", "gatesRun: ", "evidence: ", "selfCheck: pass | fail | UNMEASURED — (NEVER a ship/hold verdict)", "openIssues: ", ].join("\n"); const VERIFY_PROOF_TEMPLATE = [ "url: ", "consoleErrors: pass | fail | UNMEASURED — ", "boundingBoxes: pass | fail — ", "screenshot: @ deviceScaleFactor 2", "imageRead: ", "perf: ms vs ms — ADVISORY, never gates | UNMEASURED — ", "a11y: pass | fail | UNMEASURED — region role/label · overlays · heading roles · keyboard path · AA contrast", "structure: adia-lint clean on every written file | ", "verdict: ship | hold — ", ].join("\n"); function planningPrompt(skillBody: string): string { return ( `${skillBody}\n\n---\n` + "You are running the PLANNING pass of the ui-architect workflow above, in an isolated " + "context — a separate process with no memory of any other pass. Apply Gear selection to " + "the brief below.\n\n" + "Your final answer MUST start with a line `Gear: 1` or `Gear: 2` (per the skill's Gear " + "selection rule), followed by exactly one app-planning OrientationRecord in EXACTLY this " + "flat `label: value` line shape — no markdown headings, no table, no extra prose around " + "it:\n\n" + `${ORIENTATION_RECORD_TEMPLATE}\n\n` + "If Gear 2 (a PRD/multi-surface brief), also include a `## Waves` heading with one " + "`- ` line per surface — omit that heading entirely for Gear 1. Emit nothing " + "else — no other prose before or after." ); } function compositionPrompt(skillBody: string): string { return ( `${skillBody}\n\n---\n` + "You are running the COMPOSITION pass of the ui-architect workflow above, in an isolated " + "context, downstream of an OrientationRecord given to you as INPUT SIGNAL below — re-derive " + "from it, never adopt it uninspected. Build the surface it describes.\n\n" + "Your final answer MUST be exactly one BuildResult record in EXACTLY this flat `label: value` " + "line shape — no markdown headings, no table, no extra prose around it — never a ship/hold " + "verdict, that field is VerifyProof-exclusive:\n\n" + `${BUILD_RESULT_TEMPLATE}\n\nEmit nothing else.` ); } function qaPrompt(skillBody: string): string { return ( `${skillBody}\n\n---\n` + "You are running the QA pass of the ui-architect workflow above, in an isolated context, " + "independent of and downstream from the BuildResult given to you as INPUT SIGNAL below. You " + "did not write this code — verify it fresh; never self-certify the builder's own report.\n\n" + "Your final answer MUST be exactly one VerifyProof record in EXACTLY this flat `label: value` " + "line shape — no markdown headings, no table, no extra prose around it, carrying the " + "ship/hold verdict:\n\n" + `${VERIFY_PROOF_TEMPLATE}\n\nEmit nothing else.` ); } function parseGear(planningOutput: string): 1 | 2 { const m = planningOutput.match(/^Gear:\s*([12])/im); return m && m[1] === "2" ? 2 : 1; } function parseWaves(planningOutput: string): string[] { // No `m`/`^` here deliberately: `$` must mean true end-of-string, not end-of-line — under // the multiline flag `\s*$` matches after the FIRST wave line too (every line has an // end), truncating the capture to one entry. const section = planningOutput.match(/##\s*Waves\s*\n([\s\S]*?)(?:\n##|\n---|$)/i); if (!section) return ["the deliverable"]; const waves = section[1] .split("\n") .map((l) => l.replace(/^[-*]\s*/, "").trim()) .filter(Boolean); return waves.length ? waves : ["the deliverable"]; } function parseVerdict(verifyProofOutput: string): "ship" | "hold" | "unknown" { const m = verifyProofOutput.match(/^verdict:\s*(ship|hold)\b/im); return m ? (m[1].toLowerCase() as "ship" | "hold") : "unknown"; } interface WaveOutcome { wave: string; laps: number; verdict: "ship" | "hold" | "unknown" | "escalated"; verifyProof: string; buildResult: string; findings: string[]; } async function runWave( cwd: string, skillBody: string, topOrientation: string, wave: string, maxLaps: number, signal: AbortSignal | undefined, log: (line: string) => void, ): Promise { const findings: string[] = []; let lastVerify = ""; let lastBuild = ""; for (let lap = 1; lap <= maxLaps; lap++) { log(` [${wave}] lap ${lap}/${maxLaps} — planning`); const planTask = lap === 1 ? `Wave: ${wave}\n\nTop-level OrientationRecord (INPUT SIGNAL):\n${topOrientation}` : `Wave: ${wave}\n\nPrior VerifyProof findings to re-plan against (INPUT SIGNAL):\n${lastVerify}`; const plan = await runPhase(cwd, planningPrompt(skillBody), planTask, TOOLS.planning, signal); const planLint = runLinter(LINTERS.orientation, plan.output); if (!planLint.ok) findings.push(`[${wave}] lap ${lap} planning record-lint findings:\n${planLint.output}`); log(` [${wave}] lap ${lap} — composition`); const build = await runPhase( cwd, compositionPrompt(skillBody), `Wave: ${wave}\n\nOrientationRecord (INPUT SIGNAL):\n${plan.output}`, TOOLS.composition, signal, ); lastBuild = build.output; const buildLint = runLinter(LINTERS.build, build.output); if (!buildLint.ok) findings.push(`[${wave}] lap ${lap} build-result-lint findings:\n${buildLint.output}`); log(` [${wave}] lap ${lap} — QA`); const verify = await runPhase( cwd, qaPrompt(skillBody), `Wave: ${wave}\n\nBuildResult (INPUT SIGNAL):\n${build.output}`, TOOLS.qa, signal, ); lastVerify = verify.output; const verifyLint = runLinter(LINTERS.verify, verify.output); if (!verifyLint.ok) findings.push(`[${wave}] lap ${lap} verify-proof-lint findings:\n${verifyLint.output}`); const verdict = parseVerdict(verify.output); if (maxLaps === 1) { // Gear 1 — a single pass, hand back the VerifyProof regardless of ship/hold. return { wave, laps: lap, verdict, verifyProof: lastVerify, buildResult: lastBuild, findings }; } if (verdict === "ship") { return { wave, laps: lap, verdict, verifyProof: lastVerify, buildResult: lastBuild, findings }; } if (lap >= maxLaps) { findings.push(`[${wave}] hit the ${maxLaps}-lap cap without a ship verdict — escalating, not re-running blind.`); return { wave, laps: lap, verdict: "escalated", verifyProof: lastVerify, buildResult: lastBuild, findings }; } // hold, cap not reached — loop back to re-planning (never re-run the same build blind). } return { wave, laps: maxLaps, verdict: "escalated", verifyProof: lastVerify, buildResult: lastBuild, findings }; } async function handleUiArchitect(rawArgs: string, ctx: ExtensionCommandContext): Promise { const brief = rawArgs.trim(); const report = (line: string) => { if (ctx.hasUI) ctx.ui.notify(line, "info"); else console.log(line); }; if (!brief) { report("ui-architect: no brief given. Usage: /ui-architect "); return; } if (!fs.existsSync(SKILL_MD_PATH)) { report(`ui-architect: cannot find ${SKILL_MD_PATH} — this dispatch missing a resolvable target, reported rather than improvised.`); return; } const skillBody = readSkillBody(); const cwd = ctx.cwd; const signal = ctx.signal; report("ui-architect: dispatching an isolated PLANNING pass to size the brief (Gear selection)..."); const initialPlan = await runPhase(cwd, planningPrompt(skillBody), brief, TOOLS.planning, signal); const planLint = runLinter(LINTERS.orientation, initialPlan.output); const gear = parseGear(initialPlan.output); const waves = gear === 2 ? parseWaves(initialPlan.output) : ["the deliverable"]; const maxLaps = gear === 2 ? MAX_LAPS_PER_WAVE : 1; report(`ui-architect: GEAR ${gear} — ${waves.length} wave(s): ${waves.join(", ")}`); if (!planLint.ok) report(`ui-architect: initial OrientationRecord failed record-lint:\n${planLint.output}`); const outcomes: WaveOutcome[] = []; for (const wave of waves) { const outcome = await runWave(cwd, skillBody, initialPlan.output, wave, maxLaps, signal, report); outcomes.push(outcome); } const shipped = outcomes.filter((o) => o.verdict === "ship").length; const escalated = outcomes.filter((o) => o.verdict === "escalated"); const summaryLines = [ `ui-architect — GEAR ${gear} — ${shipped}/${outcomes.length} wave(s) shipped` + (escalated.length ? `, ${escalated.length} escalated` : ""), "", ]; for (const o of outcomes) { summaryLines.push(`## ${o.wave} — ${o.verdict} (${o.laps} lap${o.laps === 1 ? "" : "s"})`); summaryLines.push(o.verifyProof || "(no VerifyProof produced)"); if (o.findings.length) { summaryLines.push("Findings:"); for (const f of o.findings) summaryLines.push(` - ${f}`); } summaryLines.push(""); } const finalReport = summaryLines.join("\n"); report(finalReport); try { ctx.hasUI && (globalThis as any).pi?.appendEntry?.("ui-architect-report", { brief, gear, outcomes }); } catch { /* appendEntry is a nice-to-have durability aid, never load-bearing for the report */ } } export default function (pi: ExtensionAPI) { pi.registerCommand("ui-architect", { description: "Coordinate a whole deliverable end to end: isolated planning -> composition -> QA passes, gated by the portable OrientationRecord/BuildResult/VerifyProof contracts.", handler: handleUiArchitect, }); } // Exported for the plugin-level selftest fixture (scripts/build/*-manifests selftest siblings // do not cover this file — its own smoke test lives at // packages/plugins/adia-ui-factory/scripts/ui-architect-pi-selftest.mjs). export const __internal = { readSkillBody, getPiInvocation, getFinalOutput, parseGear, parseWaves, parseVerdict, runLinter, PLUGIN_ROOT, SKILL_MD_PATH, LINTERS, TOOLS, MAX_LAPS_PER_WAVE, };