@arcadialdev/arcality 3.0.2 → 3.0.3

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.
@@ -1,139 +1,153 @@
1
- #!/usr/bin/env node
2
- // scripts/gen-and-run.mjs
3
- // Main Arcality CLI loop — v3 Single Configuration Mode
4
- // Reads from arcality.config instead of multi-config .env approach.
5
-
6
- import 'dotenv/config';
7
- import fs from "node:fs";
8
- import path from "node:path";
9
- import { createRequire } from 'node:module';
10
- import { spawn, exec } from "node:child_process";
11
- import chalk from "chalk";
12
- import { fileURLToPath, pathToFileURL } from 'url';
13
- const require = createRequire(import.meta.url);
14
- import figlet from "figlet";
15
- import { intro, outro, select, text, spinner, note, isCancel, cancel } from '@clack/prompts';
16
- import { load as loadYaml } from 'js-yaml';
17
- import { loadGlobalConfig, getApiKey, getApiUrl, maskApiKey, setupProcessEnv } from '../src/configLoader.mjs';
18
- import {
19
- configExists,
20
- loadProjectConfig,
21
- validateConfig,
22
- injectConfigToEnv,
23
- getYamlOutputDir,
24
- ensureYamlOutputDir,
25
- } from '../src/configManager.mjs';
26
-
27
- // Load global config at startup (injects keys into process.env)
28
- setupProcessEnv();
29
-
30
- // ── WINDOWS FIX: Interceptar Ctrl+C antes que cmd.exe ──
31
- // En Windows, `arcality` se ejecuta vía arcality.cmd (creado por npm para el campo bin).
32
- // cmd.exe intercepta Ctrl+C y muestra "¿Desea terminar el trabajo por lotes (S/N)?".
33
- // Al presionar S, mata el árbol de procesos antes de que nuestro finally pueda correr.
34
- //
35
- // La solución: setRawMode(true) hace que Node capture los bytes del teclado directamente,
36
- // ANTES de que cmd.exe los procese. Al detectar Ctrl+C (byte 0x03) lo convertimos en
37
- // un SIGINT de Node cmd.exe nunca lo ve y nunca muestra el prompt.
38
- if (process.stdin.isTTY && process.stdin.setRawMode) {
39
- try {
40
- process.stdin.setRawMode(true);
41
- process.stdin.resume();
42
- process.stdin.setEncoding('utf8');
43
- process.stdin.on('data', (key) => {
44
- if (key === '\u0003') { // Ctrl+C
45
- process.emit('SIGINT');
46
- }
47
- });
48
- } catch (e) {
49
- // Si falla (ej. en CI sin TTY), continuar sin raw mode
50
- }
51
- }
52
-
53
- const __filename = fileURLToPath(import.meta.url);
54
- const __dirname = path.dirname(__filename);
55
- const PROJECT_ROOT = process.env.ARCALITY_ROOT || path.join(__dirname, "..");
56
-
57
- const ORIGINAL_CWD = process.cwd();
58
-
59
- // ── Load arcality.config if present ──
60
- let projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
61
- if (projectConfig) {
62
- injectConfigToEnv(projectConfig);
63
- }
64
-
65
- function parseDotEnvFile(p) {
66
- try {
67
- const raw = fs.readFileSync(p, 'utf8');
68
- const lines = raw.split(/\r?\n/);
69
- const out = {};
70
- for (const line of lines) {
71
- const trimmed = line.trim();
72
- if (!trimmed || trimmed.startsWith('#')) continue;
73
- const idx = trimmed.indexOf('=');
74
- if (idx === -1) continue;
75
- let k = trimmed.slice(0, idx).trim();
76
- let v = trimmed.slice(idx + 1).trim();
77
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
78
- out[k] = v;
79
- }
80
- return out;
81
- } catch {
82
- return {};
83
- }
84
- }
85
-
86
- function updateDotEnvFile(updates) {
87
- const p = path.join(PROJECT_ROOT, '.env');
88
- let content = '';
89
- try {
90
- content = fs.readFileSync(p, 'utf8');
91
- } catch {
92
- content = '';
93
- }
94
-
95
- let lines = content.split(/\r?\n/);
96
- const keys = Object.keys(updates);
97
- const updatedKeys = new Set();
98
-
99
- lines = lines.map(line => {
100
- const trimmed = line.trim();
101
- if (!trimmed || trimmed.startsWith('#')) return line;
102
- const idx = trimmed.indexOf('=');
103
- if (idx === -1) return line;
104
- const k = trimmed.slice(0, idx).trim();
105
- if (keys.includes(k)) {
106
- updatedKeys.add(k);
107
- return `${k}=${updates[k]}`;
108
- }
109
- return line;
110
- });
111
-
112
- keys.forEach(k => {
113
- if (!updatedKeys.has(k)) {
114
- lines.push(`${k}=${updates[k]}`);
115
- }
116
- });
117
-
118
- fs.writeFileSync(p, lines.join('\n'), 'utf8');
119
- keys.forEach(k => { process.env[k] = updates[k]; });
120
- }
121
-
1
+ #!/usr/bin/env node
2
+ // scripts/gen-and-run.mjs
3
+ // Main Arcality CLI loop — v3 Single Configuration Mode
4
+ // Reads from arcality.config instead of multi-config .env approach.
5
+
6
+ import 'dotenv/config';
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { createRequire } from 'node:module';
10
+ import { spawn } from "node:child_process";
11
+ import { createInterface } from "node:readline";
12
+ import chalk from "chalk";
13
+ import { fileURLToPath, pathToFileURL } from 'url';
14
+ const require = createRequire(import.meta.url);
15
+ import figlet from "figlet";
16
+ import { intro, outro, select, text, spinner, note, isCancel, cancel } from '@clack/prompts';
17
+ import { load as loadYaml } from 'js-yaml';
18
+ import { loadGlobalConfig, getApiKey, getApiUrl, maskApiKey, setupProcessEnv } from '../src/configLoader.mjs';
19
+ import {
20
+ configExists,
21
+ loadProjectConfig,
22
+ validateConfig,
23
+ injectConfigToEnv,
24
+ getYamlOutputDir,
25
+ ensureYamlOutputDir,
26
+ isRealProjectId,
27
+ } from '../src/configManager.mjs';
28
+
29
+ // Load global config at startup (injects keys into process.env)
30
+ setupProcessEnv();
31
+
32
+ // ── WINDOWS FIX: Interceptar Ctrl+C antes que cmd.exe ──
33
+ // En Windows, `arcality` se ejecuta vía arcality.cmd (creado por npm para el campo bin).
34
+ // cmd.exe intercepta Ctrl+C y muestra "¿Desea terminar el trabajo por lotes (S/N)?".
35
+ // Al presionar S, mata el árbol de procesos antes de que nuestro finally pueda correr.
36
+ //
37
+ // La solución: setRawMode(true) hace que Node capture los bytes del teclado directamente,
38
+ // ANTES de que cmd.exe los procese. Al detectar Ctrl+C (byte 0x03) lo convertimos en
39
+ // un SIGINT de Node — cmd.exe nunca lo ve y nunca muestra el prompt.
40
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
41
+ try {
42
+ process.stdin.setRawMode(true);
43
+ process.stdin.resume();
44
+ process.stdin.setEncoding('utf8');
45
+ process.stdin.on('data', (key) => {
46
+ if (key === '\u0003') { // Ctrl+C
47
+ process.emit('SIGINT');
48
+ }
49
+ });
50
+ } catch (e) {
51
+ // Si falla (ej. en CI sin TTY), continuar sin raw mode
52
+ }
53
+ }
54
+
55
+ const __filename = fileURLToPath(import.meta.url);
56
+ const __dirname = path.dirname(__filename);
57
+ const PROJECT_ROOT = process.env.ARCALITY_ROOT || path.join(__dirname, "..");
58
+ const RUNNER_SOURCE_FILE = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
59
+ const RUNNER_BUNDLE_FILE = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.bundle.spec.js');
60
+
61
+ const ORIGINAL_CWD = process.cwd();
62
+ const COLLECTION_DEFAULT_CONCURRENCY = 3;
63
+ const COLLECTION_MAX_CONCURRENCY = 5;
64
+
65
+ // ── Load arcality.config if present ──
66
+ let projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
67
+ if (projectConfig) {
68
+ if (!isRealProjectId(projectConfig.projectId)) {
69
+ console.error(chalk.red('\n[FATAL] Proyecto Arcality no conectado.'));
70
+ console.error(chalk.yellow('El arcality.config actual no tiene un Project ID real del backend.'));
71
+ console.error(chalk.gray('Ejecuta `arcality init` para registrar el proyecto en Arcality antes de correr misiones.\n'));
72
+ process.exit(1);
73
+ }
74
+ injectConfigToEnv(projectConfig);
75
+ }
76
+
77
+ function parseDotEnvFile(p) {
78
+ try {
79
+ const raw = fs.readFileSync(p, 'utf8');
80
+ const lines = raw.split(/\r?\n/);
81
+ const out = {};
82
+ for (const line of lines) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed || trimmed.startsWith('#')) continue;
85
+ const idx = trimmed.indexOf('=');
86
+ if (idx === -1) continue;
87
+ let k = trimmed.slice(0, idx).trim();
88
+ let v = trimmed.slice(idx + 1).trim();
89
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
90
+ out[k] = v;
91
+ }
92
+ return out;
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+
98
+ function updateDotEnvFile(updates) {
99
+ const p = path.join(PROJECT_ROOT, '.env');
100
+ let content = '';
101
+ try {
102
+ content = fs.readFileSync(p, 'utf8');
103
+ } catch {
104
+ content = '';
105
+ }
106
+
107
+ let lines = content.split(/\r?\n/);
108
+ const keys = Object.keys(updates);
109
+ const updatedKeys = new Set();
110
+
111
+ lines = lines.map(line => {
112
+ const trimmed = line.trim();
113
+ if (!trimmed || trimmed.startsWith('#')) return line;
114
+ const idx = trimmed.indexOf('=');
115
+ if (idx === -1) return line;
116
+ const k = trimmed.slice(0, idx).trim();
117
+ if (keys.includes(k)) {
118
+ updatedKeys.add(k);
119
+ return `${k}=${updates[k]}`;
120
+ }
121
+ return line;
122
+ });
123
+
124
+ keys.forEach(k => {
125
+ if (!updatedKeys.has(k)) {
126
+ lines.push(`${k}=${updates[k]}`);
127
+ }
128
+ });
129
+
130
+ fs.writeFileSync(p, lines.join('\n'), 'utf8');
131
+ keys.forEach(k => { process.env[k] = updates[k]; });
132
+ }
133
+
122
134
  function setupEnvironment() {
123
-
124
- // Use config name from arcality.config or fallback
125
- const configName = projectConfig?.project?.name || 'Default';
126
- const baseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL || 'http://localhost';
127
-
128
- const rootOutDir = path.join(ORIGINAL_CWD, '.arcality', 'out');
129
- const configDir = path.join(rootOutDir, configName.replace(/[^a-zA-Z0-9_-]/g, '_'));
130
-
131
- process.env.DOMAIN_DIR = configDir;
132
- process.env.MISSIONS_DIR = path.join(configDir, 'missions');
133
- process.env.CONTEXT_DIR = path.join(configDir, 'context');
134
- process.env.REPORTS_DIR = path.join(configDir, 'reports');
135
-
136
- [process.env.MISSIONS_DIR, process.env.CONTEXT_DIR, process.env.REPORTS_DIR].forEach(dir => {
135
+
136
+ // Use config name from arcality.config or fallback
137
+ const configName = projectConfig?.project?.name || 'Default';
138
+ const baseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL || 'http://localhost';
139
+
140
+ const rootOutDir = path.join(ORIGINAL_CWD, '.arcality', 'out');
141
+ const configDir = path.join(rootOutDir, configName.replace(/[^a-zA-Z0-9_-]/g, '_'));
142
+
143
+ const runDomainDir = process.env.ARCALITY_RUN_DOMAIN_DIR || configDir;
144
+ process.env.DOMAIN_DIR = runDomainDir;
145
+ process.env.MISSIONS_DIR = process.env.ARCALITY_RUN_MISSIONS_DIR || path.join(configDir, 'missions');
146
+ process.env.CONTEXT_DIR = process.env.ARCALITY_RUN_CONTEXT_DIR || path.join(runDomainDir, 'context');
147
+ process.env.REPORTS_DIR = process.env.ARCALITY_RUN_REPORTS_DIR || path.join(runDomainDir, 'reports');
148
+ process.env.PLAYWRIGHT_OUTPUT_DIR = process.env.ARCALITY_RUN_PLAYWRIGHT_OUTPUT_DIR || path.join(runDomainDir, 'playwright-output');
149
+
150
+ [process.env.MISSIONS_DIR, process.env.CONTEXT_DIR, process.env.REPORTS_DIR, process.env.PLAYWRIGHT_OUTPUT_DIR].forEach(dir => {
137
151
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
138
152
  });
139
153
  ensureMissionTemplates(process.env.MISSIONS_DIR);
@@ -143,22 +157,22 @@ function setupEnvironment() {
143
157
  const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
144
158
  ensureMissionTemplates(yamlOutputDir);
145
159
  }
146
-
147
- // Framework detection from package.json (NO .git)
148
- let techStack = 'Not detected';
149
- try {
150
- const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
151
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
152
- if (deps['next']) techStack = 'Next.js 🚀';
153
- else if (deps['vite']) techStack = 'Vite ⚡';
154
- else if (deps['react-scripts']) techStack = 'Create React App ⚛️';
155
- } catch { }
156
-
157
- // Override with arcality.config detection if available
158
- if (projectConfig?.project?.frameworkDetected) {
159
- techStack = projectConfig.project.frameworkDetected;
160
- }
161
-
160
+
161
+ // Framework detection from package.json (NO .git)
162
+ let techStack = 'Not detected';
163
+ try {
164
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
165
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
166
+ if (deps['next']) techStack = 'Next.js 🚀';
167
+ else if (deps['vite']) techStack = 'Vite ⚡';
168
+ else if (deps['react-scripts']) techStack = 'Create React App ⚛️';
169
+ } catch { }
170
+
171
+ // Override with arcality.config detection if available
172
+ if (projectConfig?.project?.frameworkDetected) {
173
+ techStack = projectConfig.project.frameworkDetected;
174
+ }
175
+
162
176
  return { configName, baseUrl, techStack, configDir };
163
177
  }
164
178
 
@@ -585,33 +599,42 @@ default_page_path: "/welcome"
585
599
  return created;
586
600
  }
587
601
 
588
- async function waitForEnter(message = 'Presiona Enter para continuar...') {
602
+ async function waitForEnter(message = 'Presiona Enter para continuar...', options = {}) {
589
603
  if (!process.stdin.isTTY) return;
590
604
 
591
605
  console.log(chalk.gray(`\n ${message}`));
592
606
  await new Promise(resolve => {
593
- const onKey = (key) => {
594
- const keyStr = String(key);
595
- if (keyStr.includes('\r') || keyStr.includes('\n') || keyStr === '\u0003') {
596
- process.stdin.off('data', onKey);
607
+ const restoreRawMode = options.restoreRawMode !== false;
608
+ const pauseOnResolve = options.pauseOnResolve === true;
609
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
610
+ process.stdin.setRawMode(false);
611
+ }
597
612
 
598
- if (process.stdin.isTTY && process.stdin.setRawMode) {
599
- process.stdin.setRawMode(false);
600
- }
613
+ const rl = createInterface({
614
+ input: process.stdin,
615
+ output: process.stdout
616
+ });
601
617
 
602
- if (keyStr === '\u0003') {
603
- process.exit(0);
604
- } else {
605
- resolve();
606
- }
618
+ const cleanup = () => {
619
+ rl.close();
620
+ if (process.stdin.isTTY && process.stdin.setRawMode && restoreRawMode) {
621
+ process.stdin.setRawMode(true);
622
+ process.stdin.resume();
623
+ }
624
+ if (pauseOnResolve) {
625
+ process.stdin.pause();
607
626
  }
608
627
  };
609
628
 
610
- if (process.stdin.isTTY && process.stdin.setRawMode) {
611
- process.stdin.setRawMode(true);
612
- }
613
- process.stdin.resume();
614
- process.stdin.on('data', onKey);
629
+ rl.on('SIGINT', () => {
630
+ cleanup();
631
+ process.exit(0);
632
+ });
633
+
634
+ rl.question('', () => {
635
+ cleanup();
636
+ resolve();
637
+ });
615
638
  });
616
639
  }
617
640
 
@@ -646,15 +669,320 @@ function readLatestMissionLog(contextDir, knownFiles = new Set()) {
646
669
  }
647
670
  }
648
671
 
672
+ function readMissionResult(runDomainDir, contextDir, knownFiles = new Set()) {
673
+ const persistedResultPath = runDomainDir
674
+ ? path.join(runDomainDir, 'mission-result.json')
675
+ : null;
676
+
677
+ if (persistedResultPath && fs.existsSync(persistedResultPath)) {
678
+ try {
679
+ return {
680
+ file: persistedResultPath,
681
+ data: JSON.parse(fs.readFileSync(persistedResultPath, 'utf8'))
682
+ };
683
+ } catch {
684
+ // Fallback below.
685
+ }
686
+ }
687
+
688
+ return readLatestMissionLog(contextDir, knownFiles);
689
+ }
690
+
691
+ function normalizeCollectionConcurrency(value, fallback = COLLECTION_DEFAULT_CONCURRENCY) {
692
+ const n = Number(value);
693
+ if (!Number.isFinite(n) || n < 1) return fallback;
694
+ return Math.min(COLLECTION_MAX_CONCURRENCY, Math.max(1, Math.floor(n)));
695
+ }
696
+
697
+ function createCollectionRunId() {
698
+ return new Date().toISOString()
699
+ .replace(/\.\d{3}Z$/, 'Z')
700
+ .replace(/[^0-9TZ]/g, '')
701
+ .replace('T', '-')
702
+ .replace('Z', '');
703
+ }
704
+
705
+ function formatPathForHtml(value) {
706
+ return String(value || '').split(path.sep).join('/');
707
+ }
708
+
709
+ function escapeHtml(value) {
710
+ return String(value ?? '')
711
+ .replace(/&/g, '&amp;')
712
+ .replace(/</g, '&lt;')
713
+ .replace(/>/g, '&gt;')
714
+ .replace(/"/g, '&quot;')
715
+ .replace(/'/g, '&#39;');
716
+ }
717
+
718
+ function createUniqueMissionSlug(missionSpec, fileName, index, usedSlugs) {
719
+ const baseName = missionSpec.name || fileName || `mission-${index + 1}`;
720
+ const base = sanitizePathSegment(baseName) || `mission_${index + 1}`;
721
+ const prefix = String(index + 1).padStart(2, '0');
722
+ let candidate = `${prefix}-${base}`;
723
+ let suffix = 2;
724
+
725
+ while (usedSlugs.has(candidate)) {
726
+ candidate = `${prefix}-${base}-${suffix}`;
727
+ suffix += 1;
728
+ }
729
+
730
+ usedSlugs.add(candidate);
731
+ return candidate;
732
+ }
733
+
734
+ async function ensureRunnerBundle({ debug = false, spinnerRef = null, statusMessage = 'Compilando Arcality Runner...' } = {}) {
735
+ const sourceExists = fs.existsSync(RUNNER_SOURCE_FILE);
736
+ const bundleExists = fs.existsSync(RUNNER_BUNDLE_FILE);
737
+
738
+ if (!sourceExists) {
739
+ if (bundleExists) {
740
+ if (debug) {
741
+ console.log(chalk.gray('[DEBUG] Runner fuente no incluido en el paquete; usando bundle precompilado.'));
742
+ }
743
+ return true;
744
+ }
745
+
746
+ console.warn(chalk.yellow('\nAdvertencia: No se encontro el runner fuente ni el bundle precompilado.'));
747
+ return false;
748
+ }
749
+
750
+ try {
751
+ if (spinnerRef && !debug) {
752
+ spinnerRef.message(statusMessage);
753
+ } else if (debug) {
754
+ console.log(chalk.cyan(statusMessage));
755
+ }
756
+
757
+ await runNpmScript(['run', 'build:runner'], {
758
+ cwd: PROJECT_ROOT,
759
+ streamOutput: debug
760
+ });
761
+ return true;
762
+ } catch (err) {
763
+ console.warn(chalk.yellow(`\nAdvertencia: No se pudo compilar el runner: ${err.message}`));
764
+ return false;
765
+ }
766
+ }
767
+
768
+ async function buildRunnerForCollection(debug = false, spinnerRef = null) {
769
+ return ensureRunnerBundle({
770
+ debug,
771
+ spinnerRef,
772
+ statusMessage: debug
773
+ ? '[RUN-ALL] Compilando Arcality Runner una sola vez para la coleccion...'
774
+ : 'Compilando Arcality Runner una sola vez para la coleccion...'
775
+ });
776
+ }
777
+
778
+ async function runWithConcurrency(items, concurrency, worker) {
779
+ const results = new Array(items.length);
780
+ let nextIndex = 0;
781
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
782
+
783
+ await Promise.all(Array.from({ length: workerCount }, async () => {
784
+ while (nextIndex < items.length) {
785
+ const currentIndex = nextIndex;
786
+ nextIndex += 1;
787
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
788
+ }
789
+ }));
790
+
791
+ return results;
792
+ }
793
+
794
+ function writeCollectionArtifacts(runDir, summary, metadata) {
795
+ const summaryPath = path.join(runDir, 'summary.json');
796
+ const reportDir = path.join(runDir, 'collection-report');
797
+ const reportPath = path.join(reportDir, 'index.html');
798
+
799
+ if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
800
+
801
+ const passed = summary.filter(item => item.success).length;
802
+ const failed = summary.length - passed;
803
+ const startedAt = metadata.startedAt || new Date().toISOString();
804
+ const finishedAt = new Date().toISOString();
805
+ const durationMs = summary.reduce((total, item) => total + (item.durationMs || 0), 0);
806
+
807
+ fs.writeFileSync(summaryPath, JSON.stringify({
808
+ runId: metadata.runId,
809
+ collection: metadata.collectionLabel,
810
+ concurrency: metadata.concurrency,
811
+ startedAt,
812
+ finishedAt,
813
+ total: summary.length,
814
+ passed,
815
+ failed,
816
+ missions: summary
817
+ }, null, 2));
818
+
819
+ const rows = summary.map(item => {
820
+ const reportExists = item.reportPath && fs.existsSync(item.reportPath);
821
+ const relativeReport = reportExists
822
+ ? formatPathForHtml(path.relative(reportDir, item.reportPath))
823
+ : '';
824
+ const status = item.success ? 'Exitosa' : 'Error';
825
+ const tone = item.success ? 'passed' : 'failed';
826
+ const reportLink = reportExists
827
+ ? `<a href="${escapeHtml(relativeReport)}">Abrir reporte</a>`
828
+ : '<span class="muted">Sin reporte</span>';
829
+ const reason = item.reason ? escapeHtml(item.reason) : '<span class="muted">-</span>';
830
+
831
+ return `
832
+ <tr>
833
+ <td><span class="status ${tone}">${status}</span></td>
834
+ <td>
835
+ <strong>${escapeHtml(item.name)}</strong>
836
+ <small>${escapeHtml(item.collection || 'General')}</small>
837
+ </td>
838
+ <td>${reason}</td>
839
+ <td>${item.durationMs ? Math.round(item.durationMs / 1000) + 's' : '-'}</td>
840
+ <td>${reportLink}</td>
841
+ </tr>
842
+ `;
843
+ }).join('');
844
+
845
+ const html = `<!DOCTYPE html>
846
+ <html lang="es">
847
+ <head>
848
+ <meta charset="UTF-8">
849
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
850
+ <title>Arcality Collection Report</title>
851
+ <style>
852
+ :root {
853
+ --bg: #07080d;
854
+ --surface: #10131b;
855
+ --line: #293042;
856
+ --text: #f7f4ff;
857
+ --muted: #a9afc0;
858
+ --ok: #36d399;
859
+ --bad: #ff5d7a;
860
+ --accent: #75c7ff;
861
+ }
862
+ * { box-sizing: border-box; }
863
+ body {
864
+ margin: 0;
865
+ min-height: 100vh;
866
+ background: var(--bg);
867
+ color: var(--text);
868
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
869
+ }
870
+ main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 44px 0 64px; }
871
+ header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 34px; }
872
+ h1 { margin: 0 0 10px; font-size: clamp(38px, 6vw, 72px); line-height: .95; letter-spacing: 0; }
873
+ p { margin: 0; color: var(--muted); font-size: 16px; }
874
+ .badge { border: 1px solid var(--line); border-radius: 8px; padding: 16px 18px; min-width: 250px; background: rgba(255,255,255,.02); }
875
+ .badge small { color: var(--muted); display: block; text-transform: uppercase; font-size: 11px; letter-spacing: .14em; margin-bottom: 8px; }
876
+ .badge strong { display: block; font-size: 22px; }
877
+ .grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-bottom: 28px; }
878
+ .metric { padding: 20px; background: var(--surface); border-right: 1px solid var(--line); }
879
+ .metric:last-child { border-right: 0; }
880
+ .metric b { display: block; font-size: 30px; margin-bottom: 8px; }
881
+ .metric span { color: var(--muted); text-transform: uppercase; font-size: 11px; letter-spacing: .14em; font-weight: 700; }
882
+ table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
883
+ th, td { text-align: left; padding: 16px; border-bottom: 1px solid var(--line); vertical-align: top; }
884
+ th { color: var(--muted); text-transform: uppercase; font-size: 11px; letter-spacing: .14em; }
885
+ tr:last-child td { border-bottom: 0; }
886
+ td small { display: block; color: var(--muted); margin-top: 4px; }
887
+ a { color: var(--accent); text-decoration: none; font-weight: 700; }
888
+ .status { display: inline-flex; border-radius: 999px; padding: 5px 9px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
889
+ .status.passed { color: var(--ok); background: rgba(54, 211, 153, .12); }
890
+ .status.failed { color: var(--bad); background: rgba(255, 93, 122, .12); }
891
+ .muted { color: var(--muted); }
892
+ @media (max-width: 760px) {
893
+ main { width: min(100% - 28px, 1180px); padding-top: 28px; }
894
+ header { display: block; }
895
+ .badge { margin-top: 22px; }
896
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
897
+ .metric:nth-child(2) { border-right: 0; }
898
+ table, thead, tbody, tr, th, td { display: block; }
899
+ thead { display: none; }
900
+ tr { border-bottom: 1px solid var(--line); }
901
+ td { border-bottom: 0; padding: 12px 14px; }
902
+ }
903
+ </style>
904
+ </head>
905
+ <body>
906
+ <main>
907
+ <header>
908
+ <div>
909
+ <h1>Reporte de coleccion</h1>
910
+ <p>${escapeHtml(metadata.collectionLabel)} ejecutada con hasta ${metadata.concurrency} misiones simultaneas.</p>
911
+ </div>
912
+ <div class="badge">
913
+ <small>Estado general</small>
914
+ <strong>${failed > 0 ? 'Ejecucion con errores' : 'Ejecucion exitosa'}</strong>
915
+ <p>${escapeHtml(startedAt)} - ${escapeHtml(finishedAt)}</p>
916
+ </div>
917
+ </header>
918
+ <section class="grid">
919
+ <div class="metric"><b>${summary.length}</b><span>Misiones</span></div>
920
+ <div class="metric"><b>${passed}</b><span>Exitosas</span></div>
921
+ <div class="metric"><b>${failed}</b><span>Errores</span></div>
922
+ <div class="metric"><b>${Math.round(durationMs / 1000)}s</b><span>Tiempo acumulado</span></div>
923
+ </section>
924
+ <table>
925
+ <thead>
926
+ <tr>
927
+ <th>Estado</th>
928
+ <th>Mision</th>
929
+ <th>Razon</th>
930
+ <th>Duracion</th>
931
+ <th>Evidencia</th>
932
+ </tr>
933
+ </thead>
934
+ <tbody>${rows}</tbody>
935
+ </table>
936
+ </main>
937
+ </body>
938
+ </html>`;
939
+
940
+ fs.writeFileSync(reportPath, html);
941
+ return { summaryPath, reportPath };
942
+ }
943
+
944
+ function openLocalFile(filePath) {
945
+ if (!filePath || !fs.existsSync(filePath)) return false;
946
+
947
+ try {
948
+ if (process.platform === 'win32') {
949
+ const child = spawn('cmd.exe', ['/d', '/s', '/c', 'start', '', filePath], {
950
+ detached: true,
951
+ stdio: 'ignore',
952
+ windowsHide: true
953
+ });
954
+ child.unref();
955
+ } else if (process.platform === 'darwin') {
956
+ const child = spawn('open', [filePath], { detached: true, stdio: 'ignore' });
957
+ child.unref();
958
+ } else {
959
+ const child = spawn('xdg-open', [filePath], { detached: true, stdio: 'ignore' });
960
+ child.unref();
961
+ }
962
+ return true;
963
+ } catch {
964
+ return false;
965
+ }
966
+ }
967
+
968
+ function openCollectionReport(reportPath) {
969
+ if (!reportPath || !fs.existsSync(reportPath) || process.env.CI === 'true') return;
970
+ if (!process.stdin.isTTY) return;
971
+
972
+ openLocalFile(reportPath);
973
+ }
974
+
649
975
  async function runCollectionBatch({
650
976
  missionsRootDir,
651
977
  selectedCollection = null,
652
- debug = false
978
+ debug = false,
979
+ concurrency = 1,
980
+ interactive = false
653
981
  }) {
654
982
  const collections = listMissionCollections(missionsRootDir);
655
983
  if (collections.length === 0) {
656
984
  note(chalk.yellow('No hay colecciones con misiones guardadas para ejecutar.'), 'Run All');
657
- return;
985
+ return { summary: [], failed: 0 };
658
986
  }
659
987
 
660
988
  let targetCollections = collections;
@@ -664,7 +992,7 @@ async function runCollectionBatch({
664
992
 
665
993
  if (targetCollections.length === 0) {
666
994
  note(chalk.yellow('La coleccion solicitada no existe o no contiene misiones.'), 'Run All');
667
- return;
995
+ return { summary: [], failed: 0 };
668
996
  }
669
997
 
670
998
  const missionsToRun = [];
@@ -681,38 +1009,114 @@ async function runCollectionBatch({
681
1009
 
682
1010
  if (missionsToRun.length === 0) {
683
1011
  note(chalk.yellow('No se encontraron misiones ejecutables en la seleccion actual.'), 'Run All');
684
- return;
1012
+ return { summary: [], failed: 0 };
685
1013
  }
686
1014
 
1015
+ const requestedConcurrency = normalizeCollectionConcurrency(
1016
+ concurrency,
1017
+ interactive ? COLLECTION_DEFAULT_CONCURRENCY : 1
1018
+ );
1019
+ const startedAt = new Date().toISOString();
1020
+ const runId = createCollectionRunId();
1021
+ const envInfo = setupEnvironment();
1022
+ const runDir = path.join(envInfo.configDir, 'runs', runId);
1023
+ const missionsRunDir = path.join(runDir, 'missions');
1024
+ const collectionLabel = selectedCollection && selectedCollection !== '__ALL__'
1025
+ ? (targetCollections[0]?.label || selectedCollection)
1026
+ : `Todas las colecciones (${targetCollections.length})`;
1027
+
1028
+ if (!fs.existsSync(missionsRunDir)) fs.mkdirSync(missionsRunDir, { recursive: true });
1029
+
687
1030
  const sBatch = spinner();
688
1031
  if (!debug) {
689
- sBatch.start(`Ejecutando ${missionsToRun.length} misiones de ${targetCollections.length} coleccion(es)...`);
1032
+ sBatch.start(`Preparando ${missionsToRun.length} misiones de ${targetCollections.length} coleccion(es)...`);
690
1033
  }
691
1034
 
692
1035
  const summary = [];
1036
+ const runnableMissions = [];
1037
+ const usedSlugs = new Set();
1038
+
693
1039
  for (let index = 0; index < missionsToRun.length; index++) {
694
1040
  const missionEntry = missionsToRun[index];
695
1041
  const rawMission = readYamlFile(missionEntry.fullPath);
696
1042
  const missionSpec = normalizeMissionSpec(rawMission, missionEntry.fullPath);
697
1043
  const collectionMeta = readCollectionMeta(missionEntry.collection.dir, missionEntry.collection.value);
698
1044
  const discoverPath = getMissionStartPath(missionSpec, collectionMeta, '/');
699
- const contextDir = process.env.CONTEXT_DIR;
700
- const knownLogFiles = new Set(listMissionLogFiles(contextDir));
1045
+ const slug = createUniqueMissionSlug(missionSpec, missionEntry.file, index, usedSlugs);
1046
+ const missionDir = path.join(missionsRunDir, slug);
1047
+ const contextDir = path.join(missionDir, 'context');
1048
+ const reportsDir = path.join(missionDir, 'reports');
1049
+ const playwrightOutputDir = path.join(missionDir, 'playwright-output');
701
1050
 
702
1051
  if (!missionSpec.prompt) {
703
1052
  summary.push({
1053
+ index,
704
1054
  name: missionSpec.name || missionEntry.file,
705
1055
  collection: missionEntry.collection.label,
706
1056
  success: false,
707
- reason: 'missing_prompt'
1057
+ reason: 'missing_prompt',
1058
+ durationMs: 0,
1059
+ reportPath: null,
1060
+ contextDir,
1061
+ reportsDir,
1062
+ playwrightOutputDir
708
1063
  });
709
1064
  continue;
710
1065
  }
711
1066
 
1067
+ runnableMissions.push({
1068
+ index,
1069
+ missionEntry,
1070
+ missionSpec,
1071
+ collectionMeta,
1072
+ discoverPath,
1073
+ slug,
1074
+ missionDir,
1075
+ contextDir,
1076
+ reportsDir,
1077
+ playwrightOutputDir,
1078
+ name: missionSpec.name || missionEntry.file,
1079
+ collectionLabel: missionEntry.collection.label
1080
+ });
1081
+ }
1082
+
1083
+ const runnerPrebuilt = runnableMissions.length > 0
1084
+ ? await buildRunnerForCollection(debug, sBatch)
1085
+ : false;
1086
+ let effectiveConcurrency = Math.min(requestedConcurrency, Math.max(1, runnableMissions.length));
1087
+
1088
+ if (!runnerPrebuilt && effectiveConcurrency > 1) {
1089
+ effectiveConcurrency = 1;
1090
+ note(
1091
+ 'No se pudo precompilar el runner antes del lote. Para evitar compilaciones simultaneas, esta coleccion se ejecutara de forma secuencial.',
1092
+ 'Concurrencia ajustada'
1093
+ );
1094
+ }
1095
+
1096
+ let completed = 0;
1097
+ const runnableResults = await runWithConcurrency(runnableMissions, effectiveConcurrency, async (mission) => {
1098
+ const startedMissionAt = Date.now();
1099
+
712
1100
  if (!debug) {
713
- sBatch.message(`[${index + 1}/${missionsToRun.length}] ${missionSpec.name} (${missionEntry.collection.label})`);
1101
+ sBatch.message(`[${completed}/${runnableMissions.length}] En curso hasta ${effectiveConcurrency}. Iniciando ${mission.name}`);
714
1102
  } else {
715
- console.log(chalk.cyan(`[RUN-ALL] ${index + 1}/${missionsToRun.length} -> ${missionSpec.name} [${missionEntry.collection.label}]`));
1103
+ console.log(chalk.cyan(`[RUN-ALL] ${mission.index + 1}/${missionsToRun.length} -> ${mission.name} [${mission.collectionLabel}]`));
1104
+ }
1105
+
1106
+ const childEnv = {
1107
+ ...process.env,
1108
+ ARCALITY_ROOT: PROJECT_ROOT,
1109
+ ARCALITY_BATCH_MODE: 'true',
1110
+ ARCALITY_RUN_DOMAIN_DIR: mission.missionDir,
1111
+ ARCALITY_RUN_CONTEXT_DIR: mission.contextDir,
1112
+ ARCALITY_RUN_REPORTS_DIR: mission.reportsDir,
1113
+ ARCALITY_RUN_PLAYWRIGHT_OUTPUT_DIR: mission.playwrightOutputDir,
1114
+ ARCALITY_COLLECTION_RUN_ID: runId,
1115
+ ARCALITY_COLLECTION_MISSION_SLUG: mission.slug
1116
+ };
1117
+
1118
+ if (runnerPrebuilt) {
1119
+ childEnv.ARCALITY_RUNNER_PREBUILT = 'true';
716
1120
  }
717
1121
 
718
1122
  try {
@@ -721,40 +1125,66 @@ async function runCollectionBatch({
721
1125
  '--agent',
722
1126
  '--batch',
723
1127
  '--skip-save-prompt',
724
- `--discover=${discoverPath}`,
725
- missionSpec.prompt
1128
+ `--discover=${mission.discoverPath}`,
1129
+ mission.missionSpec.prompt
726
1130
  ], {
727
1131
  cwd: ORIGINAL_CWD,
728
- env: {
729
- ...process.env,
730
- ARCALITY_ROOT: PROJECT_ROOT,
731
- ARCALITY_BATCH_MODE: 'true'
732
- }
1132
+ env: childEnv,
1133
+ streamOutput: debug,
1134
+ lastRunLog: path.join(mission.missionDir, 'last-run.log')
733
1135
  });
734
1136
 
735
- const latestLog = readLatestMissionLog(contextDir, knownLogFiles);
1137
+ const latestLog = readMissionResult(mission.missionDir, mission.contextDir);
736
1138
  const success = latestLog?.data?.success === true;
737
- summary.push({
738
- name: missionSpec.name || missionEntry.file,
739
- collection: missionEntry.collection.label,
1139
+ return {
1140
+ index: mission.index,
1141
+ name: mission.name,
1142
+ collection: mission.collectionLabel,
740
1143
  success,
741
1144
  reason: latestLog?.data?.fail_reason || null
742
- });
1145
+ || (!latestLog ? 'missing_mission_log' : null),
1146
+ durationMs: Date.now() - startedMissionAt,
1147
+ reportPath: path.join(mission.reportsDir, 'index.html'),
1148
+ contextDir: mission.contextDir,
1149
+ reportsDir: mission.reportsDir
1150
+ };
743
1151
  } catch (error) {
744
- summary.push({
745
- name: missionSpec.name || missionEntry.file,
746
- collection: missionEntry.collection.label,
1152
+ return {
1153
+ index: mission.index,
1154
+ name: mission.name,
1155
+ collection: mission.collectionLabel,
747
1156
  success: false,
748
- reason: error instanceof Error ? error.message : 'runner_error'
749
- });
1157
+ reason: error instanceof Error ? error.message : 'runner_error',
1158
+ durationMs: Date.now() - startedMissionAt,
1159
+ reportPath: path.join(mission.reportsDir, 'index.html'),
1160
+ contextDir: mission.contextDir,
1161
+ reportsDir: mission.reportsDir
1162
+ };
1163
+ } finally {
1164
+ completed += 1;
1165
+ if (!debug) {
1166
+ sBatch.message(`[${completed}/${runnableMissions.length}] Misiones terminadas. Concurrencia ${effectiveConcurrency}.`);
1167
+ }
750
1168
  }
751
- }
1169
+ });
1170
+
1171
+ summary.push(...runnableResults.filter(Boolean));
1172
+ summary.sort((a, b) => a.index - b.index);
752
1173
 
753
1174
  const passed = summary.filter(item => item.success).length;
754
1175
  const failed = summary.length - passed;
1176
+ const artifacts = writeCollectionArtifacts(runDir, summary, {
1177
+ runId,
1178
+ collectionLabel,
1179
+ concurrency: effectiveConcurrency,
1180
+ startedAt
1181
+ });
755
1182
 
756
1183
  if (!debug) {
757
- sBatch.stop(chalk.green(`Coleccion ejecutada. Exitosas: ${passed} | Fallidas: ${failed}`));
1184
+ const finalMessage = failed > 0
1185
+ ? chalk.yellow(`Coleccion ejecutada. Exitosas: ${passed} | Fallidas: ${failed}`)
1186
+ : chalk.green(`Coleccion ejecutada. Exitosas: ${passed} | Fallidas: ${failed}`);
1187
+ sBatch.stop(finalMessage);
758
1188
  }
759
1189
 
760
1190
  const lines = summary.map(item => {
@@ -763,288 +1193,311 @@ async function runCollectionBatch({
763
1193
  return `- [${status}] ${item.collection} -> ${item.name}${reason}`;
764
1194
  });
765
1195
  note(lines.join('\n'), 'Resumen Run All');
1196
+ note(
1197
+ `Resumen JSON: ${artifacts.summaryPath}\nReporte HTML: ${artifacts.reportPath}\nNota: la ejecucion paralela abre varias sesiones del portal. Arcality no puede validar reglas internas de sesion, bloqueo de usuario o datos compartidos del sistema probado.`,
1198
+ 'Evidencia de coleccion'
1199
+ );
1200
+
1201
+ openCollectionReport(artifacts.reportPath);
1202
+ return { summary, failed, runDir, reportPath: artifacts.reportPath };
766
1203
  }
767
1204
 
768
1205
  function showBanner() {
769
- console.clear();
770
-
771
- let version = 'unknown';
772
- try {
773
- const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
774
- version = pkg.version || 'unknown';
775
- } catch { }
776
-
777
- const sentinelAscii = `
778
- █████ ██████ ██████ █████ ██ ██ ████████ ██ ██
779
- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
780
- ███████ ██████ ██ ███████ ██ ██ ██ ████
781
- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
782
- ██ ██ ██ ██ ██████ ██ ██ ███████ ██ ██ ██ `;
783
-
784
- intro(chalk.gray(sentinelAscii));
785
-
786
- note(
787
- chalk.gray('◉ Motor de Observación: ') + chalk.bold.white('EN LÍNEA') + '\n' +
788
- chalk.gray('◉ Módulo de Recuperación de UI: ') + chalk.bold.white('ACTIVO') + '\n' +
789
- chalk.gray('◉ Lógica Forense: ') + chalk.bold.white('ACTIVA') + '\n' +
790
- chalk.gray('─'.repeat(50)) + '\n' +
791
- chalk.gray('Versión del Sistema: ') + chalk.white(`v${version}`),
792
- 'Estado Central de Arcality'
793
- );
794
-
795
- const { configName, baseUrl, techStack, configDir } = setupEnvironment();
796
-
797
- // Show arcality.config status instead of multi-config switching
798
- const hasConfig = !!projectConfig;
799
- const configStatus = hasConfig
800
- ? chalk.green('◉ Sincronización Establecida')
801
- : chalk.red('⚠ Desconectado — Ejecuta `arcality init`');
802
-
803
- const { key: apiKeyVal, source: apiKeySource } = getApiKey();
804
- const apiKeyDisplay = apiKeyVal
805
- ? chalk.green(maskApiKey(apiKeyVal)) + chalk.gray(` [${apiKeySource}]`)
806
- : chalk.red('NO VERIFICADO');
807
-
808
- const infoLines = [
809
- chalk.gray('Nodo Objetivo: ') + chalk.white(process.env.NODE_ENV ?? 'development'),
810
- chalk.gray('Token de Autenticación: ') + apiKeyDisplay,
811
- chalk.gray('Stack: ') + chalk.white(techStack),
812
- ];
813
-
814
- if (hasConfig) {
815
- infoLines.splice(1, 0,
816
- chalk.gray('Proyecto: ') + chalk.white(configName) + chalk.gray(` [${baseUrl}]`),
817
- chalk.gray('ID de Identidad: ') + chalk.white(projectConfig.projectId || 'N/A'),
818
- );
819
- } else {
820
- infoLines.push(chalk.gray('Configuración: ') + configStatus);
821
- }
822
-
823
- note(infoLines.join('\n'), 'Telemetría del Objetivo');
824
- }
825
-
826
- const ARCALITY_BRAIN_URL = "https://api.anthropic.com/v1/messages";
827
- const ARCALITY_MODEL = process.env.CLAUDE_MODEL || "claude-4-5-sonnet-latest";
828
- const OUT_DIR = path.join(PROJECT_ROOT, "tests");
829
- const LOGS_DIR = path.join(PROJECT_ROOT, "logs");
830
-
831
- async function arcalityChat(messages) {
832
- const internalApiUrl = getApiUrl();
833
- const isProxyMode = !!internalApiUrl;
834
-
835
- if (!process.env.ANTHROPIC_API_KEY && !isProxyMode) {
836
- throw new Error("Missing ANTHROPIC_API_KEY in .env");
837
- }
838
-
839
- const systemMessage = messages.find(m => m.role === 'system');
840
- const userMessages = messages.filter(m => m.role !== 'system');
841
-
842
- try {
843
- const endpointUrl = isProxyMode
844
- ? `${internalApiUrl}/api/v1/ai/proxy`
845
- : ARCALITY_BRAIN_URL;
846
-
847
- const headers = {
848
- "Content-Type": "application/json"
849
- };
850
-
851
- if (isProxyMode) {
852
- headers["x-api-key"] = process.env.ARCALITY_API_KEY || "";
853
- const missionId = process.env.ARCALITY_MISSION_ID || '';
854
- if (missionId) headers["x-mission-id"] = missionId;
855
- } else {
856
- headers["x-api-key"] = process.env.ANTHROPIC_API_KEY;
857
- headers["anthropic-version"] = "2023-06-01";
858
- }
859
-
860
- const res = await fetch(endpointUrl, {
861
- method: "POST",
862
- headers,
863
- body: JSON.stringify({
864
- model: ARCALITY_MODEL,
865
- system: systemMessage ? systemMessage.content : undefined,
866
- messages: userMessages,
867
- max_tokens: 4000,
868
- temperature: 0.3
869
- })
870
- });
871
-
872
- if (!res.ok) {
873
- const txt = await res.text();
874
- throw new Error(`Arcality Brain Error ${res.status}: ${txt}`);
875
- }
876
-
877
- const data = await res.json();
878
- const content = data.content?.[0]?.text || "";
879
-
880
- try {
881
- if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
882
- fs.writeFileSync(path.join(LOGS_DIR, 'last-ai-response.txt'), content);
883
- } catch (e) { /* ignore */ }
884
-
885
- return content;
886
- } catch (err) {
887
- throw new Error(`Error communicating with Arcality brain: ${err.message}`);
888
- }
889
- }
890
-
891
- function extractBlocks(text) {
892
- const cleaned = text
893
- .replace(/```[a-zA-Z]*\n?/g, "")
894
- .replace(/```/g, "")
895
- .trim();
896
-
897
- const fnMatch = cleaned.match(/^FILENAME:\s*(.+)$/m);
898
- const contentIdx = cleaned.indexOf("CONTENT:");
899
-
900
- if (!fnMatch || contentIdx === -1) {
901
- throw new Error(
902
- `Invalid format. Expected:\nFILENAME: <file.spec.ts>\nCONTENT:\n<code>\n\nResponse:\n${cleaned}`
903
- );
904
- }
905
-
906
- const filename = fnMatch[1].trim();
907
- const content = cleaned.slice(contentIdx + "CONTENT:".length).trim();
908
-
909
- return { filename, content, cleaned };
910
- }
911
-
912
- function validateSpec(ts) {
913
- const errors = [];
914
- if (!/test\s*\(.*async\s*\(\s*\{\s*page\s*\}\s*\)\s*=>/s.test(ts)) {
915
- errors.push('Missing fixture: test("...", async ({ page }) => { ... })');
916
- }
917
- if (/from\s+['"]playwright['"]/.test(ts)) {
918
- errors.push("Forbidden to import from core. Use only the Arcality SDK.");
919
- }
920
- if (!ts.includes("import { test, expect } from '@playwright/test'")) {
921
- errors.push("Must import exactly the Arcality engine.");
922
- }
923
- return errors;
924
- }
925
-
926
- function sanitizeFilename(name, fallback) {
927
- if (typeof name !== "string") return fallback;
928
- const safe = name.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
929
- if (!safe.endsWith(".spec.ts")) return fallback;
930
- return safe;
931
- }
932
-
933
- function run(cmd, args, options = {}) {
934
- const isDebug = process.argv.includes('--debug');
935
- const cwd = options.cwd || process.cwd();
936
-
937
- return new Promise((resolve, reject) => {
938
- // NODE_PATH: let Node resolve modules from both the arcality package and user's project
939
- const appNodeModules = path.join(PROJECT_ROOT, 'node_modules');
940
- const userNodeModules = path.join(ORIGINAL_CWD, 'node_modules');
941
- const existingNodePath = process.env.NODE_PATH || '';
942
- const newNodePath = [appNodeModules, userNodeModules, existingNodePath].filter(Boolean).join(path.delimiter);
943
-
944
- const env = { ...options.env || process.env, NODE_PATH: newNodePath };
945
- if (isDebug) console.log(chalk.gray(`\n[DEBUG] CWD: ${cwd}`));
946
- if (isDebug) console.log(chalk.gray(`[DEBUG] Running: ${cmd} ${args.join(' ')}`));
947
-
948
- // Use standard cross-platform spawn. When shell is false, Node's child_process
949
- // properly wraps arguments with spaces in quotes natively for CreateProcessW on Windows.
950
- //
951
- // CRITICAL (Windows): stdin MUST be 'ignore' (not 'inherit') to prevent cmd.exe from
952
- // showing the "¿Desea terminar el trabajo por lotes (S/N)?" prompt on Ctrl+C.
953
- // When stdin is 'inherit', cmd.exe intercepts Ctrl+C at the console level and kills
954
- // the entire process tree before our SIGINT handler can run, bypassing the finally block.
955
- const p = spawn(cmd, args, {
956
- stdio: isDebug ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
957
- shell: false,
958
- cwd,
959
- env,
960
- });
961
-
962
- // Exponer el proceso hijo para que el cancelHandler pueda matarlo
963
- if (options.onSpawn) options.onSpawn(p);
964
-
965
- let outputBuffer = '';
966
- const handleData = (data) => {
967
- const str = data.toString();
968
- outputBuffer += str;
969
-
970
- if (str.includes('>>ARCALITY_STATUS>>')) {
971
- const lines = str.split('\n');
972
- for (const line of lines) {
973
- if (line.includes('>>ARCALITY_STATUS>>')) {
974
- const status = line.split('>>ARCALITY_STATUS>>')[1].trim();
975
- if (options.onStatus) options.onStatus(status);
976
- } else if (line.trim()) {
977
- process.stdout.write(line + '\n');
978
- }
979
- }
980
- } else {
981
- process.stdout.write(data);
982
- }
983
- };
984
-
985
- if (p.stdout) p.stdout.on('data', handleData);
986
- if (p.stderr) {
987
- p.stderr.on('data', (data) => {
988
- if (isDebug) process.stderr.write(data);
989
- else process.stderr.write(data);
990
- outputBuffer += data.toString();
991
- });
992
- }
993
-
994
- p.on('exit', (code, signal) => {
995
- const lastRunLog = path.join(LOGS_DIR, 'last-run.log');
996
- try {
997
- if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
998
- fs.writeFileSync(lastRunLog, outputBuffer);
999
- } catch (e) { }
1000
-
1001
- // code===0 → clean success
1002
- // code===null + signal killed by signal (Ctrl+C) — treat as non-fatal so finally runs
1003
- // code===1 test failed (Playwright returns 1 on test failure) — also non-fatal
1004
- if (code === 0 || signal !== null || code === 1) resolve();
1005
- else {
1006
- if (isDebug) console.error(chalk.red(`\n[DEBUG] Process exited with code ${code}`));
1007
- reject(new Error(`Process exited with code ${code}`));
1008
- }
1009
- });
1010
-
1011
- p.on('error', (err) => {
1012
- if (isDebug) console.error(chalk.red(`\n[DEBUG] Process failed to spawn: ${err.message}`));
1013
- reject(err);
1014
- });
1015
- });
1016
- }
1017
-
1018
- /**
1019
- * Ping the project to register activity.
1020
- * Uses internal API URL — not user-configurable.
1021
- */
1022
- async function pingProject(projectId, apiKey) {
1023
- if (!projectId || !apiKey) return;
1024
- try {
1025
- await fetch(`${getApiUrl()}/api/v1/projects/${projectId}/ping`, {
1026
- method: 'PATCH',
1027
- headers: { 'x-api-key': apiKey },
1028
- });
1029
- } catch { /* silent */ }
1030
- }
1031
-
1032
- async function main() {
1033
- process.chdir(PROJECT_ROOT);
1034
-
1035
- // Backward compat: initialize .env if ACTIVE_CONFIG missing (legacy)
1036
- let initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1037
- if (!projectConfig && !initialEnv.ACTIVE_CONFIG) {
1038
- updateDotEnvFile({
1039
- ACTIVE_CONFIG: 'Default',
1040
- SAVED_CONFIGS: 'Default',
1041
- Default_URL: initialEnv.BASE_URL || '',
1042
- Default_USER: initialEnv.LOGIN_USER || '',
1043
- Default_PASS: initialEnv.LOGIN_PASSWORD || ''
1044
- });
1045
- initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1046
- }
1047
-
1206
+ console.clear();
1207
+
1208
+ let version = 'unknown';
1209
+ try {
1210
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
1211
+ version = pkg.version || 'unknown';
1212
+ } catch { }
1213
+
1214
+ const sentinelAscii = `
1215
+ █████ ██████ ██████ █████ ██ ██ ████████ ██ ██
1216
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
1217
+ ███████ ██████ ██ ███████ ██ ██ ██ ████
1218
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
1219
+ ██ ██ ██ ██ ██████ ██ ██ ███████ ██ ██ ██ `;
1220
+
1221
+ intro(chalk.gray(sentinelAscii));
1222
+
1223
+ note(
1224
+ chalk.gray('◉ Motor de Observación: ') + chalk.bold.white('EN LÍNEA') + '\n' +
1225
+ chalk.gray('◉ Módulo de Recuperación de UI: ') + chalk.bold.white('ACTIVO') + '\n' +
1226
+ chalk.gray('◉ Lógica Forense: ') + chalk.bold.white('ACTIVA') + '\n' +
1227
+ chalk.gray('─'.repeat(50)) + '\n' +
1228
+ chalk.gray('Versión del Sistema: ') + chalk.white(`v${version}`),
1229
+ 'Estado Central de Arcality'
1230
+ );
1231
+
1232
+ const { configName, baseUrl, techStack, configDir } = setupEnvironment();
1233
+
1234
+ // Show arcality.config status instead of multi-config switching
1235
+ const hasConfig = !!projectConfig;
1236
+ const configStatus = hasConfig
1237
+ ? chalk.green('◉ Sincronización Establecida')
1238
+ : chalk.red('⚠ Desconectado — Ejecuta `arcality init`');
1239
+
1240
+ const { key: apiKeyVal, source: apiKeySource } = getApiKey();
1241
+ const apiKeyDisplay = apiKeyVal
1242
+ ? chalk.green(maskApiKey(apiKeyVal)) + chalk.gray(` [${apiKeySource}]`)
1243
+ : chalk.red('NO VERIFICADO');
1244
+
1245
+ const infoLines = [
1246
+ chalk.gray('Nodo Objetivo: ') + chalk.white(process.env.NODE_ENV ?? 'development'),
1247
+ chalk.gray('Token de Autenticación: ') + apiKeyDisplay,
1248
+ chalk.gray('Stack: ') + chalk.white(techStack),
1249
+ ];
1250
+
1251
+ if (hasConfig) {
1252
+ infoLines.splice(1, 0,
1253
+ chalk.gray('Proyecto: ') + chalk.white(configName) + chalk.gray(` [${baseUrl}]`),
1254
+ chalk.gray('ID de Identidad: ') + chalk.white(projectConfig.projectId || 'N/A'),
1255
+ );
1256
+ } else {
1257
+ infoLines.push(chalk.gray('Configuración: ') + configStatus);
1258
+ }
1259
+
1260
+ note(infoLines.join('\n'), 'Telemetría del Objetivo');
1261
+ }
1262
+
1263
+ const ARCALITY_BRAIN_URL = "https://api.anthropic.com/v1/messages";
1264
+ const ARCALITY_MODEL = process.env.CLAUDE_MODEL || "claude-4-5-sonnet-latest";
1265
+ const OUT_DIR = path.join(PROJECT_ROOT, "tests");
1266
+ const LOGS_DIR = path.join(PROJECT_ROOT, "logs");
1267
+
1268
+ async function arcalityChat(messages) {
1269
+ const internalApiUrl = getApiUrl();
1270
+ const isProxyMode = !!internalApiUrl;
1271
+
1272
+ if (!process.env.ANTHROPIC_API_KEY && !isProxyMode) {
1273
+ throw new Error("Missing ANTHROPIC_API_KEY in .env");
1274
+ }
1275
+
1276
+ const systemMessage = messages.find(m => m.role === 'system');
1277
+ const userMessages = messages.filter(m => m.role !== 'system');
1278
+
1279
+ try {
1280
+ const endpointUrl = isProxyMode
1281
+ ? `${internalApiUrl}/api/v1/ai/proxy`
1282
+ : ARCALITY_BRAIN_URL;
1283
+
1284
+ const headers = {
1285
+ "Content-Type": "application/json"
1286
+ };
1287
+
1288
+ if (isProxyMode) {
1289
+ headers["x-api-key"] = process.env.ARCALITY_API_KEY || "";
1290
+ const missionId = process.env.ARCALITY_MISSION_ID || '';
1291
+ if (missionId) headers["x-mission-id"] = missionId;
1292
+ } else {
1293
+ headers["x-api-key"] = process.env.ANTHROPIC_API_KEY;
1294
+ headers["anthropic-version"] = "2023-06-01";
1295
+ }
1296
+
1297
+ const res = await fetch(endpointUrl, {
1298
+ method: "POST",
1299
+ headers,
1300
+ body: JSON.stringify({
1301
+ model: ARCALITY_MODEL,
1302
+ system: systemMessage ? systemMessage.content : undefined,
1303
+ messages: userMessages,
1304
+ max_tokens: 4000,
1305
+ temperature: 0.3
1306
+ })
1307
+ });
1308
+
1309
+ if (!res.ok) {
1310
+ const txt = await res.text();
1311
+ throw new Error(`Arcality Brain Error ${res.status}: ${txt}`);
1312
+ }
1313
+
1314
+ const data = await res.json();
1315
+ const content = data.content?.[0]?.text || "";
1316
+
1317
+ try {
1318
+ if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
1319
+ fs.writeFileSync(path.join(LOGS_DIR, 'last-ai-response.txt'), content);
1320
+ } catch (e) { /* ignore */ }
1321
+
1322
+ return content;
1323
+ } catch (err) {
1324
+ throw new Error(`Error communicating with Arcality brain: ${err.message}`);
1325
+ }
1326
+ }
1327
+
1328
+ function extractBlocks(text) {
1329
+ const cleaned = text
1330
+ .replace(/```[a-zA-Z]*\n?/g, "")
1331
+ .replace(/```/g, "")
1332
+ .trim();
1333
+
1334
+ const fnMatch = cleaned.match(/^FILENAME:\s*(.+)$/m);
1335
+ const contentIdx = cleaned.indexOf("CONTENT:");
1336
+
1337
+ if (!fnMatch || contentIdx === -1) {
1338
+ throw new Error(
1339
+ `Invalid format. Expected:\nFILENAME: <file.spec.ts>\nCONTENT:\n<code>\n\nResponse:\n${cleaned}`
1340
+ );
1341
+ }
1342
+
1343
+ const filename = fnMatch[1].trim();
1344
+ const content = cleaned.slice(contentIdx + "CONTENT:".length).trim();
1345
+
1346
+ return { filename, content, cleaned };
1347
+ }
1348
+
1349
+ function validateSpec(ts) {
1350
+ const errors = [];
1351
+ if (!/test\s*\(.*async\s*\(\s*\{\s*page\s*\}\s*\)\s*=>/s.test(ts)) {
1352
+ errors.push('Missing fixture: test("...", async ({ page }) => { ... })');
1353
+ }
1354
+ if (/from\s+['"]playwright['"]/.test(ts)) {
1355
+ errors.push("Forbidden to import from core. Use only the Arcality SDK.");
1356
+ }
1357
+ if (!ts.includes("import { test, expect } from '@playwright/test'")) {
1358
+ errors.push("Must import exactly the Arcality engine.");
1359
+ }
1360
+ return errors;
1361
+ }
1362
+
1363
+ function sanitizeFilename(name, fallback) {
1364
+ if (typeof name !== "string") return fallback;
1365
+ const safe = name.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
1366
+ if (!safe.endsWith(".spec.ts")) return fallback;
1367
+ return safe;
1368
+ }
1369
+
1370
+ function run(cmd, args, options = {}) {
1371
+ const isDebug = process.argv.includes('--debug');
1372
+ const cwd = options.cwd || process.cwd();
1373
+ const streamOutput = options.streamOutput !== false || isDebug;
1374
+
1375
+ return new Promise((resolve, reject) => {
1376
+ // NODE_PATH: let Node resolve modules from both the arcality package and user's project
1377
+ const appNodeModules = path.join(PROJECT_ROOT, 'node_modules');
1378
+ const userNodeModules = path.join(ORIGINAL_CWD, 'node_modules');
1379
+ const existingNodePath = process.env.NODE_PATH || '';
1380
+ const newNodePath = [appNodeModules, userNodeModules, existingNodePath].filter(Boolean).join(path.delimiter);
1381
+
1382
+ const env = { ...options.env || process.env, NODE_PATH: newNodePath };
1383
+ if (isDebug) console.log(chalk.gray(`\n[DEBUG] CWD: ${cwd}`));
1384
+ if (isDebug) console.log(chalk.gray(`[DEBUG] Running: ${cmd} ${args.join(' ')}`));
1385
+
1386
+ // Use standard cross-platform spawn. When shell is false, Node's child_process
1387
+ // properly wraps arguments with spaces in quotes natively for CreateProcessW on Windows.
1388
+ //
1389
+ // CRITICAL (Windows): stdin MUST be 'ignore' (not 'inherit') to prevent cmd.exe from
1390
+ // showing the "¿Desea terminar el trabajo por lotes (S/N)?" prompt on Ctrl+C.
1391
+ // When stdin is 'inherit', cmd.exe intercepts Ctrl+C at the console level and kills
1392
+ // the entire process tree before our SIGINT handler can run, bypassing the finally block.
1393
+ const p = spawn(cmd, args, {
1394
+ stdio: isDebug ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
1395
+ shell: false,
1396
+ cwd,
1397
+ env,
1398
+ });
1399
+
1400
+ // Exponer el proceso hijo para que el cancelHandler pueda matarlo
1401
+ if (options.onSpawn) options.onSpawn(p);
1402
+
1403
+ let outputBuffer = '';
1404
+ const handleData = (data) => {
1405
+ const str = data.toString();
1406
+ outputBuffer += str;
1407
+
1408
+ if (str.includes('>>ARCALITY_STATUS>>')) {
1409
+ const lines = str.split('\n');
1410
+ for (const line of lines) {
1411
+ if (line.includes('>>ARCALITY_STATUS>>')) {
1412
+ const status = line.split('>>ARCALITY_STATUS>>')[1].trim();
1413
+ if (options.onStatus) options.onStatus(status);
1414
+ } else if (line.trim() && streamOutput) {
1415
+ process.stdout.write(line + '\n');
1416
+ }
1417
+ }
1418
+ } else if (streamOutput) {
1419
+ process.stdout.write(data);
1420
+ }
1421
+ };
1422
+
1423
+ if (p.stdout) p.stdout.on('data', handleData);
1424
+ if (p.stderr) {
1425
+ p.stderr.on('data', (data) => {
1426
+ if (streamOutput) process.stderr.write(data);
1427
+ outputBuffer += data.toString();
1428
+ });
1429
+ }
1430
+
1431
+ p.on('exit', (code, signal) => {
1432
+ const lastRunLog = options.lastRunLog || path.join(LOGS_DIR, 'last-run.log');
1433
+ try {
1434
+ const lastRunLogDir = path.dirname(lastRunLog);
1435
+ if (!fs.existsSync(lastRunLogDir)) fs.mkdirSync(lastRunLogDir, { recursive: true });
1436
+ fs.writeFileSync(lastRunLog, outputBuffer);
1437
+ } catch (e) { }
1438
+
1439
+ // code===0clean success
1440
+ // code===null + signal killed by signal (Ctrl+C) — treat as non-fatal so finally runs
1441
+ // code===1 test failed (Playwright returns 1 on test failure) — also non-fatal
1442
+ if (code === 0 || signal !== null || code === 1) resolve();
1443
+ else {
1444
+ if (isDebug) console.error(chalk.red(`\n[DEBUG] Process exited with code ${code}`));
1445
+ reject(new Error(`Process exited with code ${code}`));
1446
+ }
1447
+ });
1448
+
1449
+ p.on('error', (err) => {
1450
+ if (isDebug) console.error(chalk.red(`\n[DEBUG] Process failed to spawn: ${err.message}`));
1451
+ reject(err);
1452
+ });
1453
+ });
1454
+ }
1455
+
1456
+ function runNpmScript(args, options = {}) {
1457
+ if (process.platform === 'win32') {
1458
+ return run('cmd.exe', ['/d', '/s', '/c', 'npm.cmd', ...args], options);
1459
+ }
1460
+ return run('npm', args, options);
1461
+ }
1462
+
1463
+ /**
1464
+ * Ping the project to register activity.
1465
+ * Uses internal API URL — not user-configurable.
1466
+ */
1467
+ async function pingProject(projectId, apiKey) {
1468
+ if (!projectId || !apiKey) return;
1469
+ try {
1470
+ await fetch(`${getApiUrl()}/api/v1/projects/${projectId}/ping`, {
1471
+ method: 'PATCH',
1472
+ headers: { 'x-api-key': apiKey },
1473
+ });
1474
+ } catch { /* silent */ }
1475
+ }
1476
+
1477
+ async function main() {
1478
+ process.chdir(PROJECT_ROOT);
1479
+
1480
+ // Backward compat: initialize .env if ACTIVE_CONFIG missing (legacy)
1481
+ let initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1482
+ const envProjectId = initialEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
1483
+ if (envProjectId && !isRealProjectId(envProjectId)) {
1484
+ console.error(chalk.red('\n[FATAL] ARCALITY_PROJECT_ID invalido.'));
1485
+ console.error(chalk.yellow('La herramienta no acepta Project IDs locales o mock.'));
1486
+ console.error(chalk.gray('Ejecuta `arcality init` para registrar el proyecto en Arcality.\n'));
1487
+ process.exit(1);
1488
+ }
1489
+
1490
+ if (!projectConfig && !initialEnv.ACTIVE_CONFIG) {
1491
+ updateDotEnvFile({
1492
+ ACTIVE_CONFIG: 'Default',
1493
+ SAVED_CONFIGS: 'Default',
1494
+ Default_URL: initialEnv.BASE_URL || '',
1495
+ Default_USER: initialEnv.LOGIN_USER || '',
1496
+ Default_PASS: initialEnv.LOGIN_PASSWORD || ''
1497
+ });
1498
+ initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1499
+ }
1500
+
1048
1501
  const argv = process.argv.slice(2);
1049
1502
  let initialDiscoverPath = null;
1050
1503
  let initialSmartMode = false;
@@ -1054,6 +1507,7 @@ async function main() {
1054
1507
  let initialSkipSavePrompt = process.env.ARCALITY_SKIP_SAVE_PROMPT === 'true';
1055
1508
  let initialBatchMode = process.env.ARCALITY_BATCH_MODE === 'true';
1056
1509
  let initialCollectionPath = null;
1510
+ let initialCollectionConcurrency = null;
1057
1511
 
1058
1512
  const dIndex = argv.findIndex(a => a === '--discover' || a.startsWith('--discover='));
1059
1513
  if (dIndex !== -1) {
@@ -1084,16 +1538,26 @@ async function main() {
1084
1538
  }
1085
1539
  argv.splice(collectionIndex, 1);
1086
1540
  }
1541
+ const concurrencyIndex = argv.findIndex(a => a === '--concurrency' || a.startsWith('--concurrency=') || a === '--parallel' || a.startsWith('--parallel='));
1542
+ if (concurrencyIndex !== -1) {
1543
+ const token = argv[concurrencyIndex];
1544
+ if (token.includes('=')) initialCollectionConcurrency = normalizeCollectionConcurrency(token.split('=')[1], 1);
1545
+ else if (argv[concurrencyIndex + 1]) {
1546
+ initialCollectionConcurrency = normalizeCollectionConcurrency(argv[concurrencyIndex + 1], 1);
1547
+ argv.splice(concurrencyIndex + 1, 1);
1548
+ }
1549
+ argv.splice(concurrencyIndex, 1);
1550
+ }
1087
1551
  const debugIndex = argv.findIndex(a => a === '--debug');
1088
1552
  if (debugIndex !== -1) { argv.splice(debugIndex, 1); } // Handled by run() naturally now
1089
-
1090
- const promptArg = argv.join(" ").trim();
1091
- let firstRun = true;
1092
- let globalReportProcess = null;
1093
-
1094
- setupEnvironment();
1095
-
1096
- while (true) {
1553
+
1554
+ const promptArg = argv.join(" ").trim();
1555
+ let firstRun = true;
1556
+ let globalReportProcess = null;
1557
+
1558
+ setupEnvironment();
1559
+
1560
+ while (true) {
1097
1561
  let prompt = firstRun ? promptArg : "";
1098
1562
  let agentMode = firstRun ? initialAgentMode : false;
1099
1563
  let smartMode = firstRun ? initialSmartMode : false;
@@ -1101,6 +1565,7 @@ async function main() {
1101
1565
  let runAllMode = firstRun ? initialRunAllMode : false;
1102
1566
  let templateMode = firstRun ? initialTemplateMode : false;
1103
1567
  let collectionPath = firstRun ? initialCollectionPath : null;
1568
+ let collectionConcurrency = firstRun ? initialCollectionConcurrency : null;
1104
1569
  let skipSavePrompt = firstRun ? initialSkipSavePrompt : false;
1105
1570
  let batchMode = firstRun ? initialBatchMode : false;
1106
1571
 
@@ -1108,31 +1573,31 @@ async function main() {
1108
1573
  if (agentMode) skipMenu = true; // Hard-lock: No menu in agent mode
1109
1574
 
1110
1575
  firstRun = false;
1111
-
1576
+
1112
1577
  if (!skipMenu) {
1113
1578
  showBanner();
1114
1579
  try {
1115
- // ── Build menu options (single config mode) ──
1116
- const missionsDir = process.env.MISSIONS_DIR;
1580
+ // ── Build menu options (single config mode) ──
1581
+ const missionsDir = process.env.MISSIONS_DIR;
1117
1582
  const savedMissionCollections = listMissionCollections(missionsDir);
1118
-
1119
- // Check for YAML files in the yaml output dir from arcality.config
1583
+
1584
+ // Check for YAML files in the yaml output dir from arcality.config
1120
1585
  let projectMissionCollections = [];
1121
- if (projectConfig) {
1122
- const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
1123
- if (fs.existsSync(yamlDir)) {
1586
+ if (projectConfig) {
1587
+ const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
1588
+ if (fs.existsSync(yamlDir)) {
1124
1589
  projectMissionCollections = listMissionCollections(yamlDir);
1125
- }
1126
- }
1127
-
1128
- // Root-level YAML templates
1590
+ }
1591
+ }
1592
+
1593
+ // Root-level YAML templates
1129
1594
  const rootMissionFiles = fs.existsSync(ORIGINAL_CWD)
1130
1595
  ? fs.readdirSync(ORIGINAL_CWD).filter(f => isYamlFile(f))
1131
1596
  : [];
1132
1597
  const hasSavedMissionSources = savedMissionCollections.length || projectMissionCollections.length || rootMissionFiles.length;
1133
1598
  const yamlOutputCollections = projectMissionCollections;
1134
1599
  const rootYamlFiles = rootMissionFiles;
1135
-
1600
+
1136
1601
  const options = [
1137
1602
  { label: '◉ Desplegar Arcality (Prompt)', value: 'agent' },
1138
1603
  ...(savedMissionCollections.length ? [{ label: '▶ Ejecutar colección completa', value: 'run_collection' }] : []),
@@ -1143,7 +1608,7 @@ async function main() {
1143
1608
  { label: '◎ Recalibrar Sistema (init)', value: 'reconfigure' },
1144
1609
  { label: '✕ Terminar Proceso', value: 'exit' }
1145
1610
  ];
1146
-
1611
+
1147
1612
  const menuOptions = options
1148
1613
  .filter(option => !['launch_saved', 'reuse_yaml', 'yaml_list'].includes(option.value))
1149
1614
  .map(option => option.value === 'generate_templates'
@@ -1160,15 +1625,15 @@ async function main() {
1160
1625
  }
1161
1626
 
1162
1627
  const action = await select({
1163
- message: '¿Qué te gustaría hacer?',
1628
+ message: '¿Qué te gustaría hacer?',
1164
1629
  options: menuOptions
1165
1630
  });
1166
-
1167
- if (isCancel(action) || action === 'exit') {
1168
- outro(chalk.cyan('¡Hasta luego!'));
1169
- break;
1170
- }
1171
-
1631
+
1632
+ if (isCancel(action) || action === 'exit') {
1633
+ outro(chalk.cyan('¡Hasta luego!'));
1634
+ break;
1635
+ }
1636
+
1172
1637
  if (action === 'agent') {
1173
1638
  agentMode = true;
1174
1639
  } else if (action === 'run_collection') {
@@ -1204,33 +1669,33 @@ async function main() {
1204
1669
  await waitForEnter('Presiona Enter para volver al menu...');
1205
1670
  continue;
1206
1671
  } else if (action === 'reconfigure') {
1207
- // Launch arcality init in the user's project directory
1208
- const initScript = path.join(PROJECT_ROOT, 'scripts', 'init.mjs');
1209
- try {
1210
- await new Promise((resolve, reject) => {
1211
- const child = spawn('node', [initScript], {
1212
- stdio: 'inherit',
1213
- cwd: ORIGINAL_CWD,
1214
- env: { ...process.env, ARCALITY_ROOT: PROJECT_ROOT }
1215
- });
1216
- child.on('exit', code => code === 0 ? resolve() : reject(new Error(`Init exited with ${code}`)));
1217
- child.on('error', reject);
1218
- });
1219
-
1220
- // ── REFRESH CONFIG ──
1221
- // Recargar el archivo que init.mjs acaba de escribir
1222
- projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
1223
- if (projectConfig) {
1224
- injectConfigToEnv(projectConfig);
1225
- }
1226
- setupEnvironment();
1227
-
1228
- } catch (e) {
1229
- // Mostrar el error claramente antes de que console.clear() lo borre
1230
- console.log(chalk.red(`\n❌ Reconfigure failed: ${e.message}`));
1231
- await new Promise(r => setTimeout(r, 3000));
1232
- }
1233
- continue;
1672
+ // Launch arcality init in the user's project directory
1673
+ const initScript = path.join(PROJECT_ROOT, 'scripts', 'init.mjs');
1674
+ try {
1675
+ await new Promise((resolve, reject) => {
1676
+ const child = spawn('node', [initScript], {
1677
+ stdio: 'inherit',
1678
+ cwd: ORIGINAL_CWD,
1679
+ env: { ...process.env, ARCALITY_ROOT: PROJECT_ROOT }
1680
+ });
1681
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(`Init exited with ${code}`)));
1682
+ child.on('error', reject);
1683
+ });
1684
+
1685
+ // ── REFRESH CONFIG ──
1686
+ // Recargar el archivo que init.mjs acaba de escribir
1687
+ projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
1688
+ if (projectConfig) {
1689
+ injectConfigToEnv(projectConfig);
1690
+ }
1691
+ setupEnvironment();
1692
+
1693
+ } catch (e) {
1694
+ // Mostrar el error claramente antes de que console.clear() lo borre
1695
+ console.log(chalk.red(`\n❌ Reconfigure failed: ${e.message}`));
1696
+ await new Promise(r => setTimeout(r, 3000));
1697
+ }
1698
+ continue;
1234
1699
  } else if (action === 'launch_saved') {
1235
1700
  const selectedMission = await selectMissionYaml(
1236
1701
  missionsDir,
@@ -1262,8 +1727,8 @@ async function main() {
1262
1727
  const sel = await select({
1263
1728
  message: 'Elige el archivo YAML:',
1264
1729
  options: rootYamlFiles.map(f => ({ label: f, value: f }))
1265
- });
1266
- if (isCancel(sel)) continue;
1730
+ });
1731
+ if (isCancel(sel)) continue;
1267
1732
 
1268
1733
  const yamlContent = fs.readFileSync(path.join(PROJECT_ROOT, sel), 'utf8');
1269
1734
  const missionSpec = normalizeMissionSpec(loadYaml(yamlContent), path.join(PROJECT_ROOT, sel));
@@ -1271,9 +1736,9 @@ async function main() {
1271
1736
  discoverPath = getMissionStartPath(missionSpec);
1272
1737
  agentMode = true;
1273
1738
  }
1274
- } catch (e) {
1275
- console.error("Menu error:", e);
1276
- break;
1739
+ } catch (e) {
1740
+ console.error("Menu error:", e);
1741
+ break;
1277
1742
  }
1278
1743
  }
1279
1744
 
@@ -1314,10 +1779,38 @@ async function main() {
1314
1779
  batchCollection = selectedBatchCollection;
1315
1780
  }
1316
1781
 
1782
+ let batchConcurrency = normalizeCollectionConcurrency(
1783
+ collectionConcurrency,
1784
+ skipMenu ? 1 : COLLECTION_DEFAULT_CONCURRENCY
1785
+ );
1786
+
1787
+ if (!skipMenu && process.stdin.isTTY) {
1788
+ note(
1789
+ 'La ejecucion paralela abre varias sesiones del portal al mismo tiempo. Arcality no puede conocer ni validar reglas internas de sesion, bloqueo por usuario, datos compartidos o limites del sistema probado; si tu portal no permite sesiones paralelas con el mismo usuario, usa 1.',
1790
+ 'Sesiones del portal'
1791
+ );
1792
+
1793
+ const selectedConcurrency = await select({
1794
+ message: 'Cuantas misiones quieres ejecutar al mismo tiempo?',
1795
+ options: [
1796
+ { label: `3 simultaneas (recomendado)`, value: 3 },
1797
+ { label: '1 secuencial', value: 1 },
1798
+ { label: '5 simultaneas (maximo)', value: 5 }
1799
+ ]
1800
+ });
1801
+ if (isCancel(selectedConcurrency)) {
1802
+ if (skipMenu) return;
1803
+ continue;
1804
+ }
1805
+ batchConcurrency = normalizeCollectionConcurrency(selectedConcurrency, COLLECTION_DEFAULT_CONCURRENCY);
1806
+ }
1807
+
1317
1808
  await runCollectionBatch({
1318
1809
  missionsRootDir: process.env.MISSIONS_DIR,
1319
1810
  selectedCollection: batchCollection || '__ALL__',
1320
- debug: process.argv.includes('--debug')
1811
+ debug: process.argv.includes('--debug'),
1812
+ concurrency: batchConcurrency,
1813
+ interactive: !skipMenu
1321
1814
  });
1322
1815
 
1323
1816
  if (skipMenu) return;
@@ -1326,443 +1819,495 @@ async function main() {
1326
1819
 
1327
1820
  // --- VALIDATION AND CAPTURE OF PARAMETERS FOR THE AGENT ---
1328
1821
  if (agentMode) {
1329
- if (!prompt || prompt.trim().length < 4) {
1330
- const p = await text({
1331
- message: chalk.cyan('🤖 Misión para Arcality (Requerida):'),
1332
- placeholder: 'Ej., Llena el formulario de registro con datos aleatorios',
1333
- validate: v => {
1334
- if (!v || !v.trim()) return 'La misión es requerida para iniciar.';
1335
- if (v.trim().length < 4) return 'La misión debe ser más descriptiva (mínimo 4 caracteres).';
1336
- }
1337
- });
1338
-
1339
- if (isCancel(p)) {
1340
- cancel('Misión cancelada. Regresando al menú principal...');
1341
- agentMode = false;
1342
- prompt = "";
1343
- skipMenu = false;
1344
- continue;
1345
- }
1346
- prompt = p;
1347
- }
1348
-
1349
- if (!discoverPath) {
1350
- const knownBaseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL;
1351
- const promptMsg = knownBaseUrl
1352
- ? `🌐 Initial navigation path (relative to ${knownBaseUrl}):`
1353
- : '🌐 URL Completa de Navegación (ej., http://localhost:3000/):';
1354
-
1355
- const d = await text({
1356
- message: chalk.cyan(promptMsg),
1357
- placeholder: knownBaseUrl ? '/' : 'http://localhost:3000/',
1358
- validate: v => {
1359
- if (!knownBaseUrl && !v.startsWith('http')) return 'Por favor provee una URL completa con http:// o https://';
1360
- return undefined;
1361
- }
1362
- });
1363
-
1364
- if (isCancel(d)) {
1365
- cancel('Misión abortada. Regresando al menú...');
1366
- agentMode = false;
1367
- prompt = "";
1368
- skipMenu = false;
1369
- continue;
1370
- }
1371
- discoverPath = d;
1372
- }
1373
- }
1374
-
1375
- if (agentMode) {
1376
- // Load environment from BOTH the user's project and the library's root
1377
- const userDotEnv = parseDotEnvFile(path.join(ORIGINAL_CWD, '.env'));
1378
- const libDotEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1379
- const dotEnv = { ...userDotEnv, ...libDotEnv };
1380
-
1381
- // ── API Key and Quota Validation ──
1382
- const { validateApiKey, startMission: requestMission } = await import('../src/arcalityClient.mjs');
1383
-
1384
- const validation = await validateApiKey();
1385
- if (process.env.DEBUG || process.argv.includes('--debug')) {
1386
- console.log(chalk.gray(`[DEBUG] Validation result: ${JSON.stringify(validation, null, 2)}`));
1387
- }
1388
-
1389
- const detectedOrgId = validation.organization_id || validation.organizationId || validation.OrganizationId;
1390
- if (detectedOrgId) {
1391
- process.env.ARCALITY_ORG_ID = detectedOrgId;
1392
- }
1393
- if (!validation.valid) {
1394
- const errorMessages = {
1395
- 'no_api_key': '🔑 API Key no encontrada.\n Configúrala vía `arcality init` o en .env (ARCALITY_API_KEY)',
1396
- 'invalid_format': '🔑 API Key inválida. Debe comenzar con "arc_"',
1397
- 'invalid_api_key': '🔑 API Key no reconocida por el servidor',
1398
- 'plan_expired': '💳 Tu plan ha expirado'
1399
- };
1400
- note(chalk.red(errorMessages[validation.error] || `Error: ${validation.error}`), '❌ Autenticación');
1401
- agentMode = false;
1402
- prompt = "";
1403
- skipMenu = false;
1404
- continue;
1405
- }
1406
-
1407
- // Show plan info
1408
- const modeLabel = validation.mode === 'mock' ? chalk.gray('[sandbox]') : chalk.cyanBright('[live-net]');
1409
- const dailyLimitDisplay = validation.daily_limit === -1 ? '' : (validation.daily_limit || 35);
1410
- const remainingDisplay = validation.remaining >= 999999 ? '∞' : validation.remaining;
1411
- note(
1412
- chalk.gray('Conexión: ') + chalk.cyanBright('SECURE ') + modeLabel + '\n' +
1413
- chalk.gray('Autorización: ') + chalk.white(validation.plan || 'internal') + '\n' +
1414
- chalk.gray('Misiones: ') + chalk.white(`${validation.daily_used || 0}/${dailyLimitDisplay}`) + '\n' +
1415
- chalk.gray('Reservas: ') + chalk.white(remainingDisplay),
1416
- 'Enlace Arcality'
1417
- );
1418
-
1419
- // --- RESOLVE PROJECT ID ---
1420
- let selectedProjectId = projectConfig?.projectId || dotEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
1421
- const apiBase = getApiUrl();
1422
-
1423
- if (!selectedProjectId) {
1424
- // If no project ID from config, try to create/select from backend
1425
- try {
1426
- const projController = new AbortController();
1427
- const projTimeout = setTimeout(() => projController.abort(), 5000);
1428
- const projRes = await fetch(`${apiBase}/api/v1/projects`, {
1429
- headers: { 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1430
- signal: projController.signal
1431
- });
1432
- clearTimeout(projTimeout);
1433
-
1434
- if (projRes.ok) {
1435
- const data = await projRes.json();
1436
- const projects = data.projects || [];
1437
-
1438
- const projOptions = projects.map(p => ({ label: p.name || p.Name, value: p.id || p.Id }));
1439
- projOptions.push({ label: '➕ Crear nuevo proyecto', value: 'new' });
1440
-
1441
- const projSelection = await select({
1442
- message: chalk.cyan('Elige un proyecto para esta misión:'),
1443
- options: projOptions
1444
- });
1445
-
1446
- if (isCancel(projSelection)) {
1447
- cancel('Misión abortada.');
1448
- agentMode = false; prompt = ""; skipMenu = false; continue;
1449
- }
1450
-
1451
- if (projSelection === 'new') {
1452
- const newName = await text({ message: 'Nombre del proyecto:', validate: v => !v ? 'Requerido' : undefined });
1453
- if (isCancel(newName)) { cancel('Abortado'); agentMode = false; prompt = ""; skipMenu = false; continue; }
1454
-
1455
- const createRes = await fetch(`${apiBase}/api/v1/projects`, {
1456
- method: 'POST',
1457
- headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1458
- body: JSON.stringify({
1459
- name: newName,
1460
- base_url: projectConfig?.project?.baseUrl || discoverPath || '/',
1461
- arcality_version: projectConfig?.meta?.arcalityVersion || 'unknown',
1462
- config: { source: 'npm-cli', single_configuration_mode: true, yaml_reuse_enabled: true }
1463
- })
1464
- });
1465
-
1466
- if (createRes.ok) {
1467
- const created = await createRes.json();
1468
- selectedProjectId = created.id || created.Id;
1469
- note(chalk.green(`✅ Proyecto creado: ${newName}`));
1470
- } else {
1471
- note(chalk.red(`❌ Falló al crear proyecto.`));
1472
- }
1473
- } else {
1474
- selectedProjectId = projSelection;
1475
- }
1476
-
1477
- if (selectedProjectId) {
1478
- process.env.ARCALITY_PROJECT_ID = selectedProjectId;
1479
- }
1480
- }
1481
- } catch (e) {
1482
- console.log(chalk.yellow(`\n⚠️ Error connecting to project server: ${e.message}. Using default.`));
1483
- }
1484
- } else {
1485
- process.env.ARCALITY_PROJECT_ID = selectedProjectId;
1486
- }
1487
-
1488
- // ── Start Mission ──
1489
- const mission = await requestMission(prompt, discoverPath || '/');
1490
- if (!mission.allowed) {
1491
- note(
1492
- chalk.red(`❌ ${mission.error === 'mission_limit_exceeded' ? 'Límite diario de misiones alcanzado' : mission.error}`) + '\n' +
1493
- chalk.yellow(`📊 Used: ${mission.daily_used}/${mission.daily_limit}`) + '\n' +
1494
- chalk.gray(`🔄 Resets: ${mission.resets_at ? new Date(mission.resets_at).toLocaleString() : 'tomorrow'}`),
1495
- '⚠️ Límite Alcanzado'
1496
- );
1497
- agentMode = false;
1498
- prompt = "";
1499
- skipMenu = false;
1500
- continue;
1501
- }
1502
-
1503
- const s = spinner();
1504
- if (!process.argv.includes('--debug')) {
1505
- s.start(`◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`);
1506
- } else {
1507
- console.log(chalk.cyan(`\n◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`));
1508
- }
1509
-
1510
- const finalProjectId = process.env.ARCALITY_PROJECT_ID || selectedProjectId;
1511
-
1512
- // ── Cancelación Manual (Ctrl+C) Estrategia bulletproof ──
1513
- // El problema: @clack/prompts registra su propio SIGINT handler cada tick del spinner,
1514
- // que llama a process.exit(0) directamente. removeAllListeners() no es suficiente porque
1515
- // Clack lo re-registra en cada frame de animación.
1516
- //
1517
- // La solución: interceptar process.exit() antes de que Clack lo llame,
1518
- // y usar prependListener para que NUESTRO handler siempre se ejecute primero.
1519
-
1520
- let activeChildProcess = null;
1521
- let userCanceledMission = false;
1522
- let sigintCount = 0;
1523
- let _missionActive = true; // Mientras true, bloqueamos el process.exit de Clack
1524
-
1525
- // Guardar el process.exit original y reemplazarlo temporalmente
1526
- const _origProcessExit = process.exit.bind(process);
1527
- process.exit = (code) => {
1528
- if (_missionActive) {
1529
- // Clack (u otra lib) está intentando matar el proceso — lo bloqueamos.
1530
- // Nuestro cancelHandler ya habrá sido invocado vía prependListener.
1531
- // El proceso se cerrará naturalmente cuando el child process termine.
1532
- return;
1533
- }
1534
- _origProcessExit(code);
1535
- };
1536
-
1537
- // ── RESTORE RAW MODE FOR WINDOWS FIX ──
1538
- // @clack/prompts pausea stdin y desactiva raw mode al terminar un menú.
1539
- // Debemos reactivarlo aquí para que nuestro interceptor global de Ctrl+C
1540
- // siga funcionando durante toda la misión.
1541
- if (process.stdin.isTTY && process.stdin.setRawMode) {
1542
- try {
1543
- process.stdin.setRawMode(true);
1544
- process.stdin.resume();
1545
- } catch(e) {}
1546
- }
1547
-
1548
- const cancelHandler = () => {
1549
- sigintCount++;
1550
- if (sigintCount > 3) {
1551
- console.log(chalk.red('\n🛑 Forzando salida inmediata...'));
1552
- _missionActive = false;
1553
- _origProcessExit(1);
1554
- return;
1555
- }
1556
-
1557
- if (userCanceledMission) {
1558
- console.log(chalk.yellow('\n⏳ Cancelación en progreso, esperando reporte... (Ctrl+C x4 para forzar cierre)'));
1559
- return;
1560
- }
1561
- userCanceledMission = true;
1562
-
1563
- console.log(chalk.yellow('\n\n⚠️ [ARCALITY] Directiva abortada por el operador.'));
1564
- if (!process.argv.includes('--debug')) {
1565
- try { s.stop(chalk.yellow('⚠️ Directiva abortada.')); } catch { /* ignore */ }
1566
- }
1567
-
1568
- // Terminar el proceso hijo de Playwright directamente (Windows-safe)
1569
- if (activeChildProcess && !activeChildProcess.killed) {
1570
- try {
1571
- if (process.platform === 'win32') {
1572
- spawn('taskkill', ['/pid', String(activeChildProcess.pid), '/f', '/t'], { stdio: 'ignore', windowsHide: true });
1573
- } else {
1574
- activeChildProcess.kill('SIGTERM');
1575
- }
1576
- } catch { /* silencioso */ }
1577
- }
1578
-
1579
- try {
1580
- const ctxDir = process.env.CONTEXT_DIR;
1581
- if (ctxDir && fs.existsSync(ctxDir)) {
1582
- const cancelLog = {
1583
- prompt,
1584
- history: ['[ABORTO DEL OPERADOR] La directiva fue detenida manualmente antes de completarse.'],
1585
- steps: 0,
1586
- success: false,
1587
- error: true,
1588
- fail_reason: 'user_canceled',
1589
- canceled_by_user: true,
1590
- timestamp: new Date().toISOString()
1591
- };
1592
- fs.writeFileSync(path.join(ctxDir, `arcality-log-${Date.now()}.json`), JSON.stringify(cancelLog, null, 2));
1593
- }
1594
- } catch { /* silencioso */ }
1595
-
1596
- console.log(chalk.gray(' >> Esperando que Playwright cierre y genere el reporte...'));
1597
- // Playwright también recibe el SIGINT nativamente desde la terminal Windows
1598
- // y cerrará su proceso limpiamente, lo que resolverá la Promise del run() → finally
1599
- };
1600
-
1601
- // prependListener asegura que nuestro handler se ejecuta ANTES que el de Clack
1602
- process.prependListener('SIGINT', cancelHandler);
1603
- process.prependListener('SIGTERM', cancelHandler);
1604
-
1605
-
1606
- // Build env for the runner — merge arcality.config values.
1607
- // ARCALITY_API_URL must be explicit — it comes from the library's internal config,
1608
- // not from the user's project .env, so we inject it directly via getApiUrl().
1609
- const fallbackBaseUrl = (!projectConfig && discoverPath?.startsWith('http')) ? discoverPath : undefined;
1610
-
1611
- const mergedEnv = {
1612
- ...dotEnv,
1613
- ...process.env,
1614
- ARCALITY_API_URL: getApiUrl(), // Internal URL — always injected explicitly
1615
- ARCALITY_PROJECT_ID: finalProjectId,
1616
- ARCALITY_MISSION_ID: mission.mission_id || mission.missionId || mission.MissionId || '', // ← Propagate mission_id to every proxy call
1617
- ARCALITY_ORG_ID: validation.organization_id || validation.organizationId || validation.OrganizationId || process.env.ARCALITY_ORG_ID || '', // ← Propagate org_id for pattern persistence
1618
- BASE_URL: projectConfig?.project?.baseUrl || dotEnv.BASE_URL || process.env.BASE_URL || fallbackBaseUrl || 'http://localhost:3000',
1619
- LOGIN_USER: projectConfig?.auth?.username || dotEnv.LOGIN_USER || process.env.LOGIN_USER,
1620
- LOGIN_PASSWORD: projectConfig?.auth?.password || dotEnv.LOGIN_PASSWORD || process.env.LOGIN_PASSWORD,
1621
- DOMAIN_DIR: process.env.DOMAIN_DIR,
1622
- MISSIONS_DIR: process.env.MISSIONS_DIR,
1623
- CONTEXT_DIR: process.env.CONTEXT_DIR,
1624
- REPORTS_DIR: process.env.REPORTS_DIR,
1625
- SMART_PROMPT: prompt,
1626
- TARGET_PATH: discoverPath || '/'
1627
- };
1628
-
1629
- if (finalProjectId) {
1630
- console.log(chalk.gray(`>> ARCALITY_PROJECT_ID: ${finalProjectId}`));
1631
- console.log(chalk.gray(`>> ARCALITY_ORG_ID: ${mergedEnv.ARCALITY_ORG_ID || 'NONE'}`));
1632
- }
1633
-
1634
- // ── Resolve execution paths ──
1635
- // tsx and playwright are HOISTED to ORIGINAL_CWD/node_modules when installed via npm.
1636
- // We run from ORIGINAL_CWD so relative paths for playwright work.
1637
- // For the test file and config (inside the arcality package), we use absolute paths.
1638
- //
1639
- // KEY: tsx loader uses file:// URL (pathToFileURL) so spaces encode as %20 — no quoting needed.
1640
-
1641
- const testFile = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
1642
- const configFile = path.join(PROJECT_ROOT, 'playwright.config.js');
1643
-
1644
- // Resolve playwright CLI CRITICAL singleton alignment:
1645
- //
1646
- // @playwright/test uses `playwright` internally. When npm detects a version mismatch
1647
- // between the top-level `playwright` and what `@playwright/test` needs, it installs
1648
- // a NESTED copy at: @playwright/test/node_modules/playwright/
1649
- //
1650
- // Playwright's test runner tracks the active suite via a module-level singleton
1651
- // (currentSuite). If the CLI uses a DIFFERENT playwright instance than the one
1652
- // @playwright/test loaded, their singletons are out of sync → "did not expect test()
1653
- // to be called here".
1654
- //
1655
- // Fix: ALWAYS prefer the playwright nested INSIDE @playwright/test, because that is
1656
- // guaranteed to be the same instance that @playwright/test uses for its internals.
1657
- //
1658
- // Resolution priority:
1659
- // 1. <project>/@playwright/test/node_modules/playwright/cli.js ← nested (safe)
1660
- // 2. <project>/playwright/cli.js ← sibling top-level
1661
- // (repeated for ORIGINAL_CWD and PROJECT_ROOT)
1662
- let playwrightCli;
1663
- try {
1664
- const searchPaths = [ORIGINAL_CWD, PROJECT_ROOT];
1665
- let testRunnerPath = null;
1666
- let resolveError = null;
1667
-
1668
- for (const searchPath of searchPaths) {
1669
- try {
1670
- testRunnerPath = require.resolve('@playwright/test', { paths: [searchPath] });
1671
- break;
1672
- } catch (e) {
1673
- resolveError = e;
1674
- }
1675
- }
1676
-
1677
- if (!testRunnerPath) throw resolveError;
1678
-
1679
- const testRunnerDir = path.dirname(testRunnerPath); // → .../node_modules/@playwright/test/
1680
-
1681
- // Priority 1: playwright nested INSIDE @playwright/test — same singleton as the package uses
1682
- const nestedCli = path.join(testRunnerDir, 'node_modules', 'playwright', 'cli.js');
1683
- // Priority 2: playwright as a top-level sibling (clean installs, versions aligned)
1684
- const siblingCli = path.join(testRunnerDir, '..', '..', 'playwright', 'cli.js');
1685
-
1686
- if (fs.existsSync(nestedCli)) {
1687
- playwrightCli = nestedCli;
1688
- } else if (fs.existsSync(siblingCli)) {
1689
- playwrightCli = siblingCli;
1690
- } else {
1691
- throw new Error(
1692
- `Playwright CLI not found. Tried:\n (nested) ${nestedCli}\n (sibling) ${siblingCli}`
1693
- );
1694
- }
1695
-
1696
- if (process.argv.includes('--debug')) {
1697
- console.log(chalk.gray(`[DEBUG] Using playwright CLI: ${playwrightCli}`));
1698
- }
1699
- } catch (e) {
1700
- console.error(chalk.red('\n[FATAL] Núcleo de Playwright no encontrado. Asegúrate de que @playwright/test esté instalado.'));
1701
- console.error(chalk.red(e.message));
1702
- console.error(chalk.yellow('\nConsejo: Ejecuta `npm install @playwright/test` en tu directorio de proyecto.'));
1703
- _missionActive = false;
1704
- process.exit = _origProcessExit;
1705
- _origProcessExit(1);
1706
- }
1707
-
1708
- const postProcessSigint = () => console.log(chalk.yellow('\n⏳ Finalizando reporte de telemetría, por favor espera...'));
1709
- let missionResult = null;
1710
- let finalFailReason = null;
1711
- let finalUsageData = undefined;
1712
- let finalReportUrl = null;
1713
- let finalFailReasoning = null;
1714
-
1715
- // ── Auto-compilar el runner antes de ejecutar Playwright para asegurar que los cambios de rama/merge estén aplicados ──
1716
- try {
1717
- if (!process.argv.includes('--debug')) {
1718
- s.message('⚙️ Compilando agente de pruebas (Arcality Runner)...');
1719
- } else {
1720
- console.log(chalk.cyan('⚙️ Compilando agente de pruebas (Arcality Runner)...'));
1721
- }
1722
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
1723
- await run(npmCmd, ['run', 'build:runner'], { cwd: PROJECT_ROOT });
1724
- } catch (err) {
1725
- console.warn(chalk.yellow(`\n⚠️ Advertencia: No se pudo auto-compilar el runner: ${err.message}`));
1726
- }
1727
-
1728
- try {
1729
- // No test file path is passed — playwright.config.js uses testMatch:'agentic-runner.spec.ts'
1730
- // This eliminates the Windows regex path issue permanently on any user's machine.
1731
- await run('node', [
1732
- '--no-warnings',
1733
- playwrightCli,
1734
- 'test',
1735
- '--headed',
1736
- '--config', 'playwright.config.js'
1737
- ], {
1738
- cwd: PROJECT_ROOT,
1739
- env: mergedEnv,
1740
- onStatus: (msg) => { if (!process.argv.includes('--debug')) s.message(msg); else console.log(chalk.gray(`>> Status: ${msg}`)); },
1741
- onSpawn: (child) => { activeChildProcess = child; } // ← Capturar referencia para cancelHandler
1742
- });
1743
-
1744
- process.removeAllListeners('SIGINT');
1745
- process.removeAllListeners('SIGTERM');
1746
- process.on('SIGINT', postProcessSigint);
1747
- process.on('SIGTERM', postProcessSigint);
1748
-
1749
- if (userCanceledMission) {
1750
- missionResult = 'canceled';
1751
- finalFailReason = 'user_canceled';
1752
- // Detener el spinner explícitamente antes de salir
1753
- if (!process.argv.includes('--debug')) {
1754
- try { s.stop(chalk.yellow('⚠️ Misión cancelada por el usuario.')); } catch { }
1755
- }
1756
- return; // El handler ya tomó el control, finally se ejecutará
1757
- }
1758
-
1759
- missionResult = 'success';
1760
- if (!process.argv.includes('--debug')) s.stop(chalk.green('✅ Misión completada exitosamente.'));
1761
- else console.log(chalk.green('✅ Misión completada exitosamente.'));
1762
-
1763
- // ── Ping Project ──
1764
- await pingProject(finalProjectId, process.env.ARCALITY_API_KEY);
1765
-
1822
+ if (!prompt || prompt.trim().length < 4) {
1823
+ const p = await text({
1824
+ message: chalk.cyan('🤖 Misión para Arcality (Requerida):'),
1825
+ placeholder: 'Ej., Llena el formulario de registro con datos aleatorios',
1826
+ validate: v => {
1827
+ if (!v || !v.trim()) return 'La misión es requerida para iniciar.';
1828
+ if (v.trim().length < 4) return 'La misión debe ser más descriptiva (mínimo 4 caracteres).';
1829
+ }
1830
+ });
1831
+
1832
+ if (isCancel(p)) {
1833
+ cancel('Misión cancelada. Regresando al menú principal...');
1834
+ agentMode = false;
1835
+ prompt = "";
1836
+ skipMenu = false;
1837
+ continue;
1838
+ }
1839
+ prompt = p;
1840
+ }
1841
+
1842
+ if (!discoverPath) {
1843
+ const knownBaseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL;
1844
+ const promptMsg = knownBaseUrl
1845
+ ? `🌐 URL Completa de Navegación (relative to ${knownBaseUrl}):`
1846
+ : '🌐 URL Completa de Navegación (ej., http://localhost:3000/):';
1847
+
1848
+ const d = await text({
1849
+ message: chalk.cyan(promptMsg),
1850
+ placeholder: knownBaseUrl ? '/' : 'http://localhost:3000/',
1851
+ validate: v => {
1852
+ if (!knownBaseUrl && !v.startsWith('http')) return 'Por favor provee una URL completa con http:// o https://';
1853
+ return undefined;
1854
+ }
1855
+ });
1856
+
1857
+ if (isCancel(d)) {
1858
+ cancel('Misión abortada. Regresando al menú...');
1859
+ agentMode = false;
1860
+ prompt = "";
1861
+ skipMenu = false;
1862
+ continue;
1863
+ }
1864
+ discoverPath = d;
1865
+ }
1866
+ }
1867
+
1868
+ if (agentMode) {
1869
+ // Load environment from BOTH the user's project and the library's root
1870
+ const userDotEnv = parseDotEnvFile(path.join(ORIGINAL_CWD, '.env'));
1871
+ const libDotEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1872
+ const dotEnv = { ...userDotEnv, ...libDotEnv };
1873
+
1874
+ // ── API Key and Quota Validation ──
1875
+ const { validateApiKey, startMission: requestMission } = await import('../src/arcalityClient.mjs');
1876
+
1877
+ const validation = await validateApiKey();
1878
+ if (process.env.DEBUG || process.argv.includes('--debug')) {
1879
+ console.log(chalk.gray(`[DEBUG] Validation result: ${JSON.stringify(validation, null, 2)}`));
1880
+ }
1881
+
1882
+ const detectedOrgId = validation.organization_id || validation.organizationId || validation.OrganizationId;
1883
+ if (detectedOrgId) {
1884
+ process.env.ARCALITY_ORG_ID = detectedOrgId;
1885
+ }
1886
+ if (!validation.valid) {
1887
+ const errorMessages = {
1888
+ 'no_api_key': '🔑 API Key no encontrada.\n Configúrala vía `arcality init` o en .env (ARCALITY_API_KEY)',
1889
+ 'invalid_format': '🔑 API Key inválida. Debe comenzar con "arc_"',
1890
+ 'invalid_api_key': '🔑 API Key no reconocida por el servidor',
1891
+ 'plan_expired': '💳 Tu plan ha expirado',
1892
+ 'backend_unavailable': 'No se pudo conectar con Arcality. La herramienta requiere conexion al backend para validar la cuenta.'
1893
+ };
1894
+ note(chalk.red(errorMessages[validation.error] || `Error: ${validation.error}`), '❌ Autenticación');
1895
+ agentMode = false;
1896
+ prompt = "";
1897
+ skipMenu = false;
1898
+ continue;
1899
+ }
1900
+
1901
+ // Show plan info
1902
+ const modeLabel = validation.mode === 'mock' ? chalk.gray('[sandbox]') : chalk.cyanBright('[live-net]');
1903
+ const dailyLimitDisplay = validation.daily_limit === -1 ? '∞' : (validation.daily_limit || 35);
1904
+ const remainingDisplay = validation.remaining >= 999999 ? '∞' : validation.remaining;
1905
+ note(
1906
+ chalk.gray('Conexión: ') + chalk.cyanBright('SECURE ') + modeLabel + '\n' +
1907
+ chalk.gray('Autorización: ') + chalk.white(validation.plan || 'internal') + '\n' +
1908
+ chalk.gray('Misiones: ') + chalk.white(`${validation.daily_used || 0}/${dailyLimitDisplay}`) + '\n' +
1909
+ chalk.gray('Reservas: ') + chalk.white(remainingDisplay),
1910
+ 'Enlace Arcality'
1911
+ );
1912
+
1913
+ // --- RESOLVE PROJECT ID ---
1914
+ let selectedProjectId = projectConfig?.projectId || dotEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
1915
+ const apiBase = getApiUrl();
1916
+
1917
+ if (selectedProjectId && !isRealProjectId(selectedProjectId)) {
1918
+ note(
1919
+ chalk.red('Project ID invalido: la herramienta no puede ejecutarse con IDs locales o mock.') + '\n' +
1920
+ chalk.gray('Ejecuta `arcality init` para registrar este proyecto en Arcality.'),
1921
+ 'Proyecto no conectado'
1922
+ );
1923
+ agentMode = false;
1924
+ prompt = "";
1925
+ skipMenu = false;
1926
+ continue;
1927
+ }
1928
+
1929
+ if (!selectedProjectId) {
1930
+ // If no project ID from config, try to create/select from backend
1931
+ try {
1932
+ const projController = new AbortController();
1933
+ const projTimeout = setTimeout(() => projController.abort(), 5000);
1934
+ const projRes = await fetch(`${apiBase}/api/v1/projects`, {
1935
+ headers: { 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1936
+ signal: projController.signal
1937
+ });
1938
+ clearTimeout(projTimeout);
1939
+
1940
+ if (projRes.ok) {
1941
+ const data = await projRes.json();
1942
+ const projects = data.projects || [];
1943
+
1944
+ const projOptions = projects.map(p => ({ label: p.name || p.Name, value: p.id || p.Id }));
1945
+ projOptions.push({ label: ' Crear nuevo proyecto', value: 'new' });
1946
+
1947
+ const projSelection = await select({
1948
+ message: chalk.cyan('Elige un proyecto para esta misión:'),
1949
+ options: projOptions
1950
+ });
1951
+
1952
+ if (isCancel(projSelection)) {
1953
+ cancel('Misión abortada.');
1954
+ agentMode = false; prompt = ""; skipMenu = false; continue;
1955
+ }
1956
+
1957
+ if (projSelection === 'new') {
1958
+ const newName = await text({ message: 'Nombre del proyecto:', validate: v => !v ? 'Requerido' : undefined });
1959
+ if (isCancel(newName)) { cancel('Abortado'); agentMode = false; prompt = ""; skipMenu = false; continue; }
1960
+
1961
+ const createRes = await fetch(`${apiBase}/api/v1/projects`, {
1962
+ method: 'POST',
1963
+ headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1964
+ body: JSON.stringify({
1965
+ name: newName,
1966
+ base_url: projectConfig?.project?.baseUrl || discoverPath || '/',
1967
+ arcality_version: projectConfig?.meta?.arcalityVersion || 'unknown',
1968
+ config: { source: 'npm-cli', single_configuration_mode: true, yaml_reuse_enabled: true }
1969
+ })
1970
+ });
1971
+
1972
+ if (createRes.ok) {
1973
+ const created = await createRes.json();
1974
+ selectedProjectId = created.id || created.Id;
1975
+ if (!isRealProjectId(selectedProjectId)) {
1976
+ note(chalk.red('El backend no devolvio un Project ID valido.'), 'Proyecto no conectado');
1977
+ selectedProjectId = null;
1978
+ }
1979
+ if (selectedProjectId) note(chalk.green(`✅ Proyecto creado: ${newName}`));
1980
+ } else {
1981
+ note(chalk.red(`❌ Falló al crear proyecto.`));
1982
+ }
1983
+ } else {
1984
+ selectedProjectId = projSelection;
1985
+ }
1986
+
1987
+ if (selectedProjectId) {
1988
+ process.env.ARCALITY_PROJECT_ID = selectedProjectId;
1989
+ }
1990
+ }
1991
+ } catch (e) {
1992
+ note(
1993
+ chalk.red(`No se pudo conectar con el servidor de proyectos: ${e.message}`) + '\n' +
1994
+ chalk.gray('Arcality requiere un proyecto real conectado antes de ejecutar misiones.'),
1995
+ 'Proyecto no conectado'
1996
+ );
1997
+ }
1998
+ } else {
1999
+ process.env.ARCALITY_PROJECT_ID = selectedProjectId;
2000
+ }
2001
+
2002
+ if (!isRealProjectId(selectedProjectId)) {
2003
+ note(
2004
+ chalk.red('No hay un Project ID real asignado para esta ejecucion.') + '\n' +
2005
+ chalk.gray('Ejecuta `arcality init` y vuelve a intentar.'),
2006
+ 'Proyecto no conectado'
2007
+ );
2008
+ agentMode = false;
2009
+ prompt = "";
2010
+ skipMenu = false;
2011
+ continue;
2012
+ }
2013
+
2014
+ // ── Start Mission ──
2015
+ const mission = await requestMission(prompt, discoverPath || '/');
2016
+ if (!mission.allowed) {
2017
+ if (mission.error === 'mission_limit_exceeded') {
2018
+ note(
2019
+ chalk.red('Limite diario de misiones alcanzado') + '\n' +
2020
+ chalk.yellow(`Misiones usadas: ${mission.daily_used}/${mission.daily_limit}`) + '\n' +
2021
+ chalk.gray(`Reinicio: ${mission.resets_at ? new Date(mission.resets_at).toLocaleString() : 'manana'}`),
2022
+ 'Limite alcanzado'
2023
+ );
2024
+ } else {
2025
+ const missionErrors = {
2026
+ no_project_id: 'No hay Project ID real conectado para esta ejecucion.',
2027
+ backend_unavailable: 'No se pudo iniciar la mision porque Arcality no respondio.',
2028
+ server_error: 'Arcality no pudo registrar el inicio de la mision.',
2029
+ no_api_key: 'API Key no encontrada.'
2030
+ };
2031
+ note(
2032
+ chalk.red(missionErrors[mission.error] || `No se pudo iniciar la mision: ${mission.error}`) + '\n' +
2033
+ chalk.gray('La prueba no se ejecutara en modo local. Revisa `arcality init` y la conexion al backend.'),
2034
+ 'Mision detenida'
2035
+ );
2036
+ }
2037
+ agentMode = false;
2038
+ prompt = "";
2039
+ skipMenu = false;
2040
+ continue;
2041
+ }
2042
+
2043
+ const s = spinner();
2044
+ if (!process.argv.includes('--debug')) {
2045
+ s.start(`◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`);
2046
+ } else {
2047
+ console.log(chalk.cyan(`\n◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`));
2048
+ }
2049
+
2050
+ const finalProjectId = process.env.ARCALITY_PROJECT_ID || selectedProjectId;
2051
+
2052
+ // ── Cancelación Manual (Ctrl+C) — Estrategia bulletproof ──
2053
+ // El problema: @clack/prompts registra su propio SIGINT handler cada tick del spinner,
2054
+ // que llama a process.exit(0) directamente. removeAllListeners() no es suficiente porque
2055
+ // Clack lo re-registra en cada frame de animación.
2056
+ //
2057
+ // La solución: interceptar process.exit() antes de que Clack lo llame,
2058
+ // y usar prependListener para que NUESTRO handler siempre se ejecute primero.
2059
+
2060
+ let activeChildProcess = null;
2061
+ let userCanceledMission = false;
2062
+ let sigintCount = 0;
2063
+ let _missionActive = true; // Mientras true, bloqueamos el process.exit de Clack
2064
+
2065
+ // Guardar el process.exit original y reemplazarlo temporalmente
2066
+ const _origProcessExit = process.exit.bind(process);
2067
+ process.exit = (code) => {
2068
+ if (_missionActive) {
2069
+ // Clack (u otra lib) está intentando matar el proceso — lo bloqueamos.
2070
+ // Nuestro cancelHandler ya habrá sido invocado vía prependListener.
2071
+ // El proceso se cerrará naturalmente cuando el child process termine.
2072
+ return;
2073
+ }
2074
+ _origProcessExit(code);
2075
+ };
2076
+
2077
+ // ── RESTORE RAW MODE FOR WINDOWS FIX ──
2078
+ // @clack/prompts pausea stdin y desactiva raw mode al terminar un menú.
2079
+ // Debemos reactivarlo aquí para que nuestro interceptor global de Ctrl+C
2080
+ // siga funcionando durante toda la misión.
2081
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
2082
+ try {
2083
+ process.stdin.setRawMode(true);
2084
+ process.stdin.resume();
2085
+ } catch(e) {}
2086
+ }
2087
+
2088
+ const cancelHandler = () => {
2089
+ sigintCount++;
2090
+ if (sigintCount > 3) {
2091
+ console.log(chalk.red('\n🛑 Forzando salida inmediata...'));
2092
+ _missionActive = false;
2093
+ _origProcessExit(1);
2094
+ return;
2095
+ }
2096
+
2097
+ if (userCanceledMission) {
2098
+ console.log(chalk.yellow('\n⏳ Cancelación en progreso, esperando reporte... (Ctrl+C x4 para forzar cierre)'));
2099
+ return;
2100
+ }
2101
+ userCanceledMission = true;
2102
+
2103
+ console.log(chalk.yellow('\n\n⚠️ [ARCALITY] Directiva abortada por el operador.'));
2104
+ if (!process.argv.includes('--debug')) {
2105
+ try { s.stop(chalk.yellow('⚠️ Directiva abortada.')); } catch { /* ignore */ }
2106
+ }
2107
+
2108
+ // Terminar el proceso hijo de Playwright directamente (Windows-safe)
2109
+ if (activeChildProcess && !activeChildProcess.killed) {
2110
+ try {
2111
+ if (process.platform === 'win32') {
2112
+ spawn('taskkill', ['/pid', String(activeChildProcess.pid), '/f', '/t'], { stdio: 'ignore', windowsHide: true });
2113
+ } else {
2114
+ activeChildProcess.kill('SIGTERM');
2115
+ }
2116
+ } catch { /* silencioso */ }
2117
+ }
2118
+
2119
+ try {
2120
+ const ctxDir = process.env.CONTEXT_DIR;
2121
+ if (ctxDir && fs.existsSync(ctxDir)) {
2122
+ const cancelLog = {
2123
+ prompt,
2124
+ history: ['[ABORTO DEL OPERADOR] La directiva fue detenida manualmente antes de completarse.'],
2125
+ steps: 0,
2126
+ success: false,
2127
+ error: true,
2128
+ fail_reason: 'user_canceled',
2129
+ canceled_by_user: true,
2130
+ timestamp: new Date().toISOString()
2131
+ };
2132
+ fs.writeFileSync(path.join(ctxDir, `arcality-log-${Date.now()}.json`), JSON.stringify(cancelLog, null, 2));
2133
+ }
2134
+ } catch { /* silencioso */ }
2135
+
2136
+ console.log(chalk.gray(' >> Esperando que Playwright cierre y genere el reporte...'));
2137
+ // Playwright también recibe el SIGINT nativamente desde la terminal Windows
2138
+ // y cerrará su proceso limpiamente, lo que resolverá la Promise del run() → finally
2139
+ };
2140
+
2141
+ // prependListener asegura que nuestro handler se ejecuta ANTES que el de Clack
2142
+ process.prependListener('SIGINT', cancelHandler);
2143
+ process.prependListener('SIGTERM', cancelHandler);
2144
+
2145
+
2146
+ // Build env for the runner — merge arcality.config values.
2147
+ // ARCALITY_API_URL must be explicit — it comes from the library's internal config,
2148
+ // not from the user's project .env, so we inject it directly via getApiUrl().
2149
+ const fallbackBaseUrl = (!projectConfig && discoverPath?.startsWith('http')) ? discoverPath : undefined;
2150
+
2151
+ const mergedEnv = {
2152
+ ...dotEnv,
2153
+ ...process.env,
2154
+ ARCALITY_API_URL: getApiUrl(), // Internal URL always injected explicitly
2155
+ ARCALITY_PROJECT_ID: finalProjectId,
2156
+ ARCALITY_MISSION_ID: mission.mission_id || mission.missionId || mission.MissionId || '', // ← Propagate mission_id to every proxy call
2157
+ ARCALITY_ORG_ID: validation.organization_id || validation.organizationId || validation.OrganizationId || process.env.ARCALITY_ORG_ID || '', // ← Propagate org_id for pattern persistence
2158
+ BASE_URL: projectConfig?.project?.baseUrl || dotEnv.BASE_URL || process.env.BASE_URL || fallbackBaseUrl || 'http://localhost:3000',
2159
+ LOGIN_USER: projectConfig?.auth?.username || dotEnv.LOGIN_USER || process.env.LOGIN_USER,
2160
+ LOGIN_PASSWORD: projectConfig?.auth?.password || dotEnv.LOGIN_PASSWORD || process.env.LOGIN_PASSWORD,
2161
+ DOMAIN_DIR: process.env.DOMAIN_DIR,
2162
+ MISSIONS_DIR: process.env.MISSIONS_DIR,
2163
+ CONTEXT_DIR: process.env.CONTEXT_DIR,
2164
+ REPORTS_DIR: process.env.REPORTS_DIR,
2165
+ SMART_PROMPT: prompt,
2166
+ TARGET_PATH: discoverPath || '/'
2167
+ };
2168
+
2169
+ if (finalProjectId) {
2170
+ console.log(chalk.gray(`>> ARCALITY_PROJECT_ID: ${finalProjectId}`));
2171
+ console.log(chalk.gray(`>> ARCALITY_ORG_ID: ${mergedEnv.ARCALITY_ORG_ID || 'NONE'}`));
2172
+ }
2173
+
2174
+ // ── Resolve execution paths ──
2175
+ // tsx and playwright are HOISTED to ORIGINAL_CWD/node_modules when installed via npm.
2176
+ // We run from ORIGINAL_CWD so relative paths for playwright work.
2177
+ // For the test file and config (inside the arcality package), we use absolute paths.
2178
+ //
2179
+ // KEY: tsx loader uses file:// URL (pathToFileURL) so spaces encode as %20 — no quoting needed.
2180
+
2181
+ const testFile = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
2182
+ const configFile = path.join(PROJECT_ROOT, 'playwright.config.js');
2183
+
2184
+ // Resolve playwright CLI — CRITICAL singleton alignment:
2185
+ //
2186
+ // @playwright/test uses `playwright` internally. When npm detects a version mismatch
2187
+ // between the top-level `playwright` and what `@playwright/test` needs, it installs
2188
+ // a NESTED copy at: @playwright/test/node_modules/playwright/
2189
+ //
2190
+ // Playwright's test runner tracks the active suite via a module-level singleton
2191
+ // (currentSuite). If the CLI uses a DIFFERENT playwright instance than the one
2192
+ // @playwright/test loaded, their singletons are out of sync → "did not expect test()
2193
+ // to be called here".
2194
+ //
2195
+ // Fix: ALWAYS prefer the playwright nested INSIDE @playwright/test, because that is
2196
+ // guaranteed to be the same instance that @playwright/test uses for its internals.
2197
+ //
2198
+ // Resolution priority:
2199
+ // 1. <project>/@playwright/test/node_modules/playwright/cli.js ← nested (safe)
2200
+ // 2. <project>/playwright/cli.js ← sibling top-level
2201
+ // (repeated for ORIGINAL_CWD and PROJECT_ROOT)
2202
+ let playwrightCli;
2203
+ try {
2204
+ const searchPaths = [ORIGINAL_CWD, PROJECT_ROOT];
2205
+ let testRunnerPath = null;
2206
+ let resolveError = null;
2207
+
2208
+ for (const searchPath of searchPaths) {
2209
+ try {
2210
+ testRunnerPath = require.resolve('@playwright/test', { paths: [searchPath] });
2211
+ break;
2212
+ } catch (e) {
2213
+ resolveError = e;
2214
+ }
2215
+ }
2216
+
2217
+ if (!testRunnerPath) throw resolveError;
2218
+
2219
+ const testRunnerDir = path.dirname(testRunnerPath); // → .../node_modules/@playwright/test/
2220
+
2221
+ // Priority 1: playwright nested INSIDE @playwright/test — same singleton as the package uses
2222
+ const nestedCli = path.join(testRunnerDir, 'node_modules', 'playwright', 'cli.js');
2223
+ // Priority 2: playwright as a top-level sibling (clean installs, versions aligned)
2224
+ const siblingCli = path.join(testRunnerDir, '..', '..', 'playwright', 'cli.js');
2225
+
2226
+ if (fs.existsSync(nestedCli)) {
2227
+ playwrightCli = nestedCli;
2228
+ } else if (fs.existsSync(siblingCli)) {
2229
+ playwrightCli = siblingCli;
2230
+ } else {
2231
+ throw new Error(
2232
+ `Playwright CLI not found. Tried:\n (nested) ${nestedCli}\n (sibling) ${siblingCli}`
2233
+ );
2234
+ }
2235
+
2236
+ if (process.argv.includes('--debug')) {
2237
+ console.log(chalk.gray(`[DEBUG] Using playwright CLI: ${playwrightCli}`));
2238
+ }
2239
+ } catch (e) {
2240
+ console.error(chalk.red('\n[FATAL] Núcleo de Playwright no encontrado. Asegúrate de que @playwright/test esté instalado.'));
2241
+ console.error(chalk.red(e.message));
2242
+ console.error(chalk.yellow('\nConsejo: Ejecuta `npm install @playwright/test` en tu directorio de proyecto.'));
2243
+ _missionActive = false;
2244
+ process.exit = _origProcessExit;
2245
+ _origProcessExit(1);
2246
+ }
2247
+
2248
+ const postProcessSigint = () => console.log(chalk.yellow('\n⏳ Finalizando reporte de telemetría, por favor espera...'));
2249
+ let missionResult = null;
2250
+ let finalFailReason = null;
2251
+ let finalUsageData = undefined;
2252
+ let finalReportUrl = null;
2253
+ let finalFailReasoning = null;
2254
+
2255
+ if (process.env.ARCALITY_RUNNER_PREBUILT === 'true') {
2256
+ if (process.argv.includes('--debug')) {
2257
+ console.log(chalk.gray('[DEBUG] Runner precompilado por la coleccion. Omitiendo build local.'));
2258
+ }
2259
+ } else
2260
+
2261
+ // ── Auto-compilar el runner antes de ejecutar Playwright para asegurar que los cambios de rama/merge estén aplicados ──
2262
+ try {
2263
+ if (!process.argv.includes('--debug')) {
2264
+ s.message('⚙️ Compilando agente de pruebas (Arcality Runner)...');
2265
+ } else {
2266
+ console.log(chalk.cyan('⚙️ Compilando agente de pruebas (Arcality Runner)...'));
2267
+ }
2268
+ await ensureRunnerBundle({ debug: process.argv.includes('--debug') });
2269
+ } catch (err) {
2270
+ console.warn(chalk.yellow(`\n⚠️ Advertencia: No se pudo auto-compilar el runner: ${err.message}`));
2271
+ }
2272
+
2273
+ try {
2274
+ // No test file path is passed — playwright.config.js uses testMatch:'agentic-runner.spec.ts'
2275
+ // This eliminates the Windows regex path issue permanently on any user's machine.
2276
+ await run('node', [
2277
+ '--no-warnings',
2278
+ playwrightCli,
2279
+ 'test',
2280
+ '--headed',
2281
+ '--config', 'playwright.config.js'
2282
+ ], {
2283
+ cwd: PROJECT_ROOT,
2284
+ env: mergedEnv,
2285
+ onStatus: (msg) => { if (!process.argv.includes('--debug')) s.message(msg); else console.log(chalk.gray(`>> Status: ${msg}`)); },
2286
+ onSpawn: (child) => { activeChildProcess = child; } // ← Capturar referencia para cancelHandler
2287
+ });
2288
+
2289
+ process.removeAllListeners('SIGINT');
2290
+ process.removeAllListeners('SIGTERM');
2291
+ process.on('SIGINT', postProcessSigint);
2292
+ process.on('SIGTERM', postProcessSigint);
2293
+
2294
+ if (userCanceledMission) {
2295
+ missionResult = 'canceled';
2296
+ finalFailReason = 'user_canceled';
2297
+ // Detener el spinner explícitamente antes de salir
2298
+ if (!process.argv.includes('--debug')) {
2299
+ try { s.stop(chalk.yellow('⚠️ Misión cancelada por el usuario.')); } catch { }
2300
+ }
2301
+ return; // El handler ya tomó el control, finally se ejecutará
2302
+ }
2303
+
2304
+ missionResult = 'success';
2305
+ if (!process.argv.includes('--debug')) s.stop(chalk.green('✅ Misión completada exitosamente.'));
2306
+ else console.log(chalk.green('✅ Misión completada exitosamente.'));
2307
+
2308
+ // ── Ping Project ──
2309
+ await pingProject(finalProjectId, process.env.ARCALITY_API_KEY);
2310
+
1766
2311
  // ── Save Mission YAML ──
1767
2312
  let saveDecision = 'no';
1768
2313
  if (!skipSavePrompt && !batchMode) {
@@ -1776,17 +2321,17 @@ async function main() {
1776
2321
  }
1777
2322
 
1778
2323
  if (saveDecision === 'yes') {
1779
- const name = await text({
1780
- message: 'Nombre de la misión (ej., login_valido):',
1781
- placeholder: 'my_mission',
1782
- validate: v => v.length < 3 ? 'Nombre muy corto' : undefined
1783
- });
1784
-
2324
+ const name = await text({
2325
+ message: 'Nombre de la misión (ej., login_valido):',
2326
+ placeholder: 'my_mission',
2327
+ validate: v => v.length < 3 ? 'Nombre muy corto' : undefined
2328
+ });
2329
+
1785
2330
  if (!isCancel(name)) {
1786
2331
  const safeName = name.trim().replace(/[^a-z0-9]/gi, '_').toLowerCase();
1787
-
1788
- // Save to missions dir (existing flow)
1789
- const missionsDir = process.env.MISSIONS_DIR;
2332
+
2333
+ // Save to missions dir (existing flow)
2334
+ const missionsDir = process.env.MISSIONS_DIR;
1790
2335
  const missionSaveDir = await selectMissionSaveDir(missionsDir);
1791
2336
  if (!missionSaveDir) {
1792
2337
  note(chalk.yellow('Guardado de mision cancelado.'), 'Persistencia Arcality');
@@ -1807,8 +2352,8 @@ async function main() {
1807
2352
  `priority: ${JSON.stringify('normal')}`,
1808
2353
  `created_at: ${JSON.stringify(new Date().toISOString())}`
1809
2354
  ].join('\n') + '\n';
1810
-
1811
- const smartYamlPath = path.join(process.env.CONTEXT_DIR, 'last-mission-smart.yaml');
2355
+
2356
+ const smartYamlPath = path.join(process.env.CONTEXT_DIR, 'last-mission-smart.yaml');
1812
2357
  if (fs.existsSync(smartYamlPath)) {
1813
2358
  const smartYamlContent = fs.readFileSync(smartYamlPath, 'utf8').trim();
1814
2359
  yamlData = [
@@ -1823,172 +2368,175 @@ async function main() {
1823
2368
  smartYamlContent
1824
2369
  ].join('\n') + '\n';
1825
2370
  }
1826
-
1827
- // Internal fallback backup for the agent
1828
- fs.writeFileSync(yamlPathMissions, yamlData);
1829
-
1830
- // Primary save to user's specified directory from arcality.config
1831
- if (projectConfig) {
1832
- const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
2371
+
2372
+ // Internal fallback backup for the agent
2373
+ fs.writeFileSync(yamlPathMissions, yamlData);
2374
+
2375
+ // Primary save to user's specified directory from arcality.config
2376
+ if (projectConfig) {
2377
+ const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
1833
2378
  const yamlOutputTargetDir = missionSaveDir.relDir ? path.join(yamlOutputDir, missionSaveDir.relDir) : yamlOutputDir;
1834
2379
  fs.mkdirSync(yamlOutputTargetDir, { recursive: true });
1835
2380
  const yamlPathOutput = path.join(yamlOutputTargetDir, `${safeName}.yaml`);
1836
- fs.writeFileSync(yamlPathOutput, yamlData);
1837
- note(chalk.green(`✅ Misión guardada en: ${yamlPathOutput}`), 'Persistencia Arcality');
1838
- } else {
1839
- note(chalk.green(`✅ Misión guardada en: ${yamlPathMissions}`), 'Persistencia Arcality');
1840
- }
1841
- }
1842
- }
1843
- } catch (e) {
1844
- // SIGINT ya fue manejado por cancelHandler — solo clasificamos el resultado
1845
- if (userCanceledMission) {
1846
- missionResult = 'canceled';
1847
- finalFailReason = 'user_canceled';
1848
- console.log(chalk.yellow('\n⚠️ Directiva abortada — generando reporte de evidencia...'));
1849
- } else {
1850
- missionResult = 'failed';
1851
- finalFailReason = 'timeout_or_crash';
1852
- s.stop(chalk.red(`❌ Arcality no pudo completar la directiva.`));
1853
- console.error(chalk.red(`\nDetalles del Error: ${e.message}`));
1854
- if (e.stack && process.argv.includes('--debug')) {
1855
- console.error(chalk.gray(e.stack));
1856
- }
1857
- }
1858
- } finally {
1859
- // Desactivar el bloqueo de process.exit y restaurar el original
1860
- _missionActive = false;
1861
- process.exit = _origProcessExit;
1862
- process.removeListener('SIGINT', cancelHandler);
1863
- process.removeListener('SIGTERM', cancelHandler);
1864
-
1865
- if (userCanceledMission) {
1866
- missionResult = 'canceled';
1867
- finalFailReason = 'user_canceled';
1868
- finalFailReasoning = 'La misión fue detenida manualmente por el operador (Ctrl+C). Esto no es una falla técnica del sistema.';
1869
- await new Promise(r => setTimeout(r, 800));
1870
- }
1871
-
1872
- process.on('SIGINT', postProcessSigint);
1873
- process.on('SIGTERM', postProcessSigint);
1874
-
1875
- const sRep = spinner();
1876
- if (!process.argv.includes('--debug')) {
1877
- sRep.start('📊 Generando reporte...');
1878
- } else {
1879
- console.log('📊 Generando reporte...');
1880
- }
1881
-
1882
- await run("node", [path.join(PROJECT_ROOT, "scripts/rebrand-report.mjs")]).catch(() => { });
1883
-
1884
- const reportDir = process.env.REPORTS_DIR || 'tests-report';
1885
- const arcalityPath = path.resolve(reportDir, 'index.html');
1886
-
1887
- sRep.stop();
1888
-
1889
- // Read final usage and failReason from agent-log
1890
- try {
1891
- const ctxDir = process.env.CONTEXT_DIR;
1892
- if (fs.existsSync(ctxDir)) {
2381
+ fs.writeFileSync(yamlPathOutput, yamlData);
2382
+ note(chalk.green(`✅ Misión guardada en: ${yamlPathOutput}`), 'Persistencia Arcality');
2383
+ } else {
2384
+ note(chalk.green(`✅ Misión guardada en: ${yamlPathMissions}`), 'Persistencia Arcality');
2385
+ }
2386
+ }
2387
+ }
2388
+ } catch (e) {
2389
+ // SIGINT ya fue manejado por cancelHandler — solo clasificamos el resultado
2390
+ if (userCanceledMission) {
2391
+ missionResult = 'canceled';
2392
+ finalFailReason = 'user_canceled';
2393
+ console.log(chalk.yellow('\n⚠️ Directiva abortada — generando reporte de evidencia...'));
2394
+ } else {
2395
+ missionResult = 'failed';
2396
+ finalFailReason = 'timeout_or_crash';
2397
+ s.stop(chalk.red(`❌ Arcality no pudo completar la directiva.`));
2398
+ console.error(chalk.red(`\nDetalles del Error: ${e.message}`));
2399
+ if (e.stack && process.argv.includes('--debug')) {
2400
+ console.error(chalk.gray(e.stack));
2401
+ }
2402
+ }
2403
+ } finally {
2404
+ // Desactivar el bloqueo de process.exit y restaurar el original
2405
+ _missionActive = false;
2406
+ process.exit = _origProcessExit;
2407
+ process.removeListener('SIGINT', cancelHandler);
2408
+ process.removeListener('SIGTERM', cancelHandler);
2409
+
2410
+ if (userCanceledMission) {
2411
+ missionResult = 'canceled';
2412
+ finalFailReason = 'user_canceled';
2413
+ finalFailReasoning = 'La misión fue detenida manualmente por el operador (Ctrl+C). Esto no es una falla técnica del sistema.';
2414
+ await new Promise(r => setTimeout(r, 800));
2415
+ }
2416
+
2417
+ process.on('SIGINT', postProcessSigint);
2418
+ process.on('SIGTERM', postProcessSigint);
2419
+
2420
+ const sRep = spinner();
2421
+ if (!process.argv.includes('--debug')) {
2422
+ sRep.start('📊 Generando reporte...');
2423
+ } else {
2424
+ console.log('📊 Generando reporte...');
2425
+ }
2426
+
2427
+ await run("node", [path.join(PROJECT_ROOT, "scripts/rebrand-report.mjs")]).catch(() => { });
2428
+
2429
+ const reportDir = process.env.REPORTS_DIR || 'tests-report';
2430
+ const arcalityPath = path.resolve(reportDir, 'index.html');
2431
+
2432
+ sRep.stop();
2433
+
2434
+ // Read final usage and failReason from agent-log
2435
+ try {
2436
+ const ctxDir = process.env.CONTEXT_DIR;
2437
+ if (fs.existsSync(ctxDir)) {
1893
2438
  const files = fs.readdirSync(ctxDir);
1894
2439
  const logFiles = files.filter(f =>
1895
2440
  (f.startsWith('arcality-log-') || f.startsWith('agent-log-')) &&
1896
2441
  f.endsWith('.json')
1897
2442
  ).sort();
1898
- if (logFiles.length > 0) {
1899
- const lastLog = path.join(ctxDir, logFiles[logFiles.length - 1]);
1900
- const logData = JSON.parse(fs.readFileSync(lastLog, 'utf8'));
1901
- finalUsageData = logData.usage || finalUsageData;
1902
- finalFailReasoning = logData.fail_reasoning || null;
1903
- if (missionResult === 'failed' && !finalFailReason) {
1904
- finalFailReason = logData.fail_reason || "timeout_or_crash";
1905
- }
1906
- }
1907
- }
1908
- } catch(e) {}
1909
-
1910
- // ── Upload Evidence (solo si NO fue cancelado por usuario) ──
1911
- if (missionResult !== 'canceled') {
1912
- try {
1913
- const { getEvidenceSasToken } = await import('../src/arcalityClient.mjs');
1914
- const { uploadEvidence } = await import('../src/EvidenceUploader.mjs');
1915
-
1916
- const sasData = await getEvidenceSasToken(mission.mission_id, finalProjectId);
1917
- if (process.argv.includes('--debug')) console.log(`[DEBUG] sasData received:`, sasData);
1918
-
1919
- const actualSasUrl = sasData?.sasTokenUrl || sasData?.sasUrl || sasData?.sas_url;
1920
- const actualBasePath = sasData?.basePath || sasData?.base_path;
1921
-
1922
- if (sasData && actualSasUrl && actualBasePath) {
1923
- if (!process.argv.includes('--debug')) {
1924
- sRep.start('☁️ Subiendo evidencia a Azure...');
1925
- }
1926
- process.removeAllListeners('SIGINT');
1927
- process.removeAllListeners('SIGTERM');
1928
- process.on('SIGINT', postProcessSigint);
1929
- process.on('SIGTERM', postProcessSigint);
1930
-
1931
- await uploadEvidence(reportDir, actualSasUrl, actualBasePath);
1932
-
1933
- try {
1934
- const parsedSas = new URL(actualSasUrl);
1935
- finalReportUrl = `${parsedSas.origin}${parsedSas.pathname}/${actualBasePath}/index.html`;
1936
- } catch(e) {}
1937
-
1938
- if (!process.argv.includes('--debug')) sRep.stop(chalk.green('✅ Evidencia subida exitosamente.'));
1939
- }
1940
- } catch (e) {
1941
- if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not upload evidence: ${e.message}`));
1942
- if (!process.argv.includes('--debug')) sRep.stop(chalk.yellow('⚠️ Fallo al subir evidencia.'));
1943
- }
1944
- } else {
1945
- console.log(chalk.gray(' >> Saltando subida de evidencia (la misión fue cancelada).'));
1946
- }
1947
-
1948
- // ── End Mission — SIEMPRE se llama, independientemente de si la evidencia subió ──
1949
- try {
1950
- const { endMission } = await import('../src/arcalityClient.mjs');
1951
- await endMission(mission.mission_id, missionResult || 'failed', finalUsageData, finalFailReason, finalReportUrl, finalFailReasoning);
1952
- if (process.argv.includes('--debug')) console.log(chalk.gray(`[DEBUG] endMission called → result: ${missionResult}, failReason: ${finalFailReason}`));
1953
- } catch (e) {
1954
- if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not end mission in backend: ${e.message}`));
1955
- }
1956
-
1957
- if (globalReportProcess) {
1958
- try {
1959
- if (process.platform === "win32") {
1960
- spawn("taskkill", ["/pid", globalReportProcess.pid, "/f", "/t"], { stdio: 'ignore', windowsHide: true });
1961
- } else {
1962
- globalReportProcess.kill();
1963
- }
1964
- } catch (e) { /* ignore */ }
1965
- globalReportProcess = null;
1966
- }
1967
-
2443
+ if (logFiles.length > 0) {
2444
+ const lastLog = path.join(ctxDir, logFiles[logFiles.length - 1]);
2445
+ const logData = JSON.parse(fs.readFileSync(lastLog, 'utf8'));
2446
+ finalUsageData = logData.usage || finalUsageData;
2447
+ finalFailReasoning = logData.fail_reasoning || null;
2448
+ if (missionResult === 'failed' && !finalFailReason) {
2449
+ finalFailReason = logData.fail_reason || "timeout_or_crash";
2450
+ }
2451
+ }
2452
+ }
2453
+ } catch(e) {}
2454
+
2455
+ // ── Upload Evidence (solo si NO fue cancelado por usuario) ──
2456
+ if (missionResult !== 'canceled') {
2457
+ try {
2458
+ const { getEvidenceSasToken } = await import('../src/arcalityClient.mjs');
2459
+ const { uploadEvidence } = await import('../src/EvidenceUploader.mjs');
2460
+
2461
+ const sasData = await getEvidenceSasToken(mission.mission_id, finalProjectId);
2462
+ if (process.argv.includes('--debug')) console.log(`[DEBUG] sasData received:`, sasData);
2463
+
2464
+ const actualSasUrl = sasData?.sasTokenUrl || sasData?.sasUrl || sasData?.sas_url;
2465
+ const actualBasePath = sasData?.basePath || sasData?.base_path;
2466
+
2467
+ if (sasData && actualSasUrl && actualBasePath) {
2468
+ if (!process.argv.includes('--debug')) {
2469
+ sRep.start('☁️ Subiendo evidencia a Azure...');
2470
+ }
2471
+ process.removeAllListeners('SIGINT');
2472
+ process.removeAllListeners('SIGTERM');
2473
+ process.on('SIGINT', postProcessSigint);
2474
+ process.on('SIGTERM', postProcessSigint);
2475
+
2476
+ await uploadEvidence(reportDir, actualSasUrl, actualBasePath);
2477
+
2478
+ try {
2479
+ const parsedSas = new URL(actualSasUrl);
2480
+ finalReportUrl = `${parsedSas.origin}${parsedSas.pathname}/${actualBasePath}/index.html`;
2481
+ } catch(e) {}
2482
+
2483
+ if (!process.argv.includes('--debug')) sRep.stop(chalk.green('✅ Evidencia subida exitosamente.'));
2484
+ }
2485
+ } catch (e) {
2486
+ if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not upload evidence: ${e.message}`));
2487
+ if (!process.argv.includes('--debug')) sRep.stop(chalk.yellow('⚠️ Fallo al subir evidencia.'));
2488
+ }
2489
+ } else {
2490
+ console.log(chalk.gray(' >> Saltando subida de evidencia (la misión fue cancelada).'));
2491
+ }
2492
+
2493
+ // ── End Mission — SIEMPRE se llama, independientemente de si la evidencia subió ──
2494
+ try {
2495
+ const { endMission } = await import('../src/arcalityClient.mjs');
2496
+ await endMission(mission.mission_id, missionResult || 'failed', finalUsageData, finalFailReason, finalReportUrl, finalFailReasoning);
2497
+ if (process.argv.includes('--debug')) console.log(chalk.gray(`[DEBUG] endMission called → result: ${missionResult}, failReason: ${finalFailReason}`));
2498
+ } catch (e) {
2499
+ if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not end mission in backend: ${e.message}`));
2500
+ }
2501
+
2502
+ if (globalReportProcess) {
2503
+ try {
2504
+ if (process.platform === "win32") {
2505
+ spawn("taskkill", ["/pid", globalReportProcess.pid, "/f", "/t"], { stdio: 'ignore', windowsHide: true });
2506
+ } else {
2507
+ globalReportProcess.kill();
2508
+ }
2509
+ } catch (e) { /* ignore */ }
2510
+ globalReportProcess = null;
2511
+ }
2512
+
1968
2513
  if (!batchMode && fs.existsSync(arcalityPath)) {
1969
2514
  console.log(chalk.blue(`🌐 Abriendo Reporte de Arcality: ${arcalityPath}`));
1970
- if (process.platform === "win32") {
1971
- exec(`start "" "${arcalityPath}"`);
1972
- } else {
1973
- exec(`open "${arcalityPath}"`);
1974
- }
2515
+ openLocalFile(arcalityPath);
1975
2516
  } else if (!fs.existsSync(arcalityPath)) {
1976
2517
  console.log(chalk.yellow(`⚠️ Reporte no generado (la prueba pudo haber fallado antes de completarse).`));
1977
2518
  }
1978
-
1979
- await new Promise(resolve => setTimeout(resolve, 2000));
1980
-
1981
- // Ping project after report
1982
- await pingProject(
1983
- process.env.ARCALITY_PROJECT_ID,
1984
- process.env.ARCALITY_API_KEY
1985
- );
1986
-
1987
- // Limpiar handlers del post-proceso ANTES de esperar Enter, ya que toda la telemetría y reportes ya finalizaron
1988
- process.removeListener('SIGINT', postProcessSigint);
1989
- process.removeListener('SIGTERM', postProcessSigint);
1990
-
2519
+
2520
+ await new Promise(resolve => setTimeout(resolve, 2000));
2521
+
2522
+ // Ping project after report
2523
+ await pingProject(
2524
+ process.env.ARCALITY_PROJECT_ID,
2525
+ process.env.ARCALITY_API_KEY
2526
+ );
2527
+
2528
+ // Limpiar handlers del post-proceso ANTES de esperar Enter, ya que toda la telemetría y reportes ya finalizaron
2529
+ process.removeListener('SIGINT', postProcessSigint);
2530
+ process.removeListener('SIGTERM', postProcessSigint);
2531
+
1991
2532
  if (!batchMode) {
2533
+ await waitForEnter('Presiona Enter para regresar al menu...', {
2534
+ pauseOnResolve: skipMenu,
2535
+ restoreRawMode: !skipMenu
2536
+ });
2537
+ }
2538
+
2539
+ if (false && !batchMode) {
1992
2540
  // Esperar Enter para regresar al menú
1993
2541
  // Usamos escucha directa de stdin (raw mode) porque @clack/text
1994
2542
  // no procesa Enter correctamente cuando setRawMode(true) está activo.
@@ -2030,22 +2578,22 @@ async function main() {
2030
2578
  process.stdin.on('data', onKey);
2031
2579
  });
2032
2580
  }
2033
-
2034
- if (skipMenu) {
2035
- outro(chalk.cyan('¡Misión terminada! Hasta luego.'));
2036
- return;
2037
- }
2038
-
2039
- agentMode = false;
2040
- prompt = "";
2041
- firstRun = false;
2042
- }
2043
- }
2044
- firstRun = false;
2045
- }
2046
- }
2047
-
2048
- main().catch(err => {
2049
- console.error(err);
2050
- process.exit(1);
2051
- });
2581
+
2582
+ if (skipMenu) {
2583
+ outro(chalk.cyan('¡Misión terminada! Hasta luego.'));
2584
+ return;
2585
+ }
2586
+
2587
+ agentMode = false;
2588
+ prompt = "";
2589
+ firstRun = false;
2590
+ }
2591
+ }
2592
+ firstRun = false;
2593
+ }
2594
+ }
2595
+
2596
+ main().catch(err => {
2597
+ console.error(err);
2598
+ process.exit(1);
2599
+ });