@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.
- package/.claude-plugin/plugin.json +1 -1
- package/GOVERNANCE.md +3 -3
- package/INSTALL.md +7 -7
- package/METODOLOGIA.md +44 -0
- package/README.md +63 -5
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +6 -0
- package/commands/build/front.md +15 -0
- package/commands/build/resume.md +29 -0
- package/config/build-config.template.json +8 -0
- package/dist/commands/init.js +15 -5
- package/dist/commands/status.js +1 -0
- package/dist/commands/uninstall.js +2 -1
- package/dist/lib/paths.js +7 -0
- package/dist/lib/settings-merge.js +29 -2
- package/dist/lib/state-seed.js +14 -0
- package/docs/agents.md +20 -13
- package/docs/commands.md +34 -4
- package/docs/customization/mcp-extensions.md +5 -4
- package/docs/decisiones/2026-07-03-gsd-vs-openspec-fork-vs-rama.md +157 -0
- package/docs/flujo-harness-funcional.md +42 -0
- package/docs/flujo-harness.md +192 -0
- package/docs/getting-started.md +6 -5
- package/docs/hooks.md +31 -8
- package/hooks/build/build-gate-check.sh +1 -1
- package/hooks/build/context-monitor.sh +74 -0
- package/hooks/build/design-source-guard.sh +1 -1
- package/hooks/build/lib/state-io.sh +55 -0
- package/hooks/build/lint-typecheck.sh +1 -1
- package/hooks/build/load-build-state.sh +46 -2
- package/hooks/build/reconcile-build-state.py +70 -0
- package/hooks/build/reflect-nudge.sh +1 -1
- package/hooks/build/release-gate-nudge.sh +1 -1
- package/hooks/build/scaffold-guard.sh +1 -1
- package/hooks/build/stack-guard.sh +1 -1
- package/hooks/build/statusline-bridge.sh +32 -0
- package/hooks/build-harness.json +24 -0
- package/package.json +1 -1
- package/scripts/lib/front-plan.py +47 -0
- package/scripts/smoke-test.sh +12 -0
- package/scripts/tests/test-context-monitor.sh +70 -0
- package/scripts/tests/test-front-plan.sh +38 -0
- package/scripts/tests/test-install.sh +89 -0
- package/scripts/tests/test-reconciler.sh +54 -0
- package/scripts/tests/test-schema.sh +58 -0
- package/skills/building-a-slice/references/dor.md +2 -0
- package/skills/managing-parallel-front/SKILL.md +36 -0
- package/state/build-state.schema.json +50 -0
- package/templates/CLAUDE.md.template +6 -2
- package/templates/settings-hooks.template.json +1 -1
- 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 **
|
|
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
|
|
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
|
+
## 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
|
|
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 (
|
|
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"`)
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
8
|
-
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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"
|
package/hooks/build-harness.json
CHANGED
|
@@ -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.
|
|
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
|