@ancleto/spec 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,8 @@
31
31
  - [Referencia de comandos CLI](#referencia-de-comandos-cli)
32
32
  - [Motor de memoria persistente](#motor-de-memoria-persistente)
33
33
  - [Comandos del ciclo SDD en tu IDE](#comandos-del-ciclo-sdd-en-tu-ide)
34
+ - [Perfil de test automation](#perfil-de-test-automation)
35
+ - [Portabilidad entre máquinas](#portabilidad-entre-máquinas)
34
36
  - [Azure DevOps (opcional)](#azure-devops-opcional)
35
37
  - [Referencia: agentes y modelos por tier](#referencia-agentes-y-modelos-por-tier)
36
38
 
@@ -74,6 +76,12 @@ Ancleto resuelve esto centralizando las reglas de negocio en un motor de memoria
74
76
 
75
77
  **Wizard interactivo** — Banner animado y menús navegables con flechas en `init` e `install`, sin librerías pesadas.
76
78
 
79
+ **Hosts e IDEs (7)** — opencode, claude, vscode, antigravity, cursor, roo y copilot, cada uno con su layout nativo (Copilot: prompts en `.github/prompts` + MCP en `copilot-mcp.json`).
80
+
81
+ **Perfil de test automation** — `--profile test`: tester ampliado (planning/generation/healing/coverage), comandos `cleto-test-*` y estructura `testspec/` para proyectos Playwright.
82
+
83
+ **Portabilidad entre máquinas** — `ancleto export`/`import` mudan el proyecto regenerando el MCP local (`import --repair` repara rutas rotas sin bundle).
84
+
77
85
  ---
78
86
 
79
87
  ## Requisitos
@@ -141,6 +149,7 @@ del host (el CLI no deduplica ni elige un "ganador").
141
149
  | `antigravity` | `.agents/skills/<n>/SKILL.md` | `.agents/agents/<n>.md` (adaptado) | `.agents/skills/<n>/SKILL.md` (como skill) |
142
150
  | `cursor` | `.cursor/skills/<n>/SKILL.md` | — | — |
143
151
  | `roo` | `.roo/skills/<n>/SKILL.md` | — | — |
152
+ | `copilot` | — | `.github/prompts/<n>.prompt.md` | `.github/prompts/<n>.prompt.md` |
144
153
 
145
154
  Cada host recibe sus skills en **su** directorio nativo. **`.agents/skills` es un punto de lectura
146
155
  compartido** —lo leen opencode, Cursor, VS Code y Antigravity—, no un destino universal: Claude lee
@@ -174,7 +183,11 @@ incluyendo el servidor de memoria `ancleto-memory`; preserva `mcpServers` preexi
174
183
  homónimos. `.mcp.json` (Claude) y `.vscode/mcp.json` (VS Code) siguen fuera de alcance, al igual que el
175
184
  MCP global de Antigravity. `claude` se soporta con adaptación de frontmatter obligatoria para `agents`
176
185
  (se omiten `model`/`tools` sin equivalencia verificada y las claves
177
- `mode`/`color`/`temperature`/`permission`), sin MCP propio. Tanto `init` como `install --project`
186
+ `mode`/`color`/`temperature`/`permission`), sin MCP propio. **Copilot** instala agents y commands
187
+ como prompt files en `.github/prompts/*.prompt.md` (mismo adapter, sin `model:`) y su MCP en
188
+ `copilot-mcp.json` (merge en install, regeneración de rotas en upgrade, nunca toca tu
189
+ `copilot-instructions.md`); el modelo lo elegís en el picker —el tier solo marca el nivel de
190
+ esfuerzo—. Tanto `init` como `install --project`
178
191
  configuran el MCP del host en la misma ejecución; `--no-mcp` lo omite.
179
192
 
180
193
  ---
@@ -317,10 +330,10 @@ ancleto discovery # Genera .discovery-map.json y empaqueta el repo
317
330
  ## Referencia de comandos CLI
318
331
 
319
332
  ```bash
320
- ancleto init [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>] [--with-azure]
333
+ ancleto init [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>] [--with-azure] [--profile <perfil>]
321
334
  # Configura el proyecto (interactivo en TTY)
322
335
 
323
- ancleto install [--project <dir>] [--tier <nivel>] [--agent <nombre>] [--lang <codigo>] [--exclude <globs>] [--no-mcp]
336
+ ancleto install [--project <dir>] [--tier <nivel>] [--agent <nombre>] [--lang <codigo>] [--exclude <globs>] [--no-mcp] [--profile <perfil>]
324
337
  # Instala agentes, skills y templates
325
338
 
326
339
  ancleto update # Re-instala la última versión sobre lo existente
@@ -329,6 +342,13 @@ ancleto update # Re-instala la última versión sobre lo existente
329
342
 
330
343
  ancleto upgrade # Re-aplica templates y skills respetando tus personalizaciones
331
344
 
345
+ ancleto export [--tar <archivo>]
346
+ # Empaqueta lo portable del proyecto (bundle + manifest.json sin rutas)
347
+
348
+ ancleto import <bundle> [--repair] | ancleto import --repair
349
+ # Restaura el bundle regenerando el MCP local; --repair solo arregla
350
+ # entradas con command inexistente
351
+
332
352
  ancleto check # Verifica la integridad de la instalación (faltantes / huérfanos)
333
353
 
334
354
  ancleto specs check [--change <nombre>] [--json]
@@ -406,11 +426,43 @@ Una vez instalado, tu IDE expone el ciclo de vida completo como comandos barra:
406
426
  | `/cleto-ff` | Avanzar rápido con el contexto ya recuperado. |
407
427
  | `/cleto-apply` | Aplicar el código del change. |
408
428
  | `/cleto-verify` | Verificar reglas, tests y memoria antes de cerrar. |
429
+ | `/cleto-security` | Realizar una auditoría de seguridad integral en el proyecto o change. |
409
430
  | `/cleto-sync` | Sincronizar las specs con el estado del repositorio. |
410
431
  | `/cleto-archive` | Archivar el change y registrar aprendizajes. |
411
432
  | `/cleto-continue` | Retomar un change con artefactos pendientes. |
412
433
  | `/cleto-explore` | Explorar un problema sin comprometerse a implementar. |
413
434
  | `/cleto-onboard` | Recorrer el ciclo completo en modo tutorial. |
435
+ | `/cleto-bulk-archive` | Archivar varios changes juntos. |
436
+ | `/cleto-recall` | Recuperar memoria del proyecto antes de empezar. |
437
+ | `/cleto-transplant` | Mudar el proyecto entre máquinas (export/import). |
438
+
439
+ Con perfil `test` se suman `cleto-test-proposal`, `cleto-test-apply`, `cleto-test-heal`, `cleto-test-coverage` y `cleto-test-archive` (ver perfil abajo).
440
+
441
+ ---
442
+
443
+ ## Perfil de test automation
444
+
445
+ Para proyectos de test automation con Playwright:
446
+
447
+ ```bash
448
+ ancleto init --profile test # o: ancleto install --project . --profile test
449
+ ```
450
+
451
+ Instala un tester ampliado con 4 workflows (planning, generation, healing, coverage), comandos `cleto-test-*`, estructura `testspec/specs` + `testspec/changes` (o reuso de `aspec/`) y convenciones Playwright en `AGENTS.md`. El orchestrator rutea lo test-only al tester ampliado y lo mixto al SDD general. Sin Playwright instalado, los workflows degradan a análisis y lo reportan. Combinable con cualquier host (`--profile test --agent copilot`, etc.).
452
+
453
+ ---
454
+
455
+ ## Portabilidad entre máquinas
456
+
457
+ Al copiar un proyecto de una PC a otra, las rutas absolutas del MCP se rompen (caso real: Windows → Linux). Para mudarlo:
458
+
459
+ ```bash
460
+ ancleto export --tar traslado.tgz # en origen, antes de copiar
461
+ ancleto import traslado.tgz # en destino, regenera el MCP local + doctor
462
+ ancleto import --repair # sin bundle: repara entradas con command inexistente
463
+ ```
464
+
465
+ El bundle lleva solo lo portable (manifiesto con intención MCP, sin rutas ni secretos: nunca incluye credenciales, `service.json`, global ni `memory.db`). El import no pisa entradas MCP sanas.
414
466
 
415
467
  ---
416
468
 
@@ -234,6 +234,14 @@ Delegate directly to `@tester` when the request is limited to testing work such
234
234
 
235
235
  Do not use this category if the request is likely to require product-code changes, behavior changes, or broader requirement clarification.
236
236
 
237
+ ### Test profile routing (only when `.ancletorc` declares `profile: test`)
238
+
239
+ When the project uses the test automation profile, refine the classification above:
240
+
241
+ - Change is test-only (new/fixed coverage, no product-code change) → run the profile workflow: planning (`cleto-test-proposal`), generation (`cleto-test-apply`), healing (`cleto-test-heal`), coverage (`cleto-test-coverage`); delegate to the amplified `@tester`.
242
+ - Change is mixed (product code + tests) → follow the general SDD paths above, with the amplified `@tester` owning the testing stages.
243
+ - Testing changes live under `testspec/changes/` (or `aspec/changes/` when the project reuses aspec).
244
+
237
245
  ### 3. `direct-implementation`
238
246
 
239
247
  Skip `@spec-writer` and delegate directly to implementation when the request is limited to:
@@ -0,0 +1,7 @@
1
+ ---
2
+ description: Perform a comprehensive security audit of the codebase or a specific change
3
+ ---
4
+
5
+ Invoke the `ancleto-security` skill with the Skill tool and follow it exactly; do not improvise steps.
6
+
7
+ **Input**: The argument after `/cleto-security` is the optional target (e.g., a change name, a file path, or 'all'). If omitted, infer from context or ask the user.
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Move a project between machines via ancleto export/import
3
+ ---
4
+
5
+ Mudá un proyecto ancleto entre máquinas sin romper el MCP local.
6
+
7
+ **En la máquina origen** (antes de copiar el proyecto), parado en su raíz:
8
+
9
+ ```bash
10
+ ancleto export --tar traslado.tgz
11
+ ```
12
+
13
+ Llevá el proyecto + `traslado.tgz` a la máquina destino (cualquier medio: USB, red, nube).
14
+
15
+ **En la máquina destino** (tras copiar el proyecto), parado en su raíz:
16
+
17
+ ```bash
18
+ ancleto import traslado.tgz
19
+ ```
20
+
21
+ Esto aplica los portables (`.ancletorc`, templates, `aspec/`, agents, commands, skills),
22
+ regenera las entradas MCP con las rutas locales y corre `ancleto doctor` al final.
23
+
24
+ Si no tenés bundle y el `opencode.json` apunta a rutas inexistentes:
25
+
26
+ ```bash
27
+ ancleto import --repair
28
+ ```
29
+
30
+ Nunca se exportan credenciales, tokens, `service.json`, la config global ni `memory.db`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ancleto/spec",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Orquestador SDD liviano con subagentes optimizados para costo/tokens",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "commands",
13
13
  "skills",
14
14
  "templates",
15
+ "profiles",
15
16
  "src"
16
17
  ],
17
18
  "publishConfig": {
@@ -0,0 +1,35 @@
1
+ ---
2
+ description: Reviews test automation work for correctness, conventions, and approved-scope compliance (test profile)
3
+ mode: subagent
4
+ model: opencode-go/qwen3.6-plus
5
+ temperature: 0.1
6
+ color: '#ef4444'
7
+ tools:
8
+ read: true
9
+ write: false
10
+ edit: false
11
+ bash: false
12
+ ---
13
+
14
+ # Reviewer Agent (test automation profile)
15
+
16
+ You perform a final review of test automation work for this project.
17
+
18
+ Read `AGENTS.md` at the repo root for project-specific conventions, tech stack, and patterns.
19
+
20
+ ## Primary Responsibility
21
+
22
+ Review testing work using one of these approved inputs:
23
+
24
+ - An active testing change in `testspec/changes/{change-name}/` (or `aspec/changes/{change-name}/`)
25
+ - A direct test-only request explicitly approved by `@orchestrator`
26
+
27
+ ## What to check
28
+
29
+ - Every generated or healed test traces to an approved plan item or requirement; no orphan tests.
30
+ - Playwright patterns match the repo conventions (selectors, fixtures, timeouts); no invented patterns when an established one exists.
31
+ - Healing changed the smaller side: test updated for intentional behavior change, product code touched only within the approved scope — otherwise flag for `@orchestrator`.
32
+ - The Validation Ledger classifies every command (`passed`, `task-regression`, `pre-existing-unrelated`, `inconclusive`); failures outside task-owned files are not dismissed as out of scope.
33
+ - No production behavior change hides inside a "test-only" change.
34
+
35
+ You do not have Bash and must not run commands. You do not write specs. Only raise flags so `@orchestrator` can decide.
@@ -0,0 +1,68 @@
1
+ ---
2
+ description: Plans, generates, heals and covers Playwright automation tests (test profile)
3
+ mode: subagent
4
+ model: opencode-go/deepseek-v4.1-flash
5
+ temperature: 0.1
6
+ color: '#3b82f6'
7
+ tools:
8
+ read: true
9
+ write: true
10
+ edit: true
11
+ bash: true
12
+ permission:
13
+ bash:
14
+ '*': allow
15
+ '*az *': deny
16
+ ---
17
+
18
+ # Tester Agent (test automation profile)
19
+
20
+ You are the QA Automation Engineer for this project, running the **test automation profile**.
21
+ Everything in the base tester contract applies (input modes, bash rules, verification
22
+ priority, output expectations). This profile adds four test-automation workflows and
23
+ Playwright conventions below.
24
+
25
+ Read `AGENTS.md` at the repo root for project-specific testing conventions, frameworks, and patterns.
26
+
27
+ ## Playwright precondition
28
+
29
+ Before any workflow that executes tests, check whether Playwright is available in the
30
+ target project (`package.json` dependency or `playwright.config.*` at the repo root).
31
+
32
+ - If present: run the workflows normally.
33
+ - If absent: degrade with a clear message — deliver planning and coverage analysis
34
+ (no execution), and do NOT attempt healing or generation runs. Report the gap instead
35
+ of guessing.
36
+
37
+ ## Workflows
38
+
39
+ ### 1. planning
40
+
41
+ Propose what to test: read the approved change artifacts (or the orchestrator brief),
42
+ define the coverage target (critical flows, edge cases, regressions), and record it as
43
+ a testing change under `testspec/changes/{name}/` (or `aspec/changes/{name}/` when the
44
+ project reuses aspec). No test code yet.
45
+
46
+ ### 2. generation
47
+
48
+ Generate Playwright tests from the approved testing plan. Follow the repo's existing
49
+ test structure and patterns before introducing a new pattern. Each generated test maps
50
+ to a plan item; untestable items are reported as gaps, not skipped silently.
51
+
52
+ ### 3. healing
53
+
54
+ When a Playwright test fails: reproduce first, then diagnose (product bug vs stale
55
+ test vs environment). Fix the smaller side — update the test when behavior changed
56
+ intentionally, fix the product code only when the orchestrator scope allows it;
57
+ otherwise escalate back to `@orchestrator` for reclassification. Re-run to green.
58
+
59
+ ### 4. coverage
60
+
61
+ Report gaps between specs (source-of-truth and deltas) and existing tests: which
62
+ requirements lack automation, which tests lack a backing requirement. Output a
63
+ prioritized gap list, not new tests.
64
+
65
+ ## Output expectations (profile addition)
66
+
67
+ Append to the base summary: workflow used (planning/generation/healing/coverage),
68
+ Playwright availability, and the coverage gaps found (if any).
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Generate Playwright tests from an approved testing plan
3
+ ---
4
+
5
+ You are running the test-automation **generation** workflow under the test profile.
6
+
7
+ 1. Load the approved testing plan (`testspec/changes/{name}/` or the aspec change).
8
+ 2. Verify the Playwright precondition; if absent, stop and report instead of generating runnable tests.
9
+ 3. Generate one Playwright test per plan item using the repo's existing patterns. Map each test to its item.
10
+ 4. Report untestable items as gaps (never skip silently) and hand the new tests to validation (healing/coverage as needed).
@@ -0,0 +1,9 @@
1
+ ---
2
+ description: Archive a finished testing change
3
+ ---
4
+
5
+ You are running the test-automation **archive** workflow under the test profile.
6
+
7
+ 1. Verify the testing change is complete: plan approved, tests generated, suite green (or gaps explicitly accepted).
8
+ 2. Confirm the delta specs reflect the final tests (re-delegate updates when behavior changed mid-flight).
9
+ 3. Archive per the project's SDD close process (same rules as `cleto-archive`), keeping `testspec/` deltas consistent.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Report gaps between specs and existing automation
3
+ ---
4
+
5
+ You are running the test-automation **coverage** workflow under the test profile.
6
+
7
+ 1. Read the source-of-truth specs (`testspec/specs/` or `aspec/specs/`) plus active deltas.
8
+ 2. Map each requirement to the existing Playwright tests (no execution needed — static analysis suffices).
9
+ 3. Output a prioritized gap list: requirements without automation, tests without a backing requirement.
10
+ 4. Do not write new tests here; propose them via `cleto-test-proposal` when asked.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Diagnose and repair a failing Playwright test
3
+ ---
4
+
5
+ You are running the test-automation **healing** workflow under the test profile.
6
+
7
+ 1. Reproduce the failure first (run the single failing test, not the whole suite).
8
+ 2. Diagnose: product bug vs stale test vs environment issue.
9
+ 3. Fix the smaller side: update the test when the behavior change was intentional; touch product code only within the orchestrator-approved scope, otherwise escalate to `@orchestrator` for reclassification.
10
+ 4. Re-run to green and report cause, side fixed, and commands run.
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Propose what to automate (test planning) as a testing change
3
+ ---
4
+
5
+ You are running the test-automation **planning** workflow under the test profile.
6
+
7
+ 1. Read the approved change artifacts (or the orchestrator brief) and the current Playwright suite layout.
8
+ 2. Define the coverage target: critical flows, edge cases, regressions.
9
+ 3. Check the Playwright precondition (`package.json` / `playwright.config.*`): if absent, plan analysis-only and say so.
10
+ 4. Record the plan as a testing change under `testspec/changes/{name}/` (proposal + tasks; reuse `aspec/changes/{name}/` when the project reuses aspec). STOP and wait for approval before generating tests.
@@ -0,0 +1,14 @@
1
+ # AI Agents Context (test automation profile)
2
+
3
+ > Contexto global para agentes de IA en proyectos con perfil de test automation.
4
+
5
+ <!-- LOCKED: test-profile -->
6
+ ### Test automation profile (Playwright)
7
+
8
+ Este proyecto usa el perfil `test` de ancleto (`profile: test` en `.ancletorc`).
9
+
10
+ - Workflows: planning (`cleto-test-proposal`), generation (`cleto-test-apply`), healing (`cleto-test-heal`), coverage (`cleto-test-coverage`), archive (`cleto-test-archive`).
11
+ - Estructura: `testspec/specs/` (cobertura actual) + `testspec/changes/` (deltas), o reuso de `aspec/` cuando el proyecto ya lo usa.
12
+ - Framework: Playwright. Si no está instalado, los workflows degradan a análisis sin ejecución y lo reportan.
13
+ - Ruteo: cambio test-only → tester ampliado; cambio mixto → SDD general con apoyo del tester.
14
+ <!-- /LOCKED: test-profile -->
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: ancleto-security
3
+ description: Perform a comprehensive security audit of the codebase or a specific change. Checks for common vulnerabilities like SQL injection, XSS, hardcoded secrets, etc.
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 Security Audit
12
+
13
+ Perform a comprehensive security audit of the project.
14
+
15
+ **Input**: Optionally a change name or directory (e.g., `add-auth`, `src/`); if omitted, infer from context.
16
+
17
+ ## Steps
18
+
19
+ ### 1. Identify the Scope
20
+ Determine if the user wants to review a specific file, recent changes, or the whole project.
21
+
22
+ ### 2. Basic Static Analysis
23
+ Run existing built-in static analysis tools (e.g., `npm run lint`) to catch obvious security issues caught by linters.
24
+
25
+ ### 3. Agent Manual Review
26
+ Actively search for vulnerabilities in the target scope using file viewing and grep tools. Look for:
27
+ - **SQL Injections:** Check for concatenated queries instead of parameterized ones.
28
+ - **XSS (Cross-Site Scripting):** Review how user inputs are rendered in views.
29
+ - **Hardcoded Secrets:** Look for passwords, API tokens, or encryption keys in source code.
30
+ - **Command Injection:** Review usage of `exec`, `spawn`, or similar functions with unsanitized user input.
31
+ - **Path Traversal:** Check manipulation of file paths with user input.
32
+
33
+ ### 4. Create Audit Report
34
+ Generate an artifact named `security_audit_report.md` detailing the findings:
35
+ - Classify by severity: Critical, High, Medium, Low.
36
+ - Show exactly in which file and line the issue occurs.
37
+ - Propose a remediation or corrected code.
38
+
39
+ ### 5. Action Plan
40
+ Present the report to the user and offer to apply the security fixes.
package/src/cli/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs'
3
- import { cp, mkdir, access, writeFile, readFile, readdir } from 'node:fs/promises'
3
+ import { cp, mkdir, access, writeFile, readFile, readdir, mkdtemp, rm } from 'node:fs/promises'
4
4
  import { spawnSync } from 'node:child_process'
5
5
  import { createHash } from 'node:crypto'
6
6
  import { createInterface } from 'node:readline'
7
7
  import { DatabaseSync } from 'node:sqlite'
8
- import { join, dirname, resolve, basename, relative } from 'node:path'
8
+ import { join, dirname, resolve, basename, relative, isAbsolute } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { homedir, tmpdir } from 'node:os'
11
11
  import { createMemoryEngine, createReadonlyMemoryEngine, defaultMemoryDbPath } from '../core/memory/engine.js'
@@ -35,18 +35,19 @@ Uso:
35
35
  ancleto install --tier <nivel> normal | minimo | gratis (wizard interactivo en TTY)
36
36
  ancleto install --lang <codigo> auto | es | en | pt (idioma de los artifacts; auto = idioma de la conversacion)
37
37
  ancleto install --exclude <globs> Paths excluidos del discovery, coma-separados (ej: "**/*.png,docs")
38
- ancleto install --agent <nombre> opencode | claude | vscode | antigravity | cursor | roo (wizard si no esta guardado)
38
+ ancleto install --agent <nombre> opencode | claude | vscode | antigravity | cursor | roo | copilot (wizard si no esta guardado)
39
39
  Elige el layout nativo a materializar (directorio + convencion de
40
40
  nombre) y el adapter de frontmatter del host. NO arbitra el
41
41
  descubrimiento entre hosts: si dos leen el mismo directorio, eso es
42
42
  efecto del host (no se deduplica ni se elige "ganador").
43
43
  antigravity instala skills, agents adaptados y commands como skills
44
44
  (.agents/*) y mergea el MCP de workspace .agents/mcp_config.json.
45
+ ancleto install --profile <perfil> general | test (perfil de test automation con Playwright; persiste en .ancletorc)
45
46
  ancleto update [--project <dir>] Alias de install (re-instala sobre lo existente)
46
47
  Parado en un proyecto con .ancletorc opera sobre ese proyecto;
47
48
  usa --global para forzar el alcance global
48
49
  ancleto upgrade [--agent <nombre>] Re-aplica templates (LOCKED) y skills sobre el proyecto actual
49
- ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>] [--no-mcp]
50
+ ancleto init [--with-azure] [--agent <nombre>] [--tier <nivel>] [--lang <codigo>] [--exclude <globs>] [--no-mcp] [--profile <perfil>]
50
51
  Crea .ancletorc en el repositorio actual y configura el MCP del host
51
52
  (interactivo en TTY: banner + menu; Azure desactivado por defecto)
52
53
  ancleto discovery --check Estado del seed (READY/STALE/PARTIAL/MISSING)
@@ -78,6 +79,9 @@ Uso:
78
79
  ancleto mcp Servidor MCP de memoria propia (stdio) para tu IDE
79
80
  expone searchMemory, recordRule y recordDecision
80
81
  ancleto check Verifica integridad de archivos instalados vs manifiesto
82
+ ancleto export [--tar <file>] Empaqueta lo portable del proyecto (bundle + manifest.json sin rutas)
83
+ ancleto import <bundle> [--repair] Restaura el bundle regenerando el MCP local; --repair solo arregla entradas rotas
84
+ ancleto import --repair Repara in place el opencode.json con command inexistente
81
85
  ancleto doctor Diagnostica el entorno (Node, node:sqlite, opencode.json)
82
86
  ancleto --help Esta ayuda
83
87
  ancleto --version Version del paquete
@@ -613,7 +617,7 @@ async function applyTier(agentsDir, tier, models = null) {
613
617
  }
614
618
  }
615
619
 
616
- const SUPPORTED_AGENTS = ['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo']
620
+ const SUPPORTED_AGENTS = ['opencode', 'claude', 'vscode', 'antigravity', 'cursor', 'roo', 'copilot']
617
621
  const DEFAULT_AGENT = 'opencode'
618
622
 
619
623
  function askAgent() {
@@ -663,6 +667,43 @@ function langLabel(code) {
663
667
  return { auto: 'automatico (idioma de la conversacion)', es: 'espanol', en: 'ingles', pt: 'portugues' }[code] || code
664
668
  }
665
669
 
670
+ const SUPPORTED_PROFILES = ['general', 'test']
671
+ const DEFAULT_PROFILE = 'general'
672
+
673
+ function scanProfileFlag(args) {
674
+ const p = flagValue(args, '--profile')
675
+ if (!p) return null
676
+ const norm = p.toLowerCase() === 'test:playwright' ? 'test' : p.toLowerCase()
677
+ if (!SUPPORTED_PROFILES.includes(norm)) {
678
+ console.error(`ancleto: perfil invalido: ${p} (general|test)`)
679
+ process.exit(1)
680
+ }
681
+ return norm
682
+ }
683
+
684
+ function validProfile(value) {
685
+ return SUPPORTED_PROFILES.includes(value) ? value : null
686
+ }
687
+
688
+ // Raiz de activos segun perfil: el general usa el repo; el perfil test usa
689
+ // profiles/test/ como overlay (agents/commands/templates). skills/ es comun.
690
+ function profileAssetsRoot(profile) {
691
+ return profile === 'test' ? join(ROOT, 'profiles', 'test') : ROOT
692
+ }
693
+
694
+ // Overlay del perfil test sobre los destinos nativos del host (misma ext y
695
+ // mismo adapter que la instalacion base). El paquete base queda intacto.
696
+ async function installProfileOverlay(projectDir, agent) {
697
+ const model = AGENT_TARGETS[agent] || AGENT_TARGETS[DEFAULT_AGENT]
698
+ for (const cat of ['agents', 'commands']) {
699
+ const spec = model[cat]
700
+ if (!spec) continue
701
+ const src = join(ROOT, 'profiles', 'test', cat)
702
+ if (!(await exists(src))) continue
703
+ await installAssetFiles(cat, projectDir, spec, agent, src)
704
+ }
705
+ }
706
+
666
707
  const EXCLUDE_PRESETS = [
667
708
  { key: 'tests', label: 'tests (*.test.*, *.spec.*, __tests__)', recommended: true, globs: ['**/*.test.*', '**/*.spec.*', '**/__tests__/**'] },
668
709
  { key: 'assets', label: 'assets pesados (*.png, *.jpg, *.svg, *.ico, public/)', recommended: true, globs: ['**/*.png', '**/*.jpg', '**/*.svg', '**/*.ico', 'public'] },
@@ -778,6 +819,44 @@ async function mergeAntigravityMcp(projectDir, mcpMap) {
778
819
  return { file, added }
779
820
  }
780
821
 
822
+ // Una entrada MCP esta rota cuando su binario es una ruta inexistente en este
823
+ // host: absoluta posix ausente, estilo Windows (caso T480) o command ausente.
824
+ // Comandos relativos (npx, PATH) se presumen sanos.
825
+ function mcpCommandBroken(entry) {
826
+ const cmd = entry?.command
827
+ if (!Array.isArray(cmd) || cmd.length === 0) return true
828
+ const bin = cmd[0]
829
+ if (typeof bin !== 'string' || bin.length === 0) return true
830
+ if (/^[A-Za-z]:[\\/]/.test(bin)) return true
831
+ if (isAbsolute(bin)) return !existsSync(bin)
832
+ return false
833
+ }
834
+
835
+ // Merge no destructivo hacia copilot-mcp.json del proyecto (nunca toca
836
+ // copilot-instructions.md: ese archivo es del usuario).
837
+ async function mergeCopilotMcp(projectDir, mcpMap) {
838
+ if (Object.keys(mcpMap).length === 0) return { file: null, added: [] }
839
+ const file = join(projectDir, 'copilot-mcp.json')
840
+ let cfg = {}
841
+ if (await exists(file)) {
842
+ try {
843
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
844
+ } catch {
845
+ console.warn('ancleto: no se pudo leer copilot-mcp.json como JSON; MCP no se agrego ahi')
846
+ return { file, added: [] }
847
+ }
848
+ }
849
+ cfg.mcp = cfg.mcp || {}
850
+ const added = []
851
+ for (const [name, def] of Object.entries(mcpMap)) {
852
+ if (cfg.mcp[name]) continue
853
+ cfg.mcp[name] = def
854
+ added.push(name)
855
+ }
856
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
857
+ return { file, added }
858
+ }
859
+
781
860
  // D6: dueno unico del MCP de host. Antigravity mergea `.agents/mcp_config.json`;
782
861
  // el resto conserva `mergeMcp` sobre opencode.json (targetDir = .opencode del
783
862
  // proyecto o config global).
@@ -788,6 +867,36 @@ async function setupHostMcp(targetDir, mcpMap, { projectDir = null, agent = null
788
867
  return mergeMcp(targetDir, mcpMap)
789
868
  }
790
869
 
870
+ // Regenera entradas rotas y agrega ausentes en copilot-mcp.json (para upgrade).
871
+ async function refreshCopilotMcp(projectDir) {
872
+ const file = join(projectDir, 'copilot-mcp.json')
873
+ let cfg = {}
874
+ if (await exists(file)) {
875
+ try {
876
+ cfg = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, ''))
877
+ } catch {
878
+ cfg = {}
879
+ }
880
+ }
881
+ cfg.mcp = cfg.mcp || {}
882
+ const fresh = buildDefaultMcp({ withEngram: Boolean(cfg.mcp.engram) })
883
+ const fixed = []
884
+ for (const [name, def] of Object.entries(cfg.mcp)) {
885
+ if (mcpCommandBroken(def) && fresh[name]) {
886
+ cfg.mcp[name] = fresh[name]
887
+ fixed.push(name)
888
+ }
889
+ }
890
+ for (const [name, def] of Object.entries(fresh)) {
891
+ if (!cfg.mcp[name]) {
892
+ cfg.mcp[name] = def
893
+ fixed.push(`${name} (agregado)`)
894
+ }
895
+ }
896
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
897
+ return { file, fixed }
898
+ }
899
+
791
900
  async function copyAssets(dest) {
792
901
  await mkdir(dest, { recursive: true })
793
902
  for (const d of ASSETS) {
@@ -826,7 +935,14 @@ const AGENT_TARGETS = {
826
935
  // Cursor y Roo: solo skills (D3/D7). .agents/skills es punto de lectura
827
936
  // compartido (D2), no destino universal.
828
937
  cursor: { skills: { dir: '.cursor/skills' }, agents: null, commands: null },
829
- roo: { skills: { dir: '.roo/skills' }, agents: null, commands: null }
938
+ roo: { skills: { dir: '.roo/skills' }, agents: null, commands: null },
939
+ // Copilot: host de prompts. Agents y commands comparten .github/prompts con
940
+ // ext .prompt.md (sin colision: agents/*.md vs cleto-*.md). Sin skills (D12).
941
+ copilot: {
942
+ skills: null,
943
+ agents: { dir: '.github/prompts', ext: '.prompt.md' },
944
+ commands: { dir: '.github/prompts', ext: '.prompt.md' }
945
+ }
830
946
  }
831
947
 
832
948
  const ANCLETO_SKILLS = ['ancleto-new', 'ancleto-propose', 'ancleto-apply', 'ancleto-verify', 'ancleto-archive', 'ancleto-bulk-archive', 'ancleto-continue', 'ancleto-explore', 'ancleto-ff', 'ancleto-onboard', 'ancleto-workflow']
@@ -856,11 +972,22 @@ function serializeFrontmatter(parsed) {
856
972
  }
857
973
 
858
974
  // D4/D5: hosts cuyo formato de agents difiere del origen requieren adaptacion.
859
- // Claude/VS Code eliminan las claves de opencode; Antigravity usa una
975
+ // Claude/VS Code/Copilot eliminan las claves de opencode; Antigravity usa una
860
976
  // transformacion propia (D2). El resto es identidad (preservacion verbatim).
861
- const AGENT_ADAPTER_HOSTS = new Set(['claude', 'vscode', 'antigravity'])
977
+ const AGENT_ADAPTER_HOSTS = new Set(['claude', 'vscode', 'antigravity', 'copilot'])
862
978
  const AGENT_ADAPTER_DROP = new Set(['mode', 'color', 'temperature', 'permission', 'model', 'tools'])
863
979
 
980
+ // Solo Copilot: los tiers no cambian modelos (el usuario elige en el picker);
981
+ // el orchestrator lo recuerda al empezar.
982
+ const COPILOT_MODEL_NOTE = `
983
+ ## Nota de modelo (solo Copilot)
984
+
985
+ Elegí el modelo en el picker de Copilot antes de empezar: los tiers de ancleto
986
+ (normal|minimo|gratis) no cambian modelos acá, solo el nivel de esfuerzo:
987
+ normal = flujo completo, minimo = pasos agrupados y económicos, gratis = modelo
988
+ gratis disponible en tu cuenta.
989
+ `
990
+
864
991
  // D3: tabla unica (fijada contra la tabla oficial de frontmatter de Custom
865
992
  // Subagents) de ids de Antigravity. Conjunto CERRADO: solo se emiten estos ids.
866
993
  // Los ids solo-SDK (`find_file`, `edit_file`, `search_web`, `read_url_content`,
@@ -994,14 +1121,17 @@ async function copyDirTransformed(srcDir, destDir, host, assetKind) {
994
1121
  }
995
1122
  }
996
1123
 
997
- async function installAssetFiles(cat, projectDir, spec, host) {
1124
+ async function installAssetFiles(cat, projectDir, spec, host, srcDir = null) {
998
1125
  const destDir = join(projectDir, spec.dir)
999
1126
  await mkdir(destDir, { recursive: true })
1000
- for (const e of await readdir(join(ROOT, cat), { withFileTypes: true })) {
1127
+ const origin = srcDir || join(ROOT, cat)
1128
+ for (const e of await readdir(origin, { withFileTypes: true })) {
1001
1129
  if (!e.isFile() || !e.name.endsWith(MD_EXT)) continue
1002
1130
  const base = e.name.slice(0, -MD_EXT.length)
1003
- const content = await readFile(join(ROOT, cat, e.name), 'utf8')
1004
- await writeFile(join(destDir, `${base}${spec.ext}`), adaptFrontmatter(content, host, cat, base))
1131
+ const content = await readFile(join(origin, e.name), 'utf8')
1132
+ const adapted = adaptFrontmatter(content, host, cat, base)
1133
+ const body = (host === 'copilot' && cat === 'agents' && base === 'orchestrator') ? adapted + COPILOT_MODEL_NOTE : adapted
1134
+ await writeFile(join(destDir, `${base}${spec.ext}`), body)
1005
1135
  }
1006
1136
  }
1007
1137
 
@@ -1104,6 +1234,11 @@ async function scaffoldAspec(projectDir) {
1104
1234
  }
1105
1235
  }
1106
1236
 
1237
+ async function scaffoldTestspec(projectDir) {
1238
+ await mkdir(join(projectDir, 'testspec', 'specs'), { recursive: true })
1239
+ await mkdir(join(projectDir, 'testspec', 'changes'), { recursive: true })
1240
+ }
1241
+
1107
1242
  const LEGACY_OPENSPEC_DIR = 'openspec'
1108
1243
  const MIGRATION_MARKER = '.migrated-from-openspec'
1109
1244
 
@@ -1137,6 +1272,97 @@ async function migrateLegacyOpenspec(projectDir) {
1137
1272
  console.log('ancleto: contenido migrado de openspec/ a aspec/ (openspec/ se conserva como backup)')
1138
1273
  }
1139
1274
 
1275
+ // Clasifica openspec/ pre-existente: 'none' (ausente o carpeta vacia),
1276
+ // 'external' (layout OpenSpec estandar: specs/ con archivos, o AGENTS.md con
1277
+ // firma OpenSpec) o 'legacy' (resto: rama original, intacta).
1278
+ async function detectLegacyOpenSpec(projectDir) {
1279
+ const legacyDir = join(projectDir, LEGACY_OPENSPEC_DIR)
1280
+ let entries = []
1281
+ try {
1282
+ entries = await readdir(legacyDir)
1283
+ } catch {
1284
+ return 'none'
1285
+ }
1286
+ if (entries.length === 0) return 'none' // carpeta vacia: no-op silencioso
1287
+ try {
1288
+ if ((await readdir(join(legacyDir, 'specs'))).length > 0) return 'external'
1289
+ } catch {
1290
+ // sin specs/: sigue el resto de firmas
1291
+ }
1292
+ for (const candidate of [join(projectDir, 'AGENTS.md'), join(legacyDir, 'AGENTS.md')]) {
1293
+ try {
1294
+ if (/openspec/i.test(await readFile(candidate, 'utf8'))) return 'external'
1295
+ } catch {
1296
+ // archivo inexistente o ilegible: no aporta firma
1297
+ }
1298
+ }
1299
+ return 'legacy'
1300
+ }
1301
+
1302
+ async function askOpenspecImport() {
1303
+ if (!process.stdin.isTTY) return false
1304
+ const ask = (q) => new Promise((resolve) => {
1305
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1306
+ rl.question(q, (a) => { rl.close(); resolve(a.trim().toLowerCase()) })
1307
+ })
1308
+ let answer = await ask('ancleto: Detecté un proyecto OpenSpec. ¿Importar a aspec/? [s/N/ver] (default: No): ')
1309
+ if (answer === 'ver') {
1310
+ console.log('ancleto: se copiaran openspec/specs y openspec/changes a aspec/ (copia no destructiva; openspec/ se conserva como backup)')
1311
+ answer = await ask('ancleto: ¿Importar a aspec/? [s/N] (default: No): ')
1312
+ }
1313
+ return ['s', 'si', 'sí', 'y', 'yes'].includes(answer)
1314
+ }
1315
+
1316
+ // Importacion semilla de un OpenSpec externo: copia specs + changes, respeta el
1317
+ // AGENTS.md (nunca se pisa en silencio) y escribe el mismo marcador de
1318
+ // idempotencia. openspec/ siempre se conserva como backup.
1319
+ async function importExternalOpenspec(projectDir) {
1320
+ const aspecDir = join(projectDir, CHANGES_ROOT)
1321
+ const marker = join(aspecDir, MIGRATION_MARKER)
1322
+ if (await exists(marker)) return // ya importada: no-op silencioso
1323
+ if (await hasRealContent(aspecDir)) {
1324
+ console.warn('ancleto: aspec/ ya tiene contenido real — no se importo openspec/ (revisar manualmente; openspec/ se conserva)')
1325
+ return
1326
+ }
1327
+ await cp(join(projectDir, LEGACY_OPENSPEC_DIR), aspecDir, { recursive: true, force: false })
1328
+ const bundledAgents = join(projectDir, LEGACY_OPENSPEC_DIR, 'AGENTS.md')
1329
+ if (await exists(bundledAgents)) {
1330
+ const target = join(projectDir, 'AGENTS.md')
1331
+ if (!(await exists(target))) {
1332
+ await cp(bundledAgents, target)
1333
+ console.log('ancleto: AGENTS.md de OpenSpec adoptado como base (revisarlo)')
1334
+ } else {
1335
+ console.warn('ancleto: se conserva el AGENTS.md local; el de OpenSpec queda en openspec/ como backup')
1336
+ }
1337
+ } else {
1338
+ try {
1339
+ if (/openspec/i.test(await readFile(join(projectDir, 'AGENTS.md'), 'utf8'))) {
1340
+ console.log('ancleto: se respeta el AGENTS.md de OpenSpec local (bloques ancleto fusionados por LOCKED)')
1341
+ }
1342
+ } catch {
1343
+ // sin AGENTS.md local: nada que respetar
1344
+ }
1345
+ }
1346
+ await writeFile(marker, '# Generado por ancleto: esta aspec/ ya fue importada desde openspec/\n')
1347
+ console.log('ancleto: contenido importado de openspec/ a aspec/ (openspec/ se conserva como backup)')
1348
+ }
1349
+
1350
+ // Punto unico de deteccion/migracion: lo invocan init, install --project y
1351
+ // upgrade antes del scaffold. En no-TTY rige default No salvo --yes.
1352
+ async function maybeImportOpenspec(projectDir, args = []) {
1353
+ const kind = await detectLegacyOpenSpec(projectDir)
1354
+ if (kind === 'none') return
1355
+ if (kind === 'legacy') {
1356
+ await migrateLegacyOpenspec(projectDir)
1357
+ return
1358
+ }
1359
+ if (args.includes('--yes') || await askOpenspecImport()) {
1360
+ await importExternalOpenspec(projectDir)
1361
+ } else {
1362
+ console.log('ancleto: importacion de openspec/ omitida (openspec/ se conserva)')
1363
+ }
1364
+ }
1365
+
1140
1366
  function extractLockedBlocks(content) {
1141
1367
  const blocks = new Map()
1142
1368
  const re = /<!--\s*LOCKED:\s*([\w-]+)\s*-->([\s\S]*?)<!--\s*\/LOCKED:\s*\1\s*-->/g
@@ -1194,10 +1420,11 @@ function findInsertAnchor(local, source, name) {
1194
1420
 
1195
1421
  // Solo mergea los templates raiz (AGENTS.md/PRODUCT.md). Los assets por host
1196
1422
  // los materializa installAgentAssets (dueno unico).
1197
- async function copyTemplates(projectDir) {
1423
+ async function copyTemplates(projectDir, profile = DEFAULT_PROFILE) {
1198
1424
  for (const t of TEMPLATES) {
1199
1425
  const target = join(projectDir, t)
1200
- const source = await readFile(join(ROOT, 'templates', t), 'utf8')
1426
+ const profileTemplate = join(profileAssetsRoot(profile), 'templates', t)
1427
+ const source = await readFile(await exists(profileTemplate) ? profileTemplate : join(ROOT, 'templates', t), 'utf8')
1201
1428
  if (!(await exists(target))) {
1202
1429
  await writeFile(target, source)
1203
1430
  continue
@@ -1242,6 +1469,7 @@ async function install(args) {
1242
1469
 
1243
1470
  let agent = agentFlag || (existingRc?.agent && SUPPORTED_AGENTS.includes(existingRc.agent) ? existingRc.agent : null)
1244
1471
  let language = scanLangFlag(args) || (existingRc?.language && SUPPORTED_LANGS.includes(existingRc.language) ? existingRc.language : null)
1472
+ const profile = scanProfileFlag(args) || validProfile(existingRc?.profile) || DEFAULT_PROFILE
1245
1473
  const excludeFlag = scanExcludeFlag(args)
1246
1474
  const existingDiscovery = existingRc?.discovery || { outputDir: 'docs/technical-discovery', exclude: [] }
1247
1475
  let excludeChoice = excludeFlag
@@ -1289,14 +1517,17 @@ async function install(args) {
1289
1517
  }
1290
1518
 
1291
1519
  if (projectDir) {
1292
- await copyTemplates(projectDir)
1293
- await migrateLegacyOpenspec(projectDir)
1520
+ await copyTemplates(projectDir, profile)
1521
+ await maybeImportOpenspec(projectDir, args)
1294
1522
  await scaffoldAspec(projectDir)
1523
+ if (profile === 'test') await scaffoldTestspec(projectDir)
1295
1524
  await refreshWorkingContext(projectDir)
1296
1525
  const written = await installAgentAssets(projectDir, agent)
1526
+ if (profile === 'test') await installProfileOverlay(projectDir, agent)
1297
1527
  await writeManifest(projectDir, {
1298
1528
  agent,
1299
1529
  language,
1530
+ profile,
1300
1531
  ...(excludeChoice !== null ? { discovery: { ...existingDiscovery, exclude: excludeChoice } } : {}),
1301
1532
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1302
1533
  installedPaths: unionInstalledPaths(existingRc?.installedPaths, {
@@ -1306,6 +1537,9 @@ async function install(args) {
1306
1537
  })
1307
1538
  } else {
1308
1539
  await copyAssets(target)
1540
+ if (agent === 'copilot') {
1541
+ console.log('ancleto: los prompts de Copilot son por repo; corré install --project para generarlos')
1542
+ }
1309
1543
  }
1310
1544
 
1311
1545
  const models = tierModels(tier, TIERS, gratisModelChoice)
@@ -1327,6 +1561,10 @@ async function install(args) {
1327
1561
  }
1328
1562
 
1329
1563
  const res = await setupHostMcp(target, mcpMap, { projectDir, agent })
1564
+ if (projectDir && agent === 'copilot') {
1565
+ const cres = await mergeCopilotMcp(projectDir, mcpMap)
1566
+ if (cres.added.length) console.log(`ancleto: MCP configurados: ${cres.added.join(', ')} en ${cres.file}`)
1567
+ }
1330
1568
  const loc = projectDir
1331
1569
  ? `${projectDir} (layout ${agent} + templates en la raiz)`
1332
1570
  : `${target} (disponible en todos tus proyectos)`
@@ -1343,16 +1581,26 @@ async function upgradeCmd(args) {
1343
1581
  const projectDir = process.cwd()
1344
1582
  const rc = await readAncletorc(projectDir)
1345
1583
  if (!rc) {
1584
+ if ((await detectLegacyOpenSpec(projectDir)) !== 'none') {
1585
+ await maybeImportOpenspec(projectDir, args)
1586
+ await scaffoldAspec(projectDir)
1587
+ console.log("ancleto: openspec/ migrado. Corré 'ancleto init' para completar la configuración.")
1588
+ return
1589
+ }
1346
1590
  console.error("Error: No se encontro .ancletorc. Ejecuta 'ancleto init' primero.")
1347
1591
  process.exit(1)
1348
1592
  }
1349
- await migrateLegacyOpenspec(projectDir)
1593
+ await maybeImportOpenspec(projectDir, args)
1350
1594
  const agent = await resolveAgent(args, rc.agent)
1351
- await copyTemplates(projectDir)
1595
+ const profile = scanProfileFlag(args) || validProfile(rc.profile) || DEFAULT_PROFILE
1596
+ await copyTemplates(projectDir, profile)
1597
+ if (profile === 'test') await scaffoldTestspec(projectDir)
1352
1598
  const written = await installAgentAssets(projectDir, agent)
1599
+ if (profile === 'test') await installProfileOverlay(projectDir, agent)
1353
1600
  const wentContext = await refreshWorkingContext(projectDir)
1354
1601
  const manifest = await writeManifest(projectDir, {
1355
1602
  agent,
1603
+ profile,
1356
1604
  installedPaths: unionInstalledPaths(rc.installedPaths, {
1357
1605
  templates: ['AGENTS.md', 'PRODUCT.md'],
1358
1606
  ...written
@@ -1361,7 +1609,15 @@ async function upgradeCmd(args) {
1361
1609
  const skillsDir = written.skills[0] || '.opencode/skills'
1362
1610
  console.log(`ancleto: upgrade completo (v${manifest.version}, agente: ${agent})`)
1363
1611
  console.log(' ✔ templates re-aplicados (bloques LOCKED actualizados, EXTENSIBLE intacto)')
1364
- console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1612
+ if (agent === 'copilot') {
1613
+ const { file, fixed } = await refreshCopilotMcp(projectDir)
1614
+ console.log(` ✔ prompts regenerados en .github/prompts (copilot-instructions.md intacto)`)
1615
+ console.log(fixed.length
1616
+ ? ` ✔ MCP regenerados en ${file}: ${fixed.join(', ')}`
1617
+ : ` ✔ MCP sanos en ${file}, sin cambios`)
1618
+ } else {
1619
+ console.log(` ✔ skills actualizadas en ${skillsDir} (${ANCLETO_SKILLS.length} skills)`)
1620
+ }
1365
1621
  console.log(wentContext ? ' ✔ working-context.md regenerado' : ' ✔ working-context.md sin reglas activas (vacio)')
1366
1622
  console.log(' ✔ manifiesto .ancletorc actualizado')
1367
1623
  }
@@ -1373,6 +1629,7 @@ async function initProject(args) {
1373
1629
  const agentFlag = scanAgentFlag(args)
1374
1630
  const tierFlag = scanTierFlag(args)
1375
1631
  const langFlag = scanLangFlag(args)
1632
+ const profileFlag = scanProfileFlag(args)
1376
1633
  const existing = await readAncletorc(projectDir)
1377
1634
  const azure = existing?.azure ?? { enabled: false }
1378
1635
  let discovery = existing?.discovery ?? { outputDir: 'docs/technical-discovery', exclude: [] }
@@ -1382,6 +1639,7 @@ async function initProject(args) {
1382
1639
 
1383
1640
  let agent, tier, language
1384
1641
  language = langFlag || (existing?.language && SUPPORTED_LANGS.includes(existing.language) ? existing.language : null)
1642
+ const profile = profileFlag || validProfile(existing?.profile) || DEFAULT_PROFILE
1385
1643
  const isInteractive = Boolean(process.stdout.isTTY) && (!agentFlag || !tierFlag || !language || (excludeChoice === null && (discovery.exclude || []).length === 0))
1386
1644
  if (isInteractive) {
1387
1645
  await showBanner()
@@ -1430,22 +1688,25 @@ async function initProject(args) {
1430
1688
  gratisModelChoice = persisted || gratisModel()
1431
1689
  }
1432
1690
  const written = await installAgentAssets(projectDir, agent)
1691
+ if (profile === 'test') await installProfileOverlay(projectDir, agent)
1433
1692
  const manifest = await writeManifest(projectDir, {
1434
1693
  azure,
1435
1694
  discovery,
1436
1695
  agent,
1437
1696
  language,
1697
+ profile,
1438
1698
  ...(gratisModelChoice ? { gratisModel: gratisModelChoice } : {}),
1439
1699
  installedPaths: unionInstalledPaths(existing?.installedPaths, {
1440
1700
  templates: ['AGENTS.md', 'PRODUCT.md'],
1441
1701
  ...written
1442
1702
  })
1443
1703
  })
1444
- await copyTemplates(projectDir)
1704
+ await maybeImportOpenspec(projectDir, args)
1705
+ await copyTemplates(projectDir, profile)
1706
+ if (profile === 'test') await scaffoldTestspec(projectDir)
1445
1707
  if (tier) {
1446
1708
  await applyTier(join(projectDir, '.opencode', 'agents'), tier, tierModels(tier, TIERS, gratisModelChoice))
1447
1709
  }
1448
- await migrateLegacyOpenspec(projectDir)
1449
1710
  await scaffoldAspec(projectDir)
1450
1711
  await refreshWorkingContext(projectDir)
1451
1712
  await registerProject(projectDir, { agent, tier: tier || null })
@@ -2298,6 +2559,205 @@ async function statsCommand(args) {
2298
2559
  console.log(` totales (${shownLabel}): entrada ${fmtTokens(totals.input)} · salida ${fmtTokens(totals.output)} · razon ${fmtTokens(totals.reasoning)} · cache ${fmtTokens(totals.cacheRead)} · costo ${fmtCost(totals.cost)}`)
2299
2560
  }
2300
2561
 
2562
+ const EXPORT_FORMAT = 'ancleto-export/1'
2563
+
2564
+ async function readProjectMcp(projectDir) {
2565
+ for (const c of ['opencode.json', 'opencode.jsonc']) {
2566
+ const p = join(projectDir, '.opencode', c)
2567
+ if (await exists(p)) {
2568
+ try {
2569
+ return { file: p, cfg: JSON.parse((await readFile(p, 'utf8')).replace(/^\uFEFF/, '')) }
2570
+ } catch {
2571
+ return { file: p, cfg: {} }
2572
+ }
2573
+ }
2574
+ }
2575
+ return { file: join(projectDir, '.opencode', 'opencode.json'), cfg: {} }
2576
+ }
2577
+
2578
+ // Repara entradas rotas y agrega ausentes (solo las de intendedNames cuando se
2579
+ // indica; con null solo repara las existentes). Nunca toca las sanas.
2580
+ async function repairProjectMcp(projectDir, fresh, intendedNames = null) {
2581
+ const { file, cfg } = await readProjectMcp(projectDir)
2582
+ cfg.mcp = cfg.mcp || {}
2583
+ const fixed = []
2584
+ for (const [name, def] of Object.entries(cfg.mcp)) {
2585
+ if (mcpCommandBroken(def) && fresh[name]) {
2586
+ cfg.mcp[name] = fresh[name]
2587
+ fixed.push(name)
2588
+ }
2589
+ }
2590
+ if (intendedNames) {
2591
+ for (const name of intendedNames) {
2592
+ if (!cfg.mcp[name] && fresh[name]) {
2593
+ cfg.mcp[name] = fresh[name]
2594
+ fixed.push(`${name} (agregado)`)
2595
+ }
2596
+ }
2597
+ }
2598
+ await mkdir(dirname(file), { recursive: true })
2599
+ await writeFile(file, JSON.stringify(cfg, null, 2) + '\n')
2600
+ return { file, fixed }
2601
+ }
2602
+
2603
+ async function exportCmd(args) {
2604
+ const projectDir = process.cwd()
2605
+ const rc = await readAncletorc(projectDir)
2606
+ if (!rc) {
2607
+ console.error("Error: No se encontro .ancletorc. Ejecuta 'ancleto init' primero.")
2608
+ process.exit(1)
2609
+ }
2610
+ const { cfg } = await readProjectMcp(projectDir)
2611
+ const mcpIntent = Object.entries(cfg.mcp || {})
2612
+ .map(([name, def]) => ({ name, type: def?.type || 'local', enabled: def?.enabled !== false }))
2613
+ const tierFile = join(projectDir, '.opencode', '.ancleto-tier')
2614
+ const tier = (await exists(tierFile)) ? (await readFile(tierFile, 'utf8')).trim() : null
2615
+ const manifest = {
2616
+ format: EXPORT_FORMAT,
2617
+ ancletoVersion: await packageVersion(),
2618
+ exportedAt: new Date().toISOString(),
2619
+ agent: rc.agent || null,
2620
+ tier,
2621
+ language: rc.language || 'auto',
2622
+ profile: rc.profile || 'general',
2623
+ ...(rc.gratisModel ? { gratisModel: rc.gratisModel } : {}),
2624
+ flags: { azure: Boolean(rc.azure?.enabled), engram: mcpIntent.some((m) => m.name === 'engram') },
2625
+ mcp: mcpIntent
2626
+ }
2627
+ const serialized = JSON.stringify(manifest)
2628
+ if (serialized.includes(projectDir) || /[A-Za-z]:\\/.test(serialized) || serialized.includes(homedir())) {
2629
+ console.error('ancleto: el manifiesto contiene rutas absolutas; export abortado')
2630
+ process.exit(1)
2631
+ }
2632
+ const tarIdx = args.indexOf('--tar')
2633
+ if (tarIdx >= 0 && !args[tarIdx + 1]) {
2634
+ console.error('ancleto: --tar requiere un archivo destino')
2635
+ process.exit(1)
2636
+ }
2637
+ const tarFile = tarIdx >= 0 ? resolve(args[tarIdx + 1]) : null
2638
+ const outDir = tarFile ? await mkdtemp(join(tmpdir(), 'ancleto-export-')) : join(projectDir, 'ancleto-export')
2639
+ await mkdir(outDir, { recursive: true })
2640
+ // Allowlist portable: nunca se copian opencode.json (rutas absolutas),
2641
+ // .ancleto-tier, memory.db, service.json ni la config global.
2642
+ if (await exists(join(projectDir, '.ancletorc'))) await cp(join(projectDir, '.ancletorc'), join(outDir, '.ancletorc'))
2643
+ for (const t of TEMPLATES) {
2644
+ if (await exists(join(projectDir, t))) await cp(join(projectDir, t), join(outDir, t))
2645
+ }
2646
+ if (await exists(join(projectDir, 'aspec'))) await cp(join(projectDir, 'aspec'), join(outDir, 'aspec'), { recursive: true })
2647
+ for (const d of ASSETS) {
2648
+ const src = join(projectDir, '.opencode', d)
2649
+ if (await exists(src)) await cp(src, join(outDir, '.opencode', d), { recursive: true })
2650
+ }
2651
+ await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
2652
+ if (tarFile) {
2653
+ const r = spawnSync('tar', ['czf', tarFile, '-C', outDir, '.'], { encoding: 'utf8' })
2654
+ if (r.status !== 0) {
2655
+ console.error(`ancleto: no se pudo crear el tarball (${(r.stderr || '').trim() || 'tar no disponible'})`)
2656
+ process.exit(1)
2657
+ }
2658
+ await rm(outDir, { recursive: true, force: true })
2659
+ console.log(`ancleto: exportado a ${tarFile}`)
2660
+ } else {
2661
+ console.log(`ancleto: exportado a ${join(projectDir, 'ancleto-export')}`)
2662
+ }
2663
+ console.log(`ancleto: manifiesto v${manifest.ancletoVersion} con ${mcpIntent.length} MCP (solo intencion, sin rutas)`)
2664
+ }
2665
+
2666
+ async function importCmd(args) {
2667
+ const projectDir = process.cwd()
2668
+ const repairOnly = args.includes('--repair')
2669
+ const bundleArg = args.find((a) => !a.startsWith('-'))
2670
+ if (!repairOnly && !bundleArg) {
2671
+ console.error('ancleto: uso: ancleto import <bundle> [--repair] | ancleto import --repair')
2672
+ process.exit(1)
2673
+ }
2674
+ let manifest = null
2675
+ if (bundleArg) {
2676
+ const resolved = resolve(bundleArg)
2677
+ if (!existsSync(resolved)) {
2678
+ console.error(`ancleto: bundle no encontrado: ${bundleArg}`)
2679
+ process.exit(1)
2680
+ }
2681
+ let bundleDir = resolved
2682
+ if (/\.tgz$|\.tar\.gz$/.test(resolved)) {
2683
+ bundleDir = await mkdtemp(join(tmpdir(), 'ancleto-import-'))
2684
+ const r = spawnSync('tar', ['xzf', resolved, '-C', bundleDir], { encoding: 'utf8' })
2685
+ if (r.status !== 0) {
2686
+ console.error('ancleto: no se pudo extraer el bundle')
2687
+ process.exit(1)
2688
+ }
2689
+ }
2690
+ try {
2691
+ manifest = JSON.parse(await readFile(join(bundleDir, 'manifest.json'), 'utf8'))
2692
+ } catch {
2693
+ console.error('ancleto: bundle invalido (sin manifest.json)')
2694
+ process.exit(1)
2695
+ }
2696
+ if (manifest.format !== EXPORT_FORMAT) {
2697
+ console.error(`ancleto: formato de bundle no soportado: ${manifest.format}`)
2698
+ process.exit(1)
2699
+ }
2700
+ const localVersion = await packageVersion()
2701
+ if (manifest.ancletoVersion && manifest.ancletoVersion !== localVersion) {
2702
+ console.warn(`ancleto: el bundle es de v${manifest.ancletoVersion} y el CLI es v${localVersion} (se continua igual)`)
2703
+ }
2704
+ const bundleRc = await readAncletorc(bundleDir)
2705
+ if (bundleRc) {
2706
+ await writeManifest(projectDir, {
2707
+ ...(bundleRc.agent ? { agent: bundleRc.agent } : {}),
2708
+ ...(bundleRc.language ? { language: bundleRc.language } : {}),
2709
+ ...(bundleRc.profile ? { profile: bundleRc.profile } : {}),
2710
+ ...(bundleRc.gratisModel ? { gratisModel: bundleRc.gratisModel } : {}),
2711
+ ...(bundleRc.azure ? { azure: bundleRc.azure } : {}),
2712
+ ...(bundleRc.discovery ? { discovery: bundleRc.discovery } : {})
2713
+ })
2714
+ }
2715
+ if (await exists(join(bundleDir, 'aspec'))) {
2716
+ await cp(join(bundleDir, 'aspec'), join(projectDir, 'aspec'), { recursive: true, force: false })
2717
+ }
2718
+ for (const t of TEMPLATES) {
2719
+ const src = join(bundleDir, t)
2720
+ if (!(await exists(src))) continue
2721
+ const target = join(projectDir, t)
2722
+ const content = await readFile(src, 'utf8')
2723
+ if (!(await exists(target))) {
2724
+ await writeFile(target, content)
2725
+ continue
2726
+ }
2727
+ const current = await readFile(target, 'utf8')
2728
+ const merged = mergeLocked(content, current, t)
2729
+ if (merged !== current) await writeFile(target, merged)
2730
+ }
2731
+ for (const d of ASSETS) {
2732
+ const src = join(bundleDir, '.opencode', d)
2733
+ if (await exists(src)) await cp(src, join(projectDir, '.opencode', d), { recursive: true, force: false })
2734
+ }
2735
+ const rcNow = await readAncletorc(projectDir)
2736
+ const agent = (rcNow?.agent && SUPPORTED_AGENTS.includes(rcNow.agent)) ? rcNow.agent : DEFAULT_AGENT
2737
+ const importProfile = validProfile(rcNow?.profile) || DEFAULT_PROFILE
2738
+ await installAgentAssets(projectDir, agent)
2739
+ if (importProfile === 'test') await installProfileOverlay(projectDir, agent)
2740
+ if (manifest.tier && TIERS[manifest.tier]) {
2741
+ await applyTier(join(projectDir, '.opencode', 'agents'), manifest.tier, tierModels(manifest.tier, TIERS, manifest.gratisModel))
2742
+ }
2743
+ await scaffoldAspec(projectDir)
2744
+ console.log(`ancleto: bundle aplicado (agente: ${agent}${manifest.tier ? `, tier: ${manifest.tier}` : ''})`)
2745
+ }
2746
+ const withEngram = manifest
2747
+ ? manifest.flags?.engram === true
2748
+ : Boolean((await readProjectMcp(projectDir)).cfg.mcp?.engram)
2749
+ const fresh = buildDefaultMcp({ withEngram })
2750
+ if (manifest?.flags?.azure) {
2751
+ fresh['azure-devops'] = { type: 'local', enabled: true, command: ['npx', '-y', '@davstack/mcp-azure-devops'] }
2752
+ }
2753
+ const intended = manifest ? manifest.mcp.map((m) => m.name) : null
2754
+ const { file, fixed } = await repairProjectMcp(projectDir, fresh, intended)
2755
+ console.log(fixed.length
2756
+ ? `ancleto: MCP regenerados en ${file}: ${fixed.join(', ')}`
2757
+ : `ancleto: MCP sanos en ${file}, sin cambios`)
2758
+ await doctorCommand()
2759
+ }
2760
+
2301
2761
  async function doctorCommand() {
2302
2762
  let fatal = false
2303
2763
 
@@ -2383,6 +2843,12 @@ switch (cmd) {
2383
2843
  case 'doctor':
2384
2844
  await doctorCommand()
2385
2845
  break
2846
+ case 'export':
2847
+ await exportCmd(rest)
2848
+ break
2849
+ case 'import':
2850
+ await importCmd(rest)
2851
+ break
2386
2852
  case 'mcp':
2387
2853
  await serveMemoryMcp()
2388
2854
  break