@ancleto/spec 0.7.1 → 0.7.2

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 +35 -1
  2. package/package.json +1 -1
  3. package/src/cli/index.js +164 -39
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,39 @@ 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` | — (no soportado) | — (no soportado) |
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
+ Los assets que un host no soporta (por ejemplo `agents`/`commands` de `antigravity`, `cursor` y `roo`)
150
+ **no se escriben** y el instalador avisa por `stderr`
151
+ (`skip agents: not supported by host 'antigravity'`) sin abortar. La instalación es **aditiva y no
152
+ destructiva**: instalar con otro agente escribe en su base y preserva los layouts previos, y el
153
+ manifiesto `.ancletorc.installedPaths` acumula la unión de los destinos realmente escritos.
154
+
155
+ La **configuración MCP específica de host** está fuera de alcance: el CLI no genera ni transforma
156
+ `.mcp.json`, `mcp_config.json` ni `.vscode/mcp.json`, y no pisa config MCP preexistente. `claude` se
157
+ soporta con adaptación de frontmatter obligatoria para `agents` (se omiten `model`/`tools` sin
158
+ equivalencia verificada y las claves `mode`/`color`/`temperature`/`permission`), sin MCP propio.
159
+
160
+ ---
161
+
128
162
  ## Guía de .gitignore
129
163
 
130
164
  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.7.2",
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,7 +35,11 @@ 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").
39
43
  ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
40
44
  Parado en un proyecto con .ancletorc opera sobre ese proyecto;
41
45
  usa --global para forzar el alcance global
@@ -591,13 +595,13 @@ async function applyTier(agentsDir, tier, models = null) {
591
595
  }
592
596
  }
593
597
 
594
- const SUPPORTED_AGENTS = ['opencode', 'vscode', 'antigravity', 'cursor', 'roo']
598
+ const SUPPORTED_AGENTS = ['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo']
595
599
  const DEFAULT_AGENT = 'opencode'
596
600
 
597
601
  function askAgent() {
598
602
  return new Promise((resolve) => {
599
603
  const rl = createInterface({ input: process.stdin, output: process.stdout })
600
- rl.question('Agente/IDE [opencode/vscode/antigravity/cursor/roo] (default: opencode): ', (a) => {
604
+ rl.question(`Agente/IDE [${SUPPORTED_AGENTS.join('/')}] (default: ${DEFAULT_AGENT}): `, (a) => {
601
605
  rl.close()
602
606
  const t = a.trim().toLowerCase()
603
607
  resolve(SUPPORTED_AGENTS.includes(t) ? t : DEFAULT_AGENT)
@@ -732,32 +736,145 @@ async function copyAssets(dest) {
732
736
  }
733
737
  }
734
738
 
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'
739
+ // D1: destino nativo por host. Cada asset declara su directorio y su convencion
740
+ // de nombre (ext); null = asset no soportado por ese host. Fuente unica de rutas.
741
+ const MD_EXT = '.md'
742
+ const AGENT_TARGETS = {
743
+ opencode: {
744
+ skills: { dir: '.opencode/skills' },
745
+ agents: { dir: '.opencode/agents', ext: MD_EXT },
746
+ commands: { dir: '.opencode/commands', ext: MD_EXT }
747
+ },
748
+ claude: {
749
+ skills: { dir: '.claude/skills' },
750
+ agents: { dir: '.claude/agents', ext: MD_EXT },
751
+ commands: { dir: '.claude/commands', ext: MD_EXT }
752
+ },
753
+ vscode: {
754
+ skills: { dir: '.github/skills' },
755
+ agents: { dir: '.github/agents', ext: '.agent.md' },
756
+ commands: { dir: '.github/prompts', ext: '.prompt.md' }
757
+ },
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 },
761
+ cursor: { skills: { dir: '.cursor/skills' }, agents: null, commands: null },
762
+ roo: { skills: { dir: '.roo/skills' }, agents: null, commands: null }
741
763
  }
742
764
 
743
765
  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
766
 
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}`)
767
+ // Frontmatter: parser propio sin dependencias. Cada entrada conserva su clave y
768
+ // sus lineas crudas (incluye block scalars y mapas anidados), preservando orden.
769
+ function parseFrontmatter(content) {
770
+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n?)([\s\S]*)$/.exec(content)
771
+ if (!m) return { hasFrontmatter: false, content }
772
+ const entries = []
773
+ for (const line of m[2].split(/\r?\n/)) {
774
+ if (/^[A-Za-z0-9_-]+:/.test(line)) {
775
+ entries.push({ key: line.slice(0, line.indexOf(':')), lines: [line] })
776
+ } else if (entries.length) {
777
+ entries[entries.length - 1].lines.push(line)
778
+ } else {
779
+ entries.push({ key: null, lines: [line] })
780
+ }
781
+ }
782
+ return { hasFrontmatter: true, open: m[1], entries, close: m[3], nl: m[4], body: m[5] }
783
+ }
784
+
785
+ function serializeFrontmatter(parsed) {
786
+ if (!parsed.hasFrontmatter) return parsed.content
787
+ const inner = parsed.entries.flatMap((e) => e.lines).join('\n')
788
+ return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
789
+ }
790
+
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'])
795
+ const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
796
+
797
+ function adaptFrontmatter(content, host, assetKind) {
798
+ if (assetKind !== 'agents' || !AGENT_ADAPTER_HOSTS.has(host)) return content
799
+ const parsed = parseFrontmatter(content)
800
+ if (!parsed.hasFrontmatter) return content
801
+ parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
802
+ return serializeFrontmatter(parsed)
803
+ }
804
+
805
+ // D12: asset no soportado -> no se escribe y se avisa a stderr (no bloqueante).
806
+ function warnUnsupportedAsset(asset, agent) {
807
+ console.error(`skip ${asset}: not supported by host '${agent}'`)
808
+ }
809
+
810
+ async function copyDirTransformed(srcDir, destDir, host, assetKind) {
811
+ await mkdir(destDir, { recursive: true })
812
+ for (const e of await readdir(srcDir, { withFileTypes: true })) {
813
+ const src = join(srcDir, e.name)
814
+ const dest = join(destDir, e.name)
815
+ if (e.isDirectory()) {
816
+ await copyDirTransformed(src, dest, host, assetKind)
817
+ } else if (e.isFile() && e.name.endsWith(MD_EXT)) {
818
+ await writeFile(dest, adaptFrontmatter(await readFile(src, 'utf8'), host, assetKind))
819
+ } else if (e.isFile()) {
820
+ await cp(src, dest)
821
+ }
822
+ }
823
+ }
824
+
825
+ async function installAssetFiles(cat, projectDir, spec, host) {
826
+ const destDir = join(projectDir, spec.dir)
827
+ await mkdir(destDir, { recursive: true })
828
+ for (const e of await readdir(join(ROOT, cat), { withFileTypes: true })) {
829
+ if (!e.isFile() || !e.name.endsWith(MD_EXT)) continue
830
+ 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))
833
+ }
834
+ }
835
+
836
+ // Dueno unico de la instalacion de assets por proyecto: materializa skills,
837
+ // agents y commands en el destino nativo del host y devuelve las rutas escritas.
838
+ async function installAgentAssets(projectDir, agent) {
839
+ const model = AGENT_TARGETS[agent] || AGENT_TARGETS[DEFAULT_AGENT]
840
+ const written = { agents: [], commands: [], skills: [] }
841
+
842
+ if (model.skills) {
843
+ await copyDirTransformed(join(ROOT, 'skills'), join(projectDir, model.skills.dir), agent, 'skills')
844
+ written.skills.push(model.skills.dir)
845
+ } else {
846
+ warnUnsupportedAsset('skills', agent)
847
+ }
848
+
849
+ for (const cat of ['agents', 'commands']) {
850
+ const spec = model[cat]
851
+ if (!spec) {
852
+ warnUnsupportedAsset(cat, agent)
753
853
  continue
754
854
  }
755
- await cp(src, join(dest, name), { recursive: true })
855
+ await installAssetFiles(cat, projectDir, spec, agent)
856
+ written[cat].push(spec.dir)
857
+ }
858
+ return written
859
+ }
860
+
861
+ // D10: installedPaths es la union de destinos realmente escritos; un asset
862
+ // omitido (null) no deja rastro. No se borran rutas previas (no destructivo).
863
+ function unionInstalledPaths(existing, next) {
864
+ const out = {}
865
+ for (const k of ['templates', 'agents', 'commands', 'skills']) {
866
+ out[k] = [...new Set([...(existing?.[k] || []), ...(next?.[k] || [])])]
756
867
  }
757
- if (dir !== AGENT_SKILLS_DIR.opencode) {
758
- await cp(join(ROOT, 'skills'), dest, { recursive: true })
868
+ return out
869
+ }
870
+
871
+ // Convencion de nombre esperada en un directorio instalado (para `check`).
872
+ function installedExtFor(cat, dirRel) {
873
+ for (const t of Object.values(AGENT_TARGETS)) {
874
+ const spec = t[cat]
875
+ if (spec && spec.dir === dirRel && spec.ext) return spec.ext
759
876
  }
760
- return dir.replace(/\\/g, '/')
877
+ return null
761
878
  }
762
879
 
763
880
  const CHANGES_ROOT = 'aspec'
@@ -864,9 +981,9 @@ function findInsertAnchor(local, source, name) {
864
981
  return local.length
865
982
  }
866
983
 
984
+ // Solo mergea los templates raiz (AGENTS.md/PRODUCT.md). Los assets por host
985
+ // los materializa installAgentAssets (dueno unico).
867
986
  async function copyTemplates(projectDir) {
868
- const dest = join(projectDir, '.opencode')
869
- await copyAssets(dest)
870
987
  for (const t of TEMPLATES) {
871
988
  const target = join(projectDir, t)
872
989
  const source = await readFile(join(ROOT, 'templates', t), 'utf8')
@@ -965,18 +1082,16 @@ async function install(args) {
965
1082
  await migrateLegacyOpenspec(projectDir)
966
1083
  await scaffoldAspec(projectDir)
967
1084
  await refreshWorkingContext(projectDir)
968
- const agentSkillsDir = await installAgentSkills(projectDir, agent)
1085
+ const written = await installAgentAssets(projectDir, agent)
969
1086
  await writeManifest(projectDir, {
970
1087
  agent,
971
1088
  language,
972
1089
  ...(excludeChoice !== null ? { discovery: { ...existingDiscovery, exclude: excludeChoice } } : {}),
973
1090
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
974
- installedPaths: {
1091
+ installedPaths: unionInstalledPaths(existingRc?.installedPaths, {
975
1092
  templates: ['AGENTS.md', 'PRODUCT.md'],
976
- agents: ['.opencode/agents'],
977
- commands: ['.opencode/commands'],
978
- skills: [agentSkillsDir]
979
- }
1093
+ ...written
1094
+ })
980
1095
  })
981
1096
  } else {
982
1097
  await copyAssets(target)
@@ -984,6 +1099,9 @@ async function install(args) {
984
1099
 
985
1100
  const models = tierModels(tier, TIERS, gratisModelChoice)
986
1101
  await applyTier(join(target, 'agents'), tier, models)
1102
+ // El estado de tier vive en el base de opencode; puede no existir si el host
1103
+ // resuelto ubica sus assets en otro directorio (p. ej. cursor -> .cursor/).
1104
+ await mkdir(target, { recursive: true })
987
1105
  await writeFile(tierStatePath(target), tier + '\n')
988
1106
 
989
1107
  if (projectDir) await registerProject(projectDir, { agent, tier })
@@ -999,7 +1117,7 @@ async function install(args) {
999
1117
 
1000
1118
  const res = await mergeMcp(target, mcpMap)
1001
1119
  const loc = projectDir
1002
- ? `${projectDir} (.opencode/ + templates en la raiz)`
1120
+ ? `${projectDir} (layout ${agent} + templates en la raiz)`
1003
1121
  : `${target} (disponible en todos tus proyectos)`
1004
1122
  console.log(`ancleto: instalado en ${loc}`)
1005
1123
  console.log(`ancleto: tier de costo de agents: ${tier}`)
@@ -1020,20 +1138,19 @@ async function upgradeCmd(args) {
1020
1138
  await migrateLegacyOpenspec(projectDir)
1021
1139
  const agent = await resolveAgent(args, rc.agent)
1022
1140
  await copyTemplates(projectDir)
1023
- const agentSkillsDir = await installAgentSkills(projectDir, agent)
1141
+ const written = await installAgentAssets(projectDir, agent)
1024
1142
  const wentContext = await refreshWorkingContext(projectDir)
1025
1143
  const manifest = await writeManifest(projectDir, {
1026
1144
  agent,
1027
- installedPaths: {
1145
+ installedPaths: unionInstalledPaths(rc.installedPaths, {
1028
1146
  templates: ['AGENTS.md', 'PRODUCT.md'],
1029
- agents: ['.opencode/agents'],
1030
- commands: ['.opencode/commands'],
1031
- skills: [agentSkillsDir]
1032
- }
1147
+ ...written
1148
+ })
1033
1149
  })
1150
+ const skillsDir = written.skills[0] || '.opencode/skills'
1034
1151
  console.log(`ancleto: upgrade completo (v${manifest.version}, agente: ${agent})`)
1035
1152
  console.log(' ✔ templates re-aplicados (bloques LOCKED actualizados, EXTENSIBLE intacto)')
1036
- console.log(` ✔ skills actualizadas en ${agentSkillsDir} (${ANCLETO_SKILLS.length} skills)`)
1153
+ console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1037
1154
  console.log(wentContext ? ' ✔ working-context.md regenerado' : ' ✔ working-context.md sin reglas activas (vacio)')
1038
1155
  console.log(' ✔ manifiesto .ancletorc actualizado')
1039
1156
  }
@@ -1100,12 +1217,17 @@ async function initProject(args) {
1100
1217
  const persisted = isKnownGratisModel(existing?.gratisModel) ? existing.gratisModel : null
1101
1218
  gratisModelChoice = persisted || gratisModel()
1102
1219
  }
1220
+ const written = await installAgentAssets(projectDir, agent)
1103
1221
  const manifest = await writeManifest(projectDir, {
1104
1222
  azure,
1105
1223
  discovery,
1106
1224
  agent,
1107
1225
  language,
1108
- ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {})
1226
+ ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1227
+ installedPaths: unionInstalledPaths(existing?.installedPaths, {
1228
+ templates: ['AGENTS.md', 'PRODUCT.md'],
1229
+ ...written
1230
+ })
1109
1231
  })
1110
1232
  await copyTemplates(projectDir)
1111
1233
  if (tier) {
@@ -1591,7 +1713,10 @@ async function checkCommand() {
1591
1713
  missing++
1592
1714
  continue
1593
1715
  }
1594
- const expected = (await readdir(join(ROOT, cat))).sort()
1716
+ // La convencion de nombre del host (p. ej. vscode: .agent.md/.prompt.md)
1717
+ // se deriva del mismo modelo de destino; en skills no cambia el nombre.
1718
+ const ext = installedExtFor(cat, dirRel)
1719
+ const expected = (await readdir(join(ROOT, cat))).sort().map((f) => (ext ? f.replace(/\.md$/, ext) : f))
1595
1720
  const actual = (await readdir(destDir)).sort()
1596
1721
  const missingFiles = expected.filter((f) => !actual.includes(f))
1597
1722
  const orphanFiles = actual.filter((f) => !expected.includes(f))