@ancleto/spec 0.7.2 → 0.9.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.
package/src/cli/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs'
3
- import { cp, mkdir, access, writeFile, readFile, readdir } from 'node:fs/promises'
3
+ import { cp, mkdir, access, writeFile, readFile, readdir, mkdtemp, rm } from 'node:fs/promises'
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { createInterface } from 'node:readline'
7
7
  import { DatabaseSync } from 'node:sqlite'
8
- import { join, dirname, resolve, basename, relative } from 'node:path'
8
+ import { join, dirname, resolve, basename, relative, isAbsolute } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { homedir, tmpdir } from 'node:os'
11
11
  import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
@@ -35,17 +35,20 @@ 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 | claude | vscode | antigravity | cursor | roo (wizard si no esta guardado)
38
+ ancleto install --agent <nombre> opencode | claude | vscode | antigravity | cursor | roo | copilot (wizard si no esta guardado)
39
39
  Elige el layout nativo a materializar (directorio + convencion de
40
40
  nombre) y el adapter de frontmatter del host. NO arbitra el
41
41
  descubrimiento entre hosts: si dos leen el mismo directorio, eso es
42
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.
45
+ ancleto install --profile <perfil> general | test (perfil de test automation con Playwright; persiste en .ancletorc)
43
46
  ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
44
47
  Parado en un proyecto con .ancletorc opera sobre ese proyecto;
45
48
  usa --global para forzar el alcance global
46
49
  ancleto upgrade [--agent <nombre>] Re-aplica templates (LOCKED) y skills sobre el proyecto actual
47
- ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>]
48
- Crea .ancletorc en el repositorio actual
50
+ ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>] [--no-mcp] [--profile <perfil>]
51
+ Crea .ancletorc en el repositorio actual y configura el MCP del host
49
52
  (interactivo en TTY: banner + menu; Azure desactivado por defecto)
50
53
  ancleto discovery --check Estado del seed (READY/STALE/PARTIAL/MISSING)
51
54
  ancleto discovery [--compress] [--include G] [--ignore G] [--token-budget N]
@@ -76,6 +79,9 @@ Uso:
76
79
  ancleto mcp Servidor MCP de memoria propia (stdio) para tu IDE
77
80
  expone searchMemory, recordRule y recordDecision
78
81
  ancleto check Verifica integridad de archivos instalados vs manifiesto
82
+ ancleto export [--tar <file>] Empaqueta lo portable del proyecto (bundle + manifest.json sin rutas)
83
+ ancleto import <bundle> [--repair] Restaura el bundle regenerando el MCP local; --repair solo arregla entradas rotas
84
+ ancleto import --repair Repara in place el opencode.json con command inexistente
79
85
  ancleto doctor Diagnostica el entorno (Node, node:sqlite, opencode.json)
80
86
  ancleto --help Esta ayuda
81
87
  ancleto --version Version del paquete
@@ -151,12 +157,28 @@ async function writeProjectsRegistry(reg) {
151
157
  return file
152
158
  }
153
159
 
160
+ // D9: directorios locales de agents derivados del host/manifiesto, no de
161
+ // `.opencode/agents` fijo. Un proyecto antigravity usa `.agents/agents`.
162
+ function localAgentDirs(rc, agent) {
163
+ const dirs = new Set(rc?.installedPaths?.agents || [])
164
+ const spec = AGENT_TARGETS[agent || rc?.agent]?.agents
165
+ if (spec?.dir) dirs.add(spec.dir)
166
+ return [...dirs]
167
+ }
168
+
169
+ async function hasLocalAgents(baseDir, rc, agent) {
170
+ for (const d of localAgentDirs(rc, agent)) {
171
+ if (await exists(join(baseDir, d))) return true
172
+ }
173
+ return false
174
+ }
175
+
154
176
  async function registerProject(projectDir, { agent, tier } = {}) {
155
177
  const abs = resolve(projectDir)
156
178
  const rc = await readAncletorc(abs)
157
179
  const tierFile = join(abs, '.opencode', '.ancleto-tier')
158
180
  const localTier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
159
- const hasScopedAgents = await exists(join(abs, '.opencode', 'agents'))
181
+ const hasScopedAgents = await hasLocalAgents(abs, rc, agent)
160
182
  const entry = {
161
183
  path: abs.replace(/\\/g, '/'),
162
184
  version: await packageVersion(),
@@ -231,7 +253,7 @@ async function projectStatus(entry) {
231
253
  if (!rc) return { ...entry, exists: false, reason: 'sin .ancletorc' }
232
254
  const tierFile = join(abs, '.opencode', '.ancleto-tier')
233
255
  const tier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
234
- const scoped = await exists(join(abs, '.opencode', 'agents'))
256
+ const scoped = await hasLocalAgents(abs, rc, entry.agent)
235
257
  return {
236
258
  ...entry,
237
259
  exists: true,
@@ -595,7 +617,7 @@ async function applyTier(agentsDir, tier, models = null) {
595
617
  }
596
618
  }
597
619
 
598
- const SUPPORTED_AGENTS = ['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo']
620
+ const SUPPORTED_AGENTS = ['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo', 'copilot']
599
621
  const DEFAULT_AGENT = 'opencode'
600
622
 
601
623
  function askAgent() {
@@ -645,6 +667,43 @@ function langLabel(code) {
645
667
  return { auto: 'automatico (idioma de la conversacion)', es: 'espanol', en: 'ingles', pt: 'portugues' }[code] || code
646
668
  }
647
669
 
670
+ const SUPPORTED_PROFILES = ['general', 'test']
671
+ const DEFAULT_PROFILE = 'general'
672
+
673
+ function scanProfileFlag(args) {
674
+ const p = flagValue(args, '--profile')
675
+ if (!p) return null
676
+ const norm = p.toLowerCase() === 'test:playwright' ? 'test' : p.toLowerCase()
677
+ if (!SUPPORTED_PROFILES.includes(norm)) {
678
+ console.error(`ancleto: perfil invalido: ${p} (general|test)`)
679
+ process.exit(1)
680
+ }
681
+ return norm
682
+ }
683
+
684
+ function validProfile(value) {
685
+ return SUPPORTED_PROFILES.includes(value) ? value : null
686
+ }
687
+
688
+ // Raiz de activos segun perfil: el general usa el repo; el perfil test usa
689
+ // profiles/test/ como overlay (agents/commands/templates). skills/ es comun.
690
+ function profileAssetsRoot(profile) {
691
+ return profile === 'test' ? join(ROOT, 'profiles', 'test') : ROOT
692
+ }
693
+
694
+ // Overlay del perfil test sobre los destinos nativos del host (misma ext y
695
+ // mismo adapter que la instalacion base). El paquete base queda intacto.
696
+ async function installProfileOverlay(projectDir, agent) {
697
+ const model = AGENT_TARGETS[agent] || AGENT_TARGETS[DEFAULT_AGENT]
698
+ for (const cat of ['agents', 'commands']) {
699
+ const spec = model[cat]
700
+ if (!spec) continue
701
+ const src = join(ROOT, 'profiles', 'test', cat)
702
+ if (!(await exists(src))) continue
703
+ await installAssetFiles(cat, projectDir, spec, agent, src)
704
+ }
705
+ }
706
+
648
707
  const EXCLUDE_PRESETS = [
649
708
  { key: 'tests', label: 'tests (*.test.*, *.spec.*, __tests__)', recommended: true, globs: ['**/*.test.*', '**/*.spec.*', '**/__tests__/**'] },
650
709
  { key: 'assets', label: 'assets pesados (*.png, *.jpg, *.svg, *.ico, public/)', recommended: true, globs: ['**/*.png', '**/*.jpg', '**/*.svg', '**/*.ico', 'public'] },
@@ -729,6 +788,115 @@ async function mergeMcp(configDir, mcpMap) {
729
788
  return { file: targets.join(', '), added: [...new Set(added)] }
730
789
  }
731
790
 
791
+ // D6: MCP de workspace de Antigravity (`.agents/mcp_config.json`). Merge NO
792
+ // destructivo: se preservan `mcpServers` y demas claves top-level, no se pisa un
793
+ // servidor homonimo, y un JSON invalido se avisa sin sobrescribir. Esquema del host
794
+ // `{ command, args, env }` (se descartan `type`/`enabled` de opencode).
795
+ async function mergeAntigravityMcp(projectDir, mcpMap) {
796
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
797
+ const file = join(projectDir, '.agents', 'mcp_config.json')
798
+ let cfg = {}
799
+ if (await exists(file)) {
800
+ try {
801
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
802
+ } catch {
803
+ console.warn(`ancleto: no se pudo leer ${basename(file)} como JSON; MCP de Antigravity no se modifico`)
804
+ return { file, added: [] }
805
+ }
806
+ }
807
+ cfg.mcpServers = cfg.mcpServers || {}
808
+ const added = []
809
+ for (const [name, def] of Object.entries(mcpMap)) {
810
+ if (cfg.mcpServers[name]) continue
811
+ const command = Array.isArray(def.command) ? def.command : [def.command]
812
+ const server = { command: command[0], args: command.slice(1) }
813
+ if (def.env) server.env = def.env
814
+ cfg.mcpServers[name] = server
815
+ added.push(name)
816
+ }
817
+ await mkdir(dirname(file), { recursive: true })
818
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
819
+ return { file, added }
820
+ }
821
+
822
+ // Una entrada MCP esta rota cuando su binario es una ruta inexistente en este
823
+ // host: absoluta posix ausente, estilo Windows (caso T480) o command ausente.
824
+ // Comandos relativos (npx, PATH) se presumen sanos.
825
+ function mcpCommandBroken(entry) {
826
+ const cmd = entry?.command
827
+ if (!Array.isArray(cmd) || cmd.length === 0) return true
828
+ const bin = cmd[0]
829
+ if (typeof bin !== 'string' || bin.length === 0) return true
830
+ if (/^[A-Za-z]:[\\/]/.test(bin)) return true
831
+ if (isAbsolute(bin)) return !existsSync(bin)
832
+ return false
833
+ }
834
+
835
+ // Merge no destructivo hacia copilot-mcp.json del proyecto (nunca toca
836
+ // copilot-instructions.md: ese archivo es del usuario).
837
+ async function mergeCopilotMcp(projectDir, mcpMap) {
838
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
839
+ const file = join(projectDir, 'copilot-mcp.json')
840
+ let cfg = {}
841
+ if (await exists(file)) {
842
+ try {
843
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
844
+ } catch {
845
+ console.warn('ancleto: no se pudo leer copilot-mcp.json como JSON; MCP no se agrego ahi')
846
+ return { file, added: [] }
847
+ }
848
+ }
849
+ cfg.mcp = cfg.mcp || {}
850
+ const added = []
851
+ for (const [name, def] of Object.entries(mcpMap)) {
852
+ if (cfg.mcp[name]) continue
853
+ cfg.mcp[name] = def
854
+ added.push(name)
855
+ }
856
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
857
+ return { file, added }
858
+ }
859
+
860
+ // D6: dueno unico del MCP de host. Antigravity mergea `.agents/mcp_config.json`;
861
+ // el resto conserva `mergeMcp` sobre opencode.json (targetDir = .opencode del
862
+ // proyecto o config global).
863
+ async function setupHostMcp(targetDir, mcpMap, { projectDir = null, agent = null } = {}) {
864
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
865
+ if (agent === 'antigravity' && projectDir) return mergeAntigravityMcp(projectDir, mcpMap)
866
+ await mkdir(targetDir, { recursive: true })
867
+ return mergeMcp(targetDir, mcpMap)
868
+ }
869
+
870
+ // Regenera entradas rotas y agrega ausentes en copilot-mcp.json (para upgrade).
871
+ async function refreshCopilotMcp(projectDir) {
872
+ const file = join(projectDir, 'copilot-mcp.json')
873
+ let cfg = {}
874
+ if (await exists(file)) {
875
+ try {
876
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
877
+ } catch {
878
+ cfg = {}
879
+ }
880
+ }
881
+ cfg.mcp = cfg.mcp || {}
882
+ const fresh = buildDefaultMcp({ withEngram: Boolean(cfg.mcp.engram) })
883
+ const fixed = []
884
+ for (const [name, def] of Object.entries(cfg.mcp)) {
885
+ if (mcpCommandBroken(def) && fresh[name]) {
886
+ cfg.mcp[name] = fresh[name]
887
+ fixed.push(name)
888
+ }
889
+ }
890
+ for (const [name, def] of Object.entries(fresh)) {
891
+ if (!cfg.mcp[name]) {
892
+ cfg.mcp[name] = def
893
+ fixed.push(`${name} (agregado)`)
894
+ }
895
+ }
896
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
897
+ return { file, fixed }
898
+ }
899
+
732
900
  async function copyAssets(dest) {
733
901
  await mkdir(dest, { recursive: true })
734
902
  for (const d of ASSETS) {
@@ -755,11 +923,26 @@ const AGENT_TARGETS = {
755
923
  agents: { dir: '.github/agents', ext: '.agent.md' },
756
924
  commands: { dir: '.github/prompts', ext: '.prompt.md' }
757
925
  },
758
- // Antigravity, Cursor y Roo: solo skills (D3/D7). .agents/skills es punto de
759
- // lectura compartido (D2), no destino universal.
760
- antigravity: { skills: { dir: '.agents/skills' }, agents: null, commands: null },
926
+ // Antigravity: agents adaptados en `.agents/agents` y commands materializados
927
+ // como skills dentro de `.agents/skills` (Antigravity no tiene dir de commands;
928
+ // los Workflows estan deprecados a favor de Agent Skills). `package: 'skill-dir'`
929
+ // marca que el asset se empaqueta como `<dir>/<n>/SKILL.md`, no como archivo plano.
930
+ antigravity: {
931
+ skills: { dir: '.agents/skills' },
932
+ agents: { dir: '.agents/agents', ext: MD_EXT },
933
+ commands: { dir: '.agents/skills', package: 'skill-dir' }
934
+ },
935
+ // Cursor y Roo: solo skills (D3/D7). .agents/skills es punto de lectura
936
+ // compartido (D2), no destino universal.
761
937
  cursor: { skills: { dir: '.cursor/skills' }, agents: null, commands: null },
762
- roo: { skills: { dir: '.roo/skills' }, agents: null, commands: null }
938
+ roo: { skills: { dir: '.roo/skills' }, agents: null, commands: null },
939
+ // Copilot: host de prompts. Agents y commands comparten .github/prompts con
940
+ // ext .prompt.md (sin colision: agents/*.md vs cleto-*.md). Sin skills (D12).
941
+ copilot: {
942
+ skills: null,
943
+ agents: { dir: '.github/prompts', ext: '.prompt.md' },
944
+ commands: { dir: '.github/prompts', ext: '.prompt.md' }
945
+ }
763
946
  }
764
947
 
765
948
  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']
@@ -788,14 +971,130 @@ function serializeFrontmatter(parsed) {
788
971
  return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
789
972
  }
790
973
 
791
- // D4/D5: hosts cuyo formato de agents difiere del origen requieren adaptacion
792
- // (hoy Claude y VS Code). El resto es identidad (preservacion verbatim). Nunca se
793
- // inventan equivalencias de model/tools.
794
- const AGENT_ADAPTER_HOSTS = new Set(['claude', 'vscode'])
974
+ // D4/D5: hosts cuyo formato de agents difiere del origen requieren adaptacion.
975
+ // Claude/VS Code/Copilot eliminan las claves de opencode; Antigravity usa una
976
+ // transformacion propia (D2). El resto es identidad (preservacion verbatim).
977
+ const AGENT_ADAPTER_HOSTS = new Set(['claude', 'vscode', 'antigravity', 'copilot'])
795
978
  const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
796
979
 
797
- function adaptFrontmatter(content, host, assetKind) {
980
+ // Solo Copilot: los tiers no cambian modelos (el usuario elige en el picker);
981
+ // el orchestrator lo recuerda al empezar.
982
+ const COPILOT_MODEL_NOTE = `
983
+ ## Nota de modelo (solo Copilot)
984
+
985
+ Elegí el modelo en el picker de Copilot antes de empezar: los tiers de ancleto
986
+ (normal|minimo|gratis) no cambian modelos acá, solo el nivel de esfuerzo:
987
+ normal = flujo completo, minimo = pasos agrupados y económicos, gratis = modelo
988
+ gratis disponible en tu cuenta.
989
+ `
990
+
991
+ // D3: tabla unica (fijada contra la tabla oficial de frontmatter de Custom
992
+ // Subagents) de ids de Antigravity. Conjunto CERRADO: solo se emiten estos ids.
993
+ // Los ids solo-SDK (`find_file`, `edit_file`, `search_web`, `read_url_content`,
994
+ // ...), los de comunidad (`write_to_file`, `call_mcp_tool`,
995
+ // `multi_replace_file_content`, ...) y los de delegacion (`invoke_subagent`,
996
+ // `start_subagent`, `define_subagent`) NO se emiten: no estan confirmados en el
997
+ // frontmatter y un id inexistente cuelga el subagent (Known Issue). Una clave no
998
+ // listada aqui se omite con aviso a stderr, nunca en silencio (D5).
999
+ const ANTIGRAVITY_TOOL_MAP = {
1000
+ read: 'view_file',
1001
+ edit: 'replace_file_content',
1002
+ grep: 'grep_search',
1003
+ bash: 'run_command',
1004
+ todowrite: 'manage_task'
1005
+ }
1006
+
1007
+ // Hijos directos del mapa `tools:` con su valor crudo (p. ej. `read: true`).
1008
+ function parseToolFlags(entry) {
1009
+ const flags = []
1010
+ if (!entry) return flags
1011
+ let childIndent = null
1012
+ for (const line of entry.lines.slice(1)) {
1013
+ const m = /^(\s+)([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line)
1014
+ if (!m) continue
1015
+ if (childIndent === null) childIndent = m[1].length
1016
+ if (m[1].length !== childIndent) continue
1017
+ flags.push({ key: m[2], value: m[3] })
1018
+ }
1019
+ return flags
1020
+ }
1021
+
1022
+ function frontmatterValue(entry, fallback = null) {
1023
+ if (!entry) return fallback
1024
+ const m = /^[A-Za-z0-9_-]+:\s*(.*?)\s*$/.exec(entry.lines[0] || '')
1025
+ return m ? m[1] : fallback
1026
+ }
1027
+
1028
+ // D2/D3/D5/D8: adapta el frontmatter de un agent de opencode al de Antigravity.
1029
+ // `name` (requerido por el host) se inyecta desde el nombre del archivo. `tools` se
1030
+ // emite siempre como lista (el default del host es la lista vacia). Nunca se inventa
1031
+ // un id: una clave sin id verificado se omite con aviso (un id inexistente cuelga el
1032
+ // subagent).
1033
+ function adaptAntigravityFrontmatter(content, name) {
1034
+ const parsed = parseFrontmatter(content)
1035
+ if (!parsed.hasFrontmatter) return content
1036
+ const byKey = (k) => parsed.entries.find((e) => e.key === k)
1037
+ // Claves que el adaptador gestiona: las dropeadas (AGENT_ADAPTER_DROP), las que
1038
+ // re-emite (name/tools/mainAgent/subagent/model/commandExecutionPolicy) y los campos
1039
+ // que preserva del origen (description/skills/mcpServers). Se filtran TODAS del
1040
+ // conjunto conservado para que ninguna pueda quedar duplicada, y se re-emiten una
1041
+ // sola vez en orden determinista, sin importar lo que declare la fuente.
1042
+ const managed = new Set([
1043
+ ...AGENT_ADAPTER_DROP,
1044
+ 'name',
1045
+ 'description',
1046
+ 'mainAgent',
1047
+ 'subagent',
1048
+ 'commandExecutionPolicy',
1049
+ 'mcpServers',
1050
+ 'skills'
1051
+ ])
1052
+ // Se conservan las claves extra (no gestionadas); los campos preservados se
1053
+ // re-emiten abajo desde su primera ocurrencia en el origen.
1054
+ const kept = parsed.entries.filter((e) => e.key && !managed.has(e.key))
1055
+
1056
+ const toolIds = []
1057
+ for (const { key, value } of parseToolFlags(byKey('tools'))) {
1058
+ if (value === 'false') continue
1059
+ if (key === 'skill') continue // no es tool: se cubre por `skills`/surfaceo automatico (OQ2)
1060
+ const id = ANTIGRAVITY_TOOL_MAP[key]
1061
+ if (id) {
1062
+ toolIds.push(id)
1063
+ continue
1064
+ }
1065
+ // D3/D5: regla dura. Un id fuera del frontmatter confirmado (exista o no en el
1066
+ // SDK) no se emite: un id inexistente cuelga el subagent. Se omite con aviso,
1067
+ // nunca en silencio. El uso de MCP NO se infiere desde claves desconocidas del
1068
+ // mapa `tools`: se expresa por `mcpServers`/`.agents/mcp_config.json` (D6).
1069
+ console.error(`skip tool '${key}': no verified Antigravity id for agent '${name}'`)
1070
+ }
1071
+
1072
+ const mode = frontmatterValue(byKey('mode'))
1073
+ const mainAgent = mode === 'subagent' ? 'false' : 'true'
1074
+ const subagent = mode === 'primary' ? 'false' : 'true'
1075
+
1076
+ // Orden determinista: name, description, tools, mainAgent, subagent, model,
1077
+ // commandExecutionPolicy, mcpServers, skills, y luego las claves extra conservadas.
1078
+ const out = [{ key: 'name', lines: [`name: ${name}`] }]
1079
+ const description = byKey('description')
1080
+ if (description) out.push(description)
1081
+ out.push({ key: 'tools', lines: [`tools: [${toolIds.join(', ')}]`] })
1082
+ out.push({ key: 'mainAgent', lines: [`mainAgent: ${mainAgent}`] })
1083
+ out.push({ key: 'subagent', lines: [`subagent: ${subagent}`] })
1084
+ out.push({ key: 'model', lines: [`model: inherit`] })
1085
+ out.push({ key: 'commandExecutionPolicy', lines: [`commandExecutionPolicy: sandbox`] })
1086
+ const mcpServers = byKey('mcpServers')
1087
+ if (mcpServers) out.push(mcpServers)
1088
+ const skills = byKey('skills')
1089
+ if (skills) out.push(skills)
1090
+ out.push(...kept)
1091
+ parsed.entries = out
1092
+ return serializeFrontmatter(parsed)
1093
+ }
1094
+
1095
+ function adaptFrontmatter(content, host, assetKind, name) {
798
1096
  if (assetKind !== 'agents' || !AGENT_ADAPTER_HOSTS.has(host)) return content
1097
+ if (host === 'antigravity') return adaptAntigravityFrontmatter(content, name)
799
1098
  const parsed = parseFrontmatter(content)
800
1099
  if (!parsed.hasFrontmatter) return content
801
1100
  parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
@@ -822,14 +1121,40 @@ async function copyDirTransformed(srcDir, destDir, host, assetKind) {
822
1121
  }
823
1122
  }
824
1123
 
825
- async function installAssetFiles(cat, projectDir, spec, host) {
1124
+ async function installAssetFiles(cat, projectDir, spec, host, srcDir = null) {
826
1125
  const destDir = join(projectDir, spec.dir)
827
1126
  await mkdir(destDir, { recursive: true })
828
- for (const e of await readdir(join(ROOT, cat), { withFileTypes: true })) {
1127
+ const origin = srcDir || join(ROOT, cat)
1128
+ for (const e of await readdir(origin, { withFileTypes: true })) {
1129
+ if (!e.isFile() || !e.name.endsWith(MD_EXT)) continue
1130
+ const base = e.name.slice(0, -MD_EXT.length)
1131
+ const content = await readFile(join(origin, e.name), 'utf8')
1132
+ const adapted = adaptFrontmatter(content, host, cat, base)
1133
+ const body = (host === 'copilot' && cat === 'agents' && base === 'orchestrator') ? adapted + COPILOT_MODEL_NOTE : adapted
1134
+ await writeFile(join(destDir, `${base}${spec.ext}`), body)
1135
+ }
1136
+ }
1137
+
1138
+ // D4: un command de ancleto convertido en skill (`name` + `description` del origen,
1139
+ // body verbatim). Antigravity no tiene dir de commands: sus `/cleto-*` viven como
1140
+ // command-skills dentro de `.agents/skills`.
1141
+ function commandToSkill(content, name) {
1142
+ const parsed = parseFrontmatter(content)
1143
+ const desc = parsed.hasFrontmatter ? parsed.entries.find((e) => e.key === 'description') : null
1144
+ const head = ['---', `name: ${name}`, ...(desc ? desc.lines : []), '---'].join('\n')
1145
+ const body = parsed.hasFrontmatter ? parsed.body : content
1146
+ return `${head}\n${body}`
1147
+ }
1148
+
1149
+ // Materializa `commands/*.md` como `.agents/skills/<n>/SKILL.md` (asset empaquetado).
1150
+ async function installCommandSkills(projectDir, spec) {
1151
+ const destBase = join(projectDir, spec.dir)
1152
+ for (const e of await readdir(join(ROOT, 'commands'), { withFileTypes: true })) {
829
1153
  if (!e.isFile() || !e.name.endsWith(MD_EXT)) continue
830
1154
  const base = e.name.slice(0, -MD_EXT.length)
831
- const content = await readFile(join(ROOT, cat, e.name), 'utf8')
832
- await writeFile(join(destDir, `${base}${spec.ext}`), adaptFrontmatter(content, host, cat))
1155
+ const content = await readFile(join(ROOT, 'commands', e.name), 'utf8')
1156
+ await mkdir(join(destBase, base), { recursive: true })
1157
+ await writeFile(join(destBase, base, 'SKILL.md'), commandToSkill(content, base))
833
1158
  }
834
1159
  }
835
1160
 
@@ -852,6 +1177,12 @@ async function installAgentAssets(projectDir, agent) {
852
1177
  warnUnsupportedAsset(cat, agent)
853
1178
  continue
854
1179
  }
1180
+ // D4: un asset empaquetado (commands de Antigravity) vive dentro del layout de
1181
+ // skills; no se registra un directorio de commands propio en installedPaths.
1182
+ if (spec.package === 'skill-dir') {
1183
+ await installCommandSkills(projectDir, spec)
1184
+ continue
1185
+ }
855
1186
  await installAssetFiles(cat, projectDir, spec, agent)
856
1187
  written[cat].push(spec.dir)
857
1188
  }
@@ -877,6 +1208,16 @@ function installedExtFor(cat, dirRel) {
877
1208
  return null
878
1209
  }
879
1210
 
1211
+ // D9: hosts realmente instalados derivados del manifiesto (union de destinos
1212
+ // escritos), no de un unico `rc.agent`. Un proyecto multi-host debe sumar los
1213
+ // esperados de todos los hosts para no reportar huerfanos falsos.
1214
+ function installedHostsFromPaths(ip) {
1215
+ const dirs = new Set([...(ip?.skills || []), ...(ip?.agents || []), ...(ip?.commands || [])])
1216
+ return Object.entries(AGENT_TARGETS)
1217
+ .filter(([, t]) => ['skills', 'agents', 'commands'].some((cat) => t[cat]?.dir && dirs.has(t[cat].dir)))
1218
+ .map(([host]) => host)
1219
+ }
1220
+
880
1221
  const CHANGES_ROOT = 'aspec'
881
1222
 
882
1223
  const DEFAULT_ASPEC_CONFIG = `# aspec project configuration
@@ -893,6 +1234,11 @@ async function scaffoldAspec(projectDir) {
893
1234
  }
894
1235
  }
895
1236
 
1237
+ async function scaffoldTestspec(projectDir) {
1238
+ await mkdir(join(projectDir, 'testspec', 'specs'), { recursive: true })
1239
+ await mkdir(join(projectDir, 'testspec', 'changes'), { recursive: true })
1240
+ }
1241
+
896
1242
  const LEGACY_OPENSPEC_DIR = 'openspec'
897
1243
  const MIGRATION_MARKER = '.migrated-from-openspec'
898
1244
 
@@ -926,6 +1272,97 @@ async function migrateLegacyOpenspec(projectDir) {
926
1272
  console.log('ancleto: contenido migrado de openspec/ a aspec/ (openspec/ se conserva como backup)')
927
1273
  }
928
1274
 
1275
+ // Clasifica openspec/ pre-existente: 'none' (ausente o carpeta vacia),
1276
+ // 'external' (layout OpenSpec estandar: specs/ con archivos, o AGENTS.md con
1277
+ // firma OpenSpec) o 'legacy' (resto: rama original, intacta).
1278
+ async function detectLegacyOpenSpec(projectDir) {
1279
+ const legacyDir = join(projectDir, LEGACY_OPENSPEC_DIR)
1280
+ let entries = []
1281
+ try {
1282
+ entries = await readdir(legacyDir)
1283
+ } catch {
1284
+ return 'none'
1285
+ }
1286
+ if (entries.length === 0) return 'none' // carpeta vacia: no-op silencioso
1287
+ try {
1288
+ if ((await readdir(join(legacyDir, 'specs'))).length > 0) return 'external'
1289
+ } catch {
1290
+ // sin specs/: sigue el resto de firmas
1291
+ }
1292
+ for (const candidate of [join(projectDir, 'AGENTS.md'), join(legacyDir, 'AGENTS.md')]) {
1293
+ try {
1294
+ if (/openspec/i.test(await readFile(candidate, 'utf8'))) return 'external'
1295
+ } catch {
1296
+ // archivo inexistente o ilegible: no aporta firma
1297
+ }
1298
+ }
1299
+ return 'legacy'
1300
+ }
1301
+
1302
+ async function askOpenspecImport() {
1303
+ if (!process.stdin.isTTY) return false
1304
+ const ask = (q) => new Promise((resolve) => {
1305
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1306
+ rl.question(q, (a) => { rl.close(); resolve(a.trim().toLowerCase()) })
1307
+ })
1308
+ let answer = await ask('ancleto: Detecté un proyecto OpenSpec. ¿Importar a aspec/? [s/N/ver] (default: No): ')
1309
+ if (answer === 'ver') {
1310
+ console.log('ancleto: se copiaran openspec/specs y openspec/changes a aspec/ (copia no destructiva; openspec/ se conserva como backup)')
1311
+ answer = await ask('ancleto: ¿Importar a aspec/? [s/N] (default: No): ')
1312
+ }
1313
+ return ['s', 'si', 'sí', 'y', 'yes'].includes(answer)
1314
+ }
1315
+
1316
+ // Importacion semilla de un OpenSpec externo: copia specs + changes, respeta el
1317
+ // AGENTS.md (nunca se pisa en silencio) y escribe el mismo marcador de
1318
+ // idempotencia. openspec/ siempre se conserva como backup.
1319
+ async function importExternalOpenspec(projectDir) {
1320
+ const aspecDir = join(projectDir, CHANGES_ROOT)
1321
+ const marker = join(aspecDir, MIGRATION_MARKER)
1322
+ if (await exists(marker)) return // ya importada: no-op silencioso
1323
+ if (await hasRealContent(aspecDir)) {
1324
+ console.warn('ancleto: aspec/ ya tiene contenido real — no se importo openspec/ (revisar manualmente; openspec/ se conserva)')
1325
+ return
1326
+ }
1327
+ await cp(join(projectDir, LEGACY_OPENSPEC_DIR), aspecDir, { recursive: true, force: false })
1328
+ const bundledAgents = join(projectDir, LEGACY_OPENSPEC_DIR, 'AGENTS.md')
1329
+ if (await exists(bundledAgents)) {
1330
+ const target = join(projectDir, 'AGENTS.md')
1331
+ if (!(await exists(target))) {
1332
+ await cp(bundledAgents, target)
1333
+ console.log('ancleto: AGENTS.md de OpenSpec adoptado como base (revisarlo)')
1334
+ } else {
1335
+ console.warn('ancleto: se conserva el AGENTS.md local; el de OpenSpec queda en openspec/ como backup')
1336
+ }
1337
+ } else {
1338
+ try {
1339
+ if (/openspec/i.test(await readFile(join(projectDir, 'AGENTS.md'), 'utf8'))) {
1340
+ console.log('ancleto: se respeta el AGENTS.md de OpenSpec local (bloques ancleto fusionados por LOCKED)')
1341
+ }
1342
+ } catch {
1343
+ // sin AGENTS.md local: nada que respetar
1344
+ }
1345
+ }
1346
+ await writeFile(marker, '# Generado por ancleto: esta aspec/ ya fue importada desde openspec/\n')
1347
+ console.log('ancleto: contenido importado de openspec/ a aspec/ (openspec/ se conserva como backup)')
1348
+ }
1349
+
1350
+ // Punto unico de deteccion/migracion: lo invocan init, install --project y
1351
+ // upgrade antes del scaffold. En no-TTY rige default No salvo --yes.
1352
+ async function maybeImportOpenspec(projectDir, args = []) {
1353
+ const kind = await detectLegacyOpenSpec(projectDir)
1354
+ if (kind === 'none') return
1355
+ if (kind === 'legacy') {
1356
+ await migrateLegacyOpenspec(projectDir)
1357
+ return
1358
+ }
1359
+ if (args.includes('--yes') || await askOpenspecImport()) {
1360
+ await importExternalOpenspec(projectDir)
1361
+ } else {
1362
+ console.log('ancleto: importacion de openspec/ omitida (openspec/ se conserva)')
1363
+ }
1364
+ }
1365
+
929
1366
  function extractLockedBlocks(content) {
930
1367
  const blocks = new Map()
931
1368
  const re = /<!--\s*LOCKED:\s*([\w-]+)\s*-->([\s\S]*?)<!--\s*\/LOCKED:\s*\1\s*-->/g
@@ -983,10 +1420,11 @@ function findInsertAnchor(local, source, name) {
983
1420
 
984
1421
  // Solo mergea los templates raiz (AGENTS.md/PRODUCT.md). Los assets por host
985
1422
  // los materializa installAgentAssets (dueno unico).
986
- async function copyTemplates(projectDir) {
1423
+ async function copyTemplates(projectDir, profile = DEFAULT_PROFILE) {
987
1424
  for (const t of TEMPLATES) {
988
1425
  const target = join(projectDir, t)
989
- const source = await readFile(join(ROOT, 'templates', t), 'utf8')
1426
+ const profileTemplate = join(profileAssetsRoot(profile), 'templates', t)
1427
+ const source = await readFile(await exists(profileTemplate) ? profileTemplate : join(ROOT, 'templates', t), 'utf8')
990
1428
  if (!(await exists(target))) {
991
1429
  await writeFile(target, source)
992
1430
  continue
@@ -1031,6 +1469,7 @@ async function install(args) {
1031
1469
 
1032
1470
  let agent = agentFlag || (existingRc?.agent && SUPPORTED_AGENTS.includes(existingRc.agent) ? existingRc.agent : null)
1033
1471
  let language = scanLangFlag(args) || (existingRc?.language && SUPPORTED_LANGS.includes(existingRc.language) ? existingRc.language : null)
1472
+ const profile = scanProfileFlag(args) || validProfile(existingRc?.profile) || DEFAULT_PROFILE
1034
1473
  const excludeFlag = scanExcludeFlag(args)
1035
1474
  const existingDiscovery = existingRc?.discovery || { outputDir: 'docs/technical-discovery', exclude: [] }
1036
1475
  let excludeChoice = excludeFlag
@@ -1078,14 +1517,17 @@ async function install(args) {
1078
1517
  }
1079
1518
 
1080
1519
  if (projectDir) {
1081
- await copyTemplates(projectDir)
1082
- await migrateLegacyOpenspec(projectDir)
1520
+ await copyTemplates(projectDir, profile)
1521
+ await maybeImportOpenspec(projectDir, args)
1083
1522
  await scaffoldAspec(projectDir)
1523
+ if (profile === 'test') await scaffoldTestspec(projectDir)
1084
1524
  await refreshWorkingContext(projectDir)
1085
1525
  const written = await installAgentAssets(projectDir, agent)
1526
+ if (profile === 'test') await installProfileOverlay(projectDir, agent)
1086
1527
  await writeManifest(projectDir, {
1087
1528
  agent,
1088
1529
  language,
1530
+ profile,
1089
1531
  ...(excludeChoice !== null ? { discovery: { ...existingDiscovery, exclude: excludeChoice } } : {}),
1090
1532
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1091
1533
  installedPaths: unionInstalledPaths(existingRc?.installedPaths, {
@@ -1095,6 +1537,9 @@ async function install(args) {
1095
1537
  })
1096
1538
  } else {
1097
1539
  await copyAssets(target)
1540
+ if (agent === 'copilot') {
1541
+ console.log('ancleto: los prompts de Copilot son por repo; corré install --project para generarlos')
1542
+ }
1098
1543
  }
1099
1544
 
1100
1545
  const models = tierModels(tier, TIERS, gratisModelChoice)
@@ -1115,7 +1560,11 @@ async function install(args) {
1115
1560
  }
1116
1561
  }
1117
1562
 
1118
- const res = await mergeMcp(target, mcpMap)
1563
+ const res = await setupHostMcp(target, mcpMap, { projectDir, agent })
1564
+ if (projectDir && agent === 'copilot') {
1565
+ const cres = await mergeCopilotMcp(projectDir, mcpMap)
1566
+ if (cres.added.length) console.log(`ancleto: MCP configurados: ${cres.added.join(', ')} en ${cres.file}`)
1567
+ }
1119
1568
  const loc = projectDir
1120
1569
  ? `${projectDir} (layout ${agent} + templates en la raiz)`
1121
1570
  : `${target} (disponible en todos tus proyectos)`
@@ -1132,16 +1581,26 @@ async function upgradeCmd(args) {
1132
1581
  const projectDir = process.cwd()
1133
1582
  const rc = await readAncletorc(projectDir)
1134
1583
  if (!rc) {
1584
+ if ((await detectLegacyOpenSpec(projectDir)) !== 'none') {
1585
+ await maybeImportOpenspec(projectDir, args)
1586
+ await scaffoldAspec(projectDir)
1587
+ console.log("ancleto: openspec/ migrado. Corré 'ancleto init' para completar la configuración.")
1588
+ return
1589
+ }
1135
1590
  console.error("Error: No se encontro .ancletorc. Ejecuta 'ancleto init' primero.")
1136
1591
  process.exit(1)
1137
1592
  }
1138
- await migrateLegacyOpenspec(projectDir)
1593
+ await maybeImportOpenspec(projectDir, args)
1139
1594
  const agent = await resolveAgent(args, rc.agent)
1140
- await copyTemplates(projectDir)
1595
+ const profile = scanProfileFlag(args) || validProfile(rc.profile) || DEFAULT_PROFILE
1596
+ await copyTemplates(projectDir, profile)
1597
+ if (profile === 'test') await scaffoldTestspec(projectDir)
1141
1598
  const written = await installAgentAssets(projectDir, agent)
1599
+ if (profile === 'test') await installProfileOverlay(projectDir, agent)
1142
1600
  const wentContext = await refreshWorkingContext(projectDir)
1143
1601
  const manifest = await writeManifest(projectDir, {
1144
1602
  agent,
1603
+ profile,
1145
1604
  installedPaths: unionInstalledPaths(rc.installedPaths, {
1146
1605
  templates: ['AGENTS.md', 'PRODUCT.md'],
1147
1606
  ...written
@@ -1150,7 +1609,15 @@ async function upgradeCmd(args) {
1150
1609
  const skillsDir = written.skills[0] || '.opencode/skills'
1151
1610
  console.log(`ancleto: upgrade completo (v${manifest.version}, agente: ${agent})`)
1152
1611
  console.log(' ✔ templates re-aplicados (bloques LOCKED actualizados, EXTENSIBLE intacto)')
1153
- console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1612
+ if (agent === 'copilot') {
1613
+ const { file, fixed } = await refreshCopilotMcp(projectDir)
1614
+ console.log(` ✔ prompts regenerados en .github/prompts (copilot-instructions.md intacto)`)
1615
+ console.log(fixed.length
1616
+ ? ` ✔ MCP regenerados en ${file}: ${fixed.join(', ')}`
1617
+ : ` ✔ MCP sanos en ${file}, sin cambios`)
1618
+ } else {
1619
+ console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1620
+ }
1154
1621
  console.log(wentContext ? ' ✔ working-context.md regenerado' : ' ✔ working-context.md sin reglas activas (vacio)')
1155
1622
  console.log(' ✔ manifiesto .ancletorc actualizado')
1156
1623
  }
@@ -1158,9 +1625,11 @@ async function upgradeCmd(args) {
1158
1625
  async function initProject(args) {
1159
1626
  const projectDir = process.cwd()
1160
1627
  const withAzure = args.includes('--with-azure')
1628
+ const withMcp = !args.includes('--no-mcp')
1161
1629
  const agentFlag = scanAgentFlag(args)
1162
1630
  const tierFlag = scanTierFlag(args)
1163
1631
  const langFlag = scanLangFlag(args)
1632
+ const profileFlag = scanProfileFlag(args)
1164
1633
  const existing = await readAncletorc(projectDir)
1165
1634
  const azure = existing?.azure ?? { enabled: false }
1166
1635
  let discovery = existing?.discovery ?? { outputDir: 'docs/technical-discovery', exclude: [] }
@@ -1170,6 +1639,7 @@ async function initProject(args) {
1170
1639
 
1171
1640
  let agent, tier, language
1172
1641
  language = langFlag || (existing?.language && SUPPORTED_LANGS.includes(existing.language) ? existing.language : null)
1642
+ const profile = profileFlag || validProfile(existing?.profile) || DEFAULT_PROFILE
1173
1643
  const isInteractive = Boolean(process.stdout.isTTY) && (!agentFlag || !tierFlag || !language || (excludeChoice === null && (discovery.exclude || []).length === 0))
1174
1644
  if (isInteractive) {
1175
1645
  await showBanner()
@@ -1218,26 +1688,34 @@ async function initProject(args) {
1218
1688
  gratisModelChoice = persisted || gratisModel()
1219
1689
  }
1220
1690
  const written = await installAgentAssets(projectDir, agent)
1691
+ if (profile === 'test') await installProfileOverlay(projectDir, agent)
1221
1692
  const manifest = await writeManifest(projectDir, {
1222
1693
  azure,
1223
1694
  discovery,
1224
1695
  agent,
1225
1696
  language,
1697
+ profile,
1226
1698
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1227
1699
  installedPaths: unionInstalledPaths(existing?.installedPaths, {
1228
1700
  templates: ['AGENTS.md', 'PRODUCT.md'],
1229
1701
  ...written
1230
1702
  })
1231
1703
  })
1232
- await copyTemplates(projectDir)
1704
+ await maybeImportOpenspec(projectDir, args)
1705
+ await copyTemplates(projectDir, profile)
1706
+ if (profile === 'test') await scaffoldTestspec(projectDir)
1233
1707
  if (tier) {
1234
1708
  await applyTier(join(projectDir, '.opencode', 'agents'), tier, tierModels(tier, TIERS, gratisModelChoice))
1235
1709
  }
1236
- await migrateLegacyOpenspec(projectDir)
1237
1710
  await scaffoldAspec(projectDir)
1238
1711
  await refreshWorkingContext(projectDir)
1239
1712
  await registerProject(projectDir, { agent, tier: tier || null })
1713
+ // D7: init configura el MCP del host igual que install (mismo dueno, sin rama
1714
+ // especial por host); --no-mcp conserva el escape.
1715
+ const mcpMap = withMcp ? buildDefaultMcp({}) : {}
1716
+ const mcpRes = await setupHostMcp(join(projectDir, '.opencode'), mcpMap, { projectDir, agent })
1240
1717
  if (azure.enabled) console.log(AZURE_MCP_NOTICE)
1718
+ if (mcpRes.added.length) console.log(`ancleto: MCP configurados: ${mcpRes.added.join(', ')} en ${mcpRes.file}`)
1241
1719
  console.log(`ancleto: .ancletorc actualizado en ${projectDir} (v${manifest.version})${azure.enabled ? ' (Azure habilitado)' : ' (Azure desactivado)'} (Agente: ${agent})${tier ? ` (Tier: ${tier})` : ''} (Idioma: ${language})`)
1242
1720
  }
1243
1721
 
@@ -1693,6 +2171,8 @@ async function checkCommand() {
1693
2171
  process.exit(1)
1694
2172
  }
1695
2173
  const ip = rc.installedPaths
2174
+ // D9: la union de hosts instalados se lee del manifiesto, no de un unico `rc.agent`.
2175
+ const installedHosts = installedHostsFromPaths(ip)
1696
2176
  let missing = 0
1697
2177
  let orphans = 0
1698
2178
 
@@ -1716,7 +2196,18 @@ async function checkCommand() {
1716
2196
  // La convencion de nombre del host (p. ej. vscode: .agent.md/.prompt.md)
1717
2197
  // se deriva del mismo modelo de destino; en skills no cambia el nombre.
1718
2198
  const ext = installedExtFor(cat, dirRel)
1719
- const expected = (await readdir(join(ROOT, cat))).sort().map((f) => (ext ? f.replace(/\.md$/, ext) : f))
2199
+ let expected = (await readdir(join(ROOT, cat))).sort().map((f) => (ext ? f.replace(/\.md$/, ext) : f))
2200
+ // D9: los command-skills de Antigravity viven dentro de `.agents/skills`; el
2201
+ // conjunto esperado de ese dir suma el catalogo de skills y los commands de
2202
+ // cualquier host instalado que empaquete commands ahi (union, no rc.agent).
2203
+ const packagesCommandsHere = cat === 'skills' && installedHosts.some((h) => {
2204
+ const cs = AGENT_TARGETS[h].commands
2205
+ return cs?.package === 'skill-dir' && cs.dir === dirRel
2206
+ })
2207
+ if (packagesCommandsHere) {
2208
+ const cmds = (await readdir(join(ROOT, 'commands'))).filter((f) => f.endsWith(MD_EXT)).map((f) => f.slice(0, -MD_EXT.length))
2209
+ expected = [...new Set([...expected, ...cmds])].sort()
2210
+ }
1720
2211
  const actual = (await readdir(destDir)).sort()
1721
2212
  const missingFiles = expected.filter((f) => !actual.includes(f))
1722
2213
  const orphanFiles = actual.filter((f) => !expected.includes(f))
@@ -1749,10 +2240,13 @@ async function checkTierOrphan(cwd) {
1749
2240
  if (!(await exists(tierFile))) return warnings
1750
2241
  const tier = (await readFile(tierFile, 'utf8')).trim()
1751
2242
  if (!TIERS[tier]) return warnings
1752
- const localAgents = join(cwd, '.opencode', 'agents')
2243
+ // D9: se cuentan los agents locales del host realmente instalados, no
2244
+ // `.opencode/agents` fijo (un proyecto antigravity usa `.agents/agents`).
2245
+ const rc = await readAncletorc(cwd)
1753
2246
  let count = 0
1754
- if (await exists(localAgents)) {
1755
- count = (await readdir(localAgents)).filter((f) => f.endsWith('.md')).length
2247
+ for (const d of localAgentDirs(rc)) {
2248
+ const p = join(cwd, d)
2249
+ if (await exists(p)) count += (await readdir(p)).filter((f) => f.endsWith(MD_EXT)).length
1756
2250
  }
1757
2251
  if (count === 0) {
1758
2252
  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.`)
@@ -2065,6 +2559,205 @@ async function statsCommand(args) {
2065
2559
  console.log(` totales (${shownLabel}): entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
2066
2560
  }
2067
2561
 
2562
+ const EXPORT_FORMAT = 'ancleto-export/1'
2563
+
2564
+ async function readProjectMcp(projectDir) {
2565
+ for (const c of ['opencode.json', 'opencode.jsonc']) {
2566
+ const p = join(projectDir, '.opencode', c)
2567
+ if (await exists(p)) {
2568
+ try {
2569
+ return { file: p, cfg: JSON.parse((await readFile(p, 'utf8')).replace(/^\uFEFF/, '')) }
2570
+ } catch {
2571
+ return { file: p, cfg: {} }
2572
+ }
2573
+ }
2574
+ }
2575
+ return { file: join(projectDir, '.opencode', 'opencode.json'), cfg: {} }
2576
+ }
2577
+
2578
+ // Repara entradas rotas y agrega ausentes (solo las de intendedNames cuando se
2579
+ // indica; con null solo repara las existentes). Nunca toca las sanas.
2580
+ async function repairProjectMcp(projectDir, fresh, intendedNames = null) {
2581
+ const { file, cfg } = await readProjectMcp(projectDir)
2582
+ cfg.mcp = cfg.mcp || {}
2583
+ const fixed = []
2584
+ for (const [name, def] of Object.entries(cfg.mcp)) {
2585
+ if (mcpCommandBroken(def) && fresh[name]) {
2586
+ cfg.mcp[name] = fresh[name]
2587
+ fixed.push(name)
2588
+ }
2589
+ }
2590
+ if (intendedNames) {
2591
+ for (const name of intendedNames) {
2592
+ if (!cfg.mcp[name] && fresh[name]) {
2593
+ cfg.mcp[name] = fresh[name]
2594
+ fixed.push(`${name} (agregado)`)
2595
+ }
2596
+ }
2597
+ }
2598
+ await mkdir(dirname(file), { recursive: true })
2599
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
2600
+ return { file, fixed }
2601
+ }
2602
+
2603
+ async function exportCmd(args) {
2604
+ const projectDir = process.cwd()
2605
+ const rc = await readAncletorc(projectDir)
2606
+ if (!rc) {
2607
+ console.error("Error: No se encontro .ancletorc. Ejecuta 'ancleto init' primero.")
2608
+ process.exit(1)
2609
+ }
2610
+ const { cfg } = await readProjectMcp(projectDir)
2611
+ const mcpIntent = Object.entries(cfg.mcp || {})
2612
+ .map(([name, def]) => ({ name, type: def?.type || 'local', enabled: def?.enabled !== false }))
2613
+ const tierFile = join(projectDir, '.opencode', '.ancleto-tier')
2614
+ const tier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
2615
+ const manifest = {
2616
+ format: EXPORT_FORMAT,
2617
+ ancletoVersion: await packageVersion(),
2618
+ exportedAt: new Date().toISOString(),
2619
+ agent: rc.agent || null,
2620
+ tier,
2621
+ language: rc.language || 'auto',
2622
+ profile: rc.profile || 'general',
2623
+ ...(rc.gratisModel ? { gratisModel: rc.gratisModel } : {}),
2624
+ flags: { azure: Boolean(rc.azure?.enabled), engram: mcpIntent.some((m) => m.name === 'engram') },
2625
+ mcp: mcpIntent
2626
+ }
2627
+ const serialized = JSON.stringify(manifest)
2628
+ if (serialized.includes(projectDir) || /[A-Za-z]:\\/.test(serialized) || serialized.includes(homedir())) {
2629
+ console.error('ancleto: el manifiesto contiene rutas absolutas; export abortado')
2630
+ process.exit(1)
2631
+ }
2632
+ const tarIdx = args.indexOf('--tar')
2633
+ if (tarIdx >= 0 && !args[tarIdx + 1]) {
2634
+ console.error('ancleto: --tar requiere un archivo destino')
2635
+ process.exit(1)
2636
+ }
2637
+ const tarFile = tarIdx >= 0 ? resolve(args[tarIdx + 1]) : null
2638
+ const outDir = tarFile ? await mkdtemp(join(tmpdir(), 'ancleto-export-')) : join(projectDir, 'ancleto-export')
2639
+ await mkdir(outDir, { recursive: true })
2640
+ // Allowlist portable: nunca se copian opencode.json (rutas absolutas),
2641
+ // .ancleto-tier, memory.db, service.json ni la config global.
2642
+ if (await exists(join(projectDir, '.ancletorc'))) await cp(join(projectDir, '.ancletorc'), join(outDir, '.ancletorc'))
2643
+ for (const t of TEMPLATES) {
2644
+ if (await exists(join(projectDir, t))) await cp(join(projectDir, t), join(outDir, t))
2645
+ }
2646
+ if (await exists(join(projectDir, 'aspec'))) await cp(join(projectDir, 'aspec'), join(outDir, 'aspec'), { recursive: true })
2647
+ for (const d of ASSETS) {
2648
+ const src = join(projectDir, '.opencode', d)
2649
+ if (await exists(src)) await cp(src, join(outDir, '.opencode', d), { recursive: true })
2650
+ }
2651
+ await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
2652
+ if (tarFile) {
2653
+ const r = spawnSync('tar', ['czf', tarFile, '-C', outDir, '.'], { encoding: 'utf8' })
2654
+ if (r.status !== 0) {
2655
+ console.error(`ancleto: no se pudo crear el tarball (${(r.stderr || '').trim() || 'tar no disponible'})`)
2656
+ process.exit(1)
2657
+ }
2658
+ await rm(outDir, { recursive: true, force: true })
2659
+ console.log(`ancleto: exportado a ${tarFile}`)
2660
+ } else {
2661
+ console.log(`ancleto: exportado a ${join(projectDir, 'ancleto-export')}`)
2662
+ }
2663
+ console.log(`ancleto: manifiesto v${manifest.ancletoVersion} con ${mcpIntent.length} MCP (solo intencion, sin rutas)`)
2664
+ }
2665
+
2666
+ async function importCmd(args) {
2667
+ const projectDir = process.cwd()
2668
+ const repairOnly = args.includes('--repair')
2669
+ const bundleArg = args.find((a) => !a.startsWith('-'))
2670
+ if (!repairOnly && !bundleArg) {
2671
+ console.error('ancleto: uso: ancleto import <bundle> [--repair] | ancleto import --repair')
2672
+ process.exit(1)
2673
+ }
2674
+ let manifest = null
2675
+ if (bundleArg) {
2676
+ const resolved = resolve(bundleArg)
2677
+ if (!existsSync(resolved)) {
2678
+ console.error(`ancleto: bundle no encontrado: ${bundleArg}`)
2679
+ process.exit(1)
2680
+ }
2681
+ let bundleDir = resolved
2682
+ if (/\.tgz$|\.tar\.gz$/.test(resolved)) {
2683
+ bundleDir = await mkdtemp(join(tmpdir(), 'ancleto-import-'))
2684
+ const r = spawnSync('tar', ['xzf', resolved, '-C', bundleDir], { encoding: 'utf8' })
2685
+ if (r.status !== 0) {
2686
+ console.error('ancleto: no se pudo extraer el bundle')
2687
+ process.exit(1)
2688
+ }
2689
+ }
2690
+ try {
2691
+ manifest = JSON.parse(await readFile(join(bundleDir, 'manifest.json'), 'utf8'))
2692
+ } catch {
2693
+ console.error('ancleto: bundle invalido (sin manifest.json)')
2694
+ process.exit(1)
2695
+ }
2696
+ if (manifest.format !== EXPORT_FORMAT) {
2697
+ console.error(`ancleto: formato de bundle no soportado: ${manifest.format}`)
2698
+ process.exit(1)
2699
+ }
2700
+ const localVersion = await packageVersion()
2701
+ if (manifest.ancletoVersion && manifest.ancletoVersion !== localVersion) {
2702
+ console.warn(`ancleto: el bundle es de v${manifest.ancletoVersion} y el CLI es v${localVersion} (se continua igual)`)
2703
+ }
2704
+ const bundleRc = await readAncletorc(bundleDir)
2705
+ if (bundleRc) {
2706
+ await writeManifest(projectDir, {
2707
+ ...(bundleRc.agent ? { agent: bundleRc.agent } : {}),
2708
+ ...(bundleRc.language ? { language: bundleRc.language } : {}),
2709
+ ...(bundleRc.profile ? { profile: bundleRc.profile } : {}),
2710
+ ...(bundleRc.gratisModel ? { gratisModel: bundleRc.gratisModel } : {}),
2711
+ ...(bundleRc.azure ? { azure: bundleRc.azure } : {}),
2712
+ ...(bundleRc.discovery ? { discovery: bundleRc.discovery } : {})
2713
+ })
2714
+ }
2715
+ if (await exists(join(bundleDir, 'aspec'))) {
2716
+ await cp(join(bundleDir, 'aspec'), join(projectDir, 'aspec'), { recursive: true, force: false })
2717
+ }
2718
+ for (const t of TEMPLATES) {
2719
+ const src = join(bundleDir, t)
2720
+ if (!(await exists(src))) continue
2721
+ const target = join(projectDir, t)
2722
+ const content = await readFile(src, 'utf8')
2723
+ if (!(await exists(target))) {
2724
+ await writeFile(target, content)
2725
+ continue
2726
+ }
2727
+ const current = await readFile(target, 'utf8')
2728
+ const merged = mergeLocked(content, current, t)
2729
+ if (merged !== current) await writeFile(target, merged)
2730
+ }
2731
+ for (const d of ASSETS) {
2732
+ const src = join(bundleDir, '.opencode', d)
2733
+ if (await exists(src)) await cp(src, join(projectDir, '.opencode', d), { recursive: true, force: false })
2734
+ }
2735
+ const rcNow = await readAncletorc(projectDir)
2736
+ const agent = (rcNow?.agent && SUPPORTED_AGENTS.includes(rcNow.agent)) ? rcNow.agent : DEFAULT_AGENT
2737
+ const importProfile = validProfile(rcNow?.profile) || DEFAULT_PROFILE
2738
+ await installAgentAssets(projectDir, agent)
2739
+ if (importProfile === 'test') await installProfileOverlay(projectDir, agent)
2740
+ if (manifest.tier && TIERS[manifest.tier]) {
2741
+ await applyTier(join(projectDir, '.opencode', 'agents'), manifest.tier, tierModels(manifest.tier, TIERS, manifest.gratisModel))
2742
+ }
2743
+ await scaffoldAspec(projectDir)
2744
+ console.log(`ancleto: bundle aplicado (agente: ${agent}${manifest.tier ? `, tier: ${manifest.tier}` : ''})`)
2745
+ }
2746
+ const withEngram = manifest
2747
+ ? manifest.flags?.engram === true
2748
+ : Boolean((await readProjectMcp(projectDir)).cfg.mcp?.engram)
2749
+ const fresh = buildDefaultMcp({ withEngram })
2750
+ if (manifest?.flags?.azure) {
2751
+ fresh['azure-devops'] = { type: 'local', enabled: true, command: ['npx', '-y', '@davstack/mcp-azure-devops'] }
2752
+ }
2753
+ const intended = manifest ? manifest.mcp.map((m) => m.name) : null
2754
+ const { file, fixed } = await repairProjectMcp(projectDir, fresh, intended)
2755
+ console.log(fixed.length
2756
+ ? `ancleto: MCP regenerados en ${file}: ${fixed.join(', ')}`
2757
+ : `ancleto: MCP sanos en ${file}, sin cambios`)
2758
+ await doctorCommand()
2759
+ }
2760
+
2068
2761
  async function doctorCommand() {
2069
2762
  let fatal = false
2070
2763
 
@@ -2150,6 +2843,12 @@ switch (cmd) {
2150
2843
  case 'doctor':
2151
2844
  await doctorCommand()
2152
2845
  break
2846
+ case 'export':
2847
+ await exportCmd(rest)
2848
+ break
2849
+ case 'import':
2850
+ await importCmd(rest)
2851
+ break
2153
2852
  case 'mcp':
2154
2853
  await serveMemoryMcp()
2155
2854
  break