SDD estilo zero-pi dentro de Claude Code, con un modelo del CPAM por fase

El SDD de zero-pi (/forge: clarify → explore → plan → analyze → build → veredicto, con tope de rondas) como mod de Claude Code (v2.1.287+). La sesión principal sigue en tu plan de Claude; cada fase corre en el modelo que elijas: cualquiera del CPAM local (GPT, Gemini, GLM, Kimi, DeepSeek, Claude por cuenta…) o Claude a través de Claude Code.
forge es para features: algo que merece una spec, un plan con tareas y un veredicto que lo revise. Para el laburo chico de todos los días (un typo, un renombre, un estilo, un fix de una línea, un ajuste de config) está NODD, la otra extensión de Gon para pi y Claude Code: te hace declarar la ruta antes de escribir y no deja tildar una tarea sin una corrida de tests observada. Lo hacés directo y con los tests corridos de verdad.
forge no decide por vos: si clarify marca el pedido como Size: small, te lo dice una vez (en interactive, en la pausa que sigue a clarify; en automatic, en el log y en routing.log) y el resumen final lo repite en una línea. El run sigue igual: nunca bloquea ni cambia el orden de las fases.
claude --plugin-dir ~/projects/forge
| Comando | Qué hace | ||
|---|---|---|---|
/forge <pedido> | Arranca un run. Banderas: --auto / --interactive, --cap N, `--profile barato\ | calidad\ | <tuyo>` |
/forge o /forge ui | Abre el panel FORGE (con NERV cargado, un run nuevo no lo abre solo: se sigue en la pestaña FORGE de NERV) | ||
/forge status | Modelos por fase, estado del run, tiempos y tokens | ||
/forge stop | Corta el run (funciona en medio de un turno) | ||
/forge continue [<slug>] (o seguir) | Retoma un run parado o cortado desde la fase que faltaba. Con <slug>, o sin slug y sin run parado, adopta un handoff de NODD (ver abajo). Acepta --auto / --interactive, --cap N y --profile para el run adoptado | ||
/forge profile [nombre] · /forge profile save <nombre> · /forge profile delete <nombre> | Ver, activar, guardar o borrar perfiles (los de fábrica no se borran) | ||
/forge model <fase> <modelo> | Cambia el modelo de una fase (clarify, explore, plan, analyze, build, veredicto) | ||
| `/forge phase <clarify\ | analyze> <on\ | off>` | Activa o saltea uno de los dos gates (en el panel: botón saltear / activar). Las otras cuatro fases no se pueden saltear |
/forge effort <fase> <nivel> | Cambia el effort de una fase: auto (no toca nada), off, minimal, low, medium, high, xhigh (los niveles de pi/zero-pi) | ||
| `/forge mode <interactive\ | automatic\ | ask>` | Modo por defecto. ask (o preguntar) lo consulta al arrancar |
/forge cap <n> | Tope de rondas por defecto (3) |
Handoff desde NODD (contrato en zero-pi/docs/forge-contract.md, sección Handoff desde NODD): /nodd-promote <slug> deja un solo archivo, .sdd/<slug>/requirements.md, con la línea Promoted from the NODD run. Un directorio es un handoff cuando tiene ese requirements.md y no tiene run.json, execution.json, design.md, tasks.md ni request.md (noddHandoffProblem en logic.ts). /forge continue <slug> lo adopta: abre un run con ese mismo slug y directorio, copia requirements.md a request.md byte a byte, saltea clarify (no hay línea Size: ni aviso de NODD) y arranca en explore; después sigue igual que cualquier run. Los briefs de explore y plan nombran requirements.md y aclaran que lo de Already resolved — do not redo es contexto ya hecho, no trabajo. El run queda marcado con origin: "nodd" en run.json y en state.json, y el panel lo muestra (↳ desde NODD). Sin slug: si no hay un run parado, adopta el único handoff de .sdd/; si hay varios, los lista; si no hay ninguno, dice lo de siempre. Un directorio que no es handoff no se adopta y forge dice por qué. Si un run adoptado se corta antes de que plan escriba spec.md y el mod se recargó (ya no está en memoria), /forge continue <slug> lo retoma desde explore, sin clarify, conservando routing.log; con spec.md escrito no se retoma así. En claude -p, /forge continue <slug> hace lo mismo y reescribe el prompt con la llamada a explore.
Modo interactive: después de cada fase aparece «¿Seguimos con X?» con Continuar / Parar. Lo que escribas en Other se le pasa como ajuste a la fase siguiente.
Headless (claude -p): /forge … también anda, pero ahí el comando no se registra (en -p un comando de mod no tiene sesión para arrancar un turno), así que lo toma el hook de prompt.submit. Siempre corre en automatic. Para pruebas:
claude -p "/forge --auto agregá suma(a, b) con su test" --plugin-dir ~/projects/forge \
--permission-mode default --allowedTools Agent Bash Read Glob Grep Edit Write
ag3/…, prolite/…, oc/…) o sin prefijo pero que no sea Claude (glm-5.3): va al CPAM (POST http://127.0.0.1:8317/v1/messages, clave en ~/.config/cli-proxy-api/api-key.txt).haiku / sonnet / opus / fable, o un id claude-* sin prefijo: pasa por Claude Code (tu plan).profile save, no se pueden borrar). Cada id de CPAM respondió 200 el 2026-10-02 y los de build devolvieron tool_use con una tool de prueba:| Perfil | clarify | explore | plan | analyze | build | veredicto |
|---|---|---|---|---|---|---|
turbo | ag3/gemini-3.5-flash-lite · minimal | ag3/gemini-3.5-flash-lite · minimal | prolite/gpt-6-luna · low | prolite/gpt-6-luna · low | ag3/gemini-3.5-flash-lite · low | prolite/gpt-6-luna · low |
barato | ag3/gemini-3.7-flash-high · low | ag3/gemini-3.7-flash-high · low | ag3/gemini-3.7-flash-high · medium | prolite/gpt-6-luna · medium | ag3/gemini-3.7-flash-high · medium | prolite/gpt-6-sol · high |
equilibrado | ag3/gemini-3.7-flash-high · low | ag3/gemini-3.7-flash-high · medium | prolite/gpt-6-sol · high | prolite/gpt-6-luna · high | prolite/gpt-6-luna · medium | sonnet · high |
calidad | ag3/gemini-3.8-flash-high · medium | ag3/gemini-3.8-flash-high · high | prolite/gpt-6-sol · xhigh | prolite/gpt-6-astra · high | prolite/gpt-6-sol · high | opus · xhigh |
open-source | ds/deepseek-flash · (n/a) | ds/deepseek-flash · (n/a) | ds/deepseek-v4-pro · high | ds/deepseek-v4-pro · high | ds/deepseek-v4-pro · high | ag3/gpt-oss-120b-medium · (n/a) |
gemini | ag3/gemini-3.8-flash-high · low | ag3/gemini-3.8-flash-high · medium | ag3/gemini-pro-agent · high | ag3/gemini-pro-agent · high | ag3/gemini-3.8-flash-high · high | ag3/gemini-pro-agent · high |
openai | prolite/gpt-6-luna · low | prolite/gpt-6-luna · medium | prolite/gpt-6-sol · xhigh | prolite/gpt-6-sol · high | prolite/gpt-6-sol · high | prolite/gpt-6-astra · xhigh |
solo-claude | haiku · medium | haiku · medium | opus · high | sonnet · high | sonnet · high | opus · xhigh |
Van ordenados de más rápido/barato a más fuerte (turbo → calidad) y después los de un solo proveedor. Clarify va en un modelo barato (sólo anota supuestos) y analyze en uno fuerte (es un revisor adversarial del plan), como en zero-pi. Lo fuerte de OpenAI va en gpt-6-sol: el plan de ChatGPT de la cuenta no ofrece gpt-6.1-sol (al 2026-10-03 da 400 "not supported when using Codex with a ChatGPT account"); lo rápido va en gpt-6-luna. opus/sonnet/haiku son alias del plan de Claude Code (hoy Opus 5.5 y Sonnet 5.5). Los pares modelo+effort nuevos de clarify y analyze respondieron 200 por el CPAM el 2026-10-02 (ag3/gemini-pro-agent contesta como gemini-pro-default).
~/.pi/zero.json y suma cada perfil como zero:<nombre> con los models y el thinking de las seis fases (el thinking pasa a ser el effort). Un perfil viejo sin clarify/analyze usa el modelo de explore para clarify y el de plan para analyze. Deja afuera los que usan una cuenta excluida en cualquier fase o la nombran en el nombre del perfil (ver abajo). Lo mismo vale para perfiles propios guardados antes de las seis fases. No se guardan en el store de forge ni se pueden borrar desde forge: se editan en zero-pi.claude del plan, personal, priv8, prolite, plus, ag*, ds, oc, cpam), los modelos de cada grupo por familia y del más nuevo al más viejo, y los muestra con el displayName del CPAM (GET /v1beta/models), como el proveedor cliproxy de pi.openai va todo por prolite/: la cuenta plus/ devolvía 429 usage_limit_reached.open-source usa DeepSeek directo (ds/, pago por token) y gpt-oss: los oc/* (GLM, Kimi, DeepSeek por OpenCode Go) devuelven 400 MissingSessionID porque OpenCode ahora exige el header x-opencode-session y el CPAM no lo manda, y los glm-* sin prefijo dan 429 por falta de saldo.~/.config/forge/exclude.json, un array de strings (por ejemplo ["trabajo"] saca trabajo/* del selector y los perfiles de zero-pi que la usan o tienen trabajo en el nombre). Sirve para no mezclar una cuenta del laburo con lo personal. Si el archivo no existe, no se excluye nada.Pane forge (título FORGE) con la paleta EVA: el estado del run, la ronda x/cap, los veredictos, el tiempo total y una tarjeta por fase (pendiente ·, spinner, ✔ o ✖). Para cada fase muestra tiempo, pasos y tokens, qué modelo respondió de verdad y dos Select, uno para el grupo/cuenta y otro para el modelo. Los Select del motor aceptan 64 opciones como máximo, por eso van en dos niveles. Además tiene perfil, modo, cap con −/+, un campo para escribir el pedido y los botones ▶ Iniciar / ■ Parar.
prolite/gpt-6-luna(high); off se manda como (none). Medido el 2026-10-02: prolite/gpt-6-sol(none) dio 0 tokens de razonamiento y (xhigh) 12 en la misma pregunta; ag3/gemini-3.8-flash-high(low) 2.5k tokens de salida y (xhigh) 14k; prolite/gpt-6-luna(low) 0 y (high) 53. thinking.budget_tokens y output_config.effort en el body no cambiaron nada en GPT.turn.step reescribe effort (minimal/off bajan a low).n/a: gpt-oss-* y ds/deepseek-flash (mismo razonamiento con none y xhigh), oc/* (no responden por el CPAM) y glm-* directo (sin saldo).routing.log anota el id con el sufijo que se mandó en cada paso (CPAM pedido prolite/gpt-6-luna(low) · respondió gpt-6-luna).~/.local/state/forge/state.jsonforge lo reescribe en cada cambio de configuración y en cada paso de un run (no en claude -p). Lo lee la pestaña FORGE de NERV, que manda las órdenes de vuelta con $.command.run({ command: 'forge', args }).
{ "version", "profile", "profiles": { "<nombre>": { "clarify", "explore", "plan", "analyze", "build", "veredicto", "effort": { … }, "builtin", "source": "forge|zero-pi" } },
"phaseOrder": ["clarify", "explore", "plan", "analyze", "build", "veredicto"], "phases": [ las seis, siempre ],
"models": { "<fase>": "<id>" × 6 }, "efforts": { … × 6 }, "effortLevels": [], "effortNotes": { "<fase>": "<por qué no aplica>" × 6 },
"mode": "interactive|automatic|ask", "cap",
"catalog": { "<grupo>": ["<id>"] }, "groupLabels": { "<grupo>": "prolite · GPT" }, "labels": { "<id>": "GPT 6.1 Sol" },
"run": null | { "status": "running|paused|pasa|no-verificado|bloqueado|falló|parado", "request", "slug", "round", "cap", "phase",
"phaseOrder": [], "startedAt", "endedAt", "batch": null | { "index", "total", "tasks": ["T002"], "parallel"?: true },
"phases": [{ "name", "status": "pending|running|done|failed", "model", "effort", "responded", "ms", "startedAt", "tokensIn", "tokensOut", "steps" }],
"verdicts": [], "decisions": ["replan", "continue"], "size": null | "small" | "normal", "origin": null | "nodd" },
"updatedAt" }
phaseOrder (arriba) es el orden activo de la configuración: sin las fases salteadas. run.phaseOrder y run.phases son las del run en curso, en orden (un run arrancado con clarify salteado no la lista). paused es la pausa entre fases del modo interactive, mientras espera Continuar / Parar (o la respuesta a una pregunta bloqueante de clarify). bloqueado es el segundo replan de analyze. decisions son las decisiones de analyze en orden, size el tamaño que marcó clarify (null antes de clarify o con clarify salteado, que cuenta como normal) y origin vale "nodd" cuando el run adoptó un handoff de NODD, y batch la unidad de build en curso (índice desde 1): un lote secuencial, o una tanda paralela con parallel: true y en tasks todas las tareas de la tanda. El catálogo saca las cuentas excluidas y los modelos de imagen, y suma el grupo claude (haiku/sonnet/opus/fable de tu plan). Si hay dos sesiones con forge abiertas, la última que escribe gana.
.sdd/<slug>/ del repo de trabajo)| Archivo | Lo escribe | |
|---|---|---|
request.md | forge, el pedido textual (en un run adoptado de NODD, copia exacta de requirements.md) | |
requirements.md | NODD (/nodd-promote): forge sólo lo lee para adoptar el handoff | |
clarifications.md | forge, con la respuesta de clarify (## Status, ## Size con la línea exacta `Size: small\ | normal, ## Assumptions, ## Non-blocking decisions, ## Blocking questions) y, si estaba bloqueado, un ## Resolution` con lo que contestaste o con la nota de modo automatic |
findings.md | forge, con la respuesta de explore | |
proposal.md, spec.md, design.md, tasks.md | la fase plan (forge valida tasks.md como /zero-validate: cada T### con files, depends, evidence y review, y dependencias hacia atrás) | |
checklist.md | forge, con la respuesta de analyze (## Analyzed artifacts, ## Checklist, ## Blockers, `Decision: continue\ | replan`) |
tdd-evidence.md, checkboxes de tasks.md | la fase build; en una tanda paralela los hijos escriben tdd-evidence/<T###>.md y forge tilda y junta la evidencia | |
build-rN.md, veredicto-rN.md | forge, con lo que devolvió cada ronda | |
rounds.json | forge: { cap, verdicts } | |
run.json | forge: el estado completo del run | |
routing.log | forge: cada paso de cada fase, qué modelo se pidió y cuál respondió (el campo model de la respuesta del CPAM) y los tokens |
Clarify registra supuestos antes de explorar y sólo frena ante una ambigüedad que mandaría todo el build para otro lado. En modo interactive, si vuelve blocked, forge te muestra las preguntas con $.ui.ask: lo que escribas en Other queda como respuesta en clarifications.md, Seguir con los supuestos sigue igual y Parar corta el run (se retoma con /forge continue). En automatic y en claude -p nadie contesta: el brief le pide que no bloquee, y si igual bloquea forge anota en ## Resolution que se siguió con los supuestos. Clarify también deja la línea Size: small o Size: normal: small sólo cuando el pedido entero es un cambio de un paso que no amerita una spec (typo, renombre, estilo, fix de una línea o ajuste de config, en uno o dos archivos y sin comportamiento nuevo); ante la duda o si falta la línea, normal (parseSize). Con small forge recomienda NODD una sola vez, como dice ¿forge o NODD?, y lo guarda como size en run.json.
Analyze revisa la calidad del plan (ambigüedad, criterios testeables, grafo de tareas, evidencia concreta, TDD, alcance, tamaño) después de la validación estructural. continue pasa a build; el primer replan vuelve a plan con los ## Blockers en el brief y después otra vez a analyze; el segundo replan del run lo corta como BLOQUEADO (no verificado). Ni clarify ni analyze cuentan rondas.
Build por lotes y tandas paralelas (como zero-pi, contrato en zero-pi/docs/forge-contract.md): antes de cada unidad de build, forge vuelve a leer tasks.md y elige la próxima, por código:
depends: ya están [x] o entregadas en esta ronda). Si tiene [P] en la cabecera (- [ ] T002 — Agregar parser [P]), se le suman, en orden, las siguientes tareas elegibles con [P] cuyos files: no se pisan (los paths se comparan después de sacar (new), normalizar ./ y .., y recortar el code root; un path relativo y otro absoluto que termina igual cuentan como el mismo), hasta 3 (PARALLEL_MAX). Si junta 2 o más, la instrucción al modelo principal le pide N llamadas a Agent en un solo mensaje, y Claude Code las corre a la vez.review: ~N changed lines), cortado antes de una tarea cuyas depends: no estén hechas ni en el lote y antes de una [P] que podría abrir una tanda de 2 o más. Sin ningún [P] en tasks.md, los lotes salen iguales que antes; si entra todo en un lote, se comporta como siempre.En una tanda, el hook tool.call le asigna a cada llamada la siguiente tarea libre de la tanda (se toma sin ningún await en el medio, así dos llamadas simultáneas nunca agarran la misma), y lleva un registro por llamada (flights en run.json, por tool_use_id o por agentId si quedó en segundo plano). Cada hijo implementa sólo su tarea, no toca tasks.md ni tdd-evidence.md, escribe su evidencia en tdd-evidence/<T###>.md y corre sólo sus tests. Cuando vuelve el último, forge, por código: tilda [x] las entregadas en tasks.md (respetando la forma de la cabecera: - [x] …, ## [x] … o ### T001 — [x] …), agrega cada tdd-evidence/<T###>.md a tdd-evidence.md bajo ## T### (parallel wave <i>) y suma los sobres a build-rN.md bajo ## Wave i/n: …; después avanza una sola vez. Los otros resultados le dicen al modelo que no haga nada. Los tokens de la fase build se suman entre los hijos y el tiempo es el de reloj.
[ ] y se reintenta una vez, sola, como lote secuencial; si vuelve a fallar, el run termina como FALLÓ.[forge] Extra … call refused (también en las otras fases: una sola llamada por fase a la vez).async_launched) cada hijo cierra en su turn.complete; el último manda la próxima instrucción con $.prompt.submit.TDD: si el repo tiene .sdd/config.json con tdd.mode: "off", forge se lo avisa a build y veredicto; si no, van en strict.
El veredicto tiene que terminar en VEREDICTO: pasa|corregir|replantear (parseVerdict también acepta Veredicto: \x\` o la palabra sola en la última línea). pasa cierra el run, corregir vuelve a build y replantear vuelve a plan (y de ahí a analyze, como en zero-pi), siempre con el razonamiento del veredicto en el brief. Si se llega al cap sin pasa, el run queda en NO VERIFICADO. Una fase que no entrega (clarify sin ## Status, sin findings, artefactos de plan faltantes o mal formados, analyze sin Decision:`, veredicto sin línea parseable o error del CPAM) se reintenta una vez y, si vuelve a fallar, el run termina como FALLÓ.
session.start se registran forge:clarify|explore|plan|analyze|build|veredicto con $.agent.register. Clarify, explore, analyze y veredicto tienen Read/Glob/Grep/Bash (sólo lectura: forge escribe clarifications.md, findings.md y checklist.md con lo que devuelven; en zero-pi clarify y analyze escriben su archivo ellos); plan y build suman Write/Edit. El modelo registrado es haiku: si el ruteo fallara de forma abierta (fail-open), la fase caería en el modelo más barato y no en Opus.$.prompt.submit o reescribiendo el prompt en headless) «llamá a Agent con forge:explore». El hook tool.call sobre Agent reescribe la llamada: fija la fase que toca, mete el brief completo como prompt y elige el modelo. Cuando la fase vuelve, forge guarda los artefactos, decide la transición (advance) y reemplaza el resultado de la tool por la próxima instrucción. Así el orden de las fases lo controla el mod y no el modelo, y el contexto principal casi no crece.turn.step reconoce los pasos de un subagente forge:* y, si el modelo de la fase es del CPAM, responde él en lugar del motor. Arma los mensajes con $.session.messages({ agentId, as: 'api' }), llama al CPAM, streamea text / tool / input / stop, Claude Code ejecuta las tools y vuelve a llamar al hook en el paso siguiente. Si el modelo es de Claude, hace yield* next(e) (con model cambiado si es un id).run_in_background: false y agent.spawn con background: false (verificado). forge maneja los dos casos. En primer plano (lo que pasa en -p) avanza dentro del tool.call. En segundo plano, la tool devuelve async_launched, la fase se cierra en el turn.complete del subagente y forge manda la próxima instrucción con $.prompt.submit.$.agent.spawn desde el mismo mod no pasa por el turn.step de ese mod: el mod se saltea a sí mismo. $.tool.call({ tool: 'Agent' }) está prohibido: "runs the Agent tool: that is $.agent.spawn (host check)". Por eso el modelo principal tiene que lanzar cada fase.$.prompt.submit desde un command.run falla ("it would wait on the turn this hook is holding"). Desde un timer ($.clock.after) anda en la REPL. En claude -p "/comando" no hay sesión: tanto submit como spawn fallan con "no session is bound".-p, un /algo que no es un comando registrado llega como texto a prompt.submit (con origin sdk). CLAUDE_CODE_ENTRYPOINT vale sdk-cli en -p y así forge detecta el modo headless.<system-reminder> tu CLAUDE.md global, MEMORY.md y tu mail (unos 65k caracteres con credenciales). Antes de mandar algo al CPAM, cleanMessages saca todos los system-reminders salvo el entorno y la fecha. También bajó el input de cada paso de explore de unos 22k a unos 1.7k tokens.omitClaudeMd: true en $.agent.register hizo que el modelo principal no viera el tipo forge:explore (pasó una vez). Se sacó: la privacidad la resuelve el filtro del punto anterior.thinking con firma (cpa-gemini-carrier-v1) antes de cada tool_use. El motor no guarda el thinking de un paso hecho por un hook, así que forge lo cachea por id de tool_use y lo vuelve a poner en el paso siguiente.Select acepta como máximo 64 opciones (con más, el motor rechaza el render del pane).$.tool.list() no trae los input schemas: los de Bash, Read, Glob, Grep, Edit y Write están declarados a mano en register.ts.--plugin-dir en la REPL, el mod se recarga y pierde el run en memoria. El estado queda en .sdd/<slug>/run.json pero no se rehidrata.claude plugin validate . --strict # limpio
claude plugin test # 70 tests: ruteo, catálogo y orden de modelos, effort (sufijo y n/a), perfiles de fábrica y de zero-pi con seis fases, parseo del veredicto, Status y Size de clarify y Decision de analyze, el aviso de NODD phooks/register.ts 1769 lines1import {
2 PHASES,
3 PHASE_TOOLS,
4 DEFAULT_PROFILES,
5 phaseOfAgentType,
6 routeFor,
7 parseVerdict,
8 advance,
9 slugify,
10 parseStart,
11 parseContinue,
12 noddHandoffProblem,
13 adoptedRunResumable,
14 modelCatalog,
15 groupOf,
16 tokens,
17 mmss,
18 cleanMessages,
19 thinkingByTool,
20 stateSnapshot,
21 BUILTIN_PROFILES,
22 EFFORTS,
23 DEFAULT_EFFORTS,
24 AUTO_EFFORTS,
25 isEffort,
26 cpamModel,
27 claudeEffort,
28 effortBlocked,
29 zeroProfiles,
30 isExcluded,
31 fillPhases,
32 phaseOrder,
33 OPTIONAL_PHASES,
34 REPLAN_CAP,
35 parseDecision,
36 parseClarifyStatus,
37 parseSize,
38 pickAnswer,
39 modelUnavailable,
40 DEFAULT_FALLBACK,
41 forgeMayRun,
42 section,
43 parseTasks,
44 validateTasks,
45 planUnits,
46 tickTasks,
47 waveEvidence,
48 waveEnvelope,
49 builtinModified,
50 editZeroProfile,
51 profileName,
52} from './logic.ts'
53import type { Phase, Verdict, Mode, RunStatus, Effort, Decision, Outcome, WaveResult, Size, StartArgs } from './logic.ts'
54import { PHASE_PROMPTS, briefFor, startInstruction, nextInstruction, finalInstruction, waveChildInstruction, extraCallDenial, NODD_HINT } from './prompts.ts'
55
56const PANE = 'forge'
57const VERSION = '0.4.0'
58const CPAM = 'http://127.0.0.1:8317'
59const C = {
60 violet: '#8b5cf6',
61 lime: '#a3e635',
62 amber: '#f59e0b',
63 red: '#ef4444',
64 pink: '#ff4d6d',
65 cyan: '#22d3ee',
66 text: '#ece2fb',
67 muted: '#8a73ad',
68 ink: '#140c22',
69}
70const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
71const AGENT_MODELS = ['haiku', 'sonnet', 'opus', 'fable']
72
73const TOOLS: Record<string, { description: string; input_schema: any }> = {
74 Bash: {
75 description: 'Run a shell command (bash) and return stdout/stderr. Use absolute paths. Default timeout 120000 ms.',
76 input_schema: {
77 type: 'object',
78 properties: {
79 command: { type: 'string', description: 'The command to run' },
80 description: { type: 'string', description: 'What the command does, 5-10 words' },
81 timeout: { type: 'number', description: 'Timeout in milliseconds (max 600000)' },
82 },
83 required: ['command'],
84 },
85 },
86 Read: {
87 description: 'Read a file from the local filesystem. file_path must be absolute. Returns lines numbered from 1.',
88 input_schema: {
89 type: 'object',
90 properties: {
91 file_path: { type: 'string', description: 'Absolute path of the file' },
92 offset: { type: 'number', description: 'Line to start from' },
93 limit: { type: 'number', description: 'How many lines to read' },
94 },
95 required: ['file_path'],
96 },
97 },
98 Glob: {
99 description: 'Find files by glob pattern (e.g. "**/*.ts"), sorted by modification time.',
100 input_schema: {
101 type: 'object',
102 properties: {
103 pattern: { type: 'string', description: 'The glob pattern' },
104 path: { type: 'string', description: 'Absolute directory to search in' },
105 },
106 required: ['pattern'],
107 },
108 },
109 Grep: {
110 description: 'Search file contents with a regular expression (ripgrep).',
111 input_schema: {
112 type: 'object',
113 properties: {
114 pattern: { type: 'string', description: 'Regular expression' },
115 path: { type: 'string', description: 'Absolute file or directory to search in' },
116 glob: { type: 'string', description: 'Glob to filter files, e.g. "*.js"' },
117 output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'], description: 'Default files_with_matches' },
118 '-i': { type: 'boolean', description: 'Case insensitive' },
119 '-n': { type: 'boolean', description: 'Show line numbers (content mode)' },
120 type: { type: 'string', description: 'File type, e.g. js, py' },
121 head_limit: { type: 'number', description: 'Limit the number of results' },
122 },
123 required: ['pattern'],
124 },
125 },
126 Edit: {
127 description: 'Replace an exact string in a file. Read the file first. old_string must match exactly and be unique unless replace_all is true.',
128 input_schema: {
129 type: 'object',
130 properties: {
131 file_path: { type: 'string', description: 'Absolute path of the file' },
132 old_string: { type: 'string', description: 'Exact text to replace' },
133 new_string: { type: 'string', description: 'Replacement text' },
134 replace_all: { type: 'boolean', description: 'Replace every occurrence' },
135 },
136 required: ['file_path', 'old_string', 'new_string'],
137 },
138 },
139 Write: {
140 description: 'Write a whole file (creates or overwrites). Read an existing file before overwriting it. Absolute path.',
141 input_schema: {
142 type: 'object',
143 properties: {
144 file_path: { type: 'string', description: 'Absolute path of the file' },
145 content: { type: 'string', description: 'The whole content' },
146 },
147 required: ['file_path', 'content'],
148 },
149 },
150}
151
152type Unit = { index: number; total: number; tasks: string[]; parallel: boolean; whole: boolean; retry?: boolean; reason?: string; claimed: string[]; results: Record<string, WaveResult> }
153type Flight = { phase: Phase; task?: string; agentId?: string }
154type Stat = { status: 'pending' | 'running' | 'done' | 'error'; model: string; effort?: Effort; answeredBy: string; ms: number; startedAt: number; inTok: number; outTok: number; steps: number }
155type Run = {
156 slug: string
157 dir: string
158 cwd: string
159 request: string
160 mode: Mode
161 cap: number
162 verdicts: Verdict[]
163 expected?: Phase
164 status: RunStatus
165 order: Phase[]
166 startedAt: number
167 endedAt?: number
168 stats: Record<Phase, Stat>
169 decisions: Decision[]
170 clarified: boolean
171 size?: Size
172 tdd: 'strict' | 'off'
173 unit?: Unit
174 unitIdx: number
175 delivered: string[]
176 retryAlone: { id: string; reason: string }[]
177 blockers?: string
178 feedback?: { verdict: Verdict; text: string }
179 lastVerdictText?: string
180 adjust?: string
181 retried: boolean
182 retryReason?: string
183 note: string
184 routing: string[]
185 turnId?: string
186 awaitTurn: boolean
187 flights: Record<string, Flight>
188 asyncSeen: boolean
189 owner?: string
190 origin?: 'nodd'
191}
192type Config = { mode: 'preguntar' | Mode; cap: number; profile: string; models: Record<Phase, string>; efforts: Record<Phase, Effort>; skip: Phase[] }
193
194let home = ''
195let cpamKey = ''
196let headless = false
197let frame = 0
198let paneOpen = false
199let draft = ''
200const pickedGroup: Partial<Record<Phase, string>> = {}
201let config: Config = { mode: 'preguntar', cap: 3, profile: 'barato', models: { ...DEFAULT_PROFILES.barato }, efforts: { ...DEFAULT_EFFORTS.barato }, skip: [] }
202let profiles: Record<string, Record<Phase, string>> = { ...DEFAULT_PROFILES }
203let profileEfforts: Record<string, Record<Phase, Effort>> = { ...DEFAULT_EFFORTS }
204let modelIds: string[] = []
205let modelLabels: Record<string, string> = {}
206let zeroNames: string[] = []
207let excluded: string[] = []
208let modelsNote = 'cargando modelos del CPAM…'
209const registerErrors: string[] = []
210let run: Run | undefined
211let asking = false
212let stateDirReady = false
213const agentPhase = new Map<string, Phase>()
214const answers = new Map<string, string>()
215const stepTexts = new Map<string, string[]>()
216const handbacks = new Map<string, string>()
217const autoAgents = new Set<string>()
218const cpamCalls = new Map<string, string>()
219const unavailable = new Map<string, number>()
220let fallbackModel = DEFAULT_FALLBACK
221const HANDBACK_TOOL = {
222 name: 'SubagentHandback',
223 description: 'Deliver your final report to your caller. The call ends your run, so make it your last step: put your whole report in message.',
224 input_schema: { type: 'object', properties: { message: { type: 'string', description: 'your full report' } }, required: ['message'] },
225}
226const thinking = new Map<string, any[]>()
227
228const now = () => Date.now()
229const stamp = () => {
230 const d = new Date()
231 return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
232}
233const clip = (s: string, n: number) => {
234 s = String(s ?? '')
235 return s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s
236}
237const blankStat = (model: string, effort?: Effort): Stat => ({ status: 'pending', model, effort, answeredBy: '', ms: 0, startedAt: 0, inTok: 0, outTok: 0, steps: 0 })
238const roundNow = (r: Run) => Math.min(r.cap, r.verdicts.length + (r.expected === 'build' || r.expected === 'veredicto' ? 1 : 0)) || 0
239const STATUS_LABEL: Record<RunStatus, string> = {
240 running: 'CORRIENDO',
241 pasa: 'PASA ✔',
242 'no-verificado': 'NO VERIFICADO',
243 bloqueado: 'BLOQUEADO',
244 fallido: 'FALLÓ',
245 parado: 'PARADO',
246 cortado: 'CORTADO',
247}
248
249const mapValues = <T, U>(o: Record<string, T>, f: (v: T) => U) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, f(v)]))
250
251type Own = { profiles: Record<string, Record<Phase, string>>; efforts: Record<string, Record<Phase, Effort>> }
252let zeroImport: Own = { profiles: {}, efforts: {} }
253let zeroStamp = ''
254let zeroBackedUp = false
255let editing = 0
256let editGen = 0
257
258async function readOwn($: any): Promise<Own> {
259 const sp: any = await $.store.get('profiles').catch(() => undefined)
260 const se: any = await $.store.get('profileEfforts').catch(() => undefined)
261 return {
262 profiles: sp && typeof sp === 'object' ? mapValues<any, Record<Phase, string>>(sp, fillPhases) : {},
263 efforts: se && typeof se === 'object' ? mapValues<any, Record<Phase, Effort>>(se, (e) => ({ ...AUTO_EFFORTS, ...e })) : {},
264 }
265}
266
267function currentOwn(): Own {
268 const keep = Object.keys(profiles).filter((n) => !zeroNames.includes(n))
269 return { profiles: Object.fromEntries(keep.map((n) => [n, profiles[n]!])), efforts: Object.fromEntries(keep.filter((n) => profileEfforts[n]).map((n) => [n, profileEfforts[n]!])) }
270}
271
272function rebuild(own: Own) {
273 profiles = { ...DEFAULT_PROFILES, ...own.profiles, ...zeroImport.profiles }
274 profileEfforts = { ...DEFAULT_EFFORTS, ...own.efforts, ...zeroImport.efforts }
275 zeroNames = Object.keys(zeroImport.profiles)
276}
277
278function importZero(raw: string): boolean {
279 let data: any
280 try {
281 data = raw ? JSON.parse(raw) : {}
282 } catch {
283 return false
284 }
285 zeroImport = zeroProfiles(data, excluded)
286 return true
287}
288
289async function zeroMtime($: any): Promise<string> {
290 const st: any = home ? await $.fs.stat(`${home}/.pi/zero.json`).catch(() => undefined) : undefined
291 return st ? `${st.mtimeMs}:${st.size}` : ''
292}
293
294async function saveOwn($: any) {
295 const keep = Object.keys(profiles).filter((n) => !zeroNames.includes(n) && (!BUILTIN_PROFILES.includes(n) || builtinModified(n, profiles[n], profileEfforts[n])))
296 await $.store.set('profiles', Object.fromEntries(keep.map((n) => [n, profiles[n]]))).catch(() => undefined)
297 await $.store.set('profileEfforts', Object.fromEntries(keep.filter((n) => profileEfforts[n]).map((n) => [n, profileEfforts[n]]))).catch(() => undefined)
298}
299
300async function loadConfig($: any) {
301 const own = await readOwn($)
302 try {
303 const ex = JSON.parse(String((home ? await $.fs.read(`${home}/.config/forge/exclude.json`).catch(() => '') : '') || '[]'))
304 excluded = Array.isArray(ex) ? ex.filter((x) => typeof x === 'string' && x) : []
305 } catch {
306 excluded = []
307 }
308 try {
309 const fb = JSON.parse(String((home ? await $.fs.read(`${home}/.config/forge/fallback.json`).catch(() => '') : '') || '{}'))
310 fallbackModel = typeof fb.model === 'string' && fb.model ? fb.model : DEFAULT_FALLBACK
311 } catch {
312 fallbackModel = DEFAULT_FALLBACK
313 }
314 zeroStamp = await zeroMtime($)
315 if (!importZero(String((home ? await $.fs.read(`${home}/.pi/zero.json`).catch(() => '') : '') || ''))) zeroImport = { profiles: {}, efforts: {} }
316 rebuild(own)
317 const saved: any = await $.store.get('config').catch(() => undefined)
318 if (saved && typeof saved === 'object') {
319 const base = profiles[saved.profile] || {}
320 config = {
321 ...config,
322 ...saved,
323 models: fillPhases({ ...(saved.models ? base : config.models), ...(saved.models || {}) }),
324 efforts: { ...AUTO_EFFORTS, ...(profileEfforts[saved.profile] || {}), ...(saved.efforts || {}) },
325 skip: Array.isArray(saved.skip) ? saved.skip.filter((p: Phase) => OPTIONAL_PHASES.includes(p)) : [],
326 }
327 }
328}
329
330async function saveConfig($: any) {
331 await $.store.set('config', config).catch(() => undefined)
332 await exportState($)
333}
334
335function currentPhase(r: Run): Phase | undefined {
336 return r.order.find((p) => r.stats[p].status === 'running') || r.expected
337}
338
339async function exportState($: any) {
340 if (headless) return
341 if (!home) home = (await $.env.get('HOME').catch(() => '')) || ''
342 if (!home) return
343 if (!editing) await syncConfig($)
344 const dir = `${home}/.local/state/forge`
345 if (!stateDirReady) {
346 const made = await $.process.run(['mkdir', '-p', dir]).catch(() => ({ exitCode: 1 }))
347 stateDirReady = made.exitCode === 0
348 }
349 const snap = stateSnapshot({
350 version: VERSION,
351 profile: config.profile,
352 profiles,
353 profileEfforts,
354 zeroProfiles: zeroNames,
355 models: config.models,
356 efforts: config.efforts,
357 skip: config.skip,
358 labels: modelLabels,
359 mode: config.mode,
360 cap: config.cap,
361 modelIds,
362 asking,
363 now: now(),
364 run: run && {
365 status: run.status,
366 request: run.request,
367 slug: run.slug,
368 round: roundNow(run),
369 cap: run.cap,
370 phase: currentPhase(run),
371 order: run.order,
372 startedAt: run.startedAt,
373 endedAt: run.endedAt,
374 stats: run.stats,
375 verdicts: run.verdicts,
376 decisions: run.decisions,
377 size: run.size,
378 origin: run.origin,
379 batch: run.unit && !run.unit.whole ? { index: run.unit.index, total: run.unit.total, tasks: [...run.unit.tasks], parallel: run.unit.parallel } : undefined,
380 },
381 })
382 await $.fs.write(`${dir}/state.json`, JSON.stringify(snap, null, 2) + '\n').catch(() => undefined)
383}
384
385async function nervLoaded($: any) {
386 return ((await $.command.list().catch(() => [])) as any[]).some((c: any) => c.name === 'nerv')
387}
388
389async function showRun($: any) {
390 if (paneOpen || !(await nervLoaded($))) await openPane($)
391}
392
393async function fetchModels($: any) {
394 const res = await $.http.fetch(`${CPAM}/v1/models`, { headers: { authorization: `Bearer ${cpamKey}` } }).catch((err: any) => ({ ok: false, status: 0, text: String(err) }))
395 if (!res.ok) {
396 modelsNote = `CPAM sin respuesta (${res.status || 'red'})`
397 return
398 }
399 try {
400 modelIds = (JSON.parse(res.text).data || []).map((m: any) => m.id).filter((id: string) => !isExcluded(id, excluded))
401 modelsNote = `${modelIds.length} modelos en el CPAM`
402 } catch {
403 modelsNote = 'lista de modelos ilegible'
404 }
405 const beta = await $.http.fetch(`${CPAM}/v1beta/models`, { headers: { 'x-goog-api-key': cpamKey } }).catch(() => undefined)
406 try {
407 for (const m of (beta?.ok ? JSON.parse(beta.text).models : []) || []) {
408 const id = String(m?.name || '').replace(/^models\//, '')
409 if (id && m.displayName) modelLabels[id] = String(m.displayName)
410 }
411 } catch {}
412 await exportState($)
413 $.ui.invalidate('ui.render')
414}
415
416async function recentRunDir($: any): Promise<string | undefined> {
417 const cwd = await $.session.cwd().catch(() => '')
418 if (!cwd) return undefined
419 const runs: { dir: string; at: number }[] = []
420 for (const d of ((await $.fs.list(`${cwd}/.sdd`).catch(() => [])) as any[]).filter((x: any) => x.kind === 'dir')) {
421 const st: any = await $.fs.stat(`${cwd}/.sdd/${d.name}/run.json`).catch(() => undefined)
422 if (st?.mtimeMs && now() - st.mtimeMs < 15 * 60000) runs.push({ dir: `${cwd}/.sdd/${d.name}`, at: st.mtimeMs })
423 }
424 return runs.sort((a, b) => b.at - a.at)[0]?.dir
425}
426
427let restoreTriedAt = 0
428let sessionId = ''
429let inboxSeen = ''
430let inboxBusy = false
431let syncBusy = false
432
433async function syncProfiles($: any, gen: number): Promise<boolean> {
434 const own = await readOwn($)
435 const st = await zeroMtime($)
436 if (st !== zeroStamp && importZero(String((await $.fs.read(`${home}/.pi/zero.json`).catch(() => '')) || ''))) zeroStamp = st
437 if (editing || gen !== editGen) return false
438 const before = JSON.stringify([profiles, profileEfforts])
439 rebuild(own)
440 return JSON.stringify([profiles, profileEfforts]) !== before
441}
442
443async function syncConfig($: any) {
444 if (syncBusy || editing) return
445 syncBusy = true
446 const gen = editGen
447 try {
448 if (await syncProfiles($, gen)) $.ui.invalidate('ui.render')
449 if (editing || gen !== editGen) return
450 const saved: any = await $.store.get('config').catch(() => undefined)
451 if (!saved || typeof saved !== 'object' || editing || gen !== editGen) return
452 const next = {
453 ...config,
454 ...saved,
455 models: fillPhases({ ...config.models, ...(saved.models || {}) }),
456 efforts: { ...AUTO_EFFORTS, ...(saved.efforts || {}) },
457 skip: Array.isArray(saved.skip) ? saved.skip.filter((p: Phase) => OPTIONAL_PHASES.includes(p)) : [],
458 }
459 if (JSON.stringify(next) === JSON.stringify(config)) return
460 config = next
461 $.ui.invalidate('ui.render')
462 } finally {
463 syncBusy = false
464 }
465}
466
467async function checkInbox($: any) {
468 if (inboxBusy || !home || !sessionId) return
469 inboxBusy = true
470 try {
471 const path = `${home}/.local/state/forge/inbox-${sessionId}.json`
472 const raw = await $.fs.read(path).catch(() => '')
473 if (!raw) return
474 const msg = JSON.parse(String(raw))
475 if (!msg?.id || msg.id === inboxSeen || msg.done) return
476 inboxSeen = msg.id
477 if (!msg.at || now() - Number(msg.at) > 30000) return
478 const text = await commandText($, String(msg.args || ''))
479 await $.fs.write(path, JSON.stringify({ ...msg, done: true, text: text ?? '' }) + '\n').catch(() => undefined)
480 $.ui.invalidate('ui.render')
481 } catch {
482 } finally {
483 inboxBusy = false
484 }
485}
486
487async function restoreRun($: any, agentId?: string) {
488 if (run || (!agentId && now() - restoreTriedAt < 30000)) return
489 if (!agentId) restoreTriedAt = now()
490 let dir = await $.store.get('activeRun').catch(() => undefined)
491 if (typeof dir !== 'string' || !dir) dir = await recentRunDir($)
492 if (typeof dir !== 'string' || !dir) return
493 try {
494 const saved = JSON.parse(String((await $.fs.read(`${dir}/run.json`).catch(() => '')) || ''))
495 if (!saved || saved.status !== 'running' || run) return
496 const sid = await $.session.id().catch(() => '')
497 const flights: Record<string, Flight> = {}
498 for (const [k, f] of Object.entries((saved.flights || {}) as Record<string, Flight>)) if (f?.agentId) flights[k] = f
499 if (typeof saved.inFlight === 'string' && saved.inFlight !== 'sync') flights[saved.inFlight] = { phase: saved.expected, agentId: saved.inFlight }
500 if (!(saved.owner && saved.owner === sid) && !(agentId && flights[agentId])) return
501 const log = String((await $.fs.read(`${dir}/routing.log`).catch(() => '')) || '')
502 const { inFlight: _f, batches: _b, batchIdx: _i, ...keep } = saved
503 run = {
504 ...keep,
505 flights,
506 unitIdx: Number(saved.unitIdx) || 0,
507 delivered: Array.isArray(saved.delivered) ? saved.delivered : [],
508 retryAlone: Array.isArray(saved.retryAlone) ? saved.retryAlone : [],
509 routing: [...log.split('\n').filter(Boolean), `${stamp()} forge se recargó a mitad del run; sigue desde ${saved.phase || 'la fase en curso'}`],
510 } as Run
511 await exportState($)
512 $.ui.invalidate('ui.render')
513 } catch {}
514}
515
516async function persist($: any) {
517 await exportState($)
518 if (!run) return
519 if (!run.owner) run.owner = await $.session.id().catch(() => undefined)
520 await $.store.set('activeRun', run.status === 'running' ? run.dir : null).catch(() => undefined)
521 const { routing, ...rest } = run
522 await $.fs.write(`${run.dir}/run.json`, JSON.stringify(rest, null, 2) + '\n').catch(() => undefined)
523 await $.fs.write(`${run.dir}/rounds.json`, JSON.stringify({ cap: run.cap, verdicts: run.verdicts }, null, 2) + '\n').catch(() => undefined)
524 await $.fs.write(`${run.dir}/routing.log`, routing.join('\n') + (routing.length ? '\n' : '')).catch(() => undefined)
525}
526
527function selectCurrent(name: string) {
528 config.profile = name
529 config.models = { ...profiles[name]! }
530 config.efforts = { ...AUTO_EFFORTS, ...(profileEfforts[name] || {}) }
531}
532
533async function writeZero($: any, key: string, phase: Phase, change: { model?: string; effort?: Effort }): Promise<string> {
534 if (!home) home = (await $.env.get('HOME').catch(() => '')) || ''
535 const path = `${home}/.pi/zero.json`
536 const raw = home ? await $.fs.read(path).catch(() => undefined) : undefined
537 if (typeof raw !== 'string' || !raw) return `no pude leer ${path}`
538 const res = editZeroProfile(raw, key, phase, change)
539 if ('error' in res) return res.error
540 if (!zeroBackedUp) {
541 try {
542 await $.fs.write(`${path}.bak-forge-${stamp().replace(/:/g, '')}`, raw)
543 zeroBackedUp = true
544 } catch (err) {
545 return `no pude hacer el backup de ${path}: ${err}`
546 }
547 }
548 try {
549 await $.fs.write(path, res.text)
550 } catch (err) {
551 return `no pude escribir ${path}: ${err}`
552 }
553 const own = currentOwn()
554 importZero(res.text)
555 zeroStamp = await zeroMtime($)
556 rebuild(own)
557 return ''
558}
559
560async function editPhase($: any, phase: Phase, change: { model?: string; effort?: Effort }): Promise<string> {
561 editing++
562 editGen++
563 try {
564 const name = config.profile
565 let note = ''
566 if (zeroNames.includes(name)) {
567 const err = await writeZero($, name.slice('zero:'.length), phase, change)
568 if (err) return `⚠ ${err}`
569 if (profiles[name]) {
570 selectCurrent(name)
571 note = ` · ${name} guardado en ~/.pi/zero.json`
572 } else note = ` · guardado en ~/.pi/zero.json, pero ${name} ya no se importa`
573 } else if (profiles[name]) {
574 profiles = { ...profiles, [name]: { ...profiles[name]!, ...(change.model !== undefined ? { [phase]: change.model } : {}) } }
575 profileEfforts = { ...profileEfforts, [name]: { ...AUTO_EFFORTS, ...(profileEfforts[name] || {}), ...(change.effort !== undefined ? { [phase]: change.effort } : {}) } }
576 await saveOwn($)
577 selectCurrent(name)
578 note = !BUILTIN_PROFILES.includes(name)
579 ? ` · perfil ${name} actualizado`
580 : builtinModified(name, profiles[name], profileEfforts[name])
581 ? ` · perfil ${name} ★ modificado (/forge profile reset ${name} lo restaura)`
582 : ` · perfil ${name} quedó como de fábrica`
583 }
584 if (config.profile !== name || !profiles[name]) {
585 config.profile = profiles[config.profile] ? config.profile : 'custom'
586 if (change.model !== undefined) config.models = { ...config.models, [phase]: change.model }
587 if (change.effort !== undefined) config.efforts = { ...config.efforts, [phase]: change.effort }
588 }
589 await saveConfig($)
590 $.ui.invalidate('ui.render')
591 return note
592 } finally {
593 editing--
594 }
595}
596
597async function setModel($: any, phase: Phase, model: string) {
598 return editPhase($, phase, { model })
599}
600
601async function setEffort($: any, phase: Phase, effort: Effort) {
602 return editPhase($, phase, { effort })
603}
604
605async function setProfile($: any, name: string) {
606 if (!profiles[name]) return false
607 selectCurrent(name)
608 await saveConfig($)
609 $.ui.invalidate('ui.render')
610 return true
611}
612
613async function openPane($: any) {
614 const r = await $.ui.open({ id: PANE, title: 'FORGE' }).catch(() => ({ isPlaced: false }))
615 paneOpen = true
616 $.ui.invalidate('ui.render')
617 return r
618}
619
620type Handoff = { slug: string; dir: string; cwd: string; request: string; resumed?: boolean }
621
622async function startRun($: any, args: string | StartArgs, handoff?: Handoff): Promise<{ error?: string }> {
623 if (run && run.status === 'running') return { error: `ya hay un run corriendo (${run.slug}). /forge stop para cortarlo.` }
624 const flags = typeof args === 'string' ? parseStart(args) : args
625 const parsed = handoff ? { ...flags, request: handoff.request } : flags
626 if (!parsed.request) return { error: 'falta el pedido: /forge [--auto|--interactive] [--cap N] [--profile barato|calidad] <pedido>' }
627 if (parsed.profile && !(await setProfile($, parsed.profile))) return { error: `no existe el perfil "${parsed.profile}" (hay: ${Object.keys(profiles).join(', ')})` }
628 let mode: Mode = parsed.mode || (config.mode === 'preguntar' ? 'automatic' : config.mode)
629 if (!parsed.mode && config.mode === 'preguntar' && !headless) {
630 const pick = await $.ui.ask('¿En qué modo corre forge?', { options: ['interactive', 'automatic'], header: 'forge' }).catch(() => 'automatic')
631 mode = pick === 'interactive' ? 'interactive' : 'automatic'
632 }
633 let cwd: string, slug: string, dir: string
634 if (handoff) {
635 cwd = handoff.cwd
636 slug = handoff.slug
637 dir = handoff.dir
638 await $.fs.write(`${dir}/request.md`, handoff.request)
639 } else {
640 cwd = await $.session.cwd()
641 const taken = ((await $.fs.list(`${cwd}/.sdd`).catch(() => [])) as any[]).map((f: any) => f.name)
642 slug = slugify(parsed.request, taken)
643 dir = `${cwd}/.sdd/${slug}`
644 const made = await $.process.run(['mkdir', '-p', dir]).catch((err: any) => ({ exitCode: 1, stderr: String(err) }))
645 if (made.exitCode !== 0) return { error: `no pude crear ${dir}: ${made.stderr}` }
646 await $.fs.write(`${dir}/request.md`, parsed.request + '\n')
647 }
648 const stats = {} as Record<Phase, Stat>
649 for (const p of PHASES) stats[p] = blankStat(config.models[p], config.efforts[p])
650 let sddConfig: any = {}
651 try {
652 sddConfig = JSON.parse(String((await $.fs.read(`${cwd}/.sdd/config.json`).catch(() => '')) || '{}'))
653 } catch {}
654 const order = phaseOrder(handoff ? [...config.skip, 'clarify'] : config.skip)
655 run = {
656 slug,
657 dir,
658 cwd,
659 request: parsed.request,
660 mode,
661 cap: parsed.cap || config.cap,
662 verdicts: [],
663 expected: order[0],
664 status: 'running',
665 order,
666 decisions: [],
667 clarified: false,
668 tdd: sddConfig?.tdd?.mode === 'off' ? 'off' : 'strict',
669 unitIdx: 0,
670 delivered: [],
671 retryAlone: [],
672 flights: {},
673 startedAt: now(),
674 stats,
675 retried: false,
676 note: '',
677 routing: handoff?.resumed ? String((await $.fs.read(`${dir}/routing.log`).catch(() => '')) || '').split('\n').filter(Boolean) : [],
678 awaitTurn: true,
679 asyncSeen: false,
680 ...(handoff ? { origin: 'nodd' as const } : {}),
681 }
682 run.routing.push(`${stamp()} run ${slug}${handoff ? ` · ${handoff.resumed ? 'retomado desde explore: era un run adoptado de NODD cortado antes de spec.md' : 'adoptado de NODD'} (requirements.md de /nodd-promote, sin clarify)` : ''} · modo ${mode} · cap ${run.cap} · perfil ${config.profile} · tdd ${run.tdd} · fases ${order.join(' → ')} · ${order.map((p) => `${p}=${config.models[p]}`).join(' ')}`)
683 await persist($)
684 return {}
685}
686
687async function readHandoff($: any, cwd: string, slug: string): Promise<Handoff | { why: string }> {
688 const dir = `${cwd}/.sdd/${slug}`
689 const listing = await $.fs.list(dir).catch(() => undefined)
690 if (!Array.isArray(listing)) return { why: `no existe ${dir}` }
691 const names = listing.map((f: any) => String(f?.name || ''))
692 const request = names.includes('requirements.md') ? String((await $.fs.read(`${dir}/requirements.md`).catch(() => '')) || '') : ''
693 const why = noddHandoffProblem(names, request)
694 if (!why) return { slug, dir, cwd, request }
695 let saved: any
696 try {
697 saved = names.includes('run.json') ? JSON.parse(String((await $.fs.read(`${dir}/run.json`).catch(() => '')) || '')) : undefined
698 } catch {}
699 return adoptedRunResumable(names, request, saved) ? { slug, dir, cwd, request, resumed: true } : { why }
700}
701
702async function continueRun($: any, args: string): Promise<{ text: string; started: boolean }> {
703 const parsed = parseContinue(args)
704 if (!parsed || 'error' in parsed) return { text: parsed?.error || 'uso: /forge continue [<slug>]', started: false }
705 if (run && run.expected && (run.status === 'parado' || run.status === 'cortado') && (!parsed.slug || parsed.slug === run.slug)) {
706 run.status = 'running'
707 run.note = ''
708 run.endedAt = undefined
709 run.flights = {}
710 if (run.unit) Object.assign(run.unit, { claimed: [], results: {} })
711 run.stats[run.expected] = blankStat(config.models[run.expected], config.efforts[run.expected])
712 await persist($)
713 return { text: `sigo ${run.slug} desde ${run.expected}`, started: true }
714 }
715 if (run && run.status === 'running') return { text: `ya hay un run corriendo (${run.slug}). /forge stop para cortarlo.`, started: false }
716 const cwd = await $.session.cwd().catch(() => '')
717 let handoff: Handoff | undefined
718 if (parsed.slug) {
719 const found = cwd ? await readHandoff($, cwd, parsed.slug) : { why: 'no sé en qué directorio está la sesión' }
720 if ('why' in found) return { text: `no adopto .sdd/${parsed.slug}: no es un handoff de NODD (${found.why}). /forge continue <slug> sólo adopta lo que dejó /nodd-promote: un requirements.md con la línea «Promoted from the NODD run», sin run.json, execution.json, design.md, tasks.md ni request.md.`, started: false }
721 handoff = found
722 } else {
723 const dirs = cwd ? ((await $.fs.list(`${cwd}/.sdd`).catch(() => [])) as any[]).filter((d: any) => d?.kind === 'dir').map((d: any) => String(d.name)) : []
724 const found: Handoff[] = []
725 for (const name of dirs.sort()) {
726 const h = await readHandoff($, cwd, name)
727 if (!('why' in h)) found.push(h)
728 }
729 if (!found.length) return { text: 'no hay un run parado para seguir', started: false }
730 if (found.length > 1) return { text: `hay ${found.length} handoffs de NODD en .sdd/: ${found.map((h) => h.slug).join(', ')}. Elegí uno con /forge continue <slug>.`, started: false }
731 handoff = found[0]
732 }
733 const res = await startRun($, { request: '', mode: parsed.mode, cap: parsed.cap, profile: parsed.profile }, handoff)
734 if (res.error || !run) return { text: res.error || 'forge: no pude adoptar el handoff', started: false }
735 if (handoff!.resumed)
736 return { text: `retomo el run adoptado de NODD ${run.slug}: plan no llegó a escribir spec.md, así que vuelve a ${run.expected} sin clarify · modo ${run.mode} · cap ${run.cap} · perfil ${config.profile}\nartefactos: ${run.dir}`, started: true }
737 return { text: `adopto el handoff de NODD ${run.slug}: arranca en ${run.expected} sin clarify · modo ${run.mode} · cap ${run.cap} · perfil ${config.profile}\nartefactos: ${run.dir}`, started: true }
738}
739
740async function statusText($: any) {
741 const lines = [`forge · perfil ${config.profile} · modo ${config.mode} · cap ${config.cap} · ${modelsNote}`, `fases: ${phaseOrder(config.skip).join(' → ')}`]
742 for (const err of registerErrors) lines.push(`⚠ no se registró ${err}`)
743 for (const p of PHASES)
744 lines.push(` ${p.padEnd(9)} ${config.skip.includes(p) ? '(salteada) ' : ''}${config.models[p]} · effort ${config.efforts[p]} → ${routeFor(config.models[p]).kind === 'cpam' ? 'CPAM' : 'Claude Code'}`)
745 if (!run) return lines.concat('sin runs en esta sesión').join('\n')
746 lines.push(
747 `run ${run.slug}${run.origin === 'nodd' ? ' · desde NODD' : ''} · ${STATUS_LABEL[run.status]} · ronda ${run.verdicts.length}/${run.cap} · ${run.verdicts.join(' → ') || 'sin veredictos'} · analyze ${run.decisions.join(' → ') || '—'} · ${mmss((run.endedAt || now()) - run.startedAt)}`,
748 )
749 for (const p of run.order) {
750 const s = run.stats[p]
751 if (s.status === 'pending') continue
752 lines.push(` ${p.padEnd(9)} ${s.status.padEnd(7)} ${s.answeredBy || s.model} ${s.steps} pasos ${mmss(s.status === 'running' ? now() - s.startedAt : s.ms)} in ${tokens(s.inTok)} out ${tokens(s.outTok)}`)
753 }
754 if (run.note) lines.push(`nota: ${run.note}`)
755 lines.push(`artefactos: ${run.dir}`)
756 return lines.join('\n')
757}
758
759async function stopRun($: any, why: string) {
760 if (!run || run.status !== 'running') return 'no hay un run corriendo'
761 run.status = 'parado'
762 run.endedAt = now()
763 run.note = why
764 run.routing.push(`${stamp()} parado: ${why}`)
765 await persist($)
766 if (run.turnId) await $.turn.abort({ turnId: run.turnId }).catch(() => undefined)
767 $.ui.invalidate('ui.render')
768 return `run ${run.slug} parado`
769}
770
771async function commandText($: any, args: string): Promise<string | undefined> {
772 const [head, ...rest] = args.trim().split(/\s+/)
773 const sub = (head || '').toLowerCase()
774 if (sub === 'stop' || sub === 'parar') return stopRun($, 'pedido por el usuario')
775 if (sub === 'status' || sub === 'estado') return statusText($)
776 if (sub === 'profile' || sub === 'perfil') {
777 const op = (rest[0] || '').toLowerCase()
778 if (op === 'new' || op === 'nuevo') {
779 if (!rest[1]) return 'uso: /forge profile new <nombre>'
780 const picked = profileName(rest.slice(1).join(' '), Object.keys(profiles))
781 if ('error' in picked) return `no se creó: ${picked.error}`
782 editing++
783 editGen++
784 try {
785 profiles = { ...profiles, [picked.name]: { ...config.models } }
786 profileEfforts = { ...profileEfforts, [picked.name]: { ...config.efforts } }
787 await saveOwn($)
788 config.profile = picked.name
789 await saveConfig($)
790 } finally {
791 editing--
792 }
793 $.ui.invalidate('ui.render')
794 return `perfil ${picked.name} creado con la config actual y activo`
795 }
796 if (op === 'reset' || op === 'restaurar') {
797 const name = rest[1]
798 if (!name) return 'uso: /forge profile reset <perfil de fábrica>'
799 if (!BUILTIN_PROFILES.includes(name)) return `${name} no es de fábrica: sólo se restauran los de fábrica`
800 if (!builtinModified(name, profiles[name], profileEfforts[name])) return `${name} ya está como de fábrica`
801 editing++
802 editGen++
803 try {
804 profiles = { ...profiles, [name]: { ...DEFAULT_PROFILES[name]! } }
805 profileEfforts = { ...profileEfforts, [name]: { ...DEFAULT_EFFORTS[name]! } }
806 await saveOwn($)
807 if (config.profile === name) selectCurrent(name)
808 await saveConfig($)
809 } finally {
810 editing--
811 }
812 $.ui.invalidate('ui.render')
813 return `perfil ${name} restaurado de fábrica`
814 }
815 if (op === 'save' && rest[1]) {
816 if (zeroNames.includes(rest[1]) || rest[1] === 'custom') return `no se puede guardar como ${rest[1]}`
817 editing++
818 editGen++
819 try {
820 profiles = { ...profiles, [rest[1]]: { ...config.models } }
821 profileEfforts = { ...profileEfforts, [rest[1]]: { ...config.efforts } }
822 await saveOwn($)
823 config.profile = rest[1]
824 await saveConfig($)
825 } finally {
826 editing--
827 }
828 return `perfil ${rest[1]} guardado`
829 }
830 if ((op === 'delete' || op === 'borrar') && rest[1]) {
831 const name = rest[1]
832 if (BUILTIN_PROFILES.includes(name)) return `${name} es de fábrica, no se borra${builtinModified(name, profiles[name], profileEfforts[name]) ? ` · /forge profile reset ${name} lo restaura` : ''}`
833 if (zeroNames.includes(name)) return `${name} viene de ~/.pi/zero.json: se borra en zero-pi`
834 if (!profiles[name]) return `no existe el perfil ${name}`
835 editing++
836 editGen++
837 try {
838 const { [name]: _gone, ...left } = profiles
839 const { [name]: _goneEffort, ...leftEfforts } = profileEfforts
840 profiles = left
841 profileEfforts = leftEfforts
842 await saveOwn($)
843 if (config.profile === name) config.profile = 'custom'
844 await saveConfig($)
845 } finally {
846 editing--
847 }
848 $.ui.invalidate('ui.render')
849 return `perfil ${name} borrado`
850 }
851 if (!rest[0])
852 return `perfiles: ${Object.keys(profiles)
853 .map((n) => (builtinModified(n, profiles[n], profileEfforts[n]) ? `${n} ★` : n))
854 .join(', ')} · activo: ${config.profile}`
855 return (await setProfile($, rest[0])) ? `perfil ${rest[0]} activo` : `no existe el perfil ${rest[0]}`
856 }
857 if (sub === 'phase' || sub === 'fase') {
858 const phase = rest[0] as Phase
859 const on = rest[1] === 'on' || rest[1] === 'si' || rest[1] === 'sí'
860 if (!OPTIONAL_PHASES.includes(phase) || (!on && rest[1] !== 'off' && rest[1] !== 'no')) return `uso: /forge phase <${OPTIONAL_PHASES.join('|')}> <on|off>`
861 config.skip = on ? config.skip.filter((p) => p !== phase) : [...new Set([...config.skip, phase])]
862 await saveConfig($)
863 $.ui.invalidate('ui.render')
864 return `${phase} ${on ? 'activa' : 'salteada'} · fases ${phaseOrder(config.skip).join(' → ')}`
865 }
866 if (sub === 'model' || sub === 'modelo') {
867 const phase = rest[0] as Phase
868 if (!PHASES.includes(phase) || !rest[1]) return `uso: /forge model <${PHASES.join('|')}> <modelo>`
869 const note = await setModel($, phase, rest[1])
870 return note.startsWith('⚠') ? note : `${phase} → ${rest[1]} (${routeFor(rest[1]).kind === 'cpam' ? 'CPAM' : 'Claude Code'})${note}`
871 }
872 if (sub === 'effort' || sub === 'esfuerzo') {
873 const phase = rest[0] as Phase
874 if (!PHASES.includes(phase) || !isEffort(rest[1])) return `uso: /forge effort <${PHASES.join('|')}> <${EFFORTS.join('|')}>`
875 const note = await setEffort($, phase, rest[1])
876 return note.startsWith('⚠') ? note : `${phase} · effort ${rest[1]}${note}`
877 }
878 if (sub === 'mode' || sub === 'modo') {
879 const m = rest[0] === 'ask' ? 'preguntar' : rest[0]
880 if (m !== 'interactive' && m !== 'automatic' && m !== 'preguntar') return 'uso: /forge mode <interactive|automatic|ask>'
881 config.mode = m
882 await saveConfig($)
883 return `modo por defecto ${m}`
884 }
885 if (sub === 'cap') {
886 const n = Number(rest[0])
887 if (!Number.isInteger(n) || n < 1) return 'uso: /forge cap <n>'
888 config.cap = n
889 await saveConfig($)
890 return `cap ${n}`
891 }
892 return undefined
893}
894
895function submitLater($: any, text: string) {
896 $.clock.after(30, async () => {
897 if (run) run.awaitTurn = true
898 await $.prompt.submit({ text }).catch(async (err: any) => {
899 if (!run) return
900 run.note = `no pude mandar la instrucción al modelo principal: ${err} · /forge continue`
901 run.routing.push(`${stamp()} ${run.note}`)
902 await persist($)
903 })
904 })
905}
906
907function launch($: any) {
908 if (!run || !run.expected) return
909 $.clock.after(30, async () => {
910 if (!run || !run.expected) return
911 const parallel = await parallelCount($, run.expected)
912 if (!run || !run.expected) return
913 run.awaitTurn = true
914 await $.prompt.submit({ text: startInstruction(run.slug, run.expected, parallel) }).catch(async (err: any) => {
915 if (!run) return
916 run.status = 'fallido'
917 run.note = `no pude mandar la instrucción de arranque: ${err}`
918 await persist($)
919 })
920 })
921}
922
923async function phaseOf($: any, agentId: string): Promise<Phase | undefined> {
924 if (agentPhase.has(agentId)) return agentPhase.get(agentId)
925 const info = ((await $.agent.list().catch(() => [])) as any[]).find((a: any) => a.id === agentId)
926 const phase = phaseOfAgentType(info?.type)
927 if (phase) agentPhase.set(agentId, phase)
928 return phase
929}
930
931async function postCpam($: any, body: any): Promise<{ ok: boolean; status: number; text: string }> {
932 const script = 'curl -sS -H @<(printf "x-api-key: %s\\n" "$(cat "$1")") -H "anthropic-version: 2023-06-01" -H "content-type: application/json" --max-time 590 -w "\\n%{http_code}" --data-binary @- "$2"'
933 try {
934 const r = await $.process.run(['bash', '-c', script, 'forge', `${home}/.config/cli-proxy-api/api-key.txt`, `${CPAM}/v1/messages`], { stdin: JSON.stringify(body), timeoutMs: 600000 })
935 const out = String(r.stdout || '')
936 const cut = out.lastIndexOf('\n')
937 const status = Number(out.slice(cut + 1)) || 0
938 return { ok: status >= 200 && status < 300, status, text: status ? out.slice(0, cut) : String(r.stderr || out).slice(0, 300) }
939 } catch (err: any) {
940 return { ok: false, status: 0, text: String(err) }
941 }
942}
943
944async function callCpam($: any, body: any) {
945 let last = ''
946 for (let attempt = 0; attempt < 3; attempt++) {
947 if (attempt) await $.clock.sleep(2000 * attempt)
948 const res = await postCpam($, body)
949 if (res.ok) {
950 try {
951 return { data: JSON.parse(res.text) }
952 } catch {
953 last = 'respuesta ilegible'
954 continue
955 }
956 }
957 last = `HTTP ${res.status}: ${String(res.text || '').slice(0, 300)}`
958 if (res.status && res.status < 500 && res.status !== 429) break
959 }
960 return { error: last }
961}
962
963function phaseAnswer(phase: Phase, agentId: string | undefined, content: any): string {
964 const fromContent = Array.isArray(content) ? content.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n') : ''
965 const answer = pickAnswer(phase, String((agentId && (handbacks.get(agentId) || answers.get(agentId))) || fromContent || ''), (agentId && stepTexts.get(agentId)) || [])
966 if (agentId) {
967 stepTexts.delete(agentId)
968 handbacks.delete(agentId)
969 autoAgents.delete(agentId)
970 }
971 return answer
972}
973
974async function readTasks($: any) {
975 return run ? parseTasks(String((await $.fs.read(`${run.dir}/tasks.md`).catch(() => '')) || ''), run.cwd) : []
976}
977
978async function computeUnit($: any): Promise<Unit | undefined> {
979 if (!run) return undefined
980 const tasks = await readTasks($)
981 if (!run) return undefined
982 const skip = new Set([...run.delivered, ...run.retryAlone.map((r) => r.id)])
983 const plan = planUnits(tasks.map((t) => (skip.has(t.id) ? { ...t, done: true } : t)))
984 const base = { index: run.unitIdx, claimed: [] as string[], results: {} as Record<string, WaveResult> }
985 const retry = run.retryAlone[0]
986 if (retry) return { ...base, tasks: [retry.id], parallel: false, whole: false, retry: true, reason: retry.reason, total: run.unitIdx + run.retryAlone.length + plan.length }
987 if (!plan.length) return run.unitIdx === 0 ? { ...base, tasks: [], parallel: false, whole: true, total: 1 } : undefined
988 if (run.unitIdx === 0 && plan.length === 1 && !plan[0]!.parallel) return { ...base, ...plan[0]!, whole: true, total: 1 }
989 return { ...base, ...plan[0]!, whole: false, total: run.unitIdx + plan.length }
990}
991
992function assignUnit(u: Unit | undefined) {
993 if (!run) return
994 run.unit = u
995 if (u?.retry) {
996 run.retryAlone = run.retryAlone.filter((r) => r.id !== u.tasks[0])
997 run.retried = true
998 run.retryReason = `${u.tasks[0]} failed inside a parallel wave (${u.reason || 'no result'}); it is retried alone`
999 }
1000}
1001
1002let unitLoading: Promise<void> | undefined
1003
1004async function ensureUnit($: any): Promise<Unit | undefined> {
1005 if (run && !run.unit) {
1006 unitLoading ??= (async () => {
1007 const u = await computeUnit($)
1008 if (run && !run.unit) assignUnit(u)
1009 })().finally(() => {
1010 unitLoading = undefined
1011 })
1012 await unitLoading
1013 }
1014 return run?.unit
1015}
1016
1017async function parallelCount($: any, phase: Phase | undefined): Promise<number> {
1018 if (phase !== 'build') return 1
1019 const u = await ensureUnit($)
1020 return u?.parallel ? u.tasks.length : 1
1021}
1022
1023const unitFlights = (r: Run, u: Unit) => Object.values(r.flights).filter((f) => f.phase === 'build' && f.task && u.tasks.includes(f.task)).length
1024
1025function childResult(agentId: string | undefined, content: any, denied?: string): WaveResult {
1026 if (denied) return { ok: false, text: '', reason: `refused: ${clip(denied, 200)}` }
1027 const answer = phaseAnswer('build', agentId, content)
1028 if (answer.startsWith('FORGE_CPAM_ERROR')) return { ok: false, text: clip(answer, 2000), reason: answer.split('\n')[0] }
1029 if (!answer.trim()) return { ok: false, text: '', reason: 'the build envelope came back empty' }
1030 return { ok: true, text: clip(answer.trim(), 20000) }
1031}
1032
1033async function childDone($: any, key: string, unit: Unit, task: string, res: WaveResult): Promise<{ text: string; closed: boolean }> {
1034 if (!run) return { text: 'forge: sin run', closed: false }
1035 delete run.flights[key]
1036 unit.results[task] = res
1037 const pending = unitFlights(run, unit)
1038 run.routing.push(`${stamp()} build tanda ${unit.index + 1}/${unit.total} · ${task} ${res.ok ? 'entregó' : `falló (${res.reason})`}${pending ? ` · faltan ${pending}` : ''}`)
1039 if (pending || run.unit !== unit) {
1040 await persist($)
1041 $.ui.invalidate('ui.render')
1042 return { text: waveChildInstruction(task, unit, pending), closed: false }
1043 }
1044 return { text: await closeWave($, unit), closed: true }
1045}
1046
1047async function closeWave($: any, unit: Unit): Promise<string> {
1048 if (!run) return 'forge: sin run'
1049 const ok = unit.claimed.filter((id) => unit.results[id]?.ok)
1050 const failed = unit.claimed.filter((id) => !unit.results[id]?.ok)
1051 if (ok.length) {
1052 const text = String((await $.fs.read(`${run.dir}/tasks.md`).catch(() => '')) || '')
1053 if (text) await $.fs.write(`${run.dir}/tasks.md`, tickTasks(text, ok)).catch(() => undefined)
1054 const entries: { id: string; text?: string }[] = []
1055 for (const id of ok) entries.push({ id, text: String((await $.fs.read(`${run.dir}/tdd-evidence/${id}.md`).catch(() => '')) || '') })
1056 const prev = String((await $.fs.read(`${run.dir}/tdd-evidence.md`).catch(() => '')) || '')
1057 await $.fs.write(`${run.dir}/tdd-evidence.md`, `${prev.trim() ? `${prev.trimEnd()}\n\n` : ''}${waveEvidence(unit.index + 1, entries)}\n`).catch(() => undefined)
1058 }
1059 const left = unit.tasks.filter((id) => !unit.claimed.includes(id))
1060 const note = `${ok.length}/${unit.claimed.length} entregadas${failed.length ? ` · se reintentan solas: ${failed.join(', ')}` : ''}${left.length ? ` · sin lanzar, van en la próxima: ${left.join(', ')}` : ''}`
1061 return finishPhase($, 'build', undefined, undefined, {
1062 body: waveEnvelope(unit.index + 1, unit.total, unit.claimed, unit.results),
1063 delivered: ok,
1064 failed: failed.map((id) => ({ id, reason: unit.results[id]?.reason || 'no result' })),
1065 note,
1066 })
1067}
1068
1069type Closed = { body: string; delivered: string[]; failed: { id: string; reason: string }[]; note: string }
1070
1071async function finishPhase($: any, phase: Phase, agentId: string | undefined, content: any, closed?: Closed) {
1072 if (!run) return 'forge: sin run'
1073 const st = run.stats[phase]
1074 st.ms = now() - st.startedAt
1075 const answer = closed ? closed.body : phaseAnswer(phase, agentId, content)
1076 const cpamFailed = !closed && answer.startsWith('FORGE_CPAM_ERROR')
1077 const round = run.verdicts.length + 1
1078 const unit = phase === 'build' ? run.unit : undefined
1079 const batch = unit && !unit.whole ? { index: unit.index, total: unit.total, parallel: unit.parallel, tasks: unit.tasks } : undefined
1080 let outcome: Outcome = 'ok'
1081 let reason = ''
1082 let asked = false
1083 let stopNow = false
1084 if (cpamFailed) {
1085 outcome = 'fail'
1086 reason = answer.split('\n')[0]
1087 } else if (phase === 'clarify') {
1088 const status = parseClarifyStatus(answer)
1089 if (answer.trim().length < 40 || !status) {
1090 outcome = 'fail'
1091 reason = 'clarifications came back without a "## Status" of continue|blocked'
1092 } else {
1093 let text = answer.trim()
1094 run.size = parseSize(answer)
1095 const small = run.size === 'small' ? `\n\n${NODD_HINT}` : ''
1096 if (status === 'blocked' && run.mode === 'interactive' && !headless) {
1097 asked = true
1098 asking = true
1099 await exportState($)
1100 const questions = section(text, 'Blocking questions') || text
1101 const pick = await $.ui
1102 .ask(`forge · clarify necesita una respuesta antes de explorar:\n\n${clip(questions, 1500)}\n\nRespondé en «Other» o seguí con los supuestos.${small}`, { options: ['Seguir con los supuestos', 'Parar'], header: 'forge' })
1103 .catch(() => 'Parar')
1104 asking = false
1105 if (pick === 'Parar') stopNow = true
1106 else
1107 text += `\n\n## Resolution\n${pick === 'Seguir con los supuestos' ? 'The user chose to proceed under the recorded assumptions.' : `User answer to the blocking questions (it overrides the assumptions):\n\n${pick}`}`
1108 } else if (status === 'blocked')
1109 text += '\n\n## Resolution\nAutomatic mode: nobody could answer the blocking questions, so the run proceeds under the recorded assumptions. Treat them as assumptions, not as confirmed requirements.'
1110 await $.fs.write(`${run.dir}/clarifications.md`, text + '\n')
1111 run.clarified = true
1112 if (run.size === 'small') {
1113 run.routing.push(`${stamp()} tamaño small: ${NODD_HINT}`)
1114 if (run.mode !== 'interactive' || headless) $.ui.log(`forge: ${NODD_HINT}`)
1115 }
1116 if (status === 'blocked') run.routing.push(`${stamp()} clarify bloqueado · ${asked ? (stopNow ? 'el usuario paró' : 'respondió el usuario') : 'modo automatic: sigue con supuestos'}`)
1117 }
1118 } else if (phase === 'explore') {
1119 if (answer.trim().length < 40) {
1120 outcome = 'fail'
1121 reason = 'the findings report came back empty'
1122 } else await $.fs.write(`${run.dir}/findings.md`, answer.trim() + '\n')
1123 } else if (phase === 'plan') {
1124 const missing: string[] = []
1125 for (const f of ['proposal.md', 'spec.md', 'design.md', 'tasks.md']) {
1126 const text = await $.fs.read(`${run.dir}/${f}`).catch(() => '')
1127 if (typeof text !== 'string' || text.trim().length < 20) missing.push(f)
1128 else if (f === 'tasks.md') {
1129 const defects = validateTasks(text)
1130 if (defects.length) missing.push(`tasks.md structure (${defects.slice(0, 8).join('; ')})`)
1131 }
1132 }
1133 if (missing.length) {
1134 outcome = 'fail'
1135 reason = `missing, empty or malformed plan artifacts: ${missing.join(', ')}`
1136 }
1137 } else if (phase === 'analyze') {
1138 const d = parseDecision(answer)
1139 if (!d) {
1140 outcome = 'fail'
1141 reason = 'no "Decision: continue|replan" line in the checklist'
1142 } else {
1143 await $.fs.write(`${run.dir}/checklist.md`, answer.trim() + '\n')
1144 run.decisions.push(d)
1145 outcome = d
1146 run.blockers = d === 'replan' ? clip(section(answer, 'Blockers') || answer.trim(), 6000) : undefined
1147 }
1148 } else if (phase === 'build') {
1149 if (!answer.trim()) {
1150 outcome = 'fail'
1151 reason = 'the build envelope came back empty'
1152 } else {
1153 const prev = run.unitIdx > 0 ? String((await $.fs.read(`${run.dir}/build-r${round}.md`).catch(() => '')) || '') : ''
1154 const own = closed ? closed.body : batch ? `## Batch ${batch.index + 1}/${batch.total}: ${batch.tasks.join(', ')}\n\n${answer.trim()}` : answer.trim()
1155 await $.fs.write(`${run.dir}/build-r${round}.md`, `${prev.trim() ? `${prev.trimEnd()}\n\n` : ''}${own}\n`)
1156 }
1157 } else {
1158 await $.fs.write(`${run.dir}/veredicto-r${round}.md`, answer.trim() + '\n')
1159 run.lastVerdictText = clip(answer.trim(), 6000)
1160 const v = parseVerdict(answer)
1161 if (v) {
1162 outcome = v
1163 run.verdicts.push(v)
1164 if (v !== 'pasa') run.feedback = { verdict: v, text: clip(answer, 6000) }
1165 } else reason = 'no parseable "VEREDICTO: <pasa|corregir|replantear>" line at the end'
1166 }
1167 st.status = outcome === 'fail' || reason ? 'error' : 'done'
1168 if (outcome !== 'fail' && !reason) {
1169 if ((phase === 'build' && run.feedback?.verdict === 'corregir') || (phase === 'plan' && run.feedback?.verdict === 'replantear')) run.feedback = undefined
1170 if (phase === 'plan') run.blockers = undefined
1171 run.adjust = undefined
1172 }
1173 let nextUnit: Unit | undefined
1174 if (phase === 'build' && st.status === 'done') {
1175 if (unit) {
1176 run.delivered.push(...(closed ? closed.delivered : unit.tasks))
1177 if (closed) run.retryAlone.push(...closed.failed)
1178 run.unitIdx++
1179 }
1180 run.unit = undefined
1181 nextUnit = await computeUnit($)
1182 if (!run) return 'forge: sin run'
1183 }
1184 const step = nextUnit
1185 ? { next: 'build' as Phase, status: 'running' as RunStatus }
1186 : advance(phase, outcome, { rounds: run.verdicts.length, cap: run.cap, retried: run.retried, order: run.order, replans: run.decisions.filter((d) => d === 'replan').length })
1187 run.retried = !!step.retry
1188 run.retryReason = step.retry ? reason : undefined
1189 if (nextUnit) assignUnit(nextUnit)
1190 else if (phase === 'build' && step.retry && run.unit) Object.assign(run.unit, { claimed: [], results: {} })
1191 else if (phase === 'build') {
1192 run.unit = undefined
1193 run.unitIdx = 0
1194 run.delivered = []
1195 run.retryAlone = []
1196 }
1197 run.expected = step.next
1198 run.status = step.status
1199 const who = st.answeredBy || st.model
1200 const label = batch ? `build ${batch.parallel ? 'tanda' : 'lote'} ${batch.index + 1}/${batch.total}${batch.parallel ? ` (${batch.tasks.join(', ')})` : ''}` : phasehooks/logic.ts 845 lines1export type Phase = 'clarify' | 'explore' | 'plan' | 'analyze' | 'build' | 'veredicto'
2export type Verdict = 'pasa' | 'corregir' | 'replantear'
3export type Decision = 'continue' | 'replan'
4export type Mode = 'interactive' | 'automatic'
5export type RunStatus = 'running' | 'pasa' | 'no-verificado' | 'bloqueado' | 'fallido' | 'parado' | 'cortado'
6export type Route = { kind: 'cpam'; model: string } | { kind: 'claude'; alias?: string; model?: string }
7
8export const PHASES: readonly Phase[] = ['clarify', 'explore', 'plan', 'analyze', 'build', 'veredicto']
9export const OPTIONAL_PHASES: readonly Phase[] = ['clarify', 'analyze']
10export const VERDICTS: readonly Verdict[] = ['pasa', 'corregir', 'replantear']
11export const REPLAN_CAP = 2
12export const CLAUDE_ALIASES = ['haiku', 'sonnet', 'opus', 'fable']
13export const CLAUDE_IDS: Record<string, string> = {
14 haiku: 'claude-haiku-4-5-20251001',
15 sonnet: 'claude-sonnet-5-5',
16 opus: 'claude-opus-5-5',
17 fable: 'claude-fable-5-1',
18}
19export const RESERVED_PROFILES = ['custom', 'new', 'nuevo', 'save', 'delete', 'borrar', 'reset', 'restaurar']
20
21export const PHASE_TOOLS: Record<Phase, string[]> = {
22 clarify: ['Read', 'Glob', 'Grep', 'Bash'],
23 explore: ['Read', 'Glob', 'Grep', 'Bash'],
24 analyze: ['Read', 'Glob', 'Grep', 'Bash'],
25 plan: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
26 build: ['Read', 'Glob', 'Grep', 'Bash', 'Write', 'Edit'],
27 veredicto: ['Read', 'Glob', 'Grep', 'Bash'],
28}
29
30export const DEFAULT_PROFILES: Record<string, Record<Phase, string>> = {
31 turbo: {
32 clarify: 'ag3/gemini-3.5-flash-lite',
33 explore: 'ag3/gemini-3.5-flash-lite',
34 plan: 'prolite/gpt-6-luna',
35 analyze: 'prolite/gpt-6-luna',
36 build: 'ag3/gemini-3.5-flash-lite',
37 veredicto: 'prolite/gpt-6-luna',
38 },
39 barato: {
40 clarify: 'ag3/gemini-3.7-flash-high',
41 explore: 'ag3/gemini-3.7-flash-high',
42 plan: 'ag3/gemini-3.7-flash-high',
43 analyze: 'prolite/gpt-6-luna',
44 build: 'ag3/gemini-3.7-flash-high',
45 veredicto: 'prolite/gpt-6-sol',
46 },
47 equilibrado: {
48 clarify: 'ag3/gemini-3.7-flash-high',
49 explore: 'ag3/gemini-3.7-flash-high',
50 plan: 'prolite/gpt-6-sol',
51 analyze: 'prolite/gpt-6-luna',
52 build: 'prolite/gpt-6-luna',
53 veredicto: 'sonnet',
54 },
55 calidad: {
56 clarify: 'ag3/gemini-3.8-flash-high',
57 explore: 'ag3/gemini-3.8-flash-high',
58 plan: 'prolite/gpt-6-sol',
59 analyze: 'prolite/gpt-6-astra',
60 build: 'prolite/gpt-6-sol',
61 veredicto: 'opus',
62 },
63 'open-source': {
64 clarify: 'ds/deepseek-flash',
65 explore: 'ds/deepseek-flash',
66 plan: 'ds/deepseek-v4-pro',
67 analyze: 'ds/deepseek-v4-pro',
68 build: 'ds/deepseek-v4-pro',
69 veredicto: 'ag3/gpt-oss-120b-medium',
70 },
71 gemini: {
72 clarify: 'ag3/gemini-3.8-flash-high',
73 explore: 'ag3/gemini-3.8-flash-high',
74 plan: 'ag3/gemini-pro-agent',
75 analyze: 'ag3/gemini-pro-agent',
76 build: 'ag3/gemini-3.8-flash-high',
77 veredicto: 'ag3/gemini-pro-agent',
78 },
79 openai: {
80 clarify: 'prolite/gpt-6-luna',
81 explore: 'prolite/gpt-6-luna',
82 plan: 'prolite/gpt-6-sol',
83 analyze: 'prolite/gpt-6-sol',
84 build: 'prolite/gpt-6-sol',
85 veredicto: 'prolite/gpt-6-astra',
86 },
87 'solo-claude': {
88 clarify: 'haiku',
89 explore: 'haiku',
90 plan: 'opus',
91 analyze: 'sonnet',
92 build: 'sonnet',
93 veredicto: 'opus',
94 },
95}
96
97export const BUILTIN_PROFILES = Object.keys(DEFAULT_PROFILES)
98
99export const EFFORTS = ['auto', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
100export type Effort = (typeof EFFORTS)[number]
101export const AUTO_EFFORTS: Record<Phase, Effort> = { clarify: 'auto', explore: 'auto', plan: 'auto', analyze: 'auto', build: 'auto', veredicto: 'auto' }
102
103export const DEFAULT_EFFORTS: Record<string, Record<Phase, Effort>> = {
104 turbo: { clarify: 'minimal', explore: 'minimal', plan: 'low', analyze: 'low', build: 'low', veredicto: 'low' },
105 barato: { clarify: 'low', explore: 'low', plan: 'medium', analyze: 'medium', build: 'medium', veredicto: 'high' },
106 equilibrado: { clarify: 'low', explore: 'medium', plan: 'high', analyze: 'high', build: 'medium', veredicto: 'high' },
107 calidad: { clarify: 'medium', explore: 'high', plan: 'xhigh', analyze: 'high', build: 'high', veredicto: 'xhigh' },
108 'open-source': { clarify: 'medium', explore: 'medium', plan: 'high', analyze: 'high', build: 'high', veredicto: 'high' },
109 gemini: { clarify: 'low', explore: 'medium', plan: 'high', analyze: 'high', build: 'high', veredicto: 'high' },
110 openai: { clarify: 'low', explore: 'medium', plan: 'xhigh', analyze: 'high', build: 'high', veredicto: 'xhigh' },
111 'solo-claude': { clarify: 'medium', explore: 'medium', plan: 'high', analyze: 'high', build: 'high', veredicto: 'xhigh' },
112}
113
114export function isEffort(v: unknown): v is Effort {
115 return EFFORTS.includes(v as Effort)
116}
117
118export function cpamModel(model: string, effort: Effort | undefined): string {
119 if (!effort || effort === 'auto' || /\)$/.test(model)) return model
120 return `${model}(${effort === 'off' ? 'none' : effort})`
121}
122
123export function effortBlocked(model: string): string {
124 const m = String(model || '')
125 if (/gpt-oss/.test(m)) return 'gpt-oss ignora el effort (medido: mismo razonamiento con none y xhigh)'
126 if (/deepseek-flash/.test(m)) return 'deepseek-flash ignora el effort (medido: mismo razonamiento con none y xhigh)'
127 if (m.startsWith('oc/')) return 'OpenCode no responde por el CPAM (falta x-opencode-session): sin verificar'
128 if (/^glm-/.test(m)) return 'GLM directo sin saldo (429): sin verificar'
129 return ''
130}
131
132export function claudeEffort(effort: Effort | undefined): 'low' | 'medium' | 'high' | 'xhigh' | undefined {
133 if (!effort || effort === 'auto') return undefined
134 return effort === 'off' || effort === 'minimal' ? 'low' : effort
135}
136
137export function phaseOfAgentType(type: string | undefined): Phase | undefined {
138 const m = /^forge:(clarify|explore|plan|analyze|build|veredicto)$/.exec(String(type || ''))
139 return m ? (m[1] as Phase) : undefined
140}
141
142export function routeFor(model: string): Route {
143 const m = String(model || '').trim()
144 if (CLAUDE_ALIASES.includes(m)) return { kind: 'claude', alias: m }
145 if (/^claude-/.test(m)) return { kind: 'claude', model: m }
146 return { kind: 'cpam', model: m }
147}
148
149export function parseVerdict(text: string): Verdict | undefined {
150 const s = String(text || '')
151 const tagged = [...s.matchAll(/veredicto[\s*_]*[:=][\s*_`"']*(pasa|corregir|replantear)\b/gi)]
152 if (tagged.length) return tagged[tagged.length - 1][1].toLowerCase() as Verdict
153 const last = s.trim().split('\n').filter((l) => l.trim()).at(-1) || ''
154 const bare = /^[\s*_`#>-]*(pasa|corregir|replantear)[\s*_`.!]*$/i.exec(last)
155 return bare ? (bare[1].toLowerCase() as Verdict) : undefined
156}
157
158export function parseDecision(text: string): Decision | undefined {
159 const all = [...String(text || '').matchAll(/decision[\s*_]*[:=][\s*_`"']*(continue|replan)\b/gi)]
160 return all.length ? (all[all.length - 1]![1]!.toLowerCase() as Decision) : undefined
161}
162
163export function parseClarifyStatus(text: string): 'continue' | 'blocked' | undefined {
164 const s = String(text || '')
165 const m = /##\s*Status\b[^\n]*\n?[\s*_`>:-]*(continue|blocked)\b/i.exec(s) || /\bstatus[\s*_]*[:=][\s*_`"']*(continue|blocked)\b/i.exec(s)
166 return m ? (m[1]!.toLowerCase() as 'continue' | 'blocked') : undefined
167}
168
169export type Size = 'small' | 'normal'
170
171export function parseSize(text: string): Size {
172 const all = [...String(text || '').matchAll(/^[\s>*_`-]*size[\s*_`]*:[\s*_`]*(small|normal)[\s*_`.]*$/gim)]
173 return all.length && all[all.length - 1]![1]!.toLowerCase() === 'small' ? 'small' : 'normal'
174}
175
176export function phaseAnswerOk(phase: Phase, text: string): boolean {
177 const t = String(text || '').trim()
178 if (/\bSubagentHandback\b/.test(t)) return false
179 if (phase === 'clarify') return t.length >= 40 && parseClarifyStatus(t) !== undefined
180 if (phase === 'analyze') return parseDecision(t) !== undefined
181 if (phase === 'veredicto') return parseVerdict(t) !== undefined
182 return t.length >= 40
183}
184
185export const DEFAULT_FALLBACK = 'ag/gemini-pro-agent'
186
187export function modelUnavailable(error: string): boolean {
188 return /HTTP 40[04]\b/.test(error) && /not supported|not found|does not exist|unknown model|no such model|model_not_found|unsupported model/i.test(error)
189}
190
191export function forgeMayRun(tool: string, input: unknown, cwd: string, dir: string): boolean {
192 const i = (input || {}) as Record<string, unknown>
193 if (['Read', 'Glob', 'Grep', 'Bash'].includes(tool)) return true
194 if (!['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) return false
195 const path = String(i.file_path ?? i.notebook_path ?? '')
196 const inside = (root: string) => !!root && (path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`))
197 return path.startsWith('/') && !path.split('/').includes('..') && (inside(cwd) || inside(dir))
198}
199
200export function pickAnswer(phase: Phase, final: string, steps: readonly string[]): string {
201 if (phaseAnswerOk(phase, final)) return final
202 for (let i = steps.length - 1; i >= 0; i--) if (phaseAnswerOk(phase, steps[i]!)) return steps[i]!
203 return final
204}
205
206export function section(text: string, title: string): string {
207 const lines = String(text || '').split('\n')
208 const start = lines.findIndex((l) => new RegExp(`^##\\s+${title}\\b`, 'i').test(l))
209 if (start < 0) return ''
210 const end = lines.findIndex((l, i) => i > start && /^##\s+/.test(l))
211 return lines.slice(start + 1, end < 0 ? undefined : end).join('\n').trim()
212}
213
214export function phaseOrder(skip: readonly Phase[] = []): Phase[] {
215 return PHASES.filter((p) => !(OPTIONAL_PHASES.includes(p) && skip.includes(p)))
216}
217
218export type Step = { next?: Phase; status: RunStatus; retry?: boolean }
219export type Outcome = 'ok' | 'fail' | Verdict | Decision
220
221export function advance(phase: Phase, outcome: Outcome, ctx: { rounds: number; cap: number; retried: boolean; order?: readonly Phase[]; replans?: number }): Step {
222 const order = ctx.order || PHASES
223 if (phase === 'veredicto' && !VERDICTS.includes(outcome as Verdict)) outcome = 'fail'
224 if (phase === 'analyze' && outcome !== 'continue' && outcome !== 'replan') outcome = 'fail'
225 if (outcome === 'fail') return ctx.retried ? { status: 'fallido' } : { next: phase, status: 'running', retry: true }
226 if (phase === 'analyze' && outcome === 'replan') return (ctx.replans || 0) >= REPLAN_CAP ? { status: 'bloqueado' } : { next: 'plan', status: 'running' }
227 if (phase === 'veredicto') {
228 if (outcome === 'pasa') return { status: 'pasa' }
229 if (ctx.rounds >= ctx.cap) return { status: 'no-verificado' }
230 return { next: outcome === 'corregir' ? 'build' : 'plan', status: 'running' }
231 }
232 const next = order[order.indexOf(phase) + 1]
233 return next ? { next, status: 'running' } : { status: 'fallido' }
234}
235
236export type TaskItem = { id: string; done: boolean; files: number; depends: string[] | null; evidence: string; review: number | null; reviewRaw: string | null; paths?: string[]; parallel?: boolean }
237
238export function normalizePath(path: string, root = ''): string {
239 const p = String(path || '').trim().replace(/\s*\((?:new|nuevo)\)\s*$/i, '').trim()
240 if (!p) return ''
241 const abs = p.startsWith('/')
242 const out: string[] = []
243 for (const seg of p.split('/')) {
244 if (!seg || seg === '.') continue
245 if (seg === '..' && out.length && out[out.length - 1] !== '..') out.pop()
246 else if (seg !== '..' || !abs) out.push(seg)
247 }
248 const norm = (abs ? '/' : '') + out.join('/')
249 const r = root ? normalizePath(root) : ''
250 return r && r !== '/' && norm.startsWith(`${r}/`) ? norm.slice(r.length + 1) : norm
251}
252
253export function samePath(a: string, b: string): boolean {
254 return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`)
255}
256
257function filePaths(value: string, root: string): string[] {
258 const ticks = value.match(/`[^`]+`/g)
259 const raw = ticks ? ticks.map((t) => t.slice(1, -1)) : value.split(',')
260 return raw.map((p) => normalizePath(p, root)).filter(Boolean)
261}
262
263function taskHeader(line: string): { id: string; done: boolean } | undefined {
264 const m = /^\s*- \[([ xX])\]\s+\**(T\d{3,})\b/.exec(line) || /^#{2,3}\s+\[([ xX])\]\s+(T\d{3,})\b/.exec(line)
265 if (m) return { id: m[2]!, done: m[1] !== ' ' }
266 const h = /^###\s+(T\d{3,})\b(?:\s+[—-]\s+\[([ xX])\])?/.exec(line)
267 return h ? { id: h[1]!, done: !!h[2] && h[2] !== ' ' } : undefined
268}
269
270export function parseTasks(text: string, root = ''): TaskItem[] {
271 const tasks: TaskItem[] = []
272 let cur: TaskItem | undefined
273 let collecting = false
274 for (const line of String(text || '').split(/\r?\n/)) {
275 const head = taskHeader(line)
276 if (head) {
277 cur = { id: head.id, done: head.done, files: 0, depends: null, evidence: '', review: null, reviewRaw: null, paths: [], parallel: /\[P\]/.test(line) }
278 tasks.push(cur)
279 collecting = false
280 continue
281 }
282 if (!cur) continue
283 const field = /^\s*-\s+(files|depends|evidence|review):\s*(.*)$/.exec(line)
284 if (!field) {
285 if (collecting && /^\s*(-\s+)?`[^`]+`/.test(line)) {
286 cur.files += (line.match(/`[^`]+`/g) || []).length
287 cur.paths!.push(...filePaths(line, root))
288 } else if (collecting && line.trim()) collecting = false
289 continue
290 }
291 collecting = field[1] === 'files'
292 const value = (field[2] || '').trim()
293 if (field[1] === 'files') {
294 cur.files = (value.match(/`[^`]+`/g) || []).length || (value ? 1 : 0)
295 cur.paths = filePaths(value, root)
296 } else if (field[1] === 'depends') cur.depends = /^(\[\s*\]|none|n\/a|-)?$/i.test(value) ? [] : [...new Set(value.match(/\bT\d+\b/g) || [])]
297 else if (field[1] === 'evidence') cur.evidence = value
298 else {
299 cur.reviewRaw = value
300 const m = /~?(\d+)\s*(?:changed\s+)?lines?/i.exec(value) || /~?(\d+)/.exec(value)
301 cur.review = m ? Number(m[1]) : null
302 }
303 }
304 return tasks
305}
306
307export function validateTasks(text: string): string[] {
308 const tasks = parseTasks(text)
309 if (!tasks.length) return ['tasks.md has no T### tasks']
310 const order = new Map(tasks.map((t, i) => [t.id, i]))
311 const out: string[] = []
312 tasks.forEach((t, i) => {
313 if (!t.files) out.push(`${t.id} is missing its files list`)
314 if (t.depends === null) out.push(`${t.id} is missing its depends list`)
315 else
316 for (const d of t.depends) {
317 const at = order.get(d)
318 if (d === t.id) out.push(`${t.id} depends on itself`)
319 else if (at === undefined) out.push(`${t.id} depends on unknown ${d}`)
320 else if (at > i) out.push(`${t.id} depends on later task ${d}`)
321 }
322 if (!t.evidence) out.push(`${t.id} is missing evidence`)
323 if (t.reviewRaw === null) out.push(`${t.id} is missing its review estimate`)
324 })
325 return out
326}
327
328export const BATCH_LINES = 800
329export const BATCH_TASKS = 4
330
331export function buildBatches(tasks: readonly TaskItem[]): string[][] {
332 const out: string[][] = []
333 let cur: string[] = []
334 let lines = 0
335 for (const t of tasks.filter((x) => !x.done)) {
336 const est = t.review ?? 0
337 if (cur.length && (cur.length >= BATCH_TASKS || lines + est > BATCH_LINES)) {
338 out.push(cur)
339 cur = []
340 lines = 0
341 }
342 cur.push(t.id)
343 lines += est
344 }
345 if (cur.length) out.push(cur)
346 return out
347}
348
349export const PARALLEL_MAX = 3
350export type BuildUnit = { tasks: string[]; parallel: boolean }
351
352function waveFrom(lead: TaskItem, rest: readonly TaskItem[], ready: (t: TaskItem) => boolean): string[] {
353 if (!lead.parallel || !lead.paths?.length) return [lead.id]
354 const wave = [lead.id]
355 const used = [...lead.paths]
356 for (const t of rest) {
357 if (wave.length >= PARALLEL_MAX) break
358 if (!t.parallel || !t.paths?.length || !ready(t) || t.paths.some((p) => used.some((u) => samePath(p, u)))) continue
359 wave.push(t.id)
360 used.push(...t.paths)
361 }
362 return wave
363}
364
365export function nextWave(tasks: readonly TaskItem[]): BuildUnit | undefined {
366 const open = tasks.filter((t) => !t.done)
367 if (!open.length) return undefined
368 const done = new Set(tasks.filter((t) => t.done).map((t) => t.id))
369 const readyWith = (extra: ReadonlySet<string>) => (t: TaskItem) => (t.depends || []).every((d) => done.has(d) || extra.has(d))
370 const ready = readyWith(new Set())
371 const at = Math.max(0, open.findIndex(ready))
372 const first = open[at]!
373 const wave = waveFrom(first, open.slice(at + 1), ready)
374 if (wave.length > 1) return { tasks: wave, parallel: true }
375 const batch = [first.id]
376 let lines = first.review ?? 0
377 for (let i = at + 1; i < open.length; i++) {
378 const t = open[i]!
379 const est = t.review ?? 0
380 if (batch.length >= BATCH_TASKS || lines + est > BATCH_LINES) break
381 const ok = readyWith(new Set(batch))
382 if (!ok(t)) break
383 if (t.parallel && waveFrom(t, open.slice(i + 1), ok).length > 1) break
384 batch.push(t.id)
385 lines += est
386 }
387 return { tasks: batch, parallel: false }
388}
389
390export function planUnits(tasks: readonly TaskItem[]): BuildUnit[] {
391 const view = tasks.map((t) => ({ ...t }))
392 const out: BuildUnit[] = []
393 for (let u = nextWave(view); u && out.length <= view.length; u = nextWave(view)) {
394 out.push(u)
395 for (const t of view) if (u.tasks.includes(t.id)) t.done = true
396 }
397 return out
398}
399
400export function tickTasks(text: string, ids: readonly string[]): string {
401 return String(text || '')
402 .split('\n')
403 .map((line) => {
404 const m = /^(\s*- |#{2,3}\s+)\[ \](\s+\**(T\d{3,})\b.*)$/.exec(line)
405 if (m) return ids.includes(m[3]!) ? `${m[1]}[x]${m[2]}` : line
406 const h = /^(###\s+(T\d{3,})\s+[—-]\s+)(?:\[ \]\s*)?(?!\[[xX]\])(.*)$/.exec(line)
407 return h && ids.includes(h[2]!) ? `${h[1]}[x] ${h[3]}` : line
408 })
409 .join('\n')
410}
411
412export function waveEvidence(n: number, entries: readonly { id: string; text?: string }[]): string {
413 return [...entries]
414 .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
415 .map((e) => `## ${e.id} (parallel wave ${n})\n\n${String(e.text || '').trim() || `_No tdd-evidence/${e.id}.md was written._`}`)
416 .join('\n\n')
417}
418
419export type WaveResult = { ok: boolean; text: string; reason?: string }
420
421export function waveEnvelope(index: number, total: number, tasks: readonly string[], results: Readonly<Record<string, WaveResult>>): string {
422 const parts = [`## Wave ${index}/${total}: ${tasks.join(', ')}`]
423 for (const id of tasks) {
424 const r = results[id]
425 if (!r) continue
426 parts.push(`### ${id}${r.ok ? '' : ` (failed: ${r.reason || 'no result'})`}`, String(r.text || '').trim() || '(no envelope)')
427 }
428 return parts.join('\n\n')
429}
430
431export function slugify(text: string, taken: readonly string[] = []): string {
432 const words = String(text || '')
433 .normalize('NFD')
434 .replace(/[\u0300-\u036f]/g, '')
435 .toLowerCase()
436 .replace(/[^a-z0-9]+/g, ' ')
437 .trim()
438 .split(/\s+/)
439 .filter(Boolean)
440 let base = ''
441 for (const w of words) {
442 if ((base ? base.length + 1 : 0) + w.length > 40) break
443 base = base ? `${base}-${w}` : w
444 }
445 base = base || 'run'
446 let slug = base
447 for (let i = 2; taken.includes(slug); i++) slug = `${base}-${i}`
448 return slug
449}
450
451export type StartArgs = { request: string; mode?: Mode; cap?: number; profile?: string }
452
453export function parseStart(args: string): StartArgs {
454 const out: StartArgs = { request: '' }
455 const rest: string[] = []
456 const toks = String(args || '').trim().split(/\s+/).filter(Boolean)
457 for (let i = 0; i < toks.length; i++) {
458 const t = toks[i]
459 if (t === '--auto' || t === '--automatic') out.mode = 'automatic'
460 else if (t === '--interactive' || t === '--interactivo') out.mode = 'interactive'
461 else if (t === '--cap' && /^\d+$/.test(toks[i + 1] || '')) out.cap = Math.max(1, Number(toks[++i]))
462 else if (t === '--profile' && toks[i + 1]) out.profile = toks[++i]
463 else rest.push(t)
464 }
465 out.request = rest.join(' ')
466 return out
467}
468
469export const NODD_MARKER = 'Promoted from the NODD run'
470
471export function noddHandoffProblem(files: readonly string[], requirements: string): string {
472 const has = new Set(files)
473 if (!has.has('requirements.md')) return 'no tiene requirements.md'
474 if (!new RegExp(`^\\s*${NODD_MARKER}\\b`, 'm').test(String(requirements || ''))) return `su requirements.md no trae la línea «${NODD_MARKER}»`
475 const identity = ['run.json', 'execution.json'].filter((f) => has.has(f))
476 if (identity.length) return `ya es un run (${identity.join(', ')})`
477 const plan = ['design.md', 'tasks.md'].filter((f) => has.has(f))
478 if (plan.length) return `ya tiene plan (${plan.join(', ')})`
479 if (has.has('request.md')) return 'ya tiene request.md'
480 return ''
481}
482
483export function isNoddHandoff(files: readonly string[], requirements: string): boolean {
484 return !noddHandoffProblem(files, requirements)
485}
486
487export function adoptedRunResumable(files: readonly string[], requirements: string, saved: any): boolean {
488 const has = new Set(files)
489 return (
490 has.has('requirements.md') &&
491 new RegExp(`^\\s*${NODD_MARKER}\\b`, 'm').test(String(requirements || '')) &&
492 saved?.origin === 'nodd' &&
493 saved.status !== 'running' &&
494 !has.has('execution.json') &&
495 !has.has('spec.md')
496 )
497}
498
499export type ContinueArgs = { slug: string; mode?: Mode; cap?: number; profile?: string }
500
501export function parseContinue(args: string): ContinueArgs | { error: string } | undefined {
502 const [head, ...rest] = String(args || '').trim().split(/\s+/)
503 const sub = (head || '').toLowerCase()
504 if (sub !== 'continue' && sub !== 'seguir') return undefined
505 const { request, ...flags } = parseStart(rest.join(' '))
506 if (/\s/.test(request)) return { error: 'uso: /forge continue [--auto|--interactive] [--cap N] [--profile p] [<slug>]' }
507 if (request && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(request) || request.includes('..'))) return { error: `slug inválido: ${request}` }
508 return { slug: request, ...flags }
509}
510
511export type ModelGroup = { group: string; label?: string; options: { value: string; label: string }[] }
512
513export function groupOf(model: string): string {
514 const m = String(model || '')
515 if (CLAUDE_ALIASES.includes(m) || /^claude-/.test(m)) return 'claude'
516 return m.includes('/') ? m.split('/')[0] : 'cpam'
517}
518
519const VENDORS: [RegExp, string][] = [
520 [/^claude|haiku|sonnet|opus|fable/, 'Claude'],
521 [/^gpt-oss/, 'gpt-oss'],
522 [/^(gpt|codex)/, 'GPT'],
523 [/^gemini/, 'Gemini'],
524 [/^deepseek/, 'DeepSeek'],
525 [/^glm/, 'GLM'],
526 [/^kimi/, 'Kimi'],
527 [/^qwen/, 'Qwen'],
528 [/^grok/, 'Grok'],
529 [/^minimax/, 'MiniMax'],
530 [/^mimo/, 'MiMo'],
531]
532
533export function vendorOf(id: string): string {
534 const name = id.includes('/') ? id.slice(id.indexOf('/') + 1) : id
535 return VENDORS.find(([re]) => re.test(name))?.[1] || 'otros'
536}
537
538export function groupLabel(group: string, ids: readonly string[]): string {
539 if (group === 'claude') return 'Claude Code (tu plan)'
540 const vendors = [...new Set(ids.map(vendorOf))]
541 return `${group} · ${vendors.length > 3 ? `${vendors.slice(0, 3).join('/')}…` : vendors.join('/')}`
542}
543
544const GROUP_ORDER = ['claude', 'personal', 'priv8', 'prolite', 'plus', 'ag', 'ag2', 'ag3', 'ag4', 'ag5', 'ds', 'oc', 'cpam']
545
546export function versionKey(name: string): { family: string; version: number[]; rest: string } {
547 const m = /^(.*?)-?(\d{1,2}(?:[.-]\d{1,2})*)(?!\d)(.*)$/.exec(name)
548 if (!m) return { family: name, version: [], rest: '' }
549 return { family: m[1], version: m[2].split(/[.-]/).map(Number), rest: m[3] }
550}
551
552export function compareModels(a: string, b: string): number {
553 const x = versionKey(a)
554 const y = versionKey(b)
555 if (x.family !== y.family) return x.family.localeCompare(y.family)
556 for (let i = 0; i < Math.max(x.version.length, y.version.length); i++) {
557 const d = (y.version[i] ?? -1) - (x.version[i] ?? -1)
558 if (d) return d
559 }
560 return x.rest.localeCompare(y.rest)
561}
562
563export function compareGroups(a: string, b: string): number {
564 const ra = GROUP_ORDER.indexOf(a)
565 const rb = GROUP_ORDER.indexOf(b)
566 if (ra !== rb) return (ra < 0 ? 99 : ra) - (rb < 0 ? 99 : rb)
567 return a.localeCompare(b)
568}
569
570export const isExcluded = (id: string, exclude: readonly string[]) => exclude.some((x) => id.toLowerCase().startsWith(`${x.toLowerCase()}/`))
571
572export function modelCatalog(ids: readonly string[], labels: Readonly<Record<string, string>> = {}): ModelGroup[] {
573 const keep = [...new Set(ids)].filter((id) => !/image/i.test(id) && !/^claude-/.test(id))
574 const byGroup = new Map<string, string[]>()
575 for (const id of keep) byGroup.set(groupOf(id), [...(byGroup.get(groupOf(id)) || []), id])
576 const name = (id: string) => (id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)
577 const groups: ModelGroup[] = [{ group: 'claude', label: groupLabel('claude', []), options: CLAUDE_ALIASES.map((a) => ({ value: a, label: `${a[0].toUpperCase()}${a.slice(1)} (tu plan)` })) }]
578 for (const g of [...byGroup.keys()].sort(compareGroups))
579 groups.push({
580 group: g,
581 label: groupLabel(g, byGroup.get(g)!),
582 options: byGroup
583 .get(g)!
584 .sort((a, b) => compareModels(name(a), name(b)))
585 .slice(0, 64)
586 .map((id) => ({ value: id, label: labels[id] || labels[name(id)] || name(id) })),
587 })
588 return groups.slice(0, 64)
589}
590
591export function shortModel(model: string): string {
592 return String(model || '').replace(/^[^/]+\//, '')
593}
594
595export function tokens(n: number): string {
596 if (!n) return '0'
597 return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n)
598}
599
600export function mmss(ms: number): string {
601 const s = Math.max(0, Math.floor(ms / 1000))
602 const m = Math.floor(s / 60)
603 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`
604}
605
606const KEPT_REMINDERS = /^<system-reminder>\s*(# Environment|Today's date)/
607
608export function privateReminder(block: any): boolean {
609 return block?.type === 'text' && /^\s*<system-reminder>/.test(String(block.text || '')) && !KEPT_REMINDERS.test(String(block.text).trim())
610}
611
612export function cleanMessages(messages: readonly any[], thinking: ReadonlyMap<string, any[]>): any[] {
613 return messages.map((m: any) => {
614 if (!Array.isArray(m.content)) return { role: m.role, content: m.content }
615 const blocks = m.content.filter((b: any) => b.type !== 'thinking' && b.type !== 'redacted_thinking')
616 if (m.role !== 'assistant') {
617 const kept = blocks.filter((b: any) => !privateReminder(b))
618 return { role: m.role, content: kept.length ? kept : [{ type: 'text', text: '(context omitted)' }] }
619 }
620 const out: any[] = []
621 for (const b of blocks) {
622 if (b.type === 'tool_use' && thinking.has(b.id)) out.push(...(thinking.get(b.id) || []))
623 out.push(b)
624 }
625 return { role: m.role, content: out }
626 })
627}
628
629export function thinkingByTool(content: readonly any[]): Map<string, any[]> {
630 const out = new Map<string, any[]>()
631 let pending: any[] = []
632 for (const b of content || []) {
633 if (b.type === 'thinking' || b.type === 'redacted_thinking') pending.push(b)
634 else if (b.type === 'tool_use') {
635 if (pending.length) out.set(b.id, pending)
636 pending = []
637 }
638 }
639 return out
640}
641
642
643export type ExportStatus = 'running' | 'paused' | 'pasa' | 'no-verificado' | 'bloqueado' | 'falló' | 'parado'
644export type PhaseStat = { status: 'pending' | 'running' | 'done' | 'error'; model: string; effort?: Effort; answeredBy: string; ms: number; startedAt: number; inTok: number; outTok: number; steps: number }
645export type RunView = {
646 status: RunStatus
647 request: string
648 slug: string
649 round: number
650 cap: number
651 phase?: Phase
652 order: readonly Phase[]
653 startedAt: number
654 endedAt?: number
655 stats: Record<Phase, PhaseStat>
656 verdicts: Verdict[]
657 decisions?: Decision[]
658 size?: Size
659 origin?: 'nodd'
660 batch?: { index: number; total: number; tasks: string[]; parallel?: boolean }
661}
662export type StateInput = {
663 version: string
664 profile: string
665 profiles: Record<string, Record<Phase, string>>
666 profileEfforts: Record<string, Record<Phase, Effort>>
667 zeroProfiles?: readonly string[]
668 models: Record<Phase, string>
669 efforts: Record<Phase, Effort>
670 skip?: readonly Phase[]
671 mode: string
672 cap: number
673 modelIds: readonly string[]
674 labels?: Readonly<Record<string, string>>
675 run?: RunView
676 asking: boolean
677 now: number
678}
679
680export function exportStatus(status: RunStatus, asking: boolean): ExportStatus {
681 if (status === 'running') return asking ? 'paused' : 'running'
682 if (status === 'fallido') return 'falló'
683 if (status === 'cortado') return 'parado'
684 return status
685}
686
687export function stateSnapshot(i: StateInput) {
688 const zero = i.zeroProfiles || []
689 const profiles: Record<string, Record<Phase, string> & { effort: Record<Phase, Effort>; builtin: boolean; modified: boolean; source: 'forge' | 'zero-pi' }> = {}
690 for (const [name, p] of Object.entries(i.profiles)) {
691 const fromZero = zero.includes(name)
692 profiles[name] = {
693 ...p,
694 effort: { ...AUTO_EFFORTS, ...(i.profileEfforts[name] || {}) },
695 builtin: !fromZero && BUILTIN_PROFILES.includes(name),
696 modified: !fromZero && builtinModified(name, p, i.profileEfforts[name]),
697 source: fromZero ? 'zero-pi' : 'forge',
698 }
699 }
700 const catalog: Record<string, string[]> = {}
701 const groupLabels: Record<string, string> = {}
702 const labels: Record<string, string> = {}
703 for (const g of modelCatalog(i.modelIds, i.labels)) {
704 catalog[g.group] = g.options.map((o) => o.value)
705 groupLabels[g.group] = g.label || g.group
706 for (const o of g.options) labels[o.value] = o.label
707 }
708 const effortNotes = {} as Record<Phase, string>
709 for (const p of PHASES) effortNotes[p] = routeFor(i.models[p]).kind === 'claude' ? '' : effortBlocked(i.models[p])
710 const run = i.run
711 ? {
712 status: exportStatus(i.run.status, i.asking),
713 request: i.run.request,
714 slug: i.run.slug,
715 round: i.run.round,
716 cap: i.run.cap,
717 phase: i.run.phase ?? null,
718 phaseOrder: [...i.run.order],
719 startedAt: i.run.startedAt,
720 endedAt: i.run.endedAt ?? null,
721 batch: i.run.batch ? { index: i.run.batch.index + 1, total: i.run.batch.total, tasks: [...i.run.batch.tasks], ...(i.run.batch.parallel ? { parallel: true } : {}) } : null,
722 phases: i.run.order.map((name) => {
723 const s = i.run!.stats[name]
724 return {
725 name,
726 status: s.status === 'error' ? 'failed' : s.status,
727 model: s.model,
728 effort: s.effort || 'auto',
729 responded: s.answeredBy,
730 ms: s.status === 'running' ? i.now - s.startedAt : s.ms,
731 startedAt: s.startedAt,
732 tokensIn: s.inTok,
733 tokensOut: s.outTok,
734 steps: s.steps,
735 }
736 }),
737 verdicts: [...i.run.verdicts],
738 decisions: [...(i.run.decisions || [])],
739 size: i.run.size ?? null,
740 origin: i.run.origin ?? null,
741 }
742 : null
743 return {
744 version: i.version,
745 profile: i.profile,
746 profiles,
747 phaseOrder: phaseOrder(i.skip),
748 phases: [...PHASES],
749 models: { ...i.models },
750 efforts: { ...AUTO_EFFORTS, ...i.efforts },
751 effortLevels: [...EFFORTS],
752 mode: i.mode === 'preguntar' ? 'ask' : i.mode,
753 cap: i.cap,
754 catalog,
755 groupLabels,
756 labels,
757 effortNotes,
758 run,
759 updatedAt: i.now,
760 }
761}
762
763export function fillPhases<T extends string>(p: Partial<Record<Phase, T>>): Record<Phase, T> {
764 return { ...p, clarify: p.clarify ?? p.explore, analyze: p.analyze ?? p.plan } as Record<Phase, T>
765}
766
767export function zeroProfiles(raw: unknown, exclude: readonly string[] = []): { profiles: Record<string, Record<Phase, string>>; efforts: Record<string, Record<Phase, Effort>> } {
768 const out = { profiles: {} as Record<string, Record<Phase, string>>, efforts: {} as Record<string, Record<Phase, Effort>> }
769 const all = (raw as any)?.profiles
770 if (!all || typeof all !== 'object') return out
771 const core: Phase[] = ['explore', 'plan', 'build', 'veredicto']
772 for (const [name, p] of Object.entries<any>(all)) {
773 const models = p?.models || {}
774 if (exclude.some((x) => name.toLowerCase().includes(x.toLowerCase())) || core.some((ph) => typeof models[ph] !== 'string')) continue
775 const providers = p?.providers || {}
776 const picked: Partial<Record<Phase, string>> = {}
777 for (const ph of PHASES) if (typeof models[ph] === 'string' && models[ph]) picked[ph] = providers[ph] === 'anthropic' ? aliasOf(models[ph]) : models[ph]
778 const full = fillPhases(picked)
779 if (PHASES.some((ph) => isExcluded(full[ph], exclude))) continue
780 const key = `zero:${name}`
781 out.profiles[key] = full
782 const th = p?.thinking || {}
783 out.efforts[key] = { ...AUTO_EFFORTS }
784 for (const ph of PHASES) if (isEffort(th[ph])) out.efforts[key][ph] = th[ph]
785 }
786 return out
787}
788
789export function aliasOf(id: string): string {
790 return Object.entries(CLAUDE_IDS).find(([, v]) => v === id)?.[0] || id
791}
792
793export function builtinModified(name: string, models: Readonly<Record<Phase, string>> | undefined, efforts: Readonly<Partial<Record<Phase, Effort>>> | undefined): boolean {
794 const base = DEFAULT_PROFILES[name]
795 if (!base || !models) return false
796 return PHASES.some((p) => models[p] !== base[p] || (efforts?.[p] || 'auto') !== DEFAULT_EFFORTS[name]![p])
797}
798
799export function zeroTarget(model: string): { provider: string; model: string } {
800 const m = String(model || '').trim()
801 if (CLAUDE_ALIASES.includes(m)) return { provider: 'anthropic', model: CLAUDE_IDS[m]! }
802 if (/^claude-/.test(m)) return { provider: 'anthropic', model: m }
803 return { provider: 'cliproxy', model: m }
804}
805
806export function editZeroProfile(raw: string, name: string, phase: Phase, change: { model?: string; effort?: Effort }): { text: string } | { error: string } {
807 let data: any
808 try {
809 data = JSON.parse(String(raw || ''))
810 } catch {
811 return { error: '~/.pi/zero.json no es JSON válido' }
812 }
813 const p = data?.profiles?.[name]
814 if (!p || typeof p !== 'object') return { error: `~/.pi/zero.json no tiene el perfil ${name}` }
815 if (change.model !== undefined) {
816 const target = zeroTarget(change.model)
817 p.models = { ...(p.models || {}), [phase]: target.model }
818 if (p.providers && typeof p.providers === 'object') p.providers[phase] = target.provider
819 else p.providers = { [phase]: target.provider }
820 }
821 if (change.effort !== undefined) {
822 if (change.effort === 'auto') {
823 if (p.thinking && typeof p.thinking === 'object') delete p.thinking[phase]
824 } else if (p.thinking && typeof p.thinking === 'object') p.thinking[phase] = change.effort
825 else p.thinking = { [phase]: change.effort }
826 }
827 return { text: JSON.stringify(data, null, 2) + (String(raw).endsWith('\n') ? '\n' : '') }
828}
829
830export function profileName(raw: string, taken: readonly string[]): { name: string } | { error: string } {
831 const name = String(raw || '')
832 .normalize('NFD')
833 .replace(/[\u0300-\u036f]/g, '')
834 .toLowerCase()
835 .trim()
836 .replace(/\s+/g, '-')
837 .replace(/[^a-z0-9._-]/g, '')
838 .replace(/^[-.]+|[-.]+$/g, '')
839 .slice(0, 40)
840 if (!name) return { error: 'el nombre queda vacío: usá letras, números, punto, guion o guion bajo' }
841 if (RESERVED_PROFILES.includes(name) || BUILTIN_PROFILES.includes(name)) return { error: `${name} es un nombre reservado` }
842 if (taken.includes(name)) return { error: `ya existe el perfil ${name}` }
843 return { name }
844}
845hooks/prompts.ts 233 lines1import type { Phase, Verdict } from './logic.ts'
2
3const SHARED = `
4You receive no global user context and no prior conversation. Everything you need is in the brief (your first user message) and in the files it points to.
5
6**Scope every search to the code root, never the filesystem root.** A \`find\`, \`grep -r\` or \`rg\` rooted at \`/\`, \`~\` or \`$HOME\` is forbidden. Use the Glob and Grep tools with an explicit \`path\` inside the code root.
7
8**Tools.** Paths are absolute. Read a file before you Edit it or Write over it. Do not re-read a file you already read unless it changed. Bash runs in the code root unless you \`cd\` (prefer absolute paths). Call several tools in one response when they are independent.
9
10**Return contract.** When you are done, reply with a concise result envelope in English: the phase outcome and the \`.sdd/<slug>/\` artifact paths you touched. No step-by-step narration, no echoed tool output. You never address the user directly; the forge orchestrator reads your envelope.`
11
12export const PHASE_PROMPTS: Record<Phase, string> = {
13 clarify: `You run the **clarify** phase of a forge SDD (spec-driven development) pipeline inside Claude Code. You are the first gate, before \`explore\`. De-risk the feature request cheaply: read it, record the assumptions the rest of the pipeline will build on, and surface a blocking question **only** when proceeding would risk implementing the wrong product.
14
15Work **read-only**: do not create or modify any file (Bash is for scoped inspection only). This phase does not investigate the codebase in depth, that is \`explore\`'s job: stay light (budget: **8 tool calls**).
16
17**Bias toward assumptions, not questions.** Most ambiguity is resolvable with a reasonable default: record it as an assumption and move on. Reserve a blocking question for genuinely high-risk forks, a choice that would send the whole build in the wrong direction or ship the wrong product. When in doubt, assume and record. Do not ask about harness mechanics (test commands, PR shape, changed-line budgets): those belong to \`plan\`.
18
19**Cover the product surface, not only the technical one.** Make sure each of these is answered by the request, recorded as an assumption, or named as out of scope: the problem being solved, who uses it and when, the business rules, the observable outcome that means it worked, edge cases and failure modes, explicit non-goals, and the tradeoff being accepted. Most items are a one-line assumption.
20
21The brief says whether the run is **interactive** or **automatic**. In automatic mode nobody can answer: never return \`blocked\`; turn every would-be question into an explicit assumption marked \`(assumed, automatic mode)\` and return \`continue\`.
22
23Your final reply IS \`clarifications.md\` (forge saves it verbatim). Use exactly these sections:
24
25## Status
26\`continue\` or \`blocked\`, alone on the line.
27
28## Size
29Exactly one of these lines, alone and verbatim: \`Size: small\` or \`Size: normal\`. Use \`Size: small\` only when the whole request is a one-step change that does not merit a spec: a typo, a rename, a style tweak, a one-line fix or a config adjustment, in one or two files and with no new behavior. When in doubt, \`Size: normal\`. The size never changes the route: the run continues either way.
30
31## Assumptions
32The concrete defaults the run proceeds under.
33
34## Non-blocking decisions
35Interpretation calls the user can override later.
36
37## Blocking questions
38Only when the status is \`blocked\`: each question names exactly what is unresolved and why it blocks.
39${SHARED}`,
40
41 explore: `You run the **explore** phase of a forge SDD (spec-driven development) pipeline inside Claude Code.
42
43Investigate the codebase and the feature request **read-only**. Do not modify any file (Bash is for inspection only). If the brief names a \`clarifications.md\`, read it first and respect its assumptions. Map the relevant modules, existing patterns and conventions, the integration points, and the constraints. Identify risks and unknowns. If the project root has an \`AGENTS.md\` or \`CLAUDE.md\`, skim it once for conventions.
44
45**Size exploration to the request.** A localized change (one function, one component, a config value) needs only the files that define it plus their immediate wiring; a cross-cutting change earns full breadth. Budget: **20 tool calls** for a localized change, **40** for a cross-cutting one. Stop once you can name the exact files to change and their constraints.
46
47Your final reply IS the findings report (forge saves it as \`findings.md\`). It must open with:
48
49## Code roots
50- the absolute path of every code directory relevant to the feature (never the \`.sdd/\` dir).
51
52Then: relevant files and patterns, test runner and the exact command to run tests (or "none found"), constraints and project rules, risks, unknowns, and concrete next seams for the plan phase.
53${SHARED}`,
54
55 plan: `You run the **plan** phase of a forge SDD pipeline inside Claude Code.
56
57Read \`request.md\`, \`clarifications.md\` (when the brief names it) and \`findings.md\` in the run directory given in the brief, then write the plan. Do not write implementation code in this phase, only the plan artifacts. Keep the clarify assumptions; if you must deviate from one, say so in \`proposal.md\`. If the brief carries feedback from a previous \`replantear\` verdict or blockers from the \`analyze\` gate (\`checklist.md\`), fix exactly the flaws they name and do not repeat them.
58
59Write these four files into the run directory (\`.sdd/<slug>/\`) with the Write tool:
60
61- **proposal.md**: the change intent, scope and rationale. Prose.
62- **spec.md**: the requirements as \`## ADDED\` (and \`## MODIFIED\` / \`## REMOVED\` when relevant) sections holding \`### REQ: <stable-unique-name>\` blocks, each with prose followed by an \`Acceptance criteria:\` list.
63- **design.md**: how it is built. It must carry a \`## Code roots\` section copied from the findings (absolute paths), the files to touch, and the test command.
64- **tasks.md**: an ordered checklist of small, independently verifiable tasks, each in exactly this shape:
65
66\`\`\`markdown
67- [ ] T001 — Implement focused capability
68 - files: \`<abs path>/src/example.ts\`, \`<abs path>/src/example.test.ts\` (new)
69 - depends: []
70 - evidence: \`<exact focused test command>\` passes
71 - review: ~120 changed lines
72\`\`\`
73
74Rules for tasks: ids are monotonic \`T###\`; every task carries the four bullets \`files\`, \`depends\`, \`evidence\` and \`review\` (forge validates them structurally and re-runs plan when one is missing); \`files\` lists exact paths and marks new files \`(new)\`; \`depends\` points only to earlier task ids (\`[]\` when none); \`evidence\` names a concrete command; for every code task list its test file next to the production file so the build can work test-first; keep each task under ~400 changed lines. Add the token \`[P]\` after the title (\`- [ ] T002 — Add parser [P]\`, never between the id and the dash) only for tasks that are truly parallel-safe: all of its \`depends\` are earlier tasks, and its \`files\` share no path with any other task that could run alongside it. forge runs up to 3 consecutive eligible \`[P]\` tasks with disjoint files at the same time; when in doubt, leave \`[P]\` out. End \`tasks.md\` with a \`## Review Workload\` section: a per-task list of the estimates and the **bold total**.
75
76Keep the plan proportional: a one-function change is one or two tasks, not ten.
77${SHARED}`,
78
79 build: `You run the **build** phase of a forge SDD pipeline inside Claude Code.
80
81Read \`design.md\` (its \`## Code roots\`) and \`tasks.md\` in the run directory given in the brief. When the brief names a batch, implement **only** those task ids, in order, then return; do not start a task until its \`depends:\` entries are \`[x]\`. Otherwise continue from the first \`[ ]\` task; \`[x]\` tasks are done, leave them alone. Go straight to the paths in each task's \`files:\` bullet; do not scan the tree to rediscover the code. If \`tasks.md\` is missing, report the missing prerequisite and stop: do not invent a plan.
82
83Implement the tasks in dependency order and stay inside the plan's scope. Unless the brief says \`Strict TDD mode: off\`, when a test runner exists and a task touches code, work **test-first**: RED (write the failing test first and run it), GREEN (minimum code to pass, run the focused test), TRIANGULATE (a second case with different inputs), REFACTOR (keep it green). Record each task in \`tdd-evidence.md\` in the run directory as a table row: task | test file | RED result | GREEN result | notes. Append rows, never erase earlier ones.
84
85After each task passes, mark it \`[x]\` in \`tasks.md\` (Edit). Before finishing, run the full test suite once and make it pass (with a batch, the suite must stay green for the tasks done so far). If the brief carries \`corregir\` feedback from the veredicto, fix exactly those defects first and re-run their tests.
86
87**Parallel wave.** When the brief says you are one task of a parallel wave, other build agents are editing other files at the same time: implement ONLY your task and touch only the paths in its \`files:\`. Do NOT edit \`tasks.md\` or \`tdd-evidence.md\` (they are shared; forge ticks your box and merges your evidence when the wave closes). Write your TDD evidence table to \`tdd-evidence/<T###>.md\` in the run directory instead. Run only your task's focused tests (its \`evidence:\` command), not the full suite: a sibling's half-done edit can turn the suite red for reasons that are not yours.
88
89Your final reply: what you changed (files), the test command you ran and its result (pass/fail counts), and any task you could not complete with the reason.
90${SHARED}`,
91
92 analyze: `You run the **analyze** phase of a forge SDD pipeline inside Claude Code. You are the post-plan gate: forge already checked the plan structurally (every task has \`files\`/\`depends\`/\`evidence\`/\`review\`, dependencies point backward). You review its **qualitative readiness** and decide whether build may begin. Do not merely repeat the structural checks.
93
94Work **read-only**: do not create or modify any file (Bash only for scoped sanity checks of evidence commands). You never implement anything.
95
96Read \`proposal.md\`, \`spec.md\`, \`design.md\`, \`tasks.md\` and, when the brief names it, \`clarifications.md\` in the run directory. A missing or obviously truncated \`tasks.md\`, \`design.md\` or \`spec.md\` is grounds for \`replan\`.
97
98**Qualitative checks:**
99- **Unresolved ambiguity**: do the clarify assumptions still hold, or did the plan silently pick a different interpretation?
100- **Acceptance criteria**: concrete and testable, or vague ("works", "is fast")?
101- **Task graph quality**: sane ordering, real dependencies (no missing prerequisite, no invented one).
102- **Focused evidence**: each \`evidence:\` names a concrete runnable command, not prose.
103- **TDD suitability**: each code task can be driven test-first in one RED → GREEN cycle.
104- **Scope boundaries**: the plan stays within the requested change.
105- **Review workload**: per-task estimates within ~400 changed lines; an unsplit oversized task is a defect.
106
107Decide \`continue\` when the plan is ready to build under the recorded assumptions. Decide \`replan\` only when a concrete defect would waste a build round, and list those defects precisely: forge re-runs plan with exactly your blockers. A second \`replan\` in the same run stops it as blocked, so do not replan over cosmetic issues.
108
109Your final reply IS \`checklist.md\` (forge saves it verbatim). Use exactly these sections:
110
111## Analyzed artifacts
112The files you reviewed.
113
114## Checklist
115Each qualitative check with a pass/concern note.
116
117## Blockers
118Only for \`replan\`: the concrete defects the plan must fix.
119
120## Decision
121Decision: <continue|replan>
122${SHARED}`,
123
124 veredicto: `You run the **veredicto** phase of a forge SDD pipeline inside Claude Code. You review the build **adversarially, with a fresh perspective**, and you do not modify any file (Bash only to run tests and inspect).
125
126Read \`spec.md\`, \`design.md\`, \`tasks.md\`, \`tdd-evidence.md\` (if present) and the latest \`build-r*.md\` in the run directory given in the brief, then go straight to the changed files under the code root. Check the build against every acceptance criterion: run the tests yourself, confirm the reported-green tests are still green, and look for gaps, regressions, unmet criteria, unchecked tasks, and weak tests (tautologies, assertions that cannot fail, tests that never execute the production code). A missing TDD evidence table when a test runner exists, or a reported-green test that now fails, is grounds for \`corregir\`.
127
128Choose exactly one verdict:
129- \`pasa\`: the build meets the plan and the evidence supports it.
130- \`corregir\`: fixable defects remain in the code; the build phase must re-run.
131- \`replantear\`: the plan itself is wrong or incomplete; the plan phase must re-run.
132
133Never return \`pasa\` unless the evidence supports it. State the reasoning concretely: the specific defects (file, line, failing command) for \`corregir\`, the specific plan flaw for \`replantear\`, the evidence you checked for \`pasa\`.
134
135Your reply MUST end with this exact line, alone, lowercase verdict:
136VEREDICTO: <pasa|corregir|replantear>
137${SHARED}`,
138}
139
140export function briefFor(p: {
141 phase: Phase
142 slug: string
143 dir: string
144 cwd: string
145 request: string
146 round: number
147 cap: number
148 mode?: 'interactive' | 'automatic'
149 clarified?: boolean
150 feedback?: { verdict: Verdict; text: string }
151 blockers?: string
152 batch?: { index: number; total: number; tasks: string[] }
153 wave?: { index: number; total: number; task: string; tasks: string[] }
154 tdd?: 'strict' | 'off'
155 adjust?: string
156 retryReason?: string
157 handoff?: boolean
158}): string {
159 const lines = [
160 `forge run \`${p.slug}\` · phase **${p.phase}**${p.phase === 'build' || p.phase === 'veredicto' ? ` · round ${p.round}/${p.cap}` : ''}${p.batch ? ` · batch ${p.batch.index + 1}/${p.batch.total}` : ''}${p.wave ? ` · parallel wave ${p.wave.index + 1}/${p.wave.total} · task ${p.wave.task}` : ''}`,
161 '',
162 `- Run directory (artifacts): ${p.dir}`,
163 `- Code root (working directory): ${p.cwd}`,
164 `- Feature request (verbatim, also in ${p.dir}/request.md):`,
165 '',
166 p.request
167 .split('\n')
168 .map((l) => `> ${l}`)
169 .join('\n'),
170 ]
171 if (p.phase === 'clarify') lines.push('', `Run mode: ${p.mode || 'automatic'}.${p.mode === 'interactive' ? ' A blocking question will be shown to the user.' : ' Nobody can answer questions: assume and return continue.'}`)
172 if (p.clarified && p.phase !== 'clarify' && p.phase !== 'build') lines.push('', `Clarify assumptions: ${p.dir}/clarifications.md.`)
173 if (p.handoff && (p.phase === 'explore' || p.phase === 'plan'))
174 lines.push(
175 '',
176 `This run adopts a NODD handoff: the feature request is ${p.dir}/requirements.md, written by /nodd-promote (copied verbatim to request.md). There is no clarify phase: its Objective, Scope and Constraints are already settled.`,
177 `The items under "Already resolved — do not redo" in requirements.md are context, not work: they are done and verified. Do not ${p.phase === 'plan' ? 're-plan, redo or re-implement them, and write no task for them' : 'investigate them as pending work'}; ${p.phase === 'plan' ? 'plan only the "Remaining work"' : 'focus on what the "Remaining work" needs'}.`,
178 )
179 if (p.phase === 'plan') lines.push('', `Read ${p.dir}/findings.md first.`)
180 if (p.phase === 'build') lines.push('', `Read ${p.dir}/design.md and ${p.dir}/tasks.md first.`)
181 if (p.batch) lines.push('', `Batch ${p.batch.index + 1}/${p.batch.total}: implement ONLY tasks ${p.batch.tasks.join(', ')}, then return. Do not start a task until its depends: entries are [x].`)
182 if (p.wave)
183 lines.push(
184 '',
185 `Parallel wave ${p.wave.index + 1}/${p.wave.total}: you are one of ${p.wave.tasks.length} build agents running at the same time (${p.wave.tasks.join(', ')}). Implement ONLY task ${p.wave.task}, touching only the paths in its files: list. Its depends: entries are already done.`,
186 `Do NOT edit ${p.dir}/tasks.md or ${p.dir}/tdd-evidence.md: forge ticks the box and merges the evidence when the wave closes. Write your TDD evidence table to ${p.dir}/tdd-evidence/${p.wave.task}.md (create the directory if needed).`,
187 `Run only ${p.wave.task}'s focused tests (its evidence: command), not the full suite: the other agents are editing at the same time and can turn it red.`,
188 )
189 if (p.tdd && (p.phase === 'build' || p.phase === 'veredicto'))
190 lines.push('', p.tdd === 'off' ? 'Strict TDD mode: off (the project opted out in .sdd/config.json).' : 'Strict TDD mode: strict. Follow RED → GREEN → TRIANGULATE → REFACTOR and record the TDD evidence table.')
191 if (p.phase === 'veredicto') lines.push('', `The build envelope of this round is ${p.dir}/build-r${p.round}.md.`)
192 if (p.blockers) lines.push('', `The analyze gate asked to replan (${p.dir}/checklist.md). Fix exactly these blockers:`, '', p.blockers.trim())
193 if (p.feedback) lines.push('', `Feedback from the previous veredicto (\`${p.feedback.verdict}\`), address it:`, '', p.feedback.text.trim())
194 if (p.adjust) lines.push('', 'Adjustment requested by the user before this phase (takes priority):', '', p.adjust.trim())
195 if (p.retryReason) lines.push('', `Your previous attempt at this phase did not deliver: ${p.retryReason}. Fix exactly that.`)
196 return lines.join('\n')
197}
198
199export function agentCall(slug: string, phase: Phase, parallel = 1): string {
200 const one = `subagent_type "forge:${phase}", description "forge ${phase}", prompt "forge ${slug} ${phase}" and run_in_background false`
201 if (parallel < 2) return `call the Agent tool with ${one}`
202 return `make ${parallel} Agent tool calls IN ONE SINGLE MESSAGE so they run in parallel (a parallel wave: forge assigns a different task to each), every one of them with ${one}. Send exactly ${parallel} calls together, not one after the other`
203}
204
205export function startInstruction(slug: string, phase: Phase, parallel = 1): string {
206 return [
207 `[forge] Run \`${slug}\` is ready. You are only the dispatcher of the forge mod: it decides the phase order, the models and the artifacts.`,
208 `Now ${agentCall(slug, phase, parallel)}.`,
209 'Every Agent result will tell you the next call. Do not read files, do not do the work yourself, do not comment between calls: just make the next call the result asks for, until a result says the run ended.',
210 ].join('\n')
211}
212
213export function nextInstruction(slug: string, phase: Phase, summary: string, parallel = 1): string {
214 return `[forge] ${summary}\nNext: ${agentCall(slug, phase, parallel)}. No commentary.`
215}
216
217export function waveChildInstruction(task: string, wave: { index: number; total: number }, pending: number): string {
218 return `[forge] ${task} of parallel wave ${wave.index + 1}/${wave.total} returned. ${pending} other agent(s) of the wave are still running: the result of the last one carries the next instruction. Do not call anything for this result.`
219}
220
221export function extraCallDenial(phase: Phase, tasks: readonly string[]): string {
222 return phase === 'build' && tasks.length
223 ? `[forge] Extra forge:build call refused: every task of the current build unit (${tasks.join(', ')}) already has its agent. Do not retry it; wait for the results already running.`
224 : `[forge] Extra forge:${phase} call refused: forge:${phase} is already running. Make only the calls a [forge] instruction asks for and wait for their results.`
225}
226
227export const NODD_HINT = 'Esto parece chico para forge: con NODD (https://nodd.com.ar) lo hacés directo y con los tests corridos de verdad.'
228
229export function finalInstruction(summary: string, size?: 'small' | 'normal'): string {
230 const small = size === 'small' ? ` Clarify sized this request as small: include one line recommending NODD for changes of this size, like "${NODD_HINT}"` : ''
231 return `[forge] ${summary}\nThe run ended. Do not call any more agents. Reply to the user in Spanish (rioplatense, voseo) in 2-5 short lines: the outcome, rounds, the artifacts directory and the key reason from the last veredicto. If the outcome is not "pasa", say clearly that the result is NOT verified.${small}`
232}
233