SLOPSHOPPER

cc-improved

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…

newpanecommandstatus
v0.1.0MITupdated 2026-10-24OnishellT/cc-improved
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-improved
│ ┃ cc-improved ✕ › fix the failing auth test and add an audit log call │ ┃ fase: sin tarea │ ┃ tarea: — ⏺ Read(src/auth.ts) │ ┃ plan: — ⎿ Read 6 lines │ ┃ verify: — ⏺ Update(src/auth.ts) │ ┃ revisión: — ⎿ Added 2 lines, removed 1 line │ ┃ último turno: 97.4k in / 1.5k out ⏺ Bash(bun test) │ ┃ tokens (sesión): 97.4k in / 1.5k out ⎿ 3 pass, 1 fail │ ┃ coste acumulado: $0.420 │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wf │ ⎿ cc-improved: Panel wf abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cc-improved: wf: sin tarea · turno 97.4k in / 1.5k out

Draws

Pane · cc-improved
fase: sin tarea tarea: — plan: — verify: — revisión: — último turno: 97.4k in / 1.5k out tokens (sesión): 97.4k in / 1.5k out coste acumulado: $0.420
README

cc-improved

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.

Requisitos

  • Claude Code (probado con 2.1.309).
  • Node >= 22 (los hooks y scripts corren con node --experimental-strip-types).
  • git.
  • 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.

Instalación

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

Modos de uso

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

  1. Por defecto: /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.
  2. Opcional, por riesgo: /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).

Por qué ese orden: lo que mostró la eval

BrazoQué esTests ocultos (de 436)Coste de ejecución (10 celdas)Coste con el setup repartido
D0modelo barato, sin plugin ni setup322$0.26$0.26
D0smodelo barato, sin plugin, con /setup343$0.26$0.75
D1plugin completo, sin setup337$2.41$2.41
D1v2plugin completo, con /setup333$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.

Flujo completo (modo opcional /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.)

  1. /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).
  2. /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.
  3. Gates en código. Cuando el implementer o la sesión dan la tarea por terminada, los hooks 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.
  4. /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.
  5. /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.

Herramientas de verificación portables

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

Modelos por fase

FaseQuiénModelo
/setup, /task, /done, /prsesión principalel de la sesión (usa el más fuerte para /setup)
Implementaciónagent implementerel de la sesión (inherit)
Chequeo del planplan-checkstrong_model (opus por defecto; plan-check-sonnet si es sonnet)
Revisión con riesgoreviewerstrong_model
Escalado tras fallosfixerstrong_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).

Limitaciones conocidas

  • Probado en headless, TUI sin verificar. Todo el flujo se ejecutó con 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ó.
  • Plan mode en -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.
  • Dependencias. Con --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.
  • El mod (/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).
  • El reviewer no ejecuta herramientas. Solo lee (Read, Grep, Glob) y recibe en el brief lo que verify y las herramientas ya reportaron.
  • Solo Linux probado. No se probó macOS ni Windows.
  • Seguimiento pendiente: ver docs/RELEASE-0.1.0.md.

Resultados de la eval

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 plugin160/204162/232$0.15 / $0.11
C+, pi-improved151/204182/232$2.57 / $1.74
D1, cc-improved144/204193/232$1.35 / $1.06

Lo que hay que leer con cuidado:

  • Con pista de interfaz D1 gana; ciega, no. En la variante ciega, Claude Code sin plugin (D0) supera a D1 y a C+, y cuesta unas 9 veces menos que D1. Con pista, D1 supera a D0 en 31 tests, y casi toda esa diferencia sale de una tarea cuya ejecución terminó por un límite de sesión de la cuenta.
  • Una sola ejecución por celda: diferencias de unos pocos tests están dentro del ruido.
  • La mayoría de los tests ocultos de varias tareas ya pasan sin tocar nada (140 de 204 en la variante ciega); la ganancia sobre la base es más honesta: ciega D0 +20, C+ +11, D1 +4; con pista D1 +132, C+ +121, D0 +101.
  • El criterio acordado (D1 >= C+ en tests con coste <= 1.5x) se cumple con pista y en el agregado (por muy poco), y no se cumple en la ciega. Los fallos de D1 son sobre todo nombres que el ticket no da y robustez ante entradas inválidas.
  • Costes nominales (equivalentes a API; las cuentas son de suscripción).

Desarrollo

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

Licencia

MIT. Las skills de método de skills/ son vendorizadas (origen y licencias en skills/README.md y skills/LICENSE-*).

Source 3 files
mod/register.tsx 165 lines
1// 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};
165
mod/status.ts 193 lines
1// 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");
193
lib/types.ts 461 lines
1// 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