@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.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +43 -5
  3. package/INSTALL.md +28 -6
  4. package/METODOLOGIA.md +65 -10
  5. package/README.md +41 -7
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +17 -6
  9. package/agents/build/ux-fidelity-reviewer.md +4 -1
  10. package/agents/build/wiring-adversarial-verifier.md +52 -5
  11. package/commands/build/architect.md +1 -1
  12. package/commands/build/claim.md +46 -0
  13. package/commands/build/escalate.md +36 -0
  14. package/commands/build/front.md +9 -3
  15. package/commands/build/onboard.md +63 -14
  16. package/commands/build/prototype.md +23 -0
  17. package/commands/build/reflect.md +60 -40
  18. package/commands/build/release.md +10 -7
  19. package/commands/build/resume.md +33 -13
  20. package/commands/build/slice.md +32 -27
  21. package/commands/build/status.md +35 -0
  22. package/commands/build/work.md +11 -8
  23. package/config/build-config.template.json +4 -0
  24. package/dist/cli.js +22 -0
  25. package/dist/commands/doctor.js +42 -0
  26. package/dist/commands/init.js +84 -1
  27. package/dist/commands/migrate.js +48 -0
  28. package/dist/commands/status.js +34 -0
  29. package/dist/lib/normalize.js +276 -0
  30. package/dist/lib/paths.js +6 -0
  31. package/dist/lib/runtime-client.js +196 -0
  32. package/dist/lib/settings-merge.js +3 -3
  33. package/dist/lib/state-bundle.js +46 -0
  34. package/docs/commands.md +32 -9
  35. package/docs/getting-started.md +2 -1
  36. package/docs/hooks.md +114 -27
  37. package/docs/runtime/guia-modo-dual-y-migracion.md +136 -0
  38. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  39. package/docs/runtime/protocolo-cliente-runtime.md +109 -34
  40. package/hooks/build/build-gate-check.sh +21 -0
  41. package/hooks/build/context-monitor.sh +82 -15
  42. package/hooks/build/context-sync.sh +192 -0
  43. package/hooks/build/design-source-guard.sh +31 -3
  44. package/hooks/build/dual-compare.sh +92 -0
  45. package/hooks/build/event-emitter.sh +75 -0
  46. package/hooks/build/gitflow-guard.sh +164 -14
  47. package/hooks/build/heartbeat.sh +259 -0
  48. package/hooks/build/lib/agent-context.sh +139 -0
  49. package/hooks/build/lib/config.sh +27 -0
  50. package/hooks/build/lib/projection.sh +71 -0
  51. package/hooks/build/lib/runtime-client.sh +465 -0
  52. package/hooks/build/lib/runtime-ops.sh +221 -0
  53. package/hooks/build/lib/state-io.sh +5 -18
  54. package/hooks/build/load-build-state.sh +64 -2
  55. package/hooks/build/reflect-nudge.sh +15 -0
  56. package/hooks/build/release-gate-nudge.sh +15 -0
  57. package/hooks/build/release-ops.sh +164 -0
  58. package/hooks/build/scaffold-guard.sh +29 -2
  59. package/hooks/build/session-start.sh +103 -0
  60. package/hooks/build/session-stop.sh +22 -0
  61. package/hooks/build/slice-ops.sh +877 -0
  62. package/hooks/build/stack-guard.sh +8 -0
  63. package/hooks/build/statusline-bridge.sh +24 -3
  64. package/hooks/build-harness.json +16 -0
  65. package/package.json +3 -3
  66. package/scripts/check-agnostic.sh +3 -1
  67. package/scripts/check-pack-clean.sh +31 -0
  68. package/scripts/check-runtime-purity.sh +43 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +133 -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 +853 -0
  77. package/scripts/tests/test-install.sh +57 -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 +847 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +58 -23
  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 +7 -3
  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 +104 -0
  98. package/skills/prototyping-screens/assets/DESIGN.md.template +55 -0
  99. package/skills/prototyping-screens/assets/manifest.schema.json +70 -0
  100. package/skills/prototyping-screens/assets/screen.template.html +34 -0
  101. package/skills/prototyping-screens/references/aesthetic-directions.md +42 -0
  102. package/skills/prototyping-screens/references/extraction.md +57 -0
  103. package/skills/prototyping-screens/references/self-check.md +40 -0
  104. package/skills/releasing-a-version/SKILL.md +25 -16
  105. package/skills/releasing-a-version/references/release-dod.md +7 -5
  106. package/skills/releasing-a-version/workflows/README.md +2 -1
  107. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  108. package/skills/setup-architecture/SKILL.md +4 -2
  109. package/state/README.md +20 -3
  110. package/state/build-state.schema.json +3 -2
  111. package/templates/CLAUDE.md.template +17 -1
  112. package/templates/settings-hooks.template.json +8 -4
  113. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -0,0 +1,465 @@
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 sigue siendo `tool_use_recorded` y `progress_note_recorded`.
265
+ RUNTIME_OUTBOX_PROTECTED="checkpoint_created verdict_reported slice_submitted handoff_recorded telemetry_gap branch_drift slice_archived wiring_checklist_seeded wiring_item_updated escalation_raised scaffold_confirmed design_source_declared project_kind_declared foundation_declared harness_phase_changed front_integration_reported"
266
+
267
+ runtime_outbox_dir() {
268
+ echo "$(config_root)/.claude/state/outbox"
269
+ }
270
+
271
+ # runtime_enqueue_event <type> <payload_json> [slice_id] — encola un evento con
272
+ # client_event_id propio (idempotencia server-side) y aplica la cota tras encolar.
273
+ # [EP-OR-08-B] `slice_id` es OPCIONAL y se escribe como clave de PRIMER NIVEL del evento
274
+ # (no dentro de payload): `POST /events` lo exige por elemento para los tipos de ámbito
275
+ # slice. Sin él, el evento se manda sin la clave — el ámbito proyecto la omite a propósito.
276
+ runtime_enqueue_event() {
277
+ local type="$1" payload="$2" slice_id="${3:-}" dir
278
+ dir="$(runtime_outbox_dir)"; mkdir -p "$dir"
279
+ command -v python3 >/dev/null 2>&1 || return 1
280
+ python3 - "$dir" "$type" "$payload" "$slice_id" <<'PY' 2>/dev/null || return 1
281
+ import json,sys,os,tempfile,uuid,datetime
282
+ dir_,typ,payload,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
283
+ cid=str(uuid.uuid4())
284
+ ts=datetime.datetime.now(datetime.timezone.utc)
285
+ d={"client_event_id":cid,"type":typ,"payload":json.loads(payload),
286
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
287
+ if slice_id:
288
+ d["slice_id"]=slice_id
289
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
290
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
291
+ try:
292
+ with os.fdopen(fd,"w") as out:
293
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
294
+ os.replace(tmp,fname)
295
+ except Exception:
296
+ try: os.unlink(tmp)
297
+ except OSError: pass
298
+ raise
299
+ PY
300
+ runtime_outbox_enforce_cap >/dev/null
301
+ }
302
+
303
+ # runtime_outbox_enforce_cap — cota 5MB/72h. Descarta primero los eventos NO protegidos
304
+ # (más viejos primero); jamás descarta checkpoint/verdict/submit/handoff/telemetry_gap/
305
+ # branch_drift. Si hubo descartes, encola un telemetry_gap describiéndolos. Imprime en
306
+ # stdout el conteo de descartados (última línea).
307
+ runtime_outbox_enforce_cap() {
308
+ local dir; dir="$(runtime_outbox_dir)"
309
+ [ -d "$dir" ] || { echo 0; return 0; }
310
+ command -v python3 >/dev/null 2>&1 || { echo 0; return 0; }
311
+ python3 - "$dir" "$RUNTIME_OUTBOX_MAX_BYTES" "$RUNTIME_OUTBOX_MAX_AGE_S" "$RUNTIME_OUTBOX_PROTECTED" <<'PY' 2>/dev/null
312
+ import json,os,sys,time,tempfile,uuid,datetime
313
+ dir_,max_bytes,max_age,protected=sys.argv[1],int(sys.argv[2]),int(sys.argv[3]),set(sys.argv[4].split())
314
+ files=[]
315
+ total=0
316
+ now=time.time()
317
+ for name in os.listdir(dir_):
318
+ if not name.endswith(".json") or name.startswith("."):
319
+ continue
320
+ p=os.path.join(dir_,name)
321
+ try:
322
+ st=os.stat(p)
323
+ d=json.load(open(p))
324
+ except Exception:
325
+ continue
326
+ files.append((st.st_mtime,p,d.get("type","")))
327
+ total+=st.st_size
328
+ files.sort()
329
+ dropped=[]
330
+ over_size=total>max_bytes
331
+ for mtime,p,typ in files:
332
+ over_age=(now-mtime)>max_age
333
+ if not (over_size or over_age):
334
+ break
335
+ if typ in protected:
336
+ continue
337
+ try:
338
+ sz=os.path.getsize(p)
339
+ os.unlink(p)
340
+ total-=sz
341
+ dropped.append(typ)
342
+ except OSError:
343
+ pass
344
+ over_size=total>max_bytes
345
+ if dropped:
346
+ # Coalescer en un telemetry_gap YA en cola (no despachado) en vez de acumular uno
347
+ # por descarte: telemetry_gap es protegido (nunca se evicta), así que sin coalescer
348
+ # crece sin límite mientras la cola siga sobre la cota.
349
+ existing_gap_path=None
350
+ existing_gap=None
351
+ for name in os.listdir(dir_):
352
+ if not name.endswith(".json") or name.startswith("."):
353
+ continue
354
+ p=os.path.join(dir_,name)
355
+ try:
356
+ d=json.load(open(p))
357
+ except Exception:
358
+ continue
359
+ if d.get("type")=="telemetry_gap":
360
+ existing_gap_path=p
361
+ existing_gap=d
362
+ break
363
+ if existing_gap_path:
364
+ payload=existing_gap.get("payload") or {}
365
+ payload["dropped_count"]=payload.get("dropped_count",0)+len(dropped)
366
+ payload["dropped_types"]=payload.get("dropped_types",[])+dropped
367
+ gap=existing_gap
368
+ gap["payload"]=payload
369
+ fname=existing_gap_path
370
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
371
+ else:
372
+ cid=str(uuid.uuid4())
373
+ ts=datetime.datetime.now(datetime.timezone.utc)
374
+ gap={"client_event_id":cid,"type":"telemetry_gap",
375
+ "payload":{"dropped_count":len(dropped),"dropped_types":dropped},
376
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
377
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
378
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
379
+ try:
380
+ with os.fdopen(fd,"w") as out:
381
+ json.dump(gap,out,indent=2,ensure_ascii=False)
382
+ os.replace(tmp,fname)
383
+ except Exception:
384
+ # No re-lanzar: la evicción YA tuvo éxito: fallar aquí no debe ocultar ese
385
+ # resultado ni romper el contrato de stdout (print(len(dropped)) siempre corre).
386
+ try: os.unlink(tmp)
387
+ except OSError: pass
388
+ print(len(dropped))
389
+ PY
390
+ }
391
+
392
+ RUNTIME_OUTBOX_BACKOFF_SCHEDULE="1 5 30 300"
393
+
394
+ # runtime_dispatch_outbox — despacho best-effort de la cola. No-op en modo legacy.
395
+ # Respeta un backoff persistido (nunca bloquea reintentando en el hilo del hook). FIFO
396
+ # global (el agrupado por-agregado nace con el catálogo de eventos del sub-slice C).
397
+ runtime_dispatch_outbox() {
398
+ local mode; mode="$(runtime_mode)"
399
+ [ "$mode" = "legacy" ] && return 0
400
+
401
+ command -v python3 >/dev/null 2>&1 || return 1
402
+
403
+ local dir; dir="$(runtime_outbox_dir)"
404
+ [ -d "$dir" ] || return 0
405
+
406
+ local state_file="$dir/.dispatch-state.json"
407
+ local now next_ok=0 attempt=0
408
+ now="$(date +%s)"
409
+ if [ -f "$state_file" ]; then
410
+ next_ok="$(python3 -c "import json;print(json.load(open('$state_file')).get('next_attempt_epoch',0))" 2>/dev/null || echo 0)"
411
+ attempt="$(python3 -c "import json;print(json.load(open('$state_file')).get('attempt',0))" 2>/dev/null || echo 0)"
412
+ fi
413
+ [ "$now" -lt "$next_ok" ] && return 0
414
+
415
+ local files=()
416
+ while IFS= read -r f; do files+=("$f"); done < <(find "$dir" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' | sort)
417
+ [ ${#files[@]} -eq 0 ] && return 0
418
+
419
+ local batch
420
+ batch="$(python3 - "${files[@]}" <<'PY'
421
+ import json,sys
422
+ items=[]
423
+ for p in sys.argv[1:]:
424
+ try: items.append(json.load(open(p)))
425
+ except Exception: pass
426
+ print(json.dumps(items))
427
+ PY
428
+ )"
429
+
430
+ local resp status
431
+ resp="$(runtime_post "/events" "$batch")"
432
+ status="$(runtime_http_status)"
433
+
434
+ if [ "$status" = "200" ]; then
435
+ python3 - "$resp" "${files[@]}" <<'PY'
436
+ import json,sys,os
437
+ resp=json.loads(sys.argv[1])
438
+ paths=sys.argv[2:]
439
+ results={r.get("client_event_id"):r.get("status") for r in resp.get("results",[])}
440
+ for p in paths:
441
+ try:
442
+ cid=json.load(open(p)).get("client_event_id")
443
+ except Exception:
444
+ continue
445
+ # Sin mención en `results` => se conserva para el próximo despacho (nunca se asume
446
+ # aceptado en silencio: perder un checkpoint/verdict/submit sin confirmación real
447
+ # viola el contrato de la cota, Task 8).
448
+ st=results.get(cid)
449
+ if st in ("accepted","rejected"):
450
+ try: os.unlink(p)
451
+ except OSError: pass
452
+ PY
453
+ rm -f "$state_file"
454
+ return 0
455
+ fi
456
+
457
+ local schedule idx max_idx delay
458
+ read -ra schedule <<< "$RUNTIME_OUTBOX_BACKOFF_SCHEDULE"
459
+ idx=$attempt
460
+ max_idx=$(( ${#schedule[@]} - 1 ))
461
+ [ "$idx" -gt "$max_idx" ] && idx=$max_idx
462
+ delay="${schedule[$idx]}"
463
+ python3 -c "import json; json.dump({'next_attempt_epoch': $now + $delay, 'attempt': $((attempt + 1))}, open('$state_file','w'))"
464
+ return 1
465
+ }
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env bash
2
+ # runtime-ops.sh — base común de las OPERACIONES DE DOMINIO que conducen las skills
3
+ # [EP-OR-08-C]. Los hooks (sub-slice B) observan; esto transiciona: claim, veredictos,
4
+ # checkpoints, submit, archive y hechos de proyecto. Lo usan hooks/build/slice-ops.sh y
5
+ # hooks/build/release-ops.sh, que son INVOCADOS por las skills (no hooks registrados).
6
+ #
7
+ # Aquí vive el ÚNICO sitio del cliente bash donde se escriben rutas del runtime: si el
8
+ # re-volcado de OpenAPI (EP-OR-13) mueve un endpoint, se toca este fichero y nada más.
9
+ #
10
+ # Diseño fail-open, igual que runtime-client.sh: ninguna función lanza; ante red caída,
11
+ # python3 ausente o JSON corrupto se degrada a un valor seguro o a un rc distinguible.
12
+
13
+ source "$(dirname "${BASH_SOURCE[0]}")/projection.sh"
14
+ source "$(dirname "${BASH_SOURCE[0]}")/agent-context.sh"
15
+
16
+ # ── Códigos de salida (contrato con las skills; la prosa ramifica sobre estos números) ──
17
+ OPS_RC_OK=0
18
+ OPS_RC_USAGE=2
19
+ OPS_RC_LEGACY=3
20
+ OPS_RC_NO_SLICE=4
21
+ OPS_RC_OFFLINE=5
22
+ OPS_RC_REJECTED=6
23
+ OPS_RC_NO_WORK=7
24
+
25
+ # Tope de texto libre que el cliente manda al servidor (evidencias, notas): el protocolo §8
26
+ # dice que el servidor rechaza payloads sobredimensionados; se trunca aquí, marcándolo.
27
+ OPS_MAX_TEXT=4000
28
+
29
+ # Gates canónicos (se validan en el cliente: un nombre inventado por el modelo no llega
30
+ # a la red — compuerta mecánica barata, sin round-trip).
31
+ OPS_SLICE_GATES="dor coherence_link tdd journey_smoke fidelity api data wiring_verified dod"
32
+ OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
33
+
34
+ # ── Tabla de rutas ──
35
+ OPS_PATH_CLAIM="/tasks/next"
36
+ OPS_PATH_CHECKPOINTS="/checkpoints"
37
+ ops_path_verdicts() { echo "/slices/$1/verdicts"; }
38
+ ops_path_submit() { echo "/slices/$1/submit"; }
39
+ ops_path_release_verdicts() { echo "/releases/$1/verdicts"; }
40
+ ops_path_front_integration() { echo "/fronts/$1/members/$2/integration"; }
41
+ ops_path_mirror() { echo "/projects/$1/mirror/transitions"; }
42
+ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
43
+
44
+ ops_mode() { runtime_mode; }
45
+
46
+ # ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
47
+ ops_slice_id() { projection_get active_slice.slice_id ""; }
48
+
49
+ # ops_urlenc <texto> — percent-encoding para segmentos de ruta (una línea de release puede
50
+ # llevar espacios o barras). Sin python3 devuelve el texto tal cual: peor URL, nunca excepción.
51
+ ops_urlenc() {
52
+ command -v python3 >/dev/null 2>&1 || { printf '%s' "$1"; return; }
53
+ printf '%s' "$1" | python3 -c 'import sys,urllib.parse; sys.stdout.write(urllib.parse.quote(sys.stdin.read(), safe=""))' 2>/dev/null || printf '%s' "$1"
54
+ }
55
+
56
+ # ops_json_str <texto> — imprime el texto como cadena JSON (comillas incluidas). El texto
57
+ # viaja por STDIN, nunca por argv: así ni ARG_MAX ni el quoting de bash lo limitan.
58
+ ops_json_str() {
59
+ command -v python3 >/dev/null 2>&1 || { printf '""'; return; }
60
+ printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read(), ensure_ascii=False))' 2>/dev/null || printf '""'
61
+ }
62
+
63
+ # ops_json_str_file <fichero> [max=OPS_MAX_TEXT] — igual, leyendo de un fichero y truncando
64
+ # al tope (marcado en el propio texto para que el servidor y el humano lo vean).
65
+ ops_json_str_file() {
66
+ local f="$1" max="${2:-$OPS_MAX_TEXT}"
67
+ [ -f "$f" ] || { printf '""'; return; }
68
+ command -v python3 >/dev/null 2>&1 || { printf '""'; return; }
69
+ python3 - "$f" "$max" <<'PY' 2>/dev/null || printf '""'
70
+ import json,sys
71
+ try:
72
+ t=open(sys.argv[1], encoding="utf-8", errors="replace").read()
73
+ except Exception:
74
+ t=""
75
+ m=int(sys.argv[2])
76
+ if len(t) > m:
77
+ t = t[:m] + "\n…[truncado por el cliente]"
78
+ sys.stdout.write(json.dumps(t, ensure_ascii=False))
79
+ PY
80
+ }
81
+
82
+ # ops_tmpjson <json> — materializa un JSON en un fichero temporal e imprime su ruta.
83
+ # Los payloads viajan a python3 y a curl POR FICHERO (regla dura del repo: nunca por argv).
84
+ ops_tmpjson() {
85
+ local f
86
+ f="$(mktemp)" || return 1
87
+ printf '%s' "$1" > "$f"
88
+ echo "$f"
89
+ }
90
+
91
+ # ops_context_hashes — sha256 EFECTIVO local de cada archivo gobernado del lock
92
+ # (protocolo §3 regla 3: el servidor detecta drift/manipulación local comparándolos con los
93
+ # 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. Fichero ausente => null.
95
+ ops_context_hashes() {
96
+ local lock root
97
+ lock="$(runtime_lock_path)"
98
+ root="$(config_root)"
99
+ command -v python3 >/dev/null 2>&1 || { echo "[]"; return; }
100
+ python3 - "$lock" "$root" <<'PY' 2>/dev/null || echo "[]"
101
+ import json,sys,os,hashlib
102
+ lock,root=sys.argv[1],sys.argv[2]
103
+ try:
104
+ files=json.load(open(lock)).get("files") or []
105
+ except Exception:
106
+ files=[]
107
+ out=[]
108
+ for f in files:
109
+ if not isinstance(f,dict):
110
+ continue
111
+ p=f.get("path")
112
+ if not isinstance(p,str) or not p:
113
+ continue
114
+ full=os.path.join(root,".claude",p)
115
+ sha=None
116
+ try:
117
+ h=hashlib.sha256()
118
+ with open(full,"rb") as fh:
119
+ for chunk in iter(lambda: fh.read(65536), b""):
120
+ h.update(chunk)
121
+ sha=h.hexdigest()
122
+ except Exception:
123
+ sha=None
124
+ out.append({"path":p,"sha256":sha})
125
+ print(json.dumps(out,ensure_ascii=False))
126
+ PY
127
+ }
128
+
129
+ # ops_local_slice_ref — identidad del slice según el FICHERO local. Solo tiene sentido en
130
+ # modo `dual`, donde el fichero es primario y el runtime crea la identidad por el mismo
131
+ # camino que el import (spec §4.1-D). En `runtime` no se usa: allí manda la caché.
132
+ # Imprime {} si no hay fichero, no hay python3 o no hay slice activo — nunca lanza.
133
+ ops_local_slice_ref() {
134
+ local p
135
+ p="$(config_root)/.claude/state/build-state.json"
136
+ [ -f "$p" ] || { echo "{}"; return; }
137
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
138
+ python3 - "$p" <<'PY' 2>/dev/null || echo "{}"
139
+ import json,sys
140
+ try:
141
+ d=json.load(open(sys.argv[1]))
142
+ s=d.get("active_slice") or {}
143
+ except Exception:
144
+ s={}
145
+ print(json.dumps({"epic_code": s.get("epica"),
146
+ "openspec_change": s.get("openspec_change"),
147
+ "branch": s.get("branch"),
148
+ "phase": s.get("phase")}, ensure_ascii=False))
149
+ PY
150
+ }
151
+
152
+ # ops_enqueue_from_file <type> <payload_file> [slice_id] — encola un evento cuyo payload
153
+ # puede ser grande (checklist de wiring, evidencia, contenido propuesto): el payload se lee
154
+ # del FICHERO dentro de python3, así que no pasa por argv (a diferencia de
155
+ # runtime_enqueue_event, que sigue sirviendo para payloads pequeños).
156
+ ops_enqueue_from_file() {
157
+ local type="$1" pfile="$2" slice_id="${3:-}" dir
158
+ dir="$(runtime_outbox_dir)"
159
+ mkdir -p "$dir"
160
+ command -v python3 >/dev/null 2>&1 || return 1
161
+ python3 - "$dir" "$type" "$pfile" "$slice_id" <<'PY' 2>/dev/null || return 1
162
+ import json,sys,os,tempfile,uuid,datetime
163
+ dir_,typ,pfile,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
164
+ try:
165
+ payload=json.load(open(pfile))
166
+ except Exception:
167
+ payload={}
168
+ cid=str(uuid.uuid4())
169
+ ts=datetime.datetime.now(datetime.timezone.utc)
170
+ d={"client_event_id":cid,"type":typ,"payload":payload,
171
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
172
+ if slice_id:
173
+ d["slice_id"]=slice_id
174
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
175
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
176
+ try:
177
+ with os.fdopen(fd,"w") as out:
178
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
179
+ os.replace(tmp,fname)
180
+ except Exception:
181
+ try: os.unlink(tmp)
182
+ except OSError: pass
183
+ raise
184
+ PY
185
+ runtime_outbox_enforce_cap >/dev/null
186
+ }
187
+
188
+ # ops_mirror <kind> <payload_file> — ingesta espejo del modo `dual` (spec §4.1-D). El
189
+ # fichero local sigue siendo primario: un rechazo del reducer NO bloquea el trabajo, queda
190
+ # como discrepancia del comparador. rc 0 aceptado · 5 no se pudo (red) · 6 rechazado.
191
+ ops_mirror() {
192
+ local kind="$1" pfile="$2" pid body resp status tmp
193
+ pid="$(runtime_field project_id)"
194
+ [ -n "$pid" ] || return 5
195
+ command -v python3 >/dev/null 2>&1 || return 5
196
+ tmp="$(mktemp)" || return 5
197
+ python3 - "$kind" "$pfile" > "$tmp" <<'PY' 2>/dev/null
198
+ import json,sys,datetime
199
+ kind,pfile=sys.argv[1],sys.argv[2]
200
+ try:
201
+ payload=json.load(open(pfile))
202
+ except Exception:
203
+ payload={}
204
+ print(json.dumps({"kind":kind,"payload":payload,"origin":"mirror",
205
+ "occurred_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
206
+ ensure_ascii=False))
207
+ PY
208
+ body="$(cat "$tmp" 2>/dev/null)"
209
+ rm -f "$tmp"
210
+ [ -n "$body" ] || return 5
211
+ resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
212
+ status="$(runtime_http_status)"
213
+ case "$status" in
214
+ 200|201|202) return 0 ;;
215
+ 409|422)
216
+ echo "⚠ el espejo rechazó «${kind}»: $resp" >&2
217
+ echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
218
+ return 6 ;;
219
+ *) return 5 ;;
220
+ esac
221
+ }