@trycore/spec-build-harness 0.11.1 → 0.13.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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "trycore-spec-build-harness",
4
4
  "displayName": "Trycore — Spec & Build Harness",
5
- "version": "0.11.1",
5
+ "version": "0.13.0",
6
6
  "description": "Arnés de construcción de dos loops (slice por épica + release gate) para Claude Code, con gates de calidad, estado compartido y OpenSpec. Compañero de @trycore/spec-product-flow. Agnóstico al proyecto.",
7
7
  "author": {
8
8
  "name": "Trycore",
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.11.1
1
+ 0.13.0
@@ -243,30 +243,47 @@ en el runtime. **Importarlo es un acto de gobierno**: la superficie exige sesió
243
243
  el token de agente. El arnés **prepara y valida**; una persona sube.
244
244
 
245
245
  1. Extrae el grafo de los artefactos de discovery ya leídos (`docs/03-backlog/epicas.md`,
246
- `docs/02-user-story-map/`, `docs/04-historias/`) a un JSON:
246
+ `docs/02-user-story-map/`, `docs/04-historias/`) a un JSON. **Las dependencias las extrae
247
+ el script, no tú** (paso 2): puebla `depends_on` con lo que veas, pero **nunca lo dejes
248
+ vacío por defecto** — un grafo sin aristas no falla, solo empobrece el Lienzo en silencio
249
+ (issue #56: 41/41 épicas llegaron al hub con `depends_on: []`).
247
250
 
248
251
  ```json
249
252
  {"project_ref": "<nombre del proyecto>",
250
253
  "epics": [{"code": "EP-001", "title": "…", "layer": "foundational",
251
254
  "files_scope": ["src/core/**"], "depends_on": [],
252
- "stories": [{"id": "HU-001", "title": "…"}]}],
253
- "release_lines": [{"id": "R1-mvp", "epics": ["EP-001"]}]}
255
+ "stories": [{"id": "HU-001", "title": "…"}]},
256
+ {"code": "EP-002", "title": "", "layer": "business",
257
+ "files_scope": ["src/pagos/**"], "depends_on": ["EP-001"],
258
+ "stories": [{"id": "HU-011", "title": "…"}]}],
259
+ "release_lines": [{"id": "R1-mvp", "epics": ["EP-001", "EP-002"]}]}
254
260
  ```
255
261
 
256
- 2. Normalízalo y valídalo (determinista, nunca sube nada). La salida va a
257
- `.claude/state/graph-bundle.json` para que `trycore-build migrate` la encuentre sola:
262
+ 2. Normalízalo y valídalo (determinista, nunca sube nada). **Pasa siempre `--from-docs`**: el
263
+ script relee el campo `**Depende de**` de cada sección de épica y **une** ese grafo al del
264
+ JSON — es la única lectura fiable de las dependencias, que la metodología escribe como
265
+ prosa. La salida va a `.claude/state/graph-bundle.json` para que `trycore-build migrate` la
266
+ encuentre sola:
258
267
 
259
268
  ```bash
260
- python3 .claude/scripts/lib/graph-bundle.py < /tmp/epics.json > .claude/state/graph-bundle.json
269
+ python3 .claude/scripts/lib/graph-bundle.py --from-docs docs/03-backlog/epicas.md \
270
+ < /tmp/epics.json > .claude/state/graph-bundle.json
261
271
  ```
262
272
 
273
+ Para inspeccionar solo lo extraído (sin armar el bundle):
274
+ `python3 .claude/scripts/lib/graph-bundle.py --print-deps docs/03-backlog/epicas.md`.
275
+ Si una épica declara el campo estructurado `depende_de: [EP-001]`, ese gana sobre la prosa.
276
+
263
277
  El script emite las épicas en el **formato exacto del hub**: `layer` en MAYÚSCULA
264
278
  (`FOUNDATIONAL`|`BUSINESS` — un layer inválido rechaza el grafo ENTERO), historias con
265
279
  `code`, y `release_line` (string) por épica.
266
280
 
267
281
  3. **Lee los avisos** (`warnings` del bundle y stderr): capa ausente, dependencia inexistente,
268
- ciclo, línea de release que referencia una épica desconocida. Corrígelos en discovery el arnés
269
- **no inventa** el grafo ni edita `epicas.md` (salvo el carve-out de la épica caparazón, Fase 2c).
282
+ ciclo, línea de release que referencia una épica desconocida, épica que está en los docs pero
283
+ no en tu JSON, épica sin campo `Depende de`. Contrasta la línea `info: N aristas en el
284
+ grafo` con lo que declara el backlog: **0 aristas en un backlog con dependencias es el
285
+ síntoma de #56**. Corrígelos en discovery — el arnés **no inventa** el grafo ni edita
286
+ `epicas.md` (salvo el carve-out de la épica caparazón, Fase 2c).
270
287
 
271
288
  4. **Entrega al ADMIN un solo fichero.** Si vas a migrar historial, corre `trycore-build migrate`:
272
289
  embebe el grafo como sección `graph` del bundle de estado (un único fichero para la pantalla de
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { targetPaths } from '../lib/paths.js';
11
- import { runtimeMode, readCredentials, getAgentContext, readLock, outboxStats } from '../lib/runtime-client.js';
11
+ import { runtimeMode, readCredentials, getAgentContext, readLock, outboxStats, rejectedStats } from '../lib/runtime-client.js';
12
12
  export function hasBinary(bin) {
13
13
  if (process.platform === 'win32') {
14
14
  return spawnSync('where', [bin], { stdio: 'ignore' }).status === 0;
@@ -128,6 +128,17 @@ export async function doctor(opts) {
128
128
  else {
129
129
  console.log(' Cola: vacía');
130
130
  }
131
+ // [issue #52] rejected/ era invisible: cola vacía ≠ todo entregado — el hub pudo
132
+ // descartar eventos (p. ej. un slice_archived sin la cadena de fases).
133
+ const rej = rejectedStats(targetDir);
134
+ if (rej.count > 0) {
135
+ console.log(` Rechazados: ⚠ ${rej.count} evento(s) en outbox/rejected/ — el hub los descartó, NO se reenvían`);
136
+ console.log(` último: ${rej.latestType ?? '?'} — ${rej.latestReason ?? 'razón no registrada'}`);
137
+ console.log(' revisa la causa y re-emite el hecho corregido');
138
+ }
139
+ else {
140
+ console.log(' Rechazados: ninguno');
141
+ }
131
142
  }
132
143
  // Detección de doble canal (CLI settings.json + plugin) [H3]
133
144
  const settingsHasHooks = fs.existsSync(t.settingsFile) &&
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { readPackageVersion, targetPaths, PACKAGE_ROOT } from '../lib/paths.js';
5
5
  import { countChildren } from '../lib/install-engine.js';
6
6
  import { hasBinary } from './doctor.js';
7
- import { runtimeMode, getAgentContext, readProjection, readLock } from '../lib/runtime-client.js';
7
+ import { runtimeMode, getAgentContext, readProjection, readLock, rejectedStats } from '../lib/runtime-client.js';
8
8
  export async function status(opts) {
9
9
  const targetDir = path.resolve(opts.targetDir);
10
10
  const t = targetPaths(targetDir);
@@ -61,6 +61,14 @@ export async function status(opts) {
61
61
  }
62
62
  const lock = readLock(targetDir);
63
63
  console.log(` Contexto: ${lock.manifestHash ? `✓ sincronizado (${lock.fileCount} archivo(s))` : '⚠ sin sincronizar'}`);
64
+ // [issue #52] Eventos que el hub descartó (outbox/rejected/): sin esta línea, una cola
65
+ // en 0 parecía éxito mientras un slice_archived rechazado dejaba la épica «en
66
+ // construcción» para siempre en el hub.
67
+ const rej = rejectedStats(targetDir);
68
+ if (rej.count > 0) {
69
+ console.log(` Rechazados: ⚠ ${rej.count} evento(s) en outbox/rejected/ — el hub los descartó, NO se reenvían`);
70
+ console.log(` último: ${rej.latestType ?? '?'} — ${rej.latestReason ?? 'razón no registrada'}`);
71
+ }
64
72
  if (mode === 'runtime') {
65
73
  console.log('══════════════════════════════════════════════════════');
66
74
  return;
@@ -1118,6 +1118,17 @@ export function verifyNormalizedBundle(bundle) {
1118
1118
  message: 'source_key debe tener entre 1 y 200 caracteres (LegacyImported, event_catalog.py:446)',
1119
1119
  });
1120
1120
  }
1121
+ // ImportBundleIn tipa `original` como dict: un escalar responde 422
1122
+ // `dict_type` en la validación pydantic y tumba el bundle ENTERO, antes de
1123
+ // la persistencia por-entrada (issue #55). El bundle sale envuelto de
1124
+ // `wireOriginal`; esto es la red que lo detecta en seco si alguien vuelve a
1125
+ // emitir un escalar por otra vía.
1126
+ if (typeof u.original !== 'object' || u.original === null || Array.isArray(u.original)) {
1127
+ violations.push({
1128
+ source_key: `unmapped[${i}]`,
1129
+ message: 'original debe ser un objeto JSON: el hub lo tipa como dict y responde 422 dict_type sobre el bundle entero (ImportBundleIn)',
1130
+ });
1131
+ }
1121
1132
  });
1122
1133
  return violations;
1123
1134
  }
@@ -184,6 +184,40 @@ export function outboxStats(targetDir) {
184
184
  return { count: 0, totalBytes: 0, oldestAgeSeconds: null };
185
185
  }
186
186
  }
187
+ /**
188
+ * Eventos apartados a `outbox/rejected/` — el hub (o el pre-filtro local) los descartó y
189
+ * NO se reenvían (issue #52): una cola con 0 pendientes puede seguir escondiendo hechos
190
+ * perdidos (p. ej. un `slice_archived` sin la cadena de fases). El despacho persiste la
191
+ * razón en el propio fichero (`rejected_reason`); el más reciente es el último por nombre
192
+ * (los nombres llevan el timestamp de encolado). Fail-open: sin directorio o con ficheros
193
+ * ilegibles se degrada a conteo sin razón, jamás lanza.
194
+ */
195
+ export function rejectedStats(targetDir) {
196
+ const dir = path.join(runtimePaths(targetDir).outboxDir, 'rejected');
197
+ try {
198
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('.')).sort();
199
+ if (files.length === 0)
200
+ return { count: 0, latestType: null, latestReason: null };
201
+ let latestType = null;
202
+ let latestReason = null;
203
+ try {
204
+ const d = JSON.parse(fs.readFileSync(path.join(dir, files[files.length - 1]), 'utf8'));
205
+ if (typeof d.type === 'string')
206
+ latestType = d.type;
207
+ else if (typeof d.event_type === 'string')
208
+ latestType = d.event_type;
209
+ if (typeof d.rejected_reason === 'string')
210
+ latestReason = d.rejected_reason;
211
+ }
212
+ catch {
213
+ // Fichero ilegible: el conteo sigue valiendo; la razón queda en null.
214
+ }
215
+ return { count: files.length, latestType, latestReason };
216
+ }
217
+ catch {
218
+ return { count: 0, latestType: null, latestReason: null };
219
+ }
220
+ }
187
221
  /** ¿El host resuelve una URL http(s) mínimamente bien formada? Validación local, sin red. */
188
222
  export function isPlausibleUrl(url) {
189
223
  try {
@@ -72,6 +72,20 @@ export function graphEpicsWithoutRepresentation(graph, representedCodes) {
72
72
  .map((e) => (typeof e.code === 'string' ? e.code : ''))
73
73
  .filter((code) => code !== '' && !represented.has(code));
74
74
  }
75
+ /** El hub tipa `unmapped[].original` como dict (`ImportBundleIn`): un escalar
76
+ * responde 422 `dict_type` y tumba el bundle ENTERO en la validación pydantic,
77
+ * antes de la persistencia por-entrada — observado en el piloto con
78
+ * `facts[project_kind_source]: "auto"` (issue #55), pero lo emiten cuatro
79
+ * sitios (fact fuera de catálogo, elemento de history[] no-objeto, nota de
80
+ * release y `parallel_front.status: "closed"`). Los normalizadores conservan
81
+ * el valor legacy tal cual — la envoltura pertenece al cable y solo alcanza a
82
+ * lo que no es ya un objeto JSON, para no anidar dos veces lo que ya viaja bien. */
83
+ function wireOriginal(entry) {
84
+ const { original } = entry;
85
+ if (typeof original === 'object' && original !== null && !Array.isArray(original))
86
+ return entry;
87
+ return { ...entry, original: { value: original } };
88
+ }
75
89
  export function buildStateBundle(raw, projectRef, preHarness) {
76
90
  const warnings = [];
77
91
  const st = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : {};
@@ -94,7 +108,7 @@ export function buildStateBundle(raw, projectRef, preHarness) {
94
108
  const fronts = front ? [front] : [];
95
109
  const factsUnmapped = [];
96
110
  const facts = normalizeFacts(st, EPOCH_ISO, warnings, factsUnmapped);
97
- const unmapped = [...historyUnmapped, ...releasesUnmapped, ...frontUnmapped, ...factsUnmapped];
111
+ const unmapped = [...historyUnmapped, ...releasesUnmapped, ...frontUnmapped, ...factsUnmapped].map(wireOriginal);
98
112
  // Épicas pre-arnés (issue #42): lista CONFIRMADA por el humano — entrada
99
113
  // sintética archivada sin gates por épica (patrón del workaround del piloto).
100
114
  // Conflicto obvio ⇒ error, no fusión: una épica declarada pre-arnés que YA
@@ -97,6 +97,7 @@ Offline: sin runtime se trabaja con el último lock; la statusline marca `⚠ st
97
97
  - Un archivo JSON por evento (no por lote — `runtime_enqueue_event`), con `client_event_id` (UUID) ⇒ **idempotencia server-side** (reintentos seguros). El despacho (`runtime_dispatch_outbox`) sí agrupa en un único `POST /events` por invocación, con el sobre que el hub exige: `{"events": [{client_event_id, event_type, payload, slice_id?}]}` — la clave interna `type` de los ficheros de la outbox se traduce a `event_type` **al armar el cuerpo** (el formato en disco no cambia; ficheros encolados por 0.10.x drenan sin migración). El ack real del hub es `{accepted[], duplicates[], rejected[{client_event_id, reason}]}` (issue #37).
98
98
  - **Capa de compat de nombres/payloads en el mismo punto de salida** (issue #44): los productores encolan los nombres del **catálogo v2** del hub (`gate_verdict`, `checkpoint_recorded`, `slice_escalated`, `progress_noted`), pero los ficheros 0.10.x con los nombres viejos (`verdict_reported`, `checkpoint_created`, `escalation_raised`, `progress_note_recorded`) drenan traducidos — tipo **y** claves de payload (`status`→`verdict` en mayúscula, `note`→`summary`, `reason/gate/phase`→`cause`) — sin migración. Los tipos **sin equivalente** en el catálogo (`tool_use_recorded`, `branch_drift`, `front_integration_reported`) se apartan localmente a `outbox/rejected/` sin gastar red.
99
99
  - **Un 4xx no es red caída**: un 4xx del lote (salvo 408/429) o un elemento en `rejected[]` del ack **no se reintenta** — el fichero se mueve a `outbox/rejected/` con la razón loggeada y se avisa. Solo 408/429/5xx/corte de red conservan la cola con backoff (fail-open intacto).
100
+ - **`rejected/` es visible, con su razón persistida** (issue #52): al apartar un evento (elemento rechazado en el ack, pre-filtro local de tipos sin equivalente o lote 4xx) el despacho escribe la razón DENTRO del fichero apartado (`rejected_reason`). La superficie la muestran `slice-ops.sh status` y `trycore-build status`/`doctor` (conteo + tipo y razón del más reciente): antes el rechazo solo salía por el stderr de un daemon que nadie lee — la cola marcaba 0 pendientes y parecía éxito mientras el hub había descartado un `slice_archived` (el piloto lo sufrió: épicas «en construcción» con el PR mergeado). Un rechazo NO se reintenta solo: se corrige la causa y se re-emite el hecho.
100
101
  - Despacho en background con backoff persistido (`1 s → 5 s → 30 s → 5 min`, tope; el estado vive en `outbox/.dispatch-state.json` y sobrevive entre invocaciones de hooks distintos). Orden FIFO global — el agrupado por-agregado nace con el catálogo de eventos (sub-slice C).
101
102
  - Cota: 5 MB / 72 h — al superarla se descartan primero los eventos evictables (todo tipo fuera de la lista protegida), **nunca** `checkpoint_recorded`, `gate_verdict`, `slice_escalated`, `slice_submitted`, `slice_archived`, `wiring_*`, `project_fact_updated`, `handoff_recorded` ni el propio `telemetry_gap` (nombres del catálogo v2; los alias 0.10.x de los cuatro renombrados siguen protegidos porque la capa de compat los entrega). El descarte se reporta como evento `telemetry_gap` con el payload del catálogo `{dropped, window_h, reason}` (issue #44), coalescido en un único gap mientras la cola siga sobre la cota.
102
103
  - Flush forzado en `Stop`: `session-stop.sh` deja el sentinela `outbox/.flush-request`; el daemon `heartbeat.sh` lo consume de forma asíncrona (nunca en el hilo del hook). `trycore-build doctor` **reporta** el tamaño/edad de la cola pero **no** dispara un flush síncrono — sigue sin implementar.
@@ -173,15 +174,16 @@ modo, valida en local lo que es barato validar y encola lo que no pudo entregar.
173
174
  | `slice-ops.sh mode` | ninguna (lee `build-config.json`) |
174
175
  | `slice-ops.sh claim` | `POST /tasks/next` (§3); `--epic` falla explícito, rc 2 (no hay claim dirigido, §3.6) |
175
176
  | `slice-ops.sh next-step` | ninguna: lo **deriva el cliente** desde la caché de proyección |
177
+ | `slice-ops.sh phase <to>` | evento(s) `phase_advanced {to}` en cadena (issue #52): el hub lleva la fase con orden ESTRICTO (`dor→change→red→green→refactor→smoke→api→data→dod→pr`, `domain.py` `PHASES`) y solo acepta la fase siguiente exacta. El cliente emite la cadena que falte desde la fase conocida (proyección ∪ `phase_advanced` pendientes en la cola) hasta `<to>`; idempotente (nada si ya está). `archived` no se reporta aquí (lo produce `archive`). Siempre por cola en `runtime` (tipo protegido de la cota: evictar un eslabón haría ilegal todo lo posterior); espejo en `dual` (`phase_advanced` ∈ `TIPOS_DE_ESPEJO`) |
176
178
  | `slice-ops.sh gate <g> <pass\|fail\|na>` | `POST /slices/{id}/verdicts` (`{gate, verdict: PASS\|FAIL, evidence}`); offline → evento `gate_verdict`. `na` **no viaja**: el catálogo no lo admite (aviso local, rc 0, issue #44) |
177
179
  | `slice-ops.sh wiring seed\|update` | eventos `wiring_checklist_seeded` (items `{item_id, kind, ref}`, sin `status`) / `wiring_item_updated` |
178
180
  | `slice-ops.sh progress` | evento `progress_noted` (evictable) |
179
181
  | `slice-ops.sh checkpoint` | `POST /checkpoints` (`summary`, no `note`); offline → evento `checkpoint_recorded {branch, commit_sha, summary}` |
180
182
  | `slice-ops.sh submit` | `POST /slices/{id}/submit`; offline → evento `slice_submitted` con payload `{}` (el catálogo no admite campos) |
181
- | `slice-ops.sh archive` | evento `slice_archived` con payload `{}` y `slice_id` (siempre por cola, idempotente) |
183
+ | `slice-ops.sh archive` | evento `slice_archived` con payload `{}` y `slice_id` (siempre por cola, idempotente). **Catch-up de fase** (issue #52): `slice_archived` exige `phase == pr` en el hub — si la fase conocida quedó atrás (pipeline corrido sin `phase`), se emite antes la cadena `phase_advanced` que falte hasta `pr`, con aviso `⚠ … server-side`. Es el workaround del piloto (2026-08-28) hecho sistema; sin él el archivado caía a `rejected/` en silencio y la épica quedaba «en construcción» para siempre |
182
184
  | `slice-ops.sh fact …` | hechos máquina → evento `project_fact_updated` (sin `slice_id`); hechos humanos → rc 6 con remisión al PATCH admin (§3) |
183
185
  | `slice-ops.sh propose-asset` | `POST …/context/agent-proposals` (§6) |
184
- | `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales |
186
+ | `slice-ops.sh status` | `GET /agent/context` (refresco) + ficheros locales; reporta además `outbox/rejected/` (conteo + tipo y razón del rechazo más reciente, issue #52) |
185
187
  | `slice-ops.sh escalate` | evento `slice_escalated {cause}` (gate y fase dentro del texto de la causa; exige slice activo en runtime) |
186
188
  | `release-ops.sh verdict <line> <gate> <estado>` | `POST /releases/{line}/verdicts`; **sin fallback offline** (rc 5 y reintento al reconectar: el agregado `release` no entra por `POST /events`, issue #44) |
187
189
  | `release-ops.sh close-hint <line>` | **ninguna**: el cierre es humano, con PDP |
@@ -199,3 +201,29 @@ reducer **no bloquea** el trabajo local: se avisa y queda como discrepancia del
199
201
  **Lo que el agente NO hace** (superficies humanas, con PDP): cerrar una release, planificar/abrir/
200
202
  drenar/cerrar un front, subir el bundle de import del grafo y publicar contexto. El arnés prepara,
201
203
  propone y reporta; la persona decide en la consola del hub.
204
+
205
+ ---
206
+
207
+ ## 11. Bundle de import histórico (`POST /projects/{id}/import/bundle`)
208
+
209
+ Lo produce `trycore-build migrate` (`src/lib/state-bundle.ts`) y lo sube una persona con sesión de
210
+ ADMIN. La validación del hub es **pydantic sobre el cuerpo entero**: un solo campo mal tipado
211
+ responde 422 y rechaza el bundle completo **antes** de la persistencia por-entrada — no hay
212
+ degradación parcial. De ahí que el contrato de las secciones "de rescate" importe tanto como el de
213
+ los eventos.
214
+
215
+ `unmapped[]` — todo lo que el estado legacy trae y el catálogo del hub no sabe nombrar (spec §7:
216
+ *nada se pierde, nada bloquea*):
217
+
218
+ | Campo | Tipo exigido | Nota |
219
+ |---|---|---|
220
+ | `source_key` | string, 1..200 | `LegacyImported`, `event_catalog.py:446` |
221
+ | `original` | **dict** | un escalar responde 422 `dict_type` (issue #55) |
222
+ | `occurred_at` | ISO-8601 UTC | ancla del dato legacy, no la hora de la migración |
223
+
224
+ El estado legacy guarda escalares en cuatro de los sitios que caen aquí (fact fuera de catálogo,
225
+ elemento de `history[]` no-objeto, nota de release, `parallel_front.status: "closed"`), así que
226
+ `buildStateBundle` **envuelve** todo `original` que no sea ya un objeto JSON como `{"value": …}`.
227
+ La envoltura es del cable: los normalizadores conservan el valor legacy tal cual, y un `original`
228
+ que ya es dict viaja intacto (nunca se anida dos veces). `migrate --verify` comprueba el invariante
229
+ en seco, sin red.
@@ -272,7 +272,10 @@ RUNTIME_OUTBOX_MAX_AGE_S=259200
272
272
  # los aparta a rejected/). Los tres alias 0.10.x del final (`checkpoint_created`,
273
273
  # `verdict_reported`, `escalation_raised`) siguen protegidos SOLO porque la capa de
274
274
  # compat del despacho los traduce y entrega — evictarlos perdería hechos entregables.
275
- RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated checkpoint_created verdict_reported escalation_raised"
275
+ # [#52] `phase_advanced` es protegido: el orden de fases del hub es ESTRICTO — evictar un
276
+ # eslabón de la cadena haría ilegales todos los eventos posteriores del slice (incluido el
277
+ # slice_archived, que exige phase == pr).
278
+ RUNTIME_OUTBOX_PROTECTED="checkpoint_recorded gate_verdict slice_escalated slice_submitted slice_archived handoff_recorded telemetry_gap wiring_checklist_seeded wiring_item_updated project_fact_updated phase_advanced checkpoint_created verdict_reported escalation_raised"
276
279
 
277
280
  runtime_outbox_dir() {
278
281
  echo "$(config_root)/.claude/state/outbox"
@@ -513,9 +516,15 @@ for path in sys.argv[2:]:
513
516
  continue
514
517
  t=d.get("event_type") or d.get("type") or ""
515
518
  if t in SIN_EQUIVALENTE:
519
+ # [#52] La razón viaja DENTRO del fichero apartado (rejected_reason): es lo que
520
+ # `slice-ops.sh status` y `trycore-build status/doctor` muestran después — sin
521
+ # esto el rechazo era invisible (stderr de un daemon que nadie lee).
516
522
  try:
517
523
  os.makedirs(rejected_dir,exist_ok=True)
518
- os.replace(path,os.path.join(rejected_dir,os.path.basename(path)))
524
+ d["rejected_reason"]="%r no existe en el catálogo v2 del hub (issue #44): no se envió" % t
525
+ dest=os.path.join(rejected_dir,os.path.basename(path))
526
+ json.dump(d,open(dest,"w"),indent=2,ensure_ascii=False)
527
+ os.unlink(path)
519
528
  print("⚠ %r no existe en el catálogo v2 del hub: apartado a rejected/, no se enviará (issue #44)"
520
529
  % t, file=sys.stderr)
521
530
  except OSError:
@@ -572,7 +581,8 @@ elif isinstance(resp,dict):
572
581
  reasons[cid]=r.get("reason") or "sin razón"
573
582
  for p in paths:
574
583
  try:
575
- cid=json.load(open(p)).get("client_event_id")
584
+ d=json.load(open(p))
585
+ cid=d.get("client_event_id")
576
586
  except Exception:
577
587
  continue
578
588
  st=status.get(cid)
@@ -582,9 +592,14 @@ for p in paths:
582
592
  elif st=="rejected":
583
593
  print("⚠ el hub rechazó el evento %s: %s — apartado a rejected/, no se reenviará"
584
594
  % (cid, reasons.get(cid,"sin razón")), file=sys.stderr)
595
+ # [#52] La razón del hub se persiste en el fichero apartado (rejected_reason):
596
+ # la muestran `slice-ops.sh status` y `trycore-build status/doctor`.
585
597
  try:
586
598
  os.makedirs(rejected_dir,exist_ok=True)
587
- os.replace(p, os.path.join(rejected_dir, os.path.basename(p)))
599
+ d["rejected_reason"]=reasons.get(cid,"sin razón")
600
+ dest=os.path.join(rejected_dir, os.path.basename(p))
601
+ json.dump(d,open(dest,"w"),indent=2,ensure_ascii=False)
602
+ os.unlink(p)
588
603
  except OSError:
589
604
  pass
590
605
  PY
@@ -606,8 +621,30 @@ PY
606
621
  echo " ${#files[@]} evento(s) movidos a $rejected_dir/ — NO se reintentarán." >&2
607
622
  echo " Revisa el contrato del cliente (docs/runtime/protocolo-cliente-runtime.md) o" >&2
608
623
  echo " actualiza el arnés; el trabajo local sigue (fail-open)." >&2
624
+ # [#52] También aquí la razón (HTTP status) queda persistida en cada fichero apartado.
625
+ python3 - "$rejected_dir" "$status" "${files[@]}" <<'PY' 2>/dev/null
626
+ import json,sys,os
627
+ rd,st=sys.argv[1],sys.argv[2]
628
+ for p in sys.argv[3:]:
629
+ try:
630
+ d=json.load(open(p))
631
+ except Exception:
632
+ d=None
633
+ try:
634
+ dest=os.path.join(rd,os.path.basename(p))
635
+ if isinstance(d,dict):
636
+ d["rejected_reason"]="el hub rechazó el lote entero (HTTP %s)" % st
637
+ json.dump(d,open(dest,"w"),indent=2,ensure_ascii=False)
638
+ os.unlink(p)
639
+ else:
640
+ os.replace(p,dest)
641
+ except OSError:
642
+ pass
643
+ PY
644
+ # Fallback si python3 desapareció a mitad de camino: mover sin anotar (mejor apartado
645
+ # sin razón que reintento eterno).
609
646
  for f in "${files[@]}"; do
610
- mv "$f" "$rejected_dir/" 2>/dev/null
647
+ [ -f "$f" ] && mv "$f" "$rejected_dir/" 2>/dev/null
611
648
  done
612
649
  rm -f "$state_file"
613
650
  return 1
@@ -34,6 +34,70 @@ OPS_MAX_TEXT=1970
34
34
  OPS_SLICE_GATES="dor coherence_link tdd journey_smoke fidelity api data wiring_verified dod"
35
35
  OPS_RELEASE_GATES="security smell ux coherence stack_arch integration"
36
36
 
37
+ # Fases canónicas del slice en el hub (domain.py PHASES, issue #52), SIN `archived`:
38
+ # esa fase terminal se alcanza con el evento `slice_archived`, nunca con `phase_advanced`.
39
+ # El orden es NORMATIVO: el reducer del hub solo acepta la fase siguiente exacta
40
+ # (orden estricto, domain.py:400-407) — de aquí salen las cadenas que emite `phase`.
41
+ OPS_SLICE_PHASES="dor change red green refactor smoke api data dod pr"
42
+
43
+ # ops_phase_idx <fase> — índice de la fase en OPS_SLICE_PHASES; -1 si no es fase del hub.
44
+ ops_phase_idx() {
45
+ local i=0 p
46
+ for p in $OPS_SLICE_PHASES; do
47
+ [ "$p" = "$1" ] && { echo "$i"; return; }
48
+ i=$((i + 1))
49
+ done
50
+ echo -1
51
+ }
52
+
53
+ # ops_phase_at <idx> — nombre de la fase en esa posición ("" fuera de rango).
54
+ ops_phase_at() {
55
+ local i=0 p
56
+ for p in $OPS_SLICE_PHASES; do
57
+ [ "$i" = "$1" ] && { echo "$p"; return; }
58
+ i=$((i + 1))
59
+ done
60
+ echo ""
61
+ }
62
+
63
+ # ops_known_phase [slice_id] — la fase del slice que el SERVIDOR conoce (o va a conocer):
64
+ # la de la caché de proyección, avanzada por los `phase_advanced` PENDIENTES de la outbox
65
+ # para ese slice (el daemon aún no drenó, pero el cliente ya los emitió — sin esto, dos
66
+ # llamadas seguidas a `phase` duplicarían la cadena y el hub las rechazaría una a una).
67
+ # Una fase desconocida o ausente cae a `dor` (fase de nacimiento del slice): sobre-emitir
68
+ # desde atrás es seguro — el lote se aplica en orden y el hub rechaza los eslabones ya
69
+ # aplicados elemento a elemento mientras el sufijo válido entra.
70
+ ops_known_phase() {
71
+ local sid="${1:-}" base idx pidx
72
+ base="$(projection_get active_slice.phase "")"
73
+ idx="$(ops_phase_idx "$base")"
74
+ [ "$idx" -ge 0 ] || idx=0
75
+ if [ -n "$sid" ] && command -v python3 >/dev/null 2>&1; then
76
+ pidx="$(python3 - "$(runtime_outbox_dir)" "$sid" "$OPS_SLICE_PHASES" <<'PY' 2>/dev/null
77
+ import json,sys,glob,os
78
+ d,sid,phases=sys.argv[1],sys.argv[2],sys.argv[3].split()
79
+ best=-1
80
+ for p in glob.glob(os.path.join(d,"*.json")):
81
+ if os.path.basename(p).startswith("."):
82
+ continue
83
+ try:
84
+ e=json.load(open(p))
85
+ except Exception:
86
+ continue
87
+ if e.get("type")!="phase_advanced" or e.get("slice_id")!=sid:
88
+ continue
89
+ to=(e.get("payload") or {}).get("to")
90
+ if to in phases:
91
+ best=max(best,phases.index(to))
92
+ print(best)
93
+ PY
94
+ )"
95
+ case "$pidx" in ''|*[!0-9]*) pidx=-1 ;; esac
96
+ [ "$pidx" -gt "$idx" ] && idx="$pidx"
97
+ fi
98
+ ops_phase_at "$idx"
99
+ }
100
+
37
101
  # ── Tabla de rutas ──
38
102
  OPS_PATH_CLAIM="/tasks/next"
39
103
  OPS_PATH_CHECKPOINTS="/checkpoints"
@@ -4,9 +4,9 @@
4
4
  # `building-a-slice`, los comandos /build:* y los agentes, UNA invocación por transición.
5
5
  #
6
6
  # uso: slice-ops.sh <subcomando> [opciones]
7
- # subcomandos IMPLEMENTADOS (los 13 del plan): mode | claim | next-step | gate | submit |
8
- # archive | wiring | progress | checkpoint | fact | propose-asset | status |
9
- # escalate.
7
+ # subcomandos IMPLEMENTADOS (13 del plan + phase, issue #52): mode | claim | next-step |
8
+ # phase | gate | submit | archive | wiring | progress | checkpoint | fact |
9
+ # propose-asset | status | escalate.
10
10
  #
11
11
  # Contrato con la prosa: cada subcomando devuelve un código de salida tipado
12
12
  # (0 ok · 2 uso · 3 legacy · 4 sin slice · 5 offline · 6 rechazado · 7 sin trabajo) y
@@ -24,6 +24,8 @@ uso: slice-ops.sh <subcomando> [opciones]
24
24
  claim reclama trabajo (solo modo runtime; el hub
25
25
  reparte por orden de cola — no hay --epic)
26
26
  next-step deriva la siguiente acción desde la caché
27
+ phase <to> reporta la fase del pipeline (emite la cadena
28
+ phase_advanced que falte; idempotente)
27
29
  gate <nombre> <pass|fail|na> [--evidence «texto» | --evidence-file F]
28
30
  reporta un veredicto del slice (hecho medido)
29
31
  submit [--pr-url U] [--openspec-change C] entrega el slice
@@ -470,6 +472,30 @@ cmd_status() {
470
472
  fi
471
473
  pend="$(find "$(runtime_outbox_dir)" -maxdepth 1 -name '*.json' ! -name '.dispatch-state.json' 2>/dev/null | wc -l | tr -d ' ')"
472
474
  echo "cola: $pend evento(s) pendiente(s) de despacho"
475
+ # [issue #52] outbox/rejected/ era invisible: la cola mostraba 0 pendientes y parecía
476
+ # éxito mientras el hub había descartado eventos (p.ej. un slice_archived sin la cadena
477
+ # de fases). Se reporta el conteo y la razón del rechazo más reciente.
478
+ local rej rejinfo
479
+ rej="$(find "$(runtime_outbox_dir)/rejected" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
480
+ if [ "${rej:-0}" -gt 0 ] 2>/dev/null; then
481
+ echo "rechazados: ⚠ $rej evento(s) en outbox/rejected/ — el hub los descartó, NO se reenvían"
482
+ rejinfo="$(command -v python3 >/dev/null 2>&1 && python3 - "$(runtime_outbox_dir)/rejected" <<'PY' 2>/dev/null
483
+ import json,glob,os,sys
484
+ files=sorted(glob.glob(os.path.join(sys.argv[1],"*.json")))
485
+ if files:
486
+ try:
487
+ d=json.load(open(files[-1]))
488
+ except Exception:
489
+ d={}
490
+ print("%s — %s" % (d.get("type") or d.get("event_type") or "?",
491
+ d.get("rejected_reason") or "razón no registrada (apartado por un cliente anterior)"))
492
+ PY
493
+ )"
494
+ [ -n "$rejinfo" ] && echo " último: $rejinfo"
495
+ echo " revisa la causa y re-emite el hecho corregido (un rechazo no se reintenta solo)"
496
+ else
497
+ echo "rechazados: ninguno"
498
+ fi
473
499
  echo "nudges: $(projection_get nudges '[]')"
474
500
  return $OPS_RC_OK
475
501
  }
@@ -623,6 +649,91 @@ _ops_post_or_queue() {
623
649
  esac
624
650
  }
625
651
 
652
+ # ── phase ────────────────────────────────────────────────────────────────────────────
653
+ # [issue #52] El hub modela la fase con `phase_advanced {to}` de ORDEN ESTRICTO
654
+ # (domain.py:400-407: solo la fase siguiente exacta) y `slice_archived` exige phase == pr
655
+ # (domain.py:499-500). Este subcomando emite la cadena que falte DESDE la fase conocida
656
+ # (proyección ∪ cola pendiente, ops_known_phase) HASTA <to>: nada si ya está (idempotente),
657
+ # la cadena completa si hay huecos. Va SIEMPRE por la cola en runtime (no hay endpoint
658
+ # síncrono de fase) y por el espejo en dual (phase_advanced ∈ TIPOS_DE_ESPEJO del hub).
659
+
660
+ # _phase_chain_enqueue <sid> <desde_idx> <hasta_idx> — encola phase_advanced por paso.
661
+ # rc 0 si toda la cadena quedó encolada; rc 5 si algún eslabón no se pudo encolar (y se
662
+ # corta ahí: encolar eslabones posteriores con un hueco delante era rechazo garantizado).
663
+ _phase_chain_enqueue() {
664
+ local sid="$1" i="$2" hasta="$3" step pfile
665
+ i=$((i + 1))
666
+ while [ "$i" -le "$hasta" ]; do
667
+ step="$(ops_phase_at "$i")"
668
+ pfile="$(ops_tmpjson "{\"to\":\"$step\"}")" || return $OPS_RC_OFFLINE
669
+ if ! ops_enqueue_from_file phase_advanced "$pfile" "$sid"; then
670
+ rm -f "$pfile"
671
+ echo "⚠ phase_advanced → «${step}» NO se pudo encolar (outbox no escribible u otro fallo local)" >&2
672
+ return $OPS_RC_OFFLINE
673
+ fi
674
+ rm -f "$pfile"
675
+ i=$((i + 1))
676
+ done
677
+ return $OPS_RC_OK
678
+ }
679
+
680
+ # _phase_chain_mirror <desde_idx> <hasta_idx> — espeja la cadena en dual (fichero primario).
681
+ # Un rechazo del espejo no bloquea (queda como discrepancia del comparador, spec §6.1).
682
+ _phase_chain_mirror() {
683
+ local i="$1" hasta="$2" step
684
+ i=$((i + 1))
685
+ while [ "$i" -le "$hasta" ]; do
686
+ step="$(ops_phase_at "$i")"
687
+ _ops_dual_mirror phase_advanced "{\"to\":\"$step\"}" >/dev/null
688
+ i=$((i + 1))
689
+ done
690
+ return $OPS_RC_OK
691
+ }
692
+
693
+ cmd_phase() {
694
+ local to="${1:-}" mode sid base bidx tidx rc
695
+ if [ -z "$to" ]; then
696
+ echo "uso: slice-ops.sh phase <to> (fases: $OPS_SLICE_PHASES)" >&2
697
+ return $OPS_RC_USAGE
698
+ fi
699
+ tidx="$(ops_phase_idx "$to")"
700
+ if [ "$tidx" -lt 0 ]; then
701
+ echo "⛔ fase desconocida «${to}». Canónicas: $OPS_SLICE_PHASES" >&2
702
+ echo " (la fase terminal archived no se reporta por aquí: la produce \`slice-ops.sh archive\`)" >&2
703
+ return $OPS_RC_REJECTED
704
+ fi
705
+ mode="$(ops_mode)"
706
+ [ "$mode" = "legacy" ] && { echo "LEGACY: la fase vive en el fichero (active_slice.phase, references/state-protocol.md)"; return $OPS_RC_LEGACY; }
707
+ # La base es la fase que el SERVIDOR conoce: refresco de la proyección a mejor esfuerzo
708
+ # (fail-open: sin red se sigue con la caché y con los pendientes de la cola).
709
+ agent_context_fetch_and_cache >/dev/null 2>&1
710
+ if [ "$mode" = "dual" ]; then
711
+ base="$(ops_known_phase)"
712
+ bidx="$(ops_phase_idx "$base")"; [ "$bidx" -ge 0 ] || bidx=0
713
+ if [ "$bidx" -ge "$tidx" ]; then
714
+ echo "fase «${to}» ya reportada (server-side: ${base}) — nada que emitir"
715
+ return $OPS_RC_OK
716
+ fi
717
+ [ $((tidx - bidx)) -gt 1 ] && echo "⚠ el slice estaba en «${base}» server-side: se espeja la cadena completa hasta «${to}» (orden estricto del hub, sin saltos)"
718
+ _phase_chain_mirror "$bidx" "$tidx"
719
+ echo "fase espejada: ${base} → ${to} ($((tidx - bidx)) transición(es) phase_advanced)"
720
+ return $OPS_RC_OK
721
+ fi
722
+ sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
723
+ base="$(ops_known_phase "$sid")"
724
+ bidx="$(ops_phase_idx "$base")"; [ "$bidx" -ge 0 ] || bidx=0
725
+ if [ "$bidx" -ge "$tidx" ]; then
726
+ echo "fase «${to}» ya reportada (server-side o pendiente en la cola: ${base}) — nada que emitir"
727
+ return $OPS_RC_OK
728
+ fi
729
+ [ $((tidx - bidx)) -gt 1 ] && echo "⚠ el slice estaba en «${base}» server-side: se emite la cadena completa hasta «${to}» (orden estricto del hub, sin saltos)"
730
+ _phase_chain_enqueue "$sid" "$bidx" "$tidx"
731
+ rc=$?
732
+ [ "$rc" = "$OPS_RC_OK" ] || return $rc
733
+ echo "fase reportada: ${base} → ${to} ($((tidx - bidx)) evento(s) phase_advanced encolados; se entregan al reconectar)"
734
+ return $OPS_RC_OK
735
+ }
736
+
626
737
  # ── gate ─────────────────────────────────────────────────────────────────────────────
627
738
  # Un veredicto es un HECHO MEDIDO: acto de agente (spec §2). El nombre del gate y el
628
739
  # estado se validan en el cliente (compuerta barata, sin round-trip); la compuerta real,
@@ -717,8 +828,24 @@ cmd_archive() {
717
828
  done
718
829
  mode="$(ops_mode)"
719
830
  [ "$mode" = "legacy" ] && { echo "LEGACY: archiva moviendo active_slice a history[] en el fichero"; return $OPS_RC_LEGACY; }
831
+ # [issue #52] Catch-up de fase: `slice_archived` exige phase == pr en el hub
832
+ # (domain.py:499-500) y el orden de fases es estricto — un pipeline que no reportó
833
+ # `phase` dejaba el slice en dor server-side y el archivado caía a rejected/ EN SILENCIO
834
+ # (piloto 2026-08-28: slices «en construcción» con el PR ya mergeado). Antes de emitir el
835
+ # archivado se completa la cadena phase_advanced que falte hasta pr — el workaround del
836
+ # piloto, hecho sistema. Sin refresco de red: la base sale de la caché ∪ cola pendiente
837
+ # (sobre-emitir desde atrás es seguro: el hub aplica el lote en orden y rechaza solo los
838
+ # eslabones ya aplicados, elemento a elemento).
839
+ local base bidx pridx
840
+ pridx="$(ops_phase_idx pr)"
720
841
  payload="{\"epic_code\":$(ops_json_str "$epic"),\"openspec_change\":$(ops_json_str "$change"),\"pr_url\":$(ops_json_str "$pr")}"
721
842
  if [ "$mode" = "dual" ]; then
843
+ base="$(ops_known_phase)"
844
+ bidx="$(ops_phase_idx "$base")"; [ "$bidx" -ge 0 ] || bidx=0
845
+ if [ "$bidx" -lt "$pridx" ]; then
846
+ echo "⚠ el slice estaba en «${base}» server-side: se espeja la cadena phase_advanced hasta pr antes del archivado"
847
+ _phase_chain_mirror "$bidx" "$pridx"
848
+ fi
722
849
  _ops_dual_mirror slice_archived "$payload"
723
850
  return $?
724
851
  fi
@@ -726,6 +853,12 @@ cmd_archive() {
726
853
  # tipo es de ámbito slice (slice_id de primer nivel OBLIGATORIO): con --epic/--pr-url
727
854
  # en el payload o sin slice el hub rechazaba TODO archivado — payload {} y slice exigido.
728
855
  sid="$(_ops_require_slice)" || return $OPS_RC_NO_SLICE
856
+ base="$(ops_known_phase "$sid")"
857
+ bidx="$(ops_phase_idx "$base")"; [ "$bidx" -ge 0 ] || bidx=0
858
+ if [ "$bidx" -lt "$pridx" ]; then
859
+ echo "⚠ el slice estaba en «${base}» server-side: se encola la cadena phase_advanced hasta pr antes del archivado"
860
+ _phase_chain_enqueue "$sid" "$bidx" "$pridx" || return $OPS_RC_OFFLINE
861
+ fi
729
862
  pfile="$(ops_tmpjson "{}")" || return $OPS_RC_OFFLINE
730
863
  if ops_enqueue_from_file slice_archived "$pfile" "$sid"; then
731
864
  rm -f "$pfile"
@@ -933,6 +1066,7 @@ case "$SUB" in
933
1066
  mode) ops_mode; exit 0 ;;
934
1067
  claim) cmd_claim "$@"; exit $? ;;
935
1068
  next-step) cmd_next_step; exit $? ;;
1069
+ phase) cmd_phase "$@"; exit $? ;;
936
1070
  gate) cmd_gate "$@"; exit $? ;;
937
1071
  submit) cmd_submit "$@"; exit $? ;;
938
1072
  archive) cmd_archive "$@"; exit $? ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trycore/spec-build-harness",
3
- "version": "0.11.1",
3
+ "version": "0.13.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": {