siesa-agents 2.1.83 → 2.1.84

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.
@@ -0,0 +1,79 @@
1
+ // folder-mappings.js — Single source of truth for the source→target mapping used by the
2
+ // installer (install.js) and the publish helpers (prepare-publish.js / restore-folders.js).
3
+ //
4
+ // Background — the dual-mirror pattern this repo uses:
5
+ // Every folder that ships with the npm package exists twice in the dev tree:
6
+ // - The "dotted/underscored" version used by the dev workflow:
7
+ // `.claude/`, `.github/`, `.gemini/`, `.resources/`, `.mcp.json`, `.vscode/`,
8
+ // `_bmad/`, `_siesa-agents/`
9
+ // - The "flat" version that is what npm publish actually packages and ships:
10
+ // `claude/`, `github/`, `gemini/`, `resources/`, `mcp.json`, `vscode/`,
11
+ // `bmad/`, `siesa-agents/`
12
+ // When you edit anything under `.claude/skills/`, you must mirror the change into
13
+ // `claude/skills/`. The installer reads from the flat (tarball) names and writes to the
14
+ // dotted (user project) names.
15
+ //
16
+ // Why both? npm's `files` array historically had quirks with dot-prefixed entries
17
+ // (`.github/` would sometimes ship, sometimes not, depending on npm version and registry
18
+ // rules). Keeping a flat copy guarantees the file ships, while keeping the dotted copy
19
+ // keeps the dev workflow honest (`.claude/` is what Claude Code reads, `.github/` is what
20
+ // GitHub Actions reads).
21
+ //
22
+ // Three views of the same data:
23
+ // - installerMappings() used by bin/install.js: { source = tarball name,
24
+ // target = user project name }
25
+ // - publishRenames() used by bin/prepare-publish.js. Currently empty — see comment
26
+ // on the FOLDERS array.
27
+ // - restoreRenames() used by bin/restore-folders.js. Currently empty — see comment.
28
+ //
29
+ // Add new folders here and the rest of the pipeline picks them up. If a new folder needs
30
+ // pre-publish renaming (only when there's no flat mirror in dev), add a `devSource`.
31
+
32
+ 'use strict';
33
+
34
+ // Each entry describes one shipping unit:
35
+ // - tarball: name inside the published npm tarball (no leading `.` or `_`)
36
+ // - userProject: name in the user's project after `npx siesa-agents`
37
+ // - devSource: optional — pre-publish rename source. Leave it `null` when the dev tree
38
+ // already maintains both the dotted AND the flat copy (current strategy
39
+ // for every folder shipped today). Only set it if you want the publish
40
+ // pipeline to physically rename the dev folder before `npm publish`.
41
+ const FOLDERS = [
42
+ { tarball: 'bmad', userProject: '_bmad', devSource: null },
43
+ { tarball: 'siesa-agents', userProject: '_siesa-agents', devSource: null },
44
+ { tarball: 'vscode', userProject: '.vscode', devSource: null },
45
+ { tarball: 'github', userProject: '.github', devSource: null },
46
+ { tarball: 'claude', userProject: '.claude', devSource: null },
47
+ { tarball: 'gemini', userProject: '.gemini', devSource: null },
48
+ { tarball: 'resources', userProject: '.resources', devSource: null },
49
+ { tarball: 'mcp.json', userProject: '.mcp.json', devSource: null },
50
+ ];
51
+
52
+ // Shape expected by install.js: { source, target } where source is the tarball name and
53
+ // target is what to call it in the user's project.
54
+ function installerMappings() {
55
+ return FOLDERS.map(f => ({ source: f.tarball, target: f.userProject }));
56
+ }
57
+
58
+ // Shape expected by prepare-publish.js: { from, to } where from is the dev name and to is
59
+ // the tarball name. Empty when the dev tree already maintains dual-mirror copies.
60
+ function publishRenames() {
61
+ return FOLDERS
62
+ .filter(f => f.devSource && f.devSource !== f.tarball)
63
+ .map(f => ({ from: f.devSource, to: f.tarball }));
64
+ }
65
+
66
+ // Inverse of publishRenames: restore tarball names back to dev names. Empty in the
67
+ // dual-mirror strategy.
68
+ function restoreRenames() {
69
+ return FOLDERS
70
+ .filter(f => f.devSource && f.devSource !== f.tarball)
71
+ .map(f => ({ from: f.tarball, to: f.devSource }));
72
+ }
73
+
74
+ module.exports = {
75
+ FOLDERS,
76
+ installerMappings,
77
+ publishRenames,
78
+ restoreRenames,
79
+ };
package/bin/install.js CHANGED
@@ -2,27 +2,91 @@
2
2
 
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
- const { sourceMapsEnabled } = require('process');
6
- const readline = require('readline');
7
5
  const { execSync } = require('child_process');
6
+ const { installerMappings } = require('./folder-mappings');
7
+
8
+ // @clack/prompts es ESM-only, así que desde este install.js CJS lo cargamos con
9
+ // dynamic `import()`. Cachéamos la promesa para que la primera carga pague el
10
+ // costo de resolución y las siguientes sean instantáneas.
11
+ let _clackPromise = null;
12
+ function loadClack() {
13
+ if (!_clackPromise) {
14
+ _clackPromise = import('@clack/prompts');
15
+ }
16
+ return _clackPromise;
17
+ }
18
+
19
+ // En Windows oculta/des-oculta una carpeta o archivo (atributo +h/-h). Sirve para
20
+ // que las carpetas de staging `.sa-new`/`.sa-old` no aparezcan en el explorador
21
+ // durante el breve lapso en que existen — verlas crearse y borrarse se ve raro.
22
+ // Best-effort: si `attrib` falla no pasa nada (es puramente cosmético). No-op
23
+ // fuera de Windows (en Unix el prefijo `.` ya las oculta en la mayoría de FMs).
24
+ function setHidden(targetPath, hidden) {
25
+ if (process.platform !== 'win32') return;
26
+ try {
27
+ execSync(`attrib ${hidden ? '+h' : '-h'} "${targetPath}"`, { stdio: 'ignore' });
28
+ } catch (_) { /* cosmético: ignorar */ }
29
+ }
30
+
31
+ // Maneja la cancelación de un prompt clack (Ctrl+C / Esc). Sale limpio con
32
+ // código 0 — no es un error, el usuario explícitamente abortó.
33
+ async function bailIfCancel(value) {
34
+ const clack = await loadClack();
35
+ if (clack.isCancel(value)) {
36
+ clack.cancel('Instalación cancelada por el usuario.');
37
+ process.exit(0);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ // Parse argumentos CLI básicos. Soporta:
43
+ // --yes / -y Modo no-interactivo: usa defaults sensatos en cualquier prompt
44
+ // en vez de leer de stdin. Defaults: "hacer backup de modificados"
45
+ // (opción 2) y "no hay repo remoto" (opción 2). También se activa
46
+ // automáticamente cuando stdin no es un TTY (CI, pipes).
47
+ // --version Imprime la versión del paquete y sale.
48
+ // --help / -h Imprime ayuda breve y sale.
49
+ function parseCliArgs(argv) {
50
+ const out = { yes: false, version: false, help: false };
51
+ for (const a of argv) {
52
+ if (a === '--yes' || a === '-y') out.yes = true;
53
+ else if (a === '--version') out.version = true;
54
+ else if (a === '--help' || a === '-h') out.help = true;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // Deep-merge de dos objetos JSON. Source gana en conflictos; los arrays se
60
+ // reemplazan completos (no se hace merge elemento-a-elemento). Pensado para
61
+ // `.mcp.json`: los MCPs que el usuario agregó (no existen en el paquete) sobreviven
62
+ // al update mientras los nativos se actualizan a la versión del paquete.
63
+ function deepMergeJson(target, source) {
64
+ if (source === null || typeof source !== 'object') return source;
65
+ if (target === null || typeof target !== 'object') return source;
66
+ if (Array.isArray(source) || Array.isArray(target)) return source;
67
+ const out = { ...target };
68
+ for (const key of Object.keys(source)) {
69
+ out[key] = deepMergeJson(target[key], source[key]);
70
+ }
71
+ return out;
72
+ }
8
73
 
9
74
  class SiesaBmadInstaller {
10
- constructor() {
11
- // Definir las carpetas primero (nombres en el paquete vs nombres finales)
12
- this.folderMappings = [
13
- { source: 'bmad', target: '_bmad' },
14
- { source: 'siesa-agents', target: '_siesa-agents' },
15
- { source: 'vscode', target: '.vscode' },
16
- { source: 'github', target: '.github' },
17
- { source: 'claude', target: '.claude' },
18
- { source: 'gemini', target: '.gemini' },
19
- { source: 'resources', target: '.resources' },
20
- { source: 'mcp.json', target: '.mcp.json' }
21
- ];
22
-
23
- // Lista de archivos que se preservan automáticamente (no se crean backups)
75
+ constructor(cliArgs = {}) {
76
+ // Definir las carpetas primero (nombres en el paquete vs nombres finales).
77
+ // Source of truth: bin/folder-mappings.js
78
+ this.folderMappings = installerMappings();
79
+
80
+ // Archivos que el instalador PRESERVA: no se sobrescriben si ya existen, no se
81
+ // reportan como "modificados" y no se les hace backup. El usuario los personaliza
82
+ // localmente y son suyos.
83
+ // - data/technical-preferences.md preferencias del usuario.
84
+ // - settings.json `.vscode/settings.json` (la única carpeta del paquete con
85
+ // un settings.json en su raíz es `.vscode`, así que el match es inequívoco).
86
+ // El usuario ajusta su editor; el instalador no debe pisarlo ni molestar.
24
87
  this.ignoredFiles = [
25
- 'data/technical-preferences.md'
88
+ 'data/technical-preferences.md',
89
+ 'settings.json'
26
90
  ];
27
91
 
28
92
  // Directorios base que se deben crear siempre durante la instalación
@@ -33,9 +97,14 @@ class SiesaBmadInstaller {
33
97
  this.targetDir = process.cwd();
34
98
  // Intentar múltiples ubicaciones posibles para el paquete
35
99
  this.packageDir = this.findPackageDir();
36
-
100
+
37
101
  // Almacenamiento temporal para contenido de archivos ignorados
38
102
  this.preservedContent = new Map();
103
+
104
+ // Flag no-interactivo: opt-in via --yes/-y o auto-detectado cuando stdin no es TTY
105
+ // (típicamente CI, npx detrás de pipes, scripts automatizados). Si está activo, los
106
+ // prompts usan defaults en vez de bloquearse esperando input.
107
+ this.nonInteractive = Boolean(cliArgs.yes) || !process.stdin.isTTY;
39
108
  }
40
109
 
41
110
 
@@ -121,27 +190,39 @@ class SiesaBmadInstaller {
121
190
  }
122
191
 
123
192
  async promptHasRepository() {
124
- console.log('\n¿Tienes un repositorio remoto?');
125
- console.log('1. ');
126
- console.log('2. No, parametrizar manual después');
127
-
128
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
129
- return new Promise((resolve) => {
130
- rl.question('\nElige una opción (1 o 2): ', (answer) => {
131
- rl.close();
132
- resolve(answer.trim());
133
- });
193
+ if (this.nonInteractive) {
194
+ console.log('\n (modo no-interactivo: sin repositorio remoto)');
195
+ return '2';
196
+ }
197
+
198
+ const clack = await loadClack();
199
+ const answer = await clack.select({
200
+ message: '¿Tienes un repositorio remoto que quieras vincular ahora?',
201
+ options: [
202
+ { value: '2', label: 'No, lo configuraré después', hint: 'recomendado si todavía no creaste el repo' },
203
+ { value: '1', label: 'Sí, tengo URL del repo remoto' },
204
+ ],
205
+ initialValue: '2',
134
206
  });
207
+ return bailIfCancel(answer);
135
208
  }
136
209
 
137
210
  async promptRepositoryUrl() {
138
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
139
- return new Promise((resolve) => {
140
- rl.question('Ingresa la URL del repositorio: ', (answer) => {
141
- rl.close();
142
- resolve(answer.trim());
143
- });
211
+ if (this.nonInteractive) {
212
+ // En modo no-interactivo nunca deberíamos llegar aquí (promptHasRepository
213
+ // devuelve '2'), pero por defensa devolvemos cadena vacía.
214
+ return '';
215
+ }
216
+ const clack = await loadClack();
217
+ const answer = await clack.text({
218
+ message: 'URL del repositorio',
219
+ placeholder: 'https://github.com/<org>/<repo>.git',
220
+ validate: (value) => {
221
+ if (!value || value.trim() === '') return 'La URL no puede estar vacía';
222
+ },
144
223
  });
224
+ const checked = await bailIfCancel(answer);
225
+ return checked.trim();
145
226
  }
146
227
 
147
228
  async installGitignore() {
@@ -149,7 +230,9 @@ class SiesaBmadInstaller {
149
230
  const entries = [
150
231
  'node_modules/',
151
232
  '.claude/commands/get-features/oauth-config.json',
152
- '.claude/commands/get-features/tokens.json'
233
+ '.claude/commands/get-features/tokens.json',
234
+ '.vscode/settings.json',
235
+ '.claude.json'
153
236
  ];
154
237
 
155
238
  let content = entries.join('\n') + '\n';
@@ -268,6 +351,13 @@ class SiesaBmadInstaller {
268
351
  const modifiedFiles = [];
269
352
 
270
353
  for (const mapping of this.folderMappings) {
354
+ // .mcp.json no entra en la detección de modificaciones: se gestiona con
355
+ // deep-merge JSON en performAtomicUpdate / copyWithBackupPreservation, que
356
+ // preserva los MCPs personalizados del usuario y actualiza los nativos.
357
+ // Marcarlo como "modificado" forzaría el flujo backup-y-reemplaza y
358
+ // destruiría los MCPs custom.
359
+ if (mapping.target === '.mcp.json') continue;
360
+
271
361
  const sourcePath = path.join(this.packageDir, mapping.source);
272
362
  const targetPath = path.join(this.targetDir, mapping.target);
273
363
 
@@ -284,9 +374,13 @@ class SiesaBmadInstaller {
284
374
 
285
375
  if (fs.existsSync(targetFile)) {
286
376
  try {
287
- // Comparar contenido de archivos
288
- const sourceContent = await fs.readFile(sourceFile, 'utf8');
289
- const targetContent = await fs.readFile(targetFile, 'utf8');
377
+ // Comparar contenido normalizando CRLF→LF. Sin esta normalización, en Windows
378
+ // con `core.autocrlf=true` cualquier archivo de texto cuya copia local fue
379
+ // checkout con CRLF aparece como "modificado" frente al paquete (que tiene LF),
380
+ // y el instalador pide backup en cada corrida aunque el usuario no haya tocado
381
+ // nada.
382
+ const sourceContent = (await fs.readFile(sourceFile, 'utf8')).replace(/\r\n/g, '\n');
383
+ const targetContent = (await fs.readFile(targetFile, 'utf8')).replace(/\r\n/g, '\n');
290
384
 
291
385
  if (sourceContent !== targetContent) {
292
386
  modifiedFiles.push({
@@ -297,7 +391,8 @@ class SiesaBmadInstaller {
297
391
  });
298
392
  }
299
393
  } catch (error) {
300
- // Si no se puede leer como texto, comparar como buffer (archivos binarios)
394
+ // Si no se puede leer como texto, comparar como buffer (archivos binarios).
395
+ // Para binarios la normalización CRLF no aplica.
301
396
  const sourceBuffer = await fs.readFile(sourceFile);
302
397
  const targetBuffer = await fs.readFile(targetFile);
303
398
 
@@ -341,22 +436,26 @@ class SiesaBmadInstaller {
341
436
  }
342
437
 
343
438
  async promptUser(modifiedFiles) {
439
+ // modifiedFiles solo contiene archivos QUE EXISTEN EN EL PAQUETE y cuyo
440
+ // contenido difiere del target. Es decir: archivos del paquete (skills nativas,
441
+ // configs nativas, etc.) que el usuario modificó localmente. Los archivos que
442
+ // el usuario AGREGÓ y no están en el paquete (skills custom, flows propios,
443
+ // MCPs adicionales) NO aparecen aquí — se preservan automáticamente en
444
+ // performAtomicUpdate / copyWithBackupPreservation sin pedir permiso.
445
+ const nonIgnoredFiles = modifiedFiles.filter(f => !f.is_ignored);
446
+ if (nonIgnoredFiles.length === 0) return '2';
344
447
 
345
- const hasNonIgnoredFiles = modifiedFiles.some(file => file.is_ignored == false)
346
- if (!hasNonIgnoredFiles) return '2'
347
-
348
- console.log('\n⚠️ Se detectaron archivos modificados:');
448
+ console.log(`\n⚠️ Detecté ${nonIgnoredFiles.length} archivo(s) del paquete Siesa-Agents que modificaste localmente:`);
349
449
 
350
450
  // Agrupar por carpeta
351
451
  const filesByFolder = {};
352
- modifiedFiles.forEach(item => {
452
+ nonIgnoredFiles.forEach(item => {
353
453
  if (!filesByFolder[item.folder]) {
354
454
  filesByFolder[item.folder] = [];
355
455
  }
356
456
  filesByFolder[item.folder].push(item.file);
357
457
  });
358
458
 
359
- // Mostrar archivos modificados por carpeta
360
459
  Object.keys(filesByFolder).forEach(folder => {
361
460
  console.log(`\n📁 ${folder}:`);
362
461
  filesByFolder[folder].forEach(file => {
@@ -364,21 +463,24 @@ class SiesaBmadInstaller {
364
463
  });
365
464
  });
366
465
 
367
- console.log('\n¿Qué deseas hacer?');
368
- console.log('1. Reemplazar todos los archivos (se perderán las modificaciones)');
369
- console.log('2. Hacer backup de archivos modificados (se agregarán con sufijo _bk)');
466
+ console.log('\nℹ️ Tus skills/flows/MCPs propios (los que NO vienen en el paquete) se');
467
+ console.log(' preservan automáticamente no aparecen en la lista de arriba.');
370
468
 
371
- const rl = readline.createInterface({
372
- input: process.stdin,
373
- output: process.stdout
374
- });
469
+ if (this.nonInteractive) {
470
+ console.log('\n (modo no-interactivo: backup de tus cambios)');
471
+ return '2';
472
+ }
375
473
 
376
- return new Promise((resolve) => {
377
- rl.question('\nElige una opción (1 o 2): ', (answer) => {
378
- rl.close();
379
- resolve(answer.trim());
380
- });
474
+ const clack = await loadClack();
475
+ const answer = await clack.select({
476
+ message: '¿Qué hago con TUS cambios sobre los archivos del paquete?',
477
+ options: [
478
+ { value: '2', label: 'Backup y actualizar', hint: 'guarda tus cambios con sufijo _bk + trae la versión del paquete (recomendado)' },
479
+ { value: '1', label: 'Reemplazar', hint: 'pierdes tus modificaciones — sin backup' },
480
+ ],
481
+ initialValue: '2',
381
482
  });
483
+ return bailIfCancel(answer);
382
484
  }
383
485
 
384
486
  async backupModifiedFiles(modifiedFiles) {
@@ -419,22 +521,31 @@ class SiesaBmadInstaller {
419
521
 
420
522
  // Primer intento: archivo_bk.ext
421
523
  const basicBackupPath = path.join(dir, `${name}_bk${ext}`);
422
-
423
- // Si no existe, usar el nombre básico
424
524
  if (!fs.existsSync(basicBackupPath)) {
425
525
  return basicBackupPath;
426
526
  }
427
527
 
428
- // Si ya existe _bk, crear versión con timestamp
528
+ // Si ya existe _bk, crear versión con timestamp. Incluimos milisegundos para
529
+ // evitar colisiones cuando dos backups del mismo archivo ocurren en el mismo
530
+ // segundo (p.ej. instalador corriendo en bucle / tests). Y por si dos backups
531
+ // caen en el mismo ms (esencialmente imposible pero defensivo), agregamos un
532
+ // contador incremental.
429
533
  const now = new Date();
430
534
  const timestamp = now.getFullYear().toString() +
431
535
  (now.getMonth() + 1).toString().padStart(2, '0') +
432
536
  now.getDate().toString().padStart(2, '0') + '_' +
433
537
  now.getHours().toString().padStart(2, '0') +
434
538
  now.getMinutes().toString().padStart(2, '0') +
435
- now.getSeconds().toString().padStart(2, '0');
436
-
437
- return path.join(dir, `${name}_bk_${timestamp}${ext}`);
539
+ now.getSeconds().toString().padStart(2, '0') +
540
+ now.getMilliseconds().toString().padStart(3, '0');
541
+
542
+ let candidate = path.join(dir, `${name}_bk_${timestamp}${ext}`);
543
+ let counter = 1;
544
+ while (fs.existsSync(candidate)) {
545
+ candidate = path.join(dir, `${name}_bk_${timestamp}_${counter}${ext}`);
546
+ counter++;
547
+ }
548
+ return candidate;
438
549
  }
439
550
 
440
551
  async performUpdateWithBackups() {
@@ -454,21 +565,33 @@ class SiesaBmadInstaller {
454
565
  async copyWithBackupPreservation(sourcePath, targetPath) {
455
566
  // Obtener todos los archivos backup existentes
456
567
  const backupFiles = await this.findBackupFiles(targetPath);
457
-
458
- // Copiar la carpeta preservando technical-preferences.md
459
- await fs.copy(sourcePath, targetPath, {
460
- overwrite: true,
461
- recursive: true,
462
- filter: (src) => {
463
- const relativePath = path.relative(sourcePath, src);
464
- // No sobrescribir archivos ignorados si ya existen
465
- if (this.ignoredFiles.includes(relativePath)) {
466
- const targetFile = path.join(targetPath, relativePath);
467
- return !fs.existsSync(targetFile);
568
+
569
+ const sourceIsFile = (await fs.stat(sourcePath)).isFile();
570
+
571
+ if (sourceIsFile) {
572
+ // File mapping (.mcp.json u otros). Para .mcp.json hacemos deep-merge JSON
573
+ // para preservar MCPs personalizados; para cualquier otro archivo,
574
+ // copia directa (source-wins).
575
+ await this.applyFileMapping(sourcePath, targetPath, targetPath);
576
+ } else {
577
+ // Folder mapping. fs.copy con overwrite=true sobre el target es un merge:
578
+ // actualiza archivos que ya existían y agrega los nuevos, dejando intactos
579
+ // los archivos del target que no aparecen en el source (skills custom,
580
+ // flujos agregados por el ingeniero, etc.).
581
+ await fs.copy(sourcePath, targetPath, {
582
+ overwrite: true,
583
+ recursive: true,
584
+ filter: (src) => {
585
+ const relativePath = path.relative(sourcePath, src);
586
+ // No sobrescribir archivos ignorados si ya existen
587
+ if (this.ignoredFiles.includes(relativePath)) {
588
+ const targetFile = path.join(targetPath, relativePath);
589
+ return !fs.existsSync(targetFile);
590
+ }
591
+ return true;
468
592
  }
469
- return true;
470
- }
471
- });
593
+ });
594
+ }
472
595
 
473
596
  // Restaurar los archivos backup
474
597
  for (const backupFile of backupFiles) {
@@ -485,6 +608,30 @@ class SiesaBmadInstaller {
485
608
  }
486
609
  }
487
610
 
611
+ // Aplica un mapping de archivo (no carpeta). Para `.mcp.json` hace deep-merge
612
+ // JSON con source-wins: actualiza los servidores MCP nativos a la versión del
613
+ // paquete pero conserva los MCPs adicionales que el usuario haya configurado.
614
+ // Para cualquier otro archivo, copia directa del source.
615
+ // El parámetro `outputPath` permite usar este método tanto en el camino atómico
616
+ // (escribiendo a `<target>.sa-new`) como en el camino con backups (escribiendo
617
+ // directamente al target).
618
+ async applyFileMapping(sourcePath, targetPath, outputPath) {
619
+ const isMcpJson = path.basename(targetPath) === '.mcp.json';
620
+ if (isMcpJson && fs.existsSync(targetPath)) {
621
+ try {
622
+ const sourceJson = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
623
+ const targetJson = JSON.parse(await fs.readFile(targetPath, 'utf8'));
624
+ const merged = deepMergeJson(targetJson, sourceJson);
625
+ await fs.writeFile(outputPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
626
+ console.log('🔀 .mcp.json: merge — MCPs nativos actualizados, MCPs personalizados preservados');
627
+ return;
628
+ } catch (err) {
629
+ console.warn(`⚠️ No se pudo hacer merge de .mcp.json (${err.message}). Se usará la versión del paquete.`);
630
+ }
631
+ }
632
+ await fs.copy(sourcePath, outputPath, { overwrite: true });
633
+ }
634
+
488
635
  async findBackupFiles(targetPath) {
489
636
  if (!fs.existsSync(targetPath)) {
490
637
  return [];
@@ -566,23 +713,121 @@ class SiesaBmadInstaller {
566
713
  if (hasBackups) {
567
714
  await this.performUpdateWithBackups();
568
715
  } else {
569
- // Si no hay backups, hacer actualización normal (remover y copiar)
570
- // Pero primero preservar archivos ignorados
716
+ // Update atómico con merge:
717
+ // 1. Staging arranca como CLON del target actual — preserva skills custom
718
+ // en .claude/skills/, flujos agregados por el ingeniero, y cualquier
719
+ // archivo no nativo que el usuario haya puesto dentro de las carpetas
720
+ // del paquete.
721
+ // 2. Source se sobrepone al staging (actualiza archivos nativos).
722
+ // 3. Para .mcp.json: deep-merge JSON con source-wins — los MCPs nativos
723
+ // se actualizan, los MCPs adicionales del usuario sobreviven.
724
+ // 4. Swap atómico: target → .sa-old, .sa-new → target, borrar .sa-old.
725
+ //
726
+ // El flujo anterior borraba el destino antes de copiar — si el proceso moría
727
+ // a mitad (Ctrl+C, OOM, crash), el usuario se quedaba sin instalación. Con
728
+ // el staging, una interrupción deja el target intacto y solo basura en
729
+ // .sa-new que se limpia en la próxima corrida.
730
+ //
731
+ // Trade-off conocido: archivos nativos que el paquete eliminó en una versión
732
+ // nueva permanecen en el target tras el update — no podemos distinguir
733
+ // entre "nativo removido en upstream" y "custom del usuario". El usuario
734
+ // limpia artefactos obsoletos a mano si los detecta.
571
735
  await this.preserveIgnoredFiles();
572
-
736
+ await this.performAtomicUpdate();
737
+ await this.restoreIgnoredFiles();
738
+ }
739
+ }
740
+
741
+ async performAtomicUpdate() {
742
+ const STAGING_SUFFIX = '.sa-new';
743
+ const OLD_SUFFIX = '.sa-old';
744
+
745
+ // Phase 1: limpiar restos de runs anteriores que murieron mid-update.
746
+ for (const mapping of this.folderMappings) {
747
+ const target = path.join(this.targetDir, mapping.target);
748
+ for (const suffix of [STAGING_SUFFIX, OLD_SUFFIX]) {
749
+ const stale = target + suffix;
750
+ if (fs.existsSync(stale)) {
751
+ try { await fs.remove(stale); } catch (_) {}
752
+ }
753
+ }
754
+ }
755
+
756
+ // Phase 2: stage cada mapping en `<target>.sa-new`.
757
+ // - Folder mapping: clonar target actual al staging y después overlay del
758
+ // source. fs.copy con overwrite=true es un merge — el clon preserva los
759
+ // archivos del usuario (skills custom, MCPs, etc.) y el overlay del
760
+ // source actualiza los archivos nativos del paquete.
761
+ // - File mapping `.mcp.json`: deep-merge JSON con source-wins (preserva
762
+ // MCPs personalizados, actualiza nativos). Otros file mappings: copia
763
+ // directa del source.
764
+ // Si falla cualquier mapping, rollback antes de tocar los targets reales.
765
+ const staged = [];
766
+ try {
573
767
  for (const mapping of this.folderMappings) {
768
+ const sourcePath = path.join(this.packageDir, mapping.source);
574
769
  const targetPath = path.join(this.targetDir, mapping.target);
770
+ const stagingPath = targetPath + STAGING_SUFFIX;
575
771
 
576
- if (fs.existsSync(targetPath)) {
577
- await fs.remove(targetPath);
772
+ if (!fs.existsSync(sourcePath)) {
773
+ console.warn(`⚠️ ${mapping.source} no encontrado en el paquete`);
774
+ continue;
578
775
  }
776
+
777
+ const sourceIsFile = (await fs.stat(sourcePath)).isFile();
778
+
779
+ if (sourceIsFile) {
780
+ await this.applyFileMapping(sourcePath, targetPath, stagingPath);
781
+ } else {
782
+ // Clonar target al staging primero (preserva customs), después overlay
783
+ // del source (actualiza nativos).
784
+ if (fs.existsSync(targetPath)) {
785
+ await fs.copy(targetPath, stagingPath, { overwrite: true, recursive: true });
786
+ }
787
+ await fs.copy(sourcePath, stagingPath, {
788
+ overwrite: true,
789
+ recursive: true,
790
+ filter: (src) => {
791
+ const relativePath = path.relative(sourcePath, src);
792
+ // No sobrescribir archivos ignorados si ya existen en el target real.
793
+ if (this.ignoredFiles.includes(relativePath)) {
794
+ const realTargetFile = path.join(targetPath, relativePath);
795
+ return !fs.existsSync(realTargetFile);
796
+ }
797
+ return true;
798
+ }
799
+ });
800
+ }
801
+ // Ocultar el staging mientras existe (cosmético, solo Windows).
802
+ setHidden(stagingPath, true);
803
+ staged.push({ targetPath, stagingPath });
804
+ }
805
+ } catch (err) {
806
+ // Rollback: borrar lo que sí alcanzó a copiarse al staging.
807
+ for (const { stagingPath } of staged) {
808
+ try { await fs.remove(stagingPath); } catch (_) {}
579
809
  }
810
+ throw err;
811
+ }
580
812
 
581
- // Realizar instalación nueva
582
- await this.performInstallation();
583
-
584
- // Restaurar archivos ignorados
585
- await this.restoreIgnoredFiles();
813
+ // Phase 3: swap atómico. Por cada mapping ya staged, rename target → `.sa-old`,
814
+ // rename `.sa-new` → target, borrar `.sa-old`. fs.move es rename donde es posible.
815
+ for (const { targetPath, stagingPath } of staged) {
816
+ const oldPath = targetPath + OLD_SUFFIX;
817
+
818
+ if (fs.existsSync(targetPath)) {
819
+ await fs.move(targetPath, oldPath, { overwrite: true });
820
+ setHidden(oldPath, true); // ocultar el viejo mientras se borra
821
+ }
822
+ await fs.move(stagingPath, targetPath, { overwrite: true });
823
+ // El rename hereda el atributo oculto del staging — limpiarlo para que el
824
+ // target final sea visible.
825
+ setHidden(targetPath, false);
826
+ if (fs.existsSync(oldPath)) {
827
+ try { await fs.remove(oldPath); } catch (e) {
828
+ console.warn(`⚠️ No se pudo borrar ${oldPath} (se limpiará en la próxima corrida): ${e.message}`);
829
+ }
830
+ }
586
831
  }
587
832
  }
588
833
 
@@ -660,9 +905,34 @@ class SiesaBmadInstaller {
660
905
  }
661
906
  }
662
907
 
908
+ function printHelp() {
909
+ console.log('Usage: npx siesa-agents [options]\n');
910
+ console.log('Options:');
911
+ console.log(' -y, --yes Modo no-interactivo. Usa defaults sensatos en cualquier prompt');
912
+ console.log(' (no preguntar por repo remoto, hacer backup de modificados).');
913
+ console.log(' Se activa automáticamente cuando stdin no es un TTY.');
914
+ console.log(' --version Imprime la versión del paquete y sale.');
915
+ console.log(' -h, --help Muestra esta ayuda.\n');
916
+ console.log('Environment:');
917
+ console.log(' SIESA_DEBUG=1 Muestra información de diagnóstico para el descubrimiento');
918
+ console.log(' del directorio del paquete.');
919
+ }
920
+
663
921
  // Ejecutar instalación si el script es llamado directamente
664
922
  if (require.main === module) {
665
- const installer = new SiesaBmadInstaller();
923
+ const cliArgs = parseCliArgs(process.argv.slice(2));
924
+
925
+ if (cliArgs.help) {
926
+ printHelp();
927
+ process.exit(0);
928
+ }
929
+ if (cliArgs.version) {
930
+ const pkg = require('../package.json');
931
+ console.log(pkg.version);
932
+ process.exit(0);
933
+ }
934
+
935
+ const installer = new SiesaBmadInstaller(cliArgs);
666
936
  installer.install();
667
937
  }
668
938
 
@@ -2,26 +2,33 @@
2
2
 
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
+ const { publishRenames } = require('./folder-mappings');
5
6
 
6
7
  const rootDir = path.dirname(__dirname);
7
8
 
8
- // Renombrar carpetas que empiezan con punto para que npm las incluya
9
- const folderMappings = [
10
- { from: '.bmad-core', to: 'bmad-core' },
11
- { from: '.vscode', to: 'vscode' },
12
- { from: '.github', to: 'github' }
13
- ];
9
+ // Renombrar carpetas dev-side a sus nombres "flat" antes de `npm publish`. Cuáles se
10
+ // renombran lo decide bin/folder-mappings.js (un solo source of truth compartido con
11
+ // install.js y restore-folders.js).
12
+ const folderMappings = publishRenames();
14
13
 
15
14
  console.log('📦 Preparando carpetas para publicación (siesa-agents)...');
16
15
 
17
- for (const mapping of folderMappings) {
18
- const fromPath = path.join(rootDir, mapping.from);
19
- const toPath = path.join(rootDir, mapping.to);
16
+ if (folderMappings.length === 0) {
17
+ console.log('ℹ️ El repo mantiene un mirror flat de cada carpeta (claude/, github/, bmad/, …)');
18
+ console.log(' junto al dotted/underscored (.claude/, .github/, _bmad/, …). No hace falta');
19
+ console.log(' renombrar nada antes de `npm publish` — los archivos flat ya están listos.');
20
+ console.log(' Si en el futuro se elimina algún mirror, declara su devSource en');
21
+ console.log(' bin/folder-mappings.js y este script lo renombrará automáticamente.');
22
+ } else {
23
+ for (const mapping of folderMappings) {
24
+ const fromPath = path.join(rootDir, mapping.from);
25
+ const toPath = path.join(rootDir, mapping.to);
20
26
 
21
- if (fs.existsSync(fromPath)) {
22
- console.log(`📁 Renombrando ${mapping.from} -> ${mapping.to}`);
23
- fs.moveSync(fromPath, toPath);
27
+ if (fs.existsSync(fromPath)) {
28
+ console.log(`📁 Renombrando ${mapping.from} -> ${mapping.to}`);
29
+ fs.moveSync(fromPath, toPath);
30
+ }
24
31
  }
25
32
  }
26
33
 
27
- console.log('✅ Carpetas preparadas para publicación');
34
+ console.log('✅ Carpetas preparadas para publicación');
@@ -2,26 +2,29 @@
2
2
 
3
3
  const fs = require('fs-extra');
4
4
  const path = require('path');
5
+ const { restoreRenames } = require('./folder-mappings');
5
6
 
6
7
  const rootDir = path.dirname(__dirname);
7
8
 
8
- // Restaurar nombres originales de las carpetas
9
- const folderMappings = [
10
- { from: 'bmad-core', to: '.bmad-core' },
11
- { from: 'vscode', to: '.vscode' },
12
- { from: 'github', to: '.github' }
13
- ];
9
+ // Restaurar nombres originales de las carpetas dev tras `npm publish`. Cuáles se
10
+ // restauran lo decide bin/folder-mappings.js (compartido con install.js y prepare-publish.js).
11
+ const folderMappings = restoreRenames();
14
12
 
15
13
  console.log('🔄 Restaurando nombres originales de carpetas...');
16
14
 
17
- for (const mapping of folderMappings) {
18
- const fromPath = path.join(rootDir, mapping.from);
19
- const toPath = path.join(rootDir, mapping.to);
15
+ if (folderMappings.length === 0) {
16
+ console.log('ℹ️ Nada que restaurar — prepare-publish.js no renombró nada porque el repo');
17
+ console.log(' mantiene mirrors flat junto a los dotted/underscored. Ver bin/folder-mappings.js.');
18
+ } else {
19
+ for (const mapping of folderMappings) {
20
+ const fromPath = path.join(rootDir, mapping.from);
21
+ const toPath = path.join(rootDir, mapping.to);
20
22
 
21
- if (fs.existsSync(fromPath)) {
22
- console.log(`📁 Restaurando ${mapping.from} -> ${mapping.to}`);
23
- fs.moveSync(fromPath, toPath);
23
+ if (fs.existsSync(fromPath)) {
24
+ console.log(`📁 Restaurando ${mapping.from} -> ${mapping.to}`);
25
+ fs.moveSync(fromPath, toPath);
26
+ }
24
27
  }
25
28
  }
26
29
 
27
- console.log('✅ Carpetas restauradas');
30
+ console.log('✅ Carpetas restauradas');
@@ -20,6 +20,7 @@ NO modifiques ningún archivo dentro de `.claude/agent-memory/` — es de SOLO L
20
20
  - Sé directo, funcional y breve.
21
21
  - Si encuentras issues que puedes auto-corregir, corrígelos directamente sin pedir confirmación.
22
22
  - Valida compliance contra los estándares cargados: folder structure correcta, naming conventions, uso de DateTimeOffset (no DateTime), UUID PKs, FluentValidation, patrones DDD, etc.
23
+ - **Git — NO auto-gitflow:** Cuando este agente es invocado desde `sa-quick-dev`, está **prohibido** crear ramas, hacer commits, push, pull requests, worktrees ni cualquier operación git automática. Todos los cambios (incluidas las auto-correcciones) se aplican directamente sobre la rama activa del repositorio al momento de la invocación. El versionamiento es responsabilidad exclusiva del usuario.
23
24
 
24
25
  ## Ejecución
25
26
 
@@ -20,6 +20,7 @@ NO modifiques ningún archivo dentro de `.claude/agent-memory/` — es de SOLO L
20
20
  - Sé directo, funcional y breve.
21
21
  - Implementa SOLO lo que los acceptance criteria y las tareas de la historia requieren. Nada más.
22
22
  - Todo código DEBE seguir los estándares cargados: folder structure, naming conventions, tech stack, patterns (DDD entities, value objects, CQRS, FluentValidation, DateTimeOffset, UUID PKs, etc.).
23
+ - **Git — NO auto-gitflow:** Cuando este agente es invocado desde `sa-quick-dev`, está **prohibido** crear ramas, hacer commits, push, pull requests, worktrees ni cualquier operación git automática. Todos los cambios se aplican directamente sobre la rama activa del repositorio al momento de la invocación. El versionamiento es responsabilidad exclusiva del usuario.
23
24
 
24
25
  ## Ejecución
25
26
 
@@ -99,6 +99,64 @@ Modo: epic-level
99
99
 
100
100
  ---
101
101
 
102
+ ## Reglas generales del pipeline
103
+
104
+ ### Regla 1 — Observabilidad obligatoria en cada sub-agente
105
+
106
+ Cada sub-agente que ejecuta un workflow BMAD (`sa-create-story`, `sa-dev-story`, `sa-code-review`) **DEBE honrar las instrucciones de observabilidad** definidas en su respectivo `workflow_ext.md`. Estas instrucciones emiten eventos de telemetría (`sa-emit.js`) a GCP en momentos específicos del flujo y son la fuente de todos los reportes de métricas del equipo.
107
+
108
+ **Workflows instrumentados y eventos que se deben emitir:**
109
+
110
+ | Sub-agente | `workflow_ext.md` | Eventos emitidos |
111
+ |---|---|---|
112
+ | `sa-create-story` | `_siesa-agents/bmm/workflows/4-implementation/create-story/workflow_ext.md` | `workflow.started`, `status.changed` (backlog → ready-for-dev), `workflow.finished` |
113
+ | `sa-dev-story` | `_siesa-agents/bmm/workflows/4-implementation/dev-story/workflow_ext.md` | `workflow.started`, `status.changed` (in-progress → review), `workflow.finished` |
114
+ | `sa-code-review` | `_siesa-agents/bmm/workflows/4-implementation/code-review/workflow_ext.md` | `workflow.started`, `fix.started`/`fix.finished` (cuando el usuario acepta auto_fix / action_items / show_details), `status.changed` (review → done o → in-progress), `workflow.finished` |
115
+
116
+ **Prohibido a cualquier sub-agente bajo este orquestador:**
117
+
118
+ - ❌ Saltarse, comentar, condicionar u omitir cualquier comando `node ... sa-emit.js ...` del `workflow_ext.md` correspondiente.
119
+ - ❌ Diferir la emisión al "final del pipeline" o agruparla en batch. Los eventos son **lifecycle markers** y solo tienen sentido en el instante exacto que el `workflow_ext.md` indica (`workflow.finished` mide `duration_ms` desde el `workflow.started` correspondiente; si lo retrasas, la métrica queda corrompida).
120
+
121
+ **Manejo de errores de `sa-emit.js`:**
122
+
123
+ Si la llamada a `sa-emit.js` falla (gateway caído, credenciales mal configuradas, etc.), el sub-agente debe:
124
+
125
+ 1. Registrar el fallo como advertencia **no-bloqueante**.
126
+ 2. **Continuar el workflow normalmente** (regla universal documentada en cada `workflow_ext.md`: *"observability must never block the workflow"*).
127
+ 3. NUNCA reemplazar la llamada por su omisión deliberada — el evento queda buffereado localmente en `~/.claude/observability/buffer/events.jsonl` y se reenvía automáticamente cuando el transporte se recupera.
128
+
129
+ ---
130
+
131
+ ## Reglas generales del pipeline
132
+
133
+ ### Regla 1 — Observabilidad obligatoria en cada sub-agente
134
+
135
+ Cada sub-agente que ejecuta un workflow BMAD (`sa-create-story`, `sa-dev-story`, `sa-code-review`) **DEBE honrar las instrucciones de observabilidad** definidas en su respectivo `workflow_ext.md`. Estas instrucciones emiten eventos de telemetría (`sa-emit.js`) a GCP en momentos específicos del flujo y son la fuente de todos los reportes de métricas del equipo.
136
+
137
+ **Workflows instrumentados y eventos que se deben emitir:**
138
+
139
+ | Sub-agente | `workflow_ext.md` | Eventos emitidos |
140
+ |---|---|---|
141
+ | `sa-create-story` | `_siesa-agents/bmm/workflows/4-implementation/create-story/workflow_ext.md` | `workflow.started`, `status.changed` (backlog → ready-for-dev), `workflow.finished` |
142
+ | `sa-dev-story` | `_siesa-agents/bmm/workflows/4-implementation/dev-story/workflow_ext.md` | `workflow.started`, `status.changed` (in-progress → review), `workflow.finished` |
143
+ | `sa-code-review` | `_siesa-agents/bmm/workflows/4-implementation/code-review/workflow_ext.md` | `workflow.started`, `fix.started`/`fix.finished` (cuando el usuario acepta auto_fix / action_items / show_details), `status.changed` (review → done o → in-progress), `workflow.finished` |
144
+
145
+ **Prohibido a cualquier sub-agente bajo este orquestador:**
146
+
147
+ - ❌ Saltarse, comentar, condicionar u omitir cualquier comando `node ... sa-emit.js ...` del `workflow_ext.md` correspondiente.
148
+ - ❌ Diferir la emisión al "final del pipeline" o agruparla en batch. Los eventos son **lifecycle markers** y solo tienen sentido en el instante exacto que el `workflow_ext.md` indica (`workflow.finished` mide `duration_ms` desde el `workflow.started` correspondiente; si lo retrasas, la métrica queda corrompida).
149
+
150
+ **Manejo de errores de `sa-emit.js`:**
151
+
152
+ Si la llamada a `sa-emit.js` falla (gateway caído, credenciales mal configuradas, etc.), el sub-agente debe:
153
+
154
+ 1. Registrar el fallo como advertencia **no-bloqueante**.
155
+ 2. **Continuar el workflow normalmente** (regla universal documentada en cada `workflow_ext.md`: *"observability must never block the workflow"*).
156
+ 3. NUNCA reemplazar la llamada por su omisión deliberada — el evento queda buffereado localmente en `~/.claude/observability/buffer/events.jsonl` y se reenvía automáticamente cuando el transporte se recupera.
157
+
158
+ ---
159
+
102
160
  ## PASO 1 — Loop de procesamiento por historia
103
161
 
104
162
  Para CADA historia pendiente de la épica seleccionada, ejecuta secuencialmente los sub-agentes dedicados. Cada historia completa su ciclo completo antes de pasar a la siguiente.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siesa-agents",
3
- "version": "2.1.83",
3
+ "version": "2.1.84",
4
4
  "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -26,7 +26,6 @@
26
26
  "github/**/*",
27
27
  "claude/**/*",
28
28
  "gemini/**/*",
29
- "kiro/**/*",
30
29
  "bin/**/*",
31
30
  "resources/**/*",
32
31
  "README.md",
@@ -36,7 +35,7 @@
36
35
  "node": ">=14.0.0"
37
36
  },
38
37
  "dependencies": {
39
- "fs-extra": "^11.1.1",
40
- "path": "^0.12.7"
38
+ "@clack/prompts": "^0.11.0",
39
+ "fs-extra": "^11.1.1"
41
40
  }
42
41
  }
@@ -9,5 +9,9 @@
9
9
  "cursor-workspace": true
10
10
  },
11
11
  "github.copilot.chat.agent.autoFix": true,
12
- "chat.tools.autoApprove": false
12
+ "chat.tools.autoApprove": false,
13
+ "files.exclude": {
14
+ "**/*.sa-new": true,
15
+ "**/*.sa-old": true
16
+ }
13
17
  }