@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,853 @@
1
+ #!/usr/bin/env bash
2
+ # test-hooks-runtime.sh — hooks del arnés como cliente del runtime [EP-OR-08-B].
3
+ set -uo pipefail
4
+ ROOT="$(git rev-parse --show-toplevel)"
5
+ fail=0
6
+ TMP="$(mktemp -d)"
7
+ PROJ="$TMP/proj"
8
+ mkdir -p "$PROJ/.claude/state" "$PROJ/.claude/config"
9
+ export CLAUDE_PROJECT_DIR="$PROJ"
10
+ export BUILD_CONFIG_FILE="$PROJ/.claude/config/build-config.json"
11
+ echo '{"runtime":{"mode":"runtime","stale_seconds":900},"context":{"warning_pct":35,"critical_pct":25,"stale_seconds":60,"auto_checkpoint":false}}' > "$BUILD_CONFIG_FILE"
12
+ # El daemon de heartbeat puede quedar vivo si un test falla a mitad: se apaga en el trap.
13
+ # (heartbeat.sh nace en la Task 5; hasta entonces el trap simplemente no encuentra el script
14
+ # y el error queda silenciado — nunca ensucia la salida del test.)
15
+ cleanup() {
16
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
17
+ rm -rf "$TMP"
18
+ }
19
+ trap cleanup EXIT
20
+
21
+ source "$ROOT/hooks/build/lib/projection.sh" 2>/dev/null || { echo "FAIL no existe lib/projection.sh"; exit 1; }
22
+
23
+ # --- lib/projection.sh ---
24
+ got="$(projection_get active_slice.phase sin-cache)"
25
+ [ "$got" = "sin-cache" ] && echo "OK projection_get sin cache -> default" || { echo "FAIL projection_get sin cache ($got)"; fail=1; }
26
+
27
+ runtime_projection_write '{"fetched_at":"2026-08-19T10:00:00Z","project":{"scaffold_confirmed":true,"design_source_applies":false},"active_slice":{"slice_id":"sl-1","phase":"green","gates":{"dor":true,"tdd":false},"wiring_failing":[{"item_id":"w1","kind":"e2e"}]},"nudges":[]}'
28
+
29
+ got="$(projection_get active_slice.phase "")"
30
+ [ "$got" = green ] && echo "OK projection_get escalar" || { echo "FAIL projection_get escalar ($got)"; fail=1; }
31
+ got="$(projection_get project.scaffold_confirmed false)"
32
+ [ "$got" = true ] && echo "OK projection_get bool en minusculas" || { echo "FAIL projection_get bool ($got)"; fail=1; }
33
+ got="$(projection_get active_slice.gates '{}')"
34
+ echo "$got" | grep -q '"tdd": *false' && echo "OK projection_get subarbol como JSON" || { echo "FAIL projection_get subarbol ($got)"; fail=1; }
35
+ got="$(projection_get active_slice.no_existe fallback)"
36
+ [ "$got" = fallback ] && echo "OK projection_get clave ausente -> default" || { echo "FAIL projection_get ausente ($got)"; fail=1; }
37
+
38
+ # python3 ausente: PATH vacío -> default, jamás excepción (config_root usa CLAUDE_PROJECT_DIR,
39
+ # así que no necesita git en el PATH para resolver la ruta).
40
+ got="$(PATH=/var/empty projection_get active_slice.phase degradado)"
41
+ [ "$got" = degradado ] && echo "OK projection_get sin python3 -> default" || { echo "FAIL projection_get sin python3 ($got)"; fail=1; }
42
+
43
+ # Frescura: la caché escrita arriba tiene fetched_at de 2026-08-19 (fija) -> con
44
+ # stale_seconds=900 el veredicto depende del reloj; se re-escribe con la hora actual
45
+ # para una comprobación determinista de "fresca", y con una vieja para "stale".
46
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"active_slice\":null,\"nudges\":[]}"
47
+ if projection_is_stale; then echo "FAIL caché recién escrita se declara stale"; fail=1; else echo "OK caché fresca no es stale"; fi
48
+ runtime_projection_write '{"fetched_at":"2000-01-01T00:00:00Z","active_slice":null,"nudges":[]}'
49
+ if projection_is_stale; then echo "OK caché vieja es stale"; else echo "FAIL caché vieja no se declara stale"; fail=1; fi
50
+ age="$(projection_age_seconds)"
51
+ [ "$age" -gt 900 ] && echo "OK projection_age_seconds" || { echo "FAIL projection_age_seconds ($age)"; fail=1; }
52
+
53
+ # --- lib/agent-context.sh: normalización tolerante ---
54
+ source "$ROOT/hooks/build/lib/agent-context.sh" 2>/dev/null || { echo "FAIL no existe lib/agent-context.sh"; fail=1; }
55
+
56
+ cat > "$TMP/raw1.json" <<'JSON'
57
+ {"project":{"scaffold":{"confirmed":true},"design_source":{"applies":true,"confirmed":false},
58
+ "project_kind":"greenfield","harness_phase":"active"},
59
+ "active_slice":{"slice_id":"sl-7","epic_code":"EP-003","epic_title":"Cobros","stories":["HU-011","HU-012"],
60
+ "phase":"green","gates":{"dor":true,"tdd":false},"branch":"feature/cobros",
61
+ "openspec_change":"add-cobros",
62
+ "wiring_checklist":[{"item_id":"w1","kind":"e2e","status":"failing"},
63
+ {"item_id":"w2","kind":"api","status":"passing"}],
64
+ "sub_slices":[{"id":"ss-1","status":"done"},{"id":"ss-2","status":"pending"}],
65
+ "session_continuity":{"resume_hint":"cablear w1"}},
66
+ "nudges":[{"kind":"reflect","message":"3 slices sin reflexionar"},"recordatorio suelto"],
67
+ "context":{"manifest_hash":"sha256:aaa","version":14},
68
+ "lease":{"expires_at":"2026-08-19T12:00:00Z","ttl_s":300}}
69
+ JSON
70
+ norm="$(agent_context_normalize "$TMP/raw1.json")"
71
+ echo "$norm" | python3 -c '
72
+ import json,sys
73
+ d=json.load(sys.stdin)
74
+ ok=True
75
+ ok &= d["project"]["scaffold_confirmed"] is True
76
+ ok &= d["project"]["design_source_applies"] is True
77
+ ok &= d["project"]["design_source_confirmed"] is False
78
+ ok &= d["active_slice"]["slice_id"]=="sl-7"
79
+ ok &= d["active_slice"]["epic_code"]=="EP-003"
80
+ ok &= d["active_slice"]["hus"]==["HU-011","HU-012"]
81
+ ok &= d["active_slice"]["wiring_failing"]==[{"item_id":"w1","kind":"e2e"}]
82
+ ok &= d["active_slice"]["sub_slices_pending"]==["ss-2"]
83
+ ok &= d["active_slice"]["resume_hint"]=="cablear w1"
84
+ ok &= d["context"]["manifest_hash"]=="sha256:aaa"
85
+ ok &= d["lease"]["ttl_s"]==300
86
+ ok &= len(d["nudges"])==2 and d["nudges"][1]["kind"]=="info"
87
+ ok &= "fetched_at" in d
88
+ sys.exit(0 if ok else 1)' \
89
+ && echo "OK agent_context_normalize mapea el contrato completo" \
90
+ || { echo "FAIL agent_context_normalize ($norm)"; fail=1; }
91
+
92
+ # Respuesta pobre/desconocida: nunca lanza, produce el esqueleto con defaults seguros.
93
+ echo '{"unknown":true}' > "$TMP/raw2.json"
94
+ norm2="$(agent_context_normalize "$TMP/raw2.json")"
95
+ echo "$norm2" | python3 -c '
96
+ import json,sys
97
+ d=json.load(sys.stdin)
98
+ sys.exit(0 if d["active_slice"] is None and d["project"]["scaffold_confirmed"] is False and d["nudges"]==[] else 1)' \
99
+ && echo "OK agent_context_normalize tolera respuesta desconocida" \
100
+ || { echo "FAIL normalize respuesta desconocida ($norm2)"; fail=1; }
101
+
102
+ # JSON corrupto: degrada a {} y rc 1 (no solo el stdout: el rc debe distinguir
103
+ # "parseo OK con datos vacíos" de "entrada corrupta" para quien consuma la función
104
+ # directamente, fuera de agent_context_fetch_and_cache).
105
+ printf 'no-json' > "$TMP/raw3.json"
106
+ norm3="$(agent_context_normalize "$TMP/raw3.json")"
107
+ rc3=$?
108
+ [ "$norm3" = "{}" ] && echo "OK agent_context_normalize JSON corrupto -> {}" || { echo "FAIL normalize corrupto ($norm3)"; fail=1; }
109
+ [ "$rc3" -eq 1 ] && echo "OK agent_context_normalize JSON corrupto -> rc 1" || { echo "FAIL normalize corrupto rc ($rc3)"; fail=1; }
110
+
111
+ # --- agent_context_fetch_and_cache contra el stub ---
112
+ PORT_AC="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
113
+ cat > "$TMP/routes-ac.json" <<JSON
114
+ {"GET /agent/context": {"status": 200, "body": {"active_slice": {"slice_id": "sl-9", "phase": "red", "gates": {"dor": true}}, "context": {"manifest_hash": "sha256:bbb", "version": 3}}}}
115
+ JSON
116
+ : > "$TMP/calls-ac.jsonl"
117
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_AC" "$TMP/routes-ac.json" "$TMP/calls-ac.jsonl" &
118
+ STUB_AC=$!
119
+ sleep 0.3
120
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_AC\",\"project_token\":\"tok-b\",\"project_id\":\"p-1\",\"agent_key\":\"ak-1\"}"
121
+
122
+ agent_context_fetch_and_cache
123
+ rc=$?
124
+ [ $rc -eq 0 ] && echo "OK fetch_and_cache rc 0" || { echo "FAIL fetch_and_cache rc ($rc)"; fail=1; }
125
+ got="$(projection_get active_slice.slice_id "")"
126
+ [ "$got" = "sl-9" ] && echo "OK fetch_and_cache escribe la cache" || { echo "FAIL cache tras fetch ($got)"; fail=1; }
127
+ if projection_is_stale; then echo "FAIL cache recien traida es stale"; fail=1; else echo "OK cache recien traida es fresca"; fi
128
+
129
+ kill "$STUB_AC" 2>/dev/null; wait "$STUB_AC" 2>/dev/null
130
+ # Servidor caído: rc 1 y la caché ANTERIOR intacta (nunca se pisa con vacío).
131
+ agent_context_fetch_and_cache
132
+ rc=$?
133
+ [ $rc -eq 1 ] && echo "OK fetch_and_cache offline rc 1" || { echo "FAIL fetch offline rc ($rc)"; fail=1; }
134
+ got="$(projection_get active_slice.slice_id "")"
135
+ [ "$got" = "sl-9" ] && echo "OK fetch offline preserva la cache previa" || { echo "FAIL cache pisada offline ($got)"; fail=1; }
136
+
137
+ # --- context-sync.sh ---
138
+ PORT_CS="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
139
+ python3 - "$TMP/routes-cs.json" <<'PY'
140
+ import json,sys
141
+ allow={"allow":["react","vite"]}
142
+ policy={"branch_prefixes":["feature","fix","chore"],"protected_branches":["main"]}
143
+ routes={
144
+ "GET /projects/p-1/context/manifest": {"status":200,"body":{
145
+ "manifest_hash":"sha256:v2",
146
+ "files":[
147
+ {"path":"config/stack-allowlist.json","sha256":"h1","type_key":"core.library_allowlist","version":7},
148
+ {"path":"config/policy.json","sha256":"h2","type_key":"core.policy","version":3},
149
+ {"path":"../../../etc/evil.json","sha256":"h3","type_key":"core.policy","version":1},
150
+ {"path":"CLAUDE.md","sha256":"h4","type_key":"core.claude_md","version":2}]}},
151
+ "GET /projects/p-1/context/files?path=config%2Fstack-allowlist.json&sha256=h1": {"status":200,"body":{
152
+ "path":"config/stack-allowlist.json","sha256":"h1","content":json.dumps(allow)}},
153
+ "GET /projects/p-1/context/files?path=config%2Fpolicy.json&sha256=h2": {"status":200,"body":{
154
+ "path":"config/policy.json","sha256":"h2","content":json.dumps(policy)}},
155
+ "POST /context/synced": {"status":200,"body":{"ok":True}},
156
+ }
157
+ json.dump(routes,open(sys.argv[1],"w"))
158
+ PY
159
+ : > "$TMP/calls-cs.jsonl"
160
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_CS" "$TMP/routes-cs.json" "$TMP/calls-cs.jsonl" &
161
+ STUB_CS=$!
162
+ sleep 0.3
163
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_CS\",\"project_token\":\"tok-c\",\"project_id\":\"p-1\",\"manifest_hash\":\"sha256:v2\"}"
164
+
165
+ out="$(bash "$ROOT/hooks/build/context-sync.sh" 2>&1)"
166
+ [ -f "$PROJ/.claude/config/stack-allowlist.json" ] && echo "OK context-sync escribe allowlist" || { echo "FAIL allowlist no escrita ($out)"; fail=1; }
167
+ [ -f "$PROJ/.claude/config/policy.json" ] && echo "OK context-sync escribe policy" || { echo "FAIL policy no escrita"; fail=1; }
168
+ got="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/config/stack-allowlist.json'))['allow'][0])")"
169
+ [ "$got" = react ] && echo "OK contenido sincronizado" || { echo "FAIL contenido ($got)"; fail=1; }
170
+ [ ! -e "$TMP/etc/evil.json" ] && [ ! -e "/etc/evil.json" ] && echo "OK context-sync rechaza traversal" || { echo "FAIL traversal aplicado"; fail=1; }
171
+ echo "$out" | grep -q 'CLAUDE.md' && echo "OK context-sync lista lo no aplicado" || { echo "FAIL no reporta destinos no gobernados ($out)"; fail=1; }
172
+ got="$(runtime_lock_manifest_hash)"
173
+ [ "$got" = "sha256:v2" ] && echo "OK context-sync actualiza el lock" || { echo "FAIL lock ($got)"; fail=1; }
174
+ sleep 0.2
175
+ synced="$(grep -c '"method": "POST".*"/context/synced"' "$TMP/calls-cs.jsonl" 2>/dev/null | tr -d ' ')"
176
+ if [ -z "$synced" ]; then synced=0; fi
177
+ [ "$synced" -ge 1 ] && echo "OK context-sync emite context/synced" || { echo "FAIL sin context/synced"; fail=1; }
178
+
179
+ # Segunda corrida: hash igual -> no-op, ni una sola llamada más al stub.
180
+ before="$(wc -l < "$TMP/calls-cs.jsonl" | tr -d ' ')"
181
+ bash "$ROOT/hooks/build/context-sync.sh" >/dev/null 2>&1
182
+ after="$(wc -l < "$TMP/calls-cs.jsonl" | tr -d ' ')"
183
+ [ "$before" = "$after" ] && echo "OK context-sync no-op con hash igual" || { echo "FAIL context-sync re-sincroniza ($before -> $after)"; fail=1; }
184
+
185
+ # legacy: no-op absoluto aunque el hash difiera.
186
+ runtime_credentials_merge '{"manifest_hash":"sha256:v3"}'
187
+ before="$(wc -l < "$TMP/calls-cs.jsonl" | tr -d ' ')"
188
+ TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/context-sync.sh" >/dev/null 2>&1
189
+ after="$(wc -l < "$TMP/calls-cs.jsonl" | tr -d ' ')"
190
+ [ "$before" = "$after" ] && echo "OK context-sync no-op en legacy" || { echo "FAIL context-sync corrio en legacy"; fail=1; }
191
+
192
+ # Regresión: manifiesto servido con status 200 pero body no-objeto (JSON válido, p.ej. un
193
+ # array) — el lock existente NO debe corromperse ni emitirse context/synced con hash vacío.
194
+ python3 - "$TMP/routes-cs.json" <<'PY'
195
+ import json,sys
196
+ routes=json.load(open(sys.argv[1]))
197
+ routes["GET /projects/p-1/context/manifest"]={"status":200,"body":["not","an","object"]}
198
+ json.dump(routes,open(sys.argv[1],"w"))
199
+ PY
200
+ before_lock="$(runtime_lock_manifest_hash)"
201
+ bash "$ROOT/hooks/build/context-sync.sh" >/dev/null 2>&1
202
+ rc=$?
203
+ after_lock="$(runtime_lock_manifest_hash)"
204
+ if [ $rc -eq 0 ] && [ "$after_lock" = "$before_lock" ]; then
205
+ echo "OK context-sync ignora manifiesto no-objeto (lock intacto)"
206
+ else
207
+ echo "FAIL manifiesto no-objeto corrompio el lock (rc=$rc, '$before_lock' -> '$after_lock')"; fail=1
208
+ fi
209
+
210
+ kill "$STUB_CS" 2>/dev/null; wait "$STUB_CS" 2>/dev/null
211
+ # Servidor caído: sale limpio y NO degrada el lock existente.
212
+ bash "$ROOT/hooks/build/context-sync.sh" >/dev/null 2>&1
213
+ rc=$?
214
+ [ $rc -eq 0 ] && echo "OK context-sync offline exit 0" || { echo "FAIL context-sync offline rc ($rc)"; fail=1; }
215
+ got="$(runtime_lock_manifest_hash)"
216
+ [ "$got" = "sha256:v2" ] && echo "OK context-sync offline preserva el lock" || { echo "FAIL lock pisado offline ($got)"; fail=1; }
217
+ runtime_credentials_merge '{"manifest_hash":"sha256:v2"}'
218
+
219
+ # --- heartbeat.sh (daemon §4.3) ---
220
+ PORT_HB="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
221
+ cat > "$TMP/routes-hb.json" <<JSON
222
+ {"PUT /leases/renew": {"status": 200, "body": {"expires_at": "2099-01-01T00:00:00Z"}},
223
+ "POST /events": {"status": 200, "body": {"results": []}}}
224
+ JSON
225
+ : > "$TMP/calls-hb.jsonl"
226
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_HB" "$TMP/routes-hb.json" "$TMP/calls-hb.jsonl" &
227
+ STUB_HB=$!
228
+ sleep 0.3
229
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_HB\",\"project_token\":\"tok-h\",\"agent_key\":\"ak-1\",\"project_id\":\"p-1\",\"lease_ttl_s\":300}"
230
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"active_slice\":{\"slice_id\":\"sl-9\",\"phase\":\"green\",\"gates\":{},\"wiring_failing\":[]},\"nudges\":[]}"
231
+
232
+ export TRYCORE_HEARTBEAT_TICK_S=1
233
+ export TRYCORE_HEARTBEAT_INTERVAL_S=1
234
+
235
+ # legacy: --ensure es no-op absoluto (ni pidfile, ni proceso).
236
+ TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/heartbeat.sh" --ensure $$ >/dev/null 2>&1
237
+ [ ! -f "$PROJ/.claude/state/heartbeat.pid" ] && echo "OK heartbeat no-op en legacy" || { echo "FAIL heartbeat arranco en legacy"; fail=1; }
238
+
239
+ # Sesiones falsas: dos procesos vivos que hacen de "sesiones de Claude Code".
240
+ sleep 60 & SESS1=$!
241
+ sleep 60 & SESS2=$!
242
+
243
+ bash "$ROOT/hooks/build/heartbeat.sh" --ensure "$SESS1" >/dev/null 2>&1
244
+ sleep 1
245
+ HBPID="$(cat "$PROJ/.claude/state/heartbeat.pid" 2>/dev/null)"
246
+ [ -n "$HBPID" ] && kill -0 "$HBPID" 2>/dev/null && echo "OK heartbeat lanza el daemon" || { echo "FAIL daemon no arranco"; fail=1; }
247
+
248
+ # Segundo --ensure: comparte daemon (mismo pid), no lanza otro.
249
+ bash "$ROOT/hooks/build/heartbeat.sh" --ensure "$SESS2" >/dev/null 2>&1
250
+ sleep 1
251
+ HBPID2="$(cat "$PROJ/.claude/state/heartbeat.pid" 2>/dev/null)"
252
+ [ "$HBPID" = "$HBPID2" ] && echo "OK dos sesiones comparten daemon" || { echo "FAIL se lanzo un segundo daemon ($HBPID -> $HBPID2)"; fail=1; }
253
+
254
+ # Late: con TTL 300 el intervalo real sería 100 s; el override lo baja a 1 s.
255
+ sleep 3
256
+ renews="$(grep -c '"method": "PUT"' "$TMP/calls-hb.jsonl" 2>/dev/null | tr -d ' ')"
257
+ [ "${renews:-0}" -ge 1 ] && echo "OK daemon renueva el lease" || { echo "FAIL sin renew ($renews)"; fail=1; }
258
+
259
+ # Flush: el sentinela dispara un despacho de la cola sin que el hook toque la red.
260
+ runtime_enqueue_event "checkpoint_created" '{"n":1}' "sl-9"
261
+ mkdir -p "$PROJ/.claude/state/outbox"
262
+ : > "$PROJ/.claude/state/outbox/.flush-request"
263
+ sleep 3
264
+ [ ! -f "$PROJ/.claude/state/outbox/.flush-request" ] && echo "OK daemon consume el sentinela de flush" || { echo "FAIL sentinela sin consumir"; fail=1; }
265
+ posts="$(grep -c '"method": "POST"' "$TMP/calls-hb.jsonl" 2>/dev/null | tr -d ' ')"
266
+ [ "${posts:-0}" -ge 1 ] && echo "OK daemon despacha la cola" || { echo "FAIL cola sin despachar ($posts)"; fail=1; }
267
+
268
+ # Cerrar la PRIMERA sesión NO mata el daemon (regresión del diseño anterior).
269
+ kill "$SESS1" 2>/dev/null; wait "$SESS1" 2>/dev/null
270
+ sleep 3
271
+ kill -0 "$HBPID" 2>/dev/null && echo "OK daemon sobrevive al cierre de la primera sesion" || { echo "FAIL daemon murio con la primera sesion"; fail=1; }
272
+
273
+ # kill -9 del daemon: el siguiente --ensure lo relanza con OTRO pid.
274
+ kill -9 "$HBPID" 2>/dev/null; sleep 1
275
+ bash "$ROOT/hooks/build/heartbeat.sh" --ensure "$SESS2" >/dev/null 2>&1
276
+ sleep 1
277
+ HBPID3="$(cat "$PROJ/.claude/state/heartbeat.pid" 2>/dev/null)"
278
+ [ -n "$HBPID3" ] && [ "$HBPID3" != "$HBPID" ] && kill -0 "$HBPID3" 2>/dev/null \
279
+ && echo "OK heartbeat relanza el daemon muerto" || { echo "FAIL daemon no relanzado ($HBPID3)"; fail=1; }
280
+
281
+ # Muere la ÚLTIMA sesión -> el daemon se apaga solo y limpia el pidfile.
282
+ kill "$SESS2" 2>/dev/null; wait "$SESS2" 2>/dev/null
283
+ sleep 4
284
+ kill -0 "$HBPID3" 2>/dev/null && { echo "FAIL daemon sobrevive sin sesiones"; fail=1; } || echo "OK daemon muere con la ultima sesion"
285
+ [ ! -f "$PROJ/.claude/state/heartbeat.pid" ] && echo "OK daemon limpia el pidfile" || { echo "FAIL pidfile huerfano"; fail=1; }
286
+
287
+ # 409 en el renew: se marca lease perdido y NO se apaga el daemon (spec §4.3).
288
+ cat > "$TMP/routes-hb.json" <<JSON
289
+ {"PUT /leases/renew": {"status": 409, "body": {"error": "lease_conflict"}},
290
+ "POST /events": {"status": 200, "body": {"results": []}}}
291
+ JSON
292
+ sleep 60 & SESS3=$!
293
+ bash "$ROOT/hooks/build/heartbeat.sh" --ensure "$SESS3" >/dev/null 2>&1
294
+ sleep 3
295
+ lost="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/heartbeat-status.json')).get('lease_lost'))" 2>/dev/null)"
296
+ [ "$lost" = True ] && echo "OK renew 409 marca lease perdido" || { echo "FAIL 409 no marcado ($lost)"; fail=1; }
297
+ HBPID4="$(cat "$PROJ/.claude/state/heartbeat.pid" 2>/dev/null)"
298
+ kill -0 "$HBPID4" 2>/dev/null && echo "OK daemon sigue vivo tras 409" || { echo "FAIL daemon murio por 409"; fail=1; }
299
+
300
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
301
+ sleep 1
302
+ kill -0 "$HBPID4" 2>/dev/null && { echo "FAIL --stop no apago el daemon"; fail=1; } || echo "OK --stop apaga el daemon"
303
+ kill "$SESS3" 2>/dev/null; wait "$SESS3" 2>/dev/null
304
+ kill "$STUB_HB" 2>/dev/null; wait "$STUB_HB" 2>/dev/null
305
+ unset TRYCORE_HEARTBEAT_TICK_S TRYCORE_HEARTBEAT_INTERVAL_S
306
+
307
+ # --- session-start.sh ---
308
+ PORT_SS="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
309
+ cat > "$TMP/routes-ss.json" <<JSON
310
+ {"POST /agents/register": {"status": 200, "body": {"agent_key": "ak-2", "project_id": "p-1", "poll_interval_s": 30, "lease_ttl_s": 300, "manifest_hash": "sha256:v2"}},
311
+ "GET /agent/context": {"status": 200, "body": {"project": {"scaffold": {"confirmed": true}}, "active_slice": {"slice_id": "sl-11", "epic_code": "EP-009", "phase": "green", "gates": {"dor": true, "tdd": false}}, "nudges": [{"kind": "reflect", "message": "2 slices sin reflexionar"}], "context": {"manifest_hash": "sha256:v2", "version": 9}}}}
312
+ JSON
313
+ : > "$TMP/calls-ss.jsonl"
314
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT_SS" "$TMP/routes-ss.json" "$TMP/calls-ss.jsonl" &
315
+ STUB_SS=$!
316
+ sleep 0.3
317
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT_SS\",\"project_token\":\"tok-s\",\"manifest_hash\":\"sha256:v2\"}"
318
+ export TRYCORE_HEARTBEAT_TICK_S=1 TRYCORE_HEARTBEAT_INTERVAL_S=1
319
+
320
+ # legacy: cero llamadas.
321
+ before="$(wc -l < "$TMP/calls-ss.jsonl" | tr -d ' ')"
322
+ echo '{"session_id":"s-legacy"}' | TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/session-start.sh" >/dev/null 2>&1
323
+ after="$(wc -l < "$TMP/calls-ss.jsonl" | tr -d ' ')"
324
+ [ "$before" = "$after" ] && echo "OK session-start no-op en legacy" || { echo "FAIL session-start hablo en legacy"; fail=1; }
325
+
326
+ echo '{"session_id":"s-1"}' | bash "$ROOT/hooks/build/session-start.sh" >/dev/null 2>&1
327
+ got="$(runtime_field agent_key)"
328
+ [ "$got" = "ak-2" ] && echo "OK session-start registra el agente" || { echo "FAIL registro ($got)"; fail=1; }
329
+ got="$(projection_get active_slice.slice_id "")"
330
+ [ "$got" = "sl-11" ] && echo "OK session-start hidrata la cache" || { echo "FAIL cache ($got)"; fail=1; }
331
+ at_sent="$(python3 -c "
332
+ import json
333
+ body=None
334
+ for line in open('$TMP/calls-ss.jsonl'):
335
+ c=json.loads(line)
336
+ if c['path']=='/agents/register': body=json.loads(c['body'])
337
+ print(len(body.get('asset_types') or []) if body else -1)
338
+ ")"
339
+ [ "$at_sent" -ge 1 ] && echo "OK session-start declara asset_types del paquete" || { echo "FAIL asset_types ($at_sent)"; fail=1; }
340
+ hv_sent="$(python3 -c "
341
+ import json
342
+ body=None
343
+ for line in open('$TMP/calls-ss.jsonl'):
344
+ c=json.loads(line)
345
+ if c['path']=='/agents/register': body=json.loads(c['body'])
346
+ print((body or {}).get('harness_version',''))
347
+ ")"
348
+ [ -n "$hv_sent" ] && [ "$hv_sent" != "0.0.0" ] && echo "OK session-start envia la version real del arnes" || { echo "FAIL harness_version ($hv_sent)"; fail=1; }
349
+ sleep 1
350
+ [ -f "$PROJ/.claude/state/heartbeat.pid" ] && echo "OK session-start asegura el daemon" || { echo "FAIL daemon no asegurado"; fail=1; }
351
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
352
+
353
+ # 409 incompatible_version: avisa y sale 0 (jamás rompe el arranque de sesión).
354
+ cat > "$TMP/routes-ss.json" <<JSON
355
+ {"POST /agents/register": {"status": 409, "body": {"error": "incompatible_version", "min_version": "0.9.0"}}}
356
+ JSON
357
+ out="$(echo '{"session_id":"s-2"}' | bash "$ROOT/hooks/build/session-start.sh" 2>&1)"
358
+ rc=$?
359
+ [ $rc -eq 0 ] && echo "OK session-start exit 0 con 409" || { echo "FAIL session-start rc con 409 ($rc)"; fail=1; }
360
+ echo "$out" | grep -q 'incompatible\|Actualiza' && echo "OK session-start explica el 409" || { echo "FAIL 409 sin mensaje ($out)"; fail=1; }
361
+
362
+ kill "$STUB_SS" 2>/dev/null; wait "$STUB_SS" 2>/dev/null
363
+ # Runtime caído: sale 0, avisa y NO borra la caché previa.
364
+ out="$(echo '{"session_id":"s-3"}' | bash "$ROOT/hooks/build/session-start.sh" 2>&1)"
365
+ rc=$?
366
+ [ $rc -eq 0 ] && echo "OK session-start exit 0 offline" || { echo "FAIL session-start rc offline ($rc)"; fail=1; }
367
+ got="$(projection_get active_slice.slice_id "")"
368
+ [ "$got" = "sl-11" ] && echo "OK session-start offline preserva la cache" || { echo "FAIL cache perdida offline ($got)"; fail=1; }
369
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
370
+ unset TRYCORE_HEARTBEAT_TICK_S TRYCORE_HEARTBEAT_INTERVAL_S
371
+
372
+ # --- event-emitter.sh ---
373
+ rm -f "$PROJ"/.claude/state/outbox/*.json 2>/dev/null
374
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{},\"wiring_failing\":[]},\"nudges\":[]}"
375
+ export TRYCORE_HEARTBEAT_TICK_S=1 TRYCORE_HEARTBEAT_INTERVAL_S=1
376
+
377
+ # legacy: ni encola ni arranca daemon.
378
+ echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$PROJ"'/src/a.ts"}}' \
379
+ | TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
380
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
381
+ [ "$n" = 0 ] && echo "OK event-emitter no-op en legacy" || { echo "FAIL event-emitter encolo en legacy ($n)"; fail=1; }
382
+
383
+ echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$PROJ"'/src/a.ts"}}' \
384
+ | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
385
+ ev="$(python3 -c "
386
+ import json,glob
387
+ files=sorted(p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json'))
388
+ d=json.load(open(files[0]))
389
+ print(d['type'], d.get('slice_id'), d['payload'].get('path'), d['payload'].get('tool'))
390
+ " 2>/dev/null)"
391
+ [ "$ev" = "tool_use_recorded sl-11 src/a.ts Edit" ] && echo "OK event-emitter encola ruta relativa con slice_id" || { echo "FAIL evento ($ev)"; fail=1; }
392
+
393
+ # Bash: solo el binario, jamás el comando completo (puede llevar secretos — protocolo §8).
394
+ rm -f "$PROJ"/.claude/state/outbox/*.json
395
+ echo '{"tool_name":"Bash","tool_input":{"command":"curl -H \"Authorization: Bearer SECRETO\" https://x"}}' \
396
+ | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
397
+ body="$(cat "$PROJ"/.claude/state/outbox/*.json 2>/dev/null)"
398
+ echo "$body" | grep -q 'SECRETO' && { echo "FAIL event-emitter filtra el comando completo"; fail=1; } || echo "OK event-emitter no filtra el comando"
399
+ echo "$body" | grep -q '"argv0": *"curl"' && echo "OK event-emitter reporta solo argv0" || { echo "FAIL argv0 ($body)"; fail=1; }
400
+
401
+ # Bash: secreto en asignación de entorno inline al frente del comando (`VAR=valor cmd ...`)
402
+ # tampoco debe filtrarse — argv0 debe ser el binario, no la asignación (protocolo §8).
403
+ rm -f "$PROJ"/.claude/state/outbox/*.json
404
+ echo '{"tool_name":"Bash","tool_input":{"command":"API_TOKEN=sk-live-XYZ curl https://api.example.com"}}' \
405
+ | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
406
+ body="$(cat "$PROJ"/.claude/state/outbox/*.json 2>/dev/null)"
407
+ echo "$body" | grep -q 'sk-live-XYZ' && { echo "FAIL event-emitter filtra secreto en asignacion de entorno"; fail=1; } || echo "OK event-emitter no filtra secreto en asignacion de entorno"
408
+ echo "$body" | grep -q 'API_TOKEN' && { echo "FAIL event-emitter filtra nombre de variable de entorno"; fail=1; } || echo "OK event-emitter no filtra nombre de variable de entorno"
409
+ echo "$body" | grep -q '"argv0": *"curl"' && echo "OK event-emitter salta asignacion de entorno y reporta binario" || { echo "FAIL argv0 con asignacion de entorno ($body)"; fail=1; }
410
+
411
+ # Bash: el valor citado de la asignación de entorno inline lleva un espacio interno
412
+ # (`KEY="a b" cmd`) — una tokenización naïve por espacios trocearía el valor citado y el
413
+ # segundo fragmento (parte del secreto) se colaría como argv0. shlex debe tratarlo como
414
+ # un solo token y seguir saltándolo.
415
+ rm -f "$PROJ"/.claude/state/outbox/*.json
416
+ echo '{"tool_name":"Bash","tool_input":{"command":"AWS_SECRET_ACCESS_KEY=\"abc def-secretpart\" aws s3 ls"}}' \
417
+ | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
418
+ body="$(cat "$PROJ"/.claude/state/outbox/*.json 2>/dev/null)"
419
+ echo "$body" | grep -q 'def-secretpart' && { echo "FAIL event-emitter filtra fragmento de secreto citado con espacio"; fail=1; } || echo "OK event-emitter no filtra fragmento de secreto citado con espacio"
420
+ echo "$body" | grep -q '"argv0": *"aws"' && echo "OK event-emitter reporta binario con asignacion de entorno citada" || { echo "FAIL argv0 con asignacion citada ($body)"; fail=1; }
421
+
422
+ # Sin slice reclamado: no se encola nada de ámbito slice (el servidor lo rechazaría).
423
+ rm -f "$PROJ"/.claude/state/outbox/*.json
424
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"active_slice\":null,\"nudges\":[]}"
425
+ echo '{"tool_name":"Write","tool_input":{"file_path":"'"$PROJ"'/src/b.ts"}}' \
426
+ | bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
427
+ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
428
+ [ "$n" = 0 ] && echo "OK event-emitter no encola sin slice" || { echo "FAIL encolo sin slice ($n)"; fail=1; }
429
+
430
+ # Nunca abre socket: un `curl` falso al frente del PATH no debe llegar a ejecutarse.
431
+ mkdir -p "$TMP/fakebin"
432
+ cat > "$TMP/fakebin/curl" <<'FAKE'
433
+ #!/usr/bin/env bash
434
+ touch "$FAKE_CURL_MARK"
435
+ exit 0
436
+ FAKE
437
+ chmod +x "$TMP/fakebin/curl"
438
+ rm -f "$TMP/curl-was-called"
439
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{},\"wiring_failing\":[]},\"nudges\":[]}"
440
+ echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$PROJ"'/src/c.ts"}}' \
441
+ | FAKE_CURL_MARK="$TMP/curl-was-called" PATH="$TMP/fakebin:$PATH" bash "$ROOT/hooks/build/event-emitter.sh" >/dev/null 2>&1
442
+ [ ! -f "$TMP/curl-was-called" ] && echo "OK event-emitter no toca la red" || { echo "FAIL event-emitter llamo a curl"; fail=1; }
443
+
444
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
445
+ unset TRYCORE_HEARTBEAT_TICK_S TRYCORE_HEARTBEAT_INTERVAL_S
446
+
447
+ # --- load-build-state.sh en modo runtime ---
448
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
449
+ \"project\":{\"scaffold_confirmed\":true,\"harness_phase\":\"active\",\"design_source_applies\":false,\"design_source_confirmed\":false,\"project_kind\":\"greenfield\",\"foundation_done\":true},
450
+ \"active_slice\":{\"slice_id\":\"sl-11\",\"epic_code\":\"EP-009\",\"epic_title\":\"Cobros\",\"hus\":[\"HU-021\"],
451
+ \"phase\":\"green\",\"gates\":{\"dor\":true,\"tdd\":false,\"dod\":false},\"branch\":\"feature/cobros\",
452
+ \"openspec_change\":\"add-cobros\",\"wiring_failing\":[{\"item_id\":\"w1\",\"kind\":\"e2e\"}],
453
+ \"sub_slices_pending\":[\"ss-2\"],\"resume_hint\":\"cablear w1\",\"checkpoint\":null},
454
+ \"nudges\":[],\"context\":{\"manifest_hash\":\"sha256:v2\",\"version\":9},\"lease\":{\"expires_at\":null,\"ttl_s\":300}}"
455
+
456
+ out="$(echo '{"session_id":"s-render"}' | bash "$ROOT/hooks/build/load-build-state.sh" 2>&1)"
457
+ echo "$out" | grep -q 'EP-009' && echo "OK render runtime muestra la epica" || { echo "FAIL render epica ($out)"; fail=1; }
458
+ echo "$out" | grep -q 'tdd' && echo "OK render runtime lista gates abiertos" || { echo "FAIL render gates ($out)"; fail=1; }
459
+ echo "$out" | grep -q 'w1' && echo "OK render runtime muestra wiring failing" || { echo "FAIL render wiring ($out)"; fail=1; }
460
+ echo "$out" | grep -q 'cablear w1' && echo "OK render runtime muestra resume_hint" || { echo "FAIL render resume_hint ($out)"; fail=1; }
461
+ echo "$out" | grep -q 'ss-2' && echo "OK render runtime muestra sub-slices pendientes" || { echo "FAIL render sub-slices ($out)"; fail=1; }
462
+
463
+ # En runtime NO se toca build-state.json aunque exista (el fichero es fósil).
464
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
465
+ {"version":"1.0","harness_phase":"authoring","scaffold":{"confirmed":false},"active_slice":null,"history":[],"releases":[]}
466
+ JSON
467
+ before="$(md5 -q "$PROJ/.claude/state/build-state.json" 2>/dev/null || md5sum "$PROJ/.claude/state/build-state.json" | cut -d' ' -f1)"
468
+ echo '{"session_id":"s-render2"}' | bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null 2>&1
469
+ after="$(md5 -q "$PROJ/.claude/state/build-state.json" 2>/dev/null || md5sum "$PROJ/.claude/state/build-state.json" | cut -d' ' -f1)"
470
+ [ "$before" = "$after" ] && echo "OK runtime no escribe build-state.json" || { echo "FAIL runtime escribio el fichero legacy"; fail=1; }
471
+
472
+ # Caché vieja: avisa de stale pero sigue rindiendo (guards offline, spec §8).
473
+ runtime_projection_write '{"fetched_at":"2000-01-01T00:00:00Z","project":{},"active_slice":null,"nudges":[]}'
474
+ out="$(echo '{"session_id":"s-render3"}' | bash "$ROOT/hooks/build/load-build-state.sh" 2>&1)"
475
+ echo "$out" | grep -q 'stale\|sin refrescar' && echo "OK render runtime avisa de cache vieja" || { echo "FAIL sin aviso de stale ($out)"; fail=1; }
476
+ echo "$out" | grep -q 'ninguno\|/build:claim' && echo "OK render runtime sin slice sugiere reclamar" || { echo "FAIL sin sugerencia de claim ($out)"; fail=1; }
477
+
478
+ # Regresión legacy: el camino v0.8.5 sigue intacto (lo cubre además test-context-monitor.sh).
479
+ out="$(echo '{"session_id":"s-legacy"}' | TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/load-build-state.sh" 2>&1)"
480
+ echo "$out" | grep -q 'Arnés de construcción' && echo "OK load-build-state legacy sigue rindiendo" || { echo "FAIL legacy roto ($out)"; fail=1; }
481
+ rm -f "$PROJ/.claude/state/build-state.json"
482
+
483
+ # --- guards desde la caché de proyección (modo runtime) ---
484
+ HOOK_IN='{"tool_name":"Write","tool_input":{"file_path":"'"$PROJ"'/src/x.ts","content":"x"}}'
485
+
486
+ # Sin caché: auto-arme, no bloquea (nada que vigilar todavía).
487
+ rm -f "$PROJ/.claude/state/runtime-projection.json"
488
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" >/dev/null 2>&1
489
+ [ $? -eq 0 ] && echo "OK scaffold-guard sin cache no bloquea" || { echo "FAIL scaffold-guard bloqueo sin cache"; fail=1; }
490
+
491
+ # Fase de código + scaffold NO confirmado -> bloquea.
492
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":false,\"design_source_applies\":true,\"design_source_confirmed\":false},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{\"fidelity\":false},\"wiring_failing\":[]},\"nudges\":[]}"
493
+ out="$(echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" 2>&1)"
494
+ rc=$?
495
+ [ $rc -eq 2 ] && echo "OK scaffold-guard bloquea desde la cache" || { echo "FAIL scaffold-guard rc ($rc)"; fail=1; }
496
+
497
+ # Fase de planificación -> permitido.
498
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":false},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"dor\",\"gates\":{},\"wiring_failing\":[]},\"nudges\":[]}"
499
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" >/dev/null 2>&1
500
+ [ $? -eq 0 ] && echo "OK scaffold-guard permite en fase dor" || { echo "FAIL scaffold-guard bloqueo en dor"; fail=1; }
501
+
502
+ # Scaffold confirmado -> permitido.
503
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":true},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{},\"wiring_failing\":[]},\"nudges\":[]}"
504
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" >/dev/null 2>&1
505
+ [ $? -eq 0 ] && echo "OK scaffold-guard permite con scaffold confirmado" || { echo "FAIL scaffold-guard bloqueo con scaffold ok"; fail=1; }
506
+
507
+ # Caché vieja: sigue bloqueando y lo dice (guards offline, spec §8).
508
+ runtime_projection_write '{"fetched_at":"2000-01-01T00:00:00Z","project":{"scaffold_confirmed":false},"active_slice":{"slice_id":"sl-11","phase":"green","gates":{},"wiring_failing":[]},"nudges":[]}'
509
+ out="$(echo "$HOOK_IN" | bash "$ROOT/hooks/build/scaffold-guard.sh" 2>&1)"
510
+ rc=$?
511
+ [ $rc -eq 2 ] && echo "$out" | grep -q 'stale\|sin refrescar' && echo "OK scaffold-guard bloquea offline y marca stale" || { echo "FAIL guard offline ($rc / $out)"; fail=1; }
512
+
513
+ # design-source-guard: UI pendiente + fuente sin confirmar -> bloquea; confirmada -> permite.
514
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":true,\"design_source_applies\":true,\"design_source_confirmed\":false},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{\"fidelity\":false},\"wiring_failing\":[]},\"nudges\":[]}"
515
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/design-source-guard.sh" >/dev/null 2>&1
516
+ [ $? -eq 2 ] && echo "OK design-source-guard bloquea desde la cache" || { echo "FAIL design-source-guard no bloqueo"; fail=1; }
517
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":true,\"design_source_applies\":true,\"design_source_confirmed\":true},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{\"fidelity\":false},\"wiring_failing\":[]},\"nudges\":[]}"
518
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/design-source-guard.sh" >/dev/null 2>&1
519
+ [ $? -eq 0 ] && echo "OK design-source-guard permite con fuente confirmada" || { echo "FAIL design-source-guard bloqueo con fuente ok"; fail=1; }
520
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":true,\"design_source_applies\":false,\"design_source_confirmed\":false},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{\"fidelity\":false},\"wiring_failing\":[]},\"nudges\":[]}"
521
+ echo "$HOOK_IN" | bash "$ROOT/hooks/build/design-source-guard.sh" >/dev/null 2>&1
522
+ [ $? -eq 0 ] && echo "OK design-source-guard apagado sin UI" || { echo "FAIL design-source-guard bloqueo sin UI"; fail=1; }
523
+
524
+ # ── PreToolUse: prohibida la red (spec §4.2-B) ──
525
+ # Reutiliza el `curl` falso creado en el bloque de event-emitter (más arriba en este mismo
526
+ # fichero de test): si ese bloque se moviera, hay que recrear $TMP/fakebin/curl aquí.
527
+ # Entorno REALMENTE aislado: subshell bash -c con un `curl` falso al frente del PATH y
528
+ # credenciales apuntando a un puerto vivo. Si cualquiera de los cuatro guards abriera
529
+ # socket, el falso curl dejaría su marca. No se comprueba por grep del código (eso no
530
+ # demuestra comportamiento) sino ejecutando los guards de verdad.
531
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9","project_token":"tok-x"}'
532
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{\"scaffold_confirmed\":false},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{\"fidelity\":false},\"wiring_failing\":[]},\"nudges\":[]}"
533
+ for g in gitflow-guard stack-guard scaffold-guard design-source-guard; do
534
+ rm -f "$TMP/curl-was-called"
535
+ bash -c '
536
+ echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git commit -m x\",\"file_path\":\"'"$PROJ"'/package.json\",\"content\":\"{}\"}}" \
537
+ | FAKE_CURL_MARK="'"$TMP"'/curl-was-called" PATH="'"$TMP"'/fakebin:$PATH" \
538
+ CLAUDE_PROJECT_DIR="'"$PROJ"'" BUILD_CONFIG_FILE="'"$BUILD_CONFIG_FILE"'" \
539
+ bash "'"$ROOT"'/hooks/build/'"$g"'.sh"' >/dev/null 2>&1
540
+ [ ! -f "$TMP/curl-was-called" ] && echo "OK $g no toca la red" || { echo "FAIL $g abrio socket"; fail=1; }
541
+ done
542
+
543
+ # --- gitflow-guard: repo del comando, no el de la sesión (spec §9) ---
544
+ OTHER="$TMP/otro-repo"
545
+ mkdir -p "$OTHER"
546
+ git -C "$OTHER" init -q 2>/dev/null
547
+ git -C "$OTHER" checkout -q -b main 2>/dev/null
548
+ git -C "$OTHER" commit -q --allow-empty -m init 2>/dev/null
549
+
550
+ SESSION_REPO="$TMP/sesion-repo"
551
+ mkdir -p "$SESSION_REPO"
552
+ git -C "$SESSION_REPO" init -q 2>/dev/null
553
+ git -C "$SESSION_REPO" checkout -q -b feature/ok 2>/dev/null
554
+ git -C "$SESSION_REPO" commit -q --allow-empty -m init 2>/dev/null
555
+
556
+ # La sesión está en feature/ok, pero el comando commitea en OTRO repo que está en main:
557
+ # el guard debe bloquear por la rama del repo del COMANDO.
558
+ out="$(cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git -C '"$OTHER"' commit -m x"}}' \
559
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" 2>&1)"
560
+ rc=$?
561
+ [ $rc -eq 2 ] && echo "OK gitflow-guard resuelve el repo del comando (-C)" || { echo "FAIL gitflow-guard no vio el repo del comando ($rc / $out)"; fail=1; }
562
+
563
+ # Mismo caso con `cd <dir> && git commit`.
564
+ out="$(cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"cd '"$OTHER"' && git commit -m x"}}' \
565
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" 2>&1)"
566
+ rc=$?
567
+ [ $rc -eq 2 ] && echo "OK gitflow-guard resuelve el repo tras un cd" || { echo "FAIL gitflow-guard ignoro el cd ($rc)"; fail=1; }
568
+
569
+ # Sin pistas: cae al repo de la sesión (feature/ok) -> permitido.
570
+ (cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' \
571
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" >/dev/null 2>&1)
572
+ [ $? -eq 0 ] && echo "OK gitflow-guard permite en rama tipada del repo de sesion" || { echo "FAIL gitflow-guard bloqueo rama valida"; fail=1; }
573
+
574
+ # Política desde el asset sincronizado: con `release` como prefijo permitido y `develop`
575
+ # protegida, el guard cambia de veredicto sin tocar el código.
576
+ mkdir -p "$SESSION_REPO/.claude/config"
577
+ cat > "$SESSION_REPO/.claude/config/policy.json" <<'JSON'
578
+ {"branch_prefixes":["release"],"protected_branches":["main","develop"]}
579
+ JSON
580
+ (cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' \
581
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" >/dev/null 2>&1)
582
+ [ $? -eq 2 ] && echo "OK gitflow-guard aplica los prefijos de la politica sincronizada" || { echo "FAIL politica sincronizada ignorada"; fail=1; }
583
+ git -C "$SESSION_REPO" checkout -q -b release/1.0 2>/dev/null
584
+ (cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' \
585
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" >/dev/null 2>&1)
586
+ [ $? -eq 0 ] && echo "OK gitflow-guard permite el prefijo declarado en la politica" || { echo "FAIL prefijo de politica no permitido"; fail=1; }
587
+
588
+ # --- Revision Task 10 (hallazgo 1): varios `cd` encadenados antes del git — gana el
589
+ # ULTIMO (el directorio real en el que corre el commit), no el primero. En este punto la
590
+ # politica de SESSION_REPO exige prefijo "release" y protege "main"/"develop".
591
+ TYPED_REPO="$TMP/typed-repo"
592
+ mkdir -p "$TYPED_REPO"
593
+ git -C "$TYPED_REPO" init -q 2>/dev/null
594
+ git -C "$TYPED_REPO" checkout -q -b release/typed 2>/dev/null
595
+ git -C "$TYPED_REPO" commit -q --allow-empty -m init 2>/dev/null
596
+ # TYPED_REPO (primer cd) esta en una rama permitida por la politica; OTHER (ultimo cd)
597
+ # esta en "main", protegida. Si el guard tomara el PRIMER cd (el bug), esto pasaria
598
+ # (rc=0); si toma el ULTIMO (el fix), debe bloquear (rc=2).
599
+ out="$(cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"cd '"$TYPED_REPO"' && cd '"$OTHER"' && git commit -m x"}}' \
600
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" 2>&1)"
601
+ rc=$?
602
+ [ $rc -eq 2 ] && echo "OK gitflow-guard usa el ultimo cd de una cadena, no el primero" || { echo "FAIL gitflow-guard tomo el primer cd de la cadena ($rc / $out)"; fail=1; }
603
+
604
+ # --- Revision Task 10 (hallazgo 2): `git commit -C <ref>` (flag del subcomando, reusa
605
+ # mensaje/autoria) no debe confundirse con el flag GLOBAL `git -C <dir> commit`. El guard
606
+ # debe seguir evaluando el repo real (SESSION_REPO) y su rama real.
607
+ git -C "$SESSION_REPO" checkout -q -b main 2>/dev/null
608
+ NOGIT_DIR="$TMP/nogit-dir"
609
+ mkdir -p "$NOGIT_DIR"
610
+ out="$(cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git commit -C '"$NOGIT_DIR"' -m x"}}' \
611
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" 2>&1)"
612
+ rc=$?
613
+ [ $rc -eq 2 ] && echo "OK gitflow-guard no confunde 'commit -C <ref>' con el -C global" || { echo "FAIL gitflow-guard confundio commit -C con el flag global ($rc / $out)"; fail=1; }
614
+
615
+ # --- Revision Task 10 (hallazgo 3): un metacaracter (p.ej. un punto) en un prefijo o rama
616
+ # protegida de policy.json se trata como texto LITERAL, nunca como wildcard de regex
617
+ # (regresion sobre el re.escape del commit 0b7c053).
618
+ cat > "$SESSION_REPO/.claude/config/policy.json" <<'JSON'
619
+ {"branch_prefixes":["rel.ease"],"protected_branches":["main"]}
620
+ JSON
621
+ git -C "$SESSION_REPO" checkout -q -b relXease/1 2>/dev/null
622
+ (cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' \
623
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" >/dev/null 2>&1)
624
+ [ $? -eq 2 ] && echo "OK gitflow-guard trata el punto del prefijo como literal, no wildcard" || { echo "FAIL gitflow-guard trato el punto de la politica como wildcard de regex"; fail=1; }
625
+ git -C "$SESSION_REPO" checkout -q -b rel.ease/1 2>/dev/null
626
+ (cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git commit -m x"}}' \
627
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" >/dev/null 2>&1)
628
+ [ $? -eq 0 ] && echo "OK gitflow-guard permite el prefijo literal con punto de la politica" || { echo "FAIL gitflow-guard bloqueo el prefijo literal con punto"; fail=1; }
629
+
630
+ # --- Revision final de rama: dos bypasses de git_global_dash_c reproducidos en vivo tras
631
+ # el fix del hallazgo 2 — un prefijo de entorno o una flag global con valor ANTES de -C
632
+ # hacian que el walk de tokens nunca llegara a leer el -C real, cayendo al repo de sesion
633
+ # (feature/ok, permitido) en vez del repo real del comando (OTHER, en "main", protegida).
634
+ git -C "$SESSION_REPO" checkout -q -b feature/ok 2>/dev/null
635
+
636
+ # hallazgo A: `VAR=val git -C <dir> commit` — la asignacion de entorno antes de `git` hacia
637
+ # que toks[0] != "git" y el segmento se descartaba entero.
638
+ out="$(cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"GIT_AUTHOR_NAME=x git -C '"$OTHER"' commit -m x"}}' \
639
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" 2>&1)"
640
+ rc=$?
641
+ [ $rc -eq 2 ] && echo "OK gitflow-guard resuelve -C tras un prefijo de variable de entorno" || { echo "FAIL gitflow-guard bypass via prefijo de entorno ($rc / $out)"; fail=1; }
642
+
643
+ # hallazgo B: `git -c clave=valor -C <dir> commit` — `-c` toma su valor en un token
644
+ # SEPARADO; sin saltar el par completo, el walk trataba ese valor como si fuera el
645
+ # subcomando y paraba ahi, sin llegar nunca al -C real.
646
+ out="$(cd "$SESSION_REPO" && echo '{"tool_name":"Bash","tool_input":{"command":"git -c user.name=x -C '"$OTHER"' commit -m x"}}' \
647
+ | CLAUDE_PROJECT_DIR="$SESSION_REPO" bash "$ROOT/hooks/build/gitflow-guard.sh" 2>&1)"
648
+ rc=$?
649
+ [ $rc -eq 2 ] && echo "OK gitflow-guard resuelve -C tras una flag global con valor (-c)" || { echo "FAIL gitflow-guard bypass via -c antes de -C ($rc / $out)"; fail=1; }
650
+
651
+ # stack-guard: bloquea igual que siempre y, con contexto stale, lo anota.
652
+ mkdir -p "$PROJ/.claude/config"
653
+ echo '{"allow":["react"]}' > "$PROJ/.claude/config/stack-allowlist.json"
654
+ runtime_projection_write '{"fetched_at":"2000-01-01T00:00:00Z","project":{},"active_slice":null,"nudges":[]}'
655
+ out="$(echo '{"tool_name":"Write","tool_input":{"file_path":"'"$PROJ"'/package.json","content":"{\"dependencies\":{\"lodash\":\"^4.0.0\"}}"}}' \
656
+ | bash "$ROOT/hooks/build/stack-guard.sh" 2>&1)"
657
+ rc=$?
658
+ [ $rc -eq 2 ] && echo "OK stack-guard sigue bloqueando deps fuera del allowlist" || { echo "FAIL stack-guard rc ($rc / $out)"; fail=1; }
659
+ echo "$out" | grep -q 'stale\|sin sincronizar' && echo "OK stack-guard anota contexto stale" || { echo "FAIL stack-guard sin nota de stale ($out)"; fail=1; }
660
+
661
+ # --- context-monitor.sh en modo runtime: handoff como evento, sin escritura local ---
662
+ export TMPDIR="$TMP"
663
+ rm -f "$PROJ"/.claude/state/outbox/*.json 2>/dev/null
664
+ rm -f "$PROJ"/.claude/state/.handoff-* 2>/dev/null
665
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{},\"wiring_failing\":[{\"item_id\":\"w1\",\"kind\":\"e2e\"}],\"resume_hint\":null},\"nudges\":[],\"context\":{\"version\":9}}"
666
+ echo "{\"remaining_pct\":20,\"used_pct\":80,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-cm1.json"
667
+
668
+ out="$(echo '{"session_id":"cm1","hook_event_name":"PostToolUse"}' | bash "$ROOT/hooks/build/context-monitor.sh" 2>&1)"
669
+ echo "$out" | grep -q 'additionalContext' && echo "OK context-monitor runtime inyecta aviso" || { echo "FAIL sin aviso runtime ($out)"; fail=1; }
670
+ ev="$(python3 -c "
671
+ import json,glob
672
+ files=[p for p in glob.glob('$PROJ/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json')]
673
+ d=[json.load(open(p)) for p in files]
674
+ h=[x for x in d if x['type']=='handoff_recorded']
675
+ print(h[0]['slice_id'], 'w1' in (h[0]['payload'].get('resume_hint') or '')) if h else print('none')
676
+ " 2>/dev/null)"
677
+ [ "$ev" = "sl-11 True" ] && echo "OK context-monitor encola handoff_recorded con resume_hint" || { echo "FAIL handoff_recorded ($ev)"; fail=1; }
678
+ [ ! -f "$PROJ/.claude/state/build-state.json" ] && echo "OK context-monitor runtime no crea estado local" || { echo "FAIL estado local creado en runtime"; fail=1; }
679
+
680
+ # Once-per-sesión: un segundo disparo no duplica el evento.
681
+ n_before="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
682
+ echo '{"session_id":"cm1","hook_event_name":"PostToolUse"}' | bash "$ROOT/hooks/build/context-monitor.sh" >/dev/null 2>&1
683
+ n_after="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
684
+ [ "$n_before" = "$n_after" ] && echo "OK handoff_recorded no se duplica en la misma sesion" || { echo "FAIL handoff duplicado ($n_before -> $n_after)"; fail=1; }
685
+
686
+ # --- Regresión revisión final de rama (hallazgo 1): context-monitor.sh en modo `dual` SIN
687
+ # slice_id conocido NO debe encolar handoff_recorded sin la clave slice_id. `dual` no reclama
688
+ # vía runtime, así que /agent/context normalmente no trae active_slice y el slice_id sale
689
+ # vacío: es el caso NORMAL en dual, no el borde. Sin la guardia, el evento se encolaba sin
690
+ # slice_id (violando protocolo §1) y, al estar en RUNTIME_OUTBOX_PROTECTED, quedaba como
691
+ # poison pill si el servidor rechazaba el lote.
692
+ rm -f "$PROJ"/.claude/state/outbox/*.json 2>/dev/null
693
+ rm -f "$PROJ"/.claude/state/.handoff-* 2>/dev/null
694
+ rm -f "$PROJ/.claude/state/build-state.json"
695
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
696
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
697
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
698
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t","progress_log":[]},
699
+ "history":[],"releases":[]}
700
+ JSON
701
+ # Caché de proyección de un cliente dual real: sin claim vía runtime, active_slice llega null.
702
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{},\"active_slice\":null,\"nudges\":[]}"
703
+ echo "{\"remaining_pct\":20,\"used_pct\":80,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-cmdual.json"
704
+
705
+ out="$(echo '{"session_id":"cmdual","hook_event_name":"PostToolUse"}' | TRYCORE_RUNTIME_MODE=dual bash "$ROOT/hooks/build/context-monitor.sh" 2>&1)"
706
+ echo "$out" | grep -q 'additionalContext' && echo "OK context-monitor dual inyecta aviso" || { echo "FAIL sin aviso dual ($out)"; fail=1; }
707
+ sc="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice'].get('session_continuity',{}).get('critical_recorded'))" 2>/dev/null)"
708
+ [ "$sc" = True ] && echo "OK context-monitor dual sigue escribiendo el fichero local" || { echo "FAIL dual no escribio build-state.json ($sc)"; fail=1; }
709
+ n_ev="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' 2>/dev/null | wc -l | tr -d ' ')"
710
+ [ "$n_ev" = 0 ] && echo "OK context-monitor dual sin slice_id NO encola handoff_recorded (evita poison pill)" || { echo "FAIL dual encolo handoff_recorded sin slice_id (n=$n_ev)"; fail=1; }
711
+
712
+ rm -f "$PROJ/.claude/state/build-state.json"
713
+ rm -f "$PROJ"/.claude/state/outbox/*.json 2>/dev/null
714
+ rm -f "$PROJ"/.claude/state/.handoff-* 2>/dev/null
715
+ # Restaura la caché de proyección que el bloque siguiente (statusline-bridge) da por sentada.
716
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{},\"active_slice\":{\"slice_id\":\"sl-11\",\"phase\":\"green\",\"gates\":{},\"wiring_failing\":[{\"item_id\":\"w1\",\"kind\":\"e2e\"}],\"resume_hint\":null},\"nudges\":[],\"context\":{\"version\":9}}"
717
+
718
+ # --- statusline-bridge.sh: versión de contexto, stale y lease ---
719
+ out="$(echo '{"session_id":"sl1","context_window":{"remaining_percentage":40}}' | bash "$ROOT/hooks/build/statusline-bridge.sh" 2>/dev/null)"
720
+ echo "$out" | grep -q 'ctx 40%' && echo "OK statusline conserva el porcentaje de contexto" || { echo "FAIL statusline ctx% ($out)"; fail=1; }
721
+ echo "$out" | grep -q 'v9' && echo "OK statusline muestra la version de contexto" || { echo "FAIL statusline version ($out)"; fail=1; }
722
+
723
+ runtime_projection_write '{"fetched_at":"2000-01-01T00:00:00Z","project":{},"active_slice":null,"nudges":[],"context":{"version":9}}'
724
+ out="$(echo '{"session_id":"sl2","context_window":{"remaining_percentage":40}}' | bash "$ROOT/hooks/build/statusline-bridge.sh" 2>/dev/null)"
725
+ echo "$out" | grep -q 'stale' && echo "OK statusline marca stale" || { echo "FAIL statusline stale ($out)"; fail=1; }
726
+
727
+ echo '{"lease_lost": true}' > "$PROJ/.claude/state/heartbeat-status.json"
728
+ out="$(echo '{"session_id":"sl3","context_window":{"remaining_percentage":40}}' | bash "$ROOT/hooks/build/statusline-bridge.sh" 2>/dev/null)"
729
+ echo "$out" | grep -q 'lease' && echo "OK statusline marca lease perdido" || { echo "FAIL statusline lease ($out)"; fail=1; }
730
+ rm -f "$PROJ/.claude/state/heartbeat-status.json"
731
+
732
+ # legacy: la statusline vuelve a su forma v0.8.5 (sin marcas de runtime).
733
+ out="$(echo '{"session_id":"sl4","context_window":{"remaining_percentage":40}}' | TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/statusline-bridge.sh" 2>/dev/null)"
734
+ [ "$out" = "🏗️ build · ctx 40%" ] && echo "OK statusline legacy intacta" || { echo "FAIL statusline legacy ($out)"; fail=1; }
735
+ unset TMPDIR
736
+
737
+ # --- Stop adelgazado: nudges del runtime ---
738
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{},
739
+ \"active_slice\":{\"slice_id\":\"sl-11\",\"epic_code\":\"EP-009\",\"hus\":[\"HU-021\"],\"phase\":\"green\",
740
+ \"gates\":{\"dor\":true,\"tdd\":false},\"wiring_failing\":[]},
741
+ \"nudges\":[{\"kind\":\"reflect\",\"message\":\"2 slices archivados sin reflexionar\"},
742
+ {\"kind\":\"release_gate\",\"message\":\"3 épicas sin auditar desde el último release\"},
743
+ {\"kind\":\"info\",\"message\":\"ruido que no debe salir por los nudges tipados\"}]}"
744
+
745
+ out="$(bash "$ROOT/hooks/build/build-gate-check.sh" 2>&1)"
746
+ echo "$out" | grep -q 'tdd' && echo "OK build-gate-check runtime lista gates abiertos" || { echo "FAIL build-gate-check runtime ($out)"; fail=1; }
747
+ out="$(bash "$ROOT/hooks/build/reflect-nudge.sh" 2>&1)"
748
+ echo "$out" | grep -q 'sin reflexionar' && echo "OK reflect-nudge renderiza el nudge del runtime" || { echo "FAIL reflect-nudge ($out)"; fail=1; }
749
+ echo "$out" | grep -q 'ruido' && { echo "FAIL reflect-nudge saca nudges de otro tipo"; fail=1; } || echo "OK reflect-nudge filtra por tipo"
750
+ out="$(bash "$ROOT/hooks/build/release-gate-nudge.sh" 2>&1)"
751
+ echo "$out" | grep -q 'sin auditar' && echo "OK release-gate-nudge renderiza el nudge del runtime" || { echo "FAIL release-gate-nudge ($out)"; fail=1; }
752
+
753
+ # Los tres son no-bloqueantes SIEMPRE (exit 0), pase lo que pase.
754
+ for h in build-gate-check reflect-nudge release-gate-nudge; do
755
+ bash "$ROOT/hooks/build/$h.sh" >/dev/null 2>&1
756
+ [ $? -eq 0 ] && echo "OK $h exit 0" || { echo "FAIL $h no salio 0"; fail=1; }
757
+ done
758
+
759
+ # Sin nudges y sin slice: silencio (nada que decir).
760
+ runtime_projection_write "{\"fetched_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"project\":{},\"active_slice\":null,\"nudges\":[]}"
761
+ out="$(bash "$ROOT/hooks/build/reflect-nudge.sh" 2>&1; bash "$ROOT/hooks/build/build-gate-check.sh" 2>&1)"
762
+ [ -z "$out" ] && echo "OK Stop silencioso sin nudges" || { echo "FAIL Stop ruidoso ($out)"; fail=1; }
763
+
764
+ # --- session-stop.sh: sentinela de flush, sin red ---
765
+ export TRYCORE_HEARTBEAT_TICK_S=1 TRYCORE_HEARTBEAT_INTERVAL_S=1
766
+ rm -f "$TMP/curl-was-called" "$PROJ/.claude/state/outbox/.flush-request"
767
+ mkdir -p "$TMP/fakebin"
768
+ echo '#!/bin/bash' > "$TMP/fakebin/curl" && echo 'touch "$FAKE_CURL_MARK"' >> "$TMP/fakebin/curl" && chmod +x "$TMP/fakebin/curl"
769
+ FAKE_CURL_MARK="$TMP/curl-was-called" PATH="$TMP/fakebin:$PATH" bash "$ROOT/hooks/build/session-stop.sh" >/dev/null 2>&1
770
+ rc=$?
771
+ [ $rc -eq 0 ] && echo "OK session-stop exit 0" || { echo "FAIL session-stop rc ($rc)"; fail=1; }
772
+ [ -f "$PROJ/.claude/state/outbox/.flush-request" ] && echo "OK session-stop deja el sentinela de flush" || { echo "FAIL sin sentinela"; fail=1; }
773
+ [ ! -f "$TMP/curl-was-called" ] && echo "OK session-stop no toca la red" || { echo "FAIL session-stop abrio socket"; fail=1; }
774
+
775
+ rm -f "$PROJ/.claude/state/outbox/.flush-request"
776
+ TRYCORE_RUNTIME_MODE=legacy bash "$ROOT/hooks/build/session-stop.sh" >/dev/null 2>&1
777
+ [ ! -f "$PROJ/.claude/state/outbox/.flush-request" ] && echo "OK session-stop no-op en legacy" || { echo "FAIL session-stop actuo en legacy"; fail=1; }
778
+ bash "$ROOT/hooks/build/heartbeat.sh" --stop >/dev/null 2>&1
779
+ unset TRYCORE_HEARTBEAT_TICK_S TRYCORE_HEARTBEAT_INTERVAL_S
780
+
781
+ # --- registro en los tres canales, sincronizados entre sí ---
782
+ sync_check="$(ROOT="$ROOT" python3 <<'PY' 2>&1
783
+ import json,os,re,sys
784
+ root=os.environ["ROOT"]
785
+ ts=open(os.path.join(root,"src/lib/settings-merge.ts")).read()
786
+ block=re.search(r"HOOK_SPECS:\s*HookSpec\[\]\s*=\s*\[(.*?)\n\];", ts, re.S)
787
+ if not block:
788
+ print("FALLO: no se pudo leer HOOK_SPECS de settings-merge.ts"); sys.exit(0)
789
+ ts_specs={}
790
+ for m in re.finditer(r"\{\s*event:\s*'([^']+)',\s*matcher:\s*'([^']*)',\s*scripts:\s*\[([^\]]*)\]", block.group(1)):
791
+ ev,matcher,scripts=m.group(1),m.group(2),m.group(3)
792
+ ts_specs[(ev,matcher)]=[s.strip().strip("'") for s in scripts.split(",") if s.strip()]
793
+
794
+ def from_json(path):
795
+ d=json.load(open(os.path.join(root,path)))
796
+ out={}
797
+ for ev,groups in (d.get("hooks") or {}).items():
798
+ for g in groups:
799
+ key=(ev,g.get("matcher",""))
800
+ out.setdefault(key,[])
801
+ for h in (g.get("hooks") or []):
802
+ out[key].append(h["command"].rstrip('"').split("/hooks/build/")[-1])
803
+ return out
804
+
805
+ plugin=from_json("hooks/build-harness.json")
806
+ tmpl=from_json("templates/settings-hooks.template.json")
807
+ problemas=[]
808
+ for key in set(list(ts_specs.keys())+list(plugin.keys())+list(tmpl.keys())):
809
+ a,b,c=ts_specs.get(key),plugin.get(key),tmpl.get(key)
810
+ if not (a==b==c):
811
+ problemas.append(f"{key}: ts={a} plugin={b} template={c}")
812
+ esperado={
813
+ ("SessionStart","startup|clear|compact"):["session-start.sh","load-build-state.sh"],
814
+ ("PreToolUse","Bash"):["gitflow-guard.sh"],
815
+ ("PreToolUse","Write|Edit|MultiEdit"):["stack-guard.sh","scaffold-guard.sh","design-source-guard.sh"],
816
+ ("PostToolUse","Write|Edit|MultiEdit"):["lint-typecheck.sh","coherence-flag.sh"],
817
+ ("PostToolUse","Bash|Edit|Write|MultiEdit|Task"):["context-monitor.sh","event-emitter.sh"],
818
+ ("PreCompact",".*"):["context-monitor.sh"],
819
+ ("Stop",".*"):["build-gate-check.sh","reflect-nudge.sh","release-gate-nudge.sh","dual-compare.sh","context-monitor.sh","session-stop.sh"],
820
+ }
821
+ if ts_specs!=esperado:
822
+ problemas.append(f"settings-merge.ts no coincide con el mapa objetivo: {ts_specs}")
823
+ print("SYNC-OK" if not problemas else "SYNC-FAIL: "+" | ".join(problemas))
824
+ PY
825
+ )"
826
+ echo "$sync_check" | grep -q '^SYNC-OK$' && echo "OK los tres canales registran los mismos hooks" || { echo "FAIL registro desincronizado: $sync_check"; fail=1; }
827
+
828
+ # Los scripts registrados existen y son ejecutables.
829
+ for s in session-start.sh load-build-state.sh gitflow-guard.sh stack-guard.sh scaffold-guard.sh \
830
+ design-source-guard.sh lint-typecheck.sh coherence-flag.sh context-monitor.sh \
831
+ event-emitter.sh build-gate-check.sh reflect-nudge.sh release-gate-nudge.sh dual-compare.sh session-stop.sh \
832
+ context-sync.sh heartbeat.sh; do
833
+ [ -x "$ROOT/hooks/build/$s" ] || { echo "FAIL $s no existe o no es ejecutable"; fail=1; }
834
+ done
835
+ echo "OK todos los hooks del mapa existen y son ejecutables"
836
+
837
+ # Checklist de aceptación v0.9.0 [EP-OR-08-E, plan-migracion-harness-v0.9.md §3]: "Los 4
838
+ # guards bloqueantes conservan latencia local (sin red en el camino de PreToolUse)". Ratchet
839
+ # estático: ninguno de los 4 debe llamar curl/runtime_get/runtime_post/runtime_put/
840
+ # _runtime_http/agent_context_fetch_and_cache — ni directo ni transitivo por lo que sourcean.
841
+ for g in gitflow-guard.sh stack-guard.sh scaffold-guard.sh design-source-guard.sh; do
842
+ if grep -qE '\b(curl|runtime_get|runtime_post|runtime_put|_runtime_http|agent_context_fetch_and_cache)\b' "$ROOT/hooks/build/$g"; then
843
+ echo "FAIL $g toca la red -- PreToolUse debe permanecer 100% local (spec §3 checklist v0.9.0)"
844
+ fail=1
845
+ fi
846
+ if grep -qE "^\s*source .*lib/runtime-client\.sh" "$ROOT/hooks/build/$g"; then
847
+ echo "FAIL $g sourcea runtime-client.sh -- solo config.sh/state-io.sh/projection.sh son válidos en PreToolUse"
848
+ fail=1
849
+ fi
850
+ done
851
+ echo "OK los 4 guards de PreToolUse no tocan la red (ni directo ni por runtime-client.sh)"
852
+
853
+ exit $fail