@trycore/spec-build-harness 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "trycore-spec-build-harness",
4
4
  "displayName": "Trycore — Spec & Build Harness",
5
- "version": "0.10.0",
5
+ "version": "0.11.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.10.0
1
+ 0.11.0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "BUILD: Escalate"
3
- description: Registra un bloqueo del slice en el runtime (evento escalation_raised) y devuelve la decisión al humano — recortar, diferir o desbloquear nunca lo decide el modelo. Adaptador delgado sobre slice-ops.sh escalate.
3
+ description: Registra un bloqueo del slice en el runtime (evento slice_escalated) y devuelve la decisión al humano — recortar, diferir o desbloquear nunca lo decide el modelo. Adaptador delgado sobre slice-ops.sh escalate.
4
4
  category: Workflow
5
5
  tags: [build-harness, runtime, escalada, gobierno, trycore]
6
6
  ---
@@ -253,18 +253,34 @@ el token de agente. El arnés **prepara y valida**; una persona sube.
253
253
  "release_lines": [{"id": "R1-mvp", "epics": ["EP-001"]}]}
254
254
  ```
255
255
 
256
- 2. Normalízalo y valídalo (determinista, nunca sube nada):
256
+ 2. Normalízalo y valídalo (determinista, nunca sube nada). La salida va a
257
+ `.claude/state/graph-bundle.json` para que `trycore-build migrate` la encuentre sola:
257
258
 
258
259
  ```bash
259
- python3 .claude/scripts/lib/graph-bundle.py < /tmp/epics.json > /tmp/graph-bundle.json
260
+ python3 .claude/scripts/lib/graph-bundle.py < /tmp/epics.json > .claude/state/graph-bundle.json
260
261
  ```
261
262
 
263
+ El script emite las épicas en el **formato exacto del hub**: `layer` en MAYÚSCULA
264
+ (`FOUNDATIONAL`|`BUSINESS` — un layer inválido rechaza el grafo ENTERO), historias con
265
+ `code`, y `release_line` (string) por épica.
266
+
262
267
  3. **Lee los avisos** (`warnings` del bundle y stderr): capa ausente, dependencia inexistente,
263
268
  ciclo, línea de release que referencia una épica desconocida. Corrígelos en discovery — el arnés
264
269
  **no inventa** el grafo ni edita `epicas.md` (salvo el carve-out de la épica caparazón, Fase 2c).
265
270
 
266
- 4. **Entrega el fichero a un ADMIN** con esta instrucción literal: *«súbelo en la consola del hub,
267
- pantalla de import del proyecto»*. El import es idempotente y **reanuda** si falló a medias.
271
+ 4. **Entrega al ADMIN un solo fichero.** Si vas a migrar historial, corre `trycore-build migrate`:
272
+ embebe el grafo como sección `graph` del bundle de estado (un único fichero para la pantalla de
273
+ import del proyecto). Sin historial que migrar, entrega `graph-bundle.json` con esta instrucción
274
+ literal: *«súbelo en la consola del hub, pantalla de import del proyecto»*. El import es
275
+ idempotente y **reanuda** si falló a medias.
276
+
277
+ Si el proyecto tiene épicas construidas **antes** de instalar el arnés (sin slice en el
278
+ historial del estado local), decláralas en un fichero que **el humano confirme** y pásalo con
279
+ `--pre-harness <fichero>` (`{"epics": [{"code": "EP-001", "note": "…opcional…"}]}`): se
280
+ importan archivadas **sin declarar gates**, con un acta de migración honesta — sin esto
281
+ quedan «sin empezar» en el hub y `claim_next` las reparte como trabajo nuevo. `migrate`
282
+ avisa de las épicas del grafo sin slice ni declaración pre-arnés, pero **nunca** las
283
+ añade por su cuenta.
268
284
 
269
285
  5. Modo `legacy` o sin credenciales: salta esta fase; el grafo sigue viviendo en los documentos.
270
286
 
package/dist/cli.js CHANGED
@@ -112,9 +112,19 @@ program
112
112
  .argument('[directory]', 'Directorio del proyecto', '.')
113
113
  .option('--out <path>', 'Ruta de salida del bundle (default: .claude/state/migration-bundle.json)')
114
114
  .option('--project-ref <ref>', 'Identificador de proyecto en el hub (default: nombre del directorio)')
115
+ .option('--graph <path>', 'Bundle de grafo (graph-bundle.py) a embeber como sección graph (default: .claude/state/graph-bundle.json si existe)')
116
+ .option('--pre-harness <path>', 'Fichero JSON con las épicas construidas ANTES de instalar el arnés, confirmadas por el humano: {"epics": [{"code": "EP-001", "note": "…"}]} — se importan archivadas sin declarar gates, con acta de migración')
117
+ .option('--verify', 'Solo verifica el bundle normalizado contra los invariantes del hub (offline, no escribe nada); exit ≠0 si hay violaciones')
115
118
  .action(async (directory, opts) => {
116
119
  try {
117
- await migrate({ targetDir: directory, outFile: opts.out, projectRef: opts.projectRef });
120
+ await migrate({
121
+ targetDir: directory,
122
+ outFile: opts.out,
123
+ projectRef: opts.projectRef,
124
+ graphFile: opts.graph,
125
+ preHarnessFile: opts.preHarness,
126
+ verifyOnly: Boolean(opts.verify),
127
+ });
118
128
  }
119
129
  catch (err) {
120
130
  console.error('✗ Error:', err.message);
@@ -4,7 +4,8 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { targetPaths } from '../lib/paths.js';
7
- import { buildStateBundle } from '../lib/state-bundle.js';
7
+ import { buildStateBundle, extractGraphSection, graphEpicsWithoutRepresentation, verifyStateBundle, } from '../lib/state-bundle.js';
8
+ import { parsePreHarnessList } from '../lib/normalize.js';
8
9
  export async function migrate(opts) {
9
10
  const targetDir = path.resolve(opts.targetDir);
10
11
  const t = targetPaths(targetDir);
@@ -20,14 +21,107 @@ export async function migrate(opts) {
20
21
  console.error(`✗ build-state.json no parsea como JSON: ${err.message}`);
21
22
  process.exit(1);
22
23
  }
24
+ // Épicas pre-arnés (issue #42): construidas ANTES de instalar el arnés, sin
25
+ // entrada en history[] — importadas sin esto quedan «sin empezar» en el hub
26
+ // y claim_next las reparte como trabajo nuevo (incidente del primer piloto).
27
+ // La lista la escribe/confirma el humano; nunca se infiere del grafo.
28
+ let preHarness;
29
+ if (opts.preHarnessFile) {
30
+ const preHarnessFile = path.resolve(opts.preHarnessFile);
31
+ if (!fs.existsSync(preHarnessFile)) {
32
+ console.error(`✗ No existe el fichero pre-arnés: ${preHarnessFile}`);
33
+ process.exit(1);
34
+ }
35
+ let preHarnessRaw;
36
+ try {
37
+ preHarnessRaw = JSON.parse(fs.readFileSync(preHarnessFile, 'utf8'));
38
+ }
39
+ catch (err) {
40
+ console.error(`✗ El fichero pre-arnés no parsea como JSON: ${err.message}`);
41
+ process.exit(1);
42
+ }
43
+ const { list, errors } = parsePreHarnessList(preHarnessRaw);
44
+ if (list === null || errors.length > 0) {
45
+ console.error(`✗ El fichero pre-arnés (${preHarnessFile}) no es válido:`);
46
+ for (const e of errors)
47
+ console.error(` ✗ ${e}`);
48
+ console.error(' Forma esperada: {"epics": [{"code": "EP-001", "note": "…opcional…"}], "as_of": "…ISO opcional…"}');
49
+ process.exit(1);
50
+ }
51
+ preHarness = list;
52
+ }
23
53
  const projectRef = opts.projectRef ?? path.basename(targetDir);
24
- const bundle = buildStateBundle(raw, projectRef);
54
+ const bundle = buildStateBundle(raw, projectRef, preHarness);
55
+ // Sección `graph` embebida (issue #41): el import del hub la consume DENTRO del
56
+ // bundle de estado (ImportBundleIn.graph) — un fichero de grafo aparte se ignora
57
+ // y la historia falla («la épica no existe en el grafo»). El grafo se genera con
58
+ // scripts/lib/graph-bundle.py (flujo de /build:onboard); aquí solo se embebe.
59
+ const defaultGraphFile = path.join(t.stateDir, 'graph-bundle.json');
60
+ const graphFile = opts.graphFile
61
+ ? path.resolve(opts.graphFile)
62
+ : fs.existsSync(defaultGraphFile)
63
+ ? defaultGraphFile
64
+ : undefined;
65
+ if (graphFile) {
66
+ if (!fs.existsSync(graphFile)) {
67
+ console.error(`✗ No existe el bundle de grafo: ${graphFile}`);
68
+ process.exit(1);
69
+ }
70
+ let graphRaw;
71
+ try {
72
+ graphRaw = JSON.parse(fs.readFileSync(graphFile, 'utf8'));
73
+ }
74
+ catch (err) {
75
+ console.error(`✗ El bundle de grafo no parsea como JSON: ${err.message}`);
76
+ process.exit(1);
77
+ }
78
+ const { graph, errors } = extractGraphSection(graphRaw);
79
+ if (graph === null) {
80
+ console.error(`✗ El bundle de grafo (${graphFile}) no está en el formato del hub — no se escribe el fichero.`);
81
+ for (const e of errors)
82
+ console.error(` ✗ ${e}`);
83
+ console.error(' Regenéralo con scripts/lib/graph-bundle.py (layer FOUNDATIONAL|BUSINESS, historias con `code`, `release_line` por épica).');
84
+ process.exit(1);
85
+ }
86
+ bundle.graph = graph;
87
+ // Síntoma exacto del incidente del piloto: épicas del grafo sin slice en
88
+ // history[] ni declaración pre-arnés quedan «sin empezar» en el hub y
89
+ // claim_next las reparte como trabajo nuevo. SOLO aviso — la lista
90
+ // pre-arnés la confirma el humano, nada se auto-añade.
91
+ const unrepresented = graphEpicsWithoutRepresentation(graph, bundle.history.map((h) => h.epic_code));
92
+ if (unrepresented.length > 0) {
93
+ bundle.warnings.push(`graph: ${unrepresented.length} épica(s) del grafo sin slice en el historial ni declaración pre-arnés (${unrepresented.join(', ')}) — quedarán «sin empezar» en el hub y claim_next las repartirá como trabajo nuevo; si ya están construidas, decláralas en el fichero de --pre-harness (nunca se auto-añaden)`);
94
+ }
95
+ }
25
96
  if (bundle.validation_errors.length > 0) {
26
97
  console.error('✗ El bundle normalizado no pasa la validación local — no se escribe el fichero.');
27
98
  for (const e of bundle.validation_errors)
28
99
  console.error(` ✗ ${e}`);
29
100
  process.exit(1);
30
101
  }
102
+ // Verificación offline contra los invariantes del hub (issue #40): la misma
103
+ // entrada que el import rechazaría debe salir aquí, no en la consola del ADMIN.
104
+ const violations = verifyStateBundle(bundle);
105
+ if (opts.verifyOnly) {
106
+ if (violations.length === 0) {
107
+ console.log('✓ Verificación offline: el bundle normalizado no viola ningún invariante conocido del hub.');
108
+ console.log(' (No se escribió ningún fichero: --verify solo verifica.)');
109
+ return;
110
+ }
111
+ console.error(`✗ Verificación offline: ${violations.length} violación(es) de los invariantes del hub:`);
112
+ for (const v of violations)
113
+ console.error(` ✗ ${v.source_key}: ${v.message}`);
114
+ process.exit(1);
115
+ }
116
+ if (violations.length > 0) {
117
+ // El destino del bundle es un ADMIN: un bundle que el hub rechazaría no se
118
+ // entrega en silencio — se aborta sin escribir, igual que la validación local.
119
+ console.error(`✗ El bundle viola ${violations.length} invariante(s) del hub — no se escribe el fichero.`);
120
+ for (const v of violations)
121
+ console.error(` ✗ ${v.source_key}: ${v.message}`);
122
+ console.error(' Corrige el build-state.json de origen (o reporta el caso al arnés) y reintenta.');
123
+ process.exit(1);
124
+ }
31
125
  bundle.generated_at = new Date().toISOString();
32
126
  const outFile = opts.outFile ? path.resolve(opts.outFile) : path.join(t.stateDir, 'migration-bundle.json');
33
127
  fs.mkdirSync(path.dirname(outFile), { recursive: true });
@@ -35,6 +129,17 @@ export async function migrate(opts) {
35
129
  console.log(`✓ Bundle de estado preparado: ${outFile}`);
36
130
  console.log(` ${bundle.history.length} slice(s) de historial · ${bundle.releases.length} release(s) · ` +
37
131
  `${bundle.fronts.length} front(s) · ${bundle.facts.length} hecho(s) de proyecto · ${bundle.unmapped.length} entrada(s) no mapeable(s)`);
132
+ if (preHarness) {
133
+ console.log(` Épicas pre-arnés declaradas: ${preHarness.epics.length} (${preHarness.epics.map((e) => e.code).join(', ')}) — archivadas sin declarar gates, con acta de migración`);
134
+ }
135
+ if (bundle.graph) {
136
+ console.log(` Grafo embebido: ${bundle.graph.epics.length} épica(s) (sección graph del bundle — un solo fichero para el ADMIN)`);
137
+ }
138
+ else {
139
+ console.log(' ⚠ Grafo: NO incluido — el hub reportará «Grafo: no incluida» y rechazará la historia si el');
140
+ console.log(' grafo no fue importado antes. Genera el grafo con scripts/lib/graph-bundle.py y pásalo');
141
+ console.log(' con --graph <fichero> (o déjalo en .claude/state/graph-bundle.json).');
142
+ }
38
143
  if (bundle.warnings.length > 0) {
39
144
  console.log('');
40
145
  console.log(' Avisos (no bloquean; corrígelos en discovery si aplica):');