@ancleto/spec 0.9.1 → 0.11.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damian Argañarás, Lucas Bonch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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
+ - [Instalación manual (sin npm)](docs/instalacion-manual.md)
26
27
  - [Hosts soportados y layouts por agente](#hosts-soportados-y-layouts-por-agente)
27
28
  - [Guía de .gitignore](#guía-de-gitignore)
28
29
  - [Configuración y tiers de costo](#configuración-y-tiers-de-costo)
@@ -165,10 +166,11 @@ verificados de Antigravity (`read`→`view_file`, `edit`→`replace_file_content
165
166
  tool de opencode sin id verificado se **omite con aviso** (`skip tool '<k>': no verified Antigravity id
166
167
  for agent '<n>'`): un id inexistente cuelga el subagent. Los ids que sólo existen en el SDK
167
168
  (`find_file`, `edit_file`, `search_web`, `read_url_content`, …), los de comunidad (`write_to_file`,
168
- `call_mcp_tool`, `multi_replace_file_content`, …) y los de delegación (`invoke_subagent`,
169
- `start_subagent`, `define_subagent`) **no** se emiten; el uso de MCP se expresa por
170
- `mcpServers`/`.agents/mcp_config.json`, nunca como tool id. El campo `skill` no es una tool: se cubre
171
- con el surfaceo automático de skills de Antigravity.
169
+ `multi_replace_file_content`, …) y los de delegación (`invoke_subagent`, `start_subagent`,
170
+ `define_subagent`) **no** se emiten. Las claves de MCP declaradas dentro de `tools` (`mcpServers`,
171
+ `mcp_*`, `call_mcp_tool`) caen en **omisión con aviso** y `call_mcp_tool` queda **prohibido**; el uso
172
+ de MCP se expresa por `mcpServers`/`.agents/mcp_config.json`, nunca como tool id. El campo `skill` no
173
+ es una tool: se cubre con el surfaceo automático de skills de Antigravity.
172
174
 
173
175
  Los assets que un host no soporta (por ejemplo `agents`/`commands` de `cursor` y `roo`)
174
176
  **no se escriben** y el instalador avisa por `stderr`
@@ -308,6 +310,8 @@ ancleto init # Crea .ancletorc, aspec/, AGENTS.md y PRODUCT.md
308
310
 
309
311
  `init` **no pisa** lo que ya existe: si tu repo tiene su propio `AGENTS.md` o `PRODUCT.md`, los respeta (solo fusiona los bloques `<!-- LOCKED -->` del template). Además materializa `.ancleto/working-context.md` con tus reglas activas cuando hay memoria — es el bloque que el orquestador inyecta al arrancar.
310
312
 
313
+ Si el repo trae una carpeta legacy `openspec/`, `init`, `install --project` y `upgrade` la detectan y migran su contenido a `aspec/` **por copia**, sin prompts, cuando `aspec/` no existe o sólo tiene el scaffold: conservan `openspec/` como backup y escriben el marcador `aspec/.migrated-from-openspec`. Si `aspec/` ya tiene contenido real propio no fusionan las carpetas y avisan para revisión manual, y si el marcador ya existe la corrida es un no-op silencioso.
314
+
311
315
  > ¿Querés que el proyecto lleve **su propia** copia de agents/skills (sin depender de la instalación global)? Sumá `ancleto install --project .`.
312
316
 
313
317
  ### 2. Descubrir contexto técnico
@@ -409,7 +413,7 @@ Funciona en tres piezas que comparten el mismo archivo:
409
413
 
410
414
  Cada entrada tiene un `scope`: `project` (default), `feature` o `task`. Solo las **reglas** activas con scope `project` entran en `<ProjectMemoryRules>`; las **decisiones** con scope `project` se recuperan reactivamente con `searchMemory` y no se inyectan de forma proactiva. El orquestador inyecta los bloques `<ProjectMemoryRules>` y `<ProjectTopology>` en el system prompt de tu agente desde el día cero —incluso sin reglas previas— para que no repita errores ya resueltos.
411
415
 
412
- > El mismo motor se puede consultar a mano desde la terminal: `ancleto memory context --scope project`. El archivo `.ancleto/working-context.md` que lee el orquestador se regenera solo en `init`, `install --project` y `upgrade`.
416
+ > El mismo motor se puede consultar a mano desde la terminal: `ancleto memory context --scope project`. El archivo `.ancleto/working-context.md` que lee el orquestador se regenera en los lifecycle points (`init`, `install --project`, `upgrade` y `memory context --out`) y también **on-write**, cuando una escritura MCP lo altera: al registrar una `rule` con scope `project` o al superseder una `memory_key` existente (sin importar `type` ni `scope`).
413
417
 
414
418
  **Dos memorias, sin mezcla.** La memoria **del repositorio** (esta, `.ancleto/memory.db`) guarda reglas y decisiones del proyecto y se comparte con el equipo. Si el runtime expone además una memoria **del agente** (por ejemplo engram, habilitable con `--with-engram`), esa guarda notas de sesión, no del repo. **Una entrada vive en una sola**: si un futuro agente del equipo debería encontrarla, va al repo.
415
419
 
@@ -425,7 +429,8 @@ Una vez instalado, tu IDE expone el ciclo de vida completo como comandos barra:
425
429
  | `/cleto-propose` | Proponer el diseño técnico. |
426
430
  | `/cleto-ff` | Avanzar rápido con el contexto ya recuperado. |
427
431
  | `/cleto-apply` | Aplicar el código del change. |
428
- | `/cleto-verify` | Verificar reglas, tests y memoria antes de cerrar. |
432
+ | `/cleto-verify` | Verificar reglas, tests y memoria antes de cerrar. En modo `aspec Change`, cuando el proposal declara garantías de efecto sobre estado, verifica además que se sostengan bajo el modelo real; si no, emite `GUARANTEE NOT SUSTAINED` (`CRITICAL`) y el orquestador bloquea el archive. |
433
+ | `/cleto-review` | Revisar código del scope por duplicación, redundancia, mala ubicación y código sin uso. |
429
434
  | `/cleto-security` | Realizar una auditoría de seguridad integral en el proyecto o change. |
430
435
  | `/cleto-sync` | Sincronizar las specs con el estado del repositorio. |
431
436
  | `/cleto-archive` | Archivar el change y registrar aprendizajes. |
@@ -497,6 +502,6 @@ Los 10 agentes instalados y el modelo que usa cada uno según tu tier. Al instal
497
502
 
498
503
  El tier `minimo` usa un único modelo para los 10 agentes: `deepseek-v4.1-flash` es el más económico del catálogo para coding (0.15 / 0.60 por millón de tokens) y ofrece 1M de contexto.
499
504
 
500
- **Tier `gratis`.** Los 10 agentes usan **Muse Spark 1.3 Free** (`opencode/muse-spark-1.3-contributor-free`) si está disponible en tu cuenta —no está abierto a todos—. En una instalación interactiva el wizard te lo pregunta y la respuesta queda guardada en `.ancletorc` (`gratisModel`) para los siguientes `install`/`update`. Sin TTY se detecta con `opencode models` y, si no se puede confirmar, se usa `opencode/big-pickle`. El instalador siempre informa cuál aplicó.
505
+ **Tier `gratis`.** Los 10 agentes usan **Muse Spark 1.3 Free** (`opencode/muse-spark-1.3-contributor-free`) si está disponible en tu cuenta —no está abierto a todos—. En `install` interactivo el wizard te lo pregunta la primera vez y la respuesta queda guardada en `.ancletorc` (`gratisModel`) para los siguientes `init`/`install`/`update`. `init` interactivo con tier `gratis` pregunta siempre, salvo que `ANCLETO_MUSE_SPARK` fuerce el valor (pre-selecciona el valor guardado o la detección automática como default); fuera del menú reutiliza el valor guardado o detecta con `opencode models` y, si no se puede confirmar, usa `opencode/big-pickle`. Ambos comandos informan cuál aplicaron y, cuando no preguntan, de dónde salió (variable de entorno, valor guardado o detección automática).
501
506
 
502
507
  > `ANCLETO_MUSE_SPARK=1` fuerza Muse Spark, `ANCLETO_MUSE_SPARK=0` fuerza `big-pickle`. Tiene prioridad sobre la elección guardada.
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Review scoped code for duplication, redundancy, misplacement and dead code
3
+ ---
4
+
5
+ Invoke the `ancleto-review` skill with the Skill tool and follow it exactly; do not improvise steps.
6
+
7
+ **Input**: The argument after `/cleto-review` is the scope to review: a ticket reference (e.g. `#123`, resolved to commits whose message contains it), a git range (e.g. `main..HEAD`), or paths/directories. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available scopes.
8
+
9
+ Work Item grounding (when the request references a Work Item) is owned by `@context-resolver`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,10 @@
30
30
  "node": ">=24.0.0"
31
31
  },
32
32
  "license": "MIT",
33
+ "scripts": {
34
+ "lint": "eslint src/ test/",
35
+ "test": "node --test \"test/*.test.js\""
36
+ },
33
37
  "keywords": [
34
38
  "opencode",
35
39
  "aspec",
@@ -38,5 +42,9 @@
38
42
  "agents",
39
43
  "skills",
40
44
  "spec-driven"
41
- ]
45
+ ],
46
+ "devDependencies": {
47
+ "eslint": "^10.11.0",
48
+ "globals": "^17.13.0"
49
+ }
42
50
  }
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: ancleto-review
3
+ description: Review user-scoped code for duplication, redundancy, misplacement and dead code. Reports findings with file:line refs without modifying code. No external binaries required.
4
+ license: MIT
5
+ compatibility: No external binaries required. Works with any agent runtime.
6
+ metadata:
7
+ author: ancleto
8
+ version: '1.0'
9
+ ---
10
+
11
+ # aspec Review
12
+
13
+ Review code quality inside a user-declared scope. Read-only: never write or edit code.
14
+
15
+ **Input**: the scope to review — free text after `/cleto-review`. Forms (first match wins): `#<n>` (commits whose message contains `#<n>`), an explicit git range (`A..B`, `HEAD~n`), existing paths/directories, or empty (infer from context). If ambiguous, list candidate scopes and ask. Never guess.
16
+
17
+ **IMPORTANT**: Do NOT guess or auto-select a scope when ambiguous. Always let the user choose. Work Item grounding (when the request references a Work Item) is owned by `@context-resolver` — treat a resolved item as `#<n>`.
18
+
19
+ ## Steps
20
+
21
+ ### 1. Resolve the scope
22
+
23
+ - `#<n>` → `git log --grep="#<n>" --format=%H`, then the diff of each commit (`H^ H`). Only code introduced there is in scope.
24
+ - Range (`A..B`, `HEAD~n`) → `git diff` of that range.
25
+ - Paths → worktree diff filtered to those paths.
26
+ - Nothing usable → list `aspec/changes/` dirs with `tasks.md` and recent `git log` subjects as candidates, and ask.
27
+
28
+ The result is a concrete file:line list. Report ONLY on that list.
29
+
30
+ ### 2. Collect evidence (reading outside the scope is allowed)
31
+
32
+ Read outside the diff solely as comparison evidence: twin definitions (redundancy), call/import references (dead code), and the project map (`PRODUCT.md`, directory structure, `aspec/specs/`) for placement. Never report findings outside the scope.
33
+
34
+ ### 3. Run the four detectors
35
+
36
+ **Duplicated code:** twin blocks inside the scope doing the same logic → CRITICAL: "Duplicated block: `<file:line>` and `<file:line>`" + "Extract to a shared helper".
37
+
38
+ **Redundant method:** scope logic already implemented outside the diff → WARNING: "Redundant with `<external-file:line>`" + "Reuse `<name>` instead of duplicating". If the difference is subtle or uncertain, downgrade to SUGGESTION.
39
+
40
+ **Misplaced code:** scope code contradicting the project map (e.g. business logic in a handler, stray util in a component) → WARNING or SUGGESTION: "Misplaced code: `<file:line>` belongs in `<location>`" + reason. When the architecture is unclear, prefer SUGGESTION.
41
+
42
+ **Dead code:** scope functions/imports with no references in the repo (search calls and imports) → CRITICAL when proven (no references at all): "Unused code: `<file:line>`" + "Remove it". Otherwise SUGGESTION.
43
+
44
+ ### 4. Review Report
45
+
46
+ ```
47
+ ## Review: <scope description>
48
+
49
+ ### CRITICAL
50
+ - ...
51
+
52
+ ### WARNING
53
+ - ...
54
+
55
+ ### SUGGESTION
56
+ - ...
57
+ ```
58
+
59
+ **Issues by priority**, each with a specific recommendation:
60
+
61
+ 1. **CRITICAL** (proven duplication or proven dead code inside the scope).
62
+ 2. **WARNING** (likely redundancy or misplacement, evidence cited).
63
+ 3. **SUGGESTION** (uncertain or stylistic, needs human judgment).
64
+
65
+ **Assessment:** CRITICAL → "X critical issue(s) found. Fix before merging."; warnings only → "No critical issues. Y warning(s) to consider."; clean → "No issues found in scope: `<scope>`."
66
+
67
+ ## Heuristics
68
+
69
+ - **Scope discipline**: every finding must point at a file:line inside the scope; external files appear only as evidence.
70
+ - **False positives**: when uncertain, prefer SUGGESTION > WARNING > CRITICAL.
71
+ - **Actionability**: every issue carries a specific recommendation with file/line refs.
72
+
73
+ ## Guardrails
74
+
75
+ - Read-only: never write, edit, or refactor code — only report.
76
+ - Reference code as `file.ts:123`.
77
+ - No vague suggestions ("consider reviewing") — every issue needs a specific recommendation.
78
+ - Never paste diffs or code blocks — cite locations.
79
+ - At most 5 findings per severity; if more exist, keep the 5 highest-impact and state the count of the rest.
80
+ - This skill does not verify artifacts (that is `ancleto-verify`), conventions against an approved request (that is `reviewer`), or vulnerabilities (that is `ancleto-security`).
package/src/cli/index.js CHANGED
@@ -9,6 +9,7 @@ import { join, dirname, resolve, basename, relative, isAbsolute } from 'node:pat
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'
12
+ import { adaptFrontmatter, parseFrontmatter } from '../core/adapters/frontmatter.js'
12
13
  import { writeWorkingContext } from '../core/memory/working-context.js'
13
14
  import { memoryDoctor } from '../core/memory/doctor.js'
14
15
  import { serveMemoryMcp } from '../core/memory/mcp-server.js'
@@ -74,8 +75,11 @@ Uso:
74
75
  Imprime/escribe el bloque <ProjectMemoryRules> (reglas activas)
75
76
  ancleto memory list [--type X] [--scope X] [--all] [--json]
76
77
  Lista los nodos de memoria (read-only, no toca la DB)
77
- ancleto memory doctor [--rebuild] Diagnostica .ancleto/memory.db (integridad, FTS5, unicidad)
78
- y reconstruye el indice FTS5 con --rebuild
78
+ ancleto memory doctor [--rebuild] Diagnostica .ancleto/memory.db (integridad, FTS5, unicidad)
79
+ y reconstruye el indice FTS5 con --rebuild
80
+ ancleto memory export [--out <archivo>] Exporta nodos activos a JSON (stdout o archivo)
81
+ ancleto memory import <archivo> Importa nodos desde JSON (upsert por memory_key, idempotente)
82
+ ancleto memory gc [--dry-run] [--days N] Purga superseded con antiguedad > N dias (default 30)
79
83
  ancleto mcp Servidor MCP de memoria propia (stdio) para tu IDE
80
84
  expone searchMemory, recordRule y recordDecision
81
85
  ancleto check Verifica integridad de archivos instalados vs manifiesto
@@ -108,7 +112,7 @@ async function readAncletorc(projectDir) {
108
112
 
109
113
  async function writeManifest(projectDir, extra = {}) {
110
114
  const existing = (await readAncletorc(projectDir)) || {}
111
- const { version: _legacy, ...rest } = existing
115
+ const { ...rest } = existing
112
116
  const manifest = {
113
117
  ...rest,
114
118
  schemaVersion: 1,
@@ -195,15 +199,6 @@ async function registerProject(projectDir, { agent, tier } = {}) {
195
199
  await writeProjectsRegistry(reg)
196
200
  }
197
201
 
198
- async function unregisterProject(projectDir) {
199
- const reg = await readProjectsRegistry()
200
- const key = normKey(projectDir)
201
- if (!reg.projects[key]) return false
202
- delete reg.projects[key]
203
- await writeProjectsRegistry(reg)
204
- return true
205
- }
206
-
207
202
  // Color helpers (respetan NO_COLOR y TTY).
208
203
  const useColor = () => process.stdout.isTTY && !process.env.NO_COLOR
209
204
  function paint(code, s) {
@@ -750,6 +745,9 @@ async function resolveAgent(args, existing, allowAsk = Boolean(process.stdin.isT
750
745
  if (flag) return flag
751
746
  if (existing && SUPPORTED_AGENTS.includes(existing)) return existing
752
747
  if (allowAsk) return askAgent()
748
+ if (existing && !SUPPORTED_AGENTS.includes(existing)) {
749
+ console.error(`ancleto: advertencia: agente '${existing}' no soportado, usando '${DEFAULT_AGENT}'`)
750
+ }
753
751
  return DEFAULT_AGENT
754
752
  }
755
753
 
@@ -947,36 +945,6 @@ const AGENT_TARGETS = {
947
945
 
948
946
  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']
949
947
 
950
- // Frontmatter: parser propio sin dependencias. Cada entrada conserva su clave y
951
- // sus lineas crudas (incluye block scalars y mapas anidados), preservando orden.
952
- function parseFrontmatter(content) {
953
- const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n?)([\s\S]*)$/.exec(content)
954
- if (!m) return { hasFrontmatter: false, content }
955
- const entries = []
956
- for (const line of m[2].split(/\r?\n/)) {
957
- if (/^[A-Za-z0-9_-]+:/.test(line)) {
958
- entries.push({ key: line.slice(0, line.indexOf(':')), lines: [line] })
959
- } else if (entries.length) {
960
- entries[entries.length - 1].lines.push(line)
961
- } else {
962
- entries.push({ key: null, lines: [line] })
963
- }
964
- }
965
- return { hasFrontmatter: true, open: m[1], entries, close: m[3], nl: m[4], body: m[5] }
966
- }
967
-
968
- function serializeFrontmatter(parsed) {
969
- if (!parsed.hasFrontmatter) return parsed.content
970
- const inner = parsed.entries.flatMap((e) => e.lines).join('\n')
971
- return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
972
- }
973
-
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'])
978
- const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
979
-
980
948
  // Solo Copilot: los tiers no cambian modelos (el usuario elige en el picker);
981
949
  // el orchestrator lo recuerda al empezar.
982
950
  const COPILOT_MODEL_NOTE = `
@@ -988,119 +956,6 @@ normal = flujo completo, minimo = pasos agrupados y económicos, gratis = modelo
988
956
  gratis disponible en tu cuenta.
989
957
  `
990
958
 
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) {
1096
- if (assetKind !== 'agents' || !AGENT_ADAPTER_HOSTS.has(host)) return content
1097
- if (host === 'antigravity') return adaptAntigravityFrontmatter(content, name)
1098
- const parsed = parseFrontmatter(content)
1099
- if (!parsed.hasFrontmatter) return content
1100
- parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
1101
- return serializeFrontmatter(parsed)
1102
- }
1103
-
1104
959
  // D12: asset no soportado -> no se escribe y se avisa a stderr (no bloqueante).
1105
960
  function warnUnsupportedAsset(asset, agent) {
1106
961
  console.error(`skip ${asset}: not supported by host '${agent}'`)
@@ -1208,6 +1063,18 @@ function installedExtFor(cat, dirRel) {
1208
1063
  return null
1209
1064
  }
1210
1065
 
1066
+ // Host dueno de un directorio instalado por categoria). Para agents los
1067
+ // destinos son unicos por host (vscode escribe `.github/agents`, copilot
1068
+ // `.github/prompts`), pero commands comparte `.github/prompts` entre ambos:
1069
+ // se devuelve el primer match (chequeamos solo agents en el frontmatter check).
1070
+ function hostForDir(cat, dirRel) {
1071
+ for (const [host, t] of Object.entries(AGENT_TARGETS)) {
1072
+ const spec = t[cat]
1073
+ if (spec && spec.dir === dirRel) return host
1074
+ }
1075
+ return null
1076
+ }
1077
+
1211
1078
  // D9: hosts realmente instalados derivados del manifiesto (union de destinos
1212
1079
  // escritos), no de un unico `rc.agent`. Un proyecto multi-host debe sumar los
1213
1080
  // esperados de todos los hosts para no reportar huerfanos falsos.
@@ -1497,15 +1364,22 @@ async function install(args) {
1497
1364
  }
1498
1365
  if (!language) language = 'auto'
1499
1366
 
1500
- if (projectDir && !agent) agent = DEFAULT_AGENT
1367
+ if (projectDir && !agent) {
1368
+ if (existingRc?.agent && !SUPPORTED_AGENTS.includes(existingRc.agent)) {
1369
+ console.error(`ancleto: advertencia: agente '${existingRc.agent}' no soportado, usando '${DEFAULT_AGENT}'`)
1370
+ }
1371
+ agent = DEFAULT_AGENT
1372
+ }
1501
1373
  if (!tier) tier = TIERS[storedTier] ? storedTier : 'normal'
1502
1374
 
1503
1375
  let gratisModelChoice = null
1376
+ let gratisModelOrigin = null
1504
1377
  if (tier === 'gratis') {
1505
1378
  const env = envGratisModel()
1506
1379
  const persisted = isKnownGratisModel(existingRc?.gratisModel) ? existingRc.gratisModel : null
1507
1380
  if (env || persisted) {
1508
1381
  gratisModelChoice = env || persisted
1382
+ gratisModelOrigin = env ? 'variable de entorno ANCLETO_MUSE_SPARK' : 'valor guardado en .ancletorc'
1509
1383
  } else if (process.stdout.isTTY) {
1510
1384
  if (!bannerShown) await showBanner()
1511
1385
  const answer = await selectOption('Muse Spark 1.3 Free disponible en tu cuenta?', ['No (usar big-pickle)', 'Si (Muse Spark)'], 0)
@@ -1513,6 +1387,7 @@ async function install(args) {
1513
1387
  gratisModelChoice = answer.startsWith('Si') ? MUSE_SPARK_MODEL : GRATIS_FALLBACK_MODEL
1514
1388
  } else {
1515
1389
  gratisModelChoice = gratisModel()
1390
+ gratisModelOrigin = 'detección automática'
1516
1391
  }
1517
1392
  }
1518
1393
 
@@ -1570,7 +1445,7 @@ async function install(args) {
1570
1445
  : `${target} (disponible en todos tus proyectos)`
1571
1446
  console.log(`ancleto: instalado en ${loc}`)
1572
1447
  console.log(`ancleto: tier de costo de agents: ${tier}`)
1573
- if (tier === 'gratis') console.log(`ancleto: modelo gratis: ${models.orchestrator}`)
1448
+ if (tier === 'gratis') console.log(`ancleto: modelo gratis: ${models.orchestrator}${gratisModelOrigin ? ` (${gratisModelOrigin})` : ''}`)
1574
1449
  if (res.added.length) {
1575
1450
  console.log(`ancleto: MCP configurados: ${res.added.join(', ')} en ${res.file}`)
1576
1451
  }
@@ -1683,9 +1558,26 @@ async function initProject(args) {
1683
1558
  // Paridad con install: el tier elegido tambien reescribe los modelos de los
1684
1559
  // agentes locales. Antes quedaba huerfano (tier declarado, modelos de otro tier).
1685
1560
  let gratisModelChoice = null
1561
+ let gratisModelOrigin = null
1686
1562
  if (tier === 'gratis') {
1563
+ const env = envGratisModel()
1687
1564
  const persisted = isKnownGratisModel(existing?.gratisModel) ? existing.gratisModel : null
1688
- gratisModelChoice = persisted || gratisModel()
1565
+ if (env) {
1566
+ gratisModelChoice = env
1567
+ gratisModelOrigin = 'variable de entorno ANCLETO_MUSE_SPARK'
1568
+ } else if (isInteractive) {
1569
+ // En menú interactivo siempre se pregunta: el valor guardado (o la
1570
+ // detección) solo pre-selecciona el default, nunca se aplica en silencio.
1571
+ const defIdx = (persisted || gratisModel()) === MUSE_SPARK_MODEL ? 1 : 0
1572
+ const answer = await selectOption('Muse Spark 1.3 Free disponible en tu cuenta?', ['No (usar big-pickle)', 'Si (Muse Spark)'], defIdx)
1573
+ gratisModelChoice = answer.startsWith('Si') ? MUSE_SPARK_MODEL : GRATIS_FALLBACK_MODEL
1574
+ } else if (persisted) {
1575
+ gratisModelChoice = persisted
1576
+ gratisModelOrigin = 'valor guardado en .ancletorc'
1577
+ } else {
1578
+ gratisModelChoice = gratisModel()
1579
+ gratisModelOrigin = 'detección automática'
1580
+ }
1689
1581
  }
1690
1582
  const written = await installAgentAssets(projectDir, agent)
1691
1583
  if (profile === 'test') await installProfileOverlay(projectDir, agent)
@@ -1717,6 +1609,7 @@ async function initProject(args) {
1717
1609
  if (azure.enabled) console.log(AZURE_MCP_NOTICE)
1718
1610
  if (mcpRes.added.length) console.log(`ancleto: MCP configurados: ${mcpRes.added.join(', ')} en ${mcpRes.file}`)
1719
1611
  console.log(`ancleto: .ancletorc actualizado en ${projectDir} (v${manifest.version})${azure.enabled ? ' (Azure habilitado)' : ' (Azure desactivado)'} (Agente: ${agent})${tier ? ` (Tier: ${tier})` : ''} (Idioma: ${language})`)
1612
+ if (tier === 'gratis' && gratisModelChoice) console.log(`ancleto: modelo gratis: ${gratisModelChoice}${gratisModelOrigin ? ` (${gratisModelOrigin})` : ''}`)
1720
1613
  }
1721
1614
 
1722
1615
  const DEFAULT_IGNORES = ['node_modules', '.git', 'dist']
@@ -2142,6 +2035,88 @@ async function memoryListCmd(flags) {
2142
2035
  console.log(`ancleto: ${nodes.length} nodo(s)`)
2143
2036
  }
2144
2037
 
2038
+ async function memoryExport(flags) {
2039
+ const out = flagValue(flags, '--out')
2040
+ const dbPath = defaultMemoryDbPath()
2041
+ if (!(await exists(dbPath))) {
2042
+ console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
2043
+ process.exit(1)
2044
+ }
2045
+ const engine = createMemoryEngine(dbPath)
2046
+ try {
2047
+ const nodes = engine.exportActive()
2048
+ const json = JSON.stringify(nodes, null, 2)
2049
+ if (out) {
2050
+ await writeFile(resolve(out), json + '\n')
2051
+ console.log(`ancleto: exportados ${nodes.length} nodo(s) a ${out}`)
2052
+ } else {
2053
+ console.log(json)
2054
+ }
2055
+ } finally {
2056
+ engine.close()
2057
+ }
2058
+ }
2059
+
2060
+ async function memoryImport(flags) {
2061
+ const positional = flags.find((a) => !a.startsWith('-'))
2062
+ if (!positional) {
2063
+ console.error('ancleto: uso: ancleto memory import <archivo>')
2064
+ process.exit(1)
2065
+ }
2066
+ const file = resolve(positional)
2067
+ if (!(await exists(file))) {
2068
+ console.error(`ancleto: no existe el archivo: ${file}`)
2069
+ process.exit(1)
2070
+ }
2071
+ let json
2072
+ try {
2073
+ json = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
2074
+ } catch (err) {
2075
+ console.error(`ancleto: no se pudo parsear ${file} como JSON: ${err.message}`)
2076
+ process.exit(1)
2077
+ }
2078
+ const dbPath = defaultMemoryDbPath()
2079
+ const engine = createMemoryEngine(dbPath)
2080
+ try {
2081
+ const summary = engine.importNodes(json)
2082
+ console.log(`ancleto: import OK -> insertados:${summary.inserted} actualizados:${summary.updated} omitidos:${summary.skipped}`)
2083
+ } finally {
2084
+ engine.close()
2085
+ }
2086
+ }
2087
+
2088
+ async function memoryGc(flags) {
2089
+ const dryRun = flags.includes('--dry-run')
2090
+ const daysRaw = flagValue(flags, '--days')
2091
+ let days
2092
+ if (daysRaw !== null) {
2093
+ days = Number(daysRaw)
2094
+ if (!Number.isFinite(days) || days <= 0 || !Number.isInteger(days)) {
2095
+ console.error(`ancleto: --days invalido: ${daysRaw} (entero positivo)`)
2096
+ process.exit(1)
2097
+ }
2098
+ }
2099
+ const dbPath = defaultMemoryDbPath()
2100
+ if (!(await exists(dbPath))) {
2101
+ console.error(`ancleto: no hay memoria en este repo (${dbPath})`)
2102
+ process.exit(0)
2103
+ }
2104
+ const engine = createMemoryEngine(dbPath)
2105
+ try {
2106
+ const result = engine.gcSuperseded({ dryRun, days })
2107
+ if (result.dryRun) {
2108
+ console.log(`ancleto: gc --dry-run (umbral ${result.days} dias) -> ${result.nodes} nodo(s), ~${result.estimated_bytes} bytes a liberar`)
2109
+ } else {
2110
+ console.log(`ancleto: gc (umbral ${result.days} dias) -> ${result.removed} nodo(s) purgados + VACUUM + REINDEX`)
2111
+ if (result.vacuumError) {
2112
+ console.error(`ancleto: advertencia: VACUUM/REINDEX falló: ${result.vacuumError}`)
2113
+ }
2114
+ }
2115
+ } finally {
2116
+ engine.close()
2117
+ }
2118
+ }
2119
+
2145
2120
  async function memoryCmd(args) {
2146
2121
  const [sub, ...flags] = args
2147
2122
  if (sub === 'context') {
@@ -2156,10 +2131,25 @@ async function memoryCmd(args) {
2156
2131
  await memoryDoctorCmd(flags)
2157
2132
  return
2158
2133
  }
2134
+ if (sub === 'export') {
2135
+ await memoryExport(flags)
2136
+ return
2137
+ }
2138
+ if (sub === 'import') {
2139
+ await memoryImport(flags)
2140
+ return
2141
+ }
2142
+ if (sub === 'gc') {
2143
+ await memoryGc(flags)
2144
+ return
2145
+ }
2159
2146
  console.error(`ancleto: subcomando de memory desconocido: ${sub || '(ninguno)'}`)
2160
2147
  console.error('ancleto: uso: ancleto memory context [--scope X] [--out file]')
2161
2148
  console.error('ancleto: uso: ancleto memory list [--type X] [--scope X] [--all] [--json]')
2162
2149
  console.error('ancleto: uso: ancleto memory doctor [--rebuild]')
2150
+ console.error('ancleto: uso: ancleto memory export [--out <archivo>]')
2151
+ console.error('ancleto: uso: ancleto memory import <archivo>')
2152
+ console.error('ancleto: uso: ancleto memory gc [--dry-run] [--days <n>]')
2163
2153
  process.exit(1)
2164
2154
  }
2165
2155
 
@@ -2219,6 +2209,13 @@ async function checkCommand() {
2219
2209
  console.log(` ⚠ ${dirRel}/${f} (huerfano)`)
2220
2210
  orphans++
2221
2211
  }
2212
+ // T3 (delta `frontmatter-adapters`): validacion no bloqueante del frontmatter
2213
+ // de agents contra el adaptador. Solo aplica a `cat === 'agents'` y a archivos
2214
+ // esperados (los huérfanos ya quedan avisados). Reporta como warning (⚠), no como
2215
+ // faltante (✖): el exit code NO se ve alterado por divergencias (solo por faltantes).
2216
+ if (cat === 'agents') {
2217
+ await checkAgentsFrontmatter(cwd, dirRel, actual, expected, ext)
2218
+ }
2222
2219
  if (missingFiles.length === 0 && orphanFiles.length === 0) {
2223
2220
  console.log(` ✔ ${dirRel} (${actual.length} archivos)`)
2224
2221
  }
@@ -2232,6 +2229,52 @@ async function checkCommand() {
2232
2229
  process.exit(missing > 0 ? 1 : 0)
2233
2230
  }
2234
2231
 
2232
+ // T3 (delta `frontmatter-adapters`): para cada `.md` de un directorio de agents
2233
+ // instalado, compara el contenido contra `adaptFrontmatter(source, host, 'agents', name)`.
2234
+ // Para opencode tambien aplica la sustitucion de `model:` del tier (paso del install que
2235
+ // se ejecuta despues del adaptador). Para copilot + orchestrator suma `COPILOT_MODEL_NOTE`.
2236
+ // Reporta divergencia como warning (no bloqueante). No compara archivos huérfanos (ya
2237
+ // quedan avisados arriba) ni archivos sin origen comparable.
2238
+ async function checkAgentsFrontmatter(cwd, dirRel, actual, expected, fileExt) {
2239
+ const host = hostForDir('agents', dirRel)
2240
+ if (!host) return
2241
+ // Mapa de modelos aplicado por install en opencode (model: <x> -> tier).
2242
+ // Para 'gratis' usa el gratisModel persistido en .ancletorc.
2243
+ let tierModelsMap = null
2244
+ if (host === 'opencode') {
2245
+ const tierFile = join(cwd, '.opencode', '.ancleto-tier')
2246
+ if (await exists(tierFile)) {
2247
+ const tier = (await readFile(tierFile, 'utf8')).trim()
2248
+ if (TIERS[tier]) {
2249
+ const rc = await readAncletorc(cwd)
2250
+ const persisted = isKnownGratisModel(rc?.gratisModel) ? rc.gratisModel : null
2251
+ tierModelsMap = tierModels(tier, TIERS, persisted)
2252
+ }
2253
+ }
2254
+ }
2255
+ const nameFromFile = (f) => fileExt ? f.slice(0, -fileExt.length) : f.replace(/\.md$/, '')
2256
+ for (const f of actual) {
2257
+ if (!expected.includes(f)) continue
2258
+ const name = nameFromFile(f)
2259
+ const srcPath = join(ROOT, 'agents', `${name}.md`)
2260
+ if (!(await exists(srcPath))) continue
2261
+ let source = await readFile(srcPath, 'utf8')
2262
+ // Para opencode + tier aplicado, simular la sustitucion que hace applyTier
2263
+ // sobre el campo `model:`. El resto lo gestiona el adaptador (identidad).
2264
+ if (tierModelsMap && tierModelsMap[name]) {
2265
+ source = source.replace(/^model: .*$/m, `model: ${tierModelsMap[name]}`)
2266
+ }
2267
+ let expectedContent = adaptFrontmatter(source, host, 'agents', name)
2268
+ if (host === 'copilot' && name === 'orchestrator') {
2269
+ expectedContent += COPILOT_MODEL_NOTE
2270
+ }
2271
+ const installed = await readFile(join(cwd, dirRel, f), 'utf8')
2272
+ if (installed !== expectedContent) {
2273
+ console.log(` ⚠ ${dirRel}/${f} (frontmatter diverge del adaptador para host '${host}')`)
2274
+ }
2275
+ }
2276
+ }
2277
+
2235
2278
  // Avisa si hay un tier del proyecto sin agentes locales donde aplicarlo:
2236
2279
  // el tier solo se aplica a los agentes instalados en el proyecto, no a los globales.
2237
2280
  async function checkTierOrphan(cwd) {
@@ -0,0 +1,186 @@
1
+ // Modulo puro de adaptacion de frontmatter (D2/D3/D4/D5/D8). Dueno unico de la
2
+ // transformacion del frontmatter: ningun otro punto del CLI SHALL transformarlo.
3
+ // Sin E/S, sin dependencias externas, sin estado global.
4
+ //
5
+ // Contrato publico: `adaptFrontmatter(content, host, assetKind, name)` retorna
6
+ // el contenido completo (frontmatter adaptado + body verbatim). `parseFrontmatter`
7
+ // y `serializeFrontmatter` se re-exportan para que `commandToSkill` en el CLI los
8
+ // pueda usar sin redefinirlos.
9
+
10
+ export const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
11
+
12
+ // Hosts conocidos por el modulo. Cualquier host fuera de este conjunto cae en la
13
+ // rama `unknown` (no aborta; emite aviso a stderr y retorna el contenido sin
14
+ // cambios).
15
+ const KNOWN_HOSTS = new Set(['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo', 'copilot'])
16
+
17
+ // Hosts para los que existe una transformacion documentada de agents
18
+ // (distinta de identidad). Cursor y Roo viven fuera: no hay formato de frontmatter
19
+ // propio, así que la adaptación es passthrough con aviso.
20
+ const TRANSFORM_AGENTS = new Set(['claude', 'vscode', 'antigravity', 'copilot'])
21
+
22
+ // Tabla unica (fijada contra la tabla oficial de frontmatter de Custom
23
+ // Subagents) de ids de Antigravity. Conjunto CERRADO: solo se emiten estos ids.
24
+ // Los ids solo-SDK (`find_file`, `edit_file`, `search_web`, `read_url_content`,
25
+ // ...), los de comunidad (`write_to_file`, `call_mcp_tool`,
26
+ // `multi_replace_file_content`, ...) y los de delegacion (`invoke_subagent`,
27
+ // `start_subagent`, `define_subagent`) NO se emiten: no estan confirmados en el
28
+ // frontmatter y un id inexistente cuelga el subagent (Known Issue). Una clave no
29
+ // listada aqui se omite con aviso a stderr, nunca en silencio.
30
+ export const ANTIGRAVITY_TOOL_MAP = {
31
+ read: 'view_file',
32
+ edit: 'replace_file_content',
33
+ grep: 'grep_search',
34
+ bash: 'run_command',
35
+ todowrite: 'manage_task'
36
+ }
37
+
38
+ // Parser propio sin dependencias. Cada entrada conserva su clave y sus lineas
39
+ // crudas (incluye block scalars y mapas anidados), preservando orden.
40
+ export function parseFrontmatter(content) {
41
+ const m = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n?)([\s\S]*)$/.exec(content)
42
+ if (!m) return { hasFrontmatter: false, content }
43
+ const entries = []
44
+ for (const line of m[2].split(/\r?\n/)) {
45
+ if (/^[A-Za-z0-9_-]+:/.test(line)) {
46
+ entries.push({ key: line.slice(0, line.indexOf(':')), lines: [line] })
47
+ } else if (entries.length) {
48
+ entries[entries.length - 1].lines.push(line)
49
+ } else {
50
+ entries.push({ key: null, lines: [line] })
51
+ }
52
+ }
53
+ return { hasFrontmatter: true, open: m[1], entries, close: m[3], nl: m[4], body: m[5] }
54
+ }
55
+
56
+ export function serializeFrontmatter(parsed) {
57
+ if (!parsed.hasFrontmatter) return parsed.content
58
+ const inner = parsed.entries.flatMap((e) => e.lines).join('\n')
59
+ return `${parsed.open}${inner}${parsed.close}${parsed.nl}${parsed.body}`
60
+ }
61
+
62
+ // Hijos directos del mapa `tools:` con su valor crudo (p. ej. `read: true`).
63
+ function parseToolFlags(entry) {
64
+ const flags = []
65
+ if (!entry) return flags
66
+ let childIndent = null
67
+ for (const line of entry.lines.slice(1)) {
68
+ const m = /^(\s+)([A-Za-z0-9_-]+):\s*(.*?)\s*$/.exec(line)
69
+ if (!m) continue
70
+ if (childIndent === null) childIndent = m[1].length
71
+ if (m[1].length !== childIndent) continue
72
+ flags.push({ key: m[2], value: m[3] })
73
+ }
74
+ return flags
75
+ }
76
+
77
+ function frontmatterValue(entry, fallback = null) {
78
+ if (!entry) return fallback
79
+ const m = /^[A-Za-z0-9_-]+:\s*(.*?)\s*$/.exec(entry.lines[0] || '')
80
+ return m ? m[1] : fallback
81
+ }
82
+
83
+ // D2/D3/D5/D8: adapta el frontmatter de un agent de opencode al de Antigravity.
84
+ // `name` (requerido por el host) se inyecta desde el nombre del archivo. `tools` se
85
+ // emite siempre como lista (el default del host es la lista vacia). Nunca se inventa
86
+ // un id: una clave sin id verificado se omite con aviso (un id inexistente cuelga el
87
+ // subagent).
88
+ function adaptAntigravityFrontmatter(content, name) {
89
+ const parsed = parseFrontmatter(content)
90
+ if (!parsed.hasFrontmatter) return content
91
+ const byKey = (k) => parsed.entries.find((e) => e.key === k)
92
+ // Claves que el adaptador gestiona: las dropeadas (AGENT_ADAPTER_DROP), las que
93
+ // re-emite (name/tools/mainAgent/subagent/model/commandExecutionPolicy) y los campos
94
+ // que preserva del origen (description/skills/mcpServers). Se filtran TODAS del
95
+ // conjunto conservado para que ninguna pueda quedar duplicada, y se re-emiten una
96
+ // sola vez en orden determinista, sin importar lo que declare la fuente.
97
+ const managed = new Set([
98
+ ...AGENT_ADAPTER_DROP,
99
+ 'name',
100
+ 'description',
101
+ 'mainAgent',
102
+ 'subagent',
103
+ 'commandExecutionPolicy',
104
+ 'mcpServers',
105
+ 'skills'
106
+ ])
107
+ // Se conservan las claves extra (no gestionadas); los campos preservados se
108
+ // re-emiten abajo desde su primera ocurrencia en el origen.
109
+ const kept = parsed.entries.filter((e) => e.key && !managed.has(e.key))
110
+
111
+ const toolIds = []
112
+ for (const { key, value } of parseToolFlags(byKey('tools'))) {
113
+ if (value === 'false') continue
114
+ if (key === 'skill') continue // no es tool: se cubre por `skills`/surfaceo automatico
115
+ const id = ANTIGRAVITY_TOOL_MAP[key]
116
+ if (id) {
117
+ toolIds.push(id)
118
+ continue
119
+ }
120
+ // Regla D3/D5. Un id fuera del frontmatter confirmado (exista o no en el
121
+ // SDK) no se emite: un id inexistente cuelga el subagent. Se omite con aviso,
122
+ // nunca en silencio. El uso de MCP NO se infiere desde claves desconocidas del
123
+ // mapa `tools`: se expresa por `mcpServers`/`.agents/mcp_config.json`.
124
+ console.error(`skip tool '${key}': no verified Antigravity id for agent '${name}'`)
125
+ }
126
+
127
+ const mode = frontmatterValue(byKey('mode'))
128
+ const mainAgent = mode === 'subagent' ? 'false' : 'true'
129
+ const subagent = mode === 'primary' ? 'false' : 'true'
130
+
131
+ // Orden determinista: name, description, tools, mainAgent, subagent, model,
132
+ // commandExecutionPolicy, mcpServers, skills, y luego las claves extra conservadas.
133
+ const out = [{ key: 'name', lines: [`name: ${name}`] }]
134
+ const description = byKey('description')
135
+ if (description) out.push(description)
136
+ out.push({ key: 'tools', lines: [`tools: [${toolIds.join(', ')}]`] })
137
+ out.push({ key: 'mainAgent', lines: [`mainAgent: ${mainAgent}`] })
138
+ out.push({ key: 'subagent', lines: [`subagent: ${subagent}`] })
139
+ out.push({ key: 'model', lines: [`model: inherit`] })
140
+ out.push({ key: 'commandExecutionPolicy', lines: [`commandExecutionPolicy: sandbox`] })
141
+ const mcpServers = byKey('mcpServers')
142
+ if (mcpServers) out.push(mcpServers)
143
+ const skills = byKey('skills')
144
+ if (skills) out.push(skills)
145
+ out.push(...kept)
146
+ parsed.entries = out
147
+ return serializeFrontmatter(parsed)
148
+ }
149
+
150
+ // Elimina las claves gestionadas por el adaptador (mode/color/temperature/
151
+ // permission/model/tools) preservando el resto y el body. Para claude, vscode y
152
+ // copilot. Si no hay frontmatter, retorna el contenido sin cambios.
153
+ function dropManagedKeys(content) {
154
+ const parsed = parseFrontmatter(content)
155
+ if (!parsed.hasFrontmatter) return content
156
+ parsed.entries = parsed.entries.filter((e) => !(e.key && AGENT_ADAPTER_DROP.has(e.key)))
157
+ return serializeFrontmatter(parsed)
158
+ }
159
+
160
+ // Punto de entrada publico. Funcion pura (D2/D4): `(content, host, assetKind, name)`
161
+ // → string (contenido completo con frontmatter adaptado + body verbatim).
162
+ // Sin E/S: ni leer ni escribir archivos. Sin estado global mutable.
163
+ //
164
+ // Reglas de dispatch (preservadas del spec `skill-frontmatter-adapters` y
165
+ // extendidas por este change):
166
+ // opencode → identity (passthrough)
167
+ // antigravity + agents → adaptAntigravityFrontmatter(content, name)
168
+ // antigravity + (skills/cmds) → identity
169
+ // claude/vscode/copilot + ags → dropManagedKeys(content)
170
+ // claude/vscode/copilot + sk → identity
171
+ // cursor/roo → identity + aviso stderr (sin formato documentado)
172
+ // host desconocido → identity + aviso stderr (no aborta)
173
+ export function adaptFrontmatter(content, host, assetKind, name) {
174
+ if (!KNOWN_HOSTS.has(host)) {
175
+ console.error(`unknown agent '${host}', passthrough`)
176
+ return content
177
+ }
178
+ if (host === 'cursor' || host === 'roo') {
179
+ console.error(`no documented frontmatter adaptation for host '${host}'`)
180
+ return content
181
+ }
182
+ if (assetKind !== 'agents') return content
183
+ if (!TRANSFORM_AGENTS.has(host)) return content // opencode u otro conocido sin transform
184
+ if (host === 'antigravity') return adaptAntigravityFrontmatter(content, name)
185
+ return dropManagedKeys(content)
186
+ }
@@ -14,6 +14,23 @@ const SCOPE_HIERARCHY = {
14
14
  }
15
15
 
16
16
  const PUBLIC_COLUMNS = 'n.memory_key, n.type, n.scope, n.content, n.justification, n.created_at'
17
+ const GC_DEFAULT_DAYS = 30
18
+
19
+ // Sanitizacion best-effort de paths absolutos en texto libre. Cubre los casos
20
+ // pedidos por el spec: `C:\...` en Windows, `/home/...` y `/Users/...` en Unix.
21
+ // No es exhaustiva (se podria perder un path sin barra inicial o con `~`); el
22
+ // usuario debe revisar el archivo antes de compartirlo.
23
+ const ABS_PATH_PATTERNS = [
24
+ /[A-Za-z]:\\[^\s]*\S/g,
25
+ /\/home\/[^\s)]+/g,
26
+ /\/Users\/[^\s)]+/g
27
+ ]
28
+ function sanitizePaths(text) {
29
+ if (!text) return text
30
+ let out = String(text)
31
+ for (const re of ABS_PATH_PATTERNS) out = out.replace(re, '<redacted>')
32
+ return out
33
+ }
17
34
 
18
35
  const STOPWORDS = new Set([
19
36
  'como', 'cómo', 'the', 'a', 'an', 'de', 'del', 'la', 'el', 'los', 'las', 'un', 'una', 'unos', 'unas',
@@ -80,11 +97,23 @@ export function createReadonlyMemoryEngine(dbPath = defaultMemoryDbPath()) {
80
97
  function buildEngine(db) {
81
98
 
82
99
  const findActive = db.prepare(`SELECT id FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
100
+ const findActiveRow = db.prepare(`SELECT id, created_at FROM memory_nodes WHERE memory_key = ? AND status = 'active'`)
101
+ const countSuperseded = db.prepare(`SELECT COUNT(*) AS c FROM memory_nodes WHERE memory_key = ? AND status = 'superseded'`)
83
102
  const supersede = db.prepare(`UPDATE memory_nodes SET status = 'superseded', superseded_by = ? WHERE id = ? AND status = 'active'`)
84
103
  const insertNode = db.prepare(
85
104
  `INSERT INTO memory_nodes (id, memory_key, type, scope, status, content, justification, superseded_by, source, confidence, created_at)
86
105
  VALUES (?, ?, ?, ?, 'active', ?, ?, NULL, ?, ?, ?)`
87
106
  )
107
+ const listActiveAll = db.prepare(
108
+ `SELECT memory_key, type, scope, content, justification, created_at FROM memory_nodes WHERE status = 'active' ORDER BY created_at, rowid`
109
+ )
110
+ const gcSelect = db.prepare(
111
+ `SELECT id, length(content) + length(justification) AS size FROM memory_nodes
112
+ WHERE status = 'superseded' AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-' || ? || ' days')`
113
+ )
114
+ const gcDelete = db.prepare(
115
+ `DELETE FROM memory_nodes WHERE status = 'superseded' AND created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-' || ? || ' days')`
116
+ )
88
117
 
89
118
  function buildWorkingContext(scope, maxTokens = MAX_TOKENS, cwd = process.cwd()) {
90
119
  const scopes = SCOPE_HIERARCHY[scope] || [scope]
@@ -128,7 +157,7 @@ function buildEngine(db) {
128
157
  const lim = Math.min(Math.max(Number(limit) || 10, 1), 50)
129
158
  const base = `SELECT ${PUBLIC_COLUMNS} FROM memory_fts f JOIN memory_nodes n ON n.rowid = f.rowid
130
159
  WHERE memory_fts MATCH ? AND n.status = 'active'`
131
- const run = (match, loose) => {
160
+ const run = (match, _loose) => {
132
161
  const sql = type
133
162
  ? db.prepare(`${base} AND n.type = ? ORDER BY rank, n.rowid LIMIT ?`)
134
163
  : db.prepare(`${base} ORDER BY rank, n.rowid LIMIT ?`)
@@ -166,7 +195,7 @@ function buildEngine(db) {
166
195
  const id = randomUUID()
167
196
  const source = String(context.source || 'runtime')
168
197
  const confidence = Math.min(Math.max(Number(context.confidence ?? 1), 0), 1)
169
- const created_at = new Date().toISOString()
198
+ const created_at = String(input.createdAt || new Date().toISOString())
170
199
 
171
200
  db.exec('BEGIN IMMEDIATE')
172
201
  let prev = null
@@ -210,6 +239,127 @@ function buildEngine(db) {
210
239
  return rows.map((r) => ({ ...publicNode(r), status: r.status }))
211
240
  }
212
241
 
242
+ // Devuelve los nodos activos en el formato de export. Sanitiza paths
243
+ // absolutos en `content` y `justification` antes de serializar. Solo nodos
244
+ // activos: los superseded son historia interna y no se exportan.
245
+ function exportActive() {
246
+ const rows = listActiveAll.all()
247
+ return rows.map((r) => ({
248
+ memory_key: r.memory_key,
249
+ type: r.type,
250
+ content: sanitizePaths(r.content),
251
+ justification: sanitizePaths(r.justification),
252
+ scope: r.scope,
253
+ createdAt: r.created_at
254
+ }))
255
+ }
256
+
257
+ // Importa un array validado. Cada entrada hace upsert por memory_key. Por
258
+ // decision aprobada, los superseded son historia y NO se reactivan: si la
259
+ // clave solo existe en ese estado, se ignora la entrada. Si existe activa y el
260
+ // createdAt del JSON es mas reciente, supersede el existente; si es mas
261
+ // antiguo o igual, no-op (idempotencia). Valida schema antes de procesar y
262
+ // aborta todo el batch si alguna entrada es invalida.
263
+ function importNodes(json) {
264
+ if (!Array.isArray(json)) throw new Error('importNodes: el argumento debe ser un array')
265
+ const required = ['memory_key', 'type', 'content', 'scope', 'createdAt']
266
+ for (let i = 0; i < json.length; i++) {
267
+ const entry = json[i]
268
+ if (!entry || typeof entry !== 'object') throw new Error(`entry[${i}] invalida: no es objeto`)
269
+ for (const k of required) {
270
+ if (entry[k] === undefined || entry[k] === null || entry[k] === '') {
271
+ throw new Error(`entry[${i}] invalida: falta campo obligatorio "${k}"`)
272
+ }
273
+ }
274
+ if (entry.type !== 'rule' && entry.type !== 'decision') {
275
+ throw new Error(`entry[${i}].type invalido: ${entry.type}`)
276
+ }
277
+ if (typeof entry.createdAt !== 'string' || Number.isNaN(Date.parse(entry.createdAt))) {
278
+ throw new Error(`entry[${i}].createdAt invalido: ${entry.createdAt}`)
279
+ }
280
+ }
281
+
282
+ const summary = { inserted: 0, updated: 0, skipped: 0 }
283
+ db.exec('BEGIN IMMEDIATE')
284
+ try {
285
+ for (const entry of json) {
286
+ const memory_key = String(entry.memory_key).trim()
287
+ const active = findActiveRow.get(memory_key)
288
+ if (active) {
289
+ const incomingTs = Date.parse(entry.createdAt)
290
+ const existingTs = Date.parse(active.created_at)
291
+ if (incomingTs > existingTs) {
292
+ const newId = randomUUID()
293
+ supersede.run(newId, active.id)
294
+ insertNode.run(
295
+ newId, memory_key, entry.type, entry.scope,
296
+ String(entry.content), String(entry.justification || ''),
297
+ 'import', 1, entry.createdAt
298
+ )
299
+ summary.updated++
300
+ } else {
301
+ summary.skipped++
302
+ }
303
+ continue
304
+ }
305
+ const supersededCount = countSuperseded.get(memory_key).c
306
+ if (supersededCount > 0) {
307
+ // Decision aprobada: superseded es historia, no se reactiva.
308
+ summary.skipped++
309
+ continue
310
+ }
311
+ insertNode.run(
312
+ randomUUID(), memory_key, entry.type, entry.scope,
313
+ String(entry.content), String(entry.justification || ''),
314
+ 'import', 1, entry.createdAt
315
+ )
316
+ summary.inserted++
317
+ }
318
+ db.exec('COMMIT')
319
+ } catch (err) {
320
+ db.exec('ROLLBACK')
321
+ throw err
322
+ }
323
+ return summary
324
+ }
325
+
326
+ // Purga nodos superseded cuya antiguedad (proxy: created_at, ya que el
327
+ // schema no expone superseded_at) supere `opts.days`. Con `opts.dryRun` solo
328
+ // reporta conteo y tamano estimado sin modificar la DB. Sin dryRun ejecuta
329
+ // el DELETE en transaccion propia y luego VACUUM + REINDEX fuera (SQLite no
330
+ // permite VACUUM dentro de una transaccion; si falla la compactacion la BD
331
+ // sigue consistente, solo no se reempaqueta).
332
+ function gcSuperseded(opts = {}) {
333
+ const days = Number.isFinite(opts.days) && opts.days > 0 ? Math.floor(opts.days) : GC_DEFAULT_DAYS
334
+ const dryRun = !!opts.dryRun
335
+
336
+ if (dryRun) {
337
+ const rows = gcSelect.all(days)
338
+ const nodes = rows.length
339
+ const estimated_bytes = rows.reduce((acc, r) => acc + Number(r.size || 0), 0)
340
+ return { dryRun: true, days, nodes, estimated_bytes }
341
+ }
342
+
343
+ db.exec('BEGIN IMMEDIATE')
344
+ let removed = 0
345
+ try {
346
+ const result = gcDelete.run(days)
347
+ removed = Number(result.changes || 0)
348
+ db.exec('COMMIT')
349
+ } catch (err) {
350
+ db.exec('ROLLBACK')
351
+ throw err
352
+ }
353
+ try {
354
+ db.exec('VACUUM')
355
+ db.exec('REINDEX')
356
+ } catch (err) {
357
+ // VACUUM/REINDEX son best-effort: si fallan, la BD esta consistente.
358
+ return { dryRun: false, days, removed, vacuumError: err.message }
359
+ }
360
+ return { dryRun: false, days, removed }
361
+ }
362
+
213
363
  function checkpoint() {
214
364
  return checkpointDatabase(db)
215
365
  }
@@ -218,5 +368,5 @@ function buildEngine(db) {
218
368
  db.close()
219
369
  }
220
370
 
221
- return { buildWorkingContext, searchMemory, recordNode, listNodes, checkpoint, close }
371
+ return { buildWorkingContext, searchMemory, recordNode, listNodes, exportActive, importNodes, gcSuperseded, checkpoint, close }
222
372
  }