@trycore/spec-build-harness 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/METODOLOGIA.md +6 -1
- package/README.md +1 -0
- package/VERSION +1 -1
- package/agents/build/dor-dod-gatekeeper.md +10 -3
- package/commands/build/epic.md +109 -0
- package/commands/build/onboard.md +34 -9
- package/commands/build/slice.md +4 -2
- package/commands/build/work.md +6 -2
- package/config/build-config.template.json +2 -1
- package/dist/commands/init.js +13 -0
- package/dist/commands/status.js +13 -1
- package/dist/lib/paths.js +1 -0
- package/dist/lib/runtime-client.js +20 -0
- package/docs/commands.md +2 -1
- package/docs/hooks.md +11 -1
- package/docs/runtime/protocolo-cliente-runtime.md +64 -0
- package/hooks/build/design-source-guard.sh +12 -1
- package/hooks/build/heartbeat.sh +64 -4
- package/hooks/build/lib/agent-context.sh +16 -7
- package/hooks/build/lib/config.sh +25 -1
- package/hooks/build/lib/runtime-client.sh +540 -9
- package/hooks/build/lib/runtime-ops.sh +108 -0
- package/hooks/build/scaffold-guard.sh +14 -1
- package/hooks/build/slice-ops.sh +498 -16
- package/package.json +1 -1
- package/scripts/tests/test-config.sh +41 -0
- package/scripts/tests/test-hooks-runtime.sh +198 -3
- package/scripts/tests/test-install.sh +23 -0
- package/scripts/tests/test-runtime-client.sh +500 -0
- package/scripts/tests/test-skill-ops.sh +456 -0
- package/skills/building-a-slice/references/dor.md +21 -9
- package/skills/building-a-slice/references/foundation-contract.md +4 -0
- package/state/README.md +12 -0
package/hooks/build/heartbeat.sh
CHANGED
|
@@ -18,10 +18,19 @@ set -uo pipefail
|
|
|
18
18
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
19
|
source "$HERE/lib/runtime-client.sh"
|
|
20
20
|
source "$HERE/lib/projection.sh"
|
|
21
|
+
source "$HERE/lib/agent-context.sh"
|
|
21
22
|
|
|
22
23
|
HB_TICK_S="${TRYCORE_HEARTBEAT_TICK_S:-5}"
|
|
23
24
|
case "$HB_TICK_S" in ''|*[!0-9]*) HB_TICK_S=5 ;; esac
|
|
24
25
|
|
|
26
|
+
# Cadencia del refresco de contexto [#61]. NO es el tick: renovar un lease es barato, pero
|
|
27
|
+
# `GET /agent/context` es una proyección entera del proyecto — a 5 s castigaría al hub sin
|
|
28
|
+
# ganar nada. 45 s es del orden de la latencia con la que un humano aprueba una épica.
|
|
29
|
+
# `0` lo desactiva (vuelta al comportamiento previo a #61).
|
|
30
|
+
HB_CONTEXT_S="${TRYCORE_CONTEXT_REFRESH_S:-}"
|
|
31
|
+
[ -n "$HB_CONTEXT_S" ] || HB_CONTEXT_S="$(config_get runtime.context_refresh_s 45)"
|
|
32
|
+
case "$HB_CONTEXT_S" in ''|*[!0-9]*) HB_CONTEXT_S=45 ;; esac
|
|
33
|
+
|
|
25
34
|
hb_pidfile() { echo "$(config_root)/.claude/state/heartbeat.pid"; }
|
|
26
35
|
hb_sessions() { echo "$(config_root)/.claude/state/heartbeat-sessions.json"; }
|
|
27
36
|
hb_statusfile() { echo "$(config_root)/.claude/state/heartbeat-status.json"; }
|
|
@@ -115,7 +124,11 @@ PY
|
|
|
115
124
|
echo "$n"
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
# hb_write_status <json> —
|
|
127
|
+
# hb_write_status <fragmento-json> — UPSERT del fragmento sobre el estado observable del
|
|
128
|
+
# daemon (lo lee statusline-bridge.sh y `slice-ops.sh status`). Es merge y no reemplazo
|
|
129
|
+
# desde #61: el fichero lo escriben DOS deberes distintos del tick — la renovación del lease
|
|
130
|
+
# y el refresco de contexto — y con reemplazo el último en escribir borraba el diagnóstico
|
|
131
|
+
# del otro (un refresco correcto tapaba un `lease_lost: true` recién detectado).
|
|
119
132
|
hb_write_status() {
|
|
120
133
|
local body f
|
|
121
134
|
body="$1"
|
|
@@ -126,14 +139,22 @@ hb_write_status() {
|
|
|
126
139
|
import json,sys,os,tempfile
|
|
127
140
|
path,body=sys.argv[1],sys.argv[2]
|
|
128
141
|
try:
|
|
129
|
-
d=json.
|
|
142
|
+
d=json.load(open(path))
|
|
130
143
|
except Exception:
|
|
131
144
|
d={}
|
|
145
|
+
if not isinstance(d,dict):
|
|
146
|
+
d={}
|
|
147
|
+
try:
|
|
148
|
+
frag=json.loads(body)
|
|
149
|
+
except Exception:
|
|
150
|
+
frag={}
|
|
151
|
+
if isinstance(frag,dict):
|
|
152
|
+
d.update(frag)
|
|
132
153
|
dirn=os.path.dirname(path) or "."
|
|
133
154
|
fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".heartbeat-status.",suffix=".tmp")
|
|
134
155
|
try:
|
|
135
156
|
with os.fdopen(fd,"w") as out:
|
|
136
|
-
json.dump(d,out,indent=2)
|
|
157
|
+
json.dump(d,out,indent=2); out.flush(); os.fsync(out.fileno())
|
|
137
158
|
os.replace(tmp,path)
|
|
138
159
|
except Exception:
|
|
139
160
|
try: os.unlink(tmp)
|
|
@@ -168,8 +189,40 @@ hb_renew() {
|
|
|
168
189
|
esac
|
|
169
190
|
}
|
|
170
191
|
|
|
192
|
+
# hb_refresh_context — refresca la caché de proyección desde GET /agent/context. Es el ÚNICO
|
|
193
|
+
# camino por el que una terminal ABIERTA se entera de algo nuevo: `session-start.sh` hidrata
|
|
194
|
+
# una vez al arrancar y `slice-ops.sh` solo en claim/status — y el claim ocurre una vez por
|
|
195
|
+
# slice, así que sin esto una sesión larga trabaja horas contra una foto vieja (#61).
|
|
196
|
+
# Solo en modo `runtime`: en legacy|dual el fichero local es primario y el comportamiento no
|
|
197
|
+
# cambia. `agent_context_fetch_and_cache` NUNCA pisa la caché anterior si falla (fail-open);
|
|
198
|
+
# aquí solo se anota el diagnóstico, para que `status` distinga «vieja» de «no se pudo».
|
|
199
|
+
#
|
|
200
|
+
# `last_context_status` es SIEMPRE una cadena. El diagnóstico no es solo un código HTTP
|
|
201
|
+
# (`"write_error"` no lo es) y `"000"` —el valor de «sin red» de `runtime_http_status`— no es
|
|
202
|
+
# un número JSON válido: emitirlo sin comillas rompía el parseo del fragmento entero y
|
|
203
|
+
# `hb_write_status` acababa no escribiendo NADA, ni siquiera el `context_stale: true`.
|
|
204
|
+
hb_refresh_context() {
|
|
205
|
+
local status rc
|
|
206
|
+
[ "$(runtime_mode)" = "runtime" ] || return 0
|
|
207
|
+
agent_context_fetch_and_cache >/dev/null 2>&1
|
|
208
|
+
rc=$?
|
|
209
|
+
if [ "$rc" -eq 0 ]; then
|
|
210
|
+
hb_write_status "{\"context_stale\": false, \"last_context_status\": \"200\", \"last_context_refresh_at\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
|
|
211
|
+
return 0
|
|
212
|
+
fi
|
|
213
|
+
# [#61 · ronda final] Un 200 que no se pudo normalizar o escribir NO es un fallo de red:
|
|
214
|
+
# anotarlo como «último intento: 200» mandaba a mirar el sitio equivocado.
|
|
215
|
+
if [ "$rc" -eq 2 ]; then
|
|
216
|
+
status="write_error"
|
|
217
|
+
else
|
|
218
|
+
status="$(runtime_http_status)"
|
|
219
|
+
fi
|
|
220
|
+
hb_write_status "{\"context_stale\": true, \"last_context_status\": \"$status\"}"
|
|
221
|
+
return 1
|
|
222
|
+
}
|
|
223
|
+
|
|
171
224
|
hb_daemon() {
|
|
172
|
-
local pidf interval ttl last now n
|
|
225
|
+
local pidf interval ttl last last_ctx now n
|
|
173
226
|
pidf="$(hb_pidfile)"
|
|
174
227
|
mkdir -p "$(dirname "$pidf")"
|
|
175
228
|
printf '%s' "$$" > "$pidf"
|
|
@@ -184,6 +237,9 @@ hb_daemon() {
|
|
|
184
237
|
;;
|
|
185
238
|
esac
|
|
186
239
|
last=0
|
|
240
|
+
# `session-start.sh` acaba de hidratar la caché: arrancar el contador AHORA evita un
|
|
241
|
+
# refresco redundante en el primer tick del daemon.
|
|
242
|
+
last_ctx="$(date +%s)"
|
|
187
243
|
while :; do
|
|
188
244
|
sleep "$HB_TICK_S"
|
|
189
245
|
n="$(hb_prune_sessions)"
|
|
@@ -194,6 +250,10 @@ hb_daemon() {
|
|
|
194
250
|
runtime_dispatch_outbox >/dev/null 2>&1
|
|
195
251
|
fi
|
|
196
252
|
now="$(date +%s)"
|
|
253
|
+
if [ "$HB_CONTEXT_S" -gt 0 ] && [ $(( now - last_ctx )) -ge "$HB_CONTEXT_S" ]; then
|
|
254
|
+
hb_refresh_context
|
|
255
|
+
last_ctx="$now"
|
|
256
|
+
fi
|
|
197
257
|
if [ $(( now - last )) -ge "$interval" ]; then
|
|
198
258
|
hb_renew
|
|
199
259
|
runtime_dispatch_outbox >/dev/null 2>&1
|
|
@@ -102,8 +102,12 @@ out = {
|
|
|
102
102
|
"checkpoint": checkpoint,
|
|
103
103
|
},
|
|
104
104
|
"nudges": nudges,
|
|
105
|
+
# [#63] `graph_version` viaja junto al manifest_hash: es la foto del BACKLOG (épicas y sus
|
|
106
|
+
# estados), no la del contexto gobernado. Un hub que no versiona la deja en None y el
|
|
107
|
+
# cliente sigue funcionando exactamente igual que antes (compatibilidad hacia atrás).
|
|
105
108
|
"context": {"manifest_hash": first(ctx.get("manifest_hash"), raw.get("manifest_hash")),
|
|
106
|
-
"version": first(ctx.get("version"), raw.get("context_version"))
|
|
109
|
+
"version": first(ctx.get("version"), raw.get("context_version")),
|
|
110
|
+
"graph_version": first(ctx.get("graph_version"), raw.get("graph_version"))},
|
|
107
111
|
"lease": {"expires_at": first(lease.get("expires_at"), raw.get("lease_expires_at")),
|
|
108
112
|
"ttl_s": first(lease.get("ttl_s"), raw.get("lease_ttl_s"))},
|
|
109
113
|
}
|
|
@@ -120,20 +124,25 @@ PY
|
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
# agent_context_fetch_and_cache — GET /agent/context, normaliza y escribe la caché.
|
|
123
|
-
# rc 0 = caché actualizada · 1 = no se pudo (red, status != 200
|
|
124
|
-
#
|
|
127
|
+
# rc 0 = caché actualizada · 1 = no se pudo TRAER (red caída, status != 200) · 2 = el hub
|
|
128
|
+
# respondió 200 pero la respuesta no se pudo normalizar o la proyección no se pudo escribir.
|
|
129
|
+
# En ambos fallos la caché anterior NO se toca y los guards siguen operando con ella.
|
|
130
|
+
# [#61 · ronda final] El 2 no es cosmético: con un solo rc de fallo, quien diagnostica después
|
|
131
|
+
# leía `runtime_http_status` == 200 y anunciaba «no se pudo refrescar (último intento: 200)»,
|
|
132
|
+
# que manda a mirar la red cuando el problema está en el disco o en el cuerpo de la respuesta.
|
|
133
|
+
# Todos los llamadores ramifican sobre «rc != 0», así que el código nuevo no cambia su flujo.
|
|
125
134
|
agent_context_fetch_and_cache() {
|
|
126
135
|
local body status tmp norm rc
|
|
127
136
|
body="$(runtime_get "$AGENT_CONTEXT_PATH")"
|
|
128
137
|
status="$(runtime_http_status)"
|
|
129
138
|
[ "$status" = "200" ] || return 1
|
|
130
|
-
tmp="$(mktemp)" || return
|
|
139
|
+
tmp="$(mktemp)" || return 2
|
|
131
140
|
printf '%s' "$body" > "$tmp"
|
|
132
141
|
norm="$(agent_context_normalize "$tmp")"
|
|
133
142
|
rc=$?
|
|
134
143
|
rm -f "$tmp"
|
|
135
|
-
[ $rc -eq 0 ] || return
|
|
136
|
-
[ -n "$norm" ] && [ "$norm" != "{}" ] || return
|
|
137
|
-
runtime_projection_write "$norm" || return
|
|
144
|
+
[ $rc -eq 0 ] || return 2
|
|
145
|
+
[ -n "$norm" ] && [ "$norm" != "{}" ] || return 2
|
|
146
|
+
runtime_projection_write "$norm" || return 2
|
|
138
147
|
return 0
|
|
139
148
|
}
|
|
@@ -5,8 +5,32 @@
|
|
|
5
5
|
# config_get. Diseño: fail-open, igual que state-io.sh — nunca lanza.
|
|
6
6
|
|
|
7
7
|
# config_root — raíz del proyecto consumidor (misma señal que state_path en state-io.sh).
|
|
8
|
+
#
|
|
9
|
+
# Worktrees: el estado del arnés (runtime.credentials, runtime-projection.json,
|
|
10
|
+
# outbox/, build-state.json) está en .gitignore — es secreto y working state, no
|
|
11
|
+
# se versiona — así que NO viaja a un `git worktree add`. Resolviendo la raíz solo
|
|
12
|
+
# por el toplevel, todo agente que arranque en un worktree se queda sin
|
|
13
|
+
# credenciales y opera HUÉRFANO: sin identidad de proyecto, sin lease, sin
|
|
14
|
+
# reportar al hub, y con los guards inhibidos porque su fail-open de auto-arme no
|
|
15
|
+
# distingue "este proyecto no usa el arnés" de "no veo el estado desde aquí".
|
|
16
|
+
# Por eso, cuando el árbol actual no tiene estado, se cae al clon principal
|
|
17
|
+
# (--git-common-dir), que es donde vive de verdad. Idempotente para un clon
|
|
18
|
+
# normal: si el candidato ya tiene estado, se devuelve tal cual.
|
|
8
19
|
config_root() {
|
|
9
|
-
|
|
20
|
+
local candidate
|
|
21
|
+
candidate="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
22
|
+
[ -f "$candidate/.claude/state/runtime.credentials" ] && { echo "$candidate"; return; }
|
|
23
|
+
|
|
24
|
+
local common main
|
|
25
|
+
common="$(git -C "$candidate" rev-parse --git-common-dir 2>/dev/null)" || common=""
|
|
26
|
+
if [ -n "$common" ]; then
|
|
27
|
+
case "$common" in /*) ;; *) common="$candidate/$common" ;; esac
|
|
28
|
+
main="$(cd "$common/.." 2>/dev/null && pwd)" || main=""
|
|
29
|
+
if [ -n "$main" ] && [ -f "$main/.claude/state/runtime.credentials" ]; then
|
|
30
|
+
echo "$main"; return
|
|
31
|
+
fi
|
|
32
|
+
fi
|
|
33
|
+
echo "$candidate"
|
|
10
34
|
}
|
|
11
35
|
|
|
12
36
|
# config_get <clave.punteada> <default>
|