From 32e3886183169f416220e042ca8ca10777424053 Mon Sep 17 00:00:00 2001 From: Thierry Kwiatkowski Date: Mon, 5 Oct 2026 22:53:32 +0200 Subject: [PATCH] feat(cli): emit antigravity agents as portable .agents/agents//agent.md Antigravity's flat build now writes each plugin agent to .agents/agents/-/agent.md, the nested layout agy documents, with its links resolved from that directory. The frontmatter is rebuilt rather than copied, by a builder Codex flat and Kimi can reuse: name, the source description, model: inherit, and tools as a YAML list when declared. agy 1.2.17 silently drops an agent whose model is opus or whose tools is a comma string. A blank description stops the build with the agent named. The profile declares no AgentsCapability: that would also enable the raw fallback route, which copies Claude frontmatter verbatim. The bundle budget moves from 766 KB to 771 KB, measured at 764.2 KB: +1.8 KB over the hooks merge's 762.4 KB. Co-Authored-By: Claude Opus 5.5 --- .../phase-4.md | 9 +-- cli/package.json | 2 +- cli/scripts/check-bundle-size.mjs | 2 + .../tools/domain/formats/portable-agent.ts | 27 +++++++ .../profiles/antigravity/antigravity-paths.ts | 1 + .../domain/profiles/antigravity/build.ts | 46 ++++++++++-- cli/src/kernel/errors.ts | 7 ++ cli/src/kernel/markdown.ts | 11 ++- .../formats/portable-agent.unit.test.ts | 72 +++++++++++++++++++ .../profiles/antigravity/build.unit.test.ts | 38 +++++++++- .../snapshots/framework-build/golden.json | 6 +- cli/tests/kernel/markdown.unit.test.ts | 6 ++ 12 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 cli/src/contexts/tools/domain/formats/portable-agent.ts create mode 100644 cli/tests/contexts/tools/domain/formats/portable-agent.unit.test.ts diff --git a/aidd_docs/tasks/2026_10/2026_10_02_antigravity-flat-host/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_02_antigravity-flat-host/phase-4.md index 62fa96668..687c54e91 100644 --- a/aidd_docs/tasks/2026_10/2026_10_02_antigravity-flat-host/phase-4.md +++ b/aidd_docs/tasks/2026_10/2026_10_02_antigravity-flat-host/phase-4.md @@ -1,5 +1,5 @@ --- -status: pending +status: done --- @@ -16,8 +16,9 @@ cli/ │ └── portable-agent.ts ✅ rebuild frontmatter: name, description, model: inherit, tools as YAML list; drop everything else ├── src/contexts/tools/domain/profiles/antigravity/ │ ├── antigravity-paths.ts ✏️ agents dir -│ ├── profile.ts ✏️ AgentsCapability, nested `/agent.md` -│ └── build.ts ✏️ agents artifact, link rewriting relative to the nested path +│ └── build.ts ✏️ agents artifact, nested `/agent.md`, link rewriting relative to the nested path +├── src/kernel/errors.ts ✏️ `InvalidAgentFrontmatterError` +├── package.json, scripts/check-bundle-size.mjs ✏️ budget 742 KB, measured 734.5 KB ├── tests/contexts/tools/domain/formats/ │ └── portable-agent.unit.test.ts ✅ model mapping, tools string → list, Claude-only keys dropped, empty description rejected ├── tests/contexts/tools/domain/profiles/antigravity/build.unit.test.ts ✏️ agent path and body @@ -66,7 +67,7 @@ journey ### `2)` Antigravity agents in setup and translate -1. `AgentsCapability` and flat `agents` artifact writing `.agents/agents/-/agent.md`, body links rewritten from that nested path. +1. Flat `agents` artifact writing `.agents/agents/-/agent.md`, body links rewritten from that nested path. No `AgentsCapability`: a flat tool declaring one also gets the raw fallback route (`content-translator.ts`'s `flatSectionFile`), which copies Claude frontmatter to `.agents/agents//.md`, where `agy` 1.2.17 drops it in silence (measured: `model: opus` and comma `tools` both missing from `/agents`). `setup` takes the built-tree route, which reads the artifact. 2. Unit test the path and body; regenerate golden, only `antigravity:flat` moves. ### `3)` Live check diff --git a/cli/package.json b/cli/package.json index 1faf53a04..fff9fdb34 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 766, + "bundleBudgetKB": 771, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index 1dd3921e2..7ff2dc099 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -36,6 +36,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // correctly (+5,008 bytes over 0.27.3); unchanged application and minification settings. // 766 KB: 757.9 KB, Antigravity's flat profile (+2.3 KB over next's 755.6 KB); 1.1% measured // headroom. +// 771 KB: 764.2 KB, Antigravity's portable agents (+1.8 KB over its hooks merge's 762.4 KB, which +// grew +4.5 KB over the flat profile's 757.9 KB within 766 KB); 0.9% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/contexts/tools/domain/formats/portable-agent.ts b/cli/src/contexts/tools/domain/formats/portable-agent.ts new file mode 100644 index 000000000..80e57bdda --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/portable-agent.ts @@ -0,0 +1,27 @@ +import { InvalidAgentFrontmatterError } from "../../../../kernel/errors.js"; + +/** Rebuilt, never copied: a host silently drops an agent with an unknown `model` or list-less `tools`. */ +export function portableAgentFrontmatter( + source: Record, + name: string +): Record { + const description = typeof source.description === "string" ? source.description.trim() : ""; + if (description === "") throw new InvalidAgentFrontmatterError(name, "description is empty"); + const tools = toolList(source.tools); + return { + name, + description, + model: "inherit", + ...(tools === null ? {} : { tools }), + }; +} + +function toolList(tools: unknown): string[] | null { + if (Array.isArray(tools)) return tools.map(String); + if (typeof tools !== "string") return null; + const list = tools + .split(",") + .map((tool) => tool.trim()) + .filter((tool) => tool !== ""); + return list.length > 0 ? list : null; +} diff --git a/cli/src/contexts/tools/domain/profiles/antigravity/antigravity-paths.ts b/cli/src/contexts/tools/domain/profiles/antigravity/antigravity-paths.ts index c08c65d50..d475ee98c 100644 --- a/cli/src/contexts/tools/domain/profiles/antigravity/antigravity-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/antigravity/antigravity-paths.ts @@ -5,3 +5,4 @@ export const ANTIGRAVITY_DIRECTORY = ".agents/"; export const ANTIGRAVITY_SKILLS_DIR = `${ANTIGRAVITY_DIRECTORY}skills/`; export const ANTIGRAVITY_HOOKS_DIR = `${ANTIGRAVITY_DIRECTORY}hooks/`; export const ANTIGRAVITY_HOOKS_FILE = `${ANTIGRAVITY_DIRECTORY}hooks.json`; +export const ANTIGRAVITY_AGENTS_DIR = `${ANTIGRAVITY_DIRECTORY}agents/`; diff --git a/cli/src/contexts/tools/domain/profiles/antigravity/build.ts b/cli/src/contexts/tools/domain/profiles/antigravity/build.ts index f064689a2..d8564b0e9 100644 --- a/cli/src/contexts/tools/domain/profiles/antigravity/build.ts +++ b/cli/src/contexts/tools/domain/profiles/antigravity/build.ts @@ -1,15 +1,48 @@ -/** Antigravity's project distribution is flat and carries skills and hooks. A skill sits one level - * under `.agents/skills/`, since `agy` expands none nested deeper. */ +/** Flat only. A skill sits one level under `.agents/skills/`: `agy` expands none nested deeper. */ +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; import { genericFlatSkillPath } from "../../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; import type { ToolBuildContract } from "../../build-contract.js"; +import { portableAgentFrontmatter } from "../../formats/portable-agent.js"; import { antigravityProjectHooksFormat } from "./antigravity-hooks.js"; -import { ANTIGRAVITY_HOOKS_FILE, ANTIGRAVITY_SKILLS_DIR } from "./antigravity-paths.js"; +import { + ANTIGRAVITY_AGENTS_DIR, + ANTIGRAVITY_HOOKS_FILE, + ANTIGRAVITY_SKILLS_DIR, +} from "./antigravity-paths.js"; function antigravityFlatSkillPath(plugin: string, rel: string): string { return genericFlatSkillPath(ANTIGRAVITY_SKILLS_DIR, plugin, rel.replace(/^skills\//, "")); } +function agentName(plugin: string, rel: string): string { + return `${plugin}-${rel.replace(/^agents\//, "").replace(/(\.agent)?\.md$/, "")}`; +} + +function antigravityFlatAgentPath(plugin: string, rel: string): string { + return `${ANTIGRAVITY_AGENTS_DIR}${agentName(plugin, rel)}/agent.md`; +} + +function resolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return antigravityFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return antigravityFlatSkillPath(plugin, rel); + return rel; +} + +function transformAntigravityAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const rel = `agents/${outName}`; + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: antigravityFlatAgentPath(plugin, rel), + resolveTargetPath: (target) => resolveTarget(plugin, target), + }); + return serializeFrontmatter( + portableAgentFrontmatter(frontmatter, agentName(plugin, rel)), + rewrittenBody + ); +} + export function buildAntigravityFlatContract(): ToolBuildContract { return { manifestFileRelative: null, @@ -22,7 +55,12 @@ export function buildAntigravityFlatContract(): ToolBuildContract { path: antigravityFlatSkillPath, rewriteSkillName: true, }, - agents: { supported: false }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: antigravityFlatAgentPath, + transform: transformAntigravityAgent, + }, mcp: { supported: false }, hooks: { supported: true, diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index b3200cd8d..37839cfb8 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -195,6 +195,13 @@ export class UnresolvableUserScopeError extends Error { } } +export class InvalidAgentFrontmatterError extends Error { + constructor(agentName: string, detail: string) { + super(`Agent '${agentName}' has invalid frontmatter: ${detail}.`); + this.name = "InvalidAgentFrontmatterError"; + } +} + export class InvalidPluginManifestError extends Error { constructor(detail?: string) { super(detail ? `Invalid plugin manifest: ${detail}` : "Invalid plugin manifest."); diff --git a/cli/src/kernel/markdown.ts b/cli/src/kernel/markdown.ts index 2be1507cc..a034cb3fe 100644 --- a/cli/src/kernel/markdown.ts +++ b/cli/src/kernel/markdown.ts @@ -28,6 +28,15 @@ export function parseFrontmatter(content: string): { return { frontmatter, body }; } +function isJsonArray(s: string): boolean { + if (!s.startsWith("[")) return false; + try { + return Array.isArray(JSON.parse(s)); + } catch { + return false; + } +} + export function serializeFrontmatter(frontmatter: Record, body: string): string { if (Object.keys(frontmatter).length === 0) { return body.replace(/^\n/, ""); @@ -50,7 +59,7 @@ export function serializeFrontmatter(frontmatter: Record, body: } else { const s = String(value); // JSON-array strings (e.g. from cursor globs) are emitted raw so they stay as YAML inline arrays. - if (s.startsWith("[") && s.endsWith("]")) { + if (isJsonArray(s)) { lines.push(`${key}: ${s}`); } else { lines.push(`${key}: '${s.replaceAll("'", "''")}'`); diff --git a/cli/tests/contexts/tools/domain/formats/portable-agent.unit.test.ts b/cli/tests/contexts/tools/domain/formats/portable-agent.unit.test.ts new file mode 100644 index 000000000..bb92954dd --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/portable-agent.unit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { portableAgentFrontmatter } from "../../../../../src/contexts/tools/domain/formats/portable-agent.js"; +import { InvalidAgentFrontmatterError } from "../../../../../src/kernel/errors.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../../src/kernel/markdown.js"; + +describe("portableAgentFrontmatter", () => { + it("names the agent, keeps its description and inherits the session model", () => { + expect( + portableAgentFrontmatter( + { name: "executor", description: "Turns a task into code.", model: "opus" }, + "aidd-dev-executor" + ) + ).toEqual({ + name: "aidd-dev-executor", + description: "Turns a task into code.", + model: "inherit", + }); + }); + + it("turns a comma-separated tools string into a list", () => { + expect( + portableAgentFrontmatter({ description: "d", tools: "Read, Grep,Glob" }, "a").tools + ).toEqual(["Read", "Grep", "Glob"]); + }); + + it("keeps a tools list as a list", () => { + expect( + portableAgentFrontmatter({ description: "d", tools: ["Read", "Bash"] }, "a").tools + ).toEqual(["Read", "Bash"]); + }); + + it("emits no tools key when the source declares none", () => { + expect(portableAgentFrontmatter({ description: "d" }, "a")).not.toHaveProperty("tools"); + }); + + it("drops every key it does not rebuild", () => { + expect( + Object.keys( + portableAgentFrontmatter( + { description: "d", color: "red", permissionMode: "plan", skills: ["x"] }, + "a" + ) + ).sort() + ).toEqual(["description", "model", "name"]); + }); + + it("round-trips a bracketed description and non-string tools as strings", () => { + const frontmatter = portableAgentFrontmatter( + { description: "[WIP] foo [x]", tools: [1, true] }, + "a" + ); + + const serialized = serializeFrontmatter(frontmatter, "body"); + const parsed = parseFrontmatter(serialized).frontmatter; + + expect(serialized).toContain("description: '[WIP] foo [x]'"); + expect(parsed.description).toBe("[WIP] foo [x]"); + expect(parsed.tools).toEqual(["1", "true"]); + }); + + it.each([undefined, "", " "])( + "refuses a blank description (%j), naming the agent", + (description) => { + expect(() => portableAgentFrontmatter({ description }, "aidd-dev-executor")).toThrow( + InvalidAgentFrontmatterError + ); + expect(() => portableAgentFrontmatter({ description }, "aidd-dev-executor")).toThrow( + /aidd-dev-executor/ + ); + } + ); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/antigravity/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/antigravity/build.unit.test.ts index 4681169d8..a62f772da 100644 --- a/cli/tests/contexts/tools/domain/profiles/antigravity/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/antigravity/build.unit.test.ts @@ -47,7 +47,43 @@ describe("buildAntigravityFlatContract", () => { }); }); - it.each(["agents", "mcp", "rules", "commands"] as const)("declares %s unsupported", (kind) => { + it("writes an agent only in the nested layout, named after its plugin", () => { + const agents = supported(buildAntigravityFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/executor.md")).toBe( + ".agents/agents/aidd-dev-executor/agent.md" + ); + expect(agents.path("aidd-dev", "agents/executor.agent.md")).toBe( + ".agents/agents/aidd-dev-executor/agent.md" + ); + }); + + it("rebuilds the agent's frontmatter and links its body from the nested directory", () => { + const agents = supported(buildAntigravityFlatContract().artifacts.agents); + const source = [ + "---", + "name: executor", + "description: Turns a task into code.", + "model: opus", + "color: red", + "---", + // biome-ignore lint/suspicious/noTemplateCurlyInString: the Claude placeholder the rewrite resolves + "Follow @${CLAUDE_PLUGIN_ROOT}/skills/01-plan/SKILL.md", + ].join("\n"); + + expect(agents.transform?.(source, "aidd-dev", "executor.md")).toBe( + [ + "---", + "name: 'aidd-dev-executor'", + "description: 'Turns a task into code.'", + "model: 'inherit'", + "---", + "Follow [SKILL.md](../../skills/aidd-dev-01-plan/SKILL.md)", + ].join("\n") + ); + }); + + it.each(["mcp", "rules", "commands"] as const)("declares %s unsupported", (kind) => { expect(buildAntigravityFlatContract().artifacts[kind].supported).toBe(false); }); diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index 77a909b9f..5e9be8547 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -2131,6 +2131,10 @@ ".agents/skills/aidd-async-dev-01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".agents/skills/aidd-async-dev-01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".agents/skills/aidd-async-dev-01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".agents/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149" + ".agents/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", + ".agents/agents/aidd-dev-reviewer/agent.md": "80f7f93f8fe14b7b8983466639155dca76e825c4b2a6a9b9203bd48a41135990", + ".agents/agents/aidd-dev-planner/agent.md": "dc5756134837010d3fc117bbd9b47f868a98ca185504877b6cb41f4797098ff9", + ".agents/agents/aidd-dev-implementer/agent.md": "bb397194473e1256883f08281deeabc0e2a1e3ed3a1184b8f48e6a47551ded9e", + ".agents/agents/aidd-async-dev-async-orchestrator/agent.md": "2d1838d0bb76a5e0db435e0fde792ab8502f2454cc97e1fba2f998b2eb49319d" } } diff --git a/cli/tests/kernel/markdown.unit.test.ts b/cli/tests/kernel/markdown.unit.test.ts index 884f3b4ee..f6c8a480e 100644 --- a/cli/tests/kernel/markdown.unit.test.ts +++ b/cli/tests/kernel/markdown.unit.test.ts @@ -150,6 +150,12 @@ describe("frontmatter, at the edges", () => { expect(serializeFrontmatter({ globs: '["a","b"]' }, "body")).toContain('globs: ["a","b"]'); }); + it("quotes a string that only looks like a list, so it reads back as the same string", () => { + const out = serializeFrontmatter({ description: "[WIP] foo [x]" }, "body"); + expect(out).toContain("description: '[WIP] foo [x]'"); + expect(parseFrontmatter(out).frontmatter.description).toBe("[WIP] foo [x]"); + }); + it("doubles an apostrophe rather than ending the quoted string early", () => { expect(serializeFrontmatter({ name: "it's" }, "body")).toContain("name: 'it''s'"); });