diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2cb446ed5..70c42f4db 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -19,7 +19,7 @@ { "name": "aidd-dev", "source": "./plugins/aidd-dev", - "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure. Hosts engineering agents.", + "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure.", "strict": true, "metadata": { "recommended": true @@ -46,7 +46,7 @@ { "name": "aidd-orchestrator", "source": "./plugins/aidd-orchestrator", - "description": "Orchestration: synchronous SDLC pipeline, async issue-to-PR automation, and product backlog.", + "description": "Orchestration: synchronous SDLC pipeline, async issue-to-PR automation, and product backlog. Hosts the generic agents.", "strict": true, "metadata": { "recommended": true diff --git a/AGENTS.md b/AGENTS.md index 1f94a4f4f..2e292a3be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ ## Behavior - **Stay critical.** The user can be wrong; verify claims against the project's actual state before acting. -- **Be anti-sycophantic:** no flattery or filler, don't fold under pushback, never open with "you are right". Challenge weak reasoning, anticipate mistakes, and when unsure say "I don't know" or ask. +- **Be anti-sycophantic:** no flattery or filler; agree only when the evidence supports it, and don't fold under pushback. Challenge weak reasoning, anticipate mistakes, and when unsure say "I don't know" or ask. - **Surface tradeoffs and evaluate their impact** instead of hiding them. ## Communication @@ -14,7 +14,7 @@ - **No preamble or recap:** don't restate the request or summarize visible changes. Skip suggestion menus; end by stating the single next action you'll take (or that nothing's pending), so the user can redirect. - **Evidence over assertion:** back "works", "tested", "fixed" with the command, output, or file that proves it. - **Quote the shortest decisive line** of an error or log, not the whole dump. -- **No tool-call narration.** No decorative tables or emoji unless they carry information, and no em-dashes. +- **Progress at milestones, not per call:** before a multi-step tool run, one line on what you're doing and why; no commentary on individual calls. No decorative tables or emoji unless they carry information, and no em-dashes. - **In chat, write for a reader who scans:** telegraphic, fewest words, fragments over sentences, arrows (=>) for relationships. Cut any word that doesn't change meaning. Normal prose in authored docs and code. Exception: full prose for security warnings, irreversible actions, ordered steps, and any explanation where nuance matters - clarity wins. ## Action @@ -23,14 +23,26 @@ - **Stay focused, not scattered:** exceed the literal ask only when it clearly helps, not by default. When you spot an unrelated issue, note it in one line and keep going; detour only if it blocks the task. - **Solve your own issues first:** genuinely try to resolve it yourself before escalating to the human. - **Don't assume your knowledge is current.** -- **Placement discipline:** for every plugin change, think hard about where responsibility belongs; follow `docs/ARCHITECTURE.md`. +- **Placement discipline:** for every plugin change, place the responsibility in the plugin whose concern owns it, per `docs/ARCHITECTURE.md`. - **Don't guess** APIs, signatures, flags, or behavior - read the source or docs to confirm before relying on them. - **Ambiguous or expensive task:** ask one sharp question to pin down scope before building, rather than guess. - **Batch independent operations** in one pass, not one at a time. -- **Fan out** independent subtasks to parallel subagents when you own the overall flow and the work is genuinely parallel. - **Before adding any instruction, finding, or rule, check whether an existing one already covers or contradicts it.** If so, don't add a parallel: delete it, merge it into the stronger one, or rewrite with explicit scope and priority. - **Name by intention, not mechanism:** describe the goal or responsibility, not the tool or file format. +## Agents + +When you own the overall flow, fan out genuinely independent subtasks to agents by responsibility when coordination costs less than serial work. You stay the caller: you own scope, delegation, and final acceptance. + +| Responsibility | Agent | +| --- | --- | +| Answer a scoped codebase question with sources | `explorer` | +| Implement assigned work and verify it | `executor` | +| Confirm the outcome meets its acceptance criteria | `validator` | +| Review quality, coherence, and needless complexity | `reviewer` | + +Without the agent, give the same brief to the host's default subagent. + ## Memory Management Project docs, memory, specs, and plans live in `aidd_docs/`. diff --git a/CLAUDE.md b/CLAUDE.md index de5932776..9fe11c503 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,10 +27,22 @@ - **Don't guess** APIs, signatures, flags, or behavior - read the source or docs to confirm before relying on them. - **Ambiguous or expensive task:** ask one sharp question to pin down scope before building, rather than guess. - **Batch independent operations** in one pass, not one at a time. -- **Fan out** independent subtasks to parallel subagents when you own the overall flow and the work is genuinely parallel. - **Before adding any instruction, finding, or rule, check whether an existing one already covers or contradicts it.** If so, don't add a parallel: delete it, merge it into the stronger one, or rewrite with explicit scope and priority. - **Name by intention, not mechanism:** describe the goal or responsibility, not the tool or file format. +## Agents + +When you own the overall flow, fan out genuinely independent subtasks to agents by responsibility when coordination costs less than serial work. You stay the caller: you own scope, delegation, and final acceptance. + +| Responsibility | Agent | +| --- | --- | +| Answer a scoped codebase question with sources | `explorer` | +| Implement assigned work and verify it | `executor` | +| Confirm the outcome meets its acceptance criteria | `validator` | +| Review quality, coherence, and needless complexity | `reviewer` | + +Without the agent, give the same brief to the host's default subagent. + ## Memory Management Project docs, memory, specs, and plans live in `aidd_docs/`. diff --git a/Makefile b/Makefile index 67fc69349..24aa053b7 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,3 @@ -PLUGIN ?= all - .DEFAULT_GOAL := help .PHONY: help setup doctor check reload @@ -17,5 +15,6 @@ doctor: ## Check the local toolchain check: ## Run the pre-commit checks pnpm exec lefthook run pre-commit -reload: ## Dev: reload this checkout into Claude, Codex, and OpenCode (PLUGIN="aidd-refine aidd-pm", default all) - scripts/dev-sync.sh $(PLUGIN) +reload: ## Dev: reload all plugins into Claude, Codex, OpenCode, Copilot, and Cursor + pnpm --dir cli build + scripts/dev-sync.sh diff --git a/README.md b/README.md index 714049353..d4bbb2696 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best ๐Ÿงฑ **IDE agnostic** ยท ๐Ÿ—๏ธ **Legacy systems** ยท ๐ŸŒฑ **Token-optimized** ยท ๐Ÿ‡ซ๐Ÿ‡ท **Made in France**

- 9 plugins ยท 52 skills ยท 2 agents + 9 plugins ยท 52 skills ยท 4 agents

[![Open Source](https://img.shields.io/badge/Open_Source-Yes-yellow?logo=open-source-initiative&logoColor=white)](https://opensource.org/) diff --git a/aidd_docs/memory/cli.md b/aidd_docs/memory/cli.md index 577da0a7d..f335de8c6 100644 --- a/aidd_docs/memory/cli.md +++ b/aidd_docs/memory/cli.md @@ -6,7 +6,6 @@ The `aidd` binary, built from `cli/` and published to npm as `@ai-driven-dev/cli ## Commands -- Eleven groups: `setup`, `framework`, `translate`, `plugin`, `marketplace`, `auth`, `sync`, `update`, `doctor`, `clean`, `telemetry`. - `telemetry` counts the tokens of Claude Code and Codex sessions locally: [usage contract](../product/usage-contract.md). - No list here. The surface moves; `aidd --help` is the only reading that stays true. diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 4f6b8de1c..348d0a2be 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -39,7 +39,7 @@ Branch model in `vcs.md`, cadence and safety rules in [`RELEASE.md`](../../RELEA flowchart TD RP["release-please PR, auto-merged"] --> Tags["tags + umbrella release as draft"] Tags --> Back["back-merge.yml: main into next"] - Tags --> Jobs["attach: bundle, 9 distributions, plugin archives, video"] + Tags --> Jobs["attach: bundle, per-tool distributions, plugin archives, video"] Tags --> Cli["publish-cli: npm"] Jobs --> Pub["publish-release: draft to latest"] Cli --> Recap @@ -63,6 +63,10 @@ Config: `release-please-config.json`, eleven packages. Manifest: `.release-pleas - `build-per-tool` builds the CLI from this run's own checkout (`pnpm build`, then `node cli/dist/cli.js translate`), never a pinned published version: nothing to bump, nothing to go stale. +## Local reload + +`make reload` syncs all plugins and generic delegation guidance to installed hosts; details in `scripts/dev-sync.sh`. + ## Monitoring None. Failures are a red run; `back-merge.yml` opens an issue when it cannot push. diff --git a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/review.md b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/review.md new file mode 100644 index 000000000..bf819ca61 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/review.md @@ -0,0 +1,44 @@ +# Review: scoped sub-agents + +- Rounds: 3 +- Started: 2026-10-15 + +## Round 1 ยท r-1fd6 + +- Date: 2026-10-15 +- By: alexsoyes +- Diff: `origin/next...513d323c0dcb431aa936e56bf4be16945e39ec57` +- Axes: code, relevancy +- Verdict: changes-requested +- Score: not scored + +### Findings + +- `plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md:49` : Delegated delivery sends the required final journey into Finalize, whose first step runs plan validation again (`02-implement/actions/03-finalize.md:13`). The journey is therefore no longer last โ†’ perform integrated validation before that journey and mark completion after it passes โ€” ๐ŸŸก major, fit +- `plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md:25` : The changed contract requires canonical recipe addresses, while the copied scaffold still prescribes capability discovery for cross-plugin recipes (`assets/agent-template.md:18`; `actions/02-write-agent.md:15` copies it) โ†’ align the scaffold with the contract โ€” ๐ŸŸก major, architecture +- `plugins/aidd-dev/skills/02-implement/actions/02-execute.md:17` : Missing dependencies demand replanning, while line 15 gives undeclared dependencies a serial fallback โ†’ distinguish absent declarations from unresolved referenced prerequisites โ€” ๐ŸŸข minor, error-handling +- `docs/ARCHITECTURE.md:145` : Rewriting general cross-plugin provider discovery is unnecessary for adding sub-agents; agent permission addressing already has its own row โ†’ retain the existing provider rule โ€” ๐ŸŸข minor, fit + +## Round 2 ยท r-7c2a + +- Date: 2026-10-15 +- By: alexsoyes +- Diff: `origin/next...513d323c0dcb431aa936e56bf4be16945e39ec57` plus the working tree +- Axes: code, relevancy +- Verdict: changes-requested +- Score: not scored + +### Findings + +- `plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md:7` : The local correction makes every executor stop after Execute, including whole-plan executors, while the implementation router still instructs whole-plan calls to run Prepare, Execute and Finalize (`02-implement/SKILL.md:26`). The caller must override that routing and resume inside another action โ†’ preserve the normal whole-plan route and keep shared integration/finalization ownership specific to delegated phases โ€” ๐ŸŸก major, architecture +- `plugins/aidd-dev/skills/02-implement/actions/02-execute.md:16` : Distinct checkouts/Git indexes are mandatory for parallel execution even when workers own disjoint files and leave Git operations to the caller โ†’ require independent writes and validation resources; require separate indexes only when workers also perform Git operations โ€” ๐ŸŸข minor, fit +- `plugins/aidd-dev/agents/validator.md:19` : A two-sentence maximum can hide distinct failures or verification blockers; the requested concise verdict does not need a fixed sentence count โ†’ retain one verdict and concise decisive evidence without the numeric cap โ€” ๐ŸŸข minor, fit + +## Round 3 ยท r-983d + +- Date: 2026-10-15 +- By: alexsoyes +- Diff: final working candidate +- Axes: code, relevancy +- Verdict: approve +- Score: not scored diff --git a/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md new file mode 100644 index 000000000..e3678dcba --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_14_sub_agents/verification.md @@ -0,0 +1,17 @@ +# Sub-agent verification + +Behavior and sources are in [the agent guide](../../../../docs/SUBAGENTS.md). + +## Scope + +- **Agents:** explorer, executor, validator and reviewer ship in `plugins/aidd-orchestrator/agents/`, installed with the plugin. No tool list is declared: every host gives an agent all tools by default. +- **Translation:** each host receives only the keys its documentation lists: name or file path as the ID, description, model, and Claude's `effort` under the host's own key. Each host has one agent frontmatter function, shared by the plugin install and its built distributions. +- **Out of scope:** per-provider models and a model for the main session. + +## Checks + +- **Claude Code:** the four agents load and run from a project-scoped plugin install. +- **OpenCode:** the four agents run from the flat distribution through `opencode run --standalone --agent ` with an OpenAI model, each answering after a `read` call. +- **Codex:** the four agents load from the flat distribution in a trusted project; `codex exec` spawned each, and its session records the agent's role and instructions. +- **Every host:** `cli/tests/e2e/agent-install.e2e.test.ts` builds the repository flat and checks each shipped agent's path, frontmatter and body against the host's documented format. +- **Copilot, Cursor, Kilo:** no host binary in the test environment, so loading by the host is unverified. The Copilot plugin lists agent files where its documentation describes agent directories. diff --git a/cli/src/contexts/tools/domain/capabilities/agents-capability.ts b/cli/src/contexts/tools/domain/capabilities/agents-capability.ts index 625db1ae6..cc3f7ea40 100644 --- a/cli/src/contexts/tools/domain/capabilities/agents-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/agents-capability.ts @@ -1,4 +1,5 @@ import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; +import type { AgentFrontmatter } from "../formats/agent-format.js"; function agentNameFromFrontmatter( fm: Record, @@ -9,22 +10,6 @@ function agentNameFromFrontmatter( return typeof name === "string" ? name : undefined; } -function tomlString(value: string): string { - return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function buildTomlContent(frontmatter: Record, body: string): string { - const lines: string[] = [ - `name = ${tomlString(String(frontmatter.name ?? ""))}`, - `description = ${tomlString(String(frontmatter.description ?? ""))}`, - ]; - if (frontmatter.model !== undefined) { - lines.push(`model = ${tomlString(String(frontmatter.model))}`); - } - lines.push(`developer_instructions = """\n${body}\n"""`); - return `${lines.join("\n")}\n`; -} - function stripSuffix(toolSuffix: string, fileName: string): string { const basename = fileName.split("/").at(-1) ?? fileName; const dir = fileName.slice(0, fileName.length - basename.length); @@ -51,12 +36,14 @@ export class AgentsCapability { directory: string; toolSuffix: string; format: "markdown" | "toml"; + /** The host's own agent frontmatter, shared with its built distributions. */ + frontmatter: AgentFrontmatter; userFileExt?: string; + /** How frontmatter strings are quoted; `double` for readers that keep single quotes. */ + quote?: "single" | "double"; + /** Writes the agent file when the host's format is not markdown frontmatter. */ + serialize?: (frontmatter: Record, body: string) => string; buildInstallPath?: (fileName: string) => string | null; - convertFrontmatter?: ( - fm: Record, - fileName?: string - ) => Record; } ) {} @@ -84,21 +71,12 @@ export class AgentsCapability { } convertFrontmatter(fm: Record, fileName?: string): Record { - if (this.params.convertFrontmatter) return this.params.convertFrontmatter(fm, fileName); - const name = agentNameFromFrontmatter(fm, fileName); - if (this.params.format === "toml") { - const result: Record = { name, description: fm.description }; - if (fm.model !== undefined) result.model = fm.model; - return result; - } - return { name, description: fm.description }; + return this.params.frontmatter({ ...fm, name: agentNameFromFrontmatter(fm, fileName) }); } serialize(frontmatter: Record, body: string): string { - if (this.params.format === "toml") { - return buildTomlContent(frontmatter, body); - } - return serializeFrontmatter(frontmatter, body); + if (this.params.serialize) return this.params.serialize(frontmatter, body); + return serializeFrontmatter(frontmatter, body, this.params.quote); } deserialize(content: string): { frontmatter: Record; body: string } { diff --git a/cli/src/contexts/tools/domain/formats/agent-format.ts b/cli/src/contexts/tools/domain/formats/agent-format.ts new file mode 100644 index 000000000..648667292 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-format.ts @@ -0,0 +1,23 @@ +/** One host's agent frontmatter, written from the Claude-format source. Every host declares + * exactly one, and every path that installs an agent writes through it, so the plugin + * install and the built distributions cannot diverge. */ +export type AgentFrontmatter = (source: Record) => Record; + +/** Pick only the given keys, in their order, for deterministic output. A key whose value is + * undefined is omitted. */ +export function pickFrontmatterKeys( + fm: Record, + keys: readonly string[] +): Record { + const result: Record = {}; + for (const key of keys) { + if (fm[key] !== undefined) result[key] = fm[key]; + } + return result; +} + +/** A host without an effort key reads it from the model id, between `open` and `close`. */ +export function modelWithEffort(fm: Record, open: string, close = ""): unknown { + if (typeof fm.model !== "string" || typeof fm.effort !== "string") return fm.model; + return `${fm.model}${open}${fm.effort}${close}`; +} diff --git a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts index 6aff4c5ad..a963fc32b 100644 --- a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts +++ b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts @@ -4,10 +4,9 @@ export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ "name", "description", "model", - "tools", - "agents", - "argument-hint", -] = ["name", "description", "model", "tools", "agents", "argument-hint"]; + "reasoningEffort", + "include-custom-instructions", +] = ["name", "description", "model", "reasoningEffort", "include-custom-instructions"]; /** Cursor-supported frontmatter keys: Cursor documents only name, description and model โ€” * never tools or color. */ @@ -17,6 +16,9 @@ export const CURSOR_AGENT_FRONTMATTER_KEYS: readonly ["name", "description", "mo "model", ]; +/** Keys of a host whose agent ID is its file path (OpenCode, Kilo): no `name`. */ +const PATH_ID_AGENT_FRONTMATTER_KEYS: readonly ["description", "model"] = ["description", "model"]; + /** Pick only the given keys, preserving their order. A key whose value is undefined is * omitted. */ export function pickFrontmatterKeys( @@ -32,10 +34,17 @@ export function pickFrontmatterKeys( return result; } -/** Only the Copilot-supported keys, in the allowlist's own order. Lossy, so there is no - * inverse: a key outside the allowlist cannot be recovered from the output. */ +/** Copilot reads Claude's `effort` as `reasoningEffort`, and gives a subagent the repository + * instructions only when `include-custom-instructions` is set. Lossy, so there is no inverse. */ export function stripCopilotAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, COPILOT_AGENT_FRONTMATTER_KEYS); + return pickFrontmatterKeys( + { + ...fm, + reasoningEffort: fm.effort, + "include-custom-instructions": fm["include-custom-instructions"] ?? true, + }, + COPILOT_AGENT_FRONTMATTER_KEYS + ); } /** Only the Cursor-supported keys. Tools, color and argument-hint are discarded. */ @@ -43,6 +52,8 @@ export function stripCursorAgentFrontmatter(fm: Record): Record return pickFrontmatterKeys(fm, CURSOR_AGENT_FRONTMATTER_KEYS); } -export function stripAgentFrontmatter(fm: Record): Record { - return stripCopilotAgentFrontmatter(fm); +/** OpenCode and Kilo name an agent after its file and give it every tool by default; Kilo + * passes any unknown key to the model provider, so nothing else is carried. */ +export function stripPathIdAgentFrontmatter(fm: Record): Record { + return pickFrontmatterKeys(fm, PATH_ID_AGENT_FRONTMATTER_KEYS); } diff --git a/cli/src/contexts/tools/domain/marketplace-catalog.ts b/cli/src/contexts/tools/domain/marketplace-catalog.ts index 40afff08f..121447d27 100644 --- a/cli/src/contexts/tools/domain/marketplace-catalog.ts +++ b/cli/src/contexts/tools/domain/marketplace-catalog.ts @@ -3,13 +3,11 @@ * * A tool's build contract otherwise lives entirely inside that tool's own profile directory; * these are the exception, since claude and cursor emit byte-identical plugin manifests and - * catalog entries and claude and copilot transform an agent's frontmatter identically. One - * tool importing another's directory would break the boundary, so both reach this instead. + * catalog entries. One tool importing another's directory would break the boundary, so both + * reach this instead. */ import { join } from "node:path"; import { InvalidSourceMarketplaceError } from "../../../kernel/errors.js"; -import { parseFrontmatter, serializeFrontmatter } from "../../../kernel/markdown.js"; -import { rewriteRelativeLinks } from "../../../kernel/materialization/relative-link-rewrite.js"; import type { FileReader } from "../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../kernel/ports/file-writer.js"; import type { PluginPresence } from "./build-contract.js"; @@ -23,16 +21,6 @@ type SrcEntry = } | undefined; -/** Marketplace-mode agent transform shared by claude and copilot: the frontmatter is kept - * untouched and only relative links are rewritten to the flattened output path. */ -export function transformClaudeAgent(content: string, _plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: `agents/${outName}`, - }); - return serializeFrontmatter(frontmatter, rewrittenBody); -} - export interface SynthesizeClaudeStyleManifestOpts { /** When true, include `agents` as a list of `./agents/*.md` file paths if agents are present. */ readonly agentsField: boolean; diff --git a/cli/src/contexts/tools/domain/profiles/claude/build.ts b/cli/src/contexts/tools/domain/profiles/claude/build.ts index 7bcdb38f2..7d9f8775d 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/build.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/build.ts @@ -20,8 +20,8 @@ import { buildClaudeStyleEntry, buildClaudeStyleMarketplace, synthesizeClaudeStyleManifest, - transformClaudeAgent, } from "../../marketplace-catalog.js"; +import { claudeAgentFrontmatter } from "./claude-agent.js"; import { OUTPUT_CLAUDE_MANIFEST_RELATIVE, OUTPUT_CLAUDE_MARKETPLACE_RELATIVE, @@ -94,6 +94,14 @@ function claudeFlatHooksPath(plugin: string, rel: string): string { return genericFlatHooksScriptPath(".claude/hooks/", plugin, rest); } +function transformClaudeAgent(content: string, _plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: `agents/${outName}`, + }); + return serializeFrontmatter(claudeAgentFrontmatter(frontmatter), rewrittenBody); +} + function claudeFlatResolveTarget(plugin: string, rel: string): string { if (rel.startsWith("agents/")) return claudeFlatAgentPath(plugin, rel); if (rel.startsWith("skills/")) return claudeFlatSkillPath(plugin, rel); @@ -107,8 +115,8 @@ function transformClaudeFlatAgent(content: string, plugin: string, outName: stri currentFilePluginRelative: flatRelPath, resolveTargetPath: (rel) => claudeFlatResolveTarget(plugin, rel), }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...frontmatter, name: prefixedName }, rewrittenBody); + const name = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter(claudeAgentFrontmatter({ ...frontmatter, name }), rewrittenBody); } export function buildClaudeFlatContract(): ToolBuildContract { diff --git a/cli/src/contexts/tools/domain/profiles/claude/claude-agent.ts b/cli/src/contexts/tools/domain/profiles/claude/claude-agent.ts new file mode 100644 index 000000000..792e6246d --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/claude/claude-agent.ts @@ -0,0 +1,5 @@ +import { type AgentFrontmatter, pickFrontmatterKeys } from "../../formats/agent-format.js"; + +/** Claude Code reads its own keys; an agent without `tools` inherits every tool. */ +export const claudeAgentFrontmatter: AgentFrontmatter = (fm) => + pickFrontmatterKeys(fm, ["name", "description", "model", "effort"]); diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index 2eeeeb2c6..a09f3778f 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -20,6 +20,7 @@ import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; import { claudeStyleMarketplaceKey } from "../../marketplace-entry.js"; import { registerTool } from "../../registry.js"; import { buildClaudeContract, buildClaudeFlatContract } from "./build.js"; +import { claudeAgentFrontmatter } from "./claude-agent.js"; const DIRECTORY = ".claude/"; const TOOL_SUFFIX = ".claude.md"; @@ -48,6 +49,7 @@ export const claude: AiTool `${OUTPUT_CODEX_AGENTS_DIR}/${rel.replace(/^agents\//, "").replace(/\.md$/, ".toml")}`, - transform: (content, plugin, outName) => codexAgentMarkdownToToml(content, plugin, outName), + transform: codexAgentMarkdownToToml, }, mcp: { supported: true, @@ -243,8 +243,7 @@ export function buildCodexFlatContract(): ToolBuildContract { supported: true, source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, path: codexFlatAgentPath, - transform: (content, plugin, outName) => - codexAgentMarkdownToToml(content, plugin, outName, true), + transform: codexAgentMarkdownToToml, }, mcp: { supported: false }, // handled by emitConfigArtifact (config.toml mcp_servers) hooks: { diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts index 3ed7225db..672f13017 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts @@ -1,50 +1,30 @@ import { parseFrontmatter } from "../../../../../kernel/markdown.js"; +import { type AgentFrontmatter, pickFrontmatterKeys } from "../../formats/agent-format.js"; import { stringifyToml } from "./toml.js"; -/** - * Converts a Claude-format agent markdown file (frontmatter + body) into a Codex subagent TOML - * string. Key insertion order is fixed for deterministic output, and the conversion is lossy โ€” - * no model field is emitted and the TOML schema diverges from the frontmatter, so there is no - * inverse. - * - * `prefixName` is for flat mode, where every plugin shares one `.codex/agents/` directory and - * the plugin prefix is what keeps two plugins' agents from colliding. - */ +/** Codex requires `name` and `description`, and reads the effort as `model_reasoning_effort`. */ +export const codexAgentFrontmatter: AgentFrontmatter = (fm) => ({ + name: typeof fm.name === "string" ? fm.name : "", + description: typeof fm.description === "string" ? fm.description : "", + ...pickFrontmatterKeys({ ...fm, model_reasoning_effort: fm.effort }, [ + "model", + "model_reasoning_effort", + ]), +}); + +/** Writes a Codex subagent from its native frontmatter; the body is its instructions. */ +export function codexAgentToml(frontmatter: Record, body: string): string { + return stringifyToml({ ...frontmatter, developer_instructions: body }); +} + +/** Built distributions prefix the name with the plugin: Codex loads agents only from one + * shared `agents/` directory, never from a plugin, so the prefix keeps two plugins apart. */ export function codexAgentMarkdownToToml( content: string, pluginName: string, - fileBaseName: string, - prefixName = false + fileBaseName: string ): string { const { frontmatter, body } = parseFrontmatter(content); - const name = resolveName(frontmatter, pluginName, fileBaseName, prefixName); - const obj = buildTomlObject(name, frontmatter, body); - return stringifyToml(obj); -} - -function resolveName( - frontmatter: Record, - pluginName: string, - fileBaseName: string, - prefixName: boolean -): string { - const basename = fileBaseName.replace(/\.md$/, ""); - if (!prefixName && typeof frontmatter.name === "string" && frontmatter.name.length > 0) { - return frontmatter.name; - } - return `${pluginName}-${basename}`; -} - -function buildTomlObject( - name: string, - frontmatter: Record, - body: string -): Record { - const obj: Record = {}; - obj.name = name; - // description is a required subagent key; default to "" when absent. - obj.description = typeof frontmatter.description === "string" ? frontmatter.description : ""; - // model is intentionally omitted: no known Codex model id set. - obj.developer_instructions = body; - return obj; + const name = `${pluginName}-${fileBaseName.replace(/\.md$/, "")}`; + return codexAgentToml(codexAgentFrontmatter({ ...frontmatter, name }), body); } diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index 1488b185d..320807e35 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -30,6 +30,7 @@ import { mergeCodexConfigToml, stripCodexSkillFrontmatter, } from "./build.js"; +import { codexAgentFrontmatter, codexAgentToml } from "./codex-agent-toml.js"; const DIRECTORY = ".codex/"; const TOOL_SUFFIX = ".codex.md"; @@ -138,7 +139,13 @@ export const codex: AiTool< buildContracts: { marketplace: buildCodexContract, flat: buildCodexFlatContract }, capabilities: { - agents: new AgentsCapability({ directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, format: "toml" }), + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "toml", + frontmatter: codexAgentFrontmatter, + serialize: codexAgentToml, + }), skills: new SkillsCapability({ prefix: "aidd-", buildInstallPath: buildCodexSkillFilePath, diff --git a/cli/src/contexts/tools/domain/profiles/copilot/build.ts b/cli/src/contexts/tools/domain/profiles/copilot/build.ts index 4bb9deaff..ff6cb5bfa 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/build.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/build.ts @@ -13,7 +13,6 @@ import { } from "../../../../../kernel/materialization/flat-paths.js"; import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; import type { ToolBuildContract } from "../../build-contract.js"; -import { stripAgentFrontmatter } from "../../formats/agent-frontmatter-strip.js"; import { flattenCopilotHooksShape } from "../../formats/flat-hooks-merge.js"; import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; import { mergeVscodeMcp } from "../../formats/vscode-mcp-merge.js"; @@ -21,8 +20,8 @@ import { resolveDescription, resolveVersion, synthesizeClaudeStyleManifest, - transformClaudeAgent, } from "../../marketplace-catalog.js"; +import { copilotAgentFrontmatter } from "./copilot-agent.js"; import { COPILOT_VSCODE_MCP_PATH, COPILOT_WORKSPACE_DIR } from "./copilot-paths.js"; const OUTPUT_PLUGIN_MANIFEST_RELATIVE = ".plugin/plugin.json"; @@ -64,7 +63,7 @@ export function buildCopilotMarketplaceContract(): ToolBuildContract { supported: true, source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, path: (_p, rel) => rel, - transform: transformClaudeAgent, + transform: transformCopilotAgent, }, mcp: { supported: true, @@ -122,16 +121,23 @@ function copilotFlatHooksPath(plugin: string, rel: string): string { return genericFlatHooksScriptPath(FLAT_GITHUB_HOOKS_PREFIX, plugin, rest); } +function transformCopilotAgent(content: string, _plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: `agents/${outName}`, + }); + return serializeFrontmatter(copilotAgentFrontmatter(frontmatter), rewrittenBody); +} + function transformCopilotFlatAgent(content: string, plugin: string, outName: string): string { const { frontmatter, body } = parseFrontmatter(content); - const stripped = stripAgentFrontmatter(frontmatter); const flatRelPath = copilotFlatAgentPath(plugin, `agents/${outName}`); const rewrittenBody = rewriteRelativeLinks(body, { currentFilePluginRelative: flatRelPath, resolveTargetPath: (rel) => copilotFlatResolveTarget(plugin, rel), }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); + const name = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter(copilotAgentFrontmatter({ ...frontmatter, name }), rewrittenBody); } function copilotFlatResolveTarget(plugin: string, rel: string): string { diff --git a/cli/src/contexts/tools/domain/profiles/copilot/copilot-agent.ts b/cli/src/contexts/tools/domain/profiles/copilot/copilot-agent.ts new file mode 100644 index 000000000..3d4dc78a5 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/copilot/copilot-agent.ts @@ -0,0 +1,13 @@ +import { type AgentFrontmatter, pickFrontmatterKeys } from "../../formats/agent-format.js"; + +/** Copilot reads `effort` as `reasoningEffort`, and gives a subagent the repository + * instructions only when `include-custom-instructions` is set; it defaults to false. */ +export const copilotAgentFrontmatter: AgentFrontmatter = (fm) => + pickFrontmatterKeys( + { + ...fm, + reasoningEffort: fm.effort, + "include-custom-instructions": fm["include-custom-instructions"] ?? true, + }, + ["name", "description", "model", "reasoningEffort", "include-custom-instructions"] + ); diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts index 46c17c0d5..b798a7a01 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -24,6 +24,7 @@ import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; import { claudeStyleMarketplaceKey } from "../../marketplace-entry.js"; import { registerTool } from "../../registry.js"; import { buildCopilotFlatContract, buildCopilotMarketplaceContract } from "./build.js"; +import { copilotAgentFrontmatter } from "./copilot-agent.js"; import { COPILOT_WORKSPACE_DIR } from "./copilot-paths.js"; const DIRECTORY = COPILOT_WORKSPACE_DIR; @@ -95,11 +96,6 @@ const agentsHandler = { const name = base.endsWith(".md") ? `${base.slice(0, -3)}${EXT_AGENT}` : base; return `${DIRECTORY}agents/${name}`; }, - convertFrontmatter(fm: Record, fileName?: string): Record { - const base = fileName?.split("/").at(-1); - const name = fm.name ?? base?.replace(/\.md$/, ""); - return { name: typeof name === "string" ? name : undefined, description: fm.description }; - }, }; const commandsHandler = { @@ -226,7 +222,7 @@ export const copilot: AiTool< format: "markdown", userFileExt: EXT_AGENT, buildInstallPath: (fileName) => agentsHandler.buildFilePath(fileName), - convertFrontmatter: (fm, fileName) => agentsHandler.convertFrontmatter(fm, fileName), + frontmatter: copilotAgentFrontmatter, }), skills: new SkillsCapability({ directory: DIRECTORY, diff --git a/cli/src/contexts/tools/domain/profiles/cursor/build.ts b/cli/src/contexts/tools/domain/profiles/cursor/build.ts index 55c90fe60..6cd0965a9 100644 --- a/cli/src/contexts/tools/domain/profiles/cursor/build.ts +++ b/cli/src/contexts/tools/domain/profiles/cursor/build.ts @@ -13,7 +13,6 @@ import { } from "../../../../../kernel/materialization/flat-paths.js"; import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; import type { ToolBuildContract } from "../../build-contract.js"; -import { stripCursorAgentFrontmatter } from "../../formats/agent-frontmatter-strip.js"; import { mergeCursorFlatHooks } from "../../formats/flat-hooks-merge.js"; import { CURSOR_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; import { mergeVscodeMcp } from "../../formats/vscode-mcp-merge.js"; @@ -22,18 +21,23 @@ import { buildClaudeStyleMarketplace, synthesizeClaudeStyleManifest, } from "../../marketplace-catalog.js"; +import { cursorAgentFrontmatter } from "./cursor-agent.js"; import { OUTPUT_CURSOR_MANIFEST_RELATIVE, OUTPUT_CURSOR_MARKETPLACE_RELATIVE, } from "./cursor-paths.js"; -function transformCursorAgent(content: string, _plugin: string, outName: string): string { +function transformCursorAgent(content: string, plugin: string, outName: string): string { const { frontmatter, body } = parseFrontmatter(content); - const stripped = stripCursorAgentFrontmatter(frontmatter); const rewrittenBody = rewriteRelativeLinks(body, { currentFilePluginRelative: `agents/${outName}`, }); - return serializeFrontmatter(stripped, rewrittenBody); + const name = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter( + cursorAgentFrontmatter({ ...frontmatter, name }), + rewrittenBody, + "double" + ); } export function buildCursorContract(): ToolBuildContract { @@ -108,14 +112,17 @@ function cursorFlatResolveTarget(plugin: string, rel: string): string { function transformCursorFlatAgent(content: string, plugin: string, outName: string): string { const { frontmatter, body } = parseFrontmatter(content); - const stripped = stripCursorAgentFrontmatter(frontmatter); const flatRelPath = cursorFlatAgentPath(plugin, `agents/${outName}`); const rewrittenBody = rewriteRelativeLinks(body, { currentFilePluginRelative: flatRelPath, resolveTargetPath: (rel) => cursorFlatResolveTarget(plugin, rel), }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); + const name = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter( + cursorAgentFrontmatter({ ...frontmatter, name }), + rewrittenBody, + "double" + ); } export function buildCursorFlatContract(): ToolBuildContract { diff --git a/cli/src/contexts/tools/domain/profiles/cursor/cursor-agent.ts b/cli/src/contexts/tools/domain/profiles/cursor/cursor-agent.ts new file mode 100644 index 000000000..0537492e0 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/cursor/cursor-agent.ts @@ -0,0 +1,13 @@ +import { + type AgentFrontmatter, + modelWithEffort, + pickFrontmatterKeys, +} from "../../formats/agent-format.js"; + +/** Cursor has no effort key: it reads `model[effort=]`. */ +export const cursorAgentFrontmatter: AgentFrontmatter = (fm) => + pickFrontmatterKeys({ ...fm, model: modelWithEffort(fm, "[effort=", "]") }, [ + "name", + "description", + "model", + ]); diff --git a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts index c7556a36d..6b24dea78 100644 --- a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts @@ -23,6 +23,7 @@ import { import { CURSOR_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; import { registerTool } from "../../registry.js"; import { buildCursorContract, buildCursorFlatContract } from "./build.js"; +import { cursorAgentFrontmatter } from "./cursor-agent.js"; const DIRECTORY = ".cursor/"; const TOOL_SUFFIX = ".cursor.md"; @@ -52,6 +53,9 @@ export const cursor: AiTool kiloFlatResolveTarget(plugin, rel), }); - return serializeFrontmatter( - { ...frontmatter, name: `${plugin}-${outName.replace(/\.md$/, "")}`, mode: "subagent" }, - rewrittenBody - ); + return serializeFrontmatter(kiloAgentFrontmatter(frontmatter), rewrittenBody); } async function collectKiloMcp( diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-agent.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-agent.ts new file mode 100644 index 000000000..e19a9fa08 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-agent.ts @@ -0,0 +1,8 @@ +import { type AgentFrontmatter, pickFrontmatterKeys } from "../../formats/agent-format.js"; + +/** Kilo names an agent after its file, reads the effort as the model's `variant`, and passes + * any unknown key to the model provider, so only its own keys are carried. */ +export const kiloAgentFrontmatter: AgentFrontmatter = (fm) => ({ + ...pickFrontmatterKeys({ ...fm, variant: fm.effort }, ["description", "model", "variant"]), + mode: "subagent", +}); diff --git a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts index 44717420e..708236ad2 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/profile.ts @@ -22,6 +22,7 @@ import { import { registerTool } from "../../registry.js"; import { transformMcpToOpencode } from "../opencode/build.js"; import { buildKiloFlatContract } from "./build.js"; +import { kiloAgentFrontmatter } from "./kilo-agent.js"; import { generateKiloHooksBridge } from "./kilo-hooks-bridge.js"; import { KILO_DIRECTORY, @@ -50,7 +51,7 @@ export const kilo: AiTool ({ description: fm.description, mode: "subagent" }), + frontmatter: kiloAgentFrontmatter, }), skills: new SkillsCapability({ directory: KILO_DIRECTORY, diff --git a/cli/src/contexts/tools/domain/profiles/opencode/build.ts b/cli/src/contexts/tools/domain/profiles/opencode/build.ts index c3c7d31be..c7462d7ea 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/build.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/build.ts @@ -17,6 +17,7 @@ import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; import type { ArtifactContract, ToolBuildContract } from "../../build-contract.js"; import { buildOpencodeFlatConfig } from "../../formats/opencode-mcp-merge.js"; +import { opencodeAgentFrontmatter } from "./opencode-agent.js"; import { generateOpencodeHooksBridge } from "./opencode-hooks-bridge.js"; import { makeOpencodeHooksBridgePath, @@ -132,12 +133,7 @@ function transformOpencodeFlatAgent(content: string, plugin: string, outName: st currentFilePluginRelative: flatRelPath, resolveTargetPath: (rel) => opencodeFlatResolveTarget(plugin, rel), }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - // mode: subagent ensures opencode treats copied agents as subagents, not primary agents. - return serializeFrontmatter( - { ...frontmatter, name: prefixedName, mode: "subagent" }, - rewrittenBody - ); + return serializeFrontmatter(opencodeAgentFrontmatter(frontmatter), rewrittenBody); } async function resolveOpencodeJsonPath(outDir: string, fs: FsType): Promise { diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-agent.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-agent.ts new file mode 100644 index 000000000..17bd4f4e4 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-agent.ts @@ -0,0 +1,12 @@ +import { + type AgentFrontmatter, + modelWithEffort, + pickFrontmatterKeys, +} from "../../formats/agent-format.js"; + +/** OpenCode names an agent after its file, so no `name`; `mode: subagent` keeps it out of + * the primary agents. V2 reads the effort as the model's `#variant`. */ +export const opencodeAgentFrontmatter: AgentFrontmatter = (fm) => ({ + ...pickFrontmatterKeys({ ...fm, model: modelWithEffort(fm, "#") }, ["description", "model"]), + mode: "subagent", +}); diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index a44e83716..6805756d9 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -23,6 +23,7 @@ import { } from "../../formats/command.js"; import { registerTool } from "../../registry.js"; import { buildOpencodeFlatContract, transformMcpToOpencode } from "./build.js"; +import { opencodeAgentFrontmatter } from "./opencode-agent.js"; import { generateOpencodeHooksBridge } from "./opencode-hooks-bridge.js"; import { makeOpencodeHooksBridgePath, @@ -58,7 +59,7 @@ export const opencode: AiTool< directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, format: "markdown", - convertFrontmatter: (fm) => ({ description: fm.description, mode: "subagent" }), + frontmatter: opencodeAgentFrontmatter, }), skills: new SkillsCapability({ directory: DIRECTORY, diff --git a/cli/src/contexts/translate/domain/content-translator.ts b/cli/src/contexts/translate/domain/content-translator.ts index bcd8b9c9f..bc8525d4c 100644 --- a/cli/src/contexts/translate/domain/content-translator.ts +++ b/cli/src/contexts/translate/domain/content-translator.ts @@ -349,6 +349,15 @@ export class PluginContentTranslator { // Same rule as the native path: prose is rewritten, an artefact is carried. Rewriting every // flat file left a script intact only where a tool's own rewrite happened to spare it. const content = isProse(file.relativePath) ? tool.rewriteContent(file.content) : file.content; + if (section === "agents" && hasAgents(tool)) { + const artifact = tool.buildContracts?.flat?.().artifacts.agents; + if (artifact?.supported) { + return this.makeFile( + artifact.path(pluginName, file.relativePath), + artifact.transform?.(content, pluginName, fileName) ?? content + ); + } + } return this.makeFile(`${tool.directory}${section}/${pluginName}/${fileName}`, content); } diff --git a/cli/src/kernel/markdown.ts b/cli/src/kernel/markdown.ts index 2be1507cc..7b55fd2aa 100644 --- a/cli/src/kernel/markdown.ts +++ b/cli/src/kernel/markdown.ts @@ -28,7 +28,12 @@ export function parseFrontmatter(content: string): { return { frontmatter, body }; } -export function serializeFrontmatter(frontmatter: Record, body: string): string { +/** `quote: "double"` writes strings as YAML double-quoted scalars, for readers that keep single quotes literally. */ +export function serializeFrontmatter( + frontmatter: Record, + body: string, + quote: "single" | "double" = "single" +): string { if (Object.keys(frontmatter).length === 0) { return body.replace(/^\n/, ""); } @@ -53,7 +58,11 @@ export function serializeFrontmatter(frontmatter: Record, body: if (s.startsWith("[") && s.endsWith("]")) { lines.push(`${key}: ${s}`); } else { - lines.push(`${key}: '${s.replaceAll("'", "''")}'`); + lines.push( + quote === "double" + ? `${key}: ${JSON.stringify(s)}` + : `${key}: '${s.replaceAll("'", "''")}'` + ); } } } diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts index f89233b76..98e7bfefa 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts @@ -1,5 +1,5 @@ // OpenCode uses Mode B with `mode: "flat"` and project scope, so `translateFlat` writes at -// `.opencode/
//`, never under one `.opencode/plugins//` root. +// section-specific native paths, never under one `.opencode/plugins//` root. import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { describe, expect, it } from "vitest"; import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; @@ -39,7 +39,7 @@ function buildDist(name = "aidd-context"): PluginDistribution { } describe("install opencode plugin via Mode B (integration)", () => { - it("materializes flat plugin files under .opencode/
// at projectRoot", async () => { + it("materializes project commands and namespaced native agents", async () => { const fs = new InMemoryFileAdapter(); const hasher = new DeterministicHasher(); const adapter = new ModeBFlatMaterializationTranslator(fs, hasher, () => STUB_HOME); @@ -59,9 +59,7 @@ describe("install opencode plugin via Mode B (integration)", () => { expect( written.some((p) => p.startsWith(`${PROJECT_ROOT}/.opencode/commands/aidd-context/`)) ).toBe(true); - expect( - written.some((p) => p.startsWith(`${PROJECT_ROOT}/.opencode/agents/aidd-context/`)) - ).toBe(true); + expect(written).toContain(`${PROJECT_ROOT}/.opencode/agents/aidd-context-coach.md`); }); it("does not write any file under user home for project-scope flat tools", async () => { diff --git a/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts index ca835c969..7798d089c 100644 --- a/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts @@ -1,17 +1,21 @@ import { describe, expect, it } from "vitest"; import { AgentsCapability } from "../../../../../src/contexts/tools/domain/capabilities/agents-capability.js"; +const identity = (fm: Record) => fm; + describe("AgentsCapability", () => { const markdownParams = { directory: ".claude/", toolSuffix: ".claude.md", format: "markdown" as const, + frontmatter: identity, }; const tomlParams = { directory: ".codex/", toolSuffix: ".codex.md", format: "toml" as const, + frontmatter: identity, }; describe("buildOutputPath", () => { @@ -48,24 +52,12 @@ describe("AgentsCapability", () => { }); }); - describe("serialize (toml)", () => { - it("produces TOML format with name, description, and developer_instructions", () => { - const cap = new AgentsCapability(tomlParams); - const result = cap.serialize({ name: "my-agent", description: "A test" }, "Body text."); - expect(result).toContain('name = "my-agent"'); - expect(result).toContain('description = "A test"'); - expect(result).toContain("developer_instructions"); - expect(result).toContain("Body text."); - }); - - it("includes model field when provided", () => { - const cap = new AgentsCapability(tomlParams); - const result = cap.serialize( - { name: "agent", description: "desc", model: "claude-3" }, - "body" - ); - expect(result).toContain('model = "claude-3"'); + it("writes through the host's own serializer when it declares one", () => { + const cap = new AgentsCapability({ + ...tomlParams, + serialize: (fm, body) => `${fm.name}|${body}`, }); + expect(cap.serialize({ name: "a" }, "b")).toBe("a|b"); }); describe("deserialize", () => { @@ -111,11 +103,13 @@ describe("AgentsCapability install paths", () => { directory: ".claude/", toolSuffix: ".claude.md", format: "markdown", + frontmatter: identity, }); const toml = new AgentsCapability({ directory: ".codex/", toolSuffix: ".codex.md", format: "toml", + frontmatter: identity, }); it("installs a markdown agent under agents/ by its file name alone, the tool suffix dropped", () => { @@ -137,6 +131,7 @@ describe("AgentsCapability install paths", () => { directory: ".x/", toolSuffix: ".x.md", format: "markdown", + frontmatter: identity, buildInstallPath: (fileName) => `custom/${fileName}`, }); @@ -149,6 +144,7 @@ describe("AgentsCapability file acceptance", () => { directory: ".claude/", toolSuffix: ".claude.md", format: "markdown", + frontmatter: identity, }); const suffixes = [".claude.md", ".cursor.md", ".codex.md"]; @@ -163,98 +159,37 @@ describe("AgentsCapability file acceptance", () => { }); describe("AgentsCapability frontmatter conversion", () => { - const markdown = new AgentsCapability({ - directory: ".claude/", - toolSuffix: ".claude.md", + const cap = new AgentsCapability({ + directory: ".x/", + toolSuffix: ".x.md", format: "markdown", - }); - const toml = new AgentsCapability({ - directory: ".codex/", - toolSuffix: ".codex.md", - format: "toml", - }); - - it("keeps the declared name and description, nothing else", () => { - expect( - markdown.convertFrontmatter({ name: "planner", description: "d", tools: ["x"] }) - ).toStrictEqual({ name: "planner", description: "d" }); - }); - - it("derives the name from the file's own base name when the frontmatter declares none", () => { - expect( - markdown.convertFrontmatter({ description: "d" }, "agents/sub/planner.md") - ).toStrictEqual({ name: "planner", description: "d" }); - }); - - it("leaves the name undefined when neither the frontmatter nor a file name gives one", () => { - expect(markdown.convertFrontmatter({ description: "d" })).toStrictEqual({ - name: undefined, - description: "d", - }); - }); - - it("refuses a declared name that is not a string", () => { - expect(markdown.convertFrontmatter({ name: 7, description: "d" })).toStrictEqual({ - name: undefined, - description: "d", - }); + frontmatter: (fm) => ({ native: fm }), }); - it("carries the model into a toml agent only when one is declared", () => { - expect(toml.convertFrontmatter({ name: "p", description: "d", model: "m" })).toStrictEqual({ - name: "p", - description: "d", - model: "m", - }); - expect(toml.convertFrontmatter({ name: "p", description: "d" })).toStrictEqual({ - name: "p", - description: "d", + it("writes through the host's own frontmatter, the declared name kept", () => { + expect(cap.convertFrontmatter({ name: "planner", description: "d" })).toStrictEqual({ + native: { name: "planner", description: "d" }, }); }); - it("asks the converter the tool declares before converting itself", () => { - const delegating = new AgentsCapability({ - directory: ".x/", - toolSuffix: ".x.md", - format: "markdown", - convertFrontmatter: (fm, fileName) => ({ file: fileName, keys: Object.keys(fm) }), - }); - - expect(delegating.convertFrontmatter({ name: "p" }, "p.md")).toStrictEqual({ - file: "p.md", - keys: ["name"], + it("names an agent after its file when the frontmatter declares none", () => { + expect(cap.convertFrontmatter({ description: "d" }, "agents/sub/planner.md")).toStrictEqual({ + native: { description: "d", name: "planner" }, }); }); -}); - -describe("AgentsCapability toml serialization", () => { - const toml = new AgentsCapability({ - directory: ".codex/", - toolSuffix: ".codex.md", - format: "toml", - }); - it("writes name, description and the body as developer instructions, one key per line", () => { - expect(toml.serialize({ name: "p", description: "d" }, "body")).toBe( - 'name = "p"\ndescription = "d"\ndeveloper_instructions = """\nbody\n"""\n' - ); - }); - - it("writes an empty name and description when the frontmatter declares none", () => { - expect(toml.serialize({}, "body")).toBe( - 'name = ""\ndescription = ""\ndeveloper_instructions = """\nbody\n"""\n' - ); - }); - - it("escapes a quote and a backslash inside a value", () => { - expect(toml.serialize({ name: 'a"b\\c', description: "d" }, "body")).toBe( - 'name = "a\\"b\\\\c"\ndescription = "d"\ndeveloper_instructions = """\nbody\n"""\n' - ); + it("refuses a declared name that is not a string", () => { + expect(cap.convertFrontmatter({ name: 7 })).toStrictEqual({ native: { name: undefined } }); }); }); describe("AgentsCapability equality", () => { - const base = { directory: ".claude/", toolSuffix: ".claude.md", format: "markdown" as const }; + const base = { + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown" as const, + frontmatter: identity, + }; it("differs on the tool suffix", () => { expect( diff --git a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts index 0503162b0..8d9b51fc7 100644 --- a/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts @@ -2,75 +2,68 @@ import { describe, expect, it } from "vitest"; import { COPILOT_AGENT_FRONTMATTER_KEYS, CURSOR_AGENT_FRONTMATTER_KEYS, - stripAgentFrontmatter, + stripCopilotAgentFrontmatter, stripCursorAgentFrontmatter, + stripPathIdAgentFrontmatter, } from "../../../../../src/contexts/tools/domain/formats/agent-frontmatter-strip.js"; -describe("stripAgentFrontmatter", () => { - describe("keeps allowlisted keys", () => { - it("preserves all six allowed keys when all are present", () => { - const input = { +describe("stripCopilotAgentFrontmatter", () => { + it("keeps name, description and model, and reads Claude's effort as reasoningEffort", () => { + expect( + stripCopilotAgentFrontmatter({ name: "reviewer", description: "Reviews code", model: "claude-sonnet", - tools: ["Read", "Write"], - agents: ["helper"], - "argument-hint": "[file]", - }; - const result = stripAgentFrontmatter(input); - expect(result).toEqual(input); - }); - - it("preserves a subset of allowed keys", () => { - const input = { name: "reviewer", description: "Reviews code" }; - const result = stripAgentFrontmatter(input); - expect(result).toEqual({ name: "reviewer", description: "Reviews code" }); + effort: "high", + }) + ).toEqual({ + name: "reviewer", + description: "Reviews code", + model: "claude-sonnet", + reasoningEffort: "high", + "include-custom-instructions": true, }); }); - describe("drops non-allowlisted keys", () => { - it("drops paths key", () => { - const input = { name: "reviewer", paths: ["src/**/*.ts"] }; - const result = stripAgentFrontmatter(input); - expect(result).not.toHaveProperty("paths"); - expect(result).toHaveProperty("name"); - }); - - it("drops tags key", () => { - const input = { name: "reviewer", tags: ["code", "review"], description: "Reviews" }; - const result = stripAgentFrontmatter(input); - expect(result).not.toHaveProperty("tags"); - expect(result).toHaveProperty("description"); + it("drops tools and every other key, so the agent keeps Copilot's default of all tools", () => { + const result = stripCopilotAgentFrontmatter({ + name: "reviewer", + tools: ["Read"], + agents: ["helper"], + "argument-hint": "[file]", + color: "red", }); + expect(Object.keys(result)).toEqual(["name", "include-custom-instructions"]); + }); - it("drops multiple unknown keys at once", () => { - const input = { name: "test", version: "1.0", custom: true, description: "desc" }; - const result = stripAgentFrontmatter(input); - expect(Object.keys(result)).toEqual(COPILOT_AGENT_FRONTMATTER_KEYS.filter((k) => k in input)); + it("preserves an explicit repository-instruction opt-out", () => { + expect(stripCopilotAgentFrontmatter({ "include-custom-instructions": false })).toEqual({ + "include-custom-instructions": false, }); }); - describe("drops undefined values", () => { - it("excludes keys whose value is undefined", () => { - const input = { name: "reviewer", model: undefined, description: "desc" }; - const result = stripAgentFrontmatter(input); - expect(result).not.toHaveProperty("model"); - expect(result).toHaveProperty("name"); - expect(result).toHaveProperty("description"); + it("returns keys in allowlist order for deterministic serialization", () => { + const result = stripCopilotAgentFrontmatter({ + effort: "low", + model: "sonnet", + name: "reviewer", + description: "desc", }); + expect(Object.keys(result)).toEqual(COPILOT_AGENT_FRONTMATTER_KEYS); }); +}); - describe("key ordering", () => { - it("returns keys in allowlist order for deterministic serialization", () => { - const input = { - "argument-hint": "[file]", - model: "sonnet", - name: "reviewer", - description: "desc", - }; - const result = stripAgentFrontmatter(input); - expect(Object.keys(result)).toEqual(["name", "description", "model", "argument-hint"]); - }); +describe("stripPathIdAgentFrontmatter", () => { + it("keeps description and model only: the file name is the ID, and no key restricts tools", () => { + expect( + stripPathIdAgentFrontmatter({ + name: "explorer", + description: "d", + model: "openai/gpt", + effort: "high", + tools: "Read", + }) + ).toEqual({ description: "d", model: "openai/gpt" }); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/agent-fixture.ts b/cli/tests/contexts/tools/domain/profiles/agent-fixture.ts new file mode 100644 index 000000000..7a00a6347 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/agent-fixture.ts @@ -0,0 +1,38 @@ +import type { AiTool, HasAgents } from "../../../../../src/contexts/tools/domain/contracts.js"; + +/** A Claude-format agent carrying every key a host may translate. */ +export const NAMED_AGENT = `--- +name: planner +description: Plans the work +model: m +effort: high +--- +Plan before building. +`; + +export const UNNAMED_AGENT = `--- +description: Plans the work +--- +Plan before building. +`; + +/** What a plugin install writes for `planner.md`. */ +export function installed(tool: AiTool, content: string): string { + const agents = tool.capabilities.agents; + const { frontmatter, body } = agents.deserialize(content); + return agents.serialize(agents.convertFrontmatter(frontmatter, "planner.md"), body); +} + +/** Where and what a marketplace build writes for the plugin's `planner.md`. The flat build + * is checked against the shipped agents in `tests/e2e/agent-install.e2e.test.ts`. */ +export function inMarketplace( + tool: AiTool, + content: string +): { path: string; content: string } { + const agents = tool.buildContracts?.marketplace?.().artifacts.agents; + if (!agents?.supported) throw new Error(`${tool.toolId} builds no marketplace agent`); + return { + path: agents.path("aidd-dev", "agents/planner.md"), + content: agents.transform?.(content, "aidd-dev", "planner.md") ?? content, + }; +} diff --git a/cli/tests/contexts/tools/domain/profiles/claude/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/agents.unit.test.ts index cd2cd1304..232e9c633 100644 --- a/cli/tests/contexts/tools/domain/profiles/claude/agents.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/claude/agents.unit.test.ts @@ -1,16 +1,34 @@ import { describe, expect, it } from "vitest"; import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { inMarketplace, installed, NAMED_AGENT, UNNAMED_AGENT } from "../agent-fixture.js"; describe("claude agents", () => { - it("writes agents as markdown", () => { - expect(claude.capabilities.agents.params.format).toBe("markdown"); - }); + describe("name resolution", () => { + it("installs a plugin agent under its declared name, with Claude's own keys only", () => { + expect(installed(claude, NAMED_AGENT)).toBe(`--- +name: 'planner' +description: 'Plans the work' +model: 'm' +effort: 'high' +--- +Plan before building. +`); + }); + + it("names an unnamed plugin agent after its file", () => { + expect(installed(claude, UNNAMED_AGENT)).toBe(`--- +name: 'planner' +description: 'Plans the work' +--- +Plan before building. +`); + }); - describe("capabilities.agents.convertFrontmatter()", () => { - it("strips extra fields for agents sections โ€” only name and description", () => { - const fm = { name: "alexia", description: "Agent", model: "opus" }; - const result = claude.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ name: "alexia", description: "Agent" }); + it("keeps the declared name inside the plugin of a marketplace build", () => { + expect(inMarketplace(claude, NAMED_AGENT)).toStrictEqual({ + path: "agents/planner.md", + content: installed(claude, NAMED_AGENT), + }); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/agents.unit.test.ts index f4435d1ca..726d48511 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/agents.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/agents.unit.test.ts @@ -1,7 +1,40 @@ import { describe, expect, it } from "vitest"; import { codex } from "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { inMarketplace, installed, NAMED_AGENT, UNNAMED_AGENT } from "../agent-fixture.js"; + +const PREFIXED = `name = "aidd-dev-planner" +description = "Plans the work" +model = "m" +model_reasoning_effort = "high" +developer_instructions = "Plan before building.\\n" +`; describe("codex agents", () => { + describe("name resolution", () => { + it("installs a plugin agent under its declared name, effort as model_reasoning_effort", () => { + expect(installed(codex, NAMED_AGENT)).toBe(`name = "planner" +description = "Plans the work" +model = "m" +model_reasoning_effort = "high" +developer_instructions = "Plan before building.\\n" +`); + }); + + it("names an unnamed plugin agent after its file", () => { + expect(installed(codex, UNNAMED_AGENT)).toBe(`name = "planner" +description = "Plans the work" +developer_instructions = "Plan before building.\\n" +`); + }); + + it("prefixes the name with the plugin in a marketplace build, which Codex copies into one directory", () => { + expect(inMarketplace(codex, NAMED_AGENT)).toStrictEqual({ + path: "codex-agents/planner.toml", + content: PREFIXED, + }); + }); + }); + describe("capabilities.agents.buildInstallPath()", () => { it("builds .toml path under .codex/agents/", () => { const path = codex.capabilities.agents.buildInstallPath("alexia.md"); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts index e1966316b..4cc267e8b 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts @@ -9,16 +9,6 @@ import { } from "../../../../../../src/contexts/tools/domain/profiles/codex/build.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; -const AGENT_SOURCE = [ - "---", - "name: planner", - "description: Plans the work", - "model: opus", - "---", - "Plan before building.", - "", -].join("\n"); - const HOOKS_JSON = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, }); @@ -177,14 +167,6 @@ describe("buildCodexContract()", () => { ).toBe("---\nname: 'plan'\ndescription: 'Plan it'\n---\nBody.\n"); }); - it("names an agent after its own frontmatter when the plugin tree keeps them apart", () => { - const transform = supported(buildCodexContract().artifacts.agents).transform; - - expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( - 'name = "planner"\ndescription = "Plans the work"\ndeveloper_instructions = "Plan before building.\\n"\n' - ); - }); - it("renames the events of a plugin's hooks declaration, and copies every other hook file byte for byte", () => { const transform = supported(buildCodexContract().artifacts.hooks).transform; @@ -304,14 +286,6 @@ describe("buildCodexFlatContract()", () => { ); }); - it("names a flat agent after its plugin, whatever its own frontmatter says", () => { - const transform = supported(buildCodexFlatContract().artifacts.agents).transform; - - expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( - 'name = "aidd-dev-planner"\ndescription = "Plans the work"\ndeveloper_instructions = "Plan before building.\\n"\n' - ); - }); - it("nests a plugin's hook scripts under its own folder and merges its declaration into .codex/hooks.json", () => { const hooks = supported(buildCodexFlatContract().artifacts.hooks); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts index 1d1cb9cd5..39ed0ba22 100644 --- a/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts @@ -3,127 +3,36 @@ import { codexAgentMarkdownToToml } from "../../../../../../src/contexts/tools/d import { parseToml } from "../../../../../../src/contexts/tools/domain/profiles/codex/toml.js"; describe("codexAgentMarkdownToToml()", () => { - describe("name resolution (D-16)", () => { - describe("marketplace mode (prefixName=false, default)", () => { - it("uses frontmatter name when present", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\ndescription: A planner.\n---\n\nBody.", + describe("name resolution", () => { + it("prefixes the file's base name with the plugin, whatever the frontmatter name", () => { + expect( + codexAgentMarkdownToToml( + `--- +name: planner +description: A planner. +--- +Body. +`, "aidd-dev", "planner.md" - ); - const parsed = parseToml(toml); - expect(parsed.name).toBe("planner"); - }); - - it("falls back to plugin-prefixed basename when frontmatter name is absent", () => { - const toml = codexAgentMarkdownToToml( - "---\ndescription: A reviewer.\n---\n\nBody.", - "aidd-dev", - "reviewer.md" - ); - const parsed = parseToml(toml); - expect(parsed.name).toBe("aidd-dev-reviewer"); - }); - - it("falls back to plugin-prefixed basename when frontmatter is empty", () => { - const toml = codexAgentMarkdownToToml("No frontmatter here.", "aidd-dev", "implementer.md"); - const parsed = parseToml(toml); - expect(parsed.name).toBe("aidd-dev-implementer"); - }); - }); - - describe("flat mode (prefixName=true)", () => { - it("always uses plugin-prefixed basename (ignores frontmatter name)", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\ndescription: A planner.\n---\n\nBody.", - "aidd-dev", - "planner.md", - true - ); - const parsed = parseToml(toml); - expect(parsed.name).toBe("aidd-dev-planner"); - }); - - it("uses plugin-prefix when frontmatter name is absent", () => { - const toml = codexAgentMarkdownToToml( - "---\ndescription: A reviewer.\n---\n\nBody.", - "aidd-dev", - "reviewer.md", - true - ); - const parsed = parseToml(toml); - expect(parsed.name).toBe("aidd-dev-reviewer"); - }); - - it("uses plugin-prefix when frontmatter is empty", () => { - const toml = codexAgentMarkdownToToml( - "No frontmatter here.", - "aidd-dev", - "implementer.md", - true - ); - const parsed = parseToml(toml); - expect(parsed.name).toBe("aidd-dev-implementer"); - }); - }); - }); - - describe("key order (D-15)", () => { - it("emits name before description before developer_instructions", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\ndescription: A planner.\n---\n\nBody here.", - "aidd-dev", - "planner.md" - ); - const nameIdx = toml.indexOf("name ="); - const descIdx = toml.indexOf("description ="); - const instrIdx = toml.indexOf("developer_instructions ="); - expect(nameIdx).toBeLessThan(descIdx); - expect(descIdx).toBeLessThan(instrIdx); - }); - - it("emits name before developer_instructions when description absent", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\n---\n\nBody here.", - "aidd-dev", - "planner.md" - ); - const nameIdx = toml.indexOf("name ="); - const instrIdx = toml.indexOf("developer_instructions ="); - expect(nameIdx).toBeLessThan(instrIdx); - }); - }); - - describe("model omission (D-5)", () => { - it("omits model key when frontmatter has model: opus", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\ndescription: A planner.\nmodel: opus\n---\n\nBody.", - "aidd-dev", - "planner.md" - ); - expect(toml).not.toContain("model"); + ) + ).toBe(`name = "aidd-dev-planner" +description = "A planner." +developer_instructions = "Body.\\n" +`); }); - it("omits model key when frontmatter has model: sonnet", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\nmodel: sonnet\n---\n\nBody.", - "aidd-dev", - "planner.md" - ); - expect(toml).not.toContain("model"); - }); - - it("omits model key for hypothetical Codex model id (MVP1 always omits)", () => { - const toml = codexAgentMarkdownToToml( - "---\nname: planner\nmodel: gpt-5-codex\n---\n\nBody.", - "aidd-dev", - "planner.md" - ); - expect(toml).not.toContain("model"); + it("names an agent without frontmatter after its plugin and file, description empty", () => { + expect( + codexAgentMarkdownToToml("No frontmatter here.", "aidd-dev", "implementer.md") + ).toBe(`name = "aidd-dev-implementer" +description = "" +developer_instructions = "No frontmatter here." +`); }); }); - describe("verbatim body preservation (D-4)", () => { + describe("verbatim body preservation", () => { it("preserves @./ references untouched in developer_instructions", () => { const body = "See @./foo.md for details."; const toml = codexAgentMarkdownToToml( @@ -176,7 +85,7 @@ describe("codexAgentMarkdownToToml()", () => { }); }); - describe("idempotency (AC #2, D-15)", () => { + describe("idempotency", () => { it("produces byte-identical output on two consecutive calls with identical input", () => { const content = "---\nname: planner\ndescription: A planner.\nmodel: opus\n---\n\nBody with code:\n```bash\necho hi\n```\n"; @@ -235,13 +144,12 @@ describe("the name a Codex agent falls back to", () => { ); }); - it("drops only the trailing .md from the file it names a flat agent after", () => { + it("drops only the trailing .md from the file it names an agent after", () => { expect( codexAgentMarkdownToToml( "---\ndescription: Plans\n---\nBody.\n", "aidd-dev", - "notes.md-helper.md", - true + "notes.md-helper.md" ) ).toBe( 'name = "aidd-dev-notes.md-helper"\ndescription = "Plans"\ndeveloper_instructions = "Body.\\n"\n' diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts index 873d07160..f164121bf 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/agents.unit.test.ts @@ -1,46 +1,48 @@ import { describe, expect, it } from "vitest"; import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { inMarketplace, installed, NAMED_AGENT, UNNAMED_AGENT } from "../agent-fixture.js"; -describe("copilot agents", () => { - describe("capabilities.agents.convertFrontmatter()", () => { - it("strips extra fields for agents sections โ€” only name and description", () => { - const fm = { name: "alexia", description: "Agent", model: "opus" }; - const result = copilot.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ name: "alexia", description: "Agent" }); - }); +const NATIVE = `--- +name: 'planner' +description: 'Plans the work' +model: 'm' +reasoningEffort: 'high' +include-custom-instructions: true +--- +Plan before building. +`; - it("names an unnamed agent after its own file, whatever folders it sits in", () => { - expect( - copilot.capabilities.agents.convertFrontmatter( - { description: "Reviews code" }, - "team/reviews/code-reviewer.md" - ) - ).toStrictEqual({ name: "code-reviewer", description: "Reviews code" }); +describe("copilot agents", () => { + describe("name resolution", () => { + it("installs a plugin agent under its declared name, with the repository instructions", () => { + expect(installed(copilot, NAMED_AGENT)).toBe(NATIVE); }); - it("drops only the trailing .md from the file it names an agent after", () => { - expect( - copilot.capabilities.agents.convertFrontmatter( - { description: "Helps" }, - "notes.md-helper.md" - ) - ).toStrictEqual({ name: "notes.md-helper", description: "Helps" }); + it("names an unnamed plugin agent after its file", () => { + expect(installed(copilot, UNNAMED_AGENT)).toBe(`--- +name: 'planner' +description: 'Plans the work' +include-custom-instructions: true +--- +Plan before building. +`); }); - it("leaves an agent unnamed when neither its frontmatter nor a file name gives one", () => { - expect( - copilot.capabilities.agents.convertFrontmatter({ description: "Helps" }) - ).toStrictEqual({ - name: undefined, - description: "Helps", + it("keeps the declared name inside the plugin of a marketplace build", () => { + expect(inMarketplace(copilot, NAMED_AGENT)).toStrictEqual({ + path: "agents/planner.md", + content: NATIVE, }); }); + }); - it("drops a name that is not a string rather than writing it", () => { - expect( - copilot.capabilities.agents.convertFrontmatter({ name: 42, description: "Helps" }, "x.md") - ).toStrictEqual({ name: undefined, description: "Helps" }); - }); + it("keeps an explicit opt-out of the repository instructions", () => { + expect( + copilot.capabilities.agents.convertFrontmatter({ + name: "reviewer", + "include-custom-instructions": false, + }) + ).toHaveProperty("include-custom-instructions", false); }); describe("capabilities.agents.buildInstallPath()", () => { diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts index 356012722..f3f3f0147 100644 --- a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts @@ -7,6 +7,7 @@ import { buildCopilotFlatContract, buildCopilotMarketplaceContract, } from "../../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { parseFrontmatter } from "../../../../../../src/kernel/markdown.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; // Built, not written literally: biome reads a string holding "${...}" as a lost template. @@ -114,7 +115,7 @@ describe("buildCopilotMarketplaceContract()", () => { }); }); - it("keeps an agent's whole frontmatter and links its plugin-root references from agents/", () => { + it("keeps what Copilot reads of an agent and links its plugin-root references from agents/", () => { const transform = supported(buildCopilotMarketplaceContract().artifacts.agents).transform; expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( @@ -123,7 +124,7 @@ describe("buildCopilotMarketplaceContract()", () => { "name: 'planner'", "description: 'Plans the work'", "model: 'opus'", - "color: 'blue'", + "include-custom-instructions: true", "---", "Read [SKILL.md](../skills/01-plan/SKILL.md)", "Ask [reviewer.md](./reviewer.md)", @@ -287,6 +288,7 @@ describe("buildCopilotFlatContract()", () => { "name: 'aidd-dev-planner'", "description: 'Plans the work'", "model: 'opus'", + "include-custom-instructions: true", "---", "Read [SKILL.md](../skills/aidd-dev-01-plan/SKILL.md)", "Ask [reviewer.md](./aidd-dev-reviewer.agent.md)", @@ -301,6 +303,23 @@ describe("buildCopilotFlatContract()", () => { expect( transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") - ).toBe("---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\n---\nBody.\n"); + ).toBe( + "---\nname: 'aidd-dev-notes.md-helper'\ndescription: 'Helps'\ninclude-custom-instructions: true\n---\nBody.\n" + ); }); + + it.each([true, false])( + "preserves repository instruction selection %s in flat agents", + (include) => { + const transform = supported(buildCopilotFlatContract().artifacts.agents).transform; + const result = transform?.( + `---\nname: reviewer\ninclude-custom-instructions: ${include}\n---\nReview.\n`, + "aidd-dev", + "reviewer.md" + ); + expect(parseFrontmatter(result ?? "").frontmatter["include-custom-instructions"]).toBe( + include + ); + } + ); }); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts index 49080dcc5..408cddda2 100644 --- a/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/cursor/agents.unit.test.ts @@ -1,12 +1,41 @@ import { describe, expect, it } from "vitest"; import { cursor } from "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { inMarketplace, installed, NAMED_AGENT, UNNAMED_AGENT } from "../agent-fixture.js"; + +const PREFIXED = `--- +name: "aidd-dev-planner" +description: "Plans the work" +model: "m[effort=high]" +--- +Plan before building. +`; describe("cursor agents", () => { - describe("capabilities.agents.convertFrontmatter()", () => { - it("strips extra fields for agents sections โ€” only name and description", () => { - const fm = { name: "alexia", description: "Agent", model: "opus" }; - const result = cursor.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ name: "alexia", description: "Agent" }); + describe("name resolution", () => { + it("installs a plugin agent under its declared name, double-quoted, effort inside the model", () => { + expect(installed(cursor, NAMED_AGENT)).toBe(`--- +name: "planner" +description: "Plans the work" +model: "m[effort=high]" +--- +Plan before building. +`); + }); + + it("names an unnamed plugin agent after its file", () => { + expect(installed(cursor, UNNAMED_AGENT)).toBe(`--- +name: "planner" +description: "Plans the work" +--- +Plan before building. +`); + }); + + it("prefixes the name with the plugin in a marketplace build", () => { + expect(inMarketplace(cursor, NAMED_AGENT)).toStrictEqual({ + path: "agents/planner.md", + content: PREFIXED, + }); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts index deff0395d..98cc27e6b 100644 --- a/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts @@ -7,6 +7,7 @@ import { buildCursorContract, buildCursorFlatContract, } from "../../../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { parseFrontmatter } from "../../../../../../src/kernel/markdown.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; // Built, not written literally: biome reads a string holding "${...}" as a lost template. @@ -35,6 +36,21 @@ function sourceOf(artifact: ArtifactContract): ArtifactSource | null { } describe("buildCursorContract()", () => { + it("gives same-named plugin roles distinct native identities", () => { + const transform = supported(buildCursorContract().artifacts.agents).transform; + const first = transform?.(AGENT_SOURCE, "first-plugin", "planner.md") ?? ""; + const second = transform?.(AGENT_SOURCE, "second-plugin", "planner.md") ?? ""; + expect(parseFrontmatter(first).frontmatter.name).toBe("first-plugin-planner"); + expect(parseFrontmatter(second).frontmatter.name).toBe("second-plugin-planner"); + }); + + it("drops only the file's final .md from a plugin agent's name", () => { + const transform = supported(buildCursorContract().artifacts.agents).transform; + const output = transform?.(AGENT_SOURCE, "aidd-dev", "read.md-first.md") ?? ""; + + expect(parseFrontmatter(output).frontmatter.name).toBe("aidd-dev-read.md-first"); + }); + it("declares Cursor's own plugin manifest path, schema and root token", () => { const contract = buildCursorContract(); @@ -114,9 +130,9 @@ describe("buildCursorContract()", () => { expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( [ "---", - "name: 'planner'", - "description: 'Plans the work'", - "model: 'opus'", + 'name: "aidd-dev-planner"', + 'description: "Plans the work"', + 'model: "opus"', "---", "Read [SKILL.md](../skills/01-plan/SKILL.md)", "Ask [reviewer.md](./reviewer.md)", @@ -254,7 +270,7 @@ describe("buildCursorFlatContract()", () => { const transform = supported(buildCursorFlatContract().artifacts.agents).transform; expect(transform?.(AGENT_SOURCE, "aidd-dev", "read.md-first.md")).toMatch( - /^name: 'aidd-dev-read\.md-first'$/mu + /^name: "aidd-dev-read\.md-first"$/mu ); }); @@ -264,9 +280,9 @@ describe("buildCursorFlatContract()", () => { expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( [ "---", - "name: 'aidd-dev-planner'", - "description: 'Plans the work'", - "model: 'opus'", + 'name: "aidd-dev-planner"', + 'description: "Plans the work"', + 'model: "opus"', "---", "Read [SKILL.md](../skills/aidd-dev-01-plan/SKILL.md)", "Ask [reviewer.md](./aidd-dev-reviewer.md)", diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/agents.unit.test.ts new file mode 100644 index 000000000..3c0a1e89c --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/kilo/agents.unit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { kilo } from "../../../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; +import { installed, NAMED_AGENT, UNNAMED_AGENT } from "../agent-fixture.js"; + +const NATIVE = `--- +description: 'Plans the work' +model: 'm' +variant: 'high' +mode: 'subagent' +--- +Plan before building. +`; + +describe("kilo agents", () => { + describe("name resolution", () => { + it("names a plugin agent after its file, so no name, and Claude's effort as its variant", () => { + expect(installed(kilo, NAMED_AGENT)).toBe(NATIVE); + }); + + it("writes an unnamed plugin agent the same way", () => { + expect(installed(kilo, UNNAMED_AGENT)).toBe(`--- +description: 'Plans the work' +mode: 'subagent' +--- +Plan before building. +`); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts index 4534e9652..ddb1f9ed8 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts @@ -26,6 +26,13 @@ describe("buildKiloFlatContract", () => { expect(hooks.hooksBridge?.path("aidd-context")).toBe(".kilo/plugin/aidd-context-hooks.js"); }); + it("drops the name from a flat agent: Kilo names it after its file", () => { + const transform = supported(buildKiloFlatContract().artifacts.agents).transform; + expect(transform?.("---\nname: x\ndescription: Reads\n---\nBody.\n", "aidd-dev", "x.md")).toBe( + "---\ndescription: 'Reads'\nmode: 'subagent'\n---\nBody.\n" + ); + }); + it("writes Kilo's project-local base config and Kilo-compatible MCP entries", async () => { const fs = new InMemoryFileAdapter({ "/source/plugins/aidd-dev/.mcp.json": JSON.stringify({ diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts index 562f0c839..7e6106196 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/agents.unit.test.ts @@ -1,7 +1,31 @@ import { describe, expect, it } from "vitest"; import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { installed, NAMED_AGENT, UNNAMED_AGENT } from "../agent-fixture.js"; + +const NATIVE = `--- +description: 'Plans the work' +model: 'm#high' +mode: 'subagent' +--- +Plan before building. +`; describe("opencode agents", () => { + describe("name resolution", () => { + it("names a plugin agent after its file, so no name, effort as the model's variant", () => { + expect(installed(opencode, NAMED_AGENT)).toBe(NATIVE); + }); + + it("writes an unnamed plugin agent the same way", () => { + expect(installed(opencode, UNNAMED_AGENT)).toBe(`--- +description: 'Plans the work' +mode: 'subagent' +--- +Plan before building. +`); + }); + }); + describe("capabilities.agents.buildInstallPath()", () => { it("builds path under .opencode/agents/", () => { const path = opencode.capabilities.agents.buildInstallPath("code-reviewer.md"); @@ -13,12 +37,4 @@ describe("opencode agents", () => { expect(path).toBe(".opencode/agents/alexia.md"); }); }); - - describe("capabilities.agents.convertFrontmatter()", () => { - it("adds mode subagent to OpenCode agent frontmatter", () => { - const fm = { name: "alexia", description: "Act like the user" }; - const result = opencode.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ description: "Act like the user", mode: "subagent" }); - }); - }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts index d38cb0658..d04e3b1c4 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts @@ -164,13 +164,12 @@ describe("buildOpencodeFlatContract()", () => { ); }); - it("declares an agent a subagent, renames it after its plugin, and points its references at their flat destinations", () => { + it("declares an agent a subagent, leaves its ID to the file name, and points its references at their flat destinations", () => { const transform = supported(buildOpencodeFlatContract().artifacts.agents).transform; expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( [ "---", - "name: 'aidd-dev-planner'", "description: 'Plans the work'", "mode: 'subagent'", "---", @@ -182,16 +181,6 @@ describe("buildOpencodeFlatContract()", () => { ); }); - it("drops only the trailing .md from the name it gives a flat agent", () => { - const transform = supported(buildOpencodeFlatContract().artifacts.agents).transform; - - expect( - transform?.("---\ndescription: Helps\n---\nBody.\n", "aidd-dev", "notes.md-helper.md") - ).toBe( - "---\ndescription: 'Helps'\nname: 'aidd-dev-notes.md-helper'\nmode: 'subagent'\n---\nBody.\n" - ); - }); - it("delivers a plugin's own OpenCode module where the loader scans, and every other script apart", () => { const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); diff --git a/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts b/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts index abfcdea7d..a38def427 100644 --- a/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts +++ b/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts @@ -185,7 +185,7 @@ describe("FrameworkBuildUseCase", () => { expect(fs.has(`${OUT_DIR}/plugins/aidd-test/agents/code-reviewer.agent.md`)).toBe(false); }); - it("preserves all frontmatter fields from agents (OpenPlugin does not strip)", async () => { + it("keeps only the agent fields Copilot reads", async () => { const agentPath = `${SOURCE_DIR}/plugins/aidd-test/agents/extra.md`; fs.setFile( agentPath, @@ -196,8 +196,8 @@ describe("FrameworkBuildUseCase", () => { const out = fs.getFile(`${OUT_DIR}/plugins/aidd-test/agents/extra.md`) ?? ""; expect(out).toContain("name: 'extra'"); expect(out).toContain("description: 'desc'"); - expect(out).toContain("version"); - expect(out).toContain("tags"); + expect(out).not.toContain("version"); + expect(out).not.toContain("tags"); }); }); diff --git a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts index dd94ccf1e..0feb26a7c 100644 --- a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts @@ -160,8 +160,8 @@ describe("ClaudeOutputStrategy", () => { }); }); - describe("agent frontmatter preserved (D-5)", () => { - it("source agent with tools/color/model โ€” claude output preserves all frontmatter keys", async () => { + describe("agent frontmatter", () => { + it("keeps only the name, description and model Claude reads; an agent gets every tool", async () => { const fs = await makeSeededFsFromReal(); fs.setFile( `${REAL_FIXTURE_DIR}/plugins/aidd-dev/agents/full-agent.md`, @@ -170,10 +170,13 @@ describe("ClaudeOutputStrategy", () => { const uc = makeUseCase(fs); await uc.execute({ sourceDir: REAL_FIXTURE_DIR, outDir: OUT_DIR, target: "claude" }); const out = fs.getFile(`${OUT_DIR}/plugins/aidd-dev/agents/full-agent.md`) ?? ""; - expect(out).toContain("tools:"); - expect(out).toContain("color:"); - expect(out).toContain("model:"); - expect(out).toContain("name:"); + expect(out).toBe(`--- +name: 'full-agent' +description: 'Full agent' +model: 'claude-sonnet' +--- + +Body with [foo.md](./foo.md) ref.`); }); it("agent body gets @./ rewrite (D-4)", async () => { diff --git a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts index 6d4e00522..606bef604 100644 --- a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts @@ -211,18 +211,6 @@ describe("CodexOutputStrategy", () => { expect(typeof parsed.developer_instructions).toBe("string"); } }); - - it("TOML files do not contain model key (D-5)", async () => { - const fs = await makeSeededFsFromReal(); - const uc = makeUseCase(fs); - await uc.execute({ sourceDir: REAL_FIXTURE_DIR, outDir: OUT_DIR, target: "codex" }); - const tomlFiles = fs.listUnder(OUT_DIR).filter((p) => p.endsWith(".toml")); - for (const tomlPath of tomlFiles) { - const content = fs.getFile(tomlPath) ?? ""; - const parsed = parseToml(content) as Record; - expect(parsed.model).toBeUndefined(); - } - }); }); describe("skill rewrite (AC #6)", () => { @@ -447,16 +435,4 @@ describe("CodexOutputStrategy", () => { expect(instr).toContain(CLAUDE_ROOT_VAR); }); }); - - describe("model key omission (D-5)", () => { - it("fixture agent with model: opus produces TOML with no model key", async () => { - const fs = await makeSeededFsFromCodex(); - const uc = makeUseCase(fs); - await uc.execute({ sourceDir: CODEX_FIXTURE_DIR, outDir: OUT_DIR, target: "codex" }); - const tomlContent = - fs.getFile(`${OUT_DIR}/plugins/aidd-codex-fixture/codex-agents/test-agent.toml`) ?? ""; - const parsed = parseToml(tomlContent) as Record; - expect(parsed.model).toBeUndefined(); - }); - }); }); diff --git a/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts b/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts index d0913b772..aa9886cbc 100644 --- a/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts +++ b/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { PluginsCapability } from "../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { parseToml } from "../../../../src/contexts/tools/domain/profiles/codex/toml.js"; import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; @@ -14,7 +15,7 @@ import { PluginDistribution, } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; import { FileHash } from "../../../../src/kernel/file.js"; -import { parseFrontmatter } from "../../../../src/kernel/markdown.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../src/kernel/markdown.js"; const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; const translator = new PluginContentTranslator(stubHasher); @@ -84,6 +85,62 @@ function pathsFor(tool: ToolConfig, dist = makeDist()): string[] { } describe("PluginContentTranslator.translate()", () => { + it.each([ + [ + "claude", + claude, + ".claude/plugins/aidd-dev/agents/reviewer.md", + { model: "claude-haiku-5-5", effort: "medium" }, + ], + [ + "codex", + codex, + ".codex/plugins/aidd-dev/agents/reviewer.toml", + { model: "gpt-6-luna", model_reasoning_effort: "medium" }, + ], + [ + "copilot", + copilot, + ".github/plugins/aidd-dev/agents/reviewer.agent.md", + { model: "gpt-6-luna", reasoningEffort: "medium" }, + ], + [ + "opencode", + opencode, + ".opencode/agents/aidd-dev-reviewer.md", + { model: "openai/gpt-6-luna#medium" }, + ], + ] as const)( + "carries %s's model and Claude's effort, under the host's own key, through ordinary installation", + (label, host, path, expected) => { + const base = makeDist({ manifest: { name: "aidd-dev", version: "1.0.0" } }); + const agent = makeFile( + "agents/reviewer.md", + serializeFrontmatter( + { + name: "reviewer", + description: "Role", + model: expected.model.replace(/#.*$/, ""), + effort: "medium", + }, + "Reviewer agent from sample-plugin." + ) + ); + const dist = makeDist({ + manifest: base.manifest, + files: base.files.map((file) => (file.relativePath === agent.relativePath ? agent : file)), + components: { ...base.components, agents: [agent] }, + }); + const installed = translator.translate(dist, host).find((file) => file.relativePath === path); + expect(installed).toBeDefined(); + const content = installed?.content ?? ""; + expect( + label === "codex" ? parseToml(content) : parseFrontmatter(content).frontmatter + ).toMatchObject(expected); + expect(content).toContain("Reviewer agent from sample-plugin."); + } + ); + describe("claude target", () => { it("emits all components claude supports under .claude/plugins/sample-plugin/", () => { const paths = pathsFor(claude); @@ -238,8 +295,8 @@ describe("PluginContentTranslator.translate()", () => { expect(greet?.content).toContain("name: 'aidd-sample-plugin:greet'"); }); - it("emits agents under .opencode/agents/sample-plugin/", () => { - expect(pathsFor(opencode)).toContain(".opencode/agents/sample-plugin/reviewer.md"); + it("emits agents at their native flat path", () => { + expect(pathsFor(opencode)).toContain(".opencode/agents/sample-plugin-reviewer.md"); }); it("emits skills under .opencode/skills/sample-plugin/", () => { diff --git a/cli/tests/e2e/agent-install.e2e.test.ts b/cli/tests/e2e/agent-install.e2e.test.ts new file mode 100644 index 000000000..05dd88e6b --- /dev/null +++ b/cli/tests/e2e/agent-install.e2e.test.ts @@ -0,0 +1,68 @@ +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseToml } from "../../src/contexts/tools/domain/profiles/codex/toml.js"; +import { parseFrontmatter } from "../../src/kernel/markdown.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { createTestEnv, runCli } from "./helpers.js"; + +const PLUGIN = "aidd-orchestrator"; +const SOURCE_DIR = join(REPOSITORY_ROOT, "plugins", PLUGIN, "agents"); + +/** Where each host loads a project's agents, and the frontmatter it reads beyond the description. */ +const HOSTS = [ + { host: "claude", dir: ".claude/agents", ext: ".md", extra: (id: string) => ({ name: id }) }, + { host: "codex", dir: ".codex/agents", ext: ".toml", extra: (id: string) => ({ name: id }) }, + { + host: "copilot", + dir: ".github/agents", + ext: ".agent.md", + extra: (id: string) => ({ name: id, "include-custom-instructions": true }), + }, + { host: "cursor", dir: ".cursor/agents", ext: ".md", extra: (id: string) => ({ name: id }) }, + { host: "opencode", dir: ".opencode/agents", ext: ".md", extra: () => ({ mode: "subagent" }) }, + { host: "kilo", dir: ".kilo/agents", ext: ".md", extra: () => ({ mode: "subagent" }) }, +] as const; + +describe.concurrent("E2E: the shipped agents, installed into each host", () => { + it.each(HOSTS)( + "$host loads every agent at its path, with its own frontmatter", + async ({ host, dir, ext, extra }) => { + const { projectDir, fakeHome, cleanup } = await createTestEnv(`agents-${host}`); + try { + const build = await runCli( + ["translate", REPOSITORY_ROOT, "--to", host, "--as", "flat", "--out", projectDir], + projectDir, + fakeHome + ); + expect(build.exitCode, build.stderr).toBe(0); + + const sources = (await readdir(SOURCE_DIR)).filter((file) => file.endsWith(".md")).sort(); + const installed = (await readdir(join(projectDir, dir))) + .filter((file) => file.startsWith(`${PLUGIN}-`)) + .sort(); + expect(installed).toEqual(sources.map((file) => `${PLUGIN}-${file.slice(0, -3)}${ext}`)); + + for (const file of sources) { + const source = parseFrontmatter(await readFile(join(SOURCE_DIR, file), "utf8")); + const id = `${PLUGIN}-${file.slice(0, -3)}`; + const content = await readFile(join(projectDir, dir, `${id}${ext}`), "utf8"); + const { body, ...frontmatter } = + host === "codex" + ? (({ developer_instructions, ...rest }) => ({ + ...rest, + body: developer_instructions, + }))(parseToml(content) as Record) + : { ...parseFrontmatter(content).frontmatter, body: parseFrontmatter(content).body }; + expect(frontmatter, `${host} ${file}`).toStrictEqual({ + description: source.frontmatter.description, + ...extra(id), + }); + expect(body, `${host} ${file}`).toBe(source.body); + } + } finally { + await cleanup(); + } + } + ); +}); diff --git a/cli/tests/e2e/framework-build.e2e.test.ts b/cli/tests/e2e/framework-build.e2e.test.ts index 46129a745..18d076b0d 100644 --- a/cli/tests/e2e/framework-build.e2e.test.ts +++ b/cli/tests/e2e/framework-build.e2e.test.ts @@ -252,9 +252,8 @@ describe.concurrent("E2E: aidd translate", () => { "name", "description", "model", - "tools", - "agents", - "argument-hint", + "reasoningEffort", + "include-custom-instructions", ]); const agentContent = await readFile( join(projRoot, ".github", "agents", "aidd-test-code-reviewer.agent.md"), diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index 22b4ecea6..afb0793f1 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -280,8 +280,9 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { shell: KILO_SHELL, maxBuffer: 8 * 1024 * 1024, }); - expect(agents.stdout).toContain("aidd-dev-checker"); - expect(agents.stdout).toContain("aidd-dev-executor"); + for (const role of ["explorer", "executor", "validator", "reviewer"]) { + expect(agents.stdout).toContain(`aidd-orchestrator-${role}`); + } const mcpProject = join(tempDir, "generated-mcp"); await mkdir(mcpProject, { recursive: true }); @@ -300,6 +301,13 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { fakeHome ); expect(mcpBuild.exitCode, mcpBuild.stderr).toBe(0); + const fixtureAgents = await execFileAsync(KILO_BIN, ["agent", "list"], { + cwd: mcpProject, + env, + shell: KILO_SHELL, + maxBuffer: 8 * 1024 * 1024, + }); + expect(fixtureAgents.stdout).toContain("aidd-test-code-reviewer"); const mcp = await execFileAsync(KILO_BIN, ["mcp", "list"], { cwd: mcpProject, env, diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index e4c771321..0c7e18655 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -93,9 +93,9 @@ "plugins/aidd-dev/skills/01-plan/actions/02-components-behavior.md": "cacb673334d8947c3c252feaaf0ef50c269c06c5cf8f51b45663cbd42402e094", "plugins/aidd-dev/skills/01-plan/actions/03-image-extract-details.md": "6f6cddd0893888c71b0aed5ecfe48217afe0eef10feec6415db433bfcc69f8e3", "plugins/aidd-dev/skills/00-sdlc/SKILL.md": "19bd4a8cf8c6531a6db232c31855e39475c71e2b58c91b439832d813ff594b78", - "plugins/aidd-dev/agents/implementer.md": "61b4531baa060e9b77c41cf1938c6c6433482dfdfe5eb3a774453d45cfe8b092", - "plugins/aidd-dev/agents/planner.md": "de6efeda286fe33a5245a9ed3cf9e60b78946575a5bd66b359b2428adac51ba5", - "plugins/aidd-dev/agents/reviewer.md": "bc5ffdc650d701c73b5da1e5eba904419e33346aac5c68bffdcd53b1e0476432", + "plugins/aidd-dev/agents/implementer.md": "8b44fe63e07439e62bc19a9dde3880d3b69326f10217704258e797ae97ad6ffa", + "plugins/aidd-dev/agents/planner.md": "9b50b2ac01662012894e3074a008f2b2015ac8714bfc1e097a2eb10bbfda418b", + "plugins/aidd-dev/agents/reviewer.md": "d0bf4b81cf2f16f57c722da201e38db1bbbcf96f64c4fe97ed3cf59bc38fa636", "plugins/aidd-dev/.plugin/plugin.json": "6e892c043c48fdc258cf7d13f1feba463483a91543686da3b30b260296235334", "plugins/aidd-context/skills/06-discovery/SKILL.md": "28f82e034ed5707ad6e59b012680a94ff5935bc7f8a0b5407caeea20ccb2667a", "plugins/aidd-context/skills/06-discovery/actions/01-find-skill.md": "600caf7822017bfe75d40773e1673ada784227b98f5b88d4ba98a57822fa0dcf", @@ -195,7 +195,7 @@ "plugins/aidd-async-dev/skills/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", "plugins/aidd-async-dev/skills/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", "plugins/aidd-async-dev/hooks/hooks.json": "78922a784ee78e9e50587e93628cd3b9d4dfbe49087adc4514e6781cea38cbb9", - "plugins/aidd-async-dev/agents/async-orchestrator.md": "8b29ba73f27414e75c89c6453c0557212790d00d6becfadba363466999f2c9de", + "plugins/aidd-async-dev/agents/async-orchestrator.md": "34b107031183da5da66cb14923d215abfb899ab4d1a7feeb9b68ee1fe01a1a09", "plugins/aidd-async-dev/.plugin/plugin.json": "8d9b5b5edc8ac5d51cdb24bc5605b30ed3149616bfbbcc69db45f35d477315d5", ".plugin/marketplace.json": "fda47ddacff304f5bc06b1807f5c1c1be3f787995ed2f26cc8791c0c38ee3d19" }, @@ -293,9 +293,9 @@ "plugins/aidd-dev/skills/01-plan/actions/02-components-behavior.md": "cacb673334d8947c3c252feaaf0ef50c269c06c5cf8f51b45663cbd42402e094", "plugins/aidd-dev/skills/01-plan/actions/03-image-extract-details.md": "6f6cddd0893888c71b0aed5ecfe48217afe0eef10feec6415db433bfcc69f8e3", "plugins/aidd-dev/skills/00-sdlc/SKILL.md": "e961d919f13d1650d7c47dc0fa118f8cdce7d434c2ae31aa9c30a203463c86f4", - "plugins/aidd-dev/codex-agents/implementer.toml": "d6b2739193ecc12b546f17a994af4e2949ccbea3f2560139b3bf7693fca9604d", - "plugins/aidd-dev/codex-agents/planner.toml": "347e72e5beb8d0b493618c2d03f37d765c10e407f3404539fa5dc822fd2fecad", - "plugins/aidd-dev/codex-agents/reviewer.toml": "9dd3b9e0420601fc35c287636a6553ef5e404689347313d05e53ec99f034011a", + "plugins/aidd-dev/codex-agents/implementer.toml": "d1cb0353bb91cc5d0c5ffaada12b063c7d5491e94ea33eb08277783aa4c7f493", + "plugins/aidd-dev/codex-agents/planner.toml": "2b37cea54f1da91c6fe2c781ada91580d36ff4656407cc4453d668bbb2c20f90", + "plugins/aidd-dev/codex-agents/reviewer.toml": "7b86256d9b872d3358828a4aa74338b6d6056aced63e9b70cad88b548dc89dbd", "plugins/aidd-dev/.codex-plugin/plugin.json": "20aed94921aed1aaa0dfcd83528601273582558f72ed33f23077605197dba94d", "plugins/aidd-context/skills/06-discovery/SKILL.md": "e057466afc4603a1fcac65db17f36d4d03c8403c6b2ed8b49abdcec43bc69aaf", "plugins/aidd-context/skills/06-discovery/actions/01-find-skill.md": "600caf7822017bfe75d40773e1673ada784227b98f5b88d4ba98a57822fa0dcf", @@ -395,7 +395,7 @@ "plugins/aidd-async-dev/skills/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", "plugins/aidd-async-dev/skills/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", "plugins/aidd-async-dev/hooks/hooks.json": "78922a784ee78e9e50587e93628cd3b9d4dfbe49087adc4514e6781cea38cbb9", - "plugins/aidd-async-dev/codex-agents/async-orchestrator.toml": "7490eb79d3d7b8c1f506ff1b15d03066213b08ba7a3d638befae1289bf272240", + "plugins/aidd-async-dev/codex-agents/async-orchestrator.toml": "8212b724fdacd2a20f35a2c11782c7d50b717b742f59f1a9ae08c0d831e42b4a", "plugins/aidd-async-dev/.codex-plugin/plugin.json": "19dbd85f9970a03d6e9370d1decbb9feb56451165add642440a6569af15a6233", ".agents/plugins/marketplace.json": "0f1e54dd48bb811d1b4bac2a0a0d80186573d0052e2bdec022bbd11b029e875f" }, @@ -693,9 +693,9 @@ "plugins/aidd-dev/skills/01-plan/actions/02-components-behavior.md": "cacb673334d8947c3c252feaaf0ef50c269c06c5cf8f51b45663cbd42402e094", "plugins/aidd-dev/skills/01-plan/actions/03-image-extract-details.md": "6f6cddd0893888c71b0aed5ecfe48217afe0eef10feec6415db433bfcc69f8e3", "plugins/aidd-dev/skills/00-sdlc/SKILL.md": "19bd4a8cf8c6531a6db232c31855e39475c71e2b58c91b439832d813ff594b78", - "plugins/aidd-dev/agents/implementer.md": "61b4531baa060e9b77c41cf1938c6c6433482dfdfe5eb3a774453d45cfe8b092", - "plugins/aidd-dev/agents/planner.md": "de6efeda286fe33a5245a9ed3cf9e60b78946575a5bd66b359b2428adac51ba5", - "plugins/aidd-dev/agents/reviewer.md": "bc5ffdc650d701c73b5da1e5eba904419e33346aac5c68bffdcd53b1e0476432", + "plugins/aidd-dev/agents/implementer.md": "9db35e43a843976a649c871307db136d0e47e60b8ec54f1f71dcfe1b6a93568a", + "plugins/aidd-dev/agents/planner.md": "9a78f099ceffb40cdba3de0774d3d6ba827865e549807172a41070903693d901", + "plugins/aidd-dev/agents/reviewer.md": "62ec6a3eea3116d90f55853bd8ee5c95b19db2ac813eb9cb083e33f1e326ca69", "plugins/aidd-dev/.cursor-plugin/plugin.json": "6e892c043c48fdc258cf7d13f1feba463483a91543686da3b30b260296235334", "plugins/aidd-context/skills/06-discovery/SKILL.md": "28f82e034ed5707ad6e59b012680a94ff5935bc7f8a0b5407caeea20ccb2667a", "plugins/aidd-context/skills/06-discovery/actions/01-find-skill.md": "600caf7822017bfe75d40773e1673ada784227b98f5b88d4ba98a57822fa0dcf", @@ -795,7 +795,7 @@ "plugins/aidd-async-dev/skills/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", "plugins/aidd-async-dev/skills/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", "plugins/aidd-async-dev/hooks/hooks.json": "78922a784ee78e9e50587e93628cd3b9d4dfbe49087adc4514e6781cea38cbb9", - "plugins/aidd-async-dev/agents/async-orchestrator.md": "8b29ba73f27414e75c89c6453c0557212790d00d6becfadba363466999f2c9de", + "plugins/aidd-async-dev/agents/async-orchestrator.md": "a429bcfadb7fcc036536fc693b8b5a844bdba3706617ce720166ab204957d5cd", "plugins/aidd-async-dev/.cursor-plugin/plugin.json": "8d9b5b5edc8ac5d51cdb24bc5605b30ed3149616bfbbcc69db45f35d477315d5", ".cursor-plugin/marketplace.json": "d197f330a5787647d58c7dc20253c8ef8b59c4dce115fdd7f12de45ccec7b18e" }, @@ -1176,10 +1176,10 @@ ".cursor/skills/aidd-async-dev-01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".cursor/skills/aidd-async-dev-01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", ".cursor/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", - ".cursor/agents/aidd-async-dev-async-orchestrator.md": "5ca31d8117dcc4800265ab04093432090485fdda1de8a867b29c3d3e55d30e3d", - ".cursor/agents/aidd-dev-implementer.md": "3447d0684155742c21a8692cf95057dd4f90019298e9e5cb6c4e613993393365", - ".cursor/agents/aidd-dev-planner.md": "074147c1268518db93623ed1c88681e78bfabdf57bcc51e23a19c306aa8f7394", - ".cursor/agents/aidd-dev-reviewer.md": "7d501f19569f48a2bfa04e134b26323645ab08987f88ae8a59ed6e65992a9d3d" + ".cursor/agents/aidd-async-dev-async-orchestrator.md": "a429bcfadb7fcc036536fc693b8b5a844bdba3706617ce720166ab204957d5cd", + ".cursor/agents/aidd-dev-implementer.md": "9db35e43a843976a649c871307db136d0e47e60b8ec54f1f71dcfe1b6a93568a", + ".cursor/agents/aidd-dev-planner.md": "9a78f099ceffb40cdba3de0774d3d6ba827865e549807172a41070903693d901", + ".cursor/agents/aidd-dev-reviewer.md": "62ec6a3eea3116d90f55853bd8ee5c95b19db2ac813eb9cb083e33f1e326ca69" }, "copilot:flat": { ".vscode/mcp.json": "8797ef325f7b0d8edb3d9212186501cad904443d2b44f8cc4523d2394f35aa32", @@ -1368,19 +1368,19 @@ ".github/hooks/aidd-async-dev.hooks.json": "4b8894d57dfa621e534ef4eb25263e8f00254cbcb4327f1f98796314ac279dde", ".github/hooks/aidd-context.hooks.json": "35e484606fe0c4a0e8b6f6a106b91fc1bf02dae266efac762446f2d5f8a66da9", ".github/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", - ".github/agents/aidd-async-dev-async-orchestrator.agent.md": "5ca31d8117dcc4800265ab04093432090485fdda1de8a867b29c3d3e55d30e3d", - ".github/agents/aidd-dev-implementer.agent.md": "3447d0684155742c21a8692cf95057dd4f90019298e9e5cb6c4e613993393365", - ".github/agents/aidd-dev-planner.agent.md": "074147c1268518db93623ed1c88681e78bfabdf57bcc51e23a19c306aa8f7394", - ".github/agents/aidd-dev-reviewer.agent.md": "7d501f19569f48a2bfa04e134b26323645ab08987f88ae8a59ed6e65992a9d3d" + ".github/agents/aidd-async-dev-async-orchestrator.agent.md": "c4344007eca9cda67e25d1eb48cc92b7534d57ac5892dd8b6fce4d5bcf0ff71e", + ".github/agents/aidd-dev-implementer.agent.md": "a2a774069d23b4f5ebcb4d74a647d24f54e7bab7e6240f4dc3ae755b048fefa7", + ".github/agents/aidd-dev-planner.agent.md": "dac052786941de29f0e1703a195dbc9cb4dfeb954efd47b09f9171fd0f60aa58", + ".github/agents/aidd-dev-reviewer.agent.md": "2ba11f06588fb88b5979e8fcbacc93fa19594153b30f7636cd47dcf3004752c6" }, "codex:flat": { ".codex/config.toml": "0ff7327daa069f076fa664ab373dc04730f68692f7f3a8f008ef00479fca382e", ".codex/hooks.json": "f85805f8ed17f990b1d7a2e2f966cb47f46f9bdd21bc8911325df190b7784ea9", ".codex/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".codex/agents/aidd-async-dev-async-orchestrator.toml": "8212b724fdacd2a20f35a2c11782c7d50b717b742f59f1a9ae08c0d831e42b4a", - ".codex/agents/aidd-dev-implementer.toml": "666612f997989d9f0beffc0e3438f998ea937b48259407122347430e6af6ff28", - ".codex/agents/aidd-dev-planner.toml": "ca9ade7a153873d4dd9a633d41ae3850a11b927333abbe336ab18fe47fd28b56", - ".codex/agents/aidd-dev-reviewer.toml": "0d366279100adebbf10bb0976e9057e03a8c2889d323c218e01d64b5f5038e3a", + ".codex/agents/aidd-dev-implementer.toml": "d1cb0353bb91cc5d0c5ffaada12b063c7d5491e94ea33eb08277783aa4c7f493", + ".codex/agents/aidd-dev-planner.toml": "2b37cea54f1da91c6fe2c781ada91580d36ff4656407cc4453d668bbb2c20f90", + ".codex/agents/aidd-dev-reviewer.toml": "7b86256d9b872d3358828a4aa74338b6d6056aced63e9b70cad88b548dc89dbd", ".agents/skills/aidd-vcs-04-issue-create/SKILL.md": "3983ed1c71124786fdf737ec5447637a31b1cc146eff43003978cec50c2ddc67", ".agents/skills/aidd-vcs-04-issue-create/evals/scenarios.json": "d113c62aae4867e425737948c39c9f687f56c3d37234eca345a2f65f1a234c30", ".agents/skills/aidd-vcs-04-issue-create/assets/CONTRIBUTING.md": "1372c7512c02c26e21643ae523a98d7c90ff92c03633532a8c417b4ca8d9e75f", @@ -1751,10 +1751,10 @@ ".opencode/plugin/aidd-context-hooks.js": "4d4de407a2afae13045eaf8291efac247717f9fdbe7d983be9f977eb947e4b6e", ".opencode/hooks/opencode-events.js": "ce9363e18f1f5b11c401a30bd1d8643dcdd72ced0182d97baae763276216babf", ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", - ".opencode/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", - ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", - ".opencode/agents/aidd-dev-planner.md": "c83648c34068b6fa762fdc6b073e31cb3e9dfe5bd5bf2fd7e2cb4ad3e865feee", - ".opencode/agents/aidd-dev-reviewer.md": "30e2dbb93309d23fc99cbce36a4fcfd0b6d76e49c3425979827727327ba697a6" + ".opencode/agents/aidd-async-dev-async-orchestrator.md": "cff54df5e690a947431982b41ea4172bb4a1306df11fb6a5953491112fcd0ea7", + ".opencode/agents/aidd-dev-implementer.md": "ec7bd170333d20033679ffdab1976a810067c415dd98b985154f907e2bce78b3", + ".opencode/agents/aidd-dev-planner.md": "c832e2830e5148d9cd44daa408636e3e93b914570ea579ee6fc40ec562143495", + ".opencode/agents/aidd-dev-reviewer.md": "58b25536395be2be05f14a6213b667011dd16f94fca689cedfc46cda7438e2b9" }, "kilo:flat": { ".kilo/kilo.jsonc": "3710c8020f13262b04d8e30ca8eee430c9bf07c7a8bc557d6e9dfb4dffaa8478", @@ -1942,9 +1942,9 @@ ".kilo/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", ".kilo/plugin/aidd-context-hooks.js": "1ccdf9253492b1a3663e418d58a542c7d97046964070e332c7bc110d960c45b4", ".kilo/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", - ".kilo/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", - ".kilo/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", - ".kilo/agents/aidd-dev-planner.md": "c83648c34068b6fa762fdc6b073e31cb3e9dfe5bd5bf2fd7e2cb4ad3e865feee", - ".kilo/agents/aidd-dev-reviewer.md": "30e2dbb93309d23fc99cbce36a4fcfd0b6d76e49c3425979827727327ba697a6" + ".kilo/agents/aidd-async-dev-async-orchestrator.md": "cff54df5e690a947431982b41ea4172bb4a1306df11fb6a5953491112fcd0ea7", + ".kilo/agents/aidd-dev-implementer.md": "ec7bd170333d20033679ffdab1976a810067c415dd98b985154f907e2bce78b3", + ".kilo/agents/aidd-dev-planner.md": "c832e2830e5148d9cd44daa408636e3e93b914570ea579ee6fc40ec562143495", + ".kilo/agents/aidd-dev-reviewer.md": "58b25536395be2be05f14a6213b667011dd16f94fca689cedfc46cda7438e2b9" } } diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index 93c16f4cf..325cc6a4e 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -734,7 +734,7 @@ "files": { "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/agents/code-reviewer.md": "30e6acfb1ad361a707a809e4dbb59dcc", "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" }, diff --git a/cli/tests/kernel/markdown-yaml-like.unit.test.ts b/cli/tests/kernel/markdown-yaml-like.unit.test.ts index 27812aed6..ae6f17442 100644 --- a/cli/tests/kernel/markdown-yaml-like.unit.test.ts +++ b/cli/tests/kernel/markdown-yaml-like.unit.test.ts @@ -109,6 +109,12 @@ describe("parseFrontmatter, line by line", () => { }); describe("serializeFrontmatter, line by line", () => { + it("writes double-quoted strings on request, escaping what YAML needs", () => { + expect(serializeFrontmatter({ a: `it's "x"\\` }, "body", "double")).toBe( + '---\na: "it\'s \\"x\\"\\\\"\n---\nbody' + ); + }); + it("quotes a value that only starts with a bracket", () => { expect(serializeFrontmatter({ a: "[open" }, "body")).toBe("---\na: '[open'\n---\nbody"); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8976344d..9af1df75d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -91,7 +91,7 @@ flowchart TB User -->|direct invocation| Recipe["Recipe SKILL.md"] Protocol -->|discover and invoke provider| Recipe Protocol -->|authorized isolation| Agent["Agent"] - Agent -->|declared recipes only| Recipe + Agent -->|scoped work| Recipe Recipe --> Action["Self-contained action"] Action --> Result["Result or artifact"] ``` @@ -101,9 +101,10 @@ flowchart TB | Role | Contract | | --- | --- | | Skill | Host loads `SKILL.md` on invocation; work runs in the caller's context. | -| Orchestrator | Owns routing; authorizes isolation or bounded fan-out. | -| Agent | Isolates work and returns a result. Invokes only declared canonical recipe skills; never invokes orchestrators or reads skill files. | -| Recipe | Never invents spawning; an isolated recipe never delegates flow work. | +| Caller | The session or skill that delegates: owns scope, delegation, integration, and acceptance. | +| Orchestrator | Sequences capabilities on the caller's behalf. | +| Agent | Isolates a scoped task and returns its result to the caller. The generic roles ship with `aidd-orchestrator`, the coordination plugin, because each serves every concern and the caller supplies it; without them, the host's default subagent takes the brief. | +| Recipe | Primary callers may delegate bounded work. | | Reconnaissance helper | Read-only; neither mutates nor spawns. | | Write path | At most two delegation layers; no cycles. | | SDLC | Owns planning; [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) govern execution, leaf executors, independent judgment and bounded repair. | @@ -130,7 +131,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Context | Rule | | --- | --- | -| Dispatch tables (`## Actions`), orchestration references and agent permissions (`# Skills you may invoke`) | Canonical `/plugin:folder` or `@plugin:agent` addresses. Elsewhere, name the responsibility. | +| Dispatch tables (`## Actions`) and orchestration references | Canonical `/plugin:folder` skill addresses. Delegate agent work by responsibility, without custom agent names. | | Cross-plugin providers | Recipes discover by description rather than hardcoding siblings; orchestrators verify installation before dispatch. | | Backlog flow | Owned by `aidd-orchestrator:02-backlog`. | | Orthogonality exceptions | `isExemptFromOrthogonality` permits responsibility maps in `plugins/aidd-orchestrator/**` and onboarding menus in `plugins/aidd-context/skills/00-onboard/**`. The onboarding exemption ends with runtime provider discovery. | diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index f4da8dbe7..17e0394af 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -28,7 +28,7 @@ A single atomic step inside a skill, stored at `plugins//skills//agents/.md`. Agents are scoped and spawned by an orchestrator when a step needs isolation in a dedicated role rather than the main thread. +A specialized AI persona under `plugins//agents/.md`. The main session or a skill hands it a scoped brief when a step needs a fresh context rather than the main thread. ## ๐Ÿ“ Rule diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md new file mode 100644 index 000000000..8415496a3 --- /dev/null +++ b/docs/SUBAGENTS.md @@ -0,0 +1,65 @@ +# ๐Ÿค Subagents + +Four generic agents your AI tool can hand work to, each in a fresh context. They ship with the `aidd-orchestrator` plugin; the skill that calls one gives it its concern, so the same executor writes code under `aidd-dev` and a spec under `aidd-pm`. + +## ๐Ÿ“‘ Contents + +- [How it works](#-how-it-works) +- [Which agent for which task](#-which-agent-for-which-task) +- [Install](#-install) +- [Models](#-models) +- [Limits](#-limits) + +## ๐Ÿ”€ How it works + +Your main session keeps the conversation and decides what to delegate. Each agent gets a scoped brief, works in its own context, and returns its result to the main session, which owns the final answer. + +```mermaid +sequenceDiagram + actor You + participant Main as Main session + participant Explorer + participant Executor + participant Validator + participant Reviewer + You->>Main: Request + Main->>Explorer: Where is it? How does it work? + Explorer-->>Main: Answer with sources + Main->>Executor: Build or fix this, with criteria + Executor-->>Main: Changes and checks + Main->>Validator: Does it meet the criteria? + Validator-->>Main: Validated yes or no + Main->>Reviewer: Is it clean and coherent? + Reviewer-->>Main: Findings + Main-->>You: Result +``` + +Each step is optional: the main session calls only the agents the task needs, and repeats execution when validation or review finds a gap. + +Only `/aidd-orchestrator:01-sdlc` names an agent, by its role (`validator`), so a run delegates the same way every time; other workflows ask for a responsibility. The host resolves the role to its install, `aidd-orchestrator:validator` from the Claude Code marketplace or `aidd-orchestrator-validator` in a project distribution, or gives the brief to its default subagent when the agent is not installed. + +## ๐Ÿงญ Which agent for which task + +| Agent | Use it to | It returns | Writes files, by instruction | +| --- | --- | --- | --- | +| [Explorer](../plugins/aidd-orchestrator/agents/explorer.md) | Find files and symbols, trace a flow, answer a codebase question before a change | The answer, with sources and unknowns | No | +| [Executor](../plugins/aidd-orchestrator/agents/executor.md) | Write or fix code and docs within a defined scope, then verify them | `COMPLETE`, `INCOMPLETE` or `BLOCKED`, with changes and checks run | Yes | +| [Validator](../plugins/aidd-orchestrator/agents/validator.md) | Confirm delivered work meets the request and its acceptance criteria | `Validated: yes` or `no`, with what failed | No; runs checks | +| [Reviewer](../plugins/aidd-orchestrator/agents/reviewer.md) | Find defects, contradictions, duplication and needless complexity in finished work | `no actionable findings`, `changes needed` or `insufficient evidence`, with findings | No; only the review report a workflow asks for | + +## ๐Ÿ“ฆ Install + +Install the `aidd-orchestrator` plugin: `/plugin install aidd-orchestrator@aidd-framework` in Claude Code, or see the [quick start](../README.md#-quick-start) for the other tools. The agents come with it, except as a Codex plugin (see [Limits](#-limits)); open a new session so the tool loads them. + +## ๐Ÿง  Models + +No model is pinned. Each host applies its own default for subagents, usually the model of the session that called them. A model you declare in an agent reaches every host in its own format. + +## ๐Ÿšง Limits + +- **Tools:** every agent inherits the calling session's tools and permissions; what it writes is an instruction, not a restriction. +- **Codex:** a Codex plugin cannot carry agents, so only the flat distribution installs them, in `.codex/agents/`. Codex loads them only in a trusted project. +- **Models:** OpenCode V1 reads the same file but not the `#` variant. +- **Updates:** reinstalling replaces the agents still shipped; one removed from the plugin stays where a copy put it until you delete it. + +Sources: [Claude Code sub-agents](https://code.claude.com/docs/en/sub-agents), [Copilot custom agents](https://docs.github.com/en/copilot/reference/custom-agents-configuration), [OpenCode agents](https://opencode.ai/v2/docs/agents), [Codex subagents](https://developers.openai.com/codex/subagents), [Cursor subagents](https://cursor.com/docs/subagents). diff --git a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md index d126acad9..4e5dac596 100644 --- a/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md +++ b/plugins/aidd-context/skills/02-project-memory/assets/templates/AGENTS.md @@ -16,7 +16,7 @@ - **Prefer short bullets.** Number ordered steps. - **Skip redundant preambles, recaps, and closers.** - **Quote the shortest decisive error line.** -- **Don't narrate tool calls.** Use formatting only when it improves scanability. +- **Report progress at milestones, not per tool call.** Use formatting only when it improves scanability. - **Use full prose when nuance or safety requires it.** - **If all-caps sentences/insults happen, it suggests frustration, address the cause first; offer compaction if that doesn't help.** @@ -31,9 +31,21 @@ - **Verify APIs, signatures, flags, and behavior against source or docs.** - **Ask one sharp question when ambiguity materially changes the scope or outcome.** - **Batch independent operations when it saves time or context.** -- **Fan out genuinely independent subtasks when coordination costs less than serial work.** - **Name by intention, scope by responsibility** ; not mechanism. +## Agents + +Fan out genuinely independent subtasks to agents by responsibility when coordination costs less than serial work. You stay the caller: you own scope, delegation, and final acceptance. + +| Responsibility | Agent | +| --- | --- | +| Answer a scoped codebase question with sources | `explorer` | +| Implement assigned work and verify it | `executor` | +| Confirm the outcome meets its acceptance criteria | `validator` | +| Review quality, coherence, and needless complexity | `reviewer` | + +Without the agent, give the same brief to the host's default subagent. + ## Memory Management Project docs, memory, specs, and plans live in `aidd_docs/`. diff --git a/plugins/aidd-context/skills/06-agent-generate/SKILL.md b/plugins/aidd-context/skills/06-agent-generate/SKILL.md index 0ed301d81..47cb8e55d 100644 --- a/plugins/aidd-context/skills/06-agent-generate/SKILL.md +++ b/plugins/aidd-context/skills/06-agent-generate/SKILL.md @@ -6,7 +6,7 @@ argument-hint: role # Agent Generate -Write one canonical agent from intent and render it per confirmed host tool, or once as a plugin source. +Write an agent once and render it as one file per confirmed host tool, or once as a plugin source. ## Actions diff --git a/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md b/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md index 45010c424..a433bd5f9 100644 --- a/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md +++ b/plugins/aidd-context/skills/06-agent-generate/actions/01-capture-agent.md @@ -14,7 +14,7 @@ In-context: the role and its system prompt, the chosen name, an optional user-sp 1. **Gate.** Run the asset-access precheck ([tool-paths.md](../references/tool-paths.md)). 2. **Clarify.** Ask about the purpose, tools, inputs, and instructions. If anything stays vague, ask again before moving on. -3. **Draft.** Write the canonical role ([agent-authoring.md](../references/agent-authoring.md)): a frontmatter intent + a system-prompt body. Include only the optional and orchestration sections it needs. +3. **Draft.** Write the canonical role per [agent-authoring.md](../references/agent-authoring.md). 4. **Name.** Propose three short, catchy names that fit the purpose, and have the user pick one. 5. **Score.** Rate the agent 1-10 on clarity and completeness. If it scores under 8, revise and score again. 6. **Mode.** Ask where the agent goes: diff --git a/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md b/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md index 92c656e02..da5e80a7b 100644 --- a/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md +++ b/plugins/aidd-context/skills/06-agent-generate/actions/02-write-agent.md @@ -13,11 +13,9 @@ One agent file per confirmed tool, and the list of files written. ## Process 1. **Build.** Copy [agent-template.md](../assets/agent-template.md) into one canonical agent. Strip the scaffold (comments + `<...>`). -2. **Frontmatter.** Apply the per-tool frontmatter from [tool-paths.md](../references/tool-paths.md). Drop a field where the tool does not support it. Plugin source: keep the canonical frontmatter. +2. **Frontmatter.** Apply the per-tool frontmatter from [tool-paths.md](../references/tool-paths.md). Plugin source: keep the canonical frontmatter. 3. **Convert.** Apply any per-tool structural conversion listed in [tool-paths.md](../references/tool-paths.md). Plugin source skips conversion. -4. **Render.** Per the write mode ([tool-paths.md](../references/tool-paths.md)): - - **Host**: for each confirmed tool, write to its path and extension. - - **Plugin source**: write one canonical agent. No per-tool fan-out. +4. **Render.** Write to the chosen [write target](../references/tool-paths.md#write-targets). 5. **Validate.** Run the write-target validation ([tool-paths.md](../references/tool-paths.md)). ## Test diff --git a/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md b/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md index 9169f12c2..922ebcacb 100644 --- a/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md +++ b/plugins/aidd-context/skills/06-agent-generate/actions/03-validate.md @@ -13,9 +13,9 @@ A short pass or fail line per agent file. ## Process 1. **Exists.** Confirm each file is on disk at its expected path. -2. **Shape.** Confirm each file matches its target format ([tool-paths.md](../references/tool-paths.md)), a confirmed tool's, or the canonical form in plugin source, and carries the name, description, and body. -3. **Concise.** Confirm the body stays focused on one role. +2. **Shape.** Confirm each file matches its target format ([tool-paths.md](../references/tool-paths.md)). +3. **Authoring.** Confirm the file meets [agent-authoring.md](../references/agent-authoring.md). ## Test -- Every agent file exists, carries name and description, and holds the role body. +- Every agent file exists, identifies its role in the target format, and holds the behavior. diff --git a/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md b/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md index 6af2328ce..296cf316f 100644 --- a/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md +++ b/plugins/aidd-context/skills/06-agent-generate/assets/agent-template.md @@ -9,13 +9,9 @@ description: # required, third person # Behavior - + diff --git a/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md b/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md index 28fae750c..08977eb33 100644 --- a/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md +++ b/plugins/aidd-context/skills/06-agent-generate/references/agent-authoring.md @@ -4,15 +4,15 @@ The contract every generated agent must satisfy. An agent is a focused role with ## Rules -- **R1.** One agent, one role. A single, clear responsibility. +- **R1.** One agent, one behavior, self-contained in one file per target. Workflows belong in skills; omit skill permission lists and required companion files. - **R2.** Frontmatter: `name` and `description` are required. `model` is optional. The description states what the agent does and when to use it. -- **R3.** The body is the system prompt. Required: `# Role` and `# Behavior` (the steps). Optional sections: see `## Body shape`. Concise and imperative. +- **R3.** The body is the system prompt. Required: `# Role` and `# Behavior` (bounded capabilities and constraints). Optional sections: see `## Body shape`. Concise and imperative. - **R4.** English only, regardless of conversation language. - **R5.** One idea per sentence. Split a sentence that runs past one line. Exceptions: table rows. ## Naming -- Short and catchy, a single word where possible: `executor`, `checker`, `auditor`. +- Short and catchy, a single word where possible: `executor`, `reviewer`, `auditor`. - Fits the role at a glance. Propose three options for the user to pick. ## Body shape @@ -21,8 +21,3 @@ Short imperative sentences. Optional sections, added only when they earn their p - `# Inputs`: what it receives. - `# Outputs`: what it returns. -- `# Guardrails`: what it must never do. -- `# Skills you may invoke`: name a same-plugin skill by its `plugin:folder` address, for deterministic resolution. Name a cross-plugin skill by its capability, discovered at runtime, never hardcoded. -- `# Definition of Ready` / `# Definition of Done`: start and finish gates. -- `# Decisions in scope` / `# Decisions out of scope`: what it may decide, what it must defer. -- `# Handoffs`: who it returns to. diff --git a/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md b/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md index 90a1badde..386902675 100644 --- a/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md +++ b/plugins/aidd-context/skills/06-agent-generate/references/tool-paths.md @@ -14,17 +14,27 @@ The per-tool agent path and the gate every run executes before writing. Agent sl Agents are supported on all five tools. +User-level agents, for every project of the user: Claude Code `~/.claude/agents/` (`CLAUDE_CONFIG_DIR`), Codex `~/.codex/agents/` (`CODEX_HOME`), OpenCode `~/.config/opencode/agents/` (`XDG_CONFIG_HOME`), Copilot `~/.copilot/agents/.agent.md` (`COPILOT_HOME`). Cursor documents `~/.cursor/agents/`; prefer the project path. Sources: [Claude](https://code.claude.com/docs/en/sub-agents), [Codex](https://developers.openai.com/codex/subagents), [OpenCode](https://opencode.ai/docs/agents), [Copilot](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), [Cursor](https://cursor.com/docs/subagents). + +Cursor keeps single quotes as part of a frontmatter value: quote Cursor values with `"double quotes"` or not at all. + ## Frontmatter per tool -The canonical agent carries `name` and `description`. Emit those a row accepts, drop the rest. Optional fields, including `model`, only if the user asked. Never invent a value. +The canonical agent carries `name` and `description` and no tool list: every host gives an agent all tools by default. Emit those a row accepts, drop the rest. Add model or effort settings only when requested and supported. | Tool | Accepts | | -------------- | ----------------------------------------------------------- | -| Claude Code | `name`, `description`, optional `model`, `color`, `tools` | -| Cursor | `name`, `description`, optional `model`, `readonly`, `is_background` | -| OpenCode | `name`, `description`, optional `model`, `temperature`, `permission` | -| GitHub Copilot | `name`, `description`, optional `model`, `tools` | -| Codex CLI | `name`, `description` (drops `model`) | +| Claude Code | `name`, `description`, optional `model`, `effort` | +| Cursor | `name`, `description`, optional `model` | +| OpenCode V2 | `description`, `mode: subagent`, optional `model` as `provider/model#variant`. No `name`: the file path is the ID | +| GitHub Copilot | `name`, `description`, optional `model`, `reasoningEffort`, `include-custom-instructions` | +| Codex CLI | `name`, `description`, optional `model`, `model_reasoning_effort` | + +Write OpenCode agents for V2 only: V2 still loads V1 definitions, and treats their `tools` and `permission` fields as legacy. + +Copilot subagents skip `AGENTS.md` and other repository instructions unless `include-custom-instructions: true`; set it by default. + +Sources: [Claude](https://code.claude.com/docs/en/sub-agents#frontmatter-reference), [Cursor](https://cursor.com/docs/subagents#file-format), [OpenCode V2](https://opencode.ai/v2/docs/agents#options), [V1 compatibility](https://opencode.ai/v2/docs/migrate-v1), [Copilot](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference#custom-agent-frontmatter-fields), [Codex](https://developers.openai.com/codex/subagents#custom-agent-file-schema). ## Codex TOML conversion @@ -32,7 +42,6 @@ Codex agents are TOML, not markdown. Convert: - Each frontmatter field becomes a top-level TOML key. Quote every string value with `"double quotes"` (a TOML basic string) and escape any embedded `"` or backslash, so a quote or apostrophe in the description stays valid TOML. - The body becomes `developer_instructions`, wrapped in `'''` literal delimiters (no escaping of the markdown). -- Drop `model`. ## Detect (which tools are installed) diff --git a/plugins/aidd-dev/.claude-plugin/plugin.json b/plugins/aidd-dev/.claude-plugin/plugin.json index 1858ed514..93605dcac 100644 --- a/plugins/aidd-dev/.claude-plugin/plugin.json +++ b/plugins/aidd-dev/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aidd-dev", "version": "2.7.1", - "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure. Hosts engineering agents.", + "description": "Code transformation: plan, implement, assert, audit, review, test, refactor, debug, for-sure.", "author": { "name": "AI-Driven Dev", "url": "https://github.com/ai-driven-dev" @@ -19,10 +19,6 @@ "./skills/09-for-sure", "./skills/10-batch" ], - "agents": [ - "./agents/executor.md", - "./agents/checker.md" - ], "keywords": [ "sdlc", "implementation", diff --git a/plugins/aidd-dev/CATALOG.md b/plugins/aidd-dev/CATALOG.md index 466c1eb93..579ed92ea 100644 --- a/plugins/aidd-dev/CATALOG.md +++ b/plugins/aidd-dev/CATALOG.md @@ -7,7 +7,6 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai ## Table of Contents - [`.claude-plugin`](#claude-plugin) -- [`agents`](#agents) - [`skills`](#skills) - [`skills/01-plan`](#skills01-plan) - [`skills/02-implement`](#skills02-implement) @@ -28,13 +27,6 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai |------| | [plugin.json](.claude-plugin/plugin.json) | -### `agents` - -| File | Description | -|------|---| -| [checker.md](agents/checker.md) | `Judges finished work against its validator and the real need, leaving nothing unchecked. Use when code or a deliverable needs independent verification before it ships. Never edits the work, never implements the fix.` | -| [executor.md](agents/executor.md) | `Turns a dispatched task into working, validated code that fits the project. Use when an approved scope must become code. Never plans, never judges its own work.` | - ### `skills` #### `skills/01-plan` diff --git a/plugins/aidd-dev/README.md b/plugins/aidd-dev/README.md index 30e1d7250..cc7dfc763 100644 --- a/plugins/aidd-dev/README.md +++ b/plugins/aidd-dev/README.md @@ -8,7 +8,7 @@ Code transformation plugin for the AI-Driven Development framework. First time? Install with `/plugin install aidd-dev@aidd-framework`, then run `aidd-dev:01-plan`. -Covers code transformation: planning, implementation, assertions, audits, code review, testing, refactoring, debugging, for-sure, and parallel batch execution. Also hosts AI agents. +Covers code transformation: planning, implementation, assertions, audits, code review, testing, refactoring, debugging, for-sure, and parallel batch execution. ## Skills @@ -24,10 +24,3 @@ Covers code transformation: planning, implementation, assertions, audits, code r | [2.8] | [debug](skills/08-debug/SKILL.md) | Reproduce and fix bugs systematically using test-driven workflow, root cause analysis, and hypothesis validation. | | [2.9] | [for-sure](skills/09-for-sure/SKILL.md) | Iterative agent loop that tracks attempts and retries until a success condition is met. | | [2.10] | [batch](skills/10-batch/SKILL.md) | Split a request into independent tasks, execute them in parallel, and summarize each agent's task, status, and result. | - -## Agents - -| Agent | Description | -|---|---| -| executor | Dispatched doer in its own context. Turns a scoped task into working, validated code that fits the project, deciding how, never what. Never authors a plan, never judges its own work. | -| checker | Independent critic in fresh context. Judges finished work against its validator and the real need, leaving nothing unchecked. Returns findings, scores, and a verdict. Never edits the work, never implements the fix. | diff --git a/plugins/aidd-dev/agents/checker.md b/plugins/aidd-dev/agents/checker.md deleted file mode 100644 index 0122b6033..000000000 --- a/plugins/aidd-dev/agents/checker.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: checker -description: Judges finished work against its validator and the real need, leaving nothing unchecked. Use when code or a deliverable needs independent verification before it ships. Never edits the work, never implements the fix. ---- - -# Role - -You are the checker. Your job is to judge finished work against its validator and the real need, in a fresh context with no memory of how it was built, and to leave nothing unchecked. - -# Behavior - -- Build your validator stack first: the acceptance criteria and the need the work is meant to serve. Extend the checklist below with the project's own review checklist when it provides one. -- Judge each criterion: inspect, run validation commands when they exist, and mark it fulfilled, partial, or unfulfilled with evidence. -- Run the checklist on every code or diff, leaving no item unchecked. -- Then check the layer the reviews miss: does the delivered logic serve the actual need, end to end, even when code review and functional review both pass? Name any gap between intent and result. -- Demand command output or file evidence, never bare claims. Lean strict: a false alarm costs less than a missed defect. -- When a review skill fits the work, run it and let it write its report; that report is your deliverable and your judgment is what fills it. Never hand-write a parallel prose review beside it. -- Return your verdict, findings, and score on top. Hold yourself accountable for whatever you pass. - -# Checklist - -This is the behavioral baseline. Apply it to every code or diff, and extend it with the project's own checklist when one exists. - -- [ ] No information duplication. DRY across code and docs; link to the canonical home instead of copying. -- [ ] No incoherence or contradiction. Naming, behavior, and docs-versus-code stay consistent. -- [ ] No over-engineering. The simplest solution that meets the need; no speculative generality, no unused abstraction. -- [ ] No dead code or debug leftovers. No commented-out blocks, stray logs, or silent TODOs. - -# Scoring - -- If the validator defines weights and thresholds, apply them exactly, and let any hard violation force the score to zero. -- Otherwise score the proportion of fulfilled criteria, adjusted for the severity of the findings, with your reasoning. -- The pass threshold is the caller's gate, not yours. You report the score; you do not declare pass or fail. - -# Guardrails - -- Never edit the work or its validator. Never implement the fix. Never delegate to another agent. -- Never pass on vibes: tie every verdict to a stated criterion, a checklist item, or a named need-gap. -- Flag an ambiguous criterion instead of guessing. Do not go easy because the work looks impressive. - -# Skills you may invoke - -- `/aidd-dev:05-review` -- `/aidd-dev:04-audit` -- `/aidd-refine:02-challenge` diff --git a/plugins/aidd-dev/agents/executor.md b/plugins/aidd-dev/agents/executor.md deleted file mode 100644 index 10a4d6dfc..000000000 --- a/plugins/aidd-dev/agents/executor.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: executor -description: Turns a dispatched task into working, validated code that fits the project. Use when an approved scope must become code. Never plans, never judges its own work. ---- - -# Role - -You are the executor. Your job is to turn a dispatched task into working, validated code that fits the project. You decide how, never what. - -# Behavior - -- Honour the project's own conventions where it defines them, and match the code around you. Where it stays silent, follow the prevailing idiom. -- Internalize the acceptance criteria before writing anything. If the scope is ambiguous, surface it instead of guessing. -- Work in a tight loop: build a substep, validate it, repair on red, and only then move on. Validation passing is the gate, never your own say-so. -- When a validation failure has no clear cause, run `/aidd-dev:08-debug` before repairing and validating again. -- Commit per coherent unit, code and its status together, one unit one commit. -- When you finish or stall, return to whoever invoked you with what is done, what is left, and why. - -# Guardrails - -- Never author or edit a plan, spec, or acceptance criterion. That authority is the caller's. On any drift, stop and hand it back to be replanned. -- Stay strictly in the scope you are handed. Declare anything you bypass: no silent TODO, skipped test, or placeholder mock. -- Never judge your own work, and never start a review. The caller handles that. -- Never delegate to another agent. -- When the work is physically impossible for the AI, stop and say so plainly: a real payment, a human login, an unreadable secret, anything behind hardware or 2FA. Do not fake progress. - -# Skills you may invoke - -- `/aidd-dev:02-implement` -- `/aidd-dev:03-assert` -- `/aidd-dev:06-test` -- `/aidd-dev:08-debug` -- `/aidd-vcs:01-commit` diff --git a/plugins/aidd-dev/skills/05-review/SKILL.md b/plugins/aidd-dev/skills/05-review/SKILL.md index 8ee340517..746ab25e0 100644 --- a/plugins/aidd-dev/skills/05-review/SKILL.md +++ b/plugins/aidd-dev/skills/05-review/SKILL.md @@ -9,8 +9,8 @@ argument-hint: diff | plan ```mermaid flowchart LR all([all axes]) --> prepare - one([one axis named]) --> prepare - prepare --> review-code & review-functional & review-relevancy --> finalize + one([selected axes]) --> prepare + prepare -->|selected only| review-code & review-functional & review-relevancy --> finalize finalize --> approve([approve]) finalize --> changes([changes-requested]) finalize --> blocked([blocked]) diff --git a/plugins/aidd-dev/skills/05-review/actions/01-prepare.md b/plugins/aidd-dev/skills/05-review/actions/01-prepare.md index d5454a8b7..31c503329 100644 --- a/plugins/aidd-dev/skills/05-review/actions/01-prepare.md +++ b/plugins/aidd-dev/skills/05-review/actions/01-prepare.md @@ -13,7 +13,7 @@ A round appended to the report, and the diff the axes read. ## Process 1. **Resolve.** Resolve the diff and the report per [report-contract.md](../references/report-contract.md). -2. **Open.** Append a round from the template `report-contract.md` names, filling what is known before the axes run. +2. **Open.** Append a round from the template `report-contract.md` names, recording the selected axes and filling what is known before they run. 3. **Hand over.** Name the diff and the round the axes write into. ## Test @@ -22,4 +22,4 @@ A round appended to the report, and the diff the axes read. | --- | --- | | A fresh report | round 1 appended, its date as the start date | | A report holding two rounds | a third appended, the header counting 3, the first two unchanged | -| A run naming one axis | the round names that axis alone | +| A run naming axes | the round names and runs only those axes | diff --git a/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md b/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md index e5f274b24..fbb3f1212 100644 --- a/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md +++ b/plugins/aidd-dev/skills/09-for-sure/assets/plan-template.md @@ -49,7 +49,7 @@ status: pending ## Applicable rules - + | Tool | Name | Path | Why it applies | | ------ | ------ | ------------------------------------- | -------------- | diff --git a/plugins/aidd-dev/skills/10-batch/actions/01-batch.md b/plugins/aidd-dev/skills/10-batch/actions/01-batch.md index 825f87026..ccaa25a7c 100644 --- a/plugins/aidd-dev/skills/10-batch/actions/01-batch.md +++ b/plugins/aidd-dev/skills/10-batch/actions/01-batch.md @@ -17,7 +17,7 @@ One row per dispatched agent, identified by its name or ID. Status is `Done` (ta 1. **Read.** Take `prompt` from the arguments; if empty, ask the user. 2. **Split.** Divide the prompt into distinct, independent tasks. Inline, no agent. -3. **Launch.** Spawn one `executor` agent per task, all in parallel (one message, multiple Task calls). Track each agent's name or ID and assigned task. Each agent prompt mandates, in order: +3. **Launch.** Spawn one available implementation agent per task, all in parallel (one message, multiple Task calls). Track each agent's name or ID and assigned task. Each agent prompt mandates, in order: ```markdown 1. Refine the task first: run a non-interactive refine capability if one is available (discovered at runtime, never a hardcoded plugin name); otherwise restate the task clearly and resolve obvious ambiguity inline. Never block on the user. 2. Implement the refined task. diff --git a/plugins/aidd-orchestrator/.claude-plugin/plugin.json b/plugins/aidd-orchestrator/.claude-plugin/plugin.json index eb10c8c36..97d186f79 100644 --- a/plugins/aidd-orchestrator/.claude-plugin/plugin.json +++ b/plugins/aidd-orchestrator/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aidd-orchestrator", "version": "2.4.0", - "description": "Orchestrates the synchronous SDLC pipeline, async issue-to-PR automation, and the product backlog.", + "description": "Orchestrates the synchronous SDLC pipeline, async issue-to-PR automation, and the product backlog. Hosts the generic agents.", "author": { "name": "AI-Driven Dev", "url": "https://github.com/ai-driven-dev" @@ -12,6 +12,12 @@ "./skills/01-sdlc", "./skills/02-backlog" ], + "agents": [ + "./agents/explorer.md", + "./agents/executor.md", + "./agents/validator.md", + "./agents/reviewer.md" + ], "keywords": [ "orchestration", "sdlc", diff --git a/plugins/aidd-orchestrator/CATALOG.md b/plugins/aidd-orchestrator/CATALOG.md index 61a6aa78f..f5645e535 100644 --- a/plugins/aidd-orchestrator/CATALOG.md +++ b/plugins/aidd-orchestrator/CATALOG.md @@ -7,6 +7,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai ## Table of Contents - [`.claude-plugin`](#claude-plugin) +- [`agents`](#agents) - [`skills`](#skills) - [`skills/00-async-dev`](#skills00-async-dev) - [`skills/01-sdlc`](#skills01-sdlc) @@ -20,6 +21,15 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai |------| | [plugin.json](.claude-plugin/plugin.json) | +### `agents` + +| File | Description | +|------|---| +| [executor.md](agents/executor.md) | `Implements assigned work within defined scope. Use to write or revise code and documentation, verify the result, and repair failures against the requested outcome.` | +| [explorer.md](agents/explorer.md) | `Explores a codebase to answer scoped questions. Use to locate files and symbols, trace execution or dependencies, and return relevant code with an explanation before changes.` | +| [reviewer.md](agents/reviewer.md) | `Reviews the quality and coherence of finished work. Use to find concrete defects, contradictions, duplication, and unnecessary complexity against project conventions and the real need.` | +| [validator.md](agents/validator.md) | `Validates that delivered work satisfies the request and acceptance criteria. Use to independently confirm completion and explain failures or missing verification.` | + ### `skills` #### `skills/00-async-dev` diff --git a/plugins/aidd-orchestrator/README.md b/plugins/aidd-orchestrator/README.md index 279456cad..cb225ab4c 100644 --- a/plugins/aidd-orchestrator/README.md +++ b/plugins/aidd-orchestrator/README.md @@ -32,6 +32,17 @@ See the [skill's SKILL.md](skills/00-async-dev/SKILL.md) for the sub-flow invent |------------|-------|-------------| | [6.2] | [backlog](skills/02-backlog/SKILL.md) | Route backlog intake, refinement, lifecycle events, review, and repair across PM capabilities. | +## Agents + +Generic roles the orchestrated flows delegate to. The calling skill gives each one its concern; see [sub-agents](../../docs/SUBAGENTS.md). + +| Agent | Role | +|-------|------| +| [explorer](agents/explorer.md) | Answers scoped questions about a codebase, before any change. | +| [executor](agents/executor.md) | Delivers assigned code or documentation within the agreed scope. | +| [validator](agents/validator.md) | Confirms the delivered work meets the request and its criteria. | +| [reviewer](agents/reviewer.md) | Finds quality and coherence gaps that acceptance checks miss. | + ### Roadmap | Use case | Direction | diff --git a/plugins/aidd-orchestrator/agents/executor.md b/plugins/aidd-orchestrator/agents/executor.md new file mode 100644 index 000000000..b7f6373b9 --- /dev/null +++ b/plugins/aidd-orchestrator/agents/executor.md @@ -0,0 +1,27 @@ +--- +name: executor +description: Implements assigned work within defined scope. Use to write or revise code and documentation, verify the result, and repair failures against the requested outcome. +--- + +# Role + +Deliver assigned code or documentation within the agreed outcome and criteria. + +# Behavior + +- **Choose the simplest solution within owned scope**, minimizing changes; escalate material changes to scope or dependencies. +- **Express clear intent with few words** in names, code, and documentation. Prefer removing obsolete or redundant material while preserving required behavior and useful knowledge. +- **Verify and repair the result**; report unmet criteria and missing checks. +- **Wait for every check or commit you started** before ending a turn. +- **Respect file and shared-operation ownership.** + +# Output + +Return a concise result; omit empty fields. + +```markdown +**Status:** +**Changes:** +**Checks:** +**Remaining:** +``` diff --git a/plugins/aidd-orchestrator/agents/explorer.md b/plugins/aidd-orchestrator/agents/explorer.md new file mode 100644 index 000000000..9b8f670e2 --- /dev/null +++ b/plugins/aidd-orchestrator/agents/explorer.md @@ -0,0 +1,28 @@ +--- +name: explorer +description: Explores a codebase to answer scoped questions. Use to locate files and symbols, trace execution or dependencies, and return relevant code with an explanation before changes. +--- + +# Role + +Find the code and context that answer the caller's question. + +# Behavior + +- **Read relevant code, callers, and dependencies**; return sourced paths, symbols, patterns, or short excerpts and explain what they establish. +- **Distinguish facts from hypotheses** and state unresolved questions. Suggest changes only when asked. +- **Keep sources unchanged.** + +# Output + +Keep the answer proportional to the question; omit unused fields. + +```markdown +**Answer:** + +- **:** + + + +**Unknowns:** +``` diff --git a/plugins/aidd-orchestrator/agents/reviewer.md b/plugins/aidd-orchestrator/agents/reviewer.md new file mode 100644 index 000000000..4857a0aa8 --- /dev/null +++ b/plugins/aidd-orchestrator/agents/reviewer.md @@ -0,0 +1,24 @@ +--- +name: reviewer +description: Reviews the quality and coherence of finished work. Use to find concrete defects, contradictions, duplication, and unnecessary complexity against project conventions and the real need. +--- + +# Role + +Review finished work independently and identify quality or coherence gaps that acceptance checks may miss. + +# Behavior + +- **Review the actual work against the need**, project conventions, and caller-supplied criteria. Check defects, contradictions, duplication, and unnecessary complexity. +- **Tie findings to evidence and impact**; distinguish defects from suggestions and state uncertainty. Recommend the smallest correction, favoring removal. +- **Keep the work unchanged.** + +# Output + +Return a concise review. + +```markdown +**Review:** + +- **:** +``` diff --git a/plugins/aidd-orchestrator/agents/validator.md b/plugins/aidd-orchestrator/agents/validator.md new file mode 100644 index 000000000..bd5c72e76 --- /dev/null +++ b/plugins/aidd-orchestrator/agents/validator.md @@ -0,0 +1,23 @@ +--- +name: validator +description: Validates that delivered work satisfies the request and acceptance criteria. Use to independently confirm completion and explain failures or missing verification. +--- + +# Role + +Verify whether the requested outcome has been delivered. + +# Behavior + +- **Check the requested outcome, acceptance criteria, and mandatory project checks** using observable evidence. Reuse results only for the same unchanged candidate; run missing checks when possible. +- **Report yes only when the assigned scope passes.** +- **Keep the work and criteria unchanged**; leave repairs and decisions to the caller. + +# Output + +Return one concise verdict for the assigned scope. + +```markdown +**Validated:** + +``` diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md b/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md index 9e5dad479..090db2e46 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md @@ -10,7 +10,7 @@ argument-hint: request | interactive request Decide the mode as `mode.md` says, then read only it and the current zone's reference. Verify that every named provider is installed before calling it. A zone that cannot proceed stops the run and says what it would take to resume. -Spawn specialized agents for isolated work. Parallelize independent work when it is faster. Give each agent one focused task that a smaller model can execute. Repeat the responsible zone when delegated work returns an actionable gap. +Delegate to the agent each zone names, by its role, so every run delegates the same way; without it, give the same brief to the host's default subagent. Parallelize independent work when it is faster. Repeat the responsible zone when delegated work returns an actionable gap. ```mermaid --- diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md b/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md index 7e094d169..210a932dc 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md @@ -2,7 +2,7 @@ ## Behavior -Turn the contract into a proportional plan, then give one executor the plan. The executor implements it and calls the validation skills, which own their checks and repair loops. Include architecture conformance whenever the project documents architecture. Run an end-to-end journey after every other validation when the plan calls for one, and none otherwise. +Turn the contract into a proportional plan, then give the `executor` agent the plan. It implements it and calls the validation skills, which own their checks and repair loops. Include architecture conformance whenever the project documents architecture. Run an end-to-end journey after every other validation when the plan calls for one, and none otherwise. Commit and push the validated work through the commit skill. Send only the clean committed candidate to Check. @@ -20,7 +20,7 @@ flowchart TD subgraph ExecuteStage["Execute and validate the plan"] direction TB - Executor(["@aidd-dev:executor"]) + Executor(["executor agent"]) Implement["/aidd-dev:02-implement"] Assert["/aidd-dev:03-assert"] Test["/aidd-dev:06-test"] diff --git a/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md b/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md index 9603ab221..15039e45b 100644 --- a/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md +++ b/plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md @@ -2,12 +2,15 @@ ## Behavior -- Use one fresh checker, independent from implementation, to review the candidate against the contract, plan, and validation evidence. -- After the review clears, the same checker challenges whether the real outcome is trustworthy and serves the user. +Use a fresh agent for each step: + +- **Validate the committed candidate** with the `validator` agent, against the contract, criteria, and mandatory checks. +- **Review quality and coherence** with the `reviewer` agent once validation passes: it runs `/aidd-dev:05-review` on the code and relevancy axes with the acceptance evidence. +- **Challenge the outcome** after review clears: run `/aidd-refine:02-challenge` to verify that it is trustworthy and serves the user. ## Findings -A checker's finding is routed by what it is about. A refusal the user gives at a pause is not one of these: `mode.md` routes that by artifact. +A review or validation finding is routed by what it is about. A refusal the user gives at a pause is not one of these: `mode.md` routes that by artifact. - Product or contract findings are the next Frame source. - Independent implementation findings go through Batch; dependent repairs stay together in Deliver. @@ -17,7 +20,7 @@ A checker's finding is routed by what it is about. A refusal the user gives at a ## Handoff -- Commit whatever this zone wrote, then open the draft pull request when the checker returns no actionable finding. +- Commit whatever this zone wrote, then open the draft pull request when no actionable finding remains. ```mermaid --- @@ -30,8 +33,8 @@ flowchart TD Plan["$plan"] CommittedCandidate["$committed_candidate"] ValidationReports["$validation_reports"] - Checker(["@aidd-dev:checker"]) - Review["/aidd-dev:05-review"] + Validator(["validator agent"]) + Reviewer(["reviewer agent: /aidd-dev:05-review"]) end subgraph ChallengeStage["Challenge the outcome"] @@ -53,13 +56,14 @@ flowchart TD PullRequestUrl["$pull_request_url"] end - Contract --> Checker - Plan --> Checker - CommittedCandidate --> Checker - ValidationReports --> Checker - Checker --> Review - Review -- "Return actionable review findings." --> Findings - Review -- "When the review clears, challenge the outcome." --> Challenge + Contract --> Validator + Plan --> Validator + CommittedCandidate --> Validator + ValidationReports --> Validator + Validator -- "All criteria pass with evidence." --> Reviewer + Validator -- "Fail or blocked." --> Findings + Reviewer -- "Return actionable review findings." --> Findings + Reviewer -- "When the review clears, challenge the outcome." --> Challenge Challenge -- "Return actionable challenge findings." --> Findings Challenge -- "When the outcome is trustworthy, open the draft pull request." --> PullRequest Findings -- "Use product findings as the next Frame source." --> Frame @@ -73,8 +77,8 @@ flowchart TD classDef artifact fill:#DCFCE7,stroke:#16A34A,color:#14532D,stroke-width:2px classDef zone fill:#F1F5F9,stroke:#64748B,color:#0F172A,stroke-width:2px - class Review,Challenge,Batch,PullRequest skill - class Checker agent + class Challenge,Batch,PullRequest skill + class Reviewer,Validator agent class Contract,Plan,CommittedCandidate,ValidationReports,Findings,PullRequestUrl artifact class Frame,Deliver zone ``` diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 01a42e0ea..7d1bfb714 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -1,17 +1,169 @@ const assert = require("node:assert/strict"); const { spawnSync } = require("node:child_process"); -const { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } = require("node:fs"); +const { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } = require("node:fs"); const { tmpdir } = require("node:os"); -const { delimiter, join, resolve } = require("node:path"); +const { delimiter, dirname, join, resolve } = require("node:path"); const test = require("node:test"); const repo = resolve(__dirname, "../.."); +const { render, sync } = require("../sync-global-instructions.cjs"); + +const instructionSource = "# AGENTS.md\n\n## Action\n- Keep scope.\n- Follow `docs/ARCHITECTURE.md`.\n\n## Agents\nUse available agents when useful. The caller owns scope and acceptance.\n\n## Memory Management\nProject docs live in aidd_docs/.\n\n@aidd_docs/memory/private.md\n\nLoad more memory.\n"; + +test("global instructions copy generic delegation guidance without project context", () => { + for (const host of ["codex", "claude", "opencode", "copilot", "cursor"]) { + const content = render(instructionSource, host); + assert.match(content, /Use available agents when useful/); + assert.doesNotMatch(content, /@aidd/); + assert.doesNotMatch(content, /Memory|memory|aidd_docs|ARCHITECTURE/); + assert.doesNotMatch(content, /Keep scope|## Action|## Behavior|## Communication/); + } +}); + +test("global sync preserves personal rules, updates its block, and is idempotent", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-rules-")); + try { + const source = join(root, "source.md"); + const destination = join(root, "codex/AGENTS.md"); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(source, instructionSource); + const personal = "Personal rules with no trailing newline"; + writeFileSync(destination, personal); + sync(source, [["codex", destination]]); + const first = readFileSync(destination, "utf8"); + sync(source, [["codex", destination]]); + assert.equal(readFileSync(destination, "utf8"), first); + writeFileSync(source, instructionSource.replace("Use available agents when useful.", "Updated delegation guidance.")); + sync(source, [["codex", destination]]); + const updated = readFileSync(destination, "utf8"); + assert.ok(updated.startsWith(personal)); + assert.match(updated, /Updated delegation guidance/); + assert.doesNotMatch(updated, /Use available agents when useful\./); + assert.equal(updated.split("aidd_global_instructions:start").length, 2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("global sync replaces legacy duplicated instructions without changing personal text", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-migration-")); + try { + const source = join(root, "source.md"); + const destination = join(root, "AGENTS.md"); + const prefix = "## Behavior\nMy personal rules.\n\n"; + const suffix = "\n\nMy private closing note.\n"; + writeFileSync(source, instructionSource); + writeFileSync(destination, prefix + "\n## Behavior\nLegacy duplicated rules.\n## Communication\nLegacy communication.\n" + suffix); + sync(source, [["codex", destination]]); + const migrated = readFileSync(destination, "utf8"); + assert.ok(migrated.startsWith(prefix)); + assert.ok(migrated.endsWith(suffix)); + assert.doesNotMatch(migrated, /Legacy|Keep scope|## Communication|## Action/); + assert.equal(migrated.match(/^## Behavior$/gm).length, 1); + assert.match(migrated, /Use available agents when useful/); + sync(source, [["codex", destination]]); + assert.equal(readFileSync(destination, "utf8"), migrated); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("global agent extraction works without project memory and stops at the next section", () => { + const source = "## Agents\nUse available agents when useful.\n\n## Project rules\nPrivate project rules.\n"; + assert.match(render(source, "claude"), /Use available agents when useful/); + assert.doesNotMatch(render(source, "claude"), /Project rules|Private project/); + assert.match(render(source.split("\n## Project rules")[0], "codex"), /Use available agents when useful/); +}); + +test("first OpenCode sync retains effective Claude personal rules without its managed block", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-fallback-")); + try { + const source = join(root, "source.md"); + const claude = join(root, "CLAUDE.md"); + const opencode = join(root, "opencode/AGENTS.md"); + writeFileSync(source, instructionSource); + writeFileSync(claude, "Claude personal rules.\n"); + sync(source, [["claude", claude]]); + sync(source, [["opencode", opencode]], claude); + const installed = readFileSync(opencode, "utf8"); + assert.match(installed, /Claude personal rules/); + assert.match(installed, /Use available agents when useful/); + assert.doesNotMatch(installed, /@aidd/); + writeFileSync(claude, "Changed fallback.\n"); + sync(source, [["opencode", opencode]], claude); + assert.equal(readFileSync(opencode, "utf8"), installed); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("malformed managed blocks and missing agent sections leave all global files untouched", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-invalid-")); + try { + const source = join(root, "source.md"); + const codex = join(root, "AGENTS.md"); + const claude = join(root, "CLAUDE.md"); + writeFileSync(source, instructionSource); + writeFileSync(codex, "Keep Codex.\n"); + for (const malformed of ["", "", "".repeat(2)]) { + writeFileSync(claude, malformed); + assert.throws(() => sync(source, [["codex", codex], ["claude", claude]]), /Malformed/); + assert.equal(readFileSync(codex, "utf8"), "Keep Codex.\n"); + assert.equal(readFileSync(claude, "utf8"), malformed); + } + writeFileSync(source, "Unbounded context.\n"); + assert.throws(() => sync(source, [["codex", codex]]), /Agents section/); + assert.equal(readFileSync(codex, "utf8"), "Keep Codex.\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("Copilot and Cursor global instructions retain personal rules and native rule activation", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-global-native-rules-")); + try { + const source = join(root, "source.md"), copilot = join(root, "copilot-instructions.md"), cursor = join(root, "aidd-agents.mdc"); + writeFileSync(source, instructionSource); + writeFileSync(copilot, "Copilot personal rule.\n"); + sync(source, [["copilot", copilot], ["cursor", cursor]]); + assert.match(readFileSync(copilot, "utf8"), /^Copilot personal rule\.\n/); + const rule = readFileSync(cursor, "utf8"); + assert.match(rule, /^---\nalwaysApply: true\n---\n/); + for (const file of [copilot, cursor]) { + assert.match(readFileSync(file, "utf8"), /Use available agents when useful/); + assert.doesNotMatch(readFileSync(file, "utf8"), /aidd_docs|Memory|ARCHITECTURE/); + } + writeFileSync(cursor, rule + "\nPersonal closing rule.\n"); + sync(source, [["copilot", copilot], ["cursor", cursor]]); + const updated = readFileSync(cursor, "utf8"); + assert.match(updated, /^---\nalwaysApply: true\n---\n/); + assert.ok(updated.endsWith("Personal closing rule.\n")); + sync(source, [["cursor", cursor]]); + assert.equal(readFileSync(cursor, "utf8"), updated); + } finally { rmSync(root, { recursive: true, force: true }); } +}); function executable(path, body) { writeFileSync(path, `#!/bin/sh\nset -eu\n${body}\n`); chmodSync(path, 0o755); } +function fixture(home, plugins = ["aidd-dev"]) { + const source = join(home, "framework"); + if (!existsSync(source)) { + for (const [plugin, skill] of [["aidd-dev", "01-plan"], ["aidd-pm", "03-prd"]]) { + if (!plugins.includes(plugin)) continue; + const pluginDir = join(source, "plugins", plugin); + mkdirSync(join(pluginDir, ".claude-plugin"), { recursive: true }); + writeFileSync(join(pluginDir, ".claude-plugin/plugin.json"), "{}\n"); + mkdirSync(join(pluginDir, "skills", skill), { recursive: true }); + writeFileSync(join(pluginDir, "skills", skill, "SKILL.md"), "source skill\n"); + } + writeFileSync(join(source, "AGENTS.md"), instructionSource); + } + return source; +} + /** On POSIX, nothing but the stubs and the platform's own utilities, so a real `opencode` or * `claude` installed on the machine running these tests can never answer in their place. * Windows has neither directory, and the utilities `dev-sync.sh` calls live wherever Git for @@ -19,22 +171,50 @@ function executable(path, body) { const BASE_PATH = process.platform === "win32" ? (process.env.PATH ?? "") : ["/usr/bin", "/bin"].join(delimiter); -function runSync(fakeBin, home, args = ["all"]) { +function runSync(fakeBin, home, args = ["all"], overrides = {}) { + executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); // `bash`, resolved on PATH, never the literal `/bin/bash`: that path does not exist on // Windows, where bash is the one Git for Windows ships. `spawnSync` resolves a bare name // through the platform's own rules on both. return spawnSync("bash", [join(repo, "scripts/dev-sync.sh"), ...args], { - cwd: repo, + cwd: home, env: { ...process.env, PATH: [fakeBin, BASE_PATH].join(delimiter), HOME: home, + FW: fixture(home), BUILD: join(home, "build"), + CODEX_CACHE: join(home, ".codex/plugins/cache"), + CLAUDE_CACHE: join(home, ".claude/plugins/cache"), + OPENCODE_SKILLS: join(home, ".config/opencode/skills"), + CODEX_AGENTS: join(home, ".codex/agents"), + OPENCODE_AGENTS: join(home, ".config/opencode/agents"), + CODEX_INSTRUCTIONS: join(home, ".codex/AGENTS.md"), + CLAUDE_INSTRUCTIONS: join(home, ".claude/CLAUDE.md"), + OPENCODE_INSTRUCTIONS: join(home, ".config/opencode/AGENTS.md"), + COPILOT_INSTRUCTIONS: join(home, ".copilot/copilot-instructions.md"), + CURSOR_INSTRUCTIONS: join(home, ".cursor/rules/aidd-agents.mdc"), + CURSOR_PLUGINS: join(home, ".cursor/plugins/local"), + OPENCODE_DISABLE_CLAUDE_CODE: "", + OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: "", + ...overrides, }, encoding: "utf8", }); } +test("reload fixtures run from their temporary home", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cwd-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "aidd-opencode-reload"), "node -e 'process.stdout.write(process.cwd())' > \"$HOME/observed-cwd\""); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(realpathSync(readFileSync(join(home, "observed-cwd"), "utf8").trim()), realpathSync(home)); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("managed OpenCode reload uses the fixed-purpose helper without a local build", () => { const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-managed-")); try { @@ -48,39 +228,617 @@ test("managed OpenCode reload uses the fixed-purpose helper without a local buil assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /reload opencode\s+managed:ok/); assert.equal(existsSync(join(home, "build")), false); + assert.match(readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"), /Use available agents when useful/); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("local OpenCode reload installs only the selected plugin skills globally", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); +for (const flag of ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"]) { + test(`OpenCode first reload respects disabled Claude fallback via ${flag}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-fallback-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude/CLAUDE.md"), "Excluded Claude personal rules.\n"); + executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + const result = runSync(fakeBin, home, [], { [flag]: "1" }); + assert.equal(result.status, 0, result.stderr); + const installed = readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"); + assert.doesNotMatch(installed, /Excluded Claude/); + assert.match(installed, /Use available agents when useful/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("reload delivers global instructions to all three hosts while preserving personal rules", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-rules-")); try { const fakeBin = join(root, "bin"); const home = join(root, "home"); mkdirSync(fakeBin); mkdirSync(home); - mkdirSync(join(home, ".config/opencode/skills/aidd-dev-stale"), { recursive: true }); - writeFileSync(join(home, ".config/opencode/skills/aidd-dev-stale/SKILL.md"), "stale\n"); + const source = fixture(home, ["aidd-pm"]); + writeFileSync(join(source, "AGENTS.md"), instructionSource); + const destinations = [".codex/AGENTS.md", ".claude/CLAUDE.md", ".config/opencode/AGENTS.md"]; + for (const path of destinations) { + mkdirSync(dirname(join(home, path)), { recursive: true }); + writeFileSync(join(home, path), "Personal rules.\n"); + } + for (const host of ["codex", "claude", "opencode"]) executable(join(fakeBin, host), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--out" ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/.opencode/skills/aidd-pm-03-prd" +printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-pm-03-prd/SKILL.md"`); + const first = runSync(fakeBin, home, []); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /global instructions\s+ok/); + const contents = destinations.map((path) => { + const content = readFileSync(join(home, path), "utf8"); + assert.ok(content.startsWith("Personal rules.\n")); + assert.match(content, /Use available agents when useful/); + assert.doesNotMatch(content, /@aidd/); + assert.doesNotMatch(content, /Memory|aidd_docs|ARCHITECTURE/); + return content; + }); + const second = runSync(fakeBin, home, []); + assert.equal(second.status, 0, second.stderr); + destinations.forEach((path, index) => assert.equal(readFileSync(join(home, path), "utf8"), contents[index])); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +for (const layout of ["flat", "nested"]) { + test(`local OpenCode reload refreshes the framework namespace using ${layout} output`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(home); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev-stale"), { recursive: true }); + writeFileSync(join(home, ".config/opencode/skills/aidd-dev-stale/SKILL.md"), "stale\n"); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev/removed"), { recursive: true }); + writeFileSync(join(home, ".config/opencode/skills/aidd-dev/removed/SKILL.md"), "stale\n"); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev/01-plan"), { recursive: true }); + writeFileSync(join(home, ".config/opencode/skills/aidd-dev/01-plan/SKILL.md"), "old\n"); + const unrelated = join(home, ".config/opencode/skills/aidd-pm/03-prd/SKILL.md"); + mkdirSync(join(home, ".config/opencode/skills/aidd-pm/03-prd"), { recursive: true }); + writeFileSync(unrelated, "keep\n"); + const agentDir = join(home, ".config/opencode/agents"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync(join(agentDir, "aidd-dev-checker.md"), "old agent\n"); + writeFileSync(join(agentDir, "aidd-dev-removed.md"), "stale\n"); + writeFileSync(join(agentDir, "personal.md"), "keep\n"); + writeFileSync(join(agentDir, "aidd-pm-review.md"), "keep\n"); + executable(join(fakeBin, "opencode"), "exit 0"); + const skillName = layout === "nested" ? "aidd-dev/01-plan" : "aidd-dev-01-plan"; + executable( + join(fakeBin, "npx"), + String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--out" ]; then shift; out=$1; fi + shift +done +skill="$out/.opencode/skills/${skillName}" +mkdir -p "$skill/actions" "$skill/assets" +printf '%s\n' '---' 'name: aidd-dev-01-plan' 'description: test' '---' > "$skill/SKILL.md" +printf '%s\n' 'updated action' > "$skill/actions/04-plan.md" +printf '%s\n' 'updated template' > "$skill/assets/plan-template.md" +mkdir -p "$out/.opencode/agents" +printf '%s\n' 'built executor' > "$out/.opencode/agents/aidd-dev-executor.md" +`, + ); + + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + const installed = join(home, ".config/opencode/skills", skillName); + assert.match(readFileSync(join(installed, "SKILL.md"), "utf8"), /name: aidd-dev-01-plan/); + assert.equal(readFileSync(join(installed, "actions/04-plan.md"), "utf8"), "updated action\n"); + assert.equal(readFileSync(join(installed, "assets/plan-template.md"), "utf8"), "updated template\n"); + assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev-stale")), false); + assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev/removed")), false); + assert.equal(readFileSync(unrelated, "utf8"), "keep\n"); + assert.match(result.stdout, /install opencode\s+skills:ok agents:ok/); + assert.equal(readFileSync(join(agentDir, "aidd-dev-executor.md"), "utf8"), "built executor\n"); + assert.equal(readFileSync(join(agentDir, "aidd-dev-checker.md"), "utf8"), "old agent\n"); + assert.equal(readFileSync(join(agentDir, "personal.md"), "utf8"), "keep\n"); + assert.equal(readFileSync(join(agentDir, "aidd-pm-review.md"), "utf8"), "keep\n"); + + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("local OpenCode reload rejects missing framework skills before removing installed ones", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-missing-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + const installed = join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"); + mkdirSync(join(home, ".config/opencode/skills/aidd-dev-01-plan"), { recursive: true }); + writeFileSync(installed, "keep\n"); executable(join(fakeBin, "opencode"), "exit 0"); - executable( - join(fakeBin, "npx"), - String.raw`out="" + executable(join(fakeBin, "npx"), "exit 0"); + + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built OpenCode skill for aidd-dev-01-plan/); + assert.doesNotMatch(result.stdout, /skills:ok/); + assert.equal(readFileSync(installed, "utf8"), "keep\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("default OpenCode reload installs every plugin's skills", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-all-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(home); + fixture(home, ["aidd-dev", "aidd-pm"]); + const skills = ["aidd-dev-01-plan", "aidd-pm-03-prd"]; + const buildSkills = skills.map((name) => `mkdir -p "$out/.opencode/skills/${name}"\nprintf '%s\\n' 'skill' > "$out/.opencode/skills/${name}/SKILL.md"`).join("\n"); + executable(join(fakeBin, "opencode"), "exit 0"); + executable(join(fakeBin, "npx"), `out="" while [ "$#" -gt 0 ]; do if [ "$1" = "--out" ]; then shift; out=$1; fi shift done -mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" -printf '%s\n' '---' 'name: aidd-dev-01-plan' 'description: test' '---' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md"`, - ); +${buildSkills}`); - const result = runSync(fakeBin, home, ["aidd-dev"]); + const result = runSync(fakeBin, home, []); assert.equal(result.status, 0, result.stderr); - const installed = join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"); - assert.match(readFileSync(installed, "utf8"), /name: aidd-dev-01-plan/); - assert.equal(existsSync(join(home, ".config/opencode/skills/aidd-dev-stale")), false); - assert.match(result.stdout, /install opencode\s+skills:ok/); + for (const skill of skills) { + assert.equal(readFileSync(join(home, ".config/opencode/skills", `${skill}/SKILL.md`), "utf8"), "skill\n"); + } + assert.equal(existsSync(join(home, ".config/opencode/agents")), false); + assert.match(result.stdout, /skills:ok/); } finally { rmSync(root, { recursive: true, force: true }); } }); + +for (const badOutput of ["missing", "empty"]) { + test(`OpenCode rejects ${badOutput} expected skill output despite another built skill`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-partial-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + const source = fixture(home); + const second = join(source, "plugins/aidd-dev/skills/02-implement"); + mkdirSync(second); + writeFileSync(join(second, "SKILL.md"), "source skill\n"); + const installed = join(home, ".config/opencode/skills/aidd-dev-02-implement"); + mkdirSync(installed, { recursive: true }); + writeFileSync(join(installed, "SKILL.md"), "keep\n"); + executable(join(fakeBin, "opencode"), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--out" ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" +printf '%s\n' 'skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" +${badOutput === "empty" ? 'mkdir -p "$out/.opencode/skills/aidd-dev-02-implement"; touch "$out/.opencode/skills/aidd-dev-02-implement/SKILL.md"' : ""}`); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built OpenCode skill for aidd-dev-02-implement/); + assert.equal(readFileSync(join(installed, "SKILL.md"), "utf8"), "keep\n"); + assert.doesNotMatch(result.stdout, /skills:ok|Done\./); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +for (const host of ["opencode", "codex", "copilot", "cursor"]) { + test(`reload fails when the installed ${host} host's build fails`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-build-failed-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, host), "exit 0"); + executable(join(fakeBin, "npx"), "exit 7"); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`${host} build failed`)); + assert.doesNotMatch(result.stdout, /Done\./); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +for (const host of ["codex", "claude", "copilot"]) { + test(`reload fails when the installed ${host} host rejects a plugin`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-install-failed-")); + try { + const fakeBin = join(root, "bin"); + const home = join(root, "home"); + mkdirSync(fakeBin); + fixture(home, ["aidd-pm"]); + executable(join(fakeBin, host), `if [ "$1 $2" = 'plugin marketplace' ] && [ "$3" = list ]; then echo '[]'; fi\nif [ "$1" = plugin ] && [ "$2" = ${host === "codex" ? "add" : "install"} ]; then exit 8; fi\nexit 0`); + executable(join(fakeBin, "npx"), host === "copilot" ? nativeBuildStub() : "exit 0"); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /plugin install failed for aidd-pm/); + assert.doesNotMatch(result.stdout, /Done\./); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} + +function nativeBuildStub(omitSkill = false) { + return String.raw`out=""; tool="" +while [ "$#" -gt 0 ]; do + case "$1" in --out) shift; out=$1 ;; --to) shift; tool=$1 ;; esac + shift +done +for source in "$FW/plugins/"*/; do + plugin="$(basename "$source")" + target="$out/plugins/$plugin" + manifest=".$tool-plugin" + [ "$tool" != copilot ] || manifest=".plugin" + mkdir -p "$target/$manifest" "$target/skills" + printf '{"name":"%s"}\n' "$plugin" > "$target/$manifest/plugin.json" + ${omitSkill ? ":" : 'cp -R "$FW/plugins/$plugin/skills/." "$target/skills/"'} +done`; +} + +for (const binary of ["cursor-agent", "agent", "cursor"]) { + test(`Cursor reload through ${binary} installs every built plugin and preserves personal content`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + fixture(home, ["aidd-dev", "aidd-pm"]); + const personal = [".cursor/agents/executor.md", ".cursor/rules/personal.mdc", ".cursor/mcp.json", ".cursor/plugins/local/personal/notes.md"]; + for (const file of personal) { + mkdirSync(dirname(join(home, file)), { recursive: true }); + writeFileSync(join(home, file), "personal bytes\n"); + } + const stale = join(home, ".cursor/plugins/local/aidd-dev/agents/removed.md"); + mkdirSync(dirname(stale), { recursive: true }); + writeFileSync(stale, "retired role\n"); + executable(join(fakeBin, binary), "echo 'Start the Cursor Agent'"); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + for (const [plugin, skill] of [["aidd-dev", "01-plan"], ["aidd-pm", "03-prd"]]) { + assert.equal(readFileSync(join(home, `.cursor/plugins/local/${plugin}/skills/${skill}/SKILL.md`), "utf8"), "source skill\n"); + } + assert.equal(existsSync(join(home, ".cursor/plugins/local/aidd-dev/agents")), false); + assert.match(readFileSync(join(home, ".cursor/rules/aidd-agents.mdc"), "utf8"), /Use available agents when useful/); + assert.equal(existsSync(stale), false); + for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); + const first = readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"); + assert.equal(runSync(fakeBin, home, []).status, 0); + assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), first); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("Cursor reload rejects incomplete native output before changing installed plugins", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-cursor-incomplete-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const installed = join(home, ".cursor/plugins/local/aidd-dev/agents/executor.md"); + mkdirSync(dirname(installed), { recursive: true }); + writeFileSync(installed, "old role\n"); + executable(join(fakeBin, "cursor-agent"), "exit 0"); + executable(join(fakeBin, "npx"), nativeBuildStub(true)); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built cursor skill/); + assert.equal(readFileSync(installed, "utf8"), "old role\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("another tool named agent does not trigger Cursor installation", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-other-agent-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "agent"), "echo 'Grok Build TUI'"); + executable(join(fakeBin, "npx"), "exit 9"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(home, "build/cursor")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +const copilotMarketplaceList = String.raw`node -e 'process.stdout.write(JSON.stringify([{name:"aidd-framework",source:"Local: "+process.argv[1]}]))'`; +const copilotStub = String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" +case "$1 $2 $3" in + 'plugin marketplace list') + if [ -f "$HOME/copilot-source" ]; then + ${copilotMarketplaceList} "$(cat "$HOME/copilot-source")" + else echo '[]'; fi ;; + 'plugin marketplace add') + if [ -f "$HOME/copilot-source" ]; then echo 'Marketplace "aidd-framework" already registered' >&2; exit 1; fi + printf '%s' "$4" > "$HOME/copilot-source" ;; + 'plugin marketplace remove') rm "$HOME/copilot-source" ;; + 'plugin marketplace update') : ;; +esac`; + +test("Copilot fixture serializes Windows paths and quotes as JSON", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-json-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); mkdirSync(home); + const source = String.raw`D:\Work\Example "quoted"\Framework`; + writeFileSync(join(home, "copilot-source"), source); + executable(join(fakeBin, "node"), `exec '${process.execPath}' "$@"`); + const binary = join(fakeBin, "copilot"); + executable(binary, copilotStub); + const result = spawnSync("bash", [binary, "plugin", "marketplace", "list", "--json"], { + cwd: home, + env: { ...process.env, HOME: home, PATH: [fakeBin, BASE_PATH].join(delimiter) }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), [{ name: "aidd-framework", source: `Local: ${source}` }]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot reload builds all native plugins, migrates its own source, and preserves personal files", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); mkdirSync(home); + const source = fixture(home, ["aidd-dev", "aidd-pm"]); + writeFileSync(join(home, "copilot-source"), source); + const personal = [".copilot/agents/executor.agent.md", ".copilot/instructions/personal.instructions.md", ".copilot/settings.json"]; + for (const file of personal) { + mkdirSync(dirname(join(home, file)), { recursive: true }); + writeFileSync(join(home, file), "personal bytes\n"); + } + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const first = runSync(fakeBin, home, []); + assert.equal(first.status, 0, first.stderr); + assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); + for (const plugin of ["aidd-dev", "aidd-pm"]) { + assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), new RegExp(`plugin install ${plugin}@aidd-framework`)); + assert.equal(JSON.parse(readFileSync(join(home, `build/copilot/plugins/${plugin}/.plugin/plugin.json`), "utf8")).name, plugin); + } + assert.match(readFileSync(join(home, ".copilot/copilot-instructions.md"), "utf8"), /Use available agents when useful/); + assert.equal(runSync(fakeBin, home, []).status, 0); + assert.match(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace update aidd-framework/); + for (const file of personal) assert.equal(readFileSync(join(home, file), "utf8"), "personal bytes\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot refuses another source registered under the same marketplace name", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-foreign-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); mkdirSync(home); + writeFileSync(join(home, "copilot-source"), "/another/framework"); + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /different Copilot marketplace source/); + assert.equal(readFileSync(join(home, "copilot-source"), "utf8"), "/another/framework"); + assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove|plugin install/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot rejects incomplete native output before registering or installing", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-incomplete-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub(true)); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /no built copilot skill/); + assert.equal(existsSync(join(home, "copilot-calls")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot registers an inherited own marketplace without trying to remove it", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-inherited-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), String.raw`printf '%s\n' "$*" >> "$HOME/copilot-calls" +case "$1 $2 $3" in + 'plugin marketplace list') ${copilotMarketplaceList} "$FW" ;; + 'plugin marketplace remove') echo 'Marketplace "aidd-framework" is not registered' >&2; exit 1 ;; + 'plugin marketplace add') printf '%s' "$4" > "$HOME/copilot-source" ;; +esac`); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(realpathSync(readFileSync(join(home, "copilot-source"), "utf8")), realpathSync(join(home, "build/copilot"))); + assert.doesNotMatch(readFileSync(join(home, "copilot-calls"), "utf8"), /marketplace remove/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const command of ["add", "update", "install"]) { + test(`Copilot ${command} failure retains the native cause`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-cause-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), String.raw`case "$1 $2 $3" in + 'plugin marketplace list') ${command === "update" ? `${copilotMarketplaceList} "$BUILD/copilot"` : "echo '[]'"} ;; + 'plugin marketplace ${command}'${command === "install" ? "|\'plugin install aidd-dev@aidd-framework\'" : ""}) echo 'fixture native permission denied' >&2; exit 9 ;; +esac`); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, []); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /fixture native permission denied/); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("reload rejects plugin selection arguments before changing any host", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-no-filter-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const result = runSync(fakeBin, home, ["aidd-dev"]); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /every plugin/); + assert.equal(existsSync(join(home, "build")), false); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("reload uses the checkout's built CLI instead of a published translator", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-local-cli-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const source = fixture(home), binary = join(source, "cli/dist/cli.js"); + mkdirSync(dirname(binary), { recursive: true }); + writeFileSync(binary, String.raw`const fs = require("node:fs"), path = require("node:path"); +const args = process.argv.slice(2); +if (args[0] !== "translate") process.exit(2); +const out = args[args.indexOf("--out") + 1]; +const source = args[1]; +for (const plugin of fs.readdirSync(path.join(source, "plugins"))) { + const target = path.join(out, "plugins", plugin); + fs.mkdirSync(path.join(target, ".cursor-plugin"), { recursive: true }); + fs.writeFileSync(path.join(target, ".cursor-plugin/plugin.json"), JSON.stringify({ name: plugin })); + fs.cpSync(path.join(source, "plugins", plugin, "skills"), path.join(target, "skills"), { recursive: true }); + +}`); + executable(join(fakeBin, "cursor-agent"), "echo 'Start the Cursor Agent'"); + executable(join(fakeBin, "npx"), "exit 19"); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(home, ".cursor/plugins/local/aidd-dev/skills/01-plan/SKILL.md"), "utf8"), "source skill\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Copilot instructions follow its native home with task overrides taking precedence", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-copilot-home-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"), nativeHome = join(home, "native-copilot"); + mkdirSync(fakeBin); + executable(join(fakeBin, "copilot"), copilotStub); + executable(join(fakeBin, "npx"), nativeBuildStub()); + const result = runSync(fakeBin, home, [], { COPILOT_HOME: nativeHome, COPILOT_INSTRUCTIONS: "" }); + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(join(nativeHome, "copilot-instructions.md"), "utf8"), /Use available agents when useful/); + assert.equal(existsSync(join(home, ".copilot/copilot-instructions.md")), false); + assert.equal(runSync(fakeBin, home, [], { COPILOT_HOME: nativeHome }).status, 0); + assert.match(readFileSync(join(home, ".copilot/copilot-instructions.md"), "utf8"), /Use available agents when useful/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("OpenCode skills follow its native home with task overrides taking precedence", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-opencode-home-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-config"); + mkdirSync(fakeBin); + executable(join(fakeBin, "opencode"), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = --out ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/.opencode/skills/aidd-dev-01-plan" +printf '%s\n' 'native skill' > "$out/.opencode/skills/aidd-dev-01-plan/SKILL.md" +`); + const result = runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config, OPENCODE_SKILLS: "", OPENCODE_INSTRUCTIONS: "" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(config, "opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); + assert.equal(existsSync(join(config, "opencode/agents")), false); + assert.equal(existsSync(join(home, ".config/opencode/skills")), false); + assert.equal(runSync(fakeBin, home, [], { XDG_CONFIG_HOME: config }).status, 0); + assert.equal(readFileSync(join(home, ".config/opencode/skills/aidd-dev-01-plan/SKILL.md"), "utf8"), "native skill\n"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +for (const customPluginRoot of [false, true]) { + test(`Claude cache follows its native ${customPluginRoot ? "plugin root" : "config home"}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-claude-home-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"), config = join(home, "native-claude"); + const plugins = customPluginRoot ? join(home, "native-plugins") : join(config, "plugins"); + mkdirSync(fakeBin); + executable(join(fakeBin, "claude"), String.raw`if [ "$1 $2" = 'plugin install' ]; then + cache="$CLAUDE_CONFIG_DIR/plugins" + [ -z "$CLAUDE_CODE_PLUGIN_CACHE_DIR" ] || cache="$CLAUDE_CODE_PLUGIN_CACHE_DIR" + cache="$cache/cache/aidd-framework/aidd-dev/2.6.0" + mkdir -p "$cache" + printf '%s\n' 'native plugin' > "$cache/plugin-installed" +fi`); + const result = runSync(fakeBin, home, [], { CLAUDE_CONFIG_DIR: config, CLAUDE_CODE_PLUGIN_CACHE_DIR: customPluginRoot ? plugins : "", CLAUDE_CACHE: "", CLAUDE_INSTRUCTIONS: "" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(plugins, "cache/aidd-framework/aidd-dev/2.6.0/plugin-installed"), "utf8"), "native plugin\n"); + assert.match(readFileSync(join(config, "CLAUDE.md"), "utf8"), /Use available agents when useful/); + assert.equal(existsSync(join(home, ".claude/plugins")), false); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +for (const override of ["CLAUDE_CONFIG_DIR", "CLAUDE_INSTRUCTIONS"]) { + test(`OpenCode preserves its actual Claude fallback despite ${override}`, () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-fallback-override-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude/CLAUDE.md"), "Effective OpenCode personal instructions.\n"); + const custom = join(home, "custom-claude"); + mkdirSync(custom); + writeFileSync(join(custom, "CLAUDE.md"), "Other Claude instructions.\n"); + executable(join(fakeBin, "aidd-opencode-reload"), "exit 0"); + const result = runSync(fakeBin, home, [], override === "CLAUDE_CONFIG_DIR" + ? { CLAUDE_CONFIG_DIR: custom, CLAUDE_INSTRUCTIONS: "" } + : { CLAUDE_INSTRUCTIONS: join(custom, "CLAUDE.md") }); + assert.equal(result.status, 0, result.stderr); + const installed = readFileSync(join(home, ".config/opencode/AGENTS.md"), "utf8"); + assert.match(installed, /Effective OpenCode personal instructions/); + assert.doesNotMatch(installed, /Other Claude instructions/); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +} + +test("Codex reload installs the plugin's built agents beside personal ones", () => { + const root = mkdtempSync(join(tmpdir(), "aidd-dev-sync-codex-personal-")); + try { + const fakeBin = join(root, "bin"), home = join(root, "home"); + mkdirSync(fakeBin); + const agents = join(home, ".codex/agents"); + mkdirSync(agents, { recursive: true }); + const existing = ["aidd-dev-personal.toml", "executor.toml"]; + for (const file of existing) writeFileSync(join(agents, file), `Personal ${file}\n`); + executable(join(fakeBin, "codex"), "exit 0"); + executable(join(fakeBin, "npx"), String.raw`out="" +while [ "$#" -gt 0 ]; do + if [ "$1" = --out ]; then shift; out=$1; fi + shift +done +mkdir -p "$out/plugins/aidd-dev/codex-agents" +printf '%s\n' 'built executor' > "$out/plugins/aidd-dev/codex-agents/executor.toml" +`); + const result = runSync(fakeBin, home, []); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(join(agents, "aidd-dev-executor.toml"), "utf8"), "built executor\n"); + for (const file of existing) assert.equal(readFileSync(join(agents, file), "utf8"), `Personal ${file}\n`); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 29cb7d18c..32aad997a 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,66 +1,174 @@ #!/usr/bin/env bash -# (Re)install every plugin into Claude, Codex and OpenCode from THIS checkout, named by -# argument or `all`. Claude reads the raw repo, already native; Codex installs from a tree -# the aidd CLI builds (agents -> TOML), so what runs locally matches what ships. +# (Re)install every plugin into Claude, Codex, OpenCode, Copilot and Cursor from THIS checkout. +# Claude reads the raw repo, already native; the others install from a tree the aidd CLI +# builds, so what runs locally matches what ships. Codex and OpenCode load no +# agent from a plugin, so their built agents are copied to the user's agents directory. # # NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool # whose CLI is absent is skipped, and the first run needs network. A managed OpenCode host # exposes `aidd-opencode-reload` instead, and that helper decides which checkout may load. # -# Codex caveat: the .codex-plugin manifest does not declare codex-agents/*.toml, and Codex -# loads agents only from ~/.codex/agents/, so the built TOML is copied there after install. +# Global rules: sync only the Agents section in the managed block, preserving personal +# rules. First OpenCode sync keeps its Claude personal-rule fallback unless disabled. +# Paths honor native home/cache overrides; explicit task paths take precedence. +# Sources: +# https://developers.openai.com/codex/guides/agents-md +# https://code.claude.com/docs/en/memory +# OpenCode fallback and disable flags: +# https://opencode.ai/docs/rules/#claude-code-compatibility +# Cursor local plugins: real directories only; local imports must be allowed. +# https://cursor.com/docs/plugins#test-plugins-locally +# Copilot native plugin list and install: +# https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference +# Global rules: Copilot personal instructions; Cursor always-on .mdc rules. +# https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-custom-instructions +# https://prod.cursor.com/help/customization/rules set -euo pipefail shopt -s nullglob +if [ "$#" -gt 1 ] || { [ "$#" -eq 1 ] && [ "$1" != all ]; }; then + echo "Reload syncs every plugin; plugin selection arguments are unsupported." >&2; exit 1 +fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FW="${FW:-$(dirname "$SCRIPT_DIR")}" MKT="${MKT:-aidd-framework}" -AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # override to pin if a release regresses the build +AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # published fallback for sources without a built CLI BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at -CODEX_CACHE="${CODEX_CACHE:-$HOME/.codex/plugins/cache}" -CLAUDE_CACHE="${CLAUDE_CACHE:-$HOME/.claude/plugins/cache}" -CODEX_AGENTS="${CODEX_AGENTS:-$HOME/.codex/agents}" -OPENCODE_SKILLS="${OPENCODE_SKILLS:-$HOME/.config/opencode/skills}" +CODEX_CACHE="${CODEX_CACHE:-${CODEX_HOME:-$HOME/.codex}/plugins/cache}" +CLAUDE_CACHE="${CLAUDE_CACHE:-${CLAUDE_CODE_PLUGIN_CACHE_DIR:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins}/cache}" +CODEX_AGENTS="${CODEX_AGENTS:-${CODEX_HOME:-$HOME/.codex}/agents}" +OPENCODE_SKILLS="${OPENCODE_SKILLS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills}" +OPENCODE_AGENTS="${OPENCODE_AGENTS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/agents}" +CURSOR_PLUGINS="${CURSOR_PLUGINS:-$HOME/.cursor/plugins/local}" +CODEX_INSTRUCTIONS="${CODEX_INSTRUCTIONS:-${CODEX_HOME:-$HOME/.codex}/AGENTS.md}" +CLAUDE_INSTRUCTIONS="${CLAUDE_INSTRUCTIONS:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/CLAUDE.md}" +OPENCODE_INSTRUCTIONS="${OPENCODE_INSTRUCTIONS:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode/AGENTS.md}" +COPILOT_INSTRUCTIONS="${COPILOT_INSTRUCTIONS:-${COPILOT_HOME:-$HOME/.copilot}/copilot-instructions.md}" +CURSOR_INSTRUCTIONS="${CURSOR_INSTRUCTIONS:-$HOME/.cursor/rules/aidd-agents.mdc}" HAVE_CODEX=0; command -v codex >/dev/null 2>&1 && HAVE_CODEX=1 HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_OPENCODE=0; command -v opencode >/dev/null 2>&1 && HAVE_OPENCODE=1 HAVE_MANAGED_OPENCODE=0; command -v aidd-opencode-reload >/dev/null 2>&1 && HAVE_MANAGED_OPENCODE=1 +HAVE_COPILOT=0; command -v copilot >/dev/null 2>&1 && HAVE_COPILOT=1 +HAVE_CURSOR=0 +if command -v cursor >/dev/null 2>&1 || command -v cursor-agent >/dev/null 2>&1; then + HAVE_CURSOR=1 +elif command -v agent >/dev/null 2>&1 && agent --help 2>/dev/null | grep 'Start the Cursor Agent' >/dev/null; then + HAVE_CURSOR=1 +fi build_tool() { local tool="$1" rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" local mode=marketplace [ "$tool" != opencode ] || mode=flat - npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ - --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + if [ -f "$FW/cli/dist/cli.js" ]; then + node "$FW/cli/dist/cli.js" translate "$FW" \ + --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + else + npx --yes "@ai-driven-dev/cli@${AIDD_CLI_VERSION}" translate "$FW" \ + --to "$tool" --out "$BUILD/$tool" --as "$mode" --force >/dev/null 2>&1 + fi +} + +validate_opencode_skills() { + # Check every expected nonempty skill before replacing plugin namespaces. + local name skill skill_name + for name in "$@"; do + for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do + skill_name="$(basename "$(dirname "$skill")")" + if [ ! -s "$BUILD/opencode/.opencode/skills/$name/$skill_name/SKILL.md" ] && + [ ! -s "$BUILD/opencode/.opencode/skills/$name-$skill_name/SKILL.md" ]; then + echo "no built OpenCode skill for $name-$skill_name (missing or empty SKILL.md)" >&2 + return 1 + fi + done + done +} + +validate_native_plugins() { + local tool="$1" manifest="$2" name skill + for name in "${targets[@]}"; do + if [ ! -s "$BUILD/$tool/plugins/$name/$manifest" ]; then + echo "no built $tool manifest for $name" >&2; return 1 + fi + for skill in "$FW/plugins/$name/skills/"*/SKILL.md; do + if [ ! -s "$BUILD/$tool/plugins/$name/skills/$(basename "$(dirname "$skill")")/SKILL.md" ]; then + echo "no built $tool skill for $name-$(basename "$(dirname "$skill")")" >&2; return 1 + fi + done + done } sync_opencode_skills() { + # Accept nested and legacy flat builds; preserve personal and unselected plugin content. local name source skill destination + local -a skills mkdir -p "$OPENCODE_SKILLS" + source="$BUILD/opencode/.opencode/skills" for name in "$@"; do - source="$BUILD/opencode/.opencode/skills" + skills=("$source/$name/"*/ "$source/$name-"*/) + rm -rf -- "$OPENCODE_SKILLS/$name" find "$OPENCODE_SKILLS" -mindepth 1 -maxdepth 1 -type d -name "$name-*" -exec rm -rf -- {} + - for skill in "$source/$name-"*/; do - destination="$OPENCODE_SKILLS/$(basename "$skill")" + for skill in "${skills[@]}"; do + case "$skill" in + "$source/$name/"*) destination="$OPENCODE_SKILLS/$name/$(basename "$skill")" ;; + *) destination="$OPENCODE_SKILLS/$(basename "$skill")" ;; + esac + mkdir -p "$(dirname "$destination")" cp -R "$skill" "$destination" done done } +copy_agents() { + # Overwrite only the plugin's own agents; personal agents are left untouched. + local destination="$1" prefix="$2" agent + shift 2 + for agent in "$@"; do + mkdir -p "$destination" + cp -f "$agent" "$destination/$prefix$(basename "$agent")" + done +} + register_marketplace() { case "$1" in - # Codex needs the built tree (the raw repo is Claude-syntax; Codex wants TOML/.codex-plugin). + # Codex needs the native .codex-plugin tree. codex) codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true codex plugin marketplace add "$BUILD/codex" >/dev/null 2>&1 ;; - # No build for Claude: the CLI's claude build emits an invalid agents manifest, and the - # raw repo is already native. Scope every op to user - a bare `marketplace remove` strips + # Claude installs the native repository. Scope every op to user: bare `marketplace remove` strips # the declaration from EVERY scope, wiping the repo's own project-scoped config. claude) claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true claude plugin marketplace add "$FW" --scope user >/dev/null 2>&1 ;; + copilot) + local registered_source + registered_source="$(copilot plugin marketplace list --json | node -e ' + const entries = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + if (!Array.isArray(entries)) throw new Error("Invalid Copilot marketplace list"); + const entry = entries.find(item => item.name === process.argv[1]); + if (entry && typeof entry.source !== "string") throw new Error("Invalid Copilot marketplace source"); + process.stdout.write(entry?.source ?? ""); + ' "$MKT")" || return 1 + case "$registered_source" in + "") copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; + "Local: $BUILD/copilot") copilot plugin marketplace update "$MKT" >/dev/null ;; + "Local: $FW") + # Native list also includes inherited Claude registrations, which remove cannot change. + local add_result + if add_result="$(copilot plugin marketplace add "$BUILD/copilot" 2>&1)"; then + return 0 + fi + case "$add_result" in + *"already registered"*) + copilot plugin marketplace remove "$MKT" --force >/dev/null || return 1 + copilot plugin marketplace add "$BUILD/copilot" >/dev/null ;; + *) printf '%s\n' "$add_result" >&2; return 1 ;; + esac ;; + *) echo "Refusing a different Copilot marketplace source for $MKT: $registered_source" >&2; return 1 ;; + esac ;; esac } @@ -74,79 +182,107 @@ sync_one() { rm -rf "$CODEX_CACHE/$MKT/$name" if codex plugin add "$name@$MKT" >/dev/null 2>&1; then printf ' codex:ok' - # Codex ignores plugin-bundled agents (manifest gap) - drop the built TOML where it looks. - local toml n=0 - for toml in "$BUILD/codex/plugins/$name/codex-agents/"*.toml; do - mkdir -p "$CODEX_AGENTS"; cp -f "$toml" "$CODEX_AGENTS/"; n=$((n + 1)) - done - [ "$n" -gt 0 ] && printf '(+%d agents)' "$n" + copy_agents "$CODEX_AGENTS" "$name-" "$BUILD/codex/plugins/$name/codex-agents/"*.toml else printf ' codex:FAIL' + echo "Codex plugin install failed for $name; rerun codex plugin add $name@$MKT for details." >&2 + return 1 fi fi if [ "$HAVE_CLAUDE" = 1 ]; then rm -rf "$CLAUDE_CACHE/$MKT/$name" if claude plugin install "$name@$MKT" --scope user >/dev/null 2>&1; then printf ' claude:ok' - # Claude loads agents ONLY from the installed installPath, and `plugin install` copies - # them there implicitly - skipping the copy still prints ok. Force-sync them and report - # the count, so a miss is never silent. - if [ -d "$FW/plugins/$name/agents" ]; then - local dest src n=0 fixed=0 - dest="$(ls -d "$CLAUDE_CACHE/$MKT/$name"/*/ 2>/dev/null | head -1)" - if [ -n "$dest" ]; then - mkdir -p "${dest}agents" - for src in "$FW/plugins/$name/agents/"*.md; do - n=$((n + 1)) - cmp -s "$src" "${dest}agents/$(basename "$src")" 2>/dev/null || { cp -f "$src" "${dest}agents/"; fixed=$((fixed + 1)); } - done - [ "$fixed" -gt 0 ] && printf '(+%d agents, repaired %d)' "$n" "$fixed" || printf '(+%d agents)' "$n" - else - printf '(agents:NO-INSTALLPATH)' - fi - fi else printf ' claude:FAIL' + echo "Claude plugin install failed for $name; rerun claude plugin install $name@$MKT --scope user for details." >&2 + return 1 + fi + fi + if [ "$HAVE_COPILOT" = 1 ]; then + if copilot plugin install "$name@$MKT" >/dev/null; then + printf ' copilot:ok' + else + echo "Copilot plugin install failed for $name" >&2; return 1 fi fi echo } -if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then - echo "No Claude, Codex, or OpenCode installation found - nothing to install."; exit 0 +if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ] && [ "$HAVE_OPENCODE" = 0 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ] && [ "$HAVE_COPILOT" = 0 ] && [ "$HAVE_CURSOR" = 0 ]; then + echo "No supported host installation found - nothing to install."; exit 0 fi targets=() -if [ $# -eq 0 ] || [ "${1:-}" = "all" ]; then - for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done -else - targets=("$@") -fi +for d in "$FW"/plugins/*/; do targets+=("$(basename "$d")"); done -# Codex needs a native build (md -> toml); register against it. Claude installs from the raw +# Codex needs a native plugin build; register against it. Claude installs from the raw # repo (already native), so it only needs the marketplace registered - no build. if [ "$HAVE_CODEX" = 1 ]; then printf '%-22s' "build codex" - build_tool codex && { register_marketplace codex; echo "ok"; } || { echo "FAIL"; HAVE_CODEX=0; } + build_tool codex || { echo "FAIL"; echo "codex build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } + register_marketplace codex || { echo "FAIL"; echo "Codex marketplace registration failed for $BUILD/codex." >&2; exit 1; } + echo "ok" fi if [ "$HAVE_CLAUDE" = 1 ]; then register_marketplace claude fi if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then printf '%-22s' "build opencode" - build_tool opencode && echo "ok" || { echo "FAIL"; HAVE_OPENCODE=0; } + build_tool opencode && echo "ok" || { echo "FAIL"; echo "opencode build failed; rerun the translate command in scripts/dev-sync.sh without output redirection for details." >&2; exit 1; } +fi +if [ "$HAVE_CURSOR" = 1 ]; then + printf '%-22s' "build cursor" + build_tool cursor || { echo "FAIL"; echo "cursor build failed" >&2; exit 1; } + validate_native_plugins cursor .cursor-plugin/plugin.json || { echo "FAIL"; exit 1; } + echo "ok" +fi +if [ "$HAVE_COPILOT" = 1 ]; then + printf '%-22s' "build copilot" + build_tool copilot || { echo "FAIL"; echo "copilot build failed" >&2; exit 1; } + validate_native_plugins copilot .plugin/plugin.json || { echo "FAIL"; exit 1; } + register_marketplace copilot || { echo "FAIL"; echo "Copilot marketplace registration failed" >&2; exit 1; } + echo "ok" fi for t in "${targets[@]}"; do sync_one "$t"; done if [ "$HAVE_OPENCODE" = 1 ] && [ "$HAVE_MANAGED_OPENCODE" = 0 ]; then printf '%-22s' "install opencode" + validate_opencode_skills "${targets[@]}" || { echo "FAIL"; exit 1; } sync_opencode_skills "${targets[@]}" - echo "skills:ok" + for t in "${targets[@]}"; do copy_agents "$OPENCODE_AGENTS" "" "$BUILD/opencode/.opencode/agents/$t-"*.md; done + echo "skills:ok agents:ok" fi if [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then printf '%-22s' "reload opencode" aidd-opencode-reload fi +if [ "$HAVE_CURSOR" = 1 ]; then + printf '%-22s' "install cursor" + mkdir -p "$CURSOR_PLUGINS" + for t in "${targets[@]}"; do + rm -rf -- "$CURSOR_PLUGINS/$t" + cp -R "$BUILD/cursor/plugins/$t" "$CURSOR_PLUGINS/$t" + done + echo "plugins:ok (requires local plugin imports)" +fi + +instructions=() +[ "$HAVE_CODEX" = 0 ] || instructions+=(codex "$CODEX_INSTRUCTIONS") +[ "$HAVE_CLAUDE" = 0 ] || instructions+=(claude "$CLAUDE_INSTRUCTIONS") +if [ "$HAVE_OPENCODE" = 1 ] || [ "$HAVE_MANAGED_OPENCODE" = 1 ]; then + instructions+=(opencode "$OPENCODE_INSTRUCTIONS") +fi +[ "$HAVE_COPILOT" = 0 ] || instructions+=(copilot "$COPILOT_INSTRUCTIONS") +[ "$HAVE_CURSOR" = 0 ] || instructions+=(cursor "$CURSOR_INSTRUCTIONS") +printf '%-22s' "global instructions" +# OpenCode uses this fixed Claude fallback, independent of Claude config overrides. +instruction_fallback="$HOME/.claude/CLAUDE.md" +case "${OPENCODE_DISABLE_CLAUDE_CODE:-}" in 1|true) instruction_fallback="" ;; esac +case "${OPENCODE_DISABLE_CLAUDE_CODE_PROMPT:-}" in 1|true) instruction_fallback="" ;; esac +node "$SCRIPT_DIR/sync-global-instructions.cjs" "$FW/AGENTS.md" "$instruction_fallback" "${instructions[@]}" || + { echo "FAIL"; exit 1; } +echo "ok (delegation guidance only)" -echo "Done. Restart Claude/Codex; OpenCode discovers the refreshed skills without a restart." +echo "Done. Restart hosts or reload their configuration for refreshed plugins and instructions." diff --git a/scripts/sync-global-instructions.cjs b/scripts/sync-global-instructions.cjs new file mode 100644 index 000000000..b35236035 --- /dev/null +++ b/scripts/sync-global-instructions.cjs @@ -0,0 +1,62 @@ +const { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } = require("node:fs"); +const { dirname } = require("node:path"); + +const start = ""; +const end = ""; + +function render(source, host) { + const beginning = source.search(/^## Agents[ \t]*\r?$/m); + if (beginning < 0) throw new Error("AGENTS.md has no Agents section; refusing global sync"); + const boundary = source.indexOf("\n## ", beginning + 1); + if (!["codex", "claude", "opencode", "copilot", "cursor"].includes(host)) throw new Error(`Unknown instruction host: ${host}`); + return source.slice(beginning, boundary < 0 ? undefined : boundary).trim(); +} + +function merge(existing, content) { + const starts = existing.split(start).length - 1; + const ends = existing.split(end).length - 1; + if (starts !== ends || starts > 1 || (starts === 1 && existing.indexOf(start) > existing.indexOf(end))) { + throw new Error("Malformed global AIDD instruction block; refusing to overwrite personal rules"); + } + const block = content === null ? "" : `${start}\n${content}\n${end}`; + if (starts === 1) { + return existing.slice(0, existing.indexOf(start)) + block + existing.slice(existing.indexOf(end) + end.length); + } + if (content === null) return existing; + return existing + (existing.endsWith("\n\n") || !existing ? "" : existing.endsWith("\n") ? "\n" : "\n\n") + block + "\n"; +} + +function sync(sourcePath, destinations, claudeFallback) { + const source = readFileSync(sourcePath, "utf8"); + // Prepare every destination before writing any: a malformed block must not partially sync hosts. + const updates = destinations.map(([host, path]) => { + let existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + if (host === "cursor" && !existsSync(path)) existing = "---\nalwaysApply: true\n---\n"; + // Creating OpenCode's global file disables its Claude fallback. Keep those personal rules. + if (host === "opencode" && !existsSync(path) && claudeFallback && existsSync(claudeFallback)) { + existing = merge(readFileSync(claudeFallback, "utf8"), null); + } + return { path, content: merge(existing, render(source, host)) }; + }); + for (const { path, content } of updates) { + mkdirSync(dirname(path), { recursive: true }); + // Write beside, then rename: an interrupted run never truncates personal rules. + writeFileSync(`${path}.tmp`, content); + renameSync(`${path}.tmp`, path); + } +} + +module.exports = { render, merge, sync }; + +if (require.main === module) { + const [sourcePath, claudeFallback, ...args] = process.argv.slice(2); + try { + if (!sourcePath || claudeFallback === undefined || args.length % 2) throw new Error("Usage: sync-global-instructions.cjs SOURCE CLAUDE_FALLBACK [HOST DESTINATION ...]"); + const destinations = []; + for (let i = 0; i < args.length; i += 2) destinations.push([args[i], args[i + 1]]); + sync(sourcePath, destinations, claudeFallback); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +}