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).
Enmienda — canal único de warnings en el envelope (QA 0.14.0, hallazgo #3)¶
Enmienda aclaratoria del §C. Fija una norma que el §C ya implicaba pero no escribía; no cambia la forma del envelope ni bumpea
schema(sigue"1"). Cierra un drift observado en QA: los envelopes deexportybuildduplicaban el mismo texto de aviso endata.warningsy en elwarningstop-level.
El §C define warnings como campo top-level del envelope (avisos no fatales). Esta enmienda fija
que ese es el único canal canónico:
- En modo
--json, loswarningsvan SIEMPRE en el campowarningstop-level del envelope y NUNCA duplicados dentro dedata. Un consumidor mira un solo lugar; no hay que unirdata.warningsconwarnings. - Si un servicio interno (
run_export/run_build) devuelve undata["warnings"]propio (lo conserva para sus tests unitarios), la superficie CLI lo extrae conpop—noget— antes de emitir, así el texto sobrevive en un solo lugar (el top-level) ydatano lo lleva (cli/commands/export.py,cli/commands/build.py). - Excepción por diseño —
data["empty_networks"](build) no es un warning: es un diagnóstico estructurado por-red ({kind, reason, fix_command}) que vive endata, separado del canal dewarnings(ADR 0037; verdocs/API.md§build). No se duplica enwarnings.
Aplica a todos los comandos del ciclo; los avisos ya canónicos por top-level (walk-up del cwd, ADR
0045 #259; networks_cache_stale de status) no cambian. Documentado en docs/API.md §Envelope JSON.
Enmienda — errores de Click en modo JSON como envelope (#283, 2026-10-11)¶
Enmienda aditiva al §C (envelope) y al §D (exit codes). El envelope
schema="1"queda intacto; se agrega un único punto de captura para errores que escapan al cuerpo del comando Click (losclick.exceptions.UsageErrory subclases —NoSuchCommand,NoSuchOption,BadParameter). Detectado por la auditoría AX del capstone e2e 0.12.0.
Problema (#283)¶
Antes de esta enmienda, los errores de parseo de Click salían como texto plano a stderr + exit
2, incluso en modo JSON. Un agente que siempre parsea stdout como envelope rompía: recibía
texto no-JSON sin poder rutear el error por el mismo canal. El síntoma concreto:
$ B2G_JSON=1 b2g monitor
Error de uso: No such command 'monitor'. # texto plano, exit 2, NO envelope
Estos errores son los que escapan al cuerpo del comando Click: Click los lanza antes de
entrar al cuerpo del subcomando, así que el decorador @handle_errors (que sí cubre el camino de
errores de dominio) nunca los ve.
Decisión¶
Los errores de Click se re-canalizan al envelope en modo JSON, vía un interceptor único en
main():
- Modo JSON (
--jsonflag local o envB2G_JSONtruthy): stdoutrecibe una línea envelope conschema="1",ok:false,error.code='USAGE_ERROR',error.message=<click message>,exit_code=2,commandbest-effort (nombre del verb intentado, parseado deexc.messageparaNoSuchCommand, deexc.ctx.command.namepara el resto).stderrqueda vacío (contrato stdout puro del §C).- Exit code devuelto por
main(): 2 (parse error de Click — coherente con la exit code table del §D fila 1 "uso / errores de parseo de Click", y con el comportamiento nativo de Click que ya emite 2 para esto). - Modo humano (sin flag ni env): comportamiento previo intacto — Click imprime el usage en
stderr,main()retorna 2 (UsageErrorya se capturaba enmain()para devolver exit 2; antes de la enmienda el formateo eraError de uso: <format_message()>; ahora se unifica con la rama JSON para que el comportamiento sea "exit 2 en ambos modos", diferenciándose solo en el canal de salida — texto a stderr vs envelope a stdout).
Limitación documentada de Click (importante)¶
Cuando el primer token inválido está antes de --json en argv, Click aborta el parseo y nunca
alcanza el flag. Esto afecta todos los escenarios de error de parseo:
b2g monitor --json→ Click vemonitor, lanzaNoSuchCommand, sale con exit 2 antes de parsear--json. El flag no surte efecto.b2g build --opcion-inexistente --json→ Click vebuild, ve--opcion-inexistente, lanzaNoSuchOption, sale con exit 2 antes de parsear--json.
El camino agente-native es entonces B2G_JSON=1 por entorno (no por flag), que se inspecciona
en main() una sola vez antes de invocar el grupo Click. Esto es coherente con la enmienda
B2G_JSON del 2026-06-27 y con la postura agents-first del ADR 0043.
El command del envelope es best-effort: para NoSuchCommand se parsea del mensaje
(No such command '<name>'.); para otras variantes se usa exc.ctx.command.name. Si Click no da
ninguno de los dos, queda "" (no es fatal; el consumidor puede ramificar por error.code).
Implementación¶
- Helper puro nuevo:
cli/_click_error_handler.pycon dos funciones —extract_command_from_usage_error(exc)(best-effort) yemit_click_usage_error_envelope(exc, *, command, json_mode) -> int(decide canal y retorna exit code). Sin side effects globales aparte del I/O explícito a stdout/stderr (igual que el resto del CLI). - Interceptor en
main()(cli/__init__.py): reemplaza elclick.echo(...err=True)previo por el helper. Elexcept click.exceptions.UsageErrorya existía; ahora delega al helper pasandojson_mode(env-only probe) ycommandbest-effort. - No se toca el cuerpo de los subcomandos. La interceptación es genérica y vive solo en
main()(Adaptador CLI, ADR 0028). El decorador@handle_errorsqueda intacto — sigue cubriendo el camino de errores de dominio (losB2GError,OSError,ImportError,httpx.HTTPError); el interceptor nuevo cubre los errores de parseo de Click. Las dos ramas no se solapan (losB2GErrorse lanzan dentro del cuerpo del comando; losUsageErrorde Click se lanzan antes del cuerpo).
Contrato¶
El envelope para un error de Click tiene la misma forma que cualquier envelope de error:
{
"schema": "1",
"ok": false,
"command": "monitor",
"exit_code": 2,
"data": {},
"warnings": [],
"error": {"code": "USAGE_ERROR", "message": "No such command 'monitor'."}
}
code/exit_code/schema son los mismos que el §D fila 1 ("uso"). error.subcode queda ausente
(como cualquier error que no sea de red — coherente con la enmienda ADR 0045 #258).
Consecuencias¶
- Agente puede recuperarse por el mismo canal. Un agente que tantea verbos retirados por la
poda #207 (
b2g monitor,b2g enrich, etc.) recibe ahoraok:false, error.code='USAGE_ERROR', command='monitor'y puede decidir el fallback porerror.code, sin parsear texto plano. - No se cambia
schemani se agregan campos: el cambio es estrictamente aditivo en el sentido de que ahora hay más errores que llegan como envelope. Para un consumidor que ya parseaba envelopes, loserror.code='USAGE_ERROR'de Click son indistinguibles de los de dominio (@handle_errorsya emitíaUSAGE_ERRORpara errores de workspace), pero se distinguen porcommand(verb conocido vs verb intentado) y porexit_code(1 vs 2). Un consumidor estricto puede usarlo para rutear. - No se rompe la postura humana. El usuario interactivo sigue viendo el formato de Click que ya conocía (texto a stderr + exit 2). La rama JSON es estrictamente ortogonal.
- Helper testeable en aislamiento. Las dos funciones del helper son puras (sin estado
global, sin red, sin duckdb) y se cubren con tests unit rápidos (
tests/unit/test_cli_click_errors_envelope.py).
Implementado y verificado en #283 (commit de código + esta enmienda, 2 commits en el mismo PR).
Corrección post-0.15.0 (2026-10-11, Nota 05 de Atalaya, B1). El "env-only probe" de arriba dejaba fuera el
--jsonen argv:b2g monitor --jsonob2g export --json --format wrongformatsalían como texto plano porque Click aborta antes de parsear el flag.main()ahora detecta--jsonen el argv crudo (_argv_requests_json, hasta el separador--) además deB2G_JSON. El contrato del envelope no cambia; solo llega a más casos.