SLOPSHOPPER

forge

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

newpaneguardcommandtoastprompt
v0.3.0no licenseupdated 2026-10-05gonzalonicolasr/claude-code-forge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forge
│ ┃ FORGE ✕ › fix the failing auth test and add an audit log call │ ┃ FORGE SDD ◎ EN ESPERA │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ FASES ⎿ Read 6 lines │ ┃ │ · CLARIFY CPAM saltear ⏺ Update(src/auth.ts) │ ┃ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡low ▾ ⎿ Added 2 lines, removed 1 line │ ┃ │ · EXPLORE CPAM ⏺ Bash(bun test) │ ┃ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡low ▾ ⎿ 3 pass, 1 fail │ ┃ │ · PLAN CPAM │ ┃ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡mediu ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ · ANALYZE CPAM saltear │ ┃ │ prolite ▾ prolite/gpt-6-luna ▾ ⚡medium ▾ ✻ Worked for 42s · done 4:20 PM │ ┃ │ · BUILD CPAM │ ┃ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡mediu › /forge │ ┃ │ · VEREDICTO CPAM │ ┃ │ prolite ▾ prolite/gpt-6-sol ▾ ⚡high ▾ │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ CONFIG │ │ ┃ │ perfil barato ▾ │ │ ┃ │ modo: preguntar al arrancar ▾ │ │ ┃ │ cap 3 − + │ │ ┃ │ CPAM sin respuesta (red) │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ NUEVO RUN │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · FORGE
FORGE SDD ◎ EN ESPERA ╭──────────────────────────────────────────────────────────╮ │ FASES │ │ · CLARIFY CPAM saltear │ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡low ▾ │ │ · EXPLORE CPAM │ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡low ▾ │ │ · PLAN CPAM │ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡medium ▾ │ │ · ANALYZE CPAM saltear │ │ prolite ▾ prolite/gpt-6-luna ▾ ⚡medium ▾ │ │ · BUILD CPAM │ │ ag3 ▾ ag3/gemini-3.7-flash-high ▾ ⚡medium ▾ │ │ · VEREDICTO CPAM │ │ prolite ▾ prolite/gpt-6-sol ▾ ⚡high ▾ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ CONFIG │ │ perfil barato ▾ │ │ modo: preguntar al arrancar ▾ │ │ cap 3 − + │ │ CPAM sin respuesta (red) │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ NUEVO RUN │ │ pedido para forge… │ │ [ ▶ Iniciar ] │ ╰──────────────────────────────────────────────────────────╯
README

forge

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 o NODD?

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.

Uso

claude --plugin-dir ~/projects/forge
ComandoQué hace
/forge <pedido>Arranca un run. Banderas: --auto / --interactive, --cap N, `--profile barato\calidad\<tuyo>`
/forge o /forge uiAbre el panel FORGE (con NERV cargado, un run nuevo no lo abre solo: se sigue en la pestaña FORGE de NERV)
/forge statusModelos por fase, estado del run, tiempos y tokens
/forge stopCorta 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

Modelos y perfiles

  • Con prefijo de cuenta (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).
  • Perfiles de fábrica (se pueden pisar con 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:
Perfilclarifyexploreplananalyzebuildveredicto
turboag3/gemini-3.5-flash-lite · minimalag3/gemini-3.5-flash-lite · minimalprolite/gpt-6-luna · lowprolite/gpt-6-luna · lowag3/gemini-3.5-flash-lite · lowprolite/gpt-6-luna · low
baratoag3/gemini-3.7-flash-high · lowag3/gemini-3.7-flash-high · lowag3/gemini-3.7-flash-high · mediumprolite/gpt-6-luna · mediumag3/gemini-3.7-flash-high · mediumprolite/gpt-6-sol · high
equilibradoag3/gemini-3.7-flash-high · lowag3/gemini-3.7-flash-high · mediumprolite/gpt-6-sol · highprolite/gpt-6-luna · highprolite/gpt-6-luna · mediumsonnet · high
calidadag3/gemini-3.8-flash-high · mediumag3/gemini-3.8-flash-high · highprolite/gpt-6-sol · xhighprolite/gpt-6-astra · highprolite/gpt-6-sol · highopus · xhigh
open-sourceds/deepseek-flash · (n/a)ds/deepseek-flash · (n/a)ds/deepseek-v4-pro · highds/deepseek-v4-pro · highds/deepseek-v4-pro · highag3/gpt-oss-120b-medium · (n/a)
geminiag3/gemini-3.8-flash-high · lowag3/gemini-3.8-flash-high · mediumag3/gemini-pro-agent · highag3/gemini-pro-agent · highag3/gemini-3.8-flash-high · highag3/gemini-pro-agent · high
openaiprolite/gpt-6-luna · lowprolite/gpt-6-luna · mediumprolite/gpt-6-sol · xhighprolite/gpt-6-sol · highprolite/gpt-6-sol · highprolite/gpt-6-astra · xhigh
solo-claudehaiku · mediumhaiku · mediumopus · highsonnet · highsonnet · highopus · 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).

  • Perfiles de zero-pi: al arrancar, forge lee ~/.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.
  • El selector ordena los grupos por cuenta (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.
  • El selector del panel saca los modelos de imagen y las cuentas excluidas: los prefijos del CPAM que listes en ~/.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.

El panel

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.

Effort por fase

  • CPAM: va como sufijo del modelo, 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.
  • Claude Code (haiku/sonnet/opus/fable): turn.step reescribe effort (minimal/off bajan a low).
  • Sin efecto, se muestra 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).

Estado para otros mods: ~/.local/state/forge/state.json

forge 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.

Artefactos (.sdd/<slug>/ del repo de trabajo)

ArchivoLo escribe
request.mdforge, el pedido textual (en un run adoptado de NODD, copia exacta de requirements.md)
requirements.mdNODD (/nodd-promote): forge sólo lo lee para adoptar el handoff
clarifications.mdforge, 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.mdforge, con la respuesta de explore
proposal.md, spec.md, design.md, tasks.mdla fase plan (forge valida tasks.md como /zero-validate: cada T### con files, depends, evidence y review, y dependencias hacia atrás)
checklist.mdforge, con la respuesta de analyze (## Analyzed artifacts, ## Checklist, ## Blockers, `Decision: continue\replan`)
tdd-evidence.md, checkboxes de tasks.mdla 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.mdforge, con lo que devolvió cada ronda
rounds.jsonforge: { cap, verdicts }
run.jsonforge: el estado completo del run
routing.logforge: 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:

  • Tanda paralela: arranca con la primera tarea elegible (sus 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.
  • Lote secuencial: si la tanda queda de una sola tarea, va el loteo de siempre (hasta 4 tareas o 800 líneas estimadas por 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.

  • Un hijo que falla (sobre vacío, error del CPAM o llamada rechazada) deja su tarea en [ ] y se reintenta una vez, sola, como lote secuencial; si vuelve a fallar, el run termina como FALLÓ.
  • Si el modelo lanza menos llamadas que las pedidas, la tanda cierra con las que salieron y las demás van en la unidad siguiente. Una llamada de más se rechaza con un [forge] Extra … call refused (también en las otras fases: una sola llamada por fase a la vez).
  • En segundo plano (async_launched) cada hijo cierra en su turn.complete; el último manda la próxima instrucción con $.prompt.submit.
  • Todas las unidades son una sola ronda; el veredicto corre una vez al final, y las tandas no suman rondas.

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Ó.

Cómo funciona (y por qué así)

  1. Agentes: en 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.
  2. El modelo principal es sólo el despachante. forge le manda ($.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.
  3. Ruteo: 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).
  4. Segundo plano: en la REPL Claude Code corre los agentes en segundo plano aunque se pida 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.

Gotchas verificados (2026-10-02, Claude Code 2.1.287)

  • Un subagente lanzado con $.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".
  • En -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.
  • Privacidad: el primer mensaje de cualquier subagente trae como <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.
  • Gemini 3 por el CPAM devuelve un bloque 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.
  • El 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.
  • Al guardar un archivo del mod con --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.

Desarrollo

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 p
Source 3 files
hooks/register.ts 1769 lines
1import {
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(', ')})` : ''}` : phase
hooks/logic.ts 845 lines
1export 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}
845
hooks/prompts.ts 233 lines
1import 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