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