@trycore/spec-build-harness 0.8.5 → 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.
Files changed (107) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/GOVERNANCE.md +27 -4
  3. package/INSTALL.md +27 -5
  4. package/METODOLOGIA.md +55 -5
  5. package/README.md +39 -6
  6. package/VERSION +1 -1
  7. package/agents/build/build-orchestrator.md +33 -7
  8. package/agents/build/dor-dod-gatekeeper.md +13 -5
  9. package/agents/build/wiring-adversarial-verifier.md +52 -5
  10. package/commands/build/architect.md +1 -1
  11. package/commands/build/claim.md +46 -0
  12. package/commands/build/escalate.md +36 -0
  13. package/commands/build/front.md +9 -3
  14. package/commands/build/onboard.md +75 -14
  15. package/commands/build/prototype.md +3 -2
  16. package/commands/build/reflect.md +60 -40
  17. package/commands/build/release.md +10 -7
  18. package/commands/build/resume.md +33 -13
  19. package/commands/build/slice.md +32 -27
  20. package/commands/build/status.md +35 -0
  21. package/commands/build/work.md +11 -8
  22. package/config/build-config.template.json +4 -0
  23. package/dist/cli.js +32 -0
  24. package/dist/commands/doctor.js +42 -0
  25. package/dist/commands/init.js +84 -1
  26. package/dist/commands/migrate.js +153 -0
  27. package/dist/commands/status.js +34 -0
  28. package/dist/lib/normalize.js +1123 -0
  29. package/dist/lib/paths.js +6 -0
  30. package/dist/lib/runtime-client.js +196 -0
  31. package/dist/lib/settings-merge.js +3 -3
  32. package/dist/lib/state-bundle.js +150 -0
  33. package/docs/commands.md +25 -8
  34. package/docs/getting-started.md +1 -0
  35. package/docs/hooks.md +114 -27
  36. package/docs/runtime/guia-modo-dual-y-migracion.md +143 -0
  37. package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
  38. package/docs/runtime/protocolo-cliente-runtime.md +120 -35
  39. package/hooks/build/build-gate-check.sh +21 -0
  40. package/hooks/build/context-monitor.sh +82 -15
  41. package/hooks/build/context-sync.sh +192 -0
  42. package/hooks/build/design-source-guard.sh +30 -2
  43. package/hooks/build/dual-compare.sh +92 -0
  44. package/hooks/build/event-emitter.sh +32 -0
  45. package/hooks/build/gitflow-guard.sh +164 -14
  46. package/hooks/build/heartbeat.sh +259 -0
  47. package/hooks/build/lib/agent-context.sh +139 -0
  48. package/hooks/build/lib/config.sh +27 -0
  49. package/hooks/build/lib/projection.sh +71 -0
  50. package/hooks/build/lib/runtime-client.sh +625 -0
  51. package/hooks/build/lib/runtime-ops.sh +227 -0
  52. package/hooks/build/lib/state-io.sh +5 -18
  53. package/hooks/build/load-build-state.sh +64 -2
  54. package/hooks/build/reflect-nudge.sh +15 -0
  55. package/hooks/build/release-gate-nudge.sh +15 -0
  56. package/hooks/build/release-ops.sh +171 -0
  57. package/hooks/build/scaffold-guard.sh +29 -2
  58. package/hooks/build/session-start.sh +103 -0
  59. package/hooks/build/session-stop.sh +22 -0
  60. package/hooks/build/slice-ops.sh +948 -0
  61. package/hooks/build/stack-guard.sh +8 -0
  62. package/hooks/build/statusline-bridge.sh +24 -3
  63. package/hooks/build-harness.json +16 -0
  64. package/package.json +3 -3
  65. package/scripts/check-agnostic.sh +3 -1
  66. package/scripts/check-pack-clean.sh +31 -0
  67. package/scripts/check-runtime-purity.sh +43 -0
  68. package/scripts/denylist.txt +4 -0
  69. package/scripts/lib/front-plan.py +4 -0
  70. package/scripts/lib/graph-bundle.py +181 -0
  71. package/scripts/runtime-purity-allow.txt +5 -0
  72. package/scripts/smoke-test.sh +1 -1
  73. package/scripts/tests/lib/http-stub.py +46 -0
  74. package/scripts/tests/test-baseline-verdict.sh +92 -0
  75. package/scripts/tests/test-config.sh +25 -0
  76. package/scripts/tests/test-hooks-runtime.sh +828 -0
  77. package/scripts/tests/test-install.sh +103 -0
  78. package/scripts/tests/test-runtime-client.sh +298 -0
  79. package/scripts/tests/test-schema.sh +29 -1
  80. package/scripts/tests/test-skill-ops.sh +1367 -0
  81. package/skills/building-a-micro-change/SKILL.md +22 -4
  82. package/skills/building-a-slice/SKILL.md +55 -21
  83. package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
  84. package/skills/building-a-slice/references/dod.md +12 -3
  85. package/skills/building-a-slice/references/dor.md +3 -2
  86. package/skills/building-a-slice/references/evidence-budget.md +51 -0
  87. package/skills/building-a-slice/references/exploration-fanout.md +1 -1
  88. package/skills/building-a-slice/references/gitflow.md +1 -1
  89. package/skills/building-a-slice/references/regression-baseline.md +67 -0
  90. package/skills/building-a-slice/references/runtime-protocol.md +75 -0
  91. package/skills/building-a-slice/references/state-protocol.md +12 -1
  92. package/skills/building-a-slice/workflows/README.md +7 -3
  93. package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
  94. package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
  95. package/skills/managing-parallel-front/SKILL.md +32 -16
  96. package/skills/openspec-archive-change/SKILL.md +15 -0
  97. package/skills/prototyping-screens/SKILL.md +9 -5
  98. package/skills/releasing-a-version/SKILL.md +26 -16
  99. package/skills/releasing-a-version/references/release-dod.md +7 -5
  100. package/skills/releasing-a-version/workflows/README.md +2 -1
  101. package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
  102. package/skills/setup-architecture/SKILL.md +4 -2
  103. package/state/README.md +16 -1
  104. package/state/build-state.schema.json +2 -1
  105. package/templates/CLAUDE.md.template +16 -0
  106. package/templates/settings-hooks.template.json +8 -4
  107. package/internal/skills/auditar-arnes/SKILL.md +0 -29
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { update } from './commands/update.js';
7
7
  import { status } from './commands/status.js';
8
8
  import { uninstall } from './commands/uninstall.js';
9
9
  import { doctor } from './commands/doctor.js';
10
+ import { migrate } from './commands/migrate.js';
10
11
  const program = new Command();
11
12
  program
12
13
  .name('trycore-build')
@@ -24,6 +25,9 @@ program
24
25
  .option('--runtime <semver>', 'Semver del runtime (ej. ">=18.18")')
25
26
  .option('--prd-path <path>', 'Ruta#ancla del PRD técnico (fuente del allowlist)')
26
27
  .option('--yes', 'No interactivo: usa defaults para el stack')
28
+ .option('--runtime-url <url>', 'URL del Agent Orchestrator Runtime (con --runtime-token, registra el agente)')
29
+ .option('--runtime-token <token>', 'Token de proyecto emitido por un ADMIN en el hub')
30
+ .option('--runtime-mode <mode>', 'legacy|dual|runtime tras registrar (default: dual)')
27
31
  .action(async (directory, opts) => {
28
32
  try {
29
33
  await init({
@@ -38,6 +42,9 @@ program
38
42
  prdPath: opts.prdPath,
39
43
  yes: Boolean(opts.yes),
40
44
  },
45
+ runtimeUrl: opts.runtimeUrl,
46
+ runtimeToken: opts.runtimeToken,
47
+ runtimeMode: opts.runtimeMode,
41
48
  });
42
49
  }
43
50
  catch (err) {
@@ -99,6 +106,31 @@ program
99
106
  process.exit(1);
100
107
  }
101
108
  });
109
+ program
110
+ .command('migrate')
111
+ .description('Prepara el bundle de estado (history/releases/hechos) de build-state.json para que un ADMIN lo importe en el hub. Nunca sube nada.')
112
+ .argument('[directory]', 'Directorio del proyecto', '.')
113
+ .option('--out <path>', 'Ruta de salida del bundle (default: .claude/state/migration-bundle.json)')
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')
118
+ .action(async (directory, opts) => {
119
+ try {
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
+ });
128
+ }
129
+ catch (err) {
130
+ console.error('✗ Error:', err.message);
131
+ process.exit(1);
132
+ }
133
+ });
102
134
  program.parseAsync(process.argv).catch((err) => {
103
135
  console.error('✗ Error inesperado:', err);
104
136
  process.exit(1);
@@ -8,6 +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
12
  export function hasBinary(bin) {
12
13
  if (process.platform === 'win32') {
13
14
  return spawnSync('where', [bin], { stdio: 'ignore' }).status === 0;
@@ -87,6 +88,47 @@ export async function doctor(opts) {
87
88
  else {
88
89
  console.log('Scaffold (Paso 1): — (sin build-state.json; corre `trycore-build init`)');
89
90
  }
91
+ // Runtime [EP-OR-08-D]: conectividad, validez del token, frescura del lock, cola offline.
92
+ const mode = runtimeMode(targetDir);
93
+ console.log('');
94
+ console.log(`Runtime (modo ${mode}):`);
95
+ if (mode === 'legacy') {
96
+ console.log(' — legacy: sin conexión al runtime (corre `trycore-build init --runtime-url … --runtime-token …`)');
97
+ }
98
+ else {
99
+ const creds = readCredentials(targetDir);
100
+ console.log(` Token: ${creds.project_token ? '✓ presente' : '✗ falta — corre init con --runtime-token'}`);
101
+ if (creds.project_token) {
102
+ const ctx = await getAgentContext(targetDir);
103
+ if (ctx.status === 200) {
104
+ console.log(' Conexión: ✓ el runtime responde');
105
+ }
106
+ else if (ctx.status === 401 || ctx.status === 403) {
107
+ console.log(' Conexión: ✗ token inválido/revocado — pide uno nuevo al ADMIN');
108
+ }
109
+ else if (ctx.status === 0) {
110
+ console.log(' Conexión: ⚠ runtime inalcanzable — se trabaja offline con el último lock');
111
+ }
112
+ else {
113
+ console.log(` Conexión: ⚠ el runtime respondió ${ctx.status}`);
114
+ }
115
+ }
116
+ const lock = readLock(targetDir);
117
+ if (lock.manifestHash) {
118
+ console.log(` Contexto: ✓ sincronizado (${lock.fileCount} archivo(s), ${lock.syncedAt ?? 'fecha desconocida'})`);
119
+ }
120
+ else {
121
+ console.log(' Contexto: ⚠ sin sincronizar aún (se resuelve en la primera sesión Claude)');
122
+ }
123
+ const ob = outboxStats(targetDir);
124
+ if (ob.count > 0) {
125
+ const ageMin = ob.oldestAgeSeconds !== null ? Math.floor(ob.oldestAgeSeconds / 60) : null;
126
+ console.log(` Cola: ${ob.count} evento(s) pendiente(s) (${(ob.totalBytes / 1024).toFixed(0)} KB${ageMin !== null ? `, el más viejo hace ${ageMin} min` : ''}) — el daemon los despacha`);
127
+ }
128
+ else {
129
+ console.log(' Cola: vacía');
130
+ }
131
+ }
90
132
  // Detección de doble canal (CLI settings.json + plugin) [H3]
91
133
  const settingsHasHooks = fs.existsSync(t.settingsFile) &&
92
134
  /hooks\/build\//.test(fs.readFileSync(t.settingsFile, 'utf8'));
@@ -6,12 +6,14 @@
6
6
  // hace el slash command /build:onboard (Claude), NO este binario.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
9
10
  import { ASSETS, readPackageVersion, targetPaths } from '../lib/paths.js';
10
11
  import { linkChildren, countChildren, chmodExec } from '../lib/install-engine.js';
11
12
  import { upsertMarkedBlock } from '../lib/markers.js';
12
13
  import { seedState, seedConfig, seedBuildConfig, stampProjectKind } from '../lib/state-seed.js';
13
14
  import { mergeHarnessSettings } from '../lib/settings-merge.js';
14
15
  import { captureStack, renderAllowlist } from '../lib/stack-prompt.js';
16
+ import { registerAgent, isPlausibleUrl } from '../lib/runtime-client.js';
15
17
  import { checkDeps } from './doctor.js';
16
18
  const BEGIN = '<!-- BEGIN trycore-build-harness ';
17
19
  const END = '<!-- END trycore-build-harness -->';
@@ -55,6 +57,11 @@ export async function init(opts) {
55
57
  chmodExec(t.hooksBuild); // preserva +x [H1] (recorre hooks/build/ incl. lib/)
56
58
  chmodExec(t.scriptsLib);
57
59
  }
60
+ // asset-types.json: versionado como schema/README, se refresca en cada init/update.
61
+ // session-start.sh lo busca en .claude/asset-types.json (protocolo §6; ver su comentario
62
+ // "la siembra es del sub-slice D").
63
+ if (fs.existsSync(ASSETS.assetTypes))
64
+ fs.copyFileSync(ASSETS.assetTypes, t.assetTypesFile);
58
65
  // 2) Estado (schema/README versionados; build-state.json vacío solo si falta) [C1]
59
66
  const { stateSeeded } = seedState(targetDir);
60
67
  const projectKind = stampProjectKind(targetDir);
@@ -75,6 +82,11 @@ export async function init(opts) {
75
82
  syncClaudeMdBlock(targetDir, version);
76
83
  // 6) .gitignore: lista los assets symlinkeados + estado vivo [H1]
77
84
  syncGitignoreBlock(targetDir, linkMode);
85
+ // 6-bis) Runtime (opcional, [EP-OR-08-D]): con --runtime-url/--runtime-token, registra el
86
+ // agente (bootstrap, fuera de una sesión Claude) y dispara el primer sync de contexto.
87
+ // Fail-open: un runtime inalcanzable en init NUNCA aborta la instalación — degrada a
88
+ // legacy y el registro/sync se reintentan en la primera sesión (session-start.sh).
89
+ const runtime = await maybeRegisterRuntime(targetDir, t, version, opts);
78
90
  // 7) marca de versión
79
91
  fs.writeFileSync(t.versionFile, version + '\n', 'utf8');
80
92
  // 8) Resumen
@@ -89,10 +101,71 @@ export async function init(opts) {
89
101
  console.log(` Config: ${configSeeded ? 'stack-allowlist.json sembrado' : 'stack-allowlist.json preservado'}`);
90
102
  console.log(` Config: ${buildConfigSeeded ? 'build-config.json sembrado' : 'build-config.json preservado'}`);
91
103
  console.log(` Proyecto: ${projectKind === 'brownfield' ? 'brownfield (épica caparazón N/A)' : projectKind === 'greenfield' ? 'greenfield (épica caparazón requerida — se define en /build:onboard)' : 'indeterminado (se confirma en /build:onboard)'}`);
104
+ console.log(` Runtime: ${runtime}`);
92
105
  console.log('');
93
106
  console.log('Siguiente paso — abre Claude Code y ejecuta:');
94
107
  console.log(' /build:onboard # parametriza el dominio (PII, capa IA, capa determinista) y escribe memoria');
95
108
  }
109
+ /**
110
+ * Registro del agente CLI + primer sync de contexto, solo si el llamador pasó
111
+ * --runtime-url/--runtime-token. Fail-open en TODO: nunca lanza, nunca aborta init.
112
+ * Devuelve una línea de una sola frase para el resumen.
113
+ */
114
+ async function maybeRegisterRuntime(targetDir, t, version, opts) {
115
+ if (!opts.runtimeUrl && !opts.runtimeToken) {
116
+ return 'legacy (sin --runtime-url/--runtime-token; corre `trycore-build init --runtime-url … --runtime-token …` cuando el ADMIN te dé el token)';
117
+ }
118
+ if (!opts.runtimeUrl || !opts.runtimeToken) {
119
+ console.log(' ⚠ runtime: --runtime-url y --runtime-token deben darse juntos — se omite el registro (modo legacy).');
120
+ return 'legacy (faltó --runtime-url o --runtime-token)';
121
+ }
122
+ if (!isPlausibleUrl(opts.runtimeUrl)) {
123
+ console.log(` ⚠ runtime: --runtime-url "${opts.runtimeUrl}" no es una URL http(s) válida — se omite el registro (modo legacy).`);
124
+ return 'legacy (--runtime-url inválida)';
125
+ }
126
+ const mode = opts.runtimeMode ?? 'dual';
127
+ let assetTypes = [];
128
+ if (fs.existsSync(t.assetTypesFile)) {
129
+ try {
130
+ const parsed = JSON.parse(fs.readFileSync(t.assetTypesFile, 'utf8'));
131
+ assetTypes = Array.isArray(parsed?.types) ? parsed.types : [];
132
+ }
133
+ catch {
134
+ /* asset-types.json ilegible: se registra con [] — no bloquea */
135
+ }
136
+ }
137
+ const res = await registerAgent(targetDir, { url: opts.runtimeUrl, token: opts.runtimeToken, harnessVersion: version, assetTypes });
138
+ if (!res.ok) {
139
+ console.log(` ⚠ runtime: no se pudo registrar (${res.message ?? 'error desconocido'}). Credenciales guardadas; se reintenta en la primera sesión.`);
140
+ return `legacy (registro falló: ${res.message ?? 'error desconocido'})`;
141
+ }
142
+ // build-config.json ya está sembrado (paso 3) — escribe runtime.mode sin pisar el resto.
143
+ try {
144
+ const cfg = JSON.parse(fs.readFileSync(t.buildConfigFile, 'utf8'));
145
+ cfg.runtime = { ...(cfg.runtime ?? {}), mode };
146
+ fs.writeFileSync(t.buildConfigFile, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
147
+ }
148
+ catch {
149
+ console.log(' ⚠ runtime: no se pudo escribir runtime.mode en build-config.json (queda legacy hasta ajustarlo a mano).');
150
+ return 'legacy (no se pudo escribir build-config.json)';
151
+ }
152
+ console.log(` ✓ runtime: agente registrado (modo ${mode}).`);
153
+ // Primer sync de contexto: se delega al script bash ya instalado (mismo protocolo que
154
+ // usa toda sesión Claude) en vez de reimplementar el algoritmo de sync en TS.
155
+ const syncScript = path.join(t.hooksBuild, 'context-sync.sh');
156
+ if (fs.existsSync(syncScript)) {
157
+ const r = spawnSync('bash', [syncScript], { cwd: targetDir, env: { ...process.env, CLAUDE_PROJECT_DIR: targetDir }, encoding: 'utf8' });
158
+ if (r.status === 0) {
159
+ console.log(' ✓ runtime: primer sync de contexto completado.');
160
+ if (r.stdout?.trim())
161
+ console.log(r.stdout.trim().split('\n').map((l) => ` ${l}`).join('\n'));
162
+ }
163
+ else {
164
+ console.log(' ⚠ runtime: el primer sync de contexto no terminó limpio — se reintenta en la primera sesión.');
165
+ }
166
+ }
167
+ return `${mode} (agente registrado, primer sync intentado)`;
168
+ }
96
169
  function syncClaudeMdBlock(targetDir, version) {
97
170
  const t = targetPaths(targetDir);
98
171
  if (!fs.existsSync(ASSETS.templateClaudeMd)) {
@@ -142,7 +215,17 @@ function syncGitignoreBlock(targetDir, mode) {
142
215
  const isGitRepo = fs.existsSync(path.join(targetDir, '.git'));
143
216
  if (!isGitRepo && !fs.existsSync(t.gitignore))
144
217
  return;
145
- const lines = [GI_BEGIN + ' ───────────────────────', '.claude/state/build-state.json'];
218
+ const lines = [
219
+ GI_BEGIN + ' ───────────────────────',
220
+ '.claude/state/build-state.json',
221
+ // Credenciales del runtime (token 0600) y sus artefactos derivados [EP-OR-08-D]:
222
+ // nunca deben poder llegar a un `git add` descuidado, sin importar el modo de link.
223
+ '.claude/state/runtime.credentials',
224
+ '.claude/state/context.lock',
225
+ '.claude/state/runtime-projection.json',
226
+ '.claude/state/.runtime-http-status',
227
+ '.claude/state/outbox/',
228
+ ];
146
229
  if (mode === 'symlink') {
147
230
  // Los symlinks absolutos a node_modules global se rompen en otra máquina si se commitean.
148
231
  lines.push('.claude/agents/build/', '.claude/commands/opsx/', '.claude/commands/build/', '.claude/hooks/build/', '.claude/scripts/lib/');
@@ -0,0 +1,153 @@
1
+ // commands/migrate.ts — prepara el bundle normalizado del build-state.json legacy para que
2
+ // un ADMIN lo importe en la consola del hub (docs/superpowers/specs/2026-08-20-cliente-migrate-normalizacion-eventos-design.md).
3
+ // Nunca sube nada: el import es acto de gobierno, igual que el bundle de grafo (Task 13, C).
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { targetPaths } from '../lib/paths.js';
7
+ import { buildStateBundle, extractGraphSection, graphEpicsWithoutRepresentation, verifyStateBundle, } from '../lib/state-bundle.js';
8
+ import { parsePreHarnessList } from '../lib/normalize.js';
9
+ export async function migrate(opts) {
10
+ const targetDir = path.resolve(opts.targetDir);
11
+ const t = targetPaths(targetDir);
12
+ if (!fs.existsSync(t.stateFile)) {
13
+ console.error(`✗ No hay build-state.json en ${t.stateFile} — nada que migrar.`);
14
+ process.exit(1);
15
+ }
16
+ let raw;
17
+ try {
18
+ raw = JSON.parse(fs.readFileSync(t.stateFile, 'utf8'));
19
+ }
20
+ catch (err) {
21
+ console.error(`✗ build-state.json no parsea como JSON: ${err.message}`);
22
+ process.exit(1);
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
+ }
53
+ const projectRef = opts.projectRef ?? path.basename(targetDir);
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
+ }
96
+ if (bundle.validation_errors.length > 0) {
97
+ console.error('✗ El bundle normalizado no pasa la validación local — no se escribe el fichero.');
98
+ for (const e of bundle.validation_errors)
99
+ console.error(` ✗ ${e}`);
100
+ process.exit(1);
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
+ }
125
+ bundle.generated_at = new Date().toISOString();
126
+ const outFile = opts.outFile ? path.resolve(opts.outFile) : path.join(t.stateDir, 'migration-bundle.json');
127
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
128
+ fs.writeFileSync(outFile, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
129
+ console.log(`✓ Bundle de estado preparado: ${outFile}`);
130
+ console.log(` ${bundle.history.length} slice(s) de historial · ${bundle.releases.length} release(s) · ` +
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
+ }
143
+ if (bundle.warnings.length > 0) {
144
+ console.log('');
145
+ console.log(' Avisos (no bloquean; corrígelos en discovery si aplica):');
146
+ for (const w of bundle.warnings)
147
+ console.log(` ⚠ ${w}`);
148
+ }
149
+ console.log('');
150
+ console.log('Este fichero NO se sube automáticamente: es un acto de gobierno. Entrégalo a un');
151
+ console.log('ADMIN con esta instrucción literal: «súbelo en la consola del hub, pantalla de');
152
+ console.log('import histórico del proyecto». El import es idempotente y reanuda si falló a medias.');
153
+ }
@@ -4,6 +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
8
  export async function status(opts) {
8
9
  const targetDir = path.resolve(opts.targetDir);
9
10
  const t = targetPaths(targetDir);
@@ -32,6 +33,39 @@ export async function status(opts) {
32
33
  console.log('Requisitos externos:');
33
34
  console.log(` openspec: ${hasBinary('openspec') ? '✓' : '✗ falta (npm i -g @fission-ai/openspec)'}`);
34
35
  console.log(` python3: ${hasBinary('python3') ? '✓' : '✗ falta'}`);
36
+ // Runtime [EP-OR-08-D]: en dual/runtime, la fuente preferida es la caché de proyección
37
+ // (la normaliza agent-context.sh — esta CLI no duplica esa lógica, solo lee y pinguea).
38
+ const mode = runtimeMode(targetDir);
39
+ if (mode !== 'legacy') {
40
+ console.log('');
41
+ console.log(`Runtime (modo ${mode}):`);
42
+ const ctx = await getAgentContext(targetDir);
43
+ console.log(` Conexión: ${ctx.status === 200 ? '✓' : ctx.status === 0 ? '⚠ inalcanzable (trabajando offline)' : `⚠ respondió ${ctx.status}`}`);
44
+ const proj = readProjection(targetDir);
45
+ if (proj.fetched_at) {
46
+ const ageS = Math.max(0, Math.floor((Date.now() - Date.parse(String(proj.fetched_at))) / 1000));
47
+ console.log(` Proyección: ✓ v${proj.context?.version ?? '?'} (hace ${ageS}s${ageS > 900 ? ' — ⚠ stale' : ''})`);
48
+ const slice = proj.active_slice;
49
+ if (slice) {
50
+ console.log(` Slice activo: ${slice.epic_code ?? '?'} (fase ${slice.phase ?? '?'})`);
51
+ }
52
+ else {
53
+ console.log(' Slice activo: ninguno');
54
+ }
55
+ const nudges = Array.isArray(proj.nudges) ? proj.nudges : [];
56
+ if (nudges.length > 0)
57
+ console.log(` Nudges: ${nudges.length} pendiente(s)`);
58
+ }
59
+ else {
60
+ console.log(' Proyección: — sin caché aún (se resuelve en la primera sesión Claude)');
61
+ }
62
+ const lock = readLock(targetDir);
63
+ console.log(` Contexto: ${lock.manifestHash ? `✓ sincronizado (${lock.fileCount} archivo(s))` : '⚠ sin sincronizar'}`);
64
+ if (mode === 'runtime') {
65
+ console.log('══════════════════════════════════════════════════════');
66
+ return;
67
+ }
68
+ }
35
69
  // Estado del arnés
36
70
  if (fs.existsSync(t.stateFile)) {
37
71
  try {