@trycore/spec-build-harness 0.14.2 → 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 +1 -0
- package/VERSION +1 -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 +56 -7
- package/hooks/build/lib/runtime-client.sh +55 -12
- package/hooks/build/lib/runtime-ops.sh +135 -27
- package/hooks/build/release-ops.sh +13 -8
- package/hooks/build/slice-ops.sh +409 -41
- 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 +516 -27
|
@@ -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
|
|
@@ -118,6 +129,14 @@ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
|
|
|
118
129
|
ops_path_epic_proposals() { echo "/projects/$1/agent/epic-proposals"; }
|
|
119
130
|
ops_path_epic_proposal() { echo "/projects/$1/agent/epic-proposals/$2"; }
|
|
120
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"; }
|
|
139
|
+
|
|
121
140
|
ops_mode() { runtime_mode; }
|
|
122
141
|
|
|
123
142
|
# ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
|
|
@@ -265,39 +284,96 @@ PY
|
|
|
265
284
|
runtime_outbox_enforce_cap >/dev/null
|
|
266
285
|
}
|
|
267
286
|
|
|
268
|
-
# ops_mirror <
|
|
269
|
-
# fichero local sigue siendo primario: un rechazo del reducer NO bloquea el
|
|
270
|
-
# 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.
|
|
271
299
|
ops_mirror() {
|
|
272
|
-
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
|
|
273
309
|
pid="$(runtime_field project_id)"
|
|
274
|
-
[ -n "$pid" ] || return
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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]
|
|
280
322
|
try:
|
|
281
323
|
payload=json.load(open(pfile))
|
|
282
324
|
except Exception:
|
|
283
325
|
payload={}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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))
|
|
287
332
|
PY
|
|
288
333
|
body="$(cat "$tmp" 2>/dev/null)"
|
|
289
334
|
rm -f "$tmp"
|
|
290
|
-
[ -n "$body" ] || return
|
|
335
|
+
[ -n "$body" ] || return $OPS_RC_OFFLINE
|
|
291
336
|
resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
|
|
292
337
|
status="$(runtime_http_status)"
|
|
293
338
|
case "$status" in
|
|
294
|
-
200|201|202)
|
|
339
|
+
200|201|202) _ops_mirror_report "$event_type" "$resp" ;;
|
|
295
340
|
409|422)
|
|
296
|
-
|
|
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
|
|
297
344
|
echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
|
|
298
|
-
return
|
|
299
|
-
*) 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 ;;
|
|
300
374
|
esac
|
|
375
|
+
echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
|
|
376
|
+
return $OPS_RC_REJECTED
|
|
301
377
|
}
|
|
302
378
|
|
|
303
379
|
# ── Ledger local de propuestas de épica [#62] ────────────────────────────────────────
|
|
@@ -308,11 +384,13 @@ PY
|
|
|
308
384
|
# instante); `proposal_id` llega después, con el ack, y puede no llegar nunca (offline).
|
|
309
385
|
ops_epic_ledger_path() { echo "$(config_root)/.claude/state/epic-proposals.json"; }
|
|
310
386
|
|
|
311
|
-
#
|
|
312
|
-
|
|
313
|
-
|
|
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() {
|
|
314
392
|
command -v python3 >/dev/null 2>&1 || { echo '{"proposals":[]}'; return; }
|
|
315
|
-
python3 - "$
|
|
393
|
+
python3 - "$1" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
|
|
316
394
|
import json,sys
|
|
317
395
|
try:
|
|
318
396
|
d=json.load(open(sys.argv[1]))
|
|
@@ -323,12 +401,11 @@ except Exception:
|
|
|
323
401
|
PY
|
|
324
402
|
}
|
|
325
403
|
|
|
326
|
-
#
|
|
404
|
+
# _ops_ledger_upsert <fichero> <entrada-json> — upsert por client_event_id: las claves que la
|
|
327
405
|
# entrada NO menciona se conservan (así un `epic-status` puede fijar el estado sin borrar el
|
|
328
406
|
# borrador que guardó `propose-epic`). Escritura atómica. rc 0/1.
|
|
329
|
-
|
|
330
|
-
local
|
|
331
|
-
f="$(ops_epic_ledger_path)"
|
|
407
|
+
_ops_ledger_upsert() {
|
|
408
|
+
local f="$1" entry="$2"
|
|
332
409
|
mkdir -p "$(dirname "$f")"
|
|
333
410
|
command -v python3 >/dev/null 2>&1 || return 1
|
|
334
411
|
python3 - "$f" "$entry" <<'PY' 2>/dev/null || return 1
|
|
@@ -351,7 +428,7 @@ for i,p in enumerate(ps):
|
|
|
351
428
|
else:
|
|
352
429
|
ps.append(e)
|
|
353
430
|
dirn=os.path.dirname(path) or "."
|
|
354
|
-
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".
|
|
431
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".ledger.",suffix=".tmp")
|
|
355
432
|
try:
|
|
356
433
|
with os.fdopen(fd,"w") as out:
|
|
357
434
|
json.dump({"proposals":ps},out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
|
|
@@ -363,6 +440,37 @@ except Exception:
|
|
|
363
440
|
PY
|
|
364
441
|
}
|
|
365
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
|
+
|
|
366
474
|
# ops_epic_ack_read <client_event_id> — el ack que dejó el carril directo, o "".
|
|
367
475
|
ops_epic_ack_read() {
|
|
368
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)"
|