@bi-analytics/arness-back 0.0.0-stage → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +156 -2
  3. package/bin/arness.mjs +710 -0
  4. package/harness/AGENTS.md +234 -0
  5. package/harness/agents/implementador.md +42 -0
  6. package/harness/agents/lider.md +71 -0
  7. package/harness/agents/revisor.md +62 -0
  8. package/harness/decisiones/README.md +17 -0
  9. package/harness/decisiones/_plantilla.md +24 -0
  10. package/harness/init.sh +696 -0
  11. package/harness/playbooks/add-database.md +16 -0
  12. package/harness/playbooks/new-endpoint.md +88 -0
  13. package/harness/playbooks/new-module.md +21 -0
  14. package/harness/playbooks/new-monitor-widget.md +31 -0
  15. package/harness/playbooks/review.md +40 -0
  16. package/harness/reference/configuracion.md +104 -0
  17. package/harness/reference/contrato-http.md +138 -0
  18. package/harness/reference/novedades.json +107 -0
  19. package/harness/reference/progress-plantilla.md +50 -0
  20. package/harness/skills/commit-hygiene/SKILL.md +137 -0
  21. package/harness/skills/nest-base/SKILL.md +167 -0
  22. package/harness/skills/nest-base/templates/config/configuration.spec.ts +84 -0
  23. package/harness/skills/nest-base/templates/config/configuration.ts +143 -0
  24. package/harness/skills/nest-base/templates/cors/cors.spec.ts +53 -0
  25. package/harness/skills/nest-base/templates/cors/cors.ts +75 -0
  26. package/harness/skills/nest-base/templates/docs/EVOLUTIVO.md +22 -0
  27. package/harness/skills/nest-base/templates/docs/PENDIENTES.md +20 -0
  28. package/harness/skills/nest-base/templates/docs/TECNICO.md +43 -0
  29. package/harness/skills/nest-base/templates/e2e/cors.e2e-spec.ts +60 -0
  30. package/harness/skills/nest-base/templates/e2e/monitor.e2e-spec.ts +134 -0
  31. package/harness/skills/nest-base/templates/editorconfig +12 -0
  32. package/harness/skills/nest-base/templates/eslint.boundaries.mjs +65 -0
  33. package/harness/skills/nest-base/templates/eslint.config.mjs +82 -0
  34. package/harness/skills/nest-base/templates/logging/logger-options.spec.ts +57 -0
  35. package/harness/skills/nest-base/templates/logging/logger-options.ts +85 -0
  36. package/harness/skills/nest-base/templates/monitor/monitor-login-redirect.filter.ts +16 -0
  37. package/harness/skills/nest-base/templates/monitor/monitor-login.dto.ts +8 -0
  38. package/harness/skills/nest-base/templates/monitor/monitor-page.spec.ts +41 -0
  39. package/harness/skills/nest-base/templates/monitor/monitor-page.ts +190 -0
  40. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.spec.ts +46 -0
  41. package/harness/skills/nest-base/templates/monitor/monitor-session.guard.ts +36 -0
  42. package/harness/skills/nest-base/templates/monitor/monitor-session.service.spec.ts +57 -0
  43. package/harness/skills/nest-base/templates/monitor/monitor-session.service.ts +77 -0
  44. package/harness/skills/nest-base/templates/monitor/monitor-widget.interface.ts +18 -0
  45. package/harness/skills/nest-base/templates/monitor/monitor.constants.ts +25 -0
  46. package/harness/skills/nest-base/templates/monitor/monitor.controller.ts +63 -0
  47. package/harness/skills/nest-base/templates/monitor/monitor.decorators.ts +11 -0
  48. package/harness/skills/nest-base/templates/monitor/monitor.module.ts +30 -0
  49. package/harness/skills/nest-base/templates/monitor/monitor.service.spec.ts +55 -0
  50. package/harness/skills/nest-base/templates/monitor/monitor.service.ts +49 -0
  51. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.spec.ts +51 -0
  52. package/harness/skills/nest-base/templates/monitor/widgets/endpoints-total.widget.ts +53 -0
  53. package/harness/skills/nest-base/templates/prettierignore +6 -0
  54. package/harness/skills/nest-base/templates/prettierrc.json +8 -0
  55. package/package.json +44 -3
package/bin/arness.mjs ADDED
@@ -0,0 +1,710 @@
1
+ #!/usr/bin/env node
2
+ // arness — instala, sincroniza y verifica el harness de @bi-analytics/arness-back en un repositorio.
3
+ // Sin dependencias: solo módulos de Node (>= 20).
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const PKG = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8'));
13
+ // Se publica en el registro público de npm (ADR 0013).
14
+ const INSTALL_SPEC = `${PKG.name}@${PKG.version}`;
15
+
16
+ const H = 'harness';
17
+ const MANIFEST = `${H}/.arness.json`;
18
+ const CONFIG = `${H}/arness.config.json`;
19
+ // Respaldo de la versión anterior antes de cada sync; se ignora a sí mismo en git.
20
+ const BACKUP_DIR = `${H}/.arness-backup`;
21
+ const BACKUPS_KEPT = 3;
22
+ const MANIFEST_SCHEMA = 1;
23
+ // Del repo instalador: el paquete los crea una sola vez y sync nunca los toca.
24
+ const OWNED_PREFIXES = [`${H}/decisiones/`, `${H}/progress.md`, MANIFEST, CONFIG, `${BACKUP_DIR}/`];
25
+ const DECISION_SKELETON = [`${H}/decisiones/README.md`, `${H}/decisiones/_plantilla.md`];
26
+
27
+ // Puentes de la raíz: cada herramienta busca en rutas fijas (ADR 0002).
28
+ const BRIDGE_FILES = {
29
+ 'CLAUDE.md': `# CLAUDE.md
30
+
31
+ Puente hacia el harness. Las instrucciones viven en \`harness/AGENTS.md\`; no agregues reglas aquí.
32
+
33
+ @harness/AGENTS.md
34
+ `,
35
+ 'GEMINI.md': `# GEMINI.md
36
+
37
+ Puente hacia el harness. Las instrucciones viven en \`harness/AGENTS.md\`; no agregues reglas aquí.
38
+
39
+ @./harness/AGENTS.md
40
+ `,
41
+ '.cursor/rules/agents.mdc': `---
42
+ description: Reglas del repositorio (fuente única en harness/AGENTS.md)
43
+ alwaysApply: true
44
+ ---
45
+
46
+ Sigue estrictamente \`harness/AGENTS.md\` y los playbooks de \`harness/playbooks/\`. No agregues reglas aquí.
47
+
48
+ @harness/AGENTS.md
49
+ `,
50
+ };
51
+ const BRIDGE_LINKS = [
52
+ { link: 'AGENTS.md', target: `${H}/AGENTS.md`, type: 'file' },
53
+ { link: '.claude/agents', target: `../${H}/agents`, type: 'dir' },
54
+ { link: '.claude/skills', target: `../${H}/skills`, type: 'dir' },
55
+ { link: '.agents/skills', target: `../${H}/skills`, type: 'dir' },
56
+ ];
57
+
58
+ // ── utilidades ───────────────────────────────────────────────────────────────
59
+
60
+ class CliError extends Error {}
61
+
62
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
63
+ const log = (msg = '') => process.stdout.write(`${msg}\n`);
64
+ const isOwned = (rel) => OWNED_PREFIXES.some((p) => rel === p || rel.startsWith(p));
65
+
66
+ function walk(dir, base = dir) {
67
+ const out = [];
68
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
69
+ const abs = path.join(dir, entry.name);
70
+ if (entry.isDirectory()) out.push(...walk(abs, base));
71
+ else if (entry.isFile()) out.push(path.relative(base, abs).split(path.sep).join('/'));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** Archivos gestionados que ofrece esta versión del paquete: rel → { content, mode }. */
77
+ function packageFiles() {
78
+ const files = new Map();
79
+ for (const rel of walk(path.join(PKG_ROOT, H)).map((r) => `${H}/${r}`)) {
80
+ if (isOwned(rel)) continue;
81
+ const abs = path.join(PKG_ROOT, rel);
82
+ files.set(rel, { content: fs.readFileSync(abs), mode: fs.statSync(abs).mode & 0o777 });
83
+ }
84
+ for (const [rel, text] of Object.entries(BRIDGE_FILES)) {
85
+ files.set(rel, { content: Buffer.from(text), mode: 0o644 });
86
+ }
87
+ return files;
88
+ }
89
+
90
+ /** Lleva un manifiesto de cualquier versión anterior al formato actual (idempotente). */
91
+ function migrateManifest(raw) {
92
+ return { ...raw, schema: MANIFEST_SCHEMA, files: raw.files ?? {}, links: raw.links ?? {} };
93
+ }
94
+
95
+ function readManifest(root, rel = MANIFEST) {
96
+ const abs = path.join(root, rel);
97
+ if (!fs.existsSync(abs)) return null;
98
+ return migrateManifest(JSON.parse(fs.readFileSync(abs, 'utf8')));
99
+ }
100
+
101
+ function writeManifest(root, files, links) {
102
+ const manifest = {
103
+ schema: MANIFEST_SCHEMA,
104
+ package: PKG.name,
105
+ version: PKG.version,
106
+ files: Object.fromEntries([...files].map(([rel, f]) => [rel, sha256(f.content)]).sort()),
107
+ links,
108
+ };
109
+ fs.writeFileSync(path.join(root, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
110
+ }
111
+
112
+ function writeFile(root, rel, { content, mode }) {
113
+ const abs = path.join(root, rel);
114
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
115
+ fs.writeFileSync(abs, content);
116
+ fs.chmodSync(abs, mode);
117
+ }
118
+
119
+ function exists(abs) {
120
+ try {
121
+ fs.lstatSync(abs);
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ /** Sube desde cwd hasta encontrar una carpeta con harness/ instalado. */
129
+ function findRoot(start = process.cwd()) {
130
+ let dir = path.resolve(start);
131
+ for (;;) {
132
+ if (fs.existsSync(path.join(dir, MANIFEST))) return dir;
133
+ const parent = path.dirname(dir);
134
+ if (parent === dir) throw new CliError('No se encontró harness/.arness.json. Ejecuta primero: arness init');
135
+ dir = parent;
136
+ }
137
+ }
138
+
139
+ /** Crea los enlaces de la raíz; si el sistema no permite symlinks (Windows), copia. */
140
+ function createLinks(root, previous = {}, { force = false, forceCopy = false } = {}) {
141
+ const result = {};
142
+ for (const { link, target, type } of BRIDGE_LINKS) {
143
+ const abs = path.join(root, link);
144
+ const targetAbs = path.resolve(path.dirname(abs), target);
145
+ if (exists(abs)) {
146
+ const st = fs.lstatSync(abs);
147
+ const ours = st.isSymbolicLink() || previous[link] === 'copy';
148
+ if (!ours && !force) {
149
+ throw new CliError(`Ya existe ${link} y no lo creó arness. Muévelo o usa --force.`);
150
+ }
151
+ fs.rmSync(abs, { recursive: true, force: true });
152
+ }
153
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
154
+ let mode = 'symlink';
155
+ try {
156
+ if (forceCopy) throw new Error('copy requested');
157
+ fs.symlinkSync(target, abs, type);
158
+ } catch {
159
+ fs.cpSync(targetAbs, abs, { recursive: true });
160
+ mode = 'copy';
161
+ }
162
+ result[link] = mode;
163
+ }
164
+ return result;
165
+ }
166
+
167
+ function ensureDecisionSkeleton(root) {
168
+ const created = [];
169
+ for (const rel of DECISION_SKELETON) {
170
+ const abs = path.join(root, rel);
171
+ if (exists(abs)) continue;
172
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
173
+ fs.copyFileSync(path.join(PKG_ROOT, rel), abs);
174
+ created.push(rel);
175
+ }
176
+ return created;
177
+ }
178
+
179
+ function ensureGitignore(root) {
180
+ const abs = path.join(root, '.gitignore');
181
+ const line = `${H}/progress.md`;
182
+ const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
183
+ if (current.split(/\r?\n/).includes(line)) return false;
184
+ const sep = current && !current.endsWith('\n') ? '\n' : '';
185
+ fs.writeFileSync(abs, `${current}${sep}${line}\n`);
186
+ return true;
187
+ }
188
+
189
+ /** Copia la base instalada (archivos del manifiesto + manifiesto) antes de sobrescribirla. */
190
+ function backupBase(root, manifest) {
191
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '');
192
+ const dir = path.join(root, BACKUP_DIR, `${stamp}-v${manifest.version}`);
193
+ for (const rel of [...Object.keys(manifest.files), MANIFEST]) {
194
+ const src = path.join(root, rel);
195
+ if (!fs.existsSync(src)) continue;
196
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
197
+ fs.copyFileSync(src, path.join(dir, rel));
198
+ fs.chmodSync(path.join(dir, rel), fs.statSync(src).mode & 0o777);
199
+ }
200
+ fs.writeFileSync(path.join(root, BACKUP_DIR, '.gitignore'), '*\n');
201
+ const all = listBackups(root);
202
+ for (const old of all.slice(0, Math.max(0, all.length - BACKUPS_KEPT))) {
203
+ fs.rmSync(path.join(root, BACKUP_DIR, old), { recursive: true, force: true });
204
+ }
205
+ return path.relative(root, dir);
206
+ }
207
+
208
+ /** Respaldos disponibles, del más antiguo al más reciente. */
209
+ function listBackups(root) {
210
+ const abs = path.join(root, BACKUP_DIR);
211
+ if (!fs.existsSync(abs)) return [];
212
+ return fs
213
+ .readdirSync(abs, { withFileTypes: true })
214
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(abs, e.name, MANIFEST)))
215
+ .map((e) => e.name)
216
+ .sort();
217
+ }
218
+
219
+ /** Diff unificado entre lo instalado y lo nuevo (requiere git; si no está, se omite). */
220
+ function showDiff(root, rel, next) {
221
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'arness-diff-'));
222
+ const nextPath = path.join(tmp, path.basename(rel));
223
+ fs.writeFileSync(nextPath, next ?? '');
224
+ const current = path.join(root, rel);
225
+ const res = spawnSync('git', ['diff', '--no-index', '--no-color', '--', fs.existsSync(current) ? current : '/dev/null', nextPath], {
226
+ encoding: 'utf8',
227
+ });
228
+ fs.rmSync(tmp, { recursive: true, force: true });
229
+ if (res.error) return log(` (sin git: no se puede mostrar el diff de ${rel})`);
230
+ log(res.stdout.replaceAll(nextPath, `(nuevo) ${rel}`).replaceAll(current, rel).trimEnd());
231
+ }
232
+
233
+ /** Compara lo instalado con el manifiesto: archivos editados a mano o faltantes. */
234
+ function integrity(root, manifest) {
235
+ const modified = [];
236
+ const missing = [];
237
+ for (const [rel, hash] of Object.entries(manifest.files)) {
238
+ const abs = path.join(root, rel);
239
+ if (!fs.existsSync(abs)) missing.push(rel);
240
+ else if (sha256(fs.readFileSync(abs)) !== hash) modified.push(rel);
241
+ }
242
+ return { modified, missing };
243
+ }
244
+
245
+ // ── comandos ─────────────────────────────────────────────────────────────────
246
+
247
+ function cmdInit(opts) {
248
+ const root = path.resolve(opts.dir ?? process.cwd());
249
+ if (root === PKG_ROOT) throw new CliError('Este es el repositorio fuente del paquete; aquí no se instala.');
250
+ if (readManifest(root)) throw new CliError('El harness ya está instalado. Para actualizarlo usa: arness sync');
251
+
252
+ const files = packageFiles();
253
+ const conflicts = [...files.keys()].filter((rel) => {
254
+ const abs = path.join(root, rel);
255
+ return exists(abs) && !fs.readFileSync(abs).equals(files.get(rel).content);
256
+ });
257
+ if (conflicts.length && !opts.force) {
258
+ throw new CliError(`Estos archivos ya existen con otro contenido:\n${list(conflicts)}\nMuévelos o usa --force.`);
259
+ }
260
+
261
+ for (const [rel, file] of files) writeFile(root, rel, file);
262
+ const skeleton = ensureDecisionSkeleton(root);
263
+ const links = createLinks(root, {}, { force: opts.force, forceCopy: opts.copy });
264
+ writeManifest(root, files, links);
265
+ const gitignore = ensureGitignore(root);
266
+
267
+ log(`✔ ${PKG.name}@${PKG.version} instalado en ${root}`);
268
+ log(` ${files.size} archivos gestionados · ${skeleton.length ? 'harness/decisiones/ creada' : 'harness/decisiones/ ya existía (sin cambios)'}`);
269
+ for (const [link, mode] of Object.entries(links)) log(` ${link} → ${mode === 'copy' ? 'copia (sin symlinks)' : 'enlace'}`);
270
+ if (gitignore) log(' .gitignore: se agregó harness/progress.md');
271
+ log('');
272
+ log('Siguientes pasos:');
273
+ if (fs.existsSync(path.join(root, 'package.json'))) {
274
+ log(` pnpm add -D ${INSTALL_SPEC}`);
275
+ } else {
276
+ log(' Crea la base del proyecto con la skill nest-base; ella agrega el paquete como devDependency.');
277
+ }
278
+ log(' ./harness/init.sh');
279
+ }
280
+
281
+ function cmdSync(opts) {
282
+ const root = findRoot();
283
+ const manifest = readManifest(root);
284
+ const next = packageFiles();
285
+ const { modified, missing } = integrity(root, manifest);
286
+
287
+ // Archivos nuevos del paquete que chocan con algo que ya existe en el repo.
288
+ const collisions = [...next.keys()].filter((rel) => {
289
+ if (rel in manifest.files) return false;
290
+ const abs = path.join(root, rel);
291
+ return exists(abs) && !fs.readFileSync(abs).equals(next.get(rel).content);
292
+ });
293
+
294
+ if ((modified.length || collisions.length) && !opts.force) {
295
+ const parts = [];
296
+ if (modified.length) parts.push(`Archivos de la base editados a mano:\n${list(modified)}`);
297
+ if (collisions.length) parts.push(`Archivos nuevos del paquete que ya existen en el repo:\n${list(collisions)}`);
298
+ throw new CliError(
299
+ `${parts.join('\n')}\n\nLa base no se edita (AGENTS.md §17): convierte esos cambios en una decisión en ` +
300
+ `harness/decisiones/ o propónlos al paquete. Para sobrescribir de todos modos: arness sync --force`,
301
+ );
302
+ }
303
+
304
+ const added = [...next.keys()].filter((rel) => !(rel in manifest.files));
305
+ const updated = [...next.keys()].filter(
306
+ (rel) =>
307
+ rel in manifest.files &&
308
+ (sha256(next.get(rel).content) !== manifest.files[rel] || modified.includes(rel) || missing.includes(rel)),
309
+ );
310
+ const removed = Object.keys(manifest.files).filter((rel) => !next.has(rel));
311
+
312
+ log(`${PKG.name}: ${manifest.version} → ${PKG.version}`);
313
+ log(` nuevos: ${added.length} · actualizados: ${updated.length} · eliminados: ${removed.length}`);
314
+ for (const rel of added) log(` + ${rel}`);
315
+ for (const rel of updated) log(` ~ ${rel}`);
316
+ for (const rel of removed) log(` - ${rel}`);
317
+ if (opts.diff) {
318
+ for (const rel of [...added, ...updated]) showDiff(root, rel, next.get(rel).content);
319
+ for (const rel of removed) showDiff(root, rel, null);
320
+ }
321
+ const incoming = parseNovelties(next.get(NOVELTIES)?.content.toString('utf8')).filter(
322
+ (n) => compareVersions(n.since, manifest.version) > 0,
323
+ );
324
+ if (incoming.length) {
325
+ log(`\nNovedades desde ${manifest.version} (no se activan solas; detalle y pasos: arness doctor):`);
326
+ for (const n of incoming) log(` · [${n.type}] ${n.title} (${n.id}, desde ${n.since})`);
327
+ }
328
+ if (opts.dryRun) {
329
+ log('\n(--dry-run: no se escribió nada. Usa --diff para ver los cambios línea a línea)');
330
+ return;
331
+ }
332
+
333
+ // Solo se respalda si hay archivos que escribir; los enlaces y el manifiesto se reparan siempre.
334
+ const changes = added.length + updated.length + removed.length > 0 || manifest.version !== PKG.version;
335
+ const backup = changes ? backupBase(root, manifest) : null;
336
+ for (const rel of [...added, ...updated]) writeFile(root, rel, next.get(rel));
337
+ for (const rel of removed) fs.rmSync(path.join(root, rel), { force: true });
338
+ ensureDecisionSkeleton(root);
339
+ const links = createLinks(root, manifest.links ?? {}, { forceCopy: opts.copy });
340
+ writeManifest(root, next, links);
341
+ log('\n✔ Harness sincronizado. Solo se escribieron archivos de la base (harness/ y puentes);');
342
+ log(' el código del proyecto, harness/decisiones/ y harness/progress.md no se tocaron.');
343
+ if (backup) log(` Respaldo de la versión anterior: ${backup} (para volver: arness rollback)`);
344
+ log(' Revisa la sección "Para proyectos existentes" del CHANGELOG y ejecuta: arness doctor');
345
+ }
346
+
347
+ function cmdRollback(opts) {
348
+ const root = findRoot();
349
+ const current = readManifest(root);
350
+ const backups = listBackups(root);
351
+ if (!backups.length) throw new CliError('No hay respaldos en harness/.arness-backup/: nada que restaurar.');
352
+ const name = backups.at(-1);
353
+ const dir = path.join(root, BACKUP_DIR, name);
354
+ const previous = readManifest(dir);
355
+
356
+ const { modified } = integrity(root, current);
357
+ if (modified.length && !opts.force) {
358
+ throw new CliError(
359
+ `Archivos de la base editados a mano desde el último sync:\n${list(modified)}\n` +
360
+ 'rollback los sobrescribiría. Para continuar de todos modos: arness rollback --force',
361
+ );
362
+ }
363
+
364
+ const restored = Object.keys(previous.files);
365
+ const removed = Object.keys(current.files).filter((rel) => !(rel in previous.files));
366
+ log(`${PKG.name}: ${current.version} → ${previous.version} (respaldo ${name})`);
367
+ log(` restaurados: ${restored.length} · eliminados: ${removed.length}`);
368
+ for (const rel of removed) log(` - ${rel}`);
369
+ if (opts.dryRun) {
370
+ log('\n(--dry-run: no se escribió nada)');
371
+ return;
372
+ }
373
+
374
+ for (const rel of removed) fs.rmSync(path.join(root, rel), { force: true });
375
+ for (const rel of [...restored, MANIFEST]) {
376
+ const src = path.join(dir, rel);
377
+ if (!fs.existsSync(src)) continue;
378
+ writeFile(root, rel, { content: fs.readFileSync(src), mode: fs.statSync(src).mode & 0o777 });
379
+ }
380
+ const wantsCopy = Object.values(previous.links).includes('copy');
381
+ createLinks(root, current.links, { forceCopy: wantsCopy });
382
+ fs.rmSync(dir, { recursive: true, force: true });
383
+ log(`\n✔ Base restaurada a ${previous.version}. El código del proyecto y harness/decisiones/ no se tocaron.`);
384
+ log(' Recuerda volver a fijar ese tag en la devDependency si la cambiaste.');
385
+ }
386
+
387
+ // Dónde mirar para resolver cada categoría de hallazgo de init.sh.
388
+ const DOCTOR_HINTS = {
389
+ config: 'Revisa harness/arness.config.json (JSON válido; cada ignore lleva motivo).',
390
+ harness: 'Restaura la base con arness sync (o arness sync --force si se editó a mano).',
391
+ decisions: 'Sigue harness/decisiones/README.md.',
392
+ progress: 'El líder lo reinicia desde harness/reference/progress-plantilla.md.',
393
+ git: 'Usa un repo git propio; si hay secretos versionados, sácalos del índice (git rm --cached) y rota las credenciales.',
394
+ package: 'Repo fuente del paquete: revisa bin/, CHANGELOG.md y docs/adr/.',
395
+ install: 'Instala o actualiza el paquete (README de @bi-analytics/arness-back) y ejecuta arness sync.',
396
+ toolchain: 'Skill nest-base §1 y §4: .nvmrc, packageManager, lockfile y scripts.',
397
+ structure: 'AGENTS.md §4 y skill nest-base §4–§5. Adóptalo cuando convenga; no bloquea.',
398
+ sql: 'AGENTS.md §6: SQL solo en *.sql.ts y sin escrituras.',
399
+ docs: 'AGENTS.md §12: copia harness/skills/nest-base/templates/docs/ (EVOLUTIVO.md solo avance; PENDIENTES.md lo abierto).',
400
+ env: 'harness/reference/configuracion.md (Dónde se leen las variables): process.env solo en src/config/configuration.ts.',
401
+ logging: 'harness/reference/contrato-http.md (Logging): LOG_FORMAT=json|pretty y pino-pretty solo en devDependencies.',
402
+ quality: 'pnpm install, pnpm lint, pnpm build, pnpm test.',
403
+ health: 'harness/reference/contrato-http.md (Health) y la conexión a cada BD.',
404
+ };
405
+
406
+ // Catálogo de novedades adoptables (parte de la base): lo leen doctor y sync.
407
+ const NOVELTIES = `${H}/reference/novedades.json`;
408
+ const NOVELTY_TYPES = { seguridad: 'Seguridad', recomendada: 'Recomendadas', opcional: 'Opcionales' };
409
+
410
+ /** Compara versiones x.y.z: negativo si a < b, 0 si son iguales, positivo si a > b. */
411
+ function compareVersions(a, b) {
412
+ const pa = String(a).split('.').map(Number);
413
+ const pb = String(b).split('.').map(Number);
414
+ for (let i = 0; i < 3; i++) {
415
+ const d = (pa[i] || 0) - (pb[i] || 0);
416
+ if (d) return d;
417
+ }
418
+ return 0;
419
+ }
420
+
421
+ /** Novedades de un catálogo (texto JSON); [] si no existe (bases anteriores a 0.2.0). */
422
+ function parseNovelties(text) {
423
+ if (!text) return [];
424
+ const catalog = JSON.parse(text);
425
+ return Array.isArray(catalog.novedades) ? catalog.novedades : [];
426
+ }
427
+
428
+ function readRepoConfig(root) {
429
+ const abs = path.join(root, CONFIG);
430
+ if (!fs.existsSync(abs)) return {};
431
+ try {
432
+ return JSON.parse(fs.readFileSync(abs, 'utf8'));
433
+ } catch {
434
+ return {}; // init.sh ya reporta el JSON inválido (config.invalid)
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Clasifica cada novedad: adoptada (existen sus paths y init.sh no reporta sus checks), declinada
440
+ * (harness/arness.config.json → declined, con motivo) o pendiente, con los hallazgos que la delatan.
441
+ */
442
+ function classifyNovelties(root, novelties, issues, declined) {
443
+ const out = { pending: [], adopted: [], declined: [], configErrors: [] };
444
+ for (const n of novelties) {
445
+ const reason = String(declined[n.id] ?? '').trim();
446
+ if (n.id in declined && !reason) out.configErrors.push(`declined.${n.id} sin motivo`);
447
+ if (reason) {
448
+ out.declined.push({ ...n, reason });
449
+ continue;
450
+ }
451
+ const findings = issues.filter((i) => (n.checks ?? []).includes(i.id));
452
+ const missingPaths = (n.paths ?? []).filter((p) => !fs.existsSync(path.join(root, p)));
453
+ if (!findings.length && !missingPaths.length) out.adopted.push(n);
454
+ else out.pending.push({ ...n, findings: [...missingPaths.map((p) => `No existe ${p}`), ...findings.map((i) => i.message)] });
455
+ }
456
+ return out;
457
+ }
458
+
459
+ function cmdDoctor(opts) {
460
+ const root = findRoot();
461
+ const manifest = readManifest(root);
462
+ const { modified, missing } = integrity(root, manifest);
463
+ const args = ['--json', ...(opts.full ? [] : ['--quick', '--no-health'])];
464
+ const res = spawnSync('bash', [path.join(root, H, 'init.sh'), ...args], { cwd: root, encoding: 'utf8' });
465
+ let report;
466
+ try {
467
+ report = JSON.parse(res.stdout);
468
+ } catch {
469
+ throw new CliError(`init.sh no devolvió JSON válido (¿versión anterior de la base?).\n${res.stderr.slice(-2000)}`);
470
+ }
471
+
472
+ const catalogPath = path.join(root, NOVELTIES);
473
+ let novelties = parseNovelties(fs.existsSync(catalogPath) ? fs.readFileSync(catalogPath, 'utf8') : null);
474
+ if (opts.since) novelties = novelties.filter((n) => compareVersions(n.since, opts.since) > 0);
475
+ const declined = readRepoConfig(root).declined ?? {};
476
+ const nov = classifyNovelties(root, novelties, report.issues, typeof declined === 'object' ? declined : {});
477
+
478
+ // Los avisos de una novedad se muestran con ella; los fallos y críticos siguen en su sección.
479
+ const noveltyChecks = new Set(novelties.flatMap((n) => n.checks ?? []));
480
+ const others = report.issues.filter((i) => !(noveltyChecks.has(i.id) && (i.level === 'warning' || i.level === 'ignored')));
481
+ const newer = compareVersions(PKG.version, manifest.version) > 0;
482
+
483
+ if (opts.json) {
484
+ const pick = ({ id, type, since, title, why, steps, approval, adr, reference }) => ({ id, type, since, title, why, steps, approval, adr, reference });
485
+ process.stdout.write(
486
+ `${JSON.stringify(
487
+ {
488
+ schema: 1,
489
+ installed: manifest.version,
490
+ available: PKG.version,
491
+ newerAvailable: newer,
492
+ mode: report.mode,
493
+ base: { modified, missing },
494
+ novelties: {
495
+ pending: nov.pending.map((n) => ({ ...pick(n), findings: n.findings })),
496
+ adopted: nov.adopted.map((n) => n.id),
497
+ declined: nov.declined.map((n) => ({ id: n.id, reason: n.reason })),
498
+ },
499
+ configErrors: nov.configErrors,
500
+ issues: others,
501
+ summary: report.summary,
502
+ },
503
+ null,
504
+ 2,
505
+ )}\n`,
506
+ );
507
+ if (report.summary.critical) process.exitCode = 1;
508
+ return;
509
+ }
510
+
511
+ log(`Harness ${manifest.package}@${manifest.version} · paquete disponible ${PKG.version}`);
512
+ if (newer) log(` ⚠ Hay una versión más nueva del paquete: ejecuta arness sync (y después arness doctor)`);
513
+ if (modified.length || missing.length) log(` ⚠ Base distinta al manifiesto: ${modified.length} editados, ${missing.length} faltantes`);
514
+ log(`Revisión ${opts.full ? 'completa' : 'rápida (usa --full para lint, build, tests y health)'} · modo ${report.mode}`);
515
+ for (const e of nov.configErrors) log(` ⚠ ${CONFIG}: ${e} (se ignora)`);
516
+
517
+ if (nov.pending.length) {
518
+ log(`\n◆ Novedades por adoptar${opts.since ? ` (desde ${opts.since})` : ''}: ${nov.pending.length}`);
519
+ log(' No se activan solas: adóptalas cuando convenga, o declínalas con motivo en harness/arness.config.json → declined.');
520
+ for (const [type, label] of Object.entries(NOVELTY_TYPES)) {
521
+ const group = nov.pending.filter((n) => n.type === type);
522
+ if (!group.length) continue;
523
+ log(`\n ${label}`);
524
+ for (const n of group) {
525
+ log(` ● ${n.title} [${n.id}] · desde ${n.since}${n.adr ? ` · ADR ${n.adr}` : ''}`);
526
+ log(` Por qué: ${n.why}`);
527
+ for (const f of n.findings) log(` - ${f}`);
528
+ log(' Pasos:');
529
+ n.steps.forEach((step, i) => log(` ${i + 1}. ${step}`));
530
+ if (n.approval) log(` Requiere aprobación: ${n.approval}`);
531
+ if (n.reference) log(` Referencia: ${n.reference}`);
532
+ }
533
+ }
534
+ }
535
+ if (nov.adopted.length) log(`\n✔ Novedades adoptadas: ${nov.adopted.map((n) => n.id).join(', ')}`);
536
+ if (nov.declined.length) log(`· Novedades declinadas: ${nov.declined.map((n) => `${n.id} (${n.reason})`).join('; ')}`);
537
+
538
+ const levels = [
539
+ ['critical', '✖ Críticos (bloquean)'],
540
+ ['failure', '✖ Obligatorios (strict o required)'],
541
+ ['warning', '⚠ Advertencias'],
542
+ ];
543
+ for (const [level, title] of levels) {
544
+ const items = others.filter((i) => i.level === level);
545
+ if (!items.length) continue;
546
+ log(`\n${title}: ${items.length}`);
547
+ const byCat = new Map();
548
+ for (const i of items) {
549
+ const cat = (i.id ?? 'otros').split('.')[0];
550
+ byCat.set(cat, [...(byCat.get(cat) ?? []), i]);
551
+ }
552
+ for (const [cat, group] of byCat) {
553
+ log(` ${cat}${DOCTOR_HINTS[cat] ? ` — ${DOCTOR_HINTS[cat]}` : ''}`);
554
+ for (const i of group) log(` - ${i.message}${i.id ? ` [${i.id}]` : ''}`);
555
+ }
556
+ }
557
+ if (report.summary.ignored) log(`\n· ${report.summary.ignored} chequeo(s) silenciado(s) en ${CONFIG}`);
558
+ if (!nov.pending.length && !others.some((i) => i.level !== 'ignored')) log('\n✔ Sin hallazgos: el proyecto cumple la versión instalada.');
559
+ log('\nNo se modificó ningún archivo.');
560
+ if (report.summary.critical) process.exitCode = 1;
561
+ }
562
+
563
+ function cmdStatus(opts) {
564
+ const root = findRoot();
565
+ const manifest = readManifest(root);
566
+ const { modified, missing } = integrity(root, manifest);
567
+ const outdated = manifest.version !== PKG.version;
568
+
569
+ log(`Instalado: ${manifest.package}@${manifest.version} · Paquete disponible: ${PKG.version}`);
570
+ if (outdated) log('⚠ Versión distinta a la del paquete: ejecuta arness sync');
571
+ if (modified.length) log(`✖ Base editada a mano:\n${list(modified)}`);
572
+ if (missing.length) log(`✖ Archivos de la base faltantes:\n${list(missing)}`);
573
+ if (!modified.length && !missing.length) log('✔ La base coincide con el manifiesto');
574
+ if (opts.check && (modified.length || missing.length)) process.exitCode = 1;
575
+ }
576
+
577
+ function cmdVerify(args) {
578
+ const root = findRoot();
579
+ const res = spawnSync('bash', [path.join(root, H, 'init.sh'), ...args], { stdio: 'inherit' });
580
+ process.exitCode = res.status ?? 1;
581
+ }
582
+
583
+ function slugify(text) {
584
+ return text
585
+ .normalize('NFD')
586
+ .replace(/[̀-ͯ]/g, '')
587
+ .toLowerCase()
588
+ .replace(/[^a-z0-9]+/g, '-')
589
+ .replace(/^-+|-+$/g, '');
590
+ }
591
+
592
+ function cmdDecisionNew(title) {
593
+ if (!title) throw new CliError('Uso: arness decision new "<título>"');
594
+ const root = findRoot();
595
+ const dir = path.join(root, H, 'decisiones');
596
+ ensureDecisionSkeleton(root);
597
+
598
+ const numbers = fs
599
+ .readdirSync(dir)
600
+ .map((f) => /^(\d{4})-/.exec(f)?.[1])
601
+ .filter(Boolean)
602
+ .map(Number);
603
+ const num = String((numbers.length ? Math.max(...numbers) : 0) + 1).padStart(4, '0');
604
+ const file = `${num}-${slugify(title)}.md`;
605
+ const today = new Date().toISOString().slice(0, 10);
606
+
607
+ const body = fs
608
+ .readFileSync(path.join(dir, '_plantilla.md'), 'utf8')
609
+ .replace(/^# NNNN\. .*$/m, `# ${num}. ${title}`)
610
+ .replace(/^- \*\*Estado:\*\* .*$/m, '- **Estado:** Propuesta')
611
+ .replace(/^- \*\*Fecha:\*\* .*$/m, `- **Fecha:** ${today}`);
612
+ fs.writeFileSync(path.join(dir, file), body);
613
+
614
+ const readme = path.join(dir, 'README.md');
615
+ const current = fs.readFileSync(readme, 'utf8');
616
+ const sep = current.endsWith('\n') ? '' : '\n';
617
+ fs.appendFileSync(readme, `${sep}| [${num}](${file}) | ${title} | Propuesta | <alcance> | ${today} |\n`);
618
+
619
+ log(`✔ Creada ${H}/decisiones/${file} (Propuesta) y agregada al índice.`);
620
+ log(' Completa contexto, decisión y alcance; un humano la pasa a Aceptada.');
621
+ }
622
+
623
+ // ── entrada ──────────────────────────────────────────────────────────────────
624
+
625
+ const list = (items) => items.map((i) => ` - ${i}`).join('\n');
626
+
627
+ const HELP = `arness — harness de agentes IA para backends NestJS (${PKG.name}@${PKG.version})
628
+
629
+ Uso:
630
+ arness init [--dir <ruta>] [--force] [--copy] Instala el harness en el repo
631
+ arness sync [--dry-run] [--diff] [--force] [--copy]
632
+ Actualiza la base (respalda la versión anterior)
633
+ arness rollback [--dry-run] [--force] Restaura la base desde el último respaldo
634
+ arness doctor [--full] [--json] [--since <x.y.z>]
635
+ Novedades por adoptar y hallazgos (solo lectura)
636
+ arness status [--check] Versión instalada e integridad de la base
637
+ arness verify [opciones de init.sh] Ejecuta ./harness/init.sh
638
+ arness decision new "<título>" Crea una decisión en harness/decisiones/
639
+ arness --version | --help
640
+
641
+ Para no adoptar una novedad: harness/arness.config.json → "declined": { "<id>": "<motivo>" }.
642
+ harness/decisiones/, harness/progress.md y harness/arness.config.json pertenecen al repo:
643
+ sync y rollback nunca los modifican, ni tocan el código del proyecto.
644
+ --copy crea copias en lugar de enlaces simbólicos (útil en Windows sin symlinks).`;
645
+
646
+ function parseFlags(argv) {
647
+ const opts = { _: [] };
648
+ for (let i = 0; i < argv.length; i++) {
649
+ const a = argv[i];
650
+ if (a === '--dir') opts.dir = argv[++i];
651
+ else if (a === '--force') opts.force = true;
652
+ else if (a === '--copy') opts.copy = true;
653
+ else if (a === '--dry-run') opts.dryRun = true;
654
+ else if (a === '--check') opts.check = true;
655
+ else if (a === '--diff') opts.diff = true;
656
+ else if (a === '--full') opts.full = true;
657
+ else if (a === '--json') opts.json = true;
658
+ else if (a === '--since') {
659
+ opts.since = argv[++i];
660
+ if (!/^\d+\.\d+\.\d+$/.test(opts.since ?? '')) throw new CliError('Uso: --since <x.y.z>');
661
+ }
662
+ else if (a.startsWith('--')) throw new CliError(`Opción desconocida: ${a}`);
663
+ else opts._.push(a);
664
+ }
665
+ return opts;
666
+ }
667
+
668
+ function main(argv) {
669
+ const [cmd, ...rest] = argv;
670
+ switch (cmd) {
671
+ case 'init':
672
+ return cmdInit(parseFlags(rest));
673
+ case 'sync':
674
+ return cmdSync(parseFlags(rest));
675
+ case 'rollback':
676
+ return cmdRollback(parseFlags(rest));
677
+ case 'doctor':
678
+ return cmdDoctor(parseFlags(rest));
679
+ case 'status':
680
+ return cmdStatus(parseFlags(rest));
681
+ case 'verify':
682
+ return cmdVerify(rest);
683
+ case 'decision': {
684
+ const [sub, ...words] = rest;
685
+ if (sub !== 'new') throw new CliError('Uso: arness decision new "<título>"');
686
+ return cmdDecisionNew(words.join(' ').trim());
687
+ }
688
+ case '--version':
689
+ case '-v':
690
+ return log(PKG.version);
691
+ case undefined:
692
+ case '--help':
693
+ case '-h':
694
+ case 'help':
695
+ return log(HELP);
696
+ default:
697
+ throw new CliError(`Comando desconocido: ${cmd}\n\n${HELP}`);
698
+ }
699
+ }
700
+
701
+ try {
702
+ main(process.argv.slice(2));
703
+ } catch (err) {
704
+ if (err instanceof CliError) {
705
+ process.stderr.write(`✖ ${err.message}\n`);
706
+ process.exitCode = 1;
707
+ } else {
708
+ throw err;
709
+ }
710
+ }