@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.
@@ -24,7 +24,10 @@ OPS_RC_NO_WORK=7
24
24
 
25
25
  # Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
26
26
  # dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.
27
- OPS_MAX_TEXT=4000
27
+ # [#44] El límite real del hub es 2000 (event_catalog._MAX_NOTE y VerdictIn.evidence):
28
+ # 1970 + los 27 caracteres del marcador de truncado de ops_json_str_file queda por debajo.
29
+ # El 4000 anterior producía rechazos en evidencias largas.
30
+ OPS_MAX_TEXT=1970
28
31
 
29
32
  # Gates canónicos (se validan en el cliente: un nombre inventado por el modelo no llega
30
33
  # a la red — compuerta mecánica barata, sin round-trip).
@@ -91,20 +94,23 @@ ops_tmpjson() {
91
94
  # ops_context_hashes — sha256 EFECTIVO local de cada archivo gobernado del lock
92
95
  # (protocolo §3 regla 3: el servidor detecta drift/manipulación local comparándolos con los
93
96
  # suyos). Se hashea el fichero real, NUNCA se repite el sha del lock: repetirlo convertiría
94
- # el reporte en un espejo del servidor y el drift sería indetectable. Fichero ausente => null.
97
+ # el reporte en un espejo del servidor y el drift sería indetectable.
98
+ # Forma del reporte [#36]: el hub (`TasksNextIn.context_hashes: dict[str, str] | None`)
99
+ # exige un DICT {path: sha} — la lista [{"path":…,"sha256":…}] era 422 dict_type en TODO
100
+ # claim. Fallback {} (nunca []); fichero ilegible => "" (el dict no admite null en valores).
95
101
  ops_context_hashes() {
96
102
  local lock root
97
103
  lock="$(runtime_lock_path)"
98
104
  root="$(config_root)"
99
- command -v python3 >/dev/null 2>&1 || { echo "[]"; return; }
100
- python3 - "$lock" "$root" <<'PY' 2>/dev/null || echo "[]"
105
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
106
+ python3 - "$lock" "$root" <<'PY' 2>/dev/null || echo "{}"
101
107
  import json,sys,os,hashlib
102
108
  lock,root=sys.argv[1],sys.argv[2]
103
109
  try:
104
110
  files=json.load(open(lock)).get("files") or []
105
111
  except Exception:
106
112
  files=[]
107
- out=[]
113
+ out={}
108
114
  for f in files:
109
115
  if not isinstance(f,dict):
110
116
  continue
@@ -112,7 +118,7 @@ for f in files:
112
118
  if not isinstance(p,str) or not p:
113
119
  continue
114
120
  full=os.path.join(root,".claude",p)
115
- sha=None
121
+ sha=""
116
122
  try:
117
123
  h=hashlib.sha256()
118
124
  with open(full,"rb") as fh:
@@ -120,8 +126,8 @@ for f in files:
120
126
  h.update(chunk)
121
127
  sha=h.hexdigest()
122
128
  except Exception:
123
- sha=None
124
- out.append({"path":p,"sha256":sha})
129
+ sha=""
130
+ out[p]=sha
125
131
  print(json.dumps(out,ensure_ascii=False))
126
132
  PY
127
133
  }
File without changes
@@ -35,7 +35,7 @@ _rel_evidence_json() {
35
35
 
36
36
  # ── verdict ──────────────────────────────────────────────────────────────────────────
37
37
  cmd_verdict() {
38
- local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile
38
+ local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile rel_status closed_by
39
39
  if [ -z "$line" ] || [ -z "$gate" ] || [ -z "$estado" ]; then usage; return $OPS_RC_USAGE; fi
40
40
  shift 3
41
41
  while [ $# -gt 0 ]; do
@@ -57,7 +57,7 @@ cmd_verdict() {
57
57
  esac
58
58
  mode="$(ops_mode)"
59
59
  [ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en releases[] del fichero"; return $OPS_RC_LEGACY; }
60
- payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
60
+ payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"verdict\":$(ops_json_str "$(printf %s "$estado" | tr a-z A-Z)"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
61
61
  if [ "$mode" = "dual" ]; then
62
62
  pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
63
63
  ops_mirror release_verdict_reported "$pfile" || echo "⚠ veredicto de release no espejado; el comparador dual lo marcará" >&2
@@ -69,7 +69,30 @@ cmd_verdict() {
69
69
  case "$status" in
70
70
  200|201|202)
71
71
  echo "veredicto de release registrado: $gate=$estado en $line"
72
- echo "El cierre de la release NO lo hace el agente: lo hace un humano en la consola (spec §2)."
72
+ # [A-con-B] El hub auto-cierra la release cuando el ÚLTIMO veredicto de cada uno
73
+ # de los 6 gates canónicos es PASS (closed_by=AUTO_6_OF_6_PASS, queda en el audit
74
+ # trail). Con algún fail/partial el cierre sigue siendo decisión HUMANA en la
75
+ # consola. Fail-open: un hub previo sin release_status cae al recordatorio.
76
+ rel_status="$(printf '%s' "$resp" | python3 -c 'import json,sys
77
+ try:
78
+ print(json.load(sys.stdin).get("release_status") or "")
79
+ except Exception:
80
+ print("")' 2>/dev/null)"
81
+ closed_by="$(printf '%s' "$resp" | python3 -c 'import json,sys
82
+ try:
83
+ print(json.load(sys.stdin).get("closed_by") or "")
84
+ except Exception:
85
+ print("")' 2>/dev/null)"
86
+ case "$rel_status" in
87
+ PASSED)
88
+ echo "✅ release «${line}» CERRADA por el hub: 6/6 gates PASS (closed_by: ${closed_by:-AUTO_6_OF_6_PASS})." ;;
89
+ FAILED)
90
+ echo "⛔ release «${line}» en FAILED en el hub; los hallazgos bloqueantes se corrigen como slice y se re-mide." ;;
91
+ IN_PROGRESS)
92
+ echo "release aún IN_PROGRESS: el hub auto-cierra al llegar el 6/6 PASS; con algún fail/partial el cierre es decisión humana en la consola (spec §2)." ;;
93
+ *)
94
+ echo "El hub auto-cierra con 6/6 PASS; si algún gate queda en fail, el cierre/decisión es humano en la consola (spec §2)." ;;
95
+ esac
73
96
  return $OPS_RC_OK ;;
74
97
  422)
75
98
  echo "⛔ 422 el servidor rechazó el veredicto: $resp — no reintentes; corrige y vuelve a medir." >&2
@@ -78,11 +101,14 @@ cmd_verdict() {
78
101
  echo "⛔ token sin permiso para reportar veredictos de release ($status): habla con el ADMIN." >&2
79
102
  return $OPS_RC_REJECTED ;;
80
103
  *)
81
- pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
82
- # Sin slice_id: un veredicto de release es de ámbito release/proyecto, no de slice.
83
- ops_enqueue_from_file verdict_reported "$pfile" ""
84
- rm -f "$pfile"
85
- echo "OFFLINE: veredicto de release encolado (tipo protegido)"
104
+ # [#44] SIN fallback offline: `release_gate_verdict` es del agregado `release` y
105
+ # POST /events solo ingiere ámbito slice (slice_id obligatorio y slice existente,
106
+ # progress_service._apply_and_append) o proyecto — el evento encolado era rechazo
107
+ # garantizado al drenar y el veredicto jamás llegaba. Mismo patrón que
108
+ # propose-asset: rc 5 y reintento explícito al reconectar.
109
+ echo "OFFLINE: no se pudo reportar el veredicto de release (status $status) y este acto" >&2
110
+ echo " no tiene camino por la cola offline. Reintenta cuando haya red:" >&2
111
+ echo " release-ops.sh verdict $line $gate $estado" >&2
86
112
  return $OPS_RC_OFFLINE ;;
87
113
  esac
88
114
  }
@@ -93,11 +119,13 @@ cmd_close_hint() {
93
119
  local line="${1:-}"
94
120
  [ -n "$line" ] || { usage; return $OPS_RC_USAGE; }
95
121
  cat <<HINT
96
- El cierre de la release «${line}» es un acto de GOBIERNO HUMANO (spec §2):
122
+ Política de cierre de la release «${line}» (A-con-B):
97
123
  1. El agente reporta los 6 veredictos con \`release-ops.sh verdict $line <gate> <estado>\`.
98
- 2. El runtime agrega: parciales NO promueven a passed.
99
- 3. Una persona con permiso cierra la línea en la consola del hub.
100
- El arnés no cierra releases ni con token de agente: esa superficie exige PDP.
124
+ 2. El runtime agrega y AUTO-CIERRA en verde: con el último veredicto de los 6 gates
125
+ canónicos en PASS la release pasa a PASSED (closed_by=AUTO_6_OF_6_PASS, audit trail).
126
+ 3. Los casos DEGRADADOS (algún fail/partial, deudas por decidir) siguen siendo gobierno
127
+ humano: una persona con permiso decide y cierra en la consola del hub (PDP).
128
+ Parciales no promueven; el gobierno humano vive donde hay decisión real, no en el clic.
101
129
  HINT
102
130
  return $OPS_RC_OK
103
131
  }
@@ -145,10 +173,14 @@ cmd_front_integration() {
145
173
  422) echo "⛔ 422 el servidor rechazó el reporte: $resp" >&2; return $OPS_RC_REJECTED ;;
146
174
  401|403) echo "⛔ token sin permiso ($status): habla con el ADMIN." >&2; return $OPS_RC_REJECTED ;;
147
175
  *)
148
- pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
149
- ops_enqueue_from_file front_integration_reported "$pfile" ""
150
- rm -f "$pfile"
151
- echo "OFFLINE: reporte de integración encolado"
176
+ # [#44] SIN fallback offline: `front_integration_reported` NO existe en el catálogo
177
+ # v2 del hub (el hecho del catálogo, `front_member_integrated`, es del agregado
178
+ # `front` y POST /events no lo ingiere) y la superficie síncrona de integración es
179
+ # de gobierno humano (`can_govern_front`). Encolarlo era rechazo garantizado: rc 5
180
+ # y reintento explícito al reconectar (mismo patrón que propose-asset).
181
+ echo "OFFLINE: no se pudo reportar la integración (status $status) y este acto no" >&2
182
+ echo " tiene camino por la cola offline. Reintenta cuando haya red:" >&2
183
+ echo " release-ops.sh front-integration $front --merge-status $merge${resmoke:+ --resmoke $resmoke}" >&2
152
184
  return $OPS_RC_OFFLINE ;;
153
185
  esac
154
186
  }
@@ -21,7 +21,8 @@ usage() {
21
21
  uso: slice-ops.sh <subcomando> [opciones]
22
22
 
23
23
  mode imprime legacy|dual|runtime
24
- claim [--epic EP-XXX] reclama trabajo (solo modo runtime)
24
+ claim reclama trabajo (solo modo runtime; el hub
25
+ reparte por orden de cola — no hay --epic)
25
26
  next-step deriva la siguiente acción desde la caché
26
27
  gate <nombre> <pass|fail|na> [--evidence «texto» | --evidence-file F]
27
28
  reporta un veredicto del slice (hecho medido)
@@ -90,13 +91,13 @@ if cp:
90
91
  PY
91
92
  }
92
93
 
93
- # _claim_once <epic> — una llamada. rc 20 = reintentable (drift|contention), resto = final.
94
+ # _claim_once — una llamada. rc 20 = reintentable (drift|contention), resto = final.
95
+ # [#39] El cuerpo lleva SOLO context_hashes: `TasksNextIn` del hub no declara epic_code
96
+ # (pydantic lo ignoraba en silencio y la cola repartía OTRA épica con claim "exitoso").
94
97
  _claim_once() {
95
- local epic="$1" hashes body resp status reason
98
+ local hashes body resp status reason
96
99
  hashes="$(ops_context_hashes)"
97
- body="{\"context_hashes\":$hashes"
98
- [ -n "$epic" ] && body="$body,\"epic_code\":$(ops_json_str "$epic")"
99
- body="$body}"
100
+ body="{\"context_hashes\":$hashes}"
100
101
  resp="$(runtime_post "$OPS_PATH_CLAIM" "$body")"
101
102
  status="$(runtime_http_status)"
102
103
  case "$status" in
@@ -161,12 +162,26 @@ cmd_claim() {
161
162
  echo "Abre el slice en local y reporta cada transición con los demás subcomandos (espejo)."
162
163
  return $OPS_RC_LEGACY
163
164
  fi
165
+ # [#39] El hub NO soporta el claim dirigido: `TasksNextIn` solo declara context_hashes
166
+ # y un epic_code en el cuerpo se ignoraría en silencio — en el piloto un
167
+ # `claim --epic EP-040` devolvió un claim "exitoso" de EP-001 y dejó un lease que hubo
168
+ # que liberar desde la consola. Fallo EXPLÍCITO antes de tocar la red: jamás un claim
169
+ # que devuelva otra épica distinta de la pedida sin error.
170
+ if [ -n "$epic" ]; then
171
+ echo "⛔ claim --epic no existe en el protocolo del hub: POST /tasks/next no acepta" >&2
172
+ echo " epic_code y el servidor repartiría la PRIMERA épica de su cola como si fuera" >&2
173
+ echo " la pedida (dejando un lease huérfano que solo la consola admin libera)." >&2
174
+ echo " Protocolo real: el hub reparte por orden de cola — reclama sin --epic y trabaja" >&2
175
+ echo " lo que entregue. Para dirigir una épica concreta: termina el slice en modo dual," >&2
176
+ echo " pide al ADMIN apagar el espejo (cutover) y reclama del hub lo que la cola sirva." >&2
177
+ return $OPS_RC_USAGE
178
+ fi
164
179
  # Contexto primero (protocolo §3 regla 1). context-sync.sh es fail-open: si no puede,
165
180
  # sale 0 y deja el lock anterior — el claim seguirá y el servidor detectará el drift.
166
181
  expected="$(projection_get context.manifest_hash "")"
167
182
  [ -n "$expected" ] || expected="$(runtime_field manifest_hash)"
168
183
  bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
169
- _claim_once "$epic"
184
+ _claim_once
170
185
  rc=$?
171
186
  if [ $rc -eq 20 ]; then
172
187
  # Drift o contención: reintentar EXACTAMENTE una vez (protocolo §7). Pasar "" a
@@ -180,7 +195,7 @@ cmd_claim() {
180
195
  agent_context_fetch_and_cache >/dev/null 2>&1
181
196
  expected="$(projection_get context.manifest_hash "")"
182
197
  bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
183
- _claim_once "$epic"
198
+ _claim_once
184
199
  rc=$?
185
200
  if [ $rc -eq 20 ]; then
186
201
  echo "otro agente tomó la tarea; pide la siguiente"
@@ -235,11 +250,14 @@ PY
235
250
  }
236
251
 
237
252
  # ── fact ─────────────────────────────────────────────────────────────────────────────
238
- # Hechos de PROYECTO (spec §4.1-B): dos ya persisten en el runtime con superficie admin
239
- # (`scaffold_confirmed`, `design_source_*`) y ganan evento + proyección; tres se crean
240
- # completos (`project_kind`, `foundation`, `harness_phase`). Todos son de ámbito proyecto:
241
- # se encolan SIN `slice_id` (el servidor los admite así, spec §3.1 rev. 3).
242
- # La CONFIRMACIÓN sigue siendo humana: este subcomando REPORTA lo que el humano confirmó.
253
+ # Hechos de PROYECTO, contrato del catálogo v2 del hub [#38] (project_facts_domain):
254
+ # - Hechos MÁQUINA (`project_kind`, `harness_phase`, `foundation`): UN solo tipo,
255
+ # `project_fact_updated`, con el payload EXACTO {fact, value, source:"AGENT"}
256
+ # (extra="forbid": ninguna otra clave viaja). Ámbito proyecto: sin `slice_id`.
257
+ # - Hechos HUMANOS (`scaffold_confirmed`, `design_source_*`): el hub los rechaza en TODA
258
+ # superficie de agente — se fijan tras el PDP por la superficie admin
259
+ # (PATCH /orchestrator/projects/{project_id}). En runtime/dual NO se encola: rechazo
260
+ # local claro que dirige al humano a la consola del hub. En legacy nada cambia.
243
261
  cmd_fact() {
244
262
  local hecho="${1:-}" mode
245
263
  [ -n "$hecho" ] && shift
@@ -282,50 +300,45 @@ _ops_emit_event() {
282
300
  return $OPS_RC_OK
283
301
  }
284
302
 
285
- # _fact_emit <tipo> <payload_json> un hecho de PROYECTO (sin slice_id): mismo contrato de
286
- # _ops_emit_event, con el aviso de fallo y el mensaje de éxito que esperan las skills.
287
- _fact_emit() {
288
- local tipo="$1" payload="$2"
289
- if ! _ops_emit_event "$tipo" "$payload" ""; then
290
- echo " hecho \"$tipo\" no se pudo encolar (outbox no escribible u otro fallo local)" >&2
303
+ # _fact_human_reject <fact> — [#38] rechazo LOCAL del hecho humano en runtime/dual: el hub
304
+ # responde 422 en TODA superficie de agente (`rechazo_de_hecho_humano`); encolarlo era un
305
+ # reintento eterno de algo que jamás iba a entrar. El humano lo fija en la consola del hub.
306
+ _fact_human_reject() {
307
+ local fact="$1"
308
+ echo " «${fact}» es un hecho HUMANO: no entra por la superficie de agente del hub." >&2
309
+ echo " Se fija tras el PDP por la superficie admin: PATCH /orchestrator/projects/{project_id}" >&2
310
+ echo " (consola del hub). Pide al ADMIN del proyecto que lo confirme allí; este subcomando" >&2
311
+ echo " no lo encola. En modo legacy sigue escribiéndose en el fichero local." >&2
312
+ return $OPS_RC_REJECTED
313
+ }
314
+
315
+ # _fact_machine_emit <fact> <value_json> — [#38] hecho MÁQUINA del catálogo v2: payload
316
+ # EXACTO {fact, value, source:"AGENT"} (extra="forbid" en el hub: nada más viaja) y SIN
317
+ # slice_id (ámbito proyecto). También en dual va por la COLA (POST /events): la ingesta
318
+ # espejo solo acepta transiciones de slice (TIPOS_DE_ESPEJO) y rechazaría el hecho.
319
+ _fact_machine_emit() {
320
+ local fact="$1" value_json="$2" payload pfile
321
+ payload="{\"fact\":$(ops_json_str "$fact"),\"value\":$value_json,\"source\":\"AGENT\"}"
322
+ pfile="$(ops_tmpjson "$payload")" || {
323
+ echo "⚠ hecho \"$fact\" no se pudo encolar (fallo local)" >&2
324
+ return $OPS_RC_OFFLINE
325
+ }
326
+ if ! ops_enqueue_from_file project_fact_updated "$pfile" ""; then
327
+ rm -f "$pfile"
328
+ echo "⚠ hecho \"$fact\" no se pudo encolar (outbox no escribible u otro fallo local)" >&2
291
329
  return $OPS_RC_OFFLINE
292
330
  fi
293
- [ "$(ops_mode)" = "runtime" ] && echo "hecho de proyecto \"$tipo\" encolado"
331
+ rm -f "$pfile"
332
+ echo "hecho de proyecto \"$fact\" encolado (project_fact_updated)"
294
333
  return $OPS_RC_OK
295
334
  }
296
335
 
297
336
  _fact_scaffold() {
298
- local by="" notes=""
299
- while [ $# -gt 0 ]; do
300
- case "$1" in
301
- --by) by="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
302
- --notes) notes="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
303
- *) shift ;;
304
- esac
305
- done
306
- [ -n "$by" ] || { echo "⛔ falta --by: el scaffold lo confirma una PERSONA, no el modelo" >&2; return $OPS_RC_REJECTED; }
307
- _fact_emit scaffold_confirmed "{\"confirmed\":true,\"confirmed_by\":$(ops_json_str "$by"),\"notes\":$(ops_json_str "$notes")}"
337
+ _fact_human_reject scaffold_confirmed
308
338
  }
309
339
 
310
340
  _fact_design_source() {
311
- local applies="" source="" confirmed="false" by="" notes=""
312
- while [ $# -gt 0 ]; do
313
- case "$1" in
314
- --applies) applies="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
315
- --source) source="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
316
- --confirmed) confirmed="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
317
- --by) by="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
318
- --notes) notes="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
319
- *) shift ;;
320
- esac
321
- done
322
- case "$applies" in true|false) : ;; *) echo "⛔ --applies debe ser true|false" >&2; return $OPS_RC_REJECTED ;; esac
323
- case "$confirmed" in true|false) : ;; *) echo "⛔ --confirmed debe ser true|false" >&2; return $OPS_RC_REJECTED ;; esac
324
- if [ "$confirmed" = "true" ] && [ -z "$by" ]; then
325
- echo "⛔ falta --by: la fuente de diseño la confirma una PERSONA" >&2
326
- return $OPS_RC_REJECTED
327
- fi
328
- _fact_emit design_source_declared "{\"applies\":$applies,\"source\":$(ops_json_str "$source"),\"confirmed\":$confirmed,\"confirmed_by\":$(ops_json_str "$by"),\"notes\":$(ops_json_str "$notes")}"
341
+ _fact_human_reject design_source_confirmed
329
342
  }
330
343
 
331
344
  _fact_project_kind() {
@@ -338,12 +351,14 @@ _fact_project_kind() {
338
351
  esac
339
352
  done
340
353
  case "$kind" in greenfield|brownfield) : ;; *) echo "⛔ project-kind debe ser greenfield|brownfield" >&2; return $OPS_RC_REJECTED ;; esac
354
+ # --source auto|human se sigue aceptando (compat con los comandos /build:*), pero NO
355
+ # viaja: en la superficie de agente el hub solo admite source="AGENT" (progress_service).
341
356
  case "$src" in auto|human) : ;; *) echo "⛔ --source debe ser auto|human" >&2; return $OPS_RC_REJECTED ;; esac
342
- _fact_emit project_kind_declared "{\"project_kind\":$(ops_json_str "$kind"),\"source\":$(ops_json_str "$src")}"
357
+ _fact_machine_emit project_kind "$(ops_json_str "$kind")"
343
358
  }
344
359
 
345
360
  _fact_foundation() {
346
- local required="" epic="" cfile="" checklist="[]"
361
+ local required="" epic="" cfile=""
347
362
  while [ $# -gt 0 ]; do
348
363
  case "$1" in
349
364
  --required) required="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
@@ -353,22 +368,16 @@ _fact_foundation() {
353
368
  esac
354
369
  done
355
370
  case "$required" in true|false) : ;; *) echo "⛔ --required debe ser true|false" >&2; return $OPS_RC_REJECTED ;; esac
356
- if [ -n "$cfile" ] && [ -f "$cfile" ] && command -v python3 >/dev/null 2>&1; then
357
- checklist="$(python3 -c '
358
- import json,sys
359
- try:
360
- d=json.load(open(sys.argv[1]))
361
- except Exception:
362
- d=[]
363
- print(json.dumps(d if isinstance(d,list) else [], ensure_ascii=False))' "$cfile" 2>/dev/null || echo "[]")"
364
- fi
365
- _fact_emit foundation_declared "{\"required\":$required,\"epic\":$(ops_json_str "$epic"),\"checklist\":$checklist}"
371
+ # El hub proyecta `foundation` a un Boolean (`foundation_confirmed`): el value es el
372
+ # booleano de --required. --epic y --checklist-file se aceptan por compat con
373
+ # /build:onboard pero NO viajan (extra="forbid"): la checklist queda en los docs locales.
374
+ _fact_machine_emit foundation "$required"
366
375
  }
367
376
 
368
377
  _fact_harness_phase() {
369
378
  local fase="${1:-}"
370
379
  case "$fase" in authoring|building) : ;; *) echo "⛔ harness-phase debe ser authoring|building" >&2; return $OPS_RC_REJECTED ;; esac
371
- _fact_emit harness_phase_changed "{\"harness_phase\":$(ops_json_str "$fase")}"
380
+ _fact_machine_emit harness_phase "$(ops_json_str "$fase")"
372
381
  }
373
382
 
374
383
  # ── propose-asset ────────────────────────────────────────────────────────────────────
@@ -482,7 +491,7 @@ _ops_evidence_json() {
482
491
  # Reportar un bloqueo es acto de agente; DECIDIR qué hacer con él es humano (spec §2).
483
492
  # El evento deja el rastro en el runtime; la resolución se pide por el canal humano.
484
493
  # La nota de gobierno SOLO se imprime si el evento de verdad quedó registrado (mismo patrón
485
- # de fallo que _fact_emit): un agente jamás debe creer que escaló algo que se perdió.
494
+ # de fallo que _fact_machine_emit): un agente jamás debe creer que escaló algo que se perdió.
486
495
  cmd_escalate() {
487
496
  local razon="" file="" gate="" mode sid payload
488
497
  while [ $# -gt 0 ]; do
@@ -501,9 +510,25 @@ cmd_escalate() {
501
510
  echo "LEGACY: no hay runtime donde registrar la escalada; anótala en el PR y habla con el equipo"
502
511
  return $OPS_RC_LEGACY
503
512
  fi
504
- payload="{\"reason\":$(_ops_evidence_json "$razon" "$file"),\"gate\":$(ops_json_str "$gate"),\"phase\":$(ops_json_str "$(projection_get active_slice.phase "")")}"
513
+ # [#44] El catálogo v2 la llama `slice_escalated` y su payload es EXACTAMENTE {cause}
514
+ # (extra="forbid"): gate y fase viajan DENTRO del texto de la causa, no como claves.
515
+ local prefix="" phase tmptxt
516
+ phase="$(projection_get active_slice.phase "")"
517
+ [ -n "$gate" ] && prefix="[gate: ${gate}] "
518
+ [ -n "$phase" ] && prefix="${prefix}[fase: ${phase}] "
519
+ tmptxt="$(mktemp)" || return $OPS_RC_OFFLINE
520
+ { printf '%s' "$prefix"; if [ -n "$file" ] && [ -f "$file" ]; then cat "$file"; else printf '%s' "$razon"; fi; } > "$tmptxt"
521
+ payload="{\"cause\":$(ops_json_str_file "$tmptxt" "$OPS_MAX_TEXT")}"
522
+ rm -f "$tmptxt"
505
523
  sid="$(ops_slice_id)"
506
- if ! _ops_emit_event escalation_raised "$payload" "$sid"; then
524
+ # slice_escalated es de ámbito slice (slice_id obligatorio): sin slice el hub lo
525
+ # rechazaría elemento a elemento — mejor un fallo local accionable que ruido en rejected/.
526
+ if [ "$mode" = "runtime" ] && [ -z "$sid" ]; then
527
+ echo "⛔ sin slice activo: slice_escalated exige slice_id (catálogo v2) y el hub rechazaría el evento." >&2
528
+ echo " Reclama trabajo primero o reporta el bloqueo por el PR / la consola del hub." >&2
529
+ return $OPS_RC_NO_SLICE
530
+ fi
531
+ if ! _ops_emit_event slice_escalated "$payload" "$sid"; then
507
532
  echo "⚠ la escalada no se pudo registrar (outbox no escribible u otro fallo local): repórtala por el PR mientras tanto" >&2
508
533
  return $OPS_RC_OFFLINE
509
534
  fi
@@ -559,11 +584,14 @@ _ops_require_slice() {
559
584
  return $OPS_RC_OK
560
585
  }
561
586
 
562
- # _ops_post_or_queue <path> <payload> <tipo_evento> <slice_id> <etiqueta>
587
+ # _ops_post_or_queue <path> <payload> <tipo_evento> <slice_id> <etiqueta> [queue_payload]
563
588
  # Camino común de los actos primarios: POST y, si el servidor no está, encolar el hecho.
564
589
  # Rechazo del servidor (409/422/401/403) => NUNCA se encola (no se reintenta lo rechazado).
590
+ # [#44] `queue_payload` (opcional, default = payload): el cuerpo del endpoint síncrono y
591
+ # el payload del catálogo pueden diferir (p.ej. el checkpoint lleva slice_id en el body
592
+ # de POST /checkpoints, pero el catálogo lo prohíbe dentro del payload del evento).
565
593
  _ops_post_or_queue() {
566
- local path="$1" payload="$2" tipo="$3" sid="$4" etiqueta="$5" resp status pfile
594
+ local path="$1" payload="$2" tipo="$3" sid="$4" etiqueta="$5" queue_payload="${6:-$2}" resp status pfile
567
595
  resp="$(runtime_post "$path" "$payload")"
568
596
  status="$(runtime_http_status)"
569
597
  case "$status" in
@@ -583,7 +611,7 @@ _ops_post_or_queue() {
583
611
  echo "⛔ token inválido o revocado ($status): pide un token nuevo al ADMIN." >&2
584
612
  return $OPS_RC_REJECTED ;;
585
613
  *)
586
- pfile="$(ops_tmpjson "$payload")" || { echo "OFFLINE: no se pudo encolar $etiqueta" >&2; return $OPS_RC_OFFLINE; }
614
+ pfile="$(ops_tmpjson "$queue_payload")" || { echo "OFFLINE: no se pudo encolar $etiqueta" >&2; return $OPS_RC_OFFLINE; }
587
615
  if ops_enqueue_from_file "$tipo" "$pfile" "$sid"; then
588
616
  rm -f "$pfile"
589
617
  echo "OFFLINE: $etiqueta encolado (tipo protegido, se entrega al reconectar)"
@@ -623,14 +651,30 @@ cmd_gate() {
623
651
  esac
624
652
  mode="$(ops_mode)"
625
653
  [ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en el fichero (references/state-protocol.md)"; return $OPS_RC_LEGACY; }
654
+ # [#44] El hub no tiene veredicto «na»: cualquier verdict ≠ PASS/FAIL es bloqueo
655
+ # (domain.py, invariante 5) — emitirlo era rechazo garantizado. El gate no aplicable
656
+ # simplemente no se reporta; queda documentado en el PR.
657
+ if [ "$estado" = "na" ]; then
658
+ echo "veredicto «na» de «${gate}»: el catálogo del hub no lo admite (solo PASS/FAIL) — no se reporta."
659
+ echo "Documenta la no-aplicabilidad del gate en el PR; en el hub el gate queda sin evaluar."
660
+ return $OPS_RC_OK
661
+ fi
662
+ # [#44] Contrato real del hub: la clave es `verdict` (VerdictIn y el catálogo
663
+ # GateVerdict) y el reducer solo promueve con "PASS"/"FAIL" en MAYÚSCULA
664
+ # (domain.py:423-431) — `status:"pass"` era rechazo en el sync Y en la cola.
665
+ local verdict
666
+ case "$estado" in
667
+ pass) verdict="PASS" ;;
668
+ fail) verdict="FAIL" ;;
669
+ esac
626
670
  evjson="$(_ops_evidence_json "$ev" "$evfile")"
627
- payload="{\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"evidence\":$evjson}"
671
+ payload="{\"gate\":$(ops_json_str "$gate"),\"verdict\":$(ops_json_str "$verdict"),\"evidence\":$evjson}"
628
672
  if [ "$mode" = "dual" ]; then
629
- _ops_dual_mirror verdict_reported "$payload"
673
+ _ops_dual_mirror gate_verdict "$payload"
630
674
  return $?
631
675
  fi
632
676
  sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
633
- _ops_post_or_queue "$(ops_path_verdicts "$sid")" "$payload" verdict_reported "$sid" "el veredicto de «${gate}»"
677
+ _ops_post_or_queue "$(ops_path_verdicts "$sid")" "$payload" gate_verdict "$sid" "el veredicto de «${gate}»"
634
678
  }
635
679
 
636
680
  # ── submit ───────────────────────────────────────────────────────────────────────────
@@ -651,7 +695,10 @@ cmd_submit() {
651
695
  return $?
652
696
  fi
653
697
  sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
654
- _ops_post_or_queue "$(ops_path_submit "$sid")" "$payload" slice_submitted "$sid" "el submit del slice"
698
+ # [#44] El catálogo v2 no admite ningún campo en slice_submitted (extra="forbid"):
699
+ # el evento encolado va con payload {} — pr_url/openspec_change solo acompañan el POST
700
+ # síncrono (SubmitIn los ignora) y viven en el PR.
701
+ _ops_post_or_queue "$(ops_path_submit "$sid")" "$payload" slice_submitted "$sid" "el submit del slice" "{}"
655
702
  }
656
703
 
657
704
  # ── archive ──────────────────────────────────────────────────────────────────────────
@@ -675,8 +722,11 @@ cmd_archive() {
675
722
  _ops_dual_mirror slice_archived "$payload"
676
723
  return $?
677
724
  fi
678
- sid="$(ops_slice_id)"
679
- pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
725
+ # [#44] El catálogo v2 no admite ningún campo en slice_archived (extra="forbid") y el
726
+ # tipo es de ámbito slice (slice_id de primer nivel OBLIGATORIO): con --epic/--pr-url
727
+ # en el payload o sin slice el hub rechazaba TODO archivado — payload {} y slice exigido.
728
+ sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
729
+ pfile="$(ops_tmpjson "{}")" || return $OPS_RC_OFFLINE
680
730
  if ops_enqueue_from_file slice_archived "$pfile" "$sid"; then
681
731
  rm -f "$pfile"
682
732
  echo "slice_archived encolado (se entrega al reconectar; tipo protegido de la cota)"
@@ -717,7 +767,9 @@ _wiring_seed() {
717
767
  command -v python3 >/dev/null 2>&1 || { echo "⚠ sin python3: no se puede sembrar el checklist" >&2; return $OPS_RC_OFFLINE; }
718
768
  pfile="$(mktemp)" || return $OPS_RC_OFFLINE
719
769
  # El payload se construye leyendo el fichero DENTRO de python3 (nunca por argv) y se
720
- # normaliza: cada item nace `failing` el estado inicial no es negociable.
770
+ # normaliza al schema del catálogo v2 [#44]: WiringChecklistItem es {item_id, kind, ref}
771
+ # EXACTO (extra="forbid") — `status` no viaja (cada ítem nace `failing` en el fold del
772
+ # hub; el estado inicial no es negociable) y kind/ref nunca son null (son obligatorios).
721
773
  python3 - "$file" > "$pfile" <<'PY' 2>/dev/null
722
774
  import json,sys
723
775
  try:
@@ -733,9 +785,18 @@ for it in raw if isinstance(raw,list) else []:
733
785
  iid=it.get("item_id") or it.get("id")
734
786
  if not iid:
735
787
  continue
736
- items.append({"item_id":iid,"kind":it.get("kind"),"ref":it.get("ref"),"status":"failing"})
788
+ items.append({"item_id":iid,"kind":it.get("kind") or "","ref":it.get("ref") or ""})
737
789
  print(json.dumps({"items":items},ensure_ascii=False))
738
790
  PY
791
+ # El hub exige al menos un ítem (WiringChecklistSeeded.items min_length=1): un checklist
792
+ # vacío es rechazo garantizado — se corta aquí, sin encolar.
793
+ local n_items
794
+ n_items="$(python3 -c 'import json,sys;print(len(json.load(open(sys.argv[1])).get("items") or []))' "$pfile" 2>/dev/null || echo 0)"
795
+ if [ "$n_items" = 0 ]; then
796
+ rm -f "$pfile"
797
+ echo "⛔ el checklist no tiene items válidos (item_id obligatorio): el hub exige al menos uno" >&2
798
+ return $OPS_RC_REJECTED
799
+ fi
739
800
  mode="$(ops_mode)"
740
801
  if [ "$mode" = "dual" ]; then
741
802
  ops_mirror wiring_checklist_seeded "$pfile" || echo "⚠ siembra de wiring no espejada; el comparador dual la marcará" >&2
@@ -812,14 +873,15 @@ cmd_progress() {
812
873
  fi
813
874
  mode="$(ops_mode)"
814
875
  [ "$mode" = "legacy" ] && { echo "LEGACY: apendiza la nota en progress_log[] del fichero"; return $OPS_RC_LEGACY; }
876
+ # [#44] Nombre del catálogo v2: `progress_noted` (payload {note}, sin cambio de claves).
815
877
  payload="{\"note\":$(_ops_evidence_json "$nota" "$file")}"
816
878
  if [ "$mode" = "dual" ]; then
817
- _ops_dual_mirror progress_note_recorded "$payload"
879
+ _ops_dual_mirror progress_noted "$payload"
818
880
  return $?
819
881
  fi
820
882
  sid="$(ops_slice_id)"
821
883
  pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
822
- if ops_enqueue_from_file progress_note_recorded "$pfile" "$sid"; then
884
+ if ops_enqueue_from_file progress_noted "$pfile" "$sid"; then
823
885
  rm -f "$pfile"
824
886
  echo "nota de progreso encolada"
825
887
  return $OPS_RC_OK
@@ -846,14 +908,23 @@ cmd_checkpoint() {
846
908
  root="$(config_root)"
847
909
  branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
848
910
  commit="$(git -C "$root" rev-parse HEAD 2>/dev/null || echo "")"
911
+ # [#44] Contrato real del hub: la nota se llama `summary` (CheckpointIn, max 1000; el
912
+ # catálogo CheckpointRecorded también). `note` se descartaba en silencio en el sync y
913
+ # era rechazo por extra="forbid" en la cola. Tope 970 + marcador < 1000.
914
+ local sumjson
915
+ if [ -n "$file" ]; then sumjson="$(ops_json_str_file "$file" 970)"; else sumjson="$(ops_json_str "$nota")"; fi
849
916
  if [ "$mode" = "dual" ]; then
850
- payload="{\"branch\":$(ops_json_str "$branch"),\"commit_sha\":$(ops_json_str "$commit"),\"note\":$(_ops_evidence_json "$nota" "$file")}"
851
- _ops_dual_mirror checkpoint_created "$payload"
917
+ payload="{\"branch\":$(ops_json_str "$branch"),\"commit_sha\":$(ops_json_str "$commit"),\"summary\":$sumjson}"
918
+ _ops_dual_mirror checkpoint_recorded "$payload"
852
919
  return $?
853
920
  fi
854
921
  sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
855
- payload="{\"slice_id\":$(ops_json_str "$sid"),\"branch\":$(ops_json_str "$branch"),\"commit_sha\":$(ops_json_str "$commit"),\"note\":$(_ops_evidence_json "$nota" "$file")}"
856
- _ops_post_or_queue "$OPS_PATH_CHECKPOINTS" "$payload" checkpoint_created "$sid" "el checkpoint"
922
+ # El body síncrono lleva slice_id (POST /checkpoints, CheckpointIn); el payload del
923
+ # EVENTO no lo admite (extra="forbid"): el slice_id va de primer nivel en la cola.
924
+ payload="{\"slice_id\":$(ops_json_str "$sid"),\"branch\":$(ops_json_str "$branch"),\"commit_sha\":$(ops_json_str "$commit"),\"summary\":$sumjson}"
925
+ local queue_payload
926
+ queue_payload="{\"branch\":$(ops_json_str "$branch"),\"commit_sha\":$(ops_json_str "$commit"),\"summary\":$sumjson}"
927
+ _ops_post_or_queue "$OPS_PATH_CHECKPOINTS" "$payload" checkpoint_recorded "$sid" "el checkpoint" "$queue_payload"
857
928
  }
858
929
 
859
930
  SUB="${1:-}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
5
5
  "type": "module",
6
6
  "bin": {
File without changes
@@ -28,3 +28,7 @@ cedula
28
28
  Gemini
29
29
  GEMINI_API_KEY
30
30
  validacion-documental
31
+
32
+ # Piloto runtime 2026-08
33
+ Oriuno
34
+ diagramador-ia-for-process