@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.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/doctor.d.ts.map +1 -1
  3. package/dist/commands/doctor.js +4 -3
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/generate.js +1 -1
  6. package/dist/commands/generate.js.map +1 -1
  7. package/dist/commands/init.js +2 -2
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/lib/generators/codex.d.ts.map +1 -1
  10. package/dist/lib/generators/codex.js +10 -8
  11. package/dist/lib/generators/codex.js.map +1 -1
  12. package/dist/lib/generators/kiro.d.ts +1 -1
  13. package/dist/lib/generators/kiro.d.ts.map +1 -1
  14. package/dist/lib/generators/kiro.js +15 -16
  15. package/dist/lib/generators/kiro.js.map +1 -1
  16. package/dist/lib/generators/opencode.d.ts.map +1 -1
  17. package/dist/lib/generators/opencode.js +13 -12
  18. package/dist/lib/generators/opencode.js.map +1 -1
  19. package/dist/lib/paths.d.ts +1 -2
  20. package/dist/lib/paths.d.ts.map +1 -1
  21. package/dist/lib/paths.js +12 -16
  22. package/dist/lib/paths.js.map +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.js +1 -1
  25. package/package.json +2 -2
  26. package/assets/adapters/claude-code/generate-claude-md.py +0 -304
  27. package/assets/adapters/codex/generate-codex-config.py +0 -269
  28. package/assets/adapters/codex/hooks/codex.yaml.tpl +0 -43
  29. package/assets/adapters/codex/hooks/forge-codex-finish.sh +0 -158
  30. package/assets/adapters/codex/hooks/forge-codex-start.sh +0 -186
  31. package/assets/adapters/kiro/generate-steering.py +0 -367
  32. package/assets/adapters/opencode/generate-agents-md.py +0 -262
  33. package/assets/core/hooks/pre-bash-check.py +0 -202
  34. package/assets/core/hooks/pre-edit-check.py +0 -317
  35. package/assets/forge.py +0 -1265
  36. package/assets/requirements.txt +0 -2
  37. package/assets/scripts/aitmpl-search.py +0 -808
  38. package/assets/scripts/forge-add-opportunities.py +0 -92
  39. package/assets/scripts/forge-audit.py +0 -1061
  40. package/assets/scripts/forge-generate-all.py +0 -283
  41. package/assets/scripts/forge-init.py +0 -900
  42. package/assets/scripts/forge-migrate-project-yaml.py +0 -397
  43. package/assets/scripts/forge-scaffold-profile.py +0 -181
  44. package/assets/scripts/forge-teardown.py +0 -193
  45. package/assets/scripts/forge-validate-project-yaml.py +0 -457
  46. package/assets/scripts/forge-wizard.py +0 -1003
  47. package/assets/scripts/setup-codex.sh +0 -229
  48. package/assets/scripts/team-install.sh +0 -147
  49. package/assets/scripts/token-stats.py +0 -201
  50. package/dist/lib/python.d.ts +0 -4
  51. package/dist/lib/python.d.ts.map +0 -1
  52. package/dist/lib/python.js +0 -46
  53. 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()