@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,900 +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-init.py — Setup de un proyecto nuevo con el framework forge.
|
|
6
|
-
|
|
7
|
-
Usage:
|
|
8
|
-
python3 .agentic/scripts/forge-init.py --tool claude-code
|
|
9
|
-
python3 .agentic/scripts/forge-init.py --tool claude-code --force # sobreescribir existentes
|
|
10
|
-
python3 .agentic/scripts/forge-init.py --tool claude-code --force --only=backend-engineer # solo ese agente
|
|
11
|
-
python3 .agentic/scripts/forge-init.py --tool opencode
|
|
12
|
-
python3 .agentic/scripts/forge-init.py --tool kiro
|
|
13
|
-
python3 .agentic/scripts/forge-init.py --tool codex
|
|
14
|
-
python3 .agentic/scripts/forge-init.py --tool all
|
|
15
|
-
|
|
16
|
-
Lee project.yaml en la raíz del proyecto y genera la configuración
|
|
17
|
-
para el tool especificado.
|
|
18
|
-
|
|
19
|
-
Comportamiento por defecto (sin --force):
|
|
20
|
-
- Agentes existentes en .claude/agents/ NO son sobreescritos
|
|
21
|
-
- Solo instala los agentes de forge que aún no existen
|
|
22
|
-
- AGENTS.md se genera siempre (documenta el roster completo)
|
|
23
|
-
|
|
24
|
-
--only=<nombre> Instala/actualiza únicamente el agente indicado (requiere --force).
|
|
25
|
-
|
|
26
|
-
Requiere: pyyaml → pip3 install pyyaml
|
|
27
|
-
o instalar via: pip3 install -r .agentic/requirements.txt
|
|
28
|
-
"""
|
|
29
|
-
from __future__ import annotations
|
|
30
|
-
|
|
31
|
-
import os
|
|
32
|
-
import sys
|
|
33
|
-
import shutil
|
|
34
|
-
from pathlib import Path
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
import yaml
|
|
38
|
-
except ImportError:
|
|
39
|
-
print(
|
|
40
|
-
"ERROR: pyyaml requerido.\n"
|
|
41
|
-
" pip3 install pyyaml\n"
|
|
42
|
-
" o: pip3 install -r .agentic/requirements.txt",
|
|
43
|
-
file=sys.stderr,
|
|
44
|
-
)
|
|
45
|
-
sys.exit(1)
|
|
46
|
-
|
|
47
|
-
FORCE = "--force" in sys.argv
|
|
48
|
-
VERBOSE = "--verbose" in sys.argv
|
|
49
|
-
|
|
50
|
-
# --only=<nombre> o --only <nombre>
|
|
51
|
-
ONLY_AGENT: str | None = None
|
|
52
|
-
for _arg in sys.argv:
|
|
53
|
-
if _arg.startswith("--only="):
|
|
54
|
-
ONLY_AGENT = _arg.split("=", 1)[1].strip()
|
|
55
|
-
break
|
|
56
|
-
if ONLY_AGENT is None and "--only" in sys.argv:
|
|
57
|
-
_idx = sys.argv.index("--only")
|
|
58
|
-
if _idx + 1 < len(sys.argv):
|
|
59
|
-
ONLY_AGENT = sys.argv[_idx + 1].strip()
|
|
60
|
-
|
|
61
|
-
# Mapeo agente → clave en paths/agent_paths del project.yaml
|
|
62
|
-
_AGENT_SCOPE_KEY = {
|
|
63
|
-
"api-engineer": "api",
|
|
64
|
-
"backend-engineer": "api",
|
|
65
|
-
"frontend-engineer": "frontend",
|
|
66
|
-
"admin-engineer": "admin",
|
|
67
|
-
"mobile-engineer": "mobile",
|
|
68
|
-
"scanner-engineer": "scanner",
|
|
69
|
-
"test-engineer": "tests",
|
|
70
|
-
"docs-writer": "specs",
|
|
71
|
-
"migration-specialist": "migrations",
|
|
72
|
-
"wp-engineer": "frontend",
|
|
73
|
-
"divi-engineer": "frontend",
|
|
74
|
-
"elementor-engineer": "frontend",
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _get_agent_scope(name: str, config: dict) -> str | None:
|
|
79
|
-
"""Retorna el path de scope para el agente según project.yaml, o None si no aplica."""
|
|
80
|
-
key = _AGENT_SCOPE_KEY.get(name)
|
|
81
|
-
if not key:
|
|
82
|
-
return None
|
|
83
|
-
agent_paths = config.get("agent_paths", {})
|
|
84
|
-
paths = config.get("paths", {})
|
|
85
|
-
return (agent_paths or {}).get(key) or (paths or {}).get(key) or None
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _inject_scope(content: str, scope_path: str) -> str:
|
|
89
|
-
"""Inyecta scope: en el frontmatter YAML del agente si no está presente."""
|
|
90
|
-
lines = content.split("\n")
|
|
91
|
-
if not lines or lines[0].strip() != "---":
|
|
92
|
-
return content
|
|
93
|
-
end = next((i for i, ln in enumerate(lines[1:], 1) if ln.strip() == "---"), -1)
|
|
94
|
-
if end == -1:
|
|
95
|
-
return content
|
|
96
|
-
if any(ln.startswith("scope:") for ln in lines[1:end]):
|
|
97
|
-
return content
|
|
98
|
-
lines.insert(end, f'scope: "{scope_path}"')
|
|
99
|
-
return "\n".join(lines)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def find_project_root() -> Path:
|
|
103
|
-
here = Path.cwd()
|
|
104
|
-
for p in [here] + list(here.parents):
|
|
105
|
-
if (p / "project.yaml").exists():
|
|
106
|
-
return p
|
|
107
|
-
raise FileNotFoundError(
|
|
108
|
-
"No se encontró project.yaml.\n"
|
|
109
|
-
" → Primer uso: python3 .agentic/scripts/forge-wizard.py (wizard interactivo)\n"
|
|
110
|
-
" → O copiar la plantilla: cp .agentic/templates/project.yaml.tpl project.yaml"
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def find_forge_dir() -> Path:
|
|
115
|
-
root = find_project_root()
|
|
116
|
-
for candidate in [root / ".agentic", root / "forge", Path(__file__).parent.parent]:
|
|
117
|
-
if (candidate / "core").exists():
|
|
118
|
-
return candidate
|
|
119
|
-
raise FileNotFoundError("No se encontró el directorio forge con core/")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def load_project(root: Path) -> dict:
|
|
123
|
-
with open(root / "project.yaml") as f:
|
|
124
|
-
return yaml.safe_load(f)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def get_all_agent_names(config: dict) -> tuple[list[str], list[str], list[str], list[str]]:
|
|
128
|
-
"""Retorna (active, compliance, specialized, profiles) — listas de nombres/strings."""
|
|
129
|
-
agents = config.get("agents", {})
|
|
130
|
-
active = agents.get("active", ["orchestrator", "backend-engineer", "frontend-engineer"])
|
|
131
|
-
compliance = agents.get("compliance", [])
|
|
132
|
-
specialized = agents.get("specialized", [])
|
|
133
|
-
profiles = agents.get("profiles", [])
|
|
134
|
-
|
|
135
|
-
# Compliance-reviewer automático si hay frameworks configurados
|
|
136
|
-
frameworks = config.get("compliance", {}).get("frameworks", [])
|
|
137
|
-
if frameworks and "compliance-reviewer" not in active + compliance:
|
|
138
|
-
compliance = list(set(compliance + ["compliance-reviewer"]))
|
|
139
|
-
|
|
140
|
-
return active, compliance, specialized, profiles
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def install_agent(src: Path, dst: Path, name: str, source_label: str, scope_path: str | None = None) -> str:
|
|
144
|
-
"""Copia un agente de src a dst, inyectando scope si corresponde. Respeta --force y --only."""
|
|
145
|
-
if not src.exists():
|
|
146
|
-
return "MISS"
|
|
147
|
-
if ONLY_AGENT and name != ONLY_AGENT:
|
|
148
|
-
return "SKIP"
|
|
149
|
-
already_existed = dst.exists()
|
|
150
|
-
if already_existed and not FORCE:
|
|
151
|
-
return "KEEP"
|
|
152
|
-
content = src.read_text(encoding="utf-8")
|
|
153
|
-
if scope_path:
|
|
154
|
-
content = _inject_scope(content, scope_path)
|
|
155
|
-
dst.write_text(content, encoding="utf-8")
|
|
156
|
-
return "UPDATE" if already_existed else "OK"
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def _install_templates(root: Path, forge: Path, config: dict):
|
|
160
|
-
"""Crea docs/daily-notes/, docs/specs/_template.md y .claude/architecture.rules si no existen."""
|
|
161
|
-
project_name = config.get("project", {}).get("name", "Mi Proyecto")
|
|
162
|
-
tpl_base = forge / "core" / "templates"
|
|
163
|
-
|
|
164
|
-
# 1. docs/daily-notes/
|
|
165
|
-
daily_notes_dir = root / "docs" / "daily-notes"
|
|
166
|
-
if not daily_notes_dir.exists():
|
|
167
|
-
daily_notes_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
-
print(f" [OK] docs/daily-notes/ — creado")
|
|
169
|
-
else:
|
|
170
|
-
print(f" [KEEP] docs/daily-notes/ — ya existe")
|
|
171
|
-
|
|
172
|
-
# 2. docs/specs/_template.md
|
|
173
|
-
specs_dir = root / "docs" / "specs"
|
|
174
|
-
spec_tpl_dst = specs_dir / "_template.md"
|
|
175
|
-
spec_tpl_src = tpl_base / "spec-template.md"
|
|
176
|
-
if not spec_tpl_dst.exists():
|
|
177
|
-
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
-
if spec_tpl_src.exists():
|
|
179
|
-
shutil.copy2(spec_tpl_src, spec_tpl_dst)
|
|
180
|
-
print(f" [OK] docs/specs/_template.md — copiado desde core/templates/")
|
|
181
|
-
else:
|
|
182
|
-
print(f" [MISS] core/templates/spec-template.md no encontrado en forge")
|
|
183
|
-
else:
|
|
184
|
-
print(f" [KEEP] docs/specs/_template.md — ya existe")
|
|
185
|
-
|
|
186
|
-
# 3. .claude/architecture.rules (nunca sobreescribir — editado manualmente)
|
|
187
|
-
arch_rules_dst = root / ".claude" / "architecture.rules"
|
|
188
|
-
arch_rules_src = tpl_base / "claude-md" / "architecture.rules"
|
|
189
|
-
if not arch_rules_dst.exists():
|
|
190
|
-
if arch_rules_src.exists():
|
|
191
|
-
content = arch_rules_src.read_text(encoding="utf-8")
|
|
192
|
-
content = content.replace("<NOMBRE_PROYECTO>", project_name)
|
|
193
|
-
arch_rules_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
-
arch_rules_dst.write_text(content, encoding="utf-8")
|
|
195
|
-
print(f" [OK] .claude/architecture.rules — creado desde template")
|
|
196
|
-
else:
|
|
197
|
-
print(f" [MISS] core/templates/claude-md/architecture.rules no encontrado en forge")
|
|
198
|
-
else:
|
|
199
|
-
print(f" [KEEP] .claude/architecture.rules — ya existe (no se sobreescribe)")
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def init_wiki(root: Path, forge: Path, config: dict):
|
|
203
|
-
"""Inicializa estructura docs/wiki/ desde templates si hay skills de wiki activos."""
|
|
204
|
-
skills_active = config.get("skills", {}).get("active", [])
|
|
205
|
-
wiki_skills = {"wiki-ingest", "wiki-query", "wiki-lint"}
|
|
206
|
-
if not wiki_skills.intersection(skills_active):
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
wiki_cfg = config.get("wiki", {})
|
|
210
|
-
wiki_path = root / wiki_cfg.get("path", "docs/wiki")
|
|
211
|
-
|
|
212
|
-
if wiki_path.exists():
|
|
213
|
-
print(f" [KEEP] {wiki_path.relative_to(root)}/ — ya existe")
|
|
214
|
-
return
|
|
215
|
-
|
|
216
|
-
tpl = forge / "templates" / "wiki"
|
|
217
|
-
if not tpl.exists():
|
|
218
|
-
print(f" [MISS] templates/wiki/ no encontrado en forge")
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
shutil.copytree(tpl, wiki_path)
|
|
222
|
-
# Renombrar _template.md → no copiarlos (son solo referencias)
|
|
223
|
-
for f in wiki_path.rglob("_template.md"):
|
|
224
|
-
f.unlink()
|
|
225
|
-
# Crear carpeta raw/ vacía
|
|
226
|
-
(wiki_path / "raw").mkdir(exist_ok=True)
|
|
227
|
-
|
|
228
|
-
print(f" [OK] {wiki_path.relative_to(root)}/ — estructura inicial creada")
|
|
229
|
-
print(f" Usar /wiki-ingest para agregar conocimiento")
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def install_claude_commands(root: Path, forge: Path, config: dict):
|
|
233
|
-
"""Instala slash commands de forge en .claude/commands/."""
|
|
234
|
-
skills_active = config.get("skills", {}).get("active", [])
|
|
235
|
-
commands_src = forge / "adapters" / "claude-code" / "commands"
|
|
236
|
-
if not commands_src.exists():
|
|
237
|
-
return
|
|
238
|
-
|
|
239
|
-
commands_dir = root / ".claude" / "commands"
|
|
240
|
-
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
-
|
|
242
|
-
# Comandos de ciclo de sesión — siempre instalados (v2)
|
|
243
|
-
session_commands = ["session-start.md", "session-close.md"]
|
|
244
|
-
|
|
245
|
-
# Mapeo skill → comando(s) que provee
|
|
246
|
-
skill_commands = {
|
|
247
|
-
"new-feature": ["new-feature.md"],
|
|
248
|
-
"local2prod": ["deploy-check.md"],
|
|
249
|
-
"security-audit": ["review.md"],
|
|
250
|
-
"wiki-ingest": ["wiki-ingest.md"],
|
|
251
|
-
"wiki-query": ["wiki-query.md"],
|
|
252
|
-
"wiki-lint": ["wiki-lint.md"],
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
installed = []
|
|
256
|
-
# 1. Session commands — siempre
|
|
257
|
-
for cmd_file in session_commands:
|
|
258
|
-
src = commands_src / cmd_file
|
|
259
|
-
dst = commands_dir / cmd_file
|
|
260
|
-
if not src.exists():
|
|
261
|
-
continue
|
|
262
|
-
if dst.exists() and not FORCE:
|
|
263
|
-
print(f" [KEEP] .claude/commands/{cmd_file}")
|
|
264
|
-
continue
|
|
265
|
-
shutil.copy2(src, dst)
|
|
266
|
-
installed.append(cmd_file)
|
|
267
|
-
print(f" [OK] .claude/commands/{cmd_file}")
|
|
268
|
-
|
|
269
|
-
# 2. Skill-based commands
|
|
270
|
-
for skill, cmd_files in skill_commands.items():
|
|
271
|
-
if skill not in skills_active:
|
|
272
|
-
continue
|
|
273
|
-
for cmd_file in cmd_files:
|
|
274
|
-
src = commands_src / cmd_file
|
|
275
|
-
dst = commands_dir / cmd_file
|
|
276
|
-
if not src.exists():
|
|
277
|
-
continue
|
|
278
|
-
if dst.exists() and not FORCE:
|
|
279
|
-
print(f" [KEEP] .claude/commands/{cmd_file}")
|
|
280
|
-
continue
|
|
281
|
-
shutil.copy2(src, dst)
|
|
282
|
-
installed.append(cmd_file)
|
|
283
|
-
print(f" [OK] .claude/commands/{cmd_file}")
|
|
284
|
-
|
|
285
|
-
if installed:
|
|
286
|
-
print(f" Slash commands instalados: {', '.join(f'/{f[:-3]}' for f in installed)}")
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def _generate_claude_md(root: Path, forge: Path, config: dict):
|
|
290
|
-
"""Genera CLAUDE.md en la raíz usando el adapter de claude-code."""
|
|
291
|
-
import importlib.util
|
|
292
|
-
generator_path = forge / "adapters" / "claude-code" / "generate-claude-md.py"
|
|
293
|
-
if not generator_path.exists():
|
|
294
|
-
print(f" [MISS] adapters/claude-code/generate-claude-md.py no encontrado en forge")
|
|
295
|
-
return
|
|
296
|
-
spec = importlib.util.spec_from_file_location("_gen_claude_md", generator_path)
|
|
297
|
-
mod = importlib.util.module_from_spec(spec)
|
|
298
|
-
spec.loader.exec_module(mod)
|
|
299
|
-
output_path = root / "CLAUDE.md"
|
|
300
|
-
already_existed = output_path.exists()
|
|
301
|
-
if already_existed and not FORCE:
|
|
302
|
-
print(f" [KEEP] CLAUDE.md — ya existe (--force para regenerar)")
|
|
303
|
-
return
|
|
304
|
-
content = mod.generate_claude_md(config)
|
|
305
|
-
output_path.write_text(content, encoding="utf-8")
|
|
306
|
-
action = "UPD" if already_existed else "OK"
|
|
307
|
-
print(f" [{action}] CLAUDE.md — generado desde project.yaml")
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def install_hooks(root: Path, forge: Path, config: dict):
|
|
311
|
-
"""Copia hooks de forge/core/hooks/ a .claude/hooks/ del proyecto."""
|
|
312
|
-
hooks_src = forge / "core" / "hooks"
|
|
313
|
-
if not hooks_src.exists():
|
|
314
|
-
return
|
|
315
|
-
|
|
316
|
-
registry_path = hooks_src / "hooks-registry.yaml"
|
|
317
|
-
if registry_path.exists():
|
|
318
|
-
_install_hooks_from_registry(root, hooks_src, config, registry_path)
|
|
319
|
-
else:
|
|
320
|
-
_install_hooks_legacy(root, hooks_src, config)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def _resolve_hooks_from_registry(config: dict, registry: dict) -> list[dict]:
|
|
324
|
-
"""
|
|
325
|
-
Determina qué hooks instalar según el registry y el project.yaml.
|
|
326
|
-
Retorna lista de dicts con keys: hook, event, matcher (opcional), description.
|
|
327
|
-
"""
|
|
328
|
-
mode = config.get("project", {}).get("mode", "startup")
|
|
329
|
-
stack = config.get("stack", {})
|
|
330
|
-
database = (stack.get("database") or "").lower()
|
|
331
|
-
orm = (stack.get("orm") or "").lower()
|
|
332
|
-
|
|
333
|
-
selected: list[dict] = []
|
|
334
|
-
|
|
335
|
-
# Universal — siempre
|
|
336
|
-
for entry in registry.get("universal", []):
|
|
337
|
-
selected.append(entry)
|
|
338
|
-
|
|
339
|
-
# Standard — mode standard + enterprise
|
|
340
|
-
if mode in ("standard", "enterprise"):
|
|
341
|
-
for entry in registry.get("standard", []):
|
|
342
|
-
selected.append(entry)
|
|
343
|
-
|
|
344
|
-
# Enterprise — solo mode enterprise
|
|
345
|
-
if mode == "enterprise":
|
|
346
|
-
for entry in registry.get("enterprise", []):
|
|
347
|
-
selected.append(entry)
|
|
348
|
-
|
|
349
|
-
# Stack-based
|
|
350
|
-
stack_registry = registry.get("stack", {})
|
|
351
|
-
for stack_name, entries in stack_registry.items():
|
|
352
|
-
stack_name_lower = stack_name.lower()
|
|
353
|
-
if stack_name_lower in database or stack_name_lower in orm:
|
|
354
|
-
for entry in entries:
|
|
355
|
-
selected.append(entry)
|
|
356
|
-
|
|
357
|
-
return selected
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _install_hooks_from_registry(root: Path, hooks_src: Path, config: dict, registry_path: Path):
|
|
361
|
-
"""Lee hooks-registry.yaml e instala los hooks que corresponden."""
|
|
362
|
-
with open(registry_path) as f:
|
|
363
|
-
registry = yaml.safe_load(f)
|
|
364
|
-
|
|
365
|
-
if not isinstance(registry, dict):
|
|
366
|
-
print(" [WARN] hooks-registry.yaml inválido — usando fallback legacy")
|
|
367
|
-
_install_hooks_legacy(root, hooks_src, config)
|
|
368
|
-
return
|
|
369
|
-
|
|
370
|
-
hooks_dir = root / ".claude" / "hooks"
|
|
371
|
-
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
372
|
-
|
|
373
|
-
selected = _resolve_hooks_from_registry(config, registry)
|
|
374
|
-
installed = []
|
|
375
|
-
for entry in selected:
|
|
376
|
-
hook_file = entry.get("hook", "")
|
|
377
|
-
if not hook_file:
|
|
378
|
-
continue
|
|
379
|
-
src = hooks_src / hook_file
|
|
380
|
-
dst = hooks_dir / hook_file
|
|
381
|
-
if not src.exists():
|
|
382
|
-
continue
|
|
383
|
-
if dst.exists() and not FORCE:
|
|
384
|
-
print(f" [KEEP] .claude/hooks/{hook_file}")
|
|
385
|
-
continue
|
|
386
|
-
shutil.copy2(src, dst)
|
|
387
|
-
if hook_file.endswith(".sh"):
|
|
388
|
-
dst.chmod(dst.stat().st_mode | 0o111)
|
|
389
|
-
installed.append(hook_file)
|
|
390
|
-
print(f" [OK] .claude/hooks/{hook_file}")
|
|
391
|
-
|
|
392
|
-
if installed:
|
|
393
|
-
print(f" Hooks instalados: {', '.join(installed)}")
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def _install_hooks_legacy(root: Path, hooks_src: Path, config: dict):
|
|
397
|
-
"""Fallback: instala hooks con lógica hardcodeada (sin registry)."""
|
|
398
|
-
hooks_dir = root / ".claude" / "hooks"
|
|
399
|
-
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
400
|
-
|
|
401
|
-
mode = config.get("project", {}).get("mode", "startup")
|
|
402
|
-
stack = config.get("stack", {})
|
|
403
|
-
database = (stack.get("database") or "").lower()
|
|
404
|
-
|
|
405
|
-
universal_hooks = ["pre-edit-check.py", "post-turn-check.sh"]
|
|
406
|
-
production_hooks: list[str] = []
|
|
407
|
-
if mode in ("standard", "enterprise"):
|
|
408
|
-
production_hooks.append("pre-bash-check.py")
|
|
409
|
-
stack_hooks: list[str] = []
|
|
410
|
-
if "supabase" in database:
|
|
411
|
-
stack_hooks.append("check-destructive-sql.py")
|
|
412
|
-
|
|
413
|
-
all_hooks = universal_hooks + production_hooks + stack_hooks
|
|
414
|
-
installed = []
|
|
415
|
-
for hook_file in all_hooks:
|
|
416
|
-
src = hooks_src / hook_file
|
|
417
|
-
dst = hooks_dir / hook_file
|
|
418
|
-
if not src.exists():
|
|
419
|
-
continue
|
|
420
|
-
if dst.exists() and not FORCE:
|
|
421
|
-
print(f" [KEEP] .claude/hooks/{hook_file}")
|
|
422
|
-
continue
|
|
423
|
-
shutil.copy2(src, dst)
|
|
424
|
-
if hook_file.endswith(".sh"):
|
|
425
|
-
dst.chmod(dst.stat().st_mode | 0o111)
|
|
426
|
-
installed.append(hook_file)
|
|
427
|
-
print(f" [OK] .claude/hooks/{hook_file}")
|
|
428
|
-
|
|
429
|
-
if installed:
|
|
430
|
-
print(f" Hooks instalados: {', '.join(installed)}")
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def _build_hooks_config(config: dict) -> dict:
|
|
434
|
-
"""
|
|
435
|
-
Construye el dict de hooks para settings.json.
|
|
436
|
-
Si hooks-registry.yaml existe, lo usa para determinar qué hooks incluir.
|
|
437
|
-
Si no, usa la configuración hardcodeada de fallback.
|
|
438
|
-
"""
|
|
439
|
-
# Intentar leer el registry desde el directorio forge
|
|
440
|
-
registry: dict | None = None
|
|
441
|
-
try:
|
|
442
|
-
from pathlib import Path as _Path
|
|
443
|
-
# Buscar forge dir
|
|
444
|
-
cwd = _Path.cwd()
|
|
445
|
-
for candidate in [cwd / ".agentic", cwd / "forge"]:
|
|
446
|
-
reg_path = candidate / "core" / "hooks" / "hooks-registry.yaml"
|
|
447
|
-
if reg_path.exists():
|
|
448
|
-
with open(reg_path) as f:
|
|
449
|
-
registry = yaml.safe_load(f)
|
|
450
|
-
break
|
|
451
|
-
# También probar relativo al script
|
|
452
|
-
if registry is None:
|
|
453
|
-
script_dir = _Path(__file__).parent.parent
|
|
454
|
-
reg_path = script_dir / "core" / "hooks" / "hooks-registry.yaml"
|
|
455
|
-
if reg_path.exists():
|
|
456
|
-
with open(reg_path) as f:
|
|
457
|
-
registry = yaml.safe_load(f)
|
|
458
|
-
except Exception:
|
|
459
|
-
pass
|
|
460
|
-
|
|
461
|
-
if registry and isinstance(registry, dict):
|
|
462
|
-
selected = _resolve_hooks_from_registry(config, registry)
|
|
463
|
-
return _entries_to_hooks_config(selected)
|
|
464
|
-
|
|
465
|
-
# Fallback hardcodeado
|
|
466
|
-
return {
|
|
467
|
-
"PreToolUse": [
|
|
468
|
-
{
|
|
469
|
-
"matcher": "Edit|Write",
|
|
470
|
-
"hooks": [
|
|
471
|
-
{"type": "command", "command": "python3 .claude/hooks/pre-edit-check.py"}
|
|
472
|
-
],
|
|
473
|
-
}
|
|
474
|
-
],
|
|
475
|
-
"Stop": [
|
|
476
|
-
{
|
|
477
|
-
"hooks": [
|
|
478
|
-
{"type": "command", "command": "bash .claude/hooks/post-turn-check.sh"}
|
|
479
|
-
]
|
|
480
|
-
}
|
|
481
|
-
],
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def _entries_to_hooks_config(entries: list[dict]) -> dict:
|
|
486
|
-
"""Convierte la lista de hook entries del registry al formato de settings.json."""
|
|
487
|
-
# Agrupar por evento
|
|
488
|
-
by_event: dict[str, list[dict]] = {}
|
|
489
|
-
for entry in entries:
|
|
490
|
-
event = entry.get("event", "")
|
|
491
|
-
if not event:
|
|
492
|
-
continue
|
|
493
|
-
hook_file = entry.get("hook", "")
|
|
494
|
-
if not hook_file:
|
|
495
|
-
continue
|
|
496
|
-
|
|
497
|
-
# Determinar comando según extensión
|
|
498
|
-
if hook_file.endswith(".py"):
|
|
499
|
-
command = f"python3 .claude/hooks/{hook_file}"
|
|
500
|
-
elif hook_file.endswith(".sh"):
|
|
501
|
-
command = f"bash .claude/hooks/{hook_file}"
|
|
502
|
-
else:
|
|
503
|
-
command = f".claude/hooks/{hook_file}"
|
|
504
|
-
|
|
505
|
-
hook_entry: dict = {"type": "command", "command": command}
|
|
506
|
-
matcher = entry.get("matcher")
|
|
507
|
-
|
|
508
|
-
group_key = f"{event}|{matcher or ''}"
|
|
509
|
-
if group_key not in by_event:
|
|
510
|
-
by_event[group_key] = []
|
|
511
|
-
by_event[group_key].append((event, matcher, hook_entry))
|
|
512
|
-
|
|
513
|
-
# Construir estructura final
|
|
514
|
-
result: dict = {}
|
|
515
|
-
# Agrupar por (event, matcher) para consolidar hooks del mismo bloque
|
|
516
|
-
blocks: dict[tuple, list] = {}
|
|
517
|
-
for entry in entries:
|
|
518
|
-
event = entry.get("event", "")
|
|
519
|
-
hook_file = entry.get("hook", "")
|
|
520
|
-
matcher = entry.get("matcher") or None
|
|
521
|
-
if not event or not hook_file:
|
|
522
|
-
continue
|
|
523
|
-
|
|
524
|
-
if hook_file.endswith(".py"):
|
|
525
|
-
command = f"python3 .claude/hooks/{hook_file}"
|
|
526
|
-
elif hook_file.endswith(".sh"):
|
|
527
|
-
command = f"bash .claude/hooks/{hook_file}"
|
|
528
|
-
else:
|
|
529
|
-
command = f".claude/hooks/{hook_file}"
|
|
530
|
-
|
|
531
|
-
key = (event, matcher)
|
|
532
|
-
if key not in blocks:
|
|
533
|
-
blocks[key] = []
|
|
534
|
-
blocks[key].append({"type": "command", "command": command})
|
|
535
|
-
|
|
536
|
-
for (event, matcher), hook_list in blocks.items():
|
|
537
|
-
if event not in result:
|
|
538
|
-
result[event] = []
|
|
539
|
-
block: dict = {"hooks": hook_list}
|
|
540
|
-
if matcher:
|
|
541
|
-
block["matcher"] = matcher
|
|
542
|
-
result[event].append(block)
|
|
543
|
-
|
|
544
|
-
return result
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def _generate_settings_json(root: Path, config: dict):
|
|
548
|
-
"""Genera .claude/settings.json con permisos y hooks según el stack del proyecto."""
|
|
549
|
-
import json
|
|
550
|
-
settings_path = root / ".claude" / "settings.json"
|
|
551
|
-
if settings_path.exists() and not FORCE:
|
|
552
|
-
print(f" [KEEP] .claude/settings.json — ya existe (--force para regenerar)")
|
|
553
|
-
return
|
|
554
|
-
language = config.get("project", {}).get("language", "typescript")
|
|
555
|
-
stack = config.get("stack", {})
|
|
556
|
-
backend = (stack.get("backend") or "").lower()
|
|
557
|
-
database = (stack.get("database") or "").lower()
|
|
558
|
-
profiles = config.get("agents", {}).get("profiles", [])
|
|
559
|
-
allow: list[str] = []
|
|
560
|
-
if language in ("typescript", "javascript"):
|
|
561
|
-
allow += ["Bash(pnpm *)", "Bash(npm *)", "Bash(npx *)", "Bash(node *)"]
|
|
562
|
-
if "prisma" in backend or any("prisma" in p for p in profiles):
|
|
563
|
-
allow.append("Bash(npx prisma *)")
|
|
564
|
-
if "drizzle" in backend or any("drizzle" in p for p in profiles):
|
|
565
|
-
allow.append("Bash(npx drizzle-kit *)")
|
|
566
|
-
elif language == "python":
|
|
567
|
-
allow += ["Bash(python3 *)", "Bash(pip3 *)", "Bash(uv *)", "Bash(pytest *)", "Bash(ruff *)"]
|
|
568
|
-
if "django" in backend or any("django" in p for p in profiles):
|
|
569
|
-
allow.append("Bash(python3 manage.py *)")
|
|
570
|
-
if "fastapi" in backend:
|
|
571
|
-
allow.append("Bash(uvicorn *)")
|
|
572
|
-
elif language == "ruby":
|
|
573
|
-
allow += ["Bash(bundle *)", "Bash(rails *)", "Bash(rake *)", "Bash(rspec *)"]
|
|
574
|
-
elif language == "go":
|
|
575
|
-
allow += ["Bash(go *)", "Bash(golangci-lint *)"]
|
|
576
|
-
elif language == "php":
|
|
577
|
-
allow += ["Bash(composer *)", "Bash(php *)", "Bash(php artisan *)"]
|
|
578
|
-
if database == "postgresql":
|
|
579
|
-
allow += ["Bash(psql *)", "Bash(pg_dump *)"]
|
|
580
|
-
allow += ["Bash(git status)", "Bash(git diff *)", "Bash(git log *)", "Bash(git branch *)"]
|
|
581
|
-
|
|
582
|
-
# Configuración de hooks (Forge v2) — derivada del registry si existe
|
|
583
|
-
hooks_config = _build_hooks_config(config)
|
|
584
|
-
|
|
585
|
-
settings = {
|
|
586
|
-
"permissions": {"allow": sorted(set(allow))},
|
|
587
|
-
"hooks": hooks_config,
|
|
588
|
-
}
|
|
589
|
-
already_existed = settings_path.exists()
|
|
590
|
-
with open(settings_path, "w", encoding="utf-8") as f:
|
|
591
|
-
json.dump(settings, f, indent=2, ensure_ascii=False)
|
|
592
|
-
f.write("\n")
|
|
593
|
-
action = "UPD" if already_existed else "OK"
|
|
594
|
-
print(f" [{action}] .claude/settings.json ({len(settings['permissions']['allow'])} permisos + hooks)")
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def init_claude_code(root: Path, forge: Path, config: dict):
|
|
598
|
-
"""Instala agentes de forge en .claude/agents/ (sin sobreescribir por defecto) y genera AGENTS.md."""
|
|
599
|
-
agents_dir = root / ".claude" / "agents"
|
|
600
|
-
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
601
|
-
|
|
602
|
-
active, compliance, specialized, profiles = get_all_agent_names(config)
|
|
603
|
-
stats = {"installed": [], "kept": [], "missing": []}
|
|
604
|
-
|
|
605
|
-
# 1. Tier 2 — profiles tienen prioridad sobre core
|
|
606
|
-
profile_provided: set[str] = set()
|
|
607
|
-
if profiles:
|
|
608
|
-
print(f"\n Tier 2 — profiles: {', '.join(profiles)}")
|
|
609
|
-
for profile in profiles:
|
|
610
|
-
profile_agents_dir = forge / "profiles" / profile / "agents"
|
|
611
|
-
if not profile_agents_dir.exists():
|
|
612
|
-
print(f" [WARN] profiles/{profile}/ no encontrado en forge")
|
|
613
|
-
continue
|
|
614
|
-
for src in sorted(profile_agents_dir.glob("*.md")):
|
|
615
|
-
name = src.stem
|
|
616
|
-
dst = agents_dir / src.name
|
|
617
|
-
scope = _get_agent_scope(name, config)
|
|
618
|
-
status = install_agent(src, dst, name, f"profile:{profile}", scope)
|
|
619
|
-
profile_provided.add(name)
|
|
620
|
-
_print_agent_status(status, name, f"profiles/{profile}/agents/")
|
|
621
|
-
_record_status(stats, status, name)
|
|
622
|
-
|
|
623
|
-
# 2. Tier 1 — core (solo agentes que no fueron cubiertos por profiles)
|
|
624
|
-
all_from_core = list(set(active + compliance))
|
|
625
|
-
core_only = [a for a in all_from_core if a not in profile_provided]
|
|
626
|
-
|
|
627
|
-
# Detectar y reportar conflictos Tier 1 vs Tier 2
|
|
628
|
-
tier1_discarded = [a for a in all_from_core if a in profile_provided]
|
|
629
|
-
profile_by_agent: dict[str, str] = {}
|
|
630
|
-
for profile in profiles:
|
|
631
|
-
profile_agents_dir = forge / "profiles" / profile / "agents"
|
|
632
|
-
if not profile_agents_dir.exists():
|
|
633
|
-
continue
|
|
634
|
-
for src in profile_agents_dir.glob("*.md"):
|
|
635
|
-
profile_by_agent[src.stem] = profile
|
|
636
|
-
for discarded in sorted(tier1_discarded):
|
|
637
|
-
profile_name = profile_by_agent.get(discarded, "profile")
|
|
638
|
-
print(f" INFO: '{discarded}' del core descartado — profile '{profile_name}' provee su propia versión (Tier 2 tiene prioridad)")
|
|
639
|
-
|
|
640
|
-
if core_only:
|
|
641
|
-
print(f"\n Tier 1 — core:")
|
|
642
|
-
for agent_name in sorted(core_only):
|
|
643
|
-
src = forge / "core" / "agents" / f"{agent_name}.md"
|
|
644
|
-
dst = agents_dir / f"{agent_name}.md"
|
|
645
|
-
scope = _get_agent_scope(agent_name, config)
|
|
646
|
-
status = install_agent(src, dst, agent_name, "core", scope)
|
|
647
|
-
_print_agent_status(status, agent_name, "core/agents/")
|
|
648
|
-
_record_status(stats, status, agent_name)
|
|
649
|
-
|
|
650
|
-
# 3. Tier 3 — especializados (solo verificar que existen en el proyecto)
|
|
651
|
-
if specialized:
|
|
652
|
-
print(f"\n Tier 3 — especializados del proyecto:")
|
|
653
|
-
for name in specialized:
|
|
654
|
-
dst = agents_dir / f"{name}.md"
|
|
655
|
-
status = "OK" if dst.exists() else "FALTA"
|
|
656
|
-
icon = "OK" if dst.exists() else "FALTA — crear manualmente en .claude/agents/"
|
|
657
|
-
print(f" [{icon}] {name}.md")
|
|
658
|
-
|
|
659
|
-
# AGENTS.md siempre se regenera
|
|
660
|
-
_write_agents_md(root, config, active, compliance, specialized, profiles)
|
|
661
|
-
|
|
662
|
-
# Instalar templates de proyecto (daily-notes, specs, architecture.rules)
|
|
663
|
-
print(f"\n Templates:")
|
|
664
|
-
_install_templates(root, forge, config)
|
|
665
|
-
|
|
666
|
-
if tier1_discarded and (VERBOSE or len(tier1_discarded) > 0):
|
|
667
|
-
print(f"\n Conflictos resueltos: {len(tier1_discarded)} agente(s) core descartado(s) por profiles activos.")
|
|
668
|
-
|
|
669
|
-
i, k, m = len(stats["installed"]), len(stats["kept"]), len(stats["missing"])
|
|
670
|
-
print(f"\n Instalados: {i} | Preservados: {k} | Sin archivo en forge: {m}")
|
|
671
|
-
if stats["missing"]:
|
|
672
|
-
print(f" Pendientes en forge: {', '.join(stats['missing'])}")
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def _print_agent_status(status: str, name: str, source: str):
|
|
676
|
-
msgs = {
|
|
677
|
-
"OK": f" [OK] .claude/agents/{name}.md ← {source}",
|
|
678
|
-
"UPDATE": f" [UPD] .claude/agents/{name}.md ← {source} (sobreescrito)",
|
|
679
|
-
"KEEP": f" [KEEP] .claude/agents/{name}.md — ya existe (--force para sobreescribir)",
|
|
680
|
-
"MISS": f" [MISS] {source}{name}.md — no existe en forge",
|
|
681
|
-
"SKIP": f" [SKIP] .claude/agents/{name}.md — omitido (--only={ONLY_AGENT})",
|
|
682
|
-
}
|
|
683
|
-
print(msgs.get(status, f" [?] {name}"))
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
def _record_status(stats: dict, status: str, name: str):
|
|
687
|
-
if status in ("OK", "UPDATE"):
|
|
688
|
-
stats["installed"].append(name)
|
|
689
|
-
elif status == "KEEP":
|
|
690
|
-
stats["kept"].append(name)
|
|
691
|
-
elif status == "MISS":
|
|
692
|
-
stats["missing"].append(name)
|
|
693
|
-
# SKIP no cuenta en ningún total — es comportamiento esperado con --only
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def _write_agents_md(root: Path, config: dict, active: list, compliance: list, specialized: list, profiles: list = []):
|
|
697
|
-
project_name = config.get("project", {}).get("name", "Mi Proyecto")
|
|
698
|
-
team_name = config.get("team", {}).get("name", "Team")
|
|
699
|
-
frameworks = config.get("compliance", {}).get("frameworks", [])
|
|
700
|
-
|
|
701
|
-
role_descriptions = {
|
|
702
|
-
"orchestrator": "Lead del team — coordina, descompone tareas, sintetiza",
|
|
703
|
-
"backend-engineer": "Backend — API, base de datos, lógica de negocio",
|
|
704
|
-
"frontend-engineer": "Frontend — UI, componentes, integración con API",
|
|
705
|
-
"fullstack-engineer": "Full-stack — backend + frontend + migraciones (Rails y similares)",
|
|
706
|
-
"api-engineer": "API — Hono/Express/FastAPI/NestJS + ORM + lógica de negocio",
|
|
707
|
-
"admin-engineer": "Admin dashboard — UI de gestión interna",
|
|
708
|
-
"banner-engineer": "Banner SDK — script de consentimiento embebido",
|
|
709
|
-
"scanner-engineer": "Scanner de cookies y trackers",
|
|
710
|
-
"mobile-engineer": "Apps móviles — React Native / Expo",
|
|
711
|
-
"test-engineer": "Testing — unitario, integración, E2E",
|
|
712
|
-
"docs-writer": "Documentación — specs, ADRs, READMEs",
|
|
713
|
-
"compliance-reviewer": "Compliance — revisa contra marcos regulatorios activos",
|
|
714
|
-
"security-auditor": "Seguridad — auditoría de vulnerabilidades",
|
|
715
|
-
"dsar-specialist": "DSAR — derechos del titular (acceso, rectificación, supresión…)",
|
|
716
|
-
"policy-engineer": "Policy Generator — plantillas de política de privacidad",
|
|
717
|
-
"sites-engineer": "Sites & Domains — gestión de sitios y snippets",
|
|
718
|
-
"gcm-engineer": "Google Consent Mode v2 — integración GCM",
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
lines = [
|
|
722
|
-
f"# AGENTS.md — {project_name}",
|
|
723
|
-
f"",
|
|
724
|
-
f"> Agent team de {project_name} — generado por forge-init.py.",
|
|
725
|
-
f"> Editar `project.yaml` para agregar/quitar agentes, luego re-ejecutar forge-init.",
|
|
726
|
-
f"",
|
|
727
|
-
f"## Team: {team_name}",
|
|
728
|
-
f"",
|
|
729
|
-
f"## Agentes activos",
|
|
730
|
-
f"",
|
|
731
|
-
f"| Agente | Rol | Fuente |",
|
|
732
|
-
f"|--------|-----|--------|",
|
|
733
|
-
]
|
|
734
|
-
|
|
735
|
-
for name in active:
|
|
736
|
-
desc = role_descriptions.get(name, "Agente de implementación")
|
|
737
|
-
lines.append(f"| `{name}` | {desc} | forge core |")
|
|
738
|
-
|
|
739
|
-
if compliance:
|
|
740
|
-
lines.append(f"")
|
|
741
|
-
lines.append(f"**Revisores:**")
|
|
742
|
-
lines.append(f"")
|
|
743
|
-
lines.append(f"| Agente | Rol | Fuente |")
|
|
744
|
-
lines.append(f"|--------|-----|--------|")
|
|
745
|
-
for name in compliance:
|
|
746
|
-
desc = role_descriptions.get(name, "Agente revisor")
|
|
747
|
-
lines.append(f"| `{name}` | {desc} | forge core |")
|
|
748
|
-
|
|
749
|
-
if specialized:
|
|
750
|
-
lines.append(f"")
|
|
751
|
-
lines.append(f"**Especializados del proyecto** (no están en forge core):")
|
|
752
|
-
lines.append(f"")
|
|
753
|
-
lines.append(f"| Agente | Rol | Fuente |")
|
|
754
|
-
lines.append(f"|--------|-----|--------|")
|
|
755
|
-
for name in specialized:
|
|
756
|
-
desc = role_descriptions.get(name, "Agente especializado del proyecto")
|
|
757
|
-
lines.append(f"| `{name}` | {desc} | proyecto |")
|
|
758
|
-
|
|
759
|
-
if frameworks:
|
|
760
|
-
lines += [
|
|
761
|
-
f"",
|
|
762
|
-
f"## Compliance activo",
|
|
763
|
-
f"",
|
|
764
|
-
f"Marcos: {', '.join(f.upper() for f in frameworks)}",
|
|
765
|
-
f"",
|
|
766
|
-
f"Incluir `compliance-reviewer` en PRs que toquen:",
|
|
767
|
-
f"- Datos de usuarios o consentimientos",
|
|
768
|
-
f"- Logs de auditoría (append-only)",
|
|
769
|
-
f"- Derechos del titular (DSAR)",
|
|
770
|
-
]
|
|
771
|
-
|
|
772
|
-
lines += [
|
|
773
|
-
f"",
|
|
774
|
-
f"## Reglas operativas",
|
|
775
|
-
f"",
|
|
776
|
-
f"1. Specs en `docs/specs/` primero — sin spec, sin código.",
|
|
777
|
-
f"2. El orchestrator coordina; los agentes no se comunican directamente entre sí.",
|
|
778
|
-
f"3. Cada agente respeta su scope — no sale del directorio que le corresponde.",
|
|
779
|
-
f"4. Máximo 3 agentes simultáneos en suscripción Pro / hasta 7-8 con Max 20x o API.",
|
|
780
|
-
f"5. Compliance reviewer obligatorio antes de mergear si toca datos de usuarios.",
|
|
781
|
-
]
|
|
782
|
-
|
|
783
|
-
with open(root / "AGENTS.md", "w") as f:
|
|
784
|
-
f.write("\n".join(lines) + "\n")
|
|
785
|
-
print(f" [OK] AGENTS.md (roster completo: {len(active + compliance + specialized)} agentes)")
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
def init_opencode(root: Path, forge: Path, config: dict):
|
|
789
|
-
"""Delega en el adapter de OpenCode para generar AGENTS.md."""
|
|
790
|
-
import subprocess
|
|
791
|
-
adapter = forge / "adapters" / "opencode" / "generate-agents-md.py"
|
|
792
|
-
if not adapter.exists():
|
|
793
|
-
print(f" [MISS] {adapter} — adapter de OpenCode no encontrado", file=sys.stderr)
|
|
794
|
-
return
|
|
795
|
-
args = ["python3", str(adapter)]
|
|
796
|
-
result = subprocess.run(args, cwd=str(root))
|
|
797
|
-
if result.returncode != 0:
|
|
798
|
-
print(f" [ERR] generate-agents-md.py salió con código {result.returncode}", file=sys.stderr)
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
def init_kiro(root: Path, forge: Path, config: dict):
|
|
802
|
-
"""Delega en el adapter de Kiro para generar los steering files."""
|
|
803
|
-
import subprocess
|
|
804
|
-
adapter = forge / "adapters" / "kiro" / "generate-steering.py"
|
|
805
|
-
if not adapter.exists():
|
|
806
|
-
print(f" [MISS] {adapter} — adapter de Kiro no encontrado", file=sys.stderr)
|
|
807
|
-
return
|
|
808
|
-
args = ["python3", str(adapter)]
|
|
809
|
-
if FORCE:
|
|
810
|
-
args.append("--force")
|
|
811
|
-
result = subprocess.run(args, cwd=str(root))
|
|
812
|
-
if result.returncode != 0:
|
|
813
|
-
print(f" [ERR] generate-steering.py salió con código {result.returncode}", file=sys.stderr)
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
def init_codex(root: Path, forge: Path, config: dict):
|
|
817
|
-
"""Delega en el adapter de Codex CLI para generar AGENTS.md y codex.md."""
|
|
818
|
-
import subprocess
|
|
819
|
-
adapter = forge / "adapters" / "codex" / "generate-codex-config.py"
|
|
820
|
-
if not adapter.exists():
|
|
821
|
-
print(f" [MISS] {adapter} — adapter de Codex no encontrado", file=sys.stderr)
|
|
822
|
-
return
|
|
823
|
-
args = ["python3", str(adapter)]
|
|
824
|
-
result = subprocess.run(args, cwd=str(root))
|
|
825
|
-
if result.returncode != 0:
|
|
826
|
-
print(f" [ERR] generate-codex-config.py salió con código {result.returncode}", file=sys.stderr)
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
def main():
|
|
830
|
-
if "--tool" not in sys.argv:
|
|
831
|
-
print("Uso: forge-init.py --tool <claude-code|opencode|kiro|codex|all> [--force] [--only=<agente>] [--forge <dir>]")
|
|
832
|
-
sys.exit(1)
|
|
833
|
-
|
|
834
|
-
idx = sys.argv.index("--tool")
|
|
835
|
-
tool = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else "claude-code"
|
|
836
|
-
|
|
837
|
-
# --forge <dir> o --forge=<dir> — permite a la extensión VS Code indicar la ruta explícita
|
|
838
|
-
forge_override: str | None = None
|
|
839
|
-
args = sys.argv[1:]
|
|
840
|
-
for i, arg in enumerate(args):
|
|
841
|
-
if arg.startswith("--forge="):
|
|
842
|
-
forge_override = arg[len("--forge="):]
|
|
843
|
-
elif arg == "--forge" and i + 1 < len(args):
|
|
844
|
-
forge_override = args[i + 1]
|
|
845
|
-
|
|
846
|
-
try:
|
|
847
|
-
root = find_project_root()
|
|
848
|
-
if forge_override:
|
|
849
|
-
forge = Path(forge_override)
|
|
850
|
-
if not forge.exists():
|
|
851
|
-
print(f"ERROR: forge dir no existe: {forge_override}", file=sys.stderr)
|
|
852
|
-
sys.exit(1)
|
|
853
|
-
else:
|
|
854
|
-
forge = find_forge_dir()
|
|
855
|
-
config = load_project(root)
|
|
856
|
-
except FileNotFoundError as e:
|
|
857
|
-
print(f"ERROR: {e}", file=sys.stderr)
|
|
858
|
-
print(" → Primer uso: python3 .agentic/scripts/forge-wizard.py (wizard interactivo)", file=sys.stderr)
|
|
859
|
-
sys.exit(1)
|
|
860
|
-
|
|
861
|
-
print(f"Proyecto : {config.get('project', {}).get('name', '?')}")
|
|
862
|
-
print(f"Root : {root}")
|
|
863
|
-
print(f"Forge : {forge}")
|
|
864
|
-
print(f"Tool : {tool}")
|
|
865
|
-
print(f"Force : {'sí — sobreescribe existentes' if FORCE else 'no — preserva existentes'}")
|
|
866
|
-
if ONLY_AGENT:
|
|
867
|
-
print(f"Only : {ONLY_AGENT} (solo ese agente)")
|
|
868
|
-
print()
|
|
869
|
-
|
|
870
|
-
if tool in ("claude-code", "all"):
|
|
871
|
-
print(f"--- Claude Code ---")
|
|
872
|
-
init_claude_code(root, forge, config)
|
|
873
|
-
print("\n Slash commands:")
|
|
874
|
-
install_claude_commands(root, forge, config)
|
|
875
|
-
print("\n Hooks:")
|
|
876
|
-
install_hooks(root, forge, config)
|
|
877
|
-
print("\n Wiki:")
|
|
878
|
-
init_wiki(root, forge, config)
|
|
879
|
-
print("\n CLAUDE.md:")
|
|
880
|
-
_generate_claude_md(root, forge, config)
|
|
881
|
-
print("\n settings.json:")
|
|
882
|
-
_generate_settings_json(root, config)
|
|
883
|
-
|
|
884
|
-
if tool in ("opencode", "all"):
|
|
885
|
-
print(f"\n--- OpenCode ---")
|
|
886
|
-
init_opencode(root, forge, config)
|
|
887
|
-
|
|
888
|
-
if tool in ("kiro", "all"):
|
|
889
|
-
print("\n--- Kiro ---")
|
|
890
|
-
init_kiro(root, forge, config)
|
|
891
|
-
|
|
892
|
-
if tool in ("codex", "all"):
|
|
893
|
-
print("\n--- Codex CLI ---")
|
|
894
|
-
init_codex(root, forge, config)
|
|
895
|
-
|
|
896
|
-
print("\nDone. Revisar los archivos generados antes de commitear.")
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
if __name__ == "__main__":
|
|
900
|
-
main()
|