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