@trycore/spec-build-harness 0.15.0 → 0.16.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.
@@ -24,6 +24,7 @@ uso: release-ops.sh <subcomando> [opciones]
24
24
  [--resmoke pass|fail] [--evidence T | --evidence-file F]
25
25
 
26
26
  códigos de salida: 0 ok · 2 uso · 3 legacy · 5 offline · 6 rechazado
27
+ · 9 identidad heredada (da de alta el worktree: `slice-ops.sh register`)
27
28
  USAGE
28
29
  }
29
30
 
@@ -135,9 +136,16 @@ HINT
135
136
  }
136
137
 
137
138
  # ── front-integration ────────────────────────────────────────────────────────────────
138
- # Cada worktree se registra como AGENTE PROPIO (mismo token de proyecto, `agent_key`
139
- # distinto): reporta la integración de SU miembro. Planificar, abrir, drenar y cerrar el
140
- # front siguen siendo humanos.
139
+ # Cada worktree se registra como AGENTE PROPIO y reporta la integración de SU miembro.
140
+ # Planificar, abrir, drenar y cerrar el front siguen siendo humanos.
141
+ #
142
+ # [#74] «Mismo token de proyecto, agent_key distinto» era falso y estuvo escrito aquí y en la
143
+ # skill: el hub NO crea identidad en `POST /agents/register` — el `agent_key` es el `agent_id`
144
+ # que el ADMIN grabó en el token al emitirlo. Un token por agente, o los dos worktrees son el
145
+ # mismo agente. La compuerta va en el despacho (`ops_guard_own_identity`), no aquí: el
146
+ # `[ -n "$akey" ]` de más abajo era inefectivo por construcción — leía la clave vía
147
+ # `config_root()`, que en un worktree sin credenciales ya había heredado la del principal, así
148
+ # que solo saltaba si el principal TAMPOCO estaba registrado.
141
149
  cmd_front_integration() {
142
150
  local front="${1:-}" merge="" resmoke="" ev="" evfile="" mode akey payload resp status
143
151
  [ -n "$front" ] || { usage; return $OPS_RC_USAGE; }
@@ -170,7 +178,10 @@ cmd_front_integration() {
170
178
  return $OPS_RC_OK
171
179
  fi
172
180
  akey="$(runtime_field agent_key)"
173
- [ -n "$akey" ] || { echo "⛔ este worktree no está registrado como agente: corre \`trycore-build init\` en él" >&2; return $OPS_RC_REJECTED; }
181
+ [ -n "$akey" ] || {
182
+ echo "⛔ este árbol no tiene \`agent_key\`: da de alta el agente con" >&2
183
+ echo " \`bash .claude/hooks/build/slice-ops.sh register\` (token propio del ADMIN)." >&2
184
+ return $OPS_RC_REJECTED; }
174
185
  resp="$(runtime_post "$(ops_path_front_integration "$(ops_urlenc "$front")" "$(ops_urlenc "$akey")")" "$payload")"
175
186
  status="$(runtime_http_status)"
176
187
  case "$status" in
@@ -192,6 +203,16 @@ cmd_front_integration() {
192
203
 
193
204
  SUB="${1:-}"
194
205
  [ $# -gt 0 ] && shift
206
+
207
+ # [#74] Reportar la integración de un miembro del front es EL acto por-agente de este fichero
208
+ # (la ruta lleva el `agent_key` en el path): desde un árbol con identidad heredada estaría
209
+ # firmando por el agente del clon principal. `verdict` queda fuera de la compuerta a propósito
210
+ # — un veredicto de release es del proyecto, no del agente, y se reporta desde el árbol
211
+ # principal; `close-hint` no toca la red.
212
+ case "$SUB" in
213
+ front-integration) ops_guard_own_identity "front-integration" || exit $? ;;
214
+ esac
215
+
195
216
  case "$SUB" in
196
217
  verdict) cmd_verdict "$@"; exit $? ;;
197
218
  close-hint) cmd_close_hint "$@"; exit $? ;;
@@ -11,6 +11,9 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
11
  source "$HERE/lib/runtime-client.sh"
12
12
  source "$HERE/lib/projection.sh"
13
13
  source "$HERE/lib/agent-context.sh"
14
+ # [#74] La versión del arnés y el catálogo de asset types viven en lib/harness-meta.sh:
15
+ # `slice-ops.sh register` los necesita exactamente iguales para dar de alta un worktree.
16
+ source "$HERE/lib/harness-meta.sh"
14
17
 
15
18
  cat >/dev/null 2>&1 # consumir el payload del hook (protocolo); no se usa aquí
16
19
 
@@ -19,67 +22,8 @@ MODE="$(runtime_mode)"
19
22
  command -v python3 >/dev/null 2>&1 || exit 0
20
23
  [ -n "$(runtime_url)" ] || exit 0 # sin `trycore-build init` (sub-slice D) no hay runtime
21
24
 
22
- # sst_pkg_root — raíz del PAQUETE (no del proyecto). Los hooks se instalan como symlinks
23
- # por fichero (linkChildren), así que hay que resolver la cadena de enlaces a mano:
24
- # `readlink -f` no es portable al BSD readlink de macOS.
25
- sst_pkg_root() {
26
- local src="${BASH_SOURCE[0]}" target i=0
27
- while [ -L "$src" ] && [ "$i" -lt 10 ]; do
28
- target="$(readlink "$src" 2>/dev/null)" || break
29
- case "$target" in
30
- /*) src="$target" ;;
31
- *) src="$(dirname "$src")/$target" ;;
32
- esac
33
- i=$((i + 1))
34
- done
35
- (cd "$(dirname "$src")/../.." >/dev/null 2>&1 && pwd -P) 2>/dev/null
36
- }
37
-
38
- # sst_harness_version — versión instalada en el consumidor; si no, la del paquete.
39
- sst_harness_version() {
40
- local f pkg
41
- f="$(config_root)/.claude/.build-harness-version"
42
- if [ -f "$f" ]; then
43
- tr -d ' \n' < "$f"
44
- return
45
- fi
46
- pkg="$(sst_pkg_root)"
47
- if [ -n "$pkg" ] && [ -f "$pkg/VERSION" ]; then
48
- tr -d ' \n' < "$pkg/VERSION"
49
- return
50
- fi
51
- echo "0.0.0"
52
- }
53
-
54
- # sst_asset_types — el array `types` de asset-types.json del paquete (protocolo §6).
55
- # Cadena de resolución: plugin > copia en .claude/ (la siembra es del sub-slice D) >
56
- # paquete resuelto por symlink. Sin fichero: "[]" — el registro sigue funcionando.
57
- sst_asset_types() {
58
- local pkg candidates f
59
- pkg="$(sst_pkg_root)"
60
- candidates="${CLAUDE_PLUGIN_ROOT:-}/asset-types.json
61
- $(config_root)/.claude/asset-types.json
62
- ${pkg:-}/asset-types.json"
63
- while IFS= read -r f; do
64
- case "$f" in /asset-types.json|asset-types.json) continue ;; esac
65
- [ -f "$f" ] || continue
66
- python3 - "$f" <<'PY' 2>/dev/null && return
67
- import json,sys
68
- try:
69
- d=json.load(open(sys.argv[1]))
70
- t=d.get("types")
71
- print(json.dumps(t if isinstance(t,list) else [],ensure_ascii=False))
72
- except Exception:
73
- raise SystemExit(1)
74
- PY
75
- done <<EOS
76
- $candidates
77
- EOS
78
- echo "[]"
79
- }
80
-
81
- HV="$(sst_harness_version)"
82
- AT="$(sst_asset_types)"
25
+ HV="$(harness_version)"
26
+ AT="$(harness_asset_types)"
83
27
 
84
28
  runtime_register "$HV" "$AT"
85
29
  RC=$?
@@ -17,12 +17,20 @@
17
17
  set -uo pipefail
18
18
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
19
  source "$HERE/lib/runtime-ops.sh"
20
+ # [#74] `register` manda el mismo harness_version/asset_types que el arranque de sesión.
21
+ source "$HERE/lib/harness-meta.sh"
20
22
 
21
23
  usage() {
22
24
  cat >&2 <<'USAGE'
23
25
  uso: slice-ops.sh <subcomando> [opciones]
24
26
 
25
27
  mode imprime legacy|dual|runtime
28
+ register [--runtime-url U] [--force] da de alta ESTE árbol como agente propio con
29
+ un token que el ADMIN emitió para él (el token
30
+ se pide por STDIN, nunca por argumento).
31
+ Obligatorio en un worktree antes de escribir:
32
+ sin él se opera con la identidad heredada del
33
+ clon principal.
26
34
  claim reclama trabajo (solo modo runtime; el hub
27
35
  reparte por orden de cola — no hay --epic)
28
36
  next-step deriva la siguiente acción desde la caché
@@ -56,6 +64,7 @@ uso: slice-ops.sh <subcomando> [opciones]
56
64
  escalate «razón» | --file F [--gate G] registra un bloqueo (la decisión es humana)
57
65
 
58
66
  códigos de salida: 0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo
67
+ · 9 identidad heredada (este worktree no está dado de alta: `register`)
59
68
  USAGE
60
69
  }
61
70
 
@@ -1331,6 +1340,16 @@ cmd_status() {
1331
1340
  esac
1332
1341
  done
1333
1342
  mode="$(ops_mode)"
1343
+ # [#74] `status` conserva el fallback a propósito — es la herramienta de diagnóstico de un
1344
+ # worktree que todavía no se dio de alta, y negarle la lectura lo dejaría a ciegas. Pero se
1345
+ # dice en la primera línea de quién es el estado que está mirando: sin el banner, un informe
1346
+ # heredado es indistinguible de uno propio, y es exactamente así como se llega a creer que
1347
+ # se tiene un lease que en realidad es de otro agente.
1348
+ if config_is_inherited_root; then
1349
+ echo "identidad: ⚠ HEREDADA del clon principal ($(config_main_clone_root)) — solo lectura"
1350
+ echo " lo de abajo es el estado de ESE agente, no del tuyo. Antes de reclamar"
1351
+ echo " o reportar: \`slice-ops.sh register\` con un token propio del ADMIN."
1352
+ fi
1334
1353
  echo "modo: $mode"
1335
1354
  if [ "$mode" = "legacy" ]; then
1336
1355
  echo "estado: el fichero .claude/state/build-state.json es la fuente de verdad (v0.8.5)"
@@ -1979,10 +1998,197 @@ cmd_checkpoint() {
1979
1998
  _ops_post_or_queue "$OPS_PATH_CHECKPOINTS" "$payload" checkpoint_recorded "$sid" "el checkpoint" "$queue_payload"
1980
1999
  }
1981
2000
 
2001
+ # ── register ─────────────────────────────────────────────────────────────────────────
2002
+ # [#74] Alta EXPLÍCITA de este árbol de trabajo como agente propio.
2003
+ #
2004
+ # El caso que lo motiva: dos agentes construyendo dos épicas en la misma máquina, cada uno en
2005
+ # su worktree. El hub reparte las dos sin objeción — el bloqueo estaba aquí: un worktree sin
2006
+ # `.claude/state/runtime.credentials` propio hereda el del clon principal por el fallback de
2007
+ # `config_root()` y trabaja con SU identidad sin decirlo.
2008
+ #
2009
+ # Tres decisiones de diseño que no son gratuitas:
2010
+ # 1. El token entra por STDIN (prompt si hay TTY), jamás por argumento: un `--token` queda
2011
+ # en el historial del shell y en `ps`. Por eso `register` es un comando de terminal
2012
+ # humano y NO se invoca desde dentro de un claim — los hooks y las tool calls no tienen
2013
+ # TTY y un `read` ahí colgaría la llamada.
2014
+ # 2. Se siembra PRIMERO el fichero local, para que `config_root()` ancle en este árbol antes
2015
+ # de cualquier merge. Al revés, `runtime_register` habría hecho upsert del token nuevo
2016
+ # sobre el fichero del clon principal, pisándole la identidad al agente que trabaja ahí.
2017
+ # 3. Se valida que el `agent_key` obtenido DIFIERA del que tiene el principal. El hub no crea
2018
+ # identidades en `/agents/register`: el `agent_key` es el `agent_id` que el ADMIN grabó en
2019
+ # el token al emitirlo. Mismo token ⇒ mismo agente ⇒ el claim devolvería el trabajo del
2020
+ # otro worktree. Un token por agente, siempre.
2021
+ #
2022
+ # Ante cualquier fallo se revierte: o el árbol queda con identidad propia y verificada, o
2023
+ # queda exactamente como estaba. Un fichero de credenciales a medias es peor que ninguno.
2024
+
2025
+ # _reg_rollback <fichero> <backup> — deja el fichero local como estaba antes de `register`.
2026
+ _reg_rollback() {
2027
+ local file="$1" backup="$2"
2028
+ if [ -n "$backup" ] && [ -f "$backup" ]; then
2029
+ cp "$backup" "$file" 2>/dev/null
2030
+ rm -f "$backup"
2031
+ else
2032
+ rm -f "$file"
2033
+ fi
2034
+ }
2035
+
2036
+ cmd_register() {
2037
+ local url="" force=0 wt main localfile mainfile mainkey newkey token backup="" hv at rc
2038
+ while [ $# -gt 0 ]; do
2039
+ case "$1" in
2040
+ --runtime-url) url="${2:-}"; [ $# -ge 2 ] && shift 2 || shift ;;
2041
+ --force) force=1; shift ;;
2042
+ --token|--project-token|--runtime-token)
2043
+ echo "⛔ el token NO se pasa por argumento: quedaría en el historial del shell y en \`ps\`." >&2
2044
+ echo " \`register\` lo pide por stdin. Córrelo en un terminal humano." >&2
2045
+ return $OPS_RC_USAGE ;;
2046
+ *) shift ;;
2047
+ esac
2048
+ done
2049
+ command -v python3 >/dev/null 2>&1 || {
2050
+ echo "⛔ register necesita python3 para escribir las credenciales." >&2; return $OPS_RC_OFFLINE; }
2051
+
2052
+ wt="$(config_candidate_root)"
2053
+ main="$(config_main_clone_root)"
2054
+ localfile="$wt/.claude/state/runtime.credentials"
2055
+ mainfile=""
2056
+ if [ -n "$main" ] && [ "$main" != "$wt" ]; then mainfile="$main/.claude/state/runtime.credentials"; fi
2057
+
2058
+ if [ -f "$localfile" ] && [ "$force" != 1 ]; then
2059
+ echo "este árbol YA tiene identidad propia — no se toca nada:"
2060
+ echo " raíz: $wt"
2061
+ echo " agente: $(runtime_credentials_field_at "$localfile" agent_key)"
2062
+ echo " proyecto: $(runtime_credentials_field_at "$localfile" project_id)"
2063
+ echo "(para re-registrarlo con OTRO token: \`register --force\`)"
2064
+ return $OPS_RC_OK
2065
+ fi
2066
+
2067
+ # La URL no es secreta y es la MISMA del proyecto: se hereda del principal si no se da.
2068
+ if [ -z "$url" ]; then
2069
+ url="$(runtime_credentials_field_at "$localfile" runtime_url)"
2070
+ [ -n "$url" ] || url="$(runtime_credentials_field_at "$mainfile" runtime_url)"
2071
+ fi
2072
+ case "$url" in
2073
+ http://*|https://*) : ;;
2074
+ *)
2075
+ echo "⛔ no hay runtime_url que usar (ni --runtime-url, ni credenciales del clon principal)." >&2
2076
+ echo " Pásala explícita: \`register --runtime-url https://…\`" >&2
2077
+ return $OPS_RC_USAGE ;;
2078
+ esac
2079
+
2080
+ mainkey="$(runtime_credentials_field_at "$mainfile" agent_key)"
2081
+
2082
+ if [ -t 0 ]; then
2083
+ printf 'Token de agente para este árbol (no se muestra en pantalla): ' >&2
2084
+ IFS= read -rs token
2085
+ printf '\n' >&2
2086
+ else
2087
+ IFS= read -r token || token=""
2088
+ fi
2089
+ token="$(printf '%s' "$token" | tr -d '[:space:]')"
2090
+ [ -n "$token" ] || { echo "⛔ token vacío: no se registró nada." >&2; return $OPS_RC_USAGE; }
2091
+
2092
+ # Compuerta barata y decisiva, ANTES de la red: si el token es el del clon principal, el hub
2093
+ # devolverá su misma identidad — el `agent_key` va grabado en el token, no se crea al
2094
+ # registrar. Preguntarlo por HTTP solo añadiría una llamada al mismo veredicto.
2095
+ if [ -n "$mainfile" ] && [ "$token" = "$(runtime_credentials_field_at "$mainfile" project_token)" ]; then
2096
+ token=""
2097
+ echo "⛔ ese token es la identidad del clon principal: registrarlo aquí daría el MISMO" >&2
2098
+ echo " agent_key y el claim te devolvería el trabajo que ya tiene reclamado el otro árbol." >&2
2099
+ echo " Pide al ADMIN un token con \`agent_id\` distinto (uno por agente)." >&2
2100
+ echo " El fichero del clon principal NO se ha tocado." >&2
2101
+ return $OPS_RC_REJECTED
2102
+ fi
2103
+
2104
+ if [ -f "$localfile" ]; then
2105
+ backup="$(mktemp)" && cp "$localfile" "$backup" 2>/dev/null || backup=""
2106
+ fi
2107
+
2108
+ if ! TRYCORE_REGISTER_TOKEN="$token" runtime_credentials_seed_at "$localfile" "$url"; then
2109
+ token=""
2110
+ _reg_rollback "$localfile" "$backup"
2111
+ echo "⛔ no se pudo escribir $localfile — revisa permisos." >&2
2112
+ return $OPS_RC_OFFLINE
2113
+ fi
2114
+ token="" # el secreto ya está en el fichero 0600: fuera de las variables del shell
2115
+
2116
+ hv="$(harness_version)"
2117
+ at="$(harness_asset_types)"
2118
+ runtime_register "$hv" "$at"
2119
+ rc=$?
2120
+ if [ "$rc" -ne 0 ]; then
2121
+ _reg_rollback "$localfile" "$backup"
2122
+ if [ "$rc" -eq 2 ]; then
2123
+ echo "⛔ el hub rechaza esta versión del arnés ($hv): incompatible_version." >&2
2124
+ echo " Actualiza el paquete y repite \`register\`." >&2
2125
+ return $OPS_RC_REJECTED
2126
+ fi
2127
+ case "$(runtime_http_status)" in
2128
+ 401|403)
2129
+ echo "⛔ el hub no acepta ese token (HTTP $(runtime_http_status)): pide al ADMIN uno válido" >&2
2130
+ echo " para este proyecto. Nada quedó escrito." >&2
2131
+ return $OPS_RC_REJECTED ;;
2132
+ *)
2133
+ echo "OFFLINE: no se pudo registrar contra $url (HTTP $(runtime_http_status))." >&2
2134
+ echo " Nada quedó escrito: repite \`register\` cuando haya red." >&2
2135
+ return $OPS_RC_OFFLINE ;;
2136
+ esac
2137
+ fi
2138
+
2139
+ newkey="$(runtime_credentials_field_at "$localfile" agent_key)"
2140
+ if [ -z "$newkey" ]; then
2141
+ _reg_rollback "$localfile" "$backup"
2142
+ echo "⛔ el hub respondió 200 pero sin \`agent_key\`: sin identidad verificable no se da" >&2
2143
+ echo " de alta el árbol. Nada quedó escrito." >&2
2144
+ return $OPS_RC_REJECTED
2145
+ fi
2146
+ if [ -n "$mainkey" ] && [ "$newkey" = "$mainkey" ]; then
2147
+ _reg_rollback "$localfile" "$backup"
2148
+ echo "⛔ ese token resuelve al MISMO agent_key que el clon principal ($mainkey): no es una" >&2
2149
+ echo " identidad nueva. El \`agent_key\` lo graba el ADMIN en el token al emitirlo —" >&2
2150
+ echo " pide uno con \`agent_id\` distinto. El fichero del clon principal NO se ha tocado." >&2
2151
+ return $OPS_RC_REJECTED
2152
+ fi
2153
+ rm -f "$backup"
2154
+
2155
+ # A partir de aquí `config_root()` ancla en este árbol, y con él TODA la resolución de
2156
+ # config: si `build-config.json` no viajó al worktree (no está versionado en el consumidor),
2157
+ # `runtime.mode` caería al default `legacy` y el árbol recién dado de alta dejaría de hablar
2158
+ # con el hub — el registro habría «funcionado» apagando justo lo que venía a encender. Se
2159
+ # copia del principal solo si aquí no hay ninguno: nunca se pisa una config local.
2160
+ if [ -n "$main" ] && [ ! -f "$wt/.claude/config/build-config.json" ] \
2161
+ && [ -f "$main/.claude/config/build-config.json" ]; then
2162
+ mkdir -p "$wt/.claude/config"
2163
+ cp "$main/.claude/config/build-config.json" "$wt/.claude/config/build-config.json" 2>/dev/null \
2164
+ && echo " (build-config.json copiado del clon principal: modo $(ops_mode))"
2165
+ fi
2166
+
2167
+ echo "árbol dado de alta con identidad propia:"
2168
+ echo " raíz: $wt"
2169
+ echo " agente: $newkey"
2170
+ echo " proyecto: $(runtime_credentials_field_at "$localfile" project_id)"
2171
+ [ -n "$mainkey" ] && echo " (distinto del clon principal: $mainkey — su fichero no se tocó)"
2172
+ echo "Desde aquí el claim, los gates y el heartbeat son de ESTE árbol. Siguiente paso:"
2173
+ echo " bash .claude/hooks/build/slice-ops.sh claim"
2174
+ return $OPS_RC_OK
2175
+ }
2176
+
1982
2177
  SUB="${1:-}"
1983
2178
  [ $# -gt 0 ] && shift
2179
+
2180
+ # [#74] Compuerta de identidad ANTES de despachar. Las operaciones de ESCRITURA — todo lo que
2181
+ # firma un acto con el `agent_key` o toca la cola/los ledgers — abortan si este árbol heredó la
2182
+ # identidad del clon principal. Las LECTURAS (`mode`, `next-step`, `status`, los `*-status`)
2183
+ # quedan fuera a propósito: son el diagnóstico de un worktree que aún no se dio de alta, y
2184
+ # `status` avisa de quién es el estado que enseña. `register` tampoco entra: es la salida.
2185
+ case " claim phase gate submit archive wiring progress checkpoint fact propose-asset propose-epic epic-writeback graph-sync escalate " in
2186
+ *" $SUB "*) ops_guard_own_identity "$SUB" || exit $? ;;
2187
+ esac
2188
+
1984
2189
  case "$SUB" in
1985
2190
  mode) ops_mode; exit 0 ;;
2191
+ register) cmd_register "$@"; exit $? ;;
1986
2192
  claim) cmd_claim "$@"; exit $? ;;
1987
2193
  next-step) cmd_next_step; exit $? ;;
1988
2194
  phase) cmd_phase "$@"; exit $? ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.15.0",
3
+ "version": "0.16.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": {
@@ -5,9 +5,19 @@
5
5
  (conjunto disjunto + merge_order) que un humano aprueba al abrir el front en la consola del
6
6
  hub. No llama al runtime ni lee el estado: entra JSON por stdin, sale JSON por stdout.
7
7
 
8
- stdin: JSON [{"epica","layer","files_scope":[glob]}]
8
+ stdin: JSON [{"epica","layer","files_scope":[glob],"depends_on":[EP-XXX]}]
9
9
  stdout: {"selected":[...],"serialized":[{"epica","reason"}],"excluded_foundational":[...]}
10
10
  Determinista: orden por 'epica'. Solape de globs por prefijo de directorio o glob idéntico.
11
+
12
+ Las tres compuertas, en orden (METODOLOGIA §5 «Compuertas del front»):
13
+ G1 `layer` fundacional -> excluded_foundational (nunca en paralelo).
14
+ G2a `files_scope` vacío o ausente -> serialized. «Sin `files_scope` declarado, una épica no
15
+ es candidata al front» (METODOLOGIA:343): fail-CLOSED. Que dos épicas sin ámbito
16
+ declarado salieran «disjuntas» era exactamente el fallo que el front no puede permitirse.
17
+ G2b camino en el DAG de `depends_on` con una épica ya seleccionada -> serialized. Dos épicas
18
+ encadenadas no son paralelizables aunque sus ficheros no se toquen: la de abajo construye
19
+ sobre lo que la de arriba todavía no ha mergeado.
20
+ G2c solape de `files_scope` con una épica ya seleccionada -> serialized.
11
21
  """
12
22
  import json, sys, fnmatch
13
23
 
@@ -24,6 +34,37 @@ def overlap(a, b):
24
34
  return True
25
35
  return False
26
36
 
37
+ def is_foundational(cand):
38
+ """G1 normalizada. El bundle del grafo emite `layer` en MAYÚSCULA (graph-bundle.py lo
39
+ `.upper()`a porque el hub lo exige así) mientras METODOLOGIA lo escribe en minúscula: un
40
+ `== "foundational"` a secas dejaba pasar `FOUNDATIONAL` al front, en silencio y justo en la
41
+ compuerta que más caro sale saltarse."""
42
+ layer = cand.get("layer")
43
+ return isinstance(layer, str) and layer.strip().lower() == "foundational"
44
+
45
+ def reachability(cands):
46
+ """Alcance transitivo de `depends_on` entre las candidatas (clausura por Floyd-Warshall
47
+ booleano; los conjuntos de épicas de un front son de decenas, no de miles).
48
+
49
+ Se calcula sobre TODAS las candidatas, fundacionales incluidas: un camino EP-A → EP-F → EP-B
50
+ encadena A y B aunque la fundacional del medio no entre al front. Las referencias a épicas
51
+ que no vienen en la entrada se ignoran — aquí no se inventa un grafo que no se ha visto."""
52
+ codes = [c.get("epica") for c in cands if isinstance(c.get("epica"), str)]
53
+ known = set(codes)
54
+ reach = {c: set() for c in codes}
55
+ for c in cands:
56
+ code = c.get("epica")
57
+ if code not in known:
58
+ continue
59
+ for dep in c.get("depends_on") or []:
60
+ if isinstance(dep, str) and dep in known and dep != code:
61
+ reach[code].add(dep)
62
+ for k in codes:
63
+ for i in codes:
64
+ if k in reach[i]:
65
+ reach[i] |= reach[k]
66
+ return reach
67
+
27
68
  def _empty_result():
28
69
  return {"selected": [], "serialized": [], "excluded_foundational": []}
29
70
 
@@ -33,15 +74,23 @@ def main():
33
74
  if not isinstance(cands, list) or not all(isinstance(c, dict) for c in cands):
34
75
  raise ValueError("entrada inválida: se esperaba una lista de objetos")
35
76
  cands = sorted(cands, key=lambda c: c.get("epica",""))
36
- excluded = [c["epica"] for c in cands if c.get("layer") == "foundational"]
37
- pool = [c for c in cands if c.get("layer") != "foundational"]
77
+ reach = reachability(cands)
78
+ excluded = [c["epica"] for c in cands if is_foundational(c)]
79
+ pool = [c for c in cands if not is_foundational(c)]
38
80
  selected, sel_scopes, serialized = [], [], []
39
81
  for c in pool:
82
+ code = c["epica"]
40
83
  sc = c.get("files_scope") or []
41
- if any(overlap(sc, s) for s in sel_scopes):
42
- serialized.append({"epica": c["epica"], "reason": "solape de files_scope con épica seleccionada"})
84
+ if not sc:
85
+ serialized.append({"epica": code,
86
+ "reason": "sin files_scope declarado: no es candidata al front (METODOLOGIA §5 G2)"})
87
+ elif any(code in reach.get(s, ()) or s in reach.get(code, ()) for s in selected):
88
+ serialized.append({"epica": code,
89
+ "reason": "dependencia en el grafo con una épica ya seleccionada"})
90
+ elif any(overlap(sc, s) for s in sel_scopes):
91
+ serialized.append({"epica": code, "reason": "solape de files_scope con épica seleccionada"})
43
92
  else:
44
- selected.append(c["epica"]); sel_scopes.append(sc)
93
+ selected.append(code); sel_scopes.append(sc)
45
94
  print(json.dumps({"selected":selected,"serialized":serialized,"excluded_foundational":excluded}))
46
95
  except Exception:
47
96
  print(json.dumps(_empty_result()))
@@ -5,7 +5,7 @@ DIR="$(cd "$(dirname "$0")" && pwd)"
5
5
  FAILED=0
6
6
 
7
7
  echo "── Tests del motor de contexto y front ──"
8
- for t in test-schema.sh test-config.sh test-runtime-client.sh test-hooks-runtime.sh test-skill-ops.sh test-reconciler.sh test-context-monitor.sh test-front-plan.sh test-install.sh; do
8
+ for t in test-schema.sh test-config.sh test-runtime-client.sh test-hooks-runtime.sh test-skill-ops.sh test-worktree-identity.sh test-reconciler.sh test-context-monitor.sh test-front-plan.sh test-install.sh; do
9
9
  if bash "$DIR/tests/$t"; then echo "smoke: $t OK"; else echo "smoke: $t FALLÓ"; FAILED=1; fi
10
10
  done
11
11
 
@@ -63,4 +63,44 @@ got="$(cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_root)"
63
63
  [ "$got" = "$BARE" ] && echo "OK config_root sin estado devuelve el candidato" \
64
64
  || { echo "FAIL config_root sin estado ($got)"; fail=1; }
65
65
 
66
+ # ── [#74] Señal de identidad heredada ─────────────────────────────────────────
67
+ # El fallback de arriba es correcto para LEER y veneno para ESCRIBIR: sin una señal
68
+ # que lo distinga, un worktree sin credenciales propias firma sus actos con la
69
+ # identidad del clon principal y nadie se entera. Estos tres helpers son esa señal.
70
+
71
+ got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_candidate_root)"
72
+ WTP="$(cd "$WT" && pwd -P)"
73
+ [ "$got" = "$WTP" ] && echo "OK config_candidate_root devuelve ESTE árbol, sin fallback" \
74
+ || { echo "FAIL config_candidate_root ($got, esperado $WTP)"; fail=1; }
75
+
76
+ got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_main_clone_root)"
77
+ [ "$got" = "$MAIN" ] && echo "OK config_main_clone_root ve el clon principal desde el worktree" \
78
+ || { echo "FAIL config_main_clone_root ($got, esperado $MAIN)"; fail=1; }
79
+
80
+ (cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_is_inherited_root) \
81
+ && echo "OK config_is_inherited_root detecta el worktree sin credenciales propias" \
82
+ || { echo "FAIL config_is_inherited_root no detecta la herencia"; fail=1; }
83
+
84
+ # El clon principal NUNCA hereda: es el dueño del estado.
85
+ (cd "$MAIN" && CLAUDE_PROJECT_DIR="$MAIN" config_is_inherited_root) \
86
+ && { echo "FAIL config_is_inherited_root marca herencia en el clon principal"; fail=1; } \
87
+ || echo "OK config_is_inherited_root no marca al clon principal"
88
+
89
+ # Un proyecto sin arnés tampoco: no hay identidad que heredar.
90
+ (cd "$BARE" && CLAUDE_PROJECT_DIR="$BARE" config_is_inherited_root) \
91
+ && { echo "FAIL config_is_inherited_root marca herencia sin estado en ninguna parte"; fail=1; } \
92
+ || echo "OK config_is_inherited_root no marca un proyecto sin arnés"
93
+
94
+ # Y en cuanto el worktree tiene credenciales PROPIAS, deja de heredar — sin tocar nada
95
+ # más: es exactamente lo que `slice-ops.sh register` deja hecho.
96
+ mkdir -p "$WT/.claude/state"
97
+ echo '{"runtime_url":"http://localhost","agent_key":"ak-wt"}' > "$WT/.claude/state/runtime.credentials"
98
+ (cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_is_inherited_root) \
99
+ && { echo "FAIL sigue heredando con credenciales propias"; fail=1; } \
100
+ || echo "OK con credenciales propias el worktree deja de heredar"
101
+ got="$(cd "$WT" && CLAUDE_PROJECT_DIR="$WT" config_root)"
102
+ [ "$got" = "$WT" ] && echo "OK config_root ancla en el worktree ya registrado" \
103
+ || { echo "FAIL config_root tras registrar ($got, esperado $WT)"; fail=1; }
104
+ rm -f "$WT/.claude/state/runtime.credentials"
105
+
66
106
  exit $fail
@@ -25,6 +25,59 @@ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
25
25
  assert d['selected']==['EP-010'] and [m['epica'] for m in d['serialized']]==['EP-011'], d
26
26
  print('OK front-plan mid-wildcard overlap serializado')" || { echo "FAIL mid-wildcard overlap"; fail=1; }
27
27
 
28
+ # ── [#74] G1 normalizada: el bundle del grafo emite `layer` en MAYÚSCULA ─────────────
29
+ # graph-bundle.py hace `.upper()` porque el hub lo exige así; front-plan comparaba contra
30
+ # "foundational" en minúscula. La exclusión G1 no disparaba, en silencio, justo en la
31
+ # compuerta que más caro sale saltarse.
32
+ out="$(run '[{"epica":"EP-001","layer":"FOUNDATIONAL","files_scope":["src/**"]},
33
+ {"epica":"EP-002","layer":"BUSINESS","files_scope":["src/a/**"]}]')"
34
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
35
+ assert d['excluded_foundational']==['EP-001'], d
36
+ assert d['selected']==['EP-002'], d
37
+ print('OK front-plan G1 con layer en MAYÚSCULA')" || { echo "FAIL G1 mayúscula"; fail=1; }
38
+
39
+ # ── [#74] G2 fail-CLOSED sin files_scope ─────────────────────────────────────────────
40
+ # METODOLOGIA:343: «sin `files_scope` declarado, una épica no es candidata al front». El
41
+ # código hacía lo contrario: con la lista vacía, `overlap([], …)` es falso y dos épicas sin
42
+ # ámbito salían como disjuntas.
43
+ out="$(run '[{"epica":"EP-020","layer":"business","files_scope":[]},
44
+ {"epica":"EP-021","layer":"business"},
45
+ {"epica":"EP-022","layer":"business","files_scope":["src/c/**"]}]')"
46
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
47
+ assert d['selected']==['EP-022'], d
48
+ assert {m['epica'] for m in d['serialized']}=={'EP-020','EP-021'}, d
49
+ assert all('files_scope' in m['reason'] for m in d['serialized']), d
50
+ print('OK front-plan sin files_scope -> serialized (fail-closed)')" || { echo "FAIL fail-closed sin files_scope"; fail=1; }
51
+
52
+ # ── [#74] G2 por camino en el DAG de `depends_on` ────────────────────────────────────
53
+ # Ficheros disjuntos no bastan: si EP-031 depende de EP-030, la de abajo construye sobre lo
54
+ # que la de arriba todavía no ha mergeado. La transitividad cuenta, y el camino vale aunque
55
+ # pase por una fundacional que no entra al front.
56
+ out="$(run '[{"epica":"EP-030","layer":"business","files_scope":["src/x/**"]},
57
+ {"epica":"EP-031","layer":"business","files_scope":["src/y/**"],"depends_on":["EP-030"]},
58
+ {"epica":"EP-032","layer":"business","files_scope":["src/z/**"],"depends_on":["EP-031"]},
59
+ {"epica":"EP-033","layer":"business","files_scope":["src/w/**"],"depends_on":["EP-099"]}]')"
60
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
61
+ assert d['selected']==['EP-030','EP-033'], d
62
+ assert {m['epica'] for m in d['serialized']}=={'EP-031','EP-032'}, d
63
+ assert all('grafo' in m['reason'] for m in d['serialized']), d
64
+ print('OK front-plan serializa el camino transitivo del DAG')" || { echo "FAIL DAG depends_on"; fail=1; }
65
+
66
+ out="$(run '[{"epica":"EP-040","layer":"business","files_scope":["src/x/**"]},
67
+ {"epica":"EP-041","layer":"FOUNDATIONAL","files_scope":["src/f/**"],"depends_on":["EP-040"]},
68
+ {"epica":"EP-042","layer":"business","files_scope":["src/z/**"],"depends_on":["EP-041"]}]')"
69
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
70
+ assert d['selected']==['EP-040'], d
71
+ assert [m['epica'] for m in d['serialized']]==['EP-042'], d
72
+ print('OK front-plan ve el camino que pasa por una fundacional excluida')" || { echo "FAIL DAG a través de fundacional"; fail=1; }
73
+
74
+ # Sin `depends_on` declarado nada cambia: el campo es opcional, no se inventa un grafo.
75
+ out="$(run '[{"epica":"EP-050","layer":"business","files_scope":["src/p/**"]},
76
+ {"epica":"EP-051","layer":"business","files_scope":["src/q/**"]}]')"
77
+ echo "$out" | python3 -c "import json,sys;d=json.load(sys.stdin);
78
+ assert d['selected']==['EP-050','EP-051'] and d['serialized']==[], d
79
+ print('OK front-plan sin depends_on no serializa de más')" || { echo "FAIL sin depends_on"; fail=1; }
80
+
28
81
  SK="$ROOT/skills/managing-parallel-front/SKILL.md"
29
82
  { [ -f "$SK" ] && grep -q 'git worktree add' "$SK" && grep -q 'front-plan.py' "$SK" \
30
83
  && grep -q 'merge_order' "$SK" && grep -q 'foundational' "$SK"; } \
@@ -35,4 +88,14 @@ SK="$ROOT/skills/managing-parallel-front/SKILL.md"
35
88
  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
89
  grep -q 'parallel_front' "$ROOT/agents/build/build-orchestrator.md" && echo "OK orquestador front" || { echo "FAIL orquestador front"; fail=1; }
37
90
 
91
+ # [#74] La skill decía «mismo token de proyecto»: el hub lo refuta (el `agent_key` es el
92
+ # `agent_id` grabado en el token por el ADMIN). Un token repetido = un solo agente.
93
+ grep -q 'mismo token de proyecto' "$SK" \
94
+ && { echo "FAIL la skill sigue diciendo «mismo token de proyecto»"; fail=1; } \
95
+ || echo "OK la skill ya no manda reusar el token de proyecto"
96
+ grep -q 'slice-ops.sh register' "$SK" && echo "OK la skill manda dar de alta el worktree con register" \
97
+ || { echo "FAIL la skill no menciona register"; fail=1; }
98
+ grep -q 'depends_on' "$SK" && echo "OK la skill pide depends_on entre las candidatas" \
99
+ || { echo "FAIL la skill no pide depends_on"; fail=1; }
100
+
38
101
  exit $fail