@ancleto/spec 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +55 -1
  2. package/package.json +1 -1
  3. package/src/cli/index.js +405 -47
package/README.md CHANGED
@@ -23,6 +23,7 @@
23
23
  - [Características principales](#características-principales)
24
24
  - [Requisitos](#requisitos)
25
25
  - [Instalación](#instalación)
26
+ - [Hosts soportados y layouts por agente](#hosts-soportados-y-layouts-por-agente)
26
27
  - [Guía de .gitignore](#guía-de-gitignore)
27
28
  - [Configuración y tiers de costo](#configuración-y-tiers-de-costo)
28
29
  - [Uso rápido](#uso-rápido)
@@ -39,7 +40,7 @@
39
40
 
40
41
  **Ancleto** (alias de CLI: `aspec`) es un orquestador ligero para desarrollo de software asistido por IA bajo el paradigma **SDD (Spec-Driven Development)**.
41
42
 
42
- Vive dentro de tu entorno de desarrollo (OpenCode, Cursor, VS Code, Roo, etc.) y automatiza tres cosas que normalmente hacés a mano:
43
+ Vive dentro de tu entorno de desarrollo (OpenCode, Claude, Cursor, VS Code, Roo, etc.) y automatiza tres cosas que normalmente hacés a mano:
43
44
 
44
45
  - **Especificar** — escritura guiada de especificaciones y planes de implementación.
45
46
  - **Descubrir** — relevamiento topológico del repositorio y empaquetado de contexto.
@@ -125,6 +126,59 @@ Para medir el consumo **real** por sesión (no estimado): `ancleto stats` lee la
125
126
 
126
127
  ---
127
128
 
129
+ ## Hosts soportados y layouts por agente
130
+
131
+ `--agent <nombre>` selecciona **el layout nativo a materializar** (el directorio y la convención de
132
+ nombre de cada asset) y el **pipeline de adaptación de frontmatter** de ese host. No arbitra qué
133
+ descubre cada host: si dos hosts leen el mismo directorio, el descubrimiento compartido es un efecto
134
+ del host (el CLI no deduplica ni elige un "ganador").
135
+
136
+ | Host (`--agent`) | skills | agents | commands |
137
+ |---|---|---|---|
138
+ | `opencode` (default) | `.opencode/skills/<n>/SKILL.md` | `.opencode/agents/<n>.md` | `.opencode/commands/<n>.md` |
139
+ | `claude` | `.claude/skills/<n>/SKILL.md` | `.claude/agents/<n>.md` (adaptado) | `.claude/commands/<n>.md` |
140
+ | `vscode` (VS Code / Copilot) | `.github/skills/<n>/SKILL.md` | `.github/agents/<n>.agent.md` | `.github/prompts/<n>.prompt.md` |
141
+ | `antigravity` | `.agents/skills/<n>/SKILL.md` | `.agents/agents/<n>.md` (adaptado) | `.agents/skills/<n>/SKILL.md` (como skill) |
142
+ | `cursor` | `.cursor/skills/<n>/SKILL.md` | — | — |
143
+ | `roo` | `.roo/skills/<n>/SKILL.md` | — | — |
144
+
145
+ Cada host recibe sus skills en **su** directorio nativo. **`.agents/skills` es un punto de lectura
146
+ compartido** —lo leen opencode, Cursor, VS Code y Antigravity—, no un destino universal: Claude lee
147
+ `.claude/skills` y Roo, `.roo/skills`. Por eso el CLI no escribe todas las skills en `.agents/skills`.
148
+
149
+ **Antigravity** no tiene directorio de commands de proyecto; sus `/cleto-*` se materializan como
150
+ **command-skills** en `.agents/skills/<n>/SKILL.md` (los Workflows están deprecados a favor de Agent
151
+ Skills), por eso `installedPaths.commands` queda vacío para este host. Sus `agents` se escriben en
152
+ `.agents/agents/<n>.md` con el frontmatter adaptado: `name` inyectado, `tools` como **lista** de ids
153
+ verificados de Antigravity (`read`→`view_file`, `edit`→`replace_file_content`, `grep`→`grep_search`,
154
+ `bash`→`run_command`, `todowrite`→`manage_task`), `mode`→`mainAgent`/`subagent`,
155
+ `commandExecutionPolicy: sandbox` y `model: inherit` (el tier no reescribe modelos de Antigravity). Una
156
+ tool de opencode sin id verificado se **omite con aviso** (`skip tool '<k>': no verified Antigravity id
157
+ for agent '<n>'`): un id inexistente cuelga el subagent. Los ids que sólo existen en el SDK
158
+ (`find_file`, `edit_file`, `search_web`, `read_url_content`, …), los de comunidad (`write_to_file`,
159
+ `call_mcp_tool`, `multi_replace_file_content`, …) y los de delegación (`invoke_subagent`,
160
+ `start_subagent`, `define_subagent`) **no** se emiten; el uso de MCP se expresa por
161
+ `mcpServers`/`.agents/mcp_config.json`, nunca como tool id. El campo `skill` no es una tool: se cubre
162
+ con el surfaceo automático de skills de Antigravity.
163
+
164
+ Los assets que un host no soporta (por ejemplo `agents`/`commands` de `cursor` y `roo`)
165
+ **no se escriben** y el instalador avisa por `stderr`
166
+ (`skip agents: not supported by host 'cursor'`) sin abortar. La instalación es **aditiva y no
167
+ destructiva**: instalar con otro agente escribe en su base y preserva los layouts previos, y el
168
+ manifiesto `.ancletorc.installedPaths` acumula la unión de los destinos realmente escritos.
169
+
170
+ La **configuración MCP específica de host** está fuera de alcance con **una excepción**: para
171
+ `agent: antigravity` el CLI genera o mergea (no destructivo) el MCP de workspace
172
+ `.agents/mcp_config.json` con esquema `{ "mcpServers": { "<n>": { "command", "args", "env" } } }`,
173
+ incluyendo el servidor de memoria `ancleto-memory`; preserva `mcpServers` preexistentes y no pisa
174
+ homónimos. `.mcp.json` (Claude) y `.vscode/mcp.json` (VS Code) siguen fuera de alcance, al igual que el
175
+ MCP global de Antigravity. `claude` se soporta con adaptación de frontmatter obligatoria para `agents`
176
+ (se omiten `model`/`tools` sin equivalencia verificada y las claves
177
+ `mode`/`color`/`temperature`/`permission`), sin MCP propio. Tanto `init` como `install --project`
178
+ configuran el MCP del host en la misma ejecución; `--no-mcp` lo omite.
179
+
180
+ ---
181
+
128
182
  ## Guía de .gitignore
129
183
 
130
184
  El framework **no gestiona tu `.gitignore`**: es una decisión del proyecto. Como referencia, esta es la combinación que funciona bien con Ancleto:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.js CHANGED
@@ -35,13 +35,19 @@ Uso:
35
35
  ancleto install --tier <nivel> normal | minimo | gratis (wizard interactivo en TTY)
36
36
  ancleto install --lang <codigo> auto | es | en | pt (idioma de los artifacts; auto = idioma de la conversacion)
37
37
  ancleto install --exclude <globs> Paths excluidos del discovery, coma-separados (ej: "**/*.png,docs")
38
- ancleto install --agent <nombre> opencode | vscode | antigravity | cursor | roo (wizard si no esta guardado)
38
+ ancleto install --agent <nombre> opencode | claude | vscode | antigravity | cursor | roo (wizard si no esta guardado)
39
+ Elige el layout nativo a materializar (directorio + convencion de
40
+ nombre) y el adapter de frontmatter del host. NO arbitra el
41
+ descubrimiento entre hosts: si dos leen el mismo directorio, eso es
42
+ efecto del host (no se deduplica ni se elige "ganador").
43
+ antigravity instala skills, agents adaptados y commands como skills
44
+ (.agents/*) y mergea el MCP de workspace .agents/mcp_config.json.
39
45
  ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
40
46
  Parado en un proyecto con .ancletorc opera sobre ese proyecto;
41
47
  usa --global para forzar el alcance global
42
48
  ancleto upgrade [--agent <nombre>] Re-aplica templates (LOCKED) y skills sobre el proyecto actual
43
- ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>]
44
- Crea .ancletorc en el repositorio actual
49
+ ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>] [--no-mcp]
50
+ Crea .ancletorc en el repositorio actual y configura el MCP del host
45
51
  (interactivo en TTY: banner + menu; Azure desactivado por defecto)
46
52
  ancleto discovery --check Estado del seed (READY/STALE/PARTIAL/MISSING)
47
53
  ancleto discovery [--compress] [--include G] [--ignore G] [--token-budget N]
@@ -147,12 +153,28 @@ async function writeProjectsRegistry(reg) {
147
153
  return file
148
154
  }
149
155
 
156
+ // D9: directorios locales de agents derivados del host/manifiesto, no de
157
+ // `.opencode/agents` fijo. Un proyecto antigravity usa `.agents/agents`.
158
+ function localAgentDirs(rc, agent) {
159
+ const dirs = new Set(rc?.installedPaths?.agents || [])
160
+ const spec = AGENT_TARGETS[agent || rc?.agent]?.agents
161
+ if (spec?.dir) dirs.add(spec.dir)
162
+ return [...dirs]
163
+ }
164
+
165
+ async function hasLocalAgents(baseDir, rc, agent) {
166
+ for (const d of localAgentDirs(rc, agent)) {
167
+ if (await exists(join(baseDir, d))) return true
168
+ }
169
+ return false
170
+ }
171
+
150
172
  async function registerProject(projectDir, { agent, tier } = {}) {
151
173
  const abs = resolve(projectDir)
152
174
  const rc = await readAncletorc(abs)
153
175
  const tierFile = join(abs, '.opencode', '.ancleto-tier')
154
176
  const localTier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
155
- const hasScopedAgents = await exists(join(abs, '.opencode', 'agents'))
177
+ const hasScopedAgents = await hasLocalAgents(abs, rc, agent)
156
178
  const entry = {
157
179
  path: abs.replace(/\\/g, '/'),
158
180
  version: await packageVersion(),
@@ -227,7 +249,7 @@ async function projectStatus(entry) {
227
249
  if (!rc) return { ...entry, exists: false, reason: 'sin .ancletorc' }
228
250
  const tierFile = join(abs, '.opencode', '.ancleto-tier')
229
251
  const tier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
230
- const scoped = await exists(join(abs, '.opencode', 'agents'))
252
+ const scoped = await hasLocalAgents(abs, rc, entry.agent)
231
253
  return {
232
254
  ...entry,
233
255
  exists: true,
@@ -591,13 +613,13 @@ async function applyTier(agentsDir, tier, models = null) {
591
613
  }
592
614
  }
593
615
 
594
- const SUPPORTED_AGENTS = ['opencode', 'vscode', 'antigravity', 'cursor', 'roo']
616
+ const SUPPORTED_AGENTS = ['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo']
595
617
  const DEFAULT_AGENT = 'opencode'
596
618
 
597
619
  function askAgent() {
598
620
  return new Promise((resolve) => {
599
621
  const rl = createInterface({ input: process.stdin, output: process.stdout })
600
- rl.question('Agente/IDE [opencode/vscode/antigravity/cursor/roo] (default: opencode): ', (a) => {
622
+ rl.question(`Agente/IDE [${SUPPORTED_AGENTS.join('/')}] (default: ${DEFAULT_AGENT}): `, (a) => {
601
623
  rl.close()
602
624
  const t = a.trim().toLowerCase()
603
625
  resolve(SUPPORTED_AGENTS.includes(t) ? t : DEFAULT_AGENT)
@@ -725,6 +747,47 @@ async function mergeMcp(configDir, mcpMap) {
725
747
  return { file: targets.join(', '), added: [...new Set(added)] }
726
748
  }
727
749
 
750
+ // D6: MCP de workspace de Antigravity (`.agents/mcp_config.json`). Merge NO
751
+ // destructivo: se preservan `mcpServers` y demas claves top-level, no se pisa un
752
+ // servidor homonimo, y un JSON invalido se avisa sin sobrescribir. Esquema del host
753
+ // `{ command, args, env }` (se descartan `type`/`enabled` de opencode).
754
+ async function mergeAntigravityMcp(projectDir, mcpMap) {
755
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
756
+ const file = join(projectDir, '.agents', 'mcp_config.json')
757
+ let cfg = {}
758
+ if (await exists(file)) {
759
+ try {
760
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
761
+ } catch {
762
+ console.warn(`ancleto: no se pudo leer ${basename(file)} como JSON; MCP de Antigravity no se modifico`)
763
+ return { file, added: [] }
764
+ }
765
+ }
766
+ cfg.mcpServers = cfg.mcpServers || {}
767
+ const added = []
768
+ for (const [name, def] of Object.entries(mcpMap)) {
769
+ if (cfg.mcpServers[name]) continue
770
+ const command = Array.isArray(def.command) ? def.command : [def.command]
771
+ const server = { command: command[0], args: command.slice(1) }
772
+ if (def.env) server.env = def.env
773
+ cfg.mcpServers[name] = server
774
+ added.push(name)
775
+ }
776
+ await mkdir(dirname(file), { recursive: true })
777
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
778
+ return { file, added }
779
+ }
780
+
781
+ // D6: dueno unico del MCP de host. Antigravity mergea `.agents/mcp_config.json`;
782
+ // el resto conserva `mergeMcp` sobre opencode.json (targetDir = .opencode del
783
+ // proyecto o config global).
784
+ async function setupHostMcp(targetDir, mcpMap, { projectDir = null, agent = null } = {}) {
785
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
786
+ if (agent === 'antigravity' && projectDir) return mergeAntigravityMcp(projectDir, mcpMap)
787
+ await mkdir(targetDir, { recursive: true })
788
+ return mergeMcp(targetDir, mcpMap)
789
+ }
790
+
728
791
  async function copyAssets(dest) {
729
792
  await mkdir(dest, { recursive: true })
730
793
  for (const d of ASSETS) {
@@ -732,32 +795,297 @@ async function copyAssets(dest) {
732
795
  }
733
796
  }
734
797
 
735
- const AGENT_SKILLS_DIR = {
736
- opencode: '.opencode/skills',
737
- vscode: '.vscode/skills',
738
- antigravity: '.antigravity/skills',
739
- cursor: '.cursor/skills',
740
- roo: '.roo/skills'
798
+ // D1: destino nativo por host. Cada asset declara su directorio y su convencion
799
+ // de nombre (ext); null = asset no soportado por ese host. Fuente unica de rutas.
800
+ const MD_EXT = '.md'
801
+ const AGENT_TARGETS = {
802
+ opencode: {
803
+ skills: { dir: '.opencode/skills' },
804
+ agents: { dir: '.opencode/agents', ext: MD_EXT },
805
+ commands: { dir: '.opencode/commands', ext: MD_EXT }
806
+ },
807
+ claude: {
808
+ skills: { dir: '.claude/skills' },
809
+ agents: { dir: '.claude/agents', ext: MD_EXT },
810
+ commands: { dir: '.claude/commands', ext: MD_EXT }
811
+ },
812
+ vscode: {
813
+ skills: { dir: '.github/skills' },
814
+ agents: { dir: '.github/agents', ext: '.agent.md' },
815
+ commands: { dir: '.github/prompts', ext: '.prompt.md' }
816
+ },
817
+ // Antigravity: agents adaptados en `.agents/agents` y commands materializados
818
+ // como skills dentro de `.agents/skills` (Antigravity no tiene dir de commands;
819
+ // los Workflows estan deprecados a favor de Agent Skills). `package: 'skill-dir'`
820
+ // marca que el asset se empaqueta como `<dir>/<n>/SKILL.md`, no como archivo plano.
821
+ antigravity: {
822
+ skills: { dir: '.agents/skills' },
823
+ agents: { dir: '.agents/agents', ext: MD_EXT },
824
+ commands: { dir: '.agents/skills', package: 'skill-dir' }
825
+ },
826
+ // Cursor y Roo: solo skills (D3/D7). .agents/skills es punto de lectura
827
+ // compartido (D2), no destino universal.
828
+ cursor: { skills: { dir: '.cursor/skills' }, agents: null, commands: null },
829
+ roo: { skills: { dir: '.roo/skills' }, agents: null, commands: null }
741
830
  }
742
831
 
743
832
  const ANCLETO_SKILLS = ['ancleto-new', 'ancleto-propose', 'ancleto-apply', 'ancleto-verify', 'ancleto-archive', 'ancleto-bulk-archive', 'ancleto-continue', 'ancleto-explore', 'ancleto-ff', 'ancleto-onboard', 'ancleto-workflow']
744
833
 
745
- async function installAgentSkills(projectDir, agent) {
746
- const dir = AGENT_SKILLS_DIR[agent] || AGENT_SKILLS_DIR.opencode
747
- const dest = join(projectDir, dir)
748
- await mkdir(dest, { recursive: true })
749
- for (const name of ANCLETO_SKILLS) {
750
- const src = join(ROOT, 'skills', name)
751
- if (!(await exists(src))) {
752
- console.warn(`ancleto: skill no encontrada en el paquete: ${name}`)
834
+ // Frontmatter: parser propio sin dependencias. Cada entrada conserva su clave y
835
+ // sus lineas crudas (incluye block scalars y mapas anidados), preservando orden.
836
+ function parseFrontmatter(content) {
837
+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n?)([\s\S]*)$/.exec(content)
838
+ if (!m) return { hasFrontmatter: false, content }
839
+ const entries = []
840
+ for (const line of m[2].split(/\r?\n/)) {
841
+ if (/^[A-Za-z0-9_-]+:/.test(line)) {
842
+ entries.push({ key: line.slice(0, line.indexOf(':')), lines: [line] })
843
+ } else if (entries.length) {
844
+ entries[entries.length - 1].lines.push(line)
845
+ } else {
846
+ entries.push({ key: null, lines: [line] })
847
+ }
848
+ }
849
+ return { hasFrontmatter: true, open: m[1], entries, close: m[3], nl: m[4], body: m[5] }
850
+ }
851
+
852
+ function serializeFrontmatter(parsed) {
853
+ if (!parsed.hasFrontmatter) return parsed.content
854
+ const inner = parsed.entries.flatMap((e) => e.lines).join('\n')
855
+ return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
856
+ }
857
+
858
+ // D4/D5: hosts cuyo formato de agents difiere del origen requieren adaptacion.
859
+ // Claude/VS Code eliminan las claves de opencode; Antigravity usa una
860
+ // transformacion propia (D2). El resto es identidad (preservacion verbatim).
861
+ const AGENT_ADAPTER_HOSTS = new Set(['claude', 'vscode', 'antigravity'])
862
+ const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
863
+
864
+ // D3: tabla unica (fijada contra la tabla oficial de frontmatter de Custom
865
+ // Subagents) de ids de Antigravity. Conjunto CERRADO: solo se emiten estos ids.
866
+ // Los ids solo-SDK (`find_file`, `edit_file`, `search_web`, `read_url_content`,
867
+ // ...), los de comunidad (`write_to_file`, `call_mcp_tool`,
868
+ // `multi_replace_file_content`, ...) y los de delegacion (`invoke_subagent`,
869
+ // `start_subagent`, `define_subagent`) NO se emiten: no estan confirmados en el
870
+ // frontmatter y un id inexistente cuelga el subagent (Known Issue). Una clave no
871
+ // listada aqui se omite con aviso a stderr, nunca en silencio (D5).
872
+ const ANTIGRAVITY_TOOL_MAP = {
873
+ read: 'view_file',
874
+ edit: 'replace_file_content',
875
+ grep: 'grep_search',
876
+ bash: 'run_command',
877
+ todowrite: 'manage_task'
878
+ }
879
+
880
+ // Hijos directos del mapa `tools:` con su valor crudo (p. ej. `read: true`).
881
+ function parseToolFlags(entry) {
882
+ const flags = []
883
+ if (!entry) return flags
884
+ let childIndent = null
885
+ for (const line of entry.lines.slice(1)) {
886
+ const m = /^(\s+)([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line)
887
+ if (!m) continue
888
+ if (childIndent === null) childIndent = m[1].length
889
+ if (m[1].length !== childIndent) continue
890
+ flags.push({ key: m[2], value: m[3] })
891
+ }
892
+ return flags
893
+ }
894
+
895
+ function frontmatterValue(entry, fallback = null) {
896
+ if (!entry) return fallback
897
+ const m = /^[A-Za-z0-9_-]+:\s*(.*?)\s*$/.exec(entry.lines[0] || '')
898
+ return m ? m[1] : fallback
899
+ }
900
+
901
+ // D2/D3/D5/D8: adapta el frontmatter de un agent de opencode al de Antigravity.
902
+ // `name` (requerido por el host) se inyecta desde el nombre del archivo. `tools` se
903
+ // emite siempre como lista (el default del host es la lista vacia). Nunca se inventa
904
+ // un id: una clave sin id verificado se omite con aviso (un id inexistente cuelga el
905
+ // subagent).
906
+ function adaptAntigravityFrontmatter(content, name) {
907
+ const parsed = parseFrontmatter(content)
908
+ if (!parsed.hasFrontmatter) return content
909
+ const byKey = (k) => parsed.entries.find((e) => e.key === k)
910
+ // Claves que el adaptador gestiona: las dropeadas (AGENT_ADAPTER_DROP), las que
911
+ // re-emite (name/tools/mainAgent/subagent/model/commandExecutionPolicy) y los campos
912
+ // que preserva del origen (description/skills/mcpServers). Se filtran TODAS del
913
+ // conjunto conservado para que ninguna pueda quedar duplicada, y se re-emiten una
914
+ // sola vez en orden determinista, sin importar lo que declare la fuente.
915
+ const managed = new Set([
916
+ ...AGENT_ADAPTER_DROP,
917
+ 'name',
918
+ 'description',
919
+ 'mainAgent',
920
+ 'subagent',
921
+ 'commandExecutionPolicy',
922
+ 'mcpServers',
923
+ 'skills'
924
+ ])
925
+ // Se conservan las claves extra (no gestionadas); los campos preservados se
926
+ // re-emiten abajo desde su primera ocurrencia en el origen.
927
+ const kept = parsed.entries.filter((e) => e.key && !managed.has(e.key))
928
+
929
+ const toolIds = []
930
+ for (const { key, value } of parseToolFlags(byKey('tools'))) {
931
+ if (value === 'false') continue
932
+ if (key === 'skill') continue // no es tool: se cubre por `skills`/surfaceo automatico (OQ2)
933
+ const id = ANTIGRAVITY_TOOL_MAP[key]
934
+ if (id) {
935
+ toolIds.push(id)
936
+ continue
937
+ }
938
+ // D3/D5: regla dura. Un id fuera del frontmatter confirmado (exista o no en el
939
+ // SDK) no se emite: un id inexistente cuelga el subagent. Se omite con aviso,
940
+ // nunca en silencio. El uso de MCP NO se infiere desde claves desconocidas del
941
+ // mapa `tools`: se expresa por `mcpServers`/`.agents/mcp_config.json` (D6).
942
+ console.error(`skip tool '${key}': no verified Antigravity id for agent '${name}'`)
943
+ }
944
+
945
+ const mode = frontmatterValue(byKey('mode'))
946
+ const mainAgent = mode === 'subagent' ? 'false' : 'true'
947
+ const subagent = mode === 'primary' ? 'false' : 'true'
948
+
949
+ // Orden determinista: name, description, tools, mainAgent, subagent, model,
950
+ // commandExecutionPolicy, mcpServers, skills, y luego las claves extra conservadas.
951
+ const out = [{ key: 'name', lines: [`name: ${name}`] }]
952
+ const description = byKey('description')
953
+ if (description) out.push(description)
954
+ out.push({ key: 'tools', lines: [`tools: [${toolIds.join(', ')}]`] })
955
+ out.push({ key: 'mainAgent', lines: [`mainAgent: ${mainAgent}`] })
956
+ out.push({ key: 'subagent', lines: [`subagent: ${subagent}`] })
957
+ out.push({ key: 'model', lines: [`model: inherit`] })
958
+ out.push({ key: 'commandExecutionPolicy', lines: [`commandExecutionPolicy: sandbox`] })
959
+ const mcpServers = byKey('mcpServers')
960
+ if (mcpServers) out.push(mcpServers)
961
+ const skills = byKey('skills')
962
+ if (skills) out.push(skills)
963
+ out.push(...kept)
964
+ parsed.entries = out
965
+ return serializeFrontmatter(parsed)
966
+ }
967
+
968
+ function adaptFrontmatter(content, host, assetKind, name) {
969
+ if (assetKind !== 'agents' || !AGENT_ADAPTER_HOSTS.has(host)) return content
970
+ if (host === 'antigravity') return adaptAntigravityFrontmatter(content, name)
971
+ const parsed = parseFrontmatter(content)
972
+ if (!parsed.hasFrontmatter) return content
973
+ parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
974
+ return serializeFrontmatter(parsed)
975
+ }
976
+
977
+ // D12: asset no soportado -> no se escribe y se avisa a stderr (no bloqueante).
978
+ function warnUnsupportedAsset(asset, agent) {
979
+ console.error(`skip ${asset}: not supported by host '${agent}'`)
980
+ }
981
+
982
+ async function copyDirTransformed(srcDir, destDir, host, assetKind) {
983
+ await mkdir(destDir, { recursive: true })
984
+ for (const e of await readdir(srcDir, { withFileTypes: true })) {
985
+ const src = join(srcDir, e.name)
986
+ const dest = join(destDir, e.name)
987
+ if (e.isDirectory()) {
988
+ await copyDirTransformed(src, dest, host, assetKind)
989
+ } else if (e.isFile() && e.name.endsWith(MD_EXT)) {
990
+ await writeFile(dest, adaptFrontmatter(await readFile(src, 'utf8'), host, assetKind))
991
+ } else if (e.isFile()) {
992
+ await cp(src, dest)
993
+ }
994
+ }
995
+ }
996
+
997
+ async function installAssetFiles(cat, projectDir, spec, host) {
998
+ const destDir = join(projectDir, spec.dir)
999
+ await mkdir(destDir, { recursive: true })
1000
+ for (const e of await readdir(join(ROOT, cat), { withFileTypes: true })) {
1001
+ if (!e.isFile() || !e.name.endsWith(MD_EXT)) continue
1002
+ const base = e.name.slice(0, -MD_EXT.length)
1003
+ const content = await readFile(join(ROOT, cat, e.name), 'utf8')
1004
+ await writeFile(join(destDir, `${base}${spec.ext}`), adaptFrontmatter(content, host, cat, base))
1005
+ }
1006
+ }
1007
+
1008
+ // D4: un command de ancleto convertido en skill (`name` + `description` del origen,
1009
+ // body verbatim). Antigravity no tiene dir de commands: sus `/cleto-*` viven como
1010
+ // command-skills dentro de `.agents/skills`.
1011
+ function commandToSkill(content, name) {
1012
+ const parsed = parseFrontmatter(content)
1013
+ const desc = parsed.hasFrontmatter ? parsed.entries.find((e) => e.key === 'description') : null
1014
+ const head = ['---', `name: ${name}`, ...(desc ? desc.lines : []), '---'].join('\n')
1015
+ const body = parsed.hasFrontmatter ? parsed.body : content
1016
+ return `${head}\n${body}`
1017
+ }
1018
+
1019
+ // Materializa `commands/*.md` como `.agents/skills/<n>/SKILL.md` (asset empaquetado).
1020
+ async function installCommandSkills(projectDir, spec) {
1021
+ const destBase = join(projectDir, spec.dir)
1022
+ for (const e of await readdir(join(ROOT, 'commands'), { withFileTypes: true })) {
1023
+ if (!e.isFile() || !e.name.endsWith(MD_EXT)) continue
1024
+ const base = e.name.slice(0, -MD_EXT.length)
1025
+ const content = await readFile(join(ROOT, 'commands', e.name), 'utf8')
1026
+ await mkdir(join(destBase, base), { recursive: true })
1027
+ await writeFile(join(destBase, base, 'SKILL.md'), commandToSkill(content, base))
1028
+ }
1029
+ }
1030
+
1031
+ // Dueno unico de la instalacion de assets por proyecto: materializa skills,
1032
+ // agents y commands en el destino nativo del host y devuelve las rutas escritas.
1033
+ async function installAgentAssets(projectDir, agent) {
1034
+ const model = AGENT_TARGETS[agent] || AGENT_TARGETS[DEFAULT_AGENT]
1035
+ const written = { agents: [], commands: [], skills: [] }
1036
+
1037
+ if (model.skills) {
1038
+ await copyDirTransformed(join(ROOT, 'skills'), join(projectDir, model.skills.dir), agent, 'skills')
1039
+ written.skills.push(model.skills.dir)
1040
+ } else {
1041
+ warnUnsupportedAsset('skills', agent)
1042
+ }
1043
+
1044
+ for (const cat of ['agents', 'commands']) {
1045
+ const spec = model[cat]
1046
+ if (!spec) {
1047
+ warnUnsupportedAsset(cat, agent)
1048
+ continue
1049
+ }
1050
+ // D4: un asset empaquetado (commands de Antigravity) vive dentro del layout de
1051
+ // skills; no se registra un directorio de commands propio en installedPaths.
1052
+ if (spec.package === 'skill-dir') {
1053
+ await installCommandSkills(projectDir, spec)
753
1054
  continue
754
1055
  }
755
- await cp(src, join(dest, name), { recursive: true })
1056
+ await installAssetFiles(cat, projectDir, spec, agent)
1057
+ written[cat].push(spec.dir)
1058
+ }
1059
+ return written
1060
+ }
1061
+
1062
+ // D10: installedPaths es la union de destinos realmente escritos; un asset
1063
+ // omitido (null) no deja rastro. No se borran rutas previas (no destructivo).
1064
+ function unionInstalledPaths(existing, next) {
1065
+ const out = {}
1066
+ for (const k of ['templates', 'agents', 'commands', 'skills']) {
1067
+ out[k] = [...new Set([...(existing?.[k] || []), ...(next?.[k] || [])])]
756
1068
  }
757
- if (dir !== AGENT_SKILLS_DIR.opencode) {
758
- await cp(join(ROOT, 'skills'), dest, { recursive: true })
1069
+ return out
1070
+ }
1071
+
1072
+ // Convencion de nombre esperada en un directorio instalado (para `check`).
1073
+ function installedExtFor(cat, dirRel) {
1074
+ for (const t of Object.values(AGENT_TARGETS)) {
1075
+ const spec = t[cat]
1076
+ if (spec && spec.dir === dirRel && spec.ext) return spec.ext
759
1077
  }
760
- return dir.replace(/\\/g, '/')
1078
+ return null
1079
+ }
1080
+
1081
+ // D9: hosts realmente instalados derivados del manifiesto (union de destinos
1082
+ // escritos), no de un unico `rc.agent`. Un proyecto multi-host debe sumar los
1083
+ // esperados de todos los hosts para no reportar huerfanos falsos.
1084
+ function installedHostsFromPaths(ip) {
1085
+ const dirs = new Set([...(ip?.skills || []), ...(ip?.agents || []), ...(ip?.commands || [])])
1086
+ return Object.entries(AGENT_TARGETS)
1087
+ .filter(([, t]) => ['skills', 'agents', 'commands'].some((cat) => t[cat]?.dir && dirs.has(t[cat].dir)))
1088
+ .map(([host]) => host)
761
1089
  }
762
1090
 
763
1091
  const CHANGES_ROOT = 'aspec'
@@ -864,9 +1192,9 @@ function findInsertAnchor(local, source, name) {
864
1192
  return local.length
865
1193
  }
866
1194
 
1195
+ // Solo mergea los templates raiz (AGENTS.md/PRODUCT.md). Los assets por host
1196
+ // los materializa installAgentAssets (dueno unico).
867
1197
  async function copyTemplates(projectDir) {
868
- const dest = join(projectDir, '.opencode')
869
- await copyAssets(dest)
870
1198
  for (const t of TEMPLATES) {
871
1199
  const target = join(projectDir, t)
872
1200
  const source = await readFile(join(ROOT, 'templates', t), 'utf8')
@@ -965,18 +1293,16 @@ async function install(args) {
965
1293
  await migrateLegacyOpenspec(projectDir)
966
1294
  await scaffoldAspec(projectDir)
967
1295
  await refreshWorkingContext(projectDir)
968
- const agentSkillsDir = await installAgentSkills(projectDir, agent)
1296
+ const written = await installAgentAssets(projectDir, agent)
969
1297
  await writeManifest(projectDir, {
970
1298
  agent,
971
1299
  language,
972
1300
  ...(excludeChoice !== null ? { discovery: { ...existingDiscovery, exclude: excludeChoice } } : {}),
973
1301
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
974
- installedPaths: {
1302
+ installedPaths: unionInstalledPaths(existingRc?.installedPaths, {
975
1303
  templates: ['AGENTS.md', 'PRODUCT.md'],
976
- agents: ['.opencode/agents'],
977
- commands: ['.opencode/commands'],
978
- skills: [agentSkillsDir]
979
- }
1304
+ ...written
1305
+ })
980
1306
  })
981
1307
  } else {
982
1308
  await copyAssets(target)
@@ -984,6 +1310,9 @@ async function install(args) {
984
1310
 
985
1311
  const models = tierModels(tier, TIERS, gratisModelChoice)
986
1312
  await applyTier(join(target, 'agents'), tier, models)
1313
+ // El estado de tier vive en el base de opencode; puede no existir si el host
1314
+ // resuelto ubica sus assets en otro directorio (p. ej. cursor -> .cursor/).
1315
+ await mkdir(target, { recursive: true })
987
1316
  await writeFile(tierStatePath(target), tier + '\n')
988
1317
 
989
1318
  if (projectDir) await registerProject(projectDir, { agent, tier })
@@ -997,9 +1326,9 @@ async function install(args) {
997
1326
  }
998
1327
  }
999
1328
 
1000
- const res = await mergeMcp(target, mcpMap)
1329
+ const res = await setupHostMcp(target, mcpMap, { projectDir, agent })
1001
1330
  const loc = projectDir
1002
- ? `${projectDir} (.opencode/ + templates en la raiz)`
1331
+ ? `${projectDir} (layout ${agent} + templates en la raiz)`
1003
1332
  : `${target} (disponible en todos tus proyectos)`
1004
1333
  console.log(`ancleto: instalado en ${loc}`)
1005
1334
  console.log(`ancleto: tier de costo de agents: ${tier}`)
@@ -1020,20 +1349,19 @@ async function upgradeCmd(args) {
1020
1349
  await migrateLegacyOpenspec(projectDir)
1021
1350
  const agent = await resolveAgent(args, rc.agent)
1022
1351
  await copyTemplates(projectDir)
1023
- const agentSkillsDir = await installAgentSkills(projectDir, agent)
1352
+ const written = await installAgentAssets(projectDir, agent)
1024
1353
  const wentContext = await refreshWorkingContext(projectDir)
1025
1354
  const manifest = await writeManifest(projectDir, {
1026
1355
  agent,
1027
- installedPaths: {
1356
+ installedPaths: unionInstalledPaths(rc.installedPaths, {
1028
1357
  templates: ['AGENTS.md', 'PRODUCT.md'],
1029
- agents: ['.opencode/agents'],
1030
- commands: ['.opencode/commands'],
1031
- skills: [agentSkillsDir]
1032
- }
1358
+ ...written
1359
+ })
1033
1360
  })
1361
+ const skillsDir = written.skills[0] || '.opencode/skills'
1034
1362
  console.log(`ancleto: upgrade completo (v${manifest.version}, agente: ${agent})`)
1035
1363
  console.log(' ✔ templates re-aplicados (bloques LOCKED actualizados, EXTENSIBLE intacto)')
1036
- console.log(` ✔ skills actualizadas en ${agentSkillsDir} (${ANCLETO_SKILLS.length} skills)`)
1364
+ console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1037
1365
  console.log(wentContext ? ' ✔ working-context.md regenerado' : ' ✔ working-context.md sin reglas activas (vacio)')
1038
1366
  console.log(' ✔ manifiesto .ancletorc actualizado')
1039
1367
  }
@@ -1041,6 +1369,7 @@ async function upgradeCmd(args) {
1041
1369
  async function initProject(args) {
1042
1370
  const projectDir = process.cwd()
1043
1371
  const withAzure = args.includes('--with-azure')
1372
+ const withMcp = !args.includes('--no-mcp')
1044
1373
  const agentFlag = scanAgentFlag(args)
1045
1374
  const tierFlag = scanTierFlag(args)
1046
1375
  const langFlag = scanLangFlag(args)
@@ -1100,12 +1429,17 @@ async function initProject(args) {
1100
1429
  const persisted = isKnownGratisModel(existing?.gratisModel) ? existing.gratisModel : null
1101
1430
  gratisModelChoice = persisted || gratisModel()
1102
1431
  }
1432
+ const written = await installAgentAssets(projectDir, agent)
1103
1433
  const manifest = await writeManifest(projectDir, {
1104
1434
  azure,
1105
1435
  discovery,
1106
1436
  agent,
1107
1437
  language,
1108
- ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {})
1438
+ ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1439
+ installedPaths: unionInstalledPaths(existing?.installedPaths, {
1440
+ templates: ['AGENTS.md', 'PRODUCT.md'],
1441
+ ...written
1442
+ })
1109
1443
  })
1110
1444
  await copyTemplates(projectDir)
1111
1445
  if (tier) {
@@ -1115,7 +1449,12 @@ async function initProject(args) {
1115
1449
  await scaffoldAspec(projectDir)
1116
1450
  await refreshWorkingContext(projectDir)
1117
1451
  await registerProject(projectDir, { agent, tier: tier || null })
1452
+ // D7: init configura el MCP del host igual que install (mismo dueno, sin rama
1453
+ // especial por host); --no-mcp conserva el escape.
1454
+ const mcpMap = withMcp ? buildDefaultMcp({}) : {}
1455
+ const mcpRes = await setupHostMcp(join(projectDir, '.opencode'), mcpMap, { projectDir, agent })
1118
1456
  if (azure.enabled) console.log(AZURE_MCP_NOTICE)
1457
+ if (mcpRes.added.length) console.log(`ancleto: MCP configurados: ${mcpRes.added.join(', ')} en ${mcpRes.file}`)
1119
1458
  console.log(`ancleto: .ancletorc actualizado en ${projectDir} (v${manifest.version})${azure.enabled ? ' (Azure habilitado)' : ' (Azure desactivado)'} (Agente: ${agent})${tier ? ` (Tier: ${tier})` : ''} (Idioma: ${language})`)
1120
1459
  }
1121
1460
 
@@ -1571,6 +1910,8 @@ async function checkCommand() {
1571
1910
  process.exit(1)
1572
1911
  }
1573
1912
  const ip = rc.installedPaths
1913
+ // D9: la union de hosts instalados se lee del manifiesto, no de un unico `rc.agent`.
1914
+ const installedHosts = installedHostsFromPaths(ip)
1574
1915
  let missing = 0
1575
1916
  let orphans = 0
1576
1917
 
@@ -1591,7 +1932,21 @@ async function checkCommand() {
1591
1932
  missing++
1592
1933
  continue
1593
1934
  }
1594
- const expected = (await readdir(join(ROOT, cat))).sort()
1935
+ // La convencion de nombre del host (p. ej. vscode: .agent.md/.prompt.md)
1936
+ // se deriva del mismo modelo de destino; en skills no cambia el nombre.
1937
+ const ext = installedExtFor(cat, dirRel)
1938
+ let expected = (await readdir(join(ROOT, cat))).sort().map((f) => (ext ? f.replace(/\.md$/, ext) : f))
1939
+ // D9: los command-skills de Antigravity viven dentro de `.agents/skills`; el
1940
+ // conjunto esperado de ese dir suma el catalogo de skills y los commands de
1941
+ // cualquier host instalado que empaquete commands ahi (union, no rc.agent).
1942
+ const packagesCommandsHere = cat === 'skills' && installedHosts.some((h) => {
1943
+ const cs = AGENT_TARGETS[h].commands
1944
+ return cs?.package === 'skill-dir' && cs.dir === dirRel
1945
+ })
1946
+ if (packagesCommandsHere) {
1947
+ const cmds = (await readdir(join(ROOT, 'commands'))).filter((f) => f.endsWith(MD_EXT)).map((f) => f.slice(0, -MD_EXT.length))
1948
+ expected = [...new Set([...expected, ...cmds])].sort()
1949
+ }
1595
1950
  const actual = (await readdir(destDir)).sort()
1596
1951
  const missingFiles = expected.filter((f) => !actual.includes(f))
1597
1952
  const orphanFiles = actual.filter((f) => !expected.includes(f))
@@ -1624,10 +1979,13 @@ async function checkTierOrphan(cwd) {
1624
1979
  if (!(await exists(tierFile))) return warnings
1625
1980
  const tier = (await readFile(tierFile, 'utf8')).trim()
1626
1981
  if (!TIERS[tier]) return warnings
1627
- const localAgents = join(cwd, '.opencode', 'agents')
1982
+ // D9: se cuentan los agents locales del host realmente instalados, no
1983
+ // `.opencode/agents` fijo (un proyecto antigravity usa `.agents/agents`).
1984
+ const rc = await readAncletorc(cwd)
1628
1985
  let count = 0
1629
- if (await exists(localAgents)) {
1630
- count = (await readdir(localAgents)).filter((f) => f.endsWith('.md')).length
1986
+ for (const d of localAgentDirs(rc)) {
1987
+ const p = join(cwd, d)
1988
+ if (await exists(p)) count += (await readdir(p)).filter((f) => f.endsWith(MD_EXT)).length
1631
1989
  }
1632
1990
  if (count === 0) {
1633
1991
  warnings.push(`tier "${tier}" sin agentes locales donde aplicarlo — este proyecto usa los agentes globales. Corre 'ancleto install --project .' para que el tier aplique, o borra .opencode/.ancleto-tier.`)