@cristiancorreau/forge 2.9.6 → 2.9.8
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 +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/generate.js +1 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/lib/generators/codex.d.ts.map +1 -1
- package/dist/lib/generators/codex.js +10 -8
- package/dist/lib/generators/codex.js.map +1 -1
- package/dist/lib/generators/kiro.d.ts +1 -1
- package/dist/lib/generators/kiro.d.ts.map +1 -1
- package/dist/lib/generators/kiro.js +15 -16
- package/dist/lib/generators/kiro.js.map +1 -1
- package/dist/lib/generators/opencode.d.ts.map +1 -1
- package/dist/lib/generators/opencode.js +13 -12
- package/dist/lib/generators/opencode.js.map +1 -1
- package/dist/lib/paths.d.ts +1 -2
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +12 -16
- package/dist/lib/paths.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/assets/adapters/claude-code/generate-claude-md.py +0 -304
- package/assets/adapters/codex/generate-codex-config.py +0 -269
- package/assets/adapters/codex/hooks/codex.yaml.tpl +0 -43
- package/assets/adapters/codex/hooks/forge-codex-finish.sh +0 -158
- package/assets/adapters/codex/hooks/forge-codex-start.sh +0 -186
- package/assets/adapters/kiro/generate-steering.py +0 -367
- package/assets/adapters/opencode/generate-agents-md.py +0 -262
- package/assets/core/hooks/pre-bash-check.py +0 -202
- package/assets/core/hooks/pre-edit-check.py +0 -317
- package/assets/forge.py +0 -1265
- package/assets/requirements.txt +0 -2
- package/assets/scripts/aitmpl-search.py +0 -808
- package/assets/scripts/forge-add-opportunities.py +0 -92
- package/assets/scripts/forge-audit.py +0 -1061
- package/assets/scripts/forge-generate-all.py +0 -283
- package/assets/scripts/forge-init.py +0 -900
- package/assets/scripts/forge-migrate-project-yaml.py +0 -397
- package/assets/scripts/forge-scaffold-profile.py +0 -181
- package/assets/scripts/forge-teardown.py +0 -193
- package/assets/scripts/forge-validate-project-yaml.py +0 -457
- package/assets/scripts/forge-wizard.py +0 -1003
- package/assets/scripts/setup-codex.sh +0 -229
- package/assets/scripts/team-install.sh +0 -147
- package/assets/scripts/token-stats.py +0 -201
- package/dist/lib/python.d.ts +0 -4
- package/dist/lib/python.d.ts.map +0 -1
- package/dist/lib/python.js +0 -46
- package/dist/lib/python.js.map +0 -1
|
@@ -1,1061 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# Copyright 2026 Cristian Correa — Apache License 2.0
|
|
3
|
-
# https://github.com/cristiancorreau/forge
|
|
4
|
-
"""
|
|
5
|
-
forge-audit.py — Auditoría del estado actual de un proyecto vs el estándar forge.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
python3 .agentic/scripts/forge-audit.py
|
|
9
|
-
python3 .agentic/scripts/forge-audit.py --json # output JSON para integrar en CI
|
|
10
|
-
|
|
11
|
-
Qué reporta:
|
|
12
|
-
1. Health check por agente (.claude/agents/): frontmatter, secciones, modelo
|
|
13
|
-
2. Gap analysis vs forge: diferencia entre el agente del proyecto y la versión de forge
|
|
14
|
-
3. Oportunidades: profiles y skills disponibles en forge que el proyecto no usa
|
|
15
|
-
4. Huérfanos: agentes en .claude/ que no están declarados en project.yaml
|
|
16
|
-
|
|
17
|
-
Requiere: pyyaml → pip3 install -r .agentic/requirements.txt
|
|
18
|
-
"""
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
|
-
import re
|
|
22
|
-
import sys
|
|
23
|
-
from difflib import SequenceMatcher
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
import yaml
|
|
28
|
-
except ImportError:
|
|
29
|
-
print("ERROR: pip3 install -r .agentic/requirements.txt", file=sys.stderr)
|
|
30
|
-
sys.exit(1)
|
|
31
|
-
|
|
32
|
-
# ── ANSI colors (desactivar si no es TTY) ─────────────────────────────────────
|
|
33
|
-
USE_COLOR = sys.stdout.isatty()
|
|
34
|
-
def c(code: str, text: str) -> str:
|
|
35
|
-
return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
|
|
36
|
-
OK = lambda t: c("32", t) # verde
|
|
37
|
-
WARN= lambda t: c("33", t) # amarillo
|
|
38
|
-
ERR = lambda t: c("31", t) # rojo
|
|
39
|
-
INFO= lambda t: c("36", t) # cyan
|
|
40
|
-
BOLD= lambda t: c("1", t) # negrita
|
|
41
|
-
DIM = lambda t: c("2", t) # gris
|
|
42
|
-
|
|
43
|
-
# ── Secciones requeridas por el estándar ──────────────────────────────────────
|
|
44
|
-
REQUIRED_SECTIONS = ["## Reglas", "## No hagas"]
|
|
45
|
-
OPTIONAL_SECTIONS = ["## Workflow", "## Stack", "## Tu trabajo", "## Anti-patterns"]
|
|
46
|
-
|
|
47
|
-
REQUIRED_FRONTMATTER = ["name", "description", "model", "tools", "tier"]
|
|
48
|
-
|
|
49
|
-
STANDARD_VERSION = "1.0"
|
|
50
|
-
|
|
51
|
-
VALID_MODELS = {"opus", "sonnet", "haiku"}
|
|
52
|
-
# Tier 1 reviewers deben usar opus
|
|
53
|
-
OPUS_ROLES = {"compliance-reviewer", "security-auditor", "orchestrator"}
|
|
54
|
-
|
|
55
|
-
# Umbrales de similitud de texto (SequenceMatcher.ratio, escala 0-1).
|
|
56
|
-
# Calibración: agentes Tier 1 sin modificar tienen ratio ~0.95-1.0 vs forge.
|
|
57
|
-
# Agentes con especialización moderada (añadir 20-40 líneas) caen a ~0.65-0.80.
|
|
58
|
-
# Agentes reescritos para un dominio específico (Tier 3) caen a <0.40.
|
|
59
|
-
# Ajustar SIMILARITY_WARN a 0.70 para proyectos con fuerte especialización.
|
|
60
|
-
SIMILARITY_WARN = 0.80 # <80% → posibles mejoras disponibles en forge
|
|
61
|
-
SIMILARITY_OUTDATED = 0.50 # <50% → probablemente desactualizado o fork intencional
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
# ── Parsing ───────────────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
def find_project_root() -> Path:
|
|
67
|
-
here = Path.cwd()
|
|
68
|
-
for p in [here] + list(here.parents):
|
|
69
|
-
if (p / "project.yaml").exists():
|
|
70
|
-
return p
|
|
71
|
-
raise FileNotFoundError("No se encontró project.yaml")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def find_forge_dir() -> Path:
|
|
75
|
-
root = find_project_root()
|
|
76
|
-
for candidate in [root / ".agentic", root / "forge", Path(__file__).parent.parent]:
|
|
77
|
-
if (candidate / "core").exists():
|
|
78
|
-
return candidate
|
|
79
|
-
raise FileNotFoundError("No se encontró el directorio forge con core/")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def load_project(root: Path) -> dict:
|
|
83
|
-
with open(root / "project.yaml") as f:
|
|
84
|
-
return yaml.safe_load(f)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def parse_frontmatter(content: str) -> dict:
|
|
88
|
-
"""Extrae el bloque --- ... --- del inicio del archivo."""
|
|
89
|
-
m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
90
|
-
if not m:
|
|
91
|
-
return {}
|
|
92
|
-
try:
|
|
93
|
-
return yaml.safe_load(m.group(1)) or {}
|
|
94
|
-
except Exception:
|
|
95
|
-
return {}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def read_agent(path: Path) -> dict:
|
|
99
|
-
"""Lee un archivo de agente y retorna su metadata + contenido."""
|
|
100
|
-
content = path.read_text()
|
|
101
|
-
fm = parse_frontmatter(content)
|
|
102
|
-
return {
|
|
103
|
-
"path": path,
|
|
104
|
-
"name": path.stem,
|
|
105
|
-
"content": content,
|
|
106
|
-
"frontmatter": fm,
|
|
107
|
-
"lines": len(content.splitlines()),
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def get_forge_version(forge, name, profiles):
|
|
112
|
-
"""
|
|
113
|
-
Busca la versión forge de un agente.
|
|
114
|
-
Prioridad: profiles (en orden) → core.
|
|
115
|
-
Retorna (path, source_label) o (None, "").
|
|
116
|
-
"""
|
|
117
|
-
for profile in profiles:
|
|
118
|
-
p = forge / "profiles" / profile / "agents" / f"{name}.md"
|
|
119
|
-
if p.exists():
|
|
120
|
-
return p, f"profile:{profile}"
|
|
121
|
-
p = forge / "core" / "agents" / f"{name}.md"
|
|
122
|
-
if p.exists():
|
|
123
|
-
return p, "core"
|
|
124
|
-
return None, ""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def similarity(a: str, b: str) -> float:
|
|
128
|
-
return SequenceMatcher(None, a, b).ratio()
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# ── Checks ────────────────────────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
def check_frontmatter(agent):
|
|
134
|
-
issues = []
|
|
135
|
-
fm = agent["frontmatter"]
|
|
136
|
-
|
|
137
|
-
if not fm:
|
|
138
|
-
issues.append({"level": "error", "msg": "Sin bloque frontmatter (--- ... ---)"})
|
|
139
|
-
return issues
|
|
140
|
-
|
|
141
|
-
for field in REQUIRED_FRONTMATTER:
|
|
142
|
-
if field not in fm:
|
|
143
|
-
issues.append({"level": "error", "msg": f"Falta campo '{field}' en frontmatter"})
|
|
144
|
-
|
|
145
|
-
if "description" in fm:
|
|
146
|
-
desc = str(fm["description"])
|
|
147
|
-
if len(desc) > 120:
|
|
148
|
-
issues.append({"level": "warn", "msg": f"description muy larga ({len(desc)} chars) — debe caber en 1 línea clara"})
|
|
149
|
-
if "NO" not in desc.upper() and "no trabaja" not in desc.lower() and "scope" not in desc.lower():
|
|
150
|
-
issues.append({"level": "warn", "msg": "description no declara scope explícito (qué NO hace / en qué directorio trabaja)"})
|
|
151
|
-
|
|
152
|
-
if "model" in fm:
|
|
153
|
-
model = str(fm["model"])
|
|
154
|
-
if model not in VALID_MODELS:
|
|
155
|
-
issues.append({"level": "error", "msg": f"model '{model}' inválido — usar: opus | sonnet | haiku"})
|
|
156
|
-
name = agent["name"]
|
|
157
|
-
if name in OPUS_ROLES and model != "opus":
|
|
158
|
-
issues.append({"level": "warn", "msg": f"'{name}' debería usar model: opus (toma decisiones complejas)"})
|
|
159
|
-
if name not in OPUS_ROLES and model == "opus":
|
|
160
|
-
issues.append({"level": "warn", "msg": "model: opus en agente de implementación — sonnet es suficiente y más económico"})
|
|
161
|
-
|
|
162
|
-
if "last_verified" in fm:
|
|
163
|
-
try:
|
|
164
|
-
import datetime as _dt
|
|
165
|
-
lv = str(fm["last_verified"]) # formato "YYYY-MM"
|
|
166
|
-
year, month = int(lv[:4]), int(lv[5:7])
|
|
167
|
-
verified_date = _dt.date(year, month, 1)
|
|
168
|
-
months_old = (
|
|
169
|
-
(_dt.date.today().year - verified_date.year) * 12
|
|
170
|
-
+ _dt.date.today().month - verified_date.month
|
|
171
|
-
)
|
|
172
|
-
if months_old >= 6:
|
|
173
|
-
issues.append({
|
|
174
|
-
"level": "warn",
|
|
175
|
-
"msg": f"last_verified hace {months_old} meses — verificar que las APIs de terceros siguen vigentes",
|
|
176
|
-
"fix": f"Actualizar last_verified en frontmatter tras revisar la documentación del proveedor",
|
|
177
|
-
})
|
|
178
|
-
except Exception:
|
|
179
|
-
pass
|
|
180
|
-
|
|
181
|
-
if "standard_version" not in fm:
|
|
182
|
-
issues.append({
|
|
183
|
-
"field": "standard_version",
|
|
184
|
-
"level": "info",
|
|
185
|
-
"msg": "Campo 'standard_version' no presente — agente generado antes de standard v1.0",
|
|
186
|
-
})
|
|
187
|
-
elif fm.get("standard_version") != STANDARD_VERSION:
|
|
188
|
-
issues.append({
|
|
189
|
-
"field": "standard_version",
|
|
190
|
-
"level": "info",
|
|
191
|
-
"msg": f"standard_version '{fm['standard_version']}' difiere de la actual '{STANDARD_VERSION}'",
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
return issues
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def check_sections(agent):
|
|
198
|
-
issues = []
|
|
199
|
-
content = agent["content"]
|
|
200
|
-
|
|
201
|
-
for section in REQUIRED_SECTIONS:
|
|
202
|
-
if section not in content:
|
|
203
|
-
# Buscar variantes comunes
|
|
204
|
-
section_key = section.replace("## ", "").lower()
|
|
205
|
-
variants = [f"## anti-patterns", f"## no hacer", f"## restricciones"]
|
|
206
|
-
found_variant = any(v in content.lower() for v in variants)
|
|
207
|
-
if not found_variant:
|
|
208
|
-
issues.append({"level": "error", "msg": f"Falta sección '{section}' (requerida por estándar)"})
|
|
209
|
-
|
|
210
|
-
if agent["lines"] < 15:
|
|
211
|
-
issues.append({"level": "warn", "msg": f"Agente muy corto ({agent['lines']} líneas) — probablemente incompleto"})
|
|
212
|
-
|
|
213
|
-
return issues
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def check_vs_forge(agent, forge, profiles):
|
|
217
|
-
issues = []
|
|
218
|
-
tier = agent["frontmatter"].get("tier")
|
|
219
|
-
|
|
220
|
-
# Tier 3 (dominio): no existe en forge por diseño — correcto
|
|
221
|
-
if tier == 3:
|
|
222
|
-
issues.append({"level": "ok", "msg": "Tier 3 (dominio) — no se compara con forge"})
|
|
223
|
-
return issues
|
|
224
|
-
|
|
225
|
-
forge_path, source = get_forge_version(forge, agent["name"], profiles)
|
|
226
|
-
|
|
227
|
-
if forge_path is None:
|
|
228
|
-
if not tier:
|
|
229
|
-
# Sin tier: posiblemente Tier 3 no anotado, reportar como sugerencia
|
|
230
|
-
issues.append({"level": "info", "msg": "No encontrado en forge — si es Tier 3, agregar 'tier: 3' al frontmatter"})
|
|
231
|
-
elif tier in (1, 2):
|
|
232
|
-
issues.append({"level": "warn", "msg": f"Tier {tier} declarado pero no existe en forge — ¿falta crear el profile?"})
|
|
233
|
-
return issues
|
|
234
|
-
|
|
235
|
-
forge_content = forge_path.read_text()
|
|
236
|
-
ratio = similarity(agent["content"], forge_content)
|
|
237
|
-
forge_lines = len(forge_content.splitlines())
|
|
238
|
-
project_lines = agent["lines"]
|
|
239
|
-
|
|
240
|
-
extended = project_lines > forge_lines * 1.2 # proyecto tiene >20% más líneas
|
|
241
|
-
# Divergencia simétrica: mismo orden de magnitud, contenido diferente (especialización)
|
|
242
|
-
comparable = forge_lines * 0.7 <= project_lines <= forge_lines * 1.2
|
|
243
|
-
|
|
244
|
-
if ratio >= SIMILARITY_WARN:
|
|
245
|
-
issues.append({"level": "ok", "msg": f"Al día con forge ({source}) — similitud {ratio:.0%}"})
|
|
246
|
-
elif ratio >= SIMILARITY_OUTDATED:
|
|
247
|
-
if extended or comparable:
|
|
248
|
-
diff_lines = project_lines - forge_lines
|
|
249
|
-
sign = "+" if diff_lines >= 0 else ""
|
|
250
|
-
issues.append({
|
|
251
|
-
"level": "info",
|
|
252
|
-
"msg": f"Especialización intencional vs forge ({source}) — similitud {ratio:.0%}, {sign}{diff_lines} líneas respecto a forge",
|
|
253
|
-
})
|
|
254
|
-
else:
|
|
255
|
-
diff_lines = forge_lines - project_lines
|
|
256
|
-
issues.append({
|
|
257
|
-
"level": "warn",
|
|
258
|
-
"msg": f"Posibles mejoras disponibles en forge ({source}) — similitud {ratio:.0%}, forge tiene +{diff_lines} líneas (puede ser reescritura intencional — verificar manualmente)",
|
|
259
|
-
"may_be_intentional": True,
|
|
260
|
-
"fix": f"python3 .agentic/scripts/forge-init.py --tool claude-code --force --only={agent['name']}"
|
|
261
|
-
})
|
|
262
|
-
else:
|
|
263
|
-
if extended:
|
|
264
|
-
diff_lines = project_lines - forge_lines
|
|
265
|
-
issues.append({
|
|
266
|
-
"level": "info",
|
|
267
|
-
"msg": f"Muy extendido vs forge ({source}) — similitud {ratio:.0%}, proyecto tiene +{diff_lines} líneas (fork intencional)",
|
|
268
|
-
})
|
|
269
|
-
elif comparable:
|
|
270
|
-
issues.append({
|
|
271
|
-
"level": "warn",
|
|
272
|
-
"msg": f"Contenido muy diferente a forge ({source}) — similitud {ratio:.0%}. ¿Especialización intencional o desactualizado?",
|
|
273
|
-
"fix": f"Revisar manualmente vs python3 .agentic/scripts/forge-init.py --tool claude-code --force --only={agent['name']}"
|
|
274
|
-
})
|
|
275
|
-
else:
|
|
276
|
-
issues.append({
|
|
277
|
-
"level": "error",
|
|
278
|
-
"msg": f"Muy diferente a forge ({source}) — similitud {ratio:.0%}. Probablemente desactualizado (diferencia mayor al 50% — verificar si coincide con cambio de versión de forge)",
|
|
279
|
-
"fix": f"python3 .agentic/scripts/forge-init.py --tool claude-code --force --only={agent['name']}"
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
return issues
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
# ── Relevancia de profiles por stack ─────────────────────────────────────────
|
|
286
|
-
|
|
287
|
-
# Mapeo profile → condiciones que lo hacen relevante.
|
|
288
|
-
# Una condición es un dict {campo: conjunto_de_valores_match}.
|
|
289
|
-
# El profile es relevante si CUALQUIER condición se cumple.
|
|
290
|
-
import textwrap as _textwrap
|
|
291
|
-
|
|
292
|
-
# ── Catálogos de descripción para UI ─────────────────────────────────────────
|
|
293
|
-
|
|
294
|
-
# (qué hace, qué ganás, trigger)
|
|
295
|
-
_SKILL_INFO: dict[str, tuple[str, str, str]] = {
|
|
296
|
-
"security-audit": (
|
|
297
|
-
"Checklist de seguridad para endpoints, auth y datos sensibles.",
|
|
298
|
-
"Detecta vulnerabilidades antes de cada PR. Agnóstico al stack.",
|
|
299
|
-
"/security-audit",
|
|
300
|
-
),
|
|
301
|
-
"db-migrate": (
|
|
302
|
-
"Flujo seguro de migraciones (Prisma, Drizzle, Rails, Alembic, Goose).",
|
|
303
|
-
"Evita pérdida de datos y conflictos al cambiar el schema.",
|
|
304
|
-
"/db-migrate",
|
|
305
|
-
),
|
|
306
|
-
"local2prod": (
|
|
307
|
-
"Deploy completo a producción (Vercel, Railway, Fly.io, GitHub Actions).",
|
|
308
|
-
"Garantiza que el build y el runtime estén ok antes de cerrar la tarea.",
|
|
309
|
-
"/local2prod",
|
|
310
|
-
),
|
|
311
|
-
"new-feature": (
|
|
312
|
-
"Orquesta planificación, implementación y deploy de una feature completa.",
|
|
313
|
-
"Mantiene el flujo consistente desde spec hasta producción.",
|
|
314
|
-
"/new-feature",
|
|
315
|
-
),
|
|
316
|
-
"browser-test": (
|
|
317
|
-
"Automatización de navegador: screenshots y flujos visuales vía CDP.",
|
|
318
|
-
"Verifica el UI en el browser antes de dar cualquier tarea por terminada.",
|
|
319
|
-
"/browser-test",
|
|
320
|
-
),
|
|
321
|
-
"wiki-ingest": (
|
|
322
|
-
"Ingesta documentos y fuentes en el wiki del proyecto.",
|
|
323
|
-
"Construye base de conocimiento persistente entre sesiones de trabajo.",
|
|
324
|
-
"/wiki-ingest",
|
|
325
|
-
),
|
|
326
|
-
"wiki-query": (
|
|
327
|
-
"Responde preguntas usando el wiki como base de conocimiento.",
|
|
328
|
-
"El agente recuerda decisiones, convenciones y contexto del proyecto.",
|
|
329
|
-
"/wiki-query",
|
|
330
|
-
),
|
|
331
|
-
"wiki-lint": (
|
|
332
|
-
"Verifica integridad del wiki: índice, links, páginas huérfanas.",
|
|
333
|
-
"Mantiene el wiki saludable y navegable con auto-reparación.",
|
|
334
|
-
"/wiki-lint",
|
|
335
|
-
),
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
# (descripción, agentes que provee)
|
|
339
|
-
_PROFILE_INFO: dict[str, tuple[str, str]] = {
|
|
340
|
-
"hono-drizzle": ("API TypeScript con Hono + Drizzle ORM.", "api-engineer"),
|
|
341
|
-
"nextjs-admin": ("Panel admin con Next.js + React.", "admin-engineer"),
|
|
342
|
-
"astro": ("Sitios estáticos/híbridos con Astro.", "frontend-engineer"),
|
|
343
|
-
"vuenuxt": ("Aplicaciones Vue 3 / Nuxt 3.", "frontend-engineer"),
|
|
344
|
-
"sveltekit": ("Aplicaciones SvelteKit.", "frontend-engineer"),
|
|
345
|
-
"fastapi": ("API Python con FastAPI + Pydantic.", "api-engineer"),
|
|
346
|
-
"django": ("Full-stack Python con Django.", "api-engineer"),
|
|
347
|
-
"rails": ("Full-stack Ruby on Rails.", "api-engineer"),
|
|
348
|
-
"express": ("API Node.js con Express.", "api-engineer"),
|
|
349
|
-
"nestjs": ("API Node.js modular con NestJS.", "api-engineer"),
|
|
350
|
-
"go-gin": ("API Go de alta performance con Gin.", "api-engineer"),
|
|
351
|
-
"laravel": (
|
|
352
|
-
"PHP con Laravel — API Sanctum, Blade+Livewire y migraciones L6→L13.",
|
|
353
|
-
"api-engineer · fullstack-engineer · migration-specialist",
|
|
354
|
-
),
|
|
355
|
-
"wordpress": (
|
|
356
|
-
"WordPress moderno: FSE, Gutenberg, Divi y Elementor.",
|
|
357
|
-
"wp-engineer · divi-engineer · elementor-engineer",
|
|
358
|
-
),
|
|
359
|
-
"expo": ("Apps móviles con Expo / React Native.", "mobile-engineer"),
|
|
360
|
-
"playwright-crawler": ("Web scraping y crawling con Playwright.", "scanner-engineer"),
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
_PROFILE_RELEVANCE: dict[str, list[dict]] = {
|
|
364
|
-
"hono-drizzle": [{"backend": {"hono"}}, {"language": {"typescript"}}],
|
|
365
|
-
"nextjs-admin": [{"frontend": {"nextjs"}}],
|
|
366
|
-
"astro": [{"frontend": {"astro"}}],
|
|
367
|
-
"vuenuxt": [{"frontend": {"nuxt"}}],
|
|
368
|
-
"sveltekit": [{"frontend": {"sveltekit"}}],
|
|
369
|
-
"fastapi": [{"backend": {"fastapi"}}, {"language": {"python"}}],
|
|
370
|
-
"django": [{"backend": {"django"}}, {"language": {"python"}}],
|
|
371
|
-
"rails": [{"backend": {"rails"}}, {"language": {"ruby"}}],
|
|
372
|
-
"express": [{"backend": {"express"}}],
|
|
373
|
-
"nestjs": [{"backend": {"nestjs"}}],
|
|
374
|
-
"go-gin": [{"backend": {"gin"}}, {"language": {"go"}}],
|
|
375
|
-
"laravel": [{"backend": {"laravel"}}, {"language": {"php"}}],
|
|
376
|
-
"wordpress": [{"type": {"wordpress"}}, {"language": {"php"}}],
|
|
377
|
-
"expo": [{"type": {"mobile"}}],
|
|
378
|
-
"playwright-crawler": [{"type": {"crawler"}}],
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
def _profile_is_relevant(profile_name: str, config: dict) -> bool:
|
|
383
|
-
"""Retorna True si el profile coincide con el stack del proyecto."""
|
|
384
|
-
conditions = _PROFILE_RELEVANCE.get(profile_name)
|
|
385
|
-
if not conditions:
|
|
386
|
-
return True # profile sin mapeo conocido → siempre mostrar
|
|
387
|
-
|
|
388
|
-
stack = config.get("stack", {}) or {}
|
|
389
|
-
project = config.get("project", {}) or {}
|
|
390
|
-
values = {
|
|
391
|
-
"backend": str(stack.get("backend") or "").lower(),
|
|
392
|
-
"frontend": str(stack.get("frontend") or "").lower(),
|
|
393
|
-
"language": str(project.get("language") or "").lower(),
|
|
394
|
-
"type": str(project.get("type") or "").lower(),
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
for cond in conditions:
|
|
398
|
-
if all(values.get(field, "") in allowed for field, allowed in cond.items()):
|
|
399
|
-
return True
|
|
400
|
-
return False
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
# ── Oportunidades ─────────────────────────────────────────────────────────────
|
|
404
|
-
|
|
405
|
-
def find_opportunities(forge, config, installed_names):
|
|
406
|
-
opps = []
|
|
407
|
-
agents_cfg = config.get("agents", {})
|
|
408
|
-
active_profiles = agents_cfg.get("profiles", [])
|
|
409
|
-
skills_cfg = config.get("skills", {})
|
|
410
|
-
active_skills = skills_cfg.get("active", [])
|
|
411
|
-
integrations = skills_cfg.get("integrations", [])
|
|
412
|
-
|
|
413
|
-
# Profiles disponibles en forge que el proyecto no usa Y son relevantes para su stack
|
|
414
|
-
profiles_dir = forge / "profiles"
|
|
415
|
-
has_stack_info = bool(
|
|
416
|
-
(config.get("stack", {}) or {}).get("backend") or
|
|
417
|
-
(config.get("stack", {}) or {}).get("frontend") or
|
|
418
|
-
(config.get("project", {}) or {}).get("language") or
|
|
419
|
-
(config.get("project", {}) or {}).get("type")
|
|
420
|
-
)
|
|
421
|
-
|
|
422
|
-
if profiles_dir.exists():
|
|
423
|
-
for profile_dir in sorted(profiles_dir.iterdir()):
|
|
424
|
-
if not profile_dir.is_dir():
|
|
425
|
-
continue
|
|
426
|
-
profile_name = profile_dir.name
|
|
427
|
-
if profile_name in active_profiles:
|
|
428
|
-
continue
|
|
429
|
-
# Filtrar por relevancia solo si el proyecto declara info de stack
|
|
430
|
-
if has_stack_info and not _profile_is_relevant(profile_name, config):
|
|
431
|
-
continue
|
|
432
|
-
agents_in_profile = list((profile_dir / "agents").glob("*.md"))
|
|
433
|
-
if agents_in_profile:
|
|
434
|
-
agent_names = [a.stem for a in agents_in_profile]
|
|
435
|
-
opps.append({
|
|
436
|
-
"type": "profile",
|
|
437
|
-
"slug": profile_name,
|
|
438
|
-
"msg": f"Profile '{profile_name}' disponible en forge → provee: {', '.join(agent_names)}",
|
|
439
|
-
"fix": f"Agregar '{profile_name}' a agents.profiles en project.yaml"
|
|
440
|
-
})
|
|
441
|
-
|
|
442
|
-
# Skills universales disponibles que no están activos
|
|
443
|
-
skills_dir = forge / "core" / "skills"
|
|
444
|
-
universal_skills = {
|
|
445
|
-
"security-audit", "db-migrate", "local2prod", "new-feature",
|
|
446
|
-
"browser-test", "wiki-ingest", "wiki-query", "wiki-lint",
|
|
447
|
-
}
|
|
448
|
-
for skill in sorted(universal_skills - set(active_skills)):
|
|
449
|
-
skill_path = skills_dir / skill / "SKILL.md"
|
|
450
|
-
if skill_path.exists():
|
|
451
|
-
opps.append({
|
|
452
|
-
"type": "skill",
|
|
453
|
-
"slug": skill,
|
|
454
|
-
"msg": f"Skill '{skill}' disponible pero no activo en project.yaml",
|
|
455
|
-
"fix": f"Agregar '{skill}' a skills.active en project.yaml"
|
|
456
|
-
})
|
|
457
|
-
|
|
458
|
-
# Wiki health — si los skills están activos, verificar que el wiki existe
|
|
459
|
-
wiki_skills_active = {"wiki-ingest", "wiki-query", "wiki-lint"}.intersection(active_skills)
|
|
460
|
-
if wiki_skills_active:
|
|
461
|
-
root = config.get("_root")
|
|
462
|
-
wiki_cfg = config.get("wiki", {})
|
|
463
|
-
wiki_path_str = wiki_cfg.get("path", "docs/wiki") if wiki_cfg else "docs/wiki"
|
|
464
|
-
if root:
|
|
465
|
-
wiki_path = Path(root) / wiki_path_str
|
|
466
|
-
if not wiki_path.exists():
|
|
467
|
-
opps.append({
|
|
468
|
-
"type": "wiki",
|
|
469
|
-
"msg": f"Skills de wiki activos pero {wiki_path_str}/ no existe",
|
|
470
|
-
"fix": "python3 .agentic/scripts/forge-init.py --tool claude-code (crea la estructura inicial)"
|
|
471
|
-
})
|
|
472
|
-
elif not (wiki_path / "index.md").exists():
|
|
473
|
-
opps.append({
|
|
474
|
-
"type": "wiki",
|
|
475
|
-
"msg": f"{wiki_path_str}/index.md falta — wiki incompleto",
|
|
476
|
-
"fix": "python3 .agentic/scripts/forge-init.py --tool claude-code (recrea el índice)"
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
# Integración obsidian disponible
|
|
480
|
-
if "obsidian-sync" not in integrations:
|
|
481
|
-
opps.append({
|
|
482
|
-
"type": "integration",
|
|
483
|
-
"msg": "Skill 'obsidian-sync' disponible (requiere Obsidian + Local REST API)",
|
|
484
|
-
"fix": "Agregar 'obsidian-sync' a skills.integrations si usás Obsidian"
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
# deploy.provider no configurado
|
|
488
|
-
deploy = config.get("deploy", {})
|
|
489
|
-
if not deploy.get("provider"):
|
|
490
|
-
opps.append({
|
|
491
|
-
"type": "config",
|
|
492
|
-
"msg": "deploy.provider no configurado — skill 'local2prod' no puede funcionar sin él",
|
|
493
|
-
"fix": "Configurar deploy.provider en project.yaml (vercel | railway | fly | github-actions | custom)"
|
|
494
|
-
})
|
|
495
|
-
|
|
496
|
-
return opps
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
# ── Report ────────────────────────────────────────────────────────────────────
|
|
500
|
-
|
|
501
|
-
def level_icon(level: str) -> str:
|
|
502
|
-
return {"ok": OK("✓"), "warn": WARN("⚠"), "error": ERR("✗"), "info": INFO("→")}.get(level, " ")
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
_EMPTY_SUMMARY = {
|
|
506
|
-
"agents_total": 0, "agents_declared": 0,
|
|
507
|
-
"ok": 0, "info": 0, "warnings": 0, "errors": 0, "orphans": 0,
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
def run_audit(as_json: bool = False, forge_override=None, only=None):
|
|
512
|
-
try:
|
|
513
|
-
root = find_project_root()
|
|
514
|
-
if forge_override is not None:
|
|
515
|
-
forge = Path(forge_override)
|
|
516
|
-
if not forge.exists():
|
|
517
|
-
if as_json:
|
|
518
|
-
print(json.dumps({"error": f"forge dir no existe: {forge_override}",
|
|
519
|
-
"error_code": "FORGE_NOT_FOUND",
|
|
520
|
-
"summary": {**_EMPTY_SUMMARY, "errors": 1}}))
|
|
521
|
-
sys.exit(0)
|
|
522
|
-
print(ERR(f"ERROR: forge dir no existe: {forge}"), file=sys.stderr)
|
|
523
|
-
sys.exit(1)
|
|
524
|
-
else:
|
|
525
|
-
forge = find_forge_dir()
|
|
526
|
-
config = load_project(root)
|
|
527
|
-
config["_root"] = str(root) # pass root for wiki path resolution
|
|
528
|
-
except FileNotFoundError as e:
|
|
529
|
-
if as_json:
|
|
530
|
-
code = "NO_PROJECT_YAML" if "project.yaml" in str(e) else "FORGE_NOT_FOUND"
|
|
531
|
-
print(json.dumps({
|
|
532
|
-
"error": str(e),
|
|
533
|
-
"error_code": code,
|
|
534
|
-
"hint": "Ejecuta el wizard: python3 .agentic/scripts/forge-wizard.py" if code == "NO_PROJECT_YAML" else "",
|
|
535
|
-
"summary": {**_EMPTY_SUMMARY, "errors": 1},
|
|
536
|
-
}))
|
|
537
|
-
sys.exit(0)
|
|
538
|
-
print(ERR(f"ERROR: {e}"), file=sys.stderr)
|
|
539
|
-
print(ERR(" → Primer uso: python3 .agentic/scripts/forge-wizard.py"), file=sys.stderr)
|
|
540
|
-
sys.exit(1)
|
|
541
|
-
|
|
542
|
-
agents_dir = root / ".claude" / "agents"
|
|
543
|
-
agents_cfg = config.get("agents", {})
|
|
544
|
-
active_profiles = agents_cfg.get("profiles", [])
|
|
545
|
-
|
|
546
|
-
declared = set(
|
|
547
|
-
agents_cfg.get("active", []) +
|
|
548
|
-
agents_cfg.get("compliance", []) +
|
|
549
|
-
agents_cfg.get("specialized", []) +
|
|
550
|
-
# agentes que los profiles proveerían
|
|
551
|
-
[p.stem
|
|
552
|
-
for profile in active_profiles
|
|
553
|
-
for p in (forge / "profiles" / profile / "agents").glob("*.md")
|
|
554
|
-
if (forge / "profiles" / profile / "agents").exists()]
|
|
555
|
-
)
|
|
556
|
-
|
|
557
|
-
# Leer todos los agentes instalados
|
|
558
|
-
installed = {}
|
|
559
|
-
if agents_dir.exists():
|
|
560
|
-
for f in sorted(agents_dir.glob("*.md")):
|
|
561
|
-
installed[f.stem] = read_agent(f)
|
|
562
|
-
|
|
563
|
-
# Filtrar por --only
|
|
564
|
-
if only:
|
|
565
|
-
if only not in installed:
|
|
566
|
-
print(ERR(f"ERROR: agente '{only}' no encontrado en .claude/agents/"), file=sys.stderr)
|
|
567
|
-
sys.exit(1)
|
|
568
|
-
installed = {only: installed[only]}
|
|
569
|
-
|
|
570
|
-
# ── Audit por agente ──────────────────────────────────────────────────────
|
|
571
|
-
agent_results = {}
|
|
572
|
-
for name, agent in installed.items():
|
|
573
|
-
checks = []
|
|
574
|
-
checks += check_frontmatter(agent)
|
|
575
|
-
checks += check_sections(agent)
|
|
576
|
-
checks += check_vs_forge(agent, forge, active_profiles)
|
|
577
|
-
|
|
578
|
-
tier = agent["frontmatter"].get("tier", "?")
|
|
579
|
-
in_declared = name in declared
|
|
580
|
-
if not in_declared:
|
|
581
|
-
checks.append({"level": "warn", "msg": "No declarado en project.yaml (active/compliance/profiles/specialized)"})
|
|
582
|
-
|
|
583
|
-
worst = "ok"
|
|
584
|
-
for c_ in checks:
|
|
585
|
-
if c_["level"] == "error":
|
|
586
|
-
worst = "error"
|
|
587
|
-
break
|
|
588
|
-
if c_["level"] == "warn" and worst != "error":
|
|
589
|
-
worst = "warn"
|
|
590
|
-
if c_["level"] == "info" and worst == "ok":
|
|
591
|
-
worst = "info"
|
|
592
|
-
|
|
593
|
-
agent_results[name] = {"tier": tier, "checks": checks, "status": worst}
|
|
594
|
-
|
|
595
|
-
# ── Oportunidades ─────────────────────────────────────────────────────────
|
|
596
|
-
opps = find_opportunities(forge, config, set(installed.keys()))
|
|
597
|
-
|
|
598
|
-
# ── Huérfanos ─────────────────────────────────────────────────────────────
|
|
599
|
-
orphans = [n for n in installed if n not in declared]
|
|
600
|
-
|
|
601
|
-
# ── Output ────────────────────────────────────────────────────────────────
|
|
602
|
-
proj_name = config.get("project", {}).get("name", "?")
|
|
603
|
-
n_ok = sum(1 for r in agent_results.values() if r["status"] == "ok")
|
|
604
|
-
n_info = sum(1 for r in agent_results.values() if r["status"] == "info")
|
|
605
|
-
n_warn = sum(1 for r in agent_results.values() if r["status"] == "warn")
|
|
606
|
-
n_err = sum(1 for r in agent_results.values() if r["status"] == "error")
|
|
607
|
-
|
|
608
|
-
if as_json:
|
|
609
|
-
print(json.dumps({
|
|
610
|
-
"project": config.get("project", {}).get("name"),
|
|
611
|
-
"summary": {
|
|
612
|
-
"agents_total": len(installed),
|
|
613
|
-
"agents_declared": len(declared),
|
|
614
|
-
"ok": n_ok,
|
|
615
|
-
"info": n_info,
|
|
616
|
-
"warnings": n_warn,
|
|
617
|
-
"errors": n_err,
|
|
618
|
-
"orphans": len(orphans),
|
|
619
|
-
},
|
|
620
|
-
"agents": {k: {**v, "path": str(v.get("path", ""))} for k, v in agent_results.items()},
|
|
621
|
-
"opportunities": opps,
|
|
622
|
-
"orphans": orphans,
|
|
623
|
-
}, indent=2, default=str))
|
|
624
|
-
return
|
|
625
|
-
|
|
626
|
-
print()
|
|
627
|
-
print(BOLD(f"forge audit — {proj_name}"))
|
|
628
|
-
print("═" * 60)
|
|
629
|
-
|
|
630
|
-
# Resumen
|
|
631
|
-
print(f"\n{BOLD('RESUMEN')}")
|
|
632
|
-
print(f" {len(installed)} agentes en .claude/agents/")
|
|
633
|
-
print(f" {len(declared)} declarados en project.yaml")
|
|
634
|
-
print(f" {OK(str(n_ok))} conformes {INFO(str(n_info))} sugerencias {WARN(str(n_warn))} advertencias {ERR(str(n_err))} gaps")
|
|
635
|
-
if orphans:
|
|
636
|
-
print(f" {WARN(str(len(orphans)))} huérfanos (en .claude/ pero no en project.yaml)")
|
|
637
|
-
|
|
638
|
-
# Agentes por tier
|
|
639
|
-
by_tier: dict[str, list] = {}
|
|
640
|
-
for name, result in agent_results.items():
|
|
641
|
-
t = str(result["tier"])
|
|
642
|
-
by_tier.setdefault(t, []).append((name, result))
|
|
643
|
-
|
|
644
|
-
tier_labels = {"1": "Tier 1 — universal (core)", "2": "Tier 2 — profile (stack-specific)", "3": "Tier 3 — dominio (proyecto)", "?": "Sin tier declarado"}
|
|
645
|
-
print(f"\n{BOLD('SALUD POR AGENTE')}")
|
|
646
|
-
print("─" * 60)
|
|
647
|
-
|
|
648
|
-
for tier_key in sorted(by_tier.keys()):
|
|
649
|
-
print(f"\n {DIM(tier_labels.get(tier_key, f'Tier {tier_key}'))}")
|
|
650
|
-
ok_names = [n for n, r in sorted(by_tier[tier_key]) if r["status"] == "ok"]
|
|
651
|
-
prob_items = [(n, r) for n, r in sorted(by_tier[tier_key]) if r["status"] != "ok"]
|
|
652
|
-
if ok_names:
|
|
653
|
-
print(f" {OK('✓')} {DIM(' · '.join(ok_names))}")
|
|
654
|
-
for name, result in prob_items:
|
|
655
|
-
icon = level_icon(result["status"])
|
|
656
|
-
print(f" {icon} {BOLD(name)}")
|
|
657
|
-
for check in result["checks"]:
|
|
658
|
-
if check["level"] == "ok":
|
|
659
|
-
continue
|
|
660
|
-
sub_icon = level_icon(check["level"])
|
|
661
|
-
print(f" {sub_icon} {check['msg']}")
|
|
662
|
-
if "fix" in check:
|
|
663
|
-
print(f" {DIM('→ ' + check['fix'])}")
|
|
664
|
-
|
|
665
|
-
# Oportunidades
|
|
666
|
-
selectable = [o for o in opps if o.get("type") in ("profile", "skill") and o.get("slug")]
|
|
667
|
-
non_selectable = [o for o in opps if o not in selectable]
|
|
668
|
-
items_indexed: list[dict] = []
|
|
669
|
-
|
|
670
|
-
if opps:
|
|
671
|
-
can_pick = bool(selectable) and sys.stdout.isatty() and not as_json and not only
|
|
672
|
-
|
|
673
|
-
if can_pick:
|
|
674
|
-
# Sugerencias no-seleccionables: mostrar antes del TUI
|
|
675
|
-
if non_selectable:
|
|
676
|
-
print(f"\n{BOLD('OPORTUNIDADES')} {DIM(f'({len(opps)} disponibles)')}")
|
|
677
|
-
print("─" * 60)
|
|
678
|
-
print(f"\n {DIM('─── Otras sugerencias ───────────────────────────────────')}")
|
|
679
|
-
type_labels_ns = {"integration": "Integración", "config": "Config", "wiki": "Wiki"}
|
|
680
|
-
for opp in non_selectable:
|
|
681
|
-
label = DIM(f"[{type_labels_ns.get(opp['type'], opp['type'])}]")
|
|
682
|
-
print(f" {INFO('→')} {label} {opp['msg']}")
|
|
683
|
-
if "fix" in opp:
|
|
684
|
-
print(f" {DIM('→ ' + opp['fix'])}")
|
|
685
|
-
items_indexed = selectable
|
|
686
|
-
_two_panel_opp_picker(items_indexed, root)
|
|
687
|
-
else:
|
|
688
|
-
# Modo estático — CI, JSON, --only, no-TTY
|
|
689
|
-
print(f"\n{BOLD('OPORTUNIDADES')} {DIM(f'({len(opps)} disponibles)')}")
|
|
690
|
-
print("─" * 60)
|
|
691
|
-
|
|
692
|
-
profile_opps = [o for o in selectable if o["type"] == "profile"]
|
|
693
|
-
skill_opps = [o for o in selectable if o["type"] == "skill"]
|
|
694
|
-
|
|
695
|
-
def _wrap(text: str) -> str:
|
|
696
|
-
lines = _textwrap.wrap(text, width=66)
|
|
697
|
-
return ("\n" + " " * 7).join(lines)
|
|
698
|
-
|
|
699
|
-
if profile_opps:
|
|
700
|
-
print(f"\n {DIM('─── Profiles de stack ───────────────────────────────────')}")
|
|
701
|
-
for o in profile_opps:
|
|
702
|
-
idx = len(items_indexed) + 1
|
|
703
|
-
items_indexed.append(o)
|
|
704
|
-
slug = o["slug"]
|
|
705
|
-
info = _PROFILE_INFO.get(slug)
|
|
706
|
-
desc = info[0] if info else ""
|
|
707
|
-
agents_l = info[1] if info else (o.get("msg", "").split("→ provee:")[-1].strip())
|
|
708
|
-
tag = DIM("[Profile]")
|
|
709
|
-
print(f"\n {BOLD(f'[{idx}]')} {BOLD(slug):<28} {tag}")
|
|
710
|
-
if desc:
|
|
711
|
-
print(f" {_wrap(desc)}")
|
|
712
|
-
if agents_l:
|
|
713
|
-
print(f" {DIM('Agentes: ' + agents_l)}")
|
|
714
|
-
|
|
715
|
-
if skill_opps:
|
|
716
|
-
print(f"\n {DIM('─── Skills disponibles ──────────────────────────────────')}")
|
|
717
|
-
for o in skill_opps:
|
|
718
|
-
idx = len(items_indexed) + 1
|
|
719
|
-
items_indexed.append(o)
|
|
720
|
-
slug = o["slug"]
|
|
721
|
-
info = _SKILL_INFO.get(slug)
|
|
722
|
-
what = info[0] if info else o.get("msg", "")
|
|
723
|
-
benefit = info[1] if info else ""
|
|
724
|
-
trigger = info[2] if info else ""
|
|
725
|
-
tag = DIM(f"[Skill {trigger}]") if trigger else DIM("[Skill]")
|
|
726
|
-
print(f"\n {BOLD(f'[{idx}]')} {BOLD(slug):<28} {tag}")
|
|
727
|
-
print(f" {_wrap(what)}")
|
|
728
|
-
if benefit:
|
|
729
|
-
print(f" {DIM(_wrap(benefit))}")
|
|
730
|
-
|
|
731
|
-
if non_selectable:
|
|
732
|
-
print(f"\n {DIM('─── Otras sugerencias ───────────────────────────────────')}")
|
|
733
|
-
type_labels_ns = {"integration": "Integración", "config": "Config", "wiki": "Wiki"}
|
|
734
|
-
for opp in non_selectable:
|
|
735
|
-
label = DIM(f"[{type_labels_ns.get(opp['type'], opp['type'])}]")
|
|
736
|
-
print(f" {INFO('→')} {label} {opp['msg']}")
|
|
737
|
-
if "fix" in opp:
|
|
738
|
-
print(f" {DIM('→ ' + opp['fix'])}")
|
|
739
|
-
|
|
740
|
-
# Huérfanos
|
|
741
|
-
if orphans:
|
|
742
|
-
print(f"\n{BOLD('HUÉRFANOS')} {DIM('(en .claude/ pero no en project.yaml)')}")
|
|
743
|
-
print("─" * 60)
|
|
744
|
-
for name in orphans:
|
|
745
|
-
print(f" {WARN('⚠')} {name}.md — agregar a agents.active, profiles o specialized")
|
|
746
|
-
|
|
747
|
-
# Gaps de project.yaml
|
|
748
|
-
missing_in_project = [n for n in declared if n not in installed]
|
|
749
|
-
if missing_in_project:
|
|
750
|
-
print(f"\n{BOLD('AGENTES DECLARADOS SIN ARCHIVO')}")
|
|
751
|
-
print("─" * 60)
|
|
752
|
-
for name in missing_in_project:
|
|
753
|
-
print(f" {ERR('✗')} {name}.md — declarado en project.yaml pero no existe en .claude/agents/")
|
|
754
|
-
forge_path, source = get_forge_version(forge, name, active_profiles)
|
|
755
|
-
if forge_path:
|
|
756
|
-
print(f" {DIM(f'→ Disponible en forge ({source}): python3 .agentic/scripts/forge-init.py --tool claude-code')}")
|
|
757
|
-
else:
|
|
758
|
-
print(f" {DIM('→ Crear manualmente en .claude/agents/')}")
|
|
759
|
-
|
|
760
|
-
print()
|
|
761
|
-
if n_err > 0:
|
|
762
|
-
print(ERR(f" {n_err} gap(s) requieren atención antes de usar este proyecto con forge."))
|
|
763
|
-
elif n_warn > 0:
|
|
764
|
-
print(WARN(f" {n_warn} advertencia(s). El proyecto funciona pero puede mejorar."))
|
|
765
|
-
else:
|
|
766
|
-
print(OK(" Todo conforme al estándar forge."))
|
|
767
|
-
print(DIM(" Nota: similitud < 0.80 puede indicar personalización intencional, no solo desactualización."))
|
|
768
|
-
print()
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
def _simple_opp_picker(items_indexed: list, root: Path) -> None:
|
|
773
|
-
"""Fallback picker lineal cuando no hay termios (Windows / no-TTY)."""
|
|
774
|
-
total = len(items_indexed)
|
|
775
|
-
hint = f"1-{total}" if total > 1 else "1"
|
|
776
|
-
print(f" {DIM(f'Seleccioná [{hint}], separados por coma · a=todos · Enter=saltear')}")
|
|
777
|
-
try:
|
|
778
|
-
sys.stdout.write(" > ")
|
|
779
|
-
sys.stdout.flush()
|
|
780
|
-
raw = input().strip()
|
|
781
|
-
except (EOFError, KeyboardInterrupt):
|
|
782
|
-
print()
|
|
783
|
-
return
|
|
784
|
-
if not raw or raw.lower() in ("n", ""):
|
|
785
|
-
return
|
|
786
|
-
if raw.lower() == "a":
|
|
787
|
-
selected = items_indexed
|
|
788
|
-
else:
|
|
789
|
-
selected = []
|
|
790
|
-
for part in raw.split(","):
|
|
791
|
-
part = part.strip()
|
|
792
|
-
if part.isdigit():
|
|
793
|
-
idx = int(part)
|
|
794
|
-
if 1 <= idx <= len(items_indexed):
|
|
795
|
-
selected.append(items_indexed[idx - 1])
|
|
796
|
-
if not selected:
|
|
797
|
-
print(DIM(" Sin cambios."))
|
|
798
|
-
return
|
|
799
|
-
profiles_to_add = [o["slug"] for o in selected if o["type"] == "profile"]
|
|
800
|
-
skills_to_add = [o["slug"] for o in selected if o["type"] == "skill"]
|
|
801
|
-
_apply_to_project_yaml(root, profiles_to_add, skills_to_add)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def _two_panel_opp_picker(items_indexed: list, root: Path) -> None:
|
|
805
|
-
"""Picker TUI de dos paneles: lista izquierda | detalle derecho."""
|
|
806
|
-
import shutil as _shutil
|
|
807
|
-
|
|
808
|
-
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
809
|
-
_simple_opp_picker(items_indexed, root)
|
|
810
|
-
return
|
|
811
|
-
try:
|
|
812
|
-
import termios as _termios
|
|
813
|
-
import tty as _tty
|
|
814
|
-
except ImportError:
|
|
815
|
-
_simple_opp_picker(items_indexed, root)
|
|
816
|
-
return
|
|
817
|
-
|
|
818
|
-
cols = _shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
819
|
-
if cols < 60:
|
|
820
|
-
_simple_opp_picker(items_indexed, root)
|
|
821
|
-
return
|
|
822
|
-
|
|
823
|
-
# Dimensiones
|
|
824
|
-
left_inner = min(36, max(28, (cols - 3) * 4 // 10))
|
|
825
|
-
right_inner = cols - left_inner - 5 # │ + sp + │ + sp + left border
|
|
826
|
-
panel_rows = min(len(items_indexed) + 1, max(6, 14))
|
|
827
|
-
|
|
828
|
-
# Estado
|
|
829
|
-
selected: set[int] = set()
|
|
830
|
-
cursor = 0
|
|
831
|
-
|
|
832
|
-
_HIDE = "\033[?25l"
|
|
833
|
-
_SHOW = "\033[?25h"
|
|
834
|
-
_CY = "\033[36m"
|
|
835
|
-
_BD = "\033[1m"
|
|
836
|
-
_DM = "\033[2m"
|
|
837
|
-
_GN = "\033[32m"
|
|
838
|
-
_RST = "\033[0m"
|
|
839
|
-
|
|
840
|
-
def _vlen(s: str) -> int:
|
|
841
|
-
return len(re.sub(r'\033\[[0-9;]*m', '', s))
|
|
842
|
-
|
|
843
|
-
def _pad(s: str, w: int) -> str:
|
|
844
|
-
v = _vlen(s)
|
|
845
|
-
if v > w:
|
|
846
|
-
plain = re.sub(r'\033\[[0-9;]*m', '', s)
|
|
847
|
-
return plain[:w - 1] + "…"
|
|
848
|
-
return s + " " * (w - v)
|
|
849
|
-
|
|
850
|
-
def _detail(item: dict) -> list:
|
|
851
|
-
lines: list[str] = []
|
|
852
|
-
itype = item.get("type", "")
|
|
853
|
-
slug = item.get("slug", "")
|
|
854
|
-
w = right_inner - 2
|
|
855
|
-
if itype == "skill":
|
|
856
|
-
info = _SKILL_INFO.get(slug)
|
|
857
|
-
if info:
|
|
858
|
-
what, benefit, trigger = info
|
|
859
|
-
lines.append(f"{_DM}Skill {trigger}{_RST}")
|
|
860
|
-
lines.append("")
|
|
861
|
-
lines += [f" {ln}" for ln in _textwrap.wrap(what, w - 2)]
|
|
862
|
-
lines.append("")
|
|
863
|
-
lines.append(f"{_DM}Qué ganás:{_RST}")
|
|
864
|
-
lines += [f" {ln}" for ln in _textwrap.wrap(benefit, w - 2)]
|
|
865
|
-
else:
|
|
866
|
-
lines += _textwrap.wrap(item.get("msg", ""), w)
|
|
867
|
-
elif itype == "profile":
|
|
868
|
-
info = _PROFILE_INFO.get(slug)
|
|
869
|
-
if info:
|
|
870
|
-
desc, agents_l = info
|
|
871
|
-
lines.append(f"{_DM}Profile{_RST}")
|
|
872
|
-
lines.append("")
|
|
873
|
-
lines += _textwrap.wrap(desc, w)
|
|
874
|
-
lines.append("")
|
|
875
|
-
lines.append(f"{_DM}Agentes:{_RST}")
|
|
876
|
-
for a in agents_l.split(" · "):
|
|
877
|
-
lines.append(f" · {a.strip()}")
|
|
878
|
-
else:
|
|
879
|
-
lines += _textwrap.wrap(item.get("msg", ""), w)
|
|
880
|
-
return lines
|
|
881
|
-
|
|
882
|
-
def _draw() -> None:
|
|
883
|
-
sys.stdout.write("\033[2J\033[H")
|
|
884
|
-
|
|
885
|
-
n_sel = len(selected)
|
|
886
|
-
n_prof = sum(1 for o in items_indexed if o.get("type") == "profile")
|
|
887
|
-
n_skill = sum(1 for o in items_indexed if o.get("type") == "skill")
|
|
888
|
-
hdr = f"Oportunidades — {n_prof} profile(s) · {n_skill} skill(s)"
|
|
889
|
-
sys.stdout.write(f"\n {_BD}{hdr}{_RST}\n\n")
|
|
890
|
-
|
|
891
|
-
l_border = "─" * left_inner
|
|
892
|
-
r_border = "─" * right_inner
|
|
893
|
-
cur_item = items_indexed[cursor]
|
|
894
|
-
cur_slug = cur_item.get("slug", "?")
|
|
895
|
-
sys.stdout.write(f" ╭{l_border}╮ ╭─ {_BD}{_pad(cur_slug, right_inner - 4)}{_RST} ─╮\n")
|
|
896
|
-
|
|
897
|
-
vis_start = max(0, cursor - panel_rows // 2)
|
|
898
|
-
vis_start = min(vis_start, max(0, len(items_indexed) - panel_rows))
|
|
899
|
-
detail = (_detail(cur_item) + [""] * panel_rows)[:panel_rows]
|
|
900
|
-
|
|
901
|
-
for row in range(panel_rows):
|
|
902
|
-
item_idx = vis_start + row
|
|
903
|
-
if item_idx < len(items_indexed):
|
|
904
|
-
item = items_indexed[item_idx]
|
|
905
|
-
slug = item.get("slug", "")
|
|
906
|
-
itype = item.get("type", "")
|
|
907
|
-
tag = "PRF" if itype == "profile" else "SKL"
|
|
908
|
-
chk = f"{_GN}☑{_RST}" if item_idx in selected else "☐"
|
|
909
|
-
if item_idx == cursor:
|
|
910
|
-
left_raw = f"{_CY}❯{_RST} {chk} {_DM}{tag}{_RST} {_BD}{slug}{_RST}"
|
|
911
|
-
else:
|
|
912
|
-
left_raw = f" {chk} {_DM}{tag}{_RST} {slug}"
|
|
913
|
-
else:
|
|
914
|
-
left_raw = ""
|
|
915
|
-
|
|
916
|
-
right_raw = detail[row] if row < len(detail) else ""
|
|
917
|
-
sys.stdout.write(f" │{_pad(left_raw, left_inner)}│ │{_pad(right_raw, right_inner)}│\n")
|
|
918
|
-
|
|
919
|
-
sys.stdout.write(f" ╰{l_border}╯ ╰{r_border}╯\n")
|
|
920
|
-
|
|
921
|
-
sel_slugs = [items_indexed[i].get("slug", "") for i in sorted(selected)]
|
|
922
|
-
sel_display = ", ".join(sel_slugs) if sel_slugs else "ninguno"
|
|
923
|
-
sel_label = f"{n_sel} seleccionado(s)" if n_sel else "Ninguno seleccionado"
|
|
924
|
-
sys.stdout.write(f"\n {_DM}{sel_label}:{_RST} {_DM}{sel_display}{_RST}\n")
|
|
925
|
-
sys.stdout.write(f" {_DM}↑↓ navegar Space seleccionar a todos Enter aplicar q saltear{_RST}\n")
|
|
926
|
-
sys.stdout.flush()
|
|
927
|
-
|
|
928
|
-
def _getch() -> str:
|
|
929
|
-
fd = sys.stdin.fileno()
|
|
930
|
-
old = _termios.tcgetattr(fd)
|
|
931
|
-
try:
|
|
932
|
-
_tty.setraw(fd)
|
|
933
|
-
ch = sys.stdin.read(1)
|
|
934
|
-
if ch == "\x1b":
|
|
935
|
-
ch2 = sys.stdin.read(1)
|
|
936
|
-
ch3 = sys.stdin.read(1)
|
|
937
|
-
return f"\x1b{ch2}{ch3}"
|
|
938
|
-
return ch
|
|
939
|
-
finally:
|
|
940
|
-
_termios.tcsetattr(fd, _termios.TCSADRAIN, old)
|
|
941
|
-
|
|
942
|
-
sys.stdout.write(_HIDE)
|
|
943
|
-
confirmed = False
|
|
944
|
-
try:
|
|
945
|
-
while True:
|
|
946
|
-
_draw()
|
|
947
|
-
ch = _getch()
|
|
948
|
-
if ch in ("\x03", "q"):
|
|
949
|
-
break
|
|
950
|
-
elif ch == "\x1b[A":
|
|
951
|
-
cursor = max(0, cursor - 1)
|
|
952
|
-
elif ch == "\x1b[B":
|
|
953
|
-
cursor = min(len(items_indexed) - 1, cursor + 1)
|
|
954
|
-
elif ch == " ":
|
|
955
|
-
if cursor in selected:
|
|
956
|
-
selected.discard(cursor)
|
|
957
|
-
else:
|
|
958
|
-
selected.add(cursor)
|
|
959
|
-
elif ch.lower() == "a":
|
|
960
|
-
if len(selected) == len(items_indexed):
|
|
961
|
-
selected.clear()
|
|
962
|
-
else:
|
|
963
|
-
selected = set(range(len(items_indexed)))
|
|
964
|
-
elif ch in ("\r", "\n"):
|
|
965
|
-
confirmed = True
|
|
966
|
-
break
|
|
967
|
-
finally:
|
|
968
|
-
sys.stdout.write(_SHOW)
|
|
969
|
-
sys.stdout.write("\033[2J\033[H")
|
|
970
|
-
sys.stdout.flush()
|
|
971
|
-
|
|
972
|
-
if confirmed and selected:
|
|
973
|
-
sel_items = [items_indexed[i] for i in sorted(selected)]
|
|
974
|
-
profiles_to_add = [o["slug"] for o in sel_items if o["type"] == "profile"]
|
|
975
|
-
skills_to_add = [o["slug"] for o in sel_items if o["type"] == "skill"]
|
|
976
|
-
_apply_to_project_yaml(root, profiles_to_add, skills_to_add)
|
|
977
|
-
else:
|
|
978
|
-
print(DIM(" Sin cambios."))
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
def _apply_to_project_yaml(root: Path, profiles: list[str], skills: list[str]) -> None:
|
|
982
|
-
"""Agrega profiles y skills a project.yaml de forma segura."""
|
|
983
|
-
yaml_path = root / "project.yaml"
|
|
984
|
-
if not yaml_path.exists():
|
|
985
|
-
print(ERR(" ERROR: project.yaml no encontrado."))
|
|
986
|
-
return
|
|
987
|
-
|
|
988
|
-
try:
|
|
989
|
-
with open(yaml_path) as f:
|
|
990
|
-
content = yaml.safe_load(f)
|
|
991
|
-
except Exception as e:
|
|
992
|
-
print(ERR(f" ERROR al leer project.yaml: {e}"))
|
|
993
|
-
return
|
|
994
|
-
|
|
995
|
-
changed = False
|
|
996
|
-
|
|
997
|
-
if profiles:
|
|
998
|
-
agents = content.setdefault("agents", {})
|
|
999
|
-
current_profiles = agents.get("profiles", []) or []
|
|
1000
|
-
added = []
|
|
1001
|
-
for p in profiles:
|
|
1002
|
-
if p not in current_profiles:
|
|
1003
|
-
current_profiles.append(p)
|
|
1004
|
-
added.append(p)
|
|
1005
|
-
agents["profiles"] = current_profiles
|
|
1006
|
-
if added:
|
|
1007
|
-
print(OK(f" Profiles agregados: {', '.join(added)}"))
|
|
1008
|
-
changed = True
|
|
1009
|
-
|
|
1010
|
-
if skills:
|
|
1011
|
-
skill_cfg = content.setdefault("skills", {})
|
|
1012
|
-
current_skills = skill_cfg.get("active", []) or []
|
|
1013
|
-
added = []
|
|
1014
|
-
for s in skills:
|
|
1015
|
-
if s not in current_skills:
|
|
1016
|
-
current_skills.append(s)
|
|
1017
|
-
added.append(s)
|
|
1018
|
-
skill_cfg["active"] = current_skills
|
|
1019
|
-
if added:
|
|
1020
|
-
print(OK(f" Skills agregados: {', '.join(added)}"))
|
|
1021
|
-
changed = True
|
|
1022
|
-
|
|
1023
|
-
if not changed:
|
|
1024
|
-
print(DIM(" Sin cambios (ya estaban configurados)."))
|
|
1025
|
-
return
|
|
1026
|
-
|
|
1027
|
-
try:
|
|
1028
|
-
with open(yaml_path, "w") as f:
|
|
1029
|
-
yaml.dump(content, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
|
|
1030
|
-
print(OK(f" project.yaml actualizado."))
|
|
1031
|
-
except Exception as e:
|
|
1032
|
-
print(ERR(f" ERROR al escribir project.yaml: {e}"))
|
|
1033
|
-
return
|
|
1034
|
-
|
|
1035
|
-
print()
|
|
1036
|
-
print(DIM(" Para instalar los agentes:"))
|
|
1037
|
-
print(DIM(" python3 .agentic/scripts/forge-init.py --tool claude-code"))
|
|
1038
|
-
print()
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
def main():
|
|
1042
|
-
as_json = "--json" in sys.argv
|
|
1043
|
-
forge_override = None
|
|
1044
|
-
only = None
|
|
1045
|
-
args = sys.argv[1:]
|
|
1046
|
-
i = 0
|
|
1047
|
-
while i < len(args):
|
|
1048
|
-
arg = args[i]
|
|
1049
|
-
if arg.startswith("--forge="):
|
|
1050
|
-
forge_override = arg[len("--forge="):]
|
|
1051
|
-
elif arg == "--forge" and i + 1 < len(args):
|
|
1052
|
-
i += 1
|
|
1053
|
-
forge_override = args[i]
|
|
1054
|
-
elif arg.startswith("--only="):
|
|
1055
|
-
only = arg[len("--only="):]
|
|
1056
|
-
i += 1
|
|
1057
|
-
run_audit(as_json=as_json, forge_override=forge_override, only=only)
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
if __name__ == "__main__":
|
|
1061
|
-
main()
|