@trycore/spec-build-harness 0.8.4 → 0.10.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 (113) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +43 -5
  3. package/INSTALL.md +28 -6
  4. package/METODOLOGIA.md +65 -10
  5. package/README.md +41 -7
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +17 -6
  9. package/agents/build/ux-fidelity-reviewer.md +4 -1
  10. package/agents/build/wiring-adversarial-verifier.md +52 -5
  11. package/commands/build/architect.md +1 -1
  12. package/commands/build/claim.md +46 -0
  13. package/commands/build/escalate.md +36 -0
  14. package/commands/build/front.md +9 -3
  15. package/commands/build/onboard.md +63 -14
  16. package/commands/build/prototype.md +23 -0
  17. package/commands/build/reflect.md +60 -40
  18. package/commands/build/release.md +10 -7
  19. package/commands/build/resume.md +33 -13
  20. package/commands/build/slice.md +32 -27
  21. package/commands/build/status.md +35 -0
  22. package/commands/build/work.md +11 -8
  23. package/config/build-config.template.json +4 -0
  24. package/dist/cli.js +22 -0
  25. package/dist/commands/doctor.js +42 -0
  26. package/dist/commands/init.js +84 -1
  27. package/dist/commands/migrate.js +48 -0
  28. package/dist/commands/status.js +34 -0
  29. package/dist/lib/normalize.js +276 -0
  30. package/dist/lib/paths.js +6 -0
  31. package/dist/lib/runtime-client.js +196 -0
  32. package/dist/lib/settings-merge.js +3 -3
  33. package/dist/lib/state-bundle.js +46 -0
  34. package/docs/commands.md +32 -9
  35. package/docs/getting-started.md +2 -1
  36. package/docs/hooks.md +114 -27
  37. package/docs/runtime/guia-modo-dual-y-migracion.md +136 -0
  38. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  39. package/docs/runtime/protocolo-cliente-runtime.md +109 -34
  40. package/hooks/build/build-gate-check.sh +21 -0
  41. package/hooks/build/context-monitor.sh +82 -15
  42. package/hooks/build/context-sync.sh +192 -0
  43. package/hooks/build/design-source-guard.sh +31 -3
  44. package/hooks/build/dual-compare.sh +92 -0
  45. package/hooks/build/event-emitter.sh +75 -0
  46. package/hooks/build/gitflow-guard.sh +164 -14
  47. package/hooks/build/heartbeat.sh +259 -0
  48. package/hooks/build/lib/agent-context.sh +139 -0
  49. package/hooks/build/lib/config.sh +27 -0
  50. package/hooks/build/lib/projection.sh +71 -0
  51. package/hooks/build/lib/runtime-client.sh +465 -0
  52. package/hooks/build/lib/runtime-ops.sh +221 -0
  53. package/hooks/build/lib/state-io.sh +5 -18
  54. package/hooks/build/load-build-state.sh +64 -2
  55. package/hooks/build/reflect-nudge.sh +15 -0
  56. package/hooks/build/release-gate-nudge.sh +15 -0
  57. package/hooks/build/release-ops.sh +164 -0
  58. package/hooks/build/scaffold-guard.sh +29 -2
  59. package/hooks/build/session-start.sh +103 -0
  60. package/hooks/build/session-stop.sh +22 -0
  61. package/hooks/build/slice-ops.sh +877 -0
  62. package/hooks/build/stack-guard.sh +8 -0
  63. package/hooks/build/statusline-bridge.sh +24 -3
  64. package/hooks/build-harness.json +16 -0
  65. package/package.json +3 -3
  66. package/scripts/check-agnostic.sh +3 -1
  67. package/scripts/check-pack-clean.sh +31 -0
  68. package/scripts/check-runtime-purity.sh +43 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +133 -0
  71. package/scripts/runtime-purity-allow.txt +5 -0
  72. package/scripts/smoke-test.sh +1 -1
  73. package/scripts/tests/lib/http-stub.py +46 -0
  74. package/scripts/tests/test-baseline-verdict.sh +92 -0
  75. package/scripts/tests/test-config.sh +25 -0
  76. package/scripts/tests/test-hooks-runtime.sh +853 -0
  77. package/scripts/tests/test-install.sh +57 -0
  78. package/scripts/tests/test-runtime-client.sh +298 -0
  79. package/scripts/tests/test-schema.sh +29 -1
  80. package/scripts/tests/test-skill-ops.sh +847 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +58 -23
  83. package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
  84. package/skills/building-a-slice/references/dod.md +12 -3
  85. package/skills/building-a-slice/references/dor.md +7 -3
  86. package/skills/building-a-slice/references/evidence-budget.md +51 -0
  87. package/skills/building-a-slice/references/exploration-fanout.md +1 -1
  88. package/skills/building-a-slice/references/gitflow.md +1 -1
  89. package/skills/building-a-slice/references/regression-baseline.md +67 -0
  90. package/skills/building-a-slice/references/runtime-protocol.md +75 -0
  91. package/skills/building-a-slice/references/state-protocol.md +12 -1
  92. package/skills/building-a-slice/workflows/README.md +7 -3
  93. package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
  94. package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
  95. package/skills/managing-parallel-front/SKILL.md +32 -16
  96. package/skills/openspec-archive-change/SKILL.md +15 -0
  97. package/skills/prototyping-screens/SKILL.md +104 -0
  98. package/skills/prototyping-screens/assets/DESIGN.md.template +55 -0
  99. package/skills/prototyping-screens/assets/manifest.schema.json +70 -0
  100. package/skills/prototyping-screens/assets/screen.template.html +34 -0
  101. package/skills/prototyping-screens/references/aesthetic-directions.md +42 -0
  102. package/skills/prototyping-screens/references/extraction.md +57 -0
  103. package/skills/prototyping-screens/references/self-check.md +40 -0
  104. package/skills/releasing-a-version/SKILL.md +25 -16
  105. package/skills/releasing-a-version/references/release-dod.md +7 -5
  106. package/skills/releasing-a-version/workflows/README.md +2 -1
  107. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  108. package/skills/setup-architecture/SKILL.md +4 -2
  109. package/state/README.md +20 -3
  110. package/state/build-state.schema.json +3 -2
  111. package/templates/CLAUDE.md.template +17 -1
  112. package/templates/settings-hooks.template.json +8 -4
  113. package/internal/skills/auditar-arnes/SKILL.md +0 -29
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # dual-compare.sh — comparador dual (hook Stop) [EP-OR-08-E].
3
+ # En modo dual el fichero es primario y cada transición se espeja al runtime
4
+ # (protocolo-cliente-runtime §10: POST …/mirror/transitions). Este comparador detecta
5
+ # cuando el REDUCER del servidor llegó a una proyección distinta de lo que dice el
6
+ # fichero local — señal de un espejo rechazado o perdido. Discrepancia = issue
7
+ # bloqueante DEL CORTE (spec riesgos §4: "Divergencia dual… comparador automático en el
8
+ # hook Stop; discrepancia = issue bloqueante del corte") — nunca bloquea el trabajo
9
+ # local, se escala para revisión humana vía el mismo canal que /build:escalate.
10
+ # Fail-open en TODO: sin dual, sin python3, sin build-state.json o con JSON corrupto,
11
+ # sale 0 sin escalar nada.
12
+ set -uo pipefail
13
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+ source "$HERE/lib/runtime-client.sh"
15
+ source "$HERE/lib/agent-context.sh"
16
+
17
+ MODE="$(runtime_mode)"
18
+ [ "$MODE" = "dual" ] || exit 0
19
+ command -v python3 >/dev/null 2>&1 || exit 0
20
+
21
+ ROOT="$(config_root)"
22
+ STATE_FILE="$ROOT/.claude/state/build-state.json"
23
+ [ -f "$STATE_FILE" ] || exit 0
24
+
25
+ # Refresco best-effort ANTES de comparar: Stop no es sensible a latencia como
26
+ # PreToolUse (protocolo §4.2-B), y sin refresco fresco una discrepancia real podría
27
+ # quedar enmascarada por una caché vieja de esta misma sesión.
28
+ agent_context_fetch_and_cache >/dev/null 2>&1 || true
29
+
30
+ FILE_SLICE="$(python3 - "$STATE_FILE" <<'PY' 2>/dev/null
31
+ import json,sys
32
+ try:
33
+ st = json.load(open(sys.argv[1]))
34
+ except Exception:
35
+ print("null")
36
+ sys.exit(0)
37
+ sl = st.get("active_slice")
38
+ if not isinstance(sl, dict):
39
+ print("null")
40
+ else:
41
+ print(json.dumps({
42
+ "epica": sl.get("epica"),
43
+ "phase": sl.get("phase"),
44
+ "gates": sl.get("gates") if isinstance(sl.get("gates"), dict) else {},
45
+ }))
46
+ PY
47
+ )"
48
+ [ -n "$FILE_SLICE" ] || exit 0
49
+
50
+ PROJ="$(runtime_projection_read)"
51
+
52
+ REASONS_JSON="$(python3 - "$FILE_SLICE" "$PROJ" <<'PY' 2>/dev/null
53
+ import json,sys
54
+ try:
55
+ file_slice = json.loads(sys.argv[1])
56
+ proj = json.loads(sys.argv[2]) if sys.argv[2] else {}
57
+ except Exception:
58
+ print("[]")
59
+ sys.exit(0)
60
+ proj_slice = proj.get("active_slice") if isinstance(proj, dict) else None
61
+
62
+ reasons = []
63
+ if file_slice and not proj_slice:
64
+ reasons.append("el fichero tiene un slice activo (%s) pero el runtime no reporta ninguno" % file_slice.get("epica"))
65
+ elif proj_slice and not file_slice:
66
+ reasons.append("el runtime reporta un slice activo (%s) pero el fichero no tiene ninguno" % proj_slice.get("epic_code"))
67
+ elif file_slice and proj_slice:
68
+ if file_slice.get("epica") != proj_slice.get("epic_code"):
69
+ reasons.append("épica distinta: fichero=%s runtime=%s" % (file_slice.get("epica"), proj_slice.get("epic_code")))
70
+ if file_slice.get("phase") != proj_slice.get("phase"):
71
+ reasons.append("fase distinta: fichero=%s runtime=%s" % (file_slice.get("phase"), proj_slice.get("phase")))
72
+ fg = file_slice.get("gates") or {}
73
+ pg = proj_slice.get("gates") or {}
74
+ for k in sorted(set(fg) & set(pg)):
75
+ fv, pv = fg.get(k), pg.get(k)
76
+ # Solo gates YA resueltos en ambos lados (True/False); null=pendiente no cuenta.
77
+ if fv is not None and pv is not None and fv != pv:
78
+ reasons.append("gate %s distinto: fichero=%s runtime=%s" % (k, fv, pv))
79
+
80
+ print(json.dumps(reasons, ensure_ascii=False))
81
+ PY
82
+ )"
83
+ [ -n "$REASONS_JSON" ] && [ "$REASONS_JSON" != "[]" ] || exit 0
84
+
85
+ REASON_TEXT="$(python3 -c "import json,sys; print('; '.join(json.loads(sys.argv[1])))" "$REASONS_JSON" 2>/dev/null)"
86
+ [ -n "$REASON_TEXT" ] || exit 0
87
+
88
+ echo "⚠ dual: discrepancia entre el fichero local y la proyección del runtime — ${REASON_TEXT}" >&2
89
+ echo " Se escala para revisión humana (el corte a modo runtime queda bloqueado mientras haya discrepancias sin resolver)." >&2
90
+
91
+ "$HERE/slice-ops.sh" escalate "Discrepancia dual: ${REASON_TEXT}" >/dev/null 2>&1 || true
92
+ exit 0
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # event-emitter.sh — PostToolUse (Bash|Edit|Write|MultiEdit|Task) [EP-OR-08-B].
3
+ # La telemetría es propiedad del arnés, no del modelo: este hook NO puede omitirse desde
4
+ # el prompt. Encola en .claude/state/outbox/ y devuelve el control de inmediato — el
5
+ # despacho a POST /events lo hace el daemon (protocolo §2/§5). Nunca abre socket.
6
+ # Contenido: SOLO metadatos (herramienta, ruta relativa, argv0). Jamás código fuente ni
7
+ # el comando completo (protocolo §8: el arnés no envía fuente al runtime, y un comando
8
+ # puede llevar credenciales).
9
+ set -uo pipefail
10
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ source "$HERE/lib/runtime-client.sh"
12
+ source "$HERE/lib/projection.sh"
13
+
14
+ MODE="$(runtime_mode)"
15
+ [ "$MODE" = "legacy" ] && exit 0
16
+
17
+ payload="$(cat 2>/dev/null || true)"
18
+ command -v python3 >/dev/null 2>&1 || exit 0
19
+
20
+ SLICE_ID="$(projection_get active_slice.slice_id "")"
21
+ if [ -n "$SLICE_ID" ]; then
22
+ TMPP="$(mktemp)" || exit 0
23
+ printf '%s' "$payload" > "$TMPP"
24
+ EV="$(PROJECT_ROOT="$(config_root)" python3 - "$TMPP" <<'PY' 2>/dev/null
25
+ import json,os,re,shlex,sys
26
+ try:
27
+ p=json.load(open(sys.argv[1]))
28
+ except Exception:
29
+ raise SystemExit(0)
30
+ if not isinstance(p,dict):
31
+ raise SystemExit(0)
32
+ tool=p.get("tool_name") or ""
33
+ if not tool:
34
+ raise SystemExit(0)
35
+ ti=p.get("tool_input")
36
+ if not isinstance(ti,dict):
37
+ ti={}
38
+ out={"tool":tool}
39
+ if tool=="Bash":
40
+ cmd=str(ti.get("command") or "").strip()
41
+ # shlex.split (posix=True) respeta comillas: una asignación de entorno inline con
42
+ # valor citado y espacios (p.ej. `KEY="a b" cmd`) se tokeniza como UN solo token, no
43
+ # se trocea. Si el comando trae comillas sin cerrar u otro problema de tokenización,
44
+ # shlex lanza ValueError: no arriesgamos un fragmento de secreto, degradamos a "".
45
+ try:
46
+ toks=shlex.split(cmd) if cmd else []
47
+ except ValueError:
48
+ toks=[]
49
+ # Salta asignaciones de variable de entorno inline al frente (p.ej. `VAR=secreto cmd`,
50
+ # `A=1 B=2 cmd`) para que argv0 sea siempre el binario, nunca un valor que pueda
51
+ # llevar credenciales (protocolo §8).
52
+ i=0
53
+ while i < len(toks) and re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', toks[i]):
54
+ i += 1
55
+ out["argv0"]=toks[i] if i < len(toks) else ""
56
+ elif tool=="Task":
57
+ out["subagent_type"]=str(ti.get("subagent_type") or "")
58
+ else:
59
+ fp=str(ti.get("file_path") or ti.get("path") or "")
60
+ root=os.environ.get("PROJECT_ROOT") or ""
61
+ if root and fp.startswith(root+os.sep):
62
+ fp=fp[len(root)+1:]
63
+ out["path"]=fp
64
+ print(json.dumps(out,ensure_ascii=False))
65
+ PY
66
+ )"
67
+ rm -f "$TMPP"
68
+ [ -n "$EV" ] && runtime_enqueue_event "tool_use_recorded" "$EV" "$SLICE_ID"
69
+ fi
70
+
71
+ # Comprobación barata del daemon (stat del pidfile + kill -0, SIN red): un daemon caído a
72
+ # mitad de sesión no debe quedar muerto durante horas hasta el próximo SessionStart.
73
+ bash "$HERE/heartbeat.sh" --ensure "$PPID" >/dev/null 2>&1
74
+
75
+ exit 0
@@ -1,11 +1,31 @@
1
1
  #!/usr/bin/env bash
2
2
  # gitflow-guard.sh — PreToolUse · Bash
3
3
  # Enforce GitHub Flow estricto:
4
- # - Prohíbe `git commit` directo en main/master.
5
- # - Prohíbe `git push` que apunte a main/master (la integración va por PR).
6
- # - Exige trabajar en rama feature/* | fix/* | chore/* para commitear.
7
- # Bloquea con exit 2 (stderr se devuelve a Claude). Cualquier otra cosa -> exit 0.
8
- set -euo pipefail
4
+ # - Prohíbe `git commit` directo en las ramas protegidas.
5
+ # - Prohíbe `git push` que apunte a una rama protegida (la integración va por PR).
6
+ # - Exige trabajar en una rama con prefijo declarado para commitear.
7
+ # [EP-OR-08-B] Correcciones:
8
+ # 1. La rama se resuelve en el repo del COMANDO (`git -C <dir>`, `cd <dir> && git …`),
9
+ # no en el de la sesión: en sesiones multi-repo el guard evaluaba el repo equivocado
10
+ # (spec §9). Sin pistas en el comando, se cae al repo de la sesión, como antes.
11
+ # El comando se tokeniza en segmentos (por &&, ||, ; y |) y se aplica el efecto
12
+ # ACUMULATIVO de los `cd`: si hay varios `cd` antes del segmento que ejecuta el git,
13
+ # gana el ÚLTIMO (el directorio real en el que corre el commit), no el primero.
14
+ # 2. La política (prefijos y ramas protegidas) sale del asset sincronizado
15
+ # .claude/config/policy.json — el mismo que escribe context-sync.sh. Sin fichero,
16
+ # los defaults son los de v0.8.5: feature|fix|chore y main|master.
17
+ # 3. Los prefijos/ramas de la política se escapan (re.escape) antes de entrar al patrón
18
+ # grep -E: son texto de un asset editable, no regex de confianza, y un metacaracter
19
+ # (p.ej. un punto) no debe interpretarse como comodín.
20
+ # 4. `-C <dir>` solo cuenta como cambio de repo cuando es el flag GLOBAL de git (aparece
21
+ # antes del primer subcomando, p.ej. `git -C <dir> commit`). `git commit -C <ref>`
22
+ # es un flag del subcomando commit (reusa mensaje/autoría de otro commit) y NO cambia
23
+ # de directorio: tratarlo como tal dejaba el commit sin veredicto (bypass).
24
+ # Cero red (PreToolUse). `set -uo pipefail` — nunca `-e`: este hook no puede abortar a
25
+ # mitad y dejar pasar un commit sin veredicto.
26
+ set -uo pipefail
27
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ source "$HERE/lib/config.sh"
9
29
 
10
30
  INPUT="$(cat)"
11
31
 
@@ -39,27 +59,157 @@ if [ "$is_commit" = false ] && [ "$is_push" = false ]; then
39
59
  exit 0
40
60
  fi
41
61
 
42
- BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
62
+ # ── Repo del comando ──
63
+ # Tokeniza el comando en segmentos (por &&, ||, ; y |) y camina en orden: cada `cd`
64
+ # actualiza el directorio "vigente" (acumulativo — gana el ÚLTIMO visto); al llegar al
65
+ # segmento que invoca el git de escritura, ese directorio vigente es el candidato, salvo
66
+ # que el propio segmento traiga `-C <dir>` como flag GLOBAL de git (antes del subcomando),
67
+ # que gana por ser más explícito y local a esa invocación.
68
+ REPO_DIR="$(CMD="$CMD" python3 - <<'PY' 2>/dev/null
69
+ import os,re,shlex
70
+
71
+ cmd = os.environ.get("CMD", "")
72
+ # Heurística de partición de shell (no un parser completo): &&, ||, ; y | en ese orden
73
+ # de prioridad para que "||" no se rompa en dos "|".
74
+ segments = re.split(r'&&|\|\||;|\|', cmd)
75
+
76
+
77
+ def cd_target(seg):
78
+ m = re.match(r'^\s*cd\s+(.+?)\s*$', seg)
79
+ if not m:
80
+ return None
81
+ try:
82
+ toks = shlex.split(m.group(1).strip())
83
+ except Exception:
84
+ toks = [m.group(1).strip().strip("\"'")]
85
+ return toks[0] if toks else None
86
+
87
+
88
+ ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
89
+ # Envoltorios comunes que anteponen el nombre del binario real sin ser el subcomando de
90
+ # git: `env FOO=x git ...`, `sudo git ...`. Solo se salta el nombre del envoltorio en sí
91
+ # (no sus propias flags) — cubre el caso reportado sin pretender ser un parser de sudo/env.
92
+ WRAPPER_CMDS = {"env", "sudo", "nice", "ionice", "time", "nohup"}
93
+ # Flags GLOBALES de git que toman un argumento en un token SEPARADO: si no se saltan como
94
+ # par, el walk trata el valor (p.ej. "user.name=x" de `-c user.name=x`) como si fuera el
95
+ # subcomando y para ahí, sin llegar nunca al `-C` real que viene después (bypass).
96
+ GIT_GLOBAL_FLAGS_WITH_ARG = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix"}
97
+
98
+
99
+ def find_git_token(toks):
100
+ # Salta asignaciones de variables de entorno (`FOO=x git ...`) y envoltorios conocidos
101
+ # (`env`, `sudo`, ...) antes del `git` real — sin esto, `toks[0] != "git"` descarta el
102
+ # segmento entero y el -C real queda sin detectar (bypass: cae al repo de sesión).
103
+ i = 0
104
+ while i < len(toks) and (ENV_ASSIGNMENT_RE.match(toks[i]) or toks[i] in WRAPPER_CMDS):
105
+ i += 1
106
+ return i if i < len(toks) and toks[i] == "git" else None
107
+
108
+
109
+ def git_global_dash_c(seg):
110
+ # `-C <dir>` solo cuenta si aparece ANTES del primer subcomando (commit/push/…): ahí
111
+ # es el flag GLOBAL de git. Tras el subcomando es un flag suyo (p.ej. `commit -C <ref>`
112
+ # reusa mensaje/autoría de otro commit) y no debe leerse como cambio de directorio.
113
+ try:
114
+ toks = shlex.split(seg)
115
+ except Exception:
116
+ return None
117
+ gi = find_git_token(toks)
118
+ if gi is None:
119
+ return None
120
+ i = gi + 1
121
+ while i < len(toks):
122
+ t = toks[i]
123
+ if t == "-C" and i + 1 < len(toks):
124
+ return toks[i + 1]
125
+ if t in GIT_GLOBAL_FLAGS_WITH_ARG and i + 1 < len(toks):
126
+ i += 2 # flag GLOBAL con argumento en token separado (p.ej. -c user.name=x):
127
+ continue # se salta el PAR completo, nunca se lee el valor como subcomando.
128
+ if t.startswith("-"):
129
+ i += 1
130
+ continue
131
+ break # primer token que no es flag => subcomando; -C posterior no cuenta.
132
+ return None
133
+
134
+
135
+ cur_dir = ""
136
+ target = ""
137
+ for seg in segments:
138
+ seg = seg.strip()
139
+ if not seg:
140
+ continue
141
+ cdt = cd_target(seg)
142
+ if cdt is not None:
143
+ cur_dir = cdt
144
+ continue
145
+ if re.search(r'(^|\s)git(\s|$)', seg) and re.search(r'\b(commit|push)\b', seg):
146
+ dashc = git_global_dash_c(seg)
147
+ target = dashc if dashc else cur_dir
148
+ break
149
+
150
+ print(target)
151
+ PY
152
+ )"
153
+ if [ -n "$REPO_DIR" ]; then
154
+ case "$REPO_DIR" in
155
+ /*) ;;
156
+ *) REPO_DIR="$(config_root)/$REPO_DIR" ;;
157
+ esac
158
+ else
159
+ REPO_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
160
+ fi
161
+ [ -d "$REPO_DIR" ] || REPO_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
162
+
163
+ BRANCH="$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')"
164
+
165
+ # ── Política (asset sincronizado, defaults v0.8.5) ──
166
+ POLICY="$(config_root)/.claude/config/policy.json"
167
+ POLICY_OUT="$(POLICY="$POLICY" python3 - <<'PY' 2>/dev/null
168
+ import json,os,re
169
+ try:
170
+ d=json.load(open(os.environ["POLICY"]))
171
+ except Exception:
172
+ d={}
173
+ if not isinstance(d,dict):
174
+ d={}
175
+ pref=[p for p in (d.get("branch_prefixes") or []) if isinstance(p,str)] or ["feature","fix","chore"]
176
+ prot=[p for p in (d.get("protected_branches") or []) if isinstance(p,str)] or ["main","master"]
177
+ # [EP-OR-08-B] re.escape: los prefijos/ramas vienen de un asset editable (policy.json), no son
178
+ # regex de confianza. Sin escapar, un metacaracter (p.ej. un punto en "release.candidate")
179
+ # se interpreta como comodín y afloja o rompe el gate silenciosamente.
180
+ pref_re="|".join(re.escape(p) for p in pref)
181
+ prot_re="|".join(re.escape(p) for p in prot)
182
+ print("^(" + pref_re + ")/", "^(" + prot_re + ")$")
183
+ PY
184
+ )"
185
+ case "$POLICY_OUT" in
186
+ *' '*) ;;
187
+ *) POLICY_OUT='^(feature|fix|chore)/ ^(main|master)$' ;;
188
+ esac
189
+ PREFIX_RE="${POLICY_OUT%% *}"
190
+ PROT_RE="${POLICY_OUT##* }"
43
191
 
44
192
  block() {
45
193
  echo "⛔ gitflow-guard (GitHub Flow estricto): $1" >&2
46
- echo " Política: main protegida · trabajar en feature/* | fix/* | chore/* · integrar por PR." >&2
194
+ echo " Política: ramas protegidas ${PROT_RE} · trabajar en ${PREFIX_RE}* · integrar por PR." >&2
195
+ echo " Repo evaluado: $REPO_DIR (rama '$BRANCH')." >&2
47
196
  exit 2
48
197
  }
49
198
 
50
- # 1) Commit directo a main/master.
51
- if [ "$is_commit" = true ] && printf '%s' "$BRANCH" | grep -Eq '^(main|master)$'; then
199
+ # 1) Commit directo a una rama protegida.
200
+ if [ "$is_commit" = true ] && printf '%s' "$BRANCH" | grep -Eq "$PROT_RE"; then
52
201
  block "commit directo a '$BRANCH' no permitido. Crea una rama: git switch -c feature/<slug>"
53
202
  fi
54
203
 
55
204
  # 2) Commit desde una rama no tipada.
56
- if [ "$is_commit" = true ] && [ -n "$BRANCH" ] && ! printf '%s' "$BRANCH" | grep -Eq '^(feature|fix|chore)/'; then
57
- block "rama '$BRANCH' no sigue la convención. Usa feature/* | fix/* | chore/* para commitear."
205
+ if [ "$is_commit" = true ] && [ -n "$BRANCH" ] && ! printf '%s' "$BRANCH" | grep -Eq "$PREFIX_RE"; then
206
+ block "rama '$BRANCH' no sigue la convención. Usa un prefijo declarado en la política para commitear."
58
207
  fi
59
208
 
60
- # 3) Push apuntando a main/master.
61
- if [ "$is_push" = true ] && printf '%s' "$CMD" | grep -Eq 'push[^|;&]*[ :](main|master)([ ]|$|:)'; then
62
- block "push directo a main/master no permitido. Abre un Pull Request desde tu rama feature/*."
209
+ # 3) Push apuntando a una rama protegida.
210
+ PROT_ALT="$(printf '%s' "$PROT_RE" | sed -e 's/^\^(//' -e 's/)\$$//')"
211
+ if [ "$is_push" = true ] && printf '%s' "$CMD" | grep -Eq "push[^|;&]*[ :]($PROT_ALT)([ ]|$|:)"; then
212
+ block "push directo a una rama protegida no permitido. Abre un Pull Request desde tu rama de trabajo."
63
213
  fi
64
214
 
65
215
  exit 0
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env bash
2
+ # heartbeat.sh — daemon singleton por repo [EP-OR-08-B, spec §4.3].
3
+ # uso: heartbeat.sh [--ensure <ppid>] | --daemon | --stop (default: --ensure $PPID)
4
+ #
5
+ # Por qué un daemon y no un hook: no existe evento Timer, los hooks son efímeros y un
6
+ # PostToolUse throttled NO late durante una tool call larga (suites de >5 min contra un
7
+ # TTL de 300 s). Ciclo de vida explícito:
8
+ # - Singleton por repo: pidfile + fichero de sesiones interesadas en .claude/state/.
9
+ # - Cada SessionStart registra su ppid y RELANZA el daemon si el pidfile está muerto;
10
+ # cada PostToolUse hace la misma comprobación barata (stat + kill -0, SIN red).
11
+ # - Muere cuando no queda NINGÚN ppid registrado vivo (no cuando muere el primero):
12
+ # dos sesiones sobre el mismo repo comparten daemon.
13
+ # - Deberes por tick: consumir el sentinela de flush y despachar la cola; cada
14
+ # lease_ttl_s/3, renovar el lease. Sin lease NO se apaga (los eventos de ámbito
15
+ # proyecto, pre-claim, necesitan despachador).
16
+ # - Nunca se cuelga de Stop (dispara cada turno) ni de SessionEnd (no existe).
17
+ set -uo pipefail
18
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ source "$HERE/lib/runtime-client.sh"
20
+ source "$HERE/lib/projection.sh"
21
+
22
+ HB_TICK_S="${TRYCORE_HEARTBEAT_TICK_S:-5}"
23
+ case "$HB_TICK_S" in ''|*[!0-9]*) HB_TICK_S=5 ;; esac
24
+
25
+ hb_pidfile() { echo "$(config_root)/.claude/state/heartbeat.pid"; }
26
+ hb_sessions() { echo "$(config_root)/.claude/state/heartbeat-sessions.json"; }
27
+ hb_statusfile() { echo "$(config_root)/.claude/state/heartbeat-status.json"; }
28
+ hb_flushfile() { echo "$(runtime_outbox_dir)/.flush-request"; }
29
+ hb_spawnlock() { echo "$(config_root)/.claude/state/.heartbeat.spawn.lock"; }
30
+
31
+ # hb_alive — rc 0 si hay un daemon vivo según el pidfile.
32
+ hb_alive() {
33
+ local f pid
34
+ f="$(hb_pidfile)"
35
+ [ -f "$f" ] || return 1
36
+ pid="$(cat "$f" 2>/dev/null)"
37
+ case "$pid" in ''|*[!0-9]*) return 1 ;; esac
38
+ kill -0 "$pid" 2>/dev/null
39
+ }
40
+
41
+ # hb_register_session <ppid> — upsert idempotente del ppid en el registro de sesiones.
42
+ hb_register_session() {
43
+ local ppid="${1:-}" f
44
+ case "$ppid" in ''|*[!0-9]*) return 0 ;; esac
45
+ f="$(hb_sessions)"
46
+ mkdir -p "$(dirname "$f")"
47
+ command -v python3 >/dev/null 2>&1 || return 0
48
+ python3 - "$f" "$ppid" <<'PY' 2>/dev/null
49
+ import json,sys,os,tempfile
50
+ path,ppid=sys.argv[1],int(sys.argv[2])
51
+ try:
52
+ d=json.load(open(path))
53
+ except Exception:
54
+ d={}
55
+ if not isinstance(d,dict):
56
+ d={}
57
+ ppids=[p for p in (d.get("ppids") or []) if isinstance(p,int)]
58
+ if ppid not in ppids:
59
+ ppids.append(ppid)
60
+ d["ppids"]=ppids
61
+ dirn=os.path.dirname(path) or "."
62
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".heartbeat-sessions.",suffix=".tmp")
63
+ try:
64
+ with os.fdopen(fd,"w") as out:
65
+ json.dump(d,out,indent=2)
66
+ os.replace(tmp,path)
67
+ except Exception:
68
+ try: os.unlink(tmp)
69
+ except OSError: pass
70
+ PY
71
+ return 0
72
+ }
73
+
74
+ # hb_prune_sessions — deja en el registro solo los ppids vivos e imprime cuántos quedan.
75
+ # La lista se materializa en un fichero temporal y se itera con `while read` (bash 3.2:
76
+ # nada de mapfile) para poder aplicar `kill -0` a cada uno.
77
+ hb_prune_sessions() {
78
+ local f tmp pid n
79
+ f="$(hb_sessions)"
80
+ [ -f "$f" ] || { echo 0; return 0; }
81
+ command -v python3 >/dev/null 2>&1 || { echo 0; return 0; }
82
+ tmp="$(mktemp)" || { echo 0; return 0; }
83
+ python3 - "$f" > "$tmp" <<'PY' 2>/dev/null
84
+ import json,sys
85
+ try:
86
+ d=json.load(open(sys.argv[1]))
87
+ except Exception:
88
+ d={}
89
+ for p in (d.get("ppids") or []):
90
+ print(p)
91
+ PY
92
+ local live=()
93
+ while IFS= read -r pid; do
94
+ case "$pid" in ''|*[!0-9]*) continue ;; esac
95
+ kill -0 "$pid" 2>/dev/null && live+=("$pid")
96
+ done < "$tmp"
97
+ rm -f "$tmp"
98
+ n=${#live[@]}
99
+ if [ "$n" -gt 0 ]; then
100
+ python3 - "$f" "${live[@]}" <<'PY' 2>/dev/null
101
+ import json,sys,os,tempfile
102
+ path=sys.argv[1]
103
+ ppids=[int(x) for x in sys.argv[2:]]
104
+ dirn=os.path.dirname(path) or "."
105
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".heartbeat-sessions.",suffix=".tmp")
106
+ try:
107
+ with os.fdopen(fd,"w") as out:
108
+ json.dump({"ppids":ppids},out,indent=2)
109
+ os.replace(tmp,path)
110
+ except Exception:
111
+ try: os.unlink(tmp)
112
+ except OSError: pass
113
+ PY
114
+ fi
115
+ echo "$n"
116
+ }
117
+
118
+ # hb_write_status <json> — estado observable del daemon (lo lee statusline-bridge.sh).
119
+ hb_write_status() {
120
+ local body f
121
+ body="$1"
122
+ f="$(hb_statusfile)"
123
+ mkdir -p "$(dirname "$f")"
124
+ command -v python3 >/dev/null 2>&1 || return 0
125
+ python3 - "$f" "$body" <<'PY' 2>/dev/null
126
+ import json,sys,os,tempfile
127
+ path,body=sys.argv[1],sys.argv[2]
128
+ try:
129
+ d=json.loads(body)
130
+ except Exception:
131
+ d={}
132
+ dirn=os.path.dirname(path) or "."
133
+ fd,tmp=tempfile.mkstemp(dir=dirn,prefix=".heartbeat-status.",suffix=".tmp")
134
+ try:
135
+ with os.fdopen(fd,"w") as out:
136
+ json.dump(d,out,indent=2)
137
+ os.replace(tmp,path)
138
+ except Exception:
139
+ try: os.unlink(tmp)
140
+ except OSError: pass
141
+ PY
142
+ return 0
143
+ }
144
+
145
+ # hb_renew — renueva el lease del slice activo. El servidor responde SIEMPRE 409 de cuerpo
146
+ # único cuando el lease ya no es nuestro (anti-oráculo HU-OR-41a): esa es la señal de
147
+ # «lease perdido». No se apaga el daemon; se marca el estado y se sigue despachando.
148
+ hb_renew() {
149
+ local slice_id agent_key resp status
150
+ slice_id="$(projection_get active_slice.slice_id "")"
151
+ [ -n "$slice_id" ] || return 0
152
+ agent_key="$(runtime_field agent_key)"
153
+ resp="$(runtime_put "/leases/renew" "{\"agent_key\":\"$agent_key\",\"slice_id\":\"$slice_id\"}")"
154
+ status="$(runtime_http_status)"
155
+ case "$status" in
156
+ 200)
157
+ hb_write_status "{\"lease_lost\": false, \"slice_id\": \"$slice_id\", \"last_renew_status\": 200}"
158
+ return 0
159
+ ;;
160
+ 409)
161
+ hb_write_status "{\"lease_lost\": true, \"slice_id\": \"$slice_id\", \"last_renew_status\": 409}"
162
+ return 1
163
+ ;;
164
+ *)
165
+ # Red caída: no se toca el estado anterior (no es lo mismo «sin red» que «lease perdido»).
166
+ return 1
167
+ ;;
168
+ esac
169
+ }
170
+
171
+ hb_daemon() {
172
+ local pidf interval ttl last now n
173
+ pidf="$(hb_pidfile)"
174
+ mkdir -p "$(dirname "$pidf")"
175
+ printf '%s' "$$" > "$pidf"
176
+ trap 'rm -f "$(hb_pidfile)"; exit 0' TERM INT EXIT
177
+ interval="${TRYCORE_HEARTBEAT_INTERVAL_S:-}"
178
+ case "$interval" in
179
+ ''|*[!0-9]*)
180
+ ttl="$(runtime_field lease_ttl_s)"
181
+ case "$ttl" in ''|*[!0-9]*) ttl=300 ;; esac
182
+ interval=$(( ttl / 3 ))
183
+ [ "$interval" -lt 10 ] && interval=10
184
+ ;;
185
+ esac
186
+ last=0
187
+ while :; do
188
+ sleep "$HB_TICK_S"
189
+ n="$(hb_prune_sessions)"
190
+ case "$n" in ''|*[!0-9]*) n=0 ;; esac
191
+ [ "$n" -eq 0 ] && break # murió la última sesión interesada: el daemon se va con ella
192
+ if [ -f "$(hb_flushfile)" ]; then
193
+ rm -f "$(hb_flushfile)"
194
+ runtime_dispatch_outbox >/dev/null 2>&1
195
+ fi
196
+ now="$(date +%s)"
197
+ if [ $(( now - last )) -ge "$interval" ]; then
198
+ hb_renew
199
+ runtime_dispatch_outbox >/dev/null 2>&1
200
+ last="$now"
201
+ fi
202
+ done
203
+ rm -f "$pidf"
204
+ exit 0
205
+ }
206
+
207
+ # hb_spawn — lanza el daemon bajo un mutex de directorio (`mkdir` es atómico): dos
208
+ # SessionStart simultáneos sobre el mismo repo no crean dos daemons. Un lock huérfano
209
+ # (proceso muerto entre el mkdir y el rmdir) se libera por edad.
210
+ #
211
+ # El mutex cubre «daemon confirmado arriba», no solo «proceso bifurcado»: tras el nohup se
212
+ # hace poll acotado (~3s) de hb_alive antes de soltar el lock. Sin esto, la ventana entre
213
+ # el backgrounding y que el hijo escriba heartbeat.pid deja el lock libre con hb_alive aún
214
+ # en falso; un --ensure casi simultáneo puede colarse ahí y lanzar un SEGUNDO daemon (se
215
+ # autosana en el siguiente tick al ver 0 sesiones propias, pero produce renew/dispatch
216
+ # duplicados transitorios mientras tanto).
217
+ hb_spawn() {
218
+ local lock waited
219
+ lock="$(hb_spawnlock)"
220
+ mkdir -p "$(dirname "$lock")"
221
+ if [ -d "$lock" ] && [ -n "$(find "$lock" -maxdepth 0 -mmin +1 2>/dev/null)" ]; then
222
+ rmdir "$lock" 2>/dev/null
223
+ fi
224
+ mkdir "$lock" 2>/dev/null || return 0 # otro proceso está lanzando: nada que hacer
225
+ if ! hb_alive; then
226
+ nohup bash "$HERE/heartbeat.sh" --daemon >/dev/null 2>&1 &
227
+ waited=0
228
+ while [ "$waited" -lt 30 ]; do
229
+ hb_alive && break
230
+ sleep 0.1
231
+ waited=$((waited + 1))
232
+ done
233
+ fi
234
+ rmdir "$lock" 2>/dev/null
235
+ return 0
236
+ }
237
+
238
+ hb_ensure() {
239
+ [ "$(runtime_mode)" = "legacy" ] && return 0
240
+ hb_register_session "${1:-}"
241
+ hb_alive || hb_spawn
242
+ return 0
243
+ }
244
+
245
+ hb_stop() {
246
+ local pid
247
+ pid="$(cat "$(hb_pidfile)" 2>/dev/null)"
248
+ case "$pid" in ''|*[!0-9]*) ;; *) kill "$pid" 2>/dev/null ;; esac
249
+ rm -f "$(hb_pidfile)" "$(hb_sessions)"
250
+ return 0
251
+ }
252
+
253
+ case "${1:-}" in
254
+ --daemon) hb_daemon ;;
255
+ --stop) hb_stop ;;
256
+ --ensure) hb_ensure "${2:-$PPID}" ;;
257
+ *) hb_ensure "$PPID" ;;
258
+ esac
259
+ exit 0