@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.
- package/.claude-plugin/plugin.json +1 -1
- package/VERSION +1 -1
- package/commands/build/onboard.md +25 -8
- package/dist/commands/doctor.js +12 -1
- package/dist/commands/status.js +9 -1
- package/dist/lib/normalize.js +11 -0
- package/dist/lib/runtime-client.js +34 -0
- package/dist/lib/state-bundle.js +15 -1
- package/docs/runtime/protocolo-cliente-runtime.md +30 -2
- package/hooks/build/lib/runtime-client.sh +42 -5
- package/hooks/build/lib/runtime-ops.sh +64 -0
- package/hooks/build/slice-ops.sh +137 -3
- package/package.json +1 -1
- package/scripts/lib/graph-bundle.py +165 -1
- package/scripts/tests/test-skill-ops.sh +356 -9
- package/skills/building-a-slice/SKILL.md +10 -0
- package/skills/building-a-slice/references/runtime-protocol.md +7 -2
- package/skills/openspec-archive-change/SKILL.md +4 -1
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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).
|
|
257
|
-
|
|
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
|
|
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
|
|
269
|
-
|
|
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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) &&
|
package/dist/commands/status.js
CHANGED
|
@@ -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;
|
package/dist/lib/normalize.js
CHANGED
|
@@ -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 {
|
package/dist/lib/state-bundle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
package/hooks/build/slice-ops.sh
CHANGED
|
@@ -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 (
|
|
8
|
-
#
|
|
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.
|
|
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": {
|