@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,227 @@
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
+ # [#44] El límite real del hub es 2000 (event_catalog._MAX_NOTE y VerdictIn.evidence):
28
+ # 1970 + los 27 caracteres del marcador de truncado de ops_json_str_file queda por debajo.
29
+ # El 4000 anterior producía rechazos en evidencias largas.
30
+ OPS_MAX_TEXT=1970
31
+
32
+ # Gates canónicos (se validan en el cliente: un nombre inventado por el modelo no llega
33
+ # a la red — compuerta mecánica barata, sin round-trip).
34
+ OPS_SLICE_GATES="dor coherence_link tdd journey_smoke fidelity api data wiring_verified dod"
35
+ OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
36
+
37
+ # ── Tabla de rutas ──
38
+ OPS_PATH_CLAIM="/tasks/next"
39
+ OPS_PATH_CHECKPOINTS="/checkpoints"
40
+ ops_path_verdicts() { echo "/slices/$1/verdicts"; }
41
+ ops_path_submit() { echo "/slices/$1/submit"; }
42
+ ops_path_release_verdicts() { echo "/releases/$1/verdicts"; }
43
+ ops_path_front_integration() { echo "/fronts/$1/members/$2/integration"; }
44
+ ops_path_mirror() { echo "/projects/$1/mirror/transitions"; }
45
+ ops_path_proposals() { echo "/projects/$1/context/agent-proposals"; }
46
+
47
+ ops_mode() { runtime_mode; }
48
+
49
+ # ops_slice_id — id del slice activo según la caché de proyección ("" si no hay).
50
+ ops_slice_id() { projection_get active_slice.slice_id ""; }
51
+
52
+ # ops_urlenc <texto> — percent-encoding para segmentos de ruta (una línea de release puede
53
+ # llevar espacios o barras). Sin python3 devuelve el texto tal cual: peor URL, nunca excepción.
54
+ ops_urlenc() {
55
+ command -v python3 >/dev/null 2>&1 || { printf '%s' "$1"; return; }
56
+ 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"
57
+ }
58
+
59
+ # ops_json_str <texto> — imprime el texto como cadena JSON (comillas incluidas). El texto
60
+ # viaja por STDIN, nunca por argv: así ni ARG_MAX ni el quoting de bash lo limitan.
61
+ ops_json_str() {
62
+ command -v python3 >/dev/null 2>&1 || { printf '""'; return; }
63
+ printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read(), ensure_ascii=False))' 2>/dev/null || printf '""'
64
+ }
65
+
66
+ # ops_json_str_file <fichero> [max=OPS_MAX_TEXT] — igual, leyendo de un fichero y truncando
67
+ # al tope (marcado en el propio texto para que el servidor y el humano lo vean).
68
+ ops_json_str_file() {
69
+ local f="$1" max="${2:-$OPS_MAX_TEXT}"
70
+ [ -f "$f" ] || { printf '""'; return; }
71
+ command -v python3 >/dev/null 2>&1 || { printf '""'; return; }
72
+ python3 - "$f" "$max" <<'PY' 2>/dev/null || printf '""'
73
+ import json,sys
74
+ try:
75
+ t=open(sys.argv[1], encoding="utf-8", errors="replace").read()
76
+ except Exception:
77
+ t=""
78
+ m=int(sys.argv[2])
79
+ if len(t) > m:
80
+ t = t[:m] + "\n…[truncado por el cliente]"
81
+ sys.stdout.write(json.dumps(t, ensure_ascii=False))
82
+ PY
83
+ }
84
+
85
+ # ops_tmpjson <json> — materializa un JSON en un fichero temporal e imprime su ruta.
86
+ # Los payloads viajan a python3 y a curl POR FICHERO (regla dura del repo: nunca por argv).
87
+ ops_tmpjson() {
88
+ local f
89
+ f="$(mktemp)" || return 1
90
+ printf '%s' "$1" > "$f"
91
+ echo "$f"
92
+ }
93
+
94
+ # ops_context_hashes — sha256 EFECTIVO local de cada archivo gobernado del lock
95
+ # (protocolo §3 regla 3: el servidor detecta drift/manipulación local comparándolos con los
96
+ # suyos). Se hashea el fichero real, NUNCA se repite el sha del lock: repetirlo convertiría
97
+ # el reporte en un espejo del servidor y el drift sería indetectable.
98
+ # Forma del reporte [#36]: el hub (`TasksNextIn.context_hashes: dict[str, str] | None`)
99
+ # exige un DICT {path: sha} — la lista [{"path":…,"sha256":…}] era 422 dict_type en TODO
100
+ # claim. Fallback {} (nunca []); fichero ilegible => "" (el dict no admite null en valores).
101
+ ops_context_hashes() {
102
+ local lock root
103
+ lock="$(runtime_lock_path)"
104
+ root="$(config_root)"
105
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
106
+ python3 - "$lock" "$root" <<'PY' 2>/dev/null || echo "{}"
107
+ import json,sys,os,hashlib
108
+ lock,root=sys.argv[1],sys.argv[2]
109
+ try:
110
+ files=json.load(open(lock)).get("files") or []
111
+ except Exception:
112
+ files=[]
113
+ out={}
114
+ for f in files:
115
+ if not isinstance(f,dict):
116
+ continue
117
+ p=f.get("path")
118
+ if not isinstance(p,str) or not p:
119
+ continue
120
+ full=os.path.join(root,".claude",p)
121
+ sha=""
122
+ try:
123
+ h=hashlib.sha256()
124
+ with open(full,"rb") as fh:
125
+ for chunk in iter(lambda: fh.read(65536), b""):
126
+ h.update(chunk)
127
+ sha=h.hexdigest()
128
+ except Exception:
129
+ sha=""
130
+ out[p]=sha
131
+ print(json.dumps(out,ensure_ascii=False))
132
+ PY
133
+ }
134
+
135
+ # ops_local_slice_ref — identidad del slice según el FICHERO local. Solo tiene sentido en
136
+ # modo `dual`, donde el fichero es primario y el runtime crea la identidad por el mismo
137
+ # camino que el import (spec §4.1-D). En `runtime` no se usa: allí manda la caché.
138
+ # Imprime {} si no hay fichero, no hay python3 o no hay slice activo — nunca lanza.
139
+ ops_local_slice_ref() {
140
+ local p
141
+ p="$(config_root)/.claude/state/build-state.json"
142
+ [ -f "$p" ] || { echo "{}"; return; }
143
+ command -v python3 >/dev/null 2>&1 || { echo "{}"; return; }
144
+ python3 - "$p" <<'PY' 2>/dev/null || echo "{}"
145
+ import json,sys
146
+ try:
147
+ d=json.load(open(sys.argv[1]))
148
+ s=d.get("active_slice") or {}
149
+ except Exception:
150
+ s={}
151
+ print(json.dumps({"epic_code": s.get("epica"),
152
+ "openspec_change": s.get("openspec_change"),
153
+ "branch": s.get("branch"),
154
+ "phase": s.get("phase")}, ensure_ascii=False))
155
+ PY
156
+ }
157
+
158
+ # ops_enqueue_from_file <type> <payload_file> [slice_id] — encola un evento cuyo payload
159
+ # puede ser grande (checklist de wiring, evidencia, contenido propuesto): el payload se lee
160
+ # del FICHERO dentro de python3, así que no pasa por argv (a diferencia de
161
+ # runtime_enqueue_event, que sigue sirviendo para payloads pequeños).
162
+ ops_enqueue_from_file() {
163
+ local type="$1" pfile="$2" slice_id="${3:-}" dir
164
+ dir="$(runtime_outbox_dir)"
165
+ mkdir -p "$dir"
166
+ command -v python3 >/dev/null 2>&1 || return 1
167
+ python3 - "$dir" "$type" "$pfile" "$slice_id" <<'PY' 2>/dev/null || return 1
168
+ import json,sys,os,tempfile,uuid,datetime
169
+ dir_,typ,pfile,slice_id=sys.argv[1],sys.argv[2],sys.argv[3],sys.argv[4]
170
+ try:
171
+ payload=json.load(open(pfile))
172
+ except Exception:
173
+ payload={}
174
+ cid=str(uuid.uuid4())
175
+ ts=datetime.datetime.now(datetime.timezone.utc)
176
+ d={"client_event_id":cid,"type":typ,"payload":payload,
177
+ "enqueued_at":ts.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}
178
+ if slice_id:
179
+ d["slice_id"]=slice_id
180
+ fname=os.path.join(dir_, ts.strftime("%Y%m%d%H%M%S%f")+"-"+cid+".json")
181
+ fd,tmp=tempfile.mkstemp(dir=dir_,prefix=".outbox.",suffix=".tmp")
182
+ try:
183
+ with os.fdopen(fd,"w") as out:
184
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
185
+ os.replace(tmp,fname)
186
+ except Exception:
187
+ try: os.unlink(tmp)
188
+ except OSError: pass
189
+ raise
190
+ PY
191
+ runtime_outbox_enforce_cap >/dev/null
192
+ }
193
+
194
+ # ops_mirror <kind> <payload_file> — ingesta espejo del modo `dual` (spec §4.1-D). El
195
+ # fichero local sigue siendo primario: un rechazo del reducer NO bloquea el trabajo, queda
196
+ # como discrepancia del comparador. rc 0 aceptado · 5 no se pudo (red) · 6 rechazado.
197
+ ops_mirror() {
198
+ local kind="$1" pfile="$2" pid body resp status tmp
199
+ pid="$(runtime_field project_id)"
200
+ [ -n "$pid" ] || return 5
201
+ command -v python3 >/dev/null 2>&1 || return 5
202
+ tmp="$(mktemp)" || return 5
203
+ python3 - "$kind" "$pfile" > "$tmp" <<'PY' 2>/dev/null
204
+ import json,sys,datetime
205
+ kind,pfile=sys.argv[1],sys.argv[2]
206
+ try:
207
+ payload=json.load(open(pfile))
208
+ except Exception:
209
+ payload={}
210
+ print(json.dumps({"kind":kind,"payload":payload,"origin":"mirror",
211
+ "occurred_at":datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")},
212
+ ensure_ascii=False))
213
+ PY
214
+ body="$(cat "$tmp" 2>/dev/null)"
215
+ rm -f "$tmp"
216
+ [ -n "$body" ] || return 5
217
+ resp="$(runtime_post "$(ops_path_mirror "$pid")" "$body")"
218
+ status="$(runtime_http_status)"
219
+ case "$status" in
220
+ 200|201|202) return 0 ;;
221
+ 409|422)
222
+ echo "⚠ el espejo rechazó «${kind}»: $resp" >&2
223
+ echo " (queda como discrepancia del comparador dual; el trabajo local NO se bloquea)" >&2
224
+ return 6 ;;
225
+ *) return 5 ;;
226
+ esac
227
+ }
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # state-io.sh — helpers compartidos: rutas, lectura de config y patch atómico del estado.
2
+ # state-io.sh — helpers compartidos: rutas y patch atómico del estado.
3
+ # config_get vive en lib/config.sh [EP-OR-08-A] — se re-exporta sourceándolo aquí para
4
+ # que los llamadores existentes sigan funcionando sin cambios.
3
5
  # Diseño: fail-open. Nunca lanza; ante fallo deja el archivo intacto.
4
6
 
7
+ source "$(dirname "${BASH_SOURCE[0]}")/config.sh"
8
+
5
9
  state_path() {
6
10
  # CLAUDE_PROJECT_DIR es la señal autoritativa que Claude Code fija en los hooks;
7
11
  # git rev-parse solo aplica como fallback para invocación manual/dev (sin ese env).
@@ -9,23 +13,6 @@ state_path() {
9
13
  echo "$root/.claude/state/build-state.json"
10
14
  }
11
15
 
12
- # config_get <clave.punteada> <default>
13
- config_get() {
14
- local key="$1" def="$2" file
15
- file="${BUILD_CONFIG_FILE:-$(dirname "$(state_path)")/../config/build-config.json}"
16
- command -v python3 >/dev/null 2>&1 || { echo "$def"; return; }
17
- python3 - "$file" "$key" "$def" <<'PY' 2>/dev/null || echo "$def"
18
- import json,sys
19
- file,key,default=sys.argv[1],sys.argv[2],sys.argv[3]
20
- try:
21
- d=json.load(open(file))
22
- for part in key.split("."): d=d[part]
23
- print(d if not isinstance(d,bool) else str(d).lower())
24
- except Exception:
25
- print(default)
26
- PY
27
- }
28
-
29
16
  # state_atomic_patch <state-file> <expr-python-que-muta-`d`>
30
17
  # Ejecuta la expresión con `d` = dict del estado; escribe atómico solo si válido.
31
18
  state_atomic_patch() {
@@ -1,9 +1,71 @@
1
1
  #!/usr/bin/env bash
2
2
  # load-build-state.sh — SessionStart
3
3
  # Inyecta al contexto: rama actual, harness_phase, slice activo y gates abiertos.
4
- # Además sincroniza harness_phase (authoring -> active) según exista package.json.
4
+ # [EP-OR-08-B] Dos caminos según runtime_mode():
5
+ # - legacy|dual: comportamiento v0.8.5 exacto (el fichero es primario; además sincroniza
6
+ # harness_phase y resetea el guard de handoff por sesión).
7
+ # - runtime: render desde la caché de proyección; NO se sourcea state-io.sh ni se invoca
8
+ # el reconciliador (la maquinaria legacy no se carga en runtime, spec §4.2-E).
5
9
  set -uo pipefail
6
- source "$(dirname "${BASH_SOURCE[0]}")/lib/state-io.sh"
10
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ source "$HERE/lib/runtime-client.sh"
12
+ source "$HERE/lib/projection.sh"
13
+
14
+ # render_runtime — pinta el mismo bloque de contexto que el camino legacy, pero con los
15
+ # hechos del runtime. Todo lo que falte degrada a un texto neutro: jamás rompe el arranque.
16
+ render_runtime() {
17
+ local root branch slice gates wiring subs hint ver age
18
+ root="$(config_root)"
19
+ branch="$(git -C "$root" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'desconocida')"
20
+ echo "🏗️ Arnés de construcción · modo runtime"
21
+ echo " Rama: $branch | Fase del arnés: $(projection_get project.harness_phase desconocida)"
22
+ ver="$(projection_get context.version "")"
23
+ [ -n "$ver" ] && echo " Contexto sincronizado: v$ver"
24
+ if projection_is_stale; then
25
+ age="$(projection_age_seconds)"
26
+ echo " ⚠ Proyección local sin refrescar (${age}s): el runtime no respondió. Los guards siguen operando con esta caché."
27
+ fi
28
+ if [ "$(cat "$root/.claude/state/heartbeat-status.json" 2>/dev/null | grep -c '"lease_lost": true')" != "0" ]; then
29
+ echo " ⚠ Lease perdido: otro agente pudo tomar el slice. Haz checkpoint y vuelve a reclamar (/build:claim)."
30
+ fi
31
+ slice="$(projection_get active_slice.slice_id "")"
32
+ if [ -z "$slice" ]; then
33
+ echo " Slice activo: ninguno. Reclama el siguiente con /build:claim (skill building-a-slice)."
34
+ return 0
35
+ fi
36
+ echo " Slice activo: $(projection_get active_slice.epic_code '?') [$(projection_get active_slice.hus '[]' | tr -d '[]"')] · change=$(projection_get active_slice.openspec_change '—') · fase=$(projection_get active_slice.phase '—')"
37
+ gates="$(projection_get active_slice.gates '{}' | python3 -c 'import json,sys
38
+ try: g=json.load(sys.stdin)
39
+ except Exception: g={}
40
+ abiertos=[k for k,v in g.items() if v is False]
41
+ print(", ".join(abiertos) if abiertos else "ninguno ✅")' 2>/dev/null)"
42
+ echo " Gates pendientes: ${gates:-desconocidos}"
43
+ wiring="$(projection_get active_slice.wiring_failing '[]' | python3 -c 'import json,sys
44
+ try: w=json.load(sys.stdin)
45
+ except Exception: w=[]
46
+ if w:
47
+ muestra="; ".join("%s(%s)" % (i.get("item_id"), i.get("kind")) for i in w[:8])
48
+ extra=" (+%d más)" % (len(w)-8) if len(w)>8 else ""
49
+ print("%d|%s%s" % (len(w), muestra, extra))' 2>/dev/null)"
50
+ if [ -n "$wiring" ]; then
51
+ echo " ⚠️ Cableado pendiente (${wiring%%|*} item/s failing): ${wiring#*|}"
52
+ echo " NO declares el slice terminado mientras queden items failing (verifica con prueba real)."
53
+ fi
54
+ subs="$(projection_get active_slice.sub_slices_pending '[]' | tr -d '[]"')"
55
+ [ -n "$subs" ] && echo " ⚠️ Sub-slices pendientes: $subs"
56
+ hint="$(projection_get active_slice.resume_hint "")"
57
+ [ -n "$hint" ] && echo " ▶️ Retomar: $hint"
58
+ return 0
59
+ }
60
+
61
+ if [ "$(runtime_mode)" = "runtime" ]; then
62
+ cat >/dev/null 2>&1 # consumir el payload del hook (protocolo)
63
+ render_runtime
64
+ exit 0
65
+ fi
66
+
67
+ # ── A partir de aquí, camino legacy|dual: v0.8.5 exacto ──
68
+ source "$HERE/lib/state-io.sh"
7
69
 
8
70
  # Lee el payload de SessionStart (JSON en stdin) para extraer session_id. Fail-open:
9
71
  # si no hay stdin o no trae session_id, seguimos sin tocar session_continuity.
@@ -4,6 +4,21 @@
4
4
  # Sugiere /build:reflect SOLO si hay slice(s) archivado(s) sin reflexionar (reflected != true).
5
5
  # Determinista y barato: el razonamiento (qué se aprendió) lo hace el MODELO en /build:reflect.
6
6
  set -uo pipefail
7
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ source "$HERE/lib/projection.sh"
9
+
10
+ # [EP-OR-08-B] En runtime el cómputo («qué slices quedaron sin reflexionar») lo hace el
11
+ # servidor sobre TODO el proyecto, no este clon: aquí solo se renderiza su nudge.
12
+ if [ "$(runtime_mode)" = "runtime" ]; then
13
+ projection_get nudges '[]' | python3 -c 'import json,sys
14
+ try: ns=json.load(sys.stdin)
15
+ except Exception: ns=[]
16
+ for n in ns:
17
+ if isinstance(n,dict) and n.get("kind")=="reflect" and n.get("message"):
18
+ print("💡 "+n["message"])
19
+ print(" Ejecuta /build:reflect para proponer convenciones aprendidas a CLAUDE.md (se aplican tras tu aprobación).")' 2>/dev/null
20
+ exit 0
21
+ fi
7
22
 
8
23
  ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
9
24
  STATE="$ROOT/.claude/state/build-state.json"
@@ -7,6 +7,21 @@
7
7
  # El criterio "cierra una línea de release" vive en docs/02-user-story-map/ y NO se intenta aquí
8
8
  # (lo computa la skill building-a-slice en la fase 8).
9
9
  set -uo pipefail
10
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ source "$HERE/lib/projection.sh"
12
+
13
+ # [EP-OR-08-B] Igual que reflect-nudge: la aritmética de conjuntos (archivadas − cubiertas)
14
+ # es del servidor; este hook renderiza el nudge tipado y jamás bloquea el cierre.
15
+ if [ "$(runtime_mode)" = "runtime" ]; then
16
+ projection_get nudges '[]' | python3 -c 'import json,sys
17
+ try: ns=json.load(sys.stdin)
18
+ except Exception: ns=[]
19
+ for n in ns:
20
+ if isinstance(n,dict) and n.get("kind")=="release_gate" and n.get("message"):
21
+ print("💡 Release Gate sugerido: "+n["message"])
22
+ print(" Ejecuta /build:release (o la skill releasing-a-version): corre los gates pesados UNA vez sobre el diff acumulado.")' 2>/dev/null
23
+ exit 0
24
+ fi
10
25
 
11
26
  ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
12
27
  STATE="$ROOT/.claude/state/build-state.json"
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # release-ops.sh — operaciones del OUTER LOOP que conducen las skills [EP-OR-08-C]:
3
+ # veredictos del Release Gate y reporte de integración de un miembro del front paralelo.
4
+ # NO es un hook registrado: lo invocan `releasing-a-version` y `managing-parallel-front`.
5
+ #
6
+ # Línea de corte (spec §2): **reportar un hecho medido es acto de agente; decidir es acto
7
+ # humano**. Por eso aquí están los veredictos y el reporte de integración, y NO están:
8
+ # cerrar la release, planificar/abrir/drenar/cerrar el front, ni publicar contexto — esos
9
+ # viven en la consola del hub, con PDP, y las skills se limitan a instruir al humano.
10
+ #
11
+ # uso: release-ops.sh <verdict|close-hint|front-integration> …
12
+ set -uo pipefail
13
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$HERE/lib/runtime-ops.sh"
15
+
16
+ usage() {
17
+ cat >&2 <<'USAGE'
18
+ uso: release-ops.sh <subcomando> [opciones]
19
+
20
+ verdict <line> <security|smell|ux|coherence|stack_arch|integration> <pass|fail|na>
21
+ [--evidence T | --evidence-file F] reporta un veredicto del Release Gate
22
+ close-hint <line> recuerda cómo se cierra (acto humano)
23
+ front-integration <front_id> --merge-status <pending|merged|conflict>
24
+ [--resmoke pass|fail] [--evidence T | --evidence-file F]
25
+
26
+ códigos de salida: 0 ok · 2 uso · 3 legacy · 5 offline · 6 rechazado
27
+ USAGE
28
+ }
29
+
30
+ # _rel_evidence_json — misma política que en slice-ops: fichero preferido, texto corto si no.
31
+ _rel_evidence_json() {
32
+ local ev="$1" evfile="$2"
33
+ if [ -n "$evfile" ]; then ops_json_str_file "$evfile"; else ops_json_str "$ev"; fi
34
+ }
35
+
36
+ # ── verdict ──────────────────────────────────────────────────────────────────────────
37
+ cmd_verdict() {
38
+ local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile
39
+ if [ -z "$line" ] || [ -z "$gate" ] || [ -z "$estado" ]; then usage; return $OPS_RC_USAGE; fi
40
+ shift 3
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --evidence) ev="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
44
+ --evidence-file) evfile="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
45
+ *) shift ;;
46
+ esac
47
+ done
48
+ case " $OPS_RELEASE_GATES " in
49
+ *" $gate "*) : ;;
50
+ *) echo "⛔ gate de release desconocido «${gate}». Canónicos: $OPS_RELEASE_GATES" >&2
51
+ echo " (los gates por slice — dor, tdd, journey_smoke… — van por slice-ops.sh gate)" >&2
52
+ return $OPS_RC_REJECTED ;;
53
+ esac
54
+ case "$estado" in
55
+ pass|fail|na) : ;;
56
+ *) echo "⛔ estado inválido «${estado}» (pass|fail|na)" >&2; return $OPS_RC_REJECTED ;;
57
+ esac
58
+ mode="$(ops_mode)"
59
+ [ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en releases[] del fichero"; return $OPS_RC_LEGACY; }
60
+ payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
61
+ if [ "$mode" = "dual" ]; then
62
+ pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
63
+ ops_mirror release_verdict_reported "$pfile" || echo "⚠ veredicto de release no espejado; el comparador dual lo marcará" >&2
64
+ rm -f "$pfile"
65
+ return $OPS_RC_OK
66
+ fi
67
+ resp="$(runtime_post "$(ops_path_release_verdicts "$(ops_urlenc "$line")")" "$payload")"
68
+ status="$(runtime_http_status)"
69
+ case "$status" in
70
+ 200|201|202)
71
+ echo "veredicto de release registrado: $gate=$estado en $line"
72
+ echo "El cierre de la release NO lo hace el agente: lo hace un humano en la consola (spec §2)."
73
+ return $OPS_RC_OK ;;
74
+ 422)
75
+ echo "⛔ 422 el servidor rechazó el veredicto: $resp — no reintentes; corrige y vuelve a medir." >&2
76
+ return $OPS_RC_REJECTED ;;
77
+ 401|403)
78
+ echo "⛔ token sin permiso para reportar veredictos de release ($status): habla con el ADMIN." >&2
79
+ return $OPS_RC_REJECTED ;;
80
+ *)
81
+ # [#44] SIN fallback offline: `release_gate_verdict` es del agregado `release` y
82
+ # POST /events solo ingiere ámbito slice (slice_id obligatorio y slice existente,
83
+ # progress_service._apply_and_append) o proyecto — el evento encolado era rechazo
84
+ # garantizado al drenar y el veredicto jamás llegaba. Mismo patrón que
85
+ # propose-asset: rc 5 y reintento explícito al reconectar.
86
+ echo "OFFLINE: no se pudo reportar el veredicto de release (status $status) y este acto" >&2
87
+ echo " no tiene camino por la cola offline. Reintenta cuando haya red:" >&2
88
+ echo " release-ops.sh verdict $line $gate $estado" >&2
89
+ return $OPS_RC_OFFLINE ;;
90
+ esac
91
+ }
92
+
93
+ # ── close-hint ───────────────────────────────────────────────────────────────────────
94
+ # Cero red a propósito: es un recordatorio de gobierno, no una llamada.
95
+ cmd_close_hint() {
96
+ local line="${1:-}"
97
+ [ -n "$line" ] || { usage; return $OPS_RC_USAGE; }
98
+ cat <<HINT
99
+ El cierre de la release «${line}» es un acto de GOBIERNO HUMANO (spec §2):
100
+ 1. El agente reporta los 6 veredictos con \`release-ops.sh verdict $line <gate> <estado>\`.
101
+ 2. El runtime agrega: parciales NO promueven a passed.
102
+ 3. Una persona con permiso cierra la línea en la consola del hub.
103
+ El arnés no cierra releases ni con token de agente: esa superficie exige PDP.
104
+ HINT
105
+ return $OPS_RC_OK
106
+ }
107
+
108
+ # ── front-integration ────────────────────────────────────────────────────────────────
109
+ # Cada worktree se registra como AGENTE PROPIO (mismo token de proyecto, `agent_key`
110
+ # distinto): reporta la integración de SU miembro. Planificar, abrir, drenar y cerrar el
111
+ # front siguen siendo humanos.
112
+ cmd_front_integration() {
113
+ local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status pfile
114
+ [ -n "$front" ] || { usage; return $OPS_RC_USAGE; }
115
+ shift
116
+ while [ $# -gt 0 ]; do
117
+ case "$1" in
118
+ --merge-status) merge="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
119
+ --resmoke) resmoke="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
120
+ --evidence) ev="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
121
+ --evidence-file) evfile="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
122
+ *) shift ;;
123
+ esac
124
+ done
125
+ case "$merge" in
126
+ pending|merged|conflict) : ;;
127
+ *) echo "⛔ --merge-status debe ser pending|merged|conflict" >&2; return $OPS_RC_REJECTED ;;
128
+ esac
129
+ case "$resmoke" in
130
+ ""|pass|fail) : ;;
131
+ *) echo "⛔ --resmoke debe ser pass|fail" >&2; return $OPS_RC_REJECTED ;;
132
+ esac
133
+ mode="$(ops_mode)"
134
+ [ "$mode" = "legacy" ] && { echo "LEGACY: actualiza parallel_front.members[] en el fichero"; return $OPS_RC_LEGACY; }
135
+ payload="{\"merge_status\":$(ops_json_str "$merge"),\"re_smoke\":{\"status\":$(ops_json_str "$resmoke"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}}"
136
+ if [ "$mode" = "dual" ]; then
137
+ pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
138
+ ops_mirror front_integration_reported "$pfile" || echo "⚠ integración de front no espejada; el comparador dual la marcará" >&2
139
+ rm -f "$pfile"
140
+ return $OPS_RC_OK
141
+ fi
142
+ akey="$(runtime_field agent_key)"
143
+ [ -n "$akey" ] || { echo "⛔ este worktree no está registrado como agente: corre \`trycore-build init\` en él" >&2; return $OPS_RC_REJECTED; }
144
+ resp="$(runtime_post "$(ops_path_front_integration "$(ops_urlenc "$front")" "$(ops_urlenc "$akey")")" "$payload")"
145
+ status="$(runtime_http_status)"
146
+ case "$status" in
147
+ 200|201|202) echo "integración reportada: merge_status=$merge re_smoke=${resmoke:-n/a}"; return $OPS_RC_OK ;;
148
+ 422) echo "⛔ 422 el servidor rechazó el reporte: $resp" >&2; return $OPS_RC_REJECTED ;;
149
+ 401|403) echo "⛔ token sin permiso ($status): habla con el ADMIN." >&2; return $OPS_RC_REJECTED ;;
150
+ *)
151
+ # [#44] SIN fallback offline: `front_integration_reported` NO existe en el catálogo
152
+ # v2 del hub (el hecho del catálogo, `front_member_integrated`, es del agregado
153
+ # `front` y POST /events no lo ingiere) y la superficie síncrona de integración es
154
+ # de gobierno humano (`can_govern_front`). Encolarlo era rechazo garantizado: rc 5
155
+ # y reintento explícito al reconectar (mismo patrón que propose-asset).
156
+ echo "OFFLINE: no se pudo reportar la integración (status $status) y este acto no" >&2
157
+ echo " tiene camino por la cola offline. Reintenta cuando haya red:" >&2
158
+ echo " release-ops.sh front-integration $front --merge-status $merge${resmoke:+ --resmoke $resmoke}" >&2
159
+ return $OPS_RC_OFFLINE ;;
160
+ esac
161
+ }
162
+
163
+ SUB="${1:-}"
164
+ [ $# -gt 0 ] && shift
165
+ case "$SUB" in
166
+ verdict) cmd_verdict "$@"; exit $? ;;
167
+ close-hint) cmd_close_hint "$@"; exit $? ;;
168
+ front-integration) cmd_front_integration "$@"; exit $? ;;
169
+ ""|-h|--help|help) usage; exit $OPS_RC_USAGE ;;
170
+ *) echo "subcomando desconocido: $SUB" >&2; usage; exit $OPS_RC_USAGE ;;
171
+ esac
@@ -8,11 +8,38 @@
8
8
  set -uo pipefail
9
9
 
10
10
  ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
11
- STATE="$ROOT/.claude/state/build-state.json"
12
- [ -f "$STATE" ] || exit 0
13
11
 
14
12
  INPUT="$(cat)"
15
13
 
14
+ # [EP-OR-08-B] En modo runtime los hechos vienen de la caché de proyección (la escribe
15
+ # session-start.sh desde /agent/context), NUNCA de la red: este hook corre en PreToolUse y
16
+ # la red está prohibida aquí. En legacy|dual el fichero sigue siendo primario (spec §6.1).
17
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ source "$HERE/lib/projection.sh"
19
+ if [ "$(runtime_mode)" = "runtime" ]; then
20
+ CACHE="$(runtime_projection_path)"
21
+ [ -f "$CACHE" ] || exit 0 # aún sin sincronizar: nada que vigilar (auto-arme)
22
+ if ! command -v python3 >/dev/null 2>&1; then
23
+ echo "⛔ scaffold-guard: python3 no disponible; no puedo verificar el gate de scaffold. Instala python3 (trycore-build doctor)." >&2
24
+ exit 2
25
+ fi
26
+ PHASE="$(projection_get active_slice.phase "")"
27
+ case "$PHASE" in
28
+ red|green|refactor|smoke|api|data) ;;
29
+ *) exit 0 ;; # sin slice o en dor/change/pr/archived: permitido
30
+ esac
31
+ [ "$(projection_get project.scaffold_confirmed false)" = "true" ] && exit 0
32
+ echo "⛔ scaffold-guard (Paso 1 fundamental): el slice está en fase de código pero el scaffold NO está confirmado." >&2
33
+ echo " Confirma primero que existe un scaffold runnable del proyecto (ver building-a-slice Fase 0 / DoR)." >&2
34
+ echo " El arnés NO genera el scaffold: créalo según tu stack (stack-allowlist.json) y confírmalo." >&2
35
+ projection_is_stale && echo " ⚠ Veredicto emitido con proyección stale (sin refrescar): el runtime no respondió en el último arranque." >&2
36
+ exit 2
37
+ fi
38
+
39
+ # Camino legacy|dual: leer build-state.json
40
+ STATE="$ROOT/.claude/state/build-state.json"
41
+ [ -f "$STATE" ] || exit 0
42
+
16
43
  # Guarda python3 [H4]: si falta, no podemos leer el estado de forma fiable. Fail-closed
17
44
  # DIRIGIDO: solo bloqueamos si la edición NO es del propio scaffold/planificación obvia.
18
45
  if ! command -v python3 >/dev/null 2>&1; then