@trycore/spec-build-harness 0.7.0 → 0.8.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +3 -3
  3. package/INSTALL.md +7 -7
  4. package/METODOLOGIA.md +44 -0
  5. package/README.md +63 -5
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +6 -0
  8. package/commands/build/front.md +15 -0
  9. package/commands/build/resume.md +29 -0
  10. package/config/build-config.template.json +8 -0
  11. package/dist/commands/init.js +15 -5
  12. package/dist/commands/status.js +1 -0
  13. package/dist/commands/uninstall.js +2 -1
  14. package/dist/lib/paths.js +7 -0
  15. package/dist/lib/settings-merge.js +29 -2
  16. package/dist/lib/state-seed.js +14 -0
  17. package/docs/agents.md +20 -13
  18. package/docs/commands.md +34 -4
  19. package/docs/customization/mcp-extensions.md +5 -4
  20. package/docs/decisiones/2026-07-03-gsd-vs-openspec-fork-vs-rama.md +157 -0
  21. package/docs/flujo-harness-funcional.md +42 -0
  22. package/docs/flujo-harness.md +192 -0
  23. package/docs/getting-started.md +6 -5
  24. package/docs/hooks.md +31 -8
  25. package/hooks/build/build-gate-check.sh +1 -1
  26. package/hooks/build/context-monitor.sh +74 -0
  27. package/hooks/build/design-source-guard.sh +1 -1
  28. package/hooks/build/lib/state-io.sh +55 -0
  29. package/hooks/build/lint-typecheck.sh +1 -1
  30. package/hooks/build/load-build-state.sh +46 -2
  31. package/hooks/build/reconcile-build-state.py +70 -0
  32. package/hooks/build/reflect-nudge.sh +1 -1
  33. package/hooks/build/release-gate-nudge.sh +1 -1
  34. package/hooks/build/scaffold-guard.sh +1 -1
  35. package/hooks/build/stack-guard.sh +1 -1
  36. package/hooks/build/statusline-bridge.sh +32 -0
  37. package/hooks/build-harness.json +24 -0
  38. package/package.json +1 -1
  39. package/scripts/lib/front-plan.py +47 -0
  40. package/scripts/smoke-test.sh +12 -0
  41. package/scripts/tests/test-context-monitor.sh +70 -0
  42. package/scripts/tests/test-front-plan.sh +38 -0
  43. package/scripts/tests/test-install.sh +89 -0
  44. package/scripts/tests/test-reconciler.sh +54 -0
  45. package/scripts/tests/test-schema.sh +58 -0
  46. package/skills/building-a-slice/references/dor.md +2 -0
  47. package/skills/managing-parallel-front/SKILL.md +36 -0
  48. package/state/build-state.schema.json +50 -0
  49. package/templates/CLAUDE.md.template +6 -2
  50. package/templates/settings-hooks.template.json +1 -1
  51. package/docs/super-power-workflows.md +0 -281
package/docs/hooks.md CHANGED
@@ -1,26 +1,32 @@
1
1
  # Hooks del arnés de construcción
2
2
 
3
- Este documento describe los **9 hooks** que `@trycore/spec-build-harness` instala en el proyecto del consumidor. Todos viven en `hooks/build/` (un script bash por hook) y se cablean a los eventos de Claude Code mediante una **cadena de comando única**, idéntica en ambos canales de instalación.
3
+ Este documento describe los **13 hooks** que `@trycore/spec-build-harness` instala en el proyecto del consumidor. Todos viven en `hooks/build/` (un script bash o python por hook, más el helper compartido `lib/state-io.sh`) y se cablean a los eventos de Claude Code mediante una **cadena de comando única**, idéntica en ambos canales de instalación.
4
4
 
5
- Los hooks son el sistema nervioso del arnés: vigilan GitFlow, el stack declarado, el scaffold y la fuente de diseño (bloqueantes), inyectan el estado del build al iniciar la sesión, delegan estilo/typecheck a herramientas, y recuerdan validar trazabilidad, gates abiertos y reflexionar al cerrar un slice. No reemplazan a los agentes ni a las skills; los **complementan** liberando capacidad de razonamiento del modelo y poniendo barandillas mecánicas donde un olvido cuesta caro.
5
+ Los hooks son el sistema nervioso del arnés: vigilan GitFlow, el stack declarado, el scaffold y la fuente de diseño (bloqueantes), inyectan y re-anclan el estado del build al iniciar la sesión, delegan estilo/typecheck a herramientas, vigilan la presión de contexto y escriben handoff automático, y recuerdan validar trazabilidad, gates abiertos, reflexionar al cerrar un slice y correr el Release Gate cuando se acumulan épicas sin auditar. No reemplazan a los agentes ni a las skills; los **complementan** liberando capacidad de razonamiento del modelo y poniendo barandillas mecánicas donde un olvido cuesta caro.
6
6
 
7
7
  ---
8
8
 
9
- ## Resumen de los 9 hooks
9
+ ## Resumen de los 13 hooks
10
10
 
11
11
  | Hook | Evento | Matcher | Qué hace | ¿Bloqueante? |
12
12
  |---|---|---|---|---|
13
- | `load-build-state.sh` | `SessionStart` | `startup\|clear\|compact` | Inyecta al contexto la rama, la fase del arnés, el slice activo y los gates abiertos; sincroniza `harness_phase`. | No |
13
+ | `load-build-state.sh` | `SessionStart` | `startup\|clear\|compact` | Inyecta al contexto la rama, la fase del arnés, el slice activo y los gates abiertos; sincroniza `harness_phase`. Invoca a `reconcile-build-state.py` antes de leer el estado. | No |
14
+ | `reconcile-build-state.py` | `SessionStart` (invocado por `load-build-state.sh`) | — | Ancla `build-state.json` a la realidad de git + tests: degrada a `failing` los items de `wiring_checklist` marcados `passing` sin `evidence`, y anota (sin corregir) el *drift* entre la rama real y `active_slice.branch`. Nunca revierte gates `true→false`. | No |
15
+ | `statusline-bridge.sh` | `statusLine` (comando, no evento de hooks; solo canal CLI) | — | Imprime la línea de estado (`🏗️ build · ctx N%`) y escribe el archivo-puente de contexto (`claude-ctx-<session>.json`) que `context-monitor.sh` consume para calcular la presión de contexto. | No |
14
16
  | `gitflow-guard.sh` | `PreToolUse` | `Bash` | Enforce GitHub Flow estricto sobre `git commit` / `git push`. | **Sí (exit 2)** |
15
17
  | `stack-guard.sh` | `PreToolUse` | `Write\|Edit\|MultiEdit` | Bloquea dependencias en `package.json` fuera de la allowlist del stack del PRD. | **Sí (exit 2)** |
16
18
  | `scaffold-guard.sh` | `PreToolUse` | `Write\|Edit\|MultiEdit` | Bloquea escribir código de slice (fases `red…data`) si el scaffold no está confirmado (`scaffold.confirmed`). | **Sí (exit 2)** |
17
19
  | `design-source-guard.sh` | `PreToolUse` | `Write\|Edit\|MultiEdit` | Bloquea escribir código de un slice **con UI** (`gates.fidelity===false`, fases `red…data`) si la fuente de diseño del proyecto no está confirmada (`design_source.confirmed`). | **Sí (exit 2)** |
18
20
  | `lint-typecheck.sh` | `PostToolUse` | `Write\|Edit\|MultiEdit` | Corre prettier/eslint/tsc sobre el archivo `.ts`/`.tsx` editado. | No |
19
21
  | `coherence-flag.sh` | `PostToolUse` | `Write\|Edit\|MultiEdit` | Recuerda validar la trazabilidad de un `proposal.md` de OpenSpec recién tocado. | No |
22
+ | `context-monitor.sh` | `PostToolUse` \| `PreCompact` \| `Stop` | `Bash\|Edit\|Write\|MultiEdit\|Task` (`PostToolUse`) · `.*` (`PreCompact`/`Stop`) | Lee el puente de contexto de `statusline-bridge.sh`, aplica los umbrales `context.warning_pct`/`context.critical_pct` y, si está por debajo, inyecta `additionalContext` de advertencia; en `critical` escribe una sola vez por sesión el handoff (`active_slice.session_continuity`) en `build-state.json`. | No |
20
23
  | `build-gate-check.sh` | `Stop` | `.*` | Al cerrar el turno, avisa si el slice activo tiene gates abiertos. | No |
21
24
  | `reflect-nudge.sh` | `Stop` | `.*` | Al cerrar el turno, sugiere `/build:reflect` si hay slice(s) archivado(s) sin reflexionar (`reflected != true`). | No |
25
+ | `release-gate-nudge.sh` | `Stop` | `.*` | Al cerrar el turno, sugiere correr el Release Gate (`/build:release`) cuando hay ≥2 épicas archivadas sin auditar desde el último release. Determinista; solo sugiere. | No |
22
26
 
23
- > Cuatro bloqueantes (`gitflow-guard`, `stack-guard`, `scaffold-guard`, `design-source-guard`) y cinco informativos. Los bloqueantes usan `exit 2`: Claude Code devuelve el `stderr` al modelo y aborta la herramienta; el resto siempre sale `0`.
27
+ > Cuatro bloqueantes (`gitflow-guard`, `stack-guard`, `scaffold-guard`, `design-source-guard`) y nueve informativos. Los bloqueantes usan `exit 2`: Claude Code devuelve el `stderr` al modelo y aborta la herramienta; el resto siempre sale `0`.
28
+ >
29
+ > `lib/state-io.sh` no es un hook: es el **helper compartido** (`state_path`, `config_get`, `state_atomic_patch`) que usan `context-monitor.sh` y otros scripts para resolver la ruta del estado, leer `config/build-config.json` y escribir patches atómicos.
24
30
 
25
31
  ---
26
32
 
@@ -89,6 +95,22 @@ Cierra el **ciclo autocorrectivo**. Al terminar el turno, si en `history[]` hay
89
95
 
90
96
  Refuerza el **seguro de fuente de diseño** (espejo de `scaffold-guard`, para slices con UI). Bloquea con `exit 2` la escritura de **código de un slice con UI** —cuando `active_slice.phase` ∈ `red`/`green`/`refactor`/`smoke`/`api`/`data` **y** `active_slice.gates.fidelity === false` (marcado UI-pendiente por la DoR)— mientras el proyecto tenga UI (`design_source.applies === true`) y `design_source.confirmed` no sea `true`. **Permite** todo lo demás: slices sin UI (`gates.fidelity === null` o ausente), proyectos sin UI (`applies !== true`), fases de planificación (`dor`/`change`), o fuente ya confirmada. El arnés **exige** la fuente de diseño pero **no genera** el prototipo; la confirmación es **explícita** (vía `building-a-slice` Fase 0-bis / `dor-dod-gatekeeper`), nunca auto-detectada. Guarda `python3` fail-closed dirigido. En la práctica, como `design_source` es gate de proyecto, solo muerde la **primera** construcción de UI sin fuente declarada.
91
97
 
98
+ ### 10. `release-gate-nudge.sh` — `Stop` · no bloqueante (desde v0.7.0)
99
+
100
+ Cierra el lazo del **outer loop**. Al terminar el turno, cuenta las épicas **archivadas** (`history[].epica`) que **ningún** release ha cubierto todavía (`releases[].epicas`); si quedan **≥2 épicas sin auditar**, imprime un *nudge* sugiriendo ejecutar `/build:release` (o la skill `releasing-a-version`) para correr los gates pesados **una sola vez** sobre el diff acumulado. Es puramente determinista: **aritmética de conjuntos** (archivadas − cubiertas) independiente de *timestamps*, sin consultar fechas ni el criterio de "cierre de línea de release" (eso lo computa la skill `building-a-slice` en su fase de cierre). **Nunca bloquea** el cierre, **nunca** ejecuta trabajo pesado, **nunca** llama al modelo ni escribe el estado; si falta `python3` o el estado, sale `0` en silencio (**fail-open**).
101
+
102
+ ### 11. `statusline-bridge.sh` — comando `statusLine` · no bloqueante (desde v0.8.0)
103
+
104
+ Es el motor de contexto visto desde la barra de estado. No es un hook de evento: es el comando que Claude Code invoca para renderizar el `statusLine` (solo canal CLI; el plugin no puede inyectar `statusLine`). Lee el payload por `stdin`, calcula `remaining_pct` desde `context_window.remaining_percentage` y escribe atómicamente el archivo-puente `claude-ctx-<session_id>.json` (nombre de sesión saneado contra *path traversal*) para que `context-monitor.sh` lo consuma. Imprime siempre una línea (`🏗️ build` o `🏗️ build · ctx N%`); si falta `python3` o el payload no trae el dato, degrada sin romper la barra (**fail-open**).
105
+
106
+ ### 12. `context-monitor.sh` — `PostToolUse` / `PreCompact` / `Stop` · no bloqueante (desde v0.8.0)
107
+
108
+ Es la vigilancia de **presión de contexto**. Se dispara tras herramientas (`PostToolUse`, matcher `Bash|Edit|Write|MultiEdit|Task`), antes de compactar (`PreCompact`) y al cerrar el turno (`Stop`). Lee el puente que escribió `statusline-bridge.sh` (ignora lecturas más viejas que `context.stale_seconds`) y compara `remaining_pct` contra `context.warning_pct`/`context.critical_pct` (leídos vía `lib/state-io.sh#config_get`, con defaults 35/25). En `warning` inyecta `additionalContext` pidiendo buscar un punto de corte natural. En `critical`, y **una sola vez por sesión** (`active_slice.session_continuity.critical_recorded`), escribe el handoff en `build-state.json`: `stopped_at`, `resume_hint` (a partir de los items `failing` de `wiring_checklist`) y un hito en `progress_log[]`; si `context.auto_checkpoint` está activo, lo señala para continuar en sesión fresca sin pedir confirmación. **Nunca bloquea**; sin `python3`, sin puente, o con el puente desactualizado, sale `0` en silencio (**fail-open**).
109
+
110
+ ### 13. `reconcile-build-state.py` — `SessionStart` (invocado por `load-build-state.sh`) · no bloqueante (desde v0.8.0)
111
+
112
+ Es el único hook en Python puro (los demás son bash que delegan fragmentos a `python3`). No se cablea como entrada independiente en `settings.json`/`hooks/build-harness.json`: `load-build-state.sh` lo invoca al arrancar, **antes** de leer el estado, para "anclarlo a la realidad" de git y de los tests. Deriva, nunca lanza: (1) degrada a `failing` cualquier item de `wiring_checklist` marcado `passing` sin `evidence` no vacía (self-heal contra falsos positivos); (2) detecta *drift* entre la rama git real y `active_slice.branch` y lo **anota** en `branch_drift` (no lo corrige); (3) nunca revierte un gate booleano de `true` a `false` (ratchet: solo una señal explícita lo haría). Escribe atómico (`tempfile` + `os.replace`) solo si algo cambió. Fail-open: cualquier error de lectura o parseo devuelve `0` sin tocar el archivo.
113
+
92
114
  ---
93
115
 
94
116
  ## La cadena de comando única (sin doble disparo entre canales)
@@ -113,7 +135,7 @@ Como la cadena es **carácter por carácter idéntica** en ambos canales, si el
113
135
  Los hooks parsean el JSON del evento con `python3`. Qué pasa si **falta** `python3` depende de si el hook es bloqueante:
114
136
 
115
137
  - **Bloqueantes** (`gitflow-guard`, `stack-guard`, `scaffold-guard`, `design-source-guard`) → **fail-closed dirigido**: si no pueden analizar el comando/edición, solo bloquean (`exit 2`) cuando la entrada *cruda* parece relevante (un `git commit`/`push`, una edición que menciona `package.json`, o código de slice sin scaffold/fuente de diseño confirmados); en cualquier otro caso salen `0`. Esto evita falsos negativos peligrosos sin frenar el trabajo no relacionado. El mensaje pide instalar `python3` (verificable con `trycore-build doctor`).
116
- - **No bloqueantes** (`load-build-state`, `lint-typecheck`, `coherence-flag`, `build-gate-check`, `reflect-nudge`) → si falta `python3`, simplemente **omiten** su trabajo y salen `0` (fail-open).
138
+ - **No bloqueantes** (`load-build-state`, `lint-typecheck`, `coherence-flag`, `build-gate-check`, `reflect-nudge`, `release-gate-nudge`) → si falta `python3`, simplemente **omiten** su trabajo y salen `0` (fail-open).
117
139
 
118
140
  > `python3` es un **requisito duro**: `trycore-build init` y `trycore-build doctor` **fallan** si no está presente, justo porque toda la cadena de hooks depende de él para leer el JSON del evento.
119
141
 
@@ -134,6 +156,7 @@ Los hooks que tocan el código construido se **auto-arman**: permanecen **inerte
134
156
  | `design-source-guard.sh` | Permite (sin slice UI en fases de código, o sin `design_source.applies=true`, no hay nada que bloquear). |
135
157
  | `build-gate-check.sh` | Inerte (sale `0` de inmediato). |
136
158
  | `reflect-nudge.sh` | Silencioso (en `authoring` no hay slices archivados que reflexionar). |
159
+ | `release-gate-nudge.sh` | Silencioso (en `authoring` no hay épicas archivadas que auditar). |
137
160
 
138
161
  Así el arnés convive sin fricción con la fase de *discovery* y se "enciende" cuando empieza la construcción real.
139
162
 
@@ -147,7 +170,7 @@ Una sola definición de hooks, expresada en dos archivos espejo según el canal:
147
170
 
148
171
  `trycore-build init` hace un **merge idempotente y aditivo** en el `settings.json` del consumidor (lógica en `src/lib/settings-merge.ts`; espejo documental en `templates/settings-hooks.template.json`):
149
172
 
150
- - Agrega las 5 agrupaciones de hooks (las 9 invocaciones: `PreToolUse·Write` agrupa `stack-guard` + `scaffold-guard` + `design-source-guard`; `PostToolUse·Write` agrupa `lint-typecheck` + `coherence-flag`; `Stop` agrupa `build-gate-check` + `reflect-nudge`) sin pisar lo que ya exista.
173
+ - Agrega las 5 agrupaciones de hooks (10 invocaciones: `PreToolUse·Write` agrupa `stack-guard` + `scaffold-guard` + `design-source-guard`; `PostToolUse·Write` agrupa `lint-typecheck` + `coherence-flag`; `Stop` agrupa `build-gate-check` + `reflect-nudge` + `release-gate-nudge`) sin pisar lo que ya exista. Ambos canales (CLI y plugin) cablean las **mismas 10 invocaciones**.
151
174
  - Agrega **permisos mínimos y enumerados** (sin `mcp__*` ni rutas absolutas):
152
175
 
153
176
  ```
@@ -160,6 +183,6 @@ Una sola definición de hooks, expresada en dos archivos espejo según el canal:
160
183
 
161
184
  ### Canal plugin — `hooks/build-harness.json`
162
185
 
163
- El plugin declara los hooks en `hooks/build-harness.json` (referenciado desde `.claude-plugin/plugin.json` con `"hooks": "./hooks/build-harness.json"`). El contenido es **equivalente** al bloque que mergea el CLI y usa la **misma cadena de comando**.
186
+ El plugin declara los hooks en `hooks/build-harness.json` (referenciado desde `.claude-plugin/plugin.json` con `"hooks": "./hooks/build-harness.json"`) y usa la **misma cadena de comando**. Es el bloque más completo: su grupo `Stop` cablea las **3** invocaciones (`build-gate-check` + `reflect-nudge` + `release-gate-nudge`), por lo que el canal plugin suma las **10** invocaciones. La única diferencia con el merge del CLI es ese `release-gate-nudge.sh`; las otras 4 agrupaciones son idénticas. Si un consumidor instala ambos canales, las cadenas comunes se deduplican (ver arriba) y `release-gate-nudge.sh` lo aporta el plugin.
164
187
 
165
188
  > **Caveat de canales:** el canal CLI es el **canónico** para operar en un proyecto. El plugin namespacea los componentes bajo el nombre del plugin (`/trycore-spec-build-harness:*`) por diseño de Claude Code; las cross-references internas (skills que invocan `/opsx:*`, agentes por nombre) están escritas para el canal CLI. Los **hooks**, en cambio, son idénticos en ambos canales y se deduplican si coexisten.
@@ -4,7 +4,7 @@
4
4
  # No bloquea: al cerrar el turno, avisa si hay un slice activo con gates abiertos.
5
5
  set -uo pipefail
6
6
 
7
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
7
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
8
8
  [ -f "$ROOT/package.json" ] || exit 0
9
9
  STATE="$ROOT/.claude/state/build-state.json"
10
10
  [ -f "$STATE" ] || exit 0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # context-monitor.sh — PostToolUse|PreCompact|Stop. Lee el puente, aplica umbrales,
3
+ # inyecta additionalContext y (en critical) escribe handoff. Fail-open.
4
+ set -uo pipefail
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ source "$HERE/lib/state-io.sh"
7
+ payload="$(cat)"; command -v python3 >/dev/null 2>&1 || exit 0
8
+
9
+ read -r SID EVT <<<"$(printf '%s' "$payload" | python3 -c '
10
+ import json,sys
11
+ try: p=json.load(sys.stdin)
12
+ except Exception: p={}
13
+ print(p.get("session_id","default"), p.get("hook_event_name","PostToolUse"))
14
+ ' 2>/dev/null)"
15
+ SID="${SID:-default}"; EVT="${EVT:-PostToolUse}"
16
+
17
+ sanitized_sid="$(printf '%s' "$SID" | tr -c 'A-Za-z0-9_-' '_')"
18
+ BRIDGE="${TMPDIR:-/tmp}/claude-ctx-$sanitized_sid.json"
19
+ [ -f "$BRIDGE" ] || BRIDGE="$(command ls "${TMPDIR:-/tmp}"/claude-ctx-*.json 2>/dev/null | head -1)"
20
+ [ -n "${BRIDGE:-}" ] && [ -f "$BRIDGE" ] || exit 0
21
+
22
+ WARN="$(config_get context.warning_pct 35)"; CRIT="$(config_get context.critical_pct 25)"
23
+ STALE="$(config_get context.stale_seconds 60)"; AUTO="$(config_get context.auto_checkpoint false)"
24
+ STATE="$(state_path)"
25
+
26
+ python3 - "$BRIDGE" "$WARN" "$CRIT" "$STALE" "$EVT" "$STATE" "$AUTO" <<'PY' 2>/dev/null || true
27
+ import json,sys,os,time,tempfile
28
+ bridge,warn,crit,stale,evt,state,auto=sys.argv[1:8]
29
+ warn,crit,stale=int(warn),int(crit),int(stale)
30
+ try: b=json.load(open(bridge))
31
+ except Exception: sys.exit(0)
32
+ if time.time()-b.get("ts",0) > stale: sys.exit(0) # métrica vieja
33
+ rem=b.get("remaining_pct",100)
34
+ sev = "critical" if rem<=crit else ("warning" if rem<=warn else None)
35
+ if not sev: sys.exit(0)
36
+
37
+ # ¿slice activo? handoff solo en critical y una vez por sesión.
38
+ recorded=False
39
+ if sev=="critical" and os.path.exists(state):
40
+ try:
41
+ d=json.load(open(state)); s=d.get("active_slice")
42
+ if s:
43
+ sc=s.setdefault("session_continuity",{})
44
+ if not sc.get("critical_recorded"):
45
+ iso=time.strftime("%Y-%m-%dT%H:%M:%SZ",time.gmtime())
46
+ sc["stopped_at"]=f"context exhaustion at {rem}% ({iso})"
47
+ failing=[w["id"] for w in (s.get("wiring_checklist") or []) if w.get("status")=="failing"]
48
+ sc["resume_hint"]=("cablear: "+", ".join(failing[:6])) if failing else "revisar progreso y continuar"
49
+ sc["critical_recorded"]=True
50
+ sc["auto_continue"]=(auto=="true")
51
+ s.setdefault("progress_log",[]).append(
52
+ {"at":iso,"by":"context-monitor","note":f"handoff auto a {rem}% de contexto restante"})
53
+ dirn=os.path.dirname(state) or "."
54
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".build-state.",suffix=".tmp")
55
+ try:
56
+ with os.fdopen(fd,"w") as o: json.dump(d,o,indent=2,ensure_ascii=False); o.flush(); os.fsync(o.fileno())
57
+ os.replace(tmp,state); recorded=True
58
+ except Exception:
59
+ try: os.unlink(tmp)
60
+ except OSError: pass
61
+ raise
62
+ except Exception: pass
63
+
64
+ if sev=="warning":
65
+ msg=(f"⚠️ Contexto al {rem}% restante. Acércate a un punto natural de corte "
66
+ "(fin de fase/gate). No inicies trabajo complejo nuevo.")
67
+ else:
68
+ tail=(" Handoff escrito en build-state.json (session_continuity)." if recorded else "")
69
+ cont=(" auto_checkpoint=ON: continúa en sesión fresca." if auto=="true" else
70
+ " Avisa al usuario para reiniciar en un punto natural.")
71
+ msg=(f"🛑 Contexto CRÍTICO al {rem}% restante.{tail} El estado ya vive en build-state.json;"
72
+ f" no reescribas handoff manual.{cont}")
73
+ print(json.dumps({"hookSpecificOutput":{"hookEventName":evt,"additionalContext":msg}}))
74
+ PY
@@ -8,7 +8,7 @@
8
8
  # AUTO-ARME: si no existe build-state.json, exit 0.
9
9
  set -uo pipefail
10
10
 
11
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
11
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
12
12
  STATE="$ROOT/.claude/state/build-state.json"
13
13
  [ -f "$STATE" ] || exit 0
14
14
 
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # state-io.sh — helpers compartidos: rutas, lectura de config y patch atómico del estado.
3
+ # Diseño: fail-open. Nunca lanza; ante fallo deja el archivo intacto.
4
+
5
+ state_path() {
6
+ # CLAUDE_PROJECT_DIR es la señal autoritativa que Claude Code fija en los hooks;
7
+ # git rev-parse solo aplica como fallback para invocación manual/dev (sin ese env).
8
+ local root; root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
9
+ echo "$root/.claude/state/build-state.json"
10
+ }
11
+
12
+ # config_get <clave.punteada> <default>
13
+ config_get() {
14
+ local key="$1" def="$2" file
15
+ file="${BUILD_CONFIG_FILE:-$(dirname "$(state_path)")/../config/build-config.json}"
16
+ command -v python3 >/dev/null 2>&1 || { echo "$def"; return; }
17
+ python3 - "$file" "$key" "$def" <<'PY' 2>/dev/null || echo "$def"
18
+ import json,sys
19
+ file,key,default=sys.argv[1],sys.argv[2],sys.argv[3]
20
+ try:
21
+ d=json.load(open(file))
22
+ for part in key.split("."): d=d[part]
23
+ print(d if not isinstance(d,bool) else str(d).lower())
24
+ except Exception:
25
+ print(default)
26
+ PY
27
+ }
28
+
29
+ # state_atomic_patch <state-file> <expr-python-que-muta-`d`>
30
+ # Ejecuta la expresión con `d` = dict del estado; escribe atómico solo si válido.
31
+ state_atomic_patch() {
32
+ local file="$1" expr="$2"
33
+ [ -f "$file" ] || return 0
34
+ command -v python3 >/dev/null 2>&1 || return 0
35
+ python3 - "$file" "$expr" <<'PY' 2>/dev/null || true
36
+ import json,sys,os,tempfile
37
+ path,expr=sys.argv[1],sys.argv[2]
38
+ try:
39
+ d=json.load(open(path))
40
+ if not isinstance(d,dict): sys.exit(0)
41
+ exec(expr, {}, {"d":d})
42
+ dirn=os.path.dirname(path) or "."
43
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".build-state.",suffix=".tmp")
44
+ try:
45
+ with os.fdopen(fd,"w") as out:
46
+ json.dump(d,out,indent=2,ensure_ascii=False); out.flush(); os.fsync(out.fileno())
47
+ os.replace(tmp,path)
48
+ except Exception:
49
+ try: os.unlink(tmp)
50
+ except OSError: pass
51
+ raise
52
+ except Exception:
53
+ pass
54
+ PY
55
+ }
@@ -8,7 +8,7 @@
8
8
  # coste por edición no escale con el tamaño del repo.
9
9
  set -uo pipefail
10
10
 
11
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
11
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
12
12
  [ -f "$ROOT/package.json" ] || exit 0 # guard de auto-arme
13
13
 
14
14
  # Guarda python3 [H4]: hook no-bloqueante; sin python3 no podemos extraer el archivo → omite.
@@ -3,14 +3,53 @@
3
3
  # Inyecta al contexto: rama actual, harness_phase, slice activo y gates abiertos.
4
4
  # Además sincroniza harness_phase (authoring -> active) según exista package.json.
5
5
  set -uo pipefail
6
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/state-io.sh"
6
7
 
7
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
8
- STATE="$ROOT/.claude/state/build-state.json"
8
+ # Lee el payload de SessionStart (JSON en stdin) para extraer session_id. Fail-open:
9
+ # si no hay stdin o no trae session_id, seguimos sin tocar session_continuity.
10
+ payload="$(cat 2>/dev/null || true)"
11
+
12
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
13
+ STATE="$(state_path)"
9
14
  BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo 'desconocida')"
10
15
 
11
16
  # Determina la fase real del arnés.
12
17
  if [ -f "$ROOT/package.json" ]; then PHASE="active"; else PHASE="authoring"; fi
13
18
 
19
+ # Ancla el estado a disco antes de leerlo (fail-open; nunca rompe SessionStart).
20
+ if [ -f "$STATE" ] && command -v python3 >/dev/null 2>&1; then
21
+ python3 "$(dirname "${BASH_SOURCE[0]}")/reconcile-build-state.py" "$STATE" 2>/dev/null || true
22
+ fi
23
+
24
+ # Reset once-per-SESSION del guard de auto-handoff (A1): si esta sesión (session_id del
25
+ # payload de SessionStart) es distinta de la última registrada en session_continuity,
26
+ # reabrimos critical_recorded para que context-monitor.sh pueda volver a escribir handoff
27
+ # fresco en ESTA sesión. Fail-open: sin stdin/session_id/state, no se toca nada.
28
+ if [ -f "$STATE" ] && command -v python3 >/dev/null 2>&1; then
29
+ SESSION_ID="$(printf '%s' "$payload" | python3 -c '
30
+ import json,sys
31
+ try:
32
+ p=json.load(sys.stdin)
33
+ sid=p.get("session_id","")
34
+ print(sid if isinstance(sid,str) else "")
35
+ except Exception:
36
+ print("")
37
+ ' 2>/dev/null || true)"
38
+ if [ -n "$SESSION_ID" ]; then
39
+ SID_JSON="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$SESSION_ID" 2>/dev/null || true)"
40
+ if [ -n "$SID_JSON" ]; then
41
+ state_atomic_patch "$STATE" "
42
+ s = d.get('active_slice')
43
+ if isinstance(s, dict):
44
+ sc = s.setdefault('session_continuity', {})
45
+ if sc.get('last_session') != $SID_JSON:
46
+ sc['last_session'] = $SID_JSON
47
+ sc['critical_recorded'] = False
48
+ "
49
+ fi
50
+ fi
51
+ fi
52
+
14
53
  # Sincroniza harness_phase en el estado (si python3 disponible y el archivo existe).
15
54
  # Escritura ATÓMICA + validada: este hook corre en CADA SessionStart (alta frecuencia,
16
55
  # headless incluido); una escritura no atómica que se interrumpa truncaría la ÚNICA
@@ -79,6 +118,11 @@ else:
79
118
  if log:
80
119
  last=log[-1]
81
120
  print(f" Última bitácora: [{last.get('by')}] {last.get('note')}")
121
+ sc=s.get("session_continuity") or {}
122
+ if sc.get("resume_hint"):
123
+ print(f" ▶️ Retomar: {sc.get('resume_hint')}" + (" [auto-continue]" if sc.get("auto_continue") else ""))
124
+ if s.get("branch_drift"):
125
+ print(f" ⚠️ Rama real ({s.get('branch_drift')}) != branch del slice ({s.get('branch')}).")
82
126
  PY
83
127
  fi
84
128
  exit 0
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """reconcile-build-state.py — ancla el estado a la realidad (git + tests). Fail-open.
3
+
4
+ Deriva/reconcilia, nunca lanza:
5
+ - wiring_checklist: un item 'passing' sin 'evidence' se degrada a 'failing' (self-heal).
6
+ - branch_drift: si la rama git real != active_slice.branch, lo anota (no corrige).
7
+ - ratchet: no revierte gates booleanos true->false (solo señal explícita lo haría).
8
+ Uso: reconcile-build-state.py [<state-file>]
9
+ """
10
+ import json, os, sys, subprocess, tempfile
11
+
12
+ def git_branch(root):
13
+ try:
14
+ return subprocess.run(["git","-C",root,"rev-parse","--abbrev-ref","HEAD"],
15
+ capture_output=True,text=True,timeout=5).stdout.strip() or None
16
+ except Exception:
17
+ return None
18
+
19
+ def main():
20
+ path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
21
+ os.environ.get("CLAUDE_PROJECT_DIR",os.getcwd()), ".claude","state","build-state.json")
22
+ try:
23
+ with open(path) as fh:
24
+ d = json.load(fh)
25
+ except Exception:
26
+ return 0 # fail-open
27
+ if not isinstance(d, dict):
28
+ return 0
29
+ changed = False
30
+ s = d.get("active_slice")
31
+ if isinstance(s, dict):
32
+ try:
33
+ # 1) wiring: passing sin evidencia -> failing
34
+ wc = s.get("wiring_checklist")
35
+ wc = wc if isinstance(wc, list) else []
36
+ for w in wc:
37
+ if not isinstance(w, dict):
38
+ continue
39
+ if w.get("status") == "passing" and not (w.get("evidence") or "").strip():
40
+ w["status"] = "failing"; changed = True
41
+ # 2) branch drift
42
+ root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(path))))
43
+ rb = git_branch(root)
44
+ if rb and s.get("branch") and rb != s["branch"] and rb not in ("HEAD",):
45
+ if s.get("branch_drift") != rb:
46
+ s["branch_drift"] = rb; changed = True
47
+ elif s.get("branch_drift") and rb == s.get("branch"):
48
+ s.pop("branch_drift", None); changed = True
49
+ except Exception:
50
+ pass
51
+ if changed:
52
+ try:
53
+ dirn = os.path.dirname(path) or "."
54
+ fd, tmp = tempfile.mkstemp(dir=dirn, prefix=".build-state.", suffix=".tmp")
55
+ except Exception:
56
+ return 0
57
+ try:
58
+ with os.fdopen(fd,"w") as o:
59
+ json.dump(d,o,indent=2,ensure_ascii=False); o.flush(); os.fsync(o.fileno())
60
+ os.replace(tmp, path)
61
+ sys.stderr.write("reconcile: estado re-anclado a disco\n")
62
+ except Exception:
63
+ try:
64
+ os.unlink(tmp)
65
+ except OSError:
66
+ pass
67
+ return 0
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
@@ -5,7 +5,7 @@
5
5
  # Determinista y barato: el razonamiento (qué se aprendió) lo hace el MODELO en /build:reflect.
6
6
  set -uo pipefail
7
7
 
8
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
8
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
9
9
  STATE="$ROOT/.claude/state/build-state.json"
10
10
  [ -f "$STATE" ] || exit 0
11
11
  command -v python3 >/dev/null 2>&1 || exit 0 # fail-open: jamás impide cerrar sesión
@@ -8,7 +8,7 @@
8
8
  # (lo computa la skill building-a-slice en la fase 8).
9
9
  set -uo pipefail
10
10
 
11
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
11
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
12
12
  STATE="$ROOT/.claude/state/build-state.json"
13
13
  [ -f "$STATE" ] || exit 0
14
14
  command -v python3 >/dev/null 2>&1 || exit 0 # fail-open: jamás impide cerrar sesión
@@ -7,7 +7,7 @@
7
7
  # AUTO-ARME: si no existe build-state.json, no hay nada que vigilar -> exit 0.
8
8
  set -uo pipefail
9
9
 
10
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
10
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
11
11
  STATE="$ROOT/.claude/state/build-state.json"
12
12
  [ -f "$STATE" ] || exit 0
13
13
 
@@ -5,7 +5,7 @@
5
5
  # dependencias fuera de .claude/config/stack-allowlist.json (el contrato de stack del PRD).
6
6
  set -uo pipefail
7
7
 
8
- ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-$(pwd)}")"
8
+ ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
9
9
  ALLOW="$ROOT/.claude/config/stack-allowlist.json"
10
10
  [ -f "$ALLOW" ] || exit 0
11
11
 
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # statusline-bridge.sh — statusLine command (canal CLI). Imprime la línea de estado
3
+ # y escribe el puente de contexto que lee context-monitor.sh. Fail-open siempre.
4
+ set -uo pipefail
5
+ payload="$(cat)"
6
+ command -v python3 >/dev/null 2>&1 || { echo "🏗️ build"; exit 0; }
7
+ echo "$payload" | python3 -c '
8
+ import json,sys,os,time,re,tempfile
9
+ try:
10
+ p=json.load(sys.stdin)
11
+ except Exception:
12
+ print("🏗️ build"); sys.exit(0)
13
+ sid=str(p.get("session_id","default"))
14
+ if re.search(r"[\\/]|\.\.", sid): sid=re.sub(r"[^A-Za-z0-9_-]","_",sid) # sanitiza path traversal
15
+ rem=None
16
+ try: rem=int(p.get("context_window",{}).get("remaining_percentage"))
17
+ except Exception: rem=None
18
+ if rem is not None:
19
+ d={"remaining_pct":rem,"used_pct":100-rem,"ts":int(time.time())}
20
+ path=os.path.join(tempfile.gettempdir(), f"claude-ctx-{sid}.json")
21
+ try:
22
+ fd,tmp=tempfile.mkstemp(dir=tempfile.gettempdir(),prefix=".ctx-",suffix=".tmp")
23
+ try:
24
+ with os.fdopen(fd,"w") as f: json.dump(d,f)
25
+ os.replace(tmp,path)
26
+ except Exception:
27
+ try: os.unlink(tmp)
28
+ except OSError: pass
29
+ except Exception: pass
30
+ tag = f"🏗️ build · ctx {rem}%" if rem is not None else "🏗️ build"
31
+ print(tag)
32
+ ' 2>/dev/null || echo "🏗️ build"
@@ -52,6 +52,26 @@
52
52
  "command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/coherence-flag.sh\""
53
53
  }
54
54
  ]
55
+ },
56
+ {
57
+ "matcher": "Bash|Edit|Write|MultiEdit|Task",
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/context-monitor.sh\""
62
+ }
63
+ ]
64
+ }
65
+ ],
66
+ "PreCompact": [
67
+ {
68
+ "matcher": ".*",
69
+ "hooks": [
70
+ {
71
+ "type": "command",
72
+ "command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/context-monitor.sh\""
73
+ }
74
+ ]
55
75
  }
56
76
  ],
57
77
  "Stop": [
@@ -69,6 +89,10 @@
69
89
  {
70
90
  "type": "command",
71
91
  "command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/release-gate-nudge.sh\""
92
+ },
93
+ {
94
+ "type": "command",
95
+ "command": "\"${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR/.claude}/hooks/build/context-monitor.sh\""
72
96
  }
73
97
  ]
74
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Arnés agéntico de construcción de Trycore para Claude Code: pipeline de dos loops (slice por épica + release gate) con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """front-plan.py — selecciona el conjunto disjunto máximo de épicas no fundacionales.
3
+
4
+ stdin: JSON [{"epica","layer","files_scope":[glob]}]
5
+ stdout: {"selected":[...],"serialized":[{"epica","reason"}],"excluded_foundational":[...]}
6
+ Determinista: orden por 'epica'. Solape de globs por prefijo de directorio o glob idéntico.
7
+ """
8
+ import json, sys, fnmatch
9
+
10
+ def overlap(a, b):
11
+ for ga in a:
12
+ for gb in b:
13
+ if ga == gb or fnmatch.fnmatch(ga, gb) or fnmatch.fnmatch(gb, ga):
14
+ return True
15
+ pa, pb = ga.split("*", 1)[0], gb.split("*", 1)[0]
16
+ # Fail closed: a leading/empty-prefix wildcard could match anything → assume overlap.
17
+ if not pa or not pb:
18
+ return True
19
+ if pa.startswith(pb) or pb.startswith(pa):
20
+ return True
21
+ return False
22
+
23
+ def _empty_result():
24
+ return {"selected": [], "serialized": [], "excluded_foundational": []}
25
+
26
+ def main():
27
+ try:
28
+ cands = json.load(sys.stdin)
29
+ if not isinstance(cands, list) or not all(isinstance(c, dict) for c in cands):
30
+ raise ValueError("entrada inválida: se esperaba una lista de objetos")
31
+ cands = sorted(cands, key=lambda c: c.get("epica",""))
32
+ excluded = [c["epica"] for c in cands if c.get("layer") == "foundational"]
33
+ pool = [c for c in cands if c.get("layer") != "foundational"]
34
+ selected, sel_scopes, serialized = [], [], []
35
+ for c in pool:
36
+ sc = c.get("files_scope") or []
37
+ if any(overlap(sc, s) for s in sel_scopes):
38
+ serialized.append({"epica": c["epica"], "reason": "solape de files_scope con épica seleccionada"})
39
+ else:
40
+ selected.append(c["epica"]); sel_scopes.append(sc)
41
+ print(json.dumps({"selected":selected,"serialized":serialized,"excluded_foundational":excluded}))
42
+ except Exception:
43
+ print(json.dumps(_empty_result()))
44
+ return 0
45
+
46
+ if __name__ == "__main__":
47
+ sys.exit(main())
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+ # smoke-test.sh — corre los tests de humo del motor de contexto y front.
3
+ set -uo pipefail
4
+ DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ FAILED=0
6
+
7
+ echo "── Tests del motor de contexto y front ──"
8
+ for t in test-schema.sh test-reconciler.sh test-context-monitor.sh test-front-plan.sh test-install.sh; do
9
+ if bash "$DIR/tests/$t"; then echo "smoke: $t OK"; else echo "smoke: $t FALLÓ"; FAILED=1; fi
10
+ done
11
+
12
+ exit $FAILED