@trycore/spec-build-harness 0.11.0 → 0.12.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/release.md +4 -3
- package/dist/commands/doctor.js +12 -1
- package/dist/commands/status.js +9 -1
- package/dist/lib/runtime-client.js +34 -0
- package/docs/runtime/protocolo-cliente-runtime.md +4 -2
- package/hooks/build/lib/runtime-client.sh +42 -5
- package/hooks/build/lib/runtime-ops.sh +64 -0
- package/hooks/build/release-ops.sh +32 -7
- package/hooks/build/slice-ops.sh +137 -3
- package/package.json +1 -1
- package/scripts/tests/test-skill-ops.sh +238 -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
- package/skills/releasing-a-version/SKILL.md +8 -5
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +3 -3
|
@@ -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.12.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.12.0
|
|
@@ -70,8 +70,9 @@ reviewer. Es el gate **no negociable**: sin él, no hay release.
|
|
|
70
70
|
- Reporta **cada** gate medido con
|
|
71
71
|
`bash .claude/hooks/build/release-ops.sh verdict <line> <gate> <pass|fail|na> --evidence-file f`
|
|
72
72
|
(`na` cuando no aplica). El **agregado** lo hace el runtime: **parciales no promueven a `passed`**.
|
|
73
|
-
- Con los seis reportados
|
|
74
|
-
|
|
73
|
+
- Con los seis reportados **en verde, el hub auto-cierra** (`closed_by: AUTO_6_OF_6_PASS`; lo
|
|
74
|
+
anuncia la respuesta del último `verdict`). Si la release queda degradada, el cierre es
|
|
75
|
+
decisión humana en la consola (`release-ops.sh close-hint <line>` imprime la política).
|
|
75
76
|
- Algo ✗ → los hallazgos bloqueantes se corrigen **como un slice normal**
|
|
76
77
|
(`/build:slice` / `building-a-slice`) y se **re-corre** el Release Gate.
|
|
77
78
|
|
|
@@ -83,5 +84,5 @@ reviewer. Es el gate **no negociable**: sin él, no hay release.
|
|
|
83
84
|
vez por release (`O(releases)`).
|
|
84
85
|
- **No dupliques el inner loop**: no se hace TDD ni se cierran gates por slice.
|
|
85
86
|
- **`integration` con deps reales es obligatorio**; no se acepta con todo stubbeado.
|
|
86
|
-
- **El agente reporta
|
|
87
|
+
- **El agente reporta; el verde se auto-cierra; el degradado lo decide un humano** (política A-con-B): ninguna llamada de cierre con token de agente — el auto-cierre lo ejecuta el runtime al agregar, no el cliente.
|
|
87
88
|
- Delega en `releasing-a-version`; **no** la reimplementa. Si algo contradice `METODOLOGIA.md`, gana la metodología.
|
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;
|
|
@@ -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 {
|
|
@@ -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 |
|
|
@@ -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"
|
|
@@ -35,7 +35,7 @@ _rel_evidence_json() {
|
|
|
35
35
|
|
|
36
36
|
# ── verdict ──────────────────────────────────────────────────────────────────────────
|
|
37
37
|
cmd_verdict() {
|
|
38
|
-
local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile
|
|
38
|
+
local line="${1:-}" gate="${2:-}" estado="${3:-}" ev="" evfile="" mode payload resp status pfile rel_status closed_by
|
|
39
39
|
if [ -z "$line" ] || [ -z "$gate" ] || [ -z "$estado" ]; then usage; return $OPS_RC_USAGE; fi
|
|
40
40
|
shift 3
|
|
41
41
|
while [ $# -gt 0 ]; do
|
|
@@ -57,7 +57,7 @@ cmd_verdict() {
|
|
|
57
57
|
esac
|
|
58
58
|
mode="$(ops_mode)"
|
|
59
59
|
[ "$mode" = "legacy" ] && { echo "LEGACY: escribe el gate en releases[] del fichero"; return $OPS_RC_LEGACY; }
|
|
60
|
-
payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
|
|
60
|
+
payload="{\"release_line\":$(ops_json_str "$line"),\"gate\":$(ops_json_str "$gate"),\"status\":$(ops_json_str "$estado"),\"verdict\":$(ops_json_str "$(printf %s "$estado" | tr a-z A-Z)"),\"evidence\":$(_rel_evidence_json "$ev" "$evfile")}"
|
|
61
61
|
if [ "$mode" = "dual" ]; then
|
|
62
62
|
pfile="$(ops_tmpjson "$payload")" || return $OPS_RC_OFFLINE
|
|
63
63
|
ops_mirror release_verdict_reported "$pfile" || echo "⚠ veredicto de release no espejado; el comparador dual lo marcará" >&2
|
|
@@ -69,7 +69,30 @@ cmd_verdict() {
|
|
|
69
69
|
case "$status" in
|
|
70
70
|
200|201|202)
|
|
71
71
|
echo "veredicto de release registrado: $gate=$estado en $line"
|
|
72
|
-
|
|
72
|
+
# [A-con-B] El hub auto-cierra la release cuando el ÚLTIMO veredicto de cada uno
|
|
73
|
+
# de los 6 gates canónicos es PASS (closed_by=AUTO_6_OF_6_PASS, queda en el audit
|
|
74
|
+
# trail). Con algún fail/partial el cierre sigue siendo decisión HUMANA en la
|
|
75
|
+
# consola. Fail-open: un hub previo sin release_status cae al recordatorio.
|
|
76
|
+
rel_status="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
|
77
|
+
try:
|
|
78
|
+
print(json.load(sys.stdin).get("release_status") or "")
|
|
79
|
+
except Exception:
|
|
80
|
+
print("")' 2>/dev/null)"
|
|
81
|
+
closed_by="$(printf '%s' "$resp" | python3 -c 'import json,sys
|
|
82
|
+
try:
|
|
83
|
+
print(json.load(sys.stdin).get("closed_by") or "")
|
|
84
|
+
except Exception:
|
|
85
|
+
print("")' 2>/dev/null)"
|
|
86
|
+
case "$rel_status" in
|
|
87
|
+
PASSED)
|
|
88
|
+
echo "✅ release «${line}» CERRADA por el hub: 6/6 gates PASS (closed_by: ${closed_by:-AUTO_6_OF_6_PASS})." ;;
|
|
89
|
+
FAILED)
|
|
90
|
+
echo "⛔ release «${line}» en FAILED en el hub; los hallazgos bloqueantes se corrigen como slice y se re-mide." ;;
|
|
91
|
+
IN_PROGRESS)
|
|
92
|
+
echo "release aún IN_PROGRESS: el hub auto-cierra al llegar el 6/6 PASS; con algún fail/partial el cierre es decisión humana en la consola (spec §2)." ;;
|
|
93
|
+
*)
|
|
94
|
+
echo "El hub auto-cierra con 6/6 PASS; si algún gate queda en fail, el cierre/decisión es humano en la consola (spec §2)." ;;
|
|
95
|
+
esac
|
|
73
96
|
return $OPS_RC_OK ;;
|
|
74
97
|
422)
|
|
75
98
|
echo "⛔ 422 el servidor rechazó el veredicto: $resp — no reintentes; corrige y vuelve a medir." >&2
|
|
@@ -96,11 +119,13 @@ cmd_close_hint() {
|
|
|
96
119
|
local line="${1:-}"
|
|
97
120
|
[ -n "$line" ] || { usage; return $OPS_RC_USAGE; }
|
|
98
121
|
cat <<HINT
|
|
99
|
-
|
|
122
|
+
Política de cierre de la release «${line}» (A-con-B):
|
|
100
123
|
1. El agente reporta los 6 veredictos con \`release-ops.sh verdict $line <gate> <estado>\`.
|
|
101
|
-
2. El runtime agrega:
|
|
102
|
-
|
|
103
|
-
|
|
124
|
+
2. El runtime agrega y AUTO-CIERRA en verde: con el último veredicto de los 6 gates
|
|
125
|
+
canónicos en PASS la release pasa a PASSED (closed_by=AUTO_6_OF_6_PASS, audit trail).
|
|
126
|
+
3. Los casos DEGRADADOS (algún fail/partial, deudas por decidir) siguen siendo gobierno
|
|
127
|
+
humano: una persona con permiso decide y cierra en la consola del hub (PDP).
|
|
128
|
+
Parciales no promueven; el gobierno humano vive donde hay decisión real, no en el clic.
|
|
104
129
|
HINT
|
|
105
130
|
return $OPS_RC_OK
|
|
106
131
|
}
|
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.12.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": {
|
|
@@ -83,7 +83,7 @@ rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
|
83
83
|
# protegidos porque la capa de compat del despacho los traduce y entrega.)
|
|
84
84
|
for t in slice_archived wiring_item_updated slice_escalated gate_verdict checkpoint_recorded \
|
|
85
85
|
project_fact_updated wiring_checklist_seeded slice_submitted handoff_recorded telemetry_gap \
|
|
86
|
-
checkpoint_created verdict_reported escalation_raised; do
|
|
86
|
+
phase_advanced checkpoint_created verdict_reported escalation_raised; do
|
|
87
87
|
case " $RUNTIME_OUTBOX_PROTECTED " in
|
|
88
88
|
*" $t "*) : ;;
|
|
89
89
|
*) echo "FAIL $t no está en RUNTIME_OUTBOX_PROTECTED"; fail=1 ;;
|
|
@@ -394,6 +394,28 @@ rc=$?
|
|
|
394
394
|
echo "$out" | grep -q "modo:" && echo "$out" | grep -q "sl-7" && echo "$out" | grep -qi "cola" \
|
|
395
395
|
&& echo "OK status informa modo, slice y cola" || { echo "FAIL status incompleto: $out"; fail=1; }
|
|
396
396
|
|
|
397
|
+
# [issue #52] Visibilidad de outbox/rejected/: sin rechazados, status lo dice («ninguno»);
|
|
398
|
+
# con rechazados, reporta el conteo y la RAZÓN del más reciente — hoy un slice_archived
|
|
399
|
+
# rechazado era invisible (cola 0 pendientes = «éxito» aparente).
|
|
400
|
+
echo "$out" | grep -qi "rechazados" && echo "$out" | grep -qi "ninguno" \
|
|
401
|
+
&& echo "OK status sin rechazados reporta «ninguno»" || { echo "FAIL status sin línea de rechazados: $out"; fail=1; }
|
|
402
|
+
mkdir -p "$PROJ/.claude/state/outbox/rejected"
|
|
403
|
+
cat > "$PROJ/.claude/state/outbox/rejected/20260828000000000001-cid-rej-1.json" <<'JSON'
|
|
404
|
+
{"client_event_id": "cid-rej-1", "type": "progress_noted", "payload": {"note": "x"},
|
|
405
|
+
"enqueued_at": "2026-08-28T00:00:00.000000Z", "rejected_reason": "razón vieja"}
|
|
406
|
+
JSON
|
|
407
|
+
cat > "$PROJ/.claude/state/outbox/rejected/20260828000000000002-cid-rej-2.json" <<'JSON'
|
|
408
|
+
{"client_event_id": "cid-rej-2", "type": "slice_archived", "payload": {},
|
|
409
|
+
"enqueued_at": "2026-08-28T00:00:01.000000Z", "slice_id": "sl-7",
|
|
410
|
+
"rejected_reason": "archivar exige estar en la fase pr"}
|
|
411
|
+
JSON
|
|
412
|
+
out="$(bash "$SO" status --no-refresh)"
|
|
413
|
+
echo "$out" | grep -q "2 evento(s)" && echo "$out" | grep -q "⚠" \
|
|
414
|
+
&& echo "OK status reporta el conteo de rechazados con ⚠" || { echo "FAIL status sin conteo de rechazados: $out"; fail=1; }
|
|
415
|
+
echo "$out" | grep -q "slice_archived" && echo "$out" | grep -q "archivar exige estar en la fase pr" \
|
|
416
|
+
&& echo "OK status muestra tipo y razón del rechazo más reciente" || { echo "FAIL status sin razón del último rechazo: $out"; fail=1; }
|
|
417
|
+
rm -rf "$PROJ/.claude/state/outbox/rejected"
|
|
418
|
+
|
|
397
419
|
# status es informativo: NUNCA falla aunque no haya nada.
|
|
398
420
|
rm -f "$PROJ/.claude/state/runtime-projection.json"
|
|
399
421
|
rc=0; out="$(TRYCORE_RUNTIME_MODE=legacy bash "$SO" status --no-refresh)" || rc=$?
|
|
@@ -430,20 +452,20 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
|
|
|
430
452
|
|| { echo "FAIL escalate con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
|
|
431
453
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
432
454
|
|
|
433
|
-
# usage: lista los
|
|
434
|
-
# skills) —
|
|
455
|
+
# usage: lista los 14 subcomandos REALMENTE implementados (contrato con la prosa de las
|
|
456
|
+
# skills) — 13 del plan EP-OR-08-C + `phase` (issue #52).
|
|
435
457
|
usage_out="$(bash "$SO" --help 2>&1)"
|
|
436
|
-
for sub in mode claim next-step gate submit archive wiring progress checkpoint fact propose-asset status escalate; do
|
|
458
|
+
for sub in mode claim next-step phase gate submit archive wiring progress checkpoint fact propose-asset status escalate; do
|
|
437
459
|
echo "$usage_out" | grep -q -- "$sub" || { echo "FAIL usage no documenta $sub"; fail=1; }
|
|
438
460
|
done
|
|
439
461
|
echo "$usage_out" | grep -qi "pendientes" \
|
|
440
462
|
&& { echo "FAIL usage todavía anuncia pendientes tras completar la Task 4"; fail=1; } \
|
|
441
|
-
|| echo "OK usage documenta los
|
|
463
|
+
|| echo "OK usage documenta los 14 subcomandos como implementados, sin pendientes"
|
|
442
464
|
|
|
443
465
|
# Regresión de modo LEGACY sobre TODOS los subcomandos IMPLEMENTADOS que transicionan:
|
|
444
|
-
# rc 3 y cero red (los
|
|
466
|
+
# rc 3 y cero red (los 14, ya con phase del issue #52).
|
|
445
467
|
: > "$TMP/curl-legacy.log"
|
|
446
|
-
for sub in "gate tdd pass" "submit" "archive" "wiring update w1 passing" "progress algo" "checkpoint --note algo" "fact project-kind greenfield" "propose-asset --type-key t --path p --content-file $TMP/asset.md" "escalate algo --gate fidelity"; do
|
|
468
|
+
for sub in "phase pr" "gate tdd pass" "submit" "archive" "wiring update w1 passing" "progress algo" "checkpoint --note algo" "fact project-kind greenfield" "propose-asset --type-key t --path p --content-file $TMP/asset.md" "escalate algo --gate fidelity"; do
|
|
447
469
|
rc=0
|
|
448
470
|
bash -c "PATH=\"$TMP/fakebin:\$PATH\" FAKE_CURL_LOG=\"$TMP/curl-legacy.log\" TRYCORE_RUNTIME_MODE=legacy CLAUDE_PROJECT_DIR=\"$PROJ\" BUILD_CONFIG_FILE=\"$BUILD_CONFIG_FILE\" bash \"$SO\" $sub" >/dev/null 2>&1 || rc=$?
|
|
449
471
|
[ "$rc" = 3 ] || { echo "FAIL «${sub}» en legacy devolvió $rc (esperado 3)"; fail=1; }
|
|
@@ -694,6 +716,186 @@ n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -
|
|
|
694
716
|
|| { echo "FAIL archive con outbox no escribible (rc=$rc n=$n out=$out)"; fail=1; }
|
|
695
717
|
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
696
718
|
|
|
719
|
+
# ══════════════ slice-ops.sh: phase (cadena de fases del hub) [issue #52] ══════════════
|
|
720
|
+
# El hub modela la fase con `phase_advanced {to}` de ORDEN ESTRICTO (domain.py:400-407,
|
|
721
|
+
# PHASES dor→…→pr→archived) y `slice_archived` exige phase == pr (domain.py:499-500).
|
|
722
|
+
# Sin este subcomando el slice se quedaba en dor server-side y el archivado caía a
|
|
723
|
+
# rejected/ EN SILENCIO (piloto 2026-08-28).
|
|
724
|
+
|
|
725
|
+
# Sin argumento: uso incorrecto.
|
|
726
|
+
rc=0; bash "$SO" phase >/dev/null 2>&1 || rc=$?
|
|
727
|
+
[ "$rc" = 2 ] && echo "OK phase sin argumento -> rc 2" || { echo "FAIL phase sin argumento rc ($rc)"; fail=1; }
|
|
728
|
+
|
|
729
|
+
# Fase desconocida (o archived, que no se reporta por aquí): rechazo LOCAL, sin red ni cola.
|
|
730
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
731
|
+
: > "$TMP/calls-ga.jsonl"
|
|
732
|
+
rc=0; bash "$SO" phase inventada >/dev/null 2>&1 || rc=$?
|
|
733
|
+
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
734
|
+
[ "$rc" = 6 ] && [ "$n" = 0 ] && echo "OK phase desconocida -> rc 6 sin encolar" || { echo "FAIL phase desconocida (rc=$rc n=$n)"; fail=1; }
|
|
735
|
+
rc=0; bash "$SO" phase archived >/dev/null 2>&1 || rc=$?
|
|
736
|
+
[ "$rc" = 6 ] && echo "OK phase archived -> rc 6 (archived se alcanza con archive, no con phase)" || { echo "FAIL phase archived rc ($rc)"; fail=1; }
|
|
737
|
+
|
|
738
|
+
# Runtime, un paso: el servidor conoce «red» -> `phase green` encola EXACTAMENTE un
|
|
739
|
+
# phase_advanced {to: green} con slice_id.
|
|
740
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
741
|
+
import json,sys
|
|
742
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
743
|
+
d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"red","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
|
|
744
|
+
json.dump(d,open(p,"w"))
|
|
745
|
+
PYX
|
|
746
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
747
|
+
rc=0; out="$(bash "$SO" phase green)" || rc=$?
|
|
748
|
+
[ "$rc" = 0 ] && echo "OK phase green -> rc 0" || { echo "FAIL phase green rc ($rc)"; fail=1; }
|
|
749
|
+
[ "$(python3 - "$PROJ/.claude/state/outbox" <<'PYX'
|
|
750
|
+
import json,sys,glob,os
|
|
751
|
+
evs=[json.load(open(p)) for p in sorted(glob.glob(os.path.join(sys.argv[1],"*.json"))) if not os.path.basename(p).startswith(".")]
|
|
752
|
+
pa=[e for e in evs if e.get("type")=="phase_advanced"]
|
|
753
|
+
ok = len(evs)==1 and len(pa)==1 and pa[0]["payload"]=={"to":"green"} and pa[0].get("slice_id")=="sl-7"
|
|
754
|
+
print("SI" if ok else "NO")
|
|
755
|
+
PYX
|
|
756
|
+
)" = SI ] && echo "OK phase green encola un único phase_advanced {to} con slice_id" \
|
|
757
|
+
|| { echo "FAIL evento(s) de phase green"; fail=1; }
|
|
758
|
+
|
|
759
|
+
# Cadena con huecos: el servidor sigue en «red»; `phase pr` emite green→…→pr EN ORDEN
|
|
760
|
+
# (el orden estricto del hub no admite saltos) y avisa del hueco.
|
|
761
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
762
|
+
rc=0; out="$(bash "$SO" phase pr 2>&1)" || rc=$?
|
|
763
|
+
[ "$rc" = 0 ] && echo "OK phase pr con huecos -> rc 0" || { echo "FAIL phase pr rc ($rc)"; fail=1; }
|
|
764
|
+
[ "$(python3 - "$PROJ/.claude/state/outbox" <<'PYX'
|
|
765
|
+
import json,sys,glob,os
|
|
766
|
+
tos=[json.load(open(p))["payload"].get("to") for p in sorted(glob.glob(os.path.join(sys.argv[1],"*.json")))
|
|
767
|
+
if not os.path.basename(p).startswith(".") and json.load(open(p)).get("type")=="phase_advanced"]
|
|
768
|
+
print("SI" if tos==["green","refactor","smoke","api","data","dod","pr"] else "NO:"+",".join(str(t) for t in tos))
|
|
769
|
+
PYX
|
|
770
|
+
)" = SI ] && echo "OK phase pr encola la cadena green→…→pr en orden" || { echo "FAIL cadena de phase pr"; fail=1; }
|
|
771
|
+
echo "$out" | grep -q "⚠" && echo "OK phase pr avisa del hueco rellenado" || { echo "FAIL phase pr sin aviso de hueco ($out)"; fail=1; }
|
|
772
|
+
|
|
773
|
+
# Idempotencia contra la COLA: la cadena ya está pendiente en la outbox -> repetir
|
|
774
|
+
# `phase pr` no encola nada nuevo (el daemon aún no drenó, pero el cliente ya lo sabe).
|
|
775
|
+
n_antes="$(find "$PROJ/.claude/state/outbox" -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
776
|
+
rc=0; bash "$SO" phase pr >/dev/null 2>&1 || rc=$?
|
|
777
|
+
n_despues="$(find "$PROJ/.claude/state/outbox" -name '*.json' ! -name '.dispatch-state.json' | wc -l | tr -d ' ')"
|
|
778
|
+
[ "$rc" = 0 ] && [ "$n_antes" = "$n_despues" ] \
|
|
779
|
+
&& echo "OK phase pr repetido es idempotente (cuenta la cola pendiente)" \
|
|
780
|
+
|| { echo "FAIL idempotencia por cola (antes=$n_antes despues=$n_despues rc=$rc)"; fail=1; }
|
|
781
|
+
|
|
782
|
+
# Idempotencia contra la PROYECCIÓN: el servidor ya está en pr -> nada que emitir.
|
|
783
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
784
|
+
import json,sys
|
|
785
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
786
|
+
d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"pr","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
|
|
787
|
+
json.dump(d,open(p,"w"))
|
|
788
|
+
PYX
|
|
789
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
790
|
+
rc=0; out="$(bash "$SO" phase pr)" || rc=$?
|
|
791
|
+
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
792
|
+
[ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase pr con el servidor ya en pr -> no-op idempotente" \
|
|
793
|
+
|| { echo "FAIL idempotencia por proyección (rc=$rc n=$n out=$out)"; fail=1; }
|
|
794
|
+
|
|
795
|
+
# Catch-up en archive [issue #52]: slice_archived exige phase == pr — si el servidor quedó
|
|
796
|
+
# atrás (pipeline corrido con una skill vieja, sin llamadas a `phase`), archive emite la
|
|
797
|
+
# cadena que falte ANTES del archivado, con aviso. Es el workaround del piloto hecho sistema.
|
|
798
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
799
|
+
import json,sys
|
|
800
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
801
|
+
d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"dor","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
|
|
802
|
+
json.dump(d,open(p,"w"))
|
|
803
|
+
PYX
|
|
804
|
+
agent_context_fetch_and_cache >/dev/null
|
|
805
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
806
|
+
rc=0; out="$(bash "$SO" archive --epic EP-003 2>&1)" || rc=$?
|
|
807
|
+
[ "$rc" = 0 ] && echo "OK archive con catch-up -> rc 0" || { echo "FAIL archive catch-up rc ($rc)"; fail=1; }
|
|
808
|
+
[ "$(python3 - "$PROJ/.claude/state/outbox" <<'PYX'
|
|
809
|
+
import json,sys,glob,os
|
|
810
|
+
evs=[json.load(open(p)) for p in sorted(glob.glob(os.path.join(sys.argv[1],"*.json"))) if not os.path.basename(p).startswith(".")]
|
|
811
|
+
tos=[e["payload"].get("to") for e in evs if e.get("type")=="phase_advanced"]
|
|
812
|
+
tipos=[e.get("type") for e in evs]
|
|
813
|
+
ok = tos==["change","red","green","refactor","smoke","api","data","dod","pr"] \
|
|
814
|
+
and tipos[-1]=="slice_archived" and all(t=="phase_advanced" for t in tipos[:-1]) \
|
|
815
|
+
and all(e.get("slice_id")=="sl-7" for e in evs)
|
|
816
|
+
print("SI" if ok else "NO:"+",".join(tipos))
|
|
817
|
+
PYX
|
|
818
|
+
)" = SI ] && echo "OK archive encola la cadena dor→…→pr ANTES de slice_archived" \
|
|
819
|
+
|| { echo "FAIL catch-up de archive"; fail=1; }
|
|
820
|
+
echo "$out" | grep -q "server-side" && echo "OK archive avisa del catch-up (slice atrasado server-side)" \
|
|
821
|
+
|| { echo "FAIL archive sin aviso de catch-up ($out)"; fail=1; }
|
|
822
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
823
|
+
|
|
824
|
+
# archive con el servidor ya en pr: SOLO slice_archived, sin cadena ni aviso.
|
|
825
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
826
|
+
import json,sys
|
|
827
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
828
|
+
d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"pr","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
|
|
829
|
+
json.dump(d,open(p,"w"))
|
|
830
|
+
PYX
|
|
831
|
+
agent_context_fetch_and_cache >/dev/null
|
|
832
|
+
rc=0; out="$(bash "$SO" archive --epic EP-003 2>&1)" || rc=$?
|
|
833
|
+
n_pa="$(grep -l '"type": "phase_advanced"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | wc -l | tr -d ' ')"
|
|
834
|
+
n_ar="$(grep -l '"type": "slice_archived"' "$PROJ/.claude/state/outbox/"*.json 2>/dev/null | wc -l | tr -d ' ')"
|
|
835
|
+
[ "$rc" = 0 ] && [ "$n_pa" = 0 ] && [ "$n_ar" = 1 ] && ! echo "$out" | grep -q "server-side" \
|
|
836
|
+
&& echo "OK archive con el servidor en pr no emite cadena ni aviso" \
|
|
837
|
+
|| { echo "FAIL archive sin catch-up (rc=$rc pa=$n_pa ar=$n_ar)"; fail=1; }
|
|
838
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
839
|
+
|
|
840
|
+
# dual: phase ESPEJA (phase_advanced ∈ TIPOS_DE_ESPEJO del hub) con la identidad local,
|
|
841
|
+
# sin tocar la cola — mismo patrón que gate/submit en dual.
|
|
842
|
+
cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
|
|
843
|
+
{"version":"1.0.0","active_slice":{"epica":"EP-009","openspec_change":"add-pagos","branch":"feature/pagos","phase":"green","gates":{"dor":true}}}
|
|
844
|
+
JSON
|
|
845
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
846
|
+
import json,sys
|
|
847
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
848
|
+
d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"red","gates":{"dor":True}},"context":{"manifest_hash":"sha256:v1"}}}
|
|
849
|
+
json.dump(d,open(p,"w"))
|
|
850
|
+
PYX
|
|
851
|
+
: > "$TMP/calls-ga.jsonl"
|
|
852
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
853
|
+
rc=0; TRYCORE_RUNTIME_MODE=dual bash "$SO" phase green >/dev/null 2>&1 || rc=$?
|
|
854
|
+
n="$(find "$PROJ/.claude/state/outbox" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
855
|
+
[ "$rc" = 0 ] && [ "$n" = 0 ] && echo "OK phase en dual -> rc 0 sin encolar" || { echo "FAIL phase dual (rc=$rc n=$n)"; fail=1; }
|
|
856
|
+
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
857
|
+
import json,sys
|
|
858
|
+
tos=[]
|
|
859
|
+
for line in open(sys.argv[1]):
|
|
860
|
+
c=json.loads(line)
|
|
861
|
+
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
862
|
+
b=json.loads(c["body"])
|
|
863
|
+
if b.get("kind")=="phase_advanced":
|
|
864
|
+
tos.append((b["payload"].get("to"), b["payload"].get("slice_ref",{}).get("epic_code")))
|
|
865
|
+
print("SI" if tos==[("green","EP-009")] else "NO:"+repr(tos))
|
|
866
|
+
PYX
|
|
867
|
+
)" = SI ] && echo "OK phase en dual espeja phase_advanced {to} con slice_ref" \
|
|
868
|
+
|| { echo "FAIL espejo de phase en dual"; fail=1; }
|
|
869
|
+
|
|
870
|
+
# dual: archive también hace catch-up por el espejo (cadena red→…→pr antes del archivado).
|
|
871
|
+
: > "$TMP/calls-ga.jsonl"
|
|
872
|
+
rc=0; out="$(TRYCORE_RUNTIME_MODE=dual bash "$SO" archive --epic EP-009 2>&1)" || rc=$?
|
|
873
|
+
[ "$rc" = 0 ] && echo "$out" | grep -q "server-side" \
|
|
874
|
+
&& echo "OK archive en dual avisa del catch-up" || { echo "FAIL archive dual catch-up (rc=$rc out=$out)"; fail=1; }
|
|
875
|
+
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
876
|
+
import json,sys
|
|
877
|
+
kinds=[]
|
|
878
|
+
for line in open(sys.argv[1]):
|
|
879
|
+
c=json.loads(line)
|
|
880
|
+
if c["path"]=="/projects/p-1/mirror/transitions":
|
|
881
|
+
b=json.loads(c["body"])
|
|
882
|
+
kinds.append((b.get("kind"), b["payload"].get("to")))
|
|
883
|
+
esperado=[("phase_advanced",t) for t in ["green","refactor","smoke","api","data","dod","pr"]]+[("slice_archived",None)]
|
|
884
|
+
print("SI" if kinds==esperado else "NO:"+repr(kinds))
|
|
885
|
+
PYX
|
|
886
|
+
)" = SI ] && echo "OK archive en dual espeja la cadena hasta pr y luego slice_archived" \
|
|
887
|
+
|| { echo "FAIL cadena del archive dual"; fail=1; }
|
|
888
|
+
|
|
889
|
+
# Restaura la ruta y la caché al estado que esperan los bloques siguientes (sl-7, fase tdd).
|
|
890
|
+
python3 - "$TMP/routes-ga.json" <<'PYX'
|
|
891
|
+
import json,sys
|
|
892
|
+
p=sys.argv[1]; d=json.load(open(p))
|
|
893
|
+
d["GET /agent/context"]={"status":200,"body":{"project":{"scaffold_confirmed":True},"active_slice":{"slice_id":"sl-7","phase":"tdd","gates":{"dor":True,"tdd":False}},"context":{"manifest_hash":"sha256:v1"}}}
|
|
894
|
+
json.dump(d,open(p,"w"))
|
|
895
|
+
PYX
|
|
896
|
+
agent_context_fetch_and_cache >/dev/null
|
|
897
|
+
rm -f "$PROJ/.claude/state/outbox/"*.json
|
|
898
|
+
|
|
697
899
|
# dual: el fichero es primario -> se espeja la transición con la identidad LOCAL.
|
|
698
900
|
cat > "$PROJ/.claude/state/build-state.json" <<'JSON'
|
|
699
901
|
{"version":"1.0.0","active_slice":{"epica":"EP-009","openspec_change":"add-pagos","branch":"feature/pagos","phase":"tdd","gates":{"dor":true}}}
|
|
@@ -1036,6 +1238,14 @@ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc
|
|
|
1036
1238
|
[ "$n" = 0 ] && [ "$nr" = 1 ] \
|
|
1037
1239
|
&& echo "OK el elemento rechazado por el hub se aparta a rejected/ (no se reenvía)" \
|
|
1038
1240
|
|| { echo "FAIL elemento rechazado (pendientes=$n rechazados=$nr)"; fail=1; }
|
|
1241
|
+
# [issue #52] La razón del hub queda PERSISTIDA en el fichero apartado (rejected_reason):
|
|
1242
|
+
# es lo que `slice-ops.sh status` y `trycore-build status/doctor` muestran después.
|
|
1243
|
+
fr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' | head -1)"
|
|
1244
|
+
[ -n "$fr" ] && [ "$(python3 -c "
|
|
1245
|
+
import json;d=json.load(open('$fr'))
|
|
1246
|
+
print('SI' if 'desconocido para el catálogo v2' in (d.get('rejected_reason') or '') else 'NO')")" = SI ] \
|
|
1247
|
+
&& echo "OK el fichero apartado conserva la razón del hub (rejected_reason)" \
|
|
1248
|
+
|| { echo "FAIL rejected/ sin rejected_reason persistida"; fail=1; }
|
|
1039
1249
|
# [#44] El fichero 0.10.x `verdict_reported {gate, status}` viajó TRADUCIDO al catálogo v2
|
|
1040
1250
|
# (gate_verdict {gate, verdict: PASS}) — la traducción vive en el único punto de salida.
|
|
1041
1251
|
[ "$(python3 - "$TMP/calls-ga.jsonl" <<'PYX'
|
|
@@ -1107,6 +1317,15 @@ nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc
|
|
|
1107
1317
|
[ "$rc" = 0 ] && [ "$n" = 0 ] && [ "$nr" = 3 ] && ! grep -q '"path": "/events"' "$TMP/calls-ga.jsonl" \
|
|
1108
1318
|
&& echo "OK los tipos sin equivalente se apartan localmente a rejected/ sin tocar la red" \
|
|
1109
1319
|
|| { echo "FAIL pre-filtro de tipos sin equivalente (rc=$rc pendientes=$n rechazados=$nr)"; fail=1; }
|
|
1320
|
+
# [issue #52] También el apartado local persiste su razón (rejected_reason).
|
|
1321
|
+
[ "$(python3 - "$PROJ/.claude/state/outbox/rejected" <<'PYX'
|
|
1322
|
+
import json,glob,os,sys
|
|
1323
|
+
ok=all("catálogo" in (json.load(open(p)).get("rejected_reason") or "")
|
|
1324
|
+
for p in glob.glob(os.path.join(sys.argv[1],"*.json")))
|
|
1325
|
+
print("SI" if ok else "NO")
|
|
1326
|
+
PYX
|
|
1327
|
+
)" = SI ] && echo "OK los tipos sin equivalente llevan rejected_reason" \
|
|
1328
|
+
|| { echo "FAIL apartado local sin rejected_reason"; fail=1; }
|
|
1110
1329
|
rm -rf "$PROJ/.claude/state/outbox/rejected"
|
|
1111
1330
|
|
|
1112
1331
|
# [#44] telemetry_gap de la evicción con el payload del catálogo: {dropped, window_h,
|
|
@@ -1154,6 +1373,13 @@ n="$(find "$PROJ/.claude/state/outbox" -maxdepth 1 -name '*.json' ! -name '.disp
|
|
|
1154
1373
|
nr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
1155
1374
|
[ "$n" = 0 ] && [ "$nr" = 1 ] && echo "OK el lote 4xx se mueve a rejected/ y no se reintenta" \
|
|
1156
1375
|
|| { echo "FAIL lote 4xx (pendientes=$n rechazados=$nr)"; fail=1; }
|
|
1376
|
+
# [issue #52] El lote 4xx también persiste su razón (HTTP status) en cada fichero apartado.
|
|
1377
|
+
fr="$(find "$PROJ/.claude/state/outbox/rejected" -name '*.json' | head -1)"
|
|
1378
|
+
[ -n "$fr" ] && [ "$(python3 -c "
|
|
1379
|
+
import json;d=json.load(open('$fr'))
|
|
1380
|
+
print('SI' if '422' in (d.get('rejected_reason') or '') else 'NO')")" = SI ] \
|
|
1381
|
+
&& echo "OK el lote 4xx conserva la razón (HTTP 422) en rejected_reason" \
|
|
1382
|
+
|| { echo "FAIL lote 4xx sin rejected_reason"; fail=1; }
|
|
1157
1383
|
echo "$err" | grep -q "dict_type" && echo "OK el 4xx loggea el cuerpo del error del hub" \
|
|
1158
1384
|
|| { echo "FAIL 4xx sin cuerpo del error ($err)"; fail=1; }
|
|
1159
1385
|
[ ! -f "$PROJ/.claude/state/outbox/.dispatch-state.json" ] \
|
|
@@ -1202,6 +1428,7 @@ CATALOGO = {
|
|
|
1202
1428
|
"handoff_recorded": {"claves": {"note", "resume_hint", "stopped_at"},
|
|
1203
1429
|
"obligatorias": {"note"}, "ambito": "slice"},
|
|
1204
1430
|
"slice_escalated": {"claves": {"cause"}, "obligatorias": {"cause"}, "ambito": "slice"},
|
|
1431
|
+
"phase_advanced": {"claves": {"to"}, "obligatorias": {"to"}, "ambito": "slice"},
|
|
1205
1432
|
"slice_submitted": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
|
|
1206
1433
|
"slice_archived": {"claves": set(), "obligatorias": set(), "ambito": "slice"},
|
|
1207
1434
|
"wiring_checklist_seeded": {"claves": {"items"}, "obligatorias": {"items"}, "ambito": "slice"},
|
|
@@ -1240,7 +1467,8 @@ fuera=sorted(t for t in encontrados if t not in catalogo)
|
|
|
1240
1467
|
# el guard está ciego y también es un fallo.
|
|
1241
1468
|
esperados={"gate_verdict","checkpoint_recorded","progress_noted","slice_escalated",
|
|
1242
1469
|
"slice_submitted","slice_archived","wiring_checklist_seeded",
|
|
1243
|
-
"wiring_item_updated","project_fact_updated","handoff_recorded"
|
|
1470
|
+
"wiring_item_updated","project_fact_updated","handoff_recorded",
|
|
1471
|
+
"phase_advanced"}
|
|
1244
1472
|
ciegos=sorted(esperados-encontrados)
|
|
1245
1473
|
if fuera:
|
|
1246
1474
|
print("FUERA_DE_CATALOGO:"+",".join(fuera))
|
|
@@ -1258,6 +1486,7 @@ PYX
|
|
|
1258
1486
|
runtime_credentials_merge '{"runtime_url":"http://127.0.0.1:9"}'
|
|
1259
1487
|
rm -f "$PROJ/.claude/state/outbox/"*.json "$PROJ/.claude/state/outbox/.dispatch-state.json"
|
|
1260
1488
|
bash "$SO" gate tdd pass --evidence "verde" >/dev/null 2>&1
|
|
1489
|
+
bash "$SO" phase red >/dev/null 2>&1
|
|
1261
1490
|
bash "$SO" submit --pr-url "https://example.invalid/pr/9" >/dev/null 2>&1
|
|
1262
1491
|
bash "$SO" archive --epic EP-003 >/dev/null 2>&1
|
|
1263
1492
|
bash "$SO" wiring seed --file "$TMP/items-small.json" >/dev/null 2>&1
|
|
@@ -1303,7 +1532,7 @@ for path in sorted(glob.glob(os.path.join(sys.argv[2],"*.json"))):
|
|
|
1303
1532
|
errores.append("wiring_checklist_seeded: item fuera de schema %r" % it); break
|
|
1304
1533
|
esperados={"gate_verdict","slice_submitted","slice_archived","wiring_checklist_seeded",
|
|
1305
1534
|
"wiring_item_updated","progress_noted","checkpoint_recorded","slice_escalated",
|
|
1306
|
-
"project_fact_updated","telemetry_gap"}
|
|
1535
|
+
"project_fact_updated","telemetry_gap","phase_advanced"}
|
|
1307
1536
|
faltantes=sorted(esperados-vistos)
|
|
1308
1537
|
if faltantes:
|
|
1309
1538
|
errores.append("productores sin evento encolado (¿cambió un subcomando?): %s" % faltantes)
|
|
@@ -133,6 +133,16 @@ Espejo de la Fase 0, para proyectos **con UI**. Antes de abrir el primer slice c
|
|
|
133
133
|
| 7 · pr | Abrir PR + archivar change en el mismo PR | `opsx:archive`, `opsx:sync` | — | `gitflow.md` |
|
|
134
134
|
| 8 · release? | Preguntar si correr el Release Gate ahora | usuario (default computado) | — | abajo |
|
|
135
135
|
|
|
136
|
+
> **Reporta la fase al runtime en cada transición** (modos `dual`/`runtime`; en `legacy` el
|
|
137
|
+
> comando devuelve rc 3 y no haces nada): `bash .claude/hooks/build/slice-ops.sh phase <to>`
|
|
138
|
+
> **al entrar** en cada fase. Las fases del hub son más finas que las de esta tabla:
|
|
139
|
+
> `dor → change → red → green → refactor → smoke → api → data → dod → pr` — la fase 3 · tdd
|
|
140
|
+
> reporta **tres** (`phase red` al escribir el test rojo, `phase green` al pasarlo,
|
|
141
|
+
> `phase refactor` al refactorizar) y la 5 reporta `api` y luego `data`; la 7 · pr reporta
|
|
142
|
+
> `phase pr` al abrir el PR. El subcomando es idempotente y rellena huecos solo (cadena
|
|
143
|
+
> completa, el orden del hub es estricto); sin estos reportes el slice queda «en
|
|
144
|
+
> construcción» en el hub aunque su PR ya esté mergeado. Ver `references/runtime-protocol.md`.
|
|
145
|
+
|
|
136
146
|
Los gates `stack`, `security`, `smell`, `ux` y la coherencia triple completa **ya no se cierran
|
|
137
147
|
aquí**: pertenecen al Release Gate. Las **deps** siguen vigiladas en tiempo real por el hook
|
|
138
148
|
`stack-guard.sh`; lint/tsc/gitflow por sus hooks.
|
|
@@ -35,16 +35,17 @@ acto pasa por `slice-ops.sh` (inner loop) y `release-ops.sh` (outer loop), insta
|
|
|
35
35
|
|---|---|---|
|
|
36
36
|
| Reclamar | `slice-ops.sh claim [--epic EP-XXX]` | Sincroniza el contexto **antes** (regla: no se abre trabajo con contexto viejo) y reporta los sha256 locales de los archivos gobernados. Si la respuesta trae `checkpoint`, **continúa desde él; jamás reinicies**. |
|
|
37
37
|
| Saber el paso | `slice-ops.sh next-step` | Lo deriva el **cliente** desde la proyección: `resume_hint` → wiring `failing` → sub-slice pendiente → primer gate abierto. |
|
|
38
|
+
| Reportar la fase | `slice-ops.sh phase <to>` | **Al entrar en cada fase del pipeline** (`dor → change → red → green → refactor → smoke → api → data → dod → pr`, orden estricto del hub). Emite la cadena `phase_advanced` que falte desde la fase que el servidor conoce (nada si ya está: idempotente). Sin esto el slice se queda en `dor` server-side y el archivado se rechaza. `archived` no se reporta aquí: lo produce `archive`. |
|
|
38
39
|
| Sembrar el cableado | `slice-ops.sh wiring seed --file items.json` | Un item por escenario AC y por punto de integración entre capas. Nacen `failing`. |
|
|
39
40
|
| Cablear un item | `slice-ops.sh wiring update <item_id> passing --kind K --ref HU-XXX --evidence-file f` | `passing` **solo tras prueba real ejecutada**. Sin evidencia el comando avisa: no lo ignores. |
|
|
40
41
|
| Cerrar un gate | `slice-ops.sh gate <nombre> <pass\|fail\|na> --evidence-file f` | Gates canónicos: `dor coherence_link tdd journey_smoke fidelity api data wiring_verified dod`. Un `na` es legítimo cuando el gate no aplica. |
|
|
41
42
|
| Dejar bitácora | `slice-ops.sh progress «hito»` | Sustituye a `progress_log[]`: el event stream **es** la bitácora. |
|
|
42
43
|
| Guardar continuidad | `slice-ops.sh checkpoint --note «dónde me quedé»` | Rama + commit reales. Es lo que permite que otro agente continúe tras una caída. |
|
|
43
44
|
| Entregar | `slice-ops.sh submit --pr-url U --openspec-change C` | Tras abrir el PR. |
|
|
44
|
-
| Archivar | `slice-ops.sh archive --epic EP-XXX --openspec-change C --pr-url U` | Lo dispara `opsx:archive` en el mismo PR. Va por cola (idempotente, protegido de la cota). |
|
|
45
|
+
| Archivar | `slice-ops.sh archive --epic EP-XXX --openspec-change C --pr-url U` | Lo dispara `opsx:archive` en el mismo PR. Va por cola (idempotente, protegido de la cota). **Catch-up automático**: si la fase server-side quedó atrás, emite la cadena `phase_advanced` hasta `pr` antes del archivado y lo avisa (`⚠ … server-side`). |
|
|
45
46
|
| Hechos de proyecto | `slice-ops.sh fact scaffold-confirmed --by «persona»` · `fact design-source …` · `fact project-kind …` · `fact foundation …` | **Reportan lo que un humano confirmó**; el agente nunca confirma por su cuenta. |
|
|
46
47
|
| Bloqueo | `slice-ops.sh escalate «razón» --gate G` | Deja el rastro; la decisión (recortar/diferir/desbloquear) es del equipo. |
|
|
47
|
-
| Ver el estado | `slice-ops.sh status` | Modo, slice, gates, wiring, contexto, lease y
|
|
48
|
+
| Ver el estado | `slice-ops.sh status` | Modo, slice, gates, wiring, contexto, lease, cola pendiente y **rechazados** (`outbox/rejected/`: conteo + razón del más reciente — un rechazo del hub NO se reintenta solo; corrige la causa y re-emite). |
|
|
48
49
|
|
|
49
50
|
## Reglas duras
|
|
50
51
|
|
|
@@ -60,6 +61,10 @@ acto pasa por `slice-ops.sh` (inner loop) y `release-ops.sh` (outer loop), insta
|
|
|
60
61
|
reconectar y el `client_event_id` evita duplicados.
|
|
61
62
|
7. **El agente no gobierna.** No cierra releases, no publica contexto, no importa el grafo,
|
|
62
63
|
no abre/drena/cierra fronts: prepara, propone, reporta — y el humano decide en la consola.
|
|
64
|
+
8. **La fase se reporta al entrar, no al final.** El hub lleva la fase con `phase_advanced`
|
|
65
|
+
de orden estricto: invoca `phase <to>` en cada transición del pipeline. Si un pipeline
|
|
66
|
+
corrió sin reportarla, `archive` completa la cadena solo (catch-up) — pero la telemetría
|
|
67
|
+
intermedia del hub habrá estado ciega todo el slice.
|
|
63
68
|
|
|
64
69
|
## Qué queda del fichero (legado)
|
|
65
70
|
|
|
@@ -95,7 +95,10 @@ Archive a completed change in the experimental workflow.
|
|
|
95
95
|
|
|
96
96
|
It is queued and idempotent (protected from the outbox cap), so it survives an offline
|
|
97
97
|
archive; `rc 3` simply means legacy mode (the file protocol applies). Never block the
|
|
98
|
-
archive on this step.
|
|
98
|
+
archive on this step. If the pipeline ran without phase reports, the command catches up
|
|
99
|
+
by itself: it emits the missing `phase_advanced` chain up to `pr` before the archive
|
|
100
|
+
event (the hub rejects `slice_archived` on any earlier phase) and prints a `⚠ … server-side`
|
|
101
|
+
notice — informative, not an error.
|
|
99
102
|
|
|
100
103
|
6. **Display summary**
|
|
101
104
|
|
|
@@ -24,9 +24,12 @@ de épicas de la release son las que vas a auditar en bloque.
|
|
|
24
24
|
`dual`/`runtime` la release vive en el runtime y cada gate se reporta con
|
|
25
25
|
`bash .claude/hooks/build/release-ops.sh verdict <line> <gate> <pass|fail|na>`; en `legacy`, en el
|
|
26
26
|
array `releases[]` del fichero local (protocolo en `building-a-slice/references/state-protocol.md`).
|
|
27
|
-
- **Reportar es de agentes;
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
- **Reportar es de agentes; el cierre en verde es del runtime; el degradado es humano** (política
|
|
28
|
+
A-con-B). El agente mide y reporta los seis veredictos; con el último veredicto de los 6 gates
|
|
29
|
+
canónicos en PASS **el hub auto-cierra** la release (`closed_by: AUTO_6_OF_6_PASS`, audit trail).
|
|
30
|
+
Si algún gate queda `fail`/`partial` o hay deudas por decidir, **el cierre es decisión humana en
|
|
31
|
+
la consola** — el gobierno humano vive donde hay decisión real, no en el clic de un resultado
|
|
32
|
+
derivable. `release-ops.sh close-hint <line>` imprime la política.
|
|
30
33
|
- **Sobre el diff acumulado**: el alcance es el rango de commits de todas las épicas de la release
|
|
31
34
|
(desde el merge anterior a la primera épica de la release hasta `main`).
|
|
32
35
|
- **Delega en subagentes** (devuelven síntesis, protegen el contexto).
|
|
@@ -69,8 +72,8 @@ Checklist de cierre: `references/release-dod.md`.
|
|
|
69
72
|
no aplique — p. ej. `ux` en una release sin UI). Un `rc 6` es el servidor rechazando: muestra su
|
|
70
73
|
razón, no reintentes. Sin red, el veredicto **no** tiene camino por la cola offline (`rc 5`,
|
|
71
74
|
issue #44): reintenta el mismo comando cuando vuelva la red, antes de pedir el cierre.
|
|
72
|
-
6.
|
|
73
|
-
|
|
75
|
+
6. Con los seis reportados **en verde, el hub cierra solo** (la respuesta del último `verdict`
|
|
76
|
+
anuncia `release CERRADA … closed_by: AUTO_6_OF_6_PASS`) — no hay paso manual. Si algún gate quedó `fail`,
|
|
74
77
|
lista los hallazgos bloqueantes; el usuario los corrige como un slice normal (fix en
|
|
75
78
|
`building-a-slice`) y se re-corre el Release Gate.
|
|
76
79
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// READ-ONLY sobre el estado: esta plantilla devuelve un diagnóstico; la skill
|
|
11
11
|
// releasing-a-version es la ÚNICA que reporta los veredictos (release-ops.sh verdict por gate;
|
|
12
|
-
// en modo legacy, una entrada en releases[] del fichero). El agregado y el cierre son del
|
|
12
|
+
// en modo legacy, una entrada en releases[] del fichero). El agregado y el auto-cierre en verde son del
|
|
13
13
|
// runtime/humano. Parciales NO promueven a passed. Si METODOLOGIA.md (§5) contradice algo aquí,
|
|
14
14
|
// gana la metodología.
|
|
15
15
|
//
|
|
@@ -150,11 +150,11 @@ const reviewersGreen = reviews.filter(Boolean).every((r) => r.na || r.value ===
|
|
|
150
150
|
const status = (reviewersGreen && gates.integration === true) ? 'passed' : 'failed'
|
|
151
151
|
|
|
152
152
|
return {
|
|
153
|
-
status, // reportado por releasing-a-version vía release-ops.sh verdict (agregado y cierre son del runtime
|
|
153
|
+
status, // reportado por releasing-a-version vía release-ops.sh verdict (agregado y auto-cierre en verde son del runtime; el degradado lo decide un humano).
|
|
154
154
|
gates, // {security, smell, ux, coherence, stack_arch, integration}
|
|
155
155
|
blocking: [
|
|
156
156
|
...reviews.filter((r) => r && r.value === false).map((r) => ({ gate: r.gate, error: r.error || null, findings: r.findings || [] })),
|
|
157
157
|
...(gates.integration === true ? [] : [{ gate: 'integration', findings: (integration && integration.findings) || [] }]),
|
|
158
158
|
],
|
|
159
|
-
note: 'Read-only. releasing-a-version reporta un veredicto por gate (release-ops.sh verdict). El runtime agrega y
|
|
159
|
+
note: 'Read-only. releasing-a-version reporta un veredicto por gate (release-ops.sh verdict). El runtime agrega y AUTO-CIERRA con 6/6 PASS (closed_by: AUTO_6_OF_6_PASS); el caso degradado lo decide un humano en la consola. Parciales NO promueven a passed.',
|
|
160
160
|
}
|