0021 — Contrato del CLI agente-native b2g: set de subcomandos, envelope JSON y exit codes¶
- Estado: Aceptada
- Fecha: 2026-06-15
- Decidido por: mixto — el set de 11 subcomandos (en particular incluir
accept/reject) y la separaciónbuild/exportson decisiones del Product Owner humano; el resto (forma del envelope JSON y su versionado, mapeo de errores a exit codes por tipo de excepción,--storeglobal, transiciones automáticas deLoopStatepor comando) son decisiones de la IA (Claude) validadas por el PO proxy. - Relacionada con: 0010 (CLI agente-native como columna
primaria — este ADR concreta su contrato), 0016 (
LoopStatey transiciones permisivas), 0019 (single-writer →StoreLockedError→ exit 5). - Toca: 0009 (el estado vive en el archivo
.duckdb, no en la sesión), 0020 (comandofiltery los filtros que marcanrejected). - Superficie consolidada (12→10 verbos) por 0037 y
0038: el cuerpo y sus enmiendas hablan de 11/12
subcomandos; la superficie 0.10.0 se consolida a 10 verbos agents-first (
monitor/inspect/networksabsorbidos;curate/readnoun-verb; aliases de retrocompat cierran en 0.11.0). El envelopeschema="1", los exit codes (§D) y la FSM (§F) quedan intactos. El conteo de subcomandos del cuerpo queda como historia.
Contexto¶
El ADR 0010 fijó el principio ("la CLI agente-native es
superficie primaria desde el primer comando", con doble salida, exit codes 0–5, errores
accionables y sin estado entre invocaciones) pero no el contrato concreto: qué subcomandos
existen, qué forma tiene exactamente la salida --json, ni cómo se mapea cada clase de error a un
exit code. El Hito 6 (CLI como producto) construye ese contrato y obliga a decidir cuatro cosas que
quedaban abiertas:
- ¿Cuál es el set exacto de subcomandos? El ADR 0010 y
API.md§convenciones listaban un set provisional (seed,chain,filter,build,export,snapshot,status,inspect,validate) y decían que elaccept/reject"programático sobrevive víaCorpus/backend". ¿Esa curación programática se expone como subcomando CLI o queda solo como API de librería? - ¿Una sola operación
build+exporto dos comandos separados? El cómputo de redes (Networks.quick) y su serialización a un formato concreto (GraphML/CSV) son pasos distintos con costos distintos. - ¿Qué forma tiene la salida
--json? El ADR 0010 pide "estructurado, estable y versionado" sin especificar la estructura. - ¿Cómo se mapea cada error a un exit code? El ADR 0010 fija los códigos 0–5 por significado, pero no qué excepción de Python produce cada uno.
Decisión¶
A. Set de subcomandos, incluyendo accept/reject (decisión del PO)¶
Cleanup pre-v0.3 (2026-06-16): el set creció a 12 subcomandos con el alta de
monitor(ver enmienda al final). El texto original (11) queda como historia.
El CLI b2g expone 11 subcomandos (original; 12 con monitor desde el cleanup pre-v0.3):
seed, chain, filter, build, export, snapshot, status, inspect, validate,
accept, reject (+ monitor, cleanup pre-v0.3).
Esto amplía el set provisional de API.md §convenciones (que listaba 9 y dejaba accept/
reject como "sobrevive programáticamente"): el PO decidió que la curación programática
(accept/reject por --ids) es un subcomando CLI de primera clase, no solo API de
librería — para que un agente cure la biblioteca viva por subprocess sin escribir Python (historia
C4). La curación interactiva rica (curate) y la GUI siguen siendo futuro: accept/reject
son deterministas y sin estado interactivo.
B. build y export son comandos separados (decisión del PO)¶
buildcomputa las redes conNetworks.quick(acoplamiento sobre corpus completo, co-autoría, instituciones, co-word, y co-citación sicited_by_idestá poblado trasenrich, Hito 8b → 4 o 5 redes) y escribe artefactos intermedios a disco (<store_dir>/networks/<kind>/network.graphml+metrics.json). Transiciona elLoopStateaBUILT.exportrelee esos artefactos de build y los serializa al formato pedido (--format graphml|csv) en el--out-dir. No recomputa redes y no transiciona elLoopState.
Separarlos permite computar una vez y exportar a varios formatos/destinos sin recalcular, y deja
build como el paso que avanza la máquina de estados.
C. Envelope JSON común y versionado (schema="1")¶
Cada subcomando con --json emite un único objeto JSON con la estructura estable:
{
"schema": "1",
"ok": true,
"command": "seed",
"exit_code": 0,
"data": { },
"warnings": [],
"error": null
}
schemaes la versión del contrato ("1"hasta que se declare una ruptura).- En éxito:
ok=true,error=null,datacon el payload del comando. - En error conocido:
ok=false,data={},error={"code": <CODE>, "message": <accionable>}. warningstransporta avisos no fatales (p. ej. eltranslation_reportdeseed).
El envelope es lo único que el comando imprime en stdout en modo --json: un agente parsea una
línea JSON por invocación.
D. Exit codes mapeados por tipo de error (no por comando)¶
El decorador @handle_errors(command) captura excepciones por tipo y las traduce a exit codes
(ADR 0010), de forma uniforme para los 11 comandos:
| Exit | Significado | Origen (excepción) |
|---|---|---|
0 |
éxito | — |
1 |
uso (opción faltante/inválida) | UsageError / errores de parseo de Click |
2 |
datos (schema inválido, ids inexistentes, criterio de filtro vacío) | DataError |
3 |
dependencia/capacidad faltante | ImportError (extra ausente) · DependencyError (capacidad de source faltante, p. ej. sin fetch_citing — ver enmienda R5) · NotImplementedError (p. ej. depth>1) |
4 |
red no disponible | httpx.HTTPError y subclases (captura por tipo, toda la jerarquía) |
5 |
store/snapshot bloqueado o corrupto | StoreLockedError / OSError (single-writer, ADR 0019) |
Enmienda R5 (2026-06-16) —
AttributeErrorya NO se mapea a exit 3 en el decorador. El AS-BUILT capturabaAttributeErroren@handle_errorsy lo emitía como "Capacidad no disponible" (exit 3). Eso disfrazaba bugs reales (unAttributeErrorgenuino dentro dechain/merge/_fetch_forwardse reportaba como "el source no soporta forward"). R5 separa las dos cosas (Nota 06, catálogo de secundarios): - La conversión capacidad-de-source-faltante →DependencyError(exit 3) es responsabilidad del borde CLI: el comando hace un pre-check explícito (chain.pyverificahasattr(source, "fetch_citing")antes de instanciar elForager) y lanzaDependencyErrorcon un mensaje accionable. El forager queda agnóstico de_errors(núcleo puro; no importa la capa CLI). - UnAttributeErrorinesperado se propaga limpio (falla accionable/visible), ya no se traga. - Rama muerta colapsada: elif isinstance(exc, StoreLockedError)/elsede la ramaOSErrorhacía lo mismo en ambas ramas (exit 5); R5 lo simplificó a un únicoexcept OSError → exit 5.Enmienda R5 — comandos de solo lectura no auto-crean el store.
status/validateusabanopen_store, que crea un.duckdbvacío ante un typo en--store(footgun verificado, Nota 06). R5 agregaopen_store_readonly(cli/_store.py): verifica que el archivo exista y, si no, lanzaStoreErroraccionable ("el store no existe… iniciá conb2g seed").status/validatela usan; los comandos de escritura conservanopen_store(crear-si-falta es su comportamiento correcto).
E. --store global + sin estado entre invocaciones (tensión núcleo-valor)¶
--store <archivo.duckdb> es una opción global del grupo (en el grupo b2g, antes del
subcomando), obligatoria. El núcleo es puro y sin estado de sesión (ADR 0010/0015), pero el
valor del producto exige continuidad entre invocaciones (la biblioteca viva). Esa tensión se
resuelve haciendo que todo el estado viva en el archivo .duckdb: el CLI es stateful vía
archivo, no vía proceso. Dos b2g consecutivos comparten estado solo a través de --store.
Consecuencia de borde — el error de uso sale sin envelope: si falta --store (o una opción
requerida del subcomando), Click aborta el parseo antes de entrar a la función del comando, así
que no hay envelope JSON: se emite el mensaje de uso de Click en stderr y exit code 1. El
envelope versionado solo aplica a errores que ocurren dentro de la ejecución del comando.
F. Transiciones de LoopState automáticas por comando¶
Los comandos que mutan el corpus avanzan la máquina de estados (ADR 0016, transiciones permisivas) automáticamente tras persistir con éxito:
| Comando | Transición |
|---|---|
seed |
→ SEEDED |
chain |
→ FORAGED |
filter |
→ FILTERED |
build |
→ BUILT |
export, snapshot, status, inspect, validate, accept, reject |
no transicionan |
accept/reject mutan curación pero no mueven el lazo (curar no es una fase del flujo
exploratorio). status lee y presenta el estado actual + las transiciones disponibles.
Enmienda 2026-06-15 (curación transversal en
status): queaccept/rejectno transicionen es correcto, pero el AS-BUILT también las oculta detransitions_available(cli/commands/status.py:19-34), dejando invisible lo único irreductiblemente humano. Tras la enmienda del ADR 0016 (curación transversal),b2g statusdebe mostraraccept/rejectcomo acción SIEMPRE-disponible (en cualquier estado), separada de las transiciones del lazo. Además, el FSM ganareseed(loop-back aSEEDEDcon contador de ronda) y el estadoMONITORED:statusdebe reflejarlos. Ver ROADMAP Hito R3. El bug UTF-8 en Windows (cli/_envelope.py:67:ensure_ascii=Falsesin forzar UTF-8 en stdout → acentos corruptos, rompe el contrato agente-native) se corrige en ROADMAP Hito R5.Implementado en R3 (2026-06-16): el envelope
--jsondestatus(sección C) suma dos campos endata, aditivos y que mantienenschema="1"(decisión del PO 2026-06-16: campos nuevos no rompen a los agentes, no se bumpea la versión del contrato): -curation_available:["accept", "reject"]siempre (curación transversal — disponible en cualquier estado, no transiciona). Antes de R3transitions_availablenunca las listaba (bug cerrado); ahora viven en un campo propio, separado de las transiciones del lazo. -round: contador de ronda (0sin estado ·1primera ronda ·2+re-sembrados).Además,
transitions_availableahora se deriva debib2graph.cycle.available_transitions(no de la tabla_TRANSITIONSlocal, retirada) y refleja el ciclo: incluyereseedcuando hay estado previo. La tabla F (transiciones automáticas por comando) sigue vigente;seedagrega la semánticareseed(estado previo → ronda++) cablada enseed.py.MONITOREDestá en el modelo pero ningún comando lo dispara aún. El bug UTF-8 sigue pendiente (Hito R5).
Consecuencias¶
- Un agente orquesta todo el flujo de 10 minutos por subprocess + JSON sin escribir Python:
seed → chain → filter → (accept/reject) → build → export, parseando un envelope estable por llamada y ramificando porexit_code. La frontera programática del producto queda cubierta sin tool schemas ni MCP (que siguen siendo futuro, ROADMAP §costuras futuras). accept/rejectcomo CLI cierra C4 a nivel de agente (curar la biblioteca viva sin librería), al costo de dos comandos más que mantener bajo el contrato del envelope.build/exportseparados habilitan exportar a varios formatos sin recomputar, pero introducen un acoplamiento por disco:exportdepende de quebuildhaya escrito los artefactos intermedios; si no existen,exportfalla conDataError(exit 2) y un mensaje que pide correrbuildprimero. (Gap conocido:exportrelee GraphML de disco en vez de recibir los artefactos en memoria — es el precio de desacoplar los dos pasos vía el sistema de archivos.)- El envelope versionado (
schema="1") habilita evolucionar la salida sin romper agentes: un cambio incompatible bumpeaschema. Costo: tests de contrato--json(golden/schema) por comando para que la forma no driftee (ROADMAP, disciplina de tests). - Captura de errores por tipo mantiene el mapeo de exit codes uniforme y testeable (un caso
por código), sin que cada comando reinvente el manejo. Trade-off: un
OSErrorno-bloqueo que no seaStoreLockedErrorigualmente cae en exit5(se trata cualquierOSErrordel store como "store inaccesible"); es conservador pero puede enmascarar un error de I/O no relacionado con el bloqueo. --storeglobal obligatorio simplifica el modelo mental (una investigación = un archivo) pero hace que el error de uso más común (olvidar--store) salga fuera del envelope (exit 1, stderr de Click). Un agente debe tratar exit 1 sin envelope como error de invocación, no de dominio.- Las transiciones automáticas dan un
LoopStatesiempre consistente con el último comando mutador, sin que el usuario lo gestione. Como las transiciones son permisivas (ADR 0016), no hay guardia: re-sembrar trasBUILTestá permitido y solo re-apunta el estado aSEEDED.
Enmienda — Cleanup pre-v0.3 (2026-06-16): 12° subcomando monitor¶
Cierra dos seguimientos de R3/R5 (alias
LoopStateretirado;MONITOREDalcanzable — ver ADR 0016 §Cleanup pre-v0.3). Implementado + verificado (327 tests, mypy strict, ruff+format OK).
El set pasa de 11 a 12 subcomandos con el alta de monitor (paso 8 del ciclo, Ellis):
- §A (set): se agrega
monitor→ 12 subcomandos. Re-chequea OpenAlex por citantes nuevos del corpus (forward chaining), mergea los candidatos nuevos a la biblioteca viva y transiciona aMONITORED.--email(polite pool); sin corpus/estado previo →DataError(exit 2, accionable). - §C (envelope): el
datademonitores{"new_candidates": <int>, "total_papers": <int>, "loop_state": <str>, "round": <int>}, dentro del envelope común conschema="1"(sin bump; campo de payload nuevo, no cambia la forma del envelope).--jsonemite el objeto único de siempre. - §F (transiciones): se agrega la fila
monitor→MONITORED. La reglamonitorestá encycle.apply_transitiondesdeBUILTy desdeMONITORED(re-monitoreo). El destino lo dicta el dominio (fuente única), como el resto de los comandos mutadores. - §D (pre-check de capacidad) — asimetría deliberada
monitorvschain:monitorNO hace el pre-checkhasattr(source, "fetch_citing")que sí hacechain(enmienda R5). El motivo es quemonitorinstanciaOpenAlexSourcefijo —que siempre tienefetch_citing—, mientras quechainacepta una--directionvariable y puede recibir unaSourcede solo-mínimo (ADR 0018) que no soporte forward; por esochainpre-chequea y lanzaDependencyError(exit 3) accionable, ymonitorno necesita la guardia. La asimetría es decisión documentada, no deuda: el pre-check es responsabilidad del borde solo donde la capacidad puede faltar.
Además, el alias LoopState = CycleState se RETIRÓ del código (backends/duckdb.py y
stores/duckdb.py): el contrato usa solo CycleState (de bib2graph.cycle). Donde este ADR
dice "LoopState", léase CycleState (mismo concepto, una sola clase).
Enmienda — --store deja de ser global obligatorio (AS-BUILT, 2026-06-16)¶
Implementado por 0029 (ver su AS-BUILT). El cuerpo de este ADR queda como historia; esta enmienda actualiza el §E al as-built.
El §E fijaba --store como opción global del grupo, obligatoria. Con el modelo "workspace por
investigación" (ADR 0029), --store pasó a opcional y se
agregó --workspace (ambos opcionales): la unidad de persistencia deja de ser un .duckdb
suelto y pasa a ser una carpeta workspace (marcada por workspace.json), resuelta por
ambiente (patrón git/cargo: walk-up del cwd). Precedencia: --workspace/--store explícito >
B2G_WORKSPACE (env) > workspace del cwd. --workspace y --store son mutuamente excluyentes
(juntos = error de uso). --workspace es el flag primario; --store sobrevive para apuntar a un
.duckdb suelto (workspace "degenerado", retrocompatible). Sin flag y sin workspace resoluble → error
accionable que sugiere b2g init.
El cambio es aditivo/retrocompatible: la resolución ambiente solo cubre el caso en que falta el
flag; el resto del contrato (envelope schema="1", exit codes, transiciones) no cambia. El set
de subcomandos pasa de 13 a 14 con el alta de b2g init (scaffold del workspace).
Enmienda — --store eliminado del CLI (BREAKING, #75, 2026-06-17)¶
Supera la enmienda 2026-06-16 de arriba (que dejaba
--store"opcional"). Aquella queda como historia; el §E pasa a su forma final. Implementado por la enmienda 2026-06-17 de 0029 (ver su detalle).
--store se elimina por completo del CLI: ya no está registrada como opción global en Click.
Pasarla produce el error estándar de Click (No such option: --store, con su exit code), no un
mensaje custom de migración. La única forma canónica de apuntar a la persistencia es
--workspace <carpeta> o la resolución ambiente (B2G_WORKSPACE > walk-up del cwd buscando
workspace.json). El modo degenerado (.duckdb suelto) deja de existir: un .duckdb legacy se
adopta como workspace con b2g init . en su carpeta. Es un BREAKING change del contrato CLI.
Enmienda — B2G_JSON activa el modo JSON por entorno (2026-06-27, #151)¶
Enmienda aditiva al §C (envelope). El flag
--jsony su forma no cambian; el envelopeschema="1", los exit codes (§D) y la FSM (§F) quedan intactos. Complementa la enmienda al §C que reclamó el ADR 0037 (stdout puro). Implementado + verificado (gate verde).
El §C declaraba que --json es lo único que el comando imprime en stdout. Esta enmienda fija dos
cosas que el AS-BUILT explicita:
- stdout puro ENFORCED. En modo JSON, stdout emite exactamente la línea-envelope, incluido
el camino de error (
ok=false→ envelope en stdout, no en stderr). El texto de modo humano va a stderr. Antes era contrato declarado; ahora está enforced y testeado. - Activación por entorno con
B2G_JSON. El modo JSON se activa también con la variable de entornoB2G_JSON(truthy:1/true/yes, case-insensitive), con alcance a todos los comandos (incl.init). Precedencia:--jsonexplícito >B2G_JSON. No hay--no-json. La superficie de invocación del flag no cambia:--jsonsigue siendo por-comando y post-verbo (b2g <cmd> --json), no se vuelve global. Caso de uso agents-first:export B2G_JSON=1una vez por sesión y no repetir el flag.
En código se unificó el flag vía un decorador compartido @json_option (cli/_options.py, con
json_mode(local_flag) que resuelve flag-o-entorno); es refactor interno, no cambia el contrato
externo. Documentado en docs/API.md §convenciones CLI (Envelope JSON / B2G_JSON).