@trycore/spec-build-harness 0.8.4 → 0.10.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/GOVERNANCE.md +43 -5
- package/INSTALL.md +28 -6
- package/METODOLOGIA.md +65 -10
- package/README.md +41 -7
- package/VERSION +1 -1
- package/agents/build/build-orchestrator.md +33 -7
- package/agents/build/dor-dod-gatekeeper.md +17 -6
- package/agents/build/ux-fidelity-reviewer.md +4 -1
- package/agents/build/wiring-adversarial-verifier.md +52 -5
- package/commands/build/architect.md +1 -1
- package/commands/build/claim.md +46 -0
- package/commands/build/escalate.md +36 -0
- package/commands/build/front.md +9 -3
- package/commands/build/onboard.md +63 -14
- package/commands/build/prototype.md +23 -0
- package/commands/build/reflect.md +60 -40
- package/commands/build/release.md +10 -7
- package/commands/build/resume.md +33 -13
- package/commands/build/slice.md +32 -27
- package/commands/build/status.md +35 -0
- package/commands/build/work.md +11 -8
- package/config/build-config.template.json +4 -0
- package/dist/cli.js +22 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/init.js +84 -1
- package/dist/commands/migrate.js +48 -0
- package/dist/commands/status.js +34 -0
- package/dist/lib/normalize.js +276 -0
- package/dist/lib/paths.js +6 -0
- package/dist/lib/runtime-client.js +196 -0
- package/dist/lib/settings-merge.js +3 -3
- package/dist/lib/state-bundle.js +46 -0
- package/docs/commands.md +32 -9
- package/docs/getting-started.md +2 -1
- package/docs/hooks.md +114 -27
- package/docs/runtime/guia-modo-dual-y-migracion.md +136 -0
- package/docs/runtime/plan-migracion-harness-v0.9.md +11 -0
- package/docs/runtime/protocolo-cliente-runtime.md +109 -34
- package/hooks/build/build-gate-check.sh +21 -0
- package/hooks/build/context-monitor.sh +82 -15
- package/hooks/build/context-sync.sh +192 -0
- package/hooks/build/design-source-guard.sh +31 -3
- package/hooks/build/dual-compare.sh +92 -0
- package/hooks/build/event-emitter.sh +75 -0
- package/hooks/build/gitflow-guard.sh +164 -14
- package/hooks/build/heartbeat.sh +259 -0
- package/hooks/build/lib/agent-context.sh +139 -0
- package/hooks/build/lib/config.sh +27 -0
- package/hooks/build/lib/projection.sh +71 -0
- package/hooks/build/lib/runtime-client.sh +465 -0
- package/hooks/build/lib/runtime-ops.sh +221 -0
- package/hooks/build/lib/state-io.sh +5 -18
- package/hooks/build/load-build-state.sh +64 -2
- package/hooks/build/reflect-nudge.sh +15 -0
- package/hooks/build/release-gate-nudge.sh +15 -0
- package/hooks/build/release-ops.sh +164 -0
- package/hooks/build/scaffold-guard.sh +29 -2
- package/hooks/build/session-start.sh +103 -0
- package/hooks/build/session-stop.sh +22 -0
- package/hooks/build/slice-ops.sh +877 -0
- package/hooks/build/stack-guard.sh +8 -0
- package/hooks/build/statusline-bridge.sh +24 -3
- package/hooks/build-harness.json +16 -0
- package/package.json +3 -3
- package/scripts/check-agnostic.sh +3 -1
- package/scripts/check-pack-clean.sh +31 -0
- package/scripts/check-runtime-purity.sh +43 -0
- package/scripts/lib/front-plan.py +4 -0
- package/scripts/lib/graph-bundle.py +133 -0
- package/scripts/runtime-purity-allow.txt +5 -0
- package/scripts/smoke-test.sh +1 -1
- package/scripts/tests/lib/http-stub.py +46 -0
- package/scripts/tests/test-baseline-verdict.sh +92 -0
- package/scripts/tests/test-config.sh +25 -0
- package/scripts/tests/test-hooks-runtime.sh +853 -0
- package/scripts/tests/test-install.sh +57 -0
- package/scripts/tests/test-runtime-client.sh +298 -0
- package/scripts/tests/test-schema.sh +29 -1
- package/scripts/tests/test-skill-ops.sh +847 -0
- package/skills/building-a-micro-change/SKILL.md +22 -4
- package/skills/building-a-slice/SKILL.md +58 -23
- package/skills/building-a-slice/assets/baseline-verdict.sh +172 -0
- package/skills/building-a-slice/references/dod.md +12 -3
- package/skills/building-a-slice/references/dor.md +7 -3
- package/skills/building-a-slice/references/evidence-budget.md +51 -0
- package/skills/building-a-slice/references/exploration-fanout.md +1 -1
- package/skills/building-a-slice/references/gitflow.md +1 -1
- package/skills/building-a-slice/references/regression-baseline.md +67 -0
- package/skills/building-a-slice/references/runtime-protocol.md +75 -0
- package/skills/building-a-slice/references/state-protocol.md +12 -1
- package/skills/building-a-slice/workflows/README.md +7 -3
- package/skills/building-a-slice/workflows/explore-fanout.workflow.js +3 -3
- package/skills/building-a-slice/workflows/wiring-verify.workflow.js +26 -4
- package/skills/managing-parallel-front/SKILL.md +32 -16
- package/skills/openspec-archive-change/SKILL.md +15 -0
- package/skills/prototyping-screens/SKILL.md +104 -0
- package/skills/prototyping-screens/assets/DESIGN.md.template +55 -0
- package/skills/prototyping-screens/assets/manifest.schema.json +70 -0
- package/skills/prototyping-screens/assets/screen.template.html +34 -0
- package/skills/prototyping-screens/references/aesthetic-directions.md +42 -0
- package/skills/prototyping-screens/references/extraction.md +57 -0
- package/skills/prototyping-screens/references/self-check.md +40 -0
- package/skills/releasing-a-version/SKILL.md +25 -16
- package/skills/releasing-a-version/references/release-dod.md +7 -5
- package/skills/releasing-a-version/workflows/README.md +2 -1
- package/skills/releasing-a-version/workflows/release-gate.workflow.js +6 -5
- package/skills/setup-architecture/SKILL.md +4 -2
- package/state/README.md +20 -3
- package/state/build-state.schema.json +3 -2
- package/templates/CLAUDE.md.template +17 -1
- package/templates/settings-hooks.template.json +8 -4
- package/internal/skills/auditar-arnes/SKILL.md +0 -29
package/commands/build/work.md
CHANGED
|
@@ -6,7 +6,7 @@ tags: [build-harness, router, classify-and-act, trycore]
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
**Router puro.** Decide *qué carril* aplica y **delega** en la skill correspondiente. NO ejecuta el pipeline,
|
|
9
|
-
NO
|
|
9
|
+
NO transiciona el estado del slice, NO crea ramas. Codifica como **ruteo** (no política nueva) el *decision gate*
|
|
10
10
|
del micro-change y el default del Release Gate de `METODOLOGIA.md` (§2, §4). Si algo contradice la metodología,
|
|
11
11
|
**gana la metodología**.
|
|
12
12
|
|
|
@@ -28,12 +28,14 @@ Si `NOT_INSTALLED` → ejecuta `trycore-build init` y vuelve.
|
|
|
28
28
|
|
|
29
29
|
Aplica las reglas en orden:
|
|
30
30
|
|
|
31
|
-
0. **¿Greenfield sin caparazón construido?** —
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
0. **¿Greenfield sin caparazón construido?** — consulta los hechos de proyecto con
|
|
32
|
+
`bash .claude/hooks/build/slice-ops.sh status` (línea `hechos:`): si `project_kind` es
|
|
33
|
+
`greenfield` y `foundation_done` es `false` → **enruta directo a construir la épica caparazón**
|
|
34
|
+
por el carril **`building-a-slice`**. `status` no trae **cuál** es esa épica (`foundation.epic`
|
|
35
|
+
vive hoy en el fichero legacy, leído directo por `dor-dod-gatekeeper`); si no la encuentras o
|
|
36
|
+
está sin definir, enruta a `/build:onboard` (Fase 2c) o a discovery para definirla. Ninguna épica de
|
|
37
|
+
negocio pasa por delante. Brownfield, o caparazón no requerida (`foundation_done` ya `true`) →
|
|
38
|
+
esta regla es N/A.
|
|
37
39
|
Esta regla gatea **aperturas de slice** (épicas): el carril de **mantenimiento**
|
|
38
40
|
(`building-a-micro-change`, regla 1) no se bloquea — un typo/copy/config fix sigue su carril
|
|
39
41
|
normal aun sin caparazón construida.
|
|
@@ -70,7 +72,8 @@ modelo/invariantes de datos** → **deja de ser micro-change** y se enruta a **`
|
|
|
70
72
|
|
|
71
73
|
## Guardrails
|
|
72
74
|
|
|
73
|
-
- **Solo ruteo.** No corres el pipeline, no
|
|
75
|
+
- **Solo ruteo.** No corres el pipeline, no transicionas el estado (ni por `slice-ops.sh` ni por el
|
|
76
|
+
fichero), no creas ramas: eso es de las skills destino.
|
|
74
77
|
- **Ante la duda, épica.** Nunca degrades un cambio con límite duro a micro-change.
|
|
75
78
|
- **No dupliques gates** ni saltes el orden: las skills destino los gobiernan.
|
|
76
79
|
- **Agnóstico**: vocabulario genérico del arnés; lo específico del dominio entra por `/build:onboard`.
|
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,21 @@ 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
|
+
.action(async (directory, opts) => {
|
|
116
|
+
try {
|
|
117
|
+
await migrate({ targetDir: directory, outFile: opts.out, projectRef: opts.projectRef });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
console.error('✗ Error:', err.message);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
102
124
|
program.parseAsync(process.argv).catch((err) => {
|
|
103
125
|
console.error('✗ Error inesperado:', err);
|
|
104
126
|
process.exit(1);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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'));
|
package/dist/commands/init.js
CHANGED
|
@@ -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 = [
|
|
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,48 @@
|
|
|
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 } from '../lib/state-bundle.js';
|
|
8
|
+
export async function migrate(opts) {
|
|
9
|
+
const targetDir = path.resolve(opts.targetDir);
|
|
10
|
+
const t = targetPaths(targetDir);
|
|
11
|
+
if (!fs.existsSync(t.stateFile)) {
|
|
12
|
+
console.error(`✗ No hay build-state.json en ${t.stateFile} — nada que migrar.`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = JSON.parse(fs.readFileSync(t.stateFile, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
console.error(`✗ build-state.json no parsea como JSON: ${err.message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const projectRef = opts.projectRef ?? path.basename(targetDir);
|
|
24
|
+
const bundle = buildStateBundle(raw, projectRef);
|
|
25
|
+
if (bundle.validation_errors.length > 0) {
|
|
26
|
+
console.error('✗ El bundle normalizado no pasa la validación local — no se escribe el fichero.');
|
|
27
|
+
for (const e of bundle.validation_errors)
|
|
28
|
+
console.error(` ✗ ${e}`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
bundle.generated_at = new Date().toISOString();
|
|
32
|
+
const outFile = opts.outFile ? path.resolve(opts.outFile) : path.join(t.stateDir, 'migration-bundle.json');
|
|
33
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
34
|
+
fs.writeFileSync(outFile, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
|
|
35
|
+
console.log(`✓ Bundle de estado preparado: ${outFile}`);
|
|
36
|
+
console.log(` ${bundle.history.length} slice(s) de historial · ${bundle.releases.length} release(s) · ` +
|
|
37
|
+
`${bundle.fronts.length} front(s) · ${bundle.facts.length} hecho(s) de proyecto · ${bundle.unmapped.length} entrada(s) no mapeable(s)`);
|
|
38
|
+
if (bundle.warnings.length > 0) {
|
|
39
|
+
console.log('');
|
|
40
|
+
console.log(' Avisos (no bloquean; corrígelos en discovery si aplica):');
|
|
41
|
+
for (const w of bundle.warnings)
|
|
42
|
+
console.log(` ⚠ ${w}`);
|
|
43
|
+
}
|
|
44
|
+
console.log('');
|
|
45
|
+
console.log('Este fichero NO se sube automáticamente: es un acto de gobierno. Entrégalo a un');
|
|
46
|
+
console.log('ADMIN con esta instrucción literal: «súbelo en la consola del hub, pantalla de');
|
|
47
|
+
console.log('import histórico del proyecto». El import es idempotente y reanuda si falló a medias.');
|
|
48
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -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 {
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// normalize.ts — normaliza el build-state.json legacy a la forma de eventos
|
|
2
|
+
// del catálogo v2 que exige POST /orchestrator/projects/{id}/import/bundle
|
|
3
|
+
// (docs/superpowers/specs/2026-08-20-cliente-migrate-normalizacion-eventos-design.md).
|
|
4
|
+
//
|
|
5
|
+
// Catálogo v2 embebido desde trycore-ia-hub/backend/app/modules/orchestrator/
|
|
6
|
+
// event_catalog.py (SCHEMAS/TIPOS_POR_AGREGADO), copiado 2026-08-20. Si el
|
|
7
|
+
// catálogo del hub cambia, esta copia se desincroniza — no hay forma de
|
|
8
|
+
// importarlo en build-time entre repos distintos.
|
|
9
|
+
export const PHASE_ORDER = [
|
|
10
|
+
'dor', 'change', 'red', 'green', 'refactor', 'smoke', 'api', 'data', 'dod', 'pr', 'archived',
|
|
11
|
+
];
|
|
12
|
+
export const EVENT_TYPES_BY_AGGREGATE = {
|
|
13
|
+
slice: new Set([
|
|
14
|
+
'slice_opened', 'phase_advanced', 'gate_verdict', 'wiring_checklist_seeded',
|
|
15
|
+
'wiring_item_updated', 'progress_noted', 'slice_archived', 'slice_parked', 'slice_cancelled',
|
|
16
|
+
]),
|
|
17
|
+
release: new Set(['release_opened', 'release_gate_verdict', 'release_closed']),
|
|
18
|
+
front: new Set(['front_opened', 'front_member_added', 'front_drained', 'front_closed']),
|
|
19
|
+
};
|
|
20
|
+
const SECOND_MS = 1000;
|
|
21
|
+
const EPOCH_ISO = new Date(0).toISOString();
|
|
22
|
+
function toIso(value, fallback) {
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
const d = new Date(value);
|
|
25
|
+
if (!Number.isNaN(d.getTime()))
|
|
26
|
+
return d.toISOString();
|
|
27
|
+
}
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
function ensureStrictlyIncreasing(events) {
|
|
31
|
+
let prevMs = -Infinity;
|
|
32
|
+
return events.map((ev) => {
|
|
33
|
+
let ms = new Date(ev.occurred_at).getTime();
|
|
34
|
+
if (Number.isNaN(ms) || ms <= prevMs)
|
|
35
|
+
ms = prevMs === -Infinity ? Date.parse(EPOCH_ISO) : prevMs + SECOND_MS;
|
|
36
|
+
prevMs = ms;
|
|
37
|
+
return { ...ev, occurred_at: new Date(ms).toISOString() };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function openingEvent(entry, epicCode) {
|
|
41
|
+
return {
|
|
42
|
+
event_type: 'slice_opened',
|
|
43
|
+
payload: { epic_code: epicCode },
|
|
44
|
+
occurred_at: toIso(entry.updated_at, EPOCH_ISO),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function phaseAdvancedEvents(entry) {
|
|
48
|
+
const finalPhase = typeof entry.phase === 'string' ? entry.phase : null;
|
|
49
|
+
const idx = finalPhase ? PHASE_ORDER.indexOf(finalPhase) : -1;
|
|
50
|
+
if (idx <= 0)
|
|
51
|
+
return [];
|
|
52
|
+
const upTo = finalPhase === 'archived' ? PHASE_ORDER.indexOf('pr') : idx;
|
|
53
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
54
|
+
const events = [];
|
|
55
|
+
for (let i = 1; i <= upTo; i++) {
|
|
56
|
+
events.push({ event_type: 'phase_advanced', payload: { to: PHASE_ORDER[i] }, occurred_at: anchor });
|
|
57
|
+
}
|
|
58
|
+
return events;
|
|
59
|
+
}
|
|
60
|
+
function gateVerdictEvents(entry) {
|
|
61
|
+
const gates = entry.gates && typeof entry.gates === 'object' ? entry.gates : {};
|
|
62
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
63
|
+
return Object.entries(gates)
|
|
64
|
+
.filter(([, v]) => v === true)
|
|
65
|
+
.map(([gate]) => ({ event_type: 'gate_verdict', payload: { gate, verdict: 'PASS' }, occurred_at: anchor }));
|
|
66
|
+
}
|
|
67
|
+
function wiringEvents(entry) {
|
|
68
|
+
const checklist = Array.isArray(entry.wiring_checklist) ? entry.wiring_checklist : [];
|
|
69
|
+
if (checklist.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
72
|
+
const events = [
|
|
73
|
+
{
|
|
74
|
+
event_type: 'wiring_checklist_seeded',
|
|
75
|
+
payload: {
|
|
76
|
+
items: checklist.map((item) => ({
|
|
77
|
+
item_id: String(item.item_id ?? item.id ?? ''),
|
|
78
|
+
kind: String(item.kind ?? 'unknown'),
|
|
79
|
+
ref: String(item.ref ?? ''),
|
|
80
|
+
})),
|
|
81
|
+
},
|
|
82
|
+
occurred_at: anchor,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
for (const item of checklist) {
|
|
86
|
+
if (typeof item.status === 'string') {
|
|
87
|
+
events.push({
|
|
88
|
+
event_type: 'wiring_item_updated',
|
|
89
|
+
payload: { item_id: String(item.item_id ?? item.id ?? ''), status: item.status },
|
|
90
|
+
occurred_at: anchor,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return events;
|
|
95
|
+
}
|
|
96
|
+
function progressNotedEvents(entry) {
|
|
97
|
+
const log = Array.isArray(entry.progress_log) ? entry.progress_log : [];
|
|
98
|
+
const fallback = toIso(entry.updated_at, EPOCH_ISO);
|
|
99
|
+
return log
|
|
100
|
+
.filter((p) => typeof p === 'object' && p !== null && typeof p.note === 'string')
|
|
101
|
+
.map((p) => ({ event_type: 'progress_noted', payload: { note: p.note }, occurred_at: toIso(p.at, fallback) }));
|
|
102
|
+
}
|
|
103
|
+
function bodyEvents(entry) {
|
|
104
|
+
return [...phaseAdvancedEvents(entry), ...gateVerdictEvents(entry), ...wiringEvents(entry), ...progressNotedEvents(entry)];
|
|
105
|
+
}
|
|
106
|
+
function closingEvent(entry) {
|
|
107
|
+
const anchor = toIso(entry.updated_at, EPOCH_ISO);
|
|
108
|
+
if (entry.phase === 'archived')
|
|
109
|
+
return { event_type: 'slice_archived', payload: {}, occurred_at: anchor };
|
|
110
|
+
if (entry.status === 'parked') {
|
|
111
|
+
return { event_type: 'slice_parked', payload: { reason: String(entry.reason ?? entry.notes ?? 'sin motivo registrado') }, occurred_at: anchor };
|
|
112
|
+
}
|
|
113
|
+
if (entry.status === 'cancelled')
|
|
114
|
+
return { event_type: 'slice_cancelled', payload: {}, occurred_at: anchor };
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Fusiona todas las entradas de `history[]` de UNA épica en una sola secuencia
|
|
119
|
+
* continua: un único slice_opened al inicio, un único cierre al final (de la
|
|
120
|
+
* entrada de MAYOR índice, nunca por updated_at — spec §6.1). El reducer del
|
|
121
|
+
* servidor prohíbe reabrir un slice tras un cierre, así que las entradas
|
|
122
|
+
* intermedias solo aportan su cuerpo (fases/gates/wiring/progreso), sin abrir
|
|
123
|
+
* ni cerrar.
|
|
124
|
+
*/
|
|
125
|
+
export function normalizeHistoryGroup(epicCode, entries) {
|
|
126
|
+
const objs = entries.filter((e) => typeof e === 'object' && e !== null && !Array.isArray(e));
|
|
127
|
+
if (objs.length === 0)
|
|
128
|
+
return null;
|
|
129
|
+
const events = [openingEvent(objs[0], epicCode)];
|
|
130
|
+
objs.forEach((entry, i) => {
|
|
131
|
+
events.push(...bodyEvents(entry));
|
|
132
|
+
if (i === objs.length - 1) {
|
|
133
|
+
const closing = closingEvent(entry);
|
|
134
|
+
if (closing)
|
|
135
|
+
events.push(closing);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
return { epic_code: epicCode, events: ensureStrictlyIncreasing(events), normalized: true };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Agrupa history[] por épica preservando el ORDEN DE ÍNDICE de aparición
|
|
142
|
+
* (nunca por updated_at — spec §6.1) y fusiona cada grupo en una entrada.
|
|
143
|
+
* Entradas sin `epica` reconocible caen a unmapped, sin bloquear a las demás.
|
|
144
|
+
*/
|
|
145
|
+
export function normalizeHistory(historyRaw) {
|
|
146
|
+
const groups = new Map();
|
|
147
|
+
const order = [];
|
|
148
|
+
const unmapped = [];
|
|
149
|
+
historyRaw.forEach((raw, i) => {
|
|
150
|
+
const obj = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : null;
|
|
151
|
+
const epicCode = obj && typeof obj.epica === 'string' ? obj.epica : null;
|
|
152
|
+
if (!epicCode) {
|
|
153
|
+
unmapped.push({ source_key: `history[${i}]`, original: raw, occurred_at: toIso(obj?.updated_at, EPOCH_ISO) });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!groups.has(epicCode)) {
|
|
157
|
+
groups.set(epicCode, []);
|
|
158
|
+
order.push(epicCode);
|
|
159
|
+
}
|
|
160
|
+
groups.get(epicCode).push(obj);
|
|
161
|
+
});
|
|
162
|
+
const entries = [];
|
|
163
|
+
for (const epicCode of order) {
|
|
164
|
+
const merged = normalizeHistoryGroup(epicCode, groups.get(epicCode));
|
|
165
|
+
if (merged)
|
|
166
|
+
entries.push(merged);
|
|
167
|
+
}
|
|
168
|
+
return { entries, unmapped };
|
|
169
|
+
}
|
|
170
|
+
function releaseEvents(release, key) {
|
|
171
|
+
const anchor = toIso(release.created_at, EPOCH_ISO);
|
|
172
|
+
const epicCodes = Array.isArray(release.epicas) ? release.epicas.map(String) : [];
|
|
173
|
+
const events = [
|
|
174
|
+
{ event_type: 'release_opened', payload: { release_line: key, epic_codes: epicCodes }, occurred_at: anchor },
|
|
175
|
+
];
|
|
176
|
+
const gates = release.gates && typeof release.gates === 'object' ? release.gates : {};
|
|
177
|
+
for (const [gate, verdict] of Object.entries(gates)) {
|
|
178
|
+
events.push({ event_type: 'release_gate_verdict', payload: { gate, verdict: verdict === true ? 'PASS' : 'FAIL' }, occurred_at: anchor });
|
|
179
|
+
}
|
|
180
|
+
if (release.status === 'failed' || release.status === 'passed') {
|
|
181
|
+
events.push({
|
|
182
|
+
event_type: 'release_closed',
|
|
183
|
+
payload: { status: release.status === 'failed' ? 'FAILED' : 'PASSED', epic_codes: epicCodes },
|
|
184
|
+
occurred_at: anchor,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const notes = Array.isArray(release.notes) ? release.notes : [];
|
|
188
|
+
const unmapped = notes.map((note, i) => ({
|
|
189
|
+
source_key: `releases[${key}].notes[${i}]`,
|
|
190
|
+
original: note,
|
|
191
|
+
occurred_at: anchor,
|
|
192
|
+
}));
|
|
193
|
+
return { events: ensureStrictlyIncreasing(events), unmapped };
|
|
194
|
+
}
|
|
195
|
+
/** `notes[]` de un release no tiene tipo en el catálogo del agregado `release`
|
|
196
|
+
* (event_catalog.py no declara ninguno) — se preserva en `unmapped[]` en vez
|
|
197
|
+
* de descartarse (spec §7: "nada se pierde, nada bloquea"). Mismo principio
|
|
198
|
+
* para una entrada sin `release_id` utilizable como key: cae a unmapped en
|
|
199
|
+
* vez de descartarse en silencio (consistente con normalizeHistory). */
|
|
200
|
+
export function normalizeReleases(releasesRaw) {
|
|
201
|
+
const entries = [];
|
|
202
|
+
const unmapped = [];
|
|
203
|
+
releasesRaw.forEach((raw, i) => {
|
|
204
|
+
const obj = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? raw : null;
|
|
205
|
+
const key = obj && typeof obj.release_id === 'string' ? obj.release_id : null;
|
|
206
|
+
if (!obj || !key) {
|
|
207
|
+
unmapped.push({ source_key: `releases[${i}]`, original: raw, occurred_at: toIso(obj?.created_at, EPOCH_ISO) });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const { events, unmapped: relUnmapped } = releaseEvents(obj, key);
|
|
211
|
+
entries.push({ key, events, normalized: true });
|
|
212
|
+
unmapped.push(...relUnmapped);
|
|
213
|
+
});
|
|
214
|
+
return { entries, unmapped };
|
|
215
|
+
}
|
|
216
|
+
export function normalizeFront(frontRaw) {
|
|
217
|
+
if (typeof frontRaw !== 'object' || frontRaw === null)
|
|
218
|
+
return null;
|
|
219
|
+
const front = frontRaw;
|
|
220
|
+
const anchor = toIso(front.opened_at, EPOCH_ISO);
|
|
221
|
+
const members = Array.isArray(front.members) ? front.members : [];
|
|
222
|
+
const events = [
|
|
223
|
+
{
|
|
224
|
+
event_type: 'front_opened',
|
|
225
|
+
payload: {
|
|
226
|
+
members: members.map((m) => String(m.epica ?? '')),
|
|
227
|
+
merge_order: Array.isArray(front.merge_order) ? front.merge_order : [],
|
|
228
|
+
asignaciones: members.map((m) => ({
|
|
229
|
+
epic_code: String(m.epica ?? ''),
|
|
230
|
+
worktree: String(m.worktree ?? ''),
|
|
231
|
+
assignee: String(m.assignee ?? ''),
|
|
232
|
+
})),
|
|
233
|
+
},
|
|
234
|
+
occurred_at: anchor,
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
if (front.status === 'drained')
|
|
238
|
+
events.push({ event_type: 'front_drained', payload: {}, occurred_at: anchor });
|
|
239
|
+
else if (front.status === 'closed')
|
|
240
|
+
events.push({ event_type: 'front_closed', payload: {}, occurred_at: anchor });
|
|
241
|
+
return { key: 'parallel_front', events: ensureStrictlyIncreasing(events), normalized: true };
|
|
242
|
+
}
|
|
243
|
+
const FACT_KEYS = ['scaffold', 'design_source', 'project_kind', 'project_kind_source', 'foundation', 'harness_phase'];
|
|
244
|
+
export function normalizeFacts(raw, fallbackAt) {
|
|
245
|
+
const facts = [];
|
|
246
|
+
for (const key of FACT_KEYS) {
|
|
247
|
+
if (!(key in raw))
|
|
248
|
+
continue;
|
|
249
|
+
const value = raw[key];
|
|
250
|
+
const normalizedValue = typeof value === 'boolean' || typeof value === 'string' ? value : value === null || value === undefined ? null : JSON.stringify(value);
|
|
251
|
+
facts.push({ fact: key, value: normalizedValue, occurred_at: fallbackAt, normalized: true });
|
|
252
|
+
}
|
|
253
|
+
return facts;
|
|
254
|
+
}
|
|
255
|
+
function checkSequence(errors, aggregate, key, events) {
|
|
256
|
+
if (events.length === 0)
|
|
257
|
+
errors.push(`${key}: sin eventos`);
|
|
258
|
+
for (const ev of events) {
|
|
259
|
+
if (!EVENT_TYPES_BY_AGGREGATE[aggregate].has(ev.event_type)) {
|
|
260
|
+
errors.push(`${key}: event_type "${ev.event_type}" no existe en el catálogo del agregado "${aggregate}"`);
|
|
261
|
+
}
|
|
262
|
+
if (Number.isNaN(new Date(ev.occurred_at).getTime())) {
|
|
263
|
+
errors.push(`${key}: occurred_at inválido: "${ev.occurred_at}"`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Validación local previa al volcado (spec §9): mismo catálogo que valida el
|
|
268
|
+
* servidor, sin replicar la forma de payload campo a campo (eso lo cubre el
|
|
269
|
+
* test golden, no una revalidación en runtime). */
|
|
270
|
+
export function validateNormalizedBundle(bundle) {
|
|
271
|
+
const errors = [];
|
|
272
|
+
bundle.history.forEach((h) => checkSequence(errors, 'slice', `history[${h.epic_code}]`, h.events));
|
|
273
|
+
bundle.releases.forEach((r) => checkSequence(errors, 'release', `releases[${r.key}]`, r.events));
|
|
274
|
+
bundle.fronts.forEach((f) => checkSequence(errors, 'front', `fronts[${f.key}]`, f.events));
|
|
275
|
+
return errors;
|
|
276
|
+
}
|