ai-control-center 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
@@ -0,0 +1,1229 @@
1
+ import inquirer from 'inquirer';
2
+ import { spawn } from 'child_process';
3
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
4
+ import { resolve } from 'path';
5
+ import chalk from 'chalk';
6
+ import { capture, isAvailable, runCopilot, runGemini } from '../utils/runner.js';
7
+ import { getWorkflowDir, getRootDir, getStatus, updateStatus } from '../utils/pipeline.js';
8
+ import { printError, printDeployErrorPanel } from '../utils/display.js';
9
+ import { logActivity, logRawBlock, printActivityHeader, printActivityFooter } from '../utils/activity-log.js';
10
+ import { celebrate } from '../utils/notify.js';
11
+ import { spinner, spinCycle } from '../utils/spinner.js';
12
+
13
+ const MAX_ATTEMPTS = 3;
14
+
15
+ // ─── Test level prompt ─────────────────────────────────────────────────────────
16
+ // Returns the --test-level flag string to append to any sf deploy command.
17
+
18
+ async function askTestLevel() {
19
+ const { level } = await inquirer.prompt([{
20
+ type: 'rawlist',
21
+ name: 'level',
22
+ message: 'Run test classes?',
23
+ prefix: chalk.cyan('◆'),
24
+ choices: [
25
+ {
26
+ name: `✓ Run Local Tests ${chalk.dim('·')} ${chalk.yellow('full validation')} ${chalk.dim('(recommended before production)')}`,
27
+ value: 'RunLocalTests',
28
+ },
29
+ {
30
+ name: `⚡ No Tests ${chalk.dim('·')} ${chalk.green('fast deploy')} ${chalk.dim('(skip — good for config/LWC changes)')}`,
31
+ value: 'NoTestRun',
32
+ },
33
+ { name: chalk.dim('Back'), value: 'cancel' },
34
+ ],
35
+ }]);
36
+ return level === 'cancel' ? null : level;
37
+ }
38
+
39
+ // ─── Auto-countdown helper ─────────────────────────────────────────────────────
40
+ // Prints "→ <message> 3s" and counts down. Ctrl+C aborts the whole process
41
+ // (which is fine — user deliberately interrupts). Returns when done.
42
+
43
+ async function autoCountdown(message, seconds = 3) {
44
+ const write = s => process.stdout.write(s);
45
+ write(`\n ${chalk.dim('→')} ${chalk.dim(message)} ${chalk.cyan(`${seconds}s`)}`);
46
+ for (let i = seconds - 1; i >= 0; i--) {
47
+ await new Promise(r => setTimeout(r, 1000));
48
+ const label = i > 0 ? `${i}s` : 'starting...';
49
+ write(`\r ${chalk.dim('→')} ${chalk.dim(message)} ${chalk.cyan(label)} `);
50
+ }
51
+ write('\n\n');
52
+ }
53
+
54
+ // ─── Entry point ───────────────────────────────────────────────────────────────
55
+
56
+ export async function deployAction() {
57
+ const { choice } = await inquirer.prompt([{
58
+ type: 'list',
59
+ name: 'choice',
60
+ message: 'Deploy options',
61
+ prefix: chalk.cyan('◆'),
62
+ choices: [
63
+ {
64
+ name: `◎ Smart Deploy ${chalk.dim('·')} ${chalk.green('changed files only')} ${chalk.dim('(fast)')}`,
65
+ value: 'smart',
66
+ },
67
+ {
68
+ name: `▸ Full Deploy ${chalk.dim('·')} ${chalk.yellow('all metadata')} ${chalk.dim('(slow)')}`,
69
+ value: 'full',
70
+ },
71
+ new inquirer.Separator(chalk.dim(' ──────────────────────────────────────')),
72
+ {
73
+ name: `◇ Dry Run ${chalk.dim('·')} changed files only ${chalk.dim('(validate, no deploy)')}`,
74
+ value: 'dryrun-smart',
75
+ },
76
+ {
77
+ name: `◇ Dry Run ${chalk.dim('·')} full metadata ${chalk.dim('(validate, no deploy)')}`,
78
+ value: 'dryrun-full',
79
+ },
80
+ new inquirer.Separator(chalk.dim(' ──────────────────────────────────────')),
81
+ {
82
+ name: '⊙ Deploy Specific Class (enter name manually)',
83
+ value: 'specific',
84
+ },
85
+ {
86
+ name: '≡ View Last Deploy Status',
87
+ value: 'status',
88
+ },
89
+ new inquirer.Separator(chalk.dim(' ──────────────────────────────────────')),
90
+ { name: chalk.dim(' Back'), value: 'back' },
91
+ ],
92
+ }]);
93
+
94
+ if (choice === 'back') return;
95
+
96
+ // ── Status ────────────────────────────────────────────────────────────────────
97
+ if (choice === 'status') {
98
+ printActivityHeader('PIPELINE — Last Deploy Status');
99
+ const stopCycle = spinCycle('Deploy', ['Fetching deploy report...'], 3000, 'yellow');
100
+ const result = await capture('sf project deploy report --json');
101
+ stopCycle(); spinner.stop();
102
+ showStatusReport(result);
103
+ return;
104
+ }
105
+
106
+ // ── Specific class ────────────────────────────────────────────────────────────
107
+ if (choice === 'specific') {
108
+ const { className } = await inquirer.prompt([{
109
+ type: 'input',
110
+ name: 'className',
111
+ message: 'Class name to deploy (e.g. UserService):',
112
+ validate: v => v.trim().length > 0 || 'Class name required',
113
+ }]);
114
+ const name = className.trim();
115
+ const testLevel = await askTestLevel();
116
+ if (!testLevel) return;
117
+ printActivityHeader(`PIPELINE — Deploy ${name}`);
118
+ await runDeployWithOrchestration(
119
+ `sf project deploy start --metadata ApexClass:${name} --test-level ${testLevel}`,
120
+ `Deploying ApexClass:${name}`,
121
+ false, [{ type: 'ApexClass', name }]
122
+ );
123
+ return true;
124
+ }
125
+
126
+ // ── Smart deploy (changed files since last deploy) ────────────────────────────
127
+ if (choice === 'smart' || choice === 'dryrun-smart') {
128
+ const isDryRun = choice === 'dryrun-smart';
129
+ printActivityHeader(`PIPELINE — ${isDryRun ? 'Dry Run' : 'Smart Deploy'} · Changed Since Last Deploy`);
130
+
131
+ logActivity('PIPELINE', 'Comparing HEAD against last deployed commit...');
132
+ const scan = await getChangedMetadata();
133
+
134
+ if (scan.components.length === 0) {
135
+ showEmptyDeployState(scan);
136
+ return;
137
+ }
138
+
139
+ const testLevel = await askTestLevel();
140
+ if (!testLevel) return;
141
+
142
+ // Show commit range + what will be deployed, then auto-proceed
143
+ showChangedComponents(scan, isDryRun);
144
+ await autoCountdown(
145
+ `${isDryRun ? 'Validating' : 'Deploying'} ${scan.components.length} component(s) · Ctrl+C to cancel`
146
+ );
147
+
148
+ // Use --source-dir per changed component so SF CLI discovers all files itself.
149
+ // This is more reliable than --metadata because it never misses sub-types
150
+ // (fields inside an object dir, assets inside an LWC dir, etc.).
151
+ const sourceDirs = [...new Set(scan.components.map(c => c.sourceDir))];
152
+ const sourceFlags = sourceDirs.map(d => `--source-dir "${d}"`).join(' ');
153
+ const command = isDryRun
154
+ ? `sf project deploy start --dry-run ${sourceFlags} --test-level ${testLevel}`
155
+ : `sf project deploy start ${sourceFlags} --test-level ${testLevel}`;
156
+
157
+ await runDeployWithOrchestration(command, `Smart Deploy (${scan.components.length} components)`, isDryRun, scan.components);
158
+ return true;
159
+ }
160
+
161
+ // ── Full deploy ────────────────────────────────────────────────────────────────
162
+ if (choice === 'full' || choice === 'dryrun-full') {
163
+ const isDryRun = choice === 'dryrun-full';
164
+ printActivityHeader(`PIPELINE — ${isDryRun ? 'Dry Run' : 'Full Deploy'} · All Metadata`);
165
+
166
+ const testLevel = await askTestLevel();
167
+ if (!testLevel) return;
168
+
169
+ const { confirmed } = await inquirer.prompt([{
170
+ type: 'confirm',
171
+ name: 'confirmed',
172
+ message: `${isDryRun ? 'Validate' : 'Deploy'} ALL metadata to default org? This can take 10–20 min.`,
173
+ prefix: chalk.yellow('~'),
174
+ default: false,
175
+ }]);
176
+ if (!confirmed) { logActivity('PIPELINE', 'Cancelled.'); return; }
177
+
178
+ const command = isDryRun
179
+ ? `sf project deploy start --dry-run --test-level ${testLevel}`
180
+ : `sf project deploy start --test-level ${testLevel}`;
181
+
182
+ await runDeployWithOrchestration(command, 'Full Deploy', isDryRun, null);
183
+ return true;
184
+ }
185
+ }
186
+
187
+ // ─── Commit tracking ──────────────────────────────────────────────────────────
188
+
189
+ function getLastDeployedCommit() {
190
+ return getStatus().last_deployed_commit || null;
191
+ }
192
+
193
+ async function saveDeployedCommit() {
194
+ const r = await capture('git rev-parse HEAD');
195
+ const hash = r.stdout.trim();
196
+ if (hash && hash.length === 40) {
197
+ updateStatus({ last_deployed_commit: hash });
198
+ logActivity('PIPELINE', `Deploy recorded at commit ${hash.slice(0, 8)}`, 'success');
199
+ }
200
+ }
201
+
202
+ // ─── Detect changed Salesforce files since last deploy ────────────────────────
203
+ //
204
+ // Returns:
205
+ // {
206
+ // components: [{ ref, type, name }] — deduplicated metadata refs
207
+ // commitInfo: { base, head, commits: [{hash, message}], count, note }
208
+ // uncommittedCount: number — files not yet committed
209
+ // }
210
+
211
+ async function getChangedMetadata() {
212
+ const root = getRootDir();
213
+ const baseCommit = getLastDeployedCommit();
214
+
215
+ let committedFiles = [];
216
+ let commitInfo = null;
217
+ let uncommittedFiles = [];
218
+
219
+ // ── Always include uncommitted/untracked changes too ─────────────────────────
220
+ const statusResult = await capture('git status --porcelain -- force-app/', [], { cwd: root });
221
+ uncommittedFiles = statusResult.stdout
222
+ .split('\n').filter(Boolean)
223
+ .map(l => l.slice(3).trim())
224
+ .filter(f => f.startsWith('force-app/'));
225
+
226
+ // ── Committed changes since last deploy ──────────────────────────────────────
227
+ const headResult = await capture('git rev-parse HEAD', [], { cwd: root });
228
+ const headHash = headResult.stdout.trim();
229
+
230
+ if (baseCommit) {
231
+ // Verify the stored commit still exists (handles rebases)
232
+ const check = await capture(`git cat-file -t ${baseCommit}`, [], { cwd: root });
233
+
234
+ if (check.code !== 0) {
235
+ logActivity('PIPELINE', `Stored commit ${baseCommit.slice(0, 8)} no longer exists (rebased?). Showing last 20 commits.`, 'warn');
236
+ // Fall through — baseCommit effectively null
237
+ } else if (baseCommit === headHash) {
238
+ // HEAD is the deployed commit — only uncommitted changes matter
239
+ commitInfo = {
240
+ base: baseCommit.slice(0, 8),
241
+ head: headHash.slice(0, 8),
242
+ commits: [],
243
+ count: 0,
244
+ note: 'HEAD matches last deployed commit',
245
+ };
246
+ } else {
247
+ // Normal case: diff committed files between last deploy and HEAD
248
+ const diff = await capture(
249
+ `git diff --name-only ${baseCommit}..HEAD -- force-app/`, [], { cwd: root }
250
+ );
251
+ committedFiles = diff.stdout.split('\n').filter(f => f.startsWith('force-app/'));
252
+
253
+ const log = await capture(
254
+ `git log --oneline ${baseCommit}..HEAD`, [], { cwd: root }
255
+ );
256
+ const commits = log.stdout.split('\n').filter(Boolean).map(line => ({
257
+ hash: line.slice(0, 7),
258
+ message: line.slice(8),
259
+ }));
260
+
261
+ commitInfo = {
262
+ base: baseCommit.slice(0, 8),
263
+ head: headHash.slice(0, 8),
264
+ commits,
265
+ count: commits.length,
266
+ note: null,
267
+ };
268
+ }
269
+ } else {
270
+ // No deploy record — show recent commit history and let user see what would deploy
271
+ const log = await capture('git log --oneline -20 -- force-app/', [], { cwd: root });
272
+ const commits = log.stdout.split('\n').filter(Boolean).map(line => ({
273
+ hash: line.slice(0, 7),
274
+ message: line.slice(8),
275
+ }));
276
+
277
+ if (commits.length > 0) {
278
+ // Diff from the parent of the oldest visible commit
279
+ const oldest = commits[commits.length - 1].hash;
280
+ const diff = await capture(
281
+ `git diff --name-only ${oldest}~1..HEAD -- force-app/ 2>/dev/null || git diff --name-only ${oldest}..HEAD -- force-app/`,
282
+ [], { cwd: root, shell: true }
283
+ );
284
+ committedFiles = diff.stdout.split('\n').filter(f => f.startsWith('force-app/'));
285
+ }
286
+
287
+ commitInfo = {
288
+ base: null,
289
+ head: headHash.slice(0, 8),
290
+ commits,
291
+ count: commits.length,
292
+ note: 'No deploy record — showing all recent changes (last 20 commits)',
293
+ };
294
+ }
295
+
296
+ const allFiles = [...new Set([...committedFiles, ...uncommittedFiles])];
297
+
298
+ // Convert file paths → Salesforce metadata refs, deduplicate
299
+ const metaMap = new Map();
300
+ for (const filePath of allFiles) {
301
+ const meta = filePathToMetadata(filePath);
302
+ if (meta && !metaMap.has(meta.ref)) metaMap.set(meta.ref, meta);
303
+ }
304
+
305
+ return {
306
+ components: [...metaMap.values()].sort((a, b) => a.type.localeCompare(b.type)),
307
+ commitInfo,
308
+ uncommittedCount: uncommittedFiles.length,
309
+ };
310
+ }
311
+
312
+ // ─── Show changed component list + commit range before confirming ─────────────
313
+
314
+ function showChangedComponents(scan, isDryRun) {
315
+ const { components, commitInfo, uncommittedCount } = scan;
316
+ const bar = chalk.dim(' ┃ ');
317
+ const verb = isDryRun ? 'Will validate' : 'Will deploy';
318
+
319
+ console.log('');
320
+ console.log(chalk.cyan.bold(` ┃ ${verb} ${components.length} component(s)`));
321
+ console.log(chalk.dim(' ┃'));
322
+
323
+ // ── Commit range ─────────────────────────────────────────────────────────────
324
+ if (commitInfo) {
325
+ if (commitInfo.note) {
326
+ console.log(`${bar}${chalk.yellow(commitInfo.note)}`);
327
+ }
328
+
329
+ if (commitInfo.base) {
330
+ console.log(`${bar}${chalk.dim('Last deployed')} ${chalk.white(commitInfo.base)}`);
331
+ }
332
+ console.log(`${bar}${chalk.dim('Current HEAD ')} ${chalk.white(commitInfo.head || '?')}`);
333
+
334
+ if (commitInfo.commits.length > 0) {
335
+ console.log(chalk.dim(' ┃'));
336
+ console.log(`${bar}${chalk.cyan.bold(`${commitInfo.count} commit(s) not yet deployed`)}`);
337
+ commitInfo.commits.forEach(c => {
338
+ console.log(`${bar} ${chalk.dim(c.hash)} ${chalk.white(c.message)}`);
339
+ });
340
+ } else if (commitInfo.count === 0 && !commitInfo.note) {
341
+ console.log(`${bar}${chalk.dim('No new commits since last deploy')}`);
342
+ }
343
+
344
+ if (uncommittedCount > 0) {
345
+ console.log(chalk.dim(' ┃'));
346
+ console.log(`${bar}${chalk.yellow(`+${uncommittedCount} uncommitted file(s) also included`)}`);
347
+ }
348
+
349
+ console.log(chalk.dim(' ┃'));
350
+ }
351
+
352
+ // ── Files grouped by metadata type ───────────────────────────────────────────
353
+ console.log(`${bar}${chalk.cyan.bold('Components to deploy')}`);
354
+ const byType = {};
355
+ components.forEach(c => {
356
+ if (!byType[c.type]) byType[c.type] = [];
357
+ byType[c.type].push(c.name);
358
+ });
359
+
360
+ Object.entries(byType).forEach(([type, names]) => {
361
+ console.log(`${bar} ${chalk.cyan(type)} ${chalk.dim(`(${names.length})`)}`);
362
+ names.forEach(n => console.log(`${bar} ${chalk.white(n)}`));
363
+ });
364
+
365
+ console.log(chalk.dim(' ┃'));
366
+ console.log('');
367
+ }
368
+
369
+ // ─── Empty state: no changes since last deploy ────────────────────────────────
370
+
371
+ function showEmptyDeployState(scan) {
372
+ const { commitInfo, uncommittedCount } = scan;
373
+ const bar = chalk.dim(' ┃ ');
374
+
375
+ console.log('');
376
+ console.log(chalk.green.bold(' ┃ Nothing to deploy'));
377
+ console.log(chalk.dim(' ┃'));
378
+
379
+ if (commitInfo?.base) {
380
+ console.log(`${bar}${chalk.dim('Last deployed at')} ${chalk.white(commitInfo.base)}`);
381
+ console.log(`${bar}${chalk.dim('Current HEAD ')} ${chalk.white(commitInfo.head || '?')}`);
382
+ console.log(`${bar}${chalk.green('All committed changes are already deployed.')}`);
383
+ } else {
384
+ console.log(`${bar}${chalk.dim('No deploy record found and no Salesforce files changed.')}`);
385
+ }
386
+
387
+ if (uncommittedCount === 0) {
388
+ console.log(`${bar}${chalk.dim('No uncommitted changes in force-app/ either.')}`);
389
+ }
390
+
391
+ console.log(chalk.dim(' ┃'));
392
+ console.log('');
393
+ logActivity('PIPELINE', 'Use "Full Deploy" or "Deploy Specific Class" if you want to redeploy.', 'info');
394
+ }
395
+
396
+ /**
397
+ * Convert a force-app file path to a deploy descriptor.
398
+ *
399
+ * Returns:
400
+ * {
401
+ * ref: 'ApexClass:UserService' ← deduplication + display key
402
+ * type: 'ApexClass' ← display grouping
403
+ * name: 'UserService' ← display name
404
+ * sourceDir: 'force-app/main/default/...' ← path passed to --source-dir
405
+ * }
406
+ *
407
+ * Using --source-dir instead of --metadata ensures SF CLI discovers ALL files
408
+ * in the deploy unit (object fields, LWC assets, etc.) from the filesystem —
409
+ * nothing is silently dropped because of a missing regex branch.
410
+ *
411
+ * sourceDir rules:
412
+ * - LWC / Aura : component directory (deploys all assets in one shot)
413
+ * - CustomObject: object directory (deploys fields, rules, layouts, etc.)
414
+ * - Apex / single-file metadata: the specific file (not the whole folder)
415
+ * - Unknown force-app/ file : the file itself — SF CLI determines type
416
+ *
417
+ * Returns null only for non-SF files (tests, docs, config outside force-app/).
418
+ */
419
+ function filePathToMetadata(filePath) {
420
+ const p = filePath.replace(/\\/g, '/');
421
+
422
+ // Only process files inside force-app/
423
+ if (!p.includes('/force-app/') && !p.startsWith('force-app/')) return null;
424
+
425
+ // Helper — builds the descriptor.
426
+ // sourceDir defaults to filePath when not separately specified.
427
+ const make = (type, name, sourceDir) => ({
428
+ ref: `${type}:${name}`,
429
+ type,
430
+ name,
431
+ sourceDir: (sourceDir || filePath).replace(/\\/g, '/'),
432
+ });
433
+
434
+ // Extract the directory up to (and including) a named container folder.
435
+ // e.g. filePath='force-app/main/default/lwc/myComp/myComp.js'
436
+ // → containerDir('lwc') = 'force-app/main/default/lwc/myComp'
437
+ const containerDir = (folder) => {
438
+ const m = p.match(new RegExp(`(.*/${folder}/[^/]+)`));
439
+ return m ? m[1] : filePath;
440
+ };
441
+
442
+ // ── Apex ──────────────────────────────────────────────────────────────────────
443
+ const cls = p.match(/\/classes\/([^/]+?)(?:\.cls(?:-meta\.xml)?)?$/);
444
+ if (cls) {
445
+ const name = cls[1].replace(/\.cls.*$/, '');
446
+ // Normalise to .cls (not -meta.xml) so SF CLI finds the right source file
447
+ const src = filePath.replace(/\.cls-meta\.xml$/, '.cls');
448
+ return make('ApexClass', name, src);
449
+ }
450
+
451
+ const trg = p.match(/\/triggers\/([^/]+?)(?:\.trigger(?:-meta\.xml)?)?$/);
452
+ if (trg) {
453
+ const name = trg[1].replace(/\.trigger.*$/, '');
454
+ return make('ApexTrigger', name, filePath.replace(/\.trigger-meta\.xml$/, '.trigger'));
455
+ }
456
+
457
+ const page = p.match(/\/pages\/([^/]+?)(?:\.page(?:-meta\.xml)?)?$/);
458
+ if (page) {
459
+ const name = page[1].replace(/\.page.*$/, '');
460
+ return make('ApexPage', name, filePath.replace(/\.page-meta\.xml$/, '.page'));
461
+ }
462
+
463
+ // ── LWC / Aura — deploy the COMPONENT DIRECTORY so all assets are included ──
464
+ if (p.includes('/lwc/')) {
465
+ const m = p.match(/\/lwc\/([^/]+)/);
466
+ if (m) return make('LightningComponentBundle', m[1], containerDir('lwc'));
467
+ }
468
+
469
+ if (p.includes('/aura/')) {
470
+ const m = p.match(/\/aura\/([^/]+)/);
471
+ if (m) return make('AuraDefinitionBundle', m[1], containerDir('aura'));
472
+ }
473
+
474
+ // ── Objects — deploy the OBJECT DIRECTORY so ALL sub-types are included ──────
475
+ // Covers: fields, validationRules, listViews, recordTypes, webLinks, fieldSets,
476
+ // compactLayouts, sharingReasons, businessProcesses, standardFields,
477
+ // indexes, searchLayouts, territory2Types — everything in the folder.
478
+ if (p.includes('/objects/')) {
479
+ const objDir = p.match(/(.*\/objects\/[^/]+)/);
480
+ const objName = p.match(/\/objects\/([^/]+)/);
481
+ if (objDir && objName) return make('CustomObject', objName[1], objDir[1]);
482
+ }
483
+
484
+ // ── Flows & process builder ───────────────────────────────────────────────────
485
+ const flow = p.match(/\/flows\/([^/]+?)(?:\.flow-meta\.xml)?$/);
486
+ if (flow) return make('Flow', flow[1].replace(/\.flow.*$/, ''));
487
+
488
+ // ── Static resources ─────────────────────────────────────────────────────────
489
+ const sr = p.match(/\/staticresources\/([^/]+?)(?:\.|\/)/);
490
+ if (sr) return make('StaticResource', sr[1]);
491
+
492
+ // ── Permission sets & profiles ────────────────────────────────────────────────
493
+ const perm = p.match(/\/permissionsets\/([^/]+?)(?:\.permissionset-meta\.xml)?$/);
494
+ if (perm) return make('PermissionSet', perm[1].replace(/\.permissionset.*$/, ''));
495
+
496
+ const profile = p.match(/\/profiles\/([^/]+?)(?:\.profile-meta\.xml)?$/);
497
+ if (profile) return make('Profile', profile[1].replace(/\.profile.*$/, ''));
498
+
499
+ // ── Custom labels ─────────────────────────────────────────────────────────────
500
+ if (p.includes('/labels/')) return make('CustomLabels', 'CustomLabels');
501
+
502
+ // ── Layouts ───────────────────────────────────────────────────────────────────
503
+ const layout = p.match(/\/layouts\/([^/]+?)(?:\.layout-meta\.xml)?$/);
504
+ if (layout) return make('Layout', layout[1].replace(/\.layout.*$/, ''));
505
+
506
+ // ── Flex pages (App Builder) ──────────────────────────────────────────────────
507
+ const flexi = p.match(/\/flexiPages\/([^/]+?)(?:\.flexipage-meta\.xml)?$/);
508
+ if (flexi) return make('FlexiPage', flexi[1].replace(/\.flexipage.*$/, ''));
509
+
510
+ // ── Custom metadata records ───────────────────────────────────────────────────
511
+ const cmdType = p.match(/\/customMetadata\/([^/]+?)(?:\.md-meta\.xml)?$/);
512
+ if (cmdType) return make('CustomMetadata', cmdType[1].replace(/\.md.*$/, ''));
513
+
514
+ // ── Custom metadata types (object-like) ──────────────────────────────────────
515
+ const cmdTypeDef = p.match(/\/customMetadataTypes\/([^/]+?)(?:\.object-meta\.xml)?$/);
516
+ if (cmdTypeDef) return make('CustomObject', cmdTypeDef[1].replace(/\.object.*$/, ''));
517
+
518
+ // ── Named credentials ─────────────────────────────────────────────────────────
519
+ const nc = p.match(/\/namedCredentials\/([^/]+?)(?:\.namedCredential-meta\.xml)?$/);
520
+ if (nc) return make('NamedCredential', nc[1].replace(/\.namedCredential.*$/, ''));
521
+
522
+ // ── Remote site settings ──────────────────────────────────────────────────────
523
+ const rss = p.match(/\/remoteSiteSettings\/([^/]+?)(?:\.remoteSite-meta\.xml)?$/);
524
+ if (rss) return make('RemoteSiteSetting', rss[1].replace(/\.remoteSite.*$/, ''));
525
+
526
+ // ── Custom tabs ───────────────────────────────────────────────────────────────
527
+ const tab = p.match(/\/tabs\/([^/]+?)(?:\.tab-meta\.xml)?$/);
528
+ if (tab) return make('CustomTab', tab[1].replace(/\.tab.*$/, ''));
529
+
530
+ // ── Custom applications ───────────────────────────────────────────────────────
531
+ const app = p.match(/\/applications\/([^/]+?)(?:\.app-meta\.xml)?$/);
532
+ if (app) return make('CustomApplication', app[1].replace(/\.app.*$/, ''));
533
+
534
+ // ── Global / Standard value sets ─────────────────────────────────────────────
535
+ const gvs = p.match(/\/globalValueSets\/([^/]+?)(?:\.globalValueSet-meta\.xml)?$/);
536
+ if (gvs) return make('GlobalValueSet', gvs[1].replace(/\.globalValueSet.*$/, ''));
537
+
538
+ const svs = p.match(/\/standardValueSets\/([^/]+?)(?:\.standardValueSet-meta\.xml)?$/);
539
+ if (svs) return make('StandardValueSet', svs[1].replace(/\.standardValueSet.*$/, ''));
540
+
541
+ // ── Lightning message channels ────────────────────────────────────────────────
542
+ const lmc = p.match(/\/messageChannels\/([^/]+?)(?:\.messageChannel-meta\.xml)?$/);
543
+ if (lmc) return make('LightningMessageChannel', lmc[1].replace(/\.messageChannel.*$/, ''));
544
+
545
+ // ── Content assets ────────────────────────────────────────────────────────────
546
+ const asset = p.match(/\/contentassets\/([^/]+?)(?:\.|\/)/);
547
+ if (asset) return make('ContentAsset', asset[1]);
548
+
549
+ // ── Email templates ───────────────────────────────────────────────────────────
550
+ const email = p.match(/\/email\/(.+?)(?:\.email-meta\.xml)?$/);
551
+ if (email) return make('EmailTemplate', email[1].replace(/\.email.*$/, ''));
552
+
553
+ // ── Queues ────────────────────────────────────────────────────────────────────
554
+ const queue = p.match(/\/queues\/([^/]+?)(?:\.queue-meta\.xml)?$/);
555
+ if (queue) return make('Queue', queue[1].replace(/\.queue.*$/, ''));
556
+
557
+ // ── Groups ────────────────────────────────────────────────────────────────────
558
+ const group = p.match(/\/groups\/([^/]+?)(?:\.group-meta\.xml)?$/);
559
+ if (group) return make('Group', group[1].replace(/\.group.*$/, ''));
560
+
561
+ // ── Experience bundles (communities) ─────────────────────────────────────────
562
+ if (p.includes('/experiences/')) {
563
+ const m = p.match(/\/experiences\/([^/]+)/);
564
+ if (m) return make('ExperienceBundle', m[1], containerDir('experiences'));
565
+ }
566
+
567
+ // ── Catch-all: any other force-app/ file ─────────────────────────────────────
568
+ // Return it as-is so SF CLI can determine the type. Never silently drop a file.
569
+ const anyMeta = p.match(/\/force-app\/.*\/([^/]+?)(?:-meta\.xml)?$/);
570
+ if (anyMeta) {
571
+ const name = anyMeta[1].replace(/-meta\.xml$/, '');
572
+ return make('Metadata', name);
573
+ }
574
+
575
+ return null;
576
+ }
577
+
578
+ // ─── Core orchestration loop ───────────────────────────────────────────────────
579
+
580
+ async function runDeployWithOrchestration(command, label, isDryRun, changedComponents) {
581
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
582
+
583
+ if (attempt > 1) {
584
+ logActivity('PIPELINE', `Re-deploying after fixes (attempt ${attempt} of ${MAX_ATTEMPTS})...`);
585
+ }
586
+
587
+ logActivity('PIPELINE', `${label}...`);
588
+ const result = await executeDeploy(command, label, attempt);
589
+
590
+ // ── Success ──────────────────────────────────────────────────────────────────
591
+ if (result.success) {
592
+ spinner.succeed(`${isDryRun ? 'Validation' : 'Deploy'} succeeded · ${result.deployed} component(s)`);
593
+ if (!isDryRun) await celebrate('deploy_success');
594
+ logActivity('PIPELINE', `${isDryRun ? 'Dry run validation' : 'Deploy'} complete`, 'success');
595
+ if (!isDryRun) await saveDeployedCommit();
596
+ printActivityFooter();
597
+ return;
598
+ }
599
+
600
+ // ── Failure ──────────────────────────────────────────────────────────────────
601
+ spinner.fail(`${isDryRun ? 'Validation' : 'Deploy'} failed`);
602
+ if (!isDryRun) await celebrate('deploy_failed');
603
+
604
+ if (result.parseError) {
605
+ printError(`Could not parse deploy output — check org authentication.\n ${result.parseError.slice(0, 300)}`);
606
+ return;
607
+ }
608
+
609
+ printDeployErrorPanel(result.componentErrors, result.testFailures, attempt, MAX_ATTEMPTS, result.rawErrors);
610
+
611
+ // Log full structured error detail to session log for audit
612
+ const errorDetail = [
613
+ `Command: ${command}`,
614
+ `Attempt: ${attempt} of ${MAX_ATTEMPTS}`,
615
+ '',
616
+ `Compile Errors (${result.componentErrors.length}):`,
617
+ ...result.componentErrors.map(e => ` ${e.fileName}:${e.lineNumber || '?'} ${e.problem}`),
618
+ '',
619
+ `Test Failures (${result.testFailures.length}):`,
620
+ ...result.testFailures.map(f => ` ${f.name}.${f.methodName} ${f.message || ''}`),
621
+ '',
622
+ result.rawErrors ? `Raw Output:\n${result.rawErrors}` : '',
623
+ ].filter(l => l !== undefined).join('\n');
624
+ logRawBlock('PIPELINE', `Deploy Error Detail — attempt ${attempt}`, errorDetail);
625
+
626
+ if (isDryRun) {
627
+ logActivity('PIPELINE', 'Fix the errors above, then run a deploy.');
628
+ printActivityFooter();
629
+ return;
630
+ }
631
+
632
+ // ── No actionable errors → infra/auth/CLI issue, not fixable by Copilot ────
633
+ const hasActionableErrors = result.componentErrors.length > 0 || result.testFailures.length > 0;
634
+ if (!hasActionableErrors) {
635
+ logActivity('PIPELINE', 'Deploy failed with no compile or test errors — not a code issue.', 'warn');
636
+ logActivity('PIPELINE', 'Likely causes:', 'warn');
637
+ logActivity('PIPELINE', ' 1. SF CLI outdated — run: sf update', 'warn');
638
+ logActivity('PIPELINE', ' 2. Org auth expired — run: sf org login web', 'warn');
639
+ logActivity('PIPELINE', ' 3. Another deploy already in progress — run: sf project deploy cancel', 'warn');
640
+ logActivity('PIPELINE', ' 4. Org sandbox maintenance — check trust.salesforce.com', 'warn');
641
+ printActivityFooter();
642
+ return;
643
+ }
644
+
645
+ if (attempt === MAX_ATTEMPTS) {
646
+ logActivity('PIPELINE', `Max auto-fix attempts (${MAX_ATTEMPTS}) reached.`, 'warn');
647
+ logActivity('PIPELINE', 'Review the errors manually and try again.', 'warn');
648
+ printActivityFooter();
649
+ return;
650
+ }
651
+
652
+ // ── Auto-orchestrate: Gemini analyzes → Copilot fixes → Gemini validates → redeploy ──
653
+ await autoCountdown('Auto-orchestrating · Gemini → Copilot → Gemini → redeploy · Ctrl+C to abort');
654
+
655
+ const fixFile = await geminiAnalyzeErrors(result, command, attempt);
656
+ if (!fixFile) { logActivity('GEMINI', 'Analysis failed — fix manually and retry.', 'error'); return; }
657
+
658
+ // ── Guard: detect empty/failed fix guide before sending to Copilot ────────
659
+ // When Gemini fails (429 / capacity error), the output file is written but
660
+ // contains only headers or an error message — not real fix instructions.
661
+ // Sending an empty fix guide to Copilot causes it to do nothing.
662
+ const fixGuideRaw = existsSync(fixFile) ? readFileSync(fixFile, 'utf8') : '';
663
+ const fixGuideWords = fixGuideRaw.replace(/[#\-*=\s]/g, '').length; // meaningful chars
664
+ if (fixGuideRaw.length < 100 || fixGuideWords < 80) {
665
+ logActivity('GEMINI', `Fix guide appears empty or incomplete (${fixGuideWords} meaningful chars).`, 'error');
666
+ logActivity('GEMINI', 'Gemini likely hit a capacity error (429). Check session log for details.', 'error');
667
+ logActivity('GEMINI', 'Options: wait and retry, or set GEMINI_MODEL=gemini-2.5-pro and retry.', 'warn');
668
+ const { continueEmpty } = await inquirer.prompt([{
669
+ type: 'confirm',
670
+ name: 'continueEmpty',
671
+ message: 'Fix guide is empty — Copilot will do nothing. Retry this attempt anyway?',
672
+ default: false,
673
+ }]);
674
+ if (!continueEmpty) {
675
+ logActivity('PIPELINE', 'Paused — resolve Gemini error and retry deploy.');
676
+ printActivityFooter();
677
+ return;
678
+ }
679
+ }
680
+
681
+ // ── Copilot applies the fixes (Claude = Architect only) ───────────────────
682
+ const relFixPath = fixFile.replace(getRootDir() + '/', '');
683
+ logActivity('COPILOT', `Dispatching fix guide to Copilot from ${relFixPath}...`);
684
+
685
+ const stopCopilotFix = spinCycle('Copilot', [
686
+ 'Reading deploy error analysis...',
687
+ 'Fixing compile errors in Apex classes...',
688
+ 'Fixing test class failures...',
689
+ 'Saving all changes...',
690
+ ], 5000, 'blue');
691
+
692
+ // Build Copilot prompt with BOTH the raw errors AND Gemini's fix guide.
693
+ // Raw errors = ground truth (exact file:line for every error).
694
+ // Fix guide = Gemini's root-cause analysis and how-to-fix instructions.
695
+ // Copilot must address every raw error — not just the ones Gemini mentioned.
696
+ const fixGuideContent = existsSync(fixFile) ? readFileSync(fixFile, 'utf8') : '(fix guide not found)';
697
+ const rawErrorsList = formatErrorsForCopilot(result);
698
+
699
+ const copilotPrompt =
700
+ `You are a Salesforce Apex developer. Fix ALL the deployment errors listed below.\n\n` +
701
+ `IMPORTANT: This is a fresh task — ignore any prior context. Make the exact changes described below.\n\n` +
702
+ `=== DEPLOY ERRORS (GROUND TRUTH — fix EVERY one of these) ===\n${rawErrorsList}\n=== END ERRORS ===\n\n` +
703
+ `=== FIX GUIDE (Gemini's root-cause analysis and instructions) ===\n${fixGuideContent}\n=== END FIX GUIDE ===\n\n` +
704
+ `MANDATORY SEARCH RULES — follow these before making ANY change:\n` +
705
+ `1. NEVER guess a file name, class name, method name, or field name.\n` +
706
+ `2. Before editing any file: use your search/read tools to locate and open the ACTUAL file.\n` +
707
+ ` - Search by name: find force-app/ -name "*ClassName*"\n` +
708
+ ` - If a line number is given, open the file and verify the line content before editing.\n` +
709
+ `3. Before changing any field reference: read the object metadata XML under\n` +
710
+ ` force-app/main/default/objects/<ObjectName>/fields/ to confirm the field API name exists.\n` +
711
+ ` Do NOT invent or assume field names — only use names confirmed in the metadata files.\n` +
712
+ `4. Read ±20 lines of context around each error location before applying a fix.\n` +
713
+ `5. For each error: search for ALL occurrences of the same incorrect pattern in the entire file,\n` +
714
+ ` not just the reported line number — fix every occurrence.\n` +
715
+ `6. If you cannot find a file or symbol after searching, log "NOT FOUND: <name>" and skip it.\n` +
716
+ ` Do NOT create stub code to fill a gap.\n\n` +
717
+ `Fix Steps:\n` +
718
+ `1. Work through each error in the DEPLOY ERRORS section above, one by one\n` +
719
+ `2. Use the FIX GUIDE for the root-cause explanation and recommended fix\n` +
720
+ `3. Open the ACTUAL file, verify the error line, fix ALL occurrences in the file\n` +
721
+ `4. Save the file, then move to the next error\n` +
722
+ `5. Do NOT refactor or change anything outside the listed errors`;
723
+
724
+ // Log the exact prompt sent to Copilot so we can audit what it received
725
+ logRawBlock('COPILOT', `Prompt Sent to Copilot — attempt ${attempt}`, copilotPrompt);
726
+
727
+ const copilotStart = Date.now();
728
+ await runCopilot(copilotPrompt, {} /* No featureId → fresh session each deploy-fix attempt */);
729
+ const copilotElapsed = Date.now() - copilotStart;
730
+
731
+ stopCopilotFix();
732
+
733
+ // If Copilot finishes in under 10s it almost certainly didn't run (not found, crashed, etc.)
734
+ if (copilotElapsed < 10000) {
735
+ spinner.warn(`Copilot finished in ${(copilotElapsed / 1000).toFixed(1)}s — may not have run`);
736
+ logActivity('COPILOT', `Completed in ${(copilotElapsed / 1000).toFixed(1)}s — check that 'copilot' CLI is installed and in PATH`, 'warn');
737
+ const { continueAnyway } = await inquirer.prompt([{
738
+ type: 'confirm',
739
+ name: 'continueAnyway',
740
+ message: 'Copilot may not have applied fixes. Continue to validation anyway?',
741
+ default: false,
742
+ }]);
743
+ if (!continueAnyway) {
744
+ logActivity('PIPELINE', 'Paused — verify Copilot CLI is installed: which copilot');
745
+ printActivityFooter();
746
+ return;
747
+ }
748
+ } else {
749
+ spinner.succeed('Copilot finished applying fixes');
750
+ logActivity('COPILOT', 'All fixes applied', 'success');
751
+ }
752
+
753
+ const validated = await geminiValidateFixes(result, fixFile, attempt);
754
+
755
+ if (!validated.approved) {
756
+ logActivity('GEMINI', `Validation: ${validated.verdict} · ${validated.issues} issue(s) remain.`, 'warn');
757
+ if (validated.issues > 0) {
758
+ const { retryFix } = await inquirer.prompt([{
759
+ type: 'confirm',
760
+ name: 'retryFix',
761
+ message: 'Gemini found remaining issues. Redeploy anyway?',
762
+ default: false,
763
+ }]);
764
+ if (!retryFix) {
765
+ logActivity('PIPELINE', 'Deployment paused — fix remaining issues and retry.');
766
+ printActivityFooter();
767
+ return;
768
+ }
769
+ }
770
+ } else {
771
+ logActivity('GEMINI', 'Fixes validated — all errors addressed', 'success');
772
+ }
773
+ // loop → redeploy
774
+ }
775
+ }
776
+
777
+ // ─── Deploy executor ───────────────────────────────────────────────────────────
778
+
779
+ async function executeDeploy(command, label, attempt) {
780
+ // ── Spawn deploy with piped output so we capture the final JSON ──────────
781
+ const proc = spawn(`${command} --json`, [], {
782
+ cwd: getRootDir(),
783
+ shell: true,
784
+ stdio: ['ignore', 'pipe', 'pipe'],
785
+ env: { ...process.env },
786
+ });
787
+
788
+ let stdoutBuf = '';
789
+ let stderrBuf = '';
790
+ proc.stdout?.on('data', d => { stdoutBuf += d.toString(); });
791
+ proc.stderr?.on('data', d => { stderrBuf += d.toString(); });
792
+
793
+ // ── Minimal spinner — real progress comes from polling below ─────────────
794
+ const stopCycle = spinCycle('Deploy',
795
+ attempt === 1
796
+ ? ['Waiting for org response...', 'Compiling Apex...', 'Running tests...', 'Almost done...']
797
+ : ['Re-deploying...', 'Compiling Apex...', 'Running tests...', 'Checking results...'],
798
+ 6000, 'yellow'
799
+ );
800
+
801
+ // ── Poll sf project deploy report every 4s for real-time detail ──────────
802
+ // Tracks shown items so we never print the same component/test twice.
803
+ const shownComponents = new Set();
804
+ const shownTests = new Set();
805
+ let lastNumDeployed = -1;
806
+ let lastNumTests = -1;
807
+
808
+ const poll = setInterval(async () => {
809
+ try {
810
+ const r = await capture('sf project deploy report --json 2>/dev/null');
811
+ if (!r.stdout?.trim()) return;
812
+
813
+ const json = JSON.parse(r.stdout);
814
+ const res = json.result || {};
815
+ if (!res.status || res.status === 'Pending') return;
816
+
817
+ const numDeployed = res.numberComponentsDeployed ?? 0;
818
+ const numTotal = res.numberComponentsTotal ?? 0;
819
+ const numTests = res.numberTestsCompleted ?? 0;
820
+ const numTotalT = res.numberTestsTotal ?? 0;
821
+
822
+ // ── Component progress ──────────────────────────────────────────────
823
+ if (numTotal > 0 && numDeployed !== lastNumDeployed) {
824
+ lastNumDeployed = numDeployed;
825
+ const pct = Math.round((numDeployed / numTotal) * 100);
826
+ logActivity('PIPELINE', `Components ${numDeployed}/${numTotal} (${pct}%)`, 'info');
827
+
828
+ // Each newly deployed component
829
+ const successes = res.details?.componentSuccesses || [];
830
+ for (const c of successes) {
831
+ const key = `${c.componentType}:${c.fullName}`;
832
+ if (!shownComponents.has(key)) {
833
+ shownComponents.add(key);
834
+ logActivity('PIPELINE', ` ✓ ${c.componentType}:${c.fullName}`, 'success');
835
+ }
836
+ }
837
+
838
+ // Component failures — show immediately as they appear
839
+ const failures = (res.details?.componentFailures || [])
840
+ .filter(f => f.problemType === 'Error' || !f.problemType);
841
+ for (const f of failures) {
842
+ const key = `ERR:${f.fullName}:${f.lineNumber}`;
843
+ if (!shownComponents.has(key)) {
844
+ shownComponents.add(key);
845
+ logActivity('PIPELINE',
846
+ ` ✗ ${f.fullName} line ${f.lineNumber || '?'} ${(f.problem || '').slice(0, 100)}`,
847
+ 'error'
848
+ );
849
+ }
850
+ }
851
+ }
852
+
853
+ // ── Test progress ───────────────────────────────────────────────────
854
+ if (numTotalT > 0 && numTests !== lastNumTests) {
855
+ lastNumTests = numTests;
856
+ logActivity('PIPELINE', `Tests ${numTests}/${numTotalT}`, 'info');
857
+
858
+ // Each newly passed test method
859
+ const testSuccesses = res.details?.runTestResult?.successes || [];
860
+ for (const t of testSuccesses) {
861
+ const key = `TP:${t.name}.${t.methodName}`;
862
+ if (!shownTests.has(key)) {
863
+ shownTests.add(key);
864
+ logActivity('PIPELINE', ` ✓ ${t.name}.${t.methodName}`, 'success');
865
+ }
866
+ }
867
+
868
+ // Each newly failed test — show immediately
869
+ const testFailures = res.details?.runTestResult?.failures || [];
870
+ for (const t of testFailures) {
871
+ const key = `TF:${t.name}.${t.methodName}`;
872
+ if (!shownTests.has(key)) {
873
+ shownTests.add(key);
874
+ logActivity('PIPELINE',
875
+ ` ✗ ${t.name}.${t.methodName} ${(t.message || '').slice(0, 100)}`,
876
+ 'error'
877
+ );
878
+ }
879
+ }
880
+ }
881
+ } catch { /* poll failed — non-fatal, deploy still runs */ }
882
+ }, 4000);
883
+
884
+ // ── Wait for deploy process to finish ────────────────────────────────────
885
+ await new Promise((resolvePromise, reject) => {
886
+ proc.on('close', resolvePromise);
887
+ proc.on('error', reject);
888
+ });
889
+
890
+ clearInterval(poll);
891
+ stopCycle();
892
+ spinner.stop();
893
+
894
+ return parseDeployResult({ stdout: stdoutBuf.trim(), stderr: stderrBuf.trim(), code: 0 });
895
+ }
896
+
897
+ // ─── Parse sf CLI JSON output ─────────────────────────────────────────────────
898
+
899
+ function parseDeployResult(captureResult) {
900
+ // sf CLI sometimes writes JSON to stderr when the process exits non-zero
901
+ const rawJson = captureResult.stdout.trim() || captureResult.stderr.trim();
902
+
903
+ try {
904
+ const json = JSON.parse(rawJson);
905
+
906
+ // SF CLI top-level errors (e.g. ComponentSetError) have no `result` key —
907
+ // surface the message directly so the user sees a real error, not "0 errors".
908
+ if (!json.result && json.message) {
909
+ return {
910
+ success: false, status: json.name || 'Error', deployed: 0,
911
+ componentErrors: [], testFailures: [],
912
+ rawErrors: `${json.name || 'Error'}: ${json.message}`,
913
+ raw: rawJson,
914
+ };
915
+ }
916
+
917
+ const r = json.result || {};
918
+
919
+ // componentFailures: all types (Error + Warning) — filter for actionable errors only
920
+ const comp = r.details?.componentFailures || [];
921
+ const errs = comp.filter(f => f.problemType === 'Error' || !f.problemType);
922
+
923
+ // Test failures can live at two paths depending on SF CLI version
924
+ const testResult = r.details?.runTestResult || r.runTestResult || {};
925
+ const tests = testResult.failures || testResult.testFailures || [];
926
+
927
+ const succeeded = r.status === 'Succeeded' || r.success === true;
928
+
929
+ // Build a plain-text summary of all failures for Gemini when structured arrays are empty
930
+ let rawErrors = '';
931
+ if (!succeeded && errs.length === 0 && tests.length === 0) {
932
+ // Collect any failure info from the JSON we do have
933
+ const msgs = [];
934
+ if (r.status) msgs.push(`Deploy status: ${r.status}`);
935
+ if (r.errorMessage) msgs.push(`Error: ${r.errorMessage}`);
936
+ if (r.errorStatusCode) msgs.push(`Code: ${r.errorStatusCode}`);
937
+ if (comp.length > 0) {
938
+ msgs.push(`Component issues (${comp.length}): ${comp.map(c => `${c.fullName}: ${c.problem}`).join('; ')}`);
939
+ }
940
+ // Strip known non-error lines (CLI update notices, blank lines) from stderr fallback
941
+ const cleanStderr = (captureResult.stderr || '')
942
+ .split('\n')
943
+ .filter(l => !/Warning:.*update available/i.test(l) && l.trim() !== '')
944
+ .join('\n')
945
+ .trim();
946
+ rawErrors = msgs.join('\n') || cleanStderr.slice(0, 800);
947
+ }
948
+
949
+ return {
950
+ success: succeeded && errs.length === 0 && tests.length === 0,
951
+ status: r.status || 'Unknown',
952
+ deployed: (r.details?.componentSuccesses || []).length,
953
+ componentErrors: errs,
954
+ testFailures: tests,
955
+ rawErrors,
956
+ raw: rawJson,
957
+ };
958
+ } catch {
959
+ const combined = (captureResult.stdout + '\n' + captureResult.stderr).trim();
960
+ return {
961
+ success: false, status: 'Parse Error', deployed: 0,
962
+ componentErrors: [], testFailures: [],
963
+ rawErrors: combined.slice(0, 800),
964
+ parseError: combined.slice(0, 800), raw: combined,
965
+ };
966
+ }
967
+ }
968
+
969
+ // ─── Gemini: analyze deploy errors ────────────────────────────────────────────
970
+
971
+ async function geminiAnalyzeErrors(deployResult, command, attempt) {
972
+ const geminiAvailable = await isAvailable('gemini');
973
+ const root = getRootDir();
974
+ const workflowDir = getWorkflowDir();
975
+ const geminiMd = resolve(root, 'GEMINI.md');
976
+
977
+ if (!geminiAvailable || !existsSync(geminiMd)) {
978
+ logActivity('GEMINI', 'CLI not available — copy errors manually to Copilot.', 'warn');
979
+ return null;
980
+ }
981
+
982
+ logActivity('GEMINI', 'Analyzing deployment errors...');
983
+
984
+ const errorsText = formatErrorsForGemini(deployResult);
985
+
986
+ // ── Attach real object field lists for any "field does not exist" errors ──────
987
+ // This prevents Gemini from guessing field names — it gets the ground truth.
988
+ const objectFieldContext = buildObjectFieldContext(deployResult, root);
989
+
990
+ const context = `${readFileSync(geminiMd, 'utf8')}
991
+
992
+ ## Task
993
+ Analyze these Salesforce deployment errors and write specific fix instructions for GitHub Copilot.
994
+
995
+ ## Deploy Command
996
+ \`${command}\`
997
+
998
+ ## Deployment Errors (Attempt ${attempt})
999
+ ${errorsText}
1000
+ ${objectFieldContext}
1001
+ ## Required Output
1002
+ 1. **Root Cause Analysis** — are multiple errors from one cause?
1003
+ 2. **Fix Instructions** — numbered list with exact file path, line number, what to change and why. Use ONLY field names confirmed in the "Actual Fields" section above — do NOT guess or invent field names.
1004
+ 3. **Verification Steps** — how Copilot confirms each fix is correct
1005
+ 4. **Priority Order** — fix compile errors before test failures`;
1006
+
1007
+ const timestamp = Date.now();
1008
+ const fixesDir = resolve(workflowDir, 'deploy-fixes');
1009
+ if (!existsSync(fixesDir)) mkdirSync(fixesDir, { recursive: true });
1010
+
1011
+ const fixFile = resolve(fixesDir, `DEPLOYFIX-${timestamp}.md`);
1012
+ const prompt = context +
1013
+ '\n\nAnalyze these Salesforce deployment errors and write a structured fix guide for GitHub Copilot. ' +
1014
+ 'Start your response directly with "## Root Cause Analysis" — do NOT include any planning steps, ' +
1015
+ '"I will..." sentences, or meta-commentary. Output only the structured fix guide.';
1016
+
1017
+ const stopCycle = spinCycle('Gemini', [
1018
+ 'Reading deploy errors...',
1019
+ 'Identifying root causes...',
1020
+ 'Writing fix instructions for Copilot...',
1021
+ ], 4000, 'cyan');
1022
+
1023
+ await runGemini(prompt, fixFile);
1024
+
1025
+ stopCycle();
1026
+
1027
+ // Strip any Gemini planning monologue ("I will..." lines) from the top of the file.
1028
+ // These appear when Gemini outputs its thinking before the actual analysis.
1029
+ if (existsSync(fixFile)) {
1030
+ const raw = readFileSync(fixFile, 'utf8');
1031
+ const lines = raw.split('\n');
1032
+ // Find the first markdown heading (##) — that's where the real content starts
1033
+ const firstHeading = lines.findIndex(l => /^#{1,3}\s/.test(l.trim()));
1034
+ if (firstHeading > 0) {
1035
+ writeFileSync(fixFile, lines.slice(firstHeading).join('\n'), 'utf8');
1036
+ }
1037
+ }
1038
+
1039
+ spinner.succeed(`Fix guide written → deploy-fixes/DEPLOYFIX-${timestamp}.md`);
1040
+ logActivity('GEMINI', `.ai-workflow/deploy-fixes/DEPLOYFIX-${timestamp}.md`, 'success');
1041
+
1042
+ return fixFile;
1043
+ }
1044
+
1045
+ // ─── Gemini: validate fixes ────────────────────────────────────────────────────
1046
+
1047
+ async function geminiValidateFixes(originalErrors, fixFile, attempt) {
1048
+ const geminiAvailable = await isAvailable('gemini');
1049
+ const root = getRootDir();
1050
+ const workflowDir = getWorkflowDir();
1051
+ const geminiMd = resolve(root, 'GEMINI.md');
1052
+
1053
+ if (!geminiAvailable || !existsSync(geminiMd)) return { approved: true, verdict: 'SKIPPED', issues: 0 };
1054
+
1055
+ logActivity('GEMINI', 'Validating Copilot fixes against original errors...');
1056
+
1057
+ const diffResult = await capture('git diff HEAD 2>/dev/null || git diff --cached 2>/dev/null', [], { cwd: root });
1058
+ const diff = diffResult.stdout || 'No git diff available';
1059
+
1060
+ const context = `${readFileSync(geminiMd, 'utf8')}
1061
+
1062
+ ## Task
1063
+ Validate that the code changes fix the Salesforce deployment errors listed below.
1064
+
1065
+ ## Original Errors
1066
+ ${formatErrorsForGemini(originalErrors)}
1067
+
1068
+ ## Fix Guide (Gemini's instructions to Copilot)
1069
+ ${existsSync(fixFile) ? readFileSync(fixFile, 'utf8') : 'Not available'}
1070
+
1071
+ ## Code Changes (git diff)
1072
+ \`\`\`diff
1073
+ ${diff}
1074
+ \`\`\`
1075
+
1076
+ ## Required Output
1077
+ - **Verdict**: VALIDATED or NEEDS_MORE_WORK
1078
+ - **Summary**: one sentence
1079
+ - **Remaining Issues**: bullet list (empty if none)`;
1080
+
1081
+ const timestamp = Date.now();
1082
+ const validFile = resolve(workflowDir, 'deploy-fixes', `VALIDATE-${timestamp}.md`);
1083
+ const valPrompt = context +
1084
+ '\n\nValidate these code changes fix the listed Salesforce deploy errors. Output: VALIDATED or NEEDS_MORE_WORK with remaining issues.';
1085
+
1086
+ const stopCycle = spinCycle('Gemini', [
1087
+ 'Reviewing Copilot changes...',
1088
+ 'Checking each error is addressed...',
1089
+ 'Verifying no regressions...',
1090
+ ], 4000, 'cyan');
1091
+
1092
+ await runGemini(valPrompt, validFile);
1093
+
1094
+ stopCycle();
1095
+
1096
+ const content = existsSync(validFile) ? readFileSync(validFile, 'utf8') : '';
1097
+ const approved = /\bVALIDATED\b/i.test(content) && !/NEEDS_MORE_WORK/i.test(content);
1098
+ const issues = (content.match(/^[-*]\s.+/mg) || []).length;
1099
+
1100
+ if (approved) spinner.succeed('Fixes validated — all errors addressed');
1101
+ else spinner.warn(`Gemini found ${issues} remaining issue(s)`);
1102
+
1103
+ logActivity('GEMINI', `Validation: ${approved ? 'VALIDATED' : 'NEEDS_MORE_WORK'} · ${issues} remaining`, approved ? 'success' : 'warn');
1104
+ return { approved, verdict: approved ? 'VALIDATED' : 'NEEDS_MORE_WORK', issues };
1105
+ }
1106
+
1107
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
1108
+
1109
+ function formatErrorsForGemini(deployResult) {
1110
+ const lines = [];
1111
+ if (deployResult.componentErrors.length) {
1112
+ lines.push(`### Compile Errors (${deployResult.componentErrors.length})`);
1113
+ deployResult.componentErrors.forEach((e, i) => {
1114
+ lines.push(`${i + 1}. **File:** \`${e.fileName || e.fullName}\`${e.lineNumber ? ` line ${e.lineNumber}` : ''}`);
1115
+ lines.push(` **Error:** ${e.problem}`);
1116
+ lines.push('');
1117
+ });
1118
+ }
1119
+ if (deployResult.testFailures.length) {
1120
+ lines.push(`### Test Failures (${deployResult.testFailures.length})`);
1121
+ deployResult.testFailures.forEach((f, i) => {
1122
+ lines.push(`${i + 1}. **Test:** \`${f.name}.${f.methodName}\``);
1123
+ lines.push(` **Message:** ${f.message || ''}`);
1124
+ if (f.stackTrace) lines.push(` **Stack:** ${f.stackTrace.split('\n')[0]}`);
1125
+ lines.push('');
1126
+ });
1127
+ }
1128
+ // Fallback: when SF CLI doesn't return structured errors, include raw output
1129
+ if (lines.length === 0 && deployResult.rawErrors) {
1130
+ lines.push('### Raw Deploy Output (structured errors unavailable)');
1131
+ lines.push(deployResult.rawErrors);
1132
+ }
1133
+ return lines.join('\n') || 'No errors captured — deploy may have failed due to org connectivity or auth.';
1134
+ }
1135
+
1136
+ /**
1137
+ * Format deploy errors as a compact, line-by-line list for Copilot.
1138
+ * This is the GROUND TRUTH — every error with exact file + line number.
1139
+ * Copilot must fix ALL of these, not just the ones Gemini mentioned in its guide.
1140
+ */
1141
+ function formatErrorsForCopilot(deployResult) {
1142
+ const lines = [];
1143
+ if (deployResult.componentErrors.length) {
1144
+ lines.push(`Compile Errors (${deployResult.componentErrors.length} — fix ALL of these):`);
1145
+ deployResult.componentErrors.forEach((e, i) => {
1146
+ const loc = e.lineNumber ? `:${e.lineNumber}` : '';
1147
+ lines.push(` ${i + 1}. ${e.fileName || e.fullName}${loc} → ${e.problem}`);
1148
+ });
1149
+ }
1150
+ if (deployResult.testFailures.length) {
1151
+ lines.push('');
1152
+ lines.push(`Test Failures (${deployResult.testFailures.length} — fix ALL of these):`);
1153
+ deployResult.testFailures.forEach((f, i) => {
1154
+ lines.push(` ${i + 1}. ${f.name}.${f.methodName} → ${f.message || '(no message)'}`);
1155
+ if (f.stackTrace) lines.push(` Stack: ${f.stackTrace.split('\n')[0]}`);
1156
+ });
1157
+ }
1158
+ if (lines.length === 0 && deployResult.rawErrors) {
1159
+ lines.push('Raw errors (structured parse failed):');
1160
+ lines.push(deployResult.rawErrors.slice(0, 3000));
1161
+ }
1162
+ return lines.join('\n') || 'No structured errors — see fix guide for details.';
1163
+ }
1164
+
1165
+ /**
1166
+ * For every "field does not exist on <Object>" or "variable does not exist" error,
1167
+ * look up the actual field names from local object metadata and include them in
1168
+ * the Gemini context — so Gemini gives Copilot CORRECT field names, not guesses.
1169
+ */
1170
+ function buildObjectFieldContext(deployResult, root) {
1171
+ const objectsDir = resolve(root, 'force-app/main/default/objects');
1172
+ if (!existsSync(objectsDir)) return '';
1173
+
1174
+ // Extract object names mentioned in errors (e.g. "on XConn__XConnection__c")
1175
+ const allErrors = [
1176
+ ...deployResult.componentErrors.map(e => e.problem || ''),
1177
+ ...deployResult.testFailures.map(f => f.message || ''),
1178
+ deployResult.rawErrors || '',
1179
+ ].join('\n');
1180
+
1181
+ // Match patterns like "on XConnection__c", "on XConn__XConnection__c", "does not exist: SomeField__c on ObjName__c"
1182
+ const objectMatches = new Set();
1183
+ for (const m of allErrors.matchAll(/\bon\s+([\w]+__c)\b/gi)) objectMatches.add(m[1]);
1184
+ // Also strip namespace prefix so we can find the local folder
1185
+ for (const m of allErrors.matchAll(/\bon\s+\w+__([\w]+__c)\b/gi)) objectMatches.add(m[1]);
1186
+
1187
+ if (objectMatches.size === 0) return '';
1188
+
1189
+ const lines = ['\n## Actual Object Fields (ground truth — use ONLY these names)'];
1190
+
1191
+ for (const objName of objectMatches) {
1192
+ // Try both namespaced and non-namespaced folder names
1193
+ const candidates = [objName, `XConn__${objName}`];
1194
+ for (const folder of candidates) {
1195
+ const fieldsDir = resolve(objectsDir, folder, 'fields');
1196
+ if (!existsSync(fieldsDir)) continue;
1197
+ try {
1198
+ const fieldFiles = readdirSync(fieldsDir).filter(f => f.endsWith('.field-meta.xml'));
1199
+ const fieldNames = fieldFiles.map(f => f.replace('.field-meta.xml', ''));
1200
+ lines.push(`\n### ${folder} — valid field API names`);
1201
+ lines.push(fieldNames.map(f => `- ${f}`).join('\n'));
1202
+ } catch { /* skip if unreadable */ }
1203
+ break;
1204
+ }
1205
+ }
1206
+
1207
+ return lines.length > 1 ? lines.join('\n') + '\n' : '';
1208
+ }
1209
+
1210
+ function showStatusReport(captureResult) {
1211
+ try {
1212
+ const json = JSON.parse(captureResult.stdout);
1213
+ const r = json.result || {};
1214
+ const status = r.status || r.deploymentStatus || 'Unknown';
1215
+ const color = status === 'Succeeded' ? 'green' : 'red';
1216
+ const bar = chalk.dim(' ┃ ');
1217
+ console.log('');
1218
+ console.log(chalk[color].bold(` ┃ Last Deploy: ${status}`));
1219
+ console.log(chalk.dim(' ┃'));
1220
+ if (r.id) console.log(`${bar}${chalk.dim('ID')} ${chalk.white(r.id)}`);
1221
+ const succ = r.details?.componentSuccesses?.length ?? 0;
1222
+ const fail = r.details?.componentFailures?.length ?? 0;
1223
+ if (succ || fail) console.log(`${bar}${chalk.green(`${succ} succeeded`)} ${chalk.dim('·')} ${chalk.red(`${fail} failed`)}`);
1224
+ console.log('');
1225
+ } catch {
1226
+ const out = captureResult.stdout || captureResult.stderr;
1227
+ console.log(chalk.dim('\n' + out.slice(0, 500) + '\n'));
1228
+ }
1229
+ }