@trycore/spec-build-harness 0.8.5 → 0.11.0

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.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +27 -4
  3. package/INSTALL.md +27 -5
  4. package/METODOLOGIA.md +55 -5
  5. package/README.md +39 -6
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +13 -5
  9. package/agents/build/wiring-adversarial-verifier.md +52 -5
  10. package/commands/build/architect.md +1 -1
  11. package/commands/build/claim.md +46 -0
  12. package/commands/build/escalate.md +36 -0
  13. package/commands/build/front.md +9 -3
  14. package/commands/build/onboard.md +75 -14
  15. package/commands/build/prototype.md +3 -2
  16. package/commands/build/reflect.md +60 -40
  17. package/commands/build/release.md +10 -7
  18. package/commands/build/resume.md +33 -13
  19. package/commands/build/slice.md +32 -27
  20. package/commands/build/status.md +35 -0
  21. package/commands/build/work.md +11 -8
  22. package/config/build-config.template.json +4 -0
  23. package/dist/cli.js +32 -0
  24. package/dist/commands/doctor.js +42 -0
  25. package/dist/commands/init.js +84 -1
  26. package/dist/commands/migrate.js +153 -0
  27. package/dist/commands/status.js +34 -0
  28. package/dist/lib/normalize.js +1123 -0
  29. package/dist/lib/paths.js +6 -0
  30. package/dist/lib/runtime-client.js +196 -0
  31. package/dist/lib/settings-merge.js +3 -3
  32. package/dist/lib/state-bundle.js +150 -0
  33. package/docs/commands.md +25 -8
  34. package/docs/getting-started.md +1 -0
  35. package/docs/hooks.md +114 -27
  36. package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
  37. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  38. package/docs/runtime/protocolo-cliente-runtime.md +120 -35
  39. package/hooks/build/build-gate-check.sh +21 -0
  40. package/hooks/build/context-monitor.sh +82 -15
  41. package/hooks/build/context-sync.sh +192 -0
  42. package/hooks/build/design-source-guard.sh +30 -2
  43. package/hooks/build/dual-compare.sh +92 -0
  44. package/hooks/build/event-emitter.sh +32 -0
  45. package/hooks/build/gitflow-guard.sh +164 -14
  46. package/hooks/build/heartbeat.sh +259 -0
  47. package/hooks/build/lib/agent-context.sh +139 -0
  48. package/hooks/build/lib/config.sh +27 -0
  49. package/hooks/build/lib/projection.sh +71 -0
  50. package/hooks/build/lib/runtime-client.sh +625 -0
  51. package/hooks/build/lib/runtime-ops.sh +227 -0
  52. package/hooks/build/lib/state-io.sh +5 -18
  53. package/hooks/build/load-build-state.sh +64 -2
  54. package/hooks/build/reflect-nudge.sh +15 -0
  55. package/hooks/build/release-gate-nudge.sh +15 -0
  56. package/hooks/build/release-ops.sh +171 -0
  57. package/hooks/build/scaffold-guard.sh +29 -2
  58. package/hooks/build/session-start.sh +103 -0
  59. package/hooks/build/session-stop.sh +22 -0
  60. package/hooks/build/slice-ops.sh +948 -0
  61. package/hooks/build/stack-guard.sh +8 -0
  62. package/hooks/build/statusline-bridge.sh +24 -3
  63. package/hooks/build-harness.json +16 -0
  64. package/package.json +3 -3
  65. package/scripts/check-agnostic.sh +3 -1
  66. package/scripts/check-pack-clean.sh +31 -0
  67. package/scripts/check-runtime-purity.sh +43 -0
  68. package/scripts/denylist.txt +4 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +181 -0
  71. package/scripts/runtime-purity-allow.txt +5 -0
  72. package/scripts/smoke-test.sh +1 -1
  73. package/scripts/tests/lib/http-stub.py +46 -0
  74. package/scripts/tests/test-baseline-verdict.sh +92 -0
  75. package/scripts/tests/test-config.sh +25 -0
  76. package/scripts/tests/test-hooks-runtime.sh +828 -0
  77. package/scripts/tests/test-install.sh +103 -0
  78. package/scripts/tests/test-runtime-client.sh +298 -0
  79. package/scripts/tests/test-schema.sh +29 -1
  80. package/scripts/tests/test-skill-ops.sh +1367 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +55 -21
  83. package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
  84. package/skills/building-a-slice/references/dod.md +12 -3
  85. package/skills/building-a-slice/references/dor.md +3 -2
  86. package/skills/building-a-slice/references/evidence-budget.md +51 -0
  87. package/skills/building-a-slice/references/exploration-fanout.md +1 -1
  88. package/skills/building-a-slice/references/gitflow.md +1 -1
  89. package/skills/building-a-slice/references/regression-baseline.md +67 -0
  90. package/skills/building-a-slice/references/runtime-protocol.md +75 -0
  91. package/skills/building-a-slice/references/state-protocol.md +12 -1
  92. package/skills/building-a-slice/workflows/README.md +7 -3
  93. package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
  94. package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
  95. package/skills/managing-parallel-front/SKILL.md +32 -16
  96. package/skills/openspec-archive-change/SKILL.md +15 -0
  97. package/skills/prototyping-screens/SKILL.md +9 -5
  98. package/skills/releasing-a-version/SKILL.md +26 -16
  99. package/skills/releasing-a-version/references/release-dod.md +7 -5
  100. package/skills/releasing-a-version/workflows/README.md +2 -1
  101. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  102. package/skills/setup-architecture/SKILL.md +4 -2
  103. package/state/README.md +16 -1
  104. package/state/build-state.schema.json +2 -1
  105. package/templates/CLAUDE.md.template +16 -0
  106. package/templates/settings-hooks.template.json +8 -4
  107. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -0,0 +1,948 @@
1
+ #!/usr/bin/env bash
2
+ # slice-ops.sh — operaciones de dominio del INNER LOOP que conducen las skills
3
+ # [EP-OR-08-C]. NO es un hook registrado (mismo estatus que context-sync.sh): lo invocan
4
+ # `building-a-slice`, los comandos /build:* y los agentes, UNA invocación por transición.
5
+ #
6
+ # uso: slice-ops.sh <subcomando> [opciones]
7
+ # subcomandos IMPLEMENTADOS (los 13 del plan): mode | claim | next-step | gate | submit |
8
+ # archive | wiring | progress | checkpoint | fact | propose-asset | status |
9
+ # escalate.
10
+ #
11
+ # Contrato con la prosa: cada subcomando devuelve un código de salida tipado
12
+ # (0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo) y
13
+ # escribe en stdout lo que la skill debe mostrar. Fail-open: el trabajo local nunca se
14
+ # bloquea por el runtime (RNF-6).
15
+ set -uo pipefail
16
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
+ source "$HERE/lib/runtime-ops.sh"
18
+
19
+ usage() {
20
+ cat >&2 <<'USAGE'
21
+ uso: slice-ops.sh <subcomando> [opciones]
22
+
23
+ mode imprime legacy|dual|runtime
24
+ claim reclama trabajo (solo modo runtime; el hub
25
+ reparte por orden de cola — no hay --epic)
26
+ next-step deriva la siguiente acción desde la caché
27
+ gate <nombre> <pass|fail|na> [--evidence «texto» | --evidence-file F]
28
+ reporta un veredicto del slice (hecho medido)
29
+ submit [--pr-url U] [--openspec-change C] entrega el slice
30
+ archive [--pr-url U] [--openspec-change C] [--epic EP-XXX]
31
+ archiva el slice (siempre por la cola)
32
+ wiring seed --file items.json siembra el checklist de cableado (failing)
33
+ wiring update <item_id> <passing|failing> [--kind K] [--ref R] [--evidence-file F]
34
+ progress «nota» | --file F nota de progreso (bitácora = event stream)
35
+ checkpoint [--note «t» | --note-file F] continuidad tras una caída
36
+ fact <scaffold-confirmed|design-source|project-kind|foundation|harness-phase> …
37
+ reporta un hecho de proyecto confirmado
38
+ propose-asset --type-key K --path P --content-file F
39
+ status [--no-refresh] informe local del agente
40
+ escalate «razón» | --file F [--gate G] registra un bloqueo (la decisión es humana)
41
+
42
+ códigos de salida: 0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo
43
+ USAGE
44
+ }
45
+
46
+ # ── claim ────────────────────────────────────────────────────────────────────────────
47
+ # Protocolo §3: siempre POST (el GET no acepta el reporte de hashes); regla 1: no se abre
48
+ # trabajo con contexto viejo, así que se sincroniza ANTES; los 409 se distinguen por su
49
+ # CUERPO (spec §3.1 rev. 2), no por el status.
50
+
51
+ # _claim_field <json> <clave> — lee una clave de primer nivel de una respuesta JSON.
52
+ _claim_field() {
53
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
54
+ printf '%s' "$1" | python3 -c '
55
+ import json,sys
56
+ try:
57
+ d=json.load(sys.stdin)
58
+ v=d.get(sys.argv[1]) if isinstance(d,dict) else None
59
+ print(v if isinstance(v,str) else "")
60
+ except Exception:
61
+ print("")
62
+ ' "$2" 2>/dev/null
63
+ }
64
+
65
+ # _claim_render <json> — resumen compacto del slice reclamado (lo lee el humano y el modelo).
66
+ _claim_render() {
67
+ command -v python3 >/dev/null 2>&1 || { echo "$1"; return; }
68
+ printf '%s' "$1" | python3 - <<'PY' 2>/dev/null || echo "$1"
69
+ import json,sys
70
+ try:
71
+ d=json.load(sys.stdin)
72
+ except Exception:
73
+ sys.exit(1)
74
+ if not isinstance(d,dict):
75
+ sys.exit(1)
76
+ g=d.get("gates") or {}
77
+ abiertos=[k for k,v in g.items() if v is False]
78
+ lease=d.get("lease") or {}
79
+ cp=d.get("checkpoint")
80
+ print("slice: %s (%s %s)" % (d.get("slice_id"), d.get("epic_code"), d.get("epic_title") or ""))
81
+ print("fase: %s" % d.get("phase"))
82
+ print("gates abiertos: %s" % (", ".join(abiertos) if abiertos else "ninguno"))
83
+ print("rama base: %s" % d.get("branch_base"))
84
+ print("change: %s" % d.get("openspec_change"))
85
+ print("docs: %s" % d.get("docs_ref"))
86
+ print("historias: %s" % ", ".join(d.get("stories") or []))
87
+ print("lease: expira %s (ttl %ss)" % (lease.get("expires_at"), lease.get("ttl_s")))
88
+ if cp:
89
+ print("CHECKPOINT: rama %s commit %s — CONTINÚA desde aquí, jamás reinicies (protocolo §3 regla 2)"
90
+ % (cp.get("branch"), cp.get("commit_sha")))
91
+ PY
92
+ }
93
+
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").
97
+ _claim_once() {
98
+ local hashes body resp status reason
99
+ hashes="$(ops_context_hashes)"
100
+ body="{\"context_hashes\":$hashes}"
101
+ resp="$(runtime_post "$OPS_PATH_CLAIM" "$body")"
102
+ status="$(runtime_http_status)"
103
+ case "$status" in
104
+ 200)
105
+ agent_context_fetch_and_cache || \
106
+ echo "⚠ claim aceptado, pero /agent/context no respondió: se sigue con la caché anterior" >&2
107
+ _claim_render "$resp"
108
+ return $OPS_RC_OK ;;
109
+ 204)
110
+ echo "NO_WORK: el runtime no tiene trabajo disponible para este agente"
111
+ return $OPS_RC_NO_WORK ;;
112
+ 409)
113
+ reason="$(_claim_field "$resp" reason)"
114
+ [ -n "$reason" ] || reason="$(_claim_field "$resp" error)"
115
+ case "$reason" in
116
+ drift|contention)
117
+ echo "⚠ 409 ($reason): reintentable" >&2
118
+ return 20 ;;
119
+ context_unresolvable)
120
+ echo "⛔ 409 contexto irresoluble — NO reintentar: $resp" >&2
121
+ echo " Resuélvelo en el hub (propuesta → publicación) y vuelve a reclamar." >&2
122
+ return $OPS_RC_REJECTED ;;
123
+ cross_project)
124
+ echo "⛔ 409 cruce de proyecto (queda auditado en el servidor): $resp" >&2
125
+ echo " Este clon está registrado contra otro proyecto: revisa runtime.credentials." >&2
126
+ return $OPS_RC_REJECTED ;;
127
+ *)
128
+ echo "⛔ 409 sin razón reconocida en el cuerpo: $resp" >&2
129
+ return $OPS_RC_REJECTED ;;
130
+ esac ;;
131
+ 401|403)
132
+ echo "⛔ token inválido o revocado ($status): pide un token nuevo al ADMIN del proyecto." >&2
133
+ echo " Los guards siguen operando con el último lock; el trabajo local no se detiene." >&2
134
+ return $OPS_RC_REJECTED ;;
135
+ 422)
136
+ echo "⛔ 422 el servidor rechazó el claim: $resp — no reintentar." >&2
137
+ return $OPS_RC_REJECTED ;;
138
+ 000)
139
+ echo "OFFLINE: runtime inalcanzable; se sigue con la caché local (RNF-6)" >&2
140
+ return $OPS_RC_OFFLINE ;;
141
+ *)
142
+ echo "⚠ respuesta inesperada del claim ($status): $resp" >&2
143
+ return $OPS_RC_OFFLINE ;;
144
+ esac
145
+ }
146
+
147
+ cmd_claim() {
148
+ local epic="" mode expected rc
149
+ while [ $# -gt 0 ]; do
150
+ case "$1" in
151
+ --epic) epic="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
152
+ *) shift ;;
153
+ esac
154
+ done
155
+ mode="$(ops_mode)"
156
+ if [ "$mode" = "legacy" ]; then
157
+ echo "LEGACY: el fichero es primario; abre el slice con el protocolo de references/state-protocol.md"
158
+ return $OPS_RC_LEGACY
159
+ fi
160
+ if [ "$mode" = "dual" ]; then
161
+ echo "DUAL_NO_CLAIM: en dual el slice lo decide el fichero (spec §6.1)."
162
+ echo "Abre el slice en local y reporta cada transición con los demás subcomandos (espejo)."
163
+ return $OPS_RC_LEGACY
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
179
+ # Contexto primero (protocolo §3 regla 1). context-sync.sh es fail-open: si no puede,
180
+ # sale 0 y deja el lock anterior — el claim seguirá y el servidor detectará el drift.
181
+ expected="$(projection_get context.manifest_hash "")"
182
+ [ -n "$expected" ] || expected="$(runtime_field manifest_hash)"
183
+ bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
184
+ _claim_once
185
+ rc=$?
186
+ if [ $rc -eq 20 ]; then
187
+ # Drift o contención: reintentar EXACTAMENTE una vez (protocolo §7). Pasar "" a
188
+ # context-sync.sh NO fuerza nada: su propio fallback (`EXPECTED="$(runtime_field
189
+ # manifest_hash)"`) recae en la caché de credenciales, que en el escenario de drift
190
+ # YA coincide con el lock local (eso es precisamente lo que distingue "drift" del
191
+ # estado real del servidor) — el primer guard de context-sync.sh corta en seco y
192
+ # nunca llega a preguntarle nada al servidor. Se refresca antes el estado REAL
193
+ # (GET /agent/context, que sí toca la red) y se usa SU manifest_hash como EXPECTED:
194
+ # si el drift es real, difiere del lock y el guard deja pasar el GET del manifiesto.
195
+ agent_context_fetch_and_cache >/dev/null 2>&1
196
+ expected="$(projection_get context.manifest_hash "")"
197
+ bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
198
+ _claim_once
199
+ rc=$?
200
+ if [ $rc -eq 20 ]; then
201
+ echo "otro agente tomó la tarea; pide la siguiente"
202
+ return $OPS_RC_NO_WORK
203
+ fi
204
+ fi
205
+ return $rc
206
+ }
207
+
208
+ # ── next-step ────────────────────────────────────────────────────────────────────────
209
+ # El servidor entrega hechos; el PASO lo deriva el cliente (spec §2, invierte el plan §1.3).
210
+ # Prioridad determinista, la primera que aplique.
211
+ cmd_next_step() {
212
+ local sid proj tmp_script
213
+ sid="$(ops_slice_id)"
214
+ if [ -z "$sid" ]; then
215
+ echo "sin slice activo: reclama trabajo (\`slice-ops.sh claim\`) o abre uno por DoR"
216
+ return $OPS_RC_NO_SLICE
217
+ fi
218
+ command -v python3 >/dev/null 2>&1 || { echo "continúa el pipeline por la fase del slice"; return $OPS_RC_OK; }
219
+ proj="$(runtime_projection_read)"
220
+ tmp_script="$(mktemp)"
221
+ cat > "$tmp_script" <<'PY'
222
+ import json,sys
223
+ orden=sys.argv[1].split()
224
+ try:
225
+ d=json.load(sys.stdin)
226
+ except Exception:
227
+ d={}
228
+ sl=d.get("active_slice") or {}
229
+ hint=sl.get("resume_hint")
230
+ failing=sl.get("wiring_failing") or []
231
+ subs=sl.get("sub_slices_pending") or []
232
+ gates=sl.get("gates") or {}
233
+ if hint:
234
+ print("resume_hint: %s" % hint)
235
+ elif failing:
236
+ it=failing[0]
237
+ print("cablear el item de wiring «%s» (%s) con prueba REAL ejecutada; no lo marques passing por inspección"
238
+ % (it.get("item_id"), it.get("kind")))
239
+ elif subs:
240
+ print("construir el siguiente sub-slice: %s (journey_smoke verde antes de pasar al siguiente)" % subs[0])
241
+ else:
242
+ abierto=next((g for g in orden if gates.get(g) is False), None)
243
+ if abierto:
244
+ print("cerrar el gate «%s» (fase %s)" % (abierto, sl.get("phase")))
245
+ else:
246
+ print("todos los gates conocidos están cerrados: abre el PR, archiva el change y reporta submit")
247
+ PY
248
+ echo "$proj" | python3 "$tmp_script" "$OPS_SLICE_GATES" 2>/dev/null || echo "continúa el pipeline por la fase del slice"
249
+ rm -f "$tmp_script"
250
+ }
251
+
252
+ # ── fact ─────────────────────────────────────────────────────────────────────────────
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.
261
+ cmd_fact() {
262
+ local hecho="${1:-}" mode
263
+ [ -n "$hecho" ] && shift
264
+ mode="$(ops_mode)"
265
+ if [ "$mode" = "legacy" ]; then
266
+ echo "LEGACY: escribe el hecho en el fichero (scaffold/design_source/project_kind/foundation)"
267
+ return $OPS_RC_LEGACY
268
+ fi
269
+ case "$hecho" in
270
+ scaffold-confirmed) _fact_scaffold "$@" ;;
271
+ design-source) _fact_design_source "$@" ;;
272
+ project-kind) _fact_project_kind "$@" ;;
273
+ foundation) _fact_foundation "$@" ;;
274
+ harness-phase) _fact_harness_phase "$@" ;;
275
+ *) echo "uso: slice-ops.sh fact <scaffold-confirmed|design-source|project-kind|foundation|harness-phase> [opciones]" >&2
276
+ return $OPS_RC_USAGE ;;
277
+ esac
278
+ }
279
+
280
+ # _ops_emit_event <tipo> <payload_json> [slice_id] — evento genérico de dominio: espejo en
281
+ # dual (mejor esfuerzo, spec §4.1-D) o cola en runtime (spec §3.1). El slice_id es opcional
282
+ # (los hechos de PROYECTO se encolan sin él, spec §3.1 rev. 3; una escalada de slice sí lo
283
+ # lleva). Devuelve $OPS_RC_OFFLINE si el runtime no pudo aceptar el evento (outbox no
284
+ # escribible, python3 ausente u otro fallo local) — el caller decide qué avisar y nunca debe
285
+ # reportar éxito en ese caso (fail-open sobre el trabajo local, nunca sobre el reporte).
286
+ _ops_emit_event() {
287
+ local tipo="$1" payload="$2" sid="${3:-}" pfile mode
288
+ mode="$(ops_mode)"
289
+ pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
290
+ if [ "$mode" = "dual" ]; then
291
+ ops_mirror "$tipo" "$pfile" || echo "⚠ «${tipo}» no espejado; el comparador dual lo marcará" >&2
292
+ rm -f "$pfile"
293
+ return $OPS_RC_OK
294
+ fi
295
+ if ! ops_enqueue_from_file "$tipo" "$pfile" "$sid"; then
296
+ rm -f "$pfile"
297
+ return $OPS_RC_OFFLINE
298
+ fi
299
+ rm -f "$pfile"
300
+ return $OPS_RC_OK
301
+ }
302
+
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
329
+ return $OPS_RC_OFFLINE
330
+ fi
331
+ rm -f "$pfile"
332
+ echo "hecho de proyecto \"$fact\" encolado (project_fact_updated)"
333
+ return $OPS_RC_OK
334
+ }
335
+
336
+ _fact_scaffold() {
337
+ _fact_human_reject scaffold_confirmed
338
+ }
339
+
340
+ _fact_design_source() {
341
+ _fact_human_reject design_source_confirmed
342
+ }
343
+
344
+ _fact_project_kind() {
345
+ local kind="${1:-}" src="human"
346
+ [ -n "$kind" ] && shift
347
+ while [ $# -gt 0 ]; do
348
+ case "$1" in
349
+ --source) src="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
350
+ *) shift ;;
351
+ esac
352
+ done
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).
356
+ case "$src" in auto|human) : ;; *) echo "⛔ --source debe ser auto|human" >&2; return $OPS_RC_REJECTED ;; esac
357
+ _fact_machine_emit project_kind "$(ops_json_str "$kind")"
358
+ }
359
+
360
+ _fact_foundation() {
361
+ local required="" epic="" cfile=""
362
+ while [ $# -gt 0 ]; do
363
+ case "$1" in
364
+ --required) required="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
365
+ --epic) epic="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
366
+ --checklist-file) cfile="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
367
+ *) shift ;;
368
+ esac
369
+ done
370
+ case "$required" in true|false) : ;; *) echo "⛔ --required debe ser true|false" >&2; return $OPS_RC_REJECTED ;; esac
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"
375
+ }
376
+
377
+ _fact_harness_phase() {
378
+ local fase="${1:-}"
379
+ case "$fase" in authoring|building) : ;; *) echo "⛔ harness-phase debe ser authoring|building" >&2; return $OPS_RC_REJECTED ;; esac
380
+ _fact_machine_emit harness_phase "$(ops_json_str "$fase")"
381
+ }
382
+
383
+ # ── propose-asset ────────────────────────────────────────────────────────────────────
384
+ # Un agente JAMÁS publica contexto (protocolo §6): propone contra la superficie de agente
385
+ # `…/context/agent-proposals` (no la humana `…/context/proposals`) y un ADMIN publica.
386
+ cmd_propose_asset() {
387
+ local type_key="" path="" cfile="" mode pid body resp status
388
+ while [ $# -gt 0 ]; do
389
+ case "$1" in
390
+ --type-key) type_key="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
391
+ --path) path="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
392
+ --content-file) cfile="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
393
+ *) shift ;;
394
+ esac
395
+ done
396
+ if [ -z "$type_key" ] || [ -z "$path" ] || [ -z "$cfile" ]; then
397
+ echo "uso: slice-ops.sh propose-asset --type-key K --path P --content-file F" >&2
398
+ return $OPS_RC_USAGE
399
+ fi
400
+ [ -f "$cfile" ] || { echo "⛔ no existe el fichero de contenido: $cfile" >&2; return $OPS_RC_USAGE; }
401
+ mode="$(ops_mode)"
402
+ [ "$mode" = "legacy" ] && { echo "LEGACY: el documento queda en git; no hay plano de contexto que proponer"; return $OPS_RC_LEGACY; }
403
+ pid="$(runtime_field project_id)"
404
+ [ -n "$pid" ] || { echo "⛔ sin project_id en runtime.credentials: corre \`trycore-build init\`" >&2; return $OPS_RC_REJECTED; }
405
+ # El contenido puede ser largo: se serializa por fichero (nunca por argv) y el body sale
406
+ # por `curl --data-binary @fichero` dentro de runtime_post.
407
+ body="{\"path\":$(ops_json_str "$path"),\"type_key\":$(ops_json_str "$type_key"),\"content\":$(ops_json_str_file "$cfile" 200000)}"
408
+ resp="$(runtime_post "$(ops_path_proposals "$pid")" "$body")"
409
+ status="$(runtime_http_status)"
410
+ case "$status" in
411
+ 200|201|202)
412
+ echo "propuesta creada: $resp"
413
+ echo "El agente NO publica contexto: la revisa y publica un ADMIN en la consola del hub."
414
+ return $OPS_RC_OK ;;
415
+ 422)
416
+ echo "⛔ 422 la propuesta no valida contra el schema del tipo \"$type_key\": $resp" >&2
417
+ return $OPS_RC_REJECTED ;;
418
+ 401|403)
419
+ echo "⛔ token inválido o sin permiso para proponer ($status): habla con el ADMIN." >&2
420
+ return $OPS_RC_REJECTED ;;
421
+ *)
422
+ echo "OFFLINE: no se pudo proponer el asset (status $status). El documento queda en git; reintenta al reconectar." >&2
423
+ return $OPS_RC_OFFLINE ;;
424
+ esac
425
+ }
426
+
427
+ # ── status ───────────────────────────────────────────────────────────────────────────
428
+ # Informe local del agente: modo, proyecto (con sus hechos: scaffold/fuente de diseño/
429
+ # tipo/caparazón — la precondición temprana de /build:slice y /build:work la evalúan de
430
+ # aquí, no leen la caché a mano), slice, gates, wiring, contexto, lease y cola.
431
+ # Es INFORMATIVO: rc 0 siempre, pase lo que pase (lo consume /build:status).
432
+ cmd_status() {
433
+ local refresh=1 mode sid pend lock age
434
+ while [ $# -gt 0 ]; do
435
+ case "$1" in
436
+ --no-refresh) refresh=0; shift ;;
437
+ *) shift ;;
438
+ esac
439
+ done
440
+ mode="$(ops_mode)"
441
+ echo "modo: $mode"
442
+ if [ "$mode" = "legacy" ]; then
443
+ echo "estado: el fichero .claude/state/build-state.json es la fuente de verdad (v0.8.5)"
444
+ echo "runtime: sin credenciales o modo legacy: no hay nada que consultar"
445
+ return $OPS_RC_OK
446
+ fi
447
+ [ "$refresh" = 1 ] && { agent_context_fetch_and_cache >/dev/null 2>&1 || echo "aviso: no se pudo refrescar /agent/context (se muestra la caché)"; }
448
+ echo "proyecto: $(runtime_field project_id) · agente: $(runtime_field agent_key)"
449
+ echo "hechos: scaffold_confirmed=$(projection_get project.scaffold_confirmed false) · design_source_confirmed=$(projection_get project.design_source_confirmed false) (aplica=$(projection_get project.design_source_applies false)) · project_kind=$(projection_get project.project_kind "?") · foundation_done=$(projection_get project.foundation_done false)"
450
+ sid="$(ops_slice_id)"
451
+ if [ -z "$sid" ]; then
452
+ echo "slice: ninguno reclamado"
453
+ else
454
+ echo "slice: $sid · $(projection_get active_slice.epic_code "?") · fase $(projection_get active_slice.phase "?")"
455
+ echo "gates: $(projection_get active_slice.gates '{}')"
456
+ echo "wiring: $(projection_get active_slice.wiring_failing '[]')"
457
+ echo "rama: $(projection_get active_slice.branch "?") · change: $(projection_get active_slice.openspec_change "?")"
458
+ fi
459
+ lock="$(runtime_lock_manifest_hash)"
460
+ age="$(projection_age_seconds)"
461
+ if projection_is_stale; then
462
+ echo "contexto: $lock ⚠ proyección sin refrescar (${age}s)"
463
+ else
464
+ echo "contexto: $lock ✓ v$(projection_get context.version "?") (${age}s)"
465
+ fi
466
+ echo "lease: expira $(projection_get lease.expires_at "?")"
467
+ if [ -f "$(config_root)/.claude/state/heartbeat-status.json" ]; then
468
+ grep -q '"lease_lost": *true' "$(config_root)/.claude/state/heartbeat-status.json" 2>/dev/null \
469
+ && echo " ⚠ lease perdido: haz checkpoint y vuelve a reclamar"
470
+ fi
471
+ pend="$(find "$(runtime_outbox_dir)" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' 2>/dev/null | wc -l | tr -d ' ')"
472
+ echo "cola: $pend evento(s) pendiente(s) de despacho"
473
+ echo "nudges: $(projection_get nudges '[]')"
474
+ return $OPS_RC_OK
475
+ }
476
+
477
+ # ── helpers para escalate ────────────────────────────────────────────────────────────
478
+
479
+ # _ops_evidence_json <texto> <fichero> — evidencia para escalate (texto preferentemente,
480
+ # fichero si es grande).
481
+ _ops_evidence_json() {
482
+ local txt="$1" file="$2"
483
+ if [ -n "$file" ]; then
484
+ ops_json_str_file "$file"
485
+ else
486
+ ops_json_str "$txt"
487
+ fi
488
+ }
489
+
490
+ # ── escalate ─────────────────────────────────────────────────────────────────────────
491
+ # Reportar un bloqueo es acto de agente; DECIDIR qué hacer con él es humano (spec §2).
492
+ # El evento deja el rastro en el runtime; la resolución se pide por el canal humano.
493
+ # La nota de gobierno SOLO se imprime si el evento de verdad quedó registrado (mismo patrón
494
+ # de fallo que _fact_machine_emit): un agente jamás debe creer que escaló algo que se perdió.
495
+ cmd_escalate() {
496
+ local razon="" file="" gate="" mode sid payload
497
+ while [ $# -gt 0 ]; do
498
+ case "$1" in
499
+ --file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
500
+ --gate) gate="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
501
+ *) [ -z "$razon" ] && razon="$1"; shift ;;
502
+ esac
503
+ done
504
+ if [ -z "$razon" ] && [ -z "$file" ]; then
505
+ echo "uso: slice-ops.sh escalate «razón» | --file F [--gate G]" >&2
506
+ return $OPS_RC_USAGE
507
+ fi
508
+ mode="$(ops_mode)"
509
+ if [ "$mode" = "legacy" ]; then
510
+ echo "LEGACY: no hay runtime donde registrar la escalada; anótala en el PR y habla con el equipo"
511
+ return $OPS_RC_LEGACY
512
+ fi
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"
523
+ sid="$(ops_slice_id)"
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
532
+ echo "⚠ la escalada no se pudo registrar (outbox no escribible u otro fallo local): repórtala por el PR mientras tanto" >&2
533
+ return $OPS_RC_OFFLINE
534
+ fi
535
+ [ "$mode" = "runtime" ] && echo "escalada registrada (evento encolado)"
536
+ echo "La DECISIÓN es humana (gobierno, spec §2): recortar, diferir o desbloquear lo acuerda el equipo"
537
+ echo "en la consola del hub o en el PR. El arnés no decide por ti ni degrada el alcance."
538
+ return $OPS_RC_OK
539
+ }
540
+
541
+ # ── Helpers comunes de los subcomandos que transicionan ──────────────────────────────
542
+ # _ops_dual_mirror <kind> <payload_json> — en `dual` el fichero es primario: se añade la
543
+ # identidad local al payload y se manda al espejo. Un fallo del espejo NUNCA bloquea:
544
+ # se avisa y se devuelve 0 (queda como discrepancia del comparador, spec §6.1).
545
+ _ops_dual_mirror() {
546
+ local kind="$1" payload="$2" ref pfile rc
547
+ ref="$(ops_local_slice_ref)"
548
+ pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OK
549
+ command -v python3 >/dev/null 2>&1 && python3 - "$pfile" "$ref" <<'PY' 2>/dev/null
550
+ import json,sys
551
+ p,ref=sys.argv[1],sys.argv[2]
552
+ try:
553
+ d=json.load(open(p))
554
+ except Exception:
555
+ d={}
556
+ try:
557
+ d["slice_ref"]=json.loads(ref)
558
+ except Exception:
559
+ d["slice_ref"]={}
560
+ json.dump(d,open(p,"w"),ensure_ascii=False)
561
+ PY
562
+ ops_mirror "$kind" "$pfile"
563
+ rc=$?
564
+ rm -f "$pfile"
565
+ [ $rc -eq 0 ] || echo "⚠ transición «${kind}» no espejada (rc $rc): el comparador dual la marcará" >&2
566
+ return $OPS_RC_OK
567
+ }
568
+
569
+ # _ops_evidence_json — evidencia por fichero (preferida) o por texto corto.
570
+ _ops_evidence_json() {
571
+ local ev="$1" evfile="$2"
572
+ if [ -n "$evfile" ]; then ops_json_str_file "$evfile"; else ops_json_str "$ev"; fi
573
+ }
574
+
575
+ # _ops_require_slice — id del slice activo o rc 4 con mensaje accionable.
576
+ _ops_require_slice() {
577
+ local sid
578
+ sid="$(ops_slice_id)"
579
+ if [ -z "$sid" ]; then
580
+ echo "⛔ no hay slice activo en la caché de proyección: reclama primero (\`slice-ops.sh claim\`)" >&2
581
+ return $OPS_RC_NO_SLICE
582
+ fi
583
+ echo "$sid"
584
+ return $OPS_RC_OK
585
+ }
586
+
587
+ # _ops_post_or_queue <path> <payload> <tipo_evento> <slice_id> <etiqueta> [queue_payload]
588
+ # Camino común de los actos primarios: POST y, si el servidor no está, encolar el hecho.
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).
593
+ _ops_post_or_queue() {
594
+ local path="$1" payload="$2" tipo="$3" sid="$4" etiqueta="$5" queue_payload="${6:-$2}" resp status pfile
595
+ resp="$(runtime_post "$path" "$payload")"
596
+ status="$(runtime_http_status)"
597
+ case "$status" in
598
+ 200|201|202)
599
+ agent_context_fetch_and_cache >/dev/null 2>&1
600
+ echo "$etiqueta registrado en el runtime"
601
+ return $OPS_RC_OK ;;
602
+ 422)
603
+ echo "⛔ 422 el servidor rechazó $etiqueta: $resp" >&2
604
+ echo " Compuerta mecánica funcionando: NO reintentes; corrige la causa y vuelve a medir." >&2
605
+ return $OPS_RC_REJECTED ;;
606
+ 409)
607
+ echo "⛔ 409 el servidor rechazó $etiqueta (lease perdido o transición ya aplicada): $resp" >&2
608
+ echo " Haz checkpoint local y vuelve a reclamar antes de seguir." >&2
609
+ return $OPS_RC_REJECTED ;;
610
+ 401|403)
611
+ echo "⛔ token inválido o revocado ($status): pide un token nuevo al ADMIN." >&2
612
+ return $OPS_RC_REJECTED ;;
613
+ *)
614
+ pfile="$(ops_tmpjson "$queue_payload")" || { echo "OFFLINE: no se pudo encolar $etiqueta" >&2; return $OPS_RC_OFFLINE; }
615
+ if ops_enqueue_from_file "$tipo" "$pfile" "$sid"; then
616
+ rm -f "$pfile"
617
+ echo "OFFLINE: $etiqueta encolado (tipo protegido, se entrega al reconectar)"
618
+ else
619
+ rm -f "$pfile"
620
+ echo "⚠ OFFLINE: $etiqueta NO se pudo encolar (outbox no escribible u otro fallo local) — se perderá" >&2
621
+ fi
622
+ return $OPS_RC_OFFLINE ;;
623
+ esac
624
+ }
625
+
626
+ # ── gate ─────────────────────────────────────────────────────────────────────────────
627
+ # Un veredicto es un HECHO MEDIDO: acto de agente (spec §2). El nombre del gate y el
628
+ # estado se validan en el cliente (compuerta barata, sin round-trip); la compuerta real,
629
+ # que conoce el orden y las precondiciones, es el servidor.
630
+ cmd_gate() {
631
+ local gate="${1:-}" estado="${2:-}" evfile="" ev="" mode sid evjson payload
632
+ if [ -z "$gate" ] || [ -z "$estado" ]; then
633
+ echo "uso: slice-ops.sh gate <nombre> <pass|fail|na> [--evidence «texto» | --evidence-file F]" >&2
634
+ return $OPS_RC_USAGE
635
+ fi
636
+ shift 2
637
+ while [ $# -gt 0 ]; do
638
+ case "$1" in
639
+ --evidence) ev="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
640
+ --evidence-file) evfile="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
641
+ *) shift ;;
642
+ esac
643
+ done
644
+ case " $OPS_SLICE_GATES " in
645
+ *" $gate "*) : ;;
646
+ *) echo "⛔ gate desconocido «${gate}». Canónicos: $OPS_SLICE_GATES" >&2; return $OPS_RC_REJECTED ;;
647
+ esac
648
+ case "$estado" in
649
+ pass|fail|na) : ;;
650
+ *) echo "⛔ estado inválido «${estado}» (pass|fail|na)" >&2; return $OPS_RC_REJECTED ;;
651
+ esac
652
+ mode="$(ops_mode)"
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
670
+ evjson="$(_ops_evidence_json "$ev" "$evfile")"
671
+ payload="{\"gate\":$(ops_json_str "$gate"),\"verdict\":$(ops_json_str "$verdict"),\"evidence\":$evjson}"
672
+ if [ "$mode" = "dual" ]; then
673
+ _ops_dual_mirror gate_verdict "$payload"
674
+ return $?
675
+ fi
676
+ sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
677
+ _ops_post_or_queue "$(ops_path_verdicts "$sid")" "$payload" gate_verdict "$sid" "el veredicto de «${gate}»"
678
+ }
679
+
680
+ # ── submit ───────────────────────────────────────────────────────────────────────────
681
+ cmd_submit() {
682
+ local pr="" change="" mode sid payload
683
+ while [ $# -gt 0 ]; do
684
+ case "$1" in
685
+ --pr-url) pr="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
686
+ --openspec-change) change="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
687
+ *) shift ;;
688
+ esac
689
+ done
690
+ mode="$(ops_mode)"
691
+ [ "$mode" = "legacy" ] && { echo "LEGACY: el submit no existe en el fichero; sigue el pipeline local"; return $OPS_RC_LEGACY; }
692
+ payload="{\"pr_url\":$(ops_json_str "$pr"),\"openspec_change\":$(ops_json_str "$change")}"
693
+ if [ "$mode" = "dual" ]; then
694
+ _ops_dual_mirror slice_submitted "$payload"
695
+ return $?
696
+ fi
697
+ sid="$(_ops_require_slice)" || return $OPS_RC_NO_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" "{}"
702
+ }
703
+
704
+ # ── archive ──────────────────────────────────────────────────────────────────────────
705
+ # SIEMPRE por la cola, también con el runtime vivo: `opsx:archive` corre dentro del PR,
706
+ # en el momento menos oportuno para una llamada síncrona, y el evento es idempotente por
707
+ # `client_event_id`. El tipo está protegido de la cota (Task 1): un archivado no se evicta.
708
+ cmd_archive() {
709
+ local pr="" change="" epic="" mode sid payload pfile
710
+ while [ $# -gt 0 ]; do
711
+ case "$1" in
712
+ --pr-url) pr="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
713
+ --openspec-change) change="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
714
+ --epic) epic="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
715
+ *) shift ;;
716
+ esac
717
+ done
718
+ mode="$(ops_mode)"
719
+ [ "$mode" = "legacy" ] && { echo "LEGACY: archiva moviendo active_slice a history[] en el fichero"; return $OPS_RC_LEGACY; }
720
+ payload="{\"epic_code\":$(ops_json_str "$epic"),\"openspec_change\":$(ops_json_str "$change"),\"pr_url\":$(ops_json_str "$pr")}"
721
+ if [ "$mode" = "dual" ]; then
722
+ _ops_dual_mirror slice_archived "$payload"
723
+ return $?
724
+ fi
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
730
+ if ops_enqueue_from_file slice_archived "$pfile" "$sid"; then
731
+ rm -f "$pfile"
732
+ echo "slice_archived encolado (se entrega al reconectar; tipo protegido de la cota)"
733
+ return $OPS_RC_OK
734
+ fi
735
+ rm -f "$pfile"
736
+ echo "⚠ slice_archived NO se pudo encolar (outbox no escribible u otro fallo local)" >&2
737
+ return $OPS_RC_OFFLINE
738
+ }
739
+
740
+ # ── wiring ───────────────────────────────────────────────────────────────────────────
741
+ # El checklist de cableado es el corazón del refresh de contexto: nace `failing` y solo
742
+ # pasa a `passing` tras prueba REAL ejecutada. Se emite como eventos del agregado slice
743
+ # (spec §4.1-C: el reducer proyecta status + kind/ref/evidence, no solo el status).
744
+ cmd_wiring() {
745
+ local accion="${1:-}" mode
746
+ [ -n "$accion" ] && shift
747
+ mode="$(ops_mode)"
748
+ [ "$mode" = "legacy" ] && { echo "LEGACY: mantén wiring_checklist[] en el fichero"; return $OPS_RC_LEGACY; }
749
+ case "$accion" in
750
+ seed) _wiring_seed "$@" ;;
751
+ update) _wiring_update "$@" ;;
752
+ *) echo "uso: slice-ops.sh wiring seed --file items.json | wiring update <item_id> <passing|failing> [--kind K] [--ref R] [--evidence-file F]" >&2
753
+ return $OPS_RC_USAGE ;;
754
+ esac
755
+ }
756
+
757
+ # _wiring_seed --file <json array de {item_id, kind, ref}>
758
+ _wiring_seed() {
759
+ local file="" sid pfile mode
760
+ while [ $# -gt 0 ]; do
761
+ case "$1" in
762
+ --file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
763
+ *) shift ;;
764
+ esac
765
+ done
766
+ [ -n "$file" ] && [ -f "$file" ] || { echo "uso: slice-ops.sh wiring seed --file items.json" >&2; return $OPS_RC_USAGE; }
767
+ command -v python3 >/dev/null 2>&1 || { echo "⚠ sin python3: no se puede sembrar el checklist" >&2; return $OPS_RC_OFFLINE; }
768
+ pfile="$(mktemp)" || return $OPS_RC_OFFLINE
769
+ # El payload se construye leyendo el fichero DENTRO de python3 (nunca por argv) y se
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).
773
+ python3 - "$file" > "$pfile" <<'PY' 2>/dev/null
774
+ import json,sys
775
+ try:
776
+ raw=json.load(open(sys.argv[1]))
777
+ except Exception:
778
+ raw=[]
779
+ if isinstance(raw,dict):
780
+ raw=raw.get("items") or []
781
+ items=[]
782
+ for it in raw if isinstance(raw,list) else []:
783
+ if not isinstance(it,dict):
784
+ continue
785
+ iid=it.get("item_id") or it.get("id")
786
+ if not iid:
787
+ continue
788
+ items.append({"item_id":iid,"kind":it.get("kind") or "","ref":it.get("ref") or ""})
789
+ print(json.dumps({"items":items},ensure_ascii=False))
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
800
+ mode="$(ops_mode)"
801
+ if [ "$mode" = "dual" ]; then
802
+ ops_mirror wiring_checklist_seeded "$pfile" || echo "⚠ siembra de wiring no espejada; el comparador dual la marcará" >&2
803
+ rm -f "$pfile"
804
+ return $OPS_RC_OK
805
+ fi
806
+ sid="$(ops_slice_id)"
807
+ if ops_enqueue_from_file wiring_checklist_seeded "$pfile" "$sid"; then
808
+ rm -f "$pfile"
809
+ echo "checklist de wiring sembrado (evento encolado)"
810
+ return $OPS_RC_OK
811
+ fi
812
+ rm -f "$pfile"
813
+ echo "⚠ checklist de wiring NO se pudo encolar (outbox no escribible u otro fallo local)" >&2
814
+ return $OPS_RC_OFFLINE
815
+ }
816
+
817
+ # _wiring_update <item_id> <passing|failing> [--kind K] [--ref R] [--evidence T|--evidence-file F]
818
+ _wiring_update() {
819
+ local item="${1:-}" estado="${2:-}" kind="" ref="" ev="" evfile="" sid payload pfile mode
820
+ if [ -z "$item" ] || [ -z "$estado" ]; then
821
+ echo "uso: slice-ops.sh wiring update <item_id> <passing|failing> [--kind K] [--ref R] [--evidence-file F]" >&2
822
+ return $OPS_RC_USAGE
823
+ fi
824
+ shift 2
825
+ while [ $# -gt 0 ]; do
826
+ case "$1" in
827
+ --kind) kind="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
828
+ --ref) ref="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
829
+ --evidence) ev="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
830
+ --evidence-file) evfile="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
831
+ *) shift ;;
832
+ esac
833
+ done
834
+ case "$estado" in
835
+ passing|failing) : ;;
836
+ *) echo "⛔ estado inválido «${estado}» (passing|failing)" >&2; return $OPS_RC_REJECTED ;;
837
+ esac
838
+ if [ "$estado" = "passing" ] && [ -z "$ev" ] && [ -z "$evfile" ]; then
839
+ echo "⚠ marcando «${item}» como passing SIN evidencia: la regla dura exige prueba real ejecutada" >&2
840
+ fi
841
+ payload="{\"item_id\":$(ops_json_str "$item"),\"status\":$(ops_json_str "$estado"),\"kind\":$(ops_json_str "$kind"),\"ref\":$(ops_json_str "$ref"),\"evidence\":$(_ops_evidence_json "$ev" "$evfile")}"
842
+ mode="$(ops_mode)"
843
+ if [ "$mode" = "dual" ]; then
844
+ _ops_dual_mirror wiring_item_updated "$payload"
845
+ return $?
846
+ fi
847
+ sid="$(ops_slice_id)"
848
+ pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
849
+ if ops_enqueue_from_file wiring_item_updated "$pfile" "$sid"; then
850
+ rm -f "$pfile"
851
+ echo "wiring «${item}» → $estado (evento encolado)"
852
+ return $OPS_RC_OK
853
+ fi
854
+ rm -f "$pfile"
855
+ echo "⚠ wiring «${item}» NO se pudo encolar (outbox no escribible u otro fallo local)" >&2
856
+ return $OPS_RC_OFFLINE
857
+ }
858
+
859
+ # ── progress ─────────────────────────────────────────────────────────────────────────
860
+ # Sustituye a progress_log[]: el event stream ES la bitácora (spec §5.6, el comparador NO
861
+ # compara progress_log). Tipo EVICTABLE a propósito: es volumen, no hecho de dominio.
862
+ cmd_progress() {
863
+ local nota="" file="" mode sid payload pfile
864
+ while [ $# -gt 0 ]; do
865
+ case "$1" in
866
+ --file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
867
+ *) [ -z "$nota" ] && nota="$1"; shift ;;
868
+ esac
869
+ done
870
+ if [ -z "$nota" ] && [ -z "$file" ]; then
871
+ echo "uso: slice-ops.sh progress «nota» | --file F" >&2
872
+ return $OPS_RC_USAGE
873
+ fi
874
+ mode="$(ops_mode)"
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).
877
+ payload="{\"note\":$(_ops_evidence_json "$nota" "$file")}"
878
+ if [ "$mode" = "dual" ]; then
879
+ _ops_dual_mirror progress_noted "$payload"
880
+ return $?
881
+ fi
882
+ sid="$(ops_slice_id)"
883
+ pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
884
+ if ops_enqueue_from_file progress_noted "$pfile" "$sid"; then
885
+ rm -f "$pfile"
886
+ echo "nota de progreso encolada"
887
+ return $OPS_RC_OK
888
+ fi
889
+ rm -f "$pfile"
890
+ echo "⚠ nota de progreso NO se pudo encolar (outbox no escribible u otro fallo local)" >&2
891
+ return $OPS_RC_OFFLINE
892
+ }
893
+
894
+ # ── checkpoint ───────────────────────────────────────────────────────────────────────
895
+ # El checkpoint es lo que permite que OTRO agente continúe tras una caída (protocolo §3
896
+ # regla 2). La rama y el commit se leen del repo del PROYECTO, no del cwd de la sesión.
897
+ cmd_checkpoint() {
898
+ local nota="" file="" mode sid branch commit payload root
899
+ while [ $# -gt 0 ]; do
900
+ case "$1" in
901
+ --note) nota="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
902
+ --note-file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
903
+ *) shift ;;
904
+ esac
905
+ done
906
+ mode="$(ops_mode)"
907
+ [ "$mode" = "legacy" ] && { echo "LEGACY: registra la continuidad en session_continuity del fichero"; return $OPS_RC_LEGACY; }
908
+ root="$(config_root)"
909
+ branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
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
916
+ if [ "$mode" = "dual" ]; then
917
+ payload="{\"branch\":$(ops_json_str "$branch"),\"commit_sha\":$(ops_json_str "$commit"),\"summary\":$sumjson}"
918
+ _ops_dual_mirror checkpoint_recorded "$payload"
919
+ return $?
920
+ fi
921
+ sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
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"
928
+ }
929
+
930
+ SUB="${1:-}"
931
+ [ $# -gt 0 ] && shift
932
+ case "$SUB" in
933
+ mode) ops_mode; exit 0 ;;
934
+ claim) cmd_claim "$@"; exit $? ;;
935
+ next-step) cmd_next_step; exit $? ;;
936
+ gate) cmd_gate "$@"; exit $? ;;
937
+ submit) cmd_submit "$@"; exit $? ;;
938
+ archive) cmd_archive "$@"; exit $? ;;
939
+ wiring) cmd_wiring "$@"; exit $? ;;
940
+ progress) cmd_progress "$@"; exit $? ;;
941
+ checkpoint) cmd_checkpoint "$@"; exit $? ;;
942
+ fact) cmd_fact "$@"; exit $? ;;
943
+ propose-asset) cmd_propose_asset "$@"; exit $? ;;
944
+ status) cmd_status "$@"; exit $? ;;
945
+ escalate) cmd_escalate "$@"; exit $? ;;
946
+ ""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;
947
+ *) echo "subcomando desconocido: $SUB" >&2; usage; exit $OPS_RC_USAGE ;;
948
+ esac