@trycore/spec-build-harness 0.13.0 → 0.14.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.
@@ -108,6 +108,13 @@ ops_path_front_integration() { echo "/fronts/$1/members/$2/integration"; }
108
108
  ops_path_mirror() { echo "/projects/$1/mirror/transitions"; }
109
109
  ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
110
110
 
111
+ # [#62] Propuesta de épica (hub#113/#114). El agente PROPONE; el hub decide la identidad
112
+ # (`EP-XXX`) al aprobar y un humano aprueba: por eso hay una ruta de creación y otra de
113
+ # consulta, y ninguna de publicación. Mismo patrón que las propuestas de asset
114
+ # (`ops_path_proposals`), que es su precedente en este cliente.
115
+ ops_path_epic_proposals() { echo "/projects/$1/epic-proposals"; }
116
+ ops_path_epic_proposal() { echo "/projects/$1/epic-proposals/$2"; }
117
+
111
118
  ops_mode() { runtime_mode; }
112
119
 
113
120
  # ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
@@ -289,3 +296,104 @@ PY
289
296
  *) return 5 ;;
290
297
  esac
291
298
  }
299
+
300
+ # ── Ledger local de propuestas de épica [#62] ────────────────────────────────────────
301
+ # Memoria del ciclo «propongo → un humano aprueba → escribo el fichero con el código del
302
+ # hub». Vive fuera de build-state.json a propósito: en modo `runtime` ese fichero no es la
303
+ # fuente de verdad, y el ledger no es estado del pipeline sino el rastro de una conversación
304
+ # con el hub. Clave estable: `client_event_id` (lo asigna el encolado, existe desde el primer
305
+ # instante); `proposal_id` llega después, con el ack, y puede no llegar nunca (offline).
306
+ ops_epic_ledger_path() { echo "$(config_root)/.claude/state/epic-proposals.json"; }
307
+
308
+ # ops_epic_ledger_read — imprime {"proposals":[…]}; nunca lanza.
309
+ ops_epic_ledger_read() {
310
+ local f; f="$(ops_epic_ledger_path)"
311
+ command -v python3 >/dev/null 2>&1 || { echo '{"proposals":[]}'; return; }
312
+ python3 - "$f" <<'PY' 2>/dev/null || echo '{"proposals":[]}'
313
+ import json,sys
314
+ try:
315
+ d=json.load(open(sys.argv[1]))
316
+ ps=d.get("proposals")
317
+ print(json.dumps({"proposals": ps if isinstance(ps,list) else []},ensure_ascii=False))
318
+ except Exception:
319
+ print('{"proposals":[]}')
320
+ PY
321
+ }
322
+
323
+ # ops_epic_ledger_upsert <entrada-json> — upsert por client_event_id: las claves que la
324
+ # entrada NO menciona se conservan (así un `epic-status` puede fijar el estado sin borrar el
325
+ # borrador que guardó `propose-epic`). Escritura atómica. rc 0/1.
326
+ ops_epic_ledger_upsert() {
327
+ local entry="$1" f
328
+ f="$(ops_epic_ledger_path)"
329
+ mkdir -p "$(dirname "$f")"
330
+ command -v python3 >/dev/null 2>&1 || return 1
331
+ python3 - "$f" "$entry" <<'PY' 2>/dev/null || return 1
332
+ import json,sys,os,tempfile
333
+ path,entry=sys.argv[1],sys.argv[2]
334
+ try:
335
+ d=json.load(open(path))
336
+ except Exception:
337
+ d={}
338
+ ps=d.get("proposals")
339
+ if not isinstance(ps,list):
340
+ ps=[]
341
+ e=json.loads(entry)
342
+ cid=e.get("client_event_id")
343
+ for i,p in enumerate(ps):
344
+ if isinstance(p,dict) and p.get("client_event_id")==cid:
345
+ p.update(e)
346
+ ps[i]=p
347
+ break
348
+ else:
349
+ ps.append(e)
350
+ dirn=os.path.dirname(path) or "."
351
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".epic-proposals.",suffix=".tmp")
352
+ try:
353
+ with os.fdopen(fd,"w") as out:
354
+ json.dump({"proposals":ps},out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
355
+ os.replace(tmp,path)
356
+ except Exception:
357
+ try: os.unlink(tmp)
358
+ except OSError: pass
359
+ raise
360
+ PY
361
+ }
362
+
363
+ # ops_epic_ack_read <client_event_id> — el ack que dejó el carril directo, o "".
364
+ ops_epic_ack_read() {
365
+ local f; f="$(runtime_outbox_dir)/acks/$1.json"
366
+ [ -f "$f" ] || { echo ""; return; }
367
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
368
+ python3 - "$f" <<'PY' 2>/dev/null || echo ""
369
+ import json,sys
370
+ try:
371
+ print(json.dumps(json.load(open(sys.argv[1])),ensure_ascii=False))
372
+ except Exception:
373
+ print("")
374
+ PY
375
+ }
376
+
377
+ # ops_epic_rejected_reason <client_event_id> — razón con la que el carril directo apartó la
378
+ # petición (o "" si no está apartada). Es lo que convierte «no pasa nada» en un diagnóstico.
379
+ ops_epic_rejected_reason() {
380
+ local dir; dir="$(runtime_outbox_dir)/rejected"
381
+ [ -d "$dir" ] || { echo ""; return; }
382
+ command -v python3 >/dev/null 2>&1 || { echo ""; return; }
383
+ python3 - "$dir" "$1" <<'PY' 2>/dev/null || echo ""
384
+ import json,os,sys
385
+ d,cid=sys.argv[1],sys.argv[2]
386
+ for name in sorted(os.listdir(d)):
387
+ if not name.endswith(".json"):
388
+ continue
389
+ try:
390
+ e=json.load(open(os.path.join(d,name)))
391
+ except Exception:
392
+ continue
393
+ if e.get("client_event_id")==cid:
394
+ print(e.get("rejected_reason") or "apartada sin razón registrada")
395
+ break
396
+ else:
397
+ print("")
398
+ PY
399
+ }
@@ -18,7 +18,20 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
  source "$HERE/lib/projection.sh"
19
19
  if [ "$(runtime_mode)" = "runtime" ]; then
20
20
  CACHE="$(runtime_projection_path)"
21
- [ -f "$CACHE" ] || exit 0 # aún sin sincronizar: nada que vigilar (auto-arme)
21
+ if [ ! -f "$CACHE" ]; then
22
+ # Sin proyección hay dos casos opuestos y hay que separarlos: si el proyecto
23
+ # no tiene credenciales es auto-arme (aún no usa el arnés) y se deja pasar;
24
+ # si las tiene, este agente está DESCONECTADO del hub y seguir escribiendo
25
+ # código es justo lo que produce trabajo duplicado y choques entre workers.
26
+ if runtime_disconnected; then
27
+ echo "⛔ sin conexión con el hub: hay credenciales del proyecto pero no hay proyección local." >&2
28
+ echo " Este agente no sabe qué trabajo tiene asignado, así que no puede escribir código." >&2
29
+ echo " Recupérala con \`slice-ops.sh status\` (refresca /agent/context) y reclama con \`/build:claim\`." >&2
30
+ echo " Si esto es un worktree: el estado del arnés vive en el clon principal y no viaja al worktree." >&2
31
+ exit 2
32
+ fi
33
+ exit 0 # auto-arme: el proyecto aún no usa el arnés, nada que vigilar
34
+ fi
22
35
  if ! command -v python3 >/dev/null 2>&1; then
23
36
  echo "⛔ scaffold-guard: python3 no disponible; no puedo verificar el gate de scaffold. Instala python3 (trycore-build doctor)." >&2
24
37
  exit 2