@trycore/spec-build-harness 0.7.1 → 0.8.1

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 (42) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +3 -3
  3. package/METODOLOGIA.md +44 -0
  4. package/README.md +60 -5
  5. package/VERSION +1 -1
  6. package/agents/build/build-orchestrator.md +6 -0
  7. package/commands/build/front.md +15 -0
  8. package/commands/build/resume.md +29 -0
  9. package/config/build-config.template.json +8 -0
  10. package/dist/commands/init.js +15 -5
  11. package/dist/commands/status.js +1 -0
  12. package/dist/commands/uninstall.js +2 -1
  13. package/dist/lib/paths.js +7 -0
  14. package/dist/lib/settings-merge.js +29 -2
  15. package/dist/lib/state-seed.js +14 -0
  16. package/docs/commands.md +15 -3
  17. package/docs/decisiones/2026-07-03-gsd-vs-openspec-fork-vs-rama.md +157 -0
  18. package/docs/hooks.md +22 -5
  19. package/hooks/build/build-gate-check.sh +1 -1
  20. package/hooks/build/context-monitor.sh +80 -0
  21. package/hooks/build/design-source-guard.sh +1 -1
  22. package/hooks/build/lib/state-io.sh +55 -0
  23. package/hooks/build/lint-typecheck.sh +1 -1
  24. package/hooks/build/load-build-state.sh +46 -2
  25. package/hooks/build/reconcile-build-state.py +70 -0
  26. package/hooks/build/reflect-nudge.sh +1 -1
  27. package/hooks/build/release-gate-nudge.sh +1 -1
  28. package/hooks/build/scaffold-guard.sh +1 -1
  29. package/hooks/build/stack-guard.sh +1 -1
  30. package/hooks/build/statusline-bridge.sh +32 -0
  31. package/hooks/build-harness.json +24 -0
  32. package/package.json +1 -1
  33. package/scripts/lib/front-plan.py +47 -0
  34. package/scripts/smoke-test.sh +12 -0
  35. package/scripts/tests/test-context-monitor.sh +106 -0
  36. package/scripts/tests/test-front-plan.sh +38 -0
  37. package/scripts/tests/test-install.sh +89 -0
  38. package/scripts/tests/test-reconciler.sh +54 -0
  39. package/scripts/tests/test-schema.sh +58 -0
  40. package/skills/building-a-slice/references/dor.md +2 -0
  41. package/skills/managing-parallel-front/SKILL.md +36 -0
  42. package/state/build-state.schema.json +50 -0
@@ -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.1",
3
+ "version": "0.8.1",
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
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ ROOT="$(git rev-parse --show-toplevel)"
4
+ fail=0
5
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
6
+ export TMPDIR="$TMP"
7
+
8
+ # bridge: escribe puente sanitizando session_id
9
+ echo '{"session_id":"ab/../cd","context_window":{"remaining_percentage":22}}' \
10
+ | bash "$ROOT/hooks/build/statusline-bridge.sh" >/dev/null
11
+ if ls "$TMP"/claude-ctx-*.json >/dev/null 2>&1; then
12
+ b="$(ls "$TMP"/claude-ctx-*.json)"
13
+ case "$b" in *..*|*/claude-ctx-ab/*) echo "FAIL bridge no sanitiza"; fail=1;; *) echo "OK bridge sanitiza";; esac
14
+ rp="$(python3 -c "import json;print(json.load(open('$b'))['remaining_pct'])")"
15
+ [ "$rp" = 22 ] && echo "OK bridge remaining_pct" || { echo "FAIL bridge remaining_pct ($rp)"; fail=1; }
16
+ else echo "FAIL bridge no escribió puente"; fail=1; fi
17
+
18
+ # monitor: a 20% (critical<25) inyecta additionalContext y escribe handoff
19
+ sid="sess1"; echo "{\"remaining_pct\":20,\"used_pct\":80,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-$sid.json"
20
+ mkdir -p "$TMP/proj/.claude/state" "$TMP/proj/.claude/config"
21
+ cat > "$TMP/proj/.claude/state/build-state.json" <<'JSON'
22
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
23
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
24
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t","progress_log":[]},
25
+ "history":[],"releases":[]}
26
+ JSON
27
+ echo '{"context":{"critical_pct":25,"warning_pct":35,"auto_checkpoint":false}}' > "$TMP/proj/.claude/config/build-config.json"
28
+ out="$(echo "{\"session_id\":\"$sid\",\"hook_event_name\":\"PostToolUse\"}" \
29
+ | CLAUDE_PROJECT_DIR="$TMP/proj" bash "$ROOT/hooks/build/context-monitor.sh")"
30
+ echo "$out" | grep -q 'additionalContext' && echo "OK monitor inyecta aviso" || { echo "FAIL monitor sin aviso"; fail=1; }
31
+ sc="$(python3 -c "import json;print(json.load(open('$TMP/proj/.claude/state/build-state.json'))['active_slice'].get('session_continuity',{}).get('critical_recorded'))" 2>/dev/null)"
32
+ [ "$sc" = True ] && echo "OK monitor handoff" || { echo "FAIL monitor handoff ($sc)"; fail=1; }
33
+
34
+ # ── Stop: no debe re-lanzar el turno en bucle (regresión CLAUDE_CODE_STOP_HOOK_BLOCK_CAP) ──
35
+ # En 'Stop' inyectar additionalContext = re-prompt (bloquea el cierre). El warning es solo un
36
+ # nudge mientras se trabaja → en 'Stop' NO debe inyectar (si no, bucle hasta el tope de 9).
37
+ sidW="sessW"; echo "{\"remaining_pct\":30,\"used_pct\":70,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-$sidW.json"
38
+ PW="$TMP/proj-warn-stop"; mkdir -p "$PW/.claude/state" "$PW/.claude/config"
39
+ cp "$TMP/proj/.claude/state/build-state.json" "$PW/.claude/state/build-state.json"
40
+ echo '{"context":{"critical_pct":25,"warning_pct":35,"auto_checkpoint":false}}' > "$PW/.claude/config/build-config.json"
41
+ outW="$(echo "{\"session_id\":\"$sidW\",\"hook_event_name\":\"Stop\"}" \
42
+ | CLAUDE_PROJECT_DIR="$PW" bash "$ROOT/hooks/build/context-monitor.sh")"
43
+ echo "$outW" | grep -q 'additionalContext' \
44
+ && { echo "FAIL warning+Stop inyecta (re-prompt → bucle)"; fail=1; } \
45
+ || echo "OK warning+Stop no inyecta"
46
+ # warning en PostToolUse (trabajando) SÍ debe seguir avisando (regresión: no romper el nudge útil).
47
+ outWP="$(echo "{\"session_id\":\"$sidW\",\"hook_event_name\":\"PostToolUse\"}" \
48
+ | CLAUDE_PROJECT_DIR="$PW" bash "$ROOT/hooks/build/context-monitor.sh")"
49
+ echo "$outWP" | grep -q 'additionalContext' \
50
+ && echo "OK warning+PostToolUse sigue avisando" \
51
+ || { echo "FAIL warning+PostToolUse dejó de avisar"; fail=1; }
52
+
53
+ # critical en 'Stop': inyecta como MUCHO una vez (la transición que graba el handoff), luego calla.
54
+ sidC="sessC"; echo "{\"remaining_pct\":20,\"used_pct\":80,\"ts\":$(date +%s)}" > "$TMP/claude-ctx-$sidC.json"
55
+ PC="$TMP/proj-crit-stop"; mkdir -p "$PC/.claude/state" "$PC/.claude/config"
56
+ cp "$TMP/proj/.claude/state/build-state.json" "$PC/.claude/state/build-state.json"
57
+ python3 -c "import json;p='$PC/.claude/state/build-state.json';d=json.load(open(p));d['active_slice'].pop('session_continuity',None);json.dump(d,open(p,'w'))"
58
+ echo '{"context":{"critical_pct":25,"warning_pct":35,"auto_checkpoint":false}}' > "$PC/.claude/config/build-config.json"
59
+ outC1="$(echo "{\"session_id\":\"$sidC\",\"hook_event_name\":\"Stop\"}" \
60
+ | CLAUDE_PROJECT_DIR="$PC" bash "$ROOT/hooks/build/context-monitor.sh")"
61
+ echo "$outC1" | grep -q 'additionalContext' \
62
+ && echo "OK critical+Stop inyecta la transición (1ª vez)" \
63
+ || { echo "FAIL critical+Stop no inyecta la transición"; fail=1; }
64
+ outC2="$(echo "{\"session_id\":\"$sidC\",\"hook_event_name\":\"Stop\"}" \
65
+ | CLAUDE_PROJECT_DIR="$PC" bash "$ROOT/hooks/build/context-monitor.sh")"
66
+ echo "$outC2" | grep -q 'additionalContext' \
67
+ && { echo "FAIL critical+Stop re-inyecta (bucle)"; fail=1; } \
68
+ || echo "OK critical+Stop NO re-inyecta (bucle roto)"
69
+
70
+ # load-build-state (SessionStart): reset once-per-SESSION del guard critical_recorded.
71
+ # Sesión NUEVA (last_session distinto del session_id del payload) -> reabre el guard y
72
+ # persiste el session_id actual.
73
+ PROJ="$TMP/proj-lbs"; mkdir -p "$PROJ/.claude/state"
74
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
75
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
76
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
77
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
78
+ "progress_log":[],"session_continuity":{"critical_recorded":true,"last_session":"old"}},
79
+ "history":[],"releases":[]}
80
+ JSON
81
+ echo '{"session_id":"new"}' | CLAUDE_PROJECT_DIR="$PROJ" bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null
82
+ cr="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice']['session_continuity']['critical_recorded'])")"
83
+ ls_="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice']['session_continuity']['last_session'])")"
84
+ [ "$cr" = False ] && echo "OK load-build-state resetea critical_recorded en sesión nueva" || { echo "FAIL critical_recorded no se reseteó ($cr)"; fail=1; }
85
+ [ "$ls_" = new ] && echo "OK load-build-state persiste last_session nuevo" || { echo "FAIL last_session no persistió ($ls_)"; fail=1; }
86
+
87
+ # Sesión IDÉNTICA (last_session == session_id del payload) -> NO toca critical_recorded.
88
+ PROJ2="$TMP/proj-lbs-same"; mkdir -p "$PROJ2/.claude/state"
89
+ cat > "$PROJ2/.claude/state/build-state.json" <<'JSON'
90
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
91
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
92
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
93
+ "progress_log":[],"session_continuity":{"critical_recorded":true,"last_session":"s1"}},
94
+ "history":[],"releases":[]}
95
+ JSON
96
+ echo '{"session_id":"s1"}' | CLAUDE_PROJECT_DIR="$PROJ2" bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null
97
+ cr2="$(python3 -c "import json;print(json.load(open('$PROJ2/.claude/state/build-state.json'))['active_slice']['session_continuity']['critical_recorded'])")"
98
+ [ "$cr2" = True ] && echo "OK load-build-state NO toca critical_recorded en misma sesión" || { echo "FAIL critical_recorded se tocó en misma sesión ($cr2)"; fail=1; }
99
+
100
+ # wiring: ambos canales cablean context-monitor en PostToolUse/PreCompact/Stop
101
+ grep -q 'PreCompact' "$ROOT/hooks/build-harness.json" && echo "OK plugin PreCompact" || { echo "FAIL plugin PreCompact"; fail=1; }
102
+ grep -q 'context-monitor.sh' "$ROOT/hooks/build-harness.json" && echo "OK plugin monitor" || { echo "FAIL plugin monitor"; fail=1; }
103
+ grep -q "PreCompact" "$ROOT/src/lib/settings-merge.ts" && echo "OK cli PreCompact" || { echo "FAIL cli PreCompact"; fail=1; }
104
+ grep -q "context-monitor.sh" "$ROOT/src/lib/settings-merge.ts" && echo "OK cli monitor" || { echo "FAIL cli monitor"; fail=1; }
105
+
106
+ exit $fail
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ ROOT="$(git rev-parse --show-toplevel)"; fail=0
4
+ run() { echo "$1" | python3 "$ROOT/scripts/lib/front-plan.py"; }
5
+
6
+ out="$(run '[{"epica":"EP-001","layer":"foundational","files_scope":["src/**"]},
7
+ {"epica":"EP-002","layer":"business","files_scope":["src/a/**"]},
8
+ {"epica":"EP-003","layer":"business","files_scope":["src/b/**"]},
9
+ {"epica":"EP-004","layer":"business","files_scope":["src/a/x.ts"]}]')"
10
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
11
+ assert d['excluded_foundational']==['EP-001'], d
12
+ assert set(d['selected'])=={'EP-002','EP-003'}, d
13
+ assert [m['epica'] for m in d['serialized']]==['EP-004'], d
14
+ print('OK front-plan disjunción')" || { echo "FAIL front-plan disjunción"; fail=1; }
15
+
16
+ for bad in 'null' '[1,2,3]' '"abc"' '{"a":1}'; do
17
+ if out="$(echo "$bad" | python3 "$ROOT/scripts/lib/front-plan.py" 2>/dev/null)" \
18
+ && echo "$out" | python3 -c "import json,sys;assert json.load(sys.stdin)['selected']==[]" 2>/dev/null; then
19
+ echo "OK front-plan fail-open ($bad)"; else echo "FAIL front-plan fail-open ($bad)"; fail=1; fi
20
+ done
21
+
22
+ out="$(echo '[{"epica":"EP-010","layer":"business","files_scope":["*a*.ts"]},
23
+ {"epica":"EP-011","layer":"business","files_scope":["*b*.ts"]}]' | python3 "$ROOT/scripts/lib/front-plan.py")"
24
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
25
+ assert d['selected']==['EP-010'] and [m['epica'] for m in d['serialized']]==['EP-011'], d
26
+ print('OK front-plan mid-wildcard overlap serializado')" || { echo "FAIL mid-wildcard overlap"; fail=1; }
27
+
28
+ SK="$ROOT/skills/managing-parallel-front/SKILL.md"
29
+ { [ -f "$SK" ] && grep -q 'git worktree add' "$SK" && grep -q 'front-plan.py' "$SK" \
30
+ && grep -q 'merge_order' "$SK" && grep -q 'foundational' "$SK"; } \
31
+ && echo "OK skill front" || { echo "FAIL skill front"; fail=1; }
32
+
33
+ { [ -f "$ROOT/commands/build/front.md" ] && grep -q 'managing-parallel-front' "$ROOT/commands/build/front.md"; } \
34
+ && echo "OK comando front" || { echo "FAIL comando front"; fail=1; }
35
+ grep -q 'files_scope' "$ROOT/skills/building-a-slice/references/dor.md" && echo "OK dor files_scope" || { echo "FAIL dor files_scope"; fail=1; }
36
+ grep -q 'parallel_front' "$ROOT/agents/build/build-orchestrator.md" && echo "OK orquestador front" || { echo "FAIL orquestador front"; fail=1; }
37
+
38
+ exit $fail
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # test-install.sh — smoke test del CANAL CLI de instalación (init/update).
3
+ # Reproduce el bug real: el instalador solo copiaba *.sh de hooks/build/ (sin
4
+ # recursar en lib/) y nunca creaba .claude/scripts/, dejando muertos en un
5
+ # install real los 3 archivos v0.8 (reconcile-build-state.py, state-io.sh,
6
+ # front-plan.py) aunque el smoke suite "en el repo" no lo detectara (los
7
+ # archivos ya existen ahí, fuera del flujo de instalación).
8
+ set -uo pipefail
9
+ ROOT="$(git rev-parse --show-toplevel)"
10
+ fail=0
11
+
12
+ # 1) Build fresco del CLI compilado (dist/cli.js) — el test corre contra el artefacto publicado.
13
+ if [ ! -f "$ROOT/dist/cli.js" ]; then
14
+ echo "ℹ dist/cli.js no existe — corriendo npm run build"
15
+ (cd "$ROOT" && npm run build) >/dev/null 2>&1
16
+ fi
17
+ if [ ! -f "$ROOT/dist/cli.js" ]; then
18
+ echo "FAIL install: npm run build no produjo dist/cli.js"
19
+ exit 1
20
+ fi
21
+
22
+ TMP="$(mktemp -d)"
23
+ trap 'rm -rf "$TMP"' EXIT
24
+
25
+ # 2) `init` no interactivo, modo copy (para no depender de symlinks al paquete),
26
+ # sin exigir openspec/python3/git en el runner (--skip-doctor).
27
+ node "$ROOT/dist/cli.js" init "$TMP" \
28
+ --copy \
29
+ --skip-doctor \
30
+ --yes \
31
+ --stack "" \
32
+ --pkg-manager npm \
33
+ --runtime ">=18.18" \
34
+ --prd-path "docs/01-prd/x.md#req" \
35
+ >"$TMP/.init.log" 2>&1
36
+ rc=$?
37
+ if [ $rc -ne 0 ]; then
38
+ echo "FAIL install: \`trycore-build init\` salió con código $rc"
39
+ cat "$TMP/.init.log"
40
+ fail=1
41
+ fi
42
+
43
+ check() { # <ruta relativa a .claude> <descripción>
44
+ local rel="$1"
45
+ local p="$TMP/.claude/$rel"
46
+ if [ -f "$p" ]; then
47
+ echo "OK install: .claude/$rel"
48
+ else
49
+ echo "FAIL install: .claude/$rel NO existe"
50
+ fail=1
51
+ fi
52
+ }
53
+
54
+ check "hooks/build/reconcile-build-state.py"
55
+ check "hooks/build/lib/state-io.sh"
56
+ check "hooks/build/context-monitor.sh"
57
+ check "hooks/build/statusline-bridge.sh"
58
+ check "scripts/lib/front-plan.py"
59
+
60
+ # 3) Bit ejecutable en los .py/.sh copiados (modo copy no preserva +x por defecto — el
61
+ # instalador debe forzarlo con chmodExec tras copiar).
62
+ checkexec() {
63
+ local rel="$1"
64
+ local p="$TMP/.claude/$rel"
65
+ if [ -f "$p" ] && [ -x "$p" ]; then
66
+ echo "OK install: .claude/$rel es ejecutable"
67
+ else
68
+ echo "FAIL install: .claude/$rel NO es ejecutable"
69
+ fail=1
70
+ fi
71
+ }
72
+ checkexec "hooks/build/reconcile-build-state.py"
73
+ checkexec "hooks/build/lib/state-io.sh"
74
+ checkexec "scripts/lib/front-plan.py"
75
+
76
+ # 4) `update` (canal de refresco tras `npm i` del paquete) también debe dejarlos — re-correr
77
+ # sobre la misma instalación no debe romper nada ni perder archivos.
78
+ node "$ROOT/dist/cli.js" update "$TMP" --copy --skip-doctor >"$TMP/.update.log" 2>&1
79
+ rc=$?
80
+ if [ $rc -ne 0 ]; then
81
+ echo "FAIL install: \`trycore-build update\` salió con código $rc"
82
+ cat "$TMP/.update.log"
83
+ fail=1
84
+ fi
85
+ check "hooks/build/reconcile-build-state.py"
86
+ check "hooks/build/lib/state-io.sh"
87
+ check "scripts/lib/front-plan.py"
88
+
89
+ exit $fail
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ ROOT="$(git rev-parse --show-toplevel)"
4
+ source "$ROOT/hooks/build/lib/state-io.sh" 2>/dev/null || { echo "FAIL no existe state-io.sh"; exit 1; }
5
+ fail=0
6
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
7
+
8
+ # config_get lee clave punteada con default
9
+ echo '{"context":{"warning_pct":40}}' > "$TMP/cfg.json"
10
+ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get context.warning_pct 35)"
11
+ [ "$got" = 40 ] && echo "OK config_get valor" || { echo "FAIL config_get valor ($got)"; fail=1; }
12
+ got="$(BUILD_CONFIG_FILE="$TMP/cfg.json" config_get context.critical_pct 25)"
13
+ [ "$got" = 25 ] && echo "OK config_get default" || { echo "FAIL config_get default ($got)"; fail=1; }
14
+
15
+ # state_atomic_patch muta y deja JSON válido
16
+ echo '{"version":"1.0","n":1}' > "$TMP/s.json"
17
+ state_atomic_patch "$TMP/s.json" 'd["n"]=2'
18
+ got="$(python3 -c "import json;print(json.load(open('$TMP/s.json'))['n'])")"
19
+ [ "$got" = 2 ] && echo "OK atomic_patch" || { echo "FAIL atomic_patch ($got)"; fail=1; }
20
+
21
+ # reconcile-build-state: degrada passing sin evidencia, conserva con evidencia, fail-open
22
+ cat > "$TMP/state.json" <<'JSON'
23
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
24
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
25
+ "gates":{"dor":true,"tdd":true,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
26
+ "wiring_checklist":[{"id":"HU-001-AC1","kind":"hu_ac","ref":"HU-001#1","status":"passing","evidence":""},
27
+ {"id":"HU-001-AC2","kind":"hu_ac","ref":"HU-001#2","status":"passing","evidence":"pytest ok"}]},
28
+ "history":[],"releases":[]}
29
+ JSON
30
+ python3 "$ROOT/hooks/build/reconcile-build-state.py" "$TMP/state.json" 2>/dev/null
31
+ st1="$(python3 -c "import json;d=json.load(open('$TMP/state.json'));print(d['active_slice']['wiring_checklist'][0]['status'])")"
32
+ st2="$(python3 -c "import json;d=json.load(open('$TMP/state.json'));print(d['active_slice']['wiring_checklist'][1]['status'])")"
33
+ [ "$st1" = failing ] && echo "OK reconcile degrada sin evidencia" || { echo "FAIL degrada ($st1)"; fail=1; }
34
+ [ "$st2" = passing ] && echo "OK reconcile conserva con evidencia" || { echo "FAIL conserva ($st2)"; fail=1; }
35
+ # fail-open: archivo corrupto no rompe
36
+ echo 'no json' > "$TMP/bad.json"
37
+ python3 "$ROOT/hooks/build/reconcile-build-state.py" "$TMP/bad.json" 2>/dev/null; echo "OK reconcile fail-open (rc=$?)"
38
+
39
+ # fail-open: wiring_checklist malformado (item no-dict) no debe crashear ni corromper el archivo
40
+ cat > "$TMP/mal.json" <<'JSON'
41
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
42
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
43
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
44
+ "wiring_checklist":["not-a-dict"]},
45
+ "history":[],"releases":[]}
46
+ JSON
47
+ if python3 "$ROOT/hooks/build/reconcile-build-state.py" "$TMP/mal.json" 2>/dev/null; then
48
+ python3 -c "import json;json.load(open('$TMP/mal.json'))" 2>/dev/null && echo "OK reconcile fail-open wiring malformado" || { echo "FAIL mal json corrupto"; fail=1; }
49
+ else echo "FAIL reconcile crashed on malformed wiring"; fail=1; fi
50
+
51
+ # SessionStart integra reconciliador
52
+ grep -q 'reconcile-build-state.py' "$ROOT/hooks/build/load-build-state.sh" && echo "OK sessionstart reconcilia" || { echo "FAIL sessionstart no reconcilia"; fail=1; }
53
+ grep -q 'resume_hint' "$ROOT/hooks/build/load-build-state.sh" && echo "OK sessionstart imprime hint" || { echo "FAIL sessionstart sin hint"; fail=1; }
54
+ exit $fail
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # test-schema.sh — valida build-state.schema.json contra ejemplos válidos/ inválidos.
3
+ set -uo pipefail
4
+ ROOT="$(git rev-parse --show-toplevel)"
5
+ SCHEMA="$ROOT/state/build-state.schema.json"
6
+ fail=0
7
+
8
+ check() { # <descripción> <esperado: pass|fail> <json-file>
9
+ python3 - "$SCHEMA" "$3" <<'PY'
10
+ import json,sys
11
+ try:
12
+ import jsonschema
13
+ except ImportError:
14
+ print("SKIP: jsonschema no instalado"); sys.exit(2)
15
+ schema=json.load(open(sys.argv[1])); data=json.load(open(sys.argv[2]))
16
+ from jsonschema import Draft202012Validator
17
+ errs=sorted(Draft202012Validator(schema).iter_errors(data), key=str)
18
+ sys.exit(1 if errs else 0)
19
+ PY
20
+ local rc=$?
21
+ if [ "$rc" = 2 ]; then echo "SKIP $1"; return; fi
22
+ if { [ "$2" = pass ] && [ "$rc" = 0 ]; } || { [ "$2" = fail ] && [ "$rc" = 1 ]; }; then
23
+ echo "OK $1"
24
+ else echo "FAIL $1 (rc=$rc)"; fail=1; fi
25
+ }
26
+
27
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
28
+ cat > "$TMP/valid.json" <<'JSON'
29
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
30
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"add-login",
31
+ "branch":"feature/login","phase":"change","layer":"business","files_scope":["src/auth/**"],
32
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"test",
33
+ "session_continuity":{"last_session":"s1","stopped_at":null,"resume_hint":"cablear AC2","critical_recorded":false,"auto_continue":false}},
34
+ "history":[],"releases":[],
35
+ "parallel_front":{"status":"active","opened_at":"2026-07-03T00:00:00Z","updated_by":"test",
36
+ "members":[{"epica":"EP-002","worktree":{"path":".wt/ep-002","branch":"feature/ep-002","created_at":"2026-07-03T00:00:00Z"},
37
+ "files_scope":["src/reports/**"],"journey_smoke":false,"merge_status":"pending"}],
38
+ "merge_order":["EP-002"]}}
39
+ JSON
40
+ cat > "$TMP/bad-layer.json" <<'JSON'
41
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
42
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"change",
43
+ "layer":"WRONG","gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t"},
44
+ "history":[],"releases":[]}
45
+ JSON
46
+ check "estado válido con campos nuevos" pass "$TMP/valid.json"
47
+ check "layer inválido rechazado" fail "$TMP/bad-layer.json"
48
+
49
+ CFG="$ROOT/config/build-config.template.json"
50
+ if [ -f "$CFG" ] && python3 -c "import json,sys; d=json.load(open('$CFG'))['context']; assert d['warning_pct']==35 and d['critical_pct']==25 and d['auto_checkpoint'] is False" 2>/dev/null; then
51
+ echo "OK build-config.template.json con defaults"
52
+ else echo "FAIL build-config.template.json con defaults"; fail=1; fi
53
+
54
+ CMD="$ROOT/commands/build/resume.md"
55
+ { [ -f "$CMD" ] && grep -q 'reconcile-build-state.py' "$CMD" && grep -q 'session_continuity' "$CMD"; } \
56
+ && echo "OK comando resume" || { echo "FAIL comando resume"; fail=1; }
57
+
58
+ exit $fail
@@ -16,6 +16,8 @@ la épica; lo de abajo aplica a la épica y a **cada HU** que cubre (`hus[]`). L
16
16
  - [ ] **Fuente de diseño identificada (slices con UI)**: la fuente visual de verdad del slice
17
17
  (el `DESIGN_SOURCE` del dominio) está declarada y confirmada (`design_source.confirmed`), y este
18
18
  slice apunta a la(s) pantalla(s) equivalente(s). No se construye UI fuera de la fuente declarada.
19
+ - [ ] **Clasificación `layer`**: `foundational` (auth/datos/design-system/arquitectura base) | `business`. Se escribe en `active_slice.layer`. Gatea el front paralelo (foundational nunca en paralelo).
20
+ - [ ] **`files_scope`**: globs de los archivos que la épica tocará (p.ej. `src/reports/**`). Fuente de la disjunción inter-épica. Se escribe en `active_slice.files_scope`.
19
21
 
20
22
  **Si todo ✓** → `dor-dod-gatekeeper` abre `active_slice` en `build-state.json` con `epica`, `hus[]`,
21
23
  `phase: dor`, `gates.dor: true` y el resto en `false` —incluido **`wiring_verified: false`**—
@@ -0,0 +1,36 @@
1
+ ---
2
+ name: managing-parallel-front
3
+ description: Use when building multiple NON-foundational, file-disjoint epics in parallel via git worktrees. Coordinates the parallel_front in build-state.json (selection, worktrees, deterministic merge order with re-smoke). Never parallelizes foundational epics or overlapping file scopes.
4
+ ---
5
+
6
+ # Gestionar el front paralelo inter-épica (outer-loop)
7
+
8
+ **Invariante:** el paralelismo es outer-loop. Cada worktree es un checkout aislado con su
9
+ **propio** `build-state.json` y su `active_slice` singular (el inner loop no cambia).
10
+
11
+ ## Precondiciones (compuertas)
12
+ - `scaffold.confirmed == true`.
13
+ - **G1 Fundacionales primero:** ninguna épica `layer=foundational` abierta. Si aparece una,
14
+ poner `parallel_front.status="draining"` (terminar en curso, no admitir nuevas) antes de abrirla.
15
+
16
+ ## Procedimiento
17
+ 1. **Reunir candidatas** no fundacionales listas (DoR pasado), cada una con `layer` y `files_scope`.
18
+ 2. **Seleccionar el conjunto disjunto** (G2):
19
+ `echo "$CANDS" | python3 .claude/scripts/lib/front-plan.py`
20
+ → `selected` van al front; `serialized` esperan (construir secuencial después);
21
+ `excluded_foundational` nunca en paralelo.
22
+ 3. **Abrir un worktree por épica seleccionada:**
23
+ `git worktree add ".wt/<epica>" -b feature/<slug>` (rama por `1 épica = 1 rama = 1 PR`).
24
+ Registrar el miembro en `parallel_front.members[]` (`merge_status:"pending"`).
25
+ 4. **Construir cada worktree** con la skill `building-a-slice` (inner loop normal, en su cwd).
26
+ Cada uno mantiene su `journey_smoke` verde localmente.
27
+ 5. **Coordinar merge (G3)** en `merge_order` (determinista: por orden de épica):
28
+ - Merge del PR; tras cada merge, **re-smoke del journey completo** en el árbol principal.
29
+ - Conflicto → `merge_status:"conflict"`, serializar la perdedora (rebase + re-correr sus gates).
30
+ - Éxito → `merge_status:"merged"`; `git worktree remove`.
31
+ 6. **Cerrar el front** cuando todos `merged`: `parallel_front=null`.
32
+
33
+ ## Reglas duras
34
+ - Nunca escritores paralelos sobre el mismo árbol (por eso worktrees).
35
+ - Nunca paralelizar dentro de una épica (preserva la "regla del esqueleto que camina").
36
+ - Un solape de `files_scope` no detectado que cause conflicto de merge → el re-smoke (G3) lo caza.
@@ -31,6 +31,10 @@
31
31
  "type": "array",
32
32
  "description": "Release Gates (outer loop). Una entrada por línea de release del Story Map auditada en bloque.",
33
33
  "items": { "$ref": "#/$defs/release" }
34
+ },
35
+ "parallel_front": {
36
+ "description": "Coordinación outer-loop de worktrees inter-épica (C). null = modo secuencial normal.",
37
+ "oneOf": [ { "type": "null" }, { "$ref": "#/$defs/parallel_front" } ]
34
38
  }
35
39
  },
36
40
  "$defs": {
@@ -75,6 +79,7 @@
75
79
  },
76
80
  "openspec_change": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
77
81
  "branch": { "type": "string", "pattern": "^(feature|fix|chore)/[a-z0-9._-]+$" },
82
+ "branch_drift": { "type": "string", "description": "Rama git real cuando difiere de branch (lo escribe el reconciliador)." },
78
83
  "phase": {
79
84
  "type": "string",
80
85
  "enum": ["dor", "change", "red", "green", "refactor", "smoke", "api", "data", "dod", "pr", "archived"]
@@ -151,6 +156,19 @@
151
156
  "journey_smoke": { "type": "boolean", "description": "El backbone-hasta-este-sub-slice camina end-to-end." }
152
157
  }
153
158
  }
159
+ },
160
+ "layer": { "type": "string", "enum": ["foundational", "business"], "default": "business", "description": "Clasificación (taxonomía existente del arnés): foundational (auth/datos/design-system/arquitectura base) nunca va en paralelo; business = épica de negocio (paralelizable si es disjunta). Gatea el front paralelo (C)." },
161
+ "files_scope": { "type": "array", "items": { "type": "string" }, "description": "Globs de archivos que la épica toca. Fuente de la disjunción inter-épica (G2)." },
162
+ "session_continuity": {
163
+ "type": "object", "additionalProperties": false,
164
+ "description": "Handoff de sesión (A): permite resume sin pérdida tras presión de contexto.",
165
+ "properties": {
166
+ "last_session": { "type": ["string", "null"] },
167
+ "stopped_at": { "type": ["string", "null"], "description": "p.ej. 'context exhaustion at 24% (ISO)'." },
168
+ "resume_hint": { "type": ["string", "null"], "description": "Siguiente acción concisa." },
169
+ "critical_recorded": { "type": "boolean", "description": "Guard once-per-session del auto-handoff." },
170
+ "auto_continue": { "type": "boolean", "description": "true cuando context.auto_checkpoint disparó continuación automática (§4.4)." }
171
+ }
154
172
  }
155
173
  }
156
174
  },
@@ -184,6 +202,38 @@
184
202
  "created_at": { "type": "string", "format": "date-time" },
185
203
  "updated_by": { "type": "string" }
186
204
  }
205
+ },
206
+ "parallel_front": {
207
+ "type": "object", "additionalProperties": false,
208
+ "required": ["status", "opened_at", "updated_by", "members", "merge_order"],
209
+ "properties": {
210
+ "status": { "type": "string", "enum": ["active", "draining"] },
211
+ "opened_at": { "type": "string", "format": "date-time" },
212
+ "updated_by": { "type": "string" },
213
+ "members": {
214
+ "type": "array",
215
+ "items": {
216
+ "type": "object", "additionalProperties": false,
217
+ "required": ["epica", "worktree", "files_scope", "merge_status"],
218
+ "properties": {
219
+ "epica": { "type": "string", "pattern": "^EP-[0-9]{3}$" },
220
+ "worktree": {
221
+ "type": "object", "additionalProperties": false,
222
+ "required": ["path", "branch", "created_at"],
223
+ "properties": {
224
+ "path": { "type": "string" },
225
+ "branch": { "type": "string", "pattern": "^(feature|fix|chore)/[a-z0-9._-]+$" },
226
+ "created_at": { "type": "string", "format": "date-time" }
227
+ }
228
+ },
229
+ "files_scope": { "type": "array", "items": { "type": "string" } },
230
+ "journey_smoke": { "type": "boolean" },
231
+ "merge_status": { "type": "string", "enum": ["pending", "merged", "conflict"] }
232
+ }
233
+ }
234
+ },
235
+ "merge_order": { "type": "array", "items": { "type": "string", "pattern": "^EP-[0-9]{3}$" } }
236
+ }
187
237
  }
188
238
  }
189
239
  }