@trycore/spec-build-harness 0.14.1 → 0.15.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -2
- package/VERSION +1 -1
- package/commands/build/epic.md +15 -1
- package/commands/build/graph-sync.md +129 -0
- package/dist/commands/init.js +3 -0
- package/docs/commands.md +2 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +37 -2
- package/docs/runtime/protocolo-cliente-runtime.md +74 -12
- package/hooks/build/lib/runtime-client.sh +55 -12
- package/hooks/build/lib/runtime-ops.sh +140 -29
- package/hooks/build/release-ops.sh +13 -8
- package/hooks/build/slice-ops.sh +497 -55
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +118 -3
- package/scripts/tests/test-install.sh +2 -1
- package/scripts/tests/test-runtime-client.sh +49 -3
- package/scripts/tests/test-skill-ops.sh +601 -46
|
@@ -21,6 +21,7 @@ OPS_RC_NO_SLICE=4
|
|
|
21
21
|
OPS_RC_OFFLINE=5
|
|
22
22
|
OPS_RC_REJECTED=6
|
|
23
23
|
OPS_RC_NO_WORK=7
|
|
24
|
+
OPS_RC_NOT_MIRRORABLE=8
|
|
24
25
|
|
|
25
26
|
# Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
|
|
26
27
|
# dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.
|
|
@@ -40,6 +41,16 @@ OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
|
|
|
40
41
|
# (orden estricto, domain.py:400-407) — de aquí salen las cadenas que emite `phase`.
|
|
41
42
|
OPS_SLICE_PHASES="dor change red green refactor smoke api data dod pr"
|
|
42
43
|
|
|
44
|
+
# Enumeración NORMATIVA de la ingesta espejo (`TIPOS_DE_ESPEJO` del hub, derivada allí de los
|
|
45
|
+
# schemas que heredan de `_TransicionDeEspejo`). Se replica aquí para no gastar una llamada en
|
|
46
|
+
# algo que el hub va a rechazar por elemento — y el test la compara contra esta misma lista, así
|
|
47
|
+
# que ampliarla en el hub obliga a ampliarla aquí (lección de #44: dos listas del mismo hecho
|
|
48
|
+
# divergen tarde o temprano, y la que calla es la que miente).
|
|
49
|
+
# `release_verdict_reported` y `front_integration_reported` NO están, y no es un olvido: no
|
|
50
|
+
# existen en el catálogo del hub. Sus canales de espejo son trabajo hub-side (tipo nuevo con su
|
|
51
|
+
# base `_TransicionDeEspejo` y su rama de reducer), no algo que este cliente pueda arreglar.
|
|
52
|
+
OPS_MIRROR_TYPES="slice_opened phase_advanced phase_reverted gate_verdict wiring_item_updated wiring_checklist_seeded checkpoint_recorded progress_noted handoff_recorded"
|
|
53
|
+
|
|
43
54
|
# ops_phase_idx <fase> — índice de la fase en OPS_SLICE_PHASES; -1 si no es fase del hub.
|
|
44
55
|
ops_phase_idx() {
|
|
45
56
|
local i=0 p
|
|
@@ -112,8 +123,19 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
|
|
|
112
123
|
# (`EP-XXX`) al aprobar y un humano aprueba: por eso hay una ruta de creación y otra de
|
|
113
124
|
# consulta, y ninguna de publicación. Mismo patrón que las propuestas de asset
|
|
114
125
|
# (`ops_path_proposals`), que es su precedente en este cliente.
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
# [#70] El carril del AGENTE lleva `/agent/`: `/projects/{id}/epic-proposals` (sin él) es la
|
|
127
|
+
# vista de CONSOLA — GET con JWT de usuario — y responde `405 Allow: GET` a un POST con token
|
|
128
|
+
# de proyecto. Las dos superficies no se comparten por diseño (hub CON-1).
|
|
129
|
+
ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
|
|
130
|
+
ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
|
|
131
|
+
|
|
132
|
+
# [EP-OR-17] Re-sync del grafo (HU-OR-72/73). El arnés PROPONE el bundle construido desde los
|
|
133
|
+
# docs de discovery, el hub calcula el delta y un HUMANO aprueba en la consola: nada se aplica
|
|
134
|
+
# por esta ruta. Mismo carril `/agent/` que las propuestas de épica y por la misma razón — la
|
|
135
|
+
# ruta sin `/agent/` es la superficie humana (sesión + PDP) y a un bearer de agente le responde
|
|
136
|
+
# 403 auditado, no 405: las dos superficies no se comparten por diseño.
|
|
137
|
+
ops_path_graph_sync_proposals() { echo "/projects/$1/agent/graph-sync-proposals"; }
|
|
138
|
+
ops_path_graph_sync_proposal() { echo "/projects/$1/agent/graph-sync-proposals/$2"; }
|
|
117
139
|
|
|
118
140
|
ops_mode() { runtime_mode; }
|
|
119
141
|
|
|
@@ -262,39 +284,96 @@ PY
|
|
|
262
284
|
runtime_outbox_enforce_cap >/dev/null
|
|
263
285
|
}
|
|
264
286
|
|
|
265
|
-
# ops_mirror <
|
|
266
|
-
# fichero local sigue siendo primario: un rechazo del reducer NO bloquea el
|
|
267
|
-
# como discrepancia del comparador.
|
|
287
|
+
# ops_mirror <event_type> <payload_file> [epic_code] — ingesta espejo del modo `dual`
|
|
288
|
+
# (spec §4.1-D). El fichero local sigue siendo primario: un rechazo del reducer NO bloquea el
|
|
289
|
+
# trabajo, queda como discrepancia del comparador.
|
|
290
|
+
# rc 0 aplicado o duplicado · 5 no se pudo (red, sin epic_code, sin python3) · 6 rechazado
|
|
291
|
+
# · 8 el tipo no es espejable.
|
|
292
|
+
#
|
|
293
|
+
# [EP-OR-17] El cuerpo es `MirrorTransitionsIn`: `{"transitions":[{client_event_id, epic_code,
|
|
294
|
+
# event_type, payload}]}`. El `{kind, payload, origin, occurred_at}` que se mandaba antes no
|
|
295
|
+
# existe en el contrato — el 100 % de lo que el modo dual espejaba era un 422. El `occurred_at`
|
|
296
|
+
# tampoco viaja, y no por descuido: los relojes de cliente entran SOLO por el import; aquí
|
|
297
|
+
# estampa el servidor. El `origin` tampoco: la marca de origen-espejo la pone el hub, y lo que
|
|
298
|
+
# el cliente dijera no elegiría el origen de todos modos.
|
|
268
299
|
ops_mirror() {
|
|
269
|
-
local
|
|
300
|
+
local event_type="$1" pfile="$2" epic_code="${3:-}" pid body resp status tmp
|
|
301
|
+
case " $OPS_MIRROR_TYPES " in
|
|
302
|
+
*" $event_type "*) : ;;
|
|
303
|
+
*)
|
|
304
|
+
echo "⚠ «${event_type}» está fuera de la enumeración normativa del catálogo de espejo del hub:" >&2
|
|
305
|
+
echo " no se envía (viajaría solo para volver rechazado por elemento). Espejables:" >&2
|
|
306
|
+
echo " $OPS_MIRROR_TYPES" >&2
|
|
307
|
+
return $OPS_RC_NOT_MIRRORABLE ;;
|
|
308
|
+
esac
|
|
270
309
|
pid="$(runtime_field project_id)"
|
|
271
|
-
[ -n "$pid" ] || return
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
310
|
+
[ -n "$pid" ] || return $OPS_RC_OFFLINE
|
|
311
|
+
[ -n "$epic_code" ] || epic_code="$(ops_local_slice_ref | python3 -c 'import json,sys; print(json.load(sys.stdin).get("epic_code") or "")' 2>/dev/null)"
|
|
312
|
+
if [ -z "$epic_code" ]; then
|
|
313
|
+
echo "⚠ «${event_type}» no espejado: sin epic_code (el hub lo exige y el lote sería un 422)." >&2
|
|
314
|
+
echo " Sale de active_slice.epica del build-state local: abre un slice antes de espejar." >&2
|
|
315
|
+
return $OPS_RC_OFFLINE
|
|
316
|
+
fi
|
|
317
|
+
command -v python3 >/dev/null 2>&1 || return $OPS_RC_OFFLINE
|
|
318
|
+
tmp="$(mktemp)" || return $OPS_RC_OFFLINE
|
|
319
|
+
python3 - "$event_type" "$pfile" "$epic_code" > "$tmp" <<'PY' 2>/dev/null
|
|
320
|
+
import json,sys,uuid
|
|
321
|
+
event_type,pfile,epic_code=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
277
322
|
try:
|
|
278
323
|
payload=json.load(open(pfile))
|
|
279
324
|
except Exception:
|
|
280
325
|
payload={}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
326
|
+
if not isinstance(payload,dict):
|
|
327
|
+
payload={}
|
|
328
|
+
print(json.dumps({"transitions":[{"client_event_id":str(uuid.uuid4()),
|
|
329
|
+
"epic_code":epic_code,
|
|
330
|
+
"event_type":event_type,
|
|
331
|
+
"payload":payload}]},ensure_ascii=False))
|
|
284
332
|
PY
|
|
285
333
|
body="$(cat "$tmp" 2>/dev/null)"
|
|
286
334
|
rm -f "$tmp"
|
|
287
|
-
[ -n "$body" ] || return
|
|
335
|
+
[ -n "$body" ] || return $OPS_RC_OFFLINE
|
|
288
336
|
resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
|
|
289
337
|
status="$(runtime_http_status)"
|
|
290
338
|
case "$status" in
|
|
291
|
-
200|201|202)
|
|
339
|
+
200|201|202) _ops_mirror_report "$event_type" "$resp" ;;
|
|
292
340
|
409|422)
|
|
293
|
-
|
|
341
|
+
# 409 = el cutover apagó la ingesta espejo para este proyecto; 422 = el lote no cumple el
|
|
342
|
+
# borde. Los dos son del LOTE entero, no de un elemento: no hay reporte que leer.
|
|
343
|
+
echo "⚠ el espejo rechazó «${event_type}» (HTTP $status): $resp" >&2
|
|
294
344
|
echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
|
|
295
|
-
return
|
|
296
|
-
*) return
|
|
345
|
+
return $OPS_RC_REJECTED ;;
|
|
346
|
+
*) return $OPS_RC_OFFLINE ;;
|
|
347
|
+
esac
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# _ops_mirror_report <event_type> <respuesta> — lee el reporte POR ELEMENTO (HU-OR-56 E3).
|
|
351
|
+
# rc 0 aplicado o duplicado · 6 rechazado, con el motivo del hub a la vista: volcar el cuerpo
|
|
352
|
+
# crudo escondía justo la frase que dice qué hay que arreglar.
|
|
353
|
+
_ops_mirror_report() {
|
|
354
|
+
local event_type="$1" reason
|
|
355
|
+
command -v python3 >/dev/null 2>&1 || return $OPS_RC_OK
|
|
356
|
+
reason="$(printf '%s' "$2" | python3 -c '
|
|
357
|
+
import json,sys
|
|
358
|
+
try:
|
|
359
|
+
d=json.load(sys.stdin)
|
|
360
|
+
rs=d.get("results") or []
|
|
361
|
+
sys.stdout.write(next((r.get("reason") or "rechazado sin motivo registrado")
|
|
362
|
+
for r in rs if r.get("status")=="rejected"))
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
' 2>/dev/null)"
|
|
366
|
+
[ -n "$reason" ] || return $OPS_RC_OK
|
|
367
|
+
echo "⚠ el espejo rechazó «${event_type}»: $reason" >&2
|
|
368
|
+
case "$reason" in
|
|
369
|
+
*"no existe en el grafo"*)
|
|
370
|
+
# Este es EL rechazo del piloto: el grafo del hub se quedó en la foto del import inicial
|
|
371
|
+
# y toda transición de una épica posterior rebota. Ahora hay una salida que ofrecer.
|
|
372
|
+
echo " El grafo del hub no conoce esa épica. Propón el re-sync con" >&2
|
|
373
|
+
echo " \`slice-ops.sh graph-sync --file epics.json\` y pide su aprobación en la consola." >&2 ;;
|
|
297
374
|
esac
|
|
375
|
+
echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
|
|
376
|
+
return $OPS_RC_REJECTED
|
|
298
377
|
}
|
|
299
378
|
|
|
300
379
|
# ── Ledger local de propuestas de épica [#62] ────────────────────────────────────────
|
|
@@ -305,11 +384,13 @@ PY
|
|
|
305
384
|
# instante); `proposal_id` llega después, con el ack, y puede no llegar nunca (offline).
|
|
306
385
|
ops_epic_ledger_path() { echo "$(config_root)/.claude/state/epic-proposals.json"; }
|
|
307
386
|
|
|
308
|
-
#
|
|
309
|
-
|
|
310
|
-
|
|
387
|
+
# _ops_ledger_read <fichero> — imprime {"proposals":[…]}; nunca lanza.
|
|
388
|
+
# [EP-OR-17] Base común del ledger de propuestas de épica y del de re-sync del grafo: son la
|
|
389
|
+
# misma estructura con otra ruta, y duplicar 60 líneas de python3 garantizaba que una de las
|
|
390
|
+
# dos copias se quedara atrás.
|
|
391
|
+
_ops_ledger_read() {
|
|
311
392
|
command -v python3 >/dev/null 2>&1 || { echo '{"proposals":[]}'; return; }
|
|
312
|
-
python3 - "$
|
|
393
|
+
python3 - "$1" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
|
|
313
394
|
import json,sys
|
|
314
395
|
try:
|
|
315
396
|
d=json.load(open(sys.argv[1]))
|
|
@@ -320,12 +401,11 @@ except Exception:
|
|
|
320
401
|
PY
|
|
321
402
|
}
|
|
322
403
|
|
|
323
|
-
#
|
|
404
|
+
# _ops_ledger_upsert <fichero> <entrada-json> — upsert por client_event_id: las claves que la
|
|
324
405
|
# entrada NO menciona se conservan (así un `epic-status` puede fijar el estado sin borrar el
|
|
325
406
|
# borrador que guardó `propose-epic`). Escritura atómica. rc 0/1.
|
|
326
|
-
|
|
327
|
-
local
|
|
328
|
-
f="$(ops_epic_ledger_path)"
|
|
407
|
+
_ops_ledger_upsert() {
|
|
408
|
+
local f="$1" entry="$2"
|
|
329
409
|
mkdir -p "$(dirname "$f")"
|
|
330
410
|
command -v python3 >/dev/null 2>&1 || return 1
|
|
331
411
|
python3 - "$f" "$entry" <<'PY' 2>/dev/null || return 1
|
|
@@ -348,7 +428,7 @@ for i,p in enumerate(ps):
|
|
|
348
428
|
else:
|
|
349
429
|
ps.append(e)
|
|
350
430
|
dirn=os.path.dirname(path) or "."
|
|
351
|
-
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".
|
|
431
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".ledger.",suffix=".tmp")
|
|
352
432
|
try:
|
|
353
433
|
with os.fdopen(fd,"w") as out:
|
|
354
434
|
json.dump({"proposals":ps},out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
@@ -360,6 +440,37 @@ except Exception:
|
|
|
360
440
|
PY
|
|
361
441
|
}
|
|
362
442
|
|
|
443
|
+
ops_epic_ledger_read() { _ops_ledger_read "$(ops_epic_ledger_path)"; }
|
|
444
|
+
ops_epic_ledger_upsert() { _ops_ledger_upsert "$(ops_epic_ledger_path)" "$1"; }
|
|
445
|
+
|
|
446
|
+
# ── Ledger local del re-sync del grafo [EP-OR-17] ────────────────────────────────────
|
|
447
|
+
# Fichero PROPIO, no una sección del de épicas: un re-sync no es una propuesta de épica, y
|
|
448
|
+
# mezclarlos haría que `epic-status` hablara de grafos y `graph-sync-status` de épicas.
|
|
449
|
+
ops_graph_sync_ledger_path() { echo "$(config_root)/.claude/state/graph-sync-proposals.json"; }
|
|
450
|
+
ops_graph_sync_ledger_read() { _ops_ledger_read "$(ops_graph_sync_ledger_path)"; }
|
|
451
|
+
ops_graph_sync_ledger_upsert() { _ops_ledger_upsert "$(ops_graph_sync_ledger_path)" "$1"; }
|
|
452
|
+
|
|
453
|
+
# ops_graph_sync_cid — `client_event_id` DETERMINISTA de la próxima propuesta de re-sync:
|
|
454
|
+
# `<agent_key>:graph-sync:<n>`, con n = entradas del ledger + 1.
|
|
455
|
+
#
|
|
456
|
+
# Determinista es el punto: el reintento tras un 409 de grafo rancio reusa exactamente este
|
|
457
|
+
# cid y el hub deduplica, en vez de acabar con dos propuestas del mismo grafo esperando
|
|
458
|
+
# aprobación humana. El contrato acota el campo a 64 caracteres, así que una `agent_key` larga
|
|
459
|
+
# se sustituye por su sha256 truncado — sigue siendo estable para el mismo agente, que es lo
|
|
460
|
+
# único que la idempotencia necesita.
|
|
461
|
+
ops_graph_sync_cid() {
|
|
462
|
+
local ak n
|
|
463
|
+
ak="$(runtime_field agent_key)"
|
|
464
|
+
[ -n "$ak" ] || ak="agente"
|
|
465
|
+
n="$(ops_graph_sync_ledger_read | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("proposals") or []) + 1)' 2>/dev/null)"
|
|
466
|
+
case "$n" in ''|*[!0-9]*) n=1 ;; esac
|
|
467
|
+
if [ "${#ak}" -gt 30 ]; then
|
|
468
|
+
ak="$(printf '%s' "$ak" | python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.stdin.read().encode()).hexdigest()[:16])' 2>/dev/null)"
|
|
469
|
+
[ -n "$ak" ] || ak="agente"
|
|
470
|
+
fi
|
|
471
|
+
echo "${ak}:graph-sync:${n}"
|
|
472
|
+
}
|
|
473
|
+
|
|
363
474
|
# ops_epic_ack_read <client_event_id> — el ack que dejó el carril directo, o "".
|
|
364
475
|
ops_epic_ack_read() {
|
|
365
476
|
local f; f="$(runtime_outbox_dir)/acks/$1.json"
|
|
@@ -35,7 +35,7 @@ _rel_evidence_json() {
|
|
|
35
35
|
|
|
36
36
|
# ── verdict ──────────────────────────────────────────────────────────────────────────
|
|
37
37
|
cmd_verdict() {
|
|
38
|
-
local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status
|
|
38
|
+
local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status rel_status closed_by
|
|
39
39
|
if [ -z "$line" ] || [ -z "$gate" ] || [ -z "$estado" ]; then usage; return $OPS_RC_USAGE; fi
|
|
40
40
|
shift 3
|
|
41
41
|
while [ $# -gt 0 ]; do
|
|
@@ -59,9 +59,13 @@ cmd_verdict() {
|
|
|
59
59
|
[ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en releases[] del fichero"; return $OPS_RC_LEGACY; }
|
|
60
60
|
payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"verdict\":$(ops_json_str "$(printf %s "$estado" | tr a-z A-Z)"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
|
|
61
61
|
if [ "$mode" = "dual" ]; then
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
# [EP-OR-17] El hub NO tiene canal de espejo para los veredictos de release:
|
|
63
|
+
# `release_verdict_reported` no existe en su catálogo de eventos (`TIPOS_DE_ESPEJO` solo
|
|
64
|
+
# cubre transiciones de slice) y su superficie síncrona es de gobierno humano. Mandarlo era
|
|
65
|
+
# gastar una llamada para recibir un rechazo por elemento y un aviso genérico que mandaba a
|
|
66
|
+
# revisar una red que funcionaba. Se dice lo que pasa y se sigue: el fichero es primario.
|
|
67
|
+
echo "DUAL: veredicto anotado en local. El hub todavía no espeja veredictos de release"
|
|
68
|
+
echo "(no hay tipo en su catálogo): quedará reflejado al pasar a modo \`runtime\`."
|
|
65
69
|
return $OPS_RC_OK
|
|
66
70
|
fi
|
|
67
71
|
resp="$(runtime_post "$(ops_path_release_verdicts "$(ops_urlenc "$line")")" "$payload")"
|
|
@@ -135,7 +139,7 @@ HINT
|
|
|
135
139
|
# distinto): reporta la integración de SU miembro. Planificar, abrir, drenar y cerrar el
|
|
136
140
|
# front siguen siendo humanos.
|
|
137
141
|
cmd_front_integration() {
|
|
138
|
-
local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status
|
|
142
|
+
local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status
|
|
139
143
|
[ -n "$front" ] || { usage; return $OPS_RC_USAGE; }
|
|
140
144
|
shift
|
|
141
145
|
while [ $# -gt 0 ]; do
|
|
@@ -159,9 +163,10 @@ cmd_front_integration() {
|
|
|
159
163
|
[ "$mode" = "legacy" ] && { echo "LEGACY: actualiza parallel_front.members[] en el fichero"; return $OPS_RC_LEGACY; }
|
|
160
164
|
payload="{\"merge_status\":$(ops_json_str "$merge"),\"re_smoke\":{\"status\":$(ops_json_str "$resmoke"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}}"
|
|
161
165
|
if [ "$mode" = "dual" ]; then
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
# Mismo caso que los veredictos de release: `front_integration_reported` no existe en el
|
|
167
|
+
# catálogo del hub y el agregado `front` no entra por la ingesta espejo.
|
|
168
|
+
echo "DUAL: integración anotada en local. El hub todavía no espeja integraciones de front"
|
|
169
|
+
echo "(no hay tipo en su catálogo): quedará reflejada al pasar a modo \`runtime\`."
|
|
165
170
|
return $OPS_RC_OK
|
|
166
171
|
fi
|
|
167
172
|
akey="$(runtime_field agent_key)"
|