@trycore/spec-build-harness 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/INSTALL.md +56 -3
- package/METODOLOGIA.md +6 -1
- package/README.md +46 -2
- package/VERSION +1 -1
- package/agents/build/dor-dod-gatekeeper.md +10 -3
- package/commands/build/epic.md +109 -0
- package/commands/build/onboard.md +34 -9
- package/commands/build/slice.md +4 -2
- package/commands/build/work.md +6 -2
- package/config/build-config.template.json +2 -1
- package/dist/commands/init.js +13 -0
- package/dist/commands/status.js +13 -1
- package/dist/lib/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/docs/commands.md +3 -2
- package/docs/getting-started.md +11 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/guia-modo-dual-y-migracion.md +89 -9
- package/docs/runtime/protocolo-cliente-runtime.md +64 -0
- package/hooks/build/design-source-guard.sh +12 -1
- package/hooks/build/heartbeat.sh +64 -4
- package/hooks/build/lib/agent-context.sh +16 -7
- package/hooks/build/lib/config.sh +25 -1
- package/hooks/build/lib/runtime-client.sh +540 -9
- package/hooks/build/lib/runtime-ops.sh +108 -0
- package/hooks/build/scaffold-guard.sh +14 -1
- package/hooks/build/slice-ops.sh +498 -16
- package/package.json +1 -1
- package/scripts/tests/test-config.sh +41 -0
- package/scripts/tests/test-hooks-runtime.sh +198 -3
- package/scripts/tests/test-install.sh +23 -0
- package/scripts/tests/test-runtime-client.sh +500 -0
- package/scripts/tests/test-skill-ops.sh +456 -0
- package/skills/building-a-slice/references/dor.md +21 -9
- package/skills/building-a-slice/references/foundation-contract.md +4 -0
- package/state/README.md +12 -0
package/hooks/build/slice-ops.sh
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
# `building-a-slice`, los comandos /build:* y los agentes, UNA invocación por transición.
|
|
5
5
|
#
|
|
6
6
|
# uso: slice-ops.sh <subcomando> [opciones]
|
|
7
|
-
# subcomandos IMPLEMENTADOS (13 del plan + phase, issue #52
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# subcomandos IMPLEMENTADOS (13 del plan + phase, issue #52; + propose-epic/epic-status/
|
|
8
|
+
# epic-writeback, issue #62): mode | claim | next-step | phase | gate | submit |
|
|
9
|
+
# archive | wiring | progress | checkpoint | fact | propose-asset |
|
|
10
|
+
# propose-epic | epic-status | epic-writeback | status | escalate.
|
|
10
11
|
#
|
|
11
12
|
# Contrato con la prosa: cada subcomando devuelve un código de salida tipado
|
|
12
13
|
# (0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo) y
|
|
@@ -38,6 +39,13 @@ uso: slice-ops.sh <subcomando> [opciones]
|
|
|
38
39
|
fact <scaffold-confirmed|design-source|project-kind|foundation|harness-phase> …
|
|
39
40
|
reporta un hecho de proyecto confirmado
|
|
40
41
|
propose-asset --type-key K --path P --content-file F
|
|
42
|
+
propose-epic --file borrador.json propone una épica al hub (SIN EP-XXX: la
|
|
43
|
+
identidad la asigna el hub al aprobar)
|
|
44
|
+
epic-status [--id P] estado de las propuestas (QUEUED|PROPOSED|
|
|
45
|
+
APPROVED|REJECTED|FAILED)
|
|
46
|
+
epic-writeback [--id P] [--file F] escribe en epicas.md las épicas ya APROBADAS,
|
|
47
|
+
con el código que asignó el hub (F debe estar
|
|
48
|
+
bajo docs/03-backlog/: carve-out §9.2)
|
|
41
49
|
status [--no-refresh] informe local del agente
|
|
42
50
|
escalate «razón» | --file F [--gate G] registra un bloqueo (la decisión es humana)
|
|
43
51
|
|
|
@@ -97,9 +105,16 @@ PY
|
|
|
97
105
|
# [#39] El cuerpo lleva SOLO context_hashes: `TasksNextIn` del hub no declara epic_code
|
|
98
106
|
# (pydantic lo ignoraba en silencio y la cola repartía OTRA épica con claim "exitoso").
|
|
99
107
|
_claim_once() {
|
|
100
|
-
local hashes body resp status reason
|
|
108
|
+
local hashes body resp status reason gv
|
|
101
109
|
hashes="$(ops_context_hashes)"
|
|
102
|
-
|
|
110
|
+
# [#63] La versión de grafo viaja SOLO si la conocemos: un hub que no versiona (instancia
|
|
111
|
+
# antigua) debe seguir viendo el mismo cuerpo de siempre — compatibilidad hacia atrás.
|
|
112
|
+
gv="$(_runtime_graph_version)"
|
|
113
|
+
if [ -n "$gv" ]; then
|
|
114
|
+
body="{\"context_hashes\":$hashes,\"graph_version\":$gv}"
|
|
115
|
+
else
|
|
116
|
+
body="{\"context_hashes\":$hashes}"
|
|
117
|
+
fi
|
|
103
118
|
resp="$(runtime_post "$OPS_PATH_CLAIM" "$body")"
|
|
104
119
|
status="$(runtime_http_status)"
|
|
105
120
|
case "$status" in
|
|
@@ -115,6 +130,15 @@ _claim_once() {
|
|
|
115
130
|
reason="$(_claim_field "$resp" reason)"
|
|
116
131
|
[ -n "$reason" ] || reason="$(_claim_field "$resp" error)"
|
|
117
132
|
case "$reason" in
|
|
133
|
+
stale_graph)
|
|
134
|
+
# [#63] No es un error: es la señal de que operamos contra una foto vieja del
|
|
135
|
+
# backlog. Se anota el desfase (lo muestra `status`) y se pide UN reintento con la
|
|
136
|
+
# proyección refrescada. Mismo patrón que el 409 del lease en hb_renew.
|
|
137
|
+
local server_gv
|
|
138
|
+
server_gv="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("graph_version") or "")' 2>/dev/null)"
|
|
139
|
+
runtime_graph_note_stale "$gv" "$server_gv"
|
|
140
|
+
echo "⚠ 409 (stale_graph): el grafo local (v${gv:-?}) va por detrás del hub (v${server_gv:-?})" >&2
|
|
141
|
+
return 21 ;;
|
|
118
142
|
drift|contention)
|
|
119
143
|
echo "⚠ 409 ($reason): reintentable" >&2
|
|
120
144
|
return 20 ;;
|
|
@@ -185,24 +209,36 @@ cmd_claim() {
|
|
|
185
209
|
bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
|
|
186
210
|
_claim_once
|
|
187
211
|
rc=$?
|
|
188
|
-
if [ $rc -eq 20 ]; then
|
|
189
|
-
# Drift o
|
|
190
|
-
# context-sync.sh
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
# nunca llega a preguntarle nada al servidor. Se refresca antes el estado REAL
|
|
195
|
-
# (GET /agent/context, que sí toca la red) y se usa SU manifest_hash como EXPECTED:
|
|
196
|
-
# si el drift es real, difiere del lock y el guard deja pasar el GET del manifiesto.
|
|
212
|
+
if [ $rc -eq 20 ] || [ $rc -eq 21 ]; then
|
|
213
|
+
# Drift, contención o grafo rancio: refrescar el estado REAL y reintentar EXACTAMENTE una
|
|
214
|
+
# vez (protocolo §7). Pasar "" a context-sync.sh no fuerza nada — su fallback recae en la
|
|
215
|
+
# caché de credenciales, que en el escenario de drift YA coincide con el lock local —, así
|
|
216
|
+
# que se refresca antes con GET /agent/context y se usa SU manifest_hash como EXPECTED.
|
|
217
|
+
# Para `stale_graph` ese mismo refresco es justamente lo que trae la versión nueva.
|
|
197
218
|
agent_context_fetch_and_cache >/dev/null 2>&1
|
|
198
219
|
expected="$(projection_get context.manifest_hash "")"
|
|
199
220
|
bash "$HERE/context-sync.sh" "$expected" >/dev/null 2>&1
|
|
221
|
+
local prev_rc=$rc
|
|
200
222
|
_claim_once
|
|
201
223
|
rc=$?
|
|
224
|
+
if [ $rc -eq 21 ]; then
|
|
225
|
+
# Segundo 409 con el grafo ya refrescado: no es una carrera, es un desfase que el
|
|
226
|
+
# cliente no puede resolver solo. El mensaje NOMBRA el desfase y la acción.
|
|
227
|
+
local gs
|
|
228
|
+
gs="$(runtime_graph_status_read)"
|
|
229
|
+
echo "⛔ el hub rechaza el claim por versión de grafo obsoleta, incluso tras refrescar." >&2
|
|
230
|
+
echo " desfase: $gs" >&2
|
|
231
|
+
echo " El backlog del hub cambió mientras reclamabas (una épica propuesta, aprobada o" >&2
|
|
232
|
+
echo " rechazada). Vuelve a intentarlo en unos segundos; si persiste, revisa en la consola" >&2
|
|
233
|
+
echo " del hub que este proyecto no esté a mitad de un import de grafo." >&2
|
|
234
|
+
return $OPS_RC_REJECTED
|
|
235
|
+
fi
|
|
202
236
|
if [ $rc -eq 20 ]; then
|
|
203
237
|
echo "otro agente tomó la tarea; pide la siguiente"
|
|
204
238
|
return $OPS_RC_NO_WORK
|
|
205
239
|
fi
|
|
240
|
+
# Un claim correcto tras un stale_graph deja el marcador de desfase sin sentido.
|
|
241
|
+
[ $rc -eq 0 ] && [ "$prev_rc" -eq 21 ] && runtime_graph_clear_stale
|
|
206
242
|
fi
|
|
207
243
|
return $rc
|
|
208
244
|
}
|
|
@@ -426,6 +462,415 @@ cmd_propose_asset() {
|
|
|
426
462
|
esac
|
|
427
463
|
}
|
|
428
464
|
|
|
465
|
+
# ── propose-epic ─────────────────────────────────────────────────────────────────────
|
|
466
|
+
# [#62] La terminal PROPONE, el hub decide la identidad, un humano aprueba. El agente pierde
|
|
467
|
+
# a propósito la potestad de inventar el `EP-XXX`: elegirlo mirando `epicas.md` es lo que hizo
|
|
468
|
+
# que dos terminales crearan EP-042 y EP-043 el mismo día y sus historias colisionaran.
|
|
469
|
+
# Va por el carril directo de la outbox (endpoint propio, hub#113) para sobrevivir a estar
|
|
470
|
+
# offline, y se intenta despachar en el acto para poder devolver el identificador del hub.
|
|
471
|
+
|
|
472
|
+
# _epic_draft_normalize <fichero> — imprime el cuerpo de la propuesta ya normalizado, o "" con
|
|
473
|
+
# rc 2 si el borrador no vale. `epic_code`/`code`/`id` se DESCARTAN (no se rechaza la
|
|
474
|
+
# propuesta: la decisión de hub#113 es ignorar el campo, no fallar por él).
|
|
475
|
+
_epic_draft_normalize() {
|
|
476
|
+
local f="$1"
|
|
477
|
+
# [Importante 4] sin python3 no es "borrador inválido" (rc 2 de uso): es una degradación de
|
|
478
|
+
# infraestructura — se anuncia con esas palabras y con OFFLINE, para que no se confunda con
|
|
479
|
+
# un borrador mal escrito.
|
|
480
|
+
command -v python3 >/dev/null 2>&1 || {
|
|
481
|
+
echo "⛔ sin python3: no se puede validar el borrador en local" >&2
|
|
482
|
+
return $OPS_RC_OFFLINE
|
|
483
|
+
}
|
|
484
|
+
# OJO: sin `2>/dev/null` aquí — el llamador captura este stderr en `$errf` para mostrar
|
|
485
|
+
# el motivo del rc 2 (validación) o el aviso de `epic_code` ignorado; silenciarlo aquí
|
|
486
|
+
# los perdería a los dos.
|
|
487
|
+
python3 - "$f" <<'PY'
|
|
488
|
+
import json,sys
|
|
489
|
+
CAPAS={"foundational","business","technical"}
|
|
490
|
+
try:
|
|
491
|
+
d=json.load(open(sys.argv[1]))
|
|
492
|
+
except Exception:
|
|
493
|
+
print("⛔ el borrador no es un JSON legible", file=sys.stderr); raise SystemExit(2)
|
|
494
|
+
if not isinstance(d,dict):
|
|
495
|
+
print("⛔ el borrador debe ser un objeto JSON", file=sys.stderr); raise SystemExit(2)
|
|
496
|
+
title=(d.get("title") or "").strip()
|
|
497
|
+
objective=(d.get("objective") or "").strip()
|
|
498
|
+
if not title:
|
|
499
|
+
print("⛔ el borrador no trae `title` (título de la épica)", file=sys.stderr); raise SystemExit(2)
|
|
500
|
+
if not objective:
|
|
501
|
+
print("⛔ el borrador no trae `objective` (objetivo de la épica)", file=sys.stderr); raise SystemExit(2)
|
|
502
|
+
layer=d.get("layer") or "business"
|
|
503
|
+
if layer not in CAPAS:
|
|
504
|
+
print("⛔ `layer` debe ser uno de: %s" % ", ".join(sorted(CAPAS)), file=sys.stderr); raise SystemExit(2)
|
|
505
|
+
ignorados=[k for k in ("epic_code","code","id") if d.get(k)]
|
|
506
|
+
stories=[]
|
|
507
|
+
for s in d.get("stories") or []:
|
|
508
|
+
if isinstance(s,str):
|
|
509
|
+
stories.append({"title":s})
|
|
510
|
+
elif isinstance(s,dict) and (s.get("title") or "").strip():
|
|
511
|
+
st={"title":s["title"].strip()}
|
|
512
|
+
if s.get("acceptance_criteria"):
|
|
513
|
+
st["acceptance_criteria"]=str(s["acceptance_criteria"])[:2000]
|
|
514
|
+
stories.append(st)
|
|
515
|
+
out={"title":title[:300],"objective":objective[:2000],"layer":layer,
|
|
516
|
+
"files_scope":[str(x) for x in (d.get("files_scope") or []) if isinstance(x,(str,int))],
|
|
517
|
+
"depends_on":[str(x) for x in (d.get("depends_on") or []) if isinstance(x,(str,int))],
|
|
518
|
+
"stories":stories,"origin":"harness-draft"}
|
|
519
|
+
if ignorados:
|
|
520
|
+
print("IGNORADOS %s" % ",".join(ignorados), file=sys.stderr)
|
|
521
|
+
print(json.dumps(out,ensure_ascii=False))
|
|
522
|
+
PY
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
cmd_propose_epic() {
|
|
526
|
+
local file="" mode pid norm errf rc cid pfile ack proposal_id reason
|
|
527
|
+
while [ $# -gt 0 ]; do
|
|
528
|
+
case "$1" in
|
|
529
|
+
--file) file="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
|
|
530
|
+
*) shift ;;
|
|
531
|
+
esac
|
|
532
|
+
done
|
|
533
|
+
if [ -z "$file" ]; then
|
|
534
|
+
echo "uso: slice-ops.sh propose-epic --file borrador.json" >&2
|
|
535
|
+
echo " borrador: {title, objective, layer, files_scope[], depends_on[], stories[]}" >&2
|
|
536
|
+
echo " SIN EP-XXX: el código lo asigna el hub al aprobar." >&2
|
|
537
|
+
return $OPS_RC_USAGE
|
|
538
|
+
fi
|
|
539
|
+
[ -f "$file" ] || { echo "⛔ no existe el borrador: $file" >&2; return $OPS_RC_USAGE; }
|
|
540
|
+
mode="$(ops_mode)"
|
|
541
|
+
# [Ronda final] El texto ramifica por modo: en `dual` SÍ hay hub (el fichero es primario,
|
|
542
|
+
# pero el proyecto está registrado), y decirle al usuario «no hay hub al que proponer» le
|
|
543
|
+
# hacía buscar un problema de conexión que no existe.
|
|
544
|
+
if [ "$mode" = "dual" ]; then
|
|
545
|
+
echo "DUAL: el fichero local es primario y el carril de propuesta solo opera en modo \`runtime\`."
|
|
546
|
+
echo "La épica se crea en discovery (\`/trycore:epicas\`) o, si es la caparazón, por el"
|
|
547
|
+
echo "carve-out de /build:onboard Fase 2c; llega al hub por el import de admin."
|
|
548
|
+
return $OPS_RC_LEGACY
|
|
549
|
+
fi
|
|
550
|
+
if [ "$mode" != "runtime" ]; then
|
|
551
|
+
echo "LEGACY: la épica se crea en discovery (\`/trycore:epicas\`) o, si es la caparazón,"
|
|
552
|
+
echo "por el carve-out de /build:onboard Fase 2c. No hay hub al que proponer."
|
|
553
|
+
return $OPS_RC_LEGACY
|
|
554
|
+
fi
|
|
555
|
+
pid="$(runtime_field project_id)"
|
|
556
|
+
[ -n "$pid" ] || { echo "⛔ sin project_id en runtime.credentials: corre \`trycore-build init\`" >&2; return $OPS_RC_REJECTED; }
|
|
557
|
+
|
|
558
|
+
errf="$(mktemp)" || return $OPS_RC_OFFLINE
|
|
559
|
+
norm="$(_epic_draft_normalize "$file" 2>"$errf")"
|
|
560
|
+
rc=$?
|
|
561
|
+
# [Importante 4] rc OFFLINE (sin python3) no es lo mismo que rc USAGE (borrador inválido):
|
|
562
|
+
# el primero es fallo de infraestructura, reintentable; el segundo es del agente.
|
|
563
|
+
if [ "$rc" -eq "$OPS_RC_OFFLINE" ]; then
|
|
564
|
+
cat "$errf" >&2
|
|
565
|
+
rm -f "$errf"
|
|
566
|
+
return $OPS_RC_OFFLINE
|
|
567
|
+
fi
|
|
568
|
+
if [ $rc -ne 0 ] || [ -z "$norm" ]; then
|
|
569
|
+
cat "$errf" >&2
|
|
570
|
+
rm -f "$errf"
|
|
571
|
+
return $OPS_RC_USAGE
|
|
572
|
+
fi
|
|
573
|
+
if grep -q '^IGNORADOS ' "$errf"; then
|
|
574
|
+
echo "⚠ código de épica ignorado ($(sed -n 's/^IGNORADOS //p' "$errf")): la identidad la asigna el hub al aprobar." >&2
|
|
575
|
+
fi
|
|
576
|
+
rm -f "$errf"
|
|
577
|
+
|
|
578
|
+
pfile="$(ops_tmpjson "$norm")" || return $OPS_RC_OFFLINE
|
|
579
|
+
# El `1` final pide el estampado de la versión de grafo AL DESPACHAR [#63]: una propuesta
|
|
580
|
+
# encolada offline debe salir con la versión vigente en ese momento, no con la de ayer.
|
|
581
|
+
cid="$(runtime_enqueue_direct epic_proposed POST "$(ops_path_epic_proposals "$pid")" "$pfile" 1)"
|
|
582
|
+
rm -f "$pfile"
|
|
583
|
+
if [ -z "$cid" ]; then
|
|
584
|
+
echo "⛔ no se pudo encolar la propuesta (outbox no escribible): guárdala y reintenta." >&2
|
|
585
|
+
return $OPS_RC_OFFLINE
|
|
586
|
+
fi
|
|
587
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"QUEUED\",\"proposal_id\":null,\"epic_code\":null,\"written_back_at\":null,\"draft\":$norm}"
|
|
588
|
+
|
|
589
|
+
# Intento de despacho inmediato: si hay red, la skill puede enseñar YA el identificador.
|
|
590
|
+
runtime_dispatch_outbox >/dev/null 2>&1
|
|
591
|
+
ack="$(ops_epic_ack_read "$cid")"
|
|
592
|
+
if [ -n "$ack" ]; then
|
|
593
|
+
proposal_id="$(printf '%s' "$ack" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or {}).get("proposal_id") or "")' 2>/dev/null)"
|
|
594
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$proposal_id")}"
|
|
595
|
+
echo "propuesta enviada: ${proposal_id:-sin identificador en la respuesta}"
|
|
596
|
+
echo "Queda en PROPOSED: NO es reclamable hasta que un humano la apruebe en la consola del hub."
|
|
597
|
+
echo "El código EP-XXX lo asigna el hub AL APROBAR. Consulta con \`slice-ops.sh epic-status\`."
|
|
598
|
+
return $OPS_RC_OK
|
|
599
|
+
fi
|
|
600
|
+
reason="$(ops_epic_rejected_reason "$cid")"
|
|
601
|
+
if [ -n "$reason" ]; then
|
|
602
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"FAILED\",\"reason\":$(ops_json_str "$reason")}"
|
|
603
|
+
echo "⛔ la propuesta no se pudo entregar: $reason" >&2
|
|
604
|
+
return $OPS_RC_REJECTED
|
|
605
|
+
fi
|
|
606
|
+
echo "propuesta encolada: $cid"
|
|
607
|
+
# [Ronda final] El texto ya no afirma «sin conexión»: desde que el despacho lleva mutex
|
|
608
|
+
# (A1), este camino también se toma cuando el daemon estaba despachando la cola en ese
|
|
609
|
+
# mismo instante y el intento en primer plano cedió el turno. La propuesta está encolada y
|
|
610
|
+
# el daemon la lleva en los dos casos —el rc 5 y la conducta no cambian—, pero nombrar una
|
|
611
|
+
# causa que puede ser falsa manda al usuario a revisar una red que funciona.
|
|
612
|
+
echo "PENDIENTE: se despachará sola (el daemon de heartbeat la lleva). Sin conexión aún, o"
|
|
613
|
+
echo "el daemon estaba drenando la cola en este instante. \`slice-ops.sh epic-status\` la sigue."
|
|
614
|
+
echo "NO escribas la épica en docs/03-backlog/epicas.md: el código lo asigna el hub al aprobar."
|
|
615
|
+
return $OPS_RC_OFFLINE
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# ── epic-status ──────────────────────────────────────────────────────────────────────
|
|
619
|
+
# [#62] Resuelve el ciclo de vida de cada propuesta del ledger. Dos fuentes por estado:
|
|
620
|
+
# QUEUED → mira el ack del carril directo (¿ya se despachó?) o el rechazo local.
|
|
621
|
+
# PROPOSED → pregunta al hub por su estado (APPROVED con `epic_code`, o REJECTED con razón).
|
|
622
|
+
# Es INFORMATIVO: rc 0 siempre en runtime, pase lo que pase.
|
|
623
|
+
cmd_epic_status() {
|
|
624
|
+
local only="" mode pid entries n i cid pidv st ack resp status tmp
|
|
625
|
+
while [ $# -gt 0 ]; do
|
|
626
|
+
case "$1" in
|
|
627
|
+
--id) only="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
|
|
628
|
+
*) shift ;;
|
|
629
|
+
esac
|
|
630
|
+
done
|
|
631
|
+
mode="$(ops_mode)"
|
|
632
|
+
if [ "$mode" = "dual" ]; then
|
|
633
|
+
echo "DUAL: no hay propuestas que consultar (el carril solo opera en modo \`runtime\`);"
|
|
634
|
+
echo "las épicas viven en docs/03-backlog/epicas.md"
|
|
635
|
+
return $OPS_RC_LEGACY
|
|
636
|
+
fi
|
|
637
|
+
if [ "$mode" != "runtime" ]; then
|
|
638
|
+
echo "LEGACY: no hay propuestas que consultar; las épicas viven en docs/03-backlog/epicas.md"
|
|
639
|
+
return $OPS_RC_LEGACY
|
|
640
|
+
fi
|
|
641
|
+
command -v python3 >/dev/null 2>&1 || { echo "sin python3: no se puede leer el ledger"; return $OPS_RC_OK; }
|
|
642
|
+
pid="$(runtime_field project_id)"
|
|
643
|
+
|
|
644
|
+
# Primero, la parte que NO toca la red: acks y rechazos del carril directo.
|
|
645
|
+
tmp="$(mktemp)" || return $OPS_RC_OK
|
|
646
|
+
ops_epic_ledger_read > "$tmp"
|
|
647
|
+
n="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["proposals"]))' "$tmp" 2>/dev/null)"
|
|
648
|
+
case "$n" in ''|*[!0-9]*) n=0 ;; esac
|
|
649
|
+
if [ "$n" -eq 0 ]; then
|
|
650
|
+
rm -f "$tmp"
|
|
651
|
+
echo "sin propuestas de épica registradas en esta copia del proyecto"
|
|
652
|
+
return $OPS_RC_OK
|
|
653
|
+
fi
|
|
654
|
+
i=0
|
|
655
|
+
while [ "$i" -lt "$n" ]; do
|
|
656
|
+
cid="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["proposals"][int(sys.argv[2])].get("client_event_id") or "")' "$tmp" "$i" 2>/dev/null)"
|
|
657
|
+
pidv="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["proposals"][int(sys.argv[2])].get("proposal_id") or "")' "$tmp" "$i" 2>/dev/null)"
|
|
658
|
+
st="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["proposals"][int(sys.argv[2])].get("status") or "")' "$tmp" "$i" 2>/dev/null)"
|
|
659
|
+
i=$((i + 1))
|
|
660
|
+
[ -n "$only" ] && [ "$only" != "$pidv" ] && continue
|
|
661
|
+
|
|
662
|
+
if [ "$st" = "QUEUED" ] && [ -n "$cid" ]; then
|
|
663
|
+
ack="$(ops_epic_ack_read "$cid")"
|
|
664
|
+
if [ -n "$ack" ]; then
|
|
665
|
+
pidv="$(printf '%s' "$ack" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or {}).get("proposal_id") or "")' 2>/dev/null)"
|
|
666
|
+
st=PROPOSED
|
|
667
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"PROPOSED\",\"proposal_id\":$(ops_json_str "$pidv")}"
|
|
668
|
+
else
|
|
669
|
+
local rej; rej="$(ops_epic_rejected_reason "$cid")"
|
|
670
|
+
if [ -n "$rej" ]; then
|
|
671
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"FAILED\",\"reason\":$(ops_json_str "$rej")}"
|
|
672
|
+
echo "$cid FAILED $rej"
|
|
673
|
+
continue
|
|
674
|
+
fi
|
|
675
|
+
echo "$cid QUEUED aún en la cola: se despachará al recuperar conexión"
|
|
676
|
+
continue
|
|
677
|
+
fi
|
|
678
|
+
fi
|
|
679
|
+
|
|
680
|
+
if [ "$st" = "PROPOSED" ] && [ -n "$pidv" ] && [ -n "$pid" ]; then
|
|
681
|
+
resp="$(runtime_get "$(ops_path_epic_proposal "$pid" "$(ops_urlenc "$pidv")")")"
|
|
682
|
+
status="$(runtime_http_status)"
|
|
683
|
+
case "$status" in
|
|
684
|
+
200)
|
|
685
|
+
st="$(_claim_field "$resp" status)"
|
|
686
|
+
case "$st" in
|
|
687
|
+
APPROVED)
|
|
688
|
+
local code epic
|
|
689
|
+
code="$(_claim_field "$resp" epic_code)"
|
|
690
|
+
epic="$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.dumps(json.load(sys.stdin).get("epic") or {},ensure_ascii=False))' 2>/dev/null)"
|
|
691
|
+
[ -n "$epic" ] || epic="{}"
|
|
692
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"APPROVED\",\"epic_code\":$(ops_json_str "$code"),\"epic\":$epic}"
|
|
693
|
+
echo "$pidv APPROVED $code — escríbela con \`slice-ops.sh epic-writeback\`" ;;
|
|
694
|
+
REJECTED)
|
|
695
|
+
local why; why="$(_claim_field "$resp" reason)"
|
|
696
|
+
ops_epic_ledger_upsert "{\"client_event_id\":\"$cid\",\"status\":\"REJECTED\",\"reason\":$(ops_json_str "$why")}"
|
|
697
|
+
echo "$pidv REJECTED ${why:-sin motivo registrado}" ;;
|
|
698
|
+
*)
|
|
699
|
+
echo "$pidv PROPOSED pendiente de aprobación humana en la consola del hub" ;;
|
|
700
|
+
esac ;;
|
|
701
|
+
404)
|
|
702
|
+
echo "$pidv ? el hub no expone la consulta de propuestas: instancia sin soporte (hub#113 pendiente)" ;;
|
|
703
|
+
401|403)
|
|
704
|
+
echo "$pidv ? token inválido o sin permiso ($status): habla con el ADMIN" ;;
|
|
705
|
+
*)
|
|
706
|
+
echo "$pidv ? OFFLINE: no se pudo consultar (status $status); se conserva el estado local" ;;
|
|
707
|
+
esac
|
|
708
|
+
continue
|
|
709
|
+
fi
|
|
710
|
+
echo "${pidv:-$cid} $st"
|
|
711
|
+
done
|
|
712
|
+
rm -f "$tmp"
|
|
713
|
+
return $OPS_RC_OK
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
# ── epic-writeback ───────────────────────────────────────────────────────────────────
|
|
717
|
+
# [#62] La vuelta del ciclo: aprobada la propuesta (hub#114), la épica se escribe en
|
|
718
|
+
# docs/03-backlog/epicas.md con el código que asignó el HUB. El fichero queda como
|
|
719
|
+
# PROYECCIÓN del grafo, nunca al revés — por eso el bloque es mecánico y siempre el mismo:
|
|
720
|
+
# una redacción libre por write-back haría que el fichero y el hub se separaran a cada pasada.
|
|
721
|
+
# Idempotente: si el `EP-XXX` ya tiene sección en el fichero, no se duplica (se marca hecho).
|
|
722
|
+
# Los códigos de HU los pone el hub si los trae; el arnés NUNCA los inventa (inventarlos es
|
|
723
|
+
# exactamente la colisión que motivó el issue).
|
|
724
|
+
# _epic_writeback_path_ok <ruta> <directorio-permitido> — imprime "OK" solo si la ruta cae
|
|
725
|
+
# DENTRO del directorio permitido, con symlinks y `..` ya resueltos (`realpath` sobre ambos).
|
|
726
|
+
# Sin python3 no se puede resolver la ruta: imprime "" (rechaza). Preferir el rechazo al
|
|
727
|
+
# permiso es lo correcto aquí — es una compuerta de escritura sobre ficheros ajenos.
|
|
728
|
+
_epic_writeback_path_ok() {
|
|
729
|
+
command -v python3 >/dev/null 2>&1 || { echo ""; return; }
|
|
730
|
+
python3 - "$1" "$2" <<'PY' 2>/dev/null
|
|
731
|
+
import os,sys
|
|
732
|
+
try:
|
|
733
|
+
t=os.path.realpath(sys.argv[1])
|
|
734
|
+
base=os.path.realpath(sys.argv[2])
|
|
735
|
+
except Exception:
|
|
736
|
+
print(""); raise SystemExit(0)
|
|
737
|
+
print("OK" if t.startswith(base + os.sep) else "")
|
|
738
|
+
PY
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
cmd_epic_writeback() {
|
|
742
|
+
local only="" target="" mode root wrote rc
|
|
743
|
+
while [ $# -gt 0 ]; do
|
|
744
|
+
case "$1" in
|
|
745
|
+
--id) only="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
|
|
746
|
+
--file) target="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
|
|
747
|
+
*) shift ;;
|
|
748
|
+
esac
|
|
749
|
+
done
|
|
750
|
+
mode="$(ops_mode)"
|
|
751
|
+
if [ "$mode" = "dual" ]; then
|
|
752
|
+
echo "DUAL: el fichero local es primario; docs/03-backlog/epicas.md ya es la fuente y no"
|
|
753
|
+
echo "hay grafo del hub que proyectar sobre él (el carril solo opera en modo \`runtime\`)"
|
|
754
|
+
return $OPS_RC_LEGACY
|
|
755
|
+
fi
|
|
756
|
+
if [ "$mode" != "runtime" ]; then
|
|
757
|
+
echo "LEGACY: la épica ya vive en docs/03-backlog/epicas.md; no hay grafo que proyectar"
|
|
758
|
+
return $OPS_RC_LEGACY
|
|
759
|
+
fi
|
|
760
|
+
command -v python3 >/dev/null 2>&1 || { echo "⛔ sin python3 no se puede escribir el fichero" >&2; return $OPS_RC_OFFLINE; }
|
|
761
|
+
root="$(config_root)"
|
|
762
|
+
[ -n "$target" ] || target="$root/docs/03-backlog/epicas.md"
|
|
763
|
+
# [Ronda final · METODOLOGIA §9.2] `--file` aceptaba CUALQUIER ruta: con eso, un carve-out
|
|
764
|
+
# de escritura acotado a cuatro sitios se convertía en una escritura libre sobre `docs/`, y
|
|
765
|
+
# los Guardrails de /build:epic prometen justo lo contrario. La ruta se restringe al subárbol
|
|
766
|
+
# del backlog (con symlinks y `..` resueltos), y la comprobación va ANTES de la existencia:
|
|
767
|
+
# una ruta fuera del carve-out se rechaza exista o no.
|
|
768
|
+
if [ "$(_epic_writeback_path_ok "$target" "$root/docs/03-backlog")" != "OK" ]; then
|
|
769
|
+
echo "⛔ --file fuera del carve-out: $target" >&2
|
|
770
|
+
echo " El write-back solo escribe dentro de $root/docs/03-backlog/ (METODOLOGIA §9.2)." >&2
|
|
771
|
+
echo " Usa esa ruta (o ninguna: por defecto es docs/03-backlog/epicas.md)." >&2
|
|
772
|
+
return $OPS_RC_USAGE
|
|
773
|
+
fi
|
|
774
|
+
if [ ! -f "$target" ]; then
|
|
775
|
+
echo "⛔ no existe $target — el backlog de discovery es su dueño: créalo allí primero." >&2
|
|
776
|
+
return $OPS_RC_NO_SLICE
|
|
777
|
+
fi
|
|
778
|
+
# [Importante 1/2/3] fsync en AMBAS escrituras (epicas.md es un fichero de discovery que
|
|
779
|
+
# el arnés solo toca por carve-out: dejarlo truncado por un corte de energía es exactamente
|
|
780
|
+
# el daño que la restricción prohíbe); epicas.md solo se reescribe si de verdad hay algo que
|
|
781
|
+
# escribir (si no, cada invocación en vacío convertiría CRLF→LF y degradaría los permisos de
|
|
782
|
+
# 0644 a 0600 heredados del `mkstemp` de un fichero AJENO); y si el ledger no se puede
|
|
783
|
+
# persistir, la excepción SUBE (no se traga): reportar éxito con el ledger desincronizado
|
|
784
|
+
# es peor que fallar ruidosamente.
|
|
785
|
+
wrote="$(python3 - "$(ops_epic_ledger_path)" "$target" "$only" <<'PY' 2>/dev/null
|
|
786
|
+
import json,sys,os,tempfile,datetime,re
|
|
787
|
+
ledger,target,only=sys.argv[1],sys.argv[2],sys.argv[3]
|
|
788
|
+
try:
|
|
789
|
+
d=json.load(open(ledger))
|
|
790
|
+
except Exception:
|
|
791
|
+
d={}
|
|
792
|
+
ps=[p for p in (d.get("proposals") or []) if isinstance(p,dict)]
|
|
793
|
+
texto=open(target,encoding="utf-8").read()
|
|
794
|
+
escritas=[]
|
|
795
|
+
for p in ps:
|
|
796
|
+
if p.get("status")!="APPROVED" or p.get("written_back_at"):
|
|
797
|
+
continue
|
|
798
|
+
if only and p.get("proposal_id")!=only:
|
|
799
|
+
continue
|
|
800
|
+
code=p.get("epic_code")
|
|
801
|
+
if not code:
|
|
802
|
+
continue
|
|
803
|
+
epic=p.get("epic") or p.get("draft") or {}
|
|
804
|
+
# [Ronda final] La sangría permitida se alinea con RE_ENCABEZADO de graph-bundle.py
|
|
805
|
+
# (`^\s{0,3}#{1,6}`): con `#` exigido en columna 0, un encabezado sangrado —que el
|
|
806
|
+
# extractor SÍ reconoce— daba falso negativo y el write-back DUPLICABA la sección.
|
|
807
|
+
if re.search(r"^[ \t]{0,3}#{1,6}\s+.*\b%s\b" % re.escape(code), texto, re.M):
|
|
808
|
+
p["written_back_at"]=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
809
|
+
continue
|
|
810
|
+
deps=[str(x) for x in (epic.get("depends_on") or [])]
|
|
811
|
+
lineas=["", "## %s — %s" % (code, epic.get("title") or "sin título"), "",
|
|
812
|
+
"- layer: %s" % (epic.get("layer") or "business"),
|
|
813
|
+
"- origin: harness-writeback",
|
|
814
|
+
"- depende_de: [%s]" % ", ".join(deps),
|
|
815
|
+
"- propuesta: %s (aprobada en el hub)" % (p.get("proposal_id") or "?"),
|
|
816
|
+
"", "**Objetivo**: %s" % (epic.get("objective") or ""), ""]
|
|
817
|
+
historias=epic.get("stories") or []
|
|
818
|
+
if historias:
|
|
819
|
+
lineas.append("**Historias**")
|
|
820
|
+
for h in historias:
|
|
821
|
+
if not isinstance(h,dict):
|
|
822
|
+
continue
|
|
823
|
+
t=h.get("title") or ""
|
|
824
|
+
c=h.get("code")
|
|
825
|
+
lineas.append("- %s — %s" % (c,t) if c else "- %s" % t)
|
|
826
|
+
lineas.append("")
|
|
827
|
+
texto=texto.rstrip("\n")+"\n"+"\n".join(lineas)
|
|
828
|
+
p["written_back_at"]=datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
829
|
+
escritas.append(code)
|
|
830
|
+
if escritas:
|
|
831
|
+
dirn=os.path.dirname(target) or "."
|
|
832
|
+
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".epicas.",suffix=".tmp")
|
|
833
|
+
try:
|
|
834
|
+
with os.fdopen(fd,"w",encoding="utf-8") as out:
|
|
835
|
+
out.write(texto)
|
|
836
|
+
out.flush(); os.fsync(out.fileno())
|
|
837
|
+
os.replace(tmp,target)
|
|
838
|
+
except Exception:
|
|
839
|
+
try: os.unlink(tmp)
|
|
840
|
+
except OSError: pass
|
|
841
|
+
raise
|
|
842
|
+
ldir=os.path.dirname(ledger) or "."
|
|
843
|
+
fd,tmp=tempfile.mkstemp(dir=ldir,prefix=".epic-proposals.",suffix=".tmp")
|
|
844
|
+
try:
|
|
845
|
+
with os.fdopen(fd,"w") as out:
|
|
846
|
+
json.dump({"proposals":ps},out,indent=2,ensure_ascii=False)
|
|
847
|
+
out.flush(); os.fsync(out.fileno())
|
|
848
|
+
os.replace(tmp,ledger)
|
|
849
|
+
except Exception:
|
|
850
|
+
try: os.unlink(tmp)
|
|
851
|
+
except OSError: pass
|
|
852
|
+
raise
|
|
853
|
+
print(" ".join(escritas))
|
|
854
|
+
PY
|
|
855
|
+
)"
|
|
856
|
+
rc=$?
|
|
857
|
+
# [Importante 3/4] rc != 0 del heredoc es un fallo de E/S (epicas.md o el ledger no se
|
|
858
|
+
# pudieron escribir), NUNCA "no había trabajo" — un `$wrote` vacío por éxito (rc 0, nada
|
|
859
|
+
# pendiente) y un `$wrote` vacío por excepción (rc != 0) son cosas distintas.
|
|
860
|
+
if [ "$rc" -ne 0 ]; then
|
|
861
|
+
echo "⛔ el write-back no se pudo completar (fallo de E/S al escribir $target o el ledger): reintenta." >&2
|
|
862
|
+
return $OPS_RC_OFFLINE
|
|
863
|
+
fi
|
|
864
|
+
if [ -z "$wrote" ]; then
|
|
865
|
+
echo "nada que escribir: no hay propuestas APPROVED pendientes (consulta \`slice-ops.sh epic-status\`)"
|
|
866
|
+
return $OPS_RC_NO_WORK
|
|
867
|
+
fi
|
|
868
|
+
echo "escritas en $target: $wrote"
|
|
869
|
+
echo "El fichero es una PROYECCIÓN del grafo del hub: no edites el código a mano."
|
|
870
|
+
echo "Las HU sin código las numera discovery (\`/trycore:*\`): el arnés no las inventa."
|
|
871
|
+
return $OPS_RC_OK
|
|
872
|
+
}
|
|
873
|
+
|
|
429
874
|
# ── status ───────────────────────────────────────────────────────────────────────────
|
|
430
875
|
# Informe local del agente: modo, proyecto (con sus hechos: scaffold/fuente de diseño/
|
|
431
876
|
# tipo/caparazón — la precondición temprana de /build:slice y /build:work la evalúan de
|
|
@@ -465,10 +910,44 @@ cmd_status() {
|
|
|
465
910
|
else
|
|
466
911
|
echo "contexto: $lock ✓ v$(projection_get context.version "?") (${age}s)"
|
|
467
912
|
fi
|
|
913
|
+
# [#63] La versión de grafo es la foto del BACKLOG. Solo se menciona si el hub versiona:
|
|
914
|
+
# inventarla en una instancia antigua sería ruido sin significado.
|
|
915
|
+
local gv gs gserver
|
|
916
|
+
gv="$(projection_get context.graph_version "")"
|
|
917
|
+
if [ -n "$gv" ]; then
|
|
918
|
+
gs="$(runtime_graph_status_read)"
|
|
919
|
+
gserver="$(printf '%s' "$gs" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("server") or "")' 2>/dev/null)"
|
|
920
|
+
# [Ronda de arreglo 1, Important 3] La comparación es «servidor MAYOR que local», no
|
|
921
|
+
# «distinto»: un marcador de desfase que se quedó pegado con un `server` viejo (porque el
|
|
922
|
+
# carril directo no lo limpiaba al despachar con éxito — ya corregido) podía apuntar a una
|
|
923
|
+
# versión POR DEBAJO de la local, y `!=` mentía diciendo «por detrás» con el local por
|
|
924
|
+
# delante. `2>/dev/null` deja la comparación fail-open si algún valor no es entero.
|
|
925
|
+
if [ -n "$gserver" ] && [ "$gserver" -gt "$gv" ] 2>/dev/null; then
|
|
926
|
+
echo "grafo: v$gv ⚠ por detrás del hub (v$gserver): refresca antes de reclamar o proponer"
|
|
927
|
+
else
|
|
928
|
+
echo "grafo: v$gv"
|
|
929
|
+
fi
|
|
930
|
+
fi
|
|
468
931
|
echo "lease: expira $(projection_get lease.expires_at "?")"
|
|
469
|
-
|
|
470
|
-
|
|
932
|
+
local hbf hbstatus
|
|
933
|
+
hbf="$(config_root)/.claude/state/heartbeat-status.json"
|
|
934
|
+
if [ -f "$hbf" ]; then
|
|
935
|
+
grep -q '"lease_lost": *true' "$hbf" 2>/dev/null \
|
|
471
936
|
&& echo " ⚠ lease perdido: haz checkpoint y vuelve a reclamar"
|
|
937
|
+
# [#61] Una caché vieja porque la red está caída NO es lo mismo que una caché vieja
|
|
938
|
+
# porque nadie la pidió: la primera exige no cerrar gates a ciegas.
|
|
939
|
+
if grep -q '"context_stale": *true' "$hbf" 2>/dev/null; then
|
|
940
|
+
hbstatus="$(command -v python3 >/dev/null 2>&1 && python3 - "$hbf" <<'PY' 2>/dev/null
|
|
941
|
+
import json,sys
|
|
942
|
+
try:
|
|
943
|
+
print(json.load(open(sys.argv[1])).get("last_context_status"))
|
|
944
|
+
except Exception:
|
|
945
|
+
print("?")
|
|
946
|
+
PY
|
|
947
|
+
)"
|
|
948
|
+
echo " ⚠ el heartbeat no pudo refrescar /agent/context (último intento: ${hbstatus:-?})"
|
|
949
|
+
echo " se opera con la última proyección buena; no cierres gates a ciegas"
|
|
950
|
+
fi
|
|
472
951
|
fi
|
|
473
952
|
pend="$(find "$(runtime_outbox_dir)" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
474
953
|
echo "cola: $pend evento(s) pendiente(s) de despacho"
|
|
@@ -1075,6 +1554,9 @@ case "$SUB" in
|
|
|
1075
1554
|
checkpoint) cmd_checkpoint "$@"; exit $? ;;
|
|
1076
1555
|
fact) cmd_fact "$@"; exit $? ;;
|
|
1077
1556
|
propose-asset) cmd_propose_asset "$@"; exit $? ;;
|
|
1557
|
+
propose-epic) cmd_propose_epic "$@"; exit $? ;;
|
|
1558
|
+
epic-status) cmd_epic_status "$@"; exit $? ;;
|
|
1559
|
+
epic-writeback) cmd_epic_writeback "$@"; exit $? ;;
|
|
1078
1560
|
status) cmd_status "$@"; exit $? ;;
|
|
1079
1561
|
escalate) cmd_escalate "$@"; exit $? ;;
|
|
1080
1562
|
""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trycore/spec-build-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,4 +22,45 @@ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get runtime.mode legacy)"
|
|
|
22
22
|
got="$(bash -c 'source "$1/hooks/build/lib/state-io.sh" 2>/dev/null && BUILD_CONFIG_FILE="$2" config_get context.warning_pct 35' _ "$ROOT" "$TMP/cfg.json" 2>&1)"
|
|
23
23
|
[ "$got" = 40 ] && echo "OK state-io re-exporta config_get" || { echo "FAIL state-io no re-exporta ($got)"; fail=1; }
|
|
24
24
|
|
|
25
|
+
# ── config_root en worktrees ──────────────────────────────────────────────────
|
|
26
|
+
# El estado del arnés está en .gitignore (secretos + working state), así que NO
|
|
27
|
+
# viaja a un `git worktree add`. Resolviendo la raíz solo por --show-toplevel, el
|
|
28
|
+
# agente que arranca en un worktree se queda sin credenciales y opera huérfano:
|
|
29
|
+
# sin identidad, sin lease y con los guards inhibidos. Nadie lo probaba, y por eso
|
|
30
|
+
# sobrevivió hasta 0.13.0.
|
|
31
|
+
# `git rev-parse --git-common-dir` devuelve la ruta ya resuelta, y en macOS /var es
|
|
32
|
+
# un symlink a /private/var: se compara contra la ruta FÍSICA (pwd -P) para no
|
|
33
|
+
# medir la forma en que se escribió el path.
|
|
34
|
+
MAIN="$(mkdir -p "$TMP/main" && cd "$TMP/main" && pwd -P)"
|
|
35
|
+
mkdir -p "$MAIN/.claude/state"
|
|
36
|
+
git -C "$MAIN" init -q 2>/dev/null
|
|
37
|
+
git -C "$MAIN" config user.email t@t.io; git -C "$MAIN" config user.name t
|
|
38
|
+
echo x > "$MAIN/f"; git -C "$MAIN" add -A >/dev/null 2>&1; git -C "$MAIN" commit -qm init >/dev/null 2>&1
|
|
39
|
+
echo '{"runtime_url":"http://localhost"}' > "$MAIN/.claude/state/runtime.credentials"
|
|
40
|
+
WT="$TMP/wt" # lo crea `git worktree add`, se normaliza al usarlo
|
|
41
|
+
git -C "$MAIN" worktree add -q "$WT" -b wt-test >/dev/null 2>&1
|
|
42
|
+
|
|
43
|
+
got="$(cd "$WT" && unset CLAUDE_PROJECT_DIR; config_root)"
|
|
44
|
+
[ "$got" = "$MAIN" ] && echo "OK config_root cae al clon principal desde un worktree" \
|
|
45
|
+
|| { echo "FAIL config_root en worktree ($got, esperado $MAIN)"; fail=1; }
|
|
46
|
+
|
|
47
|
+
# Con CLAUDE_PROJECT_DIR apuntando al worktree (que es lo que hace el harness de
|
|
48
|
+
# Claude Code al abrir una sesión ahí) el resultado debe ser el mismo: la variable
|
|
49
|
+
# dice dónde trabaja el agente, no dónde vive el estado del arnés.
|
|
50
|
+
got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_root)"
|
|
51
|
+
[ "$got" = "$MAIN" ] && echo "OK config_root ignora CLAUDE_PROJECT_DIR sin estado" \
|
|
52
|
+
|| { echo "FAIL config_root con CLAUDE_PROJECT_DIR ($got, esperado $MAIN)"; fail=1; }
|
|
53
|
+
|
|
54
|
+
# Idempotencia: en un clon normal con estado propio, nada cambia.
|
|
55
|
+
got="$(cd "$MAIN" && CLAUDE_PROJECT_DIR="$MAIN" config_root)"
|
|
56
|
+
[ "$got" = "$MAIN" ] && echo "OK config_root respeta el clon con estado propio" \
|
|
57
|
+
|| { echo "FAIL config_root en clon normal ($got)"; fail=1; }
|
|
58
|
+
|
|
59
|
+
# Y sin estado en ninguna parte (proyecto que aún no usa el arnés) se devuelve el
|
|
60
|
+
# candidato tal cual: no se inventa una raíz ajena.
|
|
61
|
+
BARE="$TMP/bare"; mkdir -p "$BARE"
|
|
62
|
+
got="$(cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_root)"
|
|
63
|
+
[ "$got" = "$BARE" ] && echo "OK config_root sin estado devuelve el candidato" \
|
|
64
|
+
|| { echo "FAIL config_root sin estado ($got)"; fail=1; }
|
|
65
|
+
|
|
25
66
|
exit $fail
|