@trycore/spec-build-harness 0.7.1 → 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 (44) 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 +59 -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/flujo-harness-funcional.md +42 -0
  19. package/docs/flujo-harness.md +192 -0
  20. package/docs/hooks.md +22 -5
  21. package/hooks/build/build-gate-check.sh +1 -1
  22. package/hooks/build/context-monitor.sh +74 -0
  23. package/hooks/build/design-source-guard.sh +1 -1
  24. package/hooks/build/lib/state-io.sh +55 -0
  25. package/hooks/build/lint-typecheck.sh +1 -1
  26. package/hooks/build/load-build-state.sh +46 -2
  27. package/hooks/build/reconcile-build-state.py +70 -0
  28. package/hooks/build/reflect-nudge.sh +1 -1
  29. package/hooks/build/release-gate-nudge.sh +1 -1
  30. package/hooks/build/scaffold-guard.sh +1 -1
  31. package/hooks/build/stack-guard.sh +1 -1
  32. package/hooks/build/statusline-bridge.sh +32 -0
  33. package/hooks/build-harness.json +24 -0
  34. package/package.json +1 -1
  35. package/scripts/lib/front-plan.py +47 -0
  36. package/scripts/smoke-test.sh +12 -0
  37. package/scripts/tests/test-context-monitor.sh +70 -0
  38. package/scripts/tests/test-front-plan.sh +38 -0
  39. package/scripts/tests/test-install.sh +89 -0
  40. package/scripts/tests/test-reconciler.sh +54 -0
  41. package/scripts/tests/test-schema.sh +58 -0
  42. package/skills/building-a-slice/references/dor.md +2 -0
  43. package/skills/managing-parallel-front/SKILL.md +36 -0
  44. package/state/build-state.schema.json +50 -0
@@ -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.1",
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
@@ -0,0 +1,70 @@
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
+ # load-build-state (SessionStart): reset once-per-SESSION del guard critical_recorded.
35
+ # Sesión NUEVA (last_session distinto del session_id del payload) -> reabre el guard y
36
+ # persiste el session_id actual.
37
+ PROJ="$TMP/proj-lbs"; mkdir -p "$PROJ/.claude/state"
38
+ cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
39
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
40
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
41
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
42
+ "progress_log":[],"session_continuity":{"critical_recorded":true,"last_session":"old"}},
43
+ "history":[],"releases":[]}
44
+ JSON
45
+ echo '{"session_id":"new"}' | CLAUDE_PROJECT_DIR="$PROJ" bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null
46
+ cr="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice']['session_continuity']['critical_recorded'])")"
47
+ ls_="$(python3 -c "import json;print(json.load(open('$PROJ/.claude/state/build-state.json'))['active_slice']['session_continuity']['last_session'])")"
48
+ [ "$cr" = False ] && echo "OK load-build-state resetea critical_recorded en sesión nueva" || { echo "FAIL critical_recorded no se reseteó ($cr)"; fail=1; }
49
+ [ "$ls_" = new ] && echo "OK load-build-state persiste last_session nuevo" || { echo "FAIL last_session no persistió ($ls_)"; fail=1; }
50
+
51
+ # Sesión IDÉNTICA (last_session == session_id del payload) -> NO toca critical_recorded.
52
+ PROJ2="$TMP/proj-lbs-same"; mkdir -p "$PROJ2/.claude/state"
53
+ cat > "$PROJ2/.claude/state/build-state.json" <<'JSON'
54
+ {"version":"1.0","harness_phase":"active","scaffold":{"confirmed":true},
55
+ "active_slice":{"epica":"EP-001","hus":["HU-001"],"openspec_change":"x","branch":"feature/x","phase":"tdd",
56
+ "gates":{"dor":true,"tdd":false,"dod":false},"updated_at":"2026-07-03T00:00:00Z","updated_by":"t",
57
+ "progress_log":[],"session_continuity":{"critical_recorded":true,"last_session":"s1"}},
58
+ "history":[],"releases":[]}
59
+ JSON
60
+ echo '{"session_id":"s1"}' | CLAUDE_PROJECT_DIR="$PROJ2" bash "$ROOT/hooks/build/load-build-state.sh" >/dev/null
61
+ cr2="$(python3 -c "import json;print(json.load(open('$PROJ2/.claude/state/build-state.json'))['active_slice']['session_continuity']['critical_recorded'])")"
62
+ [ "$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; }
63
+
64
+ # wiring: ambos canales cablean context-monitor en PostToolUse/PreCompact/Stop
65
+ grep -q 'PreCompact' "$ROOT/hooks/build-harness.json" && echo "OK plugin PreCompact" || { echo "FAIL plugin PreCompact"; fail=1; }
66
+ grep -q 'context-monitor.sh' "$ROOT/hooks/build-harness.json" && echo "OK plugin monitor" || { echo "FAIL plugin monitor"; fail=1; }
67
+ grep -q "PreCompact" "$ROOT/src/lib/settings-merge.ts" && echo "OK cli PreCompact" || { echo "FAIL cli PreCompact"; fail=1; }
68
+ grep -q "context-monitor.sh" "$ROOT/src/lib/settings-merge.ts" && echo "OK cli monitor" || { echo "FAIL cli monitor"; fail=1; }
69
+
70
+ 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