From 842c09edb65b41f1a65fb560280f66dbefbb467d Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:06:57 +0300 Subject: [PATCH 1/9] v2.6.0 wip: gemini, opencode, cursor, pi, mastracode presets, env allow-list, synthetic fixtures Co-Authored-By: Claude Sonnet 5 --- src/agents/env.ts | 19 +++- src/agents/executor.ts | 8 +- src/agents/presets/claude.ts | 4 + src/agents/presets/codex.ts | 4 + src/agents/presets/cursor.ts | 65 ++++++++++++++ src/agents/presets/gemini.ts | 65 ++++++++++++++ src/agents/presets/index.ts | 10 +++ src/agents/presets/mastracode.ts | 89 +++++++++++++++++++ src/agents/presets/opencode.ts | 58 ++++++++++++ src/agents/presets/pi.ts | 60 +++++++++++++ src/agents/types.ts | 11 +++ tests/fixtures.test.ts | 7 +- tests/fixtures/agents/codex/fixture.json | 4 + tests/fixtures/agents/codex/triage.jsonl | 4 + tests/fixtures/agents/cursor/fixture.json | 5 ++ tests/fixtures/agents/cursor/triage.jsonl | 6 ++ tests/fixtures/agents/gemini/fixture.json | 4 + tests/fixtures/agents/gemini/triage.jsonl | 6 ++ tests/fixtures/agents/mastracode/fixture.json | 4 + tests/fixtures/agents/mastracode/triage.jsonl | 9 ++ tests/fixtures/agents/opencode/fixture.json | 4 + tests/fixtures/agents/opencode/triage.jsonl | 4 + tests/fixtures/agents/pi/fixture.json | 4 + tests/fixtures/agents/pi/triage.jsonl | 7 ++ tests/presets.test.ts | 85 ++++++++++++++++++ 25 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 src/agents/presets/cursor.ts create mode 100644 src/agents/presets/gemini.ts create mode 100644 src/agents/presets/mastracode.ts create mode 100644 src/agents/presets/opencode.ts create mode 100644 src/agents/presets/pi.ts create mode 100644 tests/fixtures/agents/codex/fixture.json create mode 100644 tests/fixtures/agents/codex/triage.jsonl create mode 100644 tests/fixtures/agents/cursor/fixture.json create mode 100644 tests/fixtures/agents/cursor/triage.jsonl create mode 100644 tests/fixtures/agents/gemini/fixture.json create mode 100644 tests/fixtures/agents/gemini/triage.jsonl create mode 100644 tests/fixtures/agents/mastracode/fixture.json create mode 100644 tests/fixtures/agents/mastracode/triage.jsonl create mode 100644 tests/fixtures/agents/opencode/fixture.json create mode 100644 tests/fixtures/agents/opencode/triage.jsonl create mode 100644 tests/fixtures/agents/pi/fixture.json create mode 100644 tests/fixtures/agents/pi/triage.jsonl create mode 100644 tests/presets.test.ts diff --git a/src/agents/env.ts b/src/agents/env.ts index 9b77304..4ea0cfe 100644 --- a/src/agents/env.ts +++ b/src/agents/env.ts @@ -28,10 +28,27 @@ const REPOSITORY_GIT_ENV = new Set([ "GIT_WORK_TREE", ]); -export function sanitizeEnv(env: NodeJS.ProcessEnv): Record { +// Every provider key the presets know. An agent keeps only its own `envKeys`, so a +// Codex stage never sees the Anthropic key. Agents with no preset keep them all. +export const PROVIDER_KEYS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "CURSOR_API_KEY", + "OPENROUTER_API_KEY", + "MISTRAL_API_KEY", + "GROQ_API_KEY", + "XAI_API_KEY", + "DEEPSEEK_API_KEY", +] as const; + +export function sanitizeEnv(env: NodeJS.ProcessEnv, keep?: readonly string[]): Record { const out: Record = {}; for (const [key, value] of Object.entries(env)) { if (value === undefined) continue; + if (keep && (PROVIDER_KEYS as readonly string[]).includes(key) && !keep.includes(key)) continue; if (REPOSITORY_GIT_ENV.has(key) || STRIPPED_ENV_PREFIXES.some((p) => key === p || key.startsWith(p))) continue; out[key] = value; } diff --git a/src/agents/executor.ts b/src/agents/executor.ts index fd08cf4..83022f4 100644 --- a/src/agents/executor.ts +++ b/src/agents/executor.ts @@ -106,7 +106,7 @@ export class CommandExecutor implements Executor { stderr: "pipe", detached: true, // its own process group, so a kill reaches the agent's children too env: { - ...sanitizeEnv(process.env), + ...sanitizeEnv(process.env, agent.preset?.envKeys), FACTORY_ARTIFACT_DIR: artifactDir, FACTORY_ISSUE: String(opts.issue), FACTORY_STAGE: opts.stage, @@ -149,15 +149,17 @@ export class CommandExecutor implements Executor { killGroup(); }, timeoutMinutes * 60_000); + // A preset whose events span lines gets a fresh parser per stage run. + const parseLine = agent.preset?.newParser?.() ?? agent.preset?.parseLine; const handle = (line: string) => { this.recorder?.line(opts.stage, line); - if (!agent.preset) return; + if (!agent.preset || !parseLine) return; if (Buffer.byteLength(line) > MAX_EVENT_LINE_BYTES) { // Dropped, not parsed; if it was the terminal usage event the count is gone. if (agent.preset.isUsageCandidate(line.slice(0, 4096))) usageComplete = false; return; } - for (const e of agent.preset.parseLine(line)) { + for (const e of parseLine(line)) { recorded += Buffer.byteLength(e.text ?? ""); if (recorded > MAX_RECORDED_OUTPUT_BYTES) { if (events.at(-1)?.kind !== "truncated") events.push({ kind: "truncated", text: `recording stopped after ${MAX_RECORDED_OUTPUT_BYTES} output bytes; the agent keeps running` }); diff --git a/src/agents/presets/claude.ts b/src/agents/presets/claude.ts index 9c1e3a3..7243adb 100644 --- a/src/agents/presets/claude.ts +++ b/src/agents/presets/claude.ts @@ -98,6 +98,10 @@ export const claudePreset: AgentPreset = { name: "claude", binary: "claude", verified: true, + version: "2.1.281", + envKeys: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + skillsDir: ".claude/skills", + contextFile: "CLAUDE.md", ownsPrompt: true, command: (opts, agent) => ({ argv: ["claude", ...claudeArgs(opts), ...(agent.model ? ["--model", agent.model] : [])] }), parseLine: parseStreamJsonLine, diff --git a/src/agents/presets/codex.ts b/src/agents/presets/codex.ts index 6be4518..f3dc902 100644 --- a/src/agents/presets/codex.ts +++ b/src/agents/presets/codex.ts @@ -41,6 +41,10 @@ export const codexPreset: AgentPreset = { name: "codex", binary: "codex", verified: false, + version: "0.156.1", + envKeys: ["OPENAI_API_KEY"], + skillsDir: ".codex/skills", + contextFile: "AGENTS.md", returnsArtifact: true, command: (opts, agent, prompt, ctx) => ({ argv: [ diff --git a/src/agents/presets/cursor.ts b/src/agents/presets/cursor.ts new file mode 100644 index 0000000..126d1df --- /dev/null +++ b/src/agents/presets/cursor.ts @@ -0,0 +1,65 @@ +// Cursor Agent: `cursor-agent -p --output-format stream-json --force --workspace `. +// The CLI documents no stdin prompt, so the prompt is the last argument. stream-json +// reports no token usage in 2026.01.23, so usage stays "not reported". Read-only +// stages run with `--mode plan` and return their artifacts as the final message. + +import type { StageEvent } from "../../executor"; +import { type AgentPreset, stagePolicy } from "../types"; + +interface CursorLine { + type?: string; + subtype?: string; + result?: string; + message?: { content?: Array<{ type?: string; text?: string }> }; + tool_call?: Record; +} + +export function parseCursorLine(line: string): StageEvent[] { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: CursorLine; + try { + parsed = JSON.parse(trimmed); + } catch { + return []; + } + if (parsed.type === "assistant") { + const text = (parsed.message?.content ?? []).flatMap((c) => (c.type === "text" && c.text ? [c.text] : [])).join(""); + return text ? [{ kind: "text", text }] : []; + } + if (parsed.type === "tool_call" && parsed.subtype === "started") { + const key = Object.keys(parsed.tool_call ?? {})[0] ?? "tool"; + return [{ kind: "tool_use", toolName: key.replace(/ToolCall$/, "") }]; + } + if (parsed.type === "result" && parsed.result) return [{ kind: "text", text: parsed.result, finalText: parsed.result }]; + return []; +} + +export const cursorPreset: AgentPreset = { + name: "cursor", + binary: "cursor-agent", + verified: false, + version: "2026.01.23-916f423", + envKeys: ["CURSOR_API_KEY"], + skillsDir: ".cursor/skills", + contextFile: "AGENTS.md", + // Cursor's installer has no versioned download URL, so the Docker image cannot pin it. + docker: false, + returnsArtifact: true, + command: (opts, agent, prompt) => ({ + argv: [ + "cursor-agent", + "-p", + "--output-format", + "stream-json", + "--force", + "--workspace", + opts.cwd, + ...(stagePolicy(opts.stage).write ? [] : ["--mode", "plan"]), + ...(agent.model ? ["--model", agent.model] : []), + prompt, + ], + }), + parseLine: parseCursorLine, + isUsageCandidate: () => false, +}; diff --git a/src/agents/presets/gemini.ts b/src/agents/presets/gemini.ts new file mode 100644 index 0000000..fd66a31 --- /dev/null +++ b/src/agents/presets/gemini.ts @@ -0,0 +1,65 @@ +// Gemini CLI: `gemini -o stream-json --approval-mode ` with the prompt on stdin. +// Shapes read from gemini-cli 0.61.0's docs and bundle (research/agents/gemini/docs.md). +// Read-only stages run in `plan` mode and return their artifacts as the final message. + +import type { StageEvent } from "../../executor"; +import { type AgentPreset, stagePolicy } from "../types"; +import { isUsageResultCandidate } from "../usage"; + +interface GeminiLine { + type?: string; + role?: string; + content?: string; + tool_name?: string; + stats?: { input_tokens?: unknown; output_tokens?: unknown; cached?: unknown }; +} + +const count = (v: unknown): number | null => (typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : null); + +// Assistant text arrives as deltas; the closing message is what it said after its last tool call. +export function createGeminiParser(): (line: string) => StageEvent[] { + let closing = ""; + return (line) => { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: GeminiLine; + try { + parsed = JSON.parse(trimmed); + } catch { + return isUsageResultCandidate(trimmed, "result") ? [{ kind: "usage", invalid: true }] : []; + } + if (parsed.type === "message" && parsed.role === "assistant" && parsed.content) { + closing += parsed.content; + return [{ kind: "text", text: parsed.content }]; + } + if (parsed.type === "tool_use") { + closing = ""; + return [{ kind: "tool_use", toolName: parsed.tool_name ?? "tool" }]; + } + if (parsed.type !== "result") return []; + const tokensIn = count(parsed.stats?.input_tokens); + const tokensOut = count(parsed.stats?.output_tokens); + const events: StageEvent[] = closing ? [{ kind: "text", text: "", finalText: closing }] : []; + if (tokensIn === null || tokensOut === null) return [...events, { kind: "usage", invalid: true }]; + return [...events, { kind: "usage", tokensIn, tokensOut, tokensCached: count(parsed.stats?.cached) ?? 0, total: true }]; + }; +} + +export const geminiPreset: AgentPreset = { + name: "gemini", + binary: "gemini", + verified: false, + version: "0.61.0", + envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + skillsDir: ".gemini/skills", + contextFile: "GEMINI.md", + returnsArtifact: true, + command: (opts, agent, prompt) => ({ + // Headless runs have no one to approve a tool call, so write stages use yolo. + argv: ["gemini", "-o", "stream-json", "--approval-mode", stagePolicy(opts.stage).write ? "yolo" : "plan", ...(agent.model ? ["-m", agent.model] : []), "-p", ""], + stdin: prompt, + }), + parseLine: (line) => createGeminiParser()(line), + newParser: createGeminiParser, + isUsageCandidate: (line) => isUsageResultCandidate(line, "result"), +}; diff --git a/src/agents/presets/index.ts b/src/agents/presets/index.ts index 70dbfd1..9a61279 100644 --- a/src/agents/presets/index.ts +++ b/src/agents/presets/index.ts @@ -1,8 +1,18 @@ import type { AgentPreset } from "../types"; import { claudePreset } from "./claude"; import { codexPreset } from "./codex"; +import { cursorPreset } from "./cursor"; +import { geminiPreset } from "./gemini"; +import { mastracodePreset } from "./mastracode"; +import { opencodePreset } from "./opencode"; +import { piPreset } from "./pi"; export const PRESETS: Record = { claude: claudePreset, codex: codexPreset, + gemini: geminiPreset, + opencode: opencodePreset, + cursor: cursorPreset, + pi: piPreset, + mastracode: mastracodePreset, }; diff --git a/src/agents/presets/mastracode.ts b/src/agents/presets/mastracode.ts new file mode 100644 index 0000000..03bef4d --- /dev/null +++ b/src/agents/presets/mastracode.ts @@ -0,0 +1,89 @@ +// Mastra Code (Apache-2.0, Kepler Software, Inc.): `mastracode -o jsonl --permission-mode auto` +// with the prompt on stdin. Each jsonl line is a raw AgentController event; the shapes +// (`message_update` text-delta, `tool_start`, `usage_update`) come from @mastra/code-sdk's +// types and its usage accumulator. Nothing is copied from any `ee/` directory. +// Exit code 2 means the --timeout expired. Read-only stages use `--mode plan`. + +import type { StageEvent } from "../../executor"; +import { type AgentPreset, stagePolicy } from "../types"; +import { isUsageResultCandidate } from "../usage"; + +interface MastraLine { + type?: string; + id?: string; + toolName?: string; + message?: { id?: string; role?: string }; + event?: { type?: string; delta?: string }; + usage?: { promptTokens?: unknown; completionTokens?: unknown }; +} + +const count = (v: unknown): number | null => (typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : null); + +// Text arrives as deltas; the closing message is what the assistant said last. +export function createMastraParser(): (line: string) => StageEvent[] { + let assistantId: string | undefined; + let buffer = ""; + return (line) => { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: MastraLine; + try { + parsed = JSON.parse(trimmed); + } catch { + return isUsageResultCandidate(trimmed, "usage_update") ? [{ kind: "usage", invalid: true }] : []; + } + switch (parsed.type) { + case "message_start": + if (parsed.message?.role !== "assistant") return []; + assistantId = parsed.message.id; + buffer = ""; + return []; + case "message_update": + if (parsed.id === assistantId && parsed.event?.type === "text-delta" && parsed.event.delta) buffer += parsed.event.delta; + return []; + case "message_end": { + if (parsed.id !== assistantId || !buffer) return []; + const text = buffer; + buffer = ""; + return [{ kind: "text", text, finalText: text }]; + } + case "tool_start": + return [{ kind: "tool_use", toolName: parsed.toolName ?? "tool" }]; + case "usage_update": { + const tokensIn = count(parsed.usage?.promptTokens); + const tokensOut = count(parsed.usage?.completionTokens); + return tokensIn === null || tokensOut === null ? [{ kind: "usage", invalid: true }] : [{ kind: "usage", tokensIn, tokensOut, total: true }]; + } + default: + return []; + } + }; +} + +export const mastracodePreset: AgentPreset = { + name: "mastracode", + binary: "mastracode", + verified: false, + version: "0.42.0", + envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY"], + skillsDir: ".mastracode/skills", + contextFile: "AGENTS.md", + returnsArtifact: true, + command: (opts, agent, prompt) => ({ + argv: [ + "mastracode", + "--permission-mode", + "auto", + "-o", + "jsonl", + "--mode", + stagePolicy(opts.stage).write ? "build" : "plan", + ...(opts.timeoutMinutes ? ["--timeout", String(opts.timeoutMinutes * 60)] : []), + ...(agent.model ? ["-m", agent.model] : []), + ], + stdin: prompt, + }), + parseLine: (line) => createMastraParser()(line), + newParser: createMastraParser, + isUsageCandidate: (line) => isUsageResultCandidate(line, "usage_update"), +}; diff --git a/src/agents/presets/opencode.ts b/src/agents/presets/opencode.ts new file mode 100644 index 0000000..28ed190 --- /dev/null +++ b/src/agents/presets/opencode.ts @@ -0,0 +1,58 @@ +// OpenCode: `opencode run --format json --auto --dir ` with the prompt on stdin. +// Events are `{type, part}`: `tool_use` parts name the tool, `step_finish` parts carry +// `tokens {input, output, cache {read, write}}`. `run` has no read-only mode, so every +// stage writes its own files and the safety layers are the stage prompt and the gate. + +import type { StageEvent } from "../../executor"; +import type { AgentPreset } from "../types"; +import { isUsageResultCandidate } from "../usage"; + +interface OpenCodeLine { + type?: string; + part?: { + text?: string; + tool?: string; + tokens?: { input?: unknown; output?: unknown; cache?: { read?: unknown; write?: unknown } }; + }; +} + +const count = (v: unknown): number | null => (typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : null); + +export function parseOpenCodeLine(line: string): StageEvent[] { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: OpenCodeLine; + try { + parsed = JSON.parse(trimmed); + } catch { + return isUsageResultCandidate(trimmed, "step_finish") ? [{ kind: "usage", invalid: true }] : []; + } + const part = parsed.part; + if (parsed.type === "text" && part?.text) return [{ kind: "text", text: part.text, finalText: part.text }]; + if (parsed.type === "tool_use") return [{ kind: "tool_use", toolName: part?.tool ?? "tool" }]; + if (parsed.type === "step_finish") { + const input = count(part?.tokens?.input); + const output = count(part?.tokens?.output); + const read = count(part?.tokens?.cache?.read ?? 0); + const write = count(part?.tokens?.cache?.write ?? 0); + if (input === null || output === null || read === null || write === null) return [{ kind: "usage", invalid: true }]; + return [{ kind: "usage", tokensIn: input + read + write, tokensOut: output, tokensCached: read }]; + } + return []; +} + +export const opencodePreset: AgentPreset = { + name: "opencode", + binary: "opencode", + verified: false, + version: "1.18.32", + envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY"], + skillsDir: ".opencode/skills", + contextFile: "AGENTS.md", + command: (opts, agent, prompt) => ({ + argv: ["opencode", "run", "--format", "json", "--auto", "--dir", opts.cwd, ...(agent.model ? ["-m", agent.model] : [])], + stdin: prompt, + }), + parseLine: parseOpenCodeLine, + isUsageCandidate: () => false, +}; diff --git a/src/agents/presets/pi.ts b/src/agents/presets/pi.ts new file mode 100644 index 0000000..5e92596 --- /dev/null +++ b/src/agents/presets/pi.ts @@ -0,0 +1,60 @@ +// Pi: `pi -p --mode json --no-session` with the prompt on stdin. Events are from +// pi's docs/json.md; usage is on each assistant message_end (pi-ai `Usage`), so it is summed. +// Read-only stages get a read-only tool allow-list and return their artifacts as the final message. + +import type { StageEvent } from "../../executor"; +import { type AgentPreset, stagePolicy } from "../types"; +import { isUsageResultCandidate } from "../usage"; + +interface PiLine { + type?: string; + toolName?: string; + message?: { + role?: string; + content?: Array<{ type?: string; text?: string }>; + usage?: { input?: unknown; output?: unknown; cacheRead?: unknown; cacheWrite?: unknown }; + }; +} + +const count = (v: unknown): number | null => (typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : null); + +export function parsePiLine(line: string): StageEvent[] { + const trimmed = line.trim(); + if (!trimmed) return []; + let parsed: PiLine; + try { + parsed = JSON.parse(trimmed); + } catch { + return isUsageResultCandidate(trimmed, "message_end") ? [{ kind: "usage", invalid: true }] : []; + } + if (parsed.type === "tool_execution_start") return [{ kind: "tool_use", toolName: parsed.toolName ?? "tool" }]; + if (parsed.type !== "message_end" || parsed.message?.role !== "assistant") return []; + const events: StageEvent[] = []; + const text = (parsed.message.content ?? []).flatMap((c) => (c.type === "text" && c.text ? [c.text] : [])).join(""); + if (text) events.push({ kind: "text", text, finalText: text }); + const u = parsed.message.usage; + const input = count(u?.input); + const output = count(u?.output); + const read = count(u?.cacheRead ?? 0); + const write = count(u?.cacheWrite ?? 0); + if (input === null || output === null || read === null || write === null) events.push({ kind: "usage", invalid: true }); + else events.push({ kind: "usage", tokensIn: input + read + write, tokensOut: output, tokensCached: read }); + return events; +} + +export const piPreset: AgentPreset = { + name: "pi", + binary: "pi", + verified: false, + version: "0.73.1", + envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY"], + skillsDir: ".pi/agent/skills", + contextFile: "AGENTS.md", + returnsArtifact: true, + command: (opts, agent, prompt) => ({ + argv: ["pi", "-p", "--mode", "json", "--no-session", ...(stagePolicy(opts.stage).write ? [] : ["--tools", "read,grep,find,ls"]), ...(agent.model ? ["--model", agent.model] : [])], + stdin: prompt, + }), + parseLine: parsePiLine, + isUsageCandidate: () => false, +}; diff --git a/src/agents/types.ts b/src/agents/types.ts index 75a5ef7..38bfae2 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -44,12 +44,23 @@ export interface AgentPreset { readonly binary: string; // True once a recorded live run passed (a fixture in tests/fixtures/agents//). Only Claude is verified by the maintainers; the rest are verified by participants (docs/verify-an-agent.md). readonly verified: boolean; + // The CLI version the Dockerfile and CI pin; `factory doctor` warns on drift. + readonly version: string; + // Provider keys this agent may see. Every other known provider key is stripped from its env. + readonly envKeys: readonly string[]; + // Project directory `install --agents` links the skills into, and the context file it points at. + readonly skillsDir: string; + readonly contextFile: string; + // False when the agent cannot be pinned in the Docker image (no versioned download). + readonly docker?: false; // Builds argv and stdin. `prompt` is the rendered stage prompt. command(opts: StageRunOptions, agent: AgentConfig, prompt: string, ctx?: StageContext): StageInvocation; // True when a read-only stage cannot write its files: the agent returns them // as its final message and the runner writes them (see reply.ts). readonly returnsArtifact?: boolean; parseLine(line: string): StageEvent[]; + // For a CLI whose events span lines: a fresh stateful parser per stage run (used instead of parseLine). + newParser?(): (line: string) => StageEvent[]; // True for a line (or its first bytes) that would have been the terminal usage event. isUsageCandidate(line: string): boolean; // Claude runs the repo's `/factory-` skill itself; others get the skill body inlined. diff --git a/tests/fixtures.test.ts b/tests/fixtures.test.ts index a8e019d..a9ea19a 100644 --- a/tests/fixtures.test.ts +++ b/tests/fixtures.test.ts @@ -15,10 +15,13 @@ describe("recorded agent fixtures", () => { test(`${name}/${file} replays through the ${name} preset`, () => { const lines = readFileSync(join(root, name, file), "utf8").split("\n").filter(Boolean); expect(lines.length).toBeGreaterThan(1); - const events = lines.flatMap((l) => preset.parseLine(l)); + const parse = preset.newParser?.() ?? preset.parseLine; + const events = lines.flatMap((l) => parse(l)); const result = aggregateStageEvents(events, 0); expect(result.usageComplete).not.toBe(false); - expect(result.tokensIn + result.tokensOut).toBeGreaterThan(0); + const meta = JSON.parse(readFileSync(join(root, name, "fixture.json"), "utf8")); + // Cursor's stream-json carries no usage, so its cost stays "not reported". + if (meta.reportsUsage !== false) expect(result.tokensIn + result.tokensOut).toBeGreaterThan(0); expect(result.finalMessage?.length ?? 0).toBeGreaterThan(0); }); } diff --git a/tests/fixtures/agents/codex/fixture.json b/tests/fixtures/agents/codex/fixture.json new file mode 100644 index 0000000..647b75c --- /dev/null +++ b/tests/fixtures/agents/codex/fixture.json @@ -0,0 +1,4 @@ +{ + "agent": "codex", + "synthetic": true +} diff --git a/tests/fixtures/agents/codex/triage.jsonl b/tests/fixtures/agents/codex/triage.jsonl new file mode 100644 index 0000000..014b915 --- /dev/null +++ b/tests/fixtures/agents/codex/triage.jsonl @@ -0,0 +1,4 @@ +{"type": "thread.started", "thread_id": "t1"} +{"type": "item.completed", "item": {"type": "command_execution", "command": "ls"}} +{"type": "item.completed", "item": {"type": "agent_message", "text": "{\"outcome\":\"ok\"}"}} +{"type": "turn.completed", "usage": {"input_tokens": 1200, "cached_input_tokens": 400, "output_tokens": 90}} diff --git a/tests/fixtures/agents/cursor/fixture.json b/tests/fixtures/agents/cursor/fixture.json new file mode 100644 index 0000000..8221782 --- /dev/null +++ b/tests/fixtures/agents/cursor/fixture.json @@ -0,0 +1,5 @@ +{ + "agent": "cursor", + "synthetic": true, + "reportsUsage": false +} diff --git a/tests/fixtures/agents/cursor/triage.jsonl b/tests/fixtures/agents/cursor/triage.jsonl new file mode 100644 index 0000000..41c2c09 --- /dev/null +++ b/tests/fixtures/agents/cursor/triage.jsonl @@ -0,0 +1,6 @@ +{"type": "system", "subtype": "init", "model": "sonnet-4"} +{"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "prompt"}]}} +{"type": "tool_call", "subtype": "started", "call_id": "c1", "tool_call": {"readToolCall": {"args": {"path": "README.md"}}}} +{"type": "tool_call", "subtype": "completed", "call_id": "c1", "tool_call": {"readToolCall": {"result": {}}}} +{"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "{\"outcome\":\"ok\"}"}]}} +{"type": "result", "subtype": "success", "is_error": false, "duration_ms": 5000, "result": "{\"outcome\":\"ok\"}"} diff --git a/tests/fixtures/agents/gemini/fixture.json b/tests/fixtures/agents/gemini/fixture.json new file mode 100644 index 0000000..d6fe100 --- /dev/null +++ b/tests/fixtures/agents/gemini/fixture.json @@ -0,0 +1,4 @@ +{ + "agent": "gemini", + "synthetic": true +} diff --git a/tests/fixtures/agents/gemini/triage.jsonl b/tests/fixtures/agents/gemini/triage.jsonl new file mode 100644 index 0000000..a7d303f --- /dev/null +++ b/tests/fixtures/agents/gemini/triage.jsonl @@ -0,0 +1,6 @@ +{"type": "init", "session_id": "s1", "model": "gemini-3-pro"} +{"type": "message", "role": "user", "content": "prompt"} +{"type": "tool_use", "tool_name": "read_file", "tool_id": "a1", "parameters": {"file_path": "README.md"}} +{"type": "tool_result", "tool_id": "a1", "status": "success", "output": "..."} +{"type": "message", "role": "assistant", "content": "{\"outcome\":\"ok\"}", "delta": true} +{"type": "result", "status": "success", "stats": {"total_tokens": 1290, "input_tokens": 1200, "output_tokens": 90, "cached": 400, "input": 800, "duration_ms": 4100, "tool_calls": 1, "models": {}}} diff --git a/tests/fixtures/agents/mastracode/fixture.json b/tests/fixtures/agents/mastracode/fixture.json new file mode 100644 index 0000000..ff63b0d --- /dev/null +++ b/tests/fixtures/agents/mastracode/fixture.json @@ -0,0 +1,4 @@ +{ + "agent": "mastracode", + "synthetic": true +} diff --git a/tests/fixtures/agents/mastracode/triage.jsonl b/tests/fixtures/agents/mastracode/triage.jsonl new file mode 100644 index 0000000..a9f0fe0 --- /dev/null +++ b/tests/fixtures/agents/mastracode/triage.jsonl @@ -0,0 +1,9 @@ +{"type": "agent_start"} +{"type": "message_start", "message": {"id": "m1", "role": "assistant"}} +{"type": "tool_start", "toolName": "view"} +{"type": "tool_end", "toolName": "view", "isError": false, "result": "..."} +{"type": "message_update", "id": "m1", "event": {"type": "text-delta", "delta": "{\"outcome\":"}} +{"type": "message_update", "id": "m1", "event": {"type": "text-delta", "delta": "\"ok\"}"}} +{"type": "message_end", "id": "m1"} +{"type": "usage_update", "usage": {"promptTokens": 1200, "completionTokens": 90, "totalTokens": 1290}} +{"type": "agent_end"} diff --git a/tests/fixtures/agents/opencode/fixture.json b/tests/fixtures/agents/opencode/fixture.json new file mode 100644 index 0000000..cc2fb1b --- /dev/null +++ b/tests/fixtures/agents/opencode/fixture.json @@ -0,0 +1,4 @@ +{ + "agent": "opencode", + "synthetic": true +} diff --git a/tests/fixtures/agents/opencode/triage.jsonl b/tests/fixtures/agents/opencode/triage.jsonl new file mode 100644 index 0000000..e2ef489 --- /dev/null +++ b/tests/fixtures/agents/opencode/triage.jsonl @@ -0,0 +1,4 @@ +{"type": "step_start", "sessionID": "s1", "part": {"type": "step-start"}} +{"type": "tool_use", "sessionID": "s1", "part": {"type": "tool", "tool": "read", "state": {"status": "completed"}}} +{"type": "text", "sessionID": "s1", "part": {"type": "text", "text": "{\"outcome\":\"ok\"}"}} +{"type": "step_finish", "sessionID": "s1", "part": {"type": "step-finish", "cost": 0, "tokens": {"input": 800, "output": 90, "reasoning": 0, "cache": {"read": 400, "write": 0}}}} diff --git a/tests/fixtures/agents/pi/fixture.json b/tests/fixtures/agents/pi/fixture.json new file mode 100644 index 0000000..995497d --- /dev/null +++ b/tests/fixtures/agents/pi/fixture.json @@ -0,0 +1,4 @@ +{ + "agent": "pi", + "synthetic": true +} diff --git a/tests/fixtures/agents/pi/triage.jsonl b/tests/fixtures/agents/pi/triage.jsonl new file mode 100644 index 0000000..d694d78 --- /dev/null +++ b/tests/fixtures/agents/pi/triage.jsonl @@ -0,0 +1,7 @@ +{"type": "session", "version": 3, "id": "u1", "timestamp": "2026-09-24T00:00:00Z", "cwd": "/work"} +{"type": "agent_start"} +{"type": "turn_start"} +{"type": "tool_execution_start", "toolCallId": "c1", "toolName": "read", "args": {"path": "README.md"}} +{"type": "tool_execution_end", "toolCallId": "c1", "toolName": "read", "result": "...", "isError": false} +{"type": "message_end", "message": {"role": "assistant", "content": [{"type": "text", "text": "{\"outcome\":\"ok\"}"}], "usage": {"input": 800, "output": 90, "cacheRead": 400, "cacheWrite": 0, "totalTokens": 1290, "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}}}} +{"type": "agent_end", "messages": []} diff --git a/tests/presets.test.ts b/tests/presets.test.ts new file mode 100644 index 0000000..7634876 --- /dev/null +++ b/tests/presets.test.ts @@ -0,0 +1,85 @@ +// Argv and event-shape pins for the presets added in v2.6, plus the per-provider env allow-list. +import { describe, expect, test } from "bun:test"; +import { PROVIDER_KEYS, sanitizeEnv } from "../src/agents/env"; +import { PRESETS } from "../src/agents/presets"; +import type { StageName } from "../src/executor"; + +const opts = (stage: StageName) => ({ stage, issue: 7, cwd: "/work", maxBudgetUsd: 5 }); +const argv = (name: string, stage: StageName, model?: string) => PRESETS[name]!.command(opts(stage), { preset: name, model }, "PROMPT").argv; + +describe("preset argv", () => { + test("gemini: plan mode for read-only stages, yolo for write stages, prompt on stdin", () => { + expect(argv("gemini", "plan")).toEqual(["gemini", "-o", "stream-json", "--approval-mode", "plan", "-p", ""]); + expect(argv("gemini", "build", "gemini-3-pro")).toEqual(["gemini", "-o", "stream-json", "--approval-mode", "yolo", "-m", "gemini-3-pro", "-p", ""]); + expect(PRESETS.gemini!.command(opts("plan"), { preset: "gemini" }, "PROMPT").stdin).toBe("PROMPT"); + }); + test("pi: read-only tool allow-list except on write stages", () => { + expect(argv("pi", "verify")).toEqual(["pi", "-p", "--mode", "json", "--no-session", "--tools", "read,grep,find,ls"]); + expect(argv("pi", "build")).toEqual(["pi", "-p", "--mode", "json", "--no-session"]); + }); + test("opencode: json events, auto-approve, run in the worktree", () => { + expect(argv("opencode", "build", "anthropic/claude-sonnet-5")).toEqual(["opencode", "run", "--format", "json", "--auto", "--dir", "/work", "-m", "anthropic/claude-sonnet-5"]); + }); + test("cursor: plan mode when read-only, the prompt is the last argument", () => { + expect(argv("cursor", "triage")).toEqual(["cursor-agent", "-p", "--output-format", "stream-json", "--force", "--workspace", "/work", "--mode", "plan", "PROMPT"]); + expect(argv("cursor", "build").includes("--mode")).toBe(false); + }); + test("mastracode: jsonl, plan mode when read-only, timeout in seconds", () => { + const p = PRESETS.mastracode!.command({ ...opts("plan"), timeoutMinutes: 3 }, { preset: "mastracode" }, "PROMPT"); + expect(p.argv).toEqual(["mastracode", "--permission-mode", "auto", "-o", "jsonl", "--mode", "plan", "--timeout", "180"]); + expect(argv("mastracode", "build")).toContain("build"); + }); +}); + +describe("preset events", () => { + const run = (name: string, lines: object[]) => { + const parse = PRESETS[name]!.newParser?.() ?? PRESETS[name]!.parseLine; + return lines.flatMap((l) => parse(JSON.stringify(l))); + }; + test("gemini keeps only the text after the last tool call as the closing message", () => { + const events = run("gemini", [ + { type: "message", role: "assistant", content: "thinking" }, + { type: "tool_use", tool_name: "read_file" }, + { type: "message", role: "assistant", content: "done" }, + { type: "result", status: "success", stats: { input_tokens: 10, output_tokens: 2, cached: 3 } }, + ]); + expect(events.find((e) => e.finalText)?.finalText).toBe("done"); + expect(events.at(-1)).toMatchObject({ kind: "usage", tokensIn: 10, tokensOut: 2, tokensCached: 3, total: true }); + }); + test("a result with unreadable stats is 'not reported', never zero", () => { + expect(run("gemini", [{ type: "result", stats: {} }])).toEqual([{ kind: "usage", invalid: true }]); + expect(run("opencode", [{ type: "step_finish", part: { tokens: { input: -1, output: 2 } } }])).toEqual([{ kind: "usage", invalid: true }]); + }); + test("pi sums cache reads and writes into tokensIn and reports reads as cached", () => { + const events = run("pi", [{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "ok" }], usage: { input: 10, output: 2, cacheRead: 5, cacheWrite: 1 } } }]); + expect(events.at(-1)).toMatchObject({ kind: "usage", tokensIn: 16, tokensOut: 2, tokensCached: 5 }); + }); + test("mastracode joins text deltas of the assistant message only", () => { + const events = run("mastracode", [ + { type: "message_start", message: { id: "a", role: "assistant" } }, + { type: "message_update", id: "a", event: { type: "text-delta", delta: "he" } }, + { type: "message_update", id: "b", event: { type: "text-delta", delta: "NO" } }, + { type: "message_update", id: "a", event: { type: "text-delta", delta: "llo" } }, + { type: "message_end", id: "a" }, + ]); + expect(events).toEqual([{ kind: "text", text: "hello", finalText: "hello" }]); + }); + test("cursor names the tool from its tool_call key and reports no usage", () => { + const events = run("cursor", [{ type: "tool_call", subtype: "started", tool_call: { readToolCall: {} } }, { type: "result", result: "done" }]); + expect(events).toEqual([{ kind: "tool_use", toolName: "read" }, { kind: "text", text: "done", finalText: "done" }]); + }); +}); + +describe("per-provider env allow-list", () => { + const all = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, "secret"])); + test("a stage keeps only its own provider keys", () => { + const out = sanitizeEnv({ ...all, PATH: "/bin" }, PRESETS.codex!.envKeys); + expect(Object.keys(out).sort()).toEqual(["OPENAI_API_KEY", "PATH"]); + }); + test("an agent with no preset keeps every provider key", () => { + expect(Object.keys(sanitizeEnv(all)).length).toBe(PROVIDER_KEYS.length); + }); + test("every preset's envKeys are known provider keys", () => { + for (const p of Object.values(PRESETS)) for (const k of p.envKeys) expect(PROVIDER_KEYS as readonly string[]).toContain(k); + }); +}); From 23f642cff00bf1165d0521b424b3bf3a282bd070 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:08:39 +0300 Subject: [PATCH 2/9] v2.6.0 wip: install --agents, agent-dirs port, doctor version check Co-Authored-By: Claude Sonnet 5 --- THIRD_PARTY_NOTICES.md | 6 ++++ bin/factory | 14 +++++++- install.sh | 57 +++++++++++++++++++++++++++++- src/agent-dirs.ts | 41 +++++++++++++++++++++ src/doctor.ts | 12 +++++++ tests/doctor.test.ts | 32 +++++++++++++++++ tests/install.test.ts | 37 +++++++++++++++++++ tests/ported/skills/agents.test.ts | 30 ++++++++++++++++ 8 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/agent-dirs.ts create mode 100644 tests/ported/skills/agents.test.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7bc2741..c88b23b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -35,6 +35,12 @@ Source: https://github.com/owainlewis/assembler (MIT, Copyright (c) 2026 Owain L - `src/agents/reply.ts` from `src/index.ts` [tested by `tests/ported/assembler/outputs.test.ts`] - `src/recheck.ts` from `examples/review-pr.ts` [no upstream test] +## owainlewis/skills@e8cadb3 + +Source: https://github.com/owainlewis/skills (MIT, Copyright (c) 2026 Owain Lewis) + +- `src/agent-dirs.ts` from `internal/agents/agents.go` [tested by `tests/ported/skills/agents.test.ts`] + ## License text (both projects) MIT License diff --git a/bin/factory b/bin/factory index db98a8e..58e0864 100755 --- a/bin/factory +++ b/bin/factory @@ -41,6 +41,16 @@ function has(name: string): boolean { return args.includes(`--${name}`); } +async function versionOf(bin: string): Promise { + try { + const proc = Bun.spawn([bin, "--version"], { stdout: "pipe", stderr: "ignore" }); + const out = await new Response(proc.stdout).text(); + return (await proc.exited) === 0 ? out.trim() : undefined; + } catch { + return undefined; + } +} + async function which(bin: string): Promise { const proc = Bun.spawn(["which", bin], { stdout: "pipe", stderr: "pipe" }); return (await proc.exited) === 0; @@ -277,6 +287,7 @@ async function cmdDoctor(): Promise { github, git: new GitCommandRunner(), which, + versionOf, fileExists, readFile: async (path) => ((await fileExists(path)) ? await Bun.file(path).text() : undefined), isExecutable: async (path) => { @@ -373,11 +384,12 @@ async function cmdVerifyAgent(): Promise { async function cmdInstall(): Promise { const target = args[1]; - if (!target) throw new UsageError("install: usage: factory install [--dry-run] [--update] [--ci]"); + if (!target) throw new UsageError("install: usage: factory install [--dry-run] [--update] [--ci] [--agents a,b,c]"); const passthrough = [ ...(has("dry-run") ? ["--dry-run"] : []), ...(has("update") ? ["--update"] : []), ...(has("ci") ? ["--ci"] : []), + ...(flag("agents") ? ["--agents", flag("agents")!] : []), ]; const script = resolve(import.meta.dir, "..", "install.sh"); const proc = Bun.spawn(["bash", script, target, ...passthrough], { stdout: "inherit", stderr: "inherit" }); diff --git a/install.sh b/install.sh index 9cbf183..b1b7ad3 100755 --- a/install.sh +++ b/install.sh @@ -14,15 +14,20 @@ set -euo pipefail usage() { - echo "usage: install.sh [--dry-run] [--update] [--ci]" >&2 + echo "usage: install.sh [--dry-run] [--update] [--ci] [--agents a,b,c]" >&2 + echo " --agents links the skills into each agent's own dir: claude,codex,gemini,opencode,cursor,pi,mastracode" >&2 } TARGET="" DRY_RUN=0 UPDATE=0 CI=0 +AGENTS="" +NEXT_IS_AGENTS=0 for arg in "$@"; do + if [[ "$NEXT_IS_AGENTS" -eq 1 ]]; then AGENTS="$arg"; NEXT_IS_AGENTS=0; continue; fi case "$arg" in + --agents) NEXT_IS_AGENTS=1 ;; --dry-run) DRY_RUN=1 ;; --update) UPDATE=1 ;; --ci) CI=1 ;; @@ -128,6 +133,56 @@ else wrote=$((wrote + 1)) fi +# Skills stay in .claude/skills; each agent gets a symlink from its own dir. The map is +# pinned to the PRESETS registry by tests/install.test.ts. +agent_dirs() { + case "$1" in + claude) echo ".claude/skills CLAUDE.md" ;; + codex) echo ".codex/skills AGENTS.md" ;; + gemini) echo ".gemini/skills GEMINI.md" ;; + opencode) echo ".opencode/skills AGENTS.md" ;; + cursor) echo ".cursor/skills AGENTS.md" ;; + pi) echo ".pi/agent/skills AGENTS.md" ;; + mastracode) echo ".mastracode/skills AGENTS.md" ;; + *) return 1 ;; + esac +} + +if [[ -n "$AGENTS" ]]; then + IFS=',' read -r -a AGENT_LIST <<< "$AGENTS" + for agent in "${AGENT_LIST[@]}"; do + if ! read -r skills_dir context_file < <(agent_dirs "$agent"); then + echo "install.sh: unknown agent \"$agent\" (see --help)" >&2 + exit 1 + fi + [[ "$skills_dir" == ".claude/skills" ]] && continue + link="$TARGET/$skills_dir" + up="$(dirname "$skills_dir" | sed -E 's#[^/]+#..#g')" + if [[ -e "$link" || -L "$link" ]]; then + echo "skip (exists): $skills_dir" + skipped=$((skipped + 1)) + elif [[ "$DRY_RUN" -eq 1 ]]; then + echo "symlink: $skills_dir -> $up/.claude/skills" + wrote=$((wrote + 1)) + else + mkdir -p "$(dirname "$link")" + ln -s "$up/.claude/skills" "$link" + echo "symlinked: $skills_dir -> $up/.claude/skills" + wrote=$((wrote + 1)) + fi + context="$TARGET/$context_file" + if [[ "$context_file" != "CLAUDE.md" && ! -e "$context" && ! -L "$context" ]]; then + if [[ "$DRY_RUN" -eq 1 ]]; then + echo "write: $context_file" + else + printf '%s\n' "# Factory" "" "Factory skills are in .claude/skills/ (linked into $skills_dir)." "The repo charter is .factory/charter.md; read it before any stage." > "$context" + echo "wrote: $context_file" + fi + wrote=$((wrote + 1)) + fi + done +fi + if [[ "$CI" -eq 1 ]]; then CI_SRC="$SCRIPT_DIR/template-ci/factory.yml.example" CI_DEST_REL=".github/workflows/factory.yml.example" diff --git a/src/agent-dirs.ts b/src/agent-dirs.ts new file mode 100644 index 0000000..00242c1 --- /dev/null +++ b/src/agent-dirs.ts @@ -0,0 +1,41 @@ +// Ported from owainlewis/skills@e8cadb3 internal/agents/agents.go:1-60 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: the registry is built from PRESETS (each preset's skillsDir) plus upstream's hermes and agents entries, so a directory is written once; Go's New(overrides) becomes agentRegistry(overrides); no manifest parsing. + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { PRESETS } from "./agents/presets"; + +export interface AgentDirs { + readonly name?: string; + readonly global?: string; + readonly project?: string; +} + +export type Scope = "global" | "project"; + +// Selected when an install names no agents (agents.go DefaultTargets). +export const DEFAULT_TARGETS = ["agents", "claude", "hermes"] as const; + +function builtin(): Record { + const m: Record = { + hermes: { global: "~/.hermes/skills", project: ".hermes/skills" }, + agents: { global: "~/.agents/skills", project: ".agents/skills" }, + }; + for (const [name, p] of Object.entries(PRESETS)) m[name] = { global: `~/${p.skillsDir}`, project: p.skillsDir }; + return m; +} + +export function expandHome(path: string): string { + return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path; +} + +// The dir an agent reads skills from: under the project root, or the user's home. +export function skillsDirFor(agent: AgentDirs, scope: Scope, projectRoot: string): string { + return scope === "project" ? join(projectRoot, agent.project ?? "") : expandHome(agent.global ?? ""); +} + +// Built-ins merged with per-field overrides and additions. +export function agentRegistry(overrides: Record = {}): { get(name: string): (AgentDirs & { name: string }) | undefined } { + const m = builtin(); + for (const [name, ov] of Object.entries(overrides)) m[name] = { ...m[name], ...(ov.global ? { global: ov.global } : {}), ...(ov.project ? { project: ov.project } : {}) }; + return { get: (name) => (m[name] ? { ...m[name], name } : undefined) }; +} diff --git a/src/doctor.ts b/src/doctor.ts index 9bf9973..b0681b7 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -19,6 +19,8 @@ export interface DoctorDeps { readonly github: GitHub; readonly git: CommandRunner; readonly which: (bin: string) => Promise; + // ` --version` output, or undefined when it cannot be read. + readonly versionOf?: (bin: string) => Promise; readonly fileExists: (path: string) => Promise; readonly readFile: (path: string) => Promise; readonly isExecutable: (path: string) => Promise; @@ -60,6 +62,16 @@ export async function runDoctor(deps: DoctorDeps, ctx: DoctorContext): Promise { expect((await drift("new text")).ok).toBe(true); }); }); + +describe("runDoctor agent versions", () => { + const agents = { gemini: { preset: "gemini" } }; + const stages = { default: "gemini" } as const; + const withVersion = (found: string | undefined) => ({ ...deps({ which: new Set(["gh", "gemini", "python3", "jq"]) }), versionOf: async () => found }); + + test("warns, never fails, when the installed version is not the pin", async () => { + const checks = await runDoctor(withVersion("0.25.2"), { ...ctx, agents, stages }); + const v = checks.find((c) => c.name === "gemini is version 0.61.0")!; + expect([v.ok, v.warn]).toEqual([false, true]); + expect(v.detail).toContain("0.25.2"); + }); + + test("passes on the pin, and says when --version cannot be read", async () => { + const ok = await runDoctor(withVersion("0.61.0"), { ...ctx, agents, stages }); + expect(ok.find((c) => c.name === "gemini is version 0.61.0")!.ok).toBe(true); + const unreadable = await runDoctor(withVersion(undefined), { ...ctx, agents, stages }); + expect(unreadable.find((c) => c.name === "gemini is version 0.61.0")!.detail).toContain("could not read"); + }); + + test("every preset that is not verified live warns with the participant path", async () => { + const { PRESETS } = await import("../src/agents/presets"); + for (const preset of Object.values(PRESETS)) { + const d = { ...deps({ which: new Set(["gh", preset.binary, "python3", "jq"]) }), versionOf: async () => preset.version }; + const checks = await runDoctor(d, { ...ctx, agents: { a: { preset: preset.name } }, stages: { default: "a" } }); + const v = checks.find((c) => c.name === `agent "a" is verified`); + expect(v === undefined, preset.name).toBe(preset.verified); + expect(checks.filter((c) => !c.ok && !c.warn), preset.name).toEqual([]); + } + }); +}); + diff --git a/tests/install.test.ts b/tests/install.test.ts index 3513e79..13c0440 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -120,3 +120,40 @@ describe("install.sh scaffold", () => { rmSync(target, { recursive: true, force: true }); }); }); + +describe("install.sh --agents", () => { + const { PRESETS } = require("../src/agents/presets") as typeof import("../src/agents/presets"); + const { lstatSync, readlinkSync } = require("node:fs") as typeof import("node:fs"); + + test("every preset's skills dir is linked to .claude/skills and resolves to the real skills", () => { + const target = scratchTarget(); + const names = Object.keys(PRESETS); + const { code, stderr } = run([target, "--agents", names.join(",")]); + expect(code, stderr).toBe(0); + for (const p of Object.values(PRESETS)) { + if (p.skillsDir === ".claude/skills") continue; + expect(lstatSync(join(target, p.skillsDir)).isSymbolicLink(), p.skillsDir).toBe(true); + expect(existsSync(join(target, p.skillsDir, "factory-plan", "SKILL.md")), `${p.skillsDir} does not resolve (${readlinkSync(join(target, p.skillsDir))})`).toBe(true); + } + rmSync(target, { recursive: true, force: true }); + }); + + test("writes a context pointer per agent only when absent: GEMINI.md for gemini, AGENTS.md otherwise", () => { + const target = scratchTarget(); + writeFileSync(join(target, "AGENTS.md"), "mine"); + run([target, "--agents", "gemini,pi"]); + expect(readFileSync(join(target, "GEMINI.md"), "utf8")).toContain(".claude/skills"); + expect(readFileSync(join(target, "AGENTS.md"), "utf8")).toBe("mine"); + rmSync(target, { recursive: true, force: true }); + }); + + test("an unknown agent fails before anything is linked; --dry-run writes no links", () => { + const target = scratchTarget(); + expect(run([target, "--agents", "nope"]).code).toBe(1); + const dry = scratchTarget(); + run([dry, "--agents", "pi", "--dry-run"]); + expect(existsSync(join(dry, ".pi"))).toBe(false); + rmSync(target, { recursive: true, force: true }); + rmSync(dry, { recursive: true, force: true }); + }); +}); diff --git a/tests/ported/skills/agents.test.ts b/tests/ported/skills/agents.test.ts new file mode 100644 index 0000000..8032805 --- /dev/null +++ b/tests/ported/skills/agents.test.ts @@ -0,0 +1,30 @@ +// Ported from owainlewis/skills@e8cadb3 internal/agents/agents_test.go:9-46 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: TestResolvedDir and TestDefaultTargets are not in the upstream file at this SHA, so the resolver and DEFAULT_TARGETS get one case each written here; the registry is built from PRESETS. + +import { expect, test } from "bun:test"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_TARGETS, agentRegistry, skillsDirFor } from "../../../src/agent-dirs"; + +test("TestBuiltinAndDir", () => { + const a = agentRegistry().get("codex"); + expect(a).toBeDefined(); + expect(a!.name).toBe("codex"); + expect(skillsDirFor(a!, "global", "/proj")).toBe(join(homedir(), ".codex/skills")); + expect(skillsDirFor(a!, "project", "/proj")).toBe("/proj/.codex/skills"); +}); + +test("TestOverridesAndAdditions", () => { + const reg = agentRegistry({ + claude: { global: "~/.custom/claude" }, + myagent: { global: "~/.myagent/skills", project: ".myagent/skills" }, + }); + expect(reg.get("claude")!.global).toBe("~/.custom/claude"); + expect(reg.get("claude")!.project).toBe(".claude/skills"); + expect(reg.get("myagent")).toBeDefined(); + expect(reg.get("nope")).toBeUndefined(); +}); + +test("the default targets all resolve", () => { + const reg = agentRegistry(); + for (const t of DEFAULT_TARGETS) expect(reg.get(t), t).toBeDefined(); +}); From a5ddf0fb1c01671bfea177cf8f7bcc4ad1bdb0ec Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:13:31 +0300 Subject: [PATCH 3/9] v2.6.0 wip: docs, registry test, agents docs generator Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 22 ++++++++++- README.md | 6 ++- docs/agents.md | 66 ++++++++++++++++++++++++++++++++ docs/verify-an-agent.md | 17 ++++++++ scripts/gen-agents-doc.ts | 6 +++ src/agents/docs.ts | 36 +++++++++++++++++ src/agents/presets/claude.ts | 1 + src/agents/presets/codex.ts | 1 + src/agents/presets/cursor.ts | 1 + src/agents/presets/gemini.ts | 1 + src/agents/presets/mastracode.ts | 1 + src/agents/presets/opencode.ts | 1 + src/agents/presets/pi.ts | 1 + src/agents/record.ts | 18 +++++++-- src/agents/types.ts | 2 + src/help.ts | 2 +- template-ci/factory.yml.example | 7 ++++ tests/agents-registry.test.ts | 63 ++++++++++++++++++++++++++++++ tests/release.test.ts | 7 ---- tests/verify-agent.test.ts | 16 +++++++- 20 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 docs/agents.md create mode 100644 scripts/gen-agents-doc.ts create mode 100644 src/agents/docs.ts create mode 100644 tests/agents-registry.test.ts diff --git a/Dockerfile b/Dockerfile index dbebb0f..f3cbed1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,15 @@ FROM oven/bun:1.3.4-slim ARG GH_CLI_VERSION=2.63.2 ARG NODE_MAJOR=20 +# Which agent CLIs the image carries. Every version below equals its preset's `version` +# (tests/agents-registry.test.ts). Cursor has no versioned download, so it is host-only. +ARG AGENTS="claude codex gemini opencode" ARG CLAUDE_CODE_VERSION=2.1.281 +ARG CODEX_VERSION=0.156.1 +ARG GEMINI_VERSION=0.61.0 +ARG OPENCODE_VERSION=1.18.32 +ARG PI_VERSION=0.73.1 +ARG MASTRACODE_VERSION=0.42.0 ARG UV_VERSION=0.5.11 RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -29,11 +37,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ -o /tmp/uv.tar.gz \ && tar -xzf /tmp/uv.tar.gz -C /tmp \ && mv /tmp/uv-*/uv /tmp/uv-*/uvx /usr/local/bin/ \ - && npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ && rm -rf /tmp/gh* /tmp/uv* \ && apt-get purge -y curl gnupg xz-utils && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* +RUN set -e; for agent in $AGENTS; do \ + case "$agent" in \ + claude) pkg="@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" ;; \ + codex) pkg="@openai/codex@${CODEX_VERSION}" ;; \ + gemini) pkg="@google/gemini-cli@${GEMINI_VERSION}" ;; \ + opencode) pkg="opencode-ai@${OPENCODE_VERSION}" ;; \ + pi) pkg="@mariozechner/pi-coding-agent@${PI_VERSION}" ;; \ + mastracode) pkg="mastracode@${MASTRACODE_VERSION}" ;; \ + *) echo "unknown agent $agent (cursor is host-only)" >&2; exit 1 ;; \ + esac; \ + npm install -g "$pkg"; \ + done + WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile diff --git a/README.md b/README.md index ef26e79..f735bd1 100644 --- a/README.md +++ b/README.md @@ -247,11 +247,13 @@ The factory knows no agent by name. Each one is config, and `stages` says which } ``` -Presets: `claude`, `codex`. Setting both `preset` and `command` keeps the preset's event parser but runs your command. A `command` agent gets the stage skill plus an artifact contract on stdin, or +Presets: `"preset": "claude"`, `"preset": "codex"`, `"preset": "gemini"`, `"preset": "opencode"`, `"preset": "cursor"`, `"preset": "pi"`, `"preset": "mastracode"` (the +compatibility matrix, safety layers per agent and the bring-your-own walkthrough are in [docs/agents.md](docs/agents.md)). +A preset agent sees only its own provider API keys. Setting both `preset` and `command` keeps the preset's event parser but runs your command. A `command` agent gets the stage skill plus an artifact contract on stdin, or where `{{prompt}}` / `{{promptFile}}` appears, and writes its results as files under `$FACTORY_ARTIFACT_DIR`. It runs with no event parser: tokens show as "not reported" and the tool-call cap cannot be enforced, so the timeout is the backstop and `factory doctor` says so. A shell as the executable may not take `{{prompt}}` as an argument. The guard hook and -`--settings` rules are Claude-only. A preset-less agent inherits your environment (minus `GH_TOKEN`, `GITHUB_TOKEN`, `FACTORY_*`, repo `GIT_*`), including model API keys and `~/.config/gh`: run it in a sandbox until v3.0. The runner's diff check, gates and commit apply to every agent. +`--settings` rules are Claude-only. A preset-less agent inherits your environment, every provider key included (minus `GH_TOKEN`, `GITHUB_TOKEN`, `FACTORY_*`, repo `GIT_*`), including model API keys and `~/.config/gh`: run it in a sandbox until v3.0. The runner's diff check, gates and commit apply to every agent. Then bring the target repo up to speed and start the loop: diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..29c3154 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,66 @@ +# Agents + +The factory runs any coding agent that has a CLI. Seven have a built-in preset; anything else runs as a +custom `command`. Only Claude is verified live by the maintainers. The others are verified by +participants ([verify an agent](verify-an-agent.md)), so `factory doctor` warns until a real fixture +backs them. + + +| Agent | Binary | Pinned version | Verified live | Read-only stages held by | API keys it may see | Skills dir | Docker | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `claude` | `claude` | `2.1.281` | yes | the stage allow-list under `dontAsk` | `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` | `.claude/skills` | pinned | +| `codex` | `codex` | `0.156.1` | not yet | `-s read-only` | `OPENAI_API_KEY` | `.codex/skills` | pinned | +| `gemini` | `gemini` | `0.61.0` | not yet | `--approval-mode plan` | `GEMINI_API_KEY`, `GOOGLE_API_KEY` | `.gemini/skills` | pinned | +| `opencode` | `opencode` | `1.18.32` | not yet | nothing: `run` has no read-only mode, so only the stage prompt and the gate hold | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY` | `.opencode/skills` | pinned | +| `cursor` | `cursor-agent` | `2026.01.23-916f423` | not yet | `--mode plan` | `CURSOR_API_KEY` | `.cursor/skills` | host only | +| `pi` | `pi` | `0.73.1` | not yet | `--tools read,grep,find,ls` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY` | `.pi/agent/skills` | pinned | +| `mastracode` | `mastracode` | `0.42.0` | not yet | `--mode plan` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `XAI_API_KEY`, `DEEPSEEK_API_KEY` | `.mastracode/skills` | pinned | + + +The table is generated from the presets (`bun scripts/gen-agents-doc.ts`); a test fails when it drifts. + +## Choose agents + +`.factory/config.json` names the agents and which stage each one runs: + +```json +{ + "agents": { "claude": { "preset": "claude" }, "gemini": { "preset": "gemini", "model": "gemini-3-pro" } }, + "stages": { "default": "claude", "verify": "gemini" } +} +``` + +Install the skills into each agent's own directory with `factory install --agents claude,gemini`. +Skills stay in `.claude/skills`; the other directories are symlinks to it. A context pointer +(`AGENTS.md`, or `GEMINI.md` for Gemini) is written only when the file is absent. + +## What keeps a stage safe, per agent + +| Layer | Claude | Codex, Gemini, Cursor, Pi, Mastra Code | OpenCode | +| --- | --- | --- | --- | +| Read-only stages cannot edit files | allow-list | the flag in the table above; the runner writes the artifact | no: stage prompt and gate only | +| Path guard hook | yes | no | no | +| Tool-call cap and timeout | yes | yes | yes | +| Only its own API key in the env | yes | yes | yes (all multi-provider keys) | + +Everything else the runner does (the gate, plan approval, the verdict) is agent-independent. + +## Bring your own agent + +Any CLI that takes a prompt works as a `command`. Placeholders: `{{prompt}}`, `{{promptFile}}`, +`{{model}}`. With none, the prompt goes to stdin. The agent gets `FACTORY_ARTIFACT_DIR`, +`FACTORY_ISSUE`, `FACTORY_STAGE` and `FACTORY_SCRATCH_DIR`, and writes each stage's files into +`FACTORY_ARTIFACT_DIR`. Worked example with aider: + +```json +{ + "agents": { "aider": { "command": ["aider", "--yes-always", "--no-auto-commits", "--message-file", "{{promptFile}}"] } }, + "stages": { "default": "aider" } +} +``` + +With no preset the factory cannot read tokens or count tool calls, so usage shows "not reported" +and the timeout is the only cap. `factory doctor` says so. + +Cursor has no versioned download, so the Docker image cannot pin it: run it on the host. Mistral Vibe, +omp and Amp have no preset; use the `command` form. diff --git a/docs/verify-an-agent.md b/docs/verify-an-agent.md index c0417ff..16e30ad 100644 --- a/docs/verify-an-agent.md +++ b/docs/verify-an-agent.md @@ -23,3 +23,20 @@ It names the agents it skips, and it spends tokens, so `make check` never runs i The recorder replaces API keys and your home directory in the fixture. Read the files before you commit them. + +## Per agent + +Set the key the agent reads, then run step 3 with that name. `factory doctor` names the pinned CLI version. + +| Agent | Install | Key | +| --- | --- | --- | +| `codex` | `npm i -g @openai/codex@0.156.1` | `OPENAI_API_KEY` | +| `gemini` | `npm i -g @google/gemini-cli@0.61.0` | `GEMINI_API_KEY` | +| `opencode` | `npm i -g opencode-ai@1.18.32`, then `opencode auth login` | the provider's key | +| `cursor` | Cursor's own installer (no versioned download) | `CURSOR_API_KEY` or `cursor-agent login` | +| `pi` | `npm i -g @mariozechner/pi-coding-agent@0.73.1` | the provider's key | +| `mastracode` | `npm i -g mastracode@0.42.0` | the provider's key | + +The repo ships a synthetic fixture for each of these, written from the CLI's own docs. Your recording +replaces it. If the real events do not match the parser, the replay test fails: that is the bug to report. + diff --git a/scripts/gen-agents-doc.ts b/scripts/gen-agents-doc.ts new file mode 100644 index 0000000..f86b9c0 --- /dev/null +++ b/scripts/gen-agents-doc.ts @@ -0,0 +1,6 @@ +// Rewrites the generated table in docs/agents.md from PRESETS. +import { readFileSync, writeFileSync } from "node:fs"; +import { renderAgentsDoc } from "../src/agents/docs"; + +const path = new URL("../docs/agents.md", import.meta.url); +writeFileSync(path, renderAgentsDoc(readFileSync(path, "utf8"))); diff --git a/src/agents/docs.ts b/src/agents/docs.ts new file mode 100644 index 0000000..0c7c949 --- /dev/null +++ b/src/agents/docs.ts @@ -0,0 +1,36 @@ +// docs/agents.md carries a table generated from PRESETS; tests/agents-registry.test.ts fails +// when the two disagree, and `bun scripts/gen-agents-doc.ts` rewrites it. + +import { PRESETS } from "./presets"; + +export const TABLE_START = ""; +export const TABLE_END = ""; + +export function agentsTable(): string { + const rows = Object.values(PRESETS).map((p) => + [ + `\`${p.name}\``, + `\`${p.binary}\``, + `\`${p.version}\``, + p.verified ? "yes" : "not yet", + p.readOnlyBy, + p.envKeys.map((k) => `\`${k}\``).join(", "), + `\`${p.skillsDir}\``, + p.docker === false ? "host only" : "pinned", + ].join(" | "), + ); + return [ + "Agent | Binary | Pinned version | Verified live | Read-only stages held by | API keys it may see | Skills dir | Docker", + "--- | --- | --- | --- | --- | --- | --- | ---", + ...rows, + ] + .map((l) => `| ${l} |`) + .join("\n"); +} + +export function renderAgentsDoc(doc: string): string { + const start = doc.indexOf(TABLE_START); + const end = doc.indexOf(TABLE_END); + if (start < 0 || end < start) throw new Error("docs/agents.md has no agents-table markers"); + return `${doc.slice(0, start + TABLE_START.length)}\n${agentsTable()}\n${doc.slice(end)}`; +} diff --git a/src/agents/presets/claude.ts b/src/agents/presets/claude.ts index 7243adb..eda46c4 100644 --- a/src/agents/presets/claude.ts +++ b/src/agents/presets/claude.ts @@ -100,6 +100,7 @@ export const claudePreset: AgentPreset = { verified: true, version: "2.1.281", envKeys: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + readOnlyBy: "the stage allow-list under `dontAsk`", skillsDir: ".claude/skills", contextFile: "CLAUDE.md", ownsPrompt: true, diff --git a/src/agents/presets/codex.ts b/src/agents/presets/codex.ts index f3dc902..032c0b2 100644 --- a/src/agents/presets/codex.ts +++ b/src/agents/presets/codex.ts @@ -43,6 +43,7 @@ export const codexPreset: AgentPreset = { verified: false, version: "0.156.1", envKeys: ["OPENAI_API_KEY"], + readOnlyBy: "`-s read-only`", skillsDir: ".codex/skills", contextFile: "AGENTS.md", returnsArtifact: true, diff --git a/src/agents/presets/cursor.ts b/src/agents/presets/cursor.ts index 126d1df..536d612 100644 --- a/src/agents/presets/cursor.ts +++ b/src/agents/presets/cursor.ts @@ -41,6 +41,7 @@ export const cursorPreset: AgentPreset = { verified: false, version: "2026.01.23-916f423", envKeys: ["CURSOR_API_KEY"], + readOnlyBy: "`--mode plan`", skillsDir: ".cursor/skills", contextFile: "AGENTS.md", // Cursor's installer has no versioned download URL, so the Docker image cannot pin it. diff --git a/src/agents/presets/gemini.ts b/src/agents/presets/gemini.ts index fd66a31..7d760f6 100644 --- a/src/agents/presets/gemini.ts +++ b/src/agents/presets/gemini.ts @@ -51,6 +51,7 @@ export const geminiPreset: AgentPreset = { verified: false, version: "0.61.0", envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + readOnlyBy: "`--approval-mode plan`", skillsDir: ".gemini/skills", contextFile: "GEMINI.md", returnsArtifact: true, diff --git a/src/agents/presets/mastracode.ts b/src/agents/presets/mastracode.ts index 03bef4d..036a3b5 100644 --- a/src/agents/presets/mastracode.ts +++ b/src/agents/presets/mastracode.ts @@ -66,6 +66,7 @@ export const mastracodePreset: AgentPreset = { verified: false, version: "0.42.0", envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY"], + readOnlyBy: "`--mode plan`", skillsDir: ".mastracode/skills", contextFile: "AGENTS.md", returnsArtifact: true, diff --git a/src/agents/presets/opencode.ts b/src/agents/presets/opencode.ts index 28ed190..01b6af3 100644 --- a/src/agents/presets/opencode.ts +++ b/src/agents/presets/opencode.ts @@ -47,6 +47,7 @@ export const opencodePreset: AgentPreset = { verified: false, version: "1.18.32", envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY"], + readOnlyBy: "nothing: `run` has no read-only mode, so only the stage prompt and the gate hold", skillsDir: ".opencode/skills", contextFile: "AGENTS.md", command: (opts, agent, prompt) => ({ diff --git a/src/agents/presets/pi.ts b/src/agents/presets/pi.ts index 5e92596..85e64d4 100644 --- a/src/agents/presets/pi.ts +++ b/src/agents/presets/pi.ts @@ -48,6 +48,7 @@ export const piPreset: AgentPreset = { verified: false, version: "0.73.1", envKeys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY"], + readOnlyBy: "`--tools read,grep,find,ls`", skillsDir: ".pi/agent/skills", contextFile: "AGENTS.md", returnsArtifact: true, diff --git a/src/agents/record.ts b/src/agents/record.ts index 32e65c7..4d90bb1 100644 --- a/src/agents/record.ts +++ b/src/agents/record.ts @@ -1,7 +1,7 @@ // Records an agent's raw stdout, scrubbed, so a participant's `factory verify-agent` // run becomes a replayable fixture (tests/fixtures/agents//.jsonl). -import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -28,11 +28,23 @@ export class FixtureRecorder { private readonly paths: readonly string[] = [homedir()], ) { mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "fixture.json"), JSON.stringify({ agent, synthetic: false }, null, 2) + "\n"); + // Keep extra keys (Cursor's reportsUsage: false); a recording is never synthetic. + const path = join(dir, "fixture.json"); + const prior = existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Record) : {}; + writeFileSync(path, JSON.stringify({ ...prior, agent, synthetic: false }, null, 2) + "\n"); } + // The first line of a stage replaces any file already there (a synthetic fixture), later lines append. + private readonly started = new Set(); + line(stage: string, raw: string): void { if (!raw.trim()) return; - appendFileSync(join(this.dir, `${stage}.jsonl`), scrubLine(raw, this.env, this.paths) + "\n"); + const file = join(this.dir, `${stage}.jsonl`); + const scrubbed = scrubLine(raw, this.env, this.paths) + "\n"; + if (this.started.has(stage)) appendFileSync(file, scrubbed); + else { + this.started.add(stage); + writeFileSync(file, scrubbed); + } } } diff --git a/src/agents/types.ts b/src/agents/types.ts index 38bfae2..4df123b 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -48,6 +48,8 @@ export interface AgentPreset { readonly version: string; // Provider keys this agent may see. Every other known provider key is stripped from its env. readonly envKeys: readonly string[]; + // What keeps a read-only stage read-only for this agent (shown in docs/agents.md). + readonly readOnlyBy: string; // Project directory `install --agents` links the skills into, and the context file it points at. readonly skillsDir: string; readonly contextFile: string; diff --git a/src/help.ts b/src/help.ts index d1f8398..3d75a98 100644 --- a/src/help.ts +++ b/src/help.ts @@ -15,7 +15,7 @@ export const COMMANDS: { name: string; usage: string; does: string }[] = [ { name: "rebaseline", usage: "rebaseline --repo-dir [--dry-run]", does: "move the baseline tag to origin/, keeping merged setup changes across reset" }, { name: "doctor", usage: "doctor --repo-dir [--fix]", does: "check the loop can run; --fix creates missing labels" }, { name: "verify-agent", usage: "verify-agent --repo-dir --issue [--out ]", does: "run one issue on that agent, record a scrubbed fixture, print pass/fail, cost and tokens" }, - { name: "install", usage: "install [--dry-run] [--update] [--ci]", does: "install or update the template in a repo" }, + { name: "install", usage: "install [--dry-run] [--update] [--ci] [--agents a,b,c]", does: "install or update the template in a repo; --agents links skills into each agent dir" }, ]; export const OPTIONS: [string, string][] = [ diff --git a/template-ci/factory.yml.example b/template-ci/factory.yml.example index 36827bb..d5bab85 100644 --- a/template-ci/factory.yml.example +++ b/template-ci/factory.yml.example @@ -38,6 +38,13 @@ env: FACTORY_RUNNER_REF: v2.5.2 # pinned software-factory release; bump deliberately FACTORY_RUNNER_REPO: learnwithparam/software-factory CLAUDE_CODE_VERSION: "2.1.281" # pinned claude, same version the Dockerfile installs + # Pins for the other agents. For each one your config uses, add `npm install -g @` + # beside the claude install step and its API key beside ANTHROPIC_API_KEY (docs/agents.md lists both). + CODEX_VERSION: "0.156.1" + GEMINI_VERSION: "0.61.0" + OPENCODE_VERSION: "1.18.32" + PI_VERSION: "0.73.1" + MASTRACODE_VERSION: "0.42.0" # One workflow run in flight per issue at a time; a second labeled/comment # event on the same issue queues behind it instead of racing a second diff --git a/tests/agents-registry.test.ts b/tests/agents-registry.test.ts new file mode 100644 index 0000000..d31fc9e --- /dev/null +++ b/tests/agents-registry.test.ts @@ -0,0 +1,63 @@ +// One loop over PRESETS: adding a preset without its fixture, pin, install target, docs row and README +// mention fails here. docs/agents.md's table is generated, so it must equal the registry. +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { PRESETS } from "../src/agents/presets"; +import { agentsTable, renderAgentsDoc } from "../src/agents/docs"; + +const root = join(import.meta.dir, ".."); +const read = (p: string) => readFileSync(join(root, p), "utf8"); +const dockerVar = (name: string) => (name === "claude" ? "CLAUDE_CODE_VERSION" : `${name.toUpperCase()}_VERSION`); + +describe("every preset is fully registered", () => { + const docker = read("Dockerfile"); + const ci = read("template-ci/factory.yml.example"); + const installSh = read("install.sh"); + const agentsDoc = read("docs/agents.md"); + const readme = read("README.md"); + const runbook = read("docs/verify-an-agent.md"); + + for (const p of Object.values(PRESETS)) { + describe(p.name, () => { + test("has a fixture dir with a jsonl and a fixture.json", () => { + const dir = join(root, "tests/fixtures/agents", p.name); + expect(existsSync(dir)).toBe(true); + expect(readdirSync(dir).some((f) => f.endsWith(".jsonl"))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, "fixture.json"), "utf8"))).toHaveProperty("synthetic"); + }); + test("declares binary, version, envKeys, skills dir and how a read-only stage is held", () => { + expect(p.binary).toBeTruthy(); + expect(p.version).toMatch(/^\d/); + expect(p.envKeys.length).toBeGreaterThan(0); + expect(p.skillsDir).toMatch(/^\.[a-z]+.*\/skills$/); + expect(p.readOnlyBy.length).toBeGreaterThan(0); + }); + test("the Dockerfile and the CI template pin its version, or it says docker: false", () => { + if (p.docker === false) { + expect(docker).not.toContain(`ARG ${dockerVar(p.name)}=`); + return; + } + expect(new RegExp(`ARG ${dockerVar(p.name)}=${p.version.replaceAll(".", "\\.")}\\s`).test(docker)).toBe(true); + expect(ci).toContain(`${dockerVar(p.name)}: "${p.version}"`); + }); + test("install.sh links its skills dir", () => { + expect(installSh).toContain(`${p.name}) echo "${p.skillsDir} `); + }); + test("docs/agents.md and the README mention it, and the runbook covers it until it is verified", () => { + expect(agentsDoc).toContain(`| \`${p.name}\` |`); + expect(readme).toContain(`"preset": "${p.name}"`); + if (!p.verified) expect(runbook).toContain(`\`${p.name}\``); + }); + }); + } + + test("docs/agents.md's generated table equals the registry", () => { + expect(agentsDoc).toContain(agentsTable()); + expect(renderAgentsDoc(agentsDoc)).toBe(agentsDoc); + }); + + test("the version and secrets in the CI template are complete", () => { + for (const p of Object.values(PRESETS)) if (p.docker !== false) expect(ci).toContain(dockerVar(p.name)); + }); +}); diff --git a/tests/release.test.ts b/tests/release.test.ts index e05c812..3c7e26b 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -13,13 +13,6 @@ test("template-ci FACTORY_RUNNER_REF is v", () => { expect(ref).toBe(`v${version}`); }); -test("the Dockerfile and the CI template pin the same Claude Code version", () => { - const docker = /ARG CLAUDE_CODE_VERSION=(\S+)/.exec(read("Dockerfile"))?.[1]; - const ci = /CLAUDE_CODE_VERSION: "([^"]+)"/.exec(read("template-ci/factory.yml.example"))?.[1]; - expect(docker).toMatch(/^\d+\.\d+\.\d+$/); - expect(ci).toBe(docker); -}); - test("the README describes only what exists: every label is listed and no monitor stage is claimed", () => { const readme = read("README.md"); for (const label of Object.values(LABEL)) expect(readme).toContain(`\`${label}\``); diff --git a/tests/verify-agent.test.ts b/tests/verify-agent.test.ts index 05b37cb..0f306f3 100644 --- a/tests/verify-agent.test.ts +++ b/tests/verify-agent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { cpSync, existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { cpSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CommandExecutor } from "../src/agents/executor"; @@ -29,7 +29,7 @@ describe("verify-agent", () => { }); test("the matrix runs installed presets and names the skipped", async () => { - expect(await matrixPlan(async (bin) => bin === "claude")).toEqual({ run: ["claude"], skipped: ["codex"] }); + expect(await matrixPlan(async (bin) => bin === "claude")).toEqual({ run: ["claude"], skipped: Object.keys(PRESETS).filter((n) => n !== "claude") }); }); test("scrubbing removes env secrets, key shapes and home paths", () => { @@ -72,4 +72,16 @@ describe("verify-agent", () => { expect(runbook).toContain("verify-agent"); expect(runbook).toContain("agent-matrix"); }); + + test("a recording replaces a synthetic fixture instead of appending to it, and keeps extra keys", () => { + const dir = mkdtempSync(join(tmpdir(), "rec-")); + writeFileSync(join(dir, "triage.jsonl"), "synthetic\n"); + writeFileSync(join(dir, "fixture.json"), JSON.stringify({ agent: "x", synthetic: true, reportsUsage: false })); + const rec = new FixtureRecorder(dir, "x", {}); + rec.line("triage", "one"); + rec.line("triage", "two"); + expect(readFileSync(join(dir, "triage.jsonl"), "utf8")).toBe("one\ntwo\n"); + expect(JSON.parse(readFileSync(join(dir, "fixture.json"), "utf8"))).toEqual({ agent: "x", synthetic: false, reportsUsage: false }); + }); }); + From 481332d3926d4dfd50bed873e9da6c1985ba10e1 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:14:36 +0300 Subject: [PATCH 4/9] v2.6.0 wip: cursor prompt-in-argv test --- tests/agents.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 677f56e..59bbc71 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -252,7 +252,7 @@ describe("every preset is diagnosable", () => { expect(preset.parseLine("")).toEqual([]); const inv = preset.command({ stage: "plan", issue: 1, cwd: "/w", maxBudgetUsd: 1 }, { preset: name }, "SECRET-PROMPT"); expect(inv.argv[0]).toBe(preset.binary); - if (!preset.ownsPrompt) expect(inv.argv.join(" ")).not.toContain("SECRET-PROMPT"); + if (!preset.ownsPrompt && inv.stdin !== undefined) expect(inv.argv.join(" ")).not.toContain("SECRET-PROMPT"); } }); }); From efcf9c29c7535eed7a0378759ca8e87572dedca3 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:15:44 +0300 Subject: [PATCH 5/9] v2.6.0 wip: docs-links port, factory-operator skill Co-Authored-By: Claude Sonnet 5 --- THIRD_PARTY_NOTICES.md | 7 ++ src/docs-links.ts | 108 ++++++++++++++++++ .../.claude/skills/factory-operator/SKILL.md | 59 ++++++++++ tests/docs-links.test.ts | 10 ++ tests/ported/blueprint/check_repo.test.ts | 54 +++++++++ 5 files changed, 238 insertions(+) create mode 100644 src/docs-links.ts create mode 100644 template/.claude/skills/factory-operator/SKILL.md create mode 100644 tests/docs-links.test.ts create mode 100644 tests/ported/blueprint/check_repo.test.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c88b23b..01f1871 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -24,6 +24,7 @@ Source: https://github.com/owainlewis/machinist (MIT, Copyright (c) 2026 Owain L - `src/agents/presets/codex.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/usage.test.ts`] - `src/agents/usage.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/usage.test.ts`] - `src/artifacts.ts` from `internal/protocol/workflow.go` [tested by `tests/ported/machinist/workflow.test.ts`] +- `template/.claude/skills/factory-operator/SKILL.md` from `skills/machinist/SKILL.md` [no upstream test] ## owainlewis/assembler@7cac671 @@ -41,6 +42,12 @@ Source: https://github.com/owainlewis/skills (MIT, Copyright (c) 2026 Owain Lewi - `src/agent-dirs.ts` from `internal/agents/agents.go` [tested by `tests/ported/skills/agents.test.ts`] +## owainlewis/blueprint@54c952b + +Source: https://github.com/owainlewis/blueprint (MIT, Copyright (c) 2026 Owain Lewis) + +- `src/docs-links.ts` from `scripts/check_repo.py` [tested by `tests/ported/blueprint/check_repo.test.ts`] + ## License text (both projects) MIT License diff --git a/src/docs-links.ts b/src/docs-links.ts new file mode 100644 index 0000000..f7c78d2 --- /dev/null +++ b/src/docs-links.ts @@ -0,0 +1,108 @@ +// Ported from owainlewis/blueprint@54c952b scripts/check_repo.py:58-161 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: only the Markdown checks; the skill-frontmatter check is covered by skills-ref; Python's Path.rglob becomes a directory walk that also skips dist. +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const IGNORED_PARTS = new Set([".cache", ".git", "node_modules", "dist", ".worktrees"]); +const LINK_PATTERN = /!?\[[^\]]*\]\(([^)]+)\)/g; +const REFERENCE_USE_PATTERN = /!?\[([^\]]+)\]\[([^\]]*)\]/g; +const REFERENCE_PATTERN = /^ {0,3}\[(?!\^)([^\]]+)\]:[ \t]*(?:<([^>]+)>|(\S+))/gm; +const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; +const INLINE_CODE_PATTERN = /(`+)[\s\S]*?\1/g; + +export function repositoryFiles(suffix: string, root: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (IGNORED_PARTS.has(e.name)) continue; + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(suffix)) out.push(p); + } + }; + walk(root); + return out.sort(); +} + +export function hasUnbalancedFence(text: string): boolean { + let fence: [string, number] | null = null; + for (const line of text.split(/\r?\n/)) { + const m = FENCE_PATTERN.exec(line); + if (!m) continue; + const marker = m[1]!; + if (fence === null) { + fence = [marker[0]!, marker.length]; + continue; + } + if (marker[0] === fence[0] && marker.length >= fence[1] && !m[2]!.trim()) fence = null; + } + return fence !== null; +} + +export function markdownWithoutCode(text: string): string { + const visible: string[] = []; + let fence: [string, number] | null = null; + for (const line of text.split(/\r?\n/)) { + const m = FENCE_PATTERN.exec(line); + if (fence !== null) { + if (m && m[1]![0] === fence[0] && m[1]!.length >= fence[1] && !m[2]!.trim()) fence = null; + visible.push(""); + continue; + } + if (m) { + fence = [m[1]![0]!, m[1]!.length]; + visible.push(""); + continue; + } + visible.push(line.startsWith(" ") || line.startsWith("\t") ? "" : line); + } + return visible.join("\n").replace(INLINE_CODE_PATTERN, ""); +} + +export function localTargets(text: string): string[] { + const visible = markdownWithoutCode(text); + const targets = [...visible.matchAll(LINK_PATTERN)].map((m) => m[1]!); + for (const m of visible.matchAll(REFERENCE_PATTERN)) targets.push(m[2] !== undefined ? `<${m[2]}>` : m[3]!); + return targets; +} + +const normalizeLabel = (label: string) => label.split(/\s+/).filter(Boolean).join(" ").toLowerCase(); + +export function missingReferenceDefinitions(text: string): string[] { + const visible = markdownWithoutCode(text); + const defined = new Set([...visible.matchAll(REFERENCE_PATTERN)].map((m) => normalizeLabel(m[1]!))); + const missing: string[] = []; + for (const m of visible.matchAll(REFERENCE_USE_PATTERN)) { + const label = m[2] || m[1]!; + if (!defined.has(normalizeLabel(label))) missing.push(label); + } + return missing; +} + +export function linkDestination(raw: string): string { + const target = raw.trim(); + if (target.startsWith("<")) { + const closing = target.indexOf(">", 1); + return closing !== -1 ? target.slice(1, closing) : target.slice(1); + } + return target.split(/\s+/)[0]!; +} + +export function checkMarkdown(errors: string[], root: string): void { + for (const path of repositoryFiles(".md", root)) { + const rel = relative(root, path); + const text = readFileSync(path, "utf8"); + if (hasUnbalancedFence(text)) errors.push(`Unbalanced fenced code block: ${rel}`); + for (const label of missingReferenceDefinitions(text)) errors.push(`Missing reference definition in ${rel}: ${label}`); + for (const raw of localTargets(text)) { + const target = linkDestination(raw); + if (/^(https?:|mailto:|#)/.test(target)) continue; + let file = target.split("#", 1)[0]!.split("?", 1)[0]!; + try { + file = decodeURIComponent(file); + } catch {} + if (!file) continue; + const resolved = file.startsWith("/") ? resolve(root, file.replace(/^\/+/, "")) : resolve(dirname(path), file); + if (!existsSync(resolved)) errors.push(`Missing local link in ${rel}: ${raw}`); + } + } +} diff --git a/template/.claude/skills/factory-operator/SKILL.md b/template/.claude/skills/factory-operator/SKILL.md new file mode 100644 index 0000000..91616e8 --- /dev/null +++ b/template/.claude/skills/factory-operator/SKILL.md @@ -0,0 +1,59 @@ +--- +name: factory-operator +description: Use the software factory to create, assign and monitor a task. Use when a coding agent needs to hand work to the factory, label an issue factory:ready, or read what the factory is waiting on. Not used by the factory's own stages. +--- + + +# Factory operator + +The factory turns a GitHub issue into a planned, built, independently verified pull +request. It never merges the pull request. + +## Core model + +- A task is one GitHub issue in the target repository. +- Assigning a task means labelling the issue `factory:ready`. The watcher picks it up. +- Label the same issue again to continue interrupted work. The factory reuses the existing + branch, worktree and pull request. +- When `FACTORY_ISSUE` is set, you are already inside a factory run. Follow the assigned + stage and do not label or start another run. + +## Create a task + +Reuse a supplied issue when it is open and belongs to the current repository. Otherwise +create one issue with `gh issue create`. Keep it focused on one observable outcome and keep +the user's constraints. Do not invent implementation details the request does not decide. + +```sh +gh issue create --title "" --body "" +``` + +Use the issue URL that GitHub returns for every later command. + +## Assign a task + +```sh +gh issue edit --add-label factory:ready +``` + +The label has no effect unless a watcher runs for this repository (`factory doctor` says +whether the loop can run). + +## Report status + +```sh +factory inbox --repo --json +factory logs --repo --json +``` + +The inbox lists what waits for a human. The logs show one run's events, one JSON object per +line. Then read the linked pull request and its checks. + +- For blocked work, fix the reported cause or answer the question, then comment + `/factory retry` on the issue. +- For completed work, hand the pull request to a person. Never merge unless that person + explicitly decides to. +- Approving a plan (`/factory approve`) is a human decision. Do not post it on their behalf. + +When reporting status, include the issue URL, the pull request URL when there is one, the +checks, and the blocker or next human action. diff --git a/tests/docs-links.test.ts b/tests/docs-links.test.ts new file mode 100644 index 0000000..3973d9f --- /dev/null +++ b/tests/docs-links.test.ts @@ -0,0 +1,10 @@ +// Every Markdown file in the repo has balanced fences and no dead local links. +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { checkMarkdown } from "../src/docs-links"; + +test("every *.md in the repo passes the blueprint Markdown checks", () => { + const errors: string[] = []; + checkMarkdown(errors, join(import.meta.dir, "..")); + expect(errors).toEqual([]); +}); diff --git a/tests/ported/blueprint/check_repo.test.ts b/tests/ported/blueprint/check_repo.test.ts new file mode 100644 index 0000000..cb9542b --- /dev/null +++ b/tests/ported/blueprint/check_repo.test.ts @@ -0,0 +1,54 @@ +// Ported from owainlewis/blueprint@54c952b scripts/tests/test_check_repo.py:13-68 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: unittest becomes bun:test; the skill-frontmatter cases are not ported. +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { checkMarkdown } from "../../../src/docs-links"; + +function check(markdown: string, files: [string, string][] = []): string[] { + const root = mkdtempSync(join(tmpdir(), "docs-links-")); + writeFileSync(join(root, "README.md"), markdown); + for (const [name, content] of files) { + mkdirSync(dirname(join(root, name)), { recursive: true }); + writeFileSync(join(root, name), content); + } + const errors: string[] = []; + checkMarkdown(errors, root); + return errors; +} + +test("a missing reference-style link is reported", () => { + expect(check("Read the [guide][details].\n\n[details]: missing.md\n").some((e) => e.includes("missing.md"))).toBe(true); +}); + +test("a missing reference definition is reported", () => { + expect(check("Read the [guide][details].\n").some((e) => e.includes("Missing reference definition"))).toBe(true); +}); + +test("an indented backtick fence must close", () => { + expect(check(" ```python\nprint('hello')\n").some((e) => e.includes("Unbalanced fenced code block"))).toBe(true); +}); + +test("a tilde fence must close", () => { + expect(check("~~~text\nhello\n").some((e) => e.includes("Unbalanced fenced code block"))).toBe(true); +}); + +test("a matching longer fence closes", () => { + expect(check("~~~text\nhello\n~~~~\n")).toEqual([]); +}); + +test("an inline-code link is ignored", () => { + expect(check("Example: `[guide](missing.md)`\n")).toEqual([]); +}); + +test("a fenced-code link is ignored", () => { + expect(check("```markdown\n[guide](missing.md)\n```\n")).toEqual([]); +}); + +test("an angle-bracket link preserves spaces", () => { + expect(check("Read the [guide]().\n", [["docs/my guide.md", "fixture\n"]])).toEqual([]); +}); + +test("ignored cache markdown is not checked", () => { + expect(check("# Readme\n", [[".cache/generated.md", "[missing](nope.md)\n"]])).toEqual([]); +}); From b5b5677e39ec29063838519379e4ce60b15e32e6 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:17:53 +0300 Subject: [PATCH 6/9] v2.6.0 wip: Agents page, /api/agents, catalog tests Co-Authored-By: Claude Sonnet 5 --- bin/factory | 11 ++++--- dashboard/public/app.js | 18 +++++++++-- dashboard/server.ts | 10 +++++- src/agents/docs.ts | 34 +++++++++++++++++++++ tests/agents-catalog.test.ts | 30 ++++++++++++++++++ tests/ported/machinist/catalog-copy.test.ts | 18 +++++++++++ tests/visual-system.test.ts | 2 +- 7 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 tests/agents-catalog.test.ts create mode 100644 tests/ported/machinist/catalog-copy.test.ts diff --git a/bin/factory b/bin/factory index 58e0864..615a7e5 100755 --- a/bin/factory +++ b/bin/factory @@ -318,10 +318,10 @@ async function cmdDoctor(): Promise { // and remoteAddress passed through so handle() can enforce it. Shared by // `dashboard` (cockpit only, any mode) and `up` (Docker's single-process // entrypoint: watch + dashboard together). -function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false): number { +function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, fleet?: Pick): number { const port = Number(flag("port") ?? process.env.FACTORY_DASHBOARD_PORT ?? 4100); const hostname = process.env.FACTORY_DASHBOARD_HOST ?? "127.0.0.1"; - const dashboard = createDashboard(state, github, repo, autoApproveDefault); + const dashboard = createDashboard(state, github, repo, autoApproveDefault, undefined, fleet); const server = Bun.serve({ port, hostname, @@ -334,10 +334,11 @@ function serveDashboard(state: FactoryState, github: GitHub, repo: string, autoA async function cmdDashboard(): Promise { const dbPath = flag("db") ?? process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH; const repoDir = flag("repo-dir"); - const repo = flag("repo") ?? process.env.FACTORY_REPO ?? (repoDir ? (await loadConfig(resolve(repoDir))).repo : ""); + const config = repoDir ? await loadConfig(resolve(repoDir)) : undefined; + const repo = flag("repo") ?? process.env.FACTORY_REPO ?? config?.repo ?? ""; const state = new FactoryState(dbPath); const github = new GitHub(); - serveDashboard(state, github, repo); + serveDashboard(state, github, repo, false, config); await new Promise(() => {}); // keep the process alive } @@ -356,7 +357,7 @@ async function cmdUp(): Promise { if (r.paused) console.log(`factory up: paused — ${r.reason}`); else if (r.processed.length) console.log(`factory up: processed #${r.processed.join(", #")}`); }); - serveDashboard(deps.state, deps.github, config.repo, config.riskPolicy.autoApproveLowRisk); + serveDashboard(deps.state, deps.github, config.repo, config.riskPolicy.autoApproveLowRisk, config); console.log(`factory up: polling ${config.repo} every ${config.pollIntervalSeconds}s. Ctrl+C to stop.`); await new Promise(() => {}); // keep the process alive } diff --git a/dashboard/public/app.js b/dashboard/public/app.js index decb341..9c36c80 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -17,7 +17,7 @@ const ICONS = { agents: "M8 8h8v8H8zM4 10v4M20 10v4M10 4h4M10 20h4", theme: "M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9z", }; -const NAV = [["inbox", "Inbox"], ["line", "Line"], ["runs", "Runs"], ["analytics", "Analytics"]]; +const NAV = [["inbox", "Inbox"], ["line", "Line"], ["runs", "Runs"], ["analytics", "Analytics"], ["agents", "Agents"]]; const state = { route: routeFromHash(location.hash), inbox: [], repo: "", selected: null, thread: null, filter: "all", data: {}, error: {} }; @@ -246,9 +246,21 @@ function analyticsView() { bars("Cost by stage", a.byStage), bars("Cost by agent", a.byAgent)))); } +/* ---- Agents ---- */ +function agentsView() { + return h("section", null, heading("Agents", "The coding agents the factory can run, and which stages each one serves."), + stateOr("agents", (a) => !a.agents.length ? quiet("No agents configured", "Add one under agents in .factory/config.json.") : h("div", { class: "table-wrap" }, h("table", null, + h("thead", null, h("tr", null, ["Agent", "Command", "Pinned version", "Verified live", "Stages"].map((c) => h("th", null, c)))), + h("tbody", null, a.agents.map((r) => h("tr", null, + h("td", null, h("span", { class: "status", "data-tone": r.configured ? "ok" : "" }, r.name), r.configured ? null : h("span", { class: "muted" }, " not configured")), + h("td", null, r.binary), h("td", null, r.pin || "Not pinned"), + h("td", null, h("span", { class: "status", "data-tone": r.verified ? "ok" : "warn" }, r.verified ? "Verified" : "Verified by participants: not yet")), + h("td", null, r.stages.length ? r.stages.join(", ") : "None")))))))); +} + /* ---- shell ---- */ -const VIEWS = { inbox: inboxView, line: lineView, runs: runsView, task: runsView, analytics: analyticsView }; -const LOADERS = { line: ["line", "/api/line"], runs: ["runs", "/api/runs"], task: ["runs", "/api/runs"], analytics: ["analytics", "/api/analytics"] }; +const VIEWS = { inbox: inboxView, line: lineView, runs: runsView, task: runsView, analytics: analyticsView, agents: agentsView }; +const LOADERS = { line: ["line", "/api/line"], runs: ["runs", "/api/runs"], task: ["runs", "/api/runs"], analytics: ["analytics", "/api/analytics"], agents: ["agents", "/api/agents"] }; function renderNav() { const nav = document.getElementById("nav"); diff --git a/dashboard/server.ts b/dashboard/server.ts index 77a683d..fad8511 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -17,6 +17,8 @@ import { buildBoard } from "./board"; import { LABEL } from "../src/labels"; import { InboxError, act, buildInbox, type InboxAction } from "../src/inbox"; import { plain } from "../src/display"; +import { agentCatalog } from "../src/agents/docs"; +import { DEFAULT_CONFIG, type FactoryConfig } from "../src/config"; import { workspacesDir } from "../src/paths"; import { runDir } from "../src/artifacts"; import { analytics } from "./analytics"; @@ -76,7 +78,7 @@ function plainIssue ({ ...c, body: plain(c.body) })) }; } -export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, workspaces = workspacesDir()) { +export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, workspaces = workspacesDir(), fleet: Pick = DEFAULT_CONFIG) { const indexHtml = readFileSync(join(here, "public", "index.html"), "utf8"); const sessions = new Map(); @@ -297,6 +299,12 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin label: "GET /api/analytics", handler: () => json(analytics(state.listRuns(repo || undefined), repo ? allStageRuns() : [])), }, + { + method: "GET", + pattern: /^\/api\/agents$/, + label: "GET /api/agents", + handler: () => json({ agents: agentCatalog(fleet.agents, fleet.stages).map((a) => ({ ...a, name: plain(a.name), binary: plain(a.binary) })) }), + }, { method: "GET", pattern: /^\/api\/inbox$/, diff --git a/src/agents/docs.ts b/src/agents/docs.ts index 0c7c949..ded075a 100644 --- a/src/agents/docs.ts +++ b/src/agents/docs.ts @@ -2,6 +2,7 @@ // when the two disagree, and `bun scripts/gen-agents-doc.ts` rewrites it. import { PRESETS } from "./presets"; +import type { AgentConfig, StageAgents } from "./types"; export const TABLE_START = ""; export const TABLE_END = ""; @@ -34,3 +35,36 @@ export function renderAgentsDoc(doc: string): string { if (start < 0 || end < start) throw new Error("docs/agents.md has no agents-table markers"); return `${doc.slice(0, start + TABLE_START.length)}\n${agentsTable()}\n${doc.slice(end)}`; } + +const STAGE_NAMES = ["triage", "plan", "build", "verify", "pr"] as const; + +export interface AgentRow { + readonly name: string; + readonly preset: string | null; + readonly binary: string; + readonly pin: string | null; + readonly verified: boolean; + readonly configured: boolean; + readonly stages: readonly string[]; +} + +// One row per configured agent, then one per preset nobody has configured yet. +export function agentCatalog(agents: Readonly>, stages: StageAgents): AgentRow[] { + const serves = (name: string) => STAGE_NAMES.filter((s) => (stages[s] ?? stages.default) === name); + const rows: AgentRow[] = Object.entries(agents).map(([name, cfg]) => { + const preset = cfg.preset ? PRESETS[cfg.preset] : undefined; + return { + name, + preset: cfg.preset ?? null, + binary: preset?.binary ?? cfg.command?.[0] ?? name, + pin: preset?.version ?? null, + verified: preset?.verified ?? false, + configured: true, + stages: serves(name), + }; + }); + const used = new Set(rows.map((r) => r.preset)); + for (const p of Object.values(PRESETS)) + if (!used.has(p.name)) rows.push({ name: p.name, preset: p.name, binary: p.binary, pin: p.version, verified: p.verified, configured: false, stages: [] }); + return rows; +} diff --git a/tests/agents-catalog.test.ts b/tests/agents-catalog.test.ts new file mode 100644 index 0000000..1836818 --- /dev/null +++ b/tests/agents-catalog.test.ts @@ -0,0 +1,30 @@ +// The Agents page shows every preset, marks which are configured and which stages each serves, +// and never claims "verified" for a preset that has no live run. +import { expect, test } from "bun:test"; +import { agentCatalog } from "../src/agents/docs"; +import { PRESETS } from "../src/agents/presets"; +import { FactoryState } from "../src/state"; +import { GitHub } from "../src/github"; +import { createDashboard } from "../dashboard/server"; + +test("a configured agent lists the stages it serves, and every other preset is listed as not configured", () => { + const rows = agentCatalog({ claude: { preset: "claude" }, checker: { preset: "codex" }, mine: { command: ["aider", "--yes"] } }, { default: "claude", verify: "checker" }); + const by = Object.fromEntries(rows.map((r) => [r.name, r])); + expect(by.claude!.stages).toEqual(["triage", "plan", "build", "pr"]); + expect(by.checker!.stages).toEqual(["verify"]); + expect(by.mine).toMatchObject({ preset: null, binary: "aider", pin: null, verified: false, stages: [] }); + for (const name of Object.keys(PRESETS)) if (name !== "claude" && name !== "codex") expect(by[name]).toMatchObject({ configured: false, stages: [] }); +}); + +test("only a preset flagged verified shows as verified", () => { + for (const r of agentCatalog({ claude: { preset: "claude" } }, { default: "claude" })) expect(r.verified).toBe(r.preset === "claude"); +}); + +test("GET /api/agents returns the catalog from the configured fleet", async () => { + const dash = createDashboard(new FactoryState(":memory:"), new GitHub(), "acme/widgets", false, undefined, { agents: { claude: { preset: "claude" } }, stages: { default: "claude" } }); + const res = await dash.handle(new Request("http://localhost:4100/api/agents"), "127.0.0.1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { agents: { name: string; stages: string[] }[] }; + expect(body.agents.find((a) => a.name === "claude")!.stages).toHaveLength(5); + expect(body.agents.length).toBe(Object.keys(PRESETS).length); +}); diff --git a/tests/ported/machinist/catalog-copy.test.ts b/tests/ported/machinist/catalog-copy.test.ts new file mode 100644 index 0000000..11d5be0 --- /dev/null +++ b/tests/ported/machinist/catalog-copy.test.ts @@ -0,0 +1,18 @@ +// Ported from owainlewis/machinist@3943516 internal/controlplane/web/src/catalog-copy.test.js:1-end (MIT, Copyright (c) 2026 Owain Lewis). Deviations: also covers worker-status.test.js:1-end; the checks read dashboard/public/app.js and the copy is the Agents page's, since a factory agent is a CLI on this machine rather than a registered worker. +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const app = readFileSync(join(import.meta.dir, "..", "..", "..", "dashboard", "public", "app.js"), "utf8"); + +test("agents use live status copy and an empty state that says what to do", () => { + expect(app).toContain("Reading the factory's state."); + expect(app).toContain("No agents configured"); + expect(app).toContain("Add one under agents in .factory/config.json."); +}); + +test("agents show verified and not-yet-verified status, and the stages each serves", () => { + expect(app).toMatch(/r\.verified \? "Verified" : "Verified by participants: not yet"/); + expect(app).toMatch(/r\.verified \? "ok" : "warn"/); + expect(app).toMatch(/r\.stages\.length \? r\.stages\.join\(", "\) : "None"/); +}); diff --git a/tests/visual-system.test.ts b/tests/visual-system.test.ts index 87021f7..8da58f9 100644 --- a/tests/visual-system.test.ts +++ b/tests/visual-system.test.ts @@ -45,7 +45,7 @@ describe("visual system", () => { }); test("every top-level view is built with the shared heading", () => { - for (const view of ["lineView", "inboxView", "runsView", "analyticsView"]) { + for (const view of ["lineView", "inboxView", "runsView", "analyticsView", "agentsView"]) { const body = app.slice(app.indexOf(`function ${view}`)); expect(body.slice(0, body.indexOf("\n}\n")), view).toMatch(/heading\("/); } From 26ea57e7c20992f7fc6ccb5acd9d4f406a259378 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:18:12 +0300 Subject: [PATCH 7/9] v2.6.0: changelog and version --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ package.json | 2 +- template-ci/factory.yml.example | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0f174..869373d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## v2.6.0 + +One factory, any agent. Every preset except Claude ships `verified: false`; participants verify them with +`factory verify-agent` (see `docs/verify-an-agent.md`). Every fixture except Claude's is synthetic, written +from each CLI's docs. + +- New presets: `gemini`, `opencode`, `cursor`, `pi`, `mastracode`. Each declares its pinned `version`, + the API keys it may see (`envKeys`), its skills dir, its context file and how it holds read-only stages + (`readOnlyBy`). A stage's env now drops every other known provider key, so a Codex stage never sees the + Anthropic key. Agents with a custom `command` keep the old behaviour. +- Gemini and Mastra Code parse through a per-run parser (`newParser`), because their events arrive as deltas. +- `factory install --agents a,b,c` links `.claude/skills` into each agent's skills dir and writes an + `AGENTS.md` (or `GEMINI.md`) pointer only when absent. Ported from skills `internal/agents/agents.go`. +- `factory doctor` warns when an agent binary's version differs from its pin. +- The Dockerfile takes `--build-arg AGENTS="gemini pi"` and pins every agent's version; a build with two + agents was run. Cursor is host-only (`docker: false`): it has no pinnable download. +- `docs/agents.md`: a matrix generated from the registry (a test fails if they drift), a safety table and an + aider walkthrough. One registry test walks every preset for its fixture, pins, install target and docs. +- Agents page and `GET /api/agents`. New `factory-operator` skill (ported from machinist's skill). + `tests/docs-links.test.ts` checks every `*.md` for unbalanced fences and dead local links (blueprint). +- `FixtureRecorder` replaces a synthetic fixture instead of appending to it. + +Not in v2.6: +- Live runs of any agent except Claude. OpenCode and Cursor event shapes are from memory, not from a + capture. Mastra `--mode plan` as read-only, Pi print mode reading stdin and OpenCode reading stdin are + unconfirmed until a participant runs them. +- omp and Amp are cut; either still runs as a custom `command`. Mistral Vibe is config-only. +- `prompt.ts` keeps reading the canonical `.claude/skills`, because `install` always writes it and links the + other agents' dirs to it. There is no `renderPolicy` hook; each preset's `command` applies `stagePolicy`. +- The Agents page shows configuration and pins, not the installed version or doctor rows. + ## v2.5.2 Makes v2.5 work on a real run. Every fix has a test that fails when the fix is reverted. diff --git a/package.json b/package.json index 4ca107c..8c3ec20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "software-factory", - "version": "2.5.2", + "version": "2.6.0", "private": true, "type": "module", "description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR.", diff --git a/template-ci/factory.yml.example b/template-ci/factory.yml.example index d5bab85..92634df 100644 --- a/template-ci/factory.yml.example +++ b/template-ci/factory.yml.example @@ -35,7 +35,7 @@ on: required: false env: - FACTORY_RUNNER_REF: v2.5.2 # pinned software-factory release; bump deliberately + FACTORY_RUNNER_REF: v2.6.0 # pinned software-factory release; bump deliberately FACTORY_RUNNER_REPO: learnwithparam/software-factory CLAUDE_CODE_VERSION: "2.1.281" # pinned claude, same version the Dockerfile installs # Pins for the other agents. For each one your config uses, add `npm install -g @` From 19e74ac511d441f268b0fe61e26c8f87227ba44d Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:18:47 +0300 Subject: [PATCH 8/9] v2.6.0: executor-level env allow-list test Co-Authored-By: Claude Sonnet 5 --- tests/presets.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/presets.test.ts b/tests/presets.test.ts index 7634876..26cb6bd 100644 --- a/tests/presets.test.ts +++ b/tests/presets.test.ts @@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"; import { PROVIDER_KEYS, sanitizeEnv } from "../src/agents/env"; import { PRESETS } from "../src/agents/presets"; import type { StageName } from "../src/executor"; +import { CommandExecutor } from "../src/agents/executor"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const opts = (stage: StageName) => ({ stage, issue: 7, cwd: "/work", maxBudgetUsd: 5 }); const argv = (name: string, stage: StageName, model?: string) => PRESETS[name]!.command(opts(stage), { preset: name, model }, "PROMPT").argv; @@ -83,3 +87,24 @@ describe("per-provider env allow-list", () => { for (const p of Object.values(PRESETS)) for (const k of p.envKeys) expect(PROVIDER_KEYS as readonly string[]).toContain(k); }); }); + +describe("the executor applies the env allow-list", () => { + test("a stage on a preset sees only that preset's provider keys", async () => { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), "factory-envkeys-"))); + Bun.spawnSync(["git", "init", "-q"], { cwd }); + mkdirSync(join(cwd, ".claude/skills/factory-plan"), { recursive: true }); + writeFileSync(join(cwd, ".claude/skills/factory-plan/SKILL.md"), "---\nname: factory-plan\n---\nPlan it.\n"); + PRESETS.envprobe = { ...PRESETS.codex!, name: "envprobe", envKeys: ["OPENAI_API_KEY"], command: () => ({ argv: ["sh", "-c", 'printf "%s|%s" "${OPENAI_API_KEY:-none}" "${ANTHROPIC_API_KEY:-none}" > "$FACTORY_ARTIFACT_DIR/keys.txt"'] }) }; + const before = { o: process.env.OPENAI_API_KEY, a: process.env.ANTHROPIC_API_KEY }; + process.env.OPENAI_API_KEY = "openai"; + process.env.ANTHROPIC_API_KEY = "anthropic"; + try { + await new CommandExecutor({ p: { preset: "envprobe" } }, { default: "p" }).runStage({ stage: "plan", issue: 7, cwd, maxBudgetUsd: 1 }); + } finally { + delete PRESETS.envprobe; + for (const [k, v] of [["OPENAI_API_KEY", before.o], ["ANTHROPIC_API_KEY", before.a]] as const) if (v === undefined) delete process.env[k]; else process.env[k] = v; + } + expect(readFileSync(join(cwd, ".factory/runs/issue-7/keys.txt"), "utf8")).toBe("openai|none"); + rmSync(cwd, { recursive: true, force: true }); + }); +}); From 930cb9f50cbdd444346f58de47afb7704100922c Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Thu, 24 Sep 2026 15:22:07 +0300 Subject: [PATCH 9/9] v2.6.0: validate install --agents before writing, correct docs claims Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ docs/agents.md | 2 +- install.sh | 44 +++++++++++++++++++++++++++---------------- tests/install.test.ts | 3 +++ 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 869373d..8da5363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ from each CLI's docs. - Gemini and Mastra Code parse through a per-run parser (`newParser`), because their events arrive as deltas. - `factory install --agents a,b,c` links `.claude/skills` into each agent's skills dir and writes an `AGENTS.md` (or `GEMINI.md`) pointer only when absent. Ported from skills `internal/agents/agents.go`. +- The install always links `.agents/skills` too (the shared dir), and the Dockerfile now installs four CLIs by + default (`claude codex gemini opencode`); pass `--build-arg AGENTS=claude` for the old image. - `factory doctor` warns when an agent binary's version differs from its pin. - The Dockerfile takes `--build-arg AGENTS="gemini pi"` and pins every agent's version; a build with two agents was run. Cursor is host-only (`docker: false`): it has no pinnable download. diff --git a/docs/agents.md b/docs/agents.md index 29c3154..7e728aa 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -41,7 +41,7 @@ Skills stay in `.claude/skills`; the other directories are symlinks to it. A con | Read-only stages cannot edit files | allow-list | the flag in the table above; the runner writes the artifact | no: stage prompt and gate only | | Path guard hook | yes | no | no | | Tool-call cap and timeout | yes | yes | yes | -| Only its own API key in the env | yes | yes | yes (all multi-provider keys) | +| Only its own provider API keys in the env (GH_TOKEN and dashboard tokens are always stripped) | yes | yes | yes (all multi-provider keys) | Everything else the runner does (the gate, plan approval, the verdict) is agent-independent. diff --git a/install.sh b/install.sh index b1b7ad3..7cbe5dd 100755 --- a/install.sh +++ b/install.sh @@ -18,6 +18,21 @@ usage() { echo " --agents links the skills into each agent's own dir: claude,codex,gemini,opencode,cursor,pi,mastracode" >&2 } +# Skills stay in .claude/skills; each agent gets a symlink from its own dir. The map is +# pinned to the PRESETS registry by tests/install.test.ts. +agent_dirs() { + case "$1" in + claude) echo ".claude/skills CLAUDE.md" ;; + codex) echo ".codex/skills AGENTS.md" ;; + gemini) echo ".gemini/skills GEMINI.md" ;; + opencode) echo ".opencode/skills AGENTS.md" ;; + cursor) echo ".cursor/skills AGENTS.md" ;; + pi) echo ".pi/agent/skills AGENTS.md" ;; + mastracode) echo ".mastracode/skills AGENTS.md" ;; + *) return 1 ;; + esac +} + TARGET="" DRY_RUN=0 UPDATE=0 @@ -25,7 +40,10 @@ CI=0 AGENTS="" NEXT_IS_AGENTS=0 for arg in "$@"; do - if [[ "$NEXT_IS_AGENTS" -eq 1 ]]; then AGENTS="$arg"; NEXT_IS_AGENTS=0; continue; fi + if [[ "$NEXT_IS_AGENTS" -eq 1 ]]; then + if [[ -z "$arg" || "$arg" == -* ]]; then echo "install.sh: --agents needs a list like claude,codex" >&2; exit 1; fi + AGENTS="$arg"; NEXT_IS_AGENTS=0; continue + fi case "$arg" in --agents) NEXT_IS_AGENTS=1 ;; --dry-run) DRY_RUN=1 ;; @@ -36,6 +54,15 @@ for arg in "$@"; do esac done +if [[ "$NEXT_IS_AGENTS" -eq 1 ]]; then echo "install.sh: --agents needs a list like claude,codex" >&2; exit 1; fi +# Validate before anything is written, so a typo never leaves a partial install. +if [[ -n "$AGENTS" ]]; then + IFS=',' read -r -a CHECK_LIST <<< "$AGENTS" + for agent in "${CHECK_LIST[@]}"; do + agent_dirs "$agent" >/dev/null || { echo "install.sh: unknown agent \"$agent\" (see --help)" >&2; exit 1; } + done +fi + if [[ -z "$TARGET" ]]; then usage exit 1 @@ -133,21 +160,6 @@ else wrote=$((wrote + 1)) fi -# Skills stay in .claude/skills; each agent gets a symlink from its own dir. The map is -# pinned to the PRESETS registry by tests/install.test.ts. -agent_dirs() { - case "$1" in - claude) echo ".claude/skills CLAUDE.md" ;; - codex) echo ".codex/skills AGENTS.md" ;; - gemini) echo ".gemini/skills GEMINI.md" ;; - opencode) echo ".opencode/skills AGENTS.md" ;; - cursor) echo ".cursor/skills AGENTS.md" ;; - pi) echo ".pi/agent/skills AGENTS.md" ;; - mastracode) echo ".mastracode/skills AGENTS.md" ;; - *) return 1 ;; - esac -} - if [[ -n "$AGENTS" ]]; then IFS=',' read -r -a AGENT_LIST <<< "$AGENTS" for agent in "${AGENT_LIST[@]}"; do diff --git a/tests/install.test.ts b/tests/install.test.ts index 13c0440..c661fb1 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -150,6 +150,9 @@ describe("install.sh --agents", () => { test("an unknown agent fails before anything is linked; --dry-run writes no links", () => { const target = scratchTarget(); expect(run([target, "--agents", "nope"]).code).toBe(1); + expect(existsSync(join(target, ".claude"))).toBe(false); // nothing written before the check + expect(run([target, "--agents", "--dry-run"]).code).toBe(1); // a flag is not an agent list + expect(run([target, "--agents"]).code).toBe(1); const dry = scratchTarget(); run([dry, "--agents", "pi", "--dry-run"]); expect(existsSync(join(dry, ".pi"))).toBe(false);