From 6a2f86b0a81db4a538cbf471c2ec0c7e764f6363 Mon Sep 17 00:00:00 2001 From: Rafael Macalaba Date: Fri, 28 Aug 2026 12:16:54 -0400 Subject: [PATCH 1/2] feat(pi): first-class pi package with harness scaffolding and fleet subagent dispatch - pi extension: armada_dispatch tool (single + parallel fleet subagent dispatch via isolated pi subprocesses), resources_discover contributing bundled skills + prompt templates in armada-managed repos - manifest: project.harnesses field (opencode default, pi opt-in), validated, rendered into armada.yaml - scaffold: .pi/agents/.md per role when pi harness enabled; openrouter models pinned, opencode-* inherit dispatcher; edit boundaries appended to the prompt body (pi has no SDK permission globs) - uninstall: removes .pi/agents armada-owned files - tests: tests/pi-harness.test.js (8); suite 605/605 green - docs: README pi integration section, TODO wave-2 log --- README.md | 15 ++ TODO.md | 1 + package-lock.json | 14 +- package.json | 11 +- pi/extensions/armada.ts | 449 ++++++++++++++++++++++++++++++++++++ pi/prompts/armada-status.md | 4 + pi/prompts/contract.md | 4 + pi/prompts/fleet.md | 4 + pi/prompts/voyage.md | 10 + src/generator.js | 46 ++++ src/manifest.js | 13 ++ src/scaffold.js | 12 +- tests/pi-harness.test.js | 124 ++++++++++ 13 files changed, 702 insertions(+), 5 deletions(-) create mode 100644 pi/extensions/armada.ts create mode 100644 pi/prompts/armada-status.md create mode 100644 pi/prompts/contract.md create mode 100644 pi/prompts/fleet.md create mode 100644 pi/prompts/voyage.md create mode 100644 tests/pi-harness.test.js diff --git a/README.md b/README.md index 949f477..fd4dff3 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,21 @@ npx @rafamacalaba/armada@latest new my-app Requires Node.js 22+ and an authenticated [opencode](https://opencode.ai) install. Run `armada doctor` to confirm your environment is ready. +### Pi integration + +armada ships as a first-class [pi package](https://pi.dev/packages). Inside [pi](https://pi.dev) you get: + +- **14 skills** (`armada-contract`, `armada-dispatch`, `armada-gate`, `armada-tdd`, ...), loaded on-demand by the agent or invoked via `/skill:armada-contract` +- **Prompt templates**: `/voyage `, `/contract`, `/fleet`, `/armada-status` +- **Tools & commands**: `armada_fleet` / `armada_status` tools, `/armada-fleet`, `/armada-status`, `/armada-doctor` commands, and a force-push guard +- **Fleet subagents**: the `armada_dispatch` tool runs the armada team (Galleon, Clipper, Corvette, ...) as isolated pi subagents — single or parallel — with armada's file-ownership boundaries enforced in each agent prompt + +```bash +pi install npm:@rafamacalaba/armada +``` + +To run the full fleet inside pi, add `harnesses: ["opencode", "pi"]` (or just `["pi"]`) under `project:` in `armada.yaml` and re-run `armada init`. This scaffolds each role into `.pi/agents/.md` (frontmatter name/description/model, prompt body, edit boundaries). Model IDs: `openrouter/*` roles pin their model; `opencode-*` roles inherit the dispatching session's model. + ### OpenRouter Provider Discounts Save up to 20x on OpenRouter models by routing to discounted providers (Novita, StreamLake, Xiaomi): diff --git a/TODO.md b/TODO.md index af92d1a..a08400d 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,7 @@ All in-flight lanes merged. `feat/fleet-dashboard` (#59), `feat/artifacts-under- ### Wave 2 — first consumers (parallel) +- [x] **Pi harness + fleet subagents** (2026-08-09). armada as a first-class pi extension, phase 2: `project.harnesses` manifest field (`opencode` default, `pi` opt-in); scaffold emits `.pi/agents/.md` per role (openrouter models pinned, opencode-* inherit dispatcher; edit boundaries appended to the prompt body since pi has no SDK permission globs); `armada_dispatch` pi tool spawns fleet subagents (single + parallel, isolated context, JSON-mode subprocess) for the subagent workflow — orchestrator dispatches specialists and runs independent phases concurrently; extension contributes bundled skills + prompt templates via `resources_discover` in armada-managed repos. Tests: tests/pi-harness.test.js (8). - [ ] **Skills integration** (spec below). Requires: per-role configurability (shipped #52). Files: `src/skills/`, generator, manifest, prompts. - [x] **Security findings ledger** (#58) (2026-08-03). Per-feature SECURITY_FINDINGS.md schema, generator rendering, security prompt integration. diff --git a/package-lock.json b/package-lock.json index ac432f6..c442b29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rafamacalaba/armada", - "version": "1.2.6", + "version": "1.2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rafamacalaba/armada", - "version": "1.2.6", + "version": "1.2.9", "license": "MIT", "dependencies": { "yaml": "^2.9.0" @@ -18,7 +18,8 @@ "node": ">=22" }, "peerDependencies": { - "opencode": "^1.18.0" + "opencode": "^1.18.0", + "typebox": "*" }, "peerDependenciesMeta": { "opencode": { @@ -26,6 +27,13 @@ } } }, + "node_modules/typebox": { + "version": "1.3.20", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.20.tgz", + "integrity": "sha512-qAQnoUNakcqjoTqwDzvqcg5e/+i3FHnltXFmKAbc0tpTPKT86bVOUPuevp/vYQguWUMC7YsVTmk1FT5UpjB/wg==", + "license": "MIT", + "peer": true + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index db6a0de..aedebbd 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "template", "agents", "presets", + "pi/**", "docs/RELEASING.md", "docs/WHY.md", "docs/auth-and-cost.md", @@ -51,6 +52,7 @@ "node": ">=22" }, "keywords": [ + "pi-package", "opencode", "agents", "orchestrator", @@ -68,8 +70,15 @@ "harness-engineering" ], "license": "MIT", + "pi": { + "skills": ["./src/skills"], + "prompts": ["./pi/prompts"], + "extensions": ["./pi/extensions"], + "image": "https://raw.githubusercontent.com/rafmacalaba/armada/main/docs/logo-v2.png" + }, "peerDependencies": { - "opencode": "^1.18.0" + "opencode": "^1.18.0", + "typebox": "*" }, "dependencies": { "yaml": "^2.9.0" diff --git a/pi/extensions/armada.ts b/pi/extensions/armada.ts new file mode 100644 index 0000000..d78dcd7 --- /dev/null +++ b/pi/extensions/armada.ts @@ -0,0 +1,449 @@ +/** + * armada — pi extension + * + * Surfaces the armada CLI inside pi: + * - `armada_fleet` / `armada_status` LLM-callable tools (read-only CLI calls) + * - `/armada-fleet`, `/armada-status`, `/armada-doctor` commands with a status widget + * - A guard that confirms force-pushes while a voyage is active + * + * CLI resolution: prefers the `src/cli.js` shipped with this package (works for + * npm/git pi-package installs without a global bin), falls back to `armada` on PATH. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { execFile as execFileCb, spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdtemp, writeFile, unlink, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { agentNameFor, ROLES } from "../../src/role-display.js"; + +const execFile = promisify(execFileCb); +const TIMEOUT_MS = 30_000; +const MAX_BUFFER = 10 * 1024 * 1024; + +/** Package root (dir containing package.json), or null if it cannot be derived. */ +function packageRoot(): string | null { + try { + // This file lives at /pi/extensions/armada.ts + return fileURLToPath(new URL("../../", import.meta.url)); + } catch { + return null; + } +} + +function resolveCli(): { cmd: string; baseArgs: string[] } { + const root = packageRoot(); + if (root) { + const cli = join(root, "src", "cli.js"); + if (existsSync(cli)) return { cmd: process.execPath, baseArgs: [cli] }; + } + return { cmd: "armada", baseArgs: [] }; +} + +async function runArmada(args: string[], cwd?: string): Promise { + const { cmd, baseArgs } = resolveCli(); + try { + const { stdout } = await execFile(cmd, [...baseArgs, ...args], { + cwd, + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + }); + return stdout.trim() || "(no output)"; + } catch (err: any) { + if (err?.code === "ENOENT") { + throw new Error( + "armada CLI not found. Install it with `npm install -g @rafamacalaba/armada`, then run `armada doctor`.", + ); + } + throw new Error(err?.stderr?.trim() || String(err?.message || err)); + } +} + +function widgetFrom(output: string, title: string): string[] { + const lines = output.split("\n").filter((l) => l.trim().length > 0); + return [title, ...lines.slice(0, 8)]; +} + +// ---- Subagent dispatch ----------------------------------------------------- +// +// Armada's workflow is orchestration: the Commodore dispatches ship-role +// subagents (Galleon, Clipper, Corvette, ...) with task specs and runs +// independent phases in parallel. This tool spawns pi subprocesses for the +// fleet defined in `.pi/agents/.md` (scaffolded by `armada init +// --harness pi`), giving each dispatch an isolated context window. + +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const PER_TASK_OUTPUT_CAP = 50 * 1024; + +interface FleetAgent { + name: string; + model?: string; + body: string; +} + +/** Parse a pi agent markdown file: frontmatter (name/model) + body. */ +function parseAgentFile(text: string, fallbackName: string): FleetAgent { + const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) return { name: fallbackName, body: text }; + const frontmatter = match[1]; + const modelMatch = frontmatter.match(/^model:\s*["']?([^\n"']+)["']?\s*$/m); + const nameMatch = frontmatter.match(/^name:\s*["']?([^\n"']+)["']?\s*$/m); + return { + name: nameMatch?.[1]?.trim() || fallbackName, + model: modelMatch?.[1]?.trim(), + body: match[2].trim(), + }; +} + +/** + * Resolve a fleet agent by armada role ("backend-dev") or ship name + * ("galleon"). Looks in the project's .pi/agents first (armada-managed, + * role-overridable), then the user-level agent dir. + */ +function resolveFleetAgent(cwd: string, roleOrShip: string): FleetAgent | null { + const ship = ROLES.includes(roleOrShip) ? agentNameFor(roleOrShip) : roleOrShip.toLowerCase(); + const candidates = [ + join(cwd, ".pi", "agents", `${ship}.md`), + join(cwd, ".pi", "agents", `${roleOrShip}.md`), + join(cwd, ".pi", "agents", `${ship.toLowerCase()}.md`), + ]; + for (const path of candidates) { + if (existsSync(path)) { + return parseAgentFile(readFileSync(path, "utf8"), ship); + } + } + return null; +} + +/** Re-invokes pi the same way this process was started (node or bundled bin). */ +function piInvocation(args: string[]): { command: string; args: string[] } { + const script = process.argv[1]; + if (script && !script.startsWith("/$bunfs/root/") && existsSync(script)) { + return { command: process.execPath, args: [script, ...args] }; + } + return { command: "pi", args }; +} + +interface DispatchResult { + role: string; + ok: boolean; + output: string; + error?: string; + usage: { turns: number; input: number; output: number; cost: number }; +} + +/** Run one pi subprocess for a fleet agent, JSON mode, return final assistant text. */ +async function runFleetAgent( + agent: FleetAgent, + role: string, + task: string, + cwd: string, + signal: AbortSignal | undefined, +): Promise { + const usage = { turns: 0, input: 0, output: 0, cost: 0 }; + const args: string[] = ["--mode", "json", "-p", "--no-session"]; + if (agent.model) args.push("--model", agent.model); + + let promptFile: string | null = null; + let promptDir: string | null = null; + if (agent.body) { + promptDir = await mkdtemp(join(tmpdir(), "armada-dispatch-")); + promptFile = join(promptDir, `prompt-${agent.name}.md`); + await writeFile(promptFile, agent.body, "utf8"); + args.push("--append-system-prompt", promptFile); + } + args.push(`Task: ${task}`); + + try { + let output = ""; + let stderr = ""; + let stopReason: string | undefined; + let errorMessage: string | undefined; + + const exitCode = await new Promise((resolve) => { + const inv = piInvocation(args); + const proc = spawn(inv.command, inv.args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); + let buffer = ""; + const onLine = (line: string) => { + if (!line.trim()) return; + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + if (event.type === "message_end" && event.message?.role === "assistant") { + usage.turns++; + const msg = event.message; + usage.input += msg.usage?.input || 0; + usage.output += msg.usage?.output || 0; + usage.cost += msg.usage?.cost?.total || 0; + if (msg.stopReason) stopReason = msg.stopReason; + if (msg.errorMessage) errorMessage = msg.errorMessage; + for (const part of msg.content ?? []) { + if (part.type === "text" && part.text) output = part.text; + } + } + }; + proc.stdout.on("data", (data) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) onLine(line); + }); + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + proc.on("close", (code) => { + if (buffer.trim()) onLine(buffer); + resolve(code ?? 0); + }); + proc.on("error", () => resolve(1)); + if (signal) { + const kill = () => { + proc.kill("SIGTERM"); + setTimeout(() => proc.kill("SIGKILL"), 5000); + }; + if (signal.aborted) kill(); + else signal.addEventListener("abort", kill, { once: true }); + } + }); + + const failed = exitCode !== 0 || stopReason === "error" || stopReason === "aborted"; + return { + role, + ok: !failed, + output: output || "(no output)", + error: failed ? errorMessage || stderr || `exit code ${exitCode}` : undefined, + usage, + }; + } finally { + if (promptFile) await unlink(promptFile).catch(() => {}); + if (promptDir) await rm(promptDir, { recursive: true, force: true }).catch(() => {}); + } +} + +function formatUsage(u: DispatchResult["usage"]): string { + const parts = [`${u.turns} turns`, `in ${u.input}`, `out ${u.output}`]; + if (u.cost) parts.push(`$${u.cost.toFixed(4)}`); + return parts.join(", "); +} + +function truncate(text: string): string { + if (Buffer.byteLength(text, "utf8") <= PER_TASK_OUTPUT_CAP) return text; + return `${text.slice(0, PER_TASK_OUTPUT_CAP)}\n\n[output truncated]`; +} + +export default function (pi: ExtensionAPI) { + // ---- LLM-callable tools -------------------------------------------------- + + pi.registerTool({ + name: "armada_fleet", + label: "Armada Fleet", + description: + "Show active armada voyages: feature lanes, current phase, gate status, staleness, and cost. Read-only.", + promptSnippet: "armada_fleet — show active armada voyages (lanes, phases, gate status)", + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const out = await runArmada(["fleet"], ctx.cwd); + return { content: [{ type: "text", text: out }], details: {} }; + }, + }); + + pi.registerTool({ + name: "armada_status", + label: "Armada Status", + description: + "Report armada repository state as JSON: active feature, voyage runs, ledgers. Read-only. Run from a repo with an armada manifest.", + promptSnippet: "armada_status — report armada state (manifest, voyages, ledgers) as JSON", + parameters: Type.Object({}), + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const out = await runArmada(["status", "--json"], ctx.cwd); + return { content: [{ type: "text", text: out }], details: {} }; + }, + }); + + // ---- Commands ------------------------------------------------------------ + + pi.registerCommand("armada-fleet", { + description: "Show active armada voyages", + handler: async (_args, ctx) => { + const out = await runArmada(["fleet"], ctx.cwd); + ctx.ui.setWidget("armada", widgetFrom(out, "armada fleet")); + ctx.ui.notify("Fleet status updated", "info"); + }, + }); + + pi.registerCommand("armada-status", { + description: "Show armada repository status", + handler: async (_args, ctx) => { + const out = await runArmada(["status"], ctx.cwd); + ctx.ui.setWidget("armada", widgetFrom(out, "armada status")); + ctx.ui.notify("Status updated", "info"); + }, + }); + + pi.registerCommand("armada-doctor", { + description: "Check the armada environment (CLI, opencode, models)", + handler: async (_args, ctx) => { + const out = await runArmada(["doctor"], ctx.cwd); + ctx.ui.notify(out, "info"); + }, + }); + + // ---- Subagent dispatch tool ---------------------------------------------- + + pi.registerTool({ + name: "armada_dispatch", + label: "Armada Dispatch", + description: + "Dispatch armada fleet subagents with isolated context. Roles: orchestrator, backend-dev, " + + "frontend-dev, qa, adversary, security, docs, architect (ship names also accepted: galleon, " + + "clipper, corvette, xebec, frigate, caravel, bark). Modes: single {role, task} or parallel " + + "{tasks: [{role, task}]}. Requires .pi/agents/*.md (run `armada init` with the pi harness) or " + + "user-level agents in ~/.pi/agent/agents. Read the armada-task-spec skill before dispatching.", + promptSnippet: "armada_dispatch — dispatch fleet subagents (single or parallel) with isolated context", + parameters: Type.Object({ + role: Type.Optional(Type.String({ description: "Fleet role for single mode" })), + task: Type.Optional(Type.String({ description: "Task spec for the agent (single mode)" })), + tasks: Type.Optional( + Type.Array( + Type.Object({ + role: Type.String({ description: "Fleet role to dispatch" }), + task: Type.String({ description: "Task spec for this agent" }), + }), + { description: "Parallel dispatch: run all tasks concurrently (max 8)" }, + ), + ), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const resolveOrError = (role: string) => { + if (!resolveFleetAgent(ctx.cwd, role)) { + return { + role, + ok: false, + output: "", + error: + `No fleet agent for "${role}". Expected .pi/agents/${agentNameFor( + ROLES.includes(role) ? role : "backend-dev", + )}.md — scaffold with 'armada init' (pi harness) or define ~/.pi/agent/agents/${role}.md.`, + usage: { turns: 0, input: 0, output: 0, cost: 0 }, + } as DispatchResult; + } + return null; + }; + + if (params.tasks?.length) { + if (params.tasks.length > MAX_PARALLEL_TASKS) { + return { + content: [{ type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` }], + details: {}, + }; + } + const resolved = params.tasks.map((t) => resolveFleetAgent(ctx.cwd, t.role)); + const missing = params.tasks.filter((_, i) => !resolved[i]); + if (missing.length) { + return { + content: [{ type: "text", text: missing.map((t) => resolveOrError(t.role)!.error).join("\n") }], + details: {}, + isError: true, + }; + } + const results: DispatchResult[] = new Array(params.tasks.length); + let next = 0; + const workers = Array.from({ length: Math.min(MAX_CONCURRENCY, params.tasks.length) }, async () => { + while (next < params.tasks.length) { + const i = next++; + const t = params.tasks![i]; + results[i] = await runFleetAgent(resolved[i]!, t.role, t.task, ctx.cwd, signal); + } + }); + await Promise.all(workers); + const failed = results.filter((r) => !r.ok); + const summary = results + .map((r) => `### ${r.role}: ${r.ok ? "completed" : "failed"}\n\n${truncate(r.ok ? r.output : r.error || r.output)}`) + .join("\n\n---\n\n"); + const total = results.reduce( + (acc, r) => ({ + turns: acc.turns + r.usage.turns, + input: acc.input + r.usage.input, + output: acc.output + r.usage.output, + cost: acc.cost + r.usage.cost, + }), + { turns: 0, input: 0, output: 0, cost: 0 }, + ); + return { + content: [ + { + type: "text", + text: + `Parallel dispatch: ${results.length - failed.length}/${results.length} succeeded ` + + `(${formatUsage(total)}).\n\n${summary}`, + }, + ], + details: { results }, + }; + } + + if (params.role && params.task) { + const agent = resolveFleetAgent(ctx.cwd, params.role); + if (!agent) { + return { content: [{ type: "text", text: resolveOrError(params.role)!.error! }], details: {}, isError: true }; + } + const result = await runFleetAgent(agent, params.role, params.task, ctx.cwd, signal); + if (!result.ok) { + return { + content: [{ type: "text", text: `${params.role} failed: ${result.error || result.output}` }], + details: { results: [result] }, + isError: true, + }; + } + return { + content: [{ type: "text", text: `${result.output}\n\n---\n${params.role}: ${formatUsage(result.usage)}` }], + details: { results: [result] }, + }; + } + + return { + content: [{ type: "text", text: "Provide either {role, task} (single) or {tasks: [...]} (parallel)." }], + details: {}, + }; + }, + }); + + // ---- Resource discovery -------------------------------------------------- + // + // In armada-managed repos, contribute the bundled skills and pi prompt + // templates so the armada workflow is first-class in pi sessions. + + pi.on("resources_discover", async (event, _ctx) => { + const root = packageRoot(); + if (!root) return; + const managed = + existsSync(join(event.cwd, "armada.yaml")) || existsSync(join(event.cwd, "armada", "armada.yaml")); + if (!managed) return; + const skillPaths = join(root, "src", "skills"); + const promptPaths = join(root, "pi", "prompts"); + return { + skillPaths: existsSync(skillPaths) ? [skillPaths] : [], + promptPaths: existsSync(promptPaths) ? [promptPaths] : [], + }; + }); + + // ---- Guard --------------------------------------------------------------- + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return; + const cmd = String(event.input?.command ?? ""); + if (!/\bgit\s+push\s+(--force(-with-lease)?|-f)\b/.test(cmd)) return; + // Headless modes cannot confirm; leave CI flows alone. + if (!ctx.hasUI) return; + const ok = await ctx.ui.confirm("armada guard", `Allow force-push?\n\n${cmd}`); + if (!ok) return { block: true, reason: "armada guard: force-push rejected" }; + }); +} diff --git a/pi/prompts/armada-status.md b/pi/prompts/armada-status.md new file mode 100644 index 0000000..4e7dbb0 --- /dev/null +++ b/pi/prompts/armada-status.md @@ -0,0 +1,4 @@ +--- +description: Report armada repository state — manifest, voyages, ledgers, and what evidence the next gate requires +--- +Check the on-disk armada state. Run the `armada_status` tool (or `armada status --json` via bash) and inspect `armada/state/` if present. Report where each phase stands, which ledgers are pending, and what evidence the next gate requires. diff --git a/pi/prompts/contract.md b/pi/prompts/contract.md new file mode 100644 index 0000000..2f75825 --- /dev/null +++ b/pi/prompts/contract.md @@ -0,0 +1,4 @@ +--- +description: Co-write an armada contract (armada/REQUIREMENTS.md) one question at a time +--- +Load the `armada-contract` skill and follow it exactly: draft the contract with me one question at a time, write `armada/REQUIREMENTS.md` with `Status: DRAFT`, and wait for my explicit approval before flipping it to `Status: APPROVED`. No implementation starts before approval. diff --git a/pi/prompts/fleet.md b/pi/prompts/fleet.md new file mode 100644 index 0000000..259135e --- /dev/null +++ b/pi/prompts/fleet.md @@ -0,0 +1,4 @@ +--- +description: Show active armada voyages (lanes, phases, gates) and suggest the next action per lane +--- +Run the `armada_fleet` tool (or `armada fleet` via bash if unavailable). For each lane, report: phase, gate status, staleness, and cost. Then suggest the next action per lane — continue the current phase, loop defects back to the developer, or finish per the `armada-voyage-finish` skill. diff --git a/pi/prompts/voyage.md b/pi/prompts/voyage.md new file mode 100644 index 0000000..1a68e5c --- /dev/null +++ b/pi/prompts/voyage.md @@ -0,0 +1,10 @@ +--- +description: Co-write, approve, and launch an armada voyage for a feature (isolated worktree, evidence-gated phases) +argument-hint: "" +--- +You are running the armada loop. Load the `armada-contract` and `armada-voyage-finish` skills first, then: + +1. Draft the contract for the feature "$1" with me, one question at a time (goal, stack constraints, phases, per-phase success criteria, final criteria). +2. Write it to `armada/REQUIREMENTS.md` with `Status: DRAFT`. Do not start implementation until I approve it (then set `Status: APPROVED`). +3. Once approved, run: `armada voyage "$1"` +4. After the voyage boots, monitor it with the `armada_fleet` tool and gate phase transitions per the `armada-gate` skill — evidence only (passing test runs, screenshots, file:line citations). Never advance on a claim. diff --git a/src/generator.js b/src/generator.js index 899d431..4cc5d8f 100644 --- a/src/generator.js +++ b/src/generator.js @@ -222,6 +222,50 @@ export function renderAgentFile(agent, promptText) { return `---\n${yaml}\n---\n\n${promptText}` } +// Translate a role's opencode edit-permission matrix into a prompt section. +// Pi has no SDK-level permission globs, so armada's file-ownership boundaries +// live in the agent body instead. renderPiAgentFile appends this to the prompt. +function renderEditBoundaries(permissions) { + const edit = permissions?.edit + if (!edit || typeof edit !== "object") return "" + const deny = [] + const allow = [] + for (const [glob, value] of Object.entries(edit)) { + if (value === "deny") deny.push(glob) + else if (value === "allow") allow.push(glob) + } + if (!deny.length && !allow.length) return "" + const lines = [ + "# Edit boundaries", + "", + "These file-ownership boundaries are absolute. Do not work around them with shell commands:", + "", + ] + if (deny.length) lines.push(`- Never create or modify files matching: ${deny.join(", ")}.`) + if (allow.length) lines.push(`- You may write only: ${allow.join(", ")}.`) + return lines.join("\n") +} + +// Render one pi agent file: `.pi/agents/.md`. +// Pi agents are markdown with YAML frontmatter (name, description, model). +// Model: only openrouter/ IDs exist in pi's model registry; opencode-* IDs +// are opencode-only, so they are omitted and the agent inherits the model of +// the dispatching session. +export function renderPiAgentFile(agent, promptText) { + const openrouterModel = typeof agent.model === "string" && agent.model.startsWith("openrouter/") + ? agent.model + : null + const frontmatter = { + name: agentNameFor(agent.role), + description: `${displayFor(agent.role)} — ${CATALOG[agent.role].label}`, + ...(openrouterModel ? { model: openrouterModel } : {}), + } + const yaml = YAML.stringify(frontmatter).trim() + const boundaries = renderEditBoundaries(agent.permissions) + const body = [promptText.trim(), boundaries].filter(Boolean).join("\n\n") + return `---\n${yaml}\n---\n\n${body}\n` +} + // Build the per-repo `opencode.json` (project-level overrides). Merges over the // global config; only sets what armada manages. plugin[] is NOT touched here. // Agents now ship as native `.opencode/agent/.md` files (renderAgentFile); @@ -1131,6 +1175,8 @@ project: headless: ${manifest.project.headless ?? false} # Autonomous: no permission prompts (strict superset of headless) yolo: ${manifest.project.yolo ?? false} + # Agent harnesses to scaffold for: opencode (default) and/or pi + harnesses: ${q(manifest.project.harnesses ?? ["opencode"])} # Path to the contract file (default: armada/REQUIREMENTS.md) requirementsFile: ${q(manifest.project.requirementsFile ?? "armada/REQUIREMENTS.md")} ${manifest.project.feature ? ` # (optional) Active feature name; sets armada/state/features/.json\n feature: ${q(manifest.project.feature)}\n` : ""}${manifest.project.skills !== undefined ? ` # (optional) Skills to load into the orchestrator prompt\n skills: [${(manifest.project.skills || []).map((s) => q(s)).join(", ")}]\n` : ""}${manifest.project.openrouterProviders !== undefined ? ` # (optional) Preferred OpenRouter provider routing order (e.g. Novita, DeepInfra)\n openrouter_providers: [${(manifest.project.openrouterProviders || []).map((p) => q(p)).join(", ")}]\n` : ""} supervision: diff --git a/src/manifest.js b/src/manifest.js index d2fb603..5fd08de 100644 --- a/src/manifest.js +++ b/src/manifest.js @@ -115,6 +115,16 @@ function validateOpenRouterProviders(providers) { return list.map((p) => p.trim()) } +const HARNESS_VALUES = new Set(["opencode", "pi"]) + +function validateHarnesses(value) { + if (value === undefined || value === null) return ["opencode"] + if (!Array.isArray(value) || value.length === 0 || value.some((h) => !HARNESS_VALUES.has(h))) { + throw new Error('armada.yaml: schema violation: project.harnesses must be a non-empty array of "opencode" and/or "pi"') + } + return [...new Set(value)] +} + export function parseManifestYaml(text, target) { let raw try { @@ -184,6 +194,7 @@ export function parseManifestYaml(text, target) { validateRequirementsFile(p.requirementsFile ?? "armada/REQUIREMENTS.md") const skills = validateSkills(p.skills) const openrouterProviders = validateOpenRouterProviders(p.openrouter_providers) + const harnesses = validateHarnesses(p.harnesses) return { project: { name: p.name ?? "project", @@ -197,6 +208,7 @@ export function parseManifestYaml(text, target) { feature: p.feature ?? null, skills, openrouterProviders, + harnesses, supervision: { plugin: p.supervision?.plugin ?? false, fleet: p.supervision?.fleet ?? true, @@ -231,6 +243,7 @@ export const MANIFEST_SCHEMA = { headless: "boolean", // non-interactive mode: orchestrator bash allow (CI-safe) yolo: "boolean", // autonomous mode: no permission prompts (config allow, boundaries kept) supervision: { plugin: "boolean", fleet: "boolean", watchdog: "boolean", shipnames: "boolean" }, // opt-in supervision plugins + harnesses: "array", // target agent harnesses: opencode (default), pi requirementsFile: "string", // per-feature contract (default armada/REQUIREMENTS.md) }, team: "array<{name, role, model, fallback, variant?, enabled}>", diff --git a/src/scaffold.js b/src/scaffold.js index 6aadbb7..ad9b196 100644 --- a/src/scaffold.js +++ b/src/scaffold.js @@ -7,7 +7,7 @@ import { join, resolve } from "node:path" import { dirname } from "node:path" import { fileURLToPath } from "node:url" -import { buildTeam, renderAgentFile, renderSkillFile } from "./generator.js" +import { buildTeam, renderAgentFile, renderPiAgentFile, renderSkillFile } from "./generator.js" import { renderOpenCodeJson, renderAgentsMd, @@ -298,6 +298,11 @@ export function scaffold(manifest, stack, opts = {}) { const content = renderAgentFile(a, promptText) const shipName = agentNameFor(a.role) write(`.opencode/agent/${shipName}.md`, content) + // Pi harness: same roles as native .pi/agents/.md files. Pi has no + // SDK permission globs, so edit boundaries ride along in the prompt body. + if ((manifest.project?.harnesses ?? ["opencode"]).includes("pi")) { + write(`.pi/agents/${shipName}.md`, renderPiAgentFile(a, promptText)) + } // Orphan cleanup: remove legacy role-named agent file if it differs from ship name if (a.role !== shipName && LEGACY_ROLE_NAMES.has(a.role)) { const legacyRel = `.opencode/agent/${a.role}.md` @@ -520,6 +525,11 @@ export function uninstall(manifest, opts = {}) { } } removeEmptyDir(".opencode/agent") + // Remove armada's pi agent files (pi harness). + for (const role of ROLES) { + removeFile(`.pi/agents/${agentNameFor(role)}.md`) + } + removeEmptyDir(".pi/agents") // Remove armada skill files. for (const skill of skillRegistry) { removeFile(`.opencode/skills/${skill.name}/SKILL.md`) diff --git a/tests/pi-harness.test.js b/tests/pi-harness.test.js new file mode 100644 index 0000000..66bdedc --- /dev/null +++ b/tests/pi-harness.test.js @@ -0,0 +1,124 @@ +import { test } from "node:test" +import assert from "node:assert" + +import { buildTeam, renderPiAgentFile, renderManifestYaml } from "../src/generator.js" +import { parseManifestYaml } from "../src/manifest.js" +import { scaffold, uninstall } from "../src/scaffold.js" +import { ROLES, modelFor } from "../src/model-catalog.js" +import { agentNameFor } from "../src/role-display.js" +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +function makeManifest(dir, overrides = {}) { + return { + targetDir: dir, + project: { + name: "pi-harness-test", + budget: "balanced", + browserTesting: false, + devcontainer: false, + useAgentBrowser: false, + stack: { srcDirs: ["src"], languages: ["typescript"] }, + ...overrides, + }, + team: ROLES.map((role) => ({ role, model: modelFor(role, "balanced"), variant: null, enabled: true })), + playbook: {}, + } +} + +test("parseManifestYaml defaults harnesses to opencode", () => { + const manifest = parseManifestYaml("project:\n name: t\nteam:\n - role: qa\n model: openrouter/xiaomi/mimo-v2.5\n enabled: true\n") + assert.deepStrictEqual(manifest.project.harnesses, ["opencode"]) +}) + +test("parseManifestYaml accepts and dedupes harnesses", () => { + const manifest = parseManifestYaml( + "project:\n name: t\n harnesses: [pi, opencode, pi]\nteam:\n - role: qa\n model: openrouter/xiaomi/mimo-v2.5\n enabled: true\n", + ) + assert.deepStrictEqual(manifest.project.harnesses, ["pi", "opencode"]) +}) + +test("parseManifestYaml rejects invalid harnesses", () => { + assert.throws( + () => parseManifestYaml("project:\n name: t\n harnesses: [codex]\nteam:\n - role: qa\n model: openrouter/xiaomi/mimo-v2.5\n enabled: true\n"), + /harnesses/, + ) + assert.throws( + () => parseManifestYaml("project:\n name: t\n harnesses: []\nteam:\n - role: qa\n model: openrouter/xiaomi/mimo-v2.5\n enabled: true\n"), + /harnesses/, + ) +}) + +test("renderPiAgentFile emits pi frontmatter with openrouter model only", () => { + const team = buildTeam(makeManifest(".")) + const backend = team.find((a) => a.role === "backend-dev") + const file = renderPiAgentFile(backend, "# Galleon body\n\nDo backend work.") + assert.match(file, /^---\n/) + assert.match(file, new RegExp(`name: ${agentNameFor("backend-dev")}`)) + assert.match(file, /description: Galleon — Backend implementation/) + // opencode-* model IDs do not exist in pi; openrouter fallback does not + // apply here because primary is opencode-go — model must be omitted. + assert.ok(!/^model:/m.test(file), "omits opencode-only model IDs") + assert.match(file, /# Galleon body/) +}) + +test("renderPiAgentFile keeps openrouter model and appends edit boundaries", () => { + const team = buildTeam(makeManifest(".")) + const qa = team.find((a) => a.role === "qa") + qa.model = "openrouter/xiaomi/mimo-v2.5" + const file = renderPiAgentFile(qa, "QA body.") + assert.match(file, /^model: openrouter\/xiaomi\/mimo-v2\.5$/m) + // qa edit allowlist becomes a prompt-level boundary (pi has no SDK globs) + assert.match(file, /# Edit boundaries/) + assert.match(file, /armada\/e2e\/\*/) + assert.match(file, /QA body\./) +}) + +test("renderManifestYaml serializes harnesses", () => { + const manifest = makeManifest(".", { harnesses: ["opencode", "pi"] }) + const yaml = renderManifestYaml(manifest, buildTeam(manifest)) + assert.match(yaml, /harnesses: \["opencode","pi"\]/) +}) + +test("scaffold writes .pi/agents when pi harness enabled, not otherwise", () => { + const dir = mkdtempSync(join(tmpdir(), "armada-pi-harness-")) + try { + const withPi = makeManifest(dir, { harnesses: ["opencode", "pi"] }) + scaffold(withPi, withPi.project.stack) + const qaFile = join(dir, ".pi", "agents", `${agentNameFor("qa")}.md`) + assert.ok(existsSync(qaFile), "pi agent file written") + const content = readFileSync(qaFile, "utf8") + assert.match(content, /^name: corvette$/m) + assert.match(content, /description: Corvette — Quality assurance/) + + const withoutPi = makeManifest(dir) + withoutPi.project.harnesses = undefined + const dir2 = mkdtempSync(join(tmpdir(), "armada-pi-harness-")) + try { + scaffold(withoutPi, withoutPi.project.stack) + assert.ok(!existsSync(join(dir2, ".pi", "agents")), "no .pi/agents without pi harness") + } finally { + rmSync(dir2, { recursive: true, force: true }) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test("uninstall removes pi agent files", () => { + const dir = mkdtempSync(join(tmpdir(), "armada-pi-harness-")) + try { + const manifest = makeManifest(dir, { harnesses: ["pi"] }) + scaffold(manifest, manifest.project.stack) + assert.ok(existsSync(join(dir, ".pi", "agents", `${agentNameFor("qa")}.md`))) + uninstall(manifest, {}) + assert.ok(!existsSync(join(dir, ".pi", "agents", `${agentNameFor("qa")}.md`))) + assert.ok(!existsSync(join(dir, ".pi", "agents"))) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) From 4b8285649a2ecced3e69e2439cab407ffab5ec7c Mon Sep 17 00:00:00 2001 From: Rafael Macalaba Date: Fri, 28 Aug 2026 12:33:29 -0400 Subject: [PATCH 2/2] feat(pi): default the whole fleet to OpenRouter in the pi harness - renderPiAgentFile maps every role to its catalog OpenRouter fallback (opencode-* IDs are unusable in pi); openrouterModelFor helper - scaffold writes .pi/settings.json with defaultProvider=defaultModel for the orchestrator session; merges into existing settings - uninstall removes the armada-owned model keys - verified live: subprocess JSON events report the pinned openrouter model; end-to-end dispatch receipt at $0.0016/turn - tests: 606/606 green --- README.md | 2 +- TODO.md | 2 +- src/generator.js | 28 +++++++++++++++++++------ src/scaffold.js | 45 +++++++++++++++++++++++++++++++++++++++- tests/pi-harness.test.js | 37 ++++++++++++++++++++++++++------- 5 files changed, 97 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fd4dff3..40e194c 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ armada ships as a first-class [pi package](https://pi.dev/packages). Inside [pi] pi install npm:@rafamacalaba/armada ``` -To run the full fleet inside pi, add `harnesses: ["opencode", "pi"]` (or just `["pi"]`) under `project:` in `armada.yaml` and re-run `armada init`. This scaffolds each role into `.pi/agents/.md` (frontmatter name/description/model, prompt body, edit boundaries). Model IDs: `openrouter/*` roles pin their model; `opencode-*` roles inherit the dispatching session's model. +To run the full fleet inside pi, add `harnesses: ["opencode", "pi"]` (or just `["pi"]`) under `project:` in `armada.yaml` and re-run `armada init`. This scaffolds each role into `.pi/agents/.md` (frontmatter name/description/model, prompt body, edit boundaries) and writes `defaultProvider: "openrouter"` + the orchestrator's OpenRouter model into `.pi/settings.json`, so the whole fleet — orchestrator session and subagents — runs on OpenRouter. Since pi cannot use `opencode-*` model IDs, every role maps to its catalog OpenRouter fallback (override per role in `armada.yaml` with any `openrouter/*` ID). Requires `OPENROUTER_API_KEY` or an existing pi OpenRouter auth. ### OpenRouter Provider Discounts diff --git a/TODO.md b/TODO.md index a08400d..2db401e 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,7 @@ All in-flight lanes merged. `feat/fleet-dashboard` (#59), `feat/artifacts-under- ### Wave 2 — first consumers (parallel) -- [x] **Pi harness + fleet subagents** (2026-08-09). armada as a first-class pi extension, phase 2: `project.harnesses` manifest field (`opencode` default, `pi` opt-in); scaffold emits `.pi/agents/.md` per role (openrouter models pinned, opencode-* inherit dispatcher; edit boundaries appended to the prompt body since pi has no SDK permission globs); `armada_dispatch` pi tool spawns fleet subagents (single + parallel, isolated context, JSON-mode subprocess) for the subagent workflow — orchestrator dispatches specialists and runs independent phases concurrently; extension contributes bundled skills + prompt templates via `resources_discover` in armada-managed repos. Tests: tests/pi-harness.test.js (8). +- [x] **Pi harness + fleet subagents** (2026-08-09). armada as a first-class pi extension, phase 2: `project.harnesses` manifest field (`opencode` default, `pi` opt-in); scaffold emits `.pi/agents/.md` per role with every role mapped to its catalog OpenRouter fallback (opencode-* IDs are unusable in pi) and `.pi/settings.json` defaulting the orchestrator session to OpenRouter; edit boundaries appended to the prompt body since pi has no SDK permission globs; `armada_dispatch` pi tool spawns fleet subagents (single + parallel, isolated context, JSON-mode subprocess) for the subagent workflow — orchestrator dispatches specialists and runs independent phases concurrently; extension contributes bundled skills + prompt templates via `resources_discover` in armada-managed repos. Verified live: subprocess events show the pinned openrouter model; single + parallel dispatch receipts. Tests: tests/pi-harness.test.js (9). - [ ] **Skills integration** (spec below). Requires: per-role configurability (shipped #52). Files: `src/skills/`, generator, manifest, prompts. - [x] **Security findings ledger** (#58) (2026-08-03). Per-feature SECURITY_FINDINGS.md schema, generator rendering, security prompt integration. diff --git a/src/generator.js b/src/generator.js index 4cc5d8f..e98c854 100644 --- a/src/generator.js +++ b/src/generator.js @@ -246,15 +246,22 @@ function renderEditBoundaries(permissions) { return lines.join("\n") } +// Pi only knows pi-registered providers, so opencode-* model IDs are unusable +// there. Map a role to its OpenRouter ID: the primary model when it is +// openrouter/..., else the catalog fallback (also an openrouter/... ID). +// Returns null when the role has no OpenRouter model at all (custom override). +export function openrouterModelFor(agent) { + for (const id of [agent.model, agent.fallback]) { + if (typeof id === "string" && id.startsWith("openrouter/")) return id + } + return null +} + // Render one pi agent file: `.pi/agents/.md`. // Pi agents are markdown with YAML frontmatter (name, description, model). -// Model: only openrouter/ IDs exist in pi's model registry; opencode-* IDs -// are opencode-only, so they are omitted and the agent inherits the model of -// the dispatching session. +// Model: mapped via openrouterModelFor so budget tiers hold on OpenRouter. export function renderPiAgentFile(agent, promptText) { - const openrouterModel = typeof agent.model === "string" && agent.model.startsWith("openrouter/") - ? agent.model - : null + const openrouterModel = openrouterModelFor(agent) const frontmatter = { name: agentNameFor(agent.role), description: `${displayFor(agent.role)} — ${CATALOG[agent.role].label}`, @@ -266,6 +273,15 @@ export function renderPiAgentFile(agent, promptText) { return `---\n${yaml}\n---\n\n${body}\n` } +// Build pi project settings for the pi harness: default the orchestrator's +// session (and any agent without a pinned model) to OpenRouter. Merges into an +// existing .pi/settings.json; null when the team has no OpenRouter model. +export function renderPiSettings(manifest, team) { + const orchestratorModel = openrouterModelFor(team.find((a) => a.role === "orchestrator")) + if (!orchestratorModel) return null + return { defaultProvider: "openrouter", defaultModel: orchestratorModel } +} + // Build the per-repo `opencode.json` (project-level overrides). Merges over the // global config; only sets what armada manages. plugin[] is NOT touched here. // Agents now ship as native `.opencode/agent/.md` files (renderAgentFile); diff --git a/src/scaffold.js b/src/scaffold.js index ad9b196..8ac8b1b 100644 --- a/src/scaffold.js +++ b/src/scaffold.js @@ -7,7 +7,7 @@ import { join, resolve } from "node:path" import { dirname } from "node:path" import { fileURLToPath } from "node:url" -import { buildTeam, renderAgentFile, renderPiAgentFile, renderSkillFile } from "./generator.js" +import { buildTeam, renderAgentFile, renderPiAgentFile, renderPiSettings, renderSkillFile } from "./generator.js" import { renderOpenCodeJson, renderAgentsMd, @@ -320,6 +320,30 @@ export function scaffold(manifest, stack, opts = {}) { } } + // 1a. Pi harness: project settings default the orchestrator session (and any + // unpinned agent) to OpenRouter. Merges into existing .pi/settings.json; + // only the two model keys are armada-owned. + if ((manifest.project?.harnesses ?? ["opencode"]).includes("pi")) { + const piSettings = renderPiSettings(manifest, team) + if (piSettings) { + const settingsRel = ".pi/settings.json" + const settingsPath = out(settingsRel) + let current = {} + if (!opts.dryRun && existsSync(settingsPath)) { + try { + current = JSON.parse(readFileSync(settingsPath, "utf8")) + } catch { + throw new Error(`cannot parse existing ${settingsRel} — fix or remove it, then re-run init`) + } + } + if (!opts.dryRun) { + ensure(".pi") + writeFileSync(settingsPath, JSON.stringify({ ...current, ...piSettings }, null, 2) + "\n", "utf8") + } + files.push(settingsRel) + } + } + // 1b. Prune stale omo-slim artifacts from the old layout (armada-owned). const staleJsonc = out(".opencode/oh-my-opencode-slim.jsonc") if (!opts.dryRun && existsSync(staleJsonc)) rmSync(staleJsonc, { force: true }) @@ -530,6 +554,25 @@ export function uninstall(manifest, opts = {}) { removeFile(`.pi/agents/${agentNameFor(role)}.md`) } removeEmptyDir(".pi/agents") + // Remove armada-owned model keys from pi project settings. + const piSettingsPath = join(target, ".pi/settings.json") + if (existsSync(piSettingsPath)) { + try { + const current = JSON.parse(readFileSync(piSettingsPath, "utf8")) + delete current.defaultProvider + delete current.defaultModel + if (!opts.dryRun) { + if (Object.keys(current).length > 0) { + writeFileSync(piSettingsPath, JSON.stringify(current, null, 2) + "\n", "utf8") + } else { + rmSync(piSettingsPath, { force: true }) + } + } + removed.push(".pi/settings.json (model keys)") + } catch { + warnings.push(`could not clean armada model keys from .pi/settings.json — invalid JSON`) + } + } // Remove armada skill files. for (const skill of skillRegistry) { removeFile(`.opencode/skills/${skill.name}/SKILL.md`) diff --git a/tests/pi-harness.test.js b/tests/pi-harness.test.js index 66bdedc..654faec 100644 --- a/tests/pi-harness.test.js +++ b/tests/pi-harness.test.js @@ -1,7 +1,7 @@ import { test } from "node:test" import assert from "node:assert" -import { buildTeam, renderPiAgentFile, renderManifestYaml } from "../src/generator.js" +import { buildTeam, renderPiAgentFile, renderPiSettings, renderManifestYaml } from "../src/generator.js" import { parseManifestYaml } from "../src/manifest.js" import { scaffold, uninstall } from "../src/scaffold.js" import { ROLES, modelFor } from "../src/model-catalog.js" @@ -53,20 +53,20 @@ test("parseManifestYaml rejects invalid harnesses", () => { ) }) -test("renderPiAgentFile emits pi frontmatter with openrouter model only", () => { +test("renderPiAgentFile maps roles to their OpenRouter model", () => { const team = buildTeam(makeManifest(".")) const backend = team.find((a) => a.role === "backend-dev") + // Primary is opencode-go (unusable in pi); the catalog fallback is OpenRouter. + assert.strictEqual(backend.model, "opencode-go/deepseek-v4-pro") + assert.strictEqual(backend.fallback, "openrouter/deepseek/deepseek-v4-pro") const file = renderPiAgentFile(backend, "# Galleon body\n\nDo backend work.") - assert.match(file, /^---\n/) + assert.match(file, /^model: openrouter\/deepseek\/deepseek-v4-pro$/m) assert.match(file, new RegExp(`name: ${agentNameFor("backend-dev")}`)) assert.match(file, /description: Galleon — Backend implementation/) - // opencode-* model IDs do not exist in pi; openrouter fallback does not - // apply here because primary is opencode-go — model must be omitted. - assert.ok(!/^model:/m.test(file), "omits opencode-only model IDs") assert.match(file, /# Galleon body/) }) -test("renderPiAgentFile keeps openrouter model and appends edit boundaries", () => { +test("renderPiAgentFile keeps explicit openrouter model and appends edit boundaries", () => { const team = buildTeam(makeManifest(".")) const qa = team.find((a) => a.role === "qa") qa.model = "openrouter/xiaomi/mimo-v2.5" @@ -78,6 +78,19 @@ test("renderPiAgentFile keeps openrouter model and appends edit boundaries", () assert.match(file, /QA body\./) }) +test("renderPiSettings defaults orchestrator session to OpenRouter", () => { + const team = buildTeam(makeManifest(".")) + const settings = renderPiSettings(makeManifest("."), team) + // orchestrator balanced: opencode-go/minimax-m3 -> openrouter fallback + assert.deepStrictEqual(settings, { + defaultProvider: "openrouter", + defaultModel: "openrouter/z-ai/glm-5.2", + }) + // No openrouter model anywhere -> no settings written + const custom = team.map((a) => ({ ...a, model: "opencode/big-pickle", fallback: null })) + assert.strictEqual(renderPiSettings(makeManifest("."), custom), null) +}) + test("renderManifestYaml serializes harnesses", () => { const manifest = makeManifest(".", { harnesses: ["opencode", "pi"] }) const yaml = renderManifestYaml(manifest, buildTeam(manifest)) @@ -94,6 +107,10 @@ test("scaffold writes .pi/agents when pi harness enabled, not otherwise", () => const content = readFileSync(qaFile, "utf8") assert.match(content, /^name: corvette$/m) assert.match(content, /description: Corvette — Quality assurance/) + // Project settings default the whole fleet (orchestrator session) to OpenRouter + const settings = JSON.parse(readFileSync(join(dir, ".pi", "settings.json"), "utf8")) + assert.strictEqual(settings.defaultProvider, "openrouter") + assert.strictEqual(settings.defaultModel, "openrouter/z-ai/glm-5.2") const withoutPi = makeManifest(dir) withoutPi.project.harnesses = undefined @@ -109,15 +126,19 @@ test("scaffold writes .pi/agents when pi harness enabled, not otherwise", () => } }) -test("uninstall removes pi agent files", () => { +test("uninstall removes pi agent files and settings model keys", () => { const dir = mkdtempSync(join(tmpdir(), "armada-pi-harness-")) try { const manifest = makeManifest(dir, { harnesses: ["pi"] }) scaffold(manifest, manifest.project.stack) assert.ok(existsSync(join(dir, ".pi", "agents", `${agentNameFor("qa")}.md`))) + const settingsPath = join(dir, ".pi", "settings.json") + assert.ok(existsSync(settingsPath)) uninstall(manifest, {}) assert.ok(!existsSync(join(dir, ".pi", "agents", `${agentNameFor("qa")}.md`))) assert.ok(!existsSync(join(dir, ".pi", "agents"))) + // armada-owned model keys removed; file removed when nothing else remains + assert.ok(!existsSync(settingsPath)) } finally { rmSync(dir, { recursive: true, force: true }) }