@trycore/spec-build-harness 0.8.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +27 -4
  3. package/INSTALL.md +27 -5
  4. package/METODOLOGIA.md +55 -5
  5. package/README.md +39 -6
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +13 -5
  9. package/agents/build/wiring-adversarial-verifier.md +52 -5
  10. package/commands/build/architect.md +1 -1
  11. package/commands/build/claim.md +46 -0
  12. package/commands/build/escalate.md +36 -0
  13. package/commands/build/front.md +9 -3
  14. package/commands/build/onboard.md +75 -14
  15. package/commands/build/prototype.md +3 -2
  16. package/commands/build/reflect.md +60 -40
  17. package/commands/build/release.md +10 -7
  18. package/commands/build/resume.md +33 -13
  19. package/commands/build/slice.md +32 -27
  20. package/commands/build/status.md +35 -0
  21. package/commands/build/work.md +11 -8
  22. package/config/build-config.template.json +4 -0
  23. package/dist/cli.js +32 -0
  24. package/dist/commands/doctor.js +42 -0
  25. package/dist/commands/init.js +84 -1
  26. package/dist/commands/migrate.js +153 -0
  27. package/dist/commands/status.js +34 -0
  28. package/dist/lib/normalize.js +1123 -0
  29. package/dist/lib/paths.js +6 -0
  30. package/dist/lib/runtime-client.js +196 -0
  31. package/dist/lib/settings-merge.js +3 -3
  32. package/dist/lib/state-bundle.js +150 -0
  33. package/docs/commands.md +25 -8
  34. package/docs/getting-started.md +1 -0
  35. package/docs/hooks.md +114 -27
  36. package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
  37. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  38. package/docs/runtime/protocolo-cliente-runtime.md +120 -35
  39. package/hooks/build/build-gate-check.sh +21 -0
  40. package/hooks/build/context-monitor.sh +82 -15
  41. package/hooks/build/context-sync.sh +192 -0
  42. package/hooks/build/design-source-guard.sh +30 -2
  43. package/hooks/build/dual-compare.sh +92 -0
  44. package/hooks/build/event-emitter.sh +32 -0
  45. package/hooks/build/gitflow-guard.sh +164 -14
  46. package/hooks/build/heartbeat.sh +259 -0
  47. package/hooks/build/lib/agent-context.sh +139 -0
  48. package/hooks/build/lib/config.sh +27 -0
  49. package/hooks/build/lib/projection.sh +71 -0
  50. package/hooks/build/lib/runtime-client.sh +625 -0
  51. package/hooks/build/lib/runtime-ops.sh +227 -0
  52. package/hooks/build/lib/state-io.sh +5 -18
  53. package/hooks/build/load-build-state.sh +64 -2
  54. package/hooks/build/reflect-nudge.sh +15 -0
  55. package/hooks/build/release-gate-nudge.sh +15 -0
  56. package/hooks/build/release-ops.sh +171 -0
  57. package/hooks/build/scaffold-guard.sh +29 -2
  58. package/hooks/build/session-start.sh +103 -0
  59. package/hooks/build/session-stop.sh +22 -0
  60. package/hooks/build/slice-ops.sh +948 -0
  61. package/hooks/build/stack-guard.sh +8 -0
  62. package/hooks/build/statusline-bridge.sh +24 -3
  63. package/hooks/build-harness.json +16 -0
  64. package/package.json +3 -3
  65. package/scripts/check-agnostic.sh +3 -1
  66. package/scripts/check-pack-clean.sh +31 -0
  67. package/scripts/check-runtime-purity.sh +43 -0
  68. package/scripts/denylist.txt +4 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +181 -0
  71. package/scripts/runtime-purity-allow.txt +5 -0
  72. package/scripts/smoke-test.sh +1 -1
  73. package/scripts/tests/lib/http-stub.py +46 -0
  74. package/scripts/tests/test-baseline-verdict.sh +92 -0
  75. package/scripts/tests/test-config.sh +25 -0
  76. package/scripts/tests/test-hooks-runtime.sh +828 -0
  77. package/scripts/tests/test-install.sh +103 -0
  78. package/scripts/tests/test-runtime-client.sh +298 -0
  79. package/scripts/tests/test-schema.sh +29 -1
  80. package/scripts/tests/test-skill-ops.sh +1367 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +55 -21
  83. package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
  84. package/skills/building-a-slice/references/dod.md +12 -3
  85. package/skills/building-a-slice/references/dor.md +3 -2
  86. package/skills/building-a-slice/references/evidence-budget.md +51 -0
  87. package/skills/building-a-slice/references/exploration-fanout.md +1 -1
  88. package/skills/building-a-slice/references/gitflow.md +1 -1
  89. package/skills/building-a-slice/references/regression-baseline.md +67 -0
  90. package/skills/building-a-slice/references/runtime-protocol.md +75 -0
  91. package/skills/building-a-slice/references/state-protocol.md +12 -1
  92. package/skills/building-a-slice/workflows/README.md +7 -3
  93. package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
  94. package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
  95. package/skills/managing-parallel-front/SKILL.md +32 -16
  96. package/skills/openspec-archive-change/SKILL.md +15 -0
  97. package/skills/prototyping-screens/SKILL.md +9 -5
  98. package/skills/releasing-a-version/SKILL.md +26 -16
  99. package/skills/releasing-a-version/references/release-dod.md +7 -5
  100. package/skills/releasing-a-version/workflows/README.md +2 -1
  101. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  102. package/skills/setup-architecture/SKILL.md +4 -2
  103. package/state/README.md +16 -1
  104. package/state/build-state.schema.json +2 -1
  105. package/templates/CLAUDE.md.template +16 -0
  106. package/templates/settings-hooks.template.json +8 -4
  107. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env bash
2
+ # runtime-client.sh — cliente bash del Agent Orchestrator Runtime [EP-OR-08-A].
3
+ # Diseño: fail-open, igual que state-io.sh/config.sh. Ninguna función bloquea ni lanza;
4
+ # ante red caída, curl/python3 ausentes, JSON corrupto o timeout, degrada a un valor
5
+ # vacío/código distinguible y jamás corrompe el estado local.
6
+ # Contrato de red: docs/runtime/protocolo-cliente-runtime.md + spec §3.1/§4.1-A.
7
+
8
+ source "$(dirname "${BASH_SOURCE[0]}")/config.sh"
9
+
10
+ # runtime_mode — TRYCORE_RUNTIME_MODE (env) > runtime.mode (config) > "legacy".
11
+ # En "legacy" ninguna función de red debe invocarse desde un guard/hook. Normaliza
12
+ # cualquier valor que no sea exactamente "dual"/"runtime" a "legacy" — un valor inválido
13
+ # o mal escrito (typo, mayúscula) debe fallar HACIA el modo seguro, nunca hacia la red.
14
+ runtime_mode() {
15
+ local m
16
+ if [ -n "${TRYCORE_RUNTIME_MODE:-}" ]; then
17
+ m="$TRYCORE_RUNTIME_MODE"
18
+ else
19
+ m="$(config_get runtime.mode legacy)"
20
+ fi
21
+ case "$m" in
22
+ dual|runtime) echo "$m" ;;
23
+ *) echo legacy ;;
24
+ esac
25
+ }
26
+
27
+ runtime_credentials_path() {
28
+ echo "$(config_root)/.claude/state/runtime.credentials"
29
+ }
30
+
31
+ # runtime_field <clave> — lee un campo del fichero de credenciales. Vacío si falta.
32
+ runtime_field() {
33
+ local key="$1" file
34
+ file="$(runtime_credentials_path)"
35
+ [ -f "$file" ] || { echo ""; return; }
36
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
37
+ python3 - "$file" "$key" <<'PY' 2>/dev/null
38
+ import json,sys
39
+ file,key=sys.argv[1],sys.argv[2]
40
+ try:
41
+ d=json.load(open(file))
42
+ v=d.get(key)
43
+ print(v if v is not None else "")
44
+ except Exception:
45
+ print("")
46
+ PY
47
+ }
48
+
49
+ # runtime_credentials_merge <json> — hace upsert de las claves del fragmento sobre el
50
+ # fichero existente (o uno nuevo). Escritura atómica + chmod 0600. Nunca imprime el token.
51
+ runtime_credentials_merge() {
52
+ local fragment="$1" file dir
53
+ file="$(runtime_credentials_path)"
54
+ dir="$(dirname "$file")"
55
+ mkdir -p "$dir"
56
+ command -v python3 >/dev/null 2>&1 || return 1
57
+ python3 - "$file" "$fragment" <<'PY' 2>/dev/null || return 1
58
+ import json,sys,os,tempfile
59
+ path,frag=sys.argv[1],sys.argv[2]
60
+ try:
61
+ d=json.load(open(path)) if os.path.exists(path) else {}
62
+ except Exception:
63
+ d={}
64
+ if not isinstance(d,dict):
65
+ d={}
66
+ d.update(json.loads(frag))
67
+ dirn=os.path.dirname(path) or "."
68
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".runtime-credentials.",suffix=".tmp")
69
+ try:
70
+ with os.fdopen(fd,"w") as out:
71
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
72
+ os.chmod(tmp,0o600)
73
+ os.replace(tmp,path)
74
+ except Exception:
75
+ try: os.unlink(tmp)
76
+ except OSError: pass
77
+ raise
78
+ PY
79
+ }
80
+
81
+ runtime_url() {
82
+ runtime_field runtime_url
83
+ }
84
+
85
+ # runtime_status_file — fichero de estado para el código HTTP de la última llamada.
86
+ # NO se usa una variable global: toda función de este cliente se invoca vía sustitución
87
+ # de comandos (`body="$(runtime_get ...)"`), que bash ejecuta en una subshell — cualquier
88
+ # asignación de variable dentro de esa subshell se pierde al volver al shell padre. Un
89
+ # fichero en disco sí sobrevive ese límite (mismo principio que context.lock/credentials).
90
+ runtime_status_file() {
91
+ echo "$(config_root)/.claude/state/.runtime-http-status"
92
+ }
93
+
94
+ # runtime_http_status — código HTTP de la última llamada a runtime_get/runtime_post.
95
+ runtime_http_status() {
96
+ local sfile; sfile="$(runtime_status_file)"
97
+ [ -f "$sfile" ] && cat "$sfile" || echo "000"
98
+ }
99
+
100
+ # _runtime_http <METHOD> <path> [body] — primitivo interno. Nunca lanza; ante fallo de
101
+ # red/curl/python3 deja runtime_http_status()=000 y stdout vacío. El body SIEMPRE se
102
+ # manda vía `--data-binary @fichero` (nunca `-d "$body"` directo): un body en argv está
103
+ # limitado por ARG_MAX del sistema (~1 MB típico) — con la cota del outbox en 5 MB
104
+ # (Task 8) un lote grande superaría ese límite y curl fallaría en silencio, fail-open,
105
+ # sin drenar nunca la cola. Un fichero no tiene ese límite.
106
+ _runtime_http() {
107
+ local method="$1" path="$2" body="${3:-}" url token tmp bodyfile status rc sfile
108
+ url="$(runtime_url)${path}"
109
+ token="$(runtime_field project_token)"
110
+ sfile="$(runtime_status_file)"
111
+ mkdir -p "$(dirname "$sfile")"
112
+ if [ -z "$(runtime_url)" ] || ! command -v curl >/dev/null 2>&1; then
113
+ printf '000' > "$sfile"
114
+ return 1
115
+ fi
116
+ tmp="$(mktemp)" || { printf '000' > "$sfile"; return 1; }
117
+ if [ -n "$body" ]; then
118
+ bodyfile="$(mktemp)" || { printf '000' > "$sfile"; rm -f "$tmp"; return 1; }
119
+ printf '%s' "$body" > "$bodyfile"
120
+ status="$(curl -sS -m 10 -o "$tmp" -w '%{http_code}' -X "$method" "$url" \
121
+ -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
122
+ --data-binary "@$bodyfile" 2>/dev/null)"
123
+ rc=$?
124
+ rm -f "$bodyfile"
125
+ else
126
+ status="$(curl -sS -m 10 -o "$tmp" -w '%{http_code}' -X "$method" "$url" \
127
+ -H "Authorization: Bearer $token" 2>/dev/null)"
128
+ rc=$?
129
+ fi
130
+ if [ $rc -ne 0 ] || [ -z "$status" ]; then
131
+ printf '000' > "$sfile"
132
+ rm -f "$tmp"
133
+ return 1
134
+ fi
135
+ printf '%s' "$status" > "$sfile"
136
+ cat "$tmp"
137
+ rm -f "$tmp"
138
+ return 0
139
+ }
140
+
141
+ runtime_get() { _runtime_http GET "$1" ""; }
142
+ runtime_post() { _runtime_http POST "$1" "$2"; }
143
+ runtime_put() { _runtime_http PUT "$1" "$2"; }
144
+
145
+ # runtime_register <harness_version> [asset_types_json="[]"]
146
+ # 0 = registrado (credenciales actualizadas) · 1 = fallo de red/parseo (fail-open, reintentable)
147
+ # 2 = 409 incompatible_version (no reintentar sin actualizar el arnés)
148
+ runtime_register() {
149
+ local hv="$1" asset_types="${2:-[]}" resp status frag
150
+ resp="$(runtime_post "/agents/register" "{\"harness_version\":\"$hv\",\"asset_types\":$asset_types}")"
151
+ status="$(runtime_http_status)"
152
+ case "$status" in
153
+ 200)
154
+ command -v python3 >/dev/null 2>&1 || return 1
155
+ frag="$(python3 - "$resp" <<'PY'
156
+ import json,sys
157
+ try:
158
+ d=json.loads(sys.argv[1])
159
+ out={k:d.get(k) for k in ("agent_key","project_id","poll_interval_s","lease_ttl_s","manifest_hash") if k in d}
160
+ print(json.dumps(out))
161
+ except Exception:
162
+ print("{}")
163
+ PY
164
+ )"
165
+ runtime_credentials_merge "$frag" || return 1
166
+ return 0
167
+ ;;
168
+ 409)
169
+ echo "⛔ runtime_register: versión del arnés incompatible con el runtime — $resp" >&2
170
+ return 2
171
+ ;;
172
+ *)
173
+ return 1
174
+ ;;
175
+ esac
176
+ }
177
+
178
+ runtime_lock_path() {
179
+ echo "$(config_root)/.claude/state/context.lock"
180
+ }
181
+
182
+ runtime_lock_manifest_hash() {
183
+ local file; file="$(runtime_lock_path)"
184
+ [ -f "$file" ] || { echo ""; return; }
185
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
186
+ python3 - "$file" <<'PY' 2>/dev/null
187
+ import json,sys
188
+ try:
189
+ print(json.load(open(sys.argv[1])).get("manifest_hash",""))
190
+ except Exception:
191
+ print("")
192
+ PY
193
+ }
194
+
195
+ # runtime_lock_write <manifest_hash> [files_json="[]"]
196
+ runtime_lock_write() {
197
+ local hash="$1" files="${2:-[]}" file dir
198
+ file="$(runtime_lock_path)"; dir="$(dirname "$file")"
199
+ mkdir -p "$dir"
200
+ command -v python3 >/dev/null 2>&1 || return 1
201
+ python3 - "$file" "$hash" "$files" <<'PY' 2>/dev/null || return 1
202
+ import json,sys,os,tempfile,datetime
203
+ path,h,files=sys.argv[1],sys.argv[2],sys.argv[3]
204
+ d={"manifest_hash":h,
205
+ "synced_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
206
+ "files":json.loads(files)}
207
+ dirn=os.path.dirname(path) or "."
208
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".context.lock.",suffix=".tmp")
209
+ try:
210
+ with os.fdopen(fd,"w") as out:
211
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
212
+ os.replace(tmp,path)
213
+ except Exception:
214
+ try: os.unlink(tmp)
215
+ except OSError: pass
216
+ raise
217
+ PY
218
+ }
219
+
220
+ runtime_projection_path() {
221
+ echo "$(config_root)/.claude/state/runtime-projection.json"
222
+ }
223
+
224
+ runtime_projection_read() {
225
+ local file; file="$(runtime_projection_path)"
226
+ [ -f "$file" ] || { echo "{}"; return; }
227
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
228
+ python3 - "$file" <<'PY' 2>/dev/null
229
+ import json,sys
230
+ try:
231
+ d=json.load(open(sys.argv[1]))
232
+ print(json.dumps(d))
233
+ except Exception:
234
+ print("{}")
235
+ PY
236
+ }
237
+
238
+ runtime_projection_write() {
239
+ local body="$1" file dir
240
+ file="$(runtime_projection_path)"; dir="$(dirname "$file")"
241
+ mkdir -p "$dir"
242
+ command -v python3 >/dev/null 2>&1 || return 1
243
+ python3 - "$file" "$body" <<'PY' 2>/dev/null || return 1
244
+ import json,sys,os,tempfile
245
+ path,body=sys.argv[1],sys.argv[2]
246
+ d=json.loads(body)
247
+ dirn=os.path.dirname(path) or "."
248
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".runtime-projection.",suffix=".tmp")
249
+ try:
250
+ with os.fdopen(fd,"w") as out:
251
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
252
+ os.replace(tmp,path)
253
+ except Exception:
254
+ try: os.unlink(tmp)
255
+ except OSError: pass
256
+ raise
257
+ PY
258
+ }
259
+
260
+ RUNTIME_OUTBOX_MAX_BYTES=5242880
261
+ RUNTIME_OUTBOX_MAX_AGE_S=259200
262
+ # [EP-OR-08-C] Los hechos de dominio que emiten las skills (slice-ops.sh) tampoco se evictan:
263
+ # perder un `slice_archived` o un `wiring_item_updated` dejaría la proyección del servidor
264
+ # mintiendo. El volumen evictable es `progress_noted` (y su alias 0.10.x).
265
+ # [#38] Los hechos máquina de proyecto viajan como UN solo tipo del catálogo v2
266
+ # (`project_fact_updated`); los cinco alias legados (`scaffold_confirmed`,
267
+ # `design_source_declared`, `project_kind_declared`, `foundation_declared`,
268
+ # `harness_phase_changed`) ya no se encolan y salieron de la lista.
269
+ # [#44] La lista lleva los NOMBRES DEL CATÁLOGO v2 del hub (event_catalog.SCHEMAS):
270
+ # proteger de la evicción un tipo que el hub va a rechazar era absurdo. Salen
271
+ # `branch_drift` y `front_integration_reported` (no existen en el catálogo; el despacho
272
+ # los aparta a rejected/). Los tres alias 0.10.x del final (`checkpoint_created`,
273
+ # `verdict_reported`, `escalation_raised`) siguen protegidos SOLO porque la capa de
274
+ # compat del despacho los traduce y entrega — evictarlos perdería hechos entregables.
275
+ RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated checkpoint_created verdict_reported escalation_raised"
276
+
277
+ runtime_outbox_dir() {
278
+ echo "$(config_root)/.claude/state/outbox"
279
+ }
280
+
281
+ # runtime_enqueue_event <type> <payload_json> [slice_id] — encola un evento con
282
+ # client_event_id propio (idempotencia server-side) y aplica la cota tras encolar.
283
+ # [EP-OR-08-B] `slice_id` es OPCIONAL y se escribe como clave de PRIMER NIVEL del evento
284
+ # (no dentro de payload): `POST /events` lo exige por elemento para los tipos de ámbito
285
+ # slice. Sin él, el evento se manda sin la clave — el ámbito proyecto la omite a propósito.
286
+ runtime_enqueue_event() {
287
+ local type="$1" payload="$2" slice_id="${3:-}" dir
288
+ dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
289
+ command -v python3 >/dev/null 2>&1 || return 1
290
+ python3 - "$dir" "$type" "$payload" "$slice_id" <<'PY' 2>/dev/null || return 1
291
+ import json,sys,os,tempfile,uuid,datetime
292
+ dir_,typ,payload,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
293
+ cid=str(uuid.uuid4())
294
+ ts=datetime.datetime.now(datetime.timezone.utc)
295
+ d={"client_event_id":cid,"type":typ,"payload":json.loads(payload),
296
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
297
+ if slice_id:
298
+ d["slice_id"]=slice_id
299
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
300
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
301
+ try:
302
+ with os.fdopen(fd,"w") as out:
303
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
304
+ os.replace(tmp,fname)
305
+ except Exception:
306
+ try: os.unlink(tmp)
307
+ except OSError: pass
308
+ raise
309
+ PY
310
+ runtime_outbox_enforce_cap >/dev/null
311
+ }
312
+
313
+ # runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
314
+ # (más viejos primero); jamás descarta un tipo de RUNTIME_OUTBOX_PROTECTED. Si hubo
315
+ # descartes, encola un telemetry_gap con el payload EXACTO del catálogo v2 [#44]
316
+ # (TelemetryGap: {dropped>=1, window_h>0, reason<=2000}; extra="forbid" — el
317
+ # {dropped_count, dropped_types} de 0.10.x era rechazo garantizado): `dropped` es el
318
+ # conteo, `window_h` la ventana de la cota en horas y `reason` el motivo con los tipos
319
+ # descartados. Imprime en stdout el conteo de descartados (última línea).
320
+ runtime_outbox_enforce_cap() {
321
+ local dir; dir="$(runtime_outbox_dir)"
322
+ [ -d "$dir" ] || { echo 0; return 0; }
323
+ command -v python3 >/dev/null 2>&1 || { echo 0; return 0; }
324
+ python3 - "$dir" "$RUNTIME_OUTBOX_MAX_BYTES" "$RUNTIME_OUTBOX_MAX_AGE_S" "$RUNTIME_OUTBOX_PROTECTED" <<'PY' 2>/dev/null
325
+ import json,os,sys,time,tempfile,uuid,datetime
326
+ dir_,max_bytes,max_age,protected=sys.argv[1],int(sys.argv[2]),int(sys.argv[3]),set(sys.argv[4].split())
327
+ files=[]
328
+ total=0
329
+ now=time.time()
330
+ for name in os.listdir(dir_):
331
+ if not name.endswith(".json") or name.startswith("."):
332
+ continue
333
+ p=os.path.join(dir_,name)
334
+ try:
335
+ st=os.stat(p)
336
+ d=json.load(open(p))
337
+ except Exception:
338
+ continue
339
+ files.append((st.st_mtime,p,d.get("type","")))
340
+ total+=st.st_size
341
+ files.sort()
342
+ dropped=[]
343
+ over_size=total>max_bytes
344
+ for mtime,p,typ in files:
345
+ over_age=(now-mtime)>max_age
346
+ if not (over_size or over_age):
347
+ break
348
+ if typ in protected:
349
+ continue
350
+ try:
351
+ sz=os.path.getsize(p)
352
+ os.unlink(p)
353
+ total-=sz
354
+ dropped.append(typ)
355
+ except OSError:
356
+ pass
357
+ over_size=total>max_bytes
358
+ if dropped:
359
+ # Coalescer en un telemetry_gap YA en cola (no despachado) en vez de acumular uno
360
+ # por descarte: telemetry_gap es protegido (nunca se evicta), así que sin coalescer
361
+ # crece sin límite mientras la cola siga sobre la cota.
362
+ existing_gap_path=None
363
+ existing_gap=None
364
+ for name in os.listdir(dir_):
365
+ if not name.endswith(".json") or name.startswith("."):
366
+ continue
367
+ p=os.path.join(dir_,name)
368
+ try:
369
+ d=json.load(open(p))
370
+ except Exception:
371
+ continue
372
+ if d.get("type")=="telemetry_gap":
373
+ existing_gap_path=p
374
+ existing_gap=d
375
+ break
376
+ window_h=max_age/3600.0
377
+ def resumen(tipos):
378
+ counts={}
379
+ for t in tipos:
380
+ k=t or "desconocido"
381
+ counts[k]=counts.get(k,0)+1
382
+ return ", ".join("%s x%d" % kv for kv in sorted(counts.items()))
383
+ encabezado="evicción por cota de la outbox (%d bytes / %.0f h)" % (max_bytes, window_h)
384
+ if existing_gap_path:
385
+ payload=existing_gap.get("payload") or {}
386
+ # Compat 0.10.x: un gap ya encolado puede traer {dropped_count, dropped_types};
387
+ # se normaliza aquí al payload del catálogo antes de acumular.
388
+ prev_n=payload.get("dropped", payload.get("dropped_count", 0)) or 0
389
+ prev_types=payload.get("dropped_types") or []
390
+ prev_reason=payload.get("reason") if isinstance(payload.get("reason"),str) else ""
391
+ base=prev_reason or (("%s: %s" % (encabezado, resumen(prev_types))) if prev_types else "")
392
+ texto=("%s; %s" % (base, resumen(dropped))) if base else ("%s: %s" % (encabezado, resumen(dropped)))
393
+ gap=existing_gap
394
+ gap["payload"]={"dropped": prev_n+len(dropped), "window_h": window_h, "reason": texto[:2000]}
395
+ fname=existing_gap_path
396
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
397
+ else:
398
+ cid=str(uuid.uuid4())
399
+ ts=datetime.datetime.now(datetime.timezone.utc)
400
+ gap={"client_event_id":cid,"type":"telemetry_gap",
401
+ "payload":{"dropped":len(dropped),"window_h":window_h,
402
+ "reason":("%s: %s" % (encabezado, resumen(dropped)))[:2000]},
403
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
404
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
405
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
406
+ try:
407
+ with os.fdopen(fd,"w") as out:
408
+ json.dump(gap,out,indent=2,ensure_ascii=False)
409
+ os.replace(tmp,fname)
410
+ except Exception:
411
+ # No re-lanzar: la evicción YA tuvo éxito: fallar aquí no debe ocultar ese
412
+ # resultado ni romper el contrato de stdout (print(len(dropped)) siempre corre).
413
+ try: os.unlink(tmp)
414
+ except OSError: pass
415
+ print(len(dropped))
416
+ PY
417
+ }
418
+
419
+ RUNTIME_OUTBOX_BACKOFF_SCHEDULE="1 5 30 300"
420
+
421
+ # runtime_dispatch_outbox — despacho best-effort de la cola. No-op en modo legacy.
422
+ # Respeta un backoff persistido (nunca bloquea reintentando en el hilo del hook). FIFO
423
+ # global (el agrupado por-agregado nace con el catálogo de eventos del sub-slice C).
424
+ runtime_dispatch_outbox() {
425
+ local mode; mode="$(runtime_mode)"
426
+ [ "$mode" = "legacy" ] && return 0
427
+
428
+ command -v python3 >/dev/null 2>&1 || return 1
429
+
430
+ local dir; dir="$(runtime_outbox_dir)"
431
+ [ -d "$dir" ] || return 0
432
+
433
+ local state_file="$dir/.dispatch-state.json"
434
+ local now next_ok=0 attempt=0
435
+ now="$(date +%s)"
436
+ if [ -f "$state_file" ]; then
437
+ next_ok="$(python3 -c "import json;print(json.load(open('$state_file')).get('next_attempt_epoch',0))" 2>/dev/null || echo 0)"
438
+ attempt="$(python3 -c "import json;print(json.load(open('$state_file')).get('attempt',0))" 2>/dev/null || echo 0)"
439
+ fi
440
+ [ "$now" -lt "$next_ok" ] && return 0
441
+
442
+ local files=()
443
+ while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
444
+ [ ${#files[@]} -eq 0 ] && return 0
445
+
446
+ # [#37] El hub (`EventBatchIn`) exige el sobre {"events": [...]} — el array pelado era
447
+ # 422 en cada intento y la cola no drenaba jamás. Cada elemento va con `event_type`
448
+ # (`EventIn`); la clave interna de la outbox sigue siendo `type` y se traduce AQUÍ, al
449
+ # armar el cuerpo: los ficheros ya encolados en consumidores (0.10.x, clave `type`)
450
+ # drenan sin migración y el formato en disco no cambia (lo leen otros clientes).
451
+ # [#44] En este MISMO punto de salida vive la capa de compat de nombres/payloads: los
452
+ # productores ya encolan los nombres del catálogo v2, pero un consumidor 0.10.x dejó
453
+ # ficheros con los nombres viejos (`verdict_reported`, `checkpoint_created`,
454
+ # `escalation_raised`, `progress_note_recorded`) y payloads que `extra="forbid"`
455
+ # rechazaría — se traducen tipo Y claves al armar el cuerpo. Los tipos SIN equivalente
456
+ # en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se
457
+ # apartan localmente a rejected/ sin gastar red: mandarlos era rechazo garantizado.
458
+ local batch
459
+ batch="$(python3 - "$dir/rejected" "${files[@]}" <<'PY'
460
+ import json,sys,os
461
+ rejected_dir=sys.argv[1]
462
+ SIN_EQUIVALENTE={"tool_use_recorded","branch_drift","front_integration_reported"}
463
+ def traducir(t,p,slice_id):
464
+ p=dict(p)
465
+ if t=="verdict_reported":
466
+ # Un veredicto de RELEASE 0.10.x (payload con release_line, sin slice) no tiene
467
+ # camino por /events: se deja pasar tal cual y el hub lo rechaza con razón — la
468
+ # forma de slice se traduce a gate_verdict {gate, verdict, evidence…}.
469
+ t="gate_verdict"
470
+ if "status" in p and "verdict" not in p:
471
+ p["verdict"]=p.pop("status")
472
+ v=p.get("verdict")
473
+ if isinstance(v,str) and v.lower() in ("pass","fail"):
474
+ p["verdict"]=v.upper()
475
+ p={k:v for k,v in p.items() if k in ("gate","verdict","evidence","cause","details_ref")}
476
+ elif t=="checkpoint_created":
477
+ t="checkpoint_recorded"
478
+ if "note" in p and "summary" not in p:
479
+ p["summary"]=p.pop("note")
480
+ sid=p.pop("slice_id",None)
481
+ if sid and not slice_id:
482
+ slice_id=sid
483
+ p={k:v for k,v in p.items() if k in ("branch","commit_sha","summary")}
484
+ elif t=="escalation_raised":
485
+ t="slice_escalated"
486
+ pref=""
487
+ if p.get("gate"): pref+="[gate: %s] " % p["gate"]
488
+ if p.get("phase"): pref+="[fase: %s] " % p["phase"]
489
+ cause=p.get("cause") or (pref+str(p.get("reason") or ""))
490
+ p={"cause": (cause.strip() or "escalada sin causa registrada")[:2000]}
491
+ elif t=="progress_note_recorded":
492
+ t="progress_noted"
493
+ p={k:v for k,v in p.items() if k=="note"}
494
+ elif t in ("slice_submitted","slice_archived"):
495
+ p={} # el catálogo no admite ningún campo (extra="forbid")
496
+ elif t=="wiring_checklist_seeded":
497
+ items=[]
498
+ for it in p.get("items") or []:
499
+ if isinstance(it,dict) and it.get("item_id"):
500
+ items.append({"item_id":it["item_id"],"kind":it.get("kind") or "","ref":it.get("ref") or ""})
501
+ p={"items":items}
502
+ elif t=="telemetry_gap" and "dropped" not in p and "dropped_count" in p:
503
+ tipos=p.get("dropped_types") or []
504
+ p={"dropped":p.get("dropped_count") or 0,"window_h":72.0,
505
+ "reason":("evicción por cota de la outbox del cliente; tipos descartados: "
506
+ +", ".join(str(x) for x in tipos))[:2000]}
507
+ return t,p,slice_id
508
+ items=[]
509
+ for path in sys.argv[2:]:
510
+ try:
511
+ d=json.load(open(path))
512
+ except Exception:
513
+ continue
514
+ t=d.get("event_type") or d.get("type") or ""
515
+ if t in SIN_EQUIVALENTE:
516
+ try:
517
+ os.makedirs(rejected_dir,exist_ok=True)
518
+ os.replace(path,os.path.join(rejected_dir,os.path.basename(path)))
519
+ print("⚠ %r no existe en el catálogo v2 del hub: apartado a rejected/, no se enviará (issue #44)"
520
+ % t, file=sys.stderr)
521
+ except OSError:
522
+ pass
523
+ continue
524
+ t,p,slice_id=traducir(t,d.get("payload") or {},d.get("slice_id"))
525
+ ev={"client_event_id": d.get("client_event_id") or "",
526
+ "event_type": t,
527
+ "payload": p}
528
+ if slice_id:
529
+ ev["slice_id"]=slice_id
530
+ items.append(ev)
531
+ print(json.dumps({"events": items}, ensure_ascii=False))
532
+ PY
533
+ )"
534
+
535
+ # Si todo lo pendiente eran tipos sin equivalente (ya apartados), no hay nada que enviar.
536
+ local n_items
537
+ n_items="$(printf '%s' "$batch" | python3 -c 'import json,sys;print(len(json.load(sys.stdin).get("events") or []))' 2>/dev/null || echo 0)"
538
+ if [ "$n_items" = "0" ]; then
539
+ rm -f "$state_file"
540
+ return 0
541
+ fi
542
+
543
+ local resp status
544
+ resp="$(runtime_post "/events" "$batch")"
545
+ status="$(runtime_http_status)"
546
+
547
+ if [ "$status" = "200" ]; then
548
+ # [#37] Ack REAL del hub: {accepted:[cid], duplicates:[cid], rejected:[{client_event_id,
549
+ # reason}]}. Se conserva la lectura de la forma antigua {"results":[…]} (stubs/beta).
550
+ # Aceptado o duplicado => entregado, se borra. Rechazado POR ELEMENTO (fuera de catálogo
551
+ # o payload inválido) => reenviarlo es inútil: se aparta a rejected/ con su razón en
552
+ # stderr. Sin mención => se conserva para el próximo despacho (nunca se asume aceptado
553
+ # en silencio: perder un checkpoint/verdict/submit sin confirmación real viola la cota).
554
+ python3 - "$resp" "$dir/rejected" "${files[@]}" <<'PY'
555
+ import json,sys,os
556
+ resp=json.loads(sys.argv[1])
557
+ rejected_dir=sys.argv[2]
558
+ paths=sys.argv[3:]
559
+ status={}
560
+ reasons={}
561
+ if isinstance(resp,dict) and "results" in resp:
562
+ for r in resp.get("results") or []:
563
+ status[r.get("client_event_id")]=r.get("status")
564
+ elif isinstance(resp,dict):
565
+ for cid in resp.get("accepted") or []:
566
+ status[cid]="accepted"
567
+ for cid in resp.get("duplicates") or []:
568
+ status[cid]="accepted"
569
+ for r in resp.get("rejected") or []:
570
+ cid=r.get("client_event_id")
571
+ status[cid]="rejected"
572
+ reasons[cid]=r.get("reason") or "sin razón"
573
+ for p in paths:
574
+ try:
575
+ cid=json.load(open(p)).get("client_event_id")
576
+ except Exception:
577
+ continue
578
+ st=status.get(cid)
579
+ if st=="accepted":
580
+ try: os.unlink(p)
581
+ except OSError: pass
582
+ elif st=="rejected":
583
+ print("⚠ el hub rechazó el evento %s: %s — apartado a rejected/, no se reenviará"
584
+ % (cid, reasons.get(cid,"sin razón")), file=sys.stderr)
585
+ try:
586
+ os.makedirs(rejected_dir,exist_ok=True)
587
+ os.replace(p, os.path.join(rejected_dir, os.path.basename(p)))
588
+ except OSError:
589
+ pass
590
+ PY
591
+ rm -f "$state_file"
592
+ return 0
593
+ fi
594
+
595
+ # [#37] Un 4xx del LOTE (salvo 408/429) es un rechazo del contrato, no red caída:
596
+ # reintentarlo para siempre solo repite el mismo error y el usuario cree que reportó.
597
+ # Se loggea el cuerpo, el lote entero se aparta a rejected/ y NO se agenda backoff.
598
+ # 408 (timeout) y 429 (rate-limit) siguen siendo transitorios, igual que 5xx y 000.
599
+ case "$status" in
600
+ 408|429) : ;;
601
+ 4*)
602
+ local rejected_dir f
603
+ rejected_dir="$dir/rejected"
604
+ mkdir -p "$rejected_dir"
605
+ echo "⛔ el hub rechazó el lote de eventos (HTTP $status): $resp" >&2
606
+ echo " ${#files[@]} evento(s) movidos a $rejected_dir/ — NO se reintentarán." >&2
607
+ echo " Revisa el contrato del cliente (docs/runtime/protocolo-cliente-runtime.md) o" >&2
608
+ echo " actualiza el arnés; el trabajo local sigue (fail-open)." >&2
609
+ for f in "${files[@]}"; do
610
+ mv "$f" "$rejected_dir/" 2>/dev/null
611
+ done
612
+ rm -f "$state_file"
613
+ return 1
614
+ ;;
615
+ esac
616
+
617
+ local schedule idx max_idx delay
618
+ read -ra schedule <<< "$RUNTIME_OUTBOX_BACKOFF_SCHEDULE"
619
+ idx=$attempt
620
+ max_idx=$(( ${#schedule[@]} - 1 ))
621
+ [ "$idx" -gt "$max_idx" ] && idx=$max_idx
622
+ delay="${schedule[$idx]}"
623
+ python3 -c "import json; json.dump({'next_attempt_epoch': $now + $delay, 'attempt': $((attempt + 1))}, open('$state_file','w'))"
624
+ return 1
625
+ }