@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.
- package/.claude-plugin/plugin.json +1 -1
- package/GOVERNANCE.md +43 -5
- package/INSTALL.md +28 -6
- package/METODOLOGIA.md +65 -10
- package/README.md +41 -7
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +33 -7
- package/agents/build/dor-dod-gatekeeper.md +17 -6
- package/agents/build/ux-fidelity-reviewer.md +4 -1
- package/agents/build/wiring-adversarial-verifier.md +52 -5
- package/commands/build/architect.md +1 -1
- package/commands/build/claim.md +46 -0
- package/commands/build/escalate.md +36 -0
- package/commands/build/front.md +9 -3
- package/commands/build/onboard.md +63 -14
- package/commands/build/prototype.md +23 -0
- package/commands/build/reflect.md +60 -40
- package/commands/build/release.md +10 -7
- package/commands/build/resume.md +33 -13
- package/commands/build/slice.md +32 -27
- package/commands/build/status.md +35 -0
- package/commands/build/work.md +11 -8
- package/config/build-config.template.json +4 -0
- package/dist/cli.js +22 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +48 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +276 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/runtime-client.js +196 -0
- package/dist/lib/settings-merge.js +3 -3
- package/dist/lib/state-bundle.js +46 -0
- package/docs/commands.md +32 -9
- package/docs/getting-started.md +2 -1
- package/docs/hooks.md +114 -27
- package/docs/runtime/guia-modo-dual-y-migracion.md +136 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +109 -34
- package/hooks/build/build-gate-check.sh +21 -0
- package/hooks/build/context-monitor.sh +82 -15
- package/hooks/build/context-sync.sh +192 -0
- package/hooks/build/design-source-guard.sh +31 -3
- package/hooks/build/dual-compare.sh +92 -0
- package/hooks/build/event-emitter.sh +75 -0
- package/hooks/build/gitflow-guard.sh +164 -14
- package/hooks/build/heartbeat.sh +259 -0
- package/hooks/build/lib/agent-context.sh +139 -0
- package/hooks/build/lib/config.sh +27 -0
- package/hooks/build/lib/projection.sh +71 -0
- package/hooks/build/lib/runtime-client.sh +465 -0
- package/hooks/build/lib/runtime-ops.sh +221 -0
- package/hooks/build/lib/state-io.sh +5 -18
- package/hooks/build/load-build-state.sh +64 -2
- package/hooks/build/reflect-nudge.sh +15 -0
- package/hooks/build/release-gate-nudge.sh +15 -0
- package/hooks/build/release-ops.sh +164 -0
- package/hooks/build/scaffold-guard.sh +29 -2
- package/hooks/build/session-start.sh +103 -0
- package/hooks/build/session-stop.sh +22 -0
- package/hooks/build/slice-ops.sh +877 -0
- package/hooks/build/stack-guard.sh +8 -0
- package/hooks/build/statusline-bridge.sh +24 -3
- package/hooks/build-harness.json +16 -0
- package/package.json +3 -3
- package/scripts/check-agnostic.sh +3 -1
- package/scripts/check-pack-clean.sh +31 -0
- package/scripts/check-runtime-purity.sh +43 -0
- package/scripts/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +133 -0
- package/scripts/runtime-purity-allow.txt +5 -0
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/lib/http-stub.py +46 -0
- package/scripts/tests/test-baseline-verdict.sh +92 -0
- package/scripts/tests/test-config.sh +25 -0
- package/scripts/tests/test-hooks-runtime.sh +853 -0
- package/scripts/tests/test-install.sh +57 -0
- package/scripts/tests/test-runtime-client.sh +298 -0
- package/scripts/tests/test-schema.sh +29 -1
- package/scripts/tests/test-skill-ops.sh +847 -0
- package/skills/building-a-micro-change/SKILL.md +22 -4
- package/skills/building-a-slice/SKILL.md +58 -23
- package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
- package/skills/building-a-slice/references/dod.md +12 -3
- package/skills/building-a-slice/references/dor.md +7 -3
- package/skills/building-a-slice/references/evidence-budget.md +51 -0
- package/skills/building-a-slice/references/exploration-fanout.md +1 -1
- package/skills/building-a-slice/references/gitflow.md +1 -1
- package/skills/building-a-slice/references/regression-baseline.md +67 -0
- package/skills/building-a-slice/references/runtime-protocol.md +75 -0
- package/skills/building-a-slice/references/state-protocol.md +12 -1
- package/skills/building-a-slice/workflows/README.md +7 -3
- package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
- package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
- package/skills/managing-parallel-front/SKILL.md +32 -16
- package/skills/openspec-archive-change/SKILL.md +15 -0
- package/skills/prototyping-screens/SKILL.md +104 -0
- package/skills/prototyping-screens/assets/DESIGN.md.template +55 -0
- package/skills/prototyping-screens/assets/manifest.schema.json +70 -0
- package/skills/prototyping-screens/assets/screen.template.html +34 -0
- package/skills/prototyping-screens/references/aesthetic-directions.md +42 -0
- package/skills/prototyping-screens/references/extraction.md +57 -0
- package/skills/prototyping-screens/references/self-check.md +40 -0
- package/skills/releasing-a-version/SKILL.md +25 -16
- package/skills/releasing-a-version/references/release-dod.md +7 -5
- package/skills/releasing-a-version/workflows/README.md +2 -1
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
- package/skills/setup-architecture/SKILL.md +4 -2
- package/state/README.md +20 -3
- package/state/build-state.schema.json +3 -2
- package/templates/CLAUDE.md.template +17 -1
- package/templates/settings-hooks.template.json +8 -4
- 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
|