pi-improved para Claude Code: por defecto, /setup una vez con el modelo fuerte y trabajo directo con el modelo barato; /task (gates y revisión) es opcional…

pi-improved para Claude Code. Por defecto, /setup una vez con el modelo fuerte y después trabajo directo con el modelo barato. Para tareas de riesgo, un flujo opcional (/task) gobernado por código, donde el modelo fuerte entra solo donde paga (chequeo del plan, revisión con riesgo, escalado). Es un plugin de Claude Code que usa lo nativo: plan mode, subagentes con modelo propio, skills, hooks, permisos, MCP y coste.
Versión 0.1.0, pre-release. Funciona de punta a punta en modo headless (claude -p); la interfaz interactiva (TUI) no está verificada. Ver Limitaciones conocidas.
node --experimental-strip-types).gh solo para /pr.El plugin es agnóstico de lenguaje: no trae comandos de verificación fijos (nada de tsc ni eslint por defecto). Todo sale de .claude/workflow.json del proyecto.
/plugin marketplace add OnishellT/cc-improved
/plugin install cc-improved@cc-improved
Al instalar, Claude Code pide strong_model, el modelo del chequeo del plan, la revisión y el escalado (opus por defecto; sonnet usa las variantes -sonnet de los agents, más baratas).
Notas de la instalación (spike 0.6, docs/spikes/0.6-plugin-deps.md): el plugin no tiene dependencias de runtime, pero Claude Code también instala las devDependencies del repo (typescript, @types/node) en la caché del plugin. Es un coste conocido, no un fallo.
El modo por defecto es directo: el modelo barato trabaja con lo que dejó /setup (hecho una vez con opus). El flujo con gates (/task) es opcional y está pensado para tareas de riesgo. Elegir uno no impide el otro en el mismo repo; .claude/workflow.json guarda el modo del proyecto en el campo opcional mode ("direct" por defecto, o "flow" si quieres /task como ruta recomendada; sin el campo vale direct).
/setup una vez y trabajo directo. /setup deja CLAUDE.md (con dos secciones cortas: verificación antes de dar una tarea por terminada, y «contrato primero» con la skill contract-first, el script wf-contract-check.mjs y la regla de cuándo usar /task), las skills del proyecto y las herramientas de verificación. Después trabajas con el modelo barato sin plan mode, gates ni revisión con modelo. Con setup: "done" y mode: direct, SessionStart lo dice en una línea./task con gates y revisión. Plan verificado, implementación con contexto recortado, contrato, verify y, si el manifest lo activa, revisión del modelo fuerte. CLAUDE.md lo recomienda para cambios que tocan dependencias, migraciones, rutas de review.paths, o que son grandes. Es lo que tiene sentido ahí, donde el sobrecoste compra bloqueos y una segunda mirada.Los gates solo corren con una tarea abierta (/task). En modo directo no hay hooks nuevos que se activen en cada sesión; ver D-014 en docs/DECISIONS.md. Quien quiera gates sin abrir tarea tendría que hacerlo por otra vía (por ejemplo, el CI del proyecto con los mismos comandos de verify).
| Brazo | Qué es | Tests ocultos (de 436) | Coste de ejecución (10 celdas) | Coste con el setup repartido |
|---|---|---|---|---|
| D0 | modelo barato, sin plugin ni setup | 322 | $0.26 | $0.26 |
| D0s | modelo barato, sin plugin, con /setup | 343 | $0.26 | $0.75 |
| D1 | plugin completo, sin setup | 337 | $2.41 | $2.41 |
| D1v2 | plugin completo, con /setup | 333 | $2.12 | $2.61 |
Cómo leerlo: eval v2 (con setup previo; 436 tests ocultos en total; la tabla de «Resultados de la eval» más abajo es la v1 y no es comparable). n=1 por celda y un solo repositorio (thereelcapital, 5 tareas, variantes ciega y con pista): diferencias de unos pocos tests o de unos centavos están dentro del ruido. Lo medido es solo "tests ocultos por dólar"; no mide lo que aportan los gates, la revisión o los bloqueos. El coste con setup reparte los 7 setups ($4.94) entre 10 tareas. Detalle en docs/EVAL.md.
La eval v3 repitió D0 y D0s con n=3 por celda (solo modelo barato, sin plugin): 328,7 tests de media sin setup (rango 319–345) y 336,3 con setup (329–343), con rangos que se solapan. El setup no mostró una mejora de calidad más allá del ruido, y la ventaja aparente de v2 no se sostuvo. No se repitió el flujo completo (D1, D1v2).
Con estos datos: el flujo completo costó unas 8 veces más en ejecución que el modelo barato con setup ($2.12 contra $0.26) sin dar más tests, y el setup (unos $0.71 por commit base) no se paga por la calidad medida. No se puede afirmar que el flujo completo ni /setup mejoren la calidad. Se elige el modo directo como defecto por coste y simplicidad, y porque /setup deja convenciones y herramientas de verificación que se pueden usar sin plan mode; el flujo completo queda para tareas de riesgo, donde aporta bloqueos y revisión que esta eval no mide. Hay que repetir con más repos antes de concluir algo más fino.
/task)/cc-improved:setup → /cc-improved:task <ticket|descripción|spec> → /cc-improved:done → /cc-improved:pr
(Los comandos se pueden escribir sin el prefijo si no chocan con otros.)
/setup (una vez por repo, con el mejor modelo disponible). Escanea el repo en código, te entrevista y deja CLAUDE.md, GLOSSARY.md, ADRs, skills de dominio, la skill verify-app y las herramientas de verificación. Termina con .claude/workflow.json (setup: "done", verify, tools, connectors)./task lee el ticket (por MCP si hay conector), la spec, los docs y el código, y propone un plan en plan mode con un bloque wf-plan: archivos, interfaz (nombres y firmas con su fuente), seams de prueba y decisiones. Antes de que lo apruebes, un hook comprueba el plan en código (nombres verificados contra su fuente, siempre) y, solo si el manifest lo activa (review.plan: true, review.always o review.minLevel; apagado por defecto, D-015), pide el chequeo del plan al modelo (plan-check, de solo lectura). Una tarea pequeña (hasta 8 archivos, 6 entradas de interfaz y 2 decisiones, sin riesgo) va por la ruta rápida, sin plan mode. Al aprobar, un subagente implementer (modelo de la sesión) implementa con contexto recortado.SubagentStop y Stop no le creen: comprueban el contrato de interfaz, corren verify y, solo si el manifest lo activa (review.always o review.minLevel: "low"|"high"; por defecto está apagado, D-015), piden una revisión del modelo (reviewer); el nivel de riesgo se registra siempre en el resumen. Un fallo reiterado escala al fixer con el modelo fuerte. Los guards de PreToolUse piden confirmación si el agente sale del plan, añade dependencias o crea migraciones./done cierra la tarea, guarda su ficha en .claude/workflow/tasks.jsonl (esa ficha va en el commit) y cada 5 tareas propone una retro./pr corre verify, rellena la plantilla de PR del repo con el informe, el riesgo y las ADRs nuevas, y solo hace git push y gh pr create cuando lo confirmas.Otros comandos: /verify (corre a mano los comandos de verify), /wf-doctor (comprueba requisitos del plugin; no sustituye al /doctor nativo), /wf (panel de estado del mod opcional).
El detalle de las 10 etapas del flujo y de qué cubre cada una está en docs/WORKFLOW.md.
/setup diseña contigo pequeñas herramientas de verificación dentro del repo (por defecto tools/verify/): cada una hace una sola cosa, tiene --help y devuelve código de salida más una salida corta. Declaran su runtime: python (Python 3 solo con biblioteca estándar) o nix (script más shell.nix o flake.nix que fija sus dependencias). El plugin no trae ninguna propia. Se registran en .claude/workflow.json (tools) y se documentan en la sección Tools de .claude/skills/verify-app/SKILL.md, para que el implementer, el fixer y el planificador las usen.
| Fase | Quién | Modelo |
|---|---|---|
/setup, /task, /done, /pr | sesión principal | el de la sesión (usa el más fuerte para /setup) |
| Implementación | agent implementer | el de la sesión (inherit) |
| Chequeo del plan | plan-check | strong_model (opus por defecto; plan-check-sonnet si es sonnet) |
| Revisión con riesgo | reviewer | strong_model |
| Escalado tras fallos | fixer | strong_model |
El modelo del frontmatter de un agent no admite ${user_config.*} (spike 0.4), por eso hay una variante -sonnet de cada agent (D-005).
claude -p y un host de permisos (test/e2e/run.mjs), no a mano en la interfaz interactiva. El diálogo /plugin tampoco se probó.-p no expone ExitPlanMode sin --permission-prompt-tool stdio (spike 0.1): la eval usa un host que aprueba y responde a las preguntas con la primera opción.--plugin-dir o un marketplace de carpeta local no se instalan node_modules (no hace falta: no hay dependencias de runtime). Con el marketplace de GitHub sí se instalan las devDependencies./wf, línea de estado) es opcional y early access: la API cambia entre versiones. Si falla, el flujo no se entera (mod/README.md).Read, Grep, Glob) y recibe en el brief lo que verify y las herramientas ya reportaron.docs/RELEASE-0.1.0.md.Eval v1 (sin setup previo; el conjunto de tests ocultos y las tareas no son los de la eval v2 de la sección «Modos de uso», así que los totales no son comparables entre ambas tablas). Detalle y matices en docs/EVAL.md (issue #28, 5 tareas de un repo real, una ejecución por celda). Resumen: Claude Code con cc-improved (D1) frente a Claude Code sin plugin (D0) y frente a pi-improved (C+).
| Tests ocultos (ciega) | Tests ocultos (con pista de interfaz) | Coste (ciega / con pista) | |
|---|---|---|---|
| D0, sin plugin | 160/204 | 162/232 | $0.15 / $0.11 |
| C+, pi-improved | 151/204 | 182/232 | $2.57 / $1.74 |
| D1, cc-improved | 144/204 | 193/232 | $1.35 / $1.06 |
Lo que hay que leer con cuidado:
npm ci && npm test && npm run typecheck && claude plugin validate .
claude --plugin-dir . # carga el plugin en esta sesión
npm run e2e # cuesta dinero (claude -p): fuera de CI
Evals de skills (cuestan unos centavos, fuera de CI): evals/README.md. Costes del uso real, no de las evals: docs/costes/README.md. Convenciones de trabajo: docs/CONVENTIONS.md; decisiones: docs/DECISIONS.md. Los hooks están en hooks/README.md y el mod en mod/README.md.
El plan completo, con las equivalencias nativas y las fases, está en el repo pi-improved (docs/PLAN-claude-code.md).
MIT. Las skills de método de skills/ son vendorizadas (origen y licencias en skills/README.md y skills/LICENSE-*).
mod/register.tsx 165 lines1// Optional mod (function hooks, early access API): draws the workflow's state, never decides anything.
2// The gates are the classic hooks in hooks/hooks.json; nothing there reads what this module does, so the plugin
3// behaves the same when the mod is absent, refused or failing. Every hook here calls `next(e)` first and then
4// draws, swallowing its own errors: the worst case is a missing or stale status line.
5import type { EngineInterface, Register } from "claude-code";
6
7import {
8 MOD_PANE,
9 MOD_PLUGIN,
10 MOD_TASK_PATH,
11 addTokens,
12 paneRows,
13 paneText,
14 parseSession,
15 parseTask,
16 statusLine,
17 toTokens,
18 zeroTokens,
19 type TurnTokens,
20 type WfSnapshot,
21} from "./status.ts";
22
23const SESSION_ID = /^[A-Za-z0-9_-]+$/;
24
25// Module state starts over on every reload; it only feeds the drawing.
26let lastTurn: TurnTokens | undefined;
27let totals: TurnTokens | undefined;
28/** Session state file found for this session, so the plugin data folder is searched once. */
29let sessionFile: string | undefined;
30
31async function readText($: EngineInterface, path: string): Promise<string | undefined> {
32 try {
33 const text = await $.fs.read(path);
34 return typeof text === "string" ? text : undefined;
35 } catch {
36 return undefined;
37 }
38}
39
40/**
41 * `${CLAUDE_PLUGIN_DATA}/sessions/<id>.json`, written by the classic hooks. The engine gives a mod no data folder, so
42 * it is looked up: `CLAUDE_PLUGIN_DATA` if this process has it, else the `cc-improved*` folders under
43 * `<config>/plugins/data/` that hold a file for this session id (unique, so another install cannot be picked).
44 */
45async function findSessionFile($: EngineInterface, sessionId: string): Promise<string | undefined> {
46 if (!SESSION_ID.test(sessionId)) return undefined;
47 if (sessionFile?.endsWith(`/${sessionId}.json`)) return sessionFile;
48 const candidates: string[] = [];
49 const data = await $.env.get("CLAUDE_PLUGIN_DATA");
50 if (data) candidates.push(data);
51 const config = (await $.env.get("CLAUDE_CONFIG_DIR")) || `${(await $.env.get("HOME")) ?? ""}/.claude`;
52 const root = `${config}/plugins/data`;
53 try {
54 for (const entry of await $.fs.list(root)) {
55 if (entry.kind === "dir" && entry.name.startsWith(MOD_PLUGIN)) candidates.push(`${root}/${entry.name}`);
56 }
57 } catch {
58 // No plugin data yet: the hooks have not run in this configuration.
59 }
60 for (const dir of candidates) {
61 const file = `${dir}/sessions/${sessionId}.json`;
62 if (await $.fs.exists(file).catch(() => false)) return (sessionFile = file);
63 }
64 return undefined;
65}
66
67/** Reads everything one drawing shows. Never rejects: what cannot be read is left out. */
68async function snapshot($: EngineInterface): Promise<WfSnapshot> {
69 const snap: WfSnapshot = { lastTurn, totals };
70 try {
71 const root = await $.session.root();
72 const taskText = await readText($, `${root}/${MOD_TASK_PATH}`);
73 snap.task = taskText === undefined ? undefined : parseTask(taskText);
74 } catch {
75 // Left out.
76 }
77 try {
78 snap.sessionId = await $.session.id();
79 const file = await findSessionFile($, snap.sessionId);
80 const text = file === undefined ? undefined : await readText($, file);
81 snap.session = text === undefined ? undefined : parseSession(text);
82 } catch {
83 // Left out.
84 }
85 try {
86 snap.costUsd = (await $.session.usage()).cost?.usd;
87 } catch {
88 // Left out.
89 }
90 return snap;
91}
92
93/** Redraws the status line and an open pane. Never rejects. */
94async function refresh($: EngineInterface): Promise<void> {
95 try {
96 $.ui.status(statusLine(await snapshot($)));
97 $.ui.invalidate("ui.render");
98 } catch {
99 // A display that fails stays as it was.
100 }
101}
102
103export const register: Register = (on) => {
104 on("session.start", async ($, e, next) => {
105 const started = await next(e);
106 try {
107 await $.command.register({ name: MOD_PANE, description: "Estado de la tarea del flujo (fase, plan, verify, revisión, coste)" });
108 } catch {
109 // Without the command the status line still works.
110 }
111 await refresh($);
112 return started;
113 });
114
115 on("command.run", { command: "wf" }, async ($) => {
116 try {
117 const snap = await snapshot($);
118 $.ui.status(statusLine(snap));
119 const opened = await $.ui.open({ id: MOD_PANE, title: "cc-improved" }).catch(() => undefined);
120 // Where no pane is drawn (`claude -p`, a narrow terminal) the text answer carries the same rows.
121 return { text: opened?.isPlaced ? "Panel wf abierto." : paneText(snap) };
122 } catch {
123 return { text: "wf: no se pudo leer el estado." };
124 }
125 }).catch(() => ({ text: "wf: no se pudo leer el estado." }));
126
127 on("turn.complete", async ($, e, next) => {
128 const done = await next(e);
129 try {
130 const usage = e.usage ?? done.usage;
131 if (usage) {
132 const tokens = toTokens(usage);
133 totals = addTokens(totals ?? zeroTokens(), tokens);
134 if (e.agentId === undefined) lastTurn = tokens;
135 }
136 } catch {
137 // Counting is best effort.
138 }
139 // Phase changes come from the classic hooks writing task.json during the turn. refresh() never rejects and
140 // costs a few file reads; awaiting it keeps headless runs from exiting before the redraw.
141 await refresh($);
142 return done;
143 });
144
145 on("ui.render", { component: "Pane", requestId: "wf" }, async ($, e) => {
146 const { Box, Text } = $.ui.resolve(e);
147 let rows: Array<[string, string]>;
148 try {
149 rows = paneRows(await snapshot($));
150 } catch {
151 rows = [["wf", "no se pudo leer el estado"]];
152 }
153 return (
154 <Box flexDirection="column">
155 {rows.map(([label, value]) => (
156 <Text>
157 <Text dimColor>{label}: </Text>
158 {value}
159 </Text>
160 ))}
161 </Box>
162 );
163 });
164};
165mod/status.ts 193 lines1// What the optional mod shows, as pure functions over the workflow's files. No engine and no Node imports here: the
2// mod's environment has neither, and `npm test` / `npm run typecheck` cover this file through test/mod-status.test.ts.
3// The gates live in the classic hooks (hooks/hooks.json → lib/hooks/); this file only describes their state.
4import type { SessionState, Task } from "../lib/types.ts";
5
6/**
7 * Same value as `TASK_PATH` in lib/paths.ts, which cannot be imported here (it loads `node:path`, and the mod has no
8 * Node). test/mod-status.test.ts fails if the two drift apart.
9 */
10export const MOD_TASK_PATH = ".claude/workflow/task.json";
11/** Plugin name; its data folder under `plugins/data/` starts with it (`cc-improved-<marketplace>`). */
12export const MOD_PLUGIN = "cc-improved";
13/** Id of the pane and name of the slash command. */
14export const MOD_PANE = "wf";
15
16/** Token counts of one turn, as `turn.complete` reports them (`ModelUsage`). */
17export interface TurnTokens {
18 input_tokens: number;
19 output_tokens: number;
20 cache_read_input_tokens: number;
21 cache_creation_input_tokens: number;
22}
23
24/** Everything one drawing needs, read fresh from disk and from the engine. */
25export interface WfSnapshot {
26 /** task.json, `undefined` when there is none or it does not parse. */
27 task?: Task | undefined;
28 /** This session's gate counters, `undefined` when the hooks have not written them (or they could not be found). */
29 session?: SessionState | undefined;
30 /** This session's id, to tell a task owned by another session. */
31 sessionId?: string | undefined;
32 /** Tokens of the last main-loop turn. */
33 lastTurn?: TurnTokens | undefined;
34 /** Tokens summed over the turns this load of the mod saw (main loop and subagents). */
35 totals?: TurnTokens | undefined;
36 /** What the session has cost so far, as /cost totals it. */
37 costUsd?: number | undefined;
38}
39
40export const zeroTokens = (): TurnTokens => ({ input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 });
41
42const count = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
43
44/** Reads a usage object defensively: any field missing or not a number counts as 0. */
45export function toTokens(usage: unknown): TurnTokens {
46 const u = (usage && typeof usage === "object" ? usage : {}) as Record<string, unknown>;
47 return {
48 input_tokens: count(u.input_tokens),
49 output_tokens: count(u.output_tokens),
50 cache_read_input_tokens: count(u.cache_read_input_tokens),
51 cache_creation_input_tokens: count(u.cache_creation_input_tokens),
52 };
53}
54
55export function addTokens(a: TurnTokens, b: TurnTokens): TurnTokens {
56 return {
57 input_tokens: a.input_tokens + b.input_tokens,
58 output_tokens: a.output_tokens + b.output_tokens,
59 cache_read_input_tokens: a.cache_read_input_tokens + b.cache_read_input_tokens,
60 cache_creation_input_tokens: a.cache_creation_input_tokens + b.cache_creation_input_tokens,
61 };
62}
63
64/** Input side of a turn: fresh, cache read and cache write together. */
65export const inputSide = (t: TurnTokens) => t.input_tokens + t.cache_read_input_tokens + t.cache_creation_input_tokens;
66
67/** 950 → "950", 12_345 → "12.3k", 2_500_000 → "2.5M". */
68export function formatCount(n: number): string {
69 if (n < 1000) return String(Math.round(n));
70 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
71 return `${(n / 1_000_000).toFixed(1)}M`;
72}
73
74export const formatTokens = (t: TurnTokens) => `${formatCount(inputSide(t))} in / ${formatCount(t.output_tokens)} out`;
75
76export const formatUsd = (usd: number) => `$${usd.toFixed(usd < 1 ? 3 : 2)}`;
77
78/** Parses task.json text; `undefined` for anything that is not a task. */
79export function parseTask(text: string): Task | undefined {
80 try {
81 const v: unknown = JSON.parse(text);
82 if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
83 const t = v as Partial<Task>;
84 if (typeof t.id !== "string" || typeof t.status !== "string") return undefined;
85 return { ...(t as Task), title: typeof t.title === "string" ? t.title : t.id };
86 } catch {
87 return undefined;
88 }
89}
90
91/** Parses a session state file; `undefined` for anything that is not one. */
92export function parseSession(text: string): SessionState | undefined {
93 try {
94 const v: unknown = JSON.parse(text);
95 if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
96 const s = v as Partial<SessionState>;
97 if (typeof s.sessionId !== "string") return undefined;
98 return {
99 ...(s as SessionState),
100 retries: count(s.retries),
101 reviews: count(s.reviews),
102 failures: count(s.failures),
103 planChecks: count(s.planChecks),
104 updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : "",
105 };
106 } catch {
107 return undefined;
108 }
109}
110
111/** True when task.json belongs to another session open on the project (the hooks ignore it here too). */
112export const isForeign = (snap: WfSnapshot) => !!snap.task?.sessionId && !!snap.sessionId && snap.task.sessionId !== snap.sessionId;
113
114/** Session counters that belong to the current task (the gates reset them on another task). */
115function ownSession(snap: WfSnapshot): SessionState | undefined {
116 const s = snap.session;
117 if (!s || !snap.task) return undefined;
118 if (s.taskId !== undefined && s.taskId !== snap.task.id) return undefined;
119 return s;
120}
121
122/** The phase in one word, in Spanish, as the person reads it. */
123export function phaseLabel(snap: WfSnapshot): string {
124 const t = snap.task;
125 if (!t) return "sin tarea";
126 if (isForeign(snap)) return "tarea de otra sesión";
127 switch (t.status) {
128 case "planning":
129 return "plan";
130 case "implementing":
131 return "implementación";
132 case "done":
133 return "hecha";
134 default:
135 return String(t.status);
136 }
137}
138
139/** Plan line: title and how many files it may touch. */
140export function planLabel(snap: WfSnapshot): string {
141 const t = snap.task;
142 if (!t || isForeign(snap)) return "—";
143 if (!t.plan) return t.status === "planning" ? "en redacción" : "sin plan";
144 const files = t.plan.files.length + (t.allowedExtra?.length ?? 0);
145 const verdict = ownSession(snap)?.planVerdict;
146 return `${t.plan.title} (${files} archivo${files === 1 ? "" : "s"}${verdict ? `, chequeo ${verdict}` : ""})`;
147}
148
149/** Verify line, from the Stop/SubagentStop gate counters. */
150export function verifyLabel(snap: WfSnapshot): string {
151 if (!snap.task || isForeign(snap)) return "—";
152 const s = ownSession(snap);
153 if (!s) return "sin datos";
154 if (s.failures > 0) return `falla (${s.failures} seguida${s.failures === 1 ? "" : "s"})`;
155 if (s.verifiedFingerprint) return "pasa";
156 return "sin ejecutar";
157}
158
159/** Review line: the strong-model diff review, at most one round per task. */
160export function reviewLabel(snap: WfSnapshot): string {
161 if (!snap.task || isForeign(snap)) return "—";
162 const s = ownSession(snap);
163 if (!s) return "sin datos";
164 if (s.reviewVerdict) return s.reviewVerdict;
165 if (s.reviews > 0) return "omitida o sin veredicto";
166 return "pendiente";
167}
168
169/** The status line under the prompt: `wf: implementación · turno 12.3k in / 800 out`. */
170export function statusLine(snap: WfSnapshot): string {
171 const parts = [`wf: ${phaseLabel(snap)}`];
172 if (snap.lastTurn) parts.push(`turno ${formatTokens(snap.lastTurn)}`);
173 return parts.join(" · ");
174}
175
176/** The pane's rows as label/value pairs, in order. */
177export function paneRows(snap: WfSnapshot): Array<[label: string, value: string]> {
178 const rows: Array<[string, string]> = [
179 ["fase", phaseLabel(snap)],
180 ["tarea", snap.task && !isForeign(snap) ? snap.task.title : "—"],
181 ["plan", planLabel(snap)],
182 ["verify", verifyLabel(snap)],
183 ["revisión", reviewLabel(snap)],
184 ["último turno", snap.lastTurn ? formatTokens(snap.lastTurn) : "—"],
185 ["tokens (sesión)", snap.totals ? formatTokens(snap.totals) : "—"],
186 ["coste acumulado", snap.costUsd === undefined ? "—" : formatUsd(snap.costUsd)],
187 ];
188 return rows;
189}
190
191/** The same rows as plain text, for the `/wf` command's answer. */
192export const paneText = (snap: WfSnapshot) => paneRows(snap).map(([k, v]) => `${k}: ${v}`).join("\n");
193lib/types.ts 461 lines1// Shared contracts. Every lib module, hook and script imports its types from here and never redefines them.
2// Field names follow the files on disk and Claude Code's hook JSON exactly (snake_case where the harness uses it).
3
4// ---------------------------------------------------------------------------
5// Project manifest: `.claude/workflow.json` (committed)
6// ---------------------------------------------------------------------------
7
8/** Where `/setup` stands for this project. `declined`: the user opted out; hooks stay quiet. */
9export type SetupStatus = "pending" | "running" | "done" | "declined";
10
11/** How much review a diff needs: none (skip), low (cheap model), high (strong model). */
12export type RiskLevel = "none" | "low" | "high";
13
14/** When a model reviews the diff or the plan. Default (no `review`): never; the deterministic gates still run (D-015). */
15export interface ReviewPolicy {
16 /** Repo-relative paths or prefixes (ending in `/`) whose changes always count as risky. */
17 paths?: string[];
18 /** Review every task, whatever the risk. Wins over `never`. */
19 always?: boolean;
20 /** Never pay for a diff review. */
21 never?: boolean;
22 /** Model check of the plan before the user approves it. Default: off. `true` turns it on (risky plans get the strong model, big ones the cheap one); `review.always` or a `minLevel` of `low`/`high` check risky plans; `false` never. */
23 plan?: boolean;
24 /** Lowest risk level that requests a model review: `off` (default, none), `high` or `low`. The level is recorded either way. */
25 minLevel?: "off" | "low" | "high";
26 /** Diff size above which a (cheap-model) review is requested. Defaults: 200 lines, 6 files. */
27 maxLines?: number;
28 maxFiles?: number;
29 /** Diff size above which the strong-model review is requested. Defaults: 600 lines, 15 files. */
30 highLines?: number;
31 highFiles?: number;
32 /** A plan with more files / interface entries / decisions than this is not small (no fast route) and, with `plan: true`, gets the plan check. Defaults: 8, 6, 2. Risky paths always do. */
33 planFiles?: number;
34 planInterface?: number;
35 planDecisions?: number;
36}
37
38/** A read-only ticket or docs source enabled by `/setup`. Its MCP server lives in the project's `.mcp.json`. */
39export interface Connector {
40 /** Catalog id, e.g. "github", "jira", "linear". */
41 id: string;
42 /** Server key in `.mcp.json`; its tools arrive as `mcp__<server>__<tool>`. Absent when the user configures it by hand. */
43 server?: string;
44}
45
46/** Runtime a project verification tool declares: a python 3 stdlib script, or a script whose nix env pins its dependencies. */
47export type ToolRuntime = "python" | "nix";
48
49/**
50 * A verification tool `/setup` wrote inside the project. The plugin ships none: each one is generated for the repo.
51 * `wf-setup-finish` checks it exists, is executable and answers `--help`; `/wf-doctor` checks its runtime is installed.
52 */
53export interface VerifyTool {
54 /** Repo-relative executable script. */
55 path: string;
56 runtime: ToolRuntime;
57 /** nix only: repo-relative `shell.nix` or `flake.nix` next to the script that pins its dependencies. */
58 env?: string;
59}
60
61/** How the project works by default: `direct` (cheap model with what /setup left; gates only with an open /task) or `flow` (/task recommended). */
62export type WorkflowMode = "direct" | "flow";
63
64/** Project state committed to the repo at `.claude/workflow.json`. */
65export interface Manifest {
66 version: 1;
67 setup: SetupStatus;
68 /** Default way of working; optional and additive, absent means `direct`. Gates only ever run with an open /task. */
69 mode?: WorkflowMode;
70 /** Main language as `/setup` recorded it; informative only, nothing branches on it. */
71 language?: string;
72 /** ISO 8601 timestamps of the setup run. */
73 startedAt?: string;
74 completedAt?: string;
75 /** Shell commands run in order from the project root; all must exit 0. The only source of verification commands. */
76 verify: string[];
77 /** Verification tools generated for this project and referenced by `.claude/skills/verify-app/SKILL.md`. */
78 tools?: VerifyTool[];
79 review?: ReviewPolicy;
80 connectors: Connector[];
81 /** agentlint command prefix when the project enforces agentlint rules, e.g. "pnpm agentlint". */
82 agentlint?: string;
83}
84
85// ---------------------------------------------------------------------------
86// Plan: the ```wf-plan block inside the plan-mode file (schema: lib/plan.schema.json)
87// ---------------------------------------------------------------------------
88
89/** Names the implementation must expose in one file, pinned before any code is written. */
90export interface InterfaceEntry {
91 /** Repo-relative file path. */
92 file: string;
93 /** Exported names; each must appear literally (as a word) in `file` once implemented. */
94 exports: string[];
95 /** Optional signatures, in the project's own language, as plain text. */
96 signatures?: string[];
97 /** Where the names come from: ticket key, spec path or existing file. Names are checked against this evidence. */
98 source?: string;
99}
100
101/** Where a test attaches and what it does and does not cover. */
102export interface Seam {
103 name: string;
104 /** Repo-relative test file. */
105 testFile: string;
106 /** What this test catches. */
107 catches: string;
108 /** What it leaves uncovered. */
109 misses?: string;
110}
111
112/** A design decision the plan depends on. Without a `source` the plan is denied: ask the user and write an ADR. */
113export interface Decision {
114 question: string;
115 /** Options considered; may be empty when only one was viable. */
116 options: string[];
117 /** The chosen option or the user's answer. Absent when `source` already settles it. */
118 answer?: string;
119 /** Where it is settled: an ADR path (`docs/adr/...`), a spec, the ticket, or an existing file. */
120 source: string;
121}
122
123/** Structured part of a plan, parsed from the ```wf-plan fenced block. */
124export interface Plan {
125 title: string;
126 /** Ticket key such as "PAY-142"; absent when the task has no ticket. */
127 ticket?: string;
128 /** Repo-relative spec files whose text counts as evidence for interface names. */
129 spec: string[];
130 /** Repo-relative files the task may create or change. An entry ending in `/` covers a directory. */
131 files: string[];
132 interface: InterfaceEntry[];
133 seams: Seam[];
134 decisions: Decision[];
135}
136
137// ---------------------------------------------------------------------------
138// Active task: `.claude/workflow/task.json` (local, gitignored)
139// ---------------------------------------------------------------------------
140
141/** planning: plan accepted by the hook, waiting for approval; implementing: approved; done: closed by `/done`. */
142export type TaskStatus = "planning" | "implementing" | "done";
143
144/** The active task of this working copy. */
145export interface Task {
146 id: string;
147 title: string;
148 ticket?: string;
149 status: TaskStatus;
150 plan?: Plan;
151 /** Absolute path of the plan-mode file the plan was parsed from. */
152 planFilePath?: string;
153 /** Repo-relative files outside the plan the user allowed during implementation. */
154 allowedExtra: string[];
155 /** ISO 8601 timestamps. */
156 startedAt: string;
157 completedAt?: string;
158 /**
159 * `session_id` of the session that last wrote `task.json`. Hooks of any other session skip their handlers, since
160 * hooks fire in every session open on the project. Stamped by the dispatcher's `saveTask`; absent means "any session".
161 */
162 sessionId?: string;
163 /** HEAD when the task entered planning; diffs are measured against it. Absent (no git, older task): against HEAD. */
164 baseCommit?: string;
165}
166
167// ---------------------------------------------------------------------------
168// Session state: `${CLAUDE_PLUGIN_DATA}/sessions/<session_id>.json`
169// ---------------------------------------------------------------------------
170
171/**
172 * Per-session gate counters. Hooks fire in every session open on the project, so each one reads and writes only
173 * the file of its own `session_id`. A missing file means all counters are zero.
174 */
175export interface SessionState {
176 sessionId: string;
177 /** Gate continuations (`decision: "block"`) spent since the last user prompt; bounded to avoid loops. */
178 retries: number;
179 /** Diff reviews spent (or skipped as low risk) on the current task; one round only. */
180 reviews: number;
181 /** Consecutive verify failures since the last pass: the 2nd escalates to the fixer (D-009). */
182 failures: number;
183 /** Strong-model plan checks spent on the current task. */
184 planChecks: number;
185 /** ISO 8601 time of the last write. */
186 updatedAt: string;
187 // Optional, written by the Stop/SubagentStop gates (lib/hooks/gate.ts). Absent means "not yet".
188 /** `prompt_id` the per-prompt counters (`retries`, `failures`, `setupNudges`, `lastBlock`) belong to; a new prompt resets them. */
189 promptId?: string | undefined;
190 /** Pushes back to work while `setup` is `running`, since the last user prompt (at most 2). */
191 setupNudges?: number | undefined;
192 /** Task id the per-task fields belong to; another task resets them. */
193 taskId?: string | undefined;
194 /** Gate counters per subagent, keyed by `agent_id`: `stop_hook_active` cannot count retries (spike 0.3). */
195 agents?: Record<string, AgentGateState> | undefined;
196 /** Last `VERDICT:` of plan-check on the current task. */
197 planVerdict?: Verdict | undefined;
198 /** Last `VERDICT:` of the diff reviewer on the current task. */
199 reviewVerdict?: Verdict | undefined;
200 /** What the gate last decided about the diff review on the current task (level, reasons, whether it was requested). */
201 review?: ReviewRecord | undefined;
202 /** What the orchestrator's last Stop block asked for, so the same request is not repeated in a loop. */
203 lastBlock?: string | undefined;
204 /** Fingerprint of the working copy when verify last passed in the orchestrator's Stop; unchanged means no rerun. */
205 verifiedFingerprint?: string | undefined;
206}
207
208/** The gate's last review decision, recorded in the session state. */
209export interface ReviewRecord {
210 level: RiskLevel;
211 reasons: string[];
212 requested: boolean;
213}
214
215/** Verdict line a strong-model checker ends with: `VERDICT: OK` or `VERDICT: FIX`. */
216export type Verdict = "OK" | "FIX";
217
218/** Gate state of one implementer or fixer run (one `agent_id`). */
219export interface AgentGateState {
220 /** Role without plugin prefix or model suffix: "implementer", "fixer", "reviewer", "plan-check". */
221 role: string;
222 /** Blocks spent on a missing interface contract. */
223 contractBlocks: number;
224 /** Consecutive verify failures: 1 fix (with the diagnosis method), 2 released with FAILED (D-009). */
225 failures: number;
226 /** Blocks spent on agentlint (policy) failures; the second one goes to the person, not to a smarter model. */
227 policyBlocks: number;
228 /** Set when the gate last let the agent out: passed, or failed (escalate). */
229 outcome?: "passed" | "failed" | undefined;
230}
231
232// ---------------------------------------------------------------------------
233// Hook input: the JSON Claude Code writes to a command hook's stdin (fixtures: test/fixtures/hooks/)
234// ---------------------------------------------------------------------------
235
236/** Known permission modes; other strings may appear in newer Claude Code versions. */
237export type PermissionMode = "default" | "plan" | "acceptEdits" | "bypassPermissions" | "dontAsk" | "auto" | (string & {});
238
239/** Fields present in every hook event. */
240export interface HookInputBase {
241 session_id: string;
242 /** Main session transcript (JSONL), also inside a subagent. */
243 transcript_path: string;
244 cwd: string;
245}
246
247/** Present only when the event fires inside a subagent: hooks tell the orchestrator from a child by these. */
248export interface SubagentContext {
249 agent_id?: string;
250 /** Agent name, e.g. "implementer"; plugin agents may carry the plugin prefix. */
251 agent_type?: string;
252}
253
254/** Fields of events that belong to a turn. */
255export interface TurnFields {
256 /** Id of the user prompt that started the turn. */
257 prompt_id: string;
258 permission_mode: PermissionMode;
259}
260
261export interface Effort {
262 level: string;
263}
264
265/** A background task listed at Stop time (e.g. a subagent launched asynchronously). */
266export interface BackgroundTask {
267 id: string;
268 type: "subagent" | (string & {});
269 status: string;
270 description: string;
271 agent_type?: string;
272}
273
274export interface SessionStartInput extends HookInputBase {
275 hook_event_name: "SessionStart";
276 source: "startup" | "resume" | "clear" | "compact" | (string & {});
277}
278
279export interface UserPromptSubmitInput extends HookInputBase, TurnFields {
280 hook_event_name: "UserPromptSubmit";
281 prompt: string;
282}
283
284export interface PreToolUseInput extends HookInputBase, TurnFields, SubagentContext {
285 hook_event_name: "PreToolUse";
286 effort?: Effort;
287 tool_name: string;
288 /** Narrow with the `*ToolInput` types below after checking `tool_name`. */
289 tool_input: Record<string, unknown>;
290 tool_use_id: string;
291}
292
293export interface PostToolUseInput extends HookInputBase, TurnFields, SubagentContext {
294 hook_event_name: "PostToolUse";
295 effort?: Effort;
296 tool_name: string;
297 tool_input: Record<string, unknown>;
298 /** Tool-specific result; its shape varies by tool and version. */
299 tool_response: unknown;
300 tool_use_id: string;
301 duration_ms?: number;
302}
303
304export interface SubagentStartInput extends HookInputBase {
305 hook_event_name: "SubagentStart";
306 prompt_id?: string;
307 agent_id: string;
308 agent_type: string;
309}
310
311/** Fields shared by Stop and SubagentStop. */
312export interface StopFields extends TurnFields {
313 effort?: Effort;
314 /** True when this stop follows a `decision: "block"` from a Stop hook; use it to avoid loops. */
315 stop_hook_active: boolean;
316 last_assistant_message: string;
317 background_tasks?: BackgroundTask[];
318 session_crons?: unknown[];
319}
320
321export interface StopInput extends HookInputBase, StopFields {
322 hook_event_name: "Stop";
323}
324
325export interface SubagentStopInput extends HookInputBase, StopFields {
326 hook_event_name: "SubagentStop";
327 agent_id: string;
328 agent_type: string;
329 /** The subagent's own transcript (JSONL). */
330 agent_transcript_path: string;
331}
332
333export interface SessionEndInput extends HookInputBase {
334 hook_event_name: "SessionEnd";
335 prompt_id?: string;
336 reason: "clear" | "logout" | "prompt_input_exit" | "other" | (string & {});
337}
338
339/** Every hook event the plugin handles, discriminated by `hook_event_name`. */
340export type HookInput =
341 | SessionStartInput
342 | UserPromptSubmitInput
343 | PreToolUseInput
344 | PostToolUseInput
345 | SubagentStartInput
346 | SubagentStopInput
347 | StopInput
348 | SessionEndInput;
349
350export type HookEventName = HookInput["hook_event_name"];
351
352/** The input type of one event: `HookInputOf<"Stop">` is `StopInput`. */
353export type HookInputOf<E extends HookEventName> = Extract<HookInput, { hook_event_name: E }>;
354
355// Tool inputs the hooks inspect (from `tool_input`).
356
357export interface BashToolInput {
358 command: string;
359 description?: string;
360 timeout?: number;
361 run_in_background?: boolean;
362}
363
364export interface ReadToolInput {
365 file_path: string;
366 offset?: number;
367 limit?: number;
368}
369
370export interface WriteToolInput {
371 file_path: string;
372 content: string;
373}
374
375export interface EditToolInput {
376 file_path: string;
377 old_string: string;
378 new_string: string;
379 replace_all?: boolean;
380}
381
382export interface AgentToolInput {
383 description: string;
384 prompt: string;
385 subagent_type?: string;
386 model?: string;
387 run_in_background?: boolean;
388}
389
390/** ExitPlanMode `tool_input` in PreToolUse and PermissionRequest: the plan Markdown and its file (seen in spike 0.1). In PostToolUse it is `{}`; see `ExitPlanModeToolResponse`. */
391export interface ExitPlanModeToolInput {
392 plan?: string;
393 planFilePath?: string;
394}
395
396/** ExitPlanMode `tool_response` in PostToolUse once the plan is approved (seen in spike 0.1). */
397export interface ExitPlanModeToolResponse {
398 plan?: string;
399 filePath?: string;
400 isAgent?: boolean;
401}
402
403// ---------------------------------------------------------------------------
404// Hook output: the JSON a command hook prints to stdout (empty output + exit 0 = no opinion)
405// ---------------------------------------------------------------------------
406
407/** Fields every event accepts. */
408export interface HookOutputBase {
409 /** false stops Claude entirely; `stopReason` is shown to the user. */
410 continue?: boolean;
411 stopReason?: string;
412 suppressOutput?: boolean;
413 /** Message shown to the user, not to the model. */
414 systemMessage?: string;
415}
416
417export type PermissionDecision = "allow" | "deny" | "ask";
418
419export interface PreToolUseOutput extends HookOutputBase {
420 hookSpecificOutput: {
421 hookEventName: "PreToolUse";
422 permissionDecision?: PermissionDecision;
423 /** Shown to the model on deny; to the user on allow/ask. Write it so the model knows what to do next. */
424 permissionDecisionReason?: string;
425 /** Replaces the tool input before it runs. */
426 updatedInput?: Record<string, unknown>;
427 additionalContext?: string;
428 };
429}
430
431export interface PostToolUseOutput extends HookOutputBase {
432 decision?: "block";
433 reason?: string;
434 hookSpecificOutput?: { hookEventName: "PostToolUse"; additionalContext?: string };
435}
436
437/** Stop and SubagentStop: `block` keeps the agent working and `reason` is what the model reads. */
438export interface StopOutput extends HookOutputBase {
439 decision?: "block";
440 reason?: string;
441}
442
443/** Events that can only add context for the model. */
444export interface ContextOutput extends HookOutputBase {
445 hookSpecificOutput?: { hookEventName: "SessionStart" | "UserPromptSubmit" | "SubagentStart"; additionalContext?: string };
446}
447
448/** What each event's handler may print. */
449export interface HookOutputMap {
450 SessionStart: ContextOutput;
451 UserPromptSubmit: ContextOutput;
452 PreToolUse: PreToolUseOutput;
453 PostToolUse: PostToolUseOutput;
454 SubagentStart: ContextOutput;
455 SubagentStop: StopOutput;
456 Stop: StopOutput;
457 SessionEnd: HookOutputBase;
458}
459
460export type HookOutput = HookOutputMap[HookEventName];
461