@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
@@ -53,9 +53,15 @@ check() { # <ruta relativa a .claude> <descripción>
53
53
 
54
54
  check "hooks/build/reconcile-build-state.py"
55
55
  check "hooks/build/lib/state-io.sh"
56
+ check "hooks/build/lib/config.sh"
56
57
  check "hooks/build/context-monitor.sh"
57
58
  check "hooks/build/statusline-bridge.sh"
58
59
  check "scripts/lib/front-plan.py"
60
+ check "hooks/build/slice-ops.sh"
61
+ check "hooks/build/release-ops.sh"
62
+ check "hooks/build/lib/runtime-ops.sh"
63
+ check "hooks/build/dual-compare.sh"
64
+ check "scripts/lib/graph-bundle.py"
59
65
 
60
66
  # 3) Bit ejecutable en los .py/.sh copiados (modo copy no preserva +x por defecto — el
61
67
  # instalador debe forzarlo con chmodExec tras copiar).
@@ -71,7 +77,12 @@ checkexec() {
71
77
  }
72
78
  checkexec "hooks/build/reconcile-build-state.py"
73
79
  checkexec "hooks/build/lib/state-io.sh"
80
+ checkexec "hooks/build/lib/config.sh"
74
81
  checkexec "scripts/lib/front-plan.py"
82
+ checkexec "hooks/build/slice-ops.sh"
83
+ checkexec "hooks/build/release-ops.sh"
84
+ checkexec "hooks/build/dual-compare.sh"
85
+ checkexec "scripts/lib/graph-bundle.py"
75
86
 
76
87
  # 4) `update` (canal de refresco tras `npm i` del paquete) también debe dejarlos — re-correr
77
88
  # sobre la misma instalación no debe romper nada ni perder archivos.
@@ -84,7 +95,12 @@ if [ $rc -ne 0 ]; then
84
95
  fi
85
96
  check "hooks/build/reconcile-build-state.py"
86
97
  check "hooks/build/lib/state-io.sh"
98
+ check "hooks/build/lib/config.sh"
87
99
  check "scripts/lib/front-plan.py"
100
+ check "hooks/build/slice-ops.sh"
101
+ check "hooks/build/release-ops.sh"
102
+ check "hooks/build/dual-compare.sh"
103
+ check "scripts/lib/graph-bundle.py"
88
104
 
89
105
  # project_kind: el TMP de init es un directorio vacío → greenfield detectado (auto)
90
106
  if python3 -c "
@@ -138,6 +154,25 @@ else
138
154
  echo "FAIL install: project_kind no detectado como brownfield con historial git"
139
155
  fail=1
140
156
  fi
157
+
158
+ # asset-types.json [EP-OR-08-D]: versionado como schema/README, sembrado en .claude/.
159
+ [ -f "$TMP_BF/.claude/asset-types.json" ] \
160
+ && echo "OK install: .claude/asset-types.json sembrado" \
161
+ || { echo "FAIL install: .claude/asset-types.json NO sembrado"; fail=1; }
162
+
163
+ # .gitignore [EP-OR-08-D]: las credenciales del runtime y sus derivados nunca deben
164
+ # poder llegar a un \`git add\` descuidado (repo git real: TMP_BF ya tiene uno).
165
+ GI="$TMP_BF/.gitignore"
166
+ if [ -f "$GI" ] \
167
+ && grep -q '^\.claude/state/runtime\.credentials$' "$GI" \
168
+ && grep -q '^\.claude/state/outbox/$' "$GI" \
169
+ && grep -q '^\.claude/state/context\.lock$' "$GI" \
170
+ && grep -q '^\.claude/state/runtime-projection\.json$' "$GI"; then
171
+ echo "OK install: .gitignore protege runtime.credentials/outbox/lock/projection"
172
+ else
173
+ echo "FAIL install: .gitignore no protege los artefactos del runtime"
174
+ fail=1
175
+ fi
141
176
  rm -rf "$TMP_BF"
142
177
 
143
178
  # 6) Ambiguo: manifiesto + código pero SIN repo git (sin historial) → project_kind null.
@@ -174,4 +209,26 @@ else
174
209
  fi
175
210
  rm -rf "$TMP_AMB"
176
211
 
212
+ # 7) `migrate` [EP-OR-08-D]: prepara el bundle de estado sin tocar la red (TMP ya tiene
213
+ # build-state.json vacío sembrado por el init de la sección 2/3).
214
+ node "$ROOT/dist/cli.js" migrate "$TMP" --project-ref acme >"$TMP/.migrate.log" 2>&1
215
+ rc=$?
216
+ BUNDLE="$TMP/.claude/state/migration-bundle.json"
217
+ if [ $rc -eq 0 ] && [ -f "$BUNDLE" ] \
218
+ && python3 -c "
219
+ import json
220
+ d=json.load(open('$BUNDLE'))
221
+ assert d['kind']=='state', d
222
+ assert d['bundle_version']==2, d
223
+ assert d['project_ref']=='acme', d
224
+ assert d['history']==[], d
225
+ assert d['releases']==[], d
226
+ " 2>/dev/null; then
227
+ echo "OK install: migrate prepara el bundle de estado (kind/version/project_ref/history/releases)"
228
+ else
229
+ echo "FAIL install: migrate no preparó el bundle de estado esperado (rc=$rc)"
230
+ cat "$TMP/.migrate.log"
231
+ fail=1
232
+ fi
233
+
177
234
  exit $fail
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ ROOT="$(git rev-parse --show-toplevel)"
4
+ source "$ROOT/hooks/build/lib/runtime-client.sh" 2>/dev/null || { echo "FAIL no existe lib/runtime-client.sh"; exit 1; }
5
+ fail=0
6
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
7
+
8
+ # --- runtime_mode ---
9
+ echo '{}' > "$TMP/cfg.json"
10
+ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" runtime_mode)"
11
+ [ "$got" = legacy ] && echo "OK runtime_mode default legacy" || { echo "FAIL runtime_mode default ($got)"; fail=1; }
12
+
13
+ echo '{"runtime":{"mode":"dual"}}' > "$TMP/cfg.json"
14
+ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" runtime_mode)"
15
+ [ "$got" = dual ] && echo "OK runtime_mode desde config" || { echo "FAIL runtime_mode config ($got)"; fail=1; }
16
+
17
+ got="$(TRYCORE_RUNTIME_MODE=runtime BUILD_CONFIG_FILE="$TMP/cfg.json" runtime_mode)"
18
+ [ "$got" = runtime ] && echo "OK runtime_mode env gana a config" || { echo "FAIL runtime_mode env ($got)"; fail=1; }
19
+
20
+ # --- credenciales ---
21
+ export CLAUDE_PROJECT_DIR="$TMP/proj"
22
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
23
+
24
+ got="$(runtime_field project_token)"
25
+ [ "$got" = "" ] && echo "OK runtime_field sin fichero" || { echo "FAIL runtime_field sin fichero ($got)"; fail=1; }
26
+
27
+ runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9","project_token":"tok-abc"}'
28
+ perm="$(stat -f '%Lp' "$CLAUDE_PROJECT_DIR/.claude/state/runtime.credentials" 2>/dev/null || stat -c '%a' "$CLAUDE_PROJECT_DIR/.claude/state/runtime.credentials")"
29
+ [ "$perm" = 600 ] && echo "OK credenciales 0600" || { echo "FAIL permisos ($perm)"; fail=1; }
30
+ got="$(runtime_field project_token)"
31
+ [ "$got" = "tok-abc" ] && echo "OK runtime_field lee tras merge" || { echo "FAIL runtime_field tras merge ($got)"; fail=1; }
32
+
33
+ runtime_credentials_merge '{"agent_key":"ak-1"}'
34
+ got="$(runtime_field project_token)"
35
+ [ "$got" = "tok-abc" ] && echo "OK merge preserva claves previas" || { echo "FAIL merge no preserva ($got)"; fail=1; }
36
+ got="$(runtime_field agent_key)"
37
+ [ "$got" = "ak-1" ] && echo "OK merge añade clave nueva" || { echo "FAIL merge no añade ($got)"; fail=1; }
38
+
39
+ unset CLAUDE_PROJECT_DIR
40
+
41
+ # --- HTTP contra stub local ---
42
+ PORT="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
43
+ cat > "$TMP/routes.json" <<JSON
44
+ {"GET /ping": {"status": 200, "body": {"ok": true}},
45
+ "POST /echo": {"status": 200, "body": {"received": true}}}
46
+ JSON
47
+ : > "$TMP/calls.jsonl"
48
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT" "$TMP/routes.json" "$TMP/calls.jsonl" &
49
+ STUB_PID=$!
50
+ trap 'kill $STUB_PID 2>/dev/null; rm -rf "$TMP"' EXIT
51
+ sleep 0.3
52
+
53
+ export CLAUDE_PROJECT_DIR="$TMP/proj2"
54
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
55
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT\",\"project_token\":\"tok-xyz\"}"
56
+
57
+ body="$(runtime_get /ping)"
58
+ echo "$body" | grep -q '"ok": true' && echo "OK runtime_get body" || { echo "FAIL runtime_get body ($body)"; fail=1; }
59
+ st="$(runtime_http_status)"
60
+ [ "$st" = 200 ] && echo "OK runtime_get status" || { echo "FAIL runtime_get status ($st)"; fail=1; }
61
+
62
+ body="$(runtime_post /echo '{"a":1}')"
63
+ echo "$body" | grep -q '"received": true' && echo "OK runtime_post body" || { echo "FAIL runtime_post body ($body)"; fail=1; }
64
+
65
+ auth_sent="$(tail -1 "$TMP/calls.jsonl" | python3 -c 'import json,sys;print(json.load(sys.stdin)["auth"])')"
66
+ [ "$auth_sent" = "Bearer tok-xyz" ] && echo "OK Authorization Bearer" || { echo "FAIL Authorization ($auth_sent)"; fail=1; }
67
+
68
+ kill "$STUB_PID" 2>/dev/null; wait "$STUB_PID" 2>/dev/null
69
+
70
+ # offline: puerto cerrado, nunca debe colgar ni lanzar
71
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:1\"}"
72
+ body="$(runtime_get /ping)"
73
+ st="$(runtime_http_status)"
74
+ [ "$st" = 000 ] && echo "OK offline status 000" || { echo "FAIL offline status ($st)"; fail=1; }
75
+
76
+ unset CLAUDE_PROJECT_DIR
77
+
78
+ # --- runtime_register ---
79
+ PORT2="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
80
+ cat > "$TMP/routes2.json" <<JSON
81
+ {"POST /agents/register": {"status": 200, "body": {"agent_key": "ak-9", "project_id": "proj-9", "poll_interval_s": 30, "lease_ttl_s": 300, "manifest_hash": "sha256:abc"}}}
82
+ JSON
83
+ : > "$TMP/calls2.jsonl"
84
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT2" "$TMP/routes2.json" "$TMP/calls2.jsonl" &
85
+ STUB_PID2=$!
86
+ trap 'kill $STUB_PID 2>/dev/null; kill $STUB_PID2 2>/dev/null; rm -rf "$TMP"' EXIT
87
+ sleep 0.3
88
+
89
+ export CLAUDE_PROJECT_DIR="$TMP/proj3"
90
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
91
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT2\",\"project_token\":\"tok-r\"}"
92
+
93
+ runtime_register "0.9.0"
94
+ rc=$?
95
+ [ $rc -eq 0 ] && echo "OK runtime_register rc 0" || { echo "FAIL runtime_register rc ($rc)"; fail=1; }
96
+ got="$(runtime_field agent_key)"
97
+ [ "$got" = "ak-9" ] && echo "OK runtime_register persiste agent_key" || { echo "FAIL agent_key ($got)"; fail=1; }
98
+ got="$(runtime_field lease_ttl_s)"
99
+ [ "$got" = 300 ] && echo "OK runtime_register persiste lease_ttl_s" || { echo "FAIL lease_ttl_s ($got)"; fail=1; }
100
+ got="$(runtime_field project_token)"
101
+ [ "$got" = "tok-r" ] && echo "OK runtime_register preserva project_token" || { echo "FAIL project_token pisado ($got)"; fail=1; }
102
+
103
+ hv_sent="$(tail -1 "$TMP/calls2.jsonl" | python3 -c 'import json,sys;print(json.loads(json.load(sys.stdin)["body"])["harness_version"])')"
104
+ [ "$hv_sent" = "0.9.0" ] && echo "OK register envia harness_version" || { echo "FAIL harness_version no enviado ($hv_sent)"; fail=1; }
105
+
106
+ # 409 incompatible_version: no debe corromper credenciales existentes, rc distinguible
107
+ cat > "$TMP/routes2.json" <<JSON
108
+ {"POST /agents/register": {"status": 409, "body": {"error": "incompatible_version", "min_version": "0.9.0"}}}
109
+ JSON
110
+ runtime_register "0.8.5"
111
+ rc=$?
112
+ [ $rc -eq 2 ] && echo "OK runtime_register 409 rc 2" || { echo "FAIL 409 rc ($rc)"; fail=1; }
113
+ got="$(runtime_field agent_key)"
114
+ [ "$got" = "ak-9" ] && echo "OK 409 no corrompe credenciales" || { echo "FAIL 409 corrompio credenciales ($got)"; fail=1; }
115
+
116
+ kill "$STUB_PID2" 2>/dev/null; wait "$STUB_PID2" 2>/dev/null
117
+ unset CLAUDE_PROJECT_DIR
118
+
119
+ # --- context.lock ---
120
+ export CLAUDE_PROJECT_DIR="$TMP/proj4"
121
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
122
+
123
+ got="$(runtime_lock_manifest_hash)"
124
+ [ "$got" = "" ] && echo "OK lock vacio sin fichero" || { echo "FAIL lock vacio ($got)"; fail=1; }
125
+
126
+ runtime_lock_write "sha256:deadbeef" '[{"path":"config/stack-allowlist.json","sha256":"aa"}]'
127
+ got="$(runtime_lock_manifest_hash)"
128
+ [ "$got" = "sha256:deadbeef" ] && echo "OK lock_write + manifest_hash" || { echo "FAIL lock_write ($got)"; fail=1; }
129
+ got="$(python3 -c "import json;print(len(json.load(open('$CLAUDE_PROJECT_DIR/.claude/state/context.lock'))['files']))")"
130
+ [ "$got" = 1 ] && echo "OK lock_write persiste files" || { echo "FAIL lock files ($got)"; fail=1; }
131
+
132
+ unset CLAUDE_PROJECT_DIR
133
+
134
+ # --- caché de proyección ---
135
+ export CLAUDE_PROJECT_DIR="$TMP/proj5"
136
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
137
+
138
+ got="$(runtime_projection_read)"
139
+ [ "$got" = "{}" ] && echo "OK projection_read vacio" || { echo "FAIL projection_read vacio ($got)"; fail=1; }
140
+
141
+ runtime_projection_write '{"phase":"tdd","gates":{"dor":true}}'
142
+ got="$(runtime_projection_read | python3 -c 'import json,sys;print(json.load(sys.stdin)["phase"])')"
143
+ [ "$got" = "tdd" ] && echo "OK projection_write + read" || { echo "FAIL projection_write ($got)"; fail=1; }
144
+
145
+ # fichero JSON corrupto: debe degradar a {}
146
+ echo "not json at all" > "$CLAUDE_PROJECT_DIR/.claude/state/runtime-projection.json"
147
+ got="$(runtime_projection_read)"
148
+ [ "$got" = "{}" ] && echo "OK projection_read JSON corrupto" || { echo "FAIL projection_read corrupto ($got)"; fail=1; }
149
+
150
+ unset CLAUDE_PROJECT_DIR
151
+
152
+ # --- outbox: enqueue + cota ---
153
+ export CLAUDE_PROJECT_DIR="$TMP/proj6"
154
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
155
+
156
+ runtime_enqueue_event "checkpoint_created" '{"branch":"feature/x"}'
157
+ n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
158
+ [ "$n" = 1 ] && echo "OK enqueue crea archivo" || { echo "FAIL enqueue ($n)"; fail=1; }
159
+ cid1="$(python3 -c "import json,glob;print(json.load(open(glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json')[0]))['client_event_id'])")"
160
+ [ -n "$cid1" ] && echo "OK enqueue asigna client_event_id" || { echo "FAIL client_event_id vacio"; fail=1; }
161
+
162
+ runtime_enqueue_event "checkpoint_created" '{"branch":"feature/y"}'
163
+ n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
164
+ [ "$n" = 2 ] && echo "OK enqueue no pisa eventos previos" || { echo "FAIL enqueue pisa ($n)"; fail=1; }
165
+
166
+ # cota por edad: un evento evictable (tipo genérico) viejo se descarta; uno protegido sobrevive
167
+ old_file="$CLAUDE_PROJECT_DIR/.claude/state/outbox/00000000000000-old.json"
168
+ python3 -c "import json;json.dump({'client_event_id':'old-1','type':'tool_use_recorded','payload':{},'enqueued_at':'2020-01-01T00:00:00Z'},open('$old_file','w'))"
169
+ touch -t 202001010000 "$old_file"
170
+ dropped="$(runtime_outbox_enforce_cap | tail -1)"
171
+ [ "$dropped" -ge 1 ] && echo "OK enforce_cap descarta evento viejo evictable" || { echo "FAIL enforce_cap no descarto ($dropped)"; fail=1; }
172
+ [ ! -f "$old_file" ] && echo "OK evento viejo evictable eliminado" || { echo "FAIL evento viejo sigue ($old_file)"; fail=1; }
173
+ gap_found="$(grep -l '"type": "telemetry_gap"' "$CLAUDE_PROJECT_DIR"/.claude/state/outbox/*.json 2>/dev/null | wc -l | tr -d ' ')"
174
+ [ "$gap_found" -ge 1 ] && echo "OK descarte emite telemetry_gap" || { echo "FAIL sin telemetry_gap"; fail=1; }
175
+ still_protected="$(grep -l '"type": "checkpoint_created"' "$CLAUDE_PROJECT_DIR"/.claude/state/outbox/*.json 2>/dev/null | wc -l | tr -d ' ')"
176
+ [ "$still_protected" = 2 ] && echo "OK eventos protegidos sobreviven la cota" || { echo "FAIL protegidos descartados ($still_protected)"; fail=1; }
177
+
178
+ # cota alcanza MÁS ALLÁ de un protegido: el protegido es el más viejo (primero en orden
179
+ # por mtime) pero sobrevive por `continue`; el siguiente evictable, más joven, sí se
180
+ # descarta — no basta con mirar/parar en el primer archivo de la cola
181
+ export CLAUDE_PROJECT_DIR="$TMP/proj6b"
182
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state/outbox"
183
+ protected_old="$CLAUDE_PROJECT_DIR/.claude/state/outbox/00000000000001-protected.json"
184
+ python3 -c "import json;json.dump({'client_event_id':'prot-1','type':'checkpoint_created','payload':{},'enqueued_at':'2019-01-01T00:00:00Z'},open('$protected_old','w'))"
185
+ touch -t 201901010000 "$protected_old"
186
+ evictable_younger="$CLAUDE_PROJECT_DIR/.claude/state/outbox/00000000000002-evictable.json"
187
+ python3 -c "import json;json.dump({'client_event_id':'evict-1','type':'tool_use_recorded','payload':{},'enqueued_at':'2021-01-01T00:00:00Z'},open('$evictable_younger','w'))"
188
+ touch -t 202101010000 "$evictable_younger"
189
+ runtime_outbox_enforce_cap >/dev/null
190
+ [ -f "$protected_old" ] && echo "OK protegido mas viejo sobrevive" || { echo "FAIL protegido mas viejo fue descartado"; fail=1; }
191
+ [ ! -f "$evictable_younger" ] && echo "OK eviccion alcanza mas alla del protegido" || { echo "FAIL eviccion no alcanzo al evictable mas joven"; fail=1; }
192
+ unset CLAUDE_PROJECT_DIR
193
+
194
+ # --- outbox: despacho con backoff ---
195
+ PORT3="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
196
+ : > "$TMP/calls3.jsonl"
197
+
198
+ export CLAUDE_PROJECT_DIR="$TMP/proj7"
199
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
200
+ BUILD_CONFIG_FILE="$TMP/proj7-cfg.json"
201
+ echo '{"runtime":{"mode":"dual"}}' > "$BUILD_CONFIG_FILE"
202
+ export BUILD_CONFIG_FILE
203
+
204
+ # legacy: dispatch es no-op aunque haya cola
205
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT3\",\"project_token\":\"tok-d\"}"
206
+ runtime_enqueue_event "checkpoint_created" '{"n":1}'
207
+ TRYCORE_RUNTIME_MODE=legacy runtime_dispatch_outbox
208
+ n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
209
+ [ "$n" -ge 1 ] && echo "OK dispatch no-op en legacy" || { echo "FAIL dispatch corrio en legacy"; fail=1; }
210
+
211
+ # runtime caido: reintenta sin bloquear, agenda backoff, no borra la cola
212
+ runtime_dispatch_outbox
213
+ rc=$?
214
+ [ $rc -eq 1 ] && echo "OK dispatch rc 1 sin servidor" || { echo "FAIL dispatch rc sin servidor ($rc)"; fail=1; }
215
+ n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
216
+ [ "$n" -ge 1 ] && echo "OK dispatch preserva cola si falla" || { echo "FAIL dispatch borro cola en fallo"; fail=1; }
217
+ [ -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json" ] && echo "OK dispatch agenda backoff" || { echo "FAIL sin estado de backoff"; fail=1; }
218
+
219
+ # segundo intento inmediato: respeta el backoff, no vuelve a intentar (no golpea la red)
220
+ before="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
221
+ runtime_dispatch_outbox
222
+ after="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
223
+ [ "$before" = "$after" ] && echo "OK dispatch respeta backoff" || { echo "FAIL dispatch ignoro backoff"; fail=1; }
224
+
225
+ # servidor arriba: despacha, borra SOLO lo confirmado por el server, preserva lo no
226
+ # confirmado (ack parcial en el mismo lote) y limpia el estado de backoff. El stub carga
227
+ # routes3.json UNA vez al arrancar, así que el client_event_id que va a confirmar debe
228
+ # conocerse ANTES de lanzarlo — se lee del primer archivo ya encolado.
229
+ runtime_enqueue_event "checkpoint_created" '{"n":2}'
230
+ cid="$(python3 -c "
231
+ import json,glob
232
+ files=sorted(p for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json'))
233
+ print(json.load(open(files[0]))['client_event_id'])
234
+ ")"
235
+ cat > "$TMP/routes3.json" <<JSON
236
+ {"POST /events": {"status": 200, "body": {"results": [{"client_event_id": "$cid", "status": "accepted"}]}}}
237
+ JSON
238
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT3" "$TMP/routes3.json" "$TMP/calls3.jsonl" &
239
+ STUB_PID3=$!
240
+ trap 'kill $STUB_PID 2>/dev/null; kill $STUB_PID2 2>/dev/null; kill $STUB_PID3 2>/dev/null; rm -rf "$TMP"' EXIT
241
+ sleep 0.3
242
+ python3 -c "import json;json.dump({'next_attempt_epoch':0,'attempt':0}, open('$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json','w'))"
243
+
244
+ runtime_dispatch_outbox
245
+ rc=$?
246
+ [ $rc -eq 0 ] && echo "OK dispatch rc 0 con servidor arriba" || { echo "FAIL dispatch rc con servidor ($rc)"; fail=1; }
247
+ n="$(find "$CLAUDE_PROJECT_DIR/.claude/state/outbox" -maxdepth 1 -name '*.json' | wc -l | tr -d ' ')"
248
+ [ "$n" = 1 ] && echo "OK dispatch borra lo confirmado, preserva lo no confirmado" || { echo "FAIL cola tras ack parcial ($n)"; fail=1; }
249
+ [ ! -f "$CLAUDE_PROJECT_DIR/.claude/state/outbox/.dispatch-state.json" ] && echo "OK dispatch limpia estado de backoff" || { echo "FAIL estado de backoff persiste"; fail=1; }
250
+
251
+ kill "$STUB_PID3" 2>/dev/null; wait "$STUB_PID3" 2>/dev/null
252
+ unset CLAUDE_PROJECT_DIR BUILD_CONFIG_FILE
253
+
254
+ # --- [EP-OR-08-B] runtime_put + slice_id opcional en runtime_enqueue_event ---
255
+ PORT4="$(python3 -c "import socket;s=socket.socket();s.bind(('',0));print(s.getsockname()[1]);s.close()")"
256
+ cat > "$TMP/routes4.json" <<JSON
257
+ {"PUT /leases/renew": {"status": 200, "body": {"expires_at": "2026-08-19T12:00:00Z"}}}
258
+ JSON
259
+ : > "$TMP/calls4.jsonl"
260
+ python3 "$ROOT/scripts/tests/lib/http-stub.py" "$PORT4" "$TMP/routes4.json" "$TMP/calls4.jsonl" &
261
+ STUB_PID4=$!
262
+ trap 'kill $STUB_PID 2>/dev/null; kill $STUB_PID2 2>/dev/null; kill $STUB_PID3 2>/dev/null; kill $STUB_PID4 2>/dev/null; rm -rf "$TMP"' EXIT
263
+ sleep 0.3
264
+
265
+ export CLAUDE_PROJECT_DIR="$TMP/proj8"
266
+ mkdir -p "$CLAUDE_PROJECT_DIR/.claude/state"
267
+ runtime_credentials_merge "{\"runtime_url\":\"http://127.0.0.1:$PORT4\",\"project_token\":\"tok-p\"}"
268
+
269
+ body="$(runtime_put /leases/renew '{"slice_id":"sl-1"}')"
270
+ echo "$body" | grep -q '"expires_at"' && echo "OK runtime_put body" || { echo "FAIL runtime_put body ($body)"; fail=1; }
271
+ [ "$(runtime_http_status)" = 200 ] && echo "OK runtime_put status" || { echo "FAIL runtime_put status"; fail=1; }
272
+ m_sent="$(tail -1 "$TMP/calls4.jsonl" | python3 -c 'import json,sys;print(json.load(sys.stdin)["method"])')"
273
+ [ "$m_sent" = PUT ] && echo "OK runtime_put usa metodo PUT" || { echo "FAIL metodo ($m_sent)"; fail=1; }
274
+
275
+ # slice_id como TERCER argumento opcional: clave de primer nivel del evento encolado.
276
+ runtime_enqueue_event "tool_use_recorded" '{"tool":"Edit"}' "sl-42"
277
+ sid_written="$(python3 -c "
278
+ import json,glob
279
+ files=sorted(p for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json'))
280
+ print(json.load(open(files[0])).get('slice_id',''))
281
+ ")"
282
+ [ "$sid_written" = "sl-42" ] && echo "OK enqueue escribe slice_id de primer nivel" || { echo "FAIL slice_id ($sid_written)"; fail=1; }
283
+
284
+ # Regresión de firma: con DOS argumentos el evento NO lleva slice_id (los llamadores del
285
+ # sub-slice A siguen funcionando sin cambios).
286
+ rm -f "$CLAUDE_PROJECT_DIR"/.claude/state/outbox/*.json
287
+ runtime_enqueue_event "checkpoint_created" '{"n":1}'
288
+ has_sid="$(python3 -c "
289
+ import json,glob
290
+ files=sorted(p for p in glob.glob('$CLAUDE_PROJECT_DIR/.claude/state/outbox/*.json') if not p.endswith('.dispatch-state.json'))
291
+ print('slice_id' in json.load(open(files[0])))
292
+ ")"
293
+ [ "$has_sid" = False ] && echo "OK enqueue de 2 args no inventa slice_id" || { echo "FAIL enqueue 2 args ($has_sid)"; fail=1; }
294
+
295
+ kill "$STUB_PID4" 2>/dev/null; wait "$STUB_PID4" 2>/dev/null
296
+ unset CLAUDE_PROJECT_DIR
297
+
298
+ exit $fail
@@ -61,8 +61,31 @@ cat > "$TMP/bad-foundation-item.json" <<'JSON'
61
61
  "checklist":[{"applies":true}]},
62
62
  "active_slice":null,"history":[],"releases":[]}
63
63
  JSON
64
+ cat > "$TMP/valid-wiring-sha.json" <<'JSON'
65
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
66
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"add-login",
67
+ "branch":"feature/login","phase":"dod","layer":"business",
68
+ "gates":{"dor":true,"tdd":true,"dod":false},"updated_at":"2026-08-27T00:00:00Z","updated_by":"test",
69
+ "wiring_checklist":[
70
+ {"id":"HU-001-AC1","kind":"hu_ac","ref":"HU-001#happy","status":"passing",
71
+ "evidence":"npm test -- auth.spec (3 passed)","verified_at_sha":"a1b2c3d4e5f6a7b8c9d0a1b2c3d4e5f6a7b8c9d0"},
72
+ {"id":"INT-spa-gateway","kind":"integration_point","ref":"spa→gateway","status":"failing","evidence":""}]},
73
+ "history":[],"releases":[]}
74
+ JSON
75
+ cat > "$TMP/bad-wiring-sha-type.json" <<'JSON'
76
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
77
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"add-login",
78
+ "branch":"feature/login","phase":"dod","layer":"business",
79
+ "gates":{"dor":true,"tdd":true,"dod":false},"updated_at":"2026-08-27T00:00:00Z","updated_by":"test",
80
+ "wiring_checklist":[
81
+ {"id":"HU-001-AC1","kind":"hu_ac","ref":"HU-001#happy","status":"passing",
82
+ "evidence":"npm test -- auth.spec (3 passed)","verified_at_sha":12345}]},
83
+ "history":[],"releases":[]}
84
+ JSON
64
85
  check "estado válido con campos nuevos" pass "$TMP/valid.json"
65
86
  check "layer inválido rechazado" fail "$TMP/bad-layer.json"
87
+ check "wiring_checklist con verified_at_sha válido" pass "$TMP/valid-wiring-sha.json"
88
+ check "verified_at_sha no-string rechazado" fail "$TMP/bad-wiring-sha-type.json"
66
89
  check "foundation + project_kind válidos" pass "$TMP/valid-foundation.json"
67
90
  check "project_kind inválido rechazado" fail "$TMP/bad-project-kind.json"
68
91
  check "checklist item sin 'item' rechazado" fail "$TMP/bad-foundation-item.json"
@@ -73,7 +96,12 @@ if [ -f "$CFG" ] && python3 -c "import json,sys; d=json.load(open('$CFG'))['cont
73
96
  else echo "FAIL build-config.template.json con defaults"; fail=1; fi
74
97
 
75
98
  CMD="$ROOT/commands/build/resume.md"
76
- { [ -f "$CMD" ] && grep -q 'reconcile-build-state.py' "$CMD" && grep -q 'session_continuity' "$CMD"; } \
99
+ # [EP-OR-08-C] resume.md ya no menciona session_continuity literal: en dual/runtime la
100
+ # continuidad la trae el checkpoint del runtime (slice-ops.sh), no ese campo del fichero.
101
+ # El campo sigue existiendo para el camino legacy (reconcile-build-state.py lo reconcilia
102
+ # sin que resume.md necesite nombrarlo); aceptamos cualquiera de los dos como señal de que
103
+ # el comando sigue cubriendo continuidad en algún modo.
104
+ { [ -f "$CMD" ] && grep -q 'reconcile-build-state.py' "$CMD" && { grep -q 'session_continuity' "$CMD" || grep -q 'checkpoint' "$CMD"; }; } \
77
105
  && echo "OK comando resume" || { echo "FAIL comando resume"; fail=1; }
78
106
 
79
107
  exit $fail