@trycore/spec-build-harness 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,10 +5,74 @@
5
5
  // import histórico (docs/superpowers/specs/2026-08-20-cliente-migrate-normalizacion-eventos-design.md):
6
6
  // convierte el build-state.json legacy del piloto en la forma ImportBundleIn
7
7
  // que ya entiende el import del hub — eventos del catálogo v2, no snapshots crudos.
8
- import { normalizeHistory, normalizeReleases, normalizeFront, normalizeFacts, validateNormalizedBundle, } from './normalize.js';
8
+ import { normalizeHistory, normalizeReleases, normalizeFront, normalizeFacts, normalizePreHarnessEpic, validateNormalizedBundle, verifyNormalizedBundle, } from './normalize.js';
9
9
  export const STATE_BUNDLE_VERSION = 2;
10
10
  const EPOCH_ISO = new Date(0).toISOString();
11
- export function buildStateBundle(raw, projectRef) {
11
+ // Contrato del hub (graph_service.VALID_LAYERS): un layer fuera de este set
12
+ // rechaza el grafo ENTERO (GraphIntegrityError) — mejor pararlo en local.
13
+ const VALID_LAYERS = new Set(['FOUNDATIONAL', 'BUSINESS']);
14
+ /**
15
+ * Extrae la sección `graph` (formato GraphImportIn del hub) desde el bundle de
16
+ * `scripts/lib/graph-bundle.py` o desde un `{epics: [...]}` crudo, validando lo
17
+ * que el hub validará: `layer` ∈ FOUNDATIONAL|BUSINESS, `code` y `title` por
18
+ * épica, `code` y `title` por historia (`story_in["code"]`). Errores ⇒ graph
19
+ * null: embeber un grafo que el hub rechazará entero no ayuda a nadie.
20
+ */
21
+ export function extractGraphSection(raw) {
22
+ const errors = [];
23
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
24
+ return { graph: null, errors: ['el bundle de grafo no es un objeto JSON'] };
25
+ }
26
+ const epicsRaw = raw.epics;
27
+ if (!Array.isArray(epicsRaw)) {
28
+ return { graph: null, errors: ['el bundle de grafo no tiene `epics` (¿lo generó scripts/lib/graph-bundle.py?)'] };
29
+ }
30
+ const epics = [];
31
+ epicsRaw.forEach((e, i) => {
32
+ if (typeof e !== 'object' || e === null || Array.isArray(e)) {
33
+ errors.push(`epics[${i}] no es un objeto`);
34
+ return;
35
+ }
36
+ const epic = e;
37
+ const code = typeof epic.code === 'string' && epic.code ? epic.code : null;
38
+ if (!code)
39
+ errors.push(`epics[${i}] sin \`code\``);
40
+ const ref = code ?? `epics[${i}]`;
41
+ if (typeof epic.title !== 'string' || !epic.title) {
42
+ errors.push(`${ref} sin \`title\` (EpicIn del hub lo exige)`);
43
+ }
44
+ if (typeof epic.layer !== 'string' || !VALID_LAYERS.has(epic.layer)) {
45
+ errors.push(`${ref} con layer ${JSON.stringify(epic.layer ?? null)} fuera de FOUNDATIONAL|BUSINESS: el hub rechazaría el grafo ENTERO`);
46
+ }
47
+ for (const [j, s] of (Array.isArray(epic.stories) ? epic.stories : []).entries()) {
48
+ const story = typeof s === 'object' && s !== null ? s : {};
49
+ if (typeof story.code !== 'string' || !story.code) {
50
+ errors.push(`${ref}: historia [${j}] sin \`code\` (el hub hace story_in["code"] — ¿bundle en formato viejo con \`id\`?)`);
51
+ }
52
+ else if (typeof story.title !== 'string' || !story.title) {
53
+ errors.push(`${ref}: historia ${story.code} sin \`title\``);
54
+ }
55
+ }
56
+ epics.push(epic);
57
+ });
58
+ if (errors.length > 0)
59
+ return { graph: null, errors };
60
+ return { graph: { epics }, errors };
61
+ }
62
+ /**
63
+ * Épicas del grafo embebido SIN representación en el bundle: ni slice en
64
+ * history[] ni declaración pre-arnés. Es el síntoma exacto del incidente de
65
+ * el primer piloto real: quedan «sin empezar» en el hub y
66
+ * `claim_next` las reparte como trabajo nuevo. Solo detecta — la lista
67
+ * pre-arnés la confirma el humano, nunca se auto-añade nada.
68
+ */
69
+ export function graphEpicsWithoutRepresentation(graph, representedCodes) {
70
+ const represented = new Set(representedCodes);
71
+ return graph.epics
72
+ .map((e) => (typeof e.code === 'string' ? e.code : ''))
73
+ .filter((code) => code !== '' && !represented.has(code));
74
+ }
75
+ export function buildStateBundle(raw, projectRef, preHarness) {
12
76
  const warnings = [];
13
77
  const st = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {};
14
78
  if (raw !== null && (typeof raw !== 'object' || Array.isArray(raw))) {
@@ -22,13 +86,40 @@ export function buildStateBundle(raw, projectRef) {
22
86
  if (st.releases !== undefined && !Array.isArray(st.releases)) {
23
87
  warnings.push('releases no es un array: se descarta (0 releases migradas)');
24
88
  }
25
- const { entries: history, unmapped: historyUnmapped } = normalizeHistory(historyRaw);
26
- const { entries: releases, unmapped: releasesUnmapped } = normalizeReleases(releasesRaw);
27
- const front = normalizeFront(st.parallel_front ?? null);
89
+ const { entries: history, unmapped: historyUnmapped, warnings: historyWarnings } = normalizeHistory(historyRaw);
90
+ const { entries: releases, unmapped: releasesUnmapped, warnings: releaseWarnings } = normalizeReleases(releasesRaw);
91
+ warnings.push(...historyWarnings, ...releaseWarnings);
92
+ const frontUnmapped = [];
93
+ const front = normalizeFront(st.parallel_front ?? null, warnings, frontUnmapped);
28
94
  const fronts = front ? [front] : [];
29
- const facts = normalizeFacts(st, EPOCH_ISO);
30
- const unmapped = [...historyUnmapped, ...releasesUnmapped];
31
- const validationErrors = validateNormalizedBundle({ history, releases, fronts });
95
+ const factsUnmapped = [];
96
+ const facts = normalizeFacts(st, EPOCH_ISO, warnings, factsUnmapped);
97
+ const unmapped = [...historyUnmapped, ...releasesUnmapped, ...frontUnmapped, ...factsUnmapped];
98
+ // Épicas pre-arnés (issue #42): lista CONFIRMADA por el humano — entrada
99
+ // sintética archivada sin gates por épica (patrón del workaround del piloto).
100
+ // Conflicto obvio ⇒ error, no fusión: una épica declarada pre-arnés que YA
101
+ // tiene slice (en history[] o como active_slice) es una contradicción que
102
+ // debe resolver el humano, no el normalizador.
103
+ const conflictErrors = [];
104
+ if (preHarness) {
105
+ const historyCodes = new Set(history.map((h) => h.epic_code));
106
+ const active = st.active_slice;
107
+ const activeCode = typeof active === 'object' && active !== null && typeof active.epica === 'string'
108
+ ? String(active.epica)
109
+ : null;
110
+ for (const epic of preHarness.epics) {
111
+ if (historyCodes.has(epic.code)) {
112
+ conflictErrors.push(`pre-arnés: la épica "${epic.code}" ya tiene slice en build-state.json (history[]) — una épica no puede declararse pre-arnés y tener historia de slice a la vez; quítala de la lista --pre-harness`);
113
+ continue;
114
+ }
115
+ if (activeCode === epic.code) {
116
+ conflictErrors.push(`pre-arnés: la épica "${epic.code}" es el active_slice de build-state.json — un slice en construcción no es pre-arnés; ciérralo o quítala de la lista --pre-harness`);
117
+ continue;
118
+ }
119
+ history.push(normalizePreHarnessEpic(epic, preHarness.asOf));
120
+ }
121
+ }
122
+ const validationErrors = [...conflictErrors, ...validateNormalizedBundle({ history, releases, fronts })];
32
123
  return {
33
124
  bundle_version: STATE_BUNDLE_VERSION,
34
125
  kind: 'state',
@@ -44,3 +135,16 @@ export function buildStateBundle(raw, projectRef) {
44
135
  validation_errors: validationErrors,
45
136
  };
46
137
  }
138
+ /** Verificación offline del bundle contra los invariantes del hub (migrate
139
+ * --verify): delega en `verifyNormalizedBundle`, que replica en seco lo que
140
+ * `validar_secuencia` del hub aplicaría en el import (import_domain.py:126-172).
141
+ * Vacío ⇒ el bundle puede entregarse al ADMIN sin motivo de rechazo conocido. */
142
+ export function verifyStateBundle(bundle) {
143
+ return verifyNormalizedBundle({
144
+ history: bundle.history,
145
+ releases: bundle.releases,
146
+ fronts: bundle.fronts,
147
+ facts: bundle.facts,
148
+ unmapped: bundle.unmapped,
149
+ });
150
+ }
@@ -98,10 +98,17 @@ los conozca (proyecciones consultables, no solo lo que ocurra de aquí en adelan
98
98
 
99
99
  ```bash
100
100
  trycore-build migrate --project-ref "<nombre-del-proyecto-en-el-hub>"
101
- # escribe .claude/state/migration-bundle.json
101
+ # escribe .claude/state/migration-bundle.json (UN solo fichero, con la sección graph embebida
102
+ # si existe .claude/state/graph-bundle.json o se pasa --graph <fichero> — issue #41)
103
+
104
+ trycore-build migrate --verify # solo verifica offline contra los invariantes del hub; no escribe nada
102
105
  ```
103
106
 
104
- Esto **normaliza y valida en local** — nunca sube nada. Entrega el fichero resultante a un ADMIN
107
+ Esto **normaliza, verifica contra los invariantes del hub y valida en local** — nunca sube nada.
108
+ Si el bundle viola algún invariante (evidencia obligatoria en veredictos passing, límites de
109
+ longitud, catálogo de gates/eventos, orden de fases…), `migrate` **aborta sin escribir el
110
+ fichero**: un bundle que el hub rechazaría no se entrega al ADMIN (lección del primer piloto real,
111
+ issue #40 — el primer import rechazó 36/41 entradas por defectos detectables offline). Entrega el fichero resultante a un ADMIN
105
112
  con la instrucción: *«súbelo en la consola del hub, pantalla de import histórico del proyecto»*. El
106
113
  import es idempotente y reanuda si falló a medias; las entradas no mapeables se importan igual como
107
114
  `legacy_imported` con el payload original — nada se pierde, nada bloquea.
@@ -16,7 +16,7 @@
16
16
  |---|---|---|---|
17
17
  | SessionStart (`startup\|clear\|compact`) | `session-start.sh` | `POST /agents/register` (idempotente, con `asset_types`) + `GET /agent/context` + `context-sync.sh` (§4) + aseguramiento del daemon | Escribe la **caché de proyección normalizada** (`.claude/state/runtime-projection.json`). No inyecta contexto: el render lo hace `load-build-state.sh`, que corre después en el mismo evento. |
18
18
  | SessionStart (mismo matcher) | `load-build-state.sh` | **ninguna** | Renderiza `additionalContext` desde la caché en modo `runtime`; en `legacy\|dual` sigue leyendo `build-state.json`. |
19
- | PostToolUse (`Bash\|Edit\|Write\|MultiEdit\|Task`) | `event-emitter.sh` | **ninguna en el hilo del hook**: encola `tool_use_recorded` | Encola en `.claude/state/outbox/` (§5) y hace la comprobación barata del daemon (stat del pidfile). Solo metadatos: herramienta, ruta relativa, `argv0` nunca el comando completo ni el fuente (§8). |
19
+ | PostToolUse (`Bash\|Edit\|Write\|MultiEdit\|Task`) | `event-emitter.sh` | **ninguna** | Solo hace la comprobación barata del daemon (stat del pidfile). Desde el issue #44 **no encola nada**: `tool_use_recorded` no existe en el catálogo v2 del hub y era ruido garantizado en `rejected/`; la telemetría de uso queda suspendida hasta que el equipo del hub decida añadir el tipo. |
20
20
  | — (daemon, no hook) | `heartbeat.sh --daemon` | `PUT /leases/renew` + `POST /events` | Singleton por repo con registro de ppids de sesión; late cada `lease_ttl_s/3` (valor del servidor) y despacha la cola mientras viva. Ver §9. |
21
21
  | — (invocado, no registrado) | `context-sync.sh` | `GET …/context/manifest`, `GET …/context/files?path=…&sha256=…`, `POST /context/synced` | Lo llama `session-start.sh` y, desde el sub-slice C, las skills antes de reclamar. |
22
22
  | PreCompact / PostToolUse / Stop | `context-monitor.sh` | encola `handoff_recorded {note, resume_hint, stopped_at}` | En `runtime` el handoff **solo** es evento (no se escribe el fichero); en `dual` se escribe el fichero **y** se emite; en `legacy`, solo el fichero. `handoff_recorded` es de ámbito **slice**: solo se encola con `slice_id` conocido (misma guardia que `event-emitter.sh`); en `dual`, como no hay claim vía runtime, `active_slice` normalmente no llega por `/agent/context` y el evento no se emite (solo queda el fichero). |
@@ -29,7 +29,8 @@ Eventos emitidos por skills (no por hooks) — sub-slice C: `POST /checkpoints`,
29
29
  ## 3. Claim (el corazón del pull)
30
30
 
31
31
  ```
32
- POST /tasks/next {context_hashes: [{path, sha256}], epic_code?}
32
+ POST /tasks/next {context_hashes: {"<path>": "<sha256>", }} // dict[str,str]; {} = reporta vacío;
33
+ // valor "" = fichero ilegible (nunca null)
33
34
  200 → {slice_id, epic_code, epic_title, stories[], files_scope, docs_ref, phase, gates,
34
35
  branch_base, openspec_change, manifest_hash,
35
36
  lease: {ttl_s, expires_at}, checkpoint: {branch, commit_sha} | null}
@@ -53,12 +54,19 @@ Reglas del cliente (las implementa `slice-ops.sh claim`, no la prosa de la skill
53
54
  4. Un agente mantiene **un solo** slice activo; reclamar con lease vigente devuelve el mismo slice.
54
55
  5. Modo `dual`: **no se reclama** (el fichero decide el slice, spec §6.1) — el subcomando devuelve
55
56
  rc 3. Modo `legacy`: no hay red, rc 3.
57
+ 6. **No existe claim dirigido**: `TasksNextIn` no acepta `epic_code` (pydantic ignoraría el campo y
58
+ la cola repartiría otra épica — incidente del primer piloto, issue #39). `claim --epic` falla explícito
59
+ (rc 2, sin abrir socket) explicando el protocolo real: terminar el slice en dual → cutover
60
+ admin → reclamar del hub lo que la cola reparta.
56
61
 
57
62
  Actos de dominio posteriores (todos por `slice-ops.sh`, ninguno a mano):
58
63
  `POST /slices/{id}/verdicts` · `POST /checkpoints` · `POST /slices/{id}/submit` · eventos
59
- `wiring_*`, `progress_note_recorded`, `slice_archived`, `escalation_raised` y los hechos de
60
- proyecto (`scaffold_confirmed`, `design_source_declared`, `project_kind_declared`,
61
- `foundation_declared`, `harness_phase_changed`) por la cola (§5). Outer loop, por
64
+ `wiring_*`, `progress_noted`, `slice_archived`, `slice_escalated` y los hechos de
65
+ proyecto **máquina** (`project_kind`, `harness_phase`, `foundation`) como
66
+ `project_fact_updated {fact, value, source: "AGENT"}` el único tipo del catálogo v2 para hechos
67
+ (issue #38). Los hechos **humanos** (`scaffold_confirmed`, `design_source_*`) NO viajan por la
68
+ superficie de agente: se fijan tras el PDP por `PATCH /orchestrator/projects/{project_id}`
69
+ (consola admin); `slice-ops fact` los rechaza en runtime/dual con rc 6. Outer loop, por
62
70
  `release-ops.sh`: `POST /releases/{line}/verdicts` y
63
71
  `POST /fronts/{front_id}/members/{agent_key}/integration`.
64
72
 
@@ -86,9 +94,11 @@ Offline: sin runtime se trabaja con el último lock; la statusline marca `⚠ st
86
94
 
87
95
  ## 5. Cola offline de eventos (`.claude/state/outbox/`)
88
96
 
89
- - Un archivo JSON por evento (no por lote — `runtime_enqueue_event`), con `client_event_id` (UUID) ⇒ **idempotencia server-side** (reintentos seguros). El despacho (`runtime_dispatch_outbox`) sí agrupa en un único `POST /events` por invocación.
97
+ - Un archivo JSON por evento (no por lote — `runtime_enqueue_event`), con `client_event_id` (UUID) ⇒ **idempotencia server-side** (reintentos seguros). El despacho (`runtime_dispatch_outbox`) sí agrupa en un único `POST /events` por invocación, con el sobre que el hub exige: `{"events": [{client_event_id, event_type, payload, slice_id?}]}` — la clave interna `type` de los ficheros de la outbox se traduce a `event_type` **al armar el cuerpo** (el formato en disco no cambia; ficheros encolados por 0.10.x drenan sin migración). El ack real del hub es `{accepted[], duplicates[], rejected[{client_event_id, reason}]}` (issue #37).
98
+ - **Capa de compat de nombres/payloads en el mismo punto de salida** (issue #44): los productores encolan los nombres del **catálogo v2** del hub (`gate_verdict`, `checkpoint_recorded`, `slice_escalated`, `progress_noted`), pero los ficheros 0.10.x con los nombres viejos (`verdict_reported`, `checkpoint_created`, `escalation_raised`, `progress_note_recorded`) drenan traducidos — tipo **y** claves de payload (`status`→`verdict` en mayúscula, `note`→`summary`, `reason/gate/phase`→`cause`) — sin migración. Los tipos **sin equivalente** en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se apartan localmente a `outbox/rejected/` sin gastar red.
99
+ - **Un 4xx no es red caída**: un 4xx del lote (salvo 408/429) o un elemento en `rejected[]` del ack **no se reintenta** — el fichero se mueve a `outbox/rejected/` con la razón loggeada y se avisa. Solo 408/429/5xx/corte de red conservan la cola con backoff (fail-open intacto).
90
100
  - Despacho en background con backoff persistido (`1 s → 5 s → 30 s → 5 min`, tope; el estado vive en `outbox/.dispatch-state.json` y sobrevive entre invocaciones de hooks distintos). Orden FIFO global — el agrupado por-agregado nace con el catálogo de eventos (sub-slice C).
91
- - Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_created`, `verdict_reported`, `slice_submitted`, `handoff_recorded`, `branch_drift` ni el propio `telemetry_gap`; el descarte se reporta como evento `telemetry_gap` (con `dropped_count`/`dropped_types`) encolado en el mismo acto.
101
+ - Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_recorded`, `gate_verdict`, `slice_escalated`, `slice_submitted`, `slice_archived`, `wiring_*`, `project_fact_updated`, `handoff_recorded` ni el propio `telemetry_gap` (nombres del catálogo v2; los alias 0.10.x de los cuatro renombrados siguen protegidos porque la capa de compat los entrega). El descarte se reporta como evento `telemetry_gap` con el payload del catálogo `{dropped, window_h, reason}` (issue #44), coalescido en un único gap mientras la cola siga sobre la cota.
92
102
  - Flush forzado en `Stop`: `session-stop.sh` deja el sentinela `outbox/.flush-request`; el daemon `heartbeat.sh` lo consume de forma asíncrona (nunca en el hilo del hook). `trycore-build doctor` **reporta** el tamaño/edad de la cola pero **no** dispara un flush síncrono — sigue sin implementar.
93
103
 
94
104
  ## 6. Declaración de tipos de asset (`asset-types.json` del paquete)
@@ -161,24 +171,24 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
161
171
  | Comando | Superficie |
162
172
  |---|---|
163
173
  | `slice-ops.sh mode` | ninguna (lee `build-config.json`) |
164
- | `slice-ops.sh claim [--epic]` | `POST /tasks/next` (§3) |
174
+ | `slice-ops.sh claim` | `POST /tasks/next` (§3); `--epic` falla explícito, rc 2 (no hay claim dirigido, §3.6) |
165
175
  | `slice-ops.sh next-step` | ninguna: lo **deriva el cliente** desde la caché de proyección |
166
- | `slice-ops.sh gate <g> <pass\|fail\|na>` | `POST /slices/{id}/verdicts` |
167
- | `slice-ops.sh wiring seed\|update` | eventos `wiring_checklist_seeded` / `wiring_item_updated` |
168
- | `slice-ops.sh progress` | evento `progress_note_recorded` (evictable) |
169
- | `slice-ops.sh checkpoint` | `POST /checkpoints` |
170
- | `slice-ops.sh submit` | `POST /slices/{id}/submit` |
171
- | `slice-ops.sh archive` | evento `slice_archived` (siempre por cola, idempotente) |
172
- | `slice-ops.sh fact …` | eventos de **ámbito proyecto** (sin `slice_id`) |
176
+ | `slice-ops.sh gate <g> <pass\|fail\|na>` | `POST /slices/{id}/verdicts` (`{gate, verdict: PASS\|FAIL, evidence}`); offline → evento `gate_verdict`. `na` **no viaja**: el catálogo no lo admite (aviso local, rc 0, issue #44) |
177
+ | `slice-ops.sh wiring seed\|update` | eventos `wiring_checklist_seeded` (items `{item_id, kind, ref}`, sin `status`) / `wiring_item_updated` |
178
+ | `slice-ops.sh progress` | evento `progress_noted` (evictable) |
179
+ | `slice-ops.sh checkpoint` | `POST /checkpoints` (`summary`, no `note`); offline → evento `checkpoint_recorded {branch, commit_sha, summary}` |
180
+ | `slice-ops.sh submit` | `POST /slices/{id}/submit`; offline → evento `slice_submitted` con payload `{}` (el catálogo no admite campos) |
181
+ | `slice-ops.sh archive` | evento `slice_archived` con payload `{}` y `slice_id` (siempre por cola, idempotente) |
182
+ | `slice-ops.sh fact …` | hechos máquina evento `project_fact_updated` (sin `slice_id`); hechos humanos → rc 6 con remisión al PATCH admin (§3) |
173
183
  | `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
174
184
  | `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales |
175
- | `slice-ops.sh escalate` | evento `escalation_raised` |
176
- | `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts` |
185
+ | `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
186
+ | `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
177
187
  | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
178
- | `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration` |
188
+ | `release-ops.sh front-integration <front>` | `POST /fronts/{id}/members/{agent_key}/integration`; **sin fallback offline** (rc 5: `front_integration_reported` no existe en el catálogo v2, issue #44) |
179
189
 
180
190
  **Códigos de salida** (contrato con la prosa): `0` ok · `2` uso · `3` modo legacy (o claim en dual)
181
- · `4` sin slice activo · `5` offline, encolado · `6` rechazado por el servidor (no reintentar) ·
191
+ · `4` sin slice activo · `5` offline (encolado si el tipo tiene camino por la cola; si no, reintento manual al reconectar) · `6` rechazado por el servidor (no reintentar) ·
182
192
  `7` sin trabajo.
183
193
 
184
194
  **Modo `dual`:** el fichero es primario y estos comandos **espejan** cada transición a
@@ -14,59 +14,16 @@ source "$HERE/lib/projection.sh"
14
14
  MODE="$(runtime_mode)"
15
15
  [ "$MODE" = "legacy" ] && exit 0
16
16
 
17
- payload="$(cat 2>/dev/null || true)"
18
- command -v python3 >/dev/null 2>&1 || exit 0
17
+ # El stdin del hook se consume y se descarta (higiene del pipe; no se usa).
18
+ cat >/dev/null 2>&1 || true
19
19
 
20
- SLICE_ID="$(projection_get active_slice.slice_id "")"
21
- if [ -n "$SLICE_ID" ]; then
22
- TMPP="$(mktemp)" || exit 0
23
- printf '%s' "$payload" > "$TMPP"
24
- EV="$(PROJECT_ROOT="$(config_root)" python3 - "$TMPP" <<'PY' 2>/dev/null
25
- import json,os,re,shlex,sys
26
- try:
27
- p=json.load(open(sys.argv[1]))
28
- except Exception:
29
- raise SystemExit(0)
30
- if not isinstance(p,dict):
31
- raise SystemExit(0)
32
- tool=p.get("tool_name") or ""
33
- if not tool:
34
- raise SystemExit(0)
35
- ti=p.get("tool_input")
36
- if not isinstance(ti,dict):
37
- ti={}
38
- out={"tool":tool}
39
- if tool=="Bash":
40
- cmd=str(ti.get("command") or "").strip()
41
- # shlex.split (posix=True) respeta comillas: una asignación de entorno inline con
42
- # valor citado y espacios (p.ej. `KEY="a b" cmd`) se tokeniza como UN solo token, no
43
- # se trocea. Si el comando trae comillas sin cerrar u otro problema de tokenización,
44
- # shlex lanza ValueError: no arriesgamos un fragmento de secreto, degradamos a "".
45
- try:
46
- toks=shlex.split(cmd) if cmd else []
47
- except ValueError:
48
- toks=[]
49
- # Salta asignaciones de variable de entorno inline al frente (p.ej. `VAR=secreto cmd`,
50
- # `A=1 B=2 cmd`) para que argv0 sea siempre el binario, nunca un valor que pueda
51
- # llevar credenciales (protocolo §8).
52
- i=0
53
- while i < len(toks) and re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', toks[i]):
54
- i += 1
55
- out["argv0"]=toks[i] if i < len(toks) else ""
56
- elif tool=="Task":
57
- out["subagent_type"]=str(ti.get("subagent_type") or "")
58
- else:
59
- fp=str(ti.get("file_path") or ti.get("path") or "")
60
- root=os.environ.get("PROJECT_ROOT") or ""
61
- if root and fp.startswith(root+os.sep):
62
- fp=fp[len(root)+1:]
63
- out["path"]=fp
64
- print(json.dumps(out,ensure_ascii=False))
65
- PY
66
- )"
67
- rm -f "$TMPP"
68
- [ -n "$EV" ] && runtime_enqueue_event "tool_use_recorded" "$EV" "$SLICE_ID"
69
- fi
20
+ # [#44] `tool_use_recorded` NO existe en el catálogo v2 del hub (event_catalog.SCHEMAS):
21
+ # encolarlo era ruido garantizado en outbox/rejected/ al drenar, y este hook era justo su
22
+ # único productor. NO se emite telemetría de uso de herramienta hasta que el equipo del
23
+ # hub decida si añade el tipo al catálogo (decisión pendiente, issue #44); si eso ocurre,
24
+ # se reactiva aquí el armado de metadatos que vivía en este bloque hasta la v0.10.x
25
+ # (git history: solo herramienta, ruta relativa y argv0 — jamás el comando completo ni el
26
+ # fuente, protocolo §8). En legacy este hook ya salía arriba sin encolar: nada cambia.
70
27
 
71
28
  # Comprobación barata del daemon (stat del pidfile + kill -0, SIN red): un daemon caído a
72
29
  # mitad de sesión no debe quedar muerto durante horas hasta el próximo SessionStart.
@@ -261,8 +261,18 @@ RUNTIME_OUTBOX_MAX_BYTES=5242880
261
261
  RUNTIME_OUTBOX_MAX_AGE_S=259200
262
262
  # [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
263
263
  # perder un `slice_archived` o un `wiring_item_updated` dejaría la proyección del servidor
264
- # mintiendo. El volumen evictable sigue siendo `tool_use_recorded` y `progress_note_recorded`.
265
- RUNTIME_OUTBOX_PROTECTED="checkpoint_created verdict_reported slice_submitted handoff_recorded telemetry_gap branch_drift slice_archived wiring_checklist_seeded wiring_item_updated escalation_raised scaffold_confirmed design_source_declared project_kind_declared foundation_declared harness_phase_changed front_integration_reported"
264
+ # mintiendo. El volumen evictable es `progress_noted` (y su alias 0.10.x).
265
+ # [#38] Los hechos máquina de proyecto viajan como UN solo tipo del catálogo v2
266
+ # (`project_fact_updated`); los cinco alias legados (`scaffold_confirmed`,
267
+ # `design_source_declared`, `project_kind_declared`, `foundation_declared`,
268
+ # `harness_phase_changed`) ya no se encolan y salieron de la lista.
269
+ # [#44] La lista lleva los NOMBRES DEL CATÁLOGO v2 del hub (event_catalog.SCHEMAS):
270
+ # proteger de la evicción un tipo que el hub va a rechazar era absurdo. Salen
271
+ # `branch_drift` y `front_integration_reported` (no existen en el catálogo; el despacho
272
+ # los aparta a rejected/). Los tres alias 0.10.x del final (`checkpoint_created`,
273
+ # `verdict_reported`, `escalation_raised`) siguen protegidos SOLO porque la capa de
274
+ # compat del despacho los traduce y entrega — evictarlos perdería hechos entregables.
275
+ RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated checkpoint_created verdict_reported escalation_raised"
266
276
 
267
277
  runtime_outbox_dir() {
268
278
  echo "$(config_root)/.claude/state/outbox"
@@ -301,9 +311,12 @@ PY
301
311
  }
302
312
 
303
313
  # runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
304
- # (más viejos primero); jamás descarta checkpoint/verdict/submit/handoff/telemetry_gap/
305
- # branch_drift. Si hubo descartes, encola un telemetry_gap describiéndolos. Imprime en
306
- # stdout el conteo de descartados (última línea).
314
+ # (más viejos primero); jamás descarta un tipo de RUNTIME_OUTBOX_PROTECTED. Si hubo
315
+ # descartes, encola un telemetry_gap con el payload EXACTO del catálogo v2 [#44]
316
+ # (TelemetryGap: {dropped>=1, window_h>0, reason<=2000}; extra="forbid" el
317
+ # {dropped_count, dropped_types} de 0.10.x era rechazo garantizado): `dropped` es el
318
+ # conteo, `window_h` la ventana de la cota en horas y `reason` el motivo con los tipos
319
+ # descartados. Imprime en stdout el conteo de descartados (última línea).
307
320
  runtime_outbox_enforce_cap() {
308
321
  local dir; dir="$(runtime_outbox_dir)"
309
322
  [ -d "$dir" ] || { echo 0; return 0; }
@@ -360,19 +373,33 @@ if dropped:
360
373
  existing_gap_path=p
361
374
  existing_gap=d
362
375
  break
376
+ window_h=max_age/3600.0
377
+ def resumen(tipos):
378
+ counts={}
379
+ for t in tipos:
380
+ k=t or "desconocido"
381
+ counts[k]=counts.get(k,0)+1
382
+ return ", ".join("%s x%d" % kv for kv in sorted(counts.items()))
383
+ encabezado="evicción por cota de la outbox (%d bytes / %.0f h)" % (max_bytes, window_h)
363
384
  if existing_gap_path:
364
385
  payload=existing_gap.get("payload") or {}
365
- payload["dropped_count"]=payload.get("dropped_count",0)+len(dropped)
366
- payload["dropped_types"]=payload.get("dropped_types",[])+dropped
386
+ # Compat 0.10.x: un gap ya encolado puede traer {dropped_count, dropped_types};
387
+ # se normaliza aquí al payload del catálogo antes de acumular.
388
+ prev_n=payload.get("dropped", payload.get("dropped_count", 0)) or 0
389
+ prev_types=payload.get("dropped_types") or []
390
+ prev_reason=payload.get("reason") if isinstance(payload.get("reason"),str) else ""
391
+ base=prev_reason or (("%s: %s" % (encabezado, resumen(prev_types))) if prev_types else "")
392
+ texto=("%s; %s" % (base, resumen(dropped))) if base else ("%s: %s" % (encabezado, resumen(dropped)))
367
393
  gap=existing_gap
368
- gap["payload"]=payload
394
+ gap["payload"]={"dropped": prev_n+len(dropped), "window_h": window_h, "reason": texto[:2000]}
369
395
  fname=existing_gap_path
370
396
  fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
371
397
  else:
372
398
  cid=str(uuid.uuid4())
373
399
  ts=datetime.datetime.now(datetime.timezone.utc)
374
400
  gap={"client_event_id":cid,"type":"telemetry_gap",
375
- "payload":{"dropped_count":len(dropped),"dropped_types":dropped},
401
+ "payload":{"dropped":len(dropped),"window_h":window_h,
402
+ "reason":("%s: %s" % (encabezado, resumen(dropped)))[:2000]},
376
403
  "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
377
404
  fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
378
405
  fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
@@ -416,44 +443,177 @@ runtime_dispatch_outbox() {
416
443
  while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
417
444
  [ ${#files[@]} -eq 0 ] && return 0
418
445
 
446
+ # [#37] El hub (`EventBatchIn`) exige el sobre {"events": [...]} — el array pelado era
447
+ # 422 en cada intento y la cola no drenaba jamás. Cada elemento va con `event_type`
448
+ # (`EventIn`); la clave interna de la outbox sigue siendo `type` y se traduce AQUÍ, al
449
+ # armar el cuerpo: los ficheros ya encolados en consumidores (0.10.x, clave `type`)
450
+ # drenan sin migración y el formato en disco no cambia (lo leen otros clientes).
451
+ # [#44] En este MISMO punto de salida vive la capa de compat de nombres/payloads: los
452
+ # productores ya encolan los nombres del catálogo v2, pero un consumidor 0.10.x dejó
453
+ # ficheros con los nombres viejos (`verdict_reported`, `checkpoint_created`,
454
+ # `escalation_raised`, `progress_note_recorded`) y payloads que `extra="forbid"`
455
+ # rechazaría — se traducen tipo Y claves al armar el cuerpo. Los tipos SIN equivalente
456
+ # en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se
457
+ # apartan localmente a rejected/ sin gastar red: mandarlos era rechazo garantizado.
419
458
  local batch
420
- batch="$(python3 - "${files[@]}" <<'PY'
421
- import json,sys
459
+ batch="$(python3 - "$dir/rejected" "${files[@]}" <<'PY'
460
+ import json,sys,os
461
+ rejected_dir=sys.argv[1]
462
+ SIN_EQUIVALENTE={"tool_use_recorded","branch_drift","front_integration_reported"}
463
+ def traducir(t,p,slice_id):
464
+ p=dict(p)
465
+ if t=="verdict_reported":
466
+ # Un veredicto de RELEASE 0.10.x (payload con release_line, sin slice) no tiene
467
+ # camino por /events: se deja pasar tal cual y el hub lo rechaza con razón — la
468
+ # forma de slice se traduce a gate_verdict {gate, verdict, evidence…}.
469
+ t="gate_verdict"
470
+ if "status" in p and "verdict" not in p:
471
+ p["verdict"]=p.pop("status")
472
+ v=p.get("verdict")
473
+ if isinstance(v,str) and v.lower() in ("pass","fail"):
474
+ p["verdict"]=v.upper()
475
+ p={k:v for k,v in p.items() if k in ("gate","verdict","evidence","cause","details_ref")}
476
+ elif t=="checkpoint_created":
477
+ t="checkpoint_recorded"
478
+ if "note" in p and "summary" not in p:
479
+ p["summary"]=p.pop("note")
480
+ sid=p.pop("slice_id",None)
481
+ if sid and not slice_id:
482
+ slice_id=sid
483
+ p={k:v for k,v in p.items() if k in ("branch","commit_sha","summary")}
484
+ elif t=="escalation_raised":
485
+ t="slice_escalated"
486
+ pref=""
487
+ if p.get("gate"): pref+="[gate: %s] " % p["gate"]
488
+ if p.get("phase"): pref+="[fase: %s] " % p["phase"]
489
+ cause=p.get("cause") or (pref+str(p.get("reason") or ""))
490
+ p={"cause": (cause.strip() or "escalada sin causa registrada")[:2000]}
491
+ elif t=="progress_note_recorded":
492
+ t="progress_noted"
493
+ p={k:v for k,v in p.items() if k=="note"}
494
+ elif t in ("slice_submitted","slice_archived"):
495
+ p={} # el catálogo no admite ningún campo (extra="forbid")
496
+ elif t=="wiring_checklist_seeded":
497
+ items=[]
498
+ for it in p.get("items") or []:
499
+ if isinstance(it,dict) and it.get("item_id"):
500
+ items.append({"item_id":it["item_id"],"kind":it.get("kind") or "","ref":it.get("ref") or ""})
501
+ p={"items":items}
502
+ elif t=="telemetry_gap" and "dropped" not in p and "dropped_count" in p:
503
+ tipos=p.get("dropped_types") or []
504
+ p={"dropped":p.get("dropped_count") or 0,"window_h":72.0,
505
+ "reason":("evicción por cota de la outbox del cliente; tipos descartados: "
506
+ +", ".join(str(x) for x in tipos))[:2000]}
507
+ return t,p,slice_id
422
508
  items=[]
423
- for p in sys.argv[1:]:
424
- try: items.append(json.load(open(p)))
425
- except Exception: pass
426
- print(json.dumps(items))
509
+ for path in sys.argv[2:]:
510
+ try:
511
+ d=json.load(open(path))
512
+ except Exception:
513
+ continue
514
+ t=d.get("event_type") or d.get("type") or ""
515
+ if t in SIN_EQUIVALENTE:
516
+ try:
517
+ os.makedirs(rejected_dir,exist_ok=True)
518
+ os.replace(path,os.path.join(rejected_dir,os.path.basename(path)))
519
+ print("⚠ %r no existe en el catálogo v2 del hub: apartado a rejected/, no se enviará (issue #44)"
520
+ % t, file=sys.stderr)
521
+ except OSError:
522
+ pass
523
+ continue
524
+ t,p,slice_id=traducir(t,d.get("payload") or {},d.get("slice_id"))
525
+ ev={"client_event_id": d.get("client_event_id") or "",
526
+ "event_type": t,
527
+ "payload": p}
528
+ if slice_id:
529
+ ev["slice_id"]=slice_id
530
+ items.append(ev)
531
+ print(json.dumps({"events": items}, ensure_ascii=False))
427
532
  PY
428
533
  )"
429
534
 
535
+ # Si todo lo pendiente eran tipos sin equivalente (ya apartados), no hay nada que enviar.
536
+ local n_items
537
+ n_items="$(printf '%s' "$batch" | python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("events") or []))' 2>/dev/null || echo 0)"
538
+ if [ "$n_items" = "0" ]; then
539
+ rm -f "$state_file"
540
+ return 0
541
+ fi
542
+
430
543
  local resp status
431
544
  resp="$(runtime_post "/events" "$batch")"
432
545
  status="$(runtime_http_status)"
433
546
 
434
547
  if [ "$status" = "200" ]; then
435
- python3 - "$resp" "${files[@]}" <<'PY'
548
+ # [#37] Ack REAL del hub: {accepted:[cid], duplicates:[cid], rejected:[{client_event_id,
549
+ # reason}]}. Se conserva la lectura de la forma antigua {"results":[…]} (stubs/beta).
550
+ # Aceptado o duplicado => entregado, se borra. Rechazado POR ELEMENTO (fuera de catálogo
551
+ # o payload inválido) => reenviarlo es inútil: se aparta a rejected/ con su razón en
552
+ # stderr. Sin mención => se conserva para el próximo despacho (nunca se asume aceptado
553
+ # en silencio: perder un checkpoint/verdict/submit sin confirmación real viola la cota).
554
+ python3 - "$resp" "$dir/rejected" "${files[@]}" <<'PY'
436
555
  import json,sys,os
437
556
  resp=json.loads(sys.argv[1])
438
- paths=sys.argv[2:]
439
- results={r.get("client_event_id"):r.get("status") for r in resp.get("results",[])}
557
+ rejected_dir=sys.argv[2]
558
+ paths=sys.argv[3:]
559
+ status={}
560
+ reasons={}
561
+ if isinstance(resp,dict) and "results" in resp:
562
+ for r in resp.get("results") or []:
563
+ status[r.get("client_event_id")]=r.get("status")
564
+ elif isinstance(resp,dict):
565
+ for cid in resp.get("accepted") or []:
566
+ status[cid]="accepted"
567
+ for cid in resp.get("duplicates") or []:
568
+ status[cid]="accepted"
569
+ for r in resp.get("rejected") or []:
570
+ cid=r.get("client_event_id")
571
+ status[cid]="rejected"
572
+ reasons[cid]=r.get("reason") or "sin razón"
440
573
  for p in paths:
441
574
  try:
442
575
  cid=json.load(open(p)).get("client_event_id")
443
576
  except Exception:
444
577
  continue
445
- # Sin mención en `results` => se conserva para el próximo despacho (nunca se asume
446
- # aceptado en silencio: perder un checkpoint/verdict/submit sin confirmación real
447
- # viola el contrato de la cota, Task 8).
448
- st=results.get(cid)
449
- if st in ("accepted","rejected"):
578
+ st=status.get(cid)
579
+ if st=="accepted":
450
580
  try: os.unlink(p)
451
581
  except OSError: pass
582
+ elif st=="rejected":
583
+ print("⚠ el hub rechazó el evento %s: %s — apartado a rejected/, no se reenviará"
584
+ % (cid, reasons.get(cid,"sin razón")), file=sys.stderr)
585
+ try:
586
+ os.makedirs(rejected_dir,exist_ok=True)
587
+ os.replace(p, os.path.join(rejected_dir, os.path.basename(p)))
588
+ except OSError:
589
+ pass
452
590
  PY
453
591
  rm -f "$state_file"
454
592
  return 0
455
593
  fi
456
594
 
595
+ # [#37] Un 4xx del LOTE (salvo 408/429) es un rechazo del contrato, no red caída:
596
+ # reintentarlo para siempre solo repite el mismo error y el usuario cree que reportó.
597
+ # Se loggea el cuerpo, el lote entero se aparta a rejected/ y NO se agenda backoff.
598
+ # 408 (timeout) y 429 (rate-limit) siguen siendo transitorios, igual que 5xx y 000.
599
+ case "$status" in
600
+ 408|429) : ;;
601
+ 4*)
602
+ local rejected_dir f
603
+ rejected_dir="$dir/rejected"
604
+ mkdir -p "$rejected_dir"
605
+ echo "⛔ el hub rechazó el lote de eventos (HTTP $status): $resp" >&2
606
+ echo " ${#files[@]} evento(s) movidos a $rejected_dir/ — NO se reintentarán." >&2
607
+ echo " Revisa el contrato del cliente (docs/runtime/protocolo-cliente-runtime.md) o" >&2
608
+ echo " actualiza el arnés; el trabajo local sigue (fail-open)." >&2
609
+ for f in "${files[@]}"; do
610
+ mv "$f" "$rejected_dir/" 2>/dev/null
611
+ done
612
+ rm -f "$state_file"
613
+ return 1
614
+ ;;
615
+ esac
616
+
457
617
  local schedule idx max_idx delay
458
618
  read -ra schedule <<< "$RUNTIME_OUTBOX_BACKOFF_SCHEDULE"
459
619
  idx=$attempt