@ancleto/spec 0.7.0 → 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 +166 -40
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.0",
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'
@@ -793,7 +910,7 @@ async function hasRealContent(aspecDir) {
793
910
  }
794
911
 
795
912
  // Migra la carpeta legacy openspec/ a aspec/ por copia recursiva (no destructiva).
796
- // Unico dueno del invariante: la invocan init y upgrade antes del scaffold.
913
+ // Unico dueno del invariante: la invocan init e install --project antes del scaffold, y upgrade como paso previo (sin scaffold).
797
914
  async function migrateLegacyOpenspec(projectDir) {
798
915
  const legacyDir = join(projectDir, LEGACY_OPENSPEC_DIR)
799
916
  if (!(await exists(legacyDir))) return // sin legacy: no-op silencioso
@@ -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')
@@ -962,20 +1079,19 @@ async function install(args) {
962
1079
 
963
1080
  if (projectDir) {
964
1081
  await copyTemplates(projectDir)
1082
+ await migrateLegacyOpenspec(projectDir)
965
1083
  await scaffoldAspec(projectDir)
966
1084
  await refreshWorkingContext(projectDir)
967
- const agentSkillsDir = await installAgentSkills(projectDir, agent)
1085
+ const written = await installAgentAssets(projectDir, agent)
968
1086
  await writeManifest(projectDir, {
969
1087
  agent,
970
1088
  language,
971
1089
  ...(excludeChoice !== null ? { discovery: { ...existingDiscovery, exclude: excludeChoice } } : {}),
972
1090
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
973
- installedPaths: {
1091
+ installedPaths: unionInstalledPaths(existingRc?.installedPaths, {
974
1092
  templates: ['AGENTS.md', 'PRODUCT.md'],
975
- agents: ['.opencode/agents'],
976
- commands: ['.opencode/commands'],
977
- skills: [agentSkillsDir]
978
- }
1093
+ ...written
1094
+ })
979
1095
  })
980
1096
  } else {
981
1097
  await copyAssets(target)
@@ -983,6 +1099,9 @@ async function install(args) {
983
1099
 
984
1100
  const models = tierModels(tier, TIERS, gratisModelChoice)
985
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 })
986
1105
  await writeFile(tierStatePath(target), tier + '\n')
987
1106
 
988
1107
  if (projectDir) await registerProject(projectDir, { agent, tier })
@@ -998,7 +1117,7 @@ async function install(args) {
998
1117
 
999
1118
  const res = await mergeMcp(target, mcpMap)
1000
1119
  const loc = projectDir
1001
- ? `${projectDir} (.opencode/ + templates en la raiz)`
1120
+ ? `${projectDir} (layout ${agent} + templates en la raiz)`
1002
1121
  : `${target} (disponible en todos tus proyectos)`
1003
1122
  console.log(`ancleto: instalado en ${loc}`)
1004
1123
  console.log(`ancleto: tier de costo de agents: ${tier}`)
@@ -1019,20 +1138,19 @@ async function upgradeCmd(args) {
1019
1138
  await migrateLegacyOpenspec(projectDir)
1020
1139
  const agent = await resolveAgent(args, rc.agent)
1021
1140
  await copyTemplates(projectDir)
1022
- const agentSkillsDir = await installAgentSkills(projectDir, agent)
1141
+ const written = await installAgentAssets(projectDir, agent)
1023
1142
  const wentContext = await refreshWorkingContext(projectDir)
1024
1143
  const manifest = await writeManifest(projectDir, {
1025
1144
  agent,
1026
- installedPaths: {
1145
+ installedPaths: unionInstalledPaths(rc.installedPaths, {
1027
1146
  templates: ['AGENTS.md', 'PRODUCT.md'],
1028
- agents: ['.opencode/agents'],
1029
- commands: ['.opencode/commands'],
1030
- skills: [agentSkillsDir]
1031
- }
1147
+ ...written
1148
+ })
1032
1149
  })
1150
+ const skillsDir = written.skills[0] || '.opencode/skills'
1033
1151
  console.log(`ancleto: upgrade completo (v${manifest.version}, agente: ${agent})`)
1034
1152
  console.log(' ✔ templates re-aplicados (bloques LOCKED actualizados, EXTENSIBLE intacto)')
1035
- console.log(` ✔ skills actualizadas en ${agentSkillsDir} (${ANCLETO_SKILLS.length} skills)`)
1153
+ console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1036
1154
  console.log(wentContext ? ' ✔ working-context.md regenerado' : ' ✔ working-context.md sin reglas activas (vacio)')
1037
1155
  console.log(' ✔ manifiesto .ancletorc actualizado')
1038
1156
  }
@@ -1099,12 +1217,17 @@ async function initProject(args) {
1099
1217
  const persisted = isKnownGratisModel(existing?.gratisModel) ? existing.gratisModel : null
1100
1218
  gratisModelChoice = persisted || gratisModel()
1101
1219
  }
1220
+ const written = await installAgentAssets(projectDir, agent)
1102
1221
  const manifest = await writeManifest(projectDir, {
1103
1222
  azure,
1104
1223
  discovery,
1105
1224
  agent,
1106
1225
  language,
1107
- ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {})
1226
+ ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1227
+ installedPaths: unionInstalledPaths(existing?.installedPaths, {
1228
+ templates: ['AGENTS.md', 'PRODUCT.md'],
1229
+ ...written
1230
+ })
1108
1231
  })
1109
1232
  await copyTemplates(projectDir)
1110
1233
  if (tier) {
@@ -1590,7 +1713,10 @@ async function checkCommand() {
1590
1713
  missing++
1591
1714
  continue
1592
1715
  }
1593
- 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))
1594
1720
  const actual = (await readdir(destDir)).sort()
1595
1721
  const missingFiles = expected.filter((f) => !actual.includes(f))
1596
1722
  const orphanFiles = actual.filter((f) => !expected.includes(f))